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