Skip to content

Commit 23c7b69

Browse files
mattmilleraiclaude
andauthored
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/README.md

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -685,8 +685,9 @@ just untrusted data the agent analyzes, never a path to the runner's credentials
685685

686686
How `groom.yml` composes them per agent job: a **broker step** (the only step
687687
holding `secrets.ANTHROPIC_API_KEY`) starts `broker.mjs` on the host socket
688-
`$BROKER_SOCK` and waits for its `/healthz`; the **agent step** — carrying NO real
689-
key — runs `agent-sandbox.sh --uds "$BROKER_SOCK"` with the brief (and, for the
688+
`$BROKER_SOCK` and waits for its `/healthz`; a **"Preflight the sandbox" step**
689+
does the whole no-spend setup half (`--preflight-only` then `--validate-only`, see
690+
below); the **agent step** — carrying NO real key — runs `agent-sandbox.sh --uds "$BROKER_SOCK"` with the brief (and, for the
690691
builder, the finding JSON) passed `--ro-file`, every output under the one rw
691692
`--out-dir` (`$GROOM_OUT_DIR`), and a `bash -c` wrapper that brings up the in-jail
692693
`jail-shim.mjs` before `exec`ing the pinned `claude` CLI with a DUMMY key and
@@ -704,8 +705,15 @@ read-only.
704705
agent-sandbox.sh --clone <path> --clone-mode ro|rw-git-ro --out-dir <path> \
705706
[--ro-file <path> ...] [--env KEY=VALUE ...] [--uds <host-socket-path>] \
706707
-- <command...>
708+
709+
agent-sandbox.sh --preflight-only # bring-up only, no command
710+
agent-sandbox.sh --validate-only <same args as a real run, minus the command>
707711
```
708712

713+
The two extra modes are the *pre-agent-step split* described under
714+
[the no-spend pre-agent split](#the-no-spend-pre-agent-split) below;
715+
both take no `-- <command>` and neither ever starts the agent.
716+
709717
- **[`broker.mjs`](broker.mjs)** — a ~50-line node-stdlib reverse proxy
710718
(`node broker.mjs <port|socket-path>`) that holds the real key on the host and
711719
forwards the jail's requests to it. In socket mode it listens on a unix-domain
@@ -784,6 +792,63 @@ it drops the userns restriction and retests; if it *still* fails it emits
784792
`::error::bwrap sandbox unavailable …` and exits non-zero. A broken sandbox stops
785793
the run — it never silently degrades to no sandbox.
786794

795+
### The no-spend pre-agent split
796+
797+
Everything `agent-sandbox.sh` does *before* `exec bwrap` is no-spend: the sandbox
798+
bring-up above, and then a wall of fail-loud guards (required/absolute-path
799+
argument validation, the `--uds` `-S` check plus a live-broker `/healthz` probe,
800+
clone and out-dir existence, the out-dir↔clone overlap check, and the `--env
801+
KEY=VALUE` / `rw-git-ro` `.git` / `--ro-file` absolute-path-and-existence checks
802+
inside the mount assembly). Every one of them is answerable host-side, with no
803+
jail — `--ro-file` included: `--ro-bind` (unlike `--ro-bind-try`) aborts on a
804+
missing source, so an absent brief or jail-shim would otherwise sail through
805+
validation and kill the billed step, which is the whole miscount in miniature.
806+
Run from inside the billed `Run <agent>` step, any of them failing leaves that
807+
step `failure` having billed nothing — and
808+
[`interval.py`](interval.py)'s exact-name match then reads the agent as *started*,
809+
so `run_audited` counts a spent audit and advances the `GROOM_INTERVAL_DAYS`
810+
cadence clock for a run that spent nothing (BE-4814). The most plausible live
811+
trigger: the broker dies between its step and the agent step, leaving a stale
812+
socket that passes `-S` and fails `/healthz`. That probe runs over `curl`, falling
813+
back to `python3` — under `--validate-only` a host with neither is a hard failure
814+
rather than a skipped probe, because a validation that silently cannot validate
815+
is the green no-op this mode exists to prevent. (A real run keeps the older
816+
best-effort skip: it is about to run the agent regardless, and a spurious failure
817+
*there* is the expensive one.)
818+
819+
So both halves run in their own `Preflight the sandbox` step, whose name is
820+
deliberately DISTINCT from the billed step:
821+
822+
| Mode | Runs | Takes |
823+
|---|---|---|
824+
| `--preflight-only` (BE-14756) | ONLY the mutating bring-up | no clone/out-dir/uds/ro-file/env, no `-- command` |
825+
| `--validate-only` (BE-14771) | the SAME guard path a real run walks, stopping at the single `exec` point | the same arguments as the agent step; no `-- command` |
826+
827+
`--validate-only` deliberately routes through the real code rather than
828+
re-implementing the checks — a parallel copy would drift, and a guard it missed
829+
would still kill the billed step no-spend. Both modes reject nonsensical
830+
combinations loudly (each other, or a `-- command`), so a stray flag on a real
831+
agent step dies instead of becoming a green no-op that runs no agent.
832+
833+
`--validate-only` walks the *whole* pre-exec path, `preflight()` included — so
834+
**it is only side-effect-free once the bring-up has already succeeded.** In the
835+
`Preflight the sandbox` step that is guaranteed (`--preflight-only` ran first, so
836+
`preflight()` takes its idempotent fast path), and the sole remaining side effect
837+
is the `mkdir -p` on the out-dir that the real run performs anyway; the agent
838+
step's own copies of both are then no-ops. Run standalone on a host where the
839+
sandbox is *not* yet usable, the same call will `sudo apt-get install bubblewrap`,
840+
write `/etc/apparmor.d/bwrap`, and as a last resort `sudo sysctl -w
841+
kernel.apparmor_restrict_unprivileged_userns=0` — the bring-up's host-wide
842+
mutations, from a mode named for validation. Pair it with `--preflight-only`, as
843+
groom.yml does, or expect the bring-up.
844+
845+
**What this does NOT close:** the window between that step and the agent step. A
846+
broker that dies *after* the `/healthz` probe — or an input deleted after it is
847+
checked — still fails the billed step with no spend, and that failure is still
848+
counted as an audit. Nor can validation reach a mount that `bwrap` itself rejects
849+
at exec for a source that *does* exist. Proving the agent actually BILLED is
850+
tracked separately (BE-4850).
851+
787852
### Tests — deterministic, no API spend
788853

789854
[`tests/sandbox-tests.sh`](tests/sandbox-tests.sh) (run by the `sandbox-tests` job
@@ -810,7 +875,13 @@ netns is empty), then its contents (`/proc/net/dev` must list `lo` and nothing
810875
else, and neither `/proc/net/route` nor `/proc/net/ipv6_route` may carry a default
811876
route). It deliberately does *not* key on a connect exit code, which could not
812877
tell an isolated netns from a shared one behind a firewall REJECT or on an offline
813-
host. No `claude`, no API key, no spend.
878+
host. Sections 8 and 9 cover the no-spend split: `--preflight-only` exits 0 on a
879+
usable host and fails loud on a broken `bwrap`, and `--validate-only` exits 0 on
880+
a real run's arguments *without exec'ing the jail* (a stubbed `bwrap` records
881+
every invocation, so "did it exec?" is asserted, not assumed) while failing loud
882+
on a bad argument — including a `--ro-file` that does not exist — and on a
883+
`-S`-passing socket with no live broker, over curl and over the python3 fallback
884+
alike. No `claude`, no API key, no spend.
814885

815886
```bash
816887
shellcheck -x .github/groom/agent-sandbox.sh .github/groom/tests/sandbox-tests.sh

.github/groom/agent-sandbox.sh

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
#
2424
# agent-sandbox.sh --preflight-only
2525
#
26+
# agent-sandbox.sh --validate-only --clone <path> --clone-mode ro|rw-git-ro \
27+
# --out-dir <path> [--ro-file <path> ...] [--env KEY=VALUE ...] \
28+
# [--uds <host-socket-path>]
29+
#
2630
# --uds bind-mounts a host-side listening unix socket (the broker) to the fixed
2731
# in-jail path /run/broker.sock (read-only: connect(2) to a socket works under a
2832
# read-only bind, but the jail can't chmod/replace the shared inode).
@@ -39,6 +43,24 @@
3943
# sandbox is already usable), so the real `Run <agent>` step's own preflight is
4044
# then a no-op.
4145
#
46+
# --validate-only is the second half of that split (BE-14771). It takes the SAME
47+
# arguments a real run does and walks the SAME code path — argument validation,
48+
# the absolute-path checks, the --uds `-S` + live-broker healthz probe, the
49+
# clone/out-dir existence + overlap check, preflight(), and the whole bwrap_args
50+
# assembly with its embedded `--env KEY=VALUE`, `rw-git-ro` `.git`-pointer and
51+
# `--ro-file` absolute-path + existence guards — then stops at the single exec
52+
# point instead of exec'ing bwrap, printing `validate-only: all pre-exec guards passed` and
53+
# exiting 0. Every one of those guards is no-spend and fail-loud, but on a real
54+
# run they die INSIDE the billed `Run <agent>` step, which interval.py then reads
55+
# as a started (spent) audit and advances the cadence clock for a run that billed
56+
# nothing (BE-4814). Hoisting them into the same separate step as the bring-up
57+
# moves that failure off the billed step's name. It takes NO `-- <command>`:
58+
# nothing is ever executed, and rejecting one keeps a stray `--validate-only` on
59+
# a real agent step from becoming a green no-op that runs no agent. Walking the
60+
# REAL path (rather than a re-implementation of the checks) is the point — a
61+
# parallel copy would drift, and a guard it missed would still kill `Run <agent>`
62+
# no-spend.
63+
#
4264
# The preflight FAILS LOUD: if a working bwrap sandbox cannot be established on
4365
# this runner image, the script exits non-zero and the command is NEVER run. It
4466
# never falls back to running the command unsandboxed.
@@ -66,6 +88,42 @@ selftest() {
6688
true 2>/dev/null
6789
}
6890

91+
# Probe the broker's /healthz over the host-side unix socket at $1, proving a
92+
# process is actually LISTENING (a socket left behind by a crashed broker passes
93+
# `-S` but gets ECONNREFUSED here). Returns 0 = live, 1 = probe failed, 2 = no
94+
# probe tool on this host. curl is preferred; python3 is the fallback so a
95+
# curl-less host still gets the real check instead of a silent skip (both are
96+
# present on the runner images, and the test suite already requires python3).
97+
# Writes nothing to stdout — this script's stdout is the agent's exec JSON.
98+
broker_healthz() {
99+
local sock="$1"
100+
if command -v curl >/dev/null 2>&1; then
101+
if curl -fsS --max-time 5 --unix-socket "$sock" http://broker/healthz >/dev/null 2>&1; then
102+
return 0
103+
fi
104+
return 1
105+
fi
106+
if command -v python3 >/dev/null 2>&1; then
107+
# Same question curl -f answers: does a listener accept the connection and
108+
# answer /healthz with a non-error status (< 400)?
109+
if python3 - "$sock" >/dev/null 2>&1 <<-'PY'
110+
import socket, sys
111+
112+
s = socket.socket(socket.AF_UNIX)
113+
s.settimeout(5)
114+
s.connect(sys.argv[1])
115+
s.sendall(b"GET /healthz HTTP/1.0\r\nHost: broker\r\nConnection: close\r\n\r\n")
116+
parts = s.recv(256).split(b"\r\n", 1)[0].split()
117+
sys.exit(0 if len(parts) >= 2 and parts[0].startswith(b"HTTP/1.") and parts[1].isdigit() and int(parts[1]) < 400 else 1)
118+
PY
119+
then
120+
return 0
121+
fi
122+
return 1
123+
fi
124+
return 2
125+
}
126+
69127
# Establish a working unprivileged-userns bwrap sandbox or exit non-zero. Mirrors
70128
# the runner image's own podman AppArmor workaround
71129
# (actions/runner-images: images/ubuntu/scripts/build/install-container-tools.sh):
@@ -116,7 +174,7 @@ PROFILE
116174
}
117175

118176
main() {
119-
local clone="" clone_mode="" out_dir="" uds="" preflight_only=""
177+
local clone="" clone_mode="" out_dir="" uds="" preflight_only="" validate_only=""
120178
local ro_files=() envs=() cmd=()
121179

122180
while [[ $# -gt 0 ]]; do
@@ -128,11 +186,21 @@ main() {
128186
--env) [[ $# -ge 2 ]] || die "--env needs a value"; envs+=("$2"); shift 2 ;;
129187
--uds) [[ $# -ge 2 ]] || die "--uds needs a value"; [[ -n "$2" ]] || die "--uds needs a non-empty value"; [[ -z "$uds" ]] || die "--uds may be given at most once"; uds="$2"; shift 2 ;;
130188
--preflight-only) preflight_only=1; shift ;;
189+
--validate-only) validate_only=1; shift ;;
131190
--) shift; cmd=("$@"); break ;;
132191
*) die "unknown argument: $1" ;;
133192
esac
134193
done
135194

195+
# The two pre-agent-step modes are mutually exclusive: --preflight-only takes NO
196+
# execution-mode arguments and --validate-only requires the full set, so the
197+
# combination cannot mean anything. Reject it instead of letting the
198+
# --preflight-only branch below win and silently skip the validation the caller
199+
# asked for — a green no-op where a caller expected a check is exactly the
200+
# failure mode both of these modes exist to prevent.
201+
[[ -z "$preflight_only" || -z "$validate_only" ]] \
202+
|| die "--preflight-only and --validate-only are mutually exclusive"
203+
136204
# --preflight-only: run ONLY the (mutating) sandbox bring-up and report whether
137205
# a working jail is now available (BE-14756). It takes NO clone/clone-mode/
138206
# out-dir/uds/ro-file/env and NO `-- <command>`; combining it with any of those
@@ -153,7 +221,17 @@ main() {
153221

154222
[[ -n "$clone" ]] || die "--clone is required"
155223
[[ -n "$out_dir" ]] || die "--out-dir is required"
156-
[[ ${#cmd[@]} -gt 0 ]] || die "a -- <command...> is required"
224+
if [[ -n "$validate_only" ]]; then
225+
# Nothing is ever executed under --validate-only, so a `-- <command>` here
226+
# is meaningless. Rejecting it (rather than accepting and ignoring it) is
227+
# what keeps a stray --validate-only on a real agent step LOUD: it dies
228+
# instead of exiting 0 having silently discarded the agent invocation.
229+
# Same misuse-guard posture as --preflight-only above.
230+
[[ ${#cmd[@]} -eq 0 ]] \
231+
|| die "--validate-only takes no -- <command...>: nothing is executed, so drop the command"
232+
else
233+
[[ ${#cmd[@]} -gt 0 ]] || die "a -- <command...> is required"
234+
fi
157235
# bwrap binds each of these at its REAL path; a relative value would resolve
158236
# against an unexpected CWD instead of failing loud, so require absolute paths.
159237
[[ "$clone" = /* ]] || die "--clone must be an absolute path (got '$clone')"
@@ -173,11 +251,25 @@ main() {
173251
# listening — a stale socket from a crashed broker would pass -S yet the
174252
# in-jail connect() then fails at runtime, breaking the fail-loud-before-
175253
# running guarantee. Probe /healthz over the socket to confirm a live
176-
# listener (best-effort: only when curl is present, matching the tests).
177-
if command -v curl >/dev/null 2>&1; then
178-
curl -fsS --max-time 5 --unix-socket "$uds" http://broker/healthz >/dev/null 2>&1 \
179-
|| die "--uds socket has no live broker listening (healthz probe failed): $uds"
180-
fi
254+
# listener.
255+
local probe_rc=0
256+
broker_healthz "$uds" || probe_rc=$?
257+
case "$probe_rc" in
258+
0) ;;
259+
1) die "--uds socket has no live broker listening (healthz probe failed): $uds" ;;
260+
# No probe tool on this host. Under --validate-only that silently
261+
# downgrades the mode's whole promise — the crashed-broker case is the
262+
# most plausible live trigger for hoisting these guards off the billed
263+
# step, and skipping the probe hands that failure straight back to it.
264+
# A validation that cannot validate must say so rather than exit 0. On a
265+
# real run, keep the historical best-effort skip: the agent step is about
266+
# to run anyway and a spurious die there is the expensive failure.
267+
*)
268+
[[ -z "$validate_only" ]] \
269+
|| die "--validate-only cannot probe the broker: neither curl nor python3 is on PATH (install one, or drop --uds)"
270+
echo "agent-sandbox: warning: neither curl nor python3 on PATH; skipping the --uds liveness probe" >&2
271+
;;
272+
esac
181273
fi
182274

183275
# out-dir must exist on the host before it can be bound rw into the jail; create
@@ -251,6 +343,14 @@ main() {
251343
if [[ ${#ro_files[@]} -gt 0 ]]; then
252344
for f in "${ro_files[@]}"; do
253345
[[ "$f" = /* ]] || die "--ro-file must be an absolute path (got '$f')"
346+
# bwrap's --ro-bind (unlike --ro-bind-try) aborts when the SOURCE does
347+
# not exist, so a missing brief or jail-shim kills the run either way.
348+
# Check it HOST-side — no jail needed, exactly like the `-d` on --clone
349+
# and the `-S` on --uds — so --validate-only catches it too. Otherwise
350+
# validation passes and the failure lands on the billed `Run <agent>`
351+
# step having spent nothing, which is the BE-4814 miscount this whole
352+
# split exists to move off that step name.
353+
[[ -e "$f" ]] || die "--ro-file does not exist on the host: $f"
254354
bwrap_args+=(--ro-bind "$f" "$f")
255355
done
256356
fi
@@ -270,6 +370,16 @@ main() {
270370

271371
bwrap_args+=(--bind "$out_dir" "$out_dir" --chdir "$clone")
272372

373+
# THE single exec point, and therefore the single place --validate-only can
374+
# branch (BE-14771) and still be sure every pre-exec guard above ran — including
375+
# the ones embedded in the bwrap_args assembly just above (`--env KEY=VALUE`,
376+
# the rw-git-ro `.git`-pointer check, `--ro-file` absolute paths + existence),
377+
# which a validation re-implemented elsewhere would silently skip.
378+
if [[ -n "$validate_only" ]]; then
379+
echo "validate-only: all pre-exec guards passed"
380+
exit 0
381+
fi
382+
273383
# stdout/stderr pass through to the host shell; the caller redirects stdout
274384
# on the HOST side to capture any exec JSON out of the agent's reach.
275385
exec bwrap "${bwrap_args[@]}" -- "${cmd[@]}"

0 commit comments

Comments
 (0)