[Tahoe 26.x] terminal process tree EPERM in ~/Documents — root cause analysis & data
TL;DR
On macOS Tahoe 26.3, Claude Code sessions running inside a terminal (originally iTerm, later switched to Ghostty — both affected) intermittently trigger Operation not permitted for any read/exec under ~/Documents, across every shell in that terminal's process tree. cmd+Q + restart reliably recovers; no in-place fix works.
Over ~6 days I've collected 5 incidents and tested 4 hypotheses to refute. I'm filing this primarily to share refuted hypotheses so others don't redo the work, and to ask what additional data Anthropic / community would find useful.
Environment
- macOS 26.3 (Tahoe, build 25D125)
- Claude Code 2.1.139 (auto-update enabled)
- Terminal: Ghostty 1.3.1 (after migrating from iTerm2 stable — both exhibit the issue)
- Shell: zsh 5.9 (
/bin/zshsystem default) - FDA grants in place: iTerm, Ghostty,
claude.exe(added manually after first major incident) - Other relevant: company-issued machine with EndpointProtector DLP (CoSoSys) — but colleagues with same DLP + Claude Code don't see this, so DLP is not the sole cause
Symptoms
Same as #49820 / #21942 / #21654:
git,bash <script>,cat <file>,ls <dir>all returnOperation not permittedfor any pre-existing file under~/Documents- Only inside the affected terminal's process tree — opening a fresh terminal of a different app is fine
~/Desktop,~/Downloads,/tmpunaffected even during incident- Fresh-write into
~/Documentssucceeds, and fresh-exec of newly-written files succeeds — only pre-existing files are denied (this last point conflicts with the simple "kernel taints the tree" mental model) - No TCC prompt; no entry in
log show --predicate 'subsystem == "com.apple.TCC"'corresponding to the deny - cmd+Q the whole terminal app + restart → fully recovers
- Recurrence interval: 1-4 days, no obvious trigger from user side
Reproduction
I don't have a clean repro recipe. The trigger appears non-deterministic. One distinctive observation from the most recent incident: claude.exe directory was rewritten (mtime of every file under /opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/ updated within minutes of incident detection) without any npm install log → presumably Claude Code's built-in self-updater. The timing correlation is suggestive but not proven causal.
Hypotheses tested and refuted
I want to surface these so others can skip them.
❌ H1: claude.exe missing TCC entry → terminal tree gets tainted ("guilt by hosting")
- Initial hypothesis after first deep incident
- Adding
/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exeto FDA gave 4 days of no recurrence → seemed confirmed - Refuted day 5: FDA entry still in system TCC.db (
SELECT auth_value FROM access WHERE client LIKE '%claude%.exe%'returns2), incident recurred with identical symptoms
❌ H2: TCC entry binds CDHash, gets invalidated on update
- Hypothesized after H1 refute: maybe npm/self-update changes CDHash and breaks entry
- Decoded
csreqblob in/Library/Application Support/com.apple.TCC/TCC.db: it'sidentifier "com.anthropic.claude-code" and anchor apple generic and certificate leaf[subject.OU] = "Q6L2SF6YDW"— identifier + Team ID binding, not CDHash - New binary still matches the requirement
- Refuted: csreq is content-agnostic w.r.t. CDHash; npm updates don't invalidate the entry
❌ H3: Tahoe AllFiles no longer auto-subsumes DocumentsFolder
- Ghostty has
kTCCServiceSystemPolicyAllFiles=allowin system TCC.db but no explicitkTCCServiceSystemPolicyDocumentsFolderentry - iTerm has both explicitly
- Theory: Tahoe split the supersede relationship
- Refuted: System Settings → Privacy → Files and Folders shows Ghostty with "Full Disk Access" tag (locked, inherited from FDA setting), confirming AllFiles still subsumes Documents
❌ H4: DLP / EndpointSecurity extension is the gatekeeper
- Reported
EndpointProtectorClientinFiles and Folderspermission list - Theory: third-party DLP hooking file ops below TCC layer (which would explain why TCC logs show no deny)
- Refuted: Colleagues with the same DLP + Claude Code combo don't see this
Surviving but unprovable hypotheses
- H5:
claude.exeis not the responsible subject at all — H1's 4-day stability was coincidental. No clear path to falsify without an A/B comparison. - H6: Another macOS layer (CoreServices? launchd-tracked file attribution?) is gatekeeping.
systemextensionsctl listshows only Fortinet VPN; no Endpoint Security extensions present. - H7 (weak): Claude Code's silent self-updater rewrites the binary directory, which invalidates some per-inode kernel cache, which re-evaluates incorrectly under Tahoe. Time-line evidence only; no proof.
Workarounds tried
| Workaround | Effect | Cost |
|---|---|---|
| cmd+Q + restart terminal app | Reliable | Lose unsaved session state |
| Add claude.exe to FDA | Helped initially, recurred after 4 days | Manual repeat after each update? unclear |
| Switch terminal (iTerm → Ghostty) | No effect (both fail) | One-time migration |
| Force bash via CLAUDE_CODE_SHELL=/bin/bash | Reported elsewhere; haven't tried | Lose zsh |
| Disable Claude Code auto-update | Untested but planned | Manual updates required |
Diagnostic data collected
I've kept structured logs of each incident in a local repo: process chain dumps, TCC.db state snapshots, log show excerpts, xattr traces, fresh-exec boundary tests. Happy to share specific excerpts on request — I can post sanitized snippets here if there's interest, or attach a .tar.gz.
Particularly distinctive on the most recent incident: TCC log over 30 minutes around the incident contains zero denies for claude.exe or ~/Documents access — contradicting the assumption that this is a TCC-layer denial.
Asks for maintainers
- What additional data would help you root-cause this? I'm willing to instrument further.
- Is the silent self-updater documented anywhere? I want to be sure it's not coincidental that
claude.exe's mtime gets rewritten withoutnpm installlogs. - Can
claudeship with a--disable-autoupdaterorautoUpdates: falseconfig that's discoverable inclaude doctor? I'd opt in if I knew it existed. - Is anyone tracking this against Apple Feedback Assistant? If the root cause is Tahoe-side, this might need parallel reporting.
Related issues
- #49820 — Tahoe zsh sysctl sandbox
- #21942 — com.apple.provenance
- #21654 — cwd-* op not permitted
- #22109 — zsh writes to /tmp/claude-501
- #22008 — hooks ignored on iTerm
All overlap in symptom but none isolate the root cause.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Filed #60868 independently before finding this thread; closing mine as a duplicate. Adding the bits that weren't already in your investigation, in case they help.
Launch context that also reproduces: Claude Code CLI launched from the Claude Desktop app (
/Applications/Claude.app), not from a terminal. Process tree:launchd → Terminal.app → login → -zsh → Claude → Claude → /bin/zsh -c <tool-call>. Same EPERM symptom on~/Documents/*reads. So the bug isn't specific to iTerm/Ghostty — it hits any host process tree.Companion symptom: spurious "Working directory was deleted" cwd resets. During the same incidents,
claude.exeintermittently reports:…when the directory clearly exists and
statsucceeds. From decompiling the v2.1.145 binary (builddaa4c3755d45ab0cf97bb41db8c03bd2dfd2ff5f), this comes from arealpath(recorded_cwd)call inclaude.exethat throws; the wrapper walks a fallback list (origCwd,homedir, project root) and surfaces the recovery message when the recovered dir isn't first in that list. cwd is persisted between Bash invocations viapwd -P >| /tmp/claude-XXXX-cwd.This is almost certainly the same underlying kernel EPERM as your file-read symptom — just hitting
realpath()made fromclaude.exeinstead ofopen()made from the sandboxed bash child. Both fail at the same layer; the wrapper just translates the realpath failure into a different user-facing message.Sandbox vs wrapper symptoms confirmed independent:
stat <path>succeeds (metadata)dd if=<path> of=/dev/nullfails with EPERM (data)file-read-datawhile allowingfile-read-metadatafor paths that should be in the allowlist. The binary contains the profile syntax ((version 1),(allow file-read*),(allow file-write*)) and per-calluseSandboxflag, so the profile is being constructed but missing the project path.Workaround that worked reliably mid-session: bundle everything into a single
cd <project> && <work>Bash invocation. Within one sandboxed subshell, the project path stays in the allowlist; failures only appear between invocations. Doesn't fix Read/Edit tools (separate code path), but makes Bash-driven file ops viapython3 <<EOF ... EOFwork.On your H1/H2 (TCC). My data is consistent with yours: TCC alone can't explain it. The intermittency, lack of TCC log entries, and recovery on relaunch all point at sandbox-profile state managed inside
claude.exe, not at the OS TCC.db. The self-updater mtime correlation you noted is suggestive — the wrapper might be rebuilding profiles from a cached binary path that goes stale post-update.I think I found the layer your H5/H6 were pointing at: it's com.apple.macl, and the gate is the absence of that xattr on the directory, not anything in TCC.db.
Same symptom on macOS 26.5 (25F71), iTerm2 3.6.11 — "Operation not permitted" on ~/Documents/*, no TCC deny logged, recurs several times/day. I logged four snapshots across one break/fix cycle (TCC grant + access probe + xattr side by side):
state ls ~/Documents com.apple.macl on ~/Documents iTerm kTCCServiceDeveloperTool tccd PID
working OK present (288 hex) auth_value=2 unchanged
broken EPERM absent auth_value=2 (unchanged) unchanged
after fix OK present again auth_value=2 unchanged
------
The fix that confirms the mechanism: a single write into the affected dir re-stamps the macl and restores access immediately, no Settings toggle / no restart:
touch ~/Documents/.macl-poke && rm ~/Documents/.macl-pokeMust be run from an app that still has access (e.g. Terminal.app if iTerm is the broken one) — the write is what re-applies the macl. After that, xattr ~/Documents shows com.apple.macl back, and the denied app works again on a fresh shell.
So the model I'd propose: macOS Tahoe is dropping the com.apple.macl entry off protected dirs mid-session; the kernel then denies, regardless of TCC state. The earlier "toggle Developer Tools / re-add FDA" workarounds probably worked only because they incidentally re-stamped the macl.
Hopefully reproduces via: capture
xattr ~/Documents(orxattr -px com.apple.macl ~/Documents) while broken and compare to working, mine showed mail fatter absent from Documents and its' subfolders while it wasn't working, there while it was.Kernel-log evidence for the trigger layer — and a counterfactual that breaks the obvious model
Independent reproduction on another machine (Apple Silicon, 16 GB, Tahoe 26.1, Ghostty + iTerm2 both affected). Instrumented since 2026-06-29. Worth noting the version spread across this thread alone: OP on 26.3, @jni-bot on 26.5 (25F71), me on 26.1 — the bug spans at least 26.1 → 26.5, so no point release has fixed it and none introduced it.
@jni-bot's
com.apple.maclsnapshot is the missing disk-side observable. What follows is the trigger side, plus a counterfactual that kills the model most of us (me included) reached for first.TL;DR: the correlate is not "tccd died" — it's memory-pressure mass sweeps by jetsam. A solo idle-reap of
tccdis harmless. And the break can fire withtccdalive and never restarted. Recovery is terminal-app restart; CLI restart is useless.1. The incident that started it (2026-06-29, kernel logs, second-resolution)
First deny lands 6 seconds after the
tccdrespawn. Break window22:42:24 – 23:21:21.Matches your report exactly on the two things that matter: no
com.apple.TCCsubsystem deny is ever logged (the refusal surfaces as a kernel SandboxSystem Policydeny, i.e. below the TCC layer), and Cmd+Q of the terminal app is the only recovery — spawning a freshclaudein the same terminal is still denied (verified: new pid 99960 at 23:17 also denied; only the 23:30 Ghostty restart fixed it).2. Why "tccd respawn orphans the long-lived terminal tree" is wrong
That was my model for a week. It is neither necessary nor sufficient:
tccdpid 1322 alive since 16:50 and never restarted. Ghostty started 17:47 — i.e. after tccd, the supposedly "clean" ordering — and~/Documentscontent ops were still EPERM. (This incident manifested as a write block first, which diverges from your "fresh-write succeeds" observation — see §4.)``
`kernel: memorystatus: killing (idle) tccd [18026] due to idle-exit (9)
killing_idle_process pid 18026 [tccd] (idle-exit 0 598s rf:low type:daemon)
5152KB - memorystatus_available_pages: 353392 compressor_size:479015
launchdSolo reap, ~5.4 GB available, respawn ~3 s later. The Ghostty tree was **40 h+ old** and straddled the respawn (proven via
logs: no spawn/removal for that app until much later). I then hammered~/Documents` for 20 hours. Zero denies.So firing is not deterministic on the respawn-orphan condition.
3. Current best model: the sweep, not the death
What separates firing from non-firing incidents is the jetsam pressure regime, not
tccd's own lifecycle:| Date | jetsam context | tccd killed? | fired? |
|---|---|---|---|
| 06-29 | sweep, 7+ idle daemons reaped | yes | yes |
| 07-03 | tccd reaped 6–7× / 6 h (sustained pressure) | yes (repeatedly) | yes |
| 07-06 | 401
memorystatuskills in final 50 min (~8/min) | no | yes || 07-09 | single idle reap, 34% free | yes | no |
The 07-06 row is the decisive one: a mass sweep with
tccdsurviving still fired. Hypothesis: attribution is held up by more thantccdalone, and what breaks it is co-resident daemons being culled in the same desperate-pressure sweep. Unverified — but it unifies the "tccd died" and "tccd alive" variants under one mechanism, and it predicts @jni-bot's observable: themaclxattr on the folder root disappears when that sweep lands.This is exactly the correlation worth testing. @jni-bot — if you can grab a
xattr ~/Documentssnapshot plus/usr/bin/log show --predicate 'process == "kernel" AND eventMessage CONTAINS "memorystatus: killing"' --last 1hat the moment of a break, that closes the loop between your disk observable and this trigger. I have a launchd sentinel doing exactly this bundling on my side and will post the first captured incident.4. Per-folder, not per-tree (2026-07-05)
The collapse is partial. Same terminal tree, same instant:
~/Documents→ open() OK~/Desktop,~/Downloads→ EPERMMeanwhile the user
TCC.dbhadkTCCServiceSystemPolicyDesktopFolder/DocumentsFolder/DownloadsFolderall atauthValue=2, and systemTCC.dbkTCCServiceSystemPolicyAllFiles(FDA)=2. Grants are 100% intact and simply not applied — consistent with your "auth_value=2 the entire time, including while denied", and with a per-service (per-folder)maclstate rather than a whole-tree kill. It also explains why your machine sees Documents-only and mine has seen Desktop/Downloads-only: same mechanism, different folder hit.FDA is not a lever: this fires with the terminal's FDA ON.
5. Mitigation that measurably works (N=1, but the numbers moved together)
If pressure is the trigger, lowering pressure should lower firing rate. Since 2026-07-04 I keep a heavy resident GUI app quit:
| | before (07-04) | after (07-10) |
|---|---|---|
|
tccdjetsam kills | 6–7 per 6 h | 1 per 30 h || swap used | 9.1 G / 10.2 G | 1.1 G / 2.0 G |
| desperate-pressure kills | present | 0 |
| firings | recurring | 0 in 6 days |
Re-verified today at ~4 days uptime:
vm-pageshortage,highwater,lowswap,killing_top_process— all zero; the onlytccdkill in the window is the harmless 07-09 solo reap above.The other structural lever, for anyone who can take it: move active working trees out of
~/Documents/~/Desktop/~/Downloads. Non-protected paths need no grant, so they are immune regardless of what jetsam does.~/.claudeis untouched during every incident, which is the cleanest live A/B you can run mid-break.6. Diagnostic gotcha that will waste your afternoon
zshhas alogbuiltin. A barelog show --predicate ...fails withtoo many arguments— and if you piped it intotail/grep, the pipe's exit 0 swallows the failure, so you get an empty result that reads as "no events". I nearly reported "zero jetsam activity" on a window that actually held 1,392 kill lines.Always
/usr/bin/log show, and capture the exit code before the pipe:Related: pin
process == "kernel"in the predicate.CONTAINS-only predicates match thelogcommand's own invocation record (unified log stores your predicate string inargs), which produced a persistent false positive in my own tooling.Caveats
N=1 machine. Kernel unified-log retention is ~30 h here, so the 06-29 lines are as-captured at the time and can't be re-pulled. The mass-sweep hypothesis is not proven — it's the model that survives all four data points above, and it is falsifiable by the macl × sweep correlation in §3.
Happy to dump raw kernel excerpts or the sentinel's incident bundle format if useful.