Skip to content

fix(update): verify and install the release binary in-app instead of running a remote script - #53

Merged
johnpangalos merged 4 commits into
mainfrom
claude/security-audit-kcyr4m-m1-update-integrity
Sep 20, 2026
Merged

johnpangalos merged 4 commits into
mainfrom
claude/security-audit-kcyr4m-m1-update-integrity

Conversation

@johnpangalos

@johnpangalos johnpangalos commented Aug 14, 2026 •

Copy link
Copy Markdown
Owner

Security fix (M1, medium): wt update fetched a script from the tip of main and executed it under sh.

The problems

src/update.ts originally ran:

const result = await $`curl -fsSL ${INSTALL_URL} | sh`
  .env({ ...(process.env as Record<string, string>), PREFIX: prefix })
  .nothrow();

1. No integrity check on the script itself. install.sh:56-70 carefully verifies the downloaded binary's SHA256 against SHA256SUMS — but the script performing that verification came from raw.githubusercontent.com/johnpangalos/wt/main/install.sh, executed unverified. Anyone who could alter main had code execution on every wt update, and the checksum logic is exactly what they would delete.

2. Partial-download execution. curl | sh hands bytes to the shell as they arrive, so a connection dropped mid-transfer executes a truncated script. set -eu (install.sh:8) does not help — a script cut off before the checksum comparison still runs everything above the cut.

3. Confirm/install mismatch (TOCTOU). cmdUpdate resolved the tag, printed wt vCURRENT → vLATEST, and prompted — then ran the installer without WT_VERSION, so it independently re-resolved releases/latest (install.sh:42-47). The user consented to one version and could receive another.

The fix

Two commits. The first hardened the shell-out (download to a private mkdtemp file, check curl's exit code, run sh <file>, pass WT_VERSION) — that addressed 2 and 3 but left main as a trust dependency. The second removes the shell-out entirely.

src/update.ts now does the update in-process: download → verify → atomic replace, driven by the tag the user confirmed at the prompt.

  • Asset resolution (assetName) from process.platform/process.arch, mirroring the platform gate in install.sh:28-33. Only wt-darwin-arm64 is published, so anything else fails with a clear message before any network access rather than guessing at an asset name.
  • Download of https://github.com/johnpangalos/wt/releases/download/<tag>/<asset> and .../SHA256SUMS via fetch(), with response.ok checked and the HTTP status surfaced in the error. After this change the only remaining shell-out in the file is gh api in fetchLatestTag().
  • Parsing (parseSha256Sums) matches <64 hex><two spaces|space-star><filename> and compares the filename for exact equality. A prefix/suffix match would accept the digest of a different asset whose name merely overlaps. A missing entry is a hard failure.
  • Verification with Bun.CryptoHasher("sha256"), compared case-insensitively. On mismatch it aborts with both hashes and installs nothing — the write/rename is physically after this branch, so no path reaches the rename with unverified bytes.
  • Staging in the target's own directory — .wt-update-<16 hex> next to the binary, not tmpdir(). rename(2) is only atomic within a filesystem, and ~/.local/bin is very often on a different mount from /tmp (EXDEV). The random suffix keeps the path unpredictable so it cannot be pre-created as a symlink. Written with mode 0o755 plus an explicit chmodSync (writeFileSync's mode is masked by umask).
  • Atomic replace via renameSync(tmp, target), where target is realpathSync(process.execPath) — replacing the real file rather than renaming over a symlink and silently detaching the user's wt from what it pointed at. If realpath throws (dangling or unreadable path) it falls back to process.execPath, since updating the path we were invoked as beats aborting; the reason is noted in a comment.
  • Cleanup of the staging file in a finally on every failure path (a no-op once the rename has consumed it).
  • Errors stay in the wt: ... voice. EACCES/EPERM/EROFS on write or rename report that the install dir is not writable and suggest re-running with permission or reinstalling under a writable prefix — that case previously came out of install.sh and does not regress into a stack trace.

install.sh is not modified — it is still required to bootstrap a machine with no wt on it. cmdUpdate's surrounding behavior is unchanged: fetchLatestTag(), the isNewer gate, promptYes(), the cache write on success, and the returned exit code. The prefix derived from dirname(dirname(process.execPath)) is gone along with its dead imports.

Residual risk (please read)

This removes main as a trust dependency and narrows the trusted surface to the release assets themselves. It does not make updates tamper-proof: a checksum proves transport integrity, not authenticity. Anyone who can publish a release can publish a SHA256SUMS that matches their own binary, and this code would accept it. Closing that requires signing or build attestation (e.g. actions/attest-build-provenance plus verification here). That is a separate decision and explicitly out of scope for this PR — .github/workflows/release-please.yml is untouched.

Tests

tests/update.test.ts (new) — 11 tests, all offline; fixtures are served from an in-process Bun.serve() on localhost and the base URL is injected through an options seam, so no test touches the real network. A dummy target file stands in for the installed binary; bin/wt is never replaced.

  • checksum mismatch → returns non-zero, target left byte-for-byte unchanged, no staging file beside it
  • valid checksum → target replaced with the new bytes, still mode-executable, no staging file left
  • asset absent from SHA256SUMS → refuses, target unchanged
  • HTTP error on the binary → refuses; HTTP error on SHA256SUMS → refuses
  • SHA256SUMS parsing → correct entry picked among wt-darwin-arm64.sig, xwt-darwin-arm64, wt-darwin-arm, wt-linux-x64; binary-mode ( *) lines and uppercase hex handled; malformed lines and absent assets return null
  • unsupported platform → refuses without downloading
  • assetName → darwin/x64, linux, and win32 each rejected with their own message

The mismatch test was confirmed non-vacuous: with the actual !== expected branch disabled the suite goes to 10 pass / 1 fail, and only that test fails.

bun run typecheck and bun run test pass (93 tests across 5 files).

Not covered: cmdUpdate's prompt path is still not exercised end-to-end — promptYes() requires /dev/tty, which the CLI test harness cannot supply. The real-target branch (realpathSync(process.execPath)) and the EACCES message are also untested; every test injects an explicit target, and asserting the permission error would need an unwritable directory (root-dependent and flaky in CI).

Not changed (optional follow-up)

README.md still recommends the curl … | sh one-liner for initial install. Left alone deliberately: a first install has no trust anchor on the machine yet, so it is a different risk profile from self-update, and whether to keep the one-liner is a product call.


🤖 Generated with Claude Code

claude added 2 commits August 14, 2026 09:32
…version

`wt update` piped install.sh straight into `sh`, which had three problems:

- The script that verifies the binary's SHA256 was itself fetched from
  `main` and executed unverified.
- `curl | sh` feeds bytes to the shell as they arrive, so a connection
  dropped mid-transfer executes a truncated script.
- The installer was run without WT_VERSION, so it re-resolved
  `releases/latest` on its own — the user could confirm one version and
  receive another.

Download the installer into a private mkdtemp dir first, check curl's exit
code, and only then run `sh <file>`, passing WT_VERSION alongside PREFIX so
the installer installs exactly the tag shown at the prompt. The temp dir is
removed in a finally block, and a failed download reports to stderr and
returns non-zero without executing anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pNadjVSQrkeMVKfaazeSV
…script

The previous commit hardened how install.sh was fetched and run, but `wt
update` still downloaded a script from the tip of `main` and executed it
under `sh`. Anyone able to push to `main` still had code execution on every
update, and the checksum logic in install.sh was the first thing they would
remove.

Replace the shell-out with an in-app download → verify → atomic replace:

- Resolve the asset from process.platform/arch, mirroring install.sh's
  platform gate; unsupported platforms fail before any download.
- Fetch the release asset and SHA256SUMS with fetch(), surfacing HTTP status
  in errors. `gh api` in fetchLatestTag() is now the only shell-out left.
- Parse SHA256SUMS with an exact filename match so a similarly named asset
  cannot supply the digest.
- Verify with Bun.CryptoHasher; a mismatch aborts loudly with both hashes and
  cannot reach the rename.
- Stage the new binary in the target's own directory (rename is only atomic
  within a filesystem; ~/.local/bin is often not on /tmp's mount), chmod 0755,
  then renameSync over the realpath of the running executable so a symlink is
  followed rather than replaced. EACCES/EPERM/EROFS report an unwritable
  install dir instead of a stack trace.
- Remove the staging file on every failure path.

install.sh is unchanged and still bootstraps machines with no wt installed.

Checksums prove transport integrity, not authenticity: whoever can publish a
release can publish a matching SHA256SUMS. Signing/attestation is out of
scope here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pNadjVSQrkeMVKfaazeSV
@johnpangalos johnpangalos changed the title fix(update): download installer to a temp file and pin the confirmed version fix(update): verify and install the release binary in-app instead of running a remote script Aug 14, 2026
johnpangalos and others added 2 commits September 20, 2026 19:34
…rity

Conflicts were in src/update.ts only, and both sides were additive:

- Imports: main's #59 fix reads the tty through openSync/readSync/
  writeSync/closeSync; this branch added chmodSync/realpathSync/
  renameSync/rmSync and randomBytes for the staged, verified install.
  Kept the union of both.
- cmdUpdate: main gained an args parameter with -y/--yes parsing; this
  branch replaced the `curl | sh` install with in-process download,
  SHA256 verification, and an atomic rename. Kept main's signature and
  flag parsing, and this branch's installRelease call site, which had
  already merged cleanly in the function body.

INSTALL_URL is gone with the curl pipeline it served.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…taller

The merge with main was textually clean past src/update.ts, but three
things main added still described the installer this branch deleted.

tests/cli.test.ts faked a `curl` that printed `echo INSTALLED` and
asserted the output contained INSTALLED. Nothing shells out to curl any
more, so on the Linux CI runner that test now stops at the macOS-only
platform gate and fails. It cannot assert a successful install either:
installRelease's base URL is the real release CDN and is deliberately
not overridable from the command line. Narrowed it to what it can still
prove end to end -- that --yes skips the prompt and does not demand a
terminal -- and left the download, checksum and replace paths to
update.test.ts, which drives them against a local server.

The e2e-macos job faked curl the same way and asserted INSTALLED. It now
asserts --yes reached the installer, which prints `installing wt <tag>`
before fetching anything; wt-v99.0.0 does not exist, so the download
that follows is expected to fail.

README still said update re-runs install.sh with the old PREFIX.
Replaced that with what update actually does now, including the limit of
what the checksum proves, and kept install.sh where it still applies:
bootstrapping a machine with no wt on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@johnpangalos
johnpangalos merged commit 4c3e4ee into main Sep 20, 2026
3 checks passed
johnpangalos added a commit that referenced this pull request Sep 21, 2026
)

skills.sh runs three security audits against the repo and the wt skill
fails two of them. Gen Agent Trust Hub returns HIGH/Fail on
REMOTE_CODE_EXECUTION, Snyk returns MEDIUM/Warn on W012, and both quote
the same line — README's

    curl -fsSL .../install.sh | sh

— saying the same thing about it: the pattern executes remote code
without giving the reader a chance to verify what it is first.

The runtime half of that complaint is already gone. #53 moved `wt
update` off "curl the installer from main and pipe it to sh" onto an
in-app download that checks the asset against SHA256SUMS before an
atomic rename, so the documented install was the last place the pattern
survived. (The audits ran at 11:31 today, hours before #53 landed, so
they still describe the old binary too.)

Lead instead with the flow install.sh already performs internally,
spelled out so a reader can run it and watch each step: resolve the tag,
download the asset and SHA256SUMS, verify, install. Keep install.sh as
the one-command path, documented as fetch-read-run rather than as a
pipe, and drop the piped invocation from its own usage header.

Also answer the third finding, INDIRECT_PROMPT_INJECTION, which reads
`wt list` as an ingestion surface whose values flow on into `wt switch`
with no stated boundary. The skill already said to treat output as data;
it now says where the boundary runs (all of wt's output, both formats),
what wt actually sanitizes (C0/DEL/C1 stripped from plain rows since
#55, and deliberately not from --json), and what sanitizing cannot fix —
so a branch name goes back as a single argument, never spliced into a
shell string.

Docs only; no source change. Checked the documented commands against a
local fake release: the checksum verifies, install lands the binary 755,
and a tampered asset exits nonzero.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants