My plan for the day was modest: sit down and list which apps on my Mac can
reach the camera, the microphone, the disk. macOS keeps those answers in a
SQLite file, and the location is no secret. So I tried to open it.
$ ls -l ~/Library/Application\ Support/com.apple.TCC/TCC.db
-rw-r--r-- 1 mustafaerbay staff 192512 Oct 6 13:35 TCC.db
$ cat ~/Library/Application\ Support/com.apple.TCC/TCC.db > /dev/null
cat: .../com.apple.TCC/TCC.db: Operation not permitted
The first line says I own the file — in fact, that everyone on this machine
may read it. The second line doesn't read it. The afternoon went to resolving
that contradiction, and I came out of it with an article.
Every measurement here is from 6 October 2026, on macOS 26.6.2 (build 25G83,
xnu-12377.161.14, arm64), using Python 3.13.3. The numbers belong to this
machine; the method works the same on yours.
It says 644, I own it, it won't open
I have a deep-set reflex about permission bits: look at ls -l, see rw-,
know you can read it. Here that reflex gave the wrong answer.
So I asked the same question three different ways — stat() for the metadata,
access() for the "may I read this" permission, open() for actually reading:
NAME MODE stat() F_OK R_OK open()
TCC.db (user) 644 OK True False EPERM(1)
Safari History.db 644 OK True False EPERM(1)
Messages chat.db 644 OK True False EPERM(1)
AddressBook plist 600 OK True True EPERM(1)
Calendars/ 755 OK True True EPERM(1)
~/Library/Photos/ 755 OK True True EPERM(1)
Suggestions/ 755 OK True False EPERM(1)
CoreSpotlight/ 755 OK True False EPERM(1)
stat() works on every one of them: I can see the name, the size, the mode,
the owner. I can read the labels on the boxes behind the fence, not what's
inside. And the failure is identical everywhere: EPERM, error number 1.
The label has its limits too. Safari's history database carries an @ at the
end of its permission string, which says the file has extended attributes. When
I try to list them:
$ ls -l ~/Library/Safari/History.db
-rw-r--r--@ 1 mustafaerbay staff 1327104 May 15 10:49 History.db
$ xattr -l ~/Library/Safari/History.db
xattr: [Errno 1] Operation not permitted: .../Safari/History.db
ls tells me the attributes exist; xattr won't tell me what they are. An
odd layering: I can read the size, the owner, the fact that attributes exist,
not the attributes themselves, and certainly not the contents.
This matters, because this isn't the failure I'm used to. For a control, I made
an unreadable file by hand:
mode 000 file -> F_OK=True R_OK=False open=EACCES(13)
A read refused because of permission bits returns EACCES (13). Not one of
the eight lines above is EACCES. They are all EPERM. Which means the thing
keeping me out of these files isn't the permission bits — it's something sitting
above the permission bits.
The kernel said no 146 times
Is one example enough? No. So I walked the entire ~/Library tree, tried to
enter every directory, and counted the ones that refused:
~/Library walk : 111,339 directories, 787,562 files visible
directories denied : 146
errno distribution : {1: 146} # 146/146 EPERM, zero EACCES
owner (uid) : {501: 146} # 146/146 mine
mode grants read to owner : 146/146
mode grants read to everyone: 41/146
I stopped for a while after writing that output down. There are 146 directories
in my own home directory where: I am the owner, the permission bit says "the
owner may read", forty-one of them say "anyone on this machine may read" — and
not one of them lets me in. The permission bits answered these 146 questions and got
all 146 wrong. To be fair: in the same walk they were right 111,339 times, so
the overall error rate isn't even one in a thousand. But the rate is the wrong
measure here — the places the bits get it wrong aren't scattered at random,
they cluster in exactly the 146 most protected directories. If a gauge fails
only where it matters, its error rate tells you nothing.
What's behind the fence
Looking down the list, something else struck me. Reading the names of those 146
directories is like reading an inventory of me:
~/Library/Mail ~/Library/Calendars
~/Library/Messages ~/Library/Photos
~/Library/Safari ~/Library/HomeKit
~/Library/Shortcuts ~/Library/Biome
~/Library/Suggestions ~/Library/IdentityServices
~/Library/PersonalizationPortrait
~/Library/Application Support/AddressBook
~/Library/Application Support/CallHistoryDB
~/Library/Application Support/Knowledge
~/Library/Group Containers/group.com.apple.notes
~/Library/Group Containers/group.com.apple.reminders
~/Library/Group Containers/group.com.apple.siri.remembers
...
My mail. My messages. Who I called. My calendar. My contacts. My photos. The
devices in my house. What Siri remembers about me. My notes, my reminders.
You can tell the fence wasn't drawn at random: I could enter 111,339
directories in that tree, and the 146 I couldn't are precisely the part that
makes me me. That didn't feel like a restriction to me — it felt
like a sensible boundary. But I could never have found where that boundary runs
by looking at file permissions.
I'm not the one being judged
Up to here I've been writing "I can't read it." Wrong subject. In the same
shell, as the same user, in the same second, the readings came out like this:
~/Desktop OK (17 entries)
~/Documents OK (22 entries)
~/Downloads OK (317 entries)
~/Pictures OK (5 entries)
~/Library/Calendars EPERM(1)
~/Library/Mail EPERM(1)
~/Library/Safari EPERM(1)
My desktop is open, my documents are open, my downloads are open. My calendar is
closed. The difference can't be the user — it's me in both cases. The difference
is which application spawned that shell.
shell : /bin/zsh
parent : claude
parent : disclaimer
parent : Claude
parent : launchd
This shell was opened from inside an application, and the party the MAC layer
is asking about is the application at the top of that chain. Desktop, Documents
and Downloads are open because that app appears to have been granted those
folders; Mail and Safari are closed because there's no grant that reaches them.
Which grant exactly, I can't see from here — the ledger holding that answer is
the TCC.db I couldn't open at the top of this article. The finger
typing the command is mine, but the permission being checked isn't.
In practice that means this: the same cat command can give one answer when run
from Terminal on your Mac and a different one when run from an editor's embedded
terminal. On this machine, "is this file readable" has no single correct answer;
it has an answer that depends on who is asking. That's a plausible reason a
script works on your machine and fails in another window of the same machine —
and the error message won't tell you that. It just says EPERM.
Three questions, three answers, and they disagree
Now the sneaky part. There are three ways to ask "can I read this file?" and
they don't give the same answer.
access(R_OK) — test -r in the shell — got 143 of the 146 right and three
wrong. The three it got wrong are these:
~/Library/Application Support/AddressBook access(R_OK)=True listdir()=EPERM
~/Library/Calendars access(R_OK)=True listdir()=EPERM
~/Library/Photos access(R_OK)=True listdir()=EPERM
I checked all three, three times in a row; identical every time. The shell
agrees: [ -r ~/Library/Calendars ] says yes.
My contacts, my calendar, my photos. Of 146 directories, the three that fooled
test -r are a person's three most personal folders. If you're looking for a
coincidence, so was I: these three line up exactly with the permission categories
Apple names explicitly in its Platform Security documentation (Calendars,
Contacts, Photos), whereas for Mail and Safari, which that document never
names, access() answers correctly. But I won't present the pattern as a rule:
Reminders is also a named category, and in its group container access() said
no. My data shows a correlation; it does not prove a rule. I couldn't find the rule, and I'd rather write that than
write as though I had.
The practical consequence doesn't change: a script that probes read permission
with test -r and trusts the result will walk into a wall in three directories
on this machine.
The worst answer came from my own script
Everything above came out of a Python script. The first version of that
script told me "directories denied: 0".
Zero. While I had concrete paths in hand that returned EPERM. The cause was
os.walk:
A) os.walk(default) : 111,339 dirs, 787,562 files, errors reported: 0
B) os.walk(onerror=...) : 111,339 dirs, 787,562 files, errors CAUGHT: 146
errno distribution : {1: 146}
did both walks return the same? dirs True files True
C) how many of the 146 appeared as a root in the output: 0 / 146
how many of the 146 appeared in a parent's dirnames list: 146 / 146
Those last two lines matter, and they must not be conflated. The names of
the fenced directories are not lost: every one of them sits in some parent's
dirnames list, all 146. What is lost is the pass through their insides. Not
one of them ever comes back as a dirpath, so not a single filename is yielded
from them — and the error that caused this is never mentioned unless you ask
for it.
With a handler, 146 errors are caught; without one, zero are reported. That
both walks return the same directory and file counts isn't a surprise, it's
structural: onerror doesn't change the traversal, only who gets told about the
failure. And that is precisely the problem — by default, nobody is told.
This is documented behaviour, hardly a surprise. Python's os.walk
documentation says it plainly: "By default, errors from the scandir() call are
ignored." The docstring of Python 3.13.3 on my machine says the same thing in
slightly different words: "By default errors from the os.scandir() call are
ignored." So the behaviour is documented; the one who hadn't read the
documentation was me.
Now put that in a backup script. Something that walks the home directory with
os.walk, copies every file it finds, and prints "787,562 files backed up, no
errors" at the end. The report looks right. The error count really is zero. And
your backup does not contain your mail, your messages, your contacts, your
calendar, your photos. The script saw those directory names, tried to step
inside, took an EPERM at the door — and told you none of it. You find out on
the day you restore.
To be fair, tar behaves more honestly than os.walk here. When I tried to
archive the Safari directory it produced three lines at once:
tar: Could not pack extended attributes: Operation not permitted
tar: Safari: Couldn't visit directory: Operation not permitted
tar: Error exit delayed from previous errors.
The last line is the important one: tar still produces the archive. I tried
archiving the fenced directory together with an accessible one; a 12,800-byte
file was created, the exit code was 1, and the contents look like this:
Safari/
tartest/
tartest/acik.txt
tartest/sub/
tartest/sub/b.txt
There is a Safari/ entry in the archive — and it is empty. Anyone reading
the file listing would think Safari had been backed up. "The archive was
created" and "the archive is complete" are two different things, and tar says which one
you got only in the exit code — and if nobody reads the code at the end of that
cron line, it may as well have said nothing.
I have a separate reckoning about what my backups actually cover —
I have 91 backups and none of them are of this machine
— but the problem there was a wrongly drawn scope. The problem here is worse:
even with the scope drawn correctly, the tool doesn't tell you what's missing.
The tools turned out honest. I didn't.
My first reaction was "so all the tools skip this silently." Then I checked, and
I was wrong. Without any pipes, I took each tool's real exit code one by one:
/usr/bin/find -type f exit=1 Operation not permitted
/usr/bin/grep -r exit=2 Operation not permitted
/usr/bin/du -sh exit=1 Operation not permitted
/usr/bin/tar -cf exit=1 Couldn't visit directory
/usr/bin/rsync -a --dry-run exit=23 Operation not permitted
/bin/cp exit=1 Operation not permitted
They all complain, and they all return a non-zero code. I checked grep -r
separately: in an accessible but empty directory it returns 1, meaning "no
match"; in a fenced directory it returns 2. So even grep reports the two
situations distinguishably. These tools have known how to report errors since
the 1970s and they still do.
So why did I think they were skipping silently? Because on the first attempt I
piped the commands through | head -3 and read the exit code that came back.
That code is head's — zero, of course. find was shouting right next to me,
and I had asked head at the other end of the pipe whether anything was wrong
and been told no.
Writing that down stings a little, but it's exactly the subject of this article:
os.walk was swallowing the error, and that same evening I made a pipe swallow
the error in my own measurement. The most dangerous property of an audit tool
isn't measuring wrong — it's reporting its failure as success. My script did
that, and so did my shell one-liner.
Seeing du on that list reminded me of a familiar family:
I once got five different answers to how much space is on my disk.
That 13.50 GiB gap wasn't caused by this — I accounted for it in full as
purgeable space and reserve. What the two have in common isn't the cause but
the habit: every measuring tool carries its own blind spot along with it, and
doesn't print that in the output.
Apple wrote the rule; I only measured it
A description of all this behaviour is out there. A long post titled On File
System Permissions, written by a DTS engineer on Apple's developer forums,
gives the distinction I found by trial and error in a single sentence — if BSD
permissions or ACLs block an operation it fails with EACCES (13); if something
else blocks it, it fails with EPERM (1). Worth noting: that's a forum post,
not versioned official documentation, and it dates from 2021. The description
still holds exactly on today's macOS 26.6.2 — the 146 measurements above are
the test of that.
All 146 of my measurements are EPERM. The kernel was telling me "I didn't even
look at the permission bits," and I didn't know how to read it.
The same post says two more things. First: mandatory access control (MAC) is,
as the name suggests, mandatory — it isn't opt-in like the App Sandbox, and
every process on the system, including those running as root, is subject
to it. Mind the wording: "subject to" is not "cannot read". The MAC layer
evaluates root's request too; whether that request is approved is still decided
by the thing from the previous section — which grants the responsible app
holds. Typing sudo isn't a magic key, just a prefix that doesn't change the
question. I couldn't test this myself: sudo wants a password on this machine
and I didn't type one into an automated run; sudo -n cleanly reported "a
password is required". The one related thing I could measure: /Library/Application Support/com.apple.TCC/TCC.db is owned by root
with mode 644 — by the mode bits, readable by anyone on the machine — and my
read attempt still came back EPERM.
Second, the part of this architecture that feels most backwards: granting an app
access to ~/Library does not grant access to ~/Library/Mail, because the
subdirectory is separately protected by MAC. Permissions aren't nested boxes;
the key to the outer box doesn't open the inner one.
And one more thing: the man 2 access page on my machine describes EACCES as
"Permission bits of the file mode do not permit the..." The page talks only
about mode bits. Not a word about a MAC denial arriving later as EPERM from
open(). So the manual page didn't warn me either; it just spoke incompletely.
I wrote about a relative of this on the Linux side once:
being root and still unable to write to /tmp.
The thesis is built the same way — the permission bits say yes while the kernel
says no — but the mechanism is entirely different: there it's a sysctl called
fs.protected_regular, here it's a MAC layer tied to user consent. It's
interesting that the same lesson gets taught from two separate places on two
separate operating systems.
So how do you open the fence
I've measured all the way through this without saying what to do about it. Let
me close that.
If you want to open it, the place is fixed: System Settings → Privacy &
Security → Full Disk Access. What you add there is not your shell but the
responsible application that spawned it — Terminal if you're running from
Terminal, the editor if you're running from an editor's embedded terminal.
After adding it, quit the app fully and reopen it; a running process keeps
carrying the old decision. In Apple's management documentation this permission
goes by the name SystemPolicyAllFiles.
Three more notes. First, sudo does not solve this — as above, it doesn't
change the question. Second, if you want to take a grant back, the same pane
turns it off, and the tccutil command exists for resetting consent records.
Third, if your machine is managed by an organisation, that pane may not have
the last word: per Apple's deployment documentation an administrator can
pre-grant or restrict these through a PPPC profile, and where more than one
profile applies the operating system uses the more restrictive setting.
Finally, don't panic about backups: the issue here isn't packaged backup
products, it's the rsync/tar/Python script you wrote yourself. Whether the
backup application you use covers these directories is visible from that same
pane — check whether it's on the list.
Ten minutes on your own machine
If you're curious, the below is copy-and-run. None of it changes anything; it's
all read attempts:
# 1) What the mode bits say, and what open() says
ls -l ~/Library/Safari/History.db
cat ~/Library/Safari/History.db > /dev/null
# 2) EACCES or EPERM — the only way to see the distinction
python3 -c "
import os,errno
p=os.path.expanduser('~/Library/Safari/History.db')
try: open(p,'rb').read(1); print('read it')
except OSError as e: print(errno.errorcode[e.errno], e.errno)"
# 3) Count the fenced directories in your own home
python3 -c "
import os,errno
from collections import Counter
e=[]
for r,d,f in os.walk(os.path.expanduser('~/Library'), onerror=lambda x:e.append(x.errno)): pass
print('fenced dirs:', len(e), Counter(e))"
Delete the onerror= part of the third command and you'll see zero. Don't — or
rather, delete it once and look, because that's where the real lesson is.
If the machine has been yours for a while, your number will come out close to
mine. The number itself doesn't matter; what matters is the gap between the two
answers you get with and without onerror.
What's left when I take it apart
On this operating system the permission bits are a display piece. They're still
there, still in the ls -l output, still printed correctly — and on this
machine they gave the wrong answer 146 times in a row to the question of which
file I can read. A 1970s answer to a question the system no longer asks.
But the real lesson of the evening wasn't about macOS. Three tools in my hands —
ls, test -r, os.walk — spoke wrongly in three different ways, and all
three spoke with confidence. Not one of them said "I don't know." The worst
output an audit tool can give you isn't a wrong number; it's a clean-looking
report. My script said "787,562 files, no errors" and it was wrong by exactly
the part that mattered.
So here is the rule I'm giving myself from today: when I write code that counts
something, it will first count what it couldn't reach. If that second number
comes out zero, no celebrating — first I look at how the zero was arrived at.
Top comments (0)