Lafine Systems Design · Technical document

RoamSwitch Architecture & Security Whitepaper

This document explains what privileges RoamSwitch runs with and what it does at that boundary. It contains no marketing language; everything stated here can be verified against the shipping app binary and its actual behavior.

Version v1.6 Scope RoamSwitch 1.11.14 (build 161) Requirements macOS 13.0+ / Apple Silicon Published 2026-09-23 Team ID GV76B6G4YU

§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.

Note

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.

Diagram of RoamSwitch components and trust boundaries MCP Client Claude Desktop / Code MCPServer User privilege Read-only RoamSwitch.app User privilege Menu bar / Monitoring / UI Helper root / LaunchDaemon SMAppService.daemon System binaries pfctl / socketfilterfw … stdio (JSON-RPC) Read-only * XPC mach service Authorized via code signing Process execution
* MCPServer does not connect to the app itself; it reads the shared preferences domain and the monitoring modules directly (§8).
Privileges and role of each component
ExecutablePrivilegeCan doCannot 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. No network-send entitlement is declared, but because it is not sandboxed, that by itself does not forbid network traffic (outbound traffic is measured in §7)
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. By default it opens no socket and sends nothing over the network (the only exceptions are the default-off npm signature verification and the vulnerability scan aimed at this Mac itself, 127.0.0.1; see §8). No network-send entitlement is declared, but because it is not sandboxed, that by itself does not forbid network traffic

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).
Design Trade-off

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.

Boundary Summary

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.

HelperProtocol — everything that runs as root
MethodWhat it doesBinary / API invoked
setBlockAll(_:) Turns the application firewall and stealth mode on or off /usr/libexec/ApplicationFirewall/socketfilterfw
--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) —
Design Trade-off

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.shared is 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).

Privilege separation inside the helper, and the exception for reading the SSH log

Right after it starts, the helper puts a kernel-enforced ceiling on itself (a runtime sandbox, which is separate from the App Sandbox entitlement above), and the child processes it starts inherit it. The ceiling stops replacing system programs, registering a LaunchDaemon or LaunchAgent, rewriting the sudoers, PAM or sshd configuration, writing straight into the local directory store, and running programs from places that users, external volumes or temporary folders can write to. It does not stop account changes made through Open Directory (dscl), and it does not restrict network traffic (that is not what the ceiling is for).

Under this ceiling /usr/bin/log does not run. On macOS 26, log refuses to run inside a sandbox (log: Cannot run while sandboxed) and also as nobody (Must be admin). So neither the logged-in user's app nor the root helper inside its sandbox can read sshd's log (the unified log). SSH brute-force detection has to read that log, so the privilege separation has one exception.

The exception is a small root process: before the helper enters its sandbox, it starts the same binary with --ssh-log-reader. That process accepts only a one-byte command (start or stop) and does nothing but start /usr/bin/log with fixed arguments (log stream --style ndjson --level info and a fixed filter that keeps only sshd lines). It passes no environment variables, and the caller has no way to choose arguments or a command. It does not read or interpret the log; the output goes into a pipe the helper handed over.

Log lines contain strings an attacker can write, such as the user name, so the root helper does not interpret them. It hands each line to a parse worker that runs as nobody inside a sandbox that forbids network access, file writes, and starting or forking processes, and the source address is taken from the end of the line (IP port N), which the user name cannot influence. On a Mac with Remote Login off, this process does not start log and reads nothing. The SSH operations that can be called through the helper's XPC are five: applying the settings, fetching the status, the records and the logins, and lifting a block. None of them takes a command.

§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).
    1. Emergency air-gap → set skip on lo0 and block drop all (nothing else is considered)
    2. VPN kill-switch → block drop all plus pass quick for only: lo, the tunnel interface (utunN), the UDP handshake to the pinned endpoint IP(s), DHCP, and ICMP
    3. Dev-server guard → block drop in quick proto tcp ... port { … }
    4. None → reload /etc/pf.conf and return pf to its original state
  • Read back after applying. pfctl -sr reads the rules back to confirm that block drop all, or each port's rule, is actually loaded. A case where pfctl -f was silently ignored is not treated as success.
  • The temp file is written to a path containing a UUID and 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.
API Behavior

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

Kinds of block
KindScopeTriggerloopback
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 neither lingers nor can be waited out

  • How it ends depends on what triggered it (1.9.52 onward). A tampered ransomware decoy, a confirmed XProtect detection and a manual isolation chosen from an ARP warning are "high confidence"; ClickFix, port anomalies and the like are "low confidence". A low-confidence isolation still releases by itself after 10 minutes at most. A high-confidence one does not release at 10 minutes: releasing fully on time alone would let an attacker simply wait it out.
  • A high-confidence isolation stays fully closed up to a limit (1 hour by default, 5 minutes to 24 hours in settings) and checks the cause there. For ransomware it counts the cause as gone only when the suspect process was identified and has exited, and the decoys have not changed since the detection. If that cannot be confirmed it moves to "degraded mode": new connections stay blocked (except loopback and DHCP) while only the Wi-Fi radio is restored, so you can see what is going on. It never opens because time passed. It opens only when you release it or the cause is confirmed gone.
  • When an isolation starts, connections that were already open are cut too. pf's state table is flushed (pfctl -F states), which stops traffic that began before the isolation. The same happens when it moves to degraded mode.
  • An isolation caused by ARP spoofing is released automatically only when the cause is confirmed gone without any traffic. The check only reads the ARP cache (it sends no packets) and declares the cause gone when the trusted gateway MAC is seen on 3 consecutive checks spanning at least 60 seconds. While isolated, however, the gateway's ARP entry can disappear from the neighbour table (confirmed on a real Mac), and then the cause cannot be confirmed. In that case it stays in degraded mode until you release it.
  • Even if the helper dies, the release check does not stop. A dedicated LaunchDaemon with no KeepAlive at all (AirGapFailsafe) starts on its own every 3 minutes and applies the same decision as the helper (low confidence: release at 10 minutes; high confidence: check the cause at the limit, then degrade). If the helper itself crashes and loses its own release timer, this safety net is unaffected (1.8.6 onward; the confidence-based decision from 1.9.52).
  • This dedicated daemon never touches the interactive helper's own launch configuration. An earlier attempt (1.8.5) used the helper's own KeepAlive.PathState for 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 is re-applied after a restart or when the daemon is recreated. At launch the helper reads the on-disk state with reapplyFromDisk() and restores the Air-Gap, the port guard and the system default, in that order. It also carries over the isolation's state (its confidence, full or degraded) and the elapsed time, so timing continues from the first time it engaged (confirmed on a real Mac by restarting the helper).
  • A failed lift is treated as a failure. If block drop all could 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 control back at any time: with "Release Air-Gap isolation" in the menu bar, with the release button in the modal, or simply by turning Wi-Fi off. Releasing does not require Pro (it works even if Pro lapses in the middle of an isolation).

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.

General-purpose entropy-based ransomware detection — 1.10.0, Pro, on by default

Watches the Documents, Desktop, Downloads, and Pictures folders via FSEvents, and fires when writes with Shannon entropy above the threshold (7.92) arrive in a burst of 20 or more files within 5 seconds. Unlike the canary (decoy-file) approach, it does not depend on tampering with a bait file, so it also catches encryption in places with no canary file. Only once the threshold is reached does it use lsof to identify the process and check it against an allowlist (cp, mv, ditto, tar, ffmpeg, Xcode, Time Machine, major browsers, and the like). On detection it triggers the same automatic network block, via RansomwareContainmentManager, that the canary guard uses.

The Linux edition can track bursts per process because fanotify attaches a PID to each write event. macOS has neither fanotify nor (by design) EndpointSecurity, so this implementation reads only the path from FSEvents and counts the burst globally, independent of process. Limitation: several legitimate processes writing large numbers of high-entropy files at once (concurrent compression or encryption jobs, for example) may be more prone to a false positive here than on the Linux edition.

Pre-damage snapshots for ransomware recovery (Mac)

A snapshot taken at detection time already holds any file that was encrypted before the detection, so it is not a way back. RoamSwitch therefore takes APFS local snapshots on a schedule, independent of detection (every 6 hours by default; off, 1, 3, 6, 12 or 24 hours). When a detection fires, new pre-damage snapshots and pruning pause for up to 7 days (retention mode), so the encrypted state cannot push out the last pre-encryption generation. Recovery is always manual: only the files you pick are copied to ~/RoamSwitch-Recovered/. Current files are never overwritten and a whole-volume restore is not offered. Copying needs the snapshot mounted, so the privileged helper needs Full Disk Access (snapshots are still created without it). macOS can remove local snapshots on its own, for example when free space runs low, so nothing is guaranteed to survive beyond about 24 hours. The recovery window is a Pro feature (taking the snapshots themselves works without Pro).

Forensic evidence bundle — 1.10.0

When an emergency Air-Gap begins — triggered by the canary guard, the entropy-based ransomware guard, or similar — a background queue that does not block the containment itself saves a snapshot of the process list (ps), network connections (lsof -i), files modified in the last five minutes, and the files the suspect process has open, to ~/Library/Application Support/RoamSwitch/incident-evidence/<timestamp>/manifest.json. The manifest records each artifact's SHA-256 so later tampering can be detected. The bundle list is available from the get_forensic_evidence_bundles MCP tool. Limitation: built without an EndpointSecurity entitlement, it captures no packet trace and no process memory/file-descriptor snapshot.

Process execution recorder (Mac, Pro, off by default)

The privileged helper launches macOS's own /usr/bin/eslogger (exec, fork, exit) as a child process and records process launches on this Mac only. It does not request the EndpointSecurity entitlement (it never calls es_new_client), and RoamSwitch must be allowed in Full Disk Access (System Settings; if it is not listed, add /Applications/RoamSwitch.app with “+”). While permission is missing, it shows the status as "unavailable", waits and rechecks every 60 seconds (it does not switch to heavy polling). It only notifies. It never blocks execution, cuts communication or isolates.

It notifies on these ten behaviours: a browser, Office or mail app launching a shell directly; running an unsigned binary from a temporary folder or a quarantined location; a one-line command that downloads and pipes into a shell (when combined with a raw-IP URL, base64, eval or disabled TLS verification); osascript using base64 or eval; running a file right after its quarantine attribute was removed; launching an unsigned binary from a just-written auto-start registration (LaunchAgent or LaunchDaemon); a non-Apple-signed parent reading a keychain secret (such as security find-generic-password -w); a python3, perl, ruby, php or pwsh -c/-e/-Command one-liner where a base64/eval decode and a curl, wget, exec, or system call both appear on the same line (1.10.0 onward — using base64 or eval alone does not fire this); and launching a process with the DYLD_INSERT_LIBRARIES environment variable set (1.10.0 onward — to avoid leaking secrets, only this one environment variable's value is ever read; Apple-signed processes are excluded); and a command line that directly names a decoy credential file RoamSwitch planted (such as ~/.aws/credentials or ~/.env.backup, recognised by the marker inside) — for example cat, tar or curl -F; real files never match, and reads through a library are left to the honeytoken watcher. Each detection carries a MITRE ATT&CK technique ID.

Only high-severity detections become banners, and the same kind is grouped to once per 10 minutes (every event stays in the record). Developer tools such as Claude Code and the GitHub CLI reading their own credentials are not notified by default. If another program reads them, the "Allow this program" action in the notification allows just that item (by Team ID when it is signed, otherwise by executable name, paired with the item name).

The record is stored readable by root only (directory 0700, files 0600) and keeps 200 MB and 14 days by default. Environment variables are not stored, but command-line arguments may contain secrets. Each segment is sealed in a hash chain linked to the hash of the previous segment, and "Verify chain" detects edits, missing segments and removed seals (each confirmed on a real Mac). This detects tampering; it does not prevent it. Deleting or rolling back the newest segment cannot be detected. Reading goes through the helper's XPC, and MCP goes through the app (with a Pro re-check).

Limits: executions before the helper starts, or before recording is enabled, are not recorded. eslogger observes after the fact and cannot block. Under load such as thousands of process launches per second, events are dropped (the count is shown on screen and operation continues). This record is not a replacement for an EDR (endpoint detection and response).

Credential honeytokens — 1.10.0, Pro, on by default

Plants a decoy credential file, marked with a sentinel value, at each of three locations: ~/.aws/credentials, ~/.ssh/id_rsa_backup, and ~/.docker/config.json. It never overwrites a real file already present at that path. Access detection combines the same kqueue mechanism (DispatchSource.makeFileSystemObjectSource) the canary guard uses with a 30-second access-time poll, since without EndpointSecurity an open() event cannot be caught reliably. Detection only notifies; it never blocks anything or triggers isolation. The ssh decoy uses a name ssh does not look for by default (not id_rsa), so a normal ssh run is neither broken nor falsely flagged. The AWS decoy uses a separate profile name instead of [default], the Docker decoy points at a non-existent internal registry, and all are planted with mode 0600 (from 1.10.3). In addition to those three, ~/.pypirc and ~/.env.backup are planted (aimed at scans for PyPI tokens, .env files and AI API keys). ~/.npmrc is not used because npm reads it on every run, and GCP credentials are not used because a fake file would break genuine authentication. When a decoy is actually accessed, exactly one related decoy (~/.aws/config, ~/.ssh/config or ~/.kube/config) is planted and watched as well; the chain stops after one step, and no existing file is overwritten.

Browser credential-access watch — 1.10.0, opt-in even within Pro, off by default

Watches, via FSEvents, for writes to the saved password/cookie databases of Chrome, Brave, Edge, Vivaldi, Chromium, and Firefox. When a burst occurs, lsof identifies the process; anything other than the browser itself, an indexer such as Spotlight, or a backup daemon counts as a hit. Unlike the other new guards, enabling Pro does not turn this on automatically — a browser extension or a password manager's import feature can trigger it, so the false-positive risk is inherent enough that it ships opt-in.

Communication with the Sensor (TLS and signatures)

Pairing, audit requests and result retrieval with a RoamSwitch Sensor (a separate product) on the same LAN use TLS 1.3 pinned to the certificate fingerprint (SHA-256) (Sensor 0.3.9 and later). The fingerprint is not fetched over the network; the user types the value shown on the Sensor's screen. It does not validate the certificate authority, host name or expiry, and trusts only the pinned value. If TLS fails it does not fall back to plaintext (a fallback would let a man in the middle downgrade the connection). Request signatures include a timestamp and a nonce (v2) to prevent replay. Only an old-style pairing without a fingerprint still connects in plaintext as before, with a warning.

If the certificate changes, the connection is refused. Updating the pin is never automatic: it is saved only when the user types and confirms the new value and a signed probe actually succeeds over TLS. Limit: this probe does not use a key to check that the Sensor is the same one that was paired before. It relies on the user typing the value they confirmed on the Sensor's screen.

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.

Web shell detection in development web folders (Web Rules)

Files of local web development (~/Sites, MAMP, Herd, Local, /Library/WebServer/Documents and the project folders registered for package-CVE scanning) are checked against the same 18 detection rules as the Linux edition: PHP, JavaScript, JSP and ASP. The rules describe publicly documented techniques only and contain no signature extracted from a real sample. There are three layers: built into the app, a signed once-a-day receive-only update (folded into the existing single fetch), and your own rules in ~/Library/Application Support/RoamSwitch/rules.d (only files owned by root or you that group and others cannot write are read). Folders the app already watches, such as Downloads, are checked right after a write; web development folders are checked once a day and on demand (the daily check does not enter node_modules or .git; a check of a folder you pick does enter node_modules). Because EndpointSecurity is not used, nothing can be stopped before it runs: all detection is after the fact.

Only critical findings are quarantined automatically by default. A high finding leaves the file alone and shows up as a notification and in a list in Settings, where you can choose “do not detect again” (the quarantine level can be changed in Settings). The post-detection hook (~/Library/Application Support/RoamSwitch/hooks/post-detection) runs only if it is owned by root or you and nobody else can write it, and is stopped after 30 seconds. The account response (off by default, turning it on needs the administrator password) revokes the login shell of the owner of a quarantined file, but never touches root, UIDs below 501, or anyone who is logged in (including you, using this Mac). Run as root under the helper's own sandbox in a macOS 26 virtual machine, the real dscl shell change and restore were verified (the sandbox blocks direct writes to dslocal but not changes made through dscl). The administrator-password confirmation and the app-to-helper call path have not been verified on a real machine yet. There were 0 findings in 114,501 files of a development folder.

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). A NEFilterDataProvider system 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/hosts and attributes each flow to a process. QUIC (UDP/443) has no readable SNI, so in block mode it is dropped, forcing the browser to fall back to HTTP/2 over TCP. Needs a one-time approval in System Settings.
  • ② /etc/hosts sinkhole (fallback, while the extension is unapproved/declined). The privileged helper writes the target domains into a delimited managed section of /etc/hosts as 0.0.0.0 and 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 warn below). "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 a warn hit 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 reaches allowlist.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. The block notification 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/hosts is 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 a permanent entry with arp -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 -s is 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.json so 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 at 0600. Kill-switch: pf block drop all plus a pass quick only for lo, the tunnel interface, the UDP handshake to the pinned endpoint IP(s), DHCP, and ICMP. A non-full-tunnel AllowedIPs raises 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 reads tailscale status --json and runs tailscale set --exit-node=<node>. The Standalone build (direct download from tailscale.com) with CLI integration enabled (Settings → “CLI integration” → “Install Now”, which adds /usr/local/bin/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.
  • ⚠️ The Homebrew build (brew install tailscale) is not recommended. The tailscaled Homebrew ships has a known macOS bug that can fail to correctly rewrite the routing table (tailscale/tailscale#21395, #21216) — an exit node can show as “connected” while traffic still leaks, or clearing it can take down the Mac's own internet connection entirely. RoamSwitch verifies the actual OS routing table and warns when this happens, but using the Standalone build above avoids it in the first place.
  • Network reconfiguration on disconnect. The standalone macOS tailscaled doesn'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 so tailscaled survives).
  • 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.

Operations per protection level
OperationImplementationPrivilegeHow 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 stored on disk
DataLocationContents
License tokenKeychain
com.tetsuharu.RoamSwitch.license
An Ed25519-signed token. kSecAttrAccessibleAfterFirstUnlock
App settings / guard on-offUserDefaults suite
com.tetsuharu.RoamSwitch
Trusted-network registrations, protection policy, exclusion lists, and so on
pf state/Library/Application Support/RoamSwitch/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/RoamSwitch/threatfeed/feed.txtThe 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/hostsA 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/RoamSwitch/gateway_arp_lock.jsonThe IP → MAC set made permanent by the preventive lock (§5). Deleted when unlocked
WireGuard config/Library/Application Support/RoamSwitch/ (helper area, 0600)The .conf the user imported. The endpoint hostname is also stashed in UserDefaults (the app resolves it)
Device fallback UUIDUserDefaultsA random value, generated only when IOKit does not return a UUID (§9)
Logsos.Logger / NSLogUnified 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 the following eight, plus the user-initiated features, default-off features, and DNS threat protection described below the table, and that is all (the sixth only if the user configures a VPN, the seventh only if RoamSwitch Sensor pairing is enabled).

Outbound connections RoamSwitch makes
ConnectionDestinationWhen it happensWhat 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, device name (the "Name" in System Settings), and app version. On release, only the key and device hash. Personal information is handled by Stripe at purchase; the app does not handle it
Update check lafine.net
/updates/appcast.xml
Sparkle checks every 24 hours (and also at launch if 24 hours or more have passed since the last check). Always enabled by default; no system information is sent 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}
In the Pro version, when Link Guard's "Auto-update" is on (the default): 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 (the standard User-Agent includes the app name and version, and the macOS version). 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 and is 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
At app launch (if 30 days or more have passed since the last fetch). A check runs every 24 hours, and the actual fetch happens once every 30 days. Always (the package CVE scan update data has no off switch, since it is purely receive-only data used only for a local inventory match; it applies to all users, including the Free version. 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 (the standard User-Agent includes the app name and version, and the macOS version). 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 When the user has installed ClamAV. In the Pro version it launches freshclam automatically at launch, every 24 hours, and during autonomous patrols; in the Free version only when the menu item "Update Virus Definitions" is pressed A standard ClamAV definition fetch. It contains no RoamSwitch-derived information (ClamAV's own standard identifying information may be included)
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)
Scope of "Zero Telemetry"

"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, and the features described below the table, do exist. But each of them is a user-initiated action, a signature-verified, receive-only fetch, or communication with an external service the user has chosen (the ClamAV distribution mirrors, a protective DNS provider, the URL being checked, and so on), and the diagnostic results, ports, and file contents on the Mac are never sent to the developer.

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.

Some network traffic is not in the table. None of it sends diagnostic results or file contents to the developer. (1) The Pro DNS threat protection switches the system DNS to one of Quad9, Cloudflare, AdGuard, or CleanBrowsing (the default scope is “away from home / untrusted Wi-Fi”). While it is applied, name-resolution queries go to that provider. (2) The Pro npm signature verification asks for confirmation on every run and then queries the npm registry. (3) The active vulnerability scan (active reachability verification) is limited to this Mac itself (127.0.0.1). (4) The HEAD request that the “Link Safety Audit” sheet sends directly to the target URL is described in the paragraph below.

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).

Measured (2026-08-29) — Verdict: PASS

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 (as of 1.4.7): 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. This measurement covers 1.4.7 only. The paths added since then are listed in the table above, and a re-measurement of the current version is planned.

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. Since then, these paths have been added: the Link Guard threat feed (1.7.2, row 3 of the table), the VPN tunnel (1.7.6, row 6), the package CVE update data (row 4), and Sensor pairing (row 7). Audited on the current version, you will see receive-only GETs to lafine.net (the appcast, the threat feed, and update data); 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). The threat feed goes away with "Auto-update: Off", and the VPN traffic with no VPN configured (the appcast and the package CVE update data have no off switch).

§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 listening 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 no telemetry. The only tool that talks to an external host is run_npm_audit_signatures (the npm registry), which is opt-in and Pro-only on Mac. run_active_vuln_scan (off by default) only probes 127.0.0.1, and on Mac get_exposed_ports only sends one HTTP GET per exposed port to 127.0.0.1 to check the headers. To look up the default gateway's MAC address, the diagnostic tools may send an ICMP ping, normally one (a few if the MAC cannot be resolved), to the gateway on the LAN.
  • 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

The main tools returned by tools/list, and the data they return (see MCP setup for the full list)
ToolWhat it returnsTraffic
get_security_reportAn 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. Each item also carries the corresponding NIST CSF 2.0 and CIS Controls v8 control IDs (1.10.0 onward).Local only
verify_security_findingsRe-evaluates items (checkId) from get_security_report against the current state and returns one verdict per item, stillPresent (still applies), resolved (fixed, or not applicable by design) or inconclusive (could not be determined), plus a language-independent reason code. Use it after a fix to confirm that the fix took effect. An item that could not be measured, for example because of missing privileges, a privileged helper that is not connected or an unreachable Docker, is never reported as safe and comes back as inconclusive. Omit checkIds to check every item (up to 50 ids on Mac and 64 on Linux; Linux also accepts isServer). On Mac, host_firewall, network_stealth_mode, gateway_arp_lock, malware_scanning, dns_threat_guard and usb_zero_trust are decided by RoamSwitch's in-app settings, so they reflect those settings and are not a re-measurement of the actual OS state. It is read-only, does not connect to external hosts, and neither repairs anything nor changes any setting. On Linux it sends nothing to the LAN either and only reads the gateway from local caches. On Mac, to look up the gateway's MAC address, it may send an ICMP ping, normally one (a few if the MAC cannot be resolved), to the gateway on the LAN.Local only
audit_mcp_configsReads the MCP client configuration files (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code, Cline, Gemini CLI, plus .mcp.json and similar files in the registered project folders) and flags risky settings. It reports tools auto-approved without per-call confirmation (auto_approve), plaintext http:// servers on a non-loopback host, whether given in the url field or as a launch argument such as --server http://… (remote_plain_http), launch commands that are shell or interpreter one-liners (shell_wrapper), npx / uvx / docker run style launchers that fetch an unpinned package or image (unpinned_package_launcher), docker run with --privileged or with the Docker socket or root filesystem mounted (container_host_access), env entries that make the server load extra code at start-up, such as LD_PRELOAD or --require in NODE_OPTIONS (names matched without regard to case, values never returned; code_loading_env), entries pointing at HexStrike AI, an unauthenticated attack-tool backend (hexstrike), config files writable by group or others (world_writable_config),, Claude Code's enableAllProjectMcpServers switch (auto_trust_project_servers), and a config file that holds both a server that reads text other people wrote (web pages, mail, chat, issues) and a server that can run commands or write files, the pairing indirect prompt injection needs (recognised by well-known package names; it cannot see what a server's tools say; injection_exposure). It takes no arguments, is read-only and sends nothing to the network. It never returns env or header values, arguments or secrets, only server names, a command's basename, a URL host and, for a plain registry name, the package or image name. Files that could not be parsed are listed in unreadableFiles and were not audited.None
audit_agent_setupReads the instruction files and skills you give AI agents (CLAUDE.md, commands, agents and skills under ~/.claude, plus ~/.codex, ~/.cursor/rules, ~/.gemini, ~/.agents, ~/.openclaw, and CLAUDE.md, AGENTS.md, .cursorrules and similar files in the registered project folders) and OpenClaw's openclaw.json, and flags risky content or settings. In instruction and skill files it reports sentences hidden in invisible Unicode tag characters (hidden_tag_characters), zero-width and bidirectional-control characters (invisible_characters), wording that tells an agent to ignore earlier instructions or hide things from the user (override_instruction), fetch-and-run such as curl … | sh (remote_script_exec), decode-and-run (encoded_payload_exec), references to key and credential stores (credential_access) next to an upload or a drop-site service (credential_exfil_shape, exfil_endpoint), persistence set-up (persistence_instruction), compiled code inside a skill (skill_precompiled_code) and files others can write (agent_file_writable). Anything inside an HTML comment is rated higher, because a rendered page hides it. For OpenClaw it reports authentication switched off (openclaw_auth_disabled), a bind that is reachable from the network (openclaw_wide_bind), a token under 24 characters (openclaw_weak_token), a wildcard origin (openclaw_origin_wildcard), dangerously… switches turned on (openclaw_dangerous_flag), a proxy list that trusts everyone (openclaw_broad_trusted_proxy, openclaw_proxy_trust_loosened), DM or group policy open to anyone (openclaw_open_dm), full mDNS (openclaw_mdns_full), suppressed self-check findings (openclaw_audit_suppressed) and loose permissions (openclaw_loose_permissions). Every rule is deterministic: a hit means a person should read that line, not that the file is malicious. It takes no arguments, is read-only and sends nothing to the network. It never returns file contents or secrets (a token is reported by length only), only a rule name, a line number, a URL host and a configuration key path. Files that could not be read are listed in unreadableFiles and were not audited.
get_exposed_portsA list of listening TCP ports. For any exposed beyond localhost, it cross-references a known-dangerous-service DB and sends one HTTP GET per exposed port to 127.0.0.1:port (local, closed) to check CORS / headersOnly the probe to 127.0.0.1
get_guard_statusThe 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 stateLocal only
audit_url_safetyA 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_helpA full-text search of the bundled knowledge base (feature specs / settings / troubleshooting / notification-message explanations)None
audit_secretsDetects leaked API keys and private keys in text, a file, or a directory tree (matches are masked in the output).None
audit_security_logsAggregates 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. On Linux, if the journal could not be read (for example it timed out), the zero counts do not mean nothing happened; journalReadComplete becomes false and journalError gives the reason.Local only
run_active_vuln_scanNon-destructive, 127.0.0.1-only active vulnerability verification. It never contacts another host, but it sends network requests, so it is disabled by default and requires an opt-in in the settings.127.0.0.1 only (off by default)
run_package_cve_scanChecks installed packages (Mac: Homebrew / Linux: dpkg, dnf, zypper, pacman) against a local CVE map. Sends no network traffic at all. On Linux, if the package list could not be read or timed out, it does not report "no findings"; complete becomes false and incompleteReason gives the reason (an unfinished result is not a clean result). On Ubuntu (20.04, 22.04, 24.04, 26.04) it uses Ubuntu's own data, matches by source package name and source version, and checks only CVEs of CVSS 7.0 or higher that Ubuntu has already shipped a fix for, so "no findings" does not mean there are no known CVEs. The result gains ecosystem and release, and each finding carries binary (the installed binary package name); fixes available only through Ubuntu Pro are marked proOnly. Ubuntu derivatives (Linux Mint, Pop!_OS and others) and unsupported releases return complete = false with the reason in incompleteReason, and are not switched to the Debian data.None
run_package_cve_scan_languagesChecks 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_statusReturns the malware quarantine vault's contents: original path, detected threat name, quarantine time and size.Local only
get_canary_statusReturns the ransomware canary's decoy-file state plus up to the 50 most recent detected incidents.Local only
get_ransomware_recovery_snapshotsReturns the APFS recovery snapshots (pre-damage, detection, manual), the recommended one to recover from, the retention-mode state and the snapshot interval. A detection snapshot may already contain encrypted files. Pro only.Local only
search_exec_eventsSearches the process execution record (eslogger) by path, arguments or signing ID, and returns the recorder's state and whether the record is readable. Pro only.Local only
get_process_treeBuilds the ancestors and descendants of a process from the execution record. Pro only.Local only
get_notification_historyReturns 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_incidentsReturns 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_ssh_guard_incidentsReturns the state and history of the SSH brute-force guard: whether it bans or only detects (for example because another tool already guards sshd), the sources banned right now with the time left, and up to the 200 most recent records (a source was banned, was detected without a ban, or a login succeeded after a run of failures from the same source). The user names that were tried are not stored on Linux (only how many) and are returned sanitized, up to 5, on Mac. Mac also returns the recent SSH logins (source, user, method, key fingerprint). Read-only: no MCP tool lifts a ban. On Linux the ban list is readable by root only, so a non-root caller gets only the count. Linux: the Server Edition, and the Client Edition while sshd is running. Mac: Pro only.Local only
get_runtime_threat_statusReturns 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
get_ransomware_entropy_guard_statusReturns whether general-purpose entropy-based ransomware detection is enabled and its recent entropy-burst incidents.Local only
get_honeytoken_statusReturns whether the credential honeytoken guard is enabled and its recent access-detection events.Local only
get_browser_credential_watch_statusReturns whether the browser credential-access watch is enabled and its recent access-detection events.Local only
get_forensic_evidence_bundlesReturns the list of forensic evidence bundles (process list, network connections, modified files, and the like) saved when an emergency Air-Gap fired.Local only
get_vulnerability_scan_historyReturns the last recorded result and days elapsed (pass_age) for each probe that run_active_vuln_scan has recorded.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. 1.10.0 adds four agentskills.io-format triage skill documents (roamswitch://skills/*, English-only by design).

A misattribution found — and fixed — during a local-LLM fire drill

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.

Key point: Source is public

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) with JSONEncoder's .sortedKeys and .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

Key point: Works with no server

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)

  1. Run xcodebuild archive (Release, manual signing, Developer ID Application).
  2. Export with -exportArchive as method: developer-id.
  3. After notarytool submit --wait, run stapler staple on the .app.
  4. Verify with spctl -a -t exec -vv.
  5. Re-zip after stapling to produce the Sparkle update artifact (so the notarization ticket is included and it runs offline without a Gatekeeper warning).
  6. Build the DMG, notarize and staple the DMG as well, and verify with stapler validate.

Updates (Sparkle 2.9.6)

Update settings in Info.plist
KeyValue
SUFeedURLhttps://lafine.net/updates/appcast.xml
SUPublicEDKeyCNxzwijMzMCJzliId76Yl88S/9np6t/xg/zQ9YbYzHs=
SUEnableAutomaticCheckstrue
SUScheduledCheckInterval86400

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.

Key point: The appcast is designed to be public

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). The VPN tunnel and the gateway pin are Pro and off by default. Spoofing detection is on by default, and the emergency air-gap is on by default once Pro is activated.
  • Finding dev servers and databases (Redis, MongoDB, Elasticsearch, and so on) exposed on 0.0.0.0 without 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 --privileged mode or a /var/run/docker.sock bind-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 added, and how it is contained
Attack surfaceHow 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 paths in the §7 table (and the user-initiated and default-off features described below that table). No network-send entitlement is declared, but because the app is not sandboxed, that by itself does not forbid network traffic (measurements are at the end of §7). 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 per-device identifier (only the standard User-Agent). 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 (confirming that no network-send entitlement is declared; because the app is not sandboxed, this by itself does not forbid network traffic)

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 that, in normal use, no traffic occurs outside the paths in the table
sudo tcpdump -i any -n 'host not 127.0.0.1' and 'not port 53'
# No traffic other than the paths in the table (license activation, update checks, signed update data, ClamAV updates, and so on)

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 per-device identifier (standard User-Agent only). 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 tool definitions. 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}'
RoamSwitch Architecture / Security Whitepaper v1.6 · Scope 1.10.0 (build 116) · Lafine Systems Design · 2026-09-23
Please report any errors or omissions at lafine.net/contact.html.