Skip to content

security(mitmproxy): split volume so agent cannot read CA private key - #97

Open
shejnowicz wants to merge 8 commits into
masterfrom
fix/25-split-mitmproxy-private-volume
Open

security(mitmproxy): split volume so agent cannot read CA private key#97
shejnowicz wants to merge 8 commits into
masterfrom
fix/25-split-mitmproxy-private-volume

Conversation

@shejnowicz

Copy link
Copy Markdown
Collaborator

Fixes #25.

Summary

Before this PR, sandcat mounted a single mitmproxy-config Docker volume RO to the agent container at /mitmproxy-config/. That volume contained:

  • mitmproxy-ca.pem — CA cert + private key
  • mitmproxy-ca.p12 — PKCS12 with private key
  • wireguard.conf — WireGuard private keys

Because uid 1000 in the mitmproxy/mitmproxy:latest image aliases to vscode (uid 1000) in the agent, mode 0600 didn't help — the agent could read all of them (confirmed live pre-fix: docker exec -u vscode agent head -1 /mitmproxy-config/mitmproxy-ca.pem returned -----BEGIN RSA PRIVATE KEY-----).

This PR splits the volume:

  • mitmproxy-config (existing name, scope narrows) — private files. Mounted writable at /home/mitmproxy/.mitmproxy/ on mitmproxy, RO at /mitmproxy-config/ on wg-client (trusted, needs wireguard.conf). Not mounted to agent.
  • mitmproxy-public (new) — agent-facing files only: public CA cert, sandcat.env, cursor-cli-config.json. Mounted writable at /mitmproxy-public/ on mitmproxy, RO at /mitmproxy-config/ on agent (unchanged container-side path so app-init.sh doesn't need to change).

The mitmproxy addon writes sandcat.env and cursor-cli-config.json directly to /mitmproxy-public/. The public CA cert is copied by an entrypoint background bash loop that polls until mitmproxy generates it, then cp .tmp && mv atomically to the public volume. The mitmproxy service's healthcheck gates on the public cert being present, so wg-client (depends_on: mitmproxy: healthy) — and by transitivity the agent — start only after the public volume is populated.

Test plan

  • 135/135 CLI init bats tests pass on this branch locally (including new tests in extensions.bats + regression tests updated in regression.bats)
  • Hands-on end-to-end verification with sandcat init --agent claude + full compose stack:
    • docker volume ls — both mitmproxy-config (private) and mitmproxy-public (agent-facing) exist
    • docker exec -u vscode agent ls /mitmproxy-config/ — only public files: mitmproxy-ca-cert.pem, sandcat.env, cursor-cli-config.json
    • docker exec -u vscode agent cat /mitmproxy-config/mitmproxy-ca.pemNo such file or directory (exit 1)
    • docker exec -u vscode agent cat /mitmproxy-config/wireguard.confNo such file or directory (exit 1)
    • Public functionality intact: CA trust chain works, sandcat env vars sourced, curl https://api.github.com/ returns HTTP/2 200
    • wg-client sees private files as before (unchanged mount)

Notes on the fix history (visible in the branch's commit log)

Two runtime fix rounds after the initial split:

  1. f61b621 — freshly-mounted Docker volume is root-owned; mitmproxy user (uid 1000) couldn't write. Added chown -R mitmproxy:mitmproxy /mitmproxy-public to the mitmproxy entrypoint (runs as root before docker-entrypoint.sh drops privileges).
  2. 9559451 — the initial approach called _seed_public_ca_cert() from the addon's running() hook. The hook wasn't reliably invoked by mitmweb (no seed log ever appeared even after minutes of uptime). Switched to a background bash while [ ! -f ...]; do sleep 1; done loop in the entrypoint that polls for the cert and copies it once it appears — version-independent of mitmproxy addon lifecycle. Removed the addon-side seed method entirely.

Then a final review fix wave (bb864d9 + 267729f) updated stale regression tests and made the copy atomic via .tmp + mv.

Upgrade path for existing projects

Docker auto-creates the new mitmproxy-public volume on next sandcat run --build. Existing mitmproxy-config volumes keep their contents but the agent-side mount changes to the new volume, so the stale sandcat.env/cursor-cli-config.json become unreachable from the agent (they're re-written to the new volume by the addon). No user action required.

🤖 Generated with Claude Code

shejnowicz and others added 7 commits August 13, 2026 11:10
Two-volume topology closes the CA private-key exposure to the agent
container. mitmproxy-config narrows to private (mitmproxy + wg-client,
holds CA priv key + WireGuard priv keys + dns.conf + extra_hosts);
new mitmproxy-public holds only agent-facing files (public CA cert,
sandcat.env, cursor-cli-config.json). Agent's container-side path
stays /mitmproxy-config/ — app-init.sh unchanged.

Addon writes agent-visible dynamic files directly to public volume via
updated constants; adds _seed_public_ca_cert() to copy the CA cert
after mitmproxy generates it. Healthcheck gates on the public cert so
the agent depends chain guarantees the file exists before start.

Plan: 2 tasks — all interlocking compose+addon+test changes together
(they must land as one to keep the stack functional), plus hands-on
integration that verifies the agent can no longer read the private key
and everything else still works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…g() (#25)

Two runtime bugs found during Task 2 integration:
1. /mitmproxy-public is root:root on fresh volume mount; chown it in the
   entrypoint wrapper (runs as root before docker-entrypoint.sh drops privs).
2. CA cert not generated until after load(); move _seed_public_ca_cert() call
   to running() which fires after TLS engine startup; bump retry to 30s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
#25)

The running() addon lifecycle hook was not reliably fired — mitmweb routes
addon logs to the web UI termlog, silencing the hook. Switch to a background
sh loop in the entrypoint that polls for the cert file and copies it once
mitmproxy generates it, removing the dependency on Python addon hooks entirely.
Remove running() + _seed_public_ca_cert() methods and their now-unused imports
(shutil, time) from mitmproxy_addon_common.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…plit

assert_common_volumes was still checking .source == "mitmproxy-public"
agent-home, mitmproxy-public added to assert_named_volumes for both
claude and cursor compose helpers. Fixes 2 failing regression tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace cp src dst; chmod 644 dst with cp src dst.tmp && mv dst.tmp dst.
mv on the same filesystem is rename(2) which is atomic — the healthcheck
predicate 'test -f dst' can no longer observe a partially-written cert.
Drops the now-redundant chmod 644 (cp preserves the source mode 644).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 13, 2026 11:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the devcontainer proxy stack’s secret isolation by splitting the previous single mitmproxy-config volume into a private volume (not mounted into the agent) and a new agent-facing mitmproxy-public volume that only contains public artifacts like the CA certificate and generated config sidecars. This addresses issue #25 by preventing the untrusted agent container from reading CA/WireGuard private keys.

Changes:

  • Update compose templates so the agent mounts mitmproxy-public at /mitmproxy-config while mitmproxy-config remains private to mitmproxy + wg-client.
  • Update mitmproxy addon paths so sandcat.env and cursor-cli-config.json are written into /mitmproxy-public/.
  • Extend/init regression tests to assert the new volume exists, is mounted correctly, and the old private mount is absent from the agent.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
cli/test/init/regression.bats Updates regression assertions to expect mitmproxy-public on the agent and declare the new named volume.
cli/test/init/extensions.bats Adds tests asserting mitmproxy-public volume declaration, mount, and healthcheck gating behavior.
cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py Redirects agent-consumed sidecar outputs to /mitmproxy-public instead of the private mitmproxy home volume.
cli/templates/devcontainer/sandcat/compose-proxy.yml Adds mitmproxy-public volume, copies public CA cert into it, and updates healthcheck to gate on the public cert.
cli/templates/devcontainer/compose-all.yml Switches the agent’s mount from mitmproxy-config to mitmproxy-public while keeping the container path /mitmproxy-config.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

# the command) so docker-entrypoint.sh still sees `mitmweb` as $1 and drops
# privileges to the mitmproxy user.
entrypoint: ["/bin/sh", "-c", "rm -f /home/mitmproxy/.mitmproxy/dns.conf && exec docker-entrypoint.sh \"$@\"", "sh"]
entrypoint: ["/bin/sh", "-c", "chown -R mitmproxy:mitmproxy /mitmproxy-public && rm -f /home/mitmproxy/.mitmproxy/dns.conf && (while [ ! -f /home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem ]; do sleep 1; done; cp /home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem /mitmproxy-public/mitmproxy-ca-cert.pem.tmp && mv /mitmproxy-public/mitmproxy-ca-cert.pem.tmp /mitmproxy-public/mitmproxy-ca-cert.pem) & exec docker-entrypoint.sh \"$@\"", "sh"]
Prepend rm -f /mitmproxy-public/mitmproxy-ca-cert.pem to the entrypoint
chain, next to the dns.conf removal. This closes the race where a
stale cert from a previous run (when mitmproxy-public persists but
mitmproxy-config resets) causes the healthcheck predicate to pass
before the background waiter replaces the file with the fresh CA.
Without this, agent init installs the old CA into its trust store
while mitmproxy serves TLS with the new CA, causing handshake failure.

Addresses Copilot review finding on PR #97.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shejnowicz

Copy link
Copy Markdown
Collaborator Author

Addressed in dbd8a0f — entrypoint now rm -f /mitmproxy-public/mitmproxy-ca-cert.pem before spawning the background waiter. If the private mitmproxy-config volume gets reset while mitmproxy-public persists (e.g. explicit docker volume rm mitmproxy-config), the stale cert is deleted first, healthcheck stays failing until the background waiter's atomic .tmp + mv replaces it with the freshly-generated CA, and downstream containers wait as intended. Bats: 135/135 pass.

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.

security: sharing ~/.mitmproxy to app

2 participants