The App Sandbox: Learning to Live in a Cage

You cannot open a file without asking permission. Every time.

The Mac App Store requires sandboxing. Submit without the sandbox entitlement and the app is rejected. The sandbox is Apple’s security model for distributed Mac software, and for most productivity apps it is a moderate inconvenience. For a disk analyzer, it is a fundamental constraint.

Renala’s job is to read the filesystem. The sandbox says: not without permission.

The sandbox doesn’t just limit what your app can do. It limits what you can see. Every bug in this article is a perception mistake, not a coding mistake.

The sandbox container model. The app lives inside a restricted container. Everything outside is blocked by default. Access to a single folder requires the user to grant it explicitly, and persisting that access requires a security-scoped bookmark.

What the sandbox does

Think of it as a restricted environment for a single process. Docker isolates processes from each other; the App Sandbox restricts what one process can reach, enforced by the macOS kernel. A sandboxed app gets its own filesystem slice under ~/Library/Containers/<bundle-id>/Data/. Preferences, caches, application support files: all live there, not in the real home directory.

By default, a sandboxed app cannot read arbitrary files. It cannot open /Users/you/Documents/ without the user explicitly granting access. It cannot access any file the user has not explicitly granted, on any volume. It cannot read certain system directories at all.

The security model, when correctly implemented, is sound. A sandboxed app that gets compromised cannot read your SSH keys, cannot access your email, cannot touch other apps’ data. The cost is that legitimate apps, including disk analyzers, have to work harder for capabilities that non-sandboxed apps get for free. A disk analyzer that cannot read the disk is a philosophical statement, not a product.

The NSHomeDirectory trap

Early in development, the cloud drive detection code checked whether a path started with the user’s home directory. The logic used NSHomeDirectory() to get the home path. Reasonable approach, wrong environment.

Inside a sandboxed app, NSHomeDirectory() returns the container path: /Users/you/Library/Containers/com.renala.app/Data/. Not /Users/you/. The real home directory is invisible to this API.

Every cloud drive path check silently failed. iCloud paths start with /Users/you/Library/Mobile Documents/. Google Drive paths start with /Users/you/Library/CloudStorage/. Neither starts with the container path. The cloud detection code was a no-op for every user. No crash, no error, no log. Just silently, confidently wrong.

The code passed every test I wrote, because I wrote the tests in the same wrong environment. The tests were correct. The environment was lying. This is the most dangerous kind of test failure: a test that passes for the wrong reason. The fix isn’t more tests. It’s questioning whether your test environment shares the same distortion as your production code.

The fix: do not use NSHomeDirectory() at all. Check if the path contains "/Library/CloudStorage/" or "/Library/Mobile Documents/". String containment, not prefix matching. The real path will contain those substrings regardless of what the home directory prefix is.

Left: the bug. NSHomeDirectory() returns the container path, so prefix matching against real filesystem paths always fails. Right: the fix. String containment works regardless of what the home directory resolves to.

Security-scoped bookmarks

When the user selects a directory through NSOpenPanel, the app receives a URL with access for the current session. Close the app and reopen it: the URL is just a path string. Access is gone.

To reopen the same directory after a restart without re-prompting, you need a security-scoped bookmark. When the user grants access, you call url.bookmarkData(options: .withSecurityScope, ...) and store the resulting blob. On next launch, URL(resolvingBookmarkData:) reconstructs the URL, and url.startAccessingSecurityScopedResource() activates the scope.

flowchart TD
    accTitle: Security-scoped bookmark lifecycle
    accDescr: The user picks a folder in NSOpenPanel, granting a URL with security scope. The app can persist this as bookmark data and access files via startAccessingSecurityScopedResource. On next launch, the bookmark is resolved to restore access. Stale bookmarks trigger re-authorization.
    A["User picks folder<br/>in NSOpenPanel"] -->|"grants access"| B["URL + security scope<br/>active for session"]
    B -->|"url.bookmarkData(...)"| C["Bookmark blob<br/>persisted to disk"]
    B -->|"startAccessingSecurityScopedResource"| D["Access files"]
    D -->|"stopAccessingSecurityScopedResource"| E["Scope released"]
    C -->|"next launch"| F["URL(resolvingBookmarkData:)"]
    F -->|"startAccessingSecurityScopedResource"| D
    F -->|"stale or invalid"| G["Re-authorization<br/>permission dialog"]

The access is not free. Each call to startAccessingSecurityScopedResource() increments a kernel-tracked scope counter. Each call to stopAccessingSecurityScopedResource() decrements it. If you forget the stop call, the counter only goes up. A few dozen unbalanced calls, and startAccessingSecurityScopedResource() starts returning false. The app silently loses access to all bookmarked locations. No crash, no error dialog. The app just goes blind.

Renala handles this with scope-guarded access: every startAccessingSecurityScopedResource() is paired with a stopAccessingSecurityScopedResource() call, so the scope is released when the scanning operation completes. The pattern is the same as closing file handles or releasing locks: acquire, do work, release. The discipline is mundane. The failure mode without it is not.

The bookmark lifecycle isn’t just an ergonomic burden. In 2025, Microsoft discovered that the keychain entry storing the bookmark signing secret (com.apple.scopedbookmarksagent.xpc) was ACL-protected against reads but not against deletion. A sandboxed attacker could delete the secret, create a new one with a known value, forge bookmark signatures, and get ScopedBookmarkAgent to grant access to arbitrary files without user interaction (CVE-2025-31191, patched by Apple). The persistence mechanism became the escape hatch. The thing you’re managing isn’t just a resource. It’s a security surface.

When it works, the bookmark system is invisible. The user grants access once and never thinks about it again. Don’t Make Me Think applies to security UX as much as navigation. A permission prompt that appears unexpectedly reads as a bug, even when it is technically correct behavior. A user who plugs in three external drives must grant access to each one individually. For a tool whose primary use case is “help me understand what is on all my drives,” that friction adds up.

The read-write entitlement

First the API lied about where home is. Now the permission model disagrees with your own app about what it does.

The sandbox entitlement for file access has two modes: read-only and read-write. A disk analyzer only reads files. Read-only is the obvious choice. Smaller footprint, cleaner security posture.

Then came Move to Trash.

FileManager.trashItem(at:resultingItemURL:) moves a file to the Trash. It requires write access. With a read-only entitlement, the call throws a permission error. I found this during testing, not from documentation. The failure is not silent like the NSHomeDirectory() bug, but it only surfaces if you test the trash feature with the wrong entitlement, and the fix is the same: a broader permission.

The trade-off: request the read-write entitlement. Broader than strictly necessary for scanning, but a disk analyzer that can only show you the problem without letting you fix it is a diagnostic tool with no cure. Nobody wants that.

graph TD
    accTitle: App sandbox entitlements hierarchy
    accDescr: The app-sandbox entitlement is the root. It branches into read-write file access (required for Move to Trash) or read-only file access. Both variants connect to the bookmarks entitlement for persistent access across launches.
    A["App Sandbox entitlement<br/>com.apple.security.app-sandbox"] --> B["File read-write entitlement<br/>com.apple.security.files.user-selected.read-write<br/>(required for Move to Trash)"]
    A --> C["User-selected files entitlement<br/>com.apple.security.files.user-selected.read-only<br/>(read-only variant)"]
    B --> D["Bookmarks entitlement<br/>com.apple.security.files.bookmarks.app-scope<br/>(persistent access across launches)"]
    C --> D

A friend’s feedback

The sandbox doesn’t just hide files from your app. It hides problems from you. You’re testing from inside the distortion.

A friend, a graphic designer and UX specialist, tested the app on his Intel Mac. His written feedback included this: “I get the impression it doesn’t analyze hidden files. Maybe an option should be added? Besides, you can’t even target a hidden folder directly to analyze it.”

He was right on both counts. The scanner passes .skipsHiddenFiles to the file enumerator, so .Trash, .config, and similar directories are excluded from the results. The sandbox compounds this: the standard file picker does not show hidden items, so even a user who wants to scan a hidden folder has no way to navigate to it through the UI.

Adding a “show hidden files” toggle to the scanner is straightforward. Letting users target a hidden folder through the file picker requires configuring the picker explicitly, and most users would not expect to need it. These remain known limitations. The feedback confirmed they are not invisible: a designer noticed within the first session.

Living with the constraints

The sandbox restricted the API: NSHomeDirectory() returned the container path, and defensive string containment replaced prefix matching. The sandbox restricted the tests: the code passed every test because the tests shared the same distortion as the production environment. The sandbox restricted the UI: an outside observer caught what the developer, testing from inside the cage, could not see.

When you live inside a constraint long enough, you stop seeing it. The only reliable countermeasure is verification from outside: a different test environment, a different set of eyes, a different assumption about what your own app does.

The App Store requires the sandbox. The implementation details are left as an exercise for the developer.

References