use super::constants::PRE_TOOL_USE_KEY;
use super::permissions::{self, PermissionVerdict};
use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::io::{self, Read, Write};
use crate::discover::registry::{has_heredoc, rewrite_command};
const STDIN_CAP: usize = 1_048_576;
fn read_stdin_limited() -> Result<String> {
let mut input = String::new();
io::stdin()
.take((STDIN_CAP + 1) as u64)
.read_to_string(&mut input)
.context("Failed to read stdin")?;
if input.len() > STDIN_CAP {
anyhow::bail!("hook stdin exceeds {} byte limit", STDIN_CAP);
}
Ok(input)
}
enum HookFormat {
VsCode { command: String },
CopilotCli { command: String, args: Value },
PassThrough,
}
pub fn run_copilot() -> Result<()> {
let input = read_stdin_limited()?;
let input = strip_leading_bom(&input).trim();
if input.is_empty() {
return Ok(());
}
let v: Value = match serde_json::from_str(input) {
Ok(v) => v,
Err(e) => {
let _ = writeln!(io::stderr(), "[rtk hook] Failed to parse JSON input: {e}");
return Ok(());
}
};
match detect_format(&v) {
HookFormat::VsCode { command } => handle_vscode(&command),
HookFormat::CopilotCli { command, args } => handle_copilot_cli(&command, &args),
HookFormat::PassThrough => Ok(()),
}
}
fn detect_format(v: &Value) -> HookFormat {
if let Some(tool_name) = v.get("tool_name").and_then(|t| t.as_str()) {
if matches!(tool_name, "runTerminalCommand" | "Bash" | "bash") {
if let Some(cmd) = v
.pointer("/tool_input/command")
.and_then(|c| c.as_str())
.filter(|c| !c.is_empty())
{
return HookFormat::VsCode {
command: cmd.to_string(),
};
}
}
return HookFormat::PassThrough;
}
if let Some(tool_name) = v.get("toolName").and_then(|t| t.as_str()) {
if tool_name == "bash" {
if let Some(tool_args_str) = v.get("toolArgs").and_then(|t| t.as_str()) {
if let Ok(tool_args) = serde_json::from_str::<Value>(tool_args_str) {
if let Some(cmd) = tool_args
.get("command")
.and_then(|c| c.as_str())
.filter(|c| !c.is_empty())
{
return HookFormat::CopilotCli {
command: cmd.to_string(),
args: tool_args,
};
}
}
}
}
return HookFormat::PassThrough;
}
HookFormat::PassThrough
}
fn get_rewritten(cmd: &str) -> Option<String> {
if has_heredoc(cmd) {
return None;
}
let (excluded, transparent_prefixes) = crate::core::config::Config::load()
.map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes))
.unwrap_or_default();
let rewritten = rewrite_command(cmd, &excluded, &transparent_prefixes)?;
if rewritten == cmd {
return None;
}
Some(rewritten)
}
enum HookDecision {
AllowRewrite(String),
AskRewrite(String),
Defer,
Deny,
}
fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision {
if verdict == PermissionVerdict::Deny {
return HookDecision::Deny;
}
if crate::discover::lexer::contains_unattestable_construct(cmd) {
return HookDecision::Defer;
}
match get_rewritten(cmd) {
Some(r) if verdict == PermissionVerdict::Allow => HookDecision::AllowRewrite(r),
Some(r) => HookDecision::AskRewrite(r),
None => HookDecision::Defer,
}
}
fn decide_hook_action(cmd: &str, host: permissions::Host) -> HookDecision {
decide_from_verdict(cmd, permissions::check_command_for(cmd, host))
}
fn handle_vscode(cmd: &str) -> Result<()> {
let (decision, rewritten) = match decide_hook_action(cmd, permissions::Host::Claude) {
HookDecision::Deny => {
audit_log("deny", cmd, "");
return Ok(());
}
HookDecision::Defer => return Ok(()),
HookDecision::AllowRewrite(r) => ("allow", r),
HookDecision::AskRewrite(r) => ("ask", r),
};
audit_log("rewrite", cmd, &rewritten);
let output = json!({
"hookSpecificOutput": {
"hookEventName": PRE_TOOL_USE_KEY,
"permissionDecision": decision,
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": { "command": rewritten }
}
});
let _ = writeln!(io::stdout(), "{output}");
Ok(())
}
fn handle_copilot_cli(cmd: &str, args: &Value) -> Result<()> {
if let Some(response) = copilot_cli_response(cmd, args) {
let _ = writeln!(io::stdout(), "{response}");
}
Ok(())
}
fn copilot_cli_response(cmd: &str, args: &Value) -> Option<Value> {
copilot_cli_response_from_decision(
args,
decide_hook_action(cmd, permissions::Host::Claude),
cmd,
)
}
fn copilot_cli_response_from_decision(
args: &Value,
decision: HookDecision,
cmd: &str,
) -> Option<Value> {
let (rewritten, allow) = match decision {
HookDecision::Deny => {
audit_log("deny", cmd, "");
return None;
}
HookDecision::Defer => return None,
HookDecision::AllowRewrite(r) => (r, true),
HookDecision::AskRewrite(r) => (r, false),
};
audit_log("rewrite", cmd, &rewritten);
let mut modified = args.clone();
if let Some(obj) = modified.as_object_mut() {
obj.insert("command".into(), Value::String(rewritten));
}
let mut response = json!({
"permissionDecisionReason": "RTK auto-rewrite",
"modifiedArgs": modified,
});
if allow {
response["permissionDecision"] = json!("allow");
}
Some(response)
}
pub fn run_gemini() -> Result<()> {
let input = read_stdin_limited()?;
let json: Value = serde_json::from_str(&input).context("Failed to parse hook input as JSON")?;
let tool_name = json.get("tool_name").and_then(|v| v.as_str()).unwrap_or("");
if tool_name != "run_shell_command" {
print_allow();
return Ok(());
}
let cmd = json
.pointer("/tool_input/command")
.and_then(|v| v.as_str())
.unwrap_or("");
if cmd.is_empty() {
print_allow();
return Ok(());
}
match decide_hook_action(cmd, permissions::Host::Gemini) {
HookDecision::Deny => {
let _ = writeln!(
io::stdout(),
r#"{{"decision":"deny","reason":"Blocked by RTK permission rule"}}"#
);
}
HookDecision::AllowRewrite(ref rewritten) => {
audit_log("rewrite", cmd, rewritten);
print_gemini("allow", Some(rewritten));
}
HookDecision::AskRewrite(ref rewritten) => {
audit_log("ask", cmd, rewritten);
print_gemini("ask_user", Some(rewritten));
}
HookDecision::Defer => print_gemini("ask_user", None),
}
Ok(())
}
fn print_allow() {
let _ = writeln!(io::stdout(), r#"{{"decision":"allow"}}"#);
}
fn gemini_json(decision: &str, rewrite: Option<&str>) -> String {
let mut output = serde_json::json!({ "decision": decision });
if let Some(cmd) = rewrite {
output["hookSpecificOutput"] = serde_json::json!({ "tool_input": { "command": cmd } });
}
output.to_string()
}
fn print_gemini(decision: &str, rewrite: Option<&str>) {
let _ = writeln!(io::stdout(), "{}", gemini_json(decision, rewrite));
}
fn audit_log(action: &str, original: &str, rewritten: &str) {
if std::env::var("RTK_HOOK_AUDIT").as_deref() != Ok("1") {
return;
}
let _ = audit_log_inner(action, original, rewritten);
}
fn sanitize_log_field(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('|', "\\|")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
fn audit_log_inner(action: &str, original: &str, rewritten: &str) -> Option<()> {
let home = dirs::home_dir()?;
let dir = home.join(".local").join("share").join("rtk");
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join("hook-audit.log");
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.ok()?;
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S");
writeln!(
file,
"{} | {} | {} | {}",
ts,
action,
sanitize_log_field(original),
sanitize_log_field(rewritten)
)
.ok()
}
enum PayloadAction {
Rewrite {
cmd: String,
rewritten: String,
output: Value,
},
Skip {
reason: &'static str,
cmd: String,
},
Ignore,
}
fn process_claude_payload(v: &Value) -> PayloadAction {
let cmd = match v
.pointer("/tool_input/command")
.and_then(|c| c.as_str())
.filter(|c| !c.is_empty())
{
Some(c) => c,
None => return PayloadAction::Ignore,
};
let (rewritten, allow) = match decide_hook_action(cmd, permissions::Host::Claude) {
HookDecision::Deny => {
return PayloadAction::Skip {
reason: "skip:deny_rule",
cmd: cmd.to_string(),
}
}
HookDecision::Defer => {
return PayloadAction::Skip {
reason: "skip:defer",
cmd: cmd.to_string(),
}
}
HookDecision::AllowRewrite(r) => (r, true),
HookDecision::AskRewrite(r) => (r, false),
};
let updated_input = {
let mut ti = v.get("tool_input").cloned().unwrap_or_else(|| json!({}));
if let Some(obj) = ti.as_object_mut() {
obj.insert("command".into(), Value::String(rewritten.clone()));
}
ti
};
let mut hook_output = json!({
"hookEventName": PRE_TOOL_USE_KEY,
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": updated_input
});
if allow {
hook_output
.as_object_mut()
.unwrap()
.insert("permissionDecision".into(), json!("allow"));
}
PayloadAction::Rewrite {
cmd: cmd.to_string(),
rewritten,
output: json!({ "hookSpecificOutput": hook_output }),
}
}
pub fn run_claude() -> Result<()> {
let input = read_stdin_limited()?;
let input = input.trim();
if input.is_empty() {
return Ok(());
}
let v: Value = match serde_json::from_str(input) {
Ok(v) => v,
Err(e) => {
let _ = writeln!(io::stderr(), "[rtk hook] Failed to parse JSON input: {e}");
return Ok(());
}
};
match process_claude_payload(&v) {
PayloadAction::Rewrite {
cmd,
rewritten,
output,
} => {
audit_log("rewrite", &cmd, &rewritten);
let _ = writeln!(io::stdout(), "{output}");
}
PayloadAction::Skip { reason, cmd } => {
audit_log(reason, &cmd, "");
}
PayloadAction::Ignore => {}
}
Ok(())
}
#[cfg(test)]
fn run_claude_inner(input: &str) -> Option<String> {
let v: Value = serde_json::from_str(input).ok()?;
match process_claude_payload(&v) {
PayloadAction::Rewrite { output, .. } => Some(output.to_string()),
_ => None,
}
}
fn strip_leading_bom(input: &str) -> &str {
let mut s = input;
while let Some(rest) = s.strip_prefix('\u{feff}') {
s = rest;
}
s
}
pub fn run_cursor() -> Result<()> {
let input = read_stdin_limited()?;
let input = strip_leading_bom(&input).trim();
if input.is_empty() {
let _ = writeln!(io::stdout(), "{{}}");
return Ok(());
}
let v: Value = match serde_json::from_str(input) {
Ok(v) => v,
Err(_) => {
let _ = writeln!(io::stdout(), "{{}}");
return Ok(());
}
};
let cmd = match v
.pointer("/tool_input/command")
.and_then(|c| c.as_str())
.filter(|c| !c.is_empty())
{
Some(c) => c.to_string(),
None => {
let _ = writeln!(io::stdout(), "{{}}");
return Ok(());
}
};
let output = match decide_hook_action(&cmd, permissions::Host::Cursor) {
HookDecision::AllowRewrite(rewritten) => {
audit_log("rewrite", &cmd, &rewritten);
cursor_allow(&rewritten)
}
other => {
if matches!(other, HookDecision::Deny) {
audit_log("deny", &cmd, "");
}
"{}".to_string()
}
};
let _ = writeln!(io::stdout(), "{output}");
Ok(())
}
fn cursor_allow(rewritten: &str) -> String {
json!({
"continue": true,
"permission": "allow",
"updated_input": { "command": rewritten }
})
.to_string()
}
#[cfg(test)]
fn run_cursor_inner(input: &str) -> String {
run_cursor_inner_with_rules(input, &[], &[], &[])
}
#[cfg(test)]
fn run_cursor_inner_with_rules(
input: &str,
deny_rules: &[String],
ask_rules: &[String],
allow_rules: &[String],
) -> String {
let input = strip_leading_bom(input);
let v: Value = match serde_json::from_str(input) {
Ok(v) => v,
Err(_) => return "{}".to_string(),
};
let cmd = match v
.pointer("/tool_input/command")
.and_then(|c| c.as_str())
.filter(|c| !c.is_empty())
{
Some(c) => c.to_string(),
None => return "{}".to_string(),
};
let verdict = permissions::check_command_with_rules(&cmd, deny_rules, ask_rules, allow_rules);
match decide_from_verdict(&cmd, verdict) {
HookDecision::AllowRewrite(rewritten) => cursor_allow(&rewritten),
_ => "{}".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rewrite_command_no_prefixes(cmd: &str, excluded: &[String]) -> Option<String> {
crate::discover::registry::rewrite_command(cmd, excluded, &[])
}
fn vscode_input(tool: &str, cmd: &str) -> Value {
json!({
"tool_name": tool,
"tool_input": { "command": cmd }
})
}
fn copilot_cli_input(cmd: &str) -> Value {
let args = serde_json::to_string(&json!({ "command": cmd })).unwrap();
json!({ "toolName": "bash", "toolArgs": args })
}
#[test]
fn test_detect_vscode_bash() {
assert!(matches!(
detect_format(&vscode_input("Bash", "git status")),
HookFormat::VsCode { .. }
));
}
#[test]
fn test_detect_vscode_run_terminal_command() {
assert!(matches!(
detect_format(&vscode_input("runTerminalCommand", "cargo test")),
HookFormat::VsCode { .. }
));
}
#[test]
fn test_detect_copilot_cli_bash() {
assert!(matches!(
detect_format(&copilot_cli_input("git status")),
HookFormat::CopilotCli { .. }
));
}
#[test]
fn test_detect_non_bash_is_passthrough() {
let v = json!({ "tool_name": "editFiles" });
assert!(matches!(detect_format(&v), HookFormat::PassThrough));
}
#[test]
fn test_copilot_bom_prefixed_payload_is_recognized() {
for raw in [
format!("\u{feff}{}", copilot_cli_input("git status")),
format!("\u{feff}\u{feff}{}", copilot_cli_input("git status")),
] {
let cleaned = strip_leading_bom(&raw).trim();
let v: Value = serde_json::from_str(cleaned).expect("BOM-stripped JSON must parse");
assert!(matches!(detect_format(&v), HookFormat::CopilotCli { .. }));
}
let raw = format!("\u{feff}{}", vscode_input("Bash", "git status"));
let v: Value = serde_json::from_str(strip_leading_bom(&raw).trim()).unwrap();
assert!(matches!(detect_format(&v), HookFormat::VsCode { .. }));
}
#[test]
fn test_detect_unknown_is_passthrough() {
assert!(matches!(detect_format(&json!({})), HookFormat::PassThrough));
}
#[test]
fn test_get_rewritten_supported() {
assert!(get_rewritten("git status").is_some());
}
#[test]
fn test_get_rewritten_unsupported() {
assert!(get_rewritten("htop").is_none());
}
#[test]
fn test_get_rewritten_already_rtk() {
assert!(get_rewritten("rtk git status").is_none());
}
#[test]
fn test_get_rewritten_heredoc() {
assert!(get_rewritten("cat <<'EOF'\nhello\nEOF").is_none());
}
fn cli_args(cmd: &str) -> Value {
json!({ "command": cmd })
}
#[test]
fn test_copilot_cli_ask_rewrite_omits_permission_decision() {
let r = copilot_cli_response_from_decision(
&cli_args("cargo test"),
HookDecision::AskRewrite("rtk cargo test".into()),
"cargo test",
)
.unwrap();
assert!(
r.get("permissionDecision").is_none(),
"AskRewrite must NOT set permissionDecision — Copilot then runs its normal prompt flow on the rewritten command"
);
assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test");
}
#[test]
fn test_copilot_cli_allow_rewrite_returns_allow() {
let r = copilot_cli_response_from_decision(
&cli_args("cargo test"),
HookDecision::AllowRewrite("rtk cargo test".into()),
"cargo test",
)
.unwrap();
assert_eq!(r["permissionDecision"], "allow");
assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test");
}
#[test]
fn test_copilot_cli_deny_returns_none() {
assert!(copilot_cli_response_from_decision(
&cli_args("cargo test"),
HookDecision::Deny,
"cargo test",
)
.is_none());
}
#[test]
fn test_copilot_cli_defer_returns_none() {
assert!(copilot_cli_response_from_decision(
&cli_args("git status & rm -rf /tmp/x"),
HookDecision::Defer,
"git status & rm -rf /tmp/x",
)
.is_none());
}
#[test]
fn test_copilot_cli_passthrough_unsupported() {
assert!(copilot_cli_response("htop", &cli_args("htop")).is_none());
}
#[test]
fn test_copilot_cli_passthrough_already_rtk() {
assert!(copilot_cli_response("rtk cargo test", &cli_args("rtk cargo test")).is_none());
}
#[test]
fn test_copilot_cli_passthrough_heredoc() {
let cmd = "cat <<EOF\nhi\nEOF";
assert!(copilot_cli_response(cmd, &cli_args(cmd)).is_none());
}
#[test]
fn test_copilot_cli_preserves_env_prefix() {
let r = copilot_cli_response(
"RUST_LOG=debug cargo test",
&cli_args("RUST_LOG=debug cargo test"),
)
.unwrap();
assert_eq!(
r["modifiedArgs"]["command"],
"RUST_LOG=debug rtk cargo test"
);
}
#[test]
fn test_copilot_cli_preserves_extra_args_fields() {
let args = json!({
"command": "cargo install ripgrep",
"description": "install ripgrep",
"initial_wait": 30,
"mode": "sync"
});
let r = copilot_cli_response_from_decision(
&args,
HookDecision::AskRewrite("rtk cargo install ripgrep".into()),
"cargo install ripgrep",
)
.unwrap();
let modified = &r["modifiedArgs"];
assert_eq!(modified["command"], "rtk cargo install ripgrep");
assert_eq!(modified["description"], "install ripgrep");
assert_eq!(modified["initial_wait"], 30);
assert_eq!(modified["mode"], "sync");
}
fn end_to_end(cmd: &str) -> Option<Value> {
let verdict = crate::hooks::permissions::check_command_with_rules(
cmd,
&[],
&[],
&["Bash(git:*)".to_string()],
);
copilot_cli_response_from_decision(&cli_args(cmd), decide_from_verdict(cmd, verdict), cmd)
}
#[test]
fn test_copilot_cli_cve_safe_forms_still_rewrite() {
for cmd in ["git status", "git status 2>&1"] {
let r = end_to_end(cmd).unwrap_or_else(|| panic!("expected rewrite for {cmd:?}"));
assert_eq!(
r["modifiedArgs"]["command"].as_str().unwrap(),
format!("rtk {cmd}"),
"safe form {cmd:?} must rewrite",
);
}
}
#[test]
fn test_copilot_cli_cve_newline_bypass_never_auto_allows() {
let r = end_to_end("git status\nrm -rf /tmp/x");
if let Some(resp) = r {
assert!(
resp.get("permissionDecision").is_none(),
"newline-hidden command must not produce permissionDecision: \"allow\""
);
}
}
#[test]
fn test_copilot_cli_cve_background_bypass_never_auto_allows() {
let r = end_to_end("git status & rm -rf /tmp/x");
if let Some(resp) = r {
assert!(
resp.get("permissionDecision").is_none(),
"background-& hidden command must not produce permissionDecision: \"allow\""
);
}
}
#[test]
fn test_copilot_cli_cve_command_substitution_returns_none() {
assert!(
end_to_end("git log --pretty=$(rm -rf /tmp/x)").is_none(),
"$( ) command substitution must not produce modifiedArgs"
);
}
#[test]
fn test_copilot_cli_cve_backtick_substitution_returns_none() {
assert!(
end_to_end("git log --pretty=`rm -rf /tmp/x`").is_none(),
"backtick substitution must not produce modifiedArgs"
);
}
#[test]
fn test_copilot_cli_cve_file_redirect_amp_returns_none() {
assert!(
end_to_end("git status >& /tmp/evil").is_none(),
">&file redirect must not produce modifiedArgs"
);
}
#[test]
fn test_copilot_cli_cve_file_redirect_returns_none() {
assert!(
end_to_end("git status > /tmp/evil").is_none(),
">file redirect must not produce modifiedArgs"
);
}
#[test]
fn test_print_allow_format() {
let expected = r#"{"decision":"allow"}"#;
assert_eq!(expected, r#"{"decision":"allow"}"#);
}
#[test]
fn test_print_rewrite_format() {
let output = serde_json::json!({
"decision": "allow",
"hookSpecificOutput": {
"tool_input": {
"command": "rtk git status"
}
}
});
let json: Value = serde_json::from_str(&output.to_string()).unwrap();
assert_eq!(json["decision"], "allow");
assert_eq!(
json["hookSpecificOutput"]["tool_input"]["command"],
"rtk git status"
);
}
#[test]
fn test_gemini_hook_uses_rewrite_command() {
assert_eq!(
rewrite_command_no_prefixes("git status", &[]),
Some("rtk git status".into())
);
assert_eq!(
rewrite_command_no_prefixes("cargo test", &[]),
Some("rtk cargo test".into())
);
assert_eq!(
rewrite_command_no_prefixes("rtk git status", &[]),
Some("rtk git status".into())
);
assert_eq!(rewrite_command_no_prefixes("cat <<EOF", &[]), None);
}
#[test]
fn test_gemini_hook_excluded_commands() {
let excluded = vec!["curl".to_string()];
assert_eq!(
rewrite_command_no_prefixes("curl https://example.com", &excluded),
None
);
assert_eq!(
rewrite_command_no_prefixes("git status", &excluded),
Some("rtk git status".into())
);
}
#[test]
fn test_gemini_hook_env_prefix_preserved() {
assert_eq!(
rewrite_command_no_prefixes("RUST_LOG=debug cargo test", &[]),
Some("RUST_LOG=debug rtk cargo test".into())
);
}
fn claude_input(cmd: &str) -> String {
json!({
"tool_name": "Bash",
"tool_input": { "command": cmd }
})
.to_string()
}
fn claude_input_with_fields(cmd: &str, timeout: u64, description: &str) -> String {
json!({
"tool_name": "Bash",
"tool_input": {
"command": cmd,
"timeout": timeout,
"description": description
}
})
.to_string()
}
#[test]
fn test_claude_rewrite_git_status() {
let result = run_claude_inner(&claude_input("git status")).unwrap();
let v: Value = serde_json::from_str(&result).unwrap();
let cmd = v
.pointer("/hookSpecificOutput/updatedInput/command")
.and_then(|c| c.as_str())
.unwrap();
assert_eq!(cmd, "rtk git status");
}
#[test]
fn test_claude_rewrite_preserves_tool_input_fields() {
let input = claude_input_with_fields("git status", 30000, "Check repo status");
let result = run_claude_inner(&input).unwrap();
let v: Value = serde_json::from_str(&result).unwrap();
let updated = &v["hookSpecificOutput"]["updatedInput"];
assert_eq!(updated["command"], "rtk git status");
assert_eq!(updated["timeout"], 30000);
assert_eq!(updated["description"], "Check repo status");
}
#[test]
fn test_claude_passthrough_no_output() {
assert!(run_claude_inner(&claude_input("htop")).is_none());
}
#[test]
fn test_claude_substitution_not_rewritten() {
assert!(run_claude_inner(&claude_input("git status `rm -rf /tmp/x`")).is_none());
assert!(run_claude_inner(&claude_input("git status $(rm -rf /tmp/x)")).is_none());
assert!(run_claude_inner(&claude_input("git log --pretty=\"$(rm -rf /tmp/x)\"")).is_none());
}
#[test]
fn test_claude_file_redirect_not_rewritten() {
assert!(run_claude_inner(&claude_input("git log > /tmp/out.txt")).is_none());
}
#[test]
fn test_claude_fd_dup_redirect_still_rewritten() {
assert!(run_claude_inner(&claude_input("git status 2>&1")).is_some());
}
#[test]
fn test_claude_heredoc_passthrough() {
assert!(run_claude_inner(&claude_input("cat <<EOF\nhello\nEOF")).is_none());
}
#[test]
fn test_claude_already_rtk_passthrough() {
assert!(run_claude_inner(&claude_input("rtk git status")).is_none());
}
#[test]
fn test_claude_empty_command_passthrough() {
let input = json!({
"tool_name": "Bash",
"tool_input": { "command": "" }
})
.to_string();
assert!(run_claude_inner(&input).is_none());
}
#[test]
fn test_claude_malformed_json_passthrough() {
assert!(run_claude_inner("not valid json {{{").is_none());
}
#[test]
fn test_claude_env_prefix_preserved() {
let result = run_claude_inner(&claude_input("GIT_PAGER=cat git status")).unwrap();
let v: Value = serde_json::from_str(&result).unwrap();
let cmd = v
.pointer("/hookSpecificOutput/updatedInput/command")
.and_then(|c| c.as_str())
.unwrap();
assert_eq!(cmd, "GIT_PAGER=cat rtk git status");
}
#[test]
fn test_claude_compound_command() {
let result = run_claude_inner(&claude_input("git add . && cargo test")).unwrap();
let v: Value = serde_json::from_str(&result).unwrap();
let cmd = v
.pointer("/hookSpecificOutput/updatedInput/command")
.and_then(|c| c.as_str())
.unwrap();
assert_eq!(cmd, "rtk git add . && rtk cargo test");
}
#[test]
fn test_claude_json_output_structure() {
let result = run_claude_inner(&claude_input("git status")).unwrap();
let v: Value = serde_json::from_str(&result).unwrap();
let hook = &v["hookSpecificOutput"];
assert_eq!(hook["hookEventName"], PRE_TOOL_USE_KEY);
assert_eq!(hook["permissionDecisionReason"], "RTK auto-rewrite");
assert!(hook["updatedInput"].is_object());
assert!(hook["updatedInput"]["command"].is_string());
}
#[test]
fn test_claude_no_tool_input_passthrough() {
let input = json!({ "tool_name": "Bash" }).to_string();
assert!(run_claude_inner(&input).is_none());
}
fn cursor_input(cmd: &str) -> String {
json!({
"tool_name": "Bash",
"tool_input": { "command": cmd }
})
.to_string()
}
fn run_cursor_allowed(input: &str) -> String {
run_cursor_inner_with_rules(input, &[], &[], &["*".to_string()])
}
#[test]
fn test_cursor_rewrite_flat_format() {
let result = run_cursor_allowed(&cursor_input("git status"));
let v: Value = serde_json::from_str(&result).unwrap();
assert_eq!(v["permission"], "allow");
assert_eq!(v["updated_input"]["command"], "rtk git status");
assert!(v.get("hookSpecificOutput").is_none());
assert_eq!(v["continue"], true);
}
#[test]
fn test_cursor_no_allow_rule_defers() {
assert_eq!(run_cursor_inner(&cursor_input("git status")), "{}");
}
#[test]
fn test_cursor_substitution_defers_even_when_allowed() {
assert_eq!(
run_cursor_allowed(&cursor_input("git status `rm -rf /tmp/x`")),
"{}"
);
assert_eq!(
run_cursor_allowed(&cursor_input("git status $(rm -rf /tmp/x)")),
"{}"
);
}
#[test]
fn test_cursor_unallowed_segment_defers() {
let out = run_cursor_inner_with_rules(
&cursor_input("git status && rm -rf /tmp/x"),
&[],
&[],
&["git *".to_string()],
);
assert_eq!(out, "{}");
}
#[test]
fn test_cursor_passthrough_empty_json() {
let result = run_cursor_inner(&cursor_input("htop"));
assert_eq!(result, "{}");
}
#[test]
fn test_cursor_empty_input_empty_json() {
let result = run_cursor_inner("");
assert_eq!(result, "{}");
}
#[test]
fn test_cursor_heredoc_passthrough() {
let result = run_cursor_inner(&cursor_input("cat <<EOF\nhello\nEOF"));
assert_eq!(result, "{}");
}
#[test]
fn test_cursor_already_rtk_passthrough() {
let result = run_cursor_inner(&cursor_input("rtk git status"));
assert_eq!(result, "{}");
}
#[test]
fn test_cursor_no_hook_specific_output() {
let result = run_cursor_allowed(&cursor_input("cargo test"));
let v: Value = serde_json::from_str(&result).unwrap();
assert!(v.get("hookSpecificOutput").is_none());
assert_eq!(v["permission"], "allow");
assert_eq!(v["continue"], true);
}
#[test]
fn test_cursor_compound_rewrite_includes_continue() {
let cmd = "cd \"/tmp/proj\" && git status";
let result = run_cursor_allowed(&cursor_input(cmd));
let v: Value = serde_json::from_str(&result).unwrap();
assert_eq!(v["continue"], true);
assert_eq!(v["permission"], "allow");
assert_eq!(
v["updated_input"]["command"],
"cd \"/tmp/proj\" && rtk git status"
);
}
#[test]
fn test_cursor_strips_single_utf8_bom() {
let payload = cursor_input("git status");
let with_single_bom = format!("\u{feff}{}", payload);
let result = run_cursor_allowed(&with_single_bom);
let v: Value = serde_json::from_str(&result).unwrap();
assert_eq!(v["continue"], true);
assert_eq!(v["permission"], "allow");
assert_eq!(v["updated_input"]["command"], "rtk git status");
}
#[test]
fn test_cursor_strips_double_utf8_bom() {
let payload = cursor_input("git status");
let with_double_bom = format!("\u{feff}\u{feff}{}", payload);
let result = run_cursor_allowed(&with_double_bom);
let v: Value = serde_json::from_str(&result).unwrap();
assert_eq!(v["continue"], true);
assert_eq!(v["permission"], "allow");
assert_eq!(v["updated_input"]["command"], "rtk git status");
}
#[test]
fn test_strip_leading_bom_helper() {
assert_eq!(strip_leading_bom(""), "");
assert_eq!(strip_leading_bom("hello"), "hello");
assert_eq!(strip_leading_bom("\u{feff}hello"), "hello");
assert_eq!(strip_leading_bom("\u{feff}\u{feff}hello"), "hello");
assert_eq!(strip_leading_bom("\u{feff}\u{feff}\u{feff}hello"), "hello");
assert_eq!(strip_leading_bom("a\u{feff}b"), "a\u{feff}b");
}
#[test]
fn test_audit_log_silent_when_disabled() {
std::env::remove_var("RTK_HOOK_AUDIT");
audit_log("test", "git status", "rtk git status");
}
#[test]
fn test_audit_log_format_four_fields() {
let tmp = std::env::temp_dir().join("rtk-test-audit");
let _ = std::fs::create_dir_all(&tmp);
let log_path = tmp.join("hook-audit.log");
let _ = std::fs::remove_file(&log_path);
{
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.unwrap();
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S");
writeln!(file, "{} | rewrite | git status | rtk git status", ts).unwrap();
}
let content = std::fs::read_to_string(&log_path).unwrap();
let parts: Vec<&str> = content.trim().split(" | ").collect();
assert_eq!(
parts.len(),
4,
"Expected 4 pipe-delimited fields, got: {:?}",
parts
);
assert_eq!(parts[1], "rewrite");
assert_eq!(parts[2], "git status");
assert_eq!(parts[3], "rtk git status");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_audit_log_sanitizes_newlines() {
let sanitized = sanitize_log_field("git status\nfake | inject | evil");
assert!(!sanitized.contains('\n'));
assert!(sanitized.contains("\\n"));
}
#[test]
fn test_audit_log_sanitizes_pipe_delimiter() {
let sanitized = sanitize_log_field("git log | head");
assert!(
!sanitized.contains(" | "),
"unescaped ' | ' breaks field parsing: {}",
sanitized
);
assert!(sanitized.contains("\\|"));
}
#[test]
fn test_claude_unicode_null_passthrough() {
let input = claude_input("git status \u{0000}\u{FEFF}");
let _ = run_claude_inner(&input);
}
#[test]
fn test_claude_extremely_long_command() {
let long_cmd = format!("git status {}", "A".repeat(100_000));
let input = claude_input(&long_cmd);
let _ = run_claude_inner(&input);
}
#[test]
fn test_cursor_deny_blocks_rewrite() {
use super::permissions::check_command_with_rules;
let deny = vec!["git status".to_string()];
assert_eq!(
check_command_with_rules("git status", &deny, &[], &[]),
PermissionVerdict::Deny
);
}
#[test]
fn test_gemini_deny_blocks_rewrite() {
use super::permissions::check_command_with_rules;
let deny = vec!["cargo test".to_string()];
assert_eq!(
check_command_with_rules("cargo test", &deny, &[], &[]),
PermissionVerdict::Deny
);
assert!(
get_rewritten("cargo test").is_some(),
"cargo test should be rewritable when not denied"
);
}
fn decide_with_rules(
cmd: &str,
deny: &[String],
ask: &[String],
allow: &[String],
) -> HookDecision {
let verdict = permissions::check_command_with_rules(cmd, deny, ask, allow);
decide_from_verdict(cmd, verdict)
}
fn all_allowed() -> Vec<String> {
vec!["*".to_string()]
}
#[test]
fn test_decide_allow_for_attestable_allowed_command() {
assert!(matches!(
decide_with_rules("git status", &[], &[], &all_allowed()),
HookDecision::AllowRewrite(_)
));
}
#[test]
fn test_decide_ask_for_default_verdict() {
assert!(matches!(
decide_with_rules("git status", &[], &[], &[]),
HookDecision::AskRewrite(_)
));
}
#[test]
fn test_decide_deny() {
assert!(matches!(
decide_with_rules(
"rm -rf /tmp/x",
&["rm -rf".to_string()],
&[],
&all_allowed()
),
HookDecision::Deny
));
}
#[test]
fn test_decide_defer_for_substitution_even_when_allowed() {
for cmd in [
"git status `rm -rf /tmp/x`",
"git status $(rm -rf /tmp/x)",
"git log --pretty=\"$(rm -rf /tmp/x)\"",
] {
assert!(
matches!(
decide_with_rules(cmd, &[], &[], &all_allowed()),
HookDecision::Defer
),
"expected Defer for {cmd}"
);
}
}
#[test]
fn test_decide_defer_for_file_redirect() {
assert!(matches!(
decide_with_rules("git log > /tmp/out.txt", &[], &[], &all_allowed()),
HookDecision::Defer
));
}
#[test]
fn test_decide_allow_for_fd_dup_redirect() {
assert!(matches!(
decide_with_rules("git status 2>&1", &[], &[], &all_allowed()),
HookDecision::AllowRewrite(_)
));
}
fn gemini_render(cmd: &str, deny: &[String], ask: &[String], allow: &[String]) -> String {
match decide_with_rules(cmd, deny, ask, allow) {
HookDecision::Deny => {
r#"{"decision":"deny","reason":"Blocked by RTK permission rule"}"#.to_string()
}
HookDecision::AllowRewrite(r) => gemini_json("allow", Some(&r)),
HookDecision::AskRewrite(r) => gemini_json("ask_user", Some(&r)),
HookDecision::Defer => gemini_json("ask_user", None),
}
}
#[test]
fn test_gemini_allow_emits_rewrite() {
let v: Value =
serde_json::from_str(&gemini_render("git status", &[], &[], &all_allowed())).unwrap();
assert_eq!(v["decision"], "allow");
assert_eq!(
v["hookSpecificOutput"]["tool_input"]["command"],
"rtk git status"
);
}
#[test]
fn test_gemini_default_asks_user() {
let v: Value = serde_json::from_str(&gemini_render("git status", &[], &[], &[])).unwrap();
assert_eq!(v["decision"], "ask_user");
}
#[test]
fn test_gemini_substitution_asks_user_without_rewrite() {
let v: Value = serde_json::from_str(&gemini_render(
"git status `rm -rf /tmp/x`",
&[],
&[],
&all_allowed(),
))
.unwrap();
assert_eq!(v["decision"], "ask_user");
assert!(v.get("hookSpecificOutput").is_none());
}
#[test]
fn test_gemini_deny_decision() {
let v: Value = serde_json::from_str(&gemini_render(
"rm -rf /tmp/x",
&["rm -rf".to_string()],
&[],
&[],
))
.unwrap();
assert_eq!(v["decision"], "deny");
}
}