Skip to main content

vtcode_core/tools/registry/
execution_kernel.rs

1use anyhow::{Result, anyhow};
2use serde_json::Map;
3use serde_json::Value;
4use serde_json::json;
5
6use crate::config::constants::tools as tool_names;
7use crate::tools::apply_patch::{UNIFIED_FILE_MAX_PAYLOAD_BYTES_ENV, effective_max_payload_bytes};
8use crate::tools::error_messages::agent_execution;
9use crate::tools::names::canonical_tool_name;
10use crate::tools::registry::ToolCatalogSource;
11use crate::tools::tool_intent::ToolIntent;
12use crate::tools::validation::{commands, condensed_schema_hint, paths};
13use crate::utils::tool_name_parsing::MCP_QUALIFIED_TOOL_PREFIX;
14
15use super::ToolRegistry;
16
17const DESCRIPTION_FIELD: &str = "description";
18const DETAILS_ALIAS_FIELD: &str = "details";
19
20/// Path-field aliases accepted by file tools, in priority order.
21///
22/// `read_file`/`edit_file`/`unified_file` historically accept several path-key
23/// spellings. The required-arg check, the preflight path-safety check, and the
24/// execution-history path extraction must all agree on the same set (and order)
25/// so a value accepted as "present" by one stage is validated by the next.
26/// Shared across the `registry` module via `pub(super)`.
27pub(super) const PATH_ALIAS_KEYS: [&str; 5] = ["path", "file_path", "filepath", "target_path", "file"];
28
29/// The explicit set of model-hidden tools the harness path is allowed to
30/// dispatch when the public route lookup misses.
31///
32/// These are registered as real builtins (`with_llm_visibility(false)`) but
33/// deliberately excluded from the public route table
34/// (`assembly::is_removed_public_tool_name`). Only names in this allowlist may
35/// fall back to the inventory registration on the harness path; every other
36/// `with_llm_visibility(false)` helper stays inaccessible through the public
37/// execution entrypoint. This turns internal dispatch into an explicit,
38/// reviewed surface instead of "any inventory registration not in the public
39/// routes".
40const HARNESS_DISPATCHABLE_INTERNAL_TOOLS: [&str; 4] = [
41    tool_names::READ_FILE,
42    tool_names::WRITE_FILE,
43    tool_names::EDIT_FILE,
44    tool_names::LIST_FILES,
45];
46
47/// Entry point classification for a tool dispatch.
48///
49/// The public model surface exposes only the Codex baseline (apply_patch,
50/// exec_command, write_stdin, code_search). File helpers
51/// (read_file/write_file/list_files/edit_file) are registered builtins hidden
52/// from the model and excluded from the public route table, but remain
53/// dispatchable for the harness path. `DispatchMode` encodes that distinction in
54/// the type system so the "public is strict, harness is the only fallback"
55/// invariant lives in exactly one place and cannot be re-derived incorrectly by
56/// a future caller.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub(super) enum DispatchMode {
59    /// Direct model-originated entry (e.g. `execute_public_tool_ref`,
60    /// integration tests). Rejects anything not in the public route table.
61    ModelPublic,
62    /// Harness entry (via `admit_public_tool_call`). Falls back to the internal
63    /// builtin registration for the harness-dispatchable allowlist when the
64    /// public route lookup misses.
65    Harness,
66}
67
68impl DispatchMode {
69    fn allows_internal_dispatch(self) -> bool {
70        matches!(self, DispatchMode::Harness)
71    }
72}
73
74#[derive(Debug, Clone)]
75pub struct ToolPreflightOutcome {
76    pub normalized_tool_name: String,
77    pub readonly_classification: bool,
78    pub parallel_safe_after_preflight: bool,
79    pub effective_args: Value,
80    /// The intent classified on the validation args that become the executed
81    /// text. Consumers must reuse it instead of re-classifying, so planning
82    /// enforcement cannot disagree with `readonly_classification`.
83    pub intent: ToolIntent,
84}
85
86fn required_args_for_tool(tool_name: &str) -> &'static [&'static str] {
87    match tool_name {
88        tool_names::READ_FILE => &["path"],
89        tool_names::WRITE_FILE => &["path", "content"],
90        tool_names::EDIT_FILE => &["path", "old_str", "new_str"],
91        tool_names::RUN_PTY_CMD | tool_names::CREATE_PTY_SESSION => &["command"],
92        tool_names::APPLY_PATCH => &["input"],
93        _ => &[],
94    }
95}
96
97fn is_missing_arg_value(args: &Value, key: &str) -> bool {
98    match args.get(key) {
99        Some(v) => v.is_null() || (v.is_string() && v.as_str().is_none_or(|s| s.trim().is_empty())),
100        None => true,
101    }
102}
103
104fn is_missing_apply_patch_payload(args: &Value) -> bool {
105    if args.is_string() {
106        return false;
107    }
108
109    let has_object_payload = |key: &str| args.get(key).is_some_and(Value::is_string);
110    !(has_object_payload("patch") || has_object_payload("input"))
111}
112
113fn is_missing_required_arg(tool_name: &str, args: &Value, key: &str) -> bool {
114    if tool_name == tool_names::READ_FILE && key == "path" {
115        return PATH_ALIAS_KEYS.iter().all(|candidate| is_missing_arg_value(args, candidate));
116    }
117    if tool_name == tool_names::EDIT_FILE {
118        return match key {
119            "old_str" => is_missing_arg_value(args, "old_str") && is_missing_arg_value(args, "old_string"),
120            "new_str" => is_missing_arg_value(args, "new_str") && is_missing_arg_value(args, "new_string"),
121            _ => is_missing_arg_value(args, key),
122        };
123    }
124    if tool_name == tool_names::APPLY_PATCH && key == "input" {
125        return is_missing_apply_patch_payload(args);
126    }
127    is_missing_arg_value(args, key)
128}
129
130/// Format a missing-required-argument failure with the canonical wording.
131///
132/// Single source of truth for the preflight failure message so the
133/// tool-specific and command-session required-arg checks cannot drift in
134/// phrasing (both feed into the same joined `failures` list).
135fn missing_required_arg_failure(key: &str) -> String {
136    format!("Missing required argument: {key}")
137}
138
139#[cfg(test)]
140fn parse_file_operation_max_payload_bytes(raw: Option<&str>) -> Option<usize> {
141    raw.and_then(|value| value.trim().parse::<usize>().ok())
142        .filter(|value| *value >= 1024)
143}
144
145fn configured_file_operation_max_payload_bytes() -> usize {
146    // Single source of truth for the cap: both preflight and the post-decode
147    // size check in `apply_patch` resolve the env-var override the same way
148    // (including the 1 KiB safety floor), so the two stages always agree.
149    effective_max_payload_bytes()
150}
151
152fn schema_uses_description_alias(schema_properties: &Map<String, Value>) -> bool {
153    schema_properties.contains_key(DESCRIPTION_FIELD) && !schema_properties.contains_key(DETAILS_ALIAS_FIELD)
154}
155
156fn normalize_description_alias(object: &mut Map<String, Value>, schema_properties: &Map<String, Value>) -> bool {
157    if !schema_uses_description_alias(schema_properties) || object.contains_key(DESCRIPTION_FIELD) {
158        return false;
159    }
160
161    let Some(details) = object.remove(DETAILS_ALIAS_FIELD) else {
162        return false;
163    };
164    object.insert(DESCRIPTION_FIELD.to_string(), details);
165    true
166}
167
168fn normalize_schema_aliases_in_place(value: &mut Value, schema: &Value) -> bool {
169    let Some(schema_object) = schema.as_object() else {
170        return false;
171    };
172
173    let mut changed = false;
174
175    if let Value::Object(object) = value
176        && let Some(properties) = schema_object.get("properties").and_then(Value::as_object)
177    {
178        changed |= normalize_description_alias(object, properties);
179        for (property_name, property_schema) in properties {
180            if let Some(property_value) = object.get_mut(property_name) {
181                // Coerce string-encoded JSON into the schema-declared type before
182                // recursing, so nested alias/type normalization sees the real value.
183                // The output-budget field is skipped: it has a dedicated strict
184                // validator (`output_limits::max_output_tokens`) that deliberately
185                // rejects string/float values, and bypassing it would weaken that
186                // guardrail.
187                if property_name != vtcode_utility_tool_specs::MAX_OUTPUT_TOKENS_FIELD {
188                    changed |= coerce_string_to_schema_type_in_place(property_value, property_schema);
189                }
190                changed |= normalize_schema_aliases_in_place(property_value, property_schema);
191            }
192        }
193    }
194
195    if let Value::Array(items) = value
196        && let Some(items_schema) = schema_object.get("items")
197    {
198        for item in items {
199            changed |= coerce_string_to_schema_type_in_place(item, items_schema);
200            changed |= normalize_schema_aliases_in_place(item, items_schema);
201        }
202    }
203
204    for keyword in ["allOf", "anyOf", "oneOf"] {
205        if let Some(branches) = schema_object.get(keyword).and_then(Value::as_array) {
206            for branch in branches {
207                changed |= normalize_schema_aliases_in_place(value, branch);
208            }
209        }
210    }
211    for keyword in ["if", "then", "else"] {
212        if let Some(branch) = schema_object.get(keyword) {
213            changed |= normalize_schema_aliases_in_place(value, branch);
214        }
215    }
216
217    changed
218}
219
220/// Strict, schema-aware compatibility coercion of a string-encoded JSON value.
221///
222/// Some models occasionally emit a JSON-encoded *string* where the schema
223/// expects a structured or primitive value — e.g. `"[\"path\"]"` for an array
224/// field, or `"10"` for an integer field. Left unhandled the agent retries the
225/// same malformed call for many turns (six preflight failures were observed in
226/// a single checkpoint turn), wasting tool budget and context.
227///
228/// When `value` is a string and `schema` declares a single non-string `type`,
229/// this rewrites it in place to the parsed JSON value, but **only** when the
230/// parse is unambiguous and the parsed top-level type exactly matches the
231/// schema. It deliberately does not:
232/// - coerce free-form string fields (schema `type` is `string`, an array of
233///   types, or absent),
234/// - reinterpret malformed/non-JSON strings (they stay strings and fail strict
235///   validation with an actionable message),
236/// - coerce floats into integers (`"10.0"` for an integer field stays a string),
237/// - relax enum/bounds checks — strict `jsonschema` validation still runs after.
238fn coerce_string_to_schema_type_in_place(value: &mut Value, schema: &Value) -> bool {
239    let Some(raw) = value.as_str() else {
240        return false;
241    };
242    let Some(schema_type) = schema.get("type").and_then(Value::as_str) else {
243        return false;
244    };
245    let Some(coerced) = parse_string_as_schema_type(raw, schema_type) else {
246        return false;
247    };
248    *value = coerced;
249    true
250}
251
252/// Parse a JSON-encoded string into the schema-declared primitive/container type.
253///
254/// Returns `Some` only when `serde_json` accepts the string and the resulting
255/// top-level JSON type exactly matches `schema_type`. Anything else returns
256/// `None` so the caller leaves the value untouched and lets strict validation
257/// produce the error.
258fn parse_string_as_schema_type(raw: &str, schema_type: &str) -> Option<Value> {
259    let parsed = serde_json::from_str::<Value>(raw).ok()?;
260    match schema_type {
261        "array" => parsed.is_array().then_some(parsed),
262        "object" => parsed.is_object().then_some(parsed),
263        "integer" => parsed.as_i64().map(Value::from).or_else(|| parsed.as_u64().map(Value::from)),
264        "number" => parsed.is_number().then_some(parsed),
265        "boolean" => parsed.as_bool().map(Value::Bool),
266        _ => None,
267    }
268}
269
270fn normalize_details_aliases(args: &Value, parameter_schema: Option<&Value>) -> Option<Value> {
271    let schema = parameter_schema?;
272    let mut normalized = args.clone();
273    normalize_schema_aliases_in_place(&mut normalized, schema).then_some(normalized)
274}
275
276fn serialized_payload_size_bytes(args: &Value) -> usize {
277    serde_json::to_vec(args)
278        .map(|bytes| bytes.len())
279        .unwrap_or_else(|_| args.to_string().len())
280}
281
282fn file_operation_action_for_limit(normalized_tool_name: &str, args: &Value) -> Option<String> {
283    if normalized_tool_name == tool_names::UNIFIED_FILE {
284        return crate::tools::tool_intent::file_operation_action(args).map(|a| a.to_ascii_lowercase());
285    }
286    if normalized_tool_name == tool_names::APPLY_PATCH {
287        return Some("patch".to_string());
288    }
289    if normalized_tool_name == tool_names::EDIT_FILE {
290        return Some("edit".to_string());
291    }
292    None
293}
294
295/// Shared prefix for action-alias remappers: return the args object only when it
296/// is a JSON object that does not already declare an `action`. A caller that
297/// needs to reject non-matching tool names does that check before calling.
298fn args_object_without_action(args: &Value) -> Option<&Map<String, Value>> {
299    let obj = args.as_object()?;
300    if obj.contains_key("action") {
301        return None;
302    }
303    Some(obj)
304}
305
306/// Clone an args object and inject the resolved `action` field, producing the
307/// remapped payload. Single source for the clone-insert-wrap so the two
308/// action-alias remappers cannot drift in how the action is attached.
309fn with_action_inserted(obj: &Map<String, Value>, action: &str) -> Value {
310    let mut mapped = obj.clone();
311    mapped.insert("action".to_string(), Value::String(action.to_string()));
312    Value::Object(mapped)
313}
314
315pub(super) fn remap_public_file_operation_alias_args(
316    requested_name: &str,
317    normalized_tool_name: &str,
318    args: &Value,
319) -> Option<Value> {
320    if normalized_tool_name != tool_names::UNIFIED_FILE {
321        return None;
322    }
323
324    let obj = args_object_without_action(args)?;
325
326    let action = super::assembly::public_tool_name_candidates(requested_name)
327        .into_iter()
328        .find_map(|candidate| match candidate.as_str() {
329            tool_names::READ_FILE => Some("read"),
330            tool_names::WRITE_FILE => Some("write"),
331            tool_names::EDIT_FILE => Some("edit"),
332            tool_names::DELETE_FILE => Some("delete"),
333            tool_names::MOVE_FILE => Some("move"),
334            tool_names::COPY_FILE => Some("copy"),
335            tool_names::CREATE_FILE => Some("write"),
336            _ => None,
337        })?;
338
339    Some(with_action_inserted(obj, action))
340}
341
342pub(super) fn remap_consolidated_action_alias_args(
343    requested_name: &str,
344    normalized_tool_name: &str,
345    args: &Value,
346) -> Option<Value> {
347    let obj = args_object_without_action(args)?;
348
349    let action = super::assembly::public_tool_name_candidates(requested_name)
350        .into_iter()
351        .find_map(|candidate| match (normalized_tool_name, candidate.as_str()) {
352            (tool_names::MCP, tool_names::MCP_SEARCH_TOOLS) => Some("search_tools"),
353            (tool_names::MCP, tool_names::MCP_GET_TOOL_DETAILS) => Some("get_tool_details"),
354            (tool_names::MCP, tool_names::MCP_LIST_SERVERS) => Some("list_servers"),
355            (tool_names::MCP, tool_names::MCP_CONNECT_SERVER) => Some("connect"),
356            (tool_names::MCP, tool_names::MCP_DISCONNECT_SERVER) => Some("disconnect"),
357            (tool_names::CRON, tool_names::CRON_CREATE) => Some("create"),
358            (tool_names::CRON, tool_names::CRON_LIST) => Some("list"),
359            (tool_names::CRON, tool_names::CRON_DELETE) => Some("delete"),
360            (tool_names::AGENT, tool_names::SPAWN_AGENT) => Some("spawn"),
361            (tool_names::AGENT, tool_names::SPAWN_BACKGROUND_SUBPROCESS) => Some("spawn_subprocess"),
362            (tool_names::AGENT, tool_names::SEND_INPUT) => Some("send_input"),
363            (tool_names::AGENT, tool_names::RESUME_AGENT) => Some("resume"),
364            (tool_names::AGENT, tool_names::WAIT_AGENT) => Some("wait"),
365            (tool_names::AGENT, tool_names::CLOSE_AGENT) => Some("close"),
366            _ => None,
367        })?;
368
369    Some(with_action_inserted(obj, action))
370}
371
372fn enforce_file_operation_payload_limit(
373    normalized_tool_name: &str,
374    args: &Value,
375    max_payload_bytes: usize,
376    failures: &mut Vec<String>,
377) {
378    let Some(action) = file_operation_action_for_limit(normalized_tool_name, args) else {
379        return;
380    };
381    if action != "patch" && action != "edit" {
382        return;
383    }
384
385    let payload_bytes = serialized_payload_size_bytes(args);
386    if payload_bytes <= max_payload_bytes {
387        return;
388    }
389
390    tracing::warn!(
391        tool = %normalized_tool_name,
392        action = %action,
393        payload_bytes,
394        max_payload_bytes,
395        "Rejected oversized patch/edit payload during preflight"
396    );
397
398    failures.push(format!(
399        "Patch/edit payload too large for '{normalized_tool_name}': action='{action}', payload={payload_bytes} bytes exceeds {max_payload_bytes} bytes. \
400         Split the change into smaller patch/edit calls, or raise {UNIFIED_FILE_MAX_PAYLOAD_BYTES_ENV} for intentional large edits."
401    ));
402}
403
404pub(super) fn normalize_tool_args<'a>(
405    normalized_tool_name: &str,
406    args: &'a Value,
407    parameter_schema: Option<&Value>,
408) -> Result<std::borrow::Cow<'a, Value>> {
409    let mut normalized = std::borrow::Cow::Borrowed(args);
410
411    if normalized_tool_name == tool_names::APPLY_PATCH
412        && let Some(raw_patch) = normalized.as_ref().as_str()
413    {
414        normalized = std::borrow::Cow::Owned(json!({ "input": raw_patch }));
415    }
416
417    if matches!(
418        normalized_tool_name,
419        tool_names::RUN_PTY_CMD | tool_names::CREATE_PTY_SESSION | tool_names::UNIFIED_EXEC | tool_names::SHELL
420    ) {
421        let shell_args =
422            crate::tools::command_args::normalize_shell_args(normalized.as_ref()).map_err(|error| anyhow!(error))?;
423        if shell_args != *normalized.as_ref() {
424            normalized = std::borrow::Cow::Owned(shell_args);
425        }
426        // Share truthful-status normalization with gate accounting. Pure
427        // truncators are elided; static read-only filters retain their output
428        // under fail-closed pipefail. Safety and permissions still validate
429        // the resulting command. Preserve the original cmd as typed evidence.
430        if let std::borrow::Cow::Owned(executed) =
431            crate::tools::tool_intent::shell_args_as_executed(normalized_tool_name, normalized.as_ref())
432        {
433            normalized = std::borrow::Cow::Owned(executed);
434        }
435    }
436
437    if let Some(alias_args) = normalize_details_aliases(normalized.as_ref(), parameter_schema) {
438        normalized = std::borrow::Cow::Owned(alias_args);
439    }
440
441    Ok(normalized)
442}
443
444fn public_exec_validation_args(normalized_tool_name: &str, args: &Value) -> Result<Option<Value>> {
445    let write_stdin_dispatch = match normalized_tool_name {
446        tool_names::WRITE_STDIN => {
447            Some(crate::tools::command_args::write_stdin_dispatch(args).map_err(|error| anyhow!(error))?)
448        }
449        _ => None,
450    };
451    let action = match normalized_tool_name {
452        tool_names::EXEC_COMMAND => "run",
453        tool_names::WRITE_STDIN => write_stdin_dispatch
454            .map(crate::tools::command_args::WriteStdinDispatch::command_session_action)
455            .ok_or_else(|| anyhow!("write_stdin dispatch was not resolved"))?,
456        _ => return Ok(None),
457    };
458    let mut exec_args = crate::tools::command_args::normalize_shell_args(args).map_err(|error| anyhow!(error))?;
459    // Same truthful-status rewrite as `normalize_tool_args` (see above):
460    // `EXEC_COMMAND` normalizes here rather than there, so the hook must be
461    // repeated to keep validation and execution agreed on one command text.
462    if let std::borrow::Cow::Owned(executed) =
463        crate::tools::tool_intent::shell_args_as_executed(normalized_tool_name, &exec_args)
464    {
465        exec_args = executed;
466    }
467    let payload = exec_args
468        .as_object_mut()
469        .ok_or_else(|| anyhow!("{normalized_tool_name} requires a JSON object"))?;
470    if write_stdin_dispatch == Some(crate::tools::command_args::WriteStdinDispatch::Poll) {
471        payload.remove("input");
472    }
473    payload.insert("action".to_string(), Value::String(action.to_string()));
474    Ok(Some(exec_args))
475}
476
477pub(super) fn preflight_validate_call(
478    registry: &ToolRegistry,
479    name: &str,
480    args: &Value,
481) -> Result<ToolPreflightOutcome> {
482    preflight_validate_call_with_mode(registry, name, args, DispatchMode::ModelPublic)
483}
484
485/// Preflight-validate a tool call under the given [`DispatchMode`].
486///
487/// Resolution is delegated to [`resolve_dispatch_target`] so the preflight and
488/// execution paths agree on exactly which names are dispatchable in each mode.
489pub(super) fn preflight_validate_call_with_mode(
490    registry: &ToolRegistry,
491    name: &str,
492    args: &Value,
493    mode: DispatchMode,
494) -> Result<ToolPreflightOutcome> {
495    let normalized_tool_name = resolve_dispatch_target(registry, name, mode)?;
496
497    if let Some(remapped_args) = remap_public_file_operation_alias_args(name, &normalized_tool_name, args)
498        .or_else(|| remap_consolidated_action_alias_args(name, &normalized_tool_name, args))
499    {
500        preflight_validate_resolved_call(registry, &normalized_tool_name, &remapped_args)
501    } else {
502        preflight_validate_resolved_call(registry, &normalized_tool_name, args)
503    }
504}
505
506/// Resolve a requested tool name to its registration name for the given
507/// [`DispatchMode`].
508///
509/// This is the single source of truth shared by both the preflight
510/// ([`preflight_validate_call_with_mode`]) and execution
511/// (`execute_public_tool_ref_internal_with_mode`) paths, so they can never
512/// disagree on whether a tool is dispatchable.
513///
514/// - [`DispatchMode::ModelPublic`]: only the public route table resolves; any
515///   miss returns "Unknown tool".
516/// - [`DispatchMode::Harness`]: on a public-route miss, fall back to the
517///   internal builtin registration, but only for names in
518///   [`HARNESS_DISPATCHABLE_INTERNAL_TOOLS`].
519pub(super) fn resolve_dispatch_target(registry: &ToolRegistry, name: &str, mode: DispatchMode) -> Result<String> {
520    match registry.resolve_public_tool(name) {
521        Ok(resolution) => Ok(resolution.registration_name().to_string()),
522        Err(public_err) => {
523            if mode.allows_internal_dispatch() {
524                if let Some(internal_target) = resolve_internal_dispatch_tool(registry, name) {
525                    return Ok(internal_target);
526                }
527                if name.eq_ignore_ascii_case("mcp_proxy")
528                    && let Some(mcp_target) = resolve_mcp_proxy_dispatch_target(registry)
529                {
530                    return mcp_target;
531                }
532            }
533            Err(anyhow!("Unknown tool: {}: {public_err}", canonical_tool_name(name)))
534        }
535    }
536}
537
538/// Resolve the legacy `mcp_proxy` tool name used by some persisted model
539/// histories to a registered MCP proxy.
540///
541/// Returns `Ok(None)` when no MCP registration exists, and an explicit error
542/// naming the model-visible alternatives when the request is ambiguous. This
543/// keeps the fallback scoped to MCP tools instead of silently widening the
544/// harness dispatch surface.
545fn resolve_mcp_proxy_dispatch_target(registry: &ToolRegistry) -> Option<Result<String>> {
546    let mcp_registrations = registry
547        .inventory
548        .registrations_snapshot()
549        .into_iter()
550        .filter(|registration| registration.catalog_source() == ToolCatalogSource::Mcp)
551        .collect::<Vec<_>>();
552    if mcp_registrations.is_empty() {
553        return None;
554    }
555
556    if mcp_registrations.len() == 1 {
557        return Some(Ok(mcp_registrations[0].name().to_string()));
558    }
559
560    let visible_names = mcp_registrations
561        .iter()
562        .flat_map(|registration| {
563            registration
564                .metadata()
565                .aliases()
566                .iter()
567                .find(|alias| alias.starts_with(MCP_QUALIFIED_TOOL_PREFIX))
568                .map(|alias| alias.to_string())
569                .or_else(|| Some(registration.name().to_string()))
570        })
571        .collect::<Vec<_>>()
572        .join(", ");
573    Some(Err(anyhow!("Ambiguous MCP proxy call: specify one of the available MCP tools: {visible_names}")))
574}
575
576/// Resolve a requested name to a registered harness-dispatchable internal
577/// (model-hidden) tool.
578///
579/// Returns the registration name only when the canonicalized name is in
580/// [`HARNESS_DISPATCHABLE_INTERNAL_TOOLS`] AND actually registered in the
581/// inventory. This never exposes the tool publicly; it merely lets the harness
582/// dispatch the intentionally model-hidden file helpers. Any other
583/// `with_llm_visibility(false)` helper (e.g. diagnostics-only tools) is NOT
584/// reachable through this path, so registering a new hidden tool cannot silently
585/// widen the harness-dispatchable surface.
586fn resolve_internal_dispatch_tool(registry: &ToolRegistry, name: &str) -> Option<String> {
587    super::assembly::public_tool_name_candidates(name)
588        .into_iter()
589        .map(|candidate| candidate.trim().to_ascii_lowercase())
590        .filter(|candidate| !candidate.is_empty())
591        .find(|candidate| {
592            HARNESS_DISPATCHABLE_INTERNAL_TOOLS
593                .iter()
594                .any(|allowed| allowed.eq_ignore_ascii_case(candidate))
595        })
596        .and_then(|candidate| {
597            registry
598                .inventory
599                .registration_for(&candidate)
600                .map(|registration| registration.name().to_string())
601        })
602}
603
604pub(super) fn preflight_validate_resolved_call(
605    registry: &ToolRegistry,
606    normalized_tool_name: &str,
607    args: &Value,
608) -> Result<ToolPreflightOutcome> {
609    let mut routed_tool_name = normalized_tool_name.to_string();
610    let mut validation_tool_name = routed_tool_name.clone();
611    let parameter_schema = registry
612        .inventory
613        .registration_for(normalized_tool_name)
614        .and_then(|registration| registration.parameter_schema().cloned());
615    let mut validation_args = normalize_tool_args(normalized_tool_name, args, parameter_schema.as_ref())?;
616    let mut effective_args = None;
617    // Schema for the tool we ultimately validate against. Defaults to the
618    // originally-resolved tool's schema; each remap branch overwrites it with
619    // the exec schema it already fetches, so we never re-look-up the same
620    // registration (the previous form fetched the same schema up to 3x).
621    let mut effective_parameter_schema = parameter_schema;
622
623    crate::tools::output_limits::max_output_tokens(validation_args.as_ref())
624        .map_err(|error| anyhow!("Invalid arguments for tool '{routed_tool_name}': {error}"))?;
625
626    if let Some(exec_args) = public_exec_validation_args(normalized_tool_name, validation_args.as_ref())? {
627        validation_tool_name = tool_names::UNIFIED_EXEC.to_string();
628        validation_args = std::borrow::Cow::Owned(exec_args);
629        effective_args = Some(validation_args.as_ref().clone());
630        effective_parameter_schema = registry
631            .inventory
632            .registration_for(&validation_tool_name)
633            .and_then(|registration| registration.parameter_schema().cloned());
634    } else if normalized_tool_name == tool_names::UNIFIED_FILE
635        && let Some(remapped_args) =
636            crate::tools::tool_intent::remap_file_operation_command_args_to_command_session(validation_args.as_ref())
637    {
638        routed_tool_name = tool_names::UNIFIED_EXEC.to_string();
639        validation_tool_name = tool_names::UNIFIED_EXEC.to_string();
640        effective_parameter_schema = registry
641            .inventory
642            .registration_for(&validation_tool_name)
643            .and_then(|registration| registration.parameter_schema().cloned());
644        validation_args = std::borrow::Cow::Owned(
645            normalize_tool_args(&validation_tool_name, &remapped_args, effective_parameter_schema.as_ref())?
646                .into_owned(),
647        );
648        effective_args = Some(validation_args.as_ref().clone());
649    }
650
651    if validation_tool_name == tool_names::TASK_TRACKER {
652        effective_parameter_schema =
653            Some(crate::tools::handlers::task_tracker::task_tracker_parameter_schema_for_workflow(
654                registry.is_planning_active(),
655            ));
656        validation_args = std::borrow::Cow::Owned(
657            normalize_tool_args(&validation_tool_name, validation_args.as_ref(), effective_parameter_schema.as_ref())?
658                .into_owned(),
659        );
660        if effective_args.is_some() {
661            effective_args = Some(validation_args.as_ref().clone());
662        }
663    }
664
665    let required = required_args_for_tool(&validation_tool_name);
666    let mut failures = Vec::with_capacity(required.len());
667    for key in required {
668        if is_missing_required_arg(&validation_tool_name, validation_args.as_ref(), key) {
669            failures.push(if validation_tool_name == tool_names::APPLY_PATCH {
670                crate::tools::apply_patch::APPLY_PATCH_ARGUMENT_CORRECTION.to_string()
671            } else {
672                missing_required_arg_failure(key)
673            });
674        }
675    }
676    if validation_tool_name == tool_names::UNIFIED_EXEC {
677        failures.extend(
678            crate::tools::command_args::command_session_missing_required_args(validation_args.as_ref())
679                .into_iter()
680                .map(missing_required_arg_failure),
681        );
682    }
683
684    if let Some(path) = PATH_ALIAS_KEYS
685        .iter()
686        .find_map(|key| validation_args.as_ref().get(*key).and_then(Value::as_str))
687        && let Err(err) = paths::validate_path_safety(path)
688    {
689        failures.push(format!("Path security check failed: {err}"));
690    }
691
692    let should_validate_command = matches!(
693        validation_tool_name.as_str(),
694        tool_names::RUN_PTY_CMD | tool_names::CREATE_PTY_SESSION | tool_names::SHELL
695    ) || (validation_tool_name == tool_names::UNIFIED_EXEC
696        && crate::tools::command_args::command_session_requires_command_safety(validation_args.as_ref()));
697    if should_validate_command {
698        let command_value = crate::tools::command_args::normalized_command_value(validation_args.as_ref())
699            .ok()
700            .flatten();
701        let collision_command = match &command_value {
702            Some(Value::String(command)) => Some(command.as_str()),
703            Some(Value::Array(command)) => command.first().and_then(Value::as_str),
704            _ => None,
705        };
706        if collision_command.is_some_and(crate::tools::names::is_apply_patch_shell_collision_command) {
707            failures.push(format!(
708                "apply_patch is a tool, not a shell executable. {}",
709                crate::tools::apply_patch::APPLY_PATCH_ARGUMENT_CORRECTION
710            ));
711        }
712        let validation_result = match command_value {
713            Some(Value::Array(_)) => crate::tools::command_args::command_words(validation_args.as_ref())
714                .ok()
715                .flatten()
716                .map_or(Ok(()), |command| commands::validate_command_argv(&command)),
717            Some(Value::String(command)) => commands::validate_shell_script(&command),
718            _ => Ok(()),
719        };
720        if let Err(err) = validation_result {
721            failures.push(format!("Command security check failed: {err}"));
722        }
723    }
724    enforce_file_operation_payload_limit(
725        &validation_tool_name,
726        validation_args.as_ref(),
727        configured_file_operation_max_payload_bytes(),
728        &mut failures,
729    );
730
731    if !failures.is_empty() {
732        return Err(anyhow!("Tool preflight validation failed for '{}': {}", routed_tool_name, failures.join("; ")));
733    }
734
735    if validation_tool_name == tool_names::UNIFIED_EXEC
736        && crate::tools::tool_intent::command_session_action(validation_args.as_ref()).is_none()
737    {
738        return Err(anyhow!(
739            "Invalid arguments for tool '{routed_tool_name}': missing action; provide `action` or inferable exec arguments"
740        ));
741    }
742    let schema_validation_args = crate::tools::output_limits::args_without_output_metadata(validation_args.as_ref());
743    if let Some(schema) = effective_parameter_schema.as_ref() {
744        let error_msg = match jsonschema::validator_for(schema) {
745            Ok(validator) => validator
746                .iter_errors(&schema_validation_args)
747                .map(|error| crate::tools::validation::describe_jsonschema_error(&error))
748                .collect::<Vec<_>>()
749                .join("; "),
750            Err(schema_error) => crate::tools::validation::describe_jsonschema_error(&schema_error),
751        };
752        if !error_msg.is_empty() {
753            let hint_msg = condensed_schema_hint(schema)
754                .map(|hint| format!("\nExpected schema (required fields and types): {hint}"))
755                .unwrap_or_default();
756            let patch_hint = if validation_tool_name == tool_names::APPLY_PATCH {
757                crate::tools::apply_patch::APPLY_PATCH_ARGUMENT_CORRECTION
758            } else if validation_tool_name == tool_names::TASK_TRACKER
759                && crate::tools::handlers::task_tracker::is_task_tracker_shape_error(&error_msg)
760            {
761                crate::tools::handlers::task_tracker::TASK_TRACKER_ARGUMENT_CORRECTION
762            } else {
763                ""
764            };
765            return Err(anyhow!("Invalid arguments for tool '{routed_tool_name}': {error_msg}{hint_msg} {patch_hint}"));
766        }
767    }
768    if validation_tool_name == tool_names::CODE_SEARCH {
769        crate::tools::code_search::validate_args(&schema_validation_args)
770            .map_err(|error| anyhow!("Invalid arguments for tool '{routed_tool_name}': {error}"))?;
771    }
772
773    let intent = crate::tools::tool_intent::classify_tool_intent(&validation_tool_name, validation_args.as_ref());
774    let readonly_classification = !intent.mutating;
775    if registry.is_planning_active()
776        && !registry.is_planning_active_allowed_with_intent(&validation_tool_name, validation_args.as_ref(), &intent)
777    {
778        let msg = agent_execution::planning_workflow_denial_message(&routed_tool_name);
779        return Err(anyhow!(msg).context(agent_execution::PLANNING_DENIED_CONTEXT));
780    }
781
782    Ok(ToolPreflightOutcome {
783        normalized_tool_name: routed_tool_name.clone(),
784        readonly_classification,
785        parallel_safe_after_preflight: crate::tools::tool_intent::is_parallel_safe_call_with_intent(
786            &validation_tool_name,
787            validation_args.as_ref(),
788            &intent,
789        ),
790        effective_args: effective_args.unwrap_or_else(|| validation_args.into_owned()),
791        intent,
792    })
793}
794
795#[cfg(test)]
796mod tests {
797    use super::super::ToolExecutionRequest;
798    use super::super::assembly::public_tool_name_candidates;
799    use super::{
800        ToolRegistry, coerce_string_to_schema_type_in_place, configured_file_operation_max_payload_bytes,
801        enforce_file_operation_payload_limit, is_missing_required_arg, normalize_tool_args,
802        parse_file_operation_max_payload_bytes, parse_string_as_schema_type, preflight_validate_call,
803        preflight_validate_resolved_call, public_exec_validation_args,
804    };
805    use crate::config::constants::tools as tool_names;
806    use crate::tools::command_args::parse_indexed_command_parts;
807    use crate::tools::request_user_input::RequestUserInputTool;
808    use crate::tools::traits::Tool;
809    use anyhow::Result;
810    use serde_json::{Value, json};
811
812    async fn new_test_registry() -> (tempfile::TempDir, ToolRegistry) {
813        let temp = tempfile::tempdir().expect("temp workspace");
814        let registry = ToolRegistry::new(temp.path().to_path_buf()).await;
815        (temp, registry)
816    }
817
818    #[test]
819    fn normalize_elides_truncation_only_piped_verifier() {
820        // The pipeline exit status belongs to the truncator, so the piped
821        // form must never reach validation or execution: the standalone
822        // verifier runs instead and its status is truthful.
823        let args = json!({"action": "run", "command": "cargo check --locked 2>&1 | head -c 4000"});
824        let normalized =
825            normalize_tool_args(tool_names::UNIFIED_EXEC, &args, None).expect("rewrite must not fail normalization");
826        assert_eq!(normalized.as_ref()["command"], "cargo check --locked 2>&1");
827        // The caller's `cmd` spelling is left intact as the typed record.
828        assert_eq!(normalized.as_ref()["cmd"], Value::Null);
829
830        let via_cmd = json!({"action": "run", "cmd": "cargo nextest run 2>&1 | tail -15"});
831        let via_cmd =
832            normalize_tool_args(tool_names::UNIFIED_EXEC, &via_cmd, None).expect("rewrite must not fail normalization");
833        assert_eq!(via_cmd.as_ref()["command"], "cargo nextest run 2>&1");
834        assert_eq!(via_cmd.as_ref()["cmd"], "cargo nextest run 2>&1 | tail -15");
835    }
836
837    #[test]
838    fn normalize_leaves_non_rewritable_pipelines_untouched() {
839        for command in [
840            "cargo check | grep error; true",
841            "cargo check && rm -rf target | tail -5",
842            "cargo check | tail -5; rm foo.txt",
843            "rg -n 'pattern' src | head -20",
844            "cargo check --locked",
845        ] {
846            let args = json!({"action": "run", "command": command});
847            let normalized =
848                normalize_tool_args(tool_names::UNIFIED_EXEC, &args, None).expect("normalization must succeed");
849            assert_eq!(
850                normalized.as_ref()["command"],
851                Value::String(command.to_string()),
852                "must run as typed: {command}"
853            );
854        }
855    }
856
857    #[test]
858    fn exec_command_validation_path_rewrites_piped_verifier() {
859        let args = json!({"cmd": "cargo check --locked | tail -8"});
860        let Some(exec_args) =
861            public_exec_validation_args(tool_names::EXEC_COMMAND, &args).expect("validation args must build")
862        else {
863            panic!("EXEC_COMMAND run must produce exec validation args");
864        };
865        assert_eq!(exec_args["command"], "cargo check --locked");
866    }
867
868    #[tokio::test]
869    async fn preflight_accepts_output_limit_for_legacy_strict_schemas() {
870        let (_temp, registry) = new_test_registry().await;
871        let outcome = preflight_validate_call(
872            &registry,
873            tool_names::CODE_SEARCH,
874            &json!({"query": "ToolRegistry", "max_output_tokens": 37}),
875        )
876        .expect("a valid integer output limit should pass preflight");
877        assert_eq!(outcome.effective_args["max_output_tokens"], 37);
878    }
879
880    #[tokio::test]
881    async fn preflight_uses_default_output_limit_when_omitted() {
882        let (_temp, registry) = new_test_registry().await;
883        let outcome = preflight_validate_call(&registry, tool_names::CODE_SEARCH, &json!({"query": "ToolRegistry"}))
884            .expect("omitted output limits should remain dispatchable");
885
886        assert_eq!(
887            crate::tools::output_limits::max_output_tokens(&outcome.effective_args)
888                .expect("default output limit should be valid"),
889            vtcode_utility_tool_specs::DEFAULT_MAX_OUTPUT_TOKENS
890        );
891    }
892
893    #[tokio::test]
894    async fn preflight_rejects_invalid_output_limits_before_dispatch() {
895        let (_temp, registry) = new_test_registry().await;
896        let error = preflight_validate_call(
897            &registry,
898            tool_names::CODE_SEARCH,
899            &json!({"query": "ToolRegistry", "max_output_tokens": "37"}),
900        )
901        .expect_err("string output limits must be rejected");
902        assert!(error.to_string().contains("max_output_tokens must be an integer"));
903    }
904
905    // --- schema-aware string-type coercion -------------------------------------
906    //
907    // Regression coverage for the checkpoint-observed defect where the model
908    // emitted JSON-encoded strings (`"[\"path\"]"`, `"10"`) for array/integer
909    // fields and retried the same malformed call six times in one turn.
910
911    #[test]
912    fn parse_string_as_schema_type_coerces_array_and_integer() {
913        assert_eq!(parse_string_as_schema_type(r#"["path"]"#, "array"), Some(json!(["path"])));
914        assert_eq!(parse_string_as_schema_type("10", "integer"), Some(json!(10)));
915    }
916
917    #[test]
918    fn parse_string_as_schema_type_coerces_boolean_object_and_number() {
919        assert_eq!(parse_string_as_schema_type("true", "boolean"), Some(json!(true)));
920        assert_eq!(parse_string_as_schema_type("false", "boolean"), Some(json!(false)));
921        assert_eq!(parse_string_as_schema_type(r#"{"a": 1}"#, "object"), Some(json!({"a": 1})));
922        assert_eq!(parse_string_as_schema_type("3.5", "number"), Some(json!(3.5)));
923        // An integer literal is also a valid JSON number.
924        assert_eq!(parse_string_as_schema_type("3", "number"), Some(json!(3)));
925    }
926
927    #[test]
928    fn parse_string_as_schema_type_rejects_float_for_integer() {
929        // "10.0" must not be silently coerced to an integer: that would be a
930        // lossy reinterpretation. Let strict validation surface the type error.
931        assert_eq!(parse_string_as_schema_type("10.0", "integer"), None);
932    }
933
934    #[test]
935    fn parse_string_as_schema_type_rejects_mismatched_and_malformed() {
936        // Parsed type does not match schema type.
937        assert_eq!(parse_string_as_schema_type(r#"{"a": 1}"#, "array"), None);
938        assert_eq!(parse_string_as_schema_type(r#"["path"]"#, "object"), None);
939        assert_eq!(parse_string_as_schema_type("10", "boolean"), None);
940        // Malformed JSON stays a string and is left for strict validation.
941        assert_eq!(parse_string_as_schema_type("not json", "array"), None);
942        assert_eq!(parse_string_as_schema_type("[\"path\"", "array"), None);
943        // Unknown schema type is never coerced.
944        assert_eq!(parse_string_as_schema_type("anything", "string"), None);
945    }
946
947    #[test]
948    fn coerce_string_to_schema_type_in_place_only_touches_strings() {
949        let mut already_array = json!(["path"]);
950        let schema = json!({"type": "array"});
951        assert!(!coerce_string_to_schema_type_in_place(&mut already_array, &schema));
952        assert_eq!(already_array, json!(["path"]));
953
954        let mut no_type = json!("keep me");
955        let schema = json!({}); // no declared type → free-form string
956        assert!(!coerce_string_to_schema_type_in_place(&mut no_type, &schema));
957        assert_eq!(no_type, json!("keep me"));
958    }
959
960    #[test]
961    fn normalize_tool_args_coerces_code_search_stringified_args() {
962        // Mirrors the exact checkpoint defect: result_types and max_results
963        // arrive as JSON-encoded strings.
964        let schema = json!({
965            "type": "object",
966            "properties": {
967                "query": {"type": "string"},
968                "result_types": {"type": "array", "items": {"type": "string", "enum": ["definition", "usage", "text", "path"]}},
969                "max_results": {"type": "integer", "minimum": 1, "maximum": 100}
970            }
971        });
972        let args = json!({
973            "query": "turn_loop",
974            "result_types": "[\"path\"]",
975            "max_results": "10"
976        });
977        let normalized = normalize_tool_args(tool_names::CODE_SEARCH, &args, Some(&schema))
978            .expect("code_search stringified args normalize");
979        assert_eq!(normalized["result_types"], json!(["path"]));
980        assert_eq!(normalized["max_results"], json!(10));
981        assert_eq!(normalized["query"], json!("turn_loop"));
982    }
983
984    #[tokio::test]
985    async fn preflight_coerces_stringified_code_search_args_then_validates() {
986        let (_temp, registry) = new_test_registry().await;
987        // The exact malformed shape from the checkpoint should now pass preflight.
988        let outcome = preflight_validate_call(
989            &registry,
990            tool_names::CODE_SEARCH,
991            &json!({"query": "turn_loop", "result_types": "[\"path\"]", "max_results": "10"}),
992        )
993        .expect("stringified args should be coerced and pass preflight");
994        assert_eq!(outcome.effective_args["result_types"], json!(["path"]));
995        assert_eq!(outcome.effective_args["max_results"], json!(10));
996    }
997
998    #[tokio::test]
999    async fn preflight_coercion_still_enforces_bounds_and_enum() {
1000        let (_temp, registry) = new_test_registry().await;
1001        // Coerced to integer 999, then the max=100 bound must still reject it.
1002        let over_max = preflight_validate_call(
1003            &registry,
1004            tool_names::CODE_SEARCH,
1005            &json!({"query": "turn_loop", "max_results": "999"}),
1006        )
1007        .expect_err("coerced integer must still be bounds-checked");
1008        assert!(over_max.to_string().contains("max_results"), "msg: {over_max}");
1009
1010        // Coerced to array ["bogus"], then the item enum must still reject it.
1011        let bad_enum = preflight_validate_call(
1012            &registry,
1013            tool_names::CODE_SEARCH,
1014            &json!({"query": "turn_loop", "result_types": "[\"bogus\"]"}),
1015        )
1016        .expect_err("coerced array items must still be enum-checked");
1017        assert!(bad_enum.to_string().contains("result_types"), "msg: {bad_enum}");
1018    }
1019
1020    #[tokio::test]
1021    async fn preflight_does_not_coerce_max_output_tokens_string() {
1022        // The output-budget field has a dedicated strict validator that must keep
1023        // rejecting strings even though schema-aware coercion exists for other
1024        // integer fields.
1025        let (_temp, registry) = new_test_registry().await;
1026        let error = preflight_validate_call(
1027            &registry,
1028            tool_names::CODE_SEARCH,
1029            &json!({"query": "x", "max_output_tokens": "100"}),
1030        )
1031        .expect_err("max_output_tokens string must remain rejected");
1032        assert!(error.to_string().contains("max_output_tokens must be an integer"));
1033    }
1034    #[test]
1035    fn patch_action_within_limit_is_allowed() {
1036        let mut failures = Vec::new();
1037        let args = json!({
1038            "action": "patch",
1039            "patch": "*** Begin Patch\n*** End Patch\n"
1040        });
1041
1042        enforce_file_operation_payload_limit(tool_names::UNIFIED_FILE, &args, 1024, &mut failures);
1043        assert!(failures.is_empty());
1044    }
1045    #[test]
1046    fn patch_action_over_limit_is_rejected() {
1047        let mut failures = Vec::new();
1048        let args = json!({
1049            "action": "patch",
1050            "patch": "x".repeat(512)
1051        });
1052
1053        enforce_file_operation_payload_limit(tool_names::UNIFIED_FILE, &args, 128, &mut failures);
1054        assert_eq!(failures.len(), 1);
1055        assert!(failures[0].contains("payload too large"));
1056        assert!(failures[0].contains("Split the change"));
1057    }
1058    #[test]
1059    fn edit_tool_over_limit_is_rejected() {
1060        let mut failures = Vec::new();
1061        let args = json!({
1062            "path": "file.txt",
1063            "old_str": "old",
1064            "new_str": "x".repeat(512)
1065        });
1066
1067        enforce_file_operation_payload_limit(tool_names::EDIT_FILE, &args, 128, &mut failures);
1068        assert_eq!(failures.len(), 1);
1069        assert!(failures[0].contains("action='edit'"));
1070    }
1071
1072    #[test]
1073    fn read_action_is_not_limited() {
1074        let mut failures = Vec::new();
1075        let args = json!({
1076            "action": "read",
1077            "path": "README.md"
1078        });
1079
1080        enforce_file_operation_payload_limit(tool_names::UNIFIED_FILE, &args, 1, &mut failures);
1081        assert!(failures.is_empty());
1082    }
1083
1084    #[test]
1085    fn edit_file_required_args_accept_legacy_key_names() {
1086        let args = json!({
1087            "path": "file.txt",
1088            "old_string": "old",
1089            "new_string": "new"
1090        });
1091
1092        assert!(!is_missing_required_arg(tool_names::EDIT_FILE, &args, "path"));
1093        assert!(!is_missing_required_arg(tool_names::EDIT_FILE, &args, "old_str"));
1094        assert!(!is_missing_required_arg(tool_names::EDIT_FILE, &args, "new_str"));
1095    }
1096
1097    #[test]
1098    fn parse_payload_limit_accepts_safe_override() {
1099        let parsed = parse_file_operation_max_payload_bytes(Some("2048"));
1100        assert_eq!(parsed, Some(2048));
1101    }
1102
1103    #[test]
1104    fn parse_payload_limit_rejects_too_small_values() {
1105        let parsed = parse_file_operation_max_payload_bytes(Some("512"));
1106        assert_eq!(parsed, None);
1107    }
1108
1109    #[test]
1110    fn parse_payload_limit_rejects_invalid_values() {
1111        let parsed = parse_file_operation_max_payload_bytes(Some("not-a-number"));
1112        assert_eq!(parsed, None);
1113    }
1114
1115    #[test]
1116    fn configured_payload_limit_is_always_safe() {
1117        let configured = configured_file_operation_max_payload_bytes();
1118        assert!(configured >= 1024);
1119    }
1120
1121    #[test]
1122    fn apply_patch_required_arg_accepts_input_alias() {
1123        assert!(!is_missing_required_arg(tool_names::APPLY_PATCH, &json!({"input": ""}), "input"));
1124    }
1125
1126    #[test]
1127    fn apply_patch_required_arg_accepts_raw_string_payload() {
1128        assert!(!is_missing_required_arg(tool_names::APPLY_PATCH, &json!(""), "input"));
1129    }
1130
1131    #[test]
1132    fn run_pty_cmd_required_arg_accepts_zero_based_indexed_command() -> Result<()> {
1133        let input = json!({
1134            "command.0": "ls",
1135            "command.1": "-a"
1136        });
1137        let args = normalize_tool_args(tool_names::RUN_PTY_CMD, &input, None)?;
1138
1139        assert!(!is_missing_required_arg(tool_names::RUN_PTY_CMD, args.as_ref(), "command"));
1140        assert_eq!(args.get("command").and_then(|value| value.as_str()), Some("ls -a"));
1141        Ok(())
1142    }
1143
1144    #[test]
1145    fn run_pty_cmd_required_arg_accepts_one_based_indexed_command() -> Result<()> {
1146        let input = json!({
1147            "command.1": "ls",
1148            "command.2": "-a"
1149        });
1150        let args = normalize_tool_args(tool_names::RUN_PTY_CMD, &input, None)?;
1151
1152        assert!(!is_missing_required_arg(tool_names::RUN_PTY_CMD, args.as_ref(), "command"));
1153        assert_eq!(args.get("command").and_then(|value| value.as_str()), Some("ls -a"));
1154        Ok(())
1155    }
1156
1157    #[test]
1158    fn indexed_command_parts_require_zero_or_one_based_sequences() {
1159        assert_eq!(
1160            parse_indexed_command_parts(
1161                json!({
1162                    "command.0": "ls",
1163                    "command.1": "-a"
1164                })
1165                .as_object()
1166                .expect("object"),
1167            )
1168            .expect("valid indexed args"),
1169            Some(vec!["ls".to_string(), "-a".to_string()])
1170        );
1171        assert_eq!(
1172            parse_indexed_command_parts(
1173                json!({
1174                    "command.1": "ls",
1175                    "command.2": "-a"
1176                })
1177                .as_object()
1178                .expect("object"),
1179            )
1180            .expect("valid indexed args"),
1181            Some(vec!["ls".to_string(), "-a".to_string()])
1182        );
1183        assert_eq!(
1184            parse_indexed_command_parts(json!({"command.2": "ls"}).as_object().expect("object"))
1185                .expect("valid indexed args"),
1186            None
1187        );
1188    }
1189
1190    #[test]
1191    fn tool_name_candidates_extract_channel_suffix_alias() {
1192        let candidates = public_tool_name_candidates("assistant<|channel|>apply_patch");
1193        assert!(candidates.iter().any(|c| c == "apply_patch"));
1194    }
1195
1196    #[test]
1197    fn tool_name_candidates_normalize_humanized_name() {
1198        let candidates = public_tool_name_candidates("Read file");
1199        assert!(candidates.iter().any(|c| c == "read_file"));
1200    }
1201
1202    #[test]
1203    fn request_user_input_args_accept_details_alias() -> Result<()> {
1204        let schema = RequestUserInputTool.parameter_schema().expect("request_user_input schema");
1205        let args = json!({
1206            "questions": [{
1207                "id": "scope",
1208                "header": "Scope",
1209                "question": "Which direction should we take?",
1210                "options": [
1211                    {
1212                        "label": "Minimal",
1213                        "details": "Ship the smallest viable slice."
1214                    },
1215                    {
1216                        "label": "Full",
1217                        "details": "Ship the full implementation."
1218                    }
1219                ]
1220            }]
1221        });
1222
1223        let normalized = normalize_tool_args(tool_names::REQUEST_USER_INPUT, &args, Some(&schema))?;
1224        let option = &normalized["questions"][0]["options"][0];
1225        assert_eq!(option.get("description").and_then(Value::as_str), Some("Ship the smallest viable slice."));
1226        assert!(option.get("details").is_none());
1227        Ok(())
1228    }
1229
1230    #[test]
1231    fn task_tracker_args_accept_details_alias() -> Result<()> {
1232        let schema = json!({
1233            "type": "object",
1234            "properties": {
1235                "action": { "type": "string" },
1236                "description": { "type": "string" }
1237            }
1238        });
1239        let args = json!({
1240            "action": "add",
1241            "details": "Add regression coverage"
1242        });
1243
1244        let normalized = normalize_tool_args(tool_names::TASK_TRACKER, &args, Some(&schema))?;
1245        assert_eq!(normalized.get("description").and_then(Value::as_str), Some("Add regression coverage"));
1246        assert!(normalized.get("details").is_none());
1247        Ok(())
1248    }
1249
1250    #[test]
1251    fn details_alias_does_not_shadow_real_details_field() -> Result<()> {
1252        let schema = json!({
1253            "type": "object",
1254            "properties": {
1255                "description": { "type": "string" },
1256                "details": { "type": "string" }
1257            }
1258        });
1259        let args = json!({
1260            "details": "Keep the real details field."
1261        });
1262
1263        let normalized = normalize_tool_args(tool_names::TASK_TRACKER, &args, Some(&schema))?;
1264        assert!(normalized.get("description").is_none());
1265        assert_eq!(normalized.get("details").and_then(Value::as_str), Some("Keep the real details field."));
1266        Ok(())
1267    }
1268
1269    #[tokio::test]
1270    async fn command_session_preflight_rejects_run_without_command() {
1271        let (_temp, registry) = new_test_registry().await;
1272
1273        let err = preflight_validate_resolved_call(&registry, tool_names::UNIFIED_EXEC, &json!({"action": "run"}))
1274            .expect_err("missing command should fail preflight");
1275
1276        assert!(err.to_string().contains("Missing required argument: command"));
1277    }
1278
1279    #[tokio::test]
1280    async fn exec_command_preflight_preserves_public_name_and_validates_as_run() -> Result<()> {
1281        let (_temp, registry) = new_test_registry().await;
1282
1283        let result = preflight_validate_call(
1284            &registry,
1285            tool_names::EXEC_COMMAND,
1286            &json!({"cmd": "rg --files", "workdir": ".", "tty": true}),
1287        )?;
1288
1289        assert_eq!(result.normalized_tool_name, tool_names::EXEC_COMMAND);
1290        assert_eq!(result.effective_args["action"], "run");
1291        assert_eq!(result.effective_args["command"], "rg --files");
1292        assert_eq!(result.effective_args["workdir"], ".");
1293        assert_eq!(result.effective_args["tty"], true);
1294        assert!(result.readonly_classification);
1295        Ok(())
1296    }
1297
1298    #[tokio::test]
1299    async fn planning_preflight_rejects_mutating_suffixes_and_conflicting_raw_aliases() {
1300        let (_temp, registry) = new_test_registry().await;
1301        registry.enable_planning();
1302        for args in [
1303            json!({"cmd":"cargo check | sort", "args":["-o", "changed.txt"]}),
1304            json!({"cmd":"cat README.md", "raw_command":"printf changed > changed.txt"}),
1305        ] {
1306            let error = preflight_validate_call(&registry, tool_names::EXEC_COMMAND, &args)
1307                .expect_err("mutating invocation cannot acquire planning admission");
1308            assert_eq!(error.to_string(), super::agent_execution::PLANNING_DENIED_CONTEXT);
1309        }
1310        let safe =
1311            preflight_validate_call(&registry, tool_names::EXEC_COMMAND, &json!({"cmd":"sort", "args":["README.md"]}))
1312                .expect("safe appended operands remain readable in planning");
1313        assert!(safe.readonly_classification);
1314        assert!(safe.parallel_safe_after_preflight);
1315    }
1316
1317    #[tokio::test]
1318    async fn planning_preflight_allows_checkpoint_style_readonly_inspection() -> Result<()> {
1319        let (_temp, registry) = new_test_registry().await;
1320        registry.enable_planning();
1321        let command = r#"sed -n '180,285p' src/main.rs; sed -n '60,285p' src/startup/mod.rs; sed -n '1,220p' src/main_helpers/bootstrap.rs; rg -n "\[profile|lto|codegen-units|strip" Cargo.toml"#;
1322
1323        let result = preflight_validate_call(
1324            &registry,
1325            tool_names::EXEC_COMMAND,
1326            &json!({
1327                "cmd": command,
1328                "workdir": ".",
1329                "yield_time_ms": 10000,
1330                "max_output_tokens": 30000
1331            }),
1332        )?;
1333
1334        assert!(result.readonly_classification);
1335        assert_eq!(result.effective_args["action"], "run");
1336        Ok(())
1337    }
1338
1339    #[tokio::test]
1340    async fn exec_command_preflight_rejects_dangerous_command() {
1341        let (_temp, registry) = new_test_registry().await;
1342
1343        let err =
1344            preflight_validate_call(&registry, tool_names::EXEC_COMMAND, &json!({"cmd": "git reset --hard HEAD~1"}))
1345                .expect_err("dangerous exec_command should fail preflight");
1346
1347        let text = err.to_string();
1348        assert!(text.contains("Tool preflight validation failed for 'exec_command'"));
1349        assert!(text.contains("Command security check failed"));
1350    }
1351
1352    #[tokio::test]
1353    async fn exec_command_approval_required_payload_is_seen_as_shell_run() -> Result<()> {
1354        let (_temp, registry) = new_test_registry().await;
1355        let args = json!({
1356            "cmd": "cargo check",
1357            "sandbox_permissions": "require_escalated",
1358            "justification": "Need unsandboxed access for this check."
1359        });
1360
1361        let reason = registry
1362            .shell_run_approval_reason(tool_names::EXEC_COMMAND, Some(&args))
1363            .await?;
1364
1365        assert!(
1366            reason
1367                .as_deref()
1368                .is_some_and(|text| text.contains("without sandbox restrictions"))
1369        );
1370        Ok(())
1371    }
1372
1373    #[tokio::test]
1374    async fn direct_unsandboxed_exec_requires_operator_preapproval() {
1375        let (_temp, registry) = new_test_registry().await;
1376        let args = json!({
1377            "cmd": "printf guarded",
1378            "sandbox_permissions": "require_escalated",
1379            "justification": "Need unsandboxed access for this check."
1380        });
1381
1382        let outcome = registry
1383            .execute_public_tool_request(ToolExecutionRequest::new(tool_names::EXEC_COMMAND, args))
1384            .await;
1385        let error = outcome.error.expect("direct escalation must be rejected");
1386        assert!(error.message.contains("requires an enforced operator approval decision"));
1387    }
1388
1389    #[tokio::test]
1390    async fn write_stdin_preflight_uses_session_write_validation() -> Result<()> {
1391        let (_temp, registry) = new_test_registry().await;
1392
1393        let result = preflight_validate_call(
1394            &registry,
1395            tool_names::WRITE_STDIN,
1396            &json!({
1397                "session_id": "run-1",
1398                "chars": "git reset --hard HEAD~1\n"
1399            }),
1400        )?;
1401
1402        assert_eq!(result.normalized_tool_name, tool_names::WRITE_STDIN);
1403        assert_eq!(result.effective_args["action"], "write");
1404        assert_eq!(result.effective_args["input"], "git reset --hard HEAD~1\n");
1405        assert!(!result.readonly_classification);
1406        Ok(())
1407    }
1408
1409    #[tokio::test]
1410    async fn write_stdin_preflight_uses_session_poll_validation() -> Result<()> {
1411        let (_temp, registry) = new_test_registry().await;
1412
1413        let result = preflight_validate_call(
1414            &registry,
1415            tool_names::WRITE_STDIN,
1416            &json!({
1417                "session_id": "run-1",
1418                "chars": "",
1419                "yield_time_ms": 25,
1420                "max_output_tokens": 7
1421            }),
1422        )?;
1423
1424        assert_eq!(result.normalized_tool_name, tool_names::WRITE_STDIN);
1425        assert_eq!(result.effective_args["action"], "poll");
1426        assert!(result.effective_args.get("input").is_none());
1427        assert_eq!(result.effective_args["yield_time_ms"], 25);
1428        assert_eq!(result.effective_args["max_output_tokens"], 7);
1429        assert!(result.readonly_classification);
1430        Ok(())
1431    }
1432
1433    #[tokio::test]
1434    async fn write_stdin_poll_preflight_rejects_non_string_session_id() {
1435        let (_temp, registry) = new_test_registry().await;
1436
1437        let error = preflight_validate_call(&registry, tool_names::WRITE_STDIN, &json!({"session_id": 1, "chars": ""}))
1438            .expect_err("non-string session id should fail preflight");
1439
1440        assert!(error.to_string().contains("write_stdin"));
1441        assert!(error.to_string().contains("Missing required argument: session_id"));
1442    }
1443
1444    #[tokio::test]
1445    async fn command_session_preflight_rejects_missing_action_without_inferable_args() {
1446        let (_temp, registry) = new_test_registry().await;
1447
1448        let err = preflight_validate_resolved_call(&registry, tool_names::UNIFIED_EXEC, &json!({}))
1449            .expect_err("missing action should fail preflight");
1450
1451        assert!(err.to_string().contains(&format!(
1452            "Invalid arguments for tool '{}': missing action; provide `action` or inferable exec arguments",
1453            tool_names::UNIFIED_EXEC
1454        )));
1455    }
1456
1457    #[tokio::test]
1458    async fn command_session_preflight_rejects_write_without_input() {
1459        let (_temp, registry) = new_test_registry().await;
1460
1461        let err = preflight_validate_resolved_call(
1462            &registry,
1463            tool_names::UNIFIED_EXEC,
1464            &json!({"action": "write", "session_id": "run-1"}),
1465        )
1466        .expect_err("missing input should fail preflight");
1467
1468        assert!(err.to_string().contains("Missing required argument: input or chars or text"));
1469    }
1470
1471    #[tokio::test]
1472    async fn command_session_preflight_rejects_poll_without_session_id() {
1473        let (_temp, registry) = new_test_registry().await;
1474
1475        let err = preflight_validate_resolved_call(&registry, tool_names::UNIFIED_EXEC, &json!({"action": "poll"}))
1476            .expect_err("missing session_id should fail preflight");
1477
1478        assert!(err.to_string().contains("Missing required argument: session_id"));
1479    }
1480
1481    #[tokio::test]
1482    async fn command_session_preflight_accepts_list_without_extra_args() -> Result<()> {
1483        let (_temp, registry) = new_test_registry().await;
1484
1485        let result = preflight_validate_resolved_call(&registry, tool_names::UNIFIED_EXEC, &json!({"action": "list"}))?;
1486
1487        assert_eq!(result.normalized_tool_name, tool_names::UNIFIED_EXEC);
1488        Ok(())
1489    }
1490
1491    #[tokio::test]
1492    async fn command_session_preflight_accepts_inspect_with_spool_path() -> Result<()> {
1493        let (_temp, registry) = new_test_registry().await;
1494
1495        let result = preflight_validate_resolved_call(
1496            &registry,
1497            tool_names::UNIFIED_EXEC,
1498            &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/out.log"}),
1499        )?;
1500
1501        assert_eq!(result.normalized_tool_name, tool_names::UNIFIED_EXEC);
1502        Ok(())
1503    }
1504
1505    #[tokio::test]
1506    async fn file_operation_command_payload_preflight_remaps_to_command_session() -> Result<()> {
1507        let (_temp, registry) = new_test_registry().await;
1508
1509        let result = preflight_validate_resolved_call(
1510            &registry,
1511            tool_names::UNIFIED_FILE,
1512            &json!({
1513                "command": "echo vtcode",
1514                "cwd": ".",
1515            }),
1516        )?;
1517
1518        assert_eq!(result.normalized_tool_name, tool_names::UNIFIED_EXEC);
1519        assert_eq!(result.effective_args["action"], "run");
1520        assert_eq!(result.effective_args["command"], "echo vtcode");
1521        assert_eq!(result.effective_args["cwd"], ".");
1522        Ok(())
1523    }
1524}