<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ran guo</title>
    <description>The latest articles on DEV Community by ran guo (@ran_guo_5ed7f647ab1b09c50).</description>
    <link>https://dev.to/ran_guo_5ed7f647ab1b09c50</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4145460%2F92d5bb29-ffc6-4d82-9b61-3acbb3f52f7e.png</url>
      <title>DEV Community: ran guo</title>
      <link>https://dev.to/ran_guo_5ed7f647ab1b09c50</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ran_guo_5ed7f647ab1b09c50"/>
    <language>en</language>
    <item>
      <title>What App Sandbox actually takes away from a Mac disk cleaner</title>
      <dc:creator>ran guo</dc:creator>
      <pubDate>Sun, 27 Sep 2026 12:03:39 +0000</pubDate>
      <link>https://dev.to/ran_guo_5ed7f647ab1b09c50/what-app-sandbox-actually-takes-away-from-a-mac-disk-cleaner-5cmo</link>
      <guid>https://dev.to/ran_guo_5ed7f647ab1b09c50/what-app-sandbox-actually-takes-away-from-a-mac-disk-cleaner-5cmo</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Paths below are relative to the repo root: &lt;a href="https://github.com/DreamOfXM/diskwise" rel="noopener noreferrer"&gt;https://github.com/DreamOfXM/diskwise&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. You cannot scan the disk
&lt;/h2&gt;

&lt;p&gt;Not "you shouldn't" — the scope constant literally resolves to a different value per build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;effective&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ScanScope&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;HomeAccess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;runsSandboxed&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;user&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disk&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Sources/DiskCleanerCore/ScanScope.swift:24&lt;/code&gt;&lt;/p&gt;

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

&lt;p&gt;Worth noting &lt;em&gt;why&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Reading &lt;code&gt;~/Library&lt;/code&gt; costs exactly one user gesture, forever
&lt;/h2&gt;

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

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

&lt;p&gt;Until that grant exists, the app shows an authorization screen and the sidebar is locked — no scan is issued at all (&lt;code&gt;DiskCleanerApp.swift:242&lt;/code&gt;, &lt;code&gt;:309-310&lt;/code&gt;). 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.&lt;/p&gt;

&lt;p&gt;The full entitlement set is three keys, and there is deliberately no &lt;code&gt;com.apple.security.temporary-exception.files.home-relative-path.read-write&lt;/code&gt; list (&lt;code&gt;build_app/entitlements-appstore.plist:9,11,13&lt;/code&gt;). 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 (&lt;code&gt;docs/ARCHITECTURE.md:203-205&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Inside the sandbox, &lt;code&gt;NSHomeDirectory()&lt;/code&gt; lies to you — quietly
&lt;/h2&gt;

&lt;p&gt;This one would have shipped as a silent bug if it hadn't been caught.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After entering the sandbox, NSHomeDirectory() and homeDirectoryForCurrentUser&lt;/span&gt;
&lt;span class="c1"&gt;// get rewritten to ~/Library/Containers/&amp;lt;bundle id&amp;gt;/Data. Using that as a scan&lt;/span&gt;
&lt;span class="c1"&gt;// root doesn't throw. It scans an empty shell.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;HomeAccess.swift:5-10&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;pw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getpwuid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getuid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;fileURLWithPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;cString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pointee&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pw_dir&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;isDirectory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;HomeAccess.swift:14-17&lt;/code&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;startAccessingSecurityScopedResource()&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; for processes that aren't sandboxed
&lt;/h2&gt;

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

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

&lt;h2&gt;
  
  
  5. Emptying the Trash is denied — without even showing a prompt
&lt;/h2&gt;

&lt;p&gt;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 (&lt;code&gt;automation.apple-events&lt;/code&gt;). On macOS 26.6 the sandboxed build finds out that this path doesn't exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// 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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;Sources/DiskCleaner/Views/TrashView.swift:208-210&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Which means the pre-granted &lt;code&gt;automation.apple-events&lt;/code&gt; 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. &lt;code&gt;canCommandFinder = !HomeAccess.runsSandboxed&lt;/code&gt; (&lt;code&gt;TrashView.swift:211&lt;/code&gt;), and the sandboxed build shows "Empty in Finder" (&lt;code&gt;:91&lt;/code&gt;), which opens a Finder window on &lt;code&gt;~/.Trash&lt;/code&gt; and lets the user press ⌘⇧⌫ themselves (&lt;code&gt;:213-216&lt;/code&gt;). The AppleScript that actually empties the trash (&lt;code&gt;Scanner.swift:660-666&lt;/code&gt;) only ever runs in the direct build.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. No subprocess for the interesting parts
&lt;/h2&gt;

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

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

&lt;p&gt;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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  7. What the sandbox gives back
&lt;/h2&gt;

&lt;p&gt;The trade isn't only loss. Being inside the sandbox makes a claim cheap to check and expensive to fake.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"URLSession&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;NWConnection&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;import Network&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;CFNetwork&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;WebSocket&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;socket(&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;getaddrinfo&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;dataTask"&lt;/span&gt; Sources/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Two more things the architecture was pushed into, which turned out to be worth having:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One delete path.&lt;/strong&gt; Every deletion in the app funnels into &lt;code&gt;FileManager.trashItem(at:resultingItemURL:)&lt;/code&gt; (&lt;code&gt;Scanner.swift:591-596&lt;/code&gt;). There is no code path that unlinks a file. Auditing "can this destroy data?" is one function, not a search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Undo that cannot outlive the session.&lt;/strong&gt; Undo is an append-only &lt;code&gt;[TrashRecord]&lt;/code&gt; in memory (&lt;code&gt;DiskCleanerApp.swift:92&lt;/code&gt;, &lt;code&gt;:136&lt;/code&gt;), never persisted. Restoring writes back to the original path and appends a suffix on collision rather than overwriting (&lt;code&gt;Scanner.swift:604-618&lt;/code&gt;). "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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two rules from this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;If a capability differs per channel, it must differ in the UI too.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't write marketing copy from a list of assumed permissions.&lt;/strong&gt; "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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both builds are in the open: &lt;a href="https://github.com/DreamOfXM/diskwise" rel="noopener noreferrer"&gt;https://github.com/DreamOfXM/diskwise&lt;/a&gt; — the store build's &lt;code&gt;ScanScope.swift:24&lt;/code&gt; is a two-way fork you can read in one line, which is more than most dual-channel apps will tell you.&lt;/p&gt;

</description>
      <category>swift</category>
      <category>privacy</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
