@@ -127,6 +127,7 @@ class WriteTool(Tool): |
| 127 | 127 | self.workspace_root = ( |
| 128 | 128 | Path(workspace_root).expanduser().resolve() if workspace_root else None |
| 129 | 129 | ) |
| 130 | + self._pending_escape_approvals: set[str] = set() |
| 130 | 131 | |
| 131 | 132 | @property |
| 132 | 133 | def name(self) -> str: |
@@ -177,6 +178,7 @@ class WriteTool(Tool): |
| 177 | 178 | content: str, |
| 178 | 179 | **kwargs: Any, |
| 179 | 180 | ) -> ToolResult: |
| 181 | + kwargs.pop("_skip_confirmation", None) |
| 180 | 182 | try: |
| 181 | 183 | ensure_safe_to_write(content) |
| 182 | 184 | path = resolve_workspace_path( |
@@ -184,8 +186,19 @@ class WriteTool(Tool): |
| 184 | 186 | workspace_root=self.workspace_root, |
| 185 | 187 | allow_missing=True, |
| 186 | 188 | ) |
| 187 | | - except PermissionError as exc: |
| 188 | | - return ToolResult(f"Permission denied: {exc}", is_error=True) |
| 189 | + except PermissionError: |
| 190 | + resolved = Path(file_path).expanduser().resolve() |
| 191 | + key = str(resolved) |
| 192 | + if key in self._pending_escape_approvals: |
| 193 | + self._pending_escape_approvals.discard(key) |
| 194 | + path = resolved |
| 195 | + else: |
| 196 | + self._pending_escape_approvals.add(key) |
| 197 | + raise ConfirmationRequired( |
| 198 | + tool_name=self.name, |
| 199 | + message=f"Write outside workspace: {file_path}", |
| 200 | + details=f"Target is outside the workspace root ({self.workspace_root})", |
| 201 | + ) |
| 189 | 202 | except Exception as exc: |
| 190 | 203 | return ToolResult(f"Error writing file: {exc}", is_error=True) |
| 191 | 204 | |
@@ -229,6 +242,7 @@ class EditTool(Tool): |
| 229 | 242 | self.workspace_root = ( |
| 230 | 243 | Path(workspace_root).expanduser().resolve() if workspace_root else None |
| 231 | 244 | ) |
| 245 | + self._pending_escape_approvals: set[str] = set() |
| 232 | 246 | |
| 233 | 247 | @property |
| 234 | 248 | def name(self) -> str: |
@@ -286,6 +300,7 @@ class EditTool(Tool): |
| 286 | 300 | new_string: str, |
| 287 | 301 | **kwargs: Any, |
| 288 | 302 | ) -> ToolResult: |
| 303 | + kwargs.pop("_skip_confirmation", None) |
| 289 | 304 | try: |
| 290 | 305 | path = resolve_workspace_path( |
| 291 | 306 | file_path, |
@@ -293,8 +308,19 @@ class EditTool(Tool): |
| 293 | 308 | ) |
| 294 | 309 | except FileNotFoundError: |
| 295 | 310 | return ToolResult(f"File not found: {file_path}", is_error=True) |
| 296 | | - except PermissionError as exc: |
| 297 | | - return ToolResult(f"Permission denied: {exc}", is_error=True) |
| 311 | + except PermissionError: |
| 312 | + resolved = Path(file_path).expanduser().resolve() |
| 313 | + key = str(resolved) |
| 314 | + if key in self._pending_escape_approvals: |
| 315 | + self._pending_escape_approvals.discard(key) |
| 316 | + path = resolved |
| 317 | + else: |
| 318 | + self._pending_escape_approvals.add(key) |
| 319 | + raise ConfirmationRequired( |
| 320 | + tool_name=self.name, |
| 321 | + message=f"Edit outside workspace: {file_path}", |
| 322 | + details=f"Target is outside the workspace root ({self.workspace_root})", |
| 323 | + ) |
| 298 | 324 | except Exception as exc: |
| 299 | 325 | return ToolResult(f"Error resolving file path: {exc}", is_error=True) |
| 300 | 326 | |
@@ -351,6 +377,7 @@ class PatchTool(Tool): |
| 351 | 377 | self.workspace_root = ( |
| 352 | 378 | Path(workspace_root).expanduser().resolve() if workspace_root else None |
| 353 | 379 | ) |
| 380 | + self._pending_escape_approvals: set[str] = set() |
| 354 | 381 | |
| 355 | 382 | @property |
| 356 | 383 | def name(self) -> str: |
@@ -430,6 +457,7 @@ class PatchTool(Tool): |
| 430 | 457 | except Exception as exc: |
| 431 | 458 | return ToolResult(f"Invalid structured patch: {exc}", is_error=True) |
| 432 | 459 | |
| 460 | + kwargs.pop("_skip_confirmation", None) |
| 433 | 461 | try: |
| 434 | 462 | path = resolve_workspace_path( |
| 435 | 463 | file_path, |
@@ -437,8 +465,20 @@ class PatchTool(Tool): |
| 437 | 465 | ) |
| 438 | 466 | except FileNotFoundError: |
| 439 | 467 | return ToolResult(f"File not found: {file_path}", is_error=True) |
| 440 | | - except PermissionError as exc: |
| 441 | | - return ToolResult(f"Permission denied: {exc}", is_error=True) |
| 468 | + except PermissionError: |
| 469 | + resolved = Path(file_path).expanduser().resolve() |
| 470 | + key = str(resolved) |
| 471 | + if key in self._pending_escape_approvals: |
| 472 | + self._pending_escape_approvals.discard(key) |
| 473 | + path = resolved |
| 474 | + else: |
| 475 | + self._pending_escape_approvals.add(key) |
| 476 | + raise ConfirmationRequired( |
| 477 | + tool_name=self.name, |
| 478 | + message=f"Patch outside workspace: {file_path}", |
| 479 | + details=f"Target is outside the workspace root ({self.workspace_root})", |
| 480 | + ) |
| 481 | + |
| 442 | 482 | except Exception as exc: |
| 443 | 483 | return ToolResult(f"Error resolving file path: {exc}", is_error=True) |
| 444 | 484 | |