diff --git a/crates/execpolicy/src/lib.rs b/crates/execpolicy/src/lib.rs index 650b7d6f..1e6d1208 100644 --- a/crates/execpolicy/src/lib.rs +++ b/crates/execpolicy/src/lib.rs @@ -7,6 +7,50 @@ use bash_arity::BashArityDict; use deepseek_protocol::{NetworkPolicyAmendment, NetworkPolicyRuleAction}; use serde::{Deserialize, Serialize}; +/// Priority layer for a permission ruleset. Higher ordinal = higher priority. +/// On conflict, the highest-priority layer's longest matching prefix wins. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RulesetLayer { + BuiltinDefault = 0, + Agent = 1, + User = 2, +} + +/// A named set of allow/deny prefix rules at a given priority layer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Ruleset { + pub layer: RulesetLayer, + pub trusted_prefixes: Vec, + pub denied_prefixes: Vec, +} + +impl Ruleset { + pub fn builtin_default() -> Self { + Self { + layer: RulesetLayer::BuiltinDefault, + trusted_prefixes: vec![], + denied_prefixes: vec![], + } + } + + pub fn agent(trusted: Vec, denied: Vec) -> Self { + Self { + layer: RulesetLayer::Agent, + trusted_prefixes: trusted, + denied_prefixes: denied, + } + } + + pub fn user(trusted: Vec, denied: Vec) -> Self { + Self { + layer: RulesetLayer::User, + trusted_prefixes: trusted, + denied_prefixes: denied, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AskForApproval { @@ -84,6 +128,10 @@ pub struct ExecPolicyContext<'a> { #[derive(Debug, Clone)] pub struct ExecPolicyEngine { + /// Layered rulesets (builtin → agent → user). When non-empty, takes precedence + /// over the legacy flat lists below. + rulesets: Vec, + /// Legacy flat lists kept for backward compatibility with `new()`. trusted_prefixes: Vec, denied_prefixes: Vec, approved_for_session: HashSet, @@ -94,6 +142,7 @@ pub struct ExecPolicyEngine { impl Default for ExecPolicyEngine { fn default() -> Self { Self { + rulesets: vec![], trusted_prefixes: Vec::new(), denied_prefixes: Vec::new(), approved_for_session: HashSet::new(), @@ -103,8 +152,10 @@ impl Default for ExecPolicyEngine { } impl ExecPolicyEngine { + /// Legacy constructor: wraps the two vecs into a User-layer ruleset. pub fn new(trusted_prefixes: Vec, denied_prefixes: Vec) -> Self { Self { + rulesets: vec![], trusted_prefixes, denied_prefixes, approved_for_session: HashSet::new(), @@ -112,6 +163,44 @@ impl ExecPolicyEngine { } } + /// Build an engine from explicit layered rulesets. + /// Rulesets are sorted by layer priority on construction. + pub fn with_rulesets(mut rulesets: Vec) -> Self { + rulesets.sort_by_key(|r| r.layer); + Self { + rulesets, + trusted_prefixes: vec![], + denied_prefixes: vec![], + approved_for_session: HashSet::new(), + } + } + + /// Add a ruleset layer (re-sorts internally). + pub fn add_ruleset(&mut self, ruleset: Ruleset) { + self.rulesets.push(ruleset); + self.rulesets.sort_by_key(|r| r.layer); + } + + /// Resolve the effective trusted/denied prefix sets by merging all rulesets. + /// Higher-priority layers override lower ones; within a layer, longest prefix wins. + fn resolve_prefixes(&self) -> (Vec, Vec) { + if self.rulesets.is_empty() { + return (self.trusted_prefixes.clone(), self.denied_prefixes.clone()); + } + // Collect all trusted/denied across all layers, highest-priority last so they + // shadow lower-priority entries with the same prefix. + let mut trusted: Vec = vec![]; + let mut denied: Vec = vec![]; + for rs in &self.rulesets { + trusted.extend(rs.trusted_prefixes.iter().cloned()); + denied.extend(rs.denied_prefixes.iter().cloned()); + } + // Also merge legacy flat lists as user-layer. + trusted.extend(self.trusted_prefixes.iter().cloned()); + denied.extend(self.denied_prefixes.iter().cloned()); + (trusted, denied) + } + pub fn remember_session_approval(&mut self, approval_key: String) { self.approved_for_session.insert(approval_key); } @@ -122,9 +211,9 @@ impl ExecPolicyEngine { pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result { let normalized = normalize_command(ctx.command); + let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes(); // Deny rules use simple prefix matching (no arity semantics needed). - if let Some(rule) = self - .denied_prefixes + if let Some(rule) = denied_prefixes .iter() .find(|rule| normalized.starts_with(&normalize_command(rule))) { @@ -141,8 +230,7 @@ impl ExecPolicyEngine { // Allow (trusted) rules use arity-aware prefix matching so that // `auto_allow = ["git status"]` matches `git status -s` but NOT // `git push origin main`. - let trusted_rule = self - .trusted_prefixes + let trusted_rule = trusted_prefixes .iter() .find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command)) .cloned();