Skip to main content

agent_trace/state/
permissions.rs

1use crate::types::{Action, Actor, DocType};
2use anyhow::{Context, Result};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7// ── Permission Result ─────────────────────────────────────────────────────────
8
9#[derive(Debug, Clone, PartialEq)]
10pub enum PermissionResult {
11    Allowed,
12    Denied { reason: String },
13    RequiresConfirmation { prompt: String },
14}
15
16// ── Core Permission Check ─────────────────────────────────────────────────────
17
18/// Encoding of the permission table from PRD 4.2.2.
19///
20/// Permissions are currently checked by (actor, doc_type) only.
21/// Action-level granularity (create vs modify vs delete) is not yet implemented.
22pub fn check_permission(
23    doc_type: &DocType,
24    actor: &Actor,
25    overrides: &Overrides,
26    path: Option<&Path>,
27) -> PermissionResult {
28    // If there's an active override for this path + actor, allow everything.
29    if let Some(p) = path {
30        if overrides.is_overridden(p, actor) {
31            return PermissionResult::Allowed;
32        }
33    }
34
35    match (doc_type, actor) {
36        // Plan: user + agent can do anything.
37        (DocType::Plan, Actor::User | Actor::Agent { .. }) => PermissionResult::Allowed,
38        (DocType::Plan, Actor::System) => PermissionResult::Denied {
39            reason: "System cannot modify plan documents".into(),
40        },
41
42        // Context: system only. Agent denied; user requires confirmation.
43        (DocType::Context, Actor::System) => PermissionResult::Allowed,
44        (DocType::Context, Actor::Agent { .. }) => PermissionResult::Denied {
45            reason: "Agents cannot modify system-owned context documents".into(),
46        },
47        (DocType::Context, Actor::User) => PermissionResult::RequiresConfirmation {
48            prompt: "Context is system-synthesized. Use `agent-trace context update \"...\"` to update. Overwrite directly? [y/N]".into(),
49        },
50
51        // Log: system only. Agent denied; user requires confirmation.
52        (DocType::Log, Actor::System) => PermissionResult::Allowed,
53        (DocType::Log, Actor::Agent { .. }) => PermissionResult::Denied {
54            reason: "Agents cannot modify system-owned log documents".into(),
55        },
56        (DocType::Log, Actor::User) => PermissionResult::RequiresConfirmation {
57            prompt: "Log documents are system-managed. Modify anyway? [y/N]".into(),
58        },
59
60        // Reference: user only. Agent denied; system denied.
61        (DocType::Reference, Actor::User) => PermissionResult::Allowed,
62        (DocType::Reference, Actor::Agent { .. }) => PermissionResult::Denied {
63            reason: "Agents cannot modify reference documents".into(),
64        },
65        (DocType::Reference, Actor::System) => PermissionResult::Denied {
66            reason: "System cannot modify reference documents".into(),
67        },
68
69        // Scratch: anyone can write.
70        (DocType::Scratch, _) => PermissionResult::Allowed,
71    }
72}
73
74// ── Override Entry ────────────────────────────────────────────────────────────
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct OverrideEntry {
78    pub doc_id: String,
79    pub path: PathBuf,
80    pub allow_actor: String, // serialized Actor string
81    pub granted_at: DateTime<Utc>,
82    pub expires_at: DateTime<Utc>,
83    pub granted_by: String,
84}
85
86impl OverrideEntry {
87    pub fn is_active(&self) -> bool {
88        Utc::now() < self.expires_at
89    }
90
91    pub fn matches_actor(&self, actor: &Actor) -> bool {
92        // Match by actor kind.
93        match actor {
94            Actor::User => self.allow_actor == "user",
95            Actor::Agent { name } => {
96                self.allow_actor == "agent" || self.allow_actor == format!("agent:{name}")
97            }
98            Actor::System => self.allow_actor == "system",
99        }
100    }
101}
102
103// ── Overrides ────────────────────────────────────────────────────────────────
104
105#[derive(Debug, Clone, Serialize, Deserialize, Default)]
106pub struct Overrides {
107    #[serde(default, rename = "override")]
108    pub entries: Vec<OverrideEntry>,
109}
110
111impl Overrides {
112    pub fn load(store_root: &Path) -> Result<Self> {
113        let path = overrides_path(store_root);
114        if !path.exists() {
115            return Ok(Self::default());
116        }
117        let contents = std::fs::read_to_string(&path)
118            .with_context(|| format!("Reading overrides: {}", path.display()))?;
119        toml::from_str(&contents).with_context(|| format!("Parsing overrides: {}", path.display()))
120    }
121
122    pub fn save(&self, store_root: &Path) -> Result<()> {
123        let path = overrides_path(store_root);
124        std::fs::create_dir_all(path.parent().unwrap())?;
125        let contents = toml::to_string_pretty(self)?;
126        crate::util::atomic_write(&path, &contents)?;
127        Ok(())
128    }
129
130    pub fn add(&mut self, entry: OverrideEntry) -> Result<()> {
131        self.entries.push(entry);
132        Ok(())
133    }
134
135    pub fn is_overridden(&self, path: &Path, actor: &Actor) -> bool {
136        self.entries
137            .iter()
138            .any(|e| e.path == path && e.is_active() && e.matches_actor(actor))
139    }
140
141    pub fn prune_expired(&mut self) {
142        self.entries.retain(|e| e.is_active());
143    }
144}
145
146fn overrides_path(store_root: &Path) -> PathBuf {
147    store_root.join(".agent-trace").join("overrides.toml")
148}
149
150// ── Violation ─────────────────────────────────────────────────────────────────
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct Violation {
154    pub timestamp: DateTime<Utc>,
155    pub doc_path: PathBuf,
156    pub actor: Actor,
157    pub agent_name: Option<String>,
158    pub attempted_action: Action,
159    pub reason: String,
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use chrono::Duration;
166    use tempfile::TempDir;
167
168    fn agent(name: &str) -> Actor {
169        Actor::Agent { name: name.into() }
170    }
171
172    #[test]
173    fn test_plan_permissions() {
174        let o = Overrides::default();
175        assert_eq!(
176            check_permission(&DocType::Plan, &Actor::User, &o, None),
177            PermissionResult::Allowed
178        );
179        assert_eq!(
180            check_permission(&DocType::Plan, &agent("claude"), &o, None),
181            PermissionResult::Allowed
182        );
183        assert!(matches!(
184            check_permission(&DocType::Plan, &Actor::System, &o, None),
185            PermissionResult::Denied { .. }
186        ));
187    }
188
189    #[test]
190    fn test_context_permissions() {
191        let o = Overrides::default();
192        assert_eq!(
193            check_permission(&DocType::Context, &Actor::System, &o, None),
194            PermissionResult::Allowed
195        );
196        assert!(matches!(
197            check_permission(&DocType::Context, &agent("aider"), &o, None),
198            PermissionResult::Denied { .. }
199        ));
200        assert!(matches!(
201            check_permission(&DocType::Context, &Actor::User, &o, None),
202            PermissionResult::RequiresConfirmation { .. }
203        ));
204    }
205
206    #[test]
207    fn test_log_permissions() {
208        let o = Overrides::default();
209        assert_eq!(
210            check_permission(&DocType::Log, &Actor::System, &o, None),
211            PermissionResult::Allowed
212        );
213        assert!(matches!(
214            check_permission(&DocType::Log, &agent("x"), &o, None),
215            PermissionResult::Denied { .. }
216        ));
217        assert!(matches!(
218            check_permission(&DocType::Log, &Actor::User, &o, None),
219            PermissionResult::RequiresConfirmation { .. }
220        ));
221    }
222
223    #[test]
224    fn test_reference_permissions() {
225        let o = Overrides::default();
226        assert_eq!(
227            check_permission(&DocType::Reference, &Actor::User, &o, None),
228            PermissionResult::Allowed
229        );
230        assert!(matches!(
231            check_permission(&DocType::Reference, &agent("x"), &o, None),
232            PermissionResult::Denied { .. }
233        ));
234        assert!(matches!(
235            check_permission(&DocType::Reference, &Actor::System, &o, None),
236            PermissionResult::Denied { .. }
237        ));
238    }
239
240    #[test]
241    fn test_scratch_permissions() {
242        let o = Overrides::default();
243        assert_eq!(
244            check_permission(&DocType::Scratch, &Actor::User, &o, None),
245            PermissionResult::Allowed
246        );
247        assert_eq!(
248            check_permission(&DocType::Scratch, &agent("x"), &o, None),
249            PermissionResult::Allowed
250        );
251        assert_eq!(
252            check_permission(&DocType::Scratch, &Actor::System, &o, None),
253            PermissionResult::Allowed
254        );
255    }
256
257    #[test]
258    fn test_active_override_allows() {
259        let mut o = Overrides::default();
260        let path = PathBuf::from("api.md");
261        o.add(OverrideEntry {
262            doc_id: "id1".into(),
263            path: path.clone(),
264            allow_actor: "agent".into(),
265            granted_at: Utc::now(),
266            expires_at: Utc::now() + Duration::minutes(10),
267            granted_by: "user".into(),
268        })
269        .unwrap();
270        assert_eq!(
271            check_permission(&DocType::Reference, &agent("claude"), &o, Some(&path)),
272            PermissionResult::Allowed
273        );
274    }
275
276    #[test]
277    fn test_expired_override_denied() {
278        let mut o = Overrides::default();
279        let path = PathBuf::from("api.md");
280        o.add(OverrideEntry {
281            doc_id: "id1".into(),
282            path: path.clone(),
283            allow_actor: "agent".into(),
284            granted_at: Utc::now() - Duration::hours(2),
285            expires_at: Utc::now() - Duration::hours(1),
286            granted_by: "user".into(),
287        })
288        .unwrap();
289        assert!(matches!(
290            check_permission(&DocType::Reference, &agent("claude"), &o, Some(&path)),
291            PermissionResult::Denied { .. }
292        ));
293    }
294
295    #[test]
296    fn test_override_path_specificity() {
297        let mut o = Overrides::default();
298        let path_a = PathBuf::from("a.md");
299        let path_b = PathBuf::from("b.md");
300        o.add(OverrideEntry {
301            doc_id: "id1".into(),
302            path: path_a.clone(),
303            allow_actor: "agent".into(),
304            granted_at: Utc::now(),
305            expires_at: Utc::now() + Duration::minutes(10),
306            granted_by: "user".into(),
307        })
308        .unwrap();
309        // Override for a.md doesn't affect b.md
310        assert!(matches!(
311            check_permission(&DocType::Reference, &agent("x"), &o, Some(&path_b)),
312            PermissionResult::Denied { .. }
313        ));
314    }
315
316    #[test]
317    fn test_prune_expired() {
318        let mut o = Overrides::default();
319        o.add(OverrideEntry {
320            doc_id: "id1".into(),
321            path: PathBuf::from("a.md"),
322            allow_actor: "agent".into(),
323            granted_at: Utc::now() - Duration::hours(2),
324            expires_at: Utc::now() - Duration::hours(1),
325            granted_by: "user".into(),
326        })
327        .unwrap();
328        o.add(OverrideEntry {
329            doc_id: "id2".into(),
330            path: PathBuf::from("b.md"),
331            allow_actor: "user".into(),
332            granted_at: Utc::now(),
333            expires_at: Utc::now() + Duration::minutes(10),
334            granted_by: "user".into(),
335        })
336        .unwrap();
337        o.prune_expired();
338        assert_eq!(o.entries.len(), 1);
339        assert_eq!(o.entries[0].doc_id, "id2");
340    }
341
342    #[test]
343    fn test_overrides_roundtrip() {
344        let tmp = TempDir::new().unwrap();
345        let root = tmp.path();
346        std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
347        let mut o = Overrides::default();
348        o.add(OverrideEntry {
349            doc_id: "id1".into(),
350            path: PathBuf::from("api.md"),
351            allow_actor: "agent".into(),
352            granted_at: Utc::now(),
353            expires_at: Utc::now() + Duration::minutes(10),
354            granted_by: "user".into(),
355        })
356        .unwrap();
357        o.save(root).unwrap();
358        let loaded = Overrides::load(root).unwrap();
359        assert_eq!(loaded.entries.len(), 1);
360        assert_eq!(loaded.entries[0].doc_id, "id1");
361    }
362}