use anyhow::Result;
use clap::{Args, ValueEnum};
use globset::{Glob, GlobSet, GlobSetBuilder};
use std::collections::HashMap;
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,
ClaudePreEdit,
ClaudePreBash,
CodexPreBash,
CodexPostBash,
CodexPreApplyPatch,
#[value(name = "claude-post-memget")]
ClaudePostMemGet,
}
#[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();
tokio::io::AsyncReadExt::read_to_string(&mut tokio::io::stdin(), &mut input_str).await?;
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;
}
if args.variant == HookVariant::ClaudePostMemGet {
return run_post_memget(&input).await;
}
let agent_id = input
.get("agent_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
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 | HookVariant::ClaudePreEdit
);
let consult_globs = consult_globset();
let eval_data = match daemon_result(
&mati_root,
"hook_evaluate",
serde_json::json!({
"file_key": &file_key,
"include_recent": include_recent,
"actor": agent_id,
}),
)
.await
{
DaemonResult::Ok(resp) => match daemon_data(&resp) {
Some(d) => d,
None => {
log_fail_open(&rel_path, "hook_evaluate returned error");
emit_allow(args.variant);
return Ok(());
}
},
_ => {
log_fail_open(&rel_path, "hook_evaluate failed");
emit_allow(args.variant);
return Ok(());
}
};
let mut adapter = process_eval_response(args.variant, &rel_path, &eval_data);
apply_consult_mandate(
&mut adapter,
args.variant,
&rel_path,
consulted_flag(&eval_data, include_recent),
consult_globs.as_ref(),
);
let lexical_fail_open = match check_eval_data(args.variant, &rel_path, &eval_data) {
EvalDataCheck::FailOpen(reason) => Some(reason),
EvalDataCheck::Ok(_) => None,
};
if !matches!(adapter.decision, Decision::Deny { .. }) {
if let Some(canon_rel) =
canonical_rel_path(&raw_path, &cwd, repo_root.as_deref(), &rel_path)
{
let canon_key = format!("file:{canon_rel}");
if let Some(canon_eval) = match daemon_result(
&mati_root,
"hook_evaluate",
serde_json::json!({
"file_key": &canon_key,
"include_recent": include_recent,
"actor": agent_id,
}),
)
.await
{
DaemonResult::Ok(resp) => {
let d = daemon_data(&resp);
if d.is_none() {
log_fail_open(&canon_rel, "hook_evaluate returned error (canonical)");
}
d
}
_ => None,
} {
let mut canon_adapter =
process_eval_response(args.variant, &canon_rel, &canon_eval);
apply_consult_mandate(
&mut canon_adapter,
args.variant,
&canon_rel,
consulted_flag(&canon_eval, include_recent),
consult_globs.as_ref(),
);
if matches!(canon_adapter.decision, Decision::Deny { .. }) {
adapter = canon_adapter;
}
}
}
}
if !matches!(adapter.decision, Decision::Deny { .. })
&& matches!(
args.variant,
HookVariant::ClaudePreBash | HookVariant::CodexPreBash
)
{
if let Some(cmd) = input
.pointer("/tool_input/command")
.and_then(|v| v.as_str())
{
if let Some(class) = decide::classify_command(cmd) {
for extra_raw in decide::extract_file_paths(cmd, class)
.into_iter()
.take(decide::MAX_APPLY_PATCH_FILES)
{
let extra_rel = decide::normalize_path(&extra_raw, repo_root_str);
if extra_rel == rel_path {
continue; }
let extra_key = format!("file:{extra_rel}");
match daemon_result(
&mati_root,
"hook_evaluate",
serde_json::json!({
"file_key": &extra_key,
"include_recent": include_recent,
"actor": agent_id,
}),
)
.await
{
DaemonResult::Ok(resp) => {
let Some(extra_eval) = daemon_data(&resp) else {
log_fail_open(
&extra_rel,
"hook_evaluate returned error (extra file)",
);
continue;
};
let mut extra_adapter =
process_eval_response(args.variant, &extra_rel, &extra_eval);
apply_consult_mandate(
&mut extra_adapter,
args.variant,
&extra_rel,
consulted_flag(&extra_eval, include_recent),
consult_globs.as_ref(),
);
if matches!(extra_adapter.decision, Decision::Deny { .. }) {
adapter = extra_adapter;
break;
}
}
_ => log_fail_open(&extra_rel, "hook_evaluate failed (extra file)"),
}
}
}
}
}
if !adapter.stdout.is_empty() {
println!("{}", adapter.stdout);
}
if !adapter.stderr.is_empty() {
eprintln!("{}", adapter.stderr);
}
let session_id = input.get("session_id").and_then(|v| v.as_str());
fire_events(&mati_root, &adapter.events, session_id, agent_id).await;
if let Some(reason) = lexical_fail_open {
log_fail_open(&rel_path, &reason);
}
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 | HookVariant::ClaudePreEdit => {
input
.pointer("/tool_input/file_path")
.or_else(|| input.pointer("/tool_input/notebook_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,
HookVariant::ClaudePostMemGet => None,
}
}
fn discover_repo_root(cwd: &Path) -> Option<PathBuf> {
git2::Repository::discover(cwd).ok().and_then(|repo| {
let root = repo.workdir()?.to_str()?.trim_end_matches('/');
Some(PathBuf::from(root))
})
}
fn canonical_rel_path(
raw_path: &str,
cwd: &Path,
repo_root: Option<&Path>,
lexical_rel: &str,
) -> Option<String> {
let repo_root = repo_root?;
let canon_root = super::sandbox::canonicalize_lenient(repo_root)?;
let raw = Path::new(raw_path);
let abs_access = if raw.is_absolute() {
raw.to_path_buf()
} else {
cwd.join(raw)
};
let canon_access = super::sandbox::canonicalize_lenient(&abs_access)?;
let stripped = canon_access.strip_prefix(&canon_root).ok()?;
let stripped_str = stripped.to_str()?;
let canon_rel = decide::normalize_path(stripped_str, None);
if canon_rel == lexical_rel {
return None;
}
Some(canon_rel)
}
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": mati_core::store::session::CONSULTED_RECENT_TTL_SECS,
}),
)
.await
{
DaemonResult::Ok(resp) => match daemon_data(&resp) {
Some(d) => d.as_bool().unwrap_or(false),
None => return Ok(()),
},
_ => 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(),
session_id: None,
});
let _ = super::daemon::daemon_v2(mati_root, cmd).await;
Ok(())
}
async fn run_post_memget(input: &serde_json::Value) -> Result<()> {
if input
.pointer("/tool_response/isError")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return Ok(());
}
let key = match input
.pointer("/tool_input/key")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
Some(k) => k,
None => return Ok(()),
};
let agent_id = input
.get("agent_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
let session_id = input
.get("session_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
let actor = agent_id.or(session_id);
let cwd = std::env::current_dir()?;
let repo_root = discover_repo_root(&cwd);
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(_) => return Ok(()),
};
if !ensure_daemon(&mati_root).await {
return Ok(());
}
let cmd = mati_core::mcp::protocol::Command::ConsultationHit(
mati_core::mcp::protocol::ConsultationHitInput {
key: key.to_string(),
actor: actor.map(str::to_string),
session_id: session_id.map(str::to_string),
agent_id: agent_id.map(str::to_string),
},
);
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 agent_id = input
.get("agent_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
let consult_globs = consult_globset();
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, "actor": agent_id }),
)
.await
{
DaemonResult::Ok(resp) => match daemon_data(&resp) {
Some(d) => d,
None => {
log_fail_open(&rel_path, "hook_evaluate returned error");
continue;
}
},
_ => {
log_fail_open(&rel_path, "hook_evaluate failed");
continue;
}
};
let mut adapter = process_eval_response(variant, &rel_path, &eval_data);
apply_consult_mandate(
&mut adapter,
variant,
&rel_path,
consulted_flag(&eval_data, true),
consult_globs.as_ref(),
);
if !matches!(adapter.decision, Decision::Deny { .. }) {
if let Some(canon_rel) = canonical_rel_path(raw, &cwd, repo_root.as_deref(), &rel_path)
{
let canon_key = format!("file:{canon_rel}");
if let Some(canon_eval) = match daemon_result(
&mati_root,
"hook_evaluate",
serde_json::json!({ "file_key": &canon_key, "include_recent": true, "actor": agent_id }),
)
.await
{
DaemonResult::Ok(resp) => {
let d = daemon_data(&resp);
if d.is_none() {
log_fail_open(
&canon_rel,
"hook_evaluate returned error (canonical)",
);
}
d
}
_ => None,
} {
let mut canon_adapter =
process_eval_response(variant, &canon_rel, &canon_eval);
apply_consult_mandate(
&mut canon_adapter,
variant,
&canon_rel,
consulted_flag(&canon_eval, true),
consult_globs.as_ref(),
);
if matches!(canon_adapter.decision, Decision::Deny { .. }) {
adapter = canon_adapter;
}
}
}
}
if let Decision::Deny {
file_key: denied_key,
..
} = &adapter.decision
{
denied.push(denied_key.clone());
events.extend(adapter.events);
}
}
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());
fire_events(&mati_root, &events, None, agent_id).await;
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 | HookVariant::ClaudePostMemGet => {
events
}
HookVariant::ClaudePreEdit => events
.into_iter()
.filter_map(|e| match e {
HookEvent::BlockedUnconsultedRead { key } => Some(HookEvent::EditBlocked { key }),
HookEvent::FloorConsultBlocked { key } => {
Some(HookEvent::FloorConsultBlocked { key })
}
HookEvent::ComplianceHit { key } => Some(HookEvent::EditConsulted { key }),
_ => None,
})
.collect(),
HookVariant::ClaudePreRead | HookVariant::ClaudePreBash => {
events
}
}
}
async fn fire_events(
mati_root: &Path,
events: &[HookEvent],
session_id: Option<&str>,
agent_id: Option<&str>,
) {
use mati_core::mcp::protocol as p;
let sid = || session_id.map(str::to_string);
for event in events {
let cmd = match event {
HookEvent::Hit { key } => p::Command::ConsultationHit(p::ConsultationHitInput {
key: key.clone(),
actor: agent_id.map(str::to_string),
session_id: sid(),
agent_id: agent_id.map(str::to_string),
}),
HookEvent::Miss { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::Miss,
key: key.clone(),
session_id: sid(),
}),
HookEvent::BlockedUnconsultedRead { key } => {
p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::ComplianceMiss,
key: key.clone(),
session_id: sid(),
})
}
HookEvent::CodexShellBlocked { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::CodexShellMiss,
key: key.clone(),
session_id: sid(),
}),
HookEvent::ComplianceHit { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::ComplianceHit,
key: key.clone(),
session_id: sid(),
}),
HookEvent::EditConsulted { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::EditConsulted,
key: key.clone(),
session_id: sid(),
}),
HookEvent::EditBlocked { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::EditBlocked,
key: key.clone(),
session_id: sid(),
}),
HookEvent::FloorConsultBlocked { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::FloorConsultMiss,
key: key.clone(),
session_id: sid(),
}),
};
let _ = super::daemon::daemon_v2(mati_root, cmd).await;
}
}
fn allow_output(variant: HookVariant) -> Option<&'static str> {
match variant {
HookVariant::ClaudePreRead => Some(
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}"#,
),
HookVariant::ClaudePreBash
| HookVariant::CodexPreBash
| HookVariant::CodexPostBash
| HookVariant::CodexPreApplyPatch
| HookVariant::ClaudePreEdit
| HookVariant::ClaudePostMemGet => None,
}
}
fn emit_allow(variant: HookVariant) {
if let Some(json) = allow_output(variant) {
println!("{json}");
}
}
fn daemon_data(resp: &serde_json::Value) -> Option<serde_json::Value> {
if resp.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
Some(resp.get("data").cloned().unwrap_or(serde_json::Value::Null))
} else {
None
}
}
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 {
let mut quoted =
serde_json::to_string(s).expect("serializing a &str to a JSON string cannot fail");
quoted.pop();
quoted.remove(0);
quoted
}
#[derive(Debug)]
struct AdapterResult {
stdout: String,
stderr: String,
exit_code: i32,
events: Vec<HookEvent>,
#[allow(dead_code)]
decision: Decision,
}
fn consult_globset() -> Option<GlobSet> {
consult_globset_from(&std::env::var("MATI_CONSULT_GLOBS").ok()?)
}
fn consulted_flag(eval_data: &serde_json::Value, include_recent: bool) -> bool {
let field = if include_recent {
"consulted_recent"
} else {
"consulted"
};
eval_data
.get(field)
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
fn consult_globset_from(raw: &str) -> Option<GlobSet> {
let globs: Vec<String> = serde_json::from_str(raw).ok()?;
if globs.is_empty() {
return None;
}
let mut builder = GlobSetBuilder::new();
for g in &globs {
match Glob::new(g) {
Ok(glob) => {
builder.add(glob);
}
Err(e) => log_fail_open(
g,
&format!("invalid consult-mandate glob, not enforced: {e}"),
),
}
}
builder.build().ok().filter(|s| !s.is_empty())
}
fn apply_consult_mandate(
adapter: &mut AdapterResult,
variant: HookVariant,
rel_path: &str,
consulted: bool,
globs: Option<&GlobSet>,
) {
let Some(globs) = globs else {
return;
};
if consulted || matches!(adapter.decision, Decision::Deny { .. }) || !globs.is_match(rel_path) {
return;
}
let file_key = format!("file:{rel_path}");
let decision = Decision::Deny {
file_key: file_key.clone(),
reason: format!(
"[mati] Org policy requires consulting {rel_path} before access — \
call mem_get(\"{file_key}\") first."
),
};
let events = platform_events(
variant,
&decision,
vec![HookEvent::FloorConsultBlocked { key: file_key }],
);
let (stdout, stderr, exit_code) = format_decision(variant, &decision, rel_path);
*adapter = AdapterResult {
stdout,
stderr,
exit_code,
events,
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
| HookVariant::ClaudePreEdit
| HookVariant::ClaudePostMemGet
);
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 = allow_output(variant)
.map(str::to_string)
.unwrap_or_default();
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 => {
let stdout = format_claude_output(decision);
(stdout, String::new(), 0)
}
HookVariant::ClaudePreBash => {
let stdout = match decision {
Decision::Deny { reason, .. } => format_deny(reason),
Decision::AlreadyConsulted { context } => {
format_context_only(&format!("[mati] Record already consulted. {context}"))
}
Decision::Advisory { context } | Decision::Liability { context, .. } => {
format_context_only(&format!("[mati] {context}"))
}
_ => String::new(),
};
(stdout, String::new(), 0)
}
HookVariant::ClaudePreEdit => match decision {
Decision::Deny { reason, .. } => (format_deny(reason), String::new(), 0),
_ => (String::new(), 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 | HookVariant::ClaudePostMemGet => {
(String::new(), String::new(), 0)
}
}
}
fn format_deny(reason: &str) -> String {
let escaped = escape_json_string(reason);
format!(
r#"{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"{escaped}"}}}}"#
)
}
fn format_context_only(msg: &str) -> String {
let escaped = escape_json_string(msg);
format!(
r#"{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","additionalContext":"{escaped}"}}}}"#
)
}
fn format_claude_output(decision: &Decision) -> String {
match decision {
Decision::Deny { reason, .. } => format_deny(reason),
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");
assert!(
result.stdout.is_empty(),
"Bash fail-open must DEFER (empty stdout), never force-allow — \
Bash is permission-required; got: {}",
result.stdout
);
assert_eq!(result.decision, Decision::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 daemon_data_rejects_error_envelope() {
let ok = json!({"ok": true, "v": 2, "data": {"consulted": true}});
assert_eq!(
daemon_data(&ok),
Some(json!({"consulted": true})),
"ok envelope must yield its data"
);
let err = json!({"ok": false, "v": 2, "error": "backpressure", "code": "backpressure"});
assert!(
daemon_data(&err).is_none(),
"error envelope must not evaluate as data"
);
assert!(daemon_data(&json!({"v": 2})).is_none());
}
#[test]
fn only_pre_read_force_allows() {
use HookVariant::*;
for variant in [
ClaudePreRead,
ClaudePreEdit,
ClaudePreBash,
CodexPreBash,
CodexPostBash,
CodexPreApplyPatch,
ClaudePostMemGet,
] {
match variant {
ClaudePreRead => assert!(
allow_output(variant).is_some(),
"read gate keeps its no-op allow"
),
_ => assert!(
allow_output(variant).is_none(),
"{variant:?} must DEFER on allow — force-allow would bypass \
the user's permission prompt"
),
}
let non_deny_decisions = [
Decision::Allow,
Decision::NoRecord,
Decision::Tombstone,
Decision::AlreadyConsulted {
context: "ctx".into(),
},
Decision::Advisory {
context: "ctx".into(),
},
Decision::Liability {
staleness: 0.9,
context: "ctx".into(),
},
];
for decision in &non_deny_decisions {
let (stdout, _, _) = format_decision(variant, decision, "src/x.rs");
if variant != ClaudePreRead {
assert!(
!stdout.contains(r#""permissionDecision":"allow""#),
"{variant:?} emitted a force-allow for {decision:?}: {stdout}"
);
}
}
}
}
#[test]
fn e2e_claude_pre_bash_defers_no_record() {
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::ClaudePreBash, "src/new.rs", &data);
assert_eq!(result.exit_code, 0);
assert!(
result.stdout.is_empty(),
"no-record bash read must defer, got: {}",
result.stdout
);
assert!(matches!(result.decision, Decision::NoRecord));
}
#[test]
fn e2e_claude_pre_bash_advisory_injects_context_without_permission_decision() {
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::ClaudePreBash, "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!(
json.pointer("/hookSpecificOutput/permissionDecision")
.is_none(),
"bash advisory must NOT carry a permissionDecision, got: {}",
result.stdout
);
assert!(
json.pointer("/hookSpecificOutput/additionalContext")
.and_then(|v| v.as_str())
.unwrap_or("")
.contains("[mati]"),
"bash advisory must inject context, got: {}",
result.stdout
);
assert!(matches!(result.decision, Decision::Advisory { .. }));
}
#[test]
fn e2e_claude_pre_bash_deny_still_denies() {
let data = deny_eligible_eval_data();
let result = process_eval_response(HookVariant::ClaudePreBash, "src/main.rs", &data);
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")
);
}
#[test]
fn escape_json_string_escapes_control_chars() {
let hostile = "a\u{08}b\u{0C}c\u{1B}d\"e\\f\ng";
let escaped = escape_json_string(hostile);
let wrapped = format!("{{\"v\":\"{escaped}\"}}");
let parsed: serde_json::Value =
serde_json::from_str(&wrapped).expect("escaped output must be valid inside JSON");
assert_eq!(parsed.pointer("/v").and_then(|v| v.as_str()), Some(hostile));
}
#[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 { .. }));
}
#[test]
fn extract_path_claude_pre_edit_file_path() {
let input = json!({"tool_input": {"file_path": "/repo/src/pay.rs"}});
assert_eq!(
extract_path(&input, HookVariant::ClaudePreEdit),
Some("/repo/src/pay.rs".into())
);
}
#[test]
fn extract_path_claude_pre_edit_notebook_path() {
let input = json!({"tool_input": {"notebook_path": "/repo/nb/analysis.ipynb"}});
assert_eq!(
extract_path(&input, HookVariant::ClaudePreEdit),
Some("/repo/nb/analysis.ipynb".into())
);
}
#[test]
fn extract_path_codex_pre_bash_egrep_and_fgrep() {
let egrep = json!({"tool_input": {"command": "egrep TODO src/main.rs"}});
assert_eq!(
extract_path(&egrep, HookVariant::CodexPreBash),
Some("src/main.rs".into())
);
let fgrep = json!({"tool_input": {"command": "fgrep needle src/main.rs"}});
assert_eq!(
extract_path(&fgrep, HookVariant::CodexPreBash),
Some("src/main.rs".into())
);
}
#[test]
fn e2e_claude_pre_edit_denies_blind_edit() {
let data = deny_eligible_eval_data();
let result = process_eval_response(HookVariant::ClaudePreEdit, "src/main.rs", &data);
assert_eq!(
result.exit_code, 0,
"Claude always exits 0; deny is in the JSON"
);
let json: serde_json::Value =
serde_json::from_str(&result.stdout).expect("deny 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 instruct the agent to consult, got: {}",
result.stdout
);
assert_eq!(result.events.len(), 1);
assert!(matches!(&result.events[0], HookEvent::EditBlocked { .. }));
assert!(matches!(result.decision, Decision::Deny { .. }));
}
#[test]
fn e2e_claude_pre_edit_defers_after_consult() {
let mut data = deny_eligible_eval_data();
data["consulted_recent"] = json!(true);
let result = process_eval_response(HookVariant::ClaudePreEdit, "src/main.rs", &data);
assert_eq!(result.exit_code, 0);
assert!(
result.stdout.is_empty(),
"consulted edit must DEFER (empty stdout), not force-allow, got: {}",
result.stdout
);
assert!(result.stderr.is_empty());
assert!(matches!(result.decision, Decision::AlreadyConsulted { .. }));
assert_eq!(result.events.len(), 1);
assert!(matches!(&result.events[0], HookEvent::EditConsulted { .. }));
}
#[test]
fn e2e_claude_pre_edit_defers_no_record() {
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::ClaudePreEdit, "src/new.rs", &data);
assert_eq!(result.exit_code, 0);
assert!(result.stdout.is_empty(), "no-record edit must defer");
assert!(matches!(result.decision, Decision::NoRecord));
assert!(result.events.is_empty());
}
#[test]
fn e2e_claude_pre_edit_store_error_defers() {
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::ClaudePreEdit, "src/main.rs", &data);
assert_eq!(result.exit_code, 0, "store error must fail open (defer)");
assert!(result.stdout.is_empty());
assert_eq!(result.decision, Decision::Allow);
}
#[cfg(unix)]
#[test]
fn canonical_rel_resolves_symlink_to_real_target_key() {
let repo = tempfile::TempDir::new().expect("tempdir");
let root = repo.path();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/real.rs"), "fn x() {}\n").unwrap();
std::os::unix::fs::symlink(root.join("src/real.rs"), root.join("link.rs")).unwrap();
let got = canonical_rel_path(
root.join("link.rs").to_str().unwrap(),
root,
Some(root),
"link.rs",
);
assert_eq!(got.as_deref(), Some("src/real.rs"));
}
#[cfg(unix)]
#[test]
fn canonical_rel_relative_access_resolves_against_cwd() {
let repo = tempfile::TempDir::new().expect("tempdir");
let root = repo.path();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/real.rs"), "fn x() {}\n").unwrap();
std::os::unix::fs::symlink(root.join("src/real.rs"), root.join("link.rs")).unwrap();
let got = canonical_rel_path("link.rs", root, Some(root), "link.rs");
assert_eq!(got.as_deref(), Some("src/real.rs"));
}
#[test]
fn canonical_rel_non_symlink_is_noop() {
let repo = tempfile::TempDir::new().expect("tempdir");
let root = repo.path();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/real.rs"), "fn x() {}\n").unwrap();
let got = canonical_rel_path(
root.join("src/real.rs").to_str().unwrap(),
root,
Some(root),
"src/real.rs",
);
assert_eq!(got, None, "non-symlink access must not trigger a fallback");
}
#[cfg(unix)]
#[test]
fn canonical_rel_outside_repo_is_none() {
let repo = tempfile::TempDir::new().expect("tempdir");
let outside = tempfile::TempDir::new().expect("tempdir");
let root = repo.path();
std::fs::write(outside.path().join("secret.rs"), "fn x() {}\n").unwrap();
std::os::unix::fs::symlink(outside.path().join("secret.rs"), root.join("escape.rs"))
.unwrap();
let got = canonical_rel_path(
root.join("escape.rs").to_str().unwrap(),
root,
Some(root),
"escape.rs",
);
assert_eq!(got, None, "out-of-repo symlink target must yield no key");
}
#[test]
fn canonical_rel_no_repo_root_is_none() {
let got = canonical_rel_path("/some/abs/path.rs", Path::new("/tmp"), None, "path.rs");
assert_eq!(got, None);
}
#[test]
fn canonical_rel_nonexistent_leaf_under_real_dir() {
let repo = tempfile::TempDir::new().expect("tempdir");
let root = repo.path();
std::fs::create_dir_all(root.join("src")).unwrap();
let got = canonical_rel_path(
root.join("src/ghost.rs").to_str().unwrap(),
root,
Some(root),
"src/ghost.rs",
);
assert_eq!(got, None);
}
fn allow_adapter() -> AdapterResult {
AdapterResult {
stdout: "allow".to_string(),
stderr: String::new(),
exit_code: 0,
events: vec![],
decision: Decision::Allow,
}
}
fn phi_globs() -> GlobSet {
consult_globset_from(r#"["phi/**"]"#).unwrap()
}
#[test]
fn consult_globset_from_parses_and_rejects() {
assert!(consult_globset_from(r#"["phi/**","src/pay/**"]"#).is_some());
assert!(consult_globset_from("[]").is_none());
assert!(consult_globset_from("not json").is_none());
}
#[test]
fn mandate_denies_unconsulted_match() {
let g = phi_globs();
let mut a = allow_adapter();
apply_consult_mandate(
&mut a,
HookVariant::ClaudePreRead,
"phi/records.rs",
false,
Some(&g),
);
assert!(matches!(a.decision, Decision::Deny { .. }));
assert!(
a.stdout.contains("deny"),
"pre-read deny output must be emitted"
);
assert!(
matches!(
a.events.first(),
Some(HookEvent::FloorConsultBlocked { .. })
),
"floor mandate deny must emit its own event (distinct audit reason code)"
);
}
#[test]
fn mandate_pre_edit_deny_uses_org_policy_message() {
let g = phi_globs();
let mut a = allow_adapter();
apply_consult_mandate(
&mut a,
HookVariant::ClaudePreEdit,
"phi/records.rs",
false,
Some(&g),
);
assert!(matches!(a.decision, Decision::Deny { .. }));
assert!(
a.stdout.contains("deny") && a.stdout.contains("Org policy"),
"pre-edit mandate deny must show the org-policy reason, not 'Confirmed gotcha'; got {}",
a.stdout
);
}
#[test]
fn mandate_allows_when_consulted() {
let g = phi_globs();
let mut a = allow_adapter();
apply_consult_mandate(
&mut a,
HookVariant::ClaudePreRead,
"phi/records.rs",
true,
Some(&g),
);
assert!(
matches!(a.decision, Decision::Allow),
"consultation satisfies the mandate"
);
}
#[test]
fn mandate_noop_on_nonmatch_or_no_globs() {
let g = phi_globs();
let mut a = allow_adapter();
apply_consult_mandate(
&mut a,
HookVariant::ClaudePreRead,
"src/main.rs",
false,
Some(&g),
);
assert!(
matches!(a.decision, Decision::Allow),
"non-matching path is untouched"
);
let mut b = allow_adapter();
apply_consult_mandate(
&mut b,
HookVariant::ClaudePreRead,
"phi/records.rs",
false,
None,
);
assert!(
matches!(b.decision, Decision::Allow),
"no mandate -> no change"
);
}
#[test]
fn mandate_applies_to_apply_patch_variant() {
let g = phi_globs();
let mut a = allow_adapter();
apply_consult_mandate(
&mut a,
HookVariant::CodexPreApplyPatch,
"phi/records.rs",
false,
Some(&g),
);
assert!(matches!(a.decision, Decision::Deny { .. }));
assert_eq!(a.exit_code, 2, "apply_patch mandate deny must exit 2");
assert!(
a.stderr.contains("mem_get"),
"apply_patch mandate deny must instruct consultation, got: {}",
a.stderr
);
}
#[test]
fn mandate_preserves_existing_deny() {
let g = phi_globs();
let mut a = AdapterResult {
stdout: "x".to_string(),
stderr: String::new(),
exit_code: 0,
events: vec![],
decision: Decision::Deny {
file_key: "file:phi/x.rs".to_string(),
reason: "gotcha-deny".to_string(),
},
};
apply_consult_mandate(
&mut a,
HookVariant::ClaudePreRead,
"phi/x.rs",
false,
Some(&g),
);
match &a.decision {
Decision::Deny { reason, .. } => {
assert_eq!(reason, "gotcha-deny", "deny > consult; not overwritten")
}
_ => panic!("expected the pre-existing Deny to survive"),
}
}
}