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#[cfg(not(unix))]
29pub fn read_file_nofollow(path: &str) -> Result<String, std::io::Error> {
30    std::fs::read_to_string(path)
31}
32
33/// Reads a file as lossy UTF-8, rejecting binary files.
34/// Uses O_NOFOLLOW on Unix to prevent TOCTOU symlink attacks.
35pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
36    if crate::core::binary_detect::is_binary_file(path) {
37        let msg = crate::core::binary_detect::binary_file_message(path);
38        return Err(std::io::Error::other(msg));
39    }
40    read_file_nofollow(path)
41}
42
43/// Result of a file read with secret scanning applied.
44pub struct ScannedRead {
45    pub content: String,
46    pub secret_matches: Vec<secret_detection::SecretMatch>,
47    pub was_redacted: bool,
48}
49
50/// Reads a file and applies secret detection/redaction per config.
51///
52/// - `enabled=true, redact=false`: returns original content + warnings in `secret_matches`
53/// - `enabled=true, redact=true`: returns redacted content + `was_redacted=true`
54/// - `enabled=false`: returns original content, no scanning
55pub fn read_file_scanned(path: &str) -> Result<ScannedRead, std::io::Error> {
56    let raw = read_file_lossy(path)?;
57    let cfg = crate::core::config::Config::load();
58    let sd = &cfg.secret_detection;
59
60    if !sd.enabled {
61        return Ok(ScannedRead {
62            content: raw,
63            secret_matches: Vec::new(),
64            was_redacted: false,
65        });
66    }
67
68    let (content, matches) = secret_detection::scan_and_redact(&raw, sd);
69
70    if !matches.is_empty() {
71        let role_name = roles::active_role_name();
72        let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
73        let mut unique: Vec<&str> = names;
74        unique.sort_unstable();
75        unique.dedup();
76        let msg = format!(
77            "[SECRET DETECTION] {} secret(s) found in {}: {}",
78            matches.len(),
79            path,
80            unique.join(", ")
81        );
82        events::emit_policy_violation(&role_name, "read_file", &msg);
83        tracing::warn!("{msg}");
84    }
85
86    let was_redacted = sd.redact && !matches.is_empty();
87    Ok(ScannedRead {
88        content,
89        secret_matches: matches,
90        was_redacted,
91    })
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum BoundaryMode {
96    Warn,
97    Enforce,
98}
99
100impl BoundaryMode {
101    fn parse(s: &str) -> Self {
102        match s.trim().to_lowercase().as_str() {
103            "enforce" | "strict" => Self::Enforce,
104            _ => Self::Warn,
105        }
106    }
107}
108
109pub fn boundary_mode_effective(role: &roles::Role) -> BoundaryMode {
110    if let Ok(v) = std::env::var("LEAN_CTX_IO_BOUNDARY_MODE") {
111        if !v.trim().is_empty() {
112            return BoundaryMode::parse(&v);
113        }
114    }
115    BoundaryMode::parse(&role.io.boundary_mode)
116}
117
118pub fn is_secret_like(path: &Path) -> Option<&'static str> {
119    let file = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
120    let lower = file.to_lowercase();
121
122    // Directory-level sensitive roots
123    for comp in path.components() {
124        if let std::path::Component::Normal(s) = comp {
125            let c = s.to_string_lossy().to_lowercase();
126            if c == ".ssh" {
127                return Some(".ssh directory");
128            }
129            if c == ".aws" {
130                return Some(".aws directory");
131            }
132            if c == ".gnupg" {
133                return Some(".gnupg directory");
134            }
135        }
136    }
137
138    // Common secret-like files (deny-by-default unless explicitly allowed).
139    if lower == ".env" {
140        return Some(".env file");
141    }
142    if lower.starts_with(".env.") {
143        let allow_suffixes = [".example", ".sample", ".template", ".dist", ".defaults"];
144        if allow_suffixes.iter().any(|s| lower.ends_with(s)) {
145            return None;
146        }
147        return Some(".env.* file");
148    }
149
150    if matches!(
151        lower.as_str(),
152        "id_rsa"
153            | "id_ed25519"
154            | "authorized_keys"
155            | "known_hosts"
156            | ".npmrc"
157            | ".netrc"
158            | ".pypirc"
159            | ".dockerconfigjson"
160    ) {
161        return Some("credential file");
162    }
163
164    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
165    let secret_exts = ["pem", "key", "p12", "pfx", "kdbx"];
166    if secret_exts.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
167        return Some("secret key material");
168    }
169
170    // AWS credentials file (often inside .aws/)
171    if lower == "credentials" && path.to_string_lossy().to_lowercase().contains("/.aws/") {
172        return Some("aws credentials");
173    }
174
175    None
176}
177
178pub fn check_secret_path_for_tool(tool: &str, path: &Path) -> Result<Option<String>, String> {
179    let role_name = roles::active_role_name();
180    let role = roles::active_role();
181    let mode = boundary_mode_effective(&role);
182
183    let Some(reason) = is_secret_like(path) else {
184        return Ok(None);
185    };
186
187    if role.io.allow_secret_paths {
188        return Ok(None);
189    }
190
191    let msg = format!(
192        "[I/O BOUNDARY] Secret-like path detected ({reason}): {}.\n\
193Role: {role_name}. To allow: switch role to 'admin' or set io.allow_secret_paths=true in the active role.",
194        path.display()
195    );
196    events::emit_policy_violation(&role_name, tool, &msg);
197
198    match mode {
199        BoundaryMode::Enforce => Err(format!("ERROR: {msg}")),
200        BoundaryMode::Warn => {
201            if crate::core::protocol::meta_visible() {
202                Ok(Some(format!("[BOUNDARY WARNING] {msg}")))
203            } else {
204                Ok(None)
205            }
206        }
207    }
208}
209
210pub fn jail_and_check_path(
211    tool: &str,
212    candidate: &Path,
213    jail_root: &Path,
214) -> Result<(PathBuf, Option<String>), String> {
215    let role_name = roles::active_role_name();
216    let jailed = pathjail::jail_path(candidate, jail_root).map_err(|e| {
217        let msg = format!("pathjail denied: {} ({e})", candidate.display());
218        events::emit_policy_violation(&role_name, tool, &msg);
219        e
220    })?;
221    let warning = check_secret_path_for_tool(tool, &jailed)?;
222    Ok((jailed, warning))
223}
224
225pub fn ensure_ignore_gitignore_allowed(tool: &str) -> Result<(), String> {
226    let role_name = roles::active_role_name();
227    let role = roles::active_role();
228    if role.io.allow_ignore_gitignore {
229        return Ok(());
230    }
231    let msg = format!(
232        "[I/O BOUNDARY] ignore_gitignore requires explicit policy.\n\
233Role '{role_name}' does not allow scanning .gitignore'd paths. Switch to role 'admin' or set io.allow_ignore_gitignore=true."
234    );
235    events::emit_policy_violation(&role_name, tool, &msg);
236    Err(format!("ERROR: {msg}"))
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[cfg(unix)]
244    #[test]
245    fn nofollow_rejects_symlink() {
246        let dir = tempfile::tempdir().unwrap();
247        let real = dir.path().join("real.txt");
248        std::fs::write(&real, "secret").unwrap();
249        let link = dir.path().join("link.txt");
250        std::os::unix::fs::symlink(&real, &link).unwrap();
251        let result = read_file_nofollow(&link.to_string_lossy());
252        assert!(result.is_err());
253    }
254
255    #[test]
256    fn nofollow_reads_regular_file() {
257        let dir = tempfile::tempdir().unwrap();
258        let file = dir.path().join("regular.txt");
259        std::fs::write(&file, "hello").unwrap();
260        let content = read_file_nofollow(&file.to_string_lossy()).unwrap();
261        assert_eq!(content, "hello");
262    }
263
264    #[test]
265    fn env_is_secret_like() {
266        assert_eq!(is_secret_like(Path::new(".env")), Some(".env file"));
267        assert_eq!(is_secret_like(Path::new(".env.local")), Some(".env.* file"));
268        assert_eq!(is_secret_like(Path::new(".env.example")), None);
269    }
270
271    #[test]
272    fn key_is_secret_like() {
273        assert_eq!(
274            is_secret_like(Path::new("key.pem")),
275            Some("secret key material")
276        );
277        assert_eq!(
278            is_secret_like(Path::new("cert.KEY")),
279            Some("secret key material")
280        );
281    }
282}