From the workshop

My terminal kept losing ~/Documents. The log showed deleted binaries.

Every tab in a Mac terminal kept losing ~/Documents, and the first shipped fix blamed an invisible prompt nobody had seen. Read properly, the macOS log pointed, on the one day it recorded failures, to long-lived processes running on executables their updaters had deleted. A scan of about a second finds them so they can be restarted; the watchdog I also run is a stopgap for my setup, not advice.

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)."

Try it: which processes are running on nothing?
A toy model in your browser: no real process, and nothing on your machine is read. Update a tool under its running sessions and compare what ps, lsof and the kernel's path lookup report, as they did in my captures.
ON DISK
/opt/tool-a/versions/ A (keeps three) /opt/tool-a/bin/tool-a -> versions/A/tool-a /usr/local/lib/node_modules/tool-b/ version 1.0.0
RUNNING
PID 4101 zshhealthy
ps/bin/zshexists
lsof txt/bin/zshexists
lookupproc_pidpath(4101) = /bin/zsh
PID 4120 tool-ahealthy
ps/opt/tool-a/bin/tool-aexists
lsof txt/opt/tool-a/versions/A/tool-aexists
lookupproc_pidpath(4120) = /opt/tool-a/versions/A/tool-a
PID 4133 tool-ahealthy
ps/opt/tool-a/bin/tool-aexists
lsof txt/opt/tool-a/versions/A/tool-aexists
lookupproc_pidpath(4133) = /opt/tool-a/versions/A/tool-a
PID 4150 tool-bhealthy
ps/usr/local/lib/node_modules/tool-b/bin/tool-bexists
lsof txt/usr/local/lib/node_modules/tool-b/bin/tool-bexists
lookupproc_pidpath(4150) = /usr/local/lib/node_modules/tool-b/bin/tool-b
THE DETECTOR
$ lsof -a -u me -d txt -F pcn # then: is the first path still there? 4101 first record: txt /bin/zsh 4120 first record: txt /opt/tool-a/versions/A/tool-a 4133 first record: txt /opt/tool-a/versions/A/tool-a 4150 first record: txt /usr/local/lib/node_modules/tool-b/bin/tool-b found 0 stale processes
Two tool-a sessions and one tool-b session are running, each from a file that is on disk. Nothing is stale: every program's file is where lsof says it is.
This model stops at the lookup. On the one day my log recorded it, a stale process touching ~/Documents made tccd's lookup fail like this, and every Terminal tab lost ~/Documents until a reset. Why every tab is my inference from behaviour: it acted like a denial cached against Terminal.

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.

Jump into the build.

Move straight to the part you want to revisit.

Every word, easy to find.

Search the transcript or choose a passage to jump to that moment.

Keep building.

References and working notes to keep beside the editor.

Controlling app access to files in macOS (Apple Platform Security) Protect your Mac app with environment constraints (Apple, WWDC23) libproc.h: proc_pidpath and proc_pidpath_audittoken (Apple XNU source) lsof manual page (-a: list selection options are ANDed) Download captions WebVTT

Search the articles, series and videos.