mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(egress): harden Docker proxy UX and enforcement
This commit is contained in:
parent
4f65d5509f
commit
e433c41014
21 changed files with 537 additions and 42 deletions
|
|
@ -1410,6 +1410,7 @@ def start_proxy(
|
|||
binary: Optional[Path] = None,
|
||||
config_path: Optional[Path] = None,
|
||||
extra_env: Optional[Dict[str, str]] = None,
|
||||
install_if_missing: bool = True,
|
||||
refresh_secrets_from_bitwarden: bool = False,
|
||||
bitwarden_config: Optional[Dict] = None,
|
||||
) -> ProxyStatus:
|
||||
|
|
@ -1432,7 +1433,7 @@ def start_proxy(
|
|||
if existing and _pid_alive(existing):
|
||||
return get_status()
|
||||
|
||||
bin_path = binary or find_iron_proxy(install_if_missing=True)
|
||||
bin_path = binary or find_iron_proxy(install_if_missing=install_if_missing)
|
||||
if bin_path is None:
|
||||
raise RuntimeError(
|
||||
"iron-proxy binary not available — run `hermes egress install`."
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
|
|||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
|
||||
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens', 'egress', 'iron proxy', 'sandbox proxy'],
|
||||
labelKey: 'providerApiKeys',
|
||||
tab: 'providers&pview=keys'
|
||||
},
|
||||
|
|
@ -164,7 +164,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
|
|||
},
|
||||
{
|
||||
icon: Settings2,
|
||||
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
|
||||
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env', 'egress proxy', 'iron proxy'],
|
||||
labelKey: 'keysSettings',
|
||||
tab: 'keys&kview=settings'
|
||||
},
|
||||
|
|
|
|||
4
cli.py
4
cli.py
|
|
@ -7355,6 +7355,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
self._show_gateway_status()
|
||||
elif canonical == "status":
|
||||
self._show_session_status()
|
||||
elif canonical == "egress":
|
||||
from hermes_cli.proxy_cli import format_status_text
|
||||
|
||||
self._console_print(format_status_text(), highlight=False, markup=False)
|
||||
elif canonical == "statusbar":
|
||||
self._status_bar_visible = not self._status_bar_visible
|
||||
state = "visible" if self._status_bar_visible else "hidden"
|
||||
|
|
|
|||
|
|
@ -6596,6 +6596,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if _cmd_def_inner and _cmd_def_inner.name == "restart":
|
||||
return await self._handle_restart_command(event)
|
||||
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "egress":
|
||||
from hermes_cli.proxy_cli import format_status_text
|
||||
|
||||
return format_status_text()
|
||||
|
||||
# /stop must hard-kill the session when an agent is running.
|
||||
# A soft interrupt (agent.interrupt()) doesn't help when the agent
|
||||
# is truly hung — the executor thread is blocked and never checks
|
||||
|
|
@ -7047,6 +7052,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if canonical == "status":
|
||||
return await self._handle_status_command(event)
|
||||
|
||||
if canonical == "egress":
|
||||
from hermes_cli.proxy_cli import format_status_text
|
||||
|
||||
return format_status_text()
|
||||
|
||||
if canonical == "agents":
|
||||
return await self._handle_agents_command(event)
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
|
||||
args_hint="[text | remove N | clear]"),
|
||||
CommandDef("status", "Show session info", "Session"),
|
||||
CommandDef("egress", "Show Docker egress proxy status", "Session",
|
||||
args_hint="[status]", subcommands=("status",)),
|
||||
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
|
||||
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
||||
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
||||
|
|
|
|||
|
|
@ -335,9 +335,10 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||
# and surface a clear error rather than silently substituting the
|
||||
# default.
|
||||
if args.tunnel_port is not None:
|
||||
if args.tunnel_port == 0:
|
||||
if args.tunnel_port < 1 or args.tunnel_port > 65534:
|
||||
console.print(
|
||||
" [red]✗ --tunnel-port=0 is not a valid TCP port.[/red]"
|
||||
" [red]✗ --tunnel-port must be between 1 and 65534 "
|
||||
"(the plain-HTTP listener uses port+1).[/red]"
|
||||
)
|
||||
return 1
|
||||
tunnel_port = int(args.tunnel_port)
|
||||
|
|
@ -421,6 +422,15 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||
proxy_cfg.setdefault("fail_on_uncovered_providers", False)
|
||||
save_config(cfg)
|
||||
|
||||
live_status = ip.get_status()
|
||||
if live_status.pid is not None:
|
||||
ip.stop_proxy()
|
||||
console.print(
|
||||
" [yellow]⚠ stopped the running iron-proxy; config or tokens changed, "
|
||||
"so restart it with `hermes egress start` before launching new "
|
||||
"Docker sandboxes.[/yellow]"
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
"[green]✓ iron-proxy is configured.[/green] "
|
||||
|
|
@ -551,6 +561,7 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|||
|
||||
try:
|
||||
status = ip.start_proxy(
|
||||
install_if_missing=bool(proxy_cfg.get("auto_install", True)),
|
||||
refresh_secrets_from_bitwarden=refresh_bw,
|
||||
bitwarden_config=bw_cfg,
|
||||
)
|
||||
|
|
@ -582,6 +593,55 @@ def cmd_stop(args: argparse.Namespace) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def format_status_text(*, show_tokens: bool = False) -> str:
|
||||
"""Plain-text egress status for slash commands, Dashboard, and Desktop."""
|
||||
cfg = load_config()
|
||||
proxy_cfg = cfg.get("proxy") or {}
|
||||
status = ip.get_status()
|
||||
|
||||
def yn(value: bool) -> str:
|
||||
return "yes" if value else "no"
|
||||
|
||||
lines = [
|
||||
"Egress proxy status",
|
||||
"",
|
||||
f"Enabled: {yn(bool(proxy_cfg.get('enabled')))}",
|
||||
f"Binary: {status.binary_path or '(missing)'}",
|
||||
f"Binary version: {status.binary_version or '(unknown)'}",
|
||||
f"Config: {status.config_path or '(not generated)'}",
|
||||
f"CA cert: {status.ca_cert_path or '(not generated)'}",
|
||||
f"Tunnel port: {status.tunnel_port}",
|
||||
f"Process: pid {status.pid}" if status.pid else "Process: (stopped)",
|
||||
f"Listening: {yn(status.listening)}",
|
||||
f"Credential src: {proxy_cfg.get('credential_source', 'env')}",
|
||||
f"Docker enforce: {yn(bool(proxy_cfg.get('enforce_on_docker', True)))}",
|
||||
"Scope: Docker backend only in this release",
|
||||
]
|
||||
|
||||
mappings = ip.load_mappings()
|
||||
if mappings:
|
||||
lines.extend(["", "Token mappings:"])
|
||||
for m in mappings:
|
||||
tok = m.proxy_token if show_tokens else _redact_token(m.proxy_token)
|
||||
lines.append(f" - {m.real_env_name}: {tok} ({', '.join(m.upstream_hosts)})")
|
||||
|
||||
uncovered = ip.discover_uncovered_providers()
|
||||
if uncovered:
|
||||
lines.extend([
|
||||
"",
|
||||
"Uncovered providers (real credentials still visible inside the sandbox):",
|
||||
])
|
||||
for name in uncovered:
|
||||
lines.append(f" - {name}")
|
||||
|
||||
if bool(proxy_cfg.get("enabled")) and not status.configured:
|
||||
lines.extend(["", "Next: run `hermes egress setup` to mint tokens and write proxy.yaml."])
|
||||
elif bool(proxy_cfg.get("enabled")) and not (status.pid and status.listening):
|
||||
lines.extend(["", "Next: run `hermes egress start` before launching Docker sandboxes."])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
console = Console()
|
||||
cfg = load_config()
|
||||
|
|
|
|||
|
|
@ -1200,6 +1200,28 @@ def setup_terminal_backend(config: dict):
|
|||
config["terminal"].setdefault(
|
||||
"docker_image", "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
)
|
||||
print()
|
||||
print_info("Docker sandboxes can be protected with the egress credential firewall.")
|
||||
print_info(
|
||||
"It routes sandbox traffic through iron-proxy so containers receive "
|
||||
"proxy tokens instead of real API keys."
|
||||
)
|
||||
print_info(
|
||||
" Docker only for now; Modal, SSH, Daytona, and Singularity are not wired yet."
|
||||
)
|
||||
if prompt_yes_no(" Enable egress firewall for Docker sandboxes?", False):
|
||||
proxy_cfg = config.setdefault("proxy", {})
|
||||
proxy_cfg["enabled"] = True
|
||||
proxy_cfg.setdefault("enforce_on_docker", True)
|
||||
print_success("Egress firewall enabled in config")
|
||||
print_info(
|
||||
"Run `hermes egress setup` then `hermes egress start` to mint "
|
||||
"tokens and launch the proxy."
|
||||
)
|
||||
else:
|
||||
print_info(
|
||||
"Skipping egress firewall. You can enable it later with `hermes egress setup`."
|
||||
)
|
||||
|
||||
elif selected_backend == "singularity":
|
||||
print_success("Terminal backend: Singularity/Apptainer")
|
||||
|
|
|
|||
|
|
@ -435,6 +435,25 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
|||
"description": "Modal sandbox mode",
|
||||
"options": ["sandbox", "function"],
|
||||
},
|
||||
"proxy.enabled": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Docker-only egress credential firewall. Requires `hermes egress setup` "
|
||||
"and `hermes egress start`; Modal/SSH/Daytona are not wired yet."
|
||||
),
|
||||
"category": "security",
|
||||
},
|
||||
"proxy.credential_source": {
|
||||
"type": "select",
|
||||
"description": "Where iron-proxy loads real upstream secrets at start time",
|
||||
"options": ["env", "bitwarden"],
|
||||
"category": "security",
|
||||
},
|
||||
"proxy.enforce_on_docker": {
|
||||
"type": "boolean",
|
||||
"description": "Refuse Docker sandboxes when egress is enabled but not configured/running",
|
||||
"category": "security",
|
||||
},
|
||||
"tts.provider": {
|
||||
"type": "select",
|
||||
"description": "Text-to-speech provider",
|
||||
|
|
@ -2193,6 +2212,14 @@ async def get_schema():
|
|||
return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER}
|
||||
|
||||
|
||||
@app.get("/api/egress/status")
|
||||
async def get_egress_status():
|
||||
"""Dashboard/Desktop-readable egress proxy status and remediation text."""
|
||||
from hermes_cli.proxy_cli import format_status_text
|
||||
|
||||
return {"text": format_status_text()}
|
||||
|
||||
|
||||
_EMPTY_MODEL_INFO: dict = {
|
||||
"model": "",
|
||||
"provider": "",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ def test_status_command_is_available_in_cli_registry():
|
|||
assert cmd.gateway_only is False
|
||||
|
||||
|
||||
def test_egress_command_is_available_in_cli_registry():
|
||||
cmd = resolve_command("egress")
|
||||
assert cmd is not None
|
||||
assert cmd.gateway_only is False
|
||||
assert "status" in cmd.subcommands
|
||||
|
||||
|
||||
def test_process_command_status_dispatches_without_toggling_status_bar():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
|
|
@ -42,6 +49,20 @@ def test_process_command_status_dispatches_without_toggling_status_bar():
|
|||
assert cli_obj._status_bar_visible is True
|
||||
|
||||
|
||||
def test_process_command_egress_prints_proxy_status(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.proxy_cli.format_status_text",
|
||||
lambda: "Egress proxy status\nEnabled: no",
|
||||
)
|
||||
|
||||
assert cli_obj.process_command("/egress") is True
|
||||
|
||||
cli_obj.console.print.assert_called()
|
||||
printed = "\n".join(str(call.args[0]) for call in cli_obj.console.print.call_args_list)
|
||||
assert "Egress proxy status" in printed
|
||||
|
||||
|
||||
def test_statusbar_still_toggles_visibility():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
|
|
|
|||
|
|
@ -146,6 +146,36 @@ async def test_known_slash_command_not_flagged_as_unknown(monkeypatch):
|
|||
assert "Unknown command" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_egress_slash_command_reports_proxy_status(monkeypatch):
|
||||
runner = _make_runner()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.proxy_cli.format_status_text",
|
||||
lambda: "Egress proxy status\nEnabled: no",
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/egress"))
|
||||
|
||||
assert result is not None
|
||||
assert "Egress proxy status" in result
|
||||
assert "Unknown command" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_egress_slash_command_reports_proxy_status_while_agent_running(monkeypatch):
|
||||
runner = _make_runner()
|
||||
runner._running_agents[build_session_key(_make_source())] = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.proxy_cli.format_status_text",
|
||||
lambda: "Egress proxy status\nEnabled: yes",
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/egress"))
|
||||
|
||||
assert result is not None
|
||||
assert "Egress proxy status" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_underscored_alias_for_hyphenated_builtin_not_flagged(monkeypatch):
|
||||
"""Telegram autocomplete sends /reload_mcp for the /reload-mcp built-in.
|
||||
|
|
|
|||
|
|
@ -2040,6 +2040,18 @@ class TestBuildSchemaFromConfig:
|
|||
assert "options" in entry
|
||||
assert "local" in entry["options"]
|
||||
|
||||
def test_proxy_schema_warns_dashboard_users_about_lifecycle(self):
|
||||
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||
|
||||
entry = CONFIG_SCHEMA["proxy.enabled"]
|
||||
assert entry["category"] == "security"
|
||||
assert "Docker-only" in entry["description"]
|
||||
assert "hermes egress setup" in entry["description"]
|
||||
|
||||
source_entry = CONFIG_SCHEMA["proxy.credential_source"]
|
||||
assert source_entry["type"] == "select"
|
||||
assert source_entry["options"] == ["env", "bitwarden"]
|
||||
|
||||
def test_empty_prefix_produces_correct_keys(self):
|
||||
from hermes_cli.web_server import _build_schema_from_config
|
||||
test_config = {"model": "test", "nested": {"key": "val"}}
|
||||
|
|
|
|||
|
|
@ -807,12 +807,38 @@ def test_docker_egress_args_full_path(hermes_home, monkeypatch):
|
|||
assert env["NODE_EXTRA_CA_CERTS"] == env["REQUESTS_CA_BUNDLE"]
|
||||
# NO_PROXY excludes loopback
|
||||
assert "127.0.0.1" in env["NO_PROXY"]
|
||||
# Per-mapping proxy token surfaced
|
||||
# Per-mapping proxy token is surfaced under both the standard provider env
|
||||
# name (so existing SDKs work without egress-specific code) and the
|
||||
# introspection name.
|
||||
assert env["OPENROUTER_API_KEY"] == mapping.proxy_token
|
||||
assert env["HERMES_PROXY_TOKEN_OPENROUTER_API_KEY"] == mapping.proxy_token
|
||||
# Linux host-gateway mapping
|
||||
assert host == ["--add-host", "host.docker.internal:host-gateway"]
|
||||
|
||||
|
||||
def test_docker_egress_fingerprint_changes_with_tokens(hermes_home, monkeypatch):
|
||||
"""Persistent Docker container reuse must not attach to a container that
|
||||
was created before egress, before a token rotation, or with a different CA
|
||||
mount. The label hash is what forces a fresh container in those cases."""
|
||||
|
||||
from tools.environments.docker import _egress_reuse_fingerprint
|
||||
|
||||
first = _egress_reuse_fingerprint(
|
||||
["-v", "/tmp/ca:/etc/ssl/certs/hermes-egress-ca.crt:ro"],
|
||||
{"OPENROUTER_API_KEY": "token-a", "HTTPS_PROXY": "http://h:9090"},
|
||||
["--add-host", "host.docker.internal:host-gateway"],
|
||||
)
|
||||
second = _egress_reuse_fingerprint(
|
||||
["-v", "/tmp/ca:/etc/ssl/certs/hermes-egress-ca.crt:ro"],
|
||||
{"OPENROUTER_API_KEY": "token-b", "HTTPS_PROXY": "http://h:9090"},
|
||||
["--add-host", "host.docker.internal:host-gateway"],
|
||||
)
|
||||
|
||||
assert first
|
||||
assert second
|
||||
assert first != second
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform asset name resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -177,6 +177,24 @@ def test_cmd_setup_rejects_tunnel_port_zero(hermes_home, monkeypatch):
|
|||
assert rc == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_port", [-1, 65535, 65536])
|
||||
def test_cmd_setup_rejects_invalid_tunnel_port_range(hermes_home, monkeypatch, bad_port):
|
||||
"""The egress wizard owns the derived HTTP listener at tunnel_port+1,
|
||||
so both listener ports must fit in the TCP range."""
|
||||
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
|
||||
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy")
|
||||
monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test")
|
||||
monkeypatch.setattr(
|
||||
ip,
|
||||
"ensure_ca_cert",
|
||||
lambda **kw: (hermes_home / "ca.crt", hermes_home / "ca.key"),
|
||||
)
|
||||
|
||||
rc = proxy_cli.cmd_setup(_args(tunnel_port=bad_port))
|
||||
assert rc == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cmd_start — fail_on_uncovered_providers + Bitwarden rotation wire-up
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -207,6 +225,30 @@ def test_cmd_start_refuses_on_uncovered_provider_when_strict(hermes_home, monkey
|
|||
assert rc == 1
|
||||
|
||||
|
||||
def test_cmd_start_honors_auto_install_false(hermes_home, monkeypatch):
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault("proxy", {})["enabled"] = True
|
||||
cfg["proxy"]["auto_install"] = False
|
||||
save_config(cfg)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def fake_start_proxy(**kw):
|
||||
captured.update(kw)
|
||||
s = ip.ProxyStatus(pid=4242, listening=True, tunnel_port=9090)
|
||||
return s
|
||||
|
||||
monkeypatch.setattr(ip, "start_proxy", fake_start_proxy)
|
||||
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
|
||||
monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: [])
|
||||
|
||||
rc = proxy_cli.cmd_start(_args())
|
||||
assert rc == 0
|
||||
assert captured.get("install_if_missing") is False
|
||||
|
||||
|
||||
def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwarden(
|
||||
hermes_home, monkeypatch,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -39,11 +39,13 @@ def _make_dummy_env(**kwargs):
|
|||
persistent_filesystem=kwargs.get("persistent_filesystem", False),
|
||||
task_id=kwargs.get("task_id", "test-task"),
|
||||
volumes=kwargs.get("volumes", []),
|
||||
forward_env=kwargs.get("forward_env"),
|
||||
network=kwargs.get("network", True),
|
||||
host_cwd=kwargs.get("host_cwd"),
|
||||
auto_mount_cwd=kwargs.get("auto_mount_cwd", False),
|
||||
env=kwargs.get("env"),
|
||||
run_as_host_user=kwargs.get("run_as_host_user", False),
|
||||
extra_args=kwargs.get("extra_args", []),
|
||||
persist_across_processes=kwargs.get("persist_across_processes", True),
|
||||
)
|
||||
|
||||
|
|
@ -673,6 +675,7 @@ def test_labels_attribute_populated_after_init(monkeypatch):
|
|||
"hermes-agent": "1",
|
||||
"hermes-task-id": "abc",
|
||||
"hermes-profile": "default",
|
||||
"hermes-egress": "off",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -748,6 +751,95 @@ def test_reuse_attaches_to_running_container_without_docker_run(monkeypatch):
|
|||
)
|
||||
|
||||
|
||||
def test_egress_enabled_does_not_reuse_pre_egress_container(monkeypatch):
|
||||
"""A container created before egress was enabled lacks the proxy env vars
|
||||
and CA mount. Reusing it would silently bypass the credential firewall."""
|
||||
|
||||
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
|
||||
monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default")
|
||||
monkeypatch.setattr(
|
||||
docker_env,
|
||||
"_egress_proxy_args_for_docker",
|
||||
lambda: (
|
||||
["-v", "/tmp/ca:/etc/ssl/certs/hermes-egress-ca.crt:ro"],
|
||||
{"HTTPS_PROXY": "http://host.docker.internal:9090"},
|
||||
["--add-host", "host.docker.internal:host-gateway"],
|
||||
),
|
||||
)
|
||||
calls = []
|
||||
|
||||
def _run(cmd, **kwargs):
|
||||
calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs))
|
||||
if isinstance(cmd, list) and len(cmd) >= 2:
|
||||
sub = cmd[1]
|
||||
if sub == "version":
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="")
|
||||
if sub == "ps":
|
||||
# Simulate an old pre-egress container: without the egress label
|
||||
# filter it would match; with the filter Docker returns no match.
|
||||
assert any(str(part).startswith("label=hermes-egress=") for part in cmd)
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
if sub == "run":
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="fresh-cid\n", stderr="")
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(docker_env.subprocess, "run", _run)
|
||||
|
||||
env = _make_dummy_env(task_id="reuse-egress")
|
||||
|
||||
assert env._container_id == "fresh-cid"
|
||||
run_invocations = [
|
||||
c for c in calls
|
||||
if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"
|
||||
]
|
||||
assert run_invocations, "egress-enabled containers require a fresh docker run"
|
||||
|
||||
|
||||
def test_forward_env_provider_key_collision_refuses_under_egress(monkeypatch):
|
||||
"""docker_forward_env is explicit, but it still must not smuggle real
|
||||
provider keys into an enforced egress sandbox."""
|
||||
|
||||
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-real")
|
||||
monkeypatch.setattr(
|
||||
docker_env,
|
||||
"_egress_proxy_args_for_docker",
|
||||
lambda: (
|
||||
[],
|
||||
{
|
||||
"HTTPS_PROXY": "http://host.docker.internal:9090",
|
||||
"OPENROUTER_API_KEY": "hermes-proxy-openrouter-token",
|
||||
"HERMES_PROXY_TOKEN_OPENROUTER_API_KEY": "hermes-proxy-openrouter-token",
|
||||
},
|
||||
[],
|
||||
),
|
||||
)
|
||||
_mock_subprocess_run(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError, match="docker_forward_env.*OPENROUTER_API_KEY"):
|
||||
_make_dummy_env(forward_env=["OPENROUTER_API_KEY"])
|
||||
|
||||
|
||||
def test_extra_args_proxy_override_refuses_under_egress(monkeypatch):
|
||||
"""docker_extra_args are appended after Hermes args, so egress enforcement
|
||||
must reject critical overrides before Docker sees them."""
|
||||
|
||||
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
|
||||
monkeypatch.setattr(
|
||||
docker_env,
|
||||
"_egress_proxy_args_for_docker",
|
||||
lambda: (
|
||||
[],
|
||||
{"HTTPS_PROXY": "http://host.docker.internal:9090"},
|
||||
[],
|
||||
),
|
||||
)
|
||||
_mock_subprocess_run(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError, match="docker_extra_args.*HTTPS_PROXY"):
|
||||
_make_dummy_env(extra_args=["-e", "HTTPS_PROXY="])
|
||||
|
||||
|
||||
def test_reuse_starts_stopped_container_before_attaching(monkeypatch):
|
||||
"""A labeled container in ``exited`` state must be restarted via
|
||||
``docker start`` before the new Hermes process uses it. Without this
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ configurable resource limits (CPU, memory, disk), and optional filesystem
|
|||
persistence via bind mounts.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -33,6 +34,7 @@ _DOCKER_SEARCH_PATHS = [
|
|||
|
||||
_docker_executable: Optional[str] = None # resolved once, cached
|
||||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_EGRESS_LABEL_KEY = "hermes-egress"
|
||||
|
||||
|
||||
def _normalize_forward_env_names(forward_env: list[str] | None) -> list[str]:
|
||||
|
|
@ -493,10 +495,11 @@ def _egress_proxy_args_for_docker() -> tuple[list[str], dict[str, str], list[str
|
|||
"_HERMES_EGRESS_NODE_OPTIONS_APPEND": "--use-openssl-ca",
|
||||
}
|
||||
|
||||
# Surface the per-provider proxy tokens. The sandbox can swap these into
|
||||
# its provider config (or its env, if it reads the standard names) and the
|
||||
# proxy translates them to the real secrets on egress.
|
||||
# Surface the per-provider proxy tokens under the standard provider env
|
||||
# names so existing SDKs and provider clients work unchanged inside the
|
||||
# sandbox. Keep the HERMES_PROXY_TOKEN_* aliases for diagnostics.
|
||||
for m in mappings:
|
||||
env_overrides[m.real_env_name] = m.proxy_token
|
||||
env_overrides[f"HERMES_PROXY_TOKEN_{m.real_env_name}"] = m.proxy_token
|
||||
|
||||
# On Linux, host.docker.internal isn't populated by default — Docker Desktop
|
||||
|
|
@ -507,6 +510,86 @@ def _egress_proxy_args_for_docker() -> tuple[list[str], dict[str, str], list[str
|
|||
return (volume_args, env_overrides, host_args)
|
||||
|
||||
|
||||
def _egress_reuse_fingerprint(
|
||||
volume_args: list[str],
|
||||
env_overrides: dict[str, str],
|
||||
host_args: list[str],
|
||||
) -> str:
|
||||
"""Stable Docker-label value for the egress posture of a container."""
|
||||
if not (volume_args or env_overrides or host_args):
|
||||
return "off"
|
||||
payload = json.dumps(
|
||||
{
|
||||
"volume_args": volume_args,
|
||||
"env_overrides": env_overrides,
|
||||
"host_args": host_args,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
|
||||
def _egress_enforce_on_docker(default: bool = True) -> bool:
|
||||
"""Read proxy.enforce_on_docker with fail-safe defaulting."""
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_cfg
|
||||
|
||||
return bool((_load_cfg().get("proxy") or {}).get("enforce_on_docker", default))
|
||||
except (ImportError, OSError):
|
||||
return default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _critical_egress_env_names(env_overrides: dict[str, str]) -> set[str]:
|
||||
"""Env names that would weaken or bypass enforced egress if overridden."""
|
||||
critical = {
|
||||
"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy",
|
||||
"NO_PROXY", "no_proxy",
|
||||
"REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE",
|
||||
"NODE_EXTRA_CA_CERTS", "NODE_OPTIONS",
|
||||
}
|
||||
critical.update(
|
||||
key for key in env_overrides
|
||||
if key.endswith("_API_KEY") or key.endswith("_TOKEN")
|
||||
)
|
||||
return critical
|
||||
|
||||
|
||||
def _extra_args_egress_collisions(
|
||||
extra_args: list[str], critical_names: set[str],
|
||||
) -> list[str]:
|
||||
"""Return docker_extra_args entries that can override egress controls."""
|
||||
collisions: list[str] = []
|
||||
env_flags = {"-e", "--env", "--env-file"}
|
||||
network_flags = {"--network", "--net"}
|
||||
i = 0
|
||||
while i < len(extra_args):
|
||||
arg = extra_args[i]
|
||||
nxt = extra_args[i + 1] if i + 1 < len(extra_args) else ""
|
||||
if arg in env_flags:
|
||||
if arg == "--env-file":
|
||||
collisions.append(arg)
|
||||
else:
|
||||
name = nxt.split("=", 1)[0]
|
||||
if name in critical_names:
|
||||
collisions.append(name)
|
||||
i += 2
|
||||
continue
|
||||
if any(arg.startswith(f"{flag}=") for flag in env_flags):
|
||||
if arg.startswith("--env-file="):
|
||||
collisions.append("--env-file")
|
||||
else:
|
||||
name = arg.split("=", 1)[1].split("=", 1)[0]
|
||||
if name in critical_names:
|
||||
collisions.append(name)
|
||||
elif arg in network_flags or any(arg.startswith(f"{flag}=") for flag in network_flags):
|
||||
collisions.append(arg)
|
||||
i += 1
|
||||
return sorted(set(collisions))
|
||||
|
||||
|
||||
def _build_security_args(run_as_host_user: bool, run_exec: bool = False) -> list[str]:
|
||||
"""Return the security/cap/tmpfs args tailored to the privilege mode.
|
||||
|
||||
|
|
@ -878,6 +961,30 @@ class DockerEnvironment(BaseEnvironment):
|
|||
egress_volume_args, egress_env_overrides, egress_host_args = (
|
||||
_egress_proxy_args_for_docker()
|
||||
)
|
||||
egress_label = _egress_reuse_fingerprint(
|
||||
egress_volume_args, egress_env_overrides, egress_host_args,
|
||||
)
|
||||
_enforce_egress = _egress_enforce_on_docker()
|
||||
_critical_egress_names = _critical_egress_env_names(egress_env_overrides)
|
||||
if egress_env_overrides:
|
||||
_forward_collisions = sorted(
|
||||
key for key in self._forward_env if key in _critical_egress_names
|
||||
)
|
||||
if _forward_collisions:
|
||||
_msg = (
|
||||
f"docker_forward_env would inject real egress-protected "
|
||||
f"variables {_forward_collisions}; enforce_on_docker is "
|
||||
f"{'enabled' if _enforce_egress else 'disabled'}."
|
||||
)
|
||||
if _enforce_egress:
|
||||
raise RuntimeError(
|
||||
f"{_msg} Remove these names from docker_forward_env "
|
||||
"or disable enforce_on_docker to opt out of egress isolation."
|
||||
)
|
||||
logger.warning(
|
||||
"%s Explicit docker_forward_env values will override egress tokens.",
|
||||
_msg,
|
||||
)
|
||||
volume_args.extend(egress_volume_args)
|
||||
# egress env overrides are merged in further below alongside the
|
||||
# other env_args computation.
|
||||
|
|
@ -1071,6 +1178,24 @@ class DockerEnvironment(BaseEnvironment):
|
|||
logger.warning("Ignoring non-string docker_extra_args entry: %r", arg)
|
||||
continue
|
||||
validated_extra.append(arg)
|
||||
if egress_env_overrides:
|
||||
_extra_collisions = _extra_args_egress_collisions(
|
||||
validated_extra, _critical_egress_names,
|
||||
)
|
||||
if _extra_collisions:
|
||||
_msg = (
|
||||
f"docker_extra_args would override egress-proxy controls "
|
||||
f"{_extra_collisions}; enforce_on_docker is "
|
||||
f"{'enabled' if _enforce_egress else 'disabled'}."
|
||||
)
|
||||
if _enforce_egress:
|
||||
raise RuntimeError(
|
||||
f"{_msg} Remove these args or disable enforce_on_docker "
|
||||
"to opt out of egress isolation."
|
||||
)
|
||||
logger.warning(
|
||||
"%s Extra Docker args may bypass egress isolation.", _msg,
|
||||
)
|
||||
|
||||
all_run_args = (
|
||||
security_args
|
||||
|
|
@ -1099,6 +1224,7 @@ class DockerEnvironment(BaseEnvironment):
|
|||
"--label", "hermes-agent=1",
|
||||
"--label", f"hermes-task-id={task_label}",
|
||||
"--label", f"hermes-profile={profile_name}",
|
||||
"--label", f"{_EGRESS_LABEL_KEY}={egress_label}",
|
||||
]
|
||||
# Save args for container recreation on "No such container" recovery.
|
||||
self._image = image
|
||||
|
|
@ -1110,6 +1236,7 @@ class DockerEnvironment(BaseEnvironment):
|
|||
"hermes-agent": "1",
|
||||
"hermes-task-id": task_label,
|
||||
"hermes-profile": profile_name,
|
||||
_EGRESS_LABEL_KEY: egress_label,
|
||||
}
|
||||
|
||||
# Cross-process container reuse (issue #20561 — docs claim "ONE long-lived
|
||||
|
|
@ -1119,14 +1246,15 @@ class DockerEnvironment(BaseEnvironment):
|
|||
# restores the documented contract; opt out via
|
||||
# ``terminal.docker_persist_across_processes: false``.
|
||||
#
|
||||
# Reuse matches on labels only — we deliberately do NOT compare image
|
||||
# / mounts / resources. Operators who need a fresh container after
|
||||
# changing those settings should set ``docker_persist_across_processes:
|
||||
# false`` (or run ``docker rm -f`` against the labeled container) to
|
||||
# force a clean start.
|
||||
# Reuse matches on labels only. The egress posture gets its own label
|
||||
# because env vars, CA mounts, and host mappings are immutable after
|
||||
# container creation — reusing a pre-egress or pre-rotation container
|
||||
# would silently bypass the credential firewall.
|
||||
reused = False
|
||||
if persist_across_processes:
|
||||
existing = self._find_reusable_container(task_label, profile_name)
|
||||
existing = self._find_reusable_container(
|
||||
task_label, profile_name, egress_label,
|
||||
)
|
||||
if existing is not None:
|
||||
container_id, state = existing
|
||||
self._container_id = container_id
|
||||
|
|
@ -1294,7 +1422,9 @@ class DockerEnvironment(BaseEnvironment):
|
|||
# 1. Try label-based reuse (another process may have recreated it).
|
||||
task_label = self._labels.get("hermes-task-id", "")
|
||||
profile_label = self._labels.get("hermes-profile", "")
|
||||
existing = self._find_reusable_container(task_label, profile_label)
|
||||
existing = self._find_reusable_container(
|
||||
task_label, profile_label, self._labels.get(_EGRESS_LABEL_KEY, "off"),
|
||||
)
|
||||
if existing is not None:
|
||||
cid, state = existing
|
||||
if state == "running":
|
||||
|
|
@ -1419,7 +1549,12 @@ class DockerEnvironment(BaseEnvironment):
|
|||
logger.debug("Docker --storage-opt support: %s", _storage_opt_ok)
|
||||
return _storage_opt_ok
|
||||
|
||||
def _find_reusable_container(self, task_label: str, profile_label: str) -> Optional[tuple[str, str]]:
|
||||
def _find_reusable_container(
|
||||
self,
|
||||
task_label: str,
|
||||
profile_label: str,
|
||||
egress_label: str,
|
||||
) -> Optional[tuple[str, str]]:
|
||||
"""Look for an existing container labeled for this (task, profile).
|
||||
|
||||
Returns ``(container_id, state)`` on hit, ``None`` on miss / on any
|
||||
|
|
@ -1433,12 +1568,17 @@ class DockerEnvironment(BaseEnvironment):
|
|||
started by some other tool.
|
||||
"""
|
||||
try:
|
||||
filters = [
|
||||
"--filter", "label=hermes-agent=1",
|
||||
"--filter", f"label=hermes-task-id={task_label}",
|
||||
"--filter", f"label=hermes-profile={profile_label}",
|
||||
]
|
||||
if egress_label != "off":
|
||||
filters.extend(["--filter", f"label={_EGRESS_LABEL_KEY}={egress_label}"])
|
||||
result = subprocess.run(
|
||||
[
|
||||
self._docker_exe, "ps", "-a",
|
||||
"--filter", "label=hermes-agent=1",
|
||||
"--filter", f"label=hermes-task-id={task_label}",
|
||||
"--filter", f"label=hermes-profile={profile_label}",
|
||||
*filters,
|
||||
"--format", "{{.ID}}\t{{.State}}",
|
||||
],
|
||||
capture_output=True,
|
||||
|
|
|
|||
|
|
@ -300,8 +300,8 @@ The CLI uses argparse, so `--help` is a good first probe for "did my new flag re
|
|||
|
||||
## See also
|
||||
|
||||
- User-facing setup + troubleshooting: [Egress proxy](../user-guide/egress/iron-proxy.md)
|
||||
- Docker backend internals: [Docker](../user-guide/docker.md)
|
||||
- Bitwarden Secrets Manager integration: [`hermes secrets bitwarden`](../user-guide/secrets/bitwarden.md)
|
||||
- CLI command reference: [`hermes egress`](../reference/cli-commands.md#hermes-egress)
|
||||
- Sandbox-injected environment variables: [Egress proxy (sandbox-injected)](../reference/environment-variables.md#egress-proxy-sandbox-injected)
|
||||
- User-facing setup + troubleshooting: [Egress proxy](https://hermes-agent.nousresearch.com/docs/user-guide/egress/iron-proxy)
|
||||
- Docker backend internals: [Docker](https://hermes-agent.nousresearch.com/docs/user-guide/docker)
|
||||
- Bitwarden Secrets Manager integration: [`hermes secrets bitwarden`](https://hermes-agent.nousresearch.com/docs/user-guide/secrets/bitwarden)
|
||||
- CLI command reference: [`hermes egress`](https://hermes-agent.nousresearch.com/docs/reference/cli-commands#hermes-egress)
|
||||
- Sandbox-injected environment variables: [Egress proxy (sandbox-injected)](https://hermes-agent.nousresearch.com/docs/reference/environment-variables#egress-proxy-sandbox-injected)
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ hermes config set terminal.backend docker # Docker isolation
|
|||
hermes config set terminal.backend ssh # Remote server
|
||||
```
|
||||
|
||||
For Docker sandboxes, you can also enable the **egress credential-injection proxy** so the sandbox never sees your real API keys — only opaque proxy tokens that work exclusively from behind a local TLS-intercepting daemon. See [Egress proxy](../user-guide/egress/iron-proxy.md). Setup is `hermes egress setup && hermes egress start`; the Docker backend wires everything up automatically once `proxy.enabled` flips on.
|
||||
For Docker sandboxes, you can also enable the **egress credential-injection proxy** so the sandbox never sees your real API keys — only opaque proxy tokens that work exclusively from behind a local TLS-intercepting daemon. See [Egress proxy](../user-guide/egress/iron-proxy.md). Setup is `hermes egress setup && hermes egress start`; `hermes setup terminal` also points Docker users at it. Modal, SSH, Daytona, and Singularity are not wired yet.
|
||||
|
||||
### Voice mode
|
||||
|
||||
|
|
|
|||
|
|
@ -622,13 +622,13 @@ hermes egress setup --no-bitwarden # bitwarden → env
|
|||
|
||||
# Rotating all tokens (e.g. after a suspected token leak)
|
||||
hermes egress setup --rotate-tokens
|
||||
hermes egress stop && hermes egress start # restart daemon to pick up new mappings
|
||||
hermes egress start # setup stops a stale daemon; start it again
|
||||
# (running sandboxes still hold old tokens; restart them too)
|
||||
|
||||
# Adding a new upstream
|
||||
# Edit ~/.hermes/config.yaml proxy.extra_allowed_hosts: [api.example.com]
|
||||
hermes egress setup
|
||||
hermes egress stop && hermes egress start
|
||||
hermes egress start
|
||||
```
|
||||
|
||||
### Diagnostic shortcuts
|
||||
|
|
|
|||
|
|
@ -239,13 +239,14 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
|
|||
|
||||
## Egress proxy (sandbox-injected)
|
||||
|
||||
These env vars are NOT set on the host — they're injected into Docker sandboxes by the [Egress proxy](../user-guide/egress/iron-proxy.md) integration when `proxy.enabled: true`. The agent code reads them instead of real API keys.
|
||||
These env vars are NOT set on the host — they're injected into Docker sandboxes by the [Egress proxy](../user-guide/egress/iron-proxy.md) integration when `proxy.enabled: true`. Docker is the only wired backend in this release.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `HERMES_EGRESS_PROXY` | Set to `1` inside a sandbox when the egress proxy is active. Agent code can check this to know it's running behind a TLS-intercepting proxy. |
|
||||
| `HERMES_PROXY_TOKEN_<ENV_NAME>` | One per minted provider mapping. E.g. `HERMES_PROXY_TOKEN_OPENROUTER_API_KEY=hermes-proxy-openrouter-…`. The sandbox uses these in the `Authorization: Bearer` header; iron-proxy swaps them for the real upstream secret at the network boundary. |
|
||||
| `HTTPS_PROXY` / `HTTP_PROXY` | Set to `http://host.docker.internal:<tunnel_port>` so every standard HTTP client routes through iron-proxy. |
|
||||
| Provider env vars (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, …) | Set to opaque proxy tokens, not real upstream secrets, so existing SDKs keep reading the standard env names. iron-proxy swaps those tokens for the real upstream secret at the network boundary. |
|
||||
| `HERMES_PROXY_TOKEN_<ENV_NAME>` | Diagnostic alias for each minted provider mapping. E.g. `HERMES_PROXY_TOKEN_OPENROUTER_API_KEY=hermes-proxy-openrouter-…`. Same token value as the standard provider env var. |
|
||||
| `HTTPS_PROXY` / `HTTP_PROXY` | `HTTPS_PROXY` points at `http://host.docker.internal:<tunnel_port>` for CONNECT/MITM. `HTTP_PROXY` points at `<tunnel_port + 1>` for plain-HTTP forwarding. |
|
||||
| `NO_PROXY` | `127.0.0.1,localhost,::1` so loopback dev servers inside the sandbox bypass the proxy. |
|
||||
| `REQUESTS_CA_BUNDLE` / `SSL_CERT_FILE` / `CURL_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` | Path to the mounted Hermes egress CA cert inside the sandbox (`/etc/ssl/certs/hermes-egress-ca.crt`). Lets the language runtimes trust iron-proxy's MITM-minted leaf certs. |
|
||||
| `NODE_OPTIONS` | Appended with `--use-openssl-ca` (your existing flags are preserved) so Node.js routes through the OpenSSL store the other CA-bundle vars control. Narrows the [Node.js asymmetric CA caveat](../user-guide/egress/iron-proxy.md#nodejs-asymmetric-ca-caveat). |
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
|||
| `/subgoal <text>` | Append a user-supplied criterion to the active goal mid-loop. The continuation prompt surfaces all subgoals to the agent verbatim, and the judge factors them into its DONE/CONTINUE verdict — so the goal isn't marked done until the original goal **and** every subgoal are met. Subcommands: `/subgoal` (list), `/subgoal remove <N>`, `/subgoal clear`. Requires an active `/goal`. |
|
||||
| `/resume [name]` | Resume a previously-named session |
|
||||
| `/sessions` (TUI alias: `/switch`) | Classic CLI: browse and resume previous sessions in an interactive picker. TUI: open the live session switcher for currently open TUI sessions. Use `/sessions new` in the TUI to start another live session immediately. |
|
||||
| `/egress [status]` | Show Docker egress proxy status — enabled/configured/running state, credential source, token mappings, uncovered providers, and next remediation step. Works in CLI, TUI, Desktop chat, and messaging gateway. |
|
||||
| `/redraw` | Force a full UI repaint (recovers from terminal drift after tmux resize, mouse selection artifacts, etc.) |
|
||||
| `/status` | Show session info — model, provider, profile, session ID, working directory, title, created/updated timestamps, token totals, agent-running state — followed by a local **Session recap** block (recent user/assistant turn counts, tool result count, top tools used, last few files touched, the latest user prompt, and the latest assistant reply). The recap is computed locally from the in-memory conversation; no LLM call, no prompt-cache impact. |
|
||||
| `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. |
|
||||
|
|
@ -239,7 +240,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
|||
- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, and `/quit` are **CLI-only** commands.
|
||||
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
|
||||
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, and `/commands` are **messaging-only** commands.
|
||||
- `/status`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
|
||||
- `/status`, `/egress`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
|
||||
- `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord.
|
||||
- In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume <id-or-title>` for saved or closed transcripts.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
# Egress credential-injection proxy (iron-proxy)
|
||||
|
||||
When Hermes runs your agent inside a remote terminal sandbox — Docker, Modal, SSH — that sandbox normally holds your real upstream API keys (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, etc.). A prompt-injected agent in that sandbox can `cat ~/.config/openrouter/auth.json` or `printenv | grep -i key` and exfiltrate them.
|
||||
When Hermes runs your agent inside a Docker terminal sandbox, that sandbox normally holds your real upstream API keys (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, etc.). A prompt-injected agent in that sandbox can `cat ~/.config/openrouter/auth.json` or `printenv | grep -i key` and exfiltrate them.
|
||||
|
||||
The egress proxy fixes this: the sandbox holds opaque **proxy tokens**, never the real keys. All outbound traffic from the sandbox routes through a local [iron-proxy](https://github.com/ironsh/iron-proxy) daemon (Apache-2.0, Go) on the host, which terminates TLS and swaps the proxy token for the real credential before forwarding the request upstream. Compromise the sandbox and the attacker walks away with tokens that only work from behind the proxy.
|
||||
|
||||
This page covers the Docker backend, which is what v1 ships. Modal, Daytona, and SSH wiring will follow in later releases.
|
||||
This release wires the egress proxy into the Docker backend only. Modal, Daytona, SSH, and Singularity do **not** receive proxy env vars or CA mounts yet.
|
||||
|
||||
## What it is
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ This page covers the Docker backend, which is what v1 ships. Modal, Daytona, and
|
|||
- A `proxy.yaml` config at `~/.hermes/proxy/proxy.yaml` listing the upstream hosts you allow and the secrets-transform mapping
|
||||
- A `mappings.json` recording which proxy token corresponds to which real env var
|
||||
|
||||
The sandbox gets `HTTPS_PROXY=http://host.docker.internal:9090` plus a set of `HERMES_PROXY_TOKEN_<ENV_NAME>` env vars. The agent code reads those tokens instead of the real API keys. iron-proxy's `secrets` transform matches the token in the `Authorization` header and substitutes the real value sourced from its own environment.
|
||||
The sandbox gets `HTTPS_PROXY=http://host.docker.internal:9090`, `HTTP_PROXY=http://host.docker.internal:9091`, and standard provider env vars such as `OPENROUTER_API_KEY` set to opaque proxy tokens. Matching `HERMES_PROXY_TOKEN_<ENV_NAME>` aliases are also exported for diagnostics. Existing provider SDKs read the usual env names, send the proxy token in `Authorization`, and iron-proxy's `secrets` transform substitutes the real value sourced from the host-side daemon environment.
|
||||
|
||||
## What it is not
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ Once running, the Docker terminal backend automatically:
|
|||
- Sets `HTTPS_PROXY`, `HTTP_PROXY`, `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS` to make every common HTTP runtime route through the proxy and trust the CA
|
||||
- Sets `NODE_OPTIONS=--use-openssl-ca` (appended to whatever you already have in `docker_env.NODE_OPTIONS`) so Node.js routes through the OpenSSL store the other CA-bundle vars control — see [Node.js asymmetric CA caveat](#nodejs-asymmetric-ca-caveat) below for the residual gap
|
||||
- Adds `--add-host=host.docker.internal:host-gateway` so the sandbox can reach the host-side proxy on Linux (Docker Desktop handles this automatically on macOS/Windows)
|
||||
- Exports one `HERMES_PROXY_TOKEN_<ENV_NAME>` per minted mapping
|
||||
- Exports the proxy token under the standard provider env name (for example `OPENROUTER_API_KEY`) plus one `HERMES_PROXY_TOKEN_<ENV_NAME>` diagnostic alias per minted mapping
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -289,13 +289,13 @@ Non-tty invocations (CI, scripts) skip the prompt — the flag is treated as del
|
|||
backup: ~/.hermes/proxy/mappings.json.rotated-20260524T143012
|
||||
```
|
||||
|
||||
**Caveat:** rotating tokens DOES NOT automatically restart iron-proxy. The running daemon still has the old mappings in memory (and the old YAML). After `--rotate-tokens`:
|
||||
`hermes egress setup` stops a running daemon when it rewrites config or token mappings, because the daemon keeps the old YAML in memory. After `--rotate-tokens`:
|
||||
|
||||
```bash
|
||||
hermes egress stop && hermes egress start
|
||||
hermes egress start
|
||||
```
|
||||
|
||||
Containers already running hold the old tokens and will need to be restarted to pick up the new ones.
|
||||
Containers already running hold the old tokens and will need to be restarted to pick up the new ones. New persistent Docker containers include an egress-posture label, so Hermes will not reuse a pre-egress or pre-rotation container for new sessions.
|
||||
|
||||
## State directory layout
|
||||
|
||||
|
|
@ -377,7 +377,8 @@ When the Docker backend starts a container with `proxy.enabled: true` and the da
|
|||
| `-e NODE_EXTRA_CA_CERTS=…ca.crt` | Node.js — **adds** to the system store |
|
||||
| `-e NODE_OPTIONS="<your value> --use-openssl-ca"` | Node.js — route through OpenSSL store (appended; your `--max-old-space-size` etc. are preserved) |
|
||||
| `-e HERMES_EGRESS_PROXY=1` | Sentinel the agent can read to know it's proxy-aware |
|
||||
| `-e HERMES_PROXY_TOKEN_<NAME>=…` | One per mapping; the sandbox uses these instead of real keys |
|
||||
| `-e OPENROUTER_API_KEY=<proxy-token>` | Standard provider env names receive proxy tokens so existing SDKs keep working |
|
||||
| `-e HERMES_PROXY_TOKEN_<NAME>=…` | Diagnostic alias for each mapping; same value as the standard provider env var |
|
||||
| `--add-host=host.docker.internal:host-gateway` | Linux-only; Docker Desktop maps it automatically |
|
||||
|
||||
#### Node.js asymmetric CA caveat
|
||||
|
|
@ -437,6 +438,7 @@ If the nonce check fails, the code falls back to matching `argv[0]` basename aga
|
|||
|
||||
- A compromised host process. If the agent process itself is compromised, real keys in the host's `~/.hermes/.env` are exposed regardless. This is a defense-in-depth feature for *sandbox* compromise, not host compromise.
|
||||
- Sandbox processes that bypass `HTTPS_PROXY` by using a raw socket. The proxy can't intercept what doesn't route to it. Node.js is partially mitigated via `NODE_OPTIONS=--use-openssl-ca` (see caveat above).
|
||||
- Credential files explicitly mounted into Docker (`terminal.credential_files` or skill-registered mounts). Egress protects provider env vars; it does not inspect arbitrary mounted files. Do not mount real provider credentials into an enforced egress sandbox.
|
||||
- Allowlisted-host data exfiltration. If `api.openai.com` is allowed, an agent could embed exfil data in a request body to that host. The daemon log captures the request happened but doesn't prevent it.
|
||||
- Uncovered providers (Anthropic native, AWS Bedrock, Azure OpenAI, Gemini). Their env vars stay in the sandbox; if you enable them, those credentials bypass the proxy entirely. See [Uncovered providers](#uncovered-providers).
|
||||
- iron-proxy in-memory secret zeroisation. The Go binary holds swapped-in real credentials in process memory; a core-dump or `/proc/<pid>/mem` read from a same-uid attacker would expose them. Out of scope for this layer.
|
||||
|
|
@ -445,12 +447,14 @@ If the nonce check fails, the code falls back to matching `argv[0]` basename aga
|
|||
|
||||
- **Binary not installed, `auto_install: true`** — first `hermes egress setup` or `hermes egress start` downloads it. SHA-256 verified against the upstream `checksums.txt`.
|
||||
- **Binary not installed, `auto_install: false`** — `start` fails with a clear message pointing to manual install.
|
||||
- **`enabled: true` but proxy not running** — with `enforce_on_docker: true` (default), Docker sandbox creation refuses to start with an explanatory error. With `enforce: false`, it falls back to direct outbound with real creds and logs a warning.
|
||||
- **`enabled: true` but proxy not running** — with `enforce_on_docker: true` (default), Docker sandbox creation refuses to start with an explanatory error. With `enforce_on_docker: false`, it falls back to direct outbound with real creds and logs a warning.
|
||||
- **Port collision** — iron-proxy exits immediately; `hermes egress start` reports the last 20 log lines and fails with non-zero exit.
|
||||
- **Upstream-host denied** — sandbox gets HTTP 403 from the proxy with a body explaining which host wasn't allowed. The agent sees the error and reports it.
|
||||
- **Cloud metadata IP (169.254.169.254) requested** — refused by `upstream_deny_cidrs` regardless of allowlist.
|
||||
- **Strict-tier uncovered provider env var set** — `hermes egress start` refuses with a list of the offending env vars and the `proxy.fail_on_uncovered_providers: false` escape hatch.
|
||||
- **`docker_env` collides with a proxy-controlling var (enforce on)** — sandbox creation refuses with the names of the colliding keys.
|
||||
- **`docker_forward_env` tries to forward a protected provider key (enforce on)** — sandbox creation refuses; remove the key from `docker_forward_env` or opt out with `proxy.enforce_on_docker: false`.
|
||||
- **`docker_extra_args` overrides proxy env/network controls (enforce on)** — sandbox creation refuses; user-supplied `-e HTTPS_PROXY=...`, `--env-file`, or `--network` args run after Hermes' generated args and can bypass egress.
|
||||
- **BWS access token missing in `credential_source: bitwarden`** — `hermes egress start` refuses with `--no-bitwarden` as the recovery hint.
|
||||
- **iron-proxy doesn't bind within 5 seconds** — process is killed, pidfile unlinked, error names the port + tail of `iron-proxy.log`.
|
||||
- **Concurrent `hermes egress start` calls** — second call refuses with "another start in progress" if the first's daemon is up; otherwise the second unlinks the stale pidfile and proceeds.
|
||||
|
|
@ -579,7 +583,7 @@ When the pinned version moves to v0.40+ (which adds `log.audit_path`), per-reque
|
|||
- Only bearer-token providers (OpenRouter, OpenAI, Anthropic-via-OR, etc.) are wired through the `secrets` transform out of the box. Providers with custom auth (x-api-key, query params, signatures) bypass the proxy entirely — see [Uncovered providers](#uncovered-providers).
|
||||
- No native Windows binary upstream. Run on Linux / macOS / WSL.
|
||||
- The CA is a 10-year self-signed cert on first generation. Rotation requires `openssl genrsa ...` by hand (or wait for a follow-up that adds `hermes egress rotate-ca`).
|
||||
- Token rotation does not auto-restart the daemon; after `--rotate-tokens` you must `hermes egress stop && hermes egress start` and then restart running sandboxes.
|
||||
- Re-running setup stops a running daemon after rewriting config or mappings; run `hermes egress start` again, and restart already-running sandboxes after token rotation.
|
||||
- iron-proxy in-memory secret zeroisation is upstream-controlled. Same-uid attackers with `/proc/<pid>/mem` read access can read swapped-in secrets from the daemon's memory.
|
||||
- iron-proxy v0.39 only supports a **single bind per daemon** (we bind the docker bridge gateway on Linux, loopback on Docker Desktop) and combines daemon + per-request records into a single log stream. When upstream adds `proxy.http_listens` (plural) and `log.audit_path`, a version bump can wire in multi-bind and the dedicated audit stream.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue