Skip to content

M1b: codex + Custom credential proxy, WS fail-closed, rotation, VZ parity - #111

Draft
cspinetta wants to merge 13 commits into
claude/vm-agent-credentials-phase-1afrom
claude/vm-agent-credentials-phase-1b
Draft

M1b: codex + Custom credential proxy, WS fail-closed, rotation, VZ parity#111
cspinetta wants to merge 13 commits into
claude/vm-agent-credentials-phase-1afrom
claude/vm-agent-credentials-phase-1b

Conversation

@cspinetta

Copy link
Copy Markdown
Member

Summary

M1b of the credential proxy (RFC-0002), stacked on claude/vm-agent-credentials-phase-1a (M0 + M1a). The proxy holds each provider's durable secret on the host and injects the credential header at TLS egress, so the untrusted guest (uid 1000) carries only a non-secret placeholder. This milestone extends that to codex, the Anthropic-compatible Custom provider, and macOS/VZ, and adds long-run credential rotation.

What's in it

codex through the proxy — both auth modes. Provisioned via a generated $CODEX_HOME/config.toml (a dedicated [model_providers.voidbox] entry — a config entry cannot override the built-in openai provider, whose Responses-over-WebSocket transport is on) plus a placeholder auth.json. API-key mode → api.openai.com/v1 with Authorization: Bearer; ChatGPT OAuth → chatgpt.com/backend-api/codex with a host-minted Bearer + host-held chatgpt-account-id. The per-sandbox token reaches codex via the entry's http_headers and, in API-key mode, embedded in the Bearer placeholder (voidbox-proxy-<hex>); the proxy accepts either carrier, constant-time-compares it, and strips or replaces it before re-originating.

WebSocket fail-closed (R8). The generated config forces supports_websockets = false; the proxy also refuses any protocol-upgrade request after auth with a 502 + a websocket-upgrade-refused audit event, rather than silently stripping the header.

Long-run rotation. ClaudeOAuthStoreOAuthTokenStore, generalized over OAuthProviderKind {ClaudeCode, CodexChatGpt} (enum dispatch owns schema/endpoint/expiry-derivation; codex reads the minted JWT's exp, since OpenAI's refresh response has no expires_in). Refresh coalescing, cross-process flock + adopt-peer, and atomic write-back are shared. Injection stays lazy per request.

Custom provider. https-only + api_key_env-required (parse-time rejects); base URL parsed to host/port/path with the path preserved in the redirect and the port used for re-origination; the provider-emitted ANTHROPIC_BASE_URL is withheld so routing never depends on env ordering.

KVM + VZ parity. credential_proxy_bind_addr binds host loopback on KVM and the host-local VZ NAT gateway (192.168.64.1) on VZ, lifting the Linux-only gate. On VZ the per-sandbox token is the sole cross-sandbox control until the egress-track rule lands (ADR-0007).

R14 no-leak: withholding is keyed on a pure provider check so a servable provider's key is withheld even if setup later errors, and assert_no_real_credential audits every staged file (codex auth.json/config.toml, CA, /etc/hosts).

codex behavior is pinned against codex 0.141.0 and re-verified on every bump (R9) by the provisioning harness.

Validation

Static gate green (fmt, clippy -D warnings, workspace tests, doctests). Beyond that, validated with the M1b test plan (scripts/test_credential_proxy_m1b.sh, docs/testing/credential-proxy-m1b.md) — 15/15 across tiers A–E, 0 failed:

  • A static & unit (both-provider rotation chains, in-process proxy pipeline incl. codex carriers + WS fail-closed) — 5/5
  • B CLI config validation (good specs accepted; Custom-without-https / without-api_key_env rejected) — 3/3
  • C R9 provisioning harness against the real codex 0.141.0 binary — pass
  • D real-upstream injection: Claude API key → live api.anthropic.com, codex Bearer-carried token → live api.openai.com — 2/2
  • E full VM on real Virtualization.framework: e2e_credential_proxy containment, a real Claude completion with the R14 log checks, an adversarial containment probe (real key absent from log + guest output), a Custom-provider completion — 4/4

The subscription-OAuth tier (F) is opt-in and rotates a real login's single-use token; not run in this validation.

Notes for review

  • Stacked on claude/vm-agent-credentials-phase-1a; review that first, or diff against it.
  • The independent correctness/code-quality review pass was done manually (the sub-agents hit a session limit): token-never-reaches-upstream verified across all paths incl. the NotOwned case; injector fail-closed drops the account-id header too; R14 covers all staged files; SSRF applies to the custom port; production binds guest-reachable by default (tests opt into loopback).
  • Three commits: implementation, codex test harnesses, docs + test plan.

cspinetta added 13 commits June 28, 2026 13:52
Squashes the initial M0 build on top of merged origin/main. Adds the shared `src/proxy/` pipeline — per-sandbox name-constrained CA, proxy token, static API-key injector, the TLS-terminating server loop, provider mapping + guest provisioning, and the R14 no-leak gate — wires it into the agent run path (opt-in, default off), adds the proxy dependencies (hyper, rustls, tokio-rustls, rcgen), and the `#[ignore]` e2e scaffold wired into CI.

Merges origin/main: RFC-0002 + ADR-0002–0006 and the RFC/ADR process docs, the quinn-proto 0.11.15 security bump (RUSTSEC-2026-0185), and the agent manifest bumps.
Update RFC-0002 §A so the per-sandbox token's carrier is described for both paths: an `x-voidbox-proxy-token` request header on the base-URL credential path (what M0 implements), and `Proxy-Authorization` on the CONNECT/egress path. The original §A text described only the CONNECT carrier, which never matched the implemented base-URL credential path.

Narrow the M0 rollout to Claude's `x-api-key` only. Defer the Anthropic-compatible Custom provider to M1 — its `env_vars()` already emits a real `ANTHROPIC_BASE_URL`, so redirection would depend on env precedence, and its base URL can carry a path the proxy's `https://host:port` redirect would drop. Defer codex API-key mode to M1b (it needs `config.toml` redirection rather than the env-only path M0 uses).

Delete docs/design/{IMPLEMENTATION_PLAN,credential-broker,egress-policy}.md: they are superseded by RFC-0002 and ADR-0002–0006, and the plan file self-marks "delete before review".

ADR-0004 still records the token on `Proxy-Authorization`/CONNECT; it is left immutable, with RFC §A as the reconciled source.
Bring the M0 credential-injection proxy in line with RFC-0002 and the review.

Rename the proxy's per-run vocabulary to per-sandbox throughout (`SandboxContext`, `SandboxBinding`, `register_sandbox`, …) to match the RFC/ADR language and stop overloading the daemon's existing `Run*` types. Mechanical; no behaviour change.

Add an SSRF guard on the upstream client (`src/proxy/ssrf.rs`): resolve each upstream name once and reject the whole resolution if any address is internal — RFC-1918, loopback, link-local/metadata, IPv6 ULA and link-local, CGNAT, `0.0.0.0/8`, and the benchmarking/protocol-assignment ranges, with IPv4-mapped and IPv4-compatible IPv6 folded to their embedded v4. Set `no_proxy()` so a host `HTTPS_PROXY`/`ALL_PROXY` cannot route re-origination around the guard.

Harden the R14 "no real credential in the guest" gate to audit the complete staged set actually delivered — provider env (post-withholding) + user overrides + the proxy's provisioning env, plus the files the proxy writes — instead of only the proxy's placeholder env, which could never catch a leak. The structural withholding stays the primary control; this gate is the backstop.

Fail closed off Linux: the listener cannot be bound guest-only on macOS/VZ yet, so `credential_proxy` errors there rather than exposing the in-process credential parser to the host LAN (tracked for M1b).

Restrict the served set to Claude, matching the narrowed RFC; Custom and codex are deferred.

Add in-process V1 tests — client-side CA name-constraint enforcement (with a positive control isolating the constraint) and an HTTP/2→HTTP/1.1 downgrade check — and `examples/specs/credential_proxy_claude.yaml` as the real-Claude V1 runbook. Document the in-process-proxy (R10) and snapshot re-mint (R11) deferrals honestly in `src/proxy/mod.rs`.
The guest-agent's `fs_guard` permits host `WriteFile` RPCs only under `/workspace`, `/home`, and `/etc/voidbox`, and rejects `/tmp`. The CA was written to `/tmp/voidbox-proxy-ca.pem`, so the host-driven write failed with "path is outside allowed roots", breaking the E2E KVM `e2e_credential_proxy` job whenever it actually ran (it had been skipping when no backend was available, masking this).

Move `GUEST_CA_PATH` to `/home/sandbox/.voidbox-proxy-ca.pem`, under the allowed `/home` root and readable by the uid-1000 client via `NODE_EXTRA_CA_CERTS`.
The proxy redirects the upstream hostname to the gateway by setting the guest `/etc/hosts`. The host wrote it through the guarded `WriteFile` RPC, but `/etc/hosts` is under `/etc`, outside the guest-agent's `fs_guard` allowed roots (`/workspace`, `/home`, `/etc/voidbox`), so a real run would fail with "path is outside allowed roots". The deterministic e2e hid this by writing `/etc/hosts` with a shell `echo` instead of the real provisioning path.

Stage the rendered hosts file under `/etc/voidbox/hosts` (an allowed root) via the normal `WriteFile`; the guest-agent's handler recognises that path and mirrors the content into `/etc/hosts` with its own privileged (root) write, so the host never needs `fs_guard` access to `/etc` and the write allowlist stays unchanged.

Make `e2e_credential_proxy` exercise the real path (stage `/etc/voidbox/hosts`, then assert the guest-agent mirrored the alias into `/etc/hosts`) so this class of bug can't hide again. `render_guest_hosts`/`GUEST_HOSTS_PATH` move to `src/proxy/provision.rs` so host and test share the exact bytes.
Follow-up hardening on the M0 credential-injection proxy from code review, plus documentation reconciled to what M0 actually ships.

Reject `credential_proxy` where it cannot work, before any guest env is staged or the guest boots. Service mode never wires the proxy (only the task-mode `agent_box::run` does), so `mode: service` with `credential_proxy` is now a parse-time error in `validate_spec`, mirroring the existing `timeout_secs`-in-service rejection. An unsupported provider under `credential_proxy` is rejected at `VoidBox::build()` — previously `withhold_provider_secret` was false for such a provider, so the real key was staged into the exec env and the run only aborted after the pre-run provisioning execs had seen it (R14). `voidbox shell` builds the guest PTY env from `env_vars()` and does not start the proxy, so a spec with `credential_proxy: true` reaching the shell would forward the real key; it now refuses instead of silently forwarding.

Fail closed on a failed credential injection. The injector returns a three-state `InjectOutcome` (`Injected` / `NotOwned` / `Failed`) so the server can tell a host the injector does not own from an owned host whose key was malformed. A `Failed` injection now returns 502 and audits `injected: false` rather than forwarding an unauthenticated request recorded as credentialed.

Surface the guest `/etc/hosts` mirror failure in the `WriteFileResponse` instead of only kmsg, so the host does not report success while the upstream name never resolves to the proxy.

Redact `token_hex` in `SandboxBinding`'s `Debug` so a `{:?}` cannot land the per-sandbox token in a log (R15), matching `ProxyToken`.

Docs: RFC-0002 M0 scope now records, mechanism before property, that the per-sandbox network rule is not implemented yet — the SLIRP gateway maps every guest's `10.0.2.2:<port>` onto shared host loopback, so the per-sandbox token is the sole cross-sandbox control on KVM in M0 — and that M0 runs the parser in-process and stands up a proxy handle per run rather than the separate shared low-privilege process of ADR-0003/R10. ADR-0004 gets an amendment note pointing to RFC §A as the reconciled token-carrier contract; its decision body is left immutable. The stale "created once and kept warm" doc on `ProxyHandle` is corrected.

Tests: cross-sandbox token rejection (sandbox A's token presented to B's listener is refused before any upstream call), the SSRF guard exercised through the production upstream-client wiring rather than the `is_internal_ip` unit alone, an oversize-header-block rejection against the 64 KiB parser cap, and a malformed-key injection unit test.
The `CredentialInjector` module and trait docs said Phase 1 "replaces" / "swaps in" an OAuth-backed injector for this static one, and the frozen-pipeline note said "the OAuth store replaces the `CredentialInjector`". Both overstate what happens.

Per RFC-0002 §B, API keys are the sanctioned path for programmatic use and stay on the static injector — Claude with an API key, the Anthropic-compatible Custom provider, and codex API-key mode. Phase 1 adds an OAuth-backed `CredentialInjector` as a second implementation, selected per provider and auth mode behind the same trait boundary, rather than replacing the static one. OAuth providers use the new implementation; API-key providers keep the static path.

The frozen-pipeline note also conflated the credential store (the host-side durable-secret holder that performs OAuth refresh) with the injector stage it backs. The store is not a pipeline stage; corrected to name the OAuth-backed injector that occupies that stage.

Doc comments only; no behavior change.
Add a real-upstream test and a reproducible harness for validating the M0 credential-injection proxy. Test and tooling only; no production code changes.

`tests/proxy_real_upstream.rs` is an ignored integration test that drives the production proxy (`start_proxy`) end-to-end to the real `api.anthropic.com` and asserts a Messages completion. Unlike `proxy.rs`, which uses a mock upstream that accepts anything, it proves the last mile: the production upstream client completes real TLS to Anthropic, Anthropic accepts the HTTP/1.1 request the proxy re-originates, and the host-held `x-api-key` — never the guest placeholder — authenticates. It runs anywhere with no VM (including macOS) and requires a funded `ANTHROPIC_API_KEY`.

`scripts/test_credential_proxy_v1.sh` runs the checks in dependency order so a failure points at the narrowest broken layer. Modes: `mechanics` (any OS, no VM) does a zero-cost key auth check against `/v1/models`, a funded-key inference check against `/v1/messages`, then the real-upstream proxy test; `full` (Linux/KVM) builds the production Claude image, runs `credential_proxy_claude.yaml`, and greps the run log against an observation checklist tied to the enforcing code — the proxy-active/real-key-withheld line, no R14 leak abort, no CA-trust or force-login symptom, and a real completion; `all` runs both. The key is read from the environment only.
Implements milestone M1a of RFC-0002: route the `claude-personal` provider through the M0 credential-injection proxy so the durable OAuth refresh token never enters the guest.

Adds a host-side `ClaudeOAuthStore` (`src/credentials.rs`) that holds the refresh token in host memory, refreshes it against Anthropic's token endpoint to mint short-lived access tokens, and is the rotation owner across void-box runs: refreshes are serialized within the process (state mutex, coalescing concurrent requests) and across processes (an advisory `flock` held over the whole read-refresh-write cycle, so a peer run's rotation is adopted instead of double-spending the single-use refresh token), and the rotated token is written back atomically (temp + `rename`, `0600`, parent-dir fsync). It fails closed on any refresh failure and bounds the refresh round-trip with a timeout.

The injection stage is now async (`CredentialInjector::inject`) so the new `OAuthBearerInjector` can await the store; it injects `Authorization: Bearer` and drops `x-api-key`, failing closed (502, no credential headers) when no token can be minted. `ProxiedUpstream` replaces its `scheme` field with a `ProxiedAuth` enum (`ApiKey` / `Oauth`); `claude-personal` maps to `Oauth`, provisioning `ANTHROPIC_BASE_URL`, `NODE_EXTRA_CA_CERTS`, `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1`, a placeholder `ANTHROPIC_AUTH_TOKEN`, and the per-sandbox token header — no credentials file.

Migration: `prepare_claude_personal` stages nothing into the guest when the proxy is active, and both `build_pipeline_box_with_io` (per-box, using the effective post-override llm) and `validate_credential_proxy_preconditions` (pre-boot) refuse to stage the credentials file alongside the proxy, so the durable secret cannot ride into the guest in a file the env/CA/hosts audit does not cover (R14).

Reuses the M0 CA and streaming proxy. Single-platform (Linux/KVM); the proxy still refuses to run off Linux. Snapshot re-mint (R11), cold-boot cmdline provisioning, codex OAuth, and VZ parity remain M1b.

Tests: OAuth injection through the full proxy pipeline against a mock upstream + mock token endpoint; store unit tests (refresh/mint/rotation, atomic write-back + field preservation, rate-cap fail-closed, cross-process peer-adopt); the R14 backstop; and `tests/oauth_real_upstream.rs`, the V2 OAuth-acceptance harness (`#[ignore]`, gated on `VOIDBOX_V2_OAUTH=1` plus a throwaway-account credentials file), which validates that a host-replayed refresh and a host-minted Bearer are accepted by real Anthropic.
Add an AGENTS.md subsystem section for the credential proxy and containment — the per-connection pipeline, the two credential injectors, the host-side `ClaudeOAuthStore` (refresh/mint/rotation, cross-process locking, atomic write-back), the R14 no-durable-secret-in-the-guest invariant, snapshot handling, validation entry points, and a key-files table — matching the repo's other subsystem docs.

Also note in `src/credentials.rs` that `SsrfGuardResolver` is a shared network primitive intentionally reused from `proxy`, documenting the intentional mutual module dependency.
Extend the credential proxy (RFC-0002) to codex, the Anthropic-compatible Custom provider, and macOS/VZ, and add long-run credential rotation. The proxy holds each provider's durable secret on the host and injects the credential header at TLS egress, so the untrusted guest carries only a non-secret placeholder.

**codex through the proxy.** Both auth modes are provisioned via a generated `$CODEX_HOME/config.toml` — a dedicated `[model_providers.voidbox]` entry, because a config entry cannot override the built-in `openai` provider and that provider enables the Responses-over-WebSocket transport — plus a placeholder `auth.json`. API-key mode targets `api.openai.com/v1` with `Authorization: Bearer`; ChatGPT OAuth targets `chatgpt.com/backend-api/codex` with a host-minted Bearer plus the host-held `chatgpt-account-id`. The per-sandbox token reaches codex via the provider entry's `http_headers` and, in API-key mode, embedded in the Bearer placeholder (`voidbox-proxy-<hex>`); the proxy accepts either carrier, compares it in constant time, and strips or replaces it before re-originating so it never reaches the upstream. All codex behavior is pinned against codex 0.141.0 (R9).

**WebSocket fail-closed (R8).** The generated config sets `supports_websockets = false`, and the proxy additionally refuses any protocol-upgrade request after auth with a `502` and a `websocket-upgrade-refused` audit event, rather than silently stripping the Upgrade header.

**Long-run rotation.** `ClaudeOAuthStore` becomes `OAuthTokenStore`, generalized over `OAuthProviderKind {ClaudeCode, CodexChatGpt}`. The kind owns the on-disk schema, token endpoint and client id, lock-file name, and expiry derivation — codex reads the minted access-token JWT's `exp`, since the OpenAI refresh response carries no `expires_in` — while refresh coalescing, the cross-process flock with adopt-peer, and atomic write-back are shared. Injection stays lazy per request; a stream never re-authenticates mid-flight.

**Custom provider.** https-only and `api_key_env`-required, rejected at parse time otherwise. The base URL is parsed to host/port/path with the path preserved in the guest redirect and the port used for re-origination; the provider-emitted `ANTHROPIC_BASE_URL` is withheld so redirection never depends on env ordering. Internal-IP base URLs are refused before boot (the SSRF guard would reject them anyway).

**KVM + VZ parity.** `credential_proxy_bind_addr` binds host loopback on KVM (SLIRP-forwarded) and the host-local VZ NAT gateway `192.168.64.1` on VZ, lifting the Linux-only gate. On VZ every guest shares the NAT segment, so the per-sandbox token is the sole cross-sandbox control until the egress-track network rule lands, the same recorded posture as M0 on KVM's shared loopback (ADR-0007).

Withholding and the R14 no-leak audit extend to every new provider and every staged file (codex `auth.json`/`config.toml`), keyed on a pure provider check so a servable provider's key is withheld even if setup later errors. The in-process proxy suite gains codex Bearer-carrier and OAuth-with-account-id injection, the WebSocket refusal, and the both-provider rotation chains; the VZ leg of `e2e_credential_proxy` is wired into `e2e-macos.yml`.
Add the harnesses that gate the codex behavior the M1b implementation pins against codex 0.141.0, so the constants are re-verified against the real binary rather than trusted from source reading.

`tests/codex_provisioning_harness.rs` runs a real codex binary against the generated `config.toml`/`auth.json` and asserts on the wire that every pinned knob is honored: the base-URL redirect and path, the proxy-token carriers (`http_headers` and, in API-key mode, the Bearer placeholder), `originator`/`chatgpt-account-id`, the absence of any WebSocket upgrade, and — via a local refresh-endpoint override — that codex never attempts its own token refresh against the placeholder JWTs. A second leg drives the request through the real proxy to confirm the injection end to end. `scripts/test_credential_proxy_codex_v1.sh` fetches the pinned binary (hash-verified on Linux) and runs it; re-run it on every codex bump.

`tests/codex_oauth_real_upstream.rs` is the codex leg of the V2 OAuth-acceptance harness (R4/R5): host-side refresh against `auth.openai.com`, minted Bearer plus account id through the production proxy to the real ChatGPT backend, and atomic write-back of the rotated token. It is `#[ignore]` and gated on `VOIDBOX_V2_CODEX_OAUTH=1` because it spends a single-use refresh token — use a throwaway account.
Record the M1b decisions and give the subsystem a runnable validation plan.

ADR-0007 records binding the VZ credential-proxy listener to the NAT gateway address (`192.168.64.1`) rather than `0.0.0.0` or loopback, and the consequence that the per-sandbox token is the sole cross-sandbox control on VZ until the egress-track network rule lands. The AGENTS.md credential-proxy section and `docs/agents/codex.md` are updated for the codex both-mode provisioning, the Custom provider, the WebSocket fail-closed behavior, the `OAuthTokenStore` generalization, and the new validation commands.

`docs/testing/credential-proxy-m1b.md` plus `scripts/test_credential_proxy_m1b.sh` are the validation plan for the subsystem, embodied as one self-documenting script: `--list` prints every test with its check and expected result, tiers run cheapest-and-safest first, keys load from a file without being echoed, and each run writes per-test evidence logs plus a `SUMMARY.md` to a timestamped folder. Tiers A–E are ToS-clean; the subscription-OAuth tier is opt-in because it rotates a real login's single-use token.
@cspinetta
cspinetta force-pushed the claude/vm-agent-credentials-phase-1a branch 3 times, most recently from c731a05 to a425d67 Compare July 27, 2026 11:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant