From 28f9649e3a04295267120dab1680e6cca2b626bc Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sat, 12 Sep 2026 07:28:42 +0200 Subject: [PATCH] fix(fence): neutralise a delimiter shape that starts inside a run of 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 "<<<>>" output "<<<>>" byte-for-byte unchanged The output still carries <<>> starting at offset 1, and <<<<>> 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 "<<>> 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 <<>> - 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 --- .claude-plugin/plugin.json | 2 +- Cargo.lock | 33 ++++- Cargo.toml | 5 +- src/fence.rs | 255 ++++++++++++++++++++++++++++++++++--- 4 files changed, 271 insertions(+), 24 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ee4f328..21d7f86 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vta-agent-memory", "description": "Durable agent memory stored in your own Verifiable Trust Agent, not in the tool. Save and recall facts across sessions, scoped to a VTA trust context you control and can revoke.", - "version": "0.2.0", + "version": "0.2.1", "keywords": [ "memory", "vta", diff --git a/Cargo.lock b/Cargo.lock index 526b1b2..6f0f96d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3241,6 +3241,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags 2.13.1", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + [[package]] name = "quick_cache" version = "0.6.24" @@ -3414,6 +3429,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4704,6 +4728,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -4818,13 +4848,14 @@ checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" [[package]] name = "vta-agent-memory" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "chrono", "clap", "dirs", "getrandom 0.4.3", + "proptest", "rmcp", "schemars 1.2.2", "serde", diff --git a/Cargo.toml b/Cargo.toml index ea2d24e..4c54bc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vta-agent-memory" -version = "0.2.0" +version = "0.2.1" edition = "2024" rust-version = "1.95.0" description = "Agentic memory for Claude Code, stored in a Verifiable Trust Agent" @@ -67,3 +67,6 @@ tempfile = "3" # tests exercise the real payloads `memory_{put,list,delete}` build without a # VTA, a mediator, or a socket. Additive and zero-cost outside tests. vta-sdk = { version = "0.35", features = ["test-loopback"] } +# Property tests for the fence sanitizer (`fence.rs`): "no output ever contains +# a delimiter shape" is easier to state as a property than to enumerate. +proptest = { version = "1", default-features = false, features = ["std"] } diff --git a/src/fence.rs b/src/fence.rs index db0c515..4a6435c 100644 --- a/src/fence.rs +++ b/src/fence.rs @@ -123,34 +123,42 @@ impl Fence { /// Matching is deliberately broad (any `<<<` or `<<>>`. A complete token + /// is replaced whole. Anything else that starts with the sentinel prefix has + /// only that prefix replaced, so the text after it is kept — sanitising + /// never drops content that is not part of a delimiter. + /// + /// Every `<<<` position is examined. After a `<<<` that does not start a + /// delimiter the scan moves on by one byte, not past all three angles: + /// otherwise `<<<>>` would hide a closing shape that + /// starts one byte in. + /// + /// The output never contains `<<<` or `<< String { let mut out = String::with_capacity(text.len()); let mut rest = text; - // Look for `<<<` or `<<>>` run if it closes, else past the - // angles we just consumed. - match after_angles.find(">>>") { - Some(end) => rest = &after_angles[end + 3..], - None => { - // No closing angles. Consume the optional `/` and the - // sentinel itself — leaving them in the stream would - // put the shape straight back (caught by - // `unterminated_delimiter_shape_is_still_neutralised`). - let slash = after_angles.len() - body.len(); - rest = &after_angles[slash + SENTINEL.len()..]; - } + match delimiter_prefix_len(from) { + Some(prefix) => { + // Break the shape so it can never read as a delimiter. + out.push_str(REDACTED); + let tail = delimiter_tail_len(&from[prefix..]).unwrap_or(0); + rest = &from[prefix + tail..]; + } + None => { + // Not a delimiter. Keep one `<` and look again from the + // next byte; `<` is ASCII, so that is a char boundary. + out.push('<'); + rest = &from[1..]; } - } else { - out.push_str("<<<"); - rest = after_angles; } } out.push_str(rest); @@ -187,10 +195,199 @@ fn random_nonce() -> String { buf.iter().map(|b| format!("{b:02x}")).collect() } +/// What a neutralised delimiter is replaced with. Must contain no `<`, or a +/// replacement could itself contribute to a delimiter shape. +const REDACTED: &str = "[redacted-delimiter]"; + +/// The longest nonce [`Fence::sanitize`] treats as part of a delimiter token. +/// Far above the [`NONCE_BYTES`] this module mints, so a token written with a +/// longer guessed nonce is still removed whole. +const MAX_NONCE_HEX: usize = 64; + +/// If `s` starts with a delimiter prefix — `<<<` or `<< Option { + let after_angles = s.strip_prefix("<<<")?; + let slash = usize::from(after_angles.starts_with('/')); + after_angles[slash..] + .starts_with(SENTINEL) + .then_some(3 + slash + SENTINEL.len()) +} + +/// If `s` starts with the rest of a delimiter token — an optional `:`, at most +/// [`MAX_NONCE_HEX`] lowercase hex characters, then `>>>` — its length in +/// bytes. Every byte it matches is ASCII, so the length is a char boundary. +fn delimiter_tail_len(s: &str) -> Option { + let bytes = s.as_bytes(); + let hex_start = usize::from(bytes.first() == Some(&b':')); + let hex = bytes[hex_start..] + .iter() + .take(MAX_NONCE_HEX) + .take_while(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) + .count(); + let end = hex_start + hex; + bytes[end..].starts_with(b">>>").then_some(end + 3) +} + #[cfg(test)] mod tests { use super::*; + /// Prose after a `<<<` that is not a delimiter is kept, however many + /// angles precede it. + #[test] + fn prose_after_a_non_delimiter_triple_angle_is_kept() { + for text in [ + "a <<< b, and then some prose", + "<<<<<< six angles, then prose", + "<<>> and more", + "<<>>` further on. + #[test] + fn an_unclosed_delimiter_prefix_keeps_the_text_after_it() { + assert_eq!( + Fence::sanitize("<<>> and this"), + "[redacted-delimiter] keep this sentence >>> and this" + ); + } + + #[test] + fn a_whole_delimiter_token_is_replaced_and_its_neighbours_kept() { + assert_eq!( + Fence::sanitize("a <<>> b"), + "a [redacted-delimiter] b" + ); + assert_eq!( + Fence::sanitize("a <<>> b"), + "a [redacted-delimiter] b" + ); + } + + /// A token whose nonce is not lowercase hex, or is longer than any nonce + /// this module would use, is not a well-formed token: only its prefix is + /// replaced, which is enough to break the shape. + #[test] + fn a_malformed_nonce_loses_only_the_prefix() { + assert_eq!( + Fence::sanitize("<<>> tail"), + "[redacted-delimiter]:XYZ>>> tail" + ); + let long = "a".repeat(MAX_NONCE_HEX + 1); + assert_eq!( + Fence::sanitize(&format!("<<>>")), + format!("[redacted-delimiter]:{long}>>>") + ); + let max = "a".repeat(MAX_NONCE_HEX); + assert_eq!( + Fence::sanitize(&format!("<<>>")), + "[redacted-delimiter]" + ); + } + + #[test] + fn multibyte_text_around_candidates_is_handled_on_char_boundaries() { + let out = Fence::sanitize("é<<<>>é<<<é"); + assert_eq!(out, "é<[redacted-delimiter]:éé>>>é<<<é"); + } + + mod properties { + use super::*; + use proptest::prelude::*; + + /// The alphabet delimiter shapes are made of, plus ordinary text. The + /// weights favour `<` so that runs of three or more, directly before + /// the sentinel, turn up often. + fn piece() -> impl Strategy { + prop_oneof![ + 6 => Just("<".to_string()), + 2 => Just("/".to_string()), + 3 => Just(">".to_string()), + 3 => Just(SENTINEL.to_string()), + 1 => Just("UNTRUSTED-".to_string()), + 1 => Just("MEMORY".to_string()), + 2 => Just(":".to_string()), + 3 => "[0-9a-f]{1,12}", + 2 => prop_oneof![ + Just("text".to_string()), + Just(" ".to_string()), + Just("\n".to_string()), + Just("é".to_string()), + Just("ABC".to_string()), + ], + ] + } + + /// The same alphabet with the sentinel and the halves it can be built + /// from removed, for the property that text carrying no delimiter shape + /// is returned untouched. Filtering [`input`] with `prop_assume!` + /// instead would reject nearly every case generated — the sentinel is + /// most of what this alphabet is for — and proptest abandons a test + /// after 1024 rejections. + fn piece_without_the_sentinel() -> impl Strategy { + prop_oneof![ + 6 => Just("<".to_string()), + 2 => Just("/".to_string()), + 3 => Just(">".to_string()), + 2 => Just(":".to_string()), + 3 => "[0-9a-f]{1,12}", + 2 => prop_oneof![ + Just("text".to_string()), + Just(" ".to_string()), + Just("\n".to_string()), + Just("é".to_string()), + Just("ABC".to_string()), + ], + ] + } + + fn input() -> impl Strategy { + proptest::collection::vec(piece(), 0..48).prop_map(|p| p.concat()) + } + + fn input_without_the_sentinel() -> impl Strategy { + proptest::collection::vec(piece_without_the_sentinel(), 0..48).prop_map(|p| p.concat()) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(4096))] + + #[test] + fn output_never_contains_a_delimiter_shape(s in input()) { + let out = Fence::sanitize(&s); + prop_assert!( + !out.contains(&format!("<<<{SENTINEL}")), + "opening shape survived: {s:?} -> {out:?}" + ); + prop_assert!( + !out.contains(&format!("<< {out:?}" + ); + } + + #[test] + fn sanitizing_is_idempotent(s in input()) { + let once = Fence::sanitize(&s); + prop_assert_eq!(Fence::sanitize(&once), once); + } + + /// Sanitising is not a general filter on angle brackets: text with + /// no delimiter shape in it comes back exactly as it went in. The + /// assumption is a guard on the alphabet above, not a filter — it + /// cannot produce the sentinel. + #[test] + fn text_without_the_sentinel_is_unchanged(s in input_without_the_sentinel()) { + prop_assume!(!s.contains(SENTINEL)); + prop_assert_eq!(Fence::sanitize(&s), s); + } + } + } + #[test] fn wrap_places_content_between_matching_delimiters() { let f = Fence::with_nonce(Provenance::Context, "abc123"); @@ -258,6 +455,22 @@ mod tests { assert!(out.contains("[redacted-delimiter]")); } + /// A run of more than three `<` must not hide a delimiter shape that starts + /// one byte in. + #[test] + fn extra_leading_angles_do_not_hide_a_delimiter_shape() { + for input in [ + "<<<>>", + "<<<<>>", + ] { + let out = Fence::sanitize(input); + assert!( + !out.contains("<<