flowent 0.1.4 → 0.2.0
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 +1 -1
- 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/agent.py +117 -34
- package/backend/src/flowent/approval.py +148 -0
- package/backend/src/flowent/cli.py +4 -2
- package/backend/src/flowent/context.py +19 -1
- package/backend/src/flowent/llm.py +176 -16
- package/backend/src/flowent/logging.py +60 -0
- package/backend/src/flowent/main.py +639 -210
- package/backend/src/flowent/patch.py +55 -31
- package/backend/src/flowent/permissions.py +185 -42
- package/backend/src/flowent/sandbox.py +55 -1
- package/backend/src/flowent/static/assets/index-BlaCigkZ.js +82 -0
- package/backend/src/flowent/static/assets/index-CRvbsH4K.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +113 -18
- 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 +39 -0
- package/backend/tests/test_agent_tools.py +213 -1
- package/backend/tests/test_approval.py +283 -0
- package/backend/tests/test_llm_providers.py +377 -0
- package/backend/tests/test_logging.py +30 -0
- package/backend/tests/test_patch.py +112 -0
- package/backend/tests/test_permissions.py +198 -53
- package/backend/tests/test_persistence.py +78 -0
- package/backend/tests/test_startup_requirements.py +54 -0
- package/backend/tests/test_workspace_chat.py +902 -36
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-BlaCigkZ.js +82 -0
- package/dist/frontend/assets/index-CRvbsH4K.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BREidonU.css +0 -2
- package/backend/src/flowent/static/assets/index-DSniOrhL.js +0 -81
- package/dist/frontend/assets/index-BREidonU.css +0 -2
- package/dist/frontend/assets/index-DSniOrhL.js +0 -81
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class PatchError(RuntimeError):
|
|
@@ -16,6 +17,12 @@ class PatchChange:
|
|
|
16
17
|
move_path: Path | None = None
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class PatchLine:
|
|
22
|
+
kind: Literal["context", "remove", "add"]
|
|
23
|
+
text: str
|
|
24
|
+
|
|
25
|
+
|
|
19
26
|
def affected_paths(patch: str, cwd: Path) -> list[Path]:
|
|
20
27
|
paths: list[Path] = []
|
|
21
28
|
for line in patch.splitlines():
|
|
@@ -73,24 +80,29 @@ def parse_patch(patch: str, cwd: Path) -> list[dict[str, object]]:
|
|
|
73
80
|
strict=False
|
|
74
81
|
)
|
|
75
82
|
index += 1
|
|
76
|
-
chunks: list[dict[str, list[
|
|
77
|
-
current: dict[str, list[
|
|
83
|
+
chunks: list[dict[str, list[PatchLine]]] = []
|
|
84
|
+
current: dict[str, list[PatchLine]] | None = None
|
|
78
85
|
while index < len(lines) - 1 and not lines[index].startswith("*** "):
|
|
79
86
|
content_line = lines[index]
|
|
80
87
|
if content_line.startswith("@@"):
|
|
81
|
-
current = {"
|
|
88
|
+
current = {"lines": []}
|
|
82
89
|
chunks.append(current)
|
|
83
90
|
elif content_line.startswith("-"):
|
|
84
91
|
if current is None:
|
|
85
|
-
current = {"
|
|
92
|
+
current = {"lines": []}
|
|
86
93
|
chunks.append(current)
|
|
87
|
-
current["
|
|
94
|
+
current["lines"].append(PatchLine("remove", content_line[1:]))
|
|
88
95
|
elif content_line.startswith("+"):
|
|
89
96
|
if current is None:
|
|
90
|
-
current = {"
|
|
97
|
+
current = {"lines": []}
|
|
91
98
|
chunks.append(current)
|
|
92
|
-
current["
|
|
93
|
-
elif content_line.startswith(" ")
|
|
99
|
+
current["lines"].append(PatchLine("add", content_line[1:]))
|
|
100
|
+
elif content_line.startswith(" "):
|
|
101
|
+
if current is None:
|
|
102
|
+
current = {"lines": []}
|
|
103
|
+
chunks.append(current)
|
|
104
|
+
current["lines"].append(PatchLine("context", content_line[1:]))
|
|
105
|
+
elif content_line == "*** End of File":
|
|
94
106
|
pass
|
|
95
107
|
else:
|
|
96
108
|
raise PatchError(
|
|
@@ -113,30 +125,42 @@ def parse_patch(patch: str, cwd: Path) -> list[dict[str, object]]:
|
|
|
113
125
|
return operations
|
|
114
126
|
|
|
115
127
|
|
|
116
|
-
def
|
|
117
|
-
|
|
128
|
+
def find_lines(haystack: list[str], needle: list[str], start: int) -> int:
|
|
129
|
+
if not needle:
|
|
130
|
+
return len(haystack)
|
|
131
|
+
last_start = len(haystack) - len(needle)
|
|
132
|
+
for index in range(start, last_start + 1):
|
|
133
|
+
if haystack[index : index + len(needle)] == needle:
|
|
134
|
+
return index
|
|
135
|
+
return -1
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def apply_update(original: str, chunks: list[dict[str, list[PatchLine]]]) -> str:
|
|
139
|
+
lines = original.splitlines()
|
|
140
|
+
trailing_newline = original.endswith("\n")
|
|
141
|
+
cursor = 0
|
|
118
142
|
for chunk in chunks:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return
|
|
143
|
+
patch_lines = chunk["lines"]
|
|
144
|
+
old_lines = [
|
|
145
|
+
line.text for line in patch_lines if line.kind in {"context", "remove"}
|
|
146
|
+
]
|
|
147
|
+
new_lines = [
|
|
148
|
+
line.text for line in patch_lines if line.kind in {"context", "add"}
|
|
149
|
+
]
|
|
150
|
+
if not old_lines:
|
|
151
|
+
lines.extend(new_lines)
|
|
152
|
+
cursor = len(lines)
|
|
153
|
+
if new_lines:
|
|
154
|
+
trailing_newline = True
|
|
155
|
+
continue
|
|
156
|
+
match_index = find_lines(lines, old_lines, cursor)
|
|
157
|
+
if match_index == -1:
|
|
158
|
+
raise PatchError("Patch context was not found.")
|
|
159
|
+
lines[match_index : match_index + len(old_lines)] = new_lines
|
|
160
|
+
cursor = match_index + len(new_lines)
|
|
161
|
+
if not lines:
|
|
162
|
+
return ""
|
|
163
|
+
return "\n".join(lines) + ("\n" if trailing_newline else "")
|
|
140
164
|
|
|
141
165
|
|
|
142
166
|
def apply_patch(patch: str, cwd: Path) -> dict[str, object]:
|
|
@@ -2,12 +2,14 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
5
|
-
from collections.abc import Awaitable, Callable
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
from typing import Literal
|
|
8
7
|
|
|
9
|
-
from
|
|
10
|
-
|
|
8
|
+
from flowent.approval import (
|
|
9
|
+
ApprovalReviewDecision,
|
|
10
|
+
ApprovalReviewer,
|
|
11
|
+
ApprovalReviewRequest,
|
|
12
|
+
)
|
|
11
13
|
from flowent.patch import affected_paths
|
|
12
14
|
from flowent.sandbox import SandboxError, SandboxRunner, path_is_within
|
|
13
15
|
from flowent.tools import (
|
|
@@ -22,16 +24,6 @@ from flowent.tools import (
|
|
|
22
24
|
SANDBOX_WITH_ADDITIONAL_PERMISSIONS = "with_additional_permissions"
|
|
23
25
|
|
|
24
26
|
|
|
25
|
-
class WritablePathDecision(BaseModel):
|
|
26
|
-
model_config = ConfigDict(extra="forbid")
|
|
27
|
-
|
|
28
|
-
decision: Literal["allow_once", "always_allow", "deny"]
|
|
29
|
-
path: Path
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
WritablePathRequest = Callable[[Path, str], Awaitable[WritablePathDecision]]
|
|
33
|
-
|
|
34
|
-
|
|
35
27
|
def normalize_path(path: Path | str, cwd: Path) -> Path:
|
|
36
28
|
resolved = Path(path).expanduser()
|
|
37
29
|
if not resolved.is_absolute():
|
|
@@ -83,6 +75,15 @@ def validate_additional_permissions(arguments: dict[str, object]) -> ToolResult
|
|
|
83
75
|
return None
|
|
84
76
|
|
|
85
77
|
|
|
78
|
+
def approval_denial_content(decision: ApprovalReviewDecision) -> str:
|
|
79
|
+
return (
|
|
80
|
+
"Automatic approval review denied this action as high risk: "
|
|
81
|
+
f"{decision.reason} The agent must not work around this denial; choose a "
|
|
82
|
+
"safer alternative or ask the user for explicit approval after explaining "
|
|
83
|
+
"the concrete risk."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
86
87
|
def approved_writable_roots(
|
|
87
88
|
context: ToolContext, writable_paths: list[Path]
|
|
88
89
|
) -> list[Path]:
|
|
@@ -94,33 +95,51 @@ def approved_writable_roots(
|
|
|
94
95
|
return roots
|
|
95
96
|
|
|
96
97
|
|
|
97
|
-
async def
|
|
98
|
+
async def review_missing_write_paths(
|
|
98
99
|
paths: list[Path],
|
|
99
100
|
context: ToolContext,
|
|
100
101
|
*,
|
|
101
|
-
|
|
102
|
+
arguments: dict[str, object],
|
|
103
|
+
action: Literal["additional_permissions", "edit"],
|
|
104
|
+
review_approval: ApprovalReviewer,
|
|
105
|
+
tool_name: str,
|
|
102
106
|
writable_paths: list[Path],
|
|
103
|
-
|
|
104
|
-
) -> tuple[list[Path], ToolResult | None]:
|
|
107
|
+
) -> tuple[list[Path], ToolResult | None, dict[str, object] | None]:
|
|
105
108
|
effective_paths = [
|
|
106
109
|
path.expanduser().resolve(strict=False) for path in writable_paths
|
|
107
110
|
]
|
|
108
111
|
approved_roots = approved_writable_roots(context, effective_paths)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
missing_paths = [
|
|
113
|
+
path.expanduser().resolve(strict=False)
|
|
114
|
+
for path in paths
|
|
115
|
+
if not path_is_within(path, approved_roots)
|
|
116
|
+
]
|
|
117
|
+
if not missing_paths:
|
|
118
|
+
return effective_paths, None, None
|
|
119
|
+
review_request = ApprovalReviewRequest(
|
|
120
|
+
action=action,
|
|
121
|
+
arguments=arguments,
|
|
122
|
+
cwd=context.cwd,
|
|
123
|
+
tool_name=tool_name,
|
|
124
|
+
write_paths=missing_paths,
|
|
125
|
+
)
|
|
126
|
+
decision = await review_approval(review_request)
|
|
127
|
+
review_data = approval_result_data(review_request, decision)
|
|
128
|
+
if decision.decision == "denied":
|
|
129
|
+
return (
|
|
130
|
+
effective_paths,
|
|
131
|
+
ToolResult(
|
|
132
|
+
content=approval_denial_content(decision),
|
|
133
|
+
data=review_data,
|
|
117
134
|
ok=False,
|
|
118
|
-
title=
|
|
119
|
-
)
|
|
120
|
-
|
|
135
|
+
title="Denied by reviewer",
|
|
136
|
+
),
|
|
137
|
+
review_data,
|
|
138
|
+
)
|
|
139
|
+
for approved_path in missing_paths:
|
|
121
140
|
effective_paths.append(approved_path)
|
|
122
141
|
approved_roots.append(approved_path)
|
|
123
|
-
return effective_paths, None
|
|
142
|
+
return effective_paths, None, review_data
|
|
124
143
|
|
|
125
144
|
|
|
126
145
|
async def run_tool_with_path_permissions(
|
|
@@ -128,21 +147,21 @@ async def run_tool_with_path_permissions(
|
|
|
128
147
|
arguments: dict[str, object],
|
|
129
148
|
context: ToolContext,
|
|
130
149
|
*,
|
|
131
|
-
|
|
150
|
+
review_approval: ApprovalReviewer,
|
|
132
151
|
writable_paths: list[Path],
|
|
133
152
|
) -> ToolResult:
|
|
134
153
|
if name == "shell_command":
|
|
135
154
|
return await run_shell_command_with_permissions(
|
|
136
155
|
arguments,
|
|
137
156
|
context,
|
|
138
|
-
|
|
157
|
+
review_approval=review_approval,
|
|
139
158
|
writable_paths=writable_paths,
|
|
140
159
|
)
|
|
141
160
|
if name == "apply_patch":
|
|
142
161
|
return await run_apply_patch_with_permissions(
|
|
143
162
|
arguments,
|
|
144
163
|
context,
|
|
145
|
-
|
|
164
|
+
review_approval=review_approval,
|
|
146
165
|
writable_paths=writable_paths,
|
|
147
166
|
)
|
|
148
167
|
return await run_tool_async(name, arguments, context)
|
|
@@ -152,7 +171,7 @@ async def run_shell_command_with_permissions(
|
|
|
152
171
|
arguments: dict[str, object],
|
|
153
172
|
context: ToolContext,
|
|
154
173
|
*,
|
|
155
|
-
|
|
174
|
+
review_approval: ApprovalReviewer,
|
|
156
175
|
writable_paths: list[Path],
|
|
157
176
|
) -> ToolResult:
|
|
158
177
|
validation_error = validate_additional_permissions(arguments)
|
|
@@ -160,41 +179,72 @@ async def run_shell_command_with_permissions(
|
|
|
160
179
|
return validation_error
|
|
161
180
|
|
|
162
181
|
declared_paths = additional_write_paths(arguments, context.cwd)
|
|
163
|
-
effective_paths, denied = await
|
|
182
|
+
effective_paths, denied, approval_data = await review_missing_write_paths(
|
|
164
183
|
declared_paths,
|
|
165
184
|
context,
|
|
166
|
-
|
|
185
|
+
action="additional_permissions",
|
|
186
|
+
arguments=arguments,
|
|
187
|
+
review_approval=review_approval,
|
|
188
|
+
tool_name="shell_command",
|
|
167
189
|
writable_paths=writable_paths,
|
|
168
|
-
reason="The shell command needs to write this path.",
|
|
169
190
|
)
|
|
170
191
|
if denied is not None:
|
|
171
192
|
return denied
|
|
172
193
|
|
|
173
|
-
|
|
194
|
+
result = await shell_command_with_writable_paths(
|
|
195
|
+
arguments, context, effective_paths
|
|
196
|
+
)
|
|
197
|
+
if approval_data is not None:
|
|
198
|
+
result = tool_result_with_data(result, approval_data)
|
|
199
|
+
if result.ok or not is_likely_sandbox_denied_result(result):
|
|
200
|
+
return result
|
|
201
|
+
review_request = ApprovalReviewRequest(
|
|
202
|
+
action="sandbox_failure",
|
|
203
|
+
arguments=arguments,
|
|
204
|
+
cwd=context.cwd,
|
|
205
|
+
tool_name="shell_command",
|
|
206
|
+
tool_result=tool_failure_text(result),
|
|
207
|
+
)
|
|
208
|
+
decision = await review_approval(review_request)
|
|
209
|
+
review_data = approval_result_data(review_request, decision)
|
|
210
|
+
if decision.decision == "denied":
|
|
211
|
+
return ToolResult(
|
|
212
|
+
content=approval_denial_content(decision),
|
|
213
|
+
data={**result.data, **review_data},
|
|
214
|
+
ok=False,
|
|
215
|
+
title="Denied by reviewer",
|
|
216
|
+
)
|
|
217
|
+
retry_result = await shell_command_without_sandbox(arguments, context)
|
|
218
|
+
return tool_result_with_data(retry_result, review_data)
|
|
174
219
|
|
|
175
220
|
|
|
176
221
|
async def run_apply_patch_with_permissions(
|
|
177
222
|
arguments: dict[str, object],
|
|
178
223
|
context: ToolContext,
|
|
179
224
|
*,
|
|
180
|
-
|
|
225
|
+
review_approval: ApprovalReviewer,
|
|
181
226
|
writable_paths: list[Path],
|
|
182
227
|
) -> ToolResult:
|
|
183
228
|
patch = str(arguments["patch"])
|
|
184
229
|
paths = [
|
|
185
230
|
writable_root_for_path(path) for path in affected_paths(patch, context.cwd)
|
|
186
231
|
]
|
|
187
|
-
effective_paths, denied = await
|
|
232
|
+
effective_paths, denied, approval_data = await review_missing_write_paths(
|
|
188
233
|
paths,
|
|
189
234
|
context,
|
|
190
|
-
|
|
235
|
+
action="edit",
|
|
236
|
+
arguments=arguments,
|
|
237
|
+
review_approval=review_approval,
|
|
238
|
+
tool_name="apply_patch",
|
|
191
239
|
writable_paths=writable_paths,
|
|
192
|
-
reason="The edit needs to write this path.",
|
|
193
240
|
)
|
|
194
241
|
if denied is not None:
|
|
195
242
|
return denied
|
|
196
243
|
|
|
197
|
-
|
|
244
|
+
result = await apply_patch_with_writable_paths(arguments, context, effective_paths)
|
|
245
|
+
if approval_data is not None:
|
|
246
|
+
result = tool_result_with_data(result, approval_data)
|
|
247
|
+
return result
|
|
198
248
|
|
|
199
249
|
|
|
200
250
|
async def apply_patch_with_writable_paths(
|
|
@@ -257,3 +307,96 @@ async def shell_command_with_writable_paths(
|
|
|
257
307
|
ok=ok,
|
|
258
308
|
title=f"Ran {command}",
|
|
259
309
|
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def is_likely_sandbox_denied_result(result: ToolResult) -> bool:
|
|
313
|
+
data = result.data
|
|
314
|
+
exit_code = int_result_field(data.get("exit_code"))
|
|
315
|
+
if exit_code == 0:
|
|
316
|
+
return False
|
|
317
|
+
output = "\n".join(
|
|
318
|
+
str(data.get(name, "") or "") for name in ["stderr", "stdout"]
|
|
319
|
+
).lower()
|
|
320
|
+
return any(
|
|
321
|
+
keyword in output
|
|
322
|
+
for keyword in [
|
|
323
|
+
"operation not permitted",
|
|
324
|
+
"permission denied",
|
|
325
|
+
"read-only file system",
|
|
326
|
+
"seccomp",
|
|
327
|
+
"sandbox",
|
|
328
|
+
"landlock",
|
|
329
|
+
"failed to write file",
|
|
330
|
+
]
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def int_result_field(value: object) -> int:
|
|
335
|
+
if isinstance(value, int):
|
|
336
|
+
return value
|
|
337
|
+
if isinstance(value, str):
|
|
338
|
+
try:
|
|
339
|
+
return int(value)
|
|
340
|
+
except ValueError:
|
|
341
|
+
return 0
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def tool_failure_text(result: ToolResult) -> str:
|
|
346
|
+
stderr = str(result.data.get("stderr", "") or "").strip()
|
|
347
|
+
stdout = str(result.data.get("stdout", "") or "").strip()
|
|
348
|
+
content = result.content.strip()
|
|
349
|
+
return "\n".join(part for part in [stderr, stdout, content] if part)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def shell_command_without_sandbox(
|
|
353
|
+
arguments: dict[str, object],
|
|
354
|
+
context: ToolContext,
|
|
355
|
+
) -> ToolResult:
|
|
356
|
+
command = str(arguments["command"])
|
|
357
|
+
timeout_seconds = number_argument(arguments, "timeout_seconds", 30)
|
|
358
|
+
result = await SandboxRunner(cwd=context.cwd).run_unsandboxed_async(
|
|
359
|
+
["/bin/sh", "-c", command], timeout_seconds=timeout_seconds
|
|
360
|
+
)
|
|
361
|
+
ok = result.exit_code == 0
|
|
362
|
+
content = result.stdout or result.stderr
|
|
363
|
+
return ToolResult(
|
|
364
|
+
content=content,
|
|
365
|
+
data={
|
|
366
|
+
"command": command,
|
|
367
|
+
"exit_code": result.exit_code,
|
|
368
|
+
"stderr": result.stderr,
|
|
369
|
+
"stdout": result.stdout,
|
|
370
|
+
},
|
|
371
|
+
ok=ok,
|
|
372
|
+
title=f"Ran {command}",
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def approval_result_data(
|
|
377
|
+
request: ApprovalReviewRequest,
|
|
378
|
+
decision: ApprovalReviewDecision,
|
|
379
|
+
) -> dict[str, object]:
|
|
380
|
+
approval: dict[str, object] = {
|
|
381
|
+
"action": request.action,
|
|
382
|
+
"decision": decision.decision,
|
|
383
|
+
"reason": decision.reason,
|
|
384
|
+
"tool_name": request.tool_name,
|
|
385
|
+
"tool_result": request.tool_result,
|
|
386
|
+
"write_paths": [str(path) for path in request.write_paths],
|
|
387
|
+
}
|
|
388
|
+
if decision.risk_level is not None:
|
|
389
|
+
approval["risk_level"] = decision.risk_level
|
|
390
|
+
if decision.risk_score is not None:
|
|
391
|
+
approval["risk_score"] = decision.risk_score
|
|
392
|
+
if decision.evidence:
|
|
393
|
+
approval["evidence"] = [item.model_dump() for item in decision.evidence]
|
|
394
|
+
return {
|
|
395
|
+
"approval": approval,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def tool_result_with_data(
|
|
400
|
+
result: ToolResult, extra_data: dict[str, object]
|
|
401
|
+
) -> ToolResult:
|
|
402
|
+
return result.model_copy(update={"data": {**result.data, **extra_data}})
|
|
@@ -261,7 +261,8 @@ class SandboxRunner:
|
|
|
261
261
|
for root in self.writable_roots:
|
|
262
262
|
if root == self.cwd:
|
|
263
263
|
continue
|
|
264
|
-
root.
|
|
264
|
+
if not root.exists():
|
|
265
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
265
266
|
args.extend(["--bind", str(root), str(root)])
|
|
266
267
|
args.extend(
|
|
267
268
|
[
|
|
@@ -327,6 +328,59 @@ class SandboxRunner:
|
|
|
327
328
|
stdout=completed.stdout[: self.output_limit],
|
|
328
329
|
)
|
|
329
330
|
|
|
331
|
+
async def run_unsandboxed_async(
|
|
332
|
+
self,
|
|
333
|
+
command: list[str],
|
|
334
|
+
*,
|
|
335
|
+
env: dict[str, str] | None = None,
|
|
336
|
+
input_text: str | None = None,
|
|
337
|
+
timeout_seconds: float | None = None,
|
|
338
|
+
) -> CommandResult:
|
|
339
|
+
process_env = build_shell_environment(env)
|
|
340
|
+
process = await asyncio.create_subprocess_exec(
|
|
341
|
+
*command,
|
|
342
|
+
cwd=self.cwd,
|
|
343
|
+
env=process_env,
|
|
344
|
+
start_new_session=True,
|
|
345
|
+
stdin=asyncio.subprocess.PIPE if input_text is not None else None,
|
|
346
|
+
stdout=asyncio.subprocess.PIPE,
|
|
347
|
+
stderr=asyncio.subprocess.PIPE,
|
|
348
|
+
)
|
|
349
|
+
try:
|
|
350
|
+
stdout, stderr = await asyncio.wait_for(
|
|
351
|
+
process.communicate(
|
|
352
|
+
input_text.encode() if input_text is not None else None
|
|
353
|
+
),
|
|
354
|
+
timeout=timeout_seconds or self.timeout_seconds,
|
|
355
|
+
)
|
|
356
|
+
except TimeoutError as error:
|
|
357
|
+
with suppress(ProcessLookupError):
|
|
358
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
359
|
+
stdout, stderr = await process.communicate()
|
|
360
|
+
return CommandResult(
|
|
361
|
+
command=" ".join(command),
|
|
362
|
+
exit_code=124,
|
|
363
|
+
stderr=str(error) or "Command timed out.",
|
|
364
|
+
stdout=self._text_output(stdout)[: self.output_limit],
|
|
365
|
+
)
|
|
366
|
+
except asyncio.CancelledError:
|
|
367
|
+
with suppress(ProcessLookupError):
|
|
368
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
369
|
+
try:
|
|
370
|
+
await asyncio.wait_for(process.wait(), timeout=1)
|
|
371
|
+
except TimeoutError:
|
|
372
|
+
with suppress(ProcessLookupError):
|
|
373
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
374
|
+
await process.wait()
|
|
375
|
+
raise
|
|
376
|
+
|
|
377
|
+
return CommandResult(
|
|
378
|
+
command=" ".join(command),
|
|
379
|
+
exit_code=process.returncode or 0,
|
|
380
|
+
stderr=self._text_output(stderr)[: self.output_limit],
|
|
381
|
+
stdout=self._text_output(stdout)[: self.output_limit],
|
|
382
|
+
)
|
|
383
|
+
|
|
330
384
|
async def run_async(
|
|
331
385
|
self,
|
|
332
386
|
command: list[str],
|