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    /// Can declare dormant memory/context contribution providers
36    /// (continuous-memory spec §7.1). Distinct from `ProvidersRegister`
37    /// (model/LLM providers): this gates only passive context-provider
38    /// capability metadata; activation additionally requires an exact
39    /// memory-context lease (task A6).
40    ContextProvidersRegister,
41    /// Can read from the local memory store via `memory.query`.
42    MemoryRead,
43    /// Can append to the local memory store via `memory.append`.
44    MemoryWrite,
45    /// Can read/write its own plugin-namespaced config via `config.get`/`config.set`.
46    ConfigWrite,
47    /// Can subscribe to hot-reload notifications for its own plugin config.
48    ConfigSubscribe,
49    /// Can capture audio from input devices.
50    AudioInput,
51    /// Can produce audio through output devices.
52    AudioOutput,
53}
54
55impl Permission {
56    /// Every permission variant, in declaration order.
57    ///
58    /// Exhaustiveness is enforced by `all_covers_every_variant` below, which
59    /// pattern-matches every variant explicitly: adding one to the enum
60    /// breaks that test's match at compile time, and its length assertion
61    /// then forces this list to be updated too.
62    ///
63    /// This list is what `docs/extensions/contract.json` is drift-checked
64    /// against (see `crates/agent-tui/tests/extensions_contract.rs`). Before
65    /// that check existed the contract had fallen eight permissions behind
66    /// the engine while `STABILITY.md` advertised it as CI-verified.
67    pub const ALL: &'static [Permission] = &[
68        Self::ToolsIntercept,
69        Self::ToolsTransformOutput,
70        Self::ToolsOverride,
71        Self::LlmContent,
72        Self::SessionLifecycle,
73        Self::SessionDrive,
74        Self::ToolsRegister,
75        Self::ProvidersRegister,
76        Self::ContextProvidersRegister,
77        Self::MemoryRead,
78        Self::MemoryWrite,
79        Self::ConfigWrite,
80        Self::ConfigSubscribe,
81        Self::AudioInput,
82        Self::AudioOutput,
83    ];
84
85    /// Wire-format string for this permission.
86    pub fn as_str(&self) -> &'static str {
87        match self {
88            Self::ToolsIntercept => "tools.intercept",
89            Self::ToolsTransformOutput => "tools.transform_output",
90            Self::ToolsOverride => "tools.override",
91            Self::LlmContent => "privacy.llm_content",
92            Self::SessionLifecycle => "session.lifecycle",
93            Self::SessionDrive => "session.drive",
94            Self::ToolsRegister => "tools.register",
95            Self::ProvidersRegister => "providers.register",
96            Self::ContextProvidersRegister => "context_providers.register",
97            Self::MemoryRead => "memory.read",
98            Self::MemoryWrite => "memory.write",
99            Self::ConfigWrite => "config.write",
100            Self::ConfigSubscribe => "config.subscribe",
101            Self::AudioInput => "audio.input",
102            Self::AudioOutput => "audio.output",
103        }
104    }
105
106    /// Parse from wire-format string.
107    pub fn parse(s: &str) -> Option<Self> {
108        match s {
109            "tools.intercept" => Some(Self::ToolsIntercept),
110            "tools.transform_output" => Some(Self::ToolsTransformOutput),
111            "tools.override" => Some(Self::ToolsOverride),
112            "privacy.llm_content" => Some(Self::LlmContent),
113            "session.lifecycle" => Some(Self::SessionLifecycle),
114            "session.drive" => Some(Self::SessionDrive),
115            "tools.register" => Some(Self::ToolsRegister),
116            "providers.register" => Some(Self::ProvidersRegister),
117            "context_providers.register" => Some(Self::ContextProvidersRegister),
118            "memory.read" => Some(Self::MemoryRead),
119            "memory.write" => Some(Self::MemoryWrite),
120            "config.write" => Some(Self::ConfigWrite),
121            "config.subscribe" => Some(Self::ConfigSubscribe),
122            "audio.input" => Some(Self::AudioInput),
123            "audio.output" => Some(Self::AudioOutput),
124            _ => None,
125        }
126    }
127    /// Whether this permission is reserved for a future implementation.
128    pub fn is_reserved(&self) -> bool {
129        matches!(self, Self::ToolsOverride)
130    }
131}
132
133/// A set of permissions granted to an extension.
134#[derive(Debug, Clone, Default)]
135pub struct PermissionSet {
136    permissions: HashSet<Permission>,
137}
138
139impl PermissionSet {
140    /// Empty permission set.
141    pub fn new() -> Self {
142        Self::default()
143    }
144
145    /// Parse permission strings (from manifest) into a set.
146    ///
147    /// This lenient parser is kept for tests and internal callers that have
148    /// already validated manifests. Extension manifests should use
149    /// [`try_from_strings`](Self::try_from_strings) so typos fail loudly.
150    pub fn from_strings(perms: &[String]) -> Self {
151        let permissions = perms.iter().filter_map(|s| Permission::parse(s)).collect();
152        Self { permissions }
153    }
154
155    /// Parse permission strings and reject unknown values.
156    pub fn try_from_strings(perms: &[String]) -> Result<Self, String> {
157        let mut permissions = HashSet::new();
158        for perm in perms {
159            let parsed = Permission::parse(perm)
160                .ok_or_else(|| format!("Unknown extension permission: {perm}"))?;
161            if parsed.is_reserved() {
162                return Err(format!(
163                    "Reserved extension permission is not implemented yet: {perm}"
164                ));
165            }
166            permissions.insert(parsed);
167        }
168        Ok(Self { permissions })
169    }
170
171    /// Check if a permission is granted.
172    pub fn has(&self, perm: Permission) -> bool {
173        self.permissions.contains(&perm)
174    }
175
176    /// Grant a permission.
177    pub fn grant(&mut self, perm: Permission) {
178        self.permissions.insert(perm);
179    }
180
181    /// Check if this set allows subscribing to the given hook.
182    pub fn allows_hook(&self, kind: crate::extensions::hooks::events::HookKind) -> bool {
183        self.has(kind.required_permission())
184    }
185
186    /// Number of permissions.
187    pub fn len(&self) -> usize {
188        self.permissions.len()
189    }
190
191    /// Whether no permissions are granted.
192    pub fn is_empty(&self) -> bool {
193        self.permissions.is_empty()
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::extensions::hooks::events::HookKind;
201
202    #[test]
203    fn parse_valid_permissions() {
204        assert_eq!(
205            Permission::parse("tools.intercept"),
206            Some(Permission::ToolsIntercept)
207        );
208        assert_eq!(
209            Permission::parse("privacy.llm_content"),
210            Some(Permission::LlmContent)
211        );
212        assert_eq!(
213            Permission::parse("session.lifecycle"),
214            Some(Permission::SessionLifecycle)
215        );
216    }
217
218    #[test]
219    fn session_drive_is_active_explicit_and_grants_no_hooks() {
220        assert_eq!(
221            Permission::parse("session.drive"),
222            Some(Permission::SessionDrive)
223        );
224        assert!(!Permission::SessionDrive.is_reserved());
225        let perms = PermissionSet::try_from_strings(&["session.drive".into()]).unwrap();
226        assert!(perms.has(Permission::SessionDrive));
227        assert!(!perms.has(Permission::SessionLifecycle));
228        assert!(!perms.allows_hook(HookKind::OnSessionStart));
229        assert!(!perms.allows_hook(HookKind::BeforeMessage));
230        assert_eq!(Permission::parse("session_drive"), None);
231    }
232
233    #[test]
234    fn parse_invalid_returns_none() {
235        assert_eq!(Permission::parse("invalid"), None);
236        assert_eq!(Permission::parse(""), None);
237    }
238
239    #[test]
240    fn from_strings_skips_invalid() {
241        let perms = PermissionSet::from_strings(&[
242            "tools.intercept".into(),
243            "bogus".into(),
244            "session.lifecycle".into(),
245        ]);
246        assert_eq!(perms.len(), 2);
247        assert!(perms.has(Permission::ToolsIntercept));
248        assert!(perms.has(Permission::SessionLifecycle));
249        assert!(!perms.has(Permission::LlmContent));
250    }
251
252    #[test]
253    fn allows_hook_checks_required_permission() {
254        let mut perms = PermissionSet::new();
255        assert!(!perms.allows_hook(HookKind::BeforeToolCall));
256
257        perms.grant(Permission::ToolsIntercept);
258        assert!(perms.allows_hook(HookKind::BeforeToolCall));
259        assert!(perms.allows_hook(HookKind::AfterToolCall));
260        assert!(!perms.allows_hook(HookKind::BeforeMessage)); // needs LlmContent
261    }
262
263    #[test]
264    fn empty_set() {
265        let perms = PermissionSet::new();
266        assert!(perms.is_empty());
267        assert_eq!(perms.len(), 0);
268    }
269
270    #[test]
271    fn providers_register_is_active_but_tools_override_remains_reserved() {
272        let perms = PermissionSet::try_from_strings(&["providers.register".to_string()]).unwrap();
273        assert!(perms.has(Permission::ProvidersRegister));
274
275        let err = PermissionSet::try_from_strings(&["tools.override".to_string()]).unwrap_err();
276        assert!(err.contains("Reserved extension permission"));
277    }
278
279    #[test]
280    fn memory_permissions_parse_and_are_not_reserved() {
281        assert_eq!(
282            Permission::parse("memory.read"),
283            Some(Permission::MemoryRead)
284        );
285        assert_eq!(
286            Permission::parse("memory.write"),
287            Some(Permission::MemoryWrite)
288        );
289        assert!(!Permission::MemoryRead.is_reserved());
290        assert!(!Permission::MemoryWrite.is_reserved());
291        let perms = PermissionSet::try_from_strings(&[
292            "memory.read".to_string(),
293            "memory.write".to_string(),
294        ])
295        .unwrap();
296        assert!(perms.has(Permission::MemoryRead));
297        assert!(perms.has(Permission::MemoryWrite));
298    }
299
300    #[test]
301    fn audio_permissions_parse_and_are_not_reserved() {
302        assert_eq!(
303            Permission::parse("audio.input"),
304            Some(Permission::AudioInput)
305        );
306        assert_eq!(
307            Permission::parse("audio.output"),
308            Some(Permission::AudioOutput)
309        );
310        assert!(!Permission::AudioInput.is_reserved());
311        assert!(!Permission::AudioOutput.is_reserved());
312        let perms = PermissionSet::try_from_strings(&[
313            "audio.input".to_string(),
314            "audio.output".to_string(),
315        ])
316        .unwrap();
317        assert!(perms.has(Permission::AudioInput));
318        assert!(perms.has(Permission::AudioOutput));
319    }
320
321    #[test]
322    fn context_providers_register_parses_and_is_not_reserved() {
323        assert_eq!(
324            Permission::parse("context_providers.register"),
325            Some(Permission::ContextProvidersRegister)
326        );
327        assert!(!Permission::ContextProvidersRegister.is_reserved());
328        let perms =
329            PermissionSet::try_from_strings(&["context_providers.register".to_string()]).unwrap();
330        assert!(perms.has(Permission::ContextProvidersRegister));
331        // Distinct from the model/LLM provider permission.
332        assert!(!perms.has(Permission::ProvidersRegister));
333    }
334
335    #[test]
336    fn round_trip_as_str() {
337        for perm in [
338            Permission::ToolsIntercept,
339            Permission::ToolsOverride,
340            Permission::LlmContent,
341            Permission::SessionLifecycle,
342            Permission::ToolsRegister,
343            Permission::ProvidersRegister,
344            Permission::ContextProvidersRegister,
345            Permission::MemoryRead,
346            Permission::MemoryWrite,
347            Permission::AudioInput,
348            Permission::AudioOutput,
349        ] {
350            assert_eq!(Permission::parse(perm.as_str()), Some(perm));
351        }
352    }
353
354    /// `Permission::ALL` must list every variant exactly once.
355    ///
356    /// The match below is deliberately written out variant by variant: adding
357    /// a variant to the enum makes it non-exhaustive and this test stops
358    /// COMPILING, which is the point. The author is then forced to the length
359    /// assertion, which sends them to `ALL`. Without this, `ALL` is just a
360    /// hand-maintained list that silently falls behind -- the exact failure
361    /// mode that let contract.json drift eight permissions out of date.
362    #[test]
363    fn all_covers_every_variant() {
364        for perm in Permission::ALL {
365            match perm {
366                Permission::ToolsIntercept
367                | Permission::ToolsTransformOutput
368                | Permission::ToolsOverride
369                | Permission::LlmContent
370                | Permission::SessionLifecycle
371                | Permission::SessionDrive
372                | Permission::ToolsRegister
373                | Permission::ProvidersRegister
374                | Permission::ContextProvidersRegister
375                | Permission::MemoryRead
376                | Permission::MemoryWrite
377                | Permission::ConfigWrite
378                | Permission::ConfigSubscribe
379                | Permission::AudioInput
380                | Permission::AudioOutput => {}
381            }
382        }
383        assert_eq!(
384            Permission::ALL.len(),
385            15,
386            "a Permission variant was added or removed -- update Permission::ALL \
387             and docs/extensions/contract.json to match"
388        );
389        let unique: HashSet<Permission> = Permission::ALL.iter().copied().collect();
390        assert_eq!(
391            unique.len(),
392            Permission::ALL.len(),
393            "Permission::ALL contains a duplicate"
394        );
395    }
396
397    /// Every listed permission must round-trip through the wire format.
398    #[test]
399    fn all_permissions_round_trip_through_wire_format() {
400        for perm in Permission::ALL {
401            assert_eq!(
402                Permission::parse(perm.as_str()),
403                Some(*perm),
404                "{} does not round-trip",
405                perm.as_str()
406            );
407        }
408    }
409}