Skip to content
Draft
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
22 changes: 22 additions & 0 deletions .env.quickstart.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,28 @@ OPENROUTER_API_KEY=
# LOCAL — pre-filled with sensible quickstart defaults; change if needed
# =============================================================================

# BACKEND_RUNTIME
# What: Where/how the backend service runs — and therefore what isolates the
# agents from your machine.
# "docker" = run in a container; the container is the isolation
# boundary (default, recommended).
# "process" = run the backend directly on this host (no Docker). You
# must already have the developer environment installed
# (Python 3.14, uv, `cd backend && uv sync`), and the host
# OS sandbox (bubblewrap+socat on Linux, Seatbelt on macOS)
# confines the agents instead. Refuses to start a generation
# if that sandbox is unavailable (fail closed).
# How: Normally you don't set this here. On first launch the TUI asks which
# runtime to use and remembers the pick in .specflow-local/backend-runtime
# (a local-launcher file — NOT mcp-config.json, since the MCP server just
# calls the backend and doesn't care how it's launched). You can also
# switch runtimes from the dashboard. Setting BACKEND_RUNTIME as an
# exported environment variable overrides both (highest precedence after
# an explicit CLI flag); a value in this .env file alone is NOT read by
# the launcher's runtime gate.
# Docs: docs/backend/backend-runtime.md
# BACKEND_RUNTIME=docker

# AUTH_MODE
# What: Controls how the backend authenticates inbound requests.
# "local" = single-user mode, no API key header required.
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ agents/plans/*/
# Documentation archive
docs/archive
scripts/archive
docs/internal

# Contributor-local E2E workspace pool config (may hold private repo URLs).
# The committed template is e2e-workspace-config.example.json.
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,12 @@ Both layers return the same error shape so the user experience is identical rega
| `E2E_PLAN_UNPARSEABLE` | JSON conversion of the e2e plan fails | "Couldn't parse `e2e-test-plan.md` into rounds. Re-run `run_planning` — check that each round has a heading and verification steps." |
| `GENERATION_ALREADY_RUNNING` | A generation for this project is already in progress | "A generation is already running. Wait for the email notification before starting another one." |
| `MODEL_UNAVAILABLE` | A configured LLM tier has a model that isn't available on the active provider (OpenRouter/Anthropic per `DEFAULT_PROVIDER`) | "The model(s) configured for {tier} aren't available on {provider}: {models}. Did you mean '{suggestion}'? Fix {tier} in your MCP config and try again." |
| `SANDBOX_UNAVAILABLE` | `BACKEND_RUNTIME=process` but the host OS agent sandbox (bubblewrap+socat on Linux / Seatbelt on macOS) can't initialize | "Linux sandbox dependencies missing: {deps}. Install with `sudo apt-get install bubblewrap socat` … " (macOS/platform variants per `os_sandbox.check_agent_sandbox_available`). |

`MODEL_UNAVAILABLE` semantics (catalog fetch, block-on-any-invalid policy, the two gate locations, and why it doesn't release workspaces) are documented in `docs/backend/model-validation.md`.

`SANDBOX_UNAVAILABLE` semantics (the `BACKEND_RUNTIME` docker/process split, the fail-closed OS agent sandbox, the two gate locations, supported platforms, and residual risks) are documented in `docs/backend/backend-runtime.md`.

**Rules for implementers:**
- Return shape from MCP tool: `{"error": "<message>", "code": "<CODE>", "missing_files": [...], "ambiguous": [...]}`. The IDE displays the message; the structured fields exist for tooling.
- Never proceed to backend call if MCP-side precheck failed.
Expand Down
14 changes: 13 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: run stop stop-test clean help base build ops-retry-run ops-cancel-run check-complexity check-complexity-diff check-complexity-cc check-complexity-mi secret-scan secret-scan-history secret-scan-gitleaks secret-scan-trufflehog skip-mode-e2e-tests contract-validation-e2e-tests shutdown-recovery-e2e-tests real-e2e-tests quickstart require-e2e-workspace-config
.PHONY: run run-process stop-process stop stop-test clean help base build ops-retry-run ops-cancel-run check-complexity check-complexity-diff check-complexity-cc check-complexity-mi secret-scan secret-scan-history secret-scan-gitleaks secret-scan-trufflehog skip-mode-e2e-tests contract-validation-e2e-tests shutdown-recovery-e2e-tests real-e2e-tests quickstart require-e2e-workspace-config

# Default target
.DEFAULT_GOAL := build
Expand Down Expand Up @@ -137,6 +137,18 @@ run-detached-skip: build
@echo "⏭️ Agent execution: SKIPPED (testing mode)"
WORKSPACE_MOUNT_PATH=$(WORKSPACE_MOUNT_PATH) SKIP_AGENT_EXECUTION=true docker-compose up -d --no-build

# Bare-metal backend (BACKEND_RUNTIME=process) — no Docker. Requires the developer
# environment already installed (Python 3.14, uv, `cd backend && uv sync`) and the
# host OS sandbox (bubblewrap on Linux / Seatbelt on macOS). Starts detached with a
# fail-closed sandbox preflight; see docs/backend/backend-runtime.md.
run-process:
@echo "🚀 Starting backend bare-metal (BACKEND_RUNTIME=process)..."
@cd mcp_server && uv run python -c "import asyncio, sys; from pathlib import Path; from services import local_env; sys.exit(asyncio.run(local_env.run_backend_process_cli(Path('$(CURDIR)'))))"

# Stop a detached bare-metal backend started by run-process (or the TUI).
stop-process:
@cd mcp_server && uv run python -c "from pathlib import Path; from services import local_env; print('🛑 stopped' if local_env.stop_backend_process(Path('$(CURDIR)')) else 'ℹ️ no running backend process')"

# Stop ONLY the isolated local-testing stack (project: specflow-test) and wipe its ephemeral
# workspace/database state. Quickstart is stopped outside this Make target.
stop:
Expand Down
6 changes: 6 additions & 0 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ in spec_dir "my_specs", and outputs dir in "results"
- `curl`
- Cursor, Claude Code, Claude Desktop, Copilot, or another MCP-capable IDE

> Docker is the default and what this guide assumes. To run the backend without
> Docker (bare-metal, agents confined by the host OS sandbox), set
> `BACKEND_RUNTIME=process` — you then need the developer environment installed
> and, on Linux, `bubblewrap`+`socat`. See
> [`docs/backend/backend-runtime.md`](docs/backend/backend-runtime.md).

## 3. Required `.env` Values

Keep first setup focused. Fill only these values before running `specflow init`:
Expand Down
174 changes: 174 additions & 0 deletions backend/app/agents_sandboxing/os_sandbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""OS-level agent sandbox for ``BACKEND_RUNTIME=process``.

In ``docker`` mode the container is the isolation boundary and this module is a
no-op (Docker behaviour is unchanged). In ``process`` mode the backend runs on
the bare host, so the container boundary is gone; we substitute Claude Code's
built-in OS-level Bash sandbox (bubblewrap on Linux, Apple Seatbelt on macOS),
enabled per agent query via ``ClaudeAgentOptions.sandbox``.

This confines each agent's Bash subprocesses — the exact surface that escapes the
in-process allowlist and PreToolUse guard (``agent_hooks.py`` notes "``python
script.py`` is not caught … the real boundary is the sandbox"). It is an added
OS-enforced layer, **not** a replacement for the existing in-process controls
(defense in depth).

Security posture: **fail closed**. When process mode is active but the sandbox
cannot initialise (missing dependency / unsupported OS), ``run_generation``
refuses synchronously rather than running agents unconfined on the host.
"""

from __future__ import annotations

import os
import shutil
import sys
from dataclasses import dataclass

from claude_agent_sdk import SandboxNetworkConfig, SandboxSettings

from app.core.config import WORKSPACE_CACHE_SUBDIR, settings
from app.core.enums import BackendRuntime

# Curated network allowlist for sandboxed agent Bash commands (allow-only). Kept
# deliberately tight — see the domain-fronting / exfiltration warning in Claude
# Code's sandbox docs: only the package registries and git host the supported
# toolchains need to fetch dependencies during generation. The LLM API is NOT
# listed because the Claude CLI process itself runs OUTSIDE the Bash sandbox, so
# model connectivity is unaffected by this list. Override via
# ``settings.AGENT_SANDBOX_ALLOWED_DOMAINS`` (comma-separated).
DEFAULT_AGENT_SANDBOX_ALLOWED_DOMAINS: tuple[str, ...] = (
# Python
"pypi.org",
"files.pythonhosted.org",
# Node
"registry.npmjs.org",
# Go
"proxy.golang.org",
"sum.golang.org",
# Java / Gradle / Android
"repo.maven.apache.org",
"repo1.maven.org",
"plugins.gradle.org",
"services.gradle.org",
"dl.google.com",
# Dart / Flutter
"pub.dev",
# Git host + release/object CDNs
"github.com",
"*.github.com",
"codeload.github.com",
"raw.githubusercontent.com",
"objects.githubusercontent.com",
# Shared object storage used by several toolchains (go, flutter, gradle)
"storage.googleapis.com",
)

# Commands known to be incompatible with the sandbox → run outside it. ``docker``
# is documented-incompatible; deploy/QA agents already gate docker/kubectl
# separately (see tool_usage.DEPLOY_BASH_USAGE). Kept minimal per the exfil warning.
_SANDBOX_EXCLUDED_COMMANDS: tuple[str, ...] = ("docker",)


@dataclass(frozen=True)
class SandboxUnavailable:
"""Why the OS sandbox can't run on this host, with an actionable fix message."""

dependency: str
message: str


def _allowed_domains() -> list[str]:
raw = settings.AGENT_SANDBOX_ALLOWED_DOMAINS
if raw and raw.strip():
return [domain.strip() for domain in raw.split(",") if domain.strip()]
return list(DEFAULT_AGENT_SANDBOX_ALLOWED_DOMAINS)


def get_agent_sandbox_settings() -> SandboxSettings | None:
"""``SandboxSettings`` for agent queries, or ``None`` when no OS sandbox is engaged.

Returns ``None`` in DOCKER mode (the container is the boundary — behaviour is
unchanged). In PROCESS mode returns a fail-closed, allow-only sandbox that
confines agent Bash subprocesses (and their children) at the OS level. Writes
are confined by the SDK to the query ``cwd`` (already the workspace) plus the
session temp dir, reinforcing the existing ``Write/Edit({workspace}/**)``
allowlist at the OS level.
"""
if settings.BACKEND_RUNTIME != BackendRuntime.PROCESS:
return None
network: SandboxNetworkConfig = {"allowedDomains": _allowed_domains()}
return SandboxSettings(
enabled=True,
autoAllowBashIfSandboxed=True,
# Fail closed: no dangerouslyDisableSandbox escape hatch — a command that
# cannot run sandboxed fails rather than silently running on the bare host.
allowUnsandboxedCommands=False,
excludedCommands=list(_SANDBOX_EXCLUDED_COMMANDS),
network=network,
)


def get_agent_sandbox_write_allowlist() -> list[str]:
"""Extra ``Edit``/``Write`` tool rules that widen the sandbox writable set in
process mode; empty in docker mode (no sandbox engaged).

The OS Bash sandbox only allows writes to the query ``cwd`` (the workspace) and
the session temp dir. But SpecFlow redirects every tool cache
(``setup_workspace_cache_directories``) to ``{WORKSPACE_BASE_PATH}/caches/…``,
which sits OUTSIDE ``cwd`` — so ``npm install`` / ``pip install`` / ``go mod
download`` would be denied write access under the sandbox. The Claude Agent SDK
intentionally has no ``SandboxSettings.filesystem`` field and directs filesystem
write scope through ``Edit`` allow-rules (see ``SandboxSettings`` docstring),
which merge into the subprocess writable set. Granting the caches subtree here
is strictly tighter than docker mode (where bash writes are unrestricted).
"""
if settings.BACKEND_RUNTIME != BackendRuntime.PROCESS:
return []
caches_root = os.path.join(settings.WORKSPACE_BASE_PATH, WORKSPACE_CACHE_SUBDIR)
# Same single-slash absolute glob form as workspace_usage() in claude_code.py.
return [f"Edit({caches_root}/**)", f"Write({caches_root}/**)"]


def check_agent_sandbox_available() -> SandboxUnavailable | None:
"""Return ``None`` if the OS sandbox can run on this host, else why not.

- macOS: Seatbelt via the built-in ``sandbox-exec``.
- Linux: bubblewrap (``bwrap``) for filesystem/namespace isolation and
``socat`` for the network proxy relay.

Only meaningful in PROCESS mode; DOCKER mode always returns ``None`` (the
container is the boundary, so host sandbox tooling is irrelevant).
"""
if settings.BACKEND_RUNTIME != BackendRuntime.PROCESS:
return None
if sys.platform == "darwin":
if shutil.which("sandbox-exec") is None:
return SandboxUnavailable(
dependency="sandbox-exec",
message=(
"macOS sandbox tool `sandbox-exec` was not found on PATH. It ships "
"with macOS — ensure /usr/bin is on PATH."
),
)
return None
if sys.platform.startswith("linux"):
missing = [dep for dep in ("bwrap", "socat") if shutil.which(dep) is None]
if missing:
return SandboxUnavailable(
dependency=", ".join(missing),
message=(
f"Linux sandbox dependencies missing: {', '.join(missing)}. Install with "
"`sudo apt-get install bubblewrap socat` (Debian/Ubuntu) or "
"`sudo dnf install bubblewrap socat` (Fedora). On Ubuntu 24.04+ you may "
"also need an AppArmor profile allowing unprivileged user namespaces for "
"`bwrap` (see docs/backend/backend-runtime.md)."
),
)
return None
return SandboxUnavailable(
dependency=sys.platform,
message=(
f"The agent OS sandbox is not supported on this platform ({sys.platform}). Use "
"BACKEND_RUNTIME=docker, or run the backend on macOS or Linux."
),
)
20 changes: 20 additions & 0 deletions backend/app/api/v1/generation_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
from app.schemas.telemetry_workflow import PhaseKind
from app.schemas.model_validation import TierValidation, TierValidationStatus
from app.schemas.specification import GenerationWorkflowRequest
from app.agents_sandboxing.os_sandbox import check_agent_sandbox_available
from app.services.contract_validator import RejectionCode
from app.services.model_validation import validate_models_config
from app.services.generation_session import (
Expand Down Expand Up @@ -1464,6 +1465,25 @@ async def run_generation_session(
workflow_llm_overrides = build_llm_overrides(LLM_HIGH=LLM_HIGH, LLM_MEDIUM=LLM_MEDIUM, LLM_LOW=LLM_LOW)
generation_session_params.update(workflow_llm_overrides)

# Authoritative agent-sandbox gate (BACKEND_RUNTIME=process only). With no
# container boundary, agents must be confined by the OS-level Bash sandbox;
# if it can't initialise we refuse here rather than run agents unconfined on
# the host (fail closed). Like the model gate this is a synchronous rejection,
# not a state-machine fail() — workspaces stay allocated; the user installs the
# dependency and calls run_generation again. See docs/backend/backend-runtime.md.
sandbox_unavailable = check_agent_sandbox_available()
if sandbox_unavailable is not None:
logger.warning(
f"run_generation rejected (SANDBOX_UNAVAILABLE): {sandbox_unavailable.message}"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": sandbox_unavailable.message,
"code": RejectionCode.SANDBOX_UNAVAILABLE.value,
},
)

# Authoritative model gate (defense-in-depth; the MCP server also pre-flights this).
# A 2–8h run must not start against a model the active provider doesn't offer.
# Block-on-any-invalid; an unverifiable catalog (no key / outage) never blocks.
Expand Down
28 changes: 27 additions & 1 deletion backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pydantic import AliasChoices, Field, computed_field, field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict

from app.core.enums import AuthMode, DatabaseType, LLMProvider
from app.core.enums import AuthMode, BackendRuntime, DatabaseType, LLMProvider

# MCP server ids enabled for agent workflows when listed in MCP_SERVERS_ENABLED (comma-separated).
# User-supplied names outside this set are ignored. See docs/agents/enabled-mcps.md.
Expand All @@ -29,6 +29,11 @@
# SQLITE_DB_PATH (via env) to address the same bind-mounted file by its real host path.
CONTAINER_SQLITE_DB_PATH = "/root/.specflow/db/specflow.db"

# Subdirectory under WORKSPACE_BASE_PATH holding per-workspace + shared tool caches
# (npm/pip/go/gradle/…). Single source of truth shared by claude_code's cache-dir
# setup and the agent OS-sandbox write allowlist (os_sandbox), so the two never drift.
WORKSPACE_CACHE_SUBDIR = "caches"

# Single source of truth for the P10Y/Compass endpoint.
P10Y_DEFAULT_BASE_URL = "https://compass.p10y.com"

Expand Down Expand Up @@ -341,6 +346,16 @@ def _empty_str_to_none_int(cls, v: object) -> object:
GITHUB_TEAM_SLUG: Optional[str] = None
WORKSPACE_REPO_PREFIX: str = "specflow-workspace"

# Backend runtime / agent OS-sandbox settings.
# DOCKER (default): the container is the isolation boundary; no in-process
# agent sandbox is engaged (decoupled — Docker behaviour is unchanged).
# PROCESS: the backend runs bare-metal, so agents are confined by the OS-level
# Bash sandbox. See app/agents_sandboxing/os_sandbox.py.
BACKEND_RUNTIME: BackendRuntime = BackendRuntime.DOCKER
# Optional comma-separated override for the agent sandbox network allowlist
# (see os_sandbox.DEFAULT_AGENT_SANDBOX_ALLOWED_DOMAINS). Empty → use default.
AGENT_SANDBOX_ALLOWED_DOMAINS: Optional[str] = None

model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
Expand All @@ -359,6 +374,17 @@ def _validate_database_type(cls, v: object) -> object:
)
return v

@field_validator("BACKEND_RUNTIME", mode="before")
@classmethod
def _validate_backend_runtime(cls, v: object) -> object:
if isinstance(v, str):
allowed = {member.value for member in BackendRuntime}
if v not in allowed:
raise ValueError(
f"Invalid BACKEND_RUNTIME: {v!r}. Allowed values: {sorted(allowed)}"
)
return v

@model_validator(mode="after")
def _reject_local_auth_in_protected_environments(self) -> "Settings":
if self.AUTH_MODE != AuthMode.LOCAL:
Expand Down
16 changes: 16 additions & 0 deletions backend/app/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,19 @@ class DatabaseType(StrEnum):
EMULATOR = "emulator"
FIRESTORE = "firestore"
SQLITE = "sqlite"


class BackendRuntime(StrEnum):
"""Where/how the backend service is launched — and therefore what provides
the OS-level isolation boundary around the agents.

- ``DOCKER`` (default): the backend runs in a container; the container *is*
the boundary, so no in-process agent sandbox is engaged.
- ``PROCESS``: the backend runs directly on the host (bare-metal uvicorn);
the container boundary is gone, so agents must be confined by the OS-level
Bash sandbox (bubblewrap on Linux, Seatbelt on macOS). See
``app/agents_sandboxing/os_sandbox.py``.
"""

DOCKER = "docker"
PROCESS = "process"
Loading