fix(security): read prompt paths through the descriptor gate - #8249
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A recurring, named defect class closed with the repo's existing gate; scope, refusals, deferrals, and the tolerated site are each argued with evidence and pinned by revert-verified tests. [DESIGN-REVIEWED] f364767 |
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 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All verification is done. Composing the review. Verification summary: the gate ( First-Principles-Verdict: CONCERNS Every fixed site reuses the existing gate at cause level, but the defect class and the new audit-on-refusal invariant both have counted, undispositioned siblings. What this change shipsIntent: stop three dashboard reads from re-opening a validated prompt/skill path by name — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] f364767 |
6a2dd3c to
96091b3
Compare
Legitimate, and it refutes the PR body's own reasoning rather than the code. Fixed in I omitted Verified against the real three-dot diff, not the finding text: both quoted lines are added lines of this PR, so this is this PR's own residual and not pre-existing main code. Why the derived root is still the correct root, which is the part worth stating rather than leaving to the next round. A link the entry legitimately points at is followed by Pinned deterministically, with no timing. Opposite failure mode re-checked, since |
Legitimate, reproduced, and fixed in Why fixing beat deferring here, given site 4 was deferred. The two deferrals are not the same kind of thing. Site 4 cannot land because #7715 deletes the exact lines it would rewrite, so there is no shape to land it in; this one is untouched by #7715 (its The mapping is the surface's own, not a new one. A refusal leaves Coverage was absent, so it is new. The The lane's grep is now exhaustive for this surface, and I re-ran it: |
The mechanism is correct and I checked it rather than assuming — uv's default The package half of the prompt listing has no entries at all in this product. For the halves that DO have entries here, the refusal is a consistency gain rather than a new cost, which is the load-bearing half of the rebuttal. Why not scope the refusal to the user-writable scopes anyway. It would mean threading each entry's On the second bullet — record the true refusal cause in SEL metadata. Agreed in principle and not done, for a reason that is about the mechanism rather than the effort: the gate reads and judges through ONE descriptor and returns a bare |
96091b3 to
613712d
Compare
Real, and the Design lane raised the same gap independently ("today a gate refusal is undiagnosable"), which is what settled it. The refusal shapes this PR chose are deliberately indistinguishable from I/O trouble in the HTTP response, and that argument holds only for the response: SEL is operator-side and not reachable through the endpoint. Without a line there, the primary exploit is not merely refused, it is invisible — an entry that lists with an empty description is byte-identical to a prompt that simply has none, and a 404 is what an uninstalled skill name produces. So the planted alias the PR exists to stop would leave the operator nothing to find. All three refusals now write one through The line records THAT bytes were withheld and never why, and that is the gate's constraint rather than a choice. Tests, revert-proven. |
Real, and it is this handler's own convention that decides it. Now The two prompt-side reads are deliberately left on the loop, and the distinction is the bound, not consistency. Test, revert-proven. |
The site is exactly where the lane says it is — I checked the file, not the label — and it is the fifth of this shape rather than a sixth. What does not hold is that its neighbours treat that class of root differently, and correcting that is what settles it. The actor is also not the same as fixed site 3, and that is the load-bearing half. Site 3's path arrives from Applying this PR's gate there would trade an unreachable regression for a reachable one, which is the specific reason not to. An edition contributes roots that can sit inside So: examined and deliberately left alone, per the brief's instruction that an unnecessary hardening change in a security-sensitive path is a cost rather than a win. Recorded here as the census entry the lane asked for. Any future change to that branch belongs with On the Subtractions note (the hardlink rationale ships five times). Fair, and I am not spending a push on prose while the lanes are mid-round on a security fix; the shared half is already in |
613712d to
1d4b042
Compare
The mechanism is right and I re-verified it on this head rather than restating the earlier round's answer. On the residual for an edition that does fill those seams: the exposure is smaller than the finding reads, and the alternative is worse. A refused description keeps its listing entry — name, Why the "document" half is not taken in this PR, stated as a knowingly out-of-scope sibling rather than skipped. The constraint the lane wants written down is a contract on the CPP seam — "a root contributed through Rule candidate: when a security refusal keys on a property an INSTALLER can set ( |
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Three content reads validated a path and then re-opened it by NAME, so
the bytes finally served were not provably the bytes anything checked:
the listing's per-entry description (_extract_sop_description, run for
every package SOP and every user prompt behind GET /api/prompts), the
unscoped GET /api/prompts/{name} -- the branch with no ?scope= that
resolves across the package SOP roots and both user scopes -- and the
package/ branch of GET /api/skills/{name}.
A hardlink breaks the check-to-use equivalence with no race at all. It
shares its target's inode, so realpath yields the alias's own innocent
path, is_symlink() is False and is_sensitive_path sees an ordinary *.md
inside the prompt directory, while the bytes belong to whatever it
aliases. A project's .kiro/prompts is content the user CLONED, so an
alias of ~/.aws/credentials planted there had that file's first '#'
comment line published as a prompt description and its whole body
returned by the unscoped read -- a file the agent's own read gate
refuses outright. The skills site needs a weaker actor (write access to
a capability-seam package skill root) but is the same defect. st_nlink
is the only signal a second name for a protected inode leaves, and it
is readable only on a descriptor.
All three now go through hooks.safe_read_file_bytes_nolink, the gate the
scoped read already uses: it opens FIRST with O_NOFOLLOW, then fstats
that one descriptor and refuses st_nlink > 1, a non-regular inode, and
an is_sensitive_path target, so the inode validated is the inode served.
Each passes within_root as the canonical path's OWN parent rather than
as an authorization -- these sites span several roots, so no authorizing
root exists to name the way ?scope= names one. It is passed because it
is the only thing that carries the guarantee onto Windows, where
O_NOFOLLOW does not exist at all and the open follows a leaf swapped for
a link after canonicalization; there the fd-real-path check is the whole
leaf guard.
The refusals report differently per surface, in every case as the
outcome that surface already produced for a file it could not open, so a
refusal is not distinguishable from I/O trouble and no endpoint becomes
an oracle for whether a path is protected. A refused description yields
an empty description and keeps the entry, because whether a prompt
exists is its caller's decision. A refused unscoped read answers the
same 500 an unopenable file already produced, and a refused package
skill leaves the content unset, which is the existing 404. Each refusal
also writes a coarse SEL line, because an identical HTTP answer must not
also be invisible to the operator: an entry listing with no description
is otherwise byte-identical to a prompt that has none, and a 404 is what
an uninstalled name produces. The line records THAT bytes were withheld
and never why -- blocked for a name validate_file_path refused before
any open, otherwise the surface's own outcome -- because the gate judges
and reads through one descriptor and re-statting the path to recover a
cause would be another by-name look at the input these reads exist to
stop trusting.
Bounds move with the reads. The unscoped read's MAX_PROMPT_BYTES cap
comes off a pre-read stat and onto the gate's own max_bytes, so the size
that refuses is the size actually read; the description read passes
allow_truncate instead, since a description is frontmatter or a first
heading and raising would turn one oversized file into a 500 for the
whole listing. The package-skill read has no caller cap, so it reads up
to the gate's 50 MB default and runs under asyncio.to_thread like the
same handler's delete and update verbs.
Spec updated in the same commit.
Co-authored-by: Kiro Crew <kirocrew@users.noreply.github.com>
1d4b042 to
f364767
Compare
The lane's actual ask here is that these be examined instead of silently omitted, and that is the part worth answering directly: both were examined, and both reproduce the shape. Deferred rather than folded in because the correction is real but belongs to a different surface. This PR's census is scoped to the prompt/skill endpoints named in its title, and both new sites live in other modules with their own callers and their own failure contracts — the snapshot read returns Tracked in #8429, which names both call sites with the surrounding code, prescribes the same |
Verified rather than taken on trust, and the lane is right on every particular. In Deferred rather than fixed here because of what the two surfaces actually differ in. The SEL line is a rider on this PR, not its fix; the fix is the three prompt/skill reads that were serving unverified bytes. The steering sites are not serving anything — the gate already refuses correctly there, so what is missing is observability, not containment. That is the distinction that makes this deferrable at all: no bytes escape either site today, so nothing is shipping broken while it waits. Extending a newly-introduced invariant into a second handler module this PR does not otherwise touch would widen the diff into unrelated code and re-arm the full review wave for a change with no behavioural effect on the defect this PR closes. Tracked in #8430 with the two invariants that have to survive the port: record only THAT bytes were withheld and never why, since re-statting the path to recover a cause would be another by-name look at the input these reads exist to stop trusting; and leave |
The observation is accurate and I am not disputing it. The three regions are 29, 26 and 26 lines, and while the third opens by back-referencing ("Same descriptor gate as the prompt reads above, for the same reason:") it then re-derives the whole argument anyway, so "divergent copies" is a fair description of what a future editor would have to keep in step. What makes it disproportional is the cost of acting on it at this moment, not the merit. This is a comment-only edit with no behavioural effect, and the PR is currently converged: readiness The rule I am applying is the narrow one: this is about not doing work this PR does not need, and it is explicitly not a claim that the fix is incomplete — no reachable behaviour, no guard and no test is affected either way. So the commitment rather than a refusal: if any further push to this branch becomes necessary for a real reason — a CI red, a blocking finding, or a defect in this diff — I will fold this consolidation into that same amend, where it costs nothing extra. If the branch reaches merge without another push, the duplication ships as three comments that agree with each other, which is a readability cost and not a correctness one. |
chenmingwei23
left a comment
There was a problem hiding this comment.
Approving: PR Readiness green (the repo's only required check), no failing lanes, MERGEABLE.
Problem / Motivation
While #7715 was in review, the GPT lane raised the same defect class three
times under three names: by-name reopen of a validated, repository-supplied
prompt path. The shape is always the same — a prompt path is resolved and
validated once, and then a LATER step re-opens it by name, so what is
finally read is not provably the thing that was validated.
#7715 fixed the most severe instance (the
@mentionread, whose bytes reach anagent turn as instructions) and its three siblings were dispositioned
accepted-and-deferredon the owner's ruling that they land in an independentPR. This is that PR. This PR's own First Principles lane then found a fourth
instance in the same file, which is fixed here too, and a fifth in
skills.py, which is examined below and deliberately left alone. Three of thefive are fixed; one is deferred because it sits inside code #7715 rewrites, and
one is a tolerated by-name read of operator-installed content whose hardening
would cost more than it buys (both below).
The concrete, no-race exploit for every fixed site is a hardlink. It shares
its target's inode, so
realpathyields the alias's own innocent path,is_symlink()is False,O_NOFOLLOWhas no link to refuse, andis_sensitive_pathsees an ordinary file sitting inside the directory it wasfound in — while the bytes belong to whatever it aliases.
st_nlinkis the onlysignal a second name for a protected inode leaves, and it is readable only on
an open descriptor.
Why it matters
A project's
.kiro/promptsholds content the user CLONED, and anything withwrite access to a checkout (including the agent itself) can plant
ln ~/.aws/credentials <project>/.kiro/prompts/notes.md. Before this PR:GET /api/promptspublished that file's first#comment line as theprompt's description (an INI-style credentials file and
~/.ssh/configboth use
#comments, and a YAML file can supply a frontmatterdescription:).GET /api/prompts/{name}returned its whole body, up toMAX_PROMPT_BYTES.GET /api/skills/package/<name>did the same for a capability-seam packageskill root, with no size bound at all.
All of those are files the agent's own read gate refuses outright, served
through dashboard endpoints that had already "validated" the path.
What changed (motivation → approach → change)
The five sites, each verified against the real diff
origin/main)handlers/prompts.py:193_extract_sop_description—path.read_text()after every caller has already judged the path (handlers/__init__.py:579for package SOPs,:602for user prompts). Cited by the GPT lane ashandlers/__init__.py:630in #7715's numbering.handlers/prompts.pyunscopedapi_prompt_detail—validate_file_path(p["path"])thenPath(resolved).stat()+Path(resolved).read_text(). Cited asprompts.py:443.handlers/prompts.pyapi_skill_detail'spackage/branch —validate_file_path(row["path"])thenPath(resolved).read_text(), the exact pre-fix shape of site 2. Found by this PR's own First Principles lane.handlers/__init__.py:586-607,prompts_dir.glob("*.md")against a root_resolve_prompt_dirvalidated by name. Cited asprompts.py:360.skills.py:2433-2437load_skill's_extra_pathsbranch —validate_file_path(skill_file)thenPath(resolved).read_text(). Raised by the First Principles lane on the second revision.What the fix does
All three fixed reads now go through
hooks.safe_read_file_bytes_nolink, thegate #7715 already routes the
@mentionread and the scoped read through. Itopens first with
O_NOFOLLOW, thenfstats that one descriptor and refusesst_nlink > 1, a non-regular inode, and anis_sensitive_pathtarget — so theinode validated is the inode served. This is an existing repo mechanism with
existing precedent on exactly this shape (
docs/system-specs/features/steering-viewer.mddocuments the same hardlink-in-a-listing fix for the steering surface); no new
matcher, no new helper, and no existing check is weakened or removed.
within_rootis passed on all three, as the canonical path's own parent. Thefirst revision of this PR omitted it, arguing that a root derived from the
resolution being defended authorizes nothing. That reasoning was right about what
it authorizes and wrong about what it is for: on Windows
O_NOFOLLOWdoesnot exist at all and the gate asks for it with
getattr(os, "O_NOFOLLOW", 0), sothere the open FOLLOWS a leaf swapped for a link after canonicalization and the
fd-real-path check (
GetFinalPathNameByHandleW) is the only thing left that cansee the inode opened is not the one resolved. A link the entry legitimately points
at is already followed by
validate_file_path, so its target's own directory ISthe root and only a substitution landing after that resolution escapes it.
Deterministically pinned by two tests that delete
os.O_NOFOLLOW(the gate readsit with
getattrat call time, so removing the attribute reproduces Windows'open semantics on any host) and inject the swap inside the gate's own
validate_file_pathcall, so no timing is involved.The refusals report differently per surface, on purpose — in every case as the
outcome that surface already produced for a file it could not open, so a refusal
is indistinguishable from I/O trouble and no endpoint becomes an oracle for
whether a given path is protected.
Whether a prompt exists is its caller's decision, not this function's, and a
library that dropped a file because its metadata was refused would hide a name
the scoped read still serves. That also keeps an unreadable prompt (bad mode,
transient error) listed with no description, exactly as the by-name read did.
500 file not readableanunopenable file already produced. No new response body and no new status code,
so no new
codefield is owed.contentunset, which is the existing 404.Bounds. The unscoped read's
MAX_PROMPT_BYTEScap moves off a pre-readstatand onto the gate's ownmax_bytes, so the size that refuses is the sizeof the bytes actually read rather than of a separately-
stat'd name;FileTooLargeErroris not anOSError, so each site catches it explicitly — theprompt read to keep its coded 413, the skills read so the gate's default 50 MB
ceiling cannot escape as an unaudited 500 where an unbounded
read_textpreviously succeeded. The description read passes
allow_truncateinstead of acap, because a description is frontmatter or a first heading — both at the head
of the file — and raising there would turn one oversized file into a 500 for the
whole listing, something the unbounded by-name read it replaces could not do.
One tidy-up that is not incidental:
validate_file_pathmoves from twofunction-local imports to the module-level
hooksimport. That is load-bearingfor the tests above — the handlers must hold their own binding so patching
hooks.validate_file_pathreaches only the gate's internal call, which is thewindow being simulated — and it retires the two stale
# noqa: F811s.Second-revision fixes: the refusals are audited, and the 50 MB read is off the loop
The GPT lane blocked the second revision on two things that are not about the
gate but about what surrounds it, and both are fixed rather than argued.
Every refusal now writes one coarse SEL line (
prompts._audit_unread).The indistinguishable-response argument above is about the HTTP response and
holds only there: SEL is operator-side and unreachable through the endpoint.
Without a line there the primary exploit is not merely refused, it is invisible
— an entry listing with an empty description is byte-identical to a prompt that
simply has none, and a 404 is what an uninstalled skill name produces, so the
planted alias this PR exists to stop would leave the operator nothing to find.
The line says THAT bytes were withheld and never why: the gate judges and reads
through one descriptor and answers a bare
None, so a refused inode and anordinary read failure are the same value, and re-
stating the path to separatethem would be another by-name look at exactly the input these reads stopped
trusting.
blockedis carved out as the one knowable cause, becausevalidate_file_pathrefuses the name before any open; otherwise the surface'sown outcome (
error, ortoo_largefor the skills read). Best-effort, so anaudit write can never fail a listing or a detail view. The Design lane raised the
same gap independently; recording the gate's internal reason would need
safe_read_file_bytes_nolinkto return one, a contract change shared with itsother consumers and wider than this PR.
The package-skill read runs under
asyncio.to_thread. No caller-supplied capapplies to it, so it reads up to the gate's own 50 MB default off storage that can
be network-backed, on the one loop every other session's turn shares — and the
same handler already routes its DELETE and PUT verbs and its
kiro-user/GETbranch off the loop for that reason. The two prompt reads stay on it deliberately:
both are bounded (
max_bytes=MAX_PROMPT_BYTES= 100 KB, andallow_truncateforthe description), so the gate reads at most
read_limit + 1bytes and a hop wouldbuy nothing. Nothing about the gate's guarantees moves with the call — the open,
the
fstat, thest_nlinkrefusal and the fd-real-path check all still happen onone descriptor inside one call.
Site 5: examined, and left alone on purpose
The lane is right that
skills.py:2437has the same by-name shape, and the siteis exactly where it says. What does not hold is that its neighbours treat that
class of root differently.
skills.py:2468calls_read_enumerated_skill_bytes(skill_file, _within, ...), whose FIRST branch isif within is None: return path.read_bytes()— and its docstring names themembers of that branch outright: "the global skills dir, extra paths, edition
roots, and the paths writers construct themselves … these are operator-installed,
so there is no directory to confine them to". So the neighbour is hardened only
when a PROJECT grant supplies a root; handed an
_extra_pathsroot it takes thesame by-name read. There is no inconsistency inside
skills.pyto repair, thereis a documented decision with named costs: taxing that branch with the hardened
reader "measurably slowed the per-message listing path (
test_skill_listing_costguards it) and emptied frontmatter on Windows".
The actor differs too, and that is the load-bearing half. Site 3's path arrives
from
CapabilityManager.list_skills()at request time;_extra_pathsis resolvedonce at loader construction from the operator's own
cfg.skills.extra_pathsplusmcp_tooling.extra_skills(). Nothing a checkout supplies reaches it, and theentry served is
<extra>/<name>/SKILL.mdwithnamealready past_safe_name.Its immediate sibling one branch above (
self._dir / name / "SKILL.md") readswith no
validate_file_pathat all, so the_extra_pathsbranch is already thestricter of the two.
Applying this gate there would also trade an unreachable regression for a
reachable one. An edition contributes roots that can sit inside
site-packages,and a wheel installed by uv on Linux lands with
st_nlink == 2by defaultlink-mode, so a blanket
st_nlink > 1refusal would empty legitimate,operator-installed skill bodies on ordinary installs. The prompt sites carry no
such risk: nothing installs a
~/.kiro/prompts/*.mdor a checked-out.kiro/prompts/*.md, and the scoped read onmainalready refusesst_nlink > 1in both user scopes — so this PR made the listing and the unscoped read agree with
a refusal that was already there.
Site 4: why it is deferred, not skipped
The remedy for the enumeration is right and the primitive already exists in this
file (
_pin_prompt_dir, which the create and delete verbs use):scandiragainst the pinned descriptor instead of the name. It is deferred purely for
coordination. On
mainthe enumeration lives in_list_aim_prompts'user-prompt scan at
handlers/__init__.py:586-607, and #7715 replaces every oneof those lines (its
@@ -583,31 +767,10 @@hunk) with_scan_prompt_dir/_prompt_dir_entry/_local_prompt_entry— functions that do not exist onmain. Fixing it here would rewrite exactly the lines #7715 rewrites, producinga guaranteed conflict and a confusing double-fix, and there is no way to land the
pinned form on
mainwith a non-test caller in the shape #7715 is about todelete. It follows #7715, against its own
_scan_prompt_dirand_local_prompt_entry.Its residual is also the narrowest of the four, which is why it is the one left:
the actor in the chain is an agent with write access to the project, and that
actor can already get content listed and
@mention-ed by writing a real*.mdinto
.kiro/prompts— which the gate is designed to allow. What the root swapadds is aiming the enumeration at an EXTERNAL directory it did not author.
Coordination with #7715
This branch is cut from
origin/mainand its hunks are deliberately disjointfrom #7715's. Verified mechanically, not by eye:
git merge-tree --write-tree --messages HEAD refs/pull/7715/headmerges clean(exit 0, no conflict) across every shared file.
chat_runner.pyis untouchedhere on purpose — #7715 owns that whole read. The
**Prompts (CRUD)**line inlearn-cron-dashboard.mdis likewise untouched (#7715 replaces that entiresingle-line paragraph); the new spec text is a separate Prompt and
package-skill read safety paragraph three lines below it. An earlier revision
did conflict on one import line in
test/test_prompts.py; that import moved intothe helper that uses it and
merge-treeis clean again. Whichever lands secondneeds at most a trivial rebase.
Tests
Nine new tests in
test/test_prompts.py::TestPromptReadsGoThroughTheDescriptorGateand four in
test/test_skill_browser.py::TestPackageSkillDetailReadsThroughTheGate.Each planted prompt lives in a cloned checkout's
.kiro/prompts, which is theuntrusted half.
test_a_hardlinked_prompt_publishes_no_descriptiondescription == "", the secret appears nowhere in the listing, and the ordinary neighbour keeps its own description (the refusal narrows metadata, never the library)test_a_hardlinked_prompt_is_not_served_by_the_unscoped_read500, no secret bytes in the body, SELoutcome="error"test_a_prompt_resolving_onto_a_sensitive_target_publishes_no_descriptiontest_a_leaf_swapped_after_validation_publishes_no_descriptionwithin_root, in the Windows shape (os.O_NOFOLLOWdeleted)test_a_leaf_swapped_after_validation_is_not_served_by_the_unscoped_readwithin_root, same shapetest_a_hardlinked_package_skill_is_not_served404, no secret bytes in the bodytest_a_prompt_symlinked_to_an_ordinary_file_still_describesO_NOFOLLOWrefuses nothing about a link the user chose. Green before the fix too; it is here so a later round cannot tighten the description read into a blanket link refusal without going red. Labelled as such in its docstring.test_an_ordinary_package_skill_is_still_servedSKILL.mdstill returns 200 with its contenttest_a_prompt_at_exactly_the_cap_is_still_servedstatonto the gate'smax_bytestest_a_refused_description_leaves_an_audit_linetest_a_sensitive_description_target_is_audited_as_blockedblocked, the one cause that IS knowable because it precedes any opentest_a_refused_package_skill_leaves_an_audit_linetest_the_package_skill_read_runs_off_the_event_loopProven by reverting, not asserted. Every fix was reverted in the real file
and the tests re-run, then restored:
_extract_sop_descriptionback topath.read_text()test_a_hardlinked_prompt_publishes_no_description,test_a_prompt_resolving_onto_a_sensitive_target_publishes_no_descriptionstat+read_bytestest_a_hardlinked_prompt_is_not_served_by_the_unscoped_read, withassert 200 == 500— i.e. the reverted code hands the credential file back with HTTP 200within_root=dropped from both prompt readstest_a_leaf_swapped_after_validation_*testsapi_skill_detailback toPath(resolved).read_text()test_a_hardlinked_package_skill_is_not_served_audit_unreadcalls removedtest_a_refused_description_leaves_an_audit_line,test_a_sensitive_description_target_is_audited_as_blocked,test_a_refused_package_skill_leaves_an_audit_line,test_detail_unreadableawait asyncio.to_thread(...)back to a direct calltest_the_package_skill_read_runs_off_the_event_loopNothing existing was weakened.
test_detail_too_large(413,outcome="too_large") stays green unchanged, which is what pins that theresponse taxonomy did not shift.
test_detail_unreadable(chmod 000 → 500) isthe one existing test this PR edits, and the edit tightens it: it asserted
log_tool_invocation.assert_called_once(), and a mode-000 prompt now genuinelywithholds TWO reads — the resolution walks the listing, whose description read is
refused by the same bad mode — so it now enumerates the lines by
tool_nameandasserts
api_prompt_detail == ["error"]andapi_prompts == ["error"]. Itnames each lane instead of counting all calls, and it reds when either audit line
is removed. The hardlink tests
pytest.skipon a filesystem that cannot create asecond link; the symlink tests carry
@requires_symlinks, the runtime capabilityprobe, rather than a platform guess.
Manual verification
N/A — unit coverage sufficient. The change is a swap of one read primitive for
another on three backend code paths, and both the exploit and the tolerated cases
are reproducible from a planted inode, which is exactly what the tests do (real
os.linkand realos.symlink, no mocked filesystem). The Windows-only branch iscovered by removing the attribute the gate itself probes at call time, rather than
by claiming a platform run.
Related Issues
no linked issue: this is the follow-up split out of #7715 by the repository
owner's ruling, tracked by that PR's
accepted-and-deferreddispositions ratherthan by an issue of its own.
Pattern harvest
This is now the fifth instance of one class found in one file across five review
rounds, which is the definition of generalizable. The second lesson is separate
and cost this PR a round on its own: a descriptor gate whose leaf protection is
O_NOFOLLOWhas NO leaf protection on Windows, sowithin_rootis not optionalhardening there — it is the whole guard.
Rule candidate: review-prompt
Pattern: a path validated by NAME is re-opened by that same name for the read, so the bytes served are not the bytes checked — audit for
read_text/read_bytes/openon a path that a resolve,lstatoris_sensitive_pathabove it already judged, route it throughhooks.safe_read_file_bytes_nolink(the only way to seest_nlink, the one signal a hardlink leaves), and always passwithin_root, because on WindowsO_NOFOLLOWdoes not exist and the fd-real-path check is the only leaf guard left.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)