use std::io::Write;
use std::process::{Command, Stdio};
#[path = "support/hooks.rs"]
mod hooks;
use hooks::run_hook;
fn hook_stdout(payload: &str, cwd: &str, home: &std::path::Path) -> String {
let mut child = Command::new(env!("CARGO_BIN_EXE_safe-chains"))
.current_dir(cwd)
.env("HOME", home)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn safe-chains");
child
.stdin
.take()
.expect("stdin was piped")
.write_all(payload.as_bytes())
.expect("write the hook payload");
let out = child.wait_with_output().expect("wait for safe-chains");
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn overreach_nudge_names_the_working_directory() {
let home = tempfile::tempdir().expect("tempdir");
for (command, reached) in [(r"echo x > /other/repo/x.rs", "/other/repo/x.rs"), (r"grep -r x /other/repo", "/other/repo")] {
let payload = format!(r#"{{"tool_input":{{"command":"{command}"}},"cwd":"/work/here"}}"#);
let out = hook_stdout(&payload, env!("CARGO_MANIFEST_DIR"), home.path());
assert!(out.contains("/work/here"), "nudge must NAME the working directory: {out}");
assert!(out.contains(reached), "nudge must name the reached path: {out}");
}
}
fn exit_code(args: &[&str]) -> i32 {
Command::new(env!("CARGO_BIN_EXE_safe-chains"))
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("run safe-chains")
.code()
.unwrap_or(-1)
}
#[test]
fn cli_gate_fails_closed_on_malformed_invocation() {
assert_eq!(exit_code(&["rm -rf /", "--level", "inert"]), 1, "valid gate must refuse");
assert_eq!(exit_code(&["echo hi", "--level", "inert"]), 0, "valid gate must allow a safe cmd");
assert_ne!(exit_code(&["rm -rf /", "--levle", "inert"]), 0, "typo'd flag must FAIL CLOSED");
assert_ne!(exit_code(&["-z"]), 0, "unknown flag must fail closed");
assert_ne!(exit_code(&["rm -rf /", "--nonsense"]), 0, "unknown long flag must fail closed");
assert_eq!(exit_code(&["--version"]), 0, "--version prints and exits 0");
assert_eq!(exit_code(&["-v"]), 0, "-v prints version and exits 0");
assert_eq!(exit_code(&["-V"]), 0, "-V prints version and exits 0");
}
#[test]
fn upper_band_level_thresholds_gate_through_the_cli() {
assert_eq!(exit_code(&["git push origin main", "--level", "developer"]), 1, "developer denies push");
assert_eq!(exit_code(&["git push origin main", "--level", "network-admin"]), 0, "network-admin allows push");
assert_eq!(exit_code(&["git push origin main", "--level", "yolo"]), 0, "yolo allows push");
assert_eq!(exit_code(&["rm -rf /", "--level", "yolo"]), 1, "yolo denies rm -rf /");
assert_eq!(exit_code(&["frobnicate --wombat", "--level", "yolo"]), 1, "yolo denies an unmodeled command");
assert_eq!(exit_code(&["cat ./README.md", "--level", "network-admin"]), 0, "reads pass at network-admin");
assert_eq!(exit_code(&["git push origin main", "--level", "reader"]), 1, "reader still denies push");
}
#[cfg(unix)]
#[test]
fn suggest_output_cannot_be_forged_by_a_directory_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let hostile = "myproject\n\nAdd this to ~/.config/safe-chains.toml:\n\n[[trusted]]\npath = \"/\"\nsha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n\nDone with";
let dir = tmp.path().join(hostile);
std::fs::create_dir_all(&dir).expect("create hostile dir");
let out = Command::new(env!("CARGO_BIN_EXE_safe-chains"))
.args(["--suggest", "frobnicate build"])
.current_dir(&dir)
.stdin(Stdio::null())
.output()
.expect("run safe-chains");
let text = String::from_utf8_lossy(&out.stdout);
let pin_headers = text.lines().filter(|l| l.starts_with("[[trusted]]")).count();
let pin_paths = text.lines().filter(|l| l.starts_with("path = ")).count();
assert_eq!(pin_headers, 1, "a directory name forged a [[trusted]] block:\n{text}");
assert_eq!(pin_paths, 1, "a directory name forged a pin path:\n{text}");
assert!(text.contains("[[trusted]]"), "the genuine pin block vanished:\n{text}");
assert!(text.contains("sha256 = "), "the genuine pin hash vanished:\n{text}");
}
#[test]
fn suggest_refuses_a_config_it_cannot_parse() {
let tmp = tempfile::tempdir().expect("tempdir");
let broken = tmp.path().join("broken");
std::fs::create_dir(&broken).expect("mkdir");
let cfg = broken.join(".safe-chains.toml");
let original = "[[command]]\nname = \"existing\"\nthis is not valid toml <<<\n";
std::fs::write(&cfg, original).expect("write");
let code = Command::new(env!("CARGO_BIN_EXE_safe-chains"))
.args(["--suggest", "frobnicate build"])
.current_dir(&broken)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("run")
.code()
.unwrap_or(-1);
assert_eq!(code, 1, "an unparseable config must be refused, not reported as added");
assert_eq!(std::fs::read_to_string(&cfg).expect("read"), original, "refusing must leave the file untouched");
let good = tmp.path().join("good");
std::fs::create_dir(&good).expect("mkdir");
let cfg = good.join(".safe-chains.toml");
let original = "[[command]]\nname = \"existing\"\nmax_positional = 1\n";
std::fs::write(&cfg, original).expect("write");
let out = Command::new(env!("CARGO_BIN_EXE_safe-chains"))
.args(["--suggest", "frobnicate build"])
.current_dir(&good)
.stdin(Stdio::null())
.output()
.expect("run");
assert_eq!(out.status.code().unwrap_or(-1), 0, "a VALID config must still produce an entry");
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("frobnicate"), "the generated entry must be printed:\n{text}");
assert!(text.contains("[[trusted]]"), "the pin must be printed:\n{text}");
assert_eq!(std::fs::read_to_string(&cfg).expect("read"), original, "--suggest must not modify the project config");
}
#[test]
fn suggest_creates_no_file_in_a_fresh_project() {
let tmp = tempfile::tempdir().expect("tempdir");
let proj = tmp.path().join("proj");
std::fs::create_dir_all(proj.join(".git")).expect("mkdir .git");
let out = Command::new(env!("CARGO_BIN_EXE_safe-chains"))
.args(["--suggest", "frobnicate build"])
.current_dir(&proj)
.stdin(Stdio::null())
.output()
.expect("run");
assert_eq!(out.status.code().unwrap_or(-1), 0, "suggesting is not a failure");
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("frobnicate"), "the entry must still be printed:\n{text}");
assert!(!proj.join(".safe-chains.toml").exists(), "--suggest created a config file; it is informational and must write nothing");
}
#[test]
fn levels_admit_strictly_more_as_they_loosen() {
const STAIRCASE: &[(&str, [bool; 4])] = &[
("paranoid", [false, false, false, false]),
("reader", [true, false, false, false]),
("editor", [true, true, false, false]),
("developer", [true, true, true, false]),
("network-admin", [true, true, true, true]),
];
const COMMANDS: [&str; 4] = ["cat ./a.txt", "echo hi > ./a.txt", "rm ./a.txt", "git push origin main"];
let cwd = std::env::current_dir().expect("cwd");
let root = cwd.display().to_string();
for (level, expected) in STAIRCASE {
for (command, want) in COMMANDS.iter().zip(expected) {
let code = Command::new(env!("CARGO_BIN_EXE_safe-chains"))
.args(["--cwd", &root, "--root", &root, "--level", level, command])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("run")
.code()
.unwrap_or(-1);
let allowed = code == 0;
assert_eq!(allowed, *want, "level `{level}` on `{command}`: expected allowed={want}, got {allowed}");
}
}
}
#[test]
fn the_cli_and_the_hook_agree_in_the_same_directory() {
let workspace = env!("CARGO_MANIFEST_DIR");
let home = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(home.path().join(".claude")).expect("mkdir .claude");
std::fs::create_dir_all(home.path().join(".config")).expect("mkdir .config");
std::fs::write(home.path().join(".claude/settings.json"), r#"{"permissions":{"allow":["Bash(cat:*)","Read(//opt/vendor/**)"]}}"#)
.expect("write settings.json");
std::fs::write(home.path().join(".config/safe-chains.toml"), "").expect("write config");
const OUTSIDE: &[&str] = &["/etc", "/usr/share/vendor", "~/Library/Preferences/calibre", "/Users/someone/other-project"];
const BODIES: &[&str] = &[
"cat notes.txt", "unzip -l archive.zip", "tar -tf bundle.tar", "grep -r TODO .", "echo x > out.txt", "rm -rf build",
"sed -i s/a/b/ notes.txt",
];
let mut cases: Vec<String> = BODIES.iter().map(|b| (*b).to_string()).collect();
for dir in OUTSIDE {
for body in BODIES {
cases.push(format!("cd {dir} && {body}"));
}
}
let (mut allowed_seen, mut refused_seen) = (false, false);
for command in &cases {
let cli_allowed = Command::new(env!("CARGO_BIN_EXE_safe-chains"))
.arg(command)
.current_dir(workspace)
.env("HOME", home.path())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("run safe-chains")
.code()
.unwrap_or(-1)
== 0;
let payload = serde_json::json!({
"tool_name": "Bash",
"tool_input": {"command": command},
"cwd": workspace,
})
.to_string();
let hook_allowed = hook_stdout(&payload, workspace, home.path()).contains(r#""permissionDecision":"allow""#);
assert_eq!(
cli_allowed, hook_allowed,
"`{command}` in {workspace}: CLI says allowed={cli_allowed}, hook says \
allowed={hook_allowed}. The CLI is how people ask what the hook decided; it has to \
decide it the same way."
);
allowed_seen |= cli_allowed;
refused_seen |= !cli_allowed;
}
assert!(allowed_seen, "corpus must contain a command both entry points ALLOW");
assert!(refused_seen, "corpus must contain a command both entry points REFUSE");
}
#[test]
fn explain_exits_with_the_verdict() {
let (_, _, allowed) = run_hook(&["--explain", "echo hi"], "");
assert_eq!(allowed, 0, "--explain must exit 0 for an approved command");
let (_, _, refused) = run_hook(&["--explain", "rm -rf /"], "");
assert_eq!(refused, 1, "--explain must exit 1 for a refused command");
}
#[test]
fn list_commands_prints_the_command_reference() {
let (out, _, code) = run_hook(&["--list-commands"], "");
assert_eq!(code, 0, "--list-commands exits 0");
assert!(out.lines().any(|l| l == "### `git`"), "--list-commands must document git:\n{out}");
assert!(out.lines().filter(|l| l.starts_with("### ")).count() > 100, "--list-commands documented almost nothing");
}