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