# NousResearch/hermes-agent · Add docker network config for terminal sessions

## Open now

- 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.

## TL;DR

- 5 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #59149. Verify it addresses your case.
- **Recommended:** read PR #59149 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #4921: \[Feature\]: add terminal.docker_network for the Docker backend
- #26962: Docker terminal: honor \`--network=none\` (currently no config knob)
- #28806: Docker backend: \`network\` capability exists but no \`terminal.docker_network\` config key

## Prior & competing PRs

- **#59149: feat(docker): terminal network toggle with full-path coverage and reuse guard**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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.md`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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),
             )
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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 |
    ```
- **#4922: feat(terminal): wire docker_network through docker backend**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#59149`, `#46358`

## Fix status

- Merged to main in PR #59149 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-05`.
- Subsystem: `environments`
