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