Repository navigation
fix(update): verify and install the release binary in-app instead of running a remote script - #53
Merged
johnpangalos merged 4 commits intoSep 20, 2026
Conversation
…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
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security fix (M1, medium):
wt updatefetched a script from the tip ofmainand executed it undersh.The problems
src/update.tsoriginally ran:1. No integrity check on the script itself.
install.sh:56-70carefully verifies the downloaded binary's SHA256 againstSHA256SUMS— but the script performing that verification came fromraw.githubusercontent.com/johnpangalos/wt/main/install.sh, executed unverified. Anyone who could altermainhad code execution on everywt update, and the checksum logic is exactly what they would delete.2. Partial-download execution.
curl | shhands 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).
cmdUpdateresolved the tag, printedwt vCURRENT → vLATEST, and prompted — then ran the installer withoutWT_VERSION, so it independently re-resolvedreleases/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
mkdtempfile, check curl's exit code, runsh <file>, passWT_VERSION) — that addressed 2 and 3 but leftmainas a trust dependency. The second removes the shell-out entirely.src/update.tsnow does the update in-process: download → verify → atomic replace, driven by the tag the user confirmed at the prompt.assetName) fromprocess.platform/process.arch, mirroring the platform gate ininstall.sh:28-33. Onlywt-darwin-arm64is published, so anything else fails with a clear message before any network access rather than guessing at an asset name.https://github.com/johnpangalos/wt/releases/download/<tag>/<asset>and.../SHA256SUMSviafetch(), withresponse.okchecked and the HTTP status surfaced in the error. After this change the only remaining shell-out in the file isgh apiinfetchLatestTag().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.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..wt-update-<16 hex>next to the binary, nottmpdir().rename(2)is only atomic within a filesystem, and~/.local/binis 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 mode0o755plus an explicitchmodSync(writeFileSync's mode is masked by umask).renameSync(tmp, target), wheretargetisrealpathSync(process.execPath)— replacing the real file rather than renaming over a symlink and silently detaching the user'swtfrom what it pointed at. If realpath throws (dangling or unreadable path) it falls back toprocess.execPath, since updating the path we were invoked as beats aborting; the reason is noted in a comment.finallyon every failure path (a no-op once the rename has consumed it).wt: ...voice.EACCES/EPERM/EROFSon 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 ofinstall.shand does not regress into a stack trace.install.shis not modified — it is still required to bootstrap a machine with nowton it.cmdUpdate's surrounding behavior is unchanged:fetchLatestTag(), theisNewergate,promptYes(), the cache write on success, and the returned exit code. Theprefixderived fromdirname(dirname(process.execPath))is gone along with its dead imports.Residual risk (please read)
This removes
mainas 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 aSHA256SUMSthat matches their own binary, and this code would accept it. Closing that requires signing or build attestation (e.g.actions/attest-build-provenanceplus verification here). That is a separate decision and explicitly out of scope for this PR —.github/workflows/release-please.ymlis untouched.Tests
tests/update.test.ts(new) — 11 tests, all offline; fixtures are served from an in-processBun.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/wtis never replaced.SHA256SUMS→ refuses, target unchangedSHA256SUMS→ refusesSHA256SUMSparsing → correct entry picked amongwt-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 nullassetName→ darwin/x64, linux, and win32 each rejected with their own messageThe mismatch test was confirmed non-vacuous: with the
actual !== expectedbranch disabled the suite goes to 10 pass / 1 fail, and only that test fails.bun run typecheckandbun run testpass (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 theEACCESmessage 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.mdstill recommends thecurl … | shone-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