majorwiki/05-troubleshooting/macos-background-app-activity-audit-sfltool.md

154 lines
7.3 KiB
Markdown

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