use sen_plugin_api::Capabilities;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PermissionGranularity {
#[default]
Plugin,
Command,
Execution,
}
#[derive(Debug)]
pub struct PermissionContext<'a> {
pub plugin_name: &'a str,
pub command_path: &'a [String],
pub requested: &'a Capabilities,
pub granted: Option<&'a Capabilities>,
pub interactive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionDecision {
Allow,
Deny(String),
Prompt,
AllowPartial(Capabilities),
}
pub trait PermissionStrategy: Send + Sync {
fn granularity(&self) -> PermissionGranularity;
fn inherit_capabilities(&self) -> bool;
fn check(&self, ctx: &PermissionContext) -> PermissionDecision;
fn on_escalation(&self, ctx: &PermissionContext) -> PermissionDecision {
let _ = ctx;
PermissionDecision::Prompt
}
}
pub struct DefaultPermissionStrategy;
impl PermissionStrategy for DefaultPermissionStrategy {
fn granularity(&self) -> PermissionGranularity {
PermissionGranularity::Plugin
}
fn inherit_capabilities(&self) -> bool {
false
}
fn check(&self, ctx: &PermissionContext) -> PermissionDecision {
match ctx.granted {
Some(granted) if ctx.requested.is_subset_of(granted) => PermissionDecision::Allow,
Some(_) => PermissionDecision::Prompt, None if ctx.requested.is_empty() => PermissionDecision::Allow,
None => PermissionDecision::Prompt,
}
}
}
pub struct StrictPermissionStrategy;
impl PermissionStrategy for StrictPermissionStrategy {
fn granularity(&self) -> PermissionGranularity {
PermissionGranularity::Command
}
fn inherit_capabilities(&self) -> bool {
false
}
fn check(&self, ctx: &PermissionContext) -> PermissionDecision {
match ctx.granted {
Some(granted) if ctx.requested.is_subset_of(granted) => PermissionDecision::Allow,
_ if !ctx.interactive => PermissionDecision::Deny(
"Non-interactive mode requires pre-granted permissions".into(),
),
_ => PermissionDecision::Prompt,
}
}
}
pub struct PermissivePermissionStrategy;
impl PermissionStrategy for PermissivePermissionStrategy {
fn granularity(&self) -> PermissionGranularity {
PermissionGranularity::Plugin
}
fn inherit_capabilities(&self) -> bool {
true
}
fn check(&self, ctx: &PermissionContext) -> PermissionDecision {
if ctx.requested.net.is_empty() {
PermissionDecision::Allow
} else {
match ctx.granted {
Some(granted) if ctx.requested.is_subset_of(granted) => PermissionDecision::Allow,
_ => PermissionDecision::Prompt,
}
}
}
}
pub struct CiPermissionStrategy;
impl PermissionStrategy for CiPermissionStrategy {
fn granularity(&self) -> PermissionGranularity {
PermissionGranularity::Plugin
}
fn inherit_capabilities(&self) -> bool {
false
}
fn check(&self, ctx: &PermissionContext) -> PermissionDecision {
match ctx.granted {
Some(granted) if ctx.requested.is_subset_of(granted) => PermissionDecision::Allow,
None if ctx.requested.is_empty() => PermissionDecision::Allow,
_ => PermissionDecision::Deny("CI mode: all permissions must be pre-granted".into()),
}
}
fn on_escalation(&self, _ctx: &PermissionContext) -> PermissionDecision {
PermissionDecision::Deny("CI mode: capability escalation not allowed".into())
}
}
#[derive(Debug)]
pub struct TrustAllStrategy {
_private: (),
}
impl TrustAllStrategy {
#[must_use = "TrustAllStrategy must be used in a PermissionConfig, ignoring it is likely a bug"]
pub fn new_dangerous() -> Self {
Self { _private: () }
}
}
impl PermissionStrategy for TrustAllStrategy {
fn granularity(&self) -> PermissionGranularity {
PermissionGranularity::Plugin
}
fn inherit_capabilities(&self) -> bool {
true
}
fn check(&self, _ctx: &PermissionContext) -> PermissionDecision {
PermissionDecision::Allow
}
fn on_escalation(&self, _ctx: &PermissionContext) -> PermissionDecision {
PermissionDecision::Allow
}
}
#[cfg(test)]
mod tests {
use super::*;
use sen_plugin_api::{PathPattern, StdioCapability};
fn make_context<'a>(
plugin: &'a str,
requested: &'a Capabilities,
granted: Option<&'a Capabilities>,
interactive: bool,
) -> PermissionContext<'a> {
PermissionContext {
plugin_name: plugin,
command_path: &[],
requested,
granted,
interactive,
}
}
#[test]
fn test_default_strategy_empty_caps() {
let strategy = DefaultPermissionStrategy;
let caps = Capabilities::none();
let ctx = make_context("test", &caps, None, true);
assert_eq!(strategy.check(&ctx), PermissionDecision::Allow);
}
#[test]
fn test_default_strategy_ungranted() {
let strategy = DefaultPermissionStrategy;
let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
let ctx = make_context("test", &caps, None, true);
assert_eq!(strategy.check(&ctx), PermissionDecision::Prompt);
}
#[test]
fn test_default_strategy_granted() {
let strategy = DefaultPermissionStrategy;
let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
let granted =
Capabilities::default().with_fs_read(vec![PathPattern::new("./data").recursive()]);
let ctx = make_context("test", &caps, Some(&granted), true);
assert_eq!(strategy.check(&ctx), PermissionDecision::Allow);
}
#[test]
fn test_strict_strategy_non_interactive() {
let strategy = StrictPermissionStrategy;
let caps = Capabilities::default().with_stdio(StdioCapability::stdout_only());
let ctx = make_context("test", &caps, None, false);
match strategy.check(&ctx) {
PermissionDecision::Deny(_) => {}
other => panic!("Expected Deny, got {:?}", other),
}
}
#[test]
fn test_ci_strategy_denies_ungranted() {
let strategy = CiPermissionStrategy;
let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
let ctx = make_context("test", &caps, None, false);
match strategy.check(&ctx) {
PermissionDecision::Deny(msg) => {
assert!(msg.contains("CI mode"));
}
other => panic!("Expected Deny, got {:?}", other),
}
}
#[test]
fn test_permissive_allows_non_network() {
let strategy = PermissivePermissionStrategy;
let caps = Capabilities::default()
.with_fs_read(vec![PathPattern::new("./data")])
.with_fs_write(vec![PathPattern::new("./output")])
.with_stdio(StdioCapability::all());
let ctx = make_context("test", &caps, None, true);
assert_eq!(strategy.check(&ctx), PermissionDecision::Allow);
}
#[test]
fn test_trust_all_allows_everything() {
let strategy = TrustAllStrategy::new_dangerous();
let caps = Capabilities::default()
.with_fs_read(vec![PathPattern::new("/")])
.with_net(vec![sen_plugin_api::NetPattern::https("*")]);
let ctx = make_context("test", &caps, None, false);
assert_eq!(strategy.check(&ctx), PermissionDecision::Allow);
}
}