Self-update downgrade guard: refuse to install an older "latest" release without --force¶
- Authors
- Matt Cockayne, Claude Fable 5 (AI drafting assistant)
- Date
- 2026-07-23
- Status
- IMPLEMENTED — maintainer decisions resolved as follows:
implicit downgrades (release listing serves an older "latest") are
refused without
--force(§5 preamble); an explicit--versionnaming an older release is sufficient intent on its own — no--forcerequired (§5 Q1); the guard compares against the running binary version only — no persisted high-water mark (§5 Q2). - Related
- architectural review (HIGH finding),
forced update feature (the
--forcesemantics this builds on), update channels & rollback (sanctioned-downgrade UX), remote update checksum verification (what verification does and does not cover)
1. Problem¶
pkg/setup/update.go has no downgrade guard on the implicit update path:
IsLatestVersion(update.go:606-613) switches onver.CompareVersions(s.CurrentVersion, latestVersion). When the current version is newer than the reported latest (case 1, update.go:609-610), it returnsfalsewith the message "…your tardis travelled too far into the future… please downgrade to %s".shouldSkipUpdate(update.go:939-952) skips only whenisLatestVersion && !s.force(update.go:945). Afalseresult — including the current-is-newer case — letsUpdate(update.go:617-629) continue: it downloads the "latest" release and installs the older binary, with no--forceand no confirmation.
Threat model: rollback attack. Signature and checksum verification (the Phase 2
signing enforcement) authenticate that an artefact is a genuine release — they say
nothing about its recency. An attacker who can influence the release listing — a
compromised forge account, a deleted/yanked newest release, or a rogue re-tag (a class
of incident this project has already experienced with an out-of-band go/forge
release) — can serve an older, validly signed release as "latest". Every
verification step passes and the tool downgrades itself to a known-vulnerable
version. The same mechanism also fires benignly: a maintainer deleting the newest
release for operational reasons silently rolls back every user who runs update.
2. Proposed change¶
Make version regression an explicit, forced action on the implicit path:
- Guard in
shouldSkipUpdate(or a sibling check called fromUpdate): when no explicit version was requested (s.version == "") andCompareVersions(CurrentVersion, latestVersion) == 1, refuse the update with a clear error unlesss.forceis set:
refusing to downgrade from vX.Y.Z to vA.B.C: the release source reports an older "latest" release. If this is intentional, re-run with
--force, or pin the target with--version.
The error should carry an errors.WithHint so the error handler renders the
remediation. Refusal must be a non-zero exit, not a warning-and-skip — a
silently skipped update under attack conditions should be visible to the operator.
2. Keep the sanctioned downgrade paths. Explicit --version + --force remains
the supported downgrade route (per the forced-update spec). Decide whether
--version alone targeting a lower version should also require --force
(see §5 Q1); the guard above concerns only the implicit "install latest" path.
3. Leave the background check untouched. The throttled pre-run check in
pkg/cmd/root only notifies; its "tardis" message (update.go:610) can stay, but
reword it to state that a downgrade will not be applied automatically.
4. Development builds (v0.0.0, update.go:1055-1057) already require --force
for any update; the new guard must not change that behaviour.
Alternative considered: an interactive confirmation prompt instead of a hard
refusal. Rejected as the primary mechanism — update runs headlessly (CI, scripts,
the background updater), and a prompt default of "yes" would neuter the guard. A
prompt may be layered on for interactive terminals later.
3. Scope & release plan¶
- go-tool-base only:
pkg/setup/update.go(+ tests). Nogo/forge*change — the providers correctly report what the forge says; recency policy belongs in the consumer. - Ships as
fix(setup): …(patch) via the releaser-pleaser Release MR. Downstream tools built on GTB pick it up via their next go.mod bump; from that point their own self-update inherits the guard. - Behavioural change note: users who relied on implicit downgrades (if any) must now
pass
--force. Document indocs/components/(update subsystem page) and the changelog body.
4. Acceptance criteria¶
- Unit test: current
v1.2.0, latest reportedv1.1.0, no--force, no--version→Updatereturns an error (not a silent skip), no download is attempted (assert via mock release client), exit is non-zero at the command layer. - Unit test: same scenario with
--force→ update proceeds and installs (existing install pipeline assertions). - Unit test: explicit
--version v1.1.0behaves per the §5 Q1 decision, with a test pinning whichever behaviour is chosen. - Regression tests: upgrade path (
-1) and already-latest path (0) behave exactly as before, including the--forcereinstall of the current version. - Development-version (
v0.0.0) behaviour unchanged: test that it still requires--forceregardless of comparison result. - The refusal message names both versions and the
--force/--versionremediation; a test asserts the hint is attached. - E2E: extend the update Gherkin scenarios in
features/with a "release source reports an older latest" scenario (fake forge fixture), asserting refusal. just cigreen; touched code keeps ≥ 90% coverage.
5. Open questions¶
- Should an explicit
--versionthat is lower thanCurrentVersionalso require--force, or is naming an exact version already sufficient intent? (The review's direction keeps--version+--forceas the sanctioned pair; requiring both is the safer default and matches the channels/rollback spec's framing.) - Should the guard compare against the running binary version only, or also record a high-water mark (last successfully installed version) to catch downgrades across reinstalls? Proposed: running version only for now; a persisted high-water mark is a larger TUF-style feature and out of scope.