1use 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))]
32pub fn read_file_nofollow(path: &str) -> Result<String, std::io::Error> {
33 if let Ok(meta) = std::fs::symlink_metadata(path) {
34 if crate::core::pathutil::is_symlink_or_reparse(&meta) {
35 return Err(std::io::Error::other(format!(
36 "Symlink detected at {path} — refusing to follow (TOCTOU protection)"
37 )));
38 }
39 }
40 std::fs::read_to_string(path)
41}
42
43pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
46 if crate::core::binary_detect::is_binary_file(path) {
47 let msg = crate::core::binary_detect::binary_file_message(path);
48 return Err(std::io::Error::other(msg));
49 }
50 read_file_nofollow(path).map(strip_utf8_bom)
51}
52
53pub(crate) fn strip_utf8_bom(s: String) -> String {
57 match s.strip_prefix('\u{feff}') {
58 Some(rest) => rest.to_owned(),
59 None => s,
60 }
61}
62
63pub struct ScannedRead {
65 pub content: String,
66 pub secret_matches: Vec<secret_detection::SecretMatch>,
67 pub was_redacted: bool,
68}
69
70pub fn read_file_scanned(path: &str) -> Result<ScannedRead, std::io::Error> {
76 let raw = read_file_lossy(path)?;
77 let cfg = crate::core::config::Config::load();
78 let sd = &cfg.secret_detection;
79
80 if !sd.enabled {
81 return Ok(ScannedRead {
82 content: raw,
83 secret_matches: Vec::new(),
84 was_redacted: false,
85 });
86 }
87
88 let (content, matches) = secret_detection::scan_and_redact(&raw, sd);
89
90 if !matches.is_empty() {
91 let role_name = roles::active_role_name();
92 let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
93 let mut unique: Vec<&str> = names;
94 unique.sort_unstable();
95 unique.dedup();
96 let msg = format!(
97 "[SECRET DETECTION] {} secret(s) found in {}: {}",
98 matches.len(),
99 path,
100 unique.join(", ")
101 );
102 events::emit_policy_violation(&role_name, "read_file", &msg);
103 tracing::warn!("{msg}");
104 }
105
106 let was_redacted = sd.redact && !matches.is_empty();
107 Ok(ScannedRead {
108 content,
109 secret_matches: matches,
110 was_redacted,
111 })
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum BoundaryMode {
116 Warn,
117 Enforce,
118}
119
120impl BoundaryMode {
121 fn parse(s: &str) -> Self {
122 match s.trim().to_lowercase().as_str() {
123 "enforce" | "strict" => Self::Enforce,
124 _ => Self::Warn,
125 }
126 }
127}
128
129pub fn boundary_mode_effective(role: &roles::Role) -> BoundaryMode {
130 if let Ok(v) = std::env::var("LEAN_CTX_IO_BOUNDARY_MODE")
131 && !v.trim().is_empty()
132 {
133 return BoundaryMode::parse(&v);
134 }
135 BoundaryMode::parse(&role.io.boundary_mode)
136}
137
138pub fn is_secret_like(path: &Path) -> Option<&'static str> {
139 let file = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
140 let lower = file.to_lowercase();
141
142 for comp in path.components() {
144 if let std::path::Component::Normal(s) = comp {
145 let c = s.to_string_lossy().to_lowercase();
146 if c == ".ssh" {
147 return Some(".ssh directory");
148 }
149 if c == ".aws" {
150 return Some(".aws directory");
151 }
152 if c == ".gnupg" {
153 return Some(".gnupg directory");
154 }
155 }
156 }
157
158 if lower == ".env" {
160 return Some(".env file");
161 }
162 if lower.starts_with(".env.") {
163 let allow_suffixes = [".example", ".sample", ".template", ".dist", ".defaults"];
164 if allow_suffixes.iter().any(|s| lower.ends_with(s)) {
165 return None;
166 }
167 return Some(".env.* file");
168 }
169
170 if matches!(
171 lower.as_str(),
172 "id_rsa"
173 | "id_ed25519"
174 | "id_ecdsa"
175 | "id_dsa"
176 | "authorized_keys"
177 | "known_hosts"
178 | ".npmrc"
179 | ".netrc"
180 | ".pypirc"
181 | ".dockerconfigjson"
182 | "credentials.json"
183 | "secrets.json"
184 | "secrets.yaml"
185 | "secrets.yml"
186 | "keystore.jks"
187 | "truststore.jks"
188 | ".htpasswd"
189 | "shadow"
190 | "master.key"
191 ) {
192 return Some("credential file");
193 }
194
195 if lower.starts_with("service-account") {
196 let p = std::path::Path::new(&lower);
197 if p.extension()
198 .is_some_and(|ext| ext.eq_ignore_ascii_case("json") || ext.eq_ignore_ascii_case("key"))
199 {
200 return Some("service account key");
201 }
202 }
203
204 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
205 let secret_exts = ["pem", "key", "p12", "pfx", "kdbx"];
206 if secret_exts.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
207 return Some("secret key material");
208 }
209
210 if lower == "credentials" && path.to_string_lossy().to_lowercase().contains("/.aws/") {
212 return Some("aws credentials");
213 }
214
215 None
216}
217
218pub fn check_secret_path_for_tool(tool: &str, path: &Path) -> Result<Option<String>, String> {
219 let role_name = roles::active_role_name();
220 let role = roles::active_role();
221 let mode = boundary_mode_effective(&role);
222
223 let Some(reason) = is_secret_like(path) else {
224 return Ok(None);
225 };
226
227 if role.io.allow_secret_paths {
228 return Ok(None);
229 }
230
231 let msg = format!(
232 "[I/O BOUNDARY] Secret-like path detected ({reason}): {}.\n\
233Role: {role_name}. To allow: switch role to 'admin' or set io.allow_secret_paths=true in the active role.",
234 path.display()
235 );
236 events::emit_policy_violation(&role_name, tool, &msg);
237
238 match mode {
239 BoundaryMode::Enforce => Err(format!("ERROR: {msg}")),
240 BoundaryMode::Warn => {
241 if crate::core::protocol::meta_visible() {
242 Ok(Some(format!("[BOUNDARY WARNING] {msg}")))
243 } else {
244 Ok(None)
245 }
246 }
247 }
248}
249
250pub fn jail_and_check_path(
251 tool: &str,
252 candidate: &Path,
253 jail_root: &Path,
254) -> Result<(PathBuf, Option<String>), String> {
255 let role_name = roles::active_role_name();
256 let jailed = pathjail::jail_path(candidate, jail_root).map_err(|e| {
257 if !matches!(
261 e,
262 crate::core::error::PathJailError::NoExistingAncestor { .. }
263 ) {
264 let msg = format!("pathjail denied: {} ({e})", candidate.display());
265 events::emit_policy_violation(&role_name, tool, &msg);
266 }
267 e.to_string()
268 })?;
269 let warning = check_secret_path_for_tool(tool, &jailed)?;
270 Ok((jailed, warning))
271}
272
273pub fn ensure_ignore_gitignore_allowed(tool: &str) -> Result<(), String> {
274 let role_name = roles::active_role_name();
275 let role = roles::active_role();
276 if role.io.allow_ignore_gitignore {
277 return Ok(());
278 }
279 let msg = format!(
280 "[I/O BOUNDARY] ignore_gitignore requires explicit policy.\n\
281Role '{role_name}' does not allow scanning .gitignore'd paths. \
282An agent cannot escalate to a privileged role at runtime, so configure this where lean-ctx starts:\n\
283- set LEAN_CTX_ROLE=admin, or\n\
284- add `io.allow_ignore_gitignore = true` to a role file (~/.lean-ctx/roles/<name>.toml), then select it via LEAN_CTX_ROLE.\n\
285Docs: https://leanctx.com/docs/security/#ignore-gitignore"
286 );
287 events::emit_policy_violation(&role_name, tool, &msg);
288 Err(format!("ERROR: {msg}"))
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[cfg(unix)]
296 #[test]
297 fn nofollow_rejects_symlink() {
298 let dir = tempfile::tempdir().unwrap();
299 let real = dir.path().join("real.txt");
300 std::fs::write(&real, "secret").unwrap();
301 let link = dir.path().join("link.txt");
302 std::os::unix::fs::symlink(&real, &link).unwrap();
303 let result = read_file_nofollow(&link.to_string_lossy());
304 assert!(result.is_err());
305 }
306
307 #[test]
308 fn nofollow_reads_regular_file() {
309 let dir = tempfile::tempdir().unwrap();
310 let file = dir.path().join("regular.txt");
311 std::fs::write(&file, "hello").unwrap();
312 let content = read_file_nofollow(&file.to_string_lossy()).unwrap();
313 assert_eq!(content, "hello");
314 }
315
316 #[test]
317 fn env_is_secret_like() {
318 assert_eq!(is_secret_like(Path::new(".env")), Some(".env file"));
319 assert_eq!(is_secret_like(Path::new(".env.local")), Some(".env.* file"));
320 assert_eq!(is_secret_like(Path::new(".env.example")), None);
321 }
322
323 #[test]
324 fn key_is_secret_like() {
325 assert_eq!(
326 is_secret_like(Path::new("key.pem")),
327 Some("secret key material")
328 );
329 assert_eq!(
330 is_secret_like(Path::new("cert.KEY")),
331 Some("secret key material")
332 );
333 }
334
335 #[test]
336 fn credentials_json_is_secret_like() {
337 assert_eq!(
338 is_secret_like(Path::new("credentials.json")),
339 Some("credential file")
340 );
341 assert_eq!(
342 is_secret_like(Path::new("secrets.yaml")),
343 Some("credential file")
344 );
345 }
346
347 #[test]
348 fn service_account_is_secret_like() {
349 assert_eq!(
350 is_secret_like(Path::new("service-account.json")),
351 Some("service account key")
352 );
353 assert_eq!(
354 is_secret_like(Path::new("service-account-prod.key")),
355 Some("service account key")
356 );
357 }
358
359 #[test]
360 fn htpasswd_and_shadow_are_secret_like() {
361 assert_eq!(
362 is_secret_like(Path::new(".htpasswd")),
363 Some("credential file")
364 );
365 assert_eq!(is_secret_like(Path::new("shadow")), Some("credential file"));
366 }
367
368 #[test]
372 fn read_file_lossy_strips_utf8_bom() {
373 let p = std::env::temp_dir().join("lean_ctx_io_bom_test.txt");
374 std::fs::write(&p, b"\xEF\xBB\xBFhello\n").unwrap();
375 let s = read_file_lossy(p.to_str().unwrap()).unwrap();
376 let _ = std::fs::remove_file(&p);
377 assert!(!s.starts_with('\u{feff}'), "BOM must be stripped");
378 assert!(s.starts_with("hello"));
379 }
380}