[Bug] Native installer produces unsealed macOS app bundle rejected by code signature validation

Status Open
Reported on v2.1.190
Maintainer reply None cached
Activity 11 comments · opened Jun 24, 2026

Bug Description

Bug: native installer produces a malformed (unsealed) ClaudeCode.app → macOS reports it as "damaged and can't be opened"

Summary

The Claude Code native installer creates ~/.local/share/claude/ClaudeCode.app without the code-signing seal (Contents/_CodeSignature/CodeResources). The bundle's Mach-O binary carries an Anthropic Developer ID signature whose CodeDirectory indicates sealed resources must be present, but the bundle on disk has none. macOS therefore fails signature validation and shows:

"ClaudeCode.app" is damaged and can't be opened. You should move it to the Trash.

The bundle is not quarantined — this is not a Gatekeeper/quarantine prompt, it's a broken/incomplete signature. It is reproducible: claude install latest --force regenerates the same malformed bundle.

Environment

  • Claude Code CLI: 2.1.190
  • Affected app bundle: ~/.local/share/claude/ClaudeCode.app (CFBundleIdentifier = com.anthropic.claude-code, LSUIElement = true)
  • macOS: 26.5.1 (build 25F80), Apple Silicon (arm64)
  • Installed native versions present: 2.1.183, 2.1.185, 2.1.186, 2.1.187, 2.1.190
  • Launcher: ~/.local/bin/claude → symlink → ~/.local/share/claude/versions/2.1.190

Steps to reproduce

  1. Have the CLI manage the native build (auto-update or claude install).
  2. Trigger whatever launches the helper app (or simply double-click ~/.local/share/claude/ClaudeCode.app).
  3. macOS shows the "damaged and can't be opened" dialog.
  4. claude install latest --force → reinstall succeeds, but the regenerated bundle is still malformed (same dialog).

Evidence

Malformed bundle layout (missing _CodeSignature/ and CodeResources):

ClaudeCode.app/Contents/
├── Info.plist
└── MacOS/claude          # 217 MB Mach-O, signed Developer ID Application: Anthropic PBC (Q6L2SF6YDW)

A healthy reference bundle (the claude.app bundled inside Claude Desktop, v2.1.181) — for contrast, it has the seal:

claude.app/Contents/
├── _CodeSignature/        # present
├── CodeResources          # present
├── Info.plist
└── MacOS/

Gatekeeper / codesign verdict on the malformed bundle:

$ spctl -a -vvv ClaudeCode.app
ClaudeCode.app: code has no resources but signature indicates they must be present

$ codesign --verify --deep --strict --verbose=2 ClaudeCode.app
ClaudeCode.app: code has no resources but signature indicates they must be present

No quarantine attribute (rules out a normal Gatekeeper download prompt):

$ xattr -l ClaudeCode.app
com.apple.macl: …        # only this (TCC); no com.apple.quarantine

The embedded signature is genuine Anthropic, with hardened runtime + entitlements — the binary is fine; only the bundle seal is missing:

Identifier=com.anthropic.claude-code
Authority=Developer ID Application: Anthropic PBC (Q6L2SF6YDW)
TeamIdentifier=Q6L2SF6YDW
CodeDirectory … flags=0x10000(runtime)
Entitlements: com.apple.security.device.audio-input,
              com.apple.security.automation.apple-events,
              com.apple.security.cs.allow-jit,
              com.apple.security.cs.allow-unsigned-executable-memory,
              com.apple.security.cs.disable-library-validation

Likely root cause

When the installer materializes ClaudeCode.app, it writes Info.plist and the (separately signed) MacOS/claude binary but does not include/produce the bundle's _CodeSignature/CodeResources seal. Because the binary's CodeDirectory expects sealed resources, the bundle fails validation as a whole and macOS marks it "damaged." The shipped/notarized bundle should include the seal (as the Claude Desktop reference bundle does).

Impact

  • The native helper app (voice dictation / URL opening / Apple Events) cannot be launched; users get a "damaged — move to Trash" dialog that wrongly implies malware/corruption.
  • Confusing because the CLI itself (~/.local/bin/claude) works perfectly — only the .app wrapper is broken.
  • Repeated trash-and-recreate by the updater left multiple stale ClaudeCode.app copies in ~/.Trash.

Suggested fix

Have the installer either (a) ship/copy the complete signed .app bundle including Contents/_CodeSignature/CodeResources, or (b) re-sign the bundle after assembling it on disk so the seal matches the contents. A post-install codesign --verify --deep --strict check would catch this regression.

Local workaround (temporary; undone by next update)

Re-sign the assembled bundle locally, preserving entitlements and hardened-runtime flags:

codesign --force --deep --preserve-metadata=entitlements,flags -s - \
  ~/.local/share/claude/ClaudeCode.app

After this, codesign --verify --deep --strict reports "valid on disk / satisfies its Designated Requirement" and the app launches (it is not quarantined). Note this…
Note: Content was truncated.

View original on GitHub ↗

9 Comments

00mkp · 2 months ago

Confirming on CLI 2.1.197 / macOS 26.5.1 (25F80), plus a runtime trigger not captured here: the agents view (FleetView).

Same root cause — ClaudeCode.app ships without Contents/_CodeSignature/CodeResources; spctl/codesign --verify --strict return code has no resources but signature indicates they must be present; no com.apple.quarantine (only com.apple.macl); claude install latest --force regenerates the same unsealed bundle.

Two additional data points:

  1. The agents view (claude agents / FleetView) is a concrete trigger, and the symptom is worse than a static dialog. Entering the agents view launches ClaudeCode.app; on macOS 26 this shows the "damaged" dialog and the spawned worker is kernel-killed — [worker crashed (SIGKILL (137)) — respawning…] in a loop, leaving the UI on "Waiting for session to redraw…". So the impact isn't limited to the dictation/URL helper; it breaks the background-agents UI at runtime.
  2. The local re-sign workaround is undone by the agents view itself, not just by updates. After codesign --force --deep --preserve-metadata=entitlements,flags -s - ClaudeCode.app (reports valid on disk), simply re-entering the agents view re-materializes the bundle — Contents/MacOS/claude reverts to a fresh hardlink of the current versions/<x> binary (confirmed by inode), dropping the seal again. So for the agents-view path the workaround doesn't survive a single launch.

Repro: CLI 2.1.197, macOS 26.5.1 build 25F80, Apple Silicon, native install (~/.local/bin/claudeversions/2.1.197).

jeroenvisser101 · 2 months ago

I also had the agents view be a trigger for me.

mprokopov · 1 month ago

Reproduced on 2.1.201 (native install, macOS 26.5.1 arm64). Note: the CLI regenerates the unsealed bundle at ~/.local/share/claude/ClaudeCode.app on startup if it's missing, so deleting it or re-running the installer doesn't help — the fresh bundle fails codesign --verify the same way ("code has no resources but signature indicates they must be present"; no Contents/_CodeSignature/CodeResources, Info.plist not bound). Direct exec of the inner binary is fine; only LaunchServices launches hit the "damaged" dialog.

chr0m1ng · 1 month ago

Reproduced on CLI 2.1.206, macOS 26.5.1 (25F80), Apple Silicon (arm64), native install.

Same signature: spctl -a -vvv / codesign --verify --deep --strictcode has no resources but signature indicates they must be present; Contents/_CodeSignature/ absent entirely; no com.apple.quarantine.

Additional data point: a full clean reinstall (rm -rf ~/.local/share/claude/ClaudeCode.app ~/.local/share/claude/versions followed by a fresh curl -fsSL https://claude.ai/install.sh | bash, not just claude install latest --force) reproduces the identical unsealed bundle — rules out any local/incremental-update corruption, confirms the malformed bundle is baked into the distributed installer/build itself.

Also confirms the bundle self-regenerates: deleting ClaudeCode.app causes it to reappear (still unsealed) the next time something triggers a LaunchServices open against it — matches @mprokopov's note above.

johannrymill · 1 month ago

Another reproduction: macOS 26.5.2 (25F84), CLI 2.1.204, native installer (~/.local/bin/claude -> ClaudeCode.app/Contents/MacOS/claude).

Two data points not yet in this thread:

1. Reproduces on a plain terminal exec, not just LaunchServices/agents-view. We point ~/.local/bin/claude directly at the bundle's inner binary to get a stable TCC (Full Disk Access) identity across auto-updates — working around the FDA re-prompt issue in #66216. First terminal exec of that path after an auto-update + reboot raised the standard macOS "ClaudeCode.app is damaged and can't be opened. You should move it to the Trash." dialog — so this isn't limited to double-click / dictation-helper / FleetView launches; a bare shell invocation through the bundle path triggers the same Gatekeeper re-validation.

2. Timeline evidence that the exec hardlink inside the bundle lags the version drop. versions/2.1.204 landed with mtime 2026-07-15 18:23. Contents/MacOS/ directory mtime didn't move to reflect a hardlink update until 2026-07-16 14:59 — a ~20.5h window where the bundle's exec was presumably still linked to 2.1.202 (that file's nlink is now 1, i.e., detached). Contents/MacOS/claude now shares inode 246047670 with versions/2.1.204 (nlink=2). So the re-hardlinking runs asynchronously from the version download via the CLI's own update machinery, and the unsealed bundle gets re-materialized either way.

Also: Open Anyway in Privacy & Security unblocks a given build, but the exception is keyed to the binary (cdhash) — it recurs on every auto-update since the hash changes. And confirmed no com.apple.quarantine anywhere on the bundle (only com.apple.macl / com.apple.provenance) — this is a bundle-seal integrity failure, not a quarantine/notarization prompt.

Core evidence:

$ codesign --verify --deep --strict ~/.local/share/claude/ClaudeCode.app
~/.local/share/claude/ClaudeCode.app: code has no resources but signature indicates they must be present

$ codesign -dv --verbose=2 ~/.local/share/claude/ClaudeCode.app
Executable=~/.local/share/claude/ClaudeCode.app/Contents/MacOS/claude
Identifier=com.anthropic.claude-code
Format=app bundle with Mach-O thin (arm64)
CodeDirectory v=20500 size=462245 flags=0x10000(runtime) hashes=14434+7 location=embedded
Authority=Developer ID Application: Anthropic PBC (Q6L2SF6YDW)
Info.plist=not bound
TeamIdentifier=Q6L2SF6YDW

$ find ~/.local/share/claude/ClaudeCode.app -maxdepth 4
~/.local/share/claude/ClaudeCode.app
~/.local/share/claude/ClaudeCode.app/Contents
~/.local/share/claude/ClaudeCode.app/Contents/MacOS
~/.local/share/claude/ClaudeCode.app/Contents/Info.plist
~/.local/share/claude/ClaudeCode.app/Contents/MacOS/claude

No _CodeSignature/CodeResources anywhere in the bundle — confirms the assembled .app never gets sealed after the inner binary is signed.

Ask: ship the bundle already sealed post-assembly (or re-sign the assembled bundle, not just the inner executable, as a build step), and add codesign --verify --deep --strict to release smoke tests so this can't regress silently. Fixing it would also unblock the stable-TCC-identity workaround in #66216, which currently can't be used without hitting this bug on every update.

tylerbustard · 1 month ago

Reproduced on CLI 2.1.212, macOS 27.0 beta (26A5378n), Apple Silicon (arm64), native install. Same verdict: codesign --verify --strictcode has no resources but signature indicates they must be present; Contents/_CodeSignature/ absent; no com.apple.quarantine (only com.apple.macl). Trigger was the agents view here too — the dialog fired right as "Agents work started" appeared.

One data point I don't see in the thread yet: Contents/MacOS/claude is a hardlink to the versioned binary, not a copy or symlink:

$ ls -i ClaudeCode.app/Contents/MacOS/claude ~/.local/share/claude/versions/2.1.212
85673602 .../ClaudeCode.app/Contents/MacOS/claude
85673602 .../versions/2.1.212

Two consequences:

  1. An in-place codesign --force on the bundle rewrites the shared inode — i.e. it strips the Developer ID signature off the very binary the claude launcher runs. Anyone reaching for the obvious re-sign workaround should break the link first.
  2. The updater re-links on every auto-update, so any local re-sign is reverted at the next update.

Workaround that works here (bundle then passes codesign --verify --strict, gktool scan reports "allowed by system policy", LaunchServices launch succeeds, and the versioned CLI binary keeps its Anthropic signature):

app="$HOME/.local/share/claude/ClaudeCode.app"
# break the hardlink so the real CLI binary keeps its Developer ID signature
cp "$app/Contents/MacOS/claude" "$app/Contents/MacOS/claude.tmp" && mv "$app/Contents/MacOS/claude.tmp" "$app/Contents/MacOS/claude"
# ad-hoc sign the wrapper, keeping entitlements (mic, apple-events, JIT) and identifier
codesign --force -s - --preserve-metadata=entitlements,identifier,flags "$app"

Holds until the next auto-update re-links the new version binary into the bundle.

ernotuovinen · 1 month ago
I also had the agents view be a trigger for me.

I had this same issue today with Agents View.

nwilliams-lucas · 1 month ago

Reproduced on CLI 2.1.220, macOS 26.5.2 (25F84), Apple Silicon (arm64), native install — same verdict as the thread (code has no resources but signature indicates they must be present, Contents/_CodeSignature/ absent, Info.plist=not bound, no com.apple.quarantine, only com.apple.macl).

Rather than repeat the signature evidence, here is the causal chain out of the shipped 2.1.220 binary — it identifies what creates the bundle, which code path launches it, and why the failure is fatal rather than a soft fallback. I don't see these three functions in the thread yet.

(Recovered via strings on the 2.1.220 executable. Minified module-local identifiers are renamed for readability — vDefs, agtpath, SLt()dataDir(), Lt()platform(); function names o8y/ahp/s8y and the string literals are verbatim. GE() below is left minified since I can't confirm what it tests.)

1. It is the CLI at runtime, not the installer

Worth amending the issue title's premise. The bundle is fabricated by the running CLI on startup, which is why @mprokopov's observation (deleting it doesn't help) holds and why @chr0m1ng's clean reinstall reproduces it — the installer is not the author:

async function o8y(){
  let e = path.join(dataDir(), "claude");
  if (!process.execPath.startsWith(path.join(e,"versions") + path.sep)) return null;
  let t = path.join(e,"ClaudeCode.app","Contents","MacOS"), r = path.join(t,"claude");
  try {
    let n = (await fs.stat(process.execPath)).ino;
    await fs.mkdir(t,{recursive:true});
    await fs.writeFile(path.join(t,"..","Info.plist"), `<?xml version="1.0" ...`);
    try { if ((await fs.stat(r)).ino === n) return r; await fs.unlink(r) } catch {}
    return await fs.link(process.execPath, r), r      // hardlink, per @tylerbustard
  } catch { return null }
}

mkdir + writeFile(Info.plist) + link(...) — and no _CodeSignature/CodeResources is ever written. It also re-links whenever the inode differs, which is why it re-breaks after every auto-update.

2. Why the bundle exists at all: a TCC disclaim re-exec

async function ahp(){
  if (platform() !== "macos") return;
  if (process.env.CLAUDE_BG_TCC_DISCLAIMED) { delete process.env.CLAUDE_BG_TCC_DISCLAIMED; return }
  let e = await o8y() ?? process.execPath;
  let r = [...GE() ? [e] : [e, process.argv[1]], ...process.argv.slice(2)];
  let n = {...process.env}; n.CLAUDE_BG_TCC_DISCLAIMED = "1";
  try { process.execve(e, r, n, { macDisclaimResponsibility: true }) } catch {}
}

The bundle is a TCC identity wrapper: macDisclaimResponsibility: true makes the child own its permission prompts (Info.plist declares NSMicrophoneUsageDescription, NSAppleEventsUsageDescription, NSLocalNetworkUsageDescription) instead of inheriting the terminal's. CLAUDE_BG_TCC_DISCLAIMED is a one-shot recursion guard, consumed on read.

Note there is already an ?? process.execPath fallback for when o8y() returns null — it just never fires here, because o8y() succeeds at producing an unsealed bundle.

3. Trigger: runPtyHost, which is why the agents view is the repro

tt(mhp, { runPtyHost: () => s8y, holdForLateConnect: () => dhp, ... });

async function s8y(e){
  let t = e.indexOf("--"), r = e.includes("--bg-spare", t+1);
  if (!r) await ahp();
  ...
}

The disclaimed re-exec runs whenever a PTY host starts, unless --bg-spare appears in the args after --. That pins down why @00mkp, @jeroenvisser101 and @tylerbustard all hit it via the agents view while ordinary sessions don't: an in-terminal session runs backendType: "in-process" (per ~/.claude/teams/session-*/config.json) and never enters runPtyHost.

4. Why it's fatal, not a soft fallback — and the observable cost

The try { process.execve(...) } catch {} cannot rescue this. execve replaces the process image; Gatekeeper's kill lands on the replaced image, so there is no surviving JS frame for the catch to run in. The process is simply gone.

In FleetView that surfaces as a completed-but-dead teammate:

Completed
  • parallel agent orchestration        SIGKILL (137) before init

So the practical impact is stronger than a cosmetic dialog — dismissing the dialog does not save the spawn; that agent produced nothing. Users reasonably read "cancel and carry on" as harmless because a subsequent spawn or the in-process path does work.

Possibly the same root cause as #74750 (SIGKILL (137) before init, recorded as state.detail in ~/.claude/jobs/<id>/state.json). Flagging it as worth checking rather than asserting it: that report involves a bg spare, and per s8y above spares pass --bg-spare and should therefore skip ahp() — so it only unifies if the reattach path spawns its PTY host without that flag.

Suggested fix

Two narrow options, in preference order:

  1. Seal the bundle. Ship a prebuilt, properly signed ClaudeCode.app (as Claude Desktop's inner claude.app already is) instead of assembling one from a hardlink at runtime. A flat-signed Mach-O cannot be made bundle-valid by adding an Info.plist around it.
  2. Pre-flight o8y()'s result. ahp() already has the correct fallback path — have o8y() return null when Contents/_CodeSignature/CodeResources is absent (or when the bundle fails assessment). That degrades to the flat process.execPath, losing the dedicated TCC identity but never surfacing a "damaged" dialog or losing an agent spawn. Cheap, and it fails safe.
nwilliams-lucas · 1 month ago

Follow-up with ~/.claude/daemon.log evidence, and a correction to §4 of my comment above.

The lost-spawn claim holds: confirmed crashed, never retried

Two independent events on this machine, both state=crashed, neither followed by a successful retry of that job id:

[2026-07-24T17:30:20.398Z] [supervisor] binary at ~/.local/bin/claude changed
                           (versions/2.1.218 → versions/2.1.219) — self-restarting for upgrade
[2026-07-24T17:30:20.785Z] [bg] bg spare spawned host pid=29006
[2026-07-24T18:01:45.395Z] [bg] bg settled 034250bd (crashed): SIGKILL (137) before init

[2026-07-25T13:34:42.656Z] [supervisor] ─── daemon start ─── version=2.1.220 pid=99611
[2026-07-25T13:34:42.884Z] [bg] bg spawned 3797f9ee (spare)
[2026-07-25T13:35:06.732Z] [bg] bg settled 3797f9ee (crashed): SIGKILL (137) before init

The second correlates exactly with a FleetView pane showing parallel agent orchestration — SIGKILL (137) before init under Completed. So the user-visible "completed" teammate did no work, and the spare's stderr is 0 bytes (/tmp/cc-daemon-501/<id>/spare/*.pty.sock.err) — consistent with SIGKILL, and matching the same observation in #74750.

Both crashes landed on the first daemon generation after an auto-update (2.1.219 and 2.1.220 respectively). That is suggestive here, because o8y() must unlink + re-link the bundle's inner hardlink whenever process.execPath's inode changes — i.e. the bundle is rebuilt, still unsealed, immediately after every upgrade.

Correction: I over-claimed the attribution

In §4 above I presented that SIGKILL as the cost of this bug. I should not have — my own s8y snippet argues against it for these particular processes:

let r = e.includes("--bg-spare", t+1);
if (!r) await ahp();

The daemon log shows the hosts involved are spare-pool hosts (bg spare spawned host, bg spawned … (spare)). If those are spawned with --bg-spare, they skip ahp() entirely and never touch the bundle — in which case the unsealed bundle is not what killed them. I flagged exactly this tension in #74750 and should have carried it here instead of asserting the stronger reading.

What I can still state without reservation:

  • The bundle is unsealed and does fail assessment on 2.1.220 / macOS 26.5.2 — that's directly verified, and it's this issue.
  • runPtyHostahp()o8y() is the code path that launches through it, and the ?? process.execPath fallback never fires because o8y() succeeds at producing an invalid bundle.
  • The "damaged" dialog is separately reproducible via the agents view, as several others in this thread report.

What is not established: that the SIGKILL (137) before init crashes share this root cause. Whether the claim path re-execs a spare host without --bg-spare is the discriminating question, and only someone with the unminified source can answer it. If the answer is no, these are two co-occurring macOS bugs that happen to surface on the same screen, and #74750 is the right home for the second.

Apologies for the noise — better to correct it in-thread than leave a wrong causal claim where maintainers are triaging.

Showing cached comments. Read the full discussion on GitHub ↗