Skip to content

Scope & safety ​

What bleach reads vs. what it acts on ​

ReadActed on
~/Library (13 roots), ~/.cache, ~/.local/{share,state}, ~ dotdirsyesyes
/Library/{Application Support,Caches,Logs}yesnever
/Applications, /System/Applications, /var/db/receipts, /opt/homebrew, /usr/local, /Library/Launch*, lsregister, psfor attribution onlynever candidates

So "is Slack installed?" is answered machine-wide, while "may I move this?" is answered home-only.

Scan roots ​

Under ~/Library: Application Support, Caches, Containers, Group Containers, HTTPStorages, WebKit, Saved Application State, Logs, Preferences, Application Scripts, LaunchAgents, Developer, pnpm.

~/Library is scanned at named subdirectories rather than wholesale, which is deliberate — Mail, Messages and Keychains are siblings of the rest — but it means anything not on the list is invisible no matter how large. Two were: 8.5 GB under Developer (Xcode symbols, build products, simulators) and 5.4 GB of pnpm store, which lives in ~/Library/pnpm on macOS rather than under ~/.cache.

Outside it: ~/.cache, ~/.local/share, ~/.local/state, and dot-directories directly in ~.

That last group matters more than it sounds. On the development machine, opencode alone held 2.8 GB in ~/.local/share — completely invisible to a Library-only scan. Tooling installed outside the app-bundle world keeps state in XDG-ish directories, and it is not small.

.cache, .local, .config and .Trash are excluded from the dotdir root because they are separate roots or protected outright — otherwise their contents would be counted twice.

Umbrella directories ​

Some vendors shard state by product and version under one directory: JetBrains, Google, Adobe, Microsoft, Mozilla, Steam, Chromium, Code - Insiders and others. bleach descends one extra level into these, so JetBrains/IntelliJIdea2025.3 becomes its own candidate instead of hiding inside a single 5 GB blob.

The $HOME boundary ​

Three independent layers enforce it:

  1. Scan roots outside $HOME carry a requiresRoot flag, which excludes them from plans and from TUI selection.
  2. ApplyValidator refuses any path not prefixed with $HOME/, and refuses $HOME itself.
  3. PluginHost applies the same check to everything a plugin returns.

bleach never asks for sudo and cannot act outside your home even if a hand-edited plan tells it to. This is covered by SafetyTests, which asserts that /etc/hosts and /tmp are refused.

System-level state ​

/Library is scanned and reported with full evidence, so your reclaimable total is honest, but nothing there is ever plannable:

  Outside your home — needs sudo, bleach will not touch these:  900M
     529M   ORPHAN?    /Library/Application Support/Autodesk/AcActivityInsights
     197M   ORPHAN?    /Library/Application Support/Microsoft/TeamsUpdaterDaemon
      80M   ORPHAN?    /Library/Application Support/Autodesk/AdpDesktopSDK
      46M   CACHE-SAFE /Library/Logs/DiagnosticReports
    sizes are undercounts without root; verify before removing

Those rows appear in the TUI marked ⚿ and cannot be selected.

This is a deliberate line. A tool that can rm -rf in /Library as root is a categorically different risk from one confined to $HOME: the blast radius includes other users' data and /System adjacency. So bleach tells you what's there and hands you the command; you stay the one holding the root shell.

Sizes in that section are undercounts without root, and the report says so rather than implying precision it doesn't have.

Plugin boundary ​

Plugins propose; they never act. A plugin cannot delete, move, or write anything, never receives file contents, and can only speak about paths inside the scope it declared. It cannot weaken a core protection — if bleach hard-protects a path, a plugin asking for its removal is recorded and ignored. Evidence weights are clamped to ±10 so a plugin can't swamp core scoring.

See Writing a plugin.

What could still go wrong ​

Being straight about the residual risk:

  • A wrong alias would mis-attribute a directory. This is why the shipped alias table is small and was verified against a real LaunchServices registry rather than guessed — an unresolved directory is safer than a confidently-mislabelled one.
  • Heuristic staleness. A directory untouched for 200 days might still matter. That's why staleness alone never produces an actionable verdict; it has to combine with "no owner found".
  • Undercounted sizes without Full Disk Access can hide a large orphan below a --min-size filter. apply refuses partially-unreadable paths for this reason.
  • User data in an unowned directory. The user-data heuristic looks at file extensions and names, and it can miss a bespoke format. This is the main argument for leaving the default quarantine mode alone rather than reaching for --mode delete.