Skip to main content

edda_postmortem/
hooks.rs

1//! Rule execution via hooks.
2//!
3//! Rules are NOT context injections (50-70% compliance). They are hooks
4//! that block or warn (100% compliance). This module provides the
5//! enforcement interface for the bridge hook system.
6//!
7//! Execution model:
8//! - PreCommit hook reads rules store -> executes matching checks
9//! - Each active rule's trigger is matched against the current context
10//! - Matching rules produce either a block (exit 1) or warn (stderr)
11
12use crate::rules::{RuleCategory, RulesStore};
13use serde::{Deserialize, Serialize};
14
15/// Action to take when a rule matches.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Enforcement {
18    /// Block the operation with a message.
19    Block(String),
20    /// Warn but allow the operation.
21    Warn(String),
22}
23
24/// Context for evaluating rules against current operation.
25#[derive(Debug, Clone, Default)]
26pub struct HookContext {
27    /// Which hook event is firing (e.g., "PreToolUse", "PostToolUse").
28    pub hook_event: String,
29    /// Tool being used (e.g., "Bash", "Write", "Edit").
30    pub tool_name: String,
31    /// Files being modified in this operation.
32    pub files_touched: Vec<String>,
33    /// Current working directory.
34    pub cwd: String,
35    /// The command about to run (Bash tool_input.command), when available.
36    pub command: Option<String>,
37}
38
39/// Result of evaluating all active rules against a hook context.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct EvaluationResult {
42    pub rules_checked: usize,
43    pub rules_matched: usize,
44    pub matched_rule_ids: Vec<String>,
45    pub enforcements: Vec<EnforcementRecord>,
46}
47
48/// Record of a single rule enforcement.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct EnforcementRecord {
51    pub rule_id: String,
52    pub trigger: String,
53    pub action: String,
54    pub category: String,
55}
56
57/// Map hook event to the relevant rule categories.
58fn relevant_categories(hook_event: &str) -> Vec<RuleCategory> {
59    match hook_event {
60        "PreToolUse" => vec![
61            RuleCategory::PreCommit,
62            RuleCategory::CodePattern,
63            RuleCategory::Workflow,
64        ],
65        "PostToolUse" => vec![RuleCategory::CodePattern, RuleCategory::Workflow],
66        _ => vec![RuleCategory::Workflow],
67    }
68}
69
70/// Evaluate all active rules against the current hook context.
71///
72/// Returns matched rules and their enforcement actions. The caller
73/// (bridge dispatch) decides whether to block or warn based on results.
74pub fn evaluate_rules(store: &RulesStore, ctx: &HookContext) -> EvaluationResult {
75    let active = store.active_rules();
76    let categories = relevant_categories(&ctx.hook_event);
77    let mut matched_ids = Vec::new();
78    let mut enforcements = Vec::new();
79
80    for rule in &active {
81        // Filter by category relevance
82        if !categories.contains(&rule.category) {
83            continue;
84        }
85
86        // Match trigger against context
87        if matches_trigger(&rule.trigger, ctx) {
88            matched_ids.push(rule.id.clone());
89            enforcements.push(EnforcementRecord {
90                rule_id: rule.id.clone(),
91                trigger: rule.trigger.clone(),
92                action: rule.action.clone(),
93                category: rule.category.to_string(),
94            });
95        }
96    }
97
98    EvaluationResult {
99        rules_checked: active.len(),
100        rules_matched: matched_ids.len(),
101        matched_rule_ids: matched_ids,
102        enforcements,
103    }
104}
105
106/// Record hits for all matched rules (updates last_hit and hit count).
107pub fn record_matched_hits(store: &mut RulesStore, matched_ids: &[String]) {
108    for id in matched_ids {
109        if let Some(rule) = store.get_mut(id) {
110            rule.record_hit();
111        }
112    }
113}
114
115/// Record shows for matched rules: increments the show counter WITHOUT
116/// updating `last_hit`, promoting Proposed rules, or reactivating Dormant/
117/// Settled ones (GH-813: PreToolUse matches on every Bash call must not
118/// reset the rule's decay TTL).
119pub fn record_matched_shows(store: &mut RulesStore, matched_ids: &[String]) {
120    store.record_matched_shows(matched_ids);
121}
122
123/// Format enforcement results as a warning message for the user.
124pub fn format_warnings(result: &EvaluationResult) -> Option<String> {
125    if result.enforcements.is_empty() {
126        return None;
127    }
128
129    let mut lines = vec!["[edda L3] Learned rules triggered:".to_string()];
130    for e in &result.enforcements {
131        lines.push(format!("  - {} -> {}", e.trigger, e.action));
132    }
133    Some(lines.join("\n"))
134}
135
136// -- Trigger matching --
137
138/// Check if a rule trigger matches the current hook context.
139///
140/// Trigger format:
141///   - `file_churn:<path>` -- matches if the path is in files_touched
142///   - `command_failure:<cmd>` -- matches if tool_name is "Bash"
143///   - `multi_agent_start` -- matches on SessionStart-like events
144///   - Plain text -- substring match against tool_name or files_touched
145fn matches_trigger(trigger: &str, ctx: &HookContext) -> bool {
146    if let Some(path) = trigger.strip_prefix("file_churn:") {
147        return ctx.files_touched.iter().any(|f| f.contains(path));
148    }
149
150    if let Some(cmd) = trigger.strip_prefix("command_failure:") {
151        // First-token keying (GH-813): match only when the previously failed
152        // command is the command word of a segment of the incoming Bash
153        // command (split on `;`, `&&`, `||`, `|`, newline). Matching every
154        // Bash call — or any whole-word occurrence — flooded hooks with
155        // irrelevant warnings AND record_hit() kept resetting the rule's
156        // TTL, so noise rules never decayed — a self-feeding loop.
157        // No command available → no match (silence over noise).
158        if ctx.tool_name != "Bash" {
159            return false;
160        }
161        let cmd = cmd.trim();
162        if !crate::rules::is_trackable_command(cmd) {
163            // Builtins/keywords/assignments never match, even if a legacy
164            // store still holds such a rule; the decay cycle revokes it.
165            return false;
166        }
167        return ctx.command.as_deref().is_some_and(|current| {
168            crate::rules::split_command_segments(current)
169                .iter()
170                .any(|segment| crate::rules::command_word(segment).as_deref() == Some(cmd))
171        });
172    }
173
174    if trigger == "multi_agent_start" {
175        return ctx.hook_event == "SessionStart";
176    }
177
178    // Fallback: substring match on tool name or files
179    if ctx.tool_name.contains(trigger) {
180        return true;
181    }
182    ctx.files_touched.iter().any(|f| f.contains(trigger))
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::rules::{Rule, RuleCategory, RuleStatus, RulesStore};
189
190    fn active_rule(trigger: &str, action: &str, category: RuleCategory) -> Rule {
191        Rule {
192            id: format!("rule_test_{}", trigger.replace(':', "_")),
193            trigger: trigger.to_string(),
194            action: action.to_string(),
195            anchor_file: None,
196            anchor_hash: None,
197            created: "2026-01-01T00:00:00Z".to_string(),
198            last_hit: "2026-01-01T00:00:00Z".to_string(),
199            hits: 2,
200            ttl_days: 30,
201            superseded_by: None,
202            status: RuleStatus::Active,
203            source_session: "test".to_string(),
204            source_event: None,
205            shows: 0,
206            revoked_reason: None,
207            category,
208        }
209    }
210
211    fn make_store(rules: Vec<Rule>) -> RulesStore {
212        RulesStore {
213            rules,
214            last_decay_run: None,
215        }
216    }
217
218    #[test]
219    fn file_churn_trigger_matches_touched_files() {
220        let store = make_store(vec![active_rule(
221            "file_churn:src/main.rs",
222            "Review carefully",
223            RuleCategory::PreCommit,
224        )]);
225
226        let ctx = HookContext {
227            hook_event: "PreToolUse".to_string(),
228            tool_name: "Write".to_string(),
229            files_touched: vec!["src/main.rs".to_string()],
230            cwd: "/project".to_string(),
231            command: None,
232        };
233
234        let result = evaluate_rules(&store, &ctx);
235        assert_eq!(result.rules_matched, 1);
236    }
237
238    #[test]
239    fn no_match_when_file_not_touched() {
240        let store = make_store(vec![active_rule(
241            "file_churn:src/main.rs",
242            "Review carefully",
243            RuleCategory::PreCommit,
244        )]);
245
246        let ctx = HookContext {
247            hook_event: "PreToolUse".to_string(),
248            tool_name: "Write".to_string(),
249            files_touched: vec!["src/lib.rs".to_string()],
250            cwd: "/project".to_string(),
251            command: None,
252        };
253
254        let result = evaluate_rules(&store, &ctx);
255        assert_eq!(result.rules_matched, 0);
256    }
257
258    #[test]
259    fn dormant_rules_not_evaluated() {
260        let mut rule = active_rule(
261            "file_churn:src/main.rs",
262            "Review carefully",
263            RuleCategory::PreCommit,
264        );
265        rule.status = RuleStatus::Dormant;
266        let store = make_store(vec![rule]);
267
268        let ctx = HookContext {
269            hook_event: "PreToolUse".to_string(),
270            tool_name: "Write".to_string(),
271            files_touched: vec!["src/main.rs".to_string()],
272            cwd: "/project".to_string(),
273            command: None,
274        };
275
276        let result = evaluate_rules(&store, &ctx);
277        assert_eq!(result.rules_matched, 0);
278    }
279
280    #[test]
281    fn command_failure_matches_only_same_command() {
282        let store = make_store(vec![active_rule(
283            "command_failure:python",
284            "Verify python is available",
285            RuleCategory::Workflow,
286        )]);
287
288        // Bash call containing the failed command → match
289        let hit_ctx = HookContext {
290            hook_event: "PreToolUse".to_string(),
291            tool_name: "Bash".to_string(),
292            files_touched: vec![],
293            cwd: "/project".to_string(),
294            command: Some("python scripts/run.py".to_string()),
295        };
296        assert_eq!(evaluate_rules(&store, &hit_ctx).rules_matched, 1);
297
298        // Unrelated Bash call → no match (this was the noise bug)
299        let miss_ctx = HookContext {
300            command: Some("git status".to_string()),
301            ..hit_ctx.clone()
302        };
303        assert_eq!(evaluate_rules(&store, &miss_ctx).rules_matched, 0);
304
305        // Substring-only occurrence inside another word → no match
306        let substr_ctx = HookContext {
307            command: Some("pythonic-helper --run".to_string()),
308            ..hit_ctx.clone()
309        };
310        assert_eq!(evaluate_rules(&store, &substr_ctx).rules_matched, 0);
311
312        // No command available → no match (silence over noise)
313        let none_ctx = HookContext {
314            command: None,
315            ..hit_ctx.clone()
316        };
317        assert_eq!(evaluate_rules(&store, &none_ctx).rules_matched, 0);
318
319        // Non-Bash tool → no match
320        let write_ctx = HookContext {
321            tool_name: "Write".to_string(),
322            ..hit_ctx
323        };
324        assert_eq!(evaluate_rules(&store, &write_ctx).rules_matched, 0);
325    }
326
327    #[test]
328    fn command_failure_keys_on_first_token_of_segments() {
329        let store = make_store(vec![active_rule(
330            "command_failure:python",
331            "Verify python is available",
332            RuleCategory::Workflow,
333        )]);
334
335        let ctx = HookContext {
336            hook_event: "PreToolUse".to_string(),
337            tool_name: "Bash".to_string(),
338            files_touched: vec![],
339            cwd: "/project".to_string(),
340            command: Some("python scripts/run.py".to_string()),
341        };
342        assert_eq!(evaluate_rules(&store, &ctx).rules_matched, 1);
343
344        // GH-813: `echo python` runs `echo`, not `python` — the failed
345        // command only keys when it is the command word of a segment.
346        let echo_ctx = HookContext {
347            command: Some("echo python".to_string()),
348            ..ctx.clone()
349        };
350        assert_eq!(evaluate_rules(&store, &echo_ctx).rules_matched, 0);
351
352        // Segment splitting on `&&`, `;`, `|`, and newline: the next
353        // segment's command word keys again.
354        let seg_ctx = HookContext {
355            command: Some("cd /tmp && python scripts/run.py".to_string()),
356            ..ctx.clone()
357        };
358        assert_eq!(evaluate_rules(&store, &seg_ctx).rules_matched, 1);
359
360        let pipe_ctx = HookContext {
361            command: Some("cat data.txt | python -\nprint('x')".to_string()),
362            ..ctx.clone()
363        };
364        assert_eq!(evaluate_rules(&store, &pipe_ctx).rules_matched, 1);
365
366        // Leading environment variable assignments are skipped, so the
367        // command word after them keys.
368        let env_ctx = HookContext {
369            command: Some("FOO=1 BAR=2 python scripts/run.py".to_string()),
370            ..ctx.clone()
371        };
372        assert_eq!(evaluate_rules(&store, &env_ctx).rules_matched, 1);
373    }
374
375    #[test]
376    fn command_failure_does_not_match_when_cmd_is_argument() {
377        let store = make_store(vec![active_rule(
378            "command_failure:python",
379            "Verify python is available",
380            RuleCategory::Workflow,
381        )]);
382
383        // GH-813: `python` appears only as an argument, never as a segment's
384        // command word → no match.
385        let ctx = HookContext {
386            hook_event: "PreToolUse".to_string(),
387            tool_name: "Bash".to_string(),
388            files_touched: vec![],
389            cwd: "/project".to_string(),
390            command: Some("echo python; grep python file.txt".to_string()),
391        };
392        assert_eq!(evaluate_rules(&store, &ctx).rules_matched, 0);
393    }
394
395    #[test]
396    fn command_failure_does_not_match_quoted_segment_content() {
397        let store = make_store(vec![active_rule(
398            "command_failure:python",
399            "Verify python is available",
400            RuleCategory::Workflow,
401        )]);
402
403        // GH-813: `python` inside a quoted argument is not a segment's
404        // command word — the only segment runs `printf` → no match.
405        let ctx = HookContext {
406            hook_event: "PreToolUse".to_string(),
407            tool_name: "Bash".to_string(),
408            files_touched: vec![],
409            cwd: "/project".to_string(),
410            command: Some("printf '%s' 'skip; python -V'".to_string()),
411        };
412        assert_eq!(evaluate_rules(&store, &ctx).rules_matched, 0);
413
414        // Quoted command word still keys: `"python" x.py` runs python.
415        let quoted_ctx = HookContext {
416            command: Some("\"python\" x.py".to_string()),
417            ..ctx
418        };
419        assert_eq!(evaluate_rules(&store, &quoted_ctx).rules_matched, 1);
420    }
421
422    #[test]
423    fn command_failure_builtin_rules_never_match() {
424        // Legacy stores may still hold rules for builtins/keywords/common
425        // utilities (GH-813: echo 1789, cd 1618 hits). They must not match.
426        let store = make_store(vec![
427            active_rule("command_failure:cd", "no", RuleCategory::Workflow),
428            active_rule("command_failure:echo", "no", RuleCategory::Workflow),
429            active_rule("command_failure:ls", "no", RuleCategory::Workflow),
430            active_rule("command_failure:grep", "no", RuleCategory::Workflow),
431        ]);
432        let ctx = HookContext {
433            hook_event: "PreToolUse".to_string(),
434            tool_name: "Bash".to_string(),
435            files_touched: vec![],
436            cwd: "/project".to_string(),
437            command: Some("cd /tmp; echo hi; ls -la | grep foo".to_string()),
438        };
439        assert_eq!(evaluate_rules(&store, &ctx).rules_matched, 0);
440    }
441
442    #[test]
443    fn command_failure_exact_command_word_matches_segment() {
444        let store = make_store(vec![active_rule(
445            "command_failure:git",
446            "Check git config",
447            RuleCategory::Workflow,
448        )]);
449        let ctx = HookContext {
450            hook_event: "PreToolUse".to_string(),
451            tool_name: "Bash".to_string(),
452            files_touched: vec![],
453            cwd: "/project".to_string(),
454            command: Some("cd /tmp; echo hi; git status".to_string()),
455        };
456        let result = evaluate_rules(&store, &ctx);
457        assert_eq!(result.rules_matched, 1);
458        assert_eq!(result.matched_rule_ids[0], "rule_test_command_failure_git");
459    }
460
461    #[test]
462    fn record_matched_shows_does_not_reset_ttl_or_status() {
463        let mut store = make_store(vec![active_rule(
464            "command_failure:python",
465            "Verify python is available",
466            RuleCategory::Workflow,
467        )]);
468        let last_hit = store.rules[0].last_hit.clone();
469        let hits = store.rules[0].hits;
470        let id = store.rules[0].id.clone();
471
472        record_matched_shows(&mut store, &["rule_missing".to_string(), id]);
473
474        let rule = &store.rules[0];
475        assert_eq!(rule.shows, 1);
476        assert_eq!(rule.hits, hits);
477        assert_eq!(rule.last_hit, last_hit);
478        assert_eq!(rule.status, RuleStatus::Active);
479    }
480
481    #[test]
482    fn format_warnings_empty_when_no_matches() {
483        let result = EvaluationResult {
484            rules_checked: 5,
485            rules_matched: 0,
486            matched_rule_ids: vec![],
487            enforcements: vec![],
488        };
489        assert!(format_warnings(&result).is_none());
490    }
491
492    #[test]
493    fn format_warnings_produces_output() {
494        let result = EvaluationResult {
495            rules_checked: 5,
496            rules_matched: 1,
497            matched_rule_ids: vec!["rule_1".to_string()],
498            enforcements: vec![EnforcementRecord {
499                rule_id: "rule_1".to_string(),
500                trigger: "file_churn:main.rs".to_string(),
501                action: "Review carefully".to_string(),
502                category: "pre_commit".to_string(),
503            }],
504        };
505        let warning = format_warnings(&result).unwrap();
506        assert!(warning.contains("Learned rules triggered"));
507        assert!(warning.contains("file_churn:main.rs"));
508    }
509}