fix(sandbox): harden local Docker sandbox containers and port binding (#4986)

* fix(sandbox): harden local Docker sandbox containers and port binding

Root causes (security audit SBX-1/SBX-2) in the local container backend:
- _resolve_docker_bind_host published sandbox ports on 0.0.0.0 whenever
  DEER_FLOW_SANDBOX_HOST was non-loopback (docker-compose defaults to
  host.docker.internal), exposing the unauthenticated /v1/shell/* exec
  API on every host interface.
- _start_container ran every sandbox with seccomp=unconfined and no
  capability, privilege-escalation, or resource limits, so untrusted
  model-authored code could exhaust the host, escalate privileges, and
  reach internal networks / cloud metadata endpoints directly.

Hardening changes and defaults:
- Port binding: non-loopback sandbox hosts now bind the Docker default
  bridge gateway instead of 0.0.0.0, discovered dynamically via
  `docker network inspect bridge` with a static 172.17.0.1 fallback.
  host.docker.internal resolves to that gateway through host-gateway,
  so DooD gateways and the Docker host still reach the sandbox while
  external interfaces no longer see the port.
  DEER_FLOW_SANDBOX_BIND_HOST=0.0.0.0 restores the legacy broad bind.
- seccomp=unconfined is no longer unconditional: sandboxes run with
  Docker's default seccomp profile; opt back in with
  DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED=1, only when the sandbox image
  is verified to require syscalls the default profile blocks.
- Add --cap-drop=ALL and --security-opt no-new-privileges (Docker only;
  the Apple Container CLI does not support these flags).
- Bounded resources with env overrides: --memory 2g
  (DEER_FLOW_SANDBOX_MEMORY), --cpus 2 (DEER_FLOW_SANDBOX_CPUS),
  --pids-limit 512 (DEER_FLOW_SANDBOX_PIDS_LIMIT); each also accepts
  "0"/"none" to disable the limit.
- No --user is forced by default (the default AIO sandbox image's user
  is upstream-controlled and unverified), but
  DEER_FLOW_SANDBOX_CONTAINER_USER passes one through for deployments
  that know their image.
- DEER_FLOW_SANDBOX_NETWORK passes --network so sandboxes can be
  attached to a dedicated egress-controlled network; default networking
  is unchanged.

backend/docs/CONFIGURATION.md documents the new bind behavior and every
override; tests cover each default and escape hatch.

* fix(sandbox): follow host-gateway mapping for binds; keep image-required seccomp default

Review follow-ups on the hardening change:

- Bind: resolve the sandbox host itself and bind that address, instead of
  assuming the default bridge IPv4. host.docker.internal follows the
  daemon host-gateway-ip mapping (customizable, possibly IPv6), so the
  resolved address is exactly where the gateway connects — the published
  port and advertised URL always match. IPv6 is bracketed for docker -p,
  zone ids stripped, wildcard resolutions ignored; unresolved hosts fall
  back to the bridge gateway with a warning pointing at
  DEER_FLOW_SANDBOX_BIND_HOST.
- seccomp: the shipped AIO image needs seccomp=unconfined for its
  Chromium browser (upstream quick-start always passes it; the upstream
  FAQ documents the browser failing under Docker default profile), so
  that option returns as the default. Tightening stays possible via
  DEER_FLOW_SANDBOX_SECCOMP_PROFILE=<path to a restricted,
  Chromium-compatible profile> or DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED=0
  for images verified to work with Docker's default profile.
- cap-drop/no-new-privileges and the resource limits are unchanged.
- Tests updated for both behaviors; 37 pass.

* fix(sandbox): bracket bare IPv6 bind overrides; state seccomp default accurately

DEER_FLOW_SANDBOX_BIND_HOST was returned verbatim, so a bare IPv6 literal
like fd00::1 produced an invalid publish spec (fd00::1:port:8080); Docker
requires the bracketed form. Normalize raw and already-bracketed IPv6
literals (IPv4/hostnames untouched), with resolver-level and argv-level
tests covering the explicit IPv6 override.

The CONFIGURATION.md overview claimed Docker's default seccomp profile
stays active, contradicting the seccomp=unconfined default the table (and
the code) actually ship for the Chromium-based image; spell out the relaxed
default and where to change it.

* style(sandbox): apply ruff format to local_backend

* fix(sandbox): reject host networking, force builtin seccomp opt-out, resolve hostname binds

Review follow-up on #4986 (willem-bd):

- P1: DEER_FLOW_SANDBOX_NETWORK=host (and container:<name>) now raise a
  RuntimeError at start instead of silently voiding the hardened port
  bind — Docker discards -p/--publish in host mode and shares the
  network namespace for container:<name>, which would re-expose the
  unauthenticated exec API on the host's interfaces. Two regression
  tests cover both rejections.
- P2: the seccomp opt-out now passes seccomp=builtin explicitly instead
  of omitting the option, so a daemon configured with an unconfined or
  custom default cannot weaken the documented opt-out; the test asserts
  the flag.
- P2: hostname values in DEER_FLOW_SANDBOX_BIND_HOST resolve to an
  address before use (Docker publish specs require an IP literal as the
  host part, so host.docker.internal previously produced an invalid
  spec that prevented every sandbox from starting); unresolvable names
  raise a clear configuration error. Tests cover resolution and
  rejection; CONFIGURATION.md updated for all three behaviors.

43/43 pass in tests/test_aio_sandbox_local_backend.py; ruff check +
format clean.

* fix(sandbox): reject DEER_FLOW_SANDBOX_NETWORK=none (loopback-only, breaks published API port)

* fix(sandbox): validate the effective Docker network target; normalize IPv6 sandbox hosts once

name=host / name=none dodge raw-string checks but attach like the bare
words; strip name= prefixes and validate the effective target (network IDs
keep passing). Bracketed IPv6 sandbox hosts now resolve for the bind and
bare IPv6 hosts produce bracketed URL authorities — both input forms give
identical bind and URL addresses.

* fix(sandbox): parse the full Docker network long syntax before validating

Docker accepts comma-separated key=value fields in any order (name=, gw-priority=,
alias=, ...); a name=host field hides the host network behind surrounding fields.
Parse the CSV and validate the parsed name= target (last occurrence wins, fields
lowercased, mirroring opts/network.go); no-name values fall through like Docker's
own rejection.

* fix(sandbox): keep CHOWN/SETUID/SETGID through cap-drop=ALL for the default image

The shipped image's entrypoint starts as root, creates the gem user,
chowns /opt/jupyter and drops to that user via su; without those three
capabilities the set -e script dies before the readiness endpoint exists.
no-new-privileges stays (it blocks gaining privileges via exec, not using
the added caps). Adds a docker-gated real-image startup smoke test.

* fix(sandbox): let pre-initialized non-root images drop the startup capabilities

The CHOWN/SETUID/SETGID re-add only exists for the shipped image's root
entrypoint handoff. A custom image that never runs as root gets an explicit
opt-out (DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS=0) so those capabilities are
not left available to sandboxed code (chown on bind mounts, UID/GID
impersonation).

* test(sandbox): gate the real-image smoke test behind the live marker

The default offline suite (make test = -m 'not live') must not depend on a
third-party registry: mark the smoke test live, probe the daemon inside the
test body (never at collection time), and allow pinning the image reference
via DEER_FLOW_SANDBOX_SMOKE_IMAGE for a dedicated integration job.

* test/docs: isolate DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS in tests; add table row; split custom-image guidance

_clear_hardening_env now clears the new knob so a developer shell or .env
preset cannot flip the default-path tests. CONFIGURATION.md gains the table
row, and the custom-image guidance becomes its own paragraph with the
no-new-privileges scope stated correctly (it does not mitigate the retained
CAP_SETUID/SETGID risk).

* test(sandbox): make the live smoke test diagnosable

300s readiness budget (cold pull + cold start must not be conflated with
broken capabilities) and dump the container's last 40 log lines on failure
so the next live run tells us whether the capability set is incomplete
(chown/useradd/su errors) or the services are merely slow.

* test(ci): align the smoke test with the 60s provider deadline; add a dedicated live smoke workflow

Single-source the readiness deadline as SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT
(used by both provider paths and the smoke test) so the validation cannot
drift from the production contract again. New sandbox-image-smoke.yml runs
the live test on a dedicated job, with the image reference pinnable via the
SANDBOX_SMOKE_IMAGE repository variable (digest resolved and recorded in the
job summary when falling back to :latest).

* test(sandbox): pull the failing program's own logs on smoke failure

supervisord only surfaces exit codes in docker logs; nginx's stderr lands in
files inside the container. Dump supervisor program logs, nginx -t, and the
nginx error log on failure so the next run names the exact broken line.

* ci(sandbox): export an immutable repo@digest reference for the smoke run

docker pull once on the runner platform, resolve RepoDigests[0], and pass
that immutable reference to the test via GITHUB_ENV — the recorded and
executed images can no longer diverge when the tag moves, and platform
selection is left to the daemon instead of jq over the manifest index.

* fix(sandbox): add DAC_OVERRIDE — the root nginx master writes gem-owned logs

The image's root nginx master opens /var/log/nginx/{access,error}.log,
which belong to the gem user, for the container's lifetime; without
CAP_DAC_OVERRIDE it dies with 'open() failed (13: Permission denied)' on
every start (FATAL under supervisord) and readiness never arrives. Four
capabilities now: CHOWN/SETUID/SETGID for the entrypoint handoff plus this
runtime log-write need.
This commit is contained in:
陈志谦 2026-08-27 22:53:47 +08:00 committed by GitHub
parent 848eea289c
commit 9e2c1be697
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1131 additions and 24 deletions

View File

@ -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

View File

@ -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:<name>`, and `none` are rejected at startup (including through Docker's extended `name=<network>` syntax, whose effective target is validated): Docker drops `-p/--publish` in host mode (and shares the namespace for `container:<name>`), 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`

View File

@ -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

View File

@ -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.

View File

@ -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=<network>" 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:<name> 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:<name>). 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=<custom-net> long form all keep working.
cmd.extend(["--network", network])
if self._runtime == "docker":
port_mapping = f"{_resolve_docker_bind_host()}:{port}:8080"

View File

@ -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=<custom-net> 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:<port>."""
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