mnemo_core/opaque_reasoning.rs
1//! Write-time SHAPE detector for provider-returned opaque reasoning payloads.
2//!
3//! # Why this exists (arXiv:2608.09867)
4//!
5//! [arXiv:2608.09867](https://arxiv.org/abs/2608.09867) (2026-08-10) showed that
6//! provider-returned **encrypted reasoning blocks** — the opaque `reasoning` /
7//! `redacted_thinking` payloads some model APIs hand back — carry **no session,
8//! user, or model binding**, and that of 315,320 such blocks scraped from public
9//! repositories, 367 leaked PII artifacts and 182 leaked credentials. Any agent
10//! that `REMEMBER`s a raw assistant turn is now plausibly persisting one of those
11//! blocks into a durable, shareable store — where it can later be recalled or
12//! shared without anyone realizing a credential rode along inside an opaque blob.
13//!
14//! So on the write path we flag content that has the **shape** of such a payload
15//! and record the flag on the write's provenance (see
16//! [`crate::model::write_provenance::WriteFlag`]). The write is **not rejected** —
17//! a memory database that silently drops writes is worse than one that stores a
18//! flagged write you can later revoke by principal or session.
19//!
20//! # What this deliberately does NOT do
21//!
22//! **Shape detection only. We never decode.** This module does not base64-decode,
23//! decompress, JSON-parse the inner payload, or otherwise attempt to look inside
24//! the blob, and it takes **no dependency that could** (no base64, no crypto, no
25//! decompression crate). Two reasons: (1) decoding a provider-encrypted block
26//! could require pulling in a parser/crypto surface that becomes its own attack
27//! surface on untrusted input, and (2) materializing the decoded bytes would risk
28//! surfacing the very secret we are trying to avoid touching. We match a shape and
29//! record that we matched it. **A positive flag does NOT prove the payload
30//! contains a secret** — it means the content looks like an opaque provider
31//! reasoning payload, which is worth being able to find and revoke later.
32
33/// Minimum length of a contiguous base64-ish run to be treated as an opaque
34/// blob. Provider encrypted-reasoning blocks are hundreds to thousands of
35/// characters; 256 is a conservative floor that ordinary prose (which is
36/// whitespace-broken and not a single base64 run) does not reach.
37const MIN_BLOB_LEN: usize = 256;
38
39/// True if `c` is in the base64 / base64url alphabet (plus padding). Used only
40/// to measure the *shape* of a run — never to decode it.
41fn is_base64ish(c: char) -> bool {
42 c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '-' | '_')
43}
44
45/// Detect whether `content` has the shape of a provider-returned opaque
46/// reasoning payload. Returns a short, human-readable reason for the match (for
47/// logging / the flag audit trail), or `None`. **Shape only — this never decodes
48/// the content and a match does not prove a secret is present.**
49pub fn detect(content: &str) -> Option<&'static str> {
50 // 1. Structured provider reasoning-block markers. These are the JSON shapes
51 // the APIs return; we look for the key co-occurrence, not the value.
52 let lc = content.to_ascii_lowercase();
53 let has = |needle: &str| lc.contains(needle);
54
55 if (has("\"type\":\"reasoning\"") || has("\"type\": \"reasoning\"")) && has("encrypted_content")
56 {
57 return Some("openai-style reasoning block with encrypted_content");
58 }
59 if (has("\"type\":\"redacted_thinking\"") || has("\"type\": \"redacted_thinking\""))
60 && has("\"data\"")
61 {
62 return Some("anthropic-style redacted_thinking block with opaque data");
63 }
64 if has("reasoning.encrypted_content") || has("encrypted_reasoning") {
65 return Some("provider encrypted-reasoning field");
66 }
67
68 // 2. Bare opaque blob: a single long contiguous base64-ish run. We scan for
69 // the longest such run anywhere in the content (so a blob embedded in JSON
70 // quotes is caught even without the markers above), then require it to
71 // carry mixed case + digits — the entropy signature of an encoded blob —
72 // so a long lowercase hex string or a run of underscores does not trip it.
73 if longest_base64ish_run_looks_opaque(content) {
74 return Some("long high-entropy base64-shaped blob");
75 }
76
77 None
78}
79
80/// Scan `content` for the longest contiguous base64-ish run and decide whether it
81/// looks like an opaque encoded blob (length ≥ [`MIN_BLOB_LEN`] and carrying
82/// upper + lower + digit). Pure shape inspection; nothing is decoded.
83fn longest_base64ish_run_looks_opaque(content: &str) -> bool {
84 let chars: Vec<char> = content.chars().collect();
85 let mut i = 0;
86 while i < chars.len() {
87 if is_base64ish(chars[i]) {
88 let start = i;
89 while i < chars.len() && is_base64ish(chars[i]) {
90 i += 1;
91 }
92 let run = &chars[start..i];
93 if run.len() >= MIN_BLOB_LEN {
94 let has_upper = run.iter().any(|c| c.is_ascii_uppercase());
95 let has_lower = run.iter().any(|c| c.is_ascii_lowercase());
96 let has_digit = run.iter().any(|c| c.is_ascii_digit());
97 if has_upper && has_lower && has_digit {
98 return true;
99 }
100 }
101 } else {
102 i += 1;
103 }
104 }
105 false
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 fn blob(n: usize) -> String {
113 // Deterministic mixed-case+digit base64-ish run of length n (no decode
114 // target — purely a shape fixture). Vary by index so it isn't a single
115 // repeated char (which some readers might special-case).
116 const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
117 (0..n).map(|k| ALPHA[k % ALPHA.len()] as char).collect()
118 }
119
120 #[test]
121 fn openai_reasoning_block_is_flagged() {
122 let s = r#"{"type":"reasoning","summary":[],"encrypted_content":"gAAAAAB..."}"#;
123 assert!(detect(s).is_some());
124 }
125
126 #[test]
127 fn anthropic_redacted_thinking_is_flagged() {
128 let s = r#"{"type":"redacted_thinking","data":"EvwBCkYI...opaque..."}"#;
129 assert!(detect(s).is_some());
130 }
131
132 #[test]
133 fn bare_long_blob_is_flagged() {
134 assert!(detect(&blob(300)).is_some());
135 // Embedded in surrounding text is still caught (longest-run scan).
136 let wrapped = format!("assistant said: {} -- end", blob(300));
137 assert!(detect(&wrapped).is_some());
138 }
139
140 #[test]
141 fn ordinary_prose_is_not_flagged() {
142 let s = "The user prefers dark mode and lives in Berlin. Remind them at 9am.";
143 assert!(detect(s).is_none());
144 }
145
146 #[test]
147 fn short_token_is_not_flagged() {
148 // A normal API key-ish token below the blob floor is not a reasoning-blob
149 // shape (this detector is for the opaque-reasoning shape, not secrets).
150 assert!(detect(&blob(64)).is_none());
151 }
152
153 #[test]
154 fn long_lowercase_hex_is_not_flagged() {
155 // A 512-char all-lowercase hex string lacks the mixed-case entropy
156 // signature, so it is not treated as an opaque encoded blob.
157 let hex: String = std::iter::repeat_n("abcdef0123456789", 32).collect();
158 assert_eq!(hex.len(), 512);
159 assert!(detect(&hex).is_none());
160 }
161}