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        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        // Apply the patch
158        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 // apply_patch always mutates
192    }
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        // Extract patch input from payload
205        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        // Parse the patch to get file changes
230        let patch =
231            Patch::parse(&patch_input).map_err(|e| ToolCallError::respond(format!("Failed to parse patch: {e}")))?;
232
233        // Convert patch operations to file changes for tracking
234        let changes = convert_patch_to_changes(&patch, &turn.cwd);
235
236        // Create emitter for event tracking
237        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        // Create request
242        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        // Execute using orchestrator
250        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        // Emit completion event and format output
264        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
271/// Convert patch operations to file changes for tracking
272fn 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                    // For updates, we track as update with empty placeholders
291                    // The actual content will be computed during application
292                    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
315/// Create freeform apply_patch tool spec (for GPT-5 style models)
316pub 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
330/// Create JSON function apply_patch tool spec (for standard function calling)
331pub 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
357/// Parse a shell command to check if it's an apply_patch invocation
358pub(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        // Direct invocation: apply_patch <patch>
363        [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => (true, Some(body.clone())),
364        // Shell heredoc form is not directly supported here
365        // The Codex implementation uses tree-sitter to parse these
366        _ => (false, None),
367    }
368}
369
370// Constants for tool descriptions
371const 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}