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.
Files changed (53) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/agent.py +19 -9
  21. package/backend/src/flowent/main.py +356 -62
  22. package/backend/src/flowent/permissions.py +259 -0
  23. package/backend/src/flowent/sandbox.py +83 -6
  24. package/backend/src/flowent/static/assets/index-DjF2KBwE.js +81 -0
  25. package/backend/src/flowent/static/assets/index-P-bBpJG8.css +2 -0
  26. package/backend/src/flowent/static/index.html +2 -2
  27. package/backend/src/flowent/storage.py +115 -3
  28. package/backend/src/flowent/tools.py +96 -2
  29. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  30. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/test_agent_tools.py +103 -2
  42. package/backend/tests/test_permissions.py +443 -0
  43. package/backend/tests/test_workspace_chat.py +396 -1
  44. package/backend/uv.lock +1 -1
  45. package/bin/flowent.mjs +1 -1
  46. package/dist/frontend/assets/index-DjF2KBwE.js +81 -0
  47. package/dist/frontend/assets/index-P-bBpJG8.css +2 -0
  48. package/dist/frontend/index.html +2 -2
  49. package/package.json +1 -1
  50. package/backend/src/flowent/static/assets/index-BhHdc2d_.js +0 -81
  51. package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
  52. package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
  53. 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: int = 30,
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
- return [self.cwd, Path("/tmp")]
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: int | None = None,
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
+ )