use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HookVariant {
#[value(name = "claude-config-change")]
ClaudeConfigChange,
ClaudePreRead,
ClaudePreEdit,
ClaudePreBash,
CodexPreBash,
CodexPostBash,
CodexPreApplyPatch,
#[value(name = "claude-post-memget")]
ClaudePostMemGet,
#[value(name = "claude-post-bash")]
ClaudePostBash,
#[value(name = "claude-post-task")]
ClaudePostTask,
#[value(name = "claude-instructions-loaded")]
ClaudeInstructionsLoaded,
#[value(name = "claude-file-changed")]
ClaudeFileChanged,
}
#[derive(Args, Debug)]
pub struct HookDecideArgs {
#[arg(value_enum)]
pub variant: HookVariant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookRunOutcome {
Continue,
Terminate(i32),
}
const HOOK_DEADLINE_MS: u64 = 2500;
pub async fn run(args: HookDecideArgs) -> Result<HookRunOutcome> {
let variant = args.variant;
match tokio::time::timeout(Duration::from_millis(HOOK_DEADLINE_MS), run_inner(args)).await {
Ok(inner_result) => inner_result.map(|()| HookRunOutcome::Continue),
Err(_elapsed) => {
log_fail_open("<unknown>", "hook process exceeded internal deadline");
emit_allow(variant);
Ok(HookRunOutcome::Terminate(0))
}
}
}
async fn run_inner(args: HookDecideArgs) -> Result<()> {
let mut input_str = String::new();
if let Err(error) =
tokio::io::AsyncReadExt::read_to_string(&mut tokio::io::stdin(), &mut input_str).await
{
if args.variant == HookVariant::ClaudeInstructionsLoaded {
log_fail_open_named(
"instructions-loaded",
"<ambient-context>",
&format!("could not read hook payload: {error}"),
);
return Ok(());
}
if args.variant == HookVariant::ClaudeFileChanged {
log_fail_open_named(
"file-changed",
"<watched-file>",
&format!("could not read hook payload: {error}"),
);
return Ok(());
}
return Err(error.into());
}
let input: serde_json::Value =
serde_json::from_str(&input_str).unwrap_or(serde_json::Value::Null);
if args.variant == HookVariant::ClaudeInstructionsLoaded {
return run_instructions_loaded(&input).await;
}
if args.variant == HookVariant::ClaudeFileChanged {
return run_file_changed(&input).await;
}
if args.variant == HookVariant::CodexPreApplyPatch {
return run_apply_patch(&input).await;
}
if args.variant == HookVariant::ClaudePostMemGet {
return run_post_memget(&input).await;
}
if args.variant == HookVariant::ClaudePostBash {
return run_post_bash_introspection(&input).await;
}
if args.variant == HookVariant::ClaudePostTask {
return run_post_task(&input).await;
}
if args.variant == HookVariant::ClaudeConfigChange {
return run_config_change(&input).await;
}
let agent_id = input
.get("agent_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
let bash_action = if matches!(
args.variant,
HookVariant::ClaudePreBash | HookVariant::CodexPreBash
) {
input
.pointer("/tool_input/command")
.and_then(|v| v.as_str())
.map(|command| decide::normalize_action(Some(command), None))
} else {
None
};
let raw_command = input
.pointer("/tool_input/command")
.and_then(|value| value.as_str());
let raw_path = match extract_path(&input, args.variant) {
Some(p) => p,
None => {
if let Some(action) = bash_action
.as_ref()
.filter(|action| action.tool == "db_client" || action.tool == "unknown")
{
return run_fileless_policy(&input, args.variant, action, agent_id).await;
}
emit_allow(args.variant);
return Ok(());
}
};
let Ok(cwd) = std::env::current_dir() else {
return Ok(());
};
let repo_ident = mati_core::store::RepoIdent::discover(&cwd);
let repo_root = discover_repo_root_for(&repo_ident);
let repo_root_str = repo_root.as_ref().and_then(|p| p.to_str());
let worktree = mati_core::store::session::worktree_scope_tag_for(&repo_ident);
let actor_scope =
mati_core::store::session::combined_actor_scope(worktree.as_deref(), agent_id);
let rel_path = decide::normalize_path(&raw_path, repo_root_str);
let mati_root = match mati_root_for_ident(&repo_ident, &cwd) {
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": actor_scope.clone(),
}),
)
.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 file_exists = file_exists_for_deleted_signal(&eval_data, &raw_path, &cwd);
let mut adapter = process_eval_response(args.variant, &rel_path, &eval_data, file_exists);
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, None) {
EvalDataCheck::FailOpen(reason) => Some(reason),
EvalDataCheck::Ok(_) => None,
};
let canon_rel = canonical_rel_path(&raw_path, &cwd, repo_root.as_deref(), &rel_path);
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": include_recent,
"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(args.variant, canon_rel, &canon_eval, canon_file_exists);
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": actor_scope.clone(),
}),
)
.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 extra_file_exists =
file_exists_for_deleted_signal(&extra_eval, &extra_raw, &cwd);
let mut extra_adapter = process_eval_response(
args.variant,
&extra_rel,
&extra_eval,
extra_file_exists,
);
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 matches!(
args.variant,
HookVariant::ClaudePreBash | HookVariant::CodexPreBash | HookVariant::ClaudePreEdit
) && !matches!(adapter.decision, Decision::Deny { .. })
{
let policy_actions: Vec<decide::Action> = match args.variant {
HookVariant::ClaudePreBash | HookVariant::CodexPreBash => bash_action
.as_ref()
.cloned()
.map(|mut action| {
let lexical_files: Vec<String> = action
.files
.iter()
.take(decide::MAX_APPLY_PATCH_FILES)
.cloned()
.collect();
for lexical in lexical_files {
if let Some(canonical) =
canonical_rel_path(&lexical, &cwd, repo_root.as_deref(), &lexical)
{
if !action.files.iter().any(|path| path == &canonical) {
action.files.push(canonical);
}
}
}
action
})
.into_iter()
.collect(),
HookVariant::ClaudePreEdit => std::iter::once(rel_path.as_str())
.chain(canon_rel.as_deref())
.map(|path| decide::normalize_action(None, Some(path)))
.collect(),
_ => Vec::new(),
};
for action in &policy_actions {
if matches!(adapter.decision, Decision::Deny { .. }) {
break;
}
let governed_tool = if action.tool == "path" {
"path"
} else {
"db_client"
};
if let Some((policy_decision, policy_events)) = evaluate_governed_policy(
Some(&mati_root),
action,
governed_tool,
actor_scope.as_deref(),
"<policy>",
raw_command,
PolicyEvaluationOptions {
introspection_exemption: policy_block_exempt(args.variant, &input),
codex_agent: matches!(
args.variant,
HookVariant::CodexPreBash | HookVariant::CodexPreApplyPatch
),
},
)
.await
{
merge_policy_result(&mut adapter, args.variant, policy_decision, policy_events);
}
}
}
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,
actor_scope.as_deref(),
adapter.basis_hash.as_deref(),
)
.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(())
}
async fn run_instructions_loaded(input: &serde_json::Value) -> Result<()> {
let Some(payload) = decide::parse_instructions_loaded(input) else {
log_fail_open_named(
"instructions-loaded",
"<ambient-context>",
"payload did not match the InstructionsLoaded contract",
);
return Ok(());
};
let Ok(cwd) = std::env::current_dir() else {
return Ok(());
};
let repo_ident = mati_core::store::RepoIdent::discover(&cwd);
let mati_root = match mati_root_for_ident(&repo_ident, &cwd) {
Ok(root) => root,
Err(_) => {
log_fail_open_named(
"instructions-loaded",
&payload.file_path,
"cannot determine mati root",
);
return Ok(());
}
};
if !ensure_daemon(&mati_root).await {
log_fail_open_named(
"instructions-loaded",
&payload.file_path,
"daemon not running after auto-start",
);
return Ok(());
}
let command = mati_core::mcp::protocol::Command::InstructionsLoaded(
mati_core::mcp::protocol::InstructionsLoadedInput { payload },
);
if !matches!(
super::daemon::daemon_v2(&mati_root, command).await,
DaemonResult::Ok(_)
) {
log_fail_open_named(
"instructions-loaded",
"<ambient-context>",
"daemon rejected InstructionsLoaded recording",
);
}
Ok(())
}
pub(super) fn watched_rel_path(file_path: &str, repo_root: &Path) -> Option<String> {
let raw = Path::new(file_path);
let canonical = super::sandbox::canonicalize_lenient(repo_root).and_then(|root| {
let target = super::sandbox::canonicalize_lenient(raw)?;
let rel = target.strip_prefix(&root).ok()?;
Some(decide::normalize_path(rel.to_str()?, None))
});
if canonical.is_some() {
return canonical;
}
let rel = raw.strip_prefix(repo_root).ok()?;
Some(decide::normalize_path(rel.to_str()?, None))
}
async fn run_file_changed(input: &serde_json::Value) -> Result<()> {
let Some(payload) = decide::parse_file_changed(input) else {
log_fail_open_named(
"file-changed",
"<watched-file>",
"payload did not match the FileChanged contract",
);
return Ok(());
};
if !payload.drives_reparse() {
return Ok(());
}
let Ok(cwd) = std::env::current_dir() else {
return Ok(());
};
let repo_ident = mati_core::store::RepoIdent::discover(&cwd);
let repo_root = discover_repo_root_for(&repo_ident);
let root_for_slug = repo_root.as_deref().unwrap_or(&cwd);
let Some(rel_path) = watched_rel_path(&payload.file_path, root_for_slug) else {
return Ok(());
};
let mati_root = match mati_root_for_ident(&repo_ident, &cwd) {
Ok(root) => root,
Err(_) => {
log_fail_open_named("file-changed", &rel_path, "cannot determine mati root");
return Ok(());
}
};
if !ensure_daemon(&mati_root).await {
log_fail_open_named(
"file-changed",
&rel_path,
"daemon not running after auto-start",
);
return Ok(());
}
let command = mati_core::mcp::protocol::Command::FileReparse(
mati_core::mcp::protocol::FileReparseInput {
path: rel_path.clone(),
},
);
if !matches!(
super::daemon::daemon_v2(&mati_root, command).await,
DaemonResult::Ok(_)
) {
log_fail_open_named("file-changed", &rel_path, "daemon rejected the reparse");
}
Ok(())
}