Skip to main content

lean_ctx/core/
pathjail_scope.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Path-jail scope: which directories the jail treats as the user's own code.
3//!
4//! The jail used to admit only the session's project root (plus allow-lists).
5//! Anyone working on several repositories at once hit "path escapes project
6//! root" on every cross-project read, and the suggested fixes — one allow entry
7//! per directory, or env vars that need an MCP restart — did not scale.
8//!
9//! - `projects` (**default**; also the default of 3.11.1 and 3.11.2): only
10//!   paths inside a project below `~` (a directory between it and `~` holds
11//!   `.git`, `Cargo.toml`, …) are admitted, for reading; loose personal files
12//!   are not, and writes stay in the session's project, host-declared roots
13//!   and allow entries — an agent working in repo A cannot plant
14//!   `~/code/B/.git/hooks/pre-commit`.
15//! - `home` (opt-in; the default of 3.11.3 to 3.12.0): everything below the
16//!   user's home directory is admitted for reading and writing, except the
17//!   protected zones
18//!   where credentials and other programs' private state live (every
19//!   top-level dot entry such as `~/.ssh`, `~/.aws`, `~/.config`, `~/.zshrc` or
20//!   other agents' `~/.claude`, plus `~/Library` on macOS, `~/AppData` and the
21//!   `NTUSER.DAT*` registry hive on Windows). Paths outside the home directory
22//!   stay jailed. It admits writes wherever it admits reads, so it is reported
23//!   as a jail relaxation.
24//! - `project`: the classic boundary — the project root plus allow-lists.
25//!
26//! Explicit allow-lists (`allow_paths`, `extra_roots`, `read_only_roots`) and
27//! lean-ctx's own state dir still widen every scope, so `lean-ctx allow-path
28//! ~/.config/foo` reopens one protected directory without dropping the zone.
29//! `path_jail = false` keeps disabling the jail entirely.
30//!
31//! Resolution precedence (first hit wins): `LEAN_CTX_PATH_JAIL_SCOPE` env →
32//! `path_jail_scope` in the global `config.toml` → `projects`. Like `path_jail` it
33//! is global-only: a project-local `.lean-ctx.toml` is never merged for it.
34
35use std::path::{Path, PathBuf};
36
37/// Top-level home entries (besides every dot entry) holding other programs'
38/// private data: keychains, browser profiles, mail, app containers (Linux
39/// snaps keep each app's dot dirs — cookies, logins — under `~/snap/<app>`).
40const PROTECTED_HOME_DIRS: &[&str] = &["Library", "AppData", "snap"];
41
42tokio::task_local! {
43    /// The roots the tool call being served may write in: its project and its
44    /// session's host-declared roots. Set per call and never accumulated, so a
45    /// root one call (or one client) declared is not writable for the next
46    /// (AP04-G9).
47    static REQUEST_WRITE_ROOTS: Vec<PathBuf>;
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum PathJailScope {
52    /// Everything below `$HOME` except the protected zones, read and write.
53    Home,
54    /// Projects below `$HOME`, read-only outside the session's project.
55    #[default]
56    Projects,
57    /// Only the project root plus the configured allow-lists.
58    Project,
59}
60
61impl PathJailScope {
62    /// Lenient parse; unknown text returns `None` so callers fall back to the default.
63    pub fn parse(value: &str) -> Option<Self> {
64        match value.trim().to_ascii_lowercase().as_str() {
65            "home" | "user" => Some(Self::Home),
66            "projects" | "workspace" => Some(Self::Projects),
67            "project" | "strict" | "root" => Some(Self::Project),
68            _ => None,
69        }
70    }
71
72    pub fn as_str(self) -> &'static str {
73        match self {
74            Self::Home => "home",
75            Self::Projects => "projects",
76            Self::Project => "project",
77        }
78    }
79
80    /// Active scope: env override → config → default (`projects`). A
81    /// launcher-bound Workspace (#2120) always means `project`: its roots,
82    /// not the home directory, bound every path.
83    pub fn resolve() -> Self {
84        if crate::core::workspace_authority::is_configured() {
85            return Self::Project;
86        }
87        Self::explicit().unwrap_or_default()
88    }
89
90    /// The scope the user chose (env or config), if any.
91    fn explicit() -> Option<Self> {
92        if let Ok(raw) = std::env::var("LEAN_CTX_PATH_JAIL_SCOPE")
93            && let Some(scope) = Self::parse(&raw)
94        {
95            return Some(scope);
96        }
97        crate::core::config::Config::load()
98            .path_jail_scope
99            .as_deref()
100            .and_then(Self::parse)
101    }
102}
103
104/// Canonical home directory for the protected-zone checks; `None` when `$HOME`
105/// is the filesystem root.
106fn zone_home() -> Option<PathBuf> {
107    let home = super::pathjail::canonicalize_secure(&dirs::home_dir()?);
108    home.parent().is_some().then_some(home)
109}
110
111/// Home directory the `home` scope may open. `$HOME` is only an env var: in
112/// containers and CI it is often `/`, `/root` or `/tmp`, where "everything
113/// below home" would mean the whole system or other users' files. Require at
114/// least two normal components and, on Unix, ownership by this user;
115/// otherwise the jail falls back to the project boundary.
116fn scope_home() -> Option<PathBuf> {
117    let home = zone_home()?;
118    let depth = home
119        .components()
120        .filter(|c| matches!(c, std::path::Component::Normal(_)))
121        .count();
122    if depth < 2 {
123        return None;
124    }
125    #[cfg(unix)]
126    {
127        use std::os::unix::fs::MetadataExt;
128        // SAFETY: geteuid has no arguments or failure state.
129        if std::fs::metadata(&home).ok()?.uid() != unsafe { libc::geteuid() } {
130            return None;
131        }
132    }
133    Some(home)
134}
135
136/// The first path component below `home`, when `path` lies strictly inside it.
137fn first_component_below<'a>(path: &'a Path, home: &Path) -> Option<&'a std::ffi::OsStr> {
138    path.strip_prefix(home)
139        .ok()?
140        .components()
141        .next()
142        .map(std::path::Component::as_os_str)
143}
144
145/// The protected zone (e.g. `~/.ssh`) containing `path`, if any. `path` must
146/// already be canonical; `home` is the canonical home directory.
147fn protected_zone_in(path: &Path, home: &Path) -> Option<PathBuf> {
148    let first = first_component_below(path, home)?;
149    let name = first.to_string_lossy();
150    // Windows keeps the user's registry hive (`NTUSER.DAT*`) directly in home.
151    let protected = name.starts_with('.')
152        || name.to_ascii_lowercase().starts_with("ntuser.")
153        || PROTECTED_HOME_DIRS
154            .iter()
155            .any(|d| name.eq_ignore_ascii_case(d));
156    protected.then(|| home.join(first))
157}
158
159/// True when the active scope admits the canonical path `base`: below the home
160/// directory and outside every protected zone; for `projects` also inside a
161/// project.
162///
163/// "Inside a project" — some directory between `base` and `~` (not `~`
164/// itself) carries a project marker (`.git`, `Cargo.toml`, `package.json`, …).
165/// The `projects` scope keeps loose personal files (`~/Documents/taxes.pdf`)
166/// jailed; `home` admits them. The home directory itself is never admitted
167/// — a tree walk rooted there would descend into `~/Library`.
168pub(crate) fn home_scope_admits(base: &Path) -> bool {
169    let scope = PathJailScope::resolve();
170    if scope == PathJailScope::Project {
171        return false;
172    }
173    let Some(home) = scope_home() else {
174        return false;
175    };
176    admits_below(base, &home)
177        && (scope == PathJailScope::Home || project_containing(base, &home).is_some())
178}
179
180fn admits_below(base: &Path, home: &Path) -> bool {
181    first_component_below(base, home).is_some() && protected_zone_in(base, home).is_none()
182}
183
184/// The nearest directory at or above `path`, strictly below `home`, that
185/// carries a project marker. `has_project_marker` keeps its TCC guard: a
186/// launchd-owned process never probes `~/Documents` & co., so there the scope
187/// admits nothing and the project boundary applies (fail closed).
188fn project_containing(path: &Path, home: &Path) -> Option<PathBuf> {
189    let mut cur = if path.is_dir() { path } else { path.parent()? };
190    while cur != home && cur.starts_with(home) {
191        if crate::core::pathutil::has_project_marker(cur) {
192            return Some(cur.to_path_buf());
193        }
194        cur = cur.parent()?;
195    }
196    None
197}
198
199/// True when the canonical `path` lies in a protected home zone (`~/.ssh`,
200/// `~/Library`, any other dot folder, …).
201pub(crate) fn in_protected_zone(path: &Path) -> bool {
202    zone_home().is_some_and(|home| protected_zone_in(path, &home).is_some())
203}
204
205/// Protected zones are a deny, not only a gap in the widening: a root or allow
206/// entry that merely *contains* a zone (a project rooted at `~` for a dotfiles
207/// repo, an `extra_roots` entry of `~`, a host reporting `~` as its root) must
208/// not open `~/.ssh`. A zone is reachable only through a root or allow entry
209/// located inside it — `~/.codex/worktrees/x`, lean-ctx's own `~/.local/...`
210/// state, `allow-path ~/.config/myapp`, a registered `~/.cargo/registry`.
211pub(crate) fn protected_zone_permits(base: &Path, root: &Path, allow: &[PathBuf]) -> bool {
212    let Some(zone) = zone_home().and_then(|home| protected_zone_in(base, &home)) else {
213        return true;
214    };
215    (root.starts_with(&zone) && base.starts_with(root))
216        || allow
217            .iter()
218            .any(|p| p.starts_with(&zone) && base.starts_with(p))
219}
220
221/// A declared root as a write root; broad roots (`/`, `~`, temp dirs) never are.
222fn write_root(root: &str) -> Option<PathBuf> {
223    if root.trim().is_empty() {
224        return None;
225    }
226    let path = super::pathjail::canonicalize_secure(Path::new(root));
227    (!crate::core::pathutil::is_broad_or_unsafe_root(&path)).then_some(path)
228}
229
230/// Runs one tool call with its project and host-declared roots writable.
231pub fn with_request_write_roots<T>(roots: &[String], operation: impl FnOnce() -> T) -> T {
232    let roots = roots.iter().filter_map(|root| write_root(root)).collect();
233    REQUEST_WRITE_ROOTS.sync_scope(roots, operation)
234}
235
236/// The `projects` scope opens *reads* across your projects; writes stay in the
237/// session's project, host-declared roots and explicit allow entries, so a
238/// prompt-injected agent in repo A cannot plant `~/code/B/.git/hooks/pre-commit`.
239/// The `home` scope admits writes wherever it admits reads. `target` is the
240/// canonical write target. Returns the refusal, or `None` when the write may
241/// proceed (including every path the scope did not admit — the jail decided those).
242pub(crate) fn home_scope_write_denial(target: &Path) -> Option<String> {
243    if PathJailScope::resolve() != PathJailScope::Projects {
244        return None;
245    }
246    let home = scope_home()?;
247    if !admits_below(target, &home) {
248        return None;
249    }
250    // A tool call writes in its own roots; work it hands to another thread
251    // keeps at least its request project. CLI invocations (`lean-ctx pack`,
252    // `lean-ctx call`) have no session; the directory the user ran them in is
253    // their project.
254    let mut writable: Vec<PathBuf> = REQUEST_WRITE_ROOTS
255        .try_with(Clone::clone)
256        .ok()
257        .or_else(|| {
258            crate::core::policy::runtime::REQUEST_PROJECT
259                .try_with(|slot| slot.borrow().clone())
260                .ok()
261                .map(|project| {
262                    project
263                        .and_then(|root| write_root(&root.to_string_lossy()))
264                        .into_iter()
265                        .collect()
266                })
267        })
268        .unwrap_or_else(|| {
269            std::env::current_dir()
270                .ok()
271                .and_then(|cwd| write_root(&cwd.to_string_lossy()))
272                .into_iter()
273                .collect()
274        });
275    writable.extend(super::pathjail::allow_paths_from_env_and_config());
276    if let Ok(state) = crate::core::paths::state_dir() {
277        writable.push(super::pathjail::canonicalize_secure(&state));
278    }
279    if writable.iter().any(|w| target.starts_with(w)) {
280        return None;
281    }
282    Some(format!(
283        "{} is outside the active project — path_jail_scope = projects opens other \
284         projects for reading only. To allow writes there, the user runs in their terminal: \
285         lean-ctx allow-path {} — or `lean-ctx config set path_jail_scope home` for all of ~ \
286         — takes effect immediately, no restart.",
287        target.display(),
288        suggested_allow_dir(target, Some(&home)).display()
289    ))
290}
291
292/// Nearest ancestor of `path` (inclusive) carrying a project marker, below
293/// `home` when it lies there. Falls back to the containing directory. This is
294/// the directory `lean-ctx allow-path` should be offered for: allowing the
295/// whole project beats a second rejection on the neighbouring file.
296fn suggested_allow_dir(path: &Path, home: Option<&Path>) -> PathBuf {
297    let start = if path.is_dir() {
298        path.to_path_buf()
299    } else {
300        path.parent().unwrap_or(path).to_path_buf()
301    };
302    let mut cur = start.as_path();
303    loop {
304        if home.is_some_and(|h| cur == h) || cur.parent().is_none() {
305            break;
306        }
307        if crate::core::pathutil::has_project_marker(cur) {
308            return cur.to_path_buf();
309        }
310        match cur.parent() {
311            Some(p) => cur = p,
312            None => break,
313        }
314    }
315    start
316}
317
318/// Agent-facing explanation for a path the jail rejected, naming the one
319/// command the *user* runs to admit it (agent shells refuse it, see
320/// `shell_allowlist::self_config`). `candidate` is the requested path, `base`
321/// its canonical existing ancestor, `root` the active project.
322pub(crate) fn escape_hint(candidate: &Path, base: &Path, root: &Path) -> String {
323    // Agents looped on the same refusal (99 identical calls in one Cursor
324    // session): say outright that a retry cannot succeed.
325    const TAIL: &str = " — the user runs this in their own terminal; it takes effect \
326                        immediately, no restart. Until then every call on this path is \
327                        refused the same way: do not retry it, ask the user or continue \
328                        without it.";
329    let home = zone_home();
330
331    if home.as_deref() == Some(base) {
332        return ". Your home directory itself is not a project root — it holds ~/.ssh, \
333                ~/Library and other protected locations. Point the tool at a project \
334                below it."
335            .to_string();
336    }
337    if let Some(zone) = home.as_deref().and_then(|h| protected_zone_in(base, h)) {
338        // Offer the narrowest directory, never the whole zone by default.
339        let dir = if base.is_dir() {
340            base
341        } else {
342            base.parent().unwrap_or(base)
343        };
344        return format!(
345            ". {} is a protected location (credentials and other programs' private data \
346             stay jailed). To allow it anyway: lean-ctx allow-path {}{TAIL}",
347            zone.display(),
348            dir.display()
349        );
350    }
351
352    let suggestion = suggested_allow_dir(base, home.as_deref());
353    let under_home = scope_home().is_some_and(|h| admits_below(base, &h));
354    // `projects` scope: below ~ but not inside any project — loose personal files.
355    if under_home && PathJailScope::resolve() == PathJailScope::Projects {
356        return format!(
357            ". {} is in your home directory but not inside a project (no .git, Cargo.toml, \
358             package.json, … above it), so path_jail_scope = projects does not open it. To \
359             allow it: lean-ctx allow-path {} — or all of ~: lean-ctx config set \
360             path_jail_scope home{TAIL}",
361            candidate.display(),
362            suggestion.display()
363        );
364    }
365    if under_home {
366        return format!(
367            ". {} is outside the active project ({}). Allow your whole home directory \
368             (protected locations stay jailed): lean-ctx config set path_jail_scope home — \
369             or only this one: lean-ctx allow-path {}{TAIL}",
370            candidate.display(),
371            root.display(),
372            suggestion.display()
373        );
374    }
375    format!(
376        ". {} is outside the active project ({}) and outside your home directory. To \
377         allow it: lean-ctx allow-path {}{TAIL}",
378        candidate.display(),
379        root.display(),
380        suggestion.display()
381    )
382}
383
384/// What the one-time notice says about the default scope.
385fn default_notice_text() -> String {
386    "\x1b[1mPathJail now writes only in the active project:\x1b[0m agents may read every \
387     project below ~, but write only in the session's project and allowed paths; \
388     protected locations (~/.ssh, ~/.aws, ~/.config, every other dot folder, ~/Library, \
389     ~/AppData) stay closed.\n  \x1b[2mPrevious behaviour (read and write all of ~): \
390     lean-ctx config set path_jail_scope home · active project only: \
391     lean-ctx config set path_jail_scope project\x1b[0m\n"
392        .to_string()
393}
394
395/// Tells a user once that the default scope narrowed to `projects`, on the first
396/// interactive command. Never for MCP, hooks or piped use, and never when the
397/// user chose a scope or turned the jail off: an explicit choice is kept.
398pub fn maybe_show_default_notice() {
399    use std::io::IsTerminal;
400    if !(std::io::stderr().is_terminal() && crate::core::host_env::stdin_is_terminal())
401        || PathJailScope::explicit().is_some()
402        || crate::core::config::Config::load().path_jail == Some(false)
403    {
404        return;
405    }
406    let Ok(marker) =
407        crate::core::paths::state_dir().map(|dir| dir.join("path_jail_projects_default_notice"))
408    else {
409        return;
410    };
411    if marker.exists() {
412        return;
413    }
414    eprint!("{}", default_notice_text());
415    eprintln!();
416    if let Some(parent) = marker.parent() {
417        let _ = std::fs::create_dir_all(parent);
418    }
419    let _ = std::fs::write(marker, "1\n");
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn parse_accepts_canonical_names_and_aliases() {
428        assert_eq!(PathJailScope::parse("home"), Some(PathJailScope::Home));
429        assert_eq!(
430            PathJailScope::parse(" Project "),
431            Some(PathJailScope::Project)
432        );
433        assert_eq!(PathJailScope::parse("strict"), Some(PathJailScope::Project));
434        assert_eq!(
435            PathJailScope::parse("projects"),
436            Some(PathJailScope::Projects)
437        );
438        assert_eq!(PathJailScope::parse("anything"), None);
439        assert_eq!(PathJailScope::default(), PathJailScope::Projects);
440        for s in [
441            PathJailScope::Home,
442            PathJailScope::Projects,
443            PathJailScope::Project,
444        ] {
445            assert_eq!(PathJailScope::parse(s.as_str()), Some(s));
446        }
447    }
448
449    #[test]
450    fn the_notice_names_the_protected_zones_and_both_ways_back() {
451        let text = default_notice_text();
452        for needle in [
453            "~/.ssh",
454            "~/.aws",
455            "~/Library",
456            "path_jail_scope home",
457            "path_jail_scope project",
458        ] {
459            assert!(text.contains(needle), "{needle}");
460        }
461    }
462
463    #[test]
464    fn home_zone_admits_code_and_protects_secrets() {
465        let home = Path::new("/home/u");
466        assert!(admits_below(Path::new("/home/u/code/repo/src/a.rs"), home));
467        assert!(admits_below(Path::new("/home/u/Documents/p"), home));
468
469        for protected in [
470            "/home/u/.ssh/id_ed25519",
471            "/home/u/.aws/credentials",
472            "/home/u/.config/gh/hosts.yml",
473            "/home/u/.zshrc",
474            "/home/u/.claude/projects/x/session.jsonl",
475            "/home/u/Library/Keychains/login.keychain-db",
476            "/home/u/AppData/Roaming/x",
477            "/home/u/NTUSER.DAT",
478        ] {
479            assert!(!admits_below(Path::new(protected), home), "{protected}");
480            assert!(protected_zone_in(Path::new(protected), home).is_some());
481        }
482        // The home dir itself and anything outside it are never admitted.
483        assert!(!admits_below(home, home));
484        assert!(!admits_below(Path::new("/etc/passwd"), home));
485        assert!(!admits_below(Path::new("/home/user2/x"), home));
486    }
487
488    #[test]
489    fn protected_zone_is_the_top_level_entry() {
490        let home = Path::new("/home/u");
491        assert_eq!(
492            protected_zone_in(Path::new("/home/u/.config/gh/hosts.yml"), home),
493            Some(PathBuf::from("/home/u/.config"))
494        );
495        // Dot entries deeper in the tree (a repo's .git) are ordinary code.
496        assert_eq!(
497            protected_zone_in(Path::new("/home/u/code/repo/.git/HEAD"), home),
498            None
499        );
500    }
501
502    /// End-to-end through the real jail with a throwaway `$HOME`: the default
503    /// `projects` scope reads across projects but writes only in the session's
504    /// project, `home` opens all of ~ for reading and writing except protected
505    /// zones, and `project` the single-project boundary.
506    #[cfg(all(unix, not(feature = "no-jail")))]
507    #[test]
508    fn jail_admits_sibling_projects_but_not_protected_zones() {
509        let _iso = crate::core::data_dir::isolated_data_dir();
510        let tmp = tempfile::tempdir().unwrap();
511        let home = super::super::pathjail::canonicalize_secure(tmp.path()).join("home");
512        let (a, b, ssh) = (home.join("code/a"), home.join("code/b"), home.join(".ssh"));
513        for dir in [&a, &b, &ssh] {
514            std::fs::create_dir_all(dir).unwrap();
515        }
516        std::fs::create_dir_all(a.join(".git")).unwrap();
517        std::fs::write(b.join("Cargo.toml"), "[package]").unwrap();
518        std::fs::create_dir_all(b.join("src")).unwrap();
519        std::fs::write(b.join("src/lib.rs"), "x").unwrap();
520        std::fs::write(b.join("lib.rs"), "x").unwrap();
521        std::fs::write(ssh.join("id_ed25519"), "secret").unwrap();
522        let docs = home.join("Documents/Steuern");
523        std::fs::create_dir_all(&docs).unwrap();
524        std::fs::write(docs.join("2025.pdf"), "private").unwrap();
525        let outside = tmp.path().join("outside.txt");
526        std::fs::write(&outside, "x").unwrap();
527
528        let old_home = std::env::var_os("HOME");
529        crate::test_env::set_var("HOME", &home);
530        crate::test_env::remove_var("LEAN_CTX_PATH_JAIL_SCOPE");
531        let jail = super::super::pathjail::jail_path;
532        let write = super::super::pathjail::enforce_writable;
533
534        // The default scope is `projects`: no write into another project's
535        // git hooks (or anywhere else in it) from the session in `a`.
536        assert_eq!(PathJailScope::resolve(), PathJailScope::Projects);
537        assert!(jail(&b.join("lib.rs"), &a).is_ok(), "reads across projects");
538        assert!(write(&b.join(".git/hooks/pre-commit")).is_err());
539        assert!(write(&b.join("lib.rs")).is_err());
540        assert!(
541            super::super::pathjail::active_relaxations()
542                .iter()
543                .all(|r| !r.source.contains("path_jail_scope")),
544            "the default scope is not a relaxation"
545        );
546
547        crate::test_env::set_var("LEAN_CTX_PATH_JAIL_SCOPE", "home");
548        assert!(
549            super::super::pathjail::active_relaxations()
550                .iter()
551                .any(|r| r.source.contains("LEAN_CTX_PATH_JAIL_SCOPE")),
552            "the home scope is reported as a relaxation"
553        );
554        assert!(
555            jail(&b.join("lib.rs"), &a).is_ok(),
556            "sibling project under ~"
557        );
558        assert!(
559            jail(&b.join("src/lib.rs"), &a).is_ok(),
560            "nested file of a sibling project"
561        );
562        // The `home` scope opens all of ~, loose files included.
563        assert!(
564            jail(&docs.join("2025.pdf"), &a).is_ok(),
565            "loose file under ~"
566        );
567        assert!(jail(&docs, &a).is_ok(), "a folder without a project");
568        assert!(jail(&home.join("code"), &a).is_ok(), "parent of projects");
569        let err = jail(&ssh.join("id_ed25519"), &a).unwrap_err().to_string();
570        assert!(err.contains("protected location"), "{err}");
571        let err = jail(&outside, &a).unwrap_err().to_string();
572        assert!(err.contains("outside your home directory"), "{err}");
573        let err = jail(&home, &a).unwrap_err().to_string();
574        assert!(err.contains("not a project root"), "{err}");
575
576        // A root or allow entry that merely contains a zone does not open it
577        // (dotfiles repo at ~); a root inside the zone does.
578        assert!(
579            jail(&ssh.join("id_ed25519"), &home).is_err(),
580            "zone is a deny"
581        );
582        assert!(
583            jail(&ssh.join("id_ed25519"), &ssh).is_ok(),
584            "root inside the zone"
585        );
586
587        // `home` writes wherever it reads; protected zones stay closed because
588        // every write resolves its path through the jail first.
589        assert!(write(&b.join("lib.rs")).is_ok(), "another project");
590        assert!(write(&docs.join("notes.md")).is_ok(), "a loose folder");
591        assert!(
592            jail(&ssh.join("authorized_keys"), &a).is_err(),
593            "protected zone"
594        );
595
596        // `projects`: reads across projects only, writes in the session's project.
597        crate::test_env::set_var("LEAN_CTX_PATH_JAIL_SCOPE", "projects");
598        assert!(jail(&b.join("lib.rs"), &a).is_ok(), "sibling project");
599        let err = jail(&docs.join("2025.pdf"), &a).unwrap_err().to_string();
600        assert!(err.contains("not inside a project"), "{err}");
601        assert!(jail(&home.join("code"), &a).is_err(), "parent of projects");
602        let err = write(&b.join("lib.rs")).unwrap_err();
603        assert!(
604            err.contains("for reading only") && err.contains("allow-path"),
605            "{err}"
606        );
607        assert!(write(&b.join(".git/hooks/pre-commit")).is_err());
608        let as_call = |roots: &[&Path], target: &Path| {
609            let roots: Vec<String> = roots.iter().map(|r| r.to_string_lossy().into()).collect();
610            with_request_write_roots(&roots, || write(target))
611        };
612        assert!(
613            as_call(&[b.as_path()], &b.join("lib.rs")).is_ok(),
614            "session root is writable"
615        );
616        // AP04-G9: a root an earlier call (another client) declared is not
617        // writable for the next call in another project.
618        assert!(as_call(&[a.as_path()], &b.join("lib.rs")).is_err());
619
620        crate::test_env::set_var("LEAN_CTX_PATH_JAIL_SCOPE", "project");
621        let err = jail(&b.join("lib.rs"), &a).unwrap_err().to_string();
622        assert!(
623            err.contains("path_jail_scope home") && err.contains("allow-path"),
624            "{err}"
625        );
626        crate::test_env::remove_var("LEAN_CTX_PATH_JAIL_SCOPE");
627
628        // An implausible $HOME never turns into "everything below /".
629        crate::test_env::set_var("HOME", "/");
630        assert!(
631            jail(&b.join("lib.rs"), &a).is_err(),
632            "HOME=/ falls back to the project"
633        );
634
635        match old_home {
636            Some(h) => crate::test_env::set_var("HOME", h),
637            None => crate::test_env::remove_var("HOME"),
638        }
639    }
640
641    #[test]
642    fn suggestion_prefers_the_enclosing_project() {
643        let tmp = tempfile::tempdir().unwrap();
644        let repo = tmp.path().join("repo");
645        std::fs::create_dir_all(repo.join("src/deep")).unwrap();
646        std::fs::create_dir_all(repo.join(".git")).unwrap();
647        let file = repo.join("src/deep/a.rs");
648        std::fs::write(&file, "x").unwrap();
649        assert_eq!(suggested_allow_dir(&file, None), repo);
650
651        let loose = tmp.path().join("loose");
652        std::fs::create_dir_all(&loose).unwrap();
653        assert_eq!(suggested_allow_dir(&loose, Some(tmp.path())), loose);
654    }
655}