Stacked on #28 — merge that first. Until it lands the diff here shows both; afterwards it collapses to just the skill.
Why
#28 gives the container ip and wg. This gives it something that knows how to use them.
The toggle grants CAP_NET_ADMIN and /dev/net/tun and stops there, which users reasonably read as "turn the VPN on" — that gap is the reported bug that the default network doesn't route through a VPN. Rather than only documenting the gap, close it: container/skills/pia-vpn/ is baked to /opt/triple-c-skills and installed into ~/.claude/skills/ by entrypoint.sh, so asking Claude Code to connect the VPN now works.
Same mechanism Mission Control already uses. Staged under /opt because ~/.claude is a volume mount that would mask an image copy from the project's first start.
Three details that aren't incidental
VPN_SUPPORT_ENABLED is sent as 0, not omitted.~/.claude persists, so entrypoint has to be told to remove a skill left behind by an earlier run with the toggle on. An absent variable can't say that, and a stale skill is worse than no skill — it tells an agent to use a capability the container no longer has.
It's in RESERVED_ENV_EXACT, alongside MISSION_CONTROL_ENABLED. Otherwise a custom env var of the same name could claim the skill without the capability behind it, or keep it after the toggle is off. Test covers it.
The skill is re-copied every start, rm -rf'd first, so a fix reaches projects that already have an old copy, and files dropped from a later version don't linger.
What the skill carries
The parts that are easy to get wrong, all of which cost real debugging time to find:
a full tunnel captures the Docker resolver (192.168.65.7, outside the container's subnet) and takes DNS down with it — the symptom is Claude Code reporting it can't connect
an IP-literal health check (curl https://1.1.1.1/...) passes cheerfully while the resolver is dead, so status resolves a name instead
no tunnel survives a restart, and /run state riding the snapshot makes it look as though one did
It also states what it deliberately does not do — no killswitch, no autostart — so an agent surfaces those as decisions rather than improvising a killswitch that cuts the user's own session.
pia-wg.sh preflights CAP_NET_ADMIN by capability bit rather than letting the first ip call fail with a bare EPERM that points nowhere near the setting that needs changing. Credentials stay in a file (~/pia-creds, PIA_CREDS to override) rather than the environment, where docker inspect and every process in the container would see them.
Testing
install_feature_skill() exercised as the real function against a scratch container: installs, refreshes over a local edit, drops files removed in a later version, removes on disable, no-ops silently when already absent, and leaves unrelated skills in ~/.claude/skills/ alone.
Preflight with and without the capability — the error names the Config → Runtime setting.
Full up --full / down round trip with the shipped copy: handshake, DNS: ok via PIA's resolvers, api.anthropic.com -> 405 in 0.30s through the PIA exit, then routes back to the two-line baseline and resolv.conf restored.
cargo test --lib docker::container — 29 pass. Repo-wide cargo fmt/clippy failures are pre-existing (37 files, and the clippy error is in file_commands.rs); the lines added here are clean.
Stacked on #28 — merge that first. Until it lands the diff here shows both; afterwards it collapses to just the skill.
## Why
#28 gives the container `ip` and `wg`. This gives it something that knows how to use them.
The toggle grants `CAP_NET_ADMIN` and `/dev/net/tun` and stops there, which users reasonably read as "turn the VPN on" — that gap is the reported bug that the default network doesn't route through a VPN. Rather than only documenting the gap, close it: `container/skills/pia-vpn/` is baked to `/opt/triple-c-skills` and installed into `~/.claude/skills/` by `entrypoint.sh`, so asking Claude Code to connect the VPN now works.
Same mechanism Mission Control already uses. Staged under `/opt` because `~/.claude` is a volume mount that would mask an image copy from the project's first start.
## Three details that aren't incidental
**`VPN_SUPPORT_ENABLED` is sent as `0`, not omitted.** `~/.claude` persists, so entrypoint has to be *told* to remove a skill left behind by an earlier run with the toggle on. An absent variable can't say that, and a stale skill is worse than no skill — it tells an agent to use a capability the container no longer has.
**It's in `RESERVED_ENV_EXACT`,** alongside `MISSION_CONTROL_ENABLED`. Otherwise a custom env var of the same name could claim the skill without the capability behind it, or keep it after the toggle is off. Test covers it.
**The skill is re-copied every start, `rm -rf`'d first,** so a fix reaches projects that already have an old copy, and files dropped from a later version don't linger.
## What the skill carries
The parts that are easy to get wrong, all of which cost real debugging time to find:
- a full tunnel captures the Docker resolver (`192.168.65.7`, outside the container's subnet) and takes DNS down with it — the symptom is Claude Code reporting it can't connect
- an IP-literal health check (`curl https://1.1.1.1/...`) passes cheerfully while the resolver is dead, so `status` resolves a name instead
- no tunnel survives a restart, and `/run` state riding the snapshot makes it look as though one did
It also states what it deliberately does *not* do — no killswitch, no autostart — so an agent surfaces those as decisions rather than improvising a killswitch that cuts the user's own session.
`pia-wg.sh` preflights `CAP_NET_ADMIN` by capability bit rather than letting the first `ip` call fail with a bare EPERM that points nowhere near the setting that needs changing. Credentials stay in a file (`~/pia-creds`, `PIA_CREDS` to override) rather than the environment, where `docker inspect` and every process in the container would see them.
## Testing
- `install_feature_skill()` exercised as the real function against a scratch container: installs, refreshes over a local edit, drops files removed in a later version, removes on disable, no-ops silently when already absent, and leaves unrelated skills in `~/.claude/skills/` alone.
- Preflight with and without the capability — the error names the Config → Runtime setting.
- Full `up --full` / `down` round trip with the shipped copy: handshake, `DNS: ok` via PIA's resolvers, `api.anthropic.com -> 405 in 0.30s` through the PIA exit, then routes back to the two-line baseline and `resolv.conf` restored.
- `cargo test --lib docker::container` — 29 pass. Repo-wide `cargo fmt`/clippy failures are pre-existing (37 files, and the clippy error is in `file_commands.rs`); the lines added here are clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
jknapp
marked the pull request as work in progress 2026-08-17 20:24:26 +00:00
The toggle grants CAP_NET_ADMIN and /dev/net/tun and stops there, which users
reasonably read as "turn the VPN on" — the gap between the two is the reported
bug that the default network does not route through a VPN. Close it by giving
the container an agent-usable way to build the tunnel, rather than leaving
each project to rediscover it.
container/skills/ is baked to /opt/triple-c-skills and installed into
~/.claude/skills/ by entrypoint.sh from VPN_SUPPORT_ENABLED, mirroring how
Mission Control installs its own. Staged under /opt because ~/.claude is a
volume mount that would mask an image copy from first start.
Three details that are not incidental:
- The variable is sent as 0 rather than omitted when off, because ~/.claude
persists: entrypoint has to be *told* to remove a skill left by an earlier
run with the toggle on, and an absent variable cannot say that. A stale skill
is worse than none, since it instructs an agent to use a capability the
container no longer has.
- It is reserved in RESERVED_ENV_EXACT alongside MISSION_CONTROL_ENABLED, or a
custom env var of the same name could claim the skill without the capability
behind it. Covered by a test.
- The skill is re-copied on every start, rm -rf'd first, so fixes reach existing
projects and files dropped from a later version do not linger.
The skill itself carries the three things that are easy to get wrong: that a
full tunnel captures the Docker resolver and takes DNS down with it, that an
IP-literal health check cannot see a dead resolver, and that no tunnel survives
a restart while /run state riding the snapshot makes it look as though one did.
It also states what it deliberately does not do — no killswitch, no autostart —
so an agent proposes those as decisions rather than improvising them.
pia-wg.sh preflights CAP_NET_ADMIN by capability bit rather than letting the
first `ip` call fail with a bare EPERM that points nowhere near the setting
that needs changing. Credentials stay in a file (~/pia-creds, PIA_CREDS to
override) rather than the environment, where docker inspect and every process
in the container would see them.
Tested: install/refresh/remove/no-op paths of install_feature_skill against the
real function; preflight with and without the capability; and a full up --full
/ down round trip, confirming DNS via PIA's resolvers, api.anthropic.com
reachable through the exit, and routes and resolv.conf restored on teardown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`status` probed https://1.1.1.1/cdn-cgi/trace and printed the answer as
"public IP". In test mode 1.1.1.1 is the *only* address routed into the tunnel,
so that line reported a PIA exit while every other packet left directly — a
test tunnel reading exactly like a full one.
Found on a live container: default route still via eth0, one 1.1.1.1/32 route
through pia0, and the old status line claiming a PIA public IP. This is a
plausible route to concluding the VPN is on when it is not, which is close to
the confusion this skill exists to prevent.
Status now names the mode and, in test mode, prints both exits with the real
address called out. 1.0.0.1 serves the same trace endpoint as 1.1.1.1 and is
never routed into the tunnel, so the direct exit can be probed without DNS.
Verified against all four states: no tunnel, test mode on a live tunnel that
was already up, full tunnel, and after teardown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adversarial review of #29 found bugs I confirmed by reproducing each one.
**Every hand-written error message was unreachable.** `tok=$(curl ...)` is a
plain assignment, so `set -e` acts on the command substitution before the
following `|| die` can run. A wrong password produced exit 22 and no output at
all — the most likely way this gets used wrongly, and the least explained.
All four captures now go through a `run` helper that takes a *description*
rather than echoing the command, because one of them carries the account
password in `-u`.
**`up` was not idempotent, and the second run destroyed DNS.** The resolv.conf
backup was copied unconditionally, so `up --full` twice overwrote the good
backup with PIA's own resolvers; the later `down` then "restored" those and
left the container with no working DNS and no way back. `up` now runs `down`
first. Verified: two `up --full` runs, then `down`, and the backup still holds
the original 192.168.65.7.
**An empty gateway produced total connectivity loss, reported as healthy.**
`$gw` was never validated and `add_route` swallowed every failure to /dev/null.
The two half-routes need no gateway and would succeed, so the tunnel captured
everything while the exclusions keeping DNS and the Docker host reachable
silently did not exist — and `status` still printed "full tunnel". Routes are
now fatal on failure, and a via-less default (`$3` is the literal "eth0") is
rejected.
**The PIA session token was in the process arguments** — confirmed in `ps` and
/proc/*/cmdline, a ~24h bearer credential for the account readable by anything
in the container. It now goes to curl on stdin as a config. Verified: 60 polls
across a full `up`, zero sightings.
**The preflight diagnosed the wrong kernel module.** It checked /dev/net/tun
and blamed the tun module, but kernel WireGuard is a netlink interface and does
not use it — verified by creating one with NET_ADMIN and no tun device. The
check is dropped (the container could not have started without the device
anyway) and `ip link add` now reports the real dependency.
Also: a full tunnel with no DNS servers from PIA used to warn and carry on,
which is a tunnel leaking every lookup while reporting itself healthy — now
fatal. `down` validates the backup before restoring it, so a truncated one
cannot leave the container with no resolver at all. `wg.priv` is shredded on
teardown and created under umask 077, because /run rides `docker commit` into
the snapshot image. A mistyped `up --ful` is rejected instead of silently
giving a test route.
entrypoint: `install_feature_skill` gets `local`, a blank-name guard (the
disabled branch would otherwise `rm -rf` the whole skills directory under a
persisted volume), `-e`/`-L` so a leftover *file* at the destination is cleaned
up, and a chown of the parent so `claude` can still add skills of their own
when Mission Control is off. When the base image predates the skill it now says
so instead of returning silently — and `/opt/triple-c-skills` joins
FEATURE_PROBES so the migration pre-flight reports it. Docs corrected to match:
neither half reaches an existing project without a migration.
`vpn_env_var` extracted and tested, pinning the property the whole removal path
rests on — that the variable is emitted as 0 rather than omitted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit moved `down` to the top of `up` to fix a resolv.conf
idempotency bug, and in doing so put it *before* every network fetch that can
fail. Re-review caught it and I reproduced it: with a tunnel up, an `up` that
fails on bad credentials left `pia0` gone and traffic silently back on the real
address, while the error talked only about credentials. A privacy regression
introduced by a correctness fix.
`down` now runs after the token, server list and key registration have all
succeeded — nothing above that line touches the network stack — and still
clears the stale backup it was added for.
Everything after it is covered by a rollback. Note this is an EXIT trap with a
flag, not `trap ... ERR`: my first attempt used ERR and did not fire at all,
because ERR is not inherited by shell functions without `set -E`, so a failure
inside add_route missed it, and `die` exits explicitly, which is not an error.
Verified by forcing a route collision — the tunnel is torn down and DNS is
intact, where before the fix it was left half-configured with DNS dead.
Also from the review:
- **The private key lived on disk for the whole life of the tunnel.**
`commit_container_snapshot` blanks env vars, never files, and nothing tears
the tunnel down before a recreate or migrate — so the `down`-time cleanup
never covered the path that put a key in a snapshot in the first place. It is
now deleted the moment `wg set` has read it; the kernel keeps its own copy,
verified by checking the interface still works afterwards.
- **`up` claimed success without a handshake.** An unreachable peer still
routes — into a black hole — so `up --full` could exit 0 having pointed all
traffic and resolv.conf at a peer that never answered, with `status` printing
"mode: full tunnel". Now polls for a handshake and rolls back if none arrives.
- **`status` needed root and did not check.** `wg show` fails unprivileged and
was swallowed, so an unprivileged run printed "no tunnel up" and then "mode:
full tunnel" in the same breath. An agent reading the first line would re-run
`up` — which, before the fix above, destroyed the tunnel it failed to see.
- **The killswitch bullet was false.** It said `iptables` is not in the image;
it is, so a killswitch is buildable. It stays unbuilt because it would cut
Claude Code's own API traffic — an honest reason, unlike the previous one.
- `install_feature_skill` rejects path-traversal names, not just blank ones —
verified `../skills` would have deleted the whole skills directory including
Mission Control's — and reports `mkdir`/`cp` failures instead of printing a
success line regardless.
- The usage text ended by printing `set -euo pipefail`, off by one line.
- HOW-TO-USE claimed "the container says so on start". entrypoint prints to
PID 1's stdout, which no terminal or UI surfaces — `docker logs` appears
nowhere in the repo. Now points at the migration pre-flight, which does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 3 found defects in code written an hour earlier. Both reproduced.
**The handshake poll accepted empty output as a completed handshake.**
`[ "$(… | awk '{print $2}')" != 0 ]` is *true* when `wg show` prints nothing —
which it does when the interface has no peer, and when the interface is gone
(that message goes to stderr). `until` suspends `set -e` and `pipefail`, so
nothing else caught it. The poll added last round to make "success without a
tunnel" impossible produced exactly that. Now requires a number greater than
zero, and waits 20s rather than 10 so a slow link is not rolled back needlessly.
**`down` still sat above the key registration.** Last round moved it below the
token and server-list fetches but not below `addKey`, which is the most
failure-prone of the three — one gateway, by CN, pinned certificate. So a
refused registration still tore down a working tunnel. It now runs after the
last fetch; the key is generated before but written after, since `down` deletes
it. SKILL.md said "after every network fetch has succeeded", which was false;
corrected.
**`up --full` made `host.docker.internal` unresolvable — and `status` said DNS
was fine.** That name is answered only by the resolver being replaced; it is not
in `/etc/hosts`. `gateway.rs` hands it to every container for the LiteLLM
gateway, and Ollama and custom endpoints default to it, so an agent running
`up --full` silently removed the project's model backend. The route was already
excluded; only the name was lost. Now resolved with the old resolver and pinned
into `/etc/hosts` before the swap, restored on teardown, and `status` probes it
— PIA answers public names happily, which is precisely why probing only
`api.anthropic.com` reported "ok". Documented as Trap 4.
**The rollback could abort halfway.** The trap's `{ … }` is not exempt from
`set -e`, and `down`'s `cat`/`tac`/`rm` had no `|| true` — so one failure left
the interface up with all traffic captured, after printing "rolling back".
`down` now runs under `set +e`, the trap tolerates its failure, and the
interface is deleted *first*, since that removes every route pointing at it.
**The account password had a real argv window.** curl does blank `-u`, but only
once running: sampling /proc/<pid>/cmdline caught the plaintext in 2 of 400
tries, between exec and the overwrite. Small, but it is the permanent password
and the token already had the fix. Moved onto the same stdin config — 0 of 400.
Review reported this as a 25-second exposure; that was a wrapper's argv, not
curl's.
entrypoint: the skill install stages into `$_dest.new` and swaps, so a failed
copy leaves the previous copy intact instead of a truncated SKILL.md and no
script, root-owned, on a persisted volume. Verified against a size-limited
filesystem.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Stacked on #28 — merge that first. Until it lands the diff here shows both; afterwards it collapses to just the skill.
Why
#28 gives the container
ipandwg. This gives it something that knows how to use them.The toggle grants
CAP_NET_ADMINand/dev/net/tunand stops there, which users reasonably read as "turn the VPN on" — that gap is the reported bug that the default network doesn't route through a VPN. Rather than only documenting the gap, close it:container/skills/pia-vpn/is baked to/opt/triple-c-skillsand installed into~/.claude/skills/byentrypoint.sh, so asking Claude Code to connect the VPN now works.Same mechanism Mission Control already uses. Staged under
/optbecause~/.claudeis a volume mount that would mask an image copy from the project's first start.Three details that aren't incidental
VPN_SUPPORT_ENABLEDis sent as0, not omitted.~/.claudepersists, so entrypoint has to be told to remove a skill left behind by an earlier run with the toggle on. An absent variable can't say that, and a stale skill is worse than no skill — it tells an agent to use a capability the container no longer has.It's in
RESERVED_ENV_EXACT, alongsideMISSION_CONTROL_ENABLED. Otherwise a custom env var of the same name could claim the skill without the capability behind it, or keep it after the toggle is off. Test covers it.The skill is re-copied every start,
rm -rf'd first, so a fix reaches projects that already have an old copy, and files dropped from a later version don't linger.What the skill carries
The parts that are easy to get wrong, all of which cost real debugging time to find:
192.168.65.7, outside the container's subnet) and takes DNS down with it — the symptom is Claude Code reporting it can't connectcurl https://1.1.1.1/...) passes cheerfully while the resolver is dead, sostatusresolves a name instead/runstate riding the snapshot makes it look as though one didIt also states what it deliberately does not do — no killswitch, no autostart — so an agent surfaces those as decisions rather than improvising a killswitch that cuts the user's own session.
pia-wg.shpreflightsCAP_NET_ADMINby capability bit rather than letting the firstipcall fail with a bare EPERM that points nowhere near the setting that needs changing. Credentials stay in a file (~/pia-creds,PIA_CREDSto override) rather than the environment, wheredocker inspectand every process in the container would see them.Testing
install_feature_skill()exercised as the real function against a scratch container: installs, refreshes over a local edit, drops files removed in a later version, removes on disable, no-ops silently when already absent, and leaves unrelated skills in~/.claude/skills/alone.up --full/downround trip with the shipped copy: handshake,DNS: okvia PIA's resolvers,api.anthropic.com -> 405 in 0.30sthrough the PIA exit, then routes back to the two-line baseline andresolv.confrestored.cargo test --lib docker::container— 29 pass. Repo-widecargo fmt/clippy failures are pre-existing (37 files, and the clippy error is infile_commands.rs); the lines added here are clean.🤖 Generated with Claude Code
428229bd5ato49ac67304549ac673045to32149603b2upfrom tearing down a working tunnel 5b96ad4823Round 3 found defects in code written an hour earlier. Both reproduced. **The handshake poll accepted empty output as a completed handshake.** `[ "$(… | awk '{print $2}')" != 0 ]` is *true* when `wg show` prints nothing — which it does when the interface has no peer, and when the interface is gone (that message goes to stderr). `until` suspends `set -e` and `pipefail`, so nothing else caught it. The poll added last round to make "success without a tunnel" impossible produced exactly that. Now requires a number greater than zero, and waits 20s rather than 10 so a slow link is not rolled back needlessly. **`down` still sat above the key registration.** Last round moved it below the token and server-list fetches but not below `addKey`, which is the most failure-prone of the three — one gateway, by CN, pinned certificate. So a refused registration still tore down a working tunnel. It now runs after the last fetch; the key is generated before but written after, since `down` deletes it. SKILL.md said "after every network fetch has succeeded", which was false; corrected. **`up --full` made `host.docker.internal` unresolvable — and `status` said DNS was fine.** That name is answered only by the resolver being replaced; it is not in `/etc/hosts`. `gateway.rs` hands it to every container for the LiteLLM gateway, and Ollama and custom endpoints default to it, so an agent running `up --full` silently removed the project's model backend. The route was already excluded; only the name was lost. Now resolved with the old resolver and pinned into `/etc/hosts` before the swap, restored on teardown, and `status` probes it — PIA answers public names happily, which is precisely why probing only `api.anthropic.com` reported "ok". Documented as Trap 4. **The rollback could abort halfway.** The trap's `{ … }` is not exempt from `set -e`, and `down`'s `cat`/`tac`/`rm` had no `|| true` — so one failure left the interface up with all traffic captured, after printing "rolling back". `down` now runs under `set +e`, the trap tolerates its failure, and the interface is deleted *first*, since that removes every route pointing at it. **The account password had a real argv window.** curl does blank `-u`, but only once running: sampling /proc/<pid>/cmdline caught the plaintext in 2 of 400 tries, between exec and the overwrite. Small, but it is the permanent password and the token already had the fix. Moved onto the same stdin config — 0 of 400. Review reported this as a 25-second exposure; that was a wrapper's argv, not curl's. entrypoint: the skill install stages into `$_dest.new` and swaps, so a failed copy leaves the previous copy intact instead of a truncated SKILL.md and no script, root-owned, on a persisted volume. Verified against a size-limited filesystem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>32149603b2to48d0c3249aView command line instructions
Checkout
From your project repository, check out a new branch and test the changes.