Skip to content

feat(enforce): seccomp user-notify enforcement tier (E4)#103

Merged
gnanirahulnutakki merged 2 commits into
devfrom
feat/epicA-e4-seccomp
Jul 3, 2026
Merged

feat(enforce): seccomp user-notify enforcement tier (E4)#103
gnanirahulnutakki merged 2 commits into
devfrom
feat/epicA-e4-seccomp

Conversation

@gnanirahulnutakki

Copy link
Copy Markdown
Member

Summary

Implements E4: the seccomp user-notify enforcement tier, the common-case fallback for hosts where BPF-LSM never loads — stock distros ship CONFIG_BPF_LSM=y but don't put bpf in the boot lsm= list, so the BPF-LSM tier (#101, merged) silently isn't active. Part of Epic A (#63). Builds on the merged E1 (#101, privileged CI), E2 (#96, apply_policy wiring), and E3 (#100, enforce_events correlation hardening).

  • ardur-exec-shim (new binary): installs a connect(2)-scoped SECCOMP_RET_USER_NOTIF filter, hands the notification listener fd to the daemon over a dedicated Unix socket via SCM_RIGHTS, then execve()s into the target — the filter (installed before exec, inherited across it) covers the whole process tree from there with no per-child re-registration.
  • Daemon supervisor (daemon_seccomp_linux.go): services notifications using the same policy tables and net_allow CIDR matching as the BPF-LSM tier (SeccompPolicyStore mirrors bpf_policy_apply.go's schema), re-validates the notification id via SECCOMP_IOCTL_NOTIF_ID_VALID both before and after each /proc/pid/mem read (the standard seccomp-notify TOCTOU mitigation), and fails closed (EPERM) on any ambiguous or error case — SECCOMP_USER_NOTIF_FLAG_CONTINUE is only ever set on a confirmed, policy-resolved ALLOW.
  • Tier selection: the daemon attempts BPF-LSM at startup; if it doesn't load, it falls back to seccomp and advertises the active tier (bpf_lsm / seccomp / none) on health responses, so a launcher knows whether routing through ardur-exec-shim is necessary. apply_policy keeps the seccomp policy store in sync regardless of which tier is active, and reports genuine success (applied_seccomp_tier, not degraded) when the active tier fully covers the request.
  • Evidence pipeline: processEnforceEvent now takes an explicit tier parameter (previously derived internally, always bpf_lsm:*) so seccomp-tier events are correctly labeled seccomp:enforce/seccomp:permissive in TierCoverage, sharing the same hash-chained receipt format.
  • Claim boundary, stated plainly, not left implicit: seccomp user-notify is weaker than an in-kernel LSM against a racing multithreaded adversary — the supervisor's decision happens in a userspace round-trip, not synchronously in the syscalling thread. This is a real, load-bearing enforcement mechanism for single-threaded or cooperative targets (the overwhelming majority of AI-agent subprocess trees this project governs), documented in seccomp_policy.go's header comment, not a theoretically-airtight guarantee against an adversarial multithreaded target.

Two real bugs found only by kernel-in-loop verification

Neither pure-Go tests nor go vet could catch these — both were found by actually running the shim + daemon against a real kernel:

  1. Shim self-deadlock: the shim's own connect() to the daemon's handoff socket was trapped by the filter it had just installed (seccomp intercepts by syscall number only, with no socket-family awareness) — since nothing was servicing that listener yet, the connect blocked forever. Fixed by dialing the handoff connection before installing the filter (see ardur-exec-shim/main.go's run() comment).
  2. errno sign bug: SendSeccompNotifResp wrote a positive errno (e.g. unix.EPERM = +1) into the kernel's seccomp_notif_resp.error field, which requires the raw syscall-return convention (-errno). A non-negative raw return is treated as success by the kernel — so a policy-denied connect() was silently let through with a nonsense return value instead of being blocked. Fixed by negating internally in buildSeccompNotifResp, with a regression test (TestBuildSeccompNotifResp_NegatesErrno) and a permanent kernel-in-loop smoke test (see below) guarding it going forward.

Testing

  • Pure-Go / cross-platform: seccomp_sockaddr_test.go, seccomp_policy_test.go — sockaddr decoding, policy store semantics (ALLOW/DENY/ALLOWLIST, CIDR + bare-IP matching, enforce vs permissive, fail-closed on unknown action, nil-store handling), tier-refactor coverage in daemon_enforce_test.go/daemon_apply_policy_test.go.
  • Linux-only, no kernel required: seccomp_notify_linux_test.go — ioctl number pinning against known runc/containerd values, sock_fprog kernel-ABI layout, and a small classic-BPF interpreter that runs the actual assembled filter program against synthetic seccomp_data to prove its three-way decision logic (arch mismatch → kill, matching arch + wrong nr → allow, matching arch + connect's nr → user-notify).
  • Real kernel, both platforms confirmed green: go build/go vet/go test -race ./... pass on darwin and Linux (verified in a Docker container matching an unprivileged CI runner — no --privileged, no extra capabilities, CONFIG_BPF_LSM present but bpf not in the active LSM list, exactly the scenario this tier targets).
  • New CI job (seccomp-smoke in kernel-enforce.yml): runs ardur-seccomp-smoke, a permanent kernel-in-loop harness that starts a real daemon (BPF-LSM unavailable → seccomp fallback engages), runs the real shim against a real target process, and asserts a policy-denied connect(2) gets EPERM while a policy-allowed one reaches the kernel's real connect handling (observed as ECONNREFUSED, proving the syscall wasn't blocked). Unlike kernel-smoke (BPF-LSM tier), this needs no KVM/virtme-ng custom kernel boot — confirmed empirically that seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, ...) works under an ordinary PR_SET_NO_NEW_PRIVS-only process, so it runs on a plain ubuntu-24.04 runner. Set as a required check (not soft-gated like kernel-smoke) since it needs no unproven VM-boot machinery.

Honest gaps / what's not in this PR

  • The CI job itself has not yet run on an actual GitHub-hosted runner — it's manually verified end-to-end in a local Docker container configured to match those conditions (unprivileged, no BPF-LSM), but the first real CI run on this PR is the true confirmation that GitHub's runner environment behaves the same way.
  • Exec/file-open enforcement is out of scope for this tier by design (documented in seccomp_policy.go) — a seccomp filter can trap execve, but by the time a SECCOMP_RET_USER_NOTIF notification is serviced the new program image hasn't loaded yet, and SECCOMP_USER_NOTIF_FLAG_CONTINUE for execve carries TOCTOU hazards beyond what connect's fixed-size sockaddr argument has. BPF-LSM remains the only tier that governs exec and file ops.
  • No ardur run / launcher-side wiring to actually invoke ardur-exec-shim based on the daemon's advertised tier — that's launcher-side follow-on work, not part of this daemon/shim-side PR.

Ref: #63

gnanirahulnutakki and others added 2 commits July 2, 2026 17:55
Implements the seccomp user-notify fallback for hosts where BPF-LSM
never loads (stock distros that ship CONFIG_BPF_LSM=y but don't put
"bpf" in the boot lsm= list). ardur-exec-shim installs a connect(2)-
scoped SECCOMP_RET_USER_NOTIF filter and hands the listener fd to
ardur-kernelcaptured over a dedicated Unix socket via SCM_RIGHTS; the
daemon supervises it using the same policy tables and net_allow
matching as the BPF-LSM tier, with TOCTOU-safe re-validation around
each /proc/pid/mem read and fail-closed-on-ambiguity semantics.

The daemon now decides at startup whether BPF-LSM actually loads and
falls back to seccomp when it doesn't, advertising the active tier on
health responses so a launcher knows whether ardur-exec-shim is
needed. apply_policy keeps the seccomp policy store in sync
regardless of which tier is active, and reports genuine success (not
degraded) when the active tier fully covers the request.

Real-kernel verification (go/cmd/ardur-seccomp-smoke, wired into CI)
caught two bugs no pure-Go test could: the shim's own connect() to the
daemon's handoff socket deadlocking against its own just-installed
filter, and a positive-vs-negative errno sign bug in the kernel
response struct that silently let denied connects through.

Claim boundary: seccomp user-notify is weaker than an in-kernel LSM
against a racing multithreaded adversary — documented in
seccomp_policy.go rather than left implicit.
GitHub-hosted runners (and Docker Desktop's kernel) boot with "bpf" in the
active LSM list, so the daemon's tier selection preferred BPF-LSM and the
seccomp-smoke job — which exercises the seccomp user-notify path specifically —
asserted tier==seccomp and failed on every real CI run.

Add a --disable-bpf-lsm daemon flag that skips the BPF-LSM guard tier (exec/exit
observation still runs) and forces the seccomp fallback, and pass it from
ardur-seccomp-smoke so the seccomp path is genuinely exercised regardless of the
runner kernel's BPF-LSM availability. Verified on a real BPF-LSM kernel: denied
connect() -> EPERM, allowed connect() -> reaches kernel (ECONNREFUSED).
@gnanirahulnutakki gnanirahulnutakki marked this pull request as ready for review July 3, 2026 00:14
@gnanirahulnutakki gnanirahulnutakki merged commit eb2a7a2 into dev Jul 3, 2026
24 checks passed
@gnanirahulnutakki gnanirahulnutakki deleted the feat/epicA-e4-seccomp branch July 3, 2026 00:14
gnanirahulnutakki added a commit that referenced this pull request Jul 3, 2026
…dence verifier (#113)

* docs(demo): ardur run --enforce BPF-LSM end-to-end demo + offline verifier

Adds a reproducible, privileged-Linux demo that drives the whole Epic A
enforcement stack together (detect + apply_policy + BPF-LSM EPERM + attest)
and proves the cycle end to end:

- docs/demo/enforce-e2e.md — exact build/run commands, expected output, an
  enforce-vs-permissive A/B, the STRICT/file_open-vs-bprm mechanism, and
  offline verification. Validated live on Docker Desktop's LinuxKit kernel
  (lsm=capability,bpf,landlock): a governed agent's execve is refused with
  EPERM under --enforce and logged-but-allowed under permissive.
- docs/demo/enforce-e2e/{Dockerfile,agent.py,run.sh} — build daemon +
  verifier from source, run the flow, verify the evidence.
- go/cmd/enforce-verify — offline evidence verifier over enforce_events.jsonl
  using the shipped kernelcapture.VerifyEnforceReceiptChain (no kernel, daemon,
  or root). Confirms hash-chain integrity and that a session attestation's
  kernel_enforcement.chain_digest commits to the log head. With tests.

* docs(site): regenerate source-backed Hugo mirror for enforce-e2e demo

Runs site/scripts/sync_source_docs.py to mirror the new docs/demo/ pages into
site/content/source/. Also fills a pre-existing gap: the kernel-enforce.yml
workflow (added in #101) was never mirrored to site/static/repo/ because no
docs/site-touching PR had re-run the sync check since.

* docs(site): regenerate source mirror for consolidated dev (#102/#103/#112)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant