use chrono::Local;
use clap::{CommandFactory, Parser};
use clap_complete::generate;
use serde::Serialize;
use std::io::{self, Write};
use vallum::cli::{Cli, Commands, ConfigAction, PolicyCliAction};
use vallum::config::AppConfig;
use vallum::install_hook::{self, Level};
use vallum::metrics::{self, StatEntry};
use vallum::{
ansi, audit, executor, hook, optimizer, scrubber, stats, truncator, welcome, whitespace,
};
#[derive(Serialize)]
struct RunOutput<'a> {
command: &'a str,
args: &'a [String],
exit_code: i32,
optimizer: Option<&'a str>,
tokens_before: usize,
tokens_after: usize,
sanitized_output: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
policy: Option<&'a vallum::policy::PolicyVerdict>,
}
fn atty_stdin() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal()
}
fn prompt_tty(reason: &str) -> Option<String> {
use std::io::{BufRead, Write};
let mut tty = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.ok()?;
write!(tty, "[vallum] {reason} — proceed? [y/N] ").ok()?;
tty.flush().ok()?;
let mut reader = std::io::BufReader::new(tty);
let mut line = String::new();
reader.read_line(&mut line).ok()?;
Some(line)
}
fn emit_block(
json: bool,
verdict: &vallum::policy::PolicyVerdict,
cmd: &str,
args: &[String],
) -> ! {
if json {
let payload = RunOutput {
command: cmd,
args,
exit_code: 125,
optimizer: None,
tokens_before: 0,
tokens_after: 0,
sanitized_output: "[BLOCKED BY POLICY]\n",
policy: Some(verdict),
};
if let Ok(s) = serde_json::to_string(&payload) {
println!("{s}");
}
} else {
eprintln!("[vallum] blocked: {}", verdict.reason);
}
std::process::exit(125);
}
fn main() {
let cli = Cli::parse();
let Some(command) = &cli.command else {
welcome::print();
return;
};
match command {
Commands::Run {
json,
strict,
tee,
privacy,
approval_token,
cmd,
args,
} => {
let config = match AppConfig::load() {
Ok(config) => config,
Err(e) => {
eprintln!("Config Error: {}", e);
std::process::exit(125);
}
};
let command_line = if args.is_empty() {
cmd.clone()
} else {
format!("{} {}", cmd, args.join(" "))
};
let pre_approved = match approval_token.as_deref() {
Some(tok) => vallum::approval::load_secret(&config)
.map(|secret| vallum::approval::verify(&command_line, tok, &secret))
.unwrap_or(false),
None => false,
};
if config.security.guardrail {
if let Some(trip) = vallum::breaker::active_trip(&config) {
let verdict = vallum::policy::PolicyVerdict {
action: vallum::policy::PolicyAction::Deny,
reason: vallum::breaker::trip_reason(&trip),
rule_name: "circuit_breaker".to_string(),
};
emit_block(*json, &verdict, cmd, args);
}
}
if pre_approved && config.security.guardrail && config.security.approval_cache {
if let Ok(policy) = vallum::policy::Policy::compile(&config.policy) {
let inner = if cmd == "bash" && args.len() == 2 && args[0] == "-c" {
args[1].clone()
} else {
command_line.clone()
};
let v = policy.evaluate(&inner);
if v.action == vallum::policy::PolicyAction::Ask {
let cwd = std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default();
vallum::approvals::record(&config, &inner, &cwd, &v.rule_name);
}
}
}
if config.security.guardrail && !pre_approved {
let policy = match vallum::policy::Policy::compile(&config.policy) {
Ok(p) => p,
Err(e) => {
eprintln!("Config Error: policy failed to compile: {}", e);
std::process::exit(125);
}
};
let verdict = policy.evaluate(&command_line);
match verdict.action {
vallum::policy::PolicyAction::Allow => {
if !verdict.rule_name.is_empty() {
vallum::policy::audit::log_allow_downgrade(
&verdict.rule_name,
&command_line,
"direct",
&config,
);
}
}
vallum::policy::PolicyAction::Deny => {
vallum::policy::audit::log_verdict(
&verdict,
&command_line,
"direct",
&config,
);
vallum::breaker::record_and_check(&config);
emit_block(*json, &verdict, cmd, args);
}
vallum::policy::PolicyAction::Ask => {
let cwd = std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default();
if vallum::approvals::lookup(
&config,
&command_line,
&cwd,
&verdict.rule_name,
) {
vallum::policy::audit::log_allow_downgrade(
&format!("approval_cache:{}", verdict.rule_name),
&command_line,
"direct",
&config,
);
} else {
vallum::policy::audit::log_verdict(
&verdict,
&command_line,
"direct",
&config,
);
vallum::breaker::record_and_check(&config);
let assume_yes = config.security.assume_yes
|| std::env::var("VALLUM_ASSUME_YES")
.map(|v| v == "1")
.unwrap_or(false);
let (decision, via_tty) = if assume_yes {
(vallum::policy::AskDecision::Proceed, false)
} else if !*json && atty_stdin() {
let resp = prompt_tty(&verdict.reason);
(
vallum::policy::resolve_ask(false, true, resp.as_deref()),
true,
)
} else {
(vallum::policy::resolve_ask(false, false, None), false)
};
if decision == vallum::policy::AskDecision::Blocked {
emit_block(*json, &verdict, cmd, args);
} else if via_tty {
vallum::approvals::record(
&config,
&command_line,
&cwd,
&verdict.rule_name,
);
}
}
}
}
}
let tee_path = if *tee {
dirs::home_dir().map(|h| h.join(".vallum").join("live.log"))
} else {
None
};
let (raw_output, exit_code) = match executor::execute_command(
cmd,
args,
config.pipeline.max_output_bytes,
config.pipeline.timeout_secs,
tee_path.as_deref(),
) {
Ok(output) => output,
Err(e) => {
eprintln!("Proxy Error: {}", e);
std::process::exit(125);
}
};
let extra = scrubber::compile_rules(&config.scrubber.extra_secret_patterns);
let privacy_on = config.privacy.enabled
|| *privacy
|| std::env::var("VALLUM_PRIVACY")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
let privacy_opts =
privacy_on.then(|| scrubber::pii::PrivacyOptions::from_config(&config));
let opts = scrubber::ScrubOptions::from_config(&extra, &config)
.with_strict(*strict)
.with_privacy(privacy_opts.as_ref());
let safe_cmd = scrubber::redact(cmd, &opts);
let safe_args: Vec<String> = args.iter().map(|a| scrubber::redact(a, &opts)).collect();
let cmd_context = format!("{} {:?}", safe_cmd, safe_args);
let tokens_before = metrics::estimate_tokens(&raw_output);
if config.audit.raw_enabled {
let _ = audit::write_log(
"raw.local.log",
&cmd_context,
&raw_output,
config.audit.log_dir.as_deref(),
);
}
let stripped = ansi::strip(&raw_output);
let mut optimizer_name: Option<String> = None;
let processed = if metrics::estimate_tokens(&stripped)
< config.pipeline.min_optimize_tokens
{
whitespace::collapse(&stripped)
} else {
let after_optimize =
match optimizer::dispatch(cmd, args, &stripped, &config.optimizer.disabled) {
Some((out, name)) => {
optimizer_name = Some(name.to_string());
out
}
None => stripped.clone(),
};
let collapsed = whitespace::collapse(&after_optimize);
truncator::smart_truncate(
&collapsed,
config.pipeline.head_lines,
config.pipeline.tail_lines,
config.pipeline.max_line_length,
)
};
let sanitized = scrubber::sanitize(&processed, &opts);
let tokens_after = metrics::estimate_tokens(&sanitized);
if config.audit.sanitized_enabled {
let _ = audit::write_log(
"sanitized.ai.log",
&cmd_context,
&sanitized,
config.audit.log_dir.as_deref(),
);
}
let entry = StatEntry {
ts: Local::now().to_rfc3339(),
cmd: safe_cmd.clone(),
args: safe_args.clone(),
tokens_before,
tokens_after,
optimizer: optimizer_name.clone(),
exit_code,
};
metrics::append_stat(&entry);
if *json {
let payload = RunOutput {
command: &safe_cmd,
args: &safe_args,
exit_code,
optimizer: optimizer_name.as_deref(),
tokens_before,
tokens_after,
sanitized_output: &sanitized,
policy: None,
};
match serde_json::to_string(&payload) {
Ok(json_output) => println!("{}", json_output),
Err(e) => {
eprintln!("Proxy Error: failed to serialize JSON output: {}", e);
std::process::exit(125);
}
}
} else {
print!("{}", sanitized);
}
let _ = io::stdout().flush();
std::process::exit(exit_code);
}
Commands::Stats { reset } => {
let Some(path) = metrics::stats_path() else {
eprintln!("stats: no home directory — ~/.vallum/stats.jsonl is unavailable");
std::process::exit(125);
};
if *reset {
if let Err(e) = stats::reset(&path) {
eprintln!("Stats reset failed: {}", e);
}
} else {
match stats::aggregate(&path) {
Ok(report) => stats::print_report(&report),
Err(e) => eprintln!("Could not read stats: {}", e),
}
}
}
Commands::Hook { agent } => {
let code = match agent {
vallum::cli::AgentArg::Claude => hook::claude::run(),
vallum::cli::AgentArg::Codex => hook::codex::run(),
vallum::cli::AgentArg::Cursor => hook::cursor::run(),
vallum::cli::AgentArg::Gemini => hook::gemini::run(),
};
std::process::exit(code);
}
Commands::InstallHook {
agent,
user,
project,
force,
session_scan,
} => {
let level = match resolve_level(*user, *project) {
Ok(l) => l,
Err(msg) => {
eprintln!("{msg}");
std::process::exit(125);
}
};
if *project {
if let Some(a) = agent {
if !matches!(a, vallum::cli::AgentArg::Claude) {
eprintln!(
"install-hook: --project is Claude Code-only; {a:?} installs are user-level in v1",
);
std::process::exit(125);
}
}
}
#[cfg(unix)]
if agent.is_none() && !*project && !*session_scan && picker_available() {
interactive_install(level, *force); }
let resolved = agent.unwrap_or(vallum::cli::AgentArg::Claude);
match install_hook::install_agent(agent_from_arg(resolved), level, *force) {
Ok(msg) => println!("{msg}"),
Err(e) => {
eprintln!("install-hook: {e}");
std::process::exit(125);
}
}
if *session_scan {
if !matches!(resolved, vallum::cli::AgentArg::Claude) {
eprintln!("install-hook: --session-scan is Claude Code-only");
std::process::exit(125);
}
match vallum::install_hook::claude::install_session_scan(level, *force) {
Ok(msg) => println!("{msg}"),
Err(e) => {
eprintln!("install-hook: {e}");
std::process::exit(125);
}
}
}
}
Commands::UninstallHook {
agent,
user,
project,
} => {
let level = match resolve_level(*user, *project) {
Ok(l) => l,
Err(msg) => {
eprintln!("{msg}");
std::process::exit(125);
}
};
if *project {
if let Some(a) = agent {
if !matches!(a, vallum::cli::AgentArg::Claude) {
eprintln!(
"uninstall-hook: --project is Claude Code-only; {a:?} installs are user-level in v1",
);
std::process::exit(125);
}
}
}
#[cfg(unix)]
if agent.is_none() && !*project && picker_available() {
interactive_uninstall(level); }
let resolved = agent.unwrap_or(vallum::cli::AgentArg::Claude);
match install_hook::uninstall_agent(agent_from_arg(resolved), level) {
Ok(msg) => println!("{msg}"),
Err(e) => {
eprintln!("uninstall-hook: {e}");
std::process::exit(125);
}
}
}
Commands::Config { action } => match action {
ConfigAction::Show => {
let config = match AppConfig::load() {
Ok(c) => c,
Err(e) => {
eprintln!("Config Error: {e}");
std::process::exit(125);
}
};
match toml::to_string_pretty(&config) {
Ok(s) => print!("{s}"),
Err(e) => {
eprintln!("config show: serialize failed: {e}");
std::process::exit(125);
}
}
}
ConfigAction::Init { force, project } => match config_init(*force, *project) {
Ok(msg) => println!("{msg}"),
Err(e) => {
eprintln!("config init: {e}");
std::process::exit(125);
}
},
},
Commands::Policy { action } => match action {
PolicyCliAction::Test { command } => {
let config = match AppConfig::load() {
Ok(config) => config,
Err(e) => {
eprintln!("Config Error: {}", e);
std::process::exit(125);
}
};
let policy = if config.security.guardrail {
match vallum::policy::Policy::compile(&config.policy) {
Ok(p) => Some(p),
Err(e) => {
eprintln!("Config Error: policy failed to compile: {}", e);
std::process::exit(125);
}
}
} else {
None
};
let command_line = command.join(" ");
let (report, code) = vallum::hook::test_report(
&command_line,
policy.as_ref(),
config.security.guardrail,
);
print!("{report}");
std::process::exit(code);
}
},
Commands::Mcp { action } => match action {
vallum::cli::McpAction::Scan { json, paths } => {
let config = match AppConfig::load() {
Ok(c) => c,
Err(e) => {
eprintln!("Config Error: {e}");
std::process::exit(125);
}
};
std::process::exit(vallum::mcp::run_scan(paths, *json, &config));
}
},
Commands::Skills { action } => match action {
vallum::cli::SkillsAction::Scan { json, paths } => {
let config = match AppConfig::load() {
Ok(c) => c,
Err(e) => {
eprintln!("Config Error: {e}");
std::process::exit(125);
}
};
std::process::exit(vallum::skills::run_scan(paths, *json, &config));
}
},
Commands::Scan {
json,
sarif,
full,
hook_context,
paths,
} => {
if *sarif && (*full || *json) {
eprintln!("scan: --sarif cannot be combined with --full or --json");
std::process::exit(125);
}
if *hook_context {
std::process::exit(vallum::scan::run_hook_context());
}
let config = match AppConfig::load() {
Ok(c) => c,
Err(e) => {
eprintln!("Config Error: {e}");
std::process::exit(125);
}
};
let format = if *sarif {
vallum::scan::OutputFormat::Sarif
} else if *json {
vallum::scan::OutputFormat::Json
} else {
vallum::scan::OutputFormat::Human
};
std::process::exit(vallum::scan::run(paths, format, *full, &config));
}
Commands::Log { action } => match action {
vallum::cli::LogAction::Verify { expect_head } => {
let config = match AppConfig::load() {
Ok(c) => c,
Err(e) => {
eprintln!("Config Error: {e}");
std::process::exit(125);
}
};
std::process::exit(vallum::logchain::run_verify(
expect_head.as_deref(),
&config,
));
}
},
Commands::Unlock => {
let config = match AppConfig::load() {
Ok(c) => c,
Err(e) => {
eprintln!("Config Error: {e}");
std::process::exit(125);
}
};
match vallum::breaker::unlock(&config) {
Ok(Some(until)) => {
println!("circuit breaker unlocked (was locked until {until})")
}
Ok(None) => println!("circuit breaker is not locked"),
Err(e) => {
eprintln!("unlock: {e}");
std::process::exit(125);
}
}
}
Commands::Approvals { action } => {
let config = match AppConfig::load() {
Ok(c) => c,
Err(e) => {
eprintln!("Config Error: {e}");
std::process::exit(125);
}
};
match action {
vallum::cli::ApprovalsAction::List => {
let entries = vallum::approvals::list_and_prune(&config);
if entries.is_empty() {
println!("no cached approvals");
} else {
let extra = scrubber::compile_rules(&config.scrubber.extra_secret_patterns);
let opts = scrubber::ScrubOptions::from_config(&extra, &config);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
for e in entries {
let age_days = now.saturating_sub(e.ts) / 86_400;
let safe_cmd = scrubber::redact(&e.cmd, &opts);
println!("{age_days:>3}d [{}] {} {}", e.rule, e.cwd, safe_cmd);
}
}
}
vallum::cli::ApprovalsAction::Clear => match vallum::approvals::clear(&config) {
Ok(n) => println!("removed {n} cached approval(s)"),
Err(e) => {
eprintln!("approvals clear: {e}");
std::process::exit(125);
}
},
}
}
Commands::Doctor => {
std::process::exit(vallum::doctor::run());
}
Commands::Update { check, run } => {
std::process::exit(vallum::update::run(*check, *run));
}
Commands::Completions { shell } => {
let mut cmd = Cli::command();
generate(*shell, &mut cmd, "vallum", &mut std::io::stdout());
}
}
}
#[cfg(unix)]
fn picker_available() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& std::env::var_os("TERM")
.map(|t| t != "dumb")
.unwrap_or(true)
}
#[cfg(unix)]
fn interactive_install(level: Level, force: bool) -> ! {
use vallum::install_hook::{agent_status, select, ALL_AGENTS};
let statuses: Vec<_> = ALL_AGENTS.iter().map(|&a| (a, agent_status(a))).collect();
let rows = select::Row::install_rows(&statuses);
let state = select::SelectState::new("Select agents to hook", rows);
match select::run_picker(state) {
Err(e) => {
eprintln!(
"install-hook: {e}; pass --agent <claude|codex|cursor|gemini> to skip the picker"
);
std::process::exit(125);
}
Ok(None) => {
eprintln!("Aborted; nothing changed.");
std::process::exit(130);
}
Ok(Some(agents)) if agents.is_empty() => {
println!("Nothing selected; nothing changed.");
std::process::exit(0);
}
Ok(Some(agents)) => {
let mut failed = false;
for agent in agents {
match install_hook::install_agent(agent, level, force) {
Ok(msg) => println!("{msg}"),
Err(e) => {
eprintln!("install-hook: {e}");
failed = true;
}
}
}
std::process::exit(if failed { 125 } else { 0 });
}
}
}
#[cfg(unix)]
fn interactive_uninstall(level: Level) -> ! {
use vallum::install_hook::{agent_status, select, ALL_AGENTS};
let statuses: Vec<_> = ALL_AGENTS.iter().map(|&a| (a, agent_status(a))).collect();
let rows = select::Row::uninstall_rows(&statuses);
if rows.is_empty() {
println!("No Vallum hooks found; nothing to do.");
std::process::exit(0);
}
let state = select::SelectState::new("Select agents to unhook", rows);
match select::run_picker(state) {
Err(e) => {
eprintln!(
"uninstall-hook: {e}; pass --agent <claude|codex|cursor|gemini> to skip the picker"
);
std::process::exit(125);
}
Ok(None) => {
eprintln!("Aborted; nothing changed.");
std::process::exit(130);
}
Ok(Some(agents)) if agents.is_empty() => {
println!("Nothing selected; nothing changed.");
std::process::exit(0);
}
Ok(Some(agents)) => {
let mut failed = false;
for agent in agents {
match install_hook::uninstall_agent(agent, level) {
Ok(msg) => println!("{msg}"),
Err(e) => {
eprintln!("uninstall-hook: {e}");
failed = true;
}
}
}
std::process::exit(if failed { 125 } else { 0 });
}
}
}
fn resolve_level(user: bool, project: bool) -> Result<Level, String> {
match (user, project) {
(true, true) => Err("--user and --project are mutually exclusive".to_string()),
(false, true) => Ok(Level::Project),
_ => Ok(Level::User), }
}
fn agent_from_arg(a: vallum::cli::AgentArg) -> vallum::install_hook::Agent {
use vallum::cli::AgentArg;
use vallum::install_hook::Agent;
match a {
AgentArg::Claude => Agent::Claude,
AgentArg::Cursor => Agent::Cursor,
AgentArg::Gemini => Agent::Gemini,
AgentArg::Codex => Agent::Codex,
}
}
const DEFAULT_CONFIG_TOML: &str = r#"# ~/.vallum/config.toml — Vallum configuration
[audit]
# log_dir = "/tmp/vallum-logs" # override log directory (default: ~/.vallum/logs)
raw_enabled = false # raw, unredacted logging is opt-in
sanitized_enabled = true
[pipeline]
head_lines = 50
tail_lines = 50
min_optimize_tokens = 50
max_output_bytes = 10485760 # 10 MiB capture cap
timeout_secs = 300 # child timeout; 0 disables
max_line_length = 2000 # truncate single lines longer than this; 0 disables
[scrubber]
entropy = true # context-gated entropy redaction of credential-ish values
normalize = true # strip invisible/bidi chars + fold homoglyphs for injection matching
# extra_secret_patterns = [
# { pattern = "token-[0-9]+", replacement = "token-***" }
# ]
[security]
strict = false # block output if a prompt injection is detected
approval_cache = true # remember approved Asks (exact command+cwd; narrow rule set)
approval_cache_ttl_days = 14 # days a cached approval stays valid (1-90)
# Scoped allow exception: suppress ONE built-in for commands matching pattern.
# [[policy.allow]]
# pattern = '^git push --force origin main-backup$'
# suppresses = "git_push_force"
# reason = "release flow"
[optimizer]
disabled = [] # optimizer names to turn off (e.g. ["npm","docker"])
"#;
const PROJECT_CONFIG_TOML: &str = r#"# .vallum.toml — project policy (tighten-only). Commit this file.
# Only [[policy.rules]] with action "ask" or "deny" is accepted here; every
# other key ([policy] disabled, [[policy.allow]], [security], [audit], …) is
# rejected and the whole file is ignored with a warning. Limits: 64 rules,
# 512-byte patterns.
[[policy.rules]]
pattern = 'terraform\s+destroy'
action = "ask"
reason = "infra guard"
"#;
fn config_init(force: bool, project: bool) -> Result<String, String> {
let (path, contents) = if project {
(
std::path::PathBuf::from(".vallum.toml"),
PROJECT_CONFIG_TOML,
)
} else {
(
vallum::config::config_path_from_env_or_default(),
DEFAULT_CONFIG_TOML,
)
};
if path.exists() && !force {
return Ok(format!(
"{} already exists; pass --force to overwrite.",
path.display()
));
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
}
write_private(&path, contents)?;
Ok(format!(
"Wrote {} → {}",
if project {
"project config"
} else {
"default config"
},
path.display()
))
}
#[cfg(unix)]
fn write_private(path: &std::path::Path, contents: &str) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("open {}: {e}", path.display()))?;
f.write_all(contents.as_bytes())
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn write_private(path: &std::path::Path, contents: &str) -> Result<(), String> {
std::fs::write(path, contents).map_err(|e| format!("write {}: {e}", path.display()))
}