mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
fix(egress): v3 round — GodsBoy/stephenschoettler/arshkumarsingh findings
GodsBoy 2nd-round P1 (all 4 addressed):
- _detect_docker_bridge_ip: replace `ip.count('.') == 3` heuristic with
ipaddress.IPv4Address validation + reject unspecified/loopback/multicast/
reserved/link-local/global addresses. Hostile `ip` shim on PATH used to
be able to inject 0.0.0.0 here and re-open INADDR_ANY binding.
- cmd_setup credential_source preservation: re-running `hermes egress
setup` without --from-bitwarden no longer silently downgrades a previous
bitwarden config back to env. Require --no-bitwarden to switch
explicitly; otherwise preserve the existing mode and surface the
decision.
- fail_on_uncovered_providers docstring/default mismatch: docstring used
to claim default=True; behavior was default=False. Resolved by
truth-in-advertising — docstring now correctly states default=False —
AND splitting providers into a strict LLM-specific tier
(_LLM_SPECIFIC_NON_BEARER_PROVIDERS, used by start blocking) and a
generic uncovered tier (used by wizard warnings). Generic cloud creds
(AWS_*, GOOGLE_APPLICATION_CREDENTIALS) no longer trip refuse-start
for operators using terraform/gcloud alongside Hermes. New
discover_blocked_providers() returns the strict subset.
- start_proxy poll-loop must verify listening before pidfile:
previously fell through deadline-expired as success and wrote a
pidfile for a non-listening daemon. Refactored into a do-while
shape, require `listening=True` for success, kill the child + unlink
the pidfile on failure paths.
GodsBoy 2nd-round P2 (the worth-keeping subset):
- O_NOFOLLOW + 0o600 + st_uid check on iron-proxy.log open (symmetric
with the pidfile and audit-log paths the same PR hardens).
- pidfile O_EXCL: refactored pidfile-write into _write_pidfile_safely
which uses O_EXCL to detect concurrent starts. EEXIST with a live
pid means "another start in progress" — refuse with actionable
message; EEXIST with a dead pid means "stale crash" — unlink and
retry once. Discriminates rather than racing.
- _VERSION_CACHE: invalidate on install_iron_proxy success;
don't cache empty stdout (would poison `hermes egress status` for
the lifetime of the process if first probe hit a corrupt binary).
- ensure_audit_log now RAISES on OSError instead of swallowing it as
a warning. Previous behavior let the daemon create the file under
the default umask, exactly the world-readable scenario the helper
was built to prevent. cmd_setup catches the new RuntimeError and
surfaces "✗" with the actionable message.
- SIGINT/SIGTERM handler scoped around the start_proxy poll loop:
Ctrl-C while waiting for `hermes egress start` no longer leaks an
orphan daemon with the port bound. Handler kills the child +
unlinks the pidfile before re-raising.
- pidfile written IMMEDIATELY after Popen, BEFORE the listening
verification. Parent dying during the poll loop now leaves a
pidfile pointing at the orphan so the next `hermes egress stop` can
clean up. Failure paths in the poll loop explicitly unlink.
- _DEFAULT_UPSTREAM_DENY_CIDRS: add ::ffff:0:0/96 (IPv4-mapped IPv6 —
closes the v6-resolved IMDS bypass), 100.64.0.0/10 (CGNAT / cloud
overlays / K8s pod networks), 198.18.0.0/15 (RFC2544 benchmark).
- _NON_BEARER_PROVIDERS split into LLM-specific (Anthropic / Azure /
Gemini — block when strict) vs generic-cloud (AWS_*, GCP appdefault
— warn-only).
- docker.py except narrowing: load_config can raise yaml.YAMLError on
a malformed config.yaml, not just ImportError. Two callsites
(collision check + precedence resolution) now catch yaml.YAMLError
via a sentinel `import yaml` and fail-safe to enforced mode.
GodsBoy 2nd-round P3:
- _reset_for_tests: was a no-op claiming symmetry with bitwarden;
now actually clears _VERSION_CACHE and _proxy_nonce so in-process
callers (notebooks, pytest -p no:xdist) don't see state leakage.
- tests/test_iron_proxy_cli.py: replaced hardcoded Path("/tmp/...")
with hermes_home/-derived fixtures. Matches the same cleanup we
did for test_iron_proxy.py in the previous round.
- --rotate-tokens confirmation gate: when there are existing tokens,
prompt for "rotate" confirmation (skipped when stdin isn't a tty
so CI/scripted use still works) AND back up the mappings to a
timestamped sibling before overwriting. Surface a no-op note when
rotate is requested with no existing tokens.
stephenschoettler (runtime-boundary review):
- #1 BWS silent degrade at proxy start: when credential_source=bitwarden
but the BWS access token or project_id is missing OR the fetch
returns no values for mapped providers, raise instead of silently
falling back to host env. cmd_start also pre-checks at the wizard
layer for actionable error messages. Opt-in escape hatch via new
`proxy.allow_env_fallback: true` config for migration scenarios.
- #2 docker_env collision detection extended: `docker_env:
{OPENROUTER_API_KEY: sk-real}` in config.yaml with enforce_on_docker:
true now raises just like an HTTPS_PROXY collision would. The
collision check pulls mapped provider names from load_mappings() at
call time.
- #3 PID nonce persisted to disk: cross-CLI-invocation stale-pidfile
defense now works. start_proxy writes the nonce next to the pidfile
(sibling 0o600), stop_proxy reads it back via _read_persisted_nonce()
and uses it as a _pid_alive signal in the new process. Falls back
to argv0 basename matching when the file is missing (legacy install).
arshkumarsingh:
- #1 NODE_OPTIONS append-merge: egress dict no longer sets NODE_OPTIONS
directly (would clobber the operator's --max-old-space-size etc.).
Carry the egress flag in a sentinel key
_HERMES_EGRESS_NODE_OPTIONS_APPEND; DockerEnvironment merges into the
existing NODE_OPTIONS in env_args computation with de-duplication.
- #2 docs: structured per-request audit log is at audit.log, not
iron-proxy.log (the latter is daemon stdout/stderr). Diagram and
step-7 text corrected; both file roles are now documented separately.
Tests
- Added 12 new tests in test_iron_proxy.py covering bridge-IP rejection
(parametrized over 8 dangerous inputs), default deny-list adjacency
(IPv4-mapped-v6 + CGNAT), blocked-providers strict-subset property,
_pid_proc_starttime parser with paren-containing comm,
stop_proxy SIGKILL suppression on starttime drift, _reset_for_tests
clear behavior, iron_proxy_version don't-cache-empty, NODE_OPTIONS
sentinel verification, ensure_audit_log raise-on-OSError, and
persisted-nonce roundtrip.
- Added 1 new test in test_iron_proxy_cli.py covering cmd_start
BWS-token-missing fail-loud.
- All 100 tests in test_iron_proxy + test_iron_proxy_cli pass; all 78
tests in test_docker_environment + test_config still pass.
Acknowledged but not addressed:
- GodsBoy P3 dead-code `extra_env` kwarg: kept (removing is a breaking
change for any out-of-tree caller; the kwarg is documented and works).
- Residual risks GodsBoy called out: iron-proxy in-memory secret
zeroisation (Go-binary territory, out of scope); _PROXY_SUBPROCESS_ENV
_ALLOWLIST cosmetic gaps (RUST_LOG, GOMAXPROCS); follow-up.
This commit is contained in:
parent
4833acf046
commit
fa4e87b253
7 changed files with 1086 additions and 105 deletions
|
|
@ -57,7 +57,7 @@ def _args(**overrides):
|
|||
|
||||
|
||||
def test_cmd_install_success_returns_0(hermes_home, monkeypatch):
|
||||
monkeypatch.setattr(ip, "install_iron_proxy", lambda **kw: Path("/tmp/iron-proxy"))
|
||||
monkeypatch.setattr(ip, "install_iron_proxy", lambda **kw: hermes_home / "iron-proxy")
|
||||
monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "v0.39.0-test")
|
||||
rc = proxy_cli.cmd_install(_args())
|
||||
assert rc == 0
|
||||
|
|
@ -88,7 +88,7 @@ def test_cmd_setup_from_bitwarden_refuses_when_bw_disabled(hermes_home, monkeypa
|
|||
save_config(cfg)
|
||||
|
||||
# Pre-stub install + CA so we get to step 3.
|
||||
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: Path("/tmp/iron-proxy"))
|
||||
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",
|
||||
|
|
@ -118,7 +118,7 @@ def test_cmd_setup_from_bitwarden_refuses_when_token_missing(hermes_home, monkey
|
|||
save_config(cfg)
|
||||
monkeypatch.delenv("BWS_ACCESS_TOKEN", raising=False)
|
||||
|
||||
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: Path("/tmp/iron-proxy"))
|
||||
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",
|
||||
|
|
@ -144,7 +144,7 @@ def test_cmd_setup_from_bitwarden_refuses_on_empty_vault(hermes_home, monkeypatc
|
|||
save_config(cfg)
|
||||
monkeypatch.setenv("BWS_ACCESS_TOKEN", "bwsk-test-token")
|
||||
|
||||
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: Path("/tmp/iron-proxy"))
|
||||
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",
|
||||
|
|
@ -167,7 +167,7 @@ def test_cmd_setup_rejects_tunnel_port_zero(hermes_home, monkeypatch):
|
|||
the default before the fix)."""
|
||||
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
|
||||
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: Path("/tmp/iron-proxy"))
|
||||
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",
|
||||
|
|
@ -219,8 +219,16 @@ def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwa
|
|||
cfg.setdefault("proxy", {})["enabled"] = True
|
||||
cfg["proxy"]["credential_source"] = "bitwarden"
|
||||
cfg["proxy"]["fail_on_uncovered_providers"] = False
|
||||
cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": True}
|
||||
cfg.setdefault("secrets", {})["bitwarden"] = {
|
||||
"enabled": True,
|
||||
"project_id": "test-proj-id",
|
||||
"access_token_env": "BWS_ACCESS_TOKEN",
|
||||
}
|
||||
save_config(cfg)
|
||||
# v3: cmd_start now pre-checks BWS access token + project_id before
|
||||
# calling start_proxy. Provide both so we get to the rotation
|
||||
# wire-up code path.
|
||||
monkeypatch.setenv("BWS_ACCESS_TOKEN", "bwsk-test-access-token")
|
||||
|
||||
captured: dict = {}
|
||||
def fake_start_proxy(**kw):
|
||||
|
|
@ -232,6 +240,7 @@ def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwa
|
|||
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
|
||||
|
|
@ -239,6 +248,35 @@ def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwa
|
|||
assert captured.get("bitwarden_config") is not None
|
||||
|
||||
|
||||
def test_cmd_start_refuses_when_bitwarden_token_missing(hermes_home, monkeypatch):
|
||||
"""stephenschoettler #1: when credential_source=bitwarden but the
|
||||
access-token env var is empty, cmd_start must fail-loud BEFORE
|
||||
start_proxy can silently fall back to parent env."""
|
||||
|
||||
from hermes_cli.config import load_config, save_config
|
||||
cfg = load_config()
|
||||
cfg.setdefault("proxy", {})["enabled"] = True
|
||||
cfg["proxy"]["credential_source"] = "bitwarden"
|
||||
cfg["proxy"]["fail_on_uncovered_providers"] = False
|
||||
cfg.setdefault("secrets", {})["bitwarden"] = {
|
||||
"enabled": True,
|
||||
"project_id": "test-proj-id",
|
||||
"access_token_env": "BWS_ACCESS_TOKEN",
|
||||
}
|
||||
save_config(cfg)
|
||||
monkeypatch.delenv("BWS_ACCESS_TOKEN", raising=False)
|
||||
|
||||
# Sentinel: start_proxy must NOT be called.
|
||||
def must_not_call(**kw):
|
||||
pytest.fail("start_proxy should not be invoked when BWS token missing")
|
||||
monkeypatch.setattr(ip, "start_proxy", must_not_call)
|
||||
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
|
||||
monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: [])
|
||||
|
||||
rc = proxy_cli.cmd_start(_args())
|
||||
assert rc == 1
|
||||
|
||||
|
||||
def test_cmd_start_does_not_pass_bitwarden_refresh_when_credential_source_is_env(
|
||||
hermes_home, monkeypatch,
|
||||
):
|
||||
|
|
@ -335,7 +373,7 @@ def test_cmd_disable_uses_public_status_pid_not_private_read_pid(
|
|||
|
||||
def test_cmd_config_returns_0_when_present(hermes_home, monkeypatch):
|
||||
fake = ip.ProxyStatus()
|
||||
fake.config_path = Path("/tmp/proxy.yaml")
|
||||
fake.config_path = hermes_home / "proxy.yaml"
|
||||
monkeypatch.setattr(ip, "get_status", lambda: fake)
|
||||
rc = proxy_cli.cmd_config(_args())
|
||||
assert rc == 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue