1use std::collections::BTreeMap;
2use std::fs;
3use std::io::{Read, Write};
4use std::path::Path;
5use std::process::{Child, Command, ExitStatus, Stdio};
6use std::sync::Mutex;
7use std::thread;
8use std::time::{Duration, Instant};
9
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use thiserror::Error;
13
14use crate::event::EventStore;
15use crate::genui::{
16 ActionCatalog, ActionSourceType, HostCapabilities, HostConfirmation, Surface,
17 derive_host_workspace_state_identity,
18};
19use crate::inference::ToolCall;
20use crate::mcp::{LiveToolIdentity, McpClientSet};
21use crate::workspace::{Workspace, WorkspaceError};
22
23#[derive(Debug, Clone, Copy)]
24pub struct ToolLimits {
25 pub timeout: Duration,
26 pub max_output_bytes: usize,
27 pub max_file_bytes: usize,
28}
29
30impl Default for ToolLimits {
31 fn default() -> Self {
32 Self {
33 timeout: Duration::from_secs(60),
34 max_output_bytes: 256 * 1024,
35 max_file_bytes: 512 * 1024,
36 }
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct ToolResult {
42 pub tool: String,
43 pub tool_call_id: Option<String>,
44 pub ok: bool,
45 pub exit_code: Option<i32>,
46 pub timed_out: bool,
47 pub stdout: String,
48 pub stderr: String,
49 pub output_truncated: bool,
50 pub metadata: Value,
51}
52
53impl ToolResult {
54 #[must_use]
55 pub fn rejected(tool: &str, error: &str) -> Self {
56 Self {
57 tool: tool.to_owned(),
58 tool_call_id: None,
59 ok: false,
60 exit_code: None,
61 timed_out: false,
62 stdout: String::new(),
63 stderr: error.to_owned(),
64 output_truncated: false,
65 metadata: json!({"validation_error": error}),
66 }
67 }
68}
69
70#[derive(Debug, Error)]
71pub enum ToolError {
72 #[error(transparent)]
73 Workspace(#[from] WorkspaceError),
74 #[error("invalid {tool} arguments: {message}")]
75 InvalidArguments { tool: String, message: String },
76 #[error("unknown tool: {0}")]
77 UnknownTool(String),
78 #[error("tool I/O failed: {0}")]
79 Io(#[from] std::io::Error),
80 #[error("tool JSON failed: {0}")]
81 Json(#[from] serde_json::Error),
82 #[error("patch is malformed or unsafe: {0}")]
83 InvalidPatch(String),
84 #[error("GenUI action rejected: {0}")]
85 GenUi(String),
86}
87
88#[derive(Debug)]
89pub struct NativeTools {
90 workspace: Workspace,
91 limits: ToolLimits,
92 mcp: Option<McpClientSet>,
93 workspace_authority: Mutex<Option<(String, u64)>>,
94}
95
96impl NativeTools {
97 #[must_use]
98 pub const fn new(workspace: Workspace, limits: ToolLimits) -> Self {
99 Self {
100 workspace,
101 limits,
102 mcp: None,
103 workspace_authority: Mutex::new(None),
104 }
105 }
106
107 #[must_use]
108 pub fn with_mcp(mut self, mcp: McpClientSet) -> Self {
109 self.mcp = Some(mcp);
110 self
111 }
112
113 #[must_use]
114 pub fn workspace(&self) -> &Workspace {
115 &self.workspace
116 }
117
118 pub(crate) fn live_workspace_token(
125 &self,
126 session_id: &str,
127 principal: &str,
128 ) -> Result<((String, String), u64), WorkspaceError> {
129 let state = self.workspace.state()?;
130 let identity = derive_host_workspace_state_identity(session_id, principal, &state);
131 let fingerprint = format!("{}\n{}", identity.0, identity.1);
132 let mut authority = self
133 .workspace_authority
134 .lock()
135 .expect("workspace authority lock is not poisoned");
136 let generation = authority.as_ref().map_or(1, |(_, generation)| *generation);
137 *authority = Some((fingerprint, generation));
138 Ok((identity, generation))
139 }
140
141 fn note_workspace_mutation(&self) {
142 let mut authority = self
143 .workspace_authority
144 .lock()
145 .expect("workspace authority lock is not poisoned");
146 let generation = authority
147 .as_ref()
148 .map_or(1, |(_, generation)| generation.saturating_add(1));
149 let fingerprint = authority
150 .as_ref()
151 .map_or_else(String::new, |(fingerprint, _)| fingerprint.clone());
152 *authority = Some((fingerprint, generation));
153 }
154
155 #[must_use]
157 pub fn workspace_authority_generation(&self) -> u64 {
158 self.workspace_authority
159 .lock()
160 .expect("workspace authority lock is not poisoned")
161 .as_ref()
162 .map_or(0, |(_, generation)| *generation)
163 }
164
165 #[must_use]
166 pub fn may_mutate_workspace(&self, call: &ToolCall) -> bool {
167 matches!(call.name.as_str(), "apply_patch" | "shell")
168 || self
169 .mcp
170 .as_ref()
171 .is_some_and(|mcp| mcp.may_mutate(&call.name))
172 }
173
174 #[must_use]
175 pub fn tool_schemas(&self) -> Value {
176 let mut schemas = crate::inference::tool_schemas()
177 .as_array()
178 .expect("native tool schemas are an array")
179 .clone();
180 if let Some(mcp) = &self.mcp {
181 schemas.extend_from_slice(mcp.tool_schemas());
182 }
183 Value::Array(schemas)
184 }
185
186 #[must_use]
187 pub fn mcp_discovery_metadata(&self) -> Option<Value> {
188 self.mcp.as_ref().map(McpClientSet::discovery_metadata)
189 }
190
191 #[must_use]
193 pub fn live_mcp_tool_identity(&self, tool_name: &str) -> Option<LiveToolIdentity> {
194 self.mcp.as_ref()?.live_tool_identity(tool_name)
195 }
196
197 #[must_use]
200 pub fn live_mcp_registry_digest(&self) -> Option<String> {
201 self.mcp.as_ref().map(McpClientSet::live_registry_digest)
202 }
203
204 #[must_use]
206 pub fn live_mcp_registry_generation(&self) -> u64 {
207 self.mcp
208 .as_ref()
209 .map_or(0, McpClientSet::live_registry_generation)
210 }
211
212 #[doc(hidden)]
216 pub fn mcp_mut_for_testing(&mut self) -> Option<&mut McpClientSet> {
217 self.mcp.as_mut()
218 }
219
220 pub fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
221 match call.name.as_str() {
222 "read_file" => self.read_file(&call.arguments),
223 "search" => self.search(&call.arguments),
224 "apply_patch" => self.apply_patch(&call.arguments),
225 "shell" => self.shell(&call.arguments),
226 "git" => self.git(&call.arguments),
227 _ => {
228 let mcp = self
229 .mcp
230 .as_ref()
231 .ok_or_else(|| ToolError::UnknownTool(call.name.clone()))?;
232 if !mcp.handles(&call.name) {
233 return Err(ToolError::UnknownTool(call.name.clone()));
234 }
235 if mcp.may_mutate(&call.name) {
240 let mut result = ToolResult::rejected(
241 &call.name,
242 "host confirmation is required for consequential MCP tools",
243 );
244 result.metadata = json!({
245 "authority_state": "confirmation_required",
246 "confirmation_required": true,
247 "tool": call.name,
248 });
249 return Ok(result);
250 }
251 mcp.execute_validated(call)
252 .ok_or_else(|| ToolError::UnknownTool(call.name.clone()))
253 }
254 }
255 }
256
257 fn validate_fresh_workspace_before_transport(
258 &self,
259 store: &EventStore,
260 session_id: &str,
261 principal: &str,
262 catalog: &ActionCatalog,
263 action_id: &str,
264 ) -> Result<(), ToolError> {
265 let workspace_state = self.workspace.state()?;
266 let (fresh_digest, fresh_authorization_context) =
267 derive_host_workspace_state_identity(session_id, principal, &workspace_state);
268 catalog
269 .validate_fresh_workspace_identity(
270 store,
271 action_id,
272 &fresh_digest,
273 &fresh_authorization_context,
274 )
275 .map_err(|error| ToolError::GenUi(error.to_string()))
276 }
277
278 fn mark_stale_execution_unknown(
279 &self,
280 store: &mut EventStore,
281 session_id: &str,
282 action_id: &str,
283 identity: &Value,
284 ) -> Result<(), ToolError> {
285 store
286 .mark_genui_action_unknown(
287 session_id,
288 action_id,
289 identity,
290 "workspace_changed_before_external_transport",
291 )
292 .map(|_| ())
293 .map_err(|error| ToolError::GenUi(error.to_string()))
294 }
295
296 #[allow(clippy::too_many_arguments)]
301 pub fn execute_genui_action(
302 &self,
303 store: &mut EventStore,
304 session_id: &str,
305 principal: &str,
306 surface: &Surface,
307 catalog: &ActionCatalog,
308 action_id: &str,
309 payload: Value,
310 confirmation: Option<&HostConfirmation>,
311 ) -> Result<ToolResult, ToolError> {
312 catalog
313 .validate_surface(surface, &HostCapabilities::default())
314 .map_err(|error| ToolError::GenUi(error.to_string()))?;
315 let binding = catalog
316 .resolve(action_id)
317 .ok_or_else(|| ToolError::GenUi("action is not in host catalog".to_owned()))?;
318 match binding.source_type() {
319 ActionSourceType::Mcp => {}
320 ActionSourceType::HostLocal => {
321 return Err(ToolError::GenUi(
322 "host-local action requires the host-local adapter".to_owned(),
323 ));
324 }
325 ActionSourceType::Auto => {
326 return Err(ToolError::GenUi(
327 "source-unresolved action cannot be executed".to_owned(),
328 ));
329 }
330 }
331 if binding.session_id() != session_id
332 || binding.principal() != principal
333 || binding.state_digest()
334 != surface
335 .actions
336 .iter()
337 .find(|a| a.id == action_id)
338 .map(|a| a.state_digest.as_str())
339 .unwrap_or("")
340 {
341 return Err(ToolError::GenUi(
342 "session, principal, or state binding changed".to_owned(),
343 ));
344 }
345 let mcp = self
346 .mcp
347 .as_ref()
348 .ok_or_else(|| ToolError::GenUi("MCP is not configured".to_owned()))?;
349 let (policy_version, policy_digest, requires_confirmation) = mcp
350 .tool_policy_identity(binding.tool_name())
351 .ok_or_else(|| ToolError::GenUi("MCP tool policy disappeared".to_owned()))?;
352 mcp.validate_tool_identity_full(
353 binding.tool_name(),
354 binding.provider_id(),
355 binding.server_id(),
356 binding.schema_digest(),
357 policy_version,
358 &policy_digest,
359 )
360 .map_err(|error| ToolError::GenUi(error.to_string()))?;
361 let (_, _, remote_tool) = mcp
362 .tool_identity(binding.tool_name())
363 .ok_or_else(|| ToolError::GenUi("MCP remote tool disappeared".to_owned()))?;
364 if binding.remote_tool_name() != remote_tool {
365 return Err(ToolError::GenUi(
366 "MCP remote tool identity changed".to_owned(),
367 ));
368 }
369 if binding.policy_version() != policy_version
370 || binding.policy_digest() != policy_digest
371 || binding.requires_confirmation() != requires_confirmation
372 {
373 return Err(ToolError::GenUi(
374 "host tool policy changed since presentation".to_owned(),
375 ));
376 }
377 mcp.validate_tool_arguments(binding.tool_name(), &payload)
378 .map_err(|error| ToolError::GenUi(error.to_string()))?;
379 let identity = catalog
380 .identity_for(action_id, &payload)
381 .map_err(|error| ToolError::GenUi(error.to_string()))?;
382 if binding.requires_confirmation() {
383 let supplied = confirmation
384 .ok_or_else(|| ToolError::GenUi("host confirmation is required".to_owned()))?;
385 if supplied.identity() != &identity {
386 return Err(ToolError::GenUi(
387 "confirmation is stale or bound to another action".to_owned(),
388 ));
389 }
390 }
391 catalog
397 .validate_durable_current_action(store, action_id)
398 .map_err(|error| ToolError::GenUi(error.to_string()))?;
399 store
400 .start_genui_action(
401 session_id,
402 action_id,
403 &identity,
404 binding.requires_confirmation(),
405 )
406 .map_err(|error| ToolError::GenUi(error.to_string()))?;
407 catalog
408 .validate_current_action(action_id)
409 .map_err(|error| ToolError::GenUi(error.to_string()))?;
410 catalog
411 .validate_durable_current_action(store, action_id)
412 .map_err(|error| ToolError::GenUi(error.to_string()))?;
413 mcp.validate_tool_identity_full(
414 binding.tool_name(),
415 binding.provider_id(),
416 binding.server_id(),
417 binding.schema_digest(),
418 policy_version,
419 &policy_digest,
420 )
421 .map_err(|error| ToolError::GenUi(error.to_string()))?;
422 let (_, _, remote_tool) = mcp
423 .tool_identity(binding.tool_name())
424 .ok_or_else(|| ToolError::GenUi("MCP remote tool disappeared".to_owned()))?;
425 if binding.remote_tool_name() != remote_tool {
426 return Err(ToolError::GenUi(
427 "MCP remote tool identity changed".to_owned(),
428 ));
429 }
430 if let Err(error) = self.validate_fresh_workspace_before_transport(
431 store, session_id, principal, catalog, action_id,
432 ) {
433 self.mark_stale_execution_unknown(store, session_id, action_id, &identity)?;
434 return Err(error);
435 }
436 let call = ToolCall {
437 id: None,
438 name: binding.tool_name().to_owned(),
439 arguments: payload,
440 };
441 let result = mcp
442 .execute_validated_authorized(&call)
443 .ok_or_else(|| ToolError::UnknownTool(binding.tool_name().to_owned()))?;
444 let result_digest = crate::genui::digest_value(&result.metadata);
445 store
446 .complete_genui_action(session_id, action_id, &identity, result.ok, &result_digest)
447 .map_err(|error| ToolError::GenUi(error.to_string()))?;
448 Ok(result)
449 }
450
451 #[allow(clippy::too_many_arguments)]
456 pub fn execute_genui_action_after_durable_start(
457 &self,
458 store: &mut EventStore,
459 session_id: &str,
460 principal: &str,
461 surface: &Surface,
462 catalog: &ActionCatalog,
463 action_id: &str,
464 payload: Value,
465 ) -> Result<ToolResult, ToolError> {
466 catalog
467 .validate_surface(surface, &HostCapabilities::default())
468 .map_err(|error| ToolError::GenUi(error.to_string()))?;
469 let binding = catalog
470 .resolve(action_id)
471 .ok_or_else(|| ToolError::GenUi("action is not in host catalog".to_owned()))?;
472 if binding.source_type() != ActionSourceType::Mcp {
473 return Err(ToolError::GenUi(
474 "durable continuation requires an explicit MCP source".to_owned(),
475 ));
476 }
477 if binding.session_id() != session_id || binding.principal() != principal {
478 return Err(ToolError::GenUi("session or principal changed".to_owned()));
479 }
480 let identity = catalog
481 .identity_for(action_id, &payload)
482 .map_err(|error| ToolError::GenUi(error.to_string()))?;
483 let Some((kind, started_identity)) = store
484 .replay_genui_action(session_id, action_id)
485 .map_err(|error| ToolError::GenUi(error.to_string()))?
486 else {
487 return Err(ToolError::GenUi(
488 "execution start is not durable for this exact identity".to_owned(),
489 ));
490 };
491 if kind != crate::event::EventKind::GenUiActionExecutionStarted
492 || started_identity != identity
493 {
494 return Err(ToolError::GenUi(
495 "execution start is not a valid unresolved lifecycle state".to_owned(),
496 ));
497 }
498 catalog
499 .validate_current_action(action_id)
500 .map_err(|error| ToolError::GenUi(error.to_string()))?;
501 catalog
502 .validate_durable_current_action(store, action_id)
503 .map_err(|error| ToolError::GenUi(error.to_string()))?;
504 let mcp = self
505 .mcp
506 .as_ref()
507 .ok_or_else(|| ToolError::GenUi("MCP is not configured".to_owned()))?;
508 let (policy_version, policy_digest, requires_confirmation) = mcp
509 .tool_policy_identity(binding.tool_name())
510 .ok_or_else(|| ToolError::GenUi("MCP tool policy disappeared".to_owned()))?;
511 if !requires_confirmation || !binding.requires_confirmation() {
512 return Err(ToolError::GenUi(
513 "Crash-C continuation requires a consequential policy".to_owned(),
514 ));
515 }
516 if binding.policy_version() != policy_version || binding.policy_digest() != policy_digest {
517 return Err(ToolError::GenUi(
518 "MCP tool policy changed before Crash-C continuation".to_owned(),
519 ));
520 }
521 mcp.validate_tool_identity_full(
522 binding.tool_name(),
523 binding.provider_id(),
524 binding.server_id(),
525 binding.schema_digest(),
526 policy_version,
527 &policy_digest,
528 )
529 .map_err(|error| ToolError::GenUi(error.to_string()))?;
530 let (_, _, remote_tool) = mcp
531 .tool_identity(binding.tool_name())
532 .ok_or_else(|| ToolError::GenUi("MCP remote tool disappeared".to_owned()))?;
533 if binding.remote_tool_name() != remote_tool {
534 return Err(ToolError::GenUi(
535 "MCP remote tool identity changed".to_owned(),
536 ));
537 }
538 mcp.validate_tool_arguments(binding.tool_name(), &payload)
539 .map_err(|error| ToolError::GenUi(error.to_string()))?;
540 if let Err(error) = self.validate_fresh_workspace_before_transport(
541 store, session_id, principal, catalog, action_id,
542 ) {
543 self.mark_stale_execution_unknown(store, session_id, action_id, &identity)?;
544 return Err(error);
545 }
546 let call = ToolCall {
547 id: None,
548 name: binding.tool_name().to_owned(),
549 arguments: payload,
550 };
551 mcp.execute_validated_authorized(&call)
552 .ok_or_else(|| ToolError::UnknownTool(binding.tool_name().to_owned()))
553 }
554
555 pub fn confirm_genui_action(
559 &self,
560 store: &mut EventStore,
561 catalog: &ActionCatalog,
562 confirmation: &HostConfirmation,
563 ) -> Result<(), ToolError> {
564 let binding = catalog
565 .validate_confirmation_identity(confirmation)
566 .map_err(|error| ToolError::GenUi(error.to_string()))?;
567 if binding.source_type() != ActionSourceType::Mcp {
568 return Err(ToolError::GenUi(
569 "confirmation transport is not MCP for this binding".to_owned(),
570 ));
571 }
572 let session_id = confirmation
573 .identity()
574 .get("session_id")
575 .and_then(Value::as_str)
576 .ok_or_else(|| ToolError::GenUi("confirmation has no session identity".to_owned()))?;
577 catalog
578 .validate_durable_current_action(store, binding.action_id())
579 .map_err(|error| ToolError::GenUi(error.to_string()))?;
580 let mcp = self
581 .mcp
582 .as_ref()
583 .ok_or_else(|| ToolError::GenUi("MCP is not configured".to_owned()))?;
584 let (policy_version, policy_digest, requires_confirmation) = mcp
585 .tool_policy_identity(binding.tool_name())
586 .ok_or_else(|| ToolError::GenUi("MCP tool policy disappeared".to_owned()))?;
587 mcp.validate_tool_identity_full(
588 binding.tool_name(),
589 binding.provider_id(),
590 binding.server_id(),
591 binding.schema_digest(),
592 policy_version,
593 &policy_digest,
594 )
595 .map_err(|error| ToolError::GenUi(error.to_string()))?;
596 let (_, _, remote_tool) = mcp
597 .tool_identity(binding.tool_name())
598 .ok_or_else(|| ToolError::GenUi("MCP remote tool disappeared".to_owned()))?;
599 if binding.remote_tool_name() != remote_tool {
600 return Err(ToolError::GenUi(
601 "MCP remote tool identity changed before confirmation".to_owned(),
602 ));
603 }
604 if binding.policy_version() != policy_version
605 || binding.policy_digest() != policy_digest
606 || binding.requires_confirmation() != requires_confirmation
607 {
608 return Err(ToolError::GenUi(
609 "live MCP policy changed before confirmation".to_owned(),
610 ));
611 }
612 mcp.validate_tool_arguments(binding.tool_name(), confirmation.payload())
613 .map_err(|error| ToolError::GenUi(error.to_string()))?;
614 let identity = catalog
615 .identity_for(binding.action_id(), confirmation.payload())
616 .map_err(|error| ToolError::GenUi(error.to_string()))?;
617 if confirmation.identity() != &identity {
618 return Err(ToolError::GenUi(
619 "confirmation payload does not match its bound identity".to_owned(),
620 ));
621 }
622 catalog
623 .append_confirmed_after_live_validation(store, confirmation)
624 .map_err(|error| ToolError::GenUi(format!("{session_id}: {error}")))
625 }
626
627 fn read_file(&self, arguments: &Value) -> Result<ToolResult, ToolError> {
628 #[derive(Deserialize)]
629 #[serde(deny_unknown_fields)]
630 struct Arguments {
631 path: String,
632 }
633 let arguments: Arguments = parse_arguments("read_file", arguments)?;
634 let path = self.workspace.resolve(&arguments.path)?;
635 let bytes = fs::read(&path)?;
636 if bytes.len() > self.limits.max_file_bytes {
637 return Err(ToolError::InvalidArguments {
638 tool: "read_file".to_owned(),
639 message: format!("file exceeds {} byte limit", self.limits.max_file_bytes),
640 });
641 }
642 let contents = String::from_utf8(bytes).map_err(|error| ToolError::InvalidArguments {
643 tool: "read_file".to_owned(),
644 message: format!("file is not UTF-8: {error}"),
645 })?;
646 Ok(success(
647 "read_file",
648 contents,
649 json!({"path": arguments.path}),
650 ))
651 }
652
653 fn search(&self, arguments: &Value) -> Result<ToolResult, ToolError> {
654 #[derive(Deserialize)]
655 #[serde(deny_unknown_fields)]
656 struct Arguments {
657 query: String,
658 #[serde(default)]
659 path: Option<String>,
660 }
661 let arguments: Arguments = parse_arguments("search", arguments)?;
662 if arguments.query.is_empty() {
663 return Err(invalid("search", "query must not be empty"));
664 }
665 let relative = arguments.path.unwrap_or_else(|| ".".to_owned());
666 self.workspace.resolve(&relative)?;
667 let argv = vec![
668 "rg".to_owned(),
669 "--line-number".to_owned(),
670 "--color=never".to_owned(),
671 "--".to_owned(),
672 arguments.query,
673 relative,
674 ];
675 let outcome = run_command(
676 self.workspace.root(),
677 &argv,
678 &BTreeMap::new(),
679 None,
680 self.limits.timeout,
681 self.limits.max_output_bytes,
682 )?;
683 Ok(outcome.into_tool_result("search", json!({})))
684 }
685
686 fn apply_patch(&self, arguments: &Value) -> Result<ToolResult, ToolError> {
687 let _guard = crate::mcp::acquire_publication_guard();
688 #[derive(Deserialize)]
689 #[serde(deny_unknown_fields)]
690 struct Arguments {
691 patch: String,
692 }
693 let arguments: Arguments = parse_arguments("apply_patch", arguments)?;
694 validate_patch(&self.workspace, &arguments.patch)?;
695 let check = run_command(
696 self.workspace.root(),
697 &[
698 "git".to_owned(),
699 "apply".to_owned(),
700 "--check".to_owned(),
701 "-".to_owned(),
702 ],
703 &BTreeMap::new(),
704 Some(arguments.patch.as_bytes()),
705 self.limits.timeout,
706 self.limits.max_output_bytes,
707 )?;
708 if !check.status.success() {
709 return Ok(check.into_tool_result("apply_patch", json!({"phase": "check"})));
710 }
711 let outcome = run_command(
712 self.workspace.root(),
713 &["git".to_owned(), "apply".to_owned(), "-".to_owned()],
714 &BTreeMap::new(),
715 Some(arguments.patch.as_bytes()),
716 self.limits.timeout,
717 self.limits.max_output_bytes,
718 )?;
719 let result = outcome.into_tool_result("apply_patch", json!({"phase": "apply"}));
720 if result.ok {
721 self.note_workspace_mutation();
722 }
723 Ok(result)
724 }
725
726 fn shell(&self, arguments: &Value) -> Result<ToolResult, ToolError> {
727 let _guard = crate::mcp::acquire_publication_guard();
728 #[derive(Deserialize)]
729 #[serde(deny_unknown_fields)]
730 struct Arguments {
731 argv: Vec<String>,
732 #[serde(default)]
733 cwd: Option<String>,
734 #[serde(default)]
735 timeout_ms: Option<u64>,
736 #[serde(default)]
737 env: BTreeMap<String, String>,
738 }
739 let arguments: Arguments = parse_arguments("shell", arguments)?;
740 if arguments.argv.is_empty() || arguments.argv[0].is_empty() {
741 return Err(invalid("shell", "argv must contain a non-empty program"));
742 }
743 if arguments
744 .env
745 .keys()
746 .any(|key| key.contains('=') || key.contains('\0'))
747 {
748 return Err(invalid("shell", "environment key is invalid"));
749 }
750 let cwd = self
751 .workspace
752 .resolve(arguments.cwd.as_deref().unwrap_or("."))?;
753 if !cwd.is_dir() {
754 return Err(invalid("shell", "cwd is not a directory"));
755 }
756 let timeout = arguments
757 .timeout_ms
758 .map_or(self.limits.timeout, Duration::from_millis)
759 .min(self.limits.timeout);
760 let outcome = run_command(
761 &cwd,
762 &arguments.argv,
763 &arguments.env,
764 None,
765 timeout,
766 self.limits.max_output_bytes,
767 )?;
768 let result = outcome.into_tool_result(
769 "shell",
770 json!({"argv": arguments.argv, "cwd": arguments.cwd, "timeout_ms": timeout.as_millis()}),
771 );
772 if result.ok {
773 self.note_workspace_mutation();
776 }
777 Ok(result)
778 }
779
780 fn git(&self, arguments: &Value) -> Result<ToolResult, ToolError> {
781 #[derive(Deserialize)]
782 #[serde(deny_unknown_fields)]
783 struct Arguments {
784 operation: String,
785 #[serde(default)]
786 revision: Option<String>,
787 }
788 let arguments: Arguments = parse_arguments("git", arguments)?;
789 let argv = match arguments.operation.as_str() {
790 "status" => vec!["git", "status", "--short", "--branch"],
791 "diff" => vec!["git", "diff", "--no-ext-diff", "--"],
792 "show" => {
793 let revision = arguments
794 .revision
795 .as_deref()
796 .ok_or_else(|| invalid("git", "show requires revision"))?;
797 if revision.starts_with('-') || revision.contains('\0') {
798 return Err(invalid("git", "revision is invalid"));
799 }
800 vec![
801 "git",
802 "show",
803 "--no-ext-diff",
804 "--format=fuller",
805 revision,
806 "--",
807 ]
808 }
809 _ => return Err(invalid("git", "operation must be status, diff, or show")),
810 };
811 let argv: Vec<String> = argv.into_iter().map(str::to_owned).collect();
812 let outcome = run_command(
813 self.workspace.root(),
814 &argv,
815 &BTreeMap::new(),
816 None,
817 self.limits.timeout,
818 self.limits.max_output_bytes,
819 )?;
820 Ok(outcome.into_tool_result("git", json!({"operation": arguments.operation})))
821 }
822}
823
824fn parse_arguments<T: for<'de> Deserialize<'de>>(
825 tool: &str,
826 value: &Value,
827) -> Result<T, ToolError> {
828 serde_json::from_value(value.clone()).map_err(|error| ToolError::InvalidArguments {
829 tool: tool.to_owned(),
830 message: error.to_string(),
831 })
832}
833
834fn invalid(tool: &str, message: &str) -> ToolError {
835 ToolError::InvalidArguments {
836 tool: tool.to_owned(),
837 message: message.to_owned(),
838 }
839}
840
841fn success(tool: &str, stdout: String, metadata: Value) -> ToolResult {
842 ToolResult {
843 tool: tool.to_owned(),
844 tool_call_id: None,
845 ok: true,
846 exit_code: Some(0),
847 timed_out: false,
848 stdout,
849 stderr: String::new(),
850 output_truncated: false,
851 metadata,
852 }
853}
854
855fn validate_patch(workspace: &Workspace, patch: &str) -> Result<(), ToolError> {
856 if patch.is_empty() || patch.contains('\0') {
857 return Err(ToolError::InvalidPatch(
858 "patch is empty or contains NUL".to_owned(),
859 ));
860 }
861 let mut saw_old_path = false;
862 let mut saw_new_path = false;
863 for line in patch.lines() {
864 let paths: Vec<&str> = if let Some(rest) = line.strip_prefix("diff --git ") {
865 let parts: Vec<&str> = rest.split_whitespace().collect();
866 if parts.len() != 2 {
867 return Err(ToolError::InvalidPatch(
868 "diff header must contain exactly two paths".to_owned(),
869 ));
870 }
871 parts
872 } else if let Some(path) = line.strip_prefix("--- ") {
873 saw_old_path = true;
874 vec![path.split('\t').next().unwrap_or(path)]
875 } else if let Some(path) = line.strip_prefix("+++ ") {
876 saw_new_path = true;
877 vec![path.split('\t').next().unwrap_or(path)]
878 } else {
879 continue;
880 };
881 for raw_path in paths {
882 if raw_path == "/dev/null" {
883 continue;
884 }
885 let relative_path = raw_path
886 .strip_prefix("a/")
887 .or_else(|| raw_path.strip_prefix("b/"))
888 .unwrap_or(raw_path);
889 workspace.resolve(relative_path)?;
890 }
891 }
892 if !saw_old_path || !saw_new_path {
893 return Err(ToolError::InvalidPatch(
894 "missing unified diff file headers".to_owned(),
895 ));
896 }
897 Ok(())
898}
899
900#[derive(Debug)]
901pub(crate) struct CommandOutcome {
902 pub(crate) status: ExitStatus,
903 pub(crate) timed_out: bool,
904 pub(crate) stdout: String,
905 pub(crate) stderr: String,
906 pub(crate) truncated: bool,
907}
908
909impl CommandOutcome {
910 fn into_tool_result(self, tool: &str, metadata: Value) -> ToolResult {
911 ToolResult {
912 tool: tool.to_owned(),
913 tool_call_id: None,
914 ok: self.status.success() && !self.timed_out,
915 exit_code: self.status.code(),
916 timed_out: self.timed_out,
917 stdout: self.stdout,
918 stderr: self.stderr,
919 output_truncated: self.truncated,
920 metadata,
921 }
922 }
923}
924
925pub(crate) fn run_command(
926 cwd: &Path,
927 argv: &[String],
928 env: &BTreeMap<String, String>,
929 stdin: Option<&[u8]>,
930 timeout: Duration,
931 output_limit: usize,
932) -> Result<CommandOutcome, std::io::Error> {
933 let mut command = Command::new(&argv[0]);
934 command
935 .args(&argv[1..])
936 .current_dir(cwd)
937 .envs(env)
938 .stdin(if stdin.is_some() {
939 Stdio::piped()
940 } else {
941 Stdio::null()
942 })
943 .stdout(Stdio::piped())
944 .stderr(Stdio::piped());
945 let mut child = command.spawn()?;
946 if let Some(input) = stdin {
947 let mut child_stdin = child.stdin.take().expect("piped stdin exists");
948 child_stdin.write_all(input)?;
949 }
950 let stdout_reader = bounded_reader(
951 child.stdout.take().expect("piped stdout exists"),
952 output_limit,
953 );
954 let stderr_reader = bounded_reader(
955 child.stderr.take().expect("piped stderr exists"),
956 output_limit,
957 );
958 let (status, timed_out) = wait_bounded(&mut child, timeout)?;
959 let (stdout, stdout_truncated) = stdout_reader.join().expect("stdout reader did not panic")?;
960 let (stderr, stderr_truncated) = stderr_reader.join().expect("stderr reader did not panic")?;
961 Ok(CommandOutcome {
962 status,
963 timed_out,
964 stdout: String::from_utf8_lossy(&stdout).into_owned(),
965 stderr: String::from_utf8_lossy(&stderr).into_owned(),
966 truncated: stdout_truncated || stderr_truncated,
967 })
968}
969
970type ReaderHandle = thread::JoinHandle<std::io::Result<(Vec<u8>, bool)>>;
971
972fn bounded_reader(mut reader: impl Read + Send + 'static, limit: usize) -> ReaderHandle {
973 thread::spawn(move || {
974 let mut retained = Vec::new();
975 let mut buffer = [0_u8; 8192];
976 let mut truncated = false;
977 loop {
978 let count = reader.read(&mut buffer)?;
979 if count == 0 {
980 break;
981 }
982 let remaining = limit.saturating_sub(retained.len());
983 let keep = count.min(remaining);
984 retained.extend_from_slice(&buffer[..keep]);
985 truncated |= keep < count;
986 }
987 Ok((retained, truncated))
988 })
989}
990
991fn wait_bounded(
992 child: &mut Child,
993 timeout: Duration,
994) -> Result<(ExitStatus, bool), std::io::Error> {
995 let started = Instant::now();
996 loop {
997 if let Some(status) = child.try_wait()? {
998 return Ok((status, false));
999 }
1000 if started.elapsed() >= timeout {
1001 child.kill()?;
1002 return child.wait().map(|status| (status, true));
1003 }
1004 thread::sleep(Duration::from_millis(5));
1005 }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use std::fs;
1011 use std::time::Duration;
1012
1013 use serde_json::json;
1014
1015 use crate::inference::ToolCall;
1016 use crate::workspace::Workspace;
1017 use crate::workspace::tests::git_fixture;
1018
1019 use super::{NativeTools, ToolLimits};
1020
1021 fn fixture() -> (tempfile::TempDir, NativeTools) {
1022 let directory = git_fixture();
1023 let workspace = Workspace::open(directory.path()).expect("workspace");
1024 let tools = NativeTools::new(
1025 workspace,
1026 ToolLimits {
1027 timeout: Duration::from_secs(2),
1028 max_output_bytes: 4096,
1029 max_file_bytes: 4096,
1030 },
1031 );
1032 (directory, tools)
1033 }
1034
1035 fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
1036 ToolCall {
1037 id: None,
1038 name: name.to_owned(),
1039 arguments,
1040 }
1041 }
1042
1043 #[test]
1044 fn reads_allowed_file_and_searches() {
1045 let (_directory, tools) = fixture();
1046 let read = tools
1047 .execute(&call("read_file", json!({"path": "hello.txt"})))
1048 .expect("read");
1049 assert_eq!(read.stdout, "old\n");
1050 let search = tools
1051 .execute(&call("search", json!({"query": "old"})))
1052 .expect("search");
1053 assert!(search.ok);
1054 assert!(search.stdout.contains("hello.txt:1:old"));
1055 }
1056
1057 #[test]
1058 fn applies_valid_patch_and_rejects_malformed_patch() {
1059 let (directory, tools) = fixture();
1060 let patch = "diff --git a/hello.txt b/hello.txt\n--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-old\n+new\n";
1061 let result = tools
1062 .execute(&call("apply_patch", json!({"patch": patch})))
1063 .expect("patch");
1064 assert!(result.ok, "{}", result.stderr);
1065 assert_eq!(
1066 fs::read_to_string(directory.path().join("hello.txt")).expect("read"),
1067 "new\n"
1068 );
1069 assert!(
1070 tools
1071 .execute(&call("apply_patch", json!({"patch": "not a patch"})))
1072 .is_err()
1073 );
1074 }
1075
1076 #[test]
1077 fn accepts_standard_unified_diff_without_git_header() {
1078 let (directory, tools) = fixture();
1079 let patch = "--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-old\n+new\n";
1080 let result = tools
1081 .execute(&call("apply_patch", json!({"patch": patch})))
1082 .expect("patch");
1083 assert!(result.ok, "{}", result.stderr);
1084 assert_eq!(
1085 fs::read_to_string(directory.path().join("hello.txt")).expect("read"),
1086 "new\n"
1087 );
1088 }
1089
1090 #[test]
1091 fn shell_reports_success_failure_and_timeout() {
1092 let (_directory, tools) = fixture();
1093 let success = tools
1094 .execute(&call("shell", json!({"argv": ["sh", "-c", "printf ok"]})))
1095 .expect("success");
1096 assert!(success.ok);
1097 assert_eq!(success.stdout, "ok");
1098 let failure = tools
1099 .execute(&call("shell", json!({"argv": ["sh", "-c", "exit 7"]})))
1100 .expect("failure");
1101 assert!(!failure.ok);
1102 assert_eq!(failure.exit_code, Some(7));
1103 let timeout = tools
1104 .execute(&call(
1105 "shell",
1106 json!({"argv": ["sh", "-c", "sleep 1"], "timeout_ms": 20}),
1107 ))
1108 .expect("timeout");
1109 assert!(!timeout.ok);
1110 assert!(timeout.timed_out);
1111 }
1112
1113 #[test]
1114 fn git_tool_reports_state() {
1115 let (_directory, tools) = fixture();
1116 let result = tools
1117 .execute(&call("git", json!({"operation": "status"})))
1118 .expect("git status");
1119 assert!(result.ok);
1120 assert!(result.stdout.starts_with("##"));
1121 }
1122}