Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "autoforge-ai",
"version": "0.1.21",
"version": "0.1.22",
"description": "Autonomous coding agent with web UI - build complete apps with AI",
"license": "AGPL-3.0",
"bin": {
Expand Down
20 changes: 20 additions & 0 deletions server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@
from contextlib import asynccontextmanager
from pathlib import Path

# Fail fast on unsupported Python versions. Older interpreters surface as
# opaque Claude SDK errors (e.g. "Control request timeout: initialize").
if sys.version_info < (3, 11):
sys.exit(
"ERROR: AutoForge requires Python 3.11 or newer "
f"(you are running Python {sys.version.split()[0]}). "
"Older versions cause opaque Claude SDK errors such as "
"'Control request timeout: initialize'. "
"Recreate your virtual environment with Python 3.11+ "
"(python3.11 -m venv venv && pip install -r requirements.txt)."
)

# Fix for Windows subprocess support in asyncio
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
Expand Down Expand Up @@ -53,6 +65,7 @@
from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks
from .services.scheduler_service import cleanup_scheduler, get_scheduler
from .services.terminal_manager import cleanup_all_terminals
from .utils.ws_security import WebSocketOriginMiddleware
from .websocket import project_websocket

# Paths
Expand Down Expand Up @@ -142,6 +155,13 @@ async def lifespan(app: FastAPI):
# Security Middleware
# ============================================================================

# WebSocket Origin validation (CSWSH protection). Starlette's @app.middleware("http")
# never runs for WebSocket handshakes, so the require_localhost guard below does not
# cover WS routes. Browsers do not enforce same-origin on WebSocket connects either,
# so without this check any web page could hijack the terminal/chat sockets.
# Registered unconditionally so it applies even when AUTOFORGE_ALLOW_REMOTE=1.
app.add_middleware(WebSocketOriginMiddleware, allow_remote=ALLOW_REMOTE)

if not ALLOW_REMOTE:
@app.middleware("http")
async def require_localhost(request: Request, call_next):
Expand Down
29 changes: 25 additions & 4 deletions server/services/assistant_chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from .chat_constants import (
ROOT_DIR,
check_rate_limit_error,
format_client_init_error,
safe_receive_response,
)

Expand Down Expand Up @@ -68,6 +69,24 @@
"WebSearch",
]

# Tools explicitly removed from the assistant's toolset.
# allowed_tools only pre-approves permissions; disallowed_tools actually
# removes tools from the model, which matters with bypassPermissions.
DISALLOWED_ASSISTANT_TOOLS = [
# File mutation / execution
"Write",
"Edit",
"MultiEdit",
"NotebookEdit",
"Bash",
# Feature MCP tools reserved for coding agents
"mcp__features__feature_mark_passing",
"mcp__features__feature_mark_failing",
"mcp__features__feature_mark_in_progress",
"mcp__features__feature_claim_and_get",
"mcp__features__feature_clear_in_progress",
]


def get_system_prompt(project_name: str, project_dir: Path) -> str:
"""Generate the system prompt for the assistant with project context."""
Expand Down Expand Up @@ -105,11 +124,11 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:

## What You CANNOT Do

- Modify, create, or delete source code files
- Mark features as passing (that requires actual implementation by the coding agent)
- Modify, create, or delete source code files -- you have NO Write/Edit/Bash tools
- Mark features as passing or failing (that is the coding agent's job after real implementation)
- Run bash commands or execute code

If the user asks you to modify code, explain that you're a project assistant and they should use the main coding agent for implementation.
**You must NEVER implement code yourself.** Even if the user pastes code, asks for a "quick fix", or insists -- do not attempt to write or edit files. Implementation is done exclusively by the coding agents. When the user wants code changed or a bug fixed, create a feature for it with `feature_create` (or `feature_create_bulk`) describing the change, then tell the user to start the coding agent to implement it.

## Project Specification

Expand Down Expand Up @@ -234,6 +253,7 @@ async def start(self) -> AsyncGenerator[dict, None]:
"permissions": {
"defaultMode": "bypassPermissions", # Read-only, no dangerous ops
"allow": permissions_list,
"deny": ["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash"],
},
}
from autoforge_paths import get_claude_assistant_settings_path
Expand Down Expand Up @@ -288,6 +308,7 @@ async def start(self) -> AsyncGenerator[dict, None]:
# This avoids Windows command line length limit (~8191 chars)
setting_sources=["project"],
allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS],
disallowed_tools=DISALLOWED_ASSISTANT_TOOLS,
mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime
permission_mode="bypassPermissions",
max_turns=100,
Expand All @@ -302,7 +323,7 @@ async def start(self) -> AsyncGenerator[dict, None]:
logger.info("Claude client ready")
except Exception as e:
logger.exception("Failed to create Claude client")
yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"}
yield {"type": "error", "content": format_client_init_error(e)}
return

# Send initial greeting only for NEW conversations
Expand Down
29 changes: 29 additions & 0 deletions server/services/chat_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,35 @@ def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
return False, None


def format_client_init_error(exc: Exception) -> str:
"""Build an actionable error message for Claude client startup failures.

The Claude Agent SDK raises an opaque ``Control request timeout:
initialize`` when the Claude CLI subprocess never completes the
initialize handshake (auth problems, broken/missing CLI, PATH issues,
unsupported Python, antivirus interference, slow machines). Translate
that into concrete guidance instead of surfacing the raw error.
"""
if "Control request timeout" in str(exc):
msg = (
"Claude client timed out during startup (the Claude CLI did not "
"respond to the initialize handshake). Common causes:\n"
"- Claude CLI not authenticated: run `claude login` or set ANTHROPIC_API_KEY\n"
"- Claude CLI not installed or outdated: run `npm install -g @anthropic-ai/claude-code`\n"
"- Antivirus/firewall blocking the CLI subprocess (common on Windows)\n"
"- Slow machine: raise the timeout by setting the environment variable "
"CLAUDE_CODE_STREAM_CLOSE_TIMEOUT=120000 before starting AutoForge"
)
if sys.version_info < (3, 11):
py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
msg = (
f"You are running Python {py}, but AutoForge requires Python 3.11+. "
"Recreate your virtual environment with Python 3.11+ and try again.\n\n" + msg
)
return msg
return f"Failed to initialize Claude: {exc}"


async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenerator:
"""Wrap ``client.receive_response()`` to skip ``MessageParseError``.

Expand Down
9 changes: 6 additions & 3 deletions server/services/expand_chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
ROOT_DIR,
build_attachment_content_blocks,
check_rate_limit_error,
format_client_init_error,
make_multimodal_message,
safe_receive_response,
)
Expand Down Expand Up @@ -135,7 +136,7 @@ async def start(self) -> AsyncGenerator[dict, None]:

# Create temporary security settings file (unique per session to avoid conflicts)
# Note: permission_mode="bypassPermissions" is safe here because:
# 1. Only Read/Glob file tools are allowed (no Write/Edit)
# 1. Write/Edit/Bash are removed via disallowed_tools and the "deny" list below
# 2. MCP tools are restricted to feature creation only
# 3. No Bash access - cannot execute arbitrary commands
security_settings = {
Expand All @@ -147,6 +148,7 @@ async def start(self) -> AsyncGenerator[dict, None]:
"Glob(./**)",
*EXPAND_FEATURE_TOOLS,
],
"deny": ["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash"],
},
}
from autoforge_paths import get_expand_settings_path
Expand Down Expand Up @@ -196,6 +198,7 @@ async def start(self) -> AsyncGenerator[dict, None]:
"WebSearch",
*EXPAND_FEATURE_TOOLS,
],
disallowed_tools=["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash"],
mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime
permission_mode="bypassPermissions",
max_turns=100,
Expand All @@ -206,11 +209,11 @@ async def start(self) -> AsyncGenerator[dict, None]:
)
await self.client.__aenter__()
self._client_entered = True
except Exception:
except Exception as e:
logger.exception("Failed to create Claude client")
yield {
"type": "error",
"content": "Failed to initialize Claude"
"content": format_client_init_error(e)
}
return

Expand Down
3 changes: 2 additions & 1 deletion server/services/spec_chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
ROOT_DIR,
build_attachment_content_blocks,
check_rate_limit_error,
format_client_init_error,
make_multimodal_message,
safe_receive_response,
)
Expand Down Expand Up @@ -185,7 +186,7 @@ async def start(self) -> AsyncGenerator[dict, None]:
logger.exception("Failed to create Claude client")
yield {
"type": "error",
"content": f"Failed to initialize Claude: {str(e)}"
"content": format_client_init_error(e)
}
return

Expand Down
142 changes: 142 additions & 0 deletions server/utils/ws_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""
WebSocket Origin Validation
===========================

Starlette's ``@app.middleware("http")`` decorator only runs for HTTP requests,
so the ``require_localhost`` guard in ``server/main.py`` never sees WebSocket
handshakes. Browsers also do not enforce same-origin policy on WebSocket
connections, which left every WS endpoint (including the interactive PTY
terminal) open to cross-site WebSocket hijacking (CSWSH): any malicious web
page the user visited could open ``ws://127.0.0.1:8888/api/terminal/ws/...``
and drive a shell (CWE-306).

This module provides a pure-ASGI middleware that validates the ``Origin``
header on every WebSocket handshake before the route handler runs. Handshakes
from disallowed origins are rejected with close code 4403 and never reach the
application, so no PTY/session is ever created.

Non-browser clients (CLI tools, native apps) do not send an ``Origin`` header
and are allowed through: the CSWSH threat model is browser-only, and raw
socket access is governed by the localhost bind / ``AUTOFORGE_ALLOW_REMOTE``.
"""

import logging
from collections.abc import Awaitable, Callable, MutableMapping
from typing import Any
from urllib.parse import urlparse

logger = logging.getLogger(__name__)

# ASGI type aliases (kept loose to avoid a hard dependency on starlette.types)
Scope = MutableMapping[str, Any]
Message = MutableMapping[str, Any]
Receive = Callable[[], Awaitable[Message]]
Send = Callable[[Message], Awaitable[None]]

# WebSocket close code used when rejecting a disallowed origin.
# 4000-4999 is the range reserved for application-specific codes.
WS_CLOSE_FORBIDDEN = 4403

_LOCALHOST_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"})


def _hostname_from_origin(origin: str) -> str | None:
"""Extract the lowercase hostname from an Origin header value."""
try:
return urlparse(origin).hostname
except ValueError:
return None


def _hostname_from_host(host: str) -> str | None:
"""Extract the lowercase hostname from a Host header value (strips port)."""
try:
# Prefix with // so urlparse treats the value as a netloc
# (handles bracketed IPv6 literals like [::1]:8888).
return urlparse(f"//{host}").hostname
except ValueError:
return None


def is_allowed_ws_origin(origin: str | None, allow_remote: bool, host: str | None = None) -> bool:
"""Decide whether a WebSocket handshake with the given Origin is allowed.

Rules:

* No ``Origin`` header (non-browser client such as a CLI or native app):
allowed. CSWSH is a browser-only threat, and non-browser network access
is already gated by the localhost bind / AUTOFORGE_ALLOW_REMOTE opt-in.
* Origin hostname is a localhost variant (``localhost``, ``127.0.0.1``,
``::1``): allowed regardless of scheme or port. This covers the Vite
dev server (5173) and the production server (8888).
* ``allow_remote`` is True: additionally allow origins whose hostname
matches the host the browser connected to (the ``Host`` header), so the
remotely-served UI keeps working while foreign pages are still rejected.
* Everything else: rejected.

Args:
origin: Value of the Origin header, or None if absent.
allow_remote: Whether AUTOFORGE_ALLOW_REMOTE is enabled.
host: Value of the Host header (used only when allow_remote is True).

Returns:
True if the handshake should proceed, False if it must be rejected.
"""
if not origin:
return True

hostname = _hostname_from_origin(origin)
if not hostname:
return False

if hostname in _LOCALHOST_HOSTNAMES:
return True

if allow_remote and host:
host_name = _hostname_from_host(host)
return host_name is not None and hostname == host_name

return False


class WebSocketOriginMiddleware:
"""Pure-ASGI middleware that rejects WS handshakes from disallowed origins.

Implemented as raw ASGI (not ``@app.middleware("http")``) because
Starlette's http middleware decorator is never invoked for scope type
``websocket`` -- which is exactly the gap that left the WS endpoints
unprotected. HTTP and lifespan scopes pass through untouched.
"""

def __init__(self, app: Any, allow_remote: bool = False) -> None:
self.app = app
self.allow_remote = allow_remote

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "websocket":
await self.app(scope, receive, send)
return

origin: str | None = None
host: str | None = None
for name, value in scope.get("headers", []):
if name == b"origin":
origin = value.decode("latin-1")
elif name == b"host":
host = value.decode("latin-1")

if not is_allowed_ws_origin(origin, self.allow_remote, host):
logger.warning(
"Rejected WebSocket handshake from disallowed origin %r (path=%s)",
origin,
scope.get("path"),
)
# Refuse the handshake without ever accepting: consume the
# connect event, then close. The route handler never runs,
# so no terminal/chat session is created.
message = await receive()
if message["type"] == "websocket.connect":
await send({"type": "websocket.close", "code": WS_CLOSE_FORBIDDEN})
return

await self.app(scope, receive, send)
12 changes: 12 additions & 0 deletions start.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@
import sys
from pathlib import Path

# Fail fast on unsupported Python versions. Older interpreters surface as
# opaque Claude SDK errors (e.g. "Control request timeout: initialize").
if sys.version_info < (3, 11):
sys.exit(
"ERROR: AutoForge requires Python 3.11 or newer "
f"(you are running Python {sys.version.split()[0]}). "
"Older versions cause opaque Claude SDK errors such as "
"'Control request timeout: initialize'. "
"Recreate your virtual environment with Python 3.11+ "
"(python3.11 -m venv venv && pip install -r requirements.txt)."
)

from dotenv import load_dotenv

from auth import is_auth_error, print_auth_error_help
Expand Down
Loading
Loading