| 1 | pub mod bash_arity; |
| 2 | |
| 3 | use std::collections::HashSet; |
| 4 | |
| 5 | use anyhow::Result; |
| 6 | use bash_arity::BashArityDict; |
| 7 | use deepseek_protocol::{NetworkPolicyAmendment, NetworkPolicyRuleAction}; |
| 8 | use serde::{Deserialize, Serialize}; |
| 9 | |
| 10 | /// Priority layer for a permission ruleset. Higher ordinal = higher priority. |
| 11 | /// On conflict, the highest-priority layer's longest matching prefix wins. |
| 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] |
| 13 | #[serde(rename_all = "snake_case")] |
| 14 | pub enum RulesetLayer { |
| 15 | BuiltinDefault = 0, |
| 16 | Agent = 1, |
| 17 | User = 2, |
| 18 | } |
| 19 | |
| 20 | /// A named set of allow/deny prefix rules at a given priority layer. |
| 21 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 22 | pub struct Ruleset { |
| 23 | pub layer: RulesetLayer, |
| 24 | pub trusted_prefixes: Vec<String>, |
| 25 | pub denied_prefixes: Vec<String>, |
| 26 | } |
| 27 | |
| 28 | impl Ruleset { |
| 29 | pub fn builtin_default() -> Self { |
| 30 | Self { |
| 31 | layer: RulesetLayer::BuiltinDefault, |
| 32 | trusted_prefixes: vec![], |
| 33 | denied_prefixes: vec![], |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | pub fn agent(trusted: Vec<String>, denied: Vec<String>) -> Self { |
| 38 | Self { |
| 39 | layer: RulesetLayer::Agent, |
| 40 | trusted_prefixes: trusted, |
| 41 | denied_prefixes: denied, |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | pub fn user(trusted: Vec<String>, denied: Vec<String>) -> Self { |
| 46 | Self { |
| 47 | layer: RulesetLayer::User, |
| 48 | trusted_prefixes: trusted, |
| 49 | denied_prefixes: denied, |
| 50 | } |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 55 | #[serde(rename_all = "snake_case")] |
| 56 | pub enum AskForApproval { |
| 57 | UnlessTrusted, |
| 58 | OnFailure, |
| 59 | OnRequest, |
| 60 | Reject { |
| 61 | sandbox_approval: bool, |
| 62 | rules: bool, |
| 63 | mcp_elicitations: bool, |
| 64 | }, |
| 65 | Never, |
| 66 | } |
| 67 | |
| 68 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 69 | pub struct ExecPolicyAmendment { |
| 70 | pub prefixes: Vec<String>, |
| 71 | } |
| 72 | |
| 73 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 74 | pub enum ExecApprovalRequirement { |
| 75 | Skip { |
| 76 | bypass_sandbox: bool, |
| 77 | proposed_execpolicy_amendment: Option<ExecPolicyAmendment>, |
| 78 | }, |
| 79 | NeedsApproval { |
| 80 | reason: String, |
| 81 | proposed_execpolicy_amendment: Option<ExecPolicyAmendment>, |
| 82 | proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>, |
| 83 | }, |
| 84 | Forbidden { |
| 85 | reason: String, |
| 86 | }, |
| 87 | } |
| 88 | |
| 89 | impl ExecApprovalRequirement { |
| 90 | pub fn reason(&self) -> &str { |
| 91 | match self { |
| 92 | ExecApprovalRequirement::Skip { .. } => "Execution allowed by policy.", |
| 93 | ExecApprovalRequirement::NeedsApproval { reason, .. } => reason, |
| 94 | ExecApprovalRequirement::Forbidden { reason } => reason, |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | pub fn phase(&self) -> &'static str { |
| 99 | match self { |
| 100 | ExecApprovalRequirement::Skip { .. } => "allowed", |
| 101 | ExecApprovalRequirement::NeedsApproval { .. } => "needs_approval", |
| 102 | ExecApprovalRequirement::Forbidden { .. } => "forbidden", |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 108 | pub struct ExecPolicyDecision { |
| 109 | pub allow: bool, |
| 110 | pub requires_approval: bool, |
| 111 | pub requirement: ExecApprovalRequirement, |
| 112 | pub matched_rule: Option<String>, |
| 113 | } |
| 114 | |
| 115 | impl ExecPolicyDecision { |
| 116 | pub fn reason(&self) -> &str { |
| 117 | self.requirement.reason() |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | #[derive(Debug, Clone)] |
| 122 | pub struct ExecPolicyContext<'a> { |
| 123 | pub command: &'a str, |
| 124 | pub cwd: &'a str, |
| 125 | pub ask_for_approval: AskForApproval, |
| 126 | pub sandbox_mode: Option<&'a str>, |
| 127 | } |
| 128 | |
| 129 | #[derive(Debug, Clone, Default)] |
| 130 | pub struct ExecPolicyEngine { |
| 131 | /// Layered rulesets (builtin → agent → user). When non-empty, takes precedence |
| 132 | /// over the legacy flat lists below. |
| 133 | rulesets: Vec<Ruleset>, |
| 134 | /// Legacy flat lists kept for backward compatibility with `new()`. |
| 135 | trusted_prefixes: Vec<String>, |
| 136 | denied_prefixes: Vec<String>, |
| 137 | approved_for_session: HashSet<String>, |
| 138 | /// Arity dictionary for command-prefix allow-rule matching. |
| 139 | arity_dict: BashArityDict, |
| 140 | } |
| 141 | |
| 142 | impl ExecPolicyEngine { |
| 143 | /// Legacy constructor: wraps the two vecs into a User-layer ruleset. |
| 144 | pub fn new(trusted_prefixes: Vec<String>, denied_prefixes: Vec<String>) -> Self { |
| 145 | Self { |
| 146 | rulesets: vec![], |
| 147 | trusted_prefixes, |
| 148 | denied_prefixes, |
| 149 | approved_for_session: HashSet::new(), |
| 150 | arity_dict: BashArityDict::new(), |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | /// Build an engine from explicit layered rulesets. |
| 155 | /// Rulesets are sorted by layer priority on construction. |
| 156 | pub fn with_rulesets(mut rulesets: Vec<Ruleset>) -> Self { |
| 157 | rulesets.sort_by_key(|r| r.layer); |
| 158 | Self { |
| 159 | rulesets, |
| 160 | trusted_prefixes: vec![], |
| 161 | denied_prefixes: vec![], |
| 162 | approved_for_session: HashSet::new(), |
| 163 | arity_dict: BashArityDict::new(), |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | /// Add a ruleset layer (re-sorts internally). |
| 168 | pub fn add_ruleset(&mut self, ruleset: Ruleset) { |
| 169 | self.rulesets.push(ruleset); |
| 170 | self.rulesets.sort_by_key(|r| r.layer); |
| 171 | } |
| 172 | |
| 173 | /// Resolve the effective trusted/denied prefix sets by merging all rulesets. |
| 174 | /// |
| 175 | /// Collects all prefixes from every layer (builtin → agent → user) into flat |
| 176 | /// trusted/denied lists. The `check()` method then applies deny-always-wins |
| 177 | /// semantics: any matching deny prefix blocks the command regardless of layer. |
| 178 | /// Trusted rules are only consulted after deny checks pass. |
| 179 | fn resolve_prefixes(&self) -> (Vec<String>, Vec<String>) { |
| 180 | if self.rulesets.is_empty() { |
| 181 | return (self.trusted_prefixes.clone(), self.denied_prefixes.clone()); |
| 182 | } |
| 183 | // Collect all trusted/denied across all layers, highest-priority last so they |
| 184 | // shadow lower-priority entries with the same prefix. |
| 185 | let mut trusted: Vec<String> = vec![]; |
| 186 | let mut denied: Vec<String> = vec![]; |
| 187 | for rs in &self.rulesets { |
| 188 | trusted.extend(rs.trusted_prefixes.iter().cloned()); |
| 189 | denied.extend(rs.denied_prefixes.iter().cloned()); |
| 190 | } |
| 191 | // Also merge legacy flat lists as user-layer. |
| 192 | trusted.extend(self.trusted_prefixes.iter().cloned()); |
| 193 | denied.extend(self.denied_prefixes.iter().cloned()); |
| 194 | (trusted, denied) |
| 195 | } |
| 196 | |
| 197 | pub fn remember_session_approval(&mut self, approval_key: String) { |
| 198 | self.approved_for_session.insert(approval_key); |
| 199 | } |
| 200 | |
| 201 | pub fn is_session_approved(&self, approval_key: &str) -> bool { |
| 202 | self.approved_for_session.contains(approval_key) |
| 203 | } |
| 204 | |
| 205 | pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> { |
| 206 | let normalized = normalize_command(ctx.command); |
| 207 | let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes(); |
| 208 | // Deny rules use simple prefix matching (no arity semantics needed). |
| 209 | if let Some(rule) = denied_prefixes |
| 210 | .iter() |
| 211 | .find(|rule| normalized.starts_with(&normalize_command(rule))) |
| 212 | { |
| 213 | return Ok(ExecPolicyDecision { |
| 214 | allow: false, |
| 215 | requires_approval: false, |
| 216 | matched_rule: Some(rule.clone()), |
| 217 | requirement: ExecApprovalRequirement::Forbidden { |
| 218 | reason: format!("Command blocked by denied prefix rule '{rule}'"), |
| 219 | }, |
| 220 | }); |
| 221 | } |
| 222 | |
| 223 | // Allow (trusted) rules use arity-aware prefix matching so that |
| 224 | // `auto_allow = ["git status"]` matches `git status -s` but NOT |
| 225 | // `git push origin main`. |
| 226 | let trusted_rule = trusted_prefixes |
| 227 | .iter() |
| 228 | .find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command)) |
| 229 | .cloned(); |
| 230 | let is_trusted = trusted_rule.is_some(); |
| 231 | |
| 232 | let requirement = match ctx.ask_for_approval { |
| 233 | AskForApproval::Never => ExecApprovalRequirement::Skip { |
| 234 | bypass_sandbox: false, |
| 235 | proposed_execpolicy_amendment: None, |
| 236 | }, |
| 237 | AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip { |
| 238 | bypass_sandbox: false, |
| 239 | proposed_execpolicy_amendment: None, |
| 240 | }, |
| 241 | AskForApproval::OnFailure => ExecApprovalRequirement::Skip { |
| 242 | bypass_sandbox: false, |
| 243 | proposed_execpolicy_amendment: None, |
| 244 | }, |
| 245 | AskForApproval::Reject { rules, .. } if rules => ExecApprovalRequirement::Forbidden { |
| 246 | reason: "Policy is configured to reject rule-exceptions.".to_string(), |
| 247 | }, |
| 248 | _ => ExecApprovalRequirement::NeedsApproval { |
| 249 | reason: if is_trusted { |
| 250 | "Approval requested by policy mode.".to_string() |
| 251 | } else { |
| 252 | "Unmatched command prefix requires approval.".to_string() |
| 253 | }, |
| 254 | proposed_execpolicy_amendment: if is_trusted { |
| 255 | None |
| 256 | } else { |
| 257 | Some(ExecPolicyAmendment { |
| 258 | prefixes: vec![first_token(ctx.command)], |
| 259 | }) |
| 260 | }, |
| 261 | proposed_network_policy_amendments: vec![NetworkPolicyAmendment { |
| 262 | host: ctx.cwd.to_string(), |
| 263 | action: NetworkPolicyRuleAction::Allow, |
| 264 | }], |
| 265 | }, |
| 266 | }; |
| 267 | |
| 268 | let (allow, requires_approval) = match requirement { |
| 269 | ExecApprovalRequirement::Skip { .. } => (true, false), |
| 270 | ExecApprovalRequirement::NeedsApproval { .. } => (true, true), |
| 271 | ExecApprovalRequirement::Forbidden { .. } => (false, false), |
| 272 | }; |
| 273 | |
| 274 | Ok(ExecPolicyDecision { |
| 275 | allow, |
| 276 | requires_approval, |
| 277 | matched_rule: trusted_rule, |
| 278 | requirement, |
| 279 | }) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | fn normalize_command(value: &str) -> String { |
| 284 | value.trim().to_ascii_lowercase() |
| 285 | } |
| 286 | |
| 287 | fn first_token(command: &str) -> String { |
| 288 | command |
| 289 | .split_whitespace() |
| 290 | .next() |
| 291 | .unwrap_or_default() |
| 292 | .to_string() |
| 293 | } |
| 294 |