diff --git a/cli.py b/cli.py index 510ce792dee..5a52b29f40d 100644 --- a/cli.py +++ b/cli.py @@ -627,6 +627,7 @@ def load_cli_config() -> Dict[str, Any]: "docker_env": "TERMINAL_DOCKER_ENV", "docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS", "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", + "docker_network": "TERMINAL_DOCKER_NETWORK", "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER", diff --git a/gateway/run.py b/gateway/run.py index 656b03cce5a..e1ad23e0c68 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1463,6 +1463,7 @@ if _config_path.exists(): "docker_env": "TERMINAL_DOCKER_ENV", "docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS", "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", + "docker_network": "TERMINAL_DOCKER_NETWORK", "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 9129becd603..081ea070b84 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1165,6 +1165,9 @@ DEFAULT_CONFIG = { # Explicit opt-in: mount the host cwd into /workspace for Docker sessions. # Default off because passing host directories into a sandbox weakens isolation. "docker_mount_cwd_to_workspace": False, + # Opt-in egress lockdown for Docker terminal sessions. When false, + # Docker runs with --network=none so commands cannot reach the network. + "docker_network": True, "docker_extra_args": [], # Extra flags passed verbatim to docker run # Explicit opt-in: run the Docker container as the host user's uid:gid # (via `--user`). When enabled, files written into bind-mounted dirs @@ -6666,6 +6669,7 @@ TERMINAL_CONFIG_ENV_MAP = { "docker_volumes": "TERMINAL_DOCKER_VOLUMES", "docker_env": "TERMINAL_DOCKER_ENV", "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", + "docker_network": "TERMINAL_DOCKER_NETWORK", "docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS", "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", diff --git a/tests/tools/test_docker_network_config.py b/tests/tools/test_docker_network_config.py new file mode 100644 index 00000000000..f2c3c13f2cc --- /dev/null +++ b/tests/tools/test_docker_network_config.py @@ -0,0 +1,84 @@ +"""Regression tests for the Docker terminal network toggle. + +Ported from NanoClaw PR #2713's opt-in egress lockdown idea. Hermes already +has DockerEnvironment(network=False), but the terminal config path did not +expose it, so operators could not request networkless Docker execution from +config.yaml. +""" + +import tools.terminal_tool as terminal_tool +from tools.environments import docker as docker_env + + +def test_terminal_env_config_reads_docker_network_toggle(monkeypatch): + monkeypatch.setenv("TERMINAL_DOCKER_NETWORK", "false") + + config = terminal_tool._get_env_config() + + assert config["docker_network"] is False + + +def test_create_environment_passes_docker_network_toggle(monkeypatch): + captured = {} + sentinel = object() + + def _fake_docker_environment(**kwargs): + captured.update(kwargs) + return sentinel + + monkeypatch.setattr(terminal_tool, "_DockerEnvironment", _fake_docker_environment) + + env = terminal_tool._create_environment( + env_type="docker", + image="python:3.11", + cwd="/workspace", + timeout=60, + container_config={"docker_network": False}, + ) + + assert env is sentinel + assert captured["network"] is False + + +def test_docker_environment_adds_network_none_when_disabled(monkeypatch): + commands = [] + + def fake_run(cmd, *args, **kwargs): + commands.append(cmd) + + class Result: + returncode = 0 + stdout = "fake-container-id\n" if len(cmd) > 1 and cmd[1] == "run" else "" + stderr = "" + + return Result() + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env.subprocess, "run", fake_run) + monkeypatch.setattr(docker_env.DockerEnvironment, "_storage_opt_supported", lambda self: False) + + env = docker_env.DockerEnvironment( + image="python:3.11", + cwd="/workspace", + timeout=60, + task_id="network-none-test", + network=False, + ) + + run_cmd = next(cmd for cmd in commands if len(cmd) > 2 and cmd[1:3] == ["run", "-d"]) + assert "--network=none" in run_cmd + env.cleanup() + + +def test_docker_network_config_is_bridged_everywhere(): + from tests.tools.test_terminal_config_env_sync import ( + _cli_env_map_keys, + _gateway_env_map_keys, + _save_config_env_sync_keys, + _terminal_tool_env_var_names, + ) + + assert "docker_network" in _cli_env_map_keys() + assert "docker_network" in _gateway_env_map_keys() + assert "docker_network" in _save_config_env_sync_keys() + assert "TERMINAL_DOCKER_NETWORK" in _terminal_tool_env_var_names() diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index c5aec7e9523..dc2a72f55d0 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1357,6 +1357,7 @@ def _get_env_config() -> Dict[str, Any]: "docker_volumes": docker_volumes, "docker_env": docker_env, "docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"}, + "docker_network": os.getenv("TERMINAL_DOCKER_NETWORK", "true").lower() in {"true", "1", "yes"}, "docker_extra_args": docker_extra_args, # Cross-process container reuse (issue #20561). The docs claim # "ONE long-lived container shared across sessions" — this toggle @@ -1417,6 +1418,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, docker_forward_env = cc.get("docker_forward_env", []) docker_env = cc.get("docker_env", {}) docker_extra_args = cc.get("docker_extra_args", []) + docker_network = cc.get("docker_network", True) if env_type == "local": return _LocalEnvironment(cwd=cwd, timeout=timeout) @@ -1439,6 +1441,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, forward_env=docker_forward_env, env=docker_env, run_as_host_user=cc.get("docker_run_as_host_user", False), + network=docker_network, extra_args=docker_extra_args, persist_across_processes=cc.get("docker_persist_across_processes", True), ) @@ -2208,6 +2211,7 @@ def terminal_tool( "docker_env": config.get("docker_env", {}), "docker_run_as_host_user": config.get("docker_run_as_host_user", False), "docker_extra_args": config.get("docker_extra_args", []), + "docker_network": config.get("docker_network", True), "docker_persist_across_processes": config.get("docker_persist_across_processes", True), "docker_orphan_reaper": config.get("docker_orphan_reaper", True), }