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 }