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 result = execute_patch(&req).await;
131
132    // Emit patch end event
133    if let Some(tracker) = tracker {
134        let mut t = tracker.write().await;
135        t.on_patch_end(result.is_ok());
136    }
137
138    match result {
139        Ok(output) => Ok(Some(ToolOutput::simple(output))),
140        Err(e) => Ok(Some(ToolOutput::error(format!("{e} (call_id={call_id}, tool_name={tool_name})")))),
141    }
142}
143
144async fn execute_patch(req: &ApplyPatchRequest) -> Result<String, ApplyPatchError> {
145    let patch = Patch::parse(&req.patch).map_err(|e| ApplyPatchError::ParseError(e.to_string()))?;
146    if patch.is_empty() {
147        return Ok("Patch is empty, no changes applied".to_string());
148    }
149
150    let results = patch
151        .apply(&req.cwd)
152        .await
153        .map_err(|e| ApplyPatchError::PatchFailed(e.to_string()))?;
154    Ok(results.join("\n"))
155}
156
157/// Errors from apply_patch interception
158#[derive(Debug, thiserror::Error)]
159pub enum ApplyPatchError {
160    #[error("Failed to parse patch: {0}")]
161    ParseError(String),
162
163    #[error("Patch application failed: {0}")]
164    PatchFailed(String),
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::tools::handlers::Constrained;
171    use crate::tools::handlers::adapter::DefaultToolSession;
172    use crate::tools::handlers::tool_handler::ShellEnvironmentPolicy;
173
174    fn test_turn_context(cwd: PathBuf) -> TurnContext {
175        TurnContext {
176            cwd,
177            turn_id: "test-turn".to_string(),
178            sub_id: None,
179            shell_environment_policy: ShellEnvironmentPolicy::default(),
180            approval_policy: Constrained::default(),
181            linux_sandbox_launcher: None,
182            sandbox_policy: Constrained::default(),
183        }
184    }
185
186    #[test]
187    fn maybe_parse_apply_patch_detects_direct_invocation() {
188        assert_eq!(
189            maybe_parse_apply_patch_from_command(&[
190                "apply_patch".to_string(),
191                "*** Begin Patch\n*** End Patch".to_string(),
192            ]),
193            Some("*** Begin Patch\n*** End Patch".to_string())
194        );
195        assert_eq!(
196            maybe_parse_apply_patch_from_command(&[
197                "applypatch".to_string(),
198                "*** Begin Patch\n*** End Patch".to_string(),
199            ]),
200            Some("*** Begin Patch\n*** End Patch".to_string())
201        );
202        assert_eq!(
203            maybe_parse_apply_patch_from_command(&["codex".to_string(), CODEX_APPLY_PATCH_ARG.to_string(),]),
204            None
205        );
206        assert_eq!(
207            maybe_parse_apply_patch_from_command(&["git".to_string(), "apply".to_string(), "test.patch".to_string(),]),
208            None
209        );
210        assert_eq!(maybe_parse_apply_patch_from_command(&["patch".to_string(), "-p1".to_string(),]), None);
211        assert_eq!(maybe_parse_apply_patch_from_command(&["echo".to_string(), "hello".to_string(),]), None);
212    }
213
214    #[test]
215    fn test_apply_patch_request_builder() {
216        let req = ApplyPatchRequest::new("patch content".to_string(), PathBuf::from("/tmp"))
217            .with_timeout(5000)
218            .with_approval(true)
219            .with_codex_exe(PathBuf::from("/usr/bin/codex"));
220
221        assert_eq!(req.patch, "patch content");
222        assert_eq!(req.timeout_ms, Some(5000));
223        assert!(req.user_explicitly_approved);
224        assert_eq!(req.codex_exe, Some(PathBuf::from("/usr/bin/codex")));
225    }
226
227    #[cfg(unix)]
228    #[tokio::test]
229    async fn intercept_rejects_symlink_escaped_cwd_before_outside_mutation() {
230        use std::os::unix::fs::symlink;
231
232        let workspace = tempfile::TempDir::new().expect("workspace should be created");
233        let outside = tempfile::TempDir::new().expect("outside directory should be created");
234        let escaped_cwd = workspace.path().join("escape");
235        symlink(outside.path(), &escaped_cwd).expect("workspace symlink should be created");
236
237        let session = DefaultToolSession::with_workspace(escaped_cwd.clone(), workspace.path().to_path_buf());
238        let turn = test_turn_context(escaped_cwd.clone());
239        let command = vec![
240            "apply_patch".to_string(),
241            "*** Begin Patch\n*** Add File: outside.txt\n+must not exist\n*** End Patch\n".to_string(),
242        ];
243
244        let error = intercept_apply_patch(&command, &escaped_cwd, None, &session, &turn, None, "call-1", "apply_patch")
245            .await
246            .expect_err("symlink-escaped cwd must be rejected");
247
248        assert!(matches!(error, ApplyPatchError::ParseError(_)));
249        assert!(!outside.path().join("outside.txt").exists(), "rejection must precede mutation");
250    }
251}