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 if let Some(rule) = denied_prefixes.iter().find(|rule| {
397 let norm_rule = normalize_command(rule);
398 normalized == norm_rule
399 || (normalized.starts_with(&norm_rule)
400 && normalized.as_bytes().get(norm_rule.len()) == Some(&b' '))
401 }) {
402 return Ok(ExecPolicyDecision {
403 allow: false,
404 requires_approval: false,
405 matched_rule: Some(rule.clone()),
406 matched_action: None,
407 requirement: ExecApprovalRequirement::Forbidden {
408 reason: format!("Command blocked by denied prefix rule '{rule}'"),
409 },
410 });
411 }
412
413 let trusted_rule = trusted_prefixes
417 .iter()
418 .find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command))
419 .cloned();
420 let is_trusted = trusted_rule.is_some();
421
422 let ask_rule = self.matching_ask_rule(&ctx);
423
424 if let Some(rule) = &ask_rule {
427 match rule.action {
428 PermissionAction::Deny => {
429 return Ok(ExecPolicyDecision {
430 allow: false,
431 requires_approval: false,
432 matched_rule: Some(rule.label()),
433 matched_action: Some(PermissionAction::Deny),
434 requirement: ExecApprovalRequirement::Forbidden {
435 reason: format!(
436 "Permission rule '{}' explicitly denies this invocation.",
437 rule.label()
438 ),
439 },
440 });
441 }
442 PermissionAction::Allow => {
443 return Ok(ExecPolicyDecision {
444 allow: true,
445 requires_approval: false,
446 matched_rule: Some(rule.label()),
447 matched_action: Some(PermissionAction::Allow),
448 requirement: ExecApprovalRequirement::Skip {
449 bypass_sandbox: false,
450 proposed_execpolicy_amendment: None,
451 },
452 });
453 }
454 PermissionAction::Ask => {
455 }
457 }
458 }
459
460 let mut matched_ask_rule = None;
461 let ask_rule_requirement = match &ctx.ask_for_approval {
468 AskForApproval::Never | AskForApproval::Reject { rules: true, .. } => None,
469 _ => ask_rule.as_ref().map(|rule| {
470 matched_ask_rule = Some(rule.label());
471 ExecApprovalRequirement::NeedsApproval {
472 reason: format!("Typed ask rule '{}' requires approval.", rule.label()),
473 proposed_execpolicy_amendment: None,
474 proposed_network_policy_amendments: Vec::new(),
480 }
481 }),
482 };
483
484 let requirement = if let Some(req) = ask_rule_requirement {
485 req
486 } else {
487 match &ctx.ask_for_approval {
488 AskForApproval::Never => {
489 if let Some(rule) = &ask_rule {
490 matched_ask_rule = Some(rule.label());
491 ExecApprovalRequirement::Forbidden {
492 reason: format!(
493 "Typed ask rule '{}' requires approval, but approval policy is never.",
494 rule.label()
495 ),
496 }
497 } else {
498 ExecApprovalRequirement::Skip {
499 bypass_sandbox: false,
500 proposed_execpolicy_amendment: None,
501 }
502 }
503 }
504 AskForApproval::Reject { rules, .. } if *rules => {
505 ExecApprovalRequirement::Forbidden {
506 reason: "Policy is configured to reject rule-exceptions.".to_string(),
507 }
508 }
509 AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
510 bypass_sandbox: false,
511 proposed_execpolicy_amendment: None,
512 },
513 AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
514 bypass_sandbox: false,
515 proposed_execpolicy_amendment: None,
516 },
517 _ => ExecApprovalRequirement::NeedsApproval {
518 reason: if is_trusted {
519 "Approval requested by policy mode.".to_string()
520 } else {
521 "Unmatched command prefix requires approval.".to_string()
522 },
523 proposed_execpolicy_amendment: if is_trusted {
524 None
525 } else {
526 Some(ExecPolicyAmendment {
527 prefixes: vec![first_token(ctx.command)],
528 })
529 },
530 proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
531 host: ctx.cwd.to_string(),
532 action: NetworkPolicyRuleAction::Allow,
533 }],
534 },
535 }
536 };
537
538 let (allow, requires_approval) = match requirement {
539 ExecApprovalRequirement::Skip { .. } => (true, false),
540 ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
541 ExecApprovalRequirement::Forbidden { .. } => (false, false),
542 };
543
544 Ok(ExecPolicyDecision {
545 allow,
546 requires_approval,
547 matched_rule: matched_ask_rule.or(trusted_rule),
548 matched_action: ask_rule.as_ref().map(|r| r.action),
549 requirement,
550 })
551 }
552}
553
554fn normalize_command(value: &str) -> String {
555 value
558 .split_whitespace()
559 .collect::<Vec<_>>()
560 .join(" ")
561 .to_ascii_lowercase()
562}
563
564fn first_token(command: &str) -> String {
565 command
566 .split_whitespace()
567 .next()
568 .unwrap_or_default()
569 .to_string()
570}
571
572pub fn normalize_workspace_relative_path(value: &str, workspace_root: &str) -> Option<String> {
588 let path = parse_path_for_matching(value)?;
589 let workspace = parse_path_for_matching(workspace_root)?;
590 let workspace_root = workspace.root.as_ref()?;
591
592 let relative_components = match path.root.as_ref() {
593 Some(path_root) => {
594 if path_root != workspace_root {
595 return None;
596 }
597 path.components.strip_prefix(&workspace.components[..])?
598 }
599 None => path.components.as_slice(),
600 };
601
602 Some(relative_components.join("/"))
603}
604
605#[derive(Debug)]
606struct PathForMatching {
607 root: Option<String>,
608 components: Vec<String>,
609}
610
611fn parse_path_for_matching(value: &str) -> Option<PathForMatching> {
612 let value = value.trim().replace('\\', "/").to_ascii_lowercase();
613 if value.is_empty() {
614 return None;
615 }
616
617 let (root, components) = if let Some(path) = value.strip_prefix('/') {
618 (Some("/".to_string()), path)
619 } else if is_windows_absolute_path(&value) {
620 (Some(value[..2].to_string()), &value[3..])
621 } else if has_windows_drive_prefix(&value) {
622 return None;
625 } else {
626 (None, value.as_str())
627 };
628
629 let mut normalized_components = Vec::new();
630 for component in components.split('/') {
631 match component {
632 "" | "." => {}
633 ".." => return None,
634 component => normalized_components.push(component.to_string()),
635 }
636 }
637
638 Some(PathForMatching {
639 root,
640 components: normalized_components,
641 })
642}
643
644fn is_windows_absolute_path(value: &str) -> bool {
645 let bytes = value.as_bytes();
646 bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'
647}
648
649fn has_windows_drive_prefix(value: &str) -> bool {
650 let bytes = value.as_bytes();
651 bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
652}
653
654fn ask_rule_specificity(rule: &ToolAskRule) -> usize {
655 rule.tool.len()
656 + rule
657 .command
658 .as_ref()
659 .map_or(0, |command| command.len() + 1000)
660 + rule.path.as_ref().map_or(0, |path| path.len() + 1000)
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666 use AskForApproval::*;
667
668 fn ctx(command: &str, ask_for_approval: AskForApproval) -> ExecPolicyContext<'_> {
669 ExecPolicyContext {
670 command,
671 cwd: "/workspace",
672 tool: Some("exec_shell"),
673 path: None,
674 ask_for_approval,
675 sandbox_mode: Some("workspace-write"),
676 }
677 }
678
679 #[test]
680 fn trusted_prefix_skips_approval_when_policy_is_unless_trusted() {
681 let engine = ExecPolicyEngine::new(vec!["git status".to_string()], vec![]);
682
683 let decision = engine
684 .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
685 .unwrap();
686
687 assert!(decision.allow);
688 assert!(!decision.requires_approval);
689 assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
690 assert!(matches!(
691 decision.requirement,
692 ExecApprovalRequirement::Skip {
693 bypass_sandbox: false,
694 proposed_execpolicy_amendment: None,
695 }
696 ));
697 }
698
699 #[test]
700 fn denied_prefix_blocks_even_when_command_is_also_trusted() {
701 let engine = ExecPolicyEngine::new(
702 vec!["git status".to_string()],
703 vec!["git status".to_string()],
704 );
705
706 let decision = engine
707 .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
708 .unwrap();
709
710 assert!(!decision.allow);
711 assert!(!decision.requires_approval);
712 assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
713 assert!(matches!(
714 decision.requirement,
715 ExecApprovalRequirement::Forbidden { .. }
716 ));
717 assert_eq!(
718 decision.reason(),
719 "Command blocked by denied prefix rule 'git status'"
720 );
721 }
722
723 #[test]
724 fn unmatched_command_requires_approval_and_proposes_first_token_rule() {
725 let engine = ExecPolicyEngine::new(vec![], vec![]);
726
727 let decision = engine
728 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
729 .unwrap();
730
731 assert!(decision.allow);
732 assert!(decision.requires_approval);
733 assert_eq!(decision.matched_rule, None);
734 match decision.requirement {
735 ExecApprovalRequirement::NeedsApproval {
736 proposed_execpolicy_amendment: Some(amendment),
737 proposed_network_policy_amendments,
738 ..
739 } => {
740 assert_eq!(amendment.prefixes, vec!["cargo"]);
741 assert_eq!(
742 proposed_network_policy_amendments,
743 vec![NetworkPolicyAmendment {
744 host: "/workspace".to_string(),
745 action: NetworkPolicyRuleAction::Allow,
746 }]
747 );
748 }
749 other => panic!("expected approval with proposed amendment, got {other:?}"),
750 }
751 }
752
753 #[test]
754 fn trusted_command_in_on_request_mode_still_requires_approval_without_new_rule() {
755 let engine = ExecPolicyEngine::new(vec!["cargo test".to_string()], vec![]);
756
757 let decision = engine
758 .check(ctx("cargo test --workspace", AskForApproval::OnRequest))
759 .unwrap();
760
761 assert!(decision.allow);
762 assert!(decision.requires_approval);
763 assert_eq!(decision.matched_rule.as_deref(), Some("cargo test"));
764 match decision.requirement {
765 ExecApprovalRequirement::NeedsApproval {
766 proposed_execpolicy_amendment,
767 ..
768 } => assert_eq!(proposed_execpolicy_amendment, None),
769 other => panic!("expected approval without amendment, got {other:?}"),
770 }
771 }
772
773 #[test]
774 fn reject_rules_mode_forbids_unmatched_command() {
775 let engine = ExecPolicyEngine::new(vec![], vec![]);
776
777 let decision = engine
778 .check(ctx(
779 "npm install",
780 AskForApproval::Reject {
781 sandbox_approval: false,
782 rules: true,
783 mcp_elicitations: false,
784 },
785 ))
786 .unwrap();
787
788 assert!(!decision.allow);
789 assert!(!decision.requires_approval);
790 assert_eq!(decision.matched_rule, None);
791 assert_eq!(decision.requirement.phase(), "forbidden");
792 assert_eq!(
793 decision.reason(),
794 "Policy is configured to reject rule-exceptions."
795 );
796 }
797
798 #[test]
799 fn typed_ask_rule_forbids_matching_command_when_policy_is_never() {
800 let engine = ExecPolicyEngine::with_rulesets(vec![
801 Ruleset::user(vec![], vec![])
802 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
803 ]);
804
805 let decision = engine
806 .check(ctx("cargo test --workspace", AskForApproval::Never))
807 .unwrap();
808
809 assert!(!decision.allow);
810 assert!(!decision.requires_approval);
811 assert_eq!(
812 decision.matched_rule.as_deref(),
813 Some("tool=exec_shell command=cargo test")
814 );
815 assert_eq!(decision.requirement.phase(), "forbidden");
816 assert_eq!(
817 decision.reason(),
818 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
819 );
820 }
821
822 #[test]
823 fn typed_ask_rule_requires_approval_under_unless_trusted() {
824 let engine = ExecPolicyEngine::with_rulesets(vec![
825 Ruleset::user(vec![], vec![])
826 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
827 ]);
828
829 let decision = engine
830 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
831 .unwrap();
832
833 assert!(decision.allow);
834 assert!(decision.requires_approval);
835 assert_eq!(
836 decision.matched_rule.as_deref(),
837 Some("tool=exec_shell command=cargo test")
838 );
839 match decision.requirement {
840 ExecApprovalRequirement::NeedsApproval {
841 proposed_execpolicy_amendment,
842 proposed_network_policy_amendments,
843 ..
844 } => {
845 assert_eq!(proposed_execpolicy_amendment, None);
846 assert!(
849 proposed_network_policy_amendments.is_empty(),
850 "ask-rule approval must not propose network amendments, got {proposed_network_policy_amendments:?}"
851 );
852 }
853 other => panic!("expected typed ask approval, got {other:?}"),
854 }
855 }
856
857 #[test]
858 fn typed_ask_rule_requires_approval_under_on_failure() {
859 let engine = ExecPolicyEngine::with_rulesets(vec![
860 Ruleset::user(vec![], vec![])
861 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
862 ]);
863
864 let decision = engine
865 .check(ctx("cargo test --workspace", AskForApproval::OnFailure))
866 .unwrap();
867
868 assert!(decision.allow);
869 assert!(decision.requires_approval);
870 assert_eq!(
871 decision.reason(),
872 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval."
873 );
874 }
875
876 #[test]
877 fn typed_ask_rule_overrides_trusted_but_not_deny() {
878 let engine = ExecPolicyEngine::with_rulesets(vec![
879 Ruleset::user(
880 vec!["cargo test".to_string()],
881 vec!["cargo test --danger".to_string()],
882 )
883 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
884 ]);
885
886 let trusted = engine
887 .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
888 .unwrap();
889 assert!(trusted.allow);
890 assert!(trusted.requires_approval);
891 assert_eq!(
892 trusted.matched_rule.as_deref(),
893 Some("tool=exec_shell command=cargo test")
894 );
895
896 let denied = engine
897 .check(ctx("cargo test --danger", AskForApproval::Never))
898 .unwrap();
899 assert!(!denied.allow);
900 assert!(!denied.requires_approval);
901 assert_eq!(denied.matched_rule.as_deref(), Some("cargo test --danger"));
902 assert_eq!(
903 denied.reason(),
904 "Command blocked by denied prefix rule 'cargo test --danger'"
905 );
906 }
907
908 #[test]
909 fn typed_ask_rule_prefers_higher_layer_before_specificity() {
910 let engine = ExecPolicyEngine::with_rulesets(vec![
911 Ruleset::agent(vec![], vec![])
912 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test --workspace")]),
913 Ruleset::user(vec![], vec![])
914 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
915 ]);
916
917 let decision = engine
918 .check(ctx(
919 "cargo test --workspace --all-features",
920 AskForApproval::UnlessTrusted,
921 ))
922 .unwrap();
923
924 assert!(decision.requires_approval);
925 assert_eq!(
926 decision.matched_rule.as_deref(),
927 Some("tool=exec_shell command=cargo test")
928 );
929 }
930
931 #[test]
932 fn reject_rules_mode_still_forbids_matching_ask_rule() {
933 let engine = ExecPolicyEngine::with_rulesets(vec![
934 Ruleset::user(vec![], vec![])
935 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
936 ]);
937
938 let decision = engine
939 .check(ctx(
940 "cargo test --workspace",
941 AskForApproval::Reject {
942 sandbox_approval: false,
943 rules: true,
944 mcp_elicitations: false,
945 },
946 ))
947 .unwrap();
948
949 assert!(!decision.allow);
950 assert!(!decision.requires_approval);
951 assert_eq!(decision.matched_rule, None);
952 assert_eq!(
953 decision.reason(),
954 "Policy is configured to reject rule-exceptions."
955 );
956 }
957
958 #[test]
959 fn typed_ask_rule_label_wins_when_never_blocks_trusted_command() {
960 let engine = ExecPolicyEngine::with_rulesets(vec![
961 Ruleset::user(vec!["cargo test".to_string()], vec![])
962 .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
963 ]);
964
965 let decision = engine
966 .check(ctx("cargo test --workspace", AskForApproval::Never))
967 .unwrap();
968
969 assert!(!decision.allow);
970 assert_eq!(
971 decision.matched_rule.as_deref(),
972 Some("tool=exec_shell command=cargo test")
973 );
974 assert_eq!(
975 decision.reason(),
976 "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
977 );
978 }
979
980 #[test]
981 fn typed_ask_path_matching_trims_spaces_before_workspace_normalization() {
982 let engine =
983 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
984 vec![ToolAskRule::file_path(
985 "edit_file",
986 " /workspace/tmp/project/ ",
987 )],
988 )]);
989
990 let decision = engine
991 .check(ExecPolicyContext {
992 command: "",
993 cwd: "/workspace",
994 tool: Some("edit_file"),
995 path: Some("tmp/project"),
996 ask_for_approval: AskForApproval::Never,
997 sandbox_mode: Some("workspace-write"),
998 })
999 .unwrap();
1000
1001 assert!(!decision.allow);
1002 assert_eq!(
1003 decision.matched_rule.as_deref(),
1004 Some("tool=edit_file path= /workspace/tmp/project/ ")
1005 );
1006 }
1007
1008 #[test]
1009 fn typed_ask_path_matching_normalizes_relative_and_absolute_workspace_paths() {
1010 let relative_rule = ExecPolicyEngine::with_rulesets(vec![
1011 Ruleset::user(vec![], vec![])
1012 .with_ask_rules(vec![ToolAskRule::file_path("edit_file", "src/a.rs")]),
1013 ]);
1014 let absolute_path = relative_rule
1015 .check(ExecPolicyContext {
1016 command: "",
1017 cwd: "/workspace",
1018 tool: Some("edit_file"),
1019 path: Some("/workspace/src/a.rs"),
1020 ask_for_approval: AskForApproval::OnFailure,
1021 sandbox_mode: Some("workspace-write"),
1022 })
1023 .unwrap();
1024 assert!(absolute_path.requires_approval);
1025
1026 let absolute_rule =
1027 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1028 vec![ToolAskRule::file_path("edit_file", "/workspace/src/a.rs")],
1029 )]);
1030 let relative_path = absolute_rule
1031 .check(ExecPolicyContext {
1032 command: "",
1033 cwd: "/workspace",
1034 tool: Some("edit_file"),
1035 path: Some("src/a.rs"),
1036 ask_for_approval: AskForApproval::OnFailure,
1037 sandbox_mode: Some("workspace-write"),
1038 })
1039 .unwrap();
1040 assert!(relative_path.requires_approval);
1041 }
1042
1043 #[test]
1044 fn typed_ask_path_matching_rejects_traversal_and_external_paths() {
1045 for (rule_path, path) in [
1046 ("src/a.rs", "../src/a.rs"),
1047 ("src/a.rs", "/workspace/src/../src/a.rs"),
1048 ("src/a.rs", "/src/a.rs"),
1049 ("../src/a.rs", "src/a.rs"),
1050 ("/src/a.rs", "src/a.rs"),
1051 ] {
1052 let engine = ExecPolicyEngine::with_rulesets(vec![
1053 Ruleset::user(vec![], vec![])
1054 .with_ask_rules(vec![ToolAskRule::file_path("edit_file", rule_path)]),
1055 ]);
1056 let decision = engine
1057 .check(ExecPolicyContext {
1058 command: "",
1059 cwd: "/workspace",
1060 tool: Some("edit_file"),
1061 path: Some(path),
1062 ask_for_approval: AskForApproval::OnFailure,
1063 sandbox_mode: Some("workspace-write"),
1064 })
1065 .unwrap();
1066 assert_eq!(
1067 decision.matched_rule, None,
1068 "rule {rule_path:?} and path {path:?} must not match"
1069 );
1070 }
1071 }
1072
1073 #[test]
1074 fn typed_ask_path_matching_accepts_windows_separators() {
1075 let engine = ExecPolicyEngine::with_rulesets(vec![
1076 Ruleset::user(vec![], vec![])
1077 .with_ask_rules(vec![ToolAskRule::file_path("edit_file", r"src\a.rs")]),
1078 ]);
1079
1080 let decision = engine
1081 .check(ExecPolicyContext {
1082 command: "",
1083 cwd: r"C:\workspace",
1084 tool: Some("edit_file"),
1085 path: Some(r"C:\workspace\src\a.rs"),
1086 ask_for_approval: AskForApproval::OnFailure,
1087 sandbox_mode: Some("workspace-write"),
1088 })
1089 .unwrap();
1090
1091 assert!(decision.requires_approval);
1092 }
1093
1094 #[test]
1097 fn deny_action_blocks_regardless_of_mode() {
1098 let engine =
1099 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1100 vec![ToolAskRule {
1101 tool: "exec_shell".into(),
1102 command: Some("sed".into()),
1103 path: None,
1104 action: PermissionAction::Deny,
1105 }],
1106 )]);
1107
1108 let decision = engine
1110 .check(ExecPolicyContext {
1111 command: "sed -i 's/foo/bar/' file.txt",
1112 cwd: "/tmp",
1113 tool: Some("exec_shell"),
1114 path: None,
1115 ask_for_approval: AskForApproval::UnlessTrusted,
1116 sandbox_mode: None,
1117 })
1118 .unwrap();
1119
1120 assert!(!decision.allow);
1121 assert!(!decision.requires_approval);
1122 assert_eq!(decision.matched_action, Some(PermissionAction::Deny));
1123 assert_eq!(decision.requirement.phase(), "forbidden");
1124 assert!(
1125 decision.reason().contains("explicitly denies"),
1126 "expected deny reason, got: {}",
1127 decision.reason()
1128 );
1129 }
1130
1131 #[test]
1132 fn allow_action_skips_approval_regardless_of_mode() {
1133 let engine =
1134 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1135 vec![ToolAskRule {
1136 tool: "exec_shell".into(),
1137 command: Some("git status".into()),
1138 path: None,
1139 action: PermissionAction::Allow,
1140 }],
1141 )]);
1142
1143 let decision = engine
1145 .check(ExecPolicyContext {
1146 command: "git status",
1147 cwd: "/tmp",
1148 tool: Some("exec_shell"),
1149 path: None,
1150 ask_for_approval: AskForApproval::OnRequest,
1151 sandbox_mode: None,
1152 })
1153 .unwrap();
1154
1155 assert!(decision.allow);
1156 assert!(!decision.requires_approval);
1157 assert_eq!(decision.matched_action, Some(PermissionAction::Allow));
1158 }
1159
1160 #[test]
1161 fn deny_wins_over_allow_when_both_match() {
1162 let engine = ExecPolicyEngine::with_rulesets(vec![
1165 Ruleset::agent(vec!["sed".into()], vec![]).with_ask_rules(vec![]),
1166 Ruleset::user(vec![], vec!["sed".into()]).with_ask_rules(vec![]),
1167 ]);
1168
1169 let decision = engine
1170 .check(ExecPolicyContext {
1171 command: "sed -i 's/a/b/' x.txt",
1172 cwd: "/tmp",
1173 tool: Some("exec_shell"),
1174 path: None,
1175 ask_for_approval: AskForApproval::UnlessTrusted,
1176 sandbox_mode: None,
1177 })
1178 .unwrap();
1179
1180 assert!(!decision.allow);
1181 assert_eq!(decision.requirement.phase(), "forbidden");
1182 }
1183
1184 #[test]
1185 fn ask_action_default_backward_compatible() {
1186 let rule = ToolAskRule::exec_shell("cargo test");
1188 assert_eq!(rule.action, PermissionAction::Ask);
1189 }
1190
1191 #[test]
1192 fn deny_action_constructors_produce_ask_by_default() {
1193 assert_eq!(ToolAskRule::new("exec_shell").action, PermissionAction::Ask);
1194 assert_eq!(
1195 ToolAskRule::exec_shell("cargo test").action,
1196 PermissionAction::Ask
1197 );
1198 assert_eq!(
1199 ToolAskRule::file_path("read_file", "secrets.txt").action,
1200 PermissionAction::Ask
1201 );
1202 }
1203
1204 #[test]
1207 fn deny_single_word_blocks_exact_and_subcommands() {
1208 let engine = engine_with_ask_rule(ToolAskRule {
1209 tool: "exec_shell".into(),
1210 command: Some("sed".into()),
1211 path: None,
1212 action: PermissionAction::Deny,
1213 });
1214
1215 let d = engine.check(ctx("sed", UnlessTrusted)).unwrap();
1217 assert!(!d.allow, "deny must block exact 'sed'");
1218
1219 let d = engine
1221 .check(ctx("sed -i 's/a/b/' file.txt", UnlessTrusted))
1222 .unwrap();
1223 assert!(!d.allow, "deny must block 'sed -i …'");
1224 }
1225
1226 #[test]
1227 fn deny_single_word_does_not_block_unrelated() {
1228 let engine = engine_with_ask_rule(ToolAskRule {
1229 tool: "exec_shell".into(),
1230 command: Some("sed".into()),
1231 path: None,
1232 action: PermissionAction::Deny,
1233 });
1234
1235 let d = engine
1237 .check(ctx("awk '{print $1}'", UnlessTrusted))
1238 .unwrap();
1239 assert!(d.allow, "deny 'sed' must not block 'awk'");
1240 }
1241
1242 #[test]
1243 fn deny_word_boundary_prevents_false_positives() {
1244 let engine = engine_with_ask_rule(ToolAskRule {
1246 tool: "exec_shell".into(),
1247 command: Some("rm".into()),
1248 path: None,
1249 action: PermissionAction::Deny,
1250 });
1251
1252 assert!(!engine.check(ctx("rm -rf /", UnlessTrusted)).unwrap().allow);
1253 assert!(
1254 engine
1255 .check(ctx("rmdir empty-dir", UnlessTrusted))
1256 .unwrap()
1257 .allow
1258 );
1259 }
1260
1261 #[test]
1264 fn deny_multi_word_blocks_subcommands() {
1265 let engine = engine_with_ask_rule(ToolAskRule {
1266 tool: "exec_shell".into(),
1267 command: Some("git push".into()),
1268 path: None,
1269 action: PermissionAction::Deny,
1270 });
1271
1272 assert!(!engine.check(ctx("git push", UnlessTrusted)).unwrap().allow);
1273 assert!(
1274 !engine
1275 .check(ctx("git push origin main", UnlessTrusted))
1276 .unwrap()
1277 .allow
1278 );
1279 assert!(
1280 !engine
1281 .check(ctx("git push --force", UnlessTrusted))
1282 .unwrap()
1283 .allow
1284 );
1285 }
1286
1287 #[test]
1288 fn deny_multi_word_distinguishes_from_sibling_subcommands() {
1289 let engine = engine_with_ask_rule(ToolAskRule {
1291 tool: "exec_shell".into(),
1292 command: Some("git push".into()),
1293 path: None,
1294 action: PermissionAction::Deny,
1295 });
1296
1297 assert!(engine.check(ctx("git pull", UnlessTrusted)).unwrap().allow);
1298 assert!(
1299 engine
1300 .check(ctx("git pull origin main", UnlessTrusted))
1301 .unwrap()
1302 .allow
1303 );
1304 assert!(
1305 engine
1306 .check(ctx("git status", UnlessTrusted))
1307 .unwrap()
1308 .allow
1309 );
1310 }
1311
1312 #[test]
1313 fn deny_multi_word_via_denied_prefixes_path() {
1314 let engine = ExecPolicyEngine::new(vec![], vec!["git push".into()]);
1317
1318 assert!(
1319 !engine
1320 .check(ctx("git push --force", UnlessTrusted))
1321 .unwrap()
1322 .allow
1323 );
1324 assert!(engine.check(ctx("git pull", UnlessTrusted)).unwrap().allow);
1325 }
1326
1327 #[test]
1330 fn deny_wins_over_allow_via_ask_rules() {
1331 let engine =
1332 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1333 vec![
1334 ToolAskRule {
1335 tool: "exec_shell".into(),
1336 command: Some("sed".into()),
1337 path: None,
1338 action: PermissionAction::Allow,
1339 },
1340 ToolAskRule {
1341 tool: "exec_shell".into(),
1342 command: Some("sed".into()),
1343 path: None,
1344 action: PermissionAction::Deny,
1345 },
1346 ],
1347 )]);
1348
1349 let d = engine
1352 .check(ctx("sed -i 's/a/b/' x.txt", UnlessTrusted))
1353 .unwrap();
1354 assert!(!d.allow, "deny must win over allow");
1355 }
1356
1357 #[test]
1358 fn deny_wins_over_allow_via_ask_rules_regardless_of_order() {
1359 let engine =
1360 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1361 vec![
1362 ToolAskRule {
1363 tool: "exec_shell".into(),
1364 command: Some("sed".into()),
1365 path: None,
1366 action: PermissionAction::Deny,
1367 },
1368 ToolAskRule {
1369 tool: "exec_shell".into(),
1370 command: Some("sed".into()),
1371 path: None,
1372 action: PermissionAction::Allow,
1373 },
1374 ],
1375 )]);
1376
1377 let d = engine
1378 .check(ctx("sed -i 's/a/b/' x.txt", UnlessTrusted))
1379 .unwrap();
1380 assert!(!d.allow, "deny must win even if allow appears later");
1381 assert_eq!(d.matched_action, Some(PermissionAction::Deny));
1382 }
1383
1384 #[test]
1385 fn path_deny_wins_over_path_allow_regardless_of_order() {
1386 let engine =
1387 ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1388 vec![
1389 ToolAskRule {
1390 tool: "write_file".into(),
1391 command: None,
1392 path: Some("src/secrets.rs".into()),
1393 action: PermissionAction::Deny,
1394 },
1395 ToolAskRule {
1396 tool: "write_file".into(),
1397 command: None,
1398 path: Some("src/secrets.rs".into()),
1399 action: PermissionAction::Allow,
1400 },
1401 ],
1402 )]);
1403
1404 let d = engine
1405 .check(ExecPolicyContext {
1406 command: "",
1407 cwd: "/workspace",
1408 tool: Some("write_file"),
1409 path: Some("/workspace/src/secrets.rs"),
1410 ask_for_approval: UnlessTrusted,
1411 sandbox_mode: None,
1412 })
1413 .unwrap();
1414
1415 assert!(!d.allow, "path deny must win even if allow appears later");
1416 assert_eq!(d.matched_action, Some(PermissionAction::Deny));
1417 }
1418
1419 #[test]
1420 fn deny_via_prefixes_wins_over_allow_via_prefixes() {
1421 let engine = ExecPolicyEngine::new(vec!["sed".into()], vec!["sed".into()]);
1423
1424 let d = engine
1425 .check(ctx("sed -i 's/a/b/' x.txt", UnlessTrusted))
1426 .unwrap();
1427 assert!(!d.allow, "denied prefix must win over trusted prefix");
1428 }
1429
1430 #[test]
1431 fn deny_tool_only_without_command_blocks_every_invocation() {
1432 let engine = engine_with_ask_rule(ToolAskRule {
1433 tool: "exec_shell".into(),
1434 command: None,
1435 path: None,
1436 action: PermissionAction::Deny,
1437 });
1438
1439 assert!(
1441 !engine
1442 .check(ctx("git status", UnlessTrusted))
1443 .unwrap()
1444 .allow
1445 );
1446 assert!(
1447 !engine
1448 .check(ctx("cargo build", UnlessTrusted))
1449 .unwrap()
1450 .allow
1451 );
1452 assert!(
1453 !engine
1454 .check(ctx("echo hello", UnlessTrusted))
1455 .unwrap()
1456 .allow
1457 );
1458 }
1459
1460 #[test]
1463 fn allow_single_word_skips_approval() {
1464 let engine = engine_with_ask_rule(ToolAskRule {
1465 tool: "exec_shell".into(),
1466 command: Some("cargo".into()),
1467 path: None,
1468 action: PermissionAction::Allow,
1469 });
1470
1471 let d = engine
1472 .check(ctx("cargo build --release", OnRequest))
1473 .unwrap();
1474 assert!(d.allow);
1475 assert!(!d.requires_approval);
1476 assert_eq!(d.matched_action, Some(PermissionAction::Allow));
1477 }
1478
1479 #[test]
1480 fn allow_multi_word_skips_approval() {
1481 let engine = engine_with_ask_rule(ToolAskRule {
1482 tool: "exec_shell".into(),
1483 command: Some("git status".into()),
1484 path: None,
1485 action: PermissionAction::Allow,
1486 });
1487
1488 let d = engine.check(ctx("git status --short", OnRequest)).unwrap();
1489 assert!(d.allow);
1490 assert!(!d.requires_approval);
1491 }
1492
1493 #[test]
1494 fn allow_does_not_leak_to_unmatched_commands() {
1495 let engine = engine_with_ask_rule(ToolAskRule {
1496 tool: "exec_shell".into(),
1497 command: Some("git status".into()),
1498 path: None,
1499 action: PermissionAction::Allow,
1500 });
1501
1502 let d = engine
1504 .check(ctx("git push origin main", UnlessTrusted))
1505 .unwrap();
1506 assert!(d.requires_approval);
1508 }
1509
1510 #[test]
1511 fn allow_under_never_mode_still_allows() {
1512 let engine = engine_with_ask_rule(ToolAskRule {
1514 tool: "exec_shell".into(),
1515 command: Some("cargo".into()),
1516 path: None,
1517 action: PermissionAction::Allow,
1518 });
1519
1520 let d = engine.check(ctx("cargo check", Never)).unwrap();
1521 assert!(d.allow);
1522 assert!(!d.requires_approval);
1523 }
1524
1525 #[test]
1528 fn ask_action_behaves_like_before_action_field_existed() {
1529 let engine = engine_with_ask_rule(ToolAskRule {
1530 tool: "exec_shell".into(),
1531 command: Some("cargo test".into()),
1532 path: None,
1533 action: PermissionAction::Ask,
1534 });
1535
1536 let d = engine
1538 .check(ctx("cargo test --workspace", UnlessTrusted))
1539 .unwrap();
1540 assert!(d.allow);
1541 assert!(d.requires_approval);
1542
1543 let d = engine.check(ctx("cargo test --workspace", Never)).unwrap();
1545 assert!(!d.allow);
1546 assert_eq!(d.requirement.phase(), "forbidden");
1547 }
1548
1549 #[test]
1550 fn ask_is_default_when_action_omitted() {
1551 let rule = ToolAskRule::exec_shell("cargo test");
1552 assert_eq!(rule.action, PermissionAction::Ask);
1553 }
1554
1555 #[test]
1558 fn deny_blocks_tool_only_even_for_different_tool() {
1559 let engine = engine_with_ask_rule(ToolAskRule {
1561 tool: "exec_shell".into(),
1562 command: Some("sed".into()),
1563 path: None,
1564 action: PermissionAction::Deny,
1565 });
1566
1567 let d = engine
1568 .check(ExecPolicyContext {
1569 command: "",
1570 cwd: "/workspace",
1571 tool: Some("write_file"),
1572 path: Some("/workspace/src/main.rs"),
1573 ask_for_approval: UnlessTrusted,
1574 sandbox_mode: None,
1575 })
1576 .unwrap();
1577 assert!(d.allow);
1579 }
1580
1581 #[test]
1582 fn normalize_handles_extra_whitespace_in_command() {
1583 let engine = ExecPolicyEngine::new(vec![], vec!["git push".into()]);
1585
1586 let d = engine
1587 .check(ctx("git push --force", UnlessTrusted))
1588 .unwrap();
1589 assert!(!d.allow, "extra whitespace must not bypass deny");
1590 }
1591
1592 #[test]
1593 fn normalize_handles_case_insensitivity() {
1594 let engine = ExecPolicyEngine::new(vec![], vec!["sed".into()]);
1596
1597 let d = engine
1598 .check(ctx("SED -i 's/a/b/' file.txt", UnlessTrusted))
1599 .unwrap();
1600 assert!(!d.allow, "case must not bypass deny");
1601 }
1602
1603 #[test]
1604 fn allow_falls_back_to_mode_when_no_rule_matches() {
1605 let engine = ExecPolicyEngine::new(vec![], vec![]); let d = engine.check(ctx("cargo build", UnlessTrusted)).unwrap();
1608 assert!(d.allow);
1609 assert!(d.requires_approval, "untrusted cmd needs approval");
1610 }
1611
1612 fn engine_with_ask_rule(rule: ToolAskRule) -> ExecPolicyEngine {
1615 ExecPolicyEngine::with_rulesets(vec![
1616 Ruleset::user(vec![], vec![]).with_ask_rules(vec![rule]),
1617 ])
1618 }
1619}