Skip to main content

lean_ctx/core/
io_boundary.rs

1use std::path::{Path, PathBuf};
2
3use crate::core::{events, pathjail, roles, secret_detection};
4
5/// Reads a file without following symlinks (TOCTOU protection).
6/// Falls back to regular read on non-Unix platforms.
7#[cfg(unix)]
8pub fn read_file_nofollow(path: &str) -> Result<String, std::io::Error> {
9    use std::os::unix::fs::OpenOptionsExt;
10    let file = std::fs::OpenOptions::new()
11        .read(true)
12        .custom_flags(libc::O_NOFOLLOW)
13        .open(path);
14    match file {
15        Ok(mut f) => {
16            use std::io::Read;
17            let mut buf = Vec::new();
18            f.read_to_end(&mut buf)?;
19            Ok(String::from_utf8_lossy(&buf).into_owned())
20        }
21        Err(e) if e.raw_os_error() == Some(libc::ELOOP) => Err(std::io::Error::other(format!(
22            "Symlink detected at {path} — refusing to follow (TOCTOU protection)"
23        ))),
24        Err(e) => Err(e),
25    }
26}
27
28/// Windows parity (GL#442): no O_NOFOLLOW exists, so lstat first and refuse
29/// symlinks *and* NTFS junctions/reparse points before opening. Small TOCTOU
30/// window remains between the check and the open (documented in SECURITY.md).
31#[cfg(not(unix))]
32pub fn read_file_nofollow(path: &str) -> Result<String, std::io::Error> {
33    if let Ok(meta) = std::fs::symlink_metadata(path) {
34        if crate::core::pathutil::is_symlink_or_reparse(&meta) {
35            return Err(std::io::Error::other(format!(
36                "Symlink detected at {path} — refusing to follow (TOCTOU protection)"
37            )));
38        }
39    }
40    std::fs::read_to_string(path)
41}
42
43/// Reads a file as lossy UTF-8, rejecting binary files.
44/// Uses O_NOFOLLOW on Unix to prevent TOCTOU symlink attacks.
45pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
46    if crate::core::binary_detect::is_binary_file(path) {
47        let msg = crate::core::binary_detect::binary_file_message(path);
48        return Err(std::io::Error::other(msg));
49    }
50    read_file_nofollow(path).map(strip_utf8_bom)
51}
52
53/// A UTF-8 BOM is an encoding artifact, not content — leaking it corrupts the
54/// first line of every downstream view (limitations doc #11). Shared by both
55/// file readers (this module's and `tools::ctx_read::read_file_lossy`).
56pub(crate) fn strip_utf8_bom(s: String) -> String {
57    match s.strip_prefix('\u{feff}') {
58        Some(rest) => rest.to_owned(),
59        None => s,
60    }
61}
62
63/// Result of a file read with secret scanning applied.
64pub struct ScannedRead {
65    pub content: String,
66    pub secret_matches: Vec<secret_detection::SecretMatch>,
67    pub was_redacted: bool,
68}
69
70/// Reads a file and applies secret detection/redaction per config.
71///
72/// - `enabled=true, redact=false`: returns original content + warnings in `secret_matches`
73/// - `enabled=true, redact=true`: returns redacted content + `was_redacted=true`
74/// - `enabled=false`: returns original content, no scanning
75pub fn read_file_scanned(path: &str) -> Result<ScannedRead, std::io::Error> {
76    let raw = read_file_lossy(path)?;
77    let cfg = crate::core::config::Config::load();
78    let sd = &cfg.secret_detection;
79
80    if !sd.enabled {
81        return Ok(ScannedRead {
82            content: raw,
83            secret_matches: Vec::new(),
84            was_redacted: false,
85        });
86    }
87
88    let (content, matches) = secret_detection::scan_and_redact(&raw, sd);
89
90    if !matches.is_empty() {
91        let role_name = roles::active_role_name();
92        let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
93        let mut unique: Vec<&str> = names;
94        unique.sort_unstable();
95        unique.dedup();
96        let msg = format!(
97            "[SECRET DETECTION] {} secret(s) found in {}: {}",
98            matches.len(),
99            path,
100            unique.join(", ")
101        );
102        events::emit_policy_violation(&role_name, "read_file", &msg);
103        tracing::warn!("{msg}");
104    }
105
106    let was_redacted = sd.redact && !matches.is_empty();
107    Ok(ScannedRead {
108        content,
109        secret_matches: matches,
110        was_redacted,
111    })
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum BoundaryMode {
116    Warn,
117    Enforce,
118}
119
120impl BoundaryMode {
121    fn parse(s: &str) -> Self {
122        match s.trim().to_lowercase().as_str() {
123            "enforce" | "strict" => Self::Enforce,
124            _ => Self::Warn,
125        }
126    }
127}
128
129pub fn boundary_mode_effective(role: &roles::Role) -> BoundaryMode {
130    if let Ok(v) = std::env::var("LEAN_CTX_IO_BOUNDARY_MODE")
131        && !v.trim().is_empty()
132    {
133        return BoundaryMode::parse(&v);
134    }
135    BoundaryMode::parse(&role.io.boundary_mode)
136}
137
138pub fn is_secret_like(path: &Path) -> Option<&'static str> {
139    let file = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
140    let lower = file.to_lowercase();
141
142    // Directory-level sensitive roots
143    for comp in path.components() {
144        if let std::path::Component::Normal(s) = comp {
145            let c = s.to_string_lossy().to_lowercase();
146            if c == ".ssh" {
147                return Some(".ssh directory");
148            }
149            if c == ".aws" {
150                return Some(".aws directory");
151            }
152            if c == ".gnupg" {
153                return Some(".gnupg directory");
154            }
155        }
156    }
157
158    // Common secret-like files (deny-by-default unless explicitly allowed).
159    if lower == ".env" {
160        return Some(".env file");
161    }
162    if lower.starts_with(".env.") {
163        let allow_suffixes = [".example", ".sample", ".template", ".dist", ".defaults"];
164        if allow_suffixes.iter().any(|s| lower.ends_with(s)) {
165            return None;
166        }
167        return Some(".env.* file");
168    }
169
170    if matches!(
171        lower.as_str(),
172        "id_rsa"
173            | "id_ed25519"
174            | "id_ecdsa"
175            | "id_dsa"
176            | "authorized_keys"
177            | "known_hosts"
178            | ".npmrc"
179            | ".netrc"
180            | ".pypirc"
181            | ".dockerconfigjson"
182            | "credentials.json"
183            | "secrets.json"
184            | "secrets.yaml"
185            | "secrets.yml"
186            | "keystore.jks"
187            | "truststore.jks"
188            | ".htpasswd"
189            | "shadow"
190            | "master.key"
191    ) {
192        return Some("credential file");
193    }
194
195    if lower.starts_with("service-account") {
196        let p = std::path::Path::new(&lower);
197        if p.extension()
198            .is_some_and(|ext| ext.eq_ignore_ascii_case("json") || ext.eq_ignore_ascii_case("key"))
199        {
200            return Some("service account key");
201        }
202    }
203
204    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
205    let secret_exts = ["pem", "key", "p12", "pfx", "kdbx"];
206    if secret_exts.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
207        return Some("secret key material");
208    }
209
210    // AWS credentials file (often inside .aws/)
211    if lower == "credentials" && path.to_string_lossy().to_lowercase().contains("/.aws/") {
212        return Some("aws credentials");
213    }
214
215    None
216}
217
218pub fn check_secret_path_for_tool(tool: &str, path: &Path) -> Result<Option<String>, String> {
219    let role_name = roles::active_role_name();
220    let role = roles::active_role();
221    let mode = boundary_mode_effective(&role);
222
223    let Some(reason) = is_secret_like(path) else {
224        return Ok(None);
225    };
226
227    if role.io.allow_secret_paths {
228        return Ok(None);
229    }
230
231    let msg = format!(
232        "[I/O BOUNDARY] Secret-like path detected ({reason}): {}.\n\
233Role: {role_name}. To allow: switch role to 'admin' or set io.allow_secret_paths=true in the active role.",
234        path.display()
235    );
236    events::emit_policy_violation(&role_name, tool, &msg);
237
238    match mode {
239        BoundaryMode::Enforce => Err(format!("ERROR: {msg}")),
240        BoundaryMode::Warn => {
241            if crate::core::protocol::meta_visible() {
242                Ok(Some(format!("[BOUNDARY WARNING] {msg}")))
243            } else {
244                Ok(None)
245            }
246        }
247    }
248}
249
250pub fn jail_and_check_path(
251    tool: &str,
252    candidate: &Path,
253    jail_root: &Path,
254) -> Result<(PathBuf, Option<String>), String> {
255    let role_name = roles::active_role_name();
256    let jailed = pathjail::jail_path(candidate, jail_root).map_err(|e| {
257        // Only a real jail escape is a security event. A path that simply doesn't exist
258        // (stale graph entry, removed file) is benign — emitting a policy violation for it
259        // spams the event feed and mislabels missing files as denials.
260        if !matches!(
261            e,
262            crate::core::error::PathJailError::NoExistingAncestor { .. }
263        ) {
264            let msg = format!("pathjail denied: {} ({e})", candidate.display());
265            events::emit_policy_violation(&role_name, tool, &msg);
266        }
267        e.to_string()
268    })?;
269    let warning = check_secret_path_for_tool(tool, &jailed)?;
270    Ok((jailed, warning))
271}
272
273pub fn ensure_ignore_gitignore_allowed(tool: &str) -> Result<(), String> {
274    let role_name = roles::active_role_name();
275    let role = roles::active_role();
276    if role.io.allow_ignore_gitignore {
277        return Ok(());
278    }
279    let msg = format!(
280        "[I/O BOUNDARY] ignore_gitignore requires explicit policy.\n\
281Role '{role_name}' does not allow scanning .gitignore'd paths. \
282An agent cannot escalate to a privileged role at runtime, so configure this where lean-ctx starts:\n\
283- set LEAN_CTX_ROLE=admin, or\n\
284- add `io.allow_ignore_gitignore = true` to a role file (~/.lean-ctx/roles/<name>.toml), then select it via LEAN_CTX_ROLE.\n\
285Docs: https://leanctx.com/docs/security/#ignore-gitignore"
286    );
287    events::emit_policy_violation(&role_name, tool, &msg);
288    Err(format!("ERROR: {msg}"))
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[cfg(unix)]
296    #[test]
297    fn nofollow_rejects_symlink() {
298        let dir = tempfile::tempdir().unwrap();
299        let real = dir.path().join("real.txt");
300        std::fs::write(&real, "secret").unwrap();
301        let link = dir.path().join("link.txt");
302        std::os::unix::fs::symlink(&real, &link).unwrap();
303        let result = read_file_nofollow(&link.to_string_lossy());
304        assert!(result.is_err());
305    }
306
307    #[test]
308    fn nofollow_reads_regular_file() {
309        let dir = tempfile::tempdir().unwrap();
310        let file = dir.path().join("regular.txt");
311        std::fs::write(&file, "hello").unwrap();
312        let content = read_file_nofollow(&file.to_string_lossy()).unwrap();
313        assert_eq!(content, "hello");
314    }
315
316    #[test]
317    fn env_is_secret_like() {
318        assert_eq!(is_secret_like(Path::new(".env")), Some(".env file"));
319        assert_eq!(is_secret_like(Path::new(".env.local")), Some(".env.* file"));
320        assert_eq!(is_secret_like(Path::new(".env.example")), None);
321    }
322
323    #[test]
324    fn key_is_secret_like() {
325        assert_eq!(
326            is_secret_like(Path::new("key.pem")),
327            Some("secret key material")
328        );
329        assert_eq!(
330            is_secret_like(Path::new("cert.KEY")),
331            Some("secret key material")
332        );
333    }
334
335    #[test]
336    fn credentials_json_is_secret_like() {
337        assert_eq!(
338            is_secret_like(Path::new("credentials.json")),
339            Some("credential file")
340        );
341        assert_eq!(
342            is_secret_like(Path::new("secrets.yaml")),
343            Some("credential file")
344        );
345    }
346
347    #[test]
348    fn service_account_is_secret_like() {
349        assert_eq!(
350            is_secret_like(Path::new("service-account.json")),
351            Some("service account key")
352        );
353        assert_eq!(
354            is_secret_like(Path::new("service-account-prod.key")),
355            Some("service account key")
356        );
357    }
358
359    #[test]
360    fn htpasswd_and_shadow_are_secret_like() {
361        assert_eq!(
362            is_secret_like(Path::new(".htpasswd")),
363            Some("credential file")
364        );
365        assert_eq!(is_secret_like(Path::new("shadow")), Some("credential file"));
366    }
367
368    // The CLI full-read path (`cli_cache::check_and_read`) reads through THIS
369    // `read_file_lossy`, not the ctx_read one — both must strip the UTF-8 BOM
370    // or the CLI leaks it while the MCP path doesn't (limitations doc #11).
371    #[test]
372    fn read_file_lossy_strips_utf8_bom() {
373        let p = std::env::temp_dir().join("lean_ctx_io_bom_test.txt");
374        std::fs::write(&p, b"\xEF\xBB\xBFhello\n").unwrap();
375        let s = read_file_lossy(p.to_str().unwrap()).unwrap();
376        let _ = std::fs::remove_file(&p);
377        assert!(!s.starts_with('\u{feff}'), "BOM must be stripped");
378        assert!(s.starts_with("hello"));
379    }
380}