Skip to main content

falsegreen_agent/
tools.rs

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    /// Read the current workspace authority. The generation is advanced only
119    /// by a controlled mutator (apply-patch/shell) at mutation time; this read
120    /// never lazily promotes a changed fingerprint into a new authority
121    /// version. The exact identity is still returned so external filesystem
122    /// changes are rejected at the fence, while controlled ABA changes remain
123    /// visible through their mutation-time token.
124    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    /// Mutation-time workspace authority token used by the publication CAS.
156    #[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    /// Read the live MCP registry identity used by the execution path.
192    #[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    /// Digest of the complete live MCP registry/source authority used by the
198    /// admission and execution paths.
199    #[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    /// Monotonic MCP registry authority token used by the G5 publication CAS.
205    #[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    /// Qualification-only access to the live MCP registry. Production code
213    /// never needs to mutate this object; tests use it to change the actual
214    /// source after the provider proposal and before admission.
215    #[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                // Ordinary model tool calls use the same host policy gate as
236                // GenUI. A consequential route cannot be activated by model
237                // data: it returns a structured confirmation-required result
238                // without invoking MCP or creating a lifecycle event.
239                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    /// Execute a host-bound GenUI action through the canonical MCP validation
297    /// path. Durable lifecycle events are written before and after transport
298    /// invocation; an unresolved start is reconciled as unknown on restart and
299    /// is never retried blindly.
300    #[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        // The durable start and the transport are separated by a second
392        // host-state check. Reconciliation is intentionally not performed on
393        // this normal activation route: another independent connection may be
394        // between its durable start and transport, and only an explicit
395        // restart/recovery seam may convert that unresolved start to Unknown.
396        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    /// Continue an already-durable execution start through the same authority
452    /// gate without appending a terminal event. This is the explicit process-
453    /// death seam used by Crash-C; it still rechecks every live binding before
454    /// invoking transport and never exposes the raw MCP client.
455    #[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    /// Live-revalidate MCP authority before persisting a confirmation. This is
556    /// the sole production confirmation path; the catalog's persistence
557    /// primitive is crate-private and can only be reached after these checks.
558    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            // A shell command may mutate any tracked or untracked authority;
774            // conservatively advance the boundary for every successful run.
775            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}