vtcode_core/tools/handlers/
intercept_apply_patch.rs1use 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
19pub const CODEX_APPLY_PATCH_ARG: &str = "--codex-run-as-apply-patch";
21
22#[derive(Clone, Debug)]
24pub struct ApplyPatchRequest {
25 pub patch: String,
27 pub cwd: PathBuf,
29 pub timeout_ms: Option<u64>,
31 pub user_explicitly_approved: bool,
33 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
64pub 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
72pub 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 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 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 let req = ApplyPatchRequest::new(decoded.text.clone(), cwd.to_path_buf()).with_timeout(timeout_ms.unwrap_or(30000));
119
120 if let Some(tracker) = tracker {
122 let mut t = tracker.write().await;
123 t.on_patch_begin(Default::default());
124 }
125
126 let result = execute_patch(&req).await;
131
132 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#[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}