Skip to main content

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}