lean_ctx/core/
io_boundary.rs1use 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 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 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 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}