Skip to main content

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