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    cleanup_old_tee_logs(&tee_dir);
14
15    let cmd_slug: String = command
16        .chars()
17        .take(40)
18        .map(|c| {
19            if c.is_alphanumeric() || c == '-' {
20                c
21            } else {
22                '_'
23            }
24        })
25        .collect();
26    // Content-addressed path (#498): the same command always maps to the same
27    // file, so repeated tool outputs stay byte-identical (provider prompt
28    // caches reward stable text). Re-runs overwrite — newest output wins;
29    // the 24h TTL cleanup works on mtime, not the filename.
30    let cmd_hash = blake3::hash(command.as_bytes()).to_hex();
31    let filename = format!("{cmd_slug}_{}.log", &cmd_hash.as_str()[..8]);
32    let path = tee_dir.join(&filename);
33
34    let masked = crate::core::redaction::redact_text(output);
35    let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
36    std::fs::write(&path, redacted).ok()?;
37    #[cfg(unix)]
38    {
39        use std::os::unix::fs::PermissionsExt;
40        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
41    }
42    Some(path.to_string_lossy().to_string())
43}
44
45fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
46    let cutoff = std::time::SystemTime::now().checked_sub(std::time::Duration::from_hours(24));
47    let Some(cutoff) = cutoff else { return };
48
49    if let Ok(entries) = std::fs::read_dir(tee_dir) {
50        for entry in entries.flatten() {
51            if let Ok(meta) = entry.metadata()
52                && let Ok(modified) = meta.modified()
53                && modified < cutoff
54            {
55                let _ = std::fs::remove_file(entry.path());
56            }
57        }
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    /// Determinism contract (#498): the tee path must be content-addressed —
66    /// the same command always maps to the same file so repeated tool outputs
67    /// stay byte-identical for provider prompt caching.
68    #[test]
69    fn tee_path_is_content_addressed() {
70        // Serialize against tests that repoint LEAN_CTX_DATA_DIR (isolated_data_dir);
71        // without the lock the resolved tee base races and the paths diverge.
72        let _lock = crate::core::data_dir::test_env_lock();
73        let first = save_tee("cargo test --lib", "output run 1").expect("tee saved");
74        let second = save_tee("cargo test --lib", "output run 2").expect("tee saved");
75        assert_eq!(first, second, "same command must map to the same tee path");
76
77        let other = save_tee("cargo build", "output").expect("tee saved");
78        assert_ne!(first, other, "different commands get different tee paths");
79
80        // Latest output wins on overwrite.
81        let content = std::fs::read_to_string(&second).unwrap();
82        assert!(content.contains("run 2"));
83
84        for p in [first, other] {
85            let _ = std::fs::remove_file(p);
86        }
87    }
88}