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