fix: scope the separator-run collapse to shell-grammar subjects - #7913
Conversation
|
Cross-check from reading the scanner while triaging #7912 — one constraint worth verifying against this fix's shape: If "scope the collapse to shell-grammar subjects" means the script-body path skips Also noting |
b8042bc to
f38c41b
Compare
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All evidence gathered and verified against the base tree. Final review: First-Principles-Verdict: CONCERNS One exonerating branch ( What this change shipsIntent: stop a cron script that merely names or redacts a fenced path from being permanently refused at every fire — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] bde516d |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Right scoping fix, but the exoneration half builds an adversarial-grade AST analyzer to defend a layer a two-token string concat steps around entirely. Watch
Suggestions
[DESIGN-REVIEWED] bde516d |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've verified the complete production change: I independently traced the load-bearing properties:
I could not ground a reachable behavioral defect or rule violation on any changed line, and I originate none. No findings. [OPUS-REVIEWED] bde516d |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 2 of 2 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- src/kiro_crew/security.py:9055 -- Executable pattern expressions bypass the credential fence BLOCKING -- src/kiro_crew/security.py:8886 -- Stored |
60bb623 to
9e3e05d
Compare
4ace008 to
bc10afa
Compare
eee6592 to
47d3134
Compare
47d3134 to
4d50f79
Compare
4d50f79 to
c96ac4a
Compare
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
A cron script body is Python source, where a backslash run is an escape, so pass 1b's collapse manufactured a credential path the body never contained.
|
Evidence for the convergence, not a review request and not a claim on these functions — I am not Measured this branch at
Minimal repro on this branch, a body with no shell content, no fenced path and nothing to redact: from kiro_crew.mcp_cron import _vet_script_contents
_vet_script_contents("x = 1\n" * 600)
# 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
Both budgets are right for a command line, and their fail-closed exhaustion is the thing #7441 That is why this is posted here rather than as a separate PR — your literal walk already Context on why this is not hypothetical: on the install above, the six |
Problem / Motivation
mcp_cron._vet_script_contentsscans a cron script's body — Python source — withsecurity.is_sensitive_bash_command. Pass 1b of that function collapses separatorruns, which is right for a Win32 shell string and wrong for source code: in source
a backslash run is an escape.
\\is one backslash,\.a literal dot, so thecollapse strips the escape and manufactures a path the body never contained.
A body that only redacts or names a fenced store is refused as though it read
one. Three bodies that reproduce it, each with zero hits on the unmodified text —
only a collapsed copy matches, and none of them reads anything:
Fixes #7912
Why it matters
The denial is permanent in practice. The fire-time gate deliberately keeps the job
and does not feed the auto-pause counter, so an affected script is refused on every
tick until someone finds and edits the line. The message names a credential path,
pointing the reader at an access that does not exist. The third body is a docstring, and it is the one case this PR does NOT clear: see the accepted over-block below.
It also fires hardest on the code most likely to mention a credential path on purpose:
a redaction helper written to keep a jar path out of its own log output is the most
likely thing to be refused.
What changed (motivation → approach → change)
Motivation. A shell-grammar heuristic is being applied to a subject that is not
shell grammar.
Approach. Scope the heuristic to its subject, then replace it for that subject
rather than simply dropping it. Scoping alone would reopen #6350 inside a script body:
the separator run still exists in the DECODED literal, so
open(r"…\\kiro-cli\\c.json")hands the OS two backslashes that Win32 collapses at open time, while the raw source
text matches no fence pattern. Measured on this branch, four attack bodies blocked on
base were missed with the skip alone.
Scoping is still right for the shell matcher — this is the posture the function
already takes one layer out, and says why:
But a source body needs an escape-aware counterpart, and a transform of the literal
VALUE cannot supply it: a regex escape and a path separator are the same character once
decoded, so
re.compile(r"%LOCALAPPDATA%\\kiro-cli")andopen(r"%LOCALAPPDATA%\\kiro-cli\\c.json")are indistinguishable by any valuetransform. Only the sink differs — which is why the replacement is sink-aware.
Change.
is_sensitive_bash_commandtakessubject_is_shell_grammar: bool = True(keyword-only). The default preserves today's behaviour exactly, so every existing
caller is unaffected.
_vet_script_contents— the one caller whose subject is sourcecode — passes
False.grammar and gets all three pass-1b checks, or does not and gets none — the property
the extraction control depends on, per the comment at the top of that block.
sensitive_run_in_source_literalsinsecurity.py. For asource subject the collapse is replaced, not removed. The body is parsed once and
the same three pass-1b checks run over separator-collapsed copies of each decoded
literal, so a run that survives into a real path is still refused.
strandbytesboth,bytesdecoded latin-1 (total over a byte range, onecode point per byte, so a run survives unchanged) —
open/os.openaccept a bytespath, so an
rb"…"literal reaches the same sinks.pattern operand (
args[0]/pattern=) of an allowlistedre.*call. Theallowlisted calls are not uniformly safe:
re.sub(pattern, repl, string)returnsits subject verbatim and its replacement substantially so, so a fenced path in
either slot would flow on through a call that merely looks harmless.
rebinding must be authentic. The allowlist keys on the spellingre.<func>, so it is withdrawn for a body that rebinds the name at all —import evil as re,re = …,class re, a parameter or loop variable namedre.keeps the deny verdict, so an unenumerated sink over-blocks rather than opening the
fence — the direction
_TRUST_ROOT_READ_LISTERSargues for.shutil.copyiscovered without
shutilappearing in the checker.expression, which Python evaluates and discards, so it reaches no sink). A
docstring is NOT skipped: Python RETAINS it as
__doc__, where a body canread it back and hand it to a sink (
open(f.__doc__)), so docstrings arescanned. The cost is an accepted over-block — a prose-only docstring naming the
store is refused even though it reads nothing, pinned by
test_a_docstring_naming_a_fenced_store_is_an_accepted_over_block. Exemptingdocstrings would reopen a single-separator
open(f.__doc__)path, so the valuecheck stays uniform. Comments never reach the check at all.
caller runs the raw scan with the collapse; a body too deeply nested to
traverse is reported the same way rather than raising, so a legitimate deep
expression degrades to the textual scan instead of failing the gate.
docs/system-specs/modules/security.md(pass 1b, its shell-grammar scope, and the escape-aware replacement) and
docs/system-specs/modules/learn-cron-dashboard.md(the script-body scan).Tests
test/test_mcp_cron_security.py:ATTACK_SCRIPTS_WITH_A_SEPARATOR_RUN— 16 bodies that must be BLOCKED, eachverified blocked on the pre-PR base so every one is a true regression guard: raw and
non-raw
open(),pathlib, an f-string segment, a name-bound literal, anunenumerated sink (
shutil.copy), the two shapes_separator_collapsed_variants'own docstring records as prior review-found regressions (a mixed-separator run and
a UNC leading pair with an interior run), the
bytestwins in both thedrive-letter and relative-traversal spellings, a fenced literal laundered through
re.sub's subject and replacement slots, and twore-rebinding bodies.BENIGN_SCRIPTS_WITH_A_SEPARATOR_RUN(3) — the false positives this PR clears,including the motivating redactor line.
test_vet_script_contents_still_exonerates_a_pattern_slot_literal— the motivatingreal-world case must survive the argument-position narrowing.
test_vet_script_contents_keeps_the_collapse_when_the_body_does_not_parse— theunparseable body is scanned as text, never exonerated.
test_vet_script_contents_survives_a_deeply_nested_expression— a valid deep bodymust not raise out of the gate.
COMMANDS_WITH_A_SEPARATOR_RUN(4) — the leak control: the carve-out must not reachthe command path. Every payload is reachable only through pass 1b, verified
missed when the flag is flipped, so this control can actually fail.
A fourth leak-control candidate,
cat $HOME//.aws/credentials, was dropped onmeasurement: a later pass catches it regardless, so it would have passed even if the
carve-out leaked.
test/test_security.py::TestWindowsSeparatorRuns— all 14 command-path tests for#6350 still pass, unmodified. Full run: 2284 passed, mypy clean,
flake8==7.1.0clean.Manual verification
pytest test/test_mcp_cron_security.py test/test_security.py test/test_cron_gateway_integration.py test/test_cron_sdk.py test/test_app_bridges.py test/test_slack_gateway_cron_exec_coverage.py test/test_hooks.py test/test_denied_commands_security.py -n0→ 2478 passed, 3 skippedscrub-lint.sh --no-history,verify_vendor_manifest.py,check_loop_bound_locks.py(+--test),check_harness_parity.py(+--test),docs_lint.py --test,docs-lint.sh,check_black_formatting.py,check_subprocess_encoding.py(+--test),isort --check-only,flake8(pinned 7.1.0 → 0 findings),mypy src/kiro_crew/53847dcccmid-review;git patch-id --stableidentical before andafter, so the patch is unchanged by the rebase.
Screenshots / video
N/A — no user-visible UI change.
Related Issues
Fixes #7912
Docs updated in this commit, per the same-commit rule:
docs/system-specs/modules/security.md— pass 1b was undocumented (docs/had noreference to
_separator_collapsed_variantsor the collapse, and the function's owndocstring advertises "a two-pass approach"). Adds the pass and its shell-grammar
scope.
docs/system-specs/modules/learn-cron-dashboard.md— script-cron safety wasdocumented as path-gating plus SEL audit, with no mention of the body scan. Adds the
scan, its two shell-grammar exclusions, and the permanence of a fire-time denial.
Pattern harvest
Rule candidate: semgrep
Pattern: a matcher whose heuristics assume one input grammar, invoked on a subject in
a different grammar. Here a shell-command matcher was called on a Python source body,
so an escape sequence was read as a redundant path separator. The tell is a
transformation that is only meaning-preserving under the assumed grammar —
collapsing a separator run is a no-op for a Win32 shell and destructive for source
code — and the failure lands on benign inputs while the malicious ones stay caught, so
it reads as a false positive rather than as a scoping error.
Checklist