Skip to content

feat(docker): sandbox egress control (on/off/allowlist) with filtered proxy - #58601

Open
suzu784 wants to merge 6 commits into
NousResearch:mainfrom
suzu784:feat/docker-sandbox-egress-control
Open

feat(docker): sandbox egress control (on/off/allowlist) with filtered proxy#58601
suzu784 wants to merge 6 commits into
NousResearch:mainfrom
suzu784:feat/docker-sandbox-egress-control

Conversation

@suzu784

@suzu784 suzu784 commented Jul 5, 2026

Copy link
Copy Markdown

What does this PR do?

Adds a third egress mode for Docker sandboxes: terminal.container_network: "allowlist", alongside the existing on/off (network: bool). In allowlist mode, sandboxes attach to a per-allowlist --internal network and reach the internet only through a shared, dual-homed, stdlib-only forward proxy — default-deny, TLS pass-through via CONNECT (no certificate interception). Raw / non-proxied egress has no route out.

The threat model is the one already described in docs/security/network-egress-isolation.md (prompt-injected data exfiltration via curl/wget from inside a sandbox). That doc covers an ops-level pattern (compose + external squid/envoy); this PR provides the same control in-app, per-sandbox, auto-provisioned, with no external infrastructure. The two are complementary — happy to add a cross-reference to that doc if desired.

Related Issue

No linked issue — feature proposal. Closest prior art is the deployment pattern in docs/security/network-egress-isolation.md.

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

  • tools/environments/egress_proxy.py (new): network/proxy provisioning — one --internal network + one shared proxy container per allowlist hash, labeled hermes.egress, pruned from cleanup_all_environments()
  • tools/environments/egress_proxy_server.py (new): self-contained stdlib proxy, mounted read-only into python:3.13-slim (CONNECT tunnel, absolute-URI plain HTTP forward, 403 deny, 502 upstream-failure)
  • tools/environments/docker.py: DockerEnvironment(network_mode=, network_allowlist=), mode resolution + allowlist normalization; legacy docker_network: false remains a hard deny even when new defaults are present
  • Wiring (the env-driven bridges without which the environment layer is inert): cli.py env_mappings, gateway/run.py _terminal_env_map, tools/terminal_tool.py _get_env_config()/_create_environment(), hermes_cli/config.py TERMINAL_CONFIG_ENV_MAP + defaults + hermes config display
  • cli-config.yaml.example: documented container_network / container_network_allowlist incl. fail-closed and prune semantics, plus the explicit scope boundary that Codex app-server commands use Codex's separate sandbox
  • scripts/release.py: AUTHOR_MAP entry for this contributor (contributor-check)

Design points:

  • Fail closed everywhere: unknown mode → "off"; proxy provisioning or outbound-connect failure → --network=none; legacy docker_network: false cannot be weakened by container_network: "on" (never silently open)
  • IP-literal allowlist entries require an exact match (no subdomain/wildcard logic on IPs)
  • Warns when user docker_env overrides the injected proxy env vars

How to Test

  1. bash scripts/run_tests.sh tests/tools/test_egress_proxy.py tests/tools/test_docker_environment.py — 110 tests (provisioning/restart/race/fail-closed orchestration, real proxy-server integration over loopback, prune keep/remove, config-wiring regression guards)
  2. Live E2E (requires Docker): set terminal.container_network: "allowlist" and container_network_allowlist: ["pypi.org"] → inside the sandbox, https://pypi.org returns 200 via the proxy, https://example.com fails with Tunnel connection failed: 403 Forbidden, raw-socket egress is unreachable
  3. Verified E2E against a live Docker daemon on macOS (Apple Silicon) — allowlisted 200 / unlisted 403 / no raw route / prune leaves no leftovers

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (plus the AUTHOR_MAP entry required by contributor-check)
  • Affected suites pass via the canonical runner (scripts/run_tests.sh, 110 tests); relying on CI for the full matrix — my host lacks some optional deps (whisper/fastapi/mautrix) so a full local run has known env-caused failures unrelated to this change
  • I've added tests for my changes
  • I've tested on my platform: macOS 26 (Apple Silicon), Docker via Colima

Documentation & Housekeeping

  • I've updated relevant documentation (cli-config.yaml.example, docstrings)
  • I've updated cli-config.yaml.example for the new config keys
  • CONTRIBUTING.md / AGENTS.md — N/A (no architecture/workflow change)
  • Cross-platform: docker-backend-only feature; the proxy is stdlib-only and runs inside a Linux container, so host OS differences don't apply
  • Tool descriptions/schemas — N/A (no tool schema change)

🤖 Generated with Claude Code

https://claude.ai/code/session_013n8YAnnTrwfzD5oECy6zdi

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have backend/docker Docker container execution area/docker Docker image, Compose, packaging comp/gateway Gateway runner, session dispatch, delivery comp/cli CLI entry point, hermes_cli/, setup wizard area/config Config system, migrations, profiles sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 5, 2026
@suzu784
suzu784 force-pushed the feat/docker-sandbox-egress-control branch from 6ae1dd2 to 82d04bc Compare July 10, 2026 01:30

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the substantial, fail-closed design and proxy coverage. It needs a few security-boundary fixes before salvage.

Problems

  • tools/code_execution_tool.py:680-688 and tools/file_tools.py:1141-1151 independently construct Docker configs but do not receive either new egress key. Unlike the terminal path, those Docker sandboxes fall back to DockerEnvironment's default network mode.
  • Current reuse protection only inspects a reused container when legacy network is false (tools/environments/docker.py:913-917). The new allowlist mode leaves that boolean true, so a labeled bridge container can be reused after an operator changes to allowlist mode.
  • tests/tools/test_egress_proxy.py:436-442 reads source text instead of executing configuration behavior.

Suggested changes

  • Propagate both settings through file and code-execution paths, with behavioral coverage for all three creation paths.
  • Make reuse validate the expected allowlist network and replace stale bridge/different-allowlist containers.
  • Replace the source-shape test with CLI/gateway propagation tests; update website/docs/user-guide/configuration.md alongside the example config.

Automated hermes-sweeper review.

# Resolve effective egress mode. ``network_mode`` (on/off/allowlist) is the
# primary control; the legacy ``network`` bool is kept for backwards compat
# (network=False == network_mode="off"). See _SECURITY_ARGS / egress_proxy.
self._network_mode = _resolve_network_mode(network_mode, network)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowlist mode also needs to update the cross-process reuse guard. On current main that guard only checks not network, so a previously bridge-networked container with the same task/profile labels will be reused after this setting changes to allowlist, bypassing the intended boundary. Validate the expected internal network and replace mismatches.

Comment thread tests/tools/test_egress_proxy.py Outdated
"""
repo = Path(__file__).resolve().parents[2]
for rel in ("cli.py", "gateway/run.py"):
src = (repo / rel).read_text(encoding="utf-8")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please replace this source-text assertion with a behavioral propagation test. Repository policy forbids tests that read source files: this can pass while runtime wiring is broken and blocks harmless refactors.

@teknium1 teknium1 added sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
@suzu784

suzu784 commented Jul 16, 2026

Copy link
Copy Markdown
Author

Thanks for the review — all three findings were valid. Addressed in cb26d8698 + 98ca78afe:

1. Egress keys not propagated to the code-execution / file-tools Docker paths → fixed in 98ca78afe. Both container_config dicts now carry container_network / container_network_allowlist, mirroring the terminal path. Behavioral coverage added for all three creation paths: the new tests drive the real _get_env_config()container_config_create_environment chain with a capturing _DockerEnvironment fake and assert the settings reach DockerEnvironment (network_mode="allowlist", allowlist intact). Before the fix, the execute_code and file-tools tests fail with network_mode=None — reproducing exactly the gap you flagged.

2. Reuse guard skipped for allowlist mode → fixed in cb26d8698. The if not network: gate is gone; _reuse_network_mismatch() now validates the reused container's HostConfig.NetworkMode against the effective expectation in every mode:

  • off / allowlist: exact match required — none, or the current allowlist's hermes-egress-<hash> network (a changed allowlist hashes to a different name, so stale-bridge and different-allowlist containers are both replaced). Inspect failure counts as mismatch (fail closed).
  • on: replaces only containers stranded on a hermes-egress-* internal network (no route out under an open config); deliberate --network=none containers from docker_extra_args are kept, preserving the documented no-churn behavior, and legacy docker_network: false resolves to off so its guard is unchanged.

expected_network_name() (new, pure) exposes the provisioning name derivation to the guard. 10 new reuse-guard tests cover bridge/none/other-allowlist/matching/proxy-failure/inspect-failure across all three modes.

3. Source-text test → replaced in 98ca78afe with behavioral bridge tests: one executes the real cli.load_cli_config() against a temp HERMES_HOME and asserts TERMINAL_CONTAINER_NETWORK* land in os.environ (JSON-encoded list included); two more extend the gateway's subprocess import harness (tests/gateway/test_config_env_bridge_authority.py) to assert the import-time bridge exports both vars and that config.yaml wins over a stale .env. website/docs/user-guide/configuration.md now documents both keys (modes, allowlist entry format, fail-closed semantics, container-replacement-on-mode-change, proxy lifecycle) alongside the example config.

suzu784 and others added 6 commits July 17, 2026 02:29
… proxy

Add a third container egress mode "allowlist" alongside on/off: sandboxes
attach to a per-allowlist --internal network and reach the internet only
through a shared, dual-homed stdlib forward proxy (default-deny, TLS
pass-through, no certificate interception). Raw/non-proxied egress has no
route out.

- wire terminal.container_network(+_allowlist) end-to-end: cli.py
  env_mappings, gateway/run.py _terminal_env_map, _get_env_config(); the
  DockerEnvironment layer alone was inert without these bridges
- fail closed everywhere: unknown mode -> "off"; proxy provisioning or
  outbound-network connect failure -> --network=none (never open network)
- IP-literal allowlist entries require an exact match (no subdomain logic)
- label egress networks/proxies (hermes.egress) and prune unused ones from
  cleanup_all_environments(); proxies are shared per-allowlist hash
- resolve the outbound network name by runtime (docker: bridge, podman)
- skip the client Host header when re-serializing plain-HTTP forwards
- warn when docker_env overrides the injected proxy env
- show egress mode in `hermes config`; document fail-closed + prune in
  cli-config.yaml.example
- tests: provisioning/restart/race/fail-closed orchestration paths, real
  proxy-server integration over loopback (403 deny, CONNECT tunnel, plain
  forward, 502), prune keep/remove, and config-wiring regression guards

Verified E2E against a live Docker daemon: allowlisted domain 200 via
proxy, unlisted domain 403, raw socket egress unreachable, config
propagation from env vars, prune leaves no leftovers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H5Sx95bUybAUgVk2rZ1pq
The reuse guard only inspected NetworkMode when the legacy network bool
was false, so switching to container_network: "allowlist" (which leaves
that bool true) reused stale bridge containers — label-only reuse handed
the agent unfiltered egress despite the lockdown config. A changed
allowlist had the same hole: the old hermes-egress-<hash> network no
longer matches, but the container was reused anyway.

The guard now compares the reused container's NetworkMode against the
effective expectation per mode: "off" and "allowlist" require an exact
match ("none" / the current allowlist's hermes-egress-<hash> network,
failing closed on inspect failure), while "on" only replaces containers
stranded on a hermes-egress-* internal network (no route out under an
open-network config) and keeps deliberate --network=none containers
untouched. expected_network_name() exposes the provisioning name
derivation to the guard without provisioning anything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgaecGn2RaqRRmwd6mt4XU
…aths

execute_code and the file tools build their container_config dicts
independently of terminal_tool, and neither included container_network /
container_network_allowlist — so those two Docker sandboxes silently fell
back to full network while only the terminal path honored the egress
config. Add the two keys, mirroring terminal_tool's own dict.

Behavioral coverage replaces the source-shape assertions:
- creation-path tests drive the real _get_env_config → container_config →
  _create_environment chain for terminal / execute_code / file tools and
  assert the settings reach DockerEnvironment (the two fixed paths fail
  with network_mode=None without the fix),
- the source-grep bridge test is replaced by tests that execute the real
  cli.load_cli_config() and gateway/run.py import-time bridges and assert
  the TERMINAL_CONTAINER_NETWORK* env vars get set (config.yaml winning
  over a stale .env on the gateway side).

Document container_network / container_network_allowlist in the user
guide alongside the example config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgaecGn2RaqRRmwd6mt4XU
…actor

Upstream replaced file_tools' _last_known_cwd registry with the session
cwd record (get_session_cwd / record_session_cwd), so the creation-path
test no longer needs to reset it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgaecGn2RaqRRmwd6mt4XU
@suzu784
suzu784 force-pushed the feat/docker-sandbox-egress-control branch from 98ca78a to fa43479 Compare July 16, 2026 17:37
@teknium1

Copy link
Copy Markdown
Contributor

Heads-up on an interaction with a feature that just landed on main (PR #70848, re-land of #30179): the iron-proxy credential-injection egress firewall for Docker sandboxes.

Both features claim the same container-side surface:

  1. HTTPS_PROXY / HTTP_PROXY env vars — iron-proxy sets these to http://host.docker.internal:9090 / :9091 when proxy.enabled is on. If allowlist mode also sets them to its own filtered proxy, one feature silently disables the other.
  2. Network reachability — this PR's --internal network removes the container's route to host.docker.internal (host-gateway), which is how sandboxes reach the iron-proxy daemon on the host. With both enabled, credential injection breaks: SDK calls carry proxy tokens that nothing swaps for real keys, so every provider call 401s.
  3. Container reusefeat(egress): re-land iron-proxy credential-injection firewall (revert of #58489) #70848 added an egress-posture label/fingerprint to the reuse path (_find_reusable_container, hermes-egress label) because proxy env/CA mounts are immutable post-creation. A third network mode should participate in that fingerprint (or the network-mode guard added in 3167dba) so mode switches don't silently reuse a container with the wrong posture.

None of this is a rejection signal — default-deny reachability control and credential injection are complementary layers. But the PR needs an explicit answer for proxy.enabled && container_network: allowlist: either compose them (chain the filtered proxy upstream of iron-proxy, or attach the iron-proxy bind to the internal network) or refuse the combination loudly at startup. Silent breakage of the credential firewall would be the worst outcome.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles area/docker Docker image, Compose, packaging backend/docker Docker container execution comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants