Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,7 @@ def _reload_runtime_env_preserving_config_authority() -> None:
"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",
Expand Down
4 changes: 4 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,9 @@ def _ensure_hermes_home_managed(home: Path):
# 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
Expand Down Expand Up @@ -5325,6 +5328,7 @@ def load_config_readonly() -> 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_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",
Expand Down
84 changes: 84 additions & 0 deletions tests/tools/test_docker_network_config.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 4 additions & 0 deletions tools/terminal_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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),
)
Expand Down Expand Up @@ -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),
}
Expand Down
Loading