flowent 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/backend/pyproject.toml +31 -5
  2. package/backend/src/flowent/agent.py +13 -4
  3. package/backend/src/flowent/compact.py +35 -14
  4. package/backend/src/flowent/llm.py +73 -7
  5. package/backend/src/flowent/main.py +260 -59
  6. package/backend/src/flowent/static/assets/index-CRSV2xu1.css +2 -0
  7. package/backend/src/flowent/static/assets/index-DUYj6rgD.js +82 -0
  8. package/backend/src/flowent/static/index.html +2 -2
  9. package/backend/src/flowent/storage.py +135 -3
  10. package/backend/src/flowent/usage.py +315 -0
  11. package/backend/uv.lock +971 -3
  12. package/dist/frontend/assets/index-CRSV2xu1.css +2 -0
  13. package/dist/frontend/assets/index-DUYj6rgD.js +82 -0
  14. package/dist/frontend/index.html +2 -2
  15. package/package.json +24 -3
  16. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  23. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  24. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  25. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  26. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  27. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  28. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  29. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  30. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  31. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  32. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  33. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  34. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  35. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  36. package/backend/src/flowent/static/assets/index-BlaCigkZ.js +0 -82
  37. package/backend/src/flowent/static/assets/index-CRvbsH4K.css +0 -2
  38. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
  47. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  48. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  49. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  50. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  51. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  52. package/backend/tests/conftest.py +0 -60
  53. package/backend/tests/test_agent_tools.py +0 -1124
  54. package/backend/tests/test_approval.py +0 -283
  55. package/backend/tests/test_channels.py +0 -360
  56. package/backend/tests/test_health.py +0 -12
  57. package/backend/tests/test_llm_providers.py +0 -548
  58. package/backend/tests/test_logging.py +0 -212
  59. package/backend/tests/test_mcp.py +0 -788
  60. package/backend/tests/test_patch.py +0 -112
  61. package/backend/tests/test_permissions.py +0 -588
  62. package/backend/tests/test_persistence.py +0 -249
  63. package/backend/tests/test_skills.py +0 -462
  64. package/backend/tests/test_startup_requirements.py +0 -144
  65. package/backend/tests/test_workspace_chat.py +0 -2174
  66. package/dist/frontend/assets/index-BlaCigkZ.js +0 -82
  67. package/dist/frontend/assets/index-CRvbsH4K.css +0 -2
@@ -1,112 +0,0 @@
1
- import pytest
2
-
3
- from flowent.patch import PatchError, affected_paths, apply_patch
4
-
5
-
6
- def test_apply_patch_applies_context_hunk_with_interleaved_changes(tmp_path) -> None:
7
- target = tmp_path / "notes.txt"
8
- target.write_text("start\nalpha\nmiddle\nbeta\nend\n")
9
- patch = """*** Begin Patch
10
- *** Update File: notes.txt
11
- @@
12
- start
13
- -alpha
14
- +one
15
- middle
16
- -beta
17
- +two
18
- end
19
- *** End Patch
20
- """
21
-
22
- result = apply_patch(patch, tmp_path)
23
-
24
- assert result == {"files": [{"path": str(target), "status": "modified"}]}
25
- assert target.read_text() == "start\none\nmiddle\ntwo\nend\n"
26
-
27
-
28
- def test_apply_patch_reports_context_mismatch(tmp_path) -> None:
29
- target = tmp_path / "notes.txt"
30
- target.write_text("start\nalpha\nend\n")
31
- patch = """*** Begin Patch
32
- *** Update File: notes.txt
33
- @@
34
- missing
35
- -alpha
36
- +beta
37
- end
38
- *** End Patch
39
- """
40
-
41
- with pytest.raises(PatchError, match=r"Patch context was not found\."):
42
- apply_patch(patch, tmp_path)
43
-
44
- assert target.read_text() == "start\nalpha\nend\n"
45
-
46
-
47
- def test_apply_patch_applies_multiple_hunks_in_order(tmp_path) -> None:
48
- target = tmp_path / "notes.txt"
49
- target.write_text("first\nsame\nend first\nsecond\nsame\nend second\n")
50
- patch = """*** Begin Patch
51
- *** Update File: notes.txt
52
- @@
53
- first
54
- -same
55
- +one
56
- end first
57
- @@
58
- second
59
- -same
60
- +two
61
- end second
62
- *** End Patch
63
- """
64
-
65
- apply_patch(patch, tmp_path)
66
-
67
- assert target.read_text() == "first\none\nend first\nsecond\ntwo\nend second\n"
68
-
69
-
70
- def test_apply_patch_keeps_simple_contiguous_replacement(tmp_path) -> None:
71
- target = tmp_path / "notes.txt"
72
- target.write_text("alpha\nbeta\n")
73
- patch = """*** Begin Patch
74
- *** Update File: notes.txt
75
- @@
76
- -alpha
77
- -beta
78
- +ready
79
- *** End Patch
80
- """
81
-
82
- apply_patch(patch, tmp_path)
83
-
84
- assert target.read_text() == "ready\n"
85
-
86
-
87
- def test_affected_paths_reads_structured_patch_write_targets(tmp_path) -> None:
88
- patch = """*** Begin Patch
89
- *** Update File: notes.txt
90
- @@
91
- -alpha
92
- +beta
93
- *** Add File: created.txt
94
- +hello
95
- *** Delete File: old.txt
96
- *** Update File: before.txt
97
- *** Move to: after.txt
98
- @@
99
- -before
100
- +after
101
- *** End Patch
102
- """
103
-
104
- paths = affected_paths(patch, tmp_path)
105
-
106
- assert paths == [
107
- (tmp_path / "notes.txt").resolve(strict=False),
108
- (tmp_path / "created.txt").resolve(strict=False),
109
- (tmp_path / "old.txt").resolve(strict=False),
110
- (tmp_path / "before.txt").resolve(strict=False),
111
- (tmp_path / "after.txt").resolve(strict=False),
112
- ]
@@ -1,588 +0,0 @@
1
- from pathlib import Path
2
-
3
- import pytest
4
- from fastapi.testclient import TestClient
5
-
6
- from flowent.approval import ApprovalReviewDecision, ApprovalReviewRequest
7
- from flowent.main import create_app
8
- from flowent.permissions import run_tool_with_path_permissions
9
- from flowent.sandbox import CommandResult, SandboxRunner
10
- from flowent.storage import StateStore
11
- from flowent.tools import ToolContext
12
-
13
-
14
- def test_app_state_persists_writable_paths_across_app_instances(
15
- tmp_path, monkeypatch
16
- ) -> None:
17
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
18
- monkeypatch.chdir(tmp_path)
19
- client = TestClient(create_app(serve_frontend=False))
20
-
21
- response = client.post(
22
- "/api/permissions/writable-paths",
23
- json={"path": "cache"},
24
- )
25
-
26
- assert response.status_code == 200
27
- restarted_client = TestClient(create_app(serve_frontend=False))
28
- state_response = restarted_client.get("/api/state")
29
-
30
- assert state_response.status_code == 200
31
- assert state_response.json()["writable_paths"][0]["path"] == str(tmp_path / "cache")
32
- assert isinstance(state_response.json()["writable_paths"][0]["created_at"], int)
33
-
34
-
35
- def test_delete_writable_path_removes_permission(tmp_path, monkeypatch) -> None:
36
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
37
- monkeypatch.chdir(tmp_path)
38
- client = TestClient(create_app(serve_frontend=False))
39
- client.post("/api/permissions/writable-paths", json={"path": "cache"})
40
-
41
- response = client.request(
42
- "DELETE",
43
- "/api/permissions/writable-paths",
44
- json={"path": str(tmp_path / "cache")},
45
- )
46
-
47
- assert response.status_code == 200
48
- assert response.json() == {"writable_paths": []}
49
-
50
-
51
- def test_writable_paths_are_saved_as_normalized_absolute_paths(
52
- tmp_path, monkeypatch
53
- ) -> None:
54
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
55
- monkeypatch.chdir(tmp_path)
56
- store = StateStore()
57
-
58
- store.save_writable_path(Path("cache") / ".." / "cache")
59
- store.save_writable_path(tmp_path / "cache")
60
-
61
- writable_paths = store.read_writable_paths()
62
-
63
- assert [path.path for path in writable_paths] == [str(tmp_path / "cache")]
64
-
65
-
66
- @pytest.mark.anyio
67
- async def test_approved_declared_write_path_runs_command_with_extra_permission(
68
- tmp_path, monkeypatch
69
- ) -> None:
70
- cache_dir = tmp_path / "cache"
71
- calls: list[list[Path]] = []
72
- reviews: list[ApprovalReviewRequest] = []
73
-
74
- async def fake_run_async(self, command, **kwargs):
75
- calls.append(self.writable_roots)
76
- return CommandResult(
77
- command=" ".join(command),
78
- exit_code=0,
79
- stderr="",
80
- stdout="created",
81
- )
82
-
83
- async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
84
- reviews.append(request)
85
- return ApprovalReviewDecision(
86
- decision="approved", reason="Needed for cache writes."
87
- )
88
-
89
- monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
90
-
91
- result = await run_tool_with_path_permissions(
92
- "shell_command",
93
- {
94
- "additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
95
- "command": f"echo created > {cache_dir / 'file.txt'}",
96
- "sandbox_permissions": "with_additional_permissions",
97
- },
98
- ToolContext(cwd=tmp_path / "work"),
99
- review_approval=approve,
100
- writable_paths=[],
101
- )
102
-
103
- assert result.ok
104
- assert result.content == "created"
105
- assert len(calls) == 1
106
- assert cache_dir in calls[0]
107
- assert reviews[0].tool_name == "shell_command"
108
- assert reviews[0].action == "additional_permissions"
109
- assert reviews[0].write_paths == [cache_dir]
110
- assert result.data["approval"] == {
111
- "action": "additional_permissions",
112
- "decision": "approved",
113
- "reason": "Needed for cache writes.",
114
- "tool_name": "shell_command",
115
- "tool_result": "",
116
- "write_paths": [str(cache_dir)],
117
- }
118
-
119
-
120
- @pytest.mark.anyio
121
- async def test_approved_declared_write_path_does_not_persist_runtime_permission(
122
- tmp_path, monkeypatch
123
- ) -> None:
124
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
125
- store = StateStore()
126
- cache_dir = tmp_path / "cache"
127
-
128
- async def fake_run_async(self, command, **kwargs):
129
- assert cache_dir in self.writable_roots
130
- return CommandResult(
131
- command=" ".join(command),
132
- exit_code=0,
133
- stderr="",
134
- stdout="created",
135
- )
136
-
137
- async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
138
- return ApprovalReviewDecision(
139
- decision="approved", reason="Needed for cache writes."
140
- )
141
-
142
- monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
143
-
144
- result = await run_tool_with_path_permissions(
145
- "shell_command",
146
- {
147
- "additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
148
- "command": f"mkdir -p {cache_dir}",
149
- "sandbox_permissions": "with_additional_permissions",
150
- },
151
- ToolContext(cwd=tmp_path / "work"),
152
- review_approval=approve,
153
- writable_paths=[],
154
- )
155
-
156
- assert result.ok
157
- assert store.read_writable_paths() == []
158
-
159
-
160
- @pytest.mark.anyio
161
- async def test_denied_declared_write_path_returns_failed_result_before_running_command(
162
- tmp_path, monkeypatch
163
- ) -> None:
164
- cache_dir = tmp_path / "cache"
165
- calls = 0
166
-
167
- async def fake_run_async(self, command, **kwargs):
168
- nonlocal calls
169
- calls += 1
170
- return CommandResult(
171
- command=" ".join(command),
172
- exit_code=0,
173
- stderr="",
174
- stdout="created",
175
- )
176
-
177
- async def deny(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
178
- return ApprovalReviewDecision(
179
- decision="denied", reason="Outside the task scope."
180
- )
181
-
182
- monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
183
-
184
- result = await run_tool_with_path_permissions(
185
- "shell_command",
186
- {
187
- "additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
188
- "command": f"echo created > {cache_dir / 'file.txt'}",
189
- "sandbox_permissions": "with_additional_permissions",
190
- },
191
- ToolContext(cwd=tmp_path / "work"),
192
- review_approval=deny,
193
- writable_paths=[],
194
- )
195
-
196
- assert not result.ok
197
- assert "Automatic approval review denied this action" in result.content
198
- assert "Outside the task scope." in result.content
199
- assert "must not work around" in result.content
200
- assert result.data["approval"]["decision"] == "denied"
201
- assert result.data["approval"]["reason"] == "Outside the task scope."
202
- assert calls == 0
203
-
204
-
205
- @pytest.mark.anyio
206
- async def test_existing_writable_path_covers_declared_review(
207
- tmp_path, monkeypatch
208
- ) -> None:
209
- cache_dir = tmp_path / "cache"
210
- requests = 0
211
-
212
- async def fake_run_async(self, command, **kwargs):
213
- assert cache_dir in self.writable_roots
214
- return CommandResult(
215
- command=" ".join(command),
216
- exit_code=0,
217
- stderr="",
218
- stdout="created",
219
- )
220
-
221
- async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
222
- nonlocal requests
223
- requests += 1
224
- return ApprovalReviewDecision(decision="approved", reason="Already allowed.")
225
-
226
- monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
227
-
228
- result = await run_tool_with_path_permissions(
229
- "shell_command",
230
- {
231
- "additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
232
- "command": f"echo created > {cache_dir / 'file.txt'}",
233
- "sandbox_permissions": "with_additional_permissions",
234
- },
235
- ToolContext(cwd=tmp_path / "work"),
236
- review_approval=approve,
237
- writable_paths=[cache_dir],
238
- )
239
-
240
- assert result.ok
241
- assert requests == 0
242
-
243
-
244
- @pytest.mark.anyio
245
- async def test_multiple_declared_write_paths_request_each_missing_path(
246
- tmp_path, monkeypatch
247
- ) -> None:
248
- first = tmp_path / "cache"
249
- second = tmp_path / "downloads"
250
- reviews: list[ApprovalReviewRequest] = []
251
-
252
- async def fake_run_async(self, command, **kwargs):
253
- assert first in self.writable_roots
254
- assert second in self.writable_roots
255
- return CommandResult(
256
- command=" ".join(command),
257
- exit_code=0,
258
- stderr="",
259
- stdout="created",
260
- )
261
-
262
- async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
263
- reviews.append(request)
264
- return ApprovalReviewDecision(
265
- decision="approved", reason="Needed for generated files."
266
- )
267
-
268
- monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
269
-
270
- result = await run_tool_with_path_permissions(
271
- "shell_command",
272
- {
273
- "additional_permissions": {
274
- "file_system": {"write": [str(first), str(second)]}
275
- },
276
- "command": "touch done",
277
- "sandbox_permissions": "with_additional_permissions",
278
- },
279
- ToolContext(cwd=tmp_path / "work"),
280
- review_approval=approve,
281
- writable_paths=[],
282
- )
283
-
284
- assert result.ok
285
- assert len(reviews) == 1
286
- assert reviews[0].write_paths == [first, second]
287
-
288
-
289
- @pytest.mark.anyio
290
- async def test_sandbox_denied_shell_command_is_reviewed_and_retried_without_sandbox(
291
- tmp_path, monkeypatch
292
- ) -> None:
293
- calls: list[str] = []
294
- reviews: list[ApprovalReviewRequest] = []
295
-
296
- async def fake_run_async(self, command, **kwargs):
297
- calls.append("sandbox")
298
- return CommandResult(
299
- command=" ".join(command),
300
- exit_code=1,
301
- stderr="failed to write file: Read-only file system\n",
302
- stdout="",
303
- )
304
-
305
- async def fake_run_unsandboxed_async(self, command, **kwargs):
306
- calls.append("unsandboxed")
307
- return CommandResult(
308
- command=" ".join(command),
309
- exit_code=0,
310
- stderr="",
311
- stdout="created",
312
- )
313
-
314
- async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
315
- reviews.append(request)
316
- return ApprovalReviewDecision(
317
- decision="approved", reason="Retry is consistent with the task."
318
- )
319
-
320
- monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
321
- monkeypatch.setattr(
322
- SandboxRunner,
323
- "run_unsandboxed_async",
324
- fake_run_unsandboxed_async,
325
- raising=False,
326
- )
327
-
328
- result = await run_tool_with_path_permissions(
329
- "shell_command",
330
- {"command": "touch output.txt"},
331
- ToolContext(cwd=tmp_path / "work"),
332
- review_approval=approve,
333
- writable_paths=[],
334
- )
335
-
336
- assert result.ok
337
- assert result.content == "created"
338
- assert calls == ["sandbox", "unsandboxed"]
339
- assert reviews[0].action == "sandbox_failure"
340
- assert "Read-only file system" in reviews[0].tool_result
341
- assert result.data["approval"]["action"] == "sandbox_failure"
342
- assert result.data["approval"]["decision"] == "approved"
343
-
344
-
345
- @pytest.mark.anyio
346
- async def test_sandbox_denied_shell_command_is_not_retried_when_reviewer_denies(
347
- tmp_path, monkeypatch
348
- ) -> None:
349
- calls: list[str] = []
350
-
351
- async def fake_run_async(self, command, **kwargs):
352
- calls.append("sandbox")
353
- return CommandResult(
354
- command=" ".join(command),
355
- exit_code=1,
356
- stderr="failed to write file: Read-only file system\n",
357
- stdout="",
358
- )
359
-
360
- async def fake_run_unsandboxed_async(self, command, **kwargs):
361
- calls.append("unsandboxed")
362
- return CommandResult(
363
- command=" ".join(command),
364
- exit_code=0,
365
- stderr="",
366
- stdout="created",
367
- )
368
-
369
- async def deny(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
370
- return ApprovalReviewDecision(decision="denied", reason="Too broad.")
371
-
372
- monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
373
- monkeypatch.setattr(
374
- SandboxRunner,
375
- "run_unsandboxed_async",
376
- fake_run_unsandboxed_async,
377
- raising=False,
378
- )
379
-
380
- result = await run_tool_with_path_permissions(
381
- "shell_command",
382
- {"command": "touch output.txt"},
383
- ToolContext(cwd=tmp_path / "work"),
384
- review_approval=deny,
385
- writable_paths=[],
386
- )
387
-
388
- assert not result.ok
389
- assert calls == ["sandbox"]
390
- assert "Too broad." in result.content
391
- assert result.data["approval"]["decision"] == "denied"
392
-
393
-
394
- @pytest.mark.anyio
395
- async def test_command_text_is_not_used_to_guess_write_paths(
396
- tmp_path, monkeypatch
397
- ) -> None:
398
- outside = tmp_path / "outside"
399
- reviews: list[ApprovalReviewRequest] = []
400
-
401
- async def fake_run_async(self, command, **kwargs):
402
- assert outside not in self.writable_roots
403
- return CommandResult(
404
- command=" ".join(command),
405
- exit_code=1,
406
- stderr=f"rm: cannot remove '{outside / 'file.txt'}': Read-only file system\n",
407
- stdout="",
408
- )
409
-
410
- async def deny(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
411
- reviews.append(request)
412
- return ApprovalReviewDecision(
413
- decision="denied", reason="No extra write paths were declared."
414
- )
415
-
416
- monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
417
-
418
- result = await run_tool_with_path_permissions(
419
- "shell_command",
420
- {"command": f"rm -f {outside / 'file.txt'}"},
421
- ToolContext(cwd=tmp_path / "work"),
422
- review_approval=deny,
423
- writable_paths=[],
424
- )
425
-
426
- assert not result.ok
427
- assert reviews[0].action == "sandbox_failure"
428
- assert reviews[0].write_paths == []
429
-
430
-
431
- @pytest.mark.anyio
432
- async def test_additional_permissions_require_matching_sandbox_permissions(
433
- tmp_path, monkeypatch
434
- ) -> None:
435
- cache_dir = tmp_path / "cache"
436
- calls = 0
437
- reviews = 0
438
-
439
- async def fake_run_async(self, command, **kwargs):
440
- nonlocal calls
441
- calls += 1
442
- return CommandResult(
443
- command=" ".join(command),
444
- exit_code=0,
445
- stderr="",
446
- stdout="created",
447
- )
448
-
449
- async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
450
- nonlocal reviews
451
- reviews += 1
452
- return ApprovalReviewDecision(decision="approved", reason="Allowed.")
453
-
454
- monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
455
-
456
- result = await run_tool_with_path_permissions(
457
- "shell_command",
458
- {
459
- "additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
460
- "command": f"touch {cache_dir / 'file.txt'}",
461
- },
462
- ToolContext(cwd=tmp_path / "work"),
463
- review_approval=approve,
464
- writable_paths=[],
465
- )
466
-
467
- assert not result.ok
468
- assert "with_additional_permissions" in result.content
469
- assert calls == 0
470
- assert reviews == 0
471
-
472
-
473
- @pytest.mark.anyio
474
- async def test_apply_patch_uses_reviewer_before_writing_outside_workdir_file(
475
- tmp_path, monkeypatch
476
- ) -> None:
477
- work_dir = tmp_path / "work"
478
- work_dir.mkdir()
479
- outside_dir = tmp_path / "outside"
480
- outside_dir.mkdir()
481
- target = outside_dir / "notes.txt"
482
- target.write_text("alpha\n")
483
- reviews: list[ApprovalReviewRequest] = []
484
-
485
- async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
486
- reviews.append(request)
487
- return ApprovalReviewDecision(
488
- decision="approved", reason="The edit matches the request."
489
- )
490
-
491
- patch = f"""*** Begin Patch
492
- *** Update File: {target}
493
- @@
494
- -alpha
495
- +beta
496
- *** End Patch
497
- """
498
-
499
- result = await run_tool_with_path_permissions(
500
- "apply_patch",
501
- {"patch": patch},
502
- ToolContext(cwd=work_dir),
503
- review_approval=approve,
504
- writable_paths=[],
505
- )
506
-
507
- assert result.ok
508
- assert reviews[0].tool_name == "apply_patch"
509
- assert reviews[0].action == "edit"
510
- assert reviews[0].write_paths == [outside_dir]
511
- assert result.data["approval"]["action"] == "edit"
512
- assert result.data["approval"]["decision"] == "approved"
513
- assert target.read_text() == "beta\n"
514
-
515
-
516
- @pytest.mark.anyio
517
- async def test_apply_patch_uses_existing_writable_path_without_request(
518
- tmp_path, monkeypatch
519
- ) -> None:
520
- work_dir = tmp_path / "work"
521
- work_dir.mkdir()
522
- outside_dir = tmp_path / "outside"
523
- outside_dir.mkdir()
524
- target = outside_dir / "notes.txt"
525
- target.write_text("alpha\n")
526
- requests = 0
527
-
528
- async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
529
- nonlocal requests
530
- requests += 1
531
- return ApprovalReviewDecision(decision="approved", reason="Already allowed.")
532
-
533
- patch = f"""*** Begin Patch
534
- *** Update File: {target}
535
- @@
536
- -alpha
537
- +beta
538
- *** End Patch
539
- """
540
-
541
- result = await run_tool_with_path_permissions(
542
- "apply_patch",
543
- {"patch": patch},
544
- ToolContext(cwd=work_dir),
545
- review_approval=approve,
546
- writable_paths=[outside_dir],
547
- )
548
-
549
- assert result.ok
550
- assert requests == 0
551
- assert target.read_text() == "beta\n"
552
-
553
-
554
- @pytest.mark.anyio
555
- async def test_denied_apply_patch_does_not_modify_file(tmp_path) -> None:
556
- work_dir = tmp_path / "work"
557
- work_dir.mkdir()
558
- outside_dir = tmp_path / "outside"
559
- outside_dir.mkdir()
560
- target = outside_dir / "notes.txt"
561
- target.write_text("alpha\n")
562
-
563
- async def deny(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
564
- return ApprovalReviewDecision(
565
- decision="denied", reason="The target is outside the allowed scope."
566
- )
567
-
568
- patch = f"""*** Begin Patch
569
- *** Update File: {target}
570
- @@
571
- -alpha
572
- +beta
573
- *** End Patch
574
- """
575
-
576
- result = await run_tool_with_path_permissions(
577
- "apply_patch",
578
- {"patch": patch},
579
- ToolContext(cwd=work_dir),
580
- review_approval=deny,
581
- writable_paths=[],
582
- )
583
-
584
- assert not result.ok
585
- assert "outside the allowed scope" in result.content
586
- assert result.data["approval"]["action"] == "edit"
587
- assert result.data["approval"]["decision"] == "denied"
588
- assert target.read_text() == "alpha\n"