fix(security): judge a file edit by its target path, not by scanning its text - #9197
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Right subject fix — judges an edit by its write target, matching the sanctioned pattern already in Watch
[DESIGN-REVIEWED] 5665071 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence is in hand: the hook gate at First-Principles-Verdict: CONCERNS The fix earns its place, but the diff-block path plumbing and a brand-new "names no target path" hard deny ship undeclared in a fix PR. What this change shipsIntent: stop the permission gate refusing file writes for what the file's text says (#8812) — a FIX.
Watch
Duplication check run: the path gate reuses [FIRST-PRINCIPLES-REVIEWED] 5665071 |
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: |
db0c5f5 to
ab15094
Compare
|
self-added: yes
|
|
Intent: A file edit is judged by where it writes, not by reading the file's text as a shell command. Writing a doc or script that merely mentions a denied command or a fenced path must not be refused (#8812). |
ab15094 to
bec3c8f
Compare
|
self-added: yes
|
…its text A file edit's tool_input is the diff derive_edit_diff renders from the new content. _resolve_permission fed that document to the shell-command predicates (is_sensitive_bash_command, is_denied), so writing a page that says "git push origin main" or a docstring naming the gateway-restart command was refused by the regex rules, and any body over the 20 KiB command cap was refused for its length (#8812). This is the class #9082 closed for cron script bodies: a document is not the shell gate's subject. An edit is now judged by where it writes, on client-derived provenance only: tool_kind "edit" AND shell_classified with is_shell False (the shell cache the preceding tool_call populated) AND raw_params_trusted (params from that same cache). A shell call forging kind="edit" keeps the command scan. The target set is the UNION of every accepted path spelling in the params (platform.tool_paths.target_paths) and the path the tool_call's diff content block named: _dispatch caches that path per scoped toolCallId (diff_path_cache, beside the params/shell/identity caches, in both AcpClient and AcpSessionHandle) and build_permission_event carries it as event.diff_path, because a backend may stream trusted params with no path key and name the file only in the block. Every candidate goes through is_sensitive_write_path (read+write keystone plus the write-only tier); a truncated walk is denied as unverifiable; an EMPTY union is denied outright rather than falling back to the document scan. The title tier still runs first; an edit with no params at all, and every non-edit kind, keeps the document scan unchanged. _EDIT_TOOL_KIND is imported from hooks instead of redeclared. Pinned by test_llm_helpers_edit_gate.py (29 tests). Closes #8812.
bec3c8f to
5665071
Compare
|
self-added: yes
|
|
self-added: yes
|
|
self-added: yes
|
|
self-added: yes
|
|
self-added: yes
|
|
self-added: yes
|
bolichen97
left a comment
There was a problem hiding this comment.
Approving after a security-focused Tech Lead review of 5665071e.
Verdict: this narrows the false-positive surface and WIDENS the real protection surface. Not a weakening.
What I verified rather than took from the description:
-
The subject change is the pre-existing invariant, not a new one. AGENTS.md "Security invariants" already states that a script body is never a shell-gate subject and that a spelling-chase is a review smell whose first question is "is the SUBJECT wrong (a document handed to a command-line matcher)". #8812 is cited there by number. An edit's
tool_inputis a rendered unified diff, so_first_tool_input_denialwas runningis_sensitive_bash_command/is_denied/ the length cap over prose.hooks.py:784has judged edits bytarget_paths+is_sensitive_write_pathall along; this aligns the always-enforced tier with it. -
The replacement predicate is strictly stronger on the axis that matters.
is_sensitive_write_path(security/paths.py:1855) =_SENSITIVE_HOME_DIRS + _WRITE_PROTECTED_HOME_PATHS+ keystone publish artifacts, a verified superset ofis_sensitive_path. So the always-enforced tier now denies writes toconfig.jsonand~/.kiro/agents/that the old document scan only ever caught by coincidence of the path appearing in the diff header — and it now covers AUTO_APPROVE channels wherehooks.on_tool_callnever runs. That is added protection, not removed. -
Every fork of the reroute fails closed, and each one is pinned by a named test. Truncated walk -> deny (
test_truncated_walk_is_denied_as_unverifiable). Empty target union -> deny, no fallback to the document scan (test_trusted_params_naming_no_target_are_denied). Missing provenance -> keeps the document scan byte for byte (test_unclassified_edit_keeps_the_document_scan,test_inline_params_without_cache_provenance_keep_the_document_scan,test_edit_without_params_falls_back_to_the_document_scan,test_non_edit_kind_keeps_the_document_scan). Title tier still first (test_title_tier_still_runs_first). Bash unchanged (test_bash_tool_input_is_unchanged). -
The forged-
kindbypass I went looking for does not exist. The reroute keys onshell_classified and not is_shell and raw_params_trusted, all read from the origin-scoped tool_call caches, never from the permission payload's ownkind.build_permission_eventdeliberately refuses to fall back to that field, andshell_cacheis written only when the tool_call frame carried a resolved kind — so a shell call spellingkind="edit"on the permission frame still readsis_shell=Trueand keeps the command scan (test_forged_edit_kind_on_a_shell_call_keeps_the_command_scan). The residual trust in the adapter's tool_callkindis exactly the trusthooks.py:784already places; this PR adds none. -
diff_pathis monotone. The union can only ADD candidates, and the loop denies on ANY hit, so a cached diff-block path cannot mask a sensitive params path. Its one grant-side effect is rescuing the empty-union deny, and only when the named path itself verifies safe. Cache writes are guarded on a non-empty path (no clobber with"") and keyed by the origin-scoped toolCallId (test_cache_miss_leaves_diff_path_empty). -
The earlier curl-substring narrowing was correctly removed from this PR rather than carried through a fifth spelling. That is the AGENTS.md rule applied to the author's own diff.
What is genuinely given up is the coincidental scan of inert file bytes. Per the keystone invariant the enforcement point is the OS layer in sandbox.py, not a text matcher, so that scan was never the guarantee — and writes to a fenced path are now judged more strictly than before.
CI on this head: PR Readiness success, 5/5 AI lanes clear (Design PASS, GPT and Opus no blocking, UX success, First Principles CONCERNS advisory and description-completeness only), Coverage / Testpaths / PR Hygiene / Screenshot Evidence green. The three cancelled check-runs are superseded by successful re-runs at 19:14. Single commit, no unresolved review threads.
One non-blocking note for a follow-up, matching the First Principles finding: the new Blocked: file edit names no target path to verify refusal class and the diff_path_cache threading are in security.md but not in the PR description. Both are correct as shipped; only the description is short.
Problem / Motivation
Writing a file gets refused for what the file says. I replayed every policy block from 30 days of session logs (77 sessions, ~120 blocks) against
main. The most frequent class left after #9082, #9089 and #9183: the agent writes a Markdown page or a Python file, and the always-on gate reads the file's text as a shell command. A doc that saysgit push origin mainis refused by the git-publish rule. A docstring naming the gateway-restart command is refused by the self-protection rule. A file over 20 KiB is refused for its length. This is #8812.Each refusal ends the turn. The agent retries with a reworded file, which costs a round trip and teaches it to dodge the gate.
Why it matters
Writing its own code and docs is the most common thing an agent does. A refusal that names a command the file never runs trains the model to treat the gate as noise, and the rewording it learns is exactly the spelling-chase AGENTS.md calls a review smell.
What changed (motivation → approach → change)
A file edit is a document, not a command.
derive_edit_diffrenders the new file content as a diff and that text lands inevent.tool_input._resolve_permissionhanded it tois_sensitive_bash_commandandis_denied.Now an edit goes through
_edit_target_denialinstead. The reroute keys on what the ACP client established from the preceding tool_call frame, not on the payload's ownkind(which is agent-influenced):tool_kind == "edit"plusshell_classifiedwithis_shellFalse plusraw_params_trusted. A shell call that forgeskind="edit"still carries the cachedis_shell=Trueand keeps the command scan.The target is the union of two sources. One is every accepted path spelling in the params (
target_paths). The other is the path the tool_call's diff content block named. A backend can send trusted params with no path key and name the file only in that block, so_dispatchnow caches that path per toolCallId (diff_path_cache, next to the params and shell caches, in bothAcpClientandAcpSessionHandle) and the permission event carries it asdiff_path. Every candidate is checked withis_sensitive_write_path, the same gatehooks.on_tool_callapplies. A truncated walk is denied as unverifiable. An empty union is denied outright, not handed back to the document scan. The title tier still runs first. An edit with no params at all, and every non-edit kind, keeps the document scan byte for byte.An earlier revision of this PR also narrowed the curl exfil substrings (
-d @,-F x=@) to lines namingcurl. Four revisions each fell to a spelling the shell resolves after the text is read (c\url,"${x}"url,c{u..u}rl, a copied binary). That is the spelling-chase, so it is removed from this PR entirely; the substring rules are unchanged frommain.Tests
test/test_llm_helpers_edit_gate.py(new, 29 tests): prose naming a denied command is writable; the document scan never runs on an edit; a body over the command cap passes; writes to~/.ssh,~/.aws,security_policy.json,config.json,~/.kiro/agents/are denied withalways_deny_input; nestedfilePathaliases are judged; a truncated walk denies; the title tier runs first; a non-edit kind and an edit without params keep the document scan; Bash tool_input is unchanged; a forgedkind="edit"on a shell call, an unclassified frame, and inline (untrusted) params all keep the command scan; a real tool_call + permission frame pair run throughacp._dispatchshows a genuine edit carries the trusted flags and a forged one carriesis_shell=True; a protected path named only by the diff content block is denied and a safe one approved; trusted params that name no target are denied; real tool_call / tool_call_update / request_permission frames throughacp._dispatchshow the block's path reaches the permission event under its origin scope, and a scope miss or missing cache leaves it empty.test_llm_helpers_edit_gate.py,test_llm_helpers_tool_input_offload.py,test_security.py,test_hooks.py,test_workflow_security.py. On the current head: 1645 passed across every test file that touchesbuild_permission_event,raw_params_trustedordiff_path, plustest_acp_tool_identity.pyandtest/metrics/test_tool_call_duration.py(the direct_build_tool_call_eventcallers); the 6 failures are intest_acp_client.pysession-resume / tool-result-sync tests and reproduce without this change.Manual verification
Full backend suite on the previous head: 88,661 passed, 271 failed; 235 of those reproduce identically on clean
origin/main(7 storage / host-isolation files), none in security, hooks or llm_helpers. flake8, isort, mypy, docs_lint and the black baseline gate are clean.Screenshots / video
Why no screenshot: backend permission gate only.
Related Issues
Closes #8812
Follows #9082 (cron script bodies leave the shell gate) and #9183 (path regex dropped). The
~/.kiro/agentsread refusal is #9198. The curl exfil substring false positives (date -d @<epoch>) are left onmain's behavior; whether that rule should stay a bare substring or move to argv-structural parsing is a maintainer call, not another regex.Pattern harvest
Rule candidate: review-prompt
Pattern: a document (file content, diff, script body) handed to a command-line matcher. The fix is to change the subject the gate judges, not to add exceptions to the matcher.
Checklist
docs/system-specs/modules/security.md)