diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index b97aac0f8..db1ab6973 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -799,12 +799,18 @@ jobs: --public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ --comment pulse-installer )" - for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do + for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh scripts/install-mcp.sh release/install-mcp.sh; do grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || { echo "::error::${installer} does not trust the configured release signing key." exit 1 } done + for installer in scripts/install-mcp.ps1 release/install-mcp.ps1; do + grep -F "\$PinnedReleaseSshPublicKey = '${TRUSTED_SSH_PUBLIC_KEY}'" "${installer}" >/dev/null || { + echo "::error::${installer} does not trust the configured release signing key." + exit 1 + } + done - name: Validate complete candidate locally env: diff --git a/docs/AGENT_SUBSTRATE.md b/docs/AGENT_SUBSTRATE.md index 6f1619989..c1014723e 100644 --- a/docs/AGENT_SUBSTRATE.md +++ b/docs/AGENT_SUBSTRATE.md @@ -71,8 +71,10 @@ outcome without polling the audit endpoint. notifications on the stdio channel, which lets an autonomous MCP-bound agent react to push events without holding a separate HTTP connection. The one-line installers `install-mcp.sh` and `install-mcp.ps1` fetch the - matching binary from the latest Pulse release and verify its checksum. - Building from source stays available. + matching binary from the latest Pulse release, verify the checksum manifest + against Pulse's pinned release key, and then verify the binary's checksum. + They refuse installation when any integrity evidence is unavailable or + invalid. Building from source stays available. - **`cmd/agent-probe`** is a small Go binary that walks the discovery, triage, depth, and push flow against a running instance. Use it as a smoke diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f5bc62fdc..11b98169a 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -3301,7 +3301,9 @@ used by Pulse Assistant to OpenCode, Claude Desktop, Claude Code, or other MCP-speaking clients. The installers fetch a published `pulse-mcp--` binary from the latest GitHub Release, verify SHA256 against the same `checksums.txt` the rest of the -release uses, and place the binary at `~/.local/bin/pulse-mcp` +release uses, first verifying `checksums.txt.sshsig` against the pinned +Pulse release key and failing closed if either integrity artifact is missing, +invalid, or ambiguous, and place the binary at `~/.local/bin/pulse-mcp` (Unix) or `$LOCALAPPDATA\pulse-mcp\pulse-mcp.exe` (Windows). The binary takes no version ldflags because it reads the manifest from the Pulse instance it points at. `scripts/build-release.sh` @@ -3316,7 +3318,7 @@ and `.../install-mcp.sh` are stable redirect targets the installers consume. macOS notarization is intentionally skipped for v1: the README documents the Gatekeeper bypass and the install-script flow downloads the same unsigned binary, with the -audit trail of SHA256 verification preserved. +audit trail of signed-manifest and SHA256 verification preserved. The adapter's complete request/response tool-list projection, manifest projection, capability and governance metadata formatting, request/response tool filtering, typed input-schema projection, and API route/body call diff --git a/frontend-modern/public/docs/AGENT_SUBSTRATE.md b/frontend-modern/public/docs/AGENT_SUBSTRATE.md index 6f1619989..c1014723e 100644 --- a/frontend-modern/public/docs/AGENT_SUBSTRATE.md +++ b/frontend-modern/public/docs/AGENT_SUBSTRATE.md @@ -71,8 +71,10 @@ outcome without polling the audit endpoint. notifications on the stdio channel, which lets an autonomous MCP-bound agent react to push events without holding a separate HTTP connection. The one-line installers `install-mcp.sh` and `install-mcp.ps1` fetch the - matching binary from the latest Pulse release and verify its checksum. - Building from source stays available. + matching binary from the latest Pulse release, verify the checksum manifest + against Pulse's pinned release key, and then verify the binary's checksum. + They refuse installation when any integrity evidence is unavailable or + invalid. Building from source stays available. - **`cmd/agent-probe`** is a small Go binary that walks the discovery, triage, depth, and push flow against a running instance. Use it as a smoke diff --git a/scripts/install-mcp.ps1 b/scripts/install-mcp.ps1 index 3e9cad8a5..0153d3e38 100644 --- a/scripts/install-mcp.ps1 +++ b/scripts/install-mcp.ps1 @@ -1,8 +1,8 @@ # Pulse MCP Server Adapter Installer (Windows) # # Detects the local architecture, downloads the matching pulse-mcp.exe -# from the latest GitHub Release, verifies SHA256 against the published -# checksums file, and places the binary on PATH. +# from the latest GitHub Release, verifies the signed checksum manifest against +# Pulse's pinned release key, verifies SHA256, and places the binary on PATH. # # Usage: # irm https://github.com/rcourtman/Pulse/releases/latest/download/install-mcp.ps1 | iex @@ -11,7 +11,6 @@ # PULSE_MCP_VERSION Override the version to install. Default: latest. # PULSE_MCP_BIN_DIR Where to install. Default: $env:LOCALAPPDATA\pulse-mcp. # PULSE_MCP_REPO GitHub repo. Default: rcourtman/Pulse. -# PULSE_MCP_NO_VERIFY If "1", skip SHA256 verification (not recommended). # # After install, configure your MCP client per `cmd/pulse-mcp/README.md` # in the Pulse repository (or your installed Pulse server's `docs/AGENT_SUBSTRATE.md`). @@ -19,8 +18,7 @@ param ( [string]$Version = $env:PULSE_MCP_VERSION, [string]$BinDir = $env:PULSE_MCP_BIN_DIR, - [string]$Repo = $env:PULSE_MCP_REPO, - [switch]$NoVerify + [string]$Repo = $env:PULSE_MCP_REPO ) $ErrorActionPreference = 'Stop' @@ -28,7 +26,9 @@ $ErrorActionPreference = 'Stop' if (-not $Version) { $Version = 'latest' } if (-not $Repo) { $Repo = 'rcourtman/Pulse' } if (-not $BinDir) { $BinDir = Join-Path $env:LOCALAPPDATA 'pulse-mcp' } -if ($env:PULSE_MCP_NO_VERIFY -eq '1') { $NoVerify = $true } +$SignatureIdentity = 'pulse-installer' +$SignatureNamespace = 'pulse-install' +$PinnedReleaseSshPublicKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer' function Write-Log($message) { Write-Host "[install-mcp] $message" @@ -53,22 +53,73 @@ function Resolve-ReleaseBase { return "https://github.com/$Repo/releases/download/$Version" } -function Get-RemoteChecksum($base, $binaryName) { - $checksumsUrl = "$base/checksums.txt" +function Get-SshKeygenPath { + $command = Get-Command ssh-keygen.exe -ErrorAction SilentlyContinue + if ($null -eq $command) { + $command = Get-Command ssh-keygen -ErrorAction SilentlyContinue + } + if ($null -eq $command) { + throw 'ssh-keygen is required to verify signed Pulse downloads; refusing unverified install' + } + return $command.Source +} + +function Assert-ChecksumManifestSignature($manifestPath, $signaturePath) { + $allowedSignersPath = [System.IO.Path]::GetTempFileName() + $stdoutPath = [System.IO.Path]::GetTempFileName() + $stderrPath = [System.IO.Path]::GetTempFileName() try { - $response = Invoke-WebRequest -Uri $checksumsUrl -UseBasicParsing -ErrorAction Stop - } catch { - Write-Log "warning: could not fetch checksums.txt; skipping verification" - return $null - } - foreach ($line in $response.Content -split "`n") { - $parts = $line.Trim() -split '\s+', 2 - if ($parts.Length -eq 2 -and $parts[1] -eq $binaryName) { - return $parts[0] + [System.IO.File]::WriteAllText($allowedSignersPath, "$SignatureIdentity $PinnedReleaseSshPublicKey`n") + $sshKeygen = Get-SshKeygenPath + $commandLine = "`"$sshKeygen`" -Y verify -f `"$allowedSignersPath`" -I `"$SignatureIdentity`" -n `"$SignatureNamespace`" -s `"$signaturePath`" < `"$manifestPath`"" + $process = Start-Process -FilePath 'cmd.exe' ` + -ArgumentList '/d', '/s', '/c', $commandLine ` + -NoNewWindow ` + -Wait ` + -PassThru ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + if ($process.ExitCode -ne 0) { + throw 'cryptographic signature verification failed for checksums.txt' } + } finally { + Remove-Item -Force $allowedSignersPath, $stdoutPath, $stderrPath -ErrorAction SilentlyContinue + } +} + +function Get-VerifiedRemoteChecksum($base, $binaryName) { + $checksumsUrl = "$base/checksums.txt" + $signatureUrl = "$checksumsUrl.sshsig" + $manifestPath = [System.IO.Path]::GetTempFileName() + $signaturePath = [System.IO.Path]::GetTempFileName() + try { + try { + Invoke-WebRequest -Uri $checksumsUrl -UseBasicParsing -OutFile $manifestPath -ErrorAction Stop + } catch { + throw 'could not fetch checksums.txt; refusing unverified install' + } + try { + Invoke-WebRequest -Uri $signatureUrl -UseBasicParsing -OutFile $signaturePath -ErrorAction Stop + } catch { + throw 'could not fetch checksums.txt.sshsig; refusing unverified install' + } + Assert-ChecksumManifestSignature $manifestPath $signaturePath + Write-Log 'release signature verified' + + $matches = @() + foreach ($line in [System.IO.File]::ReadAllLines($manifestPath)) { + $parts = $line.Trim() -split '\s+', 2 + if ($parts.Length -eq 2 -and $parts[1] -ceq $binaryName) { + $matches += $parts[0] + } + } + if ($matches.Count -ne 1 -or $matches[0] -notmatch '^[0-9a-fA-F]{64}$') { + throw "checksums.txt must contain exactly one valid SHA256 entry for $binaryName" + } + return $matches[0].ToLowerInvariant() + } finally { + Remove-Item -Force $manifestPath, $signaturePath -ErrorAction SilentlyContinue } - Write-Log "warning: $binaryName not listed in checksums.txt; skipping verification" - return $null } function Main { @@ -94,16 +145,12 @@ function Main { throw "download failed: $url`nIf a release exists for this version, the binary may not yet be published for $platform.`nBuild from source: go install github.com/rcourtman/pulse-go-rewrite/cmd/pulse-mcp@latest" } - if (-not $NoVerify) { - $expected = Get-RemoteChecksum $base $binaryName - if ($expected) { - $actual = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash.ToLower() - if ($actual -ne $expected.ToLower()) { - throw "sha256 mismatch for ${binaryName}: expected $expected, got $actual" - } - Write-Log 'sha256 verified' - } + $expected = Get-VerifiedRemoteChecksum $base $binaryName + $actual = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + throw "sha256 mismatch for ${binaryName}: expected $expected, got $actual" } + Write-Log 'sha256 verified' $dest = Join-Path $BinDir 'pulse-mcp.exe' Move-Item -Path $tmp -Destination $dest -Force diff --git a/scripts/install-mcp.sh b/scripts/install-mcp.sh index e99c5d695..85d1242d3 100755 --- a/scripts/install-mcp.sh +++ b/scripts/install-mcp.sh @@ -4,8 +4,8 @@ # # Detects the local platform/architecture, downloads the matching # pulse-mcp binary from the latest GitHub Release, verifies the -# SHA256 checksum against the published checksums file, and places -# the binary on PATH. +# signed checksum manifest against Pulse's pinned release key, verifies the +# binary's SHA256 digest, and places the binary on PATH. # # Usage: # curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install-mcp.sh | bash @@ -16,7 +16,6 @@ # PULSE_MCP_BIN_DIR Where to install the binary. # Default: $HOME/.local/bin if writable, else /usr/local/bin. # PULSE_MCP_REPO GitHub repo to download from. Default: rcourtman/Pulse. -# PULSE_MCP_NO_VERIFY If "1", skip SHA256 verification (not recommended). # # After install, configure your MCP client per `cmd/pulse-mcp/README.md` in the # Pulse repository (or `docs/AGENT_SUBSTRATE.md` in your installed Pulse server). @@ -28,7 +27,13 @@ set -euo pipefail REPO="${PULSE_MCP_REPO:-rcourtman/Pulse}" VERSION="${PULSE_MCP_VERSION:-latest}" -NO_VERIFY="${PULSE_MCP_NO_VERIFY:-}" +SIGNATURE_IDENTITY="pulse-installer" +SIGNATURE_NAMESPACE="pulse-install" +PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer" +tmp="" +checksums_tmp="" +signature_tmp="" +allowed_signers="" log() { printf '[install-mcp] %s\n' "$*" @@ -115,8 +120,10 @@ main() { require_cmd curl require_cmd uname require_cmd install + require_cmd ssh-keygen - local platform install_dir base bin_name url tmp checksums_url checksums_tmp + local platform install_dir base bin_name url checksums_url + local signature_url expected actual matches platform="$(detect_platform)" install_dir="$(choose_install_dir)" base="$(resolve_release_base)" @@ -136,36 +143,51 @@ If a release exists for this version, the binary may not yet be published for ${ Build from source: go install github.com/rcourtman/pulse-go-rewrite/cmd/pulse-mcp@latest" fi - if [ "${NO_VERIFY}" != "1" ]; then - local sha_cmd - if command -v sha256sum >/dev/null 2>&1; then - sha_cmd="sha256sum" - elif command -v shasum >/dev/null 2>&1; then - sha_cmd="shasum -a 256" - else - err "no sha256 tool found (sha256sum or shasum). Set PULSE_MCP_NO_VERIFY=1 to skip verification." - fi - - checksums_url="${base}/checksums.txt" - checksums_tmp="$(mktemp -t pulse-mcp-sums.XXXXXX)" - trap 'rm -f "${tmp}" "${checksums_tmp}"' EXIT - if ! curl -fsSL --retry 3 "${checksums_url}" -o "${checksums_tmp}"; then - log "warning: could not fetch checksums.txt; skipping verification" - else - local expected actual - expected="$(awk -v name="${bin_name}" '$2 == name {print $1; exit}' "${checksums_tmp}" || true)" - if [ -z "${expected}" ]; then - log "warning: ${bin_name} not listed in checksums.txt; skipping verification" - else - actual="$(${sha_cmd} "${tmp}" | awk '{print $1}')" - if [ "${expected}" != "${actual}" ]; then - err "sha256 mismatch for ${bin_name}: expected ${expected}, got ${actual}" - fi - log "sha256 verified" - fi - fi + local sha_cmd + if command -v sha256sum >/dev/null 2>&1; then + sha_cmd="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + sha_cmd="shasum -a 256" + else + err "no sha256 tool found (sha256sum or shasum); refusing unverified install" fi + checksums_url="${base}/checksums.txt" + signature_url="${checksums_url}.sshsig" + checksums_tmp="$(mktemp -t pulse-mcp-sums.XXXXXX)" + signature_tmp="$(mktemp -t pulse-mcp-signature.XXXXXX)" + allowed_signers="$(mktemp -t pulse-mcp-signers.XXXXXX)" + trap 'rm -f "${tmp}" "${checksums_tmp}" "${signature_tmp}" "${allowed_signers}"' EXIT + + if ! curl -fsSL --retry 3 "${checksums_url}" -o "${checksums_tmp}"; then + err "could not fetch checksums.txt; refusing unverified install" + fi + if ! curl -fsSL --retry 3 "${signature_url}" -o "${signature_tmp}"; then + err "could not fetch checksums.txt.sshsig; refusing unverified install" + fi + + printf '%s %s\n' "${SIGNATURE_IDENTITY}" "${PINNED_RELEASE_SSH_PUBLIC_KEY}" > "${allowed_signers}" + if ! ssh-keygen -Y verify \ + -f "${allowed_signers}" \ + -I "${SIGNATURE_IDENTITY}" \ + -n "${SIGNATURE_NAMESPACE}" \ + -s "${signature_tmp}" < "${checksums_tmp}" >/dev/null 2>&1; then + err "cryptographic signature verification failed for checksums.txt" + fi + log "release signature verified" + + matches="$(awk -v name="${bin_name}" '$2 == name && NF == 2 {count++; checksum=$1} END {if (count == 1) print checksum; else exit 1}' "${checksums_tmp}" || true)" + expected="$(printf '%s' "${matches}" | tr '[:upper:]' '[:lower:]')" + if ! printf '%s' "${expected}" | grep -Eq '^[0-9a-f]{64}$'; then + err "checksums.txt must contain exactly one valid SHA256 entry for ${bin_name}" + fi + + actual="$(${sha_cmd} "${tmp}" | awk '{print tolower($1)}')" + if [ "${expected}" != "${actual}" ]; then + err "sha256 mismatch for ${bin_name}: expected ${expected}, got ${actual}" + fi + log "sha256 verified" + install -m 0755 "${tmp}" "${install_dir}/pulse-mcp" log "installed: ${install_dir}/pulse-mcp" diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index c019783c2..c8dbe733a 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2512,8 +2512,10 @@ func TestBuildReleasePackagesPulseMcpForAllPlatforms(t *testing.T) { for _, needle := range []string{ `detect_platform()`, `choose_install_dir()`, - `PULSE_MCP_NO_VERIFY`, + `PINNED_RELEASE_SSH_PUBLIC_KEY`, `checksums.txt`, + `checksums.txt.sshsig`, + `ssh-keygen -Y verify`, `sha256 mismatch`, } { if !strings.Contains(string(mcpScript), needle) { @@ -2521,14 +2523,54 @@ func TestBuildReleasePackagesPulseMcpForAllPlatforms(t *testing.T) { } } + // Unix installers consume bare binaries, not the versioned archives. Keep + // those exact assets in the signed manifest; the broad pulse-*.tar.gz and + // pulse-*.exe patterns otherwise leave only Unix bare MCP binaries out. + commonContent, err := os.ReadFile(repoFile("scripts", "release_asset_common.sh")) + if err != nil { + t.Fatalf("read release_asset_common.sh: %v", err) + } + for _, needle := range []string{ + `checksum_files+=( pulse-mcp-linux-* )`, + `checksum_files+=( pulse-mcp-darwin-* )`, + `checksum_files+=( pulse-mcp-freebsd-* )`, + } { + if !strings.Contains(string(commonContent), needle) { + t.Fatalf("release checksum collection missing bare MCP assets: %s", needle) + } + } + + releaseDir := t.TempDir() + for _, asset := range []string{ + "pulse-mcp-linux-amd64", + "pulse-mcp-darwin-arm64", + "pulse-mcp-freebsd-amd64", + } { + if err := os.WriteFile(filepath.Join(releaseDir, asset), []byte(asset), 0o755); err != nil { + t.Fatalf("write MCP checksum fixture: %v", err) + } + } + checksumCmd := exec.Command("bash", "-c", `source "$1"; pulse_release_collect_checksum_files "$2"`, "pulse-mcp-checksum-test", repoFile("scripts", "release_asset_common.sh"), releaseDir) + checksumOutput, err := checksumCmd.CombinedOutput() + if err != nil { + t.Fatalf("collect MCP release checksum files: %v\n%s", err, checksumOutput) + } + for _, asset := range []string{"pulse-mcp-linux-amd64", "pulse-mcp-darwin-arm64", "pulse-mcp-freebsd-amd64"} { + if !strings.Contains(string(checksumOutput), asset) { + t.Fatalf("bare MCP release asset %s missing from checksum/signature input:\n%s", asset, checksumOutput) + } + } + mcpPowerShell, err := os.ReadFile(repoFile("scripts", "install-mcp.ps1")) if err != nil { t.Fatalf("read install-mcp.ps1: %v", err) } for _, needle := range []string{ `function Resolve-Architecture`, - `PULSE_MCP_NO_VERIFY`, + `$PinnedReleaseSshPublicKey`, `checksums.txt`, + `checksums.txt.sshsig`, + `Assert-ChecksumManifestSignature`, `Get-FileHash -Path $tmp -Algorithm SHA256`, `sha256 mismatch`, } { diff --git a/scripts/installtests/install_mcp_test.go b/scripts/installtests/install_mcp_test.go new file mode 100644 index 000000000..3577057f5 --- /dev/null +++ b/scripts/installtests/install_mcp_test.go @@ -0,0 +1,221 @@ +package installtests + +import ( + "crypto/sha256" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const productionMCPReleaseKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer" + +func TestInstallMCPRequiresSignedChecksumEvidence(t *testing.T) { + for _, command := range []string{"bash", "ssh-keygen"} { + if _, err := exec.LookPath(command); err != nil { + t.Skipf("%s not installed", command) + } + } + + tests := []struct { + name string + manifest func(binary []byte) string + invalidSig bool + missingAsset string + wantSuccess bool + wantOutput string + }{ + { + name: "valid signed manifest", + manifest: func(binary []byte) string { + digest := sha256.Sum256(binary) + return fmt.Sprintf("%x pulse-mcp-linux-amd64\n", digest) + }, + wantSuccess: true, + wantOutput: "release signature verified", + }, + { + name: "manifest unavailable", + manifest: func(binary []byte) string { + digest := sha256.Sum256(binary) + return fmt.Sprintf("%x pulse-mcp-linux-amd64\n", digest) + }, + missingAsset: "checksums.txt", + wantOutput: "could not fetch checksums.txt; refusing unverified install", + }, + { + name: "signature unavailable", + manifest: func(binary []byte) string { + digest := sha256.Sum256(binary) + return fmt.Sprintf("%x pulse-mcp-linux-amd64\n", digest) + }, + missingAsset: "checksums.txt.sshsig", + wantOutput: "could not fetch checksums.txt.sshsig; refusing unverified install", + }, + { + name: "signature invalid", + manifest: func(binary []byte) string { + digest := sha256.Sum256(binary) + return fmt.Sprintf("%x pulse-mcp-linux-amd64\n", digest) + }, + invalidSig: true, + wantOutput: "cryptographic signature verification failed for checksums.txt", + }, + { + name: "binary omitted", + manifest: func(binary []byte) string { + digest := sha256.Sum256(binary) + return fmt.Sprintf("%x another-file\n", digest) + }, + wantOutput: "checksums.txt must contain exactly one valid SHA256 entry", + }, + { + name: "binary duplicated", + manifest: func(binary []byte) string { + digest := sha256.Sum256(binary) + return fmt.Sprintf("%x pulse-mcp-linux-amd64\n%x pulse-mcp-linux-amd64\n", digest, digest) + }, + wantOutput: "checksums.txt must contain exactly one valid SHA256 entry", + }, + { + name: "digest mismatch", + manifest: func(_ []byte) string { + return strings.Repeat("0", 64) + " pulse-mcp-linux-amd64\n" + }, + wantOutput: "sha256 mismatch for pulse-mcp-linux-amd64", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp := t.TempDir() + fixtureDir := filepath.Join(tmp, "fixtures") + binDir := filepath.Join(tmp, "bin") + installDir := filepath.Join(tmp, "install") + for _, dir := range []string{fixtureDir, binDir, installDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + + binary := []byte("test pulse-mcp executable\n") + writeTestFile(t, filepath.Join(fixtureDir, "pulse-mcp-linux-amd64"), binary, 0o644) + manifestPath := filepath.Join(fixtureDir, "checksums.txt") + writeTestFile(t, manifestPath, []byte(tt.manifest(binary)), 0o644) + + privateKeyPath := filepath.Join(tmp, "release-key") + runTestCommand(t, exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "pulse-installer", "-f", privateKeyPath)) + publicKeyBytes, err := os.ReadFile(privateKeyPath + ".pub") + if err != nil { + t.Fatalf("read test public key: %v", err) + } + publicKey := strings.TrimSpace(string(publicKeyBytes)) + + sign := exec.Command("ssh-keygen", "-q", "-Y", "sign", "-f", privateKeyPath, "-n", "pulse-install", manifestPath) + runTestCommand(t, sign) + if err := os.Rename(manifestPath+".sig", filepath.Join(fixtureDir, "checksums.txt.sshsig")); err != nil { + t.Fatalf("rename signature: %v", err) + } + if tt.invalidSig { + writeTestFile(t, filepath.Join(fixtureDir, "checksums.txt.sshsig"), []byte("not a signature\n"), 0o644) + } + + scriptBytes, err := os.ReadFile(repoFile("scripts", "install-mcp.sh")) + if err != nil { + t.Fatalf("read installer: %v", err) + } + script := strings.Replace(string(scriptBytes), productionMCPReleaseKey, publicKey, 1) + if script == string(scriptBytes) { + t.Fatal("installer no longer contains the expected pinned production key") + } + scriptPath := filepath.Join(tmp, "install-mcp.sh") + writeTestFile(t, scriptPath, []byte(script), 0o755) + + curlStub := `#!/bin/sh +set -eu +output='' +url='' +while [ "$#" -gt 0 ]; do + case "$1" in + -o) output="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac +done +name="${url##*/}" +if [ -n "${MISSING_ASSET:-}" ] && [ "$name" = "$MISSING_ASSET" ]; then + exit 22 +fi +cp "$FIXTURE_DIR/$name" "$output" +` + writeTestFile(t, filepath.Join(binDir, "curl"), []byte(curlStub), 0o755) + + cmd := exec.Command("bash", scriptPath) + cmd.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "HOME="+tmp, + "FIXTURE_DIR="+fixtureDir, + "MISSING_ASSET="+tt.missingAsset, + "PULSE_MCP_BIN_DIR="+installDir, + "PULSE_MCP_VERSION=v-test", + ) + output, runErr := cmd.CombinedOutput() + if tt.wantSuccess && runErr != nil { + t.Fatalf("installer failed: %v\n%s", runErr, output) + } + if !tt.wantSuccess && runErr == nil { + t.Fatalf("installer unexpectedly succeeded:\n%s", output) + } + if !strings.Contains(string(output), tt.wantOutput) { + t.Fatalf("installer output missing %q:\n%s", tt.wantOutput, output) + } + + installedPath := filepath.Join(installDir, "pulse-mcp") + _, statErr := os.Stat(installedPath) + if tt.wantSuccess && statErr != nil { + t.Fatalf("verified binary was not installed: %v", statErr) + } + if !tt.wantSuccess && !os.IsNotExist(statErr) { + t.Fatalf("unverified binary reached install destination: stat err=%v", statErr) + } + }) + } +} + +func TestInstallMCPPowerShellFailsClosed(t *testing.T) { + path := repoFile("scripts", "install-mcp.ps1") + assertFileContainsAll(t, path, + "$PinnedReleaseSshPublicKey = 'ssh-ed25519 ", + "Get-SshKeygenPath", + "Assert-ChecksumManifestSignature $manifestPath $signaturePath", + "could not fetch checksums.txt; refusing unverified install", + "could not fetch checksums.txt.sshsig; refusing unverified install", + "checksums.txt must contain exactly one valid SHA256 entry", + "sha256 mismatch for ${binaryName}", + ) + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read PowerShell installer: %v", err) + } + for _, insecure := range []string{"PULSE_MCP_NO_VERIFY", "NoVerify", "skipping verification"} { + if strings.Contains(string(content), insecure) { + t.Fatalf("PowerShell installer retains fail-open control %q", insecure) + } + } +} + +func writeTestFile(t *testing.T, path string, content []byte, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, content, mode); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func runTestCommand(t *testing.T, cmd *exec.Cmd) { + t.Helper() + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s failed: %v\n%s", strings.Join(cmd.Args, " "), err, output) + } +} diff --git a/scripts/release_asset_common.sh b/scripts/release_asset_common.sh index 041bae818..713e46494 100644 --- a/scripts/release_asset_common.sh +++ b/scripts/release_asset_common.sh @@ -257,6 +257,15 @@ pulse_release_collect_checksum_files() { if compgen -G "pulse-agent-freebsd-*" > /dev/null; then checksum_files+=( pulse-agent-freebsd-* ) fi + if compgen -G "pulse-mcp-linux-*" > /dev/null; then + checksum_files+=( pulse-mcp-linux-* ) + fi + if compgen -G "pulse-mcp-darwin-*" > /dev/null; then + checksum_files+=( pulse-mcp-darwin-* ) + fi + if compgen -G "pulse-mcp-freebsd-*" > /dev/null; then + checksum_files+=( pulse-mcp-freebsd-* ) + fi if compgen -G "pulse-*.exe" > /dev/null; then checksum_files+=( pulse-*.exe ) fi diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 8746a2cd1..807511fc6 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -1632,6 +1632,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertNotIn('tar -xOf "$tarball" "$entry"', release_validator) self.assertIn("go run ./scripts/release_update_key.go public-key-ssh", candidate_workflow) self.assertIn("does not trust the configured release signing key", candidate_workflow) + self.assertIn("scripts/install-mcp.sh release/install-mcp.sh", candidate_workflow) + self.assertIn("scripts/install-mcp.ps1 release/install-mcp.ps1", candidate_workflow) + self.assertIn("$PinnedReleaseSshPublicKey = '${TRUSTED_SSH_PUBLIC_KEY}'", candidate_workflow) self.assertIn("TRUSTED_SSH_PUBLIC_KEY", update_demo_workflow) self.assertIn('sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\\"${TRUSTED_SSH_PUBLIC_KEY}\\"|" /tmp/pulse-install.sh', update_demo_workflow) self.assertIn("bash .github/scripts/setup-demo-ssh.sh", update_demo_workflow)