Skip to main content

codewhale_execpolicy/
lib.rs

1pub mod bash_arity;
2
3use std::collections::HashSet;
4
5use anyhow::Result;
6use bash_arity::BashArityDict;
7use codewhale_protocol::{NetworkPolicyAmendment, NetworkPolicyRuleAction};
8use 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")]
14pub 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)]
22pub struct Ruleset {
23    pub layer: RulesetLayer,
24    pub trusted_prefixes: Vec<String>,
25    pub denied_prefixes: Vec<String>,
26}
27
28impl 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")]
56pub 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)]
69pub struct ExecPolicyAmendment {
70    pub prefixes: Vec<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub 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
89impl 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)]
108pub struct ExecPolicyDecision {
109    pub allow: bool,
110    pub requires_approval: bool,
111    pub requirement: ExecApprovalRequirement,
112    pub matched_rule: Option<String>,
113}
114
115impl ExecPolicyDecision {
116    pub fn reason(&self) -> &str {
117        self.requirement.reason()
118    }
119}
120
121#[derive(Debug, Clone)]
122pub 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)]
130pub 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
142impl 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
283fn normalize_command(value: &str) -> String {
284    value.trim().to_ascii_lowercase()
285}
286
287fn first_token(command: &str) -> String {
288    command
289        .split_whitespace()
290        .next()
291        .unwrap_or_default()
292        .to_string()
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn ctx(command: &str, ask_for_approval: AskForApproval) -> ExecPolicyContext<'_> {
300        ExecPolicyContext {
301            command,
302            cwd: "/workspace",
303            ask_for_approval,
304            sandbox_mode: Some("workspace-write"),
305        }
306    }
307
308    #[test]
309    fn trusted_prefix_skips_approval_when_policy_is_unless_trusted() {
310        let engine = ExecPolicyEngine::new(vec!["git status".to_string()], vec![]);
311
312        let decision = engine
313            .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
314            .unwrap();
315
316        assert!(decision.allow);
317        assert!(!decision.requires_approval);
318        assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
319        assert!(matches!(
320            decision.requirement,
321            ExecApprovalRequirement::Skip {
322                bypass_sandbox: false,
323                proposed_execpolicy_amendment: None,
324            }
325        ));
326    }
327
328    #[test]
329    fn denied_prefix_blocks_even_when_command_is_also_trusted() {
330        let engine = ExecPolicyEngine::new(
331            vec!["git status".to_string()],
332            vec!["git status".to_string()],
333        );
334
335        let decision = engine
336            .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
337            .unwrap();
338
339        assert!(!decision.allow);
340        assert!(!decision.requires_approval);
341        assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
342        assert!(matches!(
343            decision.requirement,
344            ExecApprovalRequirement::Forbidden { .. }
345        ));
346        assert_eq!(
347            decision.reason(),
348            "Command blocked by denied prefix rule 'git status'"
349        );
350    }
351
352    #[test]
353    fn unmatched_command_requires_approval_and_proposes_first_token_rule() {
354        let engine = ExecPolicyEngine::new(vec![], vec![]);
355
356        let decision = engine
357            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
358            .unwrap();
359
360        assert!(decision.allow);
361        assert!(decision.requires_approval);
362        assert_eq!(decision.matched_rule, None);
363        match decision.requirement {
364            ExecApprovalRequirement::NeedsApproval {
365                proposed_execpolicy_amendment: Some(amendment),
366                proposed_network_policy_amendments,
367                ..
368            } => {
369                assert_eq!(amendment.prefixes, vec!["cargo"]);
370                assert_eq!(
371                    proposed_network_policy_amendments,
372                    vec![NetworkPolicyAmendment {
373                        host: "/workspace".to_string(),
374                        action: NetworkPolicyRuleAction::Allow,
375                    }]
376                );
377            }
378            other => panic!("expected approval with proposed amendment, got {other:?}"),
379        }
380    }
381
382    #[test]
383    fn trusted_command_in_on_request_mode_still_requires_approval_without_new_rule() {
384        let engine = ExecPolicyEngine::new(vec!["cargo test".to_string()], vec![]);
385
386        let decision = engine
387            .check(ctx("cargo test --workspace", AskForApproval::OnRequest))
388            .unwrap();
389
390        assert!(decision.allow);
391        assert!(decision.requires_approval);
392        assert_eq!(decision.matched_rule.as_deref(), Some("cargo test"));
393        match decision.requirement {
394            ExecApprovalRequirement::NeedsApproval {
395                proposed_execpolicy_amendment,
396                ..
397            } => assert_eq!(proposed_execpolicy_amendment, None),
398            other => panic!("expected approval without amendment, got {other:?}"),
399        }
400    }
401
402    #[test]
403    fn reject_rules_mode_forbids_unmatched_command() {
404        let engine = ExecPolicyEngine::new(vec![], vec![]);
405
406        let decision = engine
407            .check(ctx(
408                "npm install",
409                AskForApproval::Reject {
410                    sandbox_approval: false,
411                    rules: true,
412                    mcp_elicitations: false,
413                },
414            ))
415            .unwrap();
416
417        assert!(!decision.allow);
418        assert!(!decision.requires_approval);
419        assert_eq!(decision.matched_rule, None);
420        assert_eq!(decision.requirement.phase(), "forbidden");
421        assert_eq!(
422            decision.reason(),
423            "Policy is configured to reject rule-exceptions."
424        );
425    }
426}