use super::*;
#[cfg(not(feature = "no-jail"))]
#[test]
fn rejects_path_outside_root() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(root.join("a.txt"), "ok").unwrap();
std::fs::write(other.join("b.txt"), "no").unwrap();
let ok = jail_path(&root.join("a.txt"), &root);
assert!(ok.is_ok());
let bad = jail_path(&other.join("b.txt"), &root);
assert!(bad.is_err());
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn read_only_roots_deny_writes_but_allow_reads() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let refrepo = tmp.path().join("refrepo");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(refrepo.join("sub")).unwrap();
std::fs::write(refrepo.join("lib.rs"), "pub fn x() {}\n").unwrap();
let ro_canon = canonicalize_secure(&refrepo);
crate::test_env::set_var(
"LEAN_CTX_READ_ONLY_ROOTS",
ro_canon.to_string_lossy().as_ref(),
);
let existing = refrepo.join("lib.rs");
let new_file = refrepo.join("sub").join("new.rs");
let proj_file = project.join("main.rs");
let read_existing = jail_path(&existing, &project);
let deny_existing = enforce_writable(&existing);
let deny_new = enforce_writable(&new_file);
let allow_project = enforce_writable(&proj_file);
let ro_existing = is_read_only_path(&existing);
let ro_project = is_read_only_path(&proj_file);
crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
assert!(
deny_existing.is_err(),
"write to an existing file in a read-only root must be denied"
);
assert!(
deny_new.is_err(),
"creating a new file in a read-only root must be denied"
);
assert!(
allow_project.is_ok(),
"writes into the project root must stay allowed: {allow_project:?}"
);
assert!(
read_existing.is_ok(),
"reads inside a read-only root must resolve (read allow-list): {read_existing:?}"
);
assert!(ro_existing, "the file is inside the read-only root");
assert!(!ro_project, "the project file is not read-only");
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn honors_path_jail_false_after_mtime_preserving_edit() {
let _iso = crate::core::data_dir::isolated_data_dir();
let cfg_path = crate::core::config::Config::path().unwrap();
if let Some(parent) = cfg_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let secret = outside.join("secret.txt");
std::fs::write(&secret, "x").unwrap();
std::fs::write(&cfg_path, "# jail on\n").unwrap();
let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
assert_eq!(crate::core::config::Config::load().path_jail, None);
std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
assert!(
jail_path(&secret, &root).is_ok(),
"path_jail=false must take effect without a fresh process (#406)"
);
}
#[test]
fn allows_nonexistent_child_under_root() {
let _env = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.txt"), "ok").unwrap();
let p = root.join("new").join("file.txt");
let ok = jail_path(&p, &root).unwrap();
assert!(ok.to_string_lossy().contains("file.txt"));
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn relative_candidate_resolves_against_root_not_cwd() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
let jailed = jail_path(Path::new("sub/file.rs"), &root)
.expect("relative candidate should resolve under the jail root");
assert!(jailed.ends_with("sub/file.rs"));
assert!(
is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
"resolved path must live under the jail root: {jailed:?}"
);
}
#[test]
fn relative_candidate_resolves_against_root_with_the_jail_off() {
let _iso = crate::core::data_dir::isolated_data_dir();
let cfg_path = crate::core::config::Config::path().unwrap();
std::fs::create_dir_all(cfg_path.parent().unwrap()).unwrap();
std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
let resolved = jail_path(Path::new("sub/file.rs"), &root).unwrap();
assert_eq!(
resolved,
canonicalize_or_self(&root.join("sub").join("file.rs"))
);
}
#[test]
fn ide_allow_dirs_are_registry_derived_and_skip_home() {
use crate::core::editor_registry::{ConfigType, EditorTarget};
let home = tempfile::tempdir().unwrap();
let h = home.path();
std::fs::create_dir_all(h.join("Library/Application Support/Code/User")).unwrap();
std::fs::create_dir_all(h.join(".cursor")).unwrap();
let targets = vec![
EditorTarget {
name: "VS Code",
agent_key: "vscode".into(),
config_path: h.join("Library/Application Support/Code/User/mcp.json"),
detect_path: h.join("Library/Application Support/Code"),
config_type: ConfigType::VsCodeMcp,
},
EditorTarget {
name: "Cursor",
agent_key: "cursor".into(),
config_path: h.join(".cursor/mcp.json"),
detect_path: h.join(".cursor"),
config_type: ConfigType::McpJson,
},
EditorTarget {
name: "Claude Code",
agent_key: "claude".into(),
config_path: h.join(".claude.json"),
detect_path: h.join(".no-such-dir"),
config_type: ConfigType::McpJson,
},
];
let mut out = Vec::new();
collect_ide_allow_dirs(h, &targets, &mut out);
assert!(
out.iter().any(|p| p.ends_with("Code/User")),
"non-dotfile VS Code dir must be covered: {out:?}"
);
assert!(out.iter().any(|p| p.ends_with(".cursor")), "{out:?}");
let home_canon = canonicalize_secure(h);
assert!(
!out.contains(&home_canon),
"must never widen the jail to $HOME: {out:?}"
);
}
#[test]
fn ide_config_dirs_are_excluded_by_default() {
let home = tempfile::tempdir().unwrap();
for d in [".lean-ctx", ".cursor", ".codex"] {
std::fs::create_dir_all(home.path().join(d)).unwrap();
}
let denied = home_allow_dirs(home.path(), false);
assert!(
denied.is_empty(),
"foreign editor dirs must stay jailed by default: {denied:?}"
);
let allowed = home_allow_dirs(home.path(), true);
assert!(
allowed.iter().any(|p| p.ends_with(".cursor")),
"opt-in must expose editor dirs: {allowed:?}"
);
assert!(
!allowed.iter().any(|p| p.ends_with(".lean-ctx")),
"lean-ctx's own dir is covered by the data_dir root, not home_allow_dirs: {allowed:?}"
);
}
#[test]
fn canonicalize_or_self_strips_verbatim() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("project");
std::fs::create_dir_all(&dir).unwrap();
let result = canonicalize_or_self(&dir);
let s = result.to_string_lossy();
assert!(
!s.starts_with(r"\\?\"),
"canonicalize_or_self should strip verbatim prefix, got: {s}"
);
}
#[test]
fn jail_path_accepts_same_dir_different_format() {
let _env = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("file.rs"), "ok").unwrap();
let result = jail_path(&root.join("file.rs"), &root);
assert!(result.is_ok(), "same dir should be accepted: {result:?}");
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn error_message_contains_escape_info() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("b.txt"), "no").unwrap();
let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
assert!(
err.to_string().contains("path escapes project root"),
"error should mention escape: {err}"
);
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn escape_error_names_config_keys_without_meta() {
let _iso = crate::core::data_dir::isolated_data_dir();
crate::test_env::remove_var("LEAN_CTX_META");
crate::test_env::remove_var("LEAN_CTX_DIAGNOSTICS");
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("b.txt"), "no").unwrap();
let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("lean-ctx allow-path") && msg.contains("no restart"),
"agent-visible escape error should hint at resolution: {msg}"
);
assert!(msg.contains("do not retry"), "{msg}");
assert!(!msg.contains("LEAN_CTX_EXTRA_ROOTS="), "{msg}");
}
#[test]
fn expand_user_path_expands_tilde_and_vars() {
let _env_lock = crate::core::data_dir::test_env_lock();
let home = dirs::home_dir().expect("home dir");
let env_home = std::env::var_os("HOME").map_or_else(|| home.clone(), PathBuf::from);
let env_home_s = env_home.to_string_lossy().to_string();
assert_eq!(expand_user_path("~"), home);
assert_eq!(expand_user_path("~/code"), home.join("code"));
assert_eq!(expand_user_path("$HOME/code"), env_home.join("code"));
assert_eq!(expand_user_path("${HOME}/code"), env_home.join("code"));
crate::test_env::set_var("LEAN_CTX_TEST_SUB", "sub");
assert_eq!(
expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
PathBuf::from(format!("{env_home_s}/sub/x"))
);
crate::test_env::remove_var("LEAN_CTX_TEST_SUB");
assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
}
#[cfg(windows)]
#[test]
fn expand_user_path_home_falls_back_when_env_is_unset() {
let _env_lock = crate::core::data_dir::test_env_lock();
let expected_home = dirs::home_dir().expect("home dir");
let previous_home = std::env::var_os("HOME");
struct RestoreHome(Option<std::ffi::OsString>);
impl Drop for RestoreHome {
fn drop(&mut self) {
match self.0.take() {
Some(value) => crate::test_env::set_var("HOME", value),
None => crate::test_env::remove_var("HOME"),
}
}
}
let _restore_home = RestoreHome(previous_home);
crate::test_env::remove_var("HOME");
assert_eq!(std::env::var_os("HOME"), None);
assert_eq!(expand_user_path("$HOME/code"), expected_home.join("code"));
assert_eq!(expand_user_path("${HOME}/code"), expected_home.join("code"));
}
#[test]
fn expand_user_path_leaves_unset_vars_verbatim() {
let _env_lock = crate::core::data_dir::test_env_lock();
crate::test_env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
}
#[cfg(unix)]
#[test]
fn allow_path_root_slash_permits_everything() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("b.txt"), "allowed").unwrap();
crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/");
let result = jail_path(&other.join("b.txt"), &root);
crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
}
#[test]
fn active_relaxations_detects_allow_path_env() {
let _iso = crate::core::data_dir::isolated_data_dir();
crate::test_env::remove_var("LEAN_CTX_EXTRA_ROOTS");
crate::test_env::remove_var("LEAN_CTX_ALLOW_IDE_DIRS");
crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/tmp");
let relaxed = active_relaxations();
crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
assert!(
relaxed.iter().any(|r| r.source == "LEAN_CTX_ALLOW_PATH"),
"LEAN_CTX_ALLOW_PATH must be reported as a jail relaxation: {relaxed:?}"
);
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn active_relaxations_empty_when_jail_intact() {
let _iso = crate::core::data_dir::isolated_data_dir();
for var in [
"LEAN_CTX_ALLOW_PATH",
"LCTX_ALLOW_PATH",
"LEAN_CTX_EXTRA_ROOTS",
"LEAN_CTX_ALLOW_IDE_DIRS",
"LEAN_CTX_PATH_JAIL_SCOPE",
] {
crate::test_env::remove_var(var);
}
assert!(
active_relaxations().is_empty(),
"an intact jail (clean config, no relaxation env) must report no relaxations: {:?}",
active_relaxations()
);
}
#[test]
fn allow_path_env_permits_outside_root() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("b.txt"), "allowed").unwrap();
let canon = canonicalize_or_self(&other);
crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
let result = jail_path(&other.join("b.txt"), &root);
crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
assert!(
result.is_ok(),
"LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
);
}
#[cfg(all(unix, not(feature = "no-jail")))]
#[test]
fn rejects_symlink_escape_on_unix() {
use std::os::unix::fs::symlink;
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("secret.txt"), "no").unwrap();
let link = root.join("link.txt");
symlink(other.join("secret.txt"), &link).unwrap();
let bad = jail_path(&link, &root);
assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
}
#[test]
fn rejects_null_byte_in_path() {
let _env = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
std::fs::create_dir_all(&root).unwrap();
let bad_path = PathBuf::from("file\0.txt");
let result = jail_path(&bad_path, &root);
assert!(result.is_err(), "null byte in path must be rejected");
assert!(
result.unwrap_err().to_string().contains("null byte"),
"error must mention null byte"
);
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn extra_roots_permit_paths_outside_jail() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let worktree = tmp.path().join("worktree");
let elsewhere = tmp.path().join("elsewhere");
for d in [&root, &worktree, &elsewhere] {
std::fs::create_dir_all(d).unwrap();
}
let in_worktree = worktree.join("a.txt");
std::fs::write(&in_worktree, "x").unwrap();
let outside = elsewhere.join("b.txt");
std::fs::write(&outside, "y").unwrap();
assert!(jail_path(&in_worktree, &root).is_err());
assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
let extra = vec![worktree.to_string_lossy().to_string()];
assert!(
jail_path_with_roots(&in_worktree, &root, &extra).is_ok(),
"path under a session extra_root must resolve (#403)"
);
assert!(
jail_path_with_roots(&outside, &root, &extra).is_err(),
"paths outside ALL roots must still be rejected"
);
assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn harness_auto_memory_path_is_allowed_without_extra_roots() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let old_home = std::env::var_os("HOME");
crate::test_env::set_var("HOME", tmp.path());
crate::test_env::remove_var("CLAUDE_CONFIG_DIR");
let root = tmp.path().join("project");
let memory = tmp
.path()
.join(".claude")
.join("projects")
.join("-tmp-project")
.join("memory");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&memory).unwrap();
let mem_file = memory.join("MEMORY.md");
std::fs::write(&mem_file, "# index\n").unwrap();
assert!(is_harness_auto_memory_path(&mem_file));
assert!(is_harness_auto_memory_path(&memory));
assert!(!is_harness_auto_memory_path(
&tmp.path()
.join(".claude")
.join("projects")
.join("-tmp-project")
.join("session.jsonl")
));
assert!(
jail_path_with_roots(&mem_file, &root, &[]).is_ok(),
"auto-memory file must pass PathJail without extra_roots"
);
let elsewhere = tempfile::tempdir().unwrap();
let fake = elsewhere
.path()
.join(".claude/projects/-x/memory/MEMORY.md");
std::fs::create_dir_all(fake.parent().unwrap()).unwrap();
std::fs::write(&fake, "x").unwrap();
assert!(!is_harness_auto_memory_path(&fake));
assert!(
jail_path_with_roots(&fake, &root, &[]).is_err(),
"look-alike memory dir outside ~ must stay jailed"
);
assert!(!is_harness_auto_memory_path(
&memory.join("../../../../.ssh/id_ed25519")
));
match old_home {
Some(h) => crate::test_env::set_var("HOME", h),
None => crate::test_env::remove_var("HOME"),
}
}
#[test]
fn state_dir_tee_files_pass_jail() {
let _lock = crate::core::data_dir::test_env_lock();
let state = crate::core::paths::state_dir().expect("state_dir must be available");
let tee_path = state.join("tee").join("some_command_deadbeef.log");
let fake_root = std::env::temp_dir().join("pathjail_test_820_root");
std::fs::create_dir_all(&fake_root).ok();
let result = jail_path_with_roots(&tee_path, &fake_root, &[]);
if state.exists() {
assert!(
result.is_ok(),
"tee-file path under lean-ctx state dir must be auto-allowed: {result:?}"
);
}
std::fs::remove_dir_all(&fake_root).ok();
}
#[test]
fn detected_cache_hint_recognizes_go_cargo_python() {
use std::path::Path;
let go = detected_cache_hint(Path::new("/Users/x/go/pkg/mod/github.com/foo/bar/main.go"));
assert!(go.is_some(), "Go module cache should be detected");
assert!(go.unwrap().contains("Go module cache"));
let cargo = detected_cache_hint(Path::new(
"/home/x/.cargo/registry/src/crates.io/serde-1.0/lib.rs",
));
assert!(cargo.is_some(), "Rust cargo registry should be detected");
assert!(cargo.unwrap().contains("Rust crate registry"));
let py = detected_cache_hint(Path::new(
"/usr/lib/python3.12/site-packages/requests/api.py",
));
assert!(py.is_some(), "Python site-packages should be detected");
let normal = detected_cache_hint(Path::new("/home/x/projects/myapp/src/main.rs"));
assert!(normal.is_none(), "Normal project path should not match");
}
#[test]
fn detect_cache_root_extracts_marker_dir() {
let cases = [
(
"/Users/x/go/pkg/mod/github.com/foo/bar@v1.2.3/baz.go",
"Go module cache",
"/Users/x/go/pkg/mod",
),
(
"/home/u/.cargo/registry/src/index-abc/serde-1.0/src/lib.rs",
"Rust crate registry",
"/home/u/.cargo/registry",
),
(
"/opt/venv/lib/python3.12/site-packages/requests/api.py",
"Python site-packages",
"/opt/venv/lib/python3.12/site-packages",
),
(
"/w/app/node_modules/react/index.js",
"Node modules",
"/w/app/node_modules",
),
(
"/home/u/.pyenv/versions/3.12.4/lib/python3.12/json/__init__.py",
"pyenv Python installations",
"/home/u/.pyenv/versions",
),
];
for (path, want_label, want_root) in cases {
let (label, root) = detect_language_cache_root(Path::new(path))
.unwrap_or_else(|| panic!("expected cache match for {path}"));
assert_eq!(label, want_label, "label for {path}");
assert_eq!(root, PathBuf::from(want_root), "root for {path}");
}
assert!(
detect_language_cache_root(Path::new("/home/u/proj/src/main.rs")).is_none(),
"a normal project path is not a cache"
);
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn registered_cache_root_reads_allow_writes_deny() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let dep = tmp.path().join("go/pkg/mod/example.com/lib@v1");
std::fs::create_dir_all(&dep).unwrap();
let file = dep.join("lib.go");
std::fs::write(&file, "package lib").unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
assert!(jail_path_with_roots(&file, &project, &[]).is_err());
let (_, root) = detect_language_cache_root(&file).expect("cache match");
assert!(
register_session_read_only_root(&root),
"first register is new"
);
assert!(
!register_session_read_only_root(&root),
"re-register is a no-op"
);
assert!(
jail_path_with_roots(&file, &project, &[]).is_ok(),
"registered cache root must be readable"
);
assert!(is_read_only_path(&file), "cache file is read-only");
assert!(
enforce_writable(&file).is_err(),
"writes into the cache root must be denied"
);
}
#[test]
fn agent_instruction_roots_are_admitted_but_credentials_stay_jailed() {
let home = dirs::home_dir().expect("home dir in test env");
for (inside, root) in [
(".claude/skills/review/SKILL.md", ".claude/skills"),
(".codex/skills/x/SKILL.md", ".codex/skills"),
(".claude/CLAUDE.md", ".claude/CLAUDE.md"),
(".agents/skills/a/SKILL.md", ".agents/skills"),
(".cursor/rules/style.mdc", ".cursor/rules"),
(
".claude/projects/-w-p/0a1b/tool-results/out.txt",
".claude/projects/-w-p/0a1b/tool-results",
),
(
".claude/projects/-w-p/0a1b/subagents/agent-1.jsonl",
".claude/projects/-w-p/0a1b/subagents",
),
(
".cursor/projects/17761/agent-tools/x.txt",
".cursor/projects/17761/agent-tools",
),
(
".cursor/projects/17761/terminals/1.txt",
".cursor/projects/17761/terminals",
),
(".claude/settings.json", ".claude/settings.json"),
(".codex/config.toml", ".codex/config.toml"),
(".cursor/mcp.json", ".cursor/mcp.json"),
] {
let (_, detected) = detect_language_cache_root(&home.join(inside))
.unwrap_or_else(|| panic!("{inside} must be admitted"));
assert!(
detected.ends_with(root),
"{inside} → {}",
detected.display()
);
}
for private in [
".claude/.credentials.json",
".claude.json",
".claude/settings.json.bak",
".codex/auth.json",
".codex/sessions/2026/x.jsonl",
".codex/config.toml/../auth.json",
".claude/skills/../.credentials.json",
".claude/projects/p/transcript.jsonl",
".claude/projects/p/s/tool-results/../../../../.credentials.json",
".cursor/projects/17761/mcp-auth.json",
".cursor/projects/17761/agent-transcripts/t.jsonl",
".codex/sessions/2026/10/15/rollout.jsonl",
] {
assert!(
detect_language_cache_root(&home.join(private)).is_none(),
"{private} must stay jailed"
);
}
assert!(
detect_language_cache_root(Path::new("/opt/x/.claude/skills/a/SKILL.md")).is_none(),
"only the home directory's agent folders are admitted"
);
}
#[cfg(unix)]
#[test]
fn host_scratch_dir_is_admitted_only_when_private_to_this_user() {
use std::os::unix::fs::PermissionsExt;
let uid = unsafe { libc::geteuid() };
let dir = std::env::temp_dir().join(format!("claude-{uid}"));
let created = !dir.exists();
if created {
std::fs::create_dir(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
}
let file = dir.join("-w-p/session/scratchpad/q.sql");
let (label, root) = detect_language_cache_root(&file).expect("own scratch dir admitted");
assert_eq!(label, "Claude Code scratchpad");
assert_eq!(root, canonicalize_secure(&dir));
let foreign = std::env::temp_dir().join(format!("claude-{}/x", uid.wrapping_add(1)));
assert!(detect_language_cache_root(&foreign).is_none());
if created {
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap();
assert!(
detect_language_cache_root(&file).is_none(),
"a scratch dir others can write into is not admitted"
);
std::fs::remove_dir(&dir).unwrap();
}
}
#[test]
fn cursor_agent_output_is_admitted_but_workspace_secrets_stay_jailed() {
let _guard = crate::core::data_dir::test_env_lock();
let home = dirs::home_dir().expect("home dir in test env");
for (inside, root) in [
(
".cursor/projects/1776093250124/agent-tools/9f2c.txt",
".cursor/projects/1776093250124/agent-tools",
),
(
".cursor/projects/Users-me-app/terminals/3.txt",
".cursor/projects/Users-me-app/terminals",
),
(
".cursor/projects/w/terminals",
".cursor/projects/w/terminals",
),
] {
let (label, detected) = detect_language_cache_root(&home.join(inside))
.unwrap_or_else(|| panic!("{inside} must be admitted"));
assert_eq!(label, "Cursor agent output");
assert!(
detected.ends_with(root),
"{inside} → {}",
detected.display()
);
}
for private in [
".cursor/projects/w/mcp-auth.json",
".cursor/projects/w/agent-transcripts/a.jsonl",
".cursor/projects/w",
".cursor/projects/agent-tools",
".cursor/projects/w/terminals/../mcp-auth.json",
".cursor/agent-tools/x.txt",
] {
assert!(
detect_language_cache_root(&home.join(private)).is_none(),
"{private} must stay jailed"
);
}
assert!(
detect_language_cache_root(Path::new("/opt/x/.cursor/projects/w/terminals/1.txt"))
.is_none(),
"only the home directory's Cursor folder is admitted"
);
}
#[cfg(not(feature = "no-jail"))]
#[cfg(unix)]
struct RestoreHome(Option<std::ffi::OsString>);
#[cfg(not(feature = "no-jail"))]
#[cfg(unix)]
impl Drop for RestoreHome {
fn drop(&mut self) {
match self.0.take() {
Some(value) => crate::test_env::set_var("HOME", value),
None => crate::test_env::remove_var("HOME"),
}
}
}
#[cfg(not(feature = "no-jail"))]
#[cfg(unix)]
#[test]
fn symlinked_cursor_agent_output_is_never_admitted() {
let _guard = crate::core::data_dir::test_env_lock();
let _restore_home = RestoreHome(std::env::var_os("HOME"));
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().canonicalize().unwrap();
crate::test_env::set_var("HOME", &home);
let ssh = home.join(".ssh");
let notes = home.join("notes");
let projects = home.join(".cursor/projects");
let real = projects.join("real/agent-tools");
std::fs::create_dir_all(&ssh).unwrap();
std::fs::create_dir_all(¬es).unwrap();
std::fs::write(notes.join("a.txt"), "x").unwrap();
std::fs::create_dir_all(&real).unwrap();
std::fs::create_dir_all(projects.join("linked")).unwrap();
std::os::unix::fs::symlink(&ssh, projects.join("linked/agent-tools")).unwrap();
std::os::unix::fs::symlink(&ssh, projects.join("linked-ws")).unwrap();
std::fs::create_dir_all(ssh.join("terminals")).unwrap();
std::os::unix::fs::symlink(¬es, real.join("planted")).unwrap();
assert!(detect_language_cache_root(&real.join("1.txt")).is_some());
for linked in [
projects.join("linked/agent-tools/id_ed25519"),
projects.join("linked-ws/terminals/1.txt"),
] {
assert!(
detect_language_cache_root(&linked).is_none(),
"{} must stay jailed",
linked.display()
);
}
assert!(
!register_symlink_target_read_only(&real.join("planted/a.txt"), &real),
"a link inside the agent's own output folder is never followed"
);
}
#[cfg(not(feature = "no-jail"))]
#[cfg(unix)]
#[test]
fn symlink_in_language_cache_is_never_followed() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let tmp_path = tmp.path().canonicalize().unwrap();
let root = tmp_path.join("go/pkg/mod");
let outside = tmp_path.join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("secret.txt"), "x").unwrap();
std::os::unix::fs::symlink(&outside, root.join("planted")).unwrap();
let project = tmp_path.join("project");
std::fs::create_dir_all(&project).unwrap();
let linked = root.join("planted/secret.txt");
register_session_read_only_root(&root);
assert!(!register_symlink_target_read_only(&linked, &root));
assert!(
jail_path_with_roots(&linked, &project, &[]).is_err(),
"the planted link's target stays jailed"
);
}
#[test]
fn cursor_workspace_slug_matches_cursor_naming() {
for (root, slug) in [
(
"/Users/me/Documents/Projects/lean-ctx",
"Users-me-Documents-Projects-lean-ctx",
),
(
"/private/var/folders/bb/cyj_m1m1/T/tmpO2JVyY",
"private-var-folders-bb-cyj-m1m1-T-tmpO2JVyY",
),
("/home/u/my app.v2", "home-u-my-app-v2"),
] {
assert_eq!(cursor_workspace_slug(Path::new(root)), slug);
}
}
#[cfg(not(feature = "no-jail"))]
#[cfg(unix)]
#[test]
fn cursor_agent_output_opens_only_for_the_own_workspace() {
let _iso = crate::core::data_dir::isolated_data_dir();
let _restore_home = RestoreHome(std::env::var_os("HOME"));
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().canonicalize().unwrap();
crate::test_env::set_var("HOME", &home);
let app = home.join("code/app");
let other = home.join("code/app-other");
let log_of = |project: &Path| {
let dir = home
.join(".cursor/projects")
.join(cursor_workspace_slug(project))
.join("terminals");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("1.txt"), "$ cargo test").unwrap();
dir.join("1.txt")
};
for project in [&app, &other] {
std::fs::create_dir_all(project.join(".git")).unwrap();
}
let app_log = log_of(&app);
let other_log = log_of(&other);
assert!(jail_path_auto_admitting(&app_log, &app, &[]).is_ok());
assert!(
jail_path_auto_admitting(&other_log, &app, &[]).is_err(),
"another project's terminal log stays jailed"
);
assert!(
jail_path_with_roots(&app_log, &other, &[]).is_err(),
"a folder registered for app does not open for app-other"
);
assert!(jail_path_with_roots(&app_log, &app, &[]).is_ok());
assert!(is_read_only_path(&app_log), "writes stay denied");
}