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    /// Every permission variant, in declaration order.
54    ///
55    /// Exhaustiveness is enforced by `all_covers_every_variant` below, which
56    /// pattern-matches every variant explicitly: adding one to the enum
57    /// breaks that test's match at compile time, and its length assertion
58    /// then forces this list to be updated too.
59    ///
60    /// This list is what `docs/extensions/contract.json` is drift-checked
61    /// against (see `crates/agent-tui/tests/extensions_contract.rs`). Before
62    /// that check existed the contract had fallen eight permissions behind
63    /// the engine while `STABILITY.md` advertised it as CI-verified.
64    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    /// Wire-format string for this permission.
82    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    /// Parse from wire-format string.
102    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    /// Whether this permission is reserved for a future implementation.
122    pub fn is_reserved(&self) -> bool {
123        matches!(self, Self::ToolsOverride)
124    }
125}
126
127/// A set of permissions granted to an extension.
128#[derive(Debug, Clone, Default)]
129pub struct PermissionSet {
130    permissions: HashSet<Permission>,
131}
132
133impl PermissionSet {
134    /// Empty permission set.
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Parse permission strings (from manifest) into a set.
140    ///
141    /// This lenient parser is kept for tests and internal callers that have
142    /// already validated manifests. Extension manifests should use
143    /// [`try_from_strings`](Self::try_from_strings) so typos fail loudly.
144    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    /// Parse permission strings and reject unknown values.
150    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    /// Check if a permission is granted.
166    pub fn has(&self, perm: Permission) -> bool {
167        self.permissions.contains(&perm)
168    }
169
170    /// Grant a permission.
171    pub fn grant(&mut self, perm: Permission) {
172        self.permissions.insert(perm);
173    }
174
175    /// Check if this set allows subscribing to the given hook.
176    pub fn allows_hook(&self, kind: crate::extensions::hooks::events::HookKind) -> bool {
177        self.has(kind.required_permission())
178    }
179
180    /// Number of permissions.
181    pub fn len(&self) -> usize {
182        self.permissions.len()
183    }
184
185    /// Whether no permissions are granted.
186    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)); // needs LlmContent
240    }
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        // Distinct from the model/LLM provider permission.
311        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    /// `Permission::ALL` must list every variant exactly once.
334    ///
335    /// The match below is deliberately written out variant by variant: adding
336    /// a variant to the enum makes it non-exhaustive and this test stops
337    /// COMPILING, which is the point. The author is then forced to the length
338    /// assertion, which sends them to `ALL`. Without this, `ALL` is just a
339    /// hand-maintained list that silently falls behind -- the exact failure
340    /// mode that let contract.json drift eight permissions out of date.
341    #[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    /// Every listed permission must round-trip through the wire format.
376    #[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}