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
933#[expect(
934    clippy::string_slice,
935    reason = "every slice index sits on an ASCII tag/ident/paren byte or at text.len()"
936)]
937fn prompt_tool_calls(text: &str) -> Vec<PromptToolCall<'_>> {
938    let mut calls = Vec::new();
939    let bytes = text.as_bytes();
940    let mut i = 0usize;
941    while i < bytes.len() {
942        if let Some((open_tag, close_tag)) = text_tool_call_tag_pairs()
943            .into_iter()
944            .find(|(open_tag, _)| bytes[i..].starts_with(open_tag.as_bytes()))
945        {
946            let call_start = i;
947            i += open_tag.len();
948            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
949                i += 1;
950            }
951            let name_start = i;
952            while i < bytes.len() && is_ident_byte(bytes[i]) {
953                i += 1;
954            }
955            if i > name_start {
956                let call_end = text[i..]
957                    .find(close_tag)
958                    .map(|offset| i + offset + close_tag.len())
959                    .unwrap_or(i);
960                calls.push(PromptToolCall {
961                    name: &text[name_start..i],
962                    text: &text[call_start..call_end],
963                });
964                i = call_end;
965            }
966            continue;
967        }
968
969        if !is_ident_start(bytes[i]) {
970            i += 1;
971            continue;
972        }
973
974        let start = i;
975        i += 1;
976        while i < bytes.len() && is_ident_byte(bytes[i]) {
977            i += 1;
978        }
979
980        let name = &text[start..i];
981        let mut j = i;
982        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
983            j += 1;
984        }
985        if j < bytes.len() && bytes[j] == b'(' && !prompt_ref_stopword(name) {
986            let end = prompt_call_end(bytes, j);
987            calls.push(PromptToolCall {
988                name,
989                text: &text[start..end],
990            });
991            i = end;
992            continue;
993        }
994    }
995    calls
996}
997
998fn prompt_call_end(bytes: &[u8], open_index: usize) -> usize {
999    let mut depth = 0usize;
1000    let mut quote = None;
1001    let mut escaped = false;
1002    let mut i = open_index;
1003    while i < bytes.len() {
1004        let byte = bytes[i];
1005        if let Some(quote_byte) = quote {
1006            if escaped {
1007                escaped = false;
1008            } else if byte == b'\\' {
1009                escaped = true;
1010            } else if byte == quote_byte {
1011                quote = None;
1012            }
1013            i += 1;
1014            continue;
1015        }
1016
1017        match byte {
1018            b'\'' | b'"' | b'`' => quote = Some(byte),
1019            b'(' => depth += 1,
1020            b')' => {
1021                depth = depth.saturating_sub(1);
1022                if depth == 0 {
1023                    return i + 1;
1024                }
1025            }
1026            _ => {}
1027        }
1028        i += 1;
1029    }
1030    bytes.len()
1031}
1032
1033fn validate_side_effect_ceiling(
1034    policy: Option<&CapabilityPolicy>,
1035    entries: &[ToolEntry],
1036    active_names: &BTreeSet<String>,
1037    diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
1038) {
1039    let Some(policy) = policy else { return };
1040    let Some(ceiling) = policy
1041        .side_effect_level
1042        .as_deref()
1043        .map(SideEffectLevel::parse)
1044    else {
1045        return;
1046    };
1047    for entry in entries
1048        .iter()
1049        .filter(|entry| active_names.contains(entry.name.as_str()))
1050    {
1051        let Some(level) = entry.annotations.as_ref().map(|a| a.side_effect_level) else {
1052            continue;
1053        };
1054        if level.rank() > ceiling.rank() {
1055            diagnostics.push(
1056                ToolSurfaceDiagnostic::error(
1057                    "TOOL_SURFACE_SIDE_EFFECT_CEILING_EXCEEDED",
1058                    format!(
1059                        "tool '{}' requires side-effect level '{}' but policy ceiling is '{}'",
1060                        entry.name,
1061                        level.as_str(),
1062                        ceiling.as_str()
1063                    ),
1064                )
1065                .with_tool(entry.name.clone())
1066                .with_field("side_effect_level"),
1067            );
1068        }
1069    }
1070}
1071
1072pub fn prompt_tool_references(text: &str) -> BTreeSet<String> {
1073    let text = prompt_binding_text(text);
1074    prompt_tool_calls(&text)
1075        .into_iter()
1076        .map(|call| call.name.to_string())
1077        .collect()
1078}
1079
1080fn prompt_binding_text(text: &str) -> String {
1081    let mut out = String::new();
1082    let mut in_fence = false;
1083    let mut ignore_block = false;
1084    let mut ignore_next = false;
1085    for line in text.lines() {
1086        let trimmed = line.trim();
1087        if trimmed.starts_with("```") {
1088            in_fence = !in_fence;
1089            continue;
1090        }
1091        if trimmed.contains("harn-tool-surface: ignore-start") {
1092            ignore_block = true;
1093            continue;
1094        }
1095        if trimmed.contains("harn-tool-surface: ignore-end") {
1096            ignore_block = false;
1097            continue;
1098        }
1099        if trimmed.contains("harn-tool-surface: ignore-next-line") {
1100            ignore_next = true;
1101            continue;
1102        }
1103        if in_fence
1104            || ignore_block
1105            || trimmed.contains("harn-tool-surface: ignore-line")
1106            || trimmed.contains("tool-surface-ignore")
1107        {
1108            continue;
1109        }
1110        if ignore_next {
1111            ignore_next = false;
1112            continue;
1113        }
1114        out.push_str(line);
1115        out.push('\n');
1116    }
1117    out
1118}
1119
1120fn prompt_ref_stopword(name: &str) -> bool {
1121    matches!(
1122        name,
1123        "if" | "for"
1124            | "while"
1125            | "switch"
1126            | "return"
1127            | "function"
1128            | "fn"
1129            | "JSON"
1130            | "print"
1131            | "println"
1132            | "contains"
1133            | "len"
1134            | "render"
1135            | "render_prompt"
1136    )
1137}
1138
1139fn looks_like_tool_name(name: &str) -> bool {
1140    name.contains('_') || name.starts_with("tool") || name.starts_with("run")
1141}
1142
1143fn contains_token(text: &str, needle: &str) -> bool {
1144    let bytes = text.as_bytes();
1145    let needle_bytes = needle.as_bytes();
1146    if needle_bytes.is_empty() || bytes.len() < needle_bytes.len() {
1147        return false;
1148    }
1149    for i in 0..=bytes.len() - needle_bytes.len() {
1150        if &bytes[i..i + needle_bytes.len()] != needle_bytes {
1151            continue;
1152        }
1153        let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
1154        let after = i + needle_bytes.len();
1155        let after_ok = after == bytes.len() || !is_ident_byte(bytes[after]);
1156        if before_ok && after_ok {
1157            return true;
1158        }
1159    }
1160    false
1161}
1162
1163fn is_ident_start(byte: u8) -> bool {
1164    byte.is_ascii_alphabetic() || byte == b'_'
1165}
1166
1167fn is_ident_byte(byte: u8) -> bool {
1168    byte.is_ascii_alphanumeric() || byte == b'_'
1169}
1170
1171fn is_tool_registry_like(value: &VmValue) -> bool {
1172    value.as_dict().is_some_and(|dict| {
1173        dict.get("_type")
1174            .is_some_and(|value| value.display() == "tool_registry")
1175            || dict.contains_key("tools")
1176    })
1177}
1178
1179fn vm_parameter_keys(value: Option<&VmValue>) -> (bool, BTreeSet<String>) {
1180    let Some(value) = value else {
1181        return (false, BTreeSet::new());
1182    };
1183    let json = crate::llm::vm_value_to_json(value);
1184    json_parameter_keys(Some(&json))
1185}
1186
1187fn json_parameter_keys(value: Option<&serde_json::Value>) -> (bool, BTreeSet<String>) {
1188    let Some(value) = value else {
1189        return (false, BTreeSet::new());
1190    };
1191    let mut keys = BTreeSet::new();
1192    if let Some(properties) = value.get("properties").and_then(|value| value.as_object()) {
1193        keys.extend(properties.keys().cloned());
1194    } else if let Some(map) = value.as_object() {
1195        for key in map.keys() {
1196            if key != "type" && key != "required" && key != "description" {
1197                keys.insert(key.clone());
1198            }
1199        }
1200    }
1201    (true, keys)
1202}
1203
1204fn workflow_node_tools_as_native(
1205    node: &crate::orchestration::WorkflowNode,
1206) -> Vec<serde_json::Value> {
1207    match &node.tools {
1208        serde_json::Value::Array(items) => items.clone(),
1209        serde_json::Value::Object(_) => vec![node.tools.clone()],
1210        _ => Vec::new(),
1211    }
1212}
1213
1214fn workflow_tools_as_native(
1215    policy: &CapabilityPolicy,
1216    nodes: &BTreeMap<String, crate::orchestration::WorkflowNode>,
1217) -> Vec<serde_json::Value> {
1218    let mut tools = Vec::new();
1219    let mut seen = BTreeSet::new();
1220    for node in nodes.values() {
1221        for tool in workflow_node_tools_as_native(node) {
1222            let name = tool
1223                .get("name")
1224                .and_then(|value| value.as_str())
1225                .unwrap_or("")
1226                .to_string();
1227            if !name.is_empty() && seen.insert(name) {
1228                tools.push(tool);
1229            }
1230        }
1231    }
1232    for (name, annotations) in &policy.tool_annotations {
1233        if seen.insert(name.clone()) {
1234            tools.push(serde_json::json!({
1235                "name": name,
1236                "parameters": {"type": "object"},
1237                "annotations": annotations,
1238                "executor": "host_bridge",
1239            }));
1240        }
1241    }
1242    tools
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247    use super::*;
1248    use crate::orchestration::ToolArgConstraint;
1249    use crate::tool_annotations::ToolArgSchema;
1250
1251    fn execute_annotations() -> ToolAnnotations {
1252        ToolAnnotations {
1253            kind: ToolKind::Execute,
1254            side_effect_level: SideEffectLevel::ProcessExec,
1255            emits_artifacts: true,
1256            ..ToolAnnotations::default()
1257        }
1258    }
1259
1260    #[test]
1261    fn tool_policy_preserves_agent_loop_transport_ceiling() {
1262        let mut annotations = ToolAnnotations {
1263            kind: ToolKind::Search,
1264            side_effect_level: SideEffectLevel::ReadOnly,
1265            ..ToolAnnotations::default()
1266        };
1267        annotations
1268            .capabilities
1269            .insert("workspace".into(), vec!["read_text".into()]);
1270        let policy = tool_capability_policy_from_spec(&serde_json::json!({
1271            "_type": "tool_registry",
1272            "tools": [
1273                {
1274                    "name": "look",
1275                    "parameters": {"type": "object"},
1276                    "policy": annotations
1277                }
1278            ]
1279        }));
1280
1281        assert_eq!(policy.tools, vec!["look".to_string()]);
1282        assert_eq!(policy.side_effect_level.as_deref(), Some("read_only"));
1283        assert!(policy
1284            .capabilities
1285            .get("llm")
1286            .is_some_and(|ops| ops.contains(&"call".to_string())));
1287        assert!(policy
1288            .capabilities
1289            .get("workspace")
1290            .is_some_and(|ops| ops.contains(&"read_text".to_string())));
1291    }
1292
1293    #[test]
1294    fn tool_policy_preserves_dependency_key_params() {
1295        let policy = tool_capability_policy_from_spec(&serde_json::json!({
1296            "_type": "tool_registry",
1297            "tools": [
1298                {
1299                    "name": "edit",
1300                    "parameters": {"type": "object"},
1301                    "policy": {
1302                        "kind": "edit",
1303                        "side_effect_level": "workspace_write",
1304                        "arg_schema": {
1305                            "path_params": ["path"],
1306                            "dependency_key_params": ["anchor"],
1307                            "dependency_range_params": [{"start": "range_start", "end": "range_end"}]
1308                        }
1309                    }
1310                },
1311                {
1312                    "name": "edit_direct",
1313                    "parameters": {"type": "object"},
1314                    "policy": {
1315                        "kind": "edit",
1316                        "side_effect_level": "workspace_write",
1317                        "path_params": ["path"],
1318                        "dependency_key_params": ["old_string"],
1319                        "dependency_range_params": [{"start": "line"}]
1320                    }
1321                }
1322            ]
1323        }));
1324
1325        let annotations = policy.tool_annotations.get("edit").unwrap();
1326        assert_eq!(annotations.arg_schema.path_params, vec!["path".to_string()]);
1327        assert_eq!(
1328            annotations.arg_schema.dependency_key_params,
1329            vec!["anchor".to_string()]
1330        );
1331        assert_eq!(annotations.arg_schema.dependency_range_params.len(), 1);
1332        assert_eq!(
1333            annotations.arg_schema.dependency_range_params[0].start,
1334            "range_start"
1335        );
1336        assert_eq!(
1337            annotations.arg_schema.dependency_range_params[0].end,
1338            "range_end"
1339        );
1340        let direct_annotations = policy.tool_annotations.get("edit_direct").unwrap();
1341        assert_eq!(
1342            direct_annotations.arg_schema.dependency_key_params,
1343            vec!["old_string".to_string()]
1344        );
1345        assert_eq!(
1346            direct_annotations.arg_schema.dependency_range_params.len(),
1347            1
1348        );
1349        assert_eq!(
1350            direct_annotations.arg_schema.dependency_range_params[0].start,
1351            "line"
1352        );
1353        assert_eq!(
1354            direct_annotations.arg_schema.dependency_range_params[0].end,
1355            ""
1356        );
1357    }
1358
1359    #[test]
1360    fn tool_policy_without_capabilities_keeps_capability_ceiling_unspecified() {
1361        let policy = tool_capability_policy_from_spec(&serde_json::json!({
1362            "_type": "tool_registry",
1363            "tools": [
1364                {
1365                    "name": "look",
1366                    "parameters": {"type": "object"}
1367                }
1368            ]
1369        }));
1370
1371        assert_eq!(policy.tools, vec!["look".to_string()]);
1372        assert!(policy.capabilities.is_empty());
1373        assert!(policy.side_effect_level.is_none());
1374    }
1375
1376    #[test]
1377    fn execute_artifact_tool_requires_reader() {
1378        let mut policy = CapabilityPolicy::default();
1379        policy
1380            .tool_annotations
1381            .insert("run".into(), execute_annotations());
1382        let tools = VmValue::dict(std::collections::BTreeMap::<String, VmValue>::from_iter([
1383            (
1384                "_type".into(),
1385                VmValue::String(arcstr::ArcStr::from("tool_registry")),
1386            ),
1387            (
1388                "tools".into(),
1389                VmValue::List(std::sync::Arc::new(vec![VmValue::Dict(
1390                    std::sync::Arc::new(crate::value::DictMap::from_iter([
1391                        (
1392                            crate::value::intern_key("name"),
1393                            VmValue::String(arcstr::ArcStr::from("run")),
1394                        ),
1395                        (
1396                            crate::value::intern_key("parameters"),
1397                            VmValue::dict(crate::value::DictMap::new()),
1398                        ),
1399                        (
1400                            crate::value::intern_key("executor"),
1401                            VmValue::String(arcstr::ArcStr::from("host_bridge")),
1402                        ),
1403                    ])),
1404                )])),
1405            ),
1406        ]));
1407        let report = validate_tool_surface(&ToolSurfaceInput {
1408            tools: Some(tools),
1409            policy: Some(policy),
1410            ..ToolSurfaceInput::default()
1411        });
1412        assert!(report.diagnostics.iter().any(|d| {
1413            d.code == "TOOL_SURFACE_MISSING_RESULT_READER"
1414                && d.severity == ToolSurfaceSeverity::Error
1415        }));
1416        assert!(!report.valid);
1417    }
1418
1419    #[test]
1420    fn execute_artifact_tool_accepts_inline_escape_hatch() {
1421        let mut annotations = execute_annotations();
1422        annotations.inline_result = true;
1423        let mut policy = CapabilityPolicy::default();
1424        policy.tool_annotations.insert("run".into(), annotations);
1425        let report = validate_tool_surface(&ToolSurfaceInput {
1426            native_tools: Some(vec![serde_json::json!({
1427                "name": "run",
1428                "parameters": {"type": "object"},
1429            })]),
1430            policy: Some(policy),
1431            ..ToolSurfaceInput::default()
1432        });
1433        assert!(!report
1434            .diagnostics
1435            .iter()
1436            .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
1437    }
1438
1439    #[test]
1440    fn native_tool_annotations_are_read_from_tool_json() {
1441        let mut annotations = execute_annotations();
1442        annotations.inline_result = true;
1443        let report = validate_tool_surface(&ToolSurfaceInput {
1444            native_tools: Some(vec![serde_json::json!({
1445                "name": "run",
1446                "parameters": {"type": "object"},
1447                "annotations": annotations,
1448            })]),
1449            ..ToolSurfaceInput::default()
1450        });
1451        assert!(!report
1452            .diagnostics
1453            .iter()
1454            .any(|d| d.code == "TOOL_SURFACE_MISSING_ANNOTATIONS"));
1455        assert!(!report
1456            .diagnostics
1457            .iter()
1458            .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
1459    }
1460
1461    #[test]
1462    fn prompt_reference_outside_policy_is_reported() {
1463        let policy = CapabilityPolicy {
1464            tools: vec!["read_file".into()],
1465            ..CapabilityPolicy::default()
1466        };
1467        let report = validate_tool_surface(&ToolSurfaceInput {
1468            native_tools: Some(vec![
1469                serde_json::json!({"name": "read_file", "parameters": {"type": "object"}}),
1470                serde_json::json!({"name": "run_command", "parameters": {"type": "object"}}),
1471            ]),
1472            policy: Some(policy),
1473            prompt_texts: vec!["Use run_command({command: \"cargo test\"})".into()],
1474            ..ToolSurfaceInput::default()
1475        });
1476        assert!(report
1477            .diagnostics
1478            .iter()
1479            .any(|d| d.code == "TOOL_SURFACE_PROMPT_TOOL_NOT_IN_POLICY"));
1480    }
1481
1482    #[test]
1483    fn approval_rule_tool_references_are_reported() {
1484        let approval_policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
1485            "rules": [
1486                {"ask": {"tool": "missing_tool"}, "reason": "unknown"},
1487                {"allow": {"tool": "read_*"}}
1488            ]
1489        }))
1490        .unwrap();
1491        let report = validate_tool_surface(&ToolSurfaceInput {
1492            native_tools: Some(vec![serde_json::json!({
1493                "name": "read_file",
1494                "parameters": {"type": "object"},
1495            })]),
1496            approval_policy: Some(approval_policy),
1497            ..ToolSurfaceInput::default()
1498        });
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[0].tool")
1503        }));
1504        assert!(!report.diagnostics.iter().any(|d| {
1505            d.code == "TOOL_SURFACE_APPROVAL_PATTERN_NO_MATCH"
1506                && d.field.as_deref() == Some("approval_policy.rules[1].tool")
1507        }));
1508    }
1509
1510    #[test]
1511    fn prompt_suppression_ignores_examples() {
1512        let report = validate_tool_surface(&ToolSurfaceInput {
1513            native_tools: Some(vec![serde_json::json!({
1514                "name": "read_file",
1515                "parameters": {"type": "object"},
1516            })]),
1517            prompt_texts: vec![
1518                "```text\nrun_command({command: \"old\"})\n```\n<!-- harn-tool-surface: ignore-next-line -->\nrun_command({command: \"old\"})".into(),
1519            ],
1520            ..ToolSurfaceInput::default()
1521        });
1522        assert!(!report
1523            .diagnostics
1524            .iter()
1525            .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_PROMPT_TOOL"));
1526    }
1527
1528    #[test]
1529    fn deprecated_alias_warnings_are_scoped_to_matching_tool_calls() {
1530        let mut edit_annotations = ToolAnnotations::default();
1531        edit_annotations
1532            .arg_schema
1533            .arg_aliases
1534            .insert("file".into(), "path".into());
1535        let mut look_annotations = ToolAnnotations::default();
1536        look_annotations
1537            .arg_schema
1538            .arg_aliases
1539            .insert("path".into(), "file".into());
1540
1541        let report = validate_tool_surface(&ToolSurfaceInput {
1542            native_tools: Some(vec![
1543                serde_json::json!({
1544                    "name": "edit",
1545                    "parameters": {"type": "object"},
1546                    "annotations": edit_annotations,
1547                }),
1548                serde_json::json!({
1549                    "name": "look",
1550                    "parameters": {"type": "object"},
1551                    "annotations": look_annotations,
1552                }),
1553            ]),
1554            prompt_texts: vec![
1555                "Use edit({ path: \"src/main.rs\", action: \"replace\" }) before look({ file: \"src/main.rs\" }).".into(),
1556            ],
1557            ..ToolSurfaceInput::default()
1558        });
1559
1560        assert!(!report
1561            .diagnostics
1562            .iter()
1563            .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1564    }
1565
1566    #[test]
1567    fn deprecated_alias_warnings_still_report_matching_multiline_calls() {
1568        let mut annotations = ToolAnnotations::default();
1569        annotations
1570            .arg_schema
1571            .arg_aliases
1572            .insert("file".into(), "path".into());
1573
1574        let report = validate_tool_surface(&ToolSurfaceInput {
1575            native_tools: Some(vec![serde_json::json!({
1576                "name": "edit",
1577                "parameters": {"type": "object"},
1578                "annotations": annotations,
1579            })]),
1580            prompt_texts: vec!["Use edit({\n  file: \"src/main.rs\"\n}) once.".into()],
1581            ..ToolSurfaceInput::default()
1582        });
1583
1584        assert!(report
1585            .diagnostics
1586            .iter()
1587            .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1588    }
1589
1590    #[test]
1591    fn deprecated_alias_warnings_report_tagged_text_mode_calls() {
1592        let mut annotations = ToolAnnotations::default();
1593        annotations
1594            .arg_schema
1595            .arg_aliases
1596            .insert("file".into(), "path".into());
1597
1598        let report = validate_tool_surface(&ToolSurfaceInput {
1599            native_tools: Some(vec![serde_json::json!({
1600                "name": "edit",
1601                "parameters": {"type": "object"},
1602                "annotations": annotations,
1603            })]),
1604            prompt_texts: vec!["<tool_call>\nedit({ file: \"src/main.rs\" })\n</tool_call>".into()],
1605            ..ToolSurfaceInput::default()
1606        });
1607
1608        assert!(report
1609            .diagnostics
1610            .iter()
1611            .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1612    }
1613
1614    #[test]
1615    fn prompt_reference_scanner_tolerates_non_ascii_text() {
1616        let references = prompt_tool_references("Résumé: use run_command({command: \"test\"})");
1617        assert!(references.contains("run_command"));
1618    }
1619
1620    #[test]
1621    fn prompt_reference_scanner_reads_tagged_text_mode_calls() {
1622        let references =
1623            prompt_tool_references("<tool_call>\nrun({ command: \"cargo test\" })\n</tool_call>");
1624        assert!(references.contains("run"));
1625    }
1626
1627    #[test]
1628    fn arg_constraint_key_must_exist() {
1629        let mut annotations = ToolAnnotations {
1630            kind: ToolKind::Read,
1631            side_effect_level: SideEffectLevel::ReadOnly,
1632            arg_schema: ToolArgSchema {
1633                path_params: vec!["path".into()],
1634                ..ToolArgSchema::default()
1635            },
1636            ..ToolAnnotations::default()
1637        };
1638        annotations.arg_schema.required.push("path".into());
1639        let mut policy = CapabilityPolicy {
1640            tool_arg_constraints: vec![ToolArgConstraint {
1641                tool: "read_file".into(),
1642                arg_key: Some("missing".into()),
1643                arg_patterns: vec!["src/**".into()],
1644            }],
1645            ..CapabilityPolicy::default()
1646        };
1647        policy
1648            .tool_annotations
1649            .insert("read_file".into(), annotations);
1650        let report = validate_tool_surface(&ToolSurfaceInput {
1651            native_tools: Some(vec![serde_json::json!({
1652                "name": "read_file",
1653                "parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
1654            })]),
1655            policy: Some(policy),
1656            ..ToolSurfaceInput::default()
1657        });
1658        assert!(report
1659            .diagnostics
1660            .iter()
1661            .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_KEY"));
1662    }
1663}