Skip to main content

vtcode_core/tools/handlers/
intercept_apply_patch.rs

1//! Apply Patch Interception (from Codex)
2//!
3//! This module provides the ability to intercept shell commands that contain
4//! apply_patch invocations, redirecting them to the proper patch application
5//! flow with approval and sandbox handling.
6
7use std::path::{Path, PathBuf};
8
9use anyhow::Result;
10
11use super::apply_patch_handler::parse_apply_patch_command;
12use super::tool_handler::{ToolOutput, ToolSession, TurnContext};
13use super::turn_diff_tracker::SharedTurnDiffTracker;
14use crate::tools::apply_patch::decode_apply_patch_input;
15use crate::tools::editing::Patch;
16use serde_json::json;
17use vtcode_commons::paths::ensure_path_within_workspace;
18
19/// The argument used to indicate apply_patch mode (from Codex)
20pub const CODEX_APPLY_PATCH_ARG: &str = "--codex-run-as-apply-patch";
21
22/// Apply patch request (from Codex)
23#[derive(Clone, Debug)]
24pub struct ApplyPatchRequest {
25    /// The patch content
26    pub patch: String,
27    /// Working directory
28    pub cwd: PathBuf,
29    /// Timeout in milliseconds
30    pub timeout_ms: Option<u64>,
31    /// Whether user explicitly approved
32    pub user_explicitly_approved: bool,
33    /// Path to codex executable (for self-invocation)
34    pub codex_exe: Option<PathBuf>,
35}
36
37impl ApplyPatchRequest {
38    pub fn new(patch: String, cwd: PathBuf) -> Self {
39        Self {
40            patch,
41            cwd,
42            timeout_ms: Some(30000),
43            user_explicitly_approved: false,
44            codex_exe: None,
45        }
46    }
47
48    pub fn with_timeout(mut self, timeout_ms: u64) -> Self {
49        self.timeout_ms = Some(timeout_ms);
50        self
51    }
52
53    pub fn with_approval(mut self, approved: bool) -> Self {
54        self.user_explicitly_approved = approved;
55        self
56    }
57
58    pub fn with_codex_exe(mut self, exe: PathBuf) -> Self {
59        self.codex_exe = Some(exe);
60        self
61    }
62}
63
64/// Check if a command contains an apply_patch invocation (from Codex)
65///
66/// Returns the patch content if found
67pub fn maybe_parse_apply_patch_from_command(command: &[String]) -> Option<String> {
68    let (is_apply_patch, patch_content) = parse_apply_patch_command(command);
69    is_apply_patch.then_some(patch_content).flatten()
70}
71
72/// Intercept apply_patch from shell command (from Codex)
73///
74/// This function checks if a shell command is attempting to apply a patch
75/// and redirects it through the proper patch application flow.
76#[expect(clippy::too_many_arguments)]
77pub async fn intercept_apply_patch(
78    command: &[String],
79    cwd: &Path,
80    timeout_ms: Option<u64>,
81    session: &dyn ToolSession,
82    _turn: &TurnContext,
83    tracker: Option<&SharedTurnDiffTracker>,
84    call_id: &str,
85    tool_name: &str,
86) -> Result<Option<ToolOutput>, ApplyPatchError> {
87    let Some(patch) = maybe_parse_apply_patch_from_command(command) else {
88        return Ok(None);
89    };
90
91    // Safety gate: decode the patch through the same path the registry uses, so the
92    // post-decode size cap and the env-var override apply uniformly. This prevents
93    // an oversized or base64-decompression patch from bypassing the preflight cap
94    // when it is delivered via a shell command.
95    let args = json!({ "input": &patch });
96    let decoded = match decode_apply_patch_input(&args)
97        .map_err(|e| ApplyPatchError::ParseError(e.to_string()))?
98    {
99        Some(decoded) => decoded,
100        None => return Ok(None),
101    };
102    if decoded.text.is_empty() {
103        return Ok(None);
104    }
105
106    // Path safety: ensure the working directory is contained within the session workspace.
107    // The session workspace is owned by the harness kernel and exposed via the tool
108    // session trait. This prevents intercepting a patch whose target is outside the
109    // workspace sandbox.
110    let workspace_root = session.workspace_root();
111    if let Err(reason) = ensure_path_within_workspace(cwd, workspace_root) {
112        return Err(ApplyPatchError::ParseError(format!(
113            "intercept_apply_patch rejected cwd '{}' (workspace='{}'): {}",
114            cwd.display(),
115            workspace_root.display(),
116            reason
117        )));
118    }
119
120    // Create the request
121    let req = ApplyPatchRequest::new(decoded.text.clone(), cwd.to_path_buf())
122        .with_timeout(timeout_ms.unwrap_or(30000));
123
124    // Emit patch begin event
125    if let Some(tracker) = tracker {
126        let mut t = tracker.write().await;
127        t.on_patch_begin(Default::default());
128    }
129
130    // Execute the patch through the same `Patch::parse` + `apply` pipeline that the
131    // registry's `apply_patch` tool uses, but with the safety checks above applied.
132    // The result is funneled through the turn diff tracker so the diff is recorded
133    // the same way as a model-originated `apply_patch` call.
134    let result = execute_patch(&req).await;
135
136    // Emit patch end event
137    if let Some(tracker) = tracker {
138        let mut t = tracker.write().await;
139        t.on_patch_end(result.is_ok());
140    }
141
142    match result {
143        Ok(output) => Ok(Some(ToolOutput::simple(output))),
144        Err(e) => Ok(Some(ToolOutput::error(format!(
145            "{e} (call_id={call_id}, tool_name={tool_name})"
146        )))),
147    }
148}
149
150async fn execute_patch(req: &ApplyPatchRequest) -> Result<String, ApplyPatchError> {
151    let patch = Patch::parse(&req.patch).map_err(|e| ApplyPatchError::ParseError(e.to_string()))?;
152    if patch.is_empty() {
153        return Ok("Patch is empty, no changes applied".to_string());
154    }
155
156    let results = patch
157        .apply(&req.cwd)
158        .await
159        .map_err(|e| ApplyPatchError::PatchFailed(e.to_string()))?;
160    Ok(results.join("\n"))
161}
162
163/// Errors from apply_patch interception
164#[derive(Debug, thiserror::Error)]
165pub enum ApplyPatchError {
166    #[error("Failed to parse patch: {0}")]
167    ParseError(String),
168
169    #[error("Patch application failed: {0}")]
170    PatchFailed(String),
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn maybe_parse_apply_patch_detects_direct_invocation() {
179        assert_eq!(
180            maybe_parse_apply_patch_from_command(&[
181                "apply_patch".to_string(),
182                "*** Begin Patch\n*** End Patch".to_string(),
183            ]),
184            Some("*** Begin Patch\n*** End Patch".to_string())
185        );
186        assert_eq!(
187            maybe_parse_apply_patch_from_command(&[
188                "applypatch".to_string(),
189                "*** Begin Patch\n*** End Patch".to_string(),
190            ]),
191            Some("*** Begin Patch\n*** End Patch".to_string())
192        );
193        assert_eq!(
194            maybe_parse_apply_patch_from_command(&[
195                "codex".to_string(),
196                CODEX_APPLY_PATCH_ARG.to_string(),
197            ]),
198            None
199        );
200        assert_eq!(
201            maybe_parse_apply_patch_from_command(&[
202                "git".to_string(),
203                "apply".to_string(),
204                "test.patch".to_string(),
205            ]),
206            None
207        );
208        assert_eq!(
209            maybe_parse_apply_patch_from_command(&["patch".to_string(), "-p1".to_string(),]),
210            None
211        );
212        assert_eq!(
213            maybe_parse_apply_patch_from_command(&["echo".to_string(), "hello".to_string(),]),
214            None
215        );
216    }
217
218    #[test]
219    fn test_apply_patch_request_builder() {
220        let req = ApplyPatchRequest::new("patch content".to_string(), PathBuf::from("/tmp"))
221            .with_timeout(5000)
222            .with_approval(true)
223            .with_codex_exe(PathBuf::from("/usr/bin/codex"));
224
225        assert_eq!(req.patch, "patch content");
226        assert_eq!(req.timeout_ms, Some(5000));
227        assert!(req.user_explicitly_approved);
228        assert_eq!(req.codex_exe, Some(PathBuf::from("/usr/bin/codex")));
229    }
230}