Skip to main content

mur_common/
permissions.rs

1//! Permission grants and audit log for the AskUser flow.
2//!
3//! `Decision::AskUser` from a `pre_tool_use` hook surfaces an inline
4//! approval card to the user; the GUI writes the user's choice into a
5//! `Grant` here. Grants are scoped by `(agent_id, tool_name,
6//! sha256(canonical input subset))` — each tool declares which input
7//! fields contribute (e.g. bash → argv[0]; fs.write → directory prefix).
8//!
9//! Storage:
10//! * `~/.mur/agents/<name>/permissions/grants.yaml` (0600, atomic
11//!   temp+rename)
12//! * `~/.mur/agents/<name>/permissions/audit.jsonl` (append-only,
13//!   never mutated)
14//!
15//! The `B0SafetyHook` (M8) reads grants in `pre_tool_use`; the GUI
16//! Settings → Permissions tab manages them (revoke / list).
17
18use 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    /// SHA-256 (hex) over the canonical-JSON of a per-tool subset of inputs.
27    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
89/// Read/write `~/.mur/agents/<name>/permissions/grants.yaml` (0600,
90/// atomic temp+rename) plus an append-only `audit.jsonl`.
91pub struct GrantStore {
92    grants_path: PathBuf,
93    audit_path: PathBuf,
94    cache: HashMap<ScopeKey, Grant>,
95}
96
97impl GrantStore {
98    /// `agent_dir` is `~/.mur/agents/<name>/`; this stores under
99    /// `<agent_dir>/permissions/`.
100    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    /// Returns the live `Decision` if the grant is present and not
124    /// expired; `None` otherwise (caller must AskUser or auto-Deny).
125    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(()); // idempotent
152        }
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}