vpnchecker
A VPN kill switch for macOS, built for GlobalProtect (works with any VPN that creates a utunN interface). It guarantees that Claude, Cursor and Rider reach the internet only through the VPN, and by default restricts the whole machine the same way (mode: full); mode: scoped limits it to those three apps.
mode |
What happens while the VPN is down |
|---|---|
scoped |
Only Claude, Cursor and Rider are affected. Everything else, including GlobalProtect reconnects, captive portals and split-tunnel traffic, keeps working. |
full (default) |
Additionally all outbound traffic is blocked except the VPN client itself (portal / gateways) and the local network. |
Claude / Cursor / Rider protection is always on, in both modes. It has three layers, so no single one has to be perfect:
- Freeze – every Claude / Cursor / Rider process and all its children (MCP servers, terminals, extension hosts) is frozen with
SIGSTOP(or killed) the moment the VPN drops, and resumed when the VPN is verified again. A frozen app cannot send anything. - DNS shield – lookups for
anthropic.com,claude.ai,claude.com,cursor.sh,cursor.comare answered by a local responder through per-domain resolver files in/etc/resolver. The query never leaves the Mac, so the ISP / router never sees which AI service you were about to use. - Kernel block – a pf rule refuses the known Claude / Cursor server addresses on every interface except the tunnel. Unlike the freeze it has no reaction window: it also covers the first packets after wake-up, before the daemon has run.
Layers 2 and 3 know only the Claude / Cursor servers. Rider (JetBrains) is protected by the freeze alone: it is an IDE that talks to your own repositories and services, so none of its hosts are shielded or blocked by default. Add them to protect.domains / protect.hosts if you want that.
How it works
The daemon runs as a root launchd daemon, starts at boot and is restarted if it exits. It reacts to kernel routing / address / interface changes (PF_ROUTE socket) within milliseconds and polls every check.interval as a fallback.
VPN state. The VPN is up when an interface matching interface_pattern has an address from vpn.subnets and the default route goes through it (GlobalProtect keeps utunN with a pool address after disconnect, so the interface alone is not enough) and GlobalProtect does not report a disconnected tunnel. GlobalProtect's own state is read from the root-owned settings plist (tunnel-status, current gateway and tunnel address); only an explicit "disconnected" counts, so an unknown value can never freeze your apps by itself.
Sleep / wake. While the Mac sleeps nothing runs, and right after wake GlobalProtect may still show a dead tunnel as up. The daemon notices the clock jump (protect.wake_gap), freezes first, keeps the apps frozen for at least protect.wake_hold, and resumes only after the VPN passed protect.resume_checks consecutive checks spread over a minimum time. Every wake therefore costs a short pause even when the VPN is fine. The kernel block (layer 3) covers the few hundred milliseconds before the daemon's first check.
Gateway auto-discovery. The daemon learns VPN gateways from five sources so GlobalProtect can always reconnect, also to a new gateway after a reconnect (relevant in full mode and for keeping gateways out of the protected set):
- the gateway GlobalProtect reports for its current connection (root-owned plist; no DNS needed, the address is allowed the moment it changes);
- the portal and gateway host names from GlobalProtect's settings and
pan_gp_event.log, re-resolved everyresolve_interval, right after the VPN drops, and whenever GlobalProtect reports a different gateway; - host routes the kernel installs for the gateway outside the tunnel (
allow.learn_gateways); - in
fullmode, every lookup underallow.gateway_suffixes(defaultgpcloudservice.com) and forallow.probe_hosts(defaultcaptive.apple.com,clients3.google.comandwww.msftconnecttest.com, the three hosts of GlobalProtect's captive-portal test) goes through the DNS forwarder, so the exact address the VPN client receives is allowed before it connects. This covers gateways the client has never used, rotating addresses, and the probe: GlobalProtect takes two refused probes for a captive portal and then waits instead of connecting; allow.domains/allow.cidrsyou add yourself. Each domain covers all its subdomains: the daemon runs a DNS forwarder on127.0.0.1:10053and writes/etc/resolver/<domain>for each entry, so every lookup under it goes through the forwarder and the answer's addresses enter the pf allow table before the answer reaches the app. Upstream is taken fromscutil --dnsper query (VPN split DNS keeps working).
Every address is kept for resolve_retain and persisted in /var/db/vpnchecker/cache.json. Host names found only in the user-writable event log are trusted only if they end in one of allow.gateway_suffixes (default gpcloudservice.com), or equal a portal or the gateway GlobalProtect reports itself.
full mode rules (anchor com.apple/vpnchecker; the stock /etc/pf.conf already evaluates com.apple/*, so system config is not modified):
pass out quick on lo0 all
pass out quick on utun4 all keep state (if-bound) # the tunnel
pass out quick proto { tcp udp } to any port 53 keep state # DNS (system resolver)
pass out quick proto udp to any port { 67 68 123 546 547 } keep state
pass out quick inet6 proto icmp6 all keep state
pass out quick to <vpnchecker_allow> keep state # VPN portal/gateways, LAN
block return out quick all
scoped mode rules:
table <vpnchecker_protected> persist { …Claude / Cursor server addresses… }
pass out quick on utun4 to <vpnchecker_protected> keep state (if-bound)
block return out quick to <vpnchecker_protected>
Surviving its own exit. On SIGTERM / SIGINT the daemon leaves rules that pass only traffic sourced from vpn.subnets, frozen apps stay frozen and the DNS shield stays up; launchd restarts the daemon, and vpnchecker stop releases everything. On a crash or SIGKILL the last loaded rules stay in pf.
Requirements
- macOS (Apple Silicon or Intel)
sudofor installation (pf,/etc/resolverand signalling other users' processes require root)
Installation
Connect the VPN, then:
|
To restrict only Claude / Cursor / Rider and leave the rest of the Mac alone, pass the mode through to the script:
|
The script downloads the prebuilt universal binary from the latest GitHub release, verifies its checksum, creates ~/.config/vpnchecker/config.yaml if missing, refuses to continue if the VPN is not detected (it would cut the internet), runs sudo vpnchecker install and links /usr/local/bin/vpnchecker. --mode scoped|full (or VPNCHECKER_MODE) sets mode in the config, also when the config already exists; without it a new config starts in full. Run the same command to update. If the VPN pool is not 10.0.0.0/8, set vpn.subnets in the config and run it again.
From source
# 1. Create the config and set vpn.subnets (see Configuration)
# 2. Check detection with the VPN connected
# 3. Install as a launchd daemon
install copies the running binary to /Library/PrivilegedHelperTools/vpnchecker (owned by root:wheel, so a regular user cannot swap it), writes /Library/LaunchDaemons/com.vpnchecker.daemon.plist and starts the daemon. It starts on boot and is restarted if it exits. Use the full path with sudo — sudo has its own PATH. Re-run install after every upgrade.
Commands
| Command | Root | Description |
|---|---|---|
install / uninstall |
yes | Install / remove the launchd daemon (uninstall also removes pf rules, resumes frozen apps and removes the DNS shield) |
start / restart |
yes | Start / restart the installed daemon |
stop [--keep-block] |
yes | Stop the daemon, remove its pf rules (--keep-block keeps the exit rules), resume frozen apps, remove the DNS shield |
flush |
yes | Remove vpnchecker pf rules immediately |
status [--json] |
no | VPN state, GlobalProtect gateway, mode, protection state, frozen processes, DNS shield |
check [-q] |
no | Check the VPN once. Exit code: 0 up, 1 down, 2 error |
procs |
no | List the Claude / Cursor / Rider processes covered (STAT T = frozen) |
diagnose |
no | Interfaces, checks, GlobalProtect connection, protected servers, resolved allowlist domains, learned gateways |
reload |
owner | Reload the config in the running daemon (same as SIGHUP) |
allow <domain|URL> |
owner | Add a domain (with subdomains) to allow.domains in the config, keeping comments, and reload the daemon |
logs [-f] [-n N] |
no | Show /var/log/vpnchecker.log |
config init [--force] / validate / path |
no | Manage the config file |
run [--dry-run] |
yes (no with --dry-run) |
Run in the foreground. --dry-run logs rules and what would be frozen instead of doing it |
Global options: --config <path> (env VPNCHECKER_CONFIG), --socket <path> (default /var/run/vpnchecker.sock), -v/-vv.
Configuration
Path: ~/.config/vpnchecker/config.yaml. Unknown fields are rejected. Full annotated example: config.example.yaml.
mode: full # full (default) | scoped
vpn:
interface_pattern: '^(utun\d+|gpd\d+)$'
subnets: # VPN address pool, required
check_globalprotect: true
protect: # Claude / Cursor / Rider, always active
action: stop # stop (freeze) | kill
dns_shield: true
pf_block: true
observe_connections: false
extra_process_patterns: # add your own regexes
wake_gap: 3s
wake_hold: 5s
resume_checks: 4
Everything under protect has a default covering Claude.app, Claude Code (claude), Cursor.app, cursor-agent, Rider.app (including Rider.Backend and fsnotifier), their extensions and every child process; run vpnchecker procs to see what matches on your machine.
vpn.subnets: the address pool the VPN assigns. Look at the utunN address with the VPN connected (ifconfig); GlobalProtect hands out a different address each time, so use the whole pool (e.g. 10.4.0.0/16), not one address.
What to put into allow: for the VPN itself with GlobalProtect usually nothing. It matters in full mode: the VPN client must be able to reconnect (other VPN clients, and above all the identity provider of a SAML / SSO login, e.g. vpnchecker allow jumpcloud.com), and services that should keep working without the VPN go there too, with all their subdomains: vpnchecker allow telegram.org. A domain cannot be in allow.domains and protect.domains at the same time.
Verification
Disconnect GlobalProtect: Claude, Cursor and Rider freeze within a fraction of a second (vpnchecker procs shows STAT T), vpnchecker status shows Blocked. Reconnect: after a second or two they resume. Closing and opening the laptop lid does the same, with a pause of about wake_hold.
Troubleshooting
- Claude / Cursor / Rider stay frozen although the VPN is up —
vpnchecker statusandvpnchecker checkshow which check fails (usuallyvpn.subnetsdoes not contain the tunnel address, or GlobalProtect reports a state containing "disconnect"). Fix the config andvpnchecker reload, orsudo vpnchecker stopto release everything. - After a crash apps are still frozen —
sudo vpnchecker stop, orsudo pkill -CONT -f 'Claude.app|Cursor.app|Rider.app'. - A protected domain does not resolve although the VPN is up — the shield files are removed when the VPN is verified;
sudo vpnchecker stopremoves them at once (ls /etc/resolvershows files starting with# managed by vpnchecker). vpnchecker never touches resolver files it did not write. fullmode: the VPN does not reconnect while blocked — the client needs a host that is not allowlisted. Gateways underallow.gateway_suffixesand the captive-portal probe (allow.probe_hosts) are covered automatically. What usually remains is the identity provider of a SAML / SSO login: its page opens in the browser and must load without the VPN, e.g.vpnchecker allow jumpcloud.com(okta.com,login.microsoftonline.com, ...). The login works without it only while GlobalProtect's saved auth cookie is valid, so add it before you need it. Already locked out:sudo vpnchecker stop, connect, add the host,sudo vpnchecker start.vpnchecker diagnoselists the zones in effect. Considerscopedmode, which never touches the VPN client's traffic.- Captive portals (hotels, airports) — in
scopedmode the login page just works (only Claude / Cursor / Rider are held back). Infullmode:sudo vpnchecker stop, log in,sudo vpnchecker start. - Daemon does not start —
vpnchecker logs. Anunknown fielderror means the installed binary is older than the config: reinstall withsudo "$(which vpnchecker)" install. - Uninstall —
sudo vpnchecker uninstallremoves the daemon, plist, binary, pf rules and DNS shield, resumes frozen apps and releases the pf token.
Claude Code hook (optional)
The freeze already stops Claude Code; a hook gives a clear message instead of a hang. In ~/.claude/settings.json:
Limitations
- Process-level protection is not a firewall. Between a VPN drop and the freeze there are tens of milliseconds (routing event, process scan, signal); the kernel block closes that gap only for the known server addresses. A new, never-seen Claude / Cursor address, or a process the patterns do not match, is covered only by the freeze (or not at all, for unmatched processes).
- The kernel block is destination-based, not per-process. While the VPN is down, every app on the Mac is refused the Claude / Cursor server addresses; shared CDN addresses (e.g. Cloudflare for
api3.cursor.sh) may make a few unrelated sites unreachable.observe_connectionswidens this (GitHub etc.), which is why it is off by default. - The DNS shield blocks the protected domains for every app (a browser cannot open
claude.aiwhile the VPN is down). Cached answers may still be served, but no query leaves the Mac, and the frozen apps make no connections. It works for programs that use the system resolver (getaddrinfo); a program with its own DNS client or DNS-over-HTTPS bypasses it, which the freeze and the kernel block still cover. - Apps with their own DNS (DoH, hardcoded resolvers) bypass
/etc/resolver: subdomains of allowed domains they look up are not learned and stay blocked infullmode. - No protection before the daemon starts after boot, and none while it is stopped.
fullmode: blocks traffic that the VPN routes outside the tunnel (split-tunnel exclusions) and captive portals; DNS (port 53) is allowed to any server, so lookups of other domains go to the local resolver while the VPN is down.- Trust: the daemon is root and reads a user-owned config and the user-writable GlobalProtect event log. The config holds only patterns, host names, subnets and timings (host names are validated strictly because they become file names), but a user who can edit it can make the daemon freeze arbitrary processes. Log-derived hosts are filtered by
gateway_suffixes. - First start with the VPN down and an empty cache has no kernel block yet (nothing learned); the freeze and DNS shield still apply.
Development
Tests cover the state machine (guard), process selection and real SIGSTOP / SIGCONT on a child process, the DNS responder over real UDP, pf rule rendering, GlobalProtect plist parsing and config validation.
Release: bump version in Cargo.toml, commit, git tag vX.Y.Z && git push --tags. The release workflow builds the universal binary and publishes it with install.sh to GitHub Releases.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.