#![allow(clippy::unwrap_used)]
use std::io::Write;
use std::process::{Command, Stdio};
fn binary() -> &'static str {
env!("CARGO_BIN_EXE_safe-chains")
}
fn run_hook(args: &[&str], stdin_payload: &str) -> (String, String, i32) {
let mut child = Command::new(binary())
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn safe-chains");
child
.stdin
.as_mut()
.unwrap()
.write_all(stdin_payload.as_bytes())
.unwrap();
let out = child.wait_with_output().expect("wait");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)
}
#[test]
fn claude_default_invocation_allows_safe_command() {
let payload = r#"{"tool_input": {"command": "ls -la"}}"#;
let (stdout, _stderr, code) = run_hook(&[], payload);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(
v.pointer("/hookSpecificOutput/permissionDecision")
.and_then(|d| d.as_str()),
Some("allow"),
);
}
#[test]
fn claude_default_invocation_denies_unsafe_command() {
let payload = r#"{"tool_input": {"command": "rm -rf /"}}"#;
let (stdout, _stderr, code) = run_hook(&[], payload);
assert_eq!(code, 0);
assert_eq!(stdout, "");
}
#[test]
fn claude_explicit_subcommand_allows_safe_command() {
let payload = r#"{"tool_input": {"command": "git status"}}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "claude"], payload);
assert_eq!(code, 0);
assert!(stdout.contains("\"permissionDecision\":\"allow\""));
}
#[test]
fn claude_invalid_json_exits_zero_silently() {
let (stdout, _stderr, code) = run_hook(&[], "not json at all");
assert_eq!(code, 0);
assert_eq!(stdout, "");
}
#[test]
fn claude_safewrite_carries_appropriate_reason() {
let payload = r#"{"tool_input": {"command": "cargo build"}}"#;
let (stdout, _stderr, code) = run_hook(&[], payload);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect(&stdout);
let reason = v
.pointer("/hookSpecificOutput/permissionDecisionReason")
.and_then(|s| s.as_str())
.unwrap_or("");
assert!(
reason.contains("safe utilities"),
"reason was: {reason}"
);
}
#[test]
fn codex_hook_allows_safe_command() {
let payload = r#"{"tool_name": "Bash", "tool_input": {"command": "ls -la"}}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "codex"], payload);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(
v.pointer("/hookSpecificOutput/permissionDecision")
.and_then(|d| d.as_str()),
Some("allow"),
);
assert_eq!(
v.pointer("/hookSpecificOutput/hookEventName")
.and_then(|d| d.as_str()),
Some("PreToolUse"),
);
}
#[test]
fn codex_hook_denies_unsafe_command() {
let payload = r#"{"tool_name": "Bash", "tool_input": {"command": "rm -rf /etc"}}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "codex"], payload);
assert_eq!(code, 0);
assert_eq!(stdout, "");
}
#[test]
fn codex_hook_handles_optional_cwd() {
let payload = r#"{"tool_input": {"command": "git status"}, "cwd": "/Users/me/project"}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "codex"], payload);
assert_eq!(code, 0);
assert!(stdout.contains("\"permissionDecision\":\"allow\""));
}
#[test]
fn codex_hook_invalid_json_exits_zero_silently() {
let (stdout, _stderr, code) = run_hook(&["hook", "codex"], "{");
assert_eq!(code, 0);
assert_eq!(stdout, "");
}
#[test]
fn unknown_tool_in_hook_subcommand_errors() {
let (_stdout, stderr, code) = run_hook(&["hook", "made-up-tool"], "{}");
assert_eq!(code, 1);
assert!(
stderr.contains("Unknown tool"),
"stderr was: {stderr}"
);
}
#[test]
fn list_tools_includes_every_supported_target() {
let out = Command::new(binary())
.arg("--list-tools")
.output()
.expect("run");
let s = String::from_utf8_lossy(&out.stdout);
for tool in ["claude", "codex", "cursor", "gemini", "copilot", "qwen", "droid", "opencode"] {
assert!(s.contains(tool), "list-tools missing `{tool}`: {s}");
}
}
#[test]
fn cursor_hook_allows_safe_command() {
let payload = r#"{
"conversation_id": "abc-123",
"generation_id": "gen-456",
"model": "claude-sonnet-4-5",
"hook_event_name": "beforeShellExecution",
"cursor_version": "2.0.43",
"workspace_roots": ["/Users/me/project"],
"user_email": null,
"transcript_path": null,
"command": "ls -la",
"cwd": "/Users/me/project",
"sandbox": false
}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "cursor"], payload);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(
v.get("permission").and_then(|s| s.as_str()),
Some("allow"),
);
assert!(v.get("permissionDecision").is_none(), "no Claude wrapper");
assert!(v.get("decision").is_none(), "no Gemini wrapper");
}
#[test]
fn cursor_hook_denies_unsafe_command() {
let payload = r#"{"command": "rm -rf /etc", "cwd": "/x"}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "cursor"], payload);
assert_eq!(code, 0);
assert_eq!(stdout, "");
}
#[test]
fn gemini_hook_allows_safe_command() {
let payload = r#"{
"session_id": "abc",
"transcript_path": "/t",
"cwd": "/p",
"hook_event_name": "BeforeTool",
"timestamp": "2026-05-06T12:00:00Z",
"tool_name": "run_shell_command",
"tool_input": {"command": "ls -la"}
}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "gemini"], payload);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(v.get("decision").and_then(|s| s.as_str()), Some("allow"));
assert!(v.get("permission").is_none(), "no Cursor wrapper");
assert!(v.get("permissionDecision").is_none(), "no Claude wrapper");
}
#[test]
fn gemini_hook_skips_non_shell_tool() {
let payload = r#"{"tool_name": "list_files", "tool_input": {"command": "ignored"}}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "gemini"], payload);
assert_eq!(code, 0);
assert_eq!(stdout, "");
}
#[test]
fn qwen_hook_allows_safe_command() {
let payload = r#"{
"session_id": "abc",
"transcript_path": "/t",
"cwd": "/p",
"hook_event_name": "PreToolUse",
"timestamp": "2026-05-06T12:00:00Z",
"permission_mode": "default",
"tool_name": "Bash",
"tool_input": {"command": "ls -la"},
"tool_use_id": "tu_1"
}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "qwen"], payload);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(
v.pointer("/hookSpecificOutput/permissionDecision")
.and_then(|d| d.as_str()),
Some("allow"),
);
}
#[test]
fn droid_hook_allows_safe_command() {
let payload = r#"{
"session_id": "abc",
"transcript_path": "/t",
"cwd": "/p",
"permission_mode": "off",
"hook_event_name": "PreToolUse",
"tool_name": "Execute",
"tool_input": {"command": "ls -la"}
}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "droid"], payload);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(
v.pointer("/hookSpecificOutput/permissionDecision")
.and_then(|d| d.as_str()),
Some("allow"),
);
}
#[test]
fn copilot_hook_allows_safe_command_via_double_decode() {
let payload = r#"{
"timestamp": 1704614600000,
"cwd": "/path/to/project",
"toolName": "bash",
"toolArgs": "{\"command\":\"ls -la\",\"description\":\"list files\"}"
}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "copilot"], payload);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(
v.get("permissionDecision").and_then(|s| s.as_str()),
Some("allow"),
);
assert!(
v.get("hookSpecificOutput").is_none(),
"Copilot uses flat output — no wrapper",
);
}
#[test]
fn copilot_hook_skips_non_bash_tools() {
let payload = r#"{
"timestamp": 1,
"cwd": "/p",
"toolName": "edit",
"toolArgs": "{\"path\":\"x\"}"
}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "copilot"], payload);
assert_eq!(code, 0);
assert_eq!(stdout, "");
}
#[test]
fn copilot_hook_denies_unsafe_via_double_decode() {
let payload = r#"{
"timestamp": 1,
"cwd": "/p",
"toolName": "bash",
"toolArgs": "{\"command\":\"rm -rf /etc\"}"
}"#;
let (stdout, _stderr, code) = run_hook(&["hook", "copilot"], payload);
assert_eq!(code, 0);
assert_eq!(stdout, "");
}