sec(hooks): fold and quote the steering note so it cannot forge its own frame (BACKLOG #1424) - #950
Open
wshallwshall wants to merge 2 commits into
Open
sec(hooks): fold and quote the steering note so it cannot forge its own frame (BACKLOG #1424)#950wshallwshall wants to merge 2 commits into
wshallwshall wants to merge 2 commits into
Conversation
…wn frame (BACKLOG #1424)
steer-inject.ps1 read .claude/steer.txt whole and interpolated it into one
additionalContext string whose frame asserted the owner typed it. A note holding
a line break closed that frame and opened a second one, and the forged frame
inherited the provenance claim.
The note now passes through a local Get-Fold, in mail-drain's order: \p{C} to a
space, then anything still outside \x20-\x7E to '?', then collapse runs and trim.
It is emitted as ONE line behind a ' | ' prefix, under a DATA, NOT AUTHORITY
preamble that names the provenance as an unverified claim. The frame no longer
says the user typed it.
tests/test_steer_inject.py asserts the EMITTED STRING rather than the presence of
a helper, driven as real pwsh subprocesses. Against a copy of the hook at
172b132: 20 failed, 10 passed of 30 rows. Against this one: 32 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018bCHztcFhkeeFCgX4mdYP6
…at it buys (BACKLOG #1424) The banner carries the red count by property rather than "it went red": 11 of 11 separator rows, 3 of 9 fold-away rows, 2 on the substitution mark, 2 on the prefix, 2 on the frame prose. It also names two things a reader would otherwise have to rediscover -- folding to ONE line is a decision that differs from mail-drain, which keeps a body's line structure; and there is no length cap, so that bound is named as not covered rather than left to look covered. docs/STEERING.md gains the two bullets that describe the emitted frame, since a session reading an injection is not reading that page at the time. That moved the page's size, so docs/SESSION-MAIL.md's citation of it is re-measured: 4,240 bytes on a CRLF checkout, against the 3,504 it read when the line was written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018bCHztcFhkeeFCgX4mdYP6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes BACKLOG #1424.
scripts/hooks/steer-inject.ps1read<project>\.claude\steer.txtwhole and interpolated it intoone
additionalContextstring whose frame asserted the owner typed it. A note holding a line breakclosed that frame and opened a second one, and the forged frame inherited the provenance claim.
What changed
Get-Foldmail-drain.ps1's fold, same order:\p{C}to a space, then anything still outside\x20-\x7Eto?, then collapse runs and trim.DATA, NOT AUTHORITY, naming the provenance as an unverified claim, plus a line telling the reader how to read the prefix.What proves it
tests/test_steer_inject.pyasserts the EMITTED STRING, not the presence of a helper. It drives thereal script as a pwsh subprocess against real files, and every negative assertion carries a positive
control that the hostile value actually reached the string.
Red count before the fix, by property, not "it went red". The suite was run against a copy of
the hook as it stood at
172b1327c, so the number names the pre-fix script: 20 failed, 10passed of 30 rows.
IsNullOrWhiteSpace)Against the fixed hook: 32 passed. The suite gained two rows after that measurement, when the
/simplifypass split one fail-open row into three parametrized ones; those three pass against bothversions of the hook, so they are not part of the 20.
Decisions the brief left open
add a line to the emitted
additionalContext", so folding to a single line is what satisfies itliterally. This differs from
mail-drain.ps1, which keeps a body's line structure and prefixeseach line, because a mail body is many lines by nature. The cost: a deliberately multi-line note
arrives as one line.
steer-send.ps1takes the note as one command-line argument, so that shapeis the uncommon one.
mail-drain.ps1caps a bodyand reports what it dropped. This hook caps nothing, so a large note arrives whole. That is
context cost, not frame forging (one folded line is one line at any size), and capping without
the sibling's truncation-reporting apparatus would be the silent drop this repo treats as the
defect. Unfiled and named rather than numbered: the steering-note size bound.
while leaving "the user just typed this" would have left the false claim standing on its own.
Scope
lane-level.ps1is untouched and stays neither cleared nor accused, as the item says. The hook isstill wired nowhere:
tests/test_claude_settings_contract.py:239passes unchanged, and nothing herearms it.
docs/STEERING.mdgains two bullets describing the emitted frame. That moved the page's size, sodocs/SESSION-MAIL.md's citation of that size is re-measured in the same PR: 4,240 bytes on a CRLFcheckout, against the 3,504 it read when the line was written. The clause it supports moves from
"over half a complete document" to "about half".
The original filing note in the backlog item is left verbatim, including its "not started" wording.
The collision gate reported another live session holding uncommitted
docs/BACKLOG.mdchanges(BACKLOG #1265), so I did not make a second, cosmetic edit to a contested file. The closing banner
above it carries the current status, which is how that file is read.
Checks
Run locally, all green:
ruff check,ruff format --checkmypystrict overmessagefoundry(268 files)pytest tests/test_steer_inject.py-- 32 passedpytest tests/test_backlog_status_check.py tests/test_claude_settings_contract.py-- 53 passedpytest tests/test_docs_runbooks.py tests/test_doc_ref_handle.py tests/test_doc_guards_lane.py tests/test_lint_scope_parity.py tests/test_announce_hook.py-- 81 passedpytest tests/test_session_mail.py-- 75 passed (it is the suite for the sibling hook and the doc I re-measured)scripts/docs/backlog_status_check.py-- 679 items, each declaring one statusBoth commits passed the full pre-commit hook chain.
The full suite did NOT run to completion here, and the reason is not a test result. It was
started and the local machine killed it for memory partway through, so it produced no verdict at
all -- neither green nor red. The slices above were chosen after that, to cover the changed files and
the docs and hook suites adjacent to them. Everything outside those slices is unmeasured by me.
Please read the hosted legs, in particular anything running the PowerShell hooks on a Windows
runner. This suite skips itself entirely on a non-Windows runner or without
pwsh(
pytest.mark.skipifat the top of the file), so a green Linux leg is not evidence that these 32rows ran.
Generated with Claude Code
https://claude.ai/code/session_018bCHztcFhkeeFCgX4mdYP6