feat(aws-control): curate a crew into a portable bundle - #9213
feat(aws-control): curate a crew into a portable bundle#9213chenmingwei23 wants to merge 2 commits into
Conversation
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence gathered. Composing the review now. First-Principles-Verdict: CONCERNS A 5,900-line builder with zero in-repo callers ships two extra spellings of surface it didn't need: an env-var alias and a "single-source" token table. Not justified as shipped
What this change shipsInventory (10 items) — 4 justifiedIntent: let an operator package a local crew into a digest-pinned bundle deployable off their machine — an ADDITION (piece 1 of a declared 3).
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 8cd7599 |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of I have what I need for the verdict. All facts verified: the in-package tests do run in CI ( Design-Verdict: CONCERNS Sound, heavily-tested builder — but its normative contract lives outside this repo, and a core Windows security-gate change rides along undisclosed. Watch
Suggestions
[DESIGN-REVIEWED] 8cd7599 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/apps/builtins/aws_control/crew/packaging/build.py:55 -- FINDING -- src/kiro_crew/apps/builtins/aws_control/crew/packaging/build.py:5181 -- [GPT-REVIEWED] 8cd7599 False positive or not applicable? A repository writer can comment: |
38dacc8 to
f0e65a6
Compare
f0e65a6 to
5b71879
Compare
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo findings block the merge. FINDING — build.py:3367 — the external-persona branch sets [OPUS-REVIEWED] 8cd7599 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
|
/ai-review override gpt 5b71879: Provider REFUSED (not a finding); gate says re-runs do not clear this class. Diff is one pure-Python package + 17 tests, no AWS/Docker/network, main() uncalled by design, security guards each pinned by a mutation test, 137 pass, Opus did not block. |
7006f50 to
94c7062
Compare
94c7062 to
e2c2515
Compare
e2c2515 to
6fba123
Compare
Rebuttal: concurrent staging claim,
|
|
@chenmingwei23 From the open-PR relationship audit (audited at 0636f23). This PR overlaps #9223 in exactly two files, and the two are complementary rather than competing, so both should land. Where they overlap. Both PRs add What differs. This PR is the bundle producer ( Suggestion. Pick one owner for Separately, #8470 duplicates all 17 files of this PR's packaging slice, 10 of them byte-identical. It should rebase and take this PR's Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
GPT 5.6 Review —
|
UX Review (Fable 5) — ⏭️ skippedRevision |
Turns a local crew into a portable, reviewed bundle: agent.json, mcp.json, skills/ and a manifest carrying the content digest. Deny-by-default -- nothing ships that a signed curation plan did not select, and every string is scanned before it becomes bytes. Addresses the three blocking items from tech-lead review. **The builder aborted every Windows build.** ``Path.write_text(text, encoding="utf-8")`` leaves ``newline`` at ``None``, which translates "\n" to os.linesep on write. The content pin compares ``_tree_hash`` (SOURCE bytes) against ``_staged_tree_hash`` (SHIPPED bytes), so an ordinary LF-authored skill hashed differently once staged and the build refused with "changed while the bundle was being written". ``bundle_digest`` runs over those same staged bytes, so the digest was platform-dependent too. Every ``write_text`` in the module now pins ``newline=""``, including the two whose bytes are not hashed -- a rule with exceptions is one nobody can apply from the call site. The suite could not catch this because it wrote its fixtures through the same call, so both sides of the comparison moved together; the new tests write source bytes with ``write_bytes``, and an AST tripwire holds the rule itself so the next such call is covered rather than only today's four. **skill_count undercounted nested ids.** A skill id is ``relative_to(skills_root).as_posix()`` and may nest, so ``aws/ec2`` and ``aws/s3`` are two skills under one top-level ``aws`` directory. Counting directories reported 1 for that pair, in the printed summary and in SMC_BUNDLE_JSON alike. It now counts the ids the plan selected, which is the population ``_copy_skill`` was driven from. **The prompt injection is deferred, not documented.** ``_inject_fingerprint_challenge`` prepended a ``[deployment verification]`` block to every deployed prompt, and its only consumer is the deploy gate, which is not in this change. The whole fingerprint path goes with that gate: the challenge, the injection, and the reported value, whose purpose the content digest already serves here. What ships now is the prompt the operator wrote, so a reviewer reading agent.json sees what will run. A source-text test keeps the block from returning uncalled, and ``test_fingerprint.py`` travels with the gate. Verified end to end on a real crew: three skills including two nested, one MCP server carrying a live-looking token. The token appears in no file in the bundle, the prompt is byte-identical to the source, skill_count is 3, the skill bytes are unchanged with no CRLF, and the manifest digest recomputes from the artifact.
The bundle builder inlines an agent's persona when the spec names it as file://<path>, so a curated crew ships one self-contained agent.json. Four shipped pptx_maker specs need this. The read is routed to hooks.safe_read_file_bytes_nolink, which owns the sensitive-path verdict, the fstat on the opened descriptor, the st_nlink refusal and containment against within_root. Redirects in the chain are judged before the path is handed over, because resolve() collapses links. Refuses a NUL in the reference: the target comes from the crew's spec, and a NUL-bearing string reaches a syscall as a bare ValueError. Checked on the string, since Path accepts it and defers the error past every point that could still name the reference. Removes a local opener stack that duplicated hooks and had no production caller, with the tests that pinned it. Scopes the byte ceiling to the prompt read, so an oversized agent spec or plan is not refused by a limit named for prompts. Restores nine POSIX-only markers dropped in a merge.
What this is
The packaging half of deploying a crew somewhere other than its owner's machine:
turn a local crew into a four-entry bundle (
agent.json,mcp.json,manifest.json,skills/) whose manifest carries a digest over every file itdescribes.
Pure Python. No cloud dependency, no Docker, nothing to configure. This is the
first of three pieces; the container runtime and the Fargate backend follow
separately.
There is no in-repo design document for the bundle format yet, and no in-repo
caller of
python -m packaging.build. The RFC on the Fargate backend covers thecompute side and does not mention bundling, so it is not the provenance for this
change and is not cited as such. What the format has to satisfy is written down in
this module's own docstrings and pinned by its tests; a design document and the
driver that consumes the bundle come with the piece that needs them.
Why it is 30 files for ~8,300 added lines
27 of the 30 are tests, and they run the real builder against a real temporary
filesystem rather than a dry-run stand-in. Most of them cover refusals, because
this module's job includes three recursive deletes and every one of them is a way
to destroy something the operator owns.
--outand<out>.previous, shared by one function<out>.stagingrmtreethenrenameis two operations, and a failure between them lost the old bundle, the new one, and the signed plan.is_file()is False for an empty directory, a FIFO, a socket and a link to a directory. Each was invisible to an ownership scan that used it, then deleted by thermtreethat followed.No caller lands here
build.pyexposesmain()and nothing in this diff invokes it. That isdeliberate: the driver that calls it belongs with the Fargate backend, and landing
the builder on its own keeps that review about the cloud path instead of about
bundle semantics. Reviewing this one needs no AWS account and no image build.
Checks
mypyclean over the new treeflake8,black,isortclean on the changed filescheck_subprocess_encoding.pyand the brand check passpackages = find:discovers the new package, so nothing insetup.cfgorMANIFEST.inneeds to change: the tree is Python only, and the non-Pythonpayload that does need a
MANIFEST.inentry (templates, shell, Dockerfiles)arrives with the pieces that own those files.
Scope note
Two tests that pinned this module's
O_NOFOLLOWopener against the containersidecar's deliberately duplicated copy are held back for the change that brings
that copy. The property is "the two agree", which cannot be asserted while only
one exists, and asserting it here with a skip would be a test that passes for the
wrong reason.
GPT 5.6 review: provider refusal, human override
The GPT 5.6 lane failed with
REFUSED: trueon this head, not a finding: theprovider declined to review the diff over its own content, and the gate's own
message states re-runs do not clear this refusal class (three attempts confirmed
it -- two
is_error:truetimeouts, then the content refusal). The gate failsclosed and directs a repository writer to
/ai-review override gpt.Counted evidence that no reachable Critical/High is being waved through:
Docker, no network, no non-Python payload.
build.py::main()has no caller in this diff by design; the entrypoint isunreachable until the Fargate driver lands, so no runtime path executes it here.
secret scan, ownership and shape guards), each pinned by a mutation test that
reddens when the guard is removed -- 137 tests pass.
A content-based refusal on a large security-heavy diff is a known provider
false-refusal, distinct from a substantive finding. Overriding the GPT lane only.
Filesystem access layer (security hardening)
build.pydoes filesystem work -- walk, read, write, copy, rename, delete --against operator- and author-supplied paths on POSIX and Windows. Rather than
harden each operation when a reviewer flags it, every operation routes through one
access layer with three properties:
_walk_no_reparse(os.scandir, neverdescends a reparse point) for walks;
_read_text_nofollow/_read_text_openatfor reads. On POSIX
O_NOFOLLOWrefuses a reparse final component at the open;where
O_NOFOLLOWis 0 (Windows) the reader lstat-refuses a reparse point beforeopening, so a junction to a UNC share is never followed into an SMB/NTLM probe.
<out>.previousrecursive delete goesthrough
_purge_via_private_aside: rename the tree into a directory this build alonecreated, verify ownership on the MOVED entry (the exact inode the rename captured, now
unswappable), and delete only if it passes -- a tree swapped in before the rename is
renamed back untouched. The inode verified is the inode deleted, so the check-to-rename
window is closed, not narrowed.
read as scannable UTF-8 refuses the build, naming the file. A directory that exists but
cannot be enumerated refuses rather than reading as empty. An existing report or signed
plan that cannot be read refuses rather than being treated as absent (which would delete
the report on rollback or overwrite the plan with stale bytes). Absent / unreadable /
unscannable never reads as "not selected". The standalone sensitive-path floor also
catches a credential FILE by name (
.env,*.pem, ...), not only a credential directory,so a
--allowof a credential leaf fails closed when the shared validator is unavailable.Filesystem call-site inventory
Every filesystem operation in
build.pyand how it routes:_walk_no_reparse, which never descends a reparse point.through
_read_text_openat/_read_text_nofollow, no-follow on both platforms.yielded by a
_walk_no_reparsewalk): plainread_bytes()/read_text(), becausethe path was created by this build or already cleared by the junction-safe walk. The
transaction reads of an EXISTING report and signed plan fail closed on an OSError rather
than treating an unreadable file as absent.
_write_nofollow(no-follow, atomic) or_write_guarded(scan +no-follow); the two plain
write_textcalls are inside those helpers.os.rename/os.replaceact on the entry, not a re-resolved path.<out>.previouspurge is_purge_via_private_aside,which moves-then-verifies-then-deletes; the final
rmtree(previous, ignore_errors=True)and the
rmtree(staging, ignore_errors=True)calls delete trees THIS build created this run(the aside it just renamed from its own out_dir, the staging tree in failure-rollback), and
unlink(missing_ok=True)removes this build's own marker / temp report.staging.mkdir(parents=True)claims the path (FileExistsError -> ExportRefused);private.mkdir(mode=0o700)is the run-private aside; the rest create build-owned dirs.Tests: each of the three properties has a test that reddens when its guard is reverted
(no-follow read fails closed with
O_NOFOLLOWforced to 0; the purge deletes only insideits private aside; a selected unscannable asset refuses).
Recursive-delete and mutating-fs call-site completeness
Every recursive delete and every mutating filesystem call in
build.py, and how it iscontained. A reviewer should check this list for completeness rather than hunt for an
un-routed site.
Recursive deletes (
shutil.rmtree):<out>.previous, leftover-purge and post-promotion cleanup (two sites) -- both go through_purge_via_private_aside(previous, verify=...): move into a run-private dir, verify theMOVED entry is build-written, delete only if it passes, restore a swapped-in tree otherwise.
Neither re-resolves the public path.
under a root no other writer holds.
staging(failure-rollback and post-swap cleanup, several sites) -- deletes THIS build's ownstaging tree, created by
staging.mkdir(parents=True)at a run-derived path; never anoperator path.
Failed-restore safety: if the ownership check fails AND the rename-back also fails,
_purge_via_private_asidedoes NOT delete -- it retains the private aside and refuses, namingwhere the tree sits. There is no correct recursive delete of a tree this build did not create.
Renames / replaces:
os.rename/os.replace/Path.renameact on the entry, not are-resolved path (
target->private,staging->out_dir,out_dir->previous, report temp-> report, rollback
previous->out_dir).unlink(missing_ok=True): the build's own staging marker and its own run-id-tagged tempreport; and the report the build itself wrote, only on rollback when
report_before is None.mkdir:private.mkdir(mode=0o700)(exist_ok False -- this build alone creates the name);staging.mkdir(parents=True); build-owned output/parent dirs.Writes: through
_write_nofollow(no-follow, atomic) or_write_guarded(scan + no-follow);the two bare
write_textcalls are inside those helpers.Platform support: POSIX-only for now (deliberate hold, tracked)
This builder is POSIX-only at present, by a single feature-detected entry-point guard, and
refuses on any platform without an atomic no-follow filesystem primitive (Windows today).
Why a guard and not per-site hardening: every filesystem entry point in this builder must judge
its path without following a reparse point, because following a Windows junction that names a UNC
share is an outbound SMB probe carrying an NTLM exchange. On POSIX that guarantee comes from
descriptor-relative
O_NOFOLLOWopens; on Windowsos.O_NOFOLLOWis0and there is nodescriptor-relative open, so the fallback for roughly fifteen entry points -- reads, stats,
is_dir/iterdir/exists, the sensitive-path resolution, the copies -- follows. Hardening eachfallback site is a list, and a list is complete only until the next site is found. The guarantee
is a property of the platform's primitives, so it is checked once, where the primitive is absent,
at the reading/mutating entry points (
read_agent_spec,read_plan,build_bundle).The guard is feature-detected, not
os.name == "nt": it keys on the no-follow primitive beingunavailable, so when
kiro_crew.hooksgrows a real no-follow handle (aFILE_FLAG_OPEN_REPARSE_POINTopen) and this builder adopts it, the guard lifts on its own. Thisis a hold with a tracked exit, not a bug -- the refusal message says so and links the issue. The
Windows shards assert the refusal.
Unreadable-is-refused, not a raw crash (transaction cleanup)
_refuse_unless_this_build_wrote_itenumerated--outwithiterdir, which raisesPermissionErroron an unreadable existing directory. Every caller keys its staging/markercleanup to
ExportRefused, so a rawOSErrorescaping there skipped that cleanup and leaked thestaging tree and its ownership marker (the marker authorises the next run's recursive delete). The
enumeration failure is converted to
ExportRefusedso the existing cleanup runs. Audited everyother
ExportRefused-keyed cleanup for the same escape route: the transaction reads (existingreport, carried plan, plan re-read) and the manifest/plan reads in the ownership check already
convert
OSErrortoExportRefusedor fail closed; the remaining fs ops beneath a cleanup run onbuild-owned staging/out/previous paths whose rename/replace failures are handled by the rollback.
Windows test suite: split to match the POSIX-only guard
The POSIX-only entry guard makes the builder refuse on Windows, so every existing Windows
test that drove the builder end to end now hits that refusal (about 141 cases). These are
split by what each test verifies, not silenced:
@_posix_onlyskipif) because they verify builderbehaviour -- bundle contents, prompt inlining, plan writing/carrying, skill copying,
successful builds, CLI output. That capability does not exist on Windows, so the test is
not applicable there rather than failing.
handling (reparse points, UNC, a redirected spec/out/marker/aside path). On POSIX they
still assert their original specific refusal; on Windows they assert the guard, which is
now the contract.
pytest.raises(ExportRefused)is retained in both.No test was deleted. On POSIX every one of these still runs and asserts what it always did;
the markers and Windows branches only change what happens on the platform where the builder
is deliberately guarded off.
Report is published after the outcome, not before (promotion ordering)
The bundle report is the artifact an operator reads INSTEAD of checking the bundle exists,
so it must describe what happened rather than an assumed outcome. The report
os.replaceran BEFORE
staging.rename(out_dir), so a promotion that then failed left a report claimingsuccess -- a false record in the one thing offered as proof. The promotion now runs first and
the report is published only once the outcome is known. A failed promotion writes no success
report and leaves the prior bundle in place (the rollback restores it, keyed on a
promotedflag so the contract reads directly). Cost is at most a MISSING report if the report write
fails after a good promotion -- recoverable by regenerating, strictly better than a false one.
The report-path shape checks stay before the rename (destination validation, not the outcome).
This is one instance of the recurring shape tracked across the series: a failure or absence
must be converted into the module's own refusal, never surface as silence, a crash, or a false
success.
Audit: every ExportRefused-keyed cleanup, checked for a failure escaping it
Cleanups in
build_bundleand_refuse_unless_this_build_wrote_itare keyed onExportRefused, so a rawOSErrorescaping a filesystem op beneath them would skip thestaging/marker cleanup. Audited each:
_refuse_unless_this_build_wrote_it:d.iterdir()(the F3 site) now converts itsOSErrorto
ExportRefused; the manifest read (manifest.json) and the plan-recognition read bothalready catch
(OSError, ValueError)and either refuse or fail closed; the walk goes through_walk_no_reparse, which itself refuses an unreadable directory.build_bundletransaction: the existing-report read, the carried-plan read, and theplan re-read before write-back all convert
OSErrortoExportRefused. The remainingis_dir/exists/rename/os.replace/rmtreebeneath the cleanup act on build-ownedstaging/out/previous paths; a rename/replace failure is handled by the rollback, which now
keys the previous-bundle restore on
promoted.No remaining site lets an unreadable-or-failed filesystem operation escape as a raw crash past
an
ExportRefused-keyed cleanup.Author-supplied structure: absent / wrong-type / unreadable, enumerated
Every value this builder reads from an agent spec or a curation plan is author-supplied, so
each must state what happens when it is ABSENT, the WRONG TYPE, or UNREADABLE. The rule is
uniform: a failure or an absence becomes the module's own
ExportRefused, never silence, acrash, or a coerced/invented value. The table is the deliverable -- check it for a gap rather
than sampling for the next instance.
promptnamemcpServers{}(nothing selected){}; a SELECTED server that is not a dict -> ExportRefusedmcpServers[x](server body)toolsstr()-coerced -> fabricated grant)allowedToolsplan_versionskills/mcp[]idstr()-coerced)includesha256(content pin)str()-coerced -> fabricated integrity claim)crew/reviewed_by/reviewed_atThe four cells in bold are this round's fixes: three
str()-coercions that fabricated atool id, an allowedTools grant, an entry id, or a content pin, and one silent drop. Each now
refuses, naming the field and the actual type. The GPT
:2245finding is the firsttoolsbold cell; the audit found the other three in the same seam.
Anchors (roots that operations are relative to)
Separate from operations, because verifying everything UNDER a root is not verifying the root.
A symlinked or reparse-point root makes every check relative to it follow the link and judge
another tree, and a recursive delete keyed to that verdict runs through the link.
skillsroot (--source/skills)_is_redirecting_entryrefused beforeis_dir()/ the SKILL.md walkskill_dir(copy)_is_redirecting_entryrefused before the copy; the discovery walk never descends a reparse dirstaging/--out(pre-build)_is_redirecting_entryrefused for both before any operation<out>.previous(pre-purge)_is_redirecting_entryrefused before the ownership checkd(--out, the moved aside)_is_redirecting_entry(d)refused as the FIRST line of_refuse_unless_this_build_wrote_it, so the anchor is checked beforeexists/iterdir/bundle_digestfollow it, and the move-verify-delete re-runs it on the captured entrybundle_digest/ tree-hash rootd, or build-ownedstaging, or a walk-discovered skill dir -- never an unchecked author rootThe bold row is this round's fix (the reported
:2494). Auditing the anchor axis found nosecond unverified root: the others were already refused before use.
Exception-contract (a handler that catches but does not decide what the failure MEANS)
A function whose contract is
ExportRefused(or a bool) must not let a handled failure escapeas a raw foreign type. Enumerated the
except OSErrorhandlers that re-raise:_marker_is_ours(except OSError)_write_nofollow(except OSError)ExportRefused; any other write failure (ENOSPC/EACCES/EIO) is a genuine WRITE failure, propagated deliberately -- build_bundle'sexcept BaseExceptionrollback removes staging+marker, so it aborts cleanly. Decision recorded in a comment; converting would only relabel a real I/O error.Resource-release-on-refusal (a correct refusal that leaks what the build acquired)
A refusal path must release the staging tree and ownership marker this build created, not only
report the reason -- a stray marker makes the next run read another build's claim and refuse on
it, turning one refusal into a standing one.
report_beforeread):2907named); matches every sibling refusal inbuild_bundleexcept BaseExceptionrollbackWrite-then-read-back (the build cannot assume it owns what it reads again)
A value the build WROTE and then reads later -- the staged tree, the staging marker, the temp
and promoted report, the carried plan, the prior bundle -- has a window between the write and
the read in which another process can remove, replace, or substitute it. "I wrote it, so I know
what it is" is a cached assumption with a window under it, the same shape as re-resolving a path
by name after checking it. Each read states what happens when the object is MISSING, REPLACED by
a different object, or UNREADABLE.
_staged_tree_hash)_copy_skillreturnsis_file(), same refuse pathos.replace)O_DIRECTORYonce, the leaf re-checked bylstatagainst that fd, then replaceddst_dir_fd=fd, so a foreign file swapped in during the by-name window is refused, not clobberedreport_before; a concurrent in-place edit (same inode, readable, different bytes) is a foreign write, and the build owns the report exclusively for its duration, so it refuses BEFORE promotion rather than clobber the editskill_candidates)_walk_no_reparse)_marker_is_ours)report_before)<out>.previous_is_redirecting_entry-checked:613,:3231). A value read back has FOUR independent properties, and each can have changed since it was written: whether it EXISTS, whether it is the SAME OBJECT, whether its CONTENT is unchanged, and whether it is READABLE. The columns enumerate exactly those four -- there is no fifth -- so with the rows enumerated and the columns closed the completeness argument on this axis is finished. This round adds the content column (:2809, a same-object-different-content data-loss bug where a concurrent in-place report edit was silently overwritten) and the skills-root row (:1365, a directory's shape is author-supplied input just as a spec field is: a non-directory root now refuses instead of shipping a silently empty bundle). A third fix--out: a screen that mustlstatits subject to judge it\\host\sharepath thelstatIS theread_agent_spec,read_plan) already run a purely-localis_unc_shapescreen before touching, butbuild_bundle--outis author-supplied -- every path it touches (the parent, staging, marker,_is_redirecting_entrywas the probe._refuse_unc_outbuild_bundle(so the API surface is covered,--outtouch.Write path and scan parity (two follow-ons and a new axis)
Three more findings landed after the columns closed, two of them tightening the write-then-read-back axis and one opening a new one.
_write_guarded). The staged tree lives beside--outin a directory the build does not own, and leaves were written with a plainwrite_textaftermkdir-- a symlink planted at a leaf in that window was followed and its target truncated. The write now goes through_write_nofollow(the same primitive the marker and report use), and_staged_tree_hashrejects a redirecting staged entry at final hashing so a leaf swapped to a link after the write is refused rather than hashed through.promoted and not report_writtenrollback then unlinked the report unconditionally -- destroying the same foreign write on the way out. The unlink is now conditional on the report still matchingreport_before; a drifted or foreign report is left in place. (This is the same same-object-different-content property, enforced on the exit path as well as the publish path.)kiro_crew.securityis not importable, so the credential scan falls back from the canonical redactor to local patterns plus a decode pass. A bare, unlabelled 40-char AWS secret access key matches no labelled pattern and base64-decodes to non-UTF-8 bytes the decode pass skips, so it shipped -- while the canonical redactor catches it by shape. The standalone path now carries a faithful structural detector (exact-40, all-three-char-classes, not-hex-only, bounded lowercase run, bounded vowel ratio, Shannon-entropy floor, not-decode-to-printable, slid across a 40-char window), biased toward not-flagging so a git sha, prose, or an encoded-text blob are not refused. The invariant: the deployment-path control is not strictly weaker than the canonical one for a known secret shape. Auditing this axis end to end found noDisposal-failure symmetry, a floor false-positive, and the promote-first honesty
_dispose_via_private_asidemoves a tree into a run-private aside, verifies it, then calls a caller-suppliedsettle. The verify-failure path already restores-or-retains, butsettlehad no handler: on a rebuild the aside holds the operator's verified current bundle, and ifsettle(theos.rename(moved, <out>.previous)that keeps it as the rollback copy) raised -- a concurrent nonempty<out>.previous, the racing-writer class this machinery exists for, givesENOTEMPTY-- thefinallyrmtree(private)recursively deleted it.settleis now wrapped in the same restore-or-retain discipline (BaseException, so a cancelled build is covered too): put the tree back, and if that also fails, retain the aside and name where it sits rather than delete a tree this build did not create.--crew credentials(orclient_secret,service_account,.env) maderead_agent_specunconditionally refuse the crew's own spec, a false positive the shared validator does not produce. The crew-spec leafagents/<name>.jsonis now exempt from the name rule (the directory rules and a non-.jsoncredential leaf underagentsare still caught), so the floor is not stricter than the validator.third live instance: the marker, plan, and previous-bundle reads were already closed in earlier
rounds.
CI note: the red Windows shard is a main-owned race, not this change
Backend Tests (Windows) (4)is red on issue #9352, a pre-existing concurrency race intest/test_work_ledger.py::test_two_conductors_binding_one_worker_at_once_yield_exactly_one_binding. This PR does not touchwork_ledger.py; the failing test is outside the diff. The issue's diagnostic commit is already on main (and so in this branch), so a rebase does not clear it -- the losing threads report a mix ofalready_boundandinvalid_valueacross runs on identical input, i.e. a timing race, not a test-expectation change. Every other lane is green.read_planread the--allowplan through_read_text_nofollow, whoseO_NOFOLLOWguards only the FINAL component -- so an intermediate directory swapped for a symlink (--allow /tmp/alias/auth.jsonwithalias -> ~/.codex) is followed into a credential file, and the literal-component standalone fence cannot catch it because the resolved location is not spelled in the path.read_agent_specalready reads through_read_text_openat, which opens every component no-follow viadir_fd;read_plannow does the same, anchored at the absolutized (not resolved) filesystem root, so a redirect at ANY component fails its own open. This is the anchor axis carried to the second author-facing reader.Whole-window path anchoring, enumerated
The recurring finding class -- a guard that covers a path's LEAF but not the parent or intermediate components that reach its subject -- is closed by enumeration rather than one site per round. Every filesystem operation on an author-supplied path (from
--out,--source,--allow,--crew, the crew home, a skill path) was audited against two questions: does the guard cover the entire path window (every component, no path string re-resolved after a check), and what does the operation's failure become. The reads and opens now share three whole-window primitives:_open_dir_nofollow_pinned(dir)opens a directory by walking every resolved componentO_RDONLY | O_DIRECTORY | O_NOFOLLOWdescriptor-relative -- used for the staged-leaf write parent (_write_bytes_nofollow), the marker-read parent (_marker_is_ours), and the report publish parent (_publish_report), so a parent/grandparent swapped after resolution fails its own no-follow open instead of redirecting the leaf op outside--out._read_text_openat/_read_bytes_openatread a file anchoring every component no-follow via one shared_open_leaf_nofollow_atwalk -- used for the agent spec, the--allowplan, the carried/current plan bytes, the report drift baseline, the skill-copy source reads, and the skill enumeration reads. A signed artifact (the carried plan) is read byte-exact._walk_no_reparsenever descends a reparse point.Resolved-first is deliberate: a per-component no-follow walk over an unresolved path refuses at an ordinary home-directory symlink (home dirs are often symlinks), so
resolve()collapses the legitimate links once and the walk then makes a refusal mean "a component changed after resolution" -- the swap this defends against. About 55 further sites are excluded as operating on paths the build created and owns under its staging marker, on_walk_no_reparseoutput, or already_is_redirecting_entry-checked and fail-closed; those need no anchoring because no author chose the string.