persistence-audit — enumerate what persists, don't grep for it
Self-Evolving skill — if a macOS release adds or moves a persistence mechanism, add a section and record it here.
SKILL="$(cc-plugin-root macos-permissions)/skills/persistence-audit"
bash "$SKILL/persistence-inventory.sh" # everything readable as you
sudo bash "$SKILL/persistence-inventory.sh" # adds root-only sections
Read-only. It deletes nothing.
🔴 Why enumerate instead of search
Grepping for product names answers "is X here?" and can never answer "what is here?". In a real cleanup that searched for a handful of vendor names, a root LaunchDaemon was running the entire time and was missed — nobody typed its name. The same run also checked for /Applications/<Vendor>.app when the bundle was actually named <Vendor> Helper.app — a confident false negative.
Checking for a name you guessed at is not a test. List what exists, then classify.
🔴 launchd addresses jobs by the Label INSIDE the plist, never the filename
A vendor plist named com.vendor.vendor_service.plist can carry Label com.vendor.service. So:
launchctl bootout system/com.vendor.vendor_service # ESRCH -> "not loaded"
…looks like the job is already gone, and you then delete a root binary out from under a live KeepAlive job. Always:
label="$(/usr/libexec/PlistBuddy -c 'Print :Label' "$plist")"
[ -n "$label" ] || { echo "no Label key — refusing to guess a target"; exit 1; }
launchctl bootout "system/$label"
Section 1 of the inventory flags every LABEL!=FILENAME mismatch for you.
Mechanisms people forget
| Mechanism | Why it hides |
|---|---|
Audio HAL plug-ins (/Library/Audio/Plug-Ins/HAL) | load into coreaudiod; appear in no launchd listing |
SecurityAgentPlugins + system.login.console | wired into the authorization chain, not a process list |
Background Task Management (sfltool dumpbtm) | the modern login-item registry; not a plist you can ls |
launchctl print-disabled | remembers labels of long-uninstalled software forever |
| System extensions / DriverKit | systemextensionsctl list, separate from launchd |
Privileged helpers (/Library/PrivilegedHelperTools) | installed by SMJobBless, survive app deletion |
🔴 Editing the login chain can lock you out
system.login.console is what authenticates you at the login window. Before touching it: keep a terminal open, verify an SSH route in (ssh from another machine, confirmed working), and know the recovery command security authorizationdb reset <UUID>. Verify afterwards by rebooting and locking/unlocking the screen with a terminal still open.
Listening sockets and the firewall belong in the same audit
A service bound to *: accepts connections from every interface. If the application firewall is disabled, those are reachable from the LAN and from any VPN/mesh network. That is live surface — unlike a dormant permission grant — and it is routinely overlooked while attention goes to uninstalling apps.
Verify reachability from another host, never from the machine itself: connecting to your own address routes over loopback and never crosses the filter, so a self-test proves nothing.
Uninstall order that avoids the traps
- TCC grants first, while the bundle still resolves (see the
tcc-grant-auditskill). - Enumerate persistence; resolve each launchd Label from inside its plist.
launchctl bootoutby Label; assert nothing remains loaded.- Only then delete files — previewing every path, never globbing wider than what the vendor created.
- Re-run the inventory and diff.
A warning about heuristic uninstallers
Tools that "find orphaned files" infer from whether a file maps to a known .app. On a machine with hand-written launchd runners in ~/.local/bin, ~/.claude/tools or a project libexec, that inference is catastrophically wrong — one popular uninstaller flagged dozens of LaunchAgent plists, most of them live loaded jobs, plus support files for several installed, running applications. Always use such a tool's list mode, verify against launchctl list, and never its bulk remove mode.
Post-Execution Reflection
- Did something turn up that no section covers? Add a section.
- Did a Label mismatch appear? Confirm the flag fired; if not, fix section 1.
- Did you find something by grepping that enumeration missed? That is a gap in the script, not a win for grep.
- Was a finding a false negative from a guessed name or path? Record the class so the next run enumerates instead.