autoforge-ai 0.1.0 → 0.1.2
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/autonomous_agent_demo.py +11 -0
- package/package.json +1 -1
- package/server/routers/devserver.py +131 -4
- package/server/services/dev_server_manager.py +55 -44
- package/ui/dist/assets/index-CNq40B6c.js +97 -0
- package/ui/dist/assets/index-InF2n2n-.css +1 -0
- package/ui/dist/assets/vendor-utils-Cj4T6W23.js +2 -0
- package/ui/dist/index.html +4 -4
- package/ui/dist/logo.png +0 -0
- package/ui/dist/assets/index-8W_wmZzz.js +0 -168
- package/ui/dist/assets/index-B47Ubhox.css +0 -1
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +0 -2
package/autonomous_agent_demo.py
CHANGED
|
@@ -263,6 +263,17 @@ def main() -> None:
|
|
|
263
263
|
)
|
|
264
264
|
else:
|
|
265
265
|
# Entry point mode - always use unified orchestrator
|
|
266
|
+
# Clean up stale temp files before starting (prevents temp folder bloat)
|
|
267
|
+
from temp_cleanup import cleanup_stale_temp
|
|
268
|
+
cleanup_stats = cleanup_stale_temp()
|
|
269
|
+
if cleanup_stats["dirs_deleted"] > 0 or cleanup_stats["files_deleted"] > 0:
|
|
270
|
+
mb_freed = cleanup_stats["bytes_freed"] / (1024 * 1024)
|
|
271
|
+
print(
|
|
272
|
+
f"[CLEANUP] Removed {cleanup_stats['dirs_deleted']} dirs, "
|
|
273
|
+
f"{cleanup_stats['files_deleted']} files ({mb_freed:.1f} MB freed)",
|
|
274
|
+
flush=True,
|
|
275
|
+
)
|
|
276
|
+
|
|
266
277
|
from parallel_orchestrator import run_parallel_orchestrator
|
|
267
278
|
|
|
268
279
|
# Clamp concurrency to valid range (1-5)
|
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ Uses project registry for path lookups and project_config for command detection.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import logging
|
|
10
|
+
import shlex
|
|
10
11
|
import sys
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
@@ -72,6 +73,116 @@ def get_project_dir(project_name: str) -> Path:
|
|
|
72
73
|
|
|
73
74
|
return project_dir
|
|
74
75
|
|
|
76
|
+
ALLOWED_RUNNERS = {
|
|
77
|
+
"npm", "pnpm", "yarn", "npx",
|
|
78
|
+
"uvicorn", "python", "python3",
|
|
79
|
+
"flask", "poetry",
|
|
80
|
+
"cargo", "go",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ALLOWED_NPM_SCRIPTS = {"dev", "start", "serve", "develop", "server", "preview"}
|
|
84
|
+
|
|
85
|
+
# Allowed Python -m modules for dev servers
|
|
86
|
+
ALLOWED_PYTHON_MODULES = {"uvicorn", "flask", "gunicorn", "http.server"}
|
|
87
|
+
|
|
88
|
+
BLOCKED_SHELLS = {"sh", "bash", "zsh", "cmd", "powershell", "pwsh", "cmd.exe"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_custom_command_strict(cmd: str) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Strict allowlist validation for dev server commands.
|
|
94
|
+
Prevents arbitrary command execution (no sh -c, no cmd /c, no python -c, etc.)
|
|
95
|
+
"""
|
|
96
|
+
if not isinstance(cmd, str) or not cmd.strip():
|
|
97
|
+
raise ValueError("custom_command cannot be empty")
|
|
98
|
+
|
|
99
|
+
argv = shlex.split(cmd, posix=(sys.platform != "win32"))
|
|
100
|
+
if not argv:
|
|
101
|
+
raise ValueError("custom_command could not be parsed")
|
|
102
|
+
|
|
103
|
+
base = Path(argv[0]).name.lower()
|
|
104
|
+
|
|
105
|
+
# Block direct shells / interpreters commonly used for command injection
|
|
106
|
+
if base in BLOCKED_SHELLS:
|
|
107
|
+
raise ValueError(f"custom_command runner not allowed: {base}")
|
|
108
|
+
|
|
109
|
+
if base not in ALLOWED_RUNNERS:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"custom_command runner not allowed: {base}. "
|
|
112
|
+
f"Allowed: {', '.join(sorted(ALLOWED_RUNNERS))}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Block one-liner execution for python
|
|
116
|
+
lowered = [a.lower() for a in argv]
|
|
117
|
+
if base in {"python", "python3"}:
|
|
118
|
+
if "-c" in lowered:
|
|
119
|
+
raise ValueError("python -c is not allowed")
|
|
120
|
+
if len(argv) >= 3 and argv[1] == "-m":
|
|
121
|
+
# Allow: python -m <allowed_module> ...
|
|
122
|
+
if argv[2] not in ALLOWED_PYTHON_MODULES:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"python -m {argv[2]} is not allowed. "
|
|
125
|
+
f"Allowed modules: {', '.join(sorted(ALLOWED_PYTHON_MODULES))}"
|
|
126
|
+
)
|
|
127
|
+
elif len(argv) >= 2 and argv[1].endswith(".py"):
|
|
128
|
+
# Allow: python manage.py runserver, python app.py, etc.
|
|
129
|
+
pass
|
|
130
|
+
else:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"Python commands must use 'python -m <module> ...' or 'python <script>.py ...'"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if base == "flask":
|
|
136
|
+
# Allow: flask run [--host ...] [--port ...]
|
|
137
|
+
if len(argv) < 2 or argv[1] != "run":
|
|
138
|
+
raise ValueError("flask custom_command must be 'flask run [options]'")
|
|
139
|
+
|
|
140
|
+
if base == "poetry":
|
|
141
|
+
# Allow: poetry run <subcmd> ...
|
|
142
|
+
if len(argv) < 3 or argv[1] != "run":
|
|
143
|
+
raise ValueError("poetry custom_command must be 'poetry run <command> ...'")
|
|
144
|
+
|
|
145
|
+
if base == "uvicorn":
|
|
146
|
+
if len(argv) < 2 or ":" not in argv[1]:
|
|
147
|
+
raise ValueError("uvicorn must specify an app like module:app")
|
|
148
|
+
|
|
149
|
+
allowed_flags = {"--host", "--port", "--reload", "--log-level", "--workers"}
|
|
150
|
+
for a in argv[2:]:
|
|
151
|
+
if a.startswith("-"):
|
|
152
|
+
# Handle --flag=value syntax
|
|
153
|
+
flag_key = a.split("=", 1)[0]
|
|
154
|
+
if flag_key not in allowed_flags:
|
|
155
|
+
raise ValueError(f"uvicorn flag not allowed: {flag_key}")
|
|
156
|
+
|
|
157
|
+
if base in {"npm", "pnpm", "yarn"}:
|
|
158
|
+
# Allow only known safe scripts (no arbitrary exec)
|
|
159
|
+
if base == "npm":
|
|
160
|
+
if len(argv) < 3 or argv[1] != "run" or argv[2] not in ALLOWED_NPM_SCRIPTS:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"npm custom_command must be 'npm run <script>' where script is one of: "
|
|
163
|
+
f"{', '.join(sorted(ALLOWED_NPM_SCRIPTS))}"
|
|
164
|
+
)
|
|
165
|
+
elif base == "pnpm":
|
|
166
|
+
ok = (
|
|
167
|
+
(len(argv) >= 2 and argv[1] in ALLOWED_NPM_SCRIPTS)
|
|
168
|
+
or (len(argv) >= 3 and argv[1] == "run" and argv[2] in ALLOWED_NPM_SCRIPTS)
|
|
169
|
+
)
|
|
170
|
+
if not ok:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"pnpm custom_command must use a known script: "
|
|
173
|
+
f"{', '.join(sorted(ALLOWED_NPM_SCRIPTS))}"
|
|
174
|
+
)
|
|
175
|
+
elif base == "yarn":
|
|
176
|
+
ok = (
|
|
177
|
+
(len(argv) >= 2 and argv[1] in ALLOWED_NPM_SCRIPTS)
|
|
178
|
+
or (len(argv) >= 3 and argv[1] == "run" and argv[2] in ALLOWED_NPM_SCRIPTS)
|
|
179
|
+
)
|
|
180
|
+
if not ok:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"yarn custom_command must use a known script: "
|
|
183
|
+
f"{', '.join(sorted(ALLOWED_NPM_SCRIPTS))}"
|
|
184
|
+
)
|
|
185
|
+
|
|
75
186
|
|
|
76
187
|
def get_project_devserver_manager(project_name: str):
|
|
77
188
|
"""
|
|
@@ -180,9 +291,12 @@ async def start_devserver(
|
|
|
180
291
|
# Determine which command to use
|
|
181
292
|
command: str | None
|
|
182
293
|
if request.command:
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
294
|
+
raise HTTPException(
|
|
295
|
+
status_code=400,
|
|
296
|
+
detail="Direct command execution is disabled. Use /config to set a safe custom_command."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
command = get_dev_command(project_dir)
|
|
186
300
|
|
|
187
301
|
if not command:
|
|
188
302
|
raise HTTPException(
|
|
@@ -193,6 +307,13 @@ async def start_devserver(
|
|
|
193
307
|
# Validate command against security allowlist before execution
|
|
194
308
|
validate_dev_command(command, project_dir)
|
|
195
309
|
|
|
310
|
+
# Defense-in-depth: also run strict structural validation at execution time
|
|
311
|
+
# (catches config file tampering that bypasses the /config endpoint)
|
|
312
|
+
try:
|
|
313
|
+
validate_custom_command_strict(command)
|
|
314
|
+
except ValueError as e:
|
|
315
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
316
|
+
|
|
196
317
|
# Now command is definitely str and validated
|
|
197
318
|
success, message = await manager.start(command)
|
|
198
319
|
|
|
@@ -284,7 +405,13 @@ async def update_devserver_config(
|
|
|
284
405
|
except ValueError as e:
|
|
285
406
|
raise HTTPException(status_code=400, detail=str(e))
|
|
286
407
|
else:
|
|
287
|
-
#
|
|
408
|
+
# Strict structural validation first (most specific errors)
|
|
409
|
+
try:
|
|
410
|
+
validate_custom_command_strict(update.custom_command)
|
|
411
|
+
except ValueError as e:
|
|
412
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
413
|
+
|
|
414
|
+
# Then validate against security allowlist
|
|
288
415
|
validate_dev_command(update.custom_command, project_dir)
|
|
289
416
|
|
|
290
417
|
# Set the custom command
|
|
@@ -14,17 +14,17 @@ This is a simplified version of AgentProcessManager, tailored for dev servers:
|
|
|
14
14
|
import asyncio
|
|
15
15
|
import logging
|
|
16
16
|
import re
|
|
17
|
+
import shlex
|
|
17
18
|
import subprocess
|
|
18
19
|
import sys
|
|
19
20
|
import threading
|
|
20
|
-
from datetime import datetime
|
|
21
|
+
from datetime import datetime, timezone
|
|
21
22
|
from pathlib import Path
|
|
22
23
|
from typing import Awaitable, Callable, Literal, Set
|
|
23
24
|
|
|
24
25
|
import psutil
|
|
25
26
|
|
|
26
27
|
from registry import list_registered_projects
|
|
27
|
-
from security import extract_commands, get_effective_commands, is_command_allowed
|
|
28
28
|
from server.utils.process_utils import kill_process_tree
|
|
29
29
|
|
|
30
30
|
logger = logging.getLogger(__name__)
|
|
@@ -291,53 +291,54 @@ class DevServerProcessManager:
|
|
|
291
291
|
Start the dev server as a subprocess.
|
|
292
292
|
|
|
293
293
|
Args:
|
|
294
|
-
command: The
|
|
294
|
+
command: The command to run (e.g., "npm run dev")
|
|
295
295
|
|
|
296
296
|
Returns:
|
|
297
297
|
Tuple of (success, message)
|
|
298
298
|
"""
|
|
299
|
-
|
|
299
|
+
# Already running?
|
|
300
|
+
if self.process and self.status == "running":
|
|
300
301
|
return False, "Dev server is already running"
|
|
301
302
|
|
|
303
|
+
# Lock check (prevents double-start)
|
|
302
304
|
if not self._check_lock():
|
|
303
|
-
return False, "
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if not
|
|
307
|
-
return False,
|
|
308
|
-
|
|
309
|
-
#
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
for
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
305
|
+
return False, "Dev server already running (lock file present)"
|
|
306
|
+
|
|
307
|
+
command = (command or "").strip()
|
|
308
|
+
if not command:
|
|
309
|
+
return False, "Empty dev server command"
|
|
310
|
+
|
|
311
|
+
# SECURITY: block shell operators/metacharacters (defense-in-depth)
|
|
312
|
+
# NOTE: On Windows, .cmd/.bat files are executed via cmd.exe even with
|
|
313
|
+
# shell=False (CPython limitation), so metacharacter blocking is critical.
|
|
314
|
+
# Single & is a cmd.exe command separator, ^ is cmd escape, % enables
|
|
315
|
+
# environment variable expansion, > < enable redirection.
|
|
316
|
+
dangerous_ops = ["&&", "||", ";", "|", "`", "$(", "&", ">", "<", "^", "%"]
|
|
317
|
+
if any(op in command for op in dangerous_ops):
|
|
318
|
+
return False, "Shell operators are not allowed in dev server command"
|
|
319
|
+
# Block newline injection (cmd.exe interprets newlines as command separators)
|
|
320
|
+
if "\n" in command or "\r" in command:
|
|
321
|
+
return False, "Newlines are not allowed in dev server command"
|
|
322
|
+
|
|
323
|
+
# Parse into argv and execute without shell
|
|
324
|
+
argv = shlex.split(command, posix=(sys.platform != "win32"))
|
|
325
|
+
if not argv:
|
|
326
|
+
return False, "Empty dev server command"
|
|
327
|
+
|
|
328
|
+
base = Path(argv[0]).name.lower()
|
|
329
|
+
|
|
330
|
+
# Defense-in-depth: reject direct shells/interpreters commonly used for injection
|
|
331
|
+
if base in {"sh", "bash", "zsh", "cmd", "powershell", "pwsh"}:
|
|
332
|
+
return False, f"Shell runner '{base}' is not allowed for dev server commands"
|
|
333
|
+
|
|
334
|
+
# Windows: use .cmd shims for Node package managers
|
|
335
|
+
if sys.platform == "win32" and base in {"npm", "pnpm", "yarn", "npx"} and not argv[0].lower().endswith(".cmd"):
|
|
336
|
+
argv[0] = argv[0] + ".cmd"
|
|
325
337
|
|
|
326
338
|
try:
|
|
327
|
-
# Determine shell based on platform
|
|
328
|
-
if sys.platform == "win32":
|
|
329
|
-
# On Windows, use cmd.exe
|
|
330
|
-
shell_cmd = ["cmd", "/c", command]
|
|
331
|
-
else:
|
|
332
|
-
# On Unix-like systems, use sh
|
|
333
|
-
shell_cmd = ["sh", "-c", command]
|
|
334
|
-
|
|
335
|
-
# Start subprocess with piped stdout/stderr
|
|
336
|
-
# stdin=DEVNULL prevents interactive dev servers from blocking on stdin
|
|
337
|
-
# On Windows, use CREATE_NO_WINDOW to prevent console window from flashing
|
|
338
339
|
if sys.platform == "win32":
|
|
339
340
|
self.process = subprocess.Popen(
|
|
340
|
-
|
|
341
|
+
argv,
|
|
341
342
|
stdin=subprocess.DEVNULL,
|
|
342
343
|
stdout=subprocess.PIPE,
|
|
343
344
|
stderr=subprocess.STDOUT,
|
|
@@ -346,23 +347,33 @@ class DevServerProcessManager:
|
|
|
346
347
|
)
|
|
347
348
|
else:
|
|
348
349
|
self.process = subprocess.Popen(
|
|
349
|
-
|
|
350
|
+
argv,
|
|
350
351
|
stdin=subprocess.DEVNULL,
|
|
351
352
|
stdout=subprocess.PIPE,
|
|
352
353
|
stderr=subprocess.STDOUT,
|
|
353
354
|
cwd=str(self.project_dir),
|
|
354
355
|
)
|
|
355
356
|
|
|
357
|
+
self._command = command
|
|
358
|
+
self.started_at = datetime.now(timezone.utc)
|
|
359
|
+
self._detected_url = None
|
|
360
|
+
|
|
361
|
+
# Create lock once we have a PID
|
|
356
362
|
self._create_lock()
|
|
357
|
-
self.started_at = datetime.now()
|
|
358
|
-
self.status = "running"
|
|
359
363
|
|
|
360
|
-
# Start output streaming
|
|
364
|
+
# Start output streaming
|
|
365
|
+
self.status = "running"
|
|
361
366
|
self._output_task = asyncio.create_task(self._stream_output())
|
|
362
367
|
|
|
363
|
-
return True,
|
|
368
|
+
return True, "Dev server started"
|
|
369
|
+
|
|
370
|
+
except FileNotFoundError:
|
|
371
|
+
self.status = "stopped"
|
|
372
|
+
self.process = None
|
|
373
|
+
return False, f"Command not found: {argv[0]}"
|
|
364
374
|
except Exception as e:
|
|
365
|
-
|
|
375
|
+
self.status = "stopped"
|
|
376
|
+
self.process = None
|
|
366
377
|
return False, f"Failed to start dev server: {e}"
|
|
367
378
|
|
|
368
379
|
async def stop(self) -> tuple[bool, str]:
|