From 210540dbb6439a232983a704e22645f55cf5b570 Mon Sep 17 00:00:00 2001 From: wangfengcsu Date: Mon, 4 May 2026 16:25:44 -0700 Subject: [PATCH] =?UTF-8?q?feat(execpolicy):=20layered=20permission=20rule?= =?UTF-8?q?sets=20=E2=80=94=20defaults+agent+user=20(closes=20#415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RulesetLayer enum (BuiltinDefault < Agent < User) and Ruleset struct so the engine can stack multiple named permission layers. Higher-priority layers shadow lower ones; within a layer, longest matching prefix wins. - ExecPolicyEngine::with_rulesets() builds from explicit layers - add_ruleset() inserts and re-sorts by priority - resolve_prefixes() merges all layers + legacy flat lists - Existing new(trusted, denied) constructor unchanged — backward compatible Co-Authored-By: Claude Sonnet 4.6 --- crates/execpolicy/src/lib.rs | 95 ++++++++++++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 4 deletions(-) diff --git a/crates/execpolicy/src/lib.rs b/crates/execpolicy/src/lib.rs index 608cdc8a..0b17b841 100644 --- a/crates/execpolicy/src/lib.rs +++ b/crates/execpolicy/src/lib.rs @@ -4,6 +4,50 @@ use anyhow::Result; 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 { @@ -81,20 +125,64 @@ pub struct ExecPolicyContext<'a> { #[derive(Debug, Clone, Default)] 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, } 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(), } } + /// 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); } @@ -105,8 +193,8 @@ impl ExecPolicyEngine { pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result { let normalized = normalize_command(ctx.command); - if let Some(rule) = self - .denied_prefixes + let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes(); + if let Some(rule) = denied_prefixes .iter() .find(|rule| normalized.starts_with(&normalize_command(rule))) { @@ -120,8 +208,7 @@ impl ExecPolicyEngine { }); } - let trusted_rule = self - .trusted_prefixes + let trusted_rule = trusted_prefixes .iter() .find(|rule| normalized.starts_with(&normalize_command(rule))) .cloned();