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    /// May propose foreground turns after explicit local user authorization.
29    /// Declaration alone never arms a session driver or grants a hook.
30    SessionDrive,
31    /// Can register new tools.
32    ToolsRegister,
33    /// Can register new providers.
34    ProvidersRegister,
35    /// Accepted and inert: it parses, so a manifest that asks for it still
36    /// loads, and it grants nothing. It gated context-provider declarations
37    /// for built-in automatic memory, which was removed.
38    ContextProvidersRegister,
39    /// Can read from the local memory store via `memory.query`.
40    MemoryRead,
41    /// Can append to the local memory store via `memory.append`.
42    MemoryWrite,
43    /// Can read/write its own plugin-namespaced config via `config.get`/`config.set`.
44    ConfigWrite,
45    /// Can subscribe to hot-reload notifications for its own plugin config.
46    ConfigSubscribe,
47    /// Can capture audio from input devices.
48    AudioInput,
49    /// Can produce audio through output devices.
50    AudioOutput,
51}
52
53impl Permission {
54    /// Every permission variant, in declaration order.
55    ///
56    /// Exhaustiveness is enforced by `all_covers_every_variant` below, which
57    /// pattern-matches every variant explicitly: adding one to the enum
58    /// breaks that test's match at compile time, and its length assertion
59    /// then forces this list to be updated too.
60    ///
61    /// This list is what `docs/extensions/contract.json` is drift-checked
62    /// against (see `crates/agent-tui/tests/extensions_contract.rs`). Before
63    /// that check existed the contract had fallen eight permissions behind
64    /// the engine while `STABILITY.md` advertised it as CI-verified.
65    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    /// Wire-format string for this permission.
84    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    /// Parse from wire-format string.
105    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    /// Whether this permission is reserved for a future implementation.
126    pub fn is_reserved(&self) -> bool {
127        matches!(self, Self::ToolsOverride)
128    }
129}
130
131/// A set of permissions granted to an extension.
132#[derive(Debug, Clone, Default)]
133pub struct PermissionSet {
134    permissions: HashSet<Permission>,
135}
136
137impl PermissionSet {
138    /// Empty permission set.
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    /// Parse permission strings (from manifest) into a set.
144    ///
145    /// This lenient parser is kept for tests and internal callers that have
146    /// already validated manifests. Extension manifests should use
147    /// [`try_from_strings`](Self::try_from_strings) so typos fail loudly.
148    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    /// Parse permission strings and reject unknown values.
154    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    /// Check if a permission is granted.
170    pub fn has(&self, perm: Permission) -> bool {
171        self.permissions.contains(&perm)
172    }
173
174    /// Grant a permission.
175    pub fn grant(&mut self, perm: Permission) {
176        self.permissions.insert(perm);
177    }
178
179    /// Check if this set allows subscribing to the given hook.
180    pub fn allows_hook(&self, kind: crate::extensions::hooks::events::HookKind) -> bool {
181        self.has(kind.required_permission())
182    }
183
184    /// Number of permissions.
185    pub fn len(&self) -> usize {
186        self.permissions.len()
187    }
188
189    /// Whether no permissions are granted.
190    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)); // needs LlmContent
259    }
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        // Distinct from the model/LLM provider permission.
330        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    /// `Permission::ALL` must list every variant exactly once.
353    ///
354    /// The match below is deliberately written out variant by variant: adding
355    /// a variant to the enum makes it non-exhaustive and this test stops
356    /// COMPILING, which is the point. The author is then forced to the length
357    /// assertion, which sends them to `ALL`. Without this, `ALL` is just a
358    /// hand-maintained list that silently falls behind -- the exact failure
359    /// mode that let contract.json drift eight permissions out of date.
360    #[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    /// Every listed permission must round-trip through the wire format.
396    #[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}