General
Full Disk Access Was On, and macOS Still Refused the App
K M Shahriar Hossain DEV Community 周榜
3 views
A Mac app I maintain needs Full Disk Access. It measures disk usage, and most of
what is worth measuring sits behind that permission. I switched it on in System
Settings ▸ Privacy & Security ▸ Full Disk Access, and the app went on reporting
limited access. Its banner said what these banners say: already granted?
Relaunch. Relaunching changed nothing, and never could have.
The app did not validate
A permission is granted to an app, and to macOS "the app" is not a name. So the
first question is whether macOS can tell what the app is:
$ codesign --verify --deep --strict --verbose=2 /Applications/Helm.app
/Applications/Helm.app: nested code is modified or invalid
file modified: /Applications/Helm.app/Contents/Frameworks/App.framework
App.framework is where Flutter puts the compiled Dart. Verified on its own it
was fine, and so were the other four frameworks in the bundle. What failed was
the app around them.
An app's signature seals its contents. Contents/_CodeSignature/CodeResources
records, for every nested framework, the hash of the code that should be there.
It recorded one App.framework, and the bundle held another:
seal records Frameworks/App.framework 4ba5cc60…
actually there Frameworks/App.framework c7a45621…
Contents/_CodeSignature/CodeResources 15:08:47
Contents/MacOS/Helm 15:08:47
Contents/Frameworks/App.framework/…/App 15:30:38
The seal and the executable came from one build. The framework came from a build
twenty-two minutes later, which put a new App.framework in place and never
re-sealed the app around it.
Why a stale seal turns the switch into decoration
TCC, the part of macOS behind every Privacy & Security switch, does not store
"this app is allowed". It stores a code requirement and checks the running app
against it. For an app signed with a Developer ID, that requirement names the
developer's team. For an ad-hoc signature, which is what you have without one,
it is effectively the hash of the code.
A bundle whose nested code does not match its own seal does not validate, and
code that does not validate cannot satisfy a requirement. The grant attaches to
nothing. The switch reads ON, every protected path stays refused, and no relaunch
touches any of it.
Re-signing the bundle and granting access again fixed it: the app reported Full
Disk Access on, and kept it through a restart.
How the seal went stale
Two things write a Flutter macOS bundle. A script phase runs
macos_assemble.sh embed, which hands off to Flutter's xcode_backend.dart: it
copies the freshly compiled App.framework into Contents/Frameworks and signs
it. Then Xcode signs the app, which writes the seal. In a good build the
timestamps land a second apart, framework first:
build 1 clean verify ok seal 23:19:32 App.framework 23:19:31
build 2 one Dart change verify ok seal 23:20:06 App.framework 23:20:05
So it is not simply what an incremental build does, which is what I assumed at
first and wrote into a changelog. A clean build seals correctly, and so does a
Dart-only rebuild straight after it.
What reproduced it, once, was a crash. On a copy of the project, a Swift change
built fine. The next build, a one-line Dart change, died:
error: unexpected service error: The Xcode build system has crashed. Build again to continue.
Doing as told, with another Dart-only change:
✓ Built build/macos/Build/Products/Release/Helm.app (47.1MB)
$ codesign --verify --strict Helm.app
Helm.app: nested code is modified or invalid
seal records App.framework dda6dabc96dd actual afaf15310e15
Contents/_CodeSignature/CodeResources 23:22:42
Contents/MacOS/Helm 23:22:42
Contents/Frameworks/App.framework/…/App 23:23:28
The build after the crash ran Flutter's embed step, skipped the app's signing,
and reported success. Same fingerprint as the build that shipped: seal and
executable from one build, framework from a later one.
I can't prove the shipped build went through a crash. I had kept only the last
few lines of each build, and a crash in an earlier one is exactly what that
throws away. Nor do I know which other failures leave the same state. An
interrupted build is the obvious suspect, and I haven't tested it. That
uncertainty is the argument for the next two sections.
(Reproduced on Flutter 3.47.3, Xcode 26.6, macOS 26.6.2. The shipped build was on
Flutter 3.47.1, and none of the patch-release changes in between are about
signing.)
Why nothing caught it
Not because the check is subtle. My first theory was that only --deep compares
nested code with the outer seal, so a check without it would have waved this
bundle through. It was wrong. On a copy with App.framework swapped out:
codesign --verify App.framework exit 0 valid on its own
codesign --verify Helm.app exit 1 nested code is modified or invalid
SecStaticCodeCheckValidity, flags 0 -67021 nested code is modified or invalid
No flags needed. Any verification of the app fails it.
The release script did not verify at all. Its signing and its verification lived
in the same branch:
if [[ -n "$SIGN_ID" ]]; then # a Developer ID certificate was found
# ...sign every framework, then the app...
codesign --verify --strict "$BUILD_APP"
fi
The app is free and deliberately has no $99-a-year Developer ID, so that branch
never ran. The path that always ran signed nothing and checked nothing, and the
DMG shipped whatever flutter build had left behind.
The fix
Sign and verify on every path, and let a failed verification fail the release:
if [[ -n "$SIGN_ID" ]]; then
SIGN=(--force --timestamp --options runtime --sign "$SIGN_ID")
else
SIGN=(--force --sign -) # ad-hoc: never notarised, so no runtime or timestamp
fi
# Inside-out: nested code first, then the bundle that holds it.
for fw in "$APP"/Contents/Frameworks/*.framework; do
codesign "${SIGN[@]}" "$fw"
done
codesign "${SIGN[@]}" --entitlements macos/Runner/Release.entitlements "$APP"
codesign --verify --deep --strict "$APP" || { echo "signature broken" >&2; exit 1; }
Sign with a loop rather than codesign --deep, which Apple advises against for
signing. For verifying, --deep is fine, and with --verbose it names the file
that changed.
And the app now checks itself before telling anyone to relaunch. This goes
through the Security framework rather than running /usr/bin/codesign, which
does ship with macOS, because it is a yes-or-no question with a status code for
an answer:
import Security
func bundleValidates() -> Bool {
var code: SecStaticCode?
guard SecStaticCodeCreateWithPath(Bundle.main.bundleURL as CFURL, [], &code) == errSecSuccess,
let code else { return true } // couldn't ask: don't accuse
let flags = SecCSFlags(rawValue: kSecCSCheckNestedCode | kSecCSStrictValidate)
return SecStaticCodeCheckValidity(code, flags, nil) == errSecSuccess
}
When access reads as denied and that returns false, the banner stops saying
"relaunch" and says the true thing: this copy cannot hold the permission, so
reinstall it and grant access again. When the check cannot run at all, it
answers true. Telling someone a working app is broken is worse than saying
nothing.
The part that is not a bug
An ad-hoc requirement is the hash of the code, so to TCC every build is a
different app. Every update loses Full Disk Access and needs granting again. No
release script fixes that. It is the cost of not having a Developer ID, whose
requirement names the team instead of the bytes and so survives an update. For a
free app, the honest move is to say so at the top of the release notes rather
than let people find out.
Three rules
Verify the bundle you ship, on every path. Plain codesign --verify is
enough. Never gate the check behind having a certificate: the path without
one is the path that always runs.
Don't trust ✓ Built after a build that crashed. Build clean, or verify.
The build after a crash can succeed at everything except sealing the app.
If your app needs a privacy permission, check its own signature before
blaming the user. "Relaunch" is sometimes a loop with no exit.
The app is Helm, a free Mac utility
built with Flutter. Its release script and self-check are in the repository.
Originally published at devshakib.jumyn.com. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at pub.dev/publishers/jumyn.com.
Read original: https://dev.to/devshakib/full-disk-access-was-on-and-macos-still-refused-the-app-55i9
← Previous
A Screenshot Is Not an Agent Failure Artifact
Next →
[Showoff Saturday] Asili - locally calculated personal DNA trait scorer and gene explorer
Related
I added three new checks and ten unrelated tests went red. That was the system working.
General
2
DEV Community
A Screenshot Is Not an Agent Failure Artifact
General
3
DEV Community 周榜
AI-generated websites are the Word Art era all over again
General
2
DEV Community 周榜
BPE-Style Tokenizers: The Small Algorithm That Decides What an LLM Can See
General
3
DEV Community 周榜
Comments0
No comments yet — be the first