flowent 0.1.2 → 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 +1 -0
- package/backend/src/flowent/main.py +297 -55
- package/backend/src/flowent/permissions.py +259 -0
- package/backend/src/flowent/sandbox.py +14 -4
- 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 +64 -0
- package/backend/src/flowent/tools.py +24 -1
- 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_permissions.py +443 -0
- package/backend/tests/test_workspace_chat.py +127 -0
- package/backend/uv.lock +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
|
+
)
|
|
@@ -132,14 +132,22 @@ class SandboxRunner:
|
|
|
132
132
|
cwd: Path | None = None,
|
|
133
133
|
timeout_seconds: float = 30,
|
|
134
134
|
output_limit: int = 20000,
|
|
135
|
+
writable_roots: list[Path] | None = None,
|
|
135
136
|
) -> None:
|
|
136
137
|
self.cwd = (cwd or Path.cwd()).resolve(strict=False)
|
|
137
138
|
self.timeout_seconds = timeout_seconds
|
|
138
139
|
self.output_limit = output_limit
|
|
140
|
+
self.extra_writable_roots = [
|
|
141
|
+
root.expanduser().resolve(strict=False) for root in (writable_roots or [])
|
|
142
|
+
]
|
|
139
143
|
|
|
140
144
|
@property
|
|
141
145
|
def writable_roots(self) -> list[Path]:
|
|
142
|
-
|
|
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
|
|
143
151
|
|
|
144
152
|
def ensure_writable_path(self, path: Path) -> None:
|
|
145
153
|
if not path_is_within(path, self.writable_roots):
|
|
@@ -162,10 +170,12 @@ class SandboxRunner:
|
|
|
162
170
|
"--bind",
|
|
163
171
|
str(self.cwd),
|
|
164
172
|
str(self.cwd),
|
|
165
|
-
"--bind",
|
|
166
|
-
"/tmp",
|
|
167
|
-
"/tmp",
|
|
168
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)])
|
|
169
179
|
for protected in [".git", ".codex", ".agents"]:
|
|
170
180
|
path = self.cwd / protected
|
|
171
181
|
if path.exists():
|