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/// Format enforcement results as a warning message for the user.
116pub fn format_warnings(result: &EvaluationResult) -> Option<String> {
117    if result.enforcements.is_empty() {
118        return None;
119    }
120
121    let mut lines = vec!["[edda L3] Learned rules triggered:".to_string()];
122    for e in &result.enforcements {
123        lines.push(format!("  - {} -> {}", e.trigger, e.action));
124    }
125    Some(lines.join("\n"))
126}
127
128// -- Trigger matching --
129
130/// Check if a rule trigger matches the current hook context.
131///
132/// Trigger format:
133///   - `file_churn:<path>` -- matches if the path is in files_touched
134///   - `command_failure:<cmd>` -- matches if tool_name is "Bash"
135///   - `multi_agent_start` -- matches on SessionStart-like events
136///   - Plain text -- substring match against tool_name or files_touched
137fn matches_trigger(trigger: &str, ctx: &HookContext) -> bool {
138    if let Some(path) = trigger.strip_prefix("file_churn:") {
139        return ctx.files_touched.iter().any(|f| f.contains(path));
140    }
141
142    if let Some(cmd) = trigger.strip_prefix("command_failure:") {
143        // Match only when the incoming Bash command actually contains the
144        // previously failed command token. Matching every Bash call floods
145        // hooks with irrelevant warnings AND record_hit() keeps resetting the
146        // rule's TTL, so noise rules never decay — a self-feeding loop.
147        // No command available → no match (silence over noise).
148        if ctx.tool_name != "Bash" {
149            return false;
150        }
151        let cmd = cmd.trim();
152        if cmd.is_empty() {
153            return false;
154        }
155        // Simple tokens (e.g., "ls", "python") match as whole words so they
156        // don't hit substrings like "tools"; complex tokens (e.g., "WS=$(cat")
157        // fall back to substring containment.
158        let is_simple_token = cmd
159            .chars()
160            .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.');
161        return ctx.command.as_deref().is_some_and(|current| {
162            if is_simple_token {
163                current
164                    .split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-' || c == '.'))
165                    .any(|word| word == cmd)
166            } else {
167                current.contains(cmd)
168            }
169        });
170    }
171
172    if trigger == "multi_agent_start" {
173        return ctx.hook_event == "SessionStart";
174    }
175
176    // Fallback: substring match on tool name or files
177    if ctx.tool_name.contains(trigger) {
178        return true;
179    }
180    ctx.files_touched.iter().any(|f| f.contains(trigger))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::rules::{Rule, RuleCategory, RuleStatus, RulesStore};
187
188    fn active_rule(trigger: &str, action: &str, category: RuleCategory) -> Rule {
189        Rule {
190            id: format!("rule_test_{}", trigger.replace(':', "_")),
191            trigger: trigger.to_string(),
192            action: action.to_string(),
193            anchor_file: None,
194            anchor_hash: None,
195            created: "2026-01-01T00:00:00Z".to_string(),
196            last_hit: "2026-01-01T00:00:00Z".to_string(),
197            hits: 2,
198            ttl_days: 30,
199            superseded_by: None,
200            status: RuleStatus::Active,
201            source_session: "test".to_string(),
202            source_event: None,
203            category,
204        }
205    }
206
207    fn make_store(rules: Vec<Rule>) -> RulesStore {
208        RulesStore {
209            rules,
210            last_decay_run: None,
211        }
212    }
213
214    #[test]
215    fn file_churn_trigger_matches_touched_files() {
216        let store = make_store(vec![active_rule(
217            "file_churn:src/main.rs",
218            "Review carefully",
219            RuleCategory::PreCommit,
220        )]);
221
222        let ctx = HookContext {
223            hook_event: "PreToolUse".to_string(),
224            tool_name: "Write".to_string(),
225            files_touched: vec!["src/main.rs".to_string()],
226            cwd: "/project".to_string(),
227            command: None,
228        };
229
230        let result = evaluate_rules(&store, &ctx);
231        assert_eq!(result.rules_matched, 1);
232    }
233
234    #[test]
235    fn no_match_when_file_not_touched() {
236        let store = make_store(vec![active_rule(
237            "file_churn:src/main.rs",
238            "Review carefully",
239            RuleCategory::PreCommit,
240        )]);
241
242        let ctx = HookContext {
243            hook_event: "PreToolUse".to_string(),
244            tool_name: "Write".to_string(),
245            files_touched: vec!["src/lib.rs".to_string()],
246            cwd: "/project".to_string(),
247            command: None,
248        };
249
250        let result = evaluate_rules(&store, &ctx);
251        assert_eq!(result.rules_matched, 0);
252    }
253
254    #[test]
255    fn dormant_rules_not_evaluated() {
256        let mut rule = active_rule(
257            "file_churn:src/main.rs",
258            "Review carefully",
259            RuleCategory::PreCommit,
260        );
261        rule.status = RuleStatus::Dormant;
262        let store = make_store(vec![rule]);
263
264        let ctx = HookContext {
265            hook_event: "PreToolUse".to_string(),
266            tool_name: "Write".to_string(),
267            files_touched: vec!["src/main.rs".to_string()],
268            cwd: "/project".to_string(),
269            command: None,
270        };
271
272        let result = evaluate_rules(&store, &ctx);
273        assert_eq!(result.rules_matched, 0);
274    }
275
276    #[test]
277    fn command_failure_matches_only_same_command() {
278        let store = make_store(vec![active_rule(
279            "command_failure:python",
280            "Verify python is available",
281            RuleCategory::Workflow,
282        )]);
283
284        // Bash call containing the failed command → match
285        let hit_ctx = HookContext {
286            hook_event: "PreToolUse".to_string(),
287            tool_name: "Bash".to_string(),
288            files_touched: vec![],
289            cwd: "/project".to_string(),
290            command: Some("python scripts/run.py".to_string()),
291        };
292        assert_eq!(evaluate_rules(&store, &hit_ctx).rules_matched, 1);
293
294        // Unrelated Bash call → no match (this was the noise bug)
295        let miss_ctx = HookContext {
296            command: Some("git status".to_string()),
297            ..hit_ctx.clone()
298        };
299        assert_eq!(evaluate_rules(&store, &miss_ctx).rules_matched, 0);
300
301        // Substring-only occurrence inside another word → no match
302        let substr_ctx = HookContext {
303            command: Some("pythonic-helper --run".to_string()),
304            ..hit_ctx.clone()
305        };
306        assert_eq!(evaluate_rules(&store, &substr_ctx).rules_matched, 0);
307
308        // No command available → no match (silence over noise)
309        let none_ctx = HookContext {
310            command: None,
311            ..hit_ctx.clone()
312        };
313        assert_eq!(evaluate_rules(&store, &none_ctx).rules_matched, 0);
314
315        // Non-Bash tool → no match
316        let write_ctx = HookContext {
317            tool_name: "Write".to_string(),
318            ..hit_ctx
319        };
320        assert_eq!(evaluate_rules(&store, &write_ctx).rules_matched, 0);
321    }
322
323    #[test]
324    fn format_warnings_empty_when_no_matches() {
325        let result = EvaluationResult {
326            rules_checked: 5,
327            rules_matched: 0,
328            matched_rule_ids: vec![],
329            enforcements: vec![],
330        };
331        assert!(format_warnings(&result).is_none());
332    }
333
334    #[test]
335    fn format_warnings_produces_output() {
336        let result = EvaluationResult {
337            rules_checked: 5,
338            rules_matched: 1,
339            matched_rule_ids: vec!["rule_1".to_string()],
340            enforcements: vec![EnforcementRecord {
341                rule_id: "rule_1".to_string(),
342                trigger: "file_churn:main.rs".to_string(),
343                action: "Review carefully".to_string(),
344                category: "pre_commit".to_string(),
345            }],
346        };
347        let warning = format_warnings(&result).unwrap();
348        assert!(warning.contains("Learned rules triggered"));
349        assert!(warning.contains("file_churn:main.rs"));
350    }
351}