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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autoforge-ai",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Autonomous coding agent with web UI - build complete apps with AI",
5
5
  "license": "AGPL-3.0",
6
6
  "bin": {
@@ -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
- command = request.command
184
- else:
185
- command = get_dev_command(project_dir)
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
- # Validate command against security allowlist before persisting
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 shell command to run (e.g., "npm run dev")
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
- if self.status == "running":
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, "Another dev server instance is already running for this project"
304
-
305
- # Validate that project directory exists
306
- if not self.project_dir.exists():
307
- return False, f"Project directory does not exist: {self.project_dir}"
308
-
309
- # Defense-in-depth: validate command against security allowlist
310
- commands = extract_commands(command)
311
- if not commands:
312
- return False, "Could not parse command for security validation"
313
-
314
- allowed_commands, blocked_commands = get_effective_commands(self.project_dir)
315
- for cmd in commands:
316
- if cmd in blocked_commands:
317
- logger.warning("Blocked dev server command '%s' (in blocklist) for %s", cmd, self.project_name)
318
- return False, f"Command '{cmd}' is blocked and cannot be used as a dev server command"
319
- if not is_command_allowed(cmd, allowed_commands):
320
- logger.warning("Rejected dev server command '%s' (not in allowlist) for %s", cmd, self.project_name)
321
- return False, f"Command '{cmd}' is not in the allowed commands list"
322
-
323
- self._command = command
324
- self._detected_url = None # Reset URL detection
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
- shell_cmd,
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
- shell_cmd,
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 task
364
+ # Start output streaming
365
+ self.status = "running"
361
366
  self._output_task = asyncio.create_task(self._stream_output())
362
367
 
363
- return True, f"Dev server started with PID {self.process.pid}"
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
- logger.exception("Failed to start dev server")
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]: