You ran Claude Code or Cursor against a feature for an hour, shipped a build to TestFlight, and noticed the App Privacy Report flagging camera access for a screen that has nothing to do with the camera. The agent added the permission silently, somewhere between an SDK import and a code path that never reached production. This page is for the developer who needs a fast, complete audit of every sensor or API a vibe-coded build actually reaches.
Short answer
AI agents add permission strings to Info.plist and AndroidManifest.xml by pattern-matching on imports rather than by checking whether the feature wires them up. According to Apple's documentation on the Required Reason API, a build that reaches a covered API without declaring it in PrivacyInfo.xcprivacy is rejected with ITMS-91053; a build that declares a permission it never reaches passes the automated check but can draw a Guideline 5.1.1 question. The audit is two passes: list every declared permission from the compiled IPA or APK, then prove each one matches an actual API call in the binary. Anything left over is an orphan.
What you should know
- AI agents infer permissions from imports, not from the function calls the user feature actually wires up.
- A declared but unused permission is not an automatic rejection on iOS, but it raises the cost of every App Review pass.
- Android permissions can ride in from any merged manifest, including one a transitive dependency added two SDK versions ago.
- The audit needs the compiled binary, because the agent may have removed the source-side proof of why the key was added.
- PrivacyInfo.xcprivacy covers the required reason categories: file timestamp, system boot time, disk space, active keyboards, and user defaults.
Why do AI agents add permissions you never asked for?
The short answer is that they read the file you opened, see an import that touches a sensor, and add the matching string before you ask. Three reasons this happens consistently.
First, the agent is rewarded for making the feature run. When Cursor scaffolds a screen that imports expo-camera, the resulting Info.plist almost always includes NSCameraUsageDescription, because building without it crashes the camera path at runtime. The agent has no way to check whether the code path you eventually merge actually opens the camera, so it adds the key defensively.
Second, the training data over-represents the import-implies-permission pattern. Public Expo, React Native, and FlutterFlow tutorials add the usage description in the same commit as the import, because the tutorial is teaching the happy path. The agent sees that pattern hundreds of thousands of times and reproduces it without checking the call graph.
Third, the agent often edits the manifest without leaving a trail. Cursor's diff view shows the Info.plist edit, but the rationale (which line of TypeScript triggered it) lives in chat history, not in the commit. Two prompts later, when you remove the screen, the entry stays.
Where do hidden permissions hide in an iOS build?
Four places, in order of how often the audit catches an orphan.
Info.plist carries the user-facing usage descriptions: NSCameraUsageDescription, NSMicrophoneUsageDescription, NSLocationWhenInUseUsageDescription, NSContactsUsageDescription, NSPhotoLibraryUsageDescription, NSBluetoothAlwaysUsageDescription, NSCalendarsUsageDescription. Each key triggers a runtime permission prompt the first time the matching API is reached. A key without a matching API call is an orphan.
PrivacyInfo.xcprivacy carries the required reason API declarations. Per Apple's Required Reason API documentation, five categories are covered: NSPrivacyAccessedAPICategoryFileTimestamp, NSPrivacyAccessedAPICategorySystemBootTime, NSPrivacyAccessedAPICategoryDiskSpace, NSPrivacyAccessedAPICategoryActiveKeyboards, and NSPrivacyAccessedAPICategoryUserDefaults. An entry without a matching call passes review; a call without an entry triggers ITMS-91053.
Entitlements.plist carries capability requests: aps-environment for push, com.apple.developer.associated-domains for universal links, com.apple.developer.in-app-payments for Apple Pay. An unused entitlement does not crash the build, but it widens the binary signature and can affect provisioning profile fit when the team identifier shifts.
Third-party SDK bundles ship their own privacy manifests since Apple's May 2024 deadline. The merged report you see in App Store Connect is the sum of your manifest plus each SDK manifest. An entry showing up under a category you never wrote often traces back to one of those SDK manifests rather than your own code.
Where do hidden permissions hide in an Android build?
The Android equivalent is the merged manifest. Each library can contribute a uses-permission element, and the build system unions them into the final AndroidManifest.xml packaged in the APK or AAB. The merged file lives at app/build/outputs/apk/release/AndroidManifest.xml after a release build, and the merge report at app/build/outputs/logs/manifest-merger-release-report.txt lists which module contributed each line. Per the Android manifest documentation, permissions declared in the merged file are visible on the Play Store listing and prompt the user at runtime.
Common orphans that agents leave behind: READ_EXTERNAL_STORAGE on a build that only uses the Storage Access Framework, READ_PHONE_STATE from a deprecated analytics SDK, ACCESS_FINE_LOCATION when the feature only needs ACCESS_COARSE_LOCATION, RECORD_AUDIO from a video-capture SDK whose audio path is disabled.
The android:maxSdkVersion attribute is the most useful tool the documentation gives you. A permission declared with android:maxSdkVersion="28" is dropped on API 29 and higher, which is the cleanest way to retire a permission a legacy SDK still asks for without breaking older devices that still need it.
How do you audit a compiled IPA or APK for orphan permissions?
Five commands, in order of confidence. Run them against the build you ship to the store, not the dev build.
| Step | iOS (IPA) | Android (APK or AAB) | What it shows |
|---|---|---|---|
| 1. List declarations | plutil -p Payload/App.app/Info.plist | aapt2 dump permissions app.apk | Every permission the binary asks for. |
| 2. List privacy manifest | plutil -p Payload/App.app/PrivacyInfo.xcprivacy | n/a | Required reason API declarations on iOS. |
| 3. Inspect compiled symbols | nm Payload/App.app/Binary and otool -L | apktool d app.apk then grep smali | Imported frameworks and API references. |
| 4. Match to source | grep for AVCaptureSession, CLLocationManager, CNContactStore in Sources/ | grep for Landroid/hardware/Camera or getLastKnownLocation in smali/ | Whether the feature actually reaches the API. |
| 5. Diff declarations against calls | Manual or MobSF | Manual or MobSF | Orphans, i.e., declared but never called. |
The diff in step 5 is the artifact you keep. For iOS, you can run the same pass through the Xcode build log: the linker prints every framework actually linked, and an NSCameraUsageDescription with no AVFoundation entry in the link map is a strong signal that the key is dead weight.
For builds compiled from FlutterFlow, Bubble, or Replit Agent, the source is locked away from the developer, which is why the IPA-side audit matters more than the source-side audit. The compiled artifact is the only honest record of what shipped. PTKD.com (https://ptkd.com) parses the IPA or APK, extracts the manifest and privacy declarations, and compares them against the API calls the binary actually reaches, surfacing the orphans for builders who do not control the codegen step.
What to watch out for
Four patterns show up in audits of AI-scaffolded builds.
First, an Info.plist NSLocationAlwaysUsageDescription without a matching NSLocationWhenInUseUsageDescription is treated by App Review as a malformed declaration, because Apple requires the latter as a fallback even when the feature wants always-on location. Cursor occasionally adds one without the other when the import was for a background-location SDK the developer later swapped out for a foreground-only variant.
Second, the Android QUERY_ALL_PACKAGES permission is a magnet for Google Play policy warnings. Per the Google Play policy on package visibility, the use must be a core part of the app's purpose. Agents sometimes add it to silence a transitive dependency warning, which puts the publisher on the policy team review queue for an explanation that the binary often cannot support.
Third, the privacy manifest covers required reason APIs, not regular permissions. A build that declares NSCameraUsageDescription does not satisfy ITMS-91053 if the binary also calls UserDefaults.standard.set, because the user defaults reach is a separate required reason API that has to be declared on its own line inside the same xcprivacy file.
Fourth, removing a permission from your manifest does not remove it from the merged Android manifest if a library still declares it. The uses-permission android:name tools:node="remove" element is the way to override an inherited declaration. Per the Android merged manifest documentation, the override only applies when the namespace xmlns:tools is declared in the manifest root.
Key takeaways
- A permission an AI agent added based on an import is not a permission the user feature actually uses. Audit the compiled binary, not the source.
- The iOS audit needs both Info.plist usage descriptions and the PrivacyInfo.xcprivacy required reason entries, because the App Review failure modes are different for each.
- The Android audit needs the merged manifest, not just your app module's, because transitive dependencies carry permissions across the merge.
- For builders compiling away from the developer (FlutterFlow, Bubble, Replit Agent), the IPA or APK is the only honest record of what the shipped app asks for.
- Some teams outsource this audit to platforms like PTKD.com (https://ptkd.com) when the agent-generated codebase no longer matches the binary it produces.




