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 !e.starts_with("path does not exist") {
261 let msg = format!("pathjail denied: {} ({e})", candidate.display());
262 events::emit_policy_violation(&role_name, tool, &msg);
263 }
264 e
265 })?;
266 let warning = check_secret_path_for_tool(tool, &jailed)?;
267 Ok((jailed, warning))
268}
269
270pub fn ensure_ignore_gitignore_allowed(tool: &str) -> Result<(), String> {
271 let role_name = roles::active_role_name();
272 let role = roles::active_role();
273 if role.io.allow_ignore_gitignore {
274 return Ok(());
275 }
276 let msg = format!(
277 "[I/O BOUNDARY] ignore_gitignore requires explicit policy.\n\
278Role '{role_name}' does not allow scanning .gitignore'd paths. \
279An agent cannot escalate to a privileged role at runtime, so configure this where lean-ctx starts:\n\
280- set LEAN_CTX_ROLE=admin, or\n\
281- add `io.allow_ignore_gitignore = true` to a role file (~/.lean-ctx/roles/<name>.toml), then select it via LEAN_CTX_ROLE.\n\
282Docs: https://leanctx.com/docs/security/#ignore-gitignore"
283 );
284 events::emit_policy_violation(&role_name, tool, &msg);
285 Err(format!("ERROR: {msg}"))
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[cfg(unix)]
293 #[test]
294 fn nofollow_rejects_symlink() {
295 let dir = tempfile::tempdir().unwrap();
296 let real = dir.path().join("real.txt");
297 std::fs::write(&real, "secret").unwrap();
298 let link = dir.path().join("link.txt");
299 std::os::unix::fs::symlink(&real, &link).unwrap();
300 let result = read_file_nofollow(&link.to_string_lossy());
301 assert!(result.is_err());
302 }
303
304 #[test]
305 fn nofollow_reads_regular_file() {
306 let dir = tempfile::tempdir().unwrap();
307 let file = dir.path().join("regular.txt");
308 std::fs::write(&file, "hello").unwrap();
309 let content = read_file_nofollow(&file.to_string_lossy()).unwrap();
310 assert_eq!(content, "hello");
311 }
312
313 #[test]
314 fn env_is_secret_like() {
315 assert_eq!(is_secret_like(Path::new(".env")), Some(".env file"));
316 assert_eq!(is_secret_like(Path::new(".env.local")), Some(".env.* file"));
317 assert_eq!(is_secret_like(Path::new(".env.example")), None);
318 }
319
320 #[test]
321 fn key_is_secret_like() {
322 assert_eq!(
323 is_secret_like(Path::new("key.pem")),
324 Some("secret key material")
325 );
326 assert_eq!(
327 is_secret_like(Path::new("cert.KEY")),
328 Some("secret key material")
329 );
330 }
331
332 #[test]
333 fn credentials_json_is_secret_like() {
334 assert_eq!(
335 is_secret_like(Path::new("credentials.json")),
336 Some("credential file")
337 );
338 assert_eq!(
339 is_secret_like(Path::new("secrets.yaml")),
340 Some("credential file")
341 );
342 }
343
344 #[test]
345 fn service_account_is_secret_like() {
346 assert_eq!(
347 is_secret_like(Path::new("service-account.json")),
348 Some("service account key")
349 );
350 assert_eq!(
351 is_secret_like(Path::new("service-account-prod.key")),
352 Some("service account key")
353 );
354 }
355
356 #[test]
357 fn htpasswd_and_shadow_are_secret_like() {
358 assert_eq!(
359 is_secret_like(Path::new(".htpasswd")),
360 Some("credential file")
361 );
362 assert_eq!(is_secret_like(Path::new("shadow")), Some("credential file"));
363 }
364
365 #[test]
369 fn read_file_lossy_strips_utf8_bom() {
370 let p = std::env::temp_dir().join("lean_ctx_io_bom_test.txt");
371 std::fs::write(&p, b"\xEF\xBB\xBFhello\n").unwrap();
372 let s = read_file_lossy(p.to_str().unwrap()).unwrap();
373 let _ = std::fs::remove_file(&p);
374 assert!(!s.starts_with('\u{feff}'), "BOM must be stripped");
375 assert!(s.starts_with("hello"));
376 }
377}