Fail closed when installing MCP binaries

Verify the signed release checksum manifest against Pulse's pinned SSH key before either MCP installer accepts a downloaded binary. Remove the unsigned bypass and require one exact digest entry.

Include bare Unix MCP executables in release checksum/signature assembly, cover unavailable, invalid, ambiguous, mismatched, and successful evidence paths with executable regression tests, and enforce MCP installer pins against the configured release key.
This commit is contained in:
pulse-triage[bot]
2026-08-30 05:06:49 +01:00
parent d8986c139a
commit e66f6a26f7
10 changed files with 426 additions and 70 deletions
@@ -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:
+4 -2
View File
@@ -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
@@ -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-<os>-<arch>` 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
@@ -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
+75 -28
View File
@@ -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
+55 -33
View File
@@ -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"
@@ -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`,
} {
+221
View File
@@ -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)
}
}
+9
View File
@@ -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
@@ -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)