Compare commits

...

1 Commits

Author SHA1 Message Date
Teknium
7fdd44b6d1 feat: add Docker terminal network toggle
Port from qwibitai/nanoclaw#2713: expose Hermes' existing Docker network isolation primitive through terminal config so operators can opt out of container egress.
2026-06-14 17:09:36 -07:00
5 changed files with 94 additions and 0 deletions

1
cli.py
View File

@@ -609,6 +609,7 @@ def load_cli_config() -> Dict[str, Any]:
"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_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",

View File

@@ -1145,6 +1145,7 @@ if _config_path.exists():
"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_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",

View File

@@ -1001,6 +1001,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
@@ -5325,6 +5328,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",

View File

@@ -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()

View File

@@ -1173,6 +1173,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
@@ -1233,6 +1234,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)
@@ -1255,6 +1257,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),
)
@@ -2011,6 +2014,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),
}