synaps_cli/extensions/
permissions.rs1use std::collections::HashSet;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum Permission {
15 ToolsIntercept,
17 ToolsOverride,
19 LlmContent,
21 SessionLifecycle,
23 ToolsRegister,
25 ProvidersRegister,
27 MemoryRead,
29 MemoryWrite,
31 ConfigWrite,
33 ConfigSubscribe,
35 AudioInput,
37 AudioOutput,
39}
40
41impl Permission {
42 pub fn as_str(&self) -> &'static str {
44 match self {
45 Self::ToolsIntercept => "tools.intercept",
46 Self::ToolsOverride => "tools.override",
47 Self::LlmContent => "privacy.llm_content",
48 Self::SessionLifecycle => "session.lifecycle",
49 Self::ToolsRegister => "tools.register",
50 Self::ProvidersRegister => "providers.register",
51 Self::MemoryRead => "memory.read",
52 Self::MemoryWrite => "memory.write",
53 Self::ConfigWrite => "config.write",
54 Self::ConfigSubscribe => "config.subscribe",
55 Self::AudioInput => "audio.input",
56 Self::AudioOutput => "audio.output",
57 }
58 }
59
60 pub fn parse(s: &str) -> Option<Self> {
62 match s {
63 "tools.intercept" => Some(Self::ToolsIntercept),
64 "tools.override" => Some(Self::ToolsOverride),
65 "privacy.llm_content" => Some(Self::LlmContent),
66 "session.lifecycle" => Some(Self::SessionLifecycle),
67 "tools.register" => Some(Self::ToolsRegister),
68 "providers.register" => Some(Self::ProvidersRegister),
69 "memory.read" => Some(Self::MemoryRead),
70 "memory.write" => Some(Self::MemoryWrite),
71 "config.write" => Some(Self::ConfigWrite),
72 "config.subscribe" => Some(Self::ConfigSubscribe),
73 "audio.input" => Some(Self::AudioInput),
74 "audio.output" => Some(Self::AudioOutput),
75 _ => None,
76 }
77 }
78 pub fn is_reserved(&self) -> bool {
80 matches!(
81 self,
82 Self::ToolsOverride
83 )
84 }
85}
86
87#[derive(Debug, Clone, Default)]
89pub struct PermissionSet {
90 permissions: HashSet<Permission>,
91}
92
93impl PermissionSet {
94 pub fn new() -> Self {
96 Self::default()
97 }
98
99 pub fn from_strings(perms: &[String]) -> Self {
105 let permissions = perms.iter().filter_map(|s| Permission::parse(s)).collect();
106 Self { permissions }
107 }
108
109 pub fn try_from_strings(perms: &[String]) -> Result<Self, String> {
111 let mut permissions = HashSet::new();
112 for perm in perms {
113 let parsed = Permission::parse(perm)
114 .ok_or_else(|| format!("Unknown extension permission: {perm}"))?;
115 if parsed.is_reserved() {
116 return Err(format!(
117 "Reserved extension permission is not implemented yet: {perm}"
118 ));
119 }
120 permissions.insert(parsed);
121 }
122 Ok(Self { permissions })
123 }
124
125 pub fn has(&self, perm: Permission) -> bool {
127 self.permissions.contains(&perm)
128 }
129
130 pub fn grant(&mut self, perm: Permission) {
132 self.permissions.insert(perm);
133 }
134
135 pub fn allows_hook(&self, kind: crate::extensions::hooks::events::HookKind) -> bool {
137 self.has(kind.required_permission())
138 }
139
140 pub fn len(&self) -> usize {
142 self.permissions.len()
143 }
144
145 pub fn is_empty(&self) -> bool {
147 self.permissions.is_empty()
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::extensions::hooks::events::HookKind;
155
156 #[test]
157 fn parse_valid_permissions() {
158 assert_eq!(Permission::parse("tools.intercept"), Some(Permission::ToolsIntercept));
159 assert_eq!(Permission::parse("privacy.llm_content"), Some(Permission::LlmContent));
160 assert_eq!(Permission::parse("session.lifecycle"), Some(Permission::SessionLifecycle));
161 }
162
163 #[test]
164 fn parse_invalid_returns_none() {
165 assert_eq!(Permission::parse("invalid"), None);
166 assert_eq!(Permission::parse(""), None);
167 }
168
169 #[test]
170 fn from_strings_skips_invalid() {
171 let perms = PermissionSet::from_strings(&[
172 "tools.intercept".into(),
173 "bogus".into(),
174 "session.lifecycle".into(),
175 ]);
176 assert_eq!(perms.len(), 2);
177 assert!(perms.has(Permission::ToolsIntercept));
178 assert!(perms.has(Permission::SessionLifecycle));
179 assert!(!perms.has(Permission::LlmContent));
180 }
181
182 #[test]
183 fn allows_hook_checks_required_permission() {
184 let mut perms = PermissionSet::new();
185 assert!(!perms.allows_hook(HookKind::BeforeToolCall));
186
187 perms.grant(Permission::ToolsIntercept);
188 assert!(perms.allows_hook(HookKind::BeforeToolCall));
189 assert!(perms.allows_hook(HookKind::AfterToolCall));
190 assert!(!perms.allows_hook(HookKind::BeforeMessage)); }
192
193 #[test]
194 fn empty_set() {
195 let perms = PermissionSet::new();
196 assert!(perms.is_empty());
197 assert_eq!(perms.len(), 0);
198 }
199
200 #[test]
201 fn providers_register_is_active_but_tools_override_remains_reserved() {
202 let perms = PermissionSet::try_from_strings(&["providers.register".to_string()]).unwrap();
203 assert!(perms.has(Permission::ProvidersRegister));
204
205 let err = PermissionSet::try_from_strings(&["tools.override".to_string()]).unwrap_err();
206 assert!(err.contains("Reserved extension permission"));
207 }
208
209 #[test]
210 fn memory_permissions_parse_and_are_not_reserved() {
211 assert_eq!(Permission::parse("memory.read"), Some(Permission::MemoryRead));
212 assert_eq!(Permission::parse("memory.write"), Some(Permission::MemoryWrite));
213 assert!(!Permission::MemoryRead.is_reserved());
214 assert!(!Permission::MemoryWrite.is_reserved());
215 let perms = PermissionSet::try_from_strings(&[
216 "memory.read".to_string(),
217 "memory.write".to_string(),
218 ])
219 .unwrap();
220 assert!(perms.has(Permission::MemoryRead));
221 assert!(perms.has(Permission::MemoryWrite));
222 }
223
224 #[test]
225 fn audio_permissions_parse_and_are_not_reserved() {
226 assert_eq!(Permission::parse("audio.input"), Some(Permission::AudioInput));
227 assert_eq!(Permission::parse("audio.output"), Some(Permission::AudioOutput));
228 assert!(!Permission::AudioInput.is_reserved());
229 assert!(!Permission::AudioOutput.is_reserved());
230 let perms = PermissionSet::try_from_strings(&[
231 "audio.input".to_string(),
232 "audio.output".to_string(),
233 ])
234 .unwrap();
235 assert!(perms.has(Permission::AudioInput));
236 assert!(perms.has(Permission::AudioOutput));
237 }
238
239 #[test]
240 fn round_trip_as_str() {
241 for perm in [
242 Permission::ToolsIntercept,
243 Permission::ToolsOverride,
244 Permission::LlmContent,
245 Permission::SessionLifecycle,
246 Permission::ToolsRegister,
247 Permission::ProvidersRegister,
248 Permission::MemoryRead,
249 Permission::MemoryWrite,
250 Permission::AudioInput,
251 Permission::AudioOutput,
252 ] {
253 assert_eq!(Permission::parse(perm.as_str()), Some(perm));
254 }
255 }
256}