Skip to main content

sparrow_config/permissions/
store.rs

1// ─── Permission persistence store ──────────────────────────────────────────────
2//
3// Persists per-tool permission decisions to disk so "allow always" survives
4// across sessions. Stored as JSON in ~/.config/sparrow/permissions.json.
5//
6// Format:
7// {
8//   "tools": {
9//     "web_search": "allow_always",
10//     "fs_write": "allow_session",
11//     "code_exec": "ask_user"
12//   }
13// }
14
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::path::PathBuf;
18
19use sparrow_core::event::Decision;
20
21// ─── Store ─────────────────────────────────────────────────────────────────────
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PermissionStore {
25    /// Per-tool decisions, keyed by tool name.
26    pub tools: HashMap<String, PersistedDecision>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "snake_case")]
31pub enum PersistedDecision {
32    /// Always ask for confirmation.
33    AskUser,
34    /// Allow once — not persisted (handled in-memory), but accepted for API symmetry.
35    AllowOnce,
36    /// Allow for the remainder of the current session.
37    AllowSession,
38    /// Allow permanently.
39    AllowAlways,
40    /// Deny this tool.
41    Deny,
42}
43
44impl PersistedDecision {
45    pub fn to_decision(&self) -> Decision {
46        match self {
47            PersistedDecision::AskUser => Decision::AskUser,
48            PersistedDecision::AllowOnce => Decision::AllowOnce,
49            PersistedDecision::AllowSession => Decision::AllowSession,
50            PersistedDecision::AllowAlways => Decision::AllowAlways,
51            PersistedDecision::Deny => Decision::Deny,
52        }
53    }
54
55    pub fn from_decision(d: &Decision) -> Option<Self> {
56        match d {
57            Decision::AskUser => Some(PersistedDecision::AskUser),
58            Decision::AllowOnce => Some(PersistedDecision::AllowOnce),
59            Decision::AllowSession => Some(PersistedDecision::AllowSession),
60            Decision::AllowAlways => Some(PersistedDecision::AllowAlways),
61            Decision::Deny => Some(PersistedDecision::Deny),
62            // Plain Allow is not persisted — it's a one-time runtime decision
63            Decision::Allow => None,
64        }
65    }
66
67    /// Whether this decision means the tool should be allowed without asking.
68    pub fn is_allowed(&self) -> bool {
69        matches!(
70            self,
71            PersistedDecision::AllowOnce
72                | PersistedDecision::AllowSession
73                | PersistedDecision::AllowAlways
74        )
75    }
76
77    /// Whether this decision is durable (survives sessions).
78    pub fn is_durable(&self) -> bool {
79        matches!(
80            self,
81            PersistedDecision::AllowAlways | PersistedDecision::Deny
82        )
83    }
84}
85
86impl PermissionStore {
87    /// Load from disk, or return an empty store if the file doesn't exist.
88    pub fn load(config_dir: &PathBuf) -> Self {
89        let path = permissions_path(config_dir);
90        if path.exists() {
91            std::fs::read_to_string(&path)
92                .ok()
93                .and_then(|s| serde_json::from_str(&s).ok())
94                .unwrap_or_default()
95        } else {
96            PermissionStore::default()
97        }
98    }
99
100    /// Save to disk.
101    pub fn save(&self, config_dir: &PathBuf) -> anyhow::Result<()> {
102        let path = permissions_path(config_dir);
103        if let Some(parent) = path.parent() {
104            std::fs::create_dir_all(parent)?;
105        }
106        let json = serde_json::to_string_pretty(self)?;
107        std::fs::write(&path, json)?;
108        Ok(())
109    }
110
111    /// Check if a tool has a persisted decision that allows it.
112    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
113        self.tools
114            .get(tool_name)
115            .map(|d| d.is_allowed())
116            .unwrap_or(false)
117    }
118
119    /// Check if a tool has a persisted decision that denies it.
120    pub fn is_tool_denied(&self, tool_name: &str) -> bool {
121        self.tools
122            .get(tool_name)
123            .map(|d| matches!(d, PersistedDecision::Deny))
124            .unwrap_or(false)
125    }
126
127    /// Get the persisted decision for a tool, if any.
128    pub fn get(&self, tool_name: &str) -> Option<&PersistedDecision> {
129        self.tools.get(tool_name)
130    }
131
132    /// Set a decision for a tool and save to disk (only for durable decisions).
133    pub fn set_and_save(
134        &mut self,
135        tool_name: &str,
136        decision: &Decision,
137        config_dir: &PathBuf,
138    ) -> anyhow::Result<()> {
139        if let Some(pd) = PersistedDecision::from_decision(decision) {
140            if pd.is_durable() {
141                self.tools.insert(tool_name.to_string(), pd);
142                self.save(config_dir)?;
143            }
144        }
145        Ok(())
146    }
147
148    /// Remove session-only decisions (AllowOnce, AllowSession) — called at startup.
149    pub fn expire_session_scoped(&mut self) {
150        self.tools.retain(|_, d| d.is_durable());
151    }
152
153    /// Convert to a JSON-serializable map for the WebView API.
154    pub fn to_api_map(&self) -> HashMap<String, String> {
155        self.tools
156            .iter()
157            .map(|(k, v)| {
158                let s = match v {
159                    PersistedDecision::AskUser => "ask_user",
160                    PersistedDecision::AllowOnce => "allow_once",
161                    PersistedDecision::AllowSession => "allow_session",
162                    PersistedDecision::AllowAlways => "allow_always",
163                    PersistedDecision::Deny => "deny",
164                };
165                (k.clone(), s.to_string())
166            })
167            .collect()
168    }
169}
170
171impl Default for PermissionStore {
172    fn default() -> Self {
173        PermissionStore {
174            tools: HashMap::new(),
175        }
176    }
177}
178
179fn permissions_path(config_dir: &PathBuf) -> PathBuf {
180    config_dir.join("permissions.json")
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_default_store_is_empty() {
189        let store = PermissionStore::default();
190        assert!(store.tools.is_empty());
191    }
192
193    #[test]
194    fn test_is_tool_allowed() {
195        let mut store = PermissionStore::default();
196        store
197            .tools
198            .insert("web_search".into(), PersistedDecision::AllowAlways);
199        assert!(store.is_tool_allowed("web_search"));
200        assert!(!store.is_tool_allowed("code_exec"));
201    }
202
203    #[test]
204    fn test_expire_session_scoped() {
205        let mut store = PermissionStore::default();
206        store
207            .tools
208            .insert("web_search".into(), PersistedDecision::AllowAlways);
209        store
210            .tools
211            .insert("fs_read".into(), PersistedDecision::AllowSession);
212        store.expire_session_scoped();
213        assert!(store.tools.contains_key("web_search"));
214        assert!(!store.tools.contains_key("fs_read"));
215    }
216
217    #[test]
218    fn test_persist_roundtrip() {
219        let tmp = tempfile::tempdir().unwrap();
220        let dir = tmp.path().to_path_buf();
221        let mut store = PermissionStore::default();
222        store
223            .tools
224            .insert("web_search".into(), PersistedDecision::AllowAlways);
225        store.save(&dir).unwrap();
226        let loaded = PermissionStore::load(&dir);
227        assert!(loaded.is_tool_allowed("web_search"));
228    }
229}