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