Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion backend/docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,24 @@ 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`), privilege escalation is blocked (`no-new-privileges`), and resources are bounded. 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. `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 Docker's default profile — only for images verified to start and pass browser checks with it. |
| `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. |

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
239 changes: 227 additions & 12 deletions backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@

from __future__ import annotations

import ipaddress
import json
import logging
import os
import shlex
import socket
import subprocess
from datetime import datetime

Expand Down Expand Up @@ -139,20 +141,105 @@ def _is_loopback_sandbox_host(host: str) -> bool:
return _normalize_sandbox_host(host) in {"", "localhost", "127.0.0.1", "::1", "[::1]"}


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

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] Resolve or reject hostname bind overrides

This helper deliberately passes hostnames through (and the new test blesses host.docker.internal), but its result becomes the first field of -p, whose full form requires HOST_IP:HOST_PORT:CONTAINER_PORT. An override such as DEER_FLOW_SANDBOX_BIND_HOST=host.docker.internal therefore produces host.docker.internal:18080:8080, which Docker rejects as an invalid host IP and prevents every sandbox from starting. Please resolve the override to an IP literal, or reject non-IP values with a clear configuration error and update the test/docs accordingly. Reference: https://docs.docker.com/get-started/docker-concepts/running-containers/publishing-ports/#publishing-to-a-specific-host-ip

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 = _normalize_docker_bind_spec(explicit_bind)
if explicit_bind:
logger.debug("Docker sandbox bind: %s (explicit bind host override)", explicit_bind)
return explicit_bind
Expand All @@ -165,8 +252,86 @@ 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

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.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 _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.
"""
try:
infos = socket.getaddrinfo(host, 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 _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 +717,59 @@ 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"])

# 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"):

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] Force the built-in seccomp profile for the opt-out

When DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED=0, this branch merely omits a seccomp option. That inherits the daemon's configured default, which can itself be unconfined or another custom profile, so the documented opt-out does not reliably enable Docker's default filtering. Docker exposes seccomp=builtin specifically to select the built-in profile even when the daemon default is customized or disabled; please pass that explicitly in the disabled branch and assert it in the test. Reference: https://docs.docker.com/reference/cli/docker/container/run/#optional-security-options---security-opt

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