Skip to main content

agent_engine/extensions/
permissions.rs

1//! Permission model for extensions.
2//!
3//! Permissions are declared in the plugin manifest and enforced before
4//! delivering hook events. An extension without the required permission
5//! cannot subscribe to the corresponding hook.
6
7use std::collections::HashSet;
8
9use serde::{Deserialize, Serialize};
10
11/// Permission flags an extension can request.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum Permission {
15    /// Can subscribe to before_tool_call / after_tool_call hooks.
16    ToolsIntercept,
17    /// Can rewrite tool OUTPUT via `after_tool_call` → `HookResult::Replace`.
18    /// Distinct from (and additional to) `tools.intercept`: observing a tool's
19    /// output is a weaker capability than silently rewriting what the model
20    /// believes the tool returned, so it requires explicit, separate consent.
21    ToolsTransformOutput,
22    /// Can override built-in tools.
23    ToolsOverride,
24    /// Can read LLM input/output (before_message hook).
25    LlmContent,
26    /// Can subscribe to session lifecycle hooks.
27    SessionLifecycle,
28    /// Can register new tools.
29    ToolsRegister,
30    /// Can register new providers.
31    ProvidersRegister,
32    /// Can declare dormant memory/context contribution providers
33    /// (continuous-memory spec §7.1). Distinct from `ProvidersRegister`
34    /// (model/LLM providers): this gates only passive context-provider
35    /// capability metadata; activation additionally requires an exact
36    /// memory-context lease (task A6).
37    ContextProvidersRegister,
38    /// Can read from the local memory store via `memory.query`.
39    MemoryRead,
40    /// Can append to the local memory store via `memory.append`.
41    MemoryWrite,
42    /// Can read/write its own plugin-namespaced config via `config.get`/`config.set`.
43    ConfigWrite,
44    /// Can subscribe to hot-reload notifications for its own plugin config.
45    ConfigSubscribe,
46    /// Can capture audio from input devices.
47    AudioInput,
48    /// Can produce audio through output devices.
49    AudioOutput,
50}
51
52impl Permission {
53    /// Wire-format string for this permission.
54    pub fn as_str(&self) -> &'static str {
55        match self {
56            Self::ToolsIntercept => "tools.intercept",
57            Self::ToolsTransformOutput => "tools.transform_output",
58            Self::ToolsOverride => "tools.override",
59            Self::LlmContent => "privacy.llm_content",
60            Self::SessionLifecycle => "session.lifecycle",
61            Self::ToolsRegister => "tools.register",
62            Self::ProvidersRegister => "providers.register",
63            Self::ContextProvidersRegister => "context_providers.register",
64            Self::MemoryRead => "memory.read",
65            Self::MemoryWrite => "memory.write",
66            Self::ConfigWrite => "config.write",
67            Self::ConfigSubscribe => "config.subscribe",
68            Self::AudioInput => "audio.input",
69            Self::AudioOutput => "audio.output",
70        }
71    }
72
73    /// Parse from wire-format string.
74    pub fn parse(s: &str) -> Option<Self> {
75        match s {
76            "tools.intercept" => Some(Self::ToolsIntercept),
77            "tools.transform_output" => Some(Self::ToolsTransformOutput),
78            "tools.override" => Some(Self::ToolsOverride),
79            "privacy.llm_content" => Some(Self::LlmContent),
80            "session.lifecycle" => Some(Self::SessionLifecycle),
81            "tools.register" => Some(Self::ToolsRegister),
82            "providers.register" => Some(Self::ProvidersRegister),
83            "context_providers.register" => Some(Self::ContextProvidersRegister),
84            "memory.read" => Some(Self::MemoryRead),
85            "memory.write" => Some(Self::MemoryWrite),
86            "config.write" => Some(Self::ConfigWrite),
87            "config.subscribe" => Some(Self::ConfigSubscribe),
88            "audio.input" => Some(Self::AudioInput),
89            "audio.output" => Some(Self::AudioOutput),
90            _ => None,
91        }
92    }
93    /// Whether this permission is reserved for a future implementation.
94    pub fn is_reserved(&self) -> bool {
95        matches!(self, Self::ToolsOverride)
96    }
97}
98
99/// A set of permissions granted to an extension.
100#[derive(Debug, Clone, Default)]
101pub struct PermissionSet {
102    permissions: HashSet<Permission>,
103}
104
105impl PermissionSet {
106    /// Empty permission set.
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Parse permission strings (from manifest) into a set.
112    ///
113    /// This lenient parser is kept for tests and internal callers that have
114    /// already validated manifests. Extension manifests should use
115    /// [`try_from_strings`](Self::try_from_strings) so typos fail loudly.
116    pub fn from_strings(perms: &[String]) -> Self {
117        let permissions = perms.iter().filter_map(|s| Permission::parse(s)).collect();
118        Self { permissions }
119    }
120
121    /// Parse permission strings and reject unknown values.
122    pub fn try_from_strings(perms: &[String]) -> Result<Self, String> {
123        let mut permissions = HashSet::new();
124        for perm in perms {
125            let parsed = Permission::parse(perm)
126                .ok_or_else(|| format!("Unknown extension permission: {perm}"))?;
127            if parsed.is_reserved() {
128                return Err(format!(
129                    "Reserved extension permission is not implemented yet: {perm}"
130                ));
131            }
132            permissions.insert(parsed);
133        }
134        Ok(Self { permissions })
135    }
136
137    /// Check if a permission is granted.
138    pub fn has(&self, perm: Permission) -> bool {
139        self.permissions.contains(&perm)
140    }
141
142    /// Grant a permission.
143    pub fn grant(&mut self, perm: Permission) {
144        self.permissions.insert(perm);
145    }
146
147    /// Check if this set allows subscribing to the given hook.
148    pub fn allows_hook(&self, kind: crate::extensions::hooks::events::HookKind) -> bool {
149        self.has(kind.required_permission())
150    }
151
152    /// Number of permissions.
153    pub fn len(&self) -> usize {
154        self.permissions.len()
155    }
156
157    /// Whether no permissions are granted.
158    pub fn is_empty(&self) -> bool {
159        self.permissions.is_empty()
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::extensions::hooks::events::HookKind;
167
168    #[test]
169    fn parse_valid_permissions() {
170        assert_eq!(
171            Permission::parse("tools.intercept"),
172            Some(Permission::ToolsIntercept)
173        );
174        assert_eq!(
175            Permission::parse("privacy.llm_content"),
176            Some(Permission::LlmContent)
177        );
178        assert_eq!(
179            Permission::parse("session.lifecycle"),
180            Some(Permission::SessionLifecycle)
181        );
182    }
183
184    #[test]
185    fn parse_invalid_returns_none() {
186        assert_eq!(Permission::parse("invalid"), None);
187        assert_eq!(Permission::parse(""), None);
188    }
189
190    #[test]
191    fn from_strings_skips_invalid() {
192        let perms = PermissionSet::from_strings(&[
193            "tools.intercept".into(),
194            "bogus".into(),
195            "session.lifecycle".into(),
196        ]);
197        assert_eq!(perms.len(), 2);
198        assert!(perms.has(Permission::ToolsIntercept));
199        assert!(perms.has(Permission::SessionLifecycle));
200        assert!(!perms.has(Permission::LlmContent));
201    }
202
203    #[test]
204    fn allows_hook_checks_required_permission() {
205        let mut perms = PermissionSet::new();
206        assert!(!perms.allows_hook(HookKind::BeforeToolCall));
207
208        perms.grant(Permission::ToolsIntercept);
209        assert!(perms.allows_hook(HookKind::BeforeToolCall));
210        assert!(perms.allows_hook(HookKind::AfterToolCall));
211        assert!(!perms.allows_hook(HookKind::BeforeMessage)); // needs LlmContent
212    }
213
214    #[test]
215    fn empty_set() {
216        let perms = PermissionSet::new();
217        assert!(perms.is_empty());
218        assert_eq!(perms.len(), 0);
219    }
220
221    #[test]
222    fn providers_register_is_active_but_tools_override_remains_reserved() {
223        let perms = PermissionSet::try_from_strings(&["providers.register".to_string()]).unwrap();
224        assert!(perms.has(Permission::ProvidersRegister));
225
226        let err = PermissionSet::try_from_strings(&["tools.override".to_string()]).unwrap_err();
227        assert!(err.contains("Reserved extension permission"));
228    }
229
230    #[test]
231    fn memory_permissions_parse_and_are_not_reserved() {
232        assert_eq!(
233            Permission::parse("memory.read"),
234            Some(Permission::MemoryRead)
235        );
236        assert_eq!(
237            Permission::parse("memory.write"),
238            Some(Permission::MemoryWrite)
239        );
240        assert!(!Permission::MemoryRead.is_reserved());
241        assert!(!Permission::MemoryWrite.is_reserved());
242        let perms = PermissionSet::try_from_strings(&[
243            "memory.read".to_string(),
244            "memory.write".to_string(),
245        ])
246        .unwrap();
247        assert!(perms.has(Permission::MemoryRead));
248        assert!(perms.has(Permission::MemoryWrite));
249    }
250
251    #[test]
252    fn audio_permissions_parse_and_are_not_reserved() {
253        assert_eq!(
254            Permission::parse("audio.input"),
255            Some(Permission::AudioInput)
256        );
257        assert_eq!(
258            Permission::parse("audio.output"),
259            Some(Permission::AudioOutput)
260        );
261        assert!(!Permission::AudioInput.is_reserved());
262        assert!(!Permission::AudioOutput.is_reserved());
263        let perms = PermissionSet::try_from_strings(&[
264            "audio.input".to_string(),
265            "audio.output".to_string(),
266        ])
267        .unwrap();
268        assert!(perms.has(Permission::AudioInput));
269        assert!(perms.has(Permission::AudioOutput));
270    }
271
272    #[test]
273    fn context_providers_register_parses_and_is_not_reserved() {
274        assert_eq!(
275            Permission::parse("context_providers.register"),
276            Some(Permission::ContextProvidersRegister)
277        );
278        assert!(!Permission::ContextProvidersRegister.is_reserved());
279        let perms =
280            PermissionSet::try_from_strings(&["context_providers.register".to_string()]).unwrap();
281        assert!(perms.has(Permission::ContextProvidersRegister));
282        // Distinct from the model/LLM provider permission.
283        assert!(!perms.has(Permission::ProvidersRegister));
284    }
285
286    #[test]
287    fn round_trip_as_str() {
288        for perm in [
289            Permission::ToolsIntercept,
290            Permission::ToolsOverride,
291            Permission::LlmContent,
292            Permission::SessionLifecycle,
293            Permission::ToolsRegister,
294            Permission::ProvidersRegister,
295            Permission::ContextProvidersRegister,
296            Permission::MemoryRead,
297            Permission::MemoryWrite,
298            Permission::AudioInput,
299            Permission::AudioOutput,
300        ] {
301            assert_eq!(Permission::parse(perm.as_str()), Some(perm));
302        }
303    }
304}