"Operation not permitted" on macOS: Agent Sandbox vs TCC (They Look Identical)¶
TL;DR — When a coding agent's shell tool returns
Operation not permittedreading files under~/Documents, there are two causes that produce a byte-identical signature: macOS TCC (missing Full Disk Access) and the agent's own execution sandbox. Don't diagnose from the symptom — it cannot distinguish them. Re-run the read with the sandbox disabled: if it succeeds, it was the sandbox, and no system permission was ever missing.
Symptom¶
Reading files under a TCC-protected folder (~/Documents, ~/Desktop, ~/Downloads) from an agent's shell tool fails:
cat: /Users/you/Documents/Vault/notes/host.md: Operation not permitted
sed: /Users/you/Documents/Vault/notes/host.md: Operation not permitted
grep: /Users/you/Documents/Vault/notes/host.md: Operation not permitted
Accompanied by some or all of:
- Every binary fails equally —
cat,sed,grep,awk,wc,dd. Not tool-specific. statandls -ldkeep working on the same paths — directory entries are visible while contents are not.getcwdbreaks aftercd-ing into the folder:shell-init: error retrieving current directory: getcwd: cannot access parent directories.- The readable set appears to drift mid-session — a file readable one minute returns
EPERMthe next, with nothing changed. - File mode, owner, flags, and ACLs are all completely normal (
-rw-r--r--, your own uid, nochflags, no extended ACLs).
Cause¶
Two entirely different mechanisms produce this, and the symptom above is common to both:
| macOS TCC | Agent execution sandbox | |
|---|---|---|
| Enforced by | The OS, per-application | The agent harness (Seatbelt / sandbox-exec) |
| Scope | TCC-protected folders only | Whatever the harness allowlists |
| Fixed by | Granting Full Disk Access in System Settings | Nothing in System Settings — it's harness config |
| Clears on | Grant + process restart | Per-invocation; often looks like drift |
The reason this is a genuine trap rather than a careless mistake: TCC documentation, prior incident notes, and most search results all describe the same signature, so a symptom match feels like confirmation. It isn't. EPERM is the shared errno of both mechanisms.
Diagnosis¶
Run these in order. Both are one-liners, and the first is decisive.
1. Disable the agent sandbox and retry the exact same read¶
In Claude Code, re-issue the failing Bash command with dangerouslyDisableSandbox: true. Equivalent flags exist in other harnesses; the point is to remove the harness layer without changing anything else.
- Succeeds ⇒ it was the sandbox. Stop here. No macOS permission is missing and no system setting needs changing.
- Still denied ⇒ now suspect TCC, continue to step 2.
2. Read the TCC grant table directly¶
sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \
"select client, auth_value from access where service='kTCCServiceSystemPolicyAllFiles';"
2 = allowed, 0 = explicitly denied (someone clicked Don't Allow; it will never re-prompt), absent = never asked.
Two traps when running this:
- Use the system DB under
/Library/. The per-user DB at~/Library/Application Support/com.apple.TCC/TCC.dbmay not exist at all, and its absence yieldsunable to open database file— a missing-file error that reads convincingly like a denial. - If the whole query is refused, the querying process itself lacks FDA — which is itself the answer.
The Trap: Correlation That Isn't Causation¶
A tempting shortcut is to compare readable and unreadable files and look for a distinguishing attribute. On macOS the obvious candidate is the com.apple.macl extended attribute:
You will likely find a perfect correlation — every file carrying com.apple.macl reads fine, every file without one returns EPERM. This correlation is real and it is meaningless.
com.apple.macl records a per-file access grant, written when a file is opened through an approved path. In an agent session that means it marks "files this session already opened with its own file-reading tools" — which is precisely the set the execution sandbox allowlists. Both are downstream of the same cause. Reading macl as the mechanism produces a confident, well-evidenced, wrong conclusion.
The general lesson: when two observations correlate perfectly, check whether one causes the other or whether both are effects of something upstream. Prefer a variable you can manipulate (the sandbox flag) over one you can only observe (an xattr).
Fix¶
If step 1 succeeded — the sandbox: nothing to fix at the OS level. Route reads through tools that don't go through the shell sandbox (in Claude Code, the Read tool and MCP servers bypass it), or disable the sandbox for that specific command. Do not start granting Full Disk Access; it will not help and it widens your exposure for no reason.
The bypass is narrower than it first appears, and the asymmetry is worth knowing before you rely on it:
| Operation | Under a sandbox read-block |
|---|---|
Read an existing file (Read, MCP) |
Works — bypasses the shell sandbox |
Create a new file (Write to a new path) |
Works — even in a directory whose contents can't be read |
Modify an existing file in place (Edit, Write over an existing file) |
Fails — the tool must first match the current bytes, and that read is blocked |
git anything inside the directory |
Fails — getcwd needs directory traversal, which is denied |
So "the file tools bypass the sandbox" is true for reads and for creating new files, and false for in-place edits. A session can therefore be in the confusing state of being able to write a brand-new note into a directory it cannot amend an existing note in. The fix for the write side is the same as the fix for the read side — clear the sandbox state (restart the agent process) or disable it for the specific command; there is nothing to change on the OS.
If step 2 showed a missing or 0 grant — TCC: grant Full Disk Access to the binary that is actually responsible, which is often not the terminal:
- Identify it —
codesign -dvv <path>and note theIdentifier. A tool with its own Developer ID (e.g.com.anthropic.claude-code) is its own TCC principal and does not inherit the launching terminal's grant. - System Settings → Privacy & Security → Full Disk Access → + → Cmd+Shift+G → paste the full binary path.
- Enable it, then fully quit and relaunch the app — TCC identity is bound at process start.
Granting the terminal (Warp, iTerm, Terminal.app) does nothing if the agent binary is separately signed. Conversely, adding the agent binary does nothing if the real cause was the sandbox.
Why This Matters¶
Both failure modes are silent and partial. A grep -r sweep that was blocked returns no results — indistinguishable from a sweep that found nothing. Any conclusion drawn from a blocked search is unreliable in a way that doesn't announce itself, so establishing which mechanism is in play before trusting search output is the actual point of this note.
The cost of skipping the one-line discriminator is not just wasted time: it leads to changing macOS privacy settings that didn't need changing, which is a real and unnecessary widening of what every process inside that app can reach.
Related¶
- SSH to MajorMac Hangs Indefinitely Reading Inside ~/Documents (Full Disk Access) — genuine TCC, distinguishable because it hangs rather than returning
EPERM(a non-interactive SSH session can't raise the consent dialog, so the read blocks instead of failing) - Cowork's Built-In Shell Is a Sandbox, Not the Local Machine — a different sandbox boundary with a similarly misleading surface