tenseleyflow/loader / 8cdd374

Browse files

Prompt user for approval on writes outside workspace instead of blocking

Authored by espadonne
SHA
8cdd374df9d64da865b8782cf5655919dcdaa0d5
Parents
520c30f
Tree
c4e1200

3 changed files

StatusFile+-
M src/loader/tools/file_tools.py 46 6
M tests/test_runtime_harness.py 8 1
M tests/test_tool_safety.py 13 4
src/loader/tools/file_tools.pymodified
@@ -127,6 +127,7 @@ class WriteTool(Tool):
127127
         self.workspace_root = (
128128
             Path(workspace_root).expanduser().resolve() if workspace_root else None
129129
         )
130
+        self._pending_escape_approvals: set[str] = set()
130131
 
131132
     @property
132133
     def name(self) -> str:
@@ -177,6 +178,7 @@ class WriteTool(Tool):
177178
         content: str,
178179
         **kwargs: Any,
179180
     ) -> ToolResult:
181
+        kwargs.pop("_skip_confirmation", None)
180182
         try:
181183
             ensure_safe_to_write(content)
182184
             path = resolve_workspace_path(
@@ -184,8 +186,19 @@ class WriteTool(Tool):
184186
                 workspace_root=self.workspace_root,
185187
                 allow_missing=True,
186188
             )
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
+                )
189202
         except Exception as exc:
190203
             return ToolResult(f"Error writing file: {exc}", is_error=True)
191204
 
@@ -229,6 +242,7 @@ class EditTool(Tool):
229242
         self.workspace_root = (
230243
             Path(workspace_root).expanduser().resolve() if workspace_root else None
231244
         )
245
+        self._pending_escape_approvals: set[str] = set()
232246
 
233247
     @property
234248
     def name(self) -> str:
@@ -286,6 +300,7 @@ class EditTool(Tool):
286300
         new_string: str,
287301
         **kwargs: Any,
288302
     ) -> ToolResult:
303
+        kwargs.pop("_skip_confirmation", None)
289304
         try:
290305
             path = resolve_workspace_path(
291306
                 file_path,
@@ -293,8 +308,19 @@ class EditTool(Tool):
293308
             )
294309
         except FileNotFoundError:
295310
             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
+                )
298324
         except Exception as exc:
299325
             return ToolResult(f"Error resolving file path: {exc}", is_error=True)
300326
 
@@ -351,6 +377,7 @@ class PatchTool(Tool):
351377
         self.workspace_root = (
352378
             Path(workspace_root).expanduser().resolve() if workspace_root else None
353379
         )
380
+        self._pending_escape_approvals: set[str] = set()
354381
 
355382
     @property
356383
     def name(self) -> str:
@@ -430,6 +457,7 @@ class PatchTool(Tool):
430457
         except Exception as exc:
431458
             return ToolResult(f"Invalid structured patch: {exc}", is_error=True)
432459
 
460
+        kwargs.pop("_skip_confirmation", None)
433461
         try:
434462
             path = resolve_workspace_path(
435463
                 file_path,
@@ -437,8 +465,20 @@ class PatchTool(Tool):
437465
             )
438466
         except FileNotFoundError:
439467
             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
+
442482
         except Exception as exc:
443483
             return ToolResult(f"Error resolving file path: {exc}", is_error=True)
444484
 
tests/test_runtime_harness.pymodified
@@ -557,15 +557,22 @@ async def test_workspace_write_denies_write_outside_root(temp_dir: Path) -> None
557557
         ]
558558
     )
559559
 
560
+    async def decline_confirmation(_name: str, _msg: str, _details: str) -> bool:
561
+        return False
562
+
560563
     run = await run_scenario(
561564
         "Write a file outside the workspace.",
562565
         backend,
563566
         config=config,
564567
         project_root=temp_dir,
568
+        on_confirmation=decline_confirmation,
565569
     )
566570
 
567571
     assert not outside.exists()
568
-    assert any("escapes workspace boundary" in message for message in tool_result_messages(run))
572
+    assert any(
573
+        "declined" in message.lower() or "outside workspace" in message.lower()
574
+        for message in tool_result_messages(run)
575
+    )
569576
 
570577
 
571578
 @pytest.mark.asyncio
tests/test_tool_safety.pymodified
@@ -39,14 +39,23 @@ async def test_read_tool_blocks_symlink_escape(temp_dir: Path) -> None:
3939
 
4040
 
4141
 @pytest.mark.asyncio
42
-async def test_write_tool_blocks_workspace_escape(temp_dir: Path) -> None:
42
+async def test_write_tool_prompts_for_workspace_escape(temp_dir: Path) -> None:
43
+    from loader.tools.base import ConfirmationRequired
44
+
4345
     outside = temp_dir.parent / "outside-write.txt"
4446
     tool = WriteTool(workspace_root=temp_dir)
4547
 
46
-    result = await tool.execute(file_path=str(outside), content="outside\n")
48
+    # First attempt raises ConfirmationRequired
49
+    with pytest.raises(ConfirmationRequired):
50
+        await tool.execute(file_path=str(outside), content="outside\n")
4751
 
48
-    assert result.is_error
49
-    assert "workspace boundary" in result.output.lower()
52
+    assert not outside.exists()
53
+
54
+    # Second attempt (simulating retry after user approval) succeeds
55
+    result = await tool.execute(file_path=str(outside), content="outside\n")
56
+    assert not result.is_error
57
+    assert outside.exists()
58
+    outside.unlink()
5059
 
5160
 
5261
 @pytest.mark.asyncio