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        .iter()
589        .map(|candidate| candidate.trim())
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            let candidate = candidate.to_ascii_lowercase();
598            registry
599                .inventory
600                .registration_for(&candidate)
601                .map(|registration| registration.name().to_string())
602        })
603}
604
605pub(super) fn preflight_validate_resolved_call(
606    registry: &ToolRegistry,
607    normalized_tool_name: &str,
608    args: &Value,
609) -> Result<ToolPreflightOutcome> {
610    let mut routed_tool_name = normalized_tool_name.to_string();
611    let mut validation_tool_name = routed_tool_name.clone();
612    let parameter_schema = registry
613        .inventory
614        .registration_for(normalized_tool_name)
615        .and_then(|registration| registration.parameter_schema().cloned());
616    let mut validation_args = normalize_tool_args(normalized_tool_name, args, parameter_schema.as_ref())?;
617    let mut effective_args = None;
618    // Schema for the tool we ultimately validate against. Defaults to the
619    // originally-resolved tool's schema; each remap branch overwrites it with
620    // the exec schema it already fetches, so we never re-look-up the same
621    // registration (the previous form fetched the same schema up to 3x).
622    let mut effective_parameter_schema = parameter_schema;
623
624    crate::tools::output_limits::max_output_tokens(validation_args.as_ref())
625        .map_err(|error| anyhow!("Invalid arguments for tool '{routed_tool_name}': {error}"))?;
626
627    if let Some(exec_args) = public_exec_validation_args(normalized_tool_name, validation_args.as_ref())? {
628        validation_tool_name = tool_names::UNIFIED_EXEC.to_string();
629        validation_args = std::borrow::Cow::Owned(exec_args);
630        effective_args = Some(validation_args.as_ref().clone());
631        effective_parameter_schema = registry
632            .inventory
633            .registration_for(&validation_tool_name)
634            .and_then(|registration| registration.parameter_schema().cloned());
635    } else if normalized_tool_name == tool_names::UNIFIED_FILE
636        && let Some(remapped_args) =
637            crate::tools::tool_intent::remap_file_operation_command_args_to_command_session(validation_args.as_ref())
638    {
639        routed_tool_name = tool_names::UNIFIED_EXEC.to_string();
640        validation_tool_name = tool_names::UNIFIED_EXEC.to_string();
641        effective_parameter_schema = registry
642            .inventory
643            .registration_for(&validation_tool_name)
644            .and_then(|registration| registration.parameter_schema().cloned());
645        validation_args = std::borrow::Cow::Owned(
646            normalize_tool_args(&validation_tool_name, &remapped_args, effective_parameter_schema.as_ref())?
647                .into_owned(),
648        );
649        effective_args = Some(validation_args.as_ref().clone());
650    }
651
652    if validation_tool_name == tool_names::TASK_TRACKER {
653        effective_parameter_schema =
654            Some(crate::tools::handlers::task_tracker::task_tracker_parameter_schema_for_workflow(
655                registry.is_planning_active(),
656            ));
657        validation_args = std::borrow::Cow::Owned(
658            normalize_tool_args(&validation_tool_name, validation_args.as_ref(), effective_parameter_schema.as_ref())?
659                .into_owned(),
660        );
661        if effective_args.is_some() {
662            effective_args = Some(validation_args.as_ref().clone());
663        }
664    }
665
666    let required = required_args_for_tool(&validation_tool_name);
667    let mut failures = Vec::with_capacity(required.len());
668    for key in required {
669        if is_missing_required_arg(&validation_tool_name, validation_args.as_ref(), key) {
670            failures.push(if validation_tool_name == tool_names::APPLY_PATCH {
671                crate::tools::apply_patch::APPLY_PATCH_ARGUMENT_CORRECTION.to_string()
672            } else {
673                missing_required_arg_failure(key)
674            });
675        }
676    }
677    if validation_tool_name == tool_names::UNIFIED_EXEC {
678        failures.extend(
679            crate::tools::command_args::command_session_missing_required_args(validation_args.as_ref())
680                .into_iter()
681                .map(missing_required_arg_failure),
682        );
683    }
684
685    if let Some(path) = PATH_ALIAS_KEYS
686        .iter()
687        .find_map(|key| validation_args.as_ref().get(*key).and_then(Value::as_str))
688        && let Err(err) = paths::validate_path_safety(path)
689    {
690        failures.push(format!("Path security check failed: {err}"));
691    }
692
693    let should_validate_command = matches!(
694        validation_tool_name.as_str(),
695        tool_names::RUN_PTY_CMD | tool_names::CREATE_PTY_SESSION | tool_names::SHELL
696    ) || (validation_tool_name == tool_names::UNIFIED_EXEC
697        && crate::tools::command_args::command_session_requires_command_safety(validation_args.as_ref()));
698    if should_validate_command {
699        let command_value = crate::tools::command_args::normalized_command_value(validation_args.as_ref())
700            .ok()
701            .flatten();
702        let collision_command = match &command_value {
703            Some(Value::String(command)) => Some(command.as_str()),
704            Some(Value::Array(command)) => command.first().and_then(Value::as_str),
705            _ => None,
706        };
707        if collision_command.is_some_and(crate::tools::names::is_apply_patch_shell_collision_command) {
708            failures.push(format!(
709                "apply_patch is a tool, not a shell executable. {}",
710                crate::tools::apply_patch::APPLY_PATCH_ARGUMENT_CORRECTION
711            ));
712        }
713        let validation_result = match command_value {
714            Some(Value::Array(_)) => crate::tools::command_args::command_words(validation_args.as_ref())
715                .ok()
716                .flatten()
717                .map_or(Ok(()), |command| commands::validate_command_argv(&command)),
718            Some(Value::String(command)) => commands::validate_shell_script(&command),
719            _ => Ok(()),
720        };
721        if let Err(err) = validation_result {
722            failures.push(format!("Command security check failed: {err}"));
723        }
724    }
725    enforce_file_operation_payload_limit(
726        &validation_tool_name,
727        validation_args.as_ref(),
728        configured_file_operation_max_payload_bytes(),
729        &mut failures,
730    );
731
732    if !failures.is_empty() {
733        return Err(anyhow!("Tool preflight validation failed for '{}': {}", routed_tool_name, failures.join("; ")));
734    }
735
736    if validation_tool_name == tool_names::UNIFIED_EXEC
737        && crate::tools::tool_intent::command_session_action(validation_args.as_ref()).is_none()
738    {
739        return Err(anyhow!(
740            "Invalid arguments for tool '{routed_tool_name}': missing action; provide `action` or inferable exec arguments"
741        ));
742    }
743    let schema_validation_args = crate::tools::output_limits::args_without_output_metadata(validation_args.as_ref());
744    if let Some(schema) = effective_parameter_schema.as_ref() {
745        let error_msg = match jsonschema::validator_for(schema) {
746            Ok(validator) => validator
747                .iter_errors(&schema_validation_args)
748                .map(|error| crate::tools::validation::describe_jsonschema_error(&error))
749                .collect::<Vec<_>>()
750                .join("; "),
751            Err(schema_error) => crate::tools::validation::describe_jsonschema_error(&schema_error),
752        };
753        if !error_msg.is_empty() {
754            let hint_msg = condensed_schema_hint(schema)
755                .map(|hint| format!("\nExpected schema (required fields and types): {hint}"))
756                .unwrap_or_default();
757            let patch_hint = if validation_tool_name == tool_names::APPLY_PATCH {
758                crate::tools::apply_patch::APPLY_PATCH_ARGUMENT_CORRECTION
759            } else if validation_tool_name == tool_names::TASK_TRACKER
760                && crate::tools::handlers::task_tracker::is_task_tracker_shape_error(&error_msg)
761            {
762                crate::tools::handlers::task_tracker::TASK_TRACKER_ARGUMENT_CORRECTION
763            } else {
764                ""
765            };
766            return Err(anyhow!("Invalid arguments for tool '{routed_tool_name}': {error_msg}{hint_msg} {patch_hint}"));
767        }
768    }
769    if validation_tool_name == tool_names::CODE_SEARCH {
770        crate::tools::code_search::validate_args(&schema_validation_args)
771            .map_err(|error| anyhow!("Invalid arguments for tool '{routed_tool_name}': {error}"))?;
772    }
773
774    let intent = crate::tools::tool_intent::classify_tool_intent(&validation_tool_name, validation_args.as_ref());
775    let readonly_classification = !intent.mutating;
776    if registry.is_planning_active()
777        && !registry.is_planning_active_allowed_with_intent(&validation_tool_name, validation_args.as_ref(), &intent)
778    {
779        let msg = agent_execution::planning_workflow_denial_message(&routed_tool_name);
780        return Err(anyhow!(msg).context(agent_execution::PLANNING_DENIED_CONTEXT));
781    }
782
783    Ok(ToolPreflightOutcome {
784        normalized_tool_name: routed_tool_name.clone(),
785        readonly_classification,
786        parallel_safe_after_preflight: crate::tools::tool_intent::is_parallel_safe_call_with_intent(
787            &validation_tool_name,
788            validation_args.as_ref(),
789            &intent,
790        ),
791        effective_args: effective_args.unwrap_or_else(|| validation_args.into_owned()),
792        intent,
793    })
794}
795
796#[cfg(test)]
797mod tests {
798    use super::super::ToolExecutionRequest;
799    use super::super::assembly::public_tool_name_candidates;
800    use super::{
801        DispatchMode, ToolRegistry, coerce_string_to_schema_type_in_place, configured_file_operation_max_payload_bytes,
802        enforce_file_operation_payload_limit, is_missing_required_arg, normalize_tool_args,
803        parse_file_operation_max_payload_bytes, parse_string_as_schema_type, preflight_validate_call,
804        preflight_validate_resolved_call, public_exec_validation_args, resolve_dispatch_target,
805        resolve_internal_dispatch_tool,
806    };
807    use crate::config::constants::tools as tool_names;
808    use crate::tools::command_args::parse_indexed_command_parts;
809    use crate::tools::request_user_input::RequestUserInputTool;
810    use crate::tools::traits::Tool;
811    use anyhow::Result;
812    use serde_json::{Value, json};
813
814    async fn new_test_registry() -> (tempfile::TempDir, ToolRegistry) {
815        let temp = tempfile::tempdir().expect("temp workspace");
816        let registry = ToolRegistry::new(temp.path().to_path_buf()).await;
817        (temp, registry)
818    }
819
820    #[tokio::test]
821    async fn internal_dispatch_preserves_normalization_and_candidate_order() {
822        let (_temp, registry) = new_test_registry().await;
823        for name in [
824            "read_file",
825            "  ReAd_FiLe  ",
826            "functions.ReAd_FiLe",
827            "tools.read-file",
828            "READ FILE",
829            "unknown:READ_FILE",
830        ] {
831            assert_eq!(
832                resolve_internal_dispatch_tool(&registry, name).as_deref(),
833                Some(tool_names::READ_FILE),
834                "{name}"
835            );
836            assert_eq!(resolve_dispatch_target(&registry, name, DispatchMode::Harness).unwrap(), tool_names::READ_FILE);
837            assert!(resolve_dispatch_target(&registry, name, DispatchMode::ModelPublic).is_err(), "{name}");
838        }
839        for name in [tool_names::WRITE_FILE, tool_names::EDIT_FILE, tool_names::LIST_FILES] {
840            assert_eq!(resolve_internal_dispatch_tool(&registry, name).as_deref(), Some(name));
841        }
842        assert_eq!(
843            resolve_internal_dispatch_tool(&registry, "read_file<|channel|>WRITE_FILE").as_deref(),
844            Some(tool_names::WRITE_FILE)
845        );
846        assert_eq!(
847            resolve_internal_dispatch_tool(&registry, "read_file<|channel|>write_file:list_files").as_deref(),
848            Some(tool_names::READ_FILE)
849        );
850    }
851
852    #[tokio::test]
853    async fn internal_dispatch_rejects_hidden_tools_outside_allowlist_and_near_misses() {
854        let (_temp, registry) = new_test_registry().await;
855        assert!(registry.inventory.registration_for(tool_names::GET_ERRORS).is_some());
856        for name in [
857            "",
858            " \t\u{2003}",
859            "GET_ERRORS",
860            "functions.get_errors",
861            "read_files",
862            "repo_browser.read_file",
863            "READ_FİLE",
864        ] {
865            assert!(resolve_internal_dispatch_tool(&registry, name).is_none(), "{name:?}");
866            assert!(resolve_dispatch_target(&registry, name, DispatchMode::Harness).is_err(), "{name:?}");
867            assert!(resolve_dispatch_target(&registry, name, DispatchMode::ModelPublic).is_err(), "{name:?}");
868        }
869    }
870
871    #[test]
872    fn normalize_elides_truncation_only_piped_verifier() {
873        // The pipeline exit status belongs to the truncator, so the piped
874        // form must never reach validation or execution: the standalone
875        // verifier runs instead and its status is truthful.
876        let args = json!({"action": "run", "command": "cargo check --locked 2>&1 | head -c 4000"});
877        let normalized =
878            normalize_tool_args(tool_names::UNIFIED_EXEC, &args, None).expect("rewrite must not fail normalization");
879        assert_eq!(normalized.as_ref()["command"], "cargo check --locked 2>&1");
880        // The caller's `cmd` spelling is left intact as the typed record.
881        assert_eq!(normalized.as_ref()["cmd"], Value::Null);
882
883        let via_cmd = json!({"action": "run", "cmd": "cargo nextest run 2>&1 | tail -15"});
884        let via_cmd =
885            normalize_tool_args(tool_names::UNIFIED_EXEC, &via_cmd, None).expect("rewrite must not fail normalization");
886        assert_eq!(via_cmd.as_ref()["command"], "cargo nextest run 2>&1");
887        assert_eq!(via_cmd.as_ref()["cmd"], "cargo nextest run 2>&1 | tail -15");
888    }
889
890    #[test]
891    fn normalize_leaves_non_rewritable_pipelines_untouched() {
892        for command in [
893            "cargo check | grep error; true",
894            "cargo check && rm -rf target | tail -5",
895            "cargo check | tail -5; rm foo.txt",
896            "rg -n 'pattern' src | head -20",
897            "cargo check --locked",
898        ] {
899            let args = json!({"action": "run", "command": command});
900            let normalized =
901                normalize_tool_args(tool_names::UNIFIED_EXEC, &args, None).expect("normalization must succeed");
902            assert_eq!(
903                normalized.as_ref()["command"],
904                Value::String(command.to_string()),
905                "must run as typed: {command}"
906            );
907        }
908    }
909
910    #[test]
911    fn exec_command_validation_path_rewrites_piped_verifier() {
912        let args = json!({"cmd": "cargo check --locked | tail -8"});
913        let Some(exec_args) =
914            public_exec_validation_args(tool_names::EXEC_COMMAND, &args).expect("validation args must build")
915        else {
916            panic!("EXEC_COMMAND run must produce exec validation args");
917        };
918        assert_eq!(exec_args["command"], "cargo check --locked");
919    }
920
921    #[tokio::test]
922    async fn preflight_accepts_output_limit_for_legacy_strict_schemas() {
923        let (_temp, registry) = new_test_registry().await;
924        let outcome = preflight_validate_call(
925            &registry,
926            tool_names::CODE_SEARCH,
927            &json!({"query": "ToolRegistry", "max_output_tokens": 37}),
928        )
929        .expect("a valid integer output limit should pass preflight");
930        assert_eq!(outcome.effective_args["max_output_tokens"], 37);
931    }
932
933    #[tokio::test]
934    async fn preflight_uses_default_output_limit_when_omitted() {
935        let (_temp, registry) = new_test_registry().await;
936        let outcome = preflight_validate_call(&registry, tool_names::CODE_SEARCH, &json!({"query": "ToolRegistry"}))
937            .expect("omitted output limits should remain dispatchable");
938
939        assert_eq!(
940            crate::tools::output_limits::max_output_tokens(&outcome.effective_args)
941                .expect("default output limit should be valid"),
942            vtcode_utility_tool_specs::DEFAULT_MAX_OUTPUT_TOKENS
943        );
944    }
945
946    #[tokio::test]
947    async fn preflight_rejects_invalid_output_limits_before_dispatch() {
948        let (_temp, registry) = new_test_registry().await;
949        let error = preflight_validate_call(
950            &registry,
951            tool_names::CODE_SEARCH,
952            &json!({"query": "ToolRegistry", "max_output_tokens": "37"}),
953        )
954        .expect_err("string output limits must be rejected");
955        assert!(error.to_string().contains("max_output_tokens must be an integer"));
956    }
957
958    // --- schema-aware string-type coercion -------------------------------------
959    //
960    // Regression coverage for the checkpoint-observed defect where the model
961    // emitted JSON-encoded strings (`"[\"path\"]"`, `"10"`) for array/integer
962    // fields and retried the same malformed call six times in one turn.
963
964    #[test]
965    fn parse_string_as_schema_type_coerces_array_and_integer() {
966        assert_eq!(parse_string_as_schema_type(r#"["path"]"#, "array"), Some(json!(["path"])));
967        assert_eq!(parse_string_as_schema_type("10", "integer"), Some(json!(10)));
968    }
969
970    #[test]
971    fn parse_string_as_schema_type_coerces_boolean_object_and_number() {
972        assert_eq!(parse_string_as_schema_type("true", "boolean"), Some(json!(true)));
973        assert_eq!(parse_string_as_schema_type("false", "boolean"), Some(json!(false)));
974        assert_eq!(parse_string_as_schema_type(r#"{"a": 1}"#, "object"), Some(json!({"a": 1})));
975        assert_eq!(parse_string_as_schema_type("3.5", "number"), Some(json!(3.5)));
976        // An integer literal is also a valid JSON number.
977        assert_eq!(parse_string_as_schema_type("3", "number"), Some(json!(3)));
978    }
979
980    #[test]
981    fn parse_string_as_schema_type_rejects_float_for_integer() {
982        // "10.0" must not be silently coerced to an integer: that would be a
983        // lossy reinterpretation. Let strict validation surface the type error.
984        assert_eq!(parse_string_as_schema_type("10.0", "integer"), None);
985    }
986
987    #[test]
988    fn parse_string_as_schema_type_rejects_mismatched_and_malformed() {
989        // Parsed type does not match schema type.
990        assert_eq!(parse_string_as_schema_type(r#"{"a": 1}"#, "array"), None);
991        assert_eq!(parse_string_as_schema_type(r#"["path"]"#, "object"), None);
992        assert_eq!(parse_string_as_schema_type("10", "boolean"), None);
993        // Malformed JSON stays a string and is left for strict validation.
994        assert_eq!(parse_string_as_schema_type("not json", "array"), None);
995        assert_eq!(parse_string_as_schema_type("[\"path\"", "array"), None);
996        // Unknown schema type is never coerced.
997        assert_eq!(parse_string_as_schema_type("anything", "string"), None);
998    }
999
1000    #[test]
1001    fn coerce_string_to_schema_type_in_place_only_touches_strings() {
1002        let mut already_array = json!(["path"]);
1003        let schema = json!({"type": "array"});
1004        assert!(!coerce_string_to_schema_type_in_place(&mut already_array, &schema));
1005        assert_eq!(already_array, json!(["path"]));
1006
1007        let mut no_type = json!("keep me");
1008        let schema = json!({}); // no declared type → free-form string
1009        assert!(!coerce_string_to_schema_type_in_place(&mut no_type, &schema));
1010        assert_eq!(no_type, json!("keep me"));
1011    }
1012
1013    #[test]
1014    fn normalize_tool_args_coerces_code_search_stringified_args() {
1015        // Mirrors the exact checkpoint defect: result_types and max_results
1016        // arrive as JSON-encoded strings.
1017        let schema = json!({
1018            "type": "object",
1019            "properties": {
1020                "query": {"type": "string"},
1021                "result_types": {"type": "array", "items": {"type": "string", "enum": ["definition", "usage", "text", "path"]}},
1022                "max_results": {"type": "integer", "minimum": 1, "maximum": 100}
1023            }
1024        });
1025        let args = json!({
1026            "query": "turn_loop",
1027            "result_types": "[\"path\"]",
1028            "max_results": "10"
1029        });
1030        let normalized = normalize_tool_args(tool_names::CODE_SEARCH, &args, Some(&schema))
1031            .expect("code_search stringified args normalize");
1032        assert_eq!(normalized["result_types"], json!(["path"]));
1033        assert_eq!(normalized["max_results"], json!(10));
1034        assert_eq!(normalized["query"], json!("turn_loop"));
1035    }
1036
1037    #[tokio::test]
1038    async fn preflight_coerces_stringified_code_search_args_then_validates() {
1039        let (_temp, registry) = new_test_registry().await;
1040        // The exact malformed shape from the checkpoint should now pass preflight.
1041        let outcome = preflight_validate_call(
1042            &registry,
1043            tool_names::CODE_SEARCH,
1044            &json!({"query": "turn_loop", "result_types": "[\"path\"]", "max_results": "10"}),
1045        )
1046        .expect("stringified args should be coerced and pass preflight");
1047        assert_eq!(outcome.effective_args["result_types"], json!(["path"]));
1048        assert_eq!(outcome.effective_args["max_results"], json!(10));
1049    }
1050
1051    #[tokio::test]
1052    async fn preflight_coercion_still_enforces_bounds_and_enum() {
1053        let (_temp, registry) = new_test_registry().await;
1054        // Coerced to integer 999, then the max=100 bound must still reject it.
1055        let over_max = preflight_validate_call(
1056            &registry,
1057            tool_names::CODE_SEARCH,
1058            &json!({"query": "turn_loop", "max_results": "999"}),
1059        )
1060        .expect_err("coerced integer must still be bounds-checked");
1061        assert!(over_max.to_string().contains("max_results"), "msg: {over_max}");
1062
1063        // Coerced to array ["bogus"], then the item enum must still reject it.
1064        let bad_enum = preflight_validate_call(
1065            &registry,
1066            tool_names::CODE_SEARCH,
1067            &json!({"query": "turn_loop", "result_types": "[\"bogus\"]"}),
1068        )
1069        .expect_err("coerced array items must still be enum-checked");
1070        assert!(bad_enum.to_string().contains("result_types"), "msg: {bad_enum}");
1071    }
1072
1073    #[tokio::test]
1074    async fn preflight_does_not_coerce_max_output_tokens_string() {
1075        // The output-budget field has a dedicated strict validator that must keep
1076        // rejecting strings even though schema-aware coercion exists for other
1077        // integer fields.
1078        let (_temp, registry) = new_test_registry().await;
1079        let error = preflight_validate_call(
1080            &registry,
1081            tool_names::CODE_SEARCH,
1082            &json!({"query": "x", "max_output_tokens": "100"}),
1083        )
1084        .expect_err("max_output_tokens string must remain rejected");
1085        assert!(error.to_string().contains("max_output_tokens must be an integer"));
1086    }
1087    #[test]
1088    fn patch_action_within_limit_is_allowed() {
1089        let mut failures = Vec::new();
1090        let args = json!({
1091            "action": "patch",
1092            "patch": "*** Begin Patch\n*** End Patch\n"
1093        });
1094
1095        enforce_file_operation_payload_limit(tool_names::UNIFIED_FILE, &args, 1024, &mut failures);
1096        assert!(failures.is_empty());
1097    }
1098    #[test]
1099    fn patch_action_over_limit_is_rejected() {
1100        let mut failures = Vec::new();
1101        let args = json!({
1102            "action": "patch",
1103            "patch": "x".repeat(512)
1104        });
1105
1106        enforce_file_operation_payload_limit(tool_names::UNIFIED_FILE, &args, 128, &mut failures);
1107        assert_eq!(failures.len(), 1);
1108        assert!(failures[0].contains("payload too large"));
1109        assert!(failures[0].contains("Split the change"));
1110    }
1111    #[test]
1112    fn edit_tool_over_limit_is_rejected() {
1113        let mut failures = Vec::new();
1114        let args = json!({
1115            "path": "file.txt",
1116            "old_str": "old",
1117            "new_str": "x".repeat(512)
1118        });
1119
1120        enforce_file_operation_payload_limit(tool_names::EDIT_FILE, &args, 128, &mut failures);
1121        assert_eq!(failures.len(), 1);
1122        assert!(failures[0].contains("action='edit'"));
1123    }
1124
1125    #[test]
1126    fn read_action_is_not_limited() {
1127        let mut failures = Vec::new();
1128        let args = json!({
1129            "action": "read",
1130            "path": "README.md"
1131        });
1132
1133        enforce_file_operation_payload_limit(tool_names::UNIFIED_FILE, &args, 1, &mut failures);
1134        assert!(failures.is_empty());
1135    }
1136
1137    #[test]
1138    fn edit_file_required_args_accept_legacy_key_names() {
1139        let args = json!({
1140            "path": "file.txt",
1141            "old_string": "old",
1142            "new_string": "new"
1143        });
1144
1145        assert!(!is_missing_required_arg(tool_names::EDIT_FILE, &args, "path"));
1146        assert!(!is_missing_required_arg(tool_names::EDIT_FILE, &args, "old_str"));
1147        assert!(!is_missing_required_arg(tool_names::EDIT_FILE, &args, "new_str"));
1148    }
1149
1150    #[test]
1151    fn parse_payload_limit_accepts_safe_override() {
1152        let parsed = parse_file_operation_max_payload_bytes(Some("2048"));
1153        assert_eq!(parsed, Some(2048));
1154    }
1155
1156    #[test]
1157    fn parse_payload_limit_rejects_too_small_values() {
1158        let parsed = parse_file_operation_max_payload_bytes(Some("512"));
1159        assert_eq!(parsed, None);
1160    }
1161
1162    #[test]
1163    fn parse_payload_limit_rejects_invalid_values() {
1164        let parsed = parse_file_operation_max_payload_bytes(Some("not-a-number"));
1165        assert_eq!(parsed, None);
1166    }
1167
1168    #[test]
1169    fn configured_payload_limit_is_always_safe() {
1170        let configured = configured_file_operation_max_payload_bytes();
1171        assert!(configured >= 1024);
1172    }
1173
1174    #[test]
1175    fn apply_patch_required_arg_accepts_input_alias() {
1176        assert!(!is_missing_required_arg(tool_names::APPLY_PATCH, &json!({"input": ""}), "input"));
1177    }
1178
1179    #[test]
1180    fn apply_patch_required_arg_accepts_raw_string_payload() {
1181        assert!(!is_missing_required_arg(tool_names::APPLY_PATCH, &json!(""), "input"));
1182    }
1183
1184    #[test]
1185    fn run_pty_cmd_required_arg_accepts_zero_based_indexed_command() -> Result<()> {
1186        let input = json!({
1187            "command.0": "ls",
1188            "command.1": "-a"
1189        });
1190        let args = normalize_tool_args(tool_names::RUN_PTY_CMD, &input, None)?;
1191
1192        assert!(!is_missing_required_arg(tool_names::RUN_PTY_CMD, args.as_ref(), "command"));
1193        assert_eq!(args.get("command").and_then(|value| value.as_str()), Some("ls -a"));
1194        Ok(())
1195    }
1196
1197    #[test]
1198    fn run_pty_cmd_required_arg_accepts_one_based_indexed_command() -> Result<()> {
1199        let input = json!({
1200            "command.1": "ls",
1201            "command.2": "-a"
1202        });
1203        let args = normalize_tool_args(tool_names::RUN_PTY_CMD, &input, None)?;
1204
1205        assert!(!is_missing_required_arg(tool_names::RUN_PTY_CMD, args.as_ref(), "command"));
1206        assert_eq!(args.get("command").and_then(|value| value.as_str()), Some("ls -a"));
1207        Ok(())
1208    }
1209
1210    #[test]
1211    fn indexed_command_parts_require_zero_or_one_based_sequences() {
1212        assert_eq!(
1213            parse_indexed_command_parts(
1214                json!({
1215                    "command.0": "ls",
1216                    "command.1": "-a"
1217                })
1218                .as_object()
1219                .expect("object"),
1220            )
1221            .expect("valid indexed args"),
1222            Some(vec!["ls".to_string(), "-a".to_string()])
1223        );
1224        assert_eq!(
1225            parse_indexed_command_parts(
1226                json!({
1227                    "command.1": "ls",
1228                    "command.2": "-a"
1229                })
1230                .as_object()
1231                .expect("object"),
1232            )
1233            .expect("valid indexed args"),
1234            Some(vec!["ls".to_string(), "-a".to_string()])
1235        );
1236        assert_eq!(
1237            parse_indexed_command_parts(json!({"command.2": "ls"}).as_object().expect("object"))
1238                .expect("valid indexed args"),
1239            None
1240        );
1241    }
1242
1243    #[test]
1244    fn tool_name_candidates_extract_channel_suffix_alias() {
1245        let candidates = public_tool_name_candidates("assistant<|channel|>apply_patch");
1246        assert!(candidates.iter().any(|c| c == "apply_patch"));
1247    }
1248
1249    #[test]
1250    fn tool_name_candidates_normalize_humanized_name() {
1251        let candidates = public_tool_name_candidates("Read file");
1252        assert!(candidates.iter().any(|c| c == "read_file"));
1253    }
1254
1255    #[test]
1256    fn request_user_input_args_accept_details_alias() -> Result<()> {
1257        let schema = RequestUserInputTool.parameter_schema().expect("request_user_input schema");
1258        let args = json!({
1259            "questions": [{
1260                "id": "scope",
1261                "header": "Scope",
1262                "question": "Which direction should we take?",
1263                "options": [
1264                    {
1265                        "label": "Minimal",
1266                        "details": "Ship the smallest viable slice."
1267                    },
1268                    {
1269                        "label": "Full",
1270                        "details": "Ship the full implementation."
1271                    }
1272                ]
1273            }]
1274        });
1275
1276        let normalized = normalize_tool_args(tool_names::REQUEST_USER_INPUT, &args, Some(&schema))?;
1277        let option = &normalized["questions"][0]["options"][0];
1278        assert_eq!(option.get("description").and_then(Value::as_str), Some("Ship the smallest viable slice."));
1279        assert!(option.get("details").is_none());
1280        Ok(())
1281    }
1282
1283    #[test]
1284    fn task_tracker_args_accept_details_alias() -> Result<()> {
1285        let schema = json!({
1286            "type": "object",
1287            "properties": {
1288                "action": { "type": "string" },
1289                "description": { "type": "string" }
1290            }
1291        });
1292        let args = json!({
1293            "action": "add",
1294            "details": "Add regression coverage"
1295        });
1296
1297        let normalized = normalize_tool_args(tool_names::TASK_TRACKER, &args, Some(&schema))?;
1298        assert_eq!(normalized.get("description").and_then(Value::as_str), Some("Add regression coverage"));
1299        assert!(normalized.get("details").is_none());
1300        Ok(())
1301    }
1302
1303    #[test]
1304    fn details_alias_does_not_shadow_real_details_field() -> Result<()> {
1305        let schema = json!({
1306            "type": "object",
1307            "properties": {
1308                "description": { "type": "string" },
1309                "details": { "type": "string" }
1310            }
1311        });
1312        let args = json!({
1313            "details": "Keep the real details field."
1314        });
1315
1316        let normalized = normalize_tool_args(tool_names::TASK_TRACKER, &args, Some(&schema))?;
1317        assert!(normalized.get("description").is_none());
1318        assert_eq!(normalized.get("details").and_then(Value::as_str), Some("Keep the real details field."));
1319        Ok(())
1320    }
1321
1322    #[tokio::test]
1323    async fn command_session_preflight_rejects_run_without_command() {
1324        let (_temp, registry) = new_test_registry().await;
1325
1326        let err = preflight_validate_resolved_call(&registry, tool_names::UNIFIED_EXEC, &json!({"action": "run"}))
1327            .expect_err("missing command should fail preflight");
1328
1329        assert!(err.to_string().contains("Missing required argument: command"));
1330    }
1331
1332    #[tokio::test]
1333    async fn exec_command_preflight_preserves_public_name_and_validates_as_run() -> Result<()> {
1334        let (_temp, registry) = new_test_registry().await;
1335
1336        let result = preflight_validate_call(
1337            &registry,
1338            tool_names::EXEC_COMMAND,
1339            &json!({"cmd": "rg --files", "workdir": ".", "tty": true}),
1340        )?;
1341
1342        assert_eq!(result.normalized_tool_name, tool_names::EXEC_COMMAND);
1343        assert_eq!(result.effective_args["action"], "run");
1344        assert_eq!(result.effective_args["command"], "rg --files");
1345        assert_eq!(result.effective_args["workdir"], ".");
1346        assert_eq!(result.effective_args["tty"], true);
1347        assert!(result.readonly_classification);
1348        Ok(())
1349    }
1350
1351    #[tokio::test]
1352    async fn planning_preflight_rejects_mutating_suffixes_and_conflicting_raw_aliases() {
1353        let (_temp, registry) = new_test_registry().await;
1354        registry.enable_planning();
1355        for args in [
1356            json!({"cmd":"cargo check | sort", "args":["-o", "changed.txt"]}),
1357            json!({"cmd":"cat README.md", "raw_command":"printf changed > changed.txt"}),
1358        ] {
1359            let error = preflight_validate_call(&registry, tool_names::EXEC_COMMAND, &args)
1360                .expect_err("mutating invocation cannot acquire planning admission");
1361            assert_eq!(error.to_string(), super::agent_execution::PLANNING_DENIED_CONTEXT);
1362        }
1363        let safe =
1364            preflight_validate_call(&registry, tool_names::EXEC_COMMAND, &json!({"cmd":"sort", "args":["README.md"]}))
1365                .expect("safe appended operands remain readable in planning");
1366        assert!(safe.readonly_classification);
1367        assert!(safe.parallel_safe_after_preflight);
1368    }
1369
1370    #[tokio::test]
1371    async fn planning_preflight_allows_checkpoint_style_readonly_inspection() -> Result<()> {
1372        let (_temp, registry) = new_test_registry().await;
1373        registry.enable_planning();
1374        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"#;
1375
1376        let result = preflight_validate_call(
1377            &registry,
1378            tool_names::EXEC_COMMAND,
1379            &json!({
1380                "cmd": command,
1381                "workdir": ".",
1382                "yield_time_ms": 10000,
1383                "max_output_tokens": 30000
1384            }),
1385        )?;
1386
1387        assert!(result.readonly_classification);
1388        assert_eq!(result.effective_args["action"], "run");
1389        Ok(())
1390    }
1391
1392    #[tokio::test]
1393    async fn exec_command_preflight_rejects_dangerous_command() {
1394        let (_temp, registry) = new_test_registry().await;
1395
1396        let err =
1397            preflight_validate_call(&registry, tool_names::EXEC_COMMAND, &json!({"cmd": "git reset --hard HEAD~1"}))
1398                .expect_err("dangerous exec_command should fail preflight");
1399
1400        let text = err.to_string();
1401        assert!(text.contains("Tool preflight validation failed for 'exec_command'"));
1402        assert!(text.contains("Command security check failed"));
1403    }
1404
1405    #[tokio::test]
1406    async fn exec_command_approval_required_payload_is_seen_as_shell_run() -> Result<()> {
1407        let (_temp, registry) = new_test_registry().await;
1408        let args = json!({
1409            "cmd": "cargo check",
1410            "sandbox_permissions": "require_escalated",
1411            "justification": "Need unsandboxed access for this check."
1412        });
1413
1414        let reason = registry
1415            .shell_run_approval_reason(tool_names::EXEC_COMMAND, Some(&args))
1416            .await?;
1417
1418        assert!(
1419            reason
1420                .as_deref()
1421                .is_some_and(|text| text.contains("without sandbox restrictions"))
1422        );
1423        Ok(())
1424    }
1425
1426    #[tokio::test]
1427    async fn direct_unsandboxed_exec_requires_operator_preapproval() {
1428        let (_temp, registry) = new_test_registry().await;
1429        let args = json!({
1430            "cmd": "printf guarded",
1431            "sandbox_permissions": "require_escalated",
1432            "justification": "Need unsandboxed access for this check."
1433        });
1434
1435        let outcome = registry
1436            .execute_public_tool_request(ToolExecutionRequest::new(tool_names::EXEC_COMMAND, args))
1437            .await;
1438        let error = outcome.error.expect("direct escalation must be rejected");
1439        assert!(error.message.contains("requires an enforced operator approval decision"));
1440    }
1441
1442    #[tokio::test]
1443    async fn write_stdin_preflight_uses_session_write_validation() -> Result<()> {
1444        let (_temp, registry) = new_test_registry().await;
1445
1446        let result = preflight_validate_call(
1447            &registry,
1448            tool_names::WRITE_STDIN,
1449            &json!({
1450                "session_id": "run-1",
1451                "chars": "git reset --hard HEAD~1\n"
1452            }),
1453        )?;
1454
1455        assert_eq!(result.normalized_tool_name, tool_names::WRITE_STDIN);
1456        assert_eq!(result.effective_args["action"], "write");
1457        assert_eq!(result.effective_args["input"], "git reset --hard HEAD~1\n");
1458        assert!(!result.readonly_classification);
1459        Ok(())
1460    }
1461
1462    #[tokio::test]
1463    async fn write_stdin_preflight_uses_session_poll_validation() -> Result<()> {
1464        let (_temp, registry) = new_test_registry().await;
1465
1466        let result = preflight_validate_call(
1467            &registry,
1468            tool_names::WRITE_STDIN,
1469            &json!({
1470                "session_id": "run-1",
1471                "chars": "",
1472                "yield_time_ms": 25,
1473                "max_output_tokens": 7
1474            }),
1475        )?;
1476
1477        assert_eq!(result.normalized_tool_name, tool_names::WRITE_STDIN);
1478        assert_eq!(result.effective_args["action"], "poll");
1479        assert!(result.effective_args.get("input").is_none());
1480        assert_eq!(result.effective_args["yield_time_ms"], 25);
1481        assert_eq!(result.effective_args["max_output_tokens"], 7);
1482        assert!(result.readonly_classification);
1483        Ok(())
1484    }
1485
1486    #[tokio::test]
1487    async fn write_stdin_poll_preflight_rejects_non_string_session_id() {
1488        let (_temp, registry) = new_test_registry().await;
1489
1490        let error = preflight_validate_call(&registry, tool_names::WRITE_STDIN, &json!({"session_id": 1, "chars": ""}))
1491            .expect_err("non-string session id should fail preflight");
1492
1493        assert!(error.to_string().contains("write_stdin"));
1494        assert!(error.to_string().contains("Missing required argument: session_id"));
1495    }
1496
1497    #[tokio::test]
1498    async fn command_session_preflight_rejects_missing_action_without_inferable_args() {
1499        let (_temp, registry) = new_test_registry().await;
1500
1501        let err = preflight_validate_resolved_call(&registry, tool_names::UNIFIED_EXEC, &json!({}))
1502            .expect_err("missing action should fail preflight");
1503
1504        assert!(err.to_string().contains(&format!(
1505            "Invalid arguments for tool '{}': missing action; provide `action` or inferable exec arguments",
1506            tool_names::UNIFIED_EXEC
1507        )));
1508    }
1509
1510    #[tokio::test]
1511    async fn command_session_preflight_rejects_write_without_input() {
1512        let (_temp, registry) = new_test_registry().await;
1513
1514        let err = preflight_validate_resolved_call(
1515            &registry,
1516            tool_names::UNIFIED_EXEC,
1517            &json!({"action": "write", "session_id": "run-1"}),
1518        )
1519        .expect_err("missing input should fail preflight");
1520
1521        assert!(err.to_string().contains("Missing required argument: input or chars or text"));
1522    }
1523
1524    #[tokio::test]
1525    async fn command_session_preflight_rejects_poll_without_session_id() {
1526        let (_temp, registry) = new_test_registry().await;
1527
1528        let err = preflight_validate_resolved_call(&registry, tool_names::UNIFIED_EXEC, &json!({"action": "poll"}))
1529            .expect_err("missing session_id should fail preflight");
1530
1531        assert!(err.to_string().contains("Missing required argument: session_id"));
1532    }
1533
1534    #[tokio::test]
1535    async fn command_session_preflight_accepts_list_without_extra_args() -> Result<()> {
1536        let (_temp, registry) = new_test_registry().await;
1537
1538        let result = preflight_validate_resolved_call(&registry, tool_names::UNIFIED_EXEC, &json!({"action": "list"}))?;
1539
1540        assert_eq!(result.normalized_tool_name, tool_names::UNIFIED_EXEC);
1541        Ok(())
1542    }
1543
1544    #[tokio::test]
1545    async fn command_session_preflight_accepts_inspect_with_spool_path() -> Result<()> {
1546        let (_temp, registry) = new_test_registry().await;
1547
1548        let result = preflight_validate_resolved_call(
1549            &registry,
1550            tool_names::UNIFIED_EXEC,
1551            &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/out.log"}),
1552        )?;
1553
1554        assert_eq!(result.normalized_tool_name, tool_names::UNIFIED_EXEC);
1555        Ok(())
1556    }
1557
1558    #[tokio::test]
1559    async fn file_operation_command_payload_preflight_remaps_to_command_session() -> Result<()> {
1560        let (_temp, registry) = new_test_registry().await;
1561
1562        let result = preflight_validate_resolved_call(
1563            &registry,
1564            tool_names::UNIFIED_FILE,
1565            &json!({
1566                "command": "echo vtcode",
1567                "cwd": ".",
1568            }),
1569        )?;
1570
1571        assert_eq!(result.normalized_tool_name, tool_names::UNIFIED_EXEC);
1572        assert_eq!(result.effective_args["action"], "run");
1573        assert_eq!(result.effective_args["command"], "echo vtcode");
1574        assert_eq!(result.effective_args["cwd"], ".");
1575        Ok(())
1576    }
1577}