diff --git a/.github/workflows/sandbox-image-smoke.yml b/.github/workflows/sandbox-image-smoke.yml new file mode 100644 index 000000000..2f68cd626 --- /dev/null +++ b/.github/workflows/sandbox-image-smoke.yml @@ -0,0 +1,73 @@ +name: Sandbox Image Smoke + +# Real-image validation of the Docker sandbox hardening: pulls the shipped +# AIO image and drives it through the production readiness deadline +# (SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT). The offline unit suite (-m "not +# live") never runs this, so this dedicated job is the only place the +# known-bad capability/startup regressions are caught before merge. +# +# Reproducibility: the image reference comes from the repository variable +# SANDBOX_SMOKE_IMAGE (pin a digest there, e.g. +# registry/.../all-in-one-sandbox@sha256:...). When unset it falls back to +# the mutable :latest tag and the resolved digest is printed to the job +# summary so a failure can be reproduced against the exact image tested. + +on: + workflow_dispatch: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'backend/packages/harness/deerflow/community/aio_sandbox/**' + - 'backend/tests/test_aio_sandbox_local_backend.py' + - '.github/workflows/sandbox-image-smoke.yml' + +concurrency: + group: sandbox-image-smoke-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + sandbox-image-smoke: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + SANDBOX_SMOKE_IMAGE_REF: ${{ vars.SANDBOX_SMOKE_IMAGE || 'enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest' }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve an immutable image reference for this run + # Pull once on the runner's platform and export the immutable + # repo@sha256 reference through GITHUB_ENV: the test then runs the + # exact image recorded here, and a tag moving between steps cannot + # make the summary name a different image than the one executed. + # (docker manifest inspect + jq is not used because picking a + # manifest from the index by hand can miss the runner's platform.) + run: | + set -euo pipefail + docker pull "$SANDBOX_SMOKE_IMAGE_REF" >/dev/null + repo_digest="$(docker image inspect "$SANDBOX_SMOKE_IMAGE_REF" --format '{{index .RepoDigests 0}}')" + echo "DEER_FLOW_SANDBOX_SMOKE_IMAGE=$repo_digest" >> "$GITHUB_ENV" + echo "Smoke-testing immutable reference: $repo_digest" | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + # Must match backend/Dockerfile's UV_IMAGE tag; pinned by backend/tests/test_ci_uv_version_pin.py + version: "0.11.1" + + - name: Install backend dependencies + working-directory: backend + run: uv sync --group dev + + - name: Run the live real-image smoke test + working-directory: backend + run: uv run pytest -m live tests/test_aio_sandbox_local_backend.py -v diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index a981e34d4..bc8374eef 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -655,7 +655,27 @@ sandbox: When you configure `sandbox.mounts`, DeerFlow exposes those `container_path` values in the agent prompt so the agent can discover and operate on mounted directories directly instead of assuming everything must live under `/mnt/user-data`. -For bare-metal Docker sandbox runs that use localhost, DeerFlow binds the sandbox HTTP port to `127.0.0.1` by default so it is not exposed on every host interface. Docker-outside-of-Docker deployments that connect through `host.docker.internal` keep the broad legacy bind for compatibility. Set `DEER_FLOW_SANDBOX_BIND_HOST` explicitly if your deployment needs a different bind address. +#### Sandbox container network exposure and hardening + +The sandbox HTTP API (`/v1/shell/*` and friends) has no authentication: anyone who can reach a published sandbox port can execute arbitrary commands in that sandbox. For bare-metal Docker sandbox runs that use localhost, DeerFlow binds the sandbox port to `127.0.0.1` so it is not exposed on other host interfaces. For Docker-outside-of-Docker deployments that connect through `host.docker.internal`, the port is bound to the address that hostname actually resolves to — the daemon's `host-gateway-ip` mapping (customizable, possibly IPv6) — so the published port and the address the gateway connects to always match, and the port is no longer published on external network interfaces (previously it was bound to `0.0.0.0`). If resolution fails, the Docker default bridge gateway (via `docker network inspect bridge`, falling back to `172.17.0.1`) is used as a best-effort bind and a warning is logged. Set `DEER_FLOW_SANDBOX_BIND_HOST` explicitly if your deployment needs a different bind address; setting it to `0.0.0.0` restores the legacy broad bind, which re-exposes the unauthenticated exec API on every interface and should be paired with an external firewall. + +Local Docker sandbox containers are also hardened by default: all Linux capabilities are dropped (`--cap-drop=ALL`) except the minimum four the shipped image needs — `CHOWN` (the entrypoint chowns /opt/jupyter), `SETUID`/`SETGID` (it creates the gem user and drops to it via `su`), and `DAC_OVERRIDE` (the root nginx master writes gem-owned logs under /var/log/nginx, a per-request runtime need) — privilege escalation is blocked across exec (`no-new-privileges`), and CPU/memory/PID resources are bounded. + +A custom image that is already fully initialized as a non-root user (no runtime root handoff) should set `DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS=0` to drop every capability including those three: leaving them on would let sandboxed code chown bind-mounted paths or impersonate mounted-file UIDs/GIDs for the container's lifetime. Note that `no-new-privileges` does **not** mitigate that risk — it only blocks gaining privileges across exec; the risk comes from the retained `CAP_SETUID`/`CAP_SETGID` themselves. One hardening knob is relaxed by default: the shipped AIO image runs with `seccomp=unconfined` because its Chromium browser does not start under Docker's default seccomp profile (syscall filtering is disabled — see the two seccomp variables below to change that). The following environment variables (set them in the gateway process, e.g. via `.env` loaded by docker-compose, or the gateway service `environment:`) tune or disable each knob: + +| Environment variable | Default | Purpose | +| --- | --- | --- | +| `DEER_FLOW_SANDBOX_BIND_HOST` | loopback / bridge gateway (see above) | Host interface for the sandbox `-p` publish. Must be an IP literal (bare or bracketed IPv6) or a hostname, which is resolved to an address first — Docker publish specs do not accept hostnames. `0.0.0.0` restores the legacy broad bind (risky). | +| `DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED` | on | The shipped AIO image's Chromium browser does not start under Docker's default seccomp profile (see the upstream agent-infra sandbox FAQ), so `seccomp=unconfined` remains the default. Set to `0` to run with the built-in profile — passed explicitly as `seccomp=builtin`, so a daemon configured with a different default cannot weaken the opt-out — and only for images verified to start and pass browser checks with it. | +| `DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS` | on | Keeps the four capabilities (`CHOWN`/`SETUID`/`SETGID`/`DAC_OVERRIDE`) that the shipped image needs: three for the entrypoint's runtime user handoff, plus `DAC_OVERRIDE` because the root nginx master writes gem-owned log files for the container's lifetime. Set to `0` for images already fully initialized as a non-root user — every capability is then dropped, so sandboxed code cannot chown bind-mounted paths or impersonate mounted-file UIDs/GIDs. | +| `DEER_FLOW_SANDBOX_SECCOMP_PROFILE` | unset | Path to a custom seccomp profile (e.g. a restricted, Chromium-compatible one built from Docker's default plus the namespace syscalls Chromium needs). Takes precedence over the unconfined default. | +| `DEER_FLOW_SANDBOX_MEMORY` | `2g` | `--memory` limit per sandbox container. `0`/`none` disables the limit. | +| `DEER_FLOW_SANDBOX_CPUS` | `2` | `--cpus` limit per sandbox container. `0`/`none` disables the limit. | +| `DEER_FLOW_SANDBOX_PIDS_LIMIT` | `512` | `--pids-limit` per sandbox container (fork-bomb guard). `0`/`none` disables the limit. | +| `DEER_FLOW_SANDBOX_CONTAINER_USER` | unset (image default) | Passed through as `--user` (e.g. `1000:1000`). The default AIO image's user is upstream-controlled, so DeerFlow does not force one; set this only if you know your image's runtime user. | +| `DEER_FLOW_SANDBOX_NETWORK` | unset (daemon default network) | Passed through as `--network`. Point it at a dedicated, egress-controlled Docker network so sandbox egress can be filtered by that network's policy; by default sandbox code can otherwise reach internal networks and cloud metadata endpoints directly. `host`, `container:`, and `none` are rejected at startup (including through Docker's extended `name=` syntax, whose effective target is validated): Docker drops `-p/--publish` in host mode (and shares the namespace for `container:`), which would void the hardened port bind and re-expose the unauthenticated exec API; `none` leaves the container loopback-only, so the published sandbox API port cannot receive traffic and every acquisition would time out. | + +These hardening flags are Docker-only; Apple Container (`container` runtime) keeps its previous, unhardened invocation. Sandbox control-plane HTTP calls to loopback/private IPs, single-label cluster hosts, and Docker/Podman internal hostnames bypass `HTTP_PROXY`/`HTTPS_PROXY` diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py index dc44aab7b..633c8333e 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py @@ -46,7 +46,7 @@ from deerflow.sandbox.sandbox import Sandbox from deerflow.sandbox.sandbox_provider import SandboxProvider from .aio_sandbox import AioSandbox -from .backend import SandboxBackend, wait_for_sandbox_ready, wait_for_sandbox_ready_async +from .backend import SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT, SandboxBackend, wait_for_sandbox_ready, wait_for_sandbox_ready_async from .local_backend import LocalContainerBackend from .ownership import ( OwnershipBackendError, @@ -2041,7 +2041,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): ) # Wait for sandbox to be ready - if not wait_for_sandbox_ready(info.sandbox_url, timeout=60): + if not wait_for_sandbox_ready(info.sandbox_url, timeout=SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT): # The container is running but unowned: ownership is published by # ``_register_created_sandbox`` after this gate. Claim the teardown # lease before stopping it so a peer cannot adopt the not-yet-ready @@ -2076,7 +2076,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): ) # Wait for sandbox to be ready without blocking the event loop. - if not await wait_for_sandbox_ready_async(info.sandbox_url, timeout=60): + if not await wait_for_sandbox_ready_async(info.sandbox_url, timeout=SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT): # The container is running but unowned: ownership is published by # ``_register_created_sandbox`` after this gate. Claim the teardown # lease before stopping it so a peer cannot adopt the not-yet-ready diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/backend.py b/backend/packages/harness/deerflow/community/aio_sandbox/backend.py index afad1548e..3a266fe42 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/backend.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/backend.py @@ -41,6 +41,13 @@ def sandbox_http_trust_env(sandbox_url: str) -> bool: return not (address.is_loopback or address.is_private or address.is_link_local) +# The readiness deadline the local-container provider paths (sync and async) +# enforce before destroying a sandbox that never became ready. Tests that +# validate the shipped image must use this same budget: a longer one can +# pass while every real acquisition still fails. +SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT = 60 + + def wait_for_sandbox_ready(sandbox_url: str, timeout: int = 30) -> bool: """Poll sandbox health endpoint until ready or timeout. diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py b/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py index 7f3df7006..434b1b082 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py @@ -6,10 +6,13 @@ Handles container lifecycle, port allocation, and cross-process container discov from __future__ import annotations +import csv +import ipaddress import json import logging import os import shlex +import socket import subprocess from datetime import datetime @@ -139,20 +142,130 @@ def _is_loopback_sandbox_host(host: str) -> bool: return _normalize_sandbox_host(host) in {"", "localhost", "127.0.0.1", "::1", "[::1]"} +def _is_ip_bind_spec(value: str) -> bool: + """Return True when ``value`` (bare or bracketed) is an IP literal.""" + inner = value.strip() + if inner.startswith("[") and inner.endswith("]"): + inner = inner[1:-1] + try: + ipaddress.ip_address(inner) + return True + except ValueError: + return False + + +def _normalize_docker_bind_spec(value: str) -> str: + """Bracket bare IPv6 literals for Docker's ``-p`` publish syntax. + + Docker requires the host part of a publish spec to be a bracketed IPv6 + literal (``[fd00::1]:port:8080``), but operators writing the bind override + naturally give the bare address. Raw and already-bracketed IPv6 forms are + normalized; IPv4 addresses and hostnames pass through unchanged. + """ + candidate = value.strip() + inner = candidate + if candidate.startswith("[") and candidate.endswith("]"): + inner = candidate[1:-1] + try: + if ipaddress.ip_address(inner).version == 6: + return f"[{inner}]" + except ValueError: + pass + return candidate + + +# Fallback gateway of Docker's default bridge network (docker0). Used when the +# daemon cannot be queried (see _docker_bridge_gateway_ip) so non-loopback +# sandbox deployments still get a host-only bind instead of 0.0.0.0. +_DOCKER_BRIDGE_GATEWAY_FALLBACK = "172.17.0.1" + +# Hardening defaults for sandbox containers. The sandbox executes untrusted, +# model-authored code, so containers get bounded resources by default; every +# value can be tuned or disabled through the corresponding DEER_FLOW_SANDBOX_* +# environment variable (see _start_container). +_DEFAULT_SANDBOX_MEMORY = "2g" +_DEFAULT_SANDBOX_CPUS = "2" +_DEFAULT_SANDBOX_PIDS_LIMIT = "512" + + +def _docker_bridge_gateway_ip() -> str | None: + """Return the gateway IPv4 of Docker's default bridge network, or None. + + The gateway is discovered from the daemon (``docker network inspect + bridge``) because the address is deployment-specific: daemons with a + custom ``bip`` or rootless/multi-network setups do not use 172.17.0.1. + Any failure (docker missing, daemon down, unparsable or non-IPv4 output) + returns None so the caller can fall back to the well-known default. + """ + try: + result = subprocess.run( + [ + "docker", + "network", + "inspect", + "bridge", + "--format", + "{{(index .IPAM.Config 0).Gateway}}", + ], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as e: + logger.debug(f"Could not query Docker bridge gateway: {e}") + return None + if result.returncode != 0: + logger.debug(f"docker network inspect bridge failed: {(result.stderr or '').strip()}") + return None + candidate = (result.stdout or "").strip() + try: + if ipaddress.ip_address(candidate).version != 4: + return None + except ValueError: + return None + return candidate + + def _resolve_docker_bind_host(sandbox_host: str | None = None, bind_host: str | None = None) -> str: """Choose the host interface for legacy Docker ``-p`` sandbox publishing. - Bare-metal/local runs talk to sandboxes through localhost and should not - expose the sandbox HTTP API on every host interface. Docker-outside-of- - Docker deployments commonly use ``host.docker.internal`` from another - container; keep their legacy broad bind unless operators opt into a - narrower bind with ``DEER_FLOW_SANDBOX_BIND_HOST``. When operators choose - an IPv6 loopback sandbox host, bind Docker to IPv6 loopback as well so the - advertised sandbox URL and published socket use the same address family. + Bare-metal/local runs talk to sandboxes through localhost and bind to + 127.0.0.1, so the sandbox HTTP API (which has no authentication — anyone + who can reach it gets arbitrary shell execution) is never exposed on + other host interfaces. + + Non-loopback sandbox hosts (typically Docker-outside-of-Docker via + ``host.docker.internal``) used to bind 0.0.0.0, which published the + unauthenticated exec API on every interface of the host. They now bind + the address the sandbox host itself resolves to: ``host.docker.internal`` + follows the daemon's ``host-gateway-ip`` mapping (customizable, possibly + IPv6), so resolving it yields exactly where the gateway will connect — + the published port and the advertised sandbox URL always match. Only + when resolution fails does the default bridge gateway serve as a + best-effort fallback (with a warning). Operators that genuinely need the + old broad bind (e.g. remote clients connecting to the sandbox API + directly) can restore it with ``DEER_FLOW_SANDBOX_BIND_HOST=0.0.0.0`` — + that re-exposes an unauthenticated shell endpoint and should be paired + with an external firewall. When operators choose an IPv6 loopback + sandbox host, bind Docker to IPv6 loopback as well so the advertised + sandbox URL and published socket use the same address family. """ - explicit_bind = bind_host if bind_host is not None else os.environ.get("DEER_FLOW_SANDBOX_BIND_HOST") - if explicit_bind is not None: - explicit_bind = explicit_bind.strip() + explicit_bind = bind_host if bind_host is not None else os.environ.get("DEER_FLOW_SANDBOX_BIND_HOST", "").strip() + if explicit_bind: + explicit_bind = _normalize_docker_bind_spec(explicit_bind) + if explicit_bind and not _is_ip_bind_spec(explicit_bind): + # -p requires an IP literal as the host part; Docker rejects a + # hostname there, which would prevent every sandbox from + # starting. Resolve hostname overrides to the address the daemon + # actually maps (e.g. host.docker.internal -> host-gateway-ip). + resolved = _resolve_sandbox_host_address(explicit_bind) + if resolved is None: + raise RuntimeError( + f"DEER_FLOW_SANDBOX_BIND_HOST={explicit_bind!r} is not an IP literal and could not be resolved; " + "Docker publish specs require an IP address as the host part. " + "Set an IPv4/IPv6 literal (bare or bracketed) or a resolvable hostname." + ) + explicit_bind = resolved if explicit_bind: logger.debug("Docker sandbox bind: %s (explicit bind host override)", explicit_bind) return explicit_bind @@ -165,8 +278,147 @@ def _resolve_docker_bind_host(sandbox_host: str | None = None, bind_host: str | logger.debug("Docker sandbox bind: 127.0.0.1 (loopback default)") return "127.0.0.1" - logger.debug("Docker sandbox bind: 0.0.0.0 (non-loopback sandbox host compatibility)") - return "0.0.0.0" + resolved = _resolve_sandbox_host_address(host) + if resolved: + logger.debug( + "Docker sandbox bind: %s (resolved from sandbox host %r, follows the daemon host-gateway mapping)", + resolved, + host, + ) + return resolved + + # Resolution failed (unusual — e.g. a custom hostname with no DNS entry + # yet). Fall back to the default bridge gateway so non-loopback setups + # still get a host-only bind, and tell the operator to set the explicit + # override when their host-gateway-ip is customized or IPv6. + gateway = _docker_bridge_gateway_ip() or _DOCKER_BRIDGE_GATEWAY_FALLBACK + logger.warning( + "Could not resolve sandbox host %r for the Docker bind; falling back to the default bridge gateway %s. If the daemon's host-gateway-ip is customized or IPv6, set DEER_FLOW_SANDBOX_BIND_HOST to that address explicitly.", + host, + gateway, + ) + return gateway + + +def _env_flag_enabled(name: str) -> bool: + """Return True when environment variable ``name`` holds an affirmative value.""" + return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _env_flag_disabled(name: str) -> bool: + """Return True when ``name`` is explicitly set to a negative value. + + For flags whose behavior defaults to ON, only an explicit opt-out + (``0``/``false``/``no``/``off``) counts as disabled; any other value, + including unset, keeps the default. + """ + return os.environ.get(name, "").strip().lower() in {"0", "false", "no", "off"} + + +def _strip_ipv6_brackets(value: str) -> str: + """Return ``value`` without IPv6 URL-style brackets, if any.""" + inner = value.strip() + if inner.startswith("[") and inner.endswith("]"): + return inner[1:-1] + return inner + + +def _normalize_sandbox_host_for_url(host: str) -> str: + """Bracket IPv6 literals exactly once for a URL authority (``host:port``). + + ``http://fd00::1:8080`` is malformed — the URL authority form requires + brackets around IPv6 (``http://[fd00::1]:8080``), while operators (and + DEER_FLOW_SANDBOX_HOST) may carry the address in either bare or + bracketed form. Strip first, re-bracket once, so both inputs produce the + same URL; IPv4 addresses and hostnames pass through unchanged. + """ + inner = _strip_ipv6_brackets(host) + try: + if ipaddress.ip_address(inner).version == 6: + return f"[{inner}]" + except ValueError: + pass + return inner + + +def _resolve_sandbox_host_address(host: str) -> str | None: + """Resolve ``host`` to the bind spec Docker should publish sandboxes on. + + ``host.docker.internal`` resolves to whatever the daemon's + ``host-gateway-ip`` maps it to (customizable and possibly IPv6), so the + address the gateway will actually *connect* to is exactly this + resolution — binding it keeps the published port and the advertised + sandbox URL on the same address instead of guessing the default bridge + IPv4. IPv6 results are bracketed for Docker's ``-p`` syntax. Returns + None when the name cannot be resolved. + """ + # getaddrinfo takes the bare form; a bracketed IPv6 literal (legal in + # DEER_FLOW_SANDBOX_HOST) would fail to resolve and silently fall back + # to the IPv4 bridge gateway, splitting the bind from the URL address. + lookup = _strip_ipv6_brackets(host) + try: + infos = socket.getaddrinfo(lookup, None) + except OSError as e: + logger.debug(f"Could not resolve sandbox host {host!r}: {e}") + return None + for family, _, _, _, sockaddr in infos: + ip = sockaddr[0] + if family == socket.AF_INET6: + # Drop any zone id (%eth0) — Docker bind specs do not accept it. + ip = ip.split("%", 1)[0] + if ip in ("::",): + continue + return f"[{ip}]" + if family == socket.AF_INET and ip not in ("0.0.0.0",): + return ip + return None + + +def _effective_docker_network_target(raw: str) -> str: + r"""Return the network Docker will actually attach to for ``--network raw``. + + Mirrors Docker CLI's parser (opts/network.go): a value without ``=`` is + the short syntax — the whole value is a network name or ID. A value with + ``=`` is the long syntax — a CSV of ``key=value`` fields in any order + (``name=``, ``alias=``, ``ip=``, ``ip6=``, ``mac-address=``, + ``link-local-ip=``, ``driver-opt=``, ``gw-priority=``) — and the network + is the value of the ``name=`` field, with the last occurrence winning + and fields lowercased, exactly as Docker does it. + + Validation must run on this effective value: neither ``name=host`` nor + ``gw-priority=0,name=host`` reads as the bare word ``host``, but both + attach the host network namespace all the same, silently voiding the + port publish. A long-syntax value with no ``name=`` field cannot name a + network at all (Docker rejects it as well), so the raw value is returned + and falls through the checks harmlessly. + """ + value = raw.strip() + if "=" not in value: + return value.lower() + target = "" + for field in next(csv.reader([value])): + key, _, val = field.partition("=") + key = key.strip().lower() + val = val.strip().lower() # Docker lowercases the whole field as well + if key == "name": + target = val # last name= wins, mirroring the loop in network.go + return target or value.lower() + + +def _docker_resource_limit(env_name: str, default: str) -> str | None: + """Resolve a Docker resource limit from the environment with a safe default. + + Unset/empty keeps the secure default; ``0`` or ``none`` disables the limit + entirely (escape hatch for hosts where the default breaks a workload); + any other value is passed through verbatim so operators can tune it. + """ + raw = os.environ.get(env_name) + if raw is None or not raw.strip(): + return default + value = raw.strip() + if value.lower() in {"0", "none"}: + return None + return value def _is_no_such_container_error(stderr: str, container_name: str) -> bool: @@ -332,7 +584,7 @@ class LocalContainerBackend(SandboxBackend): # When running inside Docker (DooD), sandbox containers are reachable via # host.docker.internal rather than localhost (they run on the host daemon). - sandbox_host = os.environ.get("DEER_FLOW_SANDBOX_HOST", "localhost") + sandbox_host = _normalize_sandbox_host_for_url(os.environ.get("DEER_FLOW_SANDBOX_HOST", "localhost")) return SandboxInfo( sandbox_id=sandbox_id, sandbox_url=f"http://{sandbox_host}:{port}", @@ -395,7 +647,7 @@ class LocalContainerBackend(SandboxBackend): if port is None: return None - sandbox_host = os.environ.get("DEER_FLOW_SANDBOX_HOST", "localhost") + sandbox_host = _normalize_sandbox_host_for_url(os.environ.get("DEER_FLOW_SANDBOX_HOST", "localhost")) sandbox_url = f"http://{sandbox_host}:{port}" if not wait_for_sandbox_ready(sandbox_url, timeout=5): return None @@ -461,7 +713,7 @@ class LocalContainerBackend(SandboxBackend): inspections = self._batch_inspect(container_names) infos: list[SandboxInfo] = [] - sandbox_host = os.environ.get("DEER_FLOW_SANDBOX_HOST", "localhost") + sandbox_host = _normalize_sandbox_host_for_url(os.environ.get("DEER_FLOW_SANDBOX_HOST", "localhost")) for container_name in container_names: data = inspections.get(container_name) if data is None: @@ -552,9 +804,138 @@ class LocalContainerBackend(SandboxBackend): """ cmd = [self._runtime, "run"] - # Docker-specific security options + # Docker-only security hardening. The sandbox container executes + # untrusted, model-authored code, so it must not run with the + # daemon's permissive defaults: all Linux capabilities are dropped + # except the minimum the shipped image's entrypoint needs to + # initialize itself, privilege escalation (setuid/sudo) is blocked, + # and CPU/memory/PID footprints are bounded so one runaway sandbox + # cannot exhaust the host or fork-bomb it. Each knob has an env + # escape hatch documented in backend/docs/CONFIGURATION.md. Apple + # Container's CLI does not support these flags, so they are + # Docker-only. if self._runtime == "docker": - cmd.extend(["--security-opt", "seccomp=unconfined"]) + # The default image (/opt/gem/run.sh) starts as root, creates the + # gem account at runtime, chown -R's /opt/jupyter, and drops to + # that user via su before starting the services. That needs + # CHOWN/SETUID/SETGID; additionally the root nginx master writes + # logs under /var/log/nginx that belong to the gem user, which + # requires DAC_OVERRIDE — without it nginx dies with + # "open() .../access.log failed (13: Permission denied)" on every + # start (a runtime need, not just startup: access.log is written + # per request). Dropping ALL of them makes the image fail before + # the readiness endpoint exists. + # no-new-privileges stays: it only blocks *gaining* privileges + # through exec, it does not revoke the capabilities added here, + # and su from the already-root entrypoint does not need to gain + # anything. Everything else (NET_RAW, SYS_PTRACE, ...) stays + # dropped, which is the bulk of the attack-surface reduction. + # For a pre-initialized non-root image nothing ever runs as + # root, so the handoff capabilities are not needed — and leaving + # them available for the container's lifetime would let + # sandboxed code chown bind-mounted paths or impersonate + # mounted-file UIDs/GIDs. Such images opt out with + # DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS=0 (see CONFIGURATION.md), + # which drops every capability including these three. + if _env_flag_disabled("DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS"): + cmd.extend(["--cap-drop=ALL", "--security-opt", "no-new-privileges"]) + else: + cmd.extend( + [ + "--cap-drop=ALL", + "--cap-add=CHOWN", + "--cap-add=SETUID", + "--cap-add=SETGID", + "--cap-add=DAC_OVERRIDE", + "--security-opt", + "no-new-privileges", + ] + ) + + # The shipped AIO image runs a Chromium-based browser that does + # not start under Docker's default seccomp profile — its upstream + # quick-start always passes seccomp=unconfined and the upstream + # FAQ documents the browser failing under the default profile + # (Chromium needs namespace-related syscalls). Keep that option + # as the default so the shipped image keeps working. Two ways to + # tighten it for a known image: + # DEER_FLOW_SANDBOX_SECCOMP_PROFILE=/path/to/profile.json + # → use a restricted, Chromium-compatible profile instead + # (Docker's default profile plus the needed syscalls); + # DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED=0 + # → fall back to Docker's default profile, only for images + # verified to start and pass their browser checks with it. + seccomp_profile = os.environ.get("DEER_FLOW_SANDBOX_SECCOMP_PROFILE", "").strip() + if seccomp_profile: + cmd.extend(["--security-opt", f"seccomp={seccomp_profile}"]) + elif not _env_flag_disabled("DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED"): + cmd.extend(["--security-opt", "seccomp=unconfined"]) + else: + # The documented opt-out must actually enable Docker's + # built-in filtering: merely omitting the option would + # inherit the daemon's configured default, which can itself + # be unconfined or a custom profile. + # https://docs.docker.com/reference/cli/docker/container/run/#optional-security-options---security-opt + cmd.extend(["--security-opt", "seccomp=builtin"]) + + if memory := _docker_resource_limit("DEER_FLOW_SANDBOX_MEMORY", _DEFAULT_SANDBOX_MEMORY): + cmd.extend(["--memory", memory]) + if cpus := _docker_resource_limit("DEER_FLOW_SANDBOX_CPUS", _DEFAULT_SANDBOX_CPUS): + cmd.extend(["--cpus", cpus]) + if pids_limit := _docker_resource_limit("DEER_FLOW_SANDBOX_PIDS_LIMIT", _DEFAULT_SANDBOX_PIDS_LIMIT): + cmd.extend(["--pids-limit", pids_limit]) + + # No --user is forced by default: the default AIO sandbox image + # is upstream-built and its runtime user is not pinned here, and + # a wrong user would break the sandbox server's home-directory + # assumptions. Deployments that know their image's user (and the + # UID/GID ownership of its mounts) can pass it through. + if container_user := os.environ.get("DEER_FLOW_SANDBOX_CONTAINER_USER", "").strip(): + cmd.extend(["--user", container_user]) + + # Default: the daemon's default network (unchanged behavior). + # Point this at a dedicated, egress-controlled Docker network so + # sandbox traffic can be filtered by that network's policy — + # otherwise sandbox code can reach internal networks and cloud + # metadata endpoints directly, bypassing the gateway's SSRF + # protections. + if network := os.environ.get("DEER_FLOW_SANDBOX_NETWORK", "").strip(): + # Validate the *effective* target: Docker accepts the extended + # "name=" long syntax in addition to plain names and + # network IDs, and "name=host" / "name=none" attach exactly + # like the bare words while dodging a raw-string check. + target = _effective_docker_network_target(network) + if target == "host" or target.startswith("container:"): + # Docker discards -p/--publish in host mode and + # container: shares another container's network + # namespace, so either one voids the hardened bind below + # and re-exposes the unauthenticated sandbox exec API on + # the host's interfaces. Refuse instead of silently + # losing the bind. + # https://docs.docker.com/engine/network/drivers/host/ + raise RuntimeError( + f"DEER_FLOW_SANDBOX_NETWORK={network!r} resolves to the {target.split(':', 1)[0]!r} network, " + "which would void the sandbox port bind (Docker drops -p/--publish in host mode and shares " + "the network namespace for container:). Use a dedicated egress-controlled bridge " + "network instead." + ) + if target == "none": + # The none driver gives the container only a loopback + # interface, so the published sandbox HTTP API cannot + # receive traffic: readiness would time out (60s), the + # container would be destroyed, and every acquisition + # would fail. Refuse at start-up with a clear message + # instead of failing opaquely on first use. + # https://docs.docker.com/engine/network/drivers/none/ + raise RuntimeError( + f"DEER_FLOW_SANDBOX_NETWORK={network!r} resolves to the 'none' network, which leaves the " + "container loopback-only, so the published sandbox API port cannot receive traffic (readiness " + "would time out and every acquisition would fail). Use a dedicated egress-controlled bridge " + "network instead." + ) + # Pass the raw value through: custom names, network IDs, and + # the legit name= long form all keep working. + cmd.extend(["--network", network]) if self._runtime == "docker": port_mapping = f"{_resolve_docker_bind_host()}:{port}:8080" diff --git a/backend/tests/test_aio_sandbox_local_backend.py b/backend/tests/test_aio_sandbox_local_backend.py index db4526eaf..62aaace50 100644 --- a/backend/tests/test_aio_sandbox_local_backend.py +++ b/backend/tests/test_aio_sandbox_local_backend.py @@ -1,5 +1,6 @@ import logging import os +import socket import subprocess from types import SimpleNamespace @@ -147,13 +148,101 @@ def test_resolve_docker_bind_host_defaults_loopback_for_localhost(monkeypatch): assert _resolve_docker_bind_host() == "127.0.0.1" -def test_resolve_docker_bind_host_keeps_dood_compatibility(monkeypatch): +def test_resolve_docker_bind_host_follows_host_gateway_mapping_for_dood(monkeypatch): + """The bind follows what host.docker.internal actually resolves to.""" monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False) monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal") + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address", + lambda host: "192.168.64.1", + ) + assert _resolve_docker_bind_host() == "192.168.64.1" + + +def test_resolve_docker_bind_host_brackets_ipv6_host_gateway(monkeypatch): + """An IPv6 host-gateway mapping binds the bracketed IPv6 address.""" + monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False) + monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal") + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address", + lambda host: "[fd00::1]", + ) + + assert _resolve_docker_bind_host() == "[fd00::1]" + + +def test_resolve_docker_bind_host_brackets_bare_ipv6_override(monkeypatch): + """A bare IPv6 literal in the override becomes a valid Docker publish host. + + Docker's ``-p`` syntax requires bracketed IPv6 literals + (``[fd00::1]:port:8080``); operators writing the escape hatch naturally + give the bare address, so it must be normalized before use. + """ + monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "fd00::1") + assert _resolve_docker_bind_host() == "[fd00::1]" + + monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "[fd00::1]") + assert _resolve_docker_bind_host() == "[fd00::1]" + + # IPv4 literals pass through unchanged. + monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "192.168.64.1") + assert _resolve_docker_bind_host() == "192.168.64.1" + monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "0.0.0.0") assert _resolve_docker_bind_host() == "0.0.0.0" +def test_resolve_docker_bind_host_resolves_hostname_override(monkeypatch): + """-p requires an IP literal as the host part, so a hostname override + resolves to the address the daemon actually maps before use.""" + monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "host.docker.internal") + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address", + lambda host: "192.168.64.1" if host == "host.docker.internal" else None, + ) + assert _resolve_docker_bind_host() == "192.168.64.1" + + +def test_resolve_docker_bind_host_rejects_unresolvable_hostname_override(monkeypatch): + monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "not-a-resolvable-host.invalid") + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address", + lambda host: None, + ) + with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_BIND_HOST"): + _resolve_docker_bind_host() + + +def test_resolve_docker_bind_host_uses_discovered_bridge_gateway_when_resolution_fails(monkeypatch): + monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False) + monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal") + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address", + lambda host: None, + ) + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._docker_bridge_gateway_ip", + lambda: "192.168.64.1", + ) + + assert _resolve_docker_bind_host() == "192.168.64.1" + + +def test_resolve_docker_bind_host_falls_back_to_static_bridge_gateway(monkeypatch): + monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False) + monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal") + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address", + lambda host: None, + ) + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._docker_bridge_gateway_ip", + lambda: None, + ) + + assert _resolve_docker_bind_host() == "172.17.0.1" + + def test_resolve_docker_bind_host_uses_ipv6_loopback_for_ipv6_sandbox_host(monkeypatch): monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False) monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "[::1]") @@ -176,6 +265,30 @@ def test_resolve_docker_bind_host_allows_explicit_override(monkeypatch): assert _resolve_docker_bind_host() == "192.0.2.10" +def test_resolve_docker_bind_host_allows_restoring_legacy_broad_bind(monkeypatch): + """DEER_FLOW_SANDBOX_BIND_HOST=0.0.0.0 restores the pre-hardening bind.""" + monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal") + monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "0.0.0.0") + + assert _resolve_docker_bind_host() == "0.0.0.0" + + +def _clear_hardening_env(monkeypatch): + for var in ( + "DEER_FLOW_SANDBOX_HOST", + "DEER_FLOW_SANDBOX_BIND_HOST", + "DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED", + "DEER_FLOW_SANDBOX_SECCOMP_PROFILE", + "DEER_FLOW_SANDBOX_MEMORY", + "DEER_FLOW_SANDBOX_CPUS", + "DEER_FLOW_SANDBOX_PIDS_LIMIT", + "DEER_FLOW_SANDBOX_CONTAINER_USER", + "DEER_FLOW_SANDBOX_NETWORK", + "DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS", + ): + monkeypatch.delenv(var, raising=False) + + def test_start_container_binds_local_docker_port_to_loopback_by_default(monkeypatch): backend = LocalContainerBackend( image="sandbox:latest", @@ -192,7 +305,24 @@ def test_start_container_binds_local_docker_port_to_loopback_by_default(monkeypa assert captured_cmd[captured_cmd.index("-p") + 1] == "127.0.0.1:18080:8080" -def test_start_container_keeps_broad_bind_for_dood_sandbox_host(monkeypatch): +def test_start_container_brackets_bare_ipv6_bind_override(monkeypatch): + """A bare IPv6 override reaches -p as a bracketed, valid publish host.""" + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal") + monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "fd00::1") + + captured_cmd = _capture_start_container_command(monkeypatch, backend) + + assert captured_cmd[captured_cmd.index("-p") + 1] == "[fd00::1]:18080:8080" + + +def test_start_container_binds_dood_port_to_bridge_gateway(monkeypatch): backend = LocalContainerBackend( image="sandbox:latest", base_port=8080, @@ -202,10 +332,14 @@ def test_start_container_keeps_broad_bind_for_dood_sandbox_host(monkeypatch): ) monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal") monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False) + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._docker_bridge_gateway_ip", + lambda: "172.17.0.1", + ) captured_cmd = _capture_start_container_command(monkeypatch, backend) - assert captured_cmd[captured_cmd.index("-p") + 1] == "0.0.0.0:18080:8080" + assert captured_cmd[captured_cmd.index("-p") + 1] == "172.17.0.1:18080:8080" def test_start_container_binds_ipv6_sandbox_host_to_ipv6_loopback(monkeypatch): @@ -239,6 +373,233 @@ def test_start_container_keeps_apple_container_port_format(monkeypatch): assert captured_cmd[captured_cmd.index("-p") + 1] == "18080:8080" +def test_start_container_hardens_docker_run_by_default(monkeypatch): + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + _clear_hardening_env(monkeypatch) + + captured_cmd = _capture_start_container_command(monkeypatch, backend) + + assert "--cap-drop=ALL" in captured_cmd + # The shipped image's entrypoint starts as root, creates the gem user, + # chowns /opt/jupyter, and drops to that user via su — CHOWN/SETUID/SETGID + # must survive the drop or the container exits before readiness. The root + # nginx master also writes gem-owned logs under /var/log/nginx for the + # container's lifetime, which needs DAC_OVERRIDE. + cap_adds = [arg.split("=", 1)[1] for arg in captured_cmd if arg.startswith("--cap-add=")] + assert cap_adds == ["CHOWN", "SETUID", "SETGID", "DAC_OVERRIDE"] + security_opts = [captured_cmd[i + 1] for i, arg in enumerate(captured_cmd) if arg == "--security-opt"] + assert "no-new-privileges" in security_opts + # The shipped AIO image needs seccomp=unconfined for its Chromium + # browser (upstream FAQ), so that option stays the default; the + # hardening that does not break the shipped image is kept. + assert "seccomp=unconfined" in security_opts + assert captured_cmd[captured_cmd.index("--memory") + 1] == "2g" + assert captured_cmd[captured_cmd.index("--cpus") + 1] == "2" + assert captured_cmd[captured_cmd.index("--pids-limit") + 1] == "512" + # Opt-in-only knobs stay absent unless explicitly configured. + assert "--user" not in captured_cmd + assert "--network" not in captured_cmd + + +def test_start_container_seccomp_can_opt_out_to_default_profile(monkeypatch): + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED", "0") + + captured_cmd = _capture_start_container_command(monkeypatch, backend) + + security_opts = [captured_cmd[i + 1] for i, arg in enumerate(captured_cmd) if arg == "--security-opt"] + assert "seccomp=unconfined" not in security_opts + # The opt-out must select the built-in profile explicitly: omitting the + # option would inherit the daemon's (possibly unconfined) default. + assert "seccomp=builtin" in security_opts + assert "no-new-privileges" in security_opts + + +def test_start_container_seccomp_profile_env_selects_custom_profile(monkeypatch): + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_SECCOMP_PROFILE", "/etc/docker/chromium-seccomp.json") + + captured_cmd = _capture_start_container_command(monkeypatch, backend) + + security_opts = [captured_cmd[i + 1] for i, arg in enumerate(captured_cmd) if arg == "--security-opt"] + assert "seccomp=/etc/docker/chromium-seccomp.json" in security_opts + assert "seccomp=unconfined" not in security_opts + assert "no-new-privileges" in security_opts + + +def test_resolve_sandbox_host_address_formats_and_filters(monkeypatch): + import socket as socket_module + + def fake_getaddrinfo(host, port): + if host == "v4host": + return [(socket_module.AF_INET, None, None, "", ("203.0.113.7", 0))] + if host == "v6host": + return [(socket_module.AF_INET6, None, None, "", ("fd00::1%eth0", 0, 0, 0))] + if host == "wildcard": + return [(socket_module.AF_INET, None, None, "", ("0.0.0.0", 0))] + raise OSError("no such host") + + monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.socket.getaddrinfo", fake_getaddrinfo) + + from deerflow.community.aio_sandbox.local_backend import _resolve_sandbox_host_address + + assert _resolve_sandbox_host_address("v4host") == "203.0.113.7" + # zone ids are stripped and IPv6 is bracketed for docker -p syntax + assert _resolve_sandbox_host_address("v6host") == "[fd00::1]" + # wildcard resolutions are not bindable choices + assert _resolve_sandbox_host_address("wildcard") is None + assert _resolve_sandbox_host_address("unknown.invalid") is None + + +def test_start_container_resource_limits_env_override(monkeypatch): + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_MEMORY", "4g") + monkeypatch.setenv("DEER_FLOW_SANDBOX_CPUS", "4") + monkeypatch.setenv("DEER_FLOW_SANDBOX_PIDS_LIMIT", "1024") + + captured_cmd = _capture_start_container_command(monkeypatch, backend) + + assert captured_cmd[captured_cmd.index("--memory") + 1] == "4g" + assert captured_cmd[captured_cmd.index("--cpus") + 1] == "4" + assert captured_cmd[captured_cmd.index("--pids-limit") + 1] == "1024" + + +def test_start_container_resource_limits_can_be_disabled(monkeypatch): + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_MEMORY", "0") + monkeypatch.setenv("DEER_FLOW_SANDBOX_CPUS", "none") + monkeypatch.setenv("DEER_FLOW_SANDBOX_PIDS_LIMIT", "0") + + captured_cmd = _capture_start_container_command(monkeypatch, backend) + + assert "--memory" not in captured_cmd + assert "--cpus" not in captured_cmd + assert "--pids-limit" not in captured_cmd + + +def test_start_container_passes_through_user_and_network(monkeypatch): + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_CONTAINER_USER", "1000:1000") + monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "deer-flow-sandbox-egress") + + captured_cmd = _capture_start_container_command(monkeypatch, backend) + + assert captured_cmd[captured_cmd.index("--user") + 1] == "1000:1000" + assert captured_cmd[captured_cmd.index("--network") + 1] == "deer-flow-sandbox-egress" + + +def test_start_container_rejects_host_networking(monkeypatch): + """host mode discards -p/--publish, voiding the hardened bind and + re-exposing the unauthenticated exec API on the host's interfaces.""" + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "host") + + with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_NETWORK"): + _capture_start_container_command(monkeypatch, backend) + + +def test_start_container_rejects_shared_container_network_namespace(monkeypatch): + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "container:gateway") + + with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_NETWORK"): + _capture_start_container_command(monkeypatch, backend) + + +def test_start_container_rejects_none_network(monkeypatch): + """The none driver leaves the container loopback-only, so the published + sandbox API port cannot receive traffic: readiness would time out and + every acquisition would fail. Fail fast at start-up instead.""" + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "none") + + with pytest.raises(RuntimeError, match="loopback-only"): + _capture_start_container_command(monkeypatch, backend) + + +def test_start_container_does_not_add_docker_hardening_to_apple_container(monkeypatch): + """Apple Container's CLI does not support the Docker hardening flags.""" + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "127.0.0.1") + + captured_cmd = _capture_start_container_command(monkeypatch, backend, runtime="container") + + assert "--cap-drop=ALL" not in captured_cmd + assert "--security-opt" not in captured_cmd + assert "--memory" not in captured_cmd + assert "--cpus" not in captured_cmd + assert "--pids-limit" not in captured_cmd + + def _backend_for_inspect_tests() -> LocalContainerBackend: backend = LocalContainerBackend( image="sandbox:latest", @@ -374,3 +735,268 @@ def test_stop_container_propagates_a_timeout_instead_of_reporting_success(monkey with pytest.raises(subprocess.TimeoutExpired): backend._stop_container("sandbox-wedged") + + +# ── Extended network syntax and IPv6 host normalization ────────────────────── + + +def test_start_container_rejects_extended_network_syntax_host(monkeypatch): + """name=host attaches the host network namespace exactly like `host`; + the raw string must not dodge the rejection.""" + backend = _backend_for_inspect_tests() + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "name=host") + + with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_NETWORK"): + _capture_start_container_command(monkeypatch, backend) + + +def test_start_container_rejects_extended_network_syntax_none(monkeypatch): + backend = _backend_for_inspect_tests() + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "name=none") + + with pytest.raises(RuntimeError, match="loopback-only"): + _capture_start_container_command(monkeypatch, backend) + + +def test_start_container_rejects_extended_network_syntax_container(monkeypatch): + backend = _backend_for_inspect_tests() + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "name=container:gateway") + + with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_NETWORK"): + _capture_start_container_command(monkeypatch, backend) + + +def test_start_container_passes_extended_network_syntax_for_custom_networks(monkeypatch): + """The legit name= long form (and network IDs) keep working.""" + backend = _backend_for_inspect_tests() + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "name=deer-flow-sandbox-egress") + + captured_cmd = _capture_start_container_command(monkeypatch, backend) + + assert captured_cmd[captured_cmd.index("--network") + 1] == "name=deer-flow-sandbox-egress" + + +@pytest.mark.parametrize("sandbox_host", ["fd00::1", "[fd00::1]"]) +def test_discover_brackets_ipv6_sandbox_host_for_url(monkeypatch, sandbox_host): + """Both IPv6 input forms must yield the same bracketed URL authority: + the bare form used to produce the malformed http://fd00::1:.""" + backend = _backend_for_inspect_tests() + monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", sandbox_host) + monkeypatch.setattr(backend, "_is_container_running", lambda name: True) + monkeypatch.setattr(backend, "_get_container_port", lambda name: 18081) + + seen_urls = [] + + def fake_ready(url, timeout): + seen_urls.append(url) + return True + + monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.wait_for_sandbox_ready", fake_ready) + + info = backend.discover("sbx-ipv6") + + assert info.sandbox_url == "http://[fd00::1]:18081" + assert seen_urls == ["http://[fd00::1]:18081"] + + +@pytest.mark.parametrize("sandbox_host", ["fd00::1", "[fd00::1]"]) +def test_create_brackets_ipv6_sandbox_host_for_url(monkeypatch, sandbox_host): + backend = _backend_for_inspect_tests() + monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", sandbox_host) + monkeypatch.setattr(backend, "_start_container", lambda name, port, mounts=None: "container-id") + monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.get_free_port", lambda start_port=None: 18082) + + info = backend.create(thread_id="t", sandbox_id="sbx-ipv6") + + assert info.sandbox_url == "http://[fd00::1]:18082" + + +def test_resolve_sandbox_host_address_accepts_bracketed_ipv6(monkeypatch): + """The bracketed form must resolve (unbracketed for getaddrinfo) instead + of failing through to the IPv4 bridge fallback.""" + from deerflow.community.aio_sandbox.local_backend import _resolve_sandbox_host_address + + infos = [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fd00::1", 0, 0, 0))] + + def fake_getaddrinfo(host, port, *args, **kwargs): + assert host == "fd00::1", f"getaddrinfo must receive the unbracketed form, got {host!r}" + return infos + + monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.socket.getaddrinfo", fake_getaddrinfo) + + assert _resolve_sandbox_host_address("[fd00::1]") == "[fd00::1]" + + +# ── Long-syntax fields in any position (Docker opts/network.go semantics) ──── + + +@pytest.mark.parametrize( + "network", + [ + "name=host,gw-priority=0", + "gw-priority=0,name=host", + "name=host,alias=sbx", + ], +) +def test_start_container_rejects_host_with_additional_long_syntax_fields(monkeypatch, network): + """Docker accepts comma-separated fields in any order; both orderings + select the host network and must not dodge the rejection.""" + backend = _backend_for_inspect_tests() + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", network) + + with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_NETWORK"): + _capture_start_container_command(monkeypatch, backend) + + +def test_start_container_rejects_none_with_additional_long_syntax_fields(monkeypatch): + backend = _backend_for_inspect_tests() + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "gw-priority=0,name=none") + + with pytest.raises(RuntimeError, match="loopback-only"): + _capture_start_container_command(monkeypatch, backend) + + +def test_start_container_rejects_container_mode_with_additional_long_syntax_fields(monkeypatch): + backend = _backend_for_inspect_tests() + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "name=container:gateway,gw-priority=0") + + with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_NETWORK"): + _capture_start_container_command(monkeypatch, backend) + + +def test_start_container_passes_long_syntax_custom_network_with_fields(monkeypatch): + """A legit long-syntax value with extra fields keeps passing through verbatim.""" + backend = _backend_for_inspect_tests() + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "name=egressnet,gw-priority=1") + + captured_cmd = _capture_start_container_command(monkeypatch, backend) + + assert captured_cmd[captured_cmd.index("--network") + 1] == "name=egressnet,gw-priority=1" + + +def test_effective_network_target_last_name_field_wins(): + """Docker's parser lets a later name= field overwrite an earlier one.""" + from deerflow.community.aio_sandbox.local_backend import _effective_docker_network_target as target + + assert target("name=host,name=egressnet") == "egressnet" + assert target("name=egressnet,name=host") == "host" + assert target("gw-priority=0") == "gw-priority=0" # no name= field: Docker errors itself + assert target("bridge") == "bridge" + assert target("1f2a" * 16) == "1f2a" * 16 # network ID passes through + + +# ── Real-image startup smoke test (docker-gated) ───────────────────────────── +# Keep in sync with aio_sandbox_provider.DEFAULT_IMAGE. +_DEFAULT_AIO_IMAGE = "enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest" + + +def _docker_daemon_available() -> bool: + try: + subprocess.run(["docker", "info"], capture_output=True, timeout=30, check=True) + return True + except Exception: + return False + + +# `live`: pulls and runs a mutable external image, so the default offline +# suite (`make test` = `-m "not live"`) never touches the network. The +# daemon probe happens inside the test body — never at collection time. +@pytest.mark.live +def test_default_image_starts_under_hardened_capabilities(monkeypatch): + """Real smoke test against the shipped default image — no subprocess mock. + + The image's entrypoint (/opt/gem/run.sh) starts as root, creates the gem + account at runtime, chown -R's /opt/jupyter, and drops to that user via + su before starting the services. Under the default hardened argv + (--cap-drop=ALL + no-new-privileges) that initialization needs + CHOWN/SETUID/SETGID to be re-added, or the container exits (set -e) + before the readiness endpoint exists. Reaching readiness through the + real docker run proves the whole startup chain survives the hardening. + """ + from deerflow.community.aio_sandbox.backend import SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT + from deerflow.community.aio_sandbox.local_backend import wait_for_sandbox_ready + + if not _docker_daemon_available(): + pytest.skip("requires a running Docker daemon") + + backend = LocalContainerBackend( + # Pin via this override when wiring a dedicated integration job, so + # the run does not depend on a mutable :latest tag. + image=os.environ.get("DEER_FLOW_SANDBOX_SMOKE_IMAGE", _DEFAULT_AIO_IMAGE), + base_port=18210, + container_prefix="sandbox-smoke", + config_mounts=[], + environment={}, + ) + _clear_hardening_env(monkeypatch) + + info = backend.create(thread_id="smoke", sandbox_id="caps-smoke") + try: + # The production deadline, single-sourced: the sync and async + # provider paths destroy the container after exactly this budget, so + # a longer one here could pass while every real acquisition fails. + # (create() completes docker run — including the image pull — before + # this timer starts, so the pull is not part of the budget.) + ready = wait_for_sandbox_ready(info.sandbox_url, timeout=SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT) + if not ready: + # Fail diagnosably: the entrypoint's own log tells us whether the + # capability set is still incomplete (chown/useradd/su errors) or + # the services are merely slow. + logs = subprocess.run( + ["docker", "logs", info.container_name], + capture_output=True, + text=True, + timeout=30, + ) + # supervisord only reports exit codes in the container log; the + # failing program's own stderr goes to files inside the container. + # Pull the usual suspects so the failure is actionable in CI. + prog_logs = subprocess.run( + [ + "docker", + "exec", + info.container_name, + "sh", + "-c", + "cat /var/log/supervisor/* 2>/dev/null | tail -n 40; echo '--- nginx -t ---'; nginx -t 2>&1; echo '--- nginx error.log ---'; tail -n 20 /var/log/nginx/error.log 2>/dev/null", + ], + capture_output=True, + text=True, + timeout=30, + ) + tail = "\n".join((logs.stdout + logs.stderr).splitlines()[-40:]) + "\n" + (prog_logs.stdout or "") + pytest.fail(f"default image never became ready under the hardened capabilities: {info.sandbox_url}\n--- last 40 container log lines ---\n{tail}") + assert backend.is_alive(info) + finally: + backend.destroy(info) + + +def test_start_container_preinitialized_image_can_drop_startup_caps(monkeypatch): + """A custom, pre-initialized non-root image never runs the root handoff, + so CHOWN/SETUID/SETGID must not stay available for the container's + lifetime (chown on bind mounts, UID/GID impersonation). Opting out with + DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS=0 drops every capability.""" + backend = LocalContainerBackend( + image="my-preinitialized-sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + _clear_hardening_env(monkeypatch) + monkeypatch.setenv("DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS", "0") + + captured_cmd = _capture_start_container_command(monkeypatch, backend) + + assert "--cap-drop=ALL" in captured_cmd + assert not [arg for arg in captured_cmd if arg.startswith("--cap-add=")] + security_opts = [captured_cmd[i + 1] for i, arg in enumerate(captured_cmd) if arg == "--security-opt"] + assert "no-new-privileges" in security_opts