Skip to content

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
mainfrom
claude/steer-inject-frame-1424
Open

sec(hooks): fold and quote the steering note so it cannot forge its own frame (BACKLOG #1424)#950
wshallwshall wants to merge 2 commits into
mainfrom
claude/steer-inject-frame-1424

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Closes BACKLOG #1424.

scripts/hooks/steer-inject.ps1 read <project>\.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.

What changed

Get-Fold A local copy of mail-drain.ps1's fold, same order: \p{C} to a space, then anything still outside \x20-\x7E to ?, then collapse runs and trim.
The prefix The note is emitted as ONE line behind `
The preamble DATA, NOT AUTHORITY, naming the provenance as an unverified claim, plus a line telling the reader how to read the prefix.
The frame No longer says the user typed it. That sentence was what a forged second frame inherited.

What proves it

tests/test_steer_inject.py asserts the EMITTED STRING, not the presence of a helper. It drives the
real 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, 10
passed
of 30 rows.

property red before
a note cannot add a line 11 of 11 separator rows
a note that folds away to nothing injects nothing 3 of 9 (NUL, record separator, escape; the other six were already caught by IsNullOrWhiteSpace)
a substituted separator leaves a visible mark 2 of 2
every line of note content carries the prefix 2 of 2
the frame states its own limits 2 of 2

Against the fixed hook: 32 passed. The suite gained two rows after that measurement, when the
/simplify pass split one fail-open row into three parametrized ones; those three pass against both
versions of the hook, so they are not part of the 20.

Decisions the brief left open

  1. The note folds to ONE line. The item's proof statement is that a note "must not be able to
    add a line to the emitted additionalContext", so folding to a single line is what satisfies it
    literally. This differs from mail-drain.ps1, which keeps a body's line structure and prefixes
    each line, because a mail body is many lines by nature. The cost: a deliberately multi-line note
    arrives as one line. steer-send.ps1 takes the note as one command-line argument, so that shape
    is the uncommon one.
  2. No length cap, and it is named rather than left to look covered. mail-drain.ps1 caps a body
    and 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.
  3. The frame's provenance sentence was rewritten, not just the mechanics. Folding the value
    while leaving "the user just typed this" would have left the false claim standing on its own.

Scope

lane-level.ps1 is untouched and stays neither cleared nor accused, as the item says. The hook is
still wired nowhere: tests/test_claude_settings_contract.py:239 passes unchanged, and nothing here
arms it.

docs/STEERING.md gains two bullets describing the emitted frame. That moved the page's size, so
docs/SESSION-MAIL.md's citation of that size is re-measured in the same PR: 4,240 bytes on a CRLF
checkout, 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.md changes
(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 --check
  • mypy strict over messagefoundry (268 files)
  • pytest tests/test_steer_inject.py -- 32 passed
  • pytest tests/test_backlog_status_check.py tests/test_claude_settings_contract.py -- 53 passed
  • pytest 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 passed
  • pytest 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 status
  • The three content gates the pre-commit config runs over changed files: control-char, licence-header, forbidden-content

Both 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.skipif at the top of the file), so a green Linux leg is not evidence that these 32
rows ran.

Generated with Claude Code

https://claude.ai/code/session_018bCHztcFhkeeFCgX4mdYP6

wshallwshall and others added 2 commits September 6, 2026 16:51
…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
@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant