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.
Files changed (51) 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 +1 -0
  21. package/backend/src/flowent/main.py +297 -55
  22. package/backend/src/flowent/permissions.py +259 -0
  23. package/backend/src/flowent/sandbox.py +14 -4
  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 +64 -0
  28. package/backend/src/flowent/tools.py +24 -1
  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_permissions.py +443 -0
  42. package/backend/tests/test_workspace_chat.py +127 -0
  43. package/backend/uv.lock +1 -1
  44. package/dist/frontend/assets/index-DjF2KBwE.js +81 -0
  45. package/dist/frontend/assets/index-P-bBpJG8.css +2 -0
  46. package/dist/frontend/index.html +2 -2
  47. package/package.json +1 -1
  48. package/backend/src/flowent/static/assets/index-BhHdc2d_.js +0 -81
  49. package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
  50. package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
  51. 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
- 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
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():