sparrow_config/permissions/
store.rs1use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::path::PathBuf;
18
19use sparrow_core::event::Decision;
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PermissionStore {
25 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 AskUser,
34 AllowOnce,
36 AllowSession,
38 AllowAlways,
40 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 Decision::Allow => None,
64 }
65 }
66
67 pub fn is_allowed(&self) -> bool {
69 matches!(
70 self,
71 PersistedDecision::AllowOnce
72 | PersistedDecision::AllowSession
73 | PersistedDecision::AllowAlways
74 )
75 }
76
77 pub fn is_durable(&self) -> bool {
79 matches!(
80 self,
81 PersistedDecision::AllowAlways | PersistedDecision::Deny
82 )
83 }
84}
85
86impl PermissionStore {
87 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 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 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 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 pub fn get(&self, tool_name: &str) -> Option<&PersistedDecision> {
129 self.tools.get(tool_name)
130 }
131
132 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 pub fn expire_session_scoped(&mut self) {
150 self.tools.retain(|_, d| d.is_durable());
151 }
152
153 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}