Skip to content

Add the credential-injecting proxy for Claude and Codex (#411)#417

Open
hbrodin wants to merge 16 commits into
mainfrom
issue-411-proxy-jail
Open

Add the credential-injecting proxy for Claude and Codex (#411)#417
hbrodin wants to merge 16 commits into
mainfrom
issue-411-proxy-jail

Conversation

@hbrodin

@hbrodin hbrodin commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

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, a
new 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

  • Claude Code ([proxy.anthropic]) — pointed at the proxy via
    ANTHROPIC_BASE_URL; supports an API key (x-api-key) or a Claude
    setup-token (Authorization: Bearer).
  • Codex ([proxy.openai]) — pointed at the proxy via a
    [model_providers.coop_local] block (Responses API) with the capability
    token as the provider bearer. In proxy mode ~/.codex/auth.json is no
    longer staged onto the guest disk; Codex subscription is out of scope —
    use an API key.
  • The raw ANTHROPIC_API_KEY / OPENAI_API_KEY is no longer forwarded.
    Credential resolution fails closed — a bad credential aborts the boot.
    coop model <vm> local takes precedence and tears the proxy down.
  • The proxy verifies the capability token (constant-time), strips it,
    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).
  • Per-VM credential overridescoop proxy setup [--openai] --vm <name> stores an override in that instance's state; resolution is
    override → default → off. coop proxy status shows what each VM
    resolves to, redacted. coop proxy setup is a wizard that stores the
    credential in a secret backend and writes the [proxy.*] block.

The jailed proxy process (slice 3)

coop-proxy holds the real credential and terminates guest-originated
connections, 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.

  • Linux — self-applies Landlock (ABI v4) before serving; fails closed
    on a kernel that cannot fully enforce it (host kernel ≥6.7).
  • macOS — wrapped in sandbox-exec with a Seatbelt profile.
  • Fail-closed launch: if confinement cannot be established the proxy exits
    before binding and the VM start aborts. coop-proxy --jail-selftest
    asserts 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

  • Unit tests for header rewrite/token gate/route selection, proxy-mode
    guest config, per-VM resolution, wire config, and the fail-closed launch.
  • coop-proxy end-to-end gate tests (gate.rs).
  • Integration suite (tests/integration.sh::test_proxy) passes on both
    backends: Lima (macOS) and Firecracker (Linux), including the jail
    self-test.

🤖 Generated with Claude Code

@hbrodin hbrodin changed the title Jail the host-side coop-proxy with Landlock and Seatbelt Add the credential-injecting proxy for Claude and Codex (#411) Jul 22, 2026
@hbrodin
hbrodin changed the base branch from issue-411-injecting-proxy to main July 22, 2026 08:09
Comment thread src/backend.rs
Comment thread src/backend.rs
Comment thread coop-proxy/src/tls.rs Outdated
Comment thread coop-proxy/src/proxy.rs Outdated
Comment thread src/update.rs
Comment thread src/proxy.rs
Comment thread src/proxy.rs
Comment thread src/proxy.rs
Comment thread src/model_state.rs
Comment thread coop-proxy/src/config.rs
Comment thread src/proxy_state.rs
Comment thread coop-proxy/Cargo.toml Outdated
Comment thread docs/design/issue-411-injecting-proxy.md Outdated
Comment thread docs/design/issue-411-injecting-proxy.md Outdated
Comment thread src/lib.rs
@hbrodin

hbrodin commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

15 finding(s) posted inline. Special-focus verdict on the two things called out for this review:

  • Testing sufficiency: the pure/logic layer is unusually well covered (config parse, port/range math, wire-JSON shapes, header rewrite/strip/inject, resolution precedence, key-suppression crown-jewel unit + integration, jail self-test). The gaps are concentrated at three boundaries: the TLS trust boundary (tautological test, no bad-cert rejection), the coop update sibling swap (replace_sibling_proxy has zero coverage), and two in-scope pure helpers whose edge branches slip past their tests (read_capability_token empty-trim, codex_proxy_config trailing-slash) — the last two are genuine .cargo/mutants.toml regressions since the file keeps them in scope while the tests don't kill their mutants. See the inline comments.
  • Conflicting features: the proxy ↔ coop model <vm> local interaction is coherent and mutually exclusive by constructionlocal_endpoint yields Some only in Local mode, start_agent_proxy only in Remote mode, and switching to local re-bootstraps → start_agent_proxy calls stop_provider to reap the stale proxy; proxy::stop fires on stop/destroy/purge. No structural conflict. The one real gap is teardown-on-failure asymmetry (see the bootstrap_claude P2 inline): a failed boot orphans the credential-holding Anthropic proxy, and the coop model … remote re-bootstrap path (commands/model.rs::apply_to_running, not in this diff) has no outer proxy::stop on error either.

Additional findings on files not in this diff (can't anchor inline)

  • docs/commands.md — the exhaustive command reference has no coop proxy entry (grep proxy docs/commands.md is empty). New top-level command ↔ command reference is a required sync edge; docs/credential-proxy.md covers the feature but the command reference itself is missing it.
  • docs/configuration.md — the exhaustive config reference has no [proxy] section, though config.example.toml was updated. New config field ↔ config reference is a required sync edge; the two have drifted.
  • src/commands/model.rs::apply_to_runningbootstrap_agents is called with no cleanup on error; a coop model <vm> remote switch that starts a proxy then fails a later step leaks it (same theme as the bootstrap_claude P2). The switch-to-local case is safe (start_agent_proxy tears down when mode != Remote).

Minor (not posted inline)

  • Two inline comments narrate the PR's own delivery process — // … (unchanged from slice 1) (src/proxy.rs, also on persists_token) and // … (issue #411 slice 2) (src/backend.rs test). The rationale is fine; the "slice N" reference belongs in git history.

What was verified clean

Security core invariants all held: SSRF closed (upstream host/port/scheme fixed host-side, origin_form discards absolute-form authority), TLS verification has no bypass, capability token is CSPRNG + constant-time compared with both credential slots stripped, Landlock & !from_read + explicit AccessFs::Execute handling is correct and fail-closed, secrets ride stdin only, file modes right (0600 literal / 0644 cmd:). External-crate API usage verified correct against pinned versions. deny.toml license exceptions verified correct against the pinned crates; CI/release/install cross-file sync (cmake, musl-gcc, ARM64 native runner, --workspace, sibling binary) is correct; secret_store rename and atomic_replace refactor are straggler-free.

Coverage

Ran: review-correctness, review-security, review-design, review-conventions, review-tests, review-docs, review-comments, review-api-usage. None skipped — the diff touches every domain.

@hbrodin

hbrodin commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 464e1ee, d7884ca, 7158736 addressing the review.

Non-anchorable summary findings:

  • docs/commands.md / docs/configuration.md — added the missing proxy command entry and [proxy] config section (7158736).
  • commands/model.rs::apply_to_running — the symmetric teardown guard now lives inside bootstrap_claude/bootstrap_codex, so a failure during bootstrap tears the proxy down on every caller (including apply_to_running). A failure in apply_to_running after a clean bootstrap intentionally leaves the proxy running.

Left as-is with rationale (see inline replies): kill_pid_file pid-reuse (low severity, guest-uninfluenced, robust fix is Linux-only) and the --anthropic flag (documented + conflicts_with guard). The proxy accept-bound / idle-timeout hardening is filed as follow-up.

@hbrodin

hbrodin commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

/my-review pass

Ran all 8 review-* agents over origin/main...HEAD (b3cf4db), primed to skip the 15 already-resolved findings and focus on the review-response commits. 4 new findings, all fixed in b3cf4db:

  • api-usage — coop-proxy uses tokio::sync::Semaphore directly but dropping client-legacy removed the crate-controlled provenance of tokio/sync (it compiled only via transitive enablement). Declared sync in the tokio feature list.
  • tests — the env_forward raw-key suppression branch was untested (config + guest_env paths were covered). Added proxy_mode_suppresses_anthropic_key_from_env_forward with a serialized process-env guard.
  • commentsclient_config_builds_with_pinned_roots had a tautological Arc::strong_count assert with an overstated comment; trimmed to the .unwrap() construction check and pointed at rejects_untrusted_upstream_cert for the real verification.
  • conventions — the §13 [[licenses.exceptions]] example omitted the shipped webpki-roots (CDLA-Permissive-2.0) exception; added it.

No findings from review-correctness, review-security, review-design, or review-docs. Coverage: all 8 agents ran, none skipped (the diff touches every domain).

Gates green: cargo fmt --check, cargo clippy --all-targets --all-features -D warnings, cargo test (1057 lib + coop-proxy + gate), cargo deny check, taplo format --check. Integration suite (test_proxy, both backends) not run in this environment.

hbrodin and others added 10 commits July 22, 2026 13:43
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>
hbrodin and others added 5 commits July 22, 2026 13:43
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>
@hbrodin
hbrodin force-pushed the issue-411-proxy-jail branch from b3cf4db to a3a8fc8 Compare July 22, 2026 11:44
`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>
@hbrodin
hbrodin marked this pull request as ready for review July 22, 2026 19:29
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