fix(security): gate linked ancestors before fs probes (#5962) - #6305
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound, fail-closed replication of a merged pattern — but the class is closed by grep, not by structure, so the next consumer silently reopens it. Watch
Suggestions
[DESIGN-REVIEWED] 75c3695 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All verification is done. The First-Principles-Verdict: CONCERNS Two undeclared DoS ceilings ride along in hooks.py, and the depth ceiling is a point patch — 10 of 12 What this change shipsIntent: stop a Windows ancestor/leaf link from laundering an SMB credential probe past the lexical UNC screens at every remaining site (#5962) — a FIX.
The core class is genuinely closed: grepped Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 75c3695 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe candidate list contains no candidates — the discovery pass found nothing to falsify. I independently reviewed the diff's load-bearing logic: the Windows-gated ancestor/leaf-link screens in No findings. [OPUS-REVIEWED] 75c3695 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
82ce14f to
bc21d6f
Compare
bc21d6f to
dcd7405
Compare
dcd7405 to
bf0644d
Compare
bf0644d to
0a94ef9
Compare
0a94ef9 to
da5ed32
Compare
da5ed32 to
33147ef
Compare
|
/ai-review override gpt 33147ef: The event-loop finding targets a bound this lane itself requested (round 3 demanded a depth ceiling as the event-loop remedy; round 6 flags that same <=255-lstat Windows-only bound as the stall). validate_file_path is a synchronous guard whose pre-existing realpath and is_sensitive_path already do filesystem I/O on the caller's thread; moving the whole guard off-loop is an architectural change out of scope for this hardening PR, per the maintainer escalation on issue #5962. |
Human judgment recorded@CrysisDeu marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
33147ef to
89be8cb
Compare
PR #5943 added first_linked_ancestor and wired it in the themes handler: on Windows, an ancestor symlink/junction whose target is a UNC share launders the lexical UNC screen, because the first filesystem call (even an lstat, which resolves every ancestor) becomes the outbound SMB probe. Replicate that Windows-gated, probe-precedes-nothing pattern at the four remaining is_unc_shape call sites, plus the second consumer of local_destination's lexical screen that review surfaced: - acp/prompt_blocks.py: refuse the image candidate after the lexical suffix screen and before is_file(), skipping it like the UNC case. - hooks.py validate_file_path: refuse before realpath (the exact chain-resolving call the seam describes). The EXPANDED form is also screened lexically first -- a roaming-profile `~` can surface a UNC shape the raw text did not have, and the ancestor walk itself lstat-s each component. Anchoring (lexical abspath, re-screened) and the walk are scoped INSIDE the Windows branch so POSIX keeps its byte-identical resolve-through-every-symlink semantics, pinned by a new test; the messaging spec paragraph documenting this function is updated in the same commit. - messaging/outbound_files.py _inspect: refuse before EVERY resolving call, including is_sensitive_path (whose candidate forms are built with realpath/resolve), with the same reply as the leaf symlink case; local_destination additionally screens the expanded form (still lexical, its documented contract). - image_artifacts.py _local_file: the registration-side consumer of the same lexical screen gains the same guard before its is_file() probe. - memory.py _read_root_guard: walk the workspace's ancestors before the leaf reparse checks, with an audit line; the old "deliberately not rejected" comment is rewritten to keep only the POSIX half, and the gate's INVARIANT docstring is qualified to match. No user-facing message names the offending ancestor. At every guarded site the LEAF also gets the junction-aware is_link_or_junction check the walk deliberately excludes (its docstring requires the pairing): the first resolving call FOLLOWS a final-component link, so a leaf symlink/junction targeting a UNC share is the same probe. Windows-marked tests per site mirror the themes tests: ordering pinned by wiring the downstream probes (including is_sensitive_path) to explode, mutation-checked by asserting the refusal disappears when the walk reports no link, and POSIX behavior pinned unchanged.
|
/ai-review override gpt 75c3695: The raceable-ancestor finding (walk-then-probe TOCTOU) is documented trade-off #2 in the PR body: closing it requires handle-retained no-reparse traversal with no Windows-portable equivalent across these five call sites, which the maintainer escalation on issue #5962 ruled an architectural change out of scope for this hardening PR. The walk still eliminates the static laundering class the issue names; the race window is recorded as an accepted residual. The sibling extended-namespace finding from this round is FIXED in this head (bare extended-length prefixes now require a drive-absolute remainder, else refused fail-closed, with parametrized regression tests). |
89be8cb to
75c3695
Compare
|
AI-review override not recorded: keep the reason to 500 characters or fewer. |
bolichen97
left a comment
There was a problem hiding this comment.
Correct replication of the merged #5943 pattern at the remaining is_unc_shape sites, and the load-bearing insight is the ordering one: in outbound_files._inspect the guard is placed before is_sensitive_path, not merely before the is_symlink() lstat, because is_sensitive_path builds its candidate forms with realpath/resolve and was therefore already the first chain-resolving call — putting the walk after it would have left the hole open. The validate_file_path leaf-chain walk is the most delicate part and it fails closed everywhere it should: readlink is local reparse metadata rather than a traversal, the \\?\UNC\ fold is case-insensitive so a lowercase \\?\unc\ spelling cannot fall into the local branch and launder a share, non-drive extended namespaces (GLOBALROOT, Volume{guid}) are refused instead of prefix-stripped into relative-looking strings, root-relative and drive-relative targets are refused because the string screened and the string realpath resolves could diverge by drive, and the for/else bounds the chain at an ELOOP-equivalent 40. Screening the anchored form and bounding depth before starting the walk is right too — the walk is one lstat per component, so on an untrusted UNC path or an adversarially deep one the guard itself would have been the probe. os.path.abspath is correctly scoped to the os.name == "nt" branch since it collapses .. lexically and would otherwise change what realpath returns across a symlinked component; POSIX stays byte-identical and is pinned by the dot-dot-through-symlink test. _local_file was the real gap worth catching — the sibling consumer of the same lexical local_destination screen — and refusal messages deliberately do not name the offending ancestor, which is the right call since that is filesystem layout the caller supplied a path to guess at.
Summary
PR #5943 added
first_linked_ancestorand wired it in the themes handler: on Windows, an ancestor symlink/junction whose target is a UNC share launders the lexical UNC screen, because the first filesystem call (even an lstat, which resolves every ancestor) becomes the outbound SMB credential probe. This PR replicates that Windows-gated, guard-precedes-every-resolving-call pattern at the remainingis_unc_shapecall sites, plus one sibling consumer review surfaced.Closes #5962
What changed
src/kiro_crew/acp/prompt_blocks.py— image-path loop: ancestor walk + junction-aware leaf check after the lexical suffix screen, beforeis_file(); a refused candidate is skipped like the UNC case (path stays in the text).src/kiro_crew/hooks.pyvalidate_file_path— inside a Windows-only branch: lexicalabspathanchoring (so the walk covers whatrealpathresolves), a UNC re-screen of the anchored form (~on a roaming profile or a UNC CWD can surface a UNC shape the raw text lacked;abspathnever strips UNC-ness so one screen covers both forms), the ancestor walk, and the leaf check — thenrealpathconsumes the same string the walk inspected. POSIX keeps byte-identical resolve-through-every-symlink semantics, pinned by a dot-dot-through-symlink test; themessaging.mdspec paragraph is updated in the same commit.src/kiro_crew/messaging/outbound_files.py_inspect— ancestor walk + leaf check FIRST inside the try, beforeis_sensitive_path(whose candidate forms are built withrealpath/resolve, so it was the first chain-resolving call) and before the junction-blindis_symlink()lstat. Same reply as the leaf case, per the reference.local_destinationadditionally screens the expanded form — still lexical, its documented contract.src/kiro_crew/image_artifacts.py_local_file— the registration-side consumer of the same lexical screen gains the same ancestor + leaf guards before itsis_file()probe.src/kiro_crew/memory.py_read_root_guard— Windows ancestor walk on the workspace before the leaf reparse checks, with an audit line (workspace_linked_ancestor); the old "linked ancestors deliberately not rejected" comment is rewritten to keep only the POSIX half, and the INVARIANT docstrings are qualified to match (write path inherits via_require_link_free_roots).No user-facing message names the offending ancestor (which ancestor is a link is filesystem layout the caller supplied a path to guess at).
Pre-push adversarial review
3 rounds of dual blind model-pinned review (GPT + Opus lanes, mirroring the CI charters). 14 findings total: 8 fixed (ordering vs
is_sensitive_path, expanded/anchored-form screens, the unguarded_local_filesibling consumer, the leaf-link class, POSIXabspathsemantics regression, test attributability), 4 advisory adoptions (docstring accuracy, stub completeness, audit-count bound), 2 documented trade-offs below. Round 3: GPT PASS zero findings; Opus findings were test-quality only, fixed as prescribed.Documented trade-offs (deliberate, matching the merged reference semantics)
C:\dev -> D:\dev). Narrowing to UNC-targeted links only would require reading each link's target and re-validating the target's own chain recursively; the merged themes wiring (fix(themes): enable theme pack routes on Windows #5943) chose refuse-all, and this PR replicates those semantics as the issue mandates. At the memory site a refusal surfaces as empty memory plus alogger.warning+ SEL audit entry — same surface the existing UNC/leaf refusals already use.first_linked_ancestoritself. An ancestor could be swapped to a link between the walk and the probe. Closing this needs handle-relative (openat-style) traversal with no Windows equivalent — a cross-platform redesign of the merged helper, out of scope for replicating its accepted pattern.Residuals noted in-code, follow-up planned:
is_link_or_junctionfails open on an unreadable component (pre-existing shared machinery); a mapped network drive /substtarget is outside the reparse-point class entirely.Testing
realpath,is_file,is_sensitive_path), mutation-checked (refusal disappears when the walk reports no link), POSIX behavior pinned unchanged (including the dot-dot-through-symlink canonicalization pin).no linked issue in title: issue reference carried in the trailing "(#5962)" of the conventional-commit subject and "Closes #5962" above.
No screenshots: backend-only security hardening, no UI surface.
Pattern harvest
Rule candidate: any call site that lexically screens a user- or agent-supplied path for UNC shape must place the screen (and a Windows linked-ancestor/leaf-link check) BEFORE the first resolving filesystem call — including
is_sensitive_path, whose candidate forms are built withrealpath()/resolve(). When a lexical screen lives in a shared normalizer (local_destination), every consumer that later probes the path needs its own guard; grep for sibling consumers before declaring the class closed.