From 0554b49efb16a0f19529bb804ff44e16d2747ce1 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Fri, 3 Jul 2026 18:59:54 -0400 Subject: [PATCH] Bind applyUpdate to the reviewed pending-update manifest hash appUpdate.applyUpdate re-read whatever pending update was currently staged on the app and applied the user's reviewed permission grants to it. Between appUpdate.getReviewContext and applyUpdate the background update checker can replace the pending update with a newer manifest that requests different permissions, so the grants the user reviewed could be applied to a manifest they never saw. Require applyUpdate callers to pass the reviewed manifest hash and refuse to apply if the current pending update no longer matches it. The automatic/background apply paths pass None and are unaffected. The update review builtin app now forwards the pending update's manifest hash. --- .../src/components/UpdateReviewBody.tsx | 1 + .../methods/system/app_update/apply_update.rs | 4 ++++ crates/sage-apps/src/lifecycle/update/apply.rs | 16 +++++++++++++++- .../sage-apps/src/lifecycle/update/commands.rs | 2 +- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/builtin-apps/src/system/apps/app-update/src/components/UpdateReviewBody.tsx b/builtin-apps/src/system/apps/app-update/src/components/UpdateReviewBody.tsx index 668fb76cf..6c1f9cc09 100644 --- a/builtin-apps/src/system/apps/app-update/src/components/UpdateReviewBody.tsx +++ b/builtin-apps/src/system/apps/app-update/src/components/UpdateReviewBody.tsx @@ -57,6 +57,7 @@ export function UpdateReviewBody({ state, onReload }: any) { await client.appUpdate.applyUpdate({ appId: state.app.common.identity.id, additionalGrantedPermissions, + reviewedManifestHash: state.app.pendingUpdate?.manifestHash ?? '', }); await close(); diff --git a/crates/sage-apps/src/bridge/methods/system/app_update/apply_update.rs b/crates/sage-apps/src/bridge/methods/system/app_update/apply_update.rs index ecc3cd44a..079931cc0 100644 --- a/crates/sage-apps/src/bridge/methods/system/app_update/apply_update.rs +++ b/crates/sage-apps/src/bridge/methods/system/app_update/apply_update.rs @@ -14,6 +14,9 @@ use crate::{ pub struct AppUpdateApplyUpdateParams { pub app_id: String, pub additional_granted_permissions: SageGrantedPermissionsInput, + /// Manifest hash of the pending update the user actually reviewed. Apply is + /// rejected if the pending update no longer matches this hash. + pub reviewed_manifest_hash: String, } #[derive(Debug, Clone, Serialize, Type)] @@ -56,6 +59,7 @@ impl BridgeMethod for AppUpdateApplyUpdate { tools.host_state, ¶ms.app_id, Some(params.additional_granted_permissions), + Some(params.reviewed_manifest_hash), ) .await .map_err(|err| { diff --git a/crates/sage-apps/src/lifecycle/update/apply.rs b/crates/sage-apps/src/lifecycle/update/apply.rs index a1e6e97ea..61e7821aa 100644 --- a/crates/sage-apps/src/lifecycle/update/apply.rs +++ b/crates/sage-apps/src/lifecycle/update/apply.rs @@ -16,6 +16,7 @@ pub(crate) async fn apply_app_update_inner( apps_state: &State<'_, AppsHostState>, app_id: &str, additional_granted_permissions_input: Option, + expected_manifest_hash: Option, ) -> Result { let _update_guard = apps_state .try_begin_app_update(app_id) @@ -40,6 +41,19 @@ pub(crate) async fn apply_app_update_inner( return Ok(resolved.with_app(|app| app.into())); } + // When applying a reviewed update, bind the apply to the exact manifest the + // user reviewed. If the pending update changed in between (e.g. the + // background checker fetched a newer manifest), refuse to apply the reviewed + // permission grants to a manifest the user never saw. + if let Some(expected) = expected_manifest_hash.as_deref() + && preflight.pending.manifest_hash() != expected + { + return Err(io::Error::other(format!( + "pending update for {app_id} changed since it was reviewed; re-check the update before applying" + )) + .into()); + } + let reopen_after_update = ReopenAfterUpdate::capture(app_handle, apps_state, app_id).await?; let app = execute_app_update( @@ -110,7 +124,7 @@ pub(crate) async fn try_auto_apply_pending_update( return Ok(false); } - apply_app_update_inner(app_handle, apps_state, app_id, None).await?; + apply_app_update_inner(app_handle, apps_state, app_id, None, None).await?; Ok(true) } diff --git a/crates/sage-apps/src/lifecycle/update/commands.rs b/crates/sage-apps/src/lifecycle/update/commands.rs index 356cd6061..316d9874a 100644 --- a/crates/sage-apps/src/lifecycle/update/commands.rs +++ b/crates/sage-apps/src/lifecycle/update/commands.rs @@ -22,5 +22,5 @@ pub async fn apps_apply_app_update( apps_state: State<'_, AppsHostState>, app_id: String, ) -> Result { - apply_app_update_inner(&app_handle, &apps_state, &app_id, None).await + apply_app_update_inner(&app_handle, &apps_state, &app_id, None, None).await }