Skip to main content

lean_ctx/core/
pathjail.rs

1use std::path::{Path, PathBuf};
2
3use crate::core::error::PathJailError;
4
5/// `allow_paths` / `extra_roots` come from `config.toml`, where no shell ever
6/// runs — users writing `"$HOME/code"` or `"~/code"` got a literal,
7/// never-matching prefix and concluded the whole option was broken (GH #392).
8/// Unset variables are left verbatim (and warned about) so the entry fails
9/// loudly in `lean-ctx doctor` instead of silently matching something else.
10pub fn expand_user_path(raw: &str) -> PathBuf {
11    let mut s = raw.to_string();
12
13    if (s == "~" || s.starts_with("~/"))
14        && let Some(home) = dirs::home_dir()
15    {
16        s = format!("{}{}", home.to_string_lossy(), &s[1..]);
17    }
18
19    while let Some(start) = s.find('$') {
20        let rest = &s[start + 1..];
21        let (name, token_len) = if let Some(stripped) = rest.strip_prefix('{') {
22            match stripped.find('}') {
23                Some(end) => (stripped[..end].to_string(), end + 3),
24                None => break,
25            }
26        } else {
27            let end = rest
28                .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
29                .unwrap_or(rest.len());
30            (rest[..end].to_string(), end + 1)
31        };
32        if name.is_empty() {
33            break;
34        }
35        if let Ok(val) = std::env::var(&name) {
36            s.replace_range(start..start + token_len, &val);
37        } else {
38            tracing::warn!(
39                "allow_paths/extra_roots entry '{raw}' references unset variable ${name} — entry will never match"
40            );
41            break;
42        }
43    }
44
45    PathBuf::from(s)
46}
47
48pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
49    let mut out = Vec::new();
50    let cfg = crate::core::config::Config::load();
51
52    // The allow-list defines the jail boundary, so it must be canonicalized the
53    // same (security, symlink-resolving) way as the candidate it is compared
54    // against — otherwise a guarded (lexical) root vs a resolved candidate would
55    // break `is_under_prefix`. These entries are data_dir / IDE-config dirs /
56    // user-configured paths, virtually never under ~/Documents.
57    //
58    // This is also lean-ctx's own state dir (sessions, knowledge, …) — always
59    // readable even while foreign editor dirs stay jailed. On a legacy install
60    // the resolver returns `~/.lean-ctx`; on a split install it returns the XDG
61    // data dir. Going through the resolver (not a hardcoded `~/.lean-ctx` join)
62    // is what keeps `home_allow_dirs` free of the legacy-path firewall trip.
63    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
64        out.push(canonicalize_secure(&data_dir));
65    }
66
67    if let Some(home) = dirs::home_dir() {
68        let ide_dirs_allowed = cfg.allow_ide_config_dirs.unwrap_or(false)
69            || std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
70        out.extend(home_allow_dirs(&home, ide_dirs_allowed));
71    }
72
73    for p in &cfg.allow_paths {
74        out.push(canonicalize_secure(&expand_user_path(p)));
75    }
76    for p in &cfg.extra_roots {
77        out.push(canonicalize_secure(&expand_user_path(p)));
78    }
79
80    for path in crate::core::runtime_flags::allow_paths() {
81        out.push(canonicalize_secure(&path));
82    }
83    // Env entries are expanded too: MCP host configs pass env blocks verbatim
84    // (no shell), so "$HOME/code" arrives literally there as well.
85    let v = std::env::var("LCTX_ALLOW_PATH")
86        .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
87        .unwrap_or_default();
88    if !v.trim().is_empty() {
89        for p in std::env::split_paths(&v) {
90            out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
91        }
92    }
93
94    let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
95    if !extra.trim().is_empty() {
96        for p in std::env::split_paths(&extra) {
97            out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
98        }
99    }
100
101    // Read-only roots are *readable* (the whole point is read access to sibling
102    // repos); writes into them are denied separately by `enforce_writable`
103    // (#475). Add them to the read allow-list so reads resolve, exactly like
104    // `extra_roots`, without granting write access.
105    out.extend(canonicalized_roots(
106        &cfg.read_only_roots,
107        "LEAN_CTX_READ_ONLY_ROOTS",
108    ));
109
110    out
111}
112
113/// Canonicalize a set of config-supplied root entries plus an env override
114/// (path-list separated), expanding `~`/`$VAR` first. Shared by the read
115/// allow-list and the read-only-roots collector so both tiers parse roots
116/// identically.
117fn canonicalized_roots(config_entries: &[String], env_var: &str) -> Vec<PathBuf> {
118    let mut out = Vec::new();
119    for p in config_entries {
120        out.push(canonicalize_secure(&expand_user_path(p)));
121    }
122    let v = std::env::var(env_var).unwrap_or_default();
123    if !v.trim().is_empty() {
124        for p in std::env::split_paths(&v) {
125            out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
126        }
127    }
128    out
129}
130
131/// A read-only root is a sibling subtree the agent may **read** but never
132/// **write** — e.g. a reference repo mounted next to the project. Empty by
133/// default, so [`is_read_only_path`]/[`enforce_writable`] are zero-cost no-ops
134/// for everyone who hasn't opted in (#475).
135pub fn read_only_roots_from_env_and_config() -> Vec<PathBuf> {
136    let cfg = crate::core::config::Config::load();
137    let mut roots = canonicalized_roots(&cfg.read_only_roots, "LEAN_CTX_READ_ONLY_ROOTS");
138    // #899: session-scoped roots auto-detected from language caches. Consulted
139    // by both the read allow-list (jail) and `is_read_only_path` (write-deny),
140    // so a cache root is readable but never writable — exactly like a configured
141    // read-only root, minus the config-file edit.
142    roots.extend(session_read_only_roots());
143    roots
144}
145
146static SESSION_READ_ONLY_ROOTS: std::sync::OnceLock<std::sync::Mutex<Vec<PathBuf>>> =
147    std::sync::OnceLock::new();
148
149fn session_read_only_roots_cell() -> &'static std::sync::Mutex<Vec<PathBuf>> {
150    SESSION_READ_ONLY_ROOTS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
151}
152
153/// The session-scoped read-only roots auto-registered this process (#899).
154pub fn session_read_only_roots() -> Vec<PathBuf> {
155    session_read_only_roots_cell()
156        .lock()
157        .map(|g| g.clone())
158        .unwrap_or_default()
159}
160
161/// Register a session-scoped read-only root (an auto-detected language cache).
162/// Returns `true` when newly added. Idempotent on the canonicalized path.
163///
164/// ponytail: process-global set, not per-session — language caches
165/// (`~/go/pkg/mod`, `~/.cargo/registry`, …) are machine-global read-only dirs,
166/// so sharing read access across sessions grants nothing a session couldn't
167/// already get by reading them; per-session isolation would be plumbing for no
168/// security gain. Upgrade to a session-keyed map only if writable roots ever go
169/// down this path.
170pub fn register_session_read_only_root(root: &Path) -> bool {
171    let canon = canonicalize_secure(root);
172    let mut guard = match session_read_only_roots_cell().lock() {
173        Ok(g) => g,
174        Err(poisoned) => poisoned.into_inner(),
175    };
176    if guard.iter().any(|r| r == &canon) {
177        return false;
178    }
179    guard.push(canon);
180    true
181}
182
183/// A single active relaxation of the path jail. Each one widens or disables what
184/// tools can reach beyond the project root, so it is surfaced loudly (GH security
185/// audit, finding 3): the MCP/HTTP server inherits its process env from the
186/// IDE/launchd, so a globally-set `LEAN_CTX_ALLOW_PATH` / `LEAN_CTX_EXTRA_ROOTS`
187/// / `LEAN_CTX_ALLOW_IDE_DIRS` (or `path_jail = false`) silently loosens the
188/// boundary with no in-band signal otherwise.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub struct JailRelaxation {
191    /// The knob that activated it (env var name, config key, or build feature).
192    pub source: &'static str,
193    /// Human-readable effect of the relaxation.
194    pub detail: &'static str,
195}
196
197fn env_is_set(var: &str) -> bool {
198    std::env::var(var).is_ok_and(|v| !v.trim().is_empty())
199}
200
201/// Collect every currently-active path-jail relaxation. An empty result means
202/// the jail is fully in force. This is the single source of truth shared by the
203/// startup warning ([`warn_if_relaxed`]) and `lean-ctx doctor`.
204#[must_use]
205pub fn active_relaxations() -> Vec<JailRelaxation> {
206    let mut out = Vec::new();
207
208    if cfg!(feature = "no-jail") {
209        out.push(JailRelaxation {
210            source: "no-jail (build feature)",
211            detail: "path jail compiled out — every tool path is allowed",
212        });
213    }
214
215    if crate::core::config::Config::load().path_jail == Some(false) {
216        out.push(JailRelaxation {
217            source: "path_jail = false (config.toml)",
218            detail: "path jail disabled — every tool path is allowed",
219        });
220    }
221
222    if crate::core::runtime_flags::allow_path_enabled() {
223        out.push(JailRelaxation {
224            source: "LEAN_CTX_ALLOW_PATH",
225            detail: "widens the read/write allow-list beyond the project root",
226        });
227    }
228
229    if env_is_set("LEAN_CTX_EXTRA_ROOTS") {
230        out.push(JailRelaxation {
231            source: "LEAN_CTX_EXTRA_ROOTS",
232            detail: "adds extra accessible roots beyond the project root",
233        });
234    }
235
236    let ide_env = std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
237    if ide_env
238        || crate::core::config::Config::load()
239            .allow_ide_config_dirs
240            .unwrap_or(false)
241    {
242        out.push(JailRelaxation {
243            source: if ide_env {
244                "LEAN_CTX_ALLOW_IDE_DIRS=1"
245            } else {
246                "allow_ide_config_dirs = true (config.toml)"
247            },
248            detail: "exposes ~/.cursor, ~/.claude, … (other agents' sessions/credentials) to tools",
249        });
250    }
251
252    out
253}
254
255/// Emit a loud `tracing::warn!` for every active path-jail relaxation. Called
256/// once at MCP/HTTP server startup so a trusted-but-loosening env/config leaves
257/// an in-band audit signal instead of silently defeating the jail (finding 3).
258pub fn warn_if_relaxed() {
259    for relaxation in active_relaxations() {
260        tracing::warn!(
261            "[SECURITY] path jail relaxed via {}: {} — intended for trusted local use only",
262            relaxation.source,
263            relaxation.detail
264        );
265    }
266}
267
268/// True when `candidate` resolves to a location inside a configured read-only
269/// root. The candidate's nearest existing ancestor is canonicalized (so a
270/// not-yet-existing file inherits the read-only status of the directory it
271/// would be created in — closing the "create a new file in a read-only repo"
272/// hole) and matched against the (symlink-resolved) read-only roots.
273///
274/// A `false` return is only authoritative when the roots list is empty or the
275/// path provably sits outside every root; an unresolvable candidate (no
276/// existing ancestor) is treated as *not* read-only here and is rejected later
277/// by the ordinary write/jail error, never silently written.
278pub fn is_read_only_path(candidate: &Path) -> bool {
279    let roots = read_only_roots_from_env_and_config();
280    if roots.is_empty() {
281        return false;
282    }
283
284    // Compare the canonicalized nearest-existing-ancestor (resolves symlinks so
285    // a symlink *into* a read-only root can't launder a write past the prefix
286    // check), reconstructing the full path for the comparison.
287    let base = match canonicalize_existing_ancestor(candidate) {
288        Some((base, remainder)) => {
289            let mut p = base;
290            for part in remainder.iter().rev() {
291                p.push(part);
292            }
293            p
294        }
295        None => canonicalize_or_self(candidate),
296    };
297
298    roots.iter().any(|r| is_under_prefix(&base, r))
299}
300
301/// Default-deny write guard for the read-only tier (#475): returns an error if
302/// `candidate` is inside a configured read-only root, `Ok(())` otherwise.
303///
304/// This is the single read-only-aware choke point. Every filesystem write that
305/// can target a caller-supplied path routes through it (the atomic writers in
306/// `ctx_edit`/`edit_apply`, the handoff/session export bundle writers, the
307/// in-place memory-compaction writer, and the refactor IDE pre-write gate), so
308/// a "read-only" root cannot be written through any tool. Reads are unaffected.
309pub fn enforce_writable(candidate: &Path) -> Result<(), String> {
310    if is_read_only_path(candidate) {
311        return Err(format!(
312            "path is inside a read-only root — writes are denied (read_only_roots): {}",
313            candidate.display()
314        ));
315    }
316    Ok(())
317}
318
319/// Foreign editor config dirs for the jail (~/.cursor, ~/.claude, VS Code, …).
320///
321/// These expose other projects' sessions, MCP configs and credentials to any
322/// agent, so they are opt-in only (config `allow_ide_config_dirs = true` or
323/// `LEAN_CTX_ALLOW_IDE_DIRS=1`). lean-ctx's *own* state dir is intentionally NOT
324/// handled here: the caller already adds it via the sanctioned `data_dir`
325/// resolver (the legacy `~/.lean-ctx` is just one resolution of it). Keeping this
326/// a pure foreign-editor list means no legacy `~/.lean-ctx` literal is built in
327/// this module, so the legacy-path firewall (tests/legacy_path_firewall) has
328/// nothing to flag.
329fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
330    let mut out = Vec::new();
331    if ide_dirs_allowed {
332        let targets = crate::core::editor_registry::build_targets(home);
333        collect_ide_allow_dirs(home, &targets, &mut out);
334    }
335    out
336}
337
338/// Collect the in-home config/detect directories of every supported editor.
339///
340/// Derived from the editor registry (the single source of truth) so it covers
341/// non-dotfile layouts too — VS Code's `Library/Application Support/Code/User`,
342/// Cline/Roo globalStorage, JetBrains — and never drifts as editors are added.
343/// A config file that sits directly in `$HOME` (`~/.claude.json`,
344/// `~/.jb-mcp.json`) resolves its parent to `$HOME`; those entries are skipped
345/// so the jail is never widened to the entire home directory.
346fn collect_ide_allow_dirs(
347    home: &Path,
348    targets: &[crate::core::editor_registry::EditorTarget],
349    out: &mut Vec<PathBuf>,
350) {
351    let mut seen: std::collections::HashSet<PathBuf> = out.iter().cloned().collect();
352    for target in targets {
353        let candidates = [
354            target.config_path.parent().map(Path::to_path_buf),
355            Some(target.detect_path.clone()),
356        ];
357        for cand in candidates.into_iter().flatten() {
358            if cand.as_path() == home || !cand.starts_with(home) || !cand.is_dir() {
359                continue;
360            }
361            let resolved = canonicalize_secure(&cand);
362            if seen.insert(resolved.clone()) {
363                out.push(resolved);
364            }
365        }
366    }
367}
368
369fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
370    path.starts_with(prefix)
371}
372
373/// True for Claude Code / CodeBuddy auto-memory files under
374/// `~/.claude/projects/<slug>/memory/` (and the CodeBuddy twin).
375///
376/// Auto memory uses the host's native Read/Edit/Write on these paths
377/// (code.claude.com/docs/en/memory). Replace-mode PathJail must not force
378/// agents to shell out or hit MCP `resources/read` with file URIs (GH #1228).
379/// Scoped to the `memory/` subdirectory only — session transcripts and
380/// credentials under `projects/<slug>/` stay jailed.
381pub fn is_harness_auto_memory_path(path: &Path) -> bool {
382    let lower = path.to_string_lossy().replace('\\', "/").to_lowercase();
383    for marker in ["/.claude/projects/", "/.codebuddy/projects/"] {
384        if let Some(idx) = lower.find(marker) {
385            let after = &lower[idx + marker.len()..];
386            let mut parts = after.split('/');
387            let Some(_slug) = parts.next() else {
388                continue;
389            };
390            if parts.next() == Some("memory") {
391                return true;
392            }
393        }
394    }
395    false
396}
397
398fn path_allowed_by_jail(base: &Path, root: &Path, allow: &[PathBuf]) -> bool {
399    let allowed = is_under_prefix(base, root)
400        || allow.iter().any(|p| is_under_prefix(base, p))
401        || is_harness_auto_memory_path(base);
402    #[cfg(windows)]
403    let allowed = allowed || is_under_prefix_windows(base, root);
404    allowed
405}
406
407/// Heuristic canonicalize — honours the #356 TCC guard. Used by the
408/// jail-disabled bypass and by external callers (session/startup/server roots)
409/// that must not pop a privacy prompt on their own initiative.
410pub fn canonicalize_or_self(path: &Path) -> PathBuf {
411    super::pathutil::safe_canonicalize_bounded(path, 2000)
412}
413
414/// SECURITY canonicalize for the jail boundary itself (roots + candidate +
415/// escape re-check). Deliberately bypasses the #356 TCC guard: the jail must
416/// keep resolving symlinks to detect escapes, and it only ever runs on a path
417/// the client explicitly asked to access, where a one-time prompt is legitimate.
418fn canonicalize_secure(path: &Path) -> PathBuf {
419    super::pathutil::canonicalize_secure_bounded(path, 2000)
420}
421
422fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
423    let mut cur = path.to_path_buf();
424    let mut remainder: Vec<std::ffi::OsString> = Vec::new();
425    loop {
426        if cur.exists() {
427            return Some((canonicalize_secure(&cur), remainder));
428        }
429        let name = cur.file_name()?.to_os_string();
430        remainder.push(name);
431        if !cur.pop() {
432            return None;
433        }
434    }
435}
436
437pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, PathJailError> {
438    jail_path_with_roots(candidate, jail_root, &[])
439}
440
441/// Known language-cache markers (#899): (path substring, human label, config
442/// example). Single source of truth shared by [`detected_cache_hint`] (the
443/// jail-error suggestion) and [`detect_language_cache_root`] (session
444/// auto-registration), so the two never drift.
445const LANGUAGE_CACHE_PATTERNS: &[(&str, &str, &str)] = &[
446    ("/go/pkg/mod/", "Go module cache", "~/go/pkg/mod"),
447    (
448        "/.cargo/registry/",
449        "Rust crate registry",
450        "~/.cargo/registry",
451    ),
452    (
453        "/site-packages/",
454        "Python site-packages",
455        "<venv>/lib/pythonX.Y/site-packages",
456    ),
457    ("/node_modules/", "Node modules", "<project>/node_modules"),
458    (
459        "/.m2/repository/",
460        "Maven local repository",
461        "~/.m2/repository",
462    ),
463    ("/.gradle/caches/", "Gradle cache", "~/.gradle/caches"),
464    (
465        "/.nuget/packages/",
466        "NuGet package cache",
467        "~/.nuget/packages",
468    ),
469];
470
471/// Detect well-known language cache paths and return a targeted hint. Used for
472/// jail callers that don't auto-register (e.g. batch reads); the single-path
473/// ctx_read flow instead auto-registers via [`detect_language_cache_root`].
474fn detected_cache_hint(candidate: &std::path::Path) -> Option<String> {
475    let s = candidate.to_string_lossy();
476    for &(pattern, name, example) in LANGUAGE_CACHE_PATTERNS {
477        if s.contains(pattern) {
478            return Some(format!(
479                ". Detected {name} — add read_only_roots = [\"{example}\"] to \
480                 ~/.config/lean-ctx/config.toml for cached, compressed reads without write access"
481            ));
482        }
483    }
484    None
485}
486
487/// If `candidate` sits inside a known language cache, return `(label, root)`
488/// where `root` is the path truncated at the end of the marker directory (no
489/// trailing slash). resolve_path uses this to auto-register a session read-only
490/// root so the retry resolves without a config edit or a subprocess (#899).
491pub fn detect_language_cache_root(candidate: &Path) -> Option<(&'static str, PathBuf)> {
492    let s = candidate.to_string_lossy().replace('\\', "/");
493    for &(marker, label, _) in LANGUAGE_CACHE_PATTERNS {
494        if let Some(idx) = s.find(marker) {
495            let end = idx + marker.len() - 1; // keep the marker dir, drop trailing '/'
496            return Some((label, PathBuf::from(&s[..end])));
497        }
498    }
499    None
500}
501
502/// Like [`jail_path`], but also accepts paths under any of `extra_roots`.
503///
504/// `extra_roots` are session-scoped trusted roots (MCP `roots/list` and config
505/// `extra_roots`, surfaced via `session.extra_roots`) — e.g. sibling git
506/// worktrees the agent legitimately spans. They widen the allow-list for *this
507/// call only*, so an explicit `path` under a worktree resolves instead of
508/// failing with "path escapes project root", without loosening the global jail
509/// (#403). `path_jail = false` still bypasses entirely and an empty slice is
510/// byte-for-byte identical to the old single-root behaviour.
511pub fn jail_path_with_roots(
512    candidate: &Path,
513    jail_root: &Path,
514    extra_roots: &[String],
515) -> Result<PathBuf, PathJailError> {
516    if candidate.to_string_lossy().as_bytes().contains(&0) {
517        return Err(PathJailError::NullByte);
518    }
519
520    #[cfg(feature = "no-jail")]
521    {
522        let _ = (jail_root, extra_roots);
523        return Ok(canonicalize_or_self(candidate));
524    }
525
526    #[allow(unreachable_code)]
527    {
528        let cfg = crate::core::config::Config::load();
529        if cfg.path_jail == Some(false) {
530            return Ok(canonicalize_or_self(candidate));
531        }
532
533        let root = canonicalize_secure(jail_root);
534
535        // Resolve relative candidates against the (absolute) jail root — never the process
536        // CWD. The daemon's CWD is not the project, so CWD-relative resolution made
537        // graph-relative paths (e.g. auto-preload candidates like `rust/src/core/foo.rs`)
538        // spuriously fail with "no existing ancestor". Absolute candidates are unchanged.
539        let resolved: PathBuf;
540        let candidate: &Path = if candidate.is_absolute() {
541            candidate
542        } else {
543            resolved = root.join(candidate);
544            resolved.as_path()
545        };
546
547        let mut allow = allow_paths_from_env_and_config();
548        // Session-scoped roots widen the allow-list for this call only.
549        allow.extend(
550            extra_roots
551                .iter()
552                .filter(|r| !r.is_empty())
553                .map(|r| canonicalize_secure(Path::new(r))),
554        );
555
556        // #820: lean-ctx's own state dir (tee files, artifacts, tool-results)
557        // must be readable even when outside the project root. ctx_shell
558        // tells agents to read tee-file paths, so the jail must allow them.
559        if let Ok(state) = crate::core::paths::state_dir() {
560            allow.push(canonicalize_secure(&state));
561        }
562        // Read-only roots are also allowed for reads (they only block writes
563        // via enforce_writable, not reads via the jail).
564        allow.extend(
565            read_only_roots_from_env_and_config()
566                .into_iter()
567                .map(|p| canonicalize_secure(&p)),
568        );
569
570        let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
571            PathJailError::NoExistingAncestor {
572                path: candidate.to_path_buf(),
573            }
574        })?;
575
576        let allowed = path_allowed_by_jail(&base, &root, &allow);
577
578        if !allowed {
579            let mut hint = if crate::core::protocol::meta_visible() {
580                let dir = candidate.parent().unwrap_or(candidate).display();
581                format!(
582                    ". Hint: set LEAN_CTX_ALLOW_PATH={dir} for read-write access \
583                     (colon-separated for multiple: /path/a:/path/b), \
584                     LEAN_CTX_READ_ONLY_ROOTS={dir} for read-only, \
585                     or add entries to allow_paths = [\"{dir}\"] or extra_roots = [\"{dir}\"] \
586                     in ~/.config/lean-ctx/config.toml"
587                )
588            } else {
589                // Agents otherwise get a bare rejection and shell out to work
590                // around the jail; name the config keys the same way the
591                // shell-allowlist block message names its key. The env-var and
592                // config-path detail above stays meta-gated (#540, #887).
593                ". Fix (additive): add the directory to extra_roots or allow_paths in \
594                 ~/.config/lean-ctx/config.toml — `lean-ctx doctor` shows the config in effect"
595                    .to_string()
596            };
597            // An untrusted workspace's project-local `allow_paths` is silently
598            // withheld; always surface that reason (the stderr warning is
599            // invisible over MCP, and the hint above is meta-gated off) (#540).
600            if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
601                hint.push_str(". ");
602                hint.push_str(&notice);
603            }
604            // The global config the runtime reads doesn't exist → on defaults, so
605            // an `allow_paths` edit made to a config.toml elsewhere (XDG vs legacy
606            // dir, or a sandboxed/container HOME) is never seen (#540).
607            if let Some(missing) = crate::core::config::Config::missing_config_path() {
608                hint.push_str(&format!(
609                    ". ⚠ lean-ctx reads no config file at {} (running on defaults) — an \
610                     allow_paths edit in a config.toml elsewhere is not read; \
611                     `lean-ctx doctor` shows the path in effect",
612                    missing.display()
613                ));
614            }
615            if let Some(cache_hint) = detected_cache_hint(candidate) {
616                hint.push_str(&cache_hint);
617            }
618            return Err(PathJailError::EscapesRoot {
619                path: candidate.to_path_buf(),
620                root,
621                hint,
622            });
623        }
624
625        #[cfg(windows)]
626        reject_symlink_on_windows(candidate)?;
627
628        let mut out = base;
629        for part in remainder.iter().rev() {
630            out.push(part);
631        }
632
633        // Re-validate after reconstruction: if the final path exists, canonicalize
634        // and re-check to close TOCTOU window (symlink created between check and use).
635        if out.exists() {
636            let final_canon = canonicalize_secure(&out);
637            let final_ok = path_allowed_by_jail(&final_canon, &root, &allow);
638            if !final_ok {
639                return Err(PathJailError::PostCanonicalizeEscape {
640                    path: candidate.to_path_buf(),
641                    resolved: final_canon,
642                });
643            }
644        }
645
646        Ok(out)
647    }
648}
649
650#[cfg(windows)]
651fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
652    let path_str = normalize_windows_path(&path.to_string_lossy());
653    let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
654    path_str.starts_with(&prefix_str)
655}
656
657#[cfg(windows)]
658fn normalize_windows_path(s: &str) -> String {
659    let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
660    stripped.to_lowercase().replace('/', "\\")
661}
662
663#[cfg(windows)]
664fn reject_symlink_on_windows(path: &Path) -> Result<(), PathJailError> {
665    if let Ok(meta) = std::fs::symlink_metadata(path) {
666        // Junctions and other reparse points redirect like symlinks but are
667        // invisible to `is_symlink()` — reject them too (GL#442).
668        if super::pathutil::is_symlink_or_reparse(&meta) {
669            return Err(PathJailError::Symlink {
670                path: path.to_path_buf(),
671            });
672        }
673    }
674    Ok(())
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    #[cfg(not(feature = "no-jail"))]
682    #[test]
683    fn rejects_path_outside_root() {
684        // Hermetic config (empty data dir => jail on) so a parallel test that
685        // flips `path_jail` cannot leak into this enforcement check. The guard
686        // holds the global test_env_lock, which also serializes against every
687        // `LEAN_CTX_ALLOW_PATH` mutation (all of them go through that lock).
688        let _iso = crate::core::data_dir::isolated_data_dir();
689        let tmp = tempfile::tempdir().unwrap();
690        let root = tmp.path().join("root");
691        let other = tmp.path().join("other");
692        std::fs::create_dir_all(&root).unwrap();
693        std::fs::create_dir_all(&other).unwrap();
694        std::fs::write(root.join("a.txt"), "ok").unwrap();
695        std::fs::write(other.join("b.txt"), "no").unwrap();
696
697        let ok = jail_path(&root.join("a.txt"), &root);
698        assert!(ok.is_ok());
699
700        let bad = jail_path(&other.join("b.txt"), &root);
701        assert!(bad.is_err());
702    }
703
704    /// #475: a configured read-only root is readable but never writable. Reads
705    /// resolve (the root joins the allow-list like an extra_root), while the
706    /// single write choke point `enforce_writable` default-denies every write
707    /// inside it — including a not-yet-existing file, which inherits the
708    /// directory's read-only status. `isolated_data_dir` holds `test_env_lock`,
709    /// serialising the `LEAN_CTX_READ_ONLY_ROOTS` mutation against other tests.
710    #[cfg(not(feature = "no-jail"))]
711    #[test]
712    fn read_only_roots_deny_writes_but_allow_reads() {
713        // `isolated_data_dir` already holds `test_env_lock` for its lifetime —
714        // taking it again here would self-deadlock on a non-reentrant Mutex.
715        let _iso = crate::core::data_dir::isolated_data_dir();
716
717        let tmp = tempfile::tempdir().unwrap();
718        let project = tmp.path().join("project");
719        let refrepo = tmp.path().join("refrepo");
720        std::fs::create_dir_all(&project).unwrap();
721        std::fs::create_dir_all(refrepo.join("sub")).unwrap();
722        std::fs::write(refrepo.join("lib.rs"), "pub fn x() {}\n").unwrap();
723
724        // Canonicalize the configured root the same (symlink-resolving) way the
725        // guard does, so macOS /var → /private/var can't defeat the prefix match.
726        let ro_canon = canonicalize_secure(&refrepo);
727        crate::test_env::set_var(
728            "LEAN_CTX_READ_ONLY_ROOTS",
729            ro_canon.to_string_lossy().as_ref(),
730        );
731
732        let existing = refrepo.join("lib.rs");
733        let new_file = refrepo.join("sub").join("new.rs");
734        let proj_file = project.join("main.rs");
735
736        // Capture every decision while the env is live (it is cleared below).
737        let read_existing = jail_path(&existing, &project);
738        let deny_existing = enforce_writable(&existing);
739        let deny_new = enforce_writable(&new_file);
740        let allow_project = enforce_writable(&proj_file);
741        let ro_existing = is_read_only_path(&existing);
742        let ro_project = is_read_only_path(&proj_file);
743
744        crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
745
746        assert!(
747            deny_existing.is_err(),
748            "write to an existing file in a read-only root must be denied"
749        );
750        assert!(
751            deny_new.is_err(),
752            "creating a new file in a read-only root must be denied"
753        );
754        assert!(
755            allow_project.is_ok(),
756            "writes into the project root must stay allowed: {allow_project:?}"
757        );
758        assert!(
759            read_existing.is_ok(),
760            "reads inside a read-only root must resolve (read allow-list): {read_existing:?}"
761        );
762        assert!(ro_existing, "the file is inside the read-only root");
763        assert!(!ro_project, "the project file is not read-only");
764    }
765
766    /// #406 regression: a long-lived process (the MCP server) must honor
767    /// `path_jail = false` written to config after startup. The config cache is
768    /// now keyed on content, so even an edit that preserves the file mtime takes
769    /// effect — a path outside the jail root is accepted once the flag flips.
770    /// (With the former mtime-only cache the stale `None` kept the jail on.)
771    #[cfg(not(feature = "no-jail"))]
772    #[test]
773    fn honors_path_jail_false_after_mtime_preserving_edit() {
774        let _iso = crate::core::data_dir::isolated_data_dir();
775        let cfg_path = crate::core::config::Config::path().unwrap();
776        if let Some(parent) = cfg_path.parent() {
777            std::fs::create_dir_all(parent).unwrap();
778        }
779
780        let tmp = tempfile::tempdir().unwrap();
781        let root = tmp.path().join("project");
782        let outside = tmp.path().join("outside");
783        std::fs::create_dir_all(&root).unwrap();
784        std::fs::create_dir_all(&outside).unwrap();
785        let secret = outside.join("secret.txt");
786        std::fs::write(&secret, "x").unwrap();
787
788        // Warm the config cache with the jail on (no path_jail key).
789        std::fs::write(&cfg_path, "# jail on\n").unwrap();
790        let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
791        assert_eq!(crate::core::config::Config::load().path_jail, None);
792
793        // Flip path_jail=false but restore the original mtime, so any mtime-only
794        // cache would keep serving the stale jail-on value.
795        std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
796        filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
797
798        assert!(
799            jail_path(&secret, &root).is_ok(),
800            "path_jail=false must take effect without a fresh process (#406)"
801        );
802    }
803
804    #[test]
805    fn allows_nonexistent_child_under_root() {
806        let tmp = tempfile::tempdir().unwrap();
807        let root = tmp.path().join("root");
808        std::fs::create_dir_all(&root).unwrap();
809        std::fs::write(root.join("a.txt"), "ok").unwrap();
810
811        let p = root.join("new").join("file.txt");
812        let ok = jail_path(&p, &root).unwrap();
813        assert!(ok.to_string_lossy().contains("file.txt"));
814    }
815
816    #[cfg(not(feature = "no-jail"))]
817    #[test]
818    fn relative_candidate_resolves_against_root_not_cwd() {
819        // Regression: in the daemon (CWD != project) a relative graph path like
820        // `sub/file.rs` must resolve under the jail root, not the process CWD.
821        let _iso = crate::core::data_dir::isolated_data_dir();
822        let tmp = tempfile::tempdir().unwrap();
823        let root = tmp.path().join("project");
824        std::fs::create_dir_all(root.join("sub")).unwrap();
825        std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
826
827        let jailed = jail_path(Path::new("sub/file.rs"), &root)
828            .expect("relative candidate should resolve under the jail root");
829        assert!(jailed.ends_with("sub/file.rs"));
830        assert!(
831            is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
832            "resolved path must live under the jail root: {jailed:?}"
833        );
834    }
835
836    #[test]
837    fn ide_allow_dirs_are_registry_derived_and_skip_home() {
838        use crate::core::editor_registry::{ConfigType, EditorTarget};
839
840        let home = tempfile::tempdir().unwrap();
841        let h = home.path();
842        // VS Code keeps its config outside a dotfile dir — the old hard-coded
843        // list missed this entirely.
844        std::fs::create_dir_all(h.join("Library/Application Support/Code/User")).unwrap();
845        std::fs::create_dir_all(h.join(".cursor")).unwrap();
846
847        let targets = vec![
848            EditorTarget {
849                name: "VS Code",
850                agent_key: "vscode".into(),
851                config_path: h.join("Library/Application Support/Code/User/mcp.json"),
852                detect_path: h.join("Library/Application Support/Code"),
853                config_type: ConfigType::VsCodeMcp,
854            },
855            EditorTarget {
856                name: "Cursor",
857                agent_key: "cursor".into(),
858                config_path: h.join(".cursor/mcp.json"),
859                detect_path: h.join(".cursor"),
860                config_type: ConfigType::McpJson,
861            },
862            // A $HOME-level config file: its parent is $HOME and must be skipped.
863            EditorTarget {
864                name: "Claude Code",
865                agent_key: "claude".into(),
866                config_path: h.join(".claude.json"),
867                detect_path: h.join(".no-such-dir"),
868                config_type: ConfigType::McpJson,
869            },
870        ];
871
872        let mut out = Vec::new();
873        collect_ide_allow_dirs(h, &targets, &mut out);
874
875        assert!(
876            out.iter().any(|p| p.ends_with("Code/User")),
877            "non-dotfile VS Code dir must be covered: {out:?}"
878        );
879        assert!(out.iter().any(|p| p.ends_with(".cursor")), "{out:?}");
880        let home_canon = canonicalize_secure(h);
881        assert!(
882            !out.contains(&home_canon),
883            "must never widen the jail to $HOME: {out:?}"
884        );
885    }
886
887    // P0-10 (#422): foreign editor config dirs are opt-in. lean-ctx's own state
888    // dir is added by the caller via the data_dir root, NOT by `home_allow_dirs`,
889    // so the default home allow-list is empty and `~/.lean-ctx` (not an editor)
890    // never appears here.
891    #[test]
892    fn ide_config_dirs_are_excluded_by_default() {
893        let home = tempfile::tempdir().unwrap();
894        for d in [".lean-ctx", ".cursor", ".codex"] {
895            std::fs::create_dir_all(home.path().join(d)).unwrap();
896        }
897
898        let denied = home_allow_dirs(home.path(), false);
899        assert!(
900            denied.is_empty(),
901            "foreign editor dirs must stay jailed by default: {denied:?}"
902        );
903
904        // Opt-in exposes the editor dirs that actually exist under this home.
905        // Entries are registry-derived (foreign real-$HOME paths are filtered out
906        // by the in-home guard), so the result stays hermetic — and `~/.lean-ctx`
907        // is never added here because it is not an editor.
908        let allowed = home_allow_dirs(home.path(), true);
909        assert!(
910            allowed.iter().any(|p| p.ends_with(".cursor")),
911            "opt-in must expose editor dirs: {allowed:?}"
912        );
913        assert!(
914            !allowed.iter().any(|p| p.ends_with(".lean-ctx")),
915            "lean-ctx's own dir is covered by the data_dir root, not home_allow_dirs: {allowed:?}"
916        );
917    }
918
919    #[test]
920    fn canonicalize_or_self_strips_verbatim() {
921        let tmp = tempfile::tempdir().unwrap();
922        let dir = tmp.path().join("project");
923        std::fs::create_dir_all(&dir).unwrap();
924
925        let result = canonicalize_or_self(&dir);
926        let s = result.to_string_lossy();
927        assert!(
928            !s.starts_with(r"\\?\"),
929            "canonicalize_or_self should strip verbatim prefix, got: {s}"
930        );
931    }
932
933    #[test]
934    fn jail_path_accepts_same_dir_different_format() {
935        let tmp = tempfile::tempdir().unwrap();
936        let root = tmp.path().join("project");
937        std::fs::create_dir_all(&root).unwrap();
938        std::fs::write(root.join("file.rs"), "ok").unwrap();
939
940        let result = jail_path(&root.join("file.rs"), &root);
941        assert!(result.is_ok(), "same dir should be accepted: {result:?}");
942    }
943
944    #[cfg(not(feature = "no-jail"))]
945    #[test]
946    fn error_message_contains_escape_info() {
947        // isolated_data_dir holds the global test_env_lock, serializing this
948        // against any parallel `LEAN_CTX_ALLOW_PATH="/"` mutation.
949        let _iso = crate::core::data_dir::isolated_data_dir();
950        let tmp = tempfile::tempdir().unwrap();
951        let root = tmp.path().join("root");
952        let other = tmp.path().join("other");
953        std::fs::create_dir_all(&root).unwrap();
954        std::fs::create_dir_all(&other).unwrap();
955        std::fs::write(other.join("b.txt"), "no").unwrap();
956
957        let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
958        assert!(
959            err.to_string().contains("path escapes project root"),
960            "error should mention escape: {err}"
961        );
962    }
963
964    // GH #887: over MCP (meta hints gated off) the rejection was bare, so
965    // agents shelled out to work around the jail instead of widening it via
966    // config. The agent-visible error must name the sanctioned config keys.
967    #[cfg(not(feature = "no-jail"))]
968    #[test]
969    fn escape_error_names_config_keys_without_meta() {
970        let _iso = crate::core::data_dir::isolated_data_dir();
971        crate::test_env::remove_var("LEAN_CTX_META");
972        crate::test_env::remove_var("LEAN_CTX_DIAGNOSTICS");
973        let tmp = tempfile::tempdir().unwrap();
974        let root = tmp.path().join("root");
975        let other = tmp.path().join("other");
976        std::fs::create_dir_all(&root).unwrap();
977        std::fs::create_dir_all(&other).unwrap();
978        std::fs::write(other.join("b.txt"), "no").unwrap();
979
980        let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
981        let msg = err.to_string();
982        assert!(
983            msg.contains("extra_roots") && msg.contains("allow_paths"),
984            "agent-visible escape error should name the config keys: {msg}"
985        );
986    }
987
988    // GH #392: config entries like "$HOME/code" or "~/code" were taken
989    // literally and never matched.
990    #[test]
991    fn expand_user_path_expands_tilde_and_vars() {
992        let _env_lock = crate::core::data_dir::test_env_lock();
993        let home = dirs::home_dir().expect("home dir");
994        let home_s = home.to_string_lossy().to_string();
995
996        assert_eq!(expand_user_path("~"), home);
997        assert_eq!(expand_user_path("~/code"), home.join("code"));
998        assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
999        assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
1000        // Multiple variables in one entry.
1001        crate::test_env::set_var("LEAN_CTX_TEST_SUB", "sub");
1002        assert_eq!(
1003            expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
1004            PathBuf::from(format!("{home_s}/sub/x"))
1005        );
1006        crate::test_env::remove_var("LEAN_CTX_TEST_SUB");
1007        // Absolute paths pass through untouched.
1008        assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
1009    }
1010
1011    #[test]
1012    fn expand_user_path_leaves_unset_vars_verbatim() {
1013        let _env_lock = crate::core::data_dir::test_env_lock();
1014        crate::test_env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
1015        let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
1016        assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
1017    }
1018
1019    // GH #392: `allow_paths = ["/"]` (via the same env-var channel) must grant
1020    // access to any absolute path — "/" is a prefix of everything.
1021    //
1022    // Env-mutating tests here hold the process-global
1023    // `data_dir::test_env_lock()` (directly, or via `isolated_data_dir()`
1024    // which wraps it) — NOT a module-local mutex. test_env's SAFETY contract
1025    // says *all* test env mutation serializes through that one lock; a local
1026    // lock only serializes this module against itself, so e.g.
1027    // `artifacts::external_corpus_requires_allow_list` (which holds the
1028    // global lock) could observe this test's `LEAN_CTX_ALLOW_PATH="/"` and
1029    // fail its jail-rejection assert (the pre-existing parallel-run flake
1030    // reported in #695).
1031    #[cfg(unix)]
1032    #[test]
1033    fn allow_path_root_slash_permits_everything() {
1034        let _guard = crate::core::data_dir::test_env_lock();
1035        let tmp = tempfile::tempdir().unwrap();
1036        let root = tmp.path().join("root");
1037        let other = tmp.path().join("other");
1038        std::fs::create_dir_all(&root).unwrap();
1039        std::fs::create_dir_all(&other).unwrap();
1040        std::fs::write(other.join("b.txt"), "allowed").unwrap();
1041
1042        crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/");
1043        let result = jail_path(&other.join("b.txt"), &root);
1044        crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
1045
1046        assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
1047    }
1048
1049    // Finding 3 (GH security audit): env-channel jail relaxations must be
1050    // detectable so startup + doctor can surface them loudly.
1051    #[test]
1052    fn active_relaxations_detects_allow_path_env() {
1053        let _iso = crate::core::data_dir::isolated_data_dir();
1054        crate::test_env::remove_var("LEAN_CTX_EXTRA_ROOTS");
1055        crate::test_env::remove_var("LEAN_CTX_ALLOW_IDE_DIRS");
1056        crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/tmp");
1057
1058        let relaxed = active_relaxations();
1059
1060        crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
1061
1062        assert!(
1063            relaxed.iter().any(|r| r.source == "LEAN_CTX_ALLOW_PATH"),
1064            "LEAN_CTX_ALLOW_PATH must be reported as a jail relaxation: {relaxed:?}"
1065        );
1066    }
1067
1068    #[cfg(not(feature = "no-jail"))]
1069    #[test]
1070    fn active_relaxations_empty_when_jail_intact() {
1071        let _iso = crate::core::data_dir::isolated_data_dir();
1072        for var in [
1073            "LEAN_CTX_ALLOW_PATH",
1074            "LCTX_ALLOW_PATH",
1075            "LEAN_CTX_EXTRA_ROOTS",
1076            "LEAN_CTX_ALLOW_IDE_DIRS",
1077        ] {
1078            crate::test_env::remove_var(var);
1079        }
1080
1081        assert!(
1082            active_relaxations().is_empty(),
1083            "an intact jail (clean config, no relaxation env) must report no relaxations: {:?}",
1084            active_relaxations()
1085        );
1086    }
1087
1088    #[test]
1089    fn allow_path_env_permits_outside_root() {
1090        let _guard = crate::core::data_dir::test_env_lock();
1091        let tmp = tempfile::tempdir().unwrap();
1092        let root = tmp.path().join("root");
1093        let other = tmp.path().join("other");
1094        std::fs::create_dir_all(&root).unwrap();
1095        std::fs::create_dir_all(&other).unwrap();
1096        std::fs::write(other.join("b.txt"), "allowed").unwrap();
1097
1098        let canon = canonicalize_or_self(&other);
1099        crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
1100        let result = jail_path(&other.join("b.txt"), &root);
1101        crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
1102
1103        assert!(
1104            result.is_ok(),
1105            "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
1106        );
1107    }
1108
1109    #[cfg(all(unix, not(feature = "no-jail")))]
1110    #[test]
1111    fn rejects_symlink_escape_on_unix() {
1112        use std::os::unix::fs::symlink;
1113
1114        // isolated_data_dir holds the global test_env_lock — no parallel test
1115        // can set `LEAN_CTX_ALLOW_PATH="/"` and let this escape resolve.
1116        let _iso = crate::core::data_dir::isolated_data_dir();
1117        let tmp = tempfile::tempdir().unwrap();
1118        let root = tmp.path().join("root");
1119        let other = tmp.path().join("other");
1120        std::fs::create_dir_all(&root).unwrap();
1121        std::fs::create_dir_all(&other).unwrap();
1122        std::fs::write(other.join("secret.txt"), "no").unwrap();
1123
1124        let link = root.join("link.txt");
1125        symlink(other.join("secret.txt"), &link).unwrap();
1126
1127        let bad = jail_path(&link, &root);
1128        assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
1129    }
1130
1131    #[test]
1132    fn rejects_null_byte_in_path() {
1133        let tmp = tempfile::tempdir().unwrap();
1134        let root = tmp.path().join("root");
1135        std::fs::create_dir_all(&root).unwrap();
1136
1137        let bad_path = PathBuf::from("file\0.txt");
1138        let result = jail_path(&bad_path, &root);
1139        assert!(result.is_err(), "null byte in path must be rejected");
1140        assert!(
1141            result.unwrap_err().to_string().contains("null byte"),
1142            "error must mention null byte"
1143        );
1144    }
1145
1146    /// #403 Bug 1: an explicit path under a session-scoped `extra_root` (e.g. a
1147    /// sibling git worktree from MCP `roots/list`) must resolve, while the same
1148    /// path is rejected without it — and a path under *no* root is rejected even
1149    /// when extra roots are present. Holds both env locks so neither a parallel
1150    /// `path_jail` flip nor a `LEAN_CTX_ALLOW_PATH` mutation can leak in.
1151    #[cfg(not(feature = "no-jail"))]
1152    #[test]
1153    fn extra_roots_permit_paths_outside_jail() {
1154        let _iso = crate::core::data_dir::isolated_data_dir();
1155
1156        let tmp = tempfile::tempdir().unwrap();
1157        let root = tmp.path().join("project");
1158        let worktree = tmp.path().join("worktree");
1159        let elsewhere = tmp.path().join("elsewhere");
1160        for d in [&root, &worktree, &elsewhere] {
1161            std::fs::create_dir_all(d).unwrap();
1162        }
1163        let in_worktree = worktree.join("a.txt");
1164        std::fs::write(&in_worktree, "x").unwrap();
1165        let outside = elsewhere.join("b.txt");
1166        std::fs::write(&outside, "y").unwrap();
1167
1168        // Parity: with no extra roots, the worktree path escapes the jail.
1169        assert!(jail_path(&in_worktree, &root).is_err());
1170        assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
1171
1172        // The session-scoped extra root permits it — via the slice alone, with
1173        // nothing in env/config.
1174        let extra = vec![worktree.to_string_lossy().to_string()];
1175        assert!(
1176            jail_path_with_roots(&in_worktree, &root, &extra).is_ok(),
1177            "path under a session extra_root must resolve (#403)"
1178        );
1179
1180        // A path under neither the jail nor any extra root is still rejected.
1181        assert!(
1182            jail_path_with_roots(&outside, &root, &extra).is_err(),
1183            "paths outside ALL roots must still be rejected"
1184        );
1185
1186        // Empty entries are ignored (no accidental allow-all).
1187        assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
1188    }
1189
1190    /// GH #1228: Claude Code auto-memory under `…/.claude/projects/<slug>/memory/`
1191    /// must be readable/writable via ctx_* without a manual extra_roots edit.
1192    #[cfg(not(feature = "no-jail"))]
1193    #[test]
1194    fn harness_auto_memory_path_is_allowed_without_extra_roots() {
1195        let _iso = crate::core::data_dir::isolated_data_dir();
1196        let tmp = tempfile::tempdir().unwrap();
1197        let root = tmp.path().join("project");
1198        let memory = tmp
1199            .path()
1200            .join(".claude")
1201            .join("projects")
1202            .join("-tmp-project")
1203            .join("memory");
1204        std::fs::create_dir_all(&root).unwrap();
1205        std::fs::create_dir_all(&memory).unwrap();
1206        let mem_file = memory.join("MEMORY.md");
1207        std::fs::write(&mem_file, "# index\n").unwrap();
1208
1209        assert!(is_harness_auto_memory_path(&mem_file));
1210        assert!(is_harness_auto_memory_path(&memory));
1211        assert!(!is_harness_auto_memory_path(
1212            &tmp.path()
1213                .join(".claude")
1214                .join("projects")
1215                .join("-tmp-project")
1216                .join("session.jsonl")
1217        ));
1218
1219        assert!(
1220            jail_path_with_roots(&mem_file, &root, &[]).is_ok(),
1221            "auto-memory file must pass PathJail without extra_roots"
1222        );
1223    }
1224
1225    /// #820: lean-ctx state dir (tee files) is implicitly allowed by the jail.
1226    #[test]
1227    fn state_dir_tee_files_pass_jail() {
1228        let _lock = crate::core::data_dir::test_env_lock();
1229        let state = crate::core::paths::state_dir().expect("state_dir must be available");
1230        let tee_path = state.join("tee").join("some_command_deadbeef.log");
1231        // Use a root that is clearly NOT the state dir's parent
1232        let fake_root = std::env::temp_dir().join("pathjail_test_820_root");
1233        std::fs::create_dir_all(&fake_root).ok();
1234        // The tee path is outside the fake root, but the state dir allowance
1235        // should make it pass (the state dir itself exists on disk).
1236        let result = jail_path_with_roots(&tee_path, &fake_root, &[]);
1237        // If state_dir exists on disk (it does in dev), the path should be allowed.
1238        // If the tee file itself doesn't exist, canonicalize_existing_ancestor
1239        // resolves to the state_dir (which does exist) + remainder.
1240        if state.exists() {
1241            assert!(
1242                result.is_ok(),
1243                "tee-file path under lean-ctx state dir must be auto-allowed: {result:?}"
1244            );
1245        }
1246        std::fs::remove_dir_all(&fake_root).ok();
1247    }
1248
1249    #[test]
1250    fn detected_cache_hint_recognizes_go_cargo_python() {
1251        use std::path::Path;
1252        let go = detected_cache_hint(Path::new("/Users/x/go/pkg/mod/github.com/foo/bar/main.go"));
1253        assert!(go.is_some(), "Go module cache should be detected");
1254        assert!(go.unwrap().contains("Go module cache"));
1255
1256        let cargo = detected_cache_hint(Path::new(
1257            "/home/x/.cargo/registry/src/crates.io/serde-1.0/lib.rs",
1258        ));
1259        assert!(cargo.is_some(), "Rust cargo registry should be detected");
1260        assert!(cargo.unwrap().contains("Rust crate registry"));
1261
1262        let py = detected_cache_hint(Path::new(
1263            "/usr/lib/python3.12/site-packages/requests/api.py",
1264        ));
1265        assert!(py.is_some(), "Python site-packages should be detected");
1266
1267        let normal = detected_cache_hint(Path::new("/home/x/projects/myapp/src/main.rs"));
1268        assert!(normal.is_none(), "Normal project path should not match");
1269    }
1270
1271    #[test]
1272    fn detect_cache_root_extracts_marker_dir() {
1273        let cases = [
1274            (
1275                "/Users/x/go/pkg/mod/github.com/foo/bar@v1.2.3/baz.go",
1276                "Go module cache",
1277                "/Users/x/go/pkg/mod",
1278            ),
1279            (
1280                "/home/u/.cargo/registry/src/index-abc/serde-1.0/src/lib.rs",
1281                "Rust crate registry",
1282                "/home/u/.cargo/registry",
1283            ),
1284            (
1285                "/opt/venv/lib/python3.12/site-packages/requests/api.py",
1286                "Python site-packages",
1287                "/opt/venv/lib/python3.12/site-packages",
1288            ),
1289            (
1290                "/w/app/node_modules/react/index.js",
1291                "Node modules",
1292                "/w/app/node_modules",
1293            ),
1294        ];
1295        for (path, want_label, want_root) in cases {
1296            let (label, root) = detect_language_cache_root(Path::new(path))
1297                .unwrap_or_else(|| panic!("expected cache match for {path}"));
1298            assert_eq!(label, want_label, "label for {path}");
1299            assert_eq!(root, PathBuf::from(want_root), "root for {path}");
1300        }
1301        assert!(
1302            detect_language_cache_root(Path::new("/home/u/proj/src/main.rs")).is_none(),
1303            "a normal project path is not a cache"
1304        );
1305    }
1306
1307    /// The core #899 guarantee: once a detected cache root is registered, a path
1308    /// under it *reads* (jail resolves) but never *writes* (enforce_writable
1309    /// denies), and registration is idempotent.
1310    #[cfg(not(feature = "no-jail"))]
1311    #[test]
1312    fn registered_cache_root_reads_allow_writes_deny() {
1313        let _iso = crate::core::data_dir::isolated_data_dir();
1314
1315        let tmp = tempfile::tempdir().unwrap();
1316        // A fake Go module cache so detect_language_cache_root matches the path.
1317        let dep = tmp.path().join("go/pkg/mod/example.com/lib@v1");
1318        std::fs::create_dir_all(&dep).unwrap();
1319        let file = dep.join("lib.go");
1320        std::fs::write(&file, "package lib").unwrap();
1321
1322        // A project jail that does NOT contain the cache.
1323        let project = tmp.path().join("project");
1324        std::fs::create_dir_all(&project).unwrap();
1325
1326        // Before registration: the read escapes the jail.
1327        assert!(jail_path_with_roots(&file, &project, &[]).is_err());
1328
1329        // Register the detected root; the second call is a no-op.
1330        let (_, root) = detect_language_cache_root(&file).expect("cache match");
1331        assert!(
1332            register_session_read_only_root(&root),
1333            "first register is new"
1334        );
1335        assert!(
1336            !register_session_read_only_root(&root),
1337            "re-register is a no-op"
1338        );
1339
1340        // After: the read resolves, but writes are denied (read-only tier).
1341        assert!(
1342            jail_path_with_roots(&file, &project, &[]).is_ok(),
1343            "registered cache root must be readable"
1344        );
1345        assert!(is_read_only_path(&file), "cache file is read-only");
1346        assert!(
1347            enforce_writable(&file).is_err(),
1348            "writes into the cache root must be denied"
1349        );
1350    }
1351}