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

0open issues
0open PRs
5items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #59149 feat(docker): terminal network toggle with full-path coverage and reuse guard

    merged to main

    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

    Show 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
  • Closed#4921[Feature]: add terminal.docker_network for the Docker backend
  • Closed#26962Docker terminal: honor `--network=none` (currently no config knob)
  • Closed#28806Docker backend: `network` capability exists but no `terminal.docker_network` config key
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.00 means 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 Lab

At a glance

Subsystem
environments
Merged PRs
#59149
Closed issues
3
Attempts
1 PR closed without merging
Fix link checked
5 Jul 2026

For your agent

The same facts, typed.