1use std::path::{Path, PathBuf};
2
3use crate::core::error::PathJailError;
4
5pub fn expand_user_path(raw: &str) -> PathBuf {
13 let mut s = raw.to_string();
14
15 if (s == "~" || s.starts_with("~/"))
16 && let Some(home) = dirs::home_dir()
17 {
18 s = format!("{}{}", home.to_string_lossy(), &s[1..]);
19 }
20
21 while let Some(start) = s.find('$') {
22 let rest = &s[start + 1..];
23 let (name, token_len) = if let Some(stripped) = rest.strip_prefix('{') {
24 match stripped.find('}') {
25 Some(end) => (stripped[..end].to_string(), end + 3),
26 None => break,
27 }
28 } else {
29 let end = rest
30 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
31 .unwrap_or(rest.len());
32 (rest[..end].to_string(), end + 1)
33 };
34 if name.is_empty() {
35 break;
36 }
37 if let Ok(val) = std::env::var(&name) {
38 s.replace_range(start..start + token_len, &val);
39 } else if name == "HOME"
40 && let Some(home) = dirs::home_dir()
41 {
42 let home = home.to_string_lossy();
43 s.replace_range(start..start + token_len, home.as_ref());
44 } else {
45 tracing::warn!(
46 "allow_paths/extra_roots entry '{raw}' references unset variable ${name} — entry will never match"
47 );
48 break;
49 }
50 }
51
52 PathBuf::from(s)
53}
54
55pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
56 let mut out = Vec::new();
57 let cfg = crate::core::config::Config::load();
58
59 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
71 out.push(canonicalize_secure(&data_dir));
72 }
73
74 if let Some(home) = dirs::home_dir() {
75 let ide_dirs_allowed = cfg.allow_ide_config_dirs.unwrap_or(false)
76 || std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
77 out.extend(home_allow_dirs(&home, ide_dirs_allowed));
78 }
79
80 for p in &cfg.allow_paths {
81 out.push(canonicalize_secure(&expand_user_path(p)));
82 }
83 for p in &cfg.extra_roots {
84 out.push(canonicalize_secure(&expand_user_path(p)));
85 }
86
87 for path in crate::core::runtime_flags::allow_paths() {
88 out.push(canonicalize_secure(&path));
89 }
90 let v = std::env::var("LCTX_ALLOW_PATH")
93 .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
94 .unwrap_or_default();
95 if !v.trim().is_empty() {
96 for p in std::env::split_paths(&v) {
97 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
98 }
99 }
100
101 let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
102 if !extra.trim().is_empty() {
103 for p in std::env::split_paths(&extra) {
104 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
105 }
106 }
107
108 out.extend(canonicalized_roots(
113 &cfg.read_only_roots,
114 "LEAN_CTX_READ_ONLY_ROOTS",
115 ));
116
117 out
118}
119
120fn canonicalized_roots(config_entries: &[String], env_var: &str) -> Vec<PathBuf> {
125 let mut out = Vec::new();
126 for p in config_entries {
127 out.push(canonicalize_secure(&expand_user_path(p)));
128 }
129 let v = std::env::var(env_var).unwrap_or_default();
130 if !v.trim().is_empty() {
131 for p in std::env::split_paths(&v) {
132 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
133 }
134 }
135 out
136}
137
138pub fn read_only_roots_from_env_and_config() -> Vec<PathBuf> {
143 let cfg = crate::core::config::Config::load();
144 let mut roots = canonicalized_roots(&cfg.read_only_roots, "LEAN_CTX_READ_ONLY_ROOTS");
145 roots.extend(session_read_only_roots());
150 roots
151}
152
153static SESSION_READ_ONLY_ROOTS: std::sync::OnceLock<std::sync::Mutex<Vec<PathBuf>>> =
154 std::sync::OnceLock::new();
155
156fn session_read_only_roots_cell() -> &'static std::sync::Mutex<Vec<PathBuf>> {
157 SESSION_READ_ONLY_ROOTS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
158}
159
160pub fn session_read_only_roots() -> Vec<PathBuf> {
162 session_read_only_roots_cell()
163 .lock()
164 .map(|g| g.clone())
165 .unwrap_or_default()
166}
167
168pub fn register_session_read_only_root(root: &Path) -> bool {
178 let canon = canonicalize_secure(root);
179 let mut guard = match session_read_only_roots_cell().lock() {
180 Ok(g) => g,
181 Err(poisoned) => poisoned.into_inner(),
182 };
183 if guard.iter().any(|r| r == &canon) {
184 return false;
185 }
186 guard.push(canon);
187 true
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub struct JailRelaxation {
198 pub source: &'static str,
200 pub detail: &'static str,
202}
203
204fn env_is_set(var: &str) -> bool {
205 std::env::var(var).is_ok_and(|v| !v.trim().is_empty())
206}
207
208#[must_use]
212pub fn active_relaxations() -> Vec<JailRelaxation> {
213 let mut out = Vec::new();
214
215 if cfg!(feature = "no-jail") {
216 out.push(JailRelaxation {
217 source: "no-jail (build feature)",
218 detail: "path jail compiled out — every tool path is allowed",
219 });
220 }
221
222 if crate::core::config::Config::load().path_jail == Some(false) {
223 out.push(JailRelaxation {
224 source: "path_jail = false (config.toml)",
225 detail: "path jail disabled — every tool path is allowed",
226 });
227 }
228
229 if crate::core::runtime_flags::allow_path_enabled() {
230 out.push(JailRelaxation {
231 source: "LEAN_CTX_ALLOW_PATH",
232 detail: "widens the read/write allow-list beyond the project root",
233 });
234 }
235
236 if env_is_set("LEAN_CTX_EXTRA_ROOTS") {
237 out.push(JailRelaxation {
238 source: "LEAN_CTX_EXTRA_ROOTS",
239 detail: "adds extra accessible roots beyond the project root",
240 });
241 }
242
243 let ide_env = std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
244 if ide_env
245 || crate::core::config::Config::load()
246 .allow_ide_config_dirs
247 .unwrap_or(false)
248 {
249 out.push(JailRelaxation {
250 source: if ide_env {
251 "LEAN_CTX_ALLOW_IDE_DIRS=1"
252 } else {
253 "allow_ide_config_dirs = true (config.toml)"
254 },
255 detail: "exposes ~/.cursor, ~/.claude, … (other agents' sessions/credentials) to tools",
256 });
257 }
258
259 if super::pathjail_scope::PathJailScope::resolve() == super::pathjail_scope::PathJailScope::Home
262 {
263 out.push(JailRelaxation {
264 source: if std::env::var("LEAN_CTX_PATH_JAIL_SCOPE")
265 .ok()
266 .and_then(|v| super::pathjail_scope::PathJailScope::parse(&v))
267 .is_some()
268 {
269 "LEAN_CTX_PATH_JAIL_SCOPE=home"
270 } else {
271 "path_jail_scope = home (config.toml)"
272 },
273 detail: "reads and writes anywhere below ~ outside protected zones, not only in the active project",
274 });
275 }
276
277 out
278}
279
280pub fn warn_if_relaxed() {
284 for relaxation in active_relaxations() {
285 tracing::warn!(
286 "[SECURITY] path jail relaxed via {}: {} — intended for trusted local use only",
287 relaxation.source,
288 relaxation.detail
289 );
290 }
291}
292
293pub fn is_read_only_path(candidate: &Path) -> bool {
304 let roots = read_only_roots_from_env_and_config();
305 if roots.is_empty() {
306 return false;
307 }
308 let base = canonical_target(candidate);
309 roots.iter().any(|r| is_under_prefix(&base, r))
310}
311
312fn canonical_target(candidate: &Path) -> PathBuf {
316 match canonicalize_existing_ancestor(candidate) {
317 Some((mut p, remainder)) => {
318 for part in remainder.iter().rev() {
319 p.push(part);
320 }
321 p
322 }
323 None => canonicalize_or_self(candidate),
324 }
325}
326
327pub fn enforce_writable(candidate: &Path) -> Result<(), String> {
336 crate::core::protected_store::enforce_protected_store_path(candidate)?;
339 if crate::core::policy::hive_floor::is_policy_path(&canonical_target(candidate)) {
341 return Err(format!(
342 "Hive policy files are written by the instance only; agents cannot change them: {}",
343 candidate.display()
344 ));
345 }
346 match crate::core::workspace_authority::current() {
348 crate::core::workspace_authority::Authority::Legacy => {}
349 crate::core::workspace_authority::Authority::Unavailable(reason) => {
350 return Err(crate::core::workspace_authority::unavailable_reason(
351 &reason,
352 ));
353 }
354 crate::core::workspace_authority::Authority::Workspace(snapshot) => {
355 let target = canonical_target(candidate);
356 if snapshot.admits_write(&target) {
357 return Ok(());
358 }
359 return Err(format!(
360 "writes are denied outside the read-write roots of Workspace '{}' (rev {}): {}",
361 snapshot.id,
362 snapshot.revision,
363 candidate.display()
364 ));
365 }
366 }
367 if is_read_only_path(candidate) {
368 return Err(format!(
369 "path is inside a read-only root — writes are denied (read_only_roots): {}",
370 candidate.display()
371 ));
372 }
373 match super::pathjail_scope::home_scope_write_denial(&canonical_target(candidate)) {
374 Some(denial) => Err(denial),
375 None => Ok(()),
376 }
377}
378
379fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
390 let mut out = Vec::new();
391 if ide_dirs_allowed {
392 let targets = crate::core::editor_registry::build_targets(home);
393 collect_ide_allow_dirs(home, &targets, &mut out);
394 }
395 out
396}
397
398fn collect_ide_allow_dirs(
407 home: &Path,
408 targets: &[crate::core::editor_registry::EditorTarget],
409 out: &mut Vec<PathBuf>,
410) {
411 let mut seen: std::collections::HashSet<PathBuf> = out.iter().cloned().collect();
412 for target in targets {
413 let candidates = [
414 target.config_path.parent().map(Path::to_path_buf),
415 Some(target.detect_path.clone()),
416 ];
417 for cand in candidates.into_iter().flatten() {
418 if cand.as_path() == home || !cand.starts_with(home) || !cand.is_dir() {
419 continue;
420 }
421 let resolved = canonicalize_secure(&cand);
422 if seen.insert(resolved.clone()) {
423 out.push(resolved);
424 }
425 }
426 }
427}
428
429fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
430 path.starts_with(prefix)
431}
432
433pub fn is_harness_auto_memory_path(path: &Path) -> bool {
447 use std::path::Component;
448 harness_project_dirs().iter().any(|projects| {
449 let Ok(rest) = path.strip_prefix(projects) else {
450 return false;
451 };
452 if rest
453 .components()
454 .any(|c| !matches!(c, Component::Normal(_)))
455 {
456 return false;
457 }
458 let mut parts = rest.components();
459 parts.next().is_some()
460 && parts
461 .next()
462 .is_some_and(|c| c.as_os_str().eq_ignore_ascii_case("memory"))
463 })
464}
465
466fn harness_project_dirs() -> Vec<PathBuf> {
469 let mut configs = Vec::new();
470 if let Some(home) = dirs::home_dir() {
471 configs.push(home.join(".claude"));
472 configs.push(home.join(".codebuddy"));
473 }
474 if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|v| !v.is_empty()) {
475 configs.push(PathBuf::from(dir));
476 }
477 let mut out = Vec::new();
478 for config in configs.into_iter().filter(|c| c.is_absolute()) {
479 let projects = config.join("projects");
480 let canonical = canonicalize_secure(&projects);
481 if canonical != projects {
482 out.push(canonical);
483 }
484 out.push(projects);
485 }
486 out
487}
488
489fn path_allowed_by_jail(base: &Path, root: &Path, allow: &[PathBuf]) -> bool {
490 if is_harness_auto_memory_path(base) {
491 return true;
492 }
493 let allowed = is_under_prefix(base, root)
494 || allow.iter().any(|p| is_under_prefix(base, p))
495 || super::pathjail_scope::home_scope_admits(base);
496 #[cfg(windows)]
497 let allowed = allowed || is_under_prefix_windows(base, root);
498 allowed && super::pathjail_scope::protected_zone_permits(base, root, allow)
499}
500
501pub fn canonicalize_or_self(path: &Path) -> PathBuf {
505 super::pathutil::safe_canonicalize_bounded(path, 2000)
506}
507
508pub(crate) fn canonicalize_secure(path: &Path) -> PathBuf {
513 super::pathutil::canonicalize_secure_bounded(path, 2000)
514}
515
516fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
517 let mut cur = path.to_path_buf();
518 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
519 loop {
520 if cur.exists() {
521 return Some((canonicalize_secure(&cur), remainder));
522 }
523 let name = cur.file_name()?.to_os_string();
524 remainder.push(name);
525 if !cur.pop() {
526 return None;
527 }
528 }
529}
530
531pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, PathJailError> {
532 jail_path_with_roots(candidate, jail_root, &[])
533}
534
535fn jail_in_workspace(
543 candidate: &Path,
544 jail_root: &Path,
545 snapshot: &crate::core::workspace_authority::Snapshot,
546) -> Result<PathBuf, PathJailError> {
547 let canonical = |path: &Path| crate::core::pathutil::canonicalize_secure(path).ok();
548 let base_root = canonical(jail_root)
549 .filter(|root| snapshot.admits(root))
550 .unwrap_or_else(|| snapshot.primary.clone());
551 let candidate = if candidate.is_absolute() {
552 candidate.to_path_buf()
553 } else {
554 base_root.join(candidate)
555 };
556 let admitted = |path: &Path| snapshot.admits(path);
557 let outside = |path: &Path| PathJailError::OutsideWorkspace {
558 path: path.to_path_buf(),
559 workspace: snapshot.id.clone(),
560 revision: snapshot.revision,
561 roots: snapshot
562 .granting_roots()
563 .map(|(root, access)| format!("{} ({})", root.display(), access.as_str()))
564 .collect::<Vec<_>>()
565 .join(", "),
566 };
567
568 let mut existing = candidate.clone();
569 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
570 while !existing.exists() {
571 let Some(name) = existing.file_name() else {
572 return Err(PathJailError::NoExistingAncestor { path: candidate });
573 };
574 remainder.push(name.to_os_string());
575 if !existing.pop() {
576 return Err(PathJailError::NoExistingAncestor { path: candidate });
577 }
578 }
579 if remainder
580 .iter()
581 .any(|part| part == std::ffi::OsStr::new("..") || part == std::ffi::OsStr::new("."))
582 {
583 return Err(outside(&candidate));
584 }
585 let base = canonical(&existing).ok_or_else(|| outside(&candidate))?;
588 if !admitted(&base) {
589 return Err(outside(&candidate));
590 }
591
592 #[cfg(windows)]
593 reject_symlink_on_windows(&candidate)?;
594
595 let mut out = base;
596 for part in remainder.iter().rev() {
597 out.push(part);
598 }
599 if out.exists() {
600 let resolved = canonical(&out).ok_or_else(|| outside(&candidate))?;
601 if !admitted(&resolved) {
602 return Err(PathJailError::PostCanonicalizeEscape {
603 path: candidate,
604 resolved,
605 });
606 }
607 }
608 crate::core::protected_store::enforce_protected_store_path(&out)
609 .map_err(|reason| PathJailError::ProtectedContext { reason })?;
610 Ok(out)
611}
612
613const LANGUAGE_CACHE_PATTERNS: &[(&str, &str, &str)] = &[
618 ("/go/pkg/mod/", "Go module cache", "~/go/pkg/mod"),
619 (
620 "/.cargo/registry/",
621 "Rust crate registry",
622 "~/.cargo/registry",
623 ),
624 (
625 "/site-packages/",
626 "Python site-packages",
627 "<venv>/lib/pythonX.Y/site-packages",
628 ),
629 ("/node_modules/", "Node modules", "<project>/node_modules"),
630 (
631 "/.m2/repository/",
632 "Maven local repository",
633 "~/.m2/repository",
634 ),
635 ("/.gradle/caches/", "Gradle cache", "~/.gradle/caches"),
636 (
637 "/.nuget/packages/",
638 "NuGet package cache",
639 "~/.nuget/packages",
640 ),
641 (
645 "/.rustup/toolchains/",
646 "Rust toolchain source",
647 "~/.rustup/toolchains",
648 ),
649 (
650 "/.cargo/git/checkouts/",
651 "Cargo git dependencies",
652 "~/.cargo/git/checkouts",
653 ),
654 (
655 "/.bun/install/cache/",
656 "Bun package cache",
657 "~/.bun/install/cache",
658 ),
659 (
660 "/.pub-cache/hosted/",
661 "Dart pub cache",
662 "~/.pub-cache/hosted",
663 ),
664 ("/.gem/ruby/", "Ruby gems", "~/.gem/ruby"),
665 (
668 "/.pyenv/versions/",
669 "pyenv Python installations",
670 "~/.pyenv/versions",
671 ),
672];
673
674const AGENT_INSTRUCTION_ROOTS: &[(&str, &str)] = &[
690 (".claude/skills", "Claude Code skills"),
691 (".claude/commands", "Claude Code commands"),
692 (".claude/agents", "Claude Code agents"),
693 (".claude/plans", "Claude Code plans"),
694 (".claude/rules", "Claude Code rules"),
695 (".claude/plugins/marketplaces", "Claude Code plugins"),
696 (".claude/plugins/cache", "Claude Code plugins"),
697 (".claude/CLAUDE.md", "Claude Code instructions"),
698 (".claude/settings.json", "Claude Code settings"),
699 (".claude/settings.local.json", "Claude Code settings"),
700 (".claude/keybindings.json", "Claude Code settings"),
701 (".codex/skills", "Codex skills"),
702 (".codex/prompts", "Codex prompts"),
703 (".codex/AGENTS.md", "Codex instructions"),
704 (".codex/config.toml", "Codex config"),
705 (".codex/hooks.json", "Codex config"),
706 (".agents/skills", "agent skills"),
707 (".cursor/rules", "Cursor rules"),
708 (".cursor/skills", "Cursor skills"),
709 (".cursor/commands", "Cursor commands"),
710 (".cursor/mcp.json", "Cursor config"),
711 (".cursor/hooks.json", "Cursor config"),
712 (".gemini/GEMINI.md", "Gemini instructions"),
713 (".gemini/commands", "Gemini commands"),
714 (".gemini/settings.json", "Gemini config"),
715 (".codebuddy/settings.json", "CodeBuddy settings"),
716 (".config/opencode/agent", "OpenCode agents"),
717 (".config/opencode/command", "OpenCode commands"),
718 (".config/opencode/AGENTS.md", "OpenCode instructions"),
719 (".config/opencode/opencode.json", "OpenCode config"),
720 (".config/opencode/opencode.jsonc", "OpenCode config"),
721 (".codeium/windsurf/mcp_config.json", "Windsurf config"),
722];
723
724const CURSOR_AGENT_OUTPUT_DIRS: &[&str] = &["agent-tools", "terminals"];
733const CURSOR_AGENT_OUTPUT_LABEL: &str = "Cursor agent output";
734
735fn cursor_workspace_slug(root: &Path) -> String {
739 let slug: String = root
740 .to_string_lossy()
741 .chars()
742 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
743 .collect();
744 slug.trim_start_matches('-').to_string()
745}
746
747fn is_cursor_agent_output_root(path: &Path) -> bool {
751 let mut back = path.components().rev().map(std::path::Component::as_os_str);
752 let (Some(dir), Some(_workspace), Some(projects), Some(cursor)) =
753 (back.next(), back.next(), back.next(), back.next())
754 else {
755 return false;
756 };
757 cursor == ".cursor"
758 && projects == "projects"
759 && CURSOR_AGENT_OUTPUT_DIRS.iter().any(|d| dir == *d)
760}
761
762fn cursor_workspace_matches(
768 admitted_root: &Path,
769 jail_root: &Path,
770 extra_roots: &[String],
771) -> bool {
772 let Some(workspace) = admitted_root.parent().and_then(Path::file_name) else {
773 return false;
774 };
775 std::iter::once(jail_root.to_path_buf())
776 .chain(extra_roots.iter().map(PathBuf::from))
777 .flat_map(|root| [canonicalize_secure(&root), root])
778 .any(|root| workspace == cursor_workspace_slug(&root).as_str())
779}
780
781fn cursor_agent_output_root(rel: &Path) -> Option<PathBuf> {
784 let mut parts = rel.components().map(std::path::Component::as_os_str);
785 if parts.next()? != ".cursor" || parts.next()? != "projects" {
786 return None;
787 }
788 let workspace = parts.next()?;
789 let dir = parts.next()?;
790 CURSOR_AGENT_OUTPUT_DIRS
791 .iter()
792 .any(|d| dir == *d)
793 .then(|| Path::new(".cursor/projects").join(workspace).join(dir))
794}
795
796fn detect_agent_instruction_root(candidate: &Path) -> Option<(&'static str, PathBuf)> {
801 let home = dirs::home_dir()?;
802 let canon_home = canonicalize_secure(&home);
803 let rel = candidate
804 .strip_prefix(&home)
805 .or_else(|_| candidate.strip_prefix(&canon_home))
806 .ok()?;
807 if rel
809 .components()
810 .any(|c| !matches!(c, std::path::Component::Normal(_)))
811 {
812 return None;
813 }
814 AGENT_INSTRUCTION_ROOTS
815 .iter()
816 .find(|(root, _)| rel.starts_with(root))
817 .map(|&(root, label)| (label, canon_home.join(root)))
818 .or_else(|| detect_host_session_output_root(rel, &canon_home))
819 .or_else(|| {
820 cursor_agent_output_root(rel)
821 .map(|root| canon_home.join(root))
822 .filter(|root| canonicalize_secure(root) == *root)
826 .map(|root| (CURSOR_AGENT_OUTPUT_LABEL, root))
827 })
828}
829
830fn detect_host_session_output_root(
842 rel: &Path,
843 canon_home: &Path,
844) -> Option<(&'static str, PathBuf)> {
845 let parts: Vec<&str> = rel
846 .components()
847 .map(|c| c.as_os_str().to_str())
848 .collect::<Option<_>>()?;
849 let (depth, label) = match parts.as_slice() {
850 [
851 ".claude",
852 "projects",
853 _,
854 _,
855 "tool-results" | "subagents",
856 ..,
857 ] => (5, "Claude Code session output"),
858 _ => return None,
859 };
860 Some((
861 label,
862 canon_home.join(parts[..depth].iter().collect::<PathBuf>()),
863 ))
864}
865
866#[cfg(unix)]
872fn detect_host_scratch_root(candidate: &Path) -> Option<(&'static str, PathBuf)> {
873 use std::os::unix::fs::MetadataExt;
874 let uid = unsafe { libc::geteuid() };
876 let name = format!("claude-{uid}");
877 let (base, rest) = canonicalize_existing_ancestor(candidate)?;
880 let candidate = rest.iter().rev().fold(base, |path, part| path.join(part));
881 [std::env::temp_dir(), PathBuf::from("/tmp")]
882 .into_iter()
883 .map(|tmp| tmp.join(&name))
884 .find_map(|dir| {
885 let meta = std::fs::symlink_metadata(&dir).ok()?;
886 let root = canonicalize_secure(&dir);
887 (meta.is_dir()
888 && meta.uid() == uid
889 && meta.mode() & 0o022 == 0
890 && candidate.starts_with(&root))
891 .then_some(("Claude Code scratchpad", root))
892 })
893}
894
895#[cfg(not(unix))]
896fn detect_host_scratch_root(_candidate: &Path) -> Option<(&'static str, PathBuf)> {
897 None
898}
899
900pub fn register_symlink_target_read_only(candidate: &Path, root: &Path) -> bool {
913 if detect_agent_instruction_root(root)
914 .is_none_or(|(label, _)| label == CURSOR_AGENT_OUTPUT_LABEL)
915 || (!candidate.starts_with(root) && !candidate.starts_with(canonicalize_secure(root)))
916 {
917 return false;
918 }
919 let Ok(target) = std::fs::canonicalize(candidate) else {
920 return false;
921 };
922 let root = canonicalize_secure(root);
923 if target.starts_with(&root) {
924 return false;
925 }
926 if let Some((_, agent_root)) = detect_agent_instruction_root(&target) {
929 return register_session_read_only_root(&agent_root);
930 }
931 let dir = if target.is_dir() {
932 target
933 } else {
934 match target.parent() {
935 Some(parent) => parent.to_path_buf(),
936 None => return false,
937 }
938 };
939 let below_home =
940 dirs::home_dir().is_some_and(|home| dir.starts_with(canonicalize_secure(&home)));
941 if !below_home
942 || crate::core::pathutil::is_broad_or_unsafe_root(&dir)
943 || super::pathjail_scope::in_protected_zone(&dir)
944 {
945 return false;
946 }
947 register_session_read_only_root(&dir)
948}
949
950pub fn jail_path_auto_admitting(
958 candidate: &Path,
959 jail_root: &Path,
960 extra_roots: &[String],
961) -> Result<PathBuf, PathJailError> {
962 let first = jail_path_with_roots(candidate, jail_root, extra_roots);
963 let Err(refused) = first else {
964 return first;
965 };
966 if crate::core::workspace_authority::is_configured() {
969 return Err(refused);
970 }
971 let Some((label, root)) = detect_language_cache_root(candidate) else {
972 return Err(refused);
973 };
974 if label == CURSOR_AGENT_OUTPUT_LABEL
977 && !cursor_workspace_matches(&root, jail_root, extra_roots)
978 {
979 return Err(refused);
980 }
981 if register_session_read_only_root(&root) {
982 tracing::info!(
983 "auto-registered {label} at {} as a read-only root",
984 root.display()
985 );
986 }
987 let retry = jail_path_with_roots(candidate, jail_root, extra_roots);
988 if retry.is_err() && register_symlink_target_read_only(candidate, &root) {
992 return jail_path_with_roots(candidate, jail_root, extra_roots);
993 }
994 retry
995}
996
997fn detected_cache_hint(candidate: &std::path::Path) -> Option<String> {
1001 let s = candidate.to_string_lossy();
1002 for &(pattern, name, example) in LANGUAGE_CACHE_PATTERNS {
1003 if s.contains(pattern) {
1004 return Some(format!(
1005 ". Detected {name} — add read_only_roots = [\"{example}\"] to \
1006 ~/.config/lean-ctx/config.toml for cached, compressed reads without write access"
1007 ));
1008 }
1009 }
1010 None
1011}
1012
1013pub fn detect_language_cache_root(candidate: &Path) -> Option<(&'static str, PathBuf)> {
1018 if let Some(found) = detect_agent_instruction_root(candidate) {
1019 return Some(found);
1020 }
1021 if let Some(found) = detect_host_scratch_root(candidate) {
1022 return Some(found);
1023 }
1024 let s = candidate.to_string_lossy().replace('\\', "/");
1025 for &(marker, label, _) in LANGUAGE_CACHE_PATTERNS {
1026 if let Some(idx) = s.find(marker) {
1027 let end = idx + marker.len() - 1; return Some((label, PathBuf::from(&s[..end])));
1029 }
1030 }
1031 None
1032}
1033
1034pub fn jail_path_with_roots(
1044 candidate: &Path,
1045 jail_root: &Path,
1046 extra_roots: &[String],
1047) -> Result<PathBuf, PathJailError> {
1048 jail_path_checked(candidate, jail_root, extra_roots, true)
1049}
1050
1051pub fn jail_path_enforced(candidate: &Path, jail_root: &Path) -> Result<PathBuf, PathJailError> {
1060 jail_path_checked(candidate, jail_root, &[], false)
1061}
1062
1063fn jail_path_checked(
1064 candidate: &Path,
1065 jail_root: &Path,
1066 extra_roots: &[String],
1067 honor_off_switch: bool,
1068) -> Result<PathBuf, PathJailError> {
1069 if candidate.to_string_lossy().as_bytes().contains(&0) {
1070 return Err(PathJailError::NullByte);
1071 }
1072
1073 let absolute = if candidate.is_absolute() {
1077 candidate.to_path_buf()
1078 } else {
1079 jail_root.join(candidate)
1080 };
1081 crate::core::protected_store::enforce_protected_store_path(&absolute)
1082 .map_err(|reason| PathJailError::ProtectedContext { reason })?;
1083
1084 match crate::core::workspace_authority::current() {
1088 crate::core::workspace_authority::Authority::Legacy => {}
1089 crate::core::workspace_authority::Authority::Unavailable(reason) => {
1090 return Err(PathJailError::WorkspaceUnavailable {
1091 reason: crate::core::workspace_authority::unavailable_reason(&reason),
1092 });
1093 }
1094 crate::core::workspace_authority::Authority::Workspace(snapshot) => {
1095 return jail_in_workspace(candidate, jail_root, &snapshot);
1096 }
1097 }
1098
1099 #[cfg(feature = "no-jail")]
1103 {
1104 if honor_off_switch {
1105 return Ok(canonicalize_or_self(&absolute));
1106 }
1107 }
1108
1109 {
1110 let cfg = crate::core::config::Config::load();
1111 if honor_off_switch && cfg.path_jail == Some(false) {
1112 return Ok(canonicalize_or_self(&absolute));
1113 }
1114
1115 let root = canonicalize_secure(jail_root);
1116
1117 let resolved: PathBuf;
1122 let candidate: &Path = if candidate.is_absolute() {
1123 candidate
1124 } else {
1125 resolved = root.join(candidate);
1126 resolved.as_path()
1127 };
1128
1129 let mut allow = allow_paths_from_env_and_config();
1130 allow.extend(
1132 extra_roots
1133 .iter()
1134 .filter(|r| !r.is_empty())
1135 .map(|r| canonicalize_secure(Path::new(r))),
1136 );
1137
1138 if let Ok(state) = crate::core::paths::state_dir() {
1142 allow.push(canonicalize_secure(&state));
1143 }
1144 allow.extend(
1149 read_only_roots_from_env_and_config()
1150 .into_iter()
1151 .map(|p| canonicalize_secure(&p))
1152 .filter(|p| {
1153 !is_cursor_agent_output_root(p)
1154 || cursor_workspace_matches(p, jail_root, extra_roots)
1155 }),
1156 );
1157
1158 let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
1159 PathJailError::NoExistingAncestor {
1160 path: candidate.to_path_buf(),
1161 }
1162 })?;
1163
1164 let allowed = path_allowed_by_jail(&base, &root, &allow);
1165
1166 if !allowed {
1167 let is_suspicious = crate::core::pathutil::is_suspicious_root(&root);
1168 let mut hint = if is_suspicious || root == std::path::Path::new("/") {
1169 ". lean-ctx has not bound this session to a project yet (the host reported \
1170 no project directory). Relative paths cannot bind it: pass one \
1171 absolute path inside the project, e.g. /home/me/code/my-app/README.md, \
1172 and it stays the session project; if it persists: lean-ctx doctor --fix"
1173 .to_string()
1174 } else {
1175 super::pathjail_scope::escape_hint(candidate, &base, &root)
1176 };
1177 if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
1181 hint.push_str(". ");
1182 hint.push_str(¬ice);
1183 }
1184 if let Some(missing) = crate::core::config::Config::missing_config_path() {
1188 hint.push_str(&format!(
1189 ". ⚠ lean-ctx reads no config file at {} (running on defaults) — an \
1190 allow_paths edit in a config.toml elsewhere is not read; \
1191 `lean-ctx doctor` shows the path in effect",
1192 missing.display()
1193 ));
1194 }
1195 if let Some(cache_hint) = detected_cache_hint(candidate) {
1196 hint.push_str(&cache_hint);
1197 }
1198 return Err(PathJailError::EscapesRoot {
1199 path: candidate.to_path_buf(),
1200 root,
1201 hint,
1202 });
1203 }
1204
1205 #[cfg(windows)]
1206 reject_symlink_on_windows(candidate)?;
1207
1208 let mut out = base;
1209 for part in remainder.iter().rev() {
1210 out.push(part);
1211 }
1212
1213 if out.exists() {
1216 let final_canon = canonicalize_secure(&out);
1217 let final_ok = path_allowed_by_jail(&final_canon, &root, &allow);
1218 if !final_ok {
1219 return Err(PathJailError::PostCanonicalizeEscape {
1220 path: candidate.to_path_buf(),
1221 resolved: final_canon,
1222 });
1223 }
1224 }
1225
1226 crate::core::protected_store::enforce_protected_store_path(&out)
1227 .map_err(|reason| PathJailError::ProtectedContext { reason })?;
1228 Ok(out)
1229 }
1230}
1231
1232#[cfg(all(test, target_os = "macos"))]
1233mod protected_store_tests {
1234 use super::*;
1235
1236 #[test]
1237 fn runtime_store_is_not_reopened_by_implicit_or_explicit_roots() {
1238 let isolated = crate::core::data_dir::isolated_data_dir();
1239 let project = tempfile::tempdir().unwrap();
1240 let store = isolated.path().join("knowledge");
1241 std::fs::create_dir_all(&store).unwrap();
1242 let fact = store.join("facts.json");
1243 std::fs::write(&fact, "private stored context").unwrap();
1244 let code = project.path().join("main.rs");
1245 std::fs::write(&code, "fn main() {}\n").unwrap();
1246 assert!(
1247 jail_path(&fact, project.path()).is_ok(),
1248 "Community keeps its existing runtime access"
1249 );
1250 let _protected =
1251 crate::core::protected_store::pin_synthetic_session(isolated.path()).unwrap();
1252 let roots = vec![isolated.path().to_string_lossy().into_owned()];
1253 for candidate in [&fact, &store, &isolated.path().to_path_buf()] {
1254 assert!(
1255 matches!(
1256 jail_path_with_roots(candidate, project.path(), &roots),
1257 Err(PathJailError::ProtectedContext { .. })
1258 ),
1259 "{candidate:?}"
1260 );
1261 }
1262 assert!(jail_path(&code, project.path()).is_ok());
1263 assert!(jail_path_with_roots(Path::new("facts.json"), &store, &roots).is_err());
1264 for target in [&fact, &store.join("new/backup.json")] {
1268 assert!(enforce_writable(target).is_err(), "{target:?}");
1269 }
1270 assert_eq!(
1271 std::fs::read_to_string(&fact).unwrap(),
1272 "private stored context"
1273 );
1274 assert!(!store.join("new").exists());
1275 enforce_writable(&code).unwrap();
1276 std::fs::write(&fact, "retained internal update").unwrap();
1278 assert_eq!(
1279 std::fs::read_to_string(&fact).unwrap(),
1280 "retained internal update"
1281 );
1282 }
1283
1284 #[test]
1285 fn missing_child_profile_never_falls_back_to_the_normal_jail() {
1286 let isolated = crate::core::data_dir::isolated_data_dir();
1287 let project = tempfile::tempdir().unwrap();
1288 let code = project.path().join("main.rs");
1289 std::fs::write(&code, "fn main() {}\n").unwrap();
1290 let _protected =
1291 crate::core::protected_store::pin_synthetic_session(isolated.path()).unwrap();
1292 crate::test_env::remove_var(crate::core::protected_store::CHILD_PROFILE_ENV);
1293 assert!(matches!(
1294 jail_path(&code, project.path()),
1295 Err(PathJailError::ProtectedContext { .. })
1296 ));
1297 }
1298}
1299
1300#[cfg(windows)]
1301fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
1302 let path_str = normalize_windows_path(&path.to_string_lossy());
1303 let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
1304 let prefix_str = prefix_str.trim_end_matches('\\');
1305 path_str == prefix_str
1307 || path_str
1308 .strip_prefix(prefix_str)
1309 .is_some_and(|rest| rest.starts_with('\\'))
1310}
1311
1312#[cfg(windows)]
1313fn normalize_windows_path(s: &str) -> String {
1314 let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
1315 stripped.to_lowercase().replace('/', "\\")
1316}
1317
1318#[cfg(windows)]
1319fn reject_symlink_on_windows(path: &Path) -> Result<(), PathJailError> {
1320 if let Ok(meta) = std::fs::symlink_metadata(path) {
1321 if super::pathutil::is_symlink_or_reparse(&meta) {
1324 return Err(PathJailError::Symlink {
1325 path: path.to_path_buf(),
1326 });
1327 }
1328 }
1329 Ok(())
1330}
1331
1332#[cfg(all(test, windows))]
1333#[path = "pathjail_windows_tests.rs"]
1334mod windows_prefix_tests;
1335
1336#[cfg(test)]
1337#[path = "pathjail_tests.rs"]
1338mod tests;