use std::path::{Path, PathBuf};
const PROTECTED_HOME_DIRS: &[&str] = &["Library", "AppData", "snap"];
tokio::task_local! {
static REQUEST_WRITE_ROOTS: Vec<PathBuf>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PathJailScope {
Home,
#[default]
Projects,
Project,
}
impl PathJailScope {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"home" | "user" => Some(Self::Home),
"projects" | "workspace" => Some(Self::Projects),
"project" | "strict" | "root" => Some(Self::Project),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Home => "home",
Self::Projects => "projects",
Self::Project => "project",
}
}
pub fn resolve() -> Self {
if crate::core::workspace_authority::is_configured() {
return Self::Project;
}
Self::explicit().unwrap_or_default()
}
fn explicit() -> Option<Self> {
if let Ok(raw) = std::env::var("LEAN_CTX_PATH_JAIL_SCOPE")
&& let Some(scope) = Self::parse(&raw)
{
return Some(scope);
}
crate::core::config::Config::load()
.path_jail_scope
.as_deref()
.and_then(Self::parse)
}
}
fn zone_home() -> Option<PathBuf> {
let home = super::pathjail::canonicalize_secure(&dirs::home_dir()?);
home.parent().is_some().then_some(home)
}
fn scope_home() -> Option<PathBuf> {
let home = zone_home()?;
let depth = home
.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.count();
if depth < 2 {
return None;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if std::fs::metadata(&home).ok()?.uid() != unsafe { libc::geteuid() } {
return None;
}
}
Some(home)
}
fn first_component_below<'a>(path: &'a Path, home: &Path) -> Option<&'a std::ffi::OsStr> {
path.strip_prefix(home)
.ok()?
.components()
.next()
.map(std::path::Component::as_os_str)
}
fn protected_zone_in(path: &Path, home: &Path) -> Option<PathBuf> {
let first = first_component_below(path, home)?;
let name = first.to_string_lossy();
let protected = name.starts_with('.')
|| name.to_ascii_lowercase().starts_with("ntuser.")
|| PROTECTED_HOME_DIRS
.iter()
.any(|d| name.eq_ignore_ascii_case(d));
protected.then(|| home.join(first))
}
pub(crate) fn home_scope_admits(base: &Path) -> bool {
let scope = PathJailScope::resolve();
if scope == PathJailScope::Project {
return false;
}
let Some(home) = scope_home() else {
return false;
};
admits_below(base, &home)
&& (scope == PathJailScope::Home || project_containing(base, &home).is_some())
}
fn admits_below(base: &Path, home: &Path) -> bool {
first_component_below(base, home).is_some() && protected_zone_in(base, home).is_none()
}
fn project_containing(path: &Path, home: &Path) -> Option<PathBuf> {
let mut cur = if path.is_dir() { path } else { path.parent()? };
while cur != home && cur.starts_with(home) {
if crate::core::pathutil::has_project_marker(cur) {
return Some(cur.to_path_buf());
}
cur = cur.parent()?;
}
None
}
pub(crate) fn in_protected_zone(path: &Path) -> bool {
zone_home().is_some_and(|home| protected_zone_in(path, &home).is_some())
}
pub(crate) fn protected_zone_permits(base: &Path, root: &Path, allow: &[PathBuf]) -> bool {
let Some(zone) = zone_home().and_then(|home| protected_zone_in(base, &home)) else {
return true;
};
(root.starts_with(&zone) && base.starts_with(root))
|| allow
.iter()
.any(|p| p.starts_with(&zone) && base.starts_with(p))
}
fn write_root(root: &str) -> Option<PathBuf> {
if root.trim().is_empty() {
return None;
}
let path = super::pathjail::canonicalize_secure(Path::new(root));
(!crate::core::pathutil::is_broad_or_unsafe_root(&path)).then_some(path)
}
pub fn with_request_write_roots<T>(roots: &[String], operation: impl FnOnce() -> T) -> T {
let roots = roots.iter().filter_map(|root| write_root(root)).collect();
REQUEST_WRITE_ROOTS.sync_scope(roots, operation)
}
pub(crate) fn home_scope_write_denial(target: &Path) -> Option<String> {
if PathJailScope::resolve() != PathJailScope::Projects {
return None;
}
let home = scope_home()?;
if !admits_below(target, &home) {
return None;
}
let mut writable: Vec<PathBuf> = REQUEST_WRITE_ROOTS
.try_with(Clone::clone)
.ok()
.or_else(|| {
crate::core::policy::runtime::REQUEST_PROJECT
.try_with(|slot| slot.borrow().clone())
.ok()
.map(|project| {
project
.and_then(|root| write_root(&root.to_string_lossy()))
.into_iter()
.collect()
})
})
.unwrap_or_else(|| {
std::env::current_dir()
.ok()
.and_then(|cwd| write_root(&cwd.to_string_lossy()))
.into_iter()
.collect()
});
writable.extend(super::pathjail::allow_paths_from_env_and_config());
if let Ok(state) = crate::core::paths::state_dir() {
writable.push(super::pathjail::canonicalize_secure(&state));
}
if writable.iter().any(|w| target.starts_with(w)) {
return None;
}
Some(format!(
"{} is outside the active project — path_jail_scope = projects opens other \
projects for reading only. To allow writes there, the user runs in their terminal: \
lean-ctx allow-path {} — or `lean-ctx config set path_jail_scope home` for all of ~ \
— takes effect immediately, no restart.",
target.display(),
suggested_allow_dir(target, Some(&home)).display()
))
}
fn suggested_allow_dir(path: &Path, home: Option<&Path>) -> PathBuf {
let start = if path.is_dir() {
path.to_path_buf()
} else {
path.parent().unwrap_or(path).to_path_buf()
};
let mut cur = start.as_path();
loop {
if home.is_some_and(|h| cur == h) || cur.parent().is_none() {
break;
}
if crate::core::pathutil::has_project_marker(cur) {
return cur.to_path_buf();
}
match cur.parent() {
Some(p) => cur = p,
None => break,
}
}
start
}
pub(crate) fn escape_hint(candidate: &Path, base: &Path, root: &Path) -> String {
const TAIL: &str = " — the user runs this in their own terminal; it takes effect \
immediately, no restart. Until then every call on this path is \
refused the same way: do not retry it, ask the user or continue \
without it.";
let home = zone_home();
if home.as_deref() == Some(base) {
return ". Your home directory itself is not a project root — it holds ~/.ssh, \
~/Library and other protected locations. Point the tool at a project \
below it."
.to_string();
}
if let Some(zone) = home.as_deref().and_then(|h| protected_zone_in(base, h)) {
let dir = if base.is_dir() {
base
} else {
base.parent().unwrap_or(base)
};
return format!(
". {} is a protected location (credentials and other programs' private data \
stay jailed). To allow it anyway: lean-ctx allow-path {}{TAIL}",
zone.display(),
dir.display()
);
}
let suggestion = suggested_allow_dir(base, home.as_deref());
let under_home = scope_home().is_some_and(|h| admits_below(base, &h));
if under_home && PathJailScope::resolve() == PathJailScope::Projects {
return format!(
". {} is in your home directory but not inside a project (no .git, Cargo.toml, \
package.json, … above it), so path_jail_scope = projects does not open it. To \
allow it: lean-ctx allow-path {} — or all of ~: lean-ctx config set \
path_jail_scope home{TAIL}",
candidate.display(),
suggestion.display()
);
}
if under_home {
return format!(
". {} is outside the active project ({}). Allow your whole home directory \
(protected locations stay jailed): lean-ctx config set path_jail_scope home — \
or only this one: lean-ctx allow-path {}{TAIL}",
candidate.display(),
root.display(),
suggestion.display()
);
}
format!(
". {} is outside the active project ({}) and outside your home directory. To \
allow it: lean-ctx allow-path {}{TAIL}",
candidate.display(),
root.display(),
suggestion.display()
)
}
fn default_notice_text() -> String {
"\x1b[1mPathJail now writes only in the active project:\x1b[0m agents may read every \
project below ~, but write only in the session's project and allowed paths; \
protected locations (~/.ssh, ~/.aws, ~/.config, every other dot folder, ~/Library, \
~/AppData) stay closed.\n \x1b[2mPrevious behaviour (read and write all of ~): \
lean-ctx config set path_jail_scope home · active project only: \
lean-ctx config set path_jail_scope project\x1b[0m\n"
.to_string()
}
pub fn maybe_show_default_notice() {
use std::io::IsTerminal;
if !(std::io::stderr().is_terminal() && crate::core::host_env::stdin_is_terminal())
|| PathJailScope::explicit().is_some()
|| crate::core::config::Config::load().path_jail == Some(false)
{
return;
}
let Ok(marker) =
crate::core::paths::state_dir().map(|dir| dir.join("path_jail_projects_default_notice"))
else {
return;
};
if marker.exists() {
return;
}
eprint!("{}", default_notice_text());
eprintln!();
if let Some(parent) = marker.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(marker, "1\n");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_canonical_names_and_aliases() {
assert_eq!(PathJailScope::parse("home"), Some(PathJailScope::Home));
assert_eq!(
PathJailScope::parse(" Project "),
Some(PathJailScope::Project)
);
assert_eq!(PathJailScope::parse("strict"), Some(PathJailScope::Project));
assert_eq!(
PathJailScope::parse("projects"),
Some(PathJailScope::Projects)
);
assert_eq!(PathJailScope::parse("anything"), None);
assert_eq!(PathJailScope::default(), PathJailScope::Projects);
for s in [
PathJailScope::Home,
PathJailScope::Projects,
PathJailScope::Project,
] {
assert_eq!(PathJailScope::parse(s.as_str()), Some(s));
}
}
#[test]
fn the_notice_names_the_protected_zones_and_both_ways_back() {
let text = default_notice_text();
for needle in [
"~/.ssh",
"~/.aws",
"~/Library",
"path_jail_scope home",
"path_jail_scope project",
] {
assert!(text.contains(needle), "{needle}");
}
}
#[test]
fn home_zone_admits_code_and_protects_secrets() {
let home = Path::new("/home/u");
assert!(admits_below(Path::new("/home/u/code/repo/src/a.rs"), home));
assert!(admits_below(Path::new("/home/u/Documents/p"), home));
for protected in [
"/home/u/.ssh/id_ed25519",
"/home/u/.aws/credentials",
"/home/u/.config/gh/hosts.yml",
"/home/u/.zshrc",
"/home/u/.claude/projects/x/session.jsonl",
"/home/u/Library/Keychains/login.keychain-db",
"/home/u/AppData/Roaming/x",
"/home/u/NTUSER.DAT",
] {
assert!(!admits_below(Path::new(protected), home), "{protected}");
assert!(protected_zone_in(Path::new(protected), home).is_some());
}
assert!(!admits_below(home, home));
assert!(!admits_below(Path::new("/etc/passwd"), home));
assert!(!admits_below(Path::new("/home/user2/x"), home));
}
#[test]
fn protected_zone_is_the_top_level_entry() {
let home = Path::new("/home/u");
assert_eq!(
protected_zone_in(Path::new("/home/u/.config/gh/hosts.yml"), home),
Some(PathBuf::from("/home/u/.config"))
);
assert_eq!(
protected_zone_in(Path::new("/home/u/code/repo/.git/HEAD"), home),
None
);
}
#[cfg(all(unix, not(feature = "no-jail")))]
#[test]
fn jail_admits_sibling_projects_but_not_protected_zones() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let home = super::super::pathjail::canonicalize_secure(tmp.path()).join("home");
let (a, b, ssh) = (home.join("code/a"), home.join("code/b"), home.join(".ssh"));
for dir in [&a, &b, &ssh] {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::create_dir_all(a.join(".git")).unwrap();
std::fs::write(b.join("Cargo.toml"), "[package]").unwrap();
std::fs::create_dir_all(b.join("src")).unwrap();
std::fs::write(b.join("src/lib.rs"), "x").unwrap();
std::fs::write(b.join("lib.rs"), "x").unwrap();
std::fs::write(ssh.join("id_ed25519"), "secret").unwrap();
let docs = home.join("Documents/Steuern");
std::fs::create_dir_all(&docs).unwrap();
std::fs::write(docs.join("2025.pdf"), "private").unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "x").unwrap();
let old_home = std::env::var_os("HOME");
crate::test_env::set_var("HOME", &home);
crate::test_env::remove_var("LEAN_CTX_PATH_JAIL_SCOPE");
let jail = super::super::pathjail::jail_path;
let write = super::super::pathjail::enforce_writable;
assert_eq!(PathJailScope::resolve(), PathJailScope::Projects);
assert!(jail(&b.join("lib.rs"), &a).is_ok(), "reads across projects");
assert!(write(&b.join(".git/hooks/pre-commit")).is_err());
assert!(write(&b.join("lib.rs")).is_err());
assert!(
super::super::pathjail::active_relaxations()
.iter()
.all(|r| !r.source.contains("path_jail_scope")),
"the default scope is not a relaxation"
);
crate::test_env::set_var("LEAN_CTX_PATH_JAIL_SCOPE", "home");
assert!(
super::super::pathjail::active_relaxations()
.iter()
.any(|r| r.source.contains("LEAN_CTX_PATH_JAIL_SCOPE")),
"the home scope is reported as a relaxation"
);
assert!(
jail(&b.join("lib.rs"), &a).is_ok(),
"sibling project under ~"
);
assert!(
jail(&b.join("src/lib.rs"), &a).is_ok(),
"nested file of a sibling project"
);
assert!(
jail(&docs.join("2025.pdf"), &a).is_ok(),
"loose file under ~"
);
assert!(jail(&docs, &a).is_ok(), "a folder without a project");
assert!(jail(&home.join("code"), &a).is_ok(), "parent of projects");
let err = jail(&ssh.join("id_ed25519"), &a).unwrap_err().to_string();
assert!(err.contains("protected location"), "{err}");
let err = jail(&outside, &a).unwrap_err().to_string();
assert!(err.contains("outside your home directory"), "{err}");
let err = jail(&home, &a).unwrap_err().to_string();
assert!(err.contains("not a project root"), "{err}");
assert!(
jail(&ssh.join("id_ed25519"), &home).is_err(),
"zone is a deny"
);
assert!(
jail(&ssh.join("id_ed25519"), &ssh).is_ok(),
"root inside the zone"
);
assert!(write(&b.join("lib.rs")).is_ok(), "another project");
assert!(write(&docs.join("notes.md")).is_ok(), "a loose folder");
assert!(
jail(&ssh.join("authorized_keys"), &a).is_err(),
"protected zone"
);
crate::test_env::set_var("LEAN_CTX_PATH_JAIL_SCOPE", "projects");
assert!(jail(&b.join("lib.rs"), &a).is_ok(), "sibling project");
let err = jail(&docs.join("2025.pdf"), &a).unwrap_err().to_string();
assert!(err.contains("not inside a project"), "{err}");
assert!(jail(&home.join("code"), &a).is_err(), "parent of projects");
let err = write(&b.join("lib.rs")).unwrap_err();
assert!(
err.contains("for reading only") && err.contains("allow-path"),
"{err}"
);
assert!(write(&b.join(".git/hooks/pre-commit")).is_err());
let as_call = |roots: &[&Path], target: &Path| {
let roots: Vec<String> = roots.iter().map(|r| r.to_string_lossy().into()).collect();
with_request_write_roots(&roots, || write(target))
};
assert!(
as_call(&[b.as_path()], &b.join("lib.rs")).is_ok(),
"session root is writable"
);
assert!(as_call(&[a.as_path()], &b.join("lib.rs")).is_err());
crate::test_env::set_var("LEAN_CTX_PATH_JAIL_SCOPE", "project");
let err = jail(&b.join("lib.rs"), &a).unwrap_err().to_string();
assert!(
err.contains("path_jail_scope home") && err.contains("allow-path"),
"{err}"
);
crate::test_env::remove_var("LEAN_CTX_PATH_JAIL_SCOPE");
crate::test_env::set_var("HOME", "/");
assert!(
jail(&b.join("lib.rs"), &a).is_err(),
"HOME=/ falls back to the project"
);
match old_home {
Some(h) => crate::test_env::set_var("HOME", h),
None => crate::test_env::remove_var("HOME"),
}
}
#[test]
fn suggestion_prefers_the_enclosing_project() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir_all(repo.join("src/deep")).unwrap();
std::fs::create_dir_all(repo.join(".git")).unwrap();
let file = repo.join("src/deep/a.rs");
std::fs::write(&file, "x").unwrap();
assert_eq!(suggested_allow_dir(&file, None), repo);
let loose = tmp.path().join("loose");
std::fs::create_dir_all(&loose).unwrap();
assert_eq!(suggested_allow_dir(&loose, Some(tmp.path())), loose);
}
}