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
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:
| Step | Command shape | Camera entitlement lines | Flags |
|---|---|---|---|
| A | framework first, then the app with --entitlements | 1 | adhoc,runtime |
| B | --force --deep --sign - | 0 | adhoc |
| C | --force --deep --options runtime | 0 | adhoc,runtime |
| D | --force --deep --options runtime --entitlements | 1 | adhoc,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:
| Step | CodeDirectory line (abridged) |
|---|---|
| A | v=20500 size=398 flags=0x10002(adhoc,runtime) hashes=2+7 |
| B | v=20400 size=262 flags=0x2(adhoc) hashes=2+3 |
| C | v=20500 size=270 flags=0x10002(adhoc,runtime) hashes=2+3 |
| D | v=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.shstill containscodesign --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
codesigndoes 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
- 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 anInfo.plistwithCFBundleExecutable,CFBundleIdentifierandCFBundlePackageType). - Write an
ent.plistwith one key, such ascom.apple.security.device.cameraset to true. - Sign it:
codesign --force --options runtime --entitlements ent.plist --sign - T.app. - Inspect it:
codesign -d --entitlements - T.appandcodesign -dv T.app 2>&1 | grep flags. The key should be listed and the flags should includeruntime. - Break it:
codesign --force --deep --options runtime --sign - T.app. Inspect again. The key is gone and the flags still sayruntime. - Add the check from the guard section to your own release script, and run it on the artifact you upload.
Cheat sheet
| Question | Command |
|---|---|
| What entitlements does this app carry | codesign -d --entitlements - App.app |
| Is the hardened runtime on | codesign -dv App.app, then look for runtime in the flags line |
| Is the signature intact, including nested code | codesign --verify --deep --strict App.app |
| Will Gatekeeper accept it | spctl --assess --type execute -v App.app |
| Is the ticket stapled | xcrun stapler validate Thing.dmg |
- Sign nested code first, the app last
- Pass
--entitlementson the final signature of the app - Leave
--deepfor 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.