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,
25 pub trusted_prefixes: Vec<String>,
27 pub denied_prefixes: Vec<String>,
29 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub ask_rules: Vec<ToolAskRule>,
32}
33
34impl Ruleset {
35 pub fn builtin_default() -> Self {
37 Self {
38 layer: RulesetLayer::BuiltinDefault,
39 trusted_prefixes: vec![],
40 denied_prefixes: vec![],
41 ask_rules: vec![],
42 }
43 }
44
45 pub fn agent(trusted: Vec<String>, denied: Vec<String>) -> Self {
47 Self {
48 layer: RulesetLayer::Agent,
49 trusted_prefixes: trusted,
50 denied_prefixes: denied,
51 ask_rules: vec![],
52 }
53 }
54
55 pub fn user(trusted: Vec<String>, denied: Vec<String>) -> Self {
57 Self {
58 layer: RulesetLayer::User,
59 trusted_prefixes: trusted,
60 denied_prefixes: denied,
61 ask_rules: vec![],
62 }
63 }
64
65 pub fn with_ask_rules(mut self, ask_rules: Vec<ToolAskRule>) -> Self {
67 self.ask_rules = ask_rules;
68 self
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78#[serde(deny_unknown_fields)]
79pub struct ToolAskRule {
80 pub tool: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub command: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub path: Option<String>,
88}
89
90impl ToolAskRule {
91 pub fn new(tool: impl Into<String>) -> Self {
93 Self {
94 tool: tool.into(),
95 command: None,
96 path: None,
97 }
98 }
99
100 pub fn exec_shell(command: impl Into<String>) -> Self {
102 Self {
103 tool: "exec_shell".to_string(),
104 command: Some(command.into()),
105 path: None,
106 }
107 }
108
109 pub fn file_path(tool: impl Into<String>, path: impl Into<String>) -> Self {
111 Self {
112 tool: tool.into(),
113 command: None,
114 path: Some(path.into()),
115 }
116 }
117
118 fn label(&self) -> String {
119 let mut parts = vec![format!("tool={}", self.tool)];
120 if let Some(command) = &self.command {
121 parts.push(format!("command={command}"));
122 }
123 if let Some(path) = &self.path {
124 parts.push(format!("path={path}"));
125 }
126 parts.join(" ")
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "snake_case")]
132pub enum AskForApproval {
134 UnlessTrusted,
136 OnFailure,
138 OnRequest,
140 Reject {
142 sandbox_approval: bool,
144 rules: bool,
146 mcp_elicitations: bool,
148 },
149 Never,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155pub struct ExecPolicyAmendment {
156 pub prefixes: Vec<String>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
162pub enum ExecApprovalRequirement {
163 Skip {
165 bypass_sandbox: bool,
167 proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
169 },
170 NeedsApproval {
172 reason: String,
174 proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
176 proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
178 },
179 Forbidden {
181 reason: String,
183 },
184}
185
186impl ExecApprovalRequirement {
187 pub fn reason(&self) -> &str {
189 match self {
190 ExecApprovalRequirement::Skip { .. } => "Execution allowed by policy.",
191 ExecApprovalRequirement::NeedsApproval { reason, .. } => reason,
192 ExecApprovalRequirement::Forbidden { reason } => reason,
193 }
194 }
195
196 pub fn phase(&self) -> &'static str {
198 match self {
199 ExecApprovalRequirement::Skip { .. } => "allowed",
200 ExecApprovalRequirement::NeedsApproval { .. } => "needs_approval",
201 ExecApprovalRequirement::Forbidden { .. } => "forbidden",
202 }
203 }
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208pub struct ExecPolicyDecision {
209 pub allow: bool,
211 pub requires_approval: bool,
213 pub requirement: ExecApprovalRequirement,
215 pub matched_rule: Option<String>,
217}
218
219impl ExecPolicyDecision {
220 pub fn reason(&self) -> &str {
222 self.requirement.reason()
223 }
224}
225
226#[derive(Debug, Clone)]
228pub struct ExecPolicyContext<'a> {
229 pub command: &'a str,
231 pub cwd: &'a str,
233 pub tool: Option<&'a str>,
235 pub path: Option<&'a str>,
237 pub ask_for_approval: AskForApproval,
239 pub sandbox_mode: Option<&'a str>,
241}
242
243#[derive(Debug, Clone, Default)]
244pub struct ExecPolicyEngine {
245 rulesets: Vec<Ruleset>,
248 trusted_prefixes: Vec<String>,
250 denied_prefixes: Vec<String>,
251 approved_for_session: HashSet<String>,
252 arity_dict: BashArityDict,
254}
255
256impl ExecPolicyEngine {
257 pub fn new(trusted_prefixes: Vec<String>, denied_prefixes: Vec<String>) -> Self {
259 Self {
260 rulesets: vec![],
261 trusted_prefixes,
262 denied_prefixes,
263 approved_for_session: HashSet::new(),
264 arity_dict: BashArityDict::new(),
265 }
266 }
267
268 pub fn with_rulesets(mut rulesets: Vec<Ruleset>) -> Self {
271 rulesets.sort_by_key(|r| r.layer);
272 Self {
273 rulesets,
274 trusted_prefixes: vec![],
275 denied_prefixes: vec![],
276 approved_for_session: HashSet::new(),
277 arity_dict: BashArityDict::new(),
278 }
279 }
280
281 pub fn add_ruleset(&mut self, ruleset: Ruleset) {
283 self.rulesets.push(ruleset);
284 self.rulesets.sort_by_key(|r| r.layer);
285 }
286
287 fn resolve_prefixes(&self) -> (Vec<String>, Vec<String>) {
294 if self.rulesets.is_empty() {
295 return (self.trusted_prefixes.clone(), self.denied_prefixes.clone());
296 }
297 let mut trusted: Vec<String> = vec![];
300 let mut denied: Vec<String> = vec![];
301 for rs in &self.rulesets {
302 trusted.extend(rs.trusted_prefixes.iter().cloned());
303 denied.extend(rs.denied_prefixes.iter().cloned());
304 }
305 trusted.extend(self.trusted_prefixes.iter().cloned());
307 denied.extend(self.denied_prefixes.iter().cloned());
308 (trusted, denied)
309 }
310
311 fn matching_ask_rule(&self, ctx: &ExecPolicyContext<'_>) -> Option<ToolAskRule> {
312 let tool = ctx.tool.unwrap_or("exec_shell");
313
314 self.rulesets
315 .iter()
316 .flat_map(|ruleset| ruleset.ask_rules.iter())
317 .filter(|rule| rule.tool == tool)
318 .filter(|rule| match rule.command.as_deref() {
319 Some(command) => self.arity_dict.allow_rule_matches(command, ctx.command),
320 None => true,
321 })
322 .filter(|rule| match (rule.path.as_deref(), ctx.path) {
323 (Some(pattern), Some(path)) => {
324 normalize_path_value(pattern) == normalize_path_value(path)
325 }
326 (Some(_), None) => false,
327 (None, _) => true,
328 })
329 .max_by_key(|rule| ask_rule_specificity(rule))
330 .cloned()
331 }
332
333 pub fn remember_session_approval(&mut self, approval_key: String) {
335 self.approved_for_session.insert(approval_key);
336 }
337
338 pub fn is_session_approved(&self, approval_key: &str) -> bool {
340 self.approved_for_session.contains(approval_key)
341 }
342
343 pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
348 let normalized = normalize_command(ctx.command);
349 let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes();
350 if let Some(rule) = denied_prefixes
352 .iter()
353 .find(|rule| normalized.starts_with(&normalize_command(rule)))
354 {
355 return Ok(ExecPolicyDecision {
356 allow: false,
357 requires_approval: false,
358 matched_rule: Some(rule.clone()),
359 requirement: ExecApprovalRequirement::Forbidden {
360 reason: format!("Command blocked by denied prefix rule '{rule}'"),
361 },
362 });
363 }
364
365 let trusted_rule = trusted_prefixes
369 .iter()
370 .find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command))
371 .cloned();
372 let is_trusted = trusted_rule.is_some();
373
374 let ask_rule = self.matching_ask_rule(&ctx);
375
376 let requirement = match &ctx.ask_for_approval {
377 AskForApproval::Never => {
378 if let Some(rule) = &ask_rule {
379 ExecApprovalRequirement::Forbidden {
380 reason: format!(
381 "Typed ask rule '{}' requires approval, but approval policy is never.",
382 rule.label()
383 ),
384 }
385 } else {
386 ExecApprovalRequirement::Skip {
387 bypass_sandbox: false,
388 proposed_execpolicy_amendment: None,
389 }
390 }
391 }
392 AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
393 bypass_sandbox: false,
394 proposed_execpolicy_amendment: None,
395 },
396 AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
397 bypass_sandbox: false,
398 proposed_execpolicy_amendment: None,
399 },
400 AskForApproval::Reject { rules, .. } if *rules => ExecApprovalRequirement::Forbidden {
401 reason: "Policy is configured to reject rule-exceptions.".to_string(),
402 },
403 _ => ExecApprovalRequirement::NeedsApproval {
404 reason: if is_trusted {
405 "Approval requested by policy mode.".to_string()
406 } else {
407 "Unmatched command prefix requires approval.".to_string()
408 },
409 proposed_execpolicy_amendment: if is_trusted {
410 None
411 } else {
412 Some(ExecPolicyAmendment {
413 prefixes: vec![first_token(ctx.command)],
414 })
415 },
416 proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
417 host: ctx.cwd.to_string(),
418 action: NetworkPolicyRuleAction::Allow,
419 }],
420 },
421 };
422
423 let (allow, requires_approval) = match requirement {
424 ExecApprovalRequirement::Skip { .. } => (true, false),
425 ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
426 ExecApprovalRequirement::Forbidden { .. } => (false, false),
427 };
428
429 let matched_ask_rule = if matches!(&ctx.ask_for_approval, AskForApproval::Never) {
430 ask_rule.map(|rule| rule.label())
431 } else {
432 None
433 };
434
435 Ok(ExecPolicyDecision {
436 allow,
437 requires_approval,
438 matched_rule: matched_ask_rule.or(trusted_rule),
439 requirement,
440 })
441 }
442}
443
444fn normalize_command(value: &str) -> String {
445 value.trim().to_ascii_lowercase()
446}
447
448fn first_token(command: &str) -> String {
449 command
450 .split_whitespace()
451 .next()
452 .unwrap_or_default()
453 .to_string()
454}
455
456fn normalize_path_value(value: &str) -> String {
457 value
458 .replace('\\', "/")
459 .trim()
460 .trim_matches('/')
461 .to_ascii_lowercase()
462}
463
464fn ask_rule_specificity(rule: &ToolAskRule) -> usize {
465 rule.tool.len()
466 + rule
467 .command
468 .as_ref()
469 .map_or(0, |command| command.len() + 1000)
470 + rule.path.as_ref().map_or(0, |path| path.len() + 1000)
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 fn ctx(command: &str, ask_for_approval: AskForApproval) -> ExecPolicyContext<'_> {
478 ExecPolicyContext {
479 command,
480 cwd: "/workspace",
481 tool: Some("exec_shell"),
482 path: None,
483 ask_for_approval,
484 sandbox_mode: Some("workspace-write"),
485 }
486 }
487
488 #[test]
489 fn trusted_prefix_skips_approval_when_policy_is_unless_trusted() {
490 let engine = ExecPolicyEngine::new(vec!["git status".to_string()], vec![]);
491
492 let decision = engine
493 .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
494 .unwrap();
495
496 assert!(decision.allow);
497 assert!(!decision.requires_approval);
498 assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
499 assert!(matches!(
500 decision.requirement,
501 ExecApprovalRequirement::Skip {
502 bypass_sandbox: false,
503 proposed_execpolicy_amendment: None,
504 }
505 ));
506 }
507
508 #[test]
509 fn denied_prefix_blocks_even_when_command_is_also_trusted() {
510 let engine = ExecPolicyEngine::new(
511 vec!["git status".to_string()],
512 vec!["git status".to_string()],
513 );
514
515 let decision = engine
516 .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
517 .unwrap();
518
519 assert!(!decision.allow);
520 assert!(!decision.requires_approval);
521 assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
522 assert!(matches!(
523 decision.requirement,
524 ExecApprovalRequirement::Forbidden { .. }
525 ));
526 assert_eq!(
527 decision.reason(),
528 "Command blocked by denied prefix rule 'git status'"
529 );
530 }
531
532 #[test]
533 fn unmatched_command_requires_approval_and_proposes_first_token_rule() {
534 let engine = ExecPolicyEngine::new(vec![], vec![]);
535
536 let decision = engine
537 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
538 .unwrap();
539
540 assert!(decision.allow);
541 assert!(decision.requires_approval);
542 assert_eq!(decision.matched_rule, None);
543 match decision.requirement {
544 ExecApprovalRequirement::NeedsApproval {
545 proposed_execpolicy_amendment: Some(amendment),
546 proposed_network_policy_amendments,
547 ..
548 } => {
549 assert_eq!(amendment.prefixes, vec!["cargo"]);
550 assert_eq!(
551 proposed_network_policy_amendments,
552 vec![NetworkPolicyAmendment {
553 host: "/workspace".to_string(),
554 action: NetworkPolicyRuleAction::Allow,
555 }]
556 );
557 }
558 other => panic!("expected approval with proposed amendment, got {other:?}"),
559 }
560 }
561
562 #[test]
563 fn trusted_command_in_on_request_mode_still_requires_approval_without_new_rule() {
564 let engine = ExecPolicyEngine::new(vec!["cargo test".to_string()], vec![]);
565
566 let decision = engine
567 .check(ctx("cargo test --workspace", AskForApproval::OnRequest))
568 .unwrap();
569
570 assert!(decision.allow);
571 assert!(decision.requires_approval);
572 assert_eq!(decision.matched_rule.as_deref(), Some("cargo test"));
573 match decision.requirement {
574 ExecApprovalRequirement::NeedsApproval {
575 proposed_execpolicy_amendment,
576 ..
577 } => assert_eq!(proposed_execpolicy_amendment, None),
578 other => panic!("expected approval without amendment, got {other:?}"),
579 }
580 }
581
582 #[test]
583 fn reject_rules_mode_forbids_unmatched_command() {
584 let engine = ExecPolicyEngine::new(vec![], vec![]);
585
586 let decision = engine
587 .check(ctx(
588 "npm install",
589 AskForApproval::Reject {
590 sandbox_approval: false,
591 rules: true,
592 mcp_elicitations: false,
593 },
594 ))
595 .unwrap();
596
597 assert!(!decision.allow);
598 assert!(!decision.requires_approval);
599 assert_eq!(decision.matched_rule, None);
600 assert_eq!(decision.requirement.phase(), "forbidden");
601 assert_eq!(
602 decision.reason(),
603 "Policy is configured to reject rule-exceptions."
604 );
605 }
606
607 #[test]
608 fn typed_ask_rule_forbids_matching_command_when_policy_is_never() {
609 let engine = ExecPolicyEngine::with_rulesets(vec![
610 Ruleset::user(vec![], vec![])
611 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
612 ]);
613
614 let decision = engine
615 .check(ctx("cargo test --workspace", AskForApproval::Never))
616 .unwrap();
617
618 assert!(!decision.allow);
619 assert!(!decision.requires_approval);
620 assert_eq!(
621 decision.matched_rule.as_deref(),
622 Some("tool=exec_shell command=cargo test")
623 );
624 assert_eq!(decision.requirement.phase(), "forbidden");
625 assert_eq!(
626 decision.reason(),
627 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
628 );
629 }
630
631 #[test]
632 fn typed_ask_rule_is_ignored_outside_never_mode_for_now() {
633 let engine = ExecPolicyEngine::with_rulesets(vec![
634 Ruleset::user(vec![], vec![])
635 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
636 ]);
637
638 let decision = engine
639 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
640 .unwrap();
641
642 assert!(decision.allow);
643 assert!(decision.requires_approval);
644 assert_eq!(decision.matched_rule, None);
645 match decision.requirement {
646 ExecApprovalRequirement::NeedsApproval {
647 proposed_execpolicy_amendment: Some(amendment),
648 ..
649 } => assert_eq!(amendment.prefixes, vec!["cargo"]),
650 other => panic!("expected unchanged approval behavior, got {other:?}"),
651 }
652 }
653
654 #[test]
655 fn typed_ask_rule_does_not_change_allow_deny_precedence() {
656 let engine = ExecPolicyEngine::with_rulesets(vec![
657 Ruleset::user(
658 vec!["cargo test".to_string()],
659 vec!["cargo test --danger".to_string()],
660 )
661 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
662 ]);
663
664 let trusted = engine
665 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
666 .unwrap();
667 assert!(trusted.allow);
668 assert!(!trusted.requires_approval);
669 assert_eq!(trusted.matched_rule.as_deref(), Some("cargo test"));
670
671 let denied = engine
672 .check(ctx("cargo test --danger", AskForApproval::Never))
673 .unwrap();
674 assert!(!denied.allow);
675 assert!(!denied.requires_approval);
676 assert_eq!(denied.matched_rule.as_deref(), Some("cargo test --danger"));
677 assert_eq!(
678 denied.reason(),
679 "Command blocked by denied prefix rule 'cargo test --danger'"
680 );
681 }
682
683 #[test]
684 fn typed_ask_rule_label_wins_when_never_blocks_trusted_command() {
685 let engine = ExecPolicyEngine::with_rulesets(vec![
686 Ruleset::user(vec!["cargo test".to_string()], vec![])
687 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
688 ]);
689
690 let decision = engine
691 .check(ctx("cargo test --workspace", AskForApproval::Never))
692 .unwrap();
693
694 assert!(!decision.allow);
695 assert_eq!(
696 decision.matched_rule.as_deref(),
697 Some("tool=exec_shell command=cargo test")
698 );
699 assert_eq!(
700 decision.reason(),
701 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
702 );
703 }
704
705 #[test]
706 fn typed_ask_path_matching_trims_spaces_before_boundary_slashes() {
707 let engine = ExecPolicyEngine::with_rulesets(vec![
708 Ruleset::user(vec![], vec![])
709 .with_ask_rules(vec![ToolAskRule::file_path("edit_file", " /TMP/PROJECT/ ")]),
710 ]);
711
712 let decision = engine
713 .check(ExecPolicyContext {
714 command: "",
715 cwd: "/workspace",
716 tool: Some("edit_file"),
717 path: Some("tmp/project"),
718 ask_for_approval: AskForApproval::Never,
719 sandbox_mode: Some("workspace-write"),
720 })
721 .unwrap();
722
723 assert!(!decision.allow);
724 assert_eq!(
725 decision.matched_rule.as_deref(),
726 Some("tool=edit_file path= /TMP/PROJECT/ ")
727 );
728 }
729}