diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 3bc1a0c7b86..7eac1c72e48 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -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. 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_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` and `container:` are rejected at startup: Docker drops `-p/--publish` in host mode (and shares the namespace for `container:`), which would void the hardened port bind and re-expose the unauthenticated exec API. | + +These hardening flags are Docker-only; Apple Container (`container` runtime) keeps its previous, unhardened invocation. Sandbox control-plane HTTP calls to loopback/private IPs, single-label cluster hosts, and Docker/Podman internal hostnames bypass `HTTP_PROXY`/`HTTPS_PROXY` diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py b/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py index 7f3df70066c..9ffae231b97 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py @@ -6,10 +6,12 @@ from __future__ import annotations +import ipaddress import json import logging import os import shlex +import socket import subprocess from datetime import datetime @@ -139,20 +141,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 +277,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 + 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: @@ -552,9 +742,80 @@ 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"): + 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(): + lowered = network.lower() + if lowered == "host" or lowered.startswith("container:"): + # Docker discards -p/--publish in host mode and + # container: shares another container's network + # namespace, so either one voids the hardened bind below + # and re-exposes the unauthenticated sandbox exec API on + # the host's interfaces. Refuse instead of silently + # losing the bind. + # https://docs.docker.com/engine/network/drivers/host/ + raise RuntimeError( + f"DEER_FLOW_SANDBOX_NETWORK={network!r} would void the sandbox port bind " + "(Docker drops -p/--publish in host mode and shares the network namespace " + "for container:). Use a dedicated egress-controlled bridge network instead." + ) + cmd.extend(["--network", network]) if self._runtime == "docker": port_mapping = f"{_resolve_docker_bind_host()}:{port}:8080" diff --git a/backend/tests/test_aio_sandbox_local_backend.py b/backend/tests/test_aio_sandbox_local_backend.py index db4526eafdb..a51f413872d 100644 --- a/backend/tests/test_aio_sandbox_local_backend.py +++ b/backend/tests/test_aio_sandbox_local_backend.py @@ -147,13 +147,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 +264,29 @@ 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", + ): + 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 +303,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 +330,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 +371,208 @@ 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 + 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_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",