Add docker network config for terminal sessions
NousResearch/hermes-agent · cluster 4921 · environments
Data as of
Nothing open on GitHub. Fix merged in #59149. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #59149. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and PRs concern adding a terminal.docker_network configuration key to the Docker backend so that network settings (such as --network=none) can be passed through to terminal sessions. The work spans feature requests, identified gaps between existing capability and missing config, and implementation efforts.
Open issues0
Open PRs0
Fixes on record
-
#59149 feat(docker): terminal network toggle with full-path coverage and reuse guard
files:
cli.py,gateway/run.py,hermes_cli/config.py,tests/tools/test_docker_network_config.py,tools/code_execution_tool.py,tools/environments/docker.py,tools/file_tools.py,tools/terminal_tool.py,website/docs/user-guide/configuration.mdShow the diff
-
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", -
gateway/run.py@@ -1463,6 +1463,7 @@ def _profile_runtime_scope(profile_home: "Path"): "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", -
hermes_cli/config.py@@ -1163,6 +1163,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 @@ -6639,6 +6642,7 @@ def write_platform_config_field( "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", -
tests/tools/test_docker_network_config.py@@ -0,0 +1,179 @@ +"""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() + + +def test_sibling_container_config_sites_carry_docker_network(): + """Every container_config dict that carries docker_run_as_host_user must + also carry docker_network — otherwise that code path silently falls back + to networked containers while the terminal path honors the lockdown + (the probe/exec asymmetry reported on issue #46358). + """ + import ast + import inspect + + import tools.code_execution_tool as code_execution_tool + import tools.file_tools as file_tools + + for module in (terminal_tool, file_tools, code_execution_tool): + tree = ast.parse(inspect.getsource(module)) + sites = 0 + for node in ast.walk(tree): + if not isinstance(node, ast.Dict): + continue + keys = {k.value for k in node.keys if isinstance(k, ast.Constant)} + if "docker_run_as_host_user" in keys: + sites += 1 + assert "docker_network" in keys, ( + f"{module.__name__} builds a container_config with " + f"docker_run_as_host_user but without docker_network " + f"(line {node.lineno})" + ) + assert sites >= 1, f"expected at least one container_config site in {module.__name__}" + + +def _reuse_guard_harness(monkeypatch, *, existing_mode: str, network: bool): + """Drive DockerEnvironment through the cross-process reuse path with a + fake existing container whose NetworkMode is *existing_mode*. + + Returns the list of docker commands issued. + """ + commands = [] + + def fake_run(cmd, *args, **kwargs): + commands.append(cmd) + + class Result: + returncode = 0 + stderr = "" + stdout = "" + + if len(cmd) > 1 and cmd[1] == "ps": + Result.stdout = "existing-container-id\trunning\n" + elif len(cmd) > 1 and cmd[1] == "inspect": + Result.stdout = f"{existing_mode}\n" + elif len(cmd) > 1 and cmd[1] == "run": + Result.stdout = "fresh-container-id\n" + 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) + + docker_env.DockerEnvironment( + image="python:3.11", + cwd="/workspace", + timeout=60, + task_id="reuse-guard-test", + network=network, + persist_across_processes=True, + ) + return commands + + +def test_reuse_rejects_networked_container_when_lockdown_requested(monkeypatch): + commands = _reuse_guard_harness(monkeypatch, existing_mode="bridge", network=False) + + assert any(cmd[1:3] == ["rm", "-f"] for cmd in commands), ( + "bridge-networked container must be removed when docker_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 + + +def test_reuse_keeps_airgapped_container_when_lockdown_requested(monkeypatch): + commands = _reuse_guard_harness(monkeypatch, existing_mode="none", network=False) + + assert not any(cmd[1] == "rm" for cmd in commands) + assert not any(cmd[1] == "run" for cmd in commands), "matching container must be reused" + + +def test_reuse_skips_inspect_when_network_enabled(monkeypatch): + commands = _reuse_guard_harness(monkeypatch, existing_mode="none", network=True) + + # Default-network config never churns containers, even air-gapped ones + # (operators may have created them via docker_extra_args). + assert not any(cmd[1] == "inspect" for cmd in commands) + assert not any(cmd[1] == "rm" for cmd in commands) + assert not any(cmd[1] == "run" for cmd in commands) -
tools/code_execution_tool.py@@ -684,6 +684,7 @@ def _get_or_create_env(task_id: str): "container_persistent": config.get("container_persistent", True), "docker_volumes": config.get("docker_volumes", []), "docker_run_as_host_user": config.get("docker_run_as_host_user", False), + "docker_network": config.get("docker_network", True), } ssh_config = None -
tools/environments/docker.py@@ -897,6 +897,45 @@ def __init__( reused = False if persist_across_processes: existing = self._find_reusable_container(task_label, profile_name) + if existing is not None: + container_id, state = existing + # Network-mode guard: reuse must not silently defeat an + # egress lockdown. A container created before the operator + # set ``docker_network: false`` keeps its original bridge + # NetworkMode, so label-only reuse would hand the agent a + # networked container despite the config. On mismatch we + # remove the stale container and start fresh — leaving it in + # place would let the next label-based reuse pick it up again. + # Only the lockdown direction is guarded: a ``none``-mode + # container under a default-network config is left alone so + # operators using ``docker_extra_args: ["--network=none"]`` + # don't get their container churned on every startup. + mode_mismatch = False + actual_mode = None + if not network: + actual_mode = self._container_network_mode(container_id) + mode_mismatch = actual_mode != "none" + if mode_mismatch: + logger.warning( + "Existing container %s has NetworkMode=%s but " + "docker_network=false requests an air-gapped " + "container — removing it and starting fresh " + "(task=%s, profile=%s).", + container_id[:12], actual_mode or "unknown", + task_label, profile_name, + ) + try: + subprocess.run( + [self._docker_exe, "rm", "-f", container_id], + capture_output=True, + text=True, + timeout=30, + check=False, + stdin=subprocess.DEVNULL, + ) + except (subprocess.TimeoutExpired, OSError) as e: + logger.warning("Failed to remove mismatched container %s: %s", container_id[:12], e) + existing = None if existing is not None: container_id, state = existing self._container_id = container_id @@ -1194,6 +1233,40 @@ def _storage_opt_supported() -> bool: logger.debug("Docker --storage-opt support: %s", _storage_opt_ok) return _storage_opt_ok + def _container_network_mode(self, container_id: str) -> Optional[str]: + """Return the container's ``HostConfig.NetworkMode`` (e.g. ``bridge``, + ``none``, ``host``), or ``None`` when inspection fails. + + Used by the reuse path to make sure a persisted container's network + mode still matches the operator's ``docker_network`` setting; callers + treat ``None`` (unknown) as a mismatch when lockdown was requested, + so a failed inspect fails closed rather than open. + """ + try: + result = subprocess.run( + [ + self._docker_exe, "inspect", + "--format", "{{.HostConfig.NetworkMode}}", + container_id, + ], + capture_output=True, + text=True, + timeout=10, + check=False, + stdin=subprocess.DEVNULL, + ) + except (subprocess.TimeoutExpired, OSError) as e: + logger.debug("docker inspect NetworkMode failed: %s", e) + return None + if result.returncode != 0: + logger.debug( + "docker inspect NetworkMode returned %d: %s", + result.returncode, result.stderr.strip(), + ) + return None + mode = result.stdout.strip() + return mode or None + def _find_reusable_container(self, task_label: str, profile_label: str) -> Optional[tuple[str, str]]: """Look for an existing container labeled for this (task, profile). -
tools/file_tools.py@@ -1073,6 +1073,7 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: "docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False), "docker_forward_env": config.get("docker_forward_env", []), "docker_run_as_host_user": config.get("docker_run_as_host_user", False), + "docker_network": config.get("docker_network", True), } ssh_config = None -
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), } -
website/docs/user-guide/configuration.md@@ -219,6 +219,7 @@ terminal: docker_extra_args: # Extra flags appended verbatim to `docker run` - "--gpus=all" - "--network=host" + docker_network: true # false = air-gap the container (--network=none) # Resource limits container_cpu: 1 # CPU cores (0 = unlimited) @@ -240,6 +241,8 @@ terminal: **`terminal.docker_extra_args`** (also overridable via `TERMINAL_DOCKER_EXTRA_ARGS='["--gpus=all"]'`) lets you pass arbitrary `docker run` flags that Hermes doesn't surface as first-class keys — `--gpus`, `--network`, `--add-host`, alternative `--security-opt` overrides, etc. Each entry must be a string; the list is appended last to the assembled `docker run` invocation so it can override Hermes' defaults if needed. Use sparingly — flags that conflict with the sandbox hardening (capability drops, `--user`, the workspace bind mount) will silently weaken isolation. +**`terminal.docker_network`** (default `true`; env: `TERMINAL_DOCKER_NETWORK`) — set to `false` to run the sandbox container with `--network=none`, cutting off all network egress from agent commands. This applies to the execution container used by `terminal`, `execute_code`, and the file tools. Because containers persist across Hermes processes, flipping this to `false` while an older networked container exists will remove that container and start a fresh air-gapped one (a warning is logged); background processes running inside it are lost. Prefer this key over passing `--network=none` through `docker_extra_args`. + **Requirements:** Docker Desktop or Docker Engine installed and running. Hermes probes `$PATH` plus common macOS install locations (`/usr/local/bin/docker`, `/opt/homebrew/bin/docker`, Docker Desktop app bundle). Podman is supported out of the box: set `HERMES_DOCKER_BINARY=podman` (or the full path) to force it when both are installed. #### Container lifecycle @@ -291,6 +294,7 @@ Every key under `terminal:` has an env-var override of the form `TERMINAL_<KEY_U | `TERMINAL_DOCKER_EXTRA_ARGS` | `docker_extra_args` | JSON array | | `TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE` | `docker_mount_cwd_to_workspace` | `true` / `false` | | `TERMINAL_DOCKER_RUN_AS_HOST_USER` | `docker_run_as_host_user` | `true` / `false` | +| `TERMINAL_DOCKER_NETWORK` | `docker_network` | `true` / `false` — default `true`; `false` = `--network=none` | | `TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES` | `docker_persist_across_processes` | `true` / `false` — default `true` | | `TERMINAL_DOCKER_ORPHAN_REAPER` | `docker_orphan_reaper` | `true` / `false` — default `true` | | `TERMINAL_CONTAINER_CPU` | `container_cpu` | CPU cores |
-
1 PR closed without merging
From the close comments: done elsewhere 1
-
#4922 feat(terminal): wire docker_network through docker backend · done elsewhere →
#59149,#46358
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.31: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- environments
- Merged PRs
- #59149
- Closed issues
- 3
- Attempts
- 1 PR closed without merging
- Fix link checked
- 5 Jul 2026