skip to content

A script run with sudo on macOS gets "Operation not permitted" reading files under a user's ~/Desktop even though the mode bits allow it. What is blocking it, and how is it resolved?

level: seniorimportance: should knowfreq 52%

answer

  1. sudo changes nothing here
  2. the app is judged, not the user
  3. only certain folders behave this way
  4. Terminal's grants, not the script's
  5. Full Disk Access in Privacy settings

basics

~20 s

TCC — Transparency, Consent and Control — is blocking it. TCC gates access to protected user data by the identity of the responsible application, not by uid, so running as root does not help. The fix is granting Full Disk Access to the app that launched the script.

solid answer

~40 s

macOS protects certain user data — Desktop, Documents, Downloads, removable volumes, Photos, Contacts, Calendar, camera, microphone, screen recording — behind TCC, the privacy consent layer. TCC decisions are keyed to the *responsible process*, meaning the signed application the code runs under, not the uid. A shell script inherits the grants of whatever launched the shell: Terminal, iTerm, an IDE, or a launch daemon. Because the check is on code identity, `sudo` is irrelevant — root gets `EPERM` exactly like the user. The resolution is to grant that responsible application Full Disk Access in System Settings under Privacy & Security, or to grant the specific category it needs. On a managed fleet you pre-approve it with a privacy-preferences configuration payload instead of asking every user to click. `tccutil reset` clears grants for testing.

code

bash · 10 lines
bash
# Mode bits are fine; the consent layer still refuses
sudo ls ~/Desktop
# ls: : Operation not permitted

# The same home directory, an unprotected path: works
sudo ls ~/projects

# Clear recorded consent for one app to retest the prompt
tccutil reset SystemPolicyDesktopFolder com.example.myapp
tccutil reset All com.example.myapp

go deeper

for a junior

Recognise that macOS guards folders like Desktop, Documents and Downloads behind a privacy consent layer, and that the fix lives in System Settings under Privacy & Security rather than in file permissions.

for a middle

Explain that consent is recorded per application and enforced regardless of uid, name the protected categories, and describe why the same script succeeds in one terminal and fails in another.

for a senior

Diagnose it live: distinguish this EPERM from a SIP or mode-bit failure, identify the responsible process in the launch chain, explain why SSH sessions and launch daemons need their own grants, and use tccutil reset to test the first-run path.

for a principal

Own the fleet position: which applications legitimately receive Full Disk Access, why that grant is effectively read-everything and must be justified per application, and how privacy-preferences profiles delivered by management replace user clicking without weakening the model.

## A second consent layer that ignores uid By the time a request reaches TCC, the Unix permission check has already passed: the file is mode 644 and owned by the user, or you are root and mode bits are moot. TCC then asks a completely different question — *is this application allowed to touch this class of user data?* — and answers it from a consent database, not from credentials. This is the most common source of macOS confusion for engineers coming from Linux, because there is no Linux equivalent that keys on the binary's identity rather than its uid. The daemon is `tccd`. Decisions live in two SQLite databases: a per-user one under `~/Library/Application Support/com.apple.TCC/TCC.db` and a system one under `/Library/Application Support/com.apple.TCC/TCC.db`. Both are themselves SIP-protected, so you cannot edit them to grant yourself access — a deliberate design choice, since a writable consent database would make the whole layer decorative. ## What TCC protects Two broad families: - **Data locations**: Desktop, Documents, Downloads, iCloud Drive, removable volumes, network volumes, plus the app-specific stores for Photos, Contacts, Calendars and Reminders. - **Capabilities**: camera, microphone, screen recording, Accessibility (synthetic events and control of other apps), input monitoring, and the catch-all **Full Disk Access**. Note what is *not* protected: `/tmp`, `/usr/local`, most of `/Library`, and the parts of a home directory outside the protected folders. A script that reads `~/.ssh` or `~/projects` runs unimpeded; the same script reading `~/Desktop` is stopped. Engineers often conclude the filesystem is "randomly broken" before they learn the boundary. ## Responsible process attribution The crucial mechanic: TCC does not grant to `/bin/zsh` or to your script. It attributes the request to the **responsible process** — the signed application at the root of the launch chain. Consequences: - A script you run in Terminal uses Terminal's grants. Approve Terminal for Full Disk Access and every script you run there inherits it — which is powerful and, from a security standpoint, exactly why you should not hand it out casually. - The same script run from an IDE's integrated terminal uses the IDE's grants, so it can succeed in one place and fail in the other with identical code. - A `launchd` daemon is its own responsible process and needs its own grant. - An `ssh` session into the Mac runs under `sshd`, so remote automation typically requires Full Disk Access for the remote-login service before it can touch protected folders — a classic "works locally, fails over SSH" report. Because grants are keyed to code identity, they are tied to the application's signature and bundle identifier. Replacing the binary with a differently signed one invalidates the grant, and macOS re-prompts. ## Diagnosing it The signature is `EPERM` where you expected success: ```sh $ sudo ls ~/Desktop ls: : Operation not permitted ``` The tells that this is TCC rather than SIP or mode bits: the path is user data rather than a system directory, the same command works for a different folder in the same home directory, and `sudo` changes nothing. Denials are recorded by the system log subsystem, so streaming logs for the TCC subsystem while reproducing shows the exact service (for example the Full Disk Access service, or the Desktop folder service) and the client being denied — far quicker than guessing which toggle to flip. `tccutil reset` clears decisions so you can test the first-run experience, either wholesale or for one bundle identifier: ```sh tccutil reset SystemPolicyDesktopFolder com.example.myapp tccutil reset All com.example.myapp ``` ## Resolving it properly For a developer machine, granting Full Disk Access to the terminal application is the pragmatic answer, and it should be a conscious decision: that terminal can then read every user's protected data with no further prompting. For an application you ship, request the narrowest category that does the job, and prompt at the moment the user asks for the feature so the dialog makes sense. Your Info.plist must carry the matching usage-description string; without it the request is denied rather than prompted. For a fleet, the answer is not "tell 500 people to click a toggle". Managed Macs receive a privacy-preferences configuration payload that pre-approves specific, code-signature-identified applications for specific services, which is the only supported way to grant these without user interaction. ## Why interviewers like this question It separates people who have actually automated a Mac from people who have read about it. The wrong instinct — "add sudo", "chmod it", "disable SIP" — is loud, and none of the three works. The right instinct is to recognise a consent layer keyed on code identity, and to ask which process is really being judged.

  • An automation works when you run it locally in Terminal but fails over SSH. Why?
    The responsible process changed. Locally the launch chain roots at Terminal, which you granted Full Disk Access; over SSH it roots at the remote-login service, which has no grant of its own. Consent does not follow the user account, so the remote-login service needs its own Full Disk Access entry before the same script can touch protected folders.
  • Why can't you just insert a row into TCC.db to grant access on a build machine?
    Both the system and per-user TCC databases are SIP-protected, so writes are refused even as root — a writable consent store would let any root-level compromise silently grant itself the camera and every user's documents. The supported paths are the user approving in System Settings, or a management-delivered privacy-preferences profile that names the application by its code signature.
  • How is TCC different from the App Sandbox?
    The sandbox is a restriction the application accepts on itself, declared through entitlements at build time and enforced for the process's whole lifetime. TCC is a consent gate the *user* controls at runtime, applied to protected data and capabilities regardless of whether the app is sandboxed. A sandboxed app still needs TCC consent for the camera; an unsandboxed app is still subject to TCC.
  • What tells you a denial is TCC rather than SIP?
    Both surface as EPERM, so look at the target. SIP guards system paths — /System, /bin, /sbin, /usr outside /usr/local — and no user setting affects it. TCC guards user data and capabilities, and the same command succeeds against an unprotected path in the same home directory. The privacy log entries name the service and client, which settles it.

saying these in an interview costs you the question

  • Suggesting sudo or chmod will fix it
  • Confusing TCC with SIP because both return EPERM
  • Thinking the grant applies to the script rather than the launching app
  • Proposing to edit TCC.db directly to add a grant
  • Assuming the whole home directory is protected

context