Skip to main content

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}