{"repo":"NousResearch/hermes-agent","public_id":"4921","display_label":"Add docker network config for terminal sessions","summary":"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.","subsystem":"environments","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":59149},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-05T21:41:52+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.30799999999999994},"fix_edges":[{"issue_number":4921,"pr_number":59149,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":4921,"title":"[Feature]: add terminal.docker_network for the Docker backend","url":null,"state":"closed"},{"kind":"issue","number":26962,"title":"Docker terminal: honor `--network=none` (currently no config knob)","url":null,"state":"closed"},{"kind":"issue","number":28806,"title":"Docker backend: `network` capability exists but no `terminal.docker_network` config key","url":null,"state":"closed"},{"kind":"pr","number":4922,"title":"feat(terminal): wire docker_network through docker backend","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59149","number":59149},{"kind":"pr","value":"#46358","number":46358}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":59149,"title":"feat(docker): terminal network toggle with full-path coverage and reuse guard","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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.md"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -627,6 +627,7 @@ def load_cli_config() -> Dict[str, Any]:\n         \"docker_env\": \"TERMINAL_DOCKER_ENV\",\n         \"docker_extra_args\": \"TERMINAL_DOCKER_EXTRA_ARGS\",\n         \"docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n+        \"docker_network\": \"TERMINAL_DOCKER_NETWORK\",\n         \"docker_run_as_host_user\": \"TERMINAL_DOCKER_RUN_AS_HOST_USER\",\n         \"docker_persist_across_processes\": \"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES\",\n         \"docker_orphan_reaper\": \"TERMINAL_DOCKER_ORPHAN_REAPER\","]},{"file":"gateway/run.py","hunks":["@@ -1463,6 +1463,7 @@ def _profile_runtime_scope(profile_home: \"Path\"):\n                 \"docker_env\": \"TERMINAL_DOCKER_ENV\",\n                 \"docker_extra_args\": \"TERMINAL_DOCKER_EXTRA_ARGS\",\n                 \"docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n+                \"docker_network\": \"TERMINAL_DOCKER_NETWORK\",\n                 \"docker_run_as_host_user\": \"TERMINAL_DOCKER_RUN_AS_HOST_USER\",\n                 \"docker_persist_across_processes\": \"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES\",\n                 \"docker_orphan_reaper\": \"TERMINAL_DOCKER_ORPHAN_REAPER\","]},{"file":"hermes_cli/config.py","hunks":["@@ -1163,6 +1163,9 @@ def _ensure_hermes_home_managed(home: Path):\n         # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n         # Default off because passing host directories into a sandbox weakens isolation.\n         \"docker_mount_cwd_to_workspace\": False,\n+        # Opt-in egress lockdown for Docker terminal sessions. When false,\n+        # Docker runs with --network=none so commands cannot reach the network.\n+        \"docker_network\": True,\n         \"docker_extra_args\": [],        # Extra flags passed verbatim to docker run\n         # Explicit opt-in: run the Docker container as the host user's uid:gid\n         # (via `--user`).  When enabled, files written into bind-mounted dirs","@@ -6639,6 +6642,7 @@ def write_platform_config_field(\n     \"docker_volumes\": \"TERMINAL_DOCKER_VOLUMES\",\n     \"docker_env\": \"TERMINAL_DOCKER_ENV\",\n     \"docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n+    \"docker_network\": \"TERMINAL_DOCKER_NETWORK\",\n     \"docker_extra_args\": \"TERMINAL_DOCKER_EXTRA_ARGS\",\n     \"docker_run_as_host_user\": \"TERMINAL_DOCKER_RUN_AS_HOST_USER\",\n     \"docker_persist_across_processes\": \"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES\","]},{"file":"tests/tools/test_docker_network_config.py","hunks":["@@ -0,0 +1,179 @@\n+\"\"\"Regression tests for the Docker terminal network toggle.\n+\n+Ported from NanoClaw PR #2713's opt-in egress lockdown idea. Hermes already\n+has DockerEnvironment(network=False), but the terminal config path did not\n+expose it, so operators could not request networkless Docker execution from\n+config.yaml.\n+\"\"\"\n+\n+import tools.terminal_tool as terminal_tool\n+from tools.environments import docker as docker_env\n+\n+\n+def test_terminal_env_config_reads_docker_network_toggle(monkeypatch):\n+    monkeypatch.setenv(\"TERMINAL_DOCKER_NETWORK\", \"false\")\n+\n+    config = terminal_tool._get_env_config()\n+\n+    assert config[\"docker_network\"] is False\n+\n+\n+def test_create_environment_passes_docker_network_toggle(monkeypatch):\n+    captured = {}\n+    sentinel = object()\n+\n+    def _fake_docker_environment(**kwargs):\n+        captured.update(kwargs)\n+        return sentinel\n+\n+    monkeypatch.setattr(terminal_tool, \"_DockerEnvironment\", _fake_docker_environment)\n+\n+    env = terminal_tool._create_environment(\n+        env_type=\"docker\",\n+        image=\"python:3.11\",\n+        cwd=\"/workspace\",\n+        timeout=60,\n+        container_config={\"docker_network\": False},\n+    )\n+\n+    assert env is sentinel\n+    assert captured[\"network\"] is False\n+\n+\n+def test_docker_environment_adds_network_none_when_disabled(monkeypatch):\n+    commands = []\n+\n+    def fake_run(cmd, *args, **kwargs):\n+        commands.append(cmd)\n+\n+        class Result:\n+            returncode = 0\n+            stdout = \"fake-container-id\\n\" if len(cmd) > 1 and cmd[1] == \"run\" else \"\"\n+            stderr = \"\"\n+\n+        return Result()\n+\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", fake_run)\n+    monkeypatch.setattr(docker_env.DockerEnvironment, \"_storage_opt_supported\", lambda self: False)\n+\n+    env = docker_env.DockerEnvironment(\n+        image=\"python:3.11\",\n+        cwd=\"/workspace\",\n+        timeout=60,\n+        task_id=\"network-none-test\",\n+        network=False,\n+    )\n+\n+    run_cmd = next(cmd for cmd in commands if len(cmd) > 2 and cmd[1:3] == [\"run\", \"-d\"])\n+    assert \"--network=none\" in run_cmd\n+    env.cleanup()\n+\n+\n+def test_docker_network_config_is_bridged_everywhere():\n+    from tests.tools.test_terminal_config_env_sync import (\n+        _cli_env_map_keys,\n+        _gateway_env_map_keys,\n+        _save_config_env_sync_keys,\n+        _terminal_tool_env_var_names,\n+    )\n+\n+    assert \"docker_network\" in _cli_env_map_keys()\n+    assert \"docker_network\" in _gateway_env_map_keys()\n+    assert \"docker_network\" in _save_config_env_sync_keys()\n+    assert \"TERMINAL_DOCKER_NETWORK\" in _terminal_tool_env_var_names()\n+\n+\n+def test_sibling_container_config_sites_carry_docker_network():\n+    \"\"\"Every container_config dict that carries docker_run_as_host_user must\n+    also carry docker_network — otherwise that code path silently falls back\n+    to networked containers while the terminal path honors the lockdown\n+    (the probe/exec asymmetry reported on issue #46358).\n+    \"\"\"\n+    import ast\n+    import inspect\n+\n+    import tools.code_execution_tool as code_execution_tool\n+    import tools.file_tools as file_tools\n+\n+    for module in (terminal_tool, file_tools, code_execution_tool):\n+        tree = ast.parse(inspect.getsource(module))\n+        sites = 0\n+        for node in ast.walk(tree):\n+            if not isinstance(node, ast.Dict):\n+                continue\n+            keys = {k.value for k in node.keys if isinstance(k, ast.Constant)}\n+            if \"docker_run_as_host_user\" in keys:\n+                sites += 1\n+                assert \"docker_network\" in keys, (\n+                    f\"{module.__name__} builds a container_config with \"\n+                    f\"docker_run_as_host_user but without docker_network \"\n+                    f\"(line {node.lineno})\"\n+                )\n+        assert sites >= 1, f\"expected at least one container_config site in {module.__name__}\"\n+\n+\n+def _reuse_guard_harness(monkeypatch, *, existing_mode: str, network: bool):\n+    \"\"\"Drive DockerEnvironment through the cross-process reuse path with a\n+    fake existing container whose NetworkMode is *existing_mode*.\n+\n+    Returns the list of docker commands issued.\n+    \"\"\"\n+    commands = []\n+\n+    def fake_run(cmd, *args, **kwargs):\n+        commands.append(cmd)\n+\n+        class Result:\n+            returncode = 0\n+            stderr = \"\"\n+            stdout = \"\"\n+\n+        if len(cmd) > 1 and cmd[1] == \"ps\":\n+            Result.stdout = \"existing-container-id\\trunning\\n\"\n+        elif len(cmd) > 1 and cmd[1] == \"inspect\":\n+            Result.stdout = f\"{existing_mode}\\n\"\n+        elif len(cmd) > 1 and cmd[1] == \"run\":\n+            Result.stdout = \"fresh-container-id\\n\"\n+        return Result()\n+\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", fake_run)\n+    monkeypatch.setattr(docker_env.DockerEnvironment, \"_storage_opt_supported\", lambda self: False)\n+\n+    docker_env.DockerEnvironment(\n+        image=\"python:3.11\",\n+        cwd=\"/workspace\",\n+        timeout=60,\n+        task_id=\"reuse-guard-test\",\n+        network=network,\n+        persist_across_processes=True,\n+    )\n+    return commands\n+\n+\n+def test_reuse_rejects_networked_container_when_lockdown_requested(monkeypatch):\n+    commands = _reuse_guard_harness(monkeypatch, existing_mode=\"bridge\", network=False)\n+\n+    assert any(cmd[1:3] == [\"rm\", \"-f\"] for cmd in commands), (\n+        \"bridge-networked container must be removed when docker_network=false\"\n+    )\n+    run_cmd = next(cmd for cmd in commands if len(cmd) > 2 and cmd[1:3] == [\"run\", \"-d\"])\n+    assert \"--network=none\" in run_cmd\n+\n+\n+def test_reuse_keeps_airgapped_container_when_lockdown_requested(monkeypatch):\n+    commands = _reuse_guard_harness(monkeypatch, existing_mode=\"none\", network=False)\n+\n+    assert not any(cmd[1] == \"rm\" for cmd in commands)\n+    assert not any(cmd[1] == \"run\" for cmd in commands), \"matching container must be reused\"\n+\n+\n+def test_reuse_skips_inspect_when_network_enabled(monkeypatch):\n+    commands = _reuse_guard_harness(monkeypatch, existing_mode=\"none\", network=True)\n+\n+    # Default-network config never churns containers, even air-gapped ones\n+    # (operators may have created them via docker_extra_args).\n+    assert not any(cmd[1] == \"inspect\" for cmd in commands)\n+    assert not any(cmd[1] == \"rm\" for cmd in commands)\n+    assert not any(cmd[1] == \"run\" for cmd in commands)"]},{"file":"tools/code_execution_tool.py","hunks":["@@ -684,6 +684,7 @@ def _get_or_create_env(task_id: str):\n                 \"container_persistent\": config.get(\"container_persistent\", True),\n                 \"docker_volumes\": config.get(\"docker_volumes\", []),\n                 \"docker_run_as_host_user\": config.get(\"docker_run_as_host_user\", False),\n+                \"docker_network\": config.get(\"docker_network\", True),\n             }\n \n         ssh_config = None"]},{"file":"tools/environments/docker.py","hunks":["@@ -897,6 +897,45 @@ def __init__(\n         reused = False\n         if persist_across_processes:\n             existing = self._find_reusable_container(task_label, profile_name)\n+            if existing is not None:\n+                container_id, state = existing\n+                # Network-mode guard: reuse must not silently defeat an\n+                # egress lockdown.  A container created before the operator\n+                # set ``docker_network: false`` keeps its original bridge\n+                # NetworkMode, so label-only reuse would hand the agent a\n+                # networked container despite the config.  On mismatch we\n+                # remove the stale container and start fresh — leaving it in\n+                # place would let the next label-based reuse pick it up again.\n+                # Only the lockdown direction is guarded: a ``none``-mode\n+                # container under a default-network config is left alone so\n+                # operators using ``docker_extra_args: [\"--network=none\"]``\n+                # don't get their container churned on every startup.\n+                mode_mismatch = False\n+                actual_mode = None\n+                if not network:\n+                    actual_mode = self._container_network_mode(container_id)\n+                    mode_mismatch = actual_mode != \"none\"\n+                if mode_mismatch:\n+                    logger.warning(\n+                        \"Existing container %s has NetworkMode=%s but \"\n+                        \"docker_network=false requests an air-gapped \"\n+                        \"container — removing it and starting fresh \"\n+                        \"(task=%s, profile=%s).\",\n+                        container_id[:12], actual_mode or \"unknown\",\n+                        task_label, profile_name,\n+                    )\n+                    try:\n+                        subprocess.run(\n+                            [self._docker_exe, \"rm\", \"-f\", container_id],\n+                            capture_output=True,\n+                            text=True,\n+                            timeout=30,\n+                            check=False,\n+                            stdin=subprocess.DEVNULL,\n+                        )\n+                    except (subprocess.TimeoutExpired, OSError) as e:\n+                        logger.warning(\"Failed to remove mismatched container %s: %s\", container_id[:12], e)\n+                    existing = None\n             if existing is not None:\n                 container_id, state = existing\n                 self._container_id = container_id","@@ -1194,6 +1233,40 @@ def _storage_opt_supported() -> bool:\n         logger.debug(\"Docker --storage-opt support: %s\", _storage_opt_ok)\n         return _storage_opt_ok\n \n+    def _container_network_mode(self, container_id: str) -> Optional[str]:\n+        \"\"\"Return the container's ``HostConfig.NetworkMode`` (e.g. ``bridge``,\n+        ``none``, ``host``), or ``None`` when inspection fails.\n+\n+        Used by the reuse path to make sure a persisted container's network\n+        mode still matches the operator's ``docker_network`` setting; callers\n+        treat ``None`` (unknown) as a mismatch when lockdown was requested,\n+        so a failed inspect fails closed rather than open.\n+        \"\"\"\n+        try:\n+            result = subprocess.run(\n+                [\n+                    self._docker_exe, \"inspect\",\n+                    \"--format\", \"{{.HostConfig.NetworkMode}}\",\n+                    container_id,\n+                ],\n+                capture_output=True,\n+                text=True,\n+                timeout=10,\n+                check=False,\n+                stdin=subprocess.DEVNULL,\n+            )\n+        except (subprocess.TimeoutExpired, OSError) as e:\n+            logger.debug(\"docker inspect NetworkMode failed: %s\", e)\n+            return None\n+        if result.returncode != 0:\n+            logger.debug(\n+                \"docker inspect NetworkMode returned %d: %s\",\n+                result.returncode, result.stderr.strip(),\n+            )\n+            return None\n+        mode = result.stdout.strip()\n+        return mode or None\n+\n     def _find_reusable_container(self, task_label: str, profile_label: str) -> Optional[tuple[str, str]]:\n         \"\"\"Look for an existing container labeled for this (task, profile).\n "]},{"file":"tools/file_tools.py","hunks":["@@ -1073,6 +1073,7 @@ def _get_file_ops(task_id: str = \"default\") -> ShellFileOperations:\n                     \"docker_mount_cwd_to_workspace\": config.get(\"docker_mount_cwd_to_workspace\", False),\n                     \"docker_forward_env\": config.get(\"docker_forward_env\", []),\n                     \"docker_run_as_host_user\": config.get(\"docker_run_as_host_user\", False),\n+                    \"docker_network\": config.get(\"docker_network\", True),\n                 }\n \n             ssh_config = None"]},{"file":"tools/terminal_tool.py","hunks":["@@ -1357,6 +1357,7 @@ def _get_env_config() -> Dict[str, Any]:\n         \"docker_volumes\": docker_volumes,\n         \"docker_env\": docker_env,\n         \"docker_run_as_host_user\": os.getenv(\"TERMINAL_DOCKER_RUN_AS_HOST_USER\", \"false\").lower() in {\"true\", \"1\", \"yes\"},\n+        \"docker_network\": os.getenv(\"TERMINAL_DOCKER_NETWORK\", \"true\").lower() in {\"true\", \"1\", \"yes\"},\n         \"docker_extra_args\": docker_extra_args,\n         # Cross-process container reuse (issue #20561).  The docs claim\n         # \"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,\n     docker_forward_env = cc.get(\"docker_forward_env\", [])\n     docker_env = cc.get(\"docker_env\", {})\n     docker_extra_args = cc.get(\"docker_extra_args\", [])\n+    docker_network = cc.get(\"docker_network\", True)\n \n     if env_type == \"local\":\n         return _LocalEnvironment(cwd=cwd, timeout=timeout)","@@ -1439,6 +1441,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,\n             forward_env=docker_forward_env,\n             env=docker_env,\n             run_as_host_user=cc.get(\"docker_run_as_host_user\", False),\n+            network=docker_network,\n             extra_args=docker_extra_args,\n             persist_across_processes=cc.get(\"docker_persist_across_processes\", True),\n         )","@@ -2208,6 +2211,7 @@ def terminal_tool(\n                                 \"docker_env\": config.get(\"docker_env\", {}),\n                                 \"docker_run_as_host_user\": config.get(\"docker_run_as_host_user\", False),\n                                 \"docker_extra_args\": config.get(\"docker_extra_args\", []),\n+                                \"docker_network\": config.get(\"docker_network\", True),\n                                 \"docker_persist_across_processes\": config.get(\"docker_persist_across_processes\", True),\n                                 \"docker_orphan_reaper\": config.get(\"docker_orphan_reaper\", True),\n                             }"]},{"file":"website/docs/user-guide/configuration.md","hunks":["@@ -219,6 +219,7 @@ terminal:\n   docker_extra_args:               # Extra flags appended verbatim to `docker run`\n     - \"--gpus=all\"\n     - \"--network=host\"\n+  docker_network: true             # false = air-gap the container (--network=none)\n \n   # Resource limits\n   container_cpu: 1                 # CPU cores (0 = unlimited)","@@ -240,6 +241,8 @@ terminal:\n \n **`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.\n \n+**`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`.\n+\n **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.\n \n #### Container lifecycle","@@ -291,6 +294,7 @@ Every key under `terminal:` has an env-var override of the form `TERMINAL_<KEY_U\n | `TERMINAL_DOCKER_EXTRA_ARGS` | `docker_extra_args` | JSON array |\n | `TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE` | `docker_mount_cwd_to_workspace` | `true` / `false` |\n | `TERMINAL_DOCKER_RUN_AS_HOST_USER` | `docker_run_as_host_user` | `true` / `false` |\n+| `TERMINAL_DOCKER_NETWORK` | `docker_network` | `true` / `false` — default `true`; `false` = `--network=none` |\n | `TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES` | `docker_persist_across_processes` | `true` / `false` — default `true` |\n | `TERMINAL_DOCKER_ORPHAN_REAPER` | `docker_orphan_reaper` | `true` / `false` — default `true` |\n | `TERMINAL_CONTAINER_CPU` | `container_cpu` | CPU cores |\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}