use std::path::Path;
use crate::common::{Sandbox, USAGE, bash_payload, hook_output, stderr, verdict_and_reason};
fn assert_verdict(sb: &Sandbox, cwd: &Path, command: &str, expected: &str, rule: &str) {
let out = sb.guard("claude-code", &bash_payload("s-paths", cwd, command));
let (verdict, reason) = verdict_and_reason(&out);
assert!(!verdict.is_empty(), "{command}: {}", stderr(&out));
assert_eq!(verdict, expected, "{command}: {reason}");
assert!(reason.contains(rule), "{command}: {reason}");
}
#[test]
fn a_session_in_home_has_no_project() {
let sb = Sandbox::installed(&[".claude"]);
std::fs::create_dir_all(sb.home.join(".ssh")).unwrap();
std::fs::create_dir_all(sb.home.join(".aws")).unwrap();
let home = sb.home.clone();
assert_verdict(&sb, &home, "grep -r . ~/.ssh", "deny", "secrets-paths");
assert_verdict(&sb, &home, "rg -uu . ~/.aws", "deny", "secrets-paths");
for command in [
"echo x > Library/LaunchAgents/evil.plist",
"echo x > ~/.gitconfig",
"cat notes.txt",
] {
assert_verdict(&sb, &home, command, "ask", "default");
}
let project = sb.project();
assert_verdict(&sb, &project, "echo x > notes.txt", "allow", "project-fs");
}
#[test]
fn a_repository_in_home_is_not_the_project() {
let sb = Sandbox::installed(&[".claude"]);
std::fs::create_dir_all(sb.home.join(".git")).unwrap();
let app = sb.home.join("code/app");
std::fs::create_dir_all(&app).unwrap();
assert_verdict(&sb, &sb.home, "echo x > ~/.gitconfig", "ask", "default");
assert_verdict(&sb, &app, "echo x > notes.txt", "allow", "project-fs");
}
#[test]
fn an_explicit_home_project_is_a_usage_error() {
let sb = Sandbox::installed(&[]);
let home = sb.home.to_string_lossy().into_owned();
let out = sb.moat(&["policy", "check", "ls", "--project", &home, "--cwd", &home]);
assert_eq!(out.status.code(), Some(USAGE), "{}", stderr(&out));
assert!(
stderr(&out).contains("cannot be a project root"),
"{}",
stderr(&out)
);
}
#[test]
fn tilde_user_paths_are_home_directories() {
let sb = Sandbox::installed(&[".claude"]);
let project = sb.project();
assert_verdict(
&sb,
&project,
"cat ~home/.ssh/id_rsa",
"deny",
"secrets-paths",
);
assert_verdict(&sb, &project, "cat ~alice/notes.txt", "ask", "unparseable");
assert_verdict(&sb, &project, "cat ~-/notes.txt", "ask", "unparseable");
}
#[test]
fn glob_operands_are_checked_as_the_files_they_match() {
let sb = Sandbox::installed(&[".claude"]);
let project = sb.project();
std::fs::create_dir_all(sb.home.join(".ssh")).unwrap();
std::fs::write(sb.home.join(".ssh/id_rsa"), "key").unwrap();
std::fs::write(project.join(".env"), "TOKEN=x").unwrap();
std::fs::create_dir_all(project.join("src")).unwrap();
std::fs::write(project.join("src/main.rs"), "fn main() {}").unwrap();
for command in [
"cat .env",
"cat .en?",
"cat .e*",
"cat .[e]nv",
"head -c 99 .en[v]",
"cat ~/.ss?/id_rsa",
"cat < .en?",
] {
assert_verdict(&sb, &project, command, "deny", "secrets-paths");
}
for command in ["cat src/*.rs", "cat '.en?'", "cat *"] {
assert_verdict(&sb, &project, command, "allow", "project-fs");
}
}
#[test]
fn brace_operands_are_checked_as_every_word_they_make() {
let sb = Sandbox::installed(&[".claude"]);
let project = sb.project();
std::fs::create_dir_all(sb.home.join(".ssh")).unwrap();
std::fs::write(sb.home.join(".ssh/id_rsa"), "key").unwrap();
std::fs::write(project.join(".env"), "TOKEN=x").unwrap();
for command in [
"cat .{env,x}",
"cat ~/.ssh/{id_rsa,x}",
"cat .e{n,m}v",
"cat .{e{nv,x},y}",
"cat file{1..3} .{env,}",
"{cat,.env}",
"cat .{e,x}n?",
"cat .env x{1..300}",
] {
assert_verdict(&sb, &project, command, "deny", "secrets-paths");
}
for command in ["cat '.{env,x}'", "cat src/{main,lib}.rs"] {
assert_verdict(&sb, &project, command, "allow", "project-fs");
}
}
#[cfg(unix)]
#[test]
fn relative_operands_are_resolved_through_symlinks() {
let sb = Sandbox::installed(&[".claude"]);
let project = sb.project();
std::fs::create_dir_all(sb.home.join(".ssh")).unwrap();
std::fs::write(sb.home.join(".ssh/id_rsa"), "key").unwrap();
std::os::unix::fs::symlink(sb.home.join(".ssh"), project.join("s")).unwrap();
std::os::unix::fs::symlink(sb.home.join(".ssh/id_rsa"), project.join("k")).unwrap();
for command in [
"cat s/id_rsa",
"cat s/*",
"head s/id_rsa",
"grep -r . s/",
"cat k",
"cd s && cat id_rsa",
"cd ~ && cat .ssh/id_rsa",
"cp k out.txt",
] {
assert_verdict(&sb, &project, command, "deny", "secrets-paths");
}
std::fs::create_dir_all(project.join("src")).unwrap();
for command in ["cat src/main.rs", "ls src", "git status", "echo s/id_rsa"] {
assert_verdict(&sb, &project, command, "allow", "dev-shell");
}
assert_verdict(&sb, &project, "npm install left-pad", "ask", "installs");
}
fn real(path: &Path) -> String {
let real = std::fs::canonicalize(path).unwrap();
let real = real.to_string_lossy();
real.strip_prefix(r"\\?\")
.unwrap_or(&real)
.replace('\\', "/")
}
#[test]
fn the_resolved_project_path_is_the_project() {
let sb = Sandbox::installed(&[".claude"]);
let project = sb.project();
std::fs::write(project.join("a"), "x").unwrap();
let file = format!("{}/a", real(&project));
assert_verdict(&sb, &project, &format!("cat {file}"), "allow", "project-fs");
assert_verdict(&sb, &project, "cat a", "allow", "project-fs");
}
#[cfg(unix)]
#[test]
fn a_project_under_a_symlinked_directory_is_the_project() {
let sb = Sandbox::installed(&[".claude"]);
let volume = sb.home.join("../volume/code");
std::fs::create_dir_all(volume.join("app/.git")).unwrap();
std::os::unix::fs::symlink(&volume, sb.home.join("code")).unwrap();
let project = sb.home.join("code/app");
let real = real(&project);
std::fs::write(project.join("a"), "x").unwrap();
for command in [
"cat a".to_owned(),
format!("cat {real}/a"),
format!("echo x > {real}/b"),
] {
assert_verdict(&sb, &project, &command, "allow", "project-fs");
}
let hook = format!("echo x > {real}/.git/hooks/pre-commit");
assert_verdict(&sb, &project, &hook, "ask", "default");
std::fs::create_dir_all(sb.home.join(".ssh")).unwrap();
std::fs::write(sb.home.join(".ssh/id_rsa"), "key").unwrap();
std::os::unix::fs::symlink(sb.home.join(".ssh"), project.join("s")).unwrap();
std::os::unix::fs::symlink("/etc", project.join("e")).unwrap();
assert_verdict(&sb, &project, "cat s/id_rsa", "deny", "secrets-paths");
assert_verdict(&sb, &project, "cat e/hosts", "ask", "default");
}
#[test]
fn an_absolute_glob_pattern_is_a_read_of_its_directory() {
let sb = Sandbox::installed(&[".claude"]);
let project = sb.project();
let home = sb.home.to_string_lossy().replace('\\', "/");
let glob = |pattern: &str| {
let payload = serde_json::json!({
"session_id": "s-glob", "cwd": project.to_string_lossy(),
"hook_event_name": "PreToolUse", "tool_name": "Glob",
"tool_input": {"pattern": pattern, "path": project.to_string_lossy()}
});
let d = hook_output(&sb.guard("claude-code", &payload.to_string()));
(
d["permissionDecision"].clone(),
d["permissionDecisionReason"].to_string(),
)
};
for pattern in [format!("{home}/.ssh/id_*"), "~/.aws/**/cred*".to_owned()] {
let (verdict, reason) = glob(&pattern);
assert_eq!(verdict, "deny", "{pattern}: {reason}");
assert!(reason.contains("secrets-paths"), "{pattern}: {reason}");
}
let (verdict, reason) = glob(&format!("{home}/proj/src/**/*.rs"));
assert_eq!(verdict, "allow", "{reason}");
}