lean_ctx/core/
io_boundary.rs1use std::path::{Path, PathBuf};
2
3use crate::core::{events, pathjail, roles};
4
5pub 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 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 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 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}