Skip to main content

mati_core/hooks/
decide.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
7use std::collections::HashMap;
8
9// ── Types ───────────────────────────────────────────────────────────────────
10
11/// Which class of file-reading command was detected.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum CommandClass {
14    /// cat, less, head, tail, bat — file path is first non-flag arg.
15    CatLike,
16    /// grep, rg, sed, awk — file path is last non-flag arg.
17    GrepLike,
18}
19
20/// Semantic enforcement decision. Adapters map these to platform output.
21///
22/// `FailOpen` is intentionally absent — it's a daemon-readiness outcome
23/// handled by the adapter before calling `evaluate()`.
24#[derive(Debug, Clone, PartialEq)]
25pub enum Decision {
26    /// No enforcement needed — allow unconditionally.
27    Allow,
28    /// Confirmed gotcha, agent has NOT consulted — block the read.
29    Deny { file_key: String, reason: String },
30    /// Confirmed gotcha, agent already consulted — allow with awareness.
31    AlreadyConsulted { context: String },
32    /// Medium confidence (0.3–0.6), quality >= 0.4 — advisory context.
33    Advisory { context: String },
34    /// Record too stale to trust — adapter decides whether to inject warning.
35    Liability { staleness: f32, context: String },
36    /// Record fully excluded from enforcement.
37    Tombstone,
38    /// No file record exists in the store.
39    NoRecord,
40    /// Command is not a file-reading operation.
41    NotFileRead,
42}
43
44/// Side-effect events the adapter should fire after the decision.
45/// Each variant maps 1:1 to an existing daemon socket command.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum HookEvent {
48    /// Record accessed — daemon `log_hit`.
49    Hit { key: String },
50    /// No record found — daemon `log_miss`.
51    Miss { key: String },
52    /// Pre-read/pre-bash denied an unconsulted read — daemon `log_compliance_miss`.
53    BlockedUnconsultedRead { key: String },
54    /// Codex shell command blocked — daemon `log_codex_shell_miss`.
55    CodexShellBlocked { key: String },
56    /// Post-bash confirmed a consulted read — daemon `log_compliance_hit`.
57    ComplianceHit { key: String },
58}
59
60/// Input to the enforcement decision engine.
61pub struct EnforcementInput {
62    /// Repo-relative file path (e.g. `"src/main.rs"`).
63    pub rel_path: String,
64    /// File record JSON from `hook_evaluate`, or `None` if no record.
65    pub file_record: Option<serde_json::Value>,
66    /// Gotcha records keyed by gotcha key, from `hook_evaluate`.
67    pub gotcha_records: HashMap<String, serde_json::Value>,
68    /// Whether this file was already consulted via `mem_get` this session.
69    pub already_consulted: bool,
70}
71
72/// Result of `evaluate()`.
73pub struct EnforcementResult {
74    pub decision: Decision,
75    pub events: Vec<HookEvent>,
76}
77
78// ── Command Classification ──────────────────────────────────────────────────
79
80const CAT_LIKE: &[&str] = &["cat", "less", "head", "tail", "bat"];
81const GREP_LIKE: &[&str] = &["grep", "rg", "sed", "awk"];
82
83/// Returns true if `trimmed` starts with `word` followed by whitespace
84/// (or is exactly `word`). Prevents `"catch"` matching `"cat"`.
85fn matches_command_word(trimmed: &str, word: &str) -> bool {
86    if trimmed.len() < word.len() {
87        return false;
88    }
89    if !trimmed.starts_with(word) {
90        return false;
91    }
92    if trimmed.len() == word.len() {
93        return true;
94    }
95    trimmed.as_bytes()[word.len()].is_ascii_whitespace()
96}
97
98/// Classify a bash command string. Returns `None` for non-file-read commands.
99pub fn classify_command(cmd: &str) -> Option<CommandClass> {
100    let trimmed = cmd.trim_start();
101    for &word in CAT_LIKE {
102        if matches_command_word(trimmed, word) {
103            return Some(CommandClass::CatLike);
104        }
105    }
106    for &word in GREP_LIKE {
107        if matches_command_word(trimmed, word) {
108            return Some(CommandClass::GrepLike);
109        }
110    }
111    None
112}
113
114// ── File Path Extraction ────────────────────────────────────────────────────
115
116/// Extract the target file path from a classified command.
117///
118/// Replicates the bash hook heuristic:
119/// - CatLike: prefer first double-quoted path, fallback to first non-flag arg.
120/// - GrepLike: prefer last double-quoted path, fallback to last non-flag arg
121///   (strip surrounding single quotes).
122///
123/// Stops at pipe (`|`), semicolon (`;`), `&&`, `||`.
124pub fn extract_file_path(cmd: &str, class: CommandClass) -> Option<String> {
125    let trimmed = cmd.trim_start();
126
127    // Isolate the command portion before shell operators.
128    let cmd_part = split_at_shell_operator(trimmed);
129
130    match class {
131        CommandClass::CatLike => {
132            if let Some(q) = extract_first_double_quoted(cmd_part) {
133                return Some(q);
134            }
135            positional_arg(cmd_part, true)
136        }
137        CommandClass::GrepLike => {
138            if let Some(q) = extract_last_double_quoted(cmd_part) {
139                return Some(q);
140            }
141            positional_arg(cmd_part, false).map(|s| {
142                // Strip surrounding single quotes (grep patterns).
143                s.trim_start_matches('\'')
144                    .trim_end_matches('\'')
145                    .to_string()
146            })
147        }
148    }
149}
150
151/// Split at the first shell operator (`|`, `;`, `&&`, `||`), returning the
152/// portion before the operator.
153fn split_at_shell_operator(s: &str) -> &str {
154    let bytes = s.as_bytes();
155    let mut i = 0;
156    while i < bytes.len() {
157        match bytes[i] {
158            b'|' => {
159                // Could be `|` (pipe) or `||` — both mean stop.
160                return &s[..i];
161            }
162            b';' => return &s[..i],
163            b'&' if i + 1 < bytes.len() && bytes[i + 1] == b'&' => {
164                return &s[..i];
165            }
166            b'"' => {
167                // Skip quoted strings so we don't split on operators inside quotes.
168                i += 1;
169                while i < bytes.len() && bytes[i] != b'"' {
170                    i += 1;
171                }
172            }
173            b'\'' => {
174                i += 1;
175                while i < bytes.len() && bytes[i] != b'\'' {
176                    i += 1;
177                }
178            }
179            _ => {}
180        }
181        i += 1;
182    }
183    s
184}
185
186/// Extract the content of the first double-quoted string.
187fn extract_first_double_quoted(s: &str) -> Option<String> {
188    let start = s.find('"')? + 1;
189    let end = s[start..].find('"')? + start;
190    let inner = &s[start..end];
191    if inner.is_empty() {
192        None
193    } else {
194        Some(inner.to_string())
195    }
196}
197
198/// Extract the content of the last double-quoted string.
199fn extract_last_double_quoted(s: &str) -> Option<String> {
200    let mut last: Option<String> = None;
201    let mut pos = 0;
202    while pos < s.len() {
203        if let Some(offset) = s[pos..].find('"') {
204            let abs_start = pos + offset + 1;
205            if let Some(end_offset) = s[abs_start..].find('"') {
206                let inner = &s[abs_start..abs_start + end_offset];
207                if !inner.is_empty() {
208                    last = Some(inner.to_string());
209                }
210                pos = abs_start + end_offset + 1;
211            } else {
212                break;
213            }
214        } else {
215            break;
216        }
217    }
218    last
219}
220
221/// Extract first or last positional (non-flag) argument after the command word.
222fn positional_arg(cmd_part: &str, first: bool) -> Option<String> {
223    let words: Vec<&str> = cmd_part.split_whitespace().collect();
224    if words.len() < 2 {
225        return None;
226    }
227    let args: Vec<&str> = words[1..]
228        .iter()
229        .filter(|w| !w.starts_with('-'))
230        .copied()
231        .collect();
232    if args.is_empty() {
233        return None;
234    }
235    let picked = if first { args[0] } else { args[args.len() - 1] };
236    if picked.is_empty() {
237        None
238    } else {
239        Some(picked.to_string())
240    }
241}
242
243// ── apply_patch envelope parsing ────────────────────────────────────────────
244
245/// Maximum number of files a single `apply_patch` is gated against. A patch
246/// touching more than this is rare; the cap bounds per-file daemon round-trips
247/// so the hook stays well inside its deadline. Files beyond the cap are NOT
248/// gated (fail-open bias for the edit path) and the caller logs the truncation.
249pub const MAX_APPLY_PATCH_FILES: usize = 50;
250
251/// Extract the target file paths from a Codex `apply_patch` envelope.
252///
253/// Codex delivers the patch as a single string in `tool_input.command`:
254///
255/// ```text
256/// *** Begin Patch
257/// *** Update File: src/a.rs
258/// @@ ...
259///  context
260/// -old
261/// +new
262/// *** Add File: src/b.rs
263/// +contents
264/// *** Delete File: src/c.rs
265/// *** Move to: src/a_renamed.rs
266/// *** End Patch
267/// ```
268///
269/// Markers are matched only at column 0. Diff body lines are prefixed with a
270/// space/`+`/`-`/`@@`, so a content line that happens to contain
271/// `*** Update File:` (e.g. `+*** Update File: x`) does NOT collide with a real
272/// envelope marker. Returns paths in first-seen order with duplicates removed;
273/// the caller normalizes each. Add/Update/Delete and the rename source +
274/// destination are all included — `evaluate()` allows any path with no
275/// confirmed gotcha, so over-collecting is harmless.
276pub fn extract_apply_patch_files(patch: &str) -> Vec<String> {
277    const MARKERS: &[&str] = &[
278        "*** Update File: ",
279        "*** Add File: ",
280        "*** Delete File: ",
281        "*** Move to: ",
282    ];
283    let mut files: Vec<String> = Vec::new();
284    for line in patch.lines() {
285        for marker in MARKERS {
286            if let Some(rest) = line.strip_prefix(marker) {
287                let path = rest.trim();
288                if !path.is_empty() && !files.iter().any(|f| f == path) {
289                    files.push(path.to_string());
290                }
291                break;
292            }
293        }
294    }
295    files
296}
297
298// ── Path Normalization ──────────────────────────────────────────────────────
299
300/// Normalize `file_path` to a lexical repo-relative path.
301///
302/// - Strips `repo_root` prefix (with trailing `/`).
303/// - Collapses `.` and `..` components lexically (no filesystem access).
304/// - Does NOT resolve symlinks — memory keys are lexical paths.
305pub fn normalize_path(file_path: &str, repo_root: Option<&str>) -> String {
306    let stripped = match repo_root {
307        Some(root) => file_path
308            .strip_prefix(root)
309            .and_then(|s| s.strip_prefix('/'))
310            .unwrap_or(file_path),
311        None => file_path,
312    };
313
314    let mut components: Vec<&str> = Vec::new();
315    for part in stripped.split('/') {
316        match part {
317            "" | "." => continue,
318            ".." => {
319                if components.pop().is_none() {
320                    // Path escapes above root — out of scope.
321                    // Return as-is; it won't match any store key.
322                    return stripped.to_string();
323                }
324            }
325            c => components.push(c),
326        }
327    }
328
329    if components.is_empty() {
330        ".".to_string()
331    } else {
332        components.join("/")
333    }
334}
335
336// ── Core Decision Engine ────────────────────────────────────────────────────
337
338/// Evaluate the enforcement decision for a file access.
339///
340/// Pure function — all data comes from `input`, no I/O. The decision matrix
341/// matches ARCHITECTURE.md §10.1.
342pub fn evaluate(input: &EnforcementInput) -> EnforcementResult {
343    let file_key = format!("file:{}", input.rel_path);
344
345    // ── No record ───────────────────────────────────────────────────────
346    let file_record = match &input.file_record {
347        Some(r) if r.is_object() => r,
348        _ => {
349            return EnforcementResult {
350                decision: Decision::NoRecord,
351                events: vec![HookEvent::Miss { key: file_key }],
352            };
353        }
354    };
355
356    // ── Extract scores ──────────────────────────────────────────────────
357    let confidence = json_f32(file_record, "/confidence/value");
358    let quality = json_f32(file_record, "/quality/value");
359    let staleness = json_f32(file_record, "/staleness/value");
360    let staleness_tier = json_str(file_record, "/staleness/tier");
361
362    // ── Tombstone — fully excluded ──────────────────────────────────────
363    if staleness_tier == "tombstone" {
364        return EnforcementResult {
365            decision: Decision::Tombstone,
366            events: vec![],
367        };
368    }
369
370    // ── Liability — too stale to trust ──────────────────────────────────
371    if staleness_tier == "liability" {
372        return EnforcementResult {
373            decision: Decision::Liability {
374                staleness,
375                context: format!(
376                    "WARNING: STALE record for {} is a liability (staleness {:.2}). \
377                     Read the file directly — the cached record is too stale to trust.",
378                    input.rel_path, staleness
379                ),
380            },
381            events: vec![HookEvent::Hit { key: file_key }],
382        };
383    }
384
385    // ── Build context + check gotchas ───────────────────────────────────
386    let purpose = json_str(file_record, "/value");
387    let mut context_lines: Vec<String> = Vec::new();
388    if !purpose.is_empty() {
389        context_lines.push(format!("Purpose: {purpose}"));
390    }
391
392    let mut deny_signal = false;
393    let gotcha_keys = json_string_array(file_record, "/payload/gotcha_keys");
394
395    for gkey in &gotcha_keys {
396        let grec = match input.gotcha_records.get(gkey.as_str()) {
397            Some(r) if r.is_object() => r,
398            _ => continue,
399        };
400
401        let confirmed = json_bool(grec, "/payload/confirmed");
402        let gconfidence = json_f32(grec, "/confidence/value");
403        let gquality = json_f32(grec, "/quality/value");
404        let rule = json_str(grec, "/value");
405
406        // Only confirmed, injectable gotchas contribute to the injected
407        // context (P4: unconfirmed gotchas never influence injection). Gating
408        // the rule push here also bounds the payload — without it, every
409        // attached gotcha, including unconfirmed Layer-0 stubs, was dumped into
410        // the context (a single hotspot file with 1k+ stubs produced ~47 KB).
411        if confirmed && gconfidence >= 0.6 && gquality >= 0.4 {
412            deny_signal = true;
413            if !rule.is_empty() {
414                context_lines.push(format!("\u{26a0} {rule}"));
415            }
416        }
417    }
418
419    // Staleness warning for moderately stale records.
420    if staleness >= 0.4 {
421        context_lines.push(format!(
422            "Warning: record staleness {staleness:.2} — verify critical details."
423        ));
424    }
425
426    // Blast radius warning for high-impact files.
427    {
428        let blast_tier = json_str(file_record, "/payload/blast_radius/tier");
429        if blast_tier == "high" || blast_tier == "critical" {
430            let blast_direct = file_record
431                .pointer("/payload/blast_radius/direct")
432                .and_then(|v| v.as_u64())
433                .unwrap_or(0);
434            context_lines.push(format!(
435                "\u{26a0} Blast radius: {blast_direct} direct importers ({blast_tier}) — modify carefully"
436            ));
437        }
438    }
439
440    // ── Deny path ───────────────────────────────────────────────────────
441    if deny_signal {
442        if input.already_consulted {
443            let context = if context_lines.is_empty() {
444                format!(
445                    "Gotcha exists for {} — proceed with awareness",
446                    input.rel_path
447                )
448            } else {
449                context_lines.join("\n")
450            };
451            // AllowAfterReceipt enforcement event: the read is being allowed
452            // because a valid consultation receipt exists. ComplianceHit
453            // (SessionLog v2) triggers the AllowAfterReceipt record.
454            return EnforcementResult {
455                decision: Decision::AlreadyConsulted { context },
456                events: vec![HookEvent::ComplianceHit { key: file_key }],
457            };
458        }
459
460        let safe_path = input.rel_path.replace('\\', "\\\\").replace('"', "\\\"");
461        let staleness_note = if staleness >= 0.4 {
462            format!(" (staleness {staleness:.2} — verify critical details)")
463        } else {
464            String::new()
465        };
466
467        return EnforcementResult {
468            decision: Decision::Deny {
469                file_key: file_key.clone(),
470                reason: format!(
471                    "[mati] Confirmed gotcha on {safe_path} — \
472                     call mem_get(\"file:{safe_path}\") and read the record \
473                     before accessing this file.{staleness_note}"
474                ),
475            },
476            events: vec![HookEvent::BlockedUnconsultedRead { key: file_key }],
477        };
478    }
479
480    // ── Advisory path (medium confidence) ───────────────────────────────
481    if confidence >= 0.3 && quality >= 0.4 {
482        let context = if context_lines.is_empty() {
483            format!(
484                "Record exists for {} — confidence {confidence:.2}",
485                input.rel_path
486            )
487        } else {
488            context_lines.join("\n")
489        };
490        return EnforcementResult {
491            decision: Decision::Advisory { context },
492            events: vec![HookEvent::Hit { key: file_key }],
493        };
494    }
495
496    // ── Default: allow, no injection ────────────────────────────────────
497    EnforcementResult {
498        decision: Decision::Allow,
499        events: vec![],
500    }
501}
502
503// ── JSON helpers ────────────────────────────────────────────────────────────
504
505fn json_f32(val: &serde_json::Value, pointer: &str) -> f32 {
506    val.pointer(pointer)
507        .and_then(|v| v.as_f64())
508        .map(|f| f as f32)
509        .unwrap_or(0.0)
510}
511
512fn json_str(val: &serde_json::Value, pointer: &str) -> String {
513    val.pointer(pointer)
514        .and_then(|v| v.as_str())
515        .unwrap_or("")
516        .to_string()
517}
518
519fn json_bool(val: &serde_json::Value, pointer: &str) -> bool {
520    val.pointer(pointer)
521        .and_then(|v| v.as_bool())
522        .unwrap_or(false)
523}
524
525fn json_string_array(val: &serde_json::Value, pointer: &str) -> Vec<String> {
526    val.pointer(pointer)
527        .and_then(|v| v.as_array())
528        .map(|arr| {
529            arr.iter()
530                .filter_map(|v| v.as_str().map(|s| s.to_string()))
531                .collect()
532        })
533        .unwrap_or_default()
534}
535
536// ── Tests ───────────────────────────────────────────────────────────────────
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use serde_json::json;
542
543    // ── extract_apply_patch_files ────────────────────────────────────────
544
545    #[test]
546    fn apply_patch_single_update() {
547        let patch =
548            "*** Begin Patch\n*** Update File: src/main.rs\n@@\n-old\n+new\n*** End Patch\n";
549        assert_eq!(extract_apply_patch_files(patch), vec!["src/main.rs"]);
550    }
551
552    #[test]
553    fn apply_patch_multi_file_add_update_delete() {
554        let patch = "*** Begin Patch\n\
555            *** Update File: src/a.rs\n@@\n+x\n\
556            *** Add File: src/b.rs\n+y\n\
557            *** Delete File: src/c.rs\n\
558            *** End Patch\n";
559        assert_eq!(
560            extract_apply_patch_files(patch),
561            vec!["src/a.rs", "src/b.rs", "src/c.rs"]
562        );
563    }
564
565    #[test]
566    fn apply_patch_rename_includes_source_and_destination() {
567        let patch =
568            "*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n+x\n*** End Patch\n";
569        assert_eq!(
570            extract_apply_patch_files(patch),
571            vec!["src/old.rs", "src/new.rs"]
572        );
573    }
574
575    #[test]
576    fn apply_patch_ignores_marker_inside_diff_body() {
577        // A diff line that ADDS text resembling a marker must NOT be parsed as
578        // an envelope marker: diff body lines are prefixed (+/-/space), so they
579        // never begin at column 0 with "*** ".
580        let patch = "*** Begin Patch\n\
581            *** Update File: src/real.rs\n@@\n\
582            +*** Update File: src/fake.rs\n\
583            + *** Add File: src/also_fake.rs\n\
584            *** End Patch\n";
585        assert_eq!(extract_apply_patch_files(patch), vec!["src/real.rs"]);
586    }
587
588    #[test]
589    fn apply_patch_dedups_repeated_path() {
590        let patch =
591            "*** Begin Patch\n*** Update File: src/a.rs\n*** Update File: src/a.rs\n*** End Patch\n";
592        assert_eq!(extract_apply_patch_files(patch), vec!["src/a.rs"]);
593    }
594
595    #[test]
596    fn apply_patch_empty_or_no_markers() {
597        assert!(extract_apply_patch_files("").is_empty());
598        assert!(extract_apply_patch_files("just some text\nno markers here").is_empty());
599        assert!(extract_apply_patch_files("*** Begin Patch\n*** End Patch\n").is_empty());
600    }
601
602    #[test]
603    fn apply_patch_trims_trailing_whitespace() {
604        let patch = "*** Update File: src/spaced.rs   \n";
605        assert_eq!(extract_apply_patch_files(patch), vec!["src/spaced.rs"]);
606    }
607
608    // ── classify_command ─────────────────────────────────────────────────
609
610    #[test]
611    fn classify_cat() {
612        assert_eq!(
613            classify_command("cat src/main.rs"),
614            Some(CommandClass::CatLike)
615        );
616    }
617
618    #[test]
619    fn classify_head_with_flag() {
620        assert_eq!(
621            classify_command("head -n 10 file.rs"),
622            Some(CommandClass::CatLike)
623        );
624    }
625
626    #[test]
627    fn classify_leading_whitespace() {
628        assert_eq!(classify_command("  cat file"), Some(CommandClass::CatLike));
629    }
630
631    #[test]
632    fn classify_less() {
633        assert_eq!(
634            classify_command("less README.md"),
635            Some(CommandClass::CatLike)
636        );
637    }
638
639    #[test]
640    fn classify_tail() {
641        assert_eq!(
642            classify_command("tail -f log.txt"),
643            Some(CommandClass::CatLike)
644        );
645    }
646
647    #[test]
648    fn classify_bat() {
649        assert_eq!(
650            classify_command("bat src/lib.rs"),
651            Some(CommandClass::CatLike)
652        );
653    }
654
655    #[test]
656    fn classify_grep() {
657        assert_eq!(
658            classify_command("grep -rn pattern src/"),
659            Some(CommandClass::GrepLike)
660        );
661    }
662
663    #[test]
664    fn classify_rg() {
665        assert_eq!(
666            classify_command("rg TODO src/"),
667            Some(CommandClass::GrepLike)
668        );
669    }
670
671    #[test]
672    fn classify_sed() {
673        assert_eq!(
674            classify_command("sed -i 's/a/b/' file.rs"),
675            Some(CommandClass::GrepLike)
676        );
677    }
678
679    #[test]
680    fn classify_awk() {
681        assert_eq!(
682            classify_command("awk '{print $1}' file.rs"),
683            Some(CommandClass::GrepLike)
684        );
685    }
686
687    #[test]
688    fn classify_ls_is_none() {
689        assert_eq!(classify_command("ls -la"), None);
690    }
691
692    #[test]
693    fn classify_cd_is_none() {
694        assert_eq!(classify_command("cd /tmp"), None);
695    }
696
697    #[test]
698    fn classify_catch_is_none() {
699        assert_eq!(classify_command("catch errors"), None);
700    }
701
702    #[test]
703    fn classify_catalog_is_none() {
704        assert_eq!(classify_command("catalog"), None);
705    }
706
707    #[test]
708    fn classify_grep_bare_is_none() {
709        // "grep" with no args — still classifies (extraction returns None later)
710        assert_eq!(classify_command("grep"), Some(CommandClass::GrepLike));
711    }
712
713    // ── extract_file_path ───────────────────────────────────────────────
714
715    #[test]
716    fn extract_cat_simple() {
717        assert_eq!(
718            extract_file_path("cat src/main.rs", CommandClass::CatLike),
719            Some("src/main.rs".into())
720        );
721    }
722
723    #[test]
724    fn extract_cat_with_flag() {
725        assert_eq!(
726            extract_file_path("cat -n src/main.rs", CommandClass::CatLike),
727            Some("src/main.rs".into())
728        );
729    }
730
731    #[test]
732    fn extract_cat_quoted_path() {
733        assert_eq!(
734            extract_file_path(r#"cat "path with spaces/file.rs""#, CommandClass::CatLike),
735            Some("path with spaces/file.rs".into())
736        );
737    }
738
739    #[test]
740    fn extract_cat_with_pipe() {
741        assert_eq!(
742            extract_file_path("cat file.rs | grep foo", CommandClass::CatLike),
743            Some("file.rs".into())
744        );
745    }
746
747    #[test]
748    fn extract_cat_with_semicolon() {
749        assert_eq!(
750            extract_file_path("cat file.rs; echo done", CommandClass::CatLike),
751            Some("file.rs".into())
752        );
753    }
754
755    #[test]
756    fn extract_cat_with_and() {
757        assert_eq!(
758            extract_file_path("cat file.rs && echo ok", CommandClass::CatLike),
759            Some("file.rs".into())
760        );
761    }
762
763    #[test]
764    fn extract_grep_last_arg() {
765        assert_eq!(
766            extract_file_path("grep -rn pattern src/main.rs", CommandClass::GrepLike),
767            Some("src/main.rs".into())
768        );
769    }
770
771    #[test]
772    fn extract_grep_quoted_file() {
773        assert_eq!(
774            extract_file_path(r#"grep pattern "src/main.rs""#, CommandClass::GrepLike),
775            Some("src/main.rs".into())
776        );
777    }
778
779    #[test]
780    fn extract_grep_strips_single_quotes() {
781        assert_eq!(
782            extract_file_path("grep 'pattern' file.rs", CommandClass::GrepLike),
783            Some("file.rs".into())
784        );
785    }
786
787    #[test]
788    fn extract_no_args() {
789        assert_eq!(extract_file_path("cat", CommandClass::CatLike), None);
790    }
791
792    #[test]
793    fn extract_only_flags() {
794        assert_eq!(extract_file_path("cat -n -v", CommandClass::CatLike), None);
795    }
796
797    // ── normalize_path ──────────────────────────────────────────────────
798
799    #[test]
800    fn normalize_strips_prefix() {
801        assert_eq!(
802            normalize_path("/home/user/project/src/main.rs", Some("/home/user/project")),
803            "src/main.rs"
804        );
805    }
806
807    #[test]
808    fn normalize_dot_slash() {
809        assert_eq!(normalize_path("./src/main.rs", None), "src/main.rs");
810    }
811
812    #[test]
813    fn normalize_dotdot() {
814        assert_eq!(normalize_path("src/../src/main.rs", None), "src/main.rs");
815    }
816
817    #[test]
818    fn normalize_already_relative() {
819        assert_eq!(normalize_path("src/main.rs", None), "src/main.rs");
820    }
821
822    #[test]
823    fn normalize_no_repo_root() {
824        assert_eq!(
825            normalize_path("/abs/path/file.rs", None),
826            "abs/path/file.rs"
827        );
828    }
829
830    #[test]
831    fn normalize_trailing_slash_root() {
832        // repo_root should not have trailing slash, but handle it gracefully.
833        assert_eq!(
834            normalize_path("/project/src/file.rs", Some("/project")),
835            "src/file.rs"
836        );
837    }
838
839    #[test]
840    fn normalize_leading_dotdot_returns_unchanged() {
841        // Path escaping above root is out-of-scope — return as-is.
842        assert_eq!(normalize_path("../other/file.rs", None), "../other/file.rs");
843    }
844
845    #[test]
846    fn normalize_deep_dotdot_escape_returns_unchanged() {
847        assert_eq!(normalize_path("foo/../../bar.rs", None), "foo/../../bar.rs");
848    }
849
850    #[test]
851    fn normalize_dotdot_within_scope_ok() {
852        // src/../lib/file.rs stays within the repo — collapses fine.
853        assert_eq!(normalize_path("src/../lib/file.rs", None), "lib/file.rs");
854    }
855
856    // ── evaluate ────────────────────────────────────────────────────────
857
858    fn make_file_record(
859        confidence: f32,
860        quality: f32,
861        staleness: f32,
862        staleness_tier: &str,
863        gotcha_keys: &[&str],
864    ) -> serde_json::Value {
865        json!({
866            "value": "Test file purpose",
867            "confidence": { "value": confidence },
868            "quality": { "value": quality },
869            "staleness": { "value": staleness, "tier": staleness_tier },
870            "payload": {
871                "gotcha_keys": gotcha_keys,
872            }
873        })
874    }
875
876    fn make_gotcha(confirmed: bool, confidence: f32, quality: f32) -> serde_json::Value {
877        json!({
878            "value": "Do not use unwrap here",
879            "confidence": { "value": confidence },
880            "quality": { "value": quality },
881            "payload": { "confirmed": confirmed }
882        })
883    }
884
885    #[test]
886    fn eval_no_record() {
887        let input = EnforcementInput {
888            rel_path: "src/main.rs".into(),
889            file_record: None,
890            gotcha_records: HashMap::new(),
891            already_consulted: false,
892        };
893        let result = evaluate(&input);
894        assert_eq!(result.decision, Decision::NoRecord);
895        assert_eq!(result.events.len(), 1);
896        assert!(matches!(&result.events[0], HookEvent::Miss { key } if key == "file:src/main.rs"));
897    }
898
899    #[test]
900    fn eval_tombstone() {
901        let input = EnforcementInput {
902            rel_path: "src/old.rs".into(),
903            file_record: Some(make_file_record(0.8, 0.5, 0.95, "tombstone", &[])),
904            gotcha_records: HashMap::new(),
905            already_consulted: false,
906        };
907        let result = evaluate(&input);
908        assert_eq!(result.decision, Decision::Tombstone);
909        assert!(result.events.is_empty());
910    }
911
912    #[test]
913    fn eval_liability() {
914        let input = EnforcementInput {
915            rel_path: "src/stale.rs".into(),
916            file_record: Some(make_file_record(0.8, 0.5, 0.85, "liability", &[])),
917            gotcha_records: HashMap::new(),
918            already_consulted: false,
919        };
920        let result = evaluate(&input);
921        assert!(
922            matches!(&result.decision, Decision::Liability { staleness, .. } if *staleness > 0.8)
923        );
924        assert_eq!(result.events.len(), 1);
925        assert!(matches!(&result.events[0], HookEvent::Hit { .. }));
926    }
927
928    #[test]
929    fn eval_confirmed_gotcha_denies() {
930        let mut gotchas = HashMap::new();
931        gotchas.insert("gotcha:test".to_string(), make_gotcha(true, 0.7, 0.5));
932
933        let input = EnforcementInput {
934            rel_path: "src/main.rs".into(),
935            file_record: Some(make_file_record(0.7, 0.5, 0.1, "fresh", &["gotcha:test"])),
936            gotcha_records: gotchas,
937            already_consulted: false,
938        };
939        let result = evaluate(&input);
940        assert!(matches!(&result.decision, Decision::Deny { .. }));
941        assert!(matches!(
942            &result.events[0],
943            HookEvent::BlockedUnconsultedRead { key } if key == "file:src/main.rs"
944        ));
945    }
946
947    #[test]
948    fn eval_unconfirmed_gotcha_allows() {
949        let mut gotchas = HashMap::new();
950        gotchas.insert("gotcha:test".to_string(), make_gotcha(false, 0.7, 0.5));
951
952        let input = EnforcementInput {
953            rel_path: "src/main.rs".into(),
954            file_record: Some(make_file_record(0.7, 0.5, 0.1, "fresh", &["gotcha:test"])),
955            gotcha_records: gotchas,
956            already_consulted: false,
957        };
958        let result = evaluate(&input);
959        // No deny signal — falls through to advisory (confidence 0.7 >= 0.3, quality 0.5 >= 0.4).
960        // P4: the unconfirmed gotcha's rule must NOT leak into the injected
961        // context — only confirmed gotchas contribute to injection.
962        match &result.decision {
963            Decision::Advisory { context } => assert!(
964                !context.contains("Do not use unwrap here"),
965                "unconfirmed gotcha rule leaked into injected context: {context:?}"
966            ),
967            other => panic!("expected Advisory, got {other:?}"),
968        }
969    }
970
971    #[test]
972    fn eval_low_confidence_gotcha_allows() {
973        let mut gotchas = HashMap::new();
974        gotchas.insert("gotcha:test".to_string(), make_gotcha(true, 0.4, 0.5));
975
976        let input = EnforcementInput {
977            rel_path: "src/main.rs".into(),
978            file_record: Some(make_file_record(0.7, 0.5, 0.1, "fresh", &["gotcha:test"])),
979            gotcha_records: gotchas,
980            already_consulted: false,
981        };
982        let result = evaluate(&input);
983        assert!(matches!(&result.decision, Decision::Advisory { .. }));
984    }
985
986    #[test]
987    fn eval_low_quality_gotcha_allows() {
988        let mut gotchas = HashMap::new();
989        gotchas.insert("gotcha:test".to_string(), make_gotcha(true, 0.7, 0.2));
990
991        let input = EnforcementInput {
992            rel_path: "src/main.rs".into(),
993            file_record: Some(make_file_record(0.7, 0.5, 0.1, "fresh", &["gotcha:test"])),
994            gotcha_records: gotchas,
995            already_consulted: false,
996        };
997        let result = evaluate(&input);
998        assert!(matches!(&result.decision, Decision::Advisory { .. }));
999    }
1000
1001    #[test]
1002    fn eval_consulted_downgrades_deny() {
1003        let mut gotchas = HashMap::new();
1004        gotchas.insert("gotcha:test".to_string(), make_gotcha(true, 0.7, 0.5));
1005
1006        let input = EnforcementInput {
1007            rel_path: "src/main.rs".into(),
1008            file_record: Some(make_file_record(0.7, 0.5, 0.1, "fresh", &["gotcha:test"])),
1009            gotcha_records: gotchas,
1010            already_consulted: true,
1011        };
1012        let result = evaluate(&input);
1013        assert!(matches!(
1014            &result.decision,
1015            Decision::AlreadyConsulted { .. }
1016        ));
1017        // AlreadyConsulted emits ComplianceHit so the v2 SessionLog dispatch
1018        // records an AllowAfterReceipt enforcement event (not a fresh receipt).
1019        assert!(matches!(&result.events[0], HookEvent::ComplianceHit { .. }));
1020    }
1021
1022    #[test]
1023    fn eval_medium_confidence_advisory() {
1024        let input = EnforcementInput {
1025            rel_path: "src/main.rs".into(),
1026            file_record: Some(make_file_record(0.45, 0.5, 0.1, "fresh", &[])),
1027            gotcha_records: HashMap::new(),
1028            already_consulted: false,
1029        };
1030        let result = evaluate(&input);
1031        assert!(matches!(&result.decision, Decision::Advisory { .. }));
1032        assert!(matches!(&result.events[0], HookEvent::Hit { .. }));
1033    }
1034
1035    #[test]
1036    fn eval_low_everything_allows() {
1037        let input = EnforcementInput {
1038            rel_path: "src/main.rs".into(),
1039            file_record: Some(make_file_record(0.1, 0.1, 0.1, "fresh", &[])),
1040            gotcha_records: HashMap::new(),
1041            already_consulted: false,
1042        };
1043        let result = evaluate(&input);
1044        assert_eq!(result.decision, Decision::Allow);
1045        assert!(result.events.is_empty());
1046    }
1047
1048    #[test]
1049    fn eval_staleness_warning_appended() {
1050        let input = EnforcementInput {
1051            rel_path: "src/main.rs".into(),
1052            file_record: Some(make_file_record(0.5, 0.5, 0.5, "stale", &[])),
1053            gotcha_records: HashMap::new(),
1054            already_consulted: false,
1055        };
1056        let result = evaluate(&input);
1057        if let Decision::Advisory { context } = &result.decision {
1058            assert!(context.contains("staleness 0.50"));
1059        } else {
1060            panic!("expected Advisory, got {:?}", result.decision);
1061        }
1062    }
1063
1064    #[test]
1065    fn eval_multiple_gotchas_one_deny() {
1066        let mut gotchas = HashMap::new();
1067        gotchas.insert("gotcha:safe".to_string(), make_gotcha(false, 0.7, 0.5));
1068        gotchas.insert("gotcha:danger".to_string(), make_gotcha(true, 0.8, 0.6));
1069
1070        let input = EnforcementInput {
1071            rel_path: "src/main.rs".into(),
1072            file_record: Some(make_file_record(
1073                0.7,
1074                0.5,
1075                0.1,
1076                "fresh",
1077                &["gotcha:safe", "gotcha:danger"],
1078            )),
1079            gotcha_records: gotchas,
1080            already_consulted: false,
1081        };
1082        let result = evaluate(&input);
1083        assert!(matches!(&result.decision, Decision::Deny { .. }));
1084    }
1085
1086    #[test]
1087    fn eval_deny_includes_staleness_note() {
1088        let mut gotchas = HashMap::new();
1089        gotchas.insert("gotcha:test".to_string(), make_gotcha(true, 0.7, 0.5));
1090
1091        let input = EnforcementInput {
1092            rel_path: "src/main.rs".into(),
1093            file_record: Some(make_file_record(0.7, 0.5, 0.5, "stale", &["gotcha:test"])),
1094            gotcha_records: gotchas,
1095            already_consulted: false,
1096        };
1097        let result = evaluate(&input);
1098        if let Decision::Deny { reason, .. } = &result.decision {
1099            assert!(reason.contains("staleness"));
1100        } else {
1101            panic!("expected Deny");
1102        }
1103    }
1104
1105    #[test]
1106    fn eval_invalid_json_allows() {
1107        let input = EnforcementInput {
1108            rel_path: "src/main.rs".into(),
1109            file_record: Some(json!("not an object")),
1110            gotcha_records: HashMap::new(),
1111            already_consulted: false,
1112        };
1113        let result = evaluate(&input);
1114        // Invalid record treated as no-record.
1115        assert_eq!(result.decision, Decision::NoRecord);
1116    }
1117
1118    #[test]
1119    fn eval_never_produces_fail_open() {
1120        // FailOpen is NOT in the Decision enum at all — this test documents the contract.
1121        // The enum has no FailOpen variant, so this is a compile-time guarantee.
1122        // This test verifies the doc comment claim by testing boundary cases.
1123        let cases: Vec<EnforcementInput> = vec![
1124            EnforcementInput {
1125                rel_path: "x".into(),
1126                file_record: None,
1127                gotcha_records: HashMap::new(),
1128                already_consulted: false,
1129            },
1130            EnforcementInput {
1131                rel_path: "x".into(),
1132                file_record: Some(json!(null)),
1133                gotcha_records: HashMap::new(),
1134                already_consulted: false,
1135            },
1136            EnforcementInput {
1137                rel_path: "x".into(),
1138                file_record: Some(json!({})),
1139                gotcha_records: HashMap::new(),
1140                already_consulted: false,
1141            },
1142        ];
1143        for input in cases {
1144            let result = evaluate(&input);
1145            // If Decision had a FailOpen variant, we'd match against it here.
1146            // Since it doesn't, this documents that the pure core never fails open.
1147            assert!(matches!(
1148                result.decision,
1149                Decision::Allow
1150                    | Decision::Deny { .. }
1151                    | Decision::AlreadyConsulted { .. }
1152                    | Decision::Advisory { .. }
1153                    | Decision::Liability { .. }
1154                    | Decision::Tombstone
1155                    | Decision::NoRecord
1156                    | Decision::NotFileRead
1157            ));
1158        }
1159    }
1160
1161    #[test]
1162    fn eval_context_includes_purpose_and_rules() {
1163        let mut gotchas = HashMap::new();
1164        gotchas.insert("gotcha:test".to_string(), make_gotcha(true, 0.7, 0.5));
1165
1166        let input = EnforcementInput {
1167            rel_path: "src/main.rs".into(),
1168            file_record: Some(make_file_record(0.7, 0.5, 0.1, "fresh", &["gotcha:test"])),
1169            gotcha_records: gotchas,
1170            already_consulted: true,
1171        };
1172        let result = evaluate(&input);
1173        if let Decision::AlreadyConsulted { context } = &result.decision {
1174            assert!(context.contains("Purpose: Test file purpose"));
1175            assert!(context.contains("Do not use unwrap here"));
1176        } else {
1177            panic!("expected AlreadyConsulted, got {:?}", result.decision);
1178        }
1179    }
1180
1181    #[test]
1182    fn eval_blast_radius_warning_for_critical_file() {
1183        let mut file_record = make_file_record(0.5, 0.5, 0.1, "fresh", &[]);
1184        // Inject blast_radius into payload
1185        file_record
1186            .as_object_mut()
1187            .unwrap()
1188            .get_mut("payload")
1189            .unwrap()
1190            .as_object_mut()
1191            .unwrap()
1192            .insert(
1193                "blast_radius".into(),
1194                json!({ "direct": 45, "transitive": 10, "score": 48.0, "tier": "critical" }),
1195            );
1196
1197        let input = EnforcementInput {
1198            rel_path: "src/core.rs".into(),
1199            file_record: Some(file_record),
1200            gotcha_records: HashMap::new(),
1201            already_consulted: false,
1202        };
1203        let result = evaluate(&input);
1204        if let Decision::Advisory { context } = &result.decision {
1205            assert!(
1206                context.contains("Blast radius"),
1207                "advisory context must include blast radius warning, got: {context}"
1208            );
1209            assert!(context.contains("45"), "warning must include direct count");
1210            assert!(context.contains("critical"), "warning must include tier");
1211        } else {
1212            panic!("expected Advisory, got {:?}", result.decision);
1213        }
1214    }
1215
1216    #[test]
1217    fn eval_no_blast_warning_for_low_file() {
1218        let mut file_record = make_file_record(0.5, 0.5, 0.1, "fresh", &[]);
1219        file_record
1220            .as_object_mut()
1221            .unwrap()
1222            .get_mut("payload")
1223            .unwrap()
1224            .as_object_mut()
1225            .unwrap()
1226            .insert(
1227                "blast_radius".into(),
1228                json!({ "direct": 2, "transitive": 0, "score": 2.0, "tier": "low" }),
1229            );
1230
1231        let input = EnforcementInput {
1232            rel_path: "src/leaf.rs".into(),
1233            file_record: Some(file_record),
1234            gotcha_records: HashMap::new(),
1235            already_consulted: false,
1236        };
1237        let result = evaluate(&input);
1238        if let Decision::Advisory { context } = &result.decision {
1239            assert!(
1240                !context.contains("Blast radius"),
1241                "low blast radius file should NOT have warning, got: {context}"
1242            );
1243        } else {
1244            panic!("expected Advisory, got {:?}", result.decision);
1245        }
1246    }
1247}