Skip to main content

edda_postmortem/
rules.rs

1//! Rules store with immune-system lifecycle and TTL decay.
2//!
3//! Rules are learned from post-mortem analysis and enforced via hooks.
4//! Each rule follows an immune-system lifecycle:
5//!
6//!   Proposed -> Active -> Dormant -> Settled -> Dead
7//!                                           |
8//!                              Superseded --+
9//!
10//! Three decay mechanisms:
11//! - **Time decay**: TTL (default 30 days), reset on each trigger hit
12//! - **Anchor decay**: Rule anchored to file; file changes -> stale
13//! - **Contradiction detection**: Same trigger, different action -> supersede
14
15use serde::{Deserialize, Serialize};
16use sha2::{Digest as Sha2Digest, Sha256};
17use std::fs;
18use std::path::{Path, PathBuf};
19use time::OffsetDateTime;
20
21/// Default TTL in days for new rules.
22const DEFAULT_TTL_DAYS: u32 = 30;
23
24/// Maximum number of active rules enforced simultaneously.
25const MAX_ACTIVE_RULES: usize = 15;
26
27/// Days after last_hit before a rule transitions from Active -> Dormant.
28const DORMANT_THRESHOLD_DAYS: i64 = 30;
29
30/// Days after last_hit before Dormant -> Settled.
31const SETTLED_THRESHOLD_DAYS: i64 = 60;
32
33/// Days after last_hit before Settled -> Dead.
34const DEAD_THRESHOLD_DAYS: i64 = 90;
35
36/// Minimum confirmations to promote Proposed -> Active.
37const MIN_CONFIRMATIONS: u64 = 2;
38
39/// Rule lifecycle status (immune system model).
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum RuleStatus {
43    /// First observation, needs confirmation (pattern repeated 2x to activate).
44    Proposed,
45    /// Pattern confirmed, rule is enforced.
46    Active,
47    /// TTL window passed without trigger; rule is suspended.
48    Dormant,
49    /// Long dormant, near death.
50    Settled,
51    /// TTL expired completely; rule is archived.
52    Dead,
53    /// Contradicted by a newer rule with the same trigger.
54    Superseded,
55}
56
57impl std::fmt::Display for RuleStatus {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            Self::Proposed => write!(f, "proposed"),
61            Self::Active => write!(f, "active"),
62            Self::Dormant => write!(f, "dormant"),
63            Self::Settled => write!(f, "settled"),
64            Self::Dead => write!(f, "dead"),
65            Self::Superseded => write!(f, "superseded"),
66        }
67    }
68}
69
70/// What kind of rule this is — determines enforcement mechanism.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "snake_case")]
73pub enum RuleCategory {
74    /// Check before commit (PreCommit hook).
75    PreCommit,
76    /// Check before push (PrePush hook).
77    PrePush,
78    /// Code pattern to avoid/enforce.
79    CodePattern,
80    /// Workflow pattern to follow.
81    Workflow,
82}
83
84impl std::fmt::Display for RuleCategory {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        match self {
87            Self::PreCommit => write!(f, "pre_commit"),
88            Self::PrePush => write!(f, "pre_push"),
89            Self::CodePattern => write!(f, "code_pattern"),
90            Self::Workflow => write!(f, "workflow"),
91        }
92    }
93}
94
95/// A learned rule with TTL decay.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Rule {
98    pub id: String,
99    pub trigger: String,
100    pub action: String,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub anchor_file: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub anchor_hash: Option<String>,
105    pub created: String,
106    pub last_hit: String,
107    pub hits: u64,
108    pub ttl_days: u32,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub superseded_by: Option<String>,
111    pub status: RuleStatus,
112    pub source_session: String,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub source_event: Option<String>,
115    /// Times this rule's enforcement was shown to a user (e.g. PreToolUse
116    /// matches). Unlike `hits`, shows never update `last_hit`, never promote
117    /// Proposed -> Active, and never reactivate Dormant/Settled rules
118    /// (GH-813: hit-on-every-Bash-call kept noise rules alive forever).
119    #[serde(default)]
120    pub shows: u64,
121    /// Why this rule was revoked (only set when revoked via `revoke`).
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub revoked_reason: Option<String>,
124    pub category: RuleCategory,
125}
126
127impl Rule {
128    /// Check if this rule is enforceable (Active status).
129    pub fn is_enforceable(&self) -> bool {
130        self.status == RuleStatus::Active
131    }
132
133    /// Check if this rule is alive (not Dead or Superseded).
134    pub fn is_alive(&self) -> bool {
135        !matches!(self.status, RuleStatus::Dead | RuleStatus::Superseded)
136    }
137
138    /// Record a trigger hit: increment counter and reset TTL.
139    pub fn record_hit(&mut self) {
140        self.hits += 1;
141        self.last_hit = now_rfc3339();
142        // If proposed and enough hits, promote to active
143        if self.status == RuleStatus::Proposed && self.hits >= MIN_CONFIRMATIONS {
144            self.status = RuleStatus::Active;
145        }
146        // If dormant/settled, reactivate on hit
147        if matches!(self.status, RuleStatus::Dormant | RuleStatus::Settled) {
148            self.status = RuleStatus::Active;
149        }
150    }
151
152    /// Record that this rule's enforcement was shown to the user.
153    ///
154    /// Increments the show counter only: `last_hit` is NOT updated, Proposed
155    /// rules are NOT promoted, and Dormant/Settled rules are NOT reactivated
156    /// (GH-813: shows are pure statistics and must not reset the TTL).
157    pub fn record_shown(&mut self) {
158        self.shows += 1;
159    }
160
161    /// Revoke this rule: mark it Dead with a reason.
162    pub fn revoke(&mut self, reason: String) {
163        self.status = RuleStatus::Dead;
164        self.revoked_reason = Some(reason);
165    }
166
167    /// Compute days since last hit.
168    pub fn days_since_last_hit(&self) -> Option<i64> {
169        let last = parse_rfc3339(&self.last_hit)?;
170        let now = OffsetDateTime::now_utc();
171        Some((now - last).whole_days())
172    }
173
174    /// Apply time-based decay to this rule's status.
175    pub fn apply_time_decay(&mut self) {
176        if matches!(
177            self.status,
178            RuleStatus::Dead | RuleStatus::Superseded | RuleStatus::Proposed
179        ) {
180            return;
181        }
182
183        let days = match self.days_since_last_hit() {
184            Some(d) => d,
185            None => return,
186        };
187
188        if days >= DEAD_THRESHOLD_DAYS {
189            self.status = RuleStatus::Dead;
190        } else if days >= SETTLED_THRESHOLD_DAYS {
191            self.status = RuleStatus::Settled;
192        } else if days >= DORMANT_THRESHOLD_DAYS {
193            self.status = RuleStatus::Dormant;
194        }
195        // else: still Active, no change
196    }
197}
198
199/// The rules store: manages rules.json persistence and lifecycle.
200#[derive(Debug, Clone, Serialize, Deserialize, Default)]
201pub struct RulesStore {
202    pub rules: Vec<Rule>,
203    #[serde(default)]
204    pub last_decay_run: Option<String>,
205}
206
207impl RulesStore {
208    /// Load rules store from disk. Returns default if file doesn't exist.
209    pub fn load(path: &Path) -> Self {
210        match fs::read_to_string(path) {
211            Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
212            Err(_) => Self::default(),
213        }
214    }
215
216    /// Persist rules store to disk atomically.
217    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
218        let json = serde_json::to_string_pretty(self)?;
219        edda_store::write_atomic(path, json.as_bytes())
220    }
221
222    /// Resolve the rules.json path for a project.
223    pub fn project_rules_path(project_id: &str) -> PathBuf {
224        edda_store::project_dir(project_id)
225            .join("state")
226            .join("rules.json")
227    }
228
229    /// Resolve the global rules.json path (~/.edda/rules.json).
230    pub fn global_rules_path() -> PathBuf {
231        edda_store::store_root().join("rules.json")
232    }
233
234    /// Load project-scoped rules.
235    pub fn load_project(project_id: &str) -> Self {
236        Self::load(&Self::project_rules_path(project_id))
237    }
238
239    /// Save project-scoped rules.
240    pub fn save_project(&self, project_id: &str) -> anyhow::Result<()> {
241        self.save(&Self::project_rules_path(project_id))
242    }
243
244    /// Get all active (enforceable) rules.
245    pub fn active_rules(&self) -> Vec<&Rule> {
246        self.rules.iter().filter(|r| r.is_enforceable()).collect()
247    }
248
249    /// Get all alive rules (not dead/superseded).
250    pub fn alive_rules(&self) -> Vec<&Rule> {
251        self.rules.iter().filter(|r| r.is_alive()).collect()
252    }
253
254    /// Add a new rule proposal. If a rule with the same trigger already exists
255    /// and is alive, increment its hits instead (confirmation).
256    pub fn propose_rule(
257        &mut self,
258        trigger: String,
259        action: String,
260        anchor_file: Option<String>,
261        category: RuleCategory,
262        source_session: String,
263        source_event: Option<String>,
264    ) -> String {
265        // Check for contradiction: same trigger, different action -> supersede old
266        let mut superseded_ids = Vec::new();
267        for rule in &self.rules {
268            if rule.trigger == trigger && rule.is_alive() {
269                if rule.action == action {
270                    // Same trigger + same action: confirmation, not new rule.
271                    // Find the mutable reference and record hit.
272                    let rule_id = rule.id.clone();
273                    if let Some(existing) = self.rules.iter_mut().find(|r| r.id == rule_id) {
274                        existing.record_hit();
275                    }
276                    return rule_id;
277                }
278                // Same trigger, different action -> contradiction
279                superseded_ids.push(rule.id.clone());
280            }
281        }
282
283        // Supersede contradicting rules
284        let new_id = new_rule_id();
285        for sid in &superseded_ids {
286            if let Some(old_rule) = self.rules.iter_mut().find(|r| r.id == *sid) {
287                old_rule.status = RuleStatus::Superseded;
288                old_rule.superseded_by = Some(new_id.clone());
289            }
290        }
291
292        // Compute anchor hash if anchor file provided
293        let anchor_hash = anchor_file.as_ref().and_then(|f| file_sha256(f));
294
295        let now = now_rfc3339();
296        let rule = Rule {
297            id: new_id.clone(),
298            trigger,
299            action,
300            anchor_file,
301            anchor_hash,
302            created: now.clone(),
303            last_hit: now,
304            hits: 1,
305            ttl_days: DEFAULT_TTL_DAYS,
306            superseded_by: None,
307            status: RuleStatus::Proposed,
308            source_session,
309            source_event,
310            shows: 0,
311            revoked_reason: None,
312            category,
313        };
314
315        self.rules.push(rule);
316        new_id
317    }
318
319    /// Run the full decay cycle on all rules.
320    ///
321    /// 1. Time decay: check TTL against last_hit
322    /// 2. Anchor decay: check if anchored file changed
323    /// 3. Enforce active window cap (~15)
324    pub fn run_decay_cycle(&mut self) {
325        // 0. Reclaim disallowed command triggers (GH-813): shell
326        // builtins/keywords/common utilities and variable assignments never
327        // make meaningful learned rules — revoke them outright.
328        for rule in &mut self.rules {
329            if rule.is_alive() && is_disallowed_trigger(&rule.trigger) {
330                rule.revoke("disallowed command trigger (builtin/keyword/assignment)".to_string());
331            }
332        }
333
334        // 1. Time decay
335        for rule in &mut self.rules {
336            rule.apply_time_decay();
337        }
338
339        // 2. Anchor decay: mark rules stale if anchored file changed
340        for rule in &mut self.rules {
341            if !rule.is_alive() {
342                continue;
343            }
344            if let (Some(ref anchor_file), Some(ref stored_hash)) =
345                (&rule.anchor_file, &rule.anchor_hash)
346            {
347                if let Some(current_hash) = file_sha256(anchor_file) {
348                    if current_hash != *stored_hash && rule.status == RuleStatus::Active {
349                        rule.status = RuleStatus::Dormant;
350                    }
351                } else if !Path::new(anchor_file).exists() && rule.status == RuleStatus::Active {
352                    rule.status = RuleStatus::Dormant;
353                }
354            }
355        }
356
357        // 3. Enforce active window cap: keep top N by hits, demote rest
358        let mut active_ids: Vec<(String, u64)> = self
359            .rules
360            .iter()
361            .filter(|r| r.status == RuleStatus::Active)
362            .map(|r| (r.id.clone(), r.hits))
363            .collect();
364        active_ids.sort_by_key(|entry| std::cmp::Reverse(entry.1)); // Sort by hits descending
365        if active_ids.len() > MAX_ACTIVE_RULES {
366            let demote_ids: Vec<String> = active_ids[MAX_ACTIVE_RULES..]
367                .iter()
368                .map(|(id, _)| id.clone())
369                .collect();
370            for rule in &mut self.rules {
371                if demote_ids.contains(&rule.id) {
372                    rule.status = RuleStatus::Dormant;
373                }
374            }
375        }
376
377        self.last_decay_run = Some(now_rfc3339());
378    }
379
380    /// Record shows for matched rules. Unlike `record_matched_hits`, this
381    /// never resets the TTL or reactivates decayed rules (GH-813).
382    pub fn record_matched_shows(&mut self, matched_ids: &[String]) {
383        for id in matched_ids {
384            if let Some(rule) = self.get_mut(id) {
385                rule.record_shown();
386            }
387        }
388    }
389
390    /// Revoke a rule by ID. Returns false when the ID is unknown.
391    pub fn revoke_rule(&mut self, id: &str, reason: String) -> bool {
392        match self.get_mut(id) {
393            Some(rule) => {
394                rule.revoke(reason);
395                true
396            }
397            None => false,
398        }
399    }
400
401    /// Garbage-collect dead rules (remove from store entirely).
402    pub fn gc_dead_rules(&mut self) -> usize {
403        let before = self.rules.len();
404        self.rules.retain(|r| !matches!(r.status, RuleStatus::Dead));
405        before - self.rules.len()
406    }
407
408    /// Find rules matching a given trigger pattern (substring match).
409    pub fn find_by_trigger(&self, trigger_pattern: &str) -> Vec<&Rule> {
410        self.rules
411            .iter()
412            .filter(|r| r.trigger.contains(trigger_pattern))
413            .collect()
414    }
415
416    /// Get a rule by ID.
417    pub fn get(&self, id: &str) -> Option<&Rule> {
418        self.rules.iter().find(|r| r.id == id)
419    }
420
421    /// Get a mutable rule by ID.
422    pub fn get_mut(&mut self, id: &str) -> Option<&mut Rule> {
423        self.rules.iter_mut().find(|r| r.id == id)
424    }
425
426    /// Summary statistics.
427    pub fn stats(&self) -> StoreStats {
428        let mut stats = StoreStats::default();
429        for rule in &self.rules {
430            match rule.status {
431                RuleStatus::Proposed => stats.proposed += 1,
432                RuleStatus::Active => stats.active += 1,
433                RuleStatus::Dormant => stats.dormant += 1,
434                RuleStatus::Settled => stats.settled += 1,
435                RuleStatus::Dead => stats.dead += 1,
436                RuleStatus::Superseded => stats.superseded += 1,
437            }
438        }
439        stats.total = self.rules.len();
440        stats
441    }
442}
443
444/// Summary statistics for the rules store.
445#[derive(Debug, Clone, Default, Serialize, Deserialize)]
446pub struct StoreStats {
447    pub total: usize,
448    pub proposed: usize,
449    pub active: usize,
450    pub dormant: usize,
451    pub settled: usize,
452    pub dead: usize,
453    pub superseded: usize,
454}
455
456// -- Helpers --
457
458/// Shell builtins, keywords, and ubiquitous utilities whose failures are
459/// environmental noise rather than a missing-tool signal. They must never
460/// become learned-rule command triggers (GH-813: echo 1789 / cd 1618 hits).
461pub const DISALLOWED_TRIGGER_WORDS: &[&str] = &[
462    "alias",
463    "bg",
464    "bind",
465    "break",
466    "builtin",
467    "caller",
468    "case",
469    "cd",
470    "command",
471    "compgen",
472    "complete",
473    "compopt",
474    "continue",
475    "coproc",
476    "declare",
477    "dirs",
478    "disown",
479    "do",
480    "done",
481    "echo",
482    "elif",
483    "else",
484    "enable",
485    "esac",
486    "eval",
487    "exec",
488    "exit",
489    "export",
490    "fc",
491    "fg",
492    "fi",
493    "for",
494    "function",
495    "getopts",
496    "hash",
497    "help",
498    "history",
499    "if",
500    "in",
501    "jobs",
502    "kill",
503    "let",
504    "local",
505    "logout",
506    "mapfile",
507    "popd",
508    "printf",
509    "pushd",
510    "pwd",
511    "read",
512    "readarray",
513    "readonly",
514    "return",
515    "select",
516    "set",
517    "shift",
518    "shopt",
519    "source",
520    "suspend",
521    "test",
522    "then",
523    "time",
524    "times",
525    "trap",
526    "true",
527    "type",
528    "typeset",
529    "ulimit",
530    "umask",
531    "unalias",
532    "unset",
533    "until",
534    "wait",
535    "while",
536    "cat",
537    "sed",
538    "grep",
539    "head",
540    "tail",
541    "wc",
542    "find",
543    "ls",
544    "false",
545];
546
547/// True when a bare token is a variable assignment: `NAME=value` or the
548/// bash append form `NAME+=value`. The name before `=` / `+=` must be a
549/// valid shell identifier (ASCII alpha/underscore, then ASCII alphanum or
550/// underscore).
551pub fn is_var_assignment(token: &str) -> bool {
552    let Some(eq) = token.find('=') else {
553        return false;
554    };
555    let name = &token[..eq];
556    let name = name.strip_suffix('+').unwrap_or(name);
557    !name.is_empty()
558        && name
559            .chars()
560            .next()
561            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
562        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
563}
564
565/// Split a shell command into segments on `;`, `&`, `|`, and newline.
566///
567/// Quote-aware: delimiters inside single quotes, double quotes, or after a
568/// backslash escape do not split. For example
569/// `printf '%s' 'skip; python -V'` is ONE segment whose command word is
570/// `printf`.
571pub fn split_command_segments(cmd: &str) -> Vec<&str> {
572    let mut segments = Vec::new();
573    let mut start = 0usize;
574    let mut in_single = false;
575    let mut in_double = false;
576    let mut escaped = false;
577    for (i, ch) in cmd.char_indices() {
578        if escaped {
579            escaped = false;
580            continue;
581        }
582        match ch {
583            '\\' if !in_single => escaped = true,
584            '\'' if !in_double => in_single = !in_single,
585            '"' if !in_single => in_double = !in_double,
586            ';' | '&' | '|' | '\n' if !in_single && !in_double => {
587                segments.push(&cmd[start..i]);
588                start = i + ch.len_utf8();
589            }
590            _ => {}
591        }
592    }
593    segments.push(&cmd[start..]);
594    segments
595}
596
597/// Tokenize a command segment into unquoted words.
598///
599/// Splits on unquoted whitespace; single and double quotes group characters
600/// into one word and are stripped (so `'python'` yields `python`); a
601/// backslash outside single quotes escapes the next character (so
602/// `git\ commit` is one word). Inside double quotes only `\`, `"`, `$`, and
603/// backtick drop the backslash, matching shell quoting rules closely enough
604/// for command-word extraction.
605fn unquoted_words(segment: &str) -> Vec<String> {
606    let mut words = Vec::new();
607    let mut current = String::new();
608    let mut in_word = false;
609    let mut in_single = false;
610    let mut in_double = false;
611    let mut escaped = false;
612    for ch in segment.chars() {
613        if escaped {
614            // Outside quotes any escaped character is literal; inside double
615            // quotes only a few escapes drop the backslash.
616            if !in_double || matches!(ch, '\\' | '"' | '$' | '`') {
617                current.push(ch);
618            } else {
619                current.push('\\');
620                current.push(ch);
621            }
622            escaped = false;
623            continue;
624        }
625        match ch {
626            '\\' if !in_single => {
627                escaped = true;
628                in_word = true;
629            }
630            '\'' if !in_double => {
631                in_single = !in_single;
632                in_word = true;
633            }
634            '"' if !in_single => {
635                in_double = !in_double;
636                in_word = true;
637            }
638            c if c.is_whitespace() && !in_single && !in_double => {
639                if in_word {
640                    words.push(std::mem::take(&mut current));
641                    in_word = false;
642                }
643            }
644            c => {
645                current.push(c);
646                in_word = true;
647            }
648        }
649    }
650    if in_word {
651        words.push(current);
652    }
653    words
654}
655
656/// Quote-aware command word of a command segment.
657///
658/// Tokenizes `segment` into words while respecting quotes and backslash
659/// escapes, skips leading variable assignments (including quoted values
660/// like `FOO='hello world'`), and returns the first non-assignment word,
661/// unquoted (e.g. `python` for `'python'` or `"python"`). Returns None when
662/// the segment is empty or consists only of assignments.
663pub fn command_word(segment: &str) -> Option<String> {
664    let mut words = unquoted_words(segment);
665    while words.first().is_some_and(|w| is_var_assignment(w)) {
666        words.remove(0);
667    }
668    words.into_iter().next()
669}
670
671/// True when a command may become a learned-rule command trigger.
672///
673/// Rejects empty/whitespace commands, compound commands (`;`, `&&`, `||`,
674/// `|`, newline), commands whose leading word is a variable assignment
675/// (`FOO=bar npm test` keeps its assignment prefix out of learned
676/// triggers), and shell builtins/keywords/common utilities (GH-813). The
677/// leading word is taken quote-aware, so `"echo" hi` is also rejected.
678pub fn is_trackable_command(cmd: &str) -> bool {
679    let cmd = cmd.trim();
680    if cmd.is_empty() || cmd.contains([';', '|', '&', '\n']) {
681        return false;
682    }
683    match unquoted_words(cmd).first() {
684        Some(word) if !is_var_assignment(word) => {
685            !DISALLOWED_TRIGGER_WORDS.contains(&word.as_str())
686        }
687        _ => false,
688    }
689}
690
691/// True when a stored rule trigger is disallowed and should be reclaimed by
692/// the decay cycle. ONLY `command_failure:` triggers can be disallowed
693/// command triggers: `file_churn:<path>` (paths may contain `=`),
694/// `multi_agent_start`, and free-text triggers are never revoked by the
695/// decay cycle (GH-813).
696pub fn is_disallowed_trigger(trigger: &str) -> bool {
697    let Some(cmd) = trigger.strip_prefix("command_failure:") else {
698        return false;
699    };
700    cmd.contains('=') || !is_trackable_command(cmd)
701}
702
703fn new_rule_id() -> String {
704    format!("rule_{}", ulid::Ulid::new().to_string().to_lowercase())
705}
706
707fn now_rfc3339() -> String {
708    let now = OffsetDateTime::now_utc();
709    now.format(&time::format_description::well_known::Rfc3339)
710        .expect("RFC3339 formatting should not fail")
711}
712
713fn parse_rfc3339(s: &str) -> Option<OffsetDateTime> {
714    OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
715}
716
717/// Compute SHA-256 of a file's contents. Returns None if file unreadable.
718fn file_sha256(path: &str) -> Option<String> {
719    let data = fs::read(path).ok()?;
720    let hash = Sha256::digest(&data);
721    Some(hex::encode(hash))
722}
723
724#[path = "rules_tests.rs"]
725#[cfg(test)]
726mod tests;