Skip to content

feat(groom): bwrap sandbox harness + key-broker with deterministic CI proofs (BE-4302)#66

Open
mattmillerai wants to merge 5 commits into
mainfrom
matt/be-4302-groom-sandbox
Open

feat(groom): bwrap sandbox harness + key-broker with deterministic CI proofs (BE-4302)#66
mattmillerai wants to merge 5 commits into
mainfrom
matt/be-4302-groom-sandbox

Conversation

@mattmillerai

@mattmillerai mattmillerai commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

ELI-5

The groom auto-builder lets an AI agent write code changes. We don't fully trust
that agent, so before phase 2 wires it up, this PR builds the cage it will run
in and a safe mail slot for its one allowed outside call:

  • The cage (agent-sandbox.sh): the agent gets a shell where it can only see
    a read-only copy of the system, a scratch tmpfs, its own repo checkout, and one
    folder it's allowed to write to. It cannot see the runner's other files, secrets,
    or even the other running processes. If the cage can't be built, we refuse to
    run the agent at all
    — we never quietly let it out.
  • The mail slot (broker.mjs): the agent has no API key. It talks to a tiny
    proxy on localhost that holds the real key, swaps it in, and forwards only to
    Anthropic. The key lives on the host; the agent can spend it but never read it.

Plus a CI job that proves all of this with plain bash commands — no claude,
no API key, no spend. Nothing about groom's actual agent steps changes here, so
it can merge on its own.

What's in this PR

File What it is
.github/groom/agent-sandbox.sh bwrap wrapper — jails an arbitrary command; loud fail-closed preflight
.github/groom/broker.mjs zero-dep node reverse proxy that injects the real key and strips inbound ones
.github/groom/tests/sandbox-tests.sh deterministic confinement + broker proofs (no spend)
.github/groom/tests/fake-upstream.mjs local HTTPS stand-in for api.anthropic.com in the broker test
.github/workflows/test-groom-scripts.yml new sandbox-tests job (ubuntu-latest)
.github/groom/README.md sandbox contract + loud-preflight guarantee

The wrapper matches the ticket's interface and bwrap invocation exactly
(--unshare-all --share-net --die-with-parent --new-session --clearenv, the base
mounts, HOME/PATH/TERM + each --env, the clone at its real path ro or
rw-git-ro, ro-files, the rw out-dir, --chdir <clone>). The broker holds the key
host-side, deletes inbound x-api-key/authorization, forwards only /v1/*,
answers /healthz locally, 404s everything else, streams responses unbuffered,
and logs method + path + status only.

Acceptance criteria

  • sandbox-tests job on ubuntu-latest — asserts env scrub (FOO/HOME/PATH
    present, a host HOSTSECRET canary absent), FS confinement (host $HOME /
    $RUNNER_TEMP / $GITHUB_WORKSPACE canaries unreadable; writes fail except the
    out-dir; host /tmp/canary shadowed by tmpfs), both clone modes (ro write
    fails; rw-git-ro worktree write succeeds and is visible on the host while
    .git/config write fails), pid isolation (a host sleep's pid absent from
    /proc), and the full broker contract (real key forwarded + dummy stripped,
    /healthz 200, non-/v1 404, SSE streams intact).
  • shellcheck clean on agent-sandbox.sh (verified locally; also gated in
    the new job, alongside sandbox-tests.sh).
  • README section under the groom docs — the sandbox contract table + the
    loud-preflight guarantee.
  • No changes to groom.yml agent stepsgroom.yml is untouched.

Judgment calls (please read)

1. The sandbox-tests job lives in test-groom-scripts.yml, not ci-groom.yml.
The ticket said extend ci-groom.yml, but ci-groom.yml is the scheduled groom
caller that deliberately never fires on a PR (its header + AGENTS.md: "runs
weekly on a schedule … never on-PR") and spends API. A deterministic, no-spend job
placed there could never be seen green on this PR — which is the acceptance
criterion. test-groom-scripts.yml is the repo's PR-gating, path-filtered
(.github/groom/**) home for groom's deterministic script tests, so the job runs
on this very change. The job is named sandbox-tests exactly as specified. This
felt clearly right, but flagging it since it deviates from the literal wording.

2. Verification status — proven both locally and in CI. I can't run
unprivileged-userns bwrap on the dev host (macOS), so I validated the full driver
end-to-end in a privileged ubuntu:24.04 container (all five property groups +
the broker proxy pass, shellcheck clean) and the sandbox-tests CI job is now
green on ubuntu-latest — every PASS: line plus ALL SANDBOX TESTS PASSED.
The broker layer was additionally smoke-tested standalone on the host (key
injection + dummy strip, /healthz 200, non-/v1 404, SSE pass-through).

3. Fail-closed refusal is intentional, not a capability dead-end. The preflight
ends in ::error::bwrap sandbox unavailable … refusing to run the agent unsandboxed + non-zero exit. That refusal fires only after the preflight has
exhausted every path to establish the sandbox (install bubblewrap, install the
AppArmor unconfined profile mirroring the runner image's podman workaround, drop
kernel.apparmor_restrict_unprivileged_userns, and retest). The ticket references
the runner image's own userns support, and the sandbox-tests job empirically
confirms the sandbox comes up on ubuntu-latest. This is a deliberate
security-control fail-closed, not a denial of a reachable capability.

4. NODE_TLS_REJECT_UNAUTHORIZED=0 is test-only. It's set on the test broker
process's env so it accepts the fake upstream's self-signed cert — broker.mjs
itself has no TLS bypass; production talks real TLS to api.anthropic.com. The
sk-ant-TESTFAKE-… key passed via --env in the broker test is a throwaway
literal used to prove forwarding; the real-key-never-in-jail property is a
production concern the test doesn't (and shouldn't) touch.

5. Preflight matches the ticket verbatim (sudo apt-get install -y bubblewrap
with no apt-get update). bubblewrap is present on the ubuntu-latest image and the
fast-path skips the install when the sandbox already works, so this is a no-op in
practice; kept literal to match the spec.

… proofs (BE-4302)

Phase 1 of the groom auto-builder sandbox: trusted assets that confine the
untrusted agent step, plus a no-API-spend CI job proving every confinement
property. No changes to groom.yml's agent steps (that is phase 2), so this is
mergeable independently of PR #65.

- .github/groom/agent-sandbox.sh — bwrap wrapper (fail-loud preflight; never
  runs the command unsandboxed) that jails an arbitrary command with a read-only
  /usr+/etc, tmpfs /tmp+HOME, the clone bound ro or rw-git-ro, explicit ro-files,
  one writable out-dir, a cleared env, and its own pid namespace.
- .github/groom/broker.mjs — zero-dep node reverse proxy that holds the real
  ANTHROPIC_API_KEY on the host, strips inbound credentials, injects the real
  key, forwards only /v1/* to api.anthropic.com, and streams responses (SSE).
- sandbox-tests job in test-groom-scripts.yml (the PR-gating test workflow) +
  tests/fake-upstream.mjs: asserts env scrub, FS confinement + tmpfs shadowing,
  both clone modes, pid isolation, and the broker contract — no claude, no key.
- README: sandbox contract + loud-preflight guarantee under the groom docs.
@mattmillerai mattmillerai added agent-coded Authored by the agent-work loop cursor-review Multi-model cursor review labels Jul 24, 2026
@mattmillerai
mattmillerai marked this pull request as ready for review July 24, 2026 05:52
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e49f31be-5070-4b6f-bfc9-e656b864ec81

📥 Commits

Reviewing files that changed from the base of the PR and between 8ca79f3 and 7942e58.

📒 Files selected for processing (3)
  • .github/groom/agent-sandbox.sh
  • .github/groom/broker.mjs
  • .github/groom/tests/sandbox-tests.sh
📝 Walkthrough

Walkthrough

Adds a Bubblewrap-based agent sandbox with mandatory preflight checks, a loopback credential broker for Anthropic requests, deterministic confinement and proxy tests, documentation, and CI validation.

Changes

Agent sandbox and broker

Layer / File(s) Summary
Sandbox runtime and confinement contract
.github/groom/agent-sandbox.sh, .github/groom/README.md
Adds mandatory sandbox verification, isolated filesystem and process views, clone binding modes, environment controls, read-only files, and writable output mounting.
Credential broker forwarding
.github/groom/broker.mjs, .github/groom/README.md
Adds loopback /v1/* proxying with host-only API-key injection, inbound credential stripping, health checks, 404 handling, upstream error mapping, and SSE passthrough.
Contract verification and CI wiring
.github/groom/tests/*, .github/workflows/test-groom-scripts.yml, .github/groom/README.md
Adds a fake HTTPS upstream, confinement and broker assertions, test instructions, ShellCheck execution, and a dedicated workflow job.

Sequence Diagram(s)

sequenceDiagram
  participant Agent
  participant agent-sandbox.sh
  participant broker.mjs
  participant AnthropicEndpoint
  Agent->>agent-sandbox.sh: submit command
  agent-sandbox.sh->>agent-sandbox.sh: verify and create Bubblewrap jail
  agent-sandbox.sh->>broker.mjs: send API request from jail
  broker.mjs->>AnthropicEndpoint: forward /v1/* with host credential
  AnthropicEndpoint-->>broker.mjs: stream response
  broker.mjs-->>Agent: return streamed response
Loading
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch matt/be-4302-groom-sandbox
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch matt/be-4302-groom-sandbox

Comment @coderabbitai help to get the list of available commands.

… jail)

The `< /proc/self/environ` redirect resolves /proc/self in the pre-exec forked
shell and reads empty inside the bwrap jail, so the env-scrub assertion saw no
FOO. Reading it with `cat` (which opens post-exec, /proc/self = cat) returns the
real environ. Verified end-to-end in a privileged ubuntu:24.04 container: all
sandbox assertions + broker proxy pass, shellcheck clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/groom/agent-sandbox.sh:
- Around line 150-157: Update the rw-git-ro branch in the sandbox argument
construction to detect whether $clone/.git is a directory or a gitdir pointer
file. Preserve the read-only protection for directory-based metadata, and
explicitly handle worktree checkouts by exposing the pointed-to metadata inside
the jail with read-only access so git operations continue working without
permitting history or configuration writes.
- Around line 104-116: Validate that --clone, --out-dir, and every --ro-file
value are absolute paths before preflight or any bubblewrap bind construction.
Add the checks alongside the existing argument validation in the script’s main
validation flow, rejecting relative values with clear errors while preserving
the current directory and mode checks.

In @.github/groom/broker.mjs:
- Around line 60-76: Update the proxy handler around the upstream request and
response piping to handle upstream response errors, client request abort/close
events, and response cleanup without crashing or leaving streams active. Add an
appropriate upstream timeout and ensure all failure or disconnect paths
destroy/close the corresponding request and response streams, while preserving
normal streaming through upRes.pipe(res) and the existing 502 response behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: eb9f256e-9e2d-4815-8f5f-a24d80877aa6

📥 Commits

Reviewing files that changed from the base of the PR and between fd35bf2 and 8ca79f3.

📒 Files selected for processing (6)
  • .github/groom/README.md
  • .github/groom/agent-sandbox.sh
  • .github/groom/broker.mjs
  • .github/groom/tests/fake-upstream.mjs
  • .github/groom/tests/sandbox-tests.sh
  • .github/workflows/test-groom-scripts.yml

Comment thread .github/groom/agent-sandbox.sh
Comment thread .github/groom/agent-sandbox.sh
Comment thread .github/groom/broker.mjs
… (BE-4302)

Address CodeRabbit review on PR #66:

- agent-sandbox.sh: require --clone/--out-dir/--ro-file to be absolute paths
  (bwrap binds them at their real path; a relative value would resolve against
  an unexpected CWD instead of failing loud, per the script's fail-closed design).
- agent-sandbox.sh: rw-git-ro now fails loud when .git is a gitdir pointer file
  (git-worktree checkout) rather than a directory — the pointed-to metadata
  isn't mounted, which would silently break git instead of protecting it.
- broker.mjs: handle upstream-response stream errors (previously unhandled →
  crashed the broker mid-stream), tear down on client abort/close, and add a
  120s upstream idle timeout so a hung upstream can't pin a request open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 10 finding(s).

Severity Count
🟠 High 2
🟡 Medium 4
🟢 Low 4

Panel: 8/8 reviewers contributed findings.

Comment thread .github/groom/agent-sandbox.sh
Comment thread .github/groom/broker.mjs
Comment thread .github/groom/broker.mjs Outdated
Comment thread .github/groom/broker.mjs
Comment thread .github/groom/agent-sandbox.sh
Comment thread .github/groom/agent-sandbox.sh
Comment thread .github/groom/broker.mjs
Comment thread .github/groom/agent-sandbox.sh
Comment thread .github/groom/broker.mjs Outdated
Comment thread .github/groom/broker.mjs
Addresses the still-valid cursor-review panel findings on top of 3ed4386:

- broker: reject dot-segment paths (`/v1/../v2/...`) that pass a bare `/v1/`
  prefix check but normalize off /v1 upstream — fail closed.
- broker: on an upstream error AFTER the response began streaming, tear the
  response down instead of splicing 'upstream error' bytes into the SSE body.
- broker: add a `res.on('error')` handler — `.pipe()` installs none, so a client
  socket error mid-stream would crash the whole broker via uncaughtException.
- agent-sandbox: reject an `--out-dir` that overlaps `--clone` (equal / nested /
  ancestor). It is bound rw LAST, so bwrap's last-wins ordering would otherwise
  shadow the read-only clone/.git mounts and silently un-protect them.

Tests: overlap rejection (3 cases), dot-segment 404, and broker crash-resilience
on a mid-stream client disconnect added to sandbox-tests.sh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mattmillerai

Copy link
Copy Markdown
Contributor Author

🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:

  • BE-4369 — Restrict groom sandbox agent egress to the broker only (drop --share-net)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-coded Authored by the agent-work loop cursor-review Multi-model cursor review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants