From ddf5d74764854c425a09c07f7e641bcad34c568e Mon Sep 17 00:00:00 2001 From: john Date: Wed, 10 Jun 2026 20:04:10 -0400 Subject: [PATCH 1/8] feat(update): mandatory security updates (non-dismissible banner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release flagged critical forces a non-dismissible 'Security update required' notice on both surfaces (red, shield icon, advisory link), so a vulnerable build can be patched without the user ignoring it. - update_check reads custom manifest fields via Update.raw_json: critical, severity, advisory (unsigned — the binary is still signature-verified at install, so this can only force-with-update, never run a bad binary). - Store: updateMandatory/updateSeverity/advisoryUrl, applyUpdateInfo helper, openAdvisory. When mandatory, the notice can't be dismissed on either surface. - Release workflow: mark_critical dispatch input or a 'security-critical' PR label injects critical/severity/advisory into latest.json. - Demo: the demo update is critical end-to-end (banner -> download -> restart -> greeting). Spec: REQUIREMENTS.md (App Updates / mandatory). 289 tests (+3). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 30 ++++++++++++- docs/specs/REQUIREMENTS.md | 26 ++++++++++- src-tauri/src/commands.rs | 23 ++++++++++ src/components/layout/UpdateNotice.vue | 60 +++++++++++++++++++++++--- src/demo/config.json | 5 ++- src/store/index.js | 36 ++++++++++++---- tests/integration/auto-update.test.js | 30 +++++++++++++ 7 files changed, 190 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9ec1d3..71a5648 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,14 @@ on: - patch - minor - major + mark_critical: + description: "Mandatory security update (clients can't dismiss the prompt)" + type: boolean + default: false + advisory_url: + description: "Optional advisory/release link shown on the security banner" + type: string + default: "" # Never run two releases at once — they race on the version bump + tag. concurrency: @@ -358,16 +366,24 @@ jobs: # with its signed bundle URL + detached signature (read from the .sig # files). VERSION/REPO are passed via env (never interpolated into the # script) so untrusted input can't reach the shell or node. + # MARK_CRITICAL/ADVISORY_URL flag a mandatory security update: from the + # workflow_dispatch input, or a `security-critical` label on the merged + # PR. They become unsigned manifest fields the client enforces (the + # binary is still signature-verified, so this can only force-with-update). env: GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} VERSION: ${{ needs.compute-version.outputs.version }} REPO: Canonical-AI/canonic + MARK_CRITICAL: ${{ github.event.inputs.mark_critical == 'true' || contains(github.event.pull_request.labels.*.name, 'security-critical') }} + ADVISORY_URL: ${{ github.event.inputs.advisory_url }} run: | node <<'EOF' const fs = require('fs'); const path = require('path'); const version = process.env.VERSION; const repo = process.env.REPO; + const critical = process.env.MARK_CRITICAL === 'true'; + const advisory = process.env.ADVISORY_URL || ''; // Recursively collect every downloaded artifact file. const files = []; @@ -408,12 +424,22 @@ jobs: throw new Error('no updater artifacts found — check createUpdaterArtifacts + signing secrets'); } - fs.writeFileSync('latest.json', JSON.stringify({ + const manifest = { version, notes: `See https://github.com/${repo}/releases/tag/v${version}`, pub_date: new Date().toISOString(), platforms, - }, null, 2)); + }; + // Custom fields the client reads via Update.raw_json to force a + // mandatory, non-dismissible security update. + if (critical) { + manifest.critical = true; + manifest.severity = 'critical'; + manifest.advisory = + advisory || `https://github.com/${repo}/releases/tag/v${version}`; + } + + fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2)); console.log(fs.readFileSync('latest.json', 'utf8')); EOF gh release upload "v$VERSION" latest.json --repo "$REPO" --clobber diff --git a/docs/specs/REQUIREMENTS.md b/docs/specs/REQUIREMENTS.md index af1aa2f..506cba0 100644 --- a/docs/specs/REQUIREMENTS.md +++ b/docs/specs/REQUIREMENTS.md @@ -2238,11 +2238,33 @@ pinned in `tauri.conf.json`, so an unsigned or tampered package is rejected. when: the app starts then: no greeting is shown and the stored target is cleared +### Mandatory security updates + +* scenario: a critical update can't be dismissed + given: the release manifest marks the update `critical` + when: the update is detected + then: the over-editor banner and the left-panel widget both show a red "Security update required" notice with no close button (overriding the usual dismiss) + +* scenario: advisory link is shown for security updates + given: a critical update whose manifest includes an advisory URL + when: the security banner is shown + then: it includes an "Advisory" link that opens the advisory in the browser + +* scenario: forcing is best-effort and non-destructive + given: the critical flag lives in the unsigned part of the manifest + when: the client enforces it + then: it only nags/forces-with-update — the downloaded binary is still signature-verified before install, so a tampered flag can never run a malicious build + +* scenario: releases can be flagged critical from CI + given: a release is published + when: the `mark_critical` workflow input is true or the merged PR has the `security-critical` label + then: `latest.json` is written with `critical: true`, `severity: critical`, and an advisory URL + ### Demo Mode * scenario: update flow is demoable end to end given: the app is in demo mode - when: the user downloads and restarts the fake update - then: progress animates, then an "Updated to v0.3.0" greeting with a release-notes link is shown — no real binary or network is touched + when: the user downloads and restarts the fake (critical) update + then: a non-dismissible "Security update required" banner is shown, progress animates, then an "Updated to v0.3.0" greeting with a release-notes link — no real binary or network is touched *Last updated: 2026-06-10* diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e34a5ac..a5b6eef 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2677,10 +2677,33 @@ pub async fn update_check(app: tauri::AppHandle) -> Result { let updater = app.updater().map_err(|e| e.to_string())?; match updater.check().await { Ok(Some(update)) => { + // Custom (unsigned) manifest fields drive security forcing. The + // binary itself is still signature-verified at install, so these + // flags can only nag/force-with-update — never run a bad binary. + // `critical: true` makes the update mandatory for every client old + // enough to see it (the plugin only returns one when out of date). + let raw = &update.raw_json; + let mandatory = raw + .get("critical") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let severity = raw + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let advisory = raw + .get("advisory") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); let info = json!({ "version": update.version.clone(), "notes": update.body.clone(), "date": update.date.map(|d| d.to_string()), + "mandatory": mandatory, + "severity": severity, + "advisory": advisory, }); *pending_update().lock().unwrap() = Some(PendingUpdate { update, bytes: None }); diff --git a/src/components/layout/UpdateNotice.vue b/src/components/layout/UpdateNotice.vue index 2ad0d49..e8987c7 100644 --- a/src/components/layout/UpdateNotice.vue +++ b/src/components/layout/UpdateNotice.vue @@ -21,6 +21,12 @@ href="#" @click.prevent="store.openReleaseNotes()" >Release notes ↗ + Advisory ↗