fix(guardrails): close three block-hook-bypass inline-write forms, one a reopened residual - #2367
Conversation
…e a reopened residual Three inline-write forms reached a real file without matching any detector (#2217). All three moved allowed -> blocked; nothing moved the other way. 1. The interpreter detector required the literal `python3` in both lanes, so `python -c`, `py -c`, `py3 -c`, `python2 -c` and `python3.11 -c` ran the same inline write unseen. The command word is now the python family, still separator-anchored so `notpython3`, `mypy`, `spy`, `happy` and `pytest` stay inert. 2. A physical newline inside an open quote span is not a separator — bash is inside a quoted word — but strip_literals re-emitted it, splitting a producer from its own redirect. The join is now empty, not a space: `ec"<nl>"ho x > f` is `echo x > f` to bash and a space join leaves `ec ho`. 3. REOPENED ACCEPTED RESIDUAL: the `python3 - <<PY` stdin heredoc, documented as accepted, is reopened on new reachability evidence (this repo's own session record shows an agent reaching for that form to patch a file). The acceptance comment is updated in both files rather than contradicted. `python3 <<PY` with no `-` stays an accepted residual, restated at its narrowed width. Suite: PASS=345 FAIL=0 before, extended after, no existing assertion changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @kyle-sexton's task in 4m 8s —— View job Security review — PR #2367
Scope: the 6 files changed in this PR ( Findings: noneThis PR is itself a security-hardening change to a
No injection surfaces, credential/token handling, or authorization logic are touched — this is pure command-string pattern matching against an untrusted Caveat on this review's depth: I was not able to execute |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3ebdb46a9b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ndary Adversarial probing after the suite went green found one row moving the OTHER way: `foo "a<NL>" echo x > f`, 2 -> 0. Fusing a carried-open span back into one segment puts whatever preceded it at the segment start, and there bash's command word is `foo` with `echo` as an argument — a producer this guard does not model, by design. Its single-line spelling was already allowed on main, so this makes the multi-line form agree with shipped behavior rather than granting a new exemption. Pinned, with that control and with the mirror case. Verified it is a row and not a class: every command prefix the file models (env assignments, env, if…then, !, exec -a NAME, a leading redirect) still blocks in front of a multi-line span, because the peels run on the fused segment. Each pinned with its single-line control. Direction count corrected to 23 granted->refused / 1 refused->granted in the CHANGELOG entry; the PR body carries the measurement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 29s —— View job Security review — PR #2367
Scope: all 6 files changed in this PR ( Findings: noneThis is itself a hardening change to a defensive
No injection surfaces, credential/token handling, or authorization logic are touched — this is pattern matching against an untrusted Caveat on this review's depth: like the prior automated review on this PR, I could not execute |
… floors Probing the new stdin arm turned up `python3 -O - <<PY` — an interpreter option between the name and the `-` — as uncovered, which is the same width the `-c` arm already states: no gap beyond a `-<digits>` version selector, because admitting an arbitrary option-shaped token is what would let a script path through as one. Said so where the pattern is defined, and pinned both the uncovered spelling and the glued `python3 -<<PY` that IS covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 4m 50s —— View job Security review — PR #2367
Scope: all 6 changed files via Findings: noneThis is itself a hardening change to a
No injection surfaces, credential/token handling, or authorization logic are implicated — this remains pure command-string pattern matching against an untrusted Caveat: as with the prior automated pass on this PR, I was not able to execute Per the PR author's own "Not a sign-off" note and the unresolved review thread on this same file: this is a change to a guard whose job is refusing bypasses, and it explicitly wants a human read of the diff regardless of green automated checks — I'd echo that. |
|
Claude finished @kyle-sexton's task in 6m 56s —— View job Code review — PR #2367
SummaryScope: the 6 changed files ( 1 inline finding posted — see the comment on On the two Codex findings already posted on this PR:
Not independently verified: I was not able to execute No other correctness, error-handling, or test-coverage issues found in |
| if [[ "$EXEC_LC" =~ $_py_inline_c || "$EXEC_LC" =~ $_py_stdin_code ]] && | ||
| py_write_indicator "$COMMAND_LC"; then | ||
| block_bypass "python-write" "python3 -c file write bypasses Write/Edit hooks" | ||
| block_bypass "python-write" "python inline-code file write bypasses Write/Edit hooks" |
There was a problem hiding this comment.
The per-call block message was generalized here, but the scope note block_bypass() appends right after it wasn't — so the remediation text still under-states coverage.
This line (and its PowerShell-lane counterpart at block-hook-bypass.sh:978) was correctly widened from "python3 -c inline-code file write…" to the generic "python inline-code file write…". But block_bypass() unconditionally appends _BYPASS_SCOPE_NOTE_BASH / _BYPASS_SCOPE_NOTE_PWSH right after, and neither was touched by this PR — they still read (block-hook-bypass.sh:907-908 and :912-914):
"known shell file-write forms plus inline python3 -c only"
after this PR deliberately widened coverage to the whole interpreter family (py/python/pypy + version suffix) and added the stdin-heredoc form (python3 - <<PY). Two more internal SCOPE (documented residual) comments a few lines below this one — :990-991 and :1007-1009 — say the same stale "python3 -c" thing.
This isn't cosmetic: the file's own rationale for carrying this note at all (:898-906) is that an inaccurate scope claim gets a human/agent to "credit the guard with coverage it never claimed" — here it's the opposite failure, understating coverage to the exact audience (a blocked agent reading its own remediation text) most likely to act on it, e.g. concluding py -c or a stdin heredoc is still an available workaround when both are now blocked.
It's also pinned by an existing regression test that this PR left unchanged — block-hook-bypass.test.sh:756-757:
assert_contains "bash block limits interpreter coverage to python3 -c" "$scopeout" \
"inline python3 -c only"
So fixing the message requires updating that assertion's expected substring too (and adding an equivalent one for the PowerShell note, which isn't currently pinned at all).
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
|
Post-merge: final measured numbers, and two reviewer findings this PR merged past. This merged at 06:16Z while The direction count is confirmed at the figure the body claims. The full suite was run against 23 / 1 in the shipped suite, plus 6 probe-verified rows in the granted → refused direction = 29 Two P2s from the automated reviewer arrived and were resolved without a fix. Both are real:
The |
…s shipped width (#2384) ## Summary Follow-up to #2367, which merged while its automated review was still landing. The reviewer's second P2 is correct and this fixes it. #2367 widened `block-hook-bypass`'s python lane from the literal `python3 -c` to the interpreter family plus a `python3 - <<PY` stdin heredoc — and left both **emitted scope notes** saying `inline python3 -c only`. That note is not commentary. It is the guard's contract with whoever it just blocked, and the paragraph directly above it in the source says why: understating the surface makes an agent contort around a restriction that isn't there, and overstating it makes a human credit coverage the guard never had. Shipping a widened detector behind a narrower claim is the same false-account defect as the reverse, and it points the blocked agent at a form it is told the guard cannot see — which, after #2367, it can. **The contract test is the part that should have caught this and didn't.** Its assertion pinned the literal string `inline python3 -c only`, so it kept passing while the claim it pinned went stale. It now pins the interpreter family, the stdin form, **and** the no-dash residual, so the note cannot drift from the detector again without a failure. **No detector behaviour changes: 0 granted → refused, 0 refused → granted.** Only the two note strings and the assertions over them. ## Test plan The notes as actually emitted, by invoking the hook on a blocked payload (not read off the source): ``` $ bash plugins/guardrails/hooks/block-hook-bypass.sh <<< '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cat > f.txt"},...}' Scope: only this command string is inspected — known shell file-write forms plus inline python code (python/python3/py/pypy with -c, or a program read from stdin as python3 - <<PY) only. POSIX tee pipe writes, other inline-interpreter writes (e.g. node -e, sed -i), a stdin heredoc with no - argument (python3 <<PY), writes inside an invoked script file or a program's own opaque code, and redirects produced by another program, are not seen. $ ... tool_name PowerShell, command "Set-Content f.txt x" Scope: only this command string is inspected — known PowerShell file-write cmdlets and content-producer redirects (including Tee-Object and the tee alias) plus inline python code (python/python3/py/pypy with -c) only. Other inline-interpreter writes (e.g. node -e), writes inside an invoked script file or a program's own opaque code, and redirects produced by another program, are not seen. ``` Each clause is checked against the detector it describes, all of which are already asserted in the suite from #2367: the family (`python -c`, `py -c`, `py3 -c`, `python2 -c`, `python3.11 -c`, `pypy3 -c` all `rc=2`), the stdin form (`python3 - <<PY` `rc=2`), and the residual (`python3 <<PY` with no `-` `rc=0`). **Lint:** ``` $ shellcheck -x plugins/guardrails/hooks/block-hook-bypass.sh plugins/guardrails/hooks/block-hook-bypass.test.sh (no output) $ shfmt -d <same two files> (no output) ``` The suite count moves from 412 to 414 (two assertions replaced by five, plus one on the PowerShell note). `plugin-gate` runs `plugins/**/*.test.sh` and is the gate on it. ## Related Follow-up to #2367 (closes #2217). Raised by the automated reviewer on that PR at `block-hook-bypass.sh:1057`, resolved there without a fix because the PR had already merged. **Also raised on #2367 and deliberately NOT fixed here — filed separately instead.** The reviewer's first P2 says the empty line-join reconstructs `echo` from `ec"x<newline>y"ho`, which bash would run as `ecx<newline>yho`. The symptom is real; the stated mechanism is incomplete, and measuring it changes what the fix has to be: ``` base(4c90b45) after #2367 ec"xy"ho hello > out.txt (single line, non-empty) rc=2 rc=2 ec"x<NL>y"ho hello > out.txt (multi-line, non-empty) rc=0 rc=2 ec""ho x > f (single line, empty span) rc=2 rc=2 ec"<NL>"ho x > f (multi-line, empty span) rc=0 rc=2 ``` The single-line row was **already** `rc=2` before #2367 — `strip_literals` has always dropped a quoted span's content without marking it, so the command-word splice predates this work by a long way. #2367 made the multi-line form agree with the shipped single-line form. Marking the *newline join* opaque, as suggested, would fix only the multi-line half and put the two spellings back in disagreement, while also losing the empty-span case (`ec"<newline>"ho x > f` genuinely *is* `echo` to bash). A coherent fix belongs at the dropped-span emit site for both line shapes and needs to distinguish an empty span from a non-empty one — bigger than a note fix and its own change. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Three inline-write forms reached a real file through
block-hook-bypass.shwithout matching anydetector (#2217). All three sit inside the guard's own declared threat model. One of them — the
python3 - <<PYstdin heredoc — is a documented accepted residual, and this PR treats it asreopening an accepted residual on new reachability evidence, not as an oversight: the acceptance
comment is updated in both files that carry it rather than contradicted.
Anchors are re-read at
4c90b454. The issue was filed before PR #2287 landed and citesblock-hook-bypass.sh:635-636/:670; at the base of this branch those lines are:968-969and:1005. #2287's mark-basedstrip_literalsrewrite is untouched by cause 1 and cause 3; cause 2changes one join in the same function, in the direction #2287 deliberately left alone. PR #2325 is
already merged into this base, so none of its renumbered lines are touched here.
Cause 1 — the interpreter detector was a spelling floor, not a rule
Both lanes required the literal
python3: the Bash lane'sEXEC_LCscan and the PowerShelllane's
ps::might_write_via_python3token test. So the identical inline write spelledpython -c,py -c,py3 -c,python2 -corpython3.11 -cran unseen. The guard's own scope messageadvertised
python -cas its example — naming the one spelling the regex did not match.The command word is now the python family (
py/python/pypy+ optional version suffix +optional
.exe), still separator-anchored.py -3 -cis admitted because a-<digits>token cannotbe a script path; no other gap between interpreter and flag is admitted, so
python3 build.pyandpython3 -m tool …still do not block.Cause 2 — a physical newline inside a quoted span split a producer from its own redirect
A newline reached with a quote span still open is not a separator: bash is inside a quoted word,
so the text either side of the span is one word.
strip_literalsre-emitted it anyway,normalize_segmentssplit there, andproducer_redirect_bypassrequires producer and redirect inone segment.
The join is now empty. Not a space — and that is the one place this PR's mechanism differs from
the one the issue suggested.
ec"<newline>"ho x > fisecho x > fto bash; a space join leavesec ho, which_producer_headdoes not match, so the write is still missed. That case ships as anassertion so the choice is evidenced rather than argued. Joining empty cannot manufacture a token
bash does not also form, because an open quote is precisely what makes the two sides one word.
Cause 3 — REOPENED ACCEPTED RESIDUAL (
RECONCILE.mdAD-12)python3 - <<PY … PY(no-c) was recorded as uncovered and accepted in the PowerShell lane'scomment. Reachability evidence established before changing it, by grep over the repo:
.work/handoffs/20260809T082720Z-handoff-post-2008-followups.md:211— a prior session in thisrepository reached for exactly that form (
python - <<'PY') to patch a file. Note thespelling is
python, notpython3, so it is a cause-1 datum too.python -cwrite reroutes most naturally to theheredoc.
The
-is what makes it inline — the code sits in the command string the hook reads, not in anopaque script file.
strip_literalsdrops the heredoc operator and body, soEXEC_LCretainspython3 -while the body's write indicators stay visible inCOMMAND_LC.Narrowed residual, restated at its real width:
python3 <<PYwith no-stays uncovered.Matching a bare trailing interpreter token would flip
echo "pathlib" | python3andcat script.py | python3to blocked. Both floors are asserted.Direction of every behavior change
29 granted → refused, 1 refused → granted. Measured two ways, not asserted. Deliberately not
phrased as "every change is one direction" — that was the first version of this section and it was
wrong; see the correction on the review thread.
Tier 1 — the shipped suite, run against the PRE-change hook. The new assertions were copied into
a worktree at the merge base and the suite executed there, so the direction of every assertion is
read off failures rather than argued. Exact counts are in the "Suite counts" comment below; the
split is 23
expected 2, got 0(granted → refused) and 1expected 0, got 2(refused →granted — the row named below).
Tier 2 — probes beyond the suite, run against both hooks and pasted here rather than promoted to
assertions (the six below are same-shape duplicates of rows the suite already pins):
/usr/bin/python -c "open('f','w')…"C:\Python313\python.exe -c …& 'python' -c …py3 -c …pypy3 -c …python2 -c …The one refused → granted, named rather than buried. Fusing the two sides of a span back into
one segment also puts whatever preceded the span at the segment start, where
_producer_head's^anchor sees it. In
foo "a<newline>" echo x > f, bash's command word isfooandechois one ofits arguments, so the redirect's producer is another program — and this guard is producer-scoped by
design (see the README's producer-scoping note). The newline previously split it into a bogus
echo x > fsegment and blocked it. The single-line spellingfoo "a" echo x > fis rc=0 onmaintoday, so this makes the multi-line form agree with shipped behavior rather than inventingan exemption. Both are asserted.
The mirror case is asserted next to it:
echo "a<newline>" x > f, where the command word really isthe producer, moves the other way (0 → 2).
It is one row, not a class — verified, not reasoned. The obvious escalation is a legitimate
command prefix in front of the span hiding a real producer from the
^anchor. Every prefix thefile already models was probed against both hooks and all of them still block, because
_cmd_prefix/_modifier_opt_arg/_leading_redirpeel on the fused segment: env assignments,env,if…then,!,exec -a NAME, and a leading redirect (rows 5–8 above, each paired with itssingle-line control). The only text that survives to the segment start is a genuine command word,
which is exactly the case where the producer is not
echo.Every remaining floor keeps
rc=0: the name anchor, the #1601/#2148 over-block repros re-run foreach new spelling, the multi-line prose/
--bodyfloor, the/dev/nulldiscard floor and the stdinfloor.
Test plan
Hook invoked as a decision function on
PreToolUseBash payloads built withjq -n --arg—rc=2blocked,
rc=0allowed. Adversarial-first: every row below was written and run against thepre-change hook first.
Before (
4c90b454) → after (this branch):Shipped contract suite —
bash plugins/guardrails/hooks/block-hook-bypass.test.sh. Counts arepasted from the runs in the "Suite counts" comment below; no existing assertion changed.
Lint / repo checks run locally:
CI also runs this suite.
plugin-gateexecutes everyplugins/**/*.test.sh(ci.yml:677) andpassed on the head commit in 5m33s, so the suite is green independently of the local runs.
Not a sign-off
Per
OUTCOME.md, the requiredsecurity-reviewcheck has been observed reporting pass in 16s ona ~700-line change to this same hook (run
31558511903) while the reviewer inside it did not run.A green
security-reviewon this PR should not be read as a security review of it. This is a changeto a guard whose whole job is refusing bypasses and it wants human eyes on the diff.
Related
Closes #2217.
Inbox items:
2026-08-10-plugin-quality-audit-four-components(C-H1, C-H3) and the20260811-021645-routedaudit-passreport-path item (G1).Ledgers:
.work/handoff-inbox-batch-4/ledgers/I7-four-components-023241Z.md§§ C-H1, C-H3;.work/handoff-inbox-batch-4/ledgers/I8-audit-pass-report-path.md§ G1. Adjudication:RECONCILE.mdAD-12.Adjacent and deliberately not closed by this PR: #1601 and #2148 report this same arm
over-blocking. Both were re-verified
rc=0at the base of this branch (the write-modediscrimination already fixed the mechanism #1601 names), and both repros are pinned as floors here —
for the new spellings as well — so this widening does not reopen them. They stay open on their own
terms.
#2227 needs no work: already shipped.
repo_oid_widthatorigin/maincaptures the git errorvia
2>&1, caches only a successful width (_repo_oid_width_key=""on failure), and blocks with adistinct message via
_lease_oid_width_unknown— PR #2288, commitf3336eab,plugins/guardrails/CHANGELOG.md:134-135. The issue is CLOSED/COMPLETED.