use crate::policy::{Policy, PolicyAction};
use serde::Deserialize;
use serde::Serialize;
use std::io::Read;
#[derive(Deserialize)]
struct HookInput {
#[serde(default)]
tool_name: String,
#[serde(default)]
tool_input: HookToolInput,
}
#[derive(Deserialize, Default)]
struct HookToolInput {
#[serde(default)]
command: String,
}
#[derive(Serialize)]
struct HookOutput {
#[serde(rename = "hookSpecificOutput")]
hook_specific_output: HookSpecificOutput,
}
#[derive(Serialize)]
struct HookSpecificOutput {
#[serde(rename = "hookEventName")]
hook_event_name: &'static str,
#[serde(rename = "permissionDecision")]
permission_decision: &'static str,
#[serde(
rename = "permissionDecisionReason",
skip_serializing_if = "Option::is_none"
)]
reason: Option<String>,
#[serde(rename = "updatedInput", skip_serializing_if = "Option::is_none")]
updated_input: Option<UpdatedInput>,
}
#[derive(Serialize)]
struct UpdatedInput {
command: String,
}
const TUI_SKIP: &[&str] = &[
"vim", "vi", "nano", "less", "more", "top", "htop", "tmux", "screen",
];
#[derive(Debug)]
pub enum HookDecision {
PassThrough,
Allow { command: String },
Ask {
command: String,
reason: String,
rule_name: String,
},
Deny { reason: String, rule_name: String },
}
pub fn rewrite_decision(tool_name: &str, command: &str, policy: Option<&Policy>) -> HookDecision {
if tool_name != "Bash" {
return HookDecision::PassThrough;
}
let trimmed = command.trim_start();
if trimmed.is_empty() {
return HookDecision::PassThrough;
}
let head = trimmed.split_whitespace().next().unwrap_or("");
if TUI_SKIP.contains(&head) || head == "vallum" {
return HookDecision::PassThrough;
}
let wrapped = format!(
"vallum run --policy-approved -- bash -c {}",
shell_escape(command)
);
if let Some(p) = policy {
let v = p.evaluate(command);
match v.action {
PolicyAction::Deny => {
return HookDecision::Deny {
reason: v.reason,
rule_name: v.rule_name,
}
}
PolicyAction::Ask => {
return HookDecision::Ask {
command: wrapped,
reason: v.reason,
rule_name: v.rule_name,
}
}
PolicyAction::Allow => {}
}
}
HookDecision::Allow { command: wrapped }
}
fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
pub fn run() -> i32 {
let mut buf = String::new();
if std::io::stdin().lock().read_to_string(&mut buf).is_err() {
return 0;
}
let input: HookInput = match serde_json::from_str(&buf) {
Ok(v) => v,
Err(_) => return 0, };
let config = match crate::config::AppConfig::load() {
Ok(c) => c,
Err(e) => {
eprintln!(
"vallum hook: config error — user rules ignored, using built-in policy defaults \
(run 'vallum doctor'): {e}"
);
crate::config::AppConfig::default()
}
};
let policy = if config.security.guardrail {
match Policy::compile(&config.policy) {
Ok(p) => Some(p),
Err(e) => {
eprintln!("vallum hook: policy failed to compile, using built-in defaults: {e}");
Policy::compile(&crate::config::PolicyConfig::default()).ok()
}
}
} else {
None
};
let decision = rewrite_decision(&input.tool_name, &input.tool_input.command, policy.as_ref());
let out = match decision {
HookDecision::PassThrough => return 0,
HookDecision::Allow { command } => HookSpecificOutput {
hook_event_name: "PreToolUse",
permission_decision: "allow",
reason: None,
updated_input: Some(UpdatedInput { command }),
},
HookDecision::Ask {
command,
reason,
rule_name,
} => {
let verdict = crate::policy::PolicyVerdict {
action: PolicyAction::Ask,
reason: reason.clone(),
rule_name,
};
crate::policy::audit::log_verdict(&verdict, &input.tool_input.command, &config);
HookSpecificOutput {
hook_event_name: "PreToolUse",
permission_decision: "ask",
reason: Some(reason),
updated_input: Some(UpdatedInput { command }),
}
}
HookDecision::Deny { reason, rule_name } => {
let verdict = crate::policy::PolicyVerdict {
action: PolicyAction::Deny,
reason: reason.clone(),
rule_name,
};
crate::policy::audit::log_verdict(&verdict, &input.tool_input.command, &config);
HookSpecificOutput {
hook_event_name: "PreToolUse",
permission_decision: "deny",
reason: Some(reason),
updated_input: None,
}
}
};
if let Ok(s) = serde_json::to_string(&HookOutput {
hook_specific_output: out,
}) {
println!("{}", s);
}
0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::PolicyConfig;
use crate::policy::Policy;
fn guardrail() -> Policy {
Policy::compile(&PolicyConfig::default()).unwrap()
}
#[test]
fn allow_rewrites_plain_command() {
let d = rewrite_decision("Bash", "git status", Some(&guardrail()));
match d {
HookDecision::Allow { command } => {
assert_eq!(
command,
"vallum run --policy-approved -- bash -c 'git status'"
)
}
other => panic!("expected Allow, got {other:?}"),
}
}
#[test]
fn dangerous_command_asks_with_reason() {
let d = rewrite_decision("Bash", "rm -rf /", Some(&guardrail()));
match d {
HookDecision::Ask {
command, reason, ..
} => {
assert_eq!(
command,
"vallum run --policy-approved -- bash -c 'rm -rf /'"
);
assert!(reason.contains("root or home"));
}
other => panic!("expected Ask, got {other:?}"),
}
}
fn guardrail_with_deny() -> Policy {
use crate::config::{PolicyConfig, PolicyRuleConfig};
Policy::compile(&PolicyConfig {
rules: vec![PolicyRuleConfig {
pattern: "SECRETDROP".into(),
action: "deny".into(),
reason: "denied in test".into(),
}],
disabled: vec![],
})
.unwrap()
}
#[test]
fn denied_command_returns_deny_no_rewrite() {
let d = rewrite_decision("Bash", "echo SECRETDROP", Some(&guardrail_with_deny()));
match d {
HookDecision::Deny { reason, .. } => assert!(reason.contains("denied in test")),
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn guardrail_off_always_allows() {
let d = rewrite_decision("Bash", "rm -rf /", None);
match d {
HookDecision::Allow { command } => {
assert_eq!(
command,
"vallum run --policy-approved -- bash -c 'rm -rf /'"
)
}
other => panic!("expected Allow, got {other:?}"),
}
}
#[test]
fn non_bash_and_tui_pass_through() {
assert!(matches!(
rewrite_decision("Edit", "git status", None),
HookDecision::PassThrough
));
assert!(matches!(
rewrite_decision("Bash", "vim foo", Some(&guardrail())),
HookDecision::PassThrough
));
assert!(matches!(
rewrite_decision("Bash", "vallum run x", None),
HookDecision::PassThrough
));
assert!(matches!(
rewrite_decision("Bash", "", None),
HookDecision::PassThrough
));
}
}