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
20pub(super) const PATH_ALIAS_KEYS: [&str; 5] = ["path", "file_path", "filepath", "target_path", "file"];
28
29const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub(super) enum DispatchMode {
59 ModelPublic,
62 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 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
130fn 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 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 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
220fn 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
252fn 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
295fn 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
306fn 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 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 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
485pub(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
506pub(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
538fn 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
576fn 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 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(®istry, name).as_deref(),
833 Some(tool_names::READ_FILE),
834 "{name}"
835 );
836 assert_eq!(resolve_dispatch_target(®istry, name, DispatchMode::Harness).unwrap(), tool_names::READ_FILE);
837 assert!(resolve_dispatch_target(®istry, 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(®istry, name).as_deref(), Some(name));
841 }
842 assert_eq!(
843 resolve_internal_dispatch_tool(®istry, "read_file<|channel|>WRITE_FILE").as_deref(),
844 Some(tool_names::WRITE_FILE)
845 );
846 assert_eq!(
847 resolve_internal_dispatch_tool(®istry, "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(®istry, name).is_none(), "{name:?}");
866 assert!(resolve_dispatch_target(®istry, name, DispatchMode::Harness).is_err(), "{name:?}");
867 assert!(resolve_dispatch_target(®istry, name, DispatchMode::ModelPublic).is_err(), "{name:?}");
868 }
869 }
870
871 #[test]
872 fn normalize_elides_truncation_only_piped_verifier() {
873 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 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 ®istry,
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(®istry, 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 ®istry,
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 #[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 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 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 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 assert_eq!(parse_string_as_schema_type("not json", "array"), None);
995 assert_eq!(parse_string_as_schema_type("[\"path\"", "array"), None);
996 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!({}); 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 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 let outcome = preflight_validate_call(
1042 ®istry,
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 let over_max = preflight_validate_call(
1056 ®istry,
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 let bad_enum = preflight_validate_call(
1065 ®istry,
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 let (_temp, registry) = new_test_registry().await;
1079 let error = preflight_validate_call(
1080 ®istry,
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(®istry, 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 ®istry,
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(®istry, 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(®istry, 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 ®istry,
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(®istry, 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 ®istry,
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 ®istry,
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(®istry, 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(®istry, 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 ®istry,
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(®istry, 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(®istry, 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 ®istry,
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 ®istry,
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}