use anyhow::Result;
use clap::{Args, ValueEnum};
use std::collections::HashMap;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::cli::daemon::{daemon_result, mati_root_for, DaemonResult};
use mati_core::hooks::decide::{self, Decision, EnforcementInput, HookEvent};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HookVariant {
ClaudePreRead,
ClaudePreBash,
CodexPreBash,
CodexPostBash,
CodexPreApplyPatch,
}
#[derive(Args, Debug)]
pub struct HookDecideArgs {
#[arg(value_enum)]
pub variant: HookVariant,
}
const HOOK_DEADLINE_MS: u64 = 2500;
pub async fn run(args: HookDecideArgs) -> Result<()> {
let variant = args.variant;
match tokio::time::timeout(Duration::from_millis(HOOK_DEADLINE_MS), run_inner(args)).await {
Ok(inner_result) => inner_result,
Err(_elapsed) => {
log_fail_open("<unknown>", "hook process exceeded internal deadline");
emit_allow(variant);
Ok(())
}
}
}
async fn run_inner(args: HookDecideArgs) -> Result<()> {
let mut input_str = String::new();
std::io::stdin().read_to_string(&mut input_str)?;
let input: serde_json::Value =
serde_json::from_str(&input_str).unwrap_or(serde_json::Value::Null);
if args.variant == HookVariant::CodexPreApplyPatch {
return run_apply_patch(&input).await;
}
let raw_path = match extract_path(&input, args.variant) {
Some(p) => p,
None => {
emit_allow(args.variant);
return Ok(());
}
};
let cwd = std::env::current_dir()?;
let repo_root = discover_repo_root(&cwd);
let repo_root_str = repo_root.as_ref().and_then(|p| p.to_str());
let rel_path = decide::normalize_path(&raw_path, repo_root_str);
let root_for_slug = repo_root.as_deref().unwrap_or(&cwd);
let mati_root = match mati_root_for(root_for_slug) {
Ok(r) => r,
Err(_) => {
log_fail_open(&rel_path, "cannot determine mati root");
emit_allow(args.variant);
return Ok(());
}
};
if !ensure_daemon(&mati_root).await {
log_fail_open(&rel_path, "daemon not running after auto-start");
emit_allow(args.variant);
return Ok(());
}
if args.variant == HookVariant::CodexPostBash {
return run_post_bash(&mati_root, &rel_path).await;
}
let file_key = format!("file:{rel_path}");
let include_recent = matches!(args.variant, HookVariant::CodexPreBash);
let eval_data = match daemon_result(
&mati_root,
"hook_evaluate",
serde_json::json!({
"file_key": &file_key,
"include_recent": include_recent,
}),
)
.await
{
DaemonResult::Ok(resp) => resp.get("data").cloned().unwrap_or(serde_json::Value::Null),
_ => {
log_fail_open(&rel_path, "hook_evaluate failed");
emit_allow(args.variant);
return Ok(());
}
};
let adapter = process_eval_response(args.variant, &rel_path, &eval_data);
fire_events(&mati_root, &adapter.events).await;
if let EvalDataCheck::FailOpen(reason) = check_eval_data(args.variant, &rel_path, &eval_data) {
log_fail_open(&rel_path, &reason);
}
if !adapter.stdout.is_empty() {
println!("{}", adapter.stdout);
}
if !adapter.stderr.is_empty() {
eprintln!("{}", adapter.stderr);
}
if adapter.exit_code != 0 {
let _ = std::io::Write::flush(&mut std::io::stderr());
std::process::exit(adapter.exit_code);
}
Ok(())
}
fn extract_path(input: &serde_json::Value, variant: HookVariant) -> Option<String> {
match variant {
HookVariant::ClaudePreRead => {
input
.pointer("/tool_input/file_path")
.or_else(|| input.pointer("/tool_input/path"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
HookVariant::ClaudePreBash | HookVariant::CodexPreBash | HookVariant::CodexPostBash => {
let cmd = input
.pointer("/tool_input/command")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())?;
let class = decide::classify_command(cmd)?;
decide::extract_file_path(cmd, class)
}
HookVariant::CodexPreApplyPatch => None,
}
}
fn discover_repo_root(cwd: &Path) -> Option<PathBuf> {
git2::Repository::discover(cwd).ok().and_then(|repo| {
repo.workdir().map(|p| {
let s = p.to_string_lossy();
let trimmed = s.trim_end_matches('/');
PathBuf::from(trimmed)
})
})
}
async fn ensure_daemon(mati_root: &Path) -> bool {
mati_core::mcp::daemon_lifecycle::ensure_daemon(mati_root).await
}
async fn run_post_bash(mati_root: &Path, rel_path: &str) -> Result<()> {
let file_key = format!("file:{rel_path}");
let consulted = match daemon_result(
mati_root,
"session_check_consulted_recent",
serde_json::json!({ "key": &file_key, "ttl_secs": 900 }),
)
.await
{
DaemonResult::Ok(resp) => resp.get("data").and_then(|v| v.as_bool()).unwrap_or(false),
_ => false,
};
let event = if consulted {
mati_core::mcp::protocol::SessionEvent::ComplianceHit
} else {
mati_core::mcp::protocol::SessionEvent::CodexShellMiss
};
let cmd =
mati_core::mcp::protocol::Command::SessionLog(mati_core::mcp::protocol::SessionLogInput {
event,
key: file_key.clone(),
});
let _ = super::daemon::daemon_v2(mati_root, cmd).await;
Ok(())
}
async fn run_apply_patch(input: &serde_json::Value) -> Result<()> {
let variant = HookVariant::CodexPreApplyPatch;
let Some(cmd) = input
.pointer("/tool_input/command")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
else {
emit_allow(variant);
return Ok(());
};
let mut raw_paths = decide::extract_apply_patch_files(cmd);
if raw_paths.is_empty() {
emit_allow(variant);
return Ok(());
}
if raw_paths.len() > decide::MAX_APPLY_PATCH_FILES {
log_fail_open(
"<apply_patch>",
&format!(
"patch touches {} files; gating only the first {}",
raw_paths.len(),
decide::MAX_APPLY_PATCH_FILES
),
);
raw_paths.truncate(decide::MAX_APPLY_PATCH_FILES);
}
let cwd = std::env::current_dir()?;
let repo_root = discover_repo_root(&cwd);
let repo_root_str = repo_root.as_ref().and_then(|p| p.to_str());
let root_for_slug = repo_root.as_deref().unwrap_or(&cwd);
let mati_root = match mati_root_for(root_for_slug) {
Ok(r) => r,
Err(_) => {
log_fail_open("<apply_patch>", "cannot determine mati root");
emit_allow(variant);
return Ok(());
}
};
if !ensure_daemon(&mati_root).await {
log_fail_open("<apply_patch>", "daemon not running after auto-start");
emit_allow(variant);
return Ok(());
}
let mut denied: Vec<String> = Vec::new();
let mut events: Vec<HookEvent> = Vec::new();
for raw in &raw_paths {
let rel_path = decide::normalize_path(raw, repo_root_str);
let file_key = format!("file:{rel_path}");
let eval_data = match daemon_result(
&mati_root,
"hook_evaluate",
serde_json::json!({ "file_key": &file_key, "include_recent": true }),
)
.await
{
DaemonResult::Ok(resp) => resp.get("data").cloned().unwrap_or(serde_json::Value::Null),
_ => {
log_fail_open(&rel_path, "hook_evaluate failed");
continue;
}
};
let adapter = process_eval_response(variant, &rel_path, &eval_data);
if matches!(adapter.decision, Decision::Deny { .. }) {
denied.push(file_key);
events.extend(adapter.events);
}
}
fire_events(&mati_root, &events).await;
if denied.is_empty() {
emit_allow(variant);
return Ok(());
}
let msg = if denied.len() == 1 {
format!("mati: call mem_get(\"{}\") before editing", denied[0])
} else {
format!(
"mati: consult these files before editing — call mem_get for each: {}",
denied.join(", ")
)
};
eprintln!("{msg}");
let _ = std::io::Write::flush(&mut std::io::stderr());
std::process::exit(2);
}
fn log_fail_open(rel_path: &str, reason: &str) {
eprintln!("[mati] WARNING: enforcement bypassed for {rel_path} — {reason}");
if let Some(home) = dirs::home_dir() {
let log_dir = home.join(".mati");
let _ = std::fs::create_dir_all(&log_dir);
let log_path = log_dir.join("fail_open.log");
log_fail_open_at(&log_path, rel_path, reason);
}
}
pub(super) fn log_fail_open_at(log_path: &Path, rel_path: &str, reason: &str) {
let now = iso_utc_now();
let entry = format!("{now} FAIL_OPEN hook=hook-decide file={rel_path} reason={reason}\n");
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
.and_then(|mut f| std::io::Write::write_all(&mut f, entry.as_bytes()));
}
fn iso_utc_now() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
fn platform_events(
variant: HookVariant,
decision: &Decision,
events: Vec<HookEvent>,
) -> Vec<HookEvent> {
match variant {
HookVariant::CodexPreBash | HookVariant::CodexPreApplyPatch => events
.into_iter()
.filter_map(|e| match e {
HookEvent::Miss { .. } => Some(e),
HookEvent::BlockedUnconsultedRead { key } => {
Some(HookEvent::CodexShellBlocked { key })
}
HookEvent::Hit { .. } => {
match decision {
Decision::Advisory { .. } | Decision::Liability { .. } => None,
_ => Some(e),
}
}
HookEvent::ComplianceHit { .. } => {
None
}
_ => Some(e),
})
.collect(),
HookVariant::CodexPostBash => {
events
}
HookVariant::ClaudePreRead | HookVariant::ClaudePreBash => {
events
}
}
}
async fn fire_events(mati_root: &Path, events: &[HookEvent]) {
use mati_core::mcp::protocol as p;
for event in events {
let cmd = match event {
HookEvent::Hit { key } => {
p::Command::ConsultationHit(p::ConsultationHitInput { key: key.clone() })
}
HookEvent::Miss { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::Miss,
key: key.clone(),
}),
HookEvent::BlockedUnconsultedRead { key } => {
p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::ComplianceMiss,
key: key.clone(),
})
}
HookEvent::CodexShellBlocked { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::CodexShellMiss,
key: key.clone(),
}),
HookEvent::ComplianceHit { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::ComplianceHit,
key: key.clone(),
}),
};
let _ = super::daemon::daemon_v2(mati_root, cmd).await;
}
}
fn emit_allow(variant: HookVariant) {
match variant {
HookVariant::ClaudePreRead | HookVariant::ClaudePreBash => {
println!(
r#"{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow"}}}}"#
);
}
HookVariant::CodexPreBash
| HookVariant::CodexPostBash
| HookVariant::CodexPreApplyPatch => {
}
}
}
fn extract_gotcha_map(eval_data: &serde_json::Value) -> HashMap<String, serde_json::Value> {
eval_data
.get("gotcha_records")
.and_then(|v| v.as_object())
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default()
}
fn escape_json_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
#[derive(Debug)]
struct AdapterResult {
stdout: String,
stderr: String,
exit_code: i32,
events: Vec<HookEvent>,
#[allow(dead_code)]
decision: Decision,
}
enum EvalDataCheck {
Ok(EnforcementInput),
FailOpen(String),
}
fn check_eval_data(
variant: HookVariant,
rel_path: &str,
eval_data: &serde_json::Value,
) -> EvalDataCheck {
let include_recent = matches!(
variant,
HookVariant::CodexPreBash | HookVariant::CodexPostBash | HookVariant::CodexPreApplyPatch
);
let already_consulted = if include_recent {
eval_data
.get("consulted_recent")
.and_then(|v| v.as_bool())
.unwrap_or(false)
} else {
eval_data
.get("consulted")
.and_then(|v| v.as_bool())
.unwrap_or(false)
};
let input = EnforcementInput {
rel_path: rel_path.to_string(),
file_record: eval_data
.get("file_record")
.cloned()
.filter(|v| !v.is_null()),
gotcha_records: extract_gotcha_map(eval_data),
already_consulted,
};
let store_error = eval_data
.get("store_error")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if store_error && input.file_record.is_none() {
return EvalDataCheck::FailOpen("store error during hook_evaluate".into());
}
let gotcha_error = eval_data
.get("gotcha_error")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if gotcha_error {
return EvalDataCheck::FailOpen("gotcha fetch error during hook_evaluate".into());
}
EvalDataCheck::Ok(input)
}
fn process_eval_response(
variant: HookVariant,
rel_path: &str,
eval_data: &serde_json::Value,
) -> AdapterResult {
let enforcement_input = match check_eval_data(variant, rel_path, eval_data) {
EvalDataCheck::Ok(input) => input,
EvalDataCheck::FailOpen(_reason) => {
let stdout = match variant {
HookVariant::ClaudePreRead | HookVariant::ClaudePreBash => {
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}"#.to_string()
}
_ => String::new(),
};
return AdapterResult {
stdout,
stderr: String::new(),
exit_code: 0,
events: vec![],
decision: Decision::Allow,
};
}
};
let result = decide::evaluate(&enforcement_input);
let events = platform_events(variant, &result.decision, result.events);
let (stdout, stderr, exit_code) = format_decision(variant, &result.decision, rel_path);
AdapterResult {
stdout,
stderr,
exit_code,
events,
decision: result.decision,
}
}
fn format_decision(
variant: HookVariant,
decision: &Decision,
_rel_path: &str,
) -> (String, String, i32) {
match variant {
HookVariant::ClaudePreRead | HookVariant::ClaudePreBash => {
let stdout = format_claude_output(decision);
(stdout, String::new(), 0)
}
HookVariant::CodexPreBash | HookVariant::CodexPreApplyPatch => match decision {
Decision::Deny { file_key, .. } => {
let stderr = format!("mati: call mem_get(\"{file_key}\") first");
(String::new(), stderr, 2)
}
_ => (String::new(), String::new(), 0),
},
HookVariant::CodexPostBash => (String::new(), String::new(), 0),
}
}
fn format_claude_output(decision: &Decision) -> String {
match decision {
Decision::Deny { reason, .. } => {
let escaped = escape_json_string(reason);
format!(
r#"{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"{escaped}"}}}}"#
)
}
Decision::AlreadyConsulted { context } => {
let escaped =
escape_json_string(&format!("[mati] Record already consulted. {context}"));
format!(
r#"{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","additionalContext":"{escaped}"}}}}"#
)
}
Decision::Advisory { context } => {
let escaped = escape_json_string(&format!("[mati] {context}"));
format!(
r#"{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","additionalContext":"{escaped}"}}}}"#
)
}
Decision::Liability { context, .. } => {
let escaped = escape_json_string(&format!("[mati] {context}"));
format!(
r#"{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","additionalContext":"{escaped}"}}}}"#
)
}
_ => {
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}"#
.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extract_path_claude_pre_read_file_path() {
let input = json!({"tool_input": {"file_path": "/home/user/project/src/main.rs"}});
assert_eq!(
extract_path(&input, HookVariant::ClaudePreRead),
Some("/home/user/project/src/main.rs".into())
);
}
#[test]
fn extract_path_claude_pre_read_path_fallback() {
let input = json!({"tool_input": {"path": "src/main.rs"}});
assert_eq!(
extract_path(&input, HookVariant::ClaudePreRead),
Some("src/main.rs".into())
);
}
#[test]
fn extract_path_claude_pre_read_empty() {
let input = json!({"tool_input": {"file_path": ""}});
assert_eq!(extract_path(&input, HookVariant::ClaudePreRead), None);
}
#[test]
fn extract_path_codex_pre_bash_cat() {
let input = json!({"tool_input": {"command": "cat src/main.rs"}});
assert_eq!(
extract_path(&input, HookVariant::CodexPreBash),
Some("src/main.rs".into())
);
}
#[test]
fn extract_path_codex_pre_bash_non_file_command() {
let input = json!({"tool_input": {"command": "ls -la"}});
assert_eq!(extract_path(&input, HookVariant::CodexPreBash), None);
}
#[test]
fn extract_path_codex_pre_bash_empty_command() {
let input = json!({"tool_input": {"command": ""}});
assert_eq!(extract_path(&input, HookVariant::CodexPreBash), None);
}
#[test]
fn extract_path_codex_fixture_tool_input_command() {
let input = json!({"tool_input": {"command": "cat src/main.rs"}});
assert_eq!(
extract_path(&input, HookVariant::CodexPreBash),
Some("src/main.rs".into())
);
}
#[test]
fn codex_deny_translates_to_shell_blocked() {
let events = vec![HookEvent::BlockedUnconsultedRead {
key: "file:src/main.rs".into(),
}];
let decision = Decision::Deny {
file_key: "file:src/main.rs".into(),
reason: "test".into(),
};
let result = platform_events(HookVariant::CodexPreBash, &decision, events);
assert_eq!(result.len(), 1);
assert!(matches!(
&result[0],
HookEvent::CodexShellBlocked { key } if key == "file:src/main.rs"
));
}
#[test]
fn codex_advisory_suppresses_hit() {
let events = vec![HookEvent::Hit {
key: "file:src/main.rs".into(),
}];
let decision = Decision::Advisory {
context: "test".into(),
};
let result = platform_events(HookVariant::CodexPreBash, &decision, events);
assert!(
result.is_empty(),
"Codex should not mint receipts for silent outcomes"
);
}
#[test]
fn codex_liability_suppresses_hit() {
let events = vec![HookEvent::Hit {
key: "file:src/main.rs".into(),
}];
let decision = Decision::Liability {
staleness: 0.85,
context: "test".into(),
};
let result = platform_events(HookVariant::CodexPreBash, &decision, events);
assert!(result.is_empty());
}
#[test]
fn codex_already_consulted_suppresses_hit() {
let events = vec![HookEvent::ComplianceHit {
key: "file:src/main.rs".into(),
}];
let decision = Decision::AlreadyConsulted {
context: "test".into(),
};
let result = platform_events(HookVariant::CodexPreBash, &decision, events);
assert!(result.is_empty());
}
#[test]
fn codex_no_record_keeps_miss() {
let events = vec![HookEvent::Miss {
key: "file:src/main.rs".into(),
}];
let decision = Decision::NoRecord;
let result = platform_events(HookVariant::CodexPreBash, &decision, events);
assert_eq!(result.len(), 1);
assert!(matches!(&result[0], HookEvent::Miss { .. }));
}
#[test]
fn claude_keeps_all_events() {
let events = vec![HookEvent::Hit {
key: "file:src/main.rs".into(),
}];
let decision = Decision::Advisory {
context: "test".into(),
};
let result = platform_events(HookVariant::ClaudePreRead, &decision, events);
assert_eq!(
result.len(),
1,
"Claude should keep Hit for advisory outcomes"
);
}
#[test]
fn claude_deny_keeps_blocked_event() {
let events = vec![HookEvent::BlockedUnconsultedRead {
key: "file:src/main.rs".into(),
}];
let decision = Decision::Deny {
file_key: "file:src/main.rs".into(),
reason: "test".into(),
};
let result = platform_events(HookVariant::ClaudePreBash, &decision, events);
assert_eq!(result.len(), 1);
assert!(matches!(
&result[0],
HookEvent::BlockedUnconsultedRead { .. }
));
}
fn deny_eligible_eval_data() -> serde_json::Value {
json!({
"file_key": "file:src/main.rs",
"file_record": {
"value": "Entry point",
"confidence": { "value": 0.7 },
"quality": { "value": 0.5 },
"staleness": { "value": 0.1, "tier": "fresh" },
"payload": { "gotcha_keys": ["gotcha:test-rule"] }
},
"gotcha_records": {
"gotcha:test-rule": {
"value": "Never call unwrap in this file",
"confidence": { "value": 0.8 },
"quality": { "value": 0.6 },
"payload": { "confirmed": true }
}
},
"consulted": false,
"consulted_recent": false,
"store_error": false,
"gotcha_error": false
})
}
#[test]
fn e2e_codex_deny_exit2_stderr_and_shell_blocked_event() {
let data = deny_eligible_eval_data();
let result = process_eval_response(HookVariant::CodexPreBash, "src/main.rs", &data);
assert_eq!(result.exit_code, 2, "Codex deny must exit 2");
assert!(
result.stderr.contains("mem_get"),
"stderr must instruct agent to call mem_get, got: {}",
result.stderr
);
assert!(result.stdout.is_empty(), "Codex deny should have no stdout");
assert_eq!(result.events.len(), 1);
assert!(
matches!(&result.events[0], HookEvent::CodexShellBlocked { key } if key == "file:src/main.rs"),
"Codex deny must emit CodexShellBlocked, got: {:?}",
result.events
);
assert!(matches!(result.decision, Decision::Deny { .. }));
}
#[test]
fn e2e_codex_apply_patch_deny_exit2_when_unconsulted() {
let data = deny_eligible_eval_data();
let result = process_eval_response(HookVariant::CodexPreApplyPatch, "src/main.rs", &data);
assert_eq!(result.exit_code, 2, "apply_patch deny must exit 2");
assert!(matches!(result.decision, Decision::Deny { .. }));
assert_eq!(result.events.len(), 1);
assert!(
matches!(&result.events[0], HookEvent::CodexShellBlocked { key } if key == "file:src/main.rs"),
"apply_patch deny must emit CodexShellBlocked, got: {:?}",
result.events
);
}
#[test]
fn e2e_codex_apply_patch_allows_after_consult() {
let mut data = deny_eligible_eval_data();
data["consulted_recent"] = json!(true);
let result = process_eval_response(HookVariant::CodexPreApplyPatch, "src/main.rs", &data);
assert_eq!(result.exit_code, 0, "consulted edit must be allowed");
assert!(!matches!(result.decision, Decision::Deny { .. }));
}
#[test]
fn e2e_claude_deny_json_output_and_blocked_event() {
let data = deny_eligible_eval_data();
let result = process_eval_response(HookVariant::ClaudePreBash, "src/main.rs", &data);
assert_eq!(result.exit_code, 0, "Claude always exits 0");
let json: serde_json::Value =
serde_json::from_str(&result.stdout).expect("stdout must be valid JSON");
assert_eq!(
json.pointer("/hookSpecificOutput/permissionDecision")
.and_then(|v| v.as_str()),
Some("deny")
);
assert!(
json.pointer("/hookSpecificOutput/permissionDecisionReason")
.and_then(|v| v.as_str())
.unwrap_or("")
.contains("mem_get"),
"deny reason must mention mem_get"
);
assert_eq!(result.events.len(), 1);
assert!(matches!(
&result.events[0],
HookEvent::BlockedUnconsultedRead { .. }
));
}
#[test]
fn e2e_codex_advisory_silent_no_hit() {
let data = json!({
"file_key": "file:src/lib.rs",
"file_record": {
"value": "Library root",
"confidence": { "value": 0.45 },
"quality": { "value": 0.5 },
"staleness": { "value": 0.1, "tier": "fresh" },
"payload": { "gotcha_keys": [] }
},
"gotcha_records": {},
"consulted": false,
"consulted_recent": false,
"store_error": false,
"gotcha_error": false
});
let result = process_eval_response(HookVariant::CodexPreBash, "src/lib.rs", &data);
assert_eq!(result.exit_code, 0);
assert!(result.stdout.is_empty(), "Codex advisory must be silent");
assert!(result.stderr.is_empty());
assert!(
result.events.is_empty(),
"Codex must NOT mint consultation receipt for advisory, got: {:?}",
result.events
);
assert!(matches!(result.decision, Decision::Advisory { .. }));
}
#[test]
fn e2e_claude_advisory_injects_context() {
let data = json!({
"file_key": "file:src/lib.rs",
"file_record": {
"value": "Library root",
"confidence": { "value": 0.45 },
"quality": { "value": 0.5 },
"staleness": { "value": 0.1, "tier": "fresh" },
"payload": { "gotcha_keys": [] }
},
"gotcha_records": {},
"consulted": false,
"consulted_recent": false,
"store_error": false,
"gotcha_error": false
});
let result = process_eval_response(HookVariant::ClaudePreRead, "src/lib.rs", &data);
assert_eq!(result.exit_code, 0);
let json: serde_json::Value =
serde_json::from_str(&result.stdout).expect("stdout must be valid JSON");
assert_eq!(
json.pointer("/hookSpecificOutput/permissionDecision")
.and_then(|v| v.as_str()),
Some("allow")
);
assert!(
json.pointer("/hookSpecificOutput/additionalContext")
.and_then(|v| v.as_str())
.unwrap_or("")
.contains("[mati]"),
"Claude advisory must inject context"
);
assert_eq!(result.events.len(), 1);
assert!(matches!(&result.events[0], HookEvent::Hit { .. }));
}
#[test]
fn e2e_codex_consulted_allows_silently() {
let mut data = deny_eligible_eval_data();
data["consulted_recent"] = json!(true);
let result = process_eval_response(HookVariant::CodexPreBash, "src/main.rs", &data);
assert_eq!(result.exit_code, 0, "consulted file must not be blocked");
assert!(result.stdout.is_empty());
assert!(result.stderr.is_empty());
assert!(result.events.is_empty());
}
#[test]
fn e2e_claude_consulted_records_allow_after_receipt() {
let mut data = deny_eligible_eval_data();
data["consulted"] = json!(true);
let result = process_eval_response(HookVariant::ClaudePreRead, "src/main.rs", &data);
assert_eq!(result.exit_code, 0, "Claude always exits 0");
let json: serde_json::Value =
serde_json::from_str(&result.stdout).expect("stdout must be valid JSON");
assert_eq!(
json.pointer("/hookSpecificOutput/permissionDecision")
.and_then(|v| v.as_str()),
Some("allow")
);
assert!(matches!(result.decision, Decision::AlreadyConsulted { .. }));
assert_eq!(result.events.len(), 1);
assert!(
matches!(&result.events[0], HookEvent::ComplianceHit { key } if key == "file:src/main.rs"),
"AlreadyConsulted must emit ComplianceHit so AllowAfterReceipt is recorded, got: {:?}",
result.events
);
}
#[test]
fn e2e_store_error_fails_open() {
let data = json!({
"file_key": "file:src/main.rs",
"file_record": null,
"gotcha_records": {},
"consulted": false,
"consulted_recent": false,
"store_error": true,
"gotcha_error": false
});
let result = process_eval_response(HookVariant::CodexPreBash, "src/main.rs", &data);
assert_eq!(result.exit_code, 0, "store error must fail open");
assert_eq!(result.decision, Decision::Allow);
}
#[test]
fn e2e_gotcha_error_fails_open() {
let data = json!({
"file_key": "file:src/main.rs",
"file_record": {
"value": "test",
"confidence": { "value": 0.7 },
"quality": { "value": 0.5 },
"staleness": { "value": 0.1, "tier": "fresh" },
"payload": { "gotcha_keys": ["gotcha:broken"] }
},
"gotcha_records": {},
"consulted": false,
"consulted_recent": false,
"store_error": false,
"gotcha_error": true
});
let result = process_eval_response(HookVariant::ClaudePreBash, "src/main.rs", &data);
assert_eq!(result.exit_code, 0, "gotcha error must fail open");
let json: serde_json::Value =
serde_json::from_str(&result.stdout).expect("stdout must be valid JSON");
assert_eq!(
json.pointer("/hookSpecificOutput/permissionDecision")
.and_then(|v| v.as_str()),
Some("allow"),
"gotcha error must produce allow"
);
}
async fn run_with_deadline<F>(deadline_ms: u64, variant: HookVariant, inner: F) -> Result<()>
where
F: std::future::Future<Output = Result<()>>,
{
match tokio::time::timeout(Duration::from_millis(deadline_ms), inner).await {
Ok(inner_result) => inner_result,
Err(_elapsed) => {
emit_allow(variant);
Ok(())
}
}
}
#[tokio::test]
async fn outer_deadline_emits_allow_on_timeout() {
use std::time::Instant;
let deadline_ms = 100u64;
let inner_sleep_ms = 5_000u64;
let start = Instant::now();
let result = run_with_deadline(deadline_ms, HookVariant::ClaudePreRead, async move {
tokio::time::sleep(Duration::from_millis(inner_sleep_ms)).await;
Ok(())
})
.await;
let elapsed = start.elapsed();
assert!(
result.is_ok(),
"deadline wrapper must never propagate Err on timeout, got: {result:?}"
);
assert!(
elapsed < Duration::from_millis(deadline_ms + 400),
"wrapper took {elapsed:?} — should fire near deadline ({deadline_ms}ms), not wait for inner sleep ({inner_sleep_ms}ms)"
);
assert!(
elapsed >= Duration::from_millis(deadline_ms),
"wrapper took {elapsed:?} — must wait at least the deadline ({deadline_ms}ms) before timing out"
);
let allow_json =
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}"#;
let parsed: serde_json::Value =
serde_json::from_str(allow_json).expect("allow JSON shape must parse");
assert_eq!(
parsed
.pointer("/hookSpecificOutput/permissionDecision")
.and_then(|v| v.as_str()),
Some("allow"),
"deadline path must produce permissionDecision=allow"
);
}
#[test]
fn e2e_no_record_allows() {
let data = json!({
"file_key": "file:src/new.rs",
"file_record": null,
"gotcha_records": {},
"consulted": false,
"consulted_recent": false,
"store_error": false,
"gotcha_error": false
});
let result = process_eval_response(HookVariant::ClaudePreRead, "src/new.rs", &data);
assert_eq!(result.exit_code, 0);
assert!(matches!(result.decision, Decision::NoRecord));
assert_eq!(result.events.len(), 1);
assert!(matches!(&result.events[0], HookEvent::Miss { .. }));
}
}