Skip to main content

lean_ctx/core/
io_boundary.rs

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