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/// Deterministic tee path for `content`:
46/// `{state}/tee/proxy_{blake3(content)[..16]}.log`. Pure (no I/O) so a stub
47/// embedding it stays byte-stable regardless of filesystem state.
48fn tee_path(content: &str) -> Option<PathBuf> {
49    let dir = crate::core::paths::state_dir().ok()?.join("tee");
50    let hash = crate::core::hasher::hash_short(content);
51    Some(dir.join(format!("proxy_{hash}.log")))
52}
53
54/// Run the shared 24h TTL cleanup at most once per [`CLEANUP_INTERVAL_SECS`].
55fn maybe_cleanup(tee_dir: &Path) {
56    static LAST: AtomicU64 = AtomicU64::new(0);
57    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
58        return;
59    };
60    let now = now.as_secs();
61    let last = LAST.load(Ordering::Relaxed);
62    if now.saturating_sub(last) < CLEANUP_INTERVAL_SECS {
63        return;
64    }
65    // Only one thread wins the slot; the rest skip until the next interval.
66    if LAST
67        .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
68        .is_ok()
69    {
70        crate::shell::cleanup_old_tee_logs(tee_dir);
71    }
72}
73
74/// Persist `content` verbatim (best-effort, secret-redacted) to the
75/// content-addressed tee store and return its retrieval handle (the absolute
76/// path). Returns `None` only when `content` is below [`MIN_TEE_BYTES`] or the
77/// state dir can't be resolved — never because the *write* failed, so the
78/// returned handle is a pure function of the content and the embedding stub
79/// stays deterministic. Re-persisting identical content is idempotent: same
80/// content → same path → the existing file is left untouched.
81pub(crate) fn persist(content: &str) -> Option<String> {
82    if content.len() < MIN_TEE_BYTES {
83        return None;
84    }
85    let path = tee_path(content)?;
86    let handle = path.to_string_lossy().to_string();
87
88    if !path.exists() {
89        if let Some(dir) = path.parent()
90            && std::fs::create_dir_all(dir).is_ok()
91        {
92            maybe_cleanup(dir);
93        }
94        // Same redaction the shell tee applies, so a recovered original can never
95        // re-introduce a secret the live turn would also have masked.
96        let masked = crate::core::redaction::redact_text(content);
97        let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
98        if std::fs::write(&path, redacted).is_ok() {
99            #[cfg(unix)]
100            {
101                use std::os::unix::fs::PermissionsExt;
102                let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
103            }
104        }
105    }
106    Some(handle)
107}
108
109/// Resolve a CCR retrieval `id` (as carried in a proxy stub) back to the
110/// existing tee file. Accepts any of the forms an agent might copy out of a
111/// stub: the absolute tee path, the bare file name `proxy_<hash>.log`,
112/// `proxy_<hash>`, or the bare `<hash>`.
113///
114/// Security: only the *file name* is trusted — the path is always rebuilt from
115/// the canonical `{state}/tee/` dir, so a crafted `id` can never escape the tee
116/// store (no path traversal) and a non-tee id simply resolves to `None`.
117pub(crate) fn resolve_tee(id: &str) -> Option<PathBuf> {
118    let name = Path::new(id)
119        .file_name()
120        .and_then(|n| n.to_str())
121        .unwrap_or(id);
122    let hash = name.strip_prefix("proxy_").unwrap_or(name);
123    let hash = hash.strip_suffix(".log").unwrap_or(hash);
124    if hash.len() != 16 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
125        return None;
126    }
127    let path = crate::core::paths::state_dir()
128        .ok()?
129        .join("tee")
130        .join(format!("proxy_{hash}.log"));
131    path.is_file().then_some(path)
132}
133
134/// The in-band retrieval marker `<lc_expand:HASH>` for a CCR `handle` (#493).
135///
136/// `HASH` is the content hash already embedded in the tee handle, so a model can
137/// echo the marker verbatim and the proxy can recover the original via
138/// [`resolve_tee`] on the next turn. Pure (no I/O, no config) so it is trivially
139/// testable; returns `None` for a handle that is not a canonical tee path.
140pub(crate) fn inband_marker(handle: &str) -> Option<String> {
141    let name = Path::new(handle).file_name().and_then(|n| n.to_str())?;
142    let hash = name.strip_prefix("proxy_")?.strip_suffix(".log")?;
143    (hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
144        .then(|| format!("{EXPAND_OPEN}{hash}{EXPAND_CLOSE}"))
145}
146
147/// The in-band marker for `handle` **only when in-band CCR is enabled** (#493),
148/// else `None`. Stub sites use this to advertise an echo-able `<lc_expand:HASH>`
149/// solely in in-band mode: a normal (shared-filesystem) deployment keeps its
150/// path handle, so the model never sees a marker the proxy would not splice.
151///
152/// Reads the (process-cached) config; the surrounding stub path already does
153/// per-message tee I/O via [`persist`], so this adds no new I/O class.
154pub(crate) fn inband_locator(handle: &str) -> Option<String> {
155    crate::core::config::Config::load()
156        .proxy
157        .ccr_inband_enabled()
158        .then(|| inband_marker(handle))
159        .flatten()
160}
161
162/// Recover the verbatim original for a 16-hex CCR `hash` from the local tee
163/// store, or `None` when the hash is malformed or the file is gone (past TTL).
164fn recover(hash: &str) -> Option<String> {
165    if hash.len() != 16 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
166        return None;
167    }
168    std::fs::read_to_string(resolve_tee(hash)?).ok()
169}
170
171/// Replace every `<lc_expand:HASH>` marker in `s` with the verbatim original
172/// recovered from the local tee store. Returns `Some(spliced)` only when at
173/// least one marker resolved, else `None` (so the caller leaves the string —
174/// and therefore the request bytes — untouched).
175///
176/// An unresolvable marker (bad hash, or a file dropped past the 24h TTL) is left
177/// in place verbatim: the model still sees its own marker rather than a silent
178/// deletion, and a later turn can retry once the operator restores the file.
179/// The spliced content is inserted **raw** (not `<lc_safe>`-wrapped): this runs
180/// on the recent assistant turn the model echoed the marker into, which no proxy
181/// compressor rewrites, and the proxy has no global `<lc_safe>` strip — wrapping
182/// would instead leak the markers to the provider.
183fn splice_str(s: &str) -> Option<String> {
184    if !s.contains(EXPAND_OPEN) {
185        return None;
186    }
187    let mut out = String::with_capacity(s.len());
188    let mut rest = s;
189    let mut changed = false;
190    while let Some(pos) = rest.find(EXPAND_OPEN) {
191        let after = &rest[pos + EXPAND_OPEN.len()..];
192        match after.find(EXPAND_CLOSE) {
193            Some(end) => {
194                let hash = &after[..end];
195                if let Some(original) = recover(hash) {
196                    out.push_str(&rest[..pos]);
197                    out.push_str(&original);
198                    rest = &after[end + EXPAND_CLOSE.len_utf8()..];
199                    changed = true;
200                } else {
201                    // Keep the literal marker; resume scanning past this `<` so a
202                    // later valid marker in the same string is still spliced.
203                    out.push_str(&rest[..pos + EXPAND_OPEN.len()]);
204                    rest = after;
205                }
206            }
207            // No closing `>`: nothing more can match — keep the remainder verbatim.
208            None => break,
209        }
210    }
211    out.push_str(rest);
212    changed.then_some(out)
213}
214
215/// Splice in-band `<lc_expand:HASH>` markers throughout a parsed request body
216/// (#493), replacing each with the verbatim original recovered from the local
217/// tee store. Recurses over every JSON string (object values and array items).
218///
219/// Returns `true` iff at least one marker was spliced. A request with no marker
220/// is left **byte-identical** (the function never allocates a replacement), so a
221/// marker-less turn never perturbs the provider prompt-cache prefix — the splice
222/// only ever changes the bytes the model explicitly asked to expand.
223pub(crate) fn splice_inband_in_place(value: &mut Value) -> bool {
224    match value {
225        Value::String(s) => {
226            if let Some(spliced) = splice_str(s) {
227                *s = spliced;
228                true
229            } else {
230                false
231            }
232        }
233        Value::Array(items) => {
234            let mut changed = false;
235            for item in items {
236                changed |= splice_inband_in_place(item);
237            }
238            changed
239        }
240        Value::Object(map) => {
241            let mut changed = false;
242            for (_, v) in map.iter_mut() {
243                changed |= splice_inband_in_place(v);
244            }
245            changed
246        }
247        _ => false,
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    fn big(seed: &str) -> String {
256        format!("{seed}\n").repeat(40)
257    }
258
259    #[test]
260    fn handle_is_content_addressed_and_deterministic() {
261        let _lock = crate::core::data_dir::test_env_lock();
262        let content = big("file body line");
263        let a = persist(&content).expect("persisted");
264        let b = persist(&content).expect("persisted again");
265        assert_eq!(
266            a, b,
267            "same content must map to the same handle (cache-safe)"
268        );
269        assert!(a.contains("proxy_"), "handle is a proxy tee path: {a}");
270
271        let other = persist(&big("different body")).expect("persisted");
272        assert_ne!(a, other, "different content must get a different handle");
273    }
274
275    #[test]
276    fn persisted_original_is_recoverable() {
277        let _lock = crate::core::data_dir::test_env_lock();
278        let content = big("recoverable verbatim line");
279        let handle = persist(&content).expect("persisted");
280        let on_disk = std::fs::read_to_string(&handle).expect("tee file readable");
281        assert!(
282            on_disk.contains("recoverable verbatim line"),
283            "the verbatim original must be retrievable from the handle"
284        );
285    }
286
287    #[test]
288    fn small_content_gets_no_handle() {
289        let _lock = crate::core::data_dir::test_env_lock();
290        assert!(
291            persist("too small to bother").is_none(),
292            "below MIN_TEE_BYTES there is no handle (the caller keeps its plain stub)"
293        );
294    }
295
296    #[test]
297    fn resolve_tee_accepts_every_stub_form() {
298        let _lock = crate::core::data_dir::test_env_lock();
299        let content = big("resolvable tee body");
300        let handle = persist(&content).expect("persisted");
301        let hash = crate::core::hasher::hash_short(&content);
302
303        // Full path, bare file name, proxy_<hash>, and bare <hash> all resolve to
304        // the same on-disk file — whatever the agent copied out of the stub.
305        for form in [
306            handle.clone(),
307            format!("proxy_{hash}.log"),
308            format!("proxy_{hash}"),
309            hash.clone(),
310        ] {
311            let resolved = resolve_tee(&form).unwrap_or_else(|| panic!("must resolve {form}"));
312            assert_eq!(
313                resolved.to_string_lossy(),
314                handle,
315                "form {form} -> {handle}"
316            );
317        }
318    }
319
320    #[test]
321    fn resolve_tee_rejects_nontee_and_traversal_ids() {
322        let _lock = crate::core::data_dir::test_env_lock();
323        // No FS escape: a crafted path is reduced to its file name, which is not a
324        // valid proxy tee name, so it resolves to None instead of reading it.
325        assert!(resolve_tee("/etc/passwd").is_none());
326        assert!(resolve_tee("../../secret").is_none());
327        assert!(resolve_tee("proxy_nothex0000000.log").is_none());
328        // Right shape but no such file in the store.
329        assert!(resolve_tee("deadbeefdeadbeef").is_none());
330    }
331
332    #[test]
333    fn inband_marker_is_derived_from_handle() {
334        let _lock = crate::core::data_dir::test_env_lock();
335        let content = big("inband marker body");
336        let handle = persist(&content).expect("persisted");
337        let hash = crate::core::hasher::hash_short(&content);
338        // The marker carries the same content hash the handle does, so a model can
339        // echo it and the proxy resolves it back to the very same tee file.
340        assert_eq!(inband_marker(&handle), Some(format!("<lc_expand:{hash}>")));
341        // A non-tee handle has no marker.
342        assert!(inband_marker("/tmp/not-a-tee.txt").is_none());
343    }
344
345    #[test]
346    fn splice_replaces_marker_with_verbatim_original() {
347        let _lock = crate::core::data_dir::test_env_lock();
348        let content = big("the historical verbatim line");
349        let handle = persist(&content).expect("persisted");
350        let marker = inband_marker(&handle).expect("marker");
351
352        let mut doc = serde_json::json!({
353            "messages": [{ "role": "assistant", "content": format!("recall {marker} please") }]
354        });
355        assert!(splice_inband_in_place(&mut doc), "a marker must splice");
356        let spliced = doc["messages"][0]["content"].as_str().unwrap();
357        assert!(
358            spliced.contains("the historical verbatim line"),
359            "verbatim original must be spliced in: {spliced}"
360        );
361        assert!(
362            !spliced.contains("<lc_expand:"),
363            "the marker must be consumed, not left behind"
364        );
365    }
366
367    #[test]
368    fn splice_is_byte_identical_no_op_without_marker() {
369        let _lock = crate::core::data_dir::test_env_lock();
370        let mut doc = serde_json::json!({
371            "messages": [{ "role": "user", "content": "no marker here" }],
372            "system": "plain"
373        });
374        let before = doc.clone();
375        assert!(
376            !splice_inband_in_place(&mut doc),
377            "no marker → must report no change"
378        );
379        assert_eq!(
380            doc, before,
381            "marker-less body must stay byte-identical (cache-safe)"
382        );
383    }
384
385    #[test]
386    fn splice_keeps_unresolvable_marker_verbatim() {
387        let _lock = crate::core::data_dir::test_env_lock();
388        // Right shape, but no such file in the store → leave the model's marker in
389        // place rather than silently deleting it.
390        let mut doc = serde_json::json!({ "t": "before <lc_expand:deadbeefdeadbeef> after" });
391        assert!(!splice_inband_in_place(&mut doc));
392        assert_eq!(
393            doc["t"].as_str().unwrap(),
394            "before <lc_expand:deadbeefdeadbeef> after"
395        );
396    }
397
398    #[test]
399    fn splice_recurses_and_handles_multiple_markers() {
400        let _lock = crate::core::data_dir::test_env_lock();
401        let a = big("first recovered body");
402        let b = big("second recovered body");
403        let ma = inband_marker(&persist(&a).unwrap()).unwrap();
404        let mb = inband_marker(&persist(&b).unwrap()).unwrap();
405
406        // Two markers in one nested string, plus a deeper array item.
407        let mut doc = serde_json::json!({
408            "contents": [
409                { "parts": [{ "text": format!("{ma} and {mb}") }] }
410            ]
411        });
412        assert!(splice_inband_in_place(&mut doc));
413        let text = doc["contents"][0]["parts"][0]["text"].as_str().unwrap();
414        assert!(text.contains("first recovered body"));
415        assert!(text.contains("second recovered body"));
416        assert!(!text.contains("<lc_expand:"));
417    }
418}