flowent 0.1.1 → 0.1.3
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__/channels.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.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 +19 -9
- package/backend/src/flowent/main.py +356 -62
- package/backend/src/flowent/permissions.py +259 -0
- package/backend/src/flowent/sandbox.py +83 -6
- package/backend/src/flowent/static/assets/index-DjF2KBwE.js +81 -0
- package/backend/src/flowent/static/assets/index-P-bBpJG8.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +115 -3
- package/backend/src/flowent/tools.py +96 -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_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_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/test_agent_tools.py +103 -2
- package/backend/tests/test_permissions.py +443 -0
- package/backend/tests/test_workspace_chat.py +396 -1
- package/backend/uv.lock +1 -1
- package/bin/flowent.mjs +1 -1
- package/dist/frontend/assets/index-DjF2KBwE.js +81 -0
- package/dist/frontend/assets/index-P-bBpJG8.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BhHdc2d_.js +0 -81
- package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
- package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
- package/dist/frontend/assets/index-C89n9qe2.css +0 -2
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
from flowent.patch import affected_paths
|
|
12
|
+
from flowent.sandbox import SandboxError, SandboxRunner, path_is_within
|
|
13
|
+
from flowent.tools import (
|
|
14
|
+
ToolContext,
|
|
15
|
+
ToolResult,
|
|
16
|
+
number_argument,
|
|
17
|
+
patch_title_from_result,
|
|
18
|
+
run_tool_async,
|
|
19
|
+
tool_failure_content,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
SANDBOX_WITH_ADDITIONAL_PERMISSIONS = "with_additional_permissions"
|
|
23
|
+
|
|
24
|
+
|
|
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
|
+
def normalize_path(path: Path | str, cwd: Path) -> Path:
|
|
36
|
+
resolved = Path(path).expanduser()
|
|
37
|
+
if not resolved.is_absolute():
|
|
38
|
+
resolved = cwd / resolved
|
|
39
|
+
return resolved.resolve(strict=False)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def writable_root_for_path(path: Path) -> Path:
|
|
43
|
+
if path.exists() and path.is_dir():
|
|
44
|
+
return path.resolve(strict=False)
|
|
45
|
+
if path.suffix:
|
|
46
|
+
return path.parent.resolve(strict=False)
|
|
47
|
+
return path.resolve(strict=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def additional_write_paths(arguments: dict[str, object], cwd: Path) -> list[Path]:
|
|
51
|
+
additional_permissions = arguments.get("additional_permissions")
|
|
52
|
+
if not isinstance(additional_permissions, dict):
|
|
53
|
+
return []
|
|
54
|
+
file_system = additional_permissions.get("file_system")
|
|
55
|
+
if not isinstance(file_system, dict):
|
|
56
|
+
return []
|
|
57
|
+
write_paths = file_system.get("write")
|
|
58
|
+
if not isinstance(write_paths, list):
|
|
59
|
+
return []
|
|
60
|
+
paths: list[Path] = []
|
|
61
|
+
for raw_path in write_paths:
|
|
62
|
+
if not isinstance(raw_path, str) or not raw_path.strip():
|
|
63
|
+
continue
|
|
64
|
+
path = normalize_path(raw_path, cwd)
|
|
65
|
+
paths.append(path.parent.resolve(strict=False) if path.is_file() else path)
|
|
66
|
+
return paths
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_additional_permissions(arguments: dict[str, object]) -> ToolResult | None:
|
|
70
|
+
sandbox_permissions = arguments.get("sandbox_permissions")
|
|
71
|
+
has_additional_permissions = arguments.get("additional_permissions") is not None
|
|
72
|
+
if sandbox_permissions is None and not has_additional_permissions:
|
|
73
|
+
return None
|
|
74
|
+
if sandbox_permissions != SANDBOX_WITH_ADDITIONAL_PERMISSIONS:
|
|
75
|
+
return ToolResult(
|
|
76
|
+
content=(
|
|
77
|
+
"additional_permissions requires sandbox_permissions to be "
|
|
78
|
+
"with_additional_permissions."
|
|
79
|
+
),
|
|
80
|
+
ok=False,
|
|
81
|
+
title="Permission request failed",
|
|
82
|
+
)
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def approved_writable_roots(
|
|
87
|
+
context: ToolContext, writable_paths: list[Path]
|
|
88
|
+
) -> list[Path]:
|
|
89
|
+
roots = [context.cwd.resolve(strict=False)]
|
|
90
|
+
for path in writable_paths:
|
|
91
|
+
resolved = path.expanduser().resolve(strict=False)
|
|
92
|
+
if not any(resolved == existing for existing in roots):
|
|
93
|
+
roots.append(resolved)
|
|
94
|
+
return roots
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def request_missing_write_paths(
|
|
98
|
+
paths: list[Path],
|
|
99
|
+
context: ToolContext,
|
|
100
|
+
*,
|
|
101
|
+
request_writable_path: WritablePathRequest,
|
|
102
|
+
writable_paths: list[Path],
|
|
103
|
+
reason: str,
|
|
104
|
+
) -> tuple[list[Path], ToolResult | None]:
|
|
105
|
+
effective_paths = [
|
|
106
|
+
path.expanduser().resolve(strict=False) for path in writable_paths
|
|
107
|
+
]
|
|
108
|
+
approved_roots = approved_writable_roots(context, effective_paths)
|
|
109
|
+
for path in paths:
|
|
110
|
+
if path_is_within(path, approved_roots):
|
|
111
|
+
continue
|
|
112
|
+
decision = await request_writable_path(path, reason)
|
|
113
|
+
if decision.decision == "deny":
|
|
114
|
+
return effective_paths, ToolResult(
|
|
115
|
+
content=f"Permission denied for {decision.path}",
|
|
116
|
+
data={"path": str(decision.path)},
|
|
117
|
+
ok=False,
|
|
118
|
+
title=f"Denied {decision.path}",
|
|
119
|
+
)
|
|
120
|
+
approved_path = decision.path.expanduser().resolve(strict=False)
|
|
121
|
+
effective_paths.append(approved_path)
|
|
122
|
+
approved_roots.append(approved_path)
|
|
123
|
+
return effective_paths, None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def run_tool_with_path_permissions(
|
|
127
|
+
name: str,
|
|
128
|
+
arguments: dict[str, object],
|
|
129
|
+
context: ToolContext,
|
|
130
|
+
*,
|
|
131
|
+
request_writable_path: WritablePathRequest,
|
|
132
|
+
writable_paths: list[Path],
|
|
133
|
+
) -> ToolResult:
|
|
134
|
+
if name == "shell_command":
|
|
135
|
+
return await run_shell_command_with_permissions(
|
|
136
|
+
arguments,
|
|
137
|
+
context,
|
|
138
|
+
request_writable_path=request_writable_path,
|
|
139
|
+
writable_paths=writable_paths,
|
|
140
|
+
)
|
|
141
|
+
if name == "apply_patch":
|
|
142
|
+
return await run_apply_patch_with_permissions(
|
|
143
|
+
arguments,
|
|
144
|
+
context,
|
|
145
|
+
request_writable_path=request_writable_path,
|
|
146
|
+
writable_paths=writable_paths,
|
|
147
|
+
)
|
|
148
|
+
return await run_tool_async(name, arguments, context)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def run_shell_command_with_permissions(
|
|
152
|
+
arguments: dict[str, object],
|
|
153
|
+
context: ToolContext,
|
|
154
|
+
*,
|
|
155
|
+
request_writable_path: WritablePathRequest,
|
|
156
|
+
writable_paths: list[Path],
|
|
157
|
+
) -> ToolResult:
|
|
158
|
+
validation_error = validate_additional_permissions(arguments)
|
|
159
|
+
if validation_error is not None:
|
|
160
|
+
return validation_error
|
|
161
|
+
|
|
162
|
+
declared_paths = additional_write_paths(arguments, context.cwd)
|
|
163
|
+
effective_paths, denied = await request_missing_write_paths(
|
|
164
|
+
declared_paths,
|
|
165
|
+
context,
|
|
166
|
+
request_writable_path=request_writable_path,
|
|
167
|
+
writable_paths=writable_paths,
|
|
168
|
+
reason="The shell command needs to write this path.",
|
|
169
|
+
)
|
|
170
|
+
if denied is not None:
|
|
171
|
+
return denied
|
|
172
|
+
|
|
173
|
+
return await shell_command_with_writable_paths(arguments, context, effective_paths)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def run_apply_patch_with_permissions(
|
|
177
|
+
arguments: dict[str, object],
|
|
178
|
+
context: ToolContext,
|
|
179
|
+
*,
|
|
180
|
+
request_writable_path: WritablePathRequest,
|
|
181
|
+
writable_paths: list[Path],
|
|
182
|
+
) -> ToolResult:
|
|
183
|
+
patch = str(arguments["patch"])
|
|
184
|
+
paths = [
|
|
185
|
+
writable_root_for_path(path) for path in affected_paths(patch, context.cwd)
|
|
186
|
+
]
|
|
187
|
+
effective_paths, denied = await request_missing_write_paths(
|
|
188
|
+
paths,
|
|
189
|
+
context,
|
|
190
|
+
request_writable_path=request_writable_path,
|
|
191
|
+
writable_paths=writable_paths,
|
|
192
|
+
reason="The edit needs to write this path.",
|
|
193
|
+
)
|
|
194
|
+
if denied is not None:
|
|
195
|
+
return denied
|
|
196
|
+
|
|
197
|
+
return await apply_patch_with_writable_paths(arguments, context, effective_paths)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def apply_patch_with_writable_paths(
|
|
201
|
+
arguments: dict[str, object],
|
|
202
|
+
context: ToolContext,
|
|
203
|
+
writable_paths: list[Path],
|
|
204
|
+
) -> ToolResult:
|
|
205
|
+
patch = str(arguments["patch"])
|
|
206
|
+
runner = SandboxRunner(cwd=context.cwd, writable_roots=writable_paths)
|
|
207
|
+
try:
|
|
208
|
+
result = await runner.run_async(
|
|
209
|
+
[
|
|
210
|
+
sys.executable,
|
|
211
|
+
"-m",
|
|
212
|
+
"flowent.cli",
|
|
213
|
+
"apply-patch",
|
|
214
|
+
"--cwd",
|
|
215
|
+
str(context.cwd),
|
|
216
|
+
],
|
|
217
|
+
input_text=patch,
|
|
218
|
+
)
|
|
219
|
+
except SandboxError as error:
|
|
220
|
+
return ToolResult(content=str(error), ok=False, title="Edit failed")
|
|
221
|
+
|
|
222
|
+
if result.exit_code != 0:
|
|
223
|
+
return ToolResult(
|
|
224
|
+
content=tool_failure_content(result),
|
|
225
|
+
ok=False,
|
|
226
|
+
title="Edit failed",
|
|
227
|
+
)
|
|
228
|
+
data = json.loads(result.stdout or "{}")
|
|
229
|
+
return ToolResult(
|
|
230
|
+
content=result.stdout,
|
|
231
|
+
data=data if isinstance(data, dict) else {},
|
|
232
|
+
title=patch_title_from_result(data),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def shell_command_with_writable_paths(
|
|
237
|
+
arguments: dict[str, object],
|
|
238
|
+
context: ToolContext,
|
|
239
|
+
writable_paths: list[Path],
|
|
240
|
+
) -> ToolResult:
|
|
241
|
+
command = str(arguments["command"])
|
|
242
|
+
timeout_seconds = number_argument(arguments, "timeout_seconds", 30)
|
|
243
|
+
result = await SandboxRunner(
|
|
244
|
+
cwd=context.cwd,
|
|
245
|
+
writable_roots=writable_paths,
|
|
246
|
+
).run_async(["/bin/sh", "-c", command], timeout_seconds=timeout_seconds)
|
|
247
|
+
ok = result.exit_code == 0
|
|
248
|
+
content = result.stdout or result.stderr
|
|
249
|
+
return ToolResult(
|
|
250
|
+
content=content,
|
|
251
|
+
data={
|
|
252
|
+
"command": command,
|
|
253
|
+
"exit_code": result.exit_code,
|
|
254
|
+
"stderr": result.stderr,
|
|
255
|
+
"stdout": result.stdout,
|
|
256
|
+
},
|
|
257
|
+
ok=ok,
|
|
258
|
+
title=f"Ran {command}",
|
|
259
|
+
)
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import ctypes
|
|
4
5
|
import errno
|
|
5
6
|
import os
|
|
6
7
|
import shutil
|
|
8
|
+
import signal
|
|
7
9
|
import subprocess
|
|
8
10
|
import tempfile
|
|
11
|
+
from contextlib import suppress
|
|
9
12
|
from dataclasses import dataclass
|
|
10
13
|
from pathlib import Path
|
|
11
14
|
from typing import BinaryIO
|
|
@@ -127,16 +130,24 @@ class SandboxRunner:
|
|
|
127
130
|
self,
|
|
128
131
|
*,
|
|
129
132
|
cwd: Path | None = None,
|
|
130
|
-
timeout_seconds:
|
|
133
|
+
timeout_seconds: float = 30,
|
|
131
134
|
output_limit: int = 20000,
|
|
135
|
+
writable_roots: list[Path] | None = None,
|
|
132
136
|
) -> None:
|
|
133
137
|
self.cwd = (cwd or Path.cwd()).resolve(strict=False)
|
|
134
138
|
self.timeout_seconds = timeout_seconds
|
|
135
139
|
self.output_limit = output_limit
|
|
140
|
+
self.extra_writable_roots = [
|
|
141
|
+
root.expanduser().resolve(strict=False) for root in (writable_roots or [])
|
|
142
|
+
]
|
|
136
143
|
|
|
137
144
|
@property
|
|
138
145
|
def writable_roots(self) -> list[Path]:
|
|
139
|
-
|
|
146
|
+
roots: list[Path] = [self.cwd, Path("/tmp")]
|
|
147
|
+
for root in self.extra_writable_roots:
|
|
148
|
+
if not any(root == existing for existing in roots):
|
|
149
|
+
roots.append(root)
|
|
150
|
+
return roots
|
|
140
151
|
|
|
141
152
|
def ensure_writable_path(self, path: Path) -> None:
|
|
142
153
|
if not path_is_within(path, self.writable_roots):
|
|
@@ -159,10 +170,12 @@ class SandboxRunner:
|
|
|
159
170
|
"--bind",
|
|
160
171
|
str(self.cwd),
|
|
161
172
|
str(self.cwd),
|
|
162
|
-
"--bind",
|
|
163
|
-
"/tmp",
|
|
164
|
-
"/tmp",
|
|
165
173
|
]
|
|
174
|
+
for root in self.writable_roots:
|
|
175
|
+
if root == self.cwd:
|
|
176
|
+
continue
|
|
177
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
178
|
+
args.extend(["--bind", str(root), str(root)])
|
|
166
179
|
for protected in [".git", ".codex", ".agents"]:
|
|
167
180
|
path = self.cwd / protected
|
|
168
181
|
if path.exists():
|
|
@@ -193,7 +206,7 @@ class SandboxRunner:
|
|
|
193
206
|
*,
|
|
194
207
|
env: dict[str, str] | None = None,
|
|
195
208
|
input_text: str | None = None,
|
|
196
|
-
timeout_seconds:
|
|
209
|
+
timeout_seconds: float | None = None,
|
|
197
210
|
) -> CommandResult:
|
|
198
211
|
sandbox_command = self.build_command(command)
|
|
199
212
|
pass_fds: tuple[int, ...] = ()
|
|
@@ -232,3 +245,67 @@ class SandboxRunner:
|
|
|
232
245
|
stderr=completed.stderr[: self.output_limit],
|
|
233
246
|
stdout=completed.stdout[: self.output_limit],
|
|
234
247
|
)
|
|
248
|
+
|
|
249
|
+
async def run_async(
|
|
250
|
+
self,
|
|
251
|
+
command: list[str],
|
|
252
|
+
*,
|
|
253
|
+
env: dict[str, str] | None = None,
|
|
254
|
+
input_text: str | None = None,
|
|
255
|
+
timeout_seconds: float | None = None,
|
|
256
|
+
) -> CommandResult:
|
|
257
|
+
sandbox_command = self.build_command(command)
|
|
258
|
+
pass_fds: tuple[int, ...] = ()
|
|
259
|
+
if sandbox_command.seccomp_file is not None:
|
|
260
|
+
pass_fds = (sandbox_command.seccomp_file.fileno(),)
|
|
261
|
+
|
|
262
|
+
process_env = os.environ.copy()
|
|
263
|
+
if env is not None:
|
|
264
|
+
process_env.update(env)
|
|
265
|
+
process = await asyncio.create_subprocess_exec(
|
|
266
|
+
*sandbox_command.args,
|
|
267
|
+
cwd=self.cwd,
|
|
268
|
+
env=process_env,
|
|
269
|
+
pass_fds=pass_fds,
|
|
270
|
+
start_new_session=True,
|
|
271
|
+
stdin=asyncio.subprocess.PIPE if input_text is not None else None,
|
|
272
|
+
stdout=asyncio.subprocess.PIPE,
|
|
273
|
+
stderr=asyncio.subprocess.PIPE,
|
|
274
|
+
)
|
|
275
|
+
try:
|
|
276
|
+
stdout, stderr = await asyncio.wait_for(
|
|
277
|
+
process.communicate(
|
|
278
|
+
input_text.encode() if input_text is not None else None
|
|
279
|
+
),
|
|
280
|
+
timeout=timeout_seconds or self.timeout_seconds,
|
|
281
|
+
)
|
|
282
|
+
except TimeoutError as error:
|
|
283
|
+
with suppress(ProcessLookupError):
|
|
284
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
285
|
+
stdout, stderr = await process.communicate()
|
|
286
|
+
return CommandResult(
|
|
287
|
+
command=" ".join(command),
|
|
288
|
+
exit_code=124,
|
|
289
|
+
stderr=str(error) or "Command timed out.",
|
|
290
|
+
stdout=self._text_output(stdout)[: self.output_limit],
|
|
291
|
+
)
|
|
292
|
+
except asyncio.CancelledError:
|
|
293
|
+
with suppress(ProcessLookupError):
|
|
294
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
295
|
+
try:
|
|
296
|
+
await asyncio.wait_for(process.wait(), timeout=1)
|
|
297
|
+
except TimeoutError:
|
|
298
|
+
with suppress(ProcessLookupError):
|
|
299
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
300
|
+
await process.wait()
|
|
301
|
+
raise
|
|
302
|
+
finally:
|
|
303
|
+
if sandbox_command.seccomp_file is not None:
|
|
304
|
+
sandbox_command.seccomp_file.close()
|
|
305
|
+
|
|
306
|
+
return CommandResult(
|
|
307
|
+
command=" ".join(command),
|
|
308
|
+
exit_code=process.returncode or 0,
|
|
309
|
+
stderr=self._text_output(stderr)[: self.output_limit],
|
|
310
|
+
stdout=self._text_output(stdout)[: self.output_limit],
|
|
311
|
+
)
|