From early September 2026, every so often, every tab in my Mac's terminal lost my Documents folder at the same moment. git failed, ruby failed, and plain ls ~/Documents failed with "Operation not permitted". In the same tab, ~/Desktop and ~/Downloads listed normally. A reset, tccutil reset SystemPolicyDocumentsFolder com.apple.Terminal, cleared it until the next time. I diagnosed this twice. The first diagnosis rested on something nobody had observed, and I shipped a fix for it and wrote it into the project's docs.
The repository for a template-based video generation platform lives under ~/Documents, which macOS protects through TCC (Transparency, Consent and Control). TCC attributes a file access to the responsible app, here Terminal, and Terminal holds Full Disk Access. The lockout should have been impossible.
The obvious suspect
The platform renders video by driving a headless Chrome. When the process that launches a render dies abruptly, the browser is orphaned and lives on, reparented to PID 1. On 7 September I found 41 of them, some from July. The browser binary sat inside node_modules, under ~/Documents. I did read the macOS log that day: a large burst of folder-authorisation requests from the headless binary, including a burst with no render running. The theory wrote itself, and I put it in the docs:
**The macOS TCC footgun (why this matters):** the shell binary + its ~185 MB of
resources live in `node_modules/…`, under `~/Documents`. Launching from
there makes macOS demand Documents-folder access attributed to Terminal; the
headless prompt is invisible, times out, and writes a **persistent deny** that
overrides Terminal's Full Disk Access — after which `ls ~/Documents` fails with
EPERM for the whole terminal.
It is a good story, and it fits the symptom. Nobody had seen that prompt, though, or that deny row. I inferred both. Earlier that day I had rejected another theory by checking its dates, its scope and the files on disk. For this one my note says "verified", on the strength of that burst.
The commit went in at 12:42. I killed the 41 orphans, moved the browser to a copy under ~/Library, outside the protected folder, and added a reaper. Its definition of an orphan is the part I'd keep:
function orphanPids() {
let out = '';
try {
out = execSync('ps -axo pid,ppid,comm', { encoding: 'utf8' });
} catch {
return [];
}
const pids = [];
for (const line of out.split('\n')) {
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
if (!m) continue;
const [, pid, ppid, comm] = m;
if (comm.includes(MATCH) && ppid === '1') pids.push(parseInt(pid, 10));
}
return pids;
}
A live render's browser always has a live parent, so it is never matched. Sound engineering, but not the cure.
On 10 September the lockout was back, right after a batch of headless still renders, with no leaked browser. A reset cleared it, though the folder read EPERM for another 30 to 60 seconds, and the lockout returned once more that session. I made it a standing rule: a Documents deny means stop and report, never reset and continue. The reset working never told me much, because tccutil reset deletes a real deny row too. A persistent deny would leave a row to find. On 11 September there was no row. The theory was wrong.
Reading the log properly
On 11 September I went back to the log, working through an AI coding-agent session, and the first extraction returned nothing. The agent's shell was a non-interactive zsh, where log is not the log tool but a shell builtin. Replayed later in a zsh -f session:
% type log
log is a shell builtin
[exit 0]
# …
% log show --last 5m --predicate 'process == "tccd"' | wc -l
zsh:log:1: too many arguments
0
[exit 0]
# …
% log show --last 5m --predicate 'process == "tccd"' 2>/dev/null | grep -c tccd
0
[exit 1]
% /usr/bin/log show --last 5m --predicate 'process == "tccd"' | wc -l
1558
[exit 0]
With its error hidden, the builtin's zero looks exactly like "nothing happened". You won't hit this typing in a fresh Terminal tab: Apple's /etc/zshrc runs disable log, and only interactive shells read that file. Scripts, zsh -c and the shells agents run commands in keep the builtin.
With /usr/bin/log and --info --debug, tccd's diagnostics appeared. The daemon logs one request as several lines that share a msgID: the service on AUTHREQ_CTX, the processes on AUTHREQ_ATTRIBUTION, the verdict on AUTHREQ_RESULT. (A fourth type, AUTHREQ_SUBJECT, turns up too; I ignore it.) Twenty minutes of healthy traffic, captured a month later:
$ grep -o 'AUTHREQ_[A-Z]*' authreq.log | sort | uniq -c
466 AUTHREQ_ATTRIBUTION
466 AUTHREQ_CTX
466 AUTHREQ_RESULT
292 AUTHREQ_SUBJECT
Grep any one type alone and you get the permission without the process, or the process without the verdict. The 20-line script I used in September isn't in the repository; this is a re-creation for this article:
// …
import { createInterface } from 'node:readline';
const want = process.argv[2] || '';
const reqs = new Map();
const get = (id) => reqs.get(id) ?? reqs.set(id, { id }).get(id);
for await (const line of createInterface({ input: process.stdin })) {
const m = line.match(/AUTHREQ_(CTX|ATTRIBUTION|RESULT): msgID=([\d.]+), (.*)$/);
if (!m) continue;
const [, kind, id, rest] = m; const r = get(id);
if (kind === 'CTX') r.service = rest.match(/service=(\w+)/)?.[1];
if (kind === 'ATTRIBUTION') {
r.responsible = rest.match(/responsible=\{TCCDProcess: identifier=([^,]+)/)?.[1] ?? '-';
r.accessing = rest.match(/accessing=\{TCCDProcess: identifier=([^,]+)/)?.[1] ?? '-';
r.requesting = rest.match(/requesting=\{TCCDProcess: identifier=([^,]+)/)?.[1] ?? '-';
}
if (kind === 'RESULT') { r.authValue = rest.match(/authValue=(\d+)/)?.[1]; r.error = rest.match(/error=([^,]*)/)?.[1]; }
}
const rows = [...reqs.values()].filter((r) => r.service && r.service.includes(want));
const tally = new Map();
for (const r of rows) {
const k = `${r.service.replace('kTCCService', '')} | responsible=${r.responsible} | accessing=${r.accessing} | via=${r.requesting} | authValue=${r.authValue} | error=${r.error}`;
tally.set(k, (tally.get(k) ?? 0) + 1);
}
console.log(`${rows.length} requests joined (of ${reqs.size} msgIDs seen)`);
for (const [k, n] of [...tally].sort((a, b) => b[1] - a[1])) console.log(String(n).padStart(5), k);
Its top Terminal row is git, checked against Full Disk Access (SystemPolicyAllFiles) and allowed (authValue=2):
$ node join-authreq.mjs < authreq.log | grep 'responsible=com.apple.Terminal'
87 SystemPolicyAllFiles | responsible=com.apple.Terminal | accessing=com.apple.git | via=com.apple.sandboxd | authValue=2 | error=(null)
# …
My notes from 11 September record the error-level lines that told the story, tccd first and then sandboxd:
tccd: proc_pidpath_audittoken() failed from PID[28981]: (#2) No such file or directory
tccd: Failed to build 'accessingProcess' from indirect_object_token in message from sandboxd
sandboxd: tcc_server_message_request_authorization(kTCCServiceSystemPolicyDocumentsFolder)
failed: # … "server error: Failed to create Attribution Chain"
macOS could not find the program that was asking. The log store no longer reaches back to 11 September, but the first half is easy to reproduce safely. Compile a program that only sleeps, run it, delete its file, and ask the kernel for the process's path. (Copying /bin/sleep won't do: recent macOS kills a copied platform binary at launch, a launch constraint dating from macOS 13.) Captured on macOS 26, a later release than the one September's incidents ran on:
$ cat napper.c
/* napper: a harmless stand-in for a long-running CLI. Sleeps N seconds, touches no files. */
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char **argv) { sleep(argc > 1 ? atoi(argv[1]) : 60); return 0; }
$ cc -O2 -o bin/napper napper.c
$ ./bin/napper 300 &
[1] 31067
# …
$ rm bin/napper
$ ls -l bin/napper
ls: bin/napper: No such file or directory
$ ps -o pid=,comm= -p 31067
31067 ./bin/napper
# …
$ python3 -I pidpath.py 31067
proc_pidpath(31067) failed: (#2) No such file or directory
pidpath.py is Python ctypes around libproc's proc_pidpath(), the PID-based sibling of the audit-token call tccd logs (libproc.h declares both):
# …
import ctypes, ctypes.util, os, sys
libproc = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True)
pid = int(sys.argv[1])
buf = ctypes.create_string_buffer(4096)
n = libproc.proc_pidpath(pid, buf, 4096)
if n > 0:
print(f"proc_pidpath({pid}) = {buf.value.decode()}")
else:
e = ctypes.get_errno()
print(f"proc_pidpath({pid}) failed: (#{e}) {os.strerror(e)}")
It fails with the same errno and the same words, which supports my reading of tccd's line without proving what happens inside tccd. It reproduces the path lookup, not the lockout; I never induced one on purpose.
So who was running on a deleted program? On 11 September, my AI coding-agent sessions. My notes say five had been open for days and outlived three releases of the CLI, and the installer kept three versions, so the updater pruned the one version all five were running. The corrected docs list two more sources: an npm i -g upgrade renaming a package directory from under a running process, and the render engine re-downloading its browser under orphaned shells, which they name as the cause of the first incident, on 5 September, a later attribution I can't check. So the browser was never quite innocent; the prompt and the deny were the wrong part.
Why the whole terminal? When tccd can't name the accessing process, sandboxd's authorisation request fails with a server error and the access comes back refused. From there the lockout behaves like a denial cached against the responsible app: every Terminal-attributed process gets EPERM until a reset. My notes record no database row before the reset, so the reset deleted nothing; it worked like a cache flush. That cache is my inference from behaviour, not something I could find documented. The persistent-deny half fails on my own machine, at least for Desktop; I assume Documents follows the same rule. Terminal has a real Desktop deny row, last modified in November 2025 (0 is denied, 2 allowed), and Desktop reads fine:
$ sqlite3 "file:$HOME/Library/Application Support/com.apple.TCC/TCC.db?mode=ro&immutable=1" "SELECT service, auth_value, datetime(last_modified,'unixepoch') FROM access WHERE client='com.apple.Terminal' AND service IN ('kTCCServiceSystemPolicyDesktopFolder','kTCCServiceSystemPolicyDocumentsFolder');"
kTCCServiceSystemPolicyDesktopFolder|0|2025-11-11 03:33:39
$ sqlite3 "file:/Library/Application Support/com.apple.TCC/TCC.db?mode=ro&immutable=1" "SELECT service, auth_value, datetime(last_modified,'unixepoch') FROM access WHERE client='com.apple.Terminal' AND service='kTCCServiceSystemPolicyAllFiles';"
kTCCServiceSystemPolicyAllFiles|2|2026-02-11 18:22:51
$ ls ~/Desktop > /dev/null && echo "Desktop: readable" || echo "Desktop: EPERM"
Desktop: readable
# …
One folder denied, all files allowed, and the deny does not win.
On 11 September the log store reached back five days, and every attribution failure in it was from that one day. The lockouts of 6, 7 and 10 September are not attributed to this mechanism, and the 10 September one followed browser renders. What ties cause to effect is two same-day observations in my notes: the lockout that hit mid-implementation, whose triggering PID was a stale agent session, and the recurrence three hours after the fixes, traced to a session I hadn't restarted. With the path-lookup repro, that is the evidence, not proof for every lockout.
Finding them in about a second
ps won't find these. While I was writing this, an agent CLI process that had been up about three days was running on a package npm had replaced at 08:51 that morning. With the package name and npm's random suffix generalised:
$ ps -o pid=,etime=,comm= -p 22047
22047 03-01:14:25 …/<agent-cli>/…/bin/<agent>
# …
$ lsof -a -p 22047 -d txt -F n | awk '/^n/{print substr($0,2); exit}'
…/.<agent-cli>-XXXXXXXX/…/bin/<agent>
$ lsof -a -p 22047 -d txt -F n | awk '/^n/{print substr($0,2); exit}' | xargs ls -l
ls: …/.<agent-cli>-XXXXXXXX/…/bin/<agent>: No such file or directory
$ python3 -I pidpath.py 22047
proc_pidpath(22047) failed: (#2) No such file or directory
ps shows a path that exists again, because the new version sits there. lsof shows the renamed-away path the process mapped, and that path is gone. The detector in bin/tcc-doctor is built on that:
# One lsof pass over the current user's processes: p<pid>, c<command>, n<path> records.
# The FIRST txt path per process is its executable. -a ANDs the -u/-d filters (lsof ORs them
# by default, which silently turns this into a scan of everything).
stale_processes() {
lsof -a -u "$(id -u)" -d txt -F pcn 2>/dev/null | awk '
/^p/ { pid = substr($0, 2); n = 0 }
/^c/ { cmd = substr($0, 2) }
/^n/ { n++; if (n == 1) print pid "\t" cmd "\t" substr($0, 2) }
' | while IFS="$TAB" read -r pid cmd path; do
[ -e "$path" ] || printf '%s\t%s\t%s\n' "$pid" "$cmd" "$path"
done
}
If you paste it into bash, define TAB="$(printf '\t')" first, as the script does. The -a matters. man lsof says "Normally list options that are specifically stated are ORed". Without it, -u and -d txt select every file of mine or every executable mapping, and the first record per process becomes its working directory:
$ lsof -u $(id -u) -d txt -F pfn | awk '/^p/{n=0} /^f/{n++; if(n==1) print}' | sort | uniq -c
795 fcwd
$ lsof -a -u $(id -u) -d txt -F pfn | awk '/^p/{n=0} /^f/{n++; if(n==1) print}' | sort | uniq -c
781 ftxt
A working directory almost always exists, so in a run where the detector found two stale processes with -a, it found none without. The function runs under bash. In zsh, path is tied to $PATH, and the last stage of a pipeline runs in the current shell, so pasting that loop into zsh empties your PATH:
$ zsh -f -c 'echo "1 /x" | while read -r pid path; do :; done; echo "PATH=$PATH"; ls / >/dev/null && echo ls-ok'
PATH=
zsh:1: command not found: ls
Rename the variable if you borrow it. The whole doctor ran in 1.055 seconds and found the agent process (its name generalised):
$ time bin/tcc-doctor
tcc-doctor — macOS TCC / ~/Documents health
~/Documents access ......... OK
stale-binary processes ..... 1
PID 22047 up 03-01:14:00 <agent> (package replaced by an npm upgrade)
# …
orphaned headless Chrome ... 0
# …
Then prevention. I turned off the CLI's background auto-updater, so I choose when to update and restart, and since 11 September the project's browser provisioning script refuses to replace the browser while any process runs from it:
if (FORCE && s.externalExists) {
const inUse = pidsRunningFrom(EXTERNAL_BROWSER);
if (inUse.length) {
console.warn(
`[browser-home] REFUSED --provision: ${inUse.length} process(es) are running from the external shell\n` +
` (PIDs ${inUse.join(', ')}). Replacing a binary under a live process makes macOS TCC unable to\n` +
// …
process.exit(0);
}
The browser code carries the retraction in a comment: "No TCC.db row is involved (the earlier "invisible prompt writes a persistent deny" story was wrong)."
A month of the watchdog
The fixes couldn't reach a process already running on a deleted binary, and with half a dozen multi-day sessions open, restarting the stale ones isn't a routine. So on 11 September a small daemon went live, breaking my standing rule from 10 September, never reset and continue. It streams tccd's error log, resets on a match, and runs a floor reset every ten minutes in case the stream stalls:
/usr/bin/log stream --process tccd --level error --style compact \
--predicate "eventMessage CONTAINS \"$PATTERN\"" > "$fifo" 2>&1 &
stream_pid=$!
( while :; do nap "$FLOOR_SECS"; reset_tcc "floor"; done ) &
floor_pid=$!
The stream's own "Filtering the log data using ..." banner contains the predicate text, so a plain match fires on startup, as the daemon's first run did: its first event, in the same second as "start", has no PID. The guard requires the from PID[ fragment, and a two-second debounce follows it:
last=0
while IFS= read -r line; do
# the real events carry "from PID[n]"; the stream's own "Filtering the log
# data using ..." header also contains the pattern and must not count
case "$line" in *"$PATTERN"*"from PID["*) ;; *) continue ;; esac
now=$(date +%s)
pid=$(printf '%s' "$line" | sed -n 's/.*from PID\[\([0-9]*\)\].*/\1/p')
if [ $((now - last)) -lt "$DEBOUNCE_SECS" ]; then
log "event $(describe_pid "$pid") — within debounce, skipped"
continue
fi
last=$now
reset_tcc "event $(describe_pid "$pid")"
A reset loop is safe here, and the rule could go, for one reason, written in the script's header: Terminal holds Full Disk Access and has no Documents row, so the reset deletes nothing. man tccutil says reset will "Reset all decisions for the specified service, causing apps to prompt again". If your terminal has a real Documents grant, a loop like this deletes it every ten minutes.
Not counting that banner line, 1,384 log lines matched in the watchdog's first month: 913 triggered a reset and 471 fell inside the debounce, alongside 4,012 floor resets and no failed ones. "No failed" only means tccutil exited 0. Nothing has measured lockouts since 11 September, because the watchdog resets on every match and every ten minutes, and the 1,384 are not lockouts either: the pattern, proc_pidpath_audittoken() failed, matches any errno and any service.
Of those, 1,050 came from one Chrome process between 27 September and 4 October, service unknown. The first event predates the Chrome update of 2 October, and the update of 6 October produced none, so I can't say what it was. 332 came from processes that had exited before the watchdog could name them, and 2 from a third-party app's updater helper. The one event I could inspect came just after that count: tccd's two lines, then the watchdog's:
<date> 17:20:34.699 E tccd[183:15f6a7f] [com.apple.TCC:access] proc_pidpath_audittoken() failed from PID[53497]: (#3) No such process
<date> 17:20:34.699 E tccd[183:15f6a7f] [com.apple.TCC:access] Failed to build 'accessingProcess' from target_token in message from <TCCDProcess: identifier=com.apple.appleeventsd, pid=345, auid=55, euid=55, binary_path=/System/Library/CoreServices/appleeventsd>
# …
<date> 17:20:56 reset ok — event pid=53497 (gone)
Errno 3, not 2: a Terminal-attributed ListenEvent (input monitoring) request from a process that had exited about a tenth of a second earlier. No Documents request, no deleted binary, and the reset was logged about 21 seconds later, not within the second or so the script's header describes. My watchdog over-fires. The resets are harmless, but an honest count would match only (#2) or require sandboxd's Documents-folder line, a fix I hadn't made when I counted. Nor can the script's built-in self-check vouch for the reset path: it copies /bin/zsh, this macOS kills the copy at launch, and I can't show it ever ran.
Two lessons. A long-lived process can outlive its own program, and on macOS that became a permissions problem for everything sharing its terminal. And the theory I shipped first described a prompt nobody saw and a row nobody found. I had read the log then too; what changed was the error lines and the message-ID join. The question I've asked of every diagnosis since, starting with my own, is whether anyone has actually observed it.