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("<<