diff --git a/.github/workflows/sandbox-network-proxy-image.yaml b/.github/workflows/sandbox-network-proxy-image.yaml new file mode 100644 index 000000000..98678f39d --- /dev/null +++ b/.github/workflows/sandbox-network-proxy-image.yaml @@ -0,0 +1,78 @@ +name: Publish Sandbox Network Proxy + +on: + pull_request: + paths: + - "docker/sandbox-network-proxy/**" + - "backend/packages/harness/deerflow/community/aio_sandbox/network_proxy.py" + - ".github/workflows/sandbox-network-proxy-image.yaml" + push: + branches: + - main + paths: + - "docker/sandbox-network-proxy/**" + - "backend/packages/harness/deerflow/community/aio_sandbox/network_proxy.py" + - ".github/workflows/sandbox-network-proxy-image.yaml" +env: + REGISTRY: ghcr.io + IMAGE_NAME: bytedance/deer-flow-sandbox-network-proxy + +jobs: + validate: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + - name: Build image + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + file: docker/sandbox-network-proxy/Dockerfile + platforms: linux/amd64,linux/arm64 + push: false + + publish: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'bytedance/deer-flow' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + - name: Log in to the container registry + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and publish image + id: build + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + file: docker/sandbox-network-proxy/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }} + - name: Generate artifact attestation + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/README.md b/README.md index 404d8ba4b..4e7cb73c1 100644 --- a/README.md +++ b/README.md @@ -1292,6 +1292,19 @@ downloads at the Gateway boundary. With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows. Host bash commands have a wall-clock timeout, and long-lived processes should be started in the background with output redirected to a workspace log. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes, so host-native CLI launchers retain their normal MSYS compatibility. +Docker AIO sandboxes default to their existing open egress behavior for +compatibility. Operators can set `sandbox.network.mode` to `isolated` or +`allowlist`; allowlist mode supports operator-defined domains and an interactive +Human Input card for temporary or sandbox-lifetime HTTP(S) approval. Private, +loopback, link-local, multicast, and cloud metadata addresses remain +unapprovable. Denied hostnames are rejected before DNS resolution, and +scheduled or otherwise non-interactive runs auto-deny without opening a card. +The trusted sidecar uses a dedicated per-sandbox egress bridge rather than +Docker's shared default bridge, and rejects ambiguous HTTP field names before +forwarding. See +[Sandbox configuration](backend/docs/CONFIGURATION.md#sandbox-network-policy) +for runtime requirements and the complete policy model. + `AioSandboxProvider` normally detects thread-data mounts from its backend: local containers use the mounted gateway directories, while remote/provisioner sandboxes receive uploaded files through explicit synchronization. Deployments diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index f6b7879c0..491f7b254 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -664,6 +664,91 @@ 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`. +#### Sandbox network policy + +Local Docker AIO sandboxes can opt into an outbound policy: + +```yaml +sandbox: + use: deerflow.community.aio_sandbox:AioSandboxProvider + network: + mode: allowlist + allow_domains: + - pypi.org + - files.pythonhosted.org + - registry.npmjs.org + approval: prompt + temporary_grant_ttl: 300 +``` + +`open` (the compatibility default) keeps normal Docker egress. `isolated` +places each sandbox on a per-sandbox internal bridge and denies all outbound +traffic. `allowlist` uses the same bridge and a trusted sidecar that supports +HTTP and HTTPS CONNECT only. Exact domains and leading wildcards such as +`*.pythonhosted.org` are accepted; URLs, ports, and a catch-all `*` are +rejected. Traffic that ignores proxy environment variables still has no route +out of the internal bridge. DeerFlow also sets the upstream AIO image's +`PROXY_SERVER`/`PROXY_EXCLUDE` variables so its Chromium service uses the same +policy sidecar; standard upper/lower-case HTTP, HTTPS, and ALL proxy variables +cover shell and package-manager clients. + +The sidecar is dual-homed between the sandbox's internal bridge and a separate +per-sandbox egress bridge with inter-container communication disabled. It is +never attached to Docker's shared default `bridge`, so unrelated containers +cannot reach its container address directly. Its published sandbox-API relay +also requires a cryptographically random per-sandbox token, so containers on +other bridge networks cannot use Docker's host-port mapping to bypass that +separation. Only the sidecar is attached to the egress bridge; outbound traffic +still goes through Docker NAT. + +Plain HTTP connections carry exactly one fully framed, policy-checked request; +the sidecar closes the upstream connection afterward so a pipelined request +cannot reuse the first request's decision. HTTP field names are parsed once, +must use the RFC token grammar with the colon immediately following the name, +and malformed or ambiguous fields are rejected before any policy lookup or +forwarding. HTTPS CONNECT validates both the +CONNECT authority and the TLS ClientHello SNI without intercepting TLS. Because +the encrypted HTTP `Host`/`:authority` remains invisible, a deliberately +malicious client may still reach another virtual host co-located on an allowed +endpoint if that server accepts a mismatched inner authority. Deployments that +require strict origin-level HTTPS isolation should use `isolated` mode or an +operator-managed TLS-inspecting egress gateway. + +With `approval: prompt`, a denied public domain becomes a Human Input card with +**Deny**, **Allow temporarily**, and **Allow for this sandbox** choices. DeerFlow +does not replay the failed command after approval because it may already have +performed local side effects; the agent must retry it explicitly. Non-interactive +runs auto-deny without opening a card or waiting for input. The sidecar rejects +hostnames that policy does not allow before DNS resolution; allowed hostnames +that resolve to loopback, private, link-local, multicast, IPv6 ULA/site-local, +or cloud metadata destinations are rejected and can never be approved. Raw +TCP/UDP, Git-over-SSH, and other non-HTTP protocols remain unavailable in +restricted modes. + +Restricted modes currently require the local Docker backend and Docker Engine +28 or newer. They fail closed on Apple Container, provisioner mode, and older +engines. DeerFlow applies Engine 28's isolated bridge gateway mode to both IPv4 +and IPv6 so the sandbox cannot reach services bound to either host-side bridge +address. The sandbox, sidecar, internal network, and egress network carry a +digest of the effective policy, proxy source, and image reference; startup and +reconciliation destroy and recreate a persisted resource set when that identity +or its required network properties no longer match. Docker sandboxes in every +mode also carry stable identity and mode labels. After a Gateway restart, +changing between `open` and a restricted mode is therefore reported as an +incompatible persisted sandbox and replaced only after the normal ownership, +orphan-grace, and teardown fences. Unlabelled open containers from older +DeerFlow versions are recognized when they use the configured image and retain +their published API port. Docker Desktop is detected +from the daemon, not the Gateway process, so Docker-outside-of-Docker deployments +handle its synthetic DNS range correctly. The policy sidecar publishes only its +fixed sandbox-API relay back to the Gateway; the sandbox API itself is not +published. DeerFlow generates a separate relay token for each sandbox, requires +it on every new relay connection, reconstructs it from Docker during discovery, +and injects it only into Gateway control-plane clients. The token is excluded +from `SandboxInfo` serialization, representations, and command logs. +Mirror or digest-pin `network.proxy_image` in production environments that +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. @@ -682,9 +767,9 @@ A custom image that is already fully initialized as a non-root user and needs no | `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:`, and `none` are rejected at startup (including through Docker's extended `name=` syntax, whose effective target is validated): 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; `none` leaves the container loopback-only, so the published sandbox API port cannot receive traffic and every acquisition would time out. | +| `DEER_FLOW_SANDBOX_NETWORK` | unset (daemon default network) | Legacy `open`-mode escape hatch passed through as `--network`. Prefer `sandbox.network` for managed isolation. `host`, `container:`, and `none` are rejected at startup. Restricted modes ignore this variable and use their own per-sandbox internal network. | -These hardening flags are Docker-only; Apple Container (`container` runtime) keeps its previous, unhardened invocation. +These hardening flags are Docker-only; Apple Container (`container` runtime) keeps its previous, unhardened invocation and therefore supports only `network.mode: open`. On macOS, an `open` Gateway normally prefers Apple Container, but it keeps using Docker while the configured sandbox prefix has managed Docker sandboxes so startup reconciliation can safely replace resources left by a restricted-mode deployment before the runtime changes. 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/AGENTS.md b/backend/packages/harness/deerflow/AGENTS.md index cd1a066d7..06286bb0c 100644 --- a/backend/packages/harness/deerflow/AGENTS.md +++ b/backend/packages/harness/deerflow/AGENTS.md @@ -89,6 +89,18 @@ CI. **Gateway Conformance Tests** (`TestGatewayConformance`): Validate that every dict-returning client method conforms to the corresponding Gateway Pydantic response model. Each test parses the client output through the Gateway model — if Gateway adds a required field that the client doesn't provide, Pydantic raises `ValidationError` and CI catches the drift. Covers: `ModelsListResponse`, `ModelResponse`, `SkillsListResponse`, `SkillResponse`, `SkillInstallResponse`, `McpConfigResponse`, `UploadResponse`, `MemoryConfigResponse`, `MemoryStatusResponse`. +### AIO Sandbox Network Policy + +Restricted AIO keeps sandboxes internal; a per-sandbox, ICC-disabled sidecar +handles egress and its token-authenticated API relay. Parse headers strictly; +reject policy-denied names before DNS and try all validated answers. Claim the +oldest unsurfaced denial; subagent/non-interactive runs drain and deny. Approvals +never replay tools; policy labels fence reuse. CONNECT/SNI cannot inspect +encrypted authority. Discovery and enumeration are read-only, including on a +policy or network-mode mismatch; only the provider may replace it after the +orphan grace, local teardown reservation, and cross-instance teardown lease. +Destroy the sandbox, sidecar, and both networks together. + ### E2B Mount Uploads The E2B provider uploads host mounts during sandbox creation. It passes binary file objects to the E2B SDK. diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py index 999f18f18..4b56f96e4 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py @@ -59,25 +59,35 @@ class AioSandbox(Sandbox): #: the next — recorded bash evidence cannot prove a clean environment. persistent_shell_sessions = True - def __init__(self, id: str, base_url: str, home_dir: str | None = None): + def __init__( + self, + id: str, + base_url: str, + home_dir: str | None = None, + request_headers: dict[str, str] | None = None, + ): """Initialize the AIO sandbox. Args: id: Unique identifier for this sandbox instance. base_url: URL of the sandbox API (e.g., http://localhost:8080). home_dir: Home directory inside the sandbox. If None, will be fetched from the sandbox. + request_headers: Trusted control-plane headers required by a local + relay. These are never injected into sandbox commands. """ super().__init__(id) self._base_url = base_url + client_kwargs = { + "base_url": base_url, + "timeout": 600, + } + if request_headers: + client_kwargs["headers"] = dict(request_headers) if sandbox_http_trust_env(base_url): - self._client = AioSandboxClient(base_url=base_url, timeout=600) + self._client = AioSandboxClient(**client_kwargs) else: direct_client = httpx.Client(timeout=600, follow_redirects=True, trust_env=False) - self._client = AioSandboxClient( - base_url=base_url, - timeout=600, - httpx_client=direct_client, - ) + self._client = AioSandboxClient(**client_kwargs, httpx_client=direct_client) self._home_dir = home_dir self._lock = threading.Lock() self._scope_registry_lock = threading.Lock() diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py index 817668e10..689c31063 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py @@ -86,6 +86,14 @@ class SandboxBeingDestroyedError(RuntimeError): self.sandbox_id = sandbox_id +class SandboxPolicyReplacementDeferredError(RuntimeError): + """An incompatible sandbox cannot be replaced until it is a true orphan.""" + + def __init__(self, sandbox_id: str) -> None: + super().__init__(f"sandbox {sandbox_id} has an incompatible provisioning policy; replacement is deferred until its current owner releases it") + self.sandbox_id = sandbox_id + + class SandboxIdentityCollisionError(RuntimeError): """A deterministic ID is already tracked for a different user/thread.""" @@ -248,6 +256,8 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): """ provisioner_url = self._config.get("provisioner_url") if provisioner_url: + if self.sandbox_network_mode() != "open": + raise RuntimeError("sandbox.network restricted modes are currently supported only by the local Docker AIO backend") logger.info(f"Using remote sandbox backend with provisioner at {provisioner_url}") api_key = self._config.get("provisioner_api_key", "") return RemoteSandboxBackend(provisioner_url=provisioner_url, api_key=api_key) @@ -259,6 +269,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): container_prefix=self._config["container_prefix"], config_mounts=self._config["mounts"], environment=self._config["environment"], + network_config=self._config["network"], ) # ── Configuration ──────────────────────────────────────────────────── @@ -287,6 +298,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): "mounts": sandbox_config.mounts or [], "thread_data_mounts": getattr(sandbox_config, "thread_data_mounts", None), "environment": self._resolve_env_vars(sandbox_config.environment or {}), + "network": sandbox_config.network.model_dump(), "ownership": getattr(sandbox_config, "ownership", None), # A redis stream bridge means the deployment is multi-instance, which # is what the ownership store must default to. Read the same source @@ -300,6 +312,27 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): ), } + def sandbox_network_mode(self) -> str: + return str(self._config.get("network", {}).get("mode", "open")) + + def sandbox_network_temporary_grant_ttl(self) -> int: + return int(self._config.get("network", {}).get("temporary_grant_ttl", 300)) + + def consume_network_policy_events(self, sandbox_id: str) -> list[dict[str, object]]: + if not isinstance(self._backend, LocalContainerBackend): + return [] + return self._backend.consume_network_policy_events(sandbox_id) + + def deny_pending_network_policy_events(self, sandbox_id: str) -> bool: + if not isinstance(self._backend, LocalContainerBackend): + return True + return self._backend.deny_pending_network_policy_events(sandbox_id) + + def decide_network_policy_request(self, sandbox_id: str, request_id: str, decision: str) -> bool: + if not isinstance(self._backend, LocalContainerBackend): + return False + return self._backend.decide_network_policy_request(sandbox_id, request_id, decision) + @staticmethod def _resolve_env_vars(env_config: dict[str, str]) -> dict[str, str]: """Resolve environment variable references (values starting with $).""" @@ -628,6 +661,42 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): first_seen = self._unowned_since.setdefault(sandbox_id, now) return now - first_seen >= compute_lease_ttl(self._ownership_config) + def _replace_incompatible_sandbox(self, info: SandboxInfo, now: float) -> bool: + """Destroy an incompatible sandbox only after both ownership fences. + + Backends report policy mismatches through ``SandboxInfo`` without + mutating Docker state. That is essential during rolling upgrades: an + older Gateway may still be serving the container under a live lease. + Replacement is therefore an orphan-reconciliation operation, not a + discovery side effect. The recovery grace protects against ownership + store state loss, the teardown lease excludes peers, and the local + reservation excludes this provider's own acquire/reaper paths. + """ + if not info.requires_replacement: + return False + if not self._adoptable_after_grace(info.sandbox_id, now): + return False + if not self._reserve_local_teardown( + info.sandbox_id, + lambda: info.sandbox_id not in self._sandboxes and info.sandbox_id not in self._sandbox_infos and info.sandbox_id not in self._warm_pool, + ): + return False + + try: + if not self._claim_ownership(info.sandbox_id, for_destroy=True): + return False + try: + with self._held_teardown_lease(info.sandbox_id): + self._backend.destroy(info) + except Exception as e: + logger.warning("Failed to replace sandbox %s with incompatible provisioning policy: %s", info.sandbox_id, e) + return False + self._unowned_since.pop(info.sandbox_id, None) + logger.info("Removed orphaned sandbox %s with incompatible provisioning policy", info.sandbox_id) + return True + finally: + self._finish_local_teardown(info.sandbox_id) + def _reconcile_orphans(self) -> None: """Reconcile orphaned containers left by previous process lifecycles. @@ -662,11 +731,23 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): current_time = time.time() adopted = 0 + replaced = 0 skipped_live = 0 deferred = 0 for info in running: age = current_time - info.created_at if info.created_at > 0 else float("inf") + if info.requires_replacement: + if self._replace_incompatible_sandbox(info, current_time): + replaced += 1 + else: + deferred += 1 + logger.debug( + "Deferring replacement of container %s during reconciliation: owned, locally tracked, or not yet past the recovery grace", + info.sandbox_id, + ) + continue + if not self._adoptable_after_grace(info.sandbox_id, current_time): deferred += 1 logger.debug("Deferring container %s during reconciliation: owned, or not yet past the recovery grace", info.sandbox_id) @@ -708,8 +789,9 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): logger.info(f"Adopted container {info.sandbox_id} into warm pool (age: {age:.0f}s)") logger.info( - "Startup reconciliation complete: %s adopted into warm pool, %s skipped (live peer ownership), %s deferred (owned or within recovery grace), %s total found", + "Startup reconciliation complete: %s adopted into warm pool, %s incompatible orphan(s) replaced, %s skipped (live peer ownership), %s deferred (owned, locally tracked, or within recovery grace), %s total found", adopted, + replaced, skipped_live, deferred, len(running), @@ -1575,7 +1657,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): return None self._warm_pool_identity.pop(sandbox_id, None) info, _ = warm_item - sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url) + sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url, request_headers=info.request_headers) self._sandboxes[sandbox_id] = sandbox self._sandbox_infos[sandbox_id] = info self._active_sandbox_identity[sandbox_id] = key @@ -1607,6 +1689,8 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): prevent. The window is a peer's in-flight container stop, so the thread's next turn discovers nothing and cold-starts cleanly. """ + if info.requires_replacement: + raise SandboxPolicyReplacementDeferredError(info.sandbox_id) key = self._thread_key(thread_id, user_id) with self._lock: if self._being_torn_down_locally(info.sandbox_id): @@ -1618,7 +1702,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): self._assert_active_identity_available_locked(info.sandbox_id, key) self._assert_warm_identity_available_locked(info.sandbox_id, key) - sandbox = AioSandbox(id=info.sandbox_id, base_url=info.sandbox_url) + sandbox = AioSandbox(id=info.sandbox_id, base_url=info.sandbox_url, request_headers=info.request_headers) # Ownership first, so a failure cannot leave a tracked-but-unowned sandbox. # There is no container to roll back (we did not create it), but the # host-side HTTP client constructed above is ours and must not leak — @@ -1664,7 +1748,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): def _register_created_sandbox(self, thread_id: str | None, sandbox_id: str, info: SandboxInfo, *, user_id: str | None = None) -> str: """Track a newly-created sandbox in the active maps.""" - sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url) + sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url, request_headers=info.request_headers) key = ( self._thread_key( thread_id, @@ -1706,21 +1790,14 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): SandboxIdentityCollisionError, ): logger.error( - "Could not register new sandbox %s; destroying it rather than leaking an untracked container", + "Could not register new sandbox %s; attempting ownership-fenced cleanup", sandbox_id, ) try: sandbox.close() except Exception as e: logger.warning(f"Error closing sandbox {sandbox_id} during ownership rollback: {e}") - try: - self._backend.destroy(info) - except Exception as e: - logger.error( - "Failed to destroy sandbox %s after registration failure: %s", - sandbox_id, - e, - ) + self._destroy_unready_sandbox(sandbox_id, info) raise logger.info(f"Created sandbox {sandbox_id} for thread {thread_id} at {info.sandbox_url}") @@ -2020,7 +2097,11 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): # Backend discovery: another process may have created the container. discovered = self._backend.discover(sandbox_id) if discovered is not None: - return self._register_discovered_sandbox(thread_id, discovered, user_id=effective_user_id) + if discovered.requires_replacement: + if not self._replace_incompatible_sandbox(discovered, time.time()): + raise SandboxPolicyReplacementDeferredError(sandbox_id) + else: + return self._register_discovered_sandbox(thread_id, discovered, user_id=effective_user_id) return self._create_sandbox(thread_id, sandbox_id, user_id=effective_user_id) finally: @@ -2049,10 +2130,19 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): # Docker and perform a health check; keep it off the event loop. discovered = await asyncio.to_thread(self._backend.discover, sandbox_id) if discovered is not None: - # Registration publishes ownership, which is blocking store IO - # (filesystem or network depending on the backend) — same reason - # every other step in this coroutine is offloaded. - return await asyncio.to_thread(self._register_discovered_sandbox, thread_id, discovered, user_id=effective_user_id) + if discovered.requires_replacement: + replaced = await asyncio.to_thread( + self._replace_incompatible_sandbox, + discovered, + time.time(), + ) + if not replaced: + raise SandboxPolicyReplacementDeferredError(sandbox_id) + else: + # Registration publishes ownership, which is blocking store + # IO (filesystem or network depending on the backend) — same + # reason every other step in this coroutine is offloaded. + return await asyncio.to_thread(self._register_discovered_sandbox, thread_id, discovered, user_id=effective_user_id) return await self._create_sandbox_async(thread_id, sandbox_id, user_id=effective_user_id) finally: @@ -2160,7 +2250,8 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): ) # Wait for sandbox to be ready - if not wait_for_sandbox_ready(info.sandbox_url, timeout=SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT): + readiness_kwargs = {"headers": info.request_headers} if info.request_headers else {} + if not wait_for_sandbox_ready(info.sandbox_url, timeout=SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT, **readiness_kwargs): # 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 @@ -2206,7 +2297,12 @@ 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=SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT): + readiness_kwargs = {"headers": info.request_headers} if info.request_headers else {} + if not await wait_for_sandbox_ready_async( + info.sandbox_url, + timeout=SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT, + **readiness_kwargs, + ): # 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 diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/backend.py b/backend/packages/harness/deerflow/community/aio_sandbox/backend.py index 3a266fe42..8e8508e8e 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/backend.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/backend.py @@ -7,6 +7,7 @@ import ipaddress import logging import time from abc import ABC, abstractmethod +from collections.abc import Mapping from urllib.parse import urlparse import httpx @@ -48,7 +49,12 @@ def sandbox_http_trust_env(sandbox_url: str) -> bool: SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT = 60 -def wait_for_sandbox_ready(sandbox_url: str, timeout: int = 30) -> bool: +def wait_for_sandbox_ready( + sandbox_url: str, + timeout: int = 30, + *, + headers: Mapping[str, str] | None = None, +) -> bool: """Poll sandbox health endpoint until ready or timeout. Args: @@ -61,6 +67,8 @@ def wait_for_sandbox_ready(sandbox_url: str, timeout: int = 30) -> bool: start_time = time.time() with requests.Session() as session: session.trust_env = sandbox_http_trust_env(sandbox_url) + if headers: + session.headers.update(headers) while time.time() - start_time < timeout: try: response = session.get(f"{sandbox_url}/v1/sandbox", timeout=5) @@ -72,7 +80,13 @@ def wait_for_sandbox_ready(sandbox_url: str, timeout: int = 30) -> bool: return False -async def wait_for_sandbox_ready_async(sandbox_url: str, timeout: int = 30, poll_interval: float = 1.0) -> bool: +async def wait_for_sandbox_ready_async( + sandbox_url: str, + timeout: int = 30, + poll_interval: float = 1.0, + *, + headers: Mapping[str, str] | None = None, +) -> bool: """Async variant of sandbox readiness polling. Use this from async runtime paths so sandbox startup waits do not block the @@ -82,7 +96,13 @@ async def wait_for_sandbox_ready_async(sandbox_url: str, timeout: int = 30, poll loop = asyncio.get_running_loop() deadline = loop.time() + timeout - async with httpx.AsyncClient(timeout=5, trust_env=sandbox_http_trust_env(sandbox_url)) as client: + client_kwargs: dict[str, object] = { + "timeout": 5, + "trust_env": sandbox_http_trust_env(sandbox_url), + } + if headers: + client_kwargs["headers"] = dict(headers) + async with httpx.AsyncClient(**client_kwargs) as client: while True: remaining = deadline - loop.time() if remaining <= 0: @@ -175,7 +195,10 @@ class SandboxBackend(ABC): sandbox_id: The deterministic sandbox ID to look for. Returns: - SandboxInfo if found and healthy, None otherwise. + SandboxInfo if found, including ``requires_replacement=True`` when + the backend can identify an incompatible persisted provisioning + policy without safely adopting it. Enumeration must not destroy + resources; the provider owns replacement fencing. None otherwise. """ ... @@ -189,6 +212,9 @@ class SandboxBackend(ABC): The default implementation returns an empty list, which is correct for backends that don't manage local containers (e.g., RemoteSandboxBackend delegates lifecycle to the provisioner which handles its own cleanup). + Enumeration must be read-only. Backends report resources that need + replacement through ``SandboxInfo.requires_replacement`` so the + provider can apply ownership and local teardown fencing first. Returns: A list of SandboxInfo for all currently running sandboxes. 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 77b2cfc7f..967508dce 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py @@ -7,24 +7,55 @@ Handles container lifecycle, port allocation, and cross-process container discov from __future__ import annotations import csv +import hashlib import ipaddress import json import logging import os +import platform import posixpath +import secrets import shlex import socket import subprocess +import time +from dataclasses import dataclass from datetime import datetime +from pathlib import Path from deerflow.utils.network import get_free_port, release_port from .backend import SandboxBackend, wait_for_sandbox_ready +from .network_proxy import RELAY_AUTH_HEADER, RELAY_TOKEN_ENV from .sandbox_info import SandboxInfo logger = logging.getLogger(__name__) +class _ExistingRestrictedSandbox(RuntimeError): + def __init__(self, info: SandboxInfo): + super().__init__(f"restricted sandbox {info.sandbox_id} already exists") + self.info = info + + +@dataclass(frozen=True) +class _ContainerInspection: + created_at: float + host_port: int | None + labels: dict[str, str] + image: str + networks: frozenset[str] + relay_token: str | None = None + + +@dataclass(frozen=True) +class _NetworkInspection: + driver: str + internal: bool + labels: dict[str, str] + options: dict[str, str] + + def _parse_docker_timestamp(raw: str) -> float: """Parse Docker's ISO 8601 timestamp into a Unix epoch float. @@ -71,6 +102,16 @@ def _extract_host_port(inspect_entry: dict, container_port: int) -> int | None: return None +def _extract_container_environment(config: dict, name: str) -> str | None: + """Read one exact environment value from Docker inspect data.""" + prefix = f"{name}=" + for item in config.get("Env") or []: + if isinstance(item, str) and item.startswith(prefix): + value = item[len(prefix) :] + return value or None + return None + + def _format_container_mount(runtime: str, host_path: str, container_path: str, read_only: bool) -> list[str]: """Format a bind-mount argument for the selected runtime. @@ -187,6 +228,11 @@ _DOCKER_BRIDGE_GATEWAY_FALLBACK = "172.17.0.1" _DEFAULT_SANDBOX_MEMORY = "2g" _DEFAULT_SANDBOX_CPUS = "2" _DEFAULT_SANDBOX_PIDS_LIMIT = "512" +_NETWORK_PROXY_CONTAINER_SCRIPT = "/tmp/deerflow-network-proxy.py" +_NETWORK_POLICY_DIGEST_LABEL = "deerflow.network_policy_digest" +_NETWORK_GATEWAY_MODE_IPV4 = "com.docker.network.bridge.gateway_mode_ipv4" +_NETWORK_GATEWAY_MODE_IPV6 = "com.docker.network.bridge.gateway_mode_ipv6" +_NETWORK_ENABLE_ICC = "com.docker.network.bridge.enable_icc" def _docker_bridge_gateway_ip() -> str | None: @@ -466,6 +512,7 @@ class LocalContainerBackend(SandboxBackend): container_prefix: str, config_mounts: list, environment: dict[str, str], + network_config: dict[str, object] | None = None, ): """Initialize the local container backend. @@ -481,13 +528,260 @@ class LocalContainerBackend(SandboxBackend): self._container_prefix = container_prefix self._config_mounts = config_mounts self._environment = environment + self._network_config = network_config or {"mode": "open"} + self._network_mode = str(self._network_config.get("mode", "open")) + self._allow_synthetic_dns = False self._runtime = self._detect_runtime() + if self._network_mode != "open": + if self._runtime != "docker": + raise RuntimeError("sandbox.network restricted modes require Docker; Apple Container is not supported") + self._require_restricted_network_support() + self._allow_synthetic_dns = self._docker_server_is_desktop() + @property def runtime(self) -> str: """The detected container runtime ("docker" or "container").""" return self._runtime + @property + def network_mode(self) -> str: + return self._network_mode + + def _resource_names(self, sandbox_id: str) -> tuple[str, str]: + digest = hashlib.sha256(f"{self._container_prefix}:{sandbox_id}".encode()).hexdigest()[:16] + return f"deer-flow-netproxy-{digest}", f"deer-flow-sandbox-net-{digest}" + + def _egress_network_name(self, sandbox_id: str) -> str: + digest = hashlib.sha256(f"{self._container_prefix}:{sandbox_id}".encode()).hexdigest()[:16] + return f"deer-flow-sandbox-egress-{digest}" + + def _proxy_image(self) -> str: + return str( + self._network_config.get( + "proxy_image", + "ghcr.io/bytedance/deer-flow-sandbox-network-proxy:latest", + ) + ) + + def _sandbox_labels(self, sandbox_id: str) -> dict[str, str]: + """Return the stable identity shared by every Docker sandbox mode.""" + return { + "deerflow.sandbox_id": sandbox_id, + "deerflow.role": "sandbox", + "deerflow.network_mode": self._network_mode, + } + + def _network_policy_digest(self) -> str: + allow_domains = self._network_config.get("allow_domains", []) + canonical_domains = sorted({value for value in allow_domains if isinstance(value, str)}) if isinstance(allow_domains, list) else [] + proxy_source = Path(__file__).with_name("network_proxy.py").read_bytes() + material = { + "schema": 1, + "mode": self._network_mode, + "allow_domains": canonical_domains, + "approval": self._network_config.get("approval", "prompt"), + "temporary_grant_ttl": self._network_config.get("temporary_grant_ttl", 300), + "proxy_image": self._proxy_image(), + "proxy_source_sha256": hashlib.sha256(proxy_source).hexdigest(), + "allow_synthetic_dns": self._allow_synthetic_dns, + "network": { + "driver": "bridge", + "internal": True, + "gateway_mode_ipv4": "isolated", + "gateway_mode_ipv6": "isolated", + }, + "egress_network": { + "driver": "bridge", + "internal": False, + "enable_icc": False, + }, + } + encoded = json.dumps(material, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + def _restricted_labels(self, sandbox_id: str, role: str) -> dict[str, str]: + return { + "deerflow.sandbox_id": sandbox_id, + "deerflow.role": role, + "deerflow.network_mode": self._network_mode, + _NETWORK_POLICY_DIGEST_LABEL: self._network_policy_digest(), + } + + def _persisted_sandbox_mode(self, sandbox: _ContainerInspection, sandbox_id: str) -> str | None: + """Classify an inspected container without claiming or mutating it. + + Matching DeerFlow identity labels are authoritative. Unlabelled + containers can only be legacy ``open`` sandboxes: open mode preserves + the historical name-based discovery contract, while a restricted + process accepts the narrower legacy shape of the configured image with + a published API port. Any partial/mismatched DeerFlow identity is left + unmanaged so a configurable prefix cannot turn a sidecar or unrelated + labelled container into a sandbox. + """ + labels = sandbox.labels + role = labels.get("deerflow.role") + labelled_id = labels.get("deerflow.sandbox_id") + labelled_mode = labels.get("deerflow.network_mode") + identity_keys_present = any(key in labels for key in ("deerflow.role", "deerflow.sandbox_id", "deerflow.network_mode")) + + if role == "sandbox" and labelled_id == sandbox_id: + # A missing/unknown value still proves DeerFlow ownership, but it + # cannot be adopted under any current policy. Returning a sentinel + # routes it through the fenced replacement path. + return labelled_mode or "unknown" + if identity_keys_present: + return None + + if self._network_mode == "open": + return "open" + if sandbox.host_port is not None and sandbox.image == self._image: + return "open" + return None + + @staticmethod + def _labels_match(actual: dict[str, str], expected: dict[str, str]) -> bool: + return all(actual.get(key) == value for key, value in expected.items()) + + def _inspect_network(self, network_name: str) -> _NetworkInspection | None: + try: + result = subprocess.run( + ["docker", "network", "inspect", network_name], + capture_output=True, + text=True, + timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc: + raise RuntimeError(f"Failed to inspect restricted sandbox network {network_name}") from exc + if result.returncode != 0: + stderr = (result.stderr or "").lower() + if "not found" in stderr and (network_name.lower() in stderr or "network" in stderr): + return None + raise RuntimeError(f"Failed to inspect restricted sandbox network {network_name}: {(result.stderr or '').strip()}") + try: + payload = json.loads(result.stdout or "[]") + entry = payload[0] + return _NetworkInspection( + driver=str(entry.get("Driver") or ""), + internal=entry.get("Internal") is True, + labels={str(key): str(value) for key, value in (entry.get("Labels") or {}).items()}, + options={str(key): str(value) for key, value in (entry.get("Options") or {}).items()}, + ) + except (IndexError, TypeError, AttributeError, json.JSONDecodeError) as exc: + raise RuntimeError(f"Docker returned invalid inspection data for restricted sandbox network {network_name}") from exc + + def _network_matches_policy(self, network: _NetworkInspection, sandbox_id: str) -> bool: + return ( + network.driver == "bridge" + and network.internal + and network.options.get(_NETWORK_GATEWAY_MODE_IPV4) == "isolated" + and network.options.get(_NETWORK_GATEWAY_MODE_IPV6) == "isolated" + and self._labels_match(network.labels, self._restricted_labels(sandbox_id, "network")) + ) + + def _egress_network_matches_policy(self, network: _NetworkInspection, sandbox_id: str) -> bool: + return network.driver == "bridge" and not network.internal and network.options.get(_NETWORK_ENABLE_ICC) == "false" and self._labels_match(network.labels, self._restricted_labels(sandbox_id, "egress-network")) + + def _restricted_resources_status( + self, + sandbox_id: str, + *, + inspections: dict[str, _ContainerInspection] | None = None, + ) -> str: + """Return missing, compatible, or mismatch for one restricted sandbox set.""" + container_name = f"{self._container_prefix}-{sandbox_id}" + proxy_name, network_name = self._resource_names(sandbox_id) + egress_network_name = self._egress_network_name(sandbox_id) + if inspections is None: + inspections = self._batch_inspect([container_name, proxy_name], strict=True) + sandbox = inspections.get(container_name) + proxy = inspections.get(proxy_name) + network = self._inspect_network(network_name) + egress_network = self._inspect_network(egress_network_name) + if sandbox is None and proxy is None and network is None and egress_network is None: + return "missing" + if sandbox is None or proxy is None or network is None or egress_network is None: + return "mismatch" + sandbox_matches = sandbox.host_port is None and sandbox.networks == frozenset({network_name}) and self._labels_match(sandbox.labels, self._restricted_labels(sandbox_id, "sandbox")) + proxy_matches = ( + proxy.host_port is not None + and proxy.image == self._proxy_image() + and proxy.networks == frozenset({egress_network_name, network_name}) + and isinstance(proxy.relay_token, str) + and len(proxy.relay_token) >= 32 + and self._labels_match(proxy.labels, self._restricted_labels(sandbox_id, "network-proxy")) + ) + return "compatible" if sandbox_matches and proxy_matches and self._network_matches_policy(network, sandbox_id) and self._egress_network_matches_policy(egress_network, sandbox_id) else "mismatch" + + def _require_restricted_network_support(self) -> None: + try: + result = subprocess.run( + ["docker", "version", "--format", "{{.Server.Version}}"], + capture_output=True, + text=True, + check=True, + timeout=10, + ) + major = int(result.stdout.strip().split(".", 1)[0]) + except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError) as exc: + raise RuntimeError("sandbox.network restricted modes require a reachable Docker Engine 28 or newer") from exc + if major < 28: + raise RuntimeError("sandbox.network restricted modes require Docker Engine 28 or newer so both internal bridge gateway families can use isolated mode") + + 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() + + def _docker_has_managed_sandboxes(self) -> bool: + """Keep using Docker while this prefix still has managed sandboxes. + + Restricted networking is Docker-only. On macOS, switching its config + back to ``open`` must not make Apple Container hide the Docker + resources that startup reconciliation needs to replace. The role + label excludes fixed-name proxy sidecars even when a custom sandbox + prefix overlaps their names. + """ + try: + result = subprocess.run( + [ + "docker", + "ps", + "--filter", + f"name={self._container_prefix}-", + "--filter", + "label=deerflow.role=sandbox", + "--format", + "{{.Names}}", + ], + capture_output=True, + text=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return False + if result.returncode != 0: + return False + prefix = self._container_prefix + "-" + return any(name.strip().startswith(prefix) for name in result.stdout.splitlines()) + def _detect_runtime(self) -> str: """Detect which container runtime to use. @@ -497,9 +791,7 @@ class LocalContainerBackend(SandboxBackend): Returns: "container" for Apple Container, "docker" for Docker. """ - import platform - - if platform.system() == "Darwin": + if platform.system() == "Darwin" and self._network_mode == "open": try: result = subprocess.run( ["container", "--version"], @@ -509,10 +801,16 @@ class LocalContainerBackend(SandboxBackend): timeout=5, ) logger.info(f"Detected Apple Container: {result.stdout.strip()}") + if self._docker_has_managed_sandboxes(): + logger.info("Keeping Docker runtime so managed sandboxes remain visible to startup reconciliation") + return "docker" return "container" except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired): logger.info("Apple Container not available, falling back to Docker") + if platform.system() == "Darwin" and self._network_mode != "open": + logger.info("sandbox.network mode %s requires Docker; skipping Apple Container detection", self._network_mode) + return "docker" # ── SandboxBackend interface ────────────────────────────────────────── @@ -561,17 +859,33 @@ class LocalContainerBackend(SandboxBackend): # so a reactive fallback here ensures we always make progress. _next_start = self._base_port container_id: str | None = None + relay_token: str | None = None port: int = 0 for _attempt in range(10): port = get_free_port(start_port=_next_start) try: - container_id = self._start_container( - container_name, - port, - extra_mounts, - config_mount_exclusion_root=config_mount_exclusion_root, - ) + if self._network_mode == "open": + container_id = self._start_container( + container_name, + port, + extra_mounts, + config_mount_exclusion_root=config_mount_exclusion_root, + labels=self._sandbox_labels(sandbox_id), + ) + else: + relay_token = secrets.token_urlsafe(32) + container_id = self._start_restricted_sandbox( + sandbox_id, + container_name, + port, + extra_mounts, + config_mount_exclusion_root=config_mount_exclusion_root, + relay_token=relay_token, + ) break + except _ExistingRestrictedSandbox as exc: + release_port(port) + return exc.info except RuntimeError as exc: release_port(port) err = str(exc) @@ -587,7 +901,7 @@ class LocalContainerBackend(SandboxBackend): if "is already in use by container" in err_lower or "conflict. the container name" in err_lower: logger.warning(f"Container name {container_name} already in use, attempting to discover existing sandbox instance") existing = self.discover(sandbox_id) - if existing is not None: + if existing is not None and not existing.requires_replacement: return existing raise else: @@ -601,8 +915,221 @@ class LocalContainerBackend(SandboxBackend): sandbox_url=f"http://{sandbox_host}:{port}", container_name=container_name, container_id=container_id, + request_headers={RELAY_AUTH_HEADER: relay_token} if relay_token is not None else {}, ) + def _start_restricted_sandbox( + self, + sandbox_id: str, + container_name: str, + port: int, + extra_mounts: list[tuple[str, str, bool]] | None, + *, + config_mount_exclusion_root: str | None, + relay_token: str, + ) -> str: + proxy_name, network_name = self._resource_names(sandbox_id) + egress_network_name = self._egress_network_name(sandbox_id) + resource_status = self._restricted_resources_status(sandbox_id) + if resource_status == "mismatch": + # Enumeration/provisioning is deliberately non-destructive. A + # mismatched set can still belong to a live Gateway from an older + # rolling-deployment revision, so only the provider may replace it + # after acquiring both teardown fences. + raise RuntimeError(f"Restricted sandbox {sandbox_id} requires ownership-fenced replacement") + if resource_status == "compatible": + existing = self.discover(sandbox_id) + if existing is not None and not existing.requires_replacement: + raise _ExistingRestrictedSandbox(existing) + raise RuntimeError(f"Restricted sandbox {sandbox_id} already exists but is not ready for adoption") + + try: + self._create_internal_network(network_name, sandbox_id) + self._create_egress_network(egress_network_name, sandbox_id) + self._start_network_proxy(proxy_name, network_name, egress_network_name, container_name, port, sandbox_id, relay_token) + proxy_url = f"http://{proxy_name}:3128" + return self._start_container( + container_name, + port, + extra_mounts, + config_mount_exclusion_root=config_mount_exclusion_root, + network_override=network_name, + publish_port=False, + extra_environment={ + "HTTP_PROXY": proxy_url, + "HTTPS_PROXY": proxy_url, + "ALL_PROXY": proxy_url, + "http_proxy": proxy_url, + "https_proxy": proxy_url, + "all_proxy": proxy_url, + "NO_PROXY": "localhost,127.0.0.1,::1", + "no_proxy": "localhost,127.0.0.1,::1", + # The upstream AIO image uses these to configure Chromium; + # Chromium does not consistently consume shell proxy vars. + "PROXY_SERVER": f"{proxy_name}:3128", + "PROXY_EXCLUDE": "localhost,127.0.0.1,::1", + }, + labels=self._restricted_labels(sandbox_id, "sandbox"), + ) + except BaseException as exc: + message = str(exc).lower() + if "is already in use by container" in message or "conflict. the container name" in message: + existing = self.discover(sandbox_id) + if existing is not None and not existing.requires_replacement: + raise _ExistingRestrictedSandbox(existing) from exc + # A peer may still be provisioning the deterministic resource + # set. Never roll it back just because its readiness check has + # not completed yet. + if self._restricted_resources_status(sandbox_id) != "missing": + raise + self._cleanup_restricted_resources(sandbox_id) + raise + + def _create_internal_network(self, network_name: str, sandbox_id: str) -> None: + existing = self._inspect_network(network_name) + if existing is not None: + if self._network_matches_policy(existing, sandbox_id): + return + raise RuntimeError(f"Restricted sandbox network {network_name} exists with incompatible policy or isolation settings") + labels = self._restricted_labels(sandbox_id, "network") + result = subprocess.run( + [ + "docker", + "network", + "create", + "--driver", + "bridge", + "--internal", + "--opt", + f"{_NETWORK_GATEWAY_MODE_IPV4}=isolated", + "--opt", + f"{_NETWORK_GATEWAY_MODE_IPV6}=isolated", + *(item for key, value in labels.items() for item in ("--label", f"{key}={value}")), + network_name, + ], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0: + raise RuntimeError(f"Failed to create restricted sandbox network: {result.stderr.strip()}") + + def _create_egress_network(self, network_name: str, sandbox_id: str) -> None: + existing = self._inspect_network(network_name) + if existing is not None: + if self._egress_network_matches_policy(existing, sandbox_id): + return + raise RuntimeError(f"Restricted sandbox egress network {network_name} exists with incompatible policy or isolation settings") + labels = self._restricted_labels(sandbox_id, "egress-network") + result = subprocess.run( + [ + "docker", + "network", + "create", + "--driver", + "bridge", + "--opt", + f"{_NETWORK_ENABLE_ICC}=false", + *(item for key, value in labels.items() for item in ("--label", f"{key}={value}")), + network_name, + ], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0: + raise RuntimeError(f"Failed to create restricted sandbox egress network: {result.stderr.strip()}") + + def _start_network_proxy( + self, + proxy_name: str, + network_name: str, + egress_network_name: str, + container_name: str, + port: int, + sandbox_id: str, + relay_token: str, + ) -> None: + allow_domains = self._network_config.get("allow_domains", []) + proxy_image = self._proxy_image() + labels = self._restricted_labels(sandbox_id, "network-proxy") + port_mapping = f"{_resolve_docker_bind_host()}:{port}:8080" + cmd = [ + "docker", + "create", + "--rm", + "--cap-drop=ALL", + "--security-opt", + "no-new-privileges", + "--memory", + "256m", + "--cpus", + "1", + "--pids-limit", + "128", + "--read-only", + "--tmpfs", + "/tmp:rw,noexec,nosuid,size=16m", + "--user", + "65532:65532", + "-p", + port_mapping, + "--network", + egress_network_name, + "--name", + proxy_name, + *(item for key, value in labels.items() for item in ("--label", f"{key}={value}")), + "-e", + f"DEERFLOW_NETWORK_MODE={self._network_mode}", + "-e", + f"DEERFLOW_ALLOW_DOMAINS_JSON={json.dumps(allow_domains, separators=(',', ':'))}", + "-e", + f"DEERFLOW_SANDBOX_TARGET={container_name}:8080", + "-e", + f"DEERFLOW_ALLOW_SYNTHETIC_DNS={'1' if self._allow_synthetic_dns else '0'}", + "-e", + f"DEERFLOW_RECORD_DENIALS={'1' if self._network_mode == 'allowlist' and self._network_config.get('approval', 'prompt') == 'prompt' else '0'}", + "-e", + f"{RELAY_TOKEN_ENV}={relay_token}", + proxy_image, + "sh", + "-c", + f"while [ ! -f {_NETWORK_PROXY_CONTAINER_SCRIPT} ]; do sleep 0.05; done; exec python {_NETWORK_PROXY_CONTAINER_SCRIPT} serve", + ] + # First use may pull the sidecar image. Match the sandbox create path's + # tolerance for an image download instead of killing Docker mid-pull. + created = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if created.returncode != 0: + raise RuntimeError(f"Failed to create sandbox network proxy: {created.stderr.strip()}") + connected = subprocess.run( + ["docker", "network", "connect", network_name, proxy_name], + capture_output=True, + text=True, + timeout=15, + ) + if connected.returncode != 0: + raise RuntimeError(f"Failed to connect sandbox network proxy: {connected.stderr.strip()}") + started = subprocess.run(["docker", "start", proxy_name], capture_output=True, text=True, timeout=15) + if started.returncode != 0: + raise RuntimeError(f"Failed to start sandbox network proxy: {started.stderr.strip()}") + source = Path(__file__).with_name("network_proxy.py") + copied = subprocess.run( + [ + "docker", + "exec", + "-i", + proxy_name, + "python", + "-c", + (f"import pathlib,sys; p=pathlib.Path({_NETWORK_PROXY_CONTAINER_SCRIPT!r}); tmp=p.with_suffix('.tmp'); tmp.write_bytes(sys.stdin.buffer.read()); tmp.replace(p)"), + ], + input=source.read_bytes(), + capture_output=True, + timeout=15, + ) + if copied.returncode != 0: + raise RuntimeError(f"Failed to install sandbox network proxy: {(copied.stderr or b'').decode(errors='replace').strip()}") + def destroy(self, info: SandboxInfo) -> None: """Stop the container and release its port.""" # Prefer container_id, fall back to container_name (both accepted by docker stop). @@ -611,6 +1138,12 @@ class LocalContainerBackend(SandboxBackend): stop_target = info.container_id or info.container_name if stop_target: self._stop_container(stop_target) + # An incompatible sandbox discovered while the new process is in open + # mode may have been provisioned by a previous restricted-mode process. + # Remove its deterministic sidecar/networks from this provider-owned, + # fenced destroy path as well (never from discovery itself). + if self._runtime == "docker" and (self._network_mode != "open" or info.requires_replacement): + self._cleanup_restricted_resources(info.sandbox_id, stop_sandbox=False) # Extract port from sandbox_url for release try: from urllib.parse import urlparse @@ -624,7 +1157,12 @@ class LocalContainerBackend(SandboxBackend): def is_alive(self, info: SandboxInfo) -> bool: """Check if the container is still running (lightweight, no HTTP).""" if info.container_name: - return self._is_container_running(info.container_name) + if not self._is_container_running(info.container_name): + return False + if self._network_mode != "open": + proxy_name, _ = self._resource_names(info.sandbox_id) + return self._is_container_running(proxy_name) and self._restricted_resources_status(info.sandbox_id) == "compatible" + return True return False def discover(self, sandbox_id: str) -> SandboxInfo | None: @@ -637,11 +1175,12 @@ class LocalContainerBackend(SandboxBackend): sandbox_id: The deterministic sandbox ID (determines container name). Returns: - SandboxInfo if container found and healthy, None otherwise. A - failed runtime check (e.g. transient daemon error) also returns - None — discovery must not adopt a container it cannot verify, and - falling through to create keeps acquire recoverable instead of - hard-failing on a hiccup. + SandboxInfo if a container is found and healthy, or a non-adoptable + SandboxInfo with ``requires_replacement=True`` when its persisted + restricted-network policy is incompatible. A failed runtime check + (e.g. transient daemon error) returns None — discovery must not + adopt a container it cannot verify, and falling through to create + keeps acquire recoverable instead of hard-failing on a hiccup. """ container_name = f"{self._container_prefix}-{sandbox_id}" @@ -654,36 +1193,108 @@ class LocalContainerBackend(SandboxBackend): if not running: return None - port = self._get_container_port(container_name) + request_headers: dict[str, str] = {} + restricted_port: int | None = None + created_at = time.time() + inspections: dict[str, _ContainerInspection] = {} + sandbox_inspection: _ContainerInspection | None = None + if self._runtime == "docker": + try: + inspections = self._batch_inspect([container_name], strict=True) + except RuntimeError as e: + logger.warning("Could not inspect persisted sandbox %s: %s", sandbox_id, e) + return None + sandbox_inspection = inspections.get(container_name) + if sandbox_inspection is None: + return None + persisted_mode = self._persisted_sandbox_mode(sandbox_inspection, sandbox_id) + if persisted_mode is None: + logger.warning( + "Container %s uses the sandbox name but lacks a compatible DeerFlow identity; leaving it unmanaged", + container_name, + ) + return None + created_at = sandbox_inspection.created_at + if persisted_mode != self._network_mode: + return SandboxInfo( + sandbox_id=sandbox_id, + sandbox_url="", + container_name=container_name, + created_at=created_at, + requires_replacement=True, + ) + + if self._network_mode != "open": + proxy_name, _ = self._resource_names(sandbox_id) + try: + inspections.update(self._batch_inspect([proxy_name], strict=True)) + resource_status = self._restricted_resources_status(sandbox_id, inspections=inspections) + except RuntimeError as e: + logger.warning("Could not verify persisted network policy for sandbox %s: %s", sandbox_id, e) + return None + if resource_status != "compatible": + return SandboxInfo( + sandbox_id=sandbox_id, + sandbox_url="", + container_name=container_name, + created_at=created_at, + requires_replacement=True, + ) + proxy_inspection = inspections.get(proxy_name) + if proxy_inspection is None or proxy_inspection.host_port is None or proxy_inspection.relay_token is None: + return None + restricted_port = proxy_inspection.host_port + request_headers = {RELAY_AUTH_HEADER: proxy_inspection.relay_token} + + if restricted_port is not None: + port = restricted_port + elif sandbox_inspection is not None: + port = sandbox_inspection.host_port + else: + # Apple Container is supported only in open mode and does not use + # Docker labels, so retain its native port-discovery path. + port = self._get_container_port(container_name) if port is None: - return None + return SandboxInfo( + sandbox_id=sandbox_id, + sandbox_url="", + container_name=container_name, + created_at=created_at, + requires_replacement=True, + ) 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): + readiness_kwargs = {"headers": request_headers} if request_headers else {} + if not wait_for_sandbox_ready(sandbox_url, timeout=5, **readiness_kwargs): return None return SandboxInfo( sandbox_id=sandbox_id, sandbox_url=sandbox_url, container_name=container_name, + created_at=created_at, + request_headers=request_headers, ) def list_running(self) -> list[SandboxInfo]: """Enumerate all running containers matching the configured prefix. Uses a single ``docker ps`` call to list container names, then a - single batched ``docker inspect`` call to retrieve creation timestamp - and port mapping for all containers at once. Total subprocess calls: - 2 (down from 2N+1 in the naive per-container approach). + batched ``docker inspect`` calls to retrieve creation timestamp, mode, + and port mapping. Restricted mode uses a second inspect only for the + proxies paired with already-identified restricted sandboxes, avoiding + fabricated resource names for sidecars caught by an overlapping custom + prefix. Total subprocess calls: 2 in open mode and at most 3 in a + restricted mode (down from 2N+1 in the naive per-container approach). Note: Docker's ``--filter name=`` performs *substring* matching, so a secondary ``startswith`` check is applied to ensure only containers with the exact prefix are included. - Containers without port mappings are still included (with empty - sandbox_url) so that startup reconciliation can adopt orphans - regardless of their port state. + Containers without a usable port mapping are still included with an + empty sandbox URL and ``requires_replacement=True`` so startup + reconciliation can remove them only after ownership fencing. """ # Step 1: enumerate container names via docker ps try: @@ -720,8 +1331,32 @@ class LocalContainerBackend(SandboxBackend): if not container_names: return [] - # Step 2: batched docker inspect — single subprocess call for all containers - inspections = self._batch_inspect(container_names) + # Step 2: inspect candidate containers before deriving any paired + # resource names. A custom prefix can overlap the fixed sidecar prefix, + # and only the inspected role label distinguishes that sidecar from a + # real sandbox. + try: + inspections = self._batch_inspect(container_names, strict=True) + except RuntimeError as e: + logger.warning("Failed to inspect running sandbox resources: %s", e) + return [] + + persisted_modes: dict[str, str | None] = {} + for container_name in container_names: + data = inspections.get(container_name) + if data is None: + continue + sandbox_id = container_name[len(self._container_prefix) + 1 :] + persisted_modes[container_name] = self._persisted_sandbox_mode(data, sandbox_id) if self._runtime == "docker" else "open" + + if self._network_mode != "open": + proxy_names = [self._resource_names(name[len(self._container_prefix) + 1 :])[0] for name, persisted_mode in persisted_modes.items() if persisted_mode == self._network_mode] + if proxy_names: + try: + inspections.update(self._batch_inspect(proxy_names, strict=True)) + except RuntimeError as e: + logger.warning("Failed to inspect running sandbox proxy resources: %s", e) + return [] infos: list[SandboxInfo] = [] sandbox_host = _normalize_sandbox_host_for_url(os.environ.get("DEER_FLOW_SANDBOX_HOST", "localhost")) @@ -730,8 +1365,39 @@ class LocalContainerBackend(SandboxBackend): if data is None: # Container disappeared between ps and inspect, or inspect failed continue - created_at, host_port = data sandbox_id = container_name[len(self._container_prefix) + 1 :] + persisted_mode = persisted_modes.get(container_name) + if persisted_mode is None: + # A custom prefix such as ``deer-flow`` also matches the fixed + # ``deer-flow-netproxy-*`` sidecar names. Inspecting the stable + # role/id identity excludes them while still allowing legacy + # open sandboxes to be reported for a fenced mode transition. + continue + created_at, host_port = data.created_at, data.host_port + request_headers: dict[str, str] = {} + requires_replacement = persisted_mode != self._network_mode + if not requires_replacement and self._network_mode != "open": + proxy_name, _ = self._resource_names(sandbox_id) + proxy_data = inspections.get(proxy_name) + try: + resource_status = self._restricted_resources_status(sandbox_id, inspections=inspections) + except RuntimeError as e: + logger.warning("Could not verify persisted network policy for sandbox %s during reconciliation: %s", sandbox_id, e) + continue + if resource_status != "compatible": + requires_replacement = True + host_port = None + else: + host_port = proxy_data.host_port if proxy_data is not None else None + if proxy_data is not None and proxy_data.relay_token is not None: + request_headers = {RELAY_AUTH_HEADER: proxy_data.relay_token} + elif not requires_replacement and host_port is None: + # An open-mode container without its published API port cannot + # be adopted. Report it instead of placing an unusable empty URL + # in the warm pool. + requires_replacement = True + if requires_replacement: + host_port = None sandbox_url = f"http://{sandbox_host}:{host_port}" if host_port else "" infos.append( @@ -740,16 +1406,106 @@ class LocalContainerBackend(SandboxBackend): sandbox_url=sandbox_url, container_name=container_name, created_at=created_at, + request_headers=request_headers, + requires_replacement=requires_replacement, ) ) logger.info(f"Found {len(infos)} running sandbox container(s)") return infos - def _batch_inspect(self, container_names: list[str]) -> dict[str, tuple[float, int | None]]: + def _cleanup_restricted_resources(self, sandbox_id: str, *, stop_sandbox: bool = True) -> None: + proxy_name, network_name = self._resource_names(sandbox_id) + egress_network_name = self._egress_network_name(sandbox_id) + if stop_sandbox: + self._stop_container(f"{self._container_prefix}-{sandbox_id}") + self._stop_container(proxy_name) + # ``--rm`` removes a container after it has run and then stopped, but + # not one left in Docker's Created state by a failure before start. + # An explicit remove closes that lifecycle gap and is harmless after a + # normal stop (Docker reports the already-removed name as not found). + removed = subprocess.run( + ["docker", "rm", "-f", proxy_name], + capture_output=True, + text=True, + timeout=15, + ) + if removed.returncode != 0 and "no such container" not in (removed.stderr or "").lower(): + logger.warning("Failed to remove sandbox network proxy %s: %s", proxy_name, removed.stderr.strip()) + for current_network_name in (network_name, egress_network_name): + result = subprocess.run( + ["docker", "network", "rm", current_network_name], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0 and "not found" not in (result.stderr or "").lower(): + logger.warning("Failed to remove sandbox network %s: %s", current_network_name, result.stderr.strip()) + + def consume_network_policy_events(self, sandbox_id: str) -> list[dict[str, object]]: + if self._network_mode != "allowlist" or self._network_config.get("approval", "prompt") != "prompt": + return [] + proxy_name, _ = self._resource_names(sandbox_id) + result = subprocess.run( + ["docker", "exec", proxy_name, "python", _NETWORK_PROXY_CONTAINER_SCRIPT, "pending"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + logger.warning("Failed to read sandbox network policy events for %s: %s", sandbox_id, result.stderr.strip()) + return [] + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError: + logger.warning("Sandbox network proxy returned invalid policy events for %s", sandbox_id) + return [] + return [event for event in payload if isinstance(event, dict)] if isinstance(payload, list) else [] + + def deny_pending_network_policy_events(self, sandbox_id: str) -> bool: + """Atomically deny every unsurfaced proxy event for one sandbox.""" + if self._network_mode != "allowlist" or self._network_config.get("approval", "prompt") != "prompt": + return True + proxy_name, _ = self._resource_names(sandbox_id) + result = subprocess.run( + ["docker", "exec", proxy_name, "python", _NETWORK_PROXY_CONTAINER_SCRIPT, "deny-pending"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + logger.warning("Failed to deny pending sandbox network policy events for %s: %s", sandbox_id, result.stderr.strip()) + return False + return True + + def decide_network_policy_request(self, sandbox_id: str, request_id: str, decision: str) -> bool: + if self._network_mode != "allowlist" or decision not in {"deny", "allow_temporary", "allow_sandbox"}: + return False + proxy_name, _ = self._resource_names(sandbox_id) + ttl = int(self._network_config.get("temporary_grant_ttl", 300)) + result = subprocess.run( + [ + "docker", + "exec", + proxy_name, + "python", + _NETWORK_PROXY_CONTAINER_SCRIPT, + "decide", + request_id, + decision, + "--ttl", + str(ttl), + ], + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 + + def _batch_inspect(self, container_names: list[str], *, strict: bool = False) -> dict[str, _ContainerInspection]: """Batch-inspect containers in a single subprocess call. - Returns a mapping of ``container_name -> (created_at, host_port)``. + Returns creation/port plus policy-relevant labels, image, and networks. Missing containers or parse failures are silently dropped from the result. """ if not container_names: @@ -762,26 +1518,34 @@ class LocalContainerBackend(SandboxBackend): timeout=15, ) except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: + if strict: + raise RuntimeError("Failed to batch-inspect containers") from e logger.warning(f"Failed to batch-inspect containers: {e}") return {} if result.returncode != 0: stderr = (result.stderr or "").strip() - logger.warning( - "Failed to batch-inspect containers with %s inspect (returncode=%s, stderr=%s)", - self._runtime, - result.returncode, - stderr or "", - ) - return {} + missing = "no such object" in stderr.lower() or "no such container" in stderr.lower() + if not missing: + if strict: + raise RuntimeError(f"Failed to batch-inspect containers with {self._runtime} inspect: {stderr or ''}") + logger.warning( + "Failed to batch-inspect containers with %s inspect (returncode=%s, stderr=%s)", + self._runtime, + result.returncode, + stderr or "", + ) + return {} try: payload = json.loads(result.stdout or "[]") except json.JSONDecodeError as e: + if strict: + raise RuntimeError("Failed to parse container inspection data") from e logger.warning(f"Failed to parse docker inspect output as JSON: {e}") return {} - out: dict[str, tuple[float, int | None]] = {} + out: dict[str, _ContainerInspection] = {} for entry in payload: # ``Name`` is prefixed with ``/`` in the docker inspect response name = (entry.get("Name") or "").lstrip("/") @@ -789,7 +1553,16 @@ class LocalContainerBackend(SandboxBackend): continue created_at = _parse_docker_timestamp(entry.get("Created", "")) host_port = _extract_host_port(entry, 8080) - out[name] = (created_at, host_port) + config = entry.get("Config") or {} + network_settings = entry.get("NetworkSettings") or {} + out[name] = _ContainerInspection( + created_at=created_at, + host_port=host_port, + labels={str(key): str(value) for key, value in (config.get("Labels") or {}).items()}, + image=str(config.get("Image") or ""), + networks=frozenset(str(value) for value in (network_settings.get("Networks") or {})), + relay_token=_extract_container_environment(config, RELAY_TOKEN_ENV), + ) return out # ── Container operations ───────────────────────────────────────────── @@ -801,6 +1574,10 @@ class LocalContainerBackend(SandboxBackend): extra_mounts: list[tuple[str, str, bool]] | None = None, *, config_mount_exclusion_root: str | None = None, + network_override: str | None = None, + publish_port: bool = True, + extra_environment: dict[str, str] | None = None, + labels: dict[str, str] | None = None, ) -> str: """Start a new container. @@ -918,7 +1695,10 @@ class LocalContainerBackend(SandboxBackend): # 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(): + network = network_override + if network is None: + network = os.environ.get("DEER_FLOW_SANDBOX_NETWORK", "").strip() + if network: # Validate the *effective* target: Docker accepts the extended # "name=" long syntax in addition to plain names and # network IDs, and "name=host" / "name=none" attach exactly @@ -956,25 +1736,24 @@ class LocalContainerBackend(SandboxBackend): # the legit name= long form all keep working. cmd.extend(["--network", network]) - if self._runtime == "docker": - port_mapping = f"{_resolve_docker_bind_host()}:{port}:8080" - else: - port_mapping = f"{port}:8080" + cmd.extend(["--rm", "-d"]) + if publish_port: + if self._runtime == "docker": + port_mapping = f"{_resolve_docker_bind_host()}:{port}:8080" + else: + port_mapping = f"{port}:8080" + cmd.extend(["-p", port_mapping]) + cmd.extend(["--name", container_name]) - cmd.extend( - [ - "--rm", - "-d", - "-p", - port_mapping, - "--name", - container_name, - ] - ) + if labels and self._runtime == "docker": + for key, value in labels.items(): + cmd.extend(["--label", f"{key}={value}"]) # Environment variables for key, value in self._environment.items(): cmd.extend(["-e", f"{key}={value}"]) + for key, value in (extra_environment or {}).items(): + cmd.extend(["-e", f"{key}={value}"]) # Config-level volume mounts. A policy-scoped skills view owns its # complete container subtree; keeping a more-specific config mount diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/network_proxy.py b/backend/packages/harness/deerflow/community/aio_sandbox/network_proxy.py new file mode 100644 index 000000000..4c2972a80 --- /dev/null +++ b/backend/packages/harness/deerflow/community/aio_sandbox/network_proxy.py @@ -0,0 +1,654 @@ +"""Trusted HTTP(S) policy proxy used by restricted local AIO sandboxes. + +The module is copied into a small sidecar container and executed as a script. +It deliberately supports only HTTP absolute-form requests and HTTPS CONNECT; +all other protocols remain unavailable on the sandbox's internal-only network. +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import hmac +import ipaddress +import json +import os +import socket +import sqlite3 +import time +import uuid +from pathlib import Path +from urllib.parse import urlsplit + +MAX_HEADER_BYTES = 65_536 +POLICY_DB = Path(os.environ.get("DEERFLOW_POLICY_DB", "/tmp/deerflow-network-policy.sqlite3")) +RELAY_AUTH_HEADER = "X-DeerFlow-Relay-Token" +RELAY_TOKEN_ENV = "DEERFLOW_RELAY_TOKEN" + + +class _InvalidHttpRequest(ValueError): + pass + + +class _InvalidHttpBody(_InvalidHttpRequest): + pass + + +class _InvalidHttpHeader(_InvalidHttpRequest): + pass + + +_HTTP_TOKEN_CHARS = frozenset("!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") + + +def _connect_db() -> sqlite3.Connection: + db = sqlite3.connect(POLICY_DB, timeout=5) + db.execute("PRAGMA journal_mode=WAL") + db.execute( + """ + CREATE TABLE IF NOT EXISTS events ( + request_id TEXT PRIMARY KEY, + host TEXT NOT NULL, + port INTEGER NOT NULL, + method TEXT NOT NULL, + created_at REAL NOT NULL, + surfaced INTEGER NOT NULL DEFAULT 0, + decision TEXT + ) + """ + ) + db.execute( + """ + CREATE TABLE IF NOT EXISTS grants ( + host TEXT NOT NULL, + port INTEGER NOT NULL, + expires_at REAL, + PRIMARY KEY (host, port) + ) + """ + ) + db.commit() + return db + + +def normalize_host(raw: str) -> str | None: + host = raw.strip().lower().rstrip(".") + if not host or len(host) > 253 or any(char in host for char in "/\\\x00\r\n"): + return None + try: + return host.encode("idna").decode("ascii") + except UnicodeError: + return None + + +def domain_matches(host: str, rule: str) -> bool: + if rule.startswith("*."): + suffix = rule[2:] + return host.endswith("." + suffix) and host != suffix + return host == rule + + +def address_is_public(address: str, *, allow_synthetic_dns: bool = False) -> bool: + try: + parsed = ipaddress.ip_address(address.split("%", 1)[0]) + except ValueError: + return False + if parsed.is_global and not parsed.is_multicast and not getattr(parsed, "is_site_local", False): + return True + # Docker Desktop's DNS inhibition layer maps public names into the + # benchmarking-only 198.18.0.0/15 range. Accept it only when the host + # backend explicitly identifies Desktop; native Linux keeps rejecting it. + return allow_synthetic_dns and parsed in ipaddress.ip_network("198.18.0.0/15") + + +def _static_rules() -> tuple[str, ...]: + try: + raw = json.loads(os.environ.get("DEERFLOW_ALLOW_DOMAINS_JSON", "[]")) + except json.JSONDecodeError: + return () + return tuple(value for value in raw if isinstance(value, str)) + + +def _policy_mode() -> str: + value = os.environ.get("DEERFLOW_NETWORK_MODE", "isolated") + return value if value in {"isolated", "allowlist"} else "isolated" + + +def _is_granted(host: str, port: int, now: float) -> bool: + with _connect_db() as db: + row = db.execute("SELECT expires_at FROM grants WHERE host = ? AND port = ?", (host, port)).fetchone() + if row is None: + return False + expires_at = row[0] + if expires_at is not None and float(expires_at) <= now: + db.execute("DELETE FROM grants WHERE host = ? AND port = ?", (host, port)) + return False + return True + + +def policy_allows(host: str, port: int, now: float | None = None) -> bool: + now = time.time() if now is None else now + if _policy_mode() != "allowlist": + return False + if any(domain_matches(host, rule) for rule in _static_rules()): + return True + return _is_granted(host, port, now) + + +def record_denial(host: str, port: int, method: str) -> str: + now = time.time() + with _connect_db() as db: + # Serialize the read-before-insert deduplication so simultaneous proxy + # requests cannot create multiple approval cards for one destination. + db.execute("BEGIN IMMEDIATE") + recent = db.execute( + "SELECT request_id FROM events WHERE host = ? AND port = ? AND decision IS NULL AND created_at >= ? ORDER BY created_at DESC LIMIT 1", + (host, port, now - 30), + ).fetchone() + if recent is not None: + return str(recent[0]) + request_id = str(uuid.uuid4()) + db.execute( + "INSERT INTO events(request_id, host, port, method, created_at) VALUES (?, ?, ?, ?, ?)", + (request_id, host, port, method, now), + ) + return request_id + + +def pending_events() -> list[dict[str, object]]: + with _connect_db() as db: + # Tool execution timestamps cannot reliably delimit proxy events: a + # background process may emit a denial after its launching tool returns, + # and subagent/non-interactive paths must drain events without prompting. + # Claim the oldest unsurfaced event atomically, independent of age. + db.execute("BEGIN IMMEDIATE") + row = db.execute( + "SELECT request_id, host, port, method, created_at FROM events WHERE surfaced = 0 AND decision IS NULL ORDER BY created_at LIMIT 1", + ).fetchone() + if row is not None: + db.execute("UPDATE events SET surfaced = 1 WHERE request_id = ?", (row[0],)) + # One Human Input card makes one destination decision. Close any + # sibling denials from the same tool call so a later retry records + # a fresh event instead of leaving an invisible surfaced request. + db.execute("UPDATE events SET decision = 'superseded' WHERE surfaced = 0 AND decision IS NULL") + if row is None: + return [] + return [{"request_id": row[0], "host": row[1], "port": row[2], "method": row[3], "created_at": row[4]}] + + +def deny_pending_events() -> int: + """Atomically deny every event that has not been surfaced to a user.""" + with _connect_db() as db: + db.execute("BEGIN IMMEDIATE") + result = db.execute("UPDATE events SET surfaced = 1, decision = 'deny' WHERE surfaced = 0 AND decision IS NULL") + return max(result.rowcount, 0) + + +def decide(request_id: str, decision: str, ttl: int) -> bool: + with _connect_db() as db: + row = db.execute("SELECT host, port, decision FROM events WHERE request_id = ?", (request_id,)).fetchone() + if row is None: + return False + host, port, existing = str(row[0]), int(row[1]), row[2] + if existing is not None: + return str(existing) == decision + if decision == "allow_temporary": + db.execute( + "INSERT INTO grants(host, port, expires_at) VALUES (?, ?, ?) ON CONFLICT(host, port) DO UPDATE SET expires_at = excluded.expires_at", + (host, port, time.time() + ttl), + ) + elif decision == "allow_sandbox": + db.execute( + "INSERT INTO grants(host, port, expires_at) VALUES (?, ?, NULL) ON CONFLICT(host, port) DO UPDATE SET expires_at = NULL", + (host, port), + ) + elif decision != "deny": + return False + db.execute("UPDATE events SET decision = ? WHERE request_id = ?", (decision, request_id)) + return True + + +async def resolve_public(host: str, port: int) -> tuple[tuple[int, tuple], ...] | None: + loop = asyncio.get_running_loop() + try: + infos = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM) + except OSError: + return None + allow_synthetic_dns = os.environ.get("DEERFLOW_ALLOW_SYNTHETIC_DNS") == "1" + public = [(family, sockaddr) for family, _socktype, _proto, _canonname, sockaddr in infos if address_is_public(str(sockaddr[0]), allow_synthetic_dns=allow_synthetic_dns)] + if len(public) != len(infos) or not public: + return None + return tuple(public) + + +async def _relay(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + while chunk := await reader.read(65_536): + writer.write(chunk) + await writer.drain() + except (ConnectionError, asyncio.CancelledError): + pass + finally: + with contextlib.suppress(Exception): + writer.close() + await writer.wait_closed() + + +async def _open_public(resolved: tuple[tuple[int, tuple], ...], port: int) -> tuple[asyncio.StreamReader, asyncio.StreamWriter] | None: + """Try every pre-validated DNS answer within one shared deadline.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + 15 + for index, (family, sockaddr) in enumerate(resolved): + remaining = deadline - loop.time() + if remaining <= 0: + break + # Reserve an equal share of the remaining budget for every later + # address so a black-holed first answer cannot consume the full + # deadline and strand otherwise reachable candidates. + attempt_timeout = remaining / (len(resolved) - index) + try: + return await asyncio.wait_for( + asyncio.open_connection(sockaddr[0], port, family=family), + timeout=attempt_timeout, + ) + except (OSError, TimeoutError): + continue + return None + + +async def _reject(writer: asyncio.StreamWriter, status: str, body: str) -> None: + encoded = body.encode("utf-8") + writer.write(f"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {len(encoded)}\r\nConnection: close\r\n\r\n".encode() + encoded) + await writer.drain() + writer.close() + await writer.wait_closed() + + +def _parse_http_header_fields(header_lines: list[str]) -> list[tuple[str, str, str]]: + """Parse one header block using a single strict field-name grammar.""" + fields: list[tuple[str, str, str]] = [] + for line in header_lines: + if not line: + continue + if line.startswith((" ", "\t")) or ":" not in line: + raise _InvalidHttpHeader("Obsolete or malformed HTTP headers are not supported") + raw_name, raw_value = line.split(":", 1) + if not raw_name or any(char not in _HTTP_TOKEN_CHARS for char in raw_name): + raise _InvalidHttpHeader("HTTP field names must use token characters followed immediately by a colon") + if any((ord(char) < 0x20 and char != "\t") or ord(char) == 0x7F for char in raw_value): + raise _InvalidHttpHeader("HTTP field values cannot contain control characters") + fields.append((raw_name, raw_name.lower(), raw_value.strip(" \t"))) + return fields + + +def _http_request_body_framing(header_fields: list[tuple[str, str, str]]) -> tuple[str, int]: + """Return the strictly validated framing for one HTTP proxy request.""" + content_lengths = [value for _raw_name, name, value in header_fields if name == "content-length"] + transfer_encodings = [value.lower() for _raw_name, name, value in header_fields if name == "transfer-encoding"] + if content_lengths and transfer_encodings: + raise _InvalidHttpBody("Content-Length and Transfer-Encoding cannot be combined") + if transfer_encodings: + if transfer_encodings != ["chunked"]: + raise _InvalidHttpBody("Only a single chunked Transfer-Encoding is supported") + return ("chunked", 0) + if not content_lengths: + return ("fixed", 0) + if len(content_lengths) != 1 or not content_lengths[0].isdigit(): + raise _InvalidHttpBody("Content-Length must be one non-negative decimal integer") + return ("fixed", int(content_lengths[0])) + + +def _build_http_outbound_header(method: str, path: str, version: str, header_fields: list[tuple[str, str, str]]) -> bytes: + """Strip proxy/hop headers and force the one-request upstream connection closed.""" + connection_tokens = {token.strip().lower() for _raw_name, name, value in header_fields if name == "connection" for token in value.split(",") if token.strip()} + if any(any(char not in _HTTP_TOKEN_CHARS for char in token) for token in connection_tokens): + raise _InvalidHttpHeader("Connection header options must use HTTP token characters") + if connection_tokens & {"host", "content-length", "transfer-encoding"}: + raise _InvalidHttpBody("Connection cannot remove request framing headers") + hop_headers = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "upgrade", + *connection_tokens, + } + kept_headers = [f"{raw_name}: {value}" for raw_name, name, value in header_fields if name not in hop_headers] + kept_headers.append("Connection: close") + return f"{method} {path} {version}\r\n".encode("latin-1") + "\r\n".join(kept_headers).encode("latin-1") + b"\r\n\r\n" + + +async def _copy_exact_request_bytes(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, length: int) -> None: + remaining = length + while remaining: + chunk = await asyncio.wait_for(reader.readexactly(min(remaining, 65_536)), timeout=15) + writer.write(chunk) + await writer.drain() + remaining -= len(chunk) + + +async def _copy_chunked_request_body(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + """Relay exactly one strictly framed chunked body, including its trailers.""" + while True: + line = await asyncio.wait_for(reader.readuntil(b"\r\n"), timeout=15) + if len(line) > MAX_HEADER_BYTES: + raise _InvalidHttpBody("Chunk header is too large") + size_token = line[:-2].split(b";", 1)[0].strip() + if not size_token or any(value not in b"0123456789abcdefABCDEF" for value in size_token): + raise _InvalidHttpBody("Invalid chunk size") + size = int(size_token, 16) + writer.write(line) + await writer.drain() + if size: + await _copy_exact_request_bytes(reader, writer, size) + terminator = await asyncio.wait_for(reader.readexactly(2), timeout=15) + if terminator != b"\r\n": + raise _InvalidHttpBody("Invalid chunk terminator") + writer.write(terminator) + await writer.drain() + continue + + trailer_bytes = len(line) + while True: + trailer = await asyncio.wait_for(reader.readuntil(b"\r\n"), timeout=15) + trailer_bytes += len(trailer) + if trailer_bytes > MAX_HEADER_BYTES: + raise _InvalidHttpBody("Chunk trailers are too large") + if trailer != b"\r\n" and (trailer.startswith((b" ", b"\t")) or b":" not in trailer): + raise _InvalidHttpBody("Invalid chunk trailer") + writer.write(trailer) + await writer.drain() + if trailer == b"\r\n": + return + + +def _parse_authority(authority: str, default_port: int) -> tuple[str, int] | None: + parsed = urlsplit("//" + authority) + try: + port = parsed.port or default_port + except ValueError: + return None + host = normalize_host(parsed.hostname or "") + if host is None: + return None + return host, port + + +async def _read_tls_client_hello(reader: asyncio.StreamReader) -> tuple[str, bytes] | None: + """Read one TLS ClientHello and return its normalized SNI plus wire bytes.""" + wire = bytearray() + handshake = bytearray() + expected_handshake_size: int | None = None + while len(wire) <= MAX_HEADER_BYTES: + try: + record_header = await asyncio.wait_for(reader.readexactly(5), timeout=15) + record_size = int.from_bytes(record_header[3:5], "big") + if record_header[0] != 22 or record_size <= 0 or len(wire) + 5 + record_size > MAX_HEADER_BYTES: + return None + record = await asyncio.wait_for(reader.readexactly(record_size), timeout=15) + except (asyncio.IncompleteReadError, TimeoutError): + return None + wire.extend(record_header) + wire.extend(record) + handshake.extend(record) + if expected_handshake_size is None and len(handshake) >= 4: + if handshake[0] != 1: + return None + expected_handshake_size = 4 + int.from_bytes(handshake[1:4], "big") + if expected_handshake_size > MAX_HEADER_BYTES: + return None + if expected_handshake_size is not None and len(handshake) >= expected_handshake_size: + break + if expected_handshake_size is None or len(handshake) < expected_handshake_size: + return None + + hello = memoryview(handshake)[4:expected_handshake_size] + try: + offset = 2 + 32 + offset += 1 + hello[offset] + cipher_size = int.from_bytes(hello[offset : offset + 2], "big") + offset += 2 + cipher_size + offset += 1 + hello[offset] + extensions_size = int.from_bytes(hello[offset : offset + 2], "big") + offset += 2 + extensions_end = offset + extensions_size + if extensions_end > len(hello): + return None + sni: str | None = None + while offset + 4 <= extensions_end: + extension_type = int.from_bytes(hello[offset : offset + 2], "big") + extension_size = int.from_bytes(hello[offset + 2 : offset + 4], "big") + offset += 4 + extension = hello[offset : offset + extension_size] + offset += extension_size + if offset > extensions_end: + return None + # Reject encrypted ClientHello: its hidden SNI cannot be compared + # with the approved CONNECT host without TLS interception. + if extension_type in {0xFE0D, 0xFFCE}: + return None + if extension_type != 0 or len(extension) < 5: + continue + names_size = int.from_bytes(extension[0:2], "big") + cursor = 2 + while cursor + 3 <= 2 + names_size and cursor + 3 <= len(extension): + name_type = extension[cursor] + name_size = int.from_bytes(extension[cursor + 1 : cursor + 3], "big") + cursor += 3 + if cursor + name_size > len(extension): + return None + if name_type == 0: + sni = normalize_host(bytes(extension[cursor : cursor + name_size]).decode("ascii")) + break + cursor += name_size + return (sni, bytes(wire)) if sni is not None else None + except (IndexError, UnicodeDecodeError, ValueError): + return None + + +async def handle_proxy(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + header = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=15) + except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, TimeoutError): + await _reject(writer, "400 Bad Request", "Invalid proxy request") + return + if len(header) > MAX_HEADER_BYTES: + await _reject(writer, "431 Request Header Fields Too Large", "Proxy request headers are too large") + return + try: + request_line, *header_lines = header.decode("latin-1").split("\r\n") + method, target, version = request_line.split(" ", 2) + except ValueError: + await _reject(writer, "400 Bad Request", "Invalid proxy request line") + return + if version not in {"HTTP/1.0", "HTTP/1.1"}: + await _reject(writer, "400 Bad Request", "Unsupported HTTP version") + return + try: + header_fields = _parse_http_header_fields(header_lines) + except _InvalidHttpHeader as exc: + await _reject(writer, "400 Bad Request", str(exc)) + return + + method = method.upper() + if method == "CONNECT": + parsed = _parse_authority(target, 443) + if parsed is None or parsed[1] != 443: + await _reject(writer, "403 Forbidden", "Only HTTPS CONNECT on port 443 is supported") + return + host, port = parsed + outbound_header = None + else: + parsed_url = urlsplit(target) + if parsed_url.scheme.lower() != "http" or not parsed_url.hostname: + await _reject(writer, "403 Forbidden", "Only HTTP absolute-form requests and HTTPS CONNECT are supported") + return + try: + port = parsed_url.port or 80 + except ValueError: + await _reject(writer, "400 Bad Request", "Invalid destination port") + return + host = normalize_host(parsed_url.hostname) + if host is None or port != 80: + await _reject(writer, "403 Forbidden", "Only HTTP on port 80 is supported") + return + path = parsed_url.path or "/" + if parsed_url.query: + path += "?" + parsed_url.query + host_headers = [value for _raw_name, name, value in header_fields if name == "host"] + host_authority = _parse_authority(host_headers[0], 80) if len(host_headers) == 1 else None + if host_authority != (host, port): + await _reject(writer, "400 Bad Request", "HTTP Host header must match the approved proxy destination") + return + if any(name == "expect" for _raw_name, name, _value in header_fields): + await _reject(writer, "417 Expectation Failed", "Expect is not supported by the sandbox network proxy") + return + try: + body_mode, body_length = _http_request_body_framing(header_fields) + outbound_header = _build_http_outbound_header(method, path, version, header_fields) + except _InvalidHttpRequest as exc: + await _reject(writer, "400 Bad Request", str(exc)) + return + + try: + ipaddress.ip_address(host) + except ValueError: + pass + else: + await _reject(writer, "403 Forbidden", "IP-literal destinations are not allowed by sandbox network policy") + return + if not policy_allows(host, port): + if os.environ.get("DEERFLOW_RECORD_DENIALS") == "1": + request_id = record_denial(host, port, method) + detail = f" (request {request_id})" + else: + detail = "" + await _reject(writer, "403 Forbidden", f"Sandbox network policy denied {host}:{port}{detail}") + return + resolved = await resolve_public(host, port) + if resolved is None: + await _reject(writer, "403 Forbidden", "Destination did not resolve exclusively to public addresses") + return + upstream = await _open_public(resolved, port) + if upstream is None: + await _reject(writer, "403 Forbidden", "Destination did not resolve exclusively to public addresses") + return + upstream_reader, upstream_writer = upstream + if method == "CONNECT": + writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") + await writer.drain() + client_hello = await _read_tls_client_hello(reader) + if client_hello is None or client_hello[0] != host: + upstream_writer.close() + await upstream_writer.wait_closed() + writer.close() + await writer.wait_closed() + return + upstream_writer.write(client_hello[1]) + await upstream_writer.drain() + await asyncio.gather(_relay(reader, upstream_writer), _relay(upstream_reader, writer)) + return + + upstream_writer.write(outbound_header or b"") + await upstream_writer.drain() + try: + if body_mode == "chunked": + await _copy_chunked_request_body(reader, upstream_writer) + else: + await _copy_exact_request_bytes(reader, upstream_writer, body_length) + except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, TimeoutError, _InvalidHttpBody) as exc: + upstream_writer.close() + await upstream_writer.wait_closed() + await _reject(writer, "400 Bad Request", str(exc) or "Invalid HTTP request body") + return + if upstream_writer.can_write_eof(): + upstream_writer.write_eof() + await upstream_writer.drain() + try: + # One request per client connection is intentional. Relaying arbitrary + # remaining client bytes would let a pipelined request bypass the next + # destination/Host policy check. + await _relay(upstream_reader, writer) + finally: + with contextlib.suppress(Exception): + upstream_writer.close() + await upstream_writer.wait_closed() + + +async def handle_relay(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + header = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=15) + except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, TimeoutError): + await _reject(writer, "400 Bad Request", "Invalid sandbox relay request") + return + if len(header) > MAX_HEADER_BYTES: + await _reject(writer, "431 Request Header Fields Too Large", "Sandbox relay request headers are too large") + return + try: + _request_line, *header_lines = header.decode("latin-1").split("\r\n") + header_fields = _parse_http_header_fields(header_lines) + except (UnicodeDecodeError, _InvalidHttpHeader): + await _reject(writer, "400 Bad Request", "Invalid sandbox relay request headers") + return + expected_token = os.environ.get(RELAY_TOKEN_ENV, "") + presented_tokens = [value for _raw_name, name, value in header_fields if name == RELAY_AUTH_HEADER.lower()] + presented_token = presented_tokens[0].encode("latin-1") if len(presented_tokens) == 1 else b"" + if not expected_token or len(presented_tokens) != 1 or not hmac.compare_digest(presented_token, expected_token.encode()): + await _reject(writer, "403 Forbidden", "Sandbox relay authentication failed") + return + + target = os.environ.get("DEERFLOW_SANDBOX_TARGET", "") + parsed = _parse_authority(target, 8080) + if parsed is None: + await _reject(writer, "502 Bad Gateway", "Sandbox relay target is invalid") + return + try: + upstream_reader, upstream_writer = await asyncio.wait_for(asyncio.open_connection(parsed[0], parsed[1]), timeout=5) + except (OSError, TimeoutError): + await _reject(writer, "502 Bad Gateway", "Sandbox is not ready") + return + upstream_writer.write(header) + await upstream_writer.drain() + await asyncio.gather(_relay(reader, upstream_writer), _relay(upstream_reader, writer)) + + +async def serve() -> None: + _connect_db().close() + proxy = await asyncio.start_server(handle_proxy, "0.0.0.0", 3128, limit=MAX_HEADER_BYTES) + relay = await asyncio.start_server(handle_relay, "0.0.0.0", 8080, limit=MAX_HEADER_BYTES) + async with proxy, relay: + await asyncio.gather(proxy.serve_forever(), relay.serve_forever()) + + +def main() -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + subparsers.add_parser("serve") + subparsers.add_parser("pending") + subparsers.add_parser("deny-pending") + decide_parser = subparsers.add_parser("decide") + decide_parser.add_argument("request_id") + decide_parser.add_argument("decision", choices=("deny", "allow_temporary", "allow_sandbox")) + decide_parser.add_argument("--ttl", type=int, default=300) + args = parser.parse_args() + if args.command == "serve": + asyncio.run(serve()) + return 0 + if args.command == "pending": + print(json.dumps(pending_events(), separators=(",", ":"))) + return 0 + if args.command == "deny-pending": + print(deny_pending_events()) + return 0 + if args.command == "decide": + return 0 if decide(args.request_id, args.decision, args.ttl) else 2 + parser.error("a command is required") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/sandbox_info.py b/backend/packages/harness/deerflow/community/aio_sandbox/sandbox_info.py index 8b445dee3..c5e1ffb03 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/sandbox_info.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/sandbox_info.py @@ -20,6 +20,16 @@ class SandboxInfo: container_name: str | None = None # Only for local container backend container_id: str | None = None # Only for local container backend created_at: float = field(default_factory=time.time) + # Ephemeral control-plane credentials reconstructed from local Docker + # discovery. Intentionally excluded from to_dict() and repr so they cannot + # leak through metadata persistence or routine lifecycle logs. + request_headers: dict[str, str] = field(default_factory=dict, repr=False, compare=False) + # Discovery-only lifecycle signal. A backend may report a running sandbox + # whose persisted provisioning policy is incompatible with this process, + # but it must not destroy that sandbox while merely enumerating it. The + # provider consumes this flag and performs replacement only after obtaining + # its local teardown reservation and cross-instance teardown lease. + requires_replacement: bool = field(default=False, repr=False, compare=False) def to_dict(self) -> dict: return { diff --git a/backend/packages/harness/deerflow/config/sandbox_config.py b/backend/packages/harness/deerflow/config/sandbox_config.py index 7b7144424..64b32f7f2 100644 --- a/backend/packages/harness/deerflow/config/sandbox_config.py +++ b/backend/packages/harness/deerflow/config/sandbox_config.py @@ -1,9 +1,74 @@ +import ipaddress from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator SandboxOwnershipType = Literal["memory", "redis"] SandboxOverflowPolicy = Literal["wait", "reject", "burst"] +SandboxNetworkMode = Literal["open", "isolated", "allowlist"] +SandboxNetworkApproval = Literal["deny", "prompt"] + + +class SandboxNetworkConfig(BaseModel): + """Outbound network policy for locally managed AIO sandboxes.""" + + mode: SandboxNetworkMode = Field( + default="open", + description="open keeps the current Docker networking behavior; isolated denies all egress; allowlist permits configured domains and optional runtime approval.", + ) + allow_domains: list[str] = Field( + default_factory=list, + description="Exact domains or leading-wildcard domains (for example *.pythonhosted.org) allowed in allowlist mode.", + ) + approval: SandboxNetworkApproval = Field( + default="prompt", + description="Whether a denied public HTTP(S) destination may ask an interactive user for a temporary or sandbox-lifetime grant.", + ) + temporary_grant_ttl: int = Field( + default=300, + ge=30, + le=3600, + description="Lifetime in seconds for the temporary approval choice.", + ) + proxy_image: str = Field( + default="ghcr.io/bytedance/deer-flow-sandbox-network-proxy:latest", + min_length=1, + description="Managed Python runtime image used for the trusted network-policy sidecar.", + ) + + @field_validator("allow_domains") + @classmethod + def _normalize_allow_domains(cls, values: list[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for raw in values: + value = raw.strip().lower().rstrip(".") + suffix = value[2:] if value.startswith("*.") else value + if not value or value == "*" or not suffix or "://" in value or "/" in value or ":" in value or "*" in suffix or suffix.startswith(".") or suffix.endswith("."): + raise ValueError(f"invalid sandbox network allowlist domain: {raw!r}") + try: + suffix.encode("idna") + except UnicodeError as exc: + raise ValueError(f"invalid sandbox network allowlist domain: {raw!r}") from exc + try: + ipaddress.ip_address(suffix) + except ValueError: + pass + else: + raise ValueError(f"invalid sandbox network allowlist domain: {raw!r}") + ascii_suffix = suffix.encode("idna").decode("ascii") + labels = ascii_suffix.split(".") + if ( + len(labels) < 2 + or len(ascii_suffix) > 253 + or any(not label or len(label) > 63 or label.startswith("-") or label.endswith("-") or any(not (char.isascii() and (char.isalnum() or char == "-")) for char in label) for label in labels) + ): + raise ValueError(f"invalid sandbox network allowlist domain: {raw!r}") + canonical = ("*." if value.startswith("*.") else "") + ascii_suffix + if canonical not in seen: + seen.add(canonical) + normalized.append(canonical) + return normalized class SandboxOwnershipConfig(BaseModel): @@ -173,6 +238,10 @@ class SandboxConfig(BaseModel): default_factory=dict, description="Environment variables to inject into the sandbox container. Values starting with $ will be resolved from host environment variables.", ) + network: SandboxNetworkConfig = Field( + default_factory=SandboxNetworkConfig, + description="AioSandboxProvider outbound network isolation and approval policy.", + ) bash_output_max_chars: int = Field( default=20000, diff --git a/backend/packages/harness/deerflow/runtime/journal.py b/backend/packages/harness/deerflow/runtime/journal.py index f4e92b701..dbd2e88a1 100644 --- a/backend/packages/harness/deerflow/runtime/journal.py +++ b/backend/packages/harness/deerflow/runtime/journal.py @@ -49,7 +49,7 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) _LEGACY_SUMMARY_MESSAGE_NAME = "summary" -_PERSISTED_HIDDEN_HUMAN_INPUT_RESPONSE_SOURCES = frozenset({"ask_clarification"}) +_PERSISTED_HIDDEN_HUMAN_INPUT_RESPONSE_SOURCES = frozenset({"ask_clarification", "sandbox_network"}) def _should_persist_human_input_message(message: BaseMessage) -> bool: diff --git a/backend/packages/harness/deerflow/sandbox/middleware.py b/backend/packages/harness/deerflow/sandbox/middleware.py index 885e40f09..bc2234f6e 100644 --- a/backend/packages/harness/deerflow/sandbox/middleware.py +++ b/backend/packages/harness/deerflow/sandbox/middleware.py @@ -1,16 +1,18 @@ import asyncio import logging -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from dataclasses import replace as dc_replace from typing import NotRequired, override from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware -from langchain_core.messages import ToolMessage +from langchain_core.messages import HumanMessage, ToolMessage +from langgraph.graph import END from langgraph.prebuilt.tool_node import ToolCallRequest from langgraph.runtime import Runtime from langgraph.types import Command, Overwrite +from deerflow.agents.human_input import read_human_input_response from deerflow.agents.thread_state import SandboxStateField, ThreadDataState from deerflow.authz.sandbox_authz import ( authorize_sandbox_execution, @@ -27,9 +29,17 @@ from deerflow.sandbox.lease import ( sandbox_lease_owner, ) from deerflow.sandbox.overwrite import unwrap_sandbox +from deerflow.sandbox.sandbox_provider import get_initialized_sandbox_provider logger = logging.getLogger(__name__) +NETWORK_POLICY_HUMAN_INPUT_SOURCE = "sandbox_network" +_NETWORK_POLICY_DECISIONS = frozenset({"deny", "allow_temporary", "allow_sandbox"}) + + +def _network_approval_is_non_interactive(context: Mapping[str, object]) -> bool: + return bool(context.get("disable_clarification") or context.get("non_interactive")) + class SandboxMiddlewareState(AgentState): """Compatible with the `ThreadState` schema.""" @@ -218,6 +228,7 @@ class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]): thread_id = (runtime.context or {}).get("thread_id") if thread_id is None: return super().before_agent(state, runtime) + self._apply_network_policy_response(state, runtime) user_id = resolve_runtime_user_id(runtime) projection = self._prepare_agent_skill_projection(thread_id, user_id=user_id) owner_id = ensure_sandbox_lease_owner(runtime.context) @@ -298,11 +309,46 @@ class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]): runtime.context["sandbox_id"] = retained_id return super().before_agent(state, runtime) + def _apply_network_policy_response(self, state: SandboxMiddlewareState, runtime: Runtime) -> None: + sandbox_id = self._read_sandbox_id_from_state(state) + if sandbox_id is None: + return + messages = state.get("messages", []) + response = None + for message in reversed(messages): + if not isinstance(message, HumanMessage): + continue + candidate = read_human_input_response(message.additional_kwargs) + # A network decision is actionable only when it is the current + # user turn. Stop at the newest HumanMessage so an older persisted + # card response cannot be re-applied after ordinary conversation. + if candidate is None or candidate["source"] != NETWORK_POLICY_HUMAN_INPUT_SOURCE: + return + response = candidate + break + if response is None or response["response_kind"] != "option": + return + decision = response["option_id"] + if decision not in _NETWORK_POLICY_DECISIONS: + raise SandboxRuntimeError("Invalid sandbox network approval response") + context = runtime.context or {} + applied = context.setdefault("sandbox_network_decisions_applied", set()) + marker = (sandbox_id, response["request_id"], decision) + if marker in applied: + return + provider = get_sandbox_provider() + if provider.sandbox_network_mode() != "allowlist": + return + if not provider.decide_network_policy_request(sandbox_id, response["request_id"], decision): + raise SandboxRuntimeError("The sandbox network approval is stale or does not belong to this sandbox") + applied.add(marker) + @override async def abefore_agent(self, state: SandboxMiddlewareState, runtime: Runtime) -> dict | None: thread_id = (runtime.context or {}).get("thread_id") if thread_id is None: return await super().abefore_agent(state, runtime) + await asyncio.to_thread(self._apply_network_policy_response, state, runtime) user_id = resolve_runtime_user_id(runtime) projection = await asyncio.to_thread( self._prepare_agent_skill_projection, @@ -497,12 +543,10 @@ class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]): ) -> ToolMessage | Command: prev_sandbox_id = self._read_sandbox_id_from_request(request) result = handler(request) - if prev_sandbox_id is not None: - return result curr_sandbox_id = self._read_sandbox_id_from_request(request) - if curr_sandbox_id is None: - return result - return self._attach_sandbox_update(result, curr_sandbox_id) + if prev_sandbox_id is None and curr_sandbox_id is not None: + result = self._attach_sandbox_update(result, curr_sandbox_id) + return self._maybe_request_network_approval(request, result, curr_sandbox_id or prev_sandbox_id) @override async def awrap_tool_call( @@ -512,9 +556,92 @@ class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]): ) -> ToolMessage | Command: prev_sandbox_id = self._read_sandbox_id_from_request(request) result = await handler(request) - if prev_sandbox_id is not None: - return result curr_sandbox_id = self._read_sandbox_id_from_request(request) - if curr_sandbox_id is None: + if prev_sandbox_id is None and curr_sandbox_id is not None: + result = self._attach_sandbox_update(result, curr_sandbox_id) + sandbox_id = curr_sandbox_id or prev_sandbox_id + if sandbox_id is None: return result - return self._attach_sandbox_update(result, curr_sandbox_id) + context = getattr(request.runtime, "context", None) or {} + provider = get_initialized_sandbox_provider() + if provider is None: + return result + if provider.sandbox_network_mode() != "allowlist": + return result + if _network_approval_is_non_interactive(context) or context.get("is_subagent"): + if not await provider.deny_pending_network_policy_events_async(sandbox_id): + logger.warning("Failed to drain sandbox network policy events for non-interactive sandbox %s", sandbox_id) + return result + events = await provider.consume_network_policy_events_async(sandbox_id) + return self._network_approval_result(request, result, sandbox_id, events) + + def _maybe_request_network_approval( + self, + request: ToolCallRequest, + result: ToolMessage | Command, + sandbox_id: str | None, + ) -> ToolMessage | Command: + if sandbox_id is None: + return result + context = getattr(request.runtime, "context", None) or {} + provider = get_initialized_sandbox_provider() + if provider is None: + return result + if provider.sandbox_network_mode() != "allowlist": + return result + if _network_approval_is_non_interactive(context) or context.get("is_subagent"): + if not provider.deny_pending_network_policy_events(sandbox_id): + logger.warning("Failed to drain sandbox network policy events for non-interactive sandbox %s", sandbox_id) + return result + events = provider.consume_network_policy_events(sandbox_id) + return self._network_approval_result(request, result, sandbox_id, events) + + def _network_approval_result( + self, + request: ToolCallRequest, + result: ToolMessage | Command, + sandbox_id: str, + events: list[dict[str, object]], + ) -> ToolMessage | Command: + if not events: + return result + event = events[0] + request_id = event.get("request_id") + host = event.get("host") + port = event.get("port") + if not isinstance(request_id, str) or not isinstance(host, str) or not isinstance(port, int): + logger.warning("Ignoring malformed trusted sandbox network event: %r", event) + return result + tool_call_id = str(request.tool_call.get("id") or "") + tool_name = str(request.tool_call.get("name") or "sandbox") + ttl_seconds = get_sandbox_provider().sandbox_network_temporary_grant_ttl() + ttl_label = f"{ttl_seconds // 60} minutes" if ttl_seconds % 60 == 0 else f"{ttl_seconds} seconds" + message = ToolMessage( + id=f"sandbox-network:{request_id}", + content=(f"Sandbox network policy blocked {host}:{port}. The command was not retried. Choose whether this destination should be available, then ask the agent to retry if appropriate."), + tool_call_id=tool_call_id, + name=tool_name, + artifact={ + "human_input": { + "version": 1, + "kind": "human_input_request", + "source": NETWORK_POLICY_HUMAN_INPUT_SOURCE, + "request_id": request_id, + "tool_call_id": tool_call_id, + "clarification_type": "risk_confirmation", + "title": "Sandbox network access", + "question": f"Allow this sandbox to connect to {host}:{port}?", + "context": "Private, loopback, link-local, multicast, and cloud metadata addresses can never be approved.", + "input_mode": "single_choice", + "options": [ + {"id": "deny", "label": "Deny", "value": "Deny network access"}, + {"id": "allow_temporary", "label": f"Allow for {ttl_label}", "value": f"Allow network access for {ttl_label}"}, + {"id": "allow_sandbox", "label": "Allow for this sandbox", "value": "Allow network access for this sandbox"}, + ], + } + }, + ) + update: dict = {"messages": [message], "sandbox": {"sandbox_id": sandbox_id}} + if isinstance(result, Command) and isinstance(result.update, dict): + update = {**result.update, **update} + return Command(update=update, goto=END) diff --git a/backend/packages/harness/deerflow/sandbox/sandbox_provider.py b/backend/packages/harness/deerflow/sandbox/sandbox_provider.py index f4e0d5da0..8111629fd 100644 --- a/backend/packages/harness/deerflow/sandbox/sandbox_provider.py +++ b/backend/packages/harness/deerflow/sandbox/sandbox_provider.py @@ -96,6 +96,40 @@ class SandboxProvider(ABC): """ pass + def sandbox_network_mode(self) -> str: + """Return the provider's effective outbound network mode.""" + return "open" + + def sandbox_network_temporary_grant_ttl(self) -> int: + return 300 + + def consume_network_policy_events(self, sandbox_id: str) -> list[dict[str, object]]: + """Claim the oldest unsurfaced trusted-proxy event for a sandbox. + + Providers without a managed network policy use the empty default. + """ + del sandbox_id + return [] + + async def consume_network_policy_events_async(self, sandbox_id: str) -> list[dict[str, object]]: + return await asyncio.to_thread(self.consume_network_policy_events, sandbox_id) + + def deny_pending_network_policy_events(self, sandbox_id: str) -> bool: + """Atomically deny all unsurfaced trusted-proxy events for a sandbox.""" + del sandbox_id + return False + + async def deny_pending_network_policy_events_async(self, sandbox_id: str) -> bool: + return await asyncio.to_thread(self.deny_pending_network_policy_events, sandbox_id) + + def decide_network_policy_request(self, sandbox_id: str, request_id: str, decision: str) -> bool: + """Apply a user decision to one trusted-proxy event.""" + del sandbox_id, request_id, decision + return False + + async def decide_network_policy_request_async(self, sandbox_id: str, request_id: str, decision: str) -> bool: + return await asyncio.to_thread(self.decide_network_policy_request, sandbox_id, request_id, decision) + _default_sandbox_provider: SandboxProvider | None = None # Guards every read and write of `_default_sandbox_provider`. The singleton is @@ -115,6 +149,12 @@ _default_sandbox_provider: SandboxProvider | None = None _provider_lock = threading.Lock() +def get_initialized_sandbox_provider() -> SandboxProvider | None: + """Return the provider only when another lifecycle path initialized it.""" + with _provider_lock: + return _default_sandbox_provider + + def get_sandbox_provider(**kwargs) -> SandboxProvider: """Get the sandbox provider singleton. diff --git a/backend/tests/test_aio_sandbox.py b/backend/tests/test_aio_sandbox.py index 1c7a99af2..6a1934635 100644 --- a/backend/tests/test_aio_sandbox.py +++ b/backend/tests/test_aio_sandbox.py @@ -49,6 +49,29 @@ def test_local_sandbox_client_bypasses_environment_proxy(): ) +def test_local_sandbox_client_forwards_trusted_relay_headers(): + from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox + + sentinel_httpx = MagicMock() + headers = {"X-DeerFlow-Relay-Token": "secret-token"} + with ( + patch("deerflow.community.aio_sandbox.aio_sandbox.httpx.Client", return_value=sentinel_httpx), + patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient") as sdk_cls, + ): + AioSandbox( + id="test-sandbox", + base_url="http://host.docker.internal:8080", + request_headers=headers, + ) + + sdk_cls.assert_called_once_with( + base_url="http://host.docker.internal:8080", + timeout=600, + headers=headers, + httpx_client=sentinel_httpx, + ) + + @pytest.mark.parametrize( "base_url", [ diff --git a/backend/tests/test_aio_sandbox_local_backend.py b/backend/tests/test_aio_sandbox_local_backend.py index 97838b505..e9cd48005 100644 --- a/backend/tests/test_aio_sandbox_local_backend.py +++ b/backend/tests/test_aio_sandbox_local_backend.py @@ -1,18 +1,37 @@ +import json import logging import os import socket import subprocess +import time from types import SimpleNamespace import pytest from deerflow.community.aio_sandbox.local_backend import ( LocalContainerBackend, + _ContainerInspection, _format_container_command_for_log, _format_container_mount, + _NetworkInspection, _redact_container_command_for_log, _resolve_docker_bind_host, ) +from deerflow.community.aio_sandbox.sandbox_info import SandboxInfo +from deerflow.utils.network import get_free_port, release_port + + +def test_sandbox_info_does_not_serialize_or_repr_relay_credentials(): + info = SandboxInfo( + sandbox_id="sandbox-id", + sandbox_url="http://localhost:8080", + request_headers={"X-DeerFlow-Relay-Token": "secret-token"}, + requires_replacement=True, + ) + + assert "request_headers" not in info.to_dict() + assert "requires_replacement" not in info.to_dict() + assert "secret-token" not in repr(info) def test_format_container_mount_uses_mount_syntax_for_docker_windows_paths(): @@ -128,6 +147,431 @@ def test_start_container_logs_redacted_env_values(monkeypatch, caplog): assert "visible-value" not in log_output +def test_restricted_network_requires_docker_engine_28(monkeypatch): + monkeypatch.setattr(LocalContainerBackend, "_detect_runtime", lambda _self: "docker") + monkeypatch.setattr( + "subprocess.run", + lambda *_args, **_kwargs: SimpleNamespace(stdout="27.5.1\n", stderr="", returncode=0), + ) + + with pytest.raises(RuntimeError, match="Docker Engine 28 or newer"): + LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + network_config={"mode": "isolated"}, + ) + + +@pytest.mark.parametrize( + ("operating_system", "expected"), + [ + ('"Docker Desktop"', True), + ('"Ubuntu 24.04.3 LTS"', False), + ], +) +def test_docker_desktop_detection_uses_daemon_operating_system(monkeypatch, operating_system, expected): + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + + def fake_run(cmd, **_kwargs): + assert cmd == ["docker", "info", "--format", "{{json .OperatingSystem}}"] + return SimpleNamespace(stdout=operating_system, stderr="", returncode=0) + + monkeypatch.setattr("subprocess.run", fake_run) + + assert backend._docker_server_is_desktop() is expected + + +def test_darwin_open_keeps_docker_to_reconcile_restricted_sandbox(monkeypatch): + commands: list[list[str]] = [] + + def fake_run(cmd, **_kwargs): + commands.append(cmd) + if cmd[:2] == ["docker", "ps"]: + return SimpleNamespace(stdout="sandbox-transition\n", stderr="", returncode=0) + if cmd == ["container", "--version"]: + return SimpleNamespace(stdout="container 0.7.0\n", stderr="", returncode=0) + raise AssertionError(f"unexpected command: {cmd}") + + monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.platform.system", lambda: "Darwin") + monkeypatch.setattr("subprocess.run", fake_run) + + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + network_config={"mode": "open"}, + ) + monkeypatch.setattr( + backend, + "_batch_inspect", + lambda _names, **_kwargs: { + "sandbox-transition": _ContainerInspection( + 1.0, + None, + { + "deerflow.role": "sandbox", + "deerflow.sandbox_id": "transition", + "deerflow.network_mode": "allowlist", + }, + "sandbox:latest", + frozenset({"deer-flow-sandbox-net-old"}), + ) + }, + ) + + infos = backend.list_running() + + assert backend.runtime == "docker" + assert [info.sandbox_id for info in infos] == ["transition"] + assert infos[0].requires_replacement is True + assert ["container", "--version"] in commands + assert any("label=deerflow.role=sandbox" in command for command in commands) + + +def test_darwin_open_uses_apple_container_without_managed_docker_sandboxes(monkeypatch): + commands: list[list[str]] = [] + + def fake_run(cmd, **_kwargs): + commands.append(cmd) + if cmd == ["container", "--version"]: + return SimpleNamespace(stdout="container 0.7.0\n", stderr="", returncode=0) + if cmd[:2] == ["docker", "ps"]: + return SimpleNamespace(stdout="other-sandbox-collision\n", stderr="", returncode=0) + raise AssertionError(f"unexpected command: {cmd}") + + monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.platform.system", lambda: "Darwin") + monkeypatch.setattr("subprocess.run", fake_run) + + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + network_config={"mode": "open"}, + ) + + assert backend.runtime == "container" + assert any("label=deerflow.role=sandbox" in command for command in commands) + + +def _restricted_backend() -> LocalContainerBackend: + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + backend._runtime = "docker" + backend._network_mode = "allowlist" + backend._network_config = { + "mode": "allowlist", + "allow_domains": ["pypi.org", "files.pythonhosted.org"], + "approval": "prompt", + "temporary_grant_ttl": 300, + "proxy_image": "proxy:latest", + } + return backend + + +def test_network_policy_digest_is_canonical_and_covers_effective_policy(): + backend = _restricted_backend() + original = backend._network_policy_digest() + + backend._network_config["allow_domains"] = ["files.pythonhosted.org", "pypi.org"] + assert backend._network_policy_digest() == original + + backend._network_config["allow_domains"] = ["pypi.org"] + assert backend._network_policy_digest() != original + + +def test_open_create_labels_sandbox_identity_and_mode(monkeypatch): + backend = _backend_for_inspect_tests() + captured: dict[str, object] = {} + + def fake_start(*_args, **kwargs): + captured.update(kwargs) + return "container-id" + + monkeypatch.setattr(backend, "_start_container", fake_start) + monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.get_free_port", lambda start_port=None: 18080) + + backend.create(thread_id="thread", sandbox_id="labelled-open") + + assert captured["labels"] == { + "deerflow.sandbox_id": "labelled-open", + "deerflow.role": "sandbox", + "deerflow.network_mode": "open", + } + + +def test_create_internal_network_isolates_both_gateway_families_and_labels_policy(monkeypatch): + backend = _restricted_backend() + commands: list[list[str]] = [] + monkeypatch.setattr(backend, "_inspect_network", lambda _name: None) + + def fake_run(cmd, **_kwargs): + commands.append(cmd) + return SimpleNamespace(stdout="network-id\n", stderr="", returncode=0) + + monkeypatch.setattr("subprocess.run", fake_run) + backend._create_internal_network("sandbox-network", "sandbox-id") + + create = commands[0] + assert "com.docker.network.bridge.gateway_mode_ipv4=isolated" in create + assert "com.docker.network.bridge.gateway_mode_ipv6=isolated" in create + assert f"deerflow.network_policy_digest={backend._network_policy_digest()}" in create + + +def test_create_egress_network_is_per_sandbox_and_disables_inter_container_traffic(monkeypatch): + backend = _restricted_backend() + commands: list[list[str]] = [] + monkeypatch.setattr(backend, "_inspect_network", lambda _name: None) + + def fake_run(cmd, **_kwargs): + commands.append(cmd) + return SimpleNamespace(stdout="network-id\n", stderr="", returncode=0) + + monkeypatch.setattr("subprocess.run", fake_run) + backend._create_egress_network("sandbox-egress", "sandbox-id") + + create = commands[0] + assert "--internal" not in create + assert "com.docker.network.bridge.enable_icc=false" in create + assert "deerflow.role=egress-network" in create + assert f"deerflow.network_policy_digest={backend._network_policy_digest()}" in create + + +def test_restricted_resource_status_requires_matching_policy_image_and_network(): + backend = _restricted_backend() + sandbox_id = "existing" + container_name = "sandbox-existing" + proxy_name, network_name = backend._resource_names(sandbox_id) + egress_network_name = backend._egress_network_name(sandbox_id) + inspections = { + container_name: _ContainerInspection( + created_at=1.0, + host_port=None, + labels=backend._restricted_labels(sandbox_id, "sandbox"), + image="sandbox:latest", + networks=frozenset({network_name}), + ), + proxy_name: _ContainerInspection( + created_at=1.0, + host_port=18080, + labels=backend._restricted_labels(sandbox_id, "network-proxy"), + image="proxy:latest", + networks=frozenset({egress_network_name, network_name}), + relay_token="test-relay-token-that-is-at-least-32-bytes", + ), + } + network = _NetworkInspection( + driver="bridge", + internal=True, + labels=backend._restricted_labels(sandbox_id, "network"), + options={ + "com.docker.network.bridge.gateway_mode_ipv4": "isolated", + "com.docker.network.bridge.gateway_mode_ipv6": "isolated", + }, + ) + egress_network = _NetworkInspection( + driver="bridge", + internal=False, + labels=backend._restricted_labels(sandbox_id, "egress-network"), + options={"com.docker.network.bridge.enable_icc": "false"}, + ) + backend._inspect_network = lambda name: network if name == network_name else egress_network + + assert backend._restricted_resources_status(sandbox_id, inspections=inspections) == "compatible" + + compatible_proxy = inspections[proxy_name] + inspections[proxy_name] = _ContainerInspection( + created_at=compatible_proxy.created_at, + host_port=compatible_proxy.host_port, + labels=compatible_proxy.labels, + image=compatible_proxy.image, + networks=compatible_proxy.networks, + ) + assert backend._restricted_resources_status(sandbox_id, inspections=inspections) == "mismatch" + inspections[proxy_name] = compatible_proxy + + egress_network = _NetworkInspection( + driver="bridge", + internal=False, + labels=backend._restricted_labels(sandbox_id, "egress-network"), + options={"com.docker.network.bridge.enable_icc": "true"}, + ) + assert backend._restricted_resources_status(sandbox_id, inspections=inspections) == "mismatch" + + egress_network = _NetworkInspection( + driver="bridge", + internal=False, + labels=backend._restricted_labels(sandbox_id, "egress-network"), + options={"com.docker.network.bridge.enable_icc": "false"}, + ) + inspections[proxy_name] = _ContainerInspection( + created_at=1.0, + host_port=18080, + labels={**backend._restricted_labels(sandbox_id, "network-proxy"), "deerflow.network_policy_digest": "stale"}, + image="proxy:latest", + networks=frozenset({egress_network_name, network_name}), + relay_token="test-relay-token-that-is-at-least-32-bytes", + ) + assert backend._restricted_resources_status(sandbox_id, inspections=inspections) == "mismatch" + + +def test_restricted_sandbox_has_no_published_port_and_forces_proxy_env(monkeypatch): + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={"HTTP_PROXY": "http://operator-proxy:3128"}, + ) + monkeypatch.setattr(backend, "_runtime", "docker") + captured_cmd: list[str] = [] + + def fake_run(cmd, **kwargs): + captured_cmd.extend(cmd) + return SimpleNamespace(stdout="container-id\n", stderr="", returncode=0) + + monkeypatch.setattr("subprocess.run", fake_run) + backend._start_container( + "sandbox-test", + 18080, + network_override="deer-flow-sandbox-net-test", + publish_port=False, + extra_environment={"HTTP_PROXY": "http://deer-flow-netproxy-test:3128"}, + ) + + assert "-p" not in captured_cmd + assert captured_cmd[captured_cmd.index("--network") + 1] == "deer-flow-sandbox-net-test" + proxy_values = [captured_cmd[index + 1] for index, value in enumerate(captured_cmd) if value == "-e" and captured_cmd[index + 1].startswith("HTTP_PROXY=")] + assert proxy_values[-1] == "HTTP_PROXY=http://deer-flow-netproxy-test:3128" + + +def test_restricted_start_configures_shell_and_aio_browser_proxy(monkeypatch): + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + backend._network_mode = "allowlist" + monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id: "missing") + monkeypatch.setattr(backend, "_create_internal_network", lambda _name, _sandbox_id: None) + monkeypatch.setattr(backend, "_create_egress_network", lambda _name, _sandbox_id: None) + monkeypatch.setattr(backend, "_start_network_proxy", lambda *_args: None) + captured: dict[str, object] = {} + + def fake_start(*_args, **kwargs): + captured.update(kwargs) + return "container-id" + + monkeypatch.setattr(backend, "_start_container", fake_start) + + assert ( + backend._start_restricted_sandbox( + "id", + "sandbox-id", + 18080, + None, + config_mount_exclusion_root=None, + relay_token="test-relay-token", + ) + == "container-id" + ) + + proxy_name, network_name = backend._resource_names("id") + assert captured["network_override"] == network_name + assert captured["publish_port"] is False + environment = captured["extra_environment"] + assert environment["HTTPS_PROXY"] == f"http://{proxy_name}:3128" + assert environment["ALL_PROXY"] == f"http://{proxy_name}:3128" + assert environment["PROXY_SERVER"] == f"{proxy_name}:3128" + + +def test_restricted_start_refuses_to_remove_resources_with_stale_policy(monkeypatch): + backend = _restricted_backend() + cleaned: list[str] = [] + created: list[tuple[str, str]] = [] + monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id: "mismatch") + monkeypatch.setattr(backend, "_cleanup_restricted_resources", cleaned.append) + monkeypatch.setattr(backend, "_create_internal_network", lambda name, sandbox_id: created.append((name, sandbox_id))) + monkeypatch.setattr(backend, "_create_egress_network", lambda name, sandbox_id: created.append((name, sandbox_id))) + monkeypatch.setattr(backend, "_start_network_proxy", lambda *_args: None) + monkeypatch.setattr(backend, "_start_container", lambda *_args, **_kwargs: "container-id") + + with pytest.raises(RuntimeError, match="requires ownership-fenced replacement"): + backend._start_restricted_sandbox( + "stale", + "sandbox-stale", + 18080, + None, + config_mount_exclusion_root=None, + relay_token="test-relay-token", + ) + + assert cleaned == [] + assert created == [] + + +def test_network_proxy_uses_read_only_root_and_bounded_policy_storage(monkeypatch): + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={}, + ) + backend._network_mode = "allowlist" + backend._network_config = { + "mode": "allowlist", + "allow_domains": [], + "approval": "prompt", + "proxy_image": "proxy:latest", + } + commands: list[list[str]] = [] + + def fake_run(cmd, **_kwargs): + commands.append(cmd) + return SimpleNamespace(stdout="proxy-id\n", stderr="", returncode=0) + + monkeypatch.setattr("subprocess.run", fake_run) + + backend._start_network_proxy( + "proxy-name", + "network-name", + "egress-network-name", + "sandbox-name", + 18080, + "sandbox-id", + "test-relay-token", + ) + + create = commands[0] + assert create[create.index("--network") + 1] == "egress-network-name" + assert "bridge" not in create + assert "--read-only" in create + assert create[create.index("--tmpfs") + 1] == "/tmp:rw,noexec,nosuid,size=16m" + assert "--cap-drop=ALL" in create + assert "no-new-privileges" in create + assert "DEERFLOW_RELAY_TOKEN=test-relay-token" in create + + def test_start_container_filters_nested_config_mounts_for_policy_scoped_skills( monkeypatch, ): @@ -732,6 +1176,477 @@ def test_discover_returns_none_when_runtime_check_times_out(monkeypatch): assert backend.discover("sandbox-timeout") is None +def test_restricted_discovery_uses_proxy_relay_port(monkeypatch): + backend = _backend_for_inspect_tests() + backend._network_mode = "allowlist" + container_name = "sandbox-existing" + proxy_name, _ = backend._resource_names("existing") + monkeypatch.setattr(backend, "_is_container_running", lambda _name: True) + inspections = { + container_name: _ContainerInspection( + 1.0, + None, + { + "deerflow.role": "sandbox", + "deerflow.sandbox_id": "existing", + "deerflow.network_mode": "allowlist", + }, + "sandbox:latest", + frozenset(), + ), + proxy_name: _ContainerInspection( + 1.0, + 18080, + {}, + "proxy:latest", + frozenset(), + "test-relay-token-that-is-at-least-32-bytes", + ), + } + monkeypatch.setattr(backend, "_batch_inspect", lambda *_args, **_kwargs: inspections) + monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id, **_kwargs: "compatible") + readiness: list[dict[str, object]] = [] + + def fake_ready(_url, **kwargs): + readiness.append(kwargs) + return True + + monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.wait_for_sandbox_ready", fake_ready) + + info = backend.discover("existing") + + assert info is not None + assert info.container_name == "sandbox-existing" + assert info.sandbox_url == "http://localhost:18080" + assert info.request_headers == {"X-DeerFlow-Relay-Token": "test-relay-token-that-is-at-least-32-bytes"} + assert readiness == [{"timeout": 5, "headers": info.request_headers}] + + +def test_restricted_discovery_reports_stale_policy_without_removing_resources(monkeypatch): + backend = _backend_for_inspect_tests() + backend._network_mode = "allowlist" + cleaned: list[str] = [] + container_name = "sandbox-stale" + monkeypatch.setattr(backend, "_is_container_running", lambda _name: True) + monkeypatch.setattr( + backend, + "_batch_inspect", + lambda *_args, **_kwargs: { + container_name: _ContainerInspection( + 1.0, + None, + { + "deerflow.role": "sandbox", + "deerflow.sandbox_id": "stale", + "deerflow.network_mode": "allowlist", + }, + "sandbox:latest", + frozenset(), + ) + }, + ) + monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id, **_kwargs: "mismatch") + monkeypatch.setattr(backend, "_cleanup_restricted_resources", cleaned.append) + + info = backend.discover("stale") + + assert info is not None + assert info.sandbox_id == "stale" + assert info.container_name == "sandbox-stale" + assert info.requires_replacement is True + assert cleaned == [] + + +def test_restricted_discovery_reports_legacy_open_sandbox_for_fenced_replacement(monkeypatch): + backend = _restricted_backend() + container_name = "sandbox-legacy-open" + inspected_batches: list[list[str]] = [] + monkeypatch.setattr(backend, "_is_container_running", lambda _name: True) + + def fake_batch_inspect(names, **_kwargs): + inspected_batches.append(list(names)) + return { + container_name: _ContainerInspection( + 1.0, + 18080, + {}, + "sandbox:latest", + frozenset({"bridge"}), + ) + } + + monkeypatch.setattr(backend, "_batch_inspect", fake_batch_inspect) + + info = backend.discover("legacy-open") + + assert info is not None + assert info.requires_replacement is True + assert info.sandbox_url == "" + assert inspected_batches == [[container_name]] + + +def test_restricted_discovery_reports_labelled_open_sandbox_for_fenced_replacement(monkeypatch): + backend = _restricted_backend() + container_name = "sandbox-labelled-open" + monkeypatch.setattr(backend, "_is_container_running", lambda _name: True) + monkeypatch.setattr( + backend, + "_batch_inspect", + lambda *_args, **_kwargs: { + container_name: _ContainerInspection( + 1.0, + 18080, + { + "deerflow.role": "sandbox", + "deerflow.sandbox_id": "labelled-open", + "deerflow.network_mode": "open", + }, + "sandbox:latest", + frozenset({"bridge"}), + ) + }, + ) + monkeypatch.setattr( + backend, + "_restricted_resources_status", + lambda *_args, **_kwargs: pytest.fail("a mode mismatch must be reported before restricted resource inspection"), + ) + + info = backend.discover("labelled-open") + + assert info is not None + assert info.requires_replacement is True + assert info.sandbox_url == "" + + +def test_open_discovery_reports_restricted_sandbox_for_fenced_replacement(monkeypatch): + backend = _backend_for_inspect_tests() + container_name = "sandbox-old-restricted" + monkeypatch.setattr(backend, "_is_container_running", lambda _name: True) + monkeypatch.setattr( + backend, + "_batch_inspect", + lambda *_args, **_kwargs: { + container_name: _ContainerInspection( + 1.0, + None, + { + "deerflow.role": "sandbox", + "deerflow.sandbox_id": "old-restricted", + "deerflow.network_mode": "allowlist", + }, + "sandbox:latest", + frozenset({"deer-flow-sandbox-net-old"}), + ) + }, + ) + + info = backend.discover("old-restricted") + + assert info is not None + assert info.requires_replacement is True + assert info.sandbox_url == "" + + +def test_restricted_discovery_leaves_unlabelled_name_collision_unmanaged(monkeypatch): + backend = _backend_for_inspect_tests() + backend._network_mode = "allowlist" + container_name = "sandbox-foreign" + cleaned: list[str] = [] + monkeypatch.setattr(backend, "_is_container_running", lambda _name: True) + monkeypatch.setattr( + backend, + "_batch_inspect", + lambda *_args, **_kwargs: { + container_name: _ContainerInspection( + 1.0, + None, + {}, + "foreign:latest", + frozenset(), + ) + }, + ) + monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id, **_kwargs: "mismatch") + monkeypatch.setattr(backend, "_cleanup_restricted_resources", cleaned.append) + + assert backend.discover("foreign") is None + assert cleaned == [] + + +def test_restricted_discovery_leaves_unlabelled_published_foreign_image_unmanaged(monkeypatch): + backend = _restricted_backend() + container_name = "sandbox-foreign-published" + monkeypatch.setattr(backend, "_is_container_running", lambda _name: True) + monkeypatch.setattr( + backend, + "_batch_inspect", + lambda *_args, **_kwargs: { + container_name: _ContainerInspection( + 1.0, + 18080, + {}, + "foreign:latest", + frozenset({"bridge"}), + ) + }, + ) + + assert backend.discover("foreign-published") is None + + +def test_restricted_health_rejects_resources_with_stale_policy(monkeypatch): + backend = _backend_for_inspect_tests() + backend._network_mode = "allowlist" + monkeypatch.setattr(backend, "_is_container_running", lambda _name: True) + monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id: "mismatch") + + assert not backend.is_alive( + SandboxInfo( + sandbox_id="stale", + sandbox_url="http://localhost:18080", + container_name="sandbox-stale", + ) + ) + + +def test_restricted_list_reconciliation_reports_stale_policy_without_removing_resources(monkeypatch): + backend = _backend_for_inspect_tests() + backend._network_mode = "allowlist" + proxy_name, _ = backend._resource_names("stale") + cleaned: list[str] = [] + monkeypatch.setattr( + "subprocess.run", + lambda *_args, **_kwargs: SimpleNamespace(stdout="sandbox-stale\n", stderr="", returncode=0), + ) + monkeypatch.setattr( + backend, + "_batch_inspect", + lambda _names, **_kwargs: { + "sandbox-stale": _ContainerInspection( + 1.0, + None, + { + "deerflow.role": "sandbox", + "deerflow.sandbox_id": "stale", + "deerflow.network_mode": "allowlist", + }, + "sandbox:latest", + frozenset(), + ), + proxy_name: _ContainerInspection( + 1.0, + 18080, + {"deerflow.role": "network-proxy", "deerflow.sandbox_id": "stale"}, + "proxy:latest", + frozenset(), + ), + }, + ) + monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id, **_kwargs: "mismatch") + monkeypatch.setattr(backend, "_cleanup_restricted_resources", cleaned.append) + + infos = backend.list_running() + + assert len(infos) == 1 + assert infos[0].sandbox_id == "stale" + assert infos[0].requires_replacement is True + assert cleaned == [] + + +def test_restricted_list_reports_legacy_open_sandbox_for_fenced_replacement(monkeypatch): + backend = _restricted_backend() + commands: list[list[str]] = [] + + def fake_run(cmd, **_kwargs): + commands.append(cmd) + return SimpleNamespace(stdout="sandbox-legacy-open\n", stderr="", returncode=0) + + monkeypatch.setattr("subprocess.run", fake_run) + monkeypatch.setattr( + backend, + "_batch_inspect", + lambda _names, **_kwargs: { + "sandbox-legacy-open": _ContainerInspection( + 1.0, + 18080, + {}, + "sandbox:latest", + frozenset({"bridge"}), + ) + }, + ) + + infos = backend.list_running() + + assert len(infos) == 1 + assert infos[0].requires_replacement is True + assert infos[0].sandbox_url == "" + assert "label=deerflow.role=sandbox" not in commands[0] + + +def test_open_list_reports_restricted_sandbox_for_fenced_replacement(monkeypatch): + backend = _backend_for_inspect_tests() + monkeypatch.setattr( + "subprocess.run", + lambda *_args, **_kwargs: SimpleNamespace(stdout="sandbox-old-restricted\n", stderr="", returncode=0), + ) + monkeypatch.setattr( + backend, + "_batch_inspect", + lambda _names, **_kwargs: { + "sandbox-old-restricted": _ContainerInspection( + 1.0, + None, + { + "deerflow.role": "sandbox", + "deerflow.sandbox_id": "old-restricted", + "deerflow.network_mode": "isolated", + }, + "sandbox:latest", + frozenset({"deer-flow-sandbox-net-old"}), + ) + }, + ) + + infos = backend.list_running() + + assert len(infos) == 1 + assert infos[0].requires_replacement is True + assert infos[0].sandbox_url == "" + + +def test_restricted_list_running_excludes_sidecars_for_overlapping_custom_prefix(monkeypatch): + backend = _backend_for_inspect_tests() + backend._network_mode = "allowlist" + backend._container_prefix = "deer-flow" + sandbox_id = "live" + sandbox_name = "deer-flow-live" + proxy_name, _ = backend._resource_names(sandbox_id) + commands: list[list[str]] = [] + + def fake_run(cmd, **_kwargs): + commands.append(cmd) + return SimpleNamespace( + stdout=f"{sandbox_name}\n{proxy_name}\n", + stderr="", + returncode=0, + ) + + monkeypatch.setattr("subprocess.run", fake_run) + inspected_batches: list[list[str]] = [] + + def fake_batch_inspect(names, **_kwargs): + inspected_batches.append(list(names)) + return { + sandbox_name: _ContainerInspection( + 1.0, + None, + { + "deerflow.role": "sandbox", + "deerflow.sandbox_id": sandbox_id, + "deerflow.network_mode": "allowlist", + }, + "sandbox:latest", + frozenset(), + ), + proxy_name: _ContainerInspection( + 1.0, + 18080, + {"deerflow.role": "network-proxy", "deerflow.sandbox_id": sandbox_id}, + "proxy:latest", + frozenset(), + "test-relay-token-that-is-at-least-32-bytes", + ), + } + + monkeypatch.setattr(backend, "_batch_inspect", fake_batch_inspect) + checked: list[str] = [] + + def compatible(current_sandbox_id, **_kwargs): + checked.append(current_sandbox_id) + return "compatible" + + monkeypatch.setattr(backend, "_restricted_resources_status", compatible) + + infos = backend.list_running() + + assert [info.sandbox_id for info in infos] == [sandbox_id] + assert checked == [sandbox_id] + assert "label=deerflow.role=sandbox" not in commands[0] + sidecar_as_sandbox_id = proxy_name[len(backend._container_prefix) + 1 :] + fabricated_proxy_name, _ = backend._resource_names(sidecar_as_sandbox_id) + assert fabricated_proxy_name not in {name for batch in inspected_batches for name in batch} + + +def test_restricted_destroy_stops_pair_and_removes_both_networks(monkeypatch): + backend = _backend_for_inspect_tests() + backend._network_mode = "isolated" + proxy_name, network_name = backend._resource_names("existing") + egress_network_name = backend._egress_network_name("existing") + stopped: list[str] = [] + commands: list[list[str]] = [] + monkeypatch.setattr(backend, "_stop_container", stopped.append) + + def fake_run(cmd, **_kwargs): + commands.append(cmd) + return SimpleNamespace(stdout="", stderr="", returncode=0) + + monkeypatch.setattr("subprocess.run", fake_run) + backend.destroy( + SandboxInfo( + sandbox_id="existing", + sandbox_url="http://localhost:18080", + container_name="sandbox-existing", + container_id="sandbox-container-id", + ) + ) + + assert stopped == ["sandbox-container-id", proxy_name] + assert ["docker", "rm", "-f", proxy_name] in commands + assert ["docker", "network", "rm", network_name] in commands + assert ["docker", "network", "rm", egress_network_name] in commands + + +def test_open_mode_replacement_destroy_removes_restricted_sidecar_and_networks(monkeypatch): + backend = _backend_for_inspect_tests() + stopped: list[str] = [] + cleaned: list[tuple[str, bool]] = [] + monkeypatch.setattr(backend, "_stop_container", stopped.append) + monkeypatch.setattr( + backend, + "_cleanup_restricted_resources", + lambda sandbox_id, *, stop_sandbox=True: cleaned.append((sandbox_id, stop_sandbox)), + ) + + backend.destroy( + SandboxInfo( + sandbox_id="old-restricted", + sandbox_url="", + container_name="sandbox-old-restricted", + requires_replacement=True, + ) + ) + + assert stopped == ["sandbox-old-restricted"] + assert cleaned == [("old-restricted", False)] + + +def test_deny_pending_network_policy_events_uses_atomic_proxy_command(monkeypatch): + backend = _restricted_backend() + commands: list[list[str]] = [] + + def fake_run(cmd, **_kwargs): + commands.append(cmd) + return SimpleNamespace(stdout="17\n", stderr="", returncode=0) + + monkeypatch.setattr("subprocess.run", fake_run) + + assert backend.deny_pending_network_policy_events("existing") is True + proxy_name, _ = backend._resource_names("existing") + assert commands == [["docker", "exec", proxy_name, "python", "/tmp/deerflow-network-proxy.py", "deny-pending"]] + + def test_is_container_running_false_on_apple_container_not_found(monkeypatch): """Apple Container's generic "not found" is trusted when it names the container.""" backend = _backend_for_inspect_tests() @@ -848,7 +1763,19 @@ def test_discover_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, "_is_container_running", lambda name: True) - monkeypatch.setattr(backend, "_get_container_port", lambda name: 18081) + monkeypatch.setattr( + backend, + "_batch_inspect", + lambda *_args, **_kwargs: { + "sandbox-sbx-ipv6": _ContainerInspection( + 1.0, + 18081, + {}, + "sandbox:latest", + frozenset({"bridge"}), + ) + }, + ) seen_urls = [] @@ -1071,6 +1998,170 @@ def test_aio_1_11_image_starts_with_fowner_capability(monkeypatch): ) +@pytest.mark.live +def test_restricted_network_proxy_enforces_and_approves_real_traffic(monkeypatch): + """Exercise the Engine-28 bridge, API relay, proxy, event, and grant path.""" + if not _docker_daemon_available(): + pytest.skip("requires a running Docker daemon") + + image = os.environ.get("DEER_FLOW_SANDBOX_NETWORK_SMOKE_IMAGE", "python:3.12-alpine") + backend = LocalContainerBackend( + image=image, + base_port=18310, + container_prefix="sandbox-policy-smoke", + config_mounts=[], + environment={}, + network_config={ + "mode": "allowlist", + "allow_domains": ["pypi.org"], + "approval": "prompt", + "temporary_grant_ttl": 300, + "proxy_image": image, + }, + ) + monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False) + sandbox_id = "network-live" + container_name = f"sandbox-policy-smoke-{sandbox_id}" + proxy_name, network_name = backend._resource_names(sandbox_id) + egress_network_name = backend._egress_network_name(sandbox_id) + port = get_free_port(start_port=18310) + relay_token = "live-relay-token-that-is-at-least-32-bytes" + proxy_url = f"http://{proxy_name}:3128" + + try: + assert backend._restricted_resources_status(sandbox_id) == "missing" + backend._create_internal_network(network_name, sandbox_id) + backend._create_egress_network(egress_network_name, sandbox_id) + backend._start_network_proxy(proxy_name, network_name, egress_network_name, container_name, port, sandbox_id, relay_token) + + proxy_inspect = subprocess.run( + ["docker", "inspect", proxy_name], + capture_output=True, + text=True, + timeout=15, + ) + assert proxy_inspect.returncode == 0, proxy_inspect.stderr + proxy_networks = json.loads(proxy_inspect.stdout)[0]["NetworkSettings"]["Networks"] + assert set(proxy_networks) == {network_name, egress_network_name} + assert "bridge" not in proxy_networks + proxy_egress_ip = proxy_networks[egress_network_name]["IPAddress"] + outside = subprocess.run( + [ + "docker", + "run", + "--rm", + "--network", + "bridge", + image, + "python", + "-c", + (f"import socket,sys\ntry:\n socket.create_connection(({proxy_egress_ip!r}, 8080), timeout=2)\nexcept OSError:\n sys.exit(0)\nsys.exit(42)"), + ], + capture_output=True, + text=True, + timeout=15, + ) + assert outside.returncode == 0, outside.stderr or "a container on Docker's shared bridge reached the sandbox API relay" + sandbox_labels = backend._restricted_labels(sandbox_id, "sandbox") + sandbox = subprocess.run( + [ + "docker", + "run", + "--rm", + "-d", + "--network", + network_name, + "--name", + container_name, + *(item for key, value in sandbox_labels.items() for item in ("--label", f"{key}={value}")), + "-e", + f"HTTP_PROXY={proxy_url}", + "-e", + f"HTTPS_PROXY={proxy_url}", + "-e", + f"http_proxy={proxy_url}", + "-e", + f"https_proxy={proxy_url}", + image, + "python", + "-m", + "http.server", + "8080", + ], + capture_output=True, + text=True, + timeout=60, + ) + assert sandbox.returncode == 0, sandbox.stderr + assert backend._restricted_resources_status(sandbox_id) == "compatible" + backend._network_config["allow_domains"] = ["changed.example"] + assert backend._restricted_resources_status(sandbox_id) == "mismatch" + backend._network_config["allow_domains"] = ["pypi.org"] + + sandbox_url = f"http://127.0.0.1:{port}" + unauthenticated = subprocess.run( + ["curl", "--fail", "--silent", "--max-time", "2", sandbox_url], + capture_output=True, + text=True, + ) + assert unauthenticated.returncode != 0 + deadline = time.time() + 20 + while time.time() < deadline: + relay = subprocess.run( + [ + "curl", + "--fail", + "--silent", + "--max-time", + "2", + "-H", + f"X-DeerFlow-Relay-Token: {relay_token}", + sandbox_url, + ], + capture_output=True, + text=True, + ) + if relay.returncode == 0: + break + time.sleep(0.25) + assert relay.returncode == 0, relay.stderr + + def sandbox_fetch(url: str, *, use_proxy: bool = True) -> subprocess.CompletedProcess[str]: + proxy_handler = "urllib.request.ProxyHandler()" if use_proxy else "urllib.request.ProxyHandler({})" + code = f"import urllib.request; opener=urllib.request.build_opener({proxy_handler}); print(opener.open({url!r}, timeout=15).status)" + return subprocess.run( + ["docker", "exec", container_name, "python", "-c", code], + capture_output=True, + text=True, + timeout=25, + ) + + allowed = sandbox_fetch("https://pypi.org/simple/") + assert allowed.returncode == 0, allowed.stderr + assert allowed.stdout.strip() == "200" + + denied = sandbox_fetch("https://example.com/") + assert denied.returncode != 0 + events = backend.consume_network_policy_events(sandbox_id) + assert [(event["host"], event["port"]) for event in events] == [("example.com", 443)] + + request_id = str(events[0]["request_id"]) + assert backend.decide_network_policy_request(sandbox_id, request_id, "allow_temporary") + approved = sandbox_fetch("https://example.com/") + assert approved.returncode == 0, approved.stderr + assert approved.stdout.strip() == "200" + + metadata = sandbox_fetch("http://169.254.169.254/latest/meta-data/") + assert metadata.returncode != 0 + assert backend.consume_network_policy_events(sandbox_id) == [] + + direct = sandbox_fetch("https://example.com/", use_proxy=False) + assert direct.returncode != 0 + finally: + backend._cleanup_restricted_resources(sandbox_id) + release_port(port) + + 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/FOWNER/SETUID/SETGID/DAC_OVERRIDE must not stay available for diff --git a/backend/tests/test_aio_sandbox_network_proxy.py b/backend/tests/test_aio_sandbox_network_proxy.py new file mode 100644 index 000000000..f87299ee9 --- /dev/null +++ b/backend/tests/test_aio_sandbox_network_proxy.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +import asyncio +import json +import socket +import ssl +from unittest.mock import MagicMock + +import pytest + +from deerflow.community.aio_sandbox import network_proxy + + +def test_domain_matches_exact_and_leading_wildcard_only() -> None: + assert network_proxy.domain_matches("pypi.org", "pypi.org") + assert not network_proxy.domain_matches("evilpypi.org", "pypi.org") + assert network_proxy.domain_matches("files.pythonhosted.org", "*.pythonhosted.org") + assert not network_proxy.domain_matches("pythonhosted.org", "*.pythonhosted.org") + + +def test_address_is_public_rejects_host_private_link_local_and_metadata() -> None: + for address in ( + "127.0.0.1", + "10.0.0.2", + "172.16.0.2", + "192.168.1.2", + "169.254.169.254", + "224.0.0.1", + "::1", + "fc00::1", + "fec0::1", + "fe80::1", + "ff0e::1", + ): + assert not network_proxy.address_is_public(address) + assert network_proxy.address_is_public("8.8.8.8") + assert not network_proxy.address_is_public("198.18.1.5") + assert network_proxy.address_is_public("198.18.1.5", allow_synthetic_dns=True) + + +def test_policy_denial_and_temporary_or_sandbox_grants(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(network_proxy, "POLICY_DB", tmp_path / "policy.sqlite3") + monkeypatch.setenv("DEERFLOW_NETWORK_MODE", "allowlist") + monkeypatch.setenv("DEERFLOW_ALLOW_DOMAINS_JSON", json.dumps(["pypi.org"])) + + assert network_proxy.policy_allows("pypi.org", 443, now=100) + assert not network_proxy.policy_allows("example.com", 443, now=100) + + temporary = network_proxy.record_denial("example.com", 443, "CONNECT") + assert network_proxy.decide(temporary, "allow_temporary", ttl=60) + assert network_proxy.policy_allows("example.com", 443) + + sandbox = network_proxy.record_denial("files.example.net", 443, "CONNECT") + assert network_proxy.decide(sandbox, "allow_sandbox", ttl=60) + assert network_proxy.policy_allows("files.example.net", 443, now=10**12) + + +def test_pending_events_are_consumed_once(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(network_proxy, "POLICY_DB", tmp_path / "policy.sqlite3") + request_id = network_proxy.record_denial("example.com", 443, "CONNECT") + + events = network_proxy.pending_events() + + assert events == [ + { + "request_id": request_id, + "host": "example.com", + "port": 443, + "method": "CONNECT", + "created_at": events[0]["created_at"], + } + ] + assert network_proxy.pending_events() == [] + + +def test_pending_events_surface_only_one_destination_per_approval(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(network_proxy, "POLICY_DB", tmp_path / "policy.sqlite3") + first = network_proxy.record_denial("one.example", 443, "CONNECT") + network_proxy.record_denial("two.example", 443, "CONNECT") + + assert [event["request_id"] for event in network_proxy.pending_events()] == [first] + # The sibling is superseded so a retry can create a fresh approvable event. + assert network_proxy.pending_events() == [] + fresh = network_proxy.record_denial("two.example", 443, "CONNECT") + assert [event["request_id"] for event in network_proxy.pending_events()] == [fresh] + + +def test_pending_events_supersede_every_sibling_without_a_batch_limit(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(network_proxy, "POLICY_DB", tmp_path / "policy.sqlite3") + request_ids = [network_proxy.record_denial(f"host-{index}.example", 443, "CONNECT") for index in range(17)] + + assert [event["request_id"] for event in network_proxy.pending_events()] == [request_ids[0]] + assert network_proxy.pending_events() == [] + + +def test_deny_pending_events_atomically_denies_every_unsurfaced_event(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(network_proxy, "POLICY_DB", tmp_path / "policy.sqlite3") + for index in range(17): + network_proxy.record_denial(f"host-{index}.example", 443, "CONNECT") + + assert network_proxy.deny_pending_events() == 17 + assert network_proxy.pending_events() == [] + + with network_proxy._connect_db() as db: + assert db.execute("SELECT COUNT(*) FROM events WHERE decision = 'deny'").fetchone() == (17,) + + +def test_deny_pending_events_preserves_an_already_surfaced_user_decision(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(network_proxy, "POLICY_DB", tmp_path / "policy.sqlite3") + surfaced = network_proxy.record_denial("interactive.example", 443, "CONNECT") + assert [event["request_id"] for event in network_proxy.pending_events()] == [surfaced] + unsurfaced = network_proxy.record_denial("scheduled.example", 443, "CONNECT") + + assert network_proxy.deny_pending_events() == 1 + assert network_proxy.decide(surfaced, "allow_temporary", ttl=60) + assert network_proxy.decide(unsurfaced, "deny", ttl=60) + + +def test_pending_events_claims_old_unsurfaced_denial_on_retry(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(network_proxy, "POLICY_DB", tmp_path / "policy.sqlite3") + now = [100.0] + monkeypatch.setattr(network_proxy.time, "time", lambda: now[0]) + request_id = network_proxy.record_denial("late.example", 443, "CONNECT") + + now[0] = 105.0 + assert network_proxy.record_denial("late.example", 443, "CONNECT") == request_id + assert [event["request_id"] for event in network_proxy.pending_events()] == [request_id] + + +@pytest.mark.anyio +async def test_resolve_public_fails_closed_when_dns_contains_private_answer(monkeypatch) -> None: + loop = __import__("asyncio").get_running_loop() + + async def fake_getaddrinfo(*_args, **_kwargs): + return [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 443)), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.5", 443)), + ] + + monkeypatch.setattr(loop, "getaddrinfo", fake_getaddrinfo) + assert await network_proxy.resolve_public("example.com", 443) is None + + +@pytest.mark.anyio +async def test_resolve_public_returns_every_validated_answer_and_open_retries(monkeypatch) -> None: + loop = asyncio.get_running_loop() + answers = [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 443)), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("1.1.1.1", 443)), + ] + + async def fake_getaddrinfo(*_args, **_kwargs): + return answers + + attempts: list[str] = [] + connected = (MagicMock(), MagicMock()) + + async def fake_open_connection(host: str, _port: int, *, family: int): + attempts.append(host) + assert family == socket.AF_INET + if host == "8.8.8.8": + raise OSError("first address unavailable") + return connected + + monkeypatch.setattr(loop, "getaddrinfo", fake_getaddrinfo) + monkeypatch.setattr(network_proxy.asyncio, "open_connection", fake_open_connection) + + resolved = await network_proxy.resolve_public("example.com", 443) + + assert resolved == ( + (socket.AF_INET, ("8.8.8.8", 443)), + (socket.AF_INET, ("1.1.1.1", 443)), + ) + assert await network_proxy._open_public(resolved, 443) is connected + assert attempts == ["8.8.8.8", "1.1.1.1"] + + +@pytest.mark.anyio +@pytest.mark.parametrize("mode", ["isolated", "allowlist"]) +async def test_denied_destination_is_rejected_without_dns_resolution(tmp_path, monkeypatch, mode: str) -> None: + monkeypatch.setattr(network_proxy, "POLICY_DB", tmp_path / "policy.sqlite3") + monkeypatch.setenv("DEERFLOW_NETWORK_MODE", mode) + monkeypatch.setenv("DEERFLOW_ALLOW_DOMAINS_JSON", json.dumps(["allowed.example"])) + monkeypatch.delenv("DEERFLOW_RECORD_DENIALS", raising=False) + resolutions: list[tuple[str, int]] = [] + + async def fake_resolve_public(host: str, port: int): + resolutions.append((host, port)) + return None + + monkeypatch.setattr(network_proxy, "resolve_public", fake_resolve_public) + + proxy = await asyncio.start_server(network_proxy.handle_proxy, "127.0.0.1", 0) + proxy_port = proxy.sockets[0].getsockname()[1] + try: + reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port) + writer.write(b"CONNECT denied.example:443 HTTP/1.1\r\nHost: denied.example:443\r\n\r\n") + await writer.drain() + response = await asyncio.wait_for(reader.read(), timeout=2) + writer.close() + await writer.wait_closed() + + assert b"403 Forbidden" in response + assert resolutions == [] + finally: + proxy.close() + await proxy.wait_closed() + + +@pytest.mark.anyio +async def test_tls_client_hello_sni_is_extracted_for_connect_enforcement() -> None: + incoming = ssl.MemoryBIO() + outgoing = ssl.MemoryBIO() + context = ssl.create_default_context() + tls = context.wrap_bio(incoming, outgoing, server_side=False, server_hostname="pypi.org") + with pytest.raises(ssl.SSLWantReadError): + tls.do_handshake() + + reader = __import__("asyncio").StreamReader() + wire = outgoing.read() + reader.feed_data(wire) + reader.feed_eof() + + parsed = await network_proxy._read_tls_client_hello(reader) + + assert parsed == ("pypi.org", wire) + + +def test_http_request_framing_rejects_ambiguous_or_duplicate_lengths() -> None: + with pytest.raises(ValueError, match="cannot be combined"): + fields = network_proxy._parse_http_header_fields(["Content-Length: 4", "Transfer-Encoding: chunked"]) + network_proxy._http_request_body_framing(fields) + with pytest.raises(ValueError, match="one non-negative"): + fields = network_proxy._parse_http_header_fields(["Content-Length: 4", "Content-Length: 4"]) + network_proxy._http_request_body_framing(fields) + + +@pytest.mark.anyio +async def test_chunked_request_body_rejects_non_hex_size() -> None: + reader = asyncio.StreamReader() + reader.feed_data(b"+1\r\na\r\n0\r\n\r\n") + reader.feed_eof() + writer = MagicMock() + + with pytest.raises(ValueError, match="Invalid chunk size"): + await network_proxy._copy_chunked_request_body(reader, writer) + + +@pytest.mark.anyio +async def test_http_proxy_relays_exactly_one_request_per_connection(monkeypatch) -> None: + received: asyncio.Future[bytes] = asyncio.get_running_loop().create_future() + + async def upstream_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + header = await reader.readuntil(b"\r\n\r\n") + body = await reader.readexactly(4) + received.set_result(header + body) + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + await writer.drain() + writer.close() + await writer.wait_closed() + + upstream = await asyncio.start_server(upstream_handler, "127.0.0.1", 0) + upstream_port = upstream.sockets[0].getsockname()[1] + + async def fake_resolve_public(_host: str, _port: int): + return (socket.AF_INET, ("127.0.0.1", upstream_port)) + + async def fake_open_public(_resolved, _port: int): + return await asyncio.open_connection("127.0.0.1", upstream_port) + + monkeypatch.setattr(network_proxy, "resolve_public", fake_resolve_public) + monkeypatch.setattr(network_proxy, "_open_public", fake_open_public) + monkeypatch.setattr(network_proxy, "policy_allows", lambda host, port: (host, port) == ("allowed.example", 80)) + + proxy = await asyncio.start_server(network_proxy.handle_proxy, "127.0.0.1", 0) + proxy_port = proxy.sockets[0].getsockname()[1] + try: + reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port) + writer.write(b"POST http://allowed.example/first HTTP/1.1\r\nHost: allowed.example\r\nContent-Length: 4\r\nConnection: keep-alive\r\n\r\ndataGET http://denied.example/second HTTP/1.1\r\nHost: denied.example\r\n\r\n") + await writer.drain() + response = await asyncio.wait_for(reader.read(), timeout=2) + writer.close() + await writer.wait_closed() + + upstream_request = await asyncio.wait_for(received, timeout=2) + assert b"POST /first HTTP/1.1" in upstream_request + assert b"Connection: close" in upstream_request + assert b"Connection: keep-alive" not in upstream_request + assert upstream_request.endswith(b"data") + assert b"denied.example" not in upstream_request + assert b"200 OK" in response + finally: + proxy.close() + upstream.close() + await proxy.wait_closed() + await upstream.wait_closed() + + +@pytest.mark.anyio +async def test_sandbox_api_relay_requires_per_sandbox_token(monkeypatch) -> None: + received: asyncio.Future[bytes] = asyncio.get_running_loop().create_future() + + async def upstream_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + header = await reader.readuntil(b"\r\n\r\n") + received.set_result(header) + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + await writer.drain() + writer.close() + await writer.wait_closed() + + upstream = await asyncio.start_server(upstream_handler, "127.0.0.1", 0) + upstream_port = upstream.sockets[0].getsockname()[1] + monkeypatch.setenv(network_proxy.RELAY_TOKEN_ENV, "test-relay-token") + monkeypatch.setenv("DEERFLOW_SANDBOX_TARGET", f"127.0.0.1:{upstream_port}") + relay = await asyncio.start_server(network_proxy.handle_relay, "127.0.0.1", 0) + relay_port = relay.sockets[0].getsockname()[1] + try: + denied_reader, denied_writer = await asyncio.open_connection("127.0.0.1", relay_port) + denied_writer.write(b"GET /v1/sandbox HTTP/1.1\r\nHost: sandbox\r\n\r\n") + await denied_writer.drain() + denied_response = await asyncio.wait_for(denied_reader.read(), timeout=2) + denied_writer.close() + await denied_writer.wait_closed() + + assert b"403 Forbidden" in denied_response + assert not received.done() + + reader, writer = await asyncio.open_connection("127.0.0.1", relay_port) + writer.write(b"GET /v1/sandbox HTTP/1.1\r\nHost: sandbox\r\n" + f"{network_proxy.RELAY_AUTH_HEADER}: test-relay-token\r\n\r\n".encode()) + await writer.drain() + response = await asyncio.wait_for(reader.read(), timeout=2) + writer.close() + await writer.wait_closed() + + assert b"200 OK" in response + assert network_proxy.RELAY_AUTH_HEADER.encode() in await asyncio.wait_for(received, timeout=2) + finally: + relay.close() + upstream.close() + await relay.wait_closed() + await upstream.wait_closed() + + +@pytest.mark.anyio +async def test_sandbox_api_relay_rejects_non_ascii_token(monkeypatch) -> None: + monkeypatch.setenv(network_proxy.RELAY_TOKEN_ENV, "test-relay-token") + relay = await asyncio.start_server(network_proxy.handle_relay, "127.0.0.1", 0) + relay_port = relay.sockets[0].getsockname()[1] + try: + reader, writer = await asyncio.open_connection("127.0.0.1", relay_port) + writer.write(b"GET /v1/sandbox HTTP/1.1\r\nHost: sandbox\r\n" + network_proxy.RELAY_AUTH_HEADER.encode() + b": \xff\r\n\r\n") + await writer.drain() + response = await asyncio.wait_for(reader.read(), timeout=2) + writer.close() + await writer.wait_closed() + + assert b"403 Forbidden" in response + finally: + relay.close() + await relay.wait_closed() + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "malformed_header", + [ + b"Host : denied.example", + b"Transfer-Encoding : chunked", + b"Bad(Header): value", + ], +) +async def test_http_proxy_rejects_ambiguous_field_names_before_policy_check(monkeypatch, malformed_header: bytes) -> None: + policy_checks: list[tuple[str, int]] = [] + + def fake_policy_allows(host: str, port: int) -> bool: + policy_checks.append((host, port)) + return False + + monkeypatch.setattr(network_proxy, "policy_allows", fake_policy_allows) + + proxy = await asyncio.start_server(network_proxy.handle_proxy, "127.0.0.1", 0) + proxy_port = proxy.sockets[0].getsockname()[1] + try: + reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port) + writer.write(b"GET http://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\n" + malformed_header + b"\r\n\r\n") + await writer.drain() + response = await asyncio.wait_for(reader.read(), timeout=2) + writer.close() + await writer.wait_closed() + + assert b"400 Bad Request" in response + assert policy_checks == [] + finally: + proxy.close() + await proxy.wait_closed() diff --git a/backend/tests/test_aio_sandbox_readiness.py b/backend/tests/test_aio_sandbox_readiness.py index 2accb7268..8d0b09223 100644 --- a/backend/tests/test_aio_sandbox_readiness.py +++ b/backend/tests/test_aio_sandbox_readiness.py @@ -75,6 +75,9 @@ def test_wait_for_sandbox_ready_bypasses_environment_proxy_for_docker_host(monke class FakeSession: trust_env = True + def __init__(self) -> None: + self.headers: dict[str, str] = {} + def __enter__(self): sessions.append(self) return self @@ -89,9 +92,18 @@ def test_wait_for_sandbox_ready_bypasses_environment_proxy_for_docker_host(monke monkeypatch.setattr(readiness.requests, "Session", FakeSession) - assert readiness.wait_for_sandbox_ready("http://host.docker.internal:8080", timeout=1) is True + headers = {"X-DeerFlow-Relay-Token": "secret-token"} + assert ( + readiness.wait_for_sandbox_ready( + "http://host.docker.internal:8080", + timeout=1, + headers=headers, + ) + is True + ) assert len(sessions) == 1 assert sessions[0].trust_env is False + assert sessions[0].headers == headers @pytest.mark.anyio @@ -99,8 +111,10 @@ async def test_wait_for_sandbox_ready_async_uses_nonblocking_polling(monkeypatch calls: list[str] = [] sleeps: list[float] = [] clients: list[_FakeAsyncClient] = [] + client_headers: list[dict[str, str]] = [] - def fake_client(*, timeout: float, trust_env: bool): + def fake_client(*, timeout: float, trust_env: bool, headers: dict[str, str]): + client_headers.append(headers) client = _FakeAsyncClient( responses=[SimpleNamespace(status_code=503), SimpleNamespace(status_code=200)], calls=calls, @@ -118,11 +132,21 @@ async def test_wait_for_sandbox_ready_async_uses_nonblocking_polling(monkeypatch monkeypatch.setattr(readiness.requests, "get", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("requests.get should not be used"))) monkeypatch.setattr(readiness.time, "sleep", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("time.sleep should not be used"))) - assert await readiness.wait_for_sandbox_ready_async("http://sandbox", timeout=5, poll_interval=0.05) is True + headers = {"X-DeerFlow-Relay-Token": "secret-token"} + assert ( + await readiness.wait_for_sandbox_ready_async( + "http://sandbox", + timeout=5, + poll_interval=0.05, + headers=headers, + ) + is True + ) assert calls == ["http://sandbox/v1/sandbox", "http://sandbox/v1/sandbox"] assert sleeps == [0.05] assert clients[0].trust_env is False + assert client_headers == [headers] @pytest.mark.anyio diff --git a/backend/tests/test_run_journal.py b/backend/tests/test_run_journal.py index 1b7aa5fa9..eef1279ff 100644 --- a/backend/tests/test_run_journal.py +++ b/backend/tests/test_run_journal.py @@ -1306,7 +1306,8 @@ class TestChatModelStartHumanMessage: assert not any(e["event_type"] == "llm.human.input" for e in events) @pytest.mark.anyio - async def test_hidden_human_input_response_is_captured(self, journal_setup): + @pytest.mark.parametrize("source", ["ask_clarification", "sandbox_network"]) + async def test_hidden_human_input_response_is_captured(self, journal_setup, source): """Hidden HumanInputCard replies are user-authored and must survive compaction.""" from langchain_core.messages import HumanMessage @@ -1315,7 +1316,7 @@ class TestChatModelStartHumanMessage: content='For your clarification "Which environment?", my answer is: staging', additional_kwargs={ "hide_from_ui": True, - "human_input_response": self._human_input_response(), + "human_input_response": self._human_input_response(source=source), }, ) j.on_chat_model_start({}, [[hidden_response]], run_id=uuid4(), tags=["lead_agent"]) diff --git a/backend/tests/test_sandbox_middleware.py b/backend/tests/test_sandbox_middleware.py index 51c6e73a9..c8efe015b 100644 --- a/backend/tests/test_sandbox_middleware.py +++ b/backend/tests/test_sandbox_middleware.py @@ -6,7 +6,7 @@ from typing import get_type_hints import pytest from langchain.agents.middleware import AgentMiddleware from langchain.tools import ToolRuntime -from langchain_core.messages import ToolMessage +from langchain_core.messages import HumanMessage, ToolMessage from langgraph.graph import END from langgraph.prebuilt.tool_node import ToolCallRequest from langgraph.runtime import Runtime @@ -61,6 +61,36 @@ class _AgentSkillSyncProvider(_SyncProvider): self.skill_syncs.append((sandbox_id, thread_id, user_id, projection)) +class _NetworkPolicyProvider(_SyncProvider): + def __init__(self) -> None: + super().__init__() + self.events: list[dict[str, object]] = [] + self.decisions: list[tuple[str, str, str]] = [] + self.consume_calls: list[str] = [] + self.deny_pending_calls: list[str] = [] + + def sandbox_network_mode(self) -> str: + return "allowlist" + + def consume_network_policy_events(self, sandbox_id: str) -> list[dict[str, object]]: + self.consume_calls.append(sandbox_id) + events, self.events = self.events, [] + return events + + def deny_pending_network_policy_events(self, sandbox_id: str) -> bool: + self.deny_pending_calls.append(sandbox_id) + for event in self.events: + request_id = event.get("request_id") + if isinstance(request_id, str): + self.decisions.append((sandbox_id, request_id, "deny")) + self.events = [] + return True + + def decide_network_policy_request(self, sandbox_id: str, request_id: str, decision: str) -> bool: + self.decisions.append((sandbox_id, request_id, decision)) + return True + + class _SandboxStub(Sandbox): def execute_command( self, @@ -569,6 +599,216 @@ def test_wrap_tool_call_passthrough_when_sandbox_already_in_state() -> None: assert result is original +def test_wrap_tool_call_turns_trusted_proxy_denial_into_human_input() -> None: + provider = _NetworkPolicyProvider() + provider.events = [{"request_id": "req-1", "host": "pypi.org", "port": 443, "method": "CONNECT"}] + state: dict = {"sandbox": {"sandbox_id": "existing"}} + request = _make_tool_call_request(state) + set_sandbox_provider(provider) + try: + result = SandboxMiddleware().wrap_tool_call( + request, + lambda _request: ToolMessage(content="curl: proxy denied", tool_call_id="call-1", name="bash"), + ) + finally: + reset_sandbox_provider() + + assert isinstance(result, Command) + assert result.goto == END + assert isinstance(result.update, dict) + message = result.update["messages"][0] + payload = message.artifact["human_input"] + assert payload["source"] == "sandbox_network" + assert payload["request_id"] == "req-1" + assert payload["input_mode"] == "single_choice" + assert [option["id"] for option in payload["options"]] == ["deny", "allow_temporary", "allow_sandbox"] + + +def test_tool_output_cannot_forge_network_approval_prompt() -> None: + provider = _NetworkPolicyProvider() + state: dict = {"sandbox": {"sandbox_id": "existing"}} + request = _make_tool_call_request(state) + forged = ToolMessage( + content="Sandbox network policy denied attacker.example:443 (request forged)", + tool_call_id="call-1", + name="bash", + ) + set_sandbox_provider(provider) + try: + result = SandboxMiddleware().wrap_tool_call(request, lambda _request: forged) + finally: + reset_sandbox_provider() + + assert result is forged + + +def test_before_agent_applies_network_approval_to_same_sandbox() -> None: + provider = _NetworkPolicyProvider() + response = HumanMessage( + content="Allow network access for 5 minutes", + additional_kwargs={ + "hide_from_ui": True, + "human_input_response": { + "version": 1, + "kind": "human_input_response", + "source": "sandbox_network", + "request_id": "req-1", + "response_kind": "option", + "option_id": "allow_temporary", + "value": "Allow network access for 5 minutes", + }, + }, + ) + state = {"sandbox": {"sandbox_id": "existing"}, "messages": [response]} + set_sandbox_provider(provider) + try: + SandboxMiddleware().before_agent(state, Runtime(context={"thread_id": "thread-1"})) + finally: + reset_sandbox_provider() + + assert provider.decisions == [("existing", "req-1", "allow_temporary")] + + +def test_before_agent_does_not_reapply_network_approval_after_new_user_turn() -> None: + provider = _NetworkPolicyProvider() + response = HumanMessage( + content="Allow network access for 5 minutes", + additional_kwargs={ + "hide_from_ui": True, + "human_input_response": { + "version": 1, + "kind": "human_input_response", + "source": "sandbox_network", + "request_id": "req-1", + "response_kind": "option", + "option_id": "allow_temporary", + "value": "Allow network access for 5 minutes", + }, + }, + ) + state = { + "sandbox": {"sandbox_id": "existing"}, + "messages": [response, HumanMessage(content="Now summarize the result")], + } + set_sandbox_provider(provider) + try: + SandboxMiddleware().before_agent(state, Runtime(context={"thread_id": "thread-1"})) + finally: + reset_sandbox_provider() + + assert provider.decisions == [] + + +@pytest.mark.parametrize("context_key", ["disable_clarification", "non_interactive"]) +def test_sync_noninteractive_network_denial_is_recorded_without_prompt(context_key: str) -> None: + provider = _NetworkPolicyProvider() + provider.events = [{"request_id": "req-1", "host": "example.com", "port": 443, "method": "CONNECT"}] + state: dict = {"sandbox": {"sandbox_id": "existing"}} + request = _make_tool_call_request(state) + request.runtime.context[context_key] = True + original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash") + set_sandbox_provider(provider) + try: + result = SandboxMiddleware().wrap_tool_call(request, lambda _request: original) + finally: + reset_sandbox_provider() + + assert result is original + assert provider.decisions == [("existing", "req-1", "deny")] + assert provider.deny_pending_calls == ["existing"] + assert provider.consume_calls == [] + + +@pytest.mark.anyio +@pytest.mark.parametrize("context_key", ["disable_clarification", "non_interactive"]) +async def test_async_noninteractive_network_denial_is_recorded_without_prompt(context_key: str) -> None: + provider = _NetworkPolicyProvider() + provider.events = [{"request_id": "req-1", "host": "example.com", "port": 443, "method": "CONNECT"}] + state: dict = {"sandbox": {"sandbox_id": "existing"}} + request = _make_tool_call_request(state) + request.runtime.context[context_key] = True + original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash") + + async def handler(_request: ToolCallRequest) -> ToolMessage: + return original + + set_sandbox_provider(provider) + try: + result = await SandboxMiddleware().awrap_tool_call(request, handler) + finally: + reset_sandbox_provider() + + assert result is original + assert provider.decisions == [("existing", "req-1", "deny")] + assert provider.deny_pending_calls == ["existing"] + assert provider.consume_calls == [] + + +def test_subagent_network_denial_fails_closed_without_prompt() -> None: + provider = _NetworkPolicyProvider() + provider.events = [{"request_id": "req-1", "host": "example.com", "port": 443, "method": "CONNECT"}] + state: dict = {"sandbox": {"sandbox_id": "existing"}} + request = _make_tool_call_request(state) + request.runtime.context["is_subagent"] = True + original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash") + set_sandbox_provider(provider) + try: + result = SandboxMiddleware().wrap_tool_call(request, lambda _request: original) + finally: + reset_sandbox_provider() + + assert result is original + assert provider.events == [] + assert provider.decisions == [("existing", "req-1", "deny")] + assert provider.deny_pending_calls == ["existing"] + assert provider.consume_calls == [] + + +@pytest.mark.anyio +async def test_async_subagent_network_denial_fails_closed_without_prompt() -> None: + provider = _NetworkPolicyProvider() + provider.events = [{"request_id": "req-1", "host": "example.com", "port": 443, "method": "CONNECT"}] + state: dict = {"sandbox": {"sandbox_id": "existing"}} + request = _make_tool_call_request(state) + request.runtime.context["is_subagent"] = True + original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash") + + async def handler(_request: ToolCallRequest) -> ToolMessage: + return original + + set_sandbox_provider(provider) + try: + result = await SandboxMiddleware().awrap_tool_call(request, handler) + finally: + reset_sandbox_provider() + + assert result is original + assert provider.events == [] + assert provider.decisions == [("existing", "req-1", "deny")] + assert provider.deny_pending_calls == ["existing"] + assert provider.consume_calls == [] + + +def test_noninteractive_network_denial_atomically_drains_more_than_sixteen_hosts() -> None: + provider = _NetworkPolicyProvider() + provider.events = [{"request_id": f"req-{index}", "host": f"host-{index}.example", "port": 443, "method": "CONNECT"} for index in range(17)] + state: dict = {"sandbox": {"sandbox_id": "existing"}} + request = _make_tool_call_request(state) + request.runtime.context["non_interactive"] = True + original = ToolMessage(content="proxy denied", tool_call_id="call-1", name="bash") + set_sandbox_provider(provider) + try: + result = SandboxMiddleware().wrap_tool_call(request, lambda _request: original) + finally: + reset_sandbox_provider() + + assert result is original + assert provider.events == [] + assert len(provider.decisions) == 17 + assert provider.deny_pending_calls == ["existing"] + assert provider.consume_calls == [] + + def test_wrap_tool_call_passthrough_when_handler_did_not_initialize_sandbox() -> None: middleware = SandboxMiddleware() state: dict = {} diff --git a/backend/tests/test_sandbox_network_config.py b/backend/tests/test_sandbox_network_config.py new file mode 100644 index 000000000..fc325fcd1 --- /dev/null +++ b/backend/tests/test_sandbox_network_config.py @@ -0,0 +1,38 @@ +import pytest +from pydantic import ValidationError + +from deerflow.config.sandbox_config import SandboxConfig, SandboxNetworkConfig + + +def test_sandbox_network_defaults_to_open() -> None: + config = SandboxConfig(use="test") + + assert config.network.mode == "open" + assert config.network.allow_domains == [] + assert config.network.approval == "prompt" + + +def test_sandbox_network_normalizes_domains() -> None: + config = SandboxNetworkConfig(allow_domains=["PyPI.org.", "*.PythonHosted.org", "pypi.org"]) + + assert config.allow_domains == ["pypi.org", "*.pythonhosted.org"] + + +@pytest.mark.parametrize( + "domain", + [ + "*", + "https://pypi.org", + "pypi.org:443", + "foo.*.example.com", + "../example.com", + "127.0.0.1", + "localhost", + "foo..example.com", + "-foo.example.com", + "foo_.example.com", + ], +) +def test_sandbox_network_rejects_unsafe_domain_rules(domain: str) -> None: + with pytest.raises(ValidationError, match="invalid sandbox network allowlist domain"): + SandboxNetworkConfig(allow_domains=[domain]) diff --git a/backend/tests/test_sandbox_network_proxy_workflow.py b/backend/tests/test_sandbox_network_proxy_workflow.py new file mode 100644 index 000000000..c36751421 --- /dev/null +++ b/backend/tests/test_sandbox_network_proxy_workflow.py @@ -0,0 +1,31 @@ +from pathlib import Path + +import yaml + +WORKFLOW = Path(__file__).resolve().parents[2] / ".github" / "workflows" / "sandbox-network-proxy-image.yaml" + + +def test_pull_request_proxy_build_has_read_only_permissions(): + workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + validate = workflow["jobs"]["validate"] + build = next(step for step in validate["steps"] if step["name"] == "Build image") + + assert validate["if"] == "github.event_name == 'pull_request'" + assert validate["permissions"] == {"contents": "read"} + assert build["with"]["push"] is False + + +def test_proxy_publish_credentials_are_gated_to_upstream_main_pushes(): + workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + publish = workflow["jobs"]["publish"] + build = next(step for step in publish["steps"] if step["name"] == "Build and publish image") + + assert publish["if"] == "github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'bytedance/deer-flow'" + assert publish["permissions"] == { + "contents": "read", + "packages": "write", + "attestations": "write", + "id-token": "write", + } + assert build["with"]["push"] is True + assert "workflow_dispatch" not in WORKFLOW.read_text(encoding="utf-8") diff --git a/backend/tests/test_sandbox_orphan_reconciliation.py b/backend/tests/test_sandbox_orphan_reconciliation.py index 48292eea8..c9e285662 100644 --- a/backend/tests/test_sandbox_orphan_reconciliation.py +++ b/backend/tests/test_sandbox_orphan_reconciliation.py @@ -14,11 +14,14 @@ import signal import threading import time from datetime import UTC, datetime -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from deerflow.community.aio_sandbox.aio_sandbox_provider import SandboxBeingDestroyedError +from deerflow.community.aio_sandbox.aio_sandbox_provider import ( + SandboxBeingDestroyedError, + SandboxPolicyReplacementDeferredError, +) from deerflow.community.aio_sandbox.ownership import compute_lease_ttl from deerflow.community.aio_sandbox.sandbox_info import SandboxInfo @@ -175,6 +178,7 @@ def test_list_running_includes_containers_without_port(monkeypatch): assert len(infos) == 1 assert infos[0].sandbox_id == "abc12345" assert infos[0].sandbox_url == "" + assert infos[0].requires_replacement is True def test_list_running_handles_docker_failure(monkeypatch): @@ -594,6 +598,146 @@ def test_reconcile_skips_container_owned_by_peer(): assert shared.owner("shared01") == "worker-a" +def test_reconcile_does_not_replace_mismatched_policy_while_peer_owns_container(): + """A rolling upgrade must not delete an older Gateway's live sandbox.""" + shared = _make_shared_ownership_store() + worker_a = _make_provider_for_reconciliation(worker_id="worker-a", store=shared) + worker_b = _make_provider_for_reconciliation(worker_id="worker-b", store=shared) + info = SandboxInfo( + sandbox_id="rolling01", + sandbox_url="http://localhost:8080", + container_name="deer-flow-sandbox-rolling01", + created_at=time.time() - 50, + requires_replacement=True, + ) + worker_a._publish_ownership(info.sandbox_id) + worker_b._backend.list_running.return_value = [info] + + worker_b._reconcile_orphans() + + worker_b._backend.destroy.assert_not_called() + assert info.sandbox_id not in worker_b._warm_pool + assert shared.owner(info.sandbox_id) == "worker-a" + + +def test_reconcile_replaces_mismatched_policy_after_orphan_grace_and_destroy_claim(): + """A stale policy is replaced only after grace plus an exclusive teardown lease.""" + aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider") + shared = _make_shared_ownership_store() + worker = _make_provider_for_reconciliation(worker_id="worker-b", store=shared) + info = SandboxInfo( + sandbox_id="rolling02", + sandbox_url="http://localhost:8080", + container_name="deer-flow-sandbox-rolling02", + created_at=time.time() - 50, + requires_replacement=True, + ) + worker._backend.list_running.return_value = [info] + claims: list[bool] = [] + real_claim = worker._claim_ownership + + def recording_claim(sandbox_id: str, *, for_destroy: bool = False) -> bool: + claims.append(for_destroy) + return real_claim(sandbox_id, for_destroy=for_destroy) + + worker._claim_ownership = recording_claim + now = time.time() + + with patch.object(aio_mod.time, "time", return_value=now): + worker._reconcile_orphans() + + worker._backend.destroy.assert_not_called() + assert claims == [] + + with patch.object( + aio_mod.time, + "time", + return_value=now + compute_lease_ttl(worker._ownership_config) + 1, + ): + worker._reconcile_orphans() + + worker._backend.destroy.assert_called_once_with(info) + assert claims == [True] + assert info.sandbox_id not in worker._warm_pool + + +def test_reconcile_does_not_replace_mismatched_policy_during_local_teardown(): + """The provider's local reservation is the same-process half of the fence.""" + worker = _make_provider_for_reconciliation() + info = SandboxInfo( + sandbox_id="rolling03", + sandbox_url="http://localhost:8080", + container_name="deer-flow-sandbox-rolling03", + requires_replacement=True, + ) + worker._backend.list_running.return_value = [info] + worker._local_teardown.add(info.sandbox_id) + worker._claim_ownership = MagicMock(return_value=True) + + worker._reconcile_orphans() + + worker._claim_ownership.assert_not_called() + worker._backend.destroy.assert_not_called() + assert info.sandbox_id not in worker._warm_pool + + +def test_discover_or_create_defers_mismatched_policy_owned_by_live_peer(tmp_path): + """The request path uses the same fence instead of deleting on discovery.""" + aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider") + shared = _make_shared_ownership_store() + worker_a = _make_provider_for_reconciliation(worker_id="worker-a", store=shared) + worker_b = _make_provider_for_reconciliation(worker_id="worker-b", store=shared) + info = SandboxInfo( + sandbox_id="rolling04", + sandbox_url="", + container_name="deer-flow-sandbox-rolling04", + requires_replacement=True, + ) + worker_a._publish_ownership(info.sandbox_id) + worker_b._backend.discover.return_value = info + worker_b._create_sandbox = MagicMock(return_value=info.sandbox_id) + worker_b._recheck_cached_sandbox = MagicMock(return_value=None) + + paths = MagicMock() + paths.thread_dir.return_value = tmp_path + with patch.object(aio_mod, "get_paths", return_value=paths): + with pytest.raises(SandboxPolicyReplacementDeferredError): + worker_b._discover_or_create_with_lock("thread-rolling", info.sandbox_id, user_id="user-rolling") + + worker_b._backend.destroy.assert_not_called() + worker_b._create_sandbox.assert_not_called() + assert shared.owner(info.sandbox_id) == "worker-a" + + +@pytest.mark.asyncio +async def test_async_discover_or_create_defers_mismatched_policy_owned_by_live_peer(tmp_path): + """The async request path must preserve the same replacement fence.""" + aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider") + shared = _make_shared_ownership_store() + worker_a = _make_provider_for_reconciliation(worker_id="worker-a", store=shared) + worker_b = _make_provider_for_reconciliation(worker_id="worker-b", store=shared) + info = SandboxInfo( + sandbox_id="rolling05", + sandbox_url="", + container_name="deer-flow-sandbox-rolling05", + requires_replacement=True, + ) + worker_a._publish_ownership(info.sandbox_id) + worker_b._backend.discover.return_value = info + worker_b._create_sandbox_async = AsyncMock(return_value=info.sandbox_id) + worker_b._recheck_cached_sandbox = MagicMock(return_value=None) + + paths = MagicMock() + paths.thread_dir.return_value = tmp_path + with patch.object(aio_mod, "get_paths", return_value=paths): + with pytest.raises(SandboxPolicyReplacementDeferredError): + await worker_b._discover_or_create_with_lock_async("thread-rolling", info.sandbox_id, user_id="user-rolling") + + worker_b._backend.destroy.assert_not_called() + worker_b._create_sandbox_async.assert_not_awaited() + assert shared.owner(info.sandbox_id) == "worker-a" + + def test_idle_reap_does_not_destroy_peer_owned_warm_entry(): """#4206: idle reaper must not stop a container another instance owns.""" shared = _make_shared_ownership_store() @@ -715,6 +859,7 @@ def test_acquire_fails_closed_when_ownership_cannot_be_published(): worker = _make_provider_for_reconciliation(worker_id="worker-a") worker._ownership = MagicMock() worker._ownership.take.side_effect = OwnershipBackendError("store down") + worker._ownership.claim.return_value = True info = SandboxInfo( sandbox_id="new001", @@ -726,8 +871,10 @@ def test_acquire_fails_closed_when_ownership_cannot_be_published(): with pytest.raises(OwnershipBackendError): worker._register_created_sandbox("t1", "new001", info, user_id="u1") - # The just-created container must not be leaked as an unowned orphan. + # Registration is fail-closed, and cleanup proceeds only after a separate + # teardown claim succeeds. worker._backend.destroy.assert_called_once_with(info) + worker._ownership.claim.assert_called_once_with("new001", for_destroy=True) assert "new001" not in worker._sandboxes @@ -1119,12 +1266,13 @@ def test_lost_lease_drops_sandbox_without_destroying_container(): def test_ownership_rollback_on_create_closes_the_client_it_drops(): - """The rollback destroys the container; its host-side client must not leak (#2872).""" + """A fenced rollback destroys the container; its host-side client must not leak (#2872).""" from deerflow.community.aio_sandbox.ownership import OwnershipBackendError worker = _make_provider_for_reconciliation(worker_id="worker-a") worker._ownership = MagicMock() worker._ownership.take.side_effect = OwnershipBackendError("store down") + worker._ownership.claim.return_value = True info = SandboxInfo( sandbox_id="new002", sandbox_url="http://localhost:8080", @@ -1145,6 +1293,39 @@ def test_ownership_rollback_on_create_closes_the_client_it_drops(): worker._register_created_sandbox("t1", "new002", info, user_id="u1") worker._backend.destroy.assert_called_once_with(info) + worker._ownership.claim.assert_called_once_with("new002", for_destroy=True) + assert created and created[0].close.call_count == 1 + + +def test_ownership_rollback_does_not_destroy_when_teardown_claim_is_unavailable(): + """Unknown ownership is not permission to remove a possibly peer-created container.""" + from deerflow.community.aio_sandbox.ownership import OwnershipBackendError + + worker = _make_provider_for_reconciliation(worker_id="worker-a") + worker._ownership = MagicMock() + worker._ownership.take.side_effect = OwnershipBackendError("store down") + worker._ownership.claim.side_effect = OwnershipBackendError("store still down") + info = SandboxInfo( + sandbox_id="new003", + sandbox_url="http://localhost:8080", + container_name="deer-flow-sandbox-new003", + created_at=time.time(), + ) + + aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider") + created: list[MagicMock] = [] + + def fake_aio_sandbox(**kwargs): + sandbox = MagicMock() + created.append(sandbox) + return sandbox + + with patch.object(aio_mod, "AioSandbox", side_effect=fake_aio_sandbox): + with pytest.raises(OwnershipBackendError, match="store down"): + worker._register_created_sandbox("t1", "new003", info, user_id="u1") + + worker._backend.destroy.assert_not_called() + worker._ownership.claim.assert_called_once_with("new003", for_destroy=True) assert created and created[0].close.call_count == 1 @@ -1761,14 +1942,15 @@ def test_reclaim_drops_a_container_a_peer_is_destroying(): worker_a._backend.destroy.assert_not_called() -def test_created_sandbox_is_rolled_back_when_a_peer_is_destroying_its_id(): - """Rollback must cover a teardown marker, not just a store outage. +def test_created_sandbox_is_not_rolled_back_when_a_peer_is_destroying_its_id(): + """A failed registration must not bypass a peer's teardown marker. `test_ownership_rollback_on_create_closes_the_client_it_drops` drives this - path with `OwnershipBackendError` only. The comment says the teardown case is - reachable too — a peer that died mid-stop leaves a `del:` marker until its - TTL lapses — and without rollback the container we just started is leaked as - an adoptable orphan. + path with `OwnershipBackendError` only. The teardown case is reachable too: + a peer that died mid-stop leaves a `del:` marker until its TTL lapses. A + direct backend rollback here would ignore that ownership verdict and can + remove resources the peer still owns; leave the untracked container for + ownership-fenced reconciliation after the marker expires instead. """ shared = _make_shared_ownership_store() worker_a = _make_provider_for_reconciliation(worker_id="worker-a", store=shared) @@ -1785,8 +1967,9 @@ def test_created_sandbox_is_rolled_back_when_a_peer_is_destroying_its_id(): with pytest.raises(SandboxBeingDestroyedError): worker_a._register_created_sandbox("t1", "fresh01", info, user_id="u1") - worker_a._backend.destroy.assert_called_once_with(info) - assert "fresh01" not in worker_a._sandboxes, "a container we could not own was handed out anyway" + worker_a._backend.destroy.assert_not_called() + assert "fresh01" not in worker_a._sandboxes + assert shared.owner("fresh01") == "worker-b" def test_shutdown_does_not_stop_a_peers_warm_container(): diff --git a/config.example.yaml b/config.example.yaml index df8fd4401..2ffd807dc 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -20,7 +20,7 @@ # ============================================================================ # Bump this number when the config schema changes. # Run `make config-upgrade` to merge new fields into your local config.yaml. -config_version: 39 +config_version: 40 # ============================================================================ # Logging @@ -1338,7 +1338,8 @@ sandbox: # Option 2: Container-based AIO Sandbox # Executes commands in isolated containers (Docker or Apple Container) -# On macOS: Automatically prefers Apple Container if available, falls back to Docker +# On macOS: Prefers Apple Container when available, but keeps Docker while this +# sandbox prefix still has managed Docker containers to reconcile after a mode change # On other platforms: Uses Docker # Uncomment to use: # sandbox: @@ -1365,6 +1366,23 @@ sandbox: # # Optional: Prefix for container names (default: deer-flow-sandbox) # # container_prefix: deer-flow-sandbox # +# # Optional: outbound network policy for locally managed Docker sandboxes. +# # open keeps the current behavior. isolated denies all outbound traffic. +# # allowlist permits the listed HTTP/HTTPS domains; denied public domains can +# # optionally be approved from the Human Input card. Restricted modes require +# # Docker Engine 28+ and are not supported by Apple Container or provisioner mode. +# # Private, loopback, link-local, multicast, and cloud metadata addresses are always denied. +# network: +# mode: allowlist # open | isolated | allowlist (default: open) +# allow_domains: +# - pypi.org +# - files.pythonhosted.org +# - registry.npmjs.org +# - github.com +# approval: prompt # deny | prompt +# temporary_grant_ttl: 300 # 30-3600 seconds +# proxy_image: ghcr.io/bytedance/deer-flow-sandbox-network-proxy:latest +# # # Optional: Override whether the sandbox already sees the gateway's # # thread workspace/uploads/outputs through shared mounts. # # Omit this field to auto-detect from the backend (local containers: true; diff --git a/deploy/helm/deer-flow/README.md b/deploy/helm/deer-flow/README.md index f85ee9c44..77e91b3fc 100644 --- a/deploy/helm/deer-flow/README.md +++ b/deploy/helm/deer-flow/README.md @@ -131,7 +131,7 @@ they resolve from the `secrets` map): ```yaml config: | - config_version: 39 + config_version: 40 models: - name: gpt-4 use: langchain_openai:ChatOpenAI diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index 446781182..3b214ff10 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -249,7 +249,7 @@ ingress: # -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never # inline literal secret values here. The default enables provisioner sandbox. config: | - config_version: 39 + config_version: 40 log_level: info models: [] diff --git a/docker/sandbox-network-proxy/Dockerfile b/docker/sandbox-network-proxy/Dockerfile new file mode 100644 index 000000000..3ce94f0e5 --- /dev/null +++ b/docker/sandbox-network-proxy/Dockerfile @@ -0,0 +1,11 @@ +ARG BASE_IMAGE=python:3.12-alpine +FROM ${BASE_IMAGE} + +# BASE_IMAGE is overridable for offline smoke tests. Production uses the small +# official Python Alpine image; a cached Alpine-derived base can install Python. +RUN if ! command -v python >/dev/null 2>&1; then apk add --no-cache python3 && ln -sf /usr/bin/python3 /usr/bin/python; fi + +COPY backend/packages/harness/deerflow/community/aio_sandbox/network_proxy.py /opt/deerflow-network-proxy.py + +USER 65532:65532 +CMD ["python", "/opt/deerflow-network-proxy.py", "serve"] diff --git a/frontend/tests/unit/core/messages/human-input.test.ts b/frontend/tests/unit/core/messages/human-input.test.ts index da3e611d8..cd9ae7583 100644 --- a/frontend/tests/unit/core/messages/human-input.test.ts +++ b/frontend/tests/unit/core/messages/human-input.test.ts @@ -45,6 +45,37 @@ test("extractHumanInputRequest reads a valid tool artifact payload", () => { expect(extractHumanInputRequest(message)).toEqual(requestPayload); }); +test("extractHumanInputRequest accepts sandbox network single-choice approvals", () => { + const networkRequest = { + ...requestPayload, + source: "sandbox_network", + request_id: "network-request-1", + title: "Sandbox network access", + input_mode: "single_choice", + options: [ + { id: "deny", label: "Deny", value: "Deny network access" }, + { + id: "allow_temporary", + label: "Allow for 5 minutes", + value: "Allow network access for 5 minutes", + }, + { + id: "allow_sandbox", + label: "Allow for this sandbox", + value: "Allow network access for this sandbox", + }, + ], + }; + const message = { + type: "tool", + name: "bash", + content: "network policy denied pypi.org:443", + artifact: { human_input: networkRequest }, + } as unknown as Message; + + expect(extractHumanInputRequest(message)).toEqual(networkRequest); +}); + test("extractHumanInputRequest rejects malformed artifacts", () => { const message = { type: "tool",