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, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
74#[serde(rename_all = "snake_case")]
75pub enum PermissionAction {
76 Allow,
78 Ask,
80 Deny,
82}
83
84fn default_rule_action() -> PermissionAction {
85 PermissionAction::Ask
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100#[serde(deny_unknown_fields)]
101pub struct ToolAskRule {
102 pub tool: String,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub command: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub path: Option<String>,
110 #[serde(default = "default_rule_action")]
112 pub action: PermissionAction,
113}
114
115impl ToolAskRule {
116 pub fn new(tool: impl Into<String>) -> Self {
118 Self {
119 tool: tool.into(),
120 command: None,
121 path: None,
122 action: PermissionAction::Ask,
123 }
124 }
125
126 pub fn exec_shell(command: impl Into<String>) -> Self {
128 Self {
129 tool: "exec_shell".to_string(),
130 command: Some(command.into()),
131 path: None,
132 action: PermissionAction::Ask,
133 }
134 }
135
136 pub fn file_path(tool: impl Into<String>, path: impl Into<String>) -> Self {
138 Self {
139 tool: tool.into(),
140 command: None,
141 path: Some(path.into()),
142 action: PermissionAction::Ask,
143 }
144 }
145
146 fn label(&self) -> String {
147 let mut parts = vec![format!("tool={}", self.tool)];
148 if let Some(command) = &self.command {
149 parts.push(format!("command={command}"));
150 }
151 if let Some(path) = &self.path {
152 parts.push(format!("path={path}"));
153 }
154 parts.join(" ")
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(rename_all = "snake_case")]
160pub enum AskForApproval {
162 UnlessTrusted,
164 OnFailure,
166 OnRequest,
168 Reject {
170 sandbox_approval: bool,
172 rules: bool,
174 mcp_elicitations: bool,
176 },
177 Never,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
183pub struct ExecPolicyAmendment {
184 pub prefixes: Vec<String>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
190pub enum ExecApprovalRequirement {
191 Skip {
193 bypass_sandbox: bool,
195 proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
197 },
198 NeedsApproval {
200 reason: String,
202 proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
204 proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
206 },
207 Forbidden {
209 reason: String,
211 },
212}
213
214impl ExecApprovalRequirement {
215 pub fn reason(&self) -> &str {
217 match self {
218 ExecApprovalRequirement::Skip { .. } => "Execution allowed by policy.",
219 ExecApprovalRequirement::NeedsApproval { reason, .. } => reason,
220 ExecApprovalRequirement::Forbidden { reason } => reason,
221 }
222 }
223
224 pub fn phase(&self) -> &'static str {
226 match self {
227 ExecApprovalRequirement::Skip { .. } => "allowed",
228 ExecApprovalRequirement::NeedsApproval { .. } => "needs_approval",
229 ExecApprovalRequirement::Forbidden { .. } => "forbidden",
230 }
231 }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
236pub struct ExecPolicyDecision {
237 pub allow: bool,
239 pub requires_approval: bool,
241 pub requirement: ExecApprovalRequirement,
243 pub matched_rule: Option<String>,
245 pub matched_action: Option<PermissionAction>,
248}
249
250impl ExecPolicyDecision {
251 pub fn reason(&self) -> &str {
253 self.requirement.reason()
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct ExecPolicyContext<'a> {
260 pub command: &'a str,
262 pub cwd: &'a str,
264 pub tool: Option<&'a str>,
266 pub path: Option<&'a str>,
268 pub ask_for_approval: AskForApproval,
270 pub sandbox_mode: Option<&'a str>,
272}
273
274#[derive(Debug, Clone, Default)]
275pub struct ExecPolicyEngine {
276 rulesets: Vec<Ruleset>,
279 trusted_prefixes: Vec<String>,
281 denied_prefixes: Vec<String>,
282 approved_for_session: HashSet<String>,
283 arity_dict: BashArityDict,
285}
286
287impl ExecPolicyEngine {
288 pub fn new(trusted_prefixes: Vec<String>, denied_prefixes: Vec<String>) -> Self {
290 Self {
291 rulesets: vec![],
292 trusted_prefixes,
293 denied_prefixes,
294 approved_for_session: HashSet::new(),
295 arity_dict: BashArityDict::new(),
296 }
297 }
298
299 pub fn with_rulesets(mut rulesets: Vec<Ruleset>) -> Self {
302 rulesets.sort_by_key(|r| r.layer);
303 Self {
304 rulesets,
305 trusted_prefixes: vec![],
306 denied_prefixes: vec![],
307 approved_for_session: HashSet::new(),
308 arity_dict: BashArityDict::new(),
309 }
310 }
311
312 pub fn add_ruleset(&mut self, ruleset: Ruleset) {
314 self.rulesets.push(ruleset);
315 self.rulesets.sort_by_key(|r| r.layer);
316 }
317
318 fn resolve_prefixes(&self) -> (Vec<String>, Vec<String>) {
325 if self.rulesets.is_empty() {
326 return (self.trusted_prefixes.clone(), self.denied_prefixes.clone());
327 }
328 let mut trusted: Vec<String> = vec![];
331 let mut denied: Vec<String> = vec![];
332 for rs in &self.rulesets {
333 trusted.extend(rs.trusted_prefixes.iter().cloned());
334 denied.extend(rs.denied_prefixes.iter().cloned());
335 }
336 trusted.extend(self.trusted_prefixes.iter().cloned());
338 denied.extend(self.denied_prefixes.iter().cloned());
339 (trusted, denied)
340 }
341
342 fn matching_ask_rule(&self, ctx: &ExecPolicyContext<'_>) -> Option<ToolAskRule> {
343 let tool = ctx.tool.unwrap_or("exec_shell");
344 let normalized_path = ctx
345 .path
346 .and_then(|path| normalize_workspace_relative_path(path, ctx.cwd));
347
348 self.rulesets
349 .iter()
350 .flat_map(|ruleset| {
351 ruleset
352 .ask_rules
353 .iter()
354 .map(move |rule| (ruleset.layer, rule))
355 })
356 .filter(|(_, rule)| rule.tool == tool)
357 .filter(|(_, rule)| match rule.command.as_deref() {
358 Some(command) => self.arity_dict.allow_rule_matches(command, ctx.command),
359 None => true,
360 })
361 .filter(|(_, rule)| match (rule.path.as_deref(), ctx.path) {
362 (Some(pattern), Some(_)) => match (
363 normalize_workspace_relative_path(pattern, ctx.cwd),
364 normalized_path.as_deref(),
365 ) {
366 (Some(pattern), Some(path)) => pattern == path,
367 _ => false,
368 },
369 (Some(_), None) => false,
370 (None, _) => true,
371 })
372 .max_by_key(|(layer, rule)| (rule.action, *layer, ask_rule_specificity(rule)))
373 .map(|(_, rule)| rule.clone())
374 }
375
376 pub fn remember_session_approval(&mut self, approval_key: String) {
378 self.approved_for_session.insert(approval_key);
379 }
380
381 pub fn is_session_approved(&self, approval_key: &str) -> bool {
383 self.approved_for_session.contains(approval_key)
384 }
385
386 pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
391 let normalized = normalize_command(ctx.command);
392 let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes();
393 let segments = command_segments(ctx.command);
397 if let Some(rule) = denied_prefixes.iter().find(|rule| {
398 let norm_rule = normalize_command(rule);
399 std::iter::once(normalized.clone())
401 .chain(segments.iter().map(|seg| normalize_command(seg)))
402 .any(|hay| {
403 hay == norm_rule
404 || (hay.starts_with(&norm_rule)
405 && hay.as_bytes().get(norm_rule.len()) == Some(&b' '))
406 })
407 }) {
408 return Ok(ExecPolicyDecision {
409 allow: false,
410 requires_approval: false,
411 matched_rule: Some(rule.clone()),
412 matched_action: None,
413 requirement: ExecApprovalRequirement::Forbidden {
414 reason: format!("Command blocked by denied prefix rule '{rule}'"),
415 },
416 });
417 }
418
419 let trusted_rule = if command_is_chained(ctx.command) {
427 None
428 } else {
429 trusted_prefixes
430 .iter()
431 .find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command))
432 .cloned()
433 };
434 let is_trusted = trusted_rule.is_some();
435
436 if command_is_chained(ctx.command) {
439 for seg in &segments {
440 let mut seg_ctx = ctx.clone();
441 seg_ctx.command = seg.as_str();
442 if let Some(rule) = self.matching_ask_rule(&seg_ctx)
443 && rule.action == PermissionAction::Deny
444 {
445 return Ok(ExecPolicyDecision {
446 allow: false,
447 requires_approval: false,
448 matched_rule: Some(rule.label()),
449 matched_action: Some(PermissionAction::Deny),
450 requirement: ExecApprovalRequirement::Forbidden {
451 reason: format!(
452 "Permission rule '{}' explicitly denies a chained segment of this invocation.",
453 rule.label()
454 ),
455 },
456 });
457 }
458 }
459 }
460
461 let ask_rule = self.matching_ask_rule(&ctx);
462
463 if let Some(rule) = &ask_rule {
466 match rule.action {
467 PermissionAction::Deny => {
468 return Ok(ExecPolicyDecision {
469 allow: false,
470 requires_approval: false,
471 matched_rule: Some(rule.label()),
472 matched_action: Some(PermissionAction::Deny),
473 requirement: ExecApprovalRequirement::Forbidden {
474 reason: format!(
475 "Permission rule '{}' explicitly denies this invocation.",
476 rule.label()
477 ),
478 },
479 });
480 }
481 PermissionAction::Allow => {
482 return Ok(ExecPolicyDecision {
483 allow: true,
484 requires_approval: false,
485 matched_rule: Some(rule.label()),
486 matched_action: Some(PermissionAction::Allow),
487 requirement: ExecApprovalRequirement::Skip {
488 bypass_sandbox: false,
489 proposed_execpolicy_amendment: None,
490 },
491 });
492 }
493 PermissionAction::Ask => {
494 }
496 }
497 }
498
499 let mut matched_ask_rule = None;
500 let ask_rule_requirement = match &ctx.ask_for_approval {
507 AskForApproval::Never | AskForApproval::Reject { rules: true, .. } => None,
508 _ => ask_rule.as_ref().map(|rule| {
509 matched_ask_rule = Some(rule.label());
510 ExecApprovalRequirement::NeedsApproval {
511 reason: format!("Typed ask rule '{}' requires approval.", rule.label()),
512 proposed_execpolicy_amendment: None,
513 proposed_network_policy_amendments: Vec::new(),
519 }
520 }),
521 };
522
523 let requirement = if let Some(req) = ask_rule_requirement {
524 req
525 } else {
526 match &ctx.ask_for_approval {
527 AskForApproval::Never => {
528 if let Some(rule) = &ask_rule {
529 matched_ask_rule = Some(rule.label());
530 ExecApprovalRequirement::Forbidden {
531 reason: format!(
532 "Typed ask rule '{}' requires approval, but approval policy is never.",
533 rule.label()
534 ),
535 }
536 } else {
537 ExecApprovalRequirement::Skip {
538 bypass_sandbox: false,
539 proposed_execpolicy_amendment: None,
540 }
541 }
542 }
543 AskForApproval::Reject { rules, .. } if *rules => {
544 ExecApprovalRequirement::Forbidden {
545 reason: "Policy is configured to reject rule-exceptions.".to_string(),
546 }
547 }
548 AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
549 bypass_sandbox: false,
550 proposed_execpolicy_amendment: None,
551 },
552 AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
553 bypass_sandbox: false,
554 proposed_execpolicy_amendment: None,
555 },
556 _ => ExecApprovalRequirement::NeedsApproval {
557 reason: if is_trusted {
558 "Approval requested by policy mode.".to_string()
559 } else {
560 "Unmatched command prefix requires approval.".to_string()
561 },
562 proposed_execpolicy_amendment: if is_trusted {
563 None
564 } else {
565 Some(ExecPolicyAmendment {
566 prefixes: vec![first_token(ctx.command)],
567 })
568 },
569 proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
570 host: ctx.cwd.to_string(),
571 action: NetworkPolicyRuleAction::Allow,
572 }],
573 },
574 }
575 };
576
577 let (allow, requires_approval) = match requirement {
578 ExecApprovalRequirement::Skip { .. } => (true, false),
579 ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
580 ExecApprovalRequirement::Forbidden { .. } => (false, false),
581 };
582
583 Ok(ExecPolicyDecision {
584 allow,
585 requires_approval,
586 matched_rule: matched_ask_rule.or(trusted_rule),
587 matched_action: ask_rule.as_ref().map(|r| r.action),
588 requirement,
589 })
590 }
591}
592
593fn command_segments(command: &str) -> Vec<String> {
599 command
600 .replace("&&", "\n")
601 .replace("||", "\n")
602 .replace(['|', ';'], "\n")
603 .lines()
604 .map(str::trim)
605 .filter(|segment| !segment.is_empty())
606 .map(ToOwned::to_owned)
607 .collect()
608}
609
610fn command_is_chained(command: &str) -> bool {
614 command_segments(command).len() > 1
615}
616
617fn normalize_command(value: &str) -> String {
618 value
621 .split_whitespace()
622 .collect::<Vec<_>>()
623 .join(" ")
624 .to_ascii_lowercase()
625}
626
627fn first_token(command: &str) -> String {
628 command
629 .split_whitespace()
630 .next()
631 .unwrap_or_default()
632 .to_string()
633}
634
635pub fn normalize_workspace_relative_path(value: &str, workspace_root: &str) -> Option<String> {
651 let path = parse_path_for_matching(value)?;
652 let workspace = parse_path_for_matching(workspace_root)?;
653 let workspace_root = workspace.root.as_ref()?;
654
655 let relative_components = match path.root.as_ref() {
656 Some(path_root) => {
657 if path_root != workspace_root {
658 return None;
659 }
660 path.components.strip_prefix(&workspace.components[..])?
661 }
662 None => path.components.as_slice(),
663 };
664
665 Some(relative_components.join("/"))
666}
667
668#[derive(Debug)]
669struct PathForMatching {
670 root: Option<String>,
671 components: Vec<String>,
672}
673
674fn parse_path_for_matching(value: &str) -> Option<PathForMatching> {
675 let value = value.trim().replace('\\', "/").to_ascii_lowercase();
676 if value.is_empty() {
677 return None;
678 }
679
680 let (root, components) = if let Some(path) = value.strip_prefix('/') {
681 (Some("/".to_string()), path)
682 } else if is_windows_absolute_path(&value) {
683 (Some(value[..2].to_string()), &value[3..])
684 } else if has_windows_drive_prefix(&value) {
685 return None;
688 } else {
689 (None, value.as_str())
690 };
691
692 let mut normalized_components = Vec::new();
693 for component in components.split('/') {
694 match component {
695 "" | "." => {}
696 ".." => return None,
697 component => normalized_components.push(component.to_string()),
698 }
699 }
700
701 Some(PathForMatching {
702 root,
703 components: normalized_components,
704 })
705}
706
707fn is_windows_absolute_path(value: &str) -> bool {
708 let bytes = value.as_bytes();
709 bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'
710}
711
712fn has_windows_drive_prefix(value: &str) -> bool {
713 let bytes = value.as_bytes();
714 bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
715}
716
717fn ask_rule_specificity(rule: &ToolAskRule) -> usize {
718 rule.tool.len()
719 + rule
720 .command
721 .as_ref()
722 .map_or(0, |command| command.len() + 1000)
723 + rule.path.as_ref().map_or(0, |path| path.len() + 1000)
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use AskForApproval::*;
730
731 fn ctx(command: &str, ask_for_approval: AskForApproval) -> ExecPolicyContext<'_> {
732 ExecPolicyContext {
733 command,
734 cwd: "/workspace",
735 tool: Some("exec_shell"),
736 path: None,
737 ask_for_approval,
738 sandbox_mode: Some("workspace-write"),
739 }
740 }
741
742 #[test]
743 fn denied_prefix_blocks_a_chained_segment() {
744 let engine = ExecPolicyEngine::new(vec![], vec!["npm publish".to_string()]);
746 for cmd in [
747 "ls && npm publish",
748 "true; npm publish",
749 "echo hi || npm publish",
750 "cat x | npm publish",
751 ] {
752 let decision = engine
753 .check(ctx(cmd, AskForApproval::UnlessTrusted))
754 .unwrap();
755 assert!(!decision.allow, "{cmd} should be denied");
756 assert!(
757 matches!(
758 decision.requirement,
759 ExecApprovalRequirement::Forbidden { .. }
760 ),
761 "{cmd}"
762 );
763 }
764 let d = engine
766 .check(ctx(
767 "npm publish --tag latest",
768 AskForApproval::UnlessTrusted,
769 ))
770 .unwrap();
771 assert!(!d.allow);
772 }
773
774 #[test]
775 fn denied_prefix_does_not_over_match_unrelated_commands() {
776 let engine = ExecPolicyEngine::new(vec![], vec!["npm publish".to_string()]);
777 let d = engine
780 .check(ctx("ls && echo npm publish", AskForApproval::UnlessTrusted))
781 .unwrap();
782 assert!(d.allow || d.requires_approval, "unexpected deny: {d:?}");
784 }
785
786 #[test]
787 fn trusted_prefix_does_not_auto_approve_a_chained_command() {
788 let engine = ExecPolicyEngine::new(vec!["git log".to_string()], vec![]);
790 let decision = engine
791 .check(ctx("git log ; rm -rf /", AskForApproval::UnlessTrusted))
792 .unwrap();
793 assert!(
795 !matches!(decision.requirement, ExecApprovalRequirement::Skip { .. }),
796 "chained command wrongly trusted: {decision:?}"
797 );
798 let single = engine
800 .check(ctx("git log --oneline", AskForApproval::UnlessTrusted))
801 .unwrap();
802 assert!(single.allow && !single.requires_approval);
803 }
804
805 #[test]
806 fn trusted_prefix_skips_approval_when_policy_is_unless_trusted() {
807 let engine = ExecPolicyEngine::new(vec!["git status".to_string()], vec![]);
808
809 let decision = engine
810 .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
811 .unwrap();
812
813 assert!(decision.allow);
814 assert!(!decision.requires_approval);
815 assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
816 assert!(matches!(
817 decision.requirement,
818 ExecApprovalRequirement::Skip {
819 bypass_sandbox: false,
820 proposed_execpolicy_amendment: None,
821 }
822 ));
823 }
824
825 #[test]
826 fn denied_prefix_blocks_even_when_command_is_also_trusted() {
827 let engine = ExecPolicyEngine::new(
828 vec!["git status".to_string()],
829 vec!["git status".to_string()],
830 );
831
832 let decision = engine
833 .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
834 .unwrap();
835
836 assert!(!decision.allow);
837 assert!(!decision.requires_approval);
838 assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
839 assert!(matches!(
840 decision.requirement,
841 ExecApprovalRequirement::Forbidden { .. }
842 ));
843 assert_eq!(
844 decision.reason(),
845 "Command blocked by denied prefix rule 'git status'"
846 );
847 }
848
849 #[test]
850 fn unmatched_command_requires_approval_and_proposes_first_token_rule() {
851 let engine = ExecPolicyEngine::new(vec![], vec![]);
852
853 let decision = engine
854 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
855 .unwrap();
856
857 assert!(decision.allow);
858 assert!(decision.requires_approval);
859 assert_eq!(decision.matched_rule, None);
860 match decision.requirement {
861 ExecApprovalRequirement::NeedsApproval {
862 proposed_execpolicy_amendment: Some(amendment),
863 proposed_network_policy_amendments,
864 ..
865 } => {
866 assert_eq!(amendment.prefixes, vec!["cargo"]);
867 assert_eq!(
868 proposed_network_policy_amendments,
869 vec![NetworkPolicyAmendment {
870 host: "/workspace".to_string(),
871 action: NetworkPolicyRuleAction::Allow,
872 }]
873 );
874 }
875 other => panic!("expected approval with proposed amendment, got {other:?}"),
876 }
877 }
878
879 #[test]
880 fn trusted_command_in_on_request_mode_still_requires_approval_without_new_rule() {
881 let engine = ExecPolicyEngine::new(vec!["cargo test".to_string()], vec![]);
882
883 let decision = engine
884 .check(ctx("cargo test --workspace", AskForApproval::OnRequest))
885 .unwrap();
886
887 assert!(decision.allow);
888 assert!(decision.requires_approval);
889 assert_eq!(decision.matched_rule.as_deref(), Some("cargo test"));
890 match decision.requirement {
891 ExecApprovalRequirement::NeedsApproval {
892 proposed_execpolicy_amendment,
893 ..
894 } => assert_eq!(proposed_execpolicy_amendment, None),
895 other => panic!("expected approval without amendment, got {other:?}"),
896 }
897 }
898
899 #[test]
900 fn reject_rules_mode_forbids_unmatched_command() {
901 let engine = ExecPolicyEngine::new(vec![], vec![]);
902
903 let decision = engine
904 .check(ctx(
905 "npm install",
906 AskForApproval::Reject {
907 sandbox_approval: false,
908 rules: true,
909 mcp_elicitations: false,
910 },
911 ))
912 .unwrap();
913
914 assert!(!decision.allow);
915 assert!(!decision.requires_approval);
916 assert_eq!(decision.matched_rule, None);
917 assert_eq!(decision.requirement.phase(), "forbidden");
918 assert_eq!(
919 decision.reason(),
920 "Policy is configured to reject rule-exceptions."
921 );
922 }
923
924 #[test]
925 fn typed_ask_rule_forbids_matching_command_when_policy_is_never() {
926 let engine = ExecPolicyEngine::with_rulesets(vec![
927 Ruleset::user(vec![], vec![])
928 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
929 ]);
930
931 let decision = engine
932 .check(ctx("cargo test --workspace", AskForApproval::Never))
933 .unwrap();
934
935 assert!(!decision.allow);
936 assert!(!decision.requires_approval);
937 assert_eq!(
938 decision.matched_rule.as_deref(),
939 Some("tool=exec_shell command=cargo test")
940 );
941 assert_eq!(decision.requirement.phase(), "forbidden");
942 assert_eq!(
943 decision.reason(),
944 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
945 );
946 }
947
948 #[test]
949 fn typed_ask_rule_requires_approval_under_unless_trusted() {
950 let engine = ExecPolicyEngine::with_rulesets(vec![
951 Ruleset::user(vec![], vec![])
952 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
953 ]);
954
955 let decision = engine
956 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
957 .unwrap();
958
959 assert!(decision.allow);
960 assert!(decision.requires_approval);
961 assert_eq!(
962 decision.matched_rule.as_deref(),
963 Some("tool=exec_shell command=cargo test")
964 );
965 match decision.requirement {
966 ExecApprovalRequirement::NeedsApproval {
967 proposed_execpolicy_amendment,
968 proposed_network_policy_amendments,
969 ..
970 } => {
971 assert_eq!(proposed_execpolicy_amendment, None);
972 assert!(
975 proposed_network_policy_amendments.is_empty(),
976 "ask-rule approval must not propose network amendments, got {proposed_network_policy_amendments:?}"
977 );
978 }
979 other => panic!("expected typed ask approval, got {other:?}"),
980 }
981 }
982
983 #[test]
984 fn typed_ask_rule_requires_approval_under_on_failure() {
985 let engine = ExecPolicyEngine::with_rulesets(vec![
986 Ruleset::user(vec![], vec![])
987 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
988 ]);
989
990 let decision = engine
991 .check(ctx("cargo test --workspace", AskForApproval::OnFailure))
992 .unwrap();
993
994 assert!(decision.allow);
995 assert!(decision.requires_approval);
996 assert_eq!(
997 decision.reason(),
998 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval."
999 );
1000 }
1001
1002 #[test]
1003 fn typed_ask_rule_overrides_trusted_but_not_deny() {
1004 let engine = ExecPolicyEngine::with_rulesets(vec![
1005 Ruleset::user(
1006 vec!["cargo test".to_string()],
1007 vec!["cargo test --danger".to_string()],
1008 )
1009 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
1010 ]);
1011
1012 let trusted = engine
1013 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
1014 .unwrap();
1015 assert!(trusted.allow);
1016 assert!(trusted.requires_approval);
1017 assert_eq!(
1018 trusted.matched_rule.as_deref(),
1019 Some("tool=exec_shell command=cargo test")
1020 );
1021
1022 let denied = engine
1023 .check(ctx("cargo test --danger", AskForApproval::Never))
1024 .unwrap();
1025 assert!(!denied.allow);
1026 assert!(!denied.requires_approval);
1027 assert_eq!(denied.matched_rule.as_deref(), Some("cargo test --danger"));
1028 assert_eq!(
1029 denied.reason(),
1030 "Command blocked by denied prefix rule 'cargo test --danger'"
1031 );
1032 }
1033
1034 #[test]
1035 fn typed_ask_rule_prefers_higher_layer_before_specificity() {
1036 let engine = ExecPolicyEngine::with_rulesets(vec![
1037 Ruleset::agent(vec![], vec![])
1038 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test --workspace")]),
1039 Ruleset::user(vec![], vec![])
1040 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
1041 ]);
1042
1043 let decision = engine
1044 .check(ctx(
1045 "cargo test --workspace --all-features",
1046 AskForApproval::UnlessTrusted,
1047 ))
1048 .unwrap();
1049
1050 assert!(decision.requires_approval);
1051 assert_eq!(
1052 decision.matched_rule.as_deref(),
1053 Some("tool=exec_shell command=cargo test")
1054 );
1055 }
1056
1057 #[test]
1058 fn reject_rules_mode_still_forbids_matching_ask_rule() {
1059 let engine = ExecPolicyEngine::with_rulesets(vec![
1060 Ruleset::user(vec![], vec![])
1061 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
1062 ]);
1063
1064 let decision = engine
1065 .check(ctx(
1066 "cargo test --workspace",
1067 AskForApproval::Reject {
1068 sandbox_approval: false,
1069 rules: true,
1070 mcp_elicitations: false,
1071 },
1072 ))
1073 .unwrap();
1074
1075 assert!(!decision.allow);
1076 assert!(!decision.requires_approval);
1077 assert_eq!(decision.matched_rule, None);
1078 assert_eq!(
1079 decision.reason(),
1080 "Policy is configured to reject rule-exceptions."
1081 );
1082 }
1083
1084 #[test]
1085 fn typed_ask_rule_label_wins_when_never_blocks_trusted_command() {
1086 let engine = ExecPolicyEngine::with_rulesets(vec![
1087 Ruleset::user(vec!["cargo test".to_string()], vec![])
1088 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
1089 ]);
1090
1091 let decision = engine
1092 .check(ctx("cargo test --workspace", AskForApproval::Never))
1093 .unwrap();
1094
1095 assert!(!decision.allow);
1096 assert_eq!(
1097 decision.matched_rule.as_deref(),
1098 Some("tool=exec_shell command=cargo test")
1099 );
1100 assert_eq!(
1101 decision.reason(),
1102 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
1103 );
1104 }
1105
1106 #[test]
1107 fn typed_ask_path_matching_trims_spaces_before_workspace_normalization() {
1108 let engine =
1109 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1110 vec![ToolAskRule::file_path(
1111 "edit_file",
1112 " /workspace/tmp/project/ ",
1113 )],
1114 )]);
1115
1116 let decision = engine
1117 .check(ExecPolicyContext {
1118 command: "",
1119 cwd: "/workspace",
1120 tool: Some("edit_file"),
1121 path: Some("tmp/project"),
1122 ask_for_approval: AskForApproval::Never,
1123 sandbox_mode: Some("workspace-write"),
1124 })
1125 .unwrap();
1126
1127 assert!(!decision.allow);
1128 assert_eq!(
1129 decision.matched_rule.as_deref(),
1130 Some("tool=edit_file path= /workspace/tmp/project/ ")
1131 );
1132 }
1133
1134 #[test]
1135 fn typed_ask_path_matching_normalizes_relative_and_absolute_workspace_paths() {
1136 let relative_rule = ExecPolicyEngine::with_rulesets(vec![
1137 Ruleset::user(vec![], vec![])
1138 .with_ask_rules(vec![ToolAskRule::file_path("edit_file", "src/a.rs")]),
1139 ]);
1140 let absolute_path = relative_rule
1141 .check(ExecPolicyContext {
1142 command: "",
1143 cwd: "/workspace",
1144 tool: Some("edit_file"),
1145 path: Some("/workspace/src/a.rs"),
1146 ask_for_approval: AskForApproval::OnFailure,
1147 sandbox_mode: Some("workspace-write"),
1148 })
1149 .unwrap();
1150 assert!(absolute_path.requires_approval);
1151
1152 let absolute_rule =
1153 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1154 vec![ToolAskRule::file_path("edit_file", "/workspace/src/a.rs")],
1155 )]);
1156 let relative_path = absolute_rule
1157 .check(ExecPolicyContext {
1158 command: "",
1159 cwd: "/workspace",
1160 tool: Some("edit_file"),
1161 path: Some("src/a.rs"),
1162 ask_for_approval: AskForApproval::OnFailure,
1163 sandbox_mode: Some("workspace-write"),
1164 })
1165 .unwrap();
1166 assert!(relative_path.requires_approval);
1167 }
1168
1169 #[test]
1170 fn typed_ask_path_matching_rejects_traversal_and_external_paths() {
1171 for (rule_path, path) in [
1172 ("src/a.rs", "../src/a.rs"),
1173 ("src/a.rs", "/workspace/src/../src/a.rs"),
1174 ("src/a.rs", "/src/a.rs"),
1175 ("../src/a.rs", "src/a.rs"),
1176 ("/src/a.rs", "src/a.rs"),
1177 ] {
1178 let engine = ExecPolicyEngine::with_rulesets(vec![
1179 Ruleset::user(vec![], vec![])
1180 .with_ask_rules(vec![ToolAskRule::file_path("edit_file", rule_path)]),
1181 ]);
1182 let decision = engine
1183 .check(ExecPolicyContext {
1184 command: "",
1185 cwd: "/workspace",
1186 tool: Some("edit_file"),
1187 path: Some(path),
1188 ask_for_approval: AskForApproval::OnFailure,
1189 sandbox_mode: Some("workspace-write"),
1190 })
1191 .unwrap();
1192 assert_eq!(
1193 decision.matched_rule, None,
1194 "rule {rule_path:?} and path {path:?} must not match"
1195 );
1196 }
1197 }
1198
1199 #[test]
1200 fn typed_ask_path_matching_accepts_windows_separators() {
1201 let engine = ExecPolicyEngine::with_rulesets(vec![
1202 Ruleset::user(vec![], vec![])
1203 .with_ask_rules(vec![ToolAskRule::file_path("edit_file", r"src\a.rs")]),
1204 ]);
1205
1206 let decision = engine
1207 .check(ExecPolicyContext {
1208 command: "",
1209 cwd: r"C:\workspace",
1210 tool: Some("edit_file"),
1211 path: Some(r"C:\workspace\src\a.rs"),
1212 ask_for_approval: AskForApproval::OnFailure,
1213 sandbox_mode: Some("workspace-write"),
1214 })
1215 .unwrap();
1216
1217 assert!(decision.requires_approval);
1218 }
1219
1220 #[test]
1223 fn deny_action_blocks_regardless_of_mode() {
1224 let engine =
1225 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1226 vec![ToolAskRule {
1227 tool: "exec_shell".into(),
1228 command: Some("sed".into()),
1229 path: None,
1230 action: PermissionAction::Deny,
1231 }],
1232 )]);
1233
1234 let decision = engine
1236 .check(ExecPolicyContext {
1237 command: "sed -i 's/foo/bar/' file.txt",
1238 cwd: "/tmp",
1239 tool: Some("exec_shell"),
1240 path: None,
1241 ask_for_approval: AskForApproval::UnlessTrusted,
1242 sandbox_mode: None,
1243 })
1244 .unwrap();
1245
1246 assert!(!decision.allow);
1247 assert!(!decision.requires_approval);
1248 assert_eq!(decision.matched_action, Some(PermissionAction::Deny));
1249 assert_eq!(decision.requirement.phase(), "forbidden");
1250 assert!(
1251 decision.reason().contains("explicitly denies"),
1252 "expected deny reason, got: {}",
1253 decision.reason()
1254 );
1255 }
1256
1257 #[test]
1258 fn allow_action_skips_approval_regardless_of_mode() {
1259 let engine =
1260 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1261 vec![ToolAskRule {
1262 tool: "exec_shell".into(),
1263 command: Some("git status".into()),
1264 path: None,
1265 action: PermissionAction::Allow,
1266 }],
1267 )]);
1268
1269 let decision = engine
1271 .check(ExecPolicyContext {
1272 command: "git status",
1273 cwd: "/tmp",
1274 tool: Some("exec_shell"),
1275 path: None,
1276 ask_for_approval: AskForApproval::OnRequest,
1277 sandbox_mode: None,
1278 })
1279 .unwrap();
1280
1281 assert!(decision.allow);
1282 assert!(!decision.requires_approval);
1283 assert_eq!(decision.matched_action, Some(PermissionAction::Allow));
1284 }
1285
1286 #[test]
1287 fn deny_wins_over_allow_when_both_match() {
1288 let engine = ExecPolicyEngine::with_rulesets(vec![
1291 Ruleset::agent(vec!["sed".into()], vec![]).with_ask_rules(vec![]),
1292 Ruleset::user(vec![], vec!["sed".into()]).with_ask_rules(vec![]),
1293 ]);
1294
1295 let decision = engine
1296 .check(ExecPolicyContext {
1297 command: "sed -i 's/a/b/' x.txt",
1298 cwd: "/tmp",
1299 tool: Some("exec_shell"),
1300 path: None,
1301 ask_for_approval: AskForApproval::UnlessTrusted,
1302 sandbox_mode: None,
1303 })
1304 .unwrap();
1305
1306 assert!(!decision.allow);
1307 assert_eq!(decision.requirement.phase(), "forbidden");
1308 }
1309
1310 #[test]
1311 fn ask_action_default_backward_compatible() {
1312 let rule = ToolAskRule::exec_shell("cargo test");
1314 assert_eq!(rule.action, PermissionAction::Ask);
1315 }
1316
1317 #[test]
1318 fn deny_action_constructors_produce_ask_by_default() {
1319 assert_eq!(ToolAskRule::new("exec_shell").action, PermissionAction::Ask);
1320 assert_eq!(
1321 ToolAskRule::exec_shell("cargo test").action,
1322 PermissionAction::Ask
1323 );
1324 assert_eq!(
1325 ToolAskRule::file_path("read_file", "secrets.txt").action,
1326 PermissionAction::Ask
1327 );
1328 }
1329
1330 #[test]
1333 fn deny_single_word_blocks_exact_and_subcommands() {
1334 let engine = engine_with_ask_rule(ToolAskRule {
1335 tool: "exec_shell".into(),
1336 command: Some("sed".into()),
1337 path: None,
1338 action: PermissionAction::Deny,
1339 });
1340
1341 let d = engine.check(ctx("sed", UnlessTrusted)).unwrap();
1343 assert!(!d.allow, "deny must block exact 'sed'");
1344
1345 let d = engine
1347 .check(ctx("sed -i 's/a/b/' file.txt", UnlessTrusted))
1348 .unwrap();
1349 assert!(!d.allow, "deny must block 'sed -i …'");
1350 }
1351
1352 #[test]
1353 fn deny_single_word_does_not_block_unrelated() {
1354 let engine = engine_with_ask_rule(ToolAskRule {
1355 tool: "exec_shell".into(),
1356 command: Some("sed".into()),
1357 path: None,
1358 action: PermissionAction::Deny,
1359 });
1360
1361 let d = engine
1363 .check(ctx("awk '{print $1}'", UnlessTrusted))
1364 .unwrap();
1365 assert!(d.allow, "deny 'sed' must not block 'awk'");
1366 }
1367
1368 #[test]
1369 fn deny_word_boundary_prevents_false_positives() {
1370 let engine = engine_with_ask_rule(ToolAskRule {
1372 tool: "exec_shell".into(),
1373 command: Some("rm".into()),
1374 path: None,
1375 action: PermissionAction::Deny,
1376 });
1377
1378 assert!(!engine.check(ctx("rm -rf /", UnlessTrusted)).unwrap().allow);
1379 assert!(
1380 engine
1381 .check(ctx("rmdir empty-dir", UnlessTrusted))
1382 .unwrap()
1383 .allow
1384 );
1385 }
1386
1387 #[test]
1390 fn deny_multi_word_blocks_subcommands() {
1391 let engine = engine_with_ask_rule(ToolAskRule {
1392 tool: "exec_shell".into(),
1393 command: Some("git push".into()),
1394 path: None,
1395 action: PermissionAction::Deny,
1396 });
1397
1398 assert!(!engine.check(ctx("git push", UnlessTrusted)).unwrap().allow);
1399 assert!(
1400 !engine
1401 .check(ctx("git push origin main", UnlessTrusted))
1402 .unwrap()
1403 .allow
1404 );
1405 assert!(
1406 !engine
1407 .check(ctx("git push --force", UnlessTrusted))
1408 .unwrap()
1409 .allow
1410 );
1411 }
1412
1413 #[test]
1414 fn deny_multi_word_distinguishes_from_sibling_subcommands() {
1415 let engine = engine_with_ask_rule(ToolAskRule {
1417 tool: "exec_shell".into(),
1418 command: Some("git push".into()),
1419 path: None,
1420 action: PermissionAction::Deny,
1421 });
1422
1423 assert!(engine.check(ctx("git pull", UnlessTrusted)).unwrap().allow);
1424 assert!(
1425 engine
1426 .check(ctx("git pull origin main", UnlessTrusted))
1427 .unwrap()
1428 .allow
1429 );
1430 assert!(
1431 engine
1432 .check(ctx("git status", UnlessTrusted))
1433 .unwrap()
1434 .allow
1435 );
1436 }
1437
1438 #[test]
1439 fn deny_multi_word_via_denied_prefixes_path() {
1440 let engine = ExecPolicyEngine::new(vec![], vec!["git push".into()]);
1443
1444 assert!(
1445 !engine
1446 .check(ctx("git push --force", UnlessTrusted))
1447 .unwrap()
1448 .allow
1449 );
1450 assert!(engine.check(ctx("git pull", UnlessTrusted)).unwrap().allow);
1451 }
1452
1453 #[test]
1456 fn deny_wins_over_allow_via_ask_rules() {
1457 let engine =
1458 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1459 vec![
1460 ToolAskRule {
1461 tool: "exec_shell".into(),
1462 command: Some("sed".into()),
1463 path: None,
1464 action: PermissionAction::Allow,
1465 },
1466 ToolAskRule {
1467 tool: "exec_shell".into(),
1468 command: Some("sed".into()),
1469 path: None,
1470 action: PermissionAction::Deny,
1471 },
1472 ],
1473 )]);
1474
1475 let d = engine
1478 .check(ctx("sed -i 's/a/b/' x.txt", UnlessTrusted))
1479 .unwrap();
1480 assert!(!d.allow, "deny must win over allow");
1481 }
1482
1483 #[test]
1484 fn deny_wins_over_allow_via_ask_rules_regardless_of_order() {
1485 let engine =
1486 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1487 vec![
1488 ToolAskRule {
1489 tool: "exec_shell".into(),
1490 command: Some("sed".into()),
1491 path: None,
1492 action: PermissionAction::Deny,
1493 },
1494 ToolAskRule {
1495 tool: "exec_shell".into(),
1496 command: Some("sed".into()),
1497 path: None,
1498 action: PermissionAction::Allow,
1499 },
1500 ],
1501 )]);
1502
1503 let d = engine
1504 .check(ctx("sed -i 's/a/b/' x.txt", UnlessTrusted))
1505 .unwrap();
1506 assert!(!d.allow, "deny must win even if allow appears later");
1507 assert_eq!(d.matched_action, Some(PermissionAction::Deny));
1508 }
1509
1510 #[test]
1511 fn path_deny_wins_over_path_allow_regardless_of_order() {
1512 let engine =
1513 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1514 vec![
1515 ToolAskRule {
1516 tool: "write_file".into(),
1517 command: None,
1518 path: Some("src/secrets.rs".into()),
1519 action: PermissionAction::Deny,
1520 },
1521 ToolAskRule {
1522 tool: "write_file".into(),
1523 command: None,
1524 path: Some("src/secrets.rs".into()),
1525 action: PermissionAction::Allow,
1526 },
1527 ],
1528 )]);
1529
1530 let d = engine
1531 .check(ExecPolicyContext {
1532 command: "",
1533 cwd: "/workspace",
1534 tool: Some("write_file"),
1535 path: Some("/workspace/src/secrets.rs"),
1536 ask_for_approval: UnlessTrusted,
1537 sandbox_mode: None,
1538 })
1539 .unwrap();
1540
1541 assert!(!d.allow, "path deny must win even if allow appears later");
1542 assert_eq!(d.matched_action, Some(PermissionAction::Deny));
1543 }
1544
1545 #[test]
1546 fn file_path_deny_wins_over_ask_and_allow_for_same_tool_and_path() {
1547 let engine = engine_with_ask_rules(vec![
1548 path_rule("write_file", "src/secrets.rs", PermissionAction::Allow),
1549 path_rule("write_file", "src/secrets.rs", PermissionAction::Ask),
1550 path_rule("write_file", "src/secrets.rs", PermissionAction::Deny),
1551 ]);
1552
1553 let d = engine
1554 .check(file_ctx(
1555 "write_file",
1556 "/workspace/src/secrets.rs",
1557 "/workspace",
1558 OnRequest,
1559 ))
1560 .unwrap();
1561
1562 assert!(!d.allow);
1563 assert!(!d.requires_approval);
1564 assert_eq!(d.matched_action, Some(PermissionAction::Deny));
1565 assert_eq!(
1566 d.matched_rule.as_deref(),
1567 Some("tool=write_file path=src/secrets.rs")
1568 );
1569 }
1570
1571 #[test]
1572 fn file_path_specificity_selects_path_rule_when_action_ties() {
1573 let engine = engine_with_ask_rules(vec![
1574 tool_rule("write_file", PermissionAction::Allow),
1575 path_rule("write_file", "src/secrets.rs", PermissionAction::Allow),
1576 ]);
1577
1578 let d = engine
1579 .check(file_ctx(
1580 "write_file",
1581 "/workspace/src/secrets.rs",
1582 "/workspace",
1583 OnRequest,
1584 ))
1585 .unwrap();
1586
1587 assert!(d.allow);
1588 assert!(!d.requires_approval);
1589 assert_eq!(d.matched_action, Some(PermissionAction::Allow));
1590 assert_eq!(
1591 d.matched_rule.as_deref(),
1592 Some("tool=write_file path=src/secrets.rs")
1593 );
1594 }
1595
1596 #[test]
1597 fn file_action_precedence_outranks_path_specificity() {
1598 let engine = engine_with_ask_rules(vec![
1599 tool_rule("write_file", PermissionAction::Deny),
1600 path_rule("write_file", "src/secrets.rs", PermissionAction::Allow),
1601 ]);
1602
1603 let d = engine
1604 .check(file_ctx(
1605 "write_file",
1606 "/workspace/src/secrets.rs",
1607 "/workspace",
1608 OnRequest,
1609 ))
1610 .unwrap();
1611
1612 assert!(!d.allow, "less-specific deny must beat path-specific allow");
1613 assert!(!d.requires_approval);
1614 assert_eq!(d.matched_action, Some(PermissionAction::Deny));
1615 assert_eq!(d.matched_rule.as_deref(), Some("tool=write_file"));
1616 }
1617
1618 #[test]
1619 fn file_action_precedence_uses_workspace_relative_normalization() {
1620 for (deny_path, allow_path, invocation_path) in [
1621 ("src/a.rs", "/workspace/src/a.rs", "/workspace/src/a.rs"),
1622 ("/workspace/src/a.rs", "src/a.rs", "src/a.rs"),
1623 ] {
1624 let engine = engine_with_ask_rules(vec![
1625 path_rule("write_file", allow_path, PermissionAction::Allow),
1626 path_rule("write_file", deny_path, PermissionAction::Deny),
1627 ]);
1628
1629 let d = engine
1630 .check(file_ctx(
1631 "write_file",
1632 invocation_path,
1633 "/workspace",
1634 OnRequest,
1635 ))
1636 .unwrap();
1637
1638 assert!(
1639 !d.allow,
1640 "deny path {deny_path:?} should beat allow path {allow_path:?} for invocation {invocation_path:?}"
1641 );
1642 assert_eq!(d.matched_action, Some(PermissionAction::Deny));
1643 }
1644 }
1645
1646 #[test]
1647 fn file_action_precedence_normalizes_windows_separators() {
1648 let engine = engine_with_ask_rules(vec![
1649 path_rule("write_file", r"src\a.rs", PermissionAction::Allow),
1650 path_rule("write_file", "src/a.rs", PermissionAction::Deny),
1651 ]);
1652
1653 let d = engine
1654 .check(file_ctx(
1655 "write_file",
1656 r"C:\workspace\src\a.rs",
1657 r"C:\workspace",
1658 OnRequest,
1659 ))
1660 .unwrap();
1661
1662 assert!(!d.allow);
1663 assert_eq!(d.matched_action, Some(PermissionAction::Deny));
1664 assert_eq!(
1665 d.matched_rule.as_deref(),
1666 Some("tool=write_file path=src/a.rs")
1667 );
1668 }
1669
1670 #[test]
1671 fn file_path_actions_are_scoped_by_tool_for_read_write_and_apply_patch() {
1672 let engine = engine_with_ask_rules(vec![
1673 path_rule("read_file", "src/shared.rs", PermissionAction::Deny),
1674 path_rule("write_file", "src/shared.rs", PermissionAction::Ask),
1675 path_rule("apply_patch", "src/shared.rs", PermissionAction::Allow),
1676 ]);
1677
1678 let read = engine
1679 .check(file_ctx(
1680 "read_file",
1681 "/workspace/src/shared.rs",
1682 "/workspace",
1683 OnRequest,
1684 ))
1685 .unwrap();
1686 assert!(!read.allow);
1687 assert!(!read.requires_approval);
1688 assert_eq!(read.matched_action, Some(PermissionAction::Deny));
1689
1690 let write = engine
1691 .check(file_ctx(
1692 "write_file",
1693 "/workspace/src/shared.rs",
1694 "/workspace",
1695 OnFailure,
1696 ))
1697 .unwrap();
1698 assert!(write.allow);
1699 assert!(write.requires_approval);
1700 assert_eq!(write.matched_action, Some(PermissionAction::Ask));
1701
1702 let patch = engine
1703 .check(file_ctx(
1704 "apply_patch",
1705 "/workspace/src/shared.rs",
1706 "/workspace",
1707 OnRequest,
1708 ))
1709 .unwrap();
1710 assert!(patch.allow);
1711 assert!(!patch.requires_approval);
1712 assert_eq!(patch.matched_action, Some(PermissionAction::Allow));
1713 }
1714
1715 #[test]
1716 fn deny_via_prefixes_wins_over_allow_via_prefixes() {
1717 let engine = ExecPolicyEngine::new(vec!["sed".into()], vec!["sed".into()]);
1719
1720 let d = engine
1721 .check(ctx("sed -i 's/a/b/' x.txt", UnlessTrusted))
1722 .unwrap();
1723 assert!(!d.allow, "denied prefix must win over trusted prefix");
1724 }
1725
1726 #[test]
1727 fn deny_tool_only_without_command_blocks_every_invocation() {
1728 let engine = engine_with_ask_rule(ToolAskRule {
1729 tool: "exec_shell".into(),
1730 command: None,
1731 path: None,
1732 action: PermissionAction::Deny,
1733 });
1734
1735 assert!(
1737 !engine
1738 .check(ctx("git status", UnlessTrusted))
1739 .unwrap()
1740 .allow
1741 );
1742 assert!(
1743 !engine
1744 .check(ctx("cargo build", UnlessTrusted))
1745 .unwrap()
1746 .allow
1747 );
1748 assert!(
1749 !engine
1750 .check(ctx("echo hello", UnlessTrusted))
1751 .unwrap()
1752 .allow
1753 );
1754 }
1755
1756 #[test]
1759 fn allow_single_word_skips_approval() {
1760 let engine = engine_with_ask_rule(ToolAskRule {
1761 tool: "exec_shell".into(),
1762 command: Some("cargo".into()),
1763 path: None,
1764 action: PermissionAction::Allow,
1765 });
1766
1767 let d = engine
1768 .check(ctx("cargo build --release", OnRequest))
1769 .unwrap();
1770 assert!(d.allow);
1771 assert!(!d.requires_approval);
1772 assert_eq!(d.matched_action, Some(PermissionAction::Allow));
1773 }
1774
1775 #[test]
1776 fn allow_multi_word_skips_approval() {
1777 let engine = engine_with_ask_rule(ToolAskRule {
1778 tool: "exec_shell".into(),
1779 command: Some("git status".into()),
1780 path: None,
1781 action: PermissionAction::Allow,
1782 });
1783
1784 let d = engine.check(ctx("git status --short", OnRequest)).unwrap();
1785 assert!(d.allow);
1786 assert!(!d.requires_approval);
1787 }
1788
1789 #[test]
1790 fn allow_does_not_leak_to_unmatched_commands() {
1791 let engine = engine_with_ask_rule(ToolAskRule {
1792 tool: "exec_shell".into(),
1793 command: Some("git status".into()),
1794 path: None,
1795 action: PermissionAction::Allow,
1796 });
1797
1798 let d = engine
1800 .check(ctx("git push origin main", UnlessTrusted))
1801 .unwrap();
1802 assert!(d.requires_approval);
1804 }
1805
1806 #[test]
1807 fn allow_under_never_mode_still_allows() {
1808 let engine = engine_with_ask_rule(ToolAskRule {
1810 tool: "exec_shell".into(),
1811 command: Some("cargo".into()),
1812 path: None,
1813 action: PermissionAction::Allow,
1814 });
1815
1816 let d = engine.check(ctx("cargo check", Never)).unwrap();
1817 assert!(d.allow);
1818 assert!(!d.requires_approval);
1819 }
1820
1821 #[test]
1824 fn ask_action_behaves_like_before_action_field_existed() {
1825 let engine = engine_with_ask_rule(ToolAskRule {
1826 tool: "exec_shell".into(),
1827 command: Some("cargo test".into()),
1828 path: None,
1829 action: PermissionAction::Ask,
1830 });
1831
1832 let d = engine
1834 .check(ctx("cargo test --workspace", UnlessTrusted))
1835 .unwrap();
1836 assert!(d.allow);
1837 assert!(d.requires_approval);
1838
1839 let d = engine.check(ctx("cargo test --workspace", Never)).unwrap();
1841 assert!(!d.allow);
1842 assert_eq!(d.requirement.phase(), "forbidden");
1843 }
1844
1845 #[test]
1846 fn ask_is_default_when_action_omitted() {
1847 let rule = ToolAskRule::exec_shell("cargo test");
1848 assert_eq!(rule.action, PermissionAction::Ask);
1849 }
1850
1851 #[test]
1854 fn deny_blocks_tool_only_even_for_different_tool() {
1855 let engine = engine_with_ask_rule(ToolAskRule {
1857 tool: "exec_shell".into(),
1858 command: Some("sed".into()),
1859 path: None,
1860 action: PermissionAction::Deny,
1861 });
1862
1863 let d = engine
1864 .check(ExecPolicyContext {
1865 command: "",
1866 cwd: "/workspace",
1867 tool: Some("write_file"),
1868 path: Some("/workspace/src/main.rs"),
1869 ask_for_approval: UnlessTrusted,
1870 sandbox_mode: None,
1871 })
1872 .unwrap();
1873 assert!(d.allow);
1875 }
1876
1877 #[test]
1878 fn normalize_handles_extra_whitespace_in_command() {
1879 let engine = ExecPolicyEngine::new(vec![], vec!["git push".into()]);
1881
1882 let d = engine
1883 .check(ctx("git push --force", UnlessTrusted))
1884 .unwrap();
1885 assert!(!d.allow, "extra whitespace must not bypass deny");
1886 }
1887
1888 #[test]
1889 fn normalize_handles_case_insensitivity() {
1890 let engine = ExecPolicyEngine::new(vec![], vec!["sed".into()]);
1892
1893 let d = engine
1894 .check(ctx("SED -i 's/a/b/' file.txt", UnlessTrusted))
1895 .unwrap();
1896 assert!(!d.allow, "case must not bypass deny");
1897 }
1898
1899 #[test]
1900 fn allow_falls_back_to_mode_when_no_rule_matches() {
1901 let engine = ExecPolicyEngine::new(vec![], vec![]); let d = engine.check(ctx("cargo build", UnlessTrusted)).unwrap();
1904 assert!(d.allow);
1905 assert!(d.requires_approval, "untrusted cmd needs approval");
1906 }
1907
1908 fn engine_with_ask_rule(rule: ToolAskRule) -> ExecPolicyEngine {
1911 engine_with_ask_rules(vec![rule])
1912 }
1913
1914 fn engine_with_ask_rules(rules: Vec<ToolAskRule>) -> ExecPolicyEngine {
1915 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(rules)])
1916 }
1917
1918 fn tool_rule(tool: &str, action: PermissionAction) -> ToolAskRule {
1919 ToolAskRule {
1920 tool: tool.to_string(),
1921 command: None,
1922 path: None,
1923 action,
1924 }
1925 }
1926
1927 fn path_rule(tool: &str, path: &str, action: PermissionAction) -> ToolAskRule {
1928 ToolAskRule {
1929 tool: tool.to_string(),
1930 command: None,
1931 path: Some(path.to_string()),
1932 action,
1933 }
1934 }
1935
1936 fn file_ctx<'a>(
1937 tool: &'a str,
1938 path: &'a str,
1939 cwd: &'a str,
1940 ask_for_approval: AskForApproval,
1941 ) -> ExecPolicyContext<'a> {
1942 ExecPolicyContext {
1943 command: "",
1944 cwd,
1945 tool: Some(tool),
1946 path: Some(path),
1947 ask_for_approval,
1948 sandbox_mode: Some("workspace-write"),
1949 }
1950 }
1951}