§1
RoamSwitch is a menu-bar app for the Mac. Depending on how much you trust the network you are
currently connected to, it automatically switches the macOS firewall, sharing services, AirDrop,
and DNS. It also watches for ARP spoofing, ports open to the outside, USB storage, and
ransomware-like encryption activity, and when it detects something dangerous it will go as far as
cutting off traffic with the packet filter (pf) — an emergency air-gap.
In other words, RoamSwitch installs a root-privileged daemon and, if it wanted to, could stop all network traffic on the Mac. It is built by one person, and "please trust me" is not enough to justify that privilege. So instead, this document explains the design in a form you can verify.
Who this is for
- Engineers deciding whether to install it
- Security researchers and journalists who want to understand the internals before a review or an article
- Security staff at partner companies evaluating an internal rollout or OEM bundling
What this document does not cover
It does not go into detection-threshold tuning, false-positive statistics, or UI walkthroughs.
What it explains is four things: privileges, process boundaries, data flow, and
cryptography. The feature specifications themselves are in the bundled MCP resource
roamswitch://docs/features and in the in-app help.
What is disclosed, and how far (disclosure policy)
This document is written on the assumption that an attacker already has the distributed binary.
Every endpoint URL, identifier, file path, XPC protocol, and embedded public key that appears here
can be pulled out of the shipping RoamSwitch.app in a few minutes with
strings, codesign -d, or a traffic proxy. Writing them down here therefore
gives an attacker nothing new. The only thing it advances is a reviewer's understanding.
On the other hand, server-side implementation that cannot be seen from the binary — rate-limit thresholds, keys, admin endpoints, the DB schema, the Firebase project layout — is not included. The OEM and partner integration design is also out of scope here and is covered in a separate internal document. "What you can learn by observing the client" is the disclosure line for this document.
This document corresponds to the source for the version noted at the top. Where behavior changes
in a later version, the document is revised and the version number and target build are updated.
If you find a discrepancy between the text and the code, please let us know at
lafine.net/contact.html.
§2
RoamSwitch.app is made up of three executables. Only one of them is privileged; the
other two run with login-user rights. All three ship with Hardened Runtime enabled, Developer ID
signed, and notarized.
| Executable | Privilege | Can do | Cannot do |
|---|---|---|---|
| RoamSwitch .app |
Login user | Monitor network state, run diagnostics, draw the UI, call the helper over XPC, change AirDrop via defaults, launch ClamAV (optional) |
Directly operate the firewall, pf, or system daemons (all of this goes through the helper) |
| RoamSwitch Helper |
root | Only the operations listed in HelperProtocol (§3 table): firewall/stealth, load/unload of the sharing daemons, applying the pf ruleset, changing DNS, sending signals to processes |
Anything else. There is no interface for running arbitrary commands. It has no network-send entitlement either |
| RoamSwitch MCPServer |
Login user | Read and format diagnostic values, search the local knowledge base; results are returned to the client over stdio | Change settings, toggle lockdown, isolate a port, eject a device. It opens no socket. It sends nothing over the network |
Entitlements and signing
- All three targets have
ENABLE_HARDENED_RUNTIME = true. - App Sandbox is disabled (
com.apple.security.app-sandbox = false). The helper's and MCPServer's entitlements are empty dictionaries. - Distribution builds are Developer ID Application signed, Apple notarized, and stapled (§10).
App Sandbox is not used. RoamSwitch needs to read the hardware UUID from IOKit, use CoreWLAN and
DiskArbitration, enumerate other processes' listening sockets (lsof), open an XPC
connection to a LaunchDaemon, and spawn system binaries. None of this is possible inside the
sandbox, so it is left disabled.
Four things compensate for that. First, Hardened Runtime. Second, Developer ID signing and notarization. Third, only one executable runs as root — the helper — and what that helper can do is fixed and enumerated (the §3 table). Fourth, connections to the helper are restricted by code signature (§3).
§3
How it is registered
The helper is registered as a LaunchDaemon using SMAppService.daemon(plistName:). The
plist embedded in the app
(Contents/Library/LaunchDaemons/com.tetsuharu.RoamSwitch.Helper.plist) declares only
Label, BundleProgram, a single MachServices entry, and
AssociatedBundleIdentifiers. Because of how SMAppService works,
registration is not even possible unless the app is in /Applications. On the first
registration, the helper does not become active until the user approves it by hand in System
Settings.
Whose connections it accepts (ClientValidator)
The helper inspects the connecting process in NSXPCListener's
shouldAcceptNewConnection and hands out HelperProtocol only to those that
pass. The inspection uses the audit_token rather than the PID, to
avoid PID reuse and TOCTOU.
# Code-signing requirements the Release build demands of connecting clients
identifier "com.tetsuharu.RoamSwitch"
and anchor apple generic
and certificate leaf[subject.OU] = "GV76B6G4YU"
This requirement is checked with SecCodeCopyGuestWithAttributes and
SecStaticCodeCheckValidity, and the connection is dropped if it does not pass. Only
DEBUG builds drop the team-ID pin, for development convenience. What actually ships is always a
Release build.
The helper's safety rests on this single code-signing requirement. Anything that satisfies it (a
properly signed RoamSwitch.app) can call every operation in the table below. There is
no channel for feeding it arbitrary commands, but the operations in that table are not weak in
themselves. If RoamSwitch.app itself is taken over, these operations pass to the
attacker.
What the helper can do (the complete list)
The privileged operations defined in Shared/HelperProtocol.swift are all of them.
There is no privileged API that is not listed here.
| Method | What it does | Binary / API invoked |
|---|---|---|
| setBlockAll(_:) | Turns the application firewall and stealth mode on or off | /usr/libexec/ApplicationFirewall/ --setblockall / --setstealthmode |
| getBlockAllStatus(...) | Reads the current values of the above | socketfilterfw --getblockall |
| setSharingServicesEnabled(_:) | unload / load of the SSH / SMB / Screen Sharing daemons. When stopping, it records only "the ones that were running" and restores only those | /bin/launchctl list / unload -w / load -w (fixed to the three: ssh.plist / com.apple.smbd.plist / com.apple.screensharing.plist) |
| enableNetworkAirGap(...) disableNetworkAirGap(...) |
Applies and lifts the emergency full block (block drop all). Goes through PFRulesetCoordinator (§4) |
/sbin/pfctl -f / -e / -sr |
| setGuardedDevServerPorts(_:) | Uses pf to block only external connections to the given dev-server ports (localhost passes through). Passing an empty array lifts all of them | /sbin/pfctl (also via the Coordinator) |
| setSecureDNSServers(_:) restoreOriginalDNSServers(...) getCurrentDNSServers(...) |
Switches the DNS of active network services to malware-blocking DNS (Quad9 9.9.9.9 / Cloudflare 1.1.1.2), backing up the original settings and restoring them |
/usr/sbin/networksetup -listallnetworkservices / -getdnsservers / -setdnsservers |
| setLinkGuardSinkhole(_:) | Link Guard (§5). Writes the given phishing/scam domains into a delimited managed section of /etc/hosts as 0.0.0.0, then flushes the DNS cache. An empty array removes the section. Domains are normalized and de-duplicated; IPs and junk are dropped; capped at 60,000; written via a temp file + atomic replace |
rewrites /etc/hosts (FileManager.replaceItemAt)/usr/bin/dscacheutil -flushcache /usr/bin/killall -HUP mDNSResponder |
| lockGatewayARP(_:) unlockGatewayARP(...) getGatewayARPLockStatus(...) |
Preventive gateway ARP/NDP lock (§5). Pins the given IP → MAC mappings (the IPv4 gateway, the IPv6 default router, on-link DNS resolvers) as permanent neighbour-cache entries. IP and MAC formats are validated; a reconcile model (pins not in the request are removed). The pin set is persisted to gateway_arp_lock.json |
/usr/sbin/arp -s / -d /usr/sbin/ndp -s / -d |
| wireGuardImport(_:) wireGuardForget(...) |
VPN tunnel (§5). The helper saves / deletes the WireGuard .conf text at 0600 |
file write only |
| wireGuardUp(endpointIPv4:endpointIPv6:port:) wireGuardDown(...) wireGuardStatus(...) |
Bring the tunnel up / down / read status. The endpoint hostname is resolved by the app and the IP is passed to the helper (the helper's DNS can be cut by the kill-switch) | Homebrew wg-quick up/down, wg show (wireguard-tools; disabled if not installed) |
| terminateProcess(pid:forceKill:) | Suspends (SIGSTOP) or force-quits (SIGKILL) a process. Used to contain ransomware-like processes. pid > 1 only |
kill(2) system call (not a subprocess) |
| getHelperVersion(...) | Returns the helper's version string (used for app-compatibility checks) | — |
terminateProcess can send SIGKILL to any process, as long as
pid > 1. setSecureDNSServers accepts any DNS-server string. That is
the width the feature needs, but it is not narrow. Judge it with the understanding that the
code-signing check in front of it (ClientValidator) is the only gate.
State inside the helper
HelperTool.sharedis a single instance shared across connections. It used to be a separate instance per connection, so an emergency containment that opened a new connection could hit a race that lost track of "which services to bring back."- The sharing-service and DNS backups are only mutated on a serial queue (
stateQueue).
§4
Four features touch pf: the emergency air-gap, the dev-server port guard, the VPN tunnel's
WireGuard kill-switch (§5, 1.7.6+), and its Tailscale kill-switch (§5, 1.8.0+ — only the selected backend's). All of them always go
through a single entry point, PFRulesetCoordinator, and never run
pfctl -f themselves.
Why there is a single entry point
Previously the two features each loaded rules with pfctl -f independently, competing
over pf's single main ruleset. If the port guard's narrow rule block ... port {…} was
loaded after the air-gap's block drop all, you could end up in a state where the screen
said "isolated" but the Mac was still reachable. This bug was found by actually attacking the
machine from another host, and was fixed in 1.4.3 (the story is written up in
docs/marketing/zenn/03_lan_side_attack_test.md).
How it works now
- Rebuilt in full every time. The entire required ruleset is rebuilt from the current state and applied in one shot. It is never applied as a diff.
- One serial queue. Every pf change runs on the same
DispatchQueue, so whether it came from an XPC connection, the helper's startup, or the failsafe timer, changes are processed in order. - The priority order is as follows (items higher up take precedence).
- Emergency air-gap →
set skip on lo0andblock drop all(nothing else is considered) - VPN kill-switch →
block drop allpluspass quickfor only:lo, the tunnel interface (utunN), the UDP handshake to the pinned endpoint IP(s), DHCP, and ICMP - Dev-server guard →
block drop in quick proto tcp ... port { … } - None → reload
/etc/pf.confand return pf to its original state
- Emergency air-gap →
- Read back after applying.
pfctl -srreads the rules back to confirm thatblock drop all, or each port's rule, is actually loaded. A case wherepfctl -fwas silently ignored is not treated as success. - The temp file is written to a path containing a
UUIDand deleted once applied (v1.4.5 dropped the fixed path in favor of a hard-to-guess one). The state directory is/Library/Application Support/RoamSwitch.
The XPC responses from enableNetworkAirGap and setGuardedDevServerPorts,
(Bool, String?), report whether the operation passed all the way through the
read-back. The caller (such as ARPSpoofContainmentManager) retries on failure, and if
it still fails it puts the message straight on screen: "Traffic is not stopped yet. Turn off
Wi-Fi now."
§5
The kinds of block are each different
| Kind | Scope | Trigger | loopback |
|---|---|---|---|
| Emergency air-gap | Stops all traffic, incoming and outgoing | When ransomware-like encryption activity is detected. On ARP-spoofing detection it fires immediately only in Lockdown; on Balanced / trusted networks it notifies instead (you trigger it manually). On a known-bad "ClickFix"-style command pattern in shell history, such as decoding base64 straight into a shell or AppleScript (1.8.7+, Pro, off by default). Not used for everyday away-from-home protection | Passed through with set skip on lo0 |
| VPN kill-switch | Everything except the tunnel, its handshake, DHCP, and ICMP | When the VPN tunnel (§5, Pro, off by default) is on and you join an untrusted network. Held until the tunnel is established (and while it is down) | Passed through with set skip on lo0 |
| Dev-server port guard | Only external TCP connections to the given ports | A one-click manual isolation, or automatic blocking when an unfamiliar listening port is detected (Pro) | From localhost, unchanged |
| Everyday untrusted-network protection | Firewall and stealth, sharing stopped (§6). pf is not used | When you connect to a network you have not registered | — |
| Link Guard | Only name resolution of phishing/scam domains (0.0.0.0 via /etc/hosts). pf is not used |
A destination on the threat feed, or a brand-name homograph. On by default (Pro) | Not affected |
| Preventive ARP/NDP lock | Only the MAC of the gateway, the IPv6 router, and on-link DNS (neighbour cache). pf is not used | On joining an untrusted network (Pro, off by default). Re-pinned on every network change | Not affected |
How the air-gap is kept from lingering
- It lifts after 10 minutes at most. The helper itself still runs
releaseAirGapIfExpired()right after startup and on a 60-second timer, but as a fully independent safety net, a dedicated LaunchDaemon (AirGapFailsafe) with noKeepAliveat all wakes on its own every 3 minutes and performs the same check and release. If the helper itself crashes and takes its own self-release timer down with it, this safety net is unaffected (1.8.6+). - This dedicated daemon never touches the interactive helper's own launch configuration. An earlier attempt (1.8.5) used the helper's own
KeepAlive.PathStatefor this recovery, but it occasionally interfered with a normal app Quit and caused an unintended restart, so it was reverted in favor of the current, fully independent daemon. - The timestamp check uses a hybrid of monotonic uptime (
ProcessInfo.systemUptime) and wall-clock time. It normally prefers monotonic uptime, so NTP corrections and manual clock changes don't affect it. It falls back to the wall clock only when uptime can't be trusted — right after a reboot, detected when the current value is smaller than the stored one. - It re-applies after a reboot or a daemon respawn. On startup the helper reads the on-disk state with
reapplyFromDisk()and restores it itself, in the order air-gap, port guard, system default (the 10-minute rule applies here too). - A failed lift is treated as a failure. If
block drop allcould not actually be removed, the timestamp is written back so that the failsafe timer and the retry have something to converge on. The screen never falsely shows "lifted." - You can take back control at any time — with the lift button in the modal, or simply by turning off Wi-Fi.
How a port-guard false positive is recovered
The unknown-port auto-block (Pro, on by default once Pro is activated) can stop a legitimate
LAN receiver — LocalSend, Syncthing, anything started after the guard was enabled. When that
happens, allow it from the "Allow" button on the notification banner or the matching row in
the "Exposed ports" screen. An executable allowed once is recorded as known and is not blocked
again (PortAnomalyGuard.allowPort(_:)). Note that generic script interpreters
(Python, Node.js, Netcat, etc.) are strictly scoped by path:port rather than binary
alone to prevent living-off-the-land attacks. Apple system daemons that satisfy
anchor apple (rapportd, which backs Handoff, and the like) are not
watched in the first place.
Self-healing ransomware canary bait & containment suppression
If canary decoy files are tampered with or renamed and trigger the emergency air-gap, releasing containment after verifying security automatically regenerates missing or corrupted canary files back to their authentic baseline hash generated from embedded templates, immediately restoring uninterrupted kqueue surveillance (preventing adversary baseline contamination). Furthermore, during active containment (while the emergency modal is displayed), redundant notification alerts and re-trigger events from periodic background integrity polling are automatically suppressed to avoid distracting the user during incident response.
Multi-layer download detection (static signatures + ClamAV) — static signature layer added in 1.8.7
Files newly placed in the Downloads, Desktop, or Documents folders first pass through a lightweight static signature check by StaticSignatureScanner (the first 4MB only, simple AND/OR byte-pattern matching — no regular expressions or entropy calculations). It needs no EndpointSecurity entitlement and keeps working even where ClamAV isn't installed. Detection is deliberately scoped to what can be stated as fact without a real malware corpus: the industry-standard EICAR test signature, and five textbook reverse-shell one-liners documented in public offensive-security references (e.g. PayloadsAllTheThings) — bash/sh's /dev/tcp/, netcat's -e, Python's pty.spawn, Perl's Socket, and PHP's fsockopen. We don't write byte-level "signatures" for named malware families without a real sample to extract them from — that would just be a false sense of security. The patterns themselves are stored XOR 0x5A-obfuscated, so RoamSwitch's own binary never embeds a literal EICAR string or reverse-shell one-liner that ClamAV itself could flag it for.
Only files the static signature layer clears as "clean" go on to the ClamAV scan. Files newly placed in the Downloads, Desktop, or Documents folders are scanned by ClamAV immediately regardless of a .tmp extension or a com.apple.quarantine attribute (e.g. a copy made over Terminal). EICAR and other industry-standard test signatures are treated as harmless: no notification is shown, only an entry in notification history — and they are never quarantined or blocked (only genuine malware samples are quarantined). And if a file with the same name was already quarantined before, it's still moved to the Quarantine folder under a timestamped, unique name, so a threat can never linger at its original location due to a naming collision.
Monitoring new login-item persistence (LaunchAgent/LaunchDaemon) — 1.8.7
New .plist files placed under ~/Library/LaunchAgents, /Library/LaunchAgents, or /Library/LaunchDaemons are detected in real time via FSEvents (PersistenceMonitorGuard). Without EndpointSecurity's ES_EVENT_TYPE_AUTH_CREATE, the write itself can't be blocked. What this can do is notice within seconds and judge the content.
The judgment turns on what's being launched directly, not the signature of the executable itself. Real-world 2026 techniques — a LaunchAgent disguised as Google Update hiding a base64-decoded bash script, or a root LaunchDaemon that re-executes a base64-decoded AppleScript payload on every boot — both hide the malicious code in the script itself while calling a validly-signed /bin/bash or /usr/bin/osascript plain. A check that only looks at the executable's own signature would let both straight through. So any newly-registered LaunchAgent/LaunchDaemon that invokes a raw script interpreter directly (bash, sh, zsh, osascript, python3, perl, ruby, php, and similar) is flagged unconditionally, regardless of that interpreter's own valid signature. The arguments passed to it are also run through StaticSignatureScanner, and any known-bad pattern found is included in the notification. Entries registered via BundleProgram (pointing at a signed, compiled binary inside an app bundle — including RoamSwitch's own helper) are simply verified the normal way, with codesign --verify --strict.
Detecting ClickFix and locking down in an emergency — 1.8.7, off by default
"ClickFix" is a social-engineering technique where a fake CAPTCHA or error screen instructs you to open Terminal and paste in a command "to verify." Detections surged more than 500% from 2024 to 2025, and by 2026 it's considered one of the most prevalent macOS attack vectors. Because it's your own, legitimate shell doing exactly what you typed, it slips past Gatekeeper's signature verification entirely.
Menu bar → Malware Protection → ClickFix Defense (Pro, off by default). Watches for new lines appended to ~/.zsh_history / ~/.bash_history via FSEvents (only lines added after the guard starts — it never looks back through existing history), and matches only the reverse-shell one-liners it shares with StaticSignatureScanner, plus the specific combination of decoding base64 straight into a shell or into osascript. A bare curl | bash — extremely common in legitimate installers — is deliberately excluded, since it's indistinguishable from the official install instructions for Homebrew, rustup, nvm, and similar. The instant a match is found, it triggers the same emergency air-gap described above. Since the command has already run, this is necessarily after the fact, but it may still interrupt an in-progress second-stage download or credential exfiltration. Like any other air-gap trigger, it lifts on its own after 10 minutes at most (see the start of this section).
A known limitation. Because detection depends on a write to shell history, it cannot catch the fileless variant that bypasses Terminal entirely by invoking Script Editor directly through the applescript:// URL scheme, writing nothing to disk. Apple has shipped some mitigation for this (a confirmation dialog for unidentified scripts), but variants that work around it have been reported. We tested this path against macOS's unified log (log show / log stream) on real hardware and found no distinctive log signal to key detection on. We'd rather state this limitation plainly than claim coverage we don't have.
Docker Risk Detection Guard — 1.8.9, Pro, off by default
Menu bar → "Malware Protection" → "Detect Privileged Docker Containers & docker.sock Mounts" (DockerEventGuard). Since this codebase has no precedent for a persistent streaming connection like docker events, it uses the same timer-polling approach as PortAnomalyGuard. Every 20 seconds it fetches the set of container IDs with a lightweight docker ps -q, then runs docker inspect --format only on the diff (newly-started containers) for detailed inspection. The format string used for detection is deliberately identical to the Linux edition's roamswitch_core::health::LinuxHealthChecker::DOCKER_INSPECT_RISK_FORMAT, so both platforms flag exactly the same conditions (--privileged startup, or a /var/run/docker.sock bind-mount).
No automatic action is taken on detection. A privileged container or a docker.sock mount is a risky "configuration" that could enable container escape, but it isn't confirmed compromise — there are legitimate uses too, such as deliberately running a monitoring agent as privileged. Since most users don't run Docker at all, this stays off by default even on a Pro license. It doesn't use the EndpointSecurity entitlement; the docker CLI is located by checking Docker Desktop's default install path, then Homebrew, then which, in that order. Verified against a real Docker Desktop install (29.7.2) across three scenarios — a privileged container, a docker.sock-mounted container, and a normal container — with no false positives or missed detections.
Secret/API-Key Leak Auditor — added in 1.8.4, folder scanning added in 1.8.9
Menu bar → "Malware Protection" → "Secret/API-Key Leak Audit". Paste text and it's instantly checked for leaked API keys and tokens, showing the line number, a masked string, and a recommended response per finding (SecretLeakAuditor, entirely on-device). 1.8.9 added a "Choose Folder to Scan" option, letting the same detection engine recursively audit a directory — such as a source checkout — via auditDirectory(at:). .git, node_modules, target, vendor, dist, build, __pycache__, and venv are automatically excluded, as are files over 2MB or detected as binary. Processing runs off the main thread so it doesn't block the UI, and nothing is ever sent externally.
Link Guard (blocking phishing connections) — 1.7.2 and later, hardened in 1.8.0
Menu bar → "Malware Protection" → "Link Guard" (Pro). Blocks connections to phishing/scam sites on-device, across every browser and app. There are two enforcement points, used together in priority order.
- ① Content-filter system extension (
RoamSwitchLinkFilter, 1.8.0+, preferred once approved). ANEFilterDataProvidersystem extension — no Apple review (content-filter providers are self-serve, no approval queue). It inspects the actual outbound TCP flow after name resolution. The destination name comes from the OS-resolved hostname, or failing that the TLS SNI parsed from the flow's first bytes — so a browser doing its own DoH/DoT and connecting to a bare IP is still blocked. It never rewrites/etc/hostsand attributes each flow to a process. QUIC (UDP/443) has no readable SNI, so inblockmode it is dropped, forcing the browser to fall back to HTTP/2 over TCP. Needs a one-time approval in System Settings. - ②
/etc/hostssinkhole (fallback, while the extension is unapproved/declined). The privileged helper writes the target domains into a delimited managed section of/etc/hostsas0.0.0.0and flushes the cache. Capped at 60,000. Once the extension is active this section is removed. - Three modes. "Off" disables it. "Warn only" pauses the matching connection and asks the user (see Real
warnbelow). "Auto-block obvious scam sites (recommended)" drops immediately. The default is block as of 1.7.2. - Real
warn(1.8.0+, extension only). On awarnhit the system extension holds the flow (both the OS-named and the SNI path) with.pause()and the app raises an "Allow / Block" notification. The moment the user's tap reachesallowlist.txt/extra.txt— the extension polls the small state files every 1.5 s — the held flows resume (allow) or drop (block). No answer within 25 s fails open (a warn is advisory). The decision is cached per host (allow 5 min / block 1 h), so the page's other flows and later visits are instant and enforced. Theblocknotification is unchanged: "Blocked …" with an "Allow once (5 min)" button. - Only clear cases are blocked. A listing on the threat feed, or a brand-name Unicode homograph — everything else (high-risk TLDs, subdomain impersonation, …) is a warning. The verdict engine is shared with the Linux edition and sends URLs nowhere.
- Recovering from a wrong block. Allow a domain from the notification or the menu (5 minutes or permanent). The allow-list is subtracted when the section is regenerated.
- Pro-gated. Enforcement (
applyMode()) only happens on a valid Pro license. Without Pro the mode is stored but/etc/hostsis never touched. Activating or lapsing a license takes effect mid-session. - Feed and bundled seed. The block list comes from the signed threat feed (§7, verified with a feed-dedicated key). It works on the app's bundled seed (~60,000 entries) even before the first fetch, and turning off "Auto-update" means no outbound traffic.
Preventive gateway ARP/NDP lock — 1.7.5 and later
Menu bar → "Port & Device Monitor" → "Pin the gateway's ARP/NDP on untrusted networks (preventive)" (Pro, off by default).
- How it works. On joining an untrusted network, the current MAC of the IPv4 gateway, the IPv6 default router, and on-link DNS resolvers is collected from
route/scutil --dns/arp -n/ndp -an, and the helper pins each as apermanententry witharp -s/ndp -s(trust-on-first-use — the first MAC observed is trusted). Spoofed ARP/NDP replies for those IPs are then ignored, so a man-in-the-middle attack cannot be set up. - Scope. Only those three kinds of entry are pinned. Trusted (open) networks are never pinned. On every network change it unlocks once and re-pins. Off-link public resolvers (8.8.8.8, …) have no on-link ARP entry and are automatically excluded.
arp -sis preventive; the air-gap is after the fact. This pin exists so a spoof can't succeed; ARP-spoofing detection (ARPSpoofContainmentManager) and the emergency air-gap exist to "cut faster than a human" once one is seen. They run independently.- Persistence. The pin set is saved to
/Library/Application Support/RoamSwitch/gateway_arp_lock.jsonso it can be unlocked across an XPC reconnect or a helper restart.
The VPN tunnel (WireGuard / Tailscale) and its kill-switch — 1.7.6 and later, selectable backend in 1.8.0
Menu bar → "Port & Device Monitor" → "VPN Tunnel" (Pro, off by default). This is the primary anti-MITM defense — it does not depend on the integrity of L2 (ARP/NDP). The backend is selectable (submenu → "Backend") between "WireGuard (config file)" and "Tailscale (Exit Node)". RoamSwitch implements no cryptography itself; only the chosen backend is armed.
- (A) WireGuard backend. Drives Homebrew's
wireguard-tools(no Apple Network Extension entitlement). Import your own.conf; the helper saves it at0600. Kill-switch: pfblock drop allplus apass quickonly forlo, the tunnel interface, the UDP handshake to the pinned endpoint IP(s), DHCP, and ICMP. A non-full-tunnelAllowedIPsraises a split-tunnel warning. - (B) Tailscale backend (1.8.0+). For users who already run Tailscale. RoamSwitch does not run
tailscale up/ log in / install it — it readstailscale status --jsonand runstailscale set --exit-node=<node>. The standalone CLI (brew install tailscale) is recommended — the App Store (GUI) build can't be driven from outside the app (sandbox); with it you pick the exit node in the Tailscale app and RoamSwitch only shows status. An exit node is required (routes all traffic through the tunnel); it is disarmed automatically if it goes offline or unreachable. - Network reconfiguration on disconnect. The standalone macOS
tailscaleddoesn't cleanly restore routing + DNS when an exit node is cleared, so RoamSwitch bounces the active network service(s) (the same as toggling Wi-Fi by hand; only services with an IPv4 address, safe for multi-NIC and static-IP). Traffic drops for ~5–10 s and the protection level is not demoted during that window. - The Tailscale kill-switch is off by default (opt-in). The exit node already tunnels everything; the pf
block drop all(permits only CGNAT/MagicDNS/STUN/DERP/DHCP/ICMP/DNS) is for advanced users and is "leak-resistant, not leak-proof" (DNS is allowed sotailscaledsurvives). - Automatic / on license loss. Comes up on untrusted networks, down on trusted ones; lifted when the Pro license lapses.
§6
When you connect to a network you have not registered, the "protection level" switch does not use pf. It simply changes standard OS settings in a way that can be reversed later.
| Operation | Implementation | Privilege | How it is restored |
|---|---|---|---|
| Firewall + stealth mode ON | socketfilterfw --setblockall on / --setstealthmode on | root (helper) | off when you return to a safe network |
| Stop SSH / SMB / Screen Sharing | launchctl unload -w | root (helper) | Records only the ones that were running when stopped, and load -w on return (with SSH also coupled to /usr/sbin/systemsetup -setremotelogin on for guaranteed modern macOS restore) |
| Disable AirDrop | defaults write com.apple.sharingd DiscoverableMode | User (the app itself) | Saves the previous value and writes it back on return |
None of this is a new blocking mechanism that RoamSwitch adds — it is just toggling OS settings. If you delete the app, the only thing that stops is the network-dependent switching; the last OS settings that were applied stay as they are. Nothing is left locked, but if you want to err on the safe side, set it back to "Open" on a trusted network before uninstalling.
§7
What stays on the Mac
| Data | Location | Contents |
|---|---|---|
| License token | Keychain com.tetsuharu.RoamSwitch.license | An Ed25519-signed token. kSecAttrAccessibleAfterFirstUnlock |
| App settings / guard on-off | UserDefaults suite com.tetsuharu.RoamSwitch | Trusted-network registrations, protection policy, exclusion lists, and so on |
| pf state | /Library/Application Support/ | The air-gap timestamp, JSON of the guarded ports, the VPN kill-switch state, the temp file for the ruleset being applied |
| Link Guard threat feed | ~/Library/Application Support/ | The downloaded phishing/scam domain list (or the app's bundled seed if not yet fetched). The feed version is in UserDefaults |
| Link Guard managed section | /etc/hosts | A delimited section bounded by # BEGIN RoamSwitch link guard … # END, nulling blocked domains to 0.0.0.0. Removed when the mode is "Off" (§5) |
| ARP/NDP lock pins | /Library/Application Support/ | The IP → MAC set made permanent by the preventive lock (§5). Deleted when unlocked |
| WireGuard config | /Library/Application Support/0600) | The .conf the user imported. The endpoint hostname is also stashed in UserDefaults (the app resolves it) |
| Device fallback UUID | UserDefaults | A random value, generated only when IOKit does not return a UUID (§9) |
| Logs | os.Logger / NSLog | Unified logging. Nothing is sent externally |
Traffic that leaves the machine (the complete list)
There is no code anywhere that collects and sends diagnostic results, port information, URLs, or logs. No analytics SDK and no crash-reporter SDK are included. The only external library is Sparkle (updates). What goes out to the network is these eight, and that is all (the sixth only if the user configures a VPN, the seventh only if RoamSwitch Sensor pairing is enabled).
| Connection | Destination | When it happens | What is sent |
|---|---|---|---|
| License activation / deactivation | lafine.net /api/v1/license/* |
Only when the user enters a license key, or deactivates Pro | License key, device hash, host name, app version. Personal information is handled by Stripe at purchase; the app does not handle it |
| Update check | lafine.net /updates/appcast.xml |
Sparkle, every 24 hours and at launch | An HTTP request (a standard UA and version). The downloaded item is verified by EdDSA signature (§10) |
| Link Guard threat feed | lafine.net /updates/v1/{manifest, feed/<version>.txt} |
When Link Guard (§5) is on and "Auto-update" is enabled, every 24 hours (and at launch). Turning "Auto-update" off removes this path | A GET only. No query string, no cookies, nothing that identifies the machine. A receive-only signed static file; the manifest and the feed body are both verified with Ed25519. The signing key is dedicated to the feed — a separate key from the app-update SUPublicEDKey (so a leak is confined to "a bad blocklist") |
| Package CVE Scan / active-vulnerability-scan update data | lafine.net /updates/v1/manifest |
Every 24 hours from app launch onward (always — the package CVE scan update data has no off switch, since it's purely receive-only data used for a local inventory match; the active-vulnerability-scan CVE map is only fetched if “Active Vulnerability Scan (active reachability verification)” is enabled) | A GET only. No query string, no cookies, nothing that identifies the machine. A receive-only signed static file; the manifest and the feed body are both verified with Ed25519. Reuses the same key and the same manifest as the Link Guard threat feed, but the feeds themselves (known-CVE maps for Homebrew/npm/PyPI/crates.io/RubyGems/Packagist/Go/Maven, the active-vulnerability-scan CVE map, and the popular-npm-package-name list used for typosquat detection) are separate files |
| ClamAV virus-definition update | ClamAV official mirrors | Only when the user has installed ClamAV and uses the scan feature. It launches freshclam |
A standard ClamAV definition fetch. It contains no RoamSwitch-derived information |
| VPN tunnel (§5, WireGuard / Tailscale) | The WireGuard endpoint the user configured, or the Tailscale control plane (Tailscale, Inc.) | Only when the user has set up the VPN tunnel (Pro, off by default) and joins an untrusted network. With WireGuard, the endpoint hostname is resolved via DNS once before the tunnel comes up. With Tailscale, this is control-plane traffic handled by the user's own already-installed and authenticated tailscaled — RoamSwitch itself only reads tailscale status --json and runs tailscale set --exit-node= | With WireGuard: the WireGuard handshake (UDP) and the traffic inside the tunnel. The destination is the user's own VPN server and the contents are the user's own traffic. With Tailscale: traffic between the user's own Tailscale account and Tailscale, Inc.'s control plane (device registration, DERP relay, etc., governed by Tailscale's own privacy policy). In either case RoamSwitch adds no identifier and no diagnostic data |
| RoamSwitch Sensor pairing & audit link | RoamSwitch Sensor on the same LAN (hardware the user has set up themselves, running at a fixed IP) | Only when “RoamSwitch Sensor Pairing” (Pro, off by default) is enabled and you enter the Sensor’s IP address and pairing code to pair (a one-time exchange). After pairing, it connects to that same fixed IP again only when you press “Request Audit from Sensor,” and during automatic result retrieval (starting 5 minutes after the request, then every 5 minutes, up to 5 attempts) | Pairing sends this endpoint’s hostname and Ed25519 public key, and receives the public key Sensor issued. Audit requests and result retrieval are authenticated with an Ed25519 signature — only signed requests are sent. Diagnostic results and port information are only ever received as Sensor’s response; file contents are never sent. The only destination is the fixed IP address of the Sensor the user set up themselves — it never reaches any external server, including lafine.net. Mutual trust is only established through the explicit pairing-code exchange |
| Checkout page | Stripe Checkout | Only when the user presses the buy button (it opens in the browser) | — (a browser navigation) |
"Zero Telemetry" here means that there is no telemetry that collects and sends usage data or diagnostic results. It does not mean there is no network traffic at all. The eight paths in the table above do exist. But each of them is either something the user initiates or a signature-verified, receive-only fetch, and the diagnostic results, ports, URLs, and file contents on the Mac never leave it.
The Link Guard threat feed (row 3) adds "it does pull updates" on top of the "it sends nothing" defense. The two are kept separate; the Linux whitepaper §1.1 likewise splits "Zero Telemetry" from "receive-only updates". Turn "Auto-update" off and Link Guard runs on the bundled data (~60,000 phishing/scam domains) plus offline homograph detection, and this path does not occur.
The VPN tunnel (row 6) only happens if the user configures their own WireGuard server, or chooses the Tailscale backend and is already logged into tailscaled with their own Tailscale account; the destination and the contents are under the user's control. RoamSwitch only brings the tunnel up, holds the kill-switch, or reads Tailscale's status to switch the exit node — it adds no identifier and no usage data. Without a VPN configured, this path does not exist.
RoamSwitch Sensor pairing (row 7) is a Pro-only feature that is off by default; this path does not exist unless you enable it. Pairing itself is only established through an explicit action — entering the pairing code the Sensor’s operator issued together with the Sensor’s fixed IP address. Every subsequent communication (audit requests, result retrieval) is likewise addressed to that same user-owned fixed IP and authenticated with an Ed25519 signature. It never reaches any external server, including lafine.net. Diagnostic results or file contents are never sent, other than as Sensor’s own response.
The in-app "link safety check" sheet sends a HEAD request to the target URL to see
where a shortened URL lands (following redirects to private or local addresses is stopped by the
v1.4.5 SSRF mitigation). The MCP audit_url_safety, by contrast, is offline analysis
that completes on the spot and sends the URL nowhere (§8).
This is not just asserted. On 2026-08-29 a running 1.4.7 install was audited with
tcpdump + per-process attribution (nettop / lsof / a filtered
pktap capture) + LuLu, over a ~2-hour window, with the security level pinned to
Maximum Lockdown and the appcast check forced.
Result: no outbound flow attributed to RoamSwitch, RoamSwitchHelper,
or RoamSwitchMCPServer other than the appcast check to lafine.net; the MCP
server's only sockets were to localhost; the entitlements dumps are empty.
Full write-up and a script anyone can run to reproduce it: audit/RESULTS-2026-08-29.md
This measurement is from 1.4.7, before the Link Guard threat feed (1.7.2, row 3) and the VPN
tunnel (1.7.6, row 5). Audited on 1.7.2 or later, you will see two receive-only
GETs to lafine.net (the appcast and the threat feed); if a VPN
is configured you will also see UDP to the user's own WireGuard server (destination and contents
under the user's control). All of it goes away with "Auto-update: Off" and no VPN configured.
§8
RoamSwitchMCPServer is a standalone command-line tool bundled at
RoamSwitch.app/Contents/MacOS/. An MCP client such as Claude Desktop or Claude Code
launches it as a subprocess and talks to it over stdio (newline-delimited JSON-RPC
2.0). The official SDK would not build against this machine's macOS SDK, so it is
implemented by hand on top of Foundation's JSONSerialization.
What the design constrains
- It is read-only. There is simply no API for changing the security level, isolating a port, or ejecting a device. This is not something forgotten in v1 — it is left out deliberately. Letting external code (here, an LLM) rewrite a security tool's protection state would break trust for every user.
- It opens no socket. It neither registers a service nor listens. It reads one line from stdin, returns one line on stdout, and then the client ends the process.
- It sends nothing out. All diagnostics complete inside the Mac.
- It reads settings from a different domain.
UserDefaults(suiteName: "com.tetsuharu.RoamSwitch")reads the app's domain explicitly (its own bundle-ID domain is empty). It only reads; it does not write.
The tools it exposes
| Tool | What it returns | Traffic |
|---|---|---|
| get_security_report | An 18-item check (FileVault / SIP / Gatekeeper / auto-update / XProtect / firewall / Wi-Fi encryption / ARP / gateway ARP pinning / SSH configuration audit / sudo NOPASSWD audit / exposed ports / guard configuration, and more) with a score and per-item remediation advice | Local only |
| get_exposed_ports | A list of listening TCP ports. For any exposed beyond localhost, it cross-references a known-dangerous-service DB and checks CORS / headers with an HTTP probe to 127.0.0.1:port (local, closed) | Only the probe to 127.0.0.1 |
| get_guard_status | The on/off state of the Pro auto-response guards (port anomaly / ARP / USB / Bluetooth / Web+Mail download / DNS threat protection), the current protection level, and the trusted-network state | Local only |
| audit_url_safety | A judgement of a URL for phishing / homograph (Unicode spoofing) / brand-subdomain spoofing / high-risk TLD / plaintext HTTP. It is synchronous and fully offline (analyzeURL; it does not follow redirects) | None |
| get_app_help | A full-text search of the bundled knowledge base (feature specs / settings / troubleshooting / notification-message explanations) | None |
| audit_secrets | Detects leaked API keys and private keys in text, a file, or a directory tree (matches are masked in the output). | None |
| audit_security_logs | Aggregates recent security logs (Mac: sudo auth failures, SSH brute-force attempts, Gatekeeper blocks, XProtect detections, etc. / Linux: sudo auth failures, SSH brute-force, firewall drops, AppArmor denials, ClamAV detections, etc.), automatically masks secrets like API keys and tokens, and also returns new-pattern detection (log templating) and frequency-anomaly (statistical spike) results. | Local only |
| run_active_vuln_scan | Non-destructive, 127.0.0.1-only active vulnerability verification. The only tool that uses the network; off by default and requires opting in from Settings. | 127.0.0.1 only (off by default) |
| run_package_cve_scan | Checks installed packages (Mac: Homebrew / Linux: dpkg, dnf, zypper, pacman) against a local CVE map. Sends no network traffic at all. | None |
| run_package_cve_scan_languages | Checks dependency lockfiles for npm, PyPI, crates.io, RubyGems, Packagist, Go, and Maven against the same local CVE map. Sends no network traffic at all. | None |
| get_quarantine_status | Returns the malware quarantine vault's contents: original path, detected threat name, quarantine time and size. | Local only |
| get_canary_status | Returns the ransomware canary's decoy-file state plus up to the 50 most recent detected incidents. | Local only |
| get_notification_history | Returns the history of notifications RoamSwitch has sent (security log-audit anomalies, ClickFix detections, and the like) from the past 7 days, most recent first. | Local only |
| get_port_anomaly_incidents | Returns the Port Anomaly Guard's baseline state, currently auto-blocked ports, and up to the 50 most recent incidents. The response explicitly notes that the currently-blocked ports are a present-tense snapshot with no timestamp, distinct from the timestamped incident history. | Local only |
| get_runtime_threat_status | Returns whether an XProtect malware conviction has air-gapped this Mac, and the incident that triggered it. Check this first to explain an active Air-Gap. | Local only |
The instructions field in the initialize response also states plainly,
"Cannot change security level, isolate ports, or eject devices," communicating the capability
boundary to the client-side LLM. The MCP resources (roamswitch://docs/*) are read-only
Markdown documents as well.
In September 2026, while running a fire drill that had a local LLM (Qwen 3.8 27B) investigate an incident through this MCP server, we found a case where the model misread the data returned by get_port_anomaly_incidents and folded an unrelated, older port block into the narrative of an ongoing incident. The cause: a present-tense snapshot of currently-blocked ports, carrying no timestamp, was returned in the same response as the real, timestamped incident history, with nothing distinguishing the two. The fix adds explicit note fields marking which field is a timeline and which is a snapshot. Re-running the same scenario against the same model afterward, it quoted the note's wording and explicitly declined to attribute the old port to the current incident. Verified both by an automated regression test and by re-running the real local LLM.
The source for this server, and for the detection logic it uses (ARP monitoring, port scanning,
port audit, the 18-point health check, URL safety analysis), is published at
github.com/lafine1211/roamswitch-mcp (MIT, a mirror of the shipping code, tagged per
release). You can check directly in code that it is read-only, what it passes to the LLM, and
that it sends nothing out. It does not include the privileged helper, pf control, the guards that
act, or licensing — those stay in the app repository.
The tests ship with it too — mirrored unit tests, adversarial-input tests, and mutation fuzzing,
run by swift test and verified in CI. Fuzzing turned up one unguarded crash
(JSONSerialization stack-overflows on a deeply-nested JSON object); it is fixed with
a nesting-depth check ahead of the parser, and recorded in SECURITY_TESTING.md.
§9
The token
- It uses Ed25519 (Curve25519 signatures). The public key is embedded in the app (
LicenseVerifier.embeddedPublicKeyBase64). The corresponding private key exists only in the license backend (a Firebase Functions environment variable) and is not in the repository. - The signed data is canonical JSON. The signature is created and verified over the exact bytes produced by encoding
LicensePayload(license key, tier, device hash, issued-at, expiry, seat count) withJSONEncoder's.sortedKeysand.withoutEscapingSlashes. - It is designed to fail closed. If the embedded key is missing or malformed, or if the signature or the canonical JSON cannot be produced, the result is not "verified" — it returns
invalidSignature.
Device binding
device_hash = SHA-256( "RoamSwitch-LifetimeSalt-v1" : lowercase(IOPlatformUUID) )
The raw hardware UUID is not sent to the server. In the rare case where IOKit does not return a
UUID, it falls back to a random UUID kept in UserDefaults. At verification time, if the
token's device_hash does not match the current device hash, the result is
deviceMismatch.
It works offline
All validateSavedLicense() does at startup is read the token from the Keychain and
verify it locally with the embedded public key. It does not connect to the network. If the
license server is ever shut down, Pro features keep working on a Mac that is already activated.
The server is contacted only for a new activation and for an explicit deactivation. The
deactivation notice to the server is best-effort — even if it fails, the local deactivation
always completes.
The default is a one-time (Lifetime) purchase; expires_at is checked only when
is_lifetime is false. Seat count is expressed by tier — 2 for personal Pro, 5 for
Team.
§10
Signing and notarization (scripts/release.sh)
- Run
xcodebuild archive(Release, manual signing, Developer ID Application). - Export with
-exportArchiveasmethod: developer-id. - After
notarytool submit --wait, runstapler stapleon the .app. - Verify with
spctl -a -t exec -vv. - Re-zip after stapling to produce the Sparkle update artifact (so the notarization ticket is included and it runs offline without a Gatekeeper warning).
- Build the DMG, notarize and staple the DMG as well, and verify with
stapler validate.
Updates (Sparkle 2.9.6)
| Key | Value |
|---|---|
| SUFeedURL | https://lafine.net/updates/appcast.xml |
| SUPublicEDKey | CNxzwijMzMCJzliId76Yl88S/9np6t/xg/zQ9YbYzHs= |
| SUEnableAutomaticChecks | true |
| SUScheduledCheckInterval | 86400 |
Before an update is applied, the EdDSA signature listed in the appcast is verified
against the SUPublicEDKey embedded in the app. The private signing key exists only in
the build environment. The appcast is served over HTTPS. Delta updates are signature-verified the
same way.
The fact that the appcast.xml URL is public is not a weakness in itself. Its contents
are only version numbers, release notes, download URLs, file sizes, and the EdDSA signature of
each build — nothing secret. The anchor of trust is not "is the appcast authentic in transit" but
verifying the artifact's signature with the public key baked into the app. An attacker who can
completely replace the appcast (MITM, DNS hijacking, compromising the web host) still cannot push
a malicious update without the signing key. Gatekeeper (Developer ID and notarization) is a
second gate.
Two risks remain. One is updates not arriving, because the host is down or the appcast is broken (no bad install happens — you simply do not get updated). The other is a freeze attack that deliberately withholds a security update. Sparkle 2.x rejects downgrades and replays by checking version ordering, but a complete defense against freezing needs a dedicated update server with expiry. That is on our list to address.
§11
What RoamSwitch is meant to handle
- Probing and attacks from an attacker on the same LAN, or from a compromised IoT device. It responds with stealth, exposed-port auditing, and isolation from outside.
- Exposure on a network you don't trust. It automatically stops sharing services and AirDrop.
- Man-in-the-middle attacks (ARP/NDP spoofing). As of 1.7.6 this is layered: (1) the VPN tunnel + kill-switch (§5, the primary defense — does not depend on L2 integrity), (2) a preventive gateway ARP/NDP pin on untrusted networks (§5), and (3) spoofing detection with an emergency air-gap (after the fact). All Pro, off by default (detection is on by default).
- Finding dev servers and databases (Redis, MongoDB, Elasticsearch, and so on) exposed on
0.0.0.0without authentication, and blocking them from outside. - Catching ransomware-like unauthorized encryption activity early and stopping all traffic (it does not rely on signatures).
- BadUSB and physical keyboard approval guard (
USBKeyboardGuard, CGEventTap + IOKit) that intercepts and drops keystrokes from unapproved USB keyboards/cables (Rubber Ducky, O.MG Cable, etc.) to prevent automated command injection attacks. - An approval prompt for unknown USB storage (an unrecognized drive is held read-only rather than ejected immediately), and an automatic ClamAV scan on attached storage (optional).
- Detects sensitive API keys (OpenAI, Anthropic, GitHub, AWS, etc.) in the clipboard entirely locally, and prevents an accidental paste into a web form or AI chat before it happens.
- Detects and warns about dangerous Pickle-format AI model files (an arbitrary-code-execution risk) downloaded from Hugging Face or the web.
- The industry-standard EICAR test signature and well-documented reverse-shell one-liners inside downloaded files (
StaticSignatureScanner, 1.8.7+, works even without ClamAV installed). - A newly-registered LaunchAgent/LaunchDaemon that invokes a raw script interpreter directly (
PersistenceMonitorGuard, 1.8.7+). - "ClickFix"-style attacks, where a fake warning screen talks you into running a command in Terminal yourself — detected via known-bad command patterns in shell history, triggering an emergency air-gap (
ClickFixGuard, 1.8.7+, Pro, off by default). - Detects and notifies the instant a container starts with a container-escape-risk Docker configuration, such as
--privilegedmode or a/var/run/docker.sockbind-mount (DockerEventGuard, 1.8.9+, Pro, off by default, notify-only). - Secret/API-key leak auditing that now covers not just pasted text but recursive, folder-level scanning too (
SecretLeakAuditor, folder scanning added in 1.8.9, entirely on-device).
What we have decided not to do
- It is not a replacement for antivirus. ClamAV and XProtect are used as auxiliaries; RoamSwitch on its own is not a general-purpose malware detector.
- We have decided not to pursue the EndpointSecurity entitlement. Approval rates for an Individual Apple Developer account are low, and any feature that genuinely needs pre-exec blocking (e.g.
ES_EVENT_TYPE_AUTH_EXEC) is treated as a closed non-goal for as long as that stays true. In place of stopping execution itself, we've implemented what's practical as post-hoc detection without the entitlement: static signature detection, autostart monitoring, and ClickFix defense. - It is not a guarantee. It is one layer in a defense-in-depth stack, not something that "completely prevents ransomware." Marketing copy is reviewed on this premise too.
- It cannot protect an already-compromised root or kernel. If an attacker already has root, they can remove the helper's pf rules too.
- It does not restore L2 integrity itself. The preventive ARP/NDP pin is trust-on-first-use — if an attacker is already in place before you connect, it can pin a spoofed MAC. The VPN tunnel (§5) is the answer when you don't want to make that assumption: even with L2 poisoned, the contents are encrypted and the kill-switch stops plaintext from leaking. It is not a substitute for enterprise DHCP snooping or Dynamic ARP Inspection.
- It does not provide a VPN server. The tunnel feature uses a WireGuard config the user supplies; RoamSwitch does not become a VPN provider.
Attack surface that installing RoamSwitch adds
| Attack surface | How it is contained |
|---|---|
A LaunchDaemon that runs as root, and its mach service (com.tetsuharu.RoamSwitch.Helper) |
The operation surface is fixed to HelperProtocol (the §3 table). There is no arbitrary-command channel. Connections are authorized by a code-signing requirement, using audit_token. |
If RoamSwitch.app itself is compromised, all of the helper's operations pass to the attacker |
Hardened Runtime is enabled, and the app is given no unnecessary privileges. Outbound traffic is limited to the eight paths above (the sixth only if the user configures a VPN, the seventh only if RoamSwitch Sensor pairing is enabled). We plan to have this reviewed by a third party. |
| The system-binary paths the helper spawns | Absolute paths like /sbin/pfctl are specified directly, with no dependence on PATH. Arguments are hard-coded too (apart from port numbers, DNS strings, the ARP IP/MAC pairs, and the VPN endpoint IP — all of which are format-validated). |
Link Guard rewriting /etc/hosts (setLinkGuardSinkhole) |
Writes are confined to a delimited managed section; lines outside it are preserved verbatim. Domains are normalized and validated, IPs and junk are dropped, the list is capped at 60,000, and the file is written via a temp file + atomic replace. It only sinkholes clear cases (threat-feed listing or brand-name homograph), decided by a local-only verdict engine. The block list itself comes from the signature-verified threat feed (feed-dedicated key). |
Link Guard's threat-feed fetch (a receive-only daily GET) |
A static-file fetch with no query string and no identifiers. The manifest and feed body are both Ed25519-verified, and a fetch that fails verification is discarded (no fallback to unsigned data). "Auto-update: Off" removes the path entirely. |
Preventive ARP/NDP pin (lockGatewayARP) |
Only the neighbour-cache entries for "the gateway, the IPv6 router, on-link DNS" are pinned. The IP/MAC pairs passed in are format-validated and reconciled against the request set (it never adds entries on its own). It runs only on untrusted networks; Pro, off by default. The trust-on-first-use limit is stated in §11 "What we have decided not to do". |
VPN tunnel (wireGuardImport/Up/…, Homebrew wireguard-tools) |
The .conf is stored by the helper at 0600. The endpoint IP is resolved by the app and passed to the helper (the helper never resolves an arbitrary hostname). The tunnel's destination and contents are under the user's control. The feature is disabled if wireguard-tools is not installed. Pro, off by default. The kill-switch goes through PFRulesetCoordinator (§4). |
| The MCP server passing system state to an LLM (a confused deputy) | It is read-only, with no write API implemented. URL checks are offline. The settings domain is referenced read-only. |
| Hijacking the update path | EdDSA signature verification (SUPublicEDKey), plus a bundled notarization ticket. The appcast is over HTTPS. |
Appendix A
Everything stated in this document can be verified against the distributed artifact with the following commands.
Signing and notarization
# Developer ID signature and Team ID
codesign -dvvv /Applications/RoamSwitch.app 2>&1 | grep -E 'Authority|TeamIdentifier|flags'
# Whether the notarization ticket is stapled
stapler validate /Applications/RoamSwitch.app
spctl -a -t exec -vvv /Applications/RoamSwitch.app
# Signature of the bundled helper / MCP server
codesign -dvvv /Applications/RoamSwitch.app/Contents/MacOS/RoamSwitchHelper
codesign -dvvv /Applications/RoamSwitch.app/Contents/MacOS/RoamSwitchMCPServer
Entitlements (no network-send permission)
codesign -d --entitlements :- /Applications/RoamSwitch.app/Contents/MacOS/RoamSwitchHelper
codesign -d --entitlements :- /Applications/RoamSwitch.app/Contents/MacOS/RoamSwitchMCPServer
# → An empty entitlements dictionary. No app-sandbox / network client keys.
Measuring the traffic
# Run tcpdump alongside it to confirm no traffic occurs during normal use
sudo tcpdump -i any -n 'host not 127.0.0.1' and 'not port 53'
# No traffic other than license activation, update checks, and ClamAV definition updates
For a stricter, automated check with per-process attribution, see
rs-zerotel-audit.sh in roamswitch-support/audit/
Defense Architecture & Penetration Testing (5 Defense Boundaries)
# Batch-verifies the XPC authorization boundary, pf Air-Gap priority, port exposure detection, MCP read-only, and ARP monitoring, all automatically
git clone https://github.com/lafine1211/roamswitch-support
cd roamswitch-support/audit
./rs-defense-audit.sh all
Live penetration testing & multi-layer defense audit from a macOS VM: RESULTS-DEFENSE-2026-08-30
DNS Threat Protection (Quad9 Malware & C2 Blocking) Verification
# Query Quad9's official test domain for verifying threat blocking
nslookup test.dns9.quad9.net
# → Should return ** server can't find test.dns9.quad9.net: NXDOMAIN, i.e. name resolution is blocked
Web & Mail Download Guard (ClamAV Real-Time Detection) Verification
# Create the harmless antivirus industry-standard test string (EICAR) in Downloads
echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > ~/Downloads/eicar_test.com
# → FSEvents detection triggers an immediate ClamAV scan; no notification is shown, and "EICAR test signature detected (harmless)"
# is recorded in notification history. Since EICAR is an industry-standard test file and not an actual threat,
# it is neither quarantined nor blocked and the file is left in place (only a real malware sample is moved to the quarantine manager).
Static Signature Detection (No EndpointSecurity Required) Verification
## A textbook reverse-shell one-liner from public offensive-security references (harmless, never executed)
echo 'bash -i >& /dev/tcp/127.0.0.1/4444 0>&1' > ~/Downloads/rs_test.sh
## → Even without ClamAV installed, a "Dangerous download file quarantined" notification appears and it's
## moved to the Quarantine folder immediately (doesn't depend on ClamAV's signature database)
New Autostart Monitoring (PersistenceMonitorGuard) Verification
# Create a harmless LaunchAgent that invokes a raw script interpreter directly (never actually run)
cat > ~/Library/LaunchAgents/com.example.selfcheck.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.example.selfcheck</string>
<key>ProgramArguments</key><array><string>/bin/bash</string><string>-c</string><string>echo hi</string></array>
</dict></plist>
EOF
# → Within a few seconds, a "New autostart registration detected" notification should appear. Remove it afterward:
rm ~/Library/LaunchAgents/com.example.selfcheck.plist
Link Guard (blocking phishing connections)
# Whether the managed section is present (2 BEGIN/END lines if Pro + default mode)
sudo grep -c 'RoamSwitch link guard' /etc/hosts
# The section's contents and sinkhole count
sudo sed -n '/BEGIN RoamSwitch link guard/,/END RoamSwitch link guard/p' /etc/hosts | head -4
sudo sed -n '/BEGIN RoamSwitch link guard/,/END RoamSwitch link guard/p' /etc/hosts | grep -c '^0\.0\.0\.0'
# Whether it's actually dropped (checked with one entry from the section; harmless)
D=$(sudo sed -n '/BEGIN RoamSwitch/,/END RoamSwitch/p' /etc/hosts | awk '/^0\.0\.0\.0/{print $2; exit}')
dscacheutil -q host -a name "$D" # → ip_address: 0.0.0.0 (name resolution is blocked)
# Also confirm the section above disappears via Menu → "Link Protection" → "Off"
The receive-only threat feed (signature-verified)
# Public feed and manifest (anyone can fetch and verify them)
curl -s https://lafine.net/updates/v1/manifest # version/generated/threatfeed{...}
curl -sI https://lafine.net/updates/v1/manifest.sig # → text/plain
# The only thing sent is a GET with no query string, cookies, or identifiers. Verify with tcpdump running alongside.
Preventive ARP/NDP lock (§5, 1.7.5+)
# Connect to an untrusted network with preventive lock enabled → check for permanent entries
arp -an | grep -i permanent # Gateway and other IPs are listed as (permanent)
ndp -an | grep -i 'P ' # IPv6 side (P = permanent)
sudo cat "/Library/Application Support/RoamSwitch/gateway_arp_lock.json" # The set of currently pinned IP→MAC mappings
# Release the lock from the menu → the above should disappear
VPN tunnel + kill-switch (§5, 1.7.6+)
# Assumes wireguard-tools (via Homebrew)
brew list wireguard-tools >/dev/null && echo "wireguard-tools: OK"
# Enable VPN on an untrusted network → before the tunnel is up, pf is in kill-switch state
sudo pfctl -sr | grep -E 'block drop all|pass .*(utun|udp)' # block drop all + a limited set of pass quick rules
# After the tunnel is up (wg-quick up already run)
wg show # handshake / transfer is active
route -n get default | grep interface # → utunN (the default route is the tunnel)
# Disable the VPN → kill-switch is released and pf returns to its prior state
The privileged helper itself
# The registered LaunchDaemon
sudo launchctl print system/com.tetsuharu.RoamSwitch.Helper
# The currently loaded pf rules (actual state of Air-Gap / port guard)
sudo pfctl -sr
# The helper's state directory
ls -la "/Library/Application Support/RoamSwitch/"
The MCP server's response (offline check)
BIN=/Applications/RoamSwitch.app/Contents/MacOS/RoamSwitchMCPServer
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | "$BIN"
# Returns serverInfo and the definitions of 5 tools. No network connection occurs.
The MCP server's source and tests
git clone https://github.com/lafine1211/roamswitch-mcp
cd roamswitch-mcp
swift build -c release # The same source as the shipped binary
swift test # Unit, adversarial-input, stdio, and mutation fuzzing tests
# See SECURITY_TESTING.md for what's tested and any issues found
Device identifier
# The raw value bound to the token (only a salted SHA-256 is ever sent)
ioreg -d2 -c IOPlatformExpertDevice | awk -F'"' '/IOPlatformUUID/{print $4}'