feat(groom): isolated netns sandbox — drop --share-net, broker over a bind-mounted unix socket (BE-4421)#73
Conversation
… bind-mounted unix socket (BE-4421) Removes network egress from the groom agent jail (no host, no host loopback, no cloud metadata) while keeping the key-broker reachable via a unix-domain socket bind-mounted at /run/broker.sock plus an in-jail TCP->UDS forwarder. Design from spike BE-4369 (namespace isolation by construction). - broker.mjs: accept a unix-socket path (absolute) in addition to the legacy numeric TCP port; clear a stale socket, refuse a relative path. Request handling is transport-independent and unchanged. - jail-shim.mjs (new): ~20-line node-stdlib TCP->UDS forwarder run inside the jail so HTTP tooling reaches the bind-mounted socket. - agent-sandbox.sh: drop --share-net from both bwrap invocations; add --uds (absolute + listening-socket validated) that rw-binds the socket file at the fixed in-jail path /run/broker.sock. - tests: rework the broker-proxy section onto the UDS+shim path and add the BE-4369 egress-isolation proofs (host loopback, cloud metadata, arbitrary external IP all unreachable; --uds fail-loud). - README: document the isolated-netns contract, UDS mode, the shim, and the offline consequences (set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1; git fetch/npm install cannot work in-jail).
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 8 finding(s).
| Severity | Count |
|---|---|
| 🟡 Medium | 3 |
| 🟢 Low | 4 |
| ⚪ Nit | 1 |
Panel: 8/8 reviewers contributed findings.
…4421) Address the cursor-review panel on PR #73: - broker.mjs: replace unconditional rmSync(force) at the socket path with a stat-first unlink that fails loud on a non-socket (no EISDIR crash, no silent delete of a regular file); chmod the UDS to 0600 after bind (umask-independent owner-only); add a server 'error' handler so a listen failure exits clean. - jail-shim.mjs: log the startup banner to stderr (stdout is captured as the agent's JSON) and add a server 'error' handler. - agent-sandbox.sh: reject an empty --uds value; probe /healthz over the socket (best-effort, curl-gated) to confirm a LIVE broker, not just a socket inode; bind the broker socket --ro-bind (connect(2) to a socket works read-only — sb_permission EROFS excludes sockets — while removing the agent's chmod). - sandbox-tests.sh: assert curl exists in the jail before the 7a/7b/7c egress checks so a missing tool can't false-pass them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed all 8 cursor-review panel findings in dc20444 (2 medium chmod/stdout, 1 medium rmSync, 5 low/nit):
The |
ELI-5
The groom auto-builder runs an untrusted agent in a locked box. Until now the box still had a working network cable, so a misbehaving agent could in principle phone home, hit the runner's own services, or read cloud metadata. This change cuts the cable entirely: the box gets its own empty network with only a loopback wire. The one thing the agent still needs — talking to Anthropic through the key-broker — comes in through a tiny door (a unix socket) we mount into the box, with a little in-box translator (
jail-shim.mjs) so ordinary HTTP tooling can use it. Everything else off-box is now unreachable by construction, and we prove it with tests that show cloud metadata, an arbitrary internet IP, and the host's own loopback all fail to connect from inside.What & why
Phase 2 of the groom sandbox hardening (design from spike BE-4369: namespace isolation by construction — option b; slirp4netns/nftables/broker-in-jail alternatives were rejected). Removes
--share-netso the jail runs in an isolated network namespace with onlyloup — no external hosts, no host-loopback services, no cloud metadata (169.254.169.254 / 168.63.129.16) — while keeping the key-broker reachable over a unix-domain socket bind-mounted into the jail.broker.mjs— now accepts a unix-socket path (absolute) asargv[2]in addition to the legacy numeric TCP port. UDS mode clears a stale socket thenserver.listen(path); a relative path is refused loud. The request-handling contract (strip/inject key,/v1/*only,/healthz, SSE, crash-proofing) is transport-independent and unchanged.jail-shim.mjs(new, ~20 lines, node stdlib) — a TCP→UDS forwarder run inside the jail (node jail-shim.mjs <port> /run/broker.sock) so agent tooling speaking HTTP to a127.0.0.1:<port>base URL reaches the bind-mounted socket (an isolated netns can't dial a host TCP port).agent-sandbox.sh— drops--share-netfrom both bwrap invocations (selftest+bwrap_args); adds--uds <host-socket-path>(at most once, absolute, must already be a listening socket) that rw-binds the socket file at the fixed in-jail path/run/broker.sock(connect(2)needs write on the inode;--ro-bindwould EROFS). Omit--udsfor a fully-offline jail.tests/sandbox-tests.sh— section 5 reworked onto the UDS+shim path (broker listens on a socket; in-jail shim bridges curl to it; all existing assertions unchanged). New section 7 = the BE-4369 acceptance proofs: host loopback, cloud metadata, and an arbitrary external IP (1.1.1.1) are all unreachable from the jail, plus--udsfail-loud on a nonexistent socket.README.md— isolated-netns contract row, UDS mode + shim, and the offline consequences (setCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1; in-jailgit fetch/npm installcannot work — everything must be in the clone before sandboxing).Testing
shellcheck -x .github/groom/agent-sandbox.sh .github/groom/tests/sandbox-tests.sh— clean.--share-netanywhere under.github/groom/.python3 -m unittest(groom ledger, untouched) — 48 passing (sanity).bash sandbox-tests.shrequires Linux + unprivileged user namespaces (bubblewrap), so — exactly like the phase-1 BE-4302 suite — it runs on thesandbox-testsCI job onubuntu-latest, not on the macOS dev host. To de-risk the transport-independent JS on the host I ran a smoke test proving the real composition end-to-end: broker on a UDS +jail-shim.mjsbridging TCP→UDS → key injected, caller dummy stripped,/healthz200, non-/v1404, SSE frames +[DONE]stream through; plus TCP back-compat, relative-path refusal, andjail-shimarg validation all behave. The netns egress assertions rest on the spike-verified facts (bubblewrap brings uploin the fresh netns; empty-netns connects fail immediateENETUNREACH).Negative-claim falsification (per the coder self-review)
This change's user-facing outcome denies a capability (network egress from the jail) — the trigger for the falsification check. It is not the #581 anti-pattern: cutting egress is the deliberate BE-4369 security goal, and the one capability the agent legitimately needs — reaching Anthropic — is preserved and empirically proven working via the sanctioned UDS+shim path (see the host smoke above: real key forwarded, SSE intact). The "unreachable" assertions target the security boundary (metadata / external IP / host loopback), which is deterministic by netns construction, not a mistaken claim that a needed feature is unavailable.
Out of scope
The groom caller workflow (epic BE-3870) that composes broker + shim + agent in production doesn't exist yet; this PR defines and test-proves the composition pattern the caller will copy.