Add the credential-injecting proxy for Claude and Codex (#411)#417
Add the credential-injecting proxy for Claude and Codex (#411)#417hbrodin wants to merge 16 commits into
Conversation
|
15 finding(s) posted inline. Special-focus verdict on the two things called out for this review:
Additional findings on files not in this diff (can't anchor inline)
Minor (not posted inline)
What was verified cleanSecurity core invariants all held: SSRF closed (upstream host/port/scheme fixed host-side, CoverageRan: |
|
Pushed 464e1ee, d7884ca, 7158736 addressing the review. Non-anchorable summary findings:
Left as-is with rationale (see inline replies): |
|
Implement slice 1 of the issue-411 design: an opt-in `[proxy]` mode that keeps the raw Anthropic API key out of the guest. A new workspace crate `coop-proxy` runs on the host for the lifetime of a remote-mode VM as a streaming reverse proxy (hyper + rustls/aws-lc-rs). The guest is pointed at it via `ANTHROPIC_BASE_URL` and holds only a per-instance capability token; the proxy verifies the token, strips it, injects the real credential (`x-api-key` or `Authorization: Bearer` for a setup-token), and forwards to the pinned `api.anthropic.com`. The guest controls only the request path, closing SSRF. Host side: `src/proxy.rs` resolves the credential via the existing `cmd:` machinery (fail-closed), mints a CSPRNG capability token, and spawns/tears down the proxy per instance (PID file, SIGTERM). `prepare_env_forwarding` stops forwarding `ANTHROPIC_API_KEY` in proxy mode on every session; `claude_local_env` writes the proxy base URL + token into the managed settings.json. Local model mode takes precedence. Firecracker binds the bridge gateway; Lima fails closed pending a bind-address spike. Packaging: the release matrix builds every target on a matching-arch runner (macOS arm64, Linux x86_64, and a native ARM64 Linux runner) so aws-lc-sys compiles with the native musl toolchain — no cross-compilation. `coop-proxy` ships in the same attested tarball; `install.sh` and `coop update` place/refresh both binaries in lockstep. `cargo deny` now checks the whole workspace, with scoped license exceptions for the TLS stack. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the `coop github` PAT wizard for the Anthropic proxy credential: `coop proxy setup` takes a pasted Claude setup-token (or `--api-key`), stores it in a secret backend the user picks (macOS Keychain / Linux secret-service / 1Password / a 0600 file), and writes the resulting `cmd:` reference into `[proxy.anthropic]` — the credential is never a plaintext value in the config. Generalize the secret store per-service: the file backend now derives its subdirectory from the service name (`coop-github-pat` → `github-pat`, unchanged; `coop-anthropic` → `anthropic`), so proxy secrets get their own namespace instead of landing among the GitHub PATs. `CmdToken::File`'s `dir` is the leaf directory, keeping Display/parse an exact inverse, and `delete_secret` recomputes the path from the state dir + service so a parsed token can never delete an arbitrary file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Proxy mode was Firecracker-only: it bound the bridge gateway IP, and Lima had no first-class host-side bind address so it failed closed. Bind the proxy on host loopback (`127.0.0.1:<port>`) instead and expose it into the guest with a per-instance `ssh -R` reverse tunnel, so it works identically on Firecracker and Lima — and never binds a non-loopback interface, with a per-instance tunnel rather than the shared-bridge gateway. `proxy::start` now takes the `SshTarget`, spawns the proxy on loopback, then opens the reverse tunnel (tearing the proxy back down if the tunnel fails). Both the proxy and the tunnel are tracked by PID files, so teardown needs no target. The `VmBackend::proxy_bind_ip` method is removed — the bind is loopback on every backend. The guest is pointed at `http://127.0.0.1:<port>`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`spawn_reverse_forward` returned Ok as soon as `ssh` was spawned, never confirming the `-R` forward actually bound. With `ExitOnForwardFailure`, a guest that refuses the bind (TCP forwarding disabled, port already bound) makes `ssh` exit moments later — but coop never observed it, so the VM booted "successfully" pointed at a dead endpoint, contradicting the fail-closed contract. After spawning, poll the child for a short grace window: if `ssh` exits first, abort the boot with the ssh log; if it survives, the forward is bound. Not a credential leak (the guest only ever holds the capability token) — this restores the documented "abort the boot" guarantee.
Generalize the host-side proxy manager over a `Provider` enum (Anthropic, OpenAI) so one code path serves both agents; the `coop-proxy` binary is already provider-agnostic and is unchanged. One proxy process and one `ssh -R` tunnel run per (VM, provider); ports are per-provider (Anthropic 8788+idx, OpenAI 9788+idx — disjoint across the 0..=252 index range). Codex is pointed at the proxy via a `[model_providers.coop_local]` block (Responses API, no model pin) with the per-instance capability token as the provider bearer; the token is persisted host-side (0600, OpenAI only) so later sessions forward it. In proxy mode `OPENAI_API_KEY` is no longer forwarded and `~/.codex/auth.json` is not staged onto the guest disk. Codex subscription is out of scope — use an API key. Add per-VM credential overrides in `<inst.dir>/proxy.json` (mirrors model.json): resolution is override -> default -> off, fail-closed. Extend `coop proxy setup` with `--openai`/`--anthropic`/`--vm`, and add `coop proxy status [--vm]` (credentials redacted). Secrets are namespaced per provider and VM (`coop-<provider>[-<vm>]`). The OpenAI proxy is torn down on any bootstrap-codex error so a failed boot never orphans a credential-holding process. The `--vm` instance is validated before any secret is written. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sync the cross-file infra for the OpenAI/Codex proxy slice: `[proxy.openai]` and the per-VM override / `coop proxy status` UX in config.example.toml, docs/credential-proxy.md, and docs/trust-model.md; the CHANGELOG entries; and the mutation scoping in .cargo/mutants.toml for the new IO functions (pure helpers stay in scope). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex forms the request URL as `{base_url}/responses`, and the proxy
forwards the path verbatim to `api.openai.com`. Without the `/v1` prefix the
guest hit `api.openai.com/responses` (404); the Responses endpoint is
`/v1/responses`. Mirror OpenAI's own `https://api.openai.com/v1` provider
base so the forwarded path is correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `tests/integration.sh::test_proxy` (--full): brings a VM up in proxy mode with both providers, and asserts the non-exposure property end-to-end — Codex/Claude are wired at the proxy holding only the capability token, the raw keys are suppressed even when set on the host, `~/.codex/auth.json` is not staged, the proxy enforces the capability gate on host loopback, and `stop` tears it down. There is no mock upstream (the proxy pins TLS to the real hosts); header injection stays covered by the coop-proxy unit tests and gate.rs. Also re-prepare the session before `run_post_start` when agents were bootstrapped, so a `post_start` that runs Codex in proxy mode picks up the capability token minted during bootstrap (the initial session was built before the token existed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The credential-proxy integration phase needs the `coop-proxy` binary next to `coop`, but `coop-proxy` is not a default workspace member (it needs cmake for aws-lc-rs), so `run-integration.sh` never built or deployed it — the fail-closed `up` then aborted with "coop-proxy not found next to coop". Build `coop-proxy` best-effort in both local and remote modes and ship it alongside `coop` (remote scp). Keep it best-effort so environments without cmake still run the rest of the suite; the proxy phase skips (rather than failing) when the binary is absent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`run-integration.sh --remote` cross-compiled coop-proxy on the host, but its aws-lc-rs C build is fragile across arch/libc (e.g. macOS host → Linux musl), so the proxy phase would skip on a mismatched host. Try the local cross-build first (works when host and remote share an arch); otherwise ship a clean source snapshot of the current commit and build coop-proxy natively on the remote, which supports its own arch. The binary lands next to coop either way. Still best-effort — needs cargo + cmake + a C compiler on the remote; on failure the proxy phase skips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Confine the credential-holding coop-proxy process so a proxy exploit cannot write files, execute programs, or reach any host beyond the upstream :443 and DNS :53. On Linux the proxy self-applies a Landlock (ABI v4) ruleset in main after its libraries load and before the tokio runtime spawns workers, so every worker inherits the domain: all filesystem writes and program exec are denied, TCP connect is limited to :443/:53, and bind to the listener port. It confines unconditionally unless an explicit --no-jail opt-out is passed (only the in-repo tests use it), and fails closed on a kernel that cannot fully enforce the rules. On macOS the launcher wraps the spawn in sandbox-exec with the Seatbelt profile in src/seatbelt-proxy.sb (deny by default; allow file reads, name resolution, the loopback listener, and egress only to :443/:53). A post-spawn readiness probe in proxy.rs makes both paths fail closed: if confinement cannot be established the proxy exits before binding, the probe never connects, and the VM start aborts. coop-proxy --jail-selftest asserts the restrictions on the host and runs in the integration suite on both backends. The jail is port-scoped, not host-scoped (upstream identity is enforced by the proxy's TLS verification), and does not restrict UDP on Linux; these limitations are documented in docs/trust-model.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fold config_default into ProxyState::effective — it already matches on
provider, so the fn-pointer parameter and the separate helper are
indirection with a single production call site and no behavior change.
Drop the coop-proxy hyper-util client-legacy feature: the crate uses only
rt::{TokioExecutor, TokioIo} and server::conn::auto::Builder, and the
upstream path uses hyper's own client via hyper::client::conn::http1. This
sheds futures-task, futures-util, and tower-service as dead surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Harden the credential non-exposure invariant and proxy lifecycle: - bootstrap_claude now tears the Anthropic proxy (and its reverse tunnel) down on a post-start failure, mirroring bootstrap_codex; a failed boot no longer orphans a credential-holding process on the restart / re-bootstrap paths. - Proxy-mode key suppression now also covers the env_forward loop, the guest_env merge, and the runtime GuestEnvState overlay, so a raw ANTHROPIC_API_KEY / OPENAI_API_KEY listed there is skipped (with a warning) instead of silently forwarded into the guest. - ProxyHandle::capability_token is wrapped in config::Secret so a stray Debug of the handle cannot leak it. - coop-proxy rejects an empty capability_token at parse (an empty token would otherwise authorize an empty guest token). - coop-proxy sheds load with try_acquire_owned so the "at capacity" 503 is reachable instead of queueing unbounded work. Add the coverage the review flagged as missing: - A TLS test that stands up a self-signed upstream and asserts the pinned-root verifier rejects it (static openssl-minted DER fixtures, no new dependency). - replace_sibling_proxy gains a testable seam plus tests for the no-op guard, the happy-path swap, and the fail-closed branch. - Tests for read_capability_token empty/whitespace handling and the codex_proxy_config trailing-slash normalization, killing the surviving mutants their mutants.toml scope claimed were covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the missing command and config reference for the credential proxy: - docs/commands.md gains a `proxy` entry (setup / status) matching the shipped clap definition. - docs/configuration.md gains a `proxy` section documenting the `[proxy.anthropic]` / `[proxy.openai]` credential and auth fields. Correct docs/design/issue-411-injecting-proxy.md where it had drifted from the code shipped in this PR: §13 now matches the shipped deny.toml (aws-lc-sys / subtle BSD-3-Clause exceptions, webpki-roots CDLA-Permissive-2.0, ISC in the global allow) and notes the [[bans.features]] pins were not shipped; §5 and §14 now describe the Codex guest holding the capability token via env_key = "COOP_LOCAL_API_KEY" rather than the abandoned no-credential design. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A /my-review pass over the preceding commits surfaced four items: - Declare tokio's `sync` feature in coop-proxy: the crate uses tokio::sync::Semaphore directly, but dropping the hyper-util client-legacy feature removed the crate-controlled provenance of tokio/sync (it now compiled only via transitive enablement). Declare what the crate uses. - Add a proxy-mode suppression test for the env_forward path (the config and guest_env paths were covered), completing the three raw-key entry points. - Trim the tautological client_config_builds_with_pinned_roots sanity assert and correct its comment, which overstated what it verified; real verification lives in rejects_untrusted_upstream_cert. - Add the shipped webpki-roots CDLA-Permissive-2.0 exception to the §13 deny.toml example so it matches the shipped file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
b3cf4db to
a3a8fc8
Compare
`spawn_firecracker_like` ran `bash -c "exec -a firecracker-test sleep 30"`, which execs twice (bash, then bash→sleep). `is_firecracker_process` reads `/proc/<pid>/cmdline`, which is transiently empty during the second transition, so `is_running` intermittently saw no "firecracker" match and returned false — failing `is_running_true_for_live_firecracker_like_pid` under CI load. Set argv[0] directly with `CommandExt::arg0` so the child execs once, and wait for the child's cmdline to populate before inspecting it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps the model API keys out of the guest (#411). Adds an opt-in
[proxy]mode: coop runs a small host-side reverse proxy (coop-proxy, anew binary shipped in the same tarball) for the lifetime of a remote-mode
VM. The guest is pointed at it and holds only a per-instance capability
token; the real credential stays on the host and is injected onto requests
upstream. A prompt-injected or rogue agent can no longer read a usable key.
Covers the two model APIs plus per-VM overrides and a jailed proxy
process. GitHub (#73) is separate follow-up work, not in this PR.
What it does
[proxy.anthropic]) — pointed at the proxy viaANTHROPIC_BASE_URL; supports an API key (x-api-key) or a Claudesetup-token(Authorization: Bearer).[proxy.openai]) — pointed at the proxy via a[model_providers.coop_local]block (Responses API) with the capabilitytoken as the provider bearer. In proxy mode
~/.codex/auth.jsonis nolonger staged onto the guest disk; Codex subscription is out of scope —
use an API key.
ANTHROPIC_API_KEY/OPENAI_API_KEYis no longer forwarded.Credential resolution fails closed — a bad credential aborts the boot.
coop model <vm> localtakes precedence and tears the proxy down.injects the real credential, pins the upstream
Host(closing SSRF),and streams to the fixed upstream over TLS against a pinned root set. It
binds host loopback and is reverse-tunnelled (
ssh -R) into the guest,so it works on both backends (Firecracker and Lima).
coop proxy setup [--openai] --vm <name>stores an override in that instance's state; resolution isoverride → default → off.
coop proxy statusshows what each VMresolves to, redacted.
coop proxy setupis a wizard that stores thecredential in a secret backend and writes the
[proxy.*]block.The jailed proxy process (slice 3)
coop-proxyholds the real credential and terminates guest-originatedconnections, so it is confined to bound the blast radius of an exploit —
it cannot write files, exec programs, or reach any host beyond
:443/:53.on a kernel that cannot fully enforce it (host kernel ≥6.7).
sandbox-execwith a Seatbelt profile.before binding and the VM start aborts.
coop-proxy --jail-selftestasserts the restrictions and runs in the integration suite.
The jail is port-scoped, not host-scoped (upstream identity is enforced by
the proxy's TLS verification), and does not restrict UDP on Linux — see
docs/trust-model.md.Testing
guest config, per-VM resolution, wire config, and the fail-closed launch.
coop-proxyend-to-end gate tests (gate.rs).tests/integration.sh::test_proxy) passes on bothbackends: Lima (macOS) and Firecracker (Linux), including the jail
self-test.
🤖 Generated with Claude Code