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 let _leases = if let Some(monitor) = ctx.session.edited_file_monitor() {
145 crate::tools::source_inspection::SourceInspection::acquire_patch_leases(monitor, &patch, &req.cwd)
146 .await
147 .map_err(|error| ToolError::Rejected(error.to_string()))?
148 } else {
149 Vec::new()
150 };
151 let fallback = crate::tools::source_inspection::SourceInspection::default();
152 let inspection = ctx.session.source_inspection().unwrap_or(&fallback);
153 let inspection_plan = inspection
154 .prepare_patch(&patch, &req.cwd)
155 .await
156 .map_err(|error| ToolError::Rejected(error.to_string()))?;
157 match patch.apply(&req.cwd).await {
159 Ok(results) => {
160 inspection.commit_patch(
161 inspection_plan,
162 crate::tools::source_inspection::call_key(tools::APPLY_PATCH, &json!({"input":req.patch})),
163 );
164 let output = results.join("\n");
165 Ok(ExecToolCallOutput {
166 stdout: output,
167 stderr: String::new(),
168 exit_code: 0,
169 })
170 }
171 Err(e) => Ok(ExecToolCallOutput {
172 stdout: String::new(),
173 stderr: format!("Patch application failed: {e}"),
174 exit_code: 1,
175 }),
176 }
177 }
178}
179
180#[async_trait]
181impl ToolHandler for ApplyPatchHandler {
182 fn kind(&self) -> ToolKind {
183 ToolKind::Function
184 }
185
186 fn matches_kind(&self, payload: &ToolPayload) -> bool {
187 matches!(payload, ToolPayload::Function { .. } | ToolPayload::Custom { .. })
188 }
189
190 async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
191 true }
193
194 async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, ToolCallError> {
195 let ToolInvocation {
196 session,
197 turn,
198 tracker,
199 call_id,
200 tool_name,
201 payload,
202 } = invocation;
203
204 let patch_input = match payload {
206 ToolPayload::Function { arguments } => {
207 let args: Value = serde_json::from_str(&arguments).map_err(|e| {
208 ToolCallError::respond(format!(
209 "Failed to parse function arguments: {e}. {}",
210 crate::tools::apply_patch::APPLY_PATCH_ARGUMENT_CORRECTION
211 ))
212 })?;
213 crate::tools::apply_patch::decode_apply_patch_input(&args)
214 .map_err(|e| ToolCallError::respond(format!("Failed to decode patch input: {e}")))?
215 .map(|input| input.text)
216 .ok_or_else(|| {
217 ToolCallError::respond(format!(
218 "Missing patch input {}",
219 crate::tools::error_helpers::PATCH_PARAMETER_HINT
220 ))
221 })?
222 }
223 ToolPayload::Custom { input } => input,
224 _ => {
225 return Err(ToolCallError::respond("apply_patch handler received unsupported payload"));
226 }
227 };
228
229 let patch =
231 Patch::parse(&patch_input).map_err(|e| ToolCallError::respond(format!("Failed to parse patch: {e}")))?;
232
233 let changes = convert_patch_to_changes(&patch, &turn.cwd);
235
236 let emitter = ToolEmitter::apply_patch(changes.clone(), true);
238 let event_ctx = ToolEventCtx::new(turn.as_ref(), &call_id, tracker.as_ref());
239 emitter.begin(event_ctx).await;
240
241 let req = ApplyPatchRequest {
243 patch: patch_input.clone(),
244 cwd: turn.cwd.clone(),
245 timeout_ms: None,
246 user_explicitly_approved: true,
247 };
248
249 let mut orchestrator = ToolOrchestrator::new();
251 let mut runtime = ApplyPatchRuntime::new();
252 let tool_ctx = ToolCtx {
253 session: session.clone(),
254 turn: turn.clone(),
255 call_id: call_id.clone(),
256 tool_name: tool_name.clone(),
257 };
258
259 let result = orchestrator
260 .run(&mut runtime, &req, &tool_ctx, turn.as_ref(), map_approval_policy(turn.approval_policy.value()))
261 .await;
262
263 let event_ctx = ToolEventCtx::new(turn.as_ref(), &call_id, tracker.as_ref());
265 let content = emitter.finish(event_ctx, result).await?;
266
267 Ok(ToolOutput::Function { content, content_items: None, success: Some(true) })
268 }
269}
270
271fn convert_patch_to_changes(patch: &Patch, cwd: &Path) -> HashMap<PathBuf, FileChange> {
273 let mut changes = HashMap::new();
274
275 for op in patch.operations() {
276 match op {
277 PatchOperation::AddFile { path, content } => {
278 let full_path = cwd.join(path);
279 changes.insert(full_path, FileChange::Add { content: content.clone() });
280 }
281 PatchOperation::DeleteFile { path } => {
282 let full_path = cwd.join(path);
283 changes.insert(full_path, FileChange::Delete);
284 }
285 PatchOperation::UpdateFile { path, new_path, chunks: _ } => {
286 let full_path = cwd.join(path);
287 if let Some(new_path) = new_path {
288 changes.insert(full_path, FileChange::Rename { new_path: cwd.join(new_path), content: None });
289 } else {
290 changes.insert(
293 full_path,
294 FileChange::Update {
295 old_content: String::new(),
296 new_content: String::new(),
297 },
298 );
299 }
300 }
301 }
302 }
303
304 changes
305}
306
307fn map_approval_policy(policy: ApprovalPolicy) -> AskForApproval {
308 match policy {
309 ApprovalPolicy::Never => AskForApproval::Never,
310 ApprovalPolicy::OnMutation => AskForApproval::OnRequest,
311 ApprovalPolicy::Always => AskForApproval::UnlessTrusted,
312 }
313}
314
315pub fn create_apply_patch_freeform_tool() -> ToolSpec {
317 ToolSpec::Freeform(FreeformTool {
318 name: tools::APPLY_PATCH.to_string(),
319 description: APPLY_PATCH_DESCRIPTION.to_string(),
320 format: FreeformToolFormat {
321 lark_grammar: Some(APPLY_PATCH_LARK_GRAMMAR.to_string()),
322 examples: vec![
323 APPLY_PATCH_ADD_EXAMPLE.to_string(),
324 APPLY_PATCH_UPDATE_EXAMPLE.to_string(),
325 ],
326 },
327 })
328}
329
330pub fn create_apply_patch_json_tool() -> ToolSpec {
332 use crate::tools::apply_patch::{
333 APPLY_PATCH_ALIAS_DESCRIPTION, DEFAULT_APPLY_PATCH_INPUT_DESCRIPTION, with_semantic_anchor_guidance,
334 };
335 ToolSpec::Function(ResponsesApiTool {
336 name: tools::APPLY_PATCH.to_string(),
337 description: format!("{APPLY_PATCH_DESCRIPTION}\n\n{APPLY_PATCH_GRAMMAR_HELP}"),
338 strict: false,
339 parameters: json!({
340 "type": "object",
341 "properties": {
342 "input": {
343 "type": "string",
344 "description": with_semantic_anchor_guidance(DEFAULT_APPLY_PATCH_INPUT_DESCRIPTION)
345 },
346 "patch": {
347 "type": "string",
348 "description": with_semantic_anchor_guidance(APPLY_PATCH_ALIAS_DESCRIPTION)
349 }
350 },
351 "required": ["input"],
352 "additionalProperties": false
353 }),
354 })
355}
356
357pub(crate) fn parse_apply_patch_command(command: &[String]) -> (bool, Option<String>) {
359 const APPLY_PATCH_COMMANDS: &[&str] = &["apply_patch", "applypatch"];
360
361 match command {
362 [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => (true, Some(body.clone())),
364 _ => (false, None),
367 }
368}
369
370const APPLY_PATCH_DESCRIPTION: &str = r#"Use the `apply_patch` tool to edit files.
372Your 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.
373
374You can think of it as a high-level envelope:
375
376*** Begin Patch
377[ one or more file sections ]
378*** End Patch
379
380Within that envelope, you get a sequence of file operations.
381You MUST include a header to specify the action you are taking.
382Each operation starts with one of three headers:
383
384*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
385*** Delete File: <path> - remove an existing file. Nothing follows.
386*** Update File: <path> - patch an existing file in place (optionally with a rename)."#;
387
388const APPLY_PATCH_GRAMMAR_HELP: &str = r#"May be immediately followed by *** Move to: <new path> if you want to rename the file.
389Then one or more "hunks", each introduced by @@ (optionally followed by a hunk header).
390Within a hunk each line starts with:
391
392- ` ` (space) for context lines
393- `-` for lines to remove
394- `+` for lines to add
395
396Important rules:
397- You must include a header with your intended action (Add/Delete/Update)
398- You must prefix new lines with `+` even when creating a new file
399- File references must be workspace-relative; never use absolute paths, `..`, or traversal-like forms
400- Prefer small hunks with stable semantic @@ anchors like function, class, method, or impl names"#;
401
402const APPLY_PATCH_LARK_GRAMMAR: &str = r#"
403patch := "*** Begin Patch" NEWLINE { operation } "*** End Patch"
404operation := AddFile | DeleteFile | UpdateFile
405AddFile := "*** Add File: " path NEWLINE { "+" text NEWLINE }
406DeleteFile := "*** Delete File: " path NEWLINE
407UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
408MoveTo := "*** Move to: " newPath NEWLINE
409Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
410HunkLine := (" " | "-" | "+") text NEWLINE
411"#;
412
413const APPLY_PATCH_ADD_EXAMPLE: &str = r#"*** Begin Patch
414*** Add File: hello.txt
415+Hello world
416*** End Patch"#;
417
418const APPLY_PATCH_UPDATE_EXAMPLE: &str = r#"*** Begin Patch
419*** Update File: src/app.py
420*** Move to: src/main.py
421@@ def greet():
422-print("Hi")
423+print("Hello, world!")
424*** End Patch"#;
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::exec_policy::RejectConfig;
430 use crate::tools::handlers::adapter::DefaultToolSession;
431 use crate::tools::handlers::sandboxing::{SandboxConfig, SandboxType};
432 use crate::tools::handlers::tool_handler::{Constrained, ShellEnvironmentPolicy, TurnContext};
433 use std::sync::Arc;
434 use tempfile::TempDir;
435
436 #[test]
437 fn test_parse_apply_patch_command_direct() {
438 let cmd = vec!["apply_patch".to_string(), "*** Begin Patch\n*** End Patch".to_string()];
439 let (is_patch, content) = parse_apply_patch_command(&cmd);
440 assert!(is_patch);
441 assert!(content.is_some());
442 }
443
444 #[test]
445 fn test_parse_apply_patch_command_not_patch() {
446 let cmd = vec!["ls".to_string(), "-la".to_string()];
447 let (is_patch, content) = parse_apply_patch_command(&cmd);
448 assert!(!is_patch);
449 assert!(content.is_none());
450 }
451
452 #[test]
453 fn test_create_freeform_tool() {
454 let tool = create_apply_patch_freeform_tool();
455 assert_eq!(tool.name(), "apply_patch");
456 }
457
458 #[test]
459 fn test_create_json_tool() {
460 let tool = create_apply_patch_json_tool();
461 assert_eq!(tool.name(), "apply_patch");
462 }
463
464 #[test]
465 fn test_apply_patch_json_args_support_patch_alias() {
466 let parsed: ApplyPatchToolArgs =
467 serde_json::from_str(r#"{"patch":"*** Begin Patch\n*** End Patch\n"}"#).expect("json args should parse");
468
469 assert_eq!(parsed.input, None);
470 assert_eq!(parsed.patch.as_deref(), Some("*** Begin Patch\n*** End Patch\n"));
471 }
472
473 #[test]
474 fn wants_no_sandbox_approval_reject_respects_sandbox_flag() {
475 let runtime = ApplyPatchRuntime::new();
476 assert!(runtime.wants_no_sandbox_approval(AskForApproval::OnRequest));
477 assert!(!runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig {
478 sandbox_approval: true,
479 rules: false,
480 request_permissions: false,
481 mcp_elicitations: false,
482 })));
483 assert!(runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig {
484 sandbox_approval: false,
485 rules: false,
486 request_permissions: false,
487 mcp_elicitations: false,
488 })));
489 }
490
491 #[cfg(unix)]
492 #[tokio::test]
493 async fn direct_runtime_rejects_symlink_escaped_cwd_before_mutation() {
494 use std::os::unix::fs::symlink;
495
496 let workspace = TempDir::new().expect("workspace should be created");
497 let outside = TempDir::new().expect("outside directory should be created");
498 let escaped_cwd = workspace.path().join("escaped");
499 symlink(outside.path(), &escaped_cwd).expect("cwd symlink should be created");
500
501 let session = Arc::new(DefaultToolSession::with_workspace(
502 workspace.path().to_path_buf(),
503 workspace.path().to_path_buf(),
504 ));
505 let turn = Arc::new(TurnContext {
506 cwd: escaped_cwd.clone(),
507 turn_id: "direct-apply-patch-test".to_string(),
508 sub_id: None,
509 shell_environment_policy: ShellEnvironmentPolicy::default(),
510 approval_policy: Constrained::default(),
511 linux_sandbox_launcher: None,
512 sandbox_policy: Constrained::default(),
513 });
514 let tool_ctx = ToolCtx {
515 session,
516 turn,
517 call_id: "call-1".to_string(),
518 tool_name: "apply_patch".to_string(),
519 };
520 let policy = SandboxConfig::default();
521 let attempt = SandboxAttempt {
522 sandbox: SandboxType::None,
523 policy: &policy,
524 sandbox_cwd: workspace.path(),
525 linux_sandbox_launcher: None,
526 };
527 let request = ApplyPatchRequest {
528 patch: "*** Begin Patch\n*** Add File: created.txt\n+must not exist\n*** End Patch\n".to_string(),
529 cwd: escaped_cwd,
530 timeout_ms: None,
531 user_explicitly_approved: true,
532 };
533
534 let error = ApplyPatchRuntime::new()
535 .run(&request, &attempt, &tool_ctx)
536 .await
537 .expect_err("direct apply_patch must reject an escaped cwd");
538 assert!(error.to_string().contains("outside session workspace"));
539 assert!(!outside.path().join("created.txt").exists());
540 }
541}