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 read from the local memory store via `memory.query`.
33    MemoryRead,
34    /// Can append to the local memory store via `memory.append`.
35    MemoryWrite,
36    /// Can read/write its own plugin-namespaced config via `config.get`/`config.set`.
37    ConfigWrite,
38    /// Can subscribe to hot-reload notifications for its own plugin config.
39    ConfigSubscribe,
40    /// Can capture audio from input devices.
41    AudioInput,
42    /// Can produce audio through output devices.
43    AudioOutput,
44}
45
46impl Permission {
47    /// Wire-format string for this permission.
48    pub fn as_str(&self) -> &'static str {
49        match self {
50            Self::ToolsIntercept => "tools.intercept",
51            Self::ToolsTransformOutput => "tools.transform_output",
52            Self::ToolsOverride => "tools.override",
53            Self::LlmContent => "privacy.llm_content",
54            Self::SessionLifecycle => "session.lifecycle",
55            Self::ToolsRegister => "tools.register",
56            Self::ProvidersRegister => "providers.register",
57            Self::MemoryRead => "memory.read",
58            Self::MemoryWrite => "memory.write",
59            Self::ConfigWrite => "config.write",
60            Self::ConfigSubscribe => "config.subscribe",
61            Self::AudioInput => "audio.input",
62            Self::AudioOutput => "audio.output",
63        }
64    }
65
66    /// Parse from wire-format string.
67    pub fn parse(s: &str) -> Option<Self> {
68        match s {
69            "tools.intercept" => Some(Self::ToolsIntercept),
70            "tools.transform_output" => Some(Self::ToolsTransformOutput),
71            "tools.override" => Some(Self::ToolsOverride),
72            "privacy.llm_content" => Some(Self::LlmContent),
73            "session.lifecycle" => Some(Self::SessionLifecycle),
74            "tools.register" => Some(Self::ToolsRegister),
75            "providers.register" => Some(Self::ProvidersRegister),
76            "memory.read" => Some(Self::MemoryRead),
77            "memory.write" => Some(Self::MemoryWrite),
78            "config.write" => Some(Self::ConfigWrite),
79            "config.subscribe" => Some(Self::ConfigSubscribe),
80            "audio.input" => Some(Self::AudioInput),
81            "audio.output" => Some(Self::AudioOutput),
82            _ => None,
83        }
84    }
85    /// Whether this permission is reserved for a future implementation.
86    pub fn is_reserved(&self) -> bool {
87        matches!(
88            self,
89            Self::ToolsOverride
90        )
91    }
92}
93
94/// A set of permissions granted to an extension.
95#[derive(Debug, Clone, Default)]
96pub struct PermissionSet {
97    permissions: HashSet<Permission>,
98}
99
100impl PermissionSet {
101    /// Empty permission set.
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Parse permission strings (from manifest) into a set.
107    ///
108    /// This lenient parser is kept for tests and internal callers that have
109    /// already validated manifests. Extension manifests should use
110    /// [`try_from_strings`](Self::try_from_strings) so typos fail loudly.
111    pub fn from_strings(perms: &[String]) -> Self {
112        let permissions = perms.iter().filter_map(|s| Permission::parse(s)).collect();
113        Self { permissions }
114    }
115
116    /// Parse permission strings and reject unknown values.
117    pub fn try_from_strings(perms: &[String]) -> Result<Self, String> {
118        let mut permissions = HashSet::new();
119        for perm in perms {
120            let parsed = Permission::parse(perm)
121                .ok_or_else(|| format!("Unknown extension permission: {perm}"))?;
122            if parsed.is_reserved() {
123                return Err(format!(
124                    "Reserved extension permission is not implemented yet: {perm}"
125                ));
126            }
127            permissions.insert(parsed);
128        }
129        Ok(Self { permissions })
130    }
131
132    /// Check if a permission is granted.
133    pub fn has(&self, perm: Permission) -> bool {
134        self.permissions.contains(&perm)
135    }
136
137    /// Grant a permission.
138    pub fn grant(&mut self, perm: Permission) {
139        self.permissions.insert(perm);
140    }
141
142    /// Check if this set allows subscribing to the given hook.
143    pub fn allows_hook(&self, kind: crate::extensions::hooks::events::HookKind) -> bool {
144        self.has(kind.required_permission())
145    }
146
147    /// Number of permissions.
148    pub fn len(&self) -> usize {
149        self.permissions.len()
150    }
151
152    /// Whether no permissions are granted.
153    pub fn is_empty(&self) -> bool {
154        self.permissions.is_empty()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::extensions::hooks::events::HookKind;
162
163    #[test]
164    fn parse_valid_permissions() {
165        assert_eq!(Permission::parse("tools.intercept"), Some(Permission::ToolsIntercept));
166        assert_eq!(Permission::parse("privacy.llm_content"), Some(Permission::LlmContent));
167        assert_eq!(Permission::parse("session.lifecycle"), Some(Permission::SessionLifecycle));
168    }
169
170    #[test]
171    fn parse_invalid_returns_none() {
172        assert_eq!(Permission::parse("invalid"), None);
173        assert_eq!(Permission::parse(""), None);
174    }
175
176    #[test]
177    fn from_strings_skips_invalid() {
178        let perms = PermissionSet::from_strings(&[
179            "tools.intercept".into(),
180            "bogus".into(),
181            "session.lifecycle".into(),
182        ]);
183        assert_eq!(perms.len(), 2);
184        assert!(perms.has(Permission::ToolsIntercept));
185        assert!(perms.has(Permission::SessionLifecycle));
186        assert!(!perms.has(Permission::LlmContent));
187    }
188
189    #[test]
190    fn allows_hook_checks_required_permission() {
191        let mut perms = PermissionSet::new();
192        assert!(!perms.allows_hook(HookKind::BeforeToolCall));
193
194        perms.grant(Permission::ToolsIntercept);
195        assert!(perms.allows_hook(HookKind::BeforeToolCall));
196        assert!(perms.allows_hook(HookKind::AfterToolCall));
197        assert!(!perms.allows_hook(HookKind::BeforeMessage)); // needs LlmContent
198    }
199
200    #[test]
201    fn empty_set() {
202        let perms = PermissionSet::new();
203        assert!(perms.is_empty());
204        assert_eq!(perms.len(), 0);
205    }
206
207    #[test]
208    fn providers_register_is_active_but_tools_override_remains_reserved() {
209        let perms = PermissionSet::try_from_strings(&["providers.register".to_string()]).unwrap();
210        assert!(perms.has(Permission::ProvidersRegister));
211
212        let err = PermissionSet::try_from_strings(&["tools.override".to_string()]).unwrap_err();
213        assert!(err.contains("Reserved extension permission"));
214    }
215
216    #[test]
217    fn memory_permissions_parse_and_are_not_reserved() {
218        assert_eq!(Permission::parse("memory.read"), Some(Permission::MemoryRead));
219        assert_eq!(Permission::parse("memory.write"), Some(Permission::MemoryWrite));
220        assert!(!Permission::MemoryRead.is_reserved());
221        assert!(!Permission::MemoryWrite.is_reserved());
222        let perms = PermissionSet::try_from_strings(&[
223            "memory.read".to_string(),
224            "memory.write".to_string(),
225        ])
226        .unwrap();
227        assert!(perms.has(Permission::MemoryRead));
228        assert!(perms.has(Permission::MemoryWrite));
229    }
230
231    #[test]
232    fn audio_permissions_parse_and_are_not_reserved() {
233        assert_eq!(Permission::parse("audio.input"), Some(Permission::AudioInput));
234        assert_eq!(Permission::parse("audio.output"), Some(Permission::AudioOutput));
235        assert!(!Permission::AudioInput.is_reserved());
236        assert!(!Permission::AudioOutput.is_reserved());
237        let perms = PermissionSet::try_from_strings(&[
238            "audio.input".to_string(),
239            "audio.output".to_string(),
240        ])
241        .unwrap();
242        assert!(perms.has(Permission::AudioInput));
243        assert!(perms.has(Permission::AudioOutput));
244    }
245
246    #[test]
247    fn round_trip_as_str() {
248        for perm in [
249            Permission::ToolsIntercept,
250            Permission::ToolsOverride,
251            Permission::LlmContent,
252            Permission::SessionLifecycle,
253            Permission::ToolsRegister,
254            Permission::ProvidersRegister,
255            Permission::MemoryRead,
256            Permission::MemoryWrite,
257            Permission::AudioInput,
258            Permission::AudioOutput,
259        ] {
260            assert_eq!(Permission::parse(perm.as_str()), Some(perm));
261        }
262    }
263}