[Tahoe 26.x] terminal process tree EPERM in ~/Documents — root cause analysis & data

Status Open
Reported on v2.1.139
Maintainer reply None cached
Activity 4 comments · opened May 14, 2026

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/zsh system 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 return Operation not permitted for 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, /tmp unaffected even during incident
  • Fresh-write into ~/Documents succeeds, 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.exe to 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%' returns 2), 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 csreq blob in /Library/Application Support/com.apple.TCC/TCC.db: it's identifier "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=allow in system TCC.db but no explicit kTCCServiceSystemPolicyDocumentsFolder entry
  • 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 EndpointProtectorClient in Files and Folders permission 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.exe is 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 list shows 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

  1. What additional data would help you root-cause this? I'm willing to instrument further.
  2. Is the silent self-updater documented anywhere? I want to be sure it's not coincidental that claude.exe's mtime gets rewritten without npm install logs.
  3. Can claude ship with a --disable-autoupdater or autoUpdates: false config that's discoverable in claude doctor? I'd opt in if I knew it existed.
  4. 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.

View original on GitHub ↗

3 Comments

felixlecat67 · 3 months ago

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.exe intermittently reports:

Working directory "/Users/.../<project>" was deleted; shell cwd recovered to "/Users/<user>". Re-issue your command (it will run from the recovered directory).

…when the directory clearly exists and stat succeeds. From decompiling the v2.1.145 binary (build daa4c3755d45ab0cf97bb41db8c03bd2dfd2ff5f), this comes from a realpath(recorded_cwd) call in claude.exe that 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 via pwd -P >| /tmp/claude-XXXX-cwd.

This is almost certainly the same underlying kernel EPERM as your file-read symptom — just hitting realpath() made from claude.exe instead of open() 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/null fails with EPERM (data)
  • → Seatbelt profile is denying file-read-data while allowing file-read-metadata for paths that should be in the allowlist. The binary contains the profile syntax ((version 1), (allow file-read*), (allow file-write*)) and per-call useSandbox flag, 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 via python3 <<EOF ... EOF work.

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.

jni-bot · 2 months ago

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 TCC DeveloperTool grant reads auth_value=2 (allowed) the entire time, including while denied — consistent with your "zero denies in TCC logs." TCC genuinely isn't the thing saying no.
  • ~/Desktop and ~/Downloads stay accessible while only ~/Documents is denied. All three are equally TCC-protected, so a TCC/FDA-level failure would take out all three together. The per-directory split is the tell that enforcement is happening at the macl xattr layer, which is per-directory.
  • tccd never crashes and never changes PID — so "stale handle after tccd restart" isn't it either.

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-poke

Must 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 (or xattr -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.

ohamma8253 · 1 month ago

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.macl snapshot 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 tccd is harmless. And the break can fire with tccd alive 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)

22:41:08  kernel: memorystatus: killing (idle) tccd [69137] ... idle-exit
          (part of a sweep that reaped 7+ idle daemons: installd, softwareupdated, …)
22:42:18  launchd: Successfully spawned tccd[62345]
22:42:24  kernel (Sandbox) System Policy: claude.exe(38163) deny(1) file-read-data
          /Users/<user>/Documents/...
          → cascades across the whole terminal subtree (zsh, ls, bun, other claude pids): 58 denies
23:30:19  Ghostty restarted → 0 denies afterwards

First deny lands 6 seconds after the tccd respawn. Break window 22:42:24 – 23:21:21.

Matches your report exactly on the two things that matter: no com.apple.TCC subsystem deny is ever logged (the refusal surfaces as a kernel Sandbox System Policy deny, i.e. below the TCC layer), and Cmd+Q of the terminal app is the only recovery — spawning a fresh claude in 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:

  • Not necessary (2026-07-03): fired with tccd pid 1322 alive since 16:50 and never restarted. Ghostty started 17:47 — i.e. after tccd, the supposedly "clean" ordering — and ~/Documents content ops were still EPERM. (This incident manifested as a write block first, which diverges from your "fresh-write succeeds" observation — see §4.)
  • Not sufficient (2026-07-09 18:39:48, live capture):

``
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
`
Solo reap, ~5.4 GB available, respawn ~3 s later. The Ghostty tree was **40 h+ old** and straddled the respawn (proven via
launchd 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 memorystatus kills 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 tccd surviving still fired. Hypothesis: attribution is held up by more than tccd alone, 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: the macl xattr on the folder root disappears when that sweep lands.

This is exactly the correlation worth testing. @jni-bot — if you can grab a xattr ~/Documents snapshot plus /usr/bin/log show --predicate 'process == "kernel" AND eventMessage CONTAINS "memorystatus: killing"' --last 1h at 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, ~/DownloadsEPERM

Meanwhile the user TCC.db had kTCCServiceSystemPolicyDesktopFolder / DocumentsFolder / DownloadsFolder all at authValue=2, and system TCC.db kTCCServiceSystemPolicyAllFiles (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) macl state 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) |
|---|---|---|
| tccd jetsam 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_processall zero; the only tccd kill 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. ~/.claude is untouched during every incident, which is the cleanest live A/B you can run mid-break.

6. Diagnostic gotcha that will waste your afternoon

zsh has a log builtin. A bare log show --predicate ... fails with too many arguments — and if you piped it into tail/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:

out=$(/usr/bin/log show --predicate 'process == "kernel" AND eventMessage CONTAINS "memorystatus: killing"' \
      --last 4d --style compact 2>&1); rc=$?
echo "exit=$rc lines=$(printf '%s\n' "$out" | wc -l)"

Related: pin process == "kernel" in the predicate. CONTAINS-only predicates match the log command's own invocation record (unified log stores your predicate string in args), 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.

Showing cached comments. Read the full discussion on GitHub ↗