use super::*;
pub(crate) fn log_fail_open(rel_path: &str, reason: &str) {
log_fail_open_named("hook-decide", rel_path, reason);
}
pub(crate) fn log_fail_open_named(hook: &str, rel_path: &str, reason: &str) {
eprintln!("[mati] WARNING: enforcement bypassed for {rel_path} — {reason}");
if let Some(log_dir) = mati_core::store::mati_home_opt() {
let _ = std::fs::create_dir_all(&log_dir);
let log_path = log_dir.join("fail_open.log");
log_fail_open_at_named(&log_path, hook, rel_path, reason);
}
}
#[allow(dead_code)] pub(crate) fn log_fail_open_at(log_path: &Path, rel_path: &str, reason: &str) {
log_fail_open_at_named(log_path, "hook-decide", rel_path, reason);
}
fn log_fail_open_at_named(log_path: &Path, hook: &str, rel_path: &str, reason: &str) {
let now = iso_utc_now();
let entry = format!("{now} FAIL_OPEN hook={hook} 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())?;
std::io::Write::flush(&mut f)
});
}
fn iso_utc_now() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
pub(crate) fn platform_events(
variant: HookVariant,
decision: &Decision,
events: Vec<HookEvent>,
) -> Vec<HookEvent> {
match variant {
HookVariant::ClaudeConfigChange => Vec::new(),
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
| HookVariant::ClaudePostBash
| HookVariant::ClaudePostTask
| HookVariant::ClaudeInstructionsLoaded
| HookVariant::ClaudeFileChanged => {
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::PolicyConsultBlocked { key } => {
Some(HookEvent::PolicyConsultBlocked { key })
}
HookEvent::PolicyConsulted { key } => Some(HookEvent::PolicyConsulted { key }),
HookEvent::PolicySteered { key } => Some(HookEvent::PolicySteered { key }),
HookEvent::PolicyShadowObserved { key, would, action } => {
Some(HookEvent::PolicyShadowObserved { key, would, action })
}
HookEvent::ComplianceHit { key } => Some(HookEvent::EditConsulted { key }),
HookEvent::TombstoneBypassedDeny { key } => {
Some(HookEvent::TombstoneBypassedDeny { key })
}
_ => None,
})
.collect(),
HookVariant::ClaudePreRead | HookVariant::ClaudePreBash => {
events
}
}
}
pub(crate) async fn fire_events(
mati_root: &Path,
events: &[HookEvent],
session_id: Option<&str>,
agent_id: Option<&str>,
actor_scope: Option<&str>,
basis_hash: Option<&str>,
) {
for event in events {
let Some(cmd) = session_command(event, session_id, agent_id, actor_scope, basis_hash)
else {
continue;
};
let _ = super::daemon::daemon_v2(mati_root, cmd).await;
}
}
pub(crate) fn session_command(
event: &HookEvent,
session_id: Option<&str>,
agent_id: Option<&str>,
actor_scope: Option<&str>,
basis_hash: Option<&str>,
) -> Option<mati_core::mcp::protocol::Command> {
use mati_core::mcp::protocol as p;
let sid = || session_id.map(str::to_string);
let scope = || actor_scope.map(str::to_string);
let basis = || basis_hash.map(str::to_string);
Some(match event {
HookEvent::Hit { key } => p::Command::ConsultationHit(p::ConsultationHitInput {
key: key.clone(),
capture_fingerprint: true,
actor: scope(),
session_id: sid(),
agent_id: agent_id.map(str::to_string),
decision_basis_hash: basis(),
source: Some(mati_core::store::ReceiptSource::HookContext),
}),
HookEvent::Miss { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::Miss,
key: key.clone(),
session_id: sid(),
actor: scope(),
decision_basis_hash: basis(),
}),
HookEvent::BlockedUnconsultedRead { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::ComplianceMiss,
key: key.clone(),
session_id: sid(),
actor: scope(),
decision_basis_hash: basis(),
}),
HookEvent::CodexShellBlocked { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::CodexShellBlocked,
key: key.clone(),
session_id: sid(),
actor: scope(),
decision_basis_hash: basis(),
}),
HookEvent::UnclassifiedPolicyLiteralBypass { key } => {
p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::UnclassifiedPolicyLiteralBypass,
key: key.clone(),
session_id: sid(),
actor: scope(),
decision_basis_hash: None,
})
}
HookEvent::ComplianceHit { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::ComplianceHit,
key: key.clone(),
session_id: sid(),
actor: scope(),
decision_basis_hash: basis(),
}),
HookEvent::EditConsulted { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::EditConsulted,
key: key.clone(),
session_id: sid(),
actor: scope(),
decision_basis_hash: basis(),
}),
HookEvent::EditBlocked { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::EditBlocked,
key: key.clone(),
session_id: sid(),
actor: scope(),
decision_basis_hash: basis(),
}),
HookEvent::FloorConsultBlocked { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::FloorConsultMiss,
key: key.clone(),
session_id: sid(),
actor: scope(),
decision_basis_hash: basis(),
}),
HookEvent::PolicyConsultBlocked { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::PolicyConsultMiss,
key: key.clone(),
session_id: sid(),
actor: scope(),
decision_basis_hash: None,
}),
HookEvent::PolicyConsulted { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::PolicyConsultHit,
key: key.clone(),
session_id: sid(),
actor: scope(),
decision_basis_hash: None,
}),
HookEvent::PolicySteered { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::PolicySteered,
key: key.clone(),
session_id: sid(),
actor: scope(),
decision_basis_hash: None,
}),
HookEvent::PolicyShadowObserved {
key,
would,
action: Some(action),
} => p::Command::PolicyShadowObserve(p::PolicyShadowObserveInput {
policy_key: key.clone(),
action: action.clone(),
would: *would,
}),
HookEvent::PolicyShadowObserved { .. } => return None,
HookEvent::TombstoneBypassedDeny { key } => p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::TombstoneBypassedDeny,
key: key.clone(),
session_id: sid(),
actor: scope(),
decision_basis_hash: basis(),
}),
})
}
pub(crate) fn allow_output(variant: HookVariant) -> Option<&'static str> {
match variant {
HookVariant::ClaudeConfigChange => Some(r#"{"decision":"allow"}"#),
HookVariant::ClaudePreRead => Some(
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}"#,
),
HookVariant::ClaudePreBash
| HookVariant::CodexPreBash
| HookVariant::CodexPostBash
| HookVariant::CodexPreApplyPatch
| HookVariant::ClaudePreEdit
| HookVariant::ClaudePostMemGet
| HookVariant::ClaudePostBash
| HookVariant::ClaudePostTask
| HookVariant::ClaudeInstructionsLoaded
| HookVariant::ClaudeFileChanged => None,
}
}
pub(crate) fn emit_allow(variant: HookVariant) {
if let Some(json) = allow_output(variant) {
println!("{json}");
}
let _ = std::io::Write::flush(&mut std::io::stdout());
}
pub(crate) 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()
}
pub(crate) 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)]
pub(crate) struct AdapterResult {
pub(super) stdout: String,
pub(super) stderr: String,
pub(super) exit_code: i32,
pub(super) events: Vec<HookEvent>,
pub(super) basis_hash: Option<String>,
#[allow(dead_code)]
pub(super) decision: Decision,
}
pub(crate) fn consult_globset() -> Option<GlobSet> {
consult_globset_from(&std::env::var("MATI_CONSULT_GLOBS").ok()?)
}
pub(crate) 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)
}
pub(crate) 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())
}
pub(crate) 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."
),
origin: decide::DenyOrigin::ConsultMandate,
};
let events = platform_events(
variant,
&decision,
vec![decide::DenyOrigin::ConsultMandate.deny_event(file_key)],
);
let (stdout, stderr, exit_code) = format_decision(variant, &decision, rel_path);
*adapter = AdapterResult {
stdout,
stderr,
exit_code,
events,
basis_hash: None,
decision,
};
}
pub(crate) enum EvalDataCheck {
Ok(EnforcementInput),
FailOpen(String),
}
pub(crate) fn check_eval_data(
variant: HookVariant,
rel_path: &str,
eval_data: &serde_json::Value,
file_exists: Option<bool>,
) -> 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,
file_exists,
};
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)
}
pub(crate) fn process_eval_response(
variant: HookVariant,
rel_path: &str,
eval_data: &serde_json::Value,
file_exists: Option<bool>,
) -> AdapterResult {
let enforcement_input = match check_eval_data(variant, rel_path, eval_data, file_exists) {
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![],
basis_hash: None,
decision: Decision::Allow,
};
}
};
let basis_hash = decision_basis_hash(&enforcement_input.gotcha_records);
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,
basis_hash,
decision: result.decision,
}
}
pub(crate) fn decision_basis_hash(records: &HashMap<String, serde_json::Value>) -> Option<String> {
if records.is_empty() {
return None;
}
let pairs: Vec<(&str, &serde_json::Value)> =
records.iter().map(|(k, v)| (k.as_str(), v)).collect();
Some(mati_core::store::enforcement::compute_decision_basis_hash(
&pairs,
))
}
pub(crate) fn format_decision(
variant: HookVariant,
decision: &Decision,
_rel_path: &str,
) -> (String, String, i32) {
match variant {
HookVariant::ClaudeConfigChange => (String::new(), String::new(), 0),
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),
Decision::Advisory { context } | Decision::Liability { context, .. } => (
format_context_only(&format!("[mati] {context}")),
String::new(),
0,
),
_ => (String::new(), String::new(), 0),
},
HookVariant::CodexPreBash | HookVariant::CodexPreApplyPatch => match decision {
Decision::Deny {
file_key,
reason,
origin,
} => {
let stderr = match origin {
decide::DenyOrigin::Policy => reason.clone(),
decide::DenyOrigin::Gotcha | decide::DenyOrigin::ConsultMandate => {
format!("mati: call mem_get(\"{file_key}\") first")
}
};
(String::new(), stderr, 2)
}
_ => (String::new(), String::new(), 0),
},
HookVariant::CodexPostBash
| HookVariant::ClaudePostMemGet
| HookVariant::ClaudePostBash
| HookVariant::ClaudePostTask
| HookVariant::ClaudeInstructionsLoaded
| HookVariant::ClaudeFileChanged => (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()
}
}
}