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 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;