use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_dir(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("omamori-{name}-{nanos}"));
fs::create_dir_all(&dir).unwrap();
dir
}
fn unique_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("omamori-{name}-{nanos}.toml"))
}
fn binary() -> String {
env!("CARGO_BIN_EXE_omamori").to_string()
}
fn clean_ai_env(cmd: &mut Command) -> &mut Command {
cmd.env_remove("CLAUDECODE")
.env_remove("CODEX_CI")
.env_remove("CURSOR_AGENT")
.env_remove("GEMINI_CLI")
.env_remove("CLINE_ACTIVE")
.env_remove("AI_GUARD")
}
#[test]
fn omamori_test_command_succeeds_with_defaults() {
let output = Command::new(binary())
.arg("test")
.output()
.expect("failed to run omamori test");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("detection tests passed"),
"stdout: {stdout}"
);
}
#[test]
fn malformed_config_falls_back_to_defaults() {
let path = unique_path("broken");
fs::write(&path, "[[rules]\nname = ").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
}
let output = Command::new(binary())
.arg("test")
.arg("--config")
.arg(&path)
.output()
.expect("failed to run omamori test");
let _ = fs::remove_file(&path);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Built-in default rules are active"),
"stderr should contain actionable warning: {stderr}"
);
}
#[test]
fn init_stdout_mode_prints_template() {
let output = Command::new(binary())
.args(["init", "--stdout"])
.output()
.expect("failed to run omamori init --stdout");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("# omamori config"));
assert!(stdout.contains("rm-recursive-to-trash"));
assert!(stdout.contains("git-push-force-block"));
}
#[test]
fn init_creates_config_file() {
let dir = unique_dir("init-create");
let config_path = dir.join("omamori").join("config.toml");
let output = Command::new(binary())
.args(["init"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.expect("failed to run omamori init");
assert!(
output.status.success(),
"exit={} stderr: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
assert!(config_path.exists(), "config.toml should be created");
let content = fs::read_to_string(&config_path).unwrap();
assert!(content.contains("# omamori config"));
assert!(content.contains("# [[rules]]"));
assert!(
!content
.lines()
.any(|l| l.trim_start().starts_with("[[rules]]")),
"config should have all rules commented out"
);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let mode = fs::metadata(&config_path).unwrap().mode() & 0o777;
assert_eq!(mode, 0o600, "config should be chmod 600, got {mode:o}");
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn init_refuses_overwrite_without_force() {
let dir = unique_dir("init-noforce");
let config_dir = dir.join("omamori");
fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("config.toml");
fs::write(&config_path, "# existing config\n").unwrap();
let output = Command::new(binary())
.args(["init"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.expect("failed to run omamori init");
assert_eq!(output.status.code(), Some(2));
let content = fs::read_to_string(&config_path).unwrap();
assert_eq!(content, "# existing config\n");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn init_force_overwrites_existing() {
let dir = unique_dir("init-force");
let config_dir = dir.join("omamori");
fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("config.toml");
fs::write(&config_path, "# old config\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&config_path, fs::Permissions::from_mode(0o600)).unwrap();
}
let mut cmd = Command::new(binary());
clean_ai_env(&mut cmd);
let output = cmd
.args(["init", "--force"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.expect("failed to run omamori init --force");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("# omamori config"),
"should be new template"
);
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn init_refuses_symlink_target() {
let dir = unique_dir("init-symlink");
let config_dir = dir.join("omamori");
fs::create_dir_all(&config_dir).unwrap();
let real_file = dir.join("real.toml");
fs::write(&real_file, "# real\n").unwrap();
let config_path = config_dir.join("config.toml");
std::os::unix::fs::symlink(&real_file, &config_path).unwrap();
let output = Command::new(binary())
.args(["init"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.expect("failed to run omamori init");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("symlink"),
"should mention symlink: {stderr}"
);
let content = fs::read_to_string(&real_file).unwrap();
assert_eq!(content, "# real\n");
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn init_refuses_symlinked_parent_directory() {
let dir = unique_dir("init-symlink-dir");
let real_dir = dir.join("real_omamori");
fs::create_dir_all(&real_dir).unwrap();
let symlink_dir = dir.join("omamori");
std::os::unix::fs::symlink(&real_dir, &symlink_dir).unwrap();
let output = Command::new(binary())
.args(["init"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.expect("failed to run omamori init");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("symlink"),
"should mention symlink: {stderr}"
);
assert!(!real_dir.join("config.toml").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn init_fails_without_home_and_xdg() {
let output = Command::new(binary())
.args(["init"])
.env_remove("XDG_CONFIG_HOME")
.env_remove("HOME")
.output()
.expect("failed to run omamori init");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("XDG_CONFIG_HOME") || stderr.contains("HOME"),
"should mention missing env: {stderr}"
);
}
#[test]
fn init_written_config_loads_correctly() {
let dir = unique_dir("init-roundtrip");
let config_path = dir.join("omamori").join("config.toml");
let output = Command::new(binary())
.args(["init"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.expect("failed to run omamori init");
assert!(output.status.success());
let output = Command::new(binary())
.args(["test", "--config"])
.arg(&config_path)
.output()
.expect("failed to run omamori test");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("detection tests passed"));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("warning"),
"should have no warnings: {stderr}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn warning_config_not_found_is_actionable() {
let nonexistent = unique_path("nonexistent");
let output = Command::new(binary())
.args(["test", "--config"])
.arg(&nonexistent)
.output()
.expect("failed to run omamori test");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("config not found"));
assert!(
stderr.contains("omamori init"),
"warning should suggest omamori init: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn warning_bad_permissions_is_actionable() {
let path = unique_path("badperms");
fs::write(&path, "# ok\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
}
let output = Command::new(binary())
.args(["test", "--config"])
.arg(&path)
.output()
.expect("failed to run omamori test");
let _ = fs::remove_file(&path);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("permissions are too open"));
assert!(
stderr.contains("chmod 600"),
"warning should suggest chmod 600: {stderr}"
);
}
use std::io::Write;
use std::process::Stdio;
fn run_cursor_hook(input: &str) -> (String, String, bool) {
let mut child = Command::new(binary())
.args(["cursor-hook"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn cursor-hook");
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
let output = child.wait_with_output().expect("failed to wait");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(stdout, stderr, output.status.success())
}
#[test]
fn cursor_hook_blocks_bin_rm() {
let (stdout, _, success) = run_cursor_hook(
r#"{"command":"/bin/rm -rf /tmp/test","cwd":"/tmp","hook_event_name":"beforeShellExecution"}"#,
);
assert!(success);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout must be valid JSON");
assert_eq!(parsed["continue"], false);
assert_eq!(parsed["permission"], "deny");
assert!(parsed["userMessage"].as_str().unwrap().contains("omamori"));
}
#[test]
fn cursor_hook_allows_safe_command() {
let (stdout, _, success) = run_cursor_hook(
r#"{"command":"ls /tmp","cwd":"/tmp","hook_event_name":"beforeShellExecution"}"#,
);
assert!(success);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout must be valid JSON");
assert_eq!(parsed["continue"], true);
assert_eq!(parsed["permission"], "allow");
}
#[test]
fn cursor_hook_blocks_env_unset() {
let (stdout, _, success) = run_cursor_hook(
r#"{"command":"unset CLAUDECODE && rm -rf /","cwd":"/tmp","hook_event_name":"beforeShellExecution"}"#,
);
assert!(success);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout must be valid JSON");
assert_eq!(parsed["continue"], false);
assert_eq!(parsed["permission"], "deny");
}
#[test]
fn cursor_hook_stdout_is_json_only() {
let (stdout, _, _) = run_cursor_hook(
r#"{"command":"echo hello","cwd":"/tmp","hook_event_name":"beforeShellExecution"}"#,
);
let trimmed = stdout.trim();
assert!(
serde_json::from_str::<serde_json::Value>(trimmed).is_ok(),
"stdout must be valid JSON only, got: {trimmed}"
);
assert_eq!(
trimmed.lines().count(),
1,
"stdout must be exactly one JSON line, got: {trimmed}"
);
}
#[test]
fn cursor_hook_denies_malformed_stdin() {
let (stdout, _, success) = run_cursor_hook("not json at all");
assert!(success);
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON even on bad input");
assert_eq!(parsed["continue"], false);
assert_eq!(parsed["permission"], "deny");
}
#[test]
fn cursor_hook_denies_null_command() {
let (stdout, _, success) = run_cursor_hook(r#"{"command":null,"cwd":"/tmp"}"#);
assert!(success);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["continue"], false);
assert_eq!(parsed["permission"], "deny");
}
#[test]
fn cursor_hook_denies_missing_command_key() {
let (stdout, _, success) = run_cursor_hook(r#"{"foo":"bar","cwd":"/tmp"}"#);
assert!(success);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["continue"], false);
assert_eq!(parsed["permission"], "deny");
}
#[test]
fn cursor_hook_allows_empty_command() {
let (stdout, _, success) = run_cursor_hook(r#"{"command":"","cwd":"/tmp"}"#);
assert!(success);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["continue"], true);
assert_eq!(parsed["permission"], "allow");
}
#[test]
fn cursor_hook_stderr_does_not_leak_command() {
let (_, stderr, success) =
run_cursor_hook(r#"{"command":"echo secret-token-12345","cwd":"/tmp"}"#);
assert!(success);
assert!(
!stderr.contains("secret-token-12345"),
"stderr must not contain the full command string"
);
}
#[test]
fn cursor_hook_allows_python_rmtree() {
let (stdout, _, success) = run_cursor_hook(
r#"{"command":"python3 -c \"import shutil; shutil.rmtree('/tmp/test')\"","cwd":"/tmp"}"#,
);
assert!(success);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout must be valid JSON");
assert_eq!(parsed["continue"], true);
assert_eq!(parsed["permission"], "allow");
}
#[test]
fn cursor_hook_no_warn_safe_python() {
let (stdout, _, success) =
run_cursor_hook(r#"{"command":"python3 -c \"print('hello')\"","cwd":"/tmp"}"#);
assert!(success);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout must be valid JSON");
assert_eq!(parsed["continue"], true);
assert_eq!(parsed["permission"], "allow", "safe python should not warn");
}
#[test]
fn cursor_hook_allows_node_rmsync() {
let (stdout, _, success) = run_cursor_hook(
r#"{"command":"node -e \"require('fs').rmSync('/tmp/test', {recursive: true})\"","cwd":"/tmp"}"#,
);
assert!(success);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout must be valid JSON");
assert_eq!(parsed["continue"], true);
assert_eq!(parsed["permission"], "allow");
}
#[test]
fn config_disable_blocked_in_ai_session() {
let dir = unique_dir("guard-disable");
let mut init_cmd = Command::new(binary());
clean_ai_env(&mut init_cmd);
init_cmd
.args(["init"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.unwrap();
let output = Command::new(binary())
.args(["config", "disable", "git-push-force-block"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.env("CLAUDECODE", "1")
.output()
.unwrap();
assert!(
!output.status.success(),
"should be blocked, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("blocked"));
assert!(stderr.contains("CLAUDECODE") || stderr.contains("claude-code"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn config_enable_blocked_in_ai_session() {
let output = Command::new(binary())
.args(["config", "enable", "git-push-force-block"])
.env("CODEX_CI", "1")
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("blocked"));
}
#[test]
fn uninstall_blocked_in_ai_session() {
let output = Command::new(binary())
.args(["uninstall"])
.env("CURSOR_AGENT", "1")
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("blocked"));
}
#[test]
fn init_force_blocked_in_ai_session() {
let output = Command::new(binary())
.args(["init", "--force"])
.env("GEMINI_CLI", "1")
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("blocked"));
}
#[test]
fn config_disable_core_rule_rejected() {
let dir = unique_dir("guard-core-reject");
let mut init_cmd = Command::new(binary());
clean_ai_env(&mut init_cmd);
init_cmd
.args(["init"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.unwrap();
let mut cmd = Command::new(binary());
clean_ai_env(&mut cmd);
let output = cmd
.args(["config", "disable", "git-push-force-block"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.unwrap();
assert!(
!output.status.success(),
"should be rejected, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("core safety rule"),
"should mention core safety rule: {stderr}"
);
assert!(
stderr.contains("omamori override disable"),
"should suggest override command: {stderr}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn override_disable_core_rule_works() {
let dir = unique_dir("override-allow");
let mut init_cmd = Command::new(binary());
clean_ai_env(&mut init_cmd);
init_cmd
.args(["init"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.unwrap();
let mut cmd = Command::new(binary());
clean_ai_env(&mut cmd);
let output = cmd
.args(["override", "disable", "git-push-force-block"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.unwrap();
assert!(
output.status.success(),
"should be allowed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Override"));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("core (overridden)"),
"should show core (overridden) in config list: {stdout}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn override_disable_blocked_in_ai_session() {
let output = Command::new(binary())
.args(["override", "disable", "git-push-force-block"])
.env("CLAUDECODE", "1")
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("blocked"));
}
#[test]
fn override_enable_restores_core_rule() {
let dir = unique_dir("override-restore");
let mut init_cmd = Command::new(binary());
clean_ai_env(&mut init_cmd);
init_cmd
.args(["init"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.unwrap();
let mut cmd = Command::new(binary());
clean_ai_env(&mut cmd);
cmd.args(["override", "disable", "git-push-force-block"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.unwrap();
let mut cmd = Command::new(binary());
clean_ai_env(&mut cmd);
let output = cmd
.args(["override", "enable", "git-push-force-block"])
.env("XDG_CONFIG_HOME", &dir)
.env_remove("HOME")
.output()
.unwrap();
assert!(
output.status.success(),
"should be allowed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Restored"));
let stdout = String::from_utf8_lossy(&output.stdout);
let push_force_line = stdout
.lines()
.find(|l| l.contains("git-push-force-block"))
.unwrap_or("");
assert!(
push_force_line.contains("core") && !push_force_line.contains("overridden"),
"should show core (active): {push_force_line}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn any_single_ai_env_var_blocks() {
let output = Command::new(binary())
.args(["config", "disable", "git-push-force-block"])
.env_remove("CLAUDECODE")
.env_remove("CODEX_CI")
.env_remove("CURSOR_AGENT")
.env_remove("GEMINI_CLI")
.env_remove("AI_GUARD")
.env("CLINE_ACTIVE", "true")
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("blocked"));
}
#[test]
fn version_flag_prints_version() {
let output = Command::new(binary())
.arg("--version")
.output()
.expect("failed to run omamori --version");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.starts_with("omamori "),
"expected 'omamori <version>', got: {stdout}"
);
assert!(
stdout.contains(env!("CARGO_PKG_VERSION")),
"version mismatch: {stdout}"
);
}
#[test]
fn version_short_flag_works() {
let output = Command::new(binary())
.arg("-V")
.output()
.expect("failed to run omamori -V");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.starts_with("omamori "));
}
#[test]
fn version_subcommand_works() {
let output = Command::new(binary())
.arg("version")
.output()
.expect("failed to run omamori version");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.starts_with("omamori "));
}
#[test]
fn status_command_outputs_health_check() {
let output = Command::new(binary())
.arg("status")
.output()
.expect("failed to run omamori status");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("health check"),
"stdout should contain health check header: {stdout}"
);
assert!(
stdout.contains("Shims:"),
"stdout should contain Shims section: {stdout}"
);
assert!(
stdout.contains("Core Policy:"),
"stdout should contain Core Policy section: {stdout}"
);
}
#[test]
fn status_refresh_creates_baseline() {
let dir = unique_dir("status-refresh");
let shim_dir = dir.join("shim");
fs::create_dir_all(&shim_dir).unwrap();
let fake_bin = dir.join("omamori");
fs::write(&fake_bin, "binary").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&fake_bin, shim_dir.join("rm")).unwrap();
let output = Command::new(binary())
.arg("status")
.arg("--base-dir")
.arg(dir.to_str().unwrap())
.arg("--refresh")
.output()
.expect("failed to run omamori status --refresh");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Baseline refreshed"),
"stdout should confirm baseline refresh: {stdout}"
);
assert!(
dir.join(".integrity.json").exists(),
".integrity.json should exist after --refresh"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_generates_integrity_baseline() {
let dir = unique_dir("install-baseline");
let fake_bin = dir.join("omamori");
fs::write(&fake_bin, "binary").unwrap();
let output = Command::new(binary())
.arg("install")
.arg("--base-dir")
.arg(dir.to_str().unwrap())
.arg("--source")
.arg(fake_bin.to_str().unwrap())
.arg("--hooks")
.output()
.expect("failed to run omamori install");
assert!(
output.status.success(),
"install should succeed. stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
dir.join(".integrity.json").exists(),
".integrity.json should exist after install"
);
let content = fs::read_to_string(dir.join(".integrity.json")).unwrap();
let baseline: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(baseline.get("version").is_some());
assert!(baseline.get("shims").is_some());
assert!(baseline.get("hooks").is_some());
let _ = fs::remove_dir_all(&dir);
}
fn run_hook_check(input: &str) -> (String, String, i32) {
let mut child = Command::new(binary())
.args(["hook-check", "--provider", "claude-code"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn hook-check");
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
let output = child.wait_with_output().expect("failed to wait");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
(stdout, stderr, exit_code)
}
fn pretooluse_bash_json(command: &str) -> String {
serde_json::json!({
"tool_name": "Bash",
"tool_input": { "command": command }
})
.to_string()
}
#[test]
fn hook_check_allow_returns_permission_decision_json() {
let (stdout, _, exit_code) = run_hook_check(&pretooluse_bash_json("ls /tmp"));
assert_eq!(exit_code, 0);
let trimmed = stdout.trim();
assert_eq!(
trimmed.lines().count(),
1,
"stdout must be exactly one JSON line"
);
let parsed: serde_json::Value =
serde_json::from_str(trimmed).expect("stdout must be valid JSON");
let hso = &parsed["hookSpecificOutput"];
assert_eq!(hso["hookEventName"], "PreToolUse");
assert_eq!(hso["permissionDecision"], "allow");
assert!(
hso["permissionDecisionReason"]
.as_str()
.unwrap()
.contains("omamori"),
"reason must contain 'omamori'"
);
}
#[test]
fn hook_check_block_meta_has_empty_stdout_and_exit_2() {
let (stdout, stderr, exit_code) =
run_hook_check(&pretooluse_bash_json("/bin/rm -rf /tmp/test"));
assert_eq!(exit_code, 2, "BLOCK must exit with code 2");
assert!(stdout.trim().is_empty(), "BLOCK stdout must be empty");
assert!(
stderr.contains("omamori"),
"BLOCK stderr must contain block reason"
);
}
#[test]
fn hook_check_block_rule_has_empty_stdout_and_exit_2() {
let (stdout, _, exit_code) = run_hook_check(&pretooluse_bash_json("rm -rf /"));
assert_eq!(exit_code, 2, "BLOCK must exit with code 2");
assert!(stdout.trim().is_empty(), "BLOCK stdout must be empty");
}
#[test]
fn hook_check_block_env_unset_has_empty_stdout_and_exit_2() {
let (stdout, _, exit_code) =
run_hook_check(&pretooluse_bash_json("unset CLAUDECODE && echo pwned"));
assert_eq!(exit_code, 2, "BLOCK must exit with code 2");
assert!(stdout.trim().is_empty(), "BLOCK stdout must be empty");
}
#[test]
fn hook_check_empty_command_returns_allow_json() {
let (stdout, _, exit_code) = run_hook_check(&pretooluse_bash_json(""));
assert_eq!(exit_code, 0);
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("empty command must return valid JSON");
assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn hook_check_verbose_does_not_pollute_stdout() {
let mut child = Command::new(binary())
.args(["hook-check", "--provider", "claude-code"])
.env("OMAMORI_VERBOSE", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn hook-check");
child
.stdin
.take()
.unwrap()
.write_all(pretooluse_bash_json("ls /tmp").as_bytes())
.unwrap();
let output = child.wait_with_output().expect("failed to wait");
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim();
assert_eq!(
trimmed.lines().count(),
1,
"verbose mode must not add lines to stdout"
);
assert!(
serde_json::from_str::<serde_json::Value>(trimmed).is_ok(),
"stdout must remain valid JSON even in verbose mode"
);
}
#[test]
fn hook_check_malformed_input_returns_allow_json() {
let (stdout, _, exit_code) = run_hook_check("this is not json at all");
assert_eq!(exit_code, 0);
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("malformed input must still return valid JSON");
assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn hook_check_empty_stdin_returns_allow_json() {
let (stdout, _, exit_code) = run_hook_check("");
assert_eq!(exit_code, 0);
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("empty stdin must return valid JSON");
assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn hook_check_blocks_integrity_json() {
let patterns = omamori::installer::blocked_command_patterns();
assert!(
patterns.iter().any(|(p, _)| p.contains(".integrity.json")),
"meta-patterns should block .integrity.json editing"
);
}
#[test]
fn blocked_patterns_include_integrity_json() {
let patterns = omamori::installer::blocked_command_patterns();
let has_integrity = patterns.iter().any(|(p, _)| p.contains(".integrity.json"));
assert!(
has_integrity,
"blocked_command_patterns should include .integrity.json"
);
}
fn codex_pretooluse_json(command: &str) -> String {
serde_json::json!({
"session_id": "019d3c44-test",
"turn_id": "019d3c45-test",
"transcript_path": "/tmp/test-session.jsonl",
"cwd": "/tmp",
"hook_event_name": "PreToolUse",
"model": "gpt-5.4",
"permission_mode": "default",
"tool_name": "Bash",
"tool_input": { "command": command },
"tool_use_id": "call_test_001"
})
.to_string()
}
#[test]
fn codex_hook_check_allow() {
let (stdout, _, exit_code) = run_hook_check(&codex_pretooluse_json("ls /tmp"));
assert_eq!(exit_code, 0, "safe command should exit 0");
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn codex_hook_check_block_rm_rf() {
let (stdout, stderr, exit_code) = run_hook_check(&codex_pretooluse_json("rm -rf /"));
assert_eq!(exit_code, 2, "rm -rf should exit 2");
assert!(
stdout.trim().is_empty(),
"block path should produce no stdout"
);
assert!(
stderr.contains("blocked"),
"stderr should contain 'blocked'"
);
}
#[test]
fn codex_hook_check_block_meta_pattern() {
let (_, stderr, exit_code) = run_hook_check(&codex_pretooluse_json("/bin/rm -rf /important"));
assert_eq!(exit_code, 2);
assert!(stderr.contains("blocked"));
}
#[test]
fn codex_hook_check_non_bash_tool_name() {
let input = serde_json::json!({
"session_id": "test",
"tool_name": "Shell",
"tool_input": { "command": "ls /tmp" },
"tool_use_id": "test"
})
.to_string();
let (_, _, exit_code) = run_hook_check(&input);
assert_eq!(exit_code, 0, "non-Bash tool_name should still work");
}
#[test]
fn codex_hook_check_provider_flag() {
let binary = env!("CARGO_BIN_EXE_omamori");
let input = codex_pretooluse_json("ls /tmp");
let mut child = std::process::Command::new(binary)
.args(["hook-check", "--provider", "codex"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
let output = child.wait_with_output().unwrap();
assert_eq!(output.status.code(), Some(0));
}
#[test]
fn codex_hook_check_blocks_hooks_json_edit() {
let (_, stderr, exit_code) = run_hook_check(&codex_pretooluse_json(
"sed -i '' 's/omamori/true/' ~/.codex/hooks.json",
));
assert_eq!(exit_code, 2);
assert!(stderr.contains("blocked"));
}
#[test]
fn codex_hook_check_blocks_config_toml_edit() {
let (_, stderr, exit_code) = run_hook_check(&codex_pretooluse_json(
"echo 'codex_hooks = false' > ~/.codex/config.toml",
));
assert_eq!(exit_code, 2);
assert!(stderr.contains("blocked"));
}
#[cfg(unix)]
#[test]
fn shim_invocation_runs_canary_and_rule_evaluation() {
use std::os::unix::fs::symlink;
let poc_dir = unique_dir("shim-smoke");
let fake_home = poc_dir.join("fakehome");
let shim_dir = fake_home.join(".omamori").join("shim");
fs::create_dir_all(&shim_dir).unwrap();
let target = poc_dir.join("victim");
fs::create_dir_all(&target).unwrap();
fs::write(target.join("file.txt"), "data").unwrap();
let shim_rm = shim_dir.join("rm");
symlink(binary(), &shim_rm).unwrap();
let output = Command::new(&shim_rm)
.args(["-rf", target.to_str().unwrap()])
.env("HOME", &fake_home)
.env_remove("CLAUDECODE")
.env_remove("CODEX_CI")
.env_remove("CURSOR_AGENT")
.env_remove("GEMINI_CLI")
.env_remove("CLINE_ACTIVE")
.env_remove("AI_GUARD")
.output()
.expect("failed to run shim");
let stderr = String::from_utf8_lossy(&output.stderr);
let shim_entered = stderr.contains("omamori")
|| stderr.contains("Trash")
|| stderr.contains("integrity")
|| stderr.contains("health");
assert!(
shim_entered,
"Expected shim stderr to contain omamori diagnostic output, got: {stderr}"
);
let _ = fs::remove_dir_all(&poc_dir);
}
#[test]
fn hook_check_blocks_path_traversal_rm() {
let (_, _, exit_code) = run_hook_check(&pretooluse_bash_json("/bin/../bin/rm -rf /tmp/test"));
assert_eq!(exit_code, 2, "path traversal must be blocked");
}
#[test]
fn hook_check_blocks_dot_segment_rm() {
let (_, _, exit_code) = run_hook_check(&pretooluse_bash_json("/usr/./bin/rm -rf /tmp/test"));
assert_eq!(exit_code, 2, "dot segment must be blocked");
}
#[test]
fn hook_check_blocks_relative_path_rm() {
let (_, _, exit_code) = run_hook_check(&pretooluse_bash_json("./rm -rf /tmp/test"));
assert_eq!(exit_code, 2, "relative path must be blocked");
}
#[test]
fn hook_check_blocks_git_clean_split_flags() {
let (_, _, exit_code) = run_hook_check(&pretooluse_bash_json("git clean -f -d"));
assert_eq!(exit_code, 2, "git clean -f -d must be blocked");
}
#[test]
fn hook_check_blocks_git_clean_df() {
let (_, _, exit_code) = run_hook_check(&pretooluse_bash_json("git clean -df"));
assert_eq!(exit_code, 2, "git clean -df must be blocked");
}
#[test]
fn hook_check_blocks_git_clean_three_flags() {
let (_, _, exit_code) = run_hook_check(&pretooluse_bash_json("git clean -d -f -x"));
assert_eq!(exit_code, 2, "git clean -d -f -x must be blocked");
}
#[test]
fn hook_check_blocks_git_clean_long_force() {
let (_, _, exit_code) = run_hook_check(&pretooluse_bash_json("git clean --force -d"));
assert_eq!(exit_code, 2, "git clean --force must be blocked");
}
#[test]
fn hook_check_allows_git_clean_dry_run() {
let (_, _, exit_code) = run_hook_check(&pretooluse_bash_json("git clean -n"));
assert_eq!(exit_code, 0, "git clean -n (dry-run) must be allowed");
}
#[test]
fn cursor_hook_blocks_git_clean_split_flags() {
let (stdout, _, success) = run_cursor_hook(r#"{"command":"git clean -f -d","cwd":"/tmp"}"#);
assert!(success);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["continue"], false);
assert_eq!(parsed["permission"], "deny");
}
#[test]
fn cursor_hook_blocks_path_traversal_rm() {
let (stdout, _, success) =
run_cursor_hook(r#"{"command":"/bin/../bin/rm -rf /tmp/test","cwd":"/tmp"}"#);
assert!(success);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["continue"], false);
assert_eq!(parsed["permission"], "deny");
}