1use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
23pub struct ScopeKey {
24 pub agent_id: String,
25 pub tool_name: String,
26 pub input_schema_hash: String,
28}
29
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
31pub enum GrantDecision {
32 Allow,
33 Deny,
34}
35
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37pub enum GrantSource {
38 Ui,
39 Headless,
40 Default,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Grant {
45 pub scope_key: ScopeKey,
46 pub decision: GrantDecision,
47 pub granted_at: chrono::DateTime<chrono::Utc>,
48 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
49 pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
50 pub source: GrantSource,
51 pub source_audit_id: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(tag = "type", rename_all = "snake_case")]
56pub enum AuditEvent {
57 AskedUser {
58 ts: chrono::DateTime<chrono::Utc>,
59 scope_key: ScopeKey,
60 prompt_hash: String,
61 ttl_ms: u64,
62 },
63 GrantWritten {
64 ts: chrono::DateTime<chrono::Utc>,
65 scope_key: ScopeKey,
66 decision: GrantDecision,
67 source: GrantSource,
68 },
69 GrantUsed {
70 ts: chrono::DateTime<chrono::Utc>,
71 scope_key: ScopeKey,
72 },
73 HeadlessDenied {
74 ts: chrono::DateTime<chrono::Utc>,
75 scope_key: ScopeKey,
76 },
77 Revoked {
78 ts: chrono::DateTime<chrono::Utc>,
79 scope_key: ScopeKey,
80 },
81}
82
83#[derive(Debug, Default, Serialize, Deserialize)]
84pub struct GrantsFile {
85 pub version: u32,
86 pub grants: Vec<Grant>,
87}
88
89pub struct GrantStore {
92 grants_path: PathBuf,
93 audit_path: PathBuf,
94 cache: HashMap<ScopeKey, Grant>,
95}
96
97impl GrantStore {
98 pub fn new<P: AsRef<Path>>(agent_dir: P) -> Self {
101 let dir = agent_dir.as_ref().join("permissions");
102 Self {
103 grants_path: dir.join("grants.yaml"),
104 audit_path: dir.join("audit.jsonl"),
105 cache: HashMap::new(),
106 }
107 }
108
109 pub fn load(&mut self) -> std::io::Result<()> {
110 if !self.grants_path.exists() {
111 return Ok(());
112 }
113 let bytes = std::fs::read(&self.grants_path)?;
114 let file: GrantsFile = serde_yaml_ng::from_slice(&bytes)
115 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
116 self.cache.clear();
117 for g in file.grants {
118 self.cache.insert(g.scope_key.clone(), g);
119 }
120 Ok(())
121 }
122
123 pub fn lookup(
126 &self,
127 key: &ScopeKey,
128 now: chrono::DateTime<chrono::Utc>,
129 ) -> Option<GrantDecision> {
130 self.cache.get(key).and_then(|g| {
131 if let Some(expires) = g.expires_at
132 && now > expires
133 {
134 return None;
135 }
136 Some(g.decision)
137 })
138 }
139
140 pub fn insert(&mut self, grant: Grant) -> std::io::Result<()> {
141 self.cache.insert(grant.scope_key.clone(), grant);
142 self.persist()
143 }
144
145 pub fn revoke(
146 &mut self,
147 key: &ScopeKey,
148 now: chrono::DateTime<chrono::Utc>,
149 ) -> std::io::Result<()> {
150 if self.cache.remove(key).is_none() {
151 return Ok(()); }
153 self.append_audit(&AuditEvent::Revoked {
154 ts: now,
155 scope_key: key.clone(),
156 })?;
157 self.persist()
158 }
159
160 pub fn append_audit(&self, event: &AuditEvent) -> std::io::Result<()> {
161 if let Some(parent) = self.audit_path.parent() {
162 std::fs::create_dir_all(parent)?;
163 }
164 let mut line = serde_json::to_string(event)
165 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
166 line.push('\n');
167 use std::io::Write;
168 let mut f = std::fs::OpenOptions::new()
169 .create(true)
170 .append(true)
171 .open(&self.audit_path)?;
172 f.write_all(line.as_bytes())?;
173 Ok(())
174 }
175
176 fn persist(&self) -> std::io::Result<()> {
177 if let Some(parent) = self.grants_path.parent() {
178 std::fs::create_dir_all(parent)?;
179 }
180 let file = GrantsFile {
181 version: 1,
182 grants: self.cache.values().cloned().collect(),
183 };
184 let bytes = serde_yaml_ng::to_string(&file)
185 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
186 let tmp = self.grants_path.with_extension("yaml.tmp");
187 std::fs::write(&tmp, bytes)?;
188 #[cfg(unix)]
189 {
190 use std::os::unix::fs::PermissionsExt;
191 let perms = std::fs::Permissions::from_mode(0o600);
192 std::fs::set_permissions(&tmp, perms)?;
193 }
194 std::fs::rename(&tmp, &self.grants_path)?;
195 Ok(())
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use tempfile::tempdir;
203
204 fn key(tool: &str) -> ScopeKey {
205 ScopeKey {
206 agent_id: "coach".into(),
207 tool_name: tool.into(),
208 input_schema_hash: "sha".into(),
209 }
210 }
211
212 #[test]
213 fn insert_and_lookup_round_trip() {
214 let dir = tempdir().unwrap();
215 let mut store = GrantStore::new(dir.path());
216 let now = chrono::Utc::now();
217 let k = key("fs.write");
218 store
219 .insert(Grant {
220 scope_key: k.clone(),
221 decision: GrantDecision::Allow,
222 granted_at: now,
223 expires_at: Some(now + chrono::Duration::days(30)),
224 last_used_at: None,
225 source: GrantSource::Ui,
226 source_audit_id: None,
227 })
228 .unwrap();
229
230 let mut store2 = GrantStore::new(dir.path());
231 store2.load().unwrap();
232 assert_eq!(store2.lookup(&k, now), Some(GrantDecision::Allow));
233 }
234
235 #[test]
236 fn lookup_returns_none_for_expired_grant() {
237 let dir = tempdir().unwrap();
238 let mut store = GrantStore::new(dir.path());
239 let now = chrono::Utc::now();
240 let k = key("fs.write");
241 store
242 .insert(Grant {
243 scope_key: k.clone(),
244 decision: GrantDecision::Allow,
245 granted_at: now,
246 expires_at: Some(now - chrono::Duration::seconds(1)),
247 last_used_at: None,
248 source: GrantSource::Ui,
249 source_audit_id: None,
250 })
251 .unwrap();
252 assert_eq!(store.lookup(&k, now), None);
253 }
254
255 #[test]
256 fn audit_log_appends_and_persists() {
257 let dir = tempdir().unwrap();
258 let store = GrantStore::new(dir.path());
259 let now = chrono::Utc::now();
260 store
261 .append_audit(&AuditEvent::AskedUser {
262 ts: now,
263 scope_key: key("bash"),
264 prompt_hash: "abc".into(),
265 ttl_ms: 120_000,
266 })
267 .unwrap();
268 store
269 .append_audit(&AuditEvent::HeadlessDenied {
270 ts: now,
271 scope_key: key("net.connect"),
272 })
273 .unwrap();
274
275 let body = std::fs::read_to_string(dir.path().join("permissions/audit.jsonl")).unwrap();
276 let lines: Vec<_> = body.lines().collect();
277 assert_eq!(lines.len(), 2);
278 assert!(lines[0].contains("asked_user"));
279 assert!(lines[1].contains("headless_denied"));
280 }
281
282 #[test]
283 fn revoke_drops_grant_and_writes_audit() {
284 let dir = tempdir().unwrap();
285 let mut store = GrantStore::new(dir.path());
286 let now = chrono::Utc::now();
287 let k = key("fs.write");
288 store
289 .insert(Grant {
290 scope_key: k.clone(),
291 decision: GrantDecision::Allow,
292 granted_at: now,
293 expires_at: None,
294 last_used_at: None,
295 source: GrantSource::Ui,
296 source_audit_id: None,
297 })
298 .unwrap();
299 store.revoke(&k, now).unwrap();
300 assert_eq!(store.lookup(&k, now), None);
301 let body = std::fs::read_to_string(dir.path().join("permissions/audit.jsonl")).unwrap();
302 assert!(body.contains("revoked"));
303 }
304}