feat: revalidate device permissions against OS state before use - #471
Open
TarikGul wants to merge 19 commits into
Open
feat: revalidate device permissions against OS state before use#471TarikGul wants to merge 19 commits into
TarikGul wants to merge 19 commits into
Conversation
Regenerates the host-callback surface: permissionStatus is an optional capability group, so a host that omits it is unchanged.
Amends RFC-0002's permission lifecycle, the protocol doc comment on HostDevicePermissionRequest, and the platform/host READMEs.
…ment The doc comment on HostDevicePermissionRequest reaches the truapi namespace bindings, so the committed Swift carries it too.
Imod7
reviewed
Aug 24, 2026
Imod7
left a comment
Contributor
There was a problem hiding this comment.
In js/packages/truapi-host/src/web/create-worker-host-runtime.ts, line 833:
capabilities: { chat: host.chat !== undefined },
This never reports permissionStatus, so the worker does not proxy devicePermissionStatus and no revalidation happens in the browser. I tested it: the init message carries {"chat":false} even when the host serves the capability. Please add permissionStatus: host.permissionStatus !== undefined. The two toEqual assertions on capabilities in worker-provider.test.ts then need the new key, and makeHostCallbacks in test-support.ts needs a permissionStatus branch like the chat one.
pgherveou
approved these changes
Aug 24, 2026
pgherveou
left a comment
Collaborator
There was a problem hiding this comment.
approving to unblock, will give a more torough review later this evening
An undetermined OS status no longer triggers a prompt. The prompt callback answers the product's question as well as the OS one, so using it to reach the OS dialog re-asked an answered question on every request and persisted the OS answer over the product's decision. An OS dialog declined that way became a permanent product denial that restoring the capability in system settings could not recover, and a transient prompt failure on the same path reported a held grant as denied. The OS resolves its own gate when the capability is used, so the core defers to the stored decision unless the OS refuses outright. Also reports permissionStatus in the Web Worker init message, without which the worker proxies no status callback and nothing revalidates in a browser.
Collaborator
A terminal has no OS permission gate, so the CLI reports NotApplicable for every capability and the stored product decision governs.
A host settings screen read through permission_authorization_status answered from stored state alone, so it reported Authorized for a capability a request reported as granted: false, sending the user after a product toggle that was never what blocked them. peek_device now resolves the same two gates. Every product-scoped permission service is built through one helper on ProductRuntimeHost so the request and read paths cannot drift again. Remote, identity-disclosure and account-access decisions have no OS gate and are unaffected.
Nine read-path doc comments promised a stored-only answer, plus RFC-0002 step 4 and three READMEs that described the gate on requests only. The Swift bindings carry the regenerated wrapper docs. Also folds the duplicated status-host installer doc into a link to the trait that carries the reasoning, builds the wasm capability adapters through one helper instead of repeating the wiring in both constructors, names the CLI binding after what it holds, and asserts the whole response payload in the integration test rather than splicing bytes by index.
The capability builder sat between the doc comment and the struct, which fails missing_docs on the wasm target.
HostCallbacks gains device_permission_status next to device_permission, and CallbackPlatform serves the PermissionStatusHost capability. The adapter travels on ConnectionAdapters rather than the host runtime, because a native host builds one CallbackPlatform per product execution: the object that reports OS state has to be the one presenting the prompt, and a set-once host-level slot cannot express that. The Swift protocol extension defaults the new member, so an embedding app compiles unchanged and opts in by implementing one method.
NativeCustomRendererObserver gained on_error and the Kotlin conformer never implemented it, so make android-check failed before this branch. Closing the flow with an error mirrors the Swift host, keeping a declined render distinct from a clean end. Unrelated to the permission work; fixed here because it blocked verifying the Kotlin half of it, and no CI job covers Android.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Device permissions carry two gates, not one. The product decision is persisted once and never expires. The OS grant behind it belongs to the host application and moves on its own: the user can revoke it in system settings, device policy can suspend it, and the platform can reset it, as Android does for apps that go unused.
A device capability is usable only while both gates are open. That holds for a product calling
request_device_permissionand for a host settings screen reading throughCoreAdmin, so the two cannot disagree. A screen reportingAuthorizedfor something a request reports asgranted: falsesends the user after a product toggle that was never what blocked them.The seam
PermissionStatusHostis an optional platform capability with one method,device_permission_status, which reports OS state and must not prompt. It stays separate fromPermissions::device_permissionbecause that call also puts the product's question to the user, so it cannot re-check a decision the user has already made.It sits on
OptionalPlatform, so codegen emits it as an optional group:permissionStatus?on the JS host callback surface. A host that omits it resolves device grants from stored state alone, which is what a host with no OS permission model does anyway.The rule
Only an OS refusal overrides the stored decision.
Remote, identity disclosure and account access decisions have no OS gate and are untouched. Every product scoped permission service is built through one helper on
ProductRuntimeHost, so the request and read paths cannot drift apart.Hosts
Native.
HostCallbackscarriesdevice_permission_status, andCallbackPlatformserves the capability. The adapter travels onConnectionAdaptersrather than the host runtime, because a native host builds oneCallbackPlatformper product execution, and the object reporting OS state has to be the one presenting the prompt.The Swift protocol extension and the Kotlin interface both default the new member, so an embedding app compiles unchanged and opts in by implementing one method. Answer it from
AVCaptureDevice.authorizationStatus(for:),UNUserNotificationCenter.getNotificationSettings, orContextCompat.checkSelfPermission.The callback returns
NativeDevicePermissionStatus, a same namespace mirror ofDevicePermissionStatuswith a total conversion. UniFFI lowers a cross namespace type in an async callback return into the wrongRustBuffer, and the generated Kotlin then fails to compile.WASM. The adapter installs when the JS side supplies
devicePermissionStatus, including through the Web Worker, whoseinitmessage reports the capability so the worker proxies the callback.CLI. Reports
NotApplicablefor every capability, since a terminal has no OS permission gate.Known limitation
granted: falsecovers both a product refusal and an OS refusal, so neither a product nor a settings screen can tell "you declined this" from "the OS revoked this, open system settings". The remedies differ. Separating them needs a richer device permission response than the current boolean, so it waits for a breaking change window.Android auto reset surfaces as an undetermined status, and re-requesting is the host's call, because a permission protected Android call throws rather than prompting.
Verification
Eighteen tests. Fourteen cover the decision matrix in
host_logic::permissions. Three drive a realpermissions_request_device_permissionframe and aCoreAdminstatus read through the generated dispatcher. One drives the native chain end to end: foreign callback, platform adapter, connection adapters, permission service.Every test was confirmed to fail under a deliberate mutation, including dropping the OS refusal override, re-prompting on an undetermined status, mapping a query failure to a refusal, clearing the stored grant on refusal, dropping the read path gate, an installer that stores nothing, and an execution that stops passing its adapter. The integration and native tests are the only cover for the wiring, since the unit tests construct the service directly and survive every plumbing mutation.
Clean under
RUSTFLAGS="-D warnings":cargo build --workspace --all-targets --all-features,cargo check --target wasm32-unknown-unknown -p truapi-server,cargo +nightly fmt --check,cargo +nightly clippy --workspace --all-targets --all-features -- -D warnings, andcargo test --workspace --all-features.sync-bindings.sh --checkandmake provider-swift-checkboth report the committed bindings current against a freshly generated tree.Both native gates were compiled rather than reviewed:
xcodebuild build-for-testing -scheme TrUAPIHostagainst the simulator, which is what compiles the hand written conformers, andmake android-check.make android-checkalso neededNativeCustomRendererObserver.onErrorimplemented in the Kotlin conformer. That is unrelated to permissions and carried in its own commit. No CI job covers Android, so the gate had drifted.Part of #334.