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 let normalized_path = ctx
314 .path
315 .and_then(|path| normalize_workspace_relative_path(path, ctx.cwd));
316
317 self.rulesets
318 .iter()
319 .flat_map(|ruleset| {
320 ruleset
321 .ask_rules
322 .iter()
323 .map(move |rule| (ruleset.layer, rule))
324 })
325 .filter(|(_, rule)| rule.tool == tool)
326 .filter(|(_, rule)| match rule.command.as_deref() {
327 Some(command) => self.arity_dict.allow_rule_matches(command, ctx.command),
328 None => true,
329 })
330 .filter(|(_, rule)| match (rule.path.as_deref(), ctx.path) {
331 (Some(pattern), Some(_)) => match (
332 normalize_workspace_relative_path(pattern, ctx.cwd),
333 normalized_path.as_deref(),
334 ) {
335 (Some(pattern), Some(path)) => pattern == path,
336 _ => false,
337 },
338 (Some(_), None) => false,
339 (None, _) => true,
340 })
341 .max_by_key(|(layer, rule)| (*layer, ask_rule_specificity(rule)))
342 .map(|(_, rule)| rule.clone())
343 }
344
345 pub fn remember_session_approval(&mut self, approval_key: String) {
347 self.approved_for_session.insert(approval_key);
348 }
349
350 pub fn is_session_approved(&self, approval_key: &str) -> bool {
352 self.approved_for_session.contains(approval_key)
353 }
354
355 pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
360 let normalized = normalize_command(ctx.command);
361 let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes();
362 if let Some(rule) = denied_prefixes.iter().find(|rule| {
366 let norm_rule = normalize_command(rule);
367 normalized == norm_rule
368 || (normalized.starts_with(&norm_rule)
369 && normalized.as_bytes().get(norm_rule.len()) == Some(&b' '))
370 }) {
371 return Ok(ExecPolicyDecision {
372 allow: false,
373 requires_approval: false,
374 matched_rule: Some(rule.clone()),
375 requirement: ExecApprovalRequirement::Forbidden {
376 reason: format!("Command blocked by denied prefix rule '{rule}'"),
377 },
378 });
379 }
380
381 let trusted_rule = trusted_prefixes
385 .iter()
386 .find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command))
387 .cloned();
388 let is_trusted = trusted_rule.is_some();
389
390 let ask_rule = self.matching_ask_rule(&ctx);
391
392 let mut matched_ask_rule = None;
393 let ask_rule_requirement = match &ctx.ask_for_approval {
400 AskForApproval::Never | AskForApproval::Reject { rules: true, .. } => None,
401 _ => ask_rule.as_ref().map(|rule| {
402 matched_ask_rule = Some(rule.label());
403 ExecApprovalRequirement::NeedsApproval {
404 reason: format!("Typed ask rule '{}' requires approval.", rule.label()),
405 proposed_execpolicy_amendment: None,
406 proposed_network_policy_amendments: Vec::new(),
412 }
413 }),
414 };
415
416 let requirement = if let Some(req) = ask_rule_requirement {
417 req
418 } else {
419 match &ctx.ask_for_approval {
420 AskForApproval::Never => {
421 if let Some(rule) = &ask_rule {
422 matched_ask_rule = Some(rule.label());
423 ExecApprovalRequirement::Forbidden {
424 reason: format!(
425 "Typed ask rule '{}' requires approval, but approval policy is never.",
426 rule.label()
427 ),
428 }
429 } else {
430 ExecApprovalRequirement::Skip {
431 bypass_sandbox: false,
432 proposed_execpolicy_amendment: None,
433 }
434 }
435 }
436 AskForApproval::Reject { rules, .. } if *rules => {
437 ExecApprovalRequirement::Forbidden {
438 reason: "Policy is configured to reject rule-exceptions.".to_string(),
439 }
440 }
441 AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
442 bypass_sandbox: false,
443 proposed_execpolicy_amendment: None,
444 },
445 AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
446 bypass_sandbox: false,
447 proposed_execpolicy_amendment: None,
448 },
449 _ => ExecApprovalRequirement::NeedsApproval {
450 reason: if is_trusted {
451 "Approval requested by policy mode.".to_string()
452 } else {
453 "Unmatched command prefix requires approval.".to_string()
454 },
455 proposed_execpolicy_amendment: if is_trusted {
456 None
457 } else {
458 Some(ExecPolicyAmendment {
459 prefixes: vec![first_token(ctx.command)],
460 })
461 },
462 proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
463 host: ctx.cwd.to_string(),
464 action: NetworkPolicyRuleAction::Allow,
465 }],
466 },
467 }
468 };
469
470 let (allow, requires_approval) = match requirement {
471 ExecApprovalRequirement::Skip { .. } => (true, false),
472 ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
473 ExecApprovalRequirement::Forbidden { .. } => (false, false),
474 };
475
476 Ok(ExecPolicyDecision {
477 allow,
478 requires_approval,
479 matched_rule: matched_ask_rule.or(trusted_rule),
480 requirement,
481 })
482 }
483}
484
485fn normalize_command(value: &str) -> String {
486 value
489 .split_whitespace()
490 .collect::<Vec<_>>()
491 .join(" ")
492 .to_ascii_lowercase()
493}
494
495fn first_token(command: &str) -> String {
496 command
497 .split_whitespace()
498 .next()
499 .unwrap_or_default()
500 .to_string()
501}
502
503pub fn normalize_workspace_relative_path(value: &str, workspace_root: &str) -> Option<String> {
519 let path = parse_path_for_matching(value)?;
520 let workspace = parse_path_for_matching(workspace_root)?;
521 let workspace_root = workspace.root.as_ref()?;
522
523 let relative_components = match path.root.as_ref() {
524 Some(path_root) => {
525 if path_root != workspace_root {
526 return None;
527 }
528 path.components.strip_prefix(&workspace.components[..])?
529 }
530 None => path.components.as_slice(),
531 };
532
533 Some(relative_components.join("/"))
534}
535
536#[derive(Debug)]
537struct PathForMatching {
538 root: Option<String>,
539 components: Vec<String>,
540}
541
542fn parse_path_for_matching(value: &str) -> Option<PathForMatching> {
543 let value = value.trim().replace('\\', "/").to_ascii_lowercase();
544 if value.is_empty() {
545 return None;
546 }
547
548 let (root, components) = if let Some(path) = value.strip_prefix('/') {
549 (Some("/".to_string()), path)
550 } else if is_windows_absolute_path(&value) {
551 (Some(value[..2].to_string()), &value[3..])
552 } else if has_windows_drive_prefix(&value) {
553 return None;
556 } else {
557 (None, value.as_str())
558 };
559
560 let mut normalized_components = Vec::new();
561 for component in components.split('/') {
562 match component {
563 "" | "." => {}
564 ".." => return None,
565 component => normalized_components.push(component.to_string()),
566 }
567 }
568
569 Some(PathForMatching {
570 root,
571 components: normalized_components,
572 })
573}
574
575fn is_windows_absolute_path(value: &str) -> bool {
576 let bytes = value.as_bytes();
577 bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'
578}
579
580fn has_windows_drive_prefix(value: &str) -> bool {
581 let bytes = value.as_bytes();
582 bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
583}
584
585fn ask_rule_specificity(rule: &ToolAskRule) -> usize {
586 rule.tool.len()
587 + rule
588 .command
589 .as_ref()
590 .map_or(0, |command| command.len() + 1000)
591 + rule.path.as_ref().map_or(0, |path| path.len() + 1000)
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 fn ctx(command: &str, ask_for_approval: AskForApproval) -> ExecPolicyContext<'_> {
599 ExecPolicyContext {
600 command,
601 cwd: "/workspace",
602 tool: Some("exec_shell"),
603 path: None,
604 ask_for_approval,
605 sandbox_mode: Some("workspace-write"),
606 }
607 }
608
609 #[test]
610 fn trusted_prefix_skips_approval_when_policy_is_unless_trusted() {
611 let engine = ExecPolicyEngine::new(vec!["git status".to_string()], vec![]);
612
613 let decision = engine
614 .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
615 .unwrap();
616
617 assert!(decision.allow);
618 assert!(!decision.requires_approval);
619 assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
620 assert!(matches!(
621 decision.requirement,
622 ExecApprovalRequirement::Skip {
623 bypass_sandbox: false,
624 proposed_execpolicy_amendment: None,
625 }
626 ));
627 }
628
629 #[test]
630 fn denied_prefix_blocks_even_when_command_is_also_trusted() {
631 let engine = ExecPolicyEngine::new(
632 vec!["git status".to_string()],
633 vec!["git status".to_string()],
634 );
635
636 let decision = engine
637 .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
638 .unwrap();
639
640 assert!(!decision.allow);
641 assert!(!decision.requires_approval);
642 assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
643 assert!(matches!(
644 decision.requirement,
645 ExecApprovalRequirement::Forbidden { .. }
646 ));
647 assert_eq!(
648 decision.reason(),
649 "Command blocked by denied prefix rule 'git status'"
650 );
651 }
652
653 #[test]
654 fn unmatched_command_requires_approval_and_proposes_first_token_rule() {
655 let engine = ExecPolicyEngine::new(vec![], vec![]);
656
657 let decision = engine
658 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
659 .unwrap();
660
661 assert!(decision.allow);
662 assert!(decision.requires_approval);
663 assert_eq!(decision.matched_rule, None);
664 match decision.requirement {
665 ExecApprovalRequirement::NeedsApproval {
666 proposed_execpolicy_amendment: Some(amendment),
667 proposed_network_policy_amendments,
668 ..
669 } => {
670 assert_eq!(amendment.prefixes, vec!["cargo"]);
671 assert_eq!(
672 proposed_network_policy_amendments,
673 vec![NetworkPolicyAmendment {
674 host: "/workspace".to_string(),
675 action: NetworkPolicyRuleAction::Allow,
676 }]
677 );
678 }
679 other => panic!("expected approval with proposed amendment, got {other:?}"),
680 }
681 }
682
683 #[test]
684 fn trusted_command_in_on_request_mode_still_requires_approval_without_new_rule() {
685 let engine = ExecPolicyEngine::new(vec!["cargo test".to_string()], vec![]);
686
687 let decision = engine
688 .check(ctx("cargo test --workspace", AskForApproval::OnRequest))
689 .unwrap();
690
691 assert!(decision.allow);
692 assert!(decision.requires_approval);
693 assert_eq!(decision.matched_rule.as_deref(), Some("cargo test"));
694 match decision.requirement {
695 ExecApprovalRequirement::NeedsApproval {
696 proposed_execpolicy_amendment,
697 ..
698 } => assert_eq!(proposed_execpolicy_amendment, None),
699 other => panic!("expected approval without amendment, got {other:?}"),
700 }
701 }
702
703 #[test]
704 fn reject_rules_mode_forbids_unmatched_command() {
705 let engine = ExecPolicyEngine::new(vec![], vec![]);
706
707 let decision = engine
708 .check(ctx(
709 "npm install",
710 AskForApproval::Reject {
711 sandbox_approval: false,
712 rules: true,
713 mcp_elicitations: false,
714 },
715 ))
716 .unwrap();
717
718 assert!(!decision.allow);
719 assert!(!decision.requires_approval);
720 assert_eq!(decision.matched_rule, None);
721 assert_eq!(decision.requirement.phase(), "forbidden");
722 assert_eq!(
723 decision.reason(),
724 "Policy is configured to reject rule-exceptions."
725 );
726 }
727
728 #[test]
729 fn typed_ask_rule_forbids_matching_command_when_policy_is_never() {
730 let engine = ExecPolicyEngine::with_rulesets(vec![
731 Ruleset::user(vec![], vec![])
732 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
733 ]);
734
735 let decision = engine
736 .check(ctx("cargo test --workspace", AskForApproval::Never))
737 .unwrap();
738
739 assert!(!decision.allow);
740 assert!(!decision.requires_approval);
741 assert_eq!(
742 decision.matched_rule.as_deref(),
743 Some("tool=exec_shell command=cargo test")
744 );
745 assert_eq!(decision.requirement.phase(), "forbidden");
746 assert_eq!(
747 decision.reason(),
748 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
749 );
750 }
751
752 #[test]
753 fn typed_ask_rule_requires_approval_under_unless_trusted() {
754 let engine = ExecPolicyEngine::with_rulesets(vec![
755 Ruleset::user(vec![], vec![])
756 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
757 ]);
758
759 let decision = engine
760 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
761 .unwrap();
762
763 assert!(decision.allow);
764 assert!(decision.requires_approval);
765 assert_eq!(
766 decision.matched_rule.as_deref(),
767 Some("tool=exec_shell command=cargo test")
768 );
769 match decision.requirement {
770 ExecApprovalRequirement::NeedsApproval {
771 proposed_execpolicy_amendment,
772 proposed_network_policy_amendments,
773 ..
774 } => {
775 assert_eq!(proposed_execpolicy_amendment, None);
776 assert!(
779 proposed_network_policy_amendments.is_empty(),
780 "ask-rule approval must not propose network amendments, got {proposed_network_policy_amendments:?}"
781 );
782 }
783 other => panic!("expected typed ask approval, got {other:?}"),
784 }
785 }
786
787 #[test]
788 fn typed_ask_rule_requires_approval_under_on_failure() {
789 let engine = ExecPolicyEngine::with_rulesets(vec![
790 Ruleset::user(vec![], vec![])
791 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
792 ]);
793
794 let decision = engine
795 .check(ctx("cargo test --workspace", AskForApproval::OnFailure))
796 .unwrap();
797
798 assert!(decision.allow);
799 assert!(decision.requires_approval);
800 assert_eq!(
801 decision.reason(),
802 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval."
803 );
804 }
805
806 #[test]
807 fn typed_ask_rule_overrides_trusted_but_not_deny() {
808 let engine = ExecPolicyEngine::with_rulesets(vec![
809 Ruleset::user(
810 vec!["cargo test".to_string()],
811 vec!["cargo test --danger".to_string()],
812 )
813 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
814 ]);
815
816 let trusted = engine
817 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
818 .unwrap();
819 assert!(trusted.allow);
820 assert!(trusted.requires_approval);
821 assert_eq!(
822 trusted.matched_rule.as_deref(),
823 Some("tool=exec_shell command=cargo test")
824 );
825
826 let denied = engine
827 .check(ctx("cargo test --danger", AskForApproval::Never))
828 .unwrap();
829 assert!(!denied.allow);
830 assert!(!denied.requires_approval);
831 assert_eq!(denied.matched_rule.as_deref(), Some("cargo test --danger"));
832 assert_eq!(
833 denied.reason(),
834 "Command blocked by denied prefix rule 'cargo test --danger'"
835 );
836 }
837
838 #[test]
839 fn typed_ask_rule_prefers_higher_layer_before_specificity() {
840 let engine = ExecPolicyEngine::with_rulesets(vec![
841 Ruleset::agent(vec![], vec![])
842 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test --workspace")]),
843 Ruleset::user(vec![], vec![])
844 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
845 ]);
846
847 let decision = engine
848 .check(ctx(
849 "cargo test --workspace --all-features",
850 AskForApproval::UnlessTrusted,
851 ))
852 .unwrap();
853
854 assert!(decision.requires_approval);
855 assert_eq!(
856 decision.matched_rule.as_deref(),
857 Some("tool=exec_shell command=cargo test")
858 );
859 }
860
861 #[test]
862 fn reject_rules_mode_still_forbids_matching_ask_rule() {
863 let engine = ExecPolicyEngine::with_rulesets(vec![
864 Ruleset::user(vec![], vec![])
865 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
866 ]);
867
868 let decision = engine
869 .check(ctx(
870 "cargo test --workspace",
871 AskForApproval::Reject {
872 sandbox_approval: false,
873 rules: true,
874 mcp_elicitations: false,
875 },
876 ))
877 .unwrap();
878
879 assert!(!decision.allow);
880 assert!(!decision.requires_approval);
881 assert_eq!(decision.matched_rule, None);
882 assert_eq!(
883 decision.reason(),
884 "Policy is configured to reject rule-exceptions."
885 );
886 }
887
888 #[test]
889 fn typed_ask_rule_label_wins_when_never_blocks_trusted_command() {
890 let engine = ExecPolicyEngine::with_rulesets(vec![
891 Ruleset::user(vec!["cargo test".to_string()], vec![])
892 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
893 ]);
894
895 let decision = engine
896 .check(ctx("cargo test --workspace", AskForApproval::Never))
897 .unwrap();
898
899 assert!(!decision.allow);
900 assert_eq!(
901 decision.matched_rule.as_deref(),
902 Some("tool=exec_shell command=cargo test")
903 );
904 assert_eq!(
905 decision.reason(),
906 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
907 );
908 }
909
910 #[test]
911 fn typed_ask_path_matching_trims_spaces_before_workspace_normalization() {
912 let engine =
913 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
914 vec![ToolAskRule::file_path(
915 "edit_file",
916 " /workspace/tmp/project/ ",
917 )],
918 )]);
919
920 let decision = engine
921 .check(ExecPolicyContext {
922 command: "",
923 cwd: "/workspace",
924 tool: Some("edit_file"),
925 path: Some("tmp/project"),
926 ask_for_approval: AskForApproval::Never,
927 sandbox_mode: Some("workspace-write"),
928 })
929 .unwrap();
930
931 assert!(!decision.allow);
932 assert_eq!(
933 decision.matched_rule.as_deref(),
934 Some("tool=edit_file path= /workspace/tmp/project/ ")
935 );
936 }
937
938 #[test]
939 fn typed_ask_path_matching_normalizes_relative_and_absolute_workspace_paths() {
940 let relative_rule = ExecPolicyEngine::with_rulesets(vec![
941 Ruleset::user(vec![], vec![])
942 .with_ask_rules(vec![ToolAskRule::file_path("edit_file", "src/a.rs")]),
943 ]);
944 let absolute_path = relative_rule
945 .check(ExecPolicyContext {
946 command: "",
947 cwd: "/workspace",
948 tool: Some("edit_file"),
949 path: Some("/workspace/src/a.rs"),
950 ask_for_approval: AskForApproval::OnFailure,
951 sandbox_mode: Some("workspace-write"),
952 })
953 .unwrap();
954 assert!(absolute_path.requires_approval);
955
956 let absolute_rule =
957 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
958 vec![ToolAskRule::file_path("edit_file", "/workspace/src/a.rs")],
959 )]);
960 let relative_path = absolute_rule
961 .check(ExecPolicyContext {
962 command: "",
963 cwd: "/workspace",
964 tool: Some("edit_file"),
965 path: Some("src/a.rs"),
966 ask_for_approval: AskForApproval::OnFailure,
967 sandbox_mode: Some("workspace-write"),
968 })
969 .unwrap();
970 assert!(relative_path.requires_approval);
971 }
972
973 #[test]
974 fn typed_ask_path_matching_rejects_traversal_and_external_paths() {
975 for (rule_path, path) in [
976 ("src/a.rs", "../src/a.rs"),
977 ("src/a.rs", "/workspace/src/../src/a.rs"),
978 ("src/a.rs", "/src/a.rs"),
979 ("../src/a.rs", "src/a.rs"),
980 ("/src/a.rs", "src/a.rs"),
981 ] {
982 let engine = ExecPolicyEngine::with_rulesets(vec![
983 Ruleset::user(vec![], vec![])
984 .with_ask_rules(vec![ToolAskRule::file_path("edit_file", rule_path)]),
985 ]);
986 let decision = engine
987 .check(ExecPolicyContext {
988 command: "",
989 cwd: "/workspace",
990 tool: Some("edit_file"),
991 path: Some(path),
992 ask_for_approval: AskForApproval::OnFailure,
993 sandbox_mode: Some("workspace-write"),
994 })
995 .unwrap();
996 assert_eq!(
997 decision.matched_rule, None,
998 "rule {rule_path:?} and path {path:?} must not match"
999 );
1000 }
1001 }
1002
1003 #[test]
1004 fn typed_ask_path_matching_accepts_windows_separators() {
1005 let engine = ExecPolicyEngine::with_rulesets(vec![
1006 Ruleset::user(vec![], vec![])
1007 .with_ask_rules(vec![ToolAskRule::file_path("edit_file", r"src\a.rs")]),
1008 ]);
1009
1010 let decision = engine
1011 .check(ExecPolicyContext {
1012 command: "",
1013 cwd: r"C:\workspace",
1014 tool: Some("edit_file"),
1015 path: Some(r"C:\workspace\src\a.rs"),
1016 ask_for_approval: AskForApproval::OnFailure,
1017 sandbox_mode: Some("workspace-write"),
1018 })
1019 .unwrap();
1020
1021 assert!(decision.requires_approval);
1022 }
1023}