fix(sandbox): let an unreadable cc expose source degrade, not abort - #5992
Conversation
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
|
Two measurements from the same host that landed independently of this PR, plus a survey of the sibling reads in the same launcher. Nothing here contradicts the diagnosis — one item narrows the choice of fix, and one is a caveat on a figure. 1. The
|
| loop-stall dump | nearest failure(s) |
|---|---|
| 02:06:02Z | 02:07:38Z, 02:10:04Z |
| 02:23:12Z | 02:23:10Z (−2s) |
| 02:34:46Z | 02:34:44Z (−2s) |
| 03:25:15Z | 03:24:14Z, 03:24:44Z |
The window closes at the ~03:22Z gateway restart, after which every run is ok. That is the same event the PR describes as "restarted from a clean parent" — it is independently visible in the dump timeline, so whatever applied the restriction arrived with, or alongside, a wedged parent. I am not claiming a mechanism connecting the wedge to the restriction; the correlation is all I have.
Caveat on one figure. I could not confirm or dispute "three reached _AUTO_PAUSE_THRESHOLD". My metric conflates error classes: 6 of the 7 jobs show a ≥5 consecutive non-ok streak, but those streaks (37, 15, 16, 14, 6, 7) far exceed their PermissionError counts (12, 7, 7, 6, 3, 2), so other failures from the same window are mixed in and I cannot attribute each latch to this defect. Flagging it only so the number is not treated as independently corroborated.
3. Sibling reads with the same shape (not a request to widen this PR)
I went looking for other EXPOSE_FILES entries that share the hazard. There are none — the list has exactly one entry, and the macOS site is structurally immune, since it never reads the file at all: it emits (require-not (literal …)) exceptions inside the deny file-read* rule, and cc mode there excludes .aws from hiding entirely.
But the shape — isfile() as the guard, unguarded open(), during setup — recurs three more times in the same launcher:
- The
.ssh/known_hostspre-read. Identical construction (if os.path.isfile(SSH_KNOWN_HOSTS)then an unguardedopen), and it exposes optional content for exactly the same reason. It is gated onHIDE_SSH, i.e.sandbox_level == "strict"— the default level, so its reach is wider than the cc-only path this PR fixes, not narrower. - The expose restore write. The scope note is right that a skipped entry propagates via
if src_path in expose_data, but that covers a failed pre-read only. If the pre-read succeeds and the restoreopen(dest, "wb")/os.chmod(dest, 0o444)fails, anOSErrorstill aborts setup — one loop below the fix, same severity, same optional-convenience justification. - The
known_hostsrestore write, same as (2).
Separately, and in the opposite direction: all four _libc.mount() calls discard the return value, although restype is c_int and the CDLL is built with use_errno=True. unshare is checked a few lines above, with a specific errno exit — so the checking pattern exists in the same function and the mounts are the exception. A failed bind-mount means a credential directory is not hidden and the spawn proceeds silently: fail-open on the actual security control, where the bug this PR fixes fails closed on something the PR correctly identifies as not a control. I found no post-mount verification (positive control: verify does occur elsewhere in the file, so the search can find it; within the launcher body the only return-value checks are the two unshare calls).
All of (3) is follow-up material — this PR is scoped to one loop and reads well as-is. Raising it here only so the neighbours are on record rather than rediscovered from the next incident.
Environment for everything above: single Linux host, cc mode via run_command_sandboxed.
47a8636 to
c2bf164
Compare
c2bf164 to
97acc47
Compare
|
Thanks — all three actioned in code at 1.
Your retraction is applied too. I had carried the same unsupported inference — the 2. The 3. Sibling reads and the unchecked Tell me which you want: fold the Full suite locally: 206 passed / 1 skipped across |
97acc47 to
ef77793
Compare
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All claims verified against the base. I have everything needed for the review. First-Principles-Verdict: PASS One mechanism-level fix, its counted sibling handled in the opposite (derived) direction, and every new surface has a named consumer. What this change shipsIntent: stop one unreadable optional credential file from killing every cc-mode sandbox spawn (and with it the whole
Verified counts: exactly two Watch
[FIRST-PRINCIPLES-REVIEWED] c8b9ee5 |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS A measured setup-abort fixed at the fragile read itself, with the fail-open/fail-closed split correctly derived from what each file protects. I verified the claims against the base tree: both [DESIGN-REVIEWED] c8b9ee5 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe candidate file contains the discovery pass's own conclusion ("No candidates."), not actual candidates — so Step 1 has nothing to falsify. Verifying the diff independently:
No grounded (a)/(b)/(c) chain to an observable wrong outcome exists in the changed lines, and nothing in the base rules is weakened. No findings. [OPUS-REVIEWED] c8b9ee5 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
ace8eba to
d59b2c4
Compare
d59b2c4 to
5dccb3a
Compare
5dccb3a to
b6e60c2
Compare
b6e60c2 to
6ce3984
Compare
6ce3984 to
122a939
Compare
122a939 to
5ab954c
Compare
Wrap the cc-mode EXPOSE_FILES pre-read in try/except OSError and warn on stderr, so an unreadable optional exposure skips instead of killing setup.
Problem / Motivation
The cc-mode sandbox launcher pre-reads
EXPOSE_FILESbefore bind-mounting emptydirectories over the credential paths, so
~/.aws/configsurvives the hiding andcredential_processstill resolves inside an otherwise-hidden~/.aws. The read wasunguarded (
src/kiro_crew/sandbox.py, in the generated launcher):if os.path.isfile(src_path)already handles the file being absent. It does nothandle the file being unreadable, and those are different conditions:
statcansucceed on a path whose
openis then denied.This also rules out the weaker fix. Tightening the guard to a pre-flight
os.access(src_path, os.R_OK)looks equivalent from the source alone, but measured on theaffected host
os.stat()succeeded andos.access()reported bothX_OKandR_OKas
Truewhile the operation was denied anyway. Catching the error is the only guard thatholds, and a test now pins that (see below) so the substitution cannot be made silently.
Because this read happens during sandbox setup, the
OSErroraborts the childbefore the command runs at all.
_CC_EXPOSE_FILESis non-empty only whensandbox_level == "cc", so the failure is scoped to cc mode.Why it matters (measured impact)
On one host this took out an entire cron kind.
cron_script.pysplits:run_script_sandboxedmode="standard"run_command_sandboxedmode="cc"Seven command crons failed. Jobs latched into auto-pause off the back of it, and an
auto-paused job cannot self-heal:
enabledis derived asnot user_paused and not auto_paused, so the scheduler never fires it and it can neverrecord the success that would clear the latch.
One figure is deliberately not stated more precisely than the evidence supports. An
independent scan of that host's cron history corroborates the seven jobs (7 jobs, 38
occurrences, one path, a 77-minute window closing at the restart), but it could not
attribute each auto-pause latch to this defect: 6 of the 7 jobs show a >= 5 consecutive
non-
okstreak, yet those streaks (37, 15, 16, 14, 6, 7) far exceed theirPermissionErrorcounts (12, 7, 7, 6, 3, 2), so other failures from the same window aremixed in. Treat the per-job latch attribution as unestablished.
Each failure surfaced only as a raw traceback in the job's
last_result. Nothing waslogged at ERROR level, so the surface an operator would check showed nothing.
On the cause: not established
I could not determine what produced the
EACCES, and this PR does not claim to know.The following were ruled out by measurement:
-rw-------, owned by the same user the process ranas; widening the parent directory changed nothing.
ps).getenforcereportedPermissive.The restriction was inherited from the parent process and disappeared when the process was
restarted from a clean parent (verified: 7/7 jobs back to
last_status=ok,consecutive_failures=0). A kernel LSM is the obvious suspect andlandlockis present in/sys/kernel/security/lsmon that host, but KiroCrew has zero landlock references, sowhatever applied it came from the environment rather than from this codebase. I am
deliberately not asserting it.
The argument does not depend on the cause. Selective exposure is an optimisation, not
a security control, so a failure to read one should degrade to "not exposed" rather than
abort the whole spawn.
What changed
Wrap the pre-read in
try/except OSError, skip that entry, and warn on stderr:This follows the precedent already in the launcher: the Step 7 pre-exec hardlink scan
deliberately "degrades OPEN with a stderr warning rather than failing closed" when its
budget is exhausted.
The diagnostic is not optional. A bare
except OSError: passwould trade a loudsetup failure for a silent one — the child would have no
~/.aws/configand noexplanation, and auth would fail later with an error pointing nowhere near this line.
Scope note: the restore loop below is already keyed on
src_path in expose_data, so askipped entry is skipped there too with no second change needed.
The launcher's other pre-read of this shape is guarded too — in the OPPOSITE
direction. The
known_hostsexposure (src/kiro_crew/sandbox.py, the.sshblock in thegenerated launcher) had the identical unguarded
os.path.isfile(...)->open(...)construction. Same root cause; not the same remedy. That read now FAILS CLOSED: it
reports
sandbox: FATALnaming the path, then re-raises, so sandbox setup is refused.The asymmetry is the whole point, so it is worth stating plainly:
~/.aws/configcosts reachability. Skipping it trades a convenience fora working sandbox, and nothing about host trust changes.
known_hostscosts verification. The launcher putsStrictHostKeyChecking=accept-newintoGIT_SSH_COMMAND, gated only on that variablebeing unset — never on whether this read succeeded. So continuing with an empty
kh_datawould point
UserKnownHostsFileat an absent file while auto-accept is still on: everyhost then reads as NEW and an interceptor's key is accepted. With
known_hostspresent,accept-newrefuses a changed key.Degrading there would therefore convert "refuse a changed key" into "accept anything" — a
fail-open on a security control. Refusing is the safe direction: no sandbox at all beats one
that has quietly stopped verifying hosts.
This site also has wider reach than the expose read: it is gated on
HIDE_SSH, which isset at the default strict level, whereas the expose read is reachable only when
sandbox_level == "cc".A census of the generated launcher's child-setup path finds exactly two
isfile->openpre-reads, and both are now guarded. (A third
isfilein that path guards a bind-mount,not a read.)
Tests
Ten tests in the existing
test/test_sandbox_cc_mode.py— six for the expose pre-read andfour for the
known_hostspre-read. They are deliberately not mirrors: the expose testspin a degrade, the
known_hoststests pin a refusal. They extract each read from theshipped launcher and run it via
runpy.run_path, following the patterntest/test_sandbox_hardlink_scan.pyalready established, so they cannot drift from whatthe child actually executes.
_EXPOSE_SLICE_LANDMARKSand_KH_SLICE_LANDMARKSfailloudly if an edit shrinks either extracted slice, rather than leaving the assertions
vacuously green. Only the
known_hostspre-read itself is sliced, not the whole.sshblock, because the lines around it call
_libc.mount()...._does_not_abort_setup..._is_reported_on_stderr..._is_still_read..._stays_silentisfileshorts out first)..._does_not_block_the_others..._not_a_pre_flight_access_checkos.access..._known_hosts_aborts_setupknown_hostsRAISES, and the refusal is reported on stderr naming the path and markedFATAL..._known_hosts_is_still_read..._absent_known_hosts_stays_silentknown_hostsstays silent (isfileshorts out first)..._known_hosts_guard_is_the_exception...os.accessVerified against three break-arms:
PermissionError: [Errno 13], reproducing thedefect.
except OSError: pass) — 2 tests fail on the stderrassertions (
skipping an exposure silently must not be an option).and os.access(src_path, os.R_OK)— 3 tests fail, including theone that exists for this case, so the weaker fix cannot be substituted silently.
The
known_hoststests were verified against their own break-arm. Reverting that guard tothe unguarded read makes 2 of the 4 fail — one on the missing refusal, one on the missing
diagnostic. The other 2 are the positive controls (readable, absent), which pass with or
without the guard by design: they are what would catch a "fix" that refused unconditionally.
The refusal and its diagnostic are asserted together in
..._known_hosts_aborts_setup,because they are one behaviour observed from one setup. Splitting them across two tests
required a module-level global to carry stderr past the raise; the harness now takes a
caller-owned
stderr_sinkinstead, so no test state lives at module scope.So for the expose site the non-fatal requirement and the diagnostic requirement are each
enforced independently, rather than one riding on the other.
test/test_worktree_create.py— the severity ratchet. The newsandbox: FATALline addsa severity to the launcher's vocabulary, and that file pins the SET of severities the
launcher may emit so a new one is a deliberate edit rather than a silent reclassification.
FATALis added to the pinned set, and the fatal-vs-advisory decision is encoded rather thanjust asserted: the classifier
(
src/kiro_crew/dashboard/handlers/worktree.py) treats a line as a refusal when it startswith
sandbox:and NOT withsandbox: WARNING, so two assertions pin thatFATALcarriesthe former and not the latter, and a new test
(
test_the_fail_closed_pre_read_line_still_refuses) proves the classifier actually raisesSandboxUnavailablefor the new spelling. The existing advisory — the hardlink-scanWARNING, which must never be read as a refusal — is untouched. Verified with a negativecontrol: injecting a throwaway
sandbox: NOPEline still fails the ratchet, so it has notbeen widened into uselessness.
The two tests that need an unreadable file skip themselves via
os.accessif the host canread a
0000file (root, or a filesystem ignoring the mode).Manual verification
test/test_sandbox_cc_mode.py,test_sandbox_hardlink_scan.py,test_sandbox_argv.py— 205 passed, 1 skipped.
scripts/check_black_formatting.py,scripts/check_subprocess_encoding.py,scripts/scrub-lint.sh --no-history,scripts/check_lockdown_before_publish.py,scripts/docs-lint.sh— all pass at the merge scope CI uses.black --target-version py310is clean onsandbox.py; in the test file it is clean onevery line this change adds (the file's two pre-existing offences at lines 30 and 329 are
in the baseline and left untouched).
Repo checks before opening
"sandbox aws config PermissionError"; a bare "sandbox" search confirms the query does
reach open issues, so the two empty results are not an artefact of the search).
src/kiro_crew/sandbox.py— fix(sandbox): give the shielded spawn wrapper one owner, migrate 6 sites #5863, fix: refuse to let the agent relocate the data home #5411, feat: monitor pull requests across source providers #5305 — and none of themmodifies the expose pre-read. fix: refuse to let the agent relocate the data home #5411 carries those lines as diff context only (no
+/-on them), so a textual conflict in that function is possible but there is nosemantic overlap with this change.