use super::*;
pub(crate) async fn ensure_daemon(mati_root: &Path) -> bool {
mati_core::mcp::daemon_lifecycle::ensure_daemon(mati_root).await
}
pub(crate) 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,
actor: None,
decision_basis_hash: None,
});
let _ = super::daemon::daemon_v2(mati_root, cmd).await;
Ok(())
}
pub(crate) 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 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 worktree = mati_core::store::session::worktree_scope_tag(&cwd);
let actor = receipt_actor(worktree.as_deref(), agent_id);
let cmd = mati_core::mcp::protocol::Command::ConsultationHit(
mati_core::mcp::protocol::ConsultationHitInput {
key: key.to_string(),
capture_fingerprint: true,
actor,
session_id: session_id.map(str::to_string),
agent_id: agent_id.map(str::to_string),
decision_basis_hash: None,
source: Some(mati_core::store::ReceiptSource::MemGet),
},
);
let _ = super::daemon::daemon_v2(&mati_root, cmd).await;
Ok(())
}
type NestedSpawn = (String, String, Option<String>, Option<String>);
fn parse_nested_spawn(input: &serde_json::Value) -> Option<NestedSpawn> {
if input
.pointer("/tool_response/isError")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return None;
}
if input
.pointer("/tool_response/status")
.and_then(|v| v.as_str())
!= Some("completed")
{
return None;
}
let child = input
.pointer("/tool_response/agentId")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())?
.to_string();
let parent = input
.get("agent_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())?
.to_string();
let session_id = input
.get("session_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string);
let agent_type = input
.pointer("/tool_response/agentType")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string);
Some((child, parent, session_id, agent_type))
}
pub(crate) async fn run_post_task(input: &serde_json::Value) -> Result<()> {
let Some((child, parent, session_id, agent_type)) = parse_nested_spawn(input) else {
return Ok(());
};
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::SubagentEdge(
mati_core::mcp::protocol::SubagentEdgeInput {
child_agent_id: Some(child),
parent_agent_id: Some(parent),
session_id,
agent_type,
},
);
let _ = super::daemon::daemon_v2(&mati_root, cmd).await;
Ok(())
}
pub(crate) async fn run_post_bash_introspection(input: &serde_json::Value) -> Result<()> {
if !post_bash_succeeded(input) {
return Ok(());
}
let Some(command) = input
.pointer("/tool_input/command")
.and_then(|value| value.as_str())
.filter(|command| decide::is_schema_introspection(command))
else {
return Ok(());
};
let action = decide::normalize_action(Some(command), None);
if action.tool != "db_client" {
record_wrapped_client_miss(input, &action).await;
return Ok(());
}
let Ok(cwd) = std::env::current_dir() else {
return Ok(());
};
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(root) => root,
Err(_) => return Ok(()),
};
if !ensure_daemon(&mati_root).await {
return Ok(());
}
let agent_id = input
.get("agent_id")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty());
let session_id = input
.get("session_id")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty());
let worktree = mati_core::store::session::worktree_scope_tag(&cwd);
let actor_scope = receipt_actor(worktree.as_deref(), agent_id);
let Some(evaluation) = policy_verdicts_for_action(
&mati_root,
&action,
actor_scope.as_deref(),
"<post-bash>",
Some(command),
)
.await
else {
return Ok(());
};
for verdict in evaluation.verdicts {
if !accepts_db_introspection(&verdict.via) {
continue;
}
let _ = daemon_v2(
&mati_root,
mati_core::mcp::protocol::Command::ConsultationHit(
mati_core::mcp::protocol::ConsultationHitInput {
key: verdict.requires_key,
capture_fingerprint: false,
actor: actor_scope.clone(),
session_id: session_id.map(str::to_string),
agent_id: agent_id.map(str::to_string),
decision_basis_hash: None,
source: Some(mati_core::store::ReceiptSource::DbIntrospection),
},
),
)
.await;
}
Ok(())
}
async fn record_wrapped_client_miss(input: &serde_json::Value, action: &decide::Action) {
let Ok(cwd) = std::env::current_dir() else {
return;
};
let repo_root = discover_repo_root(&cwd);
let root_for_slug = repo_root.as_deref().unwrap_or(&cwd);
let Ok(mati_root) = mati_root_for(root_for_slug) else {
return;
};
if !ensure_daemon(&mati_root).await {
return;
}
let session_id = input
.get("session_id")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty());
let wrapper = action.argv.first().map(String::as_str).unwrap_or("unknown");
tracing::warn!(
"post-bash: schema-introspection command led by '{wrapper}' did not \
classify as db_client — likely rewritten by another PreToolUse hook; \
its consultation receipt cannot mint (deadlock signature)"
);
let cmd =
mati_core::mcp::protocol::Command::SessionLog(mati_core::mcp::protocol::SessionLogInput {
event: mati_core::mcp::protocol::SessionEvent::WrappedDbClientMiss,
key: format!("enforcement:wrapped_client:{wrapper}"),
session_id: session_id.map(str::to_string),
actor: None,
decision_basis_hash: None,
});
let _ = daemon_v2(&mati_root, cmd).await;
}
pub(crate) fn receipt_actor(worktree: Option<&str>, agent_id: Option<&str>) -> Option<String> {
mati_core::store::session::combined_actor_scope(worktree, agent_id)
}
pub(crate) fn post_bash_succeeded(input: &serde_json::Value) -> bool {
let Some(response) = input.pointer("/tool_response") else {
tracing::warn!("Claude PostToolUse Bash payload is missing tool_response");
return false;
};
if response
.get("isError")
.or_else(|| response.get("is_error"))
.and_then(|value| value.as_bool())
.unwrap_or(false)
{
return false;
}
if response
.get("interrupted")
.and_then(|value| value.as_bool())
.unwrap_or(false)
{
return false;
}
for key in ["exit_code", "exitCode"] {
let Some(value) = response.get(key) else {
continue;
};
let Some(code) = value.as_i64() else {
tracing::warn!("Claude PostToolUse Bash payload has non-numeric {key}");
return false;
};
if code != 0 {
return false;
}
}
true
}
pub(crate) fn accepts_db_introspection(via: &[mati_core::store::ReceiptSource]) -> bool {
via.contains(&mati_core::store::ReceiptSource::DbIntrospection)
}
pub(crate) 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 worktree = mati_core::store::session::worktree_scope_tag(&cwd);
let actor_scope =
mati_core::store::session::combined_actor_scope(worktree.as_deref(), agent_id);
let consult_globs = consult_globset();
let mut denied: Vec<(String, String, decide::DenyOrigin)> = 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": actor_scope.clone() }),
)
.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 file_exists = file_exists_for_deleted_signal(&eval_data, raw, &cwd);
let mut adapter = process_eval_response(variant, &rel_path, &eval_data, file_exists);
apply_consult_mandate(
&mut adapter,
variant,
&rel_path,
consulted_flag(&eval_data, true),
consult_globs.as_ref(),
);
let canon_rel = canonical_rel_path(raw, &cwd, repo_root.as_deref(), &rel_path);
for policy_path in std::iter::once(rel_path.as_str()).chain(canon_rel.as_deref()) {
if matches!(adapter.decision, Decision::Deny { .. }) {
break;
}
let action = decide::normalize_action(None, Some(policy_path));
if let Some((policy_decision, policy_events)) = evaluate_governed_policy(
Some(&mati_root),
&action,
"path",
actor_scope.as_deref(),
"<apply-patch-policy>",
None,
PolicyEvaluationOptions {
introspection_exemption: policy_block_exempt(variant, input),
codex_agent: true,
},
)
.await
{
merge_policy_result(&mut adapter, variant, policy_decision, policy_events);
}
}
if !matches!(adapter.decision, Decision::Deny { .. }) {
if let Some(canon_rel) = canon_rel.as_deref() {
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": actor_scope.clone() }),
)
.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 canon_file_exists =
file_exists_for_deleted_signal(&canon_eval, canon_rel, &cwd);
let mut canon_adapter =
process_eval_response(variant, canon_rel, &canon_eval, canon_file_exists);
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,
reason,
origin,
} = &adapter.decision
{
denied.push((denied_key.clone(), reason.clone(), *origin));
}
events.extend(adapter.events);
}
if denied.is_empty() {
emit_allow(variant);
fire_events(
&mati_root,
&events,
None,
agent_id,
actor_scope.as_deref(),
None,
)
.await;
return Ok(());
}
let (policy_denies, file_denies): (Vec<_>, Vec<_>) = denied
.iter()
.partition(|(_, _, origin)| matches!(origin, decide::DenyOrigin::Policy));
let mut lines: Vec<String> = policy_denies
.iter()
.map(|(_, reason, _)| reason.clone())
.collect();
if file_denies.len() == 1 {
lines.push(format!(
"mati: call mem_get(\"{}\") before editing",
file_denies[0].0
));
} else if file_denies.len() > 1 {
lines.push(format!(
"mati: consult these files before editing — call mem_get for each: {}",
file_denies
.iter()
.map(|(key, _, _)| key.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
let msg = lines.join("\n");
eprintln!("{msg}");
let _ = std::io::Write::flush(&mut std::io::stderr());
fire_events(
&mati_root,
&events,
None,
agent_id,
actor_scope.as_deref(),
None,
)
.await;
std::process::exit(2);
}
#[cfg(test)]
mod post_task_tests {
use super::*;
use serde_json::json;
fn nested_payload() -> serde_json::Value {
json!({
"hook_event_name": "PostToolUse",
"tool_name": "Agent",
"agent_id": "a55cfb5149181a437",
"session_id": "2e97c8b3-d0a5-4bdf-9570-095ef9631b74",
"tool_response": {
"status": "completed",
"agentId": "a81912b553cdfcfac",
"agentType": "general-purpose",
"content": [{"type": "text", "text": "done"}]
}
})
}
#[test]
fn nested_spawn_parses_both_ends() {
let (child, parent, session, agent_type) = parse_nested_spawn(&nested_payload()).unwrap();
assert_eq!(child, "a81912b553cdfcfac");
assert_eq!(parent, "a55cfb5149181a437");
assert_eq!(
session.as_deref(),
Some("2e97c8b3-d0a5-4bdf-9570-095ef9631b74")
);
assert_eq!(agent_type.as_deref(), Some("general-purpose"));
}
#[test]
fn root_spawn_is_not_an_edge() {
let mut p = nested_payload();
p.as_object_mut().unwrap().remove("agent_id");
assert!(parse_nested_spawn(&p).is_none());
}
#[test]
fn empty_spawner_is_not_an_edge() {
let mut p = nested_payload();
p["agent_id"] = json!("");
assert!(parse_nested_spawn(&p).is_none());
}
#[test]
fn incomplete_spawn_is_skipped() {
let mut p = nested_payload();
p["tool_response"]["status"] = json!("in_progress");
assert!(parse_nested_spawn(&p).is_none());
}
#[test]
fn errored_spawn_is_skipped() {
let mut p = nested_payload();
p["tool_response"]["isError"] = json!(true);
assert!(parse_nested_spawn(&p).is_none());
}
#[test]
fn missing_child_id_is_skipped() {
let mut p = nested_payload();
p["tool_response"]
.as_object_mut()
.unwrap()
.remove("agentId");
assert!(parse_nested_spawn(&p).is_none());
}
#[test]
fn absent_session_and_type_are_none_not_error() {
let mut p = nested_payload();
p.as_object_mut().unwrap().remove("session_id");
p["tool_response"]
.as_object_mut()
.unwrap()
.remove("agentType");
let (child, parent, session, agent_type) = parse_nested_spawn(&p).unwrap();
assert_eq!(child, "a81912b553cdfcfac");
assert_eq!(parent, "a55cfb5149181a437");
assert!(session.is_none());
assert!(agent_type.is_none());
}
}