Commit 23c7b69
feat(groom): hoist agent-sandbox pre-exec validation out of the billed agent step (#301)
* feat(groom): run the finder/verifier/builder agents inside the sandbox + key broker
Wire the three groom agent phases onto the phase-1/2 confinement harness
(`agent-sandbox.sh` + `broker.mjs` + `jail-shim.mjs`), moving the real
ANTHROPIC_API_KEY out of every agent step and pruning the mitigations the jail
now subsumes. This jail is the gate that had been blocking groom on
untrusted-contributor repos.
Per agent job (audit_find, audit_verify, each build matrix cell):
- New "Start the key broker" step — the ONLY agent-facing step holding
`secrets.ANTHROPIC_API_KEY`. Starts `broker.mjs` on a host unix socket
($BROKER_SOCK), polls /healthz (fails after ~10s), records the pid for an
always() cleanup step.
- The agent step ("Run finder/verifier/builder") drops the key entirely and runs
`agent-sandbox.sh` (clone `ro` for finder/verifier, `rw-git-ro` for the
builder). A `bash -c` wrapper brings up the in-jail `jail-shim.mjs` TCP->UDS
forwarder, then execs the pinned `claude` CLI with a DUMMY key and
ANTHROPIC_BASE_URL pointed at the shim; the broker injects the real key. The
exec JSON is captured by a host-side stdout redirect, out of the agent's reach.
- Output files move under a single `GROOM_OUT_DIR` (/tmp/groom-out) — the jail's
one rw `--out-dir`; briefs and the finding JSON stay at /tmp and are passed
`--ro-file`. The verifier's finder-candidates download follows into GROOM_OUT_DIR.
- Removed as subsumed: the `chmod -R a-w` clone/.git lock+unlock dance, the
`env -u GITHUB_*`/RUNNER_TEMP prefix (--clearenv covers it), and the `rm -rf`
diag pre-delete (host /tmp is a shadowed tmpfs inside the jail).
- Kept as regression tripwires: the literal-key pre-publish scans (now their own
finder/verifier steps holding the key with no agent present; the builder's stays
in Capture patch), the type-guarded diag projection, and the CLI pin/flags.
Adds a text-based regression guard (test_environment_binding.py) asserting the
real key appears in no agent step and the agent step runs inside the sandbox with
the dummy key, mirroring the existing bot-App-key boundary test.
The wiring follows the canonical composition already proven by sandbox-tests.sh
section 5 (broker on UDS + agent-sandbox --uds + in-jail jail-shim), not the
earlier TCP-port design.
* fix(groom): address cursor-review panel findings on the sandbox+broker wiring
Review feedback on PR #293 (BE-4303):
- agent-sandbox.sh preflight now writes apt-get/apparmor_parser/sysctl/::error::
to stderr, not stdout: the caller captures this script's stdout as the agent
exec JSON, so preflight chatter was prepended to it and broke the redact step's
`jq -e .` guard, silently skipping the diagnostics artifact. (High)
- The in-jail shim readiness loop now records a success flag and fails loud
(to stderr) instead of falling through to `exec claude` against a dead
ANTHROPIC_BASE_URL. Mirrors sandbox-tests.sh §5. All three agent legs. (Medium)
- The verifier's finder-candidate input is downloaded to a bare /tmp path OUTSIDE
$GROOM_OUT_DIR and handed in `--ro-file` ($FINDER_IN), so a prompt-injected
verifier can no longer rewrite the candidates it adjudicates — mirrors the
builder's $FINDING_IN and honours the read-only-input invariant. (Medium)
- Capture patch guards against a symlink planted at $BUILDER_OUT/$PR_BODY_OUT on
the one host-writable surface (defense-in-depth; allowedTools grant no
symlink-creating tool today). (Low)
- Broker-startup failure now surfaces the broker log (method+path+status only) so
the diagnostic the message names is actually obtainable. All three legs. (Low)
- test_environment_binding.py now asserts the real key appears in NO agent-job
step outside the allowed broker/scan/capture set, and never at workflow-level
env — catching a job/workflow-level `env:` alias the step-scoped test missed. (Low)
- Fix the inverted rw-git-ro comment in Capture patch. (Nit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(groom): split sandbox preflight into its own step before Run finder/verifier/builder
The bubblewrap bring-up currently lives inside the billed `Run finder` step,
whose name interval.py matches EXACTLY to decide whether a FAILED finder job
spent its audit and may advance GROOM_INTERVAL_DAYS. A no-agent-budget setup
failure (no bwrap / apt unavailable / userns blocked → preflight exit 1; usage
error → die exit 2; in-jail shim never comes up → exit 1) therefore fails that
step and is wrongly counted as a spent audit, suppressing grooming for a full
interval. The `Start the key broker` step was split out for exactly this reason;
the sandbox preflight was not.
- agent-sandbox.sh: add `--preflight-only` (alias `--selftest`) — runs ONLY the
mutating preflight() bring-up and exits 0/non-zero, taking no clone/out-dir/
command. preflight() is already idempotent, so the agent step's own preflight
then hits its fast path (no side effects).
- groom.yml: add a distinctly-named `Preflight the sandbox` step BEFORE the
`Run <agent>` step in audit_find, audit_verify and build. When it fails the job
fails but `Run <agent>` is never reached → the runs-jobs API reports it
queued/skipped → interval.py reads it as unstarted → the cadence clock is NOT
advanced. Only audit_find is cadence-gated; verifier/builder get the split for
consistency with the broker precedent.
- interval.py: `_AGENT_STEP_NAME` left unchanged.
- tests: sandbox-tests.sh gains a `--preflight-only` case (exit 0 with no
command/clone/out-dir; fail-loud with a stubbed failing bwrap); test_interval.py
extends the audit_find structural pin (preflight step exists, precedes the agent
step, name != agent_step_name()) and adds a run_audited case (queued/skipped
agent step + completed/failure preflight step → not a spent audit).
* fix(groom): make --preflight-only fail loud on misuse and drop the misleading --selftest alias
Addresses cursor-review panel findings on the sandbox preflight split:
- --preflight-only silently discarded any --clone/--out-dir/--uds/-- <command>
and exited 0 having run no agent, the opposite of its "takes no execution
args" contract (6/6 reviewers). Now die loudly when it is combined with any
execution-mode argument, matching every other bad-flag path in main().
- Drop the --selftest alias: it borrowed the read-only selftest() probe's name
but mapped to the MUTATING preflight() (apt install, AppArmor profile, sysctl
hardening disable), a footgun for anyone running it ad hoc. --preflight-only
is the only name used by the workflow.
- preflight() -> `preflight || exit $?` so the fail-loud contract is structural,
not reliant on preflight() happening to terminate the process itself.
- Narrow the groom.yml "Preflight the sandbox" comment: the split covers the
bring-up ONLY; other no-spend pre-exec guards still run inside "Run <agent>"
(tracked as a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(groom): hoist agent-sandbox pre-exec validation out of the billed agent step
agent-sandbox.sh runs a wall of no-spend, fail-loud guards before `exec bwrap`
— required/absolute-path argument validation, the `--uds` `-S` check plus a
live-broker `/healthz` probe, clone and out-dir existence, the out-dir/clone
overlap check, and the `--env KEY=VALUE` / `rw-git-ro` `.git` / `--ro-file`
checks embedded in the mount assembly. Run from inside the billed
"Run finder"/"Run verifier"/"Run builder" step, any of them dying leaves that
step `failure` having billed nothing, which interval.py's exact-name match reads
as a STARTED audit: run_audited then counts a spent audit and advances the
GROOM_INTERVAL_DAYS cadence clock for a run that spent nothing. The most
plausible live trigger is a broker that dies between its step and the agent
step, leaving a stale socket that passes `-S` and fails healthz.
Add `--validate-only`, which parses exactly like a real run and walks the
IDENTICAL code path, branching at the single `exec` point rather than
re-implementing the checks — so the guards inside the bwrap_args assembly are
exercised too, which a parallel validator would silently skip. It refuses a
`-- <command>` and refuses to be combined with `--preflight-only`, mirroring
that flag's misuse-guard style, so a stray flag on a real agent step dies
instead of exiting 0 having run no agent.
All three groom agent jobs then run it in the existing, distinctly-named
"Preflight the sandbox" step, with the same arguments as their agent step. The
step name is unchanged, so interval.py's exact-name match still never counts it.
This does NOT close the window between that step and the agent step: a broker
that dies after the probe, or a failure bwrap itself raises at exec, still lands
on the billed step and is still counted. That residual is stated in the step
comment and tracked separately.
* fix(groom): close the --validate-only gaps the review panel found (BE-14771)
Four review findings, all about guards `--validate-only` claimed to cover but
did not — each one leaving the failure on the billed `Run <agent>` step, which
is the BE-4814 miscount the hoist exists to prevent.
`--ro-file` sources are now checked for EXISTENCE, not just absoluteness.
`--ro-bind` (unlike `--ro-bind-try`) aborts when the source is missing, so an
absent brief or jail-shim passed validation and then killed the billed step
no-spend. `[[ -e ]]` is a host-side check needing no jail, exactly like the `-d`
on `--clone` and the `-S` on `--uds`. Every `--ro-file` groom.yml passes is
produced by an unconditional step that precedes `Preflight the sandbox` in all
three agent jobs, so the check cannot false-fail there.
The broker liveness probe is now STRUCTURAL under `--validate-only`. It was
wrapped in `command -v curl`, so a curl-less host skipped it and exited 0 on a
stale socket — the crashed-broker case that is the most plausible live trigger
for the whole split. Extracted as `broker_healthz()`: curl preferred, python3
fallback (so the capability survives a curl-less host rather than being denied),
and only a host with NEITHER is fatal — and fatal only under `--validate-only`.
A real run keeps the historical best-effort skip: it is about to run the agent
regardless, and a spurious die there is the expensive failure.
The preflight/billed mirror assertions are parameterized over all three agent
jobs, not just `audit_find`. The verifier and builder carry the longer
`--ro-file` lists and the only `--clone-mode rw-git-ro`, so they were the two
most able to drift while the comment said "KEEP THE TWO LISTS IN SYNC". New
`SandboxPreflightHoistTest` pins ordering, both invocations, and the token-for-
token argument mirror per job; the audit_find-only copy is replaced by a pointer
so the two cannot diverge.
Docs corrected where they overclaimed: the README's "only side effect is the
`mkdir -p`" holds only once the bring-up has succeeded (standalone,
`--validate-only` still runs preflight's `apt-get`/AppArmor/sysctl mutations),
and groom.yml's residual no longer lists a missing `--ro-file` among the
failures validation cannot reach.
Tests: sandbox-tests.sh 9c gains the absent-`--ro-file` case; new 9f pins the
python3 fallback (live broker passes, stale socket still dies) and the
no-probe-tool contract, including a control proving a REAL run still reaches the
exec there. All four new guards mutation-tested: removing each one fails its
assertion.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>1 parent cf7b8d9 commit 23c7b69
5 files changed
Lines changed: 621 additions & 66 deletions
File tree
- .github
- groom
- tests
- workflows
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
685 | 685 | | |
686 | 686 | | |
687 | 687 | | |
688 | | - | |
689 | | - | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
690 | 691 | | |
691 | 692 | | |
692 | 693 | | |
| |||
704 | 705 | | |
705 | 706 | | |
706 | 707 | | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
707 | 711 | | |
708 | 712 | | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
709 | 717 | | |
710 | 718 | | |
711 | 719 | | |
| |||
784 | 792 | | |
785 | 793 | | |
786 | 794 | | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
787 | 852 | | |
788 | 853 | | |
789 | 854 | | |
| |||
810 | 875 | | |
811 | 876 | | |
812 | 877 | | |
813 | | - | |
| 878 | + | |
| 879 | + | |
| 880 | + | |
| 881 | + | |
| 882 | + | |
| 883 | + | |
| 884 | + | |
814 | 885 | | |
815 | 886 | | |
816 | 887 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
26 | 30 | | |
27 | 31 | | |
28 | 32 | | |
| |||
39 | 43 | | |
40 | 44 | | |
41 | 45 | | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
42 | 64 | | |
43 | 65 | | |
44 | 66 | | |
| |||
66 | 88 | | |
67 | 89 | | |
68 | 90 | | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
69 | 127 | | |
70 | 128 | | |
71 | 129 | | |
| |||
116 | 174 | | |
117 | 175 | | |
118 | 176 | | |
119 | | - | |
| 177 | + | |
120 | 178 | | |
121 | 179 | | |
122 | 180 | | |
| |||
128 | 186 | | |
129 | 187 | | |
130 | 188 | | |
| 189 | + | |
131 | 190 | | |
132 | 191 | | |
133 | 192 | | |
134 | 193 | | |
135 | 194 | | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
136 | 204 | | |
137 | 205 | | |
138 | 206 | | |
| |||
153 | 221 | | |
154 | 222 | | |
155 | 223 | | |
156 | | - | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
157 | 235 | | |
158 | 236 | | |
159 | 237 | | |
| |||
173 | 251 | | |
174 | 252 | | |
175 | 253 | | |
176 | | - | |
177 | | - | |
178 | | - | |
179 | | - | |
180 | | - | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
181 | 273 | | |
182 | 274 | | |
183 | 275 | | |
| |||
251 | 343 | | |
252 | 344 | | |
253 | 345 | | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
254 | 354 | | |
255 | 355 | | |
256 | 356 | | |
| |||
270 | 370 | | |
271 | 371 | | |
272 | 372 | | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
273 | 383 | | |
274 | 384 | | |
275 | 385 | | |
| |||
0 commit comments