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.
- package/backend/pyproject.toml +31 -5
- package/backend/src/flowent/agent.py +13 -4
- package/backend/src/flowent/compact.py +35 -14
- package/backend/src/flowent/llm.py +73 -7
- package/backend/src/flowent/main.py +260 -59
- package/backend/src/flowent/static/assets/index-CRSV2xu1.css +2 -0
- package/backend/src/flowent/static/assets/index-DUYj6rgD.js +82 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +135 -3
- package/backend/src/flowent/usage.py +315 -0
- package/backend/uv.lock +971 -3
- package/dist/frontend/assets/index-CRSV2xu1.css +2 -0
- package/dist/frontend/assets/index-DUYj6rgD.js +82 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +24 -3
- package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
- package/backend/src/flowent/static/assets/index-BlaCigkZ.js +0 -82
- package/backend/src/flowent/static/assets/index-CRvbsH4K.css +0 -2
- package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/conftest.py +0 -60
- package/backend/tests/test_agent_tools.py +0 -1124
- package/backend/tests/test_approval.py +0 -283
- package/backend/tests/test_channels.py +0 -360
- package/backend/tests/test_health.py +0 -12
- package/backend/tests/test_llm_providers.py +0 -548
- package/backend/tests/test_logging.py +0 -212
- package/backend/tests/test_mcp.py +0 -788
- package/backend/tests/test_patch.py +0 -112
- package/backend/tests/test_permissions.py +0 -588
- package/backend/tests/test_persistence.py +0 -249
- package/backend/tests/test_skills.py +0 -462
- package/backend/tests/test_startup_requirements.py +0 -144
- package/backend/tests/test_workspace_chat.py +0 -2174
- package/dist/frontend/assets/index-BlaCigkZ.js +0 -82
- 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"
|