Skip to main content

lean_ctx/core/
io_boundary.rs

1use std::path::{Path, PathBuf};
2
3use crate::core::{events, pathjail, roles};
4
5/// Reads a file as lossy UTF-8, rejecting binary files.
6/// Moved here from tools::ctx_read to break reverse-dependency.
7pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
8    if crate::core::binary_detect::is_binary_file(path) {
9        let msg = crate::core::binary_detect::binary_file_message(path);
10        return Err(std::io::Error::other(msg));
11    }
12    let bytes = std::fs::read(path)?;
13    Ok(String::from_utf8_lossy(&bytes).into_owned())
14}
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum BoundaryMode {
18    Warn,
19    Enforce,
20}
21
22impl BoundaryMode {
23    fn parse(s: &str) -> Self {
24        match s.trim().to_lowercase().as_str() {
25            "enforce" | "strict" => Self::Enforce,
26            _ => Self::Warn,
27        }
28    }
29}
30
31pub fn boundary_mode_effective(role: &roles::Role) -> BoundaryMode {
32    if let Ok(v) = std::env::var("LEAN_CTX_IO_BOUNDARY_MODE") {
33        if !v.trim().is_empty() {
34            return BoundaryMode::parse(&v);
35        }
36    }
37    BoundaryMode::parse(&role.io.boundary_mode)
38}
39
40pub fn is_secret_like(path: &Path) -> Option<&'static str> {
41    let file = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
42    let lower = file.to_lowercase();
43
44    // Directory-level sensitive roots
45    for comp in path.components() {
46        if let std::path::Component::Normal(s) = comp {
47            let c = s.to_string_lossy().to_lowercase();
48            if c == ".ssh" {
49                return Some(".ssh directory");
50            }
51            if c == ".aws" {
52                return Some(".aws directory");
53            }
54            if c == ".gnupg" {
55                return Some(".gnupg directory");
56            }
57        }
58    }
59
60    // Common secret-like files (deny-by-default unless explicitly allowed).
61    if lower == ".env" {
62        return Some(".env file");
63    }
64    if lower.starts_with(".env.") {
65        let allow_suffixes = [".example", ".sample", ".template", ".dist", ".defaults"];
66        if allow_suffixes.iter().any(|s| lower.ends_with(s)) {
67            return None;
68        }
69        return Some(".env.* file");
70    }
71
72    if matches!(
73        lower.as_str(),
74        "id_rsa"
75            | "id_ed25519"
76            | "authorized_keys"
77            | "known_hosts"
78            | ".npmrc"
79            | ".netrc"
80            | ".pypirc"
81            | ".dockerconfigjson"
82    ) {
83        return Some("credential file");
84    }
85
86    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
87    let secret_exts = ["pem", "key", "p12", "pfx", "kdbx"];
88    if secret_exts.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
89        return Some("secret key material");
90    }
91
92    // AWS credentials file (often inside .aws/)
93    if lower == "credentials" && path.to_string_lossy().to_lowercase().contains("/.aws/") {
94        return Some("aws credentials");
95    }
96
97    None
98}
99
100pub fn check_secret_path_for_tool(tool: &str, path: &Path) -> Result<Option<String>, String> {
101    let role_name = roles::active_role_name();
102    let role = roles::active_role();
103    let mode = boundary_mode_effective(&role);
104
105    let Some(reason) = is_secret_like(path) else {
106        return Ok(None);
107    };
108
109    if role.io.allow_secret_paths {
110        return Ok(None);
111    }
112
113    let msg = format!(
114        "[I/O BOUNDARY] Secret-like path detected ({reason}): {}.\n\
115Role: {role_name}. To allow: switch role to 'admin' or set io.allow_secret_paths=true in the active role.",
116        path.display()
117    );
118    events::emit_policy_violation(&role_name, tool, &msg);
119
120    match mode {
121        BoundaryMode::Enforce => Err(format!("ERROR: {msg}")),
122        BoundaryMode::Warn => Ok(Some(format!("[BOUNDARY WARNING] {msg}"))),
123    }
124}
125
126pub fn jail_and_check_path(
127    tool: &str,
128    candidate: &Path,
129    jail_root: &Path,
130) -> Result<(PathBuf, Option<String>), String> {
131    let role_name = roles::active_role_name();
132    let jailed = pathjail::jail_path(candidate, jail_root).map_err(|e| {
133        let msg = format!("pathjail denied: {} ({e})", candidate.display());
134        events::emit_policy_violation(&role_name, tool, &msg);
135        e
136    })?;
137    let warning = check_secret_path_for_tool(tool, &jailed)?;
138    Ok((jailed, warning))
139}
140
141pub fn ensure_ignore_gitignore_allowed(tool: &str) -> Result<(), String> {
142    let role_name = roles::active_role_name();
143    let role = roles::active_role();
144    if role.io.allow_ignore_gitignore {
145        return Ok(());
146    }
147    let msg = format!(
148        "[I/O BOUNDARY] ignore_gitignore requires explicit policy.\n\
149Role '{role_name}' does not allow scanning .gitignore'd paths. Switch to role 'admin' or set io.allow_ignore_gitignore=true."
150    );
151    events::emit_policy_violation(&role_name, tool, &msg);
152    Err(format!("ERROR: {msg}"))
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn env_is_secret_like() {
161        assert_eq!(is_secret_like(Path::new(".env")), Some(".env file"));
162        assert_eq!(is_secret_like(Path::new(".env.local")), Some(".env.* file"));
163        assert_eq!(is_secret_like(Path::new(".env.example")), None);
164    }
165
166    #[test]
167    fn key_is_secret_like() {
168        assert_eq!(
169            is_secret_like(Path::new("key.pem")),
170            Some("secret key material")
171        );
172        assert_eq!(
173            is_secret_like(Path::new("cert.KEY")),
174            Some("secret key material")
175        );
176    }
177}