Merge branch 'code/MajorAir/macos-btm-audit-wiki'
This commit is contained in:
commit
44c9d38b9f
2 changed files with 155 additions and 0 deletions
|
|
@ -0,0 +1,154 @@
|
||||||
|
---
|
||||||
|
title: "Auditing & Cleaning macOS Background App Activity (sfltool dumpbtm)"
|
||||||
|
domain: troubleshooting
|
||||||
|
category: general
|
||||||
|
tags: [macos, background-tasks, btm, sfltool, login-items, system-extensions, uninstall, little-snitch]
|
||||||
|
status: published
|
||||||
|
created: 2026-06-21
|
||||||
|
updated: 2026-06-21
|
||||||
|
---
|
||||||
|
# Auditing & Cleaning macOS Background App Activity (`sfltool dumpbtm`)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
macOS tracks every login item, agent, daemon, helper, and extension that may run in the background in its **Background Task Management (BTM)** database. The GUI shows this under **System Settings → General → Login Items & Extensions** ("Allow in the Background"), but the GUI is summarised and hides paths, identifiers, and orphans.
|
||||||
|
|
||||||
|
`sfltool dumpbtm` prints the full BTM database from the command line — and the per-user records need **no `sudo`**. This is the fastest way to answer "what is allowed to run in the background, and does each entry still map to an installed app?"
|
||||||
|
|
||||||
|
## List what's registered
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sfltool dumpbtm # per-user records, no sudo required
|
||||||
|
```
|
||||||
|
|
||||||
|
Each record looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Name: CleanMyMac Menu
|
||||||
|
Type: login item (0x4)
|
||||||
|
Disposition: [enabled, allowed, notified] (0xb)
|
||||||
|
Identifier: 4.com.macpaw.CleanMyMac-mas.Menu
|
||||||
|
URL: Contents/Library/LoginItems/CleanMyMac_5_MAS_Menu.app
|
||||||
|
Bundle Identifier: com.macpaw.CleanMyMac-mas.Menu
|
||||||
|
Parent Identifier: 2.com.macpaw.CleanMyMac-mas
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading the fields
|
||||||
|
- **Disposition** — `enabled` = actively allowed to run in the background. `disabled` = present but off.
|
||||||
|
- **Type** — what kind of item it is:
|
||||||
|
|
||||||
|
| Type | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `app (0x2)` | A normal application entry |
|
||||||
|
| `login item (0x4)` | Launches at login (menu-bar apps, helpers) |
|
||||||
|
| `agent (0x8)` / `legacy agent` | Per-user background agent |
|
||||||
|
| `legacy daemon (0x10010)` | System-wide background daemon |
|
||||||
|
| `background tasks (0x2000)` | Abstract background-task registration owned by a parent app — **has no file path of its own** |
|
||||||
|
| `developer (0x20)` | A per-developer grouping header (the collapsible row in Settings), **not an app** |
|
||||||
|
| `quicklook` / `spotlight` / `dock tile` | Plugins/extensions — not really "background apps" |
|
||||||
|
|
||||||
|
## Map entries to installed apps (find orphans)
|
||||||
|
|
||||||
|
Two gotchas make naïve path-checking fail:
|
||||||
|
|
||||||
|
1. **Absolute paths are stored as `file://` URLs**, not plain `/…`. Strip the `file://` prefix and URL-decode (`%20` → space).
|
||||||
|
2. **Child items store a *relative* `URL`** (e.g. `Contents/Library/LoginItems/…`) that must be joined to the **parent record's** absolute path, found via `Parent Identifier`.
|
||||||
|
|
||||||
|
A small parser that resolves each record to a real path and flags true orphans:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, re, os, urllib.parse
|
||||||
|
items, cur = [], None
|
||||||
|
def push():
|
||||||
|
global cur
|
||||||
|
if cur is not None: items.append(cur)
|
||||||
|
for line in sys.stdin:
|
||||||
|
s = line.strip()
|
||||||
|
if re.match(r"^#\d+:$", s): push(); cur = {}; continue
|
||||||
|
if cur is None: continue
|
||||||
|
m = re.match(r"^([A-Za-z][A-Za-z /]+):\s*(.*)$", s)
|
||||||
|
if m: cur[m.group(1).strip()] = m.group(2).strip()
|
||||||
|
push()
|
||||||
|
byid = {it["Identifier"]: it for it in items if it.get("Identifier")}
|
||||||
|
def abspath(it, d=0):
|
||||||
|
if d > 8: return None
|
||||||
|
u = it.get("URL", "")
|
||||||
|
if u and u != "(null)":
|
||||||
|
if u.startswith("file://"): return urllib.parse.unquote(u[7:]).rstrip("/")
|
||||||
|
if u.startswith("/"): return u.rstrip("/")
|
||||||
|
par = byid.get(it.get("Parent Identifier", ""))
|
||||||
|
if par:
|
||||||
|
b = abspath(par, d + 1)
|
||||||
|
if b: return os.path.join(b, urllib.parse.unquote(u)).rstrip("/")
|
||||||
|
return None
|
||||||
|
for it in items:
|
||||||
|
if not it.get("Name"): continue
|
||||||
|
p = abspath(it)
|
||||||
|
if p and not os.path.exists(p):
|
||||||
|
print("ORPHAN:", it["Name"], "->", p)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sfltool dumpbtm | python3 btm_check.py
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Expected non-orphans:** `background tasks (0x2000)` and `developer (0x20)` rows legitimately store no path — they are not missing apps. Helpers/daemons that resolve *inside* a parent bundle (e.g. `/Applications/Foo.app/Contents/Library/LoginItems/…`) or in `/Library/…` are also fine; they just don't appear as a top-level `.app`. That is usually why an entry "has no application you can find."
|
||||||
|
|
||||||
|
## Disable background for an app
|
||||||
|
|
||||||
|
This **cannot be scripted** — Apple deliberately gates the toggle behind the GUI:
|
||||||
|
|
||||||
|
**System Settings → General → Login Items & Extensions → "Allow in the Background"** → switch the app off.
|
||||||
|
|
||||||
|
Disabling a `developer (0x20)` grouping header turns off all of that developer's sub-items at once.
|
||||||
|
|
||||||
|
## Uninstall cleanly — the system-extension trap
|
||||||
|
|
||||||
|
**Dragging an app to the Trash is not a full uninstall.** Apps that install a **network/system extension** plus a privileged daemon (firewalls and VPNs especially — Little Snitch, Mullvad, etc.) leave their `/Library` daemon **still loaded and running** after the app is trashed. The BTM entry persists and the background service keeps working.
|
||||||
|
|
||||||
|
### 1. Prefer the app's own uninstaller
|
||||||
|
- **Bundled uninstall script** (Mullvad): runs cleanly, deactivates the system extension, resets the firewall.
|
||||||
|
```bash
|
||||||
|
sudo "/Applications/Mullvad VPN.app/Contents/Resources/uninstall.sh"
|
||||||
|
```
|
||||||
|
- Some apps ship an uninstaller in their DMG or a CLI tool. **Note:** Little Snitch 6.x has **no DMG uninstaller and no `littlesnitch uninstall` subcommand** — manual removal is the supported route there.
|
||||||
|
|
||||||
|
### 2. Check whether a system extension is still active
|
||||||
|
```bash
|
||||||
|
systemextensionsctl list
|
||||||
|
```
|
||||||
|
If the app's extension is **not** listed (only unrelated ones like Tailscale/Canon remain), the extension is already deactivated and a manual file removal is now complete and safe.
|
||||||
|
|
||||||
|
### 3. Manual removal (when no uninstaller exists)
|
||||||
|
Find every component first:
|
||||||
|
```bash
|
||||||
|
ls /Library/LaunchDaemons/<id>* /Library/LaunchAgents/<id>* 2>/dev/null
|
||||||
|
ls -d "/Library/Application Support/<Vendor>" 2>/dev/null
|
||||||
|
ls ~/Library/Preferences/<id>* 2>/dev/null
|
||||||
|
```
|
||||||
|
Then boot out the daemon and remove the files:
|
||||||
|
```bash
|
||||||
|
sudo launchctl bootout system /Library/LaunchDaemons/<id>.daemon.plist 2>/dev/null
|
||||||
|
sudo rm -f /Library/LaunchDaemons/<id>.daemon.plist /Library/LaunchAgents/<id>.agent.plist
|
||||||
|
sudo rm -rf "/Library/Application Support/<Vendor>" "$HOME/.Trash/<App>.app"
|
||||||
|
rm -f ~/Library/Preferences/<id>*.plist # user-owned, no sudo
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Shared-container caution:** before deleting `~/Library/Group Containers/*`, check it isn't shared. Microsoft apps share `UBF8T346G9.com.microsoft.oneauth`, `…entrabroker`, and `…teams` across Office/Teams/RDP — delete only the app-specific container (e.g. `…com.microsoft.rdc`), never the shared auth ones.
|
||||||
|
|
||||||
|
## Stale BTM "ghost" entries
|
||||||
|
|
||||||
|
After a manual uninstall, `sfltool dumpbtm` may still list the removed app, pointing at now-deleted paths. These are harmless orphans (nothing left to load). **BTM reconciles them on the next reboot / login cycle** — a reboot also finalises any system-extension teardown.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sfltool dumpbtm # full per-user BTM dump (no sudo)
|
||||||
|
sfltool dumpbtm | grep -A6 'Name:' # browse records
|
||||||
|
systemextensionsctl list # active network/system extensions
|
||||||
|
# Verify a removal:
|
||||||
|
sfltool dumpbtm | grep -i <vendor> # should be empty after a reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
- Apple gates "Allow in the Background" behind System Settings — there is no supported CLI toggle for BTM dispositions.
|
||||||
|
- For VPN/firewall apps, always reach for the vendor uninstaller first; manual `rm` alone can leave a registered system extension behind.
|
||||||
|
|
@ -127,6 +127,7 @@ updated: 2026-06-21T11:46
|
||||||
* [rsync over Tailscale: Hung in TCP Teardown After Transfer Completes](05-troubleshooting/networking/rsync-tailscale-teardown-stall.md)
|
* [rsync over Tailscale: Hung in TCP Teardown After Transfer Completes](05-troubleshooting/networking/rsync-tailscale-teardown-stall.md)
|
||||||
* [iOS Tailscale Clients Report HostName="localhost" — Breaks /etc/hosts Generators](05-troubleshooting/networking/tailscale-status-json-hostname-localhost-ios.md)
|
* [iOS Tailscale Clients Report HostName="localhost" — Breaks /etc/hosts Generators](05-troubleshooting/networking/tailscale-status-json-hostname-localhost-ios.md)
|
||||||
* [macOS: Repeating Alert Tone from Mirrored iPhone Notification](05-troubleshooting/macos-mirrored-notification-alert-loop.md)
|
* [macOS: Repeating Alert Tone from Mirrored iPhone Notification](05-troubleshooting/macos-mirrored-notification-alert-loop.md)
|
||||||
|
* [Auditing & Cleaning macOS Background App Activity (sfltool dumpbtm)](05-troubleshooting/macos-background-app-activity-audit-sfltool.md)
|
||||||
* [Time Machine: Orphaned APFS `.previous` Folder Blocks All Backups](05-troubleshooting/time-machine-apfs-orphaned-previous-blocks-backup.md)
|
* [Time Machine: Orphaned APFS `.previous` Folder Blocks All Backups](05-troubleshooting/time-machine-apfs-orphaned-previous-blocks-backup.md)
|
||||||
* [OBS Studio: Stale Script Paths After Windows Profile Rename](05-troubleshooting/obs-stale-script-paths-after-windows-profile-rename.md)
|
* [OBS Studio: Stale Script Paths After Windows Profile Rename](05-troubleshooting/obs-stale-script-paths-after-windows-profile-rename.md)
|
||||||
* [ClamAV CPU Spike: Safe Scheduling with nice/ionice](05-troubleshooting/security/clamscan-cpu-spike-nice-ionice.md)
|
* [ClamAV CPU Spike: Safe Scheduling with nice/ionice](05-troubleshooting/security/clamscan-cpu-spike-nice-ionice.md)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue