Skip to main content

harn_vm/
tool_surface.rs

1//! Validation for coherent tool surfaces before an agent spends model tokens.
2//!
3//! The checks here are deliberately structural and conservative. They do not
4//! try to understand arbitrary prose; they validate declared registries,
5//! policies, and prompt text with an explicit suppression convention.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10
11use crate::llm::tools::text_tool_call_tag_pairs;
12use crate::orchestration::{CapabilityPolicy, ToolApprovalPolicy};
13use crate::tool_annotations::{SideEffectLevel, ToolAnnotations, ToolArgSchema, ToolKind};
14use crate::value::VmValue;
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ToolSurfaceSeverity {
19    Warning,
20    Error,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24pub struct ToolSurfaceDiagnostic {
25    pub code: String,
26    pub severity: ToolSurfaceSeverity,
27    pub message: String,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub tool: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub field: Option<String>,
32}
33
34impl ToolSurfaceDiagnostic {
35    fn warning(code: &str, message: impl Into<String>) -> Self {
36        Self {
37            code: code.to_string(),
38            severity: ToolSurfaceSeverity::Warning,
39            message: message.into(),
40            tool: None,
41            field: None,
42        }
43    }
44
45    fn error(code: &str, message: impl Into<String>) -> Self {
46        Self {
47            code: code.to_string(),
48            severity: ToolSurfaceSeverity::Error,
49            message: message.into(),
50            tool: None,
51            field: None,
52        }
53    }
54
55    fn with_tool(mut self, tool: impl Into<String>) -> Self {
56        self.tool = Some(tool.into());
57        self
58    }
59
60    fn with_field(mut self, field: impl Into<String>) -> Self {
61        self.field = Some(field.into());
62        self
63    }
64}
65
66#[derive(Clone, Debug, Default, Serialize, Deserialize)]
67pub struct ToolSurfaceReport {
68    pub valid: bool,
69    pub diagnostics: Vec<ToolSurfaceDiagnostic>,
70}
71
72impl ToolSurfaceReport {
73    fn new(diagnostics: Vec<ToolSurfaceDiagnostic>) -> Self {
74        let valid = diagnostics
75            .iter()
76            .all(|d| d.severity != ToolSurfaceSeverity::Error);
77        Self { valid, diagnostics }
78    }
79}
80
81pub fn tool_names_from_spec(value: &serde_json::Value) -> Vec<String> {
82    match value {
83        serde_json::Value::Null => Vec::new(),
84        serde_json::Value::Array(items) => items
85            .iter()
86            .filter_map(|item| match item {
87                serde_json::Value::Object(map) => map
88                    .get("name")
89                    .and_then(|value| value.as_str())
90                    .filter(|name| !name.is_empty())
91                    .map(ToOwned::to_owned),
92                _ => None,
93            })
94            .collect(),
95        serde_json::Value::Object(map) => {
96            if map.get("_type").and_then(|value| value.as_str()) == Some("tool_registry") {
97                return map
98                    .get("tools")
99                    .map(tool_names_from_spec)
100                    .unwrap_or_default();
101            }
102            map.get("name")
103                .and_then(|value| value.as_str())
104                .filter(|name| !name.is_empty())
105                .map(|name| vec![name.to_string()])
106                .unwrap_or_default()
107        }
108        _ => Vec::new(),
109    }
110}
111
112fn max_side_effect_level(levels: impl Iterator<Item = String>) -> Option<String> {
113    fn rank(v: &str) -> usize {
114        match v {
115            "none" => 0,
116            "read_only" => 1,
117            "workspace_write" => 2,
118            "process_exec" => 3,
119            "network" => 4,
120            _ => 5,
121        }
122    }
123    levels.max_by_key(|level| rank(level))
124}
125
126fn parse_tool_kind(value: Option<&serde_json::Value>) -> ToolKind {
127    match value.and_then(|v| v.as_str()).unwrap_or("") {
128        "read" => ToolKind::Read,
129        "edit" => ToolKind::Edit,
130        "delete" => ToolKind::Delete,
131        "move" => ToolKind::Move,
132        "search" => ToolKind::Search,
133        "execute" => ToolKind::Execute,
134        "think" => ToolKind::Think,
135        "fetch" => ToolKind::Fetch,
136        _ => ToolKind::Other,
137    }
138}
139
140fn parse_tool_annotations(map: &serde_json::Map<String, serde_json::Value>) -> ToolAnnotations {
141    let policy = map
142        .get("policy")
143        .and_then(|value| value.as_object())
144        .cloned()
145        .unwrap_or_default();
146
147    let capabilities = policy
148        .get("capabilities")
149        .and_then(|value| value.as_object())
150        .map(|caps| {
151            caps.iter()
152                .map(|(capability, ops)| {
153                    let values = ops
154                        .as_array()
155                        .map(|items| {
156                            items
157                                .iter()
158                                .filter_map(|item| item.as_str().map(ToOwned::to_owned))
159                                .collect::<Vec<_>>()
160                        })
161                        .unwrap_or_default();
162                    (capability.clone(), values)
163                })
164                .collect::<BTreeMap<_, _>>()
165        })
166        .unwrap_or_default();
167
168    let arg_schema = if let Some(schema) = policy.get("arg_schema") {
169        serde_json::from_value::<ToolArgSchema>(schema.clone()).unwrap_or_default()
170    } else {
171        ToolArgSchema {
172            path_params: policy
173                .get("path_params")
174                .and_then(|value| value.as_array())
175                .map(|items| {
176                    items
177                        .iter()
178                        .filter_map(|item| item.as_str().map(ToOwned::to_owned))
179                        .collect::<Vec<_>>()
180                })
181                .unwrap_or_default(),
182            arg_aliases: policy
183                .get("arg_aliases")
184                .and_then(|value| value.as_object())
185                .map(|aliases| {
186                    aliases
187                        .iter()
188                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
189                        .collect::<BTreeMap<_, _>>()
190                })
191                .unwrap_or_default(),
192            required: policy
193                .get("required")
194                .and_then(|value| value.as_array())
195                .map(|items| {
196                    items
197                        .iter()
198                        .filter_map(|item| item.as_str().map(ToOwned::to_owned))
199                        .collect::<Vec<_>>()
200                })
201                .unwrap_or_default(),
202        }
203    };
204
205    let kind = parse_tool_kind(policy.get("kind"));
206    let side_effect_level = policy
207        .get("side_effect_level")
208        .and_then(|value| value.as_str())
209        .map(SideEffectLevel::parse)
210        .unwrap_or_default();
211
212    ToolAnnotations {
213        kind,
214        side_effect_level,
215        arg_schema,
216        capabilities,
217        emits_artifacts: policy
218            .get("emits_artifacts")
219            .and_then(|value| value.as_bool())
220            .unwrap_or(false),
221        result_readers: policy
222            .get("result_readers")
223            .or_else(|| policy.get("readable_result_routes"))
224            .and_then(|value| value.as_array())
225            .map(|items| {
226                items
227                    .iter()
228                    .filter_map(|item| item.as_str().map(ToOwned::to_owned))
229                    .collect::<Vec<_>>()
230            })
231            .unwrap_or_default(),
232        inline_result: policy
233            .get("inline_result")
234            .and_then(|value| value.as_bool())
235            .unwrap_or(false),
236    }
237}
238
239pub fn tool_annotations_from_spec(value: &serde_json::Value) -> BTreeMap<String, ToolAnnotations> {
240    match value {
241        serde_json::Value::Null => BTreeMap::new(),
242        serde_json::Value::Array(items) => items
243            .iter()
244            .filter_map(|item| match item {
245                serde_json::Value::Object(map) => map
246                    .get("name")
247                    .and_then(|value| value.as_str())
248                    .filter(|name| !name.is_empty())
249                    .map(|name| (name.to_string(), parse_tool_annotations(map))),
250                _ => None,
251            })
252            .collect(),
253        serde_json::Value::Object(map) => {
254            if map.get("_type").and_then(|value| value.as_str()) == Some("tool_registry") {
255                return map
256                    .get("tools")
257                    .map(tool_annotations_from_spec)
258                    .unwrap_or_default();
259            }
260            map.get("name")
261                .and_then(|value| value.as_str())
262                .filter(|name| !name.is_empty())
263                .map(|name| {
264                    let mut annotations = BTreeMap::new();
265                    annotations.insert(name.to_string(), parse_tool_annotations(map));
266                    annotations
267                })
268                .unwrap_or_default()
269        }
270        _ => BTreeMap::new(),
271    }
272}
273
274pub fn tool_capability_policy_from_spec(value: &serde_json::Value) -> CapabilityPolicy {
275    let tools = tool_names_from_spec(value);
276    let tool_annotations = tool_annotations_from_spec(value);
277    let mut capabilities: BTreeMap<String, Vec<String>> = BTreeMap::new();
278    for annotations in tool_annotations.values() {
279        for (capability, ops) in &annotations.capabilities {
280            let entry = capabilities.entry(capability.clone()).or_default();
281            for op in ops {
282                if !entry.contains(op) {
283                    entry.push(op.clone());
284                }
285            }
286            entry.sort();
287        }
288    }
289    let side_effect_level = max_side_effect_level(
290        tool_annotations
291            .values()
292            .map(|annotations| annotations.side_effect_level.as_str().to_string())
293            .filter(|level| level != "none"),
294    );
295    CapabilityPolicy {
296        tools,
297        capabilities,
298        workspace_roots: Vec::new(),
299        side_effect_level,
300        recursion_limit: None,
301        tool_arg_constraints: Vec::new(),
302        tool_annotations,
303    }
304}
305
306#[derive(Clone, Debug, Default)]
307pub struct ToolSurfaceInput {
308    pub tools: Option<VmValue>,
309    pub native_tools: Option<Vec<serde_json::Value>>,
310    pub policy: Option<CapabilityPolicy>,
311    pub approval_policy: Option<ToolApprovalPolicy>,
312    pub prompt_texts: Vec<String>,
313    pub tool_search_active: bool,
314}
315
316#[derive(Clone, Debug, Default)]
317struct ToolEntry {
318    name: String,
319    parameter_keys: BTreeSet<String>,
320    has_schema: bool,
321    annotations: Option<ToolAnnotations>,
322    has_executor: bool,
323    defer_loading: bool,
324    provider_native: bool,
325}
326
327pub fn validate_tool_surface(input: &ToolSurfaceInput) -> ToolSurfaceReport {
328    ToolSurfaceReport::new(validate_tool_surface_diagnostics(input))
329}
330
331pub fn validate_tool_surface_diagnostics(input: &ToolSurfaceInput) -> Vec<ToolSurfaceDiagnostic> {
332    let entries = collect_entries(input);
333    let active_names = effective_active_names(&entries, input.policy.as_ref());
334    let mut diagnostics = Vec::new();
335
336    for entry in entries
337        .iter()
338        .filter(|entry| active_names.contains(entry.name.as_str()))
339    {
340        if !entry.has_schema {
341            diagnostics.push(
342                ToolSurfaceDiagnostic::warning(
343                    "TOOL_SURFACE_MISSING_SCHEMA",
344                    format!("active tool '{}' has no parameter schema", entry.name),
345                )
346                .with_tool(entry.name.clone())
347                .with_field("parameters"),
348            );
349        }
350        if entry.annotations.is_none() {
351            diagnostics.push(
352                ToolSurfaceDiagnostic::warning(
353                    "TOOL_SURFACE_MISSING_ANNOTATIONS",
354                    format!("active tool '{}' has no ToolAnnotations", entry.name),
355                )
356                .with_tool(entry.name.clone())
357                .with_field("annotations"),
358            );
359        }
360        if entry
361            .annotations
362            .as_ref()
363            .is_some_and(|annotations| annotations.side_effect_level == SideEffectLevel::None)
364        {
365            diagnostics.push(
366                ToolSurfaceDiagnostic::warning(
367                    "TOOL_SURFACE_MISSING_SIDE_EFFECT_LEVEL",
368                    format!("active tool '{}' has no side-effect level", entry.name),
369                )
370                .with_tool(entry.name.clone())
371                .with_field("side_effect_level"),
372            );
373        }
374        if !entry.has_executor && !entry.provider_native {
375            diagnostics.push(
376                ToolSurfaceDiagnostic::warning(
377                    "TOOL_SURFACE_MISSING_EXECUTOR",
378                    format!("active tool '{}' has no declared executor", entry.name),
379                )
380                .with_tool(entry.name.clone())
381                .with_field("executor"),
382            );
383        }
384        validate_execute_result_routes(entry, &entries, &active_names, &mut diagnostics);
385    }
386
387    validate_arg_constraints(
388        input.policy.as_ref(),
389        &entries,
390        &active_names,
391        &mut diagnostics,
392    );
393    validate_approval_patterns(
394        input.approval_policy.as_ref(),
395        &active_names,
396        &mut diagnostics,
397    );
398    validate_prompt_references(input, &entries, &active_names, &mut diagnostics);
399    validate_side_effect_ceiling(
400        input.policy.as_ref(),
401        &entries,
402        &active_names,
403        &mut diagnostics,
404    );
405
406    diagnostics
407}
408
409pub fn validate_workflow_graph(
410    graph: &crate::orchestration::WorkflowGraph,
411) -> Vec<ToolSurfaceDiagnostic> {
412    let mut diagnostics = Vec::new();
413    diagnostics.extend(
414        validate_tool_surface_diagnostics(&ToolSurfaceInput {
415            tools: None,
416            native_tools: Some(workflow_tools_as_native(
417                &graph.capability_policy,
418                &graph.nodes,
419            )),
420            policy: Some(graph.capability_policy.clone()),
421            approval_policy: Some(graph.approval_policy.clone()),
422            prompt_texts: Vec::new(),
423            tool_search_active: false,
424        })
425        .into_iter()
426        .map(|mut diagnostic| {
427            diagnostic.message = format!("workflow: {}", diagnostic.message);
428            diagnostic
429        }),
430    );
431    for (node_id, node) in &graph.nodes {
432        let prompt_texts = [node.system.clone(), node.prompt.clone()]
433            .into_iter()
434            .flatten()
435            .collect::<Vec<_>>();
436        diagnostics.extend(
437            validate_tool_surface_diagnostics(&ToolSurfaceInput {
438                tools: None,
439                native_tools: Some(workflow_node_tools_as_native(node)),
440                policy: Some(node.capability_policy.clone()),
441                approval_policy: Some(node.approval_policy.clone()),
442                prompt_texts,
443                tool_search_active: false,
444            })
445            .into_iter()
446            .map(|mut diagnostic| {
447                diagnostic.message = format!("node {node_id}: {}", diagnostic.message);
448                diagnostic
449            }),
450        );
451    }
452    diagnostics
453}
454
455pub fn surface_report_to_json(report: &ToolSurfaceReport) -> serde_json::Value {
456    serde_json::to_value(report).unwrap_or_else(|_| serde_json::json!({"valid": false}))
457}
458
459pub fn surface_input_from_vm(surface: &VmValue, options: Option<&VmValue>) -> ToolSurfaceInput {
460    let dict = surface.as_dict();
461    let options_dict = options.and_then(VmValue::as_dict);
462    let tools = dict
463        .and_then(|d| d.get("tools").cloned())
464        .or_else(|| options_dict.and_then(|d| d.get("tools").cloned()))
465        .or_else(|| Some(surface.clone()).filter(is_tool_registry_like));
466    let native_tools = dict
467        .and_then(|d| d.get("native_tools"))
468        .or_else(|| options_dict.and_then(|d| d.get("native_tools")))
469        .map(crate::llm::vm_value_to_json)
470        .and_then(|value| value.as_array().cloned());
471    let policy = dict
472        .and_then(|d| d.get("policy"))
473        .or_else(|| options_dict.and_then(|d| d.get("policy")))
474        .map(crate::llm::vm_value_to_json)
475        .and_then(|value| serde_json::from_value(value).ok());
476    let approval_policy = dict
477        .and_then(|d| d.get("approval_policy"))
478        .or_else(|| options_dict.and_then(|d| d.get("approval_policy")))
479        .map(crate::llm::vm_value_to_json)
480        .and_then(|value| serde_json::from_value(value).ok());
481    let mut prompt_texts = Vec::new();
482    for source in [dict, options_dict].into_iter().flatten() {
483        for key in ["system", "prompt"] {
484            if let Some(text) = source.get(key).map(|value| value.display()) {
485                if !text.is_empty() {
486                    prompt_texts.push(text);
487                }
488            }
489        }
490        if let Some(VmValue::List(items)) = source.get("prompts") {
491            for item in items.iter() {
492                let text = item.display();
493                if !text.is_empty() {
494                    prompt_texts.push(text);
495                }
496            }
497        }
498    }
499    let tool_search_active = dict
500        .and_then(|d| d.get("tool_search"))
501        .or_else(|| options_dict.and_then(|d| d.get("tool_search")))
502        .is_some_and(|value| !matches!(value, VmValue::Bool(false) | VmValue::Nil));
503    ToolSurfaceInput {
504        tools,
505        native_tools,
506        policy,
507        approval_policy,
508        prompt_texts,
509        tool_search_active,
510    }
511}
512
513fn collect_entries(input: &ToolSurfaceInput) -> Vec<ToolEntry> {
514    let mut entries = Vec::new();
515    if let Some(tools) = input.tools.as_ref() {
516        collect_vm_entries(tools, input.policy.as_ref(), &mut entries);
517    }
518    if let Some(native) = input.native_tools.as_ref() {
519        let vm_names: BTreeSet<String> = entries.iter().map(|entry| entry.name.clone()).collect();
520        let mut native_entries = Vec::new();
521        collect_native_entries(native, input.policy.as_ref(), &mut native_entries);
522        entries.extend(
523            native_entries
524                .into_iter()
525                .filter(|entry| !vm_names.contains(&entry.name)),
526        );
527    }
528    entries
529}
530
531fn collect_vm_entries(
532    tools: &VmValue,
533    policy: Option<&CapabilityPolicy>,
534    entries: &mut Vec<ToolEntry>,
535) {
536    let values: Vec<&VmValue> = match tools {
537        VmValue::List(list) => list.iter().collect(),
538        VmValue::Dict(dict) => match dict.get("tools") {
539            Some(VmValue::List(list)) => list.iter().collect(),
540            _ => vec![tools],
541        },
542        _ => Vec::new(),
543    };
544    for value in values {
545        let Some(map) = value.as_dict() else { continue };
546        let name = map
547            .get("name")
548            .map(|value| value.display())
549            .unwrap_or_default();
550        if name.is_empty() {
551            continue;
552        }
553        let (has_schema, parameter_keys) = vm_parameter_keys(map.get("parameters"));
554        let annotations = map
555            .get("annotations")
556            .map(crate::llm::vm_value_to_json)
557            .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
558            .or_else(|| {
559                policy
560                    .and_then(|policy| policy.tool_annotations.get(&name))
561                    .cloned()
562            });
563        let executor = map.get("executor").and_then(|value| match value {
564            VmValue::String(s) => Some(s.to_string()),
565            _ => None,
566        });
567        entries.push(ToolEntry {
568            name,
569            parameter_keys,
570            has_schema,
571            annotations,
572            has_executor: executor.is_some()
573                || matches!(map.get("handler"), Some(VmValue::Closure(_)))
574                || matches!(map.get("_mcp_server"), Some(VmValue::String(_))),
575            defer_loading: matches!(map.get("defer_loading"), Some(VmValue::Bool(true))),
576            provider_native: false,
577        });
578    }
579}
580
581fn collect_native_entries(
582    native_tools: &[serde_json::Value],
583    policy: Option<&CapabilityPolicy>,
584    entries: &mut Vec<ToolEntry>,
585) {
586    for tool in native_tools {
587        let name = tool
588            .get("function")
589            .and_then(|function| function.get("name"))
590            .or_else(|| tool.get("name"))
591            .and_then(|value| value.as_str())
592            .unwrap_or("");
593        if name.is_empty() || name == "tool_search" || name.starts_with("tool_search_tool_") {
594            continue;
595        }
596        let schema = tool
597            .get("function")
598            .and_then(|function| function.get("parameters"))
599            .or_else(|| tool.get("input_schema"))
600            .or_else(|| tool.get("parameters"));
601        let (has_schema, parameter_keys) = json_parameter_keys(schema);
602        let annotations = tool
603            .get("annotations")
604            .or_else(|| {
605                tool.get("function")
606                    .and_then(|function| function.get("annotations"))
607            })
608            .cloned()
609            .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
610            .or_else(|| {
611                policy
612                    .and_then(|policy| policy.tool_annotations.get(name))
613                    .cloned()
614            });
615        entries.push(ToolEntry {
616            name: name.to_string(),
617            parameter_keys,
618            has_schema,
619            annotations,
620            has_executor: true,
621            defer_loading: tool
622                .get("defer_loading")
623                .and_then(|value| value.as_bool())
624                .or_else(|| {
625                    tool.get("function")
626                        .and_then(|function| function.get("defer_loading"))
627                        .and_then(|value| value.as_bool())
628                })
629                .unwrap_or(false),
630            provider_native: true,
631        });
632    }
633}
634
635fn effective_active_names(
636    entries: &[ToolEntry],
637    policy: Option<&CapabilityPolicy>,
638) -> BTreeSet<String> {
639    let policy_tools = policy.map(|policy| policy.tools.as_slice()).unwrap_or(&[]);
640    entries
641        .iter()
642        .filter(|entry| {
643            policy_tools.is_empty()
644                || policy_tools
645                    .iter()
646                    .any(|pattern| crate::orchestration::glob_match(pattern, &entry.name))
647        })
648        .map(|entry| entry.name.clone())
649        .collect()
650}
651
652fn validate_execute_result_routes(
653    entry: &ToolEntry,
654    entries: &[ToolEntry],
655    active_names: &BTreeSet<String>,
656    diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
657) {
658    let Some(annotations) = entry.annotations.as_ref() else {
659        return;
660    };
661    if annotations.kind != ToolKind::Execute || !annotations.emits_artifacts {
662        return;
663    }
664    if annotations.inline_result {
665        return;
666    }
667    let active_reader_declared = annotations
668        .result_readers
669        .iter()
670        .any(|reader| active_names.contains(reader));
671    let command_output_reader = active_names.contains("read_command_output");
672    let read_tool = entries.iter().any(|candidate| {
673        active_names.contains(candidate.name.as_str())
674            && candidate
675                .annotations
676                .as_ref()
677                .is_some_and(|a| a.kind == ToolKind::Read || a.kind == ToolKind::Search)
678    });
679    if !active_reader_declared && !command_output_reader && !read_tool {
680        diagnostics.push(
681            ToolSurfaceDiagnostic::error(
682                "TOOL_SURFACE_MISSING_RESULT_READER",
683                format!(
684                    "execute tool '{}' can emit output artifacts but has no active result reader",
685                    entry.name
686                ),
687            )
688            .with_tool(entry.name.clone())
689            .with_field("result_readers"),
690        );
691    }
692    for reader in &annotations.result_readers {
693        if !active_names.contains(reader) {
694            diagnostics.push(
695                ToolSurfaceDiagnostic::warning(
696                    "TOOL_SURFACE_UNKNOWN_RESULT_READER",
697                    format!(
698                        "tool '{}' declares result reader '{}' that is not active",
699                        entry.name, reader
700                    ),
701                )
702                .with_tool(entry.name.clone())
703                .with_field("result_readers"),
704            );
705        }
706    }
707}
708
709fn validate_arg_constraints(
710    policy: Option<&CapabilityPolicy>,
711    entries: &[ToolEntry],
712    active_names: &BTreeSet<String>,
713    diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
714) {
715    let Some(policy) = policy else { return };
716    for constraint in &policy.tool_arg_constraints {
717        let matched = entries
718            .iter()
719            .filter(|entry| active_names.contains(entry.name.as_str()))
720            .filter(|entry| crate::orchestration::glob_match(&constraint.tool, &entry.name))
721            .collect::<Vec<_>>();
722        if matched.is_empty() && !constraint.tool.contains('*') {
723            diagnostics.push(
724                ToolSurfaceDiagnostic::warning(
725                    "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_TOOL",
726                    format!(
727                        "ToolArgConstraint references tool '{}' which is not active",
728                        constraint.tool
729                    ),
730                )
731                .with_tool(constraint.tool.clone())
732                .with_field("tool_arg_constraints.tool"),
733            );
734        }
735        if let Some(arg_key) = constraint.arg_key.as_ref() {
736            for entry in matched {
737                let annotation_keys = entry
738                    .annotations
739                    .as_ref()
740                    .map(|a| {
741                        a.arg_schema
742                            .path_params
743                            .iter()
744                            .chain(a.arg_schema.required.iter())
745                            .chain(a.arg_schema.arg_aliases.keys())
746                            .chain(a.arg_schema.arg_aliases.values())
747                            .cloned()
748                            .collect::<BTreeSet<_>>()
749                    })
750                    .unwrap_or_default();
751                if !entry.parameter_keys.contains(arg_key) && !annotation_keys.contains(arg_key) {
752                    diagnostics.push(
753                        ToolSurfaceDiagnostic::warning(
754                            "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_KEY",
755                            format!(
756                                "ToolArgConstraint for '{}' targets unknown argument '{}'",
757                                entry.name, arg_key
758                            ),
759                        )
760                        .with_tool(entry.name.clone())
761                        .with_field(format!("tool_arg_constraints.{arg_key}")),
762                    );
763                }
764            }
765        }
766    }
767}
768
769fn validate_approval_patterns(
770    approval: Option<&ToolApprovalPolicy>,
771    active_names: &BTreeSet<String>,
772    diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
773) {
774    let Some(approval) = approval else { return };
775    for (field, patterns) in [
776        ("approval_policy.auto_approve", &approval.auto_approve),
777        ("approval_policy.auto_deny", &approval.auto_deny),
778        (
779            "approval_policy.require_approval",
780            &approval.require_approval,
781        ),
782    ] {
783        for pattern in patterns {
784            if pattern.contains('*') {
785                continue;
786            }
787            if !active_names
788                .iter()
789                .any(|name| crate::orchestration::glob_match(pattern, name))
790            {
791                diagnostics.push(
792                    ToolSurfaceDiagnostic::warning(
793                        "TOOL_SURFACE_APPROVAL_PATTERN_NO_MATCH",
794                        format!("{field} pattern '{pattern}' matches no active tool"),
795                    )
796                    .with_field(field),
797                );
798            }
799        }
800    }
801}
802
803fn validate_prompt_references(
804    input: &ToolSurfaceInput,
805    entries: &[ToolEntry],
806    active_names: &BTreeSet<String>,
807    diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
808) {
809    let deferred = entries
810        .iter()
811        .filter(|entry| entry.defer_loading)
812        .map(|entry| entry.name.clone())
813        .collect::<BTreeSet<_>>();
814    let known_names = entries
815        .iter()
816        .map(|entry| entry.name.clone())
817        .chain(active_names.iter().cloned())
818        .collect::<BTreeSet<_>>();
819    for text in &input.prompt_texts {
820        let binding_text = prompt_binding_text(text);
821        let calls = prompt_tool_calls(&binding_text);
822        for call in &calls {
823            let name = call.name;
824            if !known_names.contains(name) && looks_like_tool_name(name) {
825                diagnostics.push(
826                    ToolSurfaceDiagnostic::warning(
827                        "TOOL_SURFACE_UNKNOWN_PROMPT_TOOL",
828                        format!("prompt references tool '{name}' which is not active"),
829                    )
830                    .with_tool(name.to_string())
831                    .with_field("prompt"),
832                );
833                continue;
834            }
835            if known_names.contains(name) && !active_names.contains(name) {
836                diagnostics.push(
837                    ToolSurfaceDiagnostic::warning(
838                        "TOOL_SURFACE_PROMPT_TOOL_NOT_IN_POLICY",
839                        format!("prompt references tool '{name}' outside the active policy"),
840                    )
841                    .with_tool(name.to_string())
842                    .with_field("prompt"),
843                );
844            }
845            if deferred.contains(name) && !input.tool_search_active {
846                diagnostics.push(
847                    ToolSurfaceDiagnostic::warning(
848                        "TOOL_SURFACE_DEFERRED_TOOL_PROMPT_REFERENCE",
849                        format!(
850                            "prompt references deferred tool '{name}' but tool_search is not active"
851                        ),
852                    )
853                    .with_tool(name.to_string())
854                    .with_field("prompt"),
855                );
856            }
857        }
858        for entry in entries {
859            let Some(annotations) = entry.annotations.as_ref() else {
860                continue;
861            };
862            for (alias, canonical) in &annotations.arg_schema.arg_aliases {
863                if calls
864                    .iter()
865                    .any(|call| call.name == entry.name && contains_token(call.text, alias))
866                {
867                    diagnostics.push(
868                        ToolSurfaceDiagnostic::warning(
869                            "TOOL_SURFACE_DEPRECATED_ARG_ALIAS",
870                            format!(
871                                "prompt mentions alias '{}' for tool '{}'; use canonical argument '{}'",
872                                alias, entry.name, canonical
873                            ),
874                        )
875                        .with_tool(entry.name.clone())
876                        .with_field(format!("arg_schema.arg_aliases.{alias}")),
877                    );
878                }
879            }
880        }
881    }
882}
883
884struct PromptToolCall<'a> {
885    name: &'a str,
886    text: &'a str,
887}
888
889fn prompt_tool_calls(text: &str) -> Vec<PromptToolCall<'_>> {
890    let mut calls = Vec::new();
891    let bytes = text.as_bytes();
892    let mut i = 0usize;
893    while i < bytes.len() {
894        if let Some((open_tag, close_tag)) = text_tool_call_tag_pairs()
895            .into_iter()
896            .find(|(open_tag, _)| bytes[i..].starts_with(open_tag.as_bytes()))
897        {
898            let call_start = i;
899            i += open_tag.len();
900            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
901                i += 1;
902            }
903            let name_start = i;
904            while i < bytes.len() && is_ident_byte(bytes[i]) {
905                i += 1;
906            }
907            if i > name_start {
908                let call_end = text[i..]
909                    .find(close_tag)
910                    .map(|offset| i + offset + close_tag.len())
911                    .unwrap_or(i);
912                calls.push(PromptToolCall {
913                    name: &text[name_start..i],
914                    text: &text[call_start..call_end],
915                });
916                i = call_end;
917            }
918            continue;
919        }
920
921        if !is_ident_start(bytes[i]) {
922            i += 1;
923            continue;
924        }
925
926        let start = i;
927        i += 1;
928        while i < bytes.len() && is_ident_byte(bytes[i]) {
929            i += 1;
930        }
931
932        let name = &text[start..i];
933        let mut j = i;
934        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
935            j += 1;
936        }
937        if j < bytes.len() && bytes[j] == b'(' && !prompt_ref_stopword(name) {
938            let end = prompt_call_end(bytes, j);
939            calls.push(PromptToolCall {
940                name,
941                text: &text[start..end],
942            });
943            i = end;
944            continue;
945        }
946    }
947    calls
948}
949
950fn prompt_call_end(bytes: &[u8], open_index: usize) -> usize {
951    let mut depth = 0usize;
952    let mut quote = None;
953    let mut escaped = false;
954    let mut i = open_index;
955    while i < bytes.len() {
956        let byte = bytes[i];
957        if let Some(quote_byte) = quote {
958            if escaped {
959                escaped = false;
960            } else if byte == b'\\' {
961                escaped = true;
962            } else if byte == quote_byte {
963                quote = None;
964            }
965            i += 1;
966            continue;
967        }
968
969        match byte {
970            b'\'' | b'"' | b'`' => quote = Some(byte),
971            b'(' => depth += 1,
972            b')' => {
973                depth = depth.saturating_sub(1);
974                if depth == 0 {
975                    return i + 1;
976                }
977            }
978            _ => {}
979        }
980        i += 1;
981    }
982    bytes.len()
983}
984
985fn validate_side_effect_ceiling(
986    policy: Option<&CapabilityPolicy>,
987    entries: &[ToolEntry],
988    active_names: &BTreeSet<String>,
989    diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
990) {
991    let Some(policy) = policy else { return };
992    let Some(ceiling) = policy
993        .side_effect_level
994        .as_deref()
995        .map(SideEffectLevel::parse)
996    else {
997        return;
998    };
999    for entry in entries
1000        .iter()
1001        .filter(|entry| active_names.contains(entry.name.as_str()))
1002    {
1003        let Some(level) = entry.annotations.as_ref().map(|a| a.side_effect_level) else {
1004            continue;
1005        };
1006        if level.rank() > ceiling.rank() {
1007            diagnostics.push(
1008                ToolSurfaceDiagnostic::error(
1009                    "TOOL_SURFACE_SIDE_EFFECT_CEILING_EXCEEDED",
1010                    format!(
1011                        "tool '{}' requires side-effect level '{}' but policy ceiling is '{}'",
1012                        entry.name,
1013                        level.as_str(),
1014                        ceiling.as_str()
1015                    ),
1016                )
1017                .with_tool(entry.name.clone())
1018                .with_field("side_effect_level"),
1019            );
1020        }
1021    }
1022}
1023
1024pub fn prompt_tool_references(text: &str) -> BTreeSet<String> {
1025    let text = prompt_binding_text(text);
1026    prompt_tool_calls(&text)
1027        .into_iter()
1028        .map(|call| call.name.to_string())
1029        .collect()
1030}
1031
1032fn prompt_binding_text(text: &str) -> String {
1033    let mut out = String::new();
1034    let mut in_fence = false;
1035    let mut ignore_block = false;
1036    let mut ignore_next = false;
1037    for line in text.lines() {
1038        let trimmed = line.trim();
1039        if trimmed.starts_with("```") {
1040            in_fence = !in_fence;
1041            continue;
1042        }
1043        if trimmed.contains("harn-tool-surface: ignore-start") {
1044            ignore_block = true;
1045            continue;
1046        }
1047        if trimmed.contains("harn-tool-surface: ignore-end") {
1048            ignore_block = false;
1049            continue;
1050        }
1051        if trimmed.contains("harn-tool-surface: ignore-next-line") {
1052            ignore_next = true;
1053            continue;
1054        }
1055        if in_fence
1056            || ignore_block
1057            || trimmed.contains("harn-tool-surface: ignore-line")
1058            || trimmed.contains("tool-surface-ignore")
1059        {
1060            continue;
1061        }
1062        if ignore_next {
1063            ignore_next = false;
1064            continue;
1065        }
1066        out.push_str(line);
1067        out.push('\n');
1068    }
1069    out
1070}
1071
1072fn prompt_ref_stopword(name: &str) -> bool {
1073    matches!(
1074        name,
1075        "if" | "for"
1076            | "while"
1077            | "switch"
1078            | "return"
1079            | "function"
1080            | "fn"
1081            | "JSON"
1082            | "print"
1083            | "println"
1084            | "contains"
1085            | "len"
1086            | "render"
1087            | "render_prompt"
1088    )
1089}
1090
1091fn looks_like_tool_name(name: &str) -> bool {
1092    name.contains('_') || name.starts_with("tool") || name.starts_with("run")
1093}
1094
1095fn contains_token(text: &str, needle: &str) -> bool {
1096    let bytes = text.as_bytes();
1097    let needle_bytes = needle.as_bytes();
1098    if needle_bytes.is_empty() || bytes.len() < needle_bytes.len() {
1099        return false;
1100    }
1101    for i in 0..=bytes.len() - needle_bytes.len() {
1102        if &bytes[i..i + needle_bytes.len()] != needle_bytes {
1103            continue;
1104        }
1105        let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
1106        let after = i + needle_bytes.len();
1107        let after_ok = after == bytes.len() || !is_ident_byte(bytes[after]);
1108        if before_ok && after_ok {
1109            return true;
1110        }
1111    }
1112    false
1113}
1114
1115fn is_ident_start(byte: u8) -> bool {
1116    byte.is_ascii_alphabetic() || byte == b'_'
1117}
1118
1119fn is_ident_byte(byte: u8) -> bool {
1120    byte.is_ascii_alphanumeric() || byte == b'_'
1121}
1122
1123fn is_tool_registry_like(value: &VmValue) -> bool {
1124    value.as_dict().is_some_and(|dict| {
1125        dict.get("_type")
1126            .is_some_and(|value| value.display() == "tool_registry")
1127            || dict.contains_key("tools")
1128    })
1129}
1130
1131fn vm_parameter_keys(value: Option<&VmValue>) -> (bool, BTreeSet<String>) {
1132    let Some(value) = value else {
1133        return (false, BTreeSet::new());
1134    };
1135    let json = crate::llm::vm_value_to_json(value);
1136    json_parameter_keys(Some(&json))
1137}
1138
1139fn json_parameter_keys(value: Option<&serde_json::Value>) -> (bool, BTreeSet<String>) {
1140    let Some(value) = value else {
1141        return (false, BTreeSet::new());
1142    };
1143    let mut keys = BTreeSet::new();
1144    if let Some(properties) = value.get("properties").and_then(|value| value.as_object()) {
1145        keys.extend(properties.keys().cloned());
1146    } else if let Some(map) = value.as_object() {
1147        for key in map.keys() {
1148            if key != "type" && key != "required" && key != "description" {
1149                keys.insert(key.clone());
1150            }
1151        }
1152    }
1153    (true, keys)
1154}
1155
1156fn workflow_node_tools_as_native(
1157    node: &crate::orchestration::WorkflowNode,
1158) -> Vec<serde_json::Value> {
1159    match &node.tools {
1160        serde_json::Value::Array(items) => items.clone(),
1161        serde_json::Value::Object(_) => vec![node.tools.clone()],
1162        _ => Vec::new(),
1163    }
1164}
1165
1166fn workflow_tools_as_native(
1167    policy: &CapabilityPolicy,
1168    nodes: &BTreeMap<String, crate::orchestration::WorkflowNode>,
1169) -> Vec<serde_json::Value> {
1170    let mut tools = Vec::new();
1171    let mut seen = BTreeSet::new();
1172    for node in nodes.values() {
1173        for tool in workflow_node_tools_as_native(node) {
1174            let name = tool
1175                .get("name")
1176                .and_then(|value| value.as_str())
1177                .unwrap_or("")
1178                .to_string();
1179            if !name.is_empty() && seen.insert(name) {
1180                tools.push(tool);
1181            }
1182        }
1183    }
1184    for (name, annotations) in &policy.tool_annotations {
1185        if seen.insert(name.clone()) {
1186            tools.push(serde_json::json!({
1187                "name": name,
1188                "parameters": {"type": "object"},
1189                "annotations": annotations,
1190                "executor": "host_bridge",
1191            }));
1192        }
1193    }
1194    tools
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199    use super::*;
1200    use crate::orchestration::ToolArgConstraint;
1201    use crate::tool_annotations::ToolArgSchema;
1202
1203    fn execute_annotations() -> ToolAnnotations {
1204        ToolAnnotations {
1205            kind: ToolKind::Execute,
1206            side_effect_level: SideEffectLevel::ProcessExec,
1207            emits_artifacts: true,
1208            ..ToolAnnotations::default()
1209        }
1210    }
1211
1212    #[test]
1213    fn execute_artifact_tool_requires_reader() {
1214        let mut policy = CapabilityPolicy::default();
1215        policy
1216            .tool_annotations
1217            .insert("run".into(), execute_annotations());
1218        let tools = VmValue::Dict(std::rc::Rc::new(BTreeMap::from([
1219            (
1220                "_type".into(),
1221                VmValue::String(std::rc::Rc::from("tool_registry")),
1222            ),
1223            (
1224                "tools".into(),
1225                VmValue::List(std::rc::Rc::new(vec![VmValue::Dict(std::rc::Rc::new(
1226                    BTreeMap::from([
1227                        ("name".into(), VmValue::String(std::rc::Rc::from("run"))),
1228                        (
1229                            "parameters".into(),
1230                            VmValue::Dict(std::rc::Rc::new(BTreeMap::new())),
1231                        ),
1232                        (
1233                            "executor".into(),
1234                            VmValue::String(std::rc::Rc::from("host_bridge")),
1235                        ),
1236                    ]),
1237                ))])),
1238            ),
1239        ])));
1240        let report = validate_tool_surface(&ToolSurfaceInput {
1241            tools: Some(tools),
1242            policy: Some(policy),
1243            ..ToolSurfaceInput::default()
1244        });
1245        assert!(report.diagnostics.iter().any(|d| {
1246            d.code == "TOOL_SURFACE_MISSING_RESULT_READER"
1247                && d.severity == ToolSurfaceSeverity::Error
1248        }));
1249        assert!(!report.valid);
1250    }
1251
1252    #[test]
1253    fn execute_artifact_tool_accepts_inline_escape_hatch() {
1254        let mut annotations = execute_annotations();
1255        annotations.inline_result = true;
1256        let mut policy = CapabilityPolicy::default();
1257        policy.tool_annotations.insert("run".into(), annotations);
1258        let report = validate_tool_surface(&ToolSurfaceInput {
1259            native_tools: Some(vec![serde_json::json!({
1260                "name": "run",
1261                "parameters": {"type": "object"},
1262            })]),
1263            policy: Some(policy),
1264            ..ToolSurfaceInput::default()
1265        });
1266        assert!(!report
1267            .diagnostics
1268            .iter()
1269            .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
1270    }
1271
1272    #[test]
1273    fn native_tool_annotations_are_read_from_tool_json() {
1274        let mut annotations = execute_annotations();
1275        annotations.inline_result = true;
1276        let report = validate_tool_surface(&ToolSurfaceInput {
1277            native_tools: Some(vec![serde_json::json!({
1278                "name": "run",
1279                "parameters": {"type": "object"},
1280                "annotations": annotations,
1281            })]),
1282            ..ToolSurfaceInput::default()
1283        });
1284        assert!(!report
1285            .diagnostics
1286            .iter()
1287            .any(|d| d.code == "TOOL_SURFACE_MISSING_ANNOTATIONS"));
1288        assert!(!report
1289            .diagnostics
1290            .iter()
1291            .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
1292    }
1293
1294    #[test]
1295    fn prompt_reference_outside_policy_is_reported() {
1296        let policy = CapabilityPolicy {
1297            tools: vec!["read_file".into()],
1298            ..CapabilityPolicy::default()
1299        };
1300        let report = validate_tool_surface(&ToolSurfaceInput {
1301            native_tools: Some(vec![
1302                serde_json::json!({"name": "read_file", "parameters": {"type": "object"}}),
1303                serde_json::json!({"name": "run_command", "parameters": {"type": "object"}}),
1304            ]),
1305            policy: Some(policy),
1306            prompt_texts: vec!["Use run_command({command: \"cargo test\"})".into()],
1307            ..ToolSurfaceInput::default()
1308        });
1309        assert!(report
1310            .diagnostics
1311            .iter()
1312            .any(|d| d.code == "TOOL_SURFACE_PROMPT_TOOL_NOT_IN_POLICY"));
1313    }
1314
1315    #[test]
1316    fn prompt_suppression_ignores_examples() {
1317        let report = validate_tool_surface(&ToolSurfaceInput {
1318            native_tools: Some(vec![serde_json::json!({
1319                "name": "read_file",
1320                "parameters": {"type": "object"},
1321            })]),
1322            prompt_texts: vec![
1323                "```text\nrun_command({command: \"old\"})\n```\n<!-- harn-tool-surface: ignore-next-line -->\nrun_command({command: \"old\"})".into(),
1324            ],
1325            ..ToolSurfaceInput::default()
1326        });
1327        assert!(!report
1328            .diagnostics
1329            .iter()
1330            .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_PROMPT_TOOL"));
1331    }
1332
1333    #[test]
1334    fn deprecated_alias_warnings_are_scoped_to_matching_tool_calls() {
1335        let mut edit_annotations = ToolAnnotations::default();
1336        edit_annotations
1337            .arg_schema
1338            .arg_aliases
1339            .insert("file".into(), "path".into());
1340        let mut look_annotations = ToolAnnotations::default();
1341        look_annotations
1342            .arg_schema
1343            .arg_aliases
1344            .insert("path".into(), "file".into());
1345
1346        let report = validate_tool_surface(&ToolSurfaceInput {
1347            native_tools: Some(vec![
1348                serde_json::json!({
1349                    "name": "edit",
1350                    "parameters": {"type": "object"},
1351                    "annotations": edit_annotations,
1352                }),
1353                serde_json::json!({
1354                    "name": "look",
1355                    "parameters": {"type": "object"},
1356                    "annotations": look_annotations,
1357                }),
1358            ]),
1359            prompt_texts: vec![
1360                "Use edit({ path: \"src/main.rs\", action: \"replace\" }) before look({ file: \"src/main.rs\" }).".into(),
1361            ],
1362            ..ToolSurfaceInput::default()
1363        });
1364
1365        assert!(!report
1366            .diagnostics
1367            .iter()
1368            .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1369    }
1370
1371    #[test]
1372    fn deprecated_alias_warnings_still_report_matching_multiline_calls() {
1373        let mut annotations = ToolAnnotations::default();
1374        annotations
1375            .arg_schema
1376            .arg_aliases
1377            .insert("file".into(), "path".into());
1378
1379        let report = validate_tool_surface(&ToolSurfaceInput {
1380            native_tools: Some(vec![serde_json::json!({
1381                "name": "edit",
1382                "parameters": {"type": "object"},
1383                "annotations": annotations,
1384            })]),
1385            prompt_texts: vec!["Use edit({\n  file: \"src/main.rs\"\n}) once.".into()],
1386            ..ToolSurfaceInput::default()
1387        });
1388
1389        assert!(report
1390            .diagnostics
1391            .iter()
1392            .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1393    }
1394
1395    #[test]
1396    fn deprecated_alias_warnings_report_tagged_text_mode_calls() {
1397        let mut annotations = ToolAnnotations::default();
1398        annotations
1399            .arg_schema
1400            .arg_aliases
1401            .insert("file".into(), "path".into());
1402
1403        let report = validate_tool_surface(&ToolSurfaceInput {
1404            native_tools: Some(vec![serde_json::json!({
1405                "name": "edit",
1406                "parameters": {"type": "object"},
1407                "annotations": annotations,
1408            })]),
1409            prompt_texts: vec!["<tool_call>\nedit({ file: \"src/main.rs\" })\n</tool_call>".into()],
1410            ..ToolSurfaceInput::default()
1411        });
1412
1413        assert!(report
1414            .diagnostics
1415            .iter()
1416            .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1417    }
1418
1419    #[test]
1420    fn prompt_reference_scanner_tolerates_non_ascii_text() {
1421        let references = prompt_tool_references("Résumé: use run_command({command: \"test\"})");
1422        assert!(references.contains("run_command"));
1423    }
1424
1425    #[test]
1426    fn prompt_reference_scanner_reads_tagged_text_mode_calls() {
1427        let references =
1428            prompt_tool_references("<tool_call>\nrun({ command: \"cargo test\" })\n</tool_call>");
1429        assert!(references.contains("run"));
1430    }
1431
1432    #[test]
1433    fn arg_constraint_key_must_exist() {
1434        let mut annotations = ToolAnnotations {
1435            kind: ToolKind::Read,
1436            side_effect_level: SideEffectLevel::ReadOnly,
1437            arg_schema: ToolArgSchema {
1438                path_params: vec!["path".into()],
1439                ..ToolArgSchema::default()
1440            },
1441            ..ToolAnnotations::default()
1442        };
1443        annotations.arg_schema.required.push("path".into());
1444        let mut policy = CapabilityPolicy {
1445            tool_arg_constraints: vec![ToolArgConstraint {
1446                tool: "read_file".into(),
1447                arg_key: Some("missing".into()),
1448                arg_patterns: vec!["src/**".into()],
1449            }],
1450            ..CapabilityPolicy::default()
1451        };
1452        policy
1453            .tool_annotations
1454            .insert("read_file".into(), annotations);
1455        let report = validate_tool_surface(&ToolSurfaceInput {
1456            native_tools: Some(vec![serde_json::json!({
1457                "name": "read_file",
1458                "parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
1459            })]),
1460            policy: Some(policy),
1461            ..ToolSurfaceInput::default()
1462        });
1463        assert!(report
1464            .diagnostics
1465            .iter()
1466            .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_KEY"));
1467    }
1468}