Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .cargo/mutants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,21 @@ exclude_re = [
# commands/mod.rs — purges every instance + image (backend destroy calls).
"\\bpurge_all_data\\b",

# commands/proxy.rs (#411) — `coop proxy setup` / `status`. run_setup reads a
# pasted token, prompts for a backend, and writes to the real secret store;
# store_vm_override resolves an instance and writes its proxy.json; run_status
# lists instances and writes stdout; the guidance/report printers only write
# stderr — none `--lib`-observable. The config writer `upsert_proxy_entry`,
# its `read_or_init_doc` helper, and the pure `auth_str` / `auth_for` /
# `service_for` / `redact_credential` / `resolve` / `describe` /
# `write_vm_status` (buffer-tested) stay IN scope — a survivor there is a real
# gap.
"src/commands/proxy\\.rs:.*\\brun_setup\\b",
"src/commands/proxy\\.rs:.*\\brun_status\\b",
"\\bstore_vm_override\\b",
"\\bprint_setup_guidance\\b",
"\\breport_setup\\b",

# admin.rs — uninstall/init IO: terminal summary, recursive data-dir wipe,
# self-binary removal. The decide_remove_data / config_path_is_under_data_dir
# / is_dev_target_path predicates are pure and stay in scope (unit-tested).
Expand Down Expand Up @@ -301,6 +316,40 @@ exclude_re = [
"\\bcapture_version\\b",
"\\bagent_binary\\b",

# ---- src/proxy.rs (#411): host-side credential-proxy lifecycle ----
# Process spawn/teardown, binary discovery, PID-file IO, and the token-file
# writer — a `--lib` test cannot observe their effects (coop-proxy's own tests
# + tests/integration.sh cover the behavior). The pure `Provider` helpers
# (`port`/`base_port`/`upstream_host`/`name`, pinned by the port/range/host
# tests), `wire_config_json`, `mint_capability_token` (whose randomness/length
# a unit test asserts), and `read_capability_token` (trim/empty logic covered
# by `read_capability_token_ignores_empty_and_whitespace_files`) stay IN scope
# — a survivor on one of
# those is a real gap. File-anchored so the generic `start`/`stop` names don't
# scope out same-named functions elsewhere.
"src/proxy\\.rs:.*\\bstart_provider\\b",
"src/proxy\\.rs:.*\\bstop\\b",
"src/proxy\\.rs:.*\\bstop_provider\\b",
"src/proxy\\.rs:.*\\bkill_pid_file\\b",
"src/proxy\\.rs:.*\\bspawn_proxy\\b",
"src/proxy\\.rs:.*\\bspawn_reverse_forward\\b",
# slice-3 jail: liveness poll of a spawned process + TCP probe (fail-closed)
# and the platform spawn-command builder (macOS `sandbox-exec` wrap / Linux
# bare binary) — IO/spawn wrappers, not `--lib`-observable. `await_proxy_ready`
# has a unit test for its fail-closed early-exit branch; the poll/timeout loop
# itself stays excluded (IO-bound, slow timeout path). `wire_config_json`
# stays IN scope, pinned by the wire_json_* shape tests. coop-proxy's own
# `jail.rs` (the Landlock ruleset) is not in this sweep — coop-proxy is a
# separate, non-default workspace member — so it is asserted by
# `coop-proxy --jail-selftest` in tests/integration.sh instead.
"src/proxy\\.rs:.*\\bawait_proxy_ready\\b",
"src/proxy\\.rs:.*\\bconfined_command\\b",
"src/proxy\\.rs:.*\\bwrite_token_file\\b",
"src/proxy\\.rs:.*\\blocate_proxy_binary\\b",
"src/proxy\\.rs:.*\\bpid_path\\b",
"src/proxy\\.rs:.*\\bfwd_pid_path\\b",
"src/proxy\\.rs:.*\\btoken_path\\b",

# NOTE on `delete field … from struct …` mutants: cargo-mutants (27.x) does
# not honour exclude_re for these — the regex never matches their name (not
# by function, struct path, or even file:line). They are emitted only where
Expand Down
17 changes: 14 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,22 @@ jobs:
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-

# coop-proxy's aws-lc-sys crypto backend (issue #411) needs cmake; the
# `--workspace` clippy/test below build it. Preinstalled on ubuntu-latest,
# installed explicitly so the job doesn't depend on the image contents.
- name: Install cmake
run: sudo apt-get update && sudo apt-get install -y cmake

- name: Format check
run: cargo fmt -- --check

# `--workspace` so coop-proxy is linted/tested too (it is not a default
# member, to keep plain `cargo build` and the integration scripts light).
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
run: cargo clippy --workspace --all-targets --all-features -- -D warnings

- name: Unit tests
run: cargo test
run: cargo test --workspace

- name: Integration test — coop update
run: ./tests/integration-update.sh
Expand All @@ -60,8 +68,11 @@ jobs:
- name: Install cargo-deny
run: cargo binstall --no-confirm cargo-deny@0.19.9

# `--workspace` roots the crate graph at every workspace member. With a
# root package present, cargo-deny otherwise checks only `coop`, missing
# `coop-proxy`'s async/HTTP/TLS supply chain (issue #411).
- name: Check advisories, licenses, bans, sources
run: cargo deny check
run: cargo deny --workspace check

taplo:
runs-on: ubuntu-latest
Expand Down
38 changes: 22 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ jobs:
os: macos-latest
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
# Native ARM64 Linux runner — no cross-compilation, so coop-proxy's
# aws-lc-sys builds with the native musl toolchain (issue #411).
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
os: ubuntu-24.04-arm

runs-on: ${{ matrix.os }}

Expand All @@ -38,28 +40,29 @@ jobs:
with:
persist-credentials: false

- name: Install cross-compilation tools (Linux ARM)
if: matrix.target == 'aarch64-unknown-linux-musl'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu musl-tools
mkdir -p .cargo
cat >> .cargo/config.toml <<'EOF'
[target.aarch64-unknown-linux-musl]
linker = "aarch64-linux-gnu-gcc"
EOF

- name: Install musl tools (Linux x86_64)
if: matrix.target == 'x86_64-unknown-linux-musl'
# Each target builds on a matching-arch runner (no cross-compilation).
# The Linux musl legs need musl-gcc + cmake to build coop-proxy's
# aws-lc-sys crypto backend natively; macOS ships cmake preinstalled.
- name: Install musl tools and cmake (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y musl-tools
sudo apt-get install -y musl-tools cmake

- name: Add target
run: rustup target add ${{ matrix.target }}

- name: Build
run: cargo build --release --target ${{ matrix.target }}
# `--workspace` builds both `coop` and `coop-proxy` from one source
# tree, so they ship in lockstep (issue #411). Native builds: musl-gcc
# is the C compiler (for aws-lc-sys) and the linker on each Linux
# runner. The macOS leg ignores these vars.
env:
CC_x86_64_unknown_linux_musl: musl-gcc
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
CC_aarch64_unknown_linux_musl: musl-gcc
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
run: cargo build --release --workspace --target ${{ matrix.target }}

- name: Package
shell: bash
Expand All @@ -68,6 +71,9 @@ jobs:
name="${BINARY}-${tag}-${{ matrix.target }}"
mkdir -p "staging/${name}"
cp "target/${{ matrix.target }}/release/${BINARY}" "staging/${name}/"
# coop-proxy rides in the same tarball, inheriting the identical
# SLSA build-provenance attestation with no new machinery.
cp "target/${{ matrix.target }}/release/${BINARY}-proxy" "staging/${name}/"
cp LICENSE* staging/${name}/ 2>/dev/null || true
tar -czf "${name}.tar.gz" -C staging "${name}"
echo "TARBALL=${name}.tar.gz" >> "$GITHUB_ENV"
Expand Down
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,62 @@
# Changelog

## Unreleased

### New features

- **Credential-injecting proxy — keep the model API keys out of the guest**
(#411) — New opt-in `[proxy]` config. When set, coop runs a small host-side
reverse proxy (`coop-proxy`, a new binary shipped in the same tarball) — one
process per (VM, provider) — for the lifetime of a remote-mode VM: the guest
is pointed at it and holds only a per-instance capability token, while the
real credential stays on the host and is injected onto requests upstream.
- **Claude Code** (`[proxy.anthropic]`) is 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]`) is pointed at the proxy via a
`[model_providers.coop_local]` block (Responses API) with the capability
token as the provider bearer; OpenAI API keys inject as
`Authorization: Bearer`. In proxy mode Codex's `~/.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 into the
guest, so a prompt-injected or rogue agent cannot read a usable key.
Resolution fails closed — a bad credential aborts the boot rather than booting
without injection. `coop model <vm> local` takes precedence and tears the
proxy down. The proxy binds host loopback and is reverse-tunnelled (`ssh -R`)
into the guest, so it works on both backends (Firecracker and Lima). GitHub
is a tracked follow-up. `coop update` keeps `coop` and `coop-proxy` in
lockstep.

- **The credential proxy is jailed** (#411) — the host-side `coop-proxy`
process runs confined so a proxy exploit cannot write files, execute
programs, or reach any host beyond the upstream `:443` and DNS `:53`. On
Linux it self-applies Landlock (ABI v4; host kernel ≥6.7) before serving; on
macOS the launcher wraps it in `sandbox-exec` with a Seatbelt profile. The
confinement is fail-closed — if it cannot be established the VM start aborts
rather than running the credential-holding proxy unconfined. 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`](docs/trust-model.md).

- **Per-VM credential overrides + `coop proxy status`** (#411) — A single VM can
use a different credential than the `[proxy.<provider>]` default — for
per-project billing, scope, or revocation — via `coop proxy setup [--openai]
--vm <name>`. The override is stored in that instance's state
(`<inst.dir>/proxy.json`), not a growing config table, and its secret is
namespaced separately. Resolution is override → default → off. `coop proxy
status [--vm <name>]` shows what each VM resolves to, with credentials
redacted.

- **`coop proxy setup` — store a provider credential like a GitHub PAT** (#411)
— Mirrors the `coop github` PAT wizard: paste a credential, pick a secret
backend (macOS Keychain / Linux secret-service / 1Password / 0600 file), and
coop stores it and writes the `cmd:` reference into `[proxy.<provider>]` — the
credential is never plaintext in the config. Anthropic is the default;
`--openai` configures Codex. The secret store is namespaced per service, so
proxy secrets live under their own directory rather than among the GitHub PATs.

## v0.5.4

### New features
Expand Down
Loading