1use 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 ToolsTransformOutput,
22 ToolsOverride,
24 LlmContent,
26 SessionLifecycle,
28 SessionDrive,
31 ToolsRegister,
33 ProvidersRegister,
35 ContextProvidersRegister,
41 MemoryRead,
43 MemoryWrite,
45 ConfigWrite,
47 ConfigSubscribe,
49 AudioInput,
51 AudioOutput,
53}
54
55impl Permission {
56 pub const ALL: &'static [Permission] = &[
68 Self::ToolsIntercept,
69 Self::ToolsTransformOutput,
70 Self::ToolsOverride,
71 Self::LlmContent,
72 Self::SessionLifecycle,
73 Self::SessionDrive,
74 Self::ToolsRegister,
75 Self::ProvidersRegister,
76 Self::ContextProvidersRegister,
77 Self::MemoryRead,
78 Self::MemoryWrite,
79 Self::ConfigWrite,
80 Self::ConfigSubscribe,
81 Self::AudioInput,
82 Self::AudioOutput,
83 ];
84
85 pub fn as_str(&self) -> &'static str {
87 match self {
88 Self::ToolsIntercept => "tools.intercept",
89 Self::ToolsTransformOutput => "tools.transform_output",
90 Self::ToolsOverride => "tools.override",
91 Self::LlmContent => "privacy.llm_content",
92 Self::SessionLifecycle => "session.lifecycle",
93 Self::SessionDrive => "session.drive",
94 Self::ToolsRegister => "tools.register",
95 Self::ProvidersRegister => "providers.register",
96 Self::ContextProvidersRegister => "context_providers.register",
97 Self::MemoryRead => "memory.read",
98 Self::MemoryWrite => "memory.write",
99 Self::ConfigWrite => "config.write",
100 Self::ConfigSubscribe => "config.subscribe",
101 Self::AudioInput => "audio.input",
102 Self::AudioOutput => "audio.output",
103 }
104 }
105
106 pub fn parse(s: &str) -> Option<Self> {
108 match s {
109 "tools.intercept" => Some(Self::ToolsIntercept),
110 "tools.transform_output" => Some(Self::ToolsTransformOutput),
111 "tools.override" => Some(Self::ToolsOverride),
112 "privacy.llm_content" => Some(Self::LlmContent),
113 "session.lifecycle" => Some(Self::SessionLifecycle),
114 "session.drive" => Some(Self::SessionDrive),
115 "tools.register" => Some(Self::ToolsRegister),
116 "providers.register" => Some(Self::ProvidersRegister),
117 "context_providers.register" => Some(Self::ContextProvidersRegister),
118 "memory.read" => Some(Self::MemoryRead),
119 "memory.write" => Some(Self::MemoryWrite),
120 "config.write" => Some(Self::ConfigWrite),
121 "config.subscribe" => Some(Self::ConfigSubscribe),
122 "audio.input" => Some(Self::AudioInput),
123 "audio.output" => Some(Self::AudioOutput),
124 _ => None,
125 }
126 }
127 pub fn is_reserved(&self) -> bool {
129 matches!(self, Self::ToolsOverride)
130 }
131}
132
133#[derive(Debug, Clone, Default)]
135pub struct PermissionSet {
136 permissions: HashSet<Permission>,
137}
138
139impl PermissionSet {
140 pub fn new() -> Self {
142 Self::default()
143 }
144
145 pub fn from_strings(perms: &[String]) -> Self {
151 let permissions = perms.iter().filter_map(|s| Permission::parse(s)).collect();
152 Self { permissions }
153 }
154
155 pub fn try_from_strings(perms: &[String]) -> Result<Self, String> {
157 let mut permissions = HashSet::new();
158 for perm in perms {
159 let parsed = Permission::parse(perm)
160 .ok_or_else(|| format!("Unknown extension permission: {perm}"))?;
161 if parsed.is_reserved() {
162 return Err(format!(
163 "Reserved extension permission is not implemented yet: {perm}"
164 ));
165 }
166 permissions.insert(parsed);
167 }
168 Ok(Self { permissions })
169 }
170
171 pub fn has(&self, perm: Permission) -> bool {
173 self.permissions.contains(&perm)
174 }
175
176 pub fn grant(&mut self, perm: Permission) {
178 self.permissions.insert(perm);
179 }
180
181 pub fn allows_hook(&self, kind: crate::extensions::hooks::events::HookKind) -> bool {
183 self.has(kind.required_permission())
184 }
185
186 pub fn len(&self) -> usize {
188 self.permissions.len()
189 }
190
191 pub fn is_empty(&self) -> bool {
193 self.permissions.is_empty()
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::extensions::hooks::events::HookKind;
201
202 #[test]
203 fn parse_valid_permissions() {
204 assert_eq!(
205 Permission::parse("tools.intercept"),
206 Some(Permission::ToolsIntercept)
207 );
208 assert_eq!(
209 Permission::parse("privacy.llm_content"),
210 Some(Permission::LlmContent)
211 );
212 assert_eq!(
213 Permission::parse("session.lifecycle"),
214 Some(Permission::SessionLifecycle)
215 );
216 }
217
218 #[test]
219 fn session_drive_is_active_explicit_and_grants_no_hooks() {
220 assert_eq!(
221 Permission::parse("session.drive"),
222 Some(Permission::SessionDrive)
223 );
224 assert!(!Permission::SessionDrive.is_reserved());
225 let perms = PermissionSet::try_from_strings(&["session.drive".into()]).unwrap();
226 assert!(perms.has(Permission::SessionDrive));
227 assert!(!perms.has(Permission::SessionLifecycle));
228 assert!(!perms.allows_hook(HookKind::OnSessionStart));
229 assert!(!perms.allows_hook(HookKind::BeforeMessage));
230 assert_eq!(Permission::parse("session_drive"), None);
231 }
232
233 #[test]
234 fn parse_invalid_returns_none() {
235 assert_eq!(Permission::parse("invalid"), None);
236 assert_eq!(Permission::parse(""), None);
237 }
238
239 #[test]
240 fn from_strings_skips_invalid() {
241 let perms = PermissionSet::from_strings(&[
242 "tools.intercept".into(),
243 "bogus".into(),
244 "session.lifecycle".into(),
245 ]);
246 assert_eq!(perms.len(), 2);
247 assert!(perms.has(Permission::ToolsIntercept));
248 assert!(perms.has(Permission::SessionLifecycle));
249 assert!(!perms.has(Permission::LlmContent));
250 }
251
252 #[test]
253 fn allows_hook_checks_required_permission() {
254 let mut perms = PermissionSet::new();
255 assert!(!perms.allows_hook(HookKind::BeforeToolCall));
256
257 perms.grant(Permission::ToolsIntercept);
258 assert!(perms.allows_hook(HookKind::BeforeToolCall));
259 assert!(perms.allows_hook(HookKind::AfterToolCall));
260 assert!(!perms.allows_hook(HookKind::BeforeMessage)); }
262
263 #[test]
264 fn empty_set() {
265 let perms = PermissionSet::new();
266 assert!(perms.is_empty());
267 assert_eq!(perms.len(), 0);
268 }
269
270 #[test]
271 fn providers_register_is_active_but_tools_override_remains_reserved() {
272 let perms = PermissionSet::try_from_strings(&["providers.register".to_string()]).unwrap();
273 assert!(perms.has(Permission::ProvidersRegister));
274
275 let err = PermissionSet::try_from_strings(&["tools.override".to_string()]).unwrap_err();
276 assert!(err.contains("Reserved extension permission"));
277 }
278
279 #[test]
280 fn memory_permissions_parse_and_are_not_reserved() {
281 assert_eq!(
282 Permission::parse("memory.read"),
283 Some(Permission::MemoryRead)
284 );
285 assert_eq!(
286 Permission::parse("memory.write"),
287 Some(Permission::MemoryWrite)
288 );
289 assert!(!Permission::MemoryRead.is_reserved());
290 assert!(!Permission::MemoryWrite.is_reserved());
291 let perms = PermissionSet::try_from_strings(&[
292 "memory.read".to_string(),
293 "memory.write".to_string(),
294 ])
295 .unwrap();
296 assert!(perms.has(Permission::MemoryRead));
297 assert!(perms.has(Permission::MemoryWrite));
298 }
299
300 #[test]
301 fn audio_permissions_parse_and_are_not_reserved() {
302 assert_eq!(
303 Permission::parse("audio.input"),
304 Some(Permission::AudioInput)
305 );
306 assert_eq!(
307 Permission::parse("audio.output"),
308 Some(Permission::AudioOutput)
309 );
310 assert!(!Permission::AudioInput.is_reserved());
311 assert!(!Permission::AudioOutput.is_reserved());
312 let perms = PermissionSet::try_from_strings(&[
313 "audio.input".to_string(),
314 "audio.output".to_string(),
315 ])
316 .unwrap();
317 assert!(perms.has(Permission::AudioInput));
318 assert!(perms.has(Permission::AudioOutput));
319 }
320
321 #[test]
322 fn context_providers_register_parses_and_is_not_reserved() {
323 assert_eq!(
324 Permission::parse("context_providers.register"),
325 Some(Permission::ContextProvidersRegister)
326 );
327 assert!(!Permission::ContextProvidersRegister.is_reserved());
328 let perms =
329 PermissionSet::try_from_strings(&["context_providers.register".to_string()]).unwrap();
330 assert!(perms.has(Permission::ContextProvidersRegister));
331 assert!(!perms.has(Permission::ProvidersRegister));
333 }
334
335 #[test]
336 fn round_trip_as_str() {
337 for perm in [
338 Permission::ToolsIntercept,
339 Permission::ToolsOverride,
340 Permission::LlmContent,
341 Permission::SessionLifecycle,
342 Permission::ToolsRegister,
343 Permission::ProvidersRegister,
344 Permission::ContextProvidersRegister,
345 Permission::MemoryRead,
346 Permission::MemoryWrite,
347 Permission::AudioInput,
348 Permission::AudioOutput,
349 ] {
350 assert_eq!(Permission::parse(perm.as_str()), Some(perm));
351 }
352 }
353
354 #[test]
363 fn all_covers_every_variant() {
364 for perm in Permission::ALL {
365 match perm {
366 Permission::ToolsIntercept
367 | Permission::ToolsTransformOutput
368 | Permission::ToolsOverride
369 | Permission::LlmContent
370 | Permission::SessionLifecycle
371 | Permission::SessionDrive
372 | Permission::ToolsRegister
373 | Permission::ProvidersRegister
374 | Permission::ContextProvidersRegister
375 | Permission::MemoryRead
376 | Permission::MemoryWrite
377 | Permission::ConfigWrite
378 | Permission::ConfigSubscribe
379 | Permission::AudioInput
380 | Permission::AudioOutput => {}
381 }
382 }
383 assert_eq!(
384 Permission::ALL.len(),
385 15,
386 "a Permission variant was added or removed -- update Permission::ALL \
387 and docs/extensions/contract.json to match"
388 );
389 let unique: HashSet<Permission> = Permission::ALL.iter().copied().collect();
390 assert_eq!(
391 unique.len(),
392 Permission::ALL.len(),
393 "Permission::ALL contains a duplicate"
394 );
395 }
396
397 #[test]
399 fn all_permissions_round_trip_through_wire_format() {
400 for perm in Permission::ALL {
401 assert_eq!(
402 Permission::parse(perm.as_str()),
403 Some(*perm),
404 "{} does not round-trip",
405 perm.as_str()
406 );
407 }
408 }
409}