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