use super::permissions::{check_command, PermissionVerdict};
use crate::discover::registry;
use std::io::Write;
pub fn run(cmd: &str) -> anyhow::Result<()> {
let (excluded, transparent_prefixes) = crate::core::config::Config::load()
.map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes))
.unwrap_or_default();
match evaluate(cmd, &excluded, &transparent_prefixes) {
RewriteOutcome::Allow(rewritten) => {
print!("{}", rewritten);
let _ = std::io::stdout().flush();
Ok(())
}
RewriteOutcome::Ask(rewritten) => {
print!("{}", rewritten);
let _ = std::io::stdout().flush();
std::process::exit(3);
}
RewriteOutcome::Deny => std::process::exit(2),
RewriteOutcome::Passthrough => std::process::exit(1),
}
}
#[derive(Debug, PartialEq)]
enum RewriteOutcome {
Allow(String),
Passthrough,
Deny,
Ask(String),
}
fn evaluate(cmd: &str, excluded: &[String], transparent_prefixes: &[String]) -> RewriteOutcome {
let verdict = check_command(cmd);
if verdict == PermissionVerdict::Deny {
return RewriteOutcome::Deny;
}
if crate::discover::lexer::contains_unattestable_construct(cmd) {
return RewriteOutcome::Passthrough;
}
match registry::rewrite_command(cmd, excluded, transparent_prefixes) {
Some(rewritten) => match verdict {
PermissionVerdict::Allow => RewriteOutcome::Allow(rewritten),
_ => RewriteOutcome::Ask(rewritten),
},
None => RewriteOutcome::Passthrough,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rewrite_command_no_prefixes(cmd: &str) -> Option<String> {
registry::rewrite_command(cmd, &[], &[])
}
#[test]
fn test_run_supported_command_succeeds() {
assert!(rewrite_command_no_prefixes("git status").is_some());
}
#[test]
fn test_run_unsupported_returns_none() {
assert!(rewrite_command_no_prefixes("htop").is_none());
}
#[test]
fn test_run_already_rtk_returns_some() {
assert_eq!(
rewrite_command_no_prefixes("rtk git status"),
Some("rtk git status".into())
);
}
mod unattestable_passthrough {
use super::super::{evaluate, RewriteOutcome};
#[test]
fn test_backtick_substitution_passthrough() {
assert_eq!(
evaluate("git status `rm -rf /tmp/x`", &[], &[]),
RewriteOutcome::Passthrough
);
}
#[test]
fn test_dollar_substitution_passthrough() {
assert_eq!(
evaluate("git status $(rm -rf /tmp/x)", &[], &[]),
RewriteOutcome::Passthrough
);
}
#[test]
fn test_double_quoted_substitution_passthrough() {
assert_eq!(
evaluate("git log --pretty=\"$(rm -rf /tmp/x)\"", &[], &[]),
RewriteOutcome::Passthrough
);
}
#[test]
fn test_file_redirect_passthrough() {
assert_eq!(
evaluate("git log > /tmp/out.txt", &[], &[]),
RewriteOutcome::Passthrough
);
}
#[test]
fn test_fd_dup_redirect_still_rewrites() {
assert!(matches!(
evaluate("git status 2>&1", &[], &[]),
RewriteOutcome::Ask(_)
));
}
#[test]
fn test_plain_command_still_rewrites() {
assert!(matches!(
evaluate("git status", &[], &[]),
RewriteOutcome::Ask(_)
));
}
}
mod exit_code_protocol {
use super::registry;
use crate::hooks::permissions::{check_command_with_rules, PermissionVerdict};
fn expected_exit_code(verdict: &PermissionVerdict) -> i32 {
match verdict {
PermissionVerdict::Allow => 0,
PermissionVerdict::Deny => 2,
PermissionVerdict::Ask => 3,
PermissionVerdict::Default => 3, }
}
#[test]
fn test_default_verdict_maps_to_ask_exit_code() {
let verdict = check_command_with_rules("git status", &[], &[], &[]);
assert_eq!(verdict, PermissionVerdict::Default);
assert_eq!(
expected_exit_code(&verdict),
3,
"Default verdict MUST exit with code 3 (ask), not 0 (allow)"
);
}
#[test]
fn test_allow_verdict_maps_to_allow_exit_code() {
let allow = vec!["git *".to_string()];
let verdict = check_command_with_rules("git status", &[], &[], &allow);
assert_eq!(verdict, PermissionVerdict::Allow);
assert_eq!(expected_exit_code(&verdict), 0);
}
#[test]
fn test_ask_verdict_maps_to_ask_exit_code() {
let ask = vec!["git push".to_string()];
let verdict = check_command_with_rules("git push origin main", &[], &ask, &[]);
assert_eq!(verdict, PermissionVerdict::Ask);
assert_eq!(expected_exit_code(&verdict), 3);
}
#[test]
fn test_deny_verdict_maps_to_deny_exit_code() {
let deny = vec!["rm -rf".to_string()];
let verdict = check_command_with_rules("rm -rf /tmp/test", &deny, &[], &[]);
assert_eq!(verdict, PermissionVerdict::Deny);
assert_eq!(expected_exit_code(&verdict), 2);
}
#[test]
fn test_no_auto_allow_bypass_for_unrecognized_commands() {
let verdict = check_command_with_rules("git status", &[], &[], &[]);
assert_eq!(verdict, PermissionVerdict::Default);
assert!(registry::rewrite_command("git status", &[], &[]).is_some());
assert_eq!(expected_exit_code(&verdict), 3);
}
#[test]
fn test_default_never_equals_allow() {
assert_ne!(PermissionVerdict::Default, PermissionVerdict::Allow);
}
}
}