#![allow(clippy::unwrap_used)]
mod common;
use common::{run_rippy, run_rippy_in_dir};
#[test]
fn bash_c_recurses() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"bash -c 'ls -la'"}}"#;
let (stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn wrapper_time_git_status() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"time git status"}}"#;
let (stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn redirect_to_dev_null_safe() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"echo foo > /dev/null"}}"#;
let (stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn redirect_to_file_asks() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"echo foo > /tmp/output.txt"}}"#;
let (_stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(code, 2);
}
#[test]
fn heredoc_safe_allows() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"cat <<EOF\nhello\nEOF"}}"#;
let (stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn allow_command_creates_toml_rule() {
let dir = tempfile::TempDir::new().unwrap();
let output = std::process::Command::new(common::rippy_binary())
.args(["allow", "git status"])
.current_dir(dir.path())
.output()
.unwrap();
assert!(output.status.success());
let content = std::fs::read_to_string(dir.path().join(".rippy.toml")).unwrap();
assert!(content.contains("action = \"allow\""));
assert!(content.contains("pattern = \"git status\""));
}
#[test]
fn deny_command_with_message() {
let dir = tempfile::TempDir::new().unwrap();
let output = std::process::Command::new(common::rippy_binary())
.args(["deny", "rm -rf *", "use trash instead"])
.current_dir(dir.path())
.output()
.unwrap();
assert!(output.status.success());
let content = std::fs::read_to_string(dir.path().join(".rippy.toml")).unwrap();
assert!(content.contains("action = \"deny\""));
assert!(content.contains("message = \"use trash instead\""));
}
#[test]
fn ask_command_creates_toml_rule() {
let dir = tempfile::TempDir::new().unwrap();
let output = std::process::Command::new(common::rippy_binary())
.args(["ask", "docker run *"])
.current_dir(dir.path())
.output()
.unwrap();
assert!(output.status.success());
let content = std::fs::read_to_string(dir.path().join(".rippy.toml")).unwrap();
assert!(content.contains("action = \"ask\""));
assert!(content.contains("pattern = \"docker run *\""));
}
#[test]
fn allow_global_writes_to_home_config() {
let dir = tempfile::TempDir::new().unwrap();
let output = std::process::Command::new(common::rippy_binary())
.args(["allow", "git status", "--global"])
.env("HOME", dir.path())
.output()
.unwrap();
assert!(output.status.success());
let content = std::fs::read_to_string(dir.path().join(".rippy/config.toml")).unwrap();
assert!(content.contains("action = \"allow\""));
assert!(content.contains("pattern = \"git status\""));
}
#[test]
fn suggest_from_command_output() {
let dir = tempfile::TempDir::new().unwrap();
let output = std::process::Command::new(common::rippy_binary())
.args(["suggest", "--from-command", "git push origin main"])
.current_dir(dir.path())
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("git push origin main"));
assert!(stdout.contains("git push *"));
assert!(stdout.contains("git *"));
assert!(!dir.path().join(".rippy.toml").exists());
}
#[test]
fn suggest_from_db_json() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch(
"PRAGMA journal_mode=WAL;
CREATE TABLE decisions (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
session_id TEXT, mode TEXT, tool_name TEXT NOT NULL,
command TEXT, decision TEXT NOT NULL, reason TEXT, payload_json TEXT
);",
)
.unwrap();
for _ in 0..15 {
conn.execute(
"INSERT INTO decisions (tool_name, command, decision, reason) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["Bash", "git status", "allow", "safe"],
)
.unwrap();
}
for _ in 0..8 {
conn.execute(
"INSERT INTO decisions (tool_name, command, decision, reason) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["Bash", "rm -rf /", "deny", "dangerous"],
)
.unwrap();
}
drop(conn);
let output = std::process::Command::new(common::rippy_binary())
.args([
"suggest",
"--db",
db_path.to_str().unwrap(),
"--json",
"--min-count",
"3",
])
.current_dir(dir.path())
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let suggestions: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let arr = suggestions.as_array().unwrap();
assert!(arr.len() >= 2);
let actions: Vec<&str> = arr.iter().filter_map(|s| s["action"].as_str()).collect();
assert!(actions.contains(&"allow"));
assert!(actions.contains(&"deny"));
}
#[test]
fn structured_rule_denies_force_push() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join(".rippy.toml"),
r#"
[[rules]]
action = "deny"
command = "git"
subcommand = "push"
flags = ["--force", "-f"]
message = "No force push"
"#,
)
.unwrap();
let json = r#"{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}"#;
let (_stdout, code) = run_rippy_in_dir(json, "claude", dir.path());
assert_eq!(code, 2);
}
#[test]
fn structured_rule_allows_safe_subcommands() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join(".rippy.toml"),
r#"
[[rules]]
action = "allow"
command = "git"
subcommands = ["status", "log", "diff"]
"#,
)
.unwrap();
let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
let (_stdout, code) = run_rippy_in_dir(json, "claude", dir.path());
assert_eq!(code, 0);
let json2 = r#"{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}"#;
let (_stdout2, code2) = run_rippy_in_dir(json2, "claude", dir.path());
assert_eq!(code2, 2);
}
#[test]
fn structured_rule_with_flag_position_independence() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join(".rippy.toml"),
r#"
[[rules]]
action = "deny"
command = "git"
subcommand = "push"
flags = ["-f"]
message = "No force push"
"#,
)
.unwrap();
let json = r#"{"tool_name":"Bash","tool_input":{"command":"git push origin main -f"}}"#;
let (_, code) = run_rippy_in_dir(json, "claude", dir.path());
assert_eq!(code, 2);
let json2 = r#"{"tool_name":"Bash","tool_input":{"command":"git push -fv origin"}}"#;
let (_, code2) = run_rippy_in_dir(json2, "claude", dir.path());
assert_eq!(code2, 2);
}
use serial_test::serial;
#[test]
#[serial(env)]
fn param_expansion_in_echo_resolves_to_allow() {
unsafe {
std::env::set_var("HOME", "/tmp/test-home");
}
let json = r#"{"tool_name":"Bash","tool_input":{"command":"echo ${HOME}"}}"#;
let (stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(code, 0, "resolved echo should allow, stdout: {stdout}");
}
#[test]
fn ansi_c_quote_in_echo_resolves_to_allow() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"echo $'\\x41'"}}"#;
let (stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(
code, 0,
"resolved ANSI-C echo should allow, stdout: {stdout}"
);
}
#[test]
fn arithmetic_expansion_in_echo_resolves_to_allow() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"echo $((2+2))"}}"#;
let (stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(
code, 0,
"resolved arithmetic echo should allow, stdout: {stdout}"
);
}
#[test]
fn hook_json_reason_contains_resolved_annotation() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"echo $'\\x41'"}}"#;
let (stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(code, 0, "should allow, stdout: {stdout}");
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let reason = v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap_or("");
assert!(
reason.contains("(resolved: echo A)"),
"reason should contain resolved annotation, got: {reason}"
);
}
#[test]
fn brace_expansion_in_ls_resolves_to_allow() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"ls {a,b,c}"}}"#;
let (stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(code, 0, "brace-expanded ls should allow, stdout: {stdout}");
}
#[test]
fn git_with_quoted_subcommand_resolves_via_handler() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"git $'status'"}}"#;
let (stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(
code, 0,
"resolved git status should allow, stdout: {stdout}"
);
}
#[test]
#[serial(env)]
fn unset_variable_asks_with_diagnostic() {
unsafe {
std::env::remove_var("TOTALLY_UNSET_VAR_XYZ_42");
}
let json = r#"{"tool_name":"Bash","tool_input":{"command":"cat $TOTALLY_UNSET_VAR_XYZ_42"}}"#;
let (stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(code, 2, "unset variable should ask, stdout: {stdout}");
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let reason = v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap_or("");
assert!(
reason.contains("$TOTALLY_UNSET_VAR_XYZ_42 is not set"),
"reason should mention unset var, got: {reason}"
);
}
#[test]
fn plain_echo_still_allows() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"echo hello"}}"#;
let (stdout, code) = run_rippy(json, "claude", &[]);
assert_eq!(code, 0, "plain echo should still allow");
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
}