fix(cron): vet script bodies as source + literals, not as one shell command - #8564
fix(cron): vet script bodies as source + literals, not as one shell command#8564NicholasRBowers wants to merge 1 commit into
Conversation
cf77e0c to
bffb32f
Compare
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS The subject split is the right root-cause fix, but the new fail-closed forfeits re-create the PR's own failure shape (permanent benign refusal, misleading message) for a smaller class. Watch
Suggestions
[DESIGN-REVIEWED] 344df0d |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of I have everything I need: the contract, the intent, the patch, and the repository state (notably that First-Principles-Verdict: CONCERNS The headline length-refusal is already cured on the rebased base by #8550's re-pointed subjects; what this PR chiefly ships is the declared riders, one of which half-duplicates that base mechanism. What this change shipsIntent: stop long benign Python cron scripts from being permanently refused at every fire — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 344df0d |
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/mcp_cron.py:1218 -- False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThis PR hardens cron script vetting by scanning Python source bodies as three subjects (whole body, per-literal shell-execution, and a dynamic-sink literal-or-refuse gate). I traced each candidate against the implementation in All three candidates describe over-blocking (permanent false-positive refusals):
None is a security under-block, crash, data-loss, corruption, or removed guard — the change only adds refusals on top of base (which ran only No findings. [OPUS-REVIEWED] 344df0d Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
bffb32f to
2838ce0
Compare
|
|
fc35753 to
d0f6e5f
Compare
|
d0f6e5f to
628029e
Compare
|
fc6a712 to
e1f0358
Compare
|
|
e1f0358 to
68dc30b
Compare
|
…ommand A cron script body is Python source, but _vet_script_contents fed the whole file to is_sensitive_bash_command, whose shell-grammar passes read their input as ONE command line. The alt-traversal pass walks pipeline stages under a fail-closed budget (_ALT_MAX_STAGES, 512), and a source file's stage count is its line count — so every script past ~512 statements was refused at every fire, forever, with 'command has more pipeline stages than this gate inspects'. With that refusal out of the way, two sibling fabrications surfaced on real scripts: the find delivery analysis resolved cross-line fragments of ordinary Python into a fenced path the file never names, and the env-credential pipeline shapes assembled an env-dump-piped-to-filter verdict from os.environ code plus a detection-regex literal hundreds of lines away. The subject split: is_sensitive_bash_command takes _subject_is_shell_grammar (default True — command lines are unchanged). The cron vet scans the WHOLE BODY with the flag off (text-evidence passes only: regex fences, trust-root extraction, normalizer token scan, IMDS — plus the vet's own credential-path / secret-env / exfil full-text scans) and feeds EVERY NON-DOCSTRING STRING LITERAL back through the full gate at the shell subject, because a literal is exactly the text a shell would receive. That literal scan CLOSES a pre-existing hole rather than preserving parity: Python quoting swallowed embedded payloads from the raw-text scan, so the base revision returned None on a script whose string literal carries a recursive credential-directory traversal handed to shell=True — which matters because the standard-mode script sandbox deliberately leaves the user-level cloud and SSH credential directories readable. Docstrings are excluded from the literal scan: they are prose, and measured on 23 real cron scripts, 3 docstrings drew fabricated traversal verdicts while 3,700+ non-docstring literals drew zero. An unparseable body keeps the old whole-text shell-grammar scan, so it is never quietly exonerated. Dynamic shell sinks are refused outright: os.system / subprocess with shell=True must take a plain string literal (which the literal scan already judged) — a command composed at runtime (concatenation, an f-string, __doc__, a variable) reaches the shell with no individually blocking literal, so literal-or-refused is the only line that leaves nothing between the two scans. Sink recognition is module-qualified (os/subprocess attribute calls, import aliases, from-imports), so an unrelated method that merely shares a sink's name is never misclassified; a run-family call carrying a **kwargs unpacking fails closed, since the unpacking can smuggle shell=True or the command itself; and `shell` is judged POSITIONALLY too — it is Popen's 9th parameter and the run family forwards positionals to Popen, so a 9-positional call is judged on that argument and a *starred unpacking fails closed outright. Assignment aliasing is closed as a CLASS (the same closure #7913 established for module authenticity): a shell-capable value may only be CALLED, and any mention that escapes as a value — r = subprocess.run, x = subprocess, getattr(subprocess, ...), a container element — forfeits the body outright. Non-sink attribute reads (os.environ, os.path) are untouched. Measured cost: zero — none of the 23 real cron scripts uses os.system, shell=True, or an unpacked subprocess call at all. The residual that no static scan of self-referential Python can close (argv-list exec, pure-Python reads, assignment-aliased sinks, source re-read via __file__) is stated in the vet docstring rather than implied, and was equally open before this change. Verified against the real-world reproducer: a 704-line script cron body now vets clean, and 22 of 23 real cron scripts on the reporting host pass end-to-end (the 23rd is refused by the untouched credential-path regex, identically to base).
|
|
|
|
|
|
|
|
|
Please stop mentioning me |
Sorry @nrb - my agent confuses you with my initials. I will have words with it. Apologies for the annoyance. |
|
Superseded by #9082, which fixed the motivating issue (#8563) with a different design: script bodies are no longer scanned with shell grammar or AST analysis at all (size cap + credential-path regex + secret-env regex + exfil-URL scan only), with the runtime sandbox as the actual fence. Verified against all 22 production cron scripts on current main: the memory-export cron and both #8643 victims pass; the single remaining refusal is a legitimate pre-existing credential-path hit. The AST sink-analysis this PR carried is architecturally rejected by #9082's rationale, so it closes rather than rebases. |
Problem / Motivation
A
scriptcron with a body past ~512 statements is refused by the security vet at every fire, forever:_vet_script_contentsfeeds the whole Python file tois_sensitive_bash_command, whose shell-grammar passes read their input as one shell command line. The alt-traversal pass walks pipeline stages under a fail-closed budget, and a source file's stage count is its line count — so the refusal is a function of script length, not content. A real 704-line memory-export cron (plain benign Python, no shell anywhere) reproduces it on every tick; the fire-time gate deliberately does not auto-pause, so the denial is permanent until a human finds it.With the stage-budget refusal out of the way, two sibling fabrications surface on the same real scripts: the
finddelivery analysis resolves cross-line fragments of ordinary Python into a fenced path the file never names, and the env-credential pipeline shapes assemble anenv-dump-piped-to-filter verdict fromos.environcode plus a detection-regex literal hundreds of lines away (in source,|is regex alternation, not a pipe).Why it matters
Script crons are the zero-token path for deterministic polling, and the scripts this hits are exactly the well-documented, structured ones the feature encourages — measured on the reporting host, the failing job was a legitimate hourly export, and 3 of 23 real cron scripts' docstrings independently drew fabricated verdicts from prose. Every affected job dies permanently with a message pointing at a credential access that does not exist.
What changed (motivation → approach → change)
Symptom → root cause. The shell-grammar analyses (native-shell entry scan, alt-traversal walk,
finddelivery analysis, env-credential pipeline shapes) model what a shell would do with the text: variable resolution across statements,cdstate, pipeline delivery, fail-closed analysis budgets. Applied to raw Python source they judge fiction — but simply skipping them would drop true-positive detection of shell payloads embedded in scripts, and thestandardsandbox script crons run under deliberately leaves~/.aws/~/.sshreadable (user scripts may legitimately use creds; only the crew-fenced leaves are masked, at every sandbox level).Approach: split by subject, and close the sink. A shell payload embedded in Python lives in a string literal — and a literal is exactly the text a shell would receive, so the shell modeling is sound there and only there.
is_sensitive_bash_commandgains keyword-only_subject_is_shell_grammar(defaultTrue; command-line callers are unchanged — every pinned budget/exhaustion invariant holds byte-identically). With the flag off, the text-evidence passes (regex fences, trust-root extraction, separator-collapsed repeats, normalizer token scan, IMDS) still run over the full text; the shell-grammar passes do not._vet_script_contentsscans the whole body with the flag off, then feeds every non-docstring string literal back through the full gate at the shell subject. This closes a pre-existing hole rather than preserving parity: Python quoting swallowed embedded payloads from the raw-text scan, so base returnedNoneeven onsubprocess.run("rg 'AKIA' ~", shell=True). Docstrings are excluded because the modeling fabricates on prose (measured: 3 of 23 real scripts' docstrings drew traversal verdicts; 3,700+ non-docstring literals drew zero)._dynamic_shell_sink):os.system/os.popen/subprocess.getoutput/getstatusoutputalways, and therun/call/check_call/check_output/Popenfamily whenshell=is present and not literallyFalse— must take a plain string literal command. A command composed at runtime (concatenation, f-string,__doc__— which is how an excluded docstring would become executable — or a variable) carries no individually-blocking literal, so literal-or-refused is the only line that leaves nothing between the two scans. Recognition is module-qualified (attribute calls onos/subprocessincl. import aliases; bare names from-imported from them), so an unrelated method that merely shares a sink's name (renderer.run(job, shell=theme)) is never misclassified; a run-family call carrying a**kwargsunpacking fails closed, since the unpacking can smuggleshell=Trueor the command itself;shellis judged positionally too (it is Popen's 9th parameter, forwarded by the run family), and a*starredpositional unpacking fails closed outright.open/os.walk), assignment-aliased sinks, source re-read via__file__— is stated in the vet docstring rather than implied, and was equally open before this change; for those classes the controls are the runtime sandbox and thestandard-mode posture, a product decision.Alternatives considered. Raising
_ALT_MAX_STAGES(arbitrary; any larger script re-breaks it, and the budget is perf-load-bearing); making budget exhaustion non-fail-closed for source subjects (implemented first, then abandoned on evidence: thefindpass still fabricated a verdict on the real 704-line script); running script crons instrictmode (breaks legitimate cred-using scripts; product-level change).Relation to #7913 (merged mid-flight): this branch is rebased onto it and the two subject-split designs are composed, with each half owning its question.
is_sensitive_source_body(from #7913) answers the NAMING question — text-evidence passes over the body plus the decoded-literal fence scan with its redactor exonerations, replacing pass 1b for source. The newis_shell_payload_literalanswers the EXECUTION question — native-entry / alt-traversal /finddelivery / env pipeline shapes on each non-docstring literal, which is exactly the text a shell would receive. After #8550 (also merged mid-flight and rebased onto), the flag keys pass 1b and the native-entry scan, while the traversal passes and env pipeline shapes are RE-POINTED at per-string subjects (#8550's_traversal_subjects/_env_subject) rather than skipped -- a superset of the skip for those passes, so this PR's gate-level tests were rewritten against the supportedis_sensitive_source_bodyentry point. The naming passes are deliberately not re-run on literals: doing so re-denied #7913's redaction corpus, which its own tests caught during the rebase. One divergence is deliberately NOT absorbed: #8550 hands docstrings to the traversal subjects and prose docstrings drawfindverdicts (two real cron scripts re-blocked on current main) -- that is upstream's regression on the base, filed as #8643; this PR's own literal scan keeps its measured docstring exclusion.Tests
test/test_security_alt_traversal.py:test_a_source_body_past_the_stage_budget_is_not_refused_for_length— the motivating bug (red on base with the exact production error) plus a pin that the command-line default keeps its refusal.test_a_source_body_keeps_every_text_evidence_pass— length does not dilute the full-text scans.test_a_source_body_skips_the_execution_model_passes_by_design— the documented trade, with the literal-scan composition named.test_a_source_body_env_credential_shapes_are_shell_grammar_only— the minimized env-pass fabrication vector (regex alternation read as a pipe), refused as a command line, clean as source.test/test_mcp_cron_security.py:test_vet_script_contents_blocks_shell_payload_literals— payload-in-literal vectors (multiline, call-site,os.system, flow-invisible), allNoneon base (measured), blocked now.test_vet_script_contents_blocks_dynamic_shell_sinks—__doc__at a sink,os.system(command=__doc__), concatenation, f-string, variable-at-sink,**{"shell": True}and**{"args": …}unpacking, module-alias and from-import spellings.test_vet_script_contents_module_qualifies_sink_recognition— unrelated.run/.systemmethods and local functions are never misclassified.test_vet_script_contents_does_not_scan_docstrings_as_shell,test_vet_script_contents_allows_literal_shell_sinks_and_argv_lists,test_vet_script_contents_unparseable_body_keeps_the_raw_shell_scan.All 691 tests across the three files pass (this PR's additions, #7913's corpus, and #8550's subject tests, coexisting after the rebases); black/isort/flake8/mypy clean.
Manual verification
Ran the full new
_vet_script_contentsend-to-end over all 23 real script crons on the reporting host: the 704-line reproducer and 21 others vet clean; the one refusal is the untouched first-line credential-path regex, identical on base. None of the 23 usesos.system,shell=True, or an unpacked subprocess call, so the new sink rule's measured false-positive cost is zero.Related Issues
Fixes #8563
Pattern harvest
Rule candidate: review-prompt
Pattern: a shell-grammar analyzer applied to a subject that is not a shell command line (whole source files, prose) — check the subject type wherever
is_sensitive_bash_command-family gates gain new callers (computer_use/policy.pyscans typed text with the same function today).Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)