This commit is contained in:
Matt Van Horn 2026-11-05 11:20:55 -08:00 • committed by GitHub
commit ebb7f4151d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 451 additions and 10 deletions

View file

@ -333,4 +333,5 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
USER pentester
WORKDIR /workspace
LABEL strix.sandbox.caido-loopback="true"
ENTRYPOINT ["docker-entrypoint.sh"]

View file

@ -38,7 +38,12 @@ if [ -n "${STRIX_CAIDO_ALLOWED_DOMAINS:-}" ]; then
done
fi
caido-cli --listen 0.0.0.0:${CAIDO_PORT} \
# Bridge mode publishes this port on the host loopback. Host networking sets
# STRIX_CAIDO_LISTEN_ADDRESS=127.0.0.1 so --allow-guests is not reachable on
# every host interface. Unset keeps the bridge listener.
CAIDO_LISTEN_ADDRESS="${STRIX_CAIDO_LISTEN_ADDRESS:-0.0.0.0}"
caido-cli --listen "${CAIDO_LISTEN_ADDRESS}:${CAIDO_PORT}" \
--allow-guests \
--no-logging \
--no-open \

View file

@ -198,6 +198,27 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
Runtime backend for the sandbox environment.
</ParamField>
<ParamField path="STRIX_DOCKER_SANDBOX_NETWORK" type="string">
Docker network for the sandbox. Leave unset to use the default bridge and publish the Caido port on `127.0.0.1`. Set to `host` when the target is reachable only through a VPN route on a Linux host running a local Docker engine. Any other value is the name of a Docker network to attach.
</ParamField>
<ParamField path="STRIX_DOCKER_EXTRA_HOSTS" type="string">
Extra sandbox hosts entries, as a JSON object of hostname to a literal IPv4 or IPv6 address (for example `{"app.htb":"10.10.11.42","v6.htb":"fd00::20"}`). Entries merge with the reserved `host.docker.internal` → `host-gateway` mapping. Empty hostnames, values that are not IP addresses, and replacing `host.docker.internal` are rejected. The host `/etc/hosts` file is not inherited.
</ParamField>
The sandbox does not copy `/etc/hosts` from the machine running Docker. On a Linux host with a local Docker engine and the VPN already connected, map the lab hostname explicitly:
```bash
export STRIX_DOCKER_SANDBOX_NETWORK=host
export STRIX_DOCKER_EXTRA_HOSTS='{"app.htb":"10.10.11.42"}'
```
`host` shares the Docker engine's network namespace. Use it with a Docker engine on the same Linux machine as Strix. Docker Desktop runs the engine in a virtual machine, so this does not follow a VPN connected on macOS or Windows. A remote daemon (`DOCKER_HOST`) applies host networking on that other machine, while Strix still resolves Caido at `127.0.0.1` on the machine running the CLI.
Host mode binds Caido to the fixed port `48080`, so only one host-mode scan can run on a daemon at a time. The default bridge network publishes that container port to a free localhost port for each scan.
Host mode requires image label `strix.sandbox.caido-loopback=true`. That label marks an entrypoint that honors `STRIX_CAIDO_LISTEN_ADDRESS` and binds Caido to loopback. A cached older image is rejected instead of listening on every host interface.
## Sandbox Configuration
<ParamField path="STRIX_SANDBOX_EXECUTION_TIMEOUT" default="120" type="integer">

View file

@ -3,7 +3,7 @@ NET_ADMIN/NET_RAW capabilities + host-gateway.
The SDK's ``DockerSandboxClient._create_container`` does not expose a hook for
extending ``create_kwargs`` before ``containers.create`` is called. We subclass
and reimplement the method body verbatim from the SDK source, with three
and reimplement the method body verbatim from the SDK source, with five
deltas:
1. Drop the SDK's ``entrypoint=["tail"]`` override; supply ``["tail", "-f",
@ -15,6 +15,12 @@ deltas:
other raw-socket tools).
3. Add ``host.docker.internal`` → host-gateway to ``extra_hosts`` so the
agent can reach host-served apps.
4. Honor ``STRIX_DOCKER_SANDBOX_NETWORK``. ``host`` omits published ports,
tells the entrypoint to bind Caido to loopback, and resolves exposed
ports on the daemon loopback. Any other name attaches that network and
resolves the container IP. Unset keeps the SDK published-port path.
5. Merge ``STRIX_DOCKER_EXTRA_HOSTS`` (a JSON hostname-to-IP object) into
``extra_hosts``. ``host.docker.internal`` stays reserved for host-gateway.
Pinned to ``openai-agents==0.14.6``. Bumping the SDK requires
re-merging the parent body. Track upstream for an injection hook.
@ -23,6 +29,8 @@ re-merging the parent body. Track upstream for an injection hook.
from __future__ import annotations
import contextlib
import ipaddress
import json
import logging
import os
import uuid
@ -52,6 +60,14 @@ logger = logging.getLogger(__name__)
_SANDBOX_NETWORK_ENV = "STRIX_DOCKER_SANDBOX_NETWORK"
_EXTRA_HOSTS_ENV = "STRIX_DOCKER_EXTRA_HOSTS"
_HOST_GATEWAY_HOSTNAME = "host.docker.internal"
_HOST_NETWORK_NAME = "host"
_DAEMON_LOOPBACK = "127.0.0.1"
# Read by containers/docker-entrypoint.sh. Set only for host networking so the
# unauthenticated Caido listener stays on loopback instead of 0.0.0.0.
_CAIDO_LISTEN_ADDRESS_ENV = "STRIX_CAIDO_LISTEN_ADDRESS"
_CAIDO_LOOPBACK_LABEL = "strix.sandbox.caido-loopback"
def _sandbox_network() -> str | None:
@ -59,11 +75,114 @@ def _sandbox_network() -> str | None:
return value or None
def _apply_sandbox_network(create_kwargs: dict[str, Any]) -> None:
def _apply_host_network_listener(create_kwargs: dict[str, Any]) -> None:
"""Point the entrypoint at loopback when the sandbox uses host networking.
``docker-entrypoint.sh`` otherwise runs ``caido-cli --listen 0.0.0.0:48080``
with guest access enabled. Host networking would expose that proxy on every
host interface. Named and bridge networks keep the bridge listener.
"""
raw_environment = create_kwargs.get("environment")
if isinstance(raw_environment, dict):
environment: dict[str, Any] = dict(cast("dict[str, Any]", raw_environment))
else:
environment = {}
environment[_CAIDO_LISTEN_ADDRESS_ENV] = _DAEMON_LOOPBACK
create_kwargs["environment"] = environment
def _require_host_network_image(docker_client: Any, image: str) -> None:
"""Reject host mode when a cached image may still bind Caido on 0.0.0.0."""
try:
attrs = docker_client.images.get(image).attrs
except docker_errors.ImageNotFound:
attrs = None
config = attrs.get("Config") if isinstance(attrs, dict) else None
labels = config.get("Labels") if isinstance(config, dict) else None
if isinstance(labels, dict) and labels.get(_CAIDO_LOOPBACK_LABEL) == "true":
return
raise ValueError(
f"{_SANDBOX_NETWORK_ENV}=host requires label {_CAIDO_LOOPBACK_LABEL}=true "
f"so {_CAIDO_LISTEN_ADDRESS_ENV} is honored. Cached older images bind "
"Caido to 0.0.0.0 with guest access."
)
def _apply_sandbox_network(create_kwargs: dict[str, Any], docker_client: Any, image: str) -> None:
network = _sandbox_network()
if network:
create_kwargs["network"] = network
create_kwargs.pop("ports", None)
if not network:
return
if network == _HOST_NETWORK_NAME:
_require_host_network_image(docker_client, image)
create_kwargs["network"] = network
create_kwargs.pop("ports", None)
if network == _HOST_NETWORK_NAME:
_apply_host_network_listener(create_kwargs)
def _extra_host_mappings() -> dict[str, str]:
"""Hostname → literal IP entries from ``STRIX_DOCKER_EXTRA_HOSTS``.
Unset or blank leaves the built-in host-gateway mapping untouched. A
malformed value raises before the container is created.
"""
raw = os.environ.get(_EXTRA_HOSTS_ENV, "").strip()
if not raw:
return {}
try:
parsed: object = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(
f"{_EXTRA_HOSTS_ENV} must be a JSON object mapping hostnames to "
f"IP addresses ({exc.msg})"
) from exc
if not isinstance(parsed, dict):
got = "array" if isinstance(parsed, list) else type(parsed).__name__
raise TypeError(
f"{_EXTRA_HOSTS_ENV} must be a JSON object mapping hostnames to IP addresses, got {got}"
)
mappings: dict[str, str] = {}
for name, address in cast("dict[object, object]", parsed).items():
hostname = _extra_hostname(name)
mappings[hostname] = _extra_host_address(hostname, address)
return mappings
def _extra_hostname(name: object) -> str:
if not isinstance(name, str):
raise TypeError(f"{_EXTRA_HOSTS_ENV} hostnames must be strings")
hostname = name.strip()
if not hostname:
raise ValueError(f"{_EXTRA_HOSTS_ENV} contains an empty hostname")
if hostname.lower() == _HOST_GATEWAY_HOSTNAME:
raise ValueError(
f"{_EXTRA_HOSTS_ENV} cannot replace the reserved {_HOST_GATEWAY_HOSTNAME} mapping"
)
return hostname
def _extra_host_address(hostname: str, address: object) -> str:
if not isinstance(address, str):
raise TypeError(
f"{_EXTRA_HOSTS_ENV} value for {hostname!r} must be an IPv4 or IPv6 address string"
)
candidate = address.strip()
try:
ipaddress.ip_address(candidate)
except ValueError as exc:
raise ValueError(
f"{_EXTRA_HOSTS_ENV} value for {hostname!r} is not a literal IPv4 or IPv6 address"
) from exc
return candidate
def _apply_extra_hosts(create_kwargs: dict[str, Any]) -> None:
extra_hosts = create_kwargs.setdefault("extra_hosts", {})
extra_hosts[_HOST_GATEWAY_HOSTNAME] = "host-gateway"
extra_hosts.update(_extra_host_mappings())
_SANDBOX_DEVICES_ENV = "STRIX_DOCKER_SANDBOX_DEVICES"
@ -169,6 +288,11 @@ class StrixDockerSandboxSession(DockerSandboxSession):
cause=e,
) from e
# Host mode has no container IP. The entrypoint binds Caido to the
# daemon loopback on the requested port.
if self.sandbox_network == _HOST_NETWORK_NAME:
return ExposedPortEndpoint(host=_DAEMON_LOOPBACK, port=port, tls=False)
attrs = getattr(self._container, "attrs", {}) or {}
networks = attrs.get("NetworkSettings", {}).get("Networks", {})
endpoint = networks.get(self.sandbox_network) or {}
@ -256,10 +380,8 @@ class StrixDockerSandboxClient(DockerSandboxClient):
if cap not in cap_add:
cap_add.append(cap)
extra_hosts = create_kwargs.setdefault("extra_hosts", {})
extra_hosts["host.docker.internal"] = "host-gateway"
_apply_sandbox_network(create_kwargs)
_apply_extra_hosts(create_kwargs)
_apply_sandbox_network(create_kwargs, self.docker_client, image)
_apply_sandbox_devices(create_kwargs)
_apply_resource_limits(create_kwargs)
_apply_log_limits(create_kwargs)

View file

@ -0,0 +1,292 @@
"""Sandbox network override and explicit hostname mappings.
Host networking cannot use the SDK's published-port resolver: Docker reports
no container IP, and published ports are omitted. ``STRIX_DOCKER_EXTRA_HOSTS``
adds target names the sandbox does not inherit from the host hosts file.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import pytest
from agents.sandbox.errors import ExposedPortUnavailableError
from agents.sandbox.manifest import Environment, Manifest
from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions, DockerSandboxSession
from agents.sandbox.types import ExposedPortEndpoint
from docker import errors as docker_errors
from strix.runtime.docker_client import StrixDockerSandboxClient, StrixDockerSandboxSession
_IMAGE = "strix-sandbox:test"
_CAIDO_PORT = 48080
_GATEWAY = {"host.docker.internal": "host-gateway"}
_CAIDO_LOOPBACK_LABEL = "strix.sandbox.caido-loopback"
def _clear_network_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("STRIX_DOCKER_SANDBOX_NETWORK", raising=False)
monkeypatch.delenv("STRIX_DOCKER_EXTRA_HOSTS", raising=False)
def _client(
*, caido_loopback: bool = False
) -> tuple[StrixDockerSandboxClient, MagicMock, MagicMock]:
docker = MagicMock()
container = MagicMock()
container.id = "container-1"
container.short_id = "container-1"
docker.containers.create.return_value = container
if caido_loopback:
image = MagicMock()
image.attrs = {"Config": {"Labels": {_CAIDO_LOOPBACK_LABEL: "true"}}}
docker.images.get.return_value = image
return StrixDockerSandboxClient(docker), docker, container
def _created_kwargs(docker: MagicMock) -> dict[str, Any]:
kwargs: dict[str, Any] = docker.containers.create.call_args.kwargs
return kwargs
async def _create_session(
monkeypatch: pytest.MonkeyPatch,
network: str | None,
*,
manifest: Manifest | None = None,
) -> tuple[Any, MagicMock, MagicMock]:
_clear_network_env(monkeypatch)
if network is not None:
monkeypatch.setenv("STRIX_DOCKER_SANDBOX_NETWORK", network)
client, docker, container = _client(caido_loopback=network == "host")
session = await client.create(
options=DockerSandboxClientOptions(_IMAGE, (_CAIDO_PORT,)),
manifest=manifest,
)
return session, docker, container
@pytest.mark.asyncio
async def test_default_network_publishes_localhost_and_keeps_entrypoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_network_env(monkeypatch)
client, docker, _container = _client()
await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,))
kwargs = _created_kwargs(docker)
docker.images.get.assert_called_with(_IMAGE)
docker.images.pull.assert_not_called()
assert kwargs["ports"] == {f"{_CAIDO_PORT}/tcp": ("127.0.0.1", None)}
assert kwargs["command"] == ["tail", "-f", "/dev/null"]
assert "entrypoint" not in kwargs
assert kwargs["cap_add"] == ["NET_ADMIN", "NET_RAW"]
assert kwargs["extra_hosts"] == _GATEWAY
assert "network" not in kwargs
assert kwargs["environment"] is None
@pytest.mark.asyncio
async def test_host_network_resolves_loopback_without_container_ip(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manifest = Manifest(environment=Environment(value={"SCAN_TARGET": "app.htb"}))
session, docker, container = await _create_session(monkeypatch, "host", manifest=manifest)
kwargs = _created_kwargs(docker)
assert "ports" not in kwargs
assert kwargs["network"] == "host"
assert kwargs["environment"] == {
"SCAN_TARGET": "app.htb",
"STRIX_CAIDO_LISTEN_ADDRESS": "127.0.0.1",
}
assert kwargs["extra_hosts"] == _GATEWAY
assert type(session._inner) is StrixDockerSandboxSession
container.attrs = {
"NetworkSettings": {
"Networks": {"host": {"IPAddress": "", "GlobalIPv6Address": ""}},
}
}
endpoint = await session.resolve_exposed_port(_CAIDO_PORT)
assert endpoint == ExposedPortEndpoint(host="127.0.0.1", port=_CAIDO_PORT, tls=False)
container.reload.assert_called()
container.reload.side_effect = docker_errors.APIError("reload failed")
with pytest.raises(ExposedPortUnavailableError) as exc_info:
await session.resolve_exposed_port(_CAIDO_PORT)
assert exc_info.value.context["detail"] == "container_reload_failed"
@pytest.mark.asyncio
async def test_host_network_rejects_image_without_caido_loopback_label(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_network_env(monkeypatch)
monkeypatch.setenv("STRIX_DOCKER_SANDBOX_NETWORK", "host")
client, docker, _container = _client()
docker.images.get.return_value.attrs = {"Config": {"Labels": None}}
with pytest.raises(ValueError, match="caido-loopback"):
await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,))
docker.containers.create.assert_not_called()
@pytest.mark.asyncio
async def test_named_network_resolves_ipv4_and_ipv6(monkeypatch: pytest.MonkeyPatch) -> None:
session, docker, container = await _create_session(monkeypatch, "lab")
kwargs = _created_kwargs(docker)
assert kwargs["network"] == "lab"
assert "ports" not in kwargs
assert kwargs["environment"] == {}
assert type(session._inner) is StrixDockerSandboxSession
assert session._inner.sandbox_network == "lab"
container.attrs = {
"NetworkSettings": {"Networks": {"lab": {"IPAddress": "172.18.0.8"}}},
}
assert await session.resolve_exposed_port(_CAIDO_PORT) == ExposedPortEndpoint(
host="172.18.0.8", port=_CAIDO_PORT, tls=False
)
container.attrs = {
"NetworkSettings": {
"Networks": {"lab": {"IPAddress": "", "GlobalIPv6Address": "fd00::8"}},
}
}
assert await session.resolve_exposed_port(_CAIDO_PORT) == ExposedPortEndpoint(
host="[fd00::8]", port=_CAIDO_PORT, tls=False
)
@pytest.mark.asyncio
async def test_named_network_missing_address_and_reload_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session, _docker, container = await _create_session(monkeypatch, "lab")
container.attrs = {"NetworkSettings": {"Networks": {"lab": {"IPAddress": ""}}}}
with pytest.raises(ExposedPortUnavailableError) as missing:
await session.resolve_exposed_port(_CAIDO_PORT)
assert missing.value.context["detail"] == "container_not_on_network"
container.reload.side_effect = docker_errors.APIError("reload failed")
with pytest.raises(ExposedPortUnavailableError) as failed:
await session.resolve_exposed_port(_CAIDO_PORT)
assert failed.value.context["detail"] == "container_reload_failed"
@pytest.mark.parametrize("network", [None, "", " "])
@pytest.mark.asyncio
async def test_unset_network_keeps_sdk_published_port_resolver(
monkeypatch: pytest.MonkeyPatch,
network: str | None,
) -> None:
session, docker, container = await _create_session(monkeypatch, network)
kwargs = _created_kwargs(docker)
assert "network" not in kwargs
assert kwargs["ports"] == {f"{_CAIDO_PORT}/tcp": ("127.0.0.1", None)}
assert type(session._inner) is DockerSandboxSession
container.attrs = {
"NetworkSettings": {
"Ports": {f"{_CAIDO_PORT}/tcp": [{"HostIp": "127.0.0.1", "HostPort": "35123"}]},
"Networks": {},
}
}
endpoint = await session.resolve_exposed_port(_CAIDO_PORT)
assert endpoint == ExposedPortEndpoint(host="127.0.0.1", port=35123, tls=False)
@pytest.mark.parametrize("network", [None, "host"])
@pytest.mark.parametrize(
("raw", "expected"),
[
('{"app.htb":"10.10.11.42"}', {"app.htb": "10.10.11.42"}),
(
'{"app.htb":"10.10.11.42","db.htb":"10.10.11.7","v6.htb":"fd00::10"}',
{"app.htb": "10.10.11.42", "db.htb": "10.10.11.7", "v6.htb": "fd00::10"},
),
],
)
@pytest.mark.asyncio
async def test_extra_hosts_merge_with_gateway(
monkeypatch: pytest.MonkeyPatch,
network: str | None,
raw: str,
expected: dict[str, str],
) -> None:
_clear_network_env(monkeypatch)
if network is not None:
monkeypatch.setenv("STRIX_DOCKER_SANDBOX_NETWORK", network)
monkeypatch.setenv("STRIX_DOCKER_EXTRA_HOSTS", raw)
client, docker, _container = _client(caido_loopback=network == "host")
await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,))
assert _created_kwargs(docker)["extra_hosts"] == {**_GATEWAY, **expected}
@pytest.mark.parametrize("raw", [None, "", " "])
@pytest.mark.asyncio
async def test_blank_extra_hosts_keep_gateway_mapping(
monkeypatch: pytest.MonkeyPatch,
raw: str | None,
) -> None:
_clear_network_env(monkeypatch)
if raw is not None:
monkeypatch.setenv("STRIX_DOCKER_EXTRA_HOSTS", raw)
client, docker, _container = _client()
await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,))
assert _created_kwargs(docker)["extra_hosts"] == _GATEWAY
@pytest.mark.parametrize(
("raw", "exc_type", "match"),
[
("{", ValueError, r"STRIX_DOCKER_EXTRA_HOSTS must be a JSON object"),
('["app.htb"]', TypeError, r"got array"),
(
'{"app.htb": 1}',
TypeError,
r"value for 'app\.htb' must be an IPv4 or IPv6 address string",
),
(
'{"app.htb": "not-an-ip"}',
ValueError,
r"value for 'app\.htb' is not a literal IPv4 or IPv6 address",
),
('{"": "10.0.0.1"}', ValueError, r"empty hostname"),
('{" ": "10.0.0.1"}', ValueError, r"empty hostname"),
(
'{"host.docker.internal": "10.0.0.1"}',
ValueError,
r"cannot replace the reserved host\.docker\.internal mapping",
),
(
'{"HOST.Docker.Internal": "10.0.0.1"}',
ValueError,
r"cannot replace the reserved host\.docker\.internal mapping",
),
],
)
@pytest.mark.asyncio
async def test_extra_hosts_rejected_before_container_create(
monkeypatch: pytest.MonkeyPatch,
raw: str,
exc_type: type[Exception],
match: str,
) -> None:
_clear_network_env(monkeypatch)
monkeypatch.setenv("STRIX_DOCKER_EXTRA_HOSTS", raw)
client, docker, _container = _client()
with pytest.raises(exc_type, match=match):
await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,))
docker.containers.create.assert_not_called()