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#[derive(Debug, Clone, PartialEq)]
10pub enum PermissionResult {
11 Allowed,
12 Denied { reason: String },
13 RequiresConfirmation { prompt: String },
14}
15
16pub fn check_permission(
23 doc_type: &DocType,
24 actor: &Actor,
25 overrides: &Overrides,
26 path: Option<&Path>,
27) -> PermissionResult {
28 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 (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 (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 (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 (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 (DocType::Scratch, _) => PermissionResult::Allowed,
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct OverrideEntry {
78 pub doc_id: String,
79 pub path: PathBuf,
80 pub allow_actor: String, 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 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#[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#[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 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}