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.
Files changed (115) hide show
  1. forgex/__init__.py +3 -0
  2. forgex/__main__.py +6 -0
  3. forgex/agents/__init__.py +36 -0
  4. forgex/agents/architect.py +233 -0
  5. forgex/agents/base.py +385 -0
  6. forgex/agents/builder.py +269 -0
  7. forgex/agents/ensemble.py +334 -0
  8. forgex/agents/expert_coder.py +283 -0
  9. forgex/agents/supervisor.py +303 -0
  10. forgex/cli/__init__.py +1 -0
  11. forgex/cli/app.py +232 -0
  12. forgex/cli/build_views.py +94 -0
  13. forgex/cli/commands/__init__.py +1 -0
  14. forgex/cli/commands/build.py +63 -0
  15. forgex/cli/commands/checkpoint.py +41 -0
  16. forgex/cli/commands/config.py +225 -0
  17. forgex/cli/commands/context.py +94 -0
  18. forgex/cli/commands/diff.py +32 -0
  19. forgex/cli/commands/do_cmd.py +50 -0
  20. forgex/cli/commands/doctor.py +175 -0
  21. forgex/cli/commands/fix.py +46 -0
  22. forgex/cli/commands/init.py +41 -0
  23. forgex/cli/commands/logs.py +102 -0
  24. forgex/cli/commands/models.py +144 -0
  25. forgex/cli/commands/plan.py +103 -0
  26. forgex/cli/commands/review.py +76 -0
  27. forgex/cli/commands/rollback.py +65 -0
  28. forgex/cli/commands/run.py +75 -0
  29. forgex/cli/commands/status.py +141 -0
  30. forgex/cli/commands/supervise.py +61 -0
  31. forgex/cli/commands/task.py +136 -0
  32. forgex/cli/commands/tools.py +116 -0
  33. forgex/cli/commands/verify.py +65 -0
  34. forgex/cli/context_memory.py +62 -0
  35. forgex/cli/intent.py +209 -0
  36. forgex/cli/progress.py +101 -0
  37. forgex/cli/repl.py +377 -0
  38. forgex/cli/runtime.py +233 -0
  39. forgex/cli/session.py +95 -0
  40. forgex/cli/ui.py +113 -0
  41. forgex/config/__init__.py +53 -0
  42. forgex/config/loader.py +189 -0
  43. forgex/config/schema.py +145 -0
  44. forgex/context/__init__.py +6 -0
  45. forgex/context/chunker.py +102 -0
  46. forgex/context/dependency_graph.py +110 -0
  47. forgex/context/indexer.py +161 -0
  48. forgex/context/manager.py +174 -0
  49. forgex/context/retriever.py +99 -0
  50. forgex/models/__init__.py +25 -0
  51. forgex/models/base.py +112 -0
  52. forgex/models/capabilities.py +21 -0
  53. forgex/models/factory.py +66 -0
  54. forgex/models/groq.py +13 -0
  55. forgex/models/local.py +10 -0
  56. forgex/models/openai_compatible.py +233 -0
  57. forgex/models/registry.py +81 -0
  58. forgex/models/router.py +332 -0
  59. forgex/orchestration/__init__.py +36 -0
  60. forgex/orchestration/checkpoints.py +117 -0
  61. forgex/orchestration/engine.py +573 -0
  62. forgex/orchestration/events.py +101 -0
  63. forgex/orchestration/lockfile.py +82 -0
  64. forgex/orchestration/retry.py +65 -0
  65. forgex/orchestration/state.py +89 -0
  66. forgex/orchestration/stores.py +90 -0
  67. forgex/orchestration/workflow.py +103 -0
  68. forgex/project.py +209 -0
  69. forgex/prompts/__init__.py +27 -0
  70. forgex/prompts/architect.md +18 -0
  71. forgex/prompts/builder.md +20 -0
  72. forgex/prompts/expert_coder.md +25 -0
  73. forgex/prompts/reviewer.md +11 -0
  74. forgex/prompts/supervisor.md +26 -0
  75. forgex/research/__init__.py +15 -0
  76. forgex/research/citations.py +16 -0
  77. forgex/research/engine.py +83 -0
  78. forgex/research/sources.py +154 -0
  79. forgex/sandbox/__init__.py +37 -0
  80. forgex/sandbox/executor.py +418 -0
  81. forgex/sandbox/permissions.py +67 -0
  82. forgex/sandbox/policies.py +172 -0
  83. forgex/schemas/__init__.py +101 -0
  84. forgex/schemas/agent.py +109 -0
  85. forgex/schemas/checkpoint.py +19 -0
  86. forgex/schemas/error.py +125 -0
  87. forgex/schemas/event.py +76 -0
  88. forgex/schemas/plan.py +84 -0
  89. forgex/schemas/project.py +92 -0
  90. forgex/schemas/research.py +25 -0
  91. forgex/schemas/result.py +68 -0
  92. forgex/schemas/task.py +118 -0
  93. forgex/storage/__init__.py +25 -0
  94. forgex/storage/database.py +40 -0
  95. forgex/storage/migrations.py +130 -0
  96. forgex/storage/repositories.py +492 -0
  97. forgex/tools/__init__.py +73 -0
  98. forgex/tools/base.py +212 -0
  99. forgex/tools/dependencies.py +137 -0
  100. forgex/tools/filesystem.py +322 -0
  101. forgex/tools/git.py +239 -0
  102. forgex/tools/project.py +39 -0
  103. forgex/tools/search.py +205 -0
  104. forgex/tools/terminal.py +81 -0
  105. forgex/tools/testing.py +100 -0
  106. forgex/tools/verification.py +174 -0
  107. forgex/utils/__init__.py +15 -0
  108. forgex/utils/logging.py +28 -0
  109. forgex/utils/paths.py +48 -0
  110. forgex/utils/redact.py +44 -0
  111. forgex_cli-0.1.0.dist-info/METADATA +189 -0
  112. forgex_cli-0.1.0.dist-info/RECORD +115 -0
  113. forgex_cli-0.1.0.dist-info/WHEEL +4 -0
  114. forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
  115. 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