diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index afae0f5e2..112e4b0e9 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -901,7 +901,7 @@ require supply-chain pinning. #### 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. +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`). On Docker Desktop, resolving `host.docker.internal` yields an internal VM gateway address that the host OS cannot bind; because Docker Desktop forwards `host.docker.internal` to host loopback, DeerFlow defaults to `127.0.0.1` for `host.docker.internal` on Desktop daemons. Custom non-loopback sandbox hosts continue to bind their resolved address. 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 a five-capability compatibility allowlist — `CHOWN`, `FOWNER`, `SETUID`, `SETGID`, and `DAC_OVERRIDE` — while privilege escalation across exec stays blocked with `no-new-privileges` and CPU/memory/PID resources are bounded. `CHOWN`/`SETUID`/`SETGID` support the runtime user handoff and `DAC_OVERRIDE` supports the root nginx master's writes to gem-owned logs. `FOWNER` is specifically required by the newer AIO 1.11.x startup path (regression-tested against the recommended 1.11.0 image), which runs `chmod /run/user/1000` after capabilities are dropped. Images that do not perform that `chmod` do not need `FOWNER`; DeerFlow deliberately does not guess a smaller set from mutable tags, digests, or arbitrary custom images, so the default compatibility allowlist remains version-agnostic. @@ -909,7 +909,7 @@ A custom image that is already fully initialized as a non-root user and needs no | 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_BIND_HOST` | loopback (localhost or Docker Desktop with `host.docker.internal`) / host-gateway-ip / bridge gateway | 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 five-capability compatibility set (`CHOWN`/`FOWNER`/`SETUID`/`SETGID`/`DAC_OVERRIDE`). `FOWNER` specifically covers the newer AIO 1.11.x startup `chmod /run/user/1000` path (tested with 1.11.0); images without that step do not need `FOWNER`, but DeerFlow does not infer per-image capability subsets from tags/digests/custom images. Set to `0` only for images that need none of the five — the switch drops the entire set. | | `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. | 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 38033027b..14b4bd4c6 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py @@ -276,6 +276,45 @@ def _docker_bridge_gateway_ip() -> str | None: return candidate +_DOCKER_SERVER_IS_DESKTOP: bool | None = None + + +def _docker_server_is_desktop() -> bool: + """Detect Desktop from the daemon, including a Linux DooD Gateway.""" + global _DOCKER_SERVER_IS_DESKTOP + if _DOCKER_SERVER_IS_DESKTOP is not None: + return _DOCKER_SERVER_IS_DESKTOP + try: + result = subprocess.run( + ["docker", "info", "--format", "{{json .OperatingSystem}}"], + capture_output=True, + text=True, + timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc: + logger.warning("Could not identify the Docker server platform; assuming non-Desktop: %s", exc) + return False + if result.returncode != 0: + logger.warning("Could not identify the Docker server platform; assuming non-Desktop: %s", (result.stderr or "").strip()) + return False + raw = (result.stdout or "").strip() + try: + operating_system = json.loads(raw) + except json.JSONDecodeError: + operating_system = raw + is_desktop = isinstance(operating_system, str) and "docker desktop" in operating_system.lower() + _DOCKER_SERVER_IS_DESKTOP = is_desktop + return is_desktop + + +def _clear_docker_desktop_cache() -> None: + global _DOCKER_SERVER_IS_DESKTOP + _DOCKER_SERVER_IS_DESKTOP = None + + +_docker_server_is_desktop.cache_clear = _clear_docker_desktop_cache # type: ignore[attr-defined] + + 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. @@ -290,15 +329,21 @@ def _resolve_docker_bind_host(sandbox_host: str | None = None, bind_host: str | 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. + the published port and the advertised sandbox URL always match. On + Docker Desktop, resolving ``host.docker.internal`` yields an internal VM + gateway address that the host OS cannot bind, so Desktop daemons default + to host loopback (127.0.0.1) for ``host.docker.internal``; Desktop forwards + ``host.docker.internal`` to host loopback automatically. Custom non-loopback + sandbox hosts on Desktop daemons continue to bind their resolved address. + 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", "").strip() if explicit_bind: @@ -328,6 +373,17 @@ 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" + if _docker_server_is_desktop() and host.strip().rstrip(".").lower() in ( + "host.docker.internal", + "gateway.docker.internal", + "docker.for.mac.host.internal", + "docker.for.mac.localhost", + "docker.for.win.host.internal", + "docker.for.win.localhost", + ): + logger.debug("Docker sandbox bind: 127.0.0.1 (Docker Desktop host loopback)") + return "127.0.0.1" + resolved = _resolve_sandbox_host_address(host) if resolved: logger.debug( @@ -748,25 +804,7 @@ class LocalContainerBackend(SandboxBackend): def _docker_server_is_desktop(self) -> bool: """Detect Desktop from the daemon, including a Linux DooD Gateway.""" - try: - result = subprocess.run( - ["docker", "info", "--format", "{{json .OperatingSystem}}"], - capture_output=True, - text=True, - timeout=10, - ) - except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc: - logger.warning("Could not identify the Docker server platform; Desktop synthetic DNS answers remain disabled: %s", exc) - return False - if result.returncode != 0: - logger.warning("Could not identify the Docker server platform; Desktop synthetic DNS answers remain disabled: %s", (result.stderr or "").strip()) - return False - raw = (result.stdout or "").strip() - try: - operating_system = json.loads(raw) - except json.JSONDecodeError: - operating_system = raw - return isinstance(operating_system, str) and "docker desktop" in operating_system.lower() + return _docker_server_is_desktop() def _docker_has_managed_sandboxes(self) -> bool: """Keep using Docker while this prefix still has managed sandboxes. diff --git a/backend/tests/test_aio_sandbox_local_backend.py b/backend/tests/test_aio_sandbox_local_backend.py index ff2771caf..e389c3a13 100644 --- a/backend/tests/test_aio_sandbox_local_backend.py +++ b/backend/tests/test_aio_sandbox_local_backend.py @@ -11,6 +11,7 @@ import pytest from deerflow.community.aio_sandbox.local_backend import ( LocalContainerBackend, _ContainerInspection, + _docker_server_is_desktop, _format_container_command_for_log, _format_container_mount, _NetworkInspection, @@ -185,9 +186,36 @@ def test_docker_desktop_detection_uses_daemon_operating_system(monkeypatch, oper assert cmd == ["docker", "info", "--format", "{{json .OperatingSystem}}"] return SimpleNamespace(stdout=operating_system, stderr="", returncode=0) + _docker_server_is_desktop.cache_clear() monkeypatch.setattr("subprocess.run", fake_run) - assert backend._docker_server_is_desktop() is expected + try: + assert backend._docker_server_is_desktop() is expected + finally: + _docker_server_is_desktop.cache_clear() + + +def test_docker_desktop_detection_retries_after_transient_failure(monkeypatch): + """A transient probe failure is not cached; subsequent call can detect Desktop.""" + attempts = 0 + + def fake_run(cmd, **_kwargs): + nonlocal attempts + attempts += 1 + if attempts == 1: + return SimpleNamespace(stdout="", stderr="daemon starting", returncode=1) + return SimpleNamespace(stdout='"Docker Desktop"', stderr="", returncode=0) + + _docker_server_is_desktop.cache_clear() + monkeypatch.setattr("subprocess.run", fake_run) + + try: + assert _docker_server_is_desktop() is False + assert _docker_server_is_desktop() is True + assert _docker_server_is_desktop() is True + assert attempts == 2 + finally: + _docker_server_is_desktop.cache_clear() def test_darwin_open_keeps_docker_to_reconcile_restricted_sandbox(monkeypatch): @@ -651,6 +679,10 @@ def test_resolve_docker_bind_host_follows_host_gateway_mapping_for_dood(monkeypa """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._docker_server_is_desktop", + lambda: False, + ) monkeypatch.setattr( "deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address", lambda host: "192.168.64.1", @@ -659,10 +691,68 @@ def test_resolve_docker_bind_host_follows_host_gateway_mapping_for_dood(monkeypa assert _resolve_docker_bind_host() == "192.168.64.1" +@pytest.mark.parametrize( + "sandbox_host", + [ + "host.docker.internal", + "gateway.docker.internal", + "Host.Docker.Internal.", + "docker.for.mac.host.internal", + "docker.for.win.localhost", + ], +) +def test_resolve_docker_bind_host_uses_loopback_on_docker_desktop(monkeypatch, sandbox_host): + """Docker Desktop cannot bind to internal VM gateway IPs, so default to 127.0.0.1.""" + monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False) + monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", sandbox_host) + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop", + lambda: True, + ) + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address", + lambda host: "192.168.65.254", + ) + + assert _resolve_docker_bind_host() == "127.0.0.1" + + +def test_resolve_docker_bind_host_preserves_custom_host_on_docker_desktop(monkeypatch): + """Custom non-loopback sandbox host on Docker Desktop binds the resolved address.""" + monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False) + monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "desktop-box") + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop", + lambda: True, + ) + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address", + lambda host: "192.0.2.55", + ) + + assert _resolve_docker_bind_host() == "192.0.2.55" + + +def test_resolve_docker_bind_host_explicit_override_precedes_desktop_detection(monkeypatch): + """Explicit DEER_FLOW_SANDBOX_BIND_HOST takes precedence even on Docker Desktop.""" + monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "192.0.2.10") + monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal") + monkeypatch.setattr( + "deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop", + lambda: True, + ) + + assert _resolve_docker_bind_host() == "192.0.2.10" + + 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._docker_server_is_desktop", + lambda: False, + ) monkeypatch.setattr( "deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address", lambda host: "[fd00::1]", @@ -715,6 +805,10 @@ def test_resolve_docker_bind_host_rejects_unresolvable_hostname_override(monkeyp 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._docker_server_is_desktop", + lambda: False, + ) monkeypatch.setattr( "deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address", lambda host: None, @@ -730,6 +824,10 @@ def test_resolve_docker_bind_host_uses_discovered_bridge_gateway_when_resolution 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._docker_server_is_desktop", + lambda: False, + ) monkeypatch.setattr( "deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address", lambda host: None,