pub mod claude;
pub mod codex;
pub mod cursor;
pub mod gemini;
use crate::config::AppConfig;
use crate::policy::{Policy, PolicyAction};
use std::io::Read;
pub(crate) const TUI_SKIP: &[&str] = &[
"vim", "vi", "nano", "less", "more", "top", "htop", "tmux", "screen",
];
#[derive(Debug, PartialEq)]
pub enum Verdict {
PassThrough,
Allow,
AllowDowngraded { marker: String, reason: String },
Ask { reason: String, rule_name: String },
Deny { reason: String, rule_name: String },
}
fn is_env_assignment(word: &str) -> bool {
match word.split_once('=') {
Some((name, _)) => {
!name.is_empty()
&& !name.starts_with(|c: char| c.is_ascii_digit())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
None => false,
}
}
fn tui_head(trimmed: &str) -> Option<&str> {
let mut words = trimmed.split_whitespace().peekable();
while let Some(w) = words.next() {
if is_env_assignment(w) {
continue;
}
match w {
"sudo" | "doas" | "env" => {
while let Some(f) = words.peek().copied() {
if !f.starts_with('-') {
break;
}
words.next();
if matches!(f, "-u" | "-g" | "--unset") {
words.next();
}
}
}
_ => return Some(w),
}
}
None
}
fn is_vallum_head(head: &str) -> bool {
if head == "vallum" {
return true;
}
if !head.contains('/') {
return false;
}
match (
std::fs::canonicalize(head),
std::env::current_exe().and_then(std::fs::canonicalize),
) {
(Ok(h), Ok(me)) => h == me,
_ => false,
}
}
pub fn decide(command: &str, policy: Option<&Policy>) -> Verdict {
let trimmed = command.trim_start();
if trimmed.is_empty() {
return Verdict::PassThrough;
}
let head = trimmed.split_whitespace().next().unwrap_or("");
if is_vallum_head(head) {
let sub = trimmed
.split_whitespace()
.skip(1)
.find(|w| !w.starts_with('-'));
if let Some(sub) = sub {
if sub == "unlock" || sub == "uninstall-hook" {
return Verdict::Ask {
reason: "Clearing Vallum's lockdown or uninstalling its hook \
(guardrail self-disable)"
.to_string(),
rule_name: "vallum_self_disable".to_string(),
};
}
}
return Verdict::PassThrough;
}
let is_tui = tui_head(trimmed).is_some_and(|h| TUI_SKIP.contains(&h));
if let Some(p) = policy {
let v = p.evaluate(command);
match v.action {
PolicyAction::Deny => {
return Verdict::Deny {
reason: v.reason,
rule_name: v.rule_name,
}
}
PolicyAction::Ask => {
return Verdict::Ask {
reason: v.reason,
rule_name: v.rule_name,
}
}
PolicyAction::Allow => {
if !v.rule_name.is_empty() {
if !is_tui {
return Verdict::AllowDowngraded {
marker: v.rule_name,
reason: v.reason,
};
}
}
}
}
}
if is_tui {
Verdict::PassThrough
} else {
Verdict::Allow
}
}
pub fn gate(command: &str, policy: Option<&Policy>, cfg: &AppConfig) -> Verdict {
gate_cached(command, policy, cfg, None)
}
pub(crate) fn gate_cached(
command: &str,
policy: Option<&Policy>,
cfg: &AppConfig,
cache_cwd: Option<&str>,
) -> Verdict {
let trimmed = command.trim_start();
let head = trimmed.split_whitespace().next().unwrap_or("");
if trimmed.is_empty() || is_vallum_head(head) {
return decide(command, policy);
}
if let Some(trip) = crate::breaker::active_trip(cfg) {
return Verdict::Deny {
reason: crate::breaker::trip_reason(&trip),
rule_name: "circuit_breaker".to_string(),
};
}
let verdict = decide(command, policy);
if let (Verdict::Ask { rule_name, .. }, Some(cwd)) = (&verdict, cache_cwd) {
let is_tui = tui_head(trimmed).is_some_and(|h| TUI_SKIP.contains(&h));
if !is_tui && crate::approvals::lookup(cfg, command, cwd, rule_name) {
return Verdict::AllowDowngraded {
marker: format!("approval_cache:{rule_name}"),
reason: format!("cached human approval ({rule_name})"),
};
}
}
if matches!(verdict, Verdict::Ask { .. } | Verdict::Deny { .. }) {
crate::breaker::record_and_check(cfg);
}
verdict
}
pub(crate) fn read_stdin() -> Option<String> {
let mut buf = String::new();
std::io::stdin().lock().read_to_string(&mut buf).ok()?;
Some(buf)
}
pub(crate) fn load_config_and_policy() -> (AppConfig, Option<Policy>) {
let config = match AppConfig::load() {
Ok(c) => c,
Err(e) => {
eprintln!(
"vallum hook: config error — user rules ignored, using built-in policy defaults \
(run 'vallum doctor'): {e}"
);
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
};
(config, policy)
}
pub(crate) fn audit_verdict(
action: PolicyAction,
reason: String,
rule_name: String,
command: &str,
agent: &str,
cfg: &AppConfig,
) {
let verdict = crate::policy::PolicyVerdict {
action,
reason,
rule_name,
};
crate::policy::audit::log_verdict(&verdict, command, agent, cfg);
}
pub(crate) fn run_codec(respond: fn(&str, Option<&Policy>, &AppConfig) -> Option<String>) -> i32 {
let Some(raw) = read_stdin() else {
return 0;
};
let (config, policy) = load_config_and_policy();
if let Some(out) = respond(&raw, policy.as_ref(), &config) {
println!("{out}");
}
0
}
pub(crate) fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
pub(crate) fn fail_closed_ask_message(reason: &str, rule_name: &str, command: &str) -> String {
let escape_hatch = if rule_name.starts_with("user:") {
"or remove the matching [[policy.rules]] entry from your Vallum config \
(see `vallum config show`)"
.to_string()
} else if rule_name.starts_with("project:") {
"or remove the matching [[policy.rules]] entry from the repo's .vallum.toml".to_string()
} else {
format!("or disable the rule (`[policy] disabled = [\"{rule_name}\"]`)")
};
format!(
"Vallum guardrail: {reason}. If you intend this, run it yourself \
(`vallum run -- bash -c {}`) {escape_hatch}.",
shell_escape(command)
)
}
fn rule_source(rule_name: &str) -> &'static str {
if rule_name.starts_with("user:") {
"user rule"
} else if rule_name.starts_with("project:") {
"project rule"
} else {
"built-in"
}
}
pub fn test_report(command: &str, policy: Option<&Policy>, guardrail_on: bool) -> (String, i32) {
let suffix = if guardrail_on {
""
} else {
" (guardrail off — security.guardrail = false)"
};
let trimmed = command.trim_start();
let head = trimmed.split_whitespace().next().unwrap_or("");
match decide(command, policy) {
Verdict::PassThrough => {
let label = if trimmed.is_empty() {
"PASS-THROUGH (empty command)"
} else if is_vallum_head(head) {
"PASS-THROUGH (vallum wrapper command)"
} else {
"PASS-THROUGH (TUI command, no policy objection)"
};
(format!("{label}{suffix}\n"), 0)
}
Verdict::Allow => (format!("ALLOW{suffix}\n"), 0),
Verdict::AllowDowngraded { marker, reason } => (
format!("ALLOW [{marker}]{suffix}\n downgraded: {reason}\n"),
0,
),
Verdict::Ask { reason, rule_name } => (
format!(
"ASK [{rule_name}] ({})\n {reason}\n",
rule_source(&rule_name)
),
10,
),
Verdict::Deny { reason, rule_name } => (
format!(
"DENY [{rule_name}] ({})\n {reason}\n",
rule_source(&rule_name)
),
20,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::PolicyConfig;
fn guardrail() -> Policy {
Policy::compile(&PolicyConfig::default()).unwrap()
}
fn breaker_cfg(tag: &str) -> (AppConfig, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!(
"vallum_gate_{tag}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let mut cfg = AppConfig::default();
cfg.audit.log_dir = Some(dir.clone());
(cfg, dir)
}
fn lock_now(dir: &std::path::Path) {
let until = (chrono::Local::now() + chrono::Duration::seconds(300)).to_rfc3339();
std::fs::write(dir.join("breaker.state"), format!("locked {until}\n")).unwrap();
}
#[test]
fn gate_denies_everything_while_tripped() {
let (cfg, dir) = breaker_cfg("deny_all");
lock_now(&dir);
match gate("git status", Some(&guardrail()), &cfg) {
Verdict::Deny { rule_name, reason } => {
assert_eq!(rule_name, "circuit_breaker");
assert!(reason.contains("vallum unlock"), "{reason}");
}
other => panic!("expected breaker deny, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn gate_passes_vallum_and_empty_through_while_tripped() {
let (cfg, dir) = breaker_cfg("passthrough");
lock_now(&dir);
match gate("vallum unlock", Some(&guardrail()), &cfg) {
Verdict::Ask { rule_name, .. } => assert_eq!(rule_name, "vallum_self_disable"),
other => panic!("expected self-disable Ask, got {other:?}"),
}
assert_eq!(
gate("vallum run ls", Some(&guardrail()), &cfg),
Verdict::PassThrough
);
assert_eq!(gate(" ", Some(&guardrail()), &cfg), Verdict::PassThrough);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn gate_records_ask_verdicts_and_trips_at_threshold() {
let (mut cfg, dir) = breaker_cfg("records");
cfg.security.breaker_threshold = 3;
for _ in 0..3 {
match gate("rm -rf /", Some(&guardrail()), &cfg) {
Verdict::Ask { .. } => {}
other => panic!("expected Ask, got {other:?}"),
}
}
match gate("git status", Some(&guardrail()), &cfg) {
Verdict::Deny { rule_name, .. } => assert_eq!(rule_name, "circuit_breaker"),
other => panic!("expected breaker deny, got {other:?}"),
}
let log = std::fs::read_to_string(dir.join("policy.log")).unwrap();
assert!(log.contains("circuit_breaker"), "{log}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn gate_allow_verdicts_never_count() {
let (mut cfg, dir) = breaker_cfg("allow_free");
cfg.security.breaker_threshold = 1;
for _ in 0..5 {
assert_eq!(gate("git status", Some(&guardrail()), &cfg), Verdict::Allow);
}
assert!(!dir.join("breaker.state").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn gate_disabled_breaker_is_inert() {
let (mut cfg, dir) = breaker_cfg("disabled");
cfg.security.circuit_breaker = false;
lock_now(&dir);
assert_eq!(gate("git status", Some(&guardrail()), &cfg), Verdict::Allow);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn decide_gates_empty_tui_and_vallum_head() {
assert_eq!(decide("", Some(&guardrail())), Verdict::PassThrough);
assert_eq!(decide(" ", Some(&guardrail())), Verdict::PassThrough);
assert_eq!(
decide("vim /etc/passwd", Some(&guardrail())),
Verdict::PassThrough
);
assert_eq!(
decide("vallum run ls", Some(&guardrail())),
Verdict::PassThrough
);
}
fn vallum_named_file(tag: &str, link_to_self: bool) -> (std::path::PathBuf, String) {
let dir = std::env::temp_dir().join(format!(
"vallum_head_{tag}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("vallum");
if link_to_self {
std::os::unix::fs::symlink(std::env::current_exe().unwrap(), &bin).unwrap();
} else {
std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
}
let head = bin.to_string_lossy().into_owned();
(dir, head)
}
#[test]
fn path_qualified_real_vallum_passes_through_even_while_tripped() {
let (bin_dir, head) = vallum_named_file("real", true);
assert_eq!(
decide(&format!("{head} run ls"), Some(&guardrail())),
Verdict::PassThrough
);
let (cfg, dir) = breaker_cfg("real_head");
lock_now(&dir);
match gate(&format!("{head} unlock"), Some(&guardrail()), &cfg) {
Verdict::Ask { rule_name, .. } => assert_eq!(rule_name, "vallum_self_disable"),
other => panic!("expected self-disable Ask, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&bin_dir);
}
#[test]
fn path_named_vallum_but_different_file_is_still_gated() {
let (bin_dir, head) = vallum_named_file("decoy", false);
assert_eq!(
decide(&format!("{head} run ls"), Some(&guardrail())),
Verdict::Allow
);
let (cfg, dir) = breaker_cfg("decoy_head");
lock_now(&dir);
match gate(&format!("{head} unlock"), Some(&guardrail()), &cfg) {
Verdict::Deny { rule_name, .. } => assert_eq!(rule_name, "circuit_breaker"),
other => panic!("expected breaker deny for decoy vallum, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&bin_dir);
}
#[test]
fn nonexistent_vallum_path_is_still_gated() {
assert_eq!(
decide("/nonexistent/bin/vallum run ls", Some(&guardrail())),
Verdict::Allow
);
}
#[test]
fn decide_maps_policy_verdicts() {
assert_eq!(decide("git status", Some(&guardrail())), Verdict::Allow);
match decide("rm -rf /", Some(&guardrail())) {
Verdict::Ask { reason, rule_name } => {
assert!(reason.contains("force-delete"));
assert_eq!(rule_name, "rm_rf_root");
}
other => panic!("expected Ask, got {other:?}"),
}
}
#[test]
fn decide_without_policy_allows() {
assert_eq!(decide("rm -rf /", None), Verdict::Allow);
}
#[test]
fn ask_message_names_rule_command_and_escape_hatch() {
let m = fail_closed_ask_message("force push", "git_push_force", "git push --force");
assert!(m.contains("Vallum guardrail: force push"));
assert!(m.contains("vallum run -- bash -c 'git push --force'"));
assert!(m.contains("[policy] disabled = [\"git_push_force\"]"));
}
#[test]
fn ask_message_user_rule_advises_config_edit() {
let m = fail_closed_ask_message("denied in test", "user:SECRETDROP", "echo SECRETDROP");
assert!(!m.contains("[policy] disabled"));
assert!(m.contains("[[policy.rules]]"));
}
#[test]
fn decide_tui_matching_a_rule_now_asks() {
match decide("less /etc/shadow", Some(&guardrail())) {
Verdict::Ask { rule_name, .. } => assert_eq!(rule_name, "read_sensitive_creds"),
other => panic!("expected Ask, got {other:?}"),
}
}
#[test]
fn decide_tui_matching_a_deny_rule_denies() {
use crate::config::PolicyRuleConfig;
let p = Policy::compile(&PolicyConfig {
rules: vec![PolicyRuleConfig {
pattern: "less /prod/secrets".into(),
action: "deny".into(),
reason: "denied in test".into(),
}],
allow: vec![],
project_rules: vec![],
disabled: vec![],
})
.unwrap();
match decide("less /prod/secrets", Some(&p)) {
Verdict::Deny { reason, .. } => assert!(reason.contains("denied in test")),
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn decide_wrapped_tui_passes_through() {
assert_eq!(
decide("sudo vim /etc/hosts", Some(&guardrail())),
Verdict::PassThrough
);
assert_eq!(
decide("sudo -u alice vim notes.txt", Some(&guardrail())),
Verdict::PassThrough
);
assert_eq!(
decide("env LESS= less notes.txt", Some(&guardrail())),
Verdict::PassThrough
);
assert_eq!(
decide("LESS=-R less notes.txt", Some(&guardrail())),
Verdict::PassThrough
);
}
#[test]
fn decide_wrapped_tui_still_policy_gated() {
match decide("sudo less /etc/shadow", Some(&guardrail())) {
Verdict::Ask { rule_name, .. } => assert_eq!(rule_name, "read_sensitive_creds"),
other => panic!("expected Ask, got {other:?}"),
}
}
#[test]
fn decide_wrapped_non_tui_stays_wrapped() {
assert_eq!(
decide("sudo git status", Some(&guardrail())),
Verdict::Allow
);
}
#[test]
fn tui_head_resolves_through_wrappers() {
assert_eq!(tui_head("vim x"), Some("vim"));
assert_eq!(tui_head("sudo vim x"), Some("vim"));
assert_eq!(tui_head("sudo -E -u alice vim x"), Some("vim"));
assert_eq!(tui_head("env LESS= less x"), Some("less"));
assert_eq!(tui_head("env -i FOO=1 less x"), Some("less"));
assert_eq!(tui_head("A=1 B=2 top"), Some("top"));
assert_eq!(tui_head("./weird=name x"), Some("./weird=name"));
assert_eq!(tui_head("sudo"), None);
}
#[test]
fn decide_tui_with_no_objection_still_passes_through() {
assert_eq!(
decide("vim notes.txt", Some(&guardrail())),
Verdict::PassThrough
);
assert_eq!(decide("less /etc/shadow", None), Verdict::PassThrough);
}
#[test]
fn vallum_head_self_disable_asks_other_subcommands_pass() {
let p = Policy::compile(&crate::config::PolicyConfig::default()).unwrap();
for cmd in ["vallum unlock", "vallum uninstall-hook --agent codex"] {
match decide(cmd, Some(&p)) {
Verdict::Ask { rule_name, .. } => {
assert_eq!(rule_name, "vallum_self_disable", "{cmd}")
}
other => panic!("{cmd}: expected Ask, got {other:?}"),
}
}
for cmd in ["vallum stats", "vallum run -- ls", "vallum doctor"] {
assert!(
matches!(decide(cmd, Some(&p)), Verdict::PassThrough),
"{cmd}"
);
}
}
#[test]
fn test_report_covers_all_verdicts() {
let g = guardrail();
let (s, c) = test_report("git status", Some(&g), true);
assert_eq!((s.as_str(), c), ("ALLOW\n", 0));
let (s, c) = test_report("rm -rf /", Some(&g), true);
assert!(s.starts_with("ASK [rm_rf_root] (built-in)\n "));
assert_eq!(c, 10);
use crate::config::PolicyRuleConfig;
let deny = Policy::compile(&PolicyConfig {
rules: vec![PolicyRuleConfig {
pattern: "BLOCKME".into(),
action: "deny".into(),
reason: "blocked in test".into(),
}],
allow: vec![],
project_rules: vec![],
disabled: vec![],
})
.unwrap();
let (s, c) = test_report("echo BLOCKME", Some(&deny), true);
assert!(s.starts_with("DENY [user:BLOCKME] (user rule)\n blocked in test"));
assert_eq!(c, 20);
let (s, c) = test_report("vallum run ls", Some(&g), true);
assert_eq!(
(s.as_str(), c),
("PASS-THROUGH (vallum wrapper command)\n", 0)
);
let (s, c) = test_report("vim notes.txt", Some(&g), true);
assert_eq!(
(s.as_str(), c),
("PASS-THROUGH (TUI command, no policy objection)\n", 0)
);
let (s, c) = test_report("rm -rf /", None, false);
assert_eq!(
(s.as_str(), c),
("ALLOW (guardrail off — security.guardrail = false)\n", 0)
);
}
#[test]
fn decide_maps_suppressed_verdict_to_allow_downgraded() {
let cfg = crate::config::PolicyConfig {
rules: vec![],
allow: vec![crate::config::PolicyAllowConfig {
pattern: r"^git push --force origin main-backup$".into(),
suppresses: "git_push_force".into(),
reason: "release flow".into(),
}],
project_rules: vec![],
disabled: vec![],
};
let p = Policy::compile(&cfg).unwrap();
match decide("git push --force origin main-backup", Some(&p)) {
Verdict::AllowDowngraded { marker, reason } => {
assert_eq!(marker, "allow_exception:git_push_force");
assert_eq!(reason, "release flow");
}
other => panic!("expected AllowDowngraded, got {other:?}"),
}
assert!(matches!(decide("ls -la", Some(&p)), Verdict::Allow));
}
#[test]
fn test_report_shows_suppression() {
let cfg = crate::config::PolicyConfig {
rules: vec![],
allow: vec![crate::config::PolicyAllowConfig {
pattern: r"^git push --force origin main-backup$".into(),
suppresses: "git_push_force".into(),
reason: "release flow".into(),
}],
project_rules: vec![],
disabled: vec![],
};
let p = Policy::compile(&cfg).unwrap();
let (report, code) = test_report("git push --force origin main-backup", Some(&p), true);
assert_eq!(code, 0);
assert!(
report.contains("allow_exception:git_push_force"),
"{report}"
);
}
#[test]
fn project_rule_attribution_in_report_and_fail_closed_hint() {
assert_eq!(rule_source("project:foo"), "project rule");
let msg = fail_closed_ask_message("reason", "project:foo", "foo --bar");
assert!(msg.contains(".vallum.toml"), "{msg}");
assert!(
!msg.contains("[policy] disabled"),
"must not suggest disabling built-ins: {msg}"
);
}
}