Skip to main content

aperion_shield/
memory.rs

1//! Local decision memory -- append-only JSONL of past approve/deny
2//! decisions per (rule_id, argv-fingerprint). All processing is
3//! purely local; nothing is ever sent off the box.
4//!
5//! Two adaptive behaviours derive from this log:
6//!
7//!   * Demote-after-N-approvals: if the user has approved this exact
8//!     fingerprint >= `demote_after_approvals` times with no recent
9//!     denial, severity drops one tier.
10//!
11//!   * Escalate-on-recent-deny: if any denial exists within
12//!     `escalate_on_deny_days`, severity bumps one tier.
13//!
14//! The log file lives at `<cwd>/.aperion-shield/decisions.jsonl` by
15//! default -- co-located with the inbox so users only manage one
16//! Shield-state directory per project. A user-global fallback under
17//! `~/.aperion-shield/` is checked when the project directory is not
18//! writable (e.g. read-only mounts in CI).
19
20use std::fs::OpenOptions;
21use std::io::{BufRead, BufReader, Write};
22use std::path::PathBuf;
23
24use chrono::{DateTime, Duration, Utc};
25use serde::{Deserialize, Serialize};
26
27use crate::engine::DecisionMemoryCfg;
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "lowercase")]
31pub enum Outcome {
32    Approve,
33    Deny,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct MemoryEntry {
38    pub ts: DateTime<Utc>,
39    pub rule_id: String,
40    pub fingerprint: String,
41    pub outcome: Outcome,
42    pub tool: String,
43}
44
45/// Adaptive verdict drawn from the memory log for a given fingerprint.
46#[derive(Debug, Clone, Copy, Default)]
47pub struct MemoryVerdict {
48    pub recent_deny: bool,
49    pub repeated_approve: bool,
50    pub approve_count: u32,
51    pub deny_count: u32,
52}
53
54#[derive(Debug, Clone)]
55pub struct DecisionMemory {
56    path: PathBuf,
57    cfg: DecisionMemoryCfg,
58}
59
60impl DecisionMemory {
61    /// Open (or initialise) the on-disk log. Returns even if the file
62    /// doesn't exist yet -- first writes will create it.
63    pub fn open(cfg: DecisionMemoryCfg) -> Self {
64        let path = resolve_path();
65        Self { path, cfg }
66    }
67
68    #[cfg(test)]
69    pub fn at_path(cfg: DecisionMemoryCfg, path: PathBuf) -> Self {
70        Self { path, cfg }
71    }
72
73    pub fn enabled(&self) -> bool { self.cfg.enabled }
74    pub fn path(&self) -> &PathBuf { &self.path }
75
76    /// Append a new decision to the log. Errors are logged but never
77    /// raised -- decision memory is best-effort, must not break the
78    /// proxy if disk is full / RO.
79    pub fn record(&self, rule_id: &str, fingerprint: &str, outcome: Outcome, tool: &str) {
80        if !self.cfg.enabled { return; }
81        if let Some(parent) = self.path.parent() {
82            let _ = std::fs::create_dir_all(parent);
83        }
84        let entry = MemoryEntry {
85            ts: Utc::now(),
86            rule_id: rule_id.to_string(),
87            fingerprint: fingerprint.to_string(),
88            outcome,
89            tool: tool.to_string(),
90        };
91        match serde_json::to_string(&entry) {
92            Ok(line) => {
93                if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&self.path) {
94                    let _ = writeln!(f, "{}", line);
95                }
96            }
97            Err(_) => {} // can't serialise our own struct -> impossible
98        }
99    }
100
101    /// Consult the log for a given fingerprint and produce a verdict.
102    /// Reads sequentially; we expect the file to stay small (one entry
103    /// per high-severity decision per user per project).
104    pub fn verdict_for(&self, fingerprint: &str) -> MemoryVerdict {
105        if !self.cfg.enabled { return MemoryVerdict::default(); }
106        let file = match std::fs::File::open(&self.path) {
107            Ok(f) => f,
108            Err(_) => return MemoryVerdict::default(),
109        };
110        let now = Utc::now();
111        let escalate_cutoff = now - Duration::days(self.cfg.escalate_on_deny_days as i64);
112        let mut approve_count = 0u32;
113        let mut deny_count = 0u32;
114        let mut recent_deny = false;
115        let mut last_deny: Option<DateTime<Utc>> = None;
116        for line in BufReader::new(file).lines().flatten() {
117            let entry: MemoryEntry = match serde_json::from_str(&line) {
118                Ok(e) => e,
119                Err(_) => continue, // skip malformed
120            };
121            if entry.fingerprint != fingerprint { continue; }
122            match entry.outcome {
123                Outcome::Approve => approve_count += 1,
124                Outcome::Deny => {
125                    deny_count += 1;
126                    if entry.ts >= escalate_cutoff {
127                        recent_deny = true;
128                        last_deny = Some(entry.ts);
129                    }
130                }
131            }
132        }
133        // Demotion only counts approvals that came *after* the last
134        // deny. We re-walk to apply that rule precisely.
135        let approve_after_last_deny = if let Some(d) = last_deny {
136            let file = std::fs::File::open(&self.path).ok();
137            let mut count = 0u32;
138            if let Some(f) = file {
139                for line in BufReader::new(f).lines().flatten() {
140                    if let Ok(e) = serde_json::from_str::<MemoryEntry>(&line) {
141                        if e.fingerprint == fingerprint
142                            && matches!(e.outcome, Outcome::Approve)
143                            && e.ts > d
144                        {
145                            count += 1;
146                        }
147                    }
148                }
149            }
150            count
151        } else {
152            approve_count
153        };
154        let repeated_approve = approve_after_last_deny >= self.cfg.demote_after_approvals;
155        MemoryVerdict {
156            recent_deny,
157            repeated_approve,
158            approve_count,
159            deny_count,
160        }
161    }
162}
163
164fn resolve_path() -> PathBuf {
165    // Prefer cwd/.aperion-shield/decisions.jsonl (per-project memory)
166    // but fall back to ~/.aperion-shield/decisions.jsonl when cwd is
167    // read-only -- we test by attempting to create the directory.
168    let local = PathBuf::from(".aperion-shield");
169    if std::fs::create_dir_all(&local).is_ok() {
170        return local.join("decisions.jsonl");
171    }
172    if let Some(home) = dirs::home_dir() {
173        let user = home.join(".aperion-shield");
174        let _ = std::fs::create_dir_all(&user);
175        return user.join("decisions.jsonl");
176    }
177    local.join("decisions.jsonl")
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use tempfile::TempDir;
184
185    fn cfg() -> DecisionMemoryCfg {
186        DecisionMemoryCfg {
187            enabled: true,
188            demote_after_approvals: 3,
189            escalate_on_deny_days: 7,
190        }
191    }
192
193    #[test]
194    fn empty_log_means_neutral() {
195        let tmp = TempDir::new().unwrap();
196        let mem = DecisionMemory::at_path(cfg(), tmp.path().join("decisions.jsonl"));
197        let v = mem.verdict_for("abc123");
198        assert!(!v.recent_deny);
199        assert!(!v.repeated_approve);
200        assert_eq!(v.approve_count, 0);
201        assert_eq!(v.deny_count, 0);
202    }
203
204    #[test]
205    fn three_approves_triggers_demotion() {
206        let tmp = TempDir::new().unwrap();
207        let mem = DecisionMemory::at_path(cfg(), tmp.path().join("decisions.jsonl"));
208        for _ in 0..3 {
209            mem.record("r1", "abc123", Outcome::Approve, "tool");
210        }
211        let v = mem.verdict_for("abc123");
212        assert!(v.repeated_approve);
213        assert!(!v.recent_deny);
214    }
215
216    #[test]
217    fn deny_resets_approve_streak() {
218        let tmp = TempDir::new().unwrap();
219        let mem = DecisionMemory::at_path(cfg(), tmp.path().join("decisions.jsonl"));
220        // 5 approvals, then a denial, then 1 more approval.
221        for _ in 0..5 { mem.record("r1", "fp", Outcome::Approve, "t"); }
222        std::thread::sleep(std::time::Duration::from_millis(2));
223        mem.record("r1", "fp", Outcome::Deny, "t");
224        std::thread::sleep(std::time::Duration::from_millis(2));
225        mem.record("r1", "fp", Outcome::Approve, "t");
226        let v = mem.verdict_for("fp");
227        // Should NOT be considered "repeatedly approved" because there's
228        // only one approval after the deny.
229        assert!(!v.repeated_approve);
230        // AND the deny is recent -> escalation should fire.
231        assert!(v.recent_deny);
232    }
233
234    #[test]
235    fn fingerprint_isolation() {
236        let tmp = TempDir::new().unwrap();
237        let mem = DecisionMemory::at_path(cfg(), tmp.path().join("decisions.jsonl"));
238        for _ in 0..3 { mem.record("r1", "fp_A", Outcome::Approve, "t"); }
239        let other = mem.verdict_for("fp_B");
240        assert!(!other.repeated_approve);
241        let same = mem.verdict_for("fp_A");
242        assert!(same.repeated_approve);
243    }
244}