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#[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#[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 rulesets: Vec<Ruleset>,
134 trusted_prefixes: Vec<String>,
136 denied_prefixes: Vec<String>,
137 approved_for_session: HashSet<String>,
138 arity_dict: BashArityDict,
140}
141
142impl ExecPolicyEngine {
143 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 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 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 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 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 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 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 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}