Skip to content

fix(cron): vet script bodies as source + literals, not as one shell command - #8564

Closed
NicholasRBowers wants to merge 1 commit into
mainfrom
fix/cron-script-vet-stage-budget
Closed

fix(cron): vet script bodies as source + literals, not as one shell command#8564
NicholasRBowers wants to merge 1 commit into
mainfrom
fix/cron-script-vet-stage-budget

Conversation

@NicholasRBowers

@NicholasRBowers NicholasRBowers commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

A script cron with a body past ~512 statements is refused by the security vet at every fire, forever:

Error: cron script blocked by security policy: Blocked: command has more pipeline stages than this gate inspects (512), so a traversal in it cannot be ruled out

_vet_script_contents feeds the whole Python file to is_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 find delivery analysis resolves cross-line fragments of ordinary Python into a fenced path the file never names, and the env-credential pipeline shapes assemble an env-dump-piped-to-filter verdict from os.environ code 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, find delivery analysis, env-credential pipeline shapes) model what a shell would do with the text: variable resolution across statements, cd state, 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 the standard sandbox script crons run under deliberately leaves ~/.aws/~/.ssh readable (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_command gains keyword-only _subject_is_shell_grammar (default True; 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_contents scans 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 returned None even on subprocess.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 sinks are refused outright (_dynamic_shell_sink): os.system / os.popen / subprocess.getoutput/getstatusoutput always, and the run/call/check_call/check_output/Popen family when shell= is present and not literally False — 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 on os/subprocess incl. 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 **kwargs unpacking fails closed, since the unpacking can smuggle shell=True or the command itself; shell is judged positionally too (it is Popen's 9th parameter, forwarded by the run family), and a *starred positional unpacking fails closed outright.
  • An unparseable body keeps the old whole-text shell-grammar scan (never quietly exonerated; it could not run as a cron script anyway).
  • The residual no static scan of self-referential Python can close — argv-list exec, pure-Python reads (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 the standard-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: the find pass still fabricated a verdict on the real 704-line script); running script crons in strict mode (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 new is_shell_payload_literal answers the EXECUTION question — native-entry / alt-traversal / find delivery / 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 supported is_sensitive_source_body entry 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 draw find verdicts (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:

  • Long-body regressions: benign 600-statement script allowed; credential-path and secret-env naming still blocked at any length.
  • test_vet_script_contents_blocks_shell_payload_literals — payload-in-literal vectors (multiline, call-site, os.system, flow-invisible), all None on 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/.system methods 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_contents end-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 uses os.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.py scans typed text with the same function today).

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable) — behavior contracts documented in the function docstrings
  • No secrets, credentials, or internal references in the diff

@NicholasRBowers
NicholasRBowers requested a review from a team as a code owner September 4, 2026 21:25
@github-actions github-actions Bot added merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: checking Automated validation is still running labels Sep 4, 2026
@NicholasRBowers
NicholasRBowers force-pushed the fix/cron-script-vet-stage-budget branch from cf77e0c to bffb32f Compare September 4, 2026 21:49
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 344df0d8a2c5c4d0903fd6e737ef61060729f2f5 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

  • Fire-time re-vet applies the new strictness retroactively: a cron added and vetting clean on base — a composed shell=True command, a wildcard import, or a literal sink command containing a pipe/$VAR (_shell_executes_verbatim refuses shell features the command-line gate itself models for shell-type crons) — dies permanently post-upgrade at _vet_script_file (mcp_cron.py:1387), no auto-pause. The "measured cost: zero" claim rests on 23 scripts from one host; there is no compat or notification story for existing jobs the tightening newly refuses.
  • _dynamic_shell_sink forfeits far from any sink — "every wildcard import fails closed outright" (any module, e.g. from math import *), module-as-value (print(os)), runner = asyncio.run — yet all surface the one message "a shell execution call … takes a command that is not a plain string literal," naming a call that may not exist. That is a fabricated diagnostic for a benign idiom, the exact harm class (cron script body scanned as one shell command line: stage-budget refusal permanently blocks every ~512+ line script cron #8563) this PR was written to remove; per-forfeit refusal text would make each denial self-explanatory and actionable.

Suggestions

  • The ~400-line Python-source analyzer (_shell_scannable_literals, _dynamic_shell_sink) lives in mcp_cron.py while its designed companion is_sensitive_source_body lives in security.py; co-locating the source-subject machinery in security.py gives the split one owner — the PR's own harvest note anticipates further callers.

[DESIGN-REVIEWED] 344df0d

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 344df0d8a2c5c4d0903fd6e737ef61060729f2f5 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

I have everything I need: the contract, the intent, the patch, and the repository state (notably that is_sensitive_source_body with _traversal_subjects/_env_subject re-pointing is unchanged base context, and that _source_command_subjects at security.py:10852 already collects every literal — docstrings and bytes included — for the traversal and env passes). Counts run: is_shell_payload_literal has 1 consumer (mcp_cron.py:1275); two literal extractors now exist (_source_command_subjects, _shell_scannable_literals); 3 of the new function's 5 passes (alt, find, env) re-run checks the vet's preceding is_sensitive_source_body call already applied to a superset of subjects.

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 ships

Intent: stop long benign Python cron scripts from being permanently refused at every fire — a FIX.

  1. Long Python bodies no longer refused for length — the fix; on base, fix(security): point the traversal passes at a source body's command strings #8550's re-pointing already delivers most of it
  2. Shell payloads inside string literals now block the script — rides along, declared, closes a demonstrated hole
  3. os.system / shell=True must take a plain literal command — rides along, declared, justified
  4. Alias/wildcard-import/reflection mentions of os/subprocess/asyncio refuse outright — rides along, declared
  5. Sink literals with shell-rewrite characters (quotes, $, globs, pipes) refuse — rides along, declared
  6. Docstrings excluded from the vet's payload scan only; base still feeds them to find (Docstrings drawn into #8550's traversal subjects re-block real cron scripts #8643, disclosed) — justified, partial
  7. Escape-spelled IMDS endpoints in decoded literals now blocked — rides along, justified
  8. Unparseable bodies keep the full raw shell scan — justified
  9. _subject_is_shell_grammar widened to also gate pass 3 — justified, sole remaining security.py semantic change
  10. New public is_shell_payload_literal (1 consumer: mcp_cron.py:1275) — partial duplicate of is_sensitive_source_body

Watch

  • Framing: test_vet_script_contents_allows_a_long_python_body claims "Red before the fix", but the quoted stage-budget error comes from passes 4/5, which the base's _traversal_subjects re-pointing (unchanged context at security.py:11167-11168) already scopes to literals; pass 3 (security.py:14423) carries no length budget. The motivating defect is likely green on base; the riders are the PR.
  • Duplication, counted: is_shell_payload_literal runs 5 passes; its alt (11354), find (11357) and env (11366) calls re-run what is_sensitive_source_body already ran on a SUPERSET of subjects (_source_command_subjects collects docstrings and bytes too), and the vet loop only executes after that returned None — for a parsed body those three calls cannot produce a new denial. The test comment concedes it: "Which layer answers is composition detail."
  • Two literal extractors now exist (_source_command_subjects security.py:10852; _shell_scannable_literals mcp_cron.py) with diverging rules (docstrings, bytes, dedup, cap) that must be co-maintained.

Subtractions

  • Shrink is_shell_payload_literal to the two passes the source-body scan does not cover — _check_native_home_entry_then_fenced_read and decoded-literal _check_imds_access — deleting its alt/find/env calls (dead in the only composition that calls them, mcp_cron.py:1275).
  • Delete _shell_scannable_literals by folding the docstring exclusion into _source_command_subjects and re-pointing native/IMDS there, collapsing two extractors into the existing one (also the fix for Docstrings drawn into #8550's traversal subjects re-block real cron scripts #8643's disclosed docstring gap).
  • Drop the sink allow-path (_SHELL_VERBATIM_CHARS, _shell_executes_verbatim, the joined-argv judgment): the PR's own measurement is zero of 23 real scripts use os.system/shell=True, so refusing every shell-mode sink deletes ~80 lines while denying no measured user.

[FIRST-PRINCIPLES-REVIEWED] 344df0d

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 344df0d8a2c5c4d0903fd6e737ef61060729f2f5 and found no blocking issues.

This comment is updated in place on each push.

Review details

FINDING -- src/kiro_crew/mcp_cron.py:1218 -- "prose-safe" contradicts is_sensitive_source_body, which rejects traversal-shaped docstrings -> Fix: correct the changed prose and test name.
[GPT-REVIEWED] 344df0d

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 344df0d8a2c5c4d0903fd6e737ef61060729f2f5: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 344df0d8a2c5c4d0903fd6e737ef61060729f2f5 — this comment is updated in place on each push.

Review details

This 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 mcp_cron.py and security.py.

All three candidates describe over-blocking (permanent false-positive refusals):

  • C1 (**kwargs forces a literal command): subprocess.run(argv, **opts) with a variable command is refused. Confirmed behavior, but it is the explicitly documented fail-closed choice — **kwargs can invisibly carry shell=True, and the refusal message directs the author to the two accepted shapes. The literal-argv-list-with-**kwargs case is deliberately allowed (tested). This is an intended, reasoned trade, not a defect.
  • C2 (verbatim-char allowlist refuses pipes/quotes/redirects in a shell=True literal): confirmed, and it is a deliberate closed allowlist against shell rewrites, documented at length with a stated measured cost. Design choice, not a bug.
  • C3 (dunder attribute on a tracked module forfeits the body): confirmed, deliberate reflection-surface forfeit; benign dunder reads in cron bodies are not a demonstrated real input.

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 is_sensitive_source_body), and the default shell-command caller keeps passes 3–5 unchanged (_subject_is_shell_grammar defaults true), with per-literal replacement for the source path. My confidence that any of these is a genuine defect rather than the documented, measured fail-closed posture is well below the bar. No new grounded defect (crash/under-block) surfaced in Step 2.

No findings.

[OPUS-REVIEWED] 344df0d

Verdict parsed from the review's SHA-scoped output markers for commit 344df0d8a2c5c4d0903fd6e737ef61060729f2f5.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 344df0d8a2c5c4d0903fd6e737ef61060729f2f5: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: checking Automated validation is still running labels Sep 4, 2026
@NicholasRBowers
NicholasRBowers force-pushed the fix/cron-script-vet-stage-budget branch from bffb32f to 2838ce0 Compare September 4, 2026 23:04
@NicholasRBowers

Copy link
Copy Markdown
Contributor Author
  • Positional Popen(..., shell=True) bypasses dynamic-command vetting — span=02904f6955d9 — fixed in 2838ce0.

shell is Popen's 9th positional parameter and the run/call/check_* wrappers forward their positionals to Popen, so the keyword-only check missed Popen(cmd, -1, None, None, None, None, None, True, True). _dynamic_shell_sink now judges the 9th positional under the same rule as the keyword (anything not literally False is a shell), and a *starred positional unpacking — which puts every argument, the command included, at an unknowable position — fails closed outright, per the finding's own proposed remedy. Regression tests pin the 9-positional Popen and run spellings, *argv with shell=True, a fully star-unpacked Popen, and the allow-side boundaries (9th positional literally False; literal command with positional shell, which the literal scan already judges).

@NicholasRBowers

Copy link
Copy Markdown
Contributor Author
  • Spec contradicts code: subject_is_shell_grammar=False described as skipping only pass 1b — span=aec828f504b1 — fixed in 2838ce0.

The sentence in docs/system-specs/modules/security.md was written by #7913 for its pass-1b-only semantics; this PR widens the flag to key every shell-grammar heuristic. The spec bullet now states the merged semantics: the flag also gates the native-entry scan, the alt-traversal pipeline walk, the find delivery analysis, and the env-credential pipeline shapes, and names the two caller-side compensating controls (is_shell_payload_literal on each non-docstring literal at the shell subject, and mcp_cron._dynamic_shell_sink's literal-or-refused rule at shell execution sinks, including Popen's 9th positional and unpacked spellings). docs-lint.sh passes on the updated file.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 4, 2026
@NicholasRBowers
NicholasRBowers force-pushed the fix/cron-script-vet-stage-budget branch 2 times, most recently from fc35753 to d0f6e5f Compare September 4, 2026 23:54
@NicholasRBowers

Copy link
Copy Markdown
Contributor Author
  • Aliased shell sinks execute excluded docstrings — span=02904f6955d9 — fixed in d0f6e5f. (Span hit count: 2 server rounds — Popen-positional on bffb32f, aliasing on 2838ce0. Per the same-span protocol this round closes the CLASS rather than the instance.)

Assignment aliasing is closed with the escape-as-value forfeit — the same closure #7913 established for module authenticity, applied to sink capability: a shell-capable value may only be CALLED, and any mention that escapes as a value fails the vet outright. That covers the finding's r = subprocess.run; r(__doc__, shell=True) (the bare sink-attribute read forfeits before any call is judged) and equally x = subprocess, getattr(subprocess, …) (string-built attribute names included, since the getattr read itself forfeits), containers, arguments, and returns — the enumeration-proof shape, not a spelling list. Non-sink attribute reads (os.environ, os.path.join) and called sinks are untouched; measured on the 23 real cron scripts on the reporting host: zero new refusals. The remaining residual is acquisition that never mentions a watched name (importlib.import_module("subprocess"), sys.modules string routes, exec) — invisible to any static text scan, equally open on base, and moot for the same adversary who can read files in pure Python; stated in the helper docstring rather than implied. Regression tests pin all five escape spellings and the three non-sink FP guards.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 4, 2026
@NicholasRBowers
NicholasRBowers force-pushed the fix/cron-script-vet-stage-budget branch from d0f6e5f to 628029e Compare September 5, 2026 00:05
@NicholasRBowers

Copy link
Copy Markdown
Contributor Author
  • Wildcard imports bypass shell-sink vetting — span=02904f6955d9 — fixed in 628029e. (Span hit count: 3 server rounds — Popen-positional, escape-as-value aliasing, now wildcard import.)

from subprocess import * / from os import * now fail closed outright, per the finding's own remedy: a wildcard binds a set this walk cannot enumerate — run/system possibly among it, under their own names, recorded nowhere — so the forfeit condition is the unknowable binding set itself, the exact closure #7913 established for module authenticity's wildcard case. Regression tests pin both modules' wildcard spellings (payload and benign alike; benign-with-wildcard is an accepted over-block, consistent with the deny-first posture). The companion advisory (residual list still naming aliased sinks) is fixed in the same commit: aliasing and wildcards are now listed as CLOSED by the forfeits, and the residual is reduced to what genuinely has no static text signature — argv-list exec, pure-Python reads, __file__ re-read, and acquisition that never mentions a watched name (importlib.import_module, sys.modules string routes, exec). Stating the boundary plainly: those remaining classes cannot be closed by any lexical scan and their control is the runtime sandbox posture, a product decision — a further blocking round demanding them would need a maintainer ruling rather than a fourth patch.

@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@NicholasRBowers
NicholasRBowers force-pushed the fix/cron-script-vet-stage-budget branch from fc6a712 to e1f0358 Compare September 5, 2026 06:53
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@NicholasRBowers

Copy link
Copy Markdown
Contributor Author
  • Literal shell commands bypass full command vetting — span=02904f6955d9 — fixed in e1f0358. (Span hit count: 9; static, below the escalation boundary. The fix takes the finding's remedy AND closes the class the finding's own example escapes through: is_sensitive_bash_command alone cannot see through ${UNSET} either — the interrupted spelling defeats textual matching by construction — so the remedy is paired with a rewrite-syntax rule.)

A literal command at a shell sink is now judged by the FULL command-line gate (this is the one place a literal is provably a shell command, so the whole-command analyses apply at full strength), and additionally fails closed when it carries shell REWRITE operators — $ (parameter/command/arithmetic expansion) or backticks — because then the executed text differs from any text a static pass can scan: cat ~/.ss${UNSET}h/id_rsa expands to an SSH-key read at runtime. Same rule as a composed command, which such a literal is, just spelled inside one string. Applied equally to the joined argv form. Both deny vectors pinned (${UNSET} splice, backtick substitution); zero new refusals across the real cron scripts (no benign cron passes $-bearing literals to shell sinks — argv lists without shell remain the recommended shape and are unaffected).

@NicholasRBowers

Copy link
Copy Markdown
Contributor Author
  • x = asyncio.subprocess escapes the tracked submodule — span=68dc5bad29e5 — fixed in e1f0358.

Legitimate: the sink-carrying submodule could escape as a VALUE one attribute deep, binding it to an untracked name. Per the finding's remedy: a subprocess attribute of a tracked module now forfeits when it escapes direct chaining — a chained read (asyncio.subprocess.create_subprocess_shell(...)) is that node in attribute-value position and stays allowed; any other mention (assignment, argument, container) fails closed, the same rule as the bare module value. Deny vector pinned; zero new refusals on the real cron scripts.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@NicholasRBowers
NicholasRBowers force-pushed the fix/cron-script-vet-stage-budget branch from e1f0358 to 68dc30b Compare September 5, 2026 07:07
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@NicholasRBowers

Copy link
Copy Markdown
Contributor Author
  • Windows cmd.exe rewrites bypass payload vetting — span=02904f6955d9 — fixed in 68dc30b. (Span hit count: 10; static, below the escalation boundary. This round is the direct Windows sibling of round 9's POSIX fix — the widening-corollary case — and it exposed that enumerating rewrite OPERATORS is the wrong shape: measured before fixing, glob expansion (cat ~/.ss*/id_rsa) also read the key while naming no fenced path, one more unenumerated operator past the finding's own %/!/^ list. The rule therefore INVERTS rather than growing the denylist.)

A sink literal (and the joined argv form) is now accepted only when built solely from a closed ALLOWLIST of characters no shell rewrites (_SHELL_VERBATIM_CHARS: alphanumerics and -_./:=,+@~ plus space) — then the scanned text IS the executed text on POSIX and cmd.exe alike. That closes the finding's %/!/^ set, round 9's $/backtick set, globs, quote-splices, and every future rewrite operator in one rule; ~ stays allowed because tilde expansion rewrites to a path the fence matchers already model, so it cannot conceal. Deny vectors pinned for %VAR% concealment, ^ escape, and glob spellings (string and argv forms); measured zero sink literals outside the allowlist across the real cron scripts, so no benign refusals.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
…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).
@NicholasRBowers

Copy link
Copy Markdown
Contributor Author
  • From-imported modules bypass shell-sink vetting — span=02904f6955d9 — fixed in 344df0d (span hit 11)

The class is "a tracked sink-carrying module arriving under a name/chain the alias tracker cannot follow, via another module's re-export". Closed at the alias-collection layer, so every downstream forfeit applies, with the full sibling table:

  1. from <any module> import os/subprocess/asyncio [as x] — the reported spelling (from subprocess import os as o). The name now binds as a MODULE ALIAS regardless of source module; the source is irrelevant to what arrives, and a same-named non-module attribute only makes vetting stricter.
  2. from <any module> import * — previously only tracked modules forfeited; a module without __all__ re-exports every module it imports (from glob import * binds os), so the unknowable-binding-set forfeit now applies to EVERY wildcard import.
  3. carrier.os.system(...) / x = carrier.os for any imported carrier (shutil.os, glob.os) — a tracked-module-named attribute of any import-bound root now forfeits on the mention itself (read, call chain, or value escape), since the chain root is untracked.
  4. x = subprocess.os — the tracked-root variant; the existing asyncio.subprocess value-escape branch is generalized from the literal "subprocess" attribute to any _SHELL_SINK_MODULES name, keeping the chained-call exemption (the outer sink-call check judges those).
    Deny vectors added for all four spellings plus the wildcard; the real-scripts probe over 22 production crons is unchanged at the pre-existing 3-refusal baseline — the wildcard widening and carrier rule introduce zero new false positives. Verified: 728 tests passing on 344df0d.

@NicholasRBowers

Copy link
Copy Markdown
Contributor Author
  • Fire-time re-vet applies new strictness retroactively, no compat/notification story — rebutted

Fire-time re-vet of the stored body is the pre-existing mechanism on base, and this PR's net direction is permissive: every script the base accepts, this branch accepts (base refuses ALL long Python bodies; the branch refuses only concealing ones). The newly-refusable spellings are exactly the concealment class #8563's fix must not wave through, and the live-host probe (22 production crons) shows zero newly-refused jobs — the 3 refusals are pre-existing on base, two of them #8643's upstream regression. A post-upgrade refusal notification story is a real feature, but it belongs to the vet mechanism as a whole, not this fix.

@NicholasRBowers

Copy link
Copy Markdown
Contributor Author

Legitimate: the wildcard-import and module-as-value forfeits deserve per-forfeit reasons instead of naming a sink call that may not exist in the script. Not security-class — the DENIAL is correct, only its explanation is coarse — so the message split is tracked in #8763 (owner nrb, due 2026-10-03, deferred-finding) to keep this PR at its stated scope.

@NicholasRBowers

Copy link
Copy Markdown
Contributor Author

Agreed the split should have one owner. Moving ~400 lines across modules mid-review would re-arm every lane on a pure relocation, so it lands as #8763's follow-up refactor (owner nrb, due 2026-10-03, deferred-finding), with the deny vectors pinning verdicts rather than layers.

@NicholasRBowers

Copy link
Copy Markdown
Contributor Author

Measured on pristine main: #8550's re-pointing feeds docstrings and byte literals to the find-pass, and that path REFUSES two real production crons (kirocrew_leaderboard.py, pr_security_patrol.py) that this branch's docstring-aware extraction accepts — filed with pristine-main repro as #8643. The motivating defect (a benign long Python cron permanently blocked) is still live on base for real scripts; "likely green on base" holds only for bodies without docstring-resident path text.

@NicholasRBowers

Copy link
Copy Markdown
Contributor Author

The count is right for the current single caller. They are kept in-PR deliberately: the function's contract is "judge this literal as a shell payload, completely", so it stays correct if a second caller composes it without a preceding source-body scan (the harvest note anticipates this). Shrinking it to the two non-covered passes is folded into #8763's consolidation (owner nrb, due 2026-10-03, deferred-finding).

@NicholasRBowers

Copy link
Copy Markdown
Contributor Author

Same rationale as the Design Review's co-location concern: folding _shell_scannable_literals into _source_command_subjects is a pure relocation/merge that re-arms every review lane, so it belongs in its own PR (#8763, owner nrb, due 2026-10-03, deferred-finding) with existing vectors pinning denials rather than layers — and it is also the natural fix for #8643's docstring gap.

@NicholasRBowers

NicholasRBowers commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author
  • Drop the sink allow-path (_SHELL_VERBATIM_CHARS et al., ~80 lines, zero measured users) — needs-a-decision

Put to the maintainer directly: the measurement is true (0 of 22 live crons call os.system/shell=True), so refusing every shell-mode sink outright would delete the verbatim gate and joined-argv judgment with no measured user harmed. The trade: any future benign literal shell command (os.system("systemctl --user restart foo")) becomes permanently unrunnable as a cron. Keep the allow-path, or delete it in #8763's follow-up? The PR ships the permissive version pending your ruling.

@nrb

nrb commented Sep 5, 2026

Copy link
Copy Markdown

Please stop mentioning me

@NicholasRBowers

Copy link
Copy Markdown
Contributor Author

Please stop mentioning me

Sorry @nrb - my agent confuses you with my initials. I will have words with it. Apologies for the annoyance.

@NicholasRBowers

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge conflict Branch has merge conflicts with its base — author must resolve before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cron script body scanned as one shell command line: stage-budget refusal permanently blocks every ~512+ line script cron

2 participants