Skip to main content

harn_vm/orchestration/policy/
approval_rules.rs

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, 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 patterns = if policy.sensitive_path_patterns.is_empty() {
906        default_sensitive_path_patterns()
907    } else {
908        policy.sensitive_path_patterns.clone()
909    };
910    ctx.path_candidates
911        .iter()
912        .chain(ctx.string_candidates.iter())
913        .find(|candidate| is_sensitive_path_candidate(candidate, &patterns))
914        .cloned()
915}
916
917fn is_sensitive_path_candidate(candidate: &str, patterns: &[String]) -> bool {
918    let normalized = candidate.replace('\\', "/").to_ascii_lowercase();
919    let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
920    patterns.iter().any(|pattern| {
921        let pattern = pattern.to_ascii_lowercase();
922        super::super::glob_match(&pattern, &normalized)
923            || super::super::glob_match(&pattern, basename)
924            || glob_or_contains(&pattern, &normalized)
925    })
926}
927
928fn default_sensitive_path_patterns() -> Vec<String> {
929    [
930        ".env",
931        ".env.*",
932        "**/.env",
933        "**/.env.*",
934        "id_rsa",
935        "id_ed25519",
936        "**/.aws/credentials",
937        "**/.npmrc",
938        "**/.netrc",
939        "*.pem",
940        "*.key",
941    ]
942    .iter()
943    .map(|value| value.to_string())
944    .collect()
945}
946
947fn under_external_root(path: &str, roots: &[String]) -> bool {
948    if roots.is_empty() {
949        return false;
950    }
951    let path = normalize_path(Path::new(path));
952    roots
953        .iter()
954        .map(|root| normalize_path(Path::new(root)))
955        .any(|root| path.starts_with(root))
956}
957
958fn path_entry_json(entry: &WorkspacePathInfo) -> JsonValue {
959    serde_json::json!({
960        "input": entry.input,
961        "kind": entry.kind,
962        "normalized": entry.normalized,
963        "workspace_path": entry.workspace_path,
964        "host_path": entry.host_path,
965        "recovered_root_drift": entry.recovered_root_drift,
966        "reason": entry.reason,
967    })
968}
969
970fn command_candidates(args: &JsonValue) -> (Vec<String>, Vec<String>) {
971    let mut commands = Vec::new();
972    let mut identities = Vec::new();
973    if let Some(command) = string_field(args, "command").or_else(|| string_field(args, "cmd")) {
974        commands.push(collapse_whitespace(&command));
975        if let Some(identity) = shell_command_identity(&command) {
976            identities.push(identity);
977        }
978    }
979    if let Some(argv) = args.get("argv").and_then(|value| value.as_array()) {
980        let parts = argv
981            .iter()
982            .filter_map(|value| value.as_str().map(ToOwned::to_owned))
983            .collect::<Vec<_>>();
984        if !parts.is_empty() {
985            commands.push(parts.join(" "));
986            identities.push(parts[0].clone());
987        }
988    }
989    dedup(&mut commands);
990    dedup(&mut identities);
991    (commands, identities)
992}
993
994fn shell_command_identity(command: &str) -> Option<String> {
995    command
996        .split_whitespace()
997        .next()
998        .map(|part| part.trim_matches(|c| matches!(c, '"' | '\'')))
999        .filter(|part| !part.is_empty())
1000        .map(ToOwned::to_owned)
1001}
1002
1003fn url_candidates(strings: &[String]) -> (Vec<String>, Vec<String>) {
1004    let mut urls = Vec::new();
1005    let mut domains = Vec::new();
1006    for candidate in strings {
1007        if let Ok(url) = url::Url::parse(candidate) {
1008            if matches!(url.scheme(), "http" | "https") {
1009                urls.push(url.to_string());
1010                if let Some(host) = url.host_str() {
1011                    domains.push(host.to_ascii_lowercase());
1012                }
1013            }
1014        }
1015    }
1016    dedup(&mut urls);
1017    dedup(&mut domains);
1018    (urls, domains)
1019}
1020
1021fn http_method_candidates(args: &JsonValue) -> Vec<String> {
1022    let mut methods = Vec::new();
1023    for key in ["method", "http_method"] {
1024        if let Some(method) = string_field(args, key) {
1025            methods.push(method.to_ascii_uppercase());
1026        }
1027    }
1028    dedup(&mut methods);
1029    methods
1030}
1031
1032fn mcp_candidates(tool_name: &str, args: &JsonValue) -> (Vec<String>, Vec<String>) {
1033    let mut servers = Vec::new();
1034    let mut tools = Vec::new();
1035    if let Some((server, tool)) = tool_name.split_once("__") {
1036        if !server.is_empty() && !tool.is_empty() {
1037            servers.push(server.to_string());
1038            tools.push(tool.to_string());
1039        }
1040    }
1041    for key in ["mcp_server", "_mcp_server", "server"] {
1042        if let Some(value) = string_field(args, key) {
1043            servers.push(value);
1044        }
1045    }
1046    for key in ["mcp_tool", "tool"] {
1047        if let Some(value) = string_field(args, key) {
1048            tools.push(value);
1049        }
1050    }
1051    dedup(&mut servers);
1052    dedup(&mut tools);
1053    (servers, tools)
1054}
1055
1056fn string_field(args: &JsonValue, key: &str) -> Option<String> {
1057    args.get(key)
1058        .and_then(|value| value.as_str())
1059        .filter(|value| !value.trim().is_empty())
1060        .map(ToOwned::to_owned)
1061}
1062
1063fn collect_string_values(value: &JsonValue, out: &mut Vec<String>) {
1064    match value {
1065        JsonValue::String(text) => out.push(text.clone()),
1066        JsonValue::Array(items) => {
1067            for item in items {
1068                collect_string_values(item, out);
1069            }
1070        }
1071        JsonValue::Object(map) => {
1072            for value in map.values() {
1073                collect_string_values(value, out);
1074            }
1075        }
1076        _ => {}
1077    }
1078}
1079
1080fn any_glob_matches(patterns: &[String], candidates: &[String]) -> bool {
1081    candidates.iter().any(|candidate| {
1082        patterns
1083            .iter()
1084            .any(|pattern| super::super::glob_match(pattern, candidate))
1085    })
1086}
1087
1088fn any_fragment_matches(patterns: &[String], candidates: &[String]) -> bool {
1089    candidates.iter().any(|candidate| {
1090        patterns
1091            .iter()
1092            .any(|pattern| glob_or_contains(pattern, candidate))
1093    })
1094}
1095
1096fn glob_or_contains(pattern: &str, text: &str) -> bool {
1097    if super::super::glob_match(pattern, text) {
1098        return true;
1099    }
1100    if pattern.contains('*') {
1101        let mut rest = text;
1102        for part in pattern.split('*').filter(|part| !part.is_empty()) {
1103            let Some(index) = rest.find(part) else {
1104                return false;
1105            };
1106            rest = &rest[index + part.len()..];
1107        }
1108        true
1109    } else {
1110        text.contains(pattern)
1111    }
1112}
1113
1114fn normalize_patterns_upper(patterns: &[String]) -> Vec<String> {
1115    patterns
1116        .iter()
1117        .map(|pattern| pattern.to_ascii_uppercase())
1118        .collect()
1119}
1120
1121fn collapse_whitespace(value: &str) -> String {
1122    value.split_whitespace().collect::<Vec<_>>().join(" ")
1123}
1124
1125fn normalize_path(path: &Path) -> PathBuf {
1126    let raw = if path.is_absolute() {
1127        path.to_path_buf()
1128    } else {
1129        crate::stdlib::process::execution_root_path().join(path)
1130    };
1131    let mut out = PathBuf::new();
1132    for component in raw.components() {
1133        match component {
1134            std::path::Component::CurDir => {}
1135            std::path::Component::ParentDir => {
1136                out.pop();
1137            }
1138            std::path::Component::Prefix(prefix) => out.push(prefix.as_os_str()),
1139            std::path::Component::RootDir => out.push(component.as_os_str()),
1140            std::path::Component::Normal(part) => out.push(part),
1141        }
1142    }
1143    out
1144}
1145
1146fn tool_kind_string(kind: crate::tool_annotations::ToolKind) -> &'static str {
1147    match kind {
1148        crate::tool_annotations::ToolKind::Read => "read",
1149        crate::tool_annotations::ToolKind::Edit => "edit",
1150        crate::tool_annotations::ToolKind::Delete => "delete",
1151        crate::tool_annotations::ToolKind::Move => "move",
1152        crate::tool_annotations::ToolKind::Search => "search",
1153        crate::tool_annotations::ToolKind::Execute => "execute",
1154        crate::tool_annotations::ToolKind::Think => "think",
1155        crate::tool_annotations::ToolKind::Fetch => "fetch",
1156        crate::tool_annotations::ToolKind::Other => "other",
1157    }
1158}
1159
1160fn deserialize_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
1161where
1162    D: Deserializer<'de>,
1163{
1164    let value = Option::<JsonValue>::deserialize(deserializer)?.unwrap_or(JsonValue::Null);
1165    match value {
1166        JsonValue::Null => Ok(Vec::new()),
1167        JsonValue::String(value) => Ok(vec![value]),
1168        JsonValue::Array(items) => items
1169            .into_iter()
1170            .map(|item| match item {
1171                JsonValue::String(value) => Ok(value),
1172                other => Err(D::Error::custom(format!(
1173                    "expected string list item, got {other}"
1174                ))),
1175            })
1176            .collect(),
1177        other => Err(D::Error::custom(format!(
1178            "expected string or string list, got {other}"
1179        ))),
1180    }
1181}
1182
1183fn dedup(values: &mut Vec<String>) {
1184    values.sort();
1185    values.dedup();
1186}
1187
1188fn stable_json_digest(value: &JsonValue) -> String {
1189    let canonical = serde_json::to_string(value).unwrap_or_default();
1190    let digest = Sha256::digest(canonical.as_bytes());
1191    hex::encode(digest)
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196    use std::collections::BTreeMap;
1197
1198    use super::*;
1199    use crate::orchestration::{pop_execution_policy, push_execution_policy, CapabilityPolicy};
1200    use crate::tool_annotations::{SideEffectLevel, ToolAnnotations, ToolArgSchema, ToolKind};
1201
1202    fn policy_with_path_annotation(tool: &str, kind: ToolKind) {
1203        let mut annotations = BTreeMap::new();
1204        annotations.insert(
1205            tool.to_string(),
1206            ToolAnnotations {
1207                kind,
1208                side_effect_level: match kind {
1209                    ToolKind::Fetch => SideEffectLevel::Network,
1210                    ToolKind::Execute => SideEffectLevel::ProcessExec,
1211                    ToolKind::Edit | ToolKind::Delete | ToolKind::Move => {
1212                        SideEffectLevel::WorkspaceWrite
1213                    }
1214                    _ => SideEffectLevel::ReadOnly,
1215                },
1216                arg_schema: ToolArgSchema {
1217                    path_params: vec!["path".to_string()],
1218                    ..Default::default()
1219                },
1220                ..Default::default()
1221            },
1222        );
1223        push_execution_policy(CapabilityPolicy {
1224            tool_annotations: annotations,
1225            ..Default::default()
1226        });
1227    }
1228
1229    #[test]
1230    fn compact_rule_shorthand_deserializes() {
1231        let rule: PolicyRule = serde_json::from_value(serde_json::json!({
1232            "deny": {"tool": "read_*", "path": "**/.env"},
1233            "reason": "secret file"
1234        }))
1235        .expect("rule");
1236        assert_eq!(rule.action, PolicyAction::Deny);
1237        assert_eq!(rule.matches.tool, vec!["read_*"]);
1238        assert_eq!(rule.matches.path, vec!["**/.env"]);
1239        assert_eq!(rule.reason.as_deref(), Some("secret file"));
1240    }
1241
1242    #[test]
1243    fn ambiguous_or_invalid_rule_shapes_are_rejected() {
1244        let invalid_action = serde_json::from_value::<PolicyRule>(serde_json::json!({
1245            "action": "maybe",
1246            "match": {"tool": "read_file"}
1247        }));
1248        assert!(invalid_action.is_err());
1249
1250        let mixed_matchers = serde_json::from_value::<PolicyRule>(serde_json::json!({
1251            "action": "deny",
1252            "match": {"tool": "read_file"},
1253            "path": "**/.env"
1254        }));
1255        assert!(mixed_matchers.is_err());
1256    }
1257
1258    #[test]
1259    fn deny_beats_ask_and_allow_regardless_of_order() {
1260        let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
1261            "rules": [
1262                {"allow": {"tool": "write_file"}},
1263                {"ask": {"tool": "write_*"}},
1264                {"deny": {"tool": "write_file"}, "reason": "blocked"}
1265            ]
1266        }))
1267        .expect("policy");
1268        let decision =
1269            evaluate_tool_approval_policy(&policy, "write_file", &serde_json::json!({}), None);
1270        assert!(decision.is_deny());
1271        assert_eq!(decision.reason, "blocked");
1272        assert_eq!(
1273            decision.matched_rule.as_ref().and_then(|rule| rule.index),
1274            Some(2)
1275        );
1276    }
1277
1278    #[test]
1279    fn sensitive_paths_are_denied_by_default() {
1280        let policy = ToolApprovalPolicy::default();
1281        let decision = evaluate_tool_approval_policy(
1282            &policy,
1283            "read_file",
1284            &serde_json::json!({"path": "config/.env"}),
1285            None,
1286        );
1287        assert!(decision.is_deny());
1288        assert!(decision.risk_labels.contains(&"sensitive_path".to_string()));
1289    }
1290
1291    #[test]
1292    fn explicit_sensitive_opt_out_allows_regular_evaluation() {
1293        let policy = ToolApprovalPolicy {
1294            allow_sensitive_paths: true,
1295            ..Default::default()
1296        };
1297        let decision = evaluate_tool_approval_policy(
1298            &policy,
1299            "read_file",
1300            &serde_json::json!({"path": "config/.env"}),
1301            None,
1302        );
1303        assert!(decision.is_allow());
1304        assert!(!decision.has_audit_signal());
1305    }
1306
1307    #[test]
1308    fn external_declared_paths_are_denied_without_root() {
1309        let temp = tempfile::tempdir().unwrap();
1310        crate::stdlib::process::set_thread_execution_context(Some(
1311            crate::orchestration::RunExecutionRecord {
1312                cwd: Some(temp.path().to_string_lossy().into_owned()),
1313                source_dir: Some(temp.path().to_string_lossy().into_owned()),
1314                env: BTreeMap::new(),
1315                adapter: None,
1316                repo_path: None,
1317                worktree_path: None,
1318                branch: None,
1319                base_ref: None,
1320                cleanup: None,
1321            },
1322        ));
1323        policy_with_path_annotation("read_file", ToolKind::Read);
1324        let decision = evaluate_tool_approval_policy(
1325            &ToolApprovalPolicy::default(),
1326            "read_file",
1327            &serde_json::json!({"path": "/tmp/outside.txt"}),
1328            None,
1329        );
1330        assert!(decision.is_deny());
1331        assert!(decision.risk_labels.contains(&"external_path".to_string()));
1332        pop_execution_policy();
1333        crate::stdlib::process::set_thread_execution_context(None);
1334    }
1335
1336    #[test]
1337    fn path_rule_uses_declared_path_params() {
1338        policy_with_path_annotation("write_file", ToolKind::Edit);
1339        let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
1340            "allow_sensitive_paths": true,
1341            "rules": [{"ask": {"tool": "write_*", "path": "src/**"}, "reason": "source edit"}]
1342        }))
1343        .expect("policy");
1344        let decision = evaluate_tool_approval_policy(
1345            &policy,
1346            "write_file",
1347            &serde_json::json!({"path": "src/lib.rs"}),
1348            None,
1349        );
1350        assert!(decision.is_ask());
1351        assert_eq!(decision.reason, "source edit");
1352        pop_execution_policy();
1353    }
1354
1355    #[test]
1356    fn command_url_mcp_identity_and_repeat_rules_match() {
1357        let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
1358            "allow_sensitive_paths": true,
1359            "rules": [
1360                {"ask": {"tool": "run_command", "command_identity": "npm"}},
1361                {"deny": {"tool": "fetch_url", "domain": "*.example.com", "method": "POST"}},
1362                {"deny": {"mcp_server": "github", "mcp_tool": "create_issue"}},
1363                {"deny": {"tool": "read_file", "repeat_count_gte": 3}}
1364            ]
1365        }))
1366        .expect("policy");
1367        assert!(evaluate_tool_approval_policy(
1368            &policy,
1369            "run_command",
1370            &serde_json::json!({"argv": ["npm", "install"]}),
1371            None,
1372        )
1373        .is_ask());
1374        assert!(evaluate_tool_approval_policy(
1375            &policy,
1376            "fetch_url",
1377            &serde_json::json!({"url": "https://api.example.com/v1", "method": "post"}),
1378            None,
1379        )
1380        .is_deny());
1381        assert!(evaluate_tool_approval_policy(
1382            &policy,
1383            "github__create_issue",
1384            &serde_json::json!({}),
1385            None,
1386        )
1387        .is_deny());
1388        assert!(evaluate_tool_approval_policy(
1389            &policy,
1390            "read_file",
1391            &serde_json::json!({"path": "README.md"}),
1392            Some(3),
1393        )
1394        .is_deny());
1395    }
1396
1397    #[test]
1398    fn persona_agent_and_mode_rules_match_args() {
1399        let policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
1400            "allow_sensitive_paths": true,
1401            "rules": [{"deny": {"agent": "release-*", "persona": "shipper", "mode": "act"}}]
1402        }))
1403        .expect("policy");
1404        let decision = evaluate_tool_approval_policy(
1405            &policy,
1406            "publish",
1407            &serde_json::json!({"agent": "release-1", "persona": "shipper", "mode": "act"}),
1408            None,
1409        );
1410        assert!(decision.is_deny());
1411    }
1412}