From 445eeed98b484bb7de1881270335c3150e9860c8 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Fri, 3 Jul 2026 19:06:38 -0400 Subject: [PATCH] Harden sandbox launch gate against reruns, crashes, and vacuous isolation probe Three related sandbox issues: - Launch-gate bypass on rerun (H6): effective_cap fell back to the previous baseline for any Pending/Running capability, so a user-triggered rerun (which sets all caps to Running) kept the gate open using stale, previously-Passed results. Effective state now reflects the live run status, so the gate blocks until a rerun re-establishes each result. - Crash cleanup (H6): if the runner task exits abnormally before finalizing, a Drop guard now clears current_run/running and resets the baseline to pending, so a crashed run can't leave apps launchable against a stale baseline. - Vacuous isolation probe (H5): the isolation probes read fixed keys that nothing ever wrote, so the test passed even if isolation were broken. The backend now seeds those keys in the host (main Sage) webview's localStorage and IndexedDB before launching the probes, so a sandbox app that can observe them proves an isolation failure. --- .../sage-apps/src/sandbox/probes/isolation.rs | 56 ++++++++++++++++++- crates/sage-apps/src/sandbox/runner.rs | 36 +++++++++++- crates/sage-apps/src/sandbox/state_view.rs | 17 +++--- 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/crates/sage-apps/src/sandbox/probes/isolation.rs b/crates/sage-apps/src/sandbox/probes/isolation.rs index 33b63794d..2322545a6 100644 --- a/crates/sage-apps/src/sandbox/probes/isolation.rs +++ b/crates/sage-apps/src/sandbox/probes/isolation.rs @@ -1,11 +1,57 @@ +use std::time::Duration; + use tauri::{AppHandle, State}; use super::super::runtime::{start_test_app, stop_test_apps, unique_run_id}; use super::poll::poll_isolation; use crate::{ AppsHostState, BUILTIN_STORAGE_ISOLATION_INCOGNITO_ID, BUILTIN_STORAGE_ISOLATION_PERSISTENT_ID, + get_sage_webview, }; +/// Writes probe data into the host (main Sage) webview's `localStorage` and +/// `indexedDB` before the isolation probes run. Without this, the probe apps +/// read fixed keys that nothing ever wrote, so the test passed vacuously even +/// if storage isolation were completely broken. With the host seeded, a sandbox +/// app that can observe these values proves an isolation failure. +async fn seed_host_isolation_probe(app: &AppHandle, run_id: &str) -> Result<(), String> { + let webview = get_sage_webview(app)?; + + let value = serde_json::to_string(run_id) + .map_err(|err| format!("failed to encode isolation probe value: {err}"))?; + + let script = format!( + r#"(function () {{ + var value = {value}; + try {{ localStorage.setItem('sage_probe_local_storage', value); }} catch (e) {{}} + try {{ + var open = indexedDB.open('sage_probe_db', 1); + open.onupgradeneeded = function () {{ + try {{ + var db = open.result; + if (!db.objectStoreNames.contains('probe_store')) {{ + db.createObjectStore('probe_store'); + }} + }} catch (e) {{}} + }}; + open.onsuccess = function () {{ + try {{ + var db = open.result; + if (!db.objectStoreNames.contains('probe_store')) {{ db.close(); return; }} + var tx = db.transaction('probe_store', 'readwrite'); + tx.objectStore('probe_store').put(value, 'sage_probe_key'); + tx.oncomplete = function () {{ db.close(); }}; + }} catch (e) {{}} + }}; + }} catch (e) {{}} +}})();"# + ); + + webview + .eval(script) + .map_err(|err| format!("failed to seed host isolation probe data: {err}")) +} + pub(in crate::sandbox) async fn run_isolation_test( app: &AppHandle, apps_state: &State<'_, AppsHostState>, @@ -18,6 +64,11 @@ pub(in crate::sandbox) async fn run_isolation_test( stop_test_apps(app, apps_state, &app_ids).await; + // Seed the host storage first, then give the async IndexedDB write a brief + // moment to commit before launching the sandbox probes. + seed_host_isolation_probe(app, &run_id).await?; + tokio::time::sleep(Duration::from_millis(250)).await; + start_test_app( app, apps_state, @@ -76,6 +127,9 @@ pub(in crate::sandbox) async fn run_isolation_test( Ok(( true, - Some("Both sandbox probe modes were unable to observe Sage probe data.".into()), + Some( + "Both sandbox probe modes were unable to observe the seeded Sage host probe data." + .into(), + ), )) } diff --git a/crates/sage-apps/src/sandbox/runner.rs b/crates/sage-apps/src/sandbox/runner.rs index dbdaf28a7..4d0f48827 100644 --- a/crates/sage-apps/src/sandbox/runner.rs +++ b/crates/sage-apps/src/sandbox/runner.rs @@ -4,10 +4,36 @@ use super::probes::{run_isolation_test, run_network_test, run_persistence_test}; use super::state_view::{build_effective_state, build_state_view}; use super::types::{ SandboxCapability, SandboxCapabilityStatus, SandboxRunState, SandboxState, - build_running_sandbox_state, mark_cap, + build_initial_sandbox_state, build_running_sandbox_state, mark_cap, }; use crate::{AppsHostState, emit_sandbox_state_changed, unix_timestamp_ms}; +/// Resets sandbox run state if the runner exits abnormally (panic / early +/// return) before it finalizes. Without this, a crash mid-run would leave +/// `running == true` and a stale (possibly `Passed`) baseline in place, keeping +/// the app launch gate open even though no run is actually in flight. +struct SandboxRunCleanup { + app: AppHandle, + completed: bool, +} + +impl Drop for SandboxRunCleanup { + fn drop(&mut self) { + if self.completed { + return; + } + + let app = self.app.clone(); + tauri::async_runtime::spawn(async move { + let apps_state = app.state::(); + *apps_state.sandbox.current_run.lock().await = None; + *apps_state.sandbox.baseline.lock().await = build_initial_sandbox_state(); + *apps_state.sandbox.running.lock().await = false; + emit_sandbox_state_changed(&app, &apps_state).await; + }); + } +} + pub async fn ensure_initial_sandbox_run(app: AppHandle) -> Result<(), String> { let apps_state = app.state::(); @@ -79,6 +105,11 @@ async fn update_current_run_state( } pub async fn sandbox_runner(app: AppHandle) { + let mut cleanup = SandboxRunCleanup { + app: app.clone(), + completed: false, + }; + let apps_state = app.state::(); let mut current_state = { @@ -225,6 +256,9 @@ pub async fn sandbox_runner(app: AppHandle) { *apps_state.sandbox.running.lock().await = false; emit_sandbox_state_changed(&app, &apps_state).await; + + // Reached the finalize path cleanly; suppress the abnormal-exit reset. + cleanup.completed = true; } fn sandbox_state_is_all_pending(state: &SandboxState) -> bool { diff --git a/crates/sage-apps/src/sandbox/state_view.rs b/crates/sage-apps/src/sandbox/state_view.rs index b5e7886b3..ca73087d8 100644 --- a/crates/sage-apps/src/sandbox/state_view.rs +++ b/crates/sage-apps/src/sandbox/state_view.rs @@ -1,19 +1,22 @@ use tauri::State; use super::types::{ - SandboxCapabilityResult, SandboxCapabilityStatus, SandboxRunState, SandboxState, - SandboxStateView, + SandboxCapabilityResult, SandboxRunState, SandboxState, SandboxStateView, }; use crate::AppsHostState; fn effective_cap( - baseline: &SandboxCapabilityResult, + _baseline: &SandboxCapabilityResult, current: &SandboxCapabilityResult, ) -> SandboxCapabilityResult { - match current.status { - SandboxCapabilityStatus::Passed | SandboxCapabilityStatus::Failed => current.clone(), - SandboxCapabilityStatus::Pending | SandboxCapabilityStatus::Running => baseline.clone(), - } + // While a run is in progress we must reflect the *live* status of each + // capability, never the previous baseline. Falling back to a stale + // (previously `Passed`) baseline for a `Pending`/`Running` capability let a + // user-triggered rerun keep the launch gate open using results that no + // longer apply — and a runner that crashed mid-rerun could leave apps + // launchable indefinitely. Surfacing the live `Running`/`Pending` status + // makes the gate block until the rerun actually re-establishes each result. + current.clone() } pub fn build_effective_state(