1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::path::{Path, PathBuf};
4use std::thread_local;
5
6use serde::de::{Error as DeError, MapAccess, Visitor};
7use serde::{Deserialize, Deserializer, Serialize};
8use serde_json::Value as JsonValue;
9use sha2::{Digest, Sha256};
10
11use crate::workspace_path::{WorkspacePathInfo, WorkspacePathKind};
12
13use super::ToolApprovalPolicy;
14
15const POLICY_RECEIPT_TYPE: &str = "harn.permission_policy_decision.v1";
16
17thread_local! {
18 static APPROVAL_CALL_COUNTS: RefCell<BTreeMap<String, u64>> = const { RefCell::new(BTreeMap::new()) };
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
22#[serde(rename_all = "snake_case")]
23pub enum PolicyAction {
24 Allow,
25 Ask,
26 Deny,
27}
28
29impl PolicyAction {
30 pub fn as_str(self) -> &'static str {
31 match self {
32 Self::Allow => "allow",
33 Self::Ask => "ask",
34 Self::Deny => "deny",
35 }
36 }
37
38 fn rank(self) -> u8 {
39 match self {
40 Self::Allow => 0,
41 Self::Ask => 1,
42 Self::Deny => 2,
43 }
44 }
45}
46
47impl<'de> Deserialize<'de> for PolicyAction {
48 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
49 where
50 D: Deserializer<'de>,
51 {
52 let value = String::deserialize(deserializer)?;
53 parse_policy_action(&value).ok_or_else(|| {
54 D::Error::custom(format!(
55 "unsupported policy action {value:?}; expected allow, ask, require_approval, or deny"
56 ))
57 })
58 }
59}
60
61fn parse_policy_action(value: &str) -> Option<PolicyAction> {
62 match value {
63 "allow" | "approve" | "auto_approve" => Some(PolicyAction::Allow),
64 "ask" | "approval" | "require_approval" | "requires_approval" => Some(PolicyAction::Ask),
65 "deny" | "block" | "auto_deny" => Some(PolicyAction::Deny),
66 _ => None,
67 }
68}
69
70#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
71#[serde(default)]
72pub struct ApprovalShape {
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub prompt: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub risk: Option<String>,
77 #[serde(skip_serializing_if = "Vec::is_empty")]
78 pub reviewers: Vec<String>,
79 #[serde(skip_serializing_if = "Vec::is_empty")]
80 pub grant_options: Vec<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub metadata: Option<JsonValue>,
83}
84
85#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
86#[serde(default)]
87pub struct PolicyRuleMatch {
88 #[serde(
89 alias = "tools",
90 deserialize_with = "deserialize_string_list",
91 skip_serializing_if = "Vec::is_empty"
92 )]
93 pub tool: Vec<String>,
94 #[serde(
95 alias = "tool_kinds",
96 deserialize_with = "deserialize_string_list",
97 skip_serializing_if = "Vec::is_empty"
98 )]
99 pub tool_kind: Vec<String>,
100 #[serde(
101 alias = "side_effect_level",
102 alias = "side_effect_levels",
103 deserialize_with = "deserialize_string_list",
104 skip_serializing_if = "Vec::is_empty"
105 )]
106 pub side_effect: Vec<String>,
107 #[serde(
108 alias = "paths",
109 deserialize_with = "deserialize_string_list",
110 skip_serializing_if = "Vec::is_empty"
111 )]
112 pub path: Vec<String>,
113 #[serde(
114 alias = "commands",
115 deserialize_with = "deserialize_string_list",
116 skip_serializing_if = "Vec::is_empty"
117 )]
118 pub command: Vec<String>,
119 #[serde(
120 alias = "command_identities",
121 deserialize_with = "deserialize_string_list",
122 skip_serializing_if = "Vec::is_empty"
123 )]
124 pub command_identity: Vec<String>,
125 #[serde(
126 alias = "urls",
127 deserialize_with = "deserialize_string_list",
128 skip_serializing_if = "Vec::is_empty"
129 )]
130 pub url: Vec<String>,
131 #[serde(
132 alias = "domains",
133 deserialize_with = "deserialize_string_list",
134 skip_serializing_if = "Vec::is_empty"
135 )]
136 pub domain: Vec<String>,
137 #[serde(
138 alias = "method",
139 alias = "methods",
140 alias = "http_methods",
141 deserialize_with = "deserialize_string_list",
142 skip_serializing_if = "Vec::is_empty"
143 )]
144 pub http_method: Vec<String>,
145 #[serde(
146 alias = "mcp_servers",
147 deserialize_with = "deserialize_string_list",
148 skip_serializing_if = "Vec::is_empty"
149 )]
150 pub mcp_server: Vec<String>,
151 #[serde(
152 alias = "mcp_tools",
153 deserialize_with = "deserialize_string_list",
154 skip_serializing_if = "Vec::is_empty"
155 )]
156 pub mcp_tool: Vec<String>,
157 #[serde(
158 alias = "agents",
159 deserialize_with = "deserialize_string_list",
160 skip_serializing_if = "Vec::is_empty"
161 )]
162 pub agent: Vec<String>,
163 #[serde(
164 alias = "personas",
165 deserialize_with = "deserialize_string_list",
166 skip_serializing_if = "Vec::is_empty"
167 )]
168 pub persona: Vec<String>,
169 #[serde(
170 alias = "modes",
171 deserialize_with = "deserialize_string_list",
172 skip_serializing_if = "Vec::is_empty"
173 )]
174 pub mode: Vec<String>,
175 #[serde(
176 alias = "capabilities",
177 deserialize_with = "deserialize_string_list",
178 skip_serializing_if = "Vec::is_empty"
179 )]
180 pub capability: Vec<String>,
181 #[serde(alias = "repeat_count_gte", alias = "repeat_at_least")]
182 pub repeat_count_at_least: Option<u64>,
183}
184
185impl PolicyRuleMatch {
186 fn from_shorthand(value: JsonValue) -> Result<Self, String> {
187 match value {
188 JsonValue::Null | JsonValue::Bool(true) => Ok(Self::default()),
189 JsonValue::String(pattern) => Ok(Self {
190 tool: vec![pattern],
191 ..Default::default()
192 }),
193 JsonValue::Array(items) => {
194 let mut tool = Vec::new();
195 for item in items {
196 let Some(pattern) = item.as_str() else {
197 return Err(format!(
198 "policy rule shorthand list entries must be strings, got {item}"
199 ));
200 };
201 tool.push(pattern.to_string());
202 }
203 Ok(Self {
204 tool,
205 ..Default::default()
206 })
207 }
208 JsonValue::Object(_) => {
209 serde_json::from_value(value).map_err(|error| error.to_string())
210 }
211 other => Err(format!(
212 "policy rule matcher must be a string, list, or dict, got {other}"
213 )),
214 }
215 }
216
217 fn is_empty(&self) -> bool {
218 self.tool.is_empty()
219 && self.tool_kind.is_empty()
220 && self.side_effect.is_empty()
221 && self.path.is_empty()
222 && self.command.is_empty()
223 && self.command_identity.is_empty()
224 && self.url.is_empty()
225 && self.domain.is_empty()
226 && self.http_method.is_empty()
227 && self.mcp_server.is_empty()
228 && self.mcp_tool.is_empty()
229 && self.agent.is_empty()
230 && self.persona.is_empty()
231 && self.mode.is_empty()
232 && self.capability.is_empty()
233 && self.repeat_count_at_least.is_none()
234 }
235
236 fn matches(&self, ctx: &EvaluationContext) -> bool {
237 (self.tool.is_empty() || any_glob_matches(&self.tool, &[ctx.tool_name.clone()]))
238 && (self.tool_kind.is_empty() || any_glob_matches(&self.tool_kind, &ctx.tool_kinds()))
239 && (self.side_effect.is_empty()
240 || any_glob_matches(&self.side_effect, &ctx.side_effects()))
241 && (self.path.is_empty() || any_glob_matches(&self.path, &ctx.path_candidates))
242 && (self.command.is_empty()
243 || any_fragment_matches(&self.command, &ctx.command_candidates))
244 && (self.command_identity.is_empty()
245 || any_glob_matches(&self.command_identity, &ctx.command_identities))
246 && (self.url.is_empty() || any_fragment_matches(&self.url, &ctx.urls))
247 && (self.domain.is_empty() || any_glob_matches(&self.domain, &ctx.domains))
248 && (self.http_method.is_empty()
249 || any_glob_matches(
250 &normalize_patterns_upper(&self.http_method),
251 &ctx.http_methods,
252 ))
253 && (self.mcp_server.is_empty() || any_glob_matches(&self.mcp_server, &ctx.mcp_servers))
254 && (self.mcp_tool.is_empty() || any_glob_matches(&self.mcp_tool, &ctx.mcp_tools))
255 && (self.agent.is_empty()
256 || ctx.agent.as_ref().is_some_and(|agent| {
257 any_glob_matches(&self.agent, std::slice::from_ref(agent))
258 }))
259 && (self.persona.is_empty()
260 || ctx.persona.as_ref().is_some_and(|persona| {
261 any_glob_matches(&self.persona, std::slice::from_ref(persona))
262 }))
263 && (self.mode.is_empty()
264 || ctx
265 .mode
266 .as_ref()
267 .is_some_and(|mode| any_glob_matches(&self.mode, std::slice::from_ref(mode))))
268 && (self.capability.is_empty() || any_glob_matches(&self.capability, &ctx.capabilities))
269 && self
270 .repeat_count_at_least
271 .map(|threshold| ctx.repeat_count.unwrap_or(0) >= threshold)
272 .unwrap_or(true)
273 }
274}
275
276#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
277pub struct PolicyRule {
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub id: Option<String>,
280 pub action: PolicyAction,
281 #[serde(rename = "match")]
282 pub matches: PolicyRuleMatch,
283 #[serde(skip_serializing_if = "Option::is_none")]
284 pub reason: Option<String>,
285 #[serde(default, skip_serializing_if = "ApprovalShape::is_empty")]
286 pub approval: ApprovalShape,
287}
288
289impl ApprovalShape {
290 fn is_empty(&self) -> bool {
291 self.prompt.is_none()
292 && self.risk.is_none()
293 && self.reviewers.is_empty()
294 && self.grant_options.is_empty()
295 && self.metadata.is_none()
296 }
297}
298
299impl<'de> Deserialize<'de> for PolicyRule {
300 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
301 where
302 D: Deserializer<'de>,
303 {
304 deserializer.deserialize_map(PolicyRuleVisitor)
305 }
306}
307
308struct PolicyRuleVisitor;
309
310impl<'de> Visitor<'de> for PolicyRuleVisitor {
311 type Value = PolicyRule;
312
313 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 formatter.write_str("a policy rule object")
315 }
316
317 fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
318 where
319 M: MapAccess<'de>,
320 {
321 let mut raw = serde_json::Map::new();
322 while let Some((key, value)) = map.next_entry::<String, JsonValue>()? {
323 raw.insert(key, value);
324 }
325
326 let id = raw
327 .remove("id")
328 .or_else(|| raw.remove("name"))
329 .and_then(|value| value.as_str().map(ToOwned::to_owned));
330 let reason = raw
331 .remove("reason")
332 .and_then(|value| value.as_str().map(ToOwned::to_owned));
333 let approval = raw
334 .remove("approval")
335 .map(serde_json::from_value)
336 .transpose()
337 .map_err(M::Error::custom)?
338 .unwrap_or_default();
339
340 let mut action = match raw.remove("action") {
341 Some(JsonValue::String(value)) => Some(parse_policy_action(&value).ok_or_else(|| {
342 M::Error::custom(format!(
343 "unsupported policy action {value:?}; expected allow, ask, require_approval, or deny"
344 ))
345 })?),
346 Some(other) => {
347 return Err(M::Error::custom(format!(
348 "policy rule action must be a string, got {other}"
349 )));
350 }
351 None => None,
352 };
353 let mut matcher_value = raw
354 .remove("match")
355 .or_else(|| raw.remove("matches"))
356 .or_else(|| raw.remove("when"));
357
358 for (key, candidate_action) in [
359 ("deny", PolicyAction::Deny),
360 ("ask", PolicyAction::Ask),
361 ("require_approval", PolicyAction::Ask),
362 ("allow", PolicyAction::Allow),
363 ] {
364 if let Some(value) = raw.remove(key) {
365 if action.is_some() {
366 return Err(M::Error::custom(
367 "policy rule must not mix action with allow/ask/deny shorthand",
368 ));
369 }
370 action = Some(candidate_action);
371 matcher_value = Some(value);
372 }
373 }
374
375 if matcher_value.is_none() && !raw.is_empty() {
376 matcher_value = Some(JsonValue::Object(raw));
377 } else if matcher_value.is_some() && !raw.is_empty() {
378 let mut fields = raw.keys().cloned().collect::<Vec<_>>();
379 fields.sort();
380 return Err(M::Error::custom(format!(
381 "policy rule has matcher fields outside match/allow/ask/deny: {}",
382 fields.join(", ")
383 )));
384 }
385
386 let action = action.ok_or_else(|| {
387 M::Error::custom("policy rule must include action or allow/ask/deny shorthand")
388 })?;
389 let matches = PolicyRuleMatch::from_shorthand(matcher_value.unwrap_or(JsonValue::Null))
390 .map_err(M::Error::custom)?;
391 Ok(PolicyRule {
392 id,
393 action,
394 matches,
395 reason,
396 approval,
397 })
398 }
399}
400
401#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
402pub struct PolicyMatchedRule {
403 pub source: String,
404 pub action: String,
405 #[serde(skip_serializing_if = "Option::is_none")]
406 pub id: Option<String>,
407 #[serde(skip_serializing_if = "Option::is_none")]
408 pub index: Option<usize>,
409}
410
411#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
412pub struct PolicyEvaluation {
413 pub action: String,
414 pub reason: String,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub matched_rule: Option<PolicyMatchedRule>,
417 #[serde(skip_serializing_if = "Option::is_none")]
418 pub required_approval: Option<ApprovalShape>,
419 #[serde(default)]
420 pub risk_labels: Vec<String>,
421 pub receipt: JsonValue,
422}
423
424impl PolicyEvaluation {
425 pub fn is_allow(&self) -> bool {
426 self.action == PolicyAction::Allow.as_str()
427 }
428
429 pub fn is_ask(&self) -> bool {
430 self.action == PolicyAction::Ask.as_str()
431 }
432
433 pub fn is_deny(&self) -> bool {
434 self.action == PolicyAction::Deny.as_str()
435 }
436
437 pub fn has_audit_signal(&self) -> bool {
438 self.matched_rule.is_some() || !self.risk_labels.is_empty()
439 }
440}
441
442#[derive(Clone, Debug)]
443struct EvaluationContext {
444 tool_name: String,
445 tool_kind: Option<String>,
446 side_effect: Option<String>,
447 capabilities: Vec<String>,
448 path_entries: Vec<WorkspacePathInfo>,
449 path_candidates: Vec<String>,
450 string_candidates: Vec<String>,
451 command_candidates: Vec<String>,
452 command_identities: Vec<String>,
453 urls: Vec<String>,
454 domains: Vec<String>,
455 http_methods: Vec<String>,
456 mcp_servers: Vec<String>,
457 mcp_tools: Vec<String>,
458 agent: Option<String>,
459 persona: Option<String>,
460 mode: Option<String>,
461 repeat_count: Option<u64>,
462}
463
464impl EvaluationContext {
465 fn new(tool_name: &str, args: &JsonValue, repeat_count: Option<u64>) -> Self {
466 let annotations = super::current_tool_annotations(tool_name);
467 let path_entries = super::current_tool_declared_path_entries(tool_name, args);
468 let mut path_candidates = Vec::new();
469 for entry in &path_entries {
470 path_candidates.extend(entry.policy_candidates());
471 }
472 dedup(&mut path_candidates);
473
474 let mut string_candidates = Vec::new();
475 collect_string_values(args, &mut string_candidates);
476 dedup(&mut string_candidates);
477
478 let (command_candidates, command_identities) = command_candidates(args);
479 let (urls, domains) = url_candidates(&string_candidates);
480 let http_methods = http_method_candidates(args);
481 let (mcp_servers, mcp_tools) = mcp_candidates(tool_name, args);
482 let dispatch = crate::triggers::dispatcher::current_dispatch_context();
483 let agent = string_field(args, "agent")
484 .or_else(|| string_field(args, "agent_id"))
485 .or_else(|| dispatch.as_ref().map(|context| context.agent_id.clone()));
486 let persona = string_field(args, "persona").or_else(|| string_field(args, "persona_id"));
487 let mode = string_field(args, "mode")
488 .or_else(|| string_field(args, "action"))
489 .or_else(|| dispatch.as_ref().map(|context| context.action.clone()));
490 let capabilities = annotations
491 .as_ref()
492 .map(|annotations| {
493 annotations
494 .capabilities
495 .iter()
496 .flat_map(|(capability, ops)| {
497 ops.iter()
498 .map(|op| format!("{capability}.{op}"))
499 .collect::<Vec<_>>()
500 })
501 .collect::<Vec<_>>()
502 })
503 .unwrap_or_default();
504
505 Self {
506 tool_name: tool_name.to_string(),
507 tool_kind: annotations
508 .as_ref()
509 .map(|annotations| tool_kind_string(annotations.kind).to_string()),
510 side_effect: annotations
511 .as_ref()
512 .map(|annotations| annotations.side_effect_level.as_str().to_string()),
513 capabilities,
514 path_entries,
515 path_candidates,
516 string_candidates,
517 command_candidates,
518 command_identities,
519 urls,
520 domains,
521 http_methods,
522 mcp_servers,
523 mcp_tools,
524 agent,
525 persona,
526 mode,
527 repeat_count,
528 }
529 }
530
531 fn tool_kinds(&self) -> Vec<String> {
532 self.tool_kind.iter().cloned().collect()
533 }
534
535 fn side_effects(&self) -> Vec<String> {
536 self.side_effect.iter().cloned().collect()
537 }
538
539 fn receipt_context(&self) -> JsonValue {
540 serde_json::json!({
541 "tool_name": self.tool_name,
542 "tool_kind": self.tool_kind,
543 "side_effect": self.side_effect,
544 "capabilities": self.capabilities,
545 "paths": self.path_entries.iter().map(path_entry_json).collect::<Vec<_>>(),
546 "command_identities": self.command_identities,
547 "urls": self.urls,
548 "domains": self.domains,
549 "http_methods": self.http_methods,
550 "mcp_servers": self.mcp_servers,
551 "mcp_tools": self.mcp_tools,
552 "agent": self.agent,
553 "persona": self.persona,
554 "mode": self.mode,
555 "repeat_count": self.repeat_count,
556 })
557 }
558}
559
560struct Candidate {
561 source: String,
562 index: Option<usize>,
563 id: Option<String>,
564 action: PolicyAction,
565 reason: String,
566 approval: ApprovalShape,
567 risk_labels: Vec<String>,
568}
569
570impl Candidate {
571 fn matched_rule(&self) -> PolicyMatchedRule {
572 PolicyMatchedRule {
573 source: self.source.clone(),
574 action: self.action.as_str().to_string(),
575 id: self.id.clone(),
576 index: self.index,
577 }
578 }
579}
580
581pub fn next_approval_policy_repeat_count(
582 session_id: &str,
583 tool_name: &str,
584 args: &JsonValue,
585) -> u64 {
586 let key = format!("{session_id}:{tool_name}:{}", stable_json_digest(args));
587 APPROVAL_CALL_COUNTS.with(|counts| {
588 let mut counts = counts.borrow_mut();
589 let count = counts.entry(key).or_insert(0);
590 *count += 1;
591 *count
592 })
593}
594
595pub fn clear_approval_policy_repeat_counts(session_id: &str) {
596 let prefix = format!("{session_id}:");
597 APPROVAL_CALL_COUNTS.with(|counts| {
598 counts
599 .borrow_mut()
600 .retain(|key, _| !key.starts_with(prefix.as_str()));
601 });
602}
603
604pub fn clear_all_approval_policy_repeat_counts() {
605 APPROVAL_CALL_COUNTS.with(|counts| counts.borrow_mut().clear());
606}
607
608pub fn evaluate_tool_approval_policy(
609 policy: &ToolApprovalPolicy,
610 tool_name: &str,
611 args: &JsonValue,
612 repeat_count: Option<u64>,
613) -> PolicyEvaluation {
614 let ctx = EvaluationContext::new(tool_name, args, repeat_count);
615 if let Some(default) = default_guard(policy, &ctx) {
616 return evaluation_from_candidate(default, &ctx);
617 }
618
619 let mut candidates = Vec::new();
620 candidates.extend(legacy_candidates(policy, &ctx));
621 candidates.extend(rule_candidates(policy, &ctx));
622 if let Some(repeat_limit) = policy.repeat_limit {
623 if ctx.repeat_count.is_some_and(|count| count > repeat_limit) {
624 let action = policy.repeat_action.unwrap_or(PolicyAction::Ask);
625 candidates.push(Candidate {
626 source: "repeat_limit".to_string(),
627 index: None,
628 id: Some("repeat_limit".to_string()),
629 action,
630 reason: format!(
631 "tool '{}' repeated more than {repeat_limit} time(s) with the same arguments",
632 ctx.tool_name
633 ),
634 approval: ApprovalShape::default(),
635 risk_labels: vec!["repeated_call".to_string()],
636 });
637 }
638 }
639
640 if let Some(candidate) = strongest_candidate(candidates) {
641 return evaluation_from_candidate(candidate, &ctx);
642 }
643
644 default_allow(&ctx)
645}
646
647fn default_guard(policy: &ToolApprovalPolicy, ctx: &EvaluationContext) -> Option<Candidate> {
648 if !policy.allow_sensitive_paths {
649 if let Some(path) = first_sensitive_candidate(policy, ctx) {
650 return Some(Candidate {
651 source: "default_sensitive_path".to_string(),
652 index: None,
653 id: Some("sensitive_path".to_string()),
654 action: PolicyAction::Deny,
655 reason: format!("path '{path}' is denied by the sensitive-path default"),
656 approval: ApprovalShape::default(),
657 risk_labels: vec!["sensitive_path".to_string()],
658 });
659 }
660 }
661
662 if !policy.allow_external_paths {
663 for entry in &ctx.path_entries {
664 if matches!(entry.kind, WorkspacePathKind::Invalid) {
665 return Some(Candidate {
666 source: "default_path_guard".to_string(),
667 index: None,
668 id: Some("invalid_path".to_string()),
669 action: PolicyAction::Deny,
670 reason: entry
671 .reason
672 .clone()
673 .unwrap_or_else(|| format!("path '{}' is invalid", entry.display_path())),
674 approval: ApprovalShape::default(),
675 risk_labels: vec!["invalid_path".to_string()],
676 });
677 }
678 if entry.workspace_path.is_none()
679 && entry
680 .host_path
681 .as_ref()
682 .is_some_and(|path| !under_external_root(path, &policy.external_roots))
683 {
684 return Some(Candidate {
685 source: "default_external_path".to_string(),
686 index: None,
687 id: Some("external_path".to_string()),
688 action: PolicyAction::Deny,
689 reason: format!(
690 "path '{}' is outside the workspace and no external root allows it",
691 entry.display_path()
692 ),
693 approval: ApprovalShape::default(),
694 risk_labels: vec!["external_path".to_string()],
695 });
696 }
697 }
698 }
699
700 None
701}
702
703fn legacy_candidates(policy: &ToolApprovalPolicy, ctx: &EvaluationContext) -> Vec<Candidate> {
704 let mut candidates = Vec::new();
705 for (index, pattern) in policy.auto_deny.iter().enumerate() {
706 if super::super::glob_match(pattern, &ctx.tool_name) {
707 candidates.push(Candidate {
708 source: "auto_deny".to_string(),
709 index: Some(index),
710 id: Some(pattern.clone()),
711 action: PolicyAction::Deny,
712 reason: format!("tool '{}' matches deny pattern '{pattern}'", ctx.tool_name),
713 approval: ApprovalShape::default(),
714 risk_labels: vec!["matched_deny_rule".to_string()],
715 });
716 }
717 }
718
719 if !policy.write_path_allowlist.is_empty()
720 && super::tool_kind_participates_in_write_allowlist(&ctx.tool_name)
721 {
722 for path in &ctx.path_entries {
723 let allowed = policy.write_path_allowlist.iter().any(|pattern| {
724 path.policy_candidates()
725 .iter()
726 .any(|candidate| super::super::glob_match(pattern, candidate))
727 });
728 if !allowed {
729 candidates.push(Candidate {
730 source: "write_path_allowlist".to_string(),
731 index: None,
732 id: None,
733 action: PolicyAction::Deny,
734 reason: format!(
735 "tool '{}' targets '{}' which is not in the write-path allowlist",
736 ctx.tool_name,
737 path.display_path()
738 ),
739 approval: ApprovalShape::default(),
740 risk_labels: vec!["write_path_not_allowed".to_string()],
741 });
742 }
743 }
744 }
745
746 for (index, pattern) in policy.require_approval.iter().enumerate() {
747 if super::super::glob_match(pattern, &ctx.tool_name) {
748 candidates.push(Candidate {
749 source: "require_approval".to_string(),
750 index: Some(index),
751 id: Some(pattern.clone()),
752 action: PolicyAction::Ask,
753 reason: format!(
754 "tool '{}' matches approval pattern '{pattern}'",
755 ctx.tool_name
756 ),
757 approval: ApprovalShape::default(),
758 risk_labels: vec!["approval_required".to_string()],
759 });
760 }
761 }
762
763 for (index, pattern) in policy.auto_approve.iter().enumerate() {
764 if super::super::glob_match(pattern, &ctx.tool_name) {
765 candidates.push(Candidate {
766 source: "auto_approve".to_string(),
767 index: Some(index),
768 id: Some(pattern.clone()),
769 action: PolicyAction::Allow,
770 reason: format!("tool '{}' matches allow pattern '{pattern}'", ctx.tool_name),
771 approval: ApprovalShape::default(),
772 risk_labels: Vec::new(),
773 });
774 }
775 }
776 candidates
777}
778
779fn rule_candidates(policy: &ToolApprovalPolicy, ctx: &EvaluationContext) -> Vec<Candidate> {
780 policy
781 .rules
782 .iter()
783 .enumerate()
784 .filter(|(_, rule)| rule.matches.is_empty() || rule.matches.matches(ctx))
785 .map(|(index, rule)| Candidate {
786 source: "rules".to_string(),
787 index: Some(index),
788 id: rule.id.clone(),
789 action: rule.action,
790 reason: rule
791 .reason
792 .clone()
793 .or_else(|| rule.approval.risk.clone())
794 .unwrap_or_else(|| format!("tool '{}' matched policy rule", ctx.tool_name)),
795 approval: rule.approval.clone(),
796 risk_labels: risk_labels_for_rule(rule),
797 })
798 .collect()
799}
800
801fn strongest_candidate(candidates: Vec<Candidate>) -> Option<Candidate> {
802 let mut best: Option<Candidate> = None;
803 for candidate in candidates {
804 if best
805 .as_ref()
806 .map(|best| candidate.action.rank() > best.action.rank())
807 .unwrap_or(true)
808 {
809 best = Some(candidate);
810 }
811 }
812 best
813}
814
815fn evaluation_from_candidate(candidate: Candidate, ctx: &EvaluationContext) -> PolicyEvaluation {
816 let matched_rule = Some(candidate.matched_rule());
817 let required_approval = (candidate.action == PolicyAction::Ask).then_some(candidate.approval);
818 let mut risk_labels = candidate.risk_labels;
819 risk_labels.sort();
820 risk_labels.dedup();
821 let receipt = receipt_json(
822 candidate.action,
823 &candidate.reason,
824 matched_rule.as_ref(),
825 required_approval.as_ref(),
826 &risk_labels,
827 ctx,
828 );
829 PolicyEvaluation {
830 action: candidate.action.as_str().to_string(),
831 reason: candidate.reason,
832 matched_rule,
833 required_approval,
834 risk_labels,
835 receipt,
836 }
837}
838
839fn default_allow(ctx: &EvaluationContext) -> PolicyEvaluation {
840 let action = PolicyAction::Allow;
841 let reason = format!("tool '{}' approved by default", ctx.tool_name);
842 let receipt = receipt_json(action, &reason, None, None, &[], ctx);
843 PolicyEvaluation {
844 action: action.as_str().to_string(),
845 reason,
846 matched_rule: None,
847 required_approval: None,
848 risk_labels: Vec::new(),
849 receipt,
850 }
851}
852
853fn receipt_json(
854 action: PolicyAction,
855 reason: &str,
856 matched_rule: Option<&PolicyMatchedRule>,
857 approval: Option<&ApprovalShape>,
858 risk_labels: &[String],
859 ctx: &EvaluationContext,
860) -> JsonValue {
861 serde_json::json!({
862 "type": POLICY_RECEIPT_TYPE,
863 "action": action.as_str(),
864 "reason": reason,
865 "matched_rule": matched_rule,
866 "required_approval": approval,
867 "risk_labels": risk_labels,
868 "context": ctx.receipt_context(),
869 })
870}
871
872fn risk_labels_for_rule(rule: &PolicyRule) -> Vec<String> {
873 let mut labels = Vec::new();
874 if rule.action == PolicyAction::Ask {
875 labels.push("approval_required".to_string());
876 }
877 if rule.action == PolicyAction::Deny {
878 labels.push("matched_deny_rule".to_string());
879 }
880 if !rule.matches.path.is_empty() {
881 labels.push("path_rule".to_string());
882 }
883 if !rule.matches.command.is_empty() || !rule.matches.command_identity.is_empty() {
884 labels.push("command_rule".to_string());
885 }
886 if !rule.matches.url.is_empty()
887 || !rule.matches.domain.is_empty()
888 || !rule.matches.http_method.is_empty()
889 {
890 labels.push("network_rule".to_string());
891 }
892 if !rule.matches.mcp_server.is_empty() || !rule.matches.mcp_tool.is_empty() {
893 labels.push("mcp_rule".to_string());
894 }
895 if rule.matches.repeat_count_at_least.is_some() {
896 labels.push("repeated_call".to_string());
897 }
898 labels
899}
900
901fn first_sensitive_candidate(
902 policy: &ToolApprovalPolicy,
903 ctx: &EvaluationContext,
904) -> Option<String> {
905 let custom = &policy.sensitive_path_patterns;
906 ctx.path_candidates
907 .iter()
908 .chain(ctx.string_candidates.iter())
909 .find(|candidate| {
910 if custom.is_empty() {
911 is_sensitive_path_candidate(
912 candidate,
913 DEFAULT_SENSITIVE_PATH_PATTERNS.iter().copied(),
914 )
915 } else {
916 is_sensitive_path_candidate(candidate, custom.iter().map(String::as_str))
917 }
918 })
919 .cloned()
920}
921
922fn is_sensitive_path_candidate<'a>(
923 candidate: &str,
924 patterns: impl IntoIterator<Item = &'a str>,
925) -> bool {
926 let normalized = candidate.replace('\\', "/").to_ascii_lowercase();
927 let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
928 patterns.into_iter().any(|pattern| {
929 let pattern = pattern.to_ascii_lowercase();
930 super::super::glob_match(&pattern, &normalized)
931 || super::super::glob_match(&pattern, basename)
932 || glob_or_contains(&pattern, &normalized)
933 })
934}
935
936const DEFAULT_SENSITIVE_PATH_PATTERNS: &[&str] = &[
937 ".env",
938 ".env.*",
939 "**/.env",
940 "**/.env.*",
941 "id_rsa",
942 "id_ed25519",
943 "**/.aws/credentials",
944 "**/.npmrc",
945 "**/.netrc",
946 "*.pem",
947 "*.key",
948];
949
950fn under_external_root(path: &str, roots: &[String]) -> bool {
951 if roots.is_empty() {
952 return false;
953 }
954 let path = normalize_path(Path::new(path));
955 roots
956 .iter()
957 .map(|root| normalize_path(Path::new(root)))
958 .any(|root| path.starts_with(root))
959}
960
961fn path_entry_json(entry: &WorkspacePathInfo) -> JsonValue {
962 serde_json::json!({
963 "input": entry.input,
964 "kind": entry.kind,
965 "normalized": entry.normalized,
966 "workspace_path": entry.workspace_path,
967 "host_path": entry.host_path,
968 "recovered_root_drift": entry.recovered_root_drift,
969 "reason": entry.reason,
970 })
971}
972
973fn command_candidates(args: &JsonValue) -> (Vec<String>, Vec<String>) {
974 let mut commands = Vec::new();
975 let mut identities = Vec::new();
976 if let Some(command) = string_field(args, "command").or_else(|| string_field(args, "cmd")) {
977 commands.push(collapse_whitespace(&command));
978 if let Some(identity) = shell_command_identity(&command) {
979 identities.push(identity);
980 }
981 }
982 if let Some(argv) = args.get("argv").and_then(|value| value.as_array()) {
983 let parts = argv
984 .iter()
985 .filter_map(|value| value.as_str().map(ToOwned::to_owned))
986 .collect::<Vec<_>>();
987 if !parts.is_empty() {
988 commands.push(parts.join(" "));
989 identities.push(parts[0].clone());
990 }
991 }
992 dedup(&mut commands);
993 dedup(&mut identities);
994 (commands, identities)
995}
996
997fn shell_command_identity(command: &str) -> Option<String> {
998 command
999 .split_whitespace()
1000 .next()
1001 .map(|part| part.trim_matches(|c| matches!(c, '"' | '\'')))
1002 .filter(|part| !part.is_empty())
1003 .map(ToOwned::to_owned)
1004}
1005
1006fn url_candidates(strings: &[String]) -> (Vec<String>, Vec<String>) {
1007 let mut urls = Vec::new();
1008 let mut domains = Vec::new();
1009 for candidate in strings {
1010 if let Ok(url) = url::Url::parse(candidate) {
1011 if matches!(url.scheme(), "http" | "https") {
1012 urls.push(url.to_string());
1013 if let Some(host) = url.host_str() {
1014 domains.push(host.to_ascii_lowercase());
1015 }
1016 }
1017 }
1018 }
1019 dedup(&mut urls);
1020 dedup(&mut domains);
1021 (urls, domains)
1022}
1023
1024fn http_method_candidates(args: &JsonValue) -> Vec<String> {
1025 let mut methods = Vec::new();
1026 for key in ["method", "http_method"] {
1027 if let Some(method) = string_field(args, key) {
1028 methods.push(method.to_ascii_uppercase());
1029 }
1030 }
1031 dedup(&mut methods);
1032 methods
1033}
1034
1035fn mcp_candidates(tool_name: &str, args: &JsonValue) -> (Vec<String>, Vec<String>) {
1036 let mut servers = Vec::new();
1037 let mut tools = Vec::new();
1038 if let Some((server, tool)) = tool_name.split_once("__") {
1039 if !server.is_empty() && !tool.is_empty() {
1040 servers.push(server.to_string());
1041 tools.push(tool.to_string());
1042 }
1043 }
1044 for key in ["mcp_server", "_mcp_server", "server"] {
1045 if let Some(value) = string_field(args, key) {
1046 servers.push(value);
1047 }
1048 }
1049 for key in ["mcp_tool", "tool"] {
1050 if let Some(value) = string_field(args, key) {
1051 tools.push(value);
1052 }
1053 }
1054 dedup(&mut servers);
1055 dedup(&mut tools);
1056 (servers, tools)
1057}
1058
1059fn string_field(args: &JsonValue, key: &str) -> Option<String> {
1060 args.get(key)
1061 .and_then(|value| value.as_str())
1062 .filter(|value| !value.trim().is_empty())
1063 .map(ToOwned::to_owned)
1064}
1065
1066fn collect_string_values(value: &JsonValue, out: &mut Vec<String>) {
1067 match value {
1068 JsonValue::String(text) => out.push(text.clone()),
1069 JsonValue::Array(items) => {
1070 for item in items {
1071 collect_string_values(item, out);
1072 }
1073 }
1074 JsonValue::Object(map) => {
1075 for value in map.values() {
1076 collect_string_values(value, out);
1077 }
1078 }
1079 _ => {}
1080 }
1081}
1082
1083fn any_glob_matches(patterns: &[String], candidates: &[String]) -> bool {
1084 candidates.iter().any(|candidate| {
1085 patterns
1086 .iter()
1087 .any(|pattern| super::super::glob_match(pattern, candidate))
1088 })
1089}
1090
1091fn any_fragment_matches(patterns: &[String], candidates: &[String]) -> bool {
1092 candidates.iter().any(|candidate| {
1093 patterns
1094 .iter()
1095 .any(|pattern| glob_or_contains(pattern, candidate))
1096 })
1097}
1098
1099fn glob_or_contains(pattern: &str, text: &str) -> bool {
1100 if super::super::glob_match(pattern, text) {
1101 return true;
1102 }
1103 if pattern.contains('*') {
1104 let mut rest = text;
1105 for part in pattern.split('*').filter(|part| !part.is_empty()) {
1106 let Some(index) = rest.find(part) else {
1107 return false;
1108 };
1109 rest = &rest[index + part.len()..];
1110 }
1111 true
1112 } else {
1113 text.contains(pattern)
1114 }
1115}
1116
1117fn normalize_patterns_upper(patterns: &[String]) -> Vec<String> {
1118 patterns
1119 .iter()
1120 .map(|pattern| pattern.to_ascii_uppercase())
1121 .collect()
1122}
1123
1124fn collapse_whitespace(value: &str) -> String {
1125 value.split_whitespace().collect::<Vec<_>>().join(" ")
1126}
1127
1128fn normalize_path(path: &Path) -> PathBuf {
1129 let raw = if path.is_absolute() {
1130 path.to_path_buf()
1131 } else {
1132 crate::stdlib::process::execution_root_path().join(path)
1133 };
1134 let mut out = PathBuf::new();
1135 for component in raw.components() {
1136 match component {
1137 std::path::Component::CurDir => {}
1138 std::path::Component::ParentDir => {
1139 out.pop();
1140 }
1141 std::path::Component::Prefix(prefix) => out.push(prefix.as_os_str()),
1142 std::path::Component::RootDir => out.push(component.as_os_str()),
1143 std::path::Component::Normal(part) => out.push(part),
1144 }
1145 }
1146 out
1147}
1148
1149fn tool_kind_string(kind: crate::tool_annotations::ToolKind) -> &'static str {
1150 match kind {
1151 crate::tool_annotations::ToolKind::Read => "read",
1152 crate::tool_annotations::ToolKind::Edit => "edit",
1153 crate::tool_annotations::ToolKind::Delete => "delete",
1154 crate::tool_annotations::ToolKind::Move => "move",
1155 crate::tool_annotations::ToolKind::Search => "search",
1156 crate::tool_annotations::ToolKind::Execute => "execute",
1157 crate::tool_annotations::ToolKind::Think => "think",
1158 crate::tool_annotations::ToolKind::Fetch => "fetch",
1159 crate::tool_annotations::ToolKind::Other => "other",
1160 }
1161}
1162
1163fn deserialize_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
1164where
1165 D: Deserializer<'de>,
1166{
1167 let value = Option::<JsonValue>::deserialize(deserializer)?.unwrap_or(JsonValue::Null);
1168 match value {
1169 JsonValue::Null => Ok(Vec::new()),
1170 JsonValue::String(value) => Ok(vec![value]),
1171 JsonValue::Array(items) => items
1172 .into_iter()
1173 .map(|item| match item {
1174 JsonValue::String(value) => Ok(value),
1175 other => Err(D::Error::custom(format!(
1176 "expected string list item, got {other}"
1177 ))),
1178 })
1179 .collect(),
1180 other => Err(D::Error::custom(format!(
1181 "expected string or string list, got {other}"
1182 ))),
1183 }
1184}
1185
1186fn dedup(values: &mut Vec<String>) {
1187 values.sort();
1188 values.dedup();
1189}
1190
1191fn stable_json_digest(value: &JsonValue) -> String {
1192 let canonical = serde_json::to_string(value).unwrap_or_default();
1193 let digest = Sha256::digest(canonical.as_bytes());
1194 hex::encode(digest)
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199 use std::collections::BTreeMap;
1200
1201 use super::*;
1202 use crate::orchestration::{pop_execution_policy, push_execution_policy, CapabilityPolicy};
1203 use crate::tool_annotations::{SideEffectLevel, ToolAnnotations, ToolArgSchema, ToolKind};
1204
1205 fn policy_with_path_annotation(tool: &str, kind: ToolKind) {
1206 let mut annotations = BTreeMap::new();
1207 annotations.insert(
1208 tool.to_string(),
1209 ToolAnnotations {
1210 kind,
1211 side_effect_level: match kind {
1212 ToolKind::Fetch => SideEffectLevel::Network,
1213 ToolKind::Execute => SideEffectLevel::ProcessExec,
1214 ToolKind::Edit | ToolKind::Delete | ToolKind::Move => {
1215 SideEffectLevel::WorkspaceWrite
1216 }
1217 _ => SideEffectLevel::ReadOnly,
1218 },
1219 arg_schema: ToolArgSchema {
1220 path_params: vec!["path".to_string()],
1221 ..Default::default()
1222 },
1223 ..Default::default()
1224 },
1225 );
1226 push_execution_policy(CapabilityPolicy {
1227 tool_annotations: annotations,
1228 ..Default::default()
1229 });
1230 }
1231
1232 #[test]
1233 fn compact_rule_shorthand_deserializes() {
1234 let rule: PolicyRule = serde_json::from_value(serde_json::json!({
1235 "deny": {"tool": "read_*", "path": "**/.env"},
1236 "reason": "secret file"
1237 }))
1238 .expect("rule");
1239 assert_eq!(rule.action, PolicyAction::Deny);
1240 assert_eq!(rule.matches.tool, vec!["read_*"]);
1241 assert_eq!(rule.matches.path, vec!["**/.env"]);
1242 assert_eq!(rule.reason.as_deref(), Some("secret file"));
1243 }
1244
1245 #[test]
1246 fn ambiguous_or_invalid_rule_shapes_are_rejected() {
1247 let invalid_action = serde_json::from_value::<PolicyRule>(serde_json::json!({
1248 "action": "maybe",
1249 "match": {"tool": "read_file"}
1250 }));
1251 assert!(invalid_action.is_err());
1252
1253 let mixed_matchers = serde_json::from_value::<PolicyRule>(serde_json::json!({
1254 "action": "deny",
1255 "match": {"tool": "read_file"},
1256 "path": "**/.env"
1257 }));
1258 assert!(mixed_matchers.is_err());
1259 }
1260
1261 #[test]
1262 fn deny_beats_ask_and_allow_regardless_of_order() {
1263 let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
1264 "rules": [
1265 {"allow": {"tool": "write_file"}},
1266 {"ask": {"tool": "write_*"}},
1267 {"deny": {"tool": "write_file"}, "reason": "blocked"}
1268 ]
1269 }))
1270 .expect("policy");
1271 let decision =
1272 evaluate_tool_approval_policy(&policy, "write_file", &serde_json::json!({}), None);
1273 assert!(decision.is_deny());
1274 assert_eq!(decision.reason, "blocked");
1275 assert_eq!(
1276 decision.matched_rule.as_ref().and_then(|rule| rule.index),
1277 Some(2)
1278 );
1279 }
1280
1281 #[test]
1282 fn sensitive_paths_are_denied_by_default() {
1283 let policy = ToolApprovalPolicy::default();
1284 let decision = evaluate_tool_approval_policy(
1285 &policy,
1286 "read_file",
1287 &serde_json::json!({"path": "config/.env"}),
1288 None,
1289 );
1290 assert!(decision.is_deny());
1291 assert!(decision.risk_labels.contains(&"sensitive_path".to_string()));
1292 }
1293
1294 #[test]
1295 fn explicit_sensitive_opt_out_allows_regular_evaluation() {
1296 let policy = ToolApprovalPolicy {
1297 allow_sensitive_paths: true,
1298 ..Default::default()
1299 };
1300 let decision = evaluate_tool_approval_policy(
1301 &policy,
1302 "read_file",
1303 &serde_json::json!({"path": "config/.env"}),
1304 None,
1305 );
1306 assert!(decision.is_allow());
1307 assert!(!decision.has_audit_signal());
1308 }
1309
1310 #[test]
1311 fn external_declared_paths_are_denied_without_root() {
1312 let temp = tempfile::tempdir().unwrap();
1313 crate::stdlib::process::set_thread_execution_context(Some(
1314 crate::orchestration::RunExecutionRecord {
1315 cwd: Some(temp.path().to_string_lossy().into_owned()),
1316 source_dir: Some(temp.path().to_string_lossy().into_owned()),
1317 env: BTreeMap::new(),
1318 adapter: None,
1319 repo_path: None,
1320 worktree_path: None,
1321 branch: None,
1322 base_ref: None,
1323 cleanup: None,
1324 },
1325 ));
1326 policy_with_path_annotation("read_file", ToolKind::Read);
1327 let decision = evaluate_tool_approval_policy(
1328 &ToolApprovalPolicy::default(),
1329 "read_file",
1330 &serde_json::json!({"path": "/tmp/outside.txt"}),
1331 None,
1332 );
1333 assert!(decision.is_deny());
1334 assert!(decision.risk_labels.contains(&"external_path".to_string()));
1335 pop_execution_policy();
1336 crate::stdlib::process::set_thread_execution_context(None);
1337 }
1338
1339 #[test]
1340 fn path_rule_uses_declared_path_params() {
1341 policy_with_path_annotation("write_file", ToolKind::Edit);
1342 let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
1343 "allow_sensitive_paths": true,
1344 "rules": [{"ask": {"tool": "write_*", "path": "src/**"}, "reason": "source edit"}]
1345 }))
1346 .expect("policy");
1347 let decision = evaluate_tool_approval_policy(
1348 &policy,
1349 "write_file",
1350 &serde_json::json!({"path": "src/lib.rs"}),
1351 None,
1352 );
1353 assert!(decision.is_ask());
1354 assert_eq!(decision.reason, "source edit");
1355 pop_execution_policy();
1356 }
1357
1358 #[test]
1359 fn command_url_mcp_identity_and_repeat_rules_match() {
1360 let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
1361 "allow_sensitive_paths": true,
1362 "rules": [
1363 {"ask": {"tool": "run_command", "command_identity": "npm"}},
1364 {"deny": {"tool": "fetch_url", "domain": "*.example.com", "method": "POST"}},
1365 {"deny": {"mcp_server": "github", "mcp_tool": "create_issue"}},
1366 {"deny": {"tool": "read_file", "repeat_count_gte": 3}}
1367 ]
1368 }))
1369 .expect("policy");
1370 assert!(evaluate_tool_approval_policy(
1371 &policy,
1372 "run_command",
1373 &serde_json::json!({"argv": ["npm", "install"]}),
1374 None,
1375 )
1376 .is_ask());
1377 assert!(evaluate_tool_approval_policy(
1378 &policy,
1379 "fetch_url",
1380 &serde_json::json!({"url": "https://api.example.com/v1", "method": "post"}),
1381 None,
1382 )
1383 .is_deny());
1384 assert!(evaluate_tool_approval_policy(
1385 &policy,
1386 "github__create_issue",
1387 &serde_json::json!({}),
1388 None,
1389 )
1390 .is_deny());
1391 assert!(evaluate_tool_approval_policy(
1392 &policy,
1393 "read_file",
1394 &serde_json::json!({"path": "README.md"}),
1395 Some(3),
1396 )
1397 .is_deny());
1398 }
1399
1400 #[test]
1401 fn persona_agent_and_mode_rules_match_args() {
1402 let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
1403 "allow_sensitive_paths": true,
1404 "rules": [{"deny": {"agent": "release-*", "persona": "shipper", "mode": "act"}}]
1405 }))
1406 .expect("policy");
1407 let decision = evaluate_tool_approval_policy(
1408 &policy,
1409 "publish",
1410 &serde_json::json!({"agent": "release-1", "persona": "shipper", "mode": "act"}),
1411 None,
1412 );
1413 assert!(decision.is_deny());
1414 }
1415}