Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
21f41c7
fix(sandbox): harden local Docker sandbox containers and port binding
simpleqt Aug 24, 2026
18d9d54
fix(sandbox): follow host-gateway mapping for binds; keep image-requi…
simpleqt Aug 24, 2026
2ba733d
fix(sandbox): bracket bare IPv6 bind overrides; state seccomp default…
simpleqt Aug 25, 2026
5f663a4
style(sandbox): apply ruff format to local_backend
simpleqt Aug 25, 2026
0021479
fix(sandbox): reject host networking, force builtin seccomp opt-out, …
simpleqt Aug 25, 2026
b592b77
fix(sandbox): reject DEER_FLOW_SANDBOX_NETWORK=none (loopback-only, b…
simpleqt Aug 27, 2026
508f74d
fix(sandbox): validate the effective Docker network target; normalize…
simpleqt Aug 27, 2026
89fbddf
fix(sandbox): parse the full Docker network long syntax before valida…
simpleqt Aug 27, 2026
b990abe
fix(sandbox): keep CHOWN/SETUID/SETGID through cap-drop=ALL for the d…
simpleqt Aug 27, 2026
a007ba6
fix(sandbox): let pre-initialized non-root images drop the startup ca…
simpleqt Aug 27, 2026
8f8913c
test(sandbox): gate the real-image smoke test behind the live marker
simpleqt Aug 27, 2026
a97fe3c
test/docs: isolate DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS in tests; add…
simpleqt Aug 27, 2026
f7af30d
test(sandbox): make the live smoke test diagnosable
simpleqt Aug 27, 2026
854436d
test(ci): align the smoke test with the 60s provider deadline; add a …
simpleqt Aug 27, 2026
3183ba4
test(sandbox): pull the failing program's own logs on smoke failure
simpleqt Aug 27, 2026
8aab3f1
ci(sandbox): export an immutable repo@digest reference for the smoke run
simpleqt Aug 27, 2026
a238b83
fix(sandbox): add DAC_OVERRIDE — the root nginx master writes gem-own…
simpleqt Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion backend/docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,23 @@ 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 Docker default bridge gateway (discovered via `docker network inspect bridge`, falling back to `172.17.0.1`) — the gateway container and the Docker host can still reach the sandbox, but the port is no longer published on external network interfaces (previously it was bound to `0.0.0.0`). 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`), privilege escalation is blocked (`no-new-privileges`), Docker's default seccomp profile stays active, and resources are bounded. 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not claim the default seccomp profile remains active

This paragraph says Docker's default seccomp profile stays active, but _start_container adds seccomp=unconfined unless the operator explicitly opts out or supplies a custom profile; the table immediately below correctly describes that behavior. Because unconfined disables syscall filtering, this sentence materially overstates the default sandbox protection. Please make the overview match the implementation and table.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2ba733d — the overview now states the shipped image runs with seccomp=unconfined (syscall filtering disabled) because its Chromium needs it, matching the table and the implementation, and points at the two variables that change it.


| Environment variable | Default | Purpose |
| --- | --- | --- |
| `DEER_FLOW_SANDBOX_BIND_HOST` | loopback / bridge gateway (see above) | Host interface for the sandbox `-p` publish. `0.0.0.0` restores the legacy broad bind (risky). |
| `DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED` | off | Set to `1` to run with `seccomp=unconfined` (previously the unconditional default). Only enable if your sandbox image is verified to require syscalls that Docker's default seccomp profile blocks. |
| `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. |

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`
Expand Down
148 changes: 137 additions & 11 deletions backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from __future__ import annotations

import ipaddress
import json
import logging
import os
Expand Down Expand Up @@ -139,16 +140,80 @@ def _is_loopback_sandbox_host(host: str) -> bool:
return _normalize_sandbox_host(host) in {"", "localhost", "127.0.0.1", "::1", "[::1]"}


# 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 Docker default bridge gateway instead: ``host.docker.internal``
resolves to that gateway (via ``host-gateway``, which defaults to the
default bridge gateway), so DooD gateways and the Docker host itself can
still reach the sandbox, while external network interfaces no longer see
the port. 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:
Expand All @@ -165,8 +230,30 @@ 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"
gateway = _docker_bridge_gateway_ip() or _DOCKER_BRIDGE_GATEWAY_FALLBACK

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Use the actual host-gateway mapping

Docker allows host-gateway to be overridden with the daemon's host-gateway-ip setting and it can also resolve to IPv6. In those valid configurations, host.docker.internal inside the Gateway resolves to the configured address, while this code binds the sandbox port to the default bridge IPv4 (or the static 172.17.0.1 fallback). The returned sandbox URL then targets an address where the port is not listening, so readiness and acquisition fail. Please derive the bind address from the actual host-gateway mapping, or plumb the same configured value to both sides, and handle IPv6 formatting rather than falling back to a nonexistent IPv4 address.

Reference: https://docs.docker.com/reference/cli/dockerd/#configure-host-gateway-ip

logger.debug("Docker sandbox bind: %s (Docker bridge gateway for non-loopback sandbox 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 _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:
Expand Down Expand Up @@ -552,9 +639,48 @@ def _start_container(
"""
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,
# 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"])
cmd.extend(["--cap-drop=ALL", "--security-opt", "no-new-privileges"])

# Default: Docker's default seccomp profile (syscall filtering
# stays ON). seccomp=unconfined was previously added
# unconditionally, disabling syscall filtering for every sandbox;
# opt back in only when the sandbox image is verified to need
# syscalls that the default profile blocks.
if _env_flag_enabled("DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve a Chromium-compatible seccomp policy

The default image's upstream quick-start always runs it with --security-opt seccomp=unconfined, and the upstream FAQ explicitly says its browser will not start under Docker's default profile because Chromium needs namespace-related syscalls. With this environment variable unset, every DeerFlow Docker AIO sandbox now uses that incompatible profile. Please either ship and select a restricted Chromium-compatible seccomp profile or retain the required option for the shipped default image; an argv-only unit test does not validate image startup or browser functionality.

References: https://github.com/agent-infra/sandbox/blob/0f23e3c9395cd2175f5f8d009c96363f2a7711a5/website/docs/en/guide/start/quick-start.mdx and https://github.com/agent-infra/sandbox/blob/0f23e3c9395cd2175f5f8d009c96363f2a7711a5/website/docs/en/guide/start/faq.md

cmd.extend(["--security-opt", "seccomp=unconfined"])

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():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Reject host networking before starting the sandbox

This unrestricted pass-through accepts DEER_FLOW_SANDBOX_NETWORK=host. Docker discards -p/--publish mappings in host mode, so the bind selected below no longer protects the endpoint; the AIO server instead listens directly in the host network namespace. Since this PR documents the sandbox API as unauthenticated arbitrary command execution, that configuration can re-expose it on the host's interfaces while appearing to retain the hardened bind. Please reject host (and network-namespace sharing modes), or put them behind a separately named dangerous escape hatch with an explicit warning. Reference: https://docs.docker.com/engine/network/drivers/host/#note

cmd.extend(["--network", network])

if self._runtime == "docker":
port_mapping = f"{_resolve_docker_bind_host()}:{port}:8080"
Expand Down
Loading
Loading