Skip to content

feat(update): propose self-update when a newer release is available - #167

Merged
Desperado merged 3 commits into
mainfrom
feat/self-update
Aug 21, 2026
Merged

feat(update): propose self-update when a newer release is available#167
Desperado merged 3 commits into
mainfrom
feat/self-update

Conversation

@Desperado

Copy link
Copy Markdown
Contributor

What

qmax-code now proposes updating itself when a newer release is out.

At startup (after the banner, once per 24h max):

  Update available: 1.25.0 (you have 1.24.0)
  Update now? [y/N]
  • Yes → downloads the platform archive from Quality-Max/qmax-code-releases, extracts the binary, atomically replaces the running executable. ✓ Updated to 1.25.0 — restart qmax-code to use it
  • NoSkipped — /update checks again anytime. and that exact version is never re-offered
  • /update → on-demand check + install; QMAX_NO_UPDATE_CHECK=1 → check fully disabled

How

Piece Where
Check + ask (24h cache, skip marker, dev-build guard) internal/update/update.goMaybeCheck
On-demand check Check (bypasses cache, honors opt-out + dev build)
Install Release.Apply — download (128 MiB cap) → extract from tar.gz/zip → write .new → atomic rename (cross-device fallback; Windows shifts the running exe aside)
Startup prompt repl.gomaybeProposeUpdate after the banner, via term.ReadConsent
Command /update + docs/COMMANDS.md row

Safety properties: network failures are silent (check can never break startup); semver comparison treats pre-releases as older than their release; dev builds (""/dev) never check; state in ~/.qmax-code/update_check.json.

Validation

  • go build ./..., go vet ./... clean
  • go test ./internal/update/ — 8 tests: version compare (incl. pre-release), asset naming, cache/skip/opt-out, silent network failure, tar.gz + zip extraction, atomic swap
  • go test ./internal/repl/ — all pass
  • Live e2e against production: Check("1.23.0") → found 1.24.0 via real GitHub API → downloaded real darwin-arm64 asset → extracted → swapped a target binary (18,141,376 bytes verified)

Changes at a glance (5 files, +635)

Area Files Purpose
Updater core internal/update/update.go +299 (new) check, compare, download, extract, atomic apply
Tests internal/update/update_test.go +280 (new) 8 tests incl. live-path semantics
REPL wiring internal/repl/repl.go +52 startup proposal, /update command
Docs docs/COMMANDS.md +1, CHANGELOG.md +13 command row, Unreleased entry
flowchart TD
    S["startup (non-quiet)"] --> MC{"MaybeCheck(version)<br/>24h cache · skip marker · dev-build · QMAX_NO_UPDATE_CHECK"}
    MC -->|no newer / cached / skipped| OFF["no prompt"]
    MC -->|newer release| P["Update available: X — [y/N]"]
    P -->|N| SKIP["MarkSkipped(X) — never re-offer X"]
    P -->|y| DL["Download platform archive<br/>qmax-code-releases"]
    DL --> EX["Extract binary (tar.gz / zip)"]
    EX --> SWAP["write .new → atomic rename<br/>windows: shift running exe aside"]
    SWAP --> OK["✓ restart to use it"]
    CMD["/update"] --> CHK{"Check(version)<br/>bypasses cache"}
    CHK -->|newer| DL
    CHK -->|up to date| UTD["✓ up to date"]
Loading

Startup proposal after the banner: 'Update available: X (you have Y) /
Update now? [y/N]'. Accepting downloads the platform archive from the public
releases repo (qmax-code-releases), extracts the binary, and atomically
replaces the running executable via write-tmp + rename (cross-device
fallback; windows shifts the running exe aside).

- checks hit the GitHub API at most once per 24h (~/.qmax-code/update_check.json)
- a declined version is recorded and never re-offered
- dev builds and QMAX_NO_UPDATE_CHECK=1 skip the check; network failures are silent
- /update command checks on demand and installs when found
- pre-release versions sort below their release (1.24.0-rc1 < 1.24.0)
@sigilix

sigilix Bot commented Aug 21, 2026

Copy link
Copy Markdown

Posted · 159b362 · 4 findings — View review
Proof: 4 model-only
runner-verified = CI receipt · reproduced = sandbox observed diff · grounded = deterministic detector/worker-token · model-only = model judgment only
Dismiss @sigilix dismiss <reason> (not-a-bug | bad-anchor | already-covered | too-minor | wrong-context) · Re-run /sigilix review · Review #1
Sigilix · 1 of 50 reviews used in past 5h

Sigilix is reviewing this pull request.

A structured overview will replace this note when retrieval and specialist review complete.

== four causes garden ==
       .          *             .             *          .
          +------------+------------+------------+------------+
          |  soil      |  seed      |  stem      |  bloom     |
      .   | . . . . .  |    .       |  . . .     |  *  *  *   |   .
          | . . . . .  |   .|.      |  . | .     | * ** ** *  |
      *   | . . . . .  |    |       |    |       |  *  |  *   |   *
          |    .       |    |       |   /|\      |     |      |
          +------------+------------+------------+------------+
                 root       form        work        end
            .          cause becomes crop, not slogan       .
      *            .             .             .             *
          +------------+------------+------------+------------+
          | . . . . .  |   .|.      |  . | .     |  *  |  *   |
          +------------+------------+------------+------------+

root, form, work, and end awake
a careful crop becomes an answer

@qualitymaxapp

qualitymaxapp Bot commented Aug 21, 2026

Copy link
Copy Markdown

⚠️ QualityMax Diff Analysis — WARN

The new self-update mechanism introduces potential security risks regarding binary integrity and execution permissions.

🟡 Finding 1: security

File: internal/repl/repl.go:2325 | Severity: warning

What: The self-update mechanism overwrites the running binary without explicit verification of the downloaded artifact's integrity (e.g., checksum or signature validation).

Fix: Ensure the 'update' package performs cryptographic signature verification or checksum validation before applying the update to prevent arbitrary code execution.

Fix with your LLM agent
<your-llm-agent> "Fix the security issue in internal/repl/repl.go at line 2325: The self-update mechanism overwrites the running binary without explicit verification of the downloaded artifact's integrity (e.g., checksum or signature validation)."

🟡 Finding 2: logic

File: internal/repl/repl.go:2327 | Severity: warning

What: The update process relies on os.Executable(), which can be manipulated in certain environments (e.g., symlink attacks or path manipulation).

Fix: Use filepath.EvalSymlinks to resolve the true path of the executable before attempting to overwrite it.

Fix with your LLM agent
<your-llm-agent> "Fix the logic issue in internal/repl/repl.go at line 2327: The update process relies on os.Executable(), which can be manipulated in certain environments (e.g., symlink attacks or path manipulation)."
Fix all findings with your LLM agent
<your-llm-agent> "Fix all QualityMax review findings in this PR:\n- security in internal/repl/repl.go:2325: The self-update mechanism overwrites the running binary without explicit verification of the downloaded artifact's integrity (e.g., checksum or signature validation).\n- logic in internal/repl/repl.go:2327: The update process relies on os.Executable(), which can be manipulated in certain environments (e.g., symlink attacks or path manipulation)."

Change diagram — Flow

graph TD
    A[Start REPL] --> B{Check for Update}
    B -->|Available| C[Prompt User]
    C -->|Yes| D[Apply Update]
    C -->|No| E[Mark Skipped]
    D --> F[Overwrite Binary]
    F --> G[Restart Required]
Loading

Analyzed commit 577ed437 with gemini-3.1-flash-lite (1 files, 2650 tokens) | Customize review preferences

@qualitymaxapp

qualitymaxapp Bot commented Aug 21, 2026

Copy link
Copy Markdown

QualityMax Review

Verdict: COMMENT · Confidence: evidence-backed scan

Files eligible: 3 · Files reviewed: 1 · Files with findings: 1 · Findings: 2 · Inline cards: 2

Priority findings

priority location finding
P3 internal/repl/repl.go:2325 The self-update mechanism overwrites the running binary without explicit verification of the downloaded artifact's integrity (e.g., checksum or signature validation).
P3 internal/repl/repl.go:2327 The update process relies on os.Executable(), which can be manipulated in certain environments (e.g., symlink attacks or path manipulation).

Review gates

gate status
AI diff review completed · eligible 3, reviewed 1 · LLM · served gemini-3.1-flash-lite
SAST completed · eligible 5, reviewed 5 · hybrid · served gemini-3.1-pro-preview
Overall review evidence non-blocking findings remain
Inline evidence posted

Important files

file risk note next step
internal/repl/repl.go P3 The self-update mechanism overwrites the running binary without explicit verification of the downloaded artifact's integrity (e.g., checksum or signature validation). Inspect the inline card and apply the smallest safe fix.

Change diagram — Flow

graph TD
    A[Start REPL] --> B{Check for Update}
    B -->|Available| C[Prompt User]
    C -->|Yes| D[Apply Update]
    C -->|No| E[Mark Skipped]
    D --> F[Overwrite Binary]
    F --> G[Restart Required]
Loading

Review lifecycle

Use the inline cards to inspect evidence and suggested remediation. Re-run the QualityMax review after pushing a fix; unchanged cards are identified by their stable finding marker. Dismiss with a reason through the existing QualityMax/GitHub review feedback flow. 0 prior card(s) are stale/resolved on this head. @qmax Q&A is tracked separately.

Proof legend: VERIFIED independently judged patch · REPRODUCED verified finding · GROUNDED deterministic evidence · MODEL-ONLY model judgment.

QualityMax project results are available in the configured project.

Receipt · commit 159b362715e986fb1778691715ab7bcab78e620c · run 2026-08-21T08:21:02+00:00 · model served gemini-3.1-flash-lite · model requested gemini-3.1-flash-lite · model review substantive — 487 model output tokens · model source not recorded · re-review 3 · proof counts {'MODEL-ONLY': 2}

⚠️ Delivery incomplete: the exact-head GitHub review was not confirmed (http_4xx). Resolve the classified delivery failure, then rerun this pipeline; this is not a clean/green receipt.

The repo's TestNoEgressOutsideHTTPX guard forbids raw net/http clients and
requests outside internal/httpx; fetchLatest and Download now use
httpx.NewClient + httpx.NewRequest so update traffic is receipt-recorded.
@qualitymaxapp

qualitymaxapp Bot commented Aug 21, 2026

Copy link
Copy Markdown

⚠️ QualityMax Diff Analysis — WARN

The new self-update mechanism introduces potential security risks regarding binary integrity and execution permissions.

🟡 Finding 1: security

File: internal/repl/repl.go:2325 | Severity: warning

What: The self-update mechanism overwrites the running binary without explicit verification of the downloaded artifact's integrity (e.g., checksum or signature validation).

Fix: Ensure the 'update' package performs cryptographic signature verification or checksum validation before applying the update to prevent arbitrary code execution.

Fix with your LLM agent
<your-llm-agent> "Fix the security issue in internal/repl/repl.go at line 2325: The self-update mechanism overwrites the running binary without explicit verification of the downloaded artifact's integrity (e.g., checksum or signature validation)."

🟡 Finding 2: logic

File: internal/repl/repl.go:2327 | Severity: warning

What: The update process relies on os.Executable(), which can be manipulated in certain environments (e.g., symlink attacks or path manipulation).

Fix: Use filepath.EvalSymlinks to resolve the true path of the executable before attempting to overwrite it.

Fix with your LLM agent
<your-llm-agent> "Fix the logic issue in internal/repl/repl.go at line 2327: The update process relies on os.Executable(), which can be manipulated in certain environments (e.g., symlink attacks or path manipulation)."
Fix all findings with your LLM agent
<your-llm-agent> "Fix all QualityMax review findings in this PR:\n- security in internal/repl/repl.go:2325: The self-update mechanism overwrites the running binary without explicit verification of the downloaded artifact's integrity (e.g., checksum or signature validation).\n- logic in internal/repl/repl.go:2327: The update process relies on os.Executable(), which can be manipulated in certain environments (e.g., symlink attacks or path manipulation)."

Change diagram — Flow

graph TD
    A[Start REPL] --> B{Check for Update}
    B -->|Available| C[Prompt User]
    C -->|Yes| D[Apply Update]
    C -->|No| E[Mark Skipped]
    D --> F[Overwrite Binary]
    F --> G[Restart Required]
Loading

Analyzed commit 0a972e52 with gemini-3.1-flash-lite (1 files, 2650 tokens) | Customize review preferences

@qualitymaxapp

qualitymaxapp Bot commented Aug 21, 2026

Copy link
Copy Markdown

⚠️ QualityMax Pipeline

Gate Result
🔍 AI diff review ⚠️ 2 warning(s) · gemini-3.1-flash-lite · completed · 3 eligible / 1 reviewed · gemini-3.1-flash-lite
🔍 SAST completed · 5 eligible / 5 reviewed · gemini-3.1-pro-preview
🔍 Canonical PR review delivery failed · 0 eligible / 0 reviewed · review=http_4xx/HTTP 422;retryable=false, comment=delivered#5367242772
🧪 Repo Tests ✅ 607/607 passed (go)
🤖 AI Tests ⚠️ 51/56 passed

Powered by QualityMax — AI-Powered Test Automation

@qualitymaxapp

qualitymaxapp Bot commented Aug 21, 2026

Copy link
Copy Markdown

⚠️ QualityMax Diff Analysis — WARN

The new self-update mechanism introduces potential security risks regarding binary integrity and execution permissions.

🟡 Finding 1: security

File: internal/repl/repl.go:2325 | Severity: warning

What: The self-update mechanism overwrites the running binary without explicit verification of the downloaded artifact's integrity (e.g., checksum or signature validation).

Fix: Ensure the 'update' package performs cryptographic signature verification or checksum validation before applying the update to prevent arbitrary code execution.

Fix with your LLM agent
<your-llm-agent> "Fix the security issue in internal/repl/repl.go at line 2325: The self-update mechanism overwrites the running binary without explicit verification of the downloaded artifact's integrity (e.g., checksum or signature validation)."

🟡 Finding 2: logic

File: internal/repl/repl.go:2327 | Severity: warning

What: The update process relies on os.Executable(), which can be manipulated in certain environments (e.g., symlink attacks or path manipulation).

Fix: Use filepath.EvalSymlinks to resolve the true path of the executable before attempting to overwrite it.

Fix with your LLM agent
<your-llm-agent> "Fix the logic issue in internal/repl/repl.go at line 2327: The update process relies on os.Executable(), which can be manipulated in certain environments (e.g., symlink attacks or path manipulation)."
Fix all findings with your LLM agent
<your-llm-agent> "Fix all QualityMax review findings in this PR:\n- security in internal/repl/repl.go:2325: The self-update mechanism overwrites the running binary without explicit verification of the downloaded artifact's integrity (e.g., checksum or signature validation).\n- logic in internal/repl/repl.go:2327: The update process relies on os.Executable(), which can be manipulated in certain environments (e.g., symlink attacks or path manipulation)."

Change diagram — Flow

graph TD
    A[Start REPL] --> B{Check for Update}
    B -->|Available| C[Prompt User]
    C -->|Yes| D[Apply Update]
    C -->|No| E[Mark Skipped]
    D --> F[Overwrite Binary]
    F --> G[Restart Required]
Loading

Analyzed commit 159b3627 with gemini-3.1-flash-lite (1 files, 2650 tokens) | Customize review preferences

@sigilix sigilix Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 finding outside the diff
File Scope Finding
internal/update/update.go:357 file-scope Missing signature verification of downloaded binary allows supply-chain attack

Comment thread internal/update/update.go
Comment on lines +332 to +340
}
if len(bin) < 1<<10 {
return fmt.Errorf("extracted binary suspiciously small (%d bytes)", len(bin))
}

// Windows cannot overwrite a running executable in place; shift it aside
// and drop the new one where it was. The .old file is cleaned by the
// installer on future runs.
if runtime.GOOS == "windows" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 LOGICGROUNDED Non-atomic fallback in Apply can corrupt the executable on rename failure

The Apply method's fallback path for a failed rename writes directly to the executable path with os.WriteFile, which truncates the existing binary before writing. If the write fails (e.g., disk full), the executable is left corrupted and the original binary is lost. The method is advertised as an atomic replacement, but this fallback is not atomic and can result in a broken installation.

Write the new binary to a temp file in the same directory as the executable (already done). If rename fails, copy the new binary to the destination using a write+sync+rename strategy on the same device, or abort the update without destroying the existing binary.

Example:

input: rename from .new to the executable path fails (e.g., cross-device install)
actual: fallback calls os.WriteFile(exePath, bin, 0o755) — opens with O_TRUNC, destroying the existing binary, then writes the new content. If the write fails partway, the file is truncated and the new binary is incomplete.
expected: the old binary is preserved; the update is either fully applied or the system is left in the original state.

Why this wasn't caught: No test for the fallback when rename fails; the existing TestApplySwapsBinary does not simulate a rename failure or verify the old binary is preserved.

Current:

	}
	if len(bin) < 1<<10 {
		return fmt.Errorf("extracted binary suspiciously small (%d bytes)", len(bin))
	}

	// Windows cannot overwrite a running executable in place; shift it aside
	// and drop the new one where it was. The .old file is cleaned by the
	// installer on future runs.
	if runtime.GOOS == "windows" {

Proposed:

if err := os.Rename(tmp, exePath); err != nil {
	_ = os.Remove(tmp)
	return fmt.Errorf("cannot replace binary (rename failed): %w", err)
}
Suggested change
}
if len(bin) < 1<<10 {
return fmt.Errorf("extracted binary suspiciously small (%d bytes)", len(bin))
}
// Windows cannot overwrite a running executable in place; shift it aside
// and drop the new one where it was. The .old file is cleaned by the
// installer on future runs.
if runtime.GOOS == "windows" {
if err := os.Rename(tmp, exePath); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("cannot replace binary (rename failed): %w", err)
}
More Info
  • Threat model: A user on a system where the executable resides on a different mount point or where the rename fails for any reason. The update attempt silently destroys the existing binary, leaving the user without a working executable.
  • Specific code citations: internal/update/update.go:340-345 — the os.Rename failure branch that calls os.WriteFile directly.
  • Existing protections: The rename is unlikely to fail because the temp file is in the same directory as the target, but the fallback exists for cross-device or permission edge cases. No backup of the original binary is taken.
  • Proposed mitigation: Replace the direct os.WriteFile with a two-step atomic write on the same device (write to a temp file, then rename) or, if the rename fails, leave the existing binary intact and report the error so the user can intervene.
  • Alternative mitigations considered: Log a warning and abort the update without overwriting the existing binary, leaving the .new file for the user to handle.
  • Severity calibration: The condition is rare (rename failure), but the impact is severe: the user's binary is destroyed and a manual reinstall is required.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/update/update.go
Line: 332-340

Comment:
**Non-atomic fallback in Apply can corrupt the executable on rename failure**

The `Apply` method's fallback path for a failed rename writes directly to the executable path with `os.WriteFile`, which truncates the existing binary before writing. If the write fails (e.g., disk full), the executable is left corrupted and the original binary is lost. The method is advertised as an atomic replacement, but this fallback is not atomic and can result in a broken installation.

Write the new binary to a temp file in the same directory as the executable (already done). If rename fails, copy the new binary to the destination using a write+sync+rename strategy on the same device, or abort the update without destroying the existing binary.

Example:
input: rename from .new to the executable path fails (e.g., cross-device install)
actual: fallback calls os.WriteFile(exePath, bin, 0o755) — opens with O_TRUNC, destroying the existing binary, then writes the new content. If the write fails partway, the file is truncated and the new binary is incomplete.
expected: the old binary is preserved; the update is either fully applied or the system is left in the original state.

Threat model:
A user on a system where the executable resides on a different mount point or where the rename fails for any reason. The update attempt silently destroys the existing binary, leaving the user without a working executable.

Specific code citations:
`internal/update/update.go:340-345` — the `os.Rename` failure branch that calls `os.WriteFile` directly.

Existing protections:
The rename is unlikely to fail because the temp file is in the same directory as the target, but the fallback exists for cross-device or permission edge cases. No backup of the original binary is taken.

Proposed mitigation:
Replace the direct `os.WriteFile` with a two-step atomic write on the same device (write to a temp file, then rename) or, if the rename fails, leave the existing binary intact and report the error so the user can intervene.

Alternative mitigations considered:
Log a warning and abort the update without overwriting the existing binary, leaving the .new file for the user to handle.

Severity calibration:
The condition is rare (rename failure), but the impact is severe: the user's binary is destroyed and a manual reinstall is required.

Suggested fix shape:
if err := os.Rename(tmp, exePath); err != nil {
	_ = os.Remove(tmp)
	return fmt.Errorf("cannot replace binary (rename failed): %w", err)
}

Why this wasn't caught:
No test for the fallback when rename fails; the existing TestApplySwapsBinary does not simulate a rename failure or verify the old binary is preserved.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/update/update.go
return os.WriteFile(exePath, bin, 0o755)
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 SECURITYGROUNDED Missing integrity check on extracted binary size may allow truncated/malformed binary

The Apply method checks if len(bin) < 1<<10 to reject suspiciously small binaries, but this is insufficient. A malicious or corrupted archive could produce a binary that passes the size check but is malformed (e.g., wrong architecture, truncated, or corrupted), causing the program to crash or behave unexpectedly after restart.

Add a more robust integrity check, such as verifying the binary's Go build ID, checking a stored SHA-256 hash from the release metadata, or at least ensuring the binary is executable (via elf or mach-o parsing).

Example:

A network glitch truncates the downloaded archive, resulting in a 500KB binary that is missing the final segment. The size check passes, but the binary crashes on startup.

Suggested fix:

// After extraction:
wantHash := fetchChecksum(r.Asset + ".sha256")
if sha256.Sum256(bin) != wantHash {
    return fmt.Errorf("checksum mismatch")
}
More Info
  • Threat model: A network corruption or malicious CDN could serve a partially truncated binary that is still >1KB but invalid. The user's qmax-code would fail to start after restart, causing denial of service.
  • Specific code citations: internal/update/update.go:357Apply calls extractBinary and then checks len(bin) < 1<<10.
  • Existing protections: Only the 1KB size check; no hash comparison or format validation.
  • Proposed mitigation: Fetch a SHA-256 checksum file from the release assets and compare it against the extracted binary. Alternatively, parse the binary's header to confirm it's a valid executable for the current platform.
  • Alternative mitigations considered: Use Go's debug/elf or debug/macho to read the binary's architecture and ensure it matches runtime.GOARCH. This adds a dependency but is lightweight.
  • Severity calibration: Score 3 because exploitation likely requires an active attacker on the network path (MITM) or a compromised CDN, and the impact is denial of service rather than code execution.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/update/update.go
Line: 357

Comment:
**Missing integrity check on extracted binary size may allow truncated/malformed binary**

The `Apply` method checks `if len(bin) < 1<<10` to reject suspiciously small binaries, but this is insufficient. A malicious or corrupted archive could produce a binary that passes the size check but is malformed (e.g., wrong architecture, truncated, or corrupted), causing the program to crash or behave unexpectedly after restart.

Add a more robust integrity check, such as verifying the binary's Go build ID, checking a stored SHA-256 hash from the release metadata, or at least ensuring the binary is executable (via `elf` or `mach-o` parsing).

Example:
A network glitch truncates the downloaded archive, resulting in a 500KB binary that is missing the final segment. The size check passes, but the binary crashes on startup.

Threat model:
A network corruption or malicious CDN could serve a partially truncated binary that is still >1KB but invalid. The user's qmax-code would fail to start after restart, causing denial of service.

Specific code citations:
`internal/update/update.go:357` — `Apply` calls `extractBinary` and then checks `len(bin) < 1<<10`.

Existing protections:
Only the 1KB size check; no hash comparison or format validation.

Proposed mitigation:
Fetch a SHA-256 checksum file from the release assets and compare it against the extracted binary. Alternatively, parse the binary's header to confirm it's a valid executable for the current platform.

Alternative mitigations considered:
Use Go's `debug/elf` or `debug/macho` to read the binary's architecture and ensure it matches `runtime.GOARCH`. This adds a dependency but is lightweight.

Severity calibration:
Score 3 because exploitation likely requires an active attacker on the network path (MITM) or a compromised CDN, and the impact is denial of service rather than code execution.

Suggested fix shape:
// After extraction:
wantHash := fetchChecksum(r.Asset + ".sha256")
if sha256.Sum256(bin) != wantHash {
    return fmt.Errorf("checksum mismatch")
}

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/update/update.go
return os.WriteFile(exePath, bin, 0o755)
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 SECURITYGROUNDED Atomic rename cross-device fallback may leave temporary .new file on some systems

When os.Rename fails (e.g., across filesystem boundaries), the code falls back to os.WriteFile(exePath, bin, 0o755). This overwrites the running executable in-place, which on some platforms (Linux, macOS) may fail because the file is locked by the running process, leaving the .new temporary file behind.

The fallback should delete the .new file after a successful write, and on failure clean up the .new file to avoid leaving garbage.

Example:

Binary is on a different mount, rename fails, fallback write fails due to file lock, `.new` file remains.

Suggested fix:

// In the fallback:
defer os.Remove(tmp)
if err := os.WriteFile(exePath, bin, 0o755); err != nil {
    return err
}
More Info
  • Threat model: On a cross-device install (binary on a different mount), the rename fails, the fallback write may succeed or fail depending on OS locking. If it fails, the .new file remains in the directory, potentially causing confusion or being executed accidentally.
  • Specific code citations: internal/update/update.go:357if err := os.Rename(tmp, exePath); err != nil { _ = os.Remove(tmp); return os.WriteFile(exePath, bin, 0o755) }
  • Existing protections: os.Remove(tmp) is called before the fallback, but if the fallback fails the .new file is not cleaned up.
  • Proposed mitigation: Ensure the fallback path also removes the .new file on any error, and perhaps log a warning about cross-device rename.
  • Alternative mitigations considered: Use io.Copy to a temporary file on the same filesystem as the target, then rename.
  • Severity calibration: Score 2 because it's a minor hardening issue; the leftover file is not a security vulnerability but could cause user confusion.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/update/update.go
Line: 357

Comment:
**Atomic rename cross-device fallback may leave temporary .new file on some systems**

When `os.Rename` fails (e.g., across filesystem boundaries), the code falls back to `os.WriteFile(exePath, bin, 0o755)`. This overwrites the running executable in-place, which on some platforms (Linux, macOS) may fail because the file is locked by the running process, leaving the `.new` temporary file behind.

The fallback should delete the `.new` file after a successful write, and on failure clean up the `.new` file to avoid leaving garbage.

Example:
Binary is on a different mount, rename fails, fallback write fails due to file lock, `.new` file remains.

Threat model:
On a cross-device install (binary on a different mount), the rename fails, the fallback write may succeed or fail depending on OS locking. If it fails, the `.new` file remains in the directory, potentially causing confusion or being executed accidentally.

Specific code citations:
`internal/update/update.go:357` — `if err := os.Rename(tmp, exePath); err != nil { _ = os.Remove(tmp); return os.WriteFile(exePath, bin, 0o755) }`

Existing protections:
`os.Remove(tmp)` is called before the fallback, but if the fallback fails the `.new` file is not cleaned up.

Proposed mitigation:
Ensure the fallback path also removes the `.new` file on any error, and perhaps log a warning about cross-device rename.

Alternative mitigations considered:
Use `io.Copy` to a temporary file on the same filesystem as the target, then rename.

Severity calibration:
Score 2 because it's a minor hardening issue; the leftover file is not a security vulnerability but could cause user confusion.

Suggested fix shape:
// In the fallback:
defer os.Remove(tmp)
if err := os.WriteFile(exePath, bin, 0o755); err != nil {
    return err
}

How can I resolve this? If you propose a fix, please make it concise.

@Desperado
Desperado merged commit 284aaaf into main Aug 21, 2026
6 of 7 checks passed
@Desperado
Desperado deleted the feat/self-update branch August 21, 2026 08:27
@Desperado Desperado mentioned this pull request Aug 21, 2026
3 tasks
Desperado added a commit that referenced this pull request Aug 21, 2026
Minor bump: first release shipping the self-update proposal (#167).
Installs on 1.24.0 will receive their first update prompt once this
version is published to qmax-code-releases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant