use std::ffi::OsString;
use crate::AppError;
use crate::config::{self, ConfigLoadResult, load_config};
use crate::context;
use crate::engine::guard::guard_ai_config_modification;
use crate::engine::hook::{HookCheckResult, check_command_for_hook};
use crate::rules::{CommandInvocation, match_rule};
use crate::util::{parse_config_flag, usage_text};
pub(crate) fn run_explain_command(args: &[OsString]) -> Result<i32, AppError> {
guard_ai_config_modification("explain")?;
let mut json = false;
let mut config_args: Vec<OsString> = Vec::new();
let mut command_parts: Vec<String> = Vec::new();
let mut saw_separator = false;
let mut index = 2usize;
while index < args.len() {
let arg = args[index].to_str().unwrap_or("");
if saw_separator {
command_parts.push(arg.to_string());
index += 1;
continue;
}
match arg {
"--" => {
saw_separator = true;
index += 1;
}
"--json" => {
json = true;
index += 1;
}
"--config" => {
config_args.push(args[index].clone());
if let Some(val) = args.get(index + 1) {
config_args.push(val.clone());
}
index += 2;
}
_ => {
return Err(AppError::Usage(format!(
"unknown explain flag: {arg}\n\nUsage: omamori explain [--json] [--config PATH] -- <command...>\n\n{}",
usage_text()
)));
}
}
}
if command_parts.is_empty() {
return Err(AppError::Usage(format!(
"explain requires a command after `--`\n\nUsage: omamori explain [--json] [--config PATH] -- <command...>\n\n{}",
usage_text()
)));
}
let config_path = parse_config_flag(&config_args)?;
let command_str = shell_words::join(&command_parts);
let layer1 = evaluate_layer1(&command_parts, config_path.as_deref());
let layer2 = evaluate_layer2(&command_str);
let blocked = layer1.blocked || layer2.blocked;
let exit_code = if blocked { 2 } else { 0 };
if json {
print_json(&command_str, &layer1, &layer2, blocked);
} else {
print_report(&command_str, &layer1, &layer2, blocked);
}
Ok(exit_code)
}
struct Layer1Result {
blocked: bool,
matched_rule: Option<String>,
action: String,
context_override: Option<String>,
detail: String,
}
struct Layer2Result {
blocked: bool,
phase: String,
detail: String,
}
fn evaluate_layer1(
command_parts: &[String],
config_path: Option<&std::path::Path>,
) -> Layer1Result {
let load_result = match load_config(config_path) {
Ok(r) => r,
Err(e) => {
if config_path.is_some() {
return Layer1Result {
blocked: false,
matched_rule: None,
action: "error".to_string(),
context_override: None,
detail: format!("config load failed: {e} (using defaults)"),
};
}
ConfigLoadResult {
config: config::Config::default(),
warnings: vec![],
}
}
};
if command_parts.is_empty() {
return Layer1Result {
blocked: false,
matched_rule: None,
action: "allow".to_string(),
context_override: None,
detail: "no command provided".to_string(),
};
}
let program = &command_parts[0];
let args: Vec<String> = command_parts[1..].to_vec();
let invocation = CommandInvocation::new(program.clone(), args);
let matched = match_rule(&load_result.config.rules, &invocation);
let Some(rule) = matched else {
return Layer1Result {
blocked: false,
matched_rule: None,
action: "allow".to_string(),
context_override: None,
detail: "no rule matched — command would be passed through".to_string(),
};
};
let context_override = if let Some(ctx_config) = &load_result.config.context {
let ctx = context::evaluate_context(&invocation, rule, ctx_config);
ctx.action_override
.map(|action| format!("{} ({})", action.as_str(), ctx.reason))
} else {
None
};
let effective_action = if let Some(ref ctx_str) = context_override {
ctx_str.split(' ').next().unwrap_or("block")
} else {
rule.action.as_str()
};
let blocked = effective_action == "block";
let detail = rule
.message
.clone()
.unwrap_or_else(|| format!("matched rule: {}", rule.name));
Layer1Result {
blocked,
matched_rule: Some(rule.name.clone()),
action: effective_action.to_string(),
context_override,
detail,
}
}
fn evaluate_layer2(command_str: &str) -> Layer2Result {
match check_command_for_hook(command_str) {
HookCheckResult::Allow => Layer2Result {
blocked: false,
phase: "allow".to_string(),
detail: "no meta-pattern or rule match".to_string(),
},
HookCheckResult::BlockMeta(reason) => Layer2Result {
blocked: true,
phase: "meta-pattern".to_string(),
detail: reason.to_string(),
},
HookCheckResult::BlockRule {
rule_name,
message,
unwrap_chain,
} => {
let detail = if let Some(chain) = unwrap_chain {
format!("{message} ({chain})")
} else {
message
};
Layer2Result {
blocked: true,
phase: format!("rule: {rule_name}"),
detail,
}
}
HookCheckResult::BlockStructural(message) => Layer2Result {
blocked: true,
phase: "structural".to_string(),
detail: message,
},
}
}
fn print_report(command_str: &str, layer1: &Layer1Result, layer2: &Layer2Result, blocked: bool) {
let verdict = if blocked { "BLOCK" } else { "ALLOW" };
println!("omamori explain: {command_str}\n");
println!(" Verdict: {verdict}\n");
println!(" Layer 1 (PATH shim):");
if let Some(ref rule) = layer1.matched_rule {
println!(" rule: {rule}");
println!(" action: {}", layer1.action);
if let Some(ref ctx) = layer1.context_override {
println!(" context override: {ctx}");
}
println!(" detail: {}", layer1.detail);
} else {
println!(" {}", layer1.detail);
}
println!();
println!(" Layer 2 (hooks):");
if layer2.blocked {
println!(" phase: {}", layer2.phase);
println!(" detail: {}", layer2.detail);
} else {
println!(" {}", layer2.detail);
}
println!(" note: Layer 2 applies rule action directly (no context override)");
println!();
if blocked {
println!(" Guidance: run this command directly in your terminal (not via AI)");
} else {
println!(" Guidance: this command would be allowed through omamori");
}
}
fn print_json(command_str: &str, layer1: &Layer1Result, layer2: &Layer2Result, blocked: bool) {
let output = serde_json::json!({
"command": command_str,
"verdict": if blocked { "block" } else { "allow" },
"layer1": {
"blocked": layer1.blocked,
"matched_rule": layer1.matched_rule,
"action": layer1.action,
"context_override": layer1.context_override,
"detail": layer1.detail,
},
"layer2": {
"blocked": layer2.blocked,
"phase": layer2.phase,
"detail": layer2.detail,
},
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layer1_no_rule_match_is_allow() {
let parts = vec!["ls".to_string(), "/tmp".to_string()];
let result = evaluate_layer1(&parts, None);
assert!(!result.blocked);
assert!(result.matched_rule.is_none());
assert_eq!(result.action, "allow");
}
#[test]
fn layer1_rm_recursive_matches_rule() {
let parts = vec!["rm".to_string(), "-rf".to_string(), "/tmp/test".to_string()];
let result = evaluate_layer1(&parts, None);
assert!(result.matched_rule.is_some());
assert!(result.matched_rule.unwrap().contains("rm"));
}
#[test]
fn layer2_safe_command_is_allow() {
let result = evaluate_layer2("ls /tmp");
assert!(!result.blocked);
assert_eq!(result.phase, "allow");
}
#[test]
fn layer2_meta_pattern_blocks() {
let result = evaluate_layer2("unset CLAUDECODE");
assert!(result.blocked);
assert_eq!(result.phase, "meta-pattern");
}
#[test]
fn layer2_blocked_command_pattern_explain() {
let result = evaluate_layer2("omamori explain -- rm -rf /");
assert!(result.blocked);
}
#[test]
fn layer2_blocked_command_pattern_doctor_fix() {
let result = evaluate_layer2("omamori doctor --fix");
assert!(result.blocked);
}
#[test]
fn verdict_blocked_if_either_layer_blocks() {
let l1 = Layer1Result {
blocked: false,
matched_rule: None,
action: "allow".to_string(),
context_override: None,
detail: "no match".to_string(),
};
let l2 = Layer2Result {
blocked: true,
phase: "meta-pattern".to_string(),
detail: "blocked".to_string(),
};
assert!(l1.blocked || l2.blocked);
let l1b = Layer1Result {
blocked: true,
matched_rule: Some("rm-recursive".to_string()),
action: "block".to_string(),
context_override: None,
detail: "blocked".to_string(),
};
let l2b = Layer2Result {
blocked: false,
phase: "allow".to_string(),
detail: "ok".to_string(),
};
assert!(l1b.blocked || l2b.blocked);
}
#[test]
fn json_output_is_valid() {
let l1 = Layer1Result {
blocked: false,
matched_rule: None,
action: "allow".to_string(),
context_override: None,
detail: "no match".to_string(),
};
let l2 = Layer2Result {
blocked: false,
phase: "allow".to_string(),
detail: "ok".to_string(),
};
print_json("ls /tmp", &l1, &l2, false);
}
}