| 1 | use codewhale_execpolicy::{ |
| 2 | AskForApproval, ExecPolicyContext, ExecPolicyEngine, PermissionAction, Ruleset, ToolAskRule, |
| 3 | }; |
| 4 | |
| 5 | struct AuthorizationCase { |
| 6 | name: &'static str, |
| 7 | engine: ExecPolicyEngine, |
| 8 | command: &'static str, |
| 9 | approval: AskForApproval, |
| 10 | expected_allow: bool, |
| 11 | expected_approval: bool, |
| 12 | expected_phase: &'static str, |
| 13 | expected_action: Option<PermissionAction>, |
| 14 | expected_rule: Option<&'static str>, |
| 15 | } |
| 16 | |
| 17 | fn command_rule(command: &str, action: PermissionAction) -> ToolAskRule { |
| 18 | ToolAskRule { |
| 19 | action, |
| 20 | ..ToolAskRule::exec_shell(command) |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | fn tool_rule(action: PermissionAction) -> ToolAskRule { |
| 25 | ToolAskRule { |
| 26 | action, |
| 27 | ..ToolAskRule::new("exec_shell") |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | #[test] |
| 32 | fn authorization_order_contract_matches_documented_precedence() { |
| 33 | let cases = vec![ |
| 34 | AuthorizationCase { |
| 35 | name: "hard denied prefix beats typed allow", |
| 36 | engine: ExecPolicyEngine::with_rulesets(vec![ |
| 37 | Ruleset::user(vec![], vec!["cargo publish".to_string()]) |
| 38 | .with_ask_rules(vec![command_rule("cargo publish", PermissionAction::Allow)]), |
| 39 | ]), |
| 40 | command: "cargo publish --dry-run", |
| 41 | approval: AskForApproval::OnRequest, |
| 42 | expected_allow: false, |
| 43 | expected_approval: false, |
| 44 | expected_phase: "forbidden", |
| 45 | expected_action: None, |
| 46 | expected_rule: Some("cargo publish"), |
| 47 | }, |
| 48 | AuthorizationCase { |
| 49 | name: "higher typed layer beats lower action and specificity", |
| 50 | engine: ExecPolicyEngine::with_rulesets(vec![ |
| 51 | Ruleset::agent(vec![], vec![]).with_ask_rules(vec![command_rule( |
| 52 | "cargo test --workspace", |
| 53 | PermissionAction::Deny, |
| 54 | )]), |
| 55 | Ruleset::user(vec![], vec![]) |
| 56 | .with_ask_rules(vec![command_rule("cargo test", PermissionAction::Allow)]), |
| 57 | ]), |
| 58 | command: "cargo test --workspace", |
| 59 | approval: AskForApproval::OnRequest, |
| 60 | expected_allow: true, |
| 61 | expected_approval: false, |
| 62 | expected_phase: "allowed", |
| 63 | expected_action: Some(PermissionAction::Allow), |
| 64 | expected_rule: Some("tool=exec_shell command=cargo test"), |
| 65 | }, |
| 66 | AuthorizationCase { |
| 67 | name: "typed action beats specificity inside one layer", |
| 68 | engine: ExecPolicyEngine::with_rulesets(vec![ |
| 69 | Ruleset::user(vec![], vec![]).with_ask_rules(vec![ |
| 70 | tool_rule(PermissionAction::Deny), |
| 71 | command_rule("cargo test --workspace", PermissionAction::Allow), |
| 72 | ]), |
| 73 | ]), |
| 74 | command: "cargo test --workspace", |
| 75 | approval: AskForApproval::OnRequest, |
| 76 | expected_allow: false, |
| 77 | expected_approval: false, |
| 78 | expected_phase: "forbidden", |
| 79 | expected_action: Some(PermissionAction::Deny), |
| 80 | expected_rule: Some("tool=exec_shell"), |
| 81 | }, |
| 82 | AuthorizationCase { |
| 83 | name: "specificity breaks a same-layer same-action tie", |
| 84 | engine: ExecPolicyEngine::with_rulesets(vec![ |
| 85 | Ruleset::user(vec![], vec![]).with_ask_rules(vec![ |
| 86 | tool_rule(PermissionAction::Allow), |
| 87 | command_rule("cargo test", PermissionAction::Allow), |
| 88 | ]), |
| 89 | ]), |
| 90 | command: "cargo test --workspace", |
| 91 | approval: AskForApproval::OnRequest, |
| 92 | expected_allow: true, |
| 93 | expected_approval: false, |
| 94 | expected_phase: "allowed", |
| 95 | expected_action: Some(PermissionAction::Allow), |
| 96 | expected_rule: Some("tool=exec_shell command=cargo test"), |
| 97 | }, |
| 98 | AuthorizationCase { |
| 99 | name: "typed ask beats a trusted prefix", |
| 100 | engine: ExecPolicyEngine::with_rulesets(vec![ |
| 101 | Ruleset::user(vec!["cargo test".to_string()], vec![]) |
| 102 | .with_ask_rules(vec![command_rule("cargo test", PermissionAction::Ask)]), |
| 103 | ]), |
| 104 | command: "cargo test --workspace", |
| 105 | approval: AskForApproval::UnlessTrusted, |
| 106 | expected_allow: true, |
| 107 | expected_approval: true, |
| 108 | expected_phase: "needs_approval", |
| 109 | expected_action: Some(PermissionAction::Ask), |
| 110 | expected_rule: Some("tool=exec_shell command=cargo test"), |
| 111 | }, |
| 112 | AuthorizationCase { |
| 113 | name: "typed ask fails closed when prompts are forbidden", |
| 114 | engine: ExecPolicyEngine::with_rulesets(vec![ |
| 115 | Ruleset::user(vec![], vec![]) |
| 116 | .with_ask_rules(vec![command_rule("cargo test", PermissionAction::Ask)]), |
| 117 | ]), |
| 118 | command: "cargo test --workspace", |
| 119 | approval: AskForApproval::Never, |
| 120 | expected_allow: false, |
| 121 | expected_approval: false, |
| 122 | expected_phase: "forbidden", |
| 123 | expected_action: Some(PermissionAction::Ask), |
| 124 | expected_rule: Some("tool=exec_shell command=cargo test"), |
| 125 | }, |
| 126 | AuthorizationCase { |
| 127 | name: "trusted prefix feeds the approval fallback", |
| 128 | engine: ExecPolicyEngine::with_rulesets(vec![Ruleset::user( |
| 129 | vec!["cargo test".to_string()], |
| 130 | vec![], |
| 131 | )]), |
| 132 | command: "cargo test --workspace", |
| 133 | approval: AskForApproval::UnlessTrusted, |
| 134 | expected_allow: true, |
| 135 | expected_approval: false, |
| 136 | expected_phase: "allowed", |
| 137 | expected_action: None, |
| 138 | expected_rule: Some("cargo test"), |
| 139 | }, |
| 140 | ]; |
| 141 | |
| 142 | for case in cases { |
| 143 | let decision = case |
| 144 | .engine |
| 145 | .check(ExecPolicyContext { |
| 146 | command: case.command, |
| 147 | cwd: "/workspace", |
| 148 | tool: Some("exec_shell"), |
| 149 | path: None, |
| 150 | ask_for_approval: case.approval, |
| 151 | sandbox_mode: Some("workspace-write"), |
| 152 | }) |
| 153 | .unwrap_or_else(|error| panic!("{}: policy check failed: {error}", case.name)); |
| 154 | |
| 155 | assert_eq!(decision.allow, case.expected_allow, "{}: allow", case.name); |
| 156 | assert_eq!( |
| 157 | decision.requires_approval, case.expected_approval, |
| 158 | "{}: requires_approval", |
| 159 | case.name |
| 160 | ); |
| 161 | assert_eq!( |
| 162 | decision.requirement.phase(), |
| 163 | case.expected_phase, |
| 164 | "{}: phase", |
| 165 | case.name |
| 166 | ); |
| 167 | assert_eq!( |
| 168 | decision.matched_action, case.expected_action, |
| 169 | "{}: matched action", |
| 170 | case.name |
| 171 | ); |
| 172 | assert_eq!( |
| 173 | decision.matched_rule.as_deref(), |
| 174 | case.expected_rule, |
| 175 | "{}: matched rule", |
| 176 | case.name |
| 177 | ); |
| 178 | } |
| 179 | } |
| 180 |