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_resolved;
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.
76pub async fn intercept_apply_patch(
77    command: &[String],
78    cwd: &Path,
79    timeout_ms: Option<u64>,
80    session: &dyn ToolSession,
81    _turn: &TurnContext,
82    tracker: Option<&SharedTurnDiffTracker>,
83    call_id: &str,
84    tool_name: &str,
85) -> Result<Option<ToolOutput>, ApplyPatchError> {
86    let Some(patch) = maybe_parse_apply_patch_from_command(command) else {
87        return Ok(None);
88    };
89
90    // Safety gate: decode the patch through the same path the registry uses, so the
91    // post-decode size cap and the env-var override apply uniformly. This prevents
92    // an oversized or base64-decompression patch from bypassing the preflight cap
93    // when it is delivered via a shell command.
94    let args = json!({ "input": &patch });
95    let decoded = match decode_apply_patch_input(&args).map_err(|e| ApplyPatchError::ParseError(e.to_string()))? {
96        Some(decoded) => decoded,
97        None => return Ok(None),
98    };
99    if decoded.text.is_empty() {
100        return Ok(None);
101    }
102
103    // Ensure the working directory is contained within the session workspace.
104    // The session workspace is owned by the harness kernel and exposed via the tool
105    // session trait. This prevents intercepting a patch whose target is outside the
106    // workspace sandbox.
107    let workspace_root = session.workspace_root();
108    if let Err(reason) = ensure_path_within_workspace_resolved(cwd, workspace_root).await {
109        return Err(ApplyPatchError::ParseError(format!(
110            "intercept_apply_patch rejected cwd '{}' (workspace='{}'): {}",
111            cwd.display(),
112            workspace_root.display(),
113            reason
114        )));
115    }
116
117    // Create the request
118    let req = ApplyPatchRequest::new(decoded.text.clone(), cwd.to_path_buf()).with_timeout(timeout_ms.unwrap_or(30000));
119
120    // Emit patch begin event
121    if let Some(tracker) = tracker {
122        let mut t = tracker.write().await;
123        t.on_patch_begin(Default::default());
124    }
125
126    // Execute the patch through the same `Patch::parse` + `apply` pipeline that the
127    // registry's `apply_patch` tool uses, but with the safety checks above applied.
128    // The result is funneled through the turn diff tracker so the diff is recorded
129    // the same way as a model-originated `apply_patch` call.
130    let fallback = crate::tools::source_inspection::SourceInspection::default();
131    let inspection = session.source_inspection().unwrap_or(&fallback);
132    let parsed = Patch::parse(&req.patch).map_err(|error| ApplyPatchError::ParseError(error.to_string()))?;
133    let _leases = if let Some(monitor) = session.edited_file_monitor() {
134        crate::tools::source_inspection::SourceInspection::acquire_patch_leases(monitor, &parsed, &req.cwd)
135            .await
136            .map_err(|error| ApplyPatchError::PatchFailed(error.to_string()))?
137    } else {
138        Vec::new()
139    };
140    let inspection_plan = inspection
141        .prepare_patch(&parsed, &req.cwd)
142        .await
143        .map_err(|error| ApplyPatchError::PatchFailed(error.to_string()))?;
144    let result = execute_patch(&req).await;
145
146    // Emit patch end event
147    if let Some(tracker) = tracker {
148        let mut t = tracker.write().await;
149        t.on_patch_end(result.is_ok());
150    }
151
152    match result {
153        Ok(output) => {
154            inspection.commit_patch(
155                inspection_plan,
156                crate::tools::source_inspection::call_key(crate::config::constants::tools::APPLY_PATCH, &args),
157            );
158            Ok(Some(ToolOutput::simple(output)))
159        }
160        Err(e) => Ok(Some(ToolOutput::error(format!("{e} (call_id={call_id}, tool_name={tool_name})")))),
161    }
162}
163
164async fn execute_patch(req: &ApplyPatchRequest) -> Result<String, ApplyPatchError> {
165    let patch = Patch::parse(&req.patch).map_err(|e| ApplyPatchError::ParseError(e.to_string()))?;
166    if patch.is_empty() {
167        return Ok("Patch is empty, no changes applied".to_string());
168    }
169
170    let results = patch
171        .apply(&req.cwd)
172        .await
173        .map_err(|e| ApplyPatchError::PatchFailed(e.to_string()))?;
174    Ok(results.join("\n"))
175}
176
177/// Errors from apply_patch interception
178#[derive(Debug, thiserror::Error)]
179pub enum ApplyPatchError {
180    #[error("Failed to parse patch: {0}")]
181    ParseError(String),
182
183    #[error("Patch application failed: {0}")]
184    PatchFailed(String),
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::tools::handlers::Constrained;
191    use crate::tools::handlers::adapter::DefaultToolSession;
192    use crate::tools::handlers::tool_handler::ShellEnvironmentPolicy;
193
194    fn test_turn_context(cwd: PathBuf) -> TurnContext {
195        TurnContext {
196            cwd,
197            turn_id: "test-turn".to_string(),
198            sub_id: None,
199            shell_environment_policy: ShellEnvironmentPolicy::default(),
200            approval_policy: Constrained::default(),
201            linux_sandbox_launcher: None,
202            sandbox_policy: Constrained::default(),
203        }
204    }
205
206    #[test]
207    fn maybe_parse_apply_patch_detects_direct_invocation() {
208        assert_eq!(
209            maybe_parse_apply_patch_from_command(&[
210                "apply_patch".to_string(),
211                "*** Begin Patch\n*** End Patch".to_string(),
212            ]),
213            Some("*** Begin Patch\n*** End Patch".to_string())
214        );
215        assert_eq!(
216            maybe_parse_apply_patch_from_command(&[
217                "applypatch".to_string(),
218                "*** Begin Patch\n*** End Patch".to_string(),
219            ]),
220            Some("*** Begin Patch\n*** End Patch".to_string())
221        );
222        assert_eq!(
223            maybe_parse_apply_patch_from_command(&["codex".to_string(), CODEX_APPLY_PATCH_ARG.to_string(),]),
224            None
225        );
226        assert_eq!(
227            maybe_parse_apply_patch_from_command(&["git".to_string(), "apply".to_string(), "test.patch".to_string(),]),
228            None
229        );
230        assert_eq!(maybe_parse_apply_patch_from_command(&["patch".to_string(), "-p1".to_string(),]), None);
231        assert_eq!(maybe_parse_apply_patch_from_command(&["echo".to_string(), "hello".to_string(),]), None);
232    }
233
234    #[test]
235    fn test_apply_patch_request_builder() {
236        let req = ApplyPatchRequest::new("patch content".to_string(), PathBuf::from("/tmp"))
237            .with_timeout(5000)
238            .with_approval(true)
239            .with_codex_exe(PathBuf::from("/usr/bin/codex"));
240
241        assert_eq!(req.patch, "patch content");
242        assert_eq!(req.timeout_ms, Some(5000));
243        assert!(req.user_explicitly_approved);
244        assert_eq!(req.codex_exe, Some(PathBuf::from("/usr/bin/codex")));
245    }
246
247    #[cfg(unix)]
248    #[tokio::test]
249    async fn intercept_rejects_symlink_escaped_cwd_before_outside_mutation() {
250        use std::os::unix::fs::symlink;
251
252        let workspace = tempfile::TempDir::new().expect("workspace should be created");
253        let outside = tempfile::TempDir::new().expect("outside directory should be created");
254        let escaped_cwd = workspace.path().join("escape");
255        symlink(outside.path(), &escaped_cwd).expect("workspace symlink should be created");
256
257        let session = DefaultToolSession::with_workspace(escaped_cwd.clone(), workspace.path().to_path_buf());
258        let turn = test_turn_context(escaped_cwd.clone());
259        let command = vec![
260            "apply_patch".to_string(),
261            "*** Begin Patch\n*** Add File: outside.txt\n+must not exist\n*** End Patch\n".to_string(),
262        ];
263
264        let error = intercept_apply_patch(&command, &escaped_cwd, None, &session, &turn, None, "call-1", "apply_patch")
265            .await
266            .expect_err("symlink-escaped cwd must be rejected");
267
268        assert!(matches!(error, ApplyPatchError::ParseError(_)));
269        assert!(!outside.path().join("outside.txt").exists(), "rejection must precede mutation");
270    }
271}