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| {
317 ruleset
318 .ask_rules
319 .iter()
320 .map(move |rule| (ruleset.layer, rule))
321 })
322 .filter(|(_, rule)| rule.tool == tool)
323 .filter(|(_, rule)| match rule.command.as_deref() {
324 Some(command) => self.arity_dict.allow_rule_matches(command, ctx.command),
325 None => true,
326 })
327 .filter(|(_, rule)| match (rule.path.as_deref(), ctx.path) {
328 (Some(pattern), Some(path)) => {
329 normalize_path_value(pattern) == normalize_path_value(path)
330 }
331 (Some(_), None) => false,
332 (None, _) => true,
333 })
334 .max_by_key(|(layer, rule)| (*layer, ask_rule_specificity(rule)))
335 .map(|(_, rule)| rule.clone())
336 }
337
338 pub fn remember_session_approval(&mut self, approval_key: String) {
340 self.approved_for_session.insert(approval_key);
341 }
342
343 pub fn is_session_approved(&self, approval_key: &str) -> bool {
345 self.approved_for_session.contains(approval_key)
346 }
347
348 pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
353 let normalized = normalize_command(ctx.command);
354 let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes();
355 if let Some(rule) = denied_prefixes.iter().find(|rule| {
359 let norm_rule = normalize_command(rule);
360 normalized == norm_rule
361 || (normalized.starts_with(&norm_rule)
362 && normalized.as_bytes().get(norm_rule.len()) == Some(&b' '))
363 }) {
364 return Ok(ExecPolicyDecision {
365 allow: false,
366 requires_approval: false,
367 matched_rule: Some(rule.clone()),
368 requirement: ExecApprovalRequirement::Forbidden {
369 reason: format!("Command blocked by denied prefix rule '{rule}'"),
370 },
371 });
372 }
373
374 let trusted_rule = trusted_prefixes
378 .iter()
379 .find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command))
380 .cloned();
381 let is_trusted = trusted_rule.is_some();
382
383 let ask_rule = self.matching_ask_rule(&ctx);
384
385 let mut matched_ask_rule = None;
386 let ask_rule_requirement = match &ctx.ask_for_approval {
393 AskForApproval::Never | AskForApproval::Reject { rules: true, .. } => None,
394 _ => ask_rule.as_ref().map(|rule| {
395 matched_ask_rule = Some(rule.label());
396 ExecApprovalRequirement::NeedsApproval {
397 reason: format!("Typed ask rule '{}' requires approval.", rule.label()),
398 proposed_execpolicy_amendment: None,
399 proposed_network_policy_amendments: Vec::new(),
405 }
406 }),
407 };
408
409 let requirement = if let Some(req) = ask_rule_requirement {
410 req
411 } else {
412 match &ctx.ask_for_approval {
413 AskForApproval::Never => {
414 if let Some(rule) = &ask_rule {
415 matched_ask_rule = Some(rule.label());
416 ExecApprovalRequirement::Forbidden {
417 reason: format!(
418 "Typed ask rule '{}' requires approval, but approval policy is never.",
419 rule.label()
420 ),
421 }
422 } else {
423 ExecApprovalRequirement::Skip {
424 bypass_sandbox: false,
425 proposed_execpolicy_amendment: None,
426 }
427 }
428 }
429 AskForApproval::Reject { rules, .. } if *rules => {
430 ExecApprovalRequirement::Forbidden {
431 reason: "Policy is configured to reject rule-exceptions.".to_string(),
432 }
433 }
434 AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
435 bypass_sandbox: false,
436 proposed_execpolicy_amendment: None,
437 },
438 AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
439 bypass_sandbox: false,
440 proposed_execpolicy_amendment: None,
441 },
442 _ => ExecApprovalRequirement::NeedsApproval {
443 reason: if is_trusted {
444 "Approval requested by policy mode.".to_string()
445 } else {
446 "Unmatched command prefix requires approval.".to_string()
447 },
448 proposed_execpolicy_amendment: if is_trusted {
449 None
450 } else {
451 Some(ExecPolicyAmendment {
452 prefixes: vec![first_token(ctx.command)],
453 })
454 },
455 proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
456 host: ctx.cwd.to_string(),
457 action: NetworkPolicyRuleAction::Allow,
458 }],
459 },
460 }
461 };
462
463 let (allow, requires_approval) = match requirement {
464 ExecApprovalRequirement::Skip { .. } => (true, false),
465 ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
466 ExecApprovalRequirement::Forbidden { .. } => (false, false),
467 };
468
469 Ok(ExecPolicyDecision {
470 allow,
471 requires_approval,
472 matched_rule: matched_ask_rule.or(trusted_rule),
473 requirement,
474 })
475 }
476}
477
478fn normalize_command(value: &str) -> String {
479 value
482 .split_whitespace()
483 .collect::<Vec<_>>()
484 .join(" ")
485 .to_ascii_lowercase()
486}
487
488fn first_token(command: &str) -> String {
489 command
490 .split_whitespace()
491 .next()
492 .unwrap_or_default()
493 .to_string()
494}
495
496fn normalize_path_value(value: &str) -> String {
497 value
498 .replace('\\', "/")
499 .trim()
500 .trim_matches('/')
501 .to_ascii_lowercase()
502}
503
504fn ask_rule_specificity(rule: &ToolAskRule) -> usize {
505 rule.tool.len()
506 + rule
507 .command
508 .as_ref()
509 .map_or(0, |command| command.len() + 1000)
510 + rule.path.as_ref().map_or(0, |path| path.len() + 1000)
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 fn ctx(command: &str, ask_for_approval: AskForApproval) -> ExecPolicyContext<'_> {
518 ExecPolicyContext {
519 command,
520 cwd: "/workspace",
521 tool: Some("exec_shell"),
522 path: None,
523 ask_for_approval,
524 sandbox_mode: Some("workspace-write"),
525 }
526 }
527
528 #[test]
529 fn trusted_prefix_skips_approval_when_policy_is_unless_trusted() {
530 let engine = ExecPolicyEngine::new(vec!["git status".to_string()], vec![]);
531
532 let decision = engine
533 .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
534 .unwrap();
535
536 assert!(decision.allow);
537 assert!(!decision.requires_approval);
538 assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
539 assert!(matches!(
540 decision.requirement,
541 ExecApprovalRequirement::Skip {
542 bypass_sandbox: false,
543 proposed_execpolicy_amendment: None,
544 }
545 ));
546 }
547
548 #[test]
549 fn denied_prefix_blocks_even_when_command_is_also_trusted() {
550 let engine = ExecPolicyEngine::new(
551 vec!["git status".to_string()],
552 vec!["git status".to_string()],
553 );
554
555 let decision = engine
556 .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
557 .unwrap();
558
559 assert!(!decision.allow);
560 assert!(!decision.requires_approval);
561 assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
562 assert!(matches!(
563 decision.requirement,
564 ExecApprovalRequirement::Forbidden { .. }
565 ));
566 assert_eq!(
567 decision.reason(),
568 "Command blocked by denied prefix rule 'git status'"
569 );
570 }
571
572 #[test]
573 fn unmatched_command_requires_approval_and_proposes_first_token_rule() {
574 let engine = ExecPolicyEngine::new(vec![], vec![]);
575
576 let decision = engine
577 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
578 .unwrap();
579
580 assert!(decision.allow);
581 assert!(decision.requires_approval);
582 assert_eq!(decision.matched_rule, None);
583 match decision.requirement {
584 ExecApprovalRequirement::NeedsApproval {
585 proposed_execpolicy_amendment: Some(amendment),
586 proposed_network_policy_amendments,
587 ..
588 } => {
589 assert_eq!(amendment.prefixes, vec!["cargo"]);
590 assert_eq!(
591 proposed_network_policy_amendments,
592 vec![NetworkPolicyAmendment {
593 host: "/workspace".to_string(),
594 action: NetworkPolicyRuleAction::Allow,
595 }]
596 );
597 }
598 other => panic!("expected approval with proposed amendment, got {other:?}"),
599 }
600 }
601
602 #[test]
603 fn trusted_command_in_on_request_mode_still_requires_approval_without_new_rule() {
604 let engine = ExecPolicyEngine::new(vec!["cargo test".to_string()], vec![]);
605
606 let decision = engine
607 .check(ctx("cargo test --workspace", AskForApproval::OnRequest))
608 .unwrap();
609
610 assert!(decision.allow);
611 assert!(decision.requires_approval);
612 assert_eq!(decision.matched_rule.as_deref(), Some("cargo test"));
613 match decision.requirement {
614 ExecApprovalRequirement::NeedsApproval {
615 proposed_execpolicy_amendment,
616 ..
617 } => assert_eq!(proposed_execpolicy_amendment, None),
618 other => panic!("expected approval without amendment, got {other:?}"),
619 }
620 }
621
622 #[test]
623 fn reject_rules_mode_forbids_unmatched_command() {
624 let engine = ExecPolicyEngine::new(vec![], vec![]);
625
626 let decision = engine
627 .check(ctx(
628 "npm install",
629 AskForApproval::Reject {
630 sandbox_approval: false,
631 rules: true,
632 mcp_elicitations: false,
633 },
634 ))
635 .unwrap();
636
637 assert!(!decision.allow);
638 assert!(!decision.requires_approval);
639 assert_eq!(decision.matched_rule, None);
640 assert_eq!(decision.requirement.phase(), "forbidden");
641 assert_eq!(
642 decision.reason(),
643 "Policy is configured to reject rule-exceptions."
644 );
645 }
646
647 #[test]
648 fn typed_ask_rule_forbids_matching_command_when_policy_is_never() {
649 let engine = ExecPolicyEngine::with_rulesets(vec![
650 Ruleset::user(vec![], vec![])
651 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
652 ]);
653
654 let decision = engine
655 .check(ctx("cargo test --workspace", AskForApproval::Never))
656 .unwrap();
657
658 assert!(!decision.allow);
659 assert!(!decision.requires_approval);
660 assert_eq!(
661 decision.matched_rule.as_deref(),
662 Some("tool=exec_shell command=cargo test")
663 );
664 assert_eq!(decision.requirement.phase(), "forbidden");
665 assert_eq!(
666 decision.reason(),
667 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
668 );
669 }
670
671 #[test]
672 fn typed_ask_rule_requires_approval_under_unless_trusted() {
673 let engine = ExecPolicyEngine::with_rulesets(vec![
674 Ruleset::user(vec![], vec![])
675 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
676 ]);
677
678 let decision = engine
679 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
680 .unwrap();
681
682 assert!(decision.allow);
683 assert!(decision.requires_approval);
684 assert_eq!(
685 decision.matched_rule.as_deref(),
686 Some("tool=exec_shell command=cargo test")
687 );
688 match decision.requirement {
689 ExecApprovalRequirement::NeedsApproval {
690 proposed_execpolicy_amendment,
691 proposed_network_policy_amendments,
692 ..
693 } => {
694 assert_eq!(proposed_execpolicy_amendment, None);
695 assert!(
698 proposed_network_policy_amendments.is_empty(),
699 "ask-rule approval must not propose network amendments, got {proposed_network_policy_amendments:?}"
700 );
701 }
702 other => panic!("expected typed ask approval, got {other:?}"),
703 }
704 }
705
706 #[test]
707 fn typed_ask_rule_requires_approval_under_on_failure() {
708 let engine = ExecPolicyEngine::with_rulesets(vec![
709 Ruleset::user(vec![], vec![])
710 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
711 ]);
712
713 let decision = engine
714 .check(ctx("cargo test --workspace", AskForApproval::OnFailure))
715 .unwrap();
716
717 assert!(decision.allow);
718 assert!(decision.requires_approval);
719 assert_eq!(
720 decision.reason(),
721 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval."
722 );
723 }
724
725 #[test]
726 fn typed_ask_rule_overrides_trusted_but_not_deny() {
727 let engine = ExecPolicyEngine::with_rulesets(vec![
728 Ruleset::user(
729 vec!["cargo test".to_string()],
730 vec!["cargo test --danger".to_string()],
731 )
732 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
733 ]);
734
735 let trusted = engine
736 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
737 .unwrap();
738 assert!(trusted.allow);
739 assert!(trusted.requires_approval);
740 assert_eq!(
741 trusted.matched_rule.as_deref(),
742 Some("tool=exec_shell command=cargo test")
743 );
744
745 let denied = engine
746 .check(ctx("cargo test --danger", AskForApproval::Never))
747 .unwrap();
748 assert!(!denied.allow);
749 assert!(!denied.requires_approval);
750 assert_eq!(denied.matched_rule.as_deref(), Some("cargo test --danger"));
751 assert_eq!(
752 denied.reason(),
753 "Command blocked by denied prefix rule 'cargo test --danger'"
754 );
755 }
756
757 #[test]
758 fn typed_ask_rule_prefers_higher_layer_before_specificity() {
759 let engine = ExecPolicyEngine::with_rulesets(vec![
760 Ruleset::agent(vec![], vec![])
761 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test --workspace")]),
762 Ruleset::user(vec![], vec![])
763 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
764 ]);
765
766 let decision = engine
767 .check(ctx(
768 "cargo test --workspace --all-features",
769 AskForApproval::UnlessTrusted,
770 ))
771 .unwrap();
772
773 assert!(decision.requires_approval);
774 assert_eq!(
775 decision.matched_rule.as_deref(),
776 Some("tool=exec_shell command=cargo test")
777 );
778 }
779
780 #[test]
781 fn reject_rules_mode_still_forbids_matching_ask_rule() {
782 let engine = ExecPolicyEngine::with_rulesets(vec![
783 Ruleset::user(vec![], vec![])
784 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
785 ]);
786
787 let decision = engine
788 .check(ctx(
789 "cargo test --workspace",
790 AskForApproval::Reject {
791 sandbox_approval: false,
792 rules: true,
793 mcp_elicitations: false,
794 },
795 ))
796 .unwrap();
797
798 assert!(!decision.allow);
799 assert!(!decision.requires_approval);
800 assert_eq!(decision.matched_rule, None);
801 assert_eq!(
802 decision.reason(),
803 "Policy is configured to reject rule-exceptions."
804 );
805 }
806
807 #[test]
808 fn typed_ask_rule_label_wins_when_never_blocks_trusted_command() {
809 let engine = ExecPolicyEngine::with_rulesets(vec![
810 Ruleset::user(vec!["cargo test".to_string()], vec![])
811 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
812 ]);
813
814 let decision = engine
815 .check(ctx("cargo test --workspace", AskForApproval::Never))
816 .unwrap();
817
818 assert!(!decision.allow);
819 assert_eq!(
820 decision.matched_rule.as_deref(),
821 Some("tool=exec_shell command=cargo test")
822 );
823 assert_eq!(
824 decision.reason(),
825 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
826 );
827 }
828
829 #[test]
830 fn typed_ask_path_matching_trims_spaces_before_boundary_slashes() {
831 let engine = ExecPolicyEngine::with_rulesets(vec![
832 Ruleset::user(vec![], vec![])
833 .with_ask_rules(vec![ToolAskRule::file_path("edit_file", " /TMP/PROJECT/ ")]),
834 ]);
835
836 let decision = engine
837 .check(ExecPolicyContext {
838 command: "",
839 cwd: "/workspace",
840 tool: Some("edit_file"),
841 path: Some("tmp/project"),
842 ask_for_approval: AskForApproval::Never,
843 sandbox_mode: Some("workspace-write"),
844 })
845 .unwrap();
846
847 assert!(!decision.allow);
848 assert_eq!(
849 decision.matched_rule.as_deref(),
850 Some("tool=edit_file path= /TMP/PROJECT/ ")
851 );
852 }
853}