fix(security): stop scanning cron script bodies with the shell command gate - #9082
Conversation
…d gate A cron script body is Python source. Since #4243 it was handed to is_sensitive_bash_command, whose every pass reads its subject as a shell command line. Each shell-grammar pass added since then produced a new class of permanent false denial on ordinary scripts: separator collapse read regex escapes as paths (#7912), the traversal passes counted every line as a pipeline stage and refused any ~512-line script (#8563), the env rules assembled an `env | grep` pipeline from fragments hundreds of lines apart (#8563), and the find pass parsed a docstring starting with "Find ..." as a command line (#8643). Each was patched with another layer of AST analysis in security.py -- ~1500 lines -- that still could not stop `open(os.environ["LOCALAPPDATA"] + r"\kiro-cli\config.json")`. This removes that layer. `_vet_script_contents` now runs only whole-body, source-aware, linear detectors (credential path, secret env name, exfil URL) under its own size ceiling. `is_sensitive_bash_command` is back to `(command, *, enabled_ids)`; the `_subject_is_shell_grammar`, `_traversal_subjects`, `_env_subject` and `_max_chars` knobs, `is_sensitive_source_body`, `_source_command_subjects`, `_sensitive_run_in_source_literals`, the `re`-authenticity guards and the docstring-reflection audit are deleted. The runtime control for what a script may open is the sandbox `run_script` spawns it in. Two ratchet tests pin the decoupling (shell gate signature, no source-body entry point, the cron gate never reaches a shell matcher). AGENTS.md and the reviewer prompts record the invariant so review rounds stop proposing "one more spelling" table entries against a document subject. Closes #8563. Related: #8812 (the tool-input scan in llm_helpers is a separate entry point and is not changed here).
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Reviewed the full diff (mcp_cron.py rewiring, ~1500 lines of AST scaffolding deleted from security.py, new liveness/pin tests, AGENTS.md invariants, review-prompt additions) against the PR's stated intent. Design-Verdict: CONCERNS "The sandbox is the fence" is the load-bearing premise, and it is false on Windows — where the deleted separator-collapse pass was aimed in the first place. Watch
Suggestions
[DESIGN-REVIEWED] 56ca95c |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @bolichen97 overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. 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 First-Principles-Verdict: CONCERNS The fix sits at cause level and deletes ~2,700 lines, but it also ships instructions that dull the automated reviewers toward every future text-matcher removal, not just this one. What this change shipsIntent: stop cron script jobs from being permanently refused for their prose, length, or redaction regexes — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 56ca95c |
|
/ai-review override gpt 56ca95c: Ack. We are refactor security posture |
Human judgment recorded@bolichen97 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. |
…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 event whose tool_kind is "edit" and whose raw_tool_params are present is now judged by where it writes. Every accepted path spelling (platform.tool_paths.target_paths) goes through is_sensitive_write_path, the read+write keystone plus the write-only tier; a truncated walk is denied as unverifiable, mirroring hooks.on_tool_call. The title tier still runs first. An edit with no params, and every non-edit kind, keeps the document scan unchanged. Pinned by test_llm_helpers_edit_gate.py (18 tests). Closes #8812.
…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 event whose tool_kind is "edit" and whose raw_tool_params are present is now judged by where it writes. Every accepted path spelling (platform.tool_paths.target_paths) goes through is_sensitive_write_path, the read+write keystone plus the write-only tier; a truncated walk is denied as unverifiable, mirroring hooks.on_tool_call. The title tier still runs first. An edit with no params, and every non-edit kind, keeps the document scan unchanged. Pinned by test_llm_helpers_edit_gate.py (18 tests). Closes #8812.
…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.
…its text (#9197) 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. Co-authored-by: zejiangg <zejiangg@amazon.com>
Problem / Motivation
Cron
scriptjobs get refused on every fire for their documentation, their length, or a regex that redacts a credential path. Our own auto-pipeline crons were hit. Each refusal names a credential read that does not exist.The cause is one wiring decision:
mcp_cron._vet_script_contentshands the Python source body tosecurity.is_sensitive_bash_command, whose every pass reads its subject as a shell command line.Why it matters
Each shell-grammar pass added to the gate since #4243 turned into a new class of permanent false denial on ordinary scripts:
reescape as a path (cron script body scanned with a shell heuristic: separator-run collapse denies a benign Python source body forever #7912, fixed by fix: scope the separator-run collapse to shell-grammar subjects #7913)env | greppipeline from anos.environread, a|in a regex literal and a comment hundreds of lines apart (cron script body scanned as one shell command line: stage-budget refusal permanently blocks every ~512+ line script cron #8563)findpass parsed a docstring that starts with "Find …" as a command line (Docstrings drawn into #8550's traversal subjects re-block real cron scripts #8643, fixed by fix(security): keep docstrings out of a source body's traversal subjects (#8643) #8811)Each fix added another layer of AST analysis in
security.py. That layer reached ~1500 lines and still cannot stopopen(os.environ["LOCALAPPDATA"] + r"\kiro-cli\config.json")oropen("~/" + ".a" + "ws/credentials")(verified on current main). Static text analysis of a Turing-complete body is not a fence. The sandbox is.What changed (motivation → approach → change)
Symptom: a script is refused for prose or length. Root cause: a document is handed to a command-line matcher. Fix: stop handing it over.
_vet_script_contentsnow runs only whole-body, source-aware, linear detectors: a credential-path spelling anywhere, a protected secret env var by$NAMEor bare name, an exfil URL, under its own 256 KiB ceiling. It does not callis_deniedoris_sensitive_bash_command.is_sensitive_bash_commandis back to(command, *, enabled_ids). Deleted fromsecurity.py: the_subject_is_shell_grammar,_traversal_subjects,_env_subject,_max_charsknobs;is_sensitive_source_body;_source_command_subjects;_sensitive_run_in_source_literals; there-authenticity guards (_re_module_is_authentic,_pattern_reextracted,_enclosing_call_slot,_compiled_name_escapes, …); the docstring-reflection audit (_docstring_constant_ids,_reads_dunder_doc,_DOC_REFLECTION_*). Net: security.py 22,607 → 21,240 lines;import astis gone from it.The runtime control for what a script may open is unchanged and already there:
run_scriptspawns it underwrap_argv(mode="standard"), which bind-masks the crew home's credential leaves, the vault and the keystone; a secret-granted run usesstrict.Regression guards, so this does not creep back:
AGENTS.mdgets two security invariants: a cron script body is never a shell-gate subject, and a regex spelling-chase is a review smell..github/review-prompts/gpt-repo-context.mdandopus-validate.mdtell both AI lanes not to propose "spelling Y also reaches the fence" against a change that narrows a text matcher when the sandbox masks the path, and not to propose handing a document to the shell gate. Rounds 1–4 of fix(security): gate traversals that reach a fence without find #7441 were exactly that loop.Tests
test_the_shell_gate_has_no_source_body_entry_point(liveness): pins theis_sensitive_bash_commandsignature and asserts none of the removed source-body symbols exist.test_script_body_is_never_a_shell_gate_subject(cron): monkeypatches every shell matcher to raise, then vets the benign and malicious corpora; a re-coupling fails it.test_vet_script_contents_allows_source_that_only_names_a_fenced_store: eight bodies, one per false-denial class above (redactor, keywordpattern=, prose docstring naming the store, "Find …" docstring with 40 code spans, 700-line script,os.environ+|+ comment far apart). All refused at some point on main; all allowed now.test_vet_shell_command_still_blocks_a_separator_run: the shell path keeps pass 1b (Repeated path separator bypasses the Windows kiro-cli credential fence #6350 control).test_the_source_body_ceiling_is_larger_and_owned_by_the_cron_reader: the 256 KiB ceiling still refuses, and reader and gate share one constant.test/test_security_source_command_subject.py(574 lines) and 40 tests intest_mcp_cron_security.pythat pinned the removed AST layer. Their attack corpus (ATTACK_SCRIPTS_WITH_A_SEPARATOR_RUN) is gone with it: those 44 bodies are stopped by the sandbox mask, not by text, and the same bodies with+ ""inserted were never stopped by text.3464 passed across
test_security*.py,test_denied_commands_security.py,test_llm_helpers*.py,test_mcp_cron*.py,test_cron_script.py. Wider-k "security or cron or sensitive or denied or llm_helpers or sandbox or hooks": 10007 passed, 12 failed, all intest_file_explorer_app.pyandtest_host_isolation_floor.py, which fail identically on cleanorigin/mainin this environment.Manual verification
Ran
_vet_script_contentsover the six assembled-path bypass bodies above on pristine main: all six allowed, confirming the removed layer was not the fence for them. flake8, isort, mypy, docs_lint and the black baseline gate are clean.Related Issues
Closes #8563. Related: #8812 (
llm_helpers._first_tool_input_denialis a separate entry point; a file write'scontentstill goes through the shell matcher there, so an agent may still be refused writing a script cron can now run. Left for its own PR).Pattern harvest
Rule candidate: agents-md, review-prompt
Pattern: a document (source body, file content, script) handed to a command-line matcher; each new shell pass becomes a new false-denial class, and the review loop proposes table entries instead of asking what the subject is.
Checklist