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)
51}
52
53/// Result of a file read with secret scanning applied.
54pub struct ScannedRead {
55    pub content: String,
56    pub secret_matches: Vec<secret_detection::SecretMatch>,
57    pub was_redacted: bool,
58}
59
60/// Reads a file and applies secret detection/redaction per config.
61///
62/// - `enabled=true, redact=false`: returns original content + warnings in `secret_matches`
63/// - `enabled=true, redact=true`: returns redacted content + `was_redacted=true`
64/// - `enabled=false`: returns original content, no scanning
65pub fn read_file_scanned(path: &str) -> Result<ScannedRead, std::io::Error> {
66    let raw = read_file_lossy(path)?;
67    let cfg = crate::core::config::Config::load();
68    let sd = &cfg.secret_detection;
69
70    if !sd.enabled {
71        return Ok(ScannedRead {
72            content: raw,
73            secret_matches: Vec::new(),
74            was_redacted: false,
75        });
76    }
77
78    let (content, matches) = secret_detection::scan_and_redact(&raw, sd);
79
80    if !matches.is_empty() {
81        let role_name = roles::active_role_name();
82        let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
83        let mut unique: Vec<&str> = names;
84        unique.sort_unstable();
85        unique.dedup();
86        let msg = format!(
87            "[SECRET DETECTION] {} secret(s) found in {}: {}",
88            matches.len(),
89            path,
90            unique.join(", ")
91        );
92        events::emit_policy_violation(&role_name, "read_file", &msg);
93        tracing::warn!("{msg}");
94    }
95
96    let was_redacted = sd.redact && !matches.is_empty();
97    Ok(ScannedRead {
98        content,
99        secret_matches: matches,
100        was_redacted,
101    })
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub enum BoundaryMode {
106    Warn,
107    Enforce,
108}
109
110impl BoundaryMode {
111    fn parse(s: &str) -> Self {
112        match s.trim().to_lowercase().as_str() {
113            "enforce" | "strict" => Self::Enforce,
114            _ => Self::Warn,
115        }
116    }
117}
118
119pub fn boundary_mode_effective(role: &roles::Role) -> BoundaryMode {
120    if let Ok(v) = std::env::var("LEAN_CTX_IO_BOUNDARY_MODE")
121        && !v.trim().is_empty()
122    {
123        return BoundaryMode::parse(&v);
124    }
125    BoundaryMode::parse(&role.io.boundary_mode)
126}
127
128pub fn is_secret_like(path: &Path) -> Option<&'static str> {
129    let file = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
130    let lower = file.to_lowercase();
131
132    // Directory-level sensitive roots
133    for comp in path.components() {
134        if let std::path::Component::Normal(s) = comp {
135            let c = s.to_string_lossy().to_lowercase();
136            if c == ".ssh" {
137                return Some(".ssh directory");
138            }
139            if c == ".aws" {
140                return Some(".aws directory");
141            }
142            if c == ".gnupg" {
143                return Some(".gnupg directory");
144            }
145        }
146    }
147
148    // Common secret-like files (deny-by-default unless explicitly allowed).
149    if lower == ".env" {
150        return Some(".env file");
151    }
152    if lower.starts_with(".env.") {
153        let allow_suffixes = [".example", ".sample", ".template", ".dist", ".defaults"];
154        if allow_suffixes.iter().any(|s| lower.ends_with(s)) {
155            return None;
156        }
157        return Some(".env.* file");
158    }
159
160    if matches!(
161        lower.as_str(),
162        "id_rsa"
163            | "id_ed25519"
164            | "id_ecdsa"
165            | "id_dsa"
166            | "authorized_keys"
167            | "known_hosts"
168            | ".npmrc"
169            | ".netrc"
170            | ".pypirc"
171            | ".dockerconfigjson"
172            | "credentials.json"
173            | "secrets.json"
174            | "secrets.yaml"
175            | "secrets.yml"
176            | "keystore.jks"
177            | "truststore.jks"
178            | ".htpasswd"
179            | "shadow"
180            | "master.key"
181    ) {
182        return Some("credential file");
183    }
184
185    if lower.starts_with("service-account") {
186        let p = std::path::Path::new(&lower);
187        if p.extension()
188            .is_some_and(|ext| ext.eq_ignore_ascii_case("json") || ext.eq_ignore_ascii_case("key"))
189        {
190            return Some("service account key");
191        }
192    }
193
194    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
195    let secret_exts = ["pem", "key", "p12", "pfx", "kdbx"];
196    if secret_exts.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
197        return Some("secret key material");
198    }
199
200    // AWS credentials file (often inside .aws/)
201    if lower == "credentials" && path.to_string_lossy().to_lowercase().contains("/.aws/") {
202        return Some("aws credentials");
203    }
204
205    None
206}
207
208pub fn check_secret_path_for_tool(tool: &str, path: &Path) -> Result<Option<String>, String> {
209    let role_name = roles::active_role_name();
210    let role = roles::active_role();
211    let mode = boundary_mode_effective(&role);
212
213    let Some(reason) = is_secret_like(path) else {
214        return Ok(None);
215    };
216
217    if role.io.allow_secret_paths {
218        return Ok(None);
219    }
220
221    let msg = format!(
222        "[I/O BOUNDARY] Secret-like path detected ({reason}): {}.\n\
223Role: {role_name}. To allow: switch role to 'admin' or set io.allow_secret_paths=true in the active role.",
224        path.display()
225    );
226    events::emit_policy_violation(&role_name, tool, &msg);
227
228    match mode {
229        BoundaryMode::Enforce => Err(format!("ERROR: {msg}")),
230        BoundaryMode::Warn => {
231            if crate::core::protocol::meta_visible() {
232                Ok(Some(format!("[BOUNDARY WARNING] {msg}")))
233            } else {
234                Ok(None)
235            }
236        }
237    }
238}
239
240pub fn jail_and_check_path(
241    tool: &str,
242    candidate: &Path,
243    jail_root: &Path,
244) -> Result<(PathBuf, Option<String>), String> {
245    let role_name = roles::active_role_name();
246    let jailed = pathjail::jail_path(candidate, jail_root).map_err(|e| {
247        // Only a real jail escape is a security event. A path that simply doesn't exist
248        // (stale graph entry, removed file) is benign — emitting a policy violation for it
249        // spams the event feed and mislabels missing files as denials.
250        if !e.starts_with("path does not exist") {
251            let msg = format!("pathjail denied: {} ({e})", candidate.display());
252            events::emit_policy_violation(&role_name, tool, &msg);
253        }
254        e
255    })?;
256    let warning = check_secret_path_for_tool(tool, &jailed)?;
257    Ok((jailed, warning))
258}
259
260pub fn ensure_ignore_gitignore_allowed(tool: &str) -> Result<(), String> {
261    let role_name = roles::active_role_name();
262    let role = roles::active_role();
263    if role.io.allow_ignore_gitignore {
264        return Ok(());
265    }
266    let msg = format!(
267        "[I/O BOUNDARY] ignore_gitignore requires explicit policy.\n\
268Role '{role_name}' does not allow scanning .gitignore'd paths. Switch to role 'admin' or set io.allow_ignore_gitignore=true."
269    );
270    events::emit_policy_violation(&role_name, tool, &msg);
271    Err(format!("ERROR: {msg}"))
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[cfg(unix)]
279    #[test]
280    fn nofollow_rejects_symlink() {
281        let dir = tempfile::tempdir().unwrap();
282        let real = dir.path().join("real.txt");
283        std::fs::write(&real, "secret").unwrap();
284        let link = dir.path().join("link.txt");
285        std::os::unix::fs::symlink(&real, &link).unwrap();
286        let result = read_file_nofollow(&link.to_string_lossy());
287        assert!(result.is_err());
288    }
289
290    #[test]
291    fn nofollow_reads_regular_file() {
292        let dir = tempfile::tempdir().unwrap();
293        let file = dir.path().join("regular.txt");
294        std::fs::write(&file, "hello").unwrap();
295        let content = read_file_nofollow(&file.to_string_lossy()).unwrap();
296        assert_eq!(content, "hello");
297    }
298
299    #[test]
300    fn env_is_secret_like() {
301        assert_eq!(is_secret_like(Path::new(".env")), Some(".env file"));
302        assert_eq!(is_secret_like(Path::new(".env.local")), Some(".env.* file"));
303        assert_eq!(is_secret_like(Path::new(".env.example")), None);
304    }
305
306    #[test]
307    fn key_is_secret_like() {
308        assert_eq!(
309            is_secret_like(Path::new("key.pem")),
310            Some("secret key material")
311        );
312        assert_eq!(
313            is_secret_like(Path::new("cert.KEY")),
314            Some("secret key material")
315        );
316    }
317
318    #[test]
319    fn credentials_json_is_secret_like() {
320        assert_eq!(
321            is_secret_like(Path::new("credentials.json")),
322            Some("credential file")
323        );
324        assert_eq!(
325            is_secret_like(Path::new("secrets.yaml")),
326            Some("credential file")
327        );
328    }
329
330    #[test]
331    fn service_account_is_secret_like() {
332        assert_eq!(
333            is_secret_like(Path::new("service-account.json")),
334            Some("service account key")
335        );
336        assert_eq!(
337            is_secret_like(Path::new("service-account-prod.key")),
338            Some("service account key")
339        );
340    }
341
342    #[test]
343    fn htpasswd_and_shadow_are_secret_like() {
344        assert_eq!(
345            is_secret_like(Path::new(".htpasswd")),
346            Some("credential file")
347        );
348        assert_eq!(is_secret_like(Path::new("shadow")), Some("credential file"));
349    }
350}