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