mati_core/hooks/decide/mod.rs
1//! Shared enforcement core for `mati hook-decide`.
2//!
3//! Pure functions — no I/O, no daemon calls. Testable without a running daemon.
4//! Platform adapters in `cli::hook_decide` map these semantic outcomes to
5//! protocol-specific output (Claude JSON, Codex exit codes).
6
7mod classification;
8mod config_change;
9mod envelope;
10mod file_changed;
11mod instructions_loaded;
12mod json_helpers;
13mod path_extraction;
14mod path_normalize;
15
16#[cfg(test)]
17mod tests;
18
19#[cfg(fuzzing)]
20pub use classification::effective_command_for_fuzzing;
21pub use classification::{
22 classify_command, is_known_action_tool, is_schema_introspection, KNOWN_ACTION_TOOLS,
23};
24pub use config_change::{local_violations, project_violations, ConfigViolation, ExpectedFloor};
25pub use envelope::{extract_apply_patch_files, MAX_APPLY_PATCH_FILES};
26pub use file_changed::{parse_file_changed, FileChangedPayload};
27pub use instructions_loaded::{parse_instructions_loaded, InstructionsLoadedPayload};
28pub use json_helpers::has_file_deleted_signal;
29pub use path_extraction::{extract_file_path, extract_file_paths, normalize_action};
30pub use path_normalize::normalize_path;
31
32// Cross-submodule internals, not part of the public API: classification.rs
33// and path_extraction.rs each call a private helper the other defines, and
34// evaluate() below calls json_helpers.rs's payload readers directly.
35use classification::{
36 effective_command, ACTION_TOOL_DB_CLIENT, ACTION_TOOL_FILE_READ, ACTION_TOOL_PATH,
37};
38#[cfg(test)]
39use json_helpers::json_has_signal;
40use json_helpers::{any_qualifying_gotcha, json_bool, json_f32, json_str, json_string_array};
41use path_extraction::{shell_tokens, split_at_shell_operator};
42
43use std::collections::HashMap;
44
45use serde::{Deserialize, Serialize};
46
47// ── Types ───────────────────────────────────────────────────────────────────
48
49/// Which class of file-reading or path-mutating command was detected.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum CommandClass {
52 /// cat, less, head, tail, bat — file path is first non-flag arg.
53 CatLike,
54 /// grep, rg, sed, awk — file path is last non-flag arg.
55 GrepLike,
56 /// psql, mysql, redis-cli, mongosh, sqlite3, and other governed DB clients.
57 DbClientLike,
58 /// rm, mv, rmdir, shred — every positional is a target path. Normalizes to
59 /// `tool=path`, so a `target_path_glob` policy gates deletes and moves the
60 /// same way it gates edits.
61 PathMutating,
62}
63
64/// Normalized shape of an agent tool invocation for policy matching.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct Action {
67 /// Governable category, such as `db_client` or `path`.
68 pub tool: String,
69 /// Primary target path, when the invocation has one.
70 pub target_path: Option<String>,
71 /// Host extracted from a DB-client flag or environment assignment.
72 pub host: Option<String>,
73 /// Shell-normalized argument tokens.
74 pub argv: Vec<String>,
75 /// All normalized file targets in the invocation.
76 pub files: Vec<String>,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum ShadowOutcome {
82 Block,
83 Steer,
84}
85
86/// Semantic enforcement decision. Adapters map these to platform output.
87///
88/// `FailOpen` is intentionally absent — it's a daemon-readiness outcome
89/// handled by the adapter before calling `evaluate()`.
90#[derive(Debug, Clone, PartialEq)]
91pub enum Decision {
92 /// No enforcement needed — allow unconditionally.
93 Allow,
94 /// A gate blocked the action. `origin` says which gate, so the message
95 /// shape and the audit reason code are chosen from the decision rather than
96 /// re-derived by sniffing `file_key`'s namespace at each call site.
97 Deny {
98 file_key: String,
99 reason: String,
100 origin: DenyOrigin,
101 },
102 /// Confirmed gotcha, agent already consulted — allow with awareness.
103 AlreadyConsulted { context: String },
104 /// Medium confidence (0.3–0.6), quality >= 0.4 — advisory context.
105 Advisory { context: String },
106 /// Record too stale to trust — adapter decides whether to inject warning.
107 Liability { staleness: f32, context: String },
108 /// Record fully excluded from enforcement.
109 Tombstone,
110 /// No file record exists in the store.
111 NoRecord,
112 /// Command is not a file-reading operation.
113 NotFileRead,
114}
115
116/// Which gate produced a `Decision::Deny`.
117///
118/// Each origin already maps 1:1 to a distinct `decision_reason_code` in the
119/// enforcement log, so this is information the system requires downstream and
120/// used to drop between the decision and the message. Matching on it
121/// exhaustively means a new origin cannot silently inherit whatever branch its
122/// key prefix happens to land in.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum DenyOrigin {
125 /// Confirmed gotcha above the enforcement threshold. `file_key` is the
126 /// record to consult.
127 Gotcha,
128 /// Inherited signed-floor consult mandate. `file_key` is the mandated file.
129 ConsultMandate,
130 /// Local policy. `file_key` is the POLICY key, which is not consultable —
131 /// the consultable key lives in `reason`.
132 Policy,
133}
134
135impl DenyOrigin {
136 /// The event that records this deny in the enforcement log.
137 ///
138 /// Origin and event are chosen together at every deny site, and each event
139 /// maps to a distinct `decision_reason_code` downstream. Deriving the event
140 /// here means the agent-facing message and the audit chain cannot disagree
141 /// about what denied, and a new origin cannot be added without choosing one.
142 pub fn deny_event(self, key: String) -> HookEvent {
143 match self {
144 DenyOrigin::Gotcha => HookEvent::BlockedUnconsultedRead { key },
145 DenyOrigin::ConsultMandate => HookEvent::FloorConsultBlocked { key },
146 DenyOrigin::Policy => HookEvent::PolicyConsultBlocked { key },
147 }
148 }
149}
150
151/// Side-effect events the adapter should fire after the decision.
152/// Each variant maps 1:1 to an existing daemon socket command.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum HookEvent {
155 /// Record accessed — daemon `log_hit`.
156 Hit { key: String },
157 /// No record found — daemon `log_miss`.
158 Miss { key: String },
159 /// Pre-read/pre-bash denied an unconsulted read — daemon `log_compliance_miss`.
160 BlockedUnconsultedRead { key: String },
161 /// Codex shell command blocked — daemon `log_codex_shell_miss`.
162 CodexShellBlocked { key: String },
163 /// An unclassified command mentioned a literal from an active policy.
164 UnclassifiedPolicyLiteralBypass { key: String },
165 /// Post-bash confirmed a consulted read — daemon `log_compliance_hit`.
166 ComplianceHit { key: String },
167 /// Claude edit gate: edit DEFERRED because a recent consultation exists —
168 /// records `AllowAfterReceipt` with reason `edit_after_receipt` (Plane 2).
169 EditConsulted { key: String },
170 /// Claude edit gate: edit DENIED (no recent consult) — records `Deny` with
171 /// reason `edit_blocked_unconsulted` (Plane 2).
172 EditBlocked { key: String },
173 /// Enterprise floor mandate DENIED an unconsulted access to a consult-required path —
174 /// records `Deny` with reason `floor_consult_required`, distinct from a local-gotcha deny
175 /// so the audit/report can tell an org mandate from a repo rule.
176 FloorConsultBlocked { key: String },
177 /// Local policy denied an unconsulted governed action.
178 PolicyConsultBlocked { key: String },
179 /// Local policy allowed a governed action after its receipt was present.
180 PolicyConsulted { key: String },
181 /// A steer policy injected guidance without changing the action decision.
182 PolicySteered { key: String },
183 /// A shadow policy would have blocked, but never changes the decision.
184 PolicyShadowObserved {
185 key: String,
186 would: ShadowOutcome,
187 action: Option<Action>,
188 },
189 /// The `FileDeleted` bypass fired on a caller-confirmed deletion
190 /// (`file_exists: Some(false)`) that also suppressed a deny a qualifying
191 /// confirmed gotcha would otherwise have produced. Unlike the plain
192 /// `Miss` the bypass ordinarily emits, this reaches the hash-chained
193 /// enforcement log as `BypassDetected` — enforcement was suppressed, not
194 /// merely a cache miss.
195 TombstoneBypassedDeny { key: String },
196}
197
198/// The I/O-free subset of a daemon policy verdict needed by the hook adapter.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct PolicyVerdict {
201 pub key: String,
202 pub rule: String,
203 pub requires_key: String,
204 pub block: bool,
205 pub satisfied: bool,
206 pub stage: crate::store::PolicyStage,
207}
208
209/// Apply local policy verdicts with escalate-only semantics.
210///
211/// A non-strict block degrades to steering; a satisfied block records the
212/// receipt-backed allow. Existing decisions are never lowered by this layer.
213pub fn evaluate_policy_verdicts(verdicts: &[PolicyVerdict], strict: bool) -> EnforcementResult {
214 /// The first enforcing block of this pass, carried as the deny's own
215 /// fields instead of a `Decision`. A `Decision` here can hold a variant
216 /// the deny path cannot honor, and the only recovery from that in a hook
217 /// is a panic — which is neither a deny nor a recorded allow.
218 struct PolicyDeny {
219 key: String,
220 reason: String,
221 }
222
223 let mut context = Vec::new();
224 let mut events = Vec::new();
225 let mut denied: Option<PolicyDeny> = None;
226
227 for verdict in verdicts {
228 // The matcher already excludes Off; this defensive no-op prevents an
229 // unexpected Off verdict from being treated as a steer policy.
230 if matches!(verdict.stage, crate::store::PolicyStage::Off) {
231 continue;
232 }
233 if matches!(verdict.stage, crate::store::PolicyStage::Shadow) {
234 if !verdict.block {
235 events.push(HookEvent::PolicyShadowObserved {
236 key: verdict.key.clone(),
237 would: ShadowOutcome::Steer,
238 action: None,
239 });
240 } else if !verdict.satisfied {
241 events.push(HookEvent::PolicyShadowObserved {
242 key: verdict.key.clone(),
243 would: ShadowOutcome::Block,
244 action: None,
245 });
246 }
247 continue;
248 }
249 if verdict.block && !verdict.satisfied && strict {
250 denied.get_or_insert_with(|| PolicyDeny {
251 key: verdict.key.clone(),
252 reason: format!(
253 "mati: policy {} blocked this action. Consult first: mem_get(\"{}\")",
254 verdict.key, verdict.requires_key
255 ),
256 });
257 } else if verdict.block && verdict.satisfied {
258 events.push(HookEvent::PolicyConsulted {
259 key: verdict.key.clone(),
260 });
261 } else {
262 context.push(verdict.rule.clone());
263 events.push(HookEvent::PolicySteered {
264 key: verdict.key.clone(),
265 });
266 }
267 }
268
269 if let Some(deny) = denied {
270 // Keep shadow observations recorded in this same pass. A developer can
271 // stage one policy to shadow while another enforces, and discarding the
272 // observation because something else denied would make the rollout
273 // measurement silently under-report every action the two both matched.
274 //
275 // Enforcement-claiming events are dropped instead: PolicyConsulted
276 // would write an AllowAfterReceipt for an action that was in fact
277 // denied, which would falsify the audit chain. PolicySteered is also
278 // dropped because the action did not proceed with that context.
279 let mut events: Vec<HookEvent> = events
280 .into_iter()
281 .filter(|event| matches!(event, HookEvent::PolicyShadowObserved { .. }))
282 .collect();
283 events.push(DenyOrigin::Policy.deny_event(deny.key.clone()));
284 return EnforcementResult {
285 decision: Decision::Deny {
286 file_key: deny.key,
287 reason: deny.reason,
288 origin: DenyOrigin::Policy,
289 },
290 events,
291 };
292 }
293 if !context.is_empty() {
294 return EnforcementResult {
295 decision: Decision::Advisory {
296 context: context.join("\n"),
297 },
298 events,
299 };
300 }
301 EnforcementResult {
302 decision: Decision::Allow,
303 events,
304 }
305}
306
307/// Input to the enforcement decision engine.
308pub struct EnforcementInput {
309 /// Repo-relative file path (e.g. `"src/main.rs"`).
310 pub rel_path: String,
311 /// File record JSON from `hook_evaluate`, or `None` if no record.
312 pub file_record: Option<serde_json::Value>,
313 /// Gotcha records keyed by gotcha key, from `hook_evaluate`.
314 pub gotcha_records: HashMap<String, serde_json::Value>,
315 /// Whether this file was already consulted via `mem_get` this session.
316 pub already_consulted: bool,
317 /// Caller's own observation of whether `rel_path` exists on disk, checked
318 /// ONLY when the caller saw a `FileDeleted` staleness signal (that signal
319 /// is a stale snapshot — cleared only by the staleness sweep, so it can
320 /// outlive a delete-then-restore by many sessions). `None` means the
321 /// caller did not check (preserves prior behavior: the signal alone
322 /// bypasses enforcement). `Some(true)` means the path exists, so the
323 /// signal is stale and must NOT bypass enforcement. `Some(false)` means
324 /// the caller confirmed the path is really gone.
325 pub file_exists: Option<bool>,
326}
327
328/// Result of `evaluate()`.
329pub struct EnforcementResult {
330 pub decision: Decision,
331 pub events: Vec<HookEvent>,
332}
333
334// ── Core Decision Engine ────────────────────────────────────────────────────
335
336/// Evaluate the enforcement decision for a file access.
337///
338/// Pure function — all data comes from `input`, no I/O. The decision matrix
339/// matches ARCHITECTURE.md section 10.1.
340pub fn evaluate(input: &EnforcementInput) -> EnforcementResult {
341 let file_key = format!("file:{}", input.rel_path);
342
343 // ── No record ───────────────────────────────────────────────────────
344 let file_record = match &input.file_record {
345 Some(r) if r.is_object() => r,
346 _ => {
347 return EnforcementResult {
348 decision: Decision::NoRecord,
349 events: vec![HookEvent::Miss { key: file_key }],
350 };
351 }
352 };
353
354 // ── Extract scores ──────────────────────────────────────────────────
355 let confidence = json_f32(file_record, "/confidence/value");
356 let quality = json_f32(file_record, "/quality/value");
357 let staleness = json_f32(file_record, "/staleness/value");
358 let staleness_tier = json_str(file_record, "/staleness/tier");
359
360 // ── Enforcement-off: the source file is gone ─────────────────────────
361 // Keyed on the literal `FileDeleted` signal, not the tombstone tier.
362 // `semantic_factor` is stubbed at 0.0 (ARCHITECTURE.md section 17), so
363 // today only `FileDeleted` reaches tombstone — but once it's live, the
364 // other four factors alone can cross 0.9 through ordinary drift. Gating
365 // on the tier would silently reopen this bypass for a gotcha that never
366 // stopped being true; gating on the signal cannot.
367 //
368 // The signal itself is a stale snapshot: it is cleared only by the
369 // staleness sweep, which can be many sessions away from a given record's
370 // turn. `input.file_exists == Some(true)` is the caller's own fresher
371 // observation that the path is back — in that case fall through to the
372 // ordinary gotcha loop below instead of bypassing on stale information.
373 if has_file_deleted_signal(file_record) && input.file_exists != Some(true) {
374 // Behaviourally identical to `NoRecord` from the agent's point of
375 // view — allow unconditionally, nothing injected — so it shares
376 // `NoRecord`'s event, UNLESS the bypass is suppressing a real deny.
377 // `Hit` is wrong here: it mints a consultation receipt (see
378 // `HookEvent::Hit` doc comment and its Codex-path suppression in
379 // `cli::hook_decide::platform_events`), which would falsely mark
380 // this file "consulted" for a read that delivered no context, and
381 // could downgrade a real deny once staleness drops.
382 //
383 // Computed here, before the return, and used ONLY to pick the event
384 // — the decision below stays `Tombstone` (allow) either way. Denying
385 // instead would create an unclearable phantom: the file that would
386 // need denying does not exist, so the `mem_get` that clears a deny
387 // can never run against it.
388 let would_have_denied = input.file_exists == Some(false)
389 && any_qualifying_gotcha(
390 &json_string_array(file_record, "/payload/gotcha_keys"),
391 &input.gotcha_records,
392 );
393 let event = if would_have_denied {
394 HookEvent::TombstoneBypassedDeny { key: file_key }
395 } else {
396 HookEvent::Miss { key: file_key }
397 };
398 return EnforcementResult {
399 decision: Decision::Tombstone,
400 events: vec![event],
401 };
402 }
403
404 // ── Build context + check gotchas ───────────────────────────────────
405 // Staleness gates injection, not enforcement: a qualifying gotcha below
406 // must deny regardless of `staleness_tier`. The tier only decides what
407 // happens to the file record's own (possibly stale) purpose blurb once
408 // the gotcha loop below finds nothing to enforce.
409 let purpose = json_str(file_record, "/value");
410 let mut context_lines: Vec<String> = Vec::new();
411 if !purpose.is_empty() {
412 context_lines.push(format!("Purpose: {purpose}"));
413 }
414
415 let mut deny_signal = false;
416 let gotcha_keys = json_string_array(file_record, "/payload/gotcha_keys");
417
418 for gkey in &gotcha_keys {
419 let grec = match input.gotcha_records.get(gkey.as_str()) {
420 Some(r) if r.is_object() => r,
421 _ => continue,
422 };
423
424 let confirmed = json_bool(grec, "/payload/confirmed");
425 let gconfidence = json_f32(grec, "/confidence/value");
426 let gquality = json_f32(grec, "/quality/value");
427 let rule = json_str(grec, "/value");
428
429 // Only confirmed, injectable gotchas contribute to the injected
430 // context (P4: unconfirmed gotchas never influence injection). Gating
431 // the rule push here also bounds the payload — without it, every
432 // attached gotcha, including unconfirmed Layer-0 stubs, was dumped into
433 // the context (a single hotspot file with 1k+ stubs produced ~47 KB).
434 if confirmed && gconfidence >= 0.6 && gquality >= 0.4 {
435 deny_signal = true;
436 if !rule.is_empty() {
437 context_lines.push(format!("\u{26a0} {rule}"));
438 }
439 }
440 }
441
442 // Staleness warning for moderately stale records.
443 if staleness >= 0.4 {
444 context_lines.push(format!(
445 "Warning: record staleness {staleness:.2} — verify critical details."
446 ));
447 }
448
449 // Blast radius warning for high-impact files.
450 {
451 let blast_tier = json_str(file_record, "/payload/blast_radius/tier");
452 if blast_tier == "high" || blast_tier == "critical" {
453 let blast_direct = file_record
454 .pointer("/payload/blast_radius/direct")
455 .and_then(|v| v.as_u64())
456 .unwrap_or(0);
457 context_lines.push(format!(
458 "\u{26a0} Blast radius: {blast_direct} direct importers ({blast_tier}) — modify carefully"
459 ));
460 }
461 }
462
463 // ── Deny path ───────────────────────────────────────────────────────
464 if deny_signal {
465 if input.already_consulted {
466 let context = if context_lines.is_empty() {
467 format!(
468 "Gotcha exists for {} — proceed with awareness",
469 input.rel_path
470 )
471 } else {
472 context_lines.join("\n")
473 };
474 // AllowAfterReceipt enforcement event: the read is being allowed
475 // because a valid consultation receipt exists. ComplianceHit
476 // (SessionLog v2) triggers the AllowAfterReceipt record.
477 return EnforcementResult {
478 decision: Decision::AlreadyConsulted { context },
479 events: vec![HookEvent::ComplianceHit { key: file_key }],
480 };
481 }
482
483 // The reason is a SEMANTIC string — JSON escaping is the output
484 // layer's job (`escape_json_string` in the adapter). Pre-escaping
485 // here double-escapes: a path with a quote would render as `\"` in
486 // the message the agent sees.
487 let safe_path = &input.rel_path;
488 let staleness_note = if staleness >= 0.4 {
489 format!(" (staleness {staleness:.2} — verify critical details)")
490 } else {
491 String::new()
492 };
493
494 return EnforcementResult {
495 decision: Decision::Deny {
496 file_key: file_key.clone(),
497 reason: format!(
498 "[mati] Confirmed gotcha on {safe_path} — \
499 call mem_get(\"file:{safe_path}\") and read the record \
500 before accessing this file.{staleness_note}"
501 ),
502 origin: DenyOrigin::Gotcha,
503 },
504 events: vec![DenyOrigin::Gotcha.deny_event(file_key)],
505 };
506 }
507
508 // ── No qualifying gotcha: staleness now only gates injection ─────────
509 if staleness_tier == "tombstone" {
510 // `FileDeleted` already returned above. Reachable once
511 // `semantic_factor` is live (v0.2) and ordinary drift alone crosses
512 // 0.9. The purpose blurb is too degraded to trust at this tier, so
513 // it stays fully excluded — same as the pre-reorder Tombstone path,
514 // just no longer standing in front of gotcha enforcement.
515 return EnforcementResult {
516 decision: Decision::Tombstone,
517 events: vec![HookEvent::Miss { key: file_key }],
518 };
519 }
520
521 if staleness_tier == "liability" {
522 return EnforcementResult {
523 decision: Decision::Liability {
524 staleness,
525 context: format!(
526 "WARNING: STALE record for {} is a liability (staleness {:.2}). \
527 Read the file directly — the cached record is too stale to trust.",
528 input.rel_path, staleness
529 ),
530 },
531 events: vec![HookEvent::Hit { key: file_key }],
532 };
533 }
534
535 // ── Advisory path (medium confidence) ───────────────────────────────
536 if confidence >= 0.3 && quality >= 0.4 {
537 let context = if context_lines.is_empty() {
538 format!(
539 "Record exists for {} — confidence {confidence:.2}",
540 input.rel_path
541 )
542 } else {
543 context_lines.join("\n")
544 };
545 return EnforcementResult {
546 decision: Decision::Advisory { context },
547 events: vec![HookEvent::Hit { key: file_key }],
548 };
549 }
550
551 // ── Default: allow, no injection ────────────────────────────────────
552 EnforcementResult {
553 decision: Decision::Allow,
554 events: vec![],
555 }
556}