Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ this file. The format follows Keep a Changelog, and versioned releases follow
Semantic Versioning where the repository publishes a release.

## [Unreleased]
- Web verification now runs backend, frontend, and E2E commands in an isolated
workspace and accepts only local readiness URLs. Run it on a supported Linux
runner; trusted local debugging may opt out with `--isolation disabled`.
- Invalid readiness URLs and unavailable isolation now fail with clear
diagnostics before services start, so update the URL or runner instead of
retrying the same setup.
- Route Strix cross-provider fallbacks to explicit direct-OpenAI models
(`openai-direct/...`) through the OpenAI inference endpoint instead of
inheriting a provider-specific primary base: the workflow now provisions
Expand Down
30 changes: 30 additions & 0 deletions docs/doctoring/sandboxed-web-command-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Sandboxed web command isolation

`sandboxed_web_e2e.py` requires Linux `bubblewrap` (`bwrap`) by default. Each
backend, frontend, and E2E command runs with a fresh writable `tmpfs` root and
`/tmp`, plus one writable copied-repository bind at `/workspace`; the copied
repository and temporary homes are mapped there. Host runtime roots and the
minimal `/etc` identity, DNS, and time files are mounted read-only, so the host
filesystem is not reachable through absolute paths or `..` traversal.

Before wrapping a command, the helper resolves its executable and rejects paths
outside the read-only system roots mounted by bubblewrap. A tool installed in a
host-only location must be installed into one of those roots or the run exits
with code `126` before any service starts; the result marker records that code
and the selected backend.

Use `--isolation disabled` only for trusted local debugging. The result marker
records the requested mode and resolved backend so CI evidence cannot be
mistaken for an OS-isolated run. If required isolation is unavailable, the
command exits with code `126` before starting any service.

Readiness polling remains loopback-only and does not follow redirects. Invalid
readiness URLs are reported as a coded readiness failure (`125`) rather than an
uncaught traceback. The network declaration is evidence metadata; callers that
need stronger network policy must run this helper inside a network-restricted
runner or container.

## References

MITRE. (2026). *CWE-918: Server-side request forgery (SSRF)*.
https://cwe.mitre.org/data/definitions/918.html
190 changes: 182 additions & 8 deletions scripts/ci/sandboxed_web_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
from __future__ import annotations

import argparse
import ipaddress
import json
import os
import platform
import signal
import shutil
import shlex
Expand All @@ -13,6 +15,7 @@
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Sequence
from dataclasses import dataclass
Expand All @@ -25,6 +28,7 @@


RESULT_MARKER = "SANDBOXED_WEB_E2E_RESULT"
SANDBOX_MOUNT = "/workspace"


class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
Expand Down Expand Up @@ -63,6 +67,15 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser.add_argument("--startup-timeout", type=int, default=120, help="Seconds to wait for readiness URLs.")
parser.add_argument("--e2e-timeout", type=int, default=600, help="Seconds to allow the E2E command to run.")
parser.add_argument("--keep-sandbox", action="store_true", help="Keep the temporary sandbox after execution.")
parser.add_argument(
"--isolation",
choices=("required", "disabled"),
default="required",
help=(
"Require a bubblewrap OS sandbox (the default). Use disabled only for "
"trusted local debugging when bubblewrap is unavailable."
),
)
Comment thread
seonghobae marked this conversation as resolved.
parser.add_argument(
"--allow-env",
action="append",
Expand Down Expand Up @@ -98,6 +111,88 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
return args


def isolation_backend(mode: str) -> str | None:
"""Resolve the requested OS isolation backend without silently downgrading."""
if mode == "disabled":
return None
if platform.system() != "Linux":
raise RuntimeError("required isolation is only supported on Linux with bubblewrap")
backend = shutil.which("bwrap")
if backend is None:
raise RuntimeError("required isolation needs bubblewrap (bwrap) on PATH")
return backend


def _sandbox_environment(env: dict[str, str], sandbox_root: Path) -> dict[str, str]:
"""Map host sandbox paths to the path exposed inside the bubblewrap mount."""
source = str(sandbox_root)
mapped = dict(env)
for key in ("HOME", "TMPDIR", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME"):
value = mapped.get(key)
if value:
mapped[key] = value.replace(source, SANDBOX_MOUNT, 1)
return mapped
Comment thread
seonghobae marked this conversation as resolved.


def isolated_command(
command: str,
*,
backend: str,
cwd: Path,
sandbox_root: Path,
env: dict[str, str],
) -> str:
"""Wrap one command in a read-only-root bubblewrap workspace."""
argv = shlex.split(command)
if not argv:
raise ValueError("command must not be empty")
bind_roots = [
Path(path)
for path in ("/usr", "/bin", "/sbin", "/lib", "/lib64", "/opt")
if Path(path).exists()
]
executable = shutil.which(argv[0], path=env.get("PATH"))
if executable is not None:
executable_path = Path(executable)
if executable_path.is_relative_to(Path.home()):
raise RuntimeError("commands from the host home directory are not allowed in isolation")
if not any(executable_path.is_relative_to(root) for root in bind_roots):
raise RuntimeError(
f"executable is outside the isolated bind roots: {executable_path}"
)
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
args = [backend, "--die-with-parent", "--new-session", "--unshare-pid", "--tmpfs", "/"]
Comment thread
seonghobae marked this conversation as resolved.
for root in bind_roots:
args.extend(("--ro-bind", str(root), str(root)))
for path in (
"/etc/ssl",
"/etc/hosts",
"/etc/resolv.conf",
"/etc/localtime",
"/etc/passwd",
"/etc/group",
"/etc/nsswitch.conf",
):
if Path(path).exists():
args.extend(("--ro-bind", path, path))
Comment thread
seonghobae marked this conversation as resolved.
args.extend(
(
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
"--bind",
str(sandbox_root),
SANDBOX_MOUNT,
"--chdir",
f"{SANDBOX_MOUNT}/{cwd.relative_to(sandbox_root)}",
"--",
)
)
return shlex.join([*args, *argv])
Comment thread
seonghobae marked this conversation as resolved.


def start_service(label: str, command: str, cwd: Path, env: dict[str, str], logs_dir: Path) -> Service:
"""Start a service command in its own process group."""
log_path = logs_dir / f"{label}.log"
Expand All @@ -115,12 +210,29 @@ def start_service(label: str, command: str, cwd: Path, env: dict[str, str], logs
return Service(label=label, command=command, process=process, log_path=log_path)


def wait_for_url(url: str, timeout: int, service: Service) -> bool:
"""Poll a readiness URL until it responds or the service exits."""
def validate_readiness_url(url: str) -> None:
"""Reject a readiness URL that is not an HTTP(S) loopback target."""
if not url:
return True
return
if not (url.startswith("http://") or url.startswith("https://")):
raise ValueError(f"URL must start with http:// or https://, got: {url}")

parsed = urllib.parse.urlparse(url)
hostname = (parsed.hostname or "").lower()
try:
is_loopback = hostname == "localhost" or ipaddress.ip_address(hostname).is_loopback
except ValueError:
is_loopback = False
if not is_loopback:
raise ValueError(f"URL cannot target external hostname: {hostname}")
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.


def wait_for_url(url: str, timeout: int, service: Service) -> bool:
"""Poll a validated readiness URL until it responds or the service exits."""
validate_readiness_url(url)
if not url:
return True

deadline = time.monotonic() + timeout
opener = urllib.request.build_opener(NoRedirectHandler())
while time.monotonic() < deadline:
Comment thread
seonghobae marked this conversation as resolved.
Expand All @@ -130,6 +242,7 @@ def wait_for_url(url: str, timeout: int, service: Service) -> bool:
with opener.open(url, timeout=2) as response: # nosec B310
if 200 <= response.status < 500:
return True
time.sleep(1)
except (urllib.error.URLError, TimeoutError):
time.sleep(1)
return False
Expand Down Expand Up @@ -195,6 +308,8 @@ def emit_result(
"frontend_cmd": args.frontend_cmd,
"frontend_ready": frontend_ready,
"network": args.network,
"isolation": args.isolation,
"isolation_backend": getattr(args, "isolation_backend", "unknown"),
"sandbox": str(sandbox_root) if args.keep_sandbox else "(removed)",
"sandboxed": True,
}
Expand All @@ -216,21 +331,80 @@ def main(argv: Sequence[str] | None = None) -> int:
try:
copied_repo = sandboxed_verify.copy_workspace(Path(args.repo_root), sandbox, args.ignore)
env = sandboxed_verify.scrubbed_env(sandbox, args.allow_env)
try:
backend = isolation_backend(args.isolation)
except RuntimeError as exc:
print(f"sandboxed-web-e2e: {exc}", file=sys.stderr)
args.isolation_backend = "unavailable"
exit_code = 126
return exit_code
args.isolation_backend = backend or "disabled"
print(f"sandboxed-web-e2e: cwd={copied_repo}")
if args.allow_env:
print(f"sandboxed-web-e2e: allowed env names={','.join(sorted(set(args.allow_env)))}")
if args.network != "default":
print(f"sandboxed-web-e2e: network={args.network}")
services.append(start_service("backend", args.backend_cmd, copied_repo, env, logs_dir))
services.append(start_service("frontend", args.frontend_cmd, copied_repo, env, logs_dir))
backend_ready = wait_for_url(args.backend_ready_url, args.startup_timeout, services[0])
frontend_ready = wait_for_url(args.frontend_ready_url, args.startup_timeout, services[1])
command_env = _sandbox_environment(env, sandbox) if backend else env
try:
backend_cmd = (
isolated_command(
args.backend_cmd,
backend=backend,
cwd=copied_repo,
sandbox_root=sandbox,
env=env,
)
if backend
else args.backend_cmd
)
frontend_cmd = (
isolated_command(
args.frontend_cmd,
backend=backend,
cwd=copied_repo,
sandbox_root=sandbox,
env=env,
)
if backend
else args.frontend_cmd
)
e2e_cmd = (
isolated_command(
args.e2e_cmd,
backend=backend,
cwd=copied_repo,
sandbox_root=sandbox,
env=env,
)
if backend
else args.e2e_cmd
)
except RuntimeError as exc:
print(f"sandboxed-web-e2e: isolation rejected command: {exc}", file=sys.stderr)
exit_code = 126
return exit_code
try:
validate_readiness_url(args.backend_ready_url)
validate_readiness_url(args.frontend_ready_url)
except ValueError as exc:
print(f"sandboxed-web-e2e: invalid readiness URL: {exc}", file=sys.stderr)
exit_code = 125
return exit_code
services.append(start_service("backend", backend_cmd, copied_repo, command_env, logs_dir))
services.append(start_service("frontend", frontend_cmd, copied_repo, command_env, logs_dir))
Comment thread
seonghobae marked this conversation as resolved.
try:
backend_ready = wait_for_url(args.backend_ready_url, args.startup_timeout, services[0])
frontend_ready = wait_for_url(args.frontend_ready_url, args.startup_timeout, services[1])
except ValueError as exc:
print(f"sandboxed-web-e2e: invalid readiness URL: {exc}", file=sys.stderr)
exit_code = 125
return exit_code
Comment thread
seonghobae marked this conversation as resolved.
if not backend_ready or not frontend_ready:
print("sandboxed-web-e2e: service readiness failed", file=sys.stderr)
exit_code = 125
return exit_code
try:
completed = run_shell(args.e2e_cmd, copied_repo, env, args.e2e_timeout)
completed = run_shell(e2e_cmd, copied_repo, command_env, args.e2e_timeout)
if completed.stdout:
print(completed.stdout, end="")
if completed.stderr:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ def timeout_runner(
[
"--repo-root",
str(repo),
"--isolation",
"disabled",
"--backend-cmd",
"backend",
"--frontend-cmd",
Expand Down
Loading
Loading