feat(aws-control): read an external prompt reference safely - #9327
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound fix to a deliberately deferred problem, but the pinned-anchor read protocol is a one-off security composition that will face immediate duplication pressure. Watch
[DESIGN-REVIEWED] 1b7b05e |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All verifications are done. The shared hooks symbols exist, the pptx_maker agents genuinely carry First-Principles-Verdict: CONCERNS Two riders: a test pinning comment prose in two unrelated modules, and "annotated rather than lowered" chmods that the diff actually raises 0o700→0o755. Not justified as shipped
What this change shipsInventory (10 items) — 7 justifiedIntent: let an operator bundle a crew whose agent spec references its prompt as
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 1b7b05e |
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:954 -- “SUPPORTED” contradicts False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've verified the mechanism against the source. Let me confirm my analysis of the finding. The candidate is grounded: (a) Input: A crew whose (b) Call path: (c) Outcome: The whole bundle build aborts ("the prompt anchor … could not be opened") for a documented supported case — exactly the "final-component check only" behavior the design comment at 2905-2909 promises the outside branch, and exactly the "Walking from This is an over-refusal (functional/availability), not a security/crash/data-loss class — so advisory FINDING, fixable within the changed lines. A supported absolute-persona-outside-agents_dir prompt is falsely refused when any ancestor of its path is a symlink. FINDING — src/kiro_crew/apps/builtins/aws_control/crew/packaging/build.py:2989 — [OPUS-REVIEWED] 1b7b05e Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
1a99cc1 to
685b638
Compare
0636f23 to
5a2fda2
Compare
685b638 to
00e6d8a
Compare
5a2fda2 to
03f737a
Compare
00e6d8a to
5064ae6
Compare
03f737a to
e7f4bfa
Compare
5064ae6 to
a3d9bba
Compare
73d6b9c to
b564195
Compare
ebfd708 to
35037cc
Compare
b564195 to
c4c81e1
Compare
35037cc to
8aae598
Compare
2eb2935 to
aee2ba0
Compare
8aae598 to
3665349
Compare
3665349 to
92579a9
Compare
Rebuttal: pinned traversal on Windows,
|
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.
Stacked on #9213. Merge that first; this PR's diff is only the external prompt reader.
Why it is separate
#9213 refuses an agent spec whose prompt is a
file://reference. This adds the fences that make reading one safe.The split is not about line count. Across seven review rounds on #9213, this ~350-line region produced 20+ separate blocking findings while the rest of the module was settled, and several fixes to it created the next round's finding. Reviewed alone, a reader can hold all of it at once.
One test file is deleted, deliberately
tests/test_external_prompt_refused_for_now.pyis removed (-88 lines). It pinned the basebranch's REFUSAL of a
file://prompt, which is the behaviour this PR exists to lift, so itcannot pass and this change land at the same time. Its replacement is
tests/test_external_prompt_supported.py, which drives the same shapes through the reader:a persona inside the agents directory, an absolute persona outside it, a symlinked persona,
and a swapped parent, the last of these end to end in
test_a_swapped_parent_INSIDE_the_agents_dir_is_still_refused, which renames the real subdirectory aside, links its name at a secrets directory and asserts both the refusal and that no key material reachedagent.json.Stated here because a deleted test named "refused for now" reads like a silent revert to
anyone who has not traced it. The no-follow read path is not weakened by this PR: counted
across the packaging tree, base to head,
_read_text_nofollowgoes 17 to 38,_read_text_openat13 to 18,_NOFOLLOW_READ_FLAGS10 to 13,_is_redirecting_entry31 to33, and
_walk_no_reparse,scandir, and both credential detectors are unchanged. No symbolin that tree drops.
What is here
Five functions, and the reason each exists:
_resolve_prompt_pathPath.resolve()IS the traversal_open_nofollow_underO_NOFOLLOW_open_root_nofollow_open_attr_checked_underlstatwalk wheredir_fdis unavailable_read_text_nofollowTwo properties are worth stating because they are easy to get wrong:
resolve()collapses links, so a redirect check placed after it can never find one. An earlier version of this code walked the resolved path looking for reparse points and could not have worked; it passed its own tests only because they called it directly with an unresolved path.A Windows junction is a reparse point that is NOT reported as a symlink, and
shutil.rmtreetraverses one rather than unlinking it. Entries are judged by attribute, not byis_symlink().Where
dir_fdis unavailable the walk is bylstat, which catches a redirect planted before the build completely and a live swap not at all. That is weaker than the descriptor walk and is documented as weaker rather than presented as equivalent.Verification
228 packaging tests pass. Every fence has a test that reddens when the fence is reverted, verified by mutation. The Windows-only branches are pinned by source rules, because no test on a POSIX host reaches them.
file://is used by shipped agents (apps/builtins/pptx_maker/agents/*.json), so those crews are bundleable once this lands.Declared: everything in this diff that is not the prompt inline
Six review items asked for these to be justified or removed. All six are actioned.
Removed a local opener stack.
_open_nofollow_under,_open_root_nofollowand_open_attr_checked_under(191 lines) had zero production callers and duplicatedkiro_crew/hooks.py. Counted outside tests they were 2 occurrences each, which is adefinition plus one internal call; the real prompt read goes through
hooks.safe_read_file_bytes_nolink. Therootandwhatparameters went with them:neither reader ever interpolated
what, and the only caller that wanted an anchored walkdisappeared when the read was routed to hooks. Fifteen tests that pinned that mechanism
were removed with it, because a test of an unreachable guard reports coverage it does not
have.
Scoped the byte ceiling to the prompt read. It had been applied inside both shared
readers, which also serve the skill scan, the plan read and the agent-spec read, so a limit
named for prompts could refuse an oversized agent spec. The base bounds none of those. The
prompt read keeps its bound as
max_byteson the shared guard.Restored nine POSIX-only markers in
test_sensitive_source_and_report_identity.py,dropped when this branch took its own side of that file during a merge. Without them those
nine execute on the Windows shards, where they can only produce reds unrelated to this
change.
Two
0o755sites annotated withnosemgrepand a reason rather than lowered. Theyrestore a mode the fixture cleared to
0o000, and traverse permission is what thetemp-directory teardown needs.
One docstring edited outside my functions.
_dir_fd_supportednamed_open_nofollow_underas an example; that name no longer exists, so the clause now namesthe three live call sites. The count it states, three, is unchanged.
Two strings this branch removes from
_inline_promptare the base's refusal to followa
file://reference and its matching docstring. Removing that refusal is the feature.Measured across the whole file, no function outside the eleven this branch owns loses a
message.
A NUL in the reference is refused.
file://per\x00sona.mdreached a syscall as a bareValueErroron all three branches (relative, absolute, and a NUL alone) and surfaced as atraceback naming neither the spec nor the reference. Checked on the string, because
Pathaccepts a NUL and defers the error past every point that could still name it.
External-input audit of this commit (issue #9575)
GPT flagged
build.py:716as an unhandled anchor resolution. That is instance six of onegap, filed as #9575: an operation on author-supplied input called without deciding what its
failure means. Rather than patch the one line, every such call in this commit was audited by
walking the AST of the nine functions it adds or changes and recording, per call, what the
failure becomes. Consistent with #9575's proposal, absence and unreadability are separated by
MESSAGE; the type-level question that issue leaves open is not settled here.
agents_dir.resolve()_resolve_prompt_pathExportRefused, names the agents directory(agents_dir / target).resolve()_resolve_prompt_pathExportRefused, names the referencepath.resolve()_resolve_prompt_pathExportRefused, symlink loopos.readlink(hop)_resolve_prompt_pathEINVAL/ENOENTend the walk; anything else refusesagents_dir.resolve()_inline_promptExportRefused, names the agents directoryos.lstat(path)read_agent_specos.open/os.fdopen×6_read_text_nofollow,_read_text_openatNone, and each caller words its own refusalThree of these were unstated before this push and are the substance of it:
os.readlinkcollapsed "not a link" with "exists and could not be read". The first isEINVALand ends the walk normally. The second ending the walk lets theresolve()belowtraverse a hop nothing judged, which on Windows is the SMB probe the walk exists to prevent.
os.lstaton the agent spec reported a permission error as "no agent spec ... nothing todeploy", sending the operator after a missing file while the spec sits there unreadable. All
three outcomes now refuse where they are detected rather than through a sentinel the next
branch re-reads, which is also the shape #9575's completeness test could check.
agents_dir.resolve()raisedRuntimeErroron a link cycle, uncaught, on both the relativeand the absolute path in. Measured: a two-link cycle at
agents/producedRuntimeError: Symlink loopout of the CLI.The six
os.open/os.fdopencalls returnNonedeliberately, which is this module'sexisting contract: the reader cannot know whether the caller wants "agent spec" or "curation
plan" in the message, so each caller words its own refusal. That collapses absence and
unreadability at the reader, and the callers separate them again.
Each of the three has a test that reddens when the guard is removed. The hop test injects the
readlinkfailure and says so: reaching it needs a hop whoselstatsucceeds while itsreadlinkfails, and on POSIX both need the same parent traverse permission.Author-supplied path operations: window coverage and failure meaning
Four consecutive rounds on this seam each fixed the line that was named. Both rules that
produced those findings apply to every operation in this commit, so the whole set is
enumerated here rather than sampled. The set is derived mechanically: every function whose
source differs from the base, then every filesystem call inside them (9 functions, 21 calls).
Two questions per row.
Window says whether the guard covers the interval between the check and the use, or
whether something is re-derived from a name in between. Failure says what the operation's
failure becomes.
_withinpath.relative_to(root)ValueErrorreturns False, which is the question asked_resolve_prompt_pathagents_dir.resolve()OSError/RuntimeErrortoExportRefused; this libc reports link cycles asRuntimeError_resolve_prompt_path(agents_dir / target).resolve()_resolve_prompt_pathpath.relative_to(agents_root)ValueErrortoExportRefused_resolve_prompt_pathpath.relative_to(path.anchor)_resolve_prompt_pathos.readlink(hop)EINVAL/ENOENTend the walk, anything else refuses_resolve_prompt_pathpath.resolve()OSError/RuntimeErrortoExportRefused_read_text_nofollowos.open(path, O_NOFOLLOW)OSErrorreturns None; each caller words its own refusal_read_text_nofollowos.fstat(fd)OSErrorreturns None_read_text_openatos.open(root)OSErrorreturns None_read_text_openatos.open(part, dir_fd=cur_fd)OSErrorreturns None_read_text_openatos.open(leaf, dir_fd=cur_fd)OSErrorreturns Noneread_agent_specpath.relative_to(anchor)read_agent_specos.lstat(path)_inline_promptagents_dir.resolve()OSError/RuntimeErrortoExportRefused_inline_promptos.open(anchor, O_NOFOLLOW|O_DIRECTORY)OSErrortoExportRefused_inline_promptpath.relative_to(anchor)_withinin the same expression, so the raising case is unreachable_inline_promptsafe_read_file_bytes_nolink(path, anchor)FileTooLargeErrortoExportRefused; None toExportRefused_inline_promptpath.relative_to(anchor)_withinin the same expression_inline_promptos.stat(rel, dir_fd=anchor_fd)OSErrortoExportRefused_inline_promptsafe_read_file_bytes_with_identity(path, {inode})PermissionError(identity), thenOSError(the right file, read part way),FileTooLargeErrorand None each toExportRefused. The specific arm is first because it subclasses the broad oneOne row needed a fix this round. 2609's observation authorises an inode, and it did not say
what kind of inode: a persona replaced by a directory between the two reads had a directory's
inode allowlisted, and the read then failed inside the shared reader as an uncaught
IsADirectoryError, out of a function whose contract isExportRefused. Reproduced, then aregular-file and a hard-link verdict added on the pinned stat, before the identity is handed
over, because nothing after the allowlist can refuse it.
Each verdict reddens only its own case, so neither masks the other:
The two halves of the anchor pin are also independent. Widening the identity allowlist reddens
both swap shapes; disabling the byte comparison reddens only swap-and-restore, which is the
shape that defeats comparing the anchor's identity before and after.
The table's method was wrong, and one row was wrong with it
The row for 2640 listed
FileTooLargeError,PermissionErrorand None. Those were the threehandlers written at that call, not the failures the call can produce: the reader also raises a
plain
OSErrorwhen the read of the correct file fails part way, and that left a functioncontracted to raise
ExportRefusedas a bare traceback. GPT found it; the table should have.The method is what produced it. A row that enumerates the handlers present cannot discover a
missing one, because the thing it reads is the thing under question. Re-auditing every row by
what each CALL can raise, established by reading the callee rather than the handler, finds:
os.closecalls infinallyblocks that convert nothing. Left as they are, because aclose that fails means the descriptor was already invalid and swallowing it would hide that,
and because the two readers this file has always closed that way. Stated rather than silent;
relative_torows that answer "cannot fail" were measured, notassumed: total against a path's own anchor, or guarded by
_withinin the same expression.One row is right for a reason the table did not give. The sibling read at 2592 states "None to
ExportRefused" and that is complete, but not because the same reasoning applies: inhooks,safe_read_file_bytes_nolinkcatchesOSErroritself and answers None, whilesafe_read_file_bytes_with_identitywraps its read in atrywhose only clause isfinally.Two readers, two different failure surfaces, and the table had treated them as one.
Cheapest guard first
_refuse_share_reached_through_ancestorswalks a hop's ancestors withlstat. On Windows thattouch IS the probe, so a walk beginning at a share-shaped anchor performs the exchange it is
looking for. The string test now runs in front of it: characters are read, nothing is reached,
and only a path that survives earns a filesystem question. Pinned by a test that records every
filesystem question the walk asks and requires a share-shaped hop to produce none.