diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index bcb5f178..6e3e540d 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -187,11 +187,14 @@ If anything is missing, fix it before proceeding to Phase 4. Common fixes: 3. **The pipeline handles everything else:** - Runs tests - - Cross-compiles binaries for 5 platforms (macOS ARM64/x64, Linux x64/ARM64, Windows x64) - - Compiles paste service binaries (same 5 platforms) + - Cross-compiles binaries for 6 platforms (macOS ARM64/x64, Linux x64/ARM64, Windows x64/ARM64) + - Compiles paste service binaries (same 6 platforms) + - Generates SLSA build provenance attestations for all 12 binaries via `actions/attest-build-provenance` (signed through Sigstore, recorded in Rekor) - Creates the GitHub Release with all binaries attached - Publishes `@plannotator/opencode` and `@plannotator/pi-extension` to npm with provenance + **Note on immutable releases:** The repo has GitHub Immutable Releases enabled, so once the `v*` tag is pushed and the release is created, the tag→commit and tag→asset bindings are permanent. You cannot delete and re-create a tag to "fix" a bad release — you must ship a new version. Release notes remain editable (see step 5), but everything else is locked. + 4. **Monitor the pipeline:** Watch the release workflow run until it completes: ```bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e722713d..c2d47dee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,8 +15,7 @@ on: default: true permissions: - contents: write - id-token: write + contents: read env: DRY_RUN: ${{ !(startsWith(github.ref, 'refs/tags/') || inputs.dry-run == 'false') }} @@ -47,6 +46,15 @@ jobs: build: needs: test runs-on: ubuntu-latest + # Build job has NO id-token / attestations permissions. Compilation + # itself doesn't need OIDC minting — those capabilities live in the + # separate `attest` job below, which only runs on tag pushes. This + # ensures PR dry-runs (which exercise `bun install` + compile) never + # have OIDC minting available, closing the narrow-but-real + # "trusted-contributor compromise lets a malicious build step mint + # a repo-identity OIDC token" attack surface. + permissions: + contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -85,6 +93,10 @@ jobs: bun build apps/hook/server/index.ts --compile --target=bun-windows-x64 --outfile plannotator-win32-x64.exe sha256sum plannotator-win32-x64.exe > plannotator-win32-x64.exe.sha256 + # Windows ARM64 (native, via bun-windows-arm64 — stable since Bun v1.3.10) + bun build apps/hook/server/index.ts --compile --target=bun-windows-arm64 --outfile plannotator-win32-arm64.exe + sha256sum plannotator-win32-arm64.exe > plannotator-win32-arm64.exe.sha256 + # Paste service binaries bun build apps/paste-service/targets/bun.ts --compile --target=bun-darwin-arm64 --outfile plannotator-paste-darwin-arm64 sha256sum plannotator-paste-darwin-arm64 > plannotator-paste-darwin-arm64.sha256 @@ -101,6 +113,9 @@ jobs: bun build apps/paste-service/targets/bun.ts --compile --target=bun-windows-x64 --outfile plannotator-paste-win32-x64.exe sha256sum plannotator-paste-win32-x64.exe > plannotator-paste-win32-x64.exe.sha256 + bun build apps/paste-service/targets/bun.ts --compile --target=bun-windows-arm64 --outfile plannotator-paste-win32-arm64.exe + sha256sum plannotator-paste-win32-arm64.exe > plannotator-paste-win32-arm64.exe.sha256 + - name: Upload artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: @@ -109,10 +124,55 @@ jobs: plannotator-* !*.ts - release: + attest: + # Isolated attestation job — runs on tag pushes only and holds the + # OIDC minting + attestations-write capabilities that the build job + # used to have. Splitting this out means PR builds and non-tag pushes + # never get id-token: write granted, closing the trusted-contributor + # compromise window where a malicious build step could mint a + # repo-identity OIDC token. The attestation is produced against the + # same binaries the build job uploaded; attest-build-provenance + # publishes the signed bundle to GitHub's attestation store, so the + # release job downstream doesn't need any new artifact handling. needs: build if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + + steps: + - name: Download binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: binaries + + - name: Generate SLSA build provenance attestation + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + plannotator-darwin-arm64 + plannotator-darwin-x64 + plannotator-linux-x64 + plannotator-linux-arm64 + plannotator-win32-x64.exe + plannotator-win32-arm64.exe + plannotator-paste-darwin-arm64 + plannotator-paste-darwin-x64 + plannotator-paste-linux-x64 + plannotator-paste-linux-arm64 + plannotator-paste-win32-x64.exe + plannotator-paste-win32-arm64.exe + + release: + # Depends on `attest` so the signed provenance exists before the + # GitHub Release is published — otherwise there'd be a window where + # users could pull the binary and `gh attestation verify` would + # race-fail. `needs: attest` implicitly requires `build` too. + needs: attest + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e27092a3..d319f940 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,3 +30,270 @@ jobs: - name: Run tests run: bun test + + install-cmd-windows: + # End-to-end integration test for scripts/install.cmd on real cmd.exe. + # The unit tests in scripts/install.test.ts are file-content string checks + # that run on Linux and never exercise cmd's delayed-expansion parser or + # the embedded `node -e` Gemini merge — exactly where issue #506 lived. + # This job runs install.cmd end-to-end on Windows with a seeded ~/.gemini + # settings.json fixture so the Gemini merge path actually executes and + # any regression of #506 (or similar cmd-parser bugs) fails CI. + name: install.cmd (Windows integration) + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Seed fake ~/.gemini/settings.json with pre-existing hook + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini" | Out-Null + # Fixture mirrors the shape of a real Gemini settings.json (top-level + # `hooks.BeforeTool` array plus unrelated sibling keys) but uses only + # obviously-fake values. Must NOT contain the literal string + # "plannotator" anywhere — install.cmd's Gemini block is gated on + # `findstr /c:"plannotator"` returning non-zero and would otherwise + # skip the merge entirely. + $fixture = @' + { + "theme": "ci-fixture-theme", + "hooks": { + "BeforeTool": [ + { + "matcher": "ci-fixture-existing-matcher", + "hooks": [ + { + "type": "command", + "command": "ci-fixture-existing-command", + "timeout": 1000 + } + ] + } + ] + }, + "general": { + "ciFixtureSentinel": true + } + } + '@ + Set-Content -Path "$env:USERPROFILE\.gemini\settings.json" -Value $fixture -NoNewline + + - name: Run install.cmd end-to-end + shell: cmd + # v0.17.1 is pinned intentionally. This test needs a real binary + # to download so it can exercise install.cmd end-to-end — SHA256 + # verification, skills sparse-checkout, and (critically) the + # embedded `node -e` Gemini merge path that was the site of + # issue #506. Using `latest` would couple the Windows regression + # test to whatever version is currently released, so a bad + # release would retroactively break CI on every branch. + # + # v0.17.1 was the current release when the test was added and is + # locked in place by GitHub Immutable Releases. If you ever need + # to bump it (e.g. this version becomes too old to represent the + # install flow we care about), verify the replacement has: + # - plannotator-win32-x64.exe attached + # - plannotator-win32-x64.exe.sha256 attached + # - the install.cmd and packages/shared/ layout your branch + # under test expects to find in apps/skills/ + # A missing or mismatched release asset surfaces as a curl 404 + # in this step with no obvious connection to the test's purpose. + run: scripts\install.cmd v0.17.1 --skip-attestation + + - name: Verify Gemini settings.json was merged correctly + shell: pwsh + run: | + $path = "$env:USERPROFILE\.gemini\settings.json" + if (-not (Test-Path $path)) { throw "settings.json missing after install" } + + # Must still parse as JSON after the merge (regression for #506, + # where cmd's delayed expansion corrupted the embedded node script + # and left settings.json in a broken state). + $s = Get-Content $path -Raw | ConvertFrom-Json + + # The plannotator hook must have been added. + $plannotatorEntries = $s.hooks.BeforeTool | Where-Object { + $_.matcher -eq 'exit_plan_mode' + } + if (-not $plannotatorEntries) { + throw "plannotator hook was not added to BeforeTool" + } + $planCmd = $plannotatorEntries[0].hooks | Where-Object { + $_.command -eq 'plannotator' + } + if (-not $planCmd) { + throw "plannotator command entry missing inside the new hook" + } + + # The pre-existing (fixture) hook must have survived the merge. + # The original buggy JS was `if(!s.hooks.BeforeTool)s.hooks.BeforeTool=[]` + # which — after cmd ate the `!` — wiped existing arrays. The fix + # (`s.hooks.BeforeTool = s.hooks.BeforeTool || []`) must preserve them. + $fixtureHook = $s.hooks.BeforeTool | Where-Object { + $_.matcher -eq 'ci-fixture-existing-matcher' + } + if (-not $fixtureHook) { + throw "pre-existing hook was wiped — merge clobbered existing data" + } + + # Unrelated top-level keys must survive the merge. + if ($s.theme -ne 'ci-fixture-theme') { + throw "unrelated top-level field 'theme' was mangled" + } + if ($s.general.ciFixtureSentinel -ne $true) { + throw "unrelated top-level field 'general' was mangled" + } + + Write-Host "✓ Gemini settings.json merge verified (issue #506 regression guard)" + + - name: Attestation pre-flight rejects v0.17.1 on real cmd.exe + shell: pwsh + run: | + # Regression guard: the main feature of this PR (three-layer + # verification opt-in + MIN_ATTESTED_VERSION pre-flight + + # injection-safe $env:-based PowerShell version comparison) had + # no runtime coverage on Windows because the previous + # integration step passes --skip-attestation. + # + # We can't test the SUCCESS path (valid attested release) + # because v0.17.1 is the current latest and it predates the + # release.yml attestation step. Until the first post-merge + # release exists, the only realistic end-to-end test is the + # REJECTION path: invoke install.cmd with --verify-attestation + # against v0.17.1 and assert the pre-flight rejects with + # exit != 0 and stderr containing "predates". + # + # This exercises on a real cmd.exe: + # - setlocal enabledelayedexpansion parser under the guard + # - three-layer resolution reaching the CLI flag layer + # - the :~1 substring (instead of :v= global substitution) + # - the pre-release tag detection (negative — v0.17.1 is stable) + # - the PowerShell shell-out with $env:TAG_NUM / $env:MIN_NUM + # (injection-safe — the previous interpolation would have + # allowed arbitrary PS execution via --version) + # - the `[version] -ge` comparison returning false + # - the "predates" error message block + $installedBinary = "$env:USERPROFILE\.local\bin\plannotator.exe" + + # Capture the currently-installed binary's hash BEFORE running + # the rejection test. The earlier Gemini-merge integration step + # installed v0.17.1 at this path; we use the captured hash as + # a baseline so we can prove the rejected invocation left it + # untouched (no wasted download, no overwrite). + if (-not (Test-Path $installedBinary)) { + throw "Expected $installedBinary to exist from the earlier install.cmd step, but it's missing. Cannot baseline the preservation check." + } + $baselineHash = (Get-FileHash $installedBinary -Algorithm SHA256).Hash + $baselineWriteTime = (Get-Item $installedBinary).LastWriteTime + + $stderrFile = New-TemporaryFile + $p = Start-Process -Wait -PassThru -NoNewWindow cmd ` + -ArgumentList '/c','scripts\install.cmd v0.17.1 --verify-attestation' ` + -RedirectStandardError $stderrFile.FullName + $stderr = Get-Content $stderrFile.FullName -Raw -ErrorAction SilentlyContinue + Remove-Item $stderrFile.FullName -ErrorAction SilentlyContinue + + if ($p.ExitCode -eq 0) { + throw "install.cmd v0.17.1 --verify-attestation should have been rejected by the MIN_ATTESTED_VERSION pre-flight, but exited 0. stderr: $stderr" + } + if ($stderr -notmatch 'predates') { + throw "install.cmd rejected with exit $($p.ExitCode), but not via the pre-flight guard. Expected 'predates' in stderr, got: $stderr" + } + + # Assert the pre-flight ran BEFORE any download / install step. + # If the binary's hash or mtime changed, something downloaded + # and moved a new file into place — meaning the pre-flight + # rejection happened late (after the download step) instead + # of early (before it). Catches future regressions that re- + # introduce the post-download pre-flight pattern. + $postHash = (Get-FileHash $installedBinary -Algorithm SHA256).Hash + $postWriteTime = (Get-Item $installedBinary).LastWriteTime + if ($postHash -ne $baselineHash) { + throw "Binary at $installedBinary was overwritten during the rejected --verify-attestation run. Baseline SHA256 $baselineHash, post SHA256 $postHash. The pre-flight must run before any download." + } + if ($postWriteTime -ne $baselineWriteTime) { + throw "Binary at $installedBinary had its LastWriteTime modified during the rejected --verify-attestation run. Baseline $baselineWriteTime, post $postWriteTime." + } + + Write-Host "✓ MIN_ATTESTED_VERSION pre-flight rejected v0.17.1 via the expected code path" + Write-Host "✓ Installed binary was preserved (SHA256 and LastWriteTime both unchanged)" + + - name: Verify Claude Code slash command files contain the shell-invocation prefix + shell: pwsh + run: | + # Regression guard: install.cmd previously wrote `echo !`plannotator + # review $ARGUMENTS`` (note the unescaped `!`) under + # setlocal enabledelayedexpansion, which silently stripped the `!` + # from the written .md file. Without the `!` prefix, Claude Code + # renders the backtick block as inline markdown code and the slash + # command is a silent no-op when invoked — the install appears + # successful but the command does nothing. + # + # install.sh and install.ps1 already write the `!` correctly via + # single-quoted heredocs / here-strings. This step checks that + # install.cmd now matches (via `echo ^!`) and catches future + # regressions of the same class. + # Claude Code slash commands — `.md` files with `!`\`plannotator ...\`` invocation syntax + $cmdDir = "$env:USERPROFILE\.claude\commands" + foreach ($file in @("plannotator-review.md", "plannotator-annotate.md", "plannotator-last.md")) { + $path = Join-Path $cmdDir $file + if (-not (Test-Path $path)) { + throw "Expected slash command file missing: $path" + } + $content = Get-Content $path -Raw + if ($content -notmatch '!`plannotator') { + throw "Slash command file $file is missing the '!' shell-invocation prefix. Content: $content" + } + } + Write-Host "✓ All three Claude Code slash command files contain the '!' prefix" + + # Gemini slash commands — `.toml` files with `!{plannotator ...}` invocation syntax. + # Same `^^!` cmd-escape class as the Claude Code files. The earlier integration + # step seeded `~/.gemini/settings.json`, so install.cmd's Gemini block fired and + # wrote these two files alongside the Claude Code ones. They use a different + # invocation form (`!{...}` instead of `!\`...\``) but the regression risk is + # identical — a future revision that drops a `^` from the echo would silently + # produce broken Gemini commands. + $geminiDir = "$env:USERPROFILE\.gemini\commands" + foreach ($file in @("plannotator-review.toml", "plannotator-annotate.toml")) { + $path = Join-Path $geminiDir $file + if (-not (Test-Path $path)) { + throw "Expected Gemini command file missing: $path" + } + $content = Get-Content $path -Raw + if ($content -notmatch '!\{plannotator') { + throw "Gemini command file $file is missing the '!' shell-invocation prefix. Content: $content" + } + } + Write-Host "✓ Both Gemini slash command files contain the '!' prefix" + + - name: Unknown flag is rejected with non-zero exit + shell: pwsh + run: | + # Regression guard for the review finding that install.cmd silently + # reinterpreted typoed flags as version strings. A leading-dash token + # that doesn't match a known flag must now produce a non-zero exit + # AND emit "Unknown option:" on stderr — the latter is the real + # discriminator between the guard triggering and some other failure + # mode (network, gh auth, pre-PR release without an attestation) + # that also happens to exit non-zero. + # + # `--verify-attesttion` below is INTENTIONALLY MISSPELLED. Do not + # "correct" it during a typo sweep — the valid spelling is a real + # flag and would bypass this guard. The stderr assertion below + # would catch the drift, but the comment is the first line of + # defense for future maintainers. + $stderrFile = New-TemporaryFile + $p = Start-Process -Wait -PassThru -NoNewWindow cmd ` + -ArgumentList '/c','scripts\install.cmd --verify-attesttion' ` + -RedirectStandardError $stderrFile.FullName + $stderr = Get-Content $stderrFile.FullName -Raw -ErrorAction SilentlyContinue + Remove-Item $stderrFile.FullName -ErrorAction SilentlyContinue + + if ($p.ExitCode -eq 0) { + throw "install.cmd should have rejected --verify-attesttion but exited 0. stderr: $stderr" + } + if ($stderr -notmatch 'Unknown option:') { + throw "install.cmd exited $($p.ExitCode) but not via the unknown-flag guard. Expected 'Unknown option:' in stderr but got: $stderr" + } + Write-Host "✓ Unknown flag rejected with exit code $($p.ExitCode) via the unknown-flag guard" diff --git a/AGENTS.md b/AGENTS.md index 3914db3c..71bae757 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,7 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely. Default: enabled. | | `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. | | `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. | +| `PLANNOTATOR_VERIFY_ATTESTATION` | **Read by the install scripts only**, not by the runtime binary. Set to `1` / `true` to have `scripts/install.sh` / `install.ps1` / `install.cmd` run `gh attestation verify` on every install. Off by default. Can also be set persistently via `~/.plannotator/config.json` (`{ "verifyAttestation": true }`) or per-invocation via `--verify-attestation`. Requires `gh` installed and authenticated. | **Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected when `PLANNOTATOR_REMOTE` is unset. Set `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `0` / `false` to force local mode. diff --git a/README.md b/README.md index 9464e0ef..4b86e2a4 100644 --- a/README.md +++ b/README.md @@ -66,15 +66,25 @@ Plannotator lets you privately share plans, annotations, and feedback with colle **macOS / Linux / WSL:** ```bash +# Latest release curl -fsSL https://plannotator.ai/install.sh | bash + +# Pin to a specific reviewed version +curl -fsSL https://plannotator.ai/install.sh | bash -s -- --version vX.Y.Z ``` **Windows PowerShell:** ```powershell +# Latest release irm https://plannotator.ai/install.ps1 | iex + +# Pin to a specific reviewed version +& ([scriptblock]::Create((irm https://plannotator.ai/install.ps1))) -Version vX.Y.Z ``` +Every released binary ships with a SHA256 sidecar (verified automatically on every install). Version pinning, native ARM64 Windows, and [SLSA provenance](https://slsa.dev/) are supported from v0.17.2 onwards — see the [installation docs](https://plannotator.ai/docs/getting-started/installation/#verifying-your-install) for details. + **Then in Claude Code:** ``` diff --git a/apps/hook/README.md b/apps/hook/README.md index 2f16ea0c..c22485fc 100644 --- a/apps/hook/README.md +++ b/apps/hook/README.md @@ -8,19 +8,32 @@ Install the `plannotator` command so Claude Code can use it: **macOS / Linux / WSL:** ```bash +# Latest release curl -fsSL https://plannotator.ai/install.sh | bash + +# Pin to a specific reviewed version +curl -fsSL https://plannotator.ai/install.sh | bash -s -- --version vX.Y.Z ``` **Windows PowerShell:** ```powershell +# Latest release irm https://plannotator.ai/install.ps1 | iex + +# Pin to a specific reviewed version +& ([scriptblock]::Create((irm https://plannotator.ai/install.ps1))) -Version vX.Y.Z ``` **Windows CMD:** ```cmd curl -fsSL https://plannotator.ai/install.cmd -o install.cmd && install.cmd && del install.cmd + +REM Pin to a specific reviewed version +curl -fsSL https://plannotator.ai/install.cmd -o install.cmd && install.cmd --version vX.Y.Z && del install.cmd ``` +Released binaries ship with SHA256 sidecars and [SLSA build provenance](https://slsa.dev/) attestations from v0.17.2 onwards. See the [installation docs](https://plannotator.ai/docs/getting-started/installation/#verifying-your-install) for the supported-version matrix and verification commands. + --- [Plugin Installation](#plugin-installation) · [Manual Installation (Hooks)](#manual-installation-hooks) · [Obsidian Integration](#obsidian-integration) diff --git a/apps/marketing/src/content/docs/getting-started/installation.md b/apps/marketing/src/content/docs/getting-started/installation.md index 28eb4f2b..be217855 100644 --- a/apps/marketing/src/content/docs/getting-started/installation.md +++ b/apps/marketing/src/content/docs/getting-started/installation.md @@ -15,23 +15,96 @@ Install the `plannotator` command so your agent can use it. **macOS / Linux / WSL:** ```bash +# Latest release curl -fsSL https://plannotator.ai/install.sh | bash + +# Pin to a specific reviewed version +curl -fsSL https://plannotator.ai/install.sh | bash -s -- --version vX.Y.Z ``` **Windows PowerShell:** ```powershell +# Latest release irm https://plannotator.ai/install.ps1 | iex + +# Pin to a specific reviewed version +& ([scriptblock]::Create((irm https://plannotator.ai/install.ps1))) -Version vX.Y.Z ``` **Windows CMD:** ```cmd curl -fsSL https://plannotator.ai/install.cmd -o install.cmd && install.cmd && del install.cmd + +REM Pin to a specific reviewed version +curl -fsSL https://plannotator.ai/install.cmd -o install.cmd && install.cmd --version vX.Y.Z && del install.cmd ``` The install script respects `CLAUDE_CONFIG_DIR` if set, placing hooks in your custom config directory instead of `~/.claude`. +**Supported versions:** version pinning is fully supported from **v0.17.2 onwards**. v0.17.2 is the first release to ship native ARM64 Windows binaries and SLSA build-provenance attestations; earlier tags were published before either existed. Pinning to a pre-v0.17.2 tag may work for default installs on macOS, Linux, and x64 Windows, but: + +- ARM64 Windows hosts will get a 404 (no native ARM64 binary exists in older releases). +- Provenance verification (`--verify-attestation` and friends) will be rejected by the installer's pre-flight floor. + +If you need a specific pre-v0.17.2 version, install without `--version` and `--verify-attestation` flags; otherwise, pin to v0.17.2 or later. + +### Verifying your install + +Every released binary is accompanied by a SHA256 sidecar (verified automatically on every install) and a [SLSA build provenance](https://slsa.dev/) attestation signed via Sigstore and recorded in the public transparency log. The SHA256 check is mandatory and always runs. Provenance verification is **optional** — it's only needed if you want a cryptographic link from the binary back to the exact commit and workflow run that built it. + +**Manual verification (recommended for one-off audits):** + +This requires the [GitHub CLI](https://cli.github.com) to be installed and authenticated (`gh auth login`). Replace `vX.Y.Z` with the tag of the version you installed — pinning the source ref and signer workflow is what gives you the "exact commit and workflow run" guarantee described above; `--repo` alone only proves the artifact was built by _some_ workflow in our repository. + +```bash +# macOS / Linux +gh attestation verify ~/.local/bin/plannotator \ + --repo backnotprop/plannotator \ + --source-ref refs/tags/vX.Y.Z \ + --signer-workflow backnotprop/plannotator/.github/workflows/release.yml + +# Windows (PowerShell installer) +gh attestation verify "$env:LOCALAPPDATA\plannotator\plannotator.exe" ` + --repo backnotprop/plannotator ` + --source-ref refs/tags/vX.Y.Z ` + --signer-workflow backnotprop/plannotator/.github/workflows/release.yml + +# Windows (cmd installer) +gh attestation verify "%USERPROFILE%\.local\bin\plannotator.exe" ^ + --repo backnotprop/plannotator ^ + --source-ref refs/tags/vX.Y.Z ^ + --signer-workflow backnotprop/plannotator/.github/workflows/release.yml +``` + +For air-gapped or no-auth environments, see GitHub's docs on [verifying attestations offline](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/verifying-attestations-offline) (uses `gh attestation download` to fetch the bundle once, then verifies offline against it). + +**Automatic verification during install/upgrade (opt-in):** + +Provenance verification is **off by default** in the installer — the same default every major `curl | bash` installer uses (rustup, brew, bun, deno, helm). SHA256 verification always runs. To have the installer additionally run `gh attestation verify` on every upgrade, enable it via any of the three mechanisms below. Precedence is CLI flag > env var > config file > default. + +1. **Per-install flag** (one-shot, explicit): + ```bash + curl -fsSL https://plannotator.ai/install.sh | bash -s -- --verify-attestation + ``` + PowerShell: `... -VerifyAttestation`. Windows cmd: `install.cmd --verify-attestation`. + +2. **Environment variable** (persist in your shell RC): + ```bash + export PLANNOTATOR_VERIFY_ATTESTATION=1 + ``` + Scoped to whichever shell sessions export it. Follows the same `PLANNOTATOR_*` convention as `PLANNOTATOR_REMOTE`, `PLANNOTATOR_PORT`, etc. + +3. **Config file** (persist shell-agnostic): + ```bash + mkdir -p ~/.plannotator + echo '{ "verifyAttestation": true }' > ~/.plannotator/config.json + ``` + Or merge into an existing `~/.plannotator/config.json`. This applies regardless of which shell launches the installer — useful for GUI-launched terminals on macOS or `install.cmd` run from Explorer on Windows. Managed easily by dotfiles / Ansible / other provisioning tools. + +When enabled, the installer requires `gh` CLI installed and authenticated (`gh auth login`). If `gh` is missing or the check fails, the install hard-fails so you don't silently skip verification you asked for. To force-skip for a single install, pass `--skip-attestation` (bash/cmd) or `-SkipAttestation` (PowerShell). + ## Claude Code ### Plugin marketplace (recommended) diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 94e1a742..5aed0b0a 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -34,6 +34,14 @@ export interface PlannotatorConfig { conventionalComments?: boolean; /** null = explicitly cleared (use defaults), undefined = not set */ conventionalLabels?: CCLabelConfig[] | null; + /** + * Enable `gh attestation verify` during CLI installation/upgrade. + * Read by scripts/install.sh|ps1|cmd on every run (not by any runtime code). + * When true, the installer runs build-provenance verification after the + * SHA256 checksum check; requires `gh` CLI installed and authenticated + * (`gh auth login`). OS-level opt-in only — no UI surface. Default: false. + */ + verifyAttestation?: boolean; } const CONFIG_DIR = join(homedir(), ".plannotator"); diff --git a/scripts/install.cmd b/scripts/install.cmd index 07265ad8..052eea4f 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -3,24 +3,109 @@ setlocal enabledelayedexpansion REM Plannotator Windows CMD Bootstrap Script -REM Parse command line argument +REM Parse command line arguments +set "VERSION=latest" +REM Tracks whether a version was explicitly set via --version or positional. +REM Used to reject mixing --version with a stray positional token. +set "VERSION_EXPLICIT=0" +REM Three-layer opt-in for SLSA provenance verification. +REM Precedence: CLI flag > env var > %USERPROFILE%\.plannotator\config.json > default. +REM -1 = flag not set (fall through); 0 = disable; 1 = enable. +set "VERIFY_ATTESTATION_FLAG=-1" + +:parse_args +if "%~1"=="" goto args_done +if /i "%~1"=="--version" ( + if "%~2"=="" ( + echo --version requires an argument >&2 + exit /b 1 + ) + REM Reject dash-prefixed values — prevents `install.cmd --version + REM --skip-attestation` from silently setting VERSION=--skip-attestation. + set "NEXT_ARG=%~2" + if "!NEXT_ARG:~0,1!"=="-" ( + echo --version requires a tag value, got flag: "%~2" >&2 + exit /b 1 + ) + set "VERSION=%~2" + set "VERSION_EXPLICIT=1" + shift + shift + goto parse_args +) +if /i "%~1"=="--verify-attestation" ( + if "!VERIFY_ATTESTATION_FLAG!"=="0" ( + echo --verify-attestation and --skip-attestation are mutually exclusive >&2 + exit /b 1 + ) + set "VERIFY_ATTESTATION_FLAG=1" + shift + goto parse_args +) +if /i "%~1"=="--skip-attestation" ( + if "!VERIFY_ATTESTATION_FLAG!"=="1" ( + echo --skip-attestation and --verify-attestation are mutually exclusive >&2 + exit /b 1 + ) + set "VERIFY_ATTESTATION_FLAG=0" + shift + goto parse_args +) +REM Reject any other dash-prefixed token as an unknown option, so a typoed +REM flag like --verify-attesttion fails fast instead of being interpreted as +REM a version tag (which would 404 on releases/download/v--verify-attesttion/...). +REM +REM Uses a variable-assigned substring test instead of `echo %~1 | findstr` +REM because unquoted %~1 in an echo pipe lets cmd.exe interpret shell +REM metacharacters (& | > <) in the argument before the pipe runs. Assigning +REM to a `set "VAR=%~1"` literal-quoted form preserves metacharacters safely, +REM and delayed-expansion substring (!VAR:~0,1!) avoids the subprocess entirely. +REM The error-message echo also quotes "%~1" for the same reason — echoing an +REM unquoted arg containing `&` would re-trigger metacharacter interpretation. +set "CURRENT_ARG=%~1" +if "!CURRENT_ARG:~0,1!"=="-" ( + echo Unknown option: "%~1" >&2 + echo Usage: install.cmd [--version ^] [--verify-attestation ^| --skip-attestation] >&2 + exit /b 1 +) +REM Positional form: install.cmd vX.Y.Z (legacy interface). +REM Reject if --version was already passed — silent overwrite is worse +REM than a clean usage error. +if "!VERSION_EXPLICIT!"=="1" ( + echo Unexpected positional argument: "%~1" ^(version already set^) >&2 + exit /b 1 +) set "VERSION=%~1" -if "!VERSION!"=="" set "VERSION=latest" +set "VERSION_EXPLICIT=1" +shift +goto parse_args +:args_done set "REPO=backnotprop/plannotator" set "INSTALL_DIR=%USERPROFILE%\.local\bin" -set "PLATFORM=win32-x64" -REM Check for 64-bit Windows -if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" goto :arch_valid -if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" goto :arch_valid -if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" goto :arch_valid -if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" goto :arch_valid - -echo Plannotator does not support 32-bit Windows. >&2 -exit /b 1 - -:arch_valid +REM First plannotator release that carries SLSA build-provenance attestations. +REM See scripts/install.sh for the full explanation — this constant is +REM bumped once at the first attested release via the release skill. +set "MIN_ATTESTED_VERSION=v0.17.2" + +REM Detect architecture. Native ARM64 Windows binaries are built from +REM bun-windows-arm64 (stable since Bun v1.3.10), so ARM64 hosts get a +REM native binary — no Windows x86-64 emulation tax. PROCESSOR_ARCHITECTURE +REM reports the architecture the current cmd.exe process is running under; +REM PROCESSOR_ARCHITEW6432 is set only in 32-bit processes running via +REM WoW64 and reflects the host architecture (covers the edge case of a +REM 32-bit tool launching install.cmd on an ARM64 machine). +set "PLATFORM=" +if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "PLATFORM=win32-x64" +if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" set "PLATFORM=win32-arm64" +if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" set "PLATFORM=win32-x64" +if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" set "PLATFORM=win32-arm64" + +if "!PLATFORM!"=="" ( + echo Plannotator does not support 32-bit Windows. >&2 + exit /b 1 +) REM Check for curl availability curl --version >nul 2>&1 @@ -36,20 +121,23 @@ REM Get version to install if /i "!VERSION!"=="latest" ( echo Fetching latest version... - REM Download release info and extract tag_name - curl -fsSL "https://api.github.com/repos/!REPO!/releases/latest" -o "%TEMP%\release.json" + REM Download release info to a randomized temp file so concurrent + REM invocations don't collide and a same-user pre-placed symlink at + REM a predictable path can't redirect curl's output. + set "RELEASE_JSON=%TEMP%\plannotator-release-%RANDOM%.json" + curl -fsSL "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!" if !ERRORLEVEL! neq 0 ( echo Failed to get latest version >&2 exit /b 1 ) REM Extract tag_name from JSON - for /f "tokens=2 delims=:," %%i in ('findstr /c:"\"tag_name\"" "%TEMP%\release.json"') do ( + for /f "tokens=2 delims=:," %%i in ('findstr /c:"\"tag_name\"" "!RELEASE_JSON!"') do ( set "TAG=%%i" set "TAG=!TAG: =!" set "TAG=!TAG:"=!" ) - del "%TEMP%\release.json" + del "!RELEASE_JSON!" if "!TAG!"=="" ( echo Failed to parse version >&2 @@ -57,19 +145,103 @@ if /i "!VERSION!"=="latest" ( ) ) else ( set "TAG=!VERSION!" - REM Add v prefix if not present - echo !TAG! | findstr /b "v" >nul - if !ERRORLEVEL! neq 0 set "TAG=v!TAG!" + REM Add v prefix if not present. Use a substring test rather than + REM piping the expanded variable through findstr — an unquoted echo + REM pipe re-exposes cmd metacharacters (& | > <) in the value before + REM the pipe runs. Matches the safe pattern used in the arg parser. + if not "!TAG:~0,1!"=="v" set "TAG=v!TAG!" ) echo Installing plannotator !TAG!... +REM Resolve SLSA build-provenance verification opt-in BEFORE the download so +REM we can fail fast without wasting bandwidth if the requested tag predates +REM provenance support. Precedence: CLI flag > env var > config.json > default. +set "VERIFY_ATTESTATION=0" + +REM Layer 3: config file (lowest precedence of the opt-in sources). +if exist "%USERPROFILE%\.plannotator\config.json" ( + findstr /r /c:"\"verifyAttestation\"[ ]*:[ ]*true" "%USERPROFILE%\.plannotator\config.json" >nul 2>&1 + if !ERRORLEVEL! equ 0 set "VERIFY_ATTESTATION=1" +) + +REM Layer 2: env var (overrides config file). +if /i "!PLANNOTATOR_VERIFY_ATTESTATION!"=="1" set "VERIFY_ATTESTATION=1" +if /i "!PLANNOTATOR_VERIFY_ATTESTATION!"=="true" set "VERIFY_ATTESTATION=1" +if /i "!PLANNOTATOR_VERIFY_ATTESTATION!"=="yes" set "VERIFY_ATTESTATION=1" +if /i "!PLANNOTATOR_VERIFY_ATTESTATION!"=="0" set "VERIFY_ATTESTATION=0" +if /i "!PLANNOTATOR_VERIFY_ATTESTATION!"=="false" set "VERIFY_ATTESTATION=0" +if /i "!PLANNOTATOR_VERIFY_ATTESTATION!"=="no" set "VERIFY_ATTESTATION=0" + +REM Layer 1: CLI flag (overrides everything). +if "!VERIFY_ATTESTATION_FLAG!"=="1" set "VERIFY_ATTESTATION=1" +if "!VERIFY_ATTESTATION_FLAG!"=="0" set "VERIFY_ATTESTATION=0" + +REM Pre-flight: reject verification requests for tags older than the first +REM attested release BEFORE downloading. Critical security point: the version +REM comparison uses $env:TAG_NUM / $env:MIN_NUM instead of interpolating +REM !TAG_NUM! / !MIN_NUM! into the PowerShell command string. Interpolation +REM would let a crafted --version value break out of the single-quoted literal +REM and execute arbitrary PowerShell (e.g. --version "0.18.0'; calc; '0.18.0" +REM would run Calculator). $env: reads the raw string; PowerShell never parses +REM the value as code. [version] cast throws on invalid input, catch swallows, +REM VERSION_OK stays empty, and the guard rejects — safe fail. +if "!VERIFY_ATTESTATION!"=="1" ( + REM Strip the leading `v` via substring-from-index-1. cmd's `:str=repl` + REM substitution is GLOBAL, not anchored — `!TAG:v=!` would remove every + REM `v` in the string, not just the leading one, so a hypothetical tag + REM like `v1.0.0-rev2` would become `1.0.0-re2` and break the [version] + REM cast. TAG is guaranteed to start with `v` by the normalization step + REM above, so `:~1` (drop first char) is equivalent to stripping the + REM leading prefix. + set "TAG_NUM=!TAG:~1!" + set "MIN_NUM=!MIN_ATTESTED_VERSION:~1!" + + REM Detect pre-release / build-metadata tags (e.g. v0.18.0-rc1) BEFORE + REM handing the value to PowerShell. [System.Version] doesn't support + REM semver prerelease suffixes and would throw inside the try/catch, + REM leaving VERSION_OK empty and surfacing a misleading "predates + REM attestation support" error. install.sh handles these correctly via + REM `sort -V`; Windows doesn't have a built-in semver comparator, so + REM we reject explicitly with an accurate diagnosis instead of silently + REM misclassifying the failure. + REM + REM Uses native cmd substitution `!VAR:-=!` to check for `-` presence — + REM no subshell, no metacharacter risk. If removing `-` changes the + REM string, the original contained a `-`. + if not "!TAG_NUM!"=="!TAG_NUM:-=!" ( + echo Pre-release tags like !TAG! aren't currently supported for >&2 + echo provenance verification on Windows. [System.Version] doesn't >&2 + echo parse semver prerelease suffixes. Options: >&2 + echo - Install without provenance verification: --skip-attestation >&2 + echo - Pin to a stable release tag ^(no `-rc`, `-beta`, etc.^) >&2 + exit /b 1 + ) + + set "VERSION_OK=" + for /f "delims=" %%i in ('powershell -NoProfile -Command "try { if ([version]$env:TAG_NUM -ge [version]$env:MIN_NUM) { 'yes' } } catch {}"') do set "VERSION_OK=%%i" + if not "!VERSION_OK!"=="yes" ( + echo Provenance verification was requested, but !TAG! predates >&2 + echo plannotator's attestation support. The first release carrying >&2 + echo signed build provenance is !MIN_ATTESTED_VERSION!. Options: >&2 + echo - Pin to !MIN_ATTESTED_VERSION! or later: --version !MIN_ATTESTED_VERSION! >&2 + echo - Install without provenance verification: --skip-attestation >&2 + echo - Or unset PLANNOTATOR_VERIFY_ATTESTATION / remove verifyAttestation >&2 + echo from %USERPROFILE%\.plannotator\config.json >&2 + exit /b 1 + ) +) + set "BINARY_NAME=plannotator-!PLATFORM!.exe" set "BINARY_URL=https://github.com/!REPO!/releases/download/!TAG!/!BINARY_NAME!" set "CHECKSUM_URL=!BINARY_URL!.sha256" -REM Download binary -set "TEMP_FILE=%TEMP%\plannotator-!TAG!.exe" +REM Download binary to a randomized temp path so concurrent invocations +REM don't collide and a same-user pre-placed symlink at a predictable +REM path can't redirect where curl writes the downloaded executable. +REM The SHA256 check would pass regardless (content is authentic), but +REM the install destination would be corrupted. +set "TEMP_FILE=%TEMP%\plannotator-%RANDOM%.exe" curl -fsSL "!BINARY_URL!" -o "!TEMP_FILE!" if !ERRORLEVEL! neq 0 ( echo Failed to download binary >&2 @@ -77,18 +249,25 @@ if !ERRORLEVEL! neq 0 ( exit /b 1 ) -REM Download checksum -curl -fsSL "!CHECKSUM_URL!" -o "%TEMP%\checksum.txt" +REM Download checksum to a randomized temp path for the same reason as +REM the binary download above (concurrent collision + symlink pre-placement). +set "CHECKSUM_FILE=%TEMP%\plannotator-checksum-%RANDOM%.txt" +curl -fsSL "!CHECKSUM_URL!" -o "!CHECKSUM_FILE!" if !ERRORLEVEL! neq 0 ( echo Failed to download checksum >&2 + REM curl -o creates the output file before receiving data, so a + REM network failure or HTTP error leaves a 0-byte/partial file + REM at CHECKSUM_FILE. Clean it up to match the discipline used + REM for TEMP_FILE elsewhere in this script. + if exist "!CHECKSUM_FILE!" del "!CHECKSUM_FILE!" del "!TEMP_FILE!" exit /b 1 ) REM Extract expected checksum (first field) -set /p EXPECTED_CHECKSUM=<"%TEMP%\checksum.txt" +set /p EXPECTED_CHECKSUM=<"!CHECKSUM_FILE!" for /f "tokens=1" %%i in ("!EXPECTED_CHECKSUM!") do set "EXPECTED_CHECKSUM=%%i" -del "%TEMP%\checksum.txt" +del "!CHECKSUM_FILE!" REM Verify checksum using certutil set "ACTUAL_CHECKSUM=" @@ -105,6 +284,52 @@ if /i "!ACTUAL_CHECKSUM!" neq "!EXPECTED_CHECKSUM!" ( exit /b 1 ) +if "!VERIFY_ATTESTATION!"=="1" ( + REM VERIFY_ATTESTATION was resolved before the download; MIN_ATTESTED_VERSION + REM pre-flight already ran and rejected older tags. At this point we know + REM the tag is attested and gh should find a bundle. + where gh >nul 2>&1 + if !ERRORLEVEL! equ 0 ( + REM Capture combined output to a randomized temp file so gh's + REM actual error message (auth, network, missing attestation, etc.) + REM can be surfaced on failure. Randomized to match the existing + REM %RANDOM% pattern used elsewhere in this script and avoid races + REM between concurrent invocations. Matches install.sh / install.ps1. + REM + REM Verification is constrained to the exact tag (--source-ref) AND + REM the specific signing workflow file (--signer-workflow) — not + REM just "built somewhere in this repo". See install.sh for full + REM rationale. + set "GH_OUTPUT=%TEMP%\plannotator-gh-%RANDOM%.txt" + gh attestation verify "!TEMP_FILE!" ^ + --repo "!REPO!" ^ + --source-ref "refs/tags/!TAG!" ^ + --signer-workflow "backnotprop/plannotator/.github/workflows/release.yml" ^ + > "!GH_OUTPUT!" 2>&1 + if !ERRORLEVEL! neq 0 ( + type "!GH_OUTPUT!" >&2 + del "!GH_OUTPUT!" + echo Attestation verification failed! >&2 + echo The binary's SHA256 matched, but no valid signed provenance was found >&2 + echo for !REPO!. Refusing to install. >&2 + del "!TEMP_FILE!" + exit /b 1 + ) + del "!GH_OUTPUT!" + echo [OK] verified build provenance ^(SLSA^) + ) else ( + echo verifyAttestation is enabled but gh CLI was not found. >&2 + echo Install https://cli.github.com ^(and run 'gh auth login'^), >&2 + echo or unset PLANNOTATOR_VERIFY_ATTESTATION / remove verifyAttestation >&2 + echo from %USERPROFILE%\.plannotator\config.json / pass --skip-attestation. >&2 + del "!TEMP_FILE!" + exit /b 1 + ) +) else ( + echo SHA256 verified. For build provenance verification, see + echo https://plannotator.ai/docs/getting-started/installation/#verifying-your-install +) + REM Install binary set "INSTALL_PATH=!INSTALL_DIR!\plannotator.exe" move /y "!TEMP_FILE!" "!INSTALL_PATH!" >nul @@ -181,7 +406,7 @@ echo --- echo. echo ## Code Review Feedback echo. -echo !`plannotator review $ARGUMENTS` +echo ^^!`plannotator review $ARGUMENTS` echo. echo ## Your task echo. @@ -198,7 +423,7 @@ echo --- echo. echo ## Markdown Annotations echo. -echo !`plannotator annotate $ARGUMENTS` +echo ^^!`plannotator annotate $ARGUMENTS` echo. echo ## Your task echo. @@ -215,7 +440,7 @@ echo --- echo. echo ## Message Annotations echo. -echo !`plannotator annotate-last` +echo ^^!`plannotator annotate-last` echo. echo ## Your task echo. @@ -305,7 +530,7 @@ echo } if !ERRORLEVEL! equ 0 ( set "GEMINI_SETTINGS_PATH=%USERPROFILE%\.gemini\settings.json" set "GEMINI_SETTINGS_FWD=!GEMINI_SETTINGS_PATH:\=/!" - node -e "const fs=require('fs');const s=JSON.parse(fs.readFileSync('!GEMINI_SETTINGS_FWD!','utf8'));if(!s.hooks)s.hooks={};if(!s.hooks.BeforeTool)s.hooks.BeforeTool=[];s.hooks.BeforeTool.push({matcher:'exit_plan_mode',hooks:[{type:'command',command:'plannotator',timeout:345600}]});fs.writeFileSync('!GEMINI_SETTINGS_FWD!',JSON.stringify(s,null,2)+'\n');" + node -e "const fs=require('fs');const s=JSON.parse(fs.readFileSync('!GEMINI_SETTINGS_FWD!','utf8'));s.hooks=s.hooks||{};s.hooks.BeforeTool=s.hooks.BeforeTool||[];s.hooks.BeforeTool.push({matcher:'exit_plan_mode',hooks:[{type:'command',command:'plannotator',timeout:345600}]});fs.writeFileSync('!GEMINI_SETTINGS_FWD!',JSON.stringify(s,null,2)+'\n');" echo Added plannotator hook to !GEMINI_SETTINGS_PATH! ) else ( echo. @@ -329,7 +554,7 @@ echo description = "Open interactive code review for current changes or a PR URL echo prompt = """ echo ## Code Review Feedback echo. -echo ^!{plannotator review {{args}}} +echo ^^!{plannotator review {{args}}} echo. echo ## Your task echo. @@ -342,7 +567,7 @@ echo description = "Open interactive annotation UI for a markdown file or folder echo prompt = """ echo ## Markdown Annotations echo. -echo ^!{plannotator annotate {{args}}} +echo ^^!{plannotator annotate {{args}}} echo. echo ## Your task echo. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c79e7480..bc480c0a 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,15 +1,55 @@ # Plannotator Windows Installer +param( + [string]$Version = "latest", + [switch]$VerifyAttestation, + [switch]$SkipAttestation +) + $ErrorActionPreference = "Stop" +# Reject mutually-exclusive flag combinations upfront. Passing both is +# almost always a typo or wrapper-script misconfiguration; guessing which +# one the user meant is worse than failing fast. +if ($VerifyAttestation -and $SkipAttestation) { + [Console]::Error.WriteLine("-VerifyAttestation and -SkipAttestation are mutually exclusive. Pass one or the other.") + exit 1 +} + $repo = "backnotprop/plannotator" $installDir = "$env:LOCALAPPDATA\plannotator" -# Detect architecture -$arch = if ([Environment]::Is64BitOperatingSystem) { - if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" } -} else { +# First plannotator release that carries SLSA build-provenance attestations. +# See scripts/install.sh for the full explanation — this constant is bumped +# once at the first attested release via the release skill. +$minAttestedVersion = "v0.17.2" + +# Detect architecture. Native ARM64 Windows binaries are built from +# bun-windows-arm64 (stable since Bun v1.3.10), so ARM64 hosts get a +# native binary — no Windows x86-64 emulation tax. +# +# PROCESSOR_ARCHITECTURE reports the architecture the current PowerShell +# process is running under. PROCESSOR_ARCHITEW6432 is set only in 32-bit +# processes running via WoW64 and reflects the HOST architecture. Prefer +# the latter when present so a 32-bit PowerShell on ARM64 Windows still +# selects the native arm64 binary. Matches install.cmd's detection. +if (-not [Environment]::Is64BitOperatingSystem) { + # Write-Error under $ErrorActionPreference = "Stop" (set at the top + # of this file) raises a terminating error that exits the process + # with code 1. No explicit `exit 1` needed here — it would be + # unreachable. Same applies to every other Write-Error in this file. Write-Error "32-bit Windows is not supported" - exit 1 +} +$hostArch = if ($env:PROCESSOR_ARCHITEW6432) { + $env:PROCESSOR_ARCHITEW6432 +} else { + $env:PROCESSOR_ARCHITECTURE +} +if ($hostArch -eq "ARM64") { + $arch = "arm64" +} elseif ($hostArch -eq "AMD64") { + $arch = "x64" +} else { + Write-Error "Unsupported Windows architecture: $hostArch" } $platform = "win32-$arch" @@ -28,17 +68,97 @@ foreach ($oldPath in $oldLocations) { } } -Write-Host "Fetching latest version..." -$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest" -$latestTag = $release.tag_name +if ($Version -eq "latest") { + Write-Host "Fetching latest version..." + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest" + $latestTag = $release.tag_name -if (-not $latestTag) { - Write-Error "Failed to fetch latest version" - exit 1 + if (-not $latestTag) { + Write-Error "Failed to fetch latest version" + } +} else { + # Normalize: auto-prefix v if missing (matches install.cmd behaviour) + if ($Version -like "v*") { + $latestTag = $Version + } else { + $latestTag = "v$Version" + } } Write-Host "Installing plannotator $latestTag..." +# Resolve SLSA build-provenance verification opt-in BEFORE the download so we +# can fail fast without wasting bandwidth if the requested tag predates +# provenance support. Precedence: CLI flag > env var > config file > default. +$verifyAttestationResolved = $false + +# Layer 3: config file (lowest precedence of the opt-in sources). +$configPath = "$env:USERPROFILE\.plannotator\config.json" +if (Test-Path $configPath) { + try { + $cfg = Get-Content $configPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + # Strict check: only a real JSON `true` (parsed as [bool]$true) opts in. + # A stringified "true", a number, etc. do not — matches install.sh, which + # greps for a literal boolean. + if ($cfg.verifyAttestation -is [bool] -and $cfg.verifyAttestation) { + $verifyAttestationResolved = $true + } + } catch { + # Malformed config — ignore, fall through to other layers. + } +} + +# Layer 2: env var (overrides config file). +$envVerify = $env:PLANNOTATOR_VERIFY_ATTESTATION +if ($envVerify) { + if ($envVerify -match '^(1|true|yes)$') { + $verifyAttestationResolved = $true + } elseif ($envVerify -match '^(0|false|no)$') { + $verifyAttestationResolved = $false + } +} + +# Layer 1: CLI flags win. -VerifyAttestation and -SkipAttestation are +# mutually exclusive and already rejected together at the top of this +# script (lines ~13-16), so at most one of these branches can fire. +if ($VerifyAttestation) { $verifyAttestationResolved = $true } +if ($SkipAttestation) { $verifyAttestationResolved = $false } + +# Pre-flight: if verification is requested, reject tags older than the first +# attested release before we download anything. Uses PowerShell's [version] +# class for proper numeric comparison (lexicographic string cmp gets +# v0.9.0 vs v0.10.0 backwards). +if ($verifyAttestationResolved) { + # Pre-release and build-metadata tags (e.g. v0.18.0-rc1) are not + # supported by [System.Version] — the cast throws on any `-` suffix. + # install.sh handles these correctly via `sort -V`; Windows has no + # built-in semver comparator, so we detect and reject explicitly + # with an accurate error rather than surfacing a confusing "could + # not parse" message from the catch block below. + if ($latestTag -match '-') { + [Console]::Error.WriteLine("Pre-release tags like $latestTag aren't currently supported for provenance verification on Windows. [System.Version] doesn't parse semver prerelease suffixes. Options:") + [Console]::Error.WriteLine(" - Install without provenance verification: -SkipAttestation") + [Console]::Error.WriteLine(" - Pin to a stable release tag (no -rc, -beta, etc.)") + exit 1 + } + try { + $resolvedVersion = [version]($latestTag -replace '^v', '') + $minVersion = [version]($minAttestedVersion -replace '^v', '') + } catch { + # Write-Error under Stop raises a new terminating error that + # propagates past this catch and exits the script with code 1. + Write-Error "Could not parse version tags for provenance check: latest=$latestTag min=$minAttestedVersion" + } + if ($resolvedVersion -lt $minVersion) { + [Console]::Error.WriteLine("Provenance verification was requested, but $latestTag predates plannotator's attestation support.") + [Console]::Error.WriteLine("The first release carrying signed build provenance is $minAttestedVersion. Options:") + [Console]::Error.WriteLine(" - Pin to $minAttestedVersion or later: -Version $minAttestedVersion") + [Console]::Error.WriteLine(" - Install without provenance verification: -SkipAttestation") + [Console]::Error.WriteLine(" - Or unset PLANNOTATOR_VERIFY_ATTESTATION / remove verifyAttestation from $configPath") + exit 1 + } +} + $binaryUrl = "https://github.com/$repo/releases/download/$latestTag/$binaryName" $checksumUrl = "$binaryUrl.sha256" @@ -65,7 +185,43 @@ $actualChecksum = (Get-FileHash -Path $tmpFile -Algorithm SHA256).Hash.ToLower() if ($actualChecksum -ne $expectedChecksum) { Remove-Item $tmpFile -Force Write-Error "Checksum verification failed!" - exit 1 +} + +if ($verifyAttestationResolved) { + # $verifyAttestationResolved was decided before the download and the + # MIN_ATTESTED_VERSION pre-flight already rejected older tags. At this + # point we know the tag is attested and gh should find a bundle. + if (Get-Command gh -ErrorAction SilentlyContinue) { + # Constrain verification to the exact tag + signing workflow — see + # install.sh comment for rationale. + $verifyOutput = & gh attestation verify $tmpFile ` + --repo $repo ` + --source-ref "refs/tags/$latestTag" ` + --signer-workflow "backnotprop/plannotator/.github/workflows/release.yml" 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host "✓ verified build provenance (SLSA)" + } else { + # Write to stderr directly — Write-Host goes to PowerShell's + # Information stream, which is silently dropped when callers + # redirect stderr for error reporting in CI/CD pipelines. + # + # `& gh ... 2>&1` captures multi-line output as an object[] + # array. Passing the array directly to [Console]::Error.WriteLine + # binds to the WriteLine(object) overload, which calls ToString() + # on the array and yields the useless literal "System.Object[]". + # Out-String normalizes the array back into a single formatted + # string so the actual gh diagnostic is visible. + [Console]::Error.WriteLine(($verifyOutput | Out-String).TrimEnd()) + Remove-Item $tmpFile -Force + Write-Error "Attestation verification failed! The binary's SHA256 matched, but no valid signed provenance was found for $repo. Refusing to install." + } + } else { + Remove-Item $tmpFile -Force + Write-Error "verifyAttestation is enabled but gh CLI was not found. Install https://cli.github.com (and run 'gh auth login'), or unset PLANNOTATOR_VERIFY_ATTESTATION / remove verifyAttestation from $configPath / pass -SkipAttestation." + } +} else { + Write-Host "SHA256 verified. For build provenance verification, see" + Write-Host "https://plannotator.ai/docs/getting-started/installation/#verifying-your-install" } Move-Item -Force $tmpFile "$installDir\plannotator.exe" @@ -226,21 +382,34 @@ if (Get-Command git -ErrorAction SilentlyContinue) { try { git clone --depth 1 --filter=blob:none --sparse "https://github.com/$repo.git" --branch $latestTag "$skillsTmp\repo" 2>$null - Push-Location "$skillsTmp\repo" - git sparse-checkout set apps/skills 2>$null - - if (Test-Path "apps\skills") { - $items = Get-ChildItem "apps\skills" -ErrorAction SilentlyContinue - if ($items) { - New-Item -ItemType Directory -Force -Path $claudeSkillsDir | Out-Null - New-Item -ItemType Directory -Force -Path $agentsSkillsDir | Out-Null - Copy-Item -Recurse -Force "apps\skills\*" $claudeSkillsDir - Copy-Item -Recurse -Force "apps\skills\*" $agentsSkillsDir - Write-Host "Installed skills to $claudeSkillsDir\ and $agentsSkillsDir\" + # git is a native executable — it does not throw under + # $ErrorActionPreference=Stop on non-zero exit. Guard with + # Test-Path so we only Push-Location if the clone actually + # produced a repo directory. + if (Test-Path "$skillsTmp\repo") { + Push-Location "$skillsTmp\repo" + # Inner try/finally guarantees Pop-Location runs exactly once + # after a successful Push-Location, regardless of whether the + # copy operations below throw. The naive pattern (Pop-Location + # only on the success path) leaks the location stack if a + # PS-native cmdlet (Copy-Item etc.) throws under Stop. + try { + git sparse-checkout set apps/skills 2>$null + + if (Test-Path "apps\skills") { + $items = Get-ChildItem "apps\skills" -ErrorAction SilentlyContinue + if ($items) { + New-Item -ItemType Directory -Force -Path $claudeSkillsDir | Out-Null + New-Item -ItemType Directory -Force -Path $agentsSkillsDir | Out-Null + Copy-Item -Recurse -Force "apps\skills\*" $claudeSkillsDir + Copy-Item -Recurse -Force "apps\skills\*" $agentsSkillsDir + Write-Host "Installed skills to $claudeSkillsDir\ and $agentsSkillsDir\" + } + } + } finally { + Pop-Location } } - - Pop-Location } catch { Write-Host "Skipping skills install (git sparse-checkout failed)" } diff --git a/scripts/install.sh b/scripts/install.sh index 602db638..6d02bcd8 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,6 +4,143 @@ set -e REPO="backnotprop/plannotator" INSTALL_DIR="$HOME/.local/bin" +# First plannotator release that carries SLSA build-provenance attestations. +# Releases before this tag were cut before release.yml added the +# `actions/attest-build-provenance` step, so `gh attestation verify` will +# fail with "no attestations found" for them regardless of authenticity. +# When provenance verification is enabled (via flag, env var, or +# ~/.plannotator/config.json), the installer compares the resolved tag +# against this constant and fails fast with a clear message instead of +# downloading a binary, running SHA256, and then hitting a cryptic gh +# failure. Bumped once at the first attested release via the release skill. +MIN_ATTESTED_VERSION="v0.17.2" + +# Compare two vMAJOR.MINOR.PATCH tags. Returns 0 (success) if $1 >= $2. +# Uses `sort -V` (version sort) which handles minor/patch width correctly +# unlike plain lexicographic comparison (e.g. v0.9.0 vs v0.10.0). +version_ge() { + [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n 1)" = "$1" ] +} + +VERSION="latest" +# Tracks whether a version was explicitly set via --version or positional. +# Used to reject mixing --version with a stray positional token, +# which would otherwise silently overwrite the earlier value and 404. +VERSION_EXPLICIT=0 +# Three-layer opt-in for SLSA build-provenance verification. +# Precedence: CLI flag > env var > ~/.plannotator/config.json > default (off). +# -1 = flag not set yet (fall through to lower layers); 0 = disable; 1 = enable. +VERIFY_ATTESTATION_FLAG=-1 + +usage() { + cat <<'USAGE' +Usage: install.sh [--version ] [--verify-attestation | --skip-attestation] [--help] + install.sh + +Options: + --version Install a specific version (e.g. vX.Y.Z or X.Y.Z; + see https://github.com/backnotprop/plannotator/releases). + Defaults to the latest GitHub release. + --verify-attestation Require SLSA build-provenance verification via + `gh attestation verify`. Fails the install if gh is + not available or the check does not pass. + --skip-attestation Force-skip provenance verification even if enabled + via env var or ~/.plannotator/config.json. + -h, --help Show this help and exit. + +Provenance verification is off by default. Enable it by any of: + - passing --verify-attestation + - exporting PLANNOTATOR_VERIFY_ATTESTATION=1 + - setting { "verifyAttestation": true } in ~/.plannotator/config.json + +Examples: + curl -fsSL https://plannotator.ai/install.sh | bash + curl -fsSL https://plannotator.ai/install.sh | bash -s -- --version vX.Y.Z + curl -fsSL https://plannotator.ai/install.sh | bash -s -- --verify-attestation + bash install.sh vX.Y.Z +USAGE +} + +while [ $# -gt 0 ]; do + case "$1" in + --version) + if [ -z "${2:-}" ]; then + echo "--version requires an argument" >&2 + usage >&2 + exit 1 + fi + case "$2" in + -*) + echo "--version requires a tag value, got flag: $2" >&2 + usage >&2 + exit 1 + ;; + esac + VERSION="$2" + VERSION_EXPLICIT=1 + shift 2 + ;; + --version=*) + value="${1#--version=}" + if [ -z "$value" ]; then + echo "--version requires an argument" >&2 + usage >&2 + exit 1 + fi + case "$value" in + -*) + echo "--version requires a tag value, got flag: $value" >&2 + usage >&2 + exit 1 + ;; + esac + VERSION="$value" + VERSION_EXPLICIT=1 + shift + ;; + --verify-attestation) + if [ "$VERIFY_ATTESTATION_FLAG" = "0" ]; then + echo "--verify-attestation and --skip-attestation are mutually exclusive" >&2 + usage >&2 + exit 1 + fi + VERIFY_ATTESTATION_FLAG=1 + shift + ;; + --skip-attestation) + if [ "$VERIFY_ATTESTATION_FLAG" = "1" ]; then + echo "--skip-attestation and --verify-attestation are mutually exclusive" >&2 + usage >&2 + exit 1 + fi + VERIFY_ATTESTATION_FLAG=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + -*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + # Positional form: install.sh vX.Y.Z (matches install.cmd interface). + # Reject if --version was already passed — silent overwrite is worse + # than a clean usage error. + if [ "$VERSION_EXPLICIT" -eq 1 ]; then + echo "Unexpected positional argument: $1 (version already set)" >&2 + usage >&2 + exit 1 + fi + VERSION="$1" + VERSION_EXPLICIT=1 + shift + ;; + esac +done + case "$(uname -s)" in Darwin) os="darwin" ;; Linux) os="linux" ;; @@ -27,16 +164,70 @@ if [ -n "$USERPROFILE" ]; then echo "Cleaned up old Windows install locations" fi -echo "Fetching latest version..." -latest_tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4) +if [ "$VERSION" = "latest" ]; then + echo "Fetching latest version..." + latest_tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4) -if [ -z "$latest_tag" ]; then - echo "Failed to fetch latest version" >&2 - exit 1 + if [ -z "$latest_tag" ]; then + echo "Failed to fetch latest version" >&2 + exit 1 + fi +else + # Normalize: auto-prefix v if missing (matches install.cmd behaviour) + case "$VERSION" in + v*) latest_tag="$VERSION" ;; + *) latest_tag="v$VERSION" ;; + esac fi echo "Installing plannotator ${latest_tag}..." +# Resolve SLSA build-provenance verification opt-in BEFORE the download so we +# can fail fast without wasting bandwidth if the requested tag predates +# provenance support. The three layers (config file, env var, CLI flag) are +# all cheap to check — no reason to defer this past the arg parse. +# +# Precedence: CLI flag > env var > ~/.plannotator/config.json > default (off). +verify_attestation=0 + +# Layer 3: config file (lowest precedence of the opt-in sources). +# Crude grep against a flat boolean — PlannotatorConfig has no nested +# verifyAttestation, so false positives are not a concern. +if [ -f "$HOME/.plannotator/config.json" ]; then + if grep -q '"verifyAttestation"[[:space:]]*:[[:space:]]*true' "$HOME/.plannotator/config.json" 2>/dev/null; then + verify_attestation=1 + fi +fi + +# Layer 2: env var (overrides config file). +case "${PLANNOTATOR_VERIFY_ATTESTATION:-}" in + 1|true|yes|TRUE|YES|True|Yes) verify_attestation=1 ;; + 0|false|no|FALSE|NO|False|No) verify_attestation=0 ;; +esac + +# Layer 1: CLI flag (overrides everything). +if [ "$VERIFY_ATTESTATION_FLAG" -ne -1 ]; then + verify_attestation="$VERIFY_ATTESTATION_FLAG" +fi + +# Pre-flight: if verification is requested, reject tags older than the first +# attested release before we download anything. This catches both explicit +# `--version ` and implicit `latest`-resolves-to-old-tag cases with +# a clean, actionable error — no cryptic `gh: no attestations found` after +# a wasted download. +if [ "$verify_attestation" -eq 1 ]; then + if ! version_ge "$latest_tag" "$MIN_ATTESTED_VERSION"; then + echo "Provenance verification was requested, but ${latest_tag} predates" >&2 + echo "plannotator's attestation support. The first release carrying signed" >&2 + echo "build provenance is ${MIN_ATTESTED_VERSION}. Options:" >&2 + echo " - Pin to ${MIN_ATTESTED_VERSION} or later: --version ${MIN_ATTESTED_VERSION}" >&2 + echo " - Install without provenance verification: --skip-attestation" >&2 + echo " - Or unset PLANNOTATOR_VERIFY_ATTESTATION / remove verifyAttestation" >&2 + echo " from ~/.plannotator/config.json" >&2 + exit 1 + fi +fi + binary_url="https://github.com/${REPO}/releases/download/${latest_tag}/${binary_name}" checksum_url="${binary_url}.sha256" @@ -59,6 +250,45 @@ if [ "$actual_checksum" != "$expected_checksum" ]; then exit 1 fi +if [ "$verify_attestation" -eq 1 ]; then + # $verify_attestation was resolved before the download; MIN_ATTESTED_VERSION + # pre-flight already ran and rejected old tags. At this point we know + # the tag is attested and gh should find a bundle. + if command -v gh >/dev/null 2>&1; then + # Capture combined output so we can surface gh's actual error message + # (auth, network, missing attestation, etc.) on failure instead of a + # generic "verification failed" with no diagnostic detail. + # Constrain verification to the exact tag + signing workflow — not + # just "built by somewhere in this repo". --source-ref pins the + # git ref the attestation was produced from; --signer-workflow pins + # the workflow file that signed it. Together they prevent accepting + # a misattached asset or an attestation from an unrelated workflow. + if gh_output=$(gh attestation verify "$tmp_file" \ + --repo "$REPO" \ + --source-ref "refs/tags/${latest_tag}" \ + --signer-workflow "backnotprop/plannotator/.github/workflows/release.yml" 2>&1); then + echo "✓ verified build provenance (SLSA)" + else + echo "$gh_output" >&2 + echo "Attestation verification failed!" >&2 + echo "The binary's SHA256 matched, but no valid signed provenance was found" >&2 + echo "for ${REPO}. Refusing to install." >&2 + rm -f "$tmp_file" + exit 1 + fi + else + echo "verifyAttestation is enabled but gh CLI was not found." >&2 + echo "Install https://cli.github.com (and run 'gh auth login')," >&2 + echo "or unset PLANNOTATOR_VERIFY_ATTESTATION / remove verifyAttestation from" >&2 + echo "~/.plannotator/config.json / pass --skip-attestation." >&2 + rm -f "$tmp_file" + exit 1 + fi +else + echo "SHA256 verified. For build provenance verification, see" + echo "https://plannotator.ai/docs/getting-started/installation/#verifying-your-install" +fi + # Remove old binary first (handles Windows .exe and locked file issues) rm -f "$INSTALL_DIR/plannotator" "$INSTALL_DIR/plannotator.exe" 2>/dev/null || true @@ -219,20 +449,32 @@ if command -v git &>/dev/null; then AGENTS_SKILLS_DIR="$HOME/.agents/skills" skills_tmp=$(mktemp -d) - if git clone --depth 1 --filter=blob:none --sparse \ - "https://github.com/${REPO}.git" --branch "$latest_tag" "$skills_tmp/repo" 2>/dev/null && \ - cd "$skills_tmp/repo" && git sparse-checkout set apps/skills 2>/dev/null; then - - if [ -d "apps/skills" ] && [ "$(ls -A apps/skills 2>/dev/null)" ]; then - mkdir -p "$CLAUDE_SKILLS_DIR" "$AGENTS_SKILLS_DIR" - cp -r apps/skills/* "$CLAUDE_SKILLS_DIR/" - cp -r apps/skills/* "$AGENTS_SKILLS_DIR/" - echo "Installed skills to ${CLAUDE_SKILLS_DIR}/ and ${AGENTS_SKILLS_DIR}/" - fi - - cd - >/dev/null + # Wrap the cd-bearing block in a subshell so any `cd` is scoped to + # the subshell and can't leave the parent script with a dangling CWD. + # Previous version chained `cd` inside an `&&` condition, and if + # sparse-checkout failed the else branch ran without restoring the + # directory — then `rm -rf "$skills_tmp"` below executed while the + # shell's CWD was still inside the directory being deleted. No + # production failure (subsequent code uses absolute paths) but + # structurally incorrect. install.ps1 and install.cmd use + # Push-Location/pushd for the same logic; a subshell is bash's + # equivalent — the parent shell's CWD is inherited in, and any + # cd inside the subshell disappears when the subshell exits. + if ( + cd "$skills_tmp" && + git clone --depth 1 --filter=blob:none --sparse \ + "https://github.com/${REPO}.git" --branch "$latest_tag" repo 2>/dev/null && + cd repo && + git sparse-checkout set apps/skills 2>/dev/null && + [ -d "apps/skills" ] && + [ "$(ls -A apps/skills 2>/dev/null)" ] && + mkdir -p "$CLAUDE_SKILLS_DIR" "$AGENTS_SKILLS_DIR" && + cp -r apps/skills/* "$CLAUDE_SKILLS_DIR/" && + cp -r apps/skills/* "$AGENTS_SKILLS_DIR/" + ); then + echo "Installed skills to ${CLAUDE_SKILLS_DIR}/ and ${AGENTS_SKILLS_DIR}/" else - echo "Skipping skills install (git sparse-checkout failed)" + echo "Skipping skills install (git sparse-checkout failed or apps/skills empty)" fi rm -rf "$skills_tmp" diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 5f08e7af..e9d0d8f4 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -92,8 +92,24 @@ describe("install.ps1", () => { expect(script).toContain("UTF8.GetString"); }); - test("detects ARM64 architecture", () => { + test("install.ps1 selects native arm64 binary on ARM64 Windows", () => { + // release.yml now builds bun-windows-arm64 (stable since Bun v1.3.10), + // so ARM64 hosts get a native binary instead of running the x64 build + // via Windows emulation. install.ps1 must detect host architecture + // and set $arch accordingly so the downloaded binary matches the host. + // + // Must check BOTH PROCESSOR_ARCHITECTURE and PROCESSOR_ARCHITEW6432 — + // the latter is set only in 32-bit processes via WoW64 and reflects + // the host architecture. A 32-bit PowerShell on ARM64 Windows should + // still get the native arm64 binary. Matches install.cmd's detection. + expect(script).toContain("PROCESSOR_ARCHITECTURE"); + expect(script).toContain("PROCESSOR_ARCHITEW6432"); expect(script).toContain('"ARM64"'); + expect(script).toContain('$arch = "arm64"'); + expect(script).toContain('$arch = "x64"'); + // The emulation-fallback workaround from earlier cycles must be gone + // now that native ARM64 binaries ship. + expect(script).not.toContain("runs via Windows emulation"); }); test("adds to PATH via environment variable", () => { @@ -149,6 +165,19 @@ describe("install.cmd", () => { expect(script).toContain("PROCESSOR_ARCHITEW6432"); // WoW64 detection }); + test("install.cmd selects platform based on PROCESSOR_ARCHITECTURE", () => { + // Earlier revisions hardcoded `set "PLATFORM=win32-x64"` regardless + // of host architecture, so ARM64 Windows machines silently received + // the x64 binary (working via emulation, but slower). Now that + // release.yml ships a native bun-windows-arm64 build, the script + // must branch on PROCESSOR_ARCHITECTURE / PROCESSOR_ARCHITEW6432 + // and set PLATFORM to win32-arm64 when appropriate. + expect(script).toContain('set "PLATFORM=win32-x64"'); + expect(script).toContain('set "PLATFORM=win32-arm64"'); + // The old unconditional hardcode must be gone. + expect(script).not.toMatch(/^set "PLATFORM=win32-x64"$/m); + }); + test("warns about duplicate hooks", () => { expect(script).toContain("DUPLICATE HOOK DETECTED"); }); @@ -166,4 +195,385 @@ describe("install.cmd", () => { expect(script).toContain("plannotator-annotate.md"); expect(script).toContain("plannotator-last.md"); }); + + test("Gemini settings merge uses || idiom (issue #506 regression)", () => { + // cmd's delayed expansion parser eats `!` operators in `node -e "..."` + // blocks, turning `if(!s.hooks)` into a broken variable expansion and + // crashing node. The merge script must use `x = x || {}` instead, which + // contains no `!` chars. See backnotprop/plannotator#506. + expect(script).toContain("s.hooks=s.hooks||{}"); + expect(script).toContain("s.hooks.BeforeTool=s.hooks.BeforeTool||[]"); + expect(script).not.toContain("if(!s.hooks)"); + expect(script).not.toContain("if(!s.hooks.BeforeTool)"); + }); + + test("attestation verification is off by default with three-layer opt-in", () => { + // Layer 3: config file read (verifyAttestation appears inside a + // findstr pattern with escaped quotes; assert the key + findstr + // separately rather than the quoted form) + expect(script).toContain("%USERPROFILE%\\.plannotator\\config.json"); + expect(script).toContain("verifyAttestation"); + expect(script).toContain("findstr"); + // Layer 2: env var + expect(script).toContain("PLANNOTATOR_VERIFY_ATTESTATION"); + // Layer 1: CLI flags + expect(script).toContain("--verify-attestation"); + expect(script).toContain("--skip-attestation"); + // Enforcement: hard-fail when opted in but gh missing + expect(script).toContain("gh CLI was not found"); + }); +}); + +describe("install shared behavior", () => { + const sh = readFileSync(join(scriptsDir, "install.sh"), "utf-8"); + const ps = readFileSync(join(scriptsDir, "install.ps1"), "utf-8"); + + test("install.sh has three-layer opt-in resolution", () => { + // Layer 3: config file via grep against the flat JSON boolean + expect(sh).toContain("$HOME/.plannotator/config.json"); + expect(sh).toContain('"verifyAttestation"'); + // Layer 2: env var parsing + expect(sh).toContain("PLANNOTATOR_VERIFY_ATTESTATION"); + // Layer 1: CLI flags with sentinel + expect(sh).toContain("--verify-attestation"); + expect(sh).toContain("--skip-attestation"); + expect(sh).toContain("VERIFY_ATTESTATION_FLAG"); + // Enforcement + expect(sh).toContain("gh CLI was not found"); + }); + + test("install.ps1 has three-layer opt-in resolution", () => { + // Layer 3: config file via ConvertFrom-Json + expect(ps).toContain("$env:USERPROFILE\\.plannotator\\config.json"); + expect(ps).toContain("ConvertFrom-Json"); + expect(ps).toContain("$cfg.verifyAttestation"); + // Layer 2: env var + expect(ps).toContain("PLANNOTATOR_VERIFY_ATTESTATION"); + // Layer 1: CLI flags + expect(ps).toContain("[switch]$VerifyAttestation"); + expect(ps).toContain("[switch]$SkipAttestation"); + // Enforcement + expect(ps).toContain("gh CLI was not found"); + }); + + test("install.sh/cmd reject dash-prefixed --version values and positional overwrites", () => { + // Regression guard for PR #512 review cycle 4 findings: + // - `install.sh --version --verify-attestation` used to set VERSION + // to the flag name and then 404 on download + // - `install.sh --version v1.0.0 stray` used to silently overwrite + // VERSION with "stray" + // Same pair of bugs existed in install.cmd. Both scripts now track + // VERSION_EXPLICIT and dash-check the value after --version. + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + + // install.sh + expect(sh).toContain("VERSION_EXPLICIT=0"); + expect(sh).toContain('echo "--version requires a tag value, got flag:'); + expect(sh).toContain('echo "Unexpected positional argument:'); + + // install.cmd + expect(cmdScript).toContain('set "VERSION_EXPLICIT=0"'); + expect(cmdScript).toContain("--version requires a tag value, got flag:"); + expect(cmdScript).toContain("Unexpected positional argument:"); + }); + + test("install.ps1 writes gh error output to stderr via Out-String", () => { + // Regression guard 1: Write-Host goes to PowerShell's Information + // stream and is silently dropped when CI pipelines capture stderr. + // Use the native stderr handle instead. See install.sh:177 and + // install.cmd for the equivalent stderr writes. + // + // Regression guard 2: `& gh ... 2>&1` captures multi-line output as + // an object[] array. Passing the array directly to + // [Console]::Error.WriteLine binds to the WriteLine(object) overload, + // calls ToString() on the array, and yields the literal + // "System.Object[]" instead of the actual gh diagnostic — silently + // hiding exactly the error message this code path is supposed to + // surface. Must be normalized via Out-String first. + // Tighter assertion: the Out-String must be wired specifically on + // the $verifyOutput path, not just present somewhere in the file. + expect(ps).toMatch(/\$verifyOutput\s*\|\s*Out-String/); + expect(ps).toContain("[Console]::Error.WriteLine"); + expect(ps).not.toContain("Write-Host $verifyOutput"); + }); + + test("all installers reject --verify-attestation + --skip-attestation together", () => { + // Regression guard: passing both flags used to behave inconsistently + // across the three installers (bash/cmd took last-wins by command- + // line order; ps1 took a fixed SkipAttestation-always-wins). No sane + // user passes both, so the right behavior is to reject the ambiguous + // combination upfront with a clean "mutually exclusive" error. + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + + // install.sh — guards in both --verify-attestation and --skip-attestation arms + expect(sh).toContain("mutually exclusive"); + // install.cmd — same guard in both arms + expect(cmdScript).toContain("mutually exclusive"); + // install.ps1 — one guard right after param block + expect(ps).toContain("mutually exclusive"); + expect(ps).toMatch(/\$VerifyAttestation -and \$SkipAttestation/); + }); + + test("install.cmd uses randomized temp paths for all curl downloads", () => { + // Regression guard: fixed temp filenames collide between concurrent + // invocations and allow same-user symlink pre-placement to redirect + // curl's output. Every `-o` target in install.cmd must use %RANDOM%. + // Covers release.json, the binary itself, the checksum sidecar, and + // the gh attestation output capture. + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + expect(cmdScript).toContain("plannotator-release-%RANDOM%.json"); + expect(cmdScript).toContain("plannotator-%RANDOM%.exe"); + expect(cmdScript).toContain("plannotator-checksum-%RANDOM%.txt"); + expect(cmdScript).toContain("plannotator-gh-%RANDOM%.txt"); + // And every fixed-path variant must be gone + expect(cmdScript).not.toContain("%TEMP%\\release.json"); + expect(cmdScript).not.toContain("%TEMP%\\checksum.txt"); + expect(cmdScript).not.toMatch(/%TEMP%\\plannotator-!TAG!\.exe/); + }); + + test("all installers resolve verification + pre-flight BEFORE downloading the binary", () => { + // Regression guard: earlier revisions of install.ps1 and install.cmd + // resolved the three-layer verification opt-in and ran the + // MIN_ATTESTED_VERSION pre-flight AFTER the curl download, meaning + // users hit the failure only after wasting a full binary download. + // install.sh always pre-flighted correctly; the other two drifted. + // + // This test uses indexOf to assert the resolution block appears + // textually BEFORE the download line in each installer. + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + + // install.sh: resolution before curl -o + const shResolve = sh.indexOf("verify_attestation=0"); + const shDownload = sh.indexOf('curl -fsSL -o "$tmp_file"'); + expect(shResolve).toBeGreaterThan(-1); + expect(shDownload).toBeGreaterThan(-1); + expect(shResolve).toBeLessThan(shDownload); + + // install.ps1: resolution before Invoke-WebRequest -OutFile $tmpFile + const psResolve = ps.indexOf("$verifyAttestationResolved = $false"); + const psDownload = ps.indexOf("Invoke-WebRequest -Uri $binaryUrl -OutFile $tmpFile"); + expect(psResolve).toBeGreaterThan(-1); + expect(psDownload).toBeGreaterThan(-1); + expect(psResolve).toBeLessThan(psDownload); + + // install.cmd: resolution before curl -o "!TEMP_FILE!" + const cmdResolve = cmdScript.indexOf('set "VERIFY_ATTESTATION=0"'); + const cmdDownload = cmdScript.indexOf('curl -fsSL "!BINARY_URL!" -o "!TEMP_FILE!"'); + expect(cmdResolve).toBeGreaterThan(-1); + expect(cmdDownload).toBeGreaterThan(-1); + expect(cmdResolve).toBeLessThan(cmdDownload); + }); + + test("install.cmd version pre-flight uses $env: vars, not interpolated cmd vars", () => { + // Regression guard for PowerShell command injection via --version. + // Earlier revision interpolated `!TAG_NUM!` and `!MIN_NUM!` directly + // into a PowerShell -Command string between single quotes. A crafted + // --version like "0.18.0'; calc; '0.18.0" would break out of the + // literal and execute arbitrary PowerShell. Fix: pass the values via + // environment variables ($env:TAG_NUM, $env:MIN_NUM). PowerShell + // reads env var values as raw strings and never parses them as code; + // the [version] cast throws on invalid input and catch swallows it. + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + expect(cmdScript).toContain("$env:TAG_NUM"); + expect(cmdScript).toContain("$env:MIN_NUM"); + // The vulnerable interpolation form must be gone. + expect(cmdScript).not.toContain("[version]'!TAG_NUM!'"); + expect(cmdScript).not.toContain("[version]'!MIN_NUM!'"); + }); + + test("install.cmd strips leading v via substring, not global substitution", () => { + // Regression guard: cmd's `!VAR:str=repl!` is GLOBAL, not anchored, + // so `!TAG:v=!` removes every `v` in the tag — for hypothetical + // tags with internal v's (e.g. v1.0.0-rev2 → 1.0.0-re2) this + // produces an invalid version string. Use `!TAG:~1!` (substring + // from index 1) instead, which is equivalent to stripping the + // leading `v` because TAG is guaranteed to start with `v` by the + // upstream normalization. + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + expect(cmdScript).toContain('set "TAG_NUM=!TAG:~1!"'); + expect(cmdScript).toContain('set "MIN_NUM=!MIN_ATTESTED_VERSION:~1!"'); + // The global-substitution form must be gone from the pre-flight block. + expect(cmdScript).not.toContain('set "TAG_NUM=!TAG:v=!"'); + expect(cmdScript).not.toContain('set "MIN_NUM=!MIN_ATTESTED_VERSION:v=!"'); + }); + + test("both Windows installers reject pre-release tags with a dedicated error", () => { + // Regression guard: [System.Version] (used by both Windows installers + // for the pre-flight comparison) throws on semver prerelease suffixes + // like v0.18.0-rc1. Earlier revisions let the throw be swallowed by + // catch blocks and surfaced misleading diagnoses: + // install.cmd: "predates attestation support" (wrong — it's unparseable) + // install.ps1: "Could not parse version tags" (accurate but cryptic) + // Both now detect the `-` in the tag BEFORE attempting the cast and + // emit a dedicated "pre-release tags aren't currently supported" + // error that points users at --skip-attestation or a stable tag. + // install.sh handles these correctly via `sort -V` and doesn't need + // the pre-check. + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + expect(cmdScript).toContain("Pre-release tags"); + expect(cmdScript).toContain('if not "!TAG_NUM!"=="!TAG_NUM:-=!"'); + expect(ps).toContain("Pre-release tags"); + expect(ps).toMatch(/\$latestTag -match '-'/); + }); + + test("all three installers hardcode the SAME MIN_ATTESTED_VERSION value", () => { + // Cross-file consistency guard: the constant is triplicated across + // install.sh, install.ps1, install.cmd with no shared source of + // truth. A future bump that updates only one or two of the three + // files would silently ship divergent behavior — each installer + // would enforce a different floor. The per-file tests below check + // that each file contains the literal "v0.17.2" individually, but + // that doesn't catch drift where all three are internally consistent + // with themselves but differ from each other (e.g., sh says v0.17.3, + // ps says v0.17.2, cmd says v0.17.3). + // + // This test extracts the value from each file via a regex anchored + // on the assignment form (not just any mention of the string) and + // asserts all three match. + // Line-anchored regexes (/m) so a future comment that happens to + // contain the assignment form doesn't false-match and shadow the + // real declaration. All three current assignments are flush-left + // at the top of their respective files. + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + const shMatch = sh.match(/^MIN_ATTESTED_VERSION="(v\d+\.\d+\.\d+)"/m); + const psMatch = ps.match(/^\$minAttestedVersion\s*=\s*"(v\d+\.\d+\.\d+)"/m); + const cmdMatch = cmdScript.match(/^set "MIN_ATTESTED_VERSION=(v\d+\.\d+\.\d+)"/m); + expect(shMatch, "install.sh missing MIN_ATTESTED_VERSION assignment").toBeTruthy(); + expect(psMatch, "install.ps1 missing $minAttestedVersion assignment").toBeTruthy(); + expect(cmdMatch, "install.cmd missing MIN_ATTESTED_VERSION assignment").toBeTruthy(); + const values = new Set([shMatch![1], psMatch![1], cmdMatch![1]]); + if (values.size !== 1) { + throw new Error( + `MIN_ATTESTED_VERSION drift across installers: sh=${shMatch![1]}, ps=${psMatch![1]}, cmd=${cmdMatch![1]}. All three must match.` + ); + } + }); + + test("all installers hardcode MIN_ATTESTED_VERSION and guard verification against older tags", () => { + // Releases cut before this PR added `actions/attest-build-provenance` + // to release.yml have no attestations. Running `gh attestation verify` + // against them fails with "no attestations found" — a cryptic error + // that doesn't explain the user's actual problem (old version, no + // provenance support). Each installer now hardcodes a + // MIN_ATTESTED_VERSION constant and rejects verification requests + // for older tags BEFORE downloading the binary, with a clean error + // telling the user how to recover. + // + // The constant is bumped once by the release skill at the first + // attested release and then left alone as a permanent floor. + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + + // install.sh + expect(sh).toContain('MIN_ATTESTED_VERSION="v0.17.2"'); + expect(sh).toContain("version_ge"); + expect(sh).toContain("predates"); + // install.ps1 + expect(ps).toContain('$minAttestedVersion = "v0.17.2"'); + expect(ps).toContain("[version]"); + expect(ps).toContain("predates"); + // install.cmd + expect(cmdScript).toContain('set "MIN_ATTESTED_VERSION=v0.17.2"'); + expect(cmdScript).toContain("powershell -NoProfile -Command"); + expect(cmdScript).toContain("predates"); + }); + + test("install.sh and help text use vX.Y.Z placeholder not v0.17.1", () => { + // Regression guard: the docs and --help text previously used v0.17.1 + // as a concrete pinned-version example. That tag predates provenance + // support, so any user copy-pasting the example and enabling + // verification would hit a hard failure. Replaced with a generic + // vX.Y.Z placeholder across all user-facing docs. + expect(sh).not.toContain("--version v0.17.1"); + expect(sh).not.toContain("bash install.sh v0.17.1"); + }); + + test("install.cmd double-escapes ! in Claude Code and Gemini slash command echoes", () => { + // Regression guard: under setlocal enabledelayedexpansion, preserving a + // literal `!` through both cmd parser phases requires `^^!`, not `^!`. + // Phase 1 consumes one caret (`^^` → `^`), Phase 2 consumes the second + // (`^!` → `!`). A single `^!` gets converted to `!` by Phase 1 and then + // stripped by Phase 2 because it's an unmatched delayed-expansion + // reference — yielding a written file with no `!` at all. This was + // caught by the Windows CI integration step reading back the generated + // command files, after an earlier "fix" with single-caret escape + // silently continued to drop the prefix. + // + // Also covers the Gemini section, which used the same incorrect + // single-caret escape and was equally broken (but had no CI coverage). + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + // Claude Code slash commands (three files) + expect(cmdScript).toContain("echo ^^!`plannotator review $ARGUMENTS`"); + expect(cmdScript).toContain("echo ^^!`plannotator annotate $ARGUMENTS`"); + expect(cmdScript).toContain("echo ^^!`plannotator annotate-last`"); + // Gemini slash commands (two files) + expect(cmdScript).toContain("echo ^^!{plannotator review {{args}}}"); + expect(cmdScript).toContain("echo ^^!{plannotator annotate {{args}}}"); + // And the single-caret and unescaped forms must be gone + expect(cmdScript).not.toMatch(/^echo !`plannotator/m); + expect(cmdScript).not.toMatch(/^echo \^!`plannotator/m); + expect(cmdScript).not.toMatch(/^echo \^!{plannotator/m); + }); + + test("install.cmd uses substring test (not echo|findstr) for v-prefix normalization", () => { + // Regression guard: `echo !TAG! | findstr /b "v"` pipes an unquoted + // expanded variable, re-exposing cmd metacharacters (& | > <) in + // the value before the pipe parses. Must use the safe substring + // test pattern used elsewhere in the script. + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + expect(cmdScript).toContain('if not "!TAG:~0,1!"=="v"'); + expect(cmdScript).not.toContain("echo !TAG! | findstr"); + }); + + test("all installers constrain attestation verify to tag + signer workflow", () => { + // Every `gh attestation verify` call must pass --source-ref and + // --signer-workflow, not just --repo. Without --source-ref a + // misattached asset from a different release would pass; without + // --signer-workflow an attestation from an unrelated workflow in + // the same repo would pass. GitHub's own docs recommend both. + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + + for (const [name, script] of [["install.sh", sh], ["install.ps1", ps], ["install.cmd", cmdScript]] as const) { + if (!script.includes("--source-ref")) { + throw new Error(`${name} missing --source-ref constraint on gh attestation verify`); + } + if (!script.includes("refs/tags/")) { + throw new Error(`${name} --source-ref does not reference refs/tags/`); + } + if (!script.includes("--signer-workflow")) { + throw new Error(`${name} missing --signer-workflow constraint on gh attestation verify`); + } + if (!script.includes(".github/workflows/release.yml")) { + throw new Error(`${name} --signer-workflow does not reference release.yml`); + } + } + }); + + test("install.sh gates gh verification behind verify_attestation guard", () => { + // When the opt-in is off, the installer must print the SHA256-only info + // line and must not invoke gh. + expect(sh).toContain('if [ "$verify_attestation" -eq 1 ]; then'); + expect(sh).toContain("SHA256 verified"); + // The executable `gh attestation verify "$tmp_file"` call (not the + // mention in the --help usage block) must live inside the guarded branch. + const guardIdx = sh.indexOf('if [ "$verify_attestation" -eq 1 ]'); + const execIdx = sh.indexOf('gh attestation verify "$tmp_file"'); + expect(guardIdx).toBeGreaterThan(-1); + expect(execIdx).toBeGreaterThan(guardIdx); + }); +}); + +describe("PlannotatorConfig schema", () => { + test("exports verifyAttestation field", () => { + const configTs = readFileSync( + join(scriptsDir, "..", "packages", "shared", "config.ts"), + "utf-8", + ); + expect(configTs).toContain("verifyAttestation?: boolean"); + // Confirm it's part of the PlannotatorConfig interface, not unrelated code. + const match = configTs.match( + /export interface PlannotatorConfig \{([\s\S]*?)\n\}/ + ); + expect(match).toBeTruthy(); + expect(match![1]).toContain("verifyAttestation?: boolean"); + }); });