Skip to main content

sen_plugin_host/permission/
strategy.rs

1//! Permission strategy trait and default implementations
2//!
3//! Framework users can customize permission behavior by implementing
4//! the `PermissionStrategy` trait or using provided defaults.
5
6use sen_plugin_api::Capabilities;
7
8/// Permission granularity level
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum PermissionGranularity {
11    /// One permission per plugin (default)
12    #[default]
13    Plugin,
14    /// Separate permissions per command/subcommand path
15    Command,
16    /// Require permission for every execution
17    Execution,
18}
19
20/// Context provided to permission strategy for decision making
21#[derive(Debug)]
22pub struct PermissionContext<'a> {
23    /// Plugin name
24    pub plugin_name: &'a str,
25    /// Command path (e.g., ["db", "migrate"] for "db:migrate")
26    pub command_path: &'a [String],
27    /// Capabilities requested by the plugin
28    pub requested: &'a Capabilities,
29    /// Previously granted capabilities (if any)
30    pub granted: Option<&'a Capabilities>,
31    /// Whether running in interactive mode
32    pub interactive: bool,
33}
34
35/// Decision returned by permission strategy
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum PermissionDecision {
38    /// Allow execution with requested capabilities
39    Allow,
40    /// Deny execution with reason
41    Deny(String),
42    /// Prompt user for permission
43    Prompt,
44    /// Allow but with reduced capabilities
45    AllowPartial(Capabilities),
46}
47
48/// Strategy trait for permission resolution
49///
50/// Framework users implement this trait to customize permission behavior.
51///
52/// # Example
53///
54/// ```rust
55/// use sen_plugin_host::permission::{
56///     PermissionStrategy, PermissionGranularity, PermissionContext, PermissionDecision
57/// };
58///
59/// struct MyStrategy {
60///     trusted_plugins: Vec<String>,
61/// }
62///
63/// impl PermissionStrategy for MyStrategy {
64///     fn granularity(&self) -> PermissionGranularity {
65///         PermissionGranularity::Plugin
66///     }
67///
68///     fn inherit_capabilities(&self) -> bool {
69///         false
70///     }
71///
72///     fn check(&self, ctx: &PermissionContext) -> PermissionDecision {
73///         if self.trusted_plugins.contains(&ctx.plugin_name.to_string()) {
74///             PermissionDecision::Allow
75///         } else {
76///             PermissionDecision::Prompt
77///         }
78///     }
79/// }
80/// ```
81pub trait PermissionStrategy: Send + Sync {
82    /// Get the granularity level for permission checks
83    fn granularity(&self) -> PermissionGranularity;
84
85    /// Whether subcommands inherit parent command's capabilities
86    fn inherit_capabilities(&self) -> bool;
87
88    /// Check permission and return decision
89    fn check(&self, ctx: &PermissionContext) -> PermissionDecision;
90
91    /// Called when capabilities escalation is detected (plugin updated with more permissions)
92    fn on_escalation(&self, ctx: &PermissionContext) -> PermissionDecision {
93        // Default: always prompt on escalation
94        let _ = ctx;
95        PermissionDecision::Prompt
96    }
97}
98
99// ============================================================================
100// Default Implementations
101// ============================================================================
102
103/// Default permission strategy
104///
105/// - Plugin-level granularity
106/// - No capability inheritance
107/// - Prompts for ungranted permissions
108/// - Allows if already granted
109pub struct DefaultPermissionStrategy;
110
111impl PermissionStrategy for DefaultPermissionStrategy {
112    fn granularity(&self) -> PermissionGranularity {
113        PermissionGranularity::Plugin
114    }
115
116    fn inherit_capabilities(&self) -> bool {
117        false
118    }
119
120    fn check(&self, ctx: &PermissionContext) -> PermissionDecision {
121        match ctx.granted {
122            Some(granted) if ctx.requested.is_subset_of(granted) => PermissionDecision::Allow,
123            Some(_) => PermissionDecision::Prompt, // Escalation
124            None if ctx.requested.is_empty() => PermissionDecision::Allow,
125            None => PermissionDecision::Prompt,
126        }
127    }
128}
129
130/// Strict permission strategy
131///
132/// - Command-level granularity
133/// - No inheritance
134/// - Denies in non-interactive mode
135pub struct StrictPermissionStrategy;
136
137impl PermissionStrategy for StrictPermissionStrategy {
138    fn granularity(&self) -> PermissionGranularity {
139        PermissionGranularity::Command
140    }
141
142    fn inherit_capabilities(&self) -> bool {
143        false
144    }
145
146    fn check(&self, ctx: &PermissionContext) -> PermissionDecision {
147        match ctx.granted {
148            Some(granted) if ctx.requested.is_subset_of(granted) => PermissionDecision::Allow,
149            _ if !ctx.interactive => PermissionDecision::Deny(
150                "Non-interactive mode requires pre-granted permissions".into(),
151            ),
152            _ => PermissionDecision::Prompt,
153        }
154    }
155}
156
157/// Permissive strategy for trusted environments
158///
159/// - Plugin-level granularity
160/// - Allows all non-network capabilities without prompt
161/// - Still prompts for network access
162pub struct PermissivePermissionStrategy;
163
164impl PermissionStrategy for PermissivePermissionStrategy {
165    fn granularity(&self) -> PermissionGranularity {
166        PermissionGranularity::Plugin
167    }
168
169    fn inherit_capabilities(&self) -> bool {
170        true
171    }
172
173    fn check(&self, ctx: &PermissionContext) -> PermissionDecision {
174        // Allow everything except network
175        if ctx.requested.net.is_empty() {
176            PermissionDecision::Allow
177        } else {
178            match ctx.granted {
179                Some(granted) if ctx.requested.is_subset_of(granted) => PermissionDecision::Allow,
180                _ => PermissionDecision::Prompt,
181            }
182        }
183    }
184}
185
186/// CI/Batch mode strategy
187///
188/// - Never prompts (non-interactive)
189/// - Allows only pre-granted permissions
190/// - Denies everything else
191pub struct CiPermissionStrategy;
192
193impl PermissionStrategy for CiPermissionStrategy {
194    fn granularity(&self) -> PermissionGranularity {
195        PermissionGranularity::Plugin
196    }
197
198    fn inherit_capabilities(&self) -> bool {
199        false
200    }
201
202    fn check(&self, ctx: &PermissionContext) -> PermissionDecision {
203        match ctx.granted {
204            Some(granted) if ctx.requested.is_subset_of(granted) => PermissionDecision::Allow,
205            None if ctx.requested.is_empty() => PermissionDecision::Allow,
206            _ => PermissionDecision::Deny("CI mode: all permissions must be pre-granted".into()),
207        }
208    }
209
210    fn on_escalation(&self, _ctx: &PermissionContext) -> PermissionDecision {
211        PermissionDecision::Deny("CI mode: capability escalation not allowed".into())
212    }
213}
214
215/// Trust-all strategy (DANGEROUS - for development only)
216///
217/// - Allows all capabilities without prompt
218/// - Should only be used in development/testing
219///
220/// # Warning
221///
222/// This strategy bypasses ALL permission checks. Only use in:
223/// - Local development environments
224/// - Controlled testing scenarios
225/// - Never in production or with untrusted plugins
226#[derive(Debug)]
227pub struct TrustAllStrategy {
228    _private: (),
229}
230
231impl TrustAllStrategy {
232    /// Create a new trust-all strategy
233    ///
234    /// # Warning
235    ///
236    /// This strategy bypasses all permission checks. Only use in controlled environments.
237    /// The returned value must be used - ignoring it likely indicates a bug.
238    #[must_use = "TrustAllStrategy must be used in a PermissionConfig, ignoring it is likely a bug"]
239    pub fn new_dangerous() -> Self {
240        Self { _private: () }
241    }
242}
243
244impl PermissionStrategy for TrustAllStrategy {
245    fn granularity(&self) -> PermissionGranularity {
246        PermissionGranularity::Plugin
247    }
248
249    fn inherit_capabilities(&self) -> bool {
250        true
251    }
252
253    fn check(&self, _ctx: &PermissionContext) -> PermissionDecision {
254        PermissionDecision::Allow
255    }
256
257    fn on_escalation(&self, _ctx: &PermissionContext) -> PermissionDecision {
258        PermissionDecision::Allow
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use sen_plugin_api::{PathPattern, StdioCapability};
266
267    fn make_context<'a>(
268        plugin: &'a str,
269        requested: &'a Capabilities,
270        granted: Option<&'a Capabilities>,
271        interactive: bool,
272    ) -> PermissionContext<'a> {
273        PermissionContext {
274            plugin_name: plugin,
275            command_path: &[],
276            requested,
277            granted,
278            interactive,
279        }
280    }
281
282    #[test]
283    fn test_default_strategy_empty_caps() {
284        let strategy = DefaultPermissionStrategy;
285        let caps = Capabilities::none();
286        let ctx = make_context("test", &caps, None, true);
287
288        assert_eq!(strategy.check(&ctx), PermissionDecision::Allow);
289    }
290
291    #[test]
292    fn test_default_strategy_ungranted() {
293        let strategy = DefaultPermissionStrategy;
294        let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
295        let ctx = make_context("test", &caps, None, true);
296
297        assert_eq!(strategy.check(&ctx), PermissionDecision::Prompt);
298    }
299
300    #[test]
301    fn test_default_strategy_granted() {
302        let strategy = DefaultPermissionStrategy;
303        let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
304        let granted =
305            Capabilities::default().with_fs_read(vec![PathPattern::new("./data").recursive()]);
306        let ctx = make_context("test", &caps, Some(&granted), true);
307
308        assert_eq!(strategy.check(&ctx), PermissionDecision::Allow);
309    }
310
311    #[test]
312    fn test_strict_strategy_non_interactive() {
313        let strategy = StrictPermissionStrategy;
314        let caps = Capabilities::default().with_stdio(StdioCapability::stdout_only());
315        let ctx = make_context("test", &caps, None, false);
316
317        match strategy.check(&ctx) {
318            PermissionDecision::Deny(_) => {}
319            other => panic!("Expected Deny, got {:?}", other),
320        }
321    }
322
323    #[test]
324    fn test_ci_strategy_denies_ungranted() {
325        let strategy = CiPermissionStrategy;
326        let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
327        let ctx = make_context("test", &caps, None, false);
328
329        match strategy.check(&ctx) {
330            PermissionDecision::Deny(msg) => {
331                assert!(msg.contains("CI mode"));
332            }
333            other => panic!("Expected Deny, got {:?}", other),
334        }
335    }
336
337    #[test]
338    fn test_permissive_allows_non_network() {
339        let strategy = PermissivePermissionStrategy;
340        let caps = Capabilities::default()
341            .with_fs_read(vec![PathPattern::new("./data")])
342            .with_fs_write(vec![PathPattern::new("./output")])
343            .with_stdio(StdioCapability::all());
344        let ctx = make_context("test", &caps, None, true);
345
346        assert_eq!(strategy.check(&ctx), PermissionDecision::Allow);
347    }
348
349    #[test]
350    fn test_trust_all_allows_everything() {
351        let strategy = TrustAllStrategy::new_dangerous();
352        let caps = Capabilities::default()
353            .with_fs_read(vec![PathPattern::new("/")])
354            .with_net(vec![sen_plugin_api::NetPattern::https("*")]);
355        let ctx = make_context("test", &caps, None, false);
356
357        assert_eq!(strategy.check(&ctx), PermissionDecision::Allow);
358    }
359}