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))]
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)
51}
52
53pub struct ScannedRead {
55 pub content: String,
56 pub secret_matches: Vec<secret_detection::SecretMatch>,
57 pub was_redacted: bool,
58}
59
60pub fn read_file_scanned(path: &str) -> Result<ScannedRead, std::io::Error> {
66 let raw = read_file_lossy(path)?;
67 let cfg = crate::core::config::Config::load();
68 let sd = &cfg.secret_detection;
69
70 if !sd.enabled {
71 return Ok(ScannedRead {
72 content: raw,
73 secret_matches: Vec::new(),
74 was_redacted: false,
75 });
76 }
77
78 let (content, matches) = secret_detection::scan_and_redact(&raw, sd);
79
80 if !matches.is_empty() {
81 let role_name = roles::active_role_name();
82 let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
83 let mut unique: Vec<&str> = names;
84 unique.sort_unstable();
85 unique.dedup();
86 let msg = format!(
87 "[SECRET DETECTION] {} secret(s) found in {}: {}",
88 matches.len(),
89 path,
90 unique.join(", ")
91 );
92 events::emit_policy_violation(&role_name, "read_file", &msg);
93 tracing::warn!("{msg}");
94 }
95
96 let was_redacted = sd.redact && !matches.is_empty();
97 Ok(ScannedRead {
98 content,
99 secret_matches: matches,
100 was_redacted,
101 })
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub enum BoundaryMode {
106 Warn,
107 Enforce,
108}
109
110impl BoundaryMode {
111 fn parse(s: &str) -> Self {
112 match s.trim().to_lowercase().as_str() {
113 "enforce" | "strict" => Self::Enforce,
114 _ => Self::Warn,
115 }
116 }
117}
118
119pub fn boundary_mode_effective(role: &roles::Role) -> BoundaryMode {
120 if let Ok(v) = std::env::var("LEAN_CTX_IO_BOUNDARY_MODE")
121 && !v.trim().is_empty()
122 {
123 return BoundaryMode::parse(&v);
124 }
125 BoundaryMode::parse(&role.io.boundary_mode)
126}
127
128pub fn is_secret_like(path: &Path) -> Option<&'static str> {
129 let file = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
130 let lower = file.to_lowercase();
131
132 for comp in path.components() {
134 if let std::path::Component::Normal(s) = comp {
135 let c = s.to_string_lossy().to_lowercase();
136 if c == ".ssh" {
137 return Some(".ssh directory");
138 }
139 if c == ".aws" {
140 return Some(".aws directory");
141 }
142 if c == ".gnupg" {
143 return Some(".gnupg directory");
144 }
145 }
146 }
147
148 if lower == ".env" {
150 return Some(".env file");
151 }
152 if lower.starts_with(".env.") {
153 let allow_suffixes = [".example", ".sample", ".template", ".dist", ".defaults"];
154 if allow_suffixes.iter().any(|s| lower.ends_with(s)) {
155 return None;
156 }
157 return Some(".env.* file");
158 }
159
160 if matches!(
161 lower.as_str(),
162 "id_rsa"
163 | "id_ed25519"
164 | "id_ecdsa"
165 | "id_dsa"
166 | "authorized_keys"
167 | "known_hosts"
168 | ".npmrc"
169 | ".netrc"
170 | ".pypirc"
171 | ".dockerconfigjson"
172 | "credentials.json"
173 | "secrets.json"
174 | "secrets.yaml"
175 | "secrets.yml"
176 | "keystore.jks"
177 | "truststore.jks"
178 | ".htpasswd"
179 | "shadow"
180 | "master.key"
181 ) {
182 return Some("credential file");
183 }
184
185 if lower.starts_with("service-account") {
186 let p = std::path::Path::new(&lower);
187 if p.extension()
188 .is_some_and(|ext| ext.eq_ignore_ascii_case("json") || ext.eq_ignore_ascii_case("key"))
189 {
190 return Some("service account key");
191 }
192 }
193
194 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
195 let secret_exts = ["pem", "key", "p12", "pfx", "kdbx"];
196 if secret_exts.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
197 return Some("secret key material");
198 }
199
200 if lower == "credentials" && path.to_string_lossy().to_lowercase().contains("/.aws/") {
202 return Some("aws credentials");
203 }
204
205 None
206}
207
208pub fn check_secret_path_for_tool(tool: &str, path: &Path) -> Result<Option<String>, String> {
209 let role_name = roles::active_role_name();
210 let role = roles::active_role();
211 let mode = boundary_mode_effective(&role);
212
213 let Some(reason) = is_secret_like(path) else {
214 return Ok(None);
215 };
216
217 if role.io.allow_secret_paths {
218 return Ok(None);
219 }
220
221 let msg = format!(
222 "[I/O BOUNDARY] Secret-like path detected ({reason}): {}.\n\
223Role: {role_name}. To allow: switch role to 'admin' or set io.allow_secret_paths=true in the active role.",
224 path.display()
225 );
226 events::emit_policy_violation(&role_name, tool, &msg);
227
228 match mode {
229 BoundaryMode::Enforce => Err(format!("ERROR: {msg}")),
230 BoundaryMode::Warn => {
231 if crate::core::protocol::meta_visible() {
232 Ok(Some(format!("[BOUNDARY WARNING] {msg}")))
233 } else {
234 Ok(None)
235 }
236 }
237 }
238}
239
240pub fn jail_and_check_path(
241 tool: &str,
242 candidate: &Path,
243 jail_root: &Path,
244) -> Result<(PathBuf, Option<String>), String> {
245 let role_name = roles::active_role_name();
246 let jailed = pathjail::jail_path(candidate, jail_root).map_err(|e| {
247 if !e.starts_with("path does not exist") {
251 let msg = format!("pathjail denied: {} ({e})", candidate.display());
252 events::emit_policy_violation(&role_name, tool, &msg);
253 }
254 e
255 })?;
256 let warning = check_secret_path_for_tool(tool, &jailed)?;
257 Ok((jailed, warning))
258}
259
260pub fn ensure_ignore_gitignore_allowed(tool: &str) -> Result<(), String> {
261 let role_name = roles::active_role_name();
262 let role = roles::active_role();
263 if role.io.allow_ignore_gitignore {
264 return Ok(());
265 }
266 let msg = format!(
267 "[I/O BOUNDARY] ignore_gitignore requires explicit policy.\n\
268Role '{role_name}' does not allow scanning .gitignore'd paths. Switch to role 'admin' or set io.allow_ignore_gitignore=true."
269 );
270 events::emit_policy_violation(&role_name, tool, &msg);
271 Err(format!("ERROR: {msg}"))
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[cfg(unix)]
279 #[test]
280 fn nofollow_rejects_symlink() {
281 let dir = tempfile::tempdir().unwrap();
282 let real = dir.path().join("real.txt");
283 std::fs::write(&real, "secret").unwrap();
284 let link = dir.path().join("link.txt");
285 std::os::unix::fs::symlink(&real, &link).unwrap();
286 let result = read_file_nofollow(&link.to_string_lossy());
287 assert!(result.is_err());
288 }
289
290 #[test]
291 fn nofollow_reads_regular_file() {
292 let dir = tempfile::tempdir().unwrap();
293 let file = dir.path().join("regular.txt");
294 std::fs::write(&file, "hello").unwrap();
295 let content = read_file_nofollow(&file.to_string_lossy()).unwrap();
296 assert_eq!(content, "hello");
297 }
298
299 #[test]
300 fn env_is_secret_like() {
301 assert_eq!(is_secret_like(Path::new(".env")), Some(".env file"));
302 assert_eq!(is_secret_like(Path::new(".env.local")), Some(".env.* file"));
303 assert_eq!(is_secret_like(Path::new(".env.example")), None);
304 }
305
306 #[test]
307 fn key_is_secret_like() {
308 assert_eq!(
309 is_secret_like(Path::new("key.pem")),
310 Some("secret key material")
311 );
312 assert_eq!(
313 is_secret_like(Path::new("cert.KEY")),
314 Some("secret key material")
315 );
316 }
317
318 #[test]
319 fn credentials_json_is_secret_like() {
320 assert_eq!(
321 is_secret_like(Path::new("credentials.json")),
322 Some("credential file")
323 );
324 assert_eq!(
325 is_secret_like(Path::new("secrets.yaml")),
326 Some("credential file")
327 );
328 }
329
330 #[test]
331 fn service_account_is_secret_like() {
332 assert_eq!(
333 is_secret_like(Path::new("service-account.json")),
334 Some("service account key")
335 );
336 assert_eq!(
337 is_secret_like(Path::new("service-account-prod.key")),
338 Some("service account key")
339 );
340 }
341
342 #[test]
343 fn htpasswd_and_shadow_are_secret_like() {
344 assert_eq!(
345 is_secret_like(Path::new(".htpasswd")),
346 Some("credential file")
347 );
348 assert_eq!(is_secret_like(Path::new("shadow")), Some("credential file"));
349 }
350}