sen_plugin_host/permission/
strategy.rs1use sen_plugin_api::Capabilities;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum PermissionGranularity {
11 #[default]
13 Plugin,
14 Command,
16 Execution,
18}
19
20#[derive(Debug)]
22pub struct PermissionContext<'a> {
23 pub plugin_name: &'a str,
25 pub command_path: &'a [String],
27 pub requested: &'a Capabilities,
29 pub granted: Option<&'a Capabilities>,
31 pub interactive: bool,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum PermissionDecision {
38 Allow,
40 Deny(String),
42 Prompt,
44 AllowPartial(Capabilities),
46}
47
48pub trait PermissionStrategy: Send + Sync {
82 fn granularity(&self) -> PermissionGranularity;
84
85 fn inherit_capabilities(&self) -> bool;
87
88 fn check(&self, ctx: &PermissionContext) -> PermissionDecision;
90
91 fn on_escalation(&self, ctx: &PermissionContext) -> PermissionDecision {
93 let _ = ctx;
95 PermissionDecision::Prompt
96 }
97}
98
99pub 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, None if ctx.requested.is_empty() => PermissionDecision::Allow,
125 None => PermissionDecision::Prompt,
126 }
127 }
128}
129
130pub 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
157pub 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 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
186pub 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#[derive(Debug)]
227pub struct TrustAllStrategy {
228 _private: (),
229}
230
231impl TrustAllStrategy {
232 #[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}