fix(atomic-write): refuse a redirected parent for secret writes - #4918
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design review complete. The diff is a single guard in Design-Verdict: CONCERNS Sound chokepoint fix, but "Closes #4381" retires the class report while seven named hand-rolled secret writers still carry the exact exposure. Watch
[DESIGN-REVIEWED] 89cef67 |
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/atomic_write.py:272 -- function-local False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of First-Principles-Verdict: CONCERNS The fix is real and class-wide for What this change shipsIntent: stop a pre-planted parent symlink/junction from silently redirecting secret writes — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 89cef67 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates hinge on the same reachability question, so I traced the arithmetic and every production caller. Candidate 1 (IndexError at line 328): The logic error is real. If But the second iteration only runs when Candidate 2 (outside-roots walk): Same reachability wall — no production caller writes a secret outside the owned roots, so the Neither survives falsification. The normal in-tree path — which is what the diff actually exercises — computes a valid No findings. [OPUS-REVIEWED] 89cef67 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
66a4dd4 to
25f2072
Compare
atomic_write(restrict_to_owner=True) mkdir'd, mkstemp'd and renamed through whatever path it was handed, and none of those three follow-safe the parent chain, so a symlink or Windows junction pre-planted at the destination's parent redirected every byte to the link's target while the caller saw success. All nine secret-writing call sites shared the exposure, so the refusal goes in the shared helper rather than in each caller. The guard splits the parent at the innermost KiroCrew-owned root containing it. At or above that anchor a link is the operator's own layout (a relocated data home, a symlinked home) and still writes; below it every directory is one KiroCrew creates itself. Two checks then run, because neither is sufficient alone: an lstat walk over the components below the anchor, which is the only thing that sees a Windows junction, and an equality check that the parent resolves to exactly the path rebuilt from the resolved anchor. Containment would not do -- a link aimed at another directory inside the owned tree resolves to a contained path yet still lands the secret somewhere the caller never named. Outside those roots the walk stops at the first ancestor that already exists, because everything under it is a directory this write creates. Closes #4381
25f2072 to
89cef67
Compare
…dotdev#4918) atomic_write(restrict_to_owner=True) mkdir'd, mkstemp'd and renamed through whatever path it was handed, and none of those three follow-safe the parent chain, so a symlink or Windows junction pre-planted at the destination's parent redirected every byte to the link's target while the caller saw success. All nine secret-writing call sites shared the exposure, so the refusal goes in the shared helper rather than in each caller. The guard splits the parent at the innermost KiroCrew-owned root containing it. At or above that anchor a link is the operator's own layout (a relocated data home, a symlinked home) and still writes; below it every directory is one KiroCrew creates itself. Two checks then run, because neither is sufficient alone: an lstat walk over the components below the anchor, which is the only thing that sees a Windows junction, and an equality check that the parent resolves to exactly the path rebuilt from the resolved anchor. Containment would not do -- a link aimed at another directory inside the owned tree resolves to a contained path yet still lands the secret somewhere the caller never named. Outside those roots the walk stops at the first ancestor that already exists, because everything under it is a directory this write creates. Closes kirodotdev#4381
What is the problem?
kiro_crew.atomic_write.atomic_writeis the repo's canonical secret writer -- nine production call sites passrestrict_to_owner=True. It runspath.parent.mkdir(parents=True, exist_ok=True), thentempfile.mkstemp(dir=path.parent), thenos.replace, and none of those three follow-safe the parent chain. A symlink (or Windows junction) pre-planted at the destination's parent, or at any ancestor of it, silently redirects the whole write: the secret lands under whatever the link points at and the caller sees success.Reproduced on unmodified main before any change, in both shapes:
GPT 5.6 first raised this on PR #2190 against one caller, where it was rebutted for that caller because both agent write channels to that path are already gated. The class is wider: every
restrict_to_owner=Truesite has the same exposure and most of their parent directories are not on the sensitive-path denylist. The issue rejected per-caller patching as whack-a-mole.Why this issue matters to the user
The 0600 bits are only half of what protects a secret file. The other half is that it lands in the directory the caller named, inside the sensitive-path fence that is the only real boundary against a same-UID reader. A redirected write puts credentials, HMAC keys, tokens and refresh-token reuse state at an attacker-chosen location outside that fence, with no error anywhere. The planting is something an attacker can set up at leisure, long before the write happens.
How our fix solves it
Symptom: a secret written through a planted parent link lands at the link's target. Root cause: the three primitives above follow every component except the final one, and the helper never checked the chain. So
atomic_writenow calls_refuse_linked_parentwhenrestrict_to_owner=True, before themkdir-- after it would be too late, sincemkdir(parents=True)walks through the link and builds the missing directories under its target.The guard splits the parent at the innermost KiroCrew-owned root containing it (
config.paths.data_home/legacy_home/kiro_home), and the anchor is returned in the caller's own lexical namespace together with the names below it:$HOME, a symlinkedconfig.jsonare all supported setups. The innermost root matters here, because the default layout nests two owned roots (~/.kiro/crewinside~/.kiro) and anchoring on the outer one would put a relocatedcrewlink below the anchor and refuse it.mkdircreates, so a link there was planted by something else./tmpon macOS) is not ours to judge.Two checks then run, because neither is sufficient alone:
lstatwalk over the components below the anchor viaplatform_compat.is_link_or_junction-- the only thing that sees a Windows junction, whichos.path.islinkreports as False;realpathfollows butislinkmisses.A first revision of this patch anchored the walk on resolved-path equality and broke out of the loop before the link check. A pre-push security review caught that a link pointing back at the anchor then satisfied the break, so it was never link-checked and the rest of the chain was never walked. That is why the anchor is now lexical and the two checks are separate; both shapes are pinned by tests.
Resolution failures fail closed rather than escaping.
Path.resolve()reports a symlink loop asOSErroron the versions that delegate toos.path.realpathbut asRuntimeErroron Python 3.10, which this repo still supports, so both are caught: a looped parent produces the refusal instead of aRuntimeErrorsurfacing inside a caller's secret write. The same applies to root collection, wherekiro_home()resolves its own override -- a resolver that cannot answer drops out of the anchor set instead of failing the write.Only the parent chain is checked. A leaf link is not a redirect:
os.replacedoes not follow the final component, so it swaps the link itself for the new file and the link's target keeps its old contents. The check is lstat-based and so not race-free -- a link planted between the check and themkstempstill wins, and closing that would need anO_NOFOLLOWper-component descent thattempfilecannot be driven through.memory.py's lock-path check states the same limitation for the same reason. Refusing a link that is already there removes the pre-planting shape the report is about. Non-secret writes are untouched.restrict_on_error="warn"cannot downgrade this: the refusal raises beforemkstemp, so the two callers that passwarn(refresh_tokens.py,md_notebook/server.py) skip the write through their ownexcept OSErrorrather than redirect a secret.What tests we did
test/test_atomic_write_parent_link.py(new, 21 tests), one behaviour each:is_link_or_junctionis stubbed to miss it, pinning the resolved-path check on its own;RuntimeErrorinto "cannot prove", and a root resolver that raises dropping out of the anchor set rather than failing the write;restrict_on_error="warn"still refusing; non-secret writes unaffected; the ordinary write unaffected.Mutation-verified: 12 mutants, all caught -- guard not called, guard moved after the
mkdir, walk removed, equality check disabled, equality relaxed to containment, candidate order swapped, outermost root chosen as anchor, fail-closed branch removed, no-anchor walk stripped of its link check, bothRuntimeErrorcatches narrowed back toOSError, and the message dropping the component name. An A/B against the first revision confirms it fails the two alias tests that the current one passes.Caller-side suites (
browser_cli/token,refresh_tokens,secrets_vault,webhooks_store,mcp_gateway_prewarm,md_notebook) and the atomic-write suites pass -- 401 tests over the run before the last two review rounds.black(repo baselined gate),isort,flake8, the brand-name gate andmypyare clean on the touched files. Symlink-planting tests skip on Windows, where creating a directory symlink needs a privilege CI lacks.Any other suggestions on the work
Scope correction, since the issue's framing invites over-reading this: the refusal closes the exposure for every writer that goes THROUGH
atomic_write, which is the ninerestrict_to_owner=Truesites. It does not close the class. At least seven hand-rolled secret writers still do their ownmkdirplusmkstempand never enter the helper, so they keep the exact exposure --sel.py(the HMAC key),dashboard/token_auth.py,dashboard/token_secret.py,beacon.py,apps/install_receipt.py,mcp_gateway/rewriter.py(the env sidecar holding API keys),dashboard/handlers/messaging.py,dashboard/handlers_system.py. Migrating them onto the chokepoint is a larger change than this one and belongs in its own issue; naming them here so the gap is on the record rather than implied to be fixed.One behaviour change worth stating plainly: an operator who relocates a single directory below the data home onto another disk with a symlink (say a large state directory) will now get a hard
OSErroron secret writes into that subtree. That is the line the issue asked for, and the message names the offending component so it is actionable, but it is a real change and not only a hardening._owned_rootsalso listslegacy_homeandkiro_homeeven though the nine current callers all land under the data home. That breadth only ever tightens the check below those roots, and it is what makes the innermost-root rule meaningful on a legacy-layout install, so it stays.Closing the remaining race would need
atomic_writeto stop usingtempfile.mkstempand descend withO_NOFOLLOWdirectory handles instead. That is a bigger change to the writer's shape than this fix, and worth its own issue if the pre-planting refusal proves insufficient.Closes #4381