codesign --deep stripped the entitlements, and why the camera would fail only after notarization

A reproducible test of what --deep does to a hardened runtime app, and the release script guard that came out of it

By Arin Prajapati 9 min read
White headline on black reading codesign --deep stripped the entitlements, with the kicker macOS signing and the NotchStar logo

TL;DR

  • codesign --force --deep --sign - on an app bundle replaces the main executable’s signature with one that carries no entitlements. On a tiny test bundle the camera entitlement count went from 1 to 0 (measured on 4 November 2026, macOS 27.0.1, commands below).
  • That is harmless while an app is ad hoc signed and not under the hardened runtime. It is a problem once the hardened runtime is on, which notarization needs, because a camera, Calendar or Apple Events feature then depends on its entitlement.
  • NotchStar’s release script signs the framework first, then the app once with --entitlements, and then fails the release if the camera entitlement is missing from the signed result. No release was built from the faulty Developer ID command. The ad hoc releases did use --deep, and by the measurement below that was harmless.

The problem: the signature looks fine and the entitlements are gone

NotchStar is a native macOS app that uses the camera for its Mirror tool, Calendar and Reminders for the agenda, and Apple Events to control Music and Spotify. Those three needs are declared in Sources/NotchStar/NotchStar.entitlements:

<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.personal-information.calendars</key>
<true/>

An entitlements file is read at signing time, not at run time. The key lives inside the code signature of the executable. If the signing command does not pass the file, or an outer command signs the executable a second time without it, the file in the repository stays correct and the signed app has none of it. The Xcode project declares the entitlements, so a release script that signs again by hand is the step that can throw them away.

The first release script, committed on 7 October 2026 in 06a9868, did exactly that:

codesign --force --deep --options runtime --timestamp --sign "$DEVELOPER_ID" "$APP/Contents/Frameworks/MediaRemoteAdapter.framework"
codesign --force --deep --options runtime --timestamp --sign "$DEVELOPER_ID" "$APP"

and, for the ad hoc fallback:

codesign --force --deep --sign - "$APP"

None of the commands passes --entitlements. The Developer ID branch added --options runtime, the flag that turns on the hardened runtime. Together, no entitlements and a hardened runtime is the combination that silently disables features.

Why it only shows after notarization

Notarization requires the hardened runtime. Apple documents the hardened runtime as the setting that makes the system enforce protections, and several of them are lifted app by app with entitlements. Camera access, access to Calendar data and the right to send Apple Events to another app are listed there (see Apple’s hardened runtime documentation).

So the order of events for a Mac app that ships outside the Mac App Store is:

 debug build ............ runtime off, entitlements irrelevant, camera works
 ad hoc release ......... runtime off, --deep drops entitlements, camera still works
 Developer ID release ... runtime ON (needed to notarize), --deep drops entitlements
                          -> camera, calendar and Apple Events lose their permission

Everything works right up to the step that would have shipped it. The release before notarization looks identical to the one after, because nothing in the build checks the entitlement.

Per the project’s facts, releases 1.0.0 to 1.0.2 were ad hoc signed and notarization started with 1.0.3 on 9 October 2026. The signing commit 846510b (7 October 2026) replaced the --deep lines in the Developer ID branch two days before that release. No release was built from the faulty branch. The failure itself, a camera prompt that never appears or a Mirror tab that stays empty on a notarized build, was therefore not seen in the wild and was not reproduced on a real camera. What is measured is the signature, as the next section shows.

The experiment

A fake app is enough to see what --deep does. The test builds a one file C program as T.app, adds a real framework (a tiny dylib with an Info.plist) and signs it four ways. The entitlements file is NotchStar’s own. A check function counts the lines that mention the camera key and prints the flags:

chk(){ codesign -d --entitlements - T.app 2>&1 | grep -c "device.camera"
       codesign -dv T.app 2>&1 | grep flags; }

# A: framework first, then the app with entitlements (the release script's path)
codesign --force --options runtime --sign - "$F"
codesign --force --options runtime --entitlements ent.plist --sign - T.app ; chk
# B: the ad hoc line from the old script
codesign --force --deep --sign - T.app ; chk
# C: --deep with the hardened runtime and no entitlements
codesign --force --deep --options runtime --sign - T.app ; chk
# D: --deep with the runtime and the entitlements
codesign --force --deep --options runtime --entitlements ent.plist --sign - T.app ; chk

Results on 4 November 2026, macOS 27.0.1:

StepCommand shapeCamera entitlement linesFlags
Aframework first, then the app with --entitlements1adhoc,runtime
B--force --deep --sign -0adhoc
C--force --deep --options runtime0adhoc,runtime
D--force --deep --options runtime --entitlements1adhoc,runtime

Row B is the old ad hoc line. The signature is valid, it passes codesign --verify --deep --strict, and the entitlements are gone. Row C is the old Developer ID line in miniature: the hardened runtime is on and the entitlements are gone, which is the failing combination. Row D shows that the fix to the flag set is not the whole story, and a separate observation sits beside it. After D, the framework carried no camera entitlement (count 0), while the app did. Entitlements went to the executable that was named, not to every nested bundle.

So --deep is not the only way to lose them. The rule is that the last signature applied to an executable decides its entitlements. Signing again without --entitlements replaces them with nothing, deep or not.

What the signature itself shows

The test also printed the code directory line for each step, and it differs in a way that is easy to spot:

StepCodeDirectory line (abridged)
Av=20500 size=398 flags=0x10002(adhoc,runtime) hashes=2+7
Bv=20400 size=262 flags=0x2(adhoc) hashes=2+3
Cv=20500 size=270 flags=0x10002(adhoc,runtime) hashes=2+3
Dv=20500 size=398 flags=0x10002(adhoc,runtime) hashes=2+7

The runs that carry the entitlements (A and D) have hashes=2+7 and a larger directory, 398 bytes against 262 or 270. The runs without them have hashes=2+3. The extra entries are consistent with the entitlements being sealed into the signature as additional hashed slots, though that reading is an inference from the numbers and not something the test proved. The practical lesson is the one codesign -d --entitlements - already gives: the only reliable way to know what an app carries is to ask the signed file.

The Xcode project declares the entitlements too. In project.yml the three keys sit under the target’s entitlements block, which generates the .entitlements file, and ENABLE_HARDENED_RUNTIME is YES. A build signed by Xcode alone would therefore be expected to have both. The release script then signs it again by hand, which is the step that decides what ends up in the shipped file. Whether Xcode’s own output carried the entitlements was not inspected for this post.

What man codesign says

The manual page for codesign on this Mac marks the flag as deprecated for signing:

--deep  (DEPRECATED for signing as of macOS 13.0) When signing a bundle,
        specifies that nested code content such as helpers, frameworks,
        and plug-ins, should be recursively signed in turn.
        Beware:
        All signing options will be applied, in turn, to all nested
        content. This is almost never what you want.

The same flag is not deprecated for verifying. In the release script, verification still uses it, because verifying with --deep checks nested code and not only the outer seal:

codesign --verify --deep --strict "$APP" && echo "    signature OK"

The fix: sign inside out, once

The replacement in scripts/release.sh is the whole fix. The comment above it explains the reasoning, and the two commands are short:

# Inside out: the framework first, then the app. No --deep, so each piece gets exactly what it needs. The entitlements
# (camera, calendars, Music control) must be passed again here or the hardened runtime blocks those features.
codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID" "${KC_SIGN[@]}" "$APP/Contents/Frameworks/MediaRemoteAdapter.framework"
codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" --sign "$DEVELOPER_ID" "${KC_SIGN[@]}" "$APP"

The framework is signed first, because the outer signature seals the signatures of everything inside it. It gets no entitlements, because a framework that is only loaded by another process does not need any. In this app the framework is bundled but deliberately not linked: /usr/bin/perl loads it, not NotchStar. The app is signed last, with the file.

Would --deep with --entitlements have done it

Row D says yes, for the test bundle: one command, the runtime on, the entitlements present on the app and absent from the framework. It is a fair question why the script does not simply use that.

There are three reasons in the evidence. The manual marks --deep as deprecated for signing, so a command that depends on it has a shelf life. Its documented behavior is to apply every option to every nested item, so a later change, for example a second helper that needs different entitlements, would be handled by the wrong tool. And the two explicit commands make the order visible in the script: a reader sees the framework signed before the app and sees where the file is passed. None of this is a measured failure of row D. It is a preference for the form that cannot silently change meaning.

The guard

Fixing the command is not enough, because the same mistake can come back in a refactor. The script ends the signing step with a check that reads the signed bundle and fails if the camera key is missing:

codesign -d --entitlements - "$APP" 2>/dev/null | grep -q "device.camera" \
  || { echo "!! entitlements are missing from the signed app"; exit 1; }

On the test bundle, this guard behaves as intended. After the inside out signing (A) it passes. After --deep --options runtime with no entitlements (C) it prints the failure line and exits non zero. The check is cheap and runs on the artifact, not the source file, which is the point. It also runs only in the Developer ID branch, since the ad hoc branch has no runtime to protect.

The guard has a limit. It tests one key out of three. A signed app that kept the camera key and lost the other two would pass. A stricter version loops over every key in the entitlements file. This sketch is not in the repository and was not run against the real app:

for key in device.camera personal-information.calendars automation.apple-events; do
  codesign -d --entitlements - "$APP" 2>/dev/null | grep -q "$key" || { echo "!! missing $key"; exit 1; }
done

Checking the result after notarization

The script ends by asking Gatekeeper what it would say, on the DMG and on the app:

xcrun stapler validate "$DMG"
spctl --assess --type open --context context:primary-signature -v "$DMG"
spctl --assess --type execute -v "$APP"

Per the project’s facts, the live 1.0.4 DMG was checked with spctl on 11 October 2026: “Notarized Developer ID”, with the ticket stapled to the DMG and the app. That confirms the signing and notarization, and it does not confirm the entitlements, which is why the entitlement guard exists as a separate step. The two checks answer different questions.

What is still bad

  • The ad hoc fallback in scripts/release.sh still contains codesign --force --deep --sign - "$APP". It is harmless there by the measurement above (no runtime, so nothing enforces the entitlements), but it is the same command that caused the trouble, and a reader can copy it.
  • The guard checks one key of three.
  • The claim that features fail under the hardened runtime without their entitlements rests on Apple’s documentation. It was not reproduced on a notarized NotchStar build, so there is no screenshot of the failure.
  • The test bundle is a C program. It shows what codesign does to entitlements, and not how Xcode’s own signing step interacts with a script that follows it.
  • The Reminders permission has a usage string in the app’s Info.plist but no separate key in the entitlements file. Whether that key is required for Reminders on a hardened app was not verified here.

Try it yourself

  1. Compile a stub: echo 'int main(){return 0;}' > main.c && cc main.c -o T.app/Contents/MacOS/T (create the folders first, and add an Info.plist with CFBundleExecutable, CFBundleIdentifier and CFBundlePackageType).
  2. Write an ent.plist with one key, such as com.apple.security.device.camera set to true.
  3. Sign it: codesign --force --options runtime --entitlements ent.plist --sign - T.app.
  4. Inspect it: codesign -d --entitlements - T.app and codesign -dv T.app 2>&1 | grep flags. The key should be listed and the flags should include runtime.
  5. Break it: codesign --force --deep --options runtime --sign - T.app. Inspect again. The key is gone and the flags still say runtime.
  6. Add the check from the guard section to your own release script, and run it on the artifact you upload.

Cheat sheet

QuestionCommand
What entitlements does this app carrycodesign -d --entitlements - App.app
Is the hardened runtime oncodesign -dv App.app, then look for runtime in the flags line
Is the signature intact, including nested codecodesign --verify --deep --strict App.app
Will Gatekeeper accept itspctl --assess --type execute -v App.app
Is the ticket stapledxcrun stapler validate Thing.dmg
  • Sign nested code first, the app last
  • Pass --entitlements on the final signature of the app
  • Leave --deep for verifying, not for signing
  • Check the signed artifact for every entitlement the app needs, not only one
  • Run the check before the notarization upload, so a mistake costs seconds
  • Check what Gatekeeper says on the file users download

Close

NotchStar is the app this script ships. If you want to try it, it is at notchstar.com.

NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.