Skip to content

feat(run): zero-setup governance bridge for launched agents (Epic A) - #81

Merged
gnanirahulnutakki merged 3 commits into
devfrom
feat/A6-run-governance-bridge
Jul 1, 2026
Merged

feat(run): zero-setup governance bridge for launched agents (Epic A)#81
gnanirahulnutakki merged 3 commits into
devfrom
feat/A6-run-governance-bridge

Conversation

@gnanirahulnutakki

Copy link
Copy Markdown
Member

The bridge layer: eBPF detection → real auto-governance

This wires Ardur's proven detection and governance halves together so a single
command governs an agent with zero manual per-project setup. Today the eBPF
detector (go/pkg/kernelcapture/) sees an agent launch but nothing routes that
process's tool calls through the governance proxy (python/vibap/proxy.py), and
governance is opt-in via ardur protect writing per-project hooks. This PR
turns ardur run into the launcher that closes that gap.

Refs Epic A (#63), #66 (daemon/launcher), #69 (wire governance to
auto-detected agents).

What works now (Slice 1)

ardur run --mission "..." --allowed-tools Read,Glob --max-tool-calls N -- <agent-cmd...>:

  1. Issues a Mission Passport + starts a governance session in a private,
    ephemeral Ardur home (keys + state + active_mission.jwt). No prior
    ardur protect.
  2. Launches the agent with the environment that routes its tool-call
    governance to this session, via an AgentAdapter:
    • EnvProxyAdapter (generic / default): exports
      ARDUR_PROXY_URL / ARDUR_API_TOKEN / ARDUR_SESSION_ID so a cooperating
      agent POSTs each tool call to the session's /evaluate.
    • ClaudeCodeAdapter: points Claude Code's hook at this run via VIBAP_HOME
      • a scoped --plugin-dir — run-scoped, temporary, no permanent edit
        to ~/.claude/settings.json
        .
  3. Correlates detect→session: on Linux it creates a dedicated cgroup v2 for
    the agent and registers (session_id, root_pid, cgroup_id) with the eBPF
    daemon over its Unix-socket control plane (kernelcapture.daemon.v1). The
    cgroup id is the cgroup directory inode — exactly what
    bpf_get_current_cgroup_id() returns — so it correlates 1:1 with kernel
    events. When cgroup v2 or the daemon is unavailable (e.g. macOS, unprivileged
    host), it degrades gracefully and still governs via the env/hook path.
  4. On agent exit, finalizes the session into a behavioral attestation +
    signed receipt chain and prints a short governance summary
    (permits/denials, attestation digest, receipt count, kernel-link status).
── Ardur governance summary ─────────────────────────────
  session       9062175d-b062-468c-a691-99ef3872f8d9
  mission_id    mission:local-user:ardur-run:6d7028efd49d
  adapter       env-proxy (--via env)
  tool calls    3 evaluated (2 permit / 1 deny)
  receipts      3 signed → .../receipts_log.jsonl
  attestation   sha-256:d11953b3e862cd…
  kernel link   cgroup v2 unavailable or not writable (governing via env/hook only)
  agent exit    0

Tests

python/tests/test_run_bridge.py — integration test: ardur runs a stand-in
agent that makes a PERMIT-able (Read) and a DENY-able (Bash) tool call and
asserts, with zero ardur protect setup:

  • a session started;
  • 3 calls were evaluated (2 PERMIT / 1 DENY);
  • a signed receipt chain was produced and verifies cryptographically
    (verify_chain);
  • a behavioral attestation was issued and verifies (verify_attestation),
    with permits/denials matching;
  • nothing was written to ~/.claude/settings.json;
  • kernel correlation degraded gracefully.

Plus adapter unit tests and python/tests/test_kernel_correlation.py (cgroup
helpers against a temp-dir fake root; the daemon client against a fake AF_UNIX
server; input validation; graceful-degradation paths).

Results: full Python suite 1036 passed / 32 skipped; Go kernelcapture and
go build ./... green. New files are ruff-clean.

Design notes (decisive, pragmatic)

  • The bridge runs a small embedded loopback HTTP server that delegates to the
    real GovernanceProxy (evaluate / result / end / attest). It reuses the real
    evaluation, receipt-signing, and attestation logic; it is cleanly stoppable so
    it does not outlive a run (important under tests). It does not fork the
    big serve_proxy.
  • ardur run's legacy Ardur-Personal-Hub streaming path is preserved and
    selected when no governance flag is present; governance flags switch it to the
    bridge. Existing run_under_hub tests stay green.
  • The kernelcapture daemon has no Go client helper; the launcher (Python) speaks
    its documented JSON-line protocol directly. A native Go launcher remains a
    possible future.

Scaffold only / remaining gap (the honest part)

  • Transparent intercept for non-hook agents (Grok / Kimi / arbitrary CLIs)
    is TransparentInterceptAdapter — a fixed interface + clear TODO, not
    implemented
    . The intended mechanism is an iptables REDIRECT (Linux) or an
    LD_PRELOAD / proxy-env egress shim routing the agent's API traffic through
    the governance proxy. Follow-up: [A] Wire the full governance cycle to auto-detected agents #69.
  • Kernel correlation is exercised on Linux with a live daemon; the verified
    paths here are the protocol client, the cgroup helpers (temp-root), and the
    graceful-degradation branches (the bridge ran end-to-end on macOS).
  • For Claude Code, governance evidence today is the hook's in-process receipt
    chain (under the run's VIBAP_HOME); unifying the hook to also drive the
    embedded proxy session's /evaluate is a natural follow-up.

Opening as draft — not for merge.

Python client for the Go kernelcapture daemon's Unix-socket control plane
(register_session / end_session / health), speaking the JSON-line
kernelcapture.daemon.v1 protocol, plus cgroup v2 helpers that create a
per-run cgroup and report its inode-based cgroup id (the value
bpf_get_current_cgroup_id returns). Everything degrades gracefully: no
daemon, no cgroup v2, or an unprivileged launcher all return
unavailable/None instead of raising, so the launcher can still govern via
the env/hook path.

Refs Epic A (#63), task #66.
Extend 'ardur run' so that, with --mission/--allowed-tools/--max-tool-calls,
it issues a Mission Passport, starts a governance session, and launches the
agent governed end to end — no prior 'ardur protect', no permanent edit to
~/.claude/settings.json.

- run_bridge.run_governed(): ephemeral Ardur home (keys + state + active
  passport) -> embedded loopback GovernanceProxy + session -> launch agent
  via an AgentAdapter -> best-effort cgroup + eBPF daemon correlation ->
  finalize into a behavioral attestation + signed receipt chain -> print a
  governance summary.
- AgentAdapters: EnvProxyAdapter (generic, routes tool calls to the session
  via ARDUR_PROXY_URL/ARDUR_API_TOKEN/ARDUR_SESSION_ID), ClaudeCodeAdapter
  (points the hook at the run via VIBAP_HOME + a scoped --plugin-dir, no
  settings.json edit), and TransparentInterceptAdapter (SCAFFOLD only for
  non-hook CLIs — iptables/LD_PRELOAD egress shim, issue #69).
- cli: governance flags on the 'run' subparser; legacy hub-streaming path is
  preserved and selected when no governance flag is present.
- Integration test launches a stand-in agent making a PERMIT-able and a
  DENY-able call and asserts a started session, evaluated calls, a
  cryptographically verifiable signed receipt chain, and a verifiable
  behavioral attestation — with zero 'ardur protect' setup.

Refs Epic A (#63), tasks #66 and #69.
server_thread = threading.Thread(target=server.serve_forever, name="ardur-run-proxy", daemon=True)
server_thread.start()

correlation = kc.CorrelationResult(available=False, reason="not attempted")
"""
try:
self.path.rmdir()
except OSError:
passport_path.write_text(token + "\n", encoding="utf-8")
try:
passport_path.chmod(0o600)
except OSError:
kc.KernelCaptureClient(kc.daemon_socket_path()).end_session(
session_id=session_id, trace_id=trace_id
)
except (kc.DaemonUnavailable, kc.DaemonProtocolError):
ClaudeCodeAdapter's docstring omitted a key architectural fact: the Claude
Code hook evaluates tool calls locally via the plugin mechanism and never
POSTs to ARDUR_PROXY_URL/evaluate. The embedded proxy therefore observes 0
events on the claude-code path, making result.total_events misleadingly
empty while governance is still active through the hook.

Document the gap explicitly in the class docstring and add a test that
pins result.total_events == 0 on the claude-code path. The test will need
updating once the hook is wired to also report to the embedded proxy (Epic A #63).
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.

2 participants