Skip to content

sec(hooks): the claim gate's deny text interpolates peer-written values into prose a model then acts on (BACKLOG #1040) - #547

Merged
wshallwshall merged 5 commits into
mainfrom
claude/builder-1-1040-hook-prose
Aug 23, 2026
Merged

sec(hooks): the claim gate's deny text interpolates peer-written values into prose a model then acts on (BACKLOG #1040)#547
wshallwshall merged 5 commits into
mainfrom
claude/builder-1-1040-hook-prose

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Three files, 142 insertions: scripts/hooks/claim_check.py, a new tests/test_hook_prose_folding.py, and its manifest entry. No engine source.

The defect is on record, not hypothesised

The claim gate's deny text is read by a model that then acts on it, and every value it interpolates comes from a file another session wrote. Nothing folded them.

note is the sharpest: free text any peer supplies via claim.ps1 -Take <n> -Note "<what>", routinely hundreds of characters, with no constraint on content.

#1040 instance two is the proof this is not theoretical: a Write whose file_path carried embedded newlines produced a worktree_gate.ps1 reason with two "Do this instead:" blocks, the forged one first. It needed nothing on disk — only the JSON field — so no other gate saw it. The PowerShell side was closed then; this is the Python residual the re-score names.

The wiring test is the part that outlives the fix

It reads the deny block's source and fails if any claim.get(...) reaches an f-string unfolded. A future field added without the fold goes red rather than shipping.

That is a gate on the class rather than the instance — the same shape as the sdist leak-gate defect #1313 found, pointed the right way.

A local helper, not a shared module, for a mechanical reason

install-git-hooks.ps1 copies this file into the git hooks directory and runs it from there, so an import from scripts/hooks/ resolves in development and fails when the gate actually runs. collision_gate.ps1 took a local PowerShell copy for exactly this reason, recorded on #1040 itself.

Two things the author caught in their own work

The first mutant was the wrong one and all six tests stayed green. They neutered the whitespace-collapse line, but newlines are folded by the control-character pass above it — so they had removed a line that was not doing the work. They nearly recorded "the tests do not discriminate" when the mutation was the broken instrument.

The corrected mutation: a passthrough mutant fails 4 of 6. The two survivors are the readability and wiring checks, which correctly do not depend on the fold's internals.

Named as NOT done, so it is not read as covered

ledger_check.py:262 interpolates an ADR filename and push_guard.py:224 interpolates a remote ref. The refname one is genuinely influenceable by this item's own instance-one reasoning — git check-ref-format accepts ;, $, |, " and '. Both are single sites in files that are not this item's named residual, and folding them belongs with a measurement of their own.

Verification

6 passed; ruff format and check clean; all pre-commit hooks passed including bandit. Independently screened here over all 142 added lines with controls fired first: 0 secrets, 0 non-ASCII, and the manifest entry present with a bogus-name control returning 0.

Not run: the full suite on this branch. CI is its first execution.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

🤖 Generated with Claude Code

…deny prose (BACKLOG #1040)

The claim gate's deny text is read by a model that then does what it says, and every
value it interpolates comes from a file another session wrote. Nothing folded them.

THE SHARPEST VALUE IS `note`. It is free text any peer supplies with
`claim.ps1 -Take <n> -Note "<what>"`, it runs to several hundred characters in this repo
routinely, and nothing constrains its content. `worktree` and `branch` are folded too --
a refname is not inert either, since `git check-ref-format` accepts `;`, `$`, `|`, `"`
and `'`, which is #1040's own instance one.

THE DEFECT IS ON RECORD RATHER THAN IMAGINED. #1040 instance two: a `Write` whose
`file_path` carried embedded newlines produced a worktree_gate.ps1 reason with TWO
`Do this instead:` blocks, the forged one FIRST, so a model reading top-down reaches the
injected command before the real remedy. It needed nothing on disk -- only the JSON
field -- so no other gate saw it. The PowerShell side was closed then; this is the Python
residual the re-score names, at claim_check.py's ANOTHER-worktree block.

A LOCAL HELPER, NOT A SHARED MODULE, and the reason is mechanical. install-git-hooks.ps1
COPIES this file into the git hooks directory and runs it from there
(`exec "$PY" "$HOOK_DIR/claim_check.py"`), so an import from scripts/hooks/ resolves at
development time and fails when the gate actually runs. collision_gate.ps1 took a local
copy of its PowerShell equivalent for exactly this reason, recorded on #1040.

CONTROL CHARACTERS, NOT ONLY NEWLINES. A lone \x1b can rewrite a rendered line and \x08
can erase what precedes it, so a value containing no newline is not therefore inert.

THE TESTS ASSERT THE PROPERTY, NOT THE PRESENCE OF THE HELPER. A test that the fold
function exists cannot tell a working fold from one that is never called -- the "control
that cannot fire" shape #1313 found in the sdist leak gate. So they assert that a hostile
note CANNOT INTRODUCE A LINE, with a positive control proving the payload really contains
the lines being folded, plus a wiring check that reads the deny block's source and fails if
any `claim.get(...)` reaches an f-string unfolded. That last one is what would catch a
future field added without the fold.

MUTATION-CHECKED, AND MY FIRST MUTANT WAS THE WRONG ONE. Neutering the whitespace-collapse
line left all six tests green, because newlines are actually folded by the control-character
pass above it -- I had removed a line that was not doing the work. A true passthrough mutant
fails 4 of 6; the two survivors are the readability and wiring checks, which correctly do not
depend on the fold's internals.

NOT DONE HERE, and named so it is not mistaken for covered: ledger_check.py:262 interpolates
an ADR filename and push_guard.py:224 interpolates a remote ref. The refname one is genuinely
influenceable by #1040's own instance-one reasoning. Both are single sites in files that are
not this item's named residual, and folding them belongs with a measurement of their own.
…(BACKLOG #1040)

test_every_non_engine_test_is_classified refuses a tests/*.py that imports no engine
module and appears in neither tests/tooling_manifest.txt nor _STAYS_WITHOUT_IMPORTING.
tests/test_hook_prose_folding.py imports stdlib and pytest only, so it needs the entry.

MANIFEST RATHER THAN _STAYS_WITHOUT_IMPORTING, and the manifest's own header decides it:
the tier is defined by SUBJECT -- "the development harness (worktree gate, coordination
claims, session mail, ledger, announce)". This test's subject is scripts/hooks/claim_check.py,
which is the coordination claim gate. _STAYS_WITHOUT_IMPORTING is for files that LOOK like
harness and are not, which this is not.

PLACED BESIDE tests/test_claim_check.py, THE SIBLING TEST FOR THE SAME FILE, and that is a
deliberate second choice. THE LIST IS NOT SORTED -- three alphabetical insertion heuristics
each landed it somewhere different, because "first entry greater than mine" lands wherever
the local ordering happens to be broken. Chasing a sort order that does not exist produced
three wrong placements; the semantic neighbour is unambiguous and reviewable.

THIS IS THE THIRD BRANCH TONIGHT TO HIT THIS GATE (542, 544, and this one). The lander's
observation is the useful part: the registration is INVISIBLE TO A CONTENT REVIEW and only
surfaces if you happen to run that one suite locally. Nothing in the diff of a new test file
suggests it needs registering somewhere else.

36 passed across test_tooling_partition.py, test_ci_tooling_gate.py and the test itself.
@wshallwshall
wshallwshall merged commit 889dd94 into main Aug 23, 2026
53 of 56 checks passed
@wshallwshall
wshallwshall deleted the claude/builder-1-1040-hook-prose branch August 23, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant