Skip to main content

vtcode_core/tools/handlers/
apply_patch_handler.rs

1//! Apply patch handler (from Codex)
2//!
3//! Implements the apply_patch tool using the Codex-style handler pattern.
4//! Supports both freeform and JSON function call formats.
5//!
6//! Based on [openai/codex] tool handler patterns (Apache-2.0).
7//! Copyright 2025 OpenAI. See the repository `THIRD-PARTY-NOTICES` file for
8//! full attribution.
9//!
10//! [openai/codex]: https://github.com/openai/codex
11
12use 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
32/// Apply patch handler
33pub struct ApplyPatchHandler;
34
35/// Arguments for apply_patch function call
36#[derive(Debug, Deserialize, Serialize)]
37pub struct ApplyPatchToolArgs {
38    pub input: Option<String>,
39    pub patch: Option<String>,
40}
41
42/// Request for apply_patch runtime
43#[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/// Approval key for caching
52#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize)]
53pub struct ApplyPatchApprovalKey {
54    patch: String,
55    cwd: PathBuf,
56}
57
58/// Apply patch runtime for orchestrated execution
59#[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        // Patches modify files, so we prefer auto sandbox
71        SandboxablePreference::Auto
72    }
73
74    fn escalate_on_failure(&self) -> bool {
75        // Allow escalation if sandbox fails
76        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        // Preserve existing behavior from the legacy orchestrator path:
89        // apply_patch is executed without additional approval prompts here.
90        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        // Parse and apply the patch
134        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        // Apply the patch
145        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 // apply_patch always mutates
175    }
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        // Extract patch input from payload
188        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        // Parse the patch to get file changes
213        let patch =
214            Patch::parse(&patch_input).map_err(|e| ToolCallError::respond(format!("Failed to parse patch: {e}")))?;
215
216        // Convert patch operations to file changes for tracking
217        let changes = convert_patch_to_changes(&patch, &turn.cwd);
218
219        // Create emitter for event tracking
220        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        // Create request
225        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        // Execute using orchestrator
233        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        // Emit completion event and format output
247        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
254/// Convert patch operations to file changes for tracking
255fn 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                    // For updates, we track as update with empty placeholders
274                    // The actual content will be computed during application
275                    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
298/// Create freeform apply_patch tool spec (for GPT-5 style models)
299pub 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
313/// Create JSON function apply_patch tool spec (for standard function calling)
314pub 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
340/// Parse a shell command to check if it's an apply_patch invocation
341pub(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        // Direct invocation: apply_patch <patch>
346        [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => (true, Some(body.clone())),
347        // Shell heredoc form is not directly supported here
348        // The Codex implementation uses tree-sitter to parse these
349        _ => (false, None),
350    }
351}
352
353// Constants for tool descriptions
354const 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}