From 08da4f4a39d991994d4e8eb65016dbda17e3ef59 Mon Sep 17 00:00:00 2001 From: Gnani Rahul <89947795+gnanirahulnutakki@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:41:57 -0500 Subject: [PATCH 1/2] fix(enforce): wire ardur run to actually invoke the seccomp shim (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the false-success bug: on a seccomp-only host (the majority — no "bpf" in the boot lsm= list), apply_policy could report "applied_seccomp_tier" while run_bridge never invoked ardur-exec-shim or checked the daemon's advertised tier, so nothing actually wrapped the agent. ardur run now detects the active tier before spawning the agent and, when it's seccomp, launches through ardur-exec-shim; under --enforce, if the tier can't actually be wired (shim missing, or its listener never attaches — confirmed via a new session_status field), the run aborts loudly instead of reporting success. Two more bugs surfaced only by running the real shim through the real CLI, not by any unit test: a startup race where the shim's one-shot daemon handoff could lose to register_session (fixed with a file-based readiness handshake — a daemon round-trip doesn't work here since session_status enforces exact-PID peer ownership, and a signal-based handshake risks killing the shim if it arrives before the handler is installed); and every mission's default cwd-based file resource_scope making a mission un-enforceable on the seccomp tier regardless of its network scope, since that tier only ever covers OP_NET_CONNECT (fixed with an opt-in --no-resource-scope flag for network-only missions). Adds a real ardur run --enforce full-flow e2e for both tiers: a new CI job builds the enforce-e2e demo image and runs it against a seccomp-only host (--disable-bpf-lsm), and the existing BPF-LSM virtme-ng smoke test now also runs the full CLI flow, not just the piecewise guard-smoke tool. Both assert the denied syscall actually fails and the hash-chained receipt + attestation verify offline — the exact test class that would have caught this. --- .github/workflows/kernel-enforce.yml | 144 ++++++- docs/demo/enforce-e2e/Dockerfile | 21 +- docs/demo/enforce-e2e/agent_seccomp.py | 44 ++ docs/demo/enforce-e2e/ci-vng-enforce.sh | 7 + docs/demo/enforce-e2e/run-seccomp.sh | 91 ++++ go/cmd/ardur-exec-shim/main.go | 70 +++- .../daemon_enforce_test.go | 126 ++++++ go/cmd/ardur-kernelcaptured/main.go | 17 + go/pkg/kernelcapture/daemon_protocol.go | 54 ++- python/tests/test_run_bridge.py | 387 ++++++++++++++++++ python/vibap/cli.py | 9 + python/vibap/kernel_correlation.py | 57 +++ python/vibap/run_bridge.py | 223 +++++++++- 13 files changed, 1215 insertions(+), 35 deletions(-) create mode 100755 docs/demo/enforce-e2e/agent_seccomp.py create mode 100755 docs/demo/enforce-e2e/ci-vng-enforce.sh create mode 100755 docs/demo/enforce-e2e/run-seccomp.sh diff --git a/.github/workflows/kernel-enforce.yml b/.github/workflows/kernel-enforce.yml index d1fd25f8..d755051a 100644 --- a/.github/workflows/kernel-enforce.yml +++ b/.github/workflows/kernel-enforce.yml @@ -28,7 +28,10 @@ name: kernel-enforce # other hooks use, actually enforces path_allow via cgroup_file_allow # instead of failing every allowlisted file op closed. This is the one # thing bpf-generate cannot prove: that the compiled program actually -# enforces on a live kernel, not just that it loads. +# enforces on a live kernel, not just that it loads. In the same VM boot, +# also runs the full `ardur run --enforce` CLI against a real agent +# (docs/demo/enforce-e2e/run.sh) — issue #104's "no ardur run full-flow +# e2e in CI" gap, BPF-LSM half. # # seccomp-smoke — the seccomp user-notify tier's equivalent proof (plan # E4, the common-case fallback for hosts where BPF-LSM never loads). @@ -44,11 +47,28 @@ name: kernel-enforce # surfaced (see ardur-seccomp-smoke's package doc comment) — losing it # would mean losing the only thing that can catch a regression in either. # +# ardur-run-e2e-seccomp — issue #104's "no ardur run full-flow e2e in CI" +# gap, seccomp half: builds the docs/demo/enforce-e2e Docker image (real +# ardur-kernelcaptured + ardur-exec-shim + the ardur CLI with +# biscuit-python) and runs run-seccomp.sh in both enforce and permissive +# mode, asserting the real `ardur run --enforce` CLI actually routes the +# agent through ardur-exec-shim on a seccomp-only host +# (--disable-bpf-lsm), a policy-denied connect(2) gets EPERM, the +# hash-chained enforce_events receipt reflects the denial, and the +# attestation commits to it — verified offline via enforce-verify. This +# is the exact test class whose absence let issue #104 (apply_policy +# reporting success while nothing actually wrapped the agent) go +# unnoticed: piecewise ardur-seccomp-smoke drives the shim directly and +# would never have caught run_bridge.py failing to invoke it at all. +# # kernel-smoke starts as continue-on-error: true — promote it to a required # check once a burn-in period confirms the virtme-ng invocation and kernel -# cmdline handling are stable on GitHub-hosted runners. seccomp-smoke needs -# no such VM and has been stable since it was added, so it's a required -# check from the start. +# cmdline handling are stable on GitHub-hosted runners (its new `ardur run` +# full-flow step is unverified against live CI as of this writing — see that +# step's own comment). seccomp-smoke and ardur-run-e2e-seccomp need no such +# VM and have been verified directly (the latter by hand, reproducing the +# exact commands this job runs, against a real kernel before this workflow +# job existed), so they are required checks from the start. # # This workflow is also the gate for the cilium/ebpf dependency: any future # version bump that touches go/go.mod must pass bpf-generate (compiles @@ -63,8 +83,14 @@ on: - "go/cmd/ardur-guard-smoke/**" - "go/cmd/ardur-exec-shim/**" - "go/cmd/ardur-seccomp-smoke/**" + - "go/cmd/enforce-verify/**" - "go/go.mod" - "go/go.sum" + - "python/vibap/run_bridge.py" + - "python/vibap/kernel_correlation.py" + - "python/vibap/bpf_lower.py" + - "python/vibap/bpf_types.py" + - "docs/demo/enforce-e2e/**" - ".github/workflows/kernel-enforce.yml" pull_request: branches: [main, dev] @@ -74,8 +100,14 @@ on: - "go/cmd/ardur-guard-smoke/**" - "go/cmd/ardur-exec-shim/**" - "go/cmd/ardur-seccomp-smoke/**" + - "go/cmd/enforce-verify/**" - "go/go.mod" - "go/go.sum" + - "python/vibap/run_bridge.py" + - "python/vibap/kernel_correlation.py" + - "python/vibap/bpf_lower.py" + - "python/vibap/bpf_types.py" + - "docs/demo/enforce-e2e/**" - ".github/workflows/kernel-enforce.yml" workflow_dispatch: @@ -190,6 +222,38 @@ jobs: working-directory: go run: go build -o /tmp/ardur-guard-smoke ./cmd/ardur-guard-smoke + # Also build + install what the full `ardur run --enforce` BPF-LSM + # demo (docs/demo/enforce-e2e/run.sh) needs, so the vng boot below can + # run it directly instead of just the piecewise ardur-guard-smoke — + # this is issue #104's "no ardur run full-flow e2e in CI" gap, closed + # for the BPF-LSM tier the same way seccomp-smoke's sibling job closes + # it for the seccomp tier. virtme-ng's guest shares the runner's own + # root filesystem (a different kernel over the same userspace, not a + # separate image) — confirmed by ardur-guard-smoke above already being + # visible to vng's --exec without copying it into any guest-specific + # location — so anything built or installed here on the runner, before + # the boot step, is exactly what the guest sees. Installed as root with + # the system Python (not actions/setup-python, and not --user) for the + # same reason the virtme-ng install above is: `sudo vng` only sees + # root's own Python path, and sudo does not inherit a non-root PATH + # that actions/setup-python would have modified. + - name: Install the ardur CLI + biscuit-python + run: | + sudo python3 -m pip install --break-system-packages --no-cache-dir -e python + sudo python3 -m pip install --break-system-packages --no-cache-dir biscuit-python + sudo ardur --version || true + + - name: Build ardur-kernelcaptured and enforce-verify + working-directory: go + run: | + go build -o /tmp/ardur-kernelcaptured ./cmd/ardur-kernelcaptured + go build -o /tmp/enforce-verify ./cmd/enforce-verify + # /usr/local/bin (root-owned) so it's on root's PATH for the sudo + # vng invocation below and for run.sh's own PATH-based lookup — + # the same reason ardur (installed as root above) needs to be + # there rather than left under the runner user's own PATH. + sudo cp /tmp/ardur-kernelcaptured /tmp/enforce-verify /usr/local/bin/ + # `vng --run` (bare, no argument) boots "the same kernel running on the # host" per `vng --help`'s Action section — this is "the runner kernel" # per the task, not a custom build; a bare `vng` (no --run at all) @@ -224,6 +288,28 @@ jobs: --append "lsm=bpf" \ --exec /tmp/ardur-guard-smoke + # issue #104's "no ardur run full-flow e2e in CI" gap, BPF-LSM half: + # the same boot, but --exec docs/demo/enforce-e2e/run.sh (via the + # ci-vng-enforce.sh wrapper, since --exec takes no arguments of its + # own) instead of the piecewise ardur-guard-smoke — the real `ardur + # run --enforce` CLI, a real agent process, a real forbidden execve + # refused by the kernel, and offline hash-chain + attestation + # verification, exactly as verified manually in + # docs/demo/enforce-e2e.md (that manual Docker-based verification is + # what proved this sequence works at all — this step is its first run + # against actual GitHub-hosted virtme-ng, unverified until this + # workflow actually runs in CI, the same position kernel-smoke itself + # started from before its own ~6-round-trip burn-in). + - name: Boot with BPF-LSM active and run the full ardur run --enforce demo + run: | + VNG="$(command -v vng)" + test -x "$VNG" || { echo "::error::vng not found on PATH after pip install"; exit 1; } + sudo "$VNG" \ + --verbose \ + --run \ + --append "lsm=bpf" \ + --exec "$PWD/docs/demo/enforce-e2e/ci-vng-enforce.sh" + seccomp-smoke: name: seccomp-smoke needs: bpf-generate @@ -274,3 +360,53 @@ jobs: sudo /tmp/ardur-seccomp-smoke \ --daemon-bin /tmp/ardur-kernelcaptured \ --shim-bin /tmp/ardur-exec-shim + + ardur-run-e2e-seccomp: + name: ardur-run-e2e-seccomp + needs: bpf-generate + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # docs/demo/enforce-e2e/Dockerfile builds ardur-kernelcaptured, + # ardur-exec-shim, and enforce-verify from source, and installs the + # ardur CLI + biscuit-python — everything run-seccomp.sh needs. + - name: Build the enforce-e2e demo image + run: docker build -f docs/demo/enforce-e2e/Dockerfile -t ardur-enforce-demo . + + # --privileged --pid=host matches docs/demo/enforce-e2e.md's + # documented invocation: privileged for cgroup v2 + the seccomp + # install, --pid=host so the daemon's exec/exit correlation sees host + # PIDs. -disable-bpf-lsm forces the seccomp fallback tier regardless of + # what this runner's kernel would otherwise pick (GitHub-hosted + # runners may or may not have "bpf" in their active lsm= list; this + # job exists specifically to prove the seccomp tier, so it does not + # rely on that being one way or the other). + # + # Both modes run: enforce must show DENIED_EPERM and a hash-chain that + # verifies offline; permissive must show the connect reaching the + # kernel's real handling (logged, not blocked) — the same + # positive/negative pair docs/demo/enforce-e2e.md's BPF-LSM demo + # already establishes for that tier. + - name: Run the seccomp-tier ardur run --enforce demo + run: | + mkdir -p /tmp/ardur-demo-out/enforce + docker run --rm --privileged --pid=host \ + -v /tmp/ardur-demo-out/enforce:/out \ + ardur-enforce-demo \ + bash /opt/ardur/demo/run-seccomp.sh enforce | tee /tmp/seccomp-enforce.log + grep -q "RESULT=DENIED_EPERM" /tmp/seccomp-enforce.log + grep -q "chain intact = true" /tmp/seccomp-enforce.log + grep -q "attestation digest match = true" /tmp/seccomp-enforce.log + grep -q "ardur-exec-shim" /tmp/seccomp-enforce.log + + - name: Run the seccomp-tier ardur run permissive control + run: | + mkdir -p /tmp/ardur-demo-out/permissive + docker run --rm --privileged --pid=host \ + -v /tmp/ardur-demo-out/permissive:/out \ + ardur-enforce-demo \ + bash /opt/ardur/demo/run-seccomp.sh permissive | tee /tmp/seccomp-permissive.log + grep -q "RESULT=DENIED_ECONNREFUSED" /tmp/seccomp-permissive.log + grep -q "denied verdicts = 0" /tmp/seccomp-permissive.log diff --git a/docs/demo/enforce-e2e/Dockerfile b/docs/demo/enforce-e2e/Dockerfile index 97a241ad..67d19edb 100644 --- a/docs/demo/enforce-e2e/Dockerfile +++ b/docs/demo/enforce-e2e/Dockerfile @@ -1,12 +1,17 @@ -# Reproducible image for the `ardur run --enforce` BPF-LSM end-to-end demo. +# Reproducible image for the `ardur run --enforce` end-to-end demo — both the +# BPF-LSM tier (run.sh) and the seccomp fallback tier (run-seccomp.sh, plan +# E4 / issue #104). # # Build context is the REPO ROOT: # docker build -f docs/demo/enforce-e2e/Dockerfile -t ardur-enforce-demo . # -# Stage 1 builds the daemon + the offline evidence verifier from source. The -# committed bpf2go object (processguard_bpfel.o) is used as-is, so no clang or -# kernel headers are needed at build time. Stage 2 installs the Python `ardur` -# CLI. Run the image privileged with --pid=host — see docs/demo/enforce-e2e.md. +# Stage 1 builds the daemon, ardur-exec-shim (the seccomp tier's on-ramp — +# needed on PATH for run-seccomp.sh, since ardur run invokes it by resolving +# it via PATH/well-known install location, never by an explicit path), and +# the offline evidence verifier from source. The committed bpf2go object +# (processguard_bpfel.o) is used as-is, so no clang or kernel headers are +# needed at build time. Stage 2 installs the Python `ardur` CLI. Run the +# image privileged with --pid=host — see docs/demo/enforce-e2e.md. FROM golang:1.26-bookworm AS build WORKDIR /src @@ -15,6 +20,7 @@ RUN cd go && go mod download COPY go/ ./go/ RUN cd go \ && go build -o /out/ardur-kernelcaptured ./cmd/ardur-kernelcaptured \ + && go build -o /out/ardur-exec-shim ./cmd/ardur-exec-shim \ && go build -o /out/enforce-verify ./cmd/enforce-verify FROM python:3.13-bookworm @@ -29,7 +35,8 @@ COPY python/ /opt/ardur/python/ RUN pip install --no-cache-dir -e /opt/ardur/python \ && pip install --no-cache-dir 'biscuit-python==0.4.0' COPY --from=build /out/ardur-kernelcaptured /usr/local/bin/ardur-kernelcaptured +COPY --from=build /out/ardur-exec-shim /usr/local/bin/ardur-exec-shim COPY --from=build /out/enforce-verify /usr/local/bin/enforce-verify -COPY docs/demo/enforce-e2e/agent.py docs/demo/enforce-e2e/run.sh /opt/ardur/demo/ -RUN chmod +x /opt/ardur/demo/run.sh && mkdir -p /run/ardur /var/lib/ardur +COPY docs/demo/enforce-e2e/agent.py docs/demo/enforce-e2e/agent_seccomp.py docs/demo/enforce-e2e/run.sh docs/demo/enforce-e2e/run-seccomp.sh /opt/ardur/demo/ +RUN chmod +x /opt/ardur/demo/run.sh /opt/ardur/demo/run-seccomp.sh && mkdir -p /run/ardur /var/lib/ardur WORKDIR /opt/ardur/demo diff --git a/docs/demo/enforce-e2e/agent_seccomp.py b/docs/demo/enforce-e2e/agent_seccomp.py new file mode 100755 index 00000000..d1851b4c --- /dev/null +++ b/docs/demo/enforce-e2e/agent_seccomp.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Benign demo agent for the `ardur run --enforce` seccomp-tier end-to-end demo. + +The seccomp counterpart to agent.py's exec-blocking demo: instead of a +forbidden execve, this attempts a forbidden connect(2) — the one syscall the +seccomp user-notify fallback tier can enforce (plan E4). Same timeline +contract as agent.py: sleep first so ardur-exec-shim's handoff to the daemon +has landed before the probe runs (register_session/apply_policy/handoff all +race the agent's own startup, same as the BPF-LSM path's apply_policy race). + +Under --enforce (OP_NET_CONNECT deny, seccomp tier active) the kernel refuses +the connect with EPERM; under permissive the same op is logged but allowed. +""" +import errno +import os +import socket +import time + +DELAY = float(os.environ.get("AGENT_DELAY", "4")) +TARGET_HOST = os.environ.get("AGENT_CONNECT_HOST", "127.0.0.3") +TARGET_PORT = int(os.environ.get("AGENT_CONNECT_PORT", "19999")) + +try: + with open("/proc/self/cgroup", encoding="ascii") as fh: + cgline = fh.read().strip() +except OSError as exc: + cgline = f"" +print(f"AGENT: pid={os.getpid()} cgroup={cgline}", flush=True) + +print(f"AGENT: sleeping {DELAY}s for the seccomp handoff to land...", flush=True) +time.sleep(DELAY) + +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +try: + sock.connect((TARGET_HOST, TARGET_PORT)) + print(f"AGENT: connect({TARGET_HOST}:{TARGET_PORT}) SUCCEEDED — not blocked", flush=True) + print("AGENT: RESULT=ALLOWED", flush=True) +except OSError as exc: + ev = exc.errno + name = errno.errorcode.get(ev, "?") + print(f"AGENT: connect({TARGET_HOST}:{TARGET_PORT}) BLOCKED — errno={ev} ({name})", flush=True) + print(f"AGENT: RESULT={'DENIED_EPERM' if ev == errno.EPERM else f'DENIED_{name}'}", flush=True) +finally: + sock.close() diff --git a/docs/demo/enforce-e2e/ci-vng-enforce.sh b/docs/demo/enforce-e2e/ci-vng-enforce.sh new file mode 100755 index 00000000..a5cbbdd3 --- /dev/null +++ b/docs/demo/enforce-e2e/ci-vng-enforce.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# vng --exec takes exactly one executable with no argument-passing syntax of +# its own (see kernel-enforce.yml's comment on the ardur-guard-smoke +# invocation this sits alongside), so this just hardcodes run.sh's mode +# argument rather than needing one. +set -eu +exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/run.sh" enforce diff --git a/docs/demo/enforce-e2e/run-seccomp.sh b/docs/demo/enforce-e2e/run-seccomp.sh new file mode 100755 index 00000000..6594b723 --- /dev/null +++ b/docs/demo/enforce-e2e/run-seccomp.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# In-container orchestration for the `ardur run --enforce` seccomp-tier e2e +# demo (plan E4 / issue #104) — the seccomp counterpart to run.sh's BPF-LSM +# demo. Runs inside the same privileged demo image (see +# docs/demo/enforce-e2e.md), forcing the daemon onto the seccomp fallback +# tier with -disable-bpf-lsm so this proves the fallback path specifically, +# even on a host (like this image's kernel) where BPF-LSM would otherwise win +# tier selection. +# Usage: run-seccomp.sh +set -u +MODE="${1:-enforce}" +OUT="/out/seccomp-${MODE}"; mkdir -p "$OUT" +DEMO_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "============ ardur run seccomp-tier demo — mode=${MODE} ============" + +# 0. kernel-facing filesystems (idempotent; require --privileged) +mount -t securityfs securityfs /sys/kernel/security 2>/dev/null || true +mount -t bpf bpf /sys/fs/bpf 2>/dev/null || true +mount -t tracefs tracefs /sys/kernel/tracing 2>/dev/null || true +mkdir -p /run/ardur /var/lib/ardur +echo "cgroup=$(stat -fc %T /sys/fs/cgroup)" + +# 1. start the daemon *forced onto the seccomp tier*; wait for its handoff +# socket to come up (the seccomp-tier equivalent of run.sh's +# "process_guard loaded" wait). +rm -rf /var/lib/ardur/kernelcapture/evidence/* 2>/dev/null || true +ardur-kernelcaptured -debug -disable-bpf-lsm > "$OUT/daemon.log" 2>&1 & +DPID=$! +for _ in $(seq 1 40); do + grep -q "seccomp handoff socket listening" "$OUT/daemon.log" && break + kill -0 $DPID 2>/dev/null || { echo "daemon died:"; cat "$OUT/daemon.log"; exit 1; } + sleep 0.25 +done +grep -q "seccomp handoff socket listening" "$OUT/daemon.log" \ + && echo "daemon: seccomp tier active, handoff socket listening ✓" \ + || { echo "daemon: seccomp handoff socket never came up"; tail -5 "$OUT/daemon.log"; kill $DPID; exit 1; } +grep -q "\"tier\":\"seccomp\"" "$OUT/daemon.log" \ + && echo "daemon: enforcement_tier=seccomp confirmed (BPF-LSM was disabled, not just unavailable) ✓" + +# 2. ardur run a benign agent under a mission that forbids network access. +# --no-resource-scope: skip the default cwd file allowlist, which the +# seccomp tier can never satisfy (it only enforces OP_NET_CONNECT) — see +# run_governed()'s no_resource_scope docstring. Without this the mission +# would never reach applied_seccomp_tier at all, regardless of the shim +# wiring this demo exists to prove (issue #104). +ENF=""; [ "$MODE" = "enforce" ] && ENF="--enforce" +ardur run \ + --home "/out/home-seccomp-${MODE}" \ + --mission "Kernel demo: network access is forbidden (seccomp tier)." \ + --forbidden-tools fetch \ + --no-resource-scope \ + --max-tool-calls 50 \ + --via env \ + $ENF \ + -- python3 "$DEMO_DIR/agent_seccomp.py" 2>&1 | tee "$OUT/ardur-run.log" | grep -E "AGENT:|kernel policy|kernel link|attestation|agent exit|ardur-exec-shim|ardur run:" + +# 3. offline evidence verification: hash-chain integrity + attestation linkage. +# Same enforce_events.jsonl format and same enforce-verify tool as the +# BPF-LSM demo — the E3 evidence pipeline is shared across both tiers. +EVID=$(find /var/lib/ardur/kernelcapture/evidence -name enforce_events.jsonl 2>/dev/null | head -1) +if [ -n "$EVID" ]; then + cp "$EVID" "$OUT/enforce_events.jsonl" + DIGEST=$(python3 - "/out/home-seccomp-${MODE}" <<'PY' +import base64, glob, json, os, sys +home = sys.argv[1] +for p in glob.glob(f"{home}/**/*", recursive=True): + if not os.path.isfile(p): continue + try: txt = open(p, encoding="utf-8", errors="ignore").read() + except OSError: continue + for tok in txt.replace('"', " ").split(): + if tok.startswith("ey") and tok.count(".") == 2 and len(tok) > 80: + try: + pad = tok.split(".")[1]; pad += "=" * (-len(pad) % 4) + c = json.loads(base64.urlsafe_b64decode(pad)) + except Exception: continue + if "scope_compliance" in c: + print((c.get("kernel_enforcement") or {}).get("chain_digest", "")); sys.exit() +PY +) + echo "------ offline verification (no kernel, no daemon, no root) ------" + echo "attestation kernel_enforcement.chain_digest = ${DIGEST:-}" + enforce-verify "$OUT/enforce_events.jsonl" ${DIGEST:+$DIGEST} + echo "enforce-verify exit: $?" +else + echo "no enforce_events.jsonl produced — nothing to verify" + [ "$MODE" = "enforce" ] && { echo "FAIL: enforce mode must produce evidence of the denial"; kill $DPID 2>/dev/null; exit 1; } +fi + +kill $DPID 2>/dev/null; wait $DPID 2>/dev/null +echo "== seccomp demo (${MODE}) done ==" diff --git a/go/cmd/ardur-exec-shim/main.go b/go/cmd/ardur-exec-shim/main.go index d10b8a59..d024c358 100644 --- a/go/cmd/ardur-exec-shim/main.go +++ b/go/cmd/ardur-exec-shim/main.go @@ -31,6 +31,32 @@ // mechanism, and refusing to run the target at all would only be a *weaker* // guarantee (no filter, but also no program) for a caller who asked to run // something. +// +// Startup race, and why it needs its own pre-flight step: the launcher +// spawns this shim and then calls register_session on the daemon +// concurrently (it cannot register first — root_pid is this shim's own PID, +// only known once it has actually started). The seccomp handoff itself is a +// one-shot attempt once the filter is installed (see run's comment on why it +// cannot be retried after that point without risking a self-deadlock), so if +// register_session hasn't landed yet at that moment, the handoff fails +// permanently for this run — caught empirically running the real +// ardur-exec-shim through `ardur run` for the first time (issue #104's +// verification), not by any unit test. +// +// waitForReadyFile below closes this: the launcher creates a marker file +// right after its own register_session call succeeds, and this shim polls +// for that file's existence before doing anything seccomp-related. A first +// attempt at this used a daemon round trip (session_status) instead, but +// that check enforces exact-PID peer ownership on the session record +// (daemonSessionRegistryPeerOwnsRecord) — the launcher process registered +// the session, so this shim (a different PID) can never pass that check no +// matter how long it waits; that approach was replaced before it shipped. A +// signal-based handshake (launcher signals this process once ready) was +// also considered and rejected: the default disposition of most signals is +// to terminate the process, so a signal arriving before this process has +// installed its handler would kill it outright — a race with a fatal +// failure mode, not just a slow one. Plain file existence has neither +// problem: checking is always safe, whether the file exists yet or not. package main import ( @@ -63,6 +89,13 @@ const ( // succeeds. handoffAttempts = 3 handoffRetryWait = 500 * time.Millisecond + + // readyFileTimeout bounds waitForReadyFile's pre-flight poll — generous + // relative to a single register_session round trip (a millisecond-scale + // Unix-socket call) since it only needs to absorb launcher-side + // scheduling delay, not any real workload. + readyFileTimeout = 5 * time.Second + readyFileInterval = 50 * time.Millisecond ) func main() { @@ -79,6 +112,7 @@ func main() { var ( sessionID = flag.String("session-id", "", "ardur session_id this shim's connect(2) decisions are evaluated against (required)") seccompSocket = flag.String("seccomp-socket", defaultSeccompSocketPath, "ardur-kernelcaptured's seccomp handoff socket path") + readyFile = flag.String("ready-file", "", "path the launcher creates once its own register_session call has succeeded (optional; skips the pre-flight wait if empty)") ) flag.Usage = usage flag.Parse() @@ -89,7 +123,7 @@ func main() { os.Exit(2) } - if err := run(*sessionID, *seccompSocket, args); err != nil { + if err := run(*sessionID, *seccompSocket, *readyFile, args); err != nil { fmt.Fprintf(os.Stderr, "ardur-exec-shim: %v\n", err) os.Exit(1) } @@ -98,16 +132,25 @@ func main() { } func usage() { - fmt.Fprintf(os.Stderr, "usage: %s --session-id ID [--seccomp-socket PATH] -- COMMAND [ARGS...]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "usage: %s --session-id ID [--seccomp-socket PATH] [--ready-file PATH] -- COMMAND [ARGS...]\n", os.Args[0]) flag.PrintDefaults() } -func run(sessionID, seccompSocketPath string, args []string) error { +func run(sessionID, seccompSocketPath, readyFilePath string, args []string) error { target, err := lookupTarget(args[0]) if err != nil { return err } + // Wait for the launcher's register_session to land before doing + // anything seccomp-related — see the package doc comment's "Startup + // race" section. + if readyFilePath != "" { + if err := waitForReadyFile(readyFilePath, readyFileTimeout, readyFileInterval); err != nil { + fmt.Fprintf(os.Stderr, "ardur-exec-shim: warning: %v; proceeding anyway (handoff may still race)\n", err) + } + } + // Establish (and retry, if needed) the handoff connection to the // daemon BEFORE installing the seccomp filter below. This ordering is // load-bearing, not stylistic: seccomp intercepts connect(2) by @@ -171,6 +214,27 @@ func lookupTarget(name string) (string, error) { return resolved, nil } +// waitForReadyFile polls for readyFilePath's existence until the launcher +// creates it (right after its own register_session call succeeds) or +// timeout elapses. See the package doc comment's "Startup race" section for +// why this exists, and why it's a file rather than a daemon round trip or a +// signal. +// +// Returns an error (never fatal to the caller — see run's warn-and-proceed +// handling) if the file never appears in time. +func waitForReadyFile(readyFilePath string, timeout, interval time.Duration) error { + deadline := time.Now().Add(timeout) + for { + if _, err := os.Stat(readyFilePath); err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("ready file %s did not appear within %s", readyFilePath, timeout) + } + time.Sleep(interval) + } +} + // seccompHandoffRequest/seccompHandoffResponse mirror (by JSON shape only — // this is a separate binary, there is no shared Go type) the daemon's // seccompHandoffRequest/seccompHandoffResponse in diff --git a/go/cmd/ardur-kernelcaptured/daemon_enforce_test.go b/go/cmd/ardur-kernelcaptured/daemon_enforce_test.go index fc484b4a..cc5e4303 100644 --- a/go/cmd/ardur-kernelcaptured/daemon_enforce_test.go +++ b/go/cmd/ardur-kernelcaptured/daemon_enforce_test.go @@ -413,3 +413,129 @@ func TestHandleAuthorizedRequest_SessionStatusIncludesEnforcementSummary(t *test t.Errorf("enforcement summary = %+v, want 1 total denied event", statusResp.Enforcement) } } + +// TestHandleAuthorizedRequest_SessionStatusReportsSeccompListenerAttached +// guards issue #104's fix: a launcher must be able to confirm a seccomp +// listener is actually supervising a session before trusting that +// apply_policy's "applied" answer means anything is really enforced. +func TestHandleAuthorizedRequest_SessionStatusReportsSeccompListenerAttached(t *testing.T) { + d := newTestDaemon(t) + handshake := kernelcapture.DaemonProtocolPeerHandshake{ + ProtocolVersion: kernelcapture.DaemonProtocolVersion, + Method: kernelcapture.DaemonProtocolMethodRegisterSession, + SessionID: "seccomp-status-session", + SocketPath: "/run/ardur/kernelcapture/control.sock", + CredentialSource: kernelcapture.DaemonPeerCredentialSourceLinuxSOPeerCred, + ProcessStartTimeTicks: 1, + Authorization: kernelcapture.DaemonPeerAuthorization{ + Verdict: kernelcapture.DaemonPeerAuthorizationVerdictAllow, + Reason: "test", + UID: 501, + PID: 4242, + ProcessStartTimeTicks: 1, + Matched: "uid", + }, + } + registerResp := d.handleAuthorizedRequest(context.Background(), kernelcapture.DaemonProtocolRequest{ + ProtocolVersion: kernelcapture.DaemonProtocolVersion, + Method: kernelcapture.DaemonProtocolMethodRegisterSession, + RegisterSession: &kernelcapture.DaemonRegisterSessionRequest{ + SessionID: "seccomp-status-session", + RootPID: 100, + CgroupID: 55, + EventClasses: []string{kernelcapture.DaemonProtocolEventProcessLifecycle}, + TTLSeconds: 300, + }, + }, handshake) + if !registerResp.OK { + t.Fatalf("register_session failed: %+v", registerResp) + } + + statusReq := kernelcapture.DaemonProtocolRequest{ + ProtocolVersion: kernelcapture.DaemonProtocolVersion, + Method: kernelcapture.DaemonProtocolMethodSessionStatus, + SessionStatus: &kernelcapture.DaemonSessionStatusRequest{SessionID: "seccomp-status-session"}, + } + + before := d.handleAuthorizedRequest(context.Background(), statusReq, handshake) + if !before.OK { + t.Fatalf("session_status (before listener) failed: %+v", before) + } + if before.SeccompListenerAttached { + t.Error("SeccompListenerAttached = true before any listener registered, want false") + } + + if !d.registerSeccompListener("seccomp-status-session", func() {}) { + t.Fatal("registerSeccompListener: expected first registration to succeed") + } + + during := d.handleAuthorizedRequest(context.Background(), statusReq, handshake) + if !during.OK { + t.Fatalf("session_status (listener attached) failed: %+v", during) + } + if !during.SeccompListenerAttached { + t.Error("SeccompListenerAttached = false while a listener is registered, want true") + } + + d.unregisterSeccompListener("seccomp-status-session") + + after := d.handleAuthorizedRequest(context.Background(), statusReq, handshake) + if !after.OK { + t.Fatalf("session_status (after unregister) failed: %+v", after) + } + if after.SeccompListenerAttached { + t.Error("SeccompListenerAttached = true after unregisterSeccompListener, want false") + } +} + +// TestHandleAuthorizedRequest_HealthNeverReportsSeccompListenerAttached +// guards the field's scoping: listener attachment is per-session, so it must +// never appear (default false) on the daemon-wide health response even when +// some session somewhere does have a listener attached. +func TestHandleAuthorizedRequest_HealthNeverReportsSeccompListenerAttached(t *testing.T) { + d := newTestDaemon(t) + if !d.registerSeccompListener("some-other-session", func() {}) { + t.Fatal("registerSeccompListener: expected first registration to succeed") + } + + handshake := kernelcapture.DaemonProtocolPeerHandshake{ + ProtocolVersion: kernelcapture.DaemonProtocolVersion, + Method: kernelcapture.DaemonProtocolMethodHealth, + SocketPath: "/run/ardur/kernelcapture/control.sock", + CredentialSource: kernelcapture.DaemonPeerCredentialSourceLinuxSOPeerCred, + ProcessStartTimeTicks: 1, + Authorization: kernelcapture.DaemonPeerAuthorization{ + Verdict: kernelcapture.DaemonPeerAuthorizationVerdictAllow, + Reason: "test", + UID: 501, + PID: 4242, + ProcessStartTimeTicks: 1, + Matched: "uid", + }, + } + resp := d.handleAuthorizedRequest(context.Background(), kernelcapture.DaemonProtocolRequest{ + ProtocolVersion: kernelcapture.DaemonProtocolVersion, + Method: kernelcapture.DaemonProtocolMethodHealth, + Health: &kernelcapture.DaemonHealthRequest{}, + }, handshake) + if !resp.OK { + t.Fatalf("health request failed: %+v", resp) + } + if resp.SeccompListenerAttached { + t.Error("health response set SeccompListenerAttached = true, want it left at its zero value (false)") + } +} + +func TestSeccompListenerAttached_EmptySessionIDIsAlwaysFalse(t *testing.T) { + d := newTestDaemon(t) + if d.seccompListenerAttached("") { + t.Error("seccompListenerAttached(\"\") = true, want false") + } +} + +func TestSeccompListenerAttached_UnknownSessionIsFalse(t *testing.T) { + d := newTestDaemon(t) + if d.seccompListenerAttached("no-such-session") { + t.Error("seccompListenerAttached for an unregistered session = true, want false") + } +} diff --git a/go/cmd/ardur-kernelcaptured/main.go b/go/cmd/ardur-kernelcaptured/main.go index 4b269d69..1aded330 100644 --- a/go/cmd/ardur-kernelcaptured/main.go +++ b/go/cmd/ardur-kernelcaptured/main.go @@ -232,6 +232,22 @@ func (d *daemon) unregisterSeccompListener(sessionID string) { } } +// seccompListenerAttached reports whether sessionID currently has a live +// seccomp supervisor goroutine — i.e. whether some ardur-exec-shim's handoff +// for it has actually completed (registerSeccompListener succeeded) and +// hasn't since torn down (session end, listener error, daemon shutdown). +// Backs DaemonProtocolResponse.SeccompListenerAttached on session_status +// (issue #104: apply_policy succeeding is not proof of this). +func (d *daemon) seccompListenerAttached(sessionID string) bool { + if sessionID == "" { + return false + } + d.mu.RLock() + defer d.mu.RUnlock() + _, ok := d.seccompListeners[sessionID] + return ok +} + // handleAuthorizedRequest is the DaemonAuthorizedProtocolHandler wired into the // socket server. It delegates to the registry and maintains the cgroup routing // index as a side effect of successful register/end-session responses. @@ -282,6 +298,7 @@ func (d *daemon) handleAuthorizedRequest(ctx context.Context, req kernelcapture. if summary, ok := d.enforceSummaryForScope(resp.SessionID); ok { resp.Enforcement = &summary } + resp.SeccompListenerAttached = d.seccompListenerAttached(resp.SessionID) case kernelcapture.DaemonProtocolMethodHealth: // Advertise which enforcement tier is live so a launcher can decide // whether routing a governed process through ardur-exec-shim (the diff --git a/go/pkg/kernelcapture/daemon_protocol.go b/go/pkg/kernelcapture/daemon_protocol.go index d498a9b9..5ab0e131 100644 --- a/go/pkg/kernelcapture/daemon_protocol.go +++ b/go/pkg/kernelcapture/daemon_protocol.go @@ -21,12 +21,13 @@ const ( DaemonProtocolEventProcessLifecycle = "process_lifecycle" - // EnforcementTierBPFLSM/EnforcementTierNone are the values a health - // response's EnforcementTier field takes. Forward-compatible with future - // tiers (seccomp unotify, plan E4) — a new tier adds a new constant here, - // not a breaking change to the field's meaning. - EnforcementTierBPFLSM = "bpf_lsm" - EnforcementTierNone = "none" + // EnforcementTierBPFLSM/EnforcementTierSeccomp/EnforcementTierNone are the + // values a health response's EnforcementTier field takes. Forward- + // compatible with future tiers — a new tier adds a new constant here, not + // a breaking change to the field's meaning. + EnforcementTierBPFLSM = "bpf_lsm" + EnforcementTierSeccomp = "seccomp" + EnforcementTierNone = "none" MaxDaemonProtocolTTLSeconds = 24 * 60 * 60 @@ -126,16 +127,39 @@ type DaemonProtocolResponse struct { // has to learn what kernel-level enforcement happened. Enforcement *EnforceEventSummary `json:"enforcement,omitempty"` // EnforcementTier carries which kernel enforcement tier is currently - // active — "bpf_lsm", "seccomp", or "none" (EnforcementTierBPFLSM / - // EnforcementTierNone; seccomp is plan E4) — on successful health - // responses. The daemon decides this once at startup (BPF-LSM preferred, - // seccomp as fallback) and never changes it while running; a launcher - // queries it to decide whether a governed process needs to be routed - // through ardur-exec-shim (the seccomp tier's on-ramp) before spawning - // one, or can rely on BPF-LSM's cgroup-scoped enforcement with no - // per-process wrapper at all. session_status responses use Enforcement - // (per-session) instead. + // active — EnforcementTierBPFLSM, EnforcementTierSeccomp, or + // EnforcementTierNone — on successful health responses. The daemon + // decides this once at startup (BPF-LSM preferred, seccomp as fallback) + // and never changes it while running; a launcher queries it to decide + // whether a governed process needs to be routed through ardur-exec-shim + // (the seccomp tier's on-ramp) before spawning one, or can rely on + // BPF-LSM's cgroup-scoped enforcement with no per-process wrapper at all. + // session_status responses use Enforcement (per-session) instead. EnforcementTier string `json:"enforcement_tier,omitempty"` + // SeccompListenerAttached is populated on successful session_status + // responses (never on health — attachment is per-session, not + // daemon-wide) to report whether a seccomp user-notify listener is + // *currently* supervising this session, i.e. whether some + // ardur-exec-shim's handoff for it actually completed. + // + // This exists because EnforcementTier=="seccomp" alone is not proof that + // enforcement is live for any given session: it only says the daemon + // *would* enforce net-connect policy via seccomp if a listener attaches. + // apply_policy syncing the seccomp policy store (handleApplyPolicy) is + // similarly necessary but not sufficient — the store update and the + // shim's handoff are two independent operations that can each succeed or + // fail on their own. A launcher that only checked the first two and + // declared success (issue #104) would silently govern nothing: no shim + // wraps the agent, no filter traps its connect(2) calls, and yet + // apply_policy honestly (not falsely) reported the tier-side policy as + // applied. This field lets a caller confirm the *third*, session-scoped + // fact before trusting that a run is actually enforced. + // + // No omitempty: false is a meaningful, load-bearing answer here, and a + // client must be able to tell "not attached" apart from "field absent + // because this daemon predates it" — always emitting it removes that + // ambiguity on the wire. + SeccompListenerAttached bool `json:"seccomp_listener_attached"` } // CgroupFilterSequence describes daemon-side map sequencing. Enabling diff --git a/python/tests/test_run_bridge.py b/python/tests/test_run_bridge.py index 9716b37a..0add2f1e 100644 --- a/python/tests/test_run_bridge.py +++ b/python/tests/test_run_bridge.py @@ -22,6 +22,7 @@ import pytest from vibap import kernel_correlation as kc +from vibap import run_bridge from vibap.attestation import verify_attestation from vibap.passport import load_public_key from vibap.receipt import verify_chain @@ -32,8 +33,12 @@ EnvProxyAdapter, KernelPolicyEnforcementError, RunContext, + SeccompShimPlan, TransparentInterceptAdapter, _kernel_enforcement_claim, + _plan_seccomp_shim, + _verify_seccomp_listener_attached, + _wrap_command_with_seccomp_shim, run_governed, run_governed_cli, select_adapter, @@ -80,6 +85,27 @@ def standin_agent(tmp_path: Path) -> Path: return path +@pytest.fixture +def fake_exec_shim(tmp_path: Path) -> Path: + """A stand-in ``ardur-exec-shim``: parses the real CLI shape + (``--session-id ID --seccomp-socket PATH --control-socket PATH -- + CMD ARGS...``) and execs the remainder unmodified. + + It installs no real seccomp filter and talks to no daemon — tests using + it prove the *orchestration* wiring around the shim (issue #104: is it + invoked at all, does the run correctly notice when its handoff never + completes), not the shim binary's own kernel behavior. That is covered + separately by go/cmd/ardur-seccomp-smoke. + """ + path = tmp_path / "fake-exec-shim.sh" + path.write_text( + "#!/bin/sh\nset -e\nshift 6\nshift\nexec \"$@\"\n", + encoding="utf-8", + ) + path.chmod(0o755) + return path + + def _hermetic_kernel_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Force kernel correlation to degrade deterministically on any platform. @@ -416,6 +442,367 @@ def test_ardur_run_enforce_aborts_when_daemon_rejects_policy( assert "end_session" in methods +# ── seccomp-tier shim wiring (issue #104: false-success on seccomp-only hosts) ── + + +def _seccomp_health_response() -> dict: + return { + "protocol_version": kc.DAEMON_PROTOCOL_VERSION, + "ok": True, + "method": "health", + "enforcement_tier": kc.ENFORCEMENT_TIER_SECCOMP, + } + + +def test_plan_seccomp_shim_disabled_by_caller() -> None: + plan = _plan_seccomp_shim(enabled=False) + assert plan == SeccompShimPlan(tier=None, wrapped=False, reason="kernel correlation disabled by caller") + + +def test_plan_seccomp_shim_no_daemon(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(kc.DAEMON_SOCKET_ENV, str(tmp_path / "no-such-daemon.sock")) + plan = _plan_seccomp_shim(enabled=True) + assert plan.tier is None + assert plan.wrapped is False + assert "socket not present" in plan.reason + + +def test_plan_seccomp_shim_bpf_lsm_tier_does_not_wrap(monkeypatch: pytest.MonkeyPatch, sockdir: Path) -> None: + socket_path = sockdir / "daemon.sock" + monkeypatch.setenv(kc.DAEMON_SOCKET_ENV, str(socket_path)) + daemon = _FakeKernelDaemon( + socket_path, + responses={ + "health": { + "protocol_version": kc.DAEMON_PROTOCOL_VERSION, + "ok": True, + "method": "health", + "enforcement_tier": kc.ENFORCEMENT_TIER_BPF_LSM, + } + }, + ) + daemon.start() + try: + plan = _plan_seccomp_shim(enabled=True) + finally: + daemon.close() + assert plan.tier == kc.ENFORCEMENT_TIER_BPF_LSM + assert plan.wrapped is False + + +def test_plan_seccomp_shim_seccomp_tier_resolves_shim( + monkeypatch: pytest.MonkeyPatch, sockdir: Path, fake_exec_shim: Path +) -> None: + socket_path = sockdir / "daemon.sock" + monkeypatch.setenv(kc.DAEMON_SOCKET_ENV, str(socket_path)) + monkeypatch.setattr(kc, "exec_shim_path", lambda: fake_exec_shim) + daemon = _FakeKernelDaemon(socket_path, responses={"health": _seccomp_health_response()}) + daemon.start() + try: + plan = _plan_seccomp_shim(enabled=True) + finally: + daemon.close() + assert plan.tier == kc.ENFORCEMENT_TIER_SECCOMP + assert plan.wrapped is True + assert plan.shim_path == fake_exec_shim + + +def test_plan_seccomp_shim_seccomp_tier_missing_binary(monkeypatch: pytest.MonkeyPatch, sockdir: Path) -> None: + socket_path = sockdir / "daemon.sock" + monkeypatch.setenv(kc.DAEMON_SOCKET_ENV, str(socket_path)) + monkeypatch.setattr(kc, "exec_shim_path", lambda: None) + daemon = _FakeKernelDaemon(socket_path, responses={"health": _seccomp_health_response()}) + daemon.start() + try: + plan = _plan_seccomp_shim(enabled=True) + finally: + daemon.close() + assert plan.tier == kc.ENFORCEMENT_TIER_SECCOMP + assert plan.wrapped is False + assert "not found" in plan.reason + + +def test_wrap_command_with_seccomp_shim_builds_expected_argv(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv(kc.SECCOMP_SOCKET_ENV, "/run/ardur/kernelcapture/seccomp.sock") + shim_path = tmp_path / "ardur-exec-shim" + ready_file = tmp_path / "seccomp-ready-sess-1" + wrapped = _wrap_command_with_seccomp_shim( + ["claude", "--foo"], session_id="sess-1", shim_path=shim_path, ready_file=ready_file + ) + assert wrapped == [ + str(shim_path), + "--session-id", + "sess-1", + "--seccomp-socket", + "/run/ardur/kernelcapture/seccomp.sock", + "--ready-file", + str(ready_file), + "--", + "claude", + "--foo", + ] + + +def test_verify_seccomp_listener_attached_true(monkeypatch: pytest.MonkeyPatch, sockdir: Path) -> None: + sock = sockdir / "c.sock" + monkeypatch.setenv(kc.DAEMON_SOCKET_ENV, str(sock)) + daemon = _FakeSessionStatusDaemon( + sock, + { + "protocol_version": kc.DAEMON_PROTOCOL_VERSION, + "ok": True, + "method": "session_status", + "session_id": "sess-1", + "seccomp_listener_attached": True, + }, + ) + try: + assert _verify_seccomp_listener_attached("sess-1", timeout_s=1.0, poll_interval_s=0.05) is True + finally: + daemon.close() + + +def test_verify_seccomp_listener_attached_times_out_when_never_true( + monkeypatch: pytest.MonkeyPatch, sockdir: Path +) -> None: + sock = sockdir / "c.sock" + monkeypatch.setenv(kc.DAEMON_SOCKET_ENV, str(sock)) + daemon = _FakeKernelDaemon( + sock, + responses={ + "session_status": { + "protocol_version": kc.DAEMON_PROTOCOL_VERSION, + "ok": True, + "method": "session_status", + "session_id": "sess-1", + "seccomp_listener_attached": False, + } + }, + ) + daemon.start() + try: + assert _verify_seccomp_listener_attached("sess-1", timeout_s=0.3, poll_interval_s=0.05) is False + finally: + daemon.close() + # Genuinely polled more than once before giving up, not just a single + # failed round trip — proves the retry loop actually ran, not just that + # a connection failure short-circuited it. + assert len([r for r in daemon.received if r.get("method") == "session_status"]) >= 2 + + +def test_verify_seccomp_listener_attached_false_when_daemon_unreachable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv(kc.DAEMON_SOCKET_ENV, str(tmp_path / "no-such-daemon.sock")) + assert _verify_seccomp_listener_attached("sess-1", timeout_s=1.0, poll_interval_s=0.05) is False + + +def _seccomp_daemon_responses(*, listener_attached: bool) -> dict[str, dict]: + return { + "health": _seccomp_health_response(), + "register_session": { + "protocol_version": kc.DAEMON_PROTOCOL_VERSION, + "ok": True, + "method": "register_session", + "status": "registered", + }, + "apply_policy": { + "protocol_version": kc.DAEMON_PROTOCOL_VERSION, + "ok": True, + "method": "apply_policy", + "status": "applied_seccomp_tier", + }, + "session_status": { + "protocol_version": kc.DAEMON_PROTOCOL_VERSION, + "ok": True, + "method": "session_status", + "seccomp_listener_attached": listener_attached, + }, + } + + +def test_ardur_run_launches_via_shim_and_confirms_enforcement_on_seccomp_tier( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, standin_agent: Path, sockdir: Path, fake_exec_shim: Path +) -> None: + """The headline #104 fix, the success path: on a seccomp-tier host the + agent actually runs *through* ardur-exec-shim, and a genuinely-attached + listener is confirmed before the run reports enforcement as applied. + """ + socket_path = _live_kernel_env(monkeypatch, tmp_path, sockdir) + monkeypatch.setattr(kc, "exec_shim_path", lambda: fake_exec_shim) + daemon = _FakeKernelDaemon(socket_path, responses=_seccomp_daemon_responses(listener_attached=True)) + daemon.start() + try: + result = run_governed( + command=[sys.executable, str(standin_agent)], + mission="Seccomp tier launches through the shim.", + allowed_tools=["Read", "Glob", "Grep"], + forbidden_tools=["Bash"], + max_tool_calls=10, + home=tmp_path / "seccomp-home", + via="env", + enforce=True, + ) + finally: + daemon.close() + + assert result.exit_code == 0 + assert result.kernel_policy["applied"] is True + assert any("ardur-exec-shim" in note for note in result.notes) + # The standin agent still ran correctly *through* the fake shim's exec + # passthrough — proves the wrapping didn't break the governed launch. + assert result.total_events == 3 + assert result.denials == 1 + + +def test_no_resource_scope_omits_file_ops_from_lowered_plan( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, standin_agent: Path, sockdir: Path, fake_exec_shim: Path +) -> None: + """``no_resource_scope=True`` is what makes a mission genuinely + seccomp-tier-coverable end to end: without it, every mission's default + cwd-based file resource_scope adds OP_FILE_READ/OP_FILE_WRITE entries the + seccomp tier can never satisfy, so apply_policy would reject it outright + regardless of the shim wiring this test suite otherwise verifies. + """ + socket_path = _live_kernel_env(monkeypatch, tmp_path, sockdir) + monkeypatch.setattr(kc, "exec_shim_path", lambda: fake_exec_shim) + daemon = _FakeKernelDaemon(socket_path, responses=_seccomp_daemon_responses(listener_attached=True)) + daemon.start() + try: + result = run_governed( + command=[sys.executable, str(standin_agent)], + mission="Network-only mission, no file scope needed.", + allowed_tools=["Read", "Glob", "Grep"], + forbidden_tools=["fetch"], + max_tool_calls=10, + home=tmp_path / "seccomp-net-only-home", + via="env", + enforce=True, + no_resource_scope=True, + ) + finally: + daemon.close() + + assert result.kernel_policy["applied"] is True + apply_req = next(req["apply_policy"] for req in daemon.received if req.get("method") == "apply_policy") + assert "path_allow" not in apply_req + ops = {entry["op"] for entry in apply_req["op_policies"]} + from vibap.bpf_types import OP_FILE_READ, OP_FILE_WRITE, OP_NET_CONNECT + + assert ops == {OP_NET_CONNECT} + assert OP_FILE_READ not in ops + assert OP_FILE_WRITE not in ops + + +def test_ardur_run_enforce_aborts_when_seccomp_listener_never_attaches( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, standin_agent: Path, sockdir: Path, fake_exec_shim: Path +) -> None: + """The false-success bug (#104), closed: apply_policy reporting + ``applied_seccomp_tier`` must not be trusted on its own. Here the shim + resolves and "runs" (the fake shim is a pure passthrough — exactly what + a shim binary that never actually talks to the daemon would look like + from the outside), but no listener ever attaches; --enforce must abort + loudly instead of reporting success. + """ + socket_path = _live_kernel_env(monkeypatch, tmp_path, sockdir) + monkeypatch.setattr(kc, "exec_shim_path", lambda: fake_exec_shim) + monkeypatch.setattr(run_bridge, "SECCOMP_LISTENER_VERIFY_TIMEOUT_S", 0.3) + monkeypatch.setattr(run_bridge, "SECCOMP_LISTENER_VERIFY_POLL_INTERVAL_S", 0.05) + daemon = _FakeKernelDaemon(socket_path, responses=_seccomp_daemon_responses(listener_attached=False)) + daemon.start() + try: + with pytest.raises(KernelPolicyEnforcementError, match="seccomp listener never attached"): + run_governed( + command=[sys.executable, str(standin_agent)], + mission="Seccomp listener never attaches, must abort under --enforce.", + allowed_tools=["Read"], + forbidden_tools=["Bash"], + max_tool_calls=10, + home=tmp_path / "seccomp-unattached-home", + via="env", + enforce=True, + ) + finally: + daemon.close() + + +def test_ardur_run_permissive_degrades_when_seccomp_listener_never_attaches( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, standin_agent: Path, sockdir: Path, fake_exec_shim: Path +) -> None: + """Same unattached-listener scenario, permissive mode: a recorded + degrade, not an abort — the run still completes and still governs via + the hook/env path, matching every other kernel-policy degrade case. + """ + socket_path = _live_kernel_env(monkeypatch, tmp_path, sockdir) + monkeypatch.setattr(kc, "exec_shim_path", lambda: fake_exec_shim) + monkeypatch.setattr(run_bridge, "SECCOMP_LISTENER_VERIFY_TIMEOUT_S", 0.3) + monkeypatch.setattr(run_bridge, "SECCOMP_LISTENER_VERIFY_POLL_INTERVAL_S", 0.05) + daemon = _FakeKernelDaemon(socket_path, responses=_seccomp_daemon_responses(listener_attached=False)) + daemon.start() + try: + result = run_governed( + command=[sys.executable, str(standin_agent)], + mission="Seccomp listener never attaches, permissive degrade.", + allowed_tools=["Read", "Glob", "Grep"], + forbidden_tools=["Bash"], + max_tool_calls=10, + home=tmp_path / "seccomp-permissive-home", + via="env", + # enforce defaults to False + ) + finally: + daemon.close() + + assert result.exit_code == 0 + assert result.kernel_policy["applied"] is False + assert "listener never attached" in result.kernel_policy["reason"] + + +def test_ardur_run_enforce_aborts_when_seccomp_tier_active_but_shim_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, standin_agent: Path, sockdir: Path +) -> None: + """A different way #104's tier-not-wired case can happen: the daemon is + on the seccomp tier but ``ardur-exec-shim`` was never installed + (``exec_shim_path()`` returns ``None``) — no shim, no wrapping, but the + mission still has kernel-enforceable policy. --enforce must abort. + """ + socket_path = _live_kernel_env(monkeypatch, tmp_path, sockdir) + monkeypatch.setattr(kc, "exec_shim_path", lambda: None) + daemon = _FakeKernelDaemon( + socket_path, + responses={ + "health": _seccomp_health_response(), + "register_session": { + "protocol_version": kc.DAEMON_PROTOCOL_VERSION, + "ok": True, + "method": "register_session", + "status": "registered", + }, + "apply_policy": { + "protocol_version": kc.DAEMON_PROTOCOL_VERSION, + "ok": True, + "method": "apply_policy", + "status": "applied_seccomp_tier", + }, + }, + ) + daemon.start() + try: + with pytest.raises(KernelPolicyEnforcementError, match="seccomp tier active but not wired"): + run_governed( + command=[sys.executable, str(standin_agent)], + mission="Seccomp tier active, shim missing, must abort under --enforce.", + allowed_tools=["Read"], + forbidden_tools=["Bash"], + max_tool_calls=10, + home=tmp_path / "seccomp-no-shim-home", + via="env", + enforce=True, + ) + finally: + daemon.close() + + def test_run_governed_rejects_empty_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _hermetic_kernel_env(monkeypatch, tmp_path) with pytest.raises(ValueError, match="requires a command"): diff --git a/python/vibap/cli.py b/python/vibap/cli.py index 850dd72d..e89f87c9 100644 --- a/python/vibap/cli.py +++ b/python/vibap/cli.py @@ -3330,6 +3330,15 @@ def build_parser() -> argparse.ArgumentParser: help="abort the run if kernel-level BPF policy enforcement cannot be installed " "(default: permissive — degrade to hook/proxy governance with a recorded note)", ) + run.add_argument( + "--no-resource-scope", + action="store_true", + help="skip the default cwd-based file resource_scope (path_allow); use for a " + "mission that is genuinely network-only, since the seccomp fallback tier " + "(active when BPF-LSM is unavailable) can only ever enforce network policy — " + "a mission that also carries a file-scope dimension can never be fully " + "enforceable on that tier", + ) run.add_argument("command", nargs=argparse.REMAINDER, help="command to run after --") run.set_defaults(func=cmd_run) diff --git a/python/vibap/kernel_correlation.py b/python/vibap/kernel_correlation.py index 5dafe638..de8f65f9 100644 --- a/python/vibap/kernel_correlation.py +++ b/python/vibap/kernel_correlation.py @@ -44,6 +44,7 @@ import json import os +import shutil import socket from contextlib import suppress from dataclasses import dataclass, field @@ -58,11 +59,26 @@ EVENT_CLASS_PROCESS_LIFECYCLE = "process_lifecycle" MAX_TTL_SECONDS = 24 * 60 * 60 +# Enforcement tier values a health response's "enforcement_tier" field takes. +# Must track the EnforcementTier* constants in +# go/pkg/kernelcapture/daemon_protocol.go. +ENFORCEMENT_TIER_BPF_LSM = "bpf_lsm" +ENFORCEMENT_TIER_SECCOMP = "seccomp" +ENFORCEMENT_TIER_NONE = "none" + # Defaults mirror DaemonCustodyPlan.SocketPath in the Go daemon. Override with # ARDUR_KERNELCAPTURE_SOCKET for tests or a non-default deployment. DEFAULT_DAEMON_SOCKET = "/run/ardur/kernelcapture/control.sock" DAEMON_SOCKET_ENV = "ARDUR_KERNELCAPTURE_SOCKET" +# Default mirrors defaultSeccompSocketPath in +# go/cmd/ardur-kernelcaptured/main.go — the fd-handoff socket ardur-exec-shim +# connects to when the daemon's active tier is seccomp (plan E4). Override +# with ARDUR_KERNELCAPTURE_SECCOMP_SOCKET for tests or a non-default +# deployment, the same way DAEMON_SOCKET_ENV overrides the control socket. +DEFAULT_SECCOMP_SOCKET = "/run/ardur/kernelcapture/seccomp.sock" +SECCOMP_SOCKET_ENV = "ARDUR_KERNELCAPTURE_SECCOMP_SOCKET" + # cgroup v2 unified hierarchy root. Override with ARDUR_RUN_CGROUP_ROOT (used by # tests to point at a writable temp dir without root, and by deployments that # mount cgroupfs elsewhere). @@ -72,6 +88,14 @@ # Subdirectory under the cgroup root that holds Ardur's per-run cgroups. RUN_CGROUP_NAMESPACE = "ardur.run" +# ardur-exec-shim (plan E4's seccomp-tier on-ramp) resolution. ARDUR_EXEC_SHIM_PATH +# overrides discovery entirely (tests, non-default install layouts); otherwise +# this looks on PATH, then at the same install location the daemon itself +# uses in packaging/systemd/ardur-kernelcaptured.service. +EXEC_SHIM_BINARY_NAME = "ardur-exec-shim" +EXEC_SHIM_PATH_ENV = "ARDUR_EXEC_SHIM_PATH" +_WELL_KNOWN_EXEC_SHIM_PATHS = (Path("/usr/local/bin/ardur-exec-shim"),) + class DaemonProtocolError(RuntimeError): """Raised when the daemon rejects a request or replies malformed data.""" @@ -86,6 +110,17 @@ def daemon_socket_path() -> Path: return Path(os.environ.get(DAEMON_SOCKET_ENV, DEFAULT_DAEMON_SOCKET)).expanduser() +def seccomp_handoff_socket_path() -> Path: + """Resolve the daemon's seccomp user-notify fd-handoff socket path. + + This is a distinct socket from ``daemon_socket_path()`` — see + go/cmd/ardur-kernelcaptured/daemon_seccomp_linux.go's header comment for + why fd-passing needed its own listener rather than reusing the JSON-line + control socket. + """ + return Path(os.environ.get(SECCOMP_SOCKET_ENV, DEFAULT_SECCOMP_SOCKET)).expanduser() + + def daemon_available(socket_path: Path | None = None) -> bool: """Return True only when a Unix-socket file exists at the daemon path. @@ -100,6 +135,28 @@ def daemon_available(socket_path: Path | None = None) -> bool: return False +def exec_shim_path() -> Path | None: + """Locate the ``ardur-exec-shim`` binary (plan E4's seccomp-tier on-ramp). + + Returns ``None`` (never raises) when it cannot be found — the seccomp + tier then cannot be wired for this run; the caller (``run_bridge``) + decides whether that is a permissive degrade or a hard ``--enforce`` + abort. Resolution order: ``ARDUR_EXEC_SHIM_PATH`` override, ``PATH`` + lookup, then the well-known systemd-packaged install location. + """ + override = os.environ.get(EXEC_SHIM_PATH_ENV) + if override: + path = Path(override).expanduser() + return path if path.is_file() and os.access(path, os.X_OK) else None + which = shutil.which(EXEC_SHIM_BINARY_NAME) + if which: + return Path(which) + for candidate in _WELL_KNOWN_EXEC_SHIM_PATHS: + if candidate.is_file() and os.access(candidate, os.X_OK): + return candidate + return None + + class KernelCaptureClient: """Minimal JSON-line client for the kernelcapture daemon control socket.""" diff --git a/python/vibap/run_bridge.py b/python/vibap/run_bridge.py index 76e8518d..72f6cd28 100644 --- a/python/vibap/run_bridge.py +++ b/python/vibap/run_bridge.py @@ -36,6 +36,7 @@ import time import urllib.error import urllib.request +from contextlib import suppress from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -490,22 +491,155 @@ def _kernel_enforcement_claim( return enforcement +@dataclass +class SeccompShimPlan: + """Decision of whether/how to route the agent through ``ardur-exec-shim`` + (plan E4's seccomp user-notify on-ramp), made *before* the agent is + spawned so the wrapped launch command can be built in time. + + This exists because of issue #104: on a seccomp-only host (no ``bpf`` in + the boot ``lsm=`` list — the majority case), ``apply_policy`` succeeding + only proves the daemon's in-memory seccomp policy store is synced. It is + not proof that anything actually enforces it — that requires a real + ``ardur-exec-shim`` process to have installed a filter on the agent and + handed its listener off to the daemon. Deciding to wrap (or why not) has + to happen before ``subprocess.Popen`` — seccomp enforcement is + per-process (a filter the shim installs on itself before it execs into + the agent), unlike BPF-LSM's per-cgroup enforcement, so there is no way + to retroactively attach it to an already-running, unwrapped process. + """ + + tier: str | None + wrapped: bool + shim_path: Path | None = None + reason: str = "" + + +def _plan_seccomp_shim(*, enabled: bool) -> SeccompShimPlan: + """Detect the daemon's active enforcement tier and, when it is the + seccomp fallback, resolve ``ardur-exec-shim`` so the agent can be + wrapped with it. + + Never raises. Mirrors ``_correlate_launch``'s graceful-degradation + contract: any failure here (daemon absent, health call rejected, shim + binary missing) just means ``wrapped=False`` with a reason — the caller + (``_apply_kernel_policy``, after ``apply_policy`` actually runs) decides + whether that is a permissive degrade or an ``--enforce`` abort. + """ + if not enabled: + return SeccompShimPlan(tier=None, wrapped=False, reason="kernel correlation disabled by caller") + socket_path = kc.daemon_socket_path() + if not kc.daemon_available(socket_path): + return SeccompShimPlan(tier=None, wrapped=False, reason="kernelcapture daemon socket not present") + try: + response = kc.KernelCaptureClient(socket_path).health() + except (kc.DaemonUnavailable, kc.DaemonProtocolError, ValueError) as exc: + return SeccompShimPlan(tier=None, wrapped=False, reason=f"daemon health check failed: {exc}") + tier = response.get("enforcement_tier") or None + if tier != kc.ENFORCEMENT_TIER_SECCOMP: + return SeccompShimPlan(tier=tier, wrapped=False, reason=f"active enforcement tier is {tier!r}, no shim needed") + shim_path = kc.exec_shim_path() + if shim_path is None: + return SeccompShimPlan( + tier=tier, + wrapped=False, + reason="seccomp tier is active but the ardur-exec-shim binary was not found", + ) + return SeccompShimPlan(tier=tier, wrapped=True, shim_path=shim_path) + + +def _wrap_command_with_seccomp_shim( + command: list[str], *, session_id: str, shim_path: Path, ready_file: Path +) -> list[str]: + """Prepend an ``ardur-exec-shim`` invocation to ``command``. + + The shim installs the connect(2) filter, hands its listener off to the + daemon, then ``execve()``s into ``command`` — replacing itself, so the + governed process keeps the shim's PID (what cgroup adoption and + ``register_session``'s ``root_pid`` see downstream is unaffected by this + wrapping) and the filter carries over unchanged. + + ``--ready-file`` points the shim at a marker file this run creates right + after its own ``register_session`` call succeeds (see + ``run_governed``) — the shim waits for it before attempting its one-shot + daemon handoff, closing a real race caught only by running the actual + shim through an actual `ardur run` (issue #104's verification): the + handoff cannot be retried once the seccomp filter is installed, so + without this wait a slow ``register_session`` could permanently lose the + race. A first attempt at this used a daemon round trip instead of a + file — reverted because the daemon's session_status enforces exact-PID + peer ownership on the session record, which the shim (a different + process than whoever registered the session) can never satisfy. + """ + return [ + str(shim_path), + "--session-id", + session_id, + "--seccomp-socket", + str(kc.seccomp_handoff_socket_path()), + "--ready-file", + str(ready_file), + "--", + *command, + ] + + +# Module-level so tests can monkeypatch a short timeout rather than either +# waiting out the real budget or threading an override through +# run_governed's public signature for a purely internal verification detail. +# 5 seconds comfortably covers ardur-exec-shim's own handoff-dial retry +# budget (3 attempts, 500ms apart) plus process-startup and scheduling +# slack; it is not tuned to any tighter bound than that. +SECCOMP_LISTENER_VERIFY_TIMEOUT_S = 5.0 +SECCOMP_LISTENER_VERIFY_POLL_INTERVAL_S = 0.1 + + +def _verify_seccomp_listener_attached( + session_id: str, + *, + timeout_s: float | None = None, + poll_interval_s: float | None = None, +) -> bool: + """Poll the daemon until it reports ``session_id``'s seccomp listener + attached, or the timeout elapses. + + Bounded and best-effort: returns ``False`` (never raises) on timeout or + any daemon-communication failure. + """ + timeout_s = SECCOMP_LISTENER_VERIFY_TIMEOUT_S if timeout_s is None else timeout_s + poll_interval_s = SECCOMP_LISTENER_VERIFY_POLL_INTERVAL_S if poll_interval_s is None else poll_interval_s + deadline = time.time() + timeout_s + client = kc.KernelCaptureClient(kc.daemon_socket_path()) + while True: + try: + response = client.session_status(session_id=session_id) + except (kc.DaemonUnavailable, kc.DaemonProtocolError, ValueError): + return False + if response.get("seccomp_listener_attached") is True: + return True + if time.time() >= deadline: + return False + time.sleep(poll_interval_s) + + def _apply_kernel_policy( *, session_id: str, passport: "MissionPassport", correlation: kc.CorrelationResult, enforce: bool, + seccomp_plan: SeccompShimPlan, ) -> dict[str, Any]: """Lower the passport's policy and push it to the daemon's BPF maps. Loud-abort contract: when ``enforce`` is True, any failure to install kernel-level enforcement — the cgroup never got correlated with the - daemon, or the daemon rejected the plan — raises - :class:`KernelPolicyEnforcementError` so the caller aborts the run instead - of letting the agent proceed unguarded. When ``enforce`` is False the same - failures degrade to a recorded reason in the returned dict; the hook/proxy - path still governs the run. + daemon, the daemon rejected the plan, or (issue #104) the active tier is + seccomp and no ``ardur-exec-shim`` listener ever attached for this + session — raises :class:`KernelPolicyEnforcementError` so the caller + aborts the run instead of letting the agent proceed unguarded. When + ``enforce`` is False the same failures degrade to a recorded reason in + the returned dict; the hook/proxy path still governs the run. ``bpf_lower`` (and the mission compiler it builds on) is only imported once a live daemon correlation exists. That keeps the common degraded @@ -513,6 +647,12 @@ def _apply_kernel_policy( — free of the mission-compiler's optional dependencies (e.g. ``biscuit-python``, a ``[dev]`` extra) so a plain ``ardur run`` never pays for kernel-enforcement machinery it isn't using. + + ``seccomp_plan`` is threaded in from ``run_governed`` (it was resolved + before the agent was even spawned, so the launch command could be + wrapped in time — see :class:`SeccompShimPlan`) rather than re-detected + here, so the tier this function verifies against is exactly the one the + launch decision was actually made on. """ if not correlation.available: reason = f"kernel policy not applied: {correlation.reason}" @@ -550,6 +690,30 @@ def _apply_kernel_policy( raise KernelPolicyEnforcementError(reason) from exc return {"applied": False, "reason": reason, "tier2_ops": tier2_ops} + # Issue #104: apply_policy succeeding only proves the daemon's in-memory + # policy store is synced — on a seccomp-tier host that says nothing about + # whether anything actually enforces it. That requires a live + # ardur-exec-shim listener attached to *this* session, which is a + # separate, asynchronous handoff this function has no other visibility + # into. Without this check, a host where the shim silently failed to + # hand off (or was never invoked) would report "applied" while nothing + # wraps the agent — exactly the false-success this closes. BPF-LSM needs + # no equivalent check: correlation.available already proved cgroup + # adoption succeeded before this function was ever called, and cgroup + # membership *is* that tier's enforcement mechanism, not a separate + # asynchronous step. + if seccomp_plan.tier == kc.ENFORCEMENT_TIER_SECCOMP: + if not seccomp_plan.wrapped: + reason = f"seccomp tier active but not wired: {seccomp_plan.reason}" + if enforce: + raise KernelPolicyEnforcementError(reason) + return {"applied": False, "reason": reason, "tier2_ops": tier2_ops} + if not _verify_seccomp_listener_attached(session_id): + reason = "seccomp listener never attached (ardur-exec-shim handoff did not complete)" + if enforce: + raise KernelPolicyEnforcementError(reason) + return {"applied": False, "reason": reason, "tier2_ops": tier2_ops} + return { "applied": True, "reason": "kernel BPF policy installed", @@ -575,6 +739,7 @@ def run_governed( env: dict[str, str] | None = None, enable_kernel_correlation: bool = True, enforce: bool = False, + no_resource_scope: bool = False, cwd: Path | None = None, stdout: Any | None = None, stderr: Any | None = None, @@ -587,6 +752,18 @@ def run_governed( :class:`KernelPolicyEnforcementError` when ``enforce=True`` and kernel-level BPF policy enforcement could not be installed — the launched agent is killed before the error propagates. + + ``no_resource_scope`` skips the default cwd-based file resource_scope + (``path_allow``/``OP_FILE_READ``+``OP_FILE_WRITE``), which every mission + otherwise gets unconditionally. It exists because the seccomp fallback + tier (plan E4) can only ever enforce ``OP_NET_CONNECT`` — a mission that + also carries a file-scope dimension can never be "fully seccomp-coverable" + (see ``seccompFullyCoversPolicy`` in the daemon), so on a seccomp-only + host it always degrades/aborts under ``--enforce`` regardless of how + tightly the network side is scoped. Set this for a mission that is + genuinely network-only and needs the seccomp tier's real enforcement + rather than always taking that path. Leaving it False (the default) + preserves every existing caller's behavior unchanged. """ from .passport import MissionPassport, generate_keypair, issue_passport @@ -614,7 +791,7 @@ def run_governed( mission=mission_text, allowed_tools=list(allowed_tools or []), forbidden_tools=list(forbidden_tools or []), - resource_scope=[str(work_dir), f"{work_dir}/*"], + resource_scope=[] if no_resource_scope else [str(work_dir), f"{work_dir}/*"], cwd=str(work_dir), max_tool_calls=max_tool_calls, max_duration_s=max_duration_s, @@ -650,6 +827,7 @@ def run_governed( # Pre-initialized so the finally block has a safe value even if an # exception is raised before kernel correlation is attempted below. correlation = kc.CorrelationResult(available=False, reason="run did not reach kernel correlation") + seccomp_ready_file: Path | None = None try: _wait_for_health(proxy_url, api_token) @@ -671,6 +849,25 @@ def run_governed( ) notes.extend(adapter_notes) + # 3b. Detect the daemon's active enforcement tier and, if it's the + # seccomp fallback (issue #104), route the agent through + # ardur-exec-shim so a filter actually wraps it. Must happen before + # Popen below — see SeccompShimPlan's docstring for why this can't + # be done after the fact. + seccomp_plan = _plan_seccomp_shim(enabled=enable_kernel_correlation) + seccomp_ready_file = home / f"seccomp-ready-{session_id}" + if seccomp_plan.wrapped and seccomp_plan.shim_path is not None: + run_command = _wrap_command_with_seccomp_shim( + run_command, session_id=session_id, shim_path=seccomp_plan.shim_path, ready_file=seccomp_ready_file + ) + notes.append(f"seccomp enforcement tier active — agent launched via ardur-exec-shim ({seccomp_plan.shim_path})") + elif seccomp_plan.tier == kc.ENFORCEMENT_TIER_SECCOMP: + # Recorded now so it's visible even if the run never reaches + # _apply_kernel_policy's own (mission-content-gated) check of + # this same plan — e.g. a mission with no kernel-enforceable + # policy dimensions at all. + notes.append(f"seccomp tier active but not wired: {seccomp_plan.reason}") + # 4. Dedicated cgroup (Linux + cgroup v2 + writable), best effort. if enable_kernel_correlation: cgroup_handle = kc.create_run_cgroup(session_id) @@ -702,6 +899,15 @@ def run_governed( ) daemon_registered = correlation.available + # 5a. Signal ardur-exec-shim (if this run wrapped the agent with it) + # that register_session has landed — see _wrap_command_with_seccomp_shim's + # docstring for why this is a marker file rather than a daemon call + # or a signal. Only meaningful when the wrap actually happened; + # touching it otherwise would be inert but pointless. + if seccomp_plan.wrapped and correlation.available: + with suppress(OSError): + seccomp_ready_file.touch() + # 5b. Push the mission's lowered BPF policy to the daemon now that the # cgroup is registered. Under --enforce a failure here kills the agent # and aborts the run; under permissive it degrades to a recorded note. @@ -711,6 +917,7 @@ def run_governed( passport=passport, correlation=correlation, enforce=enforce, + seccomp_plan=seccomp_plan, ) except KernelPolicyEnforcementError as exc: notes.append(f"ENFORCE abort: {exc}") @@ -749,6 +956,9 @@ def run_governed( notes.append("kernel daemon end_session unavailable during local cleanup") if cgroup_handle is not None: cgroup_handle.cleanup() + if seccomp_ready_file is not None: + with suppress(OSError): + seccomp_ready_file.unlink() server.shutdown() server.server_close() @@ -898,6 +1108,7 @@ def run_governed_cli(args: Any) -> int: via=getattr(args, "via", None) or "auto", enable_kernel_correlation=not getattr(args, "no_kernel_correlation", False), enforce=bool(getattr(args, "enforce", False)), + no_resource_scope=bool(getattr(args, "no_resource_scope", False)), ) except NotImplementedError as exc: print(f"ardur run: {exc}", file=sys.stderr) From 2b0dcd11c8d888306958ac5183f78541793fd36e Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Tue, 7 Jul 2026 14:37:20 -0500 Subject: [PATCH 2/2] fix(demo): make run.sh OUT dir writable in virtme-ng (read-only rootfs) The kernel-smoke BPF-LSM full-CLI step (issue #104's ardur-run full-flow e2e, BPF-LSM half) boots the host rootfs read-only under virtme-ng, so run.sh's hardcoded OUT=/out failed at mkdir ('Read-only file system'), the daemon log couldn't be written, and the run aborted. Honor an OUT_BASE env (default /out for the Docker demo, unchanged); ci-vng-enforce.sh ensures /tmp is a writable tmpfs and points OUT_BASE at a fresh dir under it. Only affects run.sh (the BPF-LSM demo); run-seccomp.sh (the already-green ardur-run-e2e-seccomp job) is untouched. --- docs/demo/enforce-e2e/ci-vng-enforce.sh | 12 ++++++++++++ docs/demo/enforce-e2e/run.sh | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/demo/enforce-e2e/ci-vng-enforce.sh b/docs/demo/enforce-e2e/ci-vng-enforce.sh index a5cbbdd3..790a082e 100755 --- a/docs/demo/enforce-e2e/ci-vng-enforce.sh +++ b/docs/demo/enforce-e2e/ci-vng-enforce.sh @@ -4,4 +4,16 @@ # invocation this sits alongside), so this just hardcodes run.sh's mode # argument rather than needing one. set -eu + +# virtme-ng boots the host rootfs read-only, so run.sh's default OUT=/out +# (mkdir -p) fails with "Read-only file system". Point it at a writable tmpfs. +# Ensure /tmp is writable first (a minimal guest may leave it on the read-only +# rootfs), then hand run.sh a fresh dir under it via OUT_BASE. +if ! touch /tmp/.ardur-write-test 2>/dev/null; then + mount -t tmpfs tmpfs /tmp +fi +rm -f /tmp/.ardur-write-test 2>/dev/null || true +OUT_BASE="$(mktemp -d /tmp/ardur-demo-out.XXXXXX)" +export OUT_BASE + exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/run.sh" enforce diff --git a/docs/demo/enforce-e2e/run.sh b/docs/demo/enforce-e2e/run.sh index fb9917ff..81aff55c 100755 --- a/docs/demo/enforce-e2e/run.sh +++ b/docs/demo/enforce-e2e/run.sh @@ -4,7 +4,11 @@ # Usage: run.sh set -u MODE="${1:-enforce}" -OUT="/out/${MODE}"; mkdir -p "$OUT" +# OUT_BASE defaults to /out (the Docker demo image mounts a writable /out). +# virtme-ng boots the host rootfs read-only, so the vng wrapper +# (ci-vng-enforce.sh) points this at a writable tmpfs instead. +OUT_BASE="${OUT_BASE:-/out}" +OUT="${OUT_BASE}/${MODE}"; mkdir -p "$OUT" DEMO_DIR="$(cd "$(dirname "$0")" && pwd)" echo "================ ardur run BPF-LSM demo — mode=${MODE} ================"