lean_ctx/core/
io_boundary.rs1use std::path::{Path, PathBuf};
2
3use crate::core::{events, pathjail, roles, secret_detection};
4
5#[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
33pub 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
43pub struct ScannedRead {
45 pub content: String,
46 pub secret_matches: Vec<secret_detection::SecretMatch>,
47 pub was_redacted: bool,
48}
49
50pub 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 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 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 | "id_ecdsa"
155 | "id_dsa"
156 | "authorized_keys"
157 | "known_hosts"
158 | ".npmrc"
159 | ".netrc"
160 | ".pypirc"
161 | ".dockerconfigjson"
162 | "credentials.json"
163 | "secrets.json"
164 | "secrets.yaml"
165 | "secrets.yml"
166 | "keystore.jks"
167 | "truststore.jks"
168 | ".htpasswd"
169 | "shadow"
170 | "master.key"
171 ) {
172 return Some("credential file");
173 }
174
175 if lower.starts_with("service-account") {
176 let p = std::path::Path::new(&lower);
177 if p.extension()
178 .is_some_and(|ext| ext.eq_ignore_ascii_case("json") || ext.eq_ignore_ascii_case("key"))
179 {
180 return Some("service account key");
181 }
182 }
183
184 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
185 let secret_exts = ["pem", "key", "p12", "pfx", "kdbx"];
186 if secret_exts.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
187 return Some("secret key material");
188 }
189
190 if lower == "credentials" && path.to_string_lossy().to_lowercase().contains("/.aws/") {
192 return Some("aws credentials");
193 }
194
195 None
196}
197
198pub fn check_secret_path_for_tool(tool: &str, path: &Path) -> Result<Option<String>, String> {
199 let role_name = roles::active_role_name();
200 let role = roles::active_role();
201 let mode = boundary_mode_effective(&role);
202
203 let Some(reason) = is_secret_like(path) else {
204 return Ok(None);
205 };
206
207 if role.io.allow_secret_paths {
208 return Ok(None);
209 }
210
211 let msg = format!(
212 "[I/O BOUNDARY] Secret-like path detected ({reason}): {}.\n\
213Role: {role_name}. To allow: switch role to 'admin' or set io.allow_secret_paths=true in the active role.",
214 path.display()
215 );
216 events::emit_policy_violation(&role_name, tool, &msg);
217
218 match mode {
219 BoundaryMode::Enforce => Err(format!("ERROR: {msg}")),
220 BoundaryMode::Warn => {
221 if crate::core::protocol::meta_visible() {
222 Ok(Some(format!("[BOUNDARY WARNING] {msg}")))
223 } else {
224 Ok(None)
225 }
226 }
227 }
228}
229
230pub fn jail_and_check_path(
231 tool: &str,
232 candidate: &Path,
233 jail_root: &Path,
234) -> Result<(PathBuf, Option<String>), String> {
235 let role_name = roles::active_role_name();
236 let jailed = pathjail::jail_path(candidate, jail_root).map_err(|e| {
237 if !e.starts_with("path does not exist") {
241 let msg = format!("pathjail denied: {} ({e})", candidate.display());
242 events::emit_policy_violation(&role_name, tool, &msg);
243 }
244 e
245 })?;
246 let warning = check_secret_path_for_tool(tool, &jailed)?;
247 Ok((jailed, warning))
248}
249
250pub fn ensure_ignore_gitignore_allowed(tool: &str) -> Result<(), String> {
251 let role_name = roles::active_role_name();
252 let role = roles::active_role();
253 if role.io.allow_ignore_gitignore {
254 return Ok(());
255 }
256 let msg = format!(
257 "[I/O BOUNDARY] ignore_gitignore requires explicit policy.\n\
258Role '{role_name}' does not allow scanning .gitignore'd paths. Switch to role 'admin' or set io.allow_ignore_gitignore=true."
259 );
260 events::emit_policy_violation(&role_name, tool, &msg);
261 Err(format!("ERROR: {msg}"))
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[cfg(unix)]
269 #[test]
270 fn nofollow_rejects_symlink() {
271 let dir = tempfile::tempdir().unwrap();
272 let real = dir.path().join("real.txt");
273 std::fs::write(&real, "secret").unwrap();
274 let link = dir.path().join("link.txt");
275 std::os::unix::fs::symlink(&real, &link).unwrap();
276 let result = read_file_nofollow(&link.to_string_lossy());
277 assert!(result.is_err());
278 }
279
280 #[test]
281 fn nofollow_reads_regular_file() {
282 let dir = tempfile::tempdir().unwrap();
283 let file = dir.path().join("regular.txt");
284 std::fs::write(&file, "hello").unwrap();
285 let content = read_file_nofollow(&file.to_string_lossy()).unwrap();
286 assert_eq!(content, "hello");
287 }
288
289 #[test]
290 fn env_is_secret_like() {
291 assert_eq!(is_secret_like(Path::new(".env")), Some(".env file"));
292 assert_eq!(is_secret_like(Path::new(".env.local")), Some(".env.* file"));
293 assert_eq!(is_secret_like(Path::new(".env.example")), None);
294 }
295
296 #[test]
297 fn key_is_secret_like() {
298 assert_eq!(
299 is_secret_like(Path::new("key.pem")),
300 Some("secret key material")
301 );
302 assert_eq!(
303 is_secret_like(Path::new("cert.KEY")),
304 Some("secret key material")
305 );
306 }
307
308 #[test]
309 fn credentials_json_is_secret_like() {
310 assert_eq!(
311 is_secret_like(Path::new("credentials.json")),
312 Some("credential file")
313 );
314 assert_eq!(
315 is_secret_like(Path::new("secrets.yaml")),
316 Some("credential file")
317 );
318 }
319
320 #[test]
321 fn service_account_is_secret_like() {
322 assert_eq!(
323 is_secret_like(Path::new("service-account.json")),
324 Some("service account key")
325 );
326 assert_eq!(
327 is_secret_like(Path::new("service-account-prod.key")),
328 Some("service account key")
329 );
330 }
331
332 #[test]
333 fn htpasswd_and_shadow_are_secret_like() {
334 assert_eq!(
335 is_secret_like(Path::new(".htpasswd")),
336 Some("credential file")
337 );
338 assert_eq!(is_secret_like(Path::new("shadow")), Some("credential file"));
339 }
340}