Skip to main content

lean_ctx/shell/
redact.rs

1//! Tee logging for shell output.
2//!
3//! Secret masking is delegated to [`crate::core::redaction`] — the single
4//! source of truth shared with `ctx_read` redaction — so the regex set can
5//! never drift between the two layers again (it used to be a hand-copied
6//! duplicate). `save_tee` then runs the config-driven secret scanner on top
7//! for defense in depth.
8
9pub fn save_tee(command: &str, output: &str) -> Option<String> {
10    // Shell output has no complete source authority; do not create a protected
11    // recovery copy or advertise a handle that cannot be reauthorized.
12    if crate::core::policy::runtime::is_active() {
13        return None;
14    }
15    // Inspect both content and filename inputs before any recovery copy exists.
16    let command = crate::core::policy::content::protect_active(command).ok()?;
17    let output = crate::core::policy::content::protect_active(output).ok()?;
18    let command = command.as_ref();
19    let output = output.as_ref();
20    let tee_dir = crate::core::paths::state_dir().ok()?.join("tee");
21    std::fs::create_dir_all(&tee_dir).ok()?;
22
23    // #950: cleanup is an O(N) read_dir + per-file metadata() scan of the
24    // whole tee directory. Running it on every save_tee (i.e. every
25    // compressed shell call) means its cost scales with directory size on
26    // every single invocation under heavy shell activity. Entries already
27    // carry a 24h TTL, so throttling the scan to once per interval is enough
28    // to keep the directory bounded without paying the O(N) cost every time.
29    if let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
30        && TEE_CLEANUP_THROTTLE.is_due(now.as_secs())
31    {
32        cleanup_old_tee_logs(&tee_dir);
33    }
34
35    let cmd_slug: String = command
36        .chars()
37        .take(40)
38        .map(|c| {
39            if c.is_alphanumeric() || c == '-' {
40                c
41            } else {
42                '_'
43            }
44        })
45        .collect();
46    // Content-addressed path (#498): the same command always maps to the same
47    // file, so repeated tool outputs stay byte-identical (provider prompt
48    // caches reward stable text). Re-runs overwrite — newest output wins;
49    // the 24h TTL cleanup works on mtime, not the filename.
50    let cmd_hash = blake3::hash(command.as_bytes()).to_hex();
51    let filename = format!("{cmd_slug}_{}.log", &cmd_hash.as_str()[..8]);
52    let path = tee_dir.join(&filename);
53
54    crate::core::ccr::write_tee(&path, output)?;
55    let handle = path.to_string_lossy().to_string();
56    crate::core::relevance_tracker::register_compressed(
57        handle.clone(),
58        output,
59        "ctx_shell",
60        crate::core::tokens::count_tokens(output),
61        0,
62    );
63    Some(handle)
64}
65
66/// Lock-free gate that lets [`cleanup_old_tee_logs`]'s directory scan run at
67/// most once per `interval_secs`, no matter how many `save_tee` calls land
68/// concurrently. Only the caller whose compare-exchange wins gets `true`.
69struct CleanupThrottle {
70    last_run_unix_secs: std::sync::atomic::AtomicU64,
71    interval_secs: u64,
72}
73
74impl CleanupThrottle {
75    const fn new(interval_secs: u64) -> Self {
76        Self {
77            last_run_unix_secs: std::sync::atomic::AtomicU64::new(0),
78            interval_secs,
79        }
80    }
81
82    /// `0` means "never run" and is always due, regardless of `now_unix_secs`
83    /// — avoids the throttle depending on `now` being a large real epoch time.
84    fn is_due(&self, now_unix_secs: u64) -> bool {
85        use std::sync::atomic::Ordering;
86        let last = self.last_run_unix_secs.load(Ordering::Relaxed);
87        let due = last == 0 || now_unix_secs.saturating_sub(last) >= self.interval_secs;
88        due && self
89            .last_run_unix_secs
90            .compare_exchange(last, now_unix_secs, Ordering::Relaxed, Ordering::Relaxed)
91            .is_ok()
92    }
93}
94
95static TEE_CLEANUP_THROTTLE: CleanupThrottle = CleanupThrottle::new(10 * 60);
96
97/// Removes tee log entries older than 24h. Throttled by [`TEE_CLEANUP_THROTTLE`]
98/// (called from `save_tee`) rather than run on every call — the read_dir +
99/// per-file metadata() scan is O(N) in directory size.
100pub(crate) fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
101    let cutoff = std::time::SystemTime::now().checked_sub(std::time::Duration::from_hours(24));
102    let Some(cutoff) = cutoff else { return };
103
104    if let Ok(entries) = std::fs::read_dir(tee_dir) {
105        for entry in entries.flatten() {
106            if let Ok(meta) = entry.metadata()
107                && let Ok(modified) = meta.modified()
108                && modified < cutoff
109            {
110                let _ = std::fs::remove_file(entry.path());
111            }
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    // --- #950: tee cleanup throttle ---
121
122    #[test]
123    fn cleanup_throttle_gates_by_interval() {
124        let throttle = CleanupThrottle::new(600);
125        assert!(
126            throttle.is_due(1_000),
127            "never run before: first call is always due"
128        );
129        assert!(
130            !throttle.is_due(1_100),
131            "only 100s elapsed of a 600s interval"
132        );
133        assert!(!throttle.is_due(1_599), "still short of the interval by 1s");
134        assert!(
135            throttle.is_due(1_600),
136            "exactly interval-elapsed must be due again"
137        );
138    }
139
140    #[test]
141    fn cleanup_throttle_lets_only_one_racer_through_per_interval() {
142        let throttle = CleanupThrottle::new(600);
143        let hits = std::thread::scope(|scope| {
144            let handles: Vec<_> = (0..16)
145                .map(|_| scope.spawn(|| throttle.is_due(1_000)))
146                .collect();
147            handles
148                .into_iter()
149                .map(|h| h.join().unwrap())
150                .filter(|&due| due)
151                .count()
152        });
153        assert_eq!(hits, 1, "exactly one racer should win the cleanup slot");
154    }
155
156    /// Determinism contract (#498): the tee path must be content-addressed —
157    /// the same command always maps to the same file so repeated tool outputs
158    /// stay byte-identical for provider prompt caching.
159    #[test]
160    fn tee_path_is_content_addressed() {
161        // Serialize against tests that repoint LEAN_CTX_DATA_DIR (isolated_data_dir);
162        // without the lock the resolved tee base races and the paths diverge.
163        let _lock = crate::core::data_dir::test_env_lock();
164        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
165        let first = save_tee("cargo test --lib", "output run 1").expect("tee saved");
166        let second = save_tee("cargo test --lib", "output run 2").expect("tee saved");
167        assert_eq!(first, second, "same command must map to the same tee path");
168
169        let other = save_tee("cargo build", "output").expect("tee saved");
170        assert_ne!(first, other, "different commands get different tee paths");
171
172        // Latest output wins on overwrite.
173        let content = std::fs::read_to_string(&second).unwrap();
174        assert!(content.contains("run 2"));
175
176        for p in [first, other] {
177            let _ = std::fs::remove_file(p);
178        }
179    }
180}