Skip to main content

lean_ctx/proxy/
ccr.rs

1//! Content-addressed recovery (CCR) for the proxy's lossy rewrites (#482).
2//!
3//! When the proxy prunes an old `tool_result` from conversation history, the
4//! lossy stub used to say *"re-read the file"* — which is stale-unsafe by
5//! construction: in an agent session files are edited or deleted between turns,
6//! so a re-read returns the *current* bytes (or fails), not the historical
7//! version the conversation actually showed. The model could then silently
8//! reason about the wrong content.
9//!
10//! CCR fixes this by persisting the **verbatim original** to the shared,
11//! content-addressed tee store (`{state}/tee/`, reused from the shell path) and
12//! embedding a **retrieval handle** — the absolute path of that file — in the
13//! stub. Retrieval is MCP-independent: the agent reads the path with its native
14//! file read; no lean-ctx tool has to be attached.
15//!
16//! ## Cache-safety (#448)
17//! The handle is the file path, and the path is a pure function of the content
18//! hash ([`crate::core::hasher::hash_short`]). For a fixed pruned message the
19//! handle is therefore byte-identical on every later turn, so the provider
20//! prompt-cache prefix is never invalidated. The on-disk *write* is best-effort
21//! and never affects the returned handle — only retrievability degrades if the
22//! write (or the 24h TTL cleanup) loses the file, so a stub can never become
23//! non-deterministic based on filesystem state.
24
25use std::path::{Path, PathBuf};
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::time::{SystemTime, UNIX_EPOCH};
28
29use serde_json::Value;
30
31/// Opening delimiter of an in-band retrieval marker: `<lc_expand:HASH>` (#493).
32const EXPAND_OPEN: &str = "<lc_expand:";
33/// Closing delimiter of an in-band retrieval marker.
34const EXPAND_CLOSE: char = '>';
35
36/// Originals smaller than this are not worth a tee file + handle; the caller
37/// keeps its plain stub. Matches the spirit of the prune length thresholds.
38pub(crate) const MIN_TEE_BYTES: usize = 512;
39
40/// Throttle the O(dir) TTL cleanup so the prune hot path does at most one
41/// directory scan per this interval (the write itself is content-addressed and
42/// idempotent, so steady-state cost is a single `stat`).
43const CLEANUP_INTERVAL_SECS: u64 = 600;
44
45/// Length of the content hash in a proxy/json tee name ([`hash_short`]).
46const TEE_HASH_LEN: usize = 16;
47/// Length of the command hash in a shell tee name (`shell::redact::save_tee`).
48const SHELL_TEE_HASH_LEN: usize = 8;
49
50/// Deterministic tee path for `content`:
51/// `{state}/tee/{prefix}_{blake3(content)[..16]}.log`. Pure (no I/O) so a stub
52/// embedding it stays byte-stable regardless of filesystem state. `prefix`
53/// segregates the producer (`proxy` for history-prune / live-compression stubs,
54/// `json` for the JSON crusher's lossy originals, #936) yet keeps one shared
55/// store + one resolver ([`resolve_tee`]).
56fn tee_path(content: &str, prefix: &str) -> Option<PathBuf> {
57    let dir = crate::core::paths::state_dir().ok()?.join("tee");
58    let hash = crate::core::hasher::hash_short(content);
59    Some(dir.join(format!("{prefix}_{hash}.log")))
60}
61
62/// Run the shared 24h TTL cleanup at most once per [`CLEANUP_INTERVAL_SECS`].
63fn maybe_cleanup(tee_dir: &Path) {
64    static LAST: AtomicU64 = AtomicU64::new(0);
65    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
66        return;
67    };
68    let now = now.as_secs();
69    let last = LAST.load(Ordering::Relaxed);
70    if now.saturating_sub(last) < CLEANUP_INTERVAL_SECS {
71        return;
72    }
73    // Only one thread wins the slot; the rest skip until the next interval.
74    if LAST
75        .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
76        .is_ok()
77    {
78        crate::shell::cleanup_old_tee_logs(tee_dir);
79    }
80}
81
82/// Persist `content` verbatim (best-effort, secret-redacted) to the
83/// content-addressed tee store and return its retrieval handle (the absolute
84/// path). Returns `None` only when `content` is below [`MIN_TEE_BYTES`] or the
85/// state dir can't be resolved — never because the *write* failed, so the
86/// returned handle is a pure function of the content and the embedding stub
87/// stays deterministic. Re-persisting identical content is idempotent: same
88/// content → same path → the existing file is left untouched.
89pub(crate) fn persist(content: &str) -> Option<String> {
90    persist_with(content, "proxy")
91}
92
93/// Persist a JSON crusher's verbatim original (#936) under the `json_` prefix and
94/// return its `{state}/tee/json_{hash}.log` handle. Used by the lossy crush stage
95/// so a dropped column is always recoverable out-of-band via [`resolve_tee`] /
96/// `ctx_expand`, never reconstructed from the (lossy) text. Shares the
97/// content-address and best-effort write contract of [`persist`], so the embedded
98/// handle stays deterministic (cache-safe).
99pub(crate) fn persist_json(content: &str) -> Option<String> {
100    persist_with(content, "json")
101}
102
103fn persist_with(content: &str, prefix: &str) -> Option<String> {
104    if content.len() < MIN_TEE_BYTES {
105        return None;
106    }
107    let path = tee_path(content, prefix)?;
108    let handle = path.to_string_lossy().to_string();
109
110    if !path.exists() {
111        if let Some(dir) = path.parent()
112            && std::fs::create_dir_all(dir).is_ok()
113        {
114            maybe_cleanup(dir);
115        }
116        // Same redaction the shell tee applies, so a recovered original can never
117        // re-introduce a secret the live turn would also have masked.
118        let masked = crate::core::redaction::redact_text(content);
119        let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
120        if std::fs::write(&path, redacted).is_ok() {
121            #[cfg(unix)]
122            {
123                use std::os::unix::fs::PermissionsExt;
124                let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
125            }
126        }
127    }
128    Some(handle)
129}
130
131fn is_hex(s: &str, len: usize) -> bool {
132    s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit())
133}
134
135/// Canonical `{prefix}_{16hex}.log` name for a proxy / json / bare-hash id, or
136/// `None`. A bare 16-hex id defaults to the `proxy_` store (back-compat: that is
137/// the only form pre-#936 stubs carry).
138fn canonical_tee_name(name: &str) -> Option<String> {
139    let stem = name.strip_suffix(".log").unwrap_or(name);
140    if let Some(hash) = stem.strip_prefix("proxy_") {
141        return is_hex(hash, TEE_HASH_LEN).then(|| format!("proxy_{hash}.log"));
142    }
143    if let Some(hash) = stem.strip_prefix("json_") {
144        return is_hex(hash, TEE_HASH_LEN).then(|| format!("json_{hash}.log"));
145    }
146    is_hex(stem, TEE_HASH_LEN).then(|| format!("proxy_{stem}.log"))
147}
148
149/// True for a shell tee basename `<slug>_<8hex>.log` (`shell::redact::save_tee`):
150/// ends in `.log`, the whole basename is safe (`[A-Za-z0-9_-]`), and the **last**
151/// `_`-segment is exactly 8 hex. The slug itself may contain `_`, so the hash is
152/// matched as the suffix — never the first segment (the documented parsing trap).
153fn is_shell_tee_name(name: &str) -> bool {
154    let Some(stem) = name.strip_suffix(".log") else {
155        return false;
156    };
157    if !stem
158        .bytes()
159        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
160    {
161        return false;
162    }
163    match stem.rsplit_once('_') {
164        Some((slug, hash)) => !slug.is_empty() && is_hex(hash, SHELL_TEE_HASH_LEN),
165        None => false,
166    }
167}
168
169/// Resolve a retrieval `id` back to a file in the shared `{state}/tee/` store.
170/// Accepts every handle form a stub or footer can carry, with a fixed precedence
171/// so the forms can never collide (#936):
172///
173/// 1. **Prefix forms** — `proxy_<16hex>(.log)`, `json_<16hex>(.log)`, or a bare
174///    `<16hex>` (→ `proxy_`, back-compat). The proxy history-prune / live stubs
175///    and the JSON crusher's lossy originals.
176/// 2. **Shell-tee form** — `<slug>_<8hex>.log` (`save_tee`), so every compressed
177///    shell command's already-teed verbatim output is surgically retrievable.
178///
179/// The 16-vs-8 hex length already disambiguates the two classes; the explicit
180/// order documents intent. Security: only the *file name* is trusted — the path
181/// is always rebuilt under `{state}/tee/`, so a crafted `id` can never escape the
182/// store (no path traversal) and a non-tee id resolves to `None`.
183pub(crate) fn resolve_tee(id: &str) -> Option<PathBuf> {
184    let name = Path::new(id)
185        .file_name()
186        .and_then(|n| n.to_str())
187        .unwrap_or(id);
188    let canon =
189        canonical_tee_name(name).or_else(|| is_shell_tee_name(name).then(|| name.to_string()))?;
190    let path = crate::core::paths::state_dir()
191        .ok()?
192        .join("tee")
193        .join(canon);
194    path.is_file().then_some(path)
195}
196
197/// The in-band retrieval marker `<lc_expand:HASH>` for a CCR `handle` (#493).
198///
199/// `HASH` is the content hash already embedded in the tee handle, so a model can
200/// echo the marker verbatim and the proxy can recover the original via
201/// [`resolve_tee`] on the next turn. Pure (no I/O, no config) so it is trivially
202/// testable; returns `None` for a handle that is not a canonical tee path.
203pub(crate) fn inband_marker(handle: &str) -> Option<String> {
204    let name = Path::new(handle).file_name().and_then(|n| n.to_str())?;
205    let hash = name.strip_prefix("proxy_")?.strip_suffix(".log")?;
206    (hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
207        .then(|| format!("{EXPAND_OPEN}{hash}{EXPAND_CLOSE}"))
208}
209
210/// The in-band marker for `handle` **only when in-band CCR is enabled** (#493),
211/// else `None`. Stub sites use this to advertise an echo-able `<lc_expand:HASH>`
212/// solely in in-band mode: a normal (shared-filesystem) deployment keeps its
213/// path handle, so the model never sees a marker the proxy would not splice.
214///
215/// Reads the (process-cached) config; the surrounding stub path already does
216/// per-message tee I/O via [`persist`], so this adds no new I/O class.
217pub(crate) fn inband_locator(handle: &str) -> Option<String> {
218    crate::core::config::Config::load()
219        .proxy
220        .ccr_inband_enabled()
221        .then(|| inband_marker(handle))
222        .flatten()
223}
224
225/// Recover the verbatim original for a 16-hex CCR `hash` from the local tee
226/// store, or `None` when the hash is malformed or the file is gone (past TTL).
227fn recover(hash: &str) -> Option<String> {
228    if hash.len() != 16 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
229        return None;
230    }
231    std::fs::read_to_string(resolve_tee(hash)?).ok()
232}
233
234/// Replace every `<lc_expand:HASH>` marker in `s` with the verbatim original
235/// recovered from the local tee store. Returns `Some(spliced)` only when at
236/// least one marker resolved, else `None` (so the caller leaves the string —
237/// and therefore the request bytes — untouched).
238///
239/// An unresolvable marker (bad hash, or a file dropped past the 24h TTL) is left
240/// in place verbatim: the model still sees its own marker rather than a silent
241/// deletion, and a later turn can retry once the operator restores the file.
242/// The spliced content is inserted **raw** (not `<lc_safe>`-wrapped): this runs
243/// on the recent assistant turn the model echoed the marker into, which no proxy
244/// compressor rewrites, and the proxy has no global `<lc_safe>` strip — wrapping
245/// would instead leak the markers to the provider.
246fn splice_str(s: &str) -> Option<String> {
247    if !s.contains(EXPAND_OPEN) {
248        return None;
249    }
250    let mut out = String::with_capacity(s.len());
251    let mut rest = s;
252    let mut changed = false;
253    while let Some(pos) = rest.find(EXPAND_OPEN) {
254        let after = &rest[pos + EXPAND_OPEN.len()..];
255        match after.find(EXPAND_CLOSE) {
256            Some(end) => {
257                let hash = &after[..end];
258                if let Some(original) = recover(hash) {
259                    out.push_str(&rest[..pos]);
260                    out.push_str(&original);
261                    rest = &after[end + EXPAND_CLOSE.len_utf8()..];
262                    changed = true;
263                } else {
264                    // Keep the literal marker; resume scanning past this `<` so a
265                    // later valid marker in the same string is still spliced.
266                    out.push_str(&rest[..pos + EXPAND_OPEN.len()]);
267                    rest = after;
268                }
269            }
270            // No closing `>`: nothing more can match — keep the remainder verbatim.
271            None => break,
272        }
273    }
274    out.push_str(rest);
275    changed.then_some(out)
276}
277
278/// Splice in-band `<lc_expand:HASH>` markers throughout a parsed request body
279/// (#493), replacing each with the verbatim original recovered from the local
280/// tee store. Recurses over every JSON string (object values and array items).
281///
282/// Returns `true` iff at least one marker was spliced. A request with no marker
283/// is left **byte-identical** (the function never allocates a replacement), so a
284/// marker-less turn never perturbs the provider prompt-cache prefix — the splice
285/// only ever changes the bytes the model explicitly asked to expand.
286pub(crate) fn splice_inband_in_place(value: &mut Value) -> bool {
287    match value {
288        Value::String(s) => {
289            if let Some(spliced) = splice_str(s) {
290                *s = spliced;
291                true
292            } else {
293                false
294            }
295        }
296        Value::Array(items) => {
297            let mut changed = false;
298            for item in items {
299                changed |= splice_inband_in_place(item);
300            }
301            changed
302        }
303        Value::Object(map) => {
304            let mut changed = false;
305            for (_, v) in map.iter_mut() {
306                changed |= splice_inband_in_place(v);
307            }
308            changed
309        }
310        _ => false,
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    fn big(seed: &str) -> String {
319        format!("{seed}\n").repeat(40)
320    }
321
322    #[test]
323    fn handle_is_content_addressed_and_deterministic() {
324        let _lock = crate::core::data_dir::test_env_lock();
325        let content = big("file body line");
326        let a = persist(&content).expect("persisted");
327        let b = persist(&content).expect("persisted again");
328        assert_eq!(
329            a, b,
330            "same content must map to the same handle (cache-safe)"
331        );
332        assert!(a.contains("proxy_"), "handle is a proxy tee path: {a}");
333
334        let other = persist(&big("different body")).expect("persisted");
335        assert_ne!(a, other, "different content must get a different handle");
336    }
337
338    #[test]
339    fn persisted_original_is_recoverable() {
340        let _lock = crate::core::data_dir::test_env_lock();
341        let content = big("recoverable verbatim line");
342        let handle = persist(&content).expect("persisted");
343        let on_disk = std::fs::read_to_string(&handle).expect("tee file readable");
344        assert!(
345            on_disk.contains("recoverable verbatim line"),
346            "the verbatim original must be retrievable from the handle"
347        );
348    }
349
350    #[test]
351    fn small_content_gets_no_handle() {
352        let _lock = crate::core::data_dir::test_env_lock();
353        assert!(
354            persist("too small to bother").is_none(),
355            "below MIN_TEE_BYTES there is no handle (the caller keeps its plain stub)"
356        );
357    }
358
359    #[test]
360    fn resolve_tee_accepts_every_stub_form() {
361        let _lock = crate::core::data_dir::test_env_lock();
362        let content = big("resolvable tee body");
363        let handle = persist(&content).expect("persisted");
364        let hash = crate::core::hasher::hash_short(&content);
365
366        // Full path, bare file name, proxy_<hash>, and bare <hash> all resolve to
367        // the same on-disk file — whatever the agent copied out of the stub.
368        for form in [
369            handle.clone(),
370            format!("proxy_{hash}.log"),
371            format!("proxy_{hash}"),
372            hash.clone(),
373        ] {
374            let resolved = resolve_tee(&form).unwrap_or_else(|| panic!("must resolve {form}"));
375            assert_eq!(
376                resolved.to_string_lossy(),
377                handle,
378                "form {form} -> {handle}"
379            );
380        }
381    }
382
383    #[test]
384    fn resolve_tee_rejects_nontee_and_traversal_ids() {
385        let _lock = crate::core::data_dir::test_env_lock();
386        // No FS escape: a crafted path is reduced to its file name, which is not a
387        // valid proxy tee name, so it resolves to None instead of reading it.
388        assert!(resolve_tee("/etc/passwd").is_none());
389        assert!(resolve_tee("../../secret").is_none());
390        assert!(resolve_tee("proxy_nothex0000000.log").is_none());
391        // Right shape but no such file in the store.
392        assert!(resolve_tee("deadbeefdeadbeef").is_none());
393    }
394
395    #[test]
396    fn persist_json_is_distinct_prefix_and_resolvable() {
397        let _lock = crate::core::data_dir::test_env_lock();
398        let content = big("json crusher original");
399        let proxy = persist(&content).expect("proxy persisted");
400        let json = persist_json(&content).expect("json persisted");
401        assert!(
402            json.contains("json_"),
403            "json handle uses json_ prefix: {json}"
404        );
405        assert_ne!(
406            proxy, json,
407            "same content gets distinct files per producer prefix"
408        );
409
410        // The json_ handle resolves through the unified resolver in every form a
411        // stub / footer can carry: full path, bare file name, and bare json_id.
412        let hash = crate::core::hasher::hash_short(&content);
413        for form in [
414            json.clone(),
415            format!("json_{hash}.log"),
416            format!("json_{hash}"),
417        ] {
418            assert_eq!(
419                resolve_tee(&form)
420                    .expect("json form resolves")
421                    .to_string_lossy(),
422                json,
423                "json form {form} -> {json}"
424            );
425        }
426    }
427
428    #[test]
429    fn resolve_tee_resolves_shell_tee_with_underscored_slug() {
430        let _lock = crate::core::data_dir::test_env_lock();
431        // A real shell command whose slug contains underscores: the hash is the
432        // last `_`-segment (8 hex), never the first — the parsing trap. The full
433        // verbatim output the shell already teed is now surgically retrievable.
434        let path = crate::shell::save_tee("gh api /repos/foo/bar", &big("api row"))
435            .expect("shell tee saved");
436        let name = std::path::Path::new(&path)
437            .file_name()
438            .and_then(|n| n.to_str())
439            .unwrap()
440            .to_string();
441        assert!(
442            is_shell_tee_name(&name),
443            "save_tee name must be recognized as a shell tee: {name}"
444        );
445        for form in [path.clone(), name] {
446            assert_eq!(
447                resolve_tee(&form)
448                    .expect("shell tee form resolves")
449                    .to_string_lossy(),
450                path,
451                "shell tee form -> {path}"
452            );
453        }
454    }
455
456    #[test]
457    fn resolve_tee_does_not_capture_reference_ids() {
458        let _lock = crate::core::data_dir::test_env_lock();
459        // A reference-store id (`ref_<16hex>`, no `.log`) must fall through the
460        // tee resolver so ctx_expand routes it to the reference store, not the
461        // tee store — the precedence guard for the unified retrieve ladder.
462        assert!(resolve_tee("ref_deadbeefcafef00d").is_none());
463        // A bare 16-hex archive id with no backing tee file also stays None.
464        assert!(resolve_tee("0123456789abcdef").is_none());
465    }
466
467    #[test]
468    fn inband_marker_is_derived_from_handle() {
469        let _lock = crate::core::data_dir::test_env_lock();
470        let content = big("inband marker body");
471        let handle = persist(&content).expect("persisted");
472        let hash = crate::core::hasher::hash_short(&content);
473        // The marker carries the same content hash the handle does, so a model can
474        // echo it and the proxy resolves it back to the very same tee file.
475        assert_eq!(inband_marker(&handle), Some(format!("<lc_expand:{hash}>")));
476        // A non-tee handle has no marker.
477        assert!(inband_marker("/tmp/not-a-tee.txt").is_none());
478    }
479
480    #[test]
481    fn splice_replaces_marker_with_verbatim_original() {
482        let _lock = crate::core::data_dir::test_env_lock();
483        let content = big("the historical verbatim line");
484        let handle = persist(&content).expect("persisted");
485        let marker = inband_marker(&handle).expect("marker");
486
487        let mut doc = serde_json::json!({
488            "messages": [{ "role": "assistant", "content": format!("recall {marker} please") }]
489        });
490        assert!(splice_inband_in_place(&mut doc), "a marker must splice");
491        let spliced = doc["messages"][0]["content"].as_str().unwrap();
492        assert!(
493            spliced.contains("the historical verbatim line"),
494            "verbatim original must be spliced in: {spliced}"
495        );
496        assert!(
497            !spliced.contains("<lc_expand:"),
498            "the marker must be consumed, not left behind"
499        );
500    }
501
502    #[test]
503    fn splice_is_byte_identical_no_op_without_marker() {
504        let _lock = crate::core::data_dir::test_env_lock();
505        let mut doc = serde_json::json!({
506            "messages": [{ "role": "user", "content": "no marker here" }],
507            "system": "plain"
508        });
509        let before = doc.clone();
510        assert!(
511            !splice_inband_in_place(&mut doc),
512            "no marker → must report no change"
513        );
514        assert_eq!(
515            doc, before,
516            "marker-less body must stay byte-identical (cache-safe)"
517        );
518    }
519
520    #[test]
521    fn splice_keeps_unresolvable_marker_verbatim() {
522        let _lock = crate::core::data_dir::test_env_lock();
523        // Right shape, but no such file in the store → leave the model's marker in
524        // place rather than silently deleting it.
525        let mut doc = serde_json::json!({ "t": "before <lc_expand:deadbeefdeadbeef> after" });
526        assert!(!splice_inband_in_place(&mut doc));
527        assert_eq!(
528            doc["t"].as_str().unwrap(),
529            "before <lc_expand:deadbeefdeadbeef> after"
530        );
531    }
532
533    #[test]
534    fn splice_recurses_and_handles_multiple_markers() {
535        let _lock = crate::core::data_dir::test_env_lock();
536        let a = big("first recovered body");
537        let b = big("second recovered body");
538        let ma = inband_marker(&persist(&a).unwrap()).unwrap();
539        let mb = inband_marker(&persist(&b).unwrap()).unwrap();
540
541        // Two markers in one nested string, plus a deeper array item.
542        let mut doc = serde_json::json!({
543            "contents": [
544                { "parts": [{ "text": format!("{ma} and {mb}") }] }
545            ]
546        });
547        assert!(splice_inband_in_place(&mut doc));
548        let text = doc["contents"][0]["parts"][0]["text"].as_str().unwrap();
549        assert!(text.contains("first recovered body"));
550        assert!(text.contains("second recovered body"));
551        assert!(!text.contains("<lc_expand:"));
552    }
553}