DEV Community

ran guo
ran guo

Posted on

What App Sandbox actually takes away from a Mac disk cleaner

A disk cleaner is a badly-shaped citizen of the App Sandbox: its whole job is to look at files the user did not explicitly pick. DiskWise ships twice — one build inside the sandbox (Mac App Store), one outside it (direct download) — and the two builds are not the same program. Same binary logic, different capabilities.

This post is the list of differences, each one pinned to a line in the public repo so you can check it instead of trusting it. If you're planning a sandboxed utility that touches other apps' data, most of these will show up for you too.

Paths below are relative to the repo root: https://github.com/DreamOfXM/diskwise

1. You cannot scan the disk

Not "you shouldn't" — the scope constant literally resolves to a different value per build:

public static var effective: ScanScope { HomeAccess.runsSandboxed ? .user : .disk }
Enter fullscreen mode Exit fullscreen mode

Sources/DiskCleanerCore/ScanScope.swift:24

And the root list follows: the sandboxed build gets the home directory and /Applications; the direct build additionally appends the system roots (/Library, /opt, /private, /usr/local, /Volumes) at ScanScope.swift:79-81.

Worth noting why there's no scope switch in the settings UI any more. There used to be two options, and one machine exposed the bug: the overview ring chart was accounted from the user-scope numbers while the list below was built from full-disk numbers. Two denominators on one screen, neither reconcilable with the other. "The numbers don't match" is not a UX nit for this category — it's the thing that ends trust. So the app only ever scans what it can actually see, and says so.

2. Reading ~/Library costs exactly one user gesture, forever

There is no entitlement that means "please let me read the user's Library". com.apple.security.files.user-selected.read-write means user-selected. So the app asks the user to select one directory and keeps a security-scoped bookmark for it:

  • NSOpenPanel opened with canChooseDirectories = true, pre-pointed at the real home dir, one prompt labelled "grant access" (Sources/DiskCleaner/HomeGrant.swift:24-36).
  • The returned URL is turned into a bookmark and stored in UserDefaults (HomeAccess.swift:48).
  • Every launch resolves the bookmark and re-adopts the scope.

Until that grant exists, the app shows an authorization screen and the sidebar is locked — no scan is issued at all (DiskCleanerApp.swift:242, :309-310). That's a product decision, not a limitation: a cleaner that silently returns "nothing to clean" because it can't see anything is worse than one that refuses to answer.

The full entitlement set is three keys, and there is deliberately no com.apple.security.temporary-exception.files.home-relative-path.read-write list (build_app/entitlements-appstore.plist:9,11,13). Those temporary exceptions are the classic route for this category and they are also a review red line; the repo's own architecture notes say so (docs/ARCHITECTURE.md:203-205).

3. Inside the sandbox, NSHomeDirectory() lies to you — quietly

This one would have shipped as a silent bug if it hadn't been caught.

// After entering the sandbox, NSHomeDirectory() and homeDirectoryForCurrentUser
// get rewritten to ~/Library/Containers/<bundle id>/Data. Using that as a scan
// root doesn't throw. It scans an empty shell.
Enter fullscreen mode Exit fullscreen mode

(HomeAccess.swift:5-10)

The failure mode is the worst available: no error, just "your Mac looks clean". The fix is to get the home directory from the password entry instead of Foundation:

guard let pw = getpwuid(getuid()) else { ... }
return URL(fileURLWithPath: String(cString: pw.pointee.pw_dir), isDirectory: true)
Enter fullscreen mode Exit fullscreen mode

(HomeAccess.swift:14-17)

4. startAccessingSecurityScopedResource() returns true for processes that aren't sandboxed

So it can't be your sandbox test. The app detects the environment at runtime from the tell-tale path instead (HomeAccess.swift:29-31) — deliberately not with a compile-time flag, because the same binary should behave correctly in both places.

A second, related trap: the URL handed back by NSOpenPanel does not work if you call startAccessingSecurityScopedResource() on it directly — that returns false. You have to write the bookmark, resolve it back into a new URL, and access that one (HomeAccess.swift:41-47). Two steps of ceremony to obtain access you'd think the panel just gave you.

5. Emptying the Trash is denied — without even showing a prompt

The obvious design is: the app moves files to the Trash, and "empty Trash" is delegated to Finder via one Apple Event. The entitlement is there for it (automation.apple-events). On macOS 26.6 the sandboxed build finds out that this path doesn't exist:

// Measured in the sandboxed build (macOS 26.6): the event sent to Finder is killed
// by appleeventsd (`deny appleevent-send com.apple.finder`). No consent dialog is
// ever shown — TCC is never consulted.
Enter fullscreen mode Exit fullscreen mode

(Sources/DiskCleaner/Views/TrashView.swift:208-210)

Which means the pre-granted automation.apple-events entitlement buys nothing in the store build: the denial happens upstream of consent. Consequence for the UI: a button that can never light up is removed rather than left as decoration. canCommandFinder = !HomeAccess.runsSandboxed (TrashView.swift:211), and the sandboxed build shows "Empty in Finder" (:91), which opens a Finder window on ~/.Trash and lets the user press ⌘⇧⌫ themselves (:213-216). The AppleScript that actually empties the trash (Scanner.swift:660-666) only ever runs in the direct build.

So "does your app empty my Trash?" has two honest answers, and they depend on where you installed it from. Writing copy for a dual-channel app means writing it per channel.

6. No subprocess for the interesting parts

docker system df is the only way to report Docker's reclaimable space by kind (images, build cache, volumes). It's a subprocess, and subprocesses are where the two builds split:

  • Direct build: /bin/sh -c "command -v docker" (ScanJobs.swift:515), then docker info / docker system df / docker image ls.
  • Sandboxed build: the CLI chain is cut off by if !HomeAccess.runsSandboxed at ScanJobs.swift:530. It falls back to enumerating ~/Library/Containers/com.docker.docker (:527) and reporting one aggregate number.

That's a real capability difference users can see: virtual disk size is a single blob in the store build and a breakdown in the direct one.

The only Process() that survives into the sandboxed build is /usr/sbin/diskutil apfs list -plist (Scanner.swift:380, spawned at :519) — read-only volume accounting. And ~/.Trash itself is unreadable even with the home grant: the doc comment says so (Scanner.swift:624) and the contentsOfDirectory guard returns nil, not zero (:628). The UI keeps "unknown" and "0 bytes" as different states, because collapsing them is how you accidentally tell someone their Trash is empty.

7. What the sandbox gives back

The trade isn't only loss. Being inside the sandbox makes a claim cheap to check and expensive to fake.

Two years of arguing about cleaner apps all come down to "what does it actually touch, and does it phone home". The sandboxed build's answer is structural: three entitlements, no network client key, no privileged helper. That's a grep, not a promise:

grep -rn "URLSession\|NWConnection\|import Network\|CFNetwork\|WebSocket\|socket(\|getaddrinfo\|dataTask" Sources/
Enter fullscreen mode Exit fullscreen mode

The only hit in the whole source tree is a line in Localizable.strings — the UI string that says the app has no telemetry. Same for SMAppService, SMJobBless, AuthorizationExecuteWithPrivileges, "with administrator privileges": zero hits, i.e. no helper tool and no admin password prompt anywhere in the product.

Two more things the architecture was pushed into, which turned out to be worth having:

  • One delete path. Every deletion in the app funnels into FileManager.trashItem(at:resultingItemURL:) (Scanner.swift:591-596). There is no code path that unlinks a file. Auditing "can this destroy data?" is one function, not a search.
  • Undo that cannot outlive the session. Undo is an append-only [TrashRecord] in memory (DiskCleanerApp.swift:92, :136), never persisted. Restoring writes back to the original path and appends a suffix on collision rather than overwriting (Scanner.swift:604-618). "Undo stops working when you quit" is not a cleanup routine, it's the absence of a storage layer — which is also why the app can't leave a stale database behind.

The two rules from this

  1. If a capability differs per channel, it must differ in the UI too. A sandboxed build that renders a button it can't execute teaches users that the app lies. Two of the fixes above exist for exactly that reason.
  2. Don't write marketing copy from a list of assumed permissions. "It asks for Apple Events, therefore it empties your Trash via Finder" was a sentence that read fine and was false. Every capability sentence needs a measured line number behind it, or it belongs in the "we should test this" column.

Both builds are in the open: https://github.com/DreamOfXM/diskwise — the store build's ScanScope.swift:24 is a two-way fork you can read in one line, which is more than most dual-channel apps will tell you.

Top comments (0)