fix(hooks): fold the steering note so it cannot forge its frame (BACKLOG #1428) - #799
fix(hooks): fold the steering note so it cannot forge its frame (BACKLOG #1428)#799wshallwshall wants to merge 3 commits into
Conversation
…LOG #1428) steer-inject.ps1 read <project>\.claude\steer.txt whole and interpolated it, unfolded, into a bracketed frame telling the reading agent "the user just typed this via a side channel". The value was .Trim()ed and otherwise untouched, so one line break closed that frame and opened whatever the note put next. What a forged frame carries here is an assertion of owner authority, which is the one authority that overrides everything else an agent has been told. Scope, said honestly: the note file is written by anything running as this user on this machine, so the realistic actor is a stray process or another agent on a maintainer workstation. The engine ships none of this and no deployment is exposed. The treatment already existed twice, so this follows it rather than inventing one. mail-drain.ps1 prefixes every content line " | " and states in its own frame that message content cannot reach column 0; usage-headroom-inject.ps1 folds and cites #1040. The stronger of the two is the model. What changes: - Format-Note is the only place the note becomes lines. Control characters and newlines fold to a space; remaining non-ASCII is SUBSTITUTED with '?' rather than deleted, because deleting a zero-width character joins its neighbours. Every line is then prefixed " | ". - The frame says what the prefix guarantees. A containment rule the reader was never told about protects nobody. - The frame states provenance as a claim, not evidence. docs/STEERING.md already said a note is data rather than authority; the hook contradicted it. - Caps: 240 characters per line (matching mail-drain) and 4,000 bytes for the whole rendered note, charged after the prefix. Truncation says how much was queued, how much was shown, and that the remainder is gone. - Fail-safe behaviour is unchanged: missing file, blank file, absent CLAUDE_PROJECT_DIR, unreadable queue and any error all exit 0 and emit nothing. The note is still consumed and deleted on read, so delivery stays exactly-once. tests/test_steer_inject.py drives the real script as a subprocess and asserts on the emitted additionalContext, in three arms: 1. A note carrying a line break plus a replica of this frame emits exactly one line that opens a frame, the hook's own at index 0, and the forged copy survives on a prefixed line. Present AND contained, so a silent censor would fail too. 2. An ordinary single-line note renders as one prefixed line with its text intact, and a multi-line note keeps its paragraphs. 3. Mutation check: the fold call is reverted in a scratch copy of the real script and arm 1's assertion must flip. A test that passes against the fixed and the unfixed hook alike measures nothing. The substitution asserts it matched exactly once, so a moved mutation point fails loudly. The number: this work was allocated #1424 by a Builder that ended without filing. The ledger gate refuses that number to any other session, so it is a permanent hole, recorded in the BACKLOG.md Ledger erratum, and the row is filed at #1428. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
CORRECTION FROM THE CONSOLE SEAT, and the error is mine, not this Builder's. This PR records #1424 as a PERMANENT HOLE in the Ledger erratum. That is FALSE. #1424 is still owned and still filable, by two independent routes. MEASURED, by importing the real module rather than reading it, with os.chdir into each tree: from the RECORDED worktree agent-ae993276907874405 = True ROUTE A: the recorded worktree still exists on disk and still owns the number. Key A compares the worktree PATH and returns before the branch fallback is ever consulted, so the branch move I made so much of is real and irrelevant. ROUTE B: the ref worktree-agent-ae993276907874405 exists and is checked out in NO worktree, so any tree can adopt it and match that way. I verified both: 1 matching ref, 0 worktrees on it. WHAT ACTUALLY HAPPENED. The gate refused a SECOND Builder, in a DIFFERENT worktree, a number allocated to another tree. That is the collision the gate exists to prevent, and it worked as designed. My brief told this Builder to expect an allocation defect, and it faithfully recorded the story I gave it. THE REAL DEFECT, credit to a peer Console seat. The refusal text says to run alloc.ps1, which issues a NEW number, and never names either recovery above. So a seat meeting a correct refusal burns an allocation instead of recovering one. #1428 exists because of that, which is the defect firing in real time on this very PR. The work in this PR is good and I am not disputing it. What needs withdrawing is the erratum entry for #1424 and the provenance note saying a citation of #1424 is not a pointer to it. Both rest on a premise I supplied and that measurement refutes. Also: the class is |
tests/test_tooling_partition.py::test_every_non_engine_test_is_classified reds all three required test legs when a test that does not import the engine is absent from tests/tooling_manifest.txt. This PR adds such a test, so CI could not go green as it stood. The manifest is read as a set, so this is a single inserted line at its alphabetical slot; no existing line moves.
|
Lander: added the missing
Verified before pushing: the test fails on the previous head naming this exact file, and passes with Re-applied the |
Two sessions filed ONE defect under two numbers. main carries `## 1424.` and this
branch carried `## 1428.`, same script, same defect, byte-identical titles in the
two allocation records, claimed 52 minutes apart. That merges clean and is invisible
to `parse_items`, whose duplicate detection keys on the NUMBER.
This branch's justification for its number is false against main: it said #1424's
Builder "ended without filing" and that "there is no item #1424 and there never will
be". #1424 is filed, landed, and cited three times by the #1040 close as the row
covering exactly this defect.
Renumbering the UNLANDED side is the minimal repair -- it edits no row already on
main and redirects no landed citation:
- the row is now `## 1424.`, keeping main's heading and its opt-in caveat,
proof-of-fix bar, "AT LEAST ONE FILE" guard, Related and Source
- it keeps this branch's banner and score, actor note, sibling table, no-denylist
rationale, change list, three-arm proof, and the #1040 distinction
- the PROVENANCE paragraph asserting #1424 does not exist is deleted
- the ledger erratum now records #1428 as the hole, with the true cause: an
ownership refusal says who may commit a number, not that the item is unfiled
- STEERING.md, steer-inject.ps1 and test_steer_inject.py now cite #1424
Checked: exactly one `## 1424.` heading and zero `## 1428.`; parse_items reports 422
items, matching main, with #1428 the only item dropped and nothing added;
tests/test_steer_inject.py passes 14 tests.
|
Lander: this branch and The evidence. The two allocation records carry byte-identical titles, claimed 52 minutes apart:
This row's PROVENANCE paragraph justified its number by saying #1424's Builder "ended without filing" and that "there is no item #1424 and there never will be". Both are false against Why renumber this side rather than tombstone #1424. Option A edits no row already on What the merged row keeps. From Also: the ledger erratum now records #1428 as the hole with the true cause, and Checked. Exactly one Note that nothing automated catches this class: duplicate detection keys on the NUMBER, so two rows for one defect under different numbers merge clean and parse clean. PR 806 (#1426) is building the screener for it. Re-applied the |
|
Lander, routing this back rather than fixing it, because the fix needs your intent and not mine. WHAT IS RED. WHY IT MATTERS MORE THAN THE TEST NAME SUGGESTS. It is not on WHAT I AM NOT DOING, and why. I will not allocate
AFTER YOU PUSH, do the label sequence or you will lose a merge silently: wait for this branch's Everything else on this PR is green. Send me the number when it is pushed and I will land it. |
What was wrong
scripts/hooks/steer-inject.ps1read<project>\.claude\steer.txtwhole and interpolated it,unfolded, into a bracketed frame telling the reading agent "the user just typed this via a side
channel". The value was
.Trim()ed and otherwise untouched, so one line break closed that frameand opened whatever the note put next. What a forged frame carries here is an assertion of owner
authority, which is the one authority that overrides everything else an agent has been told. That
makes it a sharper surface than the hook deny text BACKLOG #1040 treated.
Scope, neither inflated nor deflated. The note file is written by anything running as this user
on this machine, so the realistic actor is a stray process or another agent on a maintainer
workstation. The engine ships none of these scripts and no deployment is exposed by them. The hook is
also opt-in and unregistered in the shared
.claude/settings.json, which bounds who was reachablewithout changing the shape of the defect.
What changed
The treatment already existed twice in this repository, so this follows it rather than inventing one.
scripts/hooks/mail-drain.ps1prefixes every content line|and states the rule in its ownframe;
scripts/hooks/usage-headroom-inject.ps1folds and cites #1040. The mail-drain treatment isthe stronger of the two and is the model.
Format-Noteis the only place the note becomes lines. Control characters and newlines fold toa space; remaining non-ASCII is substituted with
?rather than deleted, because deleting azero-width character joins its neighbours into a delimiter. Every line is then prefixed
|.protects nobody.
docs/STEERING.mdalready said a note isdata rather than authority; the hook contradicted it in the one sentence that mattered.
note, charged after the prefix so the cap bounds what actually arrives. Truncation states how
much was queued, how much was shown, and that the remainder is gone.
CLAUDE_PROJECT_DIR,unreadable queue and any error all exit 0 and emit nothing. The note is still consumed and deleted
on read, so delivery stays exactly-once. There is no new deny path.
Nothing was installed. The repository copy is edited and nothing was copied into
%USERPROFILE%\.claude\hooks\or any other live location. Installing aPreToolUsehook is not aBuilder's call, and BACKLOG #1247 records that the installer overwrites with no backup and no receipt.
Test arms, all three run locally
tests/test_steer_inject.pydrives the real script as a subprocess and asserts on the emittedadditionalContext. 14 tests, all passing.|line, so it is contained rather than censored. The smuggled instruction is asserted present and prefixed.Arm 3 was also demonstrated outside pytest, by repointing arm 1 at the unfolded copy: it fails with
a second frame opener reached column 0: [0, 13]. Arm 2 fails against the mutant too, which isexpected -- with no prefix there is no prefixed body line to assert on.
The suite also covers: control characters neutralised, a zero-width character substituted rather than
deleted, a long note truncated with both counts, the file consumed on read, and every fail-safe path
(missing / empty / whitespace-only note, no
CLAUDE_PROJECT_DIR, and a queue that cannot be read)exiting 0 with no output.
Which allocation path this took, and the hole it leaves
The brief's first path failed, so this is the second one.
#1424was allocated 2026-09-03 by aBuilder in worktree
agent-ae993276907874405on branchworktree-agent-ae993276907874405, and thatsession ended without filing. A commit citing
#1424from this worktree was refused:--no-verifywas not used. The branch fallback inledger_check.pydoes not reach this case: itmatches only a session standing on the recorded branch, and here the recorded worktree still
exists and has since moved to a different branch, so neither key can match another session however
dead the original one is. The gate behaved correctly.
So the row is re-allocated and filed at BACKLOG #1428, and #1424 is recorded as a permanent
hole in the Ledger erratum at the top of
docs/BACKLOG.md, beside#1297. The item itself carriesa provenance note saying a citation of
#1424is not a pointer to it.Checks run
ruff format --check/ruff checkontests/test_steer_inject.pymypy tests/test_steer_inject.pypytest tests/test_steer_inject.pypytest tests/test_backlog_status_check.py tests/test_backlog_citation_check.pypytest tests/test_claude_settings_contract.py tests/test_hook_prose_folding.py tests/test_hook_prose_folding_push_ledger.pyscripts/docs/backlog_status_check.pyscripts/docs/backlog_citation_check.pyscripts/docs/link_check.pyscripts/docs/verdict_divergence_check.pyNot run, and why. The full
pytestsuite was not run to completion in this worktree; the legsabove are the ones this change can affect.
mypy messagefoundrywas not run -- no engine Python wastouched. Hosted-runner-only legs (
windows-service-smoke, the SQL Server and PostgreSQL legs) werenever visible to this session; read them on the PR.
Two pre-existing local failures, confirmed pre-existing.
tests/test_installed_coord_hooks.py::test_the_installed_coord_hook_matches_the_committed_sourcefails for
claim_check.pyandpush_guard.pyon this box -- the copies installed into.git/hooks/differ from source. Both fail identically with this branch's changes stashed, andneither file is touched here.
scripts/docs/citation_line_check.pyexits 1 on 138 findings, all inmessagefoundry/files this branch does not touch.For the lander
This PR touches no
messagefoundry/,ide/ormessagefoundry_webconsole/path, sobacklog-hygieneshould exit with "no banner update required". The row is filed open (in progress / PR pending) rather than closed, because concluding an item closed is not the builder's.The banner text to write on merge, replacing the leading blockquote line of
## 1428.:The claim on
#1428is held by this worktree and should be released once this merges:pwsh -NoProfile -File scripts\coord\claim.ps1 -Release 1428.Open questions
steer-send.ps1refuse an oversized note at the sending end? Today the sender writeswhatever it is given and the hook truncates. Truncating at the reader is the safe half and is what
shipped; refusing at the sender would tell the operator at the moment they can fix it. Not built
here because it changes the sender's contract and no row asks for it.
4000the right budget? It is a judgement, not a measurement --mail-drain.ps1bounds awhole delivery at 12,800 bytes across several messages, and one steering note getting a third of
that seemed generous for a redirect. Say so if a bigger note is wanted.
Not applied: the
reviewedlabel. Auto-merge is not armed.🤖 Generated with Claude Code