forgex-cli 0.1.0__py3-none-any.whl
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.
- forgex/__init__.py +3 -0
- forgex/__main__.py +6 -0
- forgex/agents/__init__.py +36 -0
- forgex/agents/architect.py +233 -0
- forgex/agents/base.py +385 -0
- forgex/agents/builder.py +269 -0
- forgex/agents/ensemble.py +334 -0
- forgex/agents/expert_coder.py +283 -0
- forgex/agents/supervisor.py +303 -0
- forgex/cli/__init__.py +1 -0
- forgex/cli/app.py +232 -0
- forgex/cli/build_views.py +94 -0
- forgex/cli/commands/__init__.py +1 -0
- forgex/cli/commands/build.py +63 -0
- forgex/cli/commands/checkpoint.py +41 -0
- forgex/cli/commands/config.py +225 -0
- forgex/cli/commands/context.py +94 -0
- forgex/cli/commands/diff.py +32 -0
- forgex/cli/commands/do_cmd.py +50 -0
- forgex/cli/commands/doctor.py +175 -0
- forgex/cli/commands/fix.py +46 -0
- forgex/cli/commands/init.py +41 -0
- forgex/cli/commands/logs.py +102 -0
- forgex/cli/commands/models.py +144 -0
- forgex/cli/commands/plan.py +103 -0
- forgex/cli/commands/review.py +76 -0
- forgex/cli/commands/rollback.py +65 -0
- forgex/cli/commands/run.py +75 -0
- forgex/cli/commands/status.py +141 -0
- forgex/cli/commands/supervise.py +61 -0
- forgex/cli/commands/task.py +136 -0
- forgex/cli/commands/tools.py +116 -0
- forgex/cli/commands/verify.py +65 -0
- forgex/cli/context_memory.py +62 -0
- forgex/cli/intent.py +209 -0
- forgex/cli/progress.py +101 -0
- forgex/cli/repl.py +377 -0
- forgex/cli/runtime.py +233 -0
- forgex/cli/session.py +95 -0
- forgex/cli/ui.py +113 -0
- forgex/config/__init__.py +53 -0
- forgex/config/loader.py +189 -0
- forgex/config/schema.py +145 -0
- forgex/context/__init__.py +6 -0
- forgex/context/chunker.py +102 -0
- forgex/context/dependency_graph.py +110 -0
- forgex/context/indexer.py +161 -0
- forgex/context/manager.py +174 -0
- forgex/context/retriever.py +99 -0
- forgex/models/__init__.py +25 -0
- forgex/models/base.py +112 -0
- forgex/models/capabilities.py +21 -0
- forgex/models/factory.py +66 -0
- forgex/models/groq.py +13 -0
- forgex/models/local.py +10 -0
- forgex/models/openai_compatible.py +233 -0
- forgex/models/registry.py +81 -0
- forgex/models/router.py +332 -0
- forgex/orchestration/__init__.py +36 -0
- forgex/orchestration/checkpoints.py +117 -0
- forgex/orchestration/engine.py +573 -0
- forgex/orchestration/events.py +101 -0
- forgex/orchestration/lockfile.py +82 -0
- forgex/orchestration/retry.py +65 -0
- forgex/orchestration/state.py +89 -0
- forgex/orchestration/stores.py +90 -0
- forgex/orchestration/workflow.py +103 -0
- forgex/project.py +209 -0
- forgex/prompts/__init__.py +27 -0
- forgex/prompts/architect.md +18 -0
- forgex/prompts/builder.md +20 -0
- forgex/prompts/expert_coder.md +25 -0
- forgex/prompts/reviewer.md +11 -0
- forgex/prompts/supervisor.md +26 -0
- forgex/research/__init__.py +15 -0
- forgex/research/citations.py +16 -0
- forgex/research/engine.py +83 -0
- forgex/research/sources.py +154 -0
- forgex/sandbox/__init__.py +37 -0
- forgex/sandbox/executor.py +418 -0
- forgex/sandbox/permissions.py +67 -0
- forgex/sandbox/policies.py +172 -0
- forgex/schemas/__init__.py +101 -0
- forgex/schemas/agent.py +109 -0
- forgex/schemas/checkpoint.py +19 -0
- forgex/schemas/error.py +125 -0
- forgex/schemas/event.py +76 -0
- forgex/schemas/plan.py +84 -0
- forgex/schemas/project.py +92 -0
- forgex/schemas/research.py +25 -0
- forgex/schemas/result.py +68 -0
- forgex/schemas/task.py +118 -0
- forgex/storage/__init__.py +25 -0
- forgex/storage/database.py +40 -0
- forgex/storage/migrations.py +130 -0
- forgex/storage/repositories.py +492 -0
- forgex/tools/__init__.py +73 -0
- forgex/tools/base.py +212 -0
- forgex/tools/dependencies.py +137 -0
- forgex/tools/filesystem.py +322 -0
- forgex/tools/git.py +239 -0
- forgex/tools/project.py +39 -0
- forgex/tools/search.py +205 -0
- forgex/tools/terminal.py +81 -0
- forgex/tools/testing.py +100 -0
- forgex/tools/verification.py +174 -0
- forgex/utils/__init__.py +15 -0
- forgex/utils/logging.py +28 -0
- forgex/utils/paths.py +48 -0
- forgex/utils/redact.py +44 -0
- forgex_cli-0.1.0.dist-info/METADATA +189 -0
- forgex_cli-0.1.0.dist-info/RECORD +115 -0
- forgex_cli-0.1.0.dist-info/WHEEL +4 -0
- forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
- forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Restricted command execution (spec sections 19, 20, 45, 75).
|
|
2
|
+
|
|
3
|
+
Every command runs as an ARGV LIST — never a shell string.
|
|
4
|
+
Guarantees:
|
|
5
|
+
* cwd must resolve inside the workspace (path traversal rejected)
|
|
6
|
+
* child environment is sanitized (parent secrets are never inherited)
|
|
7
|
+
* timeouts are enforced
|
|
8
|
+
* Docker sandbox option with container confinement
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import shlex
|
|
17
|
+
import shutil
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from forgex.config.schema import ForgexConfig
|
|
26
|
+
from forgex.sandbox.policies import (
|
|
27
|
+
ApprovalCallback,
|
|
28
|
+
CommandPolicy,
|
|
29
|
+
Decision,
|
|
30
|
+
resolve_approval,
|
|
31
|
+
)
|
|
32
|
+
from forgex.schemas.error import CommandDeniedError, SandboxError
|
|
33
|
+
from forgex.utils.paths import resolve_in_workspace
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("forgex.sandbox")
|
|
36
|
+
|
|
37
|
+
_SAFE_ENV_VARS = (
|
|
38
|
+
"PATH",
|
|
39
|
+
"HOME",
|
|
40
|
+
"LANG",
|
|
41
|
+
"LC_ALL",
|
|
42
|
+
"LC_CTYPE",
|
|
43
|
+
"TERM",
|
|
44
|
+
"USERPROFILE",
|
|
45
|
+
"SYSTEMROOT",
|
|
46
|
+
"SYSTEMDRIVE",
|
|
47
|
+
"COMSPEC",
|
|
48
|
+
"PATHEXT",
|
|
49
|
+
"TEMP",
|
|
50
|
+
"TMP",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class CommandResult:
|
|
56
|
+
argv: list[str]
|
|
57
|
+
exit_code: int
|
|
58
|
+
stdout: str = ""
|
|
59
|
+
stderr: str = ""
|
|
60
|
+
timed_out: bool = False
|
|
61
|
+
duration_ms: float = 0.0
|
|
62
|
+
truncated: bool = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CommandExecutor(ABC):
|
|
66
|
+
@abstractmethod
|
|
67
|
+
async def run(
|
|
68
|
+
self,
|
|
69
|
+
command: list[str] | str,
|
|
70
|
+
*,
|
|
71
|
+
cwd: str = ".",
|
|
72
|
+
timeout: float | None = None,
|
|
73
|
+
env: Mapping[str, str] | None = None,
|
|
74
|
+
) -> CommandResult:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PolicyGatedExecutor(CommandExecutor):
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
workspace: Path,
|
|
82
|
+
policy: CommandPolicy,
|
|
83
|
+
approval: ApprovalCallback | None = None,
|
|
84
|
+
default_timeout: int = 120,
|
|
85
|
+
max_output_bytes: int = 1_000_000,
|
|
86
|
+
) -> None:
|
|
87
|
+
self.workspace = Path(workspace).resolve()
|
|
88
|
+
self.policy = policy
|
|
89
|
+
self.approval = approval
|
|
90
|
+
self.default_timeout = default_timeout
|
|
91
|
+
self.max_output_bytes = max_output_bytes
|
|
92
|
+
|
|
93
|
+
async def run(
|
|
94
|
+
self,
|
|
95
|
+
command: list[str] | str,
|
|
96
|
+
*,
|
|
97
|
+
cwd: str = ".",
|
|
98
|
+
timeout: float | None = None,
|
|
99
|
+
env: Mapping[str, str] | None = None,
|
|
100
|
+
) -> CommandResult:
|
|
101
|
+
if isinstance(command, str):
|
|
102
|
+
argv = shlex.split(command)
|
|
103
|
+
else:
|
|
104
|
+
argv = list(command)
|
|
105
|
+
|
|
106
|
+
if not argv:
|
|
107
|
+
raise CommandDeniedError("Empty command")
|
|
108
|
+
|
|
109
|
+
# 1. Policy check
|
|
110
|
+
decision = self.policy.check(argv)
|
|
111
|
+
if decision.decision is Decision.DENY:
|
|
112
|
+
raise CommandDeniedError(f"Command denied by policy: {decision.reason}")
|
|
113
|
+
if decision.decision is Decision.APPROVAL:
|
|
114
|
+
approved = resolve_approval(self.policy.approval_mode, shlex.join(argv), self.approval)
|
|
115
|
+
if not approved:
|
|
116
|
+
raise CommandDeniedError(f"Command not approved: {shlex.join(argv)}")
|
|
117
|
+
|
|
118
|
+
# 2. Working directory confinement
|
|
119
|
+
try:
|
|
120
|
+
workdir = resolve_in_workspace(self.workspace, cwd)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
raise SandboxError(f"Working directory escapes workspace: {exc}") from exc
|
|
123
|
+
|
|
124
|
+
return await self._execute(argv, workdir, timeout or self.default_timeout, env)
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
async def _execute(
|
|
128
|
+
self,
|
|
129
|
+
argv: list[str],
|
|
130
|
+
workdir: Path,
|
|
131
|
+
timeout: float,
|
|
132
|
+
env: Mapping[str, str] | None,
|
|
133
|
+
) -> CommandResult:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class RestrictedLocalExecutor(PolicyGatedExecutor):
|
|
138
|
+
async def _execute(
|
|
139
|
+
self,
|
|
140
|
+
argv: list[str],
|
|
141
|
+
workdir: Path,
|
|
142
|
+
timeout: float,
|
|
143
|
+
env: Mapping[str, str] | None,
|
|
144
|
+
) -> CommandResult:
|
|
145
|
+
# Sanitize environment
|
|
146
|
+
child_env: dict[str, str] = {}
|
|
147
|
+
for var in _SAFE_ENV_VARS:
|
|
148
|
+
val = os.environ.get(var)
|
|
149
|
+
if val is not None:
|
|
150
|
+
child_env[var] = val
|
|
151
|
+
if env:
|
|
152
|
+
child_env.update(env)
|
|
153
|
+
|
|
154
|
+
start = time.perf_counter()
|
|
155
|
+
timed_out = False
|
|
156
|
+
try:
|
|
157
|
+
proc = await asyncio.create_subprocess_exec(
|
|
158
|
+
*argv,
|
|
159
|
+
cwd=str(workdir),
|
|
160
|
+
env=child_env,
|
|
161
|
+
stdout=asyncio.subprocess.PIPE,
|
|
162
|
+
stderr=asyncio.subprocess.PIPE,
|
|
163
|
+
)
|
|
164
|
+
except FileNotFoundError:
|
|
165
|
+
duration = (time.perf_counter() - start) * 1000
|
|
166
|
+
return CommandResult(
|
|
167
|
+
argv=argv,
|
|
168
|
+
exit_code=127,
|
|
169
|
+
stderr=f"command not found: {argv[0]}",
|
|
170
|
+
duration_ms=duration,
|
|
171
|
+
)
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
duration = (time.perf_counter() - start) * 1000
|
|
174
|
+
return CommandResult(
|
|
175
|
+
argv=argv,
|
|
176
|
+
exit_code=126,
|
|
177
|
+
stderr=f"failed to execute command: {exc}",
|
|
178
|
+
duration_ms=duration,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
183
|
+
except TimeoutError:
|
|
184
|
+
timed_out = True
|
|
185
|
+
try:
|
|
186
|
+
proc.kill()
|
|
187
|
+
except ProcessLookupError:
|
|
188
|
+
pass
|
|
189
|
+
stdout_b, stderr_b = await proc.communicate()
|
|
190
|
+
except asyncio.CancelledError:
|
|
191
|
+
try:
|
|
192
|
+
proc.kill()
|
|
193
|
+
except ProcessLookupError:
|
|
194
|
+
pass
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
duration = (time.perf_counter() - start) * 1000
|
|
198
|
+
truncated = False
|
|
199
|
+
if len(stdout_b) > self.max_output_bytes:
|
|
200
|
+
stdout_b = stdout_b[: self.max_output_bytes]
|
|
201
|
+
truncated = True
|
|
202
|
+
if len(stderr_b) > self.max_output_bytes:
|
|
203
|
+
stderr_b = stderr_b[: self.max_output_bytes]
|
|
204
|
+
truncated = True
|
|
205
|
+
|
|
206
|
+
return CommandResult(
|
|
207
|
+
argv=argv,
|
|
208
|
+
exit_code=proc.returncode if proc.returncode is not None else -1,
|
|
209
|
+
stdout=stdout_b.decode("utf-8", errors="replace"),
|
|
210
|
+
stderr=stderr_b.decode("utf-8", errors="replace"),
|
|
211
|
+
timed_out=timed_out,
|
|
212
|
+
duration_ms=duration,
|
|
213
|
+
truncated=truncated,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class DockerExecutor(PolicyGatedExecutor):
|
|
218
|
+
DEFAULT_IMAGE = "python:3.12-slim"
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
workspace: Path,
|
|
223
|
+
policy: CommandPolicy,
|
|
224
|
+
approval: ApprovalCallback | None = None,
|
|
225
|
+
image: str = DEFAULT_IMAGE,
|
|
226
|
+
cpu_limit: str | None = None,
|
|
227
|
+
memory_limit: str | None = None,
|
|
228
|
+
network: bool = False,
|
|
229
|
+
read_only: bool = True,
|
|
230
|
+
user: str | None = None,
|
|
231
|
+
default_timeout: int = 120,
|
|
232
|
+
max_output_bytes: int = 1_000_000,
|
|
233
|
+
) -> None:
|
|
234
|
+
super().__init__(workspace, policy, approval, default_timeout, max_output_bytes)
|
|
235
|
+
self.image = image
|
|
236
|
+
self.cpu_limit = cpu_limit
|
|
237
|
+
self.memory_limit = memory_limit
|
|
238
|
+
self.network = network
|
|
239
|
+
self.read_only = read_only
|
|
240
|
+
self.user = user
|
|
241
|
+
|
|
242
|
+
def build_docker_argv(
|
|
243
|
+
self,
|
|
244
|
+
argv: list[str],
|
|
245
|
+
workdir: Path,
|
|
246
|
+
container_name: str,
|
|
247
|
+
env: Mapping[str, str] | None = None,
|
|
248
|
+
) -> list[str]:
|
|
249
|
+
cmd = [
|
|
250
|
+
"docker",
|
|
251
|
+
"run",
|
|
252
|
+
"--rm",
|
|
253
|
+
"--name",
|
|
254
|
+
container_name,
|
|
255
|
+
"--network",
|
|
256
|
+
"bridge" if self.network else "none",
|
|
257
|
+
"--cap-drop",
|
|
258
|
+
"ALL",
|
|
259
|
+
"--security-opt",
|
|
260
|
+
"no-new-privileges",
|
|
261
|
+
]
|
|
262
|
+
if self.read_only:
|
|
263
|
+
cmd.extend(["--read-only", "--tmpfs", "/tmp"])
|
|
264
|
+
if self.cpu_limit:
|
|
265
|
+
cmd.extend(["--cpus", self.cpu_limit])
|
|
266
|
+
if self.memory_limit:
|
|
267
|
+
cmd.extend(["--memory", self.memory_limit])
|
|
268
|
+
|
|
269
|
+
# UID/GID
|
|
270
|
+
if self.user:
|
|
271
|
+
cmd.extend(["--user", self.user])
|
|
272
|
+
elif os.name == "posix":
|
|
273
|
+
cmd.extend(["--user", f"{os.getuid()}:{os.getgid()}"])
|
|
274
|
+
|
|
275
|
+
# Mount workspace
|
|
276
|
+
ws_resolved = self.workspace.resolve()
|
|
277
|
+
cmd.extend(["-v", f"{ws_resolved}:/workspace"])
|
|
278
|
+
|
|
279
|
+
# Working directory inside container
|
|
280
|
+
try:
|
|
281
|
+
rel = workdir.relative_to(ws_resolved).as_posix()
|
|
282
|
+
w_path = f"/workspace/{rel}" if rel and rel != "." else "/workspace"
|
|
283
|
+
except ValueError:
|
|
284
|
+
w_path = "/workspace"
|
|
285
|
+
cmd.extend(["-w", w_path])
|
|
286
|
+
|
|
287
|
+
if env:
|
|
288
|
+
for k, v in env.items():
|
|
289
|
+
cmd.extend(["-e", f"{k}={v}"])
|
|
290
|
+
|
|
291
|
+
cmd.append(self.image)
|
|
292
|
+
cmd.extend(argv)
|
|
293
|
+
return cmd
|
|
294
|
+
|
|
295
|
+
async def _execute(
|
|
296
|
+
self,
|
|
297
|
+
argv: list[str],
|
|
298
|
+
workdir: Path,
|
|
299
|
+
timeout: float,
|
|
300
|
+
env: Mapping[str, str] | None,
|
|
301
|
+
) -> CommandResult:
|
|
302
|
+
name = f"forgex-{uuid.uuid4().hex[:8]}"
|
|
303
|
+
docker_cmd = self.build_docker_argv(argv, workdir, name, env)
|
|
304
|
+
|
|
305
|
+
start = time.perf_counter()
|
|
306
|
+
timed_out = False
|
|
307
|
+
try:
|
|
308
|
+
proc = await asyncio.create_subprocess_exec(
|
|
309
|
+
*docker_cmd,
|
|
310
|
+
stdout=asyncio.subprocess.PIPE,
|
|
311
|
+
stderr=asyncio.subprocess.PIPE,
|
|
312
|
+
)
|
|
313
|
+
except OSError as exc:
|
|
314
|
+
duration = (time.perf_counter() - start) * 1000
|
|
315
|
+
return CommandResult(
|
|
316
|
+
argv=argv,
|
|
317
|
+
exit_code=125,
|
|
318
|
+
stderr=f"docker execution failed: {exc}",
|
|
319
|
+
duration_ms=duration,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
324
|
+
except TimeoutError:
|
|
325
|
+
timed_out = True
|
|
326
|
+
try:
|
|
327
|
+
# Force kill container
|
|
328
|
+
kill_p = await asyncio.create_subprocess_exec(
|
|
329
|
+
"docker", "kill", name,
|
|
330
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
331
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
332
|
+
)
|
|
333
|
+
await kill_p.wait()
|
|
334
|
+
proc.kill()
|
|
335
|
+
except Exception: # noqa: BLE001
|
|
336
|
+
pass
|
|
337
|
+
stdout_b, stderr_b = await proc.communicate()
|
|
338
|
+
except asyncio.CancelledError:
|
|
339
|
+
try:
|
|
340
|
+
kill_p = await asyncio.create_subprocess_exec(
|
|
341
|
+
"docker", "kill", name,
|
|
342
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
343
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
344
|
+
)
|
|
345
|
+
await kill_p.wait()
|
|
346
|
+
proc.kill()
|
|
347
|
+
except Exception: # noqa: BLE001
|
|
348
|
+
pass
|
|
349
|
+
raise
|
|
350
|
+
|
|
351
|
+
duration = (time.perf_counter() - start) * 1000
|
|
352
|
+
truncated = False
|
|
353
|
+
if len(stdout_b) > self.max_output_bytes:
|
|
354
|
+
stdout_b = stdout_b[: self.max_output_bytes]
|
|
355
|
+
truncated = True
|
|
356
|
+
if len(stderr_b) > self.max_output_bytes:
|
|
357
|
+
stderr_b = stderr_b[: self.max_output_bytes]
|
|
358
|
+
truncated = True
|
|
359
|
+
|
|
360
|
+
return CommandResult(
|
|
361
|
+
argv=argv,
|
|
362
|
+
exit_code=proc.returncode if proc.returncode is not None else -1,
|
|
363
|
+
stdout=stdout_b.decode("utf-8", errors="replace"),
|
|
364
|
+
stderr=stderr_b.decode("utf-8", errors="replace"),
|
|
365
|
+
timed_out=timed_out,
|
|
366
|
+
duration_ms=duration,
|
|
367
|
+
truncated=truncated,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def docker_available() -> bool:
|
|
372
|
+
return shutil.which("docker") is not None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def build_executor(
|
|
376
|
+
workspace: Path,
|
|
377
|
+
config: ForgexConfig,
|
|
378
|
+
approval: ApprovalCallback | None = None,
|
|
379
|
+
) -> CommandExecutor:
|
|
380
|
+
policy = CommandPolicy.from_config(config)
|
|
381
|
+
if config.sandbox.enabled:
|
|
382
|
+
if docker_available():
|
|
383
|
+
sb = config.sandbox
|
|
384
|
+
return DockerExecutor(
|
|
385
|
+
workspace=workspace,
|
|
386
|
+
policy=policy,
|
|
387
|
+
approval=approval,
|
|
388
|
+
image=sb.image,
|
|
389
|
+
cpu_limit=sb.cpu_limit,
|
|
390
|
+
memory_limit=sb.memory_limit,
|
|
391
|
+
network=sb.network,
|
|
392
|
+
read_only=sb.read_only,
|
|
393
|
+
user=sb.user,
|
|
394
|
+
default_timeout=sb.timeout_seconds,
|
|
395
|
+
max_output_bytes=config.terminal.max_output_bytes,
|
|
396
|
+
)
|
|
397
|
+
logger.warning("sandbox.enabled is true but docker is not available — using restricted local executor")
|
|
398
|
+
|
|
399
|
+
return RestrictedLocalExecutor(
|
|
400
|
+
workspace=workspace,
|
|
401
|
+
policy=policy,
|
|
402
|
+
approval=approval,
|
|
403
|
+
default_timeout=config.terminal.timeout_seconds,
|
|
404
|
+
max_output_bytes=config.terminal.max_output_bytes,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def make_executor(
|
|
409
|
+
workspace: Path | str,
|
|
410
|
+
*,
|
|
411
|
+
policy: CommandPolicy | None = None,
|
|
412
|
+
approval: ApprovalCallback | None = None,
|
|
413
|
+
) -> PolicyGatedExecutor:
|
|
414
|
+
return RestrictedLocalExecutor(
|
|
415
|
+
workspace=Path(workspace),
|
|
416
|
+
policy=policy or CommandPolicy(),
|
|
417
|
+
approval=approval,
|
|
418
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Per-agent tool permissions (spec section 32).
|
|
2
|
+
|
|
3
|
+
No agent gets unlimited permissions. Unknown roles get an EMPTY set —
|
|
4
|
+
fail closed, always.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from forgex.schemas.agent import AgentRole
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Permission(str, Enum):
|
|
18
|
+
READ = "read"
|
|
19
|
+
WRITE = "write"
|
|
20
|
+
DELETE = "delete"
|
|
21
|
+
TERMINAL = "terminal"
|
|
22
|
+
GIT = "git"
|
|
23
|
+
TEST = "test"
|
|
24
|
+
BUILD = "build"
|
|
25
|
+
RESEARCH = "research"
|
|
26
|
+
NETWORK = "network"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PermissionSet(BaseModel):
|
|
30
|
+
allow: set[Permission] = Field(default_factory=set)
|
|
31
|
+
|
|
32
|
+
def permits(self, *permissions: Permission) -> bool:
|
|
33
|
+
return all(p in self.allow for p in permissions)
|
|
34
|
+
|
|
35
|
+
def deny_reason(self, *permissions: Permission) -> str | None:
|
|
36
|
+
missing = [p.value for p in permissions if p not in self.allow]
|
|
37
|
+
return f"missing permissions: {', '.join(missing)}" if missing else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def permission_set_for(role: Any) -> PermissionSet:
|
|
41
|
+
"""Return the static permission grant for an agent role."""
|
|
42
|
+
role_val = getattr(role, "value", str(role))
|
|
43
|
+
|
|
44
|
+
if role_val == AgentRole.ARCHITECT.value:
|
|
45
|
+
return PermissionSet(allow={Permission.READ, Permission.RESEARCH})
|
|
46
|
+
elif role_val in (AgentRole.BUILDER.value, AgentRole.EXPERT_CODER.value):
|
|
47
|
+
return PermissionSet(
|
|
48
|
+
allow={
|
|
49
|
+
Permission.READ,
|
|
50
|
+
Permission.WRITE,
|
|
51
|
+
Permission.DELETE,
|
|
52
|
+
Permission.TERMINAL,
|
|
53
|
+
Permission.GIT,
|
|
54
|
+
Permission.TEST,
|
|
55
|
+
Permission.BUILD,
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
elif role_val == AgentRole.SUPERVISOR.value:
|
|
59
|
+
return PermissionSet(
|
|
60
|
+
allow={
|
|
61
|
+
Permission.READ,
|
|
62
|
+
Permission.TEST,
|
|
63
|
+
Permission.BUILD,
|
|
64
|
+
Permission.GIT,
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
return PermissionSet(allow=set())
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Command policy: allow/deny lists + network rules (spec sections 20, 45, 75).
|
|
2
|
+
|
|
3
|
+
Decisions are fail-safe: unknown situations ask or deny — never silently
|
|
4
|
+
allow.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from forgex.config.schema import ForgexConfig
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("forgex.sandbox")
|
|
19
|
+
|
|
20
|
+
ApprovalCallback = Callable[[str], bool]
|
|
21
|
+
|
|
22
|
+
DEFAULT_DENIED: frozenset[str] = frozenset(
|
|
23
|
+
{
|
|
24
|
+
# destructive / system
|
|
25
|
+
"rm",
|
|
26
|
+
"rmdir",
|
|
27
|
+
"sudo",
|
|
28
|
+
"su",
|
|
29
|
+
"doas",
|
|
30
|
+
"shutdown",
|
|
31
|
+
"reboot",
|
|
32
|
+
"poweroff",
|
|
33
|
+
"halt",
|
|
34
|
+
"mkfs",
|
|
35
|
+
"dd",
|
|
36
|
+
"fdisk",
|
|
37
|
+
"format",
|
|
38
|
+
"chmod",
|
|
39
|
+
"chown",
|
|
40
|
+
"kill",
|
|
41
|
+
"killall",
|
|
42
|
+
"pkill",
|
|
43
|
+
"systemctl",
|
|
44
|
+
"service",
|
|
45
|
+
"launchctl",
|
|
46
|
+
"reg",
|
|
47
|
+
"regedit",
|
|
48
|
+
# shells
|
|
49
|
+
"sh",
|
|
50
|
+
"bash",
|
|
51
|
+
"zsh",
|
|
52
|
+
"dash",
|
|
53
|
+
"ksh",
|
|
54
|
+
"csh",
|
|
55
|
+
"tcsh",
|
|
56
|
+
"fish",
|
|
57
|
+
"cmd",
|
|
58
|
+
"powershell",
|
|
59
|
+
"pwsh",
|
|
60
|
+
# argument launchers / bypasses
|
|
61
|
+
"env",
|
|
62
|
+
"xargs",
|
|
63
|
+
"find",
|
|
64
|
+
"nohup",
|
|
65
|
+
"timeout",
|
|
66
|
+
"stdbuf",
|
|
67
|
+
"nice",
|
|
68
|
+
"watch",
|
|
69
|
+
"tee",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
DEFAULT_NETWORK_COMMANDS: frozenset[str] = frozenset(
|
|
74
|
+
{
|
|
75
|
+
"pip",
|
|
76
|
+
"pip3",
|
|
77
|
+
"uv",
|
|
78
|
+
"npm",
|
|
79
|
+
"npx",
|
|
80
|
+
"pnpm",
|
|
81
|
+
"yarn",
|
|
82
|
+
"bun",
|
|
83
|
+
"cargo",
|
|
84
|
+
"go",
|
|
85
|
+
"curl",
|
|
86
|
+
"wget",
|
|
87
|
+
"docker",
|
|
88
|
+
"podman",
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Decision(Enum):
|
|
94
|
+
ALLOW = "allow"
|
|
95
|
+
DENY = "deny"
|
|
96
|
+
APPROVAL = "approval"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class PolicyDecision:
|
|
101
|
+
decision: Decision
|
|
102
|
+
reason: str = ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class CommandPolicy:
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
allowed: frozenset[str] | None = None,
|
|
109
|
+
denied: frozenset[str] | None = None,
|
|
110
|
+
network_commands: frozenset[str] | None = None,
|
|
111
|
+
network_allowed: bool = False,
|
|
112
|
+
approval_mode: str = "ask",
|
|
113
|
+
) -> None:
|
|
114
|
+
self.allowed = allowed
|
|
115
|
+
self.denied = denied or DEFAULT_DENIED
|
|
116
|
+
self.network_commands = network_commands or DEFAULT_NETWORK_COMMANDS
|
|
117
|
+
self.network_allowed = network_allowed
|
|
118
|
+
self.approval_mode = approval_mode
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def from_config(cls, config: ForgexConfig) -> CommandPolicy:
|
|
122
|
+
term = config.terminal
|
|
123
|
+
perm = config.permissions
|
|
124
|
+
allowed = frozenset(term.allowed) if term.allowed else None
|
|
125
|
+
denied = frozenset(term.denied) if term.denied else DEFAULT_DENIED
|
|
126
|
+
return cls(
|
|
127
|
+
allowed=allowed,
|
|
128
|
+
denied=denied,
|
|
129
|
+
network_allowed=(perm.mode == "auto"),
|
|
130
|
+
approval_mode=perm.mode,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def check(self, argv: list[str]) -> PolicyDecision:
|
|
134
|
+
if not argv:
|
|
135
|
+
return PolicyDecision(Decision.DENY, "Empty command line")
|
|
136
|
+
|
|
137
|
+
raw_base = argv[0]
|
|
138
|
+
# Resolve command base name
|
|
139
|
+
base = Path(raw_base).name.lower()
|
|
140
|
+
base = base.removesuffix(".exe")
|
|
141
|
+
|
|
142
|
+
norm_base = re.sub(r"[0-9.]+$", "", base)
|
|
143
|
+
|
|
144
|
+
# 1. Deny list
|
|
145
|
+
if base in self.denied or norm_base in self.denied:
|
|
146
|
+
return PolicyDecision(Decision.DENY, f"Command '{base}' is in the deny list")
|
|
147
|
+
|
|
148
|
+
# 2. Allowlist mode
|
|
149
|
+
if self.allowed is not None:
|
|
150
|
+
if base not in self.allowed and norm_base not in self.allowed:
|
|
151
|
+
return PolicyDecision(Decision.DENY, f"Command '{base}' is not in the allowlist")
|
|
152
|
+
|
|
153
|
+
# 3. Network commands
|
|
154
|
+
if (base in self.network_commands or norm_base in self.network_commands) and not self.network_allowed:
|
|
155
|
+
return PolicyDecision(Decision.APPROVAL, f"Command '{base}' may access the network")
|
|
156
|
+
|
|
157
|
+
return PolicyDecision(Decision.ALLOW)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve_approval(mode: str, description: str, callback: ApprovalCallback | None) -> bool:
|
|
161
|
+
if mode == "safe":
|
|
162
|
+
return False
|
|
163
|
+
if mode == "auto":
|
|
164
|
+
return True
|
|
165
|
+
if mode == "ask":
|
|
166
|
+
if callback is None:
|
|
167
|
+
return False
|
|
168
|
+
try:
|
|
169
|
+
return bool(callback(description))
|
|
170
|
+
except Exception: # noqa: BLE001
|
|
171
|
+
return False
|
|
172
|
+
return False
|