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