Fix the fence sanitizer: a delimiter shape starting inside a run of angles survived - #16
Merged
Merged
Conversation
…angles
Fence::sanitize looked for <<<, and when what followed was not the
sentinel it emitted <<< and advanced three bytes. A delimiter shape
starting one byte later was therefore never examined. Traced and then run
against main before changing anything:
input "<<<</UNTRUSTED-MEMORY:abc>>>"
output "<<<</UNTRUSTED-MEMORY:abc>>>" byte-for-byte unchanged
The output still carries <<</UNTRUSTED-MEMORY:abc>>> starting at offset 1,
and <<<<<UNTRUSTED-MEMORY:abc>>> keeps an opening shape the same way.
This was not a working injection. The delimiters carry a per-render 48-bit
nonce from the OS RNG, and content cannot close a fence whose nonce it
cannot predict; that control held throughout. But the module documents
shape neutralisation as belt and braces on top of the nonce, and the belt
was not fastened - which is worse than not claiming it, because the claim
is what the next reader relies on.
The same function had a second defect, in the opposite direction: a
matched opener with no delimiter after it consumed everything up to the
next >>> anywhere in the text, so
"<<<UNTRUSTED-MEMORY keep this sentence >>> and this"
lost the sentence. Sanitising is meant to defang content while leaving it
readable, not to censor it.
Both come from the same place, so both are fixed there. After a <<< that
does not begin a delimiter the scan advances one byte (<< is ASCII, so
that is always a char boundary). What gets replaced is now bounded by the
delimiter token's own grammar - <<< or <<</, the sentinel, an optional :,
at most 64 lowercase hex characters, then >>> - so a complete token is
replaced whole, and anything else that starts with the prefix loses only
the <<< and the sentinel. Text after it survives. The replacement contains
no <, and a token's fourth byte is always / or the sentinel's first
letter, so no shape can begin inside a replacement either.
Tests are the argument, per CLAUDE.md. A proptest over strings built from
{<, /, >, sentinel, :, hex, prose} asserts that no output ever contains an
opening or closing shape, that sanitising is idempotent, and that text
with no delimiter shape in it comes back untouched - that last one drawn
from a sentinel-free alphabet rather than filtered, because filtering the
first alphabet rejects almost every case it generates. Unit tests cover
both bypasses above, prose after a non-delimiter <<<, an unclosed prefix
keeping what follows, a malformed or over-long nonce losing only the
prefix, and multi-byte text around a candidate.
proptest joins dev-dependencies. Plugin and crate versions bumped in step:
the sanitizer runs in the installed binary, and claude plugin update
compares that version, not the commit.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
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.
The bug
Fence::sanitizelooked for<<<, and when the bytes after it were not thesentinel it emitted
<<<and advanced three bytes. A delimiter shapestarting one byte later was therefore never examined.
Confirmed on
mainbefore changing anything:The output still contains
<<</UNTRUSTED-MEMORY:abc>>>, starting at offset 1.<<<<<UNTRUSTED-MEMORY:abc>>>keeps an opening shape the same way.This was not a working injection: the delimiters carry a per-render 48-bit nonce
from the OS RNG, and content cannot close a fence whose nonce it cannot predict.
That control held. But the module documents shape neutralisation as belt and
braces on top of the nonce, and the belt was not fastened.
Second defect in the same function
A matched opener with no delimiter after it consumed everything up to the next
>>>anywhere in the text:The sentence was silently dropped. Sanitising is meant to defang content so a
person can still read it, not to censor it.
The fix
<<<that does not begin a delimiter, the scan advances one byte.<is ASCII, so that is always a char boundary.<<<or<<</, the sentinel, an optional:, at most 64 lowercase hex characters,then
>>>. A complete token is replaced whole; anything else that starts withthe prefix loses only the
<<<+ sentinel prefix, so the text after itsurvives.
<, and a token's fourth byte is always/or thesentinel's first letter, so no delimiter shape can begin inside a replacement
either.
Tests
{<, /, >, sentinel, :, hex, prose}asserting the output never contains
<<<UNTRUSTED-MEMORYor<<</UNTRUSTED-MEMORY, plus idempotence, plus "text with no delimiter shapecomes back untouched" (drawn from a sentinel-free alphabet rather than filtered,
so the generator is not rejected into oblivion).
<<<, foran unclosed prefix keeping what follows it, for a malformed or over-long nonce
losing only the prefix, and for multi-byte text around a candidate.
proptestis a new dev-dependency (default-features = false,std).Versions
Plugin and crate versions are bumped in step (0.2.0 → 0.2.1): the sanitizer runs
inside the installed binary, and
claude plugin updatecompares that version,not the commit.
Checks
cargo fmt --all -- --check,cargo clippy --all-targets --all-features -D warningsandcargo test --all-features— the three commands CI runs.