1use hashbrown::HashMap;
13use std::path::{Path, PathBuf};
14
15use async_trait::async_trait;
16use serde::{Deserialize, Serialize};
17use serde_json::{Value, json};
18
19use super::events::{ToolEmitter, ToolEventCtx};
20use super::sandboxing::{
21 Approvable, ApprovalCtx, AskForApproval, BoxFuture, ExecApprovalRequirement, ExecToolCallOutput, ReviewDecision,
22 SandboxAttempt, Sandboxable, SandboxablePreference, ToolCtx, ToolError, ToolRuntime,
23};
24use super::tool_handler::{
25 ApprovalPolicy, FileChange, FreeformTool, FreeformToolFormat, ResponsesApiTool, ToolCallError, ToolHandler,
26 ToolInvocation, ToolKind, ToolOutput, ToolPayload, ToolSpec,
27};
28use super::tool_orchestrator::ToolOrchestrator;
29use crate::config::constants::tools;
30use crate::tools::editing::{Patch, PatchOperation};
31
32pub struct ApplyPatchHandler;
34
35#[derive(Debug, Deserialize, Serialize)]
37pub struct ApplyPatchToolArgs {
38 pub input: Option<String>,
39 pub patch: Option<String>,
40}
41
42#[derive(Clone, Debug)]
44pub struct ApplyPatchRequest {
45 pub patch: String,
46 pub cwd: PathBuf,
47 pub timeout_ms: Option<u64>,
48 pub user_explicitly_approved: bool,
49}
50
51#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize)]
53pub struct ApplyPatchApprovalKey {
54 patch: String,
55 cwd: PathBuf,
56}
57
58#[derive(Default)]
60pub struct ApplyPatchRuntime;
61
62impl ApplyPatchRuntime {
63 pub fn new() -> Self {
64 Self
65 }
66}
67
68impl Sandboxable for ApplyPatchRuntime {
69 fn sandbox_preference(&self) -> SandboxablePreference {
70 SandboxablePreference::Auto
72 }
73
74 fn escalate_on_failure(&self) -> bool {
75 true
77 }
78}
79
80impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
81 type ApprovalKey = ApplyPatchApprovalKey;
82
83 fn approval_key(&self, req: &ApplyPatchRequest) -> Self::ApprovalKey {
84 ApplyPatchApprovalKey { patch: req.patch.clone(), cwd: req.cwd.clone() }
85 }
86
87 fn exec_approval_requirement(&self, _req: &ApplyPatchRequest) -> Option<ExecApprovalRequirement> {
88 Some(ExecApprovalRequirement::Skip {
91 bypass_sandbox: false,
92 proposed_execpolicy_amendment: None,
93 })
94 }
95
96 fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
97 match policy {
98 AskForApproval::Never => false,
99 AskForApproval::Reject(reject_config) => !reject_config.rejects_sandbox_approval(),
100 AskForApproval::OnFailure => true,
101 AskForApproval::OnRequest => true,
102 AskForApproval::UnlessTrusted => true,
103 }
104 }
105
106 fn start_approval_async<'a>(
107 &'a mut self,
108 _req: &'a ApplyPatchRequest,
109 _ctx: ApprovalCtx<'a>,
110 ) -> BoxFuture<'a, ReviewDecision> {
111 Box::pin(async { ReviewDecision::Approved })
112 }
113}
114
115#[async_trait]
116impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
117 async fn run(
118 &mut self,
119 req: &ApplyPatchRequest,
120 _attempt: &SandboxAttempt<'_>,
121 ctx: &ToolCtx,
122 ) -> Result<ExecToolCallOutput, ToolError> {
123 vtcode_commons::paths::ensure_path_within_workspace_resolved(&req.cwd, ctx.session.workspace_root())
124 .await
125 .map_err(|error| {
126 ToolError::Rejected(format!(
127 "apply_patch rejected cwd '{}' outside session workspace '{}': {error}",
128 req.cwd.display(),
129 ctx.session.workspace_root().display()
130 ))
131 })?;
132
133 let patch = Patch::parse(&req.patch).map_err(|e| ToolError::Rejected(format!("Failed to parse patch: {e}")))?;
135
136 if patch.is_empty() {
137 return Ok(ExecToolCallOutput {
138 stdout: "Patch is empty, no changes applied".to_string(),
139 stderr: String::new(),
140 exit_code: 0,
141 });
142 }
143
144 match patch.apply(&req.cwd).await {
146 Ok(results) => {
147 let output = results.join("\n");
148 Ok(ExecToolCallOutput {
149 stdout: output,
150 stderr: String::new(),
151 exit_code: 0,
152 })
153 }
154 Err(e) => Ok(ExecToolCallOutput {
155 stdout: String::new(),
156 stderr: format!("Patch application failed: {e}"),
157 exit_code: 1,
158 }),
159 }
160 }
161}
162
163#[async_trait]
164impl ToolHandler for ApplyPatchHandler {
165 fn kind(&self) -> ToolKind {
166 ToolKind::Function
167 }
168
169 fn matches_kind(&self, payload: &ToolPayload) -> bool {
170 matches!(payload, ToolPayload::Function { .. } | ToolPayload::Custom { .. })
171 }
172
173 async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
174 true }
176
177 async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, ToolCallError> {
178 let ToolInvocation {
179 session,
180 turn,
181 tracker,
182 call_id,
183 tool_name,
184 payload,
185 } = invocation;
186
187 let patch_input = match payload {
189 ToolPayload::Function { arguments } => {
190 let args: Value = serde_json::from_str(&arguments).map_err(|e| {
191 ToolCallError::respond(format!(
192 "Failed to parse function arguments: {e}. {}",
193 crate::tools::apply_patch::APPLY_PATCH_ARGUMENT_CORRECTION
194 ))
195 })?;
196 crate::tools::apply_patch::decode_apply_patch_input(&args)
197 .map_err(|e| ToolCallError::respond(format!("Failed to decode patch input: {e}")))?
198 .map(|input| input.text)
199 .ok_or_else(|| {
200 ToolCallError::respond(format!(
201 "Missing patch input {}",
202 crate::tools::error_helpers::PATCH_PARAMETER_HINT
203 ))
204 })?
205 }
206 ToolPayload::Custom { input } => input,
207 _ => {
208 return Err(ToolCallError::respond("apply_patch handler received unsupported payload"));
209 }
210 };
211
212 let patch =
214 Patch::parse(&patch_input).map_err(|e| ToolCallError::respond(format!("Failed to parse patch: {e}")))?;
215
216 let changes = convert_patch_to_changes(&patch, &turn.cwd);
218
219 let emitter = ToolEmitter::apply_patch(changes.clone(), true);
221 let event_ctx = ToolEventCtx::new(turn.as_ref(), &call_id, tracker.as_ref());
222 emitter.begin(event_ctx).await;
223
224 let req = ApplyPatchRequest {
226 patch: patch_input.clone(),
227 cwd: turn.cwd.clone(),
228 timeout_ms: None,
229 user_explicitly_approved: true,
230 };
231
232 let mut orchestrator = ToolOrchestrator::new();
234 let mut runtime = ApplyPatchRuntime::new();
235 let tool_ctx = ToolCtx {
236 session: session.clone(),
237 turn: turn.clone(),
238 call_id: call_id.clone(),
239 tool_name: tool_name.clone(),
240 };
241
242 let result = orchestrator
243 .run(&mut runtime, &req, &tool_ctx, turn.as_ref(), map_approval_policy(turn.approval_policy.value()))
244 .await;
245
246 let event_ctx = ToolEventCtx::new(turn.as_ref(), &call_id, tracker.as_ref());
248 let content = emitter.finish(event_ctx, result).await?;
249
250 Ok(ToolOutput::Function { content, content_items: None, success: Some(true) })
251 }
252}
253
254fn convert_patch_to_changes(patch: &Patch, cwd: &Path) -> HashMap<PathBuf, FileChange> {
256 let mut changes = HashMap::new();
257
258 for op in patch.operations() {
259 match op {
260 PatchOperation::AddFile { path, content } => {
261 let full_path = cwd.join(path);
262 changes.insert(full_path, FileChange::Add { content: content.clone() });
263 }
264 PatchOperation::DeleteFile { path } => {
265 let full_path = cwd.join(path);
266 changes.insert(full_path, FileChange::Delete);
267 }
268 PatchOperation::UpdateFile { path, new_path, chunks: _ } => {
269 let full_path = cwd.join(path);
270 if let Some(new_path) = new_path {
271 changes.insert(full_path, FileChange::Rename { new_path: cwd.join(new_path), content: None });
272 } else {
273 changes.insert(
276 full_path,
277 FileChange::Update {
278 old_content: String::new(),
279 new_content: String::new(),
280 },
281 );
282 }
283 }
284 }
285 }
286
287 changes
288}
289
290fn map_approval_policy(policy: ApprovalPolicy) -> AskForApproval {
291 match policy {
292 ApprovalPolicy::Never => AskForApproval::Never,
293 ApprovalPolicy::OnMutation => AskForApproval::OnRequest,
294 ApprovalPolicy::Always => AskForApproval::UnlessTrusted,
295 }
296}
297
298pub fn create_apply_patch_freeform_tool() -> ToolSpec {
300 ToolSpec::Freeform(FreeformTool {
301 name: tools::APPLY_PATCH.to_string(),
302 description: APPLY_PATCH_DESCRIPTION.to_string(),
303 format: FreeformToolFormat {
304 lark_grammar: Some(APPLY_PATCH_LARK_GRAMMAR.to_string()),
305 examples: vec![
306 APPLY_PATCH_ADD_EXAMPLE.to_string(),
307 APPLY_PATCH_UPDATE_EXAMPLE.to_string(),
308 ],
309 },
310 })
311}
312
313pub fn create_apply_patch_json_tool() -> ToolSpec {
315 use crate::tools::apply_patch::{
316 APPLY_PATCH_ALIAS_DESCRIPTION, DEFAULT_APPLY_PATCH_INPUT_DESCRIPTION, with_semantic_anchor_guidance,
317 };
318 ToolSpec::Function(ResponsesApiTool {
319 name: tools::APPLY_PATCH.to_string(),
320 description: format!("{APPLY_PATCH_DESCRIPTION}\n\n{APPLY_PATCH_GRAMMAR_HELP}"),
321 strict: false,
322 parameters: json!({
323 "type": "object",
324 "properties": {
325 "input": {
326 "type": "string",
327 "description": with_semantic_anchor_guidance(DEFAULT_APPLY_PATCH_INPUT_DESCRIPTION)
328 },
329 "patch": {
330 "type": "string",
331 "description": with_semantic_anchor_guidance(APPLY_PATCH_ALIAS_DESCRIPTION)
332 }
333 },
334 "required": ["input"],
335 "additionalProperties": false
336 }),
337 })
338}
339
340pub(crate) fn parse_apply_patch_command(command: &[String]) -> (bool, Option<String>) {
342 const APPLY_PATCH_COMMANDS: &[&str] = &["apply_patch", "applypatch"];
343
344 match command {
345 [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => (true, Some(body.clone())),
347 _ => (false, None),
350 }
351}
352
353const APPLY_PATCH_DESCRIPTION: &str = r#"Use the `apply_patch` tool to edit files.
355Your patch language is a stripped-down, file-oriented diff format designed to be easy to parse and safe to apply. Every patch path must be workspace-relative; never use absolute paths, `..`, or traversal-like forms.
356
357You can think of it as a high-level envelope:
358
359*** Begin Patch
360[ one or more file sections ]
361*** End Patch
362
363Within that envelope, you get a sequence of file operations.
364You MUST include a header to specify the action you are taking.
365Each operation starts with one of three headers:
366
367*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
368*** Delete File: <path> - remove an existing file. Nothing follows.
369*** Update File: <path> - patch an existing file in place (optionally with a rename)."#;
370
371const APPLY_PATCH_GRAMMAR_HELP: &str = r#"May be immediately followed by *** Move to: <new path> if you want to rename the file.
372Then one or more "hunks", each introduced by @@ (optionally followed by a hunk header).
373Within a hunk each line starts with:
374
375- ` ` (space) for context lines
376- `-` for lines to remove
377- `+` for lines to add
378
379Important rules:
380- You must include a header with your intended action (Add/Delete/Update)
381- You must prefix new lines with `+` even when creating a new file
382- File references must be workspace-relative; never use absolute paths, `..`, or traversal-like forms
383- Prefer small hunks with stable semantic @@ anchors like function, class, method, or impl names"#;
384
385const APPLY_PATCH_LARK_GRAMMAR: &str = r#"
386patch := "*** Begin Patch" NEWLINE { operation } "*** End Patch"
387operation := AddFile | DeleteFile | UpdateFile
388AddFile := "*** Add File: " path NEWLINE { "+" text NEWLINE }
389DeleteFile := "*** Delete File: " path NEWLINE
390UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
391MoveTo := "*** Move to: " newPath NEWLINE
392Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
393HunkLine := (" " | "-" | "+") text NEWLINE
394"#;
395
396const APPLY_PATCH_ADD_EXAMPLE: &str = r#"*** Begin Patch
397*** Add File: hello.txt
398+Hello world
399*** End Patch"#;
400
401const APPLY_PATCH_UPDATE_EXAMPLE: &str = r#"*** Begin Patch
402*** Update File: src/app.py
403*** Move to: src/main.py
404@@ def greet():
405-print("Hi")
406+print("Hello, world!")
407*** End Patch"#;
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::exec_policy::RejectConfig;
413 use crate::tools::handlers::adapter::DefaultToolSession;
414 use crate::tools::handlers::sandboxing::{SandboxConfig, SandboxType};
415 use crate::tools::handlers::tool_handler::{Constrained, ShellEnvironmentPolicy, TurnContext};
416 use std::sync::Arc;
417 use tempfile::TempDir;
418
419 #[test]
420 fn test_parse_apply_patch_command_direct() {
421 let cmd = vec!["apply_patch".to_string(), "*** Begin Patch\n*** End Patch".to_string()];
422 let (is_patch, content) = parse_apply_patch_command(&cmd);
423 assert!(is_patch);
424 assert!(content.is_some());
425 }
426
427 #[test]
428 fn test_parse_apply_patch_command_not_patch() {
429 let cmd = vec!["ls".to_string(), "-la".to_string()];
430 let (is_patch, content) = parse_apply_patch_command(&cmd);
431 assert!(!is_patch);
432 assert!(content.is_none());
433 }
434
435 #[test]
436 fn test_create_freeform_tool() {
437 let tool = create_apply_patch_freeform_tool();
438 assert_eq!(tool.name(), "apply_patch");
439 }
440
441 #[test]
442 fn test_create_json_tool() {
443 let tool = create_apply_patch_json_tool();
444 assert_eq!(tool.name(), "apply_patch");
445 }
446
447 #[test]
448 fn test_apply_patch_json_args_support_patch_alias() {
449 let parsed: ApplyPatchToolArgs =
450 serde_json::from_str(r#"{"patch":"*** Begin Patch\n*** End Patch\n"}"#).expect("json args should parse");
451
452 assert_eq!(parsed.input, None);
453 assert_eq!(parsed.patch.as_deref(), Some("*** Begin Patch\n*** End Patch\n"));
454 }
455
456 #[test]
457 fn wants_no_sandbox_approval_reject_respects_sandbox_flag() {
458 let runtime = ApplyPatchRuntime::new();
459 assert!(runtime.wants_no_sandbox_approval(AskForApproval::OnRequest));
460 assert!(!runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig {
461 sandbox_approval: true,
462 rules: false,
463 request_permissions: false,
464 mcp_elicitations: false,
465 })));
466 assert!(runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig {
467 sandbox_approval: false,
468 rules: false,
469 request_permissions: false,
470 mcp_elicitations: false,
471 })));
472 }
473
474 #[cfg(unix)]
475 #[tokio::test]
476 async fn direct_runtime_rejects_symlink_escaped_cwd_before_mutation() {
477 use std::os::unix::fs::symlink;
478
479 let workspace = TempDir::new().expect("workspace should be created");
480 let outside = TempDir::new().expect("outside directory should be created");
481 let escaped_cwd = workspace.path().join("escaped");
482 symlink(outside.path(), &escaped_cwd).expect("cwd symlink should be created");
483
484 let session = Arc::new(DefaultToolSession::with_workspace(
485 workspace.path().to_path_buf(),
486 workspace.path().to_path_buf(),
487 ));
488 let turn = Arc::new(TurnContext {
489 cwd: escaped_cwd.clone(),
490 turn_id: "direct-apply-patch-test".to_string(),
491 sub_id: None,
492 shell_environment_policy: ShellEnvironmentPolicy::default(),
493 approval_policy: Constrained::default(),
494 linux_sandbox_launcher: None,
495 sandbox_policy: Constrained::default(),
496 });
497 let tool_ctx = ToolCtx {
498 session,
499 turn,
500 call_id: "call-1".to_string(),
501 tool_name: "apply_patch".to_string(),
502 };
503 let policy = SandboxConfig::default();
504 let attempt = SandboxAttempt {
505 sandbox: SandboxType::None,
506 policy: &policy,
507 sandbox_cwd: workspace.path(),
508 linux_sandbox_launcher: None,
509 };
510 let request = ApplyPatchRequest {
511 patch: "*** Begin Patch\n*** Add File: created.txt\n+must not exist\n*** End Patch\n".to_string(),
512 cwd: escaped_cwd,
513 timeout_ms: None,
514 user_explicitly_approved: true,
515 };
516
517 let error = ApplyPatchRuntime::new()
518 .run(&request, &attempt, &tool_ctx)
519 .await
520 .expect_err("direct apply_patch must reject an escaped cwd");
521 assert!(error.to_string().contains("outside session workspace"));
522 assert!(!outside.path().join("created.txt").exists());
523 }
524}