use std::ffi::OsString;
use crate::AppError;
use crate::config::{self, ConfigLoadResult, load_config};
use crate::installer;
use crate::rules::{CommandInvocation, match_rule};
use crate::unwrap;
pub(crate) enum HookCheckResult {
Allow,
BlockMeta(&'static str),
BlockRule {
rule_name: String,
message: String,
unwrap_chain: Option<String>,
},
BlockStructural {
message: String,
wrapper_kind: Option<&'static str>,
},
}
fn is_command_position(tokens: &[String], idx: usize) -> bool {
if idx == 0 {
return true;
}
let mut j = idx;
while j > 0 {
let prev = &tokens[j - 1];
if matches!(prev.as_str(), "&&" | "||" | ";" | "|" | "&") {
return true;
}
if unwrap::is_env_assignment(prev) {
j -= 1;
continue;
}
return false;
}
true }
fn detect_env_var_tampering(tokens: &[String]) -> Option<&'static str> {
let vars = installer::PROTECTED_ENV_VARS;
for (i, w) in tokens.windows(2).enumerate() {
if w[0] == "unset" && is_command_position(tokens, i) && vars.contains(&w[1].as_str()) {
return Some("blocked attempt to unset a detector env var");
}
}
for (i, w) in tokens.windows(2).enumerate() {
if !is_command_position(tokens, i) {
continue;
}
if w[0] == "env" && w[1].starts_with("-u") {
let rest = &w[1][2..];
if !rest.is_empty() && vars.contains(&rest) {
return Some("blocked attempt to unset a detector env var");
}
}
if w[0] == "export" && w[1].starts_with("-n") {
let rest = &w[1][2..];
if !rest.is_empty() && vars.contains(&rest) {
return Some("blocked attempt to unexport detector env var");
}
}
}
for (i, w) in tokens.windows(3).enumerate() {
if !is_command_position(tokens, i) {
continue;
}
if w[0] == "env" && w[1] == "-u" && vars.contains(&w[2].as_str()) {
return Some("blocked attempt to unset a detector env var");
}
if w[0] == "export" && w[1] == "-n" && vars.contains(&w[2].as_str()) {
return Some("blocked attempt to unexport detector env var");
}
}
for (i, token) in tokens.iter().enumerate() {
if is_command_position(tokens, i) {
for var in vars {
if token
.strip_prefix(var)
.is_some_and(|rest| rest.starts_with('='))
{
return Some("blocked attempt to unset a detector env var");
}
}
}
}
None
}
fn detect_path_shim_bypass(tokens: &[String]) -> Option<&'static str> {
let shim_cmds = installer::SHIM_COMMANDS;
for (i, token) in tokens.iter().enumerate() {
if !is_command_position(tokens, i) {
continue;
}
if token.strip_prefix("PATH=").is_some() {
let mut cmd_idx = i + 1;
while cmd_idx < tokens.len() && unwrap::is_env_assignment(&tokens[cmd_idx]) {
cmd_idx += 1;
}
if cmd_idx < tokens.len() {
let cmd_base = tokens[cmd_idx]
.rsplit('/')
.next()
.unwrap_or(&tokens[cmd_idx]);
if shim_cmds.contains(&cmd_base) {
return Some("blocked PATH override that bypasses shim protection");
}
}
}
let base = token.rsplit('/').next().unwrap_or(token);
if base == "env" {
let mut pos = i + 1;
let mut found_path_override = false;
let mut past_options = false;
while pos < tokens.len() {
let t = &tokens[pos];
if !past_options {
if t == "--" {
past_options = true;
pos += 1;
continue;
}
if t == "-u" || t == "-S" || t == "-C" || t == "-P" {
pos += 2;
continue;
}
if t.starts_with('-') {
pos += 1;
continue;
}
}
if unwrap::is_env_assignment(t) {
if t.starts_with("PATH=") {
found_path_override = true;
}
pos += 1;
continue;
}
break;
}
if found_path_override && pos < tokens.len() {
let cmd_base = tokens[pos].rsplit('/').next().unwrap_or(&tokens[pos]);
if shim_cmds.contains(&cmd_base) {
return Some("blocked PATH override that bypasses shim protection");
}
}
}
}
None
}
fn check_pre_phase_2(command: &str) -> Result<Vec<CommandInvocation>, HookCheckResult> {
for (pattern, reason) in installer::blocked_string_patterns() {
if command.contains(pattern) {
return Err(HookCheckResult::BlockMeta(reason));
}
}
let normalized = unwrap::normalize_compound_operators(command);
if let Ok(tokens) = shell_words::split(&normalized) {
if let Some(reason) = detect_env_var_tampering(&tokens) {
return Err(HookCheckResult::BlockMeta(reason));
}
if let Some(reason) = detect_path_shim_bypass(&tokens) {
return Err(HookCheckResult::BlockMeta(reason));
}
}
match unwrap::parse_command_string(command) {
unwrap::ParseResult::Block(reason) => {
let wrapper_kind = match &reason {
unwrap::BlockReason::PipeToShell { wrapper } => *wrapper,
unwrap::BlockReason::ObfuscatedExpansion => {
Some("__obfuscated_expansion__")
}
_ => None,
};
Err(HookCheckResult::BlockStructural {
message: format!("omamori hook: blocked — {}", reason.message()),
wrapper_kind,
})
}
unwrap::ParseResult::Commands(invocations) => Ok(invocations),
}
}
fn match_invocations_against_rules(
command: &str,
invocations: &[CommandInvocation],
rules: &[crate::rules::RuleConfig],
) -> HookCheckResult {
for inv in invocations {
if let Some(rule) = match_rule(rules, inv) {
let chain_desc = format_unwrap_chain(command, inv);
let msg = rule
.message
.clone()
.unwrap_or_else(|| format!("matched rule: {}", rule.name));
return HookCheckResult::BlockRule {
rule_name: rule.name.clone(),
message: msg,
unwrap_chain: chain_desc,
};
}
}
HookCheckResult::Allow
}
pub(crate) fn check_command_for_hook(command: &str) -> HookCheckResult {
let invocations = match check_pre_phase_2(command) {
Ok(invs) => invs,
Err(verdict) => return verdict,
};
let load_result = load_config(None).unwrap_or_else(|_| ConfigLoadResult {
config: config::Config::default(),
warnings: vec![],
});
match_invocations_against_rules(command, &invocations, &load_result.config.rules)
}
#[cfg(test)]
pub(crate) fn check_command_for_hook_with_rules(
command: &str,
rules: &[crate::rules::RuleConfig],
) -> HookCheckResult {
let invocations = match check_pre_phase_2(command) {
Ok(invs) => invs,
Err(verdict) => return verdict,
};
match_invocations_against_rules(command, &invocations, rules)
}
fn format_unwrap_chain(original: &str, invocation: &CommandInvocation) -> Option<String> {
let trimmed = original.trim();
if !trimmed.starts_with(&invocation.program) {
let outer = trimmed.split_whitespace().next().unwrap_or("");
let outer_base = outer.rsplit('/').next().unwrap_or(outer);
if trimmed.contains("-c") {
Some(format!("via {} -c", outer_base))
} else {
Some(format!("via {}", outer_base))
}
} else {
None
}
}
pub(crate) fn run_hook_check(args: &[OsString]) -> Result<i32, AppError> {
use std::io::Read;
let provider = parse_provider_flag(args);
let verbose = std::env::var("OMAMORI_VERBOSE").is_ok();
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
match extract_hook_input(&input) {
HookInput::MalformedJson => {
eprintln!("omamori hook: blocked — hook input is not valid JSON");
eprintln!(" The command was denied because omamori cannot verify its safety.");
eprintln!(
" This may happen after an AI tool update. Try: upgrade omamori, or report at https://github.com/yottayoshida/omamori/issues"
);
if verbose {
eprintln!(" provider: {provider}");
eprintln!(
" raw input (first 200 chars): {}",
truncate_for_log(&input, 200)
);
}
Ok(2)
}
HookInput::MalformedMissingField => {
eprintln!("omamori hook: blocked — required fields missing from hook input");
eprintln!(" The command was denied because omamori cannot verify its safety.");
eprintln!(" Expected: tool_input.command or tool_input.file_path");
if verbose {
eprintln!(" provider: {provider}");
eprintln!(
" raw input (first 200 chars): {}",
truncate_for_log(&input, 200)
);
}
Ok(2)
}
HookInput::UnknownTool {
tool_name,
tool_input,
} => run_hook_check_unknown_tool(&tool_name, &tool_input, &provider, verbose),
HookInput::FileOp { tool, path } => {
if let Some(reason) = is_protected_file_path(&path) {
eprintln!("omamori hook: blocked {tool} to protected file — {reason}");
eprintln!(" AI agents cannot modify omamori configuration or security files.");
eprintln!(
" To edit config: use `omamori config` CLI or edit the file directly in your terminal."
);
if verbose {
eprintln!(" provider: {provider}");
eprintln!(" tool: {tool}");
eprintln!(" path: {path}");
}
Ok(2)
} else {
print_hook_check_allow_response(&format!(
"omamori: {tool} to non-protected path — allowed"
));
Ok(0)
}
}
HookInput::Command(command) => {
if command.is_empty() {
print_hook_check_allow_response("omamori: empty command");
return Ok(0);
}
run_hook_check_command(&command, &provider, verbose)
}
}
}
fn run_hook_check_command(command: &str, provider: &str, verbose: bool) -> Result<i32, AppError> {
match check_command_for_hook(command) {
HookCheckResult::Allow => {
print_hook_check_allow_response("omamori: no dangerous pattern detected");
Ok(0)
}
HookCheckResult::BlockMeta(reason) => {
audit_log_hook_block(
command,
provider,
None,
None,
"layer2:meta-pattern".to_string(),
);
eprintln!("omamori hook: blocked — {reason}");
if verbose {
eprintln!(" provider: {provider}");
eprintln!(" layer: meta-pattern (string-level)");
}
eprintln!(" hint: run `omamori explain -- {}` for details", command);
Ok(2)
}
HookCheckResult::BlockRule {
rule_name,
message,
unwrap_chain,
} => {
let chain_str = unwrap_chain
.as_deref()
.map(|c| format!(" ({c})"))
.unwrap_or_default();
audit_log_hook_block(
command,
provider,
Some(&rule_name),
unwrap_chain.clone(),
"layer2:rule".to_string(),
);
eprintln!("omamori hook: blocked — {message}{chain_str}");
if verbose {
eprintln!(" provider: {provider}");
eprintln!(" rule: {rule_name}");
eprintln!(" layer: unwrap-stack (token-level)");
}
eprintln!(" hint: run `omamori explain -- {command}` for details");
Ok(2)
}
HookCheckResult::BlockStructural {
message,
wrapper_kind,
} => {
let detection_layer = match wrapper_kind {
Some("__obfuscated_expansion__") => "layer2:obfuscated-expansion".to_string(),
Some(w) => format!("layer2:pipe-to-shell:{w}"),
None => "layer2:structural".to_string(),
};
audit_log_hook_block(command, provider, None, None, detection_layer);
eprintln!("{message}");
if verbose {
eprintln!(" provider: {provider}");
eprintln!(" layer: unwrap-stack (structural)");
}
eprintln!(" hint: run `omamori explain -- {command}` for details");
Ok(2)
}
}
}
fn run_hook_check_unknown_tool(
tool_name: &str,
tool_input: &serde_json::Value,
provider: &str,
verbose: bool,
) -> Result<i32, AppError> {
match classify_input_shape(tool_input) {
InputShape::ShellCommand(cmd) => {
if cmd.is_empty() {
print_hook_check_allow_response("omamori: empty command");
return Ok(0);
}
run_hook_check_command(cmd, provider, verbose)
}
InputShape::FileOp(path) => {
if let Some(reason) = is_protected_file_path(path) {
eprintln!("omamori hook: blocked {tool_name} to protected file — {reason}");
eprintln!(" AI agents cannot modify omamori configuration or security files.");
eprintln!(
" To edit config: use `omamori config` CLI or edit the file directly in your terminal."
);
if verbose {
eprintln!(" provider: {provider}");
eprintln!(" tool: {tool_name}");
eprintln!(" path: {path}");
}
Ok(2)
} else {
print_hook_check_allow_response(&format!(
"omamori: '{tool_name}' file op to non-protected path — allowed"
));
Ok(0)
}
}
InputShape::ReadOnlyUrl => {
print_hook_check_allow_response(&format!(
"omamori: '{tool_name}' read-only url tool — allowed"
));
Ok(0)
}
InputShape::Unknown => {
eprintln!(
"omamori: unknown tool '{tool_name}' routed as fail-open. \
Review via 'omamori audit unknown'"
);
audit_log_unknown_tool_fail_open(tool_name, tool_input, provider);
print_hook_check_allow_response(&format!(
"omamori: unknown tool '{tool_name}' routed as fail-open — allowed"
));
Ok(0)
}
}
}
fn audit_log_unknown_tool_fail_open(
tool_name: &str,
tool_input: &serde_json::Value,
provider: &str,
) {
let load_result = match load_config(None) {
Ok(r) => r,
Err(e) => {
eprintln!(
"omamori warning: could not record unknown_tool_fail_open event for '{tool_name}' \
— config load failed: {e}. The 'omamori audit unknown' review surface is \
incomplete for this event."
);
return;
}
};
let logger = match crate::audit::AuditLogger::from_config(&load_result.config.audit) {
Some(l) => l,
None => {
return;
}
};
let invocation = CommandInvocation::new(tool_name.to_string(), Vec::new());
let detectors = vec![provider.to_string()];
let outcome = crate::actions::ActionOutcome::PassedThrough { exit_code: 0 };
let mut event = logger.create_event(&invocation, None, &detectors, &outcome);
event.action = "unknown_tool_fail_open".to_string();
event.result = "allow".to_string();
event.detection_layer = Some("shape-routing".to_string());
event.target_count = tool_input.as_object().map(|o| o.len()).unwrap_or(0);
if let Err(e) = logger.append(event) {
eprintln!(
"omamori warning: failed to record unknown_tool_fail_open event for '{tool_name}': {e}. \
The 'omamori audit unknown' review surface is incomplete for this event."
);
}
}
const VALID_DETECTION_LAYERS_STATIC: &[&str] = &[
"layer1",
"shape-routing",
"layer2:meta-pattern",
"layer2:rule",
"layer2:structural",
"layer2:obfuscated-expansion",
];
fn is_valid_detection_layer(s: &str) -> bool {
if VALID_DETECTION_LAYERS_STATIC.contains(&s) {
return true;
}
if let Some(rest) = s.strip_prefix("layer2:pipe-to-shell:") {
return crate::unwrap::TRANSPARENT_WRAPPERS.contains(&rest);
}
false
}
fn audit_log_hook_block(
command: &str,
provider: &str,
rule_name: Option<&str>,
unwrap_chain: Option<String>,
detection_layer_value: String,
) {
debug_assert!(
is_valid_detection_layer(&detection_layer_value),
"detection_layer value must come from VALID_DETECTION_LAYERS taxonomy: got {detection_layer_value:?}"
);
let load_result = match load_config(None) {
Ok(r) => r,
Err(e) => {
eprintln!(
"omamori warning: could not record Layer 2 hook deny event for {command:?} \
— config load failed: {e}. The 'omamori audit show --action block' surface \
is incomplete for this event."
);
return;
}
};
let logger = match crate::audit::AuditLogger::from_config(&load_result.config.audit) {
Some(l) => l,
None => {
return;
}
};
let invocation = CommandInvocation::new(command.to_string(), Vec::new());
let detectors = vec![provider.to_string()];
let outcome = crate::actions::ActionOutcome::Blocked {
message: "blocked at Layer 2 hook".to_string(),
};
let mut event = logger.create_event(&invocation, None, &detectors, &outcome);
event.action = "block".to_string();
event.result = "block".to_string();
event.detection_layer = Some(detection_layer_value);
event.rule_id = rule_name.map(String::from);
event.unwrap_chain = unwrap_chain.map(|c| vec![c]);
if let Err(e) = logger.append(event) {
eprintln!(
"omamori warning: failed to record Layer 2 hook deny event for {command:?}: {e}. \
The 'omamori audit show --action block' surface is incomplete for this event."
);
}
}
pub(crate) fn run_cursor_hook() -> Result<i32, AppError> {
use std::io::Read;
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
let command = match serde_json::from_str::<serde_json::Value>(&input) {
Ok(v) => match v.get("command") {
Some(c) if c.is_string() => c.as_str().unwrap().to_string(),
Some(_) | None => {
eprintln!("omamori cursor-hook: missing or invalid 'command' field");
print_cursor_response(false, "deny", Some("omamori: malformed hook input"), None);
return Ok(0);
}
},
Err(_) => {
eprintln!("omamori cursor-hook: failed to parse stdin JSON");
print_cursor_response(false, "deny", Some("omamori: malformed hook input"), None);
return Ok(0);
}
};
if command.is_empty() {
print_cursor_response(true, "allow", None, None);
return Ok(0);
}
match check_command_for_hook(&command) {
HookCheckResult::Allow => {
print_cursor_response(true, "allow", None, None);
}
HookCheckResult::BlockMeta(reason) => {
eprintln!("omamori cursor-hook: BLOCKED ({reason})");
print_cursor_response(
false,
"deny",
Some(&format!("omamori hook: {reason}")),
Some(&format!(
"This command was blocked by omamori: {reason}. Use a safer alternative."
)),
);
}
HookCheckResult::BlockRule {
message,
unwrap_chain,
..
} => {
let chain_str = unwrap_chain
.as_deref()
.map(|c| format!(" ({c})"))
.unwrap_or_default();
eprintln!("omamori cursor-hook: BLOCKED ({message}{chain_str})");
print_cursor_response(
false,
"deny",
Some(&format!("omamori hook: blocked — {message}{chain_str}")),
Some("This command was blocked by omamori safety guard. Use a safer alternative."),
);
}
HookCheckResult::BlockStructural {
message,
wrapper_kind: _,
} => {
eprintln!("omamori cursor-hook: BLOCKED ({message})");
print_cursor_response(
false,
"deny",
Some(&message),
Some("This command was blocked by omamori safety guard. Use a safer alternative."),
);
}
}
Ok(0)
}
pub(crate) const PROTECTED_FILE_PATTERNS: &[(&str, &str)] = &[
("omamori/config.toml", "omamori config"),
(".integrity.json", "integrity baseline"),
("audit-secret", "audit HMAC secret"),
("audit.jsonl", "audit log"),
(".local/share/omamori", "omamori data directory"),
("claude-pretooluse.sh", "omamori hook script"),
("codex-pretooluse.sh", "omamori Codex hook script"),
(".codex/hooks.json", "Codex hooks config"),
(".codex/config.toml", "Codex config"),
(
".claude/settings.json",
"Claude Code settings (contains hook config)",
),
];
fn is_protected_file_path(path: &str) -> Option<&'static str> {
let lexical = crate::context::normalize_path(path);
let candidates: Vec<std::path::PathBuf> = match std::fs::canonicalize(&lexical) {
Ok(canonical) => vec![canonical],
Err(_) => lexical
.parent()
.and_then(|p| std::fs::canonicalize(p).ok())
.and_then(|cp| lexical.file_name().map(|f| cp.join(f)))
.into_iter()
.collect(),
};
let lexical_str = lexical.to_string_lossy();
for &(pattern, reason) in PROTECTED_FILE_PATTERNS {
if lexical_str.contains(pattern) {
return Some(reason);
}
for candidate in &candidates {
if candidate.to_string_lossy().contains(pattern) {
return Some(reason);
}
}
}
None
}
#[derive(Debug)]
enum HookInput {
Command(String),
FileOp {
tool: String,
path: String,
},
UnknownTool {
tool_name: String,
tool_input: serde_json::Value,
},
MalformedJson,
MalformedMissingField,
}
#[derive(Debug, PartialEq, Eq)]
enum InputShape<'a> {
ShellCommand(&'a str),
FileOp(&'a str),
ReadOnlyUrl,
Unknown,
}
fn classify_input_shape(tool_input: &serde_json::Value) -> InputShape<'_> {
if let Some(s) = tool_input.get("command").and_then(|v| v.as_str()) {
return InputShape::ShellCommand(s);
}
if let Some(s) = tool_input.get("cmd").and_then(|v| v.as_str()) {
return InputShape::ShellCommand(s);
}
if let Some(s) = tool_input.get("file_path").and_then(|v| v.as_str()) {
return InputShape::FileOp(s);
}
if let Some(s) = tool_input.get("path").and_then(|v| v.as_str()) {
return InputShape::FileOp(s);
}
if tool_input.get("url").and_then(|v| v.as_str()).is_some() {
return InputShape::ReadOnlyUrl;
}
InputShape::Unknown
}
fn has_routing_field_with_wrong_type(tool_input: &serde_json::Value) -> bool {
for field in ["command", "cmd", "file_path", "path", "url"] {
if let Some(val) = tool_input.get(field)
&& val.as_str().is_none()
{
return true;
}
}
false
}
fn extract_hook_input(input: &str) -> HookInput {
let v = match serde_json::from_str::<serde_json::Value>(input) {
Ok(v) => v,
Err(_) => return HookInput::MalformedJson,
};
let tool_name = v.get("tool_name").and_then(|t| t.as_str());
let ti = v.get("tool_input");
let ti_object_check = ti.map(|t| {
let object_ok = matches!(t.as_object(), Some(obj) if !obj.is_empty());
let wrong_type = has_routing_field_with_wrong_type(t);
(t, object_ok, wrong_type)
});
if let Some((_, false, _)) = ti_object_check {
return HookInput::MalformedMissingField;
}
if let Some((_, _, true)) = ti_object_check {
return HookInput::MalformedMissingField;
}
let ti_shape = ti.map(classify_input_shape);
if let Some(InputShape::ShellCommand(cmd)) = ti_shape {
return HookInput::Command(cmd.to_string());
}
if let Some(cmd_val) = v.get("command") {
return match cmd_val.as_str() {
Some(cmd) => HookInput::Command(cmd.to_string()),
None => HookInput::MalformedMissingField,
};
}
if let Some(shape) = ti_shape {
return match shape {
InputShape::ShellCommand(_) => unreachable!("handled at Priority 1"),
InputShape::FileOp(path) => HookInput::FileOp {
tool: tool_name.unwrap_or("unknown").to_string(),
path: path.to_string(),
},
InputShape::ReadOnlyUrl | InputShape::Unknown => match tool_name {
Some(name) => HookInput::UnknownTool {
tool_name: name.to_string(),
tool_input: ti.expect("ti_shape implies ti was Some").clone(),
},
None => HookInput::MalformedMissingField,
},
};
}
if let Some(name) = tool_name {
return HookInput::UnknownTool {
tool_name: name.to_string(),
tool_input: serde_json::Value::Null,
};
}
HookInput::MalformedMissingField
}
fn truncate_for_log(s: &str, max_chars: usize) -> &str {
match s.char_indices().nth(max_chars) {
Some((idx, _)) => &s[..idx],
None => s,
}
}
fn parse_provider_flag(args: &[OsString]) -> String {
for (i, arg) in args.iter().enumerate() {
if arg.to_str() == Some("--provider")
&& let Some(val) = args.get(i + 1)
{
return val.to_string_lossy().to_string();
}
}
"unknown".to_string()
}
fn print_hook_check_allow_response(reason: &str) {
let response = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": reason,
}
});
println!(
"{}",
serde_json::to_string(&response).unwrap_or_else(|_| {
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"omamori: fallback"}}"#.to_string()
})
);
}
fn print_cursor_response(
cont: bool,
permission: &str,
user_message: Option<&str>,
agent_message: Option<&str>,
) {
let mut response = serde_json::json!({
"continue": cont,
"permission": permission,
});
if let Some(msg) = user_message {
response["userMessage"] = serde_json::json!(msg);
}
if let Some(msg) = agent_message {
response["agentMessage"] = serde_json::json!(msg);
}
println!(
"{}",
serde_json::to_string(&response)
.unwrap_or_else(|_| { r#"{"continue":false,"permission":"deny"}"#.to_string() })
);
}
pub fn fuzz_extract_hook_input(input: &str) {
let _ = extract_hook_input(input);
}
pub fn fuzz_check_command_for_hook(command: &str) {
let _ = check_command_for_hook(command);
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn isolate_config() -> (Option<String>, Option<String>, PathBuf) {
let dir = std::env::temp_dir().join(format!("omamori-gr-iso-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let old_xdg = std::env::var("XDG_CONFIG_HOME").ok();
let old_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir.join("xdg"));
std::env::set_var("HOME", &dir);
}
(old_xdg, old_home, dir)
}
fn restore_config(old_xdg: Option<String>, old_home: Option<String>, dir: PathBuf) {
unsafe {
match old_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
match old_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
#[serial_test::serial]
fn check_command_for_hook_blocks_rm_rf_with_default_rules() {
let (old_xdg, old_home, dir) = isolate_config();
match check_command_for_hook("rm -rf /") {
HookCheckResult::BlockRule { rule_name, .. } => {
assert!(
rule_name.contains("rm"),
"expected rm-related rule, got: {rule_name}"
);
}
HookCheckResult::BlockMeta(_) | HookCheckResult::BlockStructural { .. } => {}
HookCheckResult::Allow => {
restore_config(old_xdg, old_home, dir);
panic!("SECURITY: rm -rf / was ALLOWED — fail-close fallback is broken");
}
}
restore_config(old_xdg, old_home, dir);
}
#[test]
#[serial_test::serial]
fn check_command_for_hook_allows_safe_command() {
let (old_xdg, old_home, dir) = isolate_config();
match check_command_for_hook("ls /tmp") {
HookCheckResult::Allow => {}
other => {
restore_config(old_xdg, old_home, dir);
panic!(
"expected Allow for 'ls /tmp', got: {}",
match other {
HookCheckResult::BlockMeta(r) => format!("BlockMeta({r})"),
HookCheckResult::BlockRule { rule_name, .. } =>
format!("BlockRule({rule_name})"),
HookCheckResult::BlockStructural { message: r, .. } =>
format!("BlockStructural({r})"),
HookCheckResult::Allow => unreachable!(),
}
);
}
}
restore_config(old_xdg, old_home, dir);
}
#[test]
fn extract_hook_input_command_from_tool_input() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"ls -la"}}"#;
match extract_hook_input(input) {
HookInput::Command(cmd) => assert_eq!(cmd, "ls -la"),
other => panic!("expected Command, got: {other:?}"),
}
}
#[test]
fn extract_hook_input_command_from_top_level() {
let input = r#"{"command":"echo hello"}"#;
match extract_hook_input(input) {
HookInput::Command(cmd) => assert_eq!(cmd, "echo hello"),
other => panic!("expected Command, got: {other:?}"),
}
}
#[test]
fn extract_hook_input_file_op() {
let input = r#"{"tool_name":"Edit","tool_input":{"file_path":"/tmp/x.rs"}}"#;
match extract_hook_input(input) {
HookInput::FileOp { tool, path } => {
assert_eq!(tool, "Edit");
assert_eq!(path, "/tmp/x.rs");
}
other => panic!("expected FileOp, got: {other:?}"),
}
}
#[test]
fn extract_hook_input_unknown_tool() {
let input = r#"{"tool_name":"FutureTool","tool_input":{"query":"something"}}"#;
match extract_hook_input(input) {
HookInput::UnknownTool {
tool_name,
tool_input,
} => {
assert_eq!(tool_name, "FutureTool");
assert_eq!(
tool_input.get("query").and_then(|v| v.as_str()),
Some("something"),
"tool_input must be carried through verbatim for routing"
);
}
other => panic!("expected UnknownTool, got: {other:?}"),
}
}
#[test]
fn extract_hook_input_unknown_tool_with_command_routes_to_command() {
let input = r#"{"tool_name":"FuturePlanWriter","tool_input":{"command":"ls -la"}}"#;
match extract_hook_input(input) {
HookInput::Command(cmd) => assert_eq!(cmd, "ls -la"),
other => panic!(
"expected Command (structure routing), got: {other:?} — \
PR6 fail-open fix means tool_input.command always routes \
to shell pipeline regardless of tool_name"
),
}
}
#[test]
fn extract_hook_input_unknown_tool_with_cmd_alias_routes_to_command() {
let input = r#"{"tool_name":"FutureExec","tool_input":{"cmd":"echo hi"}}"#;
match extract_hook_input(input) {
HookInput::Command(cmd) => assert_eq!(cmd, "echo hi"),
other => panic!("expected Command via cmd alias, got: {other:?}"),
}
}
#[test]
fn extract_hook_input_unknown_tool_with_path_alias_routes_to_file_op() {
let input = r#"{"tool_name":"FutureEditor","tool_input":{"path":"/tmp/x"}}"#;
match extract_hook_input(input) {
HookInput::FileOp { tool, path } => {
assert_eq!(tool, "FutureEditor");
assert_eq!(path, "/tmp/x");
}
other => panic!("expected FileOp via path alias, got: {other:?}"),
}
}
#[test]
fn extract_hook_input_url_routes_to_unknown_tool_for_read_only() {
let input = r#"{"tool_name":"FutureFetch","tool_input":{"url":"https://example.com"}}"#;
match extract_hook_input(input) {
HookInput::UnknownTool {
tool_name,
tool_input,
} => {
assert_eq!(tool_name, "FutureFetch");
assert_eq!(classify_input_shape(&tool_input), InputShape::ReadOnlyUrl);
}
other => panic!(
"expected UnknownTool carrying url-shape (router decides allow), got: {other:?}"
),
}
}
#[test]
fn extract_hook_input_wrong_type_command_fails_closed() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":42}}"#;
match extract_hook_input(input) {
HookInput::MalformedMissingField => {}
other => panic!(
"expected MalformedMissingField (fail-close on type mismatch), got: {other:?}"
),
}
}
#[test]
fn classify_input_shape_command_priority_over_url() {
let v = serde_json::json!({
"command": "rm -rf /",
"url": "https://example.com",
});
assert_eq!(
classify_input_shape(&v),
InputShape::ShellCommand("rm -rf /")
);
}
#[test]
fn extract_hook_input_mixed_payload_prefers_tool_input() {
let input = r#"{
"command": "echo ok",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf /tmp/x" }
}"#;
match extract_hook_input(input) {
HookInput::Command(cmd) => assert_eq!(
cmd, "rm -rf /tmp/x",
"tool_input.command must take priority over top-level command"
),
other => panic!("expected Command from tool_input, got: {other:?}"),
}
}
#[test]
fn extract_hook_input_mixed_payload_prefers_tool_input_alias() {
let input = r#"{
"command": "echo ok",
"tool_name": "FutureExec",
"tool_input": { "cmd": "/bin/rm -rf /tmp/x" }
}"#;
match extract_hook_input(input) {
HookInput::Command(cmd) => assert_eq!(cmd, "/bin/rm -rf /tmp/x"),
other => panic!("expected Command from tool_input.cmd, got: {other:?}"),
}
}
#[test]
fn extract_hook_input_top_level_command_used_when_tool_input_absent() {
let input = r#"{"command":"ls -la"}"#;
match extract_hook_input(input) {
HookInput::Command(cmd) => assert_eq!(cmd, "ls -la"),
other => panic!("expected legacy top-level Command, got: {other:?}"),
}
}
#[test]
fn extract_hook_input_top_level_command_wins_over_unknown_shape() {
let input = r#"{
"command": "/bin/rm -rf /tmp/x",
"tool_name": "FutureSearch",
"tool_input": { "query": "x" }
}"#;
match extract_hook_input(input) {
HookInput::Command(cmd) => assert_eq!(
cmd, "/bin/rm -rf /tmp/x",
"top-level command must win over tool_input non-shell shape"
),
other => panic!("expected top-level Command (R2 regression guard), got: {other:?}"),
}
}
#[test]
fn extract_hook_input_top_level_command_wins_over_url_shape() {
let input = r#"{
"command": "/bin/rm -rf /tmp/x",
"tool_name": "FutureFetch",
"tool_input": { "url": "https://example.com" }
}"#;
match extract_hook_input(input) {
HookInput::Command(cmd) => assert_eq!(cmd, "/bin/rm -rf /tmp/x"),
other => panic!("expected top-level Command, got: {other:?}"),
}
}
#[test]
fn extract_hook_input_top_level_command_wins_over_file_op_shape() {
let input = r#"{
"command": "/bin/rm -rf /tmp/x",
"tool_name": "FutureEditor",
"tool_input": { "file_path": "/tmp/x" }
}"#;
match extract_hook_input(input) {
HookInput::Command(cmd) => assert_eq!(cmd, "/bin/rm -rf /tmp/x"),
other => panic!("expected top-level Command, got: {other:?}"),
}
}
#[test]
fn extract_hook_input_malformed_json() {
match extract_hook_input("not json at all") {
HookInput::MalformedJson => {}
other => panic!("expected MalformedJson, got: {other:?}"),
}
}
#[test]
fn extract_hook_input_missing_field() {
let input = r#"{"tool_name":"Bash","tool_input":{}}"#;
match extract_hook_input(input) {
HookInput::MalformedMissingField => {}
other => panic!("expected MalformedMissingField, got: {other:?}"),
}
}
#[test]
fn protected_file_path_matches_config_toml() {
let result = is_protected_file_path("/home/user/.config/omamori/config.toml");
assert!(result.is_some(), "config.toml should be protected");
}
#[test]
fn protected_file_path_rejects_unrelated() {
let result = is_protected_file_path("/tmp/myfile.txt");
assert!(result.is_none(), "/tmp/myfile.txt should not be protected");
}
#[test]
fn protected_file_path_all_patterns_match() {
let test_paths = [
"/home/user/.config/omamori/config.toml",
"/home/user/.local/share/omamori/.integrity.json",
"/home/user/.local/share/omamori/audit-secret",
"/home/user/.local/share/omamori/audit.jsonl",
"/home/user/.local/share/omamori",
"/home/user/.local/share/omamori/hooks/claude-pretooluse.sh",
"/home/user/.local/share/omamori/hooks/codex-pretooluse.sh",
"/home/user/.codex/hooks.json",
"/home/user/.codex/config.toml",
"/home/user/.claude/settings.json",
];
for path in &test_paths {
assert!(
is_protected_file_path(path).is_some(),
"PROTECTED_FILE_PATTERNS gap: {path} was not matched"
);
}
}
#[test]
#[serial_test::serial]
fn check_command_for_hook_blocks_meta_pattern() {
let (old_xdg, old_home, dir) = isolate_config();
match check_command_for_hook("unset CLAUDECODE") {
HookCheckResult::BlockMeta(_) => {}
HookCheckResult::BlockRule { .. } | HookCheckResult::BlockStructural { .. } => {}
HookCheckResult::Allow => {
restore_config(old_xdg, old_home, dir);
panic!("SECURITY: 'unset CLAUDECODE' was ALLOWED — meta-pattern is broken");
}
}
restore_config(old_xdg, old_home, dir);
}
#[test]
#[serial_test::serial]
fn check_command_for_hook_allows_echo() {
let (old_xdg, old_home, dir) = isolate_config();
match check_command_for_hook("echo hello world") {
HookCheckResult::Allow => {}
_ => {
restore_config(old_xdg, old_home, dir);
panic!("'echo hello world' should be allowed");
}
}
restore_config(old_xdg, old_home, dir);
}
fn assert_blocks_meta(command: &str) {
let (old_xdg, old_home, dir) = isolate_config();
match check_command_for_hook(command) {
HookCheckResult::BlockMeta(_) => {}
HookCheckResult::Allow => {
restore_config(old_xdg, old_home, dir);
panic!("SECURITY: {command:?} was ALLOWED — should be BlockMeta");
}
other => {
let desc = match other {
HookCheckResult::BlockRule { rule_name, .. } => {
format!("BlockRule({rule_name})")
}
HookCheckResult::BlockStructural { message: r, .. } => {
format!("BlockStructural({r})")
}
_ => unreachable!(),
};
restore_config(old_xdg, old_home, dir);
panic!("{command:?} blocked by {desc}, expected BlockMeta (Phase 1B)");
}
}
restore_config(old_xdg, old_home, dir);
}
fn assert_allows(command: &str) {
let (old_xdg, old_home, dir) = isolate_config();
match check_command_for_hook(command) {
HookCheckResult::Allow => {}
other => {
let desc = match other {
HookCheckResult::BlockMeta(r) => format!("BlockMeta({r})"),
HookCheckResult::BlockRule { rule_name, .. } => {
format!("BlockRule({rule_name})")
}
HookCheckResult::BlockStructural { message: r, .. } => {
format!("BlockStructural({r})")
}
HookCheckResult::Allow => unreachable!(),
};
restore_config(old_xdg, old_home, dir);
panic!("expected Allow for {command:?}, got: {desc}");
}
}
restore_config(old_xdg, old_home, dir);
}
#[test]
#[serial_test::serial]
fn phase1b_unset_double_space() {
assert_blocks_meta("unset CLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_unset_tab() {
assert_blocks_meta("unset\tCLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_var_assignment_empty() {
assert_blocks_meta("CLAUDECODE=");
}
#[test]
#[serial_test::serial]
fn phase1b_var_assignment_value() {
assert_blocks_meta("CLAUDECODE=fake");
}
#[test]
#[serial_test::serial]
fn phase1b_semicolon_adjacent_unset() {
assert_blocks_meta("echo ok;unset CLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_and_adjacent_export() {
assert_blocks_meta("cmd&&export -nCLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_newline_adjacent_env_u() {
assert_blocks_meta("echo ok\nenv -u CLAUDECODE bash");
}
#[test]
#[serial_test::serial]
fn phase1b_after_semicolon() {
assert_blocks_meta("echo ok ; unset CLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_after_pipe() {
assert_blocks_meta("cat /dev/null | unset CLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_env_u_extra_spaces() {
assert_blocks_meta("env -u CLAUDECODE bash");
}
#[test]
#[serial_test::serial]
fn phase1b_env_u_tabs() {
assert_blocks_meta("env\t-u\tCLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_env_u_combined() {
assert_blocks_meta("env -uCLAUDECODE bash");
}
#[test]
#[serial_test::serial]
fn phase1b_export_n_extra_space() {
assert_blocks_meta("export -n CLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_export_n_combined() {
assert_blocks_meta("export -nCLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_assignment_prefix_unset() {
assert_blocks_meta("FOO=1 unset CLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_assignment_prefix_env_u() {
assert_blocks_meta("BAR=x env -uCLAUDECODE bash");
}
#[test]
#[serial_test::serial]
fn phase1b_multi_assignment_export() {
assert_blocks_meta("X=1 Y=2 export -n CLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_benign_printf_unset_args() {
assert_allows("printf '%s %s' unset CLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_benign_echo_unset() {
assert_allows("echo unset CLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_benign_echo_env_u() {
assert_allows("echo env -u CLAUDECODE");
}
#[test]
#[serial_test::serial]
fn phase1b_benign_printf_var_assignment() {
assert_allows("printf %s CLAUDECODE=test");
}
#[test]
#[serial_test::serial]
fn phase1b_benign_printf_unset_quoted() {
assert_allows("printf 'unset CLAUDECODE'");
}
#[test]
#[serial_test::serial]
fn phase1b_benign_echo_env_u_quoted() {
assert_allows("echo \"env -u CLAUDECODE\"");
}
#[test]
#[serial_test::serial]
fn phase1b_benign_echo_newline_in_quotes() {
assert_allows("echo 'line1\nline2'");
}
#[test]
#[serial_test::serial]
fn phase1b_benign_env_assignment_in_string() {
assert_allows("echo 'CLAUDECODE=test'");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_inline_rm() {
assert_blocks_meta("PATH=/usr/bin:$PATH rm dummy.txt");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_inline_git() {
assert_blocks_meta("PATH=/usr/bin git status");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_inline_chmod() {
assert_blocks_meta("PATH=/opt/bin chmod 755 file");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_inline_find() {
assert_blocks_meta("PATH=/usr/bin find . -name foo");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_inline_rsync() {
assert_blocks_meta("PATH=/usr/bin rsync -a src/ dst/");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_empty_value_rm() {
assert_blocks_meta("PATH= rm file");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_env_rm() {
assert_blocks_meta("env PATH=/usr/bin rm file");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_env_i_rm() {
assert_blocks_meta("env -i PATH=/usr/bin rm file");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_env_u_home_path_rm() {
assert_blocks_meta("env -uHOME PATH=/usr/bin rm file");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_env_dashdash_rm() {
assert_blocks_meta("env -- PATH=/usr/bin rm file");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_usr_bin_env_rm() {
assert_blocks_meta("/usr/bin/env PATH=/usr/bin rm file");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_env_git() {
assert_blocks_meta("env PATH=/opt/git/bin git push");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_compound_tail() {
assert_blocks_meta("echo ok; PATH=/usr/bin rm file");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_non_shim_node() {
assert_allows("PATH=/custom/dir node script.js");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_non_shim_python() {
assert_allows("PATH=/opt/python/bin python -c 'print(1)'");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_export_path() {
assert_allows("export PATH=/usr/local/bin:$PATH");
}
#[test]
#[serial_test::serial]
fn phase1b_path_override_env_non_shim() {
assert_allows("env PATH=/custom/dir node script.js");
}
}