1use std::path::{Path, PathBuf};
36
37const PROTECTED_HOME_DIRS: &[&str] = &["Library", "AppData", "snap"];
41
42tokio::task_local! {
43 static REQUEST_WRITE_ROOTS: Vec<PathBuf>;
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum PathJailScope {
52 Home,
54 #[default]
56 Projects,
57 Project,
59}
60
61impl PathJailScope {
62 pub fn parse(value: &str) -> Option<Self> {
64 match value.trim().to_ascii_lowercase().as_str() {
65 "home" | "user" => Some(Self::Home),
66 "projects" | "workspace" => Some(Self::Projects),
67 "project" | "strict" | "root" => Some(Self::Project),
68 _ => None,
69 }
70 }
71
72 pub fn as_str(self) -> &'static str {
73 match self {
74 Self::Home => "home",
75 Self::Projects => "projects",
76 Self::Project => "project",
77 }
78 }
79
80 pub fn resolve() -> Self {
84 if crate::core::workspace_authority::is_configured() {
85 return Self::Project;
86 }
87 Self::explicit().unwrap_or_default()
88 }
89
90 fn explicit() -> Option<Self> {
92 if let Ok(raw) = std::env::var("LEAN_CTX_PATH_JAIL_SCOPE")
93 && let Some(scope) = Self::parse(&raw)
94 {
95 return Some(scope);
96 }
97 crate::core::config::Config::load()
98 .path_jail_scope
99 .as_deref()
100 .and_then(Self::parse)
101 }
102}
103
104fn zone_home() -> Option<PathBuf> {
107 let home = super::pathjail::canonicalize_secure(&dirs::home_dir()?);
108 home.parent().is_some().then_some(home)
109}
110
111fn scope_home() -> Option<PathBuf> {
117 let home = zone_home()?;
118 let depth = home
119 .components()
120 .filter(|c| matches!(c, std::path::Component::Normal(_)))
121 .count();
122 if depth < 2 {
123 return None;
124 }
125 #[cfg(unix)]
126 {
127 use std::os::unix::fs::MetadataExt;
128 if std::fs::metadata(&home).ok()?.uid() != unsafe { libc::geteuid() } {
130 return None;
131 }
132 }
133 Some(home)
134}
135
136fn first_component_below<'a>(path: &'a Path, home: &Path) -> Option<&'a std::ffi::OsStr> {
138 path.strip_prefix(home)
139 .ok()?
140 .components()
141 .next()
142 .map(std::path::Component::as_os_str)
143}
144
145fn protected_zone_in(path: &Path, home: &Path) -> Option<PathBuf> {
148 let first = first_component_below(path, home)?;
149 let name = first.to_string_lossy();
150 let protected = name.starts_with('.')
152 || name.to_ascii_lowercase().starts_with("ntuser.")
153 || PROTECTED_HOME_DIRS
154 .iter()
155 .any(|d| name.eq_ignore_ascii_case(d));
156 protected.then(|| home.join(first))
157}
158
159pub(crate) fn home_scope_admits(base: &Path) -> bool {
169 let scope = PathJailScope::resolve();
170 if scope == PathJailScope::Project {
171 return false;
172 }
173 let Some(home) = scope_home() else {
174 return false;
175 };
176 admits_below(base, &home)
177 && (scope == PathJailScope::Home || project_containing(base, &home).is_some())
178}
179
180fn admits_below(base: &Path, home: &Path) -> bool {
181 first_component_below(base, home).is_some() && protected_zone_in(base, home).is_none()
182}
183
184fn project_containing(path: &Path, home: &Path) -> Option<PathBuf> {
189 let mut cur = if path.is_dir() { path } else { path.parent()? };
190 while cur != home && cur.starts_with(home) {
191 if crate::core::pathutil::has_project_marker(cur) {
192 return Some(cur.to_path_buf());
193 }
194 cur = cur.parent()?;
195 }
196 None
197}
198
199pub(crate) fn in_protected_zone(path: &Path) -> bool {
202 zone_home().is_some_and(|home| protected_zone_in(path, &home).is_some())
203}
204
205pub(crate) fn protected_zone_permits(base: &Path, root: &Path, allow: &[PathBuf]) -> bool {
212 let Some(zone) = zone_home().and_then(|home| protected_zone_in(base, &home)) else {
213 return true;
214 };
215 (root.starts_with(&zone) && base.starts_with(root))
216 || allow
217 .iter()
218 .any(|p| p.starts_with(&zone) && base.starts_with(p))
219}
220
221fn write_root(root: &str) -> Option<PathBuf> {
223 if root.trim().is_empty() {
224 return None;
225 }
226 let path = super::pathjail::canonicalize_secure(Path::new(root));
227 (!crate::core::pathutil::is_broad_or_unsafe_root(&path)).then_some(path)
228}
229
230pub fn with_request_write_roots<T>(roots: &[String], operation: impl FnOnce() -> T) -> T {
232 let roots = roots.iter().filter_map(|root| write_root(root)).collect();
233 REQUEST_WRITE_ROOTS.sync_scope(roots, operation)
234}
235
236pub(crate) fn home_scope_write_denial(target: &Path) -> Option<String> {
243 if PathJailScope::resolve() != PathJailScope::Projects {
244 return None;
245 }
246 let home = scope_home()?;
247 if !admits_below(target, &home) {
248 return None;
249 }
250 let mut writable: Vec<PathBuf> = REQUEST_WRITE_ROOTS
255 .try_with(Clone::clone)
256 .ok()
257 .or_else(|| {
258 crate::core::policy::runtime::REQUEST_PROJECT
259 .try_with(|slot| slot.borrow().clone())
260 .ok()
261 .map(|project| {
262 project
263 .and_then(|root| write_root(&root.to_string_lossy()))
264 .into_iter()
265 .collect()
266 })
267 })
268 .unwrap_or_else(|| {
269 std::env::current_dir()
270 .ok()
271 .and_then(|cwd| write_root(&cwd.to_string_lossy()))
272 .into_iter()
273 .collect()
274 });
275 writable.extend(super::pathjail::allow_paths_from_env_and_config());
276 if let Ok(state) = crate::core::paths::state_dir() {
277 writable.push(super::pathjail::canonicalize_secure(&state));
278 }
279 if writable.iter().any(|w| target.starts_with(w)) {
280 return None;
281 }
282 Some(format!(
283 "{} is outside the active project — path_jail_scope = projects opens other \
284 projects for reading only. To allow writes there, the user runs in their terminal: \
285 lean-ctx allow-path {} — or `lean-ctx config set path_jail_scope home` for all of ~ \
286 — takes effect immediately, no restart.",
287 target.display(),
288 suggested_allow_dir(target, Some(&home)).display()
289 ))
290}
291
292fn suggested_allow_dir(path: &Path, home: Option<&Path>) -> PathBuf {
297 let start = if path.is_dir() {
298 path.to_path_buf()
299 } else {
300 path.parent().unwrap_or(path).to_path_buf()
301 };
302 let mut cur = start.as_path();
303 loop {
304 if home.is_some_and(|h| cur == h) || cur.parent().is_none() {
305 break;
306 }
307 if crate::core::pathutil::has_project_marker(cur) {
308 return cur.to_path_buf();
309 }
310 match cur.parent() {
311 Some(p) => cur = p,
312 None => break,
313 }
314 }
315 start
316}
317
318pub(crate) fn escape_hint(candidate: &Path, base: &Path, root: &Path) -> String {
323 const TAIL: &str = " — the user runs this in their own terminal; it takes effect \
326 immediately, no restart. Until then every call on this path is \
327 refused the same way: do not retry it, ask the user or continue \
328 without it.";
329 let home = zone_home();
330
331 if home.as_deref() == Some(base) {
332 return ". Your home directory itself is not a project root — it holds ~/.ssh, \
333 ~/Library and other protected locations. Point the tool at a project \
334 below it."
335 .to_string();
336 }
337 if let Some(zone) = home.as_deref().and_then(|h| protected_zone_in(base, h)) {
338 let dir = if base.is_dir() {
340 base
341 } else {
342 base.parent().unwrap_or(base)
343 };
344 return format!(
345 ". {} is a protected location (credentials and other programs' private data \
346 stay jailed). To allow it anyway: lean-ctx allow-path {}{TAIL}",
347 zone.display(),
348 dir.display()
349 );
350 }
351
352 let suggestion = suggested_allow_dir(base, home.as_deref());
353 let under_home = scope_home().is_some_and(|h| admits_below(base, &h));
354 if under_home && PathJailScope::resolve() == PathJailScope::Projects {
356 return format!(
357 ". {} is in your home directory but not inside a project (no .git, Cargo.toml, \
358 package.json, … above it), so path_jail_scope = projects does not open it. To \
359 allow it: lean-ctx allow-path {} — or all of ~: lean-ctx config set \
360 path_jail_scope home{TAIL}",
361 candidate.display(),
362 suggestion.display()
363 );
364 }
365 if under_home {
366 return format!(
367 ". {} is outside the active project ({}). Allow your whole home directory \
368 (protected locations stay jailed): lean-ctx config set path_jail_scope home — \
369 or only this one: lean-ctx allow-path {}{TAIL}",
370 candidate.display(),
371 root.display(),
372 suggestion.display()
373 );
374 }
375 format!(
376 ". {} is outside the active project ({}) and outside your home directory. To \
377 allow it: lean-ctx allow-path {}{TAIL}",
378 candidate.display(),
379 root.display(),
380 suggestion.display()
381 )
382}
383
384fn default_notice_text() -> String {
386 "\x1b[1mPathJail now writes only in the active project:\x1b[0m agents may read every \
387 project below ~, but write only in the session's project and allowed paths; \
388 protected locations (~/.ssh, ~/.aws, ~/.config, every other dot folder, ~/Library, \
389 ~/AppData) stay closed.\n \x1b[2mPrevious behaviour (read and write all of ~): \
390 lean-ctx config set path_jail_scope home · active project only: \
391 lean-ctx config set path_jail_scope project\x1b[0m\n"
392 .to_string()
393}
394
395pub fn maybe_show_default_notice() {
399 use std::io::IsTerminal;
400 if !(std::io::stderr().is_terminal() && crate::core::host_env::stdin_is_terminal())
401 || PathJailScope::explicit().is_some()
402 || crate::core::config::Config::load().path_jail == Some(false)
403 {
404 return;
405 }
406 let Ok(marker) =
407 crate::core::paths::state_dir().map(|dir| dir.join("path_jail_projects_default_notice"))
408 else {
409 return;
410 };
411 if marker.exists() {
412 return;
413 }
414 eprint!("{}", default_notice_text());
415 eprintln!();
416 if let Some(parent) = marker.parent() {
417 let _ = std::fs::create_dir_all(parent);
418 }
419 let _ = std::fs::write(marker, "1\n");
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn parse_accepts_canonical_names_and_aliases() {
428 assert_eq!(PathJailScope::parse("home"), Some(PathJailScope::Home));
429 assert_eq!(
430 PathJailScope::parse(" Project "),
431 Some(PathJailScope::Project)
432 );
433 assert_eq!(PathJailScope::parse("strict"), Some(PathJailScope::Project));
434 assert_eq!(
435 PathJailScope::parse("projects"),
436 Some(PathJailScope::Projects)
437 );
438 assert_eq!(PathJailScope::parse("anything"), None);
439 assert_eq!(PathJailScope::default(), PathJailScope::Projects);
440 for s in [
441 PathJailScope::Home,
442 PathJailScope::Projects,
443 PathJailScope::Project,
444 ] {
445 assert_eq!(PathJailScope::parse(s.as_str()), Some(s));
446 }
447 }
448
449 #[test]
450 fn the_notice_names_the_protected_zones_and_both_ways_back() {
451 let text = default_notice_text();
452 for needle in [
453 "~/.ssh",
454 "~/.aws",
455 "~/Library",
456 "path_jail_scope home",
457 "path_jail_scope project",
458 ] {
459 assert!(text.contains(needle), "{needle}");
460 }
461 }
462
463 #[test]
464 fn home_zone_admits_code_and_protects_secrets() {
465 let home = Path::new("/home/u");
466 assert!(admits_below(Path::new("/home/u/code/repo/src/a.rs"), home));
467 assert!(admits_below(Path::new("/home/u/Documents/p"), home));
468
469 for protected in [
470 "/home/u/.ssh/id_ed25519",
471 "/home/u/.aws/credentials",
472 "/home/u/.config/gh/hosts.yml",
473 "/home/u/.zshrc",
474 "/home/u/.claude/projects/x/session.jsonl",
475 "/home/u/Library/Keychains/login.keychain-db",
476 "/home/u/AppData/Roaming/x",
477 "/home/u/NTUSER.DAT",
478 ] {
479 assert!(!admits_below(Path::new(protected), home), "{protected}");
480 assert!(protected_zone_in(Path::new(protected), home).is_some());
481 }
482 assert!(!admits_below(home, home));
484 assert!(!admits_below(Path::new("/etc/passwd"), home));
485 assert!(!admits_below(Path::new("/home/user2/x"), home));
486 }
487
488 #[test]
489 fn protected_zone_is_the_top_level_entry() {
490 let home = Path::new("/home/u");
491 assert_eq!(
492 protected_zone_in(Path::new("/home/u/.config/gh/hosts.yml"), home),
493 Some(PathBuf::from("/home/u/.config"))
494 );
495 assert_eq!(
497 protected_zone_in(Path::new("/home/u/code/repo/.git/HEAD"), home),
498 None
499 );
500 }
501
502 #[cfg(all(unix, not(feature = "no-jail")))]
507 #[test]
508 fn jail_admits_sibling_projects_but_not_protected_zones() {
509 let _iso = crate::core::data_dir::isolated_data_dir();
510 let tmp = tempfile::tempdir().unwrap();
511 let home = super::super::pathjail::canonicalize_secure(tmp.path()).join("home");
512 let (a, b, ssh) = (home.join("code/a"), home.join("code/b"), home.join(".ssh"));
513 for dir in [&a, &b, &ssh] {
514 std::fs::create_dir_all(dir).unwrap();
515 }
516 std::fs::create_dir_all(a.join(".git")).unwrap();
517 std::fs::write(b.join("Cargo.toml"), "[package]").unwrap();
518 std::fs::create_dir_all(b.join("src")).unwrap();
519 std::fs::write(b.join("src/lib.rs"), "x").unwrap();
520 std::fs::write(b.join("lib.rs"), "x").unwrap();
521 std::fs::write(ssh.join("id_ed25519"), "secret").unwrap();
522 let docs = home.join("Documents/Steuern");
523 std::fs::create_dir_all(&docs).unwrap();
524 std::fs::write(docs.join("2025.pdf"), "private").unwrap();
525 let outside = tmp.path().join("outside.txt");
526 std::fs::write(&outside, "x").unwrap();
527
528 let old_home = std::env::var_os("HOME");
529 crate::test_env::set_var("HOME", &home);
530 crate::test_env::remove_var("LEAN_CTX_PATH_JAIL_SCOPE");
531 let jail = super::super::pathjail::jail_path;
532 let write = super::super::pathjail::enforce_writable;
533
534 assert_eq!(PathJailScope::resolve(), PathJailScope::Projects);
537 assert!(jail(&b.join("lib.rs"), &a).is_ok(), "reads across projects");
538 assert!(write(&b.join(".git/hooks/pre-commit")).is_err());
539 assert!(write(&b.join("lib.rs")).is_err());
540 assert!(
541 super::super::pathjail::active_relaxations()
542 .iter()
543 .all(|r| !r.source.contains("path_jail_scope")),
544 "the default scope is not a relaxation"
545 );
546
547 crate::test_env::set_var("LEAN_CTX_PATH_JAIL_SCOPE", "home");
548 assert!(
549 super::super::pathjail::active_relaxations()
550 .iter()
551 .any(|r| r.source.contains("LEAN_CTX_PATH_JAIL_SCOPE")),
552 "the home scope is reported as a relaxation"
553 );
554 assert!(
555 jail(&b.join("lib.rs"), &a).is_ok(),
556 "sibling project under ~"
557 );
558 assert!(
559 jail(&b.join("src/lib.rs"), &a).is_ok(),
560 "nested file of a sibling project"
561 );
562 assert!(
564 jail(&docs.join("2025.pdf"), &a).is_ok(),
565 "loose file under ~"
566 );
567 assert!(jail(&docs, &a).is_ok(), "a folder without a project");
568 assert!(jail(&home.join("code"), &a).is_ok(), "parent of projects");
569 let err = jail(&ssh.join("id_ed25519"), &a).unwrap_err().to_string();
570 assert!(err.contains("protected location"), "{err}");
571 let err = jail(&outside, &a).unwrap_err().to_string();
572 assert!(err.contains("outside your home directory"), "{err}");
573 let err = jail(&home, &a).unwrap_err().to_string();
574 assert!(err.contains("not a project root"), "{err}");
575
576 assert!(
579 jail(&ssh.join("id_ed25519"), &home).is_err(),
580 "zone is a deny"
581 );
582 assert!(
583 jail(&ssh.join("id_ed25519"), &ssh).is_ok(),
584 "root inside the zone"
585 );
586
587 assert!(write(&b.join("lib.rs")).is_ok(), "another project");
590 assert!(write(&docs.join("notes.md")).is_ok(), "a loose folder");
591 assert!(
592 jail(&ssh.join("authorized_keys"), &a).is_err(),
593 "protected zone"
594 );
595
596 crate::test_env::set_var("LEAN_CTX_PATH_JAIL_SCOPE", "projects");
598 assert!(jail(&b.join("lib.rs"), &a).is_ok(), "sibling project");
599 let err = jail(&docs.join("2025.pdf"), &a).unwrap_err().to_string();
600 assert!(err.contains("not inside a project"), "{err}");
601 assert!(jail(&home.join("code"), &a).is_err(), "parent of projects");
602 let err = write(&b.join("lib.rs")).unwrap_err();
603 assert!(
604 err.contains("for reading only") && err.contains("allow-path"),
605 "{err}"
606 );
607 assert!(write(&b.join(".git/hooks/pre-commit")).is_err());
608 let as_call = |roots: &[&Path], target: &Path| {
609 let roots: Vec<String> = roots.iter().map(|r| r.to_string_lossy().into()).collect();
610 with_request_write_roots(&roots, || write(target))
611 };
612 assert!(
613 as_call(&[b.as_path()], &b.join("lib.rs")).is_ok(),
614 "session root is writable"
615 );
616 assert!(as_call(&[a.as_path()], &b.join("lib.rs")).is_err());
619
620 crate::test_env::set_var("LEAN_CTX_PATH_JAIL_SCOPE", "project");
621 let err = jail(&b.join("lib.rs"), &a).unwrap_err().to_string();
622 assert!(
623 err.contains("path_jail_scope home") && err.contains("allow-path"),
624 "{err}"
625 );
626 crate::test_env::remove_var("LEAN_CTX_PATH_JAIL_SCOPE");
627
628 crate::test_env::set_var("HOME", "/");
630 assert!(
631 jail(&b.join("lib.rs"), &a).is_err(),
632 "HOME=/ falls back to the project"
633 );
634
635 match old_home {
636 Some(h) => crate::test_env::set_var("HOME", h),
637 None => crate::test_env::remove_var("HOME"),
638 }
639 }
640
641 #[test]
642 fn suggestion_prefers_the_enclosing_project() {
643 let tmp = tempfile::tempdir().unwrap();
644 let repo = tmp.path().join("repo");
645 std::fs::create_dir_all(repo.join("src/deep")).unwrap();
646 std::fs::create_dir_all(repo.join(".git")).unwrap();
647 let file = repo.join("src/deep/a.rs");
648 std::fs::write(&file, "x").unwrap();
649 assert_eq!(suggested_allow_dir(&file, None), repo);
650
651 let loose = tmp.path().join("loose");
652 std::fs::create_dir_all(&loose).unwrap();
653 assert_eq!(suggested_allow_dir(&loose, Some(tmp.path())), loose);
654 }
655}