Skip to main content

lean_ctx/core/
pathjail.rs

1use std::path::{Path, PathBuf};
2
3use crate::core::error::PathJailError;
4
5pub use crate::core::pathjail_write::{rebuild_below, write_below};
6
7/// `allow_paths` / `extra_roots` come from `config.toml`, where no shell ever
8/// runs — users writing `"$HOME/code"` or `"~/code"` got a literal,
9/// never-matching prefix and concluded the whole option was broken (GH #392).
10/// Unset variables are left verbatim (and warned about) so the entry fails
11/// loudly in `lean-ctx doctor` instead of silently matching something else.
12/// `HOME` falls back to the platform home directory when the environment
13/// variable is absent, as is common in native Windows shells.
14pub fn expand_user_path(raw: &str) -> PathBuf {
15    let mut s = raw.to_string();
16
17    if (s == "~" || s.starts_with("~/"))
18        && let Some(home) = dirs::home_dir()
19    {
20        s = format!("{}{}", home.to_string_lossy(), &s[1..]);
21    }
22
23    while let Some(start) = s.find('$') {
24        let rest = &s[start + 1..];
25        let (name, token_len) = if let Some(stripped) = rest.strip_prefix('{') {
26            match stripped.find('}') {
27                Some(end) => (stripped[..end].to_string(), end + 3),
28                None => break,
29            }
30        } else {
31            let end = rest
32                .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
33                .unwrap_or(rest.len());
34            (rest[..end].to_string(), end + 1)
35        };
36        if name.is_empty() {
37            break;
38        }
39        if let Ok(val) = std::env::var(&name) {
40            s.replace_range(start..start + token_len, &val);
41        } else if name == "HOME"
42            && let Some(home) = dirs::home_dir()
43        {
44            let home = home.to_string_lossy();
45            s.replace_range(start..start + token_len, home.as_ref());
46        } else {
47            tracing::warn!(
48                "allow_paths/extra_roots entry '{raw}' references unset variable ${name} — entry will never match"
49            );
50            break;
51        }
52    }
53
54    PathBuf::from(s)
55}
56
57pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
58    let mut out = Vec::new();
59    let cfg = crate::core::config::Config::load();
60
61    // The allow-list defines the jail boundary, so it must be canonicalized the
62    // same (security, symlink-resolving) way as the candidate it is compared
63    // against — otherwise a guarded (lexical) root vs a resolved candidate would
64    // break `is_under_prefix`. These entries are data_dir / IDE-config dirs /
65    // user-configured paths, virtually never under ~/Documents.
66    //
67    // This is also lean-ctx's own state dir (sessions, knowledge, …) — always
68    // readable even while foreign editor dirs stay jailed. On a legacy install
69    // the resolver returns `~/.lean-ctx`; on a split install it returns the XDG
70    // data dir. Going through the resolver (not a hardcoded `~/.lean-ctx` join)
71    // is what keeps `home_allow_dirs` free of the legacy-path firewall trip.
72    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
73        out.push(canonicalize_secure(&data_dir));
74    }
75
76    if let Some(home) = dirs::home_dir() {
77        let ide_dirs_allowed = cfg.allow_ide_config_dirs.unwrap_or(false)
78            || std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
79        out.extend(home_allow_dirs(&home, ide_dirs_allowed));
80    }
81
82    for p in &cfg.allow_paths {
83        out.push(canonicalize_secure(&expand_user_path(p)));
84    }
85    for p in &cfg.extra_roots {
86        out.push(canonicalize_secure(&expand_user_path(p)));
87    }
88
89    for path in crate::core::runtime_flags::allow_paths() {
90        out.push(canonicalize_secure(&path));
91    }
92    // Env entries are expanded too: MCP host configs pass env blocks verbatim
93    // (no shell), so "$HOME/code" arrives literally there as well.
94    let v = std::env::var("LCTX_ALLOW_PATH")
95        .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
96        .unwrap_or_default();
97    if !v.trim().is_empty() {
98        for p in std::env::split_paths(&v) {
99            out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
100        }
101    }
102
103    let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
104    if !extra.trim().is_empty() {
105        for p in std::env::split_paths(&extra) {
106            out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
107        }
108    }
109
110    // Read-only roots are *readable* (the whole point is read access to sibling
111    // repos); writes into them are denied separately by `enforce_writable`
112    // (#475). Add them to the read allow-list so reads resolve, exactly like
113    // `extra_roots`, without granting write access.
114    out.extend(canonicalized_roots(
115        &cfg.read_only_roots,
116        "LEAN_CTX_READ_ONLY_ROOTS",
117    ));
118
119    out
120}
121
122/// Canonicalize a set of config-supplied root entries plus an env override
123/// (path-list separated), expanding `~`/`$VAR` first. Shared by the read
124/// allow-list and the read-only-roots collector so both tiers parse roots
125/// identically.
126fn canonicalized_roots(config_entries: &[String], env_var: &str) -> Vec<PathBuf> {
127    let mut out = Vec::new();
128    for p in config_entries {
129        out.push(canonicalize_secure(&expand_user_path(p)));
130    }
131    let v = std::env::var(env_var).unwrap_or_default();
132    if !v.trim().is_empty() {
133        for p in std::env::split_paths(&v) {
134            out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
135        }
136    }
137    out
138}
139
140/// A read-only root is a sibling subtree the agent may **read** but never
141/// **write** — e.g. a reference repo mounted next to the project. Empty by
142/// default, so [`is_read_only_path`]/[`enforce_writable`] are zero-cost no-ops
143/// for everyone who hasn't opted in (#475).
144pub fn read_only_roots_from_env_and_config() -> Vec<PathBuf> {
145    let cfg = crate::core::config::Config::load();
146    let mut roots = canonicalized_roots(&cfg.read_only_roots, "LEAN_CTX_READ_ONLY_ROOTS");
147    // #899: session-scoped roots auto-detected from language caches. Consulted
148    // by both the read allow-list (jail) and `is_read_only_path` (write-deny),
149    // so a cache root is readable but never writable — exactly like a configured
150    // read-only root, minus the config-file edit.
151    roots.extend(session_read_only_roots());
152    roots
153}
154
155static SESSION_READ_ONLY_ROOTS: std::sync::OnceLock<std::sync::Mutex<Vec<PathBuf>>> =
156    std::sync::OnceLock::new();
157
158fn session_read_only_roots_cell() -> &'static std::sync::Mutex<Vec<PathBuf>> {
159    SESSION_READ_ONLY_ROOTS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
160}
161
162/// The session-scoped read-only roots auto-registered this process (#899).
163pub fn session_read_only_roots() -> Vec<PathBuf> {
164    session_read_only_roots_cell()
165        .lock()
166        .map(|g| g.clone())
167        .unwrap_or_default()
168}
169
170/// Register a session-scoped read-only root (an auto-detected language cache).
171/// Returns `true` when newly added. Idempotent on the canonicalized path.
172///
173/// ponytail: process-global set, not per-session — language caches
174/// (`~/go/pkg/mod`, `~/.cargo/registry`, …) are machine-global read-only dirs,
175/// so sharing read access across sessions grants nothing a session couldn't
176/// already get by reading them; per-session isolation would be plumbing for no
177/// security gain. Upgrade to a session-keyed map only if writable roots ever go
178/// down this path.
179pub fn register_session_read_only_root(root: &Path) -> bool {
180    let canon = canonicalize_secure(root);
181    let mut guard = match session_read_only_roots_cell().lock() {
182        Ok(g) => g,
183        Err(poisoned) => poisoned.into_inner(),
184    };
185    if guard.iter().any(|r| r == &canon) {
186        return false;
187    }
188    guard.push(canon);
189    true
190}
191
192/// A single active relaxation of the path jail. Each one widens or disables what
193/// tools can reach beyond the project root, so it is surfaced loudly (GH security
194/// audit, finding 3): the MCP/HTTP server inherits its process env from the
195/// IDE/launchd, so a globally-set `LEAN_CTX_ALLOW_PATH` / `LEAN_CTX_EXTRA_ROOTS`
196/// / `LEAN_CTX_ALLOW_IDE_DIRS` (or `path_jail = false`) silently loosens the
197/// boundary with no in-band signal otherwise.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub struct JailRelaxation {
200    /// The knob that activated it (env var name, config key, or build feature).
201    pub source: &'static str,
202    /// Human-readable effect of the relaxation.
203    pub detail: &'static str,
204}
205
206fn env_is_set(var: &str) -> bool {
207    std::env::var(var).is_ok_and(|v| !v.trim().is_empty())
208}
209
210/// Collect every currently-active path-jail relaxation. An empty result means
211/// the jail is fully in force. This is the single source of truth shared by the
212/// startup warning ([`warn_if_relaxed`]) and `lean-ctx doctor`.
213#[must_use]
214pub fn active_relaxations() -> Vec<JailRelaxation> {
215    let mut out = Vec::new();
216
217    if cfg!(feature = "no-jail") {
218        out.push(JailRelaxation {
219            source: "no-jail (build feature)",
220            detail: "path jail compiled out — every tool path is allowed",
221        });
222    }
223
224    if crate::core::config::Config::load().path_jail == Some(false) {
225        out.push(JailRelaxation {
226            source: "path_jail = false (config.toml)",
227            detail: "path jail disabled — every tool path is allowed",
228        });
229    }
230
231    if crate::core::runtime_flags::allow_path_enabled() {
232        out.push(JailRelaxation {
233            source: "LEAN_CTX_ALLOW_PATH",
234            detail: "widens the read/write allow-list beyond the project root",
235        });
236    }
237
238    if env_is_set("LEAN_CTX_EXTRA_ROOTS") {
239        out.push(JailRelaxation {
240            source: "LEAN_CTX_EXTRA_ROOTS",
241            detail: "adds extra accessible roots beyond the project root",
242        });
243    }
244
245    let ide_env = std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
246    if ide_env
247        || crate::core::config::Config::load()
248            .allow_ide_config_dirs
249            .unwrap_or(false)
250    {
251        out.push(JailRelaxation {
252            source: if ide_env {
253                "LEAN_CTX_ALLOW_IDE_DIRS=1"
254            } else {
255                "allow_ide_config_dirs = true (config.toml)"
256            },
257            detail: "exposes ~/.cursor, ~/.claude, … (other agents' sessions/credentials) to tools",
258        });
259    }
260
261    // `home` admits writes wherever it admits reads (every project below ~);
262    // the default `projects` keeps writes in the session's project.
263    if super::pathjail_scope::PathJailScope::resolve() == super::pathjail_scope::PathJailScope::Home
264    {
265        out.push(JailRelaxation {
266            source: if std::env::var("LEAN_CTX_PATH_JAIL_SCOPE")
267                .ok()
268                .and_then(|v| super::pathjail_scope::PathJailScope::parse(&v))
269                .is_some()
270            {
271                "LEAN_CTX_PATH_JAIL_SCOPE=home"
272            } else {
273                "path_jail_scope = home (config.toml)"
274            },
275            detail: "reads and writes anywhere below ~ outside protected zones, not only in the active project",
276        });
277    }
278
279    out
280}
281
282/// Emit a loud `tracing::warn!` for every active path-jail relaxation. Called
283/// once at MCP/HTTP server startup so a trusted-but-loosening env/config leaves
284/// an in-band audit signal instead of silently defeating the jail (finding 3).
285pub fn warn_if_relaxed() {
286    for relaxation in active_relaxations() {
287        tracing::warn!(
288            "[SECURITY] path jail relaxed via {}: {} — intended for trusted local use only",
289            relaxation.source,
290            relaxation.detail
291        );
292    }
293}
294
295/// True when `candidate` resolves to a location inside a configured read-only
296/// root. The candidate's nearest existing ancestor is canonicalized (so a
297/// not-yet-existing file inherits the read-only status of the directory it
298/// would be created in — closing the "create a new file in a read-only repo"
299/// hole) and matched against the (symlink-resolved) read-only roots.
300///
301/// A `false` return is only authoritative when the roots list is empty or the
302/// path provably sits outside every root; an unresolvable candidate (no
303/// existing ancestor) is treated as *not* read-only here and is rejected later
304/// by the ordinary write/jail error, never silently written.
305pub fn is_read_only_path(candidate: &Path) -> bool {
306    let roots = read_only_roots_from_env_and_config();
307    if roots.is_empty() {
308        return false;
309    }
310    let base = canonical_target(candidate);
311    roots.iter().any(|r| is_under_prefix(&base, r))
312}
313
314/// The canonicalized nearest-existing-ancestor of `candidate` with the
315/// not-yet-existing remainder re-appended. Resolves symlinks so a symlink
316/// *into* a guarded root cannot launder a write past a prefix check.
317pub(crate) fn canonical_target(candidate: &Path) -> PathBuf {
318    match canonicalize_existing_ancestor(candidate) {
319        Some((mut p, remainder)) => {
320            for part in remainder.iter().rev() {
321                p.push(part);
322            }
323            p
324        }
325        None => canonicalize_or_self(candidate),
326    }
327}
328
329/// Default-deny write guard for the read-only tier (#475): returns an error if
330/// `candidate` is inside a configured read-only root, `Ok(())` otherwise.
331///
332/// This is the single read-only-aware choke point. Every filesystem write that
333/// can target a caller-supplied path routes through it (the atomic writers in
334/// `ctx_edit`/`edit_apply`, the handoff/session export bundle writers, the
335/// in-place memory-compaction writer, and the refactor IDE pre-write gate), so
336/// a "read-only" root cannot be written through any tool. Reads are unaffected.
337pub fn enforce_writable(candidate: &Path) -> Result<(), String> {
338    // This boundary covers caller-selected edit/backup/export destinations.
339    // Engine-owned knowledge persistence uses its own authorized store writer.
340    crate::core::protected_store::enforce_protected_store_path(candidate)?;
341    // Hive policies and limits are never changed by an agent (Release B).
342    if crate::core::policy::hive_floor::is_policy_path(&canonical_target(candidate)) {
343        return Err(format!(
344            "Hive policy files are written by the instance only; agents cannot change them: {}",
345            candidate.display()
346        ));
347    }
348    // The session's own rights profile (process-v1 §9) narrows every write,
349    // under a Workspace too: own worktree only, never a locked path.
350    crate::core::session_rights::check_write(candidate)?;
351    // Under a Workspace a write needs a read-write root (#2120).
352    match crate::core::workspace_authority::current() {
353        crate::core::workspace_authority::Authority::Legacy => {}
354        crate::core::workspace_authority::Authority::Unavailable(reason) => {
355            return Err(crate::core::workspace_authority::unavailable_reason(
356                &reason,
357            ));
358        }
359        crate::core::workspace_authority::Authority::Workspace(snapshot) => {
360            let target = canonical_target(candidate);
361            if snapshot.admits_write(&target) {
362                return Ok(());
363            }
364            return Err(format!(
365                "writes are denied outside the read-write roots of Workspace '{}' (rev {}): {}",
366                snapshot.id,
367                snapshot.revision,
368                candidate.display()
369            ));
370        }
371    }
372    if is_read_only_path(candidate) {
373        return Err(format!(
374            "path is inside a read-only root — writes are denied (read_only_roots): {}",
375            candidate.display()
376        ));
377    }
378    match super::pathjail_scope::home_scope_write_denial(&canonical_target(candidate)) {
379        Some(denial) => Err(denial),
380        None => Ok(()),
381    }
382}
383
384/// Foreign editor config dirs for the jail (~/.cursor, ~/.claude, VS Code, …).
385///
386/// These expose other projects' sessions, MCP configs and credentials to any
387/// agent, so they are opt-in only (config `allow_ide_config_dirs = true` or
388/// `LEAN_CTX_ALLOW_IDE_DIRS=1`). lean-ctx's *own* state dir is intentionally NOT
389/// handled here: the caller already adds it via the sanctioned `data_dir`
390/// resolver (the legacy `~/.lean-ctx` is just one resolution of it). Keeping this
391/// a pure foreign-editor list means no legacy `~/.lean-ctx` literal is built in
392/// this module, so the legacy-path firewall (tests/legacy_path_firewall) has
393/// nothing to flag.
394fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
395    let mut out = Vec::new();
396    if ide_dirs_allowed {
397        // Cached per home and config generation (#2195); see `ide_dirs`.
398        let targets = ide_dirs::editor_targets(home);
399        collect_ide_allow_dirs(home, &targets, &mut out);
400    }
401    out
402}
403
404/// Collect the in-home config/detect directories of every supported editor.
405///
406/// Derived from the editor registry (the single source of truth) so it covers
407/// non-dotfile layouts too — VS Code's `Library/Application Support/Code/User`,
408/// Cline/Roo globalStorage, JetBrains — and never drifts as editors are added.
409/// A config file that sits directly in `$HOME` (`~/.claude.json`,
410/// `~/.jb-mcp.json`) resolves its parent to `$HOME`; those entries are skipped
411/// so the jail is never widened to the entire home directory.
412fn collect_ide_allow_dirs(
413    home: &Path,
414    targets: &[crate::core::editor_registry::EditorTarget],
415    out: &mut Vec<PathBuf>,
416) {
417    let mut seen: std::collections::HashSet<PathBuf> = out.iter().cloned().collect();
418    for target in targets {
419        let candidates = [
420            target.config_path.parent().map(Path::to_path_buf),
421            Some(target.detect_path.clone()),
422        ];
423        for cand in candidates.into_iter().flatten() {
424            if cand.as_path() == home || !cand.starts_with(home) || !cand.is_dir() {
425                continue;
426            }
427            let resolved = canonicalize_secure(&cand);
428            if seen.insert(resolved.clone()) {
429                out.push(resolved);
430            }
431        }
432    }
433}
434
435fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
436    path.starts_with(prefix)
437}
438
439/// True for Claude Code / CodeBuddy auto-memory files under
440/// `~/.claude/projects/<slug>/memory/` (and the CodeBuddy twin).
441///
442/// Anchored to the harness home — the current user's `~/.claude` /
443/// `~/.codebuddy`, or `$CLAUDE_CONFIG_DIR` — so a look-alike
444/// `…/.claude/projects/<slug>/memory` elsewhere on disk (a cloned repo, `/tmp`)
445/// is not admitted. `..` components never match.
446///
447/// Auto memory uses the host's native Read/Edit/Write on these paths
448/// (code.claude.com/docs/en/memory). Replace-mode PathJail must not force
449/// agents to shell out or hit MCP `resources/read` with file URIs (GH #1228).
450/// Scoped to the `memory/` subdirectory only — session transcripts and
451/// credentials under `projects/<slug>/` stay jailed.
452pub fn is_harness_auto_memory_path(path: &Path) -> bool {
453    use std::path::Component;
454    harness_project_dirs().iter().any(|projects| {
455        let Ok(rest) = path.strip_prefix(projects) else {
456            return false;
457        };
458        if rest
459            .components()
460            .any(|c| !matches!(c, Component::Normal(_)))
461        {
462            return false;
463        }
464        let mut parts = rest.components();
465        parts.next().is_some()
466            && parts
467                .next()
468                .is_some_and(|c| c.as_os_str().eq_ignore_ascii_case("memory"))
469    })
470}
471
472/// The harness `projects/` directories auto memory lives in, as configured and
473/// canonical (hook payloads carry the spelled path, the jail the canonical one).
474fn harness_project_dirs() -> Vec<PathBuf> {
475    let mut configs = Vec::new();
476    if let Some(home) = dirs::home_dir() {
477        configs.push(home.join(".claude"));
478        configs.push(home.join(".codebuddy"));
479    }
480    if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|v| !v.is_empty()) {
481        configs.push(PathBuf::from(dir));
482    }
483    let mut out = Vec::new();
484    for config in configs.into_iter().filter(|c| c.is_absolute()) {
485        let projects = config.join("projects");
486        let canonical = canonicalize_secure(&projects);
487        if canonical != projects {
488            out.push(canonical);
489        }
490        out.push(projects);
491    }
492    out
493}
494
495fn path_allowed_by_jail(base: &Path, root: &Path, allow: &[PathBuf]) -> bool {
496    if is_harness_auto_memory_path(base) {
497        return true;
498    }
499    let allowed = is_under_prefix(base, root)
500        || allow.iter().any(|p| is_under_prefix(base, p))
501        || super::pathjail_scope::home_scope_admits(base);
502    #[cfg(windows)]
503    let allowed = allowed || is_under_prefix_windows(base, root);
504    allowed && super::pathjail_scope::protected_zone_permits(base, root, allow)
505}
506
507/// Heuristic canonicalize — honours the #356 TCC guard. Used by the
508/// jail-disabled bypass and by external callers (session/startup/server roots)
509/// that must not pop a privacy prompt on their own initiative.
510pub fn canonicalize_or_self(path: &Path) -> PathBuf {
511    super::pathutil::safe_canonicalize_bounded(path, 2000)
512}
513
514/// SECURITY canonicalize for the jail boundary itself (roots + candidate +
515/// escape re-check). Deliberately bypasses the #356 TCC guard: the jail must
516/// keep resolving symlinks to detect escapes, and it only ever runs on a path
517/// the client explicitly asked to access, where a one-time prompt is legitimate.
518pub(crate) fn canonicalize_secure(path: &Path) -> PathBuf {
519    super::pathutil::canonicalize_secure_bounded(path, 2000)
520}
521
522fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
523    let mut cur = path.to_path_buf();
524    let mut remainder: Vec<std::ffi::OsString> = Vec::new();
525    loop {
526        if cur.exists() {
527            return Some((canonicalize_secure(&cur), remainder));
528        }
529        let name = cur.file_name()?.to_os_string();
530        remainder.push(name);
531        if !cur.pop() {
532            return None;
533        }
534    }
535}
536
537pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, PathJailError> {
538    jail_path_with_roots(candidate, jail_root, &[])
539}
540
541/// Jail `candidate` to the request's Workspace snapshot (#2120). A relative
542/// path resolves against `jail_root` when the Workspace contains it, else
543/// against the primary root. Only the snapshot's roots admit — lean-ctx's own
544/// data and state directories hold every project's indexes, sessions and
545/// archives, and reach them through engine APIs (`ctx_expand`, recall), not
546/// through a model-chosen path. `..` below a missing ancestor is refused, and an
547/// existing target is re-resolved and re-checked.
548fn jail_in_workspace(
549    candidate: &Path,
550    jail_root: &Path,
551    snapshot: &crate::core::workspace_authority::Snapshot,
552) -> Result<PathBuf, PathJailError> {
553    let canonical = |path: &Path| crate::core::pathutil::canonicalize_secure(path).ok();
554    let base_root = canonical(jail_root)
555        .filter(|root| snapshot.admits(root))
556        .unwrap_or_else(|| snapshot.primary.clone());
557    let candidate = if candidate.is_absolute() {
558        candidate.to_path_buf()
559    } else {
560        base_root.join(candidate)
561    };
562    let admitted = |path: &Path| snapshot.admits(path);
563    let outside = |path: &Path| PathJailError::OutsideWorkspace {
564        path: path.to_path_buf(),
565        workspace: snapshot.id.clone(),
566        revision: snapshot.revision,
567        roots: snapshot
568            .granting_roots()
569            .map(|(root, access)| format!("{} ({})", root.display(), access.as_str()))
570            .collect::<Vec<_>>()
571            .join(", "),
572    };
573
574    let mut existing = candidate.clone();
575    let mut remainder: Vec<std::ffi::OsString> = Vec::new();
576    while !existing.exists() {
577        let Some(name) = existing.file_name() else {
578            return Err(PathJailError::NoExistingAncestor { path: candidate });
579        };
580        remainder.push(name.to_os_string());
581        if !existing.pop() {
582            return Err(PathJailError::NoExistingAncestor { path: candidate });
583        }
584    }
585    if remainder
586        .iter()
587        .any(|part| part == std::ffi::OsStr::new("..") || part == std::ffi::OsStr::new("."))
588    {
589        return Err(outside(&candidate));
590    }
591    // Unbounded on purpose: a timed-out canonicalization would leave a lexical
592    // path whose symlinks were never resolved.
593    let base = canonical(&existing).ok_or_else(|| outside(&candidate))?;
594    if !admitted(&base) {
595        return Err(outside(&candidate));
596    }
597
598    #[cfg(windows)]
599    reject_symlink_on_windows(&candidate)?;
600
601    let mut out = base;
602    for part in remainder.iter().rev() {
603        out.push(part);
604    }
605    if out.exists() {
606        let resolved = canonical(&out).ok_or_else(|| outside(&candidate))?;
607        if !admitted(&resolved) {
608            return Err(PathJailError::PostCanonicalizeEscape {
609                path: candidate,
610                resolved,
611            });
612        }
613    }
614    crate::core::protected_store::enforce_protected_store_path(&out)
615        .map_err(|reason| PathJailError::ProtectedContext { reason })?;
616    Ok(out)
617}
618
619/// Known language-cache markers (#899): (path substring, human label, config
620/// example). Single source of truth shared by [`detected_cache_hint`] (the
621/// jail-error suggestion) and [`detect_language_cache_root`] (session
622/// auto-registration), so the two never drift.
623const LANGUAGE_CACHE_PATTERNS: &[(&str, &str, &str)] = &[
624    ("/go/pkg/mod/", "Go module cache", "~/go/pkg/mod"),
625    (
626        "/.cargo/registry/",
627        "Rust crate registry",
628        "~/.cargo/registry",
629    ),
630    (
631        "/site-packages/",
632        "Python site-packages",
633        "<venv>/lib/pythonX.Y/site-packages",
634    ),
635    ("/node_modules/", "Node modules", "<project>/node_modules"),
636    (
637        "/.m2/repository/",
638        "Maven local repository",
639        "~/.m2/repository",
640    ),
641    ("/.gradle/caches/", "Gradle cache", "~/.gradle/caches"),
642    (
643        "/.nuget/packages/",
644        "NuGet package cache",
645        "~/.nuget/packages",
646    ),
647    // Dependency source agents look up as often as the registry: the Rust
648    // standard library and git dependencies, and the Bun, Dart and Ruby
649    // package stores. Package source only — no credentials live here.
650    (
651        "/.rustup/toolchains/",
652        "Rust toolchain source",
653        "~/.rustup/toolchains",
654    ),
655    (
656        "/.cargo/git/checkouts/",
657        "Cargo git dependencies",
658        "~/.cargo/git/checkouts",
659    ),
660    (
661        "/.bun/install/cache/",
662        "Bun package cache",
663        "~/.bun/install/cache",
664    ),
665    (
666        "/.pub-cache/hosted/",
667        "Dart pub cache",
668        "~/.pub-cache/hosted",
669    ),
670    ("/.gem/ruby/", "Ruby gems", "~/.gem/ruby"),
671    // Interpreter installs whose stdlib agents look up like site-packages
672    // (which is already admitted inside them). Source and binaries only.
673    (
674        "/.pyenv/versions/",
675        "pyenv Python installations",
676        "~/.pyenv/versions",
677    ),
678];
679
680/// Agent-owned instruction locations below `~` that the agents themselves are
681/// told to read: skills, slash commands, sub-agent definitions, rules, plans
682/// and the global instruction files. They live inside protected dot folders,
683/// so every such read failed although nothing in them is a credential (auth
684/// tokens sit next to them — `~/.codex/auth.json`, `~/.claude/.credentials.json`
685/// — and stay jailed because only these exact subtrees are opened). Opened
686/// read-only, like a language cache; writes stay denied. (Per-project auto
687/// memory is admitted separately by [`is_harness_auto_memory_path`].)
688///
689/// The agents' own settings and MCP configs are opened the same way, file by
690/// file: agents read them to debug their setup, and "protected location" was
691/// still the refusal seen on the most installations in 3.11.4. A token in them
692/// is masked by the context gateway like in any other read. `~/.claude.json`
693/// (may hold an API key), credential files, sessions and transcripts stay
694/// jailed.
695const AGENT_INSTRUCTION_ROOTS: &[(&str, &str)] = &[
696    (".claude/skills", "Claude Code skills"),
697    (".claude/commands", "Claude Code commands"),
698    (".claude/agents", "Claude Code agents"),
699    (".claude/plans", "Claude Code plans"),
700    (".claude/rules", "Claude Code rules"),
701    (".claude/plugins/marketplaces", "Claude Code plugins"),
702    (".claude/plugins/cache", "Claude Code plugins"),
703    (".claude/CLAUDE.md", "Claude Code instructions"),
704    (".claude/settings.json", "Claude Code settings"),
705    (".claude/settings.local.json", "Claude Code settings"),
706    (".claude/keybindings.json", "Claude Code settings"),
707    (".codex/skills", "Codex skills"),
708    (".codex/prompts", "Codex prompts"),
709    (".codex/AGENTS.md", "Codex instructions"),
710    (".codex/config.toml", "Codex config"),
711    (".codex/hooks.json", "Codex config"),
712    (".agents/skills", "agent skills"),
713    (".cursor/rules", "Cursor rules"),
714    (".cursor/skills", "Cursor skills"),
715    (".cursor/commands", "Cursor commands"),
716    (".cursor/mcp.json", "Cursor config"),
717    (".cursor/hooks.json", "Cursor config"),
718    (".gemini/GEMINI.md", "Gemini instructions"),
719    (".gemini/commands", "Gemini commands"),
720    (".gemini/settings.json", "Gemini config"),
721    (".codebuddy/settings.json", "CodeBuddy settings"),
722    (".config/opencode/agent", "OpenCode agents"),
723    (".config/opencode/command", "OpenCode commands"),
724    (".config/opencode/AGENTS.md", "OpenCode instructions"),
725    (".config/opencode/opencode.json", "OpenCode config"),
726    (".config/opencode/opencode.jsonc", "OpenCode config"),
727    (".codeium/windsurf/mcp_config.json", "Windsurf config"),
728];
729
730/// Folders below `~/.cursor/projects/<workspace>/` where Cursor's agent writes
731/// long tool output and terminal logs, then greps and reads them back. With
732/// lean-ctx in place those reads go through the jail, which refused every one
733/// as a protected location — Cursor sessions hit that refusal on `ctx_search`
734/// about three times as often per installation as other clients in 3.11.4.
735/// Opened read-only, and only for the session's own workspace
736/// ([`cursor_workspace_matches`]); other projects' logs, the workspace's
737/// `mcp-auth.json`, transcripts and the rest of `~/.cursor` stay jailed.
738const CURSOR_AGENT_OUTPUT_DIRS: &[&str] = &["agent-tools", "terminals"];
739const CURSOR_AGENT_OUTPUT_LABEL: &str = "Cursor agent output";
740
741/// Cursor names a workspace's folder after the path it opened: every
742/// character other than an ASCII letter or digit becomes `-`, leading ones
743/// dropped (`/Users/me/my_app` → `Users-me-my-app`).
744fn cursor_workspace_slug(root: &Path) -> String {
745    let slug: String = root
746        .to_string_lossy()
747        .chars()
748        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
749        .collect();
750    slug.trim_start_matches('-').to_string()
751}
752
753/// `…/.cursor/projects/<workspace>/<dir>` with `<dir>` in
754/// [`CURSOR_AGENT_OUTPUT_DIRS`]; path components only, no filesystem access
755/// (runs for every registered root on every jail call).
756fn is_cursor_agent_output_root(path: &Path) -> bool {
757    let mut back = path.components().rev().map(std::path::Component::as_os_str);
758    let (Some(dir), Some(_workspace), Some(projects), Some(cursor)) =
759        (back.next(), back.next(), back.next(), back.next())
760    else {
761        return false;
762    };
763    cursor == ".cursor"
764        && projects == "projects"
765        && CURSOR_AGENT_OUTPUT_DIRS.iter().any(|d| dir == *d)
766}
767
768/// True when the Cursor output folder `admitted_root`
769/// (`~/.cursor/projects/<workspace>/<dir>`) belongs to the session's project
770/// or one of its declared roots, raw or canonical (`/var` vs `/private/var`).
771/// Exact match only: a prefix would let `lean-ctx` read `lean-ctx-enterprise`.
772/// Numbered and `empty-window` workspaces belong to no project and stay jailed.
773fn cursor_workspace_matches(
774    admitted_root: &Path,
775    jail_root: &Path,
776    extra_roots: &[String],
777) -> bool {
778    let Some(workspace) = admitted_root.parent().and_then(Path::file_name) else {
779        return false;
780    };
781    std::iter::once(jail_root.to_path_buf())
782        .chain(extra_roots.iter().map(PathBuf::from))
783        .flat_map(|root| [canonicalize_secure(&root), root])
784        .any(|root| workspace == cursor_workspace_slug(&root).as_str())
785}
786
787/// `rel` (relative to `~`, only normal components) inside one of
788/// [`CURSOR_AGENT_OUTPUT_DIRS`] → that folder, relative to `~`.
789fn cursor_agent_output_root(rel: &Path) -> Option<PathBuf> {
790    let mut parts = rel.components().map(std::path::Component::as_os_str);
791    if parts.next()? != ".cursor" || parts.next()? != "projects" {
792        return None;
793    }
794    let workspace = parts.next()?;
795    let dir = parts.next()?;
796    CURSOR_AGENT_OUTPUT_DIRS
797        .iter()
798        .any(|d| dir == *d)
799        .then(|| Path::new(".cursor/projects").join(workspace).join(dir))
800}
801
802/// If `candidate` lies in one of [`AGENT_INSTRUCTION_ROOTS`] or
803/// [`CURSOR_AGENT_OUTPUT_DIRS`] below the home directory, return
804/// `(label, root)`. Anchored at `~` so a same-named folder elsewhere on disk
805/// is not opened.
806fn detect_agent_instruction_root(candidate: &Path) -> Option<(&'static str, PathBuf)> {
807    let home = dirs::home_dir()?;
808    let canon_home = canonicalize_secure(&home);
809    let rel = candidate
810        .strip_prefix(&home)
811        .or_else(|_| candidate.strip_prefix(&canon_home))
812        .ok()?;
813    // A `..` would let `~/.claude/skills/../.credentials.json` match by prefix.
814    if rel
815        .components()
816        .any(|c| !matches!(c, std::path::Component::Normal(_)))
817    {
818        return None;
819    }
820    AGENT_INSTRUCTION_ROOTS
821        .iter()
822        .find(|(root, _)| rel.starts_with(root))
823        .map(|&(root, label)| (label, canon_home.join(root)))
824        .or_else(|| detect_host_session_output_root(rel, &canon_home))
825        .or_else(|| {
826            cursor_agent_output_root(rel)
827                .map(|root| canon_home.join(root))
828                // The agent can write here, so a symlinked folder (or workspace)
829                // would open its target — `agent-tools -> ~/.ssh`. Admit only a
830                // root that resolves to itself.
831                .filter(|root| canonicalize_secure(root) == *root)
832                .map(|root| (CURSOR_AGENT_OUTPUT_LABEL, root))
833        })
834}
835
836/// Output the host wrote for the agent's *own* session and then pointed the
837/// agent at: Claude Code persists large tool results under
838/// `~/.claude/projects/<slug>/<session>/tool-results` (and subagent logs next
839/// to it). Cursor's `agent-tools`/`terminals` take the stricter path of
840/// [`cursor_agent_output_root`] (own workspace only, no symlinked folder).
841/// They sit in protected dot folders, so every follow-up read was refused —
842/// ~1'900 refusals in 30 days on 3.11.3+ (focus analysis 2026-10-15). Only
843/// these exact per-session subtrees open, read-only; credentials beside them
844/// (`~/.claude/.credentials.json`, `~/.cursor/projects/<id>/mcp-auth.json`)
845/// and other conversations' transcripts stay jailed. `rel` is relative to
846/// `~` and contains normal components only.
847fn detect_host_session_output_root(
848    rel: &Path,
849    canon_home: &Path,
850) -> Option<(&'static str, PathBuf)> {
851    let parts: Vec<&str> = rel
852        .components()
853        .map(|c| c.as_os_str().to_str())
854        .collect::<Option<_>>()?;
855    let (depth, label) = match parts.as_slice() {
856        [
857            ".claude",
858            "projects",
859            _,
860            _,
861            "tool-results" | "subagents",
862            ..,
863        ] => (5, "Claude Code session output"),
864        _ => return None,
865    };
866    Some((
867        label,
868        canon_home.join(parts[..depth].iter().collect::<PathBuf>()),
869    ))
870}
871
872/// The host's own per-user scratch directory outside `~`: Claude Code keeps
873/// the session scratchpad below `<tmp>/claude-<uid>/` and tells the agent to
874/// put intermediate files there. Admitted read-only only when that directory
875/// belongs to this user and nobody else can write into it, so another local
876/// account cannot pre-create it in the shared `/tmp`.
877#[cfg(unix)]
878fn detect_host_scratch_root(candidate: &Path) -> Option<(&'static str, PathBuf)> {
879    use std::os::unix::fs::MetadataExt;
880    // SAFETY: geteuid has no arguments or failure state.
881    let uid = unsafe { libc::geteuid() };
882    let name = format!("claude-{uid}");
883    // Canonical through the nearest existing ancestor (`/var` vs
884    // `/private/var` on macOS); a `..` in the missing tail yields `None`.
885    let (base, rest) = canonicalize_existing_ancestor(candidate)?;
886    let candidate = rest.iter().rev().fold(base, |path, part| path.join(part));
887    [std::env::temp_dir(), PathBuf::from("/tmp")]
888        .into_iter()
889        .map(|tmp| tmp.join(&name))
890        .find_map(|dir| {
891            let meta = std::fs::symlink_metadata(&dir).ok()?;
892            let root = canonicalize_secure(&dir);
893            (meta.is_dir()
894                && meta.uid() == uid
895                && meta.mode() & 0o022 == 0
896                && candidate.starts_with(&root))
897            .then_some(("Claude Code scratchpad", root))
898        })
899}
900
901#[cfg(not(unix))]
902fn detect_host_scratch_root(_candidate: &Path) -> Option<(&'static str, PathBuf)> {
903    None
904}
905
906/// When `candidate` reaches its target through a symlink inside an agent
907/// instruction `root` (a skill directory linked in from a checkout or from
908/// `~/.agents/skills`), register the target's directory read-only too.
909///
910/// Only agent instruction roots qualify: lean-ctx's tools cannot write there
911/// (protected zone, read-only root), so a link inside was put there by the
912/// user. A language cache such as `node_modules` sits in a writable project,
913/// where an agent could plant a link to anything — those are never followed.
914/// Targets must stay below `~` and outside protected zones, so a link cannot
915/// smuggle `~/.ssh` or a system directory in. Cursor's agent output folders
916/// are excluded: the agent writes them itself. Returns `true` when a target
917/// directory was newly registered.
918pub fn register_symlink_target_read_only(candidate: &Path, root: &Path) -> bool {
919    if detect_agent_instruction_root(root)
920        .is_none_or(|(label, _)| label == CURSOR_AGENT_OUTPUT_LABEL)
921        || (!candidate.starts_with(root) && !candidate.starts_with(canonicalize_secure(root)))
922    {
923        return false;
924    }
925    let Ok(target) = std::fs::canonicalize(candidate) else {
926        return false;
927    };
928    let root = canonicalize_secure(root);
929    if target.starts_with(&root) {
930        return false;
931    }
932    // Skill managers link `~/.claude/skills/x` to `~/.agents/skills/x`: a
933    // target in another agent instruction root opens that root, not more.
934    if let Some((_, agent_root)) = detect_agent_instruction_root(&target) {
935        return register_session_read_only_root(&agent_root);
936    }
937    let dir = if target.is_dir() {
938        target
939    } else {
940        match target.parent() {
941            Some(parent) => parent.to_path_buf(),
942            None => return false,
943        }
944    };
945    let below_home =
946        dirs::home_dir().is_some_and(|home| dir.starts_with(canonicalize_secure(&home)));
947    if !below_home
948        || crate::core::pathutil::is_broad_or_unsafe_root(&dir)
949        || super::pathjail_scope::in_protected_zone(&dir)
950    {
951        return false;
952    }
953    register_session_read_only_root(&dir)
954}
955
956/// [`jail_path_with_roots`] plus the read-only auto-admission of #899: a path
957/// the jail refuses inside a language cache or an agent instruction folder
958/// registers that root read-only for the session and resolves again, so the
959/// call is served instead of failing with "retry". A symlinked skill whose
960/// target is still refused gets its target opened read-only
961/// ([`register_symlink_target_read_only`]). Writes stay denied either way.
962/// One implementation for the MCP server and the one-shot CLI.
963pub fn jail_path_auto_admitting(
964    candidate: &Path,
965    jail_root: &Path,
966    extra_roots: &[String],
967) -> Result<PathBuf, PathJailError> {
968    let first = jail_path_with_roots(candidate, jail_root, extra_roots);
969    let Err(refused) = first else {
970        return first;
971    };
972    // Under a Workspace nothing is admitted on the fly (#2120): a cache or
973    // instruction folder is reachable only as an attached root.
974    if crate::core::workspace_authority::is_configured() {
975        return Err(refused);
976    }
977    let Some((label, root)) = detect_language_cache_root(candidate) else {
978        return Err(refused);
979    };
980    // One output folder per Cursor workspace: another project's terminal logs
981    // stay jailed.
982    if label == CURSOR_AGENT_OUTPUT_LABEL
983        && !cursor_workspace_matches(&root, jail_root, extra_roots)
984    {
985        return Err(refused);
986    }
987    if register_session_read_only_root(&root) {
988        tracing::info!(
989            "auto-registered {label} at {} as a read-only root",
990            root.display()
991        );
992    }
993    let retry = jail_path_with_roots(candidate, jail_root, extra_roots);
994    // Only when the jail still refuses the target is it opened — never when it
995    // is already readable, which would make it read-only for a project the
996    // agent may be editing.
997    if retry.is_err() && register_symlink_target_read_only(candidate, &root) {
998        return jail_path_with_roots(candidate, jail_root, extra_roots);
999    }
1000    retry
1001}
1002
1003/// Detect well-known language cache paths and return a targeted hint. Used for
1004/// jail callers that don't auto-register (e.g. batch reads); the single-path
1005/// ctx_read flow instead auto-registers via [`detect_language_cache_root`].
1006fn detected_cache_hint(candidate: &std::path::Path) -> Option<String> {
1007    let s = candidate.to_string_lossy();
1008    for &(pattern, name, example) in LANGUAGE_CACHE_PATTERNS {
1009        if s.contains(pattern) {
1010            return Some(format!(
1011                ". Detected {name} — add read_only_roots = [\"{example}\"] to \
1012                 ~/.config/lean-ctx/config.toml for cached, compressed reads without write access"
1013            ));
1014        }
1015    }
1016    None
1017}
1018
1019/// If `candidate` sits inside a known language cache, return `(label, root)`
1020/// where `root` is the path truncated at the end of the marker directory (no
1021/// trailing slash). resolve_path uses this to auto-register a session read-only
1022/// root so the retry resolves without a config edit or a subprocess (#899).
1023pub fn detect_language_cache_root(candidate: &Path) -> Option<(&'static str, PathBuf)> {
1024    if let Some(found) = detect_agent_instruction_root(candidate) {
1025        return Some(found);
1026    }
1027    if let Some(found) = detect_host_scratch_root(candidate) {
1028        return Some(found);
1029    }
1030    let s = candidate.to_string_lossy().replace('\\', "/");
1031    for &(marker, label, _) in LANGUAGE_CACHE_PATTERNS {
1032        if let Some(idx) = s.find(marker) {
1033            let end = idx + marker.len() - 1; // keep the marker dir, drop trailing '/'
1034            return Some((label, PathBuf::from(&s[..end])));
1035        }
1036    }
1037    None
1038}
1039
1040/// Like [`jail_path`], but also accepts paths under any of `extra_roots`.
1041///
1042/// `extra_roots` are session-scoped trusted roots (MCP `roots/list` and config
1043/// `extra_roots`, surfaced via `session.extra_roots`) — e.g. sibling git
1044/// worktrees the agent legitimately spans. They widen the allow-list for *this
1045/// call only*, so an explicit `path` under a worktree resolves instead of
1046/// failing with "path escapes project root", without loosening the global jail
1047/// (#403). `path_jail = false` still bypasses entirely and an empty slice is
1048/// byte-for-byte identical to the old single-root behaviour.
1049pub fn jail_path_with_roots(
1050    candidate: &Path,
1051    jail_root: &Path,
1052    extra_roots: &[String],
1053) -> Result<PathBuf, PathJailError> {
1054    jail_path_checked(candidate, jail_root, extra_roots, true)
1055}
1056
1057/// [`jail_path`] with the off-switches ignored: the root + allow-list check
1058/// runs even in a `no-jail` build and under `path_jail = false`.
1059///
1060/// For destinations whose containment is a property of the operation rather
1061/// than of the general tool jail — an unredacted canonical session export is
1062/// bound to its session's project root (plus the configured allow-list), and a
1063/// build or config that opens tool paths must not also open where that export
1064/// may land. With the jail in force this is identical to [`jail_path`].
1065pub fn jail_path_enforced(candidate: &Path, jail_root: &Path) -> Result<PathBuf, PathJailError> {
1066    jail_path_checked(candidate, jail_root, &[], false)
1067}
1068
1069fn jail_path_checked(
1070    candidate: &Path,
1071    jail_root: &Path,
1072    extra_roots: &[String],
1073    honor_off_switch: bool,
1074) -> Result<PathBuf, PathJailError> {
1075    if candidate.to_string_lossy().as_bytes().contains(&0) {
1076        return Err(PathJailError::NullByte);
1077    }
1078
1079    // Runtime context is reached through authorized knowledge/recovery APIs,
1080    // never by widening a model-directed file read to the Engine's own store.
1081    // Apply this before no-jail/config/implicit runtime allow-list shortcuts.
1082    let absolute = if candidate.is_absolute() {
1083        candidate.to_path_buf()
1084    } else {
1085        jail_root.join(candidate)
1086    };
1087    crate::core::protected_store::enforce_protected_store_path(&absolute)
1088        .map_err(|reason| PathJailError::ProtectedContext { reason })?;
1089
1090    // A launcher-bound Workspace is the only authority (#2120): it is checked
1091    // before the off-switches, the configured widening and the home scope,
1092    // none of which apply while it is bound.
1093    match crate::core::workspace_authority::current() {
1094        crate::core::workspace_authority::Authority::Legacy => {}
1095        crate::core::workspace_authority::Authority::Unavailable(reason) => {
1096            return Err(PathJailError::WorkspaceUnavailable {
1097                reason: crate::core::workspace_authority::unavailable_reason(&reason),
1098            });
1099        }
1100        crate::core::workspace_authority::Authority::Workspace(snapshot) => {
1101            return jail_in_workspace(candidate, jail_root, &snapshot);
1102        }
1103    }
1104
1105    // With the jail off a relative path still means "in the project": the
1106    // process CWD (the daemon's) is not the project, so resolve it against
1107    // the root here as well.
1108    #[cfg(feature = "no-jail")]
1109    {
1110        if honor_off_switch {
1111            return Ok(canonicalize_or_self(&absolute));
1112        }
1113    }
1114
1115    {
1116        let cfg = crate::core::config::Config::load();
1117        if honor_off_switch && cfg.path_jail == Some(false) {
1118            return Ok(canonicalize_or_self(&absolute));
1119        }
1120
1121        let root = canonicalize_secure(jail_root);
1122
1123        // Resolve relative candidates against the (absolute) jail root — never the process
1124        // CWD. The daemon's CWD is not the project, so CWD-relative resolution made
1125        // graph-relative paths (e.g. auto-preload candidates like `rust/src/core/foo.rs`)
1126        // spuriously fail with "no existing ancestor". Absolute candidates are unchanged.
1127        let resolved: PathBuf;
1128        let candidate: &Path = if candidate.is_absolute() {
1129            candidate
1130        } else {
1131            resolved = root.join(candidate);
1132            resolved.as_path()
1133        };
1134
1135        let mut allow = allow_paths_from_env_and_config();
1136        // Session-scoped roots widen the allow-list for this call only.
1137        allow.extend(
1138            extra_roots
1139                .iter()
1140                .filter(|r| !r.is_empty())
1141                .map(|r| canonicalize_secure(Path::new(r))),
1142        );
1143
1144        // #820: lean-ctx's own state dir (tee files, artifacts, tool-results)
1145        // must be readable even when outside the project root. ctx_shell
1146        // tells agents to read tee-file paths, so the jail must allow them.
1147        if let Ok(state) = crate::core::paths::state_dir() {
1148            allow.push(canonicalize_secure(&state));
1149        }
1150        // Read-only roots are also allowed for reads (they only block writes
1151        // via enforce_writable, not reads via the jail). The registry is
1152        // process-global; a Cursor output folder is per workspace, so one
1153        // registered by another project's session stays jailed here.
1154        allow.extend(
1155            read_only_roots_from_env_and_config()
1156                .into_iter()
1157                .map(|p| canonicalize_secure(&p))
1158                .filter(|p| {
1159                    !is_cursor_agent_output_root(p)
1160                        || cursor_workspace_matches(p, jail_root, extra_roots)
1161                }),
1162        );
1163
1164        let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
1165            PathJailError::NoExistingAncestor {
1166                path: candidate.to_path_buf(),
1167            }
1168        })?;
1169
1170        let allowed = path_allowed_by_jail(&base, &root, &allow);
1171
1172        if !allowed {
1173            let is_suspicious = crate::core::pathutil::is_suspicious_root(&root);
1174            let mut hint = if is_suspicious || root == std::path::Path::new("/") {
1175                ". lean-ctx has not bound this session to a project yet (the host reported \
1176                     no project directory). Relative paths cannot bind it: pass one \
1177                     absolute path inside the project, e.g. /home/me/code/my-app/README.md, \
1178                     and it stays the session project; if it persists: lean-ctx doctor --fix"
1179                    .to_string()
1180            } else {
1181                super::pathjail_scope::escape_hint(candidate, &base, &root)
1182            };
1183            // An untrusted workspace's project-local `allow_paths` is silently
1184            // withheld; always surface that reason (the stderr warning is
1185            // invisible over MCP, and the hint above is meta-gated off) (#540).
1186            if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
1187                hint.push_str(". ");
1188                hint.push_str(&notice);
1189            }
1190            // The global config the runtime reads doesn't exist → on defaults, so
1191            // an `allow_paths` edit made to a config.toml elsewhere (XDG vs legacy
1192            // dir, or a sandboxed/container HOME) is never seen (#540).
1193            if let Some(missing) = crate::core::config::Config::missing_config_path() {
1194                hint.push_str(&format!(
1195                    ". ⚠ lean-ctx reads no config file at {} (running on defaults) — an \
1196                     allow_paths edit in a config.toml elsewhere is not read; \
1197                     `lean-ctx doctor` shows the path in effect",
1198                    missing.display()
1199                ));
1200            }
1201            if let Some(cache_hint) = detected_cache_hint(candidate) {
1202                hint.push_str(&cache_hint);
1203            }
1204            return Err(PathJailError::EscapesRoot {
1205                path: candidate.to_path_buf(),
1206                root,
1207                hint,
1208            });
1209        }
1210
1211        #[cfg(windows)]
1212        reject_symlink_on_windows(candidate)?;
1213
1214        let mut out = base;
1215        for part in remainder.iter().rev() {
1216            out.push(part);
1217        }
1218
1219        // Re-validate after reconstruction: if the final path exists, canonicalize
1220        // and re-check to close TOCTOU window (symlink created between check and use).
1221        if out.exists() {
1222            let final_canon = canonicalize_secure(&out);
1223            let final_ok = path_allowed_by_jail(&final_canon, &root, &allow);
1224            if !final_ok {
1225                return Err(PathJailError::PostCanonicalizeEscape {
1226                    path: candidate.to_path_buf(),
1227                    resolved: final_canon,
1228                });
1229            }
1230        }
1231
1232        crate::core::protected_store::enforce_protected_store_path(&out)
1233            .map_err(|reason| PathJailError::ProtectedContext { reason })?;
1234        Ok(out)
1235    }
1236}
1237
1238#[cfg(all(test, target_os = "macos"))]
1239mod protected_store_tests {
1240    use super::*;
1241
1242    #[test]
1243    fn runtime_store_is_not_reopened_by_implicit_or_explicit_roots() {
1244        let isolated = crate::core::data_dir::isolated_data_dir();
1245        let project = tempfile::tempdir().unwrap();
1246        let store = isolated.path().join("knowledge");
1247        std::fs::create_dir_all(&store).unwrap();
1248        let fact = store.join("facts.json");
1249        std::fs::write(&fact, "private stored context").unwrap();
1250        let code = project.path().join("main.rs");
1251        std::fs::write(&code, "fn main() {}\n").unwrap();
1252        assert!(
1253            jail_path(&fact, project.path()).is_ok(),
1254            "Community keeps its existing runtime access"
1255        );
1256        let _protected =
1257            crate::core::protected_store::pin_synthetic_session(isolated.path()).unwrap();
1258        let roots = vec![isolated.path().to_string_lossy().into_owned()];
1259        for candidate in [&fact, &store, &isolated.path().to_path_buf()] {
1260            assert!(
1261                matches!(
1262                    jail_path_with_roots(candidate, project.path(), &roots),
1263                    Err(PathJailError::ProtectedContext { .. })
1264                ),
1265                "{candidate:?}"
1266            );
1267        }
1268        assert!(jail_path(&code, project.path()).is_ok());
1269        assert!(jail_path_with_roots(Path::new("facts.json"), &store, &roots).is_err());
1270        // Raw backup paths enter the shared atomic writer without the dispatch
1271        // resolver; its write guard must refuse a hidden record or a sibling.
1272        // (The writer itself is exercised end to end in `tools::edit_io`.)
1273        for target in [&fact, &store.join("new/backup.json")] {
1274            assert!(enforce_writable(target).is_err(), "{target:?}");
1275        }
1276        assert_eq!(
1277            std::fs::read_to_string(&fact).unwrap(),
1278            "private stored context"
1279        );
1280        assert!(!store.join("new").exists());
1281        enforce_writable(&code).unwrap();
1282        // Internal storage is unaffected by the model-directed path resolver.
1283        std::fs::write(&fact, "retained internal update").unwrap();
1284        assert_eq!(
1285            std::fs::read_to_string(&fact).unwrap(),
1286            "retained internal update"
1287        );
1288    }
1289
1290    #[test]
1291    fn missing_child_profile_never_falls_back_to_the_normal_jail() {
1292        let isolated = crate::core::data_dir::isolated_data_dir();
1293        let project = tempfile::tempdir().unwrap();
1294        let code = project.path().join("main.rs");
1295        std::fs::write(&code, "fn main() {}\n").unwrap();
1296        let _protected =
1297            crate::core::protected_store::pin_synthetic_session(isolated.path()).unwrap();
1298        crate::test_env::remove_var(crate::core::protected_store::CHILD_PROFILE_ENV);
1299        assert!(matches!(
1300            jail_path(&code, project.path()),
1301            Err(PathJailError::ProtectedContext { .. })
1302        ));
1303    }
1304}
1305
1306#[cfg(windows)]
1307fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
1308    let path_str = normalize_windows_path(&path.to_string_lossy());
1309    let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
1310    let prefix_str = prefix_str.trim_end_matches('\\');
1311    // Component boundary: `c:\proj` must not admit `c:\proj-evil\x`.
1312    path_str == prefix_str
1313        || path_str
1314            .strip_prefix(prefix_str)
1315            .is_some_and(|rest| rest.starts_with('\\'))
1316}
1317
1318#[cfg(windows)]
1319fn normalize_windows_path(s: &str) -> String {
1320    let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
1321    stripped.to_lowercase().replace('/', "\\")
1322}
1323
1324#[cfg(windows)]
1325fn reject_symlink_on_windows(path: &Path) -> Result<(), PathJailError> {
1326    if let Ok(meta) = std::fs::symlink_metadata(path) {
1327        // Junctions and other reparse points redirect like symlinks but are
1328        // invisible to `is_symlink()` — reject them too (GL#442).
1329        if super::pathutil::is_symlink_or_reparse(&meta) {
1330            return Err(PathJailError::Symlink {
1331                path: path.to_path_buf(),
1332            });
1333        }
1334    }
1335    Ok(())
1336}
1337
1338#[path = "pathjail_ide_dirs.rs"]
1339mod ide_dirs;
1340
1341#[cfg(all(test, windows))]
1342#[path = "pathjail_windows_tests.rs"]
1343mod windows_prefix_tests;
1344
1345#[cfg(test)]
1346#[path = "pathjail_tests.rs"]
1347mod tests;