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