Commit Graph

2 Commits

Author SHA1 Message Date
teknium1 fa4e87b253 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.
2026-05-24 04:22:53 -07:00
teknium1 128a6837b7 fix(egress): address PR review findings — P0/P1/P2/P3 + CI greens
P0 — must-fix
- iron_proxy: emit default upstream_deny_cidrs (loopback, IMDS
  169.254.0.0/16, RFC1918) when caller passes None.  Honours the docs
  promise that cloud-metadata IPs are refused regardless of allowlist.
- iron_proxy: bind 127.0.0.1 (+ docker0 bridge IP on Linux) instead of
  INADDR_ANY (':9090').  LAN peers with a leaked sandbox token could
  otherwise spend the operator's API quota against any allowlisted
  upstream.
- ensure_ca_cert: write the CA private key via os.open(..., 0o600)
  instead of shutil.copy2+os.chmod — closes the TOCTOU window where
  the key existed under the default umask.
- discover_uncovered_providers + proxy.fail_on_uncovered_providers
  config: refuse to start (when strict) if env vars for non-bearer
  providers (Anthropic native x-api-key, AWS SigV4, Azure OpenAI,
  etc.) are present.  Surfaces a wizard warning in non-strict mode.

P1 — should-fix
- start_proxy: build a minimal subprocess env (PATH/HOME/locale +
  only the env names referenced by mappings) instead of os.environ
  .copy().  Strips proxy-recursion vars (HTTPS_PROXY etc.).  Stops
  the proxy's /proc/<pid>/environ from leaking every host secret
  to same-uid local processes.
- start_proxy: optional Bitwarden refresh path
  (refresh_secrets_from_bitwarden=True, bitwarden_config=...).
  When credential_source=bitwarden, cmd_start wires it in — that's
  what delivers the rotation guarantee the docs make.
- build_proxy_config: wire audit_log into the rendered yaml
  (log.audit_path).  Parameter was accepted but never used.
- ensure_audit_log: pre-create the audit log with 0o600 perms so
  iron-proxy inherits tight permissions instead of relying on umask.
- Rename 'hermes proxy ...' → 'hermes egress ...' in user-facing
  strings (docstring, RuntimeError messages, post-setup banner).
- start_proxy: open log file with 0o600 perms and close the parent
  fd immediately after Popen — fixes the per-restart fd leak.
- DockerEnvironment: detect collisions between docker_env and the
  egress-controlling env vars (HTTPS_PROXY, SSL_CERT_FILE, etc.).
  When enforce_on_docker=true, fail loud rather than silently
  inverting the isolation; when false, warn and let docker_env win.
- proxy_cli: merge_mappings preserves existing tokens on re-setup;
  --rotate-tokens flag re-mints all of them.  Stops re-running
  `hermes egress setup` from invalidating tokens baked into
  already-running sandboxes.
- proxy_cli: --from-bitwarden fail-loud on disabled BW config,
  missing access token, or empty vault.  Previously fell through to
  the env path while still writing credential_source: bitwarden.
- docker.py: narrow `except Exception` → `except ImportError`;
  iron_proxy._read_tunnel_port_from_config: same.  Bare excepts
  were masking real config-load bugs.
- start_proxy: write pidfile via os.open with O_NOFOLLOW + 0o600
  + st_uid check.  Refuses to follow a pre-existing symlink at the
  pidfile path.
- mint_proxy_token docstring: document the 128-bit suffix entropy
  explicitly (sha256 truncated to 32 hex chars).

P2 — follow-up
- start_proxy: poll-with-timeout (100ms cadence on _port_listening)
  instead of an unconditional 5s sleep.  Saves several seconds per
  Docker container create when enforce_on_docker=true.
- docker.py: apply enforce_on_docker semantics when CA file vanishes
  between status.configured check and CA mount.  Previously returned
  empty args silently.
- docker.py: refuse to mount when mappings.json is empty/corrupt
  (was indistinguishable from upstream outage from inside the
  sandbox).
- install_iron_proxy: tarfile.extract(..., filter='data') to silence
  the PEP 706 deprecation and opt into the 3.14+ default.
- _proxy_state_dir: chmod 0o700 unconditionally; add
  _proxy_state_dir_ro() so read-only callers don't create the dir.
- stop_proxy: re-verify pid before SIGKILL via /proc/<pid>/stat
  starttime AND _pid_alive.  Prevents SIGKILL'ing a recycled pid.
- _pid_alive: tightened cmdline check — basename match on argv[0]
  plus an in-process nonce env var ('iron-proxy' in cmdline matched
  'tail iron-proxy.log' and editors with the log open).
- docker.py: NODE_OPTIONS=--use-openssl-ca so Node.js routes through
  the OpenSSL CA store SSL_CERT_FILE controls, narrowing the
  Python/curl-replace vs Node-add asymmetry waefrebeorn flagged.

P3 — polish
- proxy_cli: dest='egress_command' (was 'proxy_command' which
  collided lexically with the inbound OAuth subparser).
- iron_proxy_version: cache by binary path — get_status is called
  per Docker container create, version is constant per binary.
- Drop unused `import sys` from iron_proxy.
- proxy_cli: `is not None` check on --tunnel-port (was treating 0
  as falsy and silently substituting the default).
- proxy_cli cmd_disable: use get_status().pid instead of reaching
  into ip._read_pid() (stale pidfile from a crashed run would have
  fired a spurious "still running" warning).
- Tests: replace hardcoded /tmp/ca.* paths with tmp_path-derived
  fixtures so tests are hermetic across hosts.

CI
- Windows footguns scanner: os.kill(pid, 0) is now gated behind
  platform.system() != 'Windows' with a windows-footgun: ok marker;
  signal.SIGKILL falls back to SIGTERM on Windows via
  getattr(signal, 'SIGKILL', signal.SIGTERM).
- docs MDX compilation: replace bare `<https://…>` URLs with
  `[text](url)` syntax (MDX-jsx parser rejects the angle-bracket
  form).

Tests
- 32 new tests covering default deny CIDRs, bind policy, audit log
  wiring, subprocess env minimization, CA TOCTOU 0o600, state dir
  0o700, empty-mappings refusal, CA-vanished refusal, docker_env
  collision detection, token preservation/rotate, uncovered provider
  detection, and the proxy_cli command handlers + argparse wiring.
- All 156 tests in test_iron_proxy + test_iron_proxy_cli +
  test_docker_environment + test_config pass locally.

Acknowledged but not addressed in this revision
- E2E test for HTTPS CONNECT + TLS-MITM path: existing E2E exercises
  plain HTTP; full MITM coverage needs separate CI infra (real iron-
  proxy binary + curl with custom CA).  Tracked as follow-up.
- Cosign-style supply-chain verification for the binary checksum:
  upstream iron-proxy doesn't sign releases yet.  Accepted pattern
  (same as Bitwarden integration); tracked as follow-up.
- CA rotation CLI (`hermes egress rotate-ca`): scope-cut to a
  follow-up.

Reviewers: @annguyenNous @waefrebeorn @GodsBoy @erhnysr
2026-05-23 20:38:27 -07:00