1use std::path::{Path, PathBuf};
2
3use crate::core::error::PathJailError;
4
5pub use crate::core::pathjail_write::{rebuild_below, write_below};
6
7pub fn expand_user_path(raw: &str) -> PathBuf {
15 let mut s = raw.to_string();
16
17 if (s == "~" || s.starts_with("~/"))
18 && let Some(home) = dirs::home_dir()
19 {
20 s = format!("{}{}", home.to_string_lossy(), &s[1..]);
21 }
22
23 while let Some(start) = s.find('$') {
24 let rest = &s[start + 1..];
25 let (name, token_len) = if let Some(stripped) = rest.strip_prefix('{') {
26 match stripped.find('}') {
27 Some(end) => (stripped[..end].to_string(), end + 3),
28 None => break,
29 }
30 } else {
31 let end = rest
32 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
33 .unwrap_or(rest.len());
34 (rest[..end].to_string(), end + 1)
35 };
36 if name.is_empty() {
37 break;
38 }
39 if let Ok(val) = std::env::var(&name) {
40 s.replace_range(start..start + token_len, &val);
41 } else if name == "HOME"
42 && let Some(home) = dirs::home_dir()
43 {
44 let home = home.to_string_lossy();
45 s.replace_range(start..start + token_len, home.as_ref());
46 } else {
47 tracing::warn!(
48 "allow_paths/extra_roots entry '{raw}' references unset variable ${name} — entry will never match"
49 );
50 break;
51 }
52 }
53
54 PathBuf::from(s)
55}
56
57pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
58 let mut out = Vec::new();
59 let cfg = crate::core::config::Config::load();
60
61 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
73 out.push(canonicalize_secure(&data_dir));
74 }
75
76 if let Some(home) = dirs::home_dir() {
77 let ide_dirs_allowed = cfg.allow_ide_config_dirs.unwrap_or(false)
78 || std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
79 out.extend(home_allow_dirs(&home, ide_dirs_allowed));
80 }
81
82 for p in &cfg.allow_paths {
83 out.push(canonicalize_secure(&expand_user_path(p)));
84 }
85 for p in &cfg.extra_roots {
86 out.push(canonicalize_secure(&expand_user_path(p)));
87 }
88
89 for path in crate::core::runtime_flags::allow_paths() {
90 out.push(canonicalize_secure(&path));
91 }
92 let v = std::env::var("LCTX_ALLOW_PATH")
95 .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
96 .unwrap_or_default();
97 if !v.trim().is_empty() {
98 for p in std::env::split_paths(&v) {
99 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
100 }
101 }
102
103 let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
104 if !extra.trim().is_empty() {
105 for p in std::env::split_paths(&extra) {
106 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
107 }
108 }
109
110 out.extend(canonicalized_roots(
115 &cfg.read_only_roots,
116 "LEAN_CTX_READ_ONLY_ROOTS",
117 ));
118
119 out
120}
121
122fn canonicalized_roots(config_entries: &[String], env_var: &str) -> Vec<PathBuf> {
127 let mut out = Vec::new();
128 for p in config_entries {
129 out.push(canonicalize_secure(&expand_user_path(p)));
130 }
131 let v = std::env::var(env_var).unwrap_or_default();
132 if !v.trim().is_empty() {
133 for p in std::env::split_paths(&v) {
134 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
135 }
136 }
137 out
138}
139
140pub fn read_only_roots_from_env_and_config() -> Vec<PathBuf> {
145 let cfg = crate::core::config::Config::load();
146 let mut roots = canonicalized_roots(&cfg.read_only_roots, "LEAN_CTX_READ_ONLY_ROOTS");
147 roots.extend(session_read_only_roots());
152 roots
153}
154
155static SESSION_READ_ONLY_ROOTS: std::sync::OnceLock<std::sync::Mutex<Vec<PathBuf>>> =
156 std::sync::OnceLock::new();
157
158fn session_read_only_roots_cell() -> &'static std::sync::Mutex<Vec<PathBuf>> {
159 SESSION_READ_ONLY_ROOTS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
160}
161
162pub fn session_read_only_roots() -> Vec<PathBuf> {
164 session_read_only_roots_cell()
165 .lock()
166 .map(|g| g.clone())
167 .unwrap_or_default()
168}
169
170pub fn register_session_read_only_root(root: &Path) -> bool {
180 let canon = canonicalize_secure(root);
181 let mut guard = match session_read_only_roots_cell().lock() {
182 Ok(g) => g,
183 Err(poisoned) => poisoned.into_inner(),
184 };
185 if guard.iter().any(|r| r == &canon) {
186 return false;
187 }
188 guard.push(canon);
189 true
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub struct JailRelaxation {
200 pub source: &'static str,
202 pub detail: &'static str,
204}
205
206fn env_is_set(var: &str) -> bool {
207 std::env::var(var).is_ok_and(|v| !v.trim().is_empty())
208}
209
210#[must_use]
214pub fn active_relaxations() -> Vec<JailRelaxation> {
215 let mut out = Vec::new();
216
217 if cfg!(feature = "no-jail") {
218 out.push(JailRelaxation {
219 source: "no-jail (build feature)",
220 detail: "path jail compiled out — every tool path is allowed",
221 });
222 }
223
224 if crate::core::config::Config::load().path_jail == Some(false) {
225 out.push(JailRelaxation {
226 source: "path_jail = false (config.toml)",
227 detail: "path jail disabled — every tool path is allowed",
228 });
229 }
230
231 if crate::core::runtime_flags::allow_path_enabled() {
232 out.push(JailRelaxation {
233 source: "LEAN_CTX_ALLOW_PATH",
234 detail: "widens the read/write allow-list beyond the project root",
235 });
236 }
237
238 if env_is_set("LEAN_CTX_EXTRA_ROOTS") {
239 out.push(JailRelaxation {
240 source: "LEAN_CTX_EXTRA_ROOTS",
241 detail: "adds extra accessible roots beyond the project root",
242 });
243 }
244
245 let ide_env = std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
246 if ide_env
247 || crate::core::config::Config::load()
248 .allow_ide_config_dirs
249 .unwrap_or(false)
250 {
251 out.push(JailRelaxation {
252 source: if ide_env {
253 "LEAN_CTX_ALLOW_IDE_DIRS=1"
254 } else {
255 "allow_ide_config_dirs = true (config.toml)"
256 },
257 detail: "exposes ~/.cursor, ~/.claude, … (other agents' sessions/credentials) to tools",
258 });
259 }
260
261 if super::pathjail_scope::PathJailScope::resolve() == super::pathjail_scope::PathJailScope::Home
264 {
265 out.push(JailRelaxation {
266 source: if std::env::var("LEAN_CTX_PATH_JAIL_SCOPE")
267 .ok()
268 .and_then(|v| super::pathjail_scope::PathJailScope::parse(&v))
269 .is_some()
270 {
271 "LEAN_CTX_PATH_JAIL_SCOPE=home"
272 } else {
273 "path_jail_scope = home (config.toml)"
274 },
275 detail: "reads and writes anywhere below ~ outside protected zones, not only in the active project",
276 });
277 }
278
279 out
280}
281
282pub fn warn_if_relaxed() {
286 for relaxation in active_relaxations() {
287 tracing::warn!(
288 "[SECURITY] path jail relaxed via {}: {} — intended for trusted local use only",
289 relaxation.source,
290 relaxation.detail
291 );
292 }
293}
294
295pub fn is_read_only_path(candidate: &Path) -> bool {
306 let roots = read_only_roots_from_env_and_config();
307 if roots.is_empty() {
308 return false;
309 }
310 let base = canonical_target(candidate);
311 roots.iter().any(|r| is_under_prefix(&base, r))
312}
313
314pub(crate) fn canonical_target(candidate: &Path) -> PathBuf {
318 match canonicalize_existing_ancestor(candidate) {
319 Some((mut p, remainder)) => {
320 for part in remainder.iter().rev() {
321 p.push(part);
322 }
323 p
324 }
325 None => canonicalize_or_self(candidate),
326 }
327}
328
329pub fn enforce_writable(candidate: &Path) -> Result<(), String> {
338 crate::core::protected_store::enforce_protected_store_path(candidate)?;
341 if crate::core::policy::hive_floor::is_policy_path(&canonical_target(candidate)) {
343 return Err(format!(
344 "Hive policy files are written by the instance only; agents cannot change them: {}",
345 candidate.display()
346 ));
347 }
348 crate::core::session_rights::check_write(candidate)?;
351 match crate::core::workspace_authority::current() {
353 crate::core::workspace_authority::Authority::Legacy => {}
354 crate::core::workspace_authority::Authority::Unavailable(reason) => {
355 return Err(crate::core::workspace_authority::unavailable_reason(
356 &reason,
357 ));
358 }
359 crate::core::workspace_authority::Authority::Workspace(snapshot) => {
360 let target = canonical_target(candidate);
361 if snapshot.admits_write(&target) {
362 return Ok(());
363 }
364 return Err(format!(
365 "writes are denied outside the read-write roots of Workspace '{}' (rev {}): {}",
366 snapshot.id,
367 snapshot.revision,
368 candidate.display()
369 ));
370 }
371 }
372 if is_read_only_path(candidate) {
373 return Err(format!(
374 "path is inside a read-only root — writes are denied (read_only_roots): {}",
375 candidate.display()
376 ));
377 }
378 match super::pathjail_scope::home_scope_write_denial(&canonical_target(candidate)) {
379 Some(denial) => Err(denial),
380 None => Ok(()),
381 }
382}
383
384fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
395 let mut out = Vec::new();
396 if ide_dirs_allowed {
397 let targets = ide_dirs::editor_targets(home);
399 collect_ide_allow_dirs(home, &targets, &mut out);
400 }
401 out
402}
403
404fn collect_ide_allow_dirs(
413 home: &Path,
414 targets: &[crate::core::editor_registry::EditorTarget],
415 out: &mut Vec<PathBuf>,
416) {
417 let mut seen: std::collections::HashSet<PathBuf> = out.iter().cloned().collect();
418 for target in targets {
419 let candidates = [
420 target.config_path.parent().map(Path::to_path_buf),
421 Some(target.detect_path.clone()),
422 ];
423 for cand in candidates.into_iter().flatten() {
424 if cand.as_path() == home || !cand.starts_with(home) || !cand.is_dir() {
425 continue;
426 }
427 let resolved = canonicalize_secure(&cand);
428 if seen.insert(resolved.clone()) {
429 out.push(resolved);
430 }
431 }
432 }
433}
434
435fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
436 path.starts_with(prefix)
437}
438
439pub fn is_harness_auto_memory_path(path: &Path) -> bool {
453 use std::path::Component;
454 harness_project_dirs().iter().any(|projects| {
455 let Ok(rest) = path.strip_prefix(projects) else {
456 return false;
457 };
458 if rest
459 .components()
460 .any(|c| !matches!(c, Component::Normal(_)))
461 {
462 return false;
463 }
464 let mut parts = rest.components();
465 parts.next().is_some()
466 && parts
467 .next()
468 .is_some_and(|c| c.as_os_str().eq_ignore_ascii_case("memory"))
469 })
470}
471
472fn harness_project_dirs() -> Vec<PathBuf> {
475 let mut configs = Vec::new();
476 if let Some(home) = dirs::home_dir() {
477 configs.push(home.join(".claude"));
478 configs.push(home.join(".codebuddy"));
479 }
480 if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|v| !v.is_empty()) {
481 configs.push(PathBuf::from(dir));
482 }
483 let mut out = Vec::new();
484 for config in configs.into_iter().filter(|c| c.is_absolute()) {
485 let projects = config.join("projects");
486 let canonical = canonicalize_secure(&projects);
487 if canonical != projects {
488 out.push(canonical);
489 }
490 out.push(projects);
491 }
492 out
493}
494
495fn path_allowed_by_jail(base: &Path, root: &Path, allow: &[PathBuf]) -> bool {
496 if is_harness_auto_memory_path(base) {
497 return true;
498 }
499 let allowed = is_under_prefix(base, root)
500 || allow.iter().any(|p| is_under_prefix(base, p))
501 || super::pathjail_scope::home_scope_admits(base);
502 #[cfg(windows)]
503 let allowed = allowed || is_under_prefix_windows(base, root);
504 allowed && super::pathjail_scope::protected_zone_permits(base, root, allow)
505}
506
507pub fn canonicalize_or_self(path: &Path) -> PathBuf {
511 super::pathutil::safe_canonicalize_bounded(path, 2000)
512}
513
514pub(crate) fn canonicalize_secure(path: &Path) -> PathBuf {
519 super::pathutil::canonicalize_secure_bounded(path, 2000)
520}
521
522fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
523 let mut cur = path.to_path_buf();
524 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
525 loop {
526 if cur.exists() {
527 return Some((canonicalize_secure(&cur), remainder));
528 }
529 let name = cur.file_name()?.to_os_string();
530 remainder.push(name);
531 if !cur.pop() {
532 return None;
533 }
534 }
535}
536
537pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, PathJailError> {
538 jail_path_with_roots(candidate, jail_root, &[])
539}
540
541fn jail_in_workspace(
549 candidate: &Path,
550 jail_root: &Path,
551 snapshot: &crate::core::workspace_authority::Snapshot,
552) -> Result<PathBuf, PathJailError> {
553 let canonical = |path: &Path| crate::core::pathutil::canonicalize_secure(path).ok();
554 let base_root = canonical(jail_root)
555 .filter(|root| snapshot.admits(root))
556 .unwrap_or_else(|| snapshot.primary.clone());
557 let candidate = if candidate.is_absolute() {
558 candidate.to_path_buf()
559 } else {
560 base_root.join(candidate)
561 };
562 let admitted = |path: &Path| snapshot.admits(path);
563 let outside = |path: &Path| PathJailError::OutsideWorkspace {
564 path: path.to_path_buf(),
565 workspace: snapshot.id.clone(),
566 revision: snapshot.revision,
567 roots: snapshot
568 .granting_roots()
569 .map(|(root, access)| format!("{} ({})", root.display(), access.as_str()))
570 .collect::<Vec<_>>()
571 .join(", "),
572 };
573
574 let mut existing = candidate.clone();
575 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
576 while !existing.exists() {
577 let Some(name) = existing.file_name() else {
578 return Err(PathJailError::NoExistingAncestor { path: candidate });
579 };
580 remainder.push(name.to_os_string());
581 if !existing.pop() {
582 return Err(PathJailError::NoExistingAncestor { path: candidate });
583 }
584 }
585 if remainder
586 .iter()
587 .any(|part| part == std::ffi::OsStr::new("..") || part == std::ffi::OsStr::new("."))
588 {
589 return Err(outside(&candidate));
590 }
591 let base = canonical(&existing).ok_or_else(|| outside(&candidate))?;
594 if !admitted(&base) {
595 return Err(outside(&candidate));
596 }
597
598 #[cfg(windows)]
599 reject_symlink_on_windows(&candidate)?;
600
601 let mut out = base;
602 for part in remainder.iter().rev() {
603 out.push(part);
604 }
605 if out.exists() {
606 let resolved = canonical(&out).ok_or_else(|| outside(&candidate))?;
607 if !admitted(&resolved) {
608 return Err(PathJailError::PostCanonicalizeEscape {
609 path: candidate,
610 resolved,
611 });
612 }
613 }
614 crate::core::protected_store::enforce_protected_store_path(&out)
615 .map_err(|reason| PathJailError::ProtectedContext { reason })?;
616 Ok(out)
617}
618
619const LANGUAGE_CACHE_PATTERNS: &[(&str, &str, &str)] = &[
624 ("/go/pkg/mod/", "Go module cache", "~/go/pkg/mod"),
625 (
626 "/.cargo/registry/",
627 "Rust crate registry",
628 "~/.cargo/registry",
629 ),
630 (
631 "/site-packages/",
632 "Python site-packages",
633 "<venv>/lib/pythonX.Y/site-packages",
634 ),
635 ("/node_modules/", "Node modules", "<project>/node_modules"),
636 (
637 "/.m2/repository/",
638 "Maven local repository",
639 "~/.m2/repository",
640 ),
641 ("/.gradle/caches/", "Gradle cache", "~/.gradle/caches"),
642 (
643 "/.nuget/packages/",
644 "NuGet package cache",
645 "~/.nuget/packages",
646 ),
647 (
651 "/.rustup/toolchains/",
652 "Rust toolchain source",
653 "~/.rustup/toolchains",
654 ),
655 (
656 "/.cargo/git/checkouts/",
657 "Cargo git dependencies",
658 "~/.cargo/git/checkouts",
659 ),
660 (
661 "/.bun/install/cache/",
662 "Bun package cache",
663 "~/.bun/install/cache",
664 ),
665 (
666 "/.pub-cache/hosted/",
667 "Dart pub cache",
668 "~/.pub-cache/hosted",
669 ),
670 ("/.gem/ruby/", "Ruby gems", "~/.gem/ruby"),
671 (
674 "/.pyenv/versions/",
675 "pyenv Python installations",
676 "~/.pyenv/versions",
677 ),
678];
679
680const AGENT_INSTRUCTION_ROOTS: &[(&str, &str)] = &[
696 (".claude/skills", "Claude Code skills"),
697 (".claude/commands", "Claude Code commands"),
698 (".claude/agents", "Claude Code agents"),
699 (".claude/plans", "Claude Code plans"),
700 (".claude/rules", "Claude Code rules"),
701 (".claude/plugins/marketplaces", "Claude Code plugins"),
702 (".claude/plugins/cache", "Claude Code plugins"),
703 (".claude/CLAUDE.md", "Claude Code instructions"),
704 (".claude/settings.json", "Claude Code settings"),
705 (".claude/settings.local.json", "Claude Code settings"),
706 (".claude/keybindings.json", "Claude Code settings"),
707 (".codex/skills", "Codex skills"),
708 (".codex/prompts", "Codex prompts"),
709 (".codex/AGENTS.md", "Codex instructions"),
710 (".codex/config.toml", "Codex config"),
711 (".codex/hooks.json", "Codex config"),
712 (".agents/skills", "agent skills"),
713 (".cursor/rules", "Cursor rules"),
714 (".cursor/skills", "Cursor skills"),
715 (".cursor/commands", "Cursor commands"),
716 (".cursor/mcp.json", "Cursor config"),
717 (".cursor/hooks.json", "Cursor config"),
718 (".gemini/GEMINI.md", "Gemini instructions"),
719 (".gemini/commands", "Gemini commands"),
720 (".gemini/settings.json", "Gemini config"),
721 (".codebuddy/settings.json", "CodeBuddy settings"),
722 (".config/opencode/agent", "OpenCode agents"),
723 (".config/opencode/command", "OpenCode commands"),
724 (".config/opencode/AGENTS.md", "OpenCode instructions"),
725 (".config/opencode/opencode.json", "OpenCode config"),
726 (".config/opencode/opencode.jsonc", "OpenCode config"),
727 (".codeium/windsurf/mcp_config.json", "Windsurf config"),
728];
729
730const CURSOR_AGENT_OUTPUT_DIRS: &[&str] = &["agent-tools", "terminals"];
739const CURSOR_AGENT_OUTPUT_LABEL: &str = "Cursor agent output";
740
741fn cursor_workspace_slug(root: &Path) -> String {
745 let slug: String = root
746 .to_string_lossy()
747 .chars()
748 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
749 .collect();
750 slug.trim_start_matches('-').to_string()
751}
752
753fn is_cursor_agent_output_root(path: &Path) -> bool {
757 let mut back = path.components().rev().map(std::path::Component::as_os_str);
758 let (Some(dir), Some(_workspace), Some(projects), Some(cursor)) =
759 (back.next(), back.next(), back.next(), back.next())
760 else {
761 return false;
762 };
763 cursor == ".cursor"
764 && projects == "projects"
765 && CURSOR_AGENT_OUTPUT_DIRS.iter().any(|d| dir == *d)
766}
767
768fn cursor_workspace_matches(
774 admitted_root: &Path,
775 jail_root: &Path,
776 extra_roots: &[String],
777) -> bool {
778 let Some(workspace) = admitted_root.parent().and_then(Path::file_name) else {
779 return false;
780 };
781 std::iter::once(jail_root.to_path_buf())
782 .chain(extra_roots.iter().map(PathBuf::from))
783 .flat_map(|root| [canonicalize_secure(&root), root])
784 .any(|root| workspace == cursor_workspace_slug(&root).as_str())
785}
786
787fn cursor_agent_output_root(rel: &Path) -> Option<PathBuf> {
790 let mut parts = rel.components().map(std::path::Component::as_os_str);
791 if parts.next()? != ".cursor" || parts.next()? != "projects" {
792 return None;
793 }
794 let workspace = parts.next()?;
795 let dir = parts.next()?;
796 CURSOR_AGENT_OUTPUT_DIRS
797 .iter()
798 .any(|d| dir == *d)
799 .then(|| Path::new(".cursor/projects").join(workspace).join(dir))
800}
801
802fn detect_agent_instruction_root(candidate: &Path) -> Option<(&'static str, PathBuf)> {
807 let home = dirs::home_dir()?;
808 let canon_home = canonicalize_secure(&home);
809 let rel = candidate
810 .strip_prefix(&home)
811 .or_else(|_| candidate.strip_prefix(&canon_home))
812 .ok()?;
813 if rel
815 .components()
816 .any(|c| !matches!(c, std::path::Component::Normal(_)))
817 {
818 return None;
819 }
820 AGENT_INSTRUCTION_ROOTS
821 .iter()
822 .find(|(root, _)| rel.starts_with(root))
823 .map(|&(root, label)| (label, canon_home.join(root)))
824 .or_else(|| detect_host_session_output_root(rel, &canon_home))
825 .or_else(|| {
826 cursor_agent_output_root(rel)
827 .map(|root| canon_home.join(root))
828 .filter(|root| canonicalize_secure(root) == *root)
832 .map(|root| (CURSOR_AGENT_OUTPUT_LABEL, root))
833 })
834}
835
836fn detect_host_session_output_root(
848 rel: &Path,
849 canon_home: &Path,
850) -> Option<(&'static str, PathBuf)> {
851 let parts: Vec<&str> = rel
852 .components()
853 .map(|c| c.as_os_str().to_str())
854 .collect::<Option<_>>()?;
855 let (depth, label) = match parts.as_slice() {
856 [
857 ".claude",
858 "projects",
859 _,
860 _,
861 "tool-results" | "subagents",
862 ..,
863 ] => (5, "Claude Code session output"),
864 _ => return None,
865 };
866 Some((
867 label,
868 canon_home.join(parts[..depth].iter().collect::<PathBuf>()),
869 ))
870}
871
872#[cfg(unix)]
878fn detect_host_scratch_root(candidate: &Path) -> Option<(&'static str, PathBuf)> {
879 use std::os::unix::fs::MetadataExt;
880 let uid = unsafe { libc::geteuid() };
882 let name = format!("claude-{uid}");
883 let (base, rest) = canonicalize_existing_ancestor(candidate)?;
886 let candidate = rest.iter().rev().fold(base, |path, part| path.join(part));
887 [std::env::temp_dir(), PathBuf::from("/tmp")]
888 .into_iter()
889 .map(|tmp| tmp.join(&name))
890 .find_map(|dir| {
891 let meta = std::fs::symlink_metadata(&dir).ok()?;
892 let root = canonicalize_secure(&dir);
893 (meta.is_dir()
894 && meta.uid() == uid
895 && meta.mode() & 0o022 == 0
896 && candidate.starts_with(&root))
897 .then_some(("Claude Code scratchpad", root))
898 })
899}
900
901#[cfg(not(unix))]
902fn detect_host_scratch_root(_candidate: &Path) -> Option<(&'static str, PathBuf)> {
903 None
904}
905
906pub fn register_symlink_target_read_only(candidate: &Path, root: &Path) -> bool {
919 if detect_agent_instruction_root(root)
920 .is_none_or(|(label, _)| label == CURSOR_AGENT_OUTPUT_LABEL)
921 || (!candidate.starts_with(root) && !candidate.starts_with(canonicalize_secure(root)))
922 {
923 return false;
924 }
925 let Ok(target) = std::fs::canonicalize(candidate) else {
926 return false;
927 };
928 let root = canonicalize_secure(root);
929 if target.starts_with(&root) {
930 return false;
931 }
932 if let Some((_, agent_root)) = detect_agent_instruction_root(&target) {
935 return register_session_read_only_root(&agent_root);
936 }
937 let dir = if target.is_dir() {
938 target
939 } else {
940 match target.parent() {
941 Some(parent) => parent.to_path_buf(),
942 None => return false,
943 }
944 };
945 let below_home =
946 dirs::home_dir().is_some_and(|home| dir.starts_with(canonicalize_secure(&home)));
947 if !below_home
948 || crate::core::pathutil::is_broad_or_unsafe_root(&dir)
949 || super::pathjail_scope::in_protected_zone(&dir)
950 {
951 return false;
952 }
953 register_session_read_only_root(&dir)
954}
955
956pub fn jail_path_auto_admitting(
964 candidate: &Path,
965 jail_root: &Path,
966 extra_roots: &[String],
967) -> Result<PathBuf, PathJailError> {
968 let first = jail_path_with_roots(candidate, jail_root, extra_roots);
969 let Err(refused) = first else {
970 return first;
971 };
972 if crate::core::workspace_authority::is_configured() {
975 return Err(refused);
976 }
977 let Some((label, root)) = detect_language_cache_root(candidate) else {
978 return Err(refused);
979 };
980 if label == CURSOR_AGENT_OUTPUT_LABEL
983 && !cursor_workspace_matches(&root, jail_root, extra_roots)
984 {
985 return Err(refused);
986 }
987 if register_session_read_only_root(&root) {
988 tracing::info!(
989 "auto-registered {label} at {} as a read-only root",
990 root.display()
991 );
992 }
993 let retry = jail_path_with_roots(candidate, jail_root, extra_roots);
994 if retry.is_err() && register_symlink_target_read_only(candidate, &root) {
998 return jail_path_with_roots(candidate, jail_root, extra_roots);
999 }
1000 retry
1001}
1002
1003fn detected_cache_hint(candidate: &std::path::Path) -> Option<String> {
1007 let s = candidate.to_string_lossy();
1008 for &(pattern, name, example) in LANGUAGE_CACHE_PATTERNS {
1009 if s.contains(pattern) {
1010 return Some(format!(
1011 ". Detected {name} — add read_only_roots = [\"{example}\"] to \
1012 ~/.config/lean-ctx/config.toml for cached, compressed reads without write access"
1013 ));
1014 }
1015 }
1016 None
1017}
1018
1019pub fn detect_language_cache_root(candidate: &Path) -> Option<(&'static str, PathBuf)> {
1024 if let Some(found) = detect_agent_instruction_root(candidate) {
1025 return Some(found);
1026 }
1027 if let Some(found) = detect_host_scratch_root(candidate) {
1028 return Some(found);
1029 }
1030 let s = candidate.to_string_lossy().replace('\\', "/");
1031 for &(marker, label, _) in LANGUAGE_CACHE_PATTERNS {
1032 if let Some(idx) = s.find(marker) {
1033 let end = idx + marker.len() - 1; return Some((label, PathBuf::from(&s[..end])));
1035 }
1036 }
1037 None
1038}
1039
1040pub fn jail_path_with_roots(
1050 candidate: &Path,
1051 jail_root: &Path,
1052 extra_roots: &[String],
1053) -> Result<PathBuf, PathJailError> {
1054 jail_path_checked(candidate, jail_root, extra_roots, true)
1055}
1056
1057pub fn jail_path_enforced(candidate: &Path, jail_root: &Path) -> Result<PathBuf, PathJailError> {
1066 jail_path_checked(candidate, jail_root, &[], false)
1067}
1068
1069fn jail_path_checked(
1070 candidate: &Path,
1071 jail_root: &Path,
1072 extra_roots: &[String],
1073 honor_off_switch: bool,
1074) -> Result<PathBuf, PathJailError> {
1075 if candidate.to_string_lossy().as_bytes().contains(&0) {
1076 return Err(PathJailError::NullByte);
1077 }
1078
1079 let absolute = if candidate.is_absolute() {
1083 candidate.to_path_buf()
1084 } else {
1085 jail_root.join(candidate)
1086 };
1087 crate::core::protected_store::enforce_protected_store_path(&absolute)
1088 .map_err(|reason| PathJailError::ProtectedContext { reason })?;
1089
1090 match crate::core::workspace_authority::current() {
1094 crate::core::workspace_authority::Authority::Legacy => {}
1095 crate::core::workspace_authority::Authority::Unavailable(reason) => {
1096 return Err(PathJailError::WorkspaceUnavailable {
1097 reason: crate::core::workspace_authority::unavailable_reason(&reason),
1098 });
1099 }
1100 crate::core::workspace_authority::Authority::Workspace(snapshot) => {
1101 return jail_in_workspace(candidate, jail_root, &snapshot);
1102 }
1103 }
1104
1105 #[cfg(feature = "no-jail")]
1109 {
1110 if honor_off_switch {
1111 return Ok(canonicalize_or_self(&absolute));
1112 }
1113 }
1114
1115 {
1116 let cfg = crate::core::config::Config::load();
1117 if honor_off_switch && cfg.path_jail == Some(false) {
1118 return Ok(canonicalize_or_self(&absolute));
1119 }
1120
1121 let root = canonicalize_secure(jail_root);
1122
1123 let resolved: PathBuf;
1128 let candidate: &Path = if candidate.is_absolute() {
1129 candidate
1130 } else {
1131 resolved = root.join(candidate);
1132 resolved.as_path()
1133 };
1134
1135 let mut allow = allow_paths_from_env_and_config();
1136 allow.extend(
1138 extra_roots
1139 .iter()
1140 .filter(|r| !r.is_empty())
1141 .map(|r| canonicalize_secure(Path::new(r))),
1142 );
1143
1144 if let Ok(state) = crate::core::paths::state_dir() {
1148 allow.push(canonicalize_secure(&state));
1149 }
1150 allow.extend(
1155 read_only_roots_from_env_and_config()
1156 .into_iter()
1157 .map(|p| canonicalize_secure(&p))
1158 .filter(|p| {
1159 !is_cursor_agent_output_root(p)
1160 || cursor_workspace_matches(p, jail_root, extra_roots)
1161 }),
1162 );
1163
1164 let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
1165 PathJailError::NoExistingAncestor {
1166 path: candidate.to_path_buf(),
1167 }
1168 })?;
1169
1170 let allowed = path_allowed_by_jail(&base, &root, &allow);
1171
1172 if !allowed {
1173 let is_suspicious = crate::core::pathutil::is_suspicious_root(&root);
1174 let mut hint = if is_suspicious || root == std::path::Path::new("/") {
1175 ". lean-ctx has not bound this session to a project yet (the host reported \
1176 no project directory). Relative paths cannot bind it: pass one \
1177 absolute path inside the project, e.g. /home/me/code/my-app/README.md, \
1178 and it stays the session project; if it persists: lean-ctx doctor --fix"
1179 .to_string()
1180 } else {
1181 super::pathjail_scope::escape_hint(candidate, &base, &root)
1182 };
1183 if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
1187 hint.push_str(". ");
1188 hint.push_str(¬ice);
1189 }
1190 if let Some(missing) = crate::core::config::Config::missing_config_path() {
1194 hint.push_str(&format!(
1195 ". ⚠ lean-ctx reads no config file at {} (running on defaults) — an \
1196 allow_paths edit in a config.toml elsewhere is not read; \
1197 `lean-ctx doctor` shows the path in effect",
1198 missing.display()
1199 ));
1200 }
1201 if let Some(cache_hint) = detected_cache_hint(candidate) {
1202 hint.push_str(&cache_hint);
1203 }
1204 return Err(PathJailError::EscapesRoot {
1205 path: candidate.to_path_buf(),
1206 root,
1207 hint,
1208 });
1209 }
1210
1211 #[cfg(windows)]
1212 reject_symlink_on_windows(candidate)?;
1213
1214 let mut out = base;
1215 for part in remainder.iter().rev() {
1216 out.push(part);
1217 }
1218
1219 if out.exists() {
1222 let final_canon = canonicalize_secure(&out);
1223 let final_ok = path_allowed_by_jail(&final_canon, &root, &allow);
1224 if !final_ok {
1225 return Err(PathJailError::PostCanonicalizeEscape {
1226 path: candidate.to_path_buf(),
1227 resolved: final_canon,
1228 });
1229 }
1230 }
1231
1232 crate::core::protected_store::enforce_protected_store_path(&out)
1233 .map_err(|reason| PathJailError::ProtectedContext { reason })?;
1234 Ok(out)
1235 }
1236}
1237
1238#[cfg(all(test, target_os = "macos"))]
1239mod protected_store_tests {
1240 use super::*;
1241
1242 #[test]
1243 fn runtime_store_is_not_reopened_by_implicit_or_explicit_roots() {
1244 let isolated = crate::core::data_dir::isolated_data_dir();
1245 let project = tempfile::tempdir().unwrap();
1246 let store = isolated.path().join("knowledge");
1247 std::fs::create_dir_all(&store).unwrap();
1248 let fact = store.join("facts.json");
1249 std::fs::write(&fact, "private stored context").unwrap();
1250 let code = project.path().join("main.rs");
1251 std::fs::write(&code, "fn main() {}\n").unwrap();
1252 assert!(
1253 jail_path(&fact, project.path()).is_ok(),
1254 "Community keeps its existing runtime access"
1255 );
1256 let _protected =
1257 crate::core::protected_store::pin_synthetic_session(isolated.path()).unwrap();
1258 let roots = vec![isolated.path().to_string_lossy().into_owned()];
1259 for candidate in [&fact, &store, &isolated.path().to_path_buf()] {
1260 assert!(
1261 matches!(
1262 jail_path_with_roots(candidate, project.path(), &roots),
1263 Err(PathJailError::ProtectedContext { .. })
1264 ),
1265 "{candidate:?}"
1266 );
1267 }
1268 assert!(jail_path(&code, project.path()).is_ok());
1269 assert!(jail_path_with_roots(Path::new("facts.json"), &store, &roots).is_err());
1270 for target in [&fact, &store.join("new/backup.json")] {
1274 assert!(enforce_writable(target).is_err(), "{target:?}");
1275 }
1276 assert_eq!(
1277 std::fs::read_to_string(&fact).unwrap(),
1278 "private stored context"
1279 );
1280 assert!(!store.join("new").exists());
1281 enforce_writable(&code).unwrap();
1282 std::fs::write(&fact, "retained internal update").unwrap();
1284 assert_eq!(
1285 std::fs::read_to_string(&fact).unwrap(),
1286 "retained internal update"
1287 );
1288 }
1289
1290 #[test]
1291 fn missing_child_profile_never_falls_back_to_the_normal_jail() {
1292 let isolated = crate::core::data_dir::isolated_data_dir();
1293 let project = tempfile::tempdir().unwrap();
1294 let code = project.path().join("main.rs");
1295 std::fs::write(&code, "fn main() {}\n").unwrap();
1296 let _protected =
1297 crate::core::protected_store::pin_synthetic_session(isolated.path()).unwrap();
1298 crate::test_env::remove_var(crate::core::protected_store::CHILD_PROFILE_ENV);
1299 assert!(matches!(
1300 jail_path(&code, project.path()),
1301 Err(PathJailError::ProtectedContext { .. })
1302 ));
1303 }
1304}
1305
1306#[cfg(windows)]
1307fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
1308 let path_str = normalize_windows_path(&path.to_string_lossy());
1309 let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
1310 let prefix_str = prefix_str.trim_end_matches('\\');
1311 path_str == prefix_str
1313 || path_str
1314 .strip_prefix(prefix_str)
1315 .is_some_and(|rest| rest.starts_with('\\'))
1316}
1317
1318#[cfg(windows)]
1319fn normalize_windows_path(s: &str) -> String {
1320 let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
1321 stripped.to_lowercase().replace('/', "\\")
1322}
1323
1324#[cfg(windows)]
1325fn reject_symlink_on_windows(path: &Path) -> Result<(), PathJailError> {
1326 if let Ok(meta) = std::fs::symlink_metadata(path) {
1327 if super::pathutil::is_symlink_or_reparse(&meta) {
1330 return Err(PathJailError::Symlink {
1331 path: path.to_path_buf(),
1332 });
1333 }
1334 }
1335 Ok(())
1336}
1337
1338#[path = "pathjail_ide_dirs.rs"]
1339mod ide_dirs;
1340
1341#[cfg(all(test, windows))]
1342#[path = "pathjail_windows_tests.rs"]
1343mod windows_prefix_tests;
1344
1345#[cfg(test)]
1346#[path = "pathjail_tests.rs"]
1347mod tests;