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