Skip to content

fix(hooks): fold the steering note so it cannot forge its frame (BACKLOG #1428) - #799

Open
wshallwshall wants to merge 3 commits into
mainfrom
worktree-agent-a2baeeba559ec2796
Open

fix(hooks): fold the steering note so it cannot forge its frame (BACKLOG #1428)#799
wshallwshall wants to merge 3 commits into
mainfrom
worktree-agent-a2baeeba559ec2796

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

What was wrong

scripts/hooks/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. 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 reachable
without 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.ps1 prefixes every content line | and states the rule in its own
frame; scripts/hooks/usage-headroom-inject.ps1 folds and cites #1040. The mail-drain treatment is
the stronger of the two and is the model.

  • 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 into a delimiter. 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 in the one sentence that mattered.
  • Caps: 240 characters per line (matching mail-drain) and 4,000 bytes for the whole rendered
    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.
  • 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. 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 a PreToolUse hook is not a
Builder'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.py drives the real script as a subprocess and asserts on the emitted
additionalContext. 14 tests, all passing.

arm result
1. A note carrying a line break plus a replica of this frame renders as inert content PASS. Exactly one line opens a frame -- the hook's own, at index 0. The forged copy survives on a | line, so it is contained rather than censored. The smuggled instruction is asserted present and prefixed.
2. An ordinary single-line note still renders normally and reaches the agent PASS. The body is exactly one prefixed line with the note's text intact. A multi-line note keeps its paragraphs.
3. Mutation check PASS. The fold call is reverted in a scratch copy of the real script and arm 1's assertion must flip: the unfolded copy emits a second frame opener at column 0. The substitution asserts it matched exactly once, so a moved mutation point fails loudly instead of passing by doing nothing.

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 is
expected -- 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. #1424 was allocated 2026-09-03 by a
Builder in worktree agent-ae993276907874405 on branch worktree-agent-ae993276907874405, and that
session ended without filing. A commit citing #1424 from this worktree was refused:

  BLOCKED: BACKLOG item #1424 was not allocated to this worktree

--no-verify was not used. The branch fallback in ledger_check.py does not reach this case: it
matches 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 carries
a provenance note saying a citation of #1424 is not a pointer to it.

Checks run

check result
ruff format --check / ruff check on tests/test_steer_inject.py pass (also pass in the pre-commit run)
mypy tests/test_steer_inject.py pass
pytest tests/test_steer_inject.py 14 passed
pytest tests/test_backlog_status_check.py tests/test_backlog_citation_check.py 55 passed
pytest tests/test_claude_settings_contract.py tests/test_hook_prose_folding.py tests/test_hook_prose_folding_push_ledger.py 39 passed
scripts/docs/backlog_status_check.py OK, 658 items
scripts/docs/backlog_citation_check.py OK (2 pre-existing advisory warnings, unrelated)
scripts/docs/link_check.py OK, every relative link resolves
scripts/docs/verdict_divergence_check.py OK
pre-commit (full hook set) pass, including the ledger gate and the claim gate

Not run, and why. The full pytest suite was not run to completion in this worktree; the legs
above are the ones this change can affect. mypy messagefoundry was not run -- no engine Python was
touched. Hosted-runner-only legs (windows-service-smoke, the SQL Server and PostgreSQL legs) were
never 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_source
fails for claim_check.py and push_guard.py on this box -- the copies installed into
.git/hooks/ differ from source. Both fail identically with this branch's changes stashed, and
neither file is touched here. scripts/docs/citation_line_check.py exits 1 on 138 findings, all in
messagefoundry/ files this branch does not touch.

For the lander

This PR touches no messagefoundry/, ide/ or messagefoundry_webconsole/ path, so
backlog-hygiene should 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.:

SHIPPED -- verified on main at <sha>. scripts/hooks/steer-inject.ps1 now folds the
note and prefixes every derived line |, states the containment rule and the unverified
provenance in its own frame, and caps a note at 240 characters per line and 4,000 rendered bytes.
Fail-open behaviour and the consume-on-read delete are unchanged.
tests/test_steer_inject.py: 14 passed, including a mutation arm that reverts the fold and
requires the forgery assertion to flip. Original filing follows.

The claim on #1428 is held by this worktree and should be released once this merges:
pwsh -NoProfile -File scripts\coord\claim.ps1 -Release 1428.

Open questions

  1. Should steer-send.ps1 refuse an oversized note at the sending end? Today the sender writes
    whatever 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.
  2. Is 4000 the right budget? It is a judgement, not a measurement -- mail-drain.ps1 bounds a
    whole 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 reviewed label. Auto-merge is not armed.

🤖 Generated with Claude Code

…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>
@wshallwshall wshallwshall added the reviewed A reviewer has read this. Removed automatically when new commits arrive. label Sep 3, 2026
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

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:

mod.Ledger(ci=False).owns("backlog", "1424")

from the RECORDED worktree agent-ae993276907874405 = True
from an unrelated worktree = False negative control, fired
owns("backlog","999999") from the recorded worktree = False control, fired

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 Ledger at ledger_check.py:168. There is no Checker in that file, despite what my brief and several ledger rows say.

@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Sep 4, 2026
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.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: added the missing tests/tooling_manifest.txt line for the test this PR introduces.

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 the manifest, and it is not
marked tooling, so -m 'not tooling' does not deselect it. This PR could not go green as it stood.

Verified before pushing: the test fails on the previous head naming this exact file, and passes with
the line added. The manifest is read as a set(), so the change is one inserted line at its
alphabetical slot -- no existing line moves.

Re-applied the reviewed label, which the push stripped.

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.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: this branch and main filed the SAME defect under two numbers, so I folded this row back into #1424 and retired #1428.

The evidence. The two allocation records carry byte-identical titles, claimed 52 minutes apart:

  • #1424 -> worktree-agent-ae993276907874405, 2026-09-03T16:20:15
  • #1428 -> worktree-agent-a2baeeba559ec2796, 2026-09-03T17:12:39

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 main: #1424 is filed, landed, and cited three times by the #1040 close as the row covering exactly this defect. An ownership refusal says who may COMMIT a number, never that the item is unfiled.

Why renumber this side rather than tombstone #1424. Option A edits no row already on main and redirects no landed citation. The alternative moves three landed references through a hop and buys nothing.

What the merged row keeps. From main: the heading, the opt-in caveat, the proof-of-fix bar, the "AT LEAST ONE FILE, NOT AN ENUMERATION" guard, Related and Source. From this branch: the banner and score (Value 6, Difficulty 2), the actor note, the sibling-treatment table, the no-denylist rationale, the five-point change list, the three-arm proof, and the #1040 distinction. The PROVENANCE paragraph is deleted.

Also: the ledger erratum now records #1428 as the hole with the true cause, and STEERING.md, steer-inject.ps1 and test_steer_inject.py 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. The claim moved from #1428 to #1424 on this same worktree.

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 reviewed label, which the push stripped.

@github-actions github-actions Bot removed the reviewed A reviewer has read this. Removed automatically when new commits arrive. label Sep 4, 2026
@wshallwshall wshallwshall added the reviewed A reviewer has read this. Removed automatically when new commits arrive. label Sep 4, 2026
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander, routing this back rather than fixing it, because the fix needs your intent and not mine.

WHAT IS RED. tests/test_dangling_citation_check.py::test_no_docs_citation_names_a_number_that_can_still_be_issued, on BOTH repo harness tests (ubuntu-latest) and (windows-2025). Failing on both legs is what makes this a real defect rather than a flake -- I checked the other six PRs red on this job today and every one of them fails a DIFFERENT test, most on only one leg.

AssertionError: citations naming a still-issuable number:
    docs/BACKLOG.md:55 #1428

WHY IT MATTERS MORE THAN THE TEST NAME SUGGESTS. #1428 is not merely unissued. It is ALREADY ALLOCATED, to different work:

number:   1428
title:    steer-inject.ps1 puts an unfolded file value inside a frame asserting the OWNER typed it
branch:   worktree-agent-a2baeeba559ec2796
claimed:  2026-09-03T17:12:39 -05:00

It is not on main's ledger yet -- git cat-file -p origin/main:docs/BACKLOG.md | grep '^## 1428\.' returns zero -- which is why the test still calls it "still-issuable". So today your citation resolves to nothing, and the day that item lands it will start resolving to somebody else's subject with nothing reporting a problem. CLAUDE.md section 5 names this exact failure.

WHAT I AM NOT DOING, and why. I will not allocate #1428 for you (it is taken), and I will not pick a replacement number or reword the line, because only you know what docs/BACKLOG.md:55 was meant to point at. Three routes, all yours to choose:

  1. You meant a NEW item -- allocate it properly and cite that:
    pwsh -NoProfile -File scripts\coord\alloc.ps1 -Kind backlog -Title "<title>"
  2. You meant an EXISTING item -- cite that number instead.
  3. You meant to gesture at unfiled work -- name the subject in prose rather than a number. CLAUDE.md: "To gesture at unfiled work, name the subject, not a number."

AFTER YOU PUSH, do the label sequence or you will lose a merge silently: wait for this branch's review-gate run to read completed, THEN gh pr edit 799 --add-label reviewed, THEN read the label back. The gate strips the label when the run EXECUTES, not when your push returns; I have lost that race 15 times out of 16 with every command reporting success.

Everything else on this PR is green. Send me the number when it is pushed and I will land it.

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. reviewed A reviewer has read this. Removed automatically when new commits arrive.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant