From b25c55eb9b59b043495b8fffff6c4dca249cbbb2 Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:15:24 +0400
Subject: [PATCH 01/14] ci(release): publish SHA256SUMS for every release asset
Let the installer verify downloads against a same-origin checksum
manifest before extract (#1267).
---
.github/workflows/build-release.yml | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index 9e5292b5..64a54a88 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -113,9 +113,26 @@ jobs:
path: release
merge-multiple: true
+ # Same-origin integrity manifest for every release asset. Hashes use bare
+ # basenames (no directory prefix) so the installer can match the file it
+ # downloaded into a temp dir. Detects corruption / single-asset swap; not
+ # a defense against a fully compromised release pipeline — that needs
+ # Sigstore / build-provenance attestation (handled separately in #1267).
+ - name: Generate SHA256SUMS
+ working-directory: release
+ shell: bash
+ run: |
+ : > SHA256SUMS
+ while IFS= read -r -d '' f; do
+ ( cd "$(dirname "$f")" && sha256sum "$(basename "$f")" )
+ done < <(find . -type f ! -name SHA256SUMS -print0 | sort -z) >> SHA256SUMS
+ echo "=== SHA256SUMS ==="
+ cat SHA256SUMS
+
- name: Create Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
generate_release_notes: true
+ # release/** already includes release/SHA256SUMS generated above.
files: release/**
From 3b870c8fbbbadefe512f0f8180c306cc87e284db Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:17:41 +0400
Subject: [PATCH 02/14] ci(release): attest binaries, wheels, and SHA256SUMS
Sign each published artifact with Sigstore keyless provenance from
this workflow so the installer can verify origin (#1267).
---
.github/workflows/build-release.yml | 36 ++++++++++++++++++++++++++---
1 file changed, 33 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index 64a54a88..4d13adbe 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -32,6 +32,10 @@ jobs:
wheel-platform: win_amd64
runs-on: ${{ matrix.os }}
+ permissions:
+ contents: read
+ id-token: write
+ attestations: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -92,6 +96,25 @@ jobs:
tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}"
fi
+ # Sigstore keyless provenance (GitHub OIDC). Attest the published archive
+ # and wheel on the runner that built them so the installer can pin
+ # usestrix/strix/.github/workflows/build-release.yml.
+ - name: Attest Unix archive and wheel
+ if: runner.os != 'Windows'
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
+ with:
+ subject-path: |
+ dist/release/*.tar.gz
+ dist/*.whl
+
+ - name: Attest Windows archive and wheel
+ if: runner.os == 'Windows'
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
+ with:
+ subject-path: |
+ dist/release/*.zip
+ dist/*.whl
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: strix-${{ matrix.target }}
@@ -106,6 +129,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
+ id-token: write
+ attestations: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
@@ -115,9 +140,9 @@ jobs:
# Same-origin integrity manifest for every release asset. Hashes use bare
# basenames (no directory prefix) so the installer can match the file it
- # downloaded into a temp dir. Detects corruption / single-asset swap; not
- # a defense against a fully compromised release pipeline — that needs
- # Sigstore / build-provenance attestation (handled separately in #1267).
+ # downloaded into a temp dir. Detects corruption / single-asset swap.
+ # Sigstore provenance on the archive itself is what binds the bytes to
+ # this workflow (#1267).
- name: Generate SHA256SUMS
working-directory: release
shell: bash
@@ -129,6 +154,11 @@ jobs:
echo "=== SHA256SUMS ==="
cat SHA256SUMS
+ - name: Attest SHA256SUMS
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
+ with:
+ subject-path: release/SHA256SUMS
+
- name: Create Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
From 8d09cff91251e61068be2491dbd7a0aa3b4cadeb Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:22:07 +0400
Subject: [PATCH 03/14] ci(release): publish Sigstore attestation bundles
Ship per-target and SHA256SUMS .intoto.jsonl files with the release
so provenance is downloadable for manual and offline checks (#1267).
---
.github/workflows/build-release.yml | 39 +++++++++++++++++++++++------
1 file changed, 31 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index 4d13adbe..c1ab14c8 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -98,8 +98,11 @@ jobs:
# Sigstore keyless provenance (GitHub OIDC). Attest the published archive
# and wheel on the runner that built them so the installer can pin
- # usestrix/strix/.github/workflows/build-release.yml.
+ # usestrix/strix/.github/workflows/build-release.yml. The bundle is also
+ # copied into dist/release/ so it ships as a release asset for manual /
+ # offline verification (#1267).
- name: Attest Unix archive and wheel
+ id: attest-unix
if: runner.os != 'Windows'
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
@@ -108,6 +111,7 @@ jobs:
dist/*.whl
- name: Attest Windows archive and wheel
+ id: attest-windows
if: runner.os == 'Windows'
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
@@ -115,12 +119,23 @@ jobs:
dist/release/*.zip
dist/*.whl
+ - name: Publish attestation bundle
+ shell: bash
+ env:
+ # Only one of the attest steps runs; the other output is empty.
+ BUNDLE_PATH: ${{ steps.attest-unix.outputs.bundle-path }}${{ steps.attest-windows.outputs.bundle-path }}
+ run: |
+ test -n "$BUNDLE_PATH"
+ test -f "$BUNDLE_PATH"
+ cp "$BUNDLE_PATH" "dist/release/strix-${{ matrix.target }}.intoto.jsonl"
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: strix-${{ matrix.target }}
path: |
dist/release/*.tar.gz
dist/release/*.zip
+ dist/release/*.intoto.jsonl
dist/*.whl
if-no-files-found: error
@@ -138,11 +153,9 @@ jobs:
path: release
merge-multiple: true
- # Same-origin integrity manifest for every release asset. Hashes use bare
- # basenames (no directory prefix) so the installer can match the file it
- # downloaded into a temp dir. Detects corruption / single-asset swap.
- # Sigstore provenance on the archive itself is what binds the bytes to
- # this workflow (#1267).
+ # Same-origin integrity manifest for product assets (archives + wheels).
+ # Provenance bundles are published alongside the release but excluded here
+ # so SHA256SUMS stays a stable product-byte list the installer greps.
- name: Generate SHA256SUMS
working-directory: release
shell: bash
@@ -150,19 +163,29 @@ jobs:
: > SHA256SUMS
while IFS= read -r -d '' f; do
( cd "$(dirname "$f")" && sha256sum "$(basename "$f")" )
- done < <(find . -type f ! -name SHA256SUMS -print0 | sort -z) >> SHA256SUMS
+ done < <(find . -type f ! -name SHA256SUMS ! -name '*.intoto.jsonl' -print0 | sort -z) >> SHA256SUMS
echo "=== SHA256SUMS ==="
cat SHA256SUMS
- name: Attest SHA256SUMS
+ id: attest-sums
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: release/SHA256SUMS
+ - name: Publish SHA256SUMS attestation bundle
+ shell: bash
+ env:
+ BUNDLE_PATH: ${{ steps.attest-sums.outputs.bundle-path }}
+ run: |
+ test -n "$BUNDLE_PATH"
+ test -f "$BUNDLE_PATH"
+ cp "$BUNDLE_PATH" release/SHA256SUMS.intoto.jsonl
+
- name: Create Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
generate_release_notes: true
- # release/** already includes release/SHA256SUMS generated above.
+ # Includes SHA256SUMS and *.intoto.jsonl provenance bundles.
files: release/**
From d61e0a2a6399e54ac5be6041259e5af98122e290 Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:31:52 +0400
Subject: [PATCH 04/14] fix(install): verify Sigstore provenance before extract
Fail closed unless gh or cosign can check the published bundle
against this release workflow (#1267).
---
scripts/install.sh | 81 ++++++++++++++++++++++++++++++++++++
tests/test_install_script.py | 10 +++++
2 files changed, 91 insertions(+)
diff --git a/scripts/install.sh b/scripts/install.sh
index e179b558..471e7d89 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -85,6 +85,10 @@ fi
filename="$APP-${specific_version}-${target}${archive_ext}"
url="https://github.com/$REPO/releases/download/v${specific_version}/$filename"
+bundle_name="strix-${target}.intoto.jsonl"
+bundle_url="https://github.com/$REPO/releases/download/v${specific_version}/$bundle_name"
+SIGNER_WORKFLOW="$REPO/.github/workflows/build-release.yml"
+CERT_IDENTITY_REGEXP="^https://github.com/${REPO}/.github/workflows/build-release.yml"
print_message() {
local level=$1
@@ -129,6 +133,75 @@ check_existing_installation() {
fi
}
+abort_unverified() {
+ echo -e "${RED}✗ Refusing to install an unverified binary.${NC}"
+ echo -e "${RED}Re-run with STRIX_INSTALL_SKIP_VERIFY=1 to override (at your own risk).${NC}"
+ exit 1
+}
+
+gh_can_verify_attestation() {
+ command -v gh >/dev/null 2>&1 && gh attestation verify --help >/dev/null 2>&1
+}
+
+cosign_can_verify_attestation() {
+ command -v cosign >/dev/null 2>&1 && cosign verify-blob-attestation --help >/dev/null 2>&1
+}
+
+# Fail-closed provenance check. The bundle is signed Sigstore SLSA provenance
+# for this workflow; checksum verification (same-origin) is a separate step.
+# Prefer `gh attestation verify`; otherwise a local `cosign`. We do not
+# bootstrap cosign — current releases are ~140MB, which is too heavy for
+# curl|bash. Missing verifier or a failed check aborts before extract.
+verify_provenance() {
+ local file=$1
+ local bundle=$2
+
+ if [ -n "${STRIX_INSTALL_SKIP_VERIFY:-}" ]; then
+ echo -e "${YELLOW}⚠ STRIX_INSTALL_SKIP_VERIFY set — skipping provenance verification (at your own risk).${NC}"
+ return 0
+ fi
+
+ if [ ! -s "$bundle" ]; then
+ echo -e "${RED}✗ Missing provenance bundle ${bundle}.${NC}"
+ abort_unverified
+ fi
+
+ echo -e "${MUTED}Verifying Sigstore provenance...${NC}"
+
+ if gh_can_verify_attestation; then
+ if gh attestation verify "$file" \
+ --repo "$REPO" \
+ --bundle "$bundle" \
+ --signer-workflow "$SIGNER_WORKFLOW" \
+ --predicate-type "https://slsa.dev/provenance/v1" \
+ --deny-self-hosted-runners; then
+ echo -e "${GREEN}✓ Provenance verified${NC} ${MUTED}(gh)${NC}"
+ return 0
+ fi
+ echo -e "${RED}✗ gh attestation verify failed.${NC}"
+ abort_unverified
+ fi
+
+ if cosign_can_verify_attestation; then
+ if cosign verify-blob-attestation \
+ --bundle "$bundle" \
+ --new-bundle-format \
+ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
+ --certificate-identity-regexp "$CERT_IDENTITY_REGEXP" \
+ --type slsaprovenance1 \
+ "$file"; then
+ echo -e "${GREEN}✓ Provenance verified${NC} ${MUTED}(cosign)${NC}"
+ return 0
+ fi
+ echo -e "${RED}✗ cosign verify-blob-attestation failed.${NC}"
+ abort_unverified
+ fi
+
+ echo -e "${RED}✗ Neither a usable 'gh' nor 'cosign' was found; cannot verify provenance.${NC}"
+ echo -e "${MUTED}Install GitHub CLI (gh) or cosign, then re-run.${NC}"
+ abort_unverified
+}
+
check_version() {
check_existing_installation
@@ -158,6 +231,14 @@ download_and_install() {
exit 1
fi
+ echo -e "${MUTED}Downloading provenance...${NC}"
+ if ! curl -sfL -o "$bundle_name" "$bundle_url" || [ ! -s "$bundle_name" ]; then
+ echo -e "${RED}✗ Failed to download provenance bundle.${NC}"
+ abort_unverified
+ fi
+
+ verify_provenance "$filename" "$bundle_name"
+
echo -e "${MUTED}Extracting...${NC}"
if [ "$os" = "windows" ]; then
unzip -q "$filename"
diff --git a/tests/test_install_script.py b/tests/test_install_script.py
index 67ca4843..2c9acc9b 100644
--- a/tests/test_install_script.py
+++ b/tests/test_install_script.py
@@ -48,6 +48,16 @@ esac
""",
)
_write_executable(mock_bin / "docker", "#!/bin/sh\nexit 0\n")
+ _write_executable(
+ mock_bin / "gh",
+ """#!/bin/sh
+# Prefer this mock over a real gh on PATH so tests do not hit GitHub.
+if [ "$1" = "attestation" ]; then
+ exit 0
+fi
+exit 1
+""",
+ )
_write_executable(
mock_bin / "curl",
"""#!/bin/sh
From 14b0aee28bd6bd56f1841f310875c2c0180a83c1 Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:35:05 +0400
Subject: [PATCH 05/14] fix(install): verify SHA256SUMS before extract
Fail closed when the published checksum is missing or does not
match the downloaded archive (#1267).
---
scripts/install.sh | 73 +++++++++++++++++++++++++++++++++---
tests/test_install_script.py | 18 ++++++++-
2 files changed, 84 insertions(+), 7 deletions(-)
diff --git a/scripts/install.sh b/scripts/install.sh
index 471e7d89..3bcc0478 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -85,6 +85,8 @@ fi
filename="$APP-${specific_version}-${target}${archive_ext}"
url="https://github.com/$REPO/releases/download/v${specific_version}/$filename"
+sums_name="SHA256SUMS"
+sums_url="https://github.com/$REPO/releases/download/v${specific_version}/$sums_name"
bundle_name="strix-${target}.intoto.jsonl"
bundle_url="https://github.com/$REPO/releases/download/v${specific_version}/$bundle_name"
SIGNER_WORKFLOW="$REPO/.github/workflows/build-release.yml"
@@ -139,6 +141,55 @@ abort_unverified() {
exit 1
}
+# Fail-closed checksum check against the published SHA256SUMS manifest.
+# Same-origin only (detects corruption / single-asset swap). Exact field
+# match — do not grep the filename as a regex ('.' would be wild).
+verify_checksum() {
+ local file=$1
+
+ if [ -n "${STRIX_INSTALL_SKIP_VERIFY:-}" ]; then
+ echo -e "${YELLOW}⚠ STRIX_INSTALL_SKIP_VERIFY set — skipping checksum verification (at your own risk).${NC}"
+ return 0
+ 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
+ echo -e "${RED}✗ Neither 'sha256sum' nor 'shasum' is available; cannot verify integrity.${NC}"
+ abort_unverified
+ fi
+
+ if [ ! -s "$sums_name" ]; then
+ echo -e "${RED}✗ Missing checksum manifest ${sums_name}.${NC}"
+ abort_unverified
+ fi
+
+ echo -e "${MUTED}Verifying checksum...${NC}"
+
+ local expected
+ expected=$(awk -v file="$file" '
+ $2 == file || $2 == ("*" file) { print $1; exit }
+ ' "$sums_name")
+ if [ -z "$expected" ]; then
+ echo -e "${RED}✗ No SHA256SUMS entry for ${file}.${NC}"
+ abort_unverified
+ fi
+
+ local actual
+ actual=$($sha_cmd "$file" | awk '{print $1}')
+ if [ "$actual" != "$expected" ]; then
+ echo -e "${RED}✗ Checksum mismatch for ${file}.${NC}"
+ echo -e "${MUTED}Expected: ${NC}$expected"
+ echo -e "${MUTED}Actual: ${NC}$actual"
+ abort_unverified
+ fi
+
+ echo -e "${GREEN}✓ Checksum verified${NC}"
+}
+
gh_can_verify_attestation() {
command -v gh >/dev/null 2>&1 && gh attestation verify --help >/dev/null 2>&1
}
@@ -231,13 +282,23 @@ download_and_install() {
exit 1
fi
- echo -e "${MUTED}Downloading provenance...${NC}"
- if ! curl -sfL -o "$bundle_name" "$bundle_url" || [ ! -s "$bundle_name" ]; then
- echo -e "${RED}✗ Failed to download provenance bundle.${NC}"
- abort_unverified
- fi
+ if [ -n "${STRIX_INSTALL_SKIP_VERIFY:-}" ]; then
+ echo -e "${YELLOW}⚠ STRIX_INSTALL_SKIP_VERIFY set — skipping checksum and provenance checks.${NC}"
+ else
+ echo -e "${MUTED}Downloading checksums...${NC}"
+ if ! curl -sfL -o "$sums_name" "$sums_url" || [ ! -s "$sums_name" ]; then
+ echo -e "${RED}✗ Failed to download checksum manifest.${NC}"
+ abort_unverified
+ fi
+ verify_checksum "$filename"
- verify_provenance "$filename" "$bundle_name"
+ echo -e "${MUTED}Downloading provenance...${NC}"
+ if ! curl -sfL -o "$bundle_name" "$bundle_url" || [ ! -s "$bundle_name" ]; then
+ echo -e "${RED}✗ Failed to download provenance bundle.${NC}"
+ abort_unverified
+ fi
+ verify_provenance "$filename" "$bundle_name"
+ fi
echo -e "${MUTED}Extracting...${NC}"
if [ "$os" = "windows" ]; then
diff --git a/tests/test_install_script.py b/tests/test_install_script.py
index 2c9acc9b..2bde6820 100644
--- a/tests/test_install_script.py
+++ b/tests/test_install_script.py
@@ -62,16 +62,32 @@ exit 1
mock_bin / "curl",
"""#!/bin/sh
output=""
+url=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "-o" ]; then
output="$2"
shift 2
continue
fi
+ case "$1" in
+ http://*|https://*) url="$1" ;;
+ esac
printf '%s\\n' "$1" >> "$STRIX_TEST_CURL_LOG"
shift
done
-cp "$STRIX_TEST_ARCHIVE" "$output"
+case "$url" in
+ */SHA256SUMS)
+ hash=$(sha256sum "$STRIX_TEST_ARCHIVE" | awk '{print $1}')
+ name=$(basename "$STRIX_TEST_ARCHIVE")
+ printf '%s %s\\n' "$hash" "$name" > "$output"
+ ;;
+ *.intoto.jsonl)
+ printf '{"test":true}\\n' > "$output"
+ ;;
+ *)
+ cp "$STRIX_TEST_ARCHIVE" "$output"
+ ;;
+esac
""",
)
return mock_bin
From 0636584eef6735b9508dd0023b23e9552e8b7174 Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:38:33 +0400
Subject: [PATCH 06/14] fix(install): leave existing Strix untouched on verify
failure
Stage into *.new and only rename after checks pass; abort cleans
temps and keeps the current binary (#1267).
---
scripts/install.sh | 30 ++++++++++++++++++++------
tests/test_install_script.py | 41 ++++++++++++++++++++++++++++++++++--
2 files changed, 63 insertions(+), 8 deletions(-)
diff --git a/scripts/install.sh b/scripts/install.sh
index 3bcc0478..a81f4d46 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -137,6 +137,9 @@ check_existing_installation() {
abort_unverified() {
echo -e "${RED}✗ Refusing to install an unverified binary.${NC}"
+ if [[ -x "$INSTALL_DIR/strix" || -x "$INSTALL_DIR/strix.exe" ]]; then
+ echo -e "${MUTED}Existing Strix installation left unchanged.${NC}"
+ fi
echo -e "${RED}Re-run with STRIX_INSTALL_SKIP_VERIFY=1 to override (at your own risk).${NC}"
exit 1
}
@@ -271,7 +274,20 @@ download_and_install() {
print_message info "\n${CYAN}🦉 Installing Strix${NC} ${MUTED}version: ${NC}$specific_version"
print_message info "${MUTED}Platform: ${NC}$target\n"
- local tmp_dir=$(mktemp -d)
+ local tmp_dir
+ tmp_dir=$(mktemp -d)
+
+ # Never leave a half-written binary in INSTALL_DIR. Stage to *.new and only
+ # rename into place after a verified archive has been extracted. On any
+ # abort (including verification failure), remove the staging file and the
+ # download temp dir; the current install stays untouched.
+ cleanup_install_temps() {
+ cd / >/dev/null 2>&1 || true
+ rm -rf "$tmp_dir"
+ rm -f "$INSTALL_DIR/strix.new" "$INSTALL_DIR/strix.exe.new"
+ }
+ trap cleanup_install_temps EXIT
+
cd "$tmp_dir"
echo -e "${MUTED}Downloading...${NC}"
@@ -303,15 +319,17 @@ download_and_install() {
echo -e "${MUTED}Extracting...${NC}"
if [ "$os" = "windows" ]; then
unzip -q "$filename"
- mv "strix-${specific_version}-${target}.exe" "$INSTALL_DIR/strix.exe"
+ mv "strix-${specific_version}-${target}.exe" "$INSTALL_DIR/strix.exe.new"
+ mv -f "$INSTALL_DIR/strix.exe.new" "$INSTALL_DIR/strix.exe"
else
tar -xzf "$filename"
- mv "strix-${specific_version}-${target}" "$INSTALL_DIR/strix"
- chmod 755 "$INSTALL_DIR/strix"
+ mv "strix-${specific_version}-${target}" "$INSTALL_DIR/strix.new"
+ chmod 755 "$INSTALL_DIR/strix.new"
+ mv -f "$INSTALL_DIR/strix.new" "$INSTALL_DIR/strix"
fi
- cd - > /dev/null
- rm -rf "$tmp_dir"
+ trap - EXIT
+ cleanup_install_temps
echo -e "${GREEN}✓ Strix installed to $INSTALL_DIR${NC}"
}
diff --git a/tests/test_install_script.py b/tests/test_install_script.py
index 2bde6820..eec83ba9 100644
--- a/tests/test_install_script.py
+++ b/tests/test_install_script.py
@@ -77,9 +77,13 @@ while [ "$#" -gt 0 ]; do
done
case "$url" in
*/SHA256SUMS)
- hash=$(sha256sum "$STRIX_TEST_ARCHIVE" | awk '{print $1}')
name=$(basename "$STRIX_TEST_ARCHIVE")
- printf '%s %s\\n' "$hash" "$name" > "$output"
+ if [ -n "${STRIX_TEST_BAD_CHECKSUM:-}" ]; then
+ printf '%s %s\\n' "0" "$name" > "$output"
+ else
+ hash=$(sha256sum "$STRIX_TEST_ARCHIVE" | awk '{print $1}')
+ printf '%s %s\\n' "$hash" "$name" > "$output"
+ fi
;;
*.intoto.jsonl)
printf '{"test":true}\\n' > "$output"
@@ -178,3 +182,36 @@ def test_installer_rejects_unsupported_architecture(tmp_path: Path) -> None:
assert "Unsupported OS/Arch: linux/riscv64" in result.stdout
assert not curl_log_path.exists()
assert not (home_path / ".strix").exists()
+
+
+def test_installer_leaves_existing_install_on_checksum_failure(tmp_path: Path) -> None:
+ repository_root = Path(__file__).resolve().parents[1]
+ archive_path = _create_release_archive(tmp_path)
+ mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
+ environment, home_path, _curl_log_path = _create_installer_environment(
+ tmp_path,
+ archive_path,
+ mock_bin,
+ )
+ environment["STRIX_TEST_BAD_CHECKSUM"] = "1"
+
+ install_dir = home_path / ".strix" / "bin"
+ install_dir.mkdir(parents=True)
+ existing = install_dir / "strix"
+ _write_executable(existing, "#!/bin/sh\nprintf 'strix 1.0.0\\n'\n")
+ before = existing.read_bytes()
+
+ result = _run_installer(repository_root, environment)
+
+ assert result.returncode != 0
+ assert "Checksum mismatch" in result.stdout
+ assert "Existing Strix installation left unchanged" in result.stdout
+ assert existing.read_bytes() == before
+ assert not (install_dir / "strix.new").exists()
+ installed_result = subprocess.run( # noqa: S603
+ [str(existing), "--version"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ assert installed_result.stdout.strip() == "strix 1.0.0"
From a079b6c4182daa3bb22f92928a44126412950751 Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:47:03 +0400
Subject: [PATCH 07/14] test(install): cover checksum and provenance
verification
Pin fail-closed installer paths for missing manifests, verifier
fallback, and the skip-verify escape hatch (#1267).
---
tests/test_install_script.py | 207 ++++++++++++++++++++++++++++++++++-
1 file changed, 204 insertions(+), 3 deletions(-)
diff --git a/tests/test_install_script.py b/tests/test_install_script.py
index eec83ba9..c2fd5015 100644
--- a/tests/test_install_script.py
+++ b/tests/test_install_script.py
@@ -48,14 +48,42 @@ esac
""",
)
_write_executable(mock_bin / "docker", "#!/bin/sh\nexit 0\n")
+ # Shadow any real gh/cosign on PATH. Behaviour is driven by env flags.
_write_executable(
mock_bin / "gh",
"""#!/bin/sh
-# Prefer this mock over a real gh on PATH so tests do not hit GitHub.
-if [ "$1" = "attestation" ]; then
+if [ -n "${STRIX_TEST_NO_VERIFIER:-}" ]; then
+ exit 1
+fi
+if [ "$1" = "attestation" ] && [ "$2" = "verify" ]; then
+ if [ "$3" = "--help" ]; then
+ exit 0
+ fi
+ printf '%s\\n' "$*" >> "$STRIX_TEST_GH_LOG"
+ if [ -n "${STRIX_TEST_GH_FAIL_VERIFY:-}" ]; then
+ exit 1
+ fi
exit 0
fi
exit 1
+""",
+ )
+ _write_executable(
+ mock_bin / "cosign",
+ """#!/bin/sh
+if [ -n "${STRIX_TEST_NO_VERIFIER:-}" ]; then
+ exit 1
+fi
+if [ "$1" = "verify-blob-attestation" ]; then
+ if [ "$2" = "--help" ]; then
+ [ -n "${STRIX_TEST_USE_COSIGN:-}" ] && exit 0
+ exit 1
+ fi
+ printf '%s\\n' "$*" >> "$STRIX_TEST_COSIGN_LOG"
+ [ -n "${STRIX_TEST_USE_COSIGN:-}" ] && exit 0
+ exit 1
+fi
+exit 1
""",
)
_write_executable(
@@ -75,11 +103,19 @@ while [ "$#" -gt 0 ]; do
printf '%s\\n' "$1" >> "$STRIX_TEST_CURL_LOG"
shift
done
+if [ -n "${STRIX_TEST_CURL_FAIL:-}" ]; then
+ case "$url" in
+ *"$STRIX_TEST_CURL_FAIL"*) exit 22 ;;
+ esac
+fi
case "$url" in
*/SHA256SUMS)
name=$(basename "$STRIX_TEST_ARCHIVE")
if [ -n "${STRIX_TEST_BAD_CHECKSUM:-}" ]; then
printf '%s %s\\n' "0" "$name" > "$output"
+ elif [ -n "${STRIX_TEST_SUMS_WRONG_NAME:-}" ]; then
+ hash=$(sha256sum "$STRIX_TEST_ARCHIVE" | awk '{print $1}')
+ printf '%s %s\\n' "$hash" "other-file.tar.gz" > "$output"
else
hash=$(sha256sum "$STRIX_TEST_ARCHIVE" | awk '{print $1}')
printf '%s %s\\n' "$hash" "$name" > "$output"
@@ -113,6 +149,8 @@ def _create_installer_environment(
download_path = tmp_path / "downloads"
download_path.mkdir()
curl_log_path = tmp_path / "curl.log"
+ gh_log_path = tmp_path / "gh.log"
+ cosign_log_path = tmp_path / "cosign.log"
environment = {
"HOME": str(home_path),
"XDG_CONFIG_HOME": str(home_path / ".config"),
@@ -121,6 +159,8 @@ def _create_installer_environment(
"TMPDIR": str(download_path),
"STRIX_TEST_ARCHIVE": str(archive_path),
"STRIX_TEST_CURL_LOG": str(curl_log_path),
+ "STRIX_TEST_GH_LOG": str(gh_log_path),
+ "STRIX_TEST_COSIGN_LOG": str(cosign_log_path),
"VERSION": RELEASE_VERSION,
}
return environment, home_path, curl_log_path
@@ -154,7 +194,20 @@ def test_installer_downloads_and_runs_linux_arm64_release(tmp_path: Path) -> Non
assert result.returncode == 0, result.stderr
expected_filename = f"strix-{RELEASE_VERSION}-{RELEASE_TARGET}.tar.gz"
- assert expected_filename in curl_log_path.read_text(encoding="utf-8")
+ curl_log = curl_log_path.read_text(encoding="utf-8")
+ assert expected_filename in curl_log
+ assert f"releases/download/v{RELEASE_VERSION}/SHA256SUMS" in curl_log
+ assert f"strix-{RELEASE_TARGET}.intoto.jsonl" in curl_log
+ assert "Checksum verified" in result.stdout
+ assert "Provenance verified" in result.stdout
+ gh_log = (tmp_path / "gh.log").read_text(encoding="utf-8")
+ assert "--repo usestrix/strix" in gh_log
+ assert "--bundle strix-linux-arm64.intoto.jsonl" in gh_log
+ assert (
+ "--signer-workflow usestrix/strix/.github/workflows/build-release.yml" in gh_log
+ )
+ assert "--deny-self-hosted-runners" in gh_log
+ assert not (tmp_path / "cosign.log").exists()
installed_binary = home_path / ".strix/bin/strix"
installed_result = subprocess.run( # noqa: S603
@@ -215,3 +268,151 @@ def test_installer_leaves_existing_install_on_checksum_failure(tmp_path: Path) -
check=True,
)
assert installed_result.stdout.strip() == "strix 1.0.0"
+
+
+def test_installer_rejects_missing_checksum_manifest(tmp_path: Path) -> None:
+ repository_root = Path(__file__).resolve().parents[1]
+ archive_path = _create_release_archive(tmp_path)
+ mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
+ environment, home_path, _curl_log_path = _create_installer_environment(
+ tmp_path,
+ archive_path,
+ mock_bin,
+ )
+ environment["STRIX_TEST_CURL_FAIL"] = "SHA256SUMS"
+
+ result = _run_installer(repository_root, environment)
+
+ assert result.returncode != 0
+ assert "Failed to download checksum manifest" in result.stdout
+ assert not (home_path / ".strix/bin/strix").exists()
+
+
+def test_installer_rejects_checksum_manifest_without_archive_entry(
+ tmp_path: Path,
+) -> None:
+ repository_root = Path(__file__).resolve().parents[1]
+ archive_path = _create_release_archive(tmp_path)
+ mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
+ environment, home_path, _curl_log_path = _create_installer_environment(
+ tmp_path,
+ archive_path,
+ mock_bin,
+ )
+ environment["STRIX_TEST_SUMS_WRONG_NAME"] = "1"
+
+ result = _run_installer(repository_root, environment)
+
+ assert result.returncode != 0
+ assert "No SHA256SUMS entry" in result.stdout
+ assert not (home_path / ".strix/bin/strix").exists()
+
+
+def test_installer_rejects_missing_provenance_bundle(tmp_path: Path) -> None:
+ repository_root = Path(__file__).resolve().parents[1]
+ archive_path = _create_release_archive(tmp_path)
+ mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
+ environment, home_path, _curl_log_path = _create_installer_environment(
+ tmp_path,
+ archive_path,
+ mock_bin,
+ )
+ environment["STRIX_TEST_CURL_FAIL"] = "intoto.jsonl"
+
+ result = _run_installer(repository_root, environment)
+
+ assert result.returncode != 0
+ assert "Failed to download provenance bundle" in result.stdout
+ assert "Checksum verified" in result.stdout
+ assert not (home_path / ".strix/bin/strix").exists()
+
+
+def test_installer_rejects_failed_gh_attestation_verify(tmp_path: Path) -> None:
+ repository_root = Path(__file__).resolve().parents[1]
+ archive_path = _create_release_archive(tmp_path)
+ mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
+ environment, home_path, _curl_log_path = _create_installer_environment(
+ tmp_path,
+ archive_path,
+ mock_bin,
+ )
+ environment["STRIX_TEST_GH_FAIL_VERIFY"] = "1"
+
+ result = _run_installer(repository_root, environment)
+
+ assert result.returncode != 0
+ assert "gh attestation verify failed" in result.stdout
+ assert not (home_path / ".strix/bin/strix").exists()
+ assert not (tmp_path / "cosign.log").exists()
+
+
+def test_installer_uses_cosign_when_gh_cannot_verify(tmp_path: Path) -> None:
+ repository_root = Path(__file__).resolve().parents[1]
+ archive_path = _create_release_archive(tmp_path)
+ mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
+ environment, home_path, _curl_log_path = _create_installer_environment(
+ tmp_path,
+ archive_path,
+ mock_bin,
+ )
+ environment["STRIX_TEST_USE_COSIGN"] = "1"
+ _write_executable(
+ mock_bin / "gh",
+ "#!/bin/sh\nexit 1\n",
+ )
+
+ result = _run_installer(repository_root, environment)
+
+ assert result.returncode == 0, result.stderr
+ assert "Provenance verified" in result.stdout
+ assert "(cosign)" in result.stdout
+ cosign_log = (tmp_path / "cosign.log").read_text(encoding="utf-8")
+ assert "--new-bundle-format" in cosign_log
+ assert "--certificate-oidc-issuer https://token.actions.githubusercontent.com" in (
+ cosign_log
+ )
+ assert "build-release.yml" in cosign_log
+ assert "--type slsaprovenance1" in cosign_log
+ assert (home_path / ".strix/bin/strix").exists()
+
+
+def test_installer_rejects_when_no_provenance_verifier_is_available(
+ tmp_path: Path,
+) -> None:
+ repository_root = Path(__file__).resolve().parents[1]
+ archive_path = _create_release_archive(tmp_path)
+ mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
+ environment, home_path, _curl_log_path = _create_installer_environment(
+ tmp_path,
+ archive_path,
+ mock_bin,
+ )
+ environment["STRIX_TEST_NO_VERIFIER"] = "1"
+
+ result = _run_installer(repository_root, environment)
+
+ assert result.returncode != 0
+ assert "Neither a usable 'gh' nor 'cosign'" in result.stdout
+ assert not (home_path / ".strix/bin/strix").exists()
+
+
+def test_installer_skip_verify_does_not_download_manifests(tmp_path: Path) -> None:
+ repository_root = Path(__file__).resolve().parents[1]
+ archive_path = _create_release_archive(tmp_path)
+ mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
+ environment, home_path, curl_log_path = _create_installer_environment(
+ tmp_path,
+ archive_path,
+ mock_bin,
+ )
+ environment["STRIX_INSTALL_SKIP_VERIFY"] = "1"
+
+ result = _run_installer(repository_root, environment)
+
+ assert result.returncode == 0, result.stderr
+ curl_log = curl_log_path.read_text(encoding="utf-8")
+ assert "SHA256SUMS" not in curl_log
+ assert "intoto.jsonl" not in curl_log
+ assert "skipping checksum and provenance checks" in result.stdout
+ assert (home_path / ".strix/bin/strix").exists()
+ assert not (tmp_path / "gh.log").exists()
From 6a3ad2ae3e552a0432862db1c66340ff03d2822b Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:50:10 +0400
Subject: [PATCH 08/14] test(ci): lock release checksum and attestation
workflow
Assert the build-release job signs artifacts, publishes bundles,
and generates SHA256SUMS without hashing provenance (#1267).
---
tests/test_build_release_workflow.py | 184 +++++++++++++++++++++++++++
1 file changed, 184 insertions(+)
create mode 100644 tests/test_build_release_workflow.py
diff --git a/tests/test_build_release_workflow.py b/tests/test_build_release_workflow.py
new file mode 100644
index 00000000..4ef3ed0d
--- /dev/null
+++ b/tests/test_build_release_workflow.py
@@ -0,0 +1,184 @@
+"""Structural checks for release signing + checksum publishing (#1267)."""
+
+from __future__ import annotations
+
+import hashlib
+import re
+import subprocess
+from pathlib import Path
+
+import pytest
+import yaml
+
+
+WORKFLOW = (
+ Path(__file__).resolve().parent.parent / ".github" / "workflows" / "build-release.yml"
+)
+ATTEST_PIN = "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6"
+
+
+def _workflow() -> dict[str, object]:
+ return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
+
+
+def _job(name: str) -> dict[str, object]:
+ jobs = _workflow()["jobs"]
+ assert isinstance(jobs, dict)
+ job = jobs[name]
+ assert isinstance(job, dict)
+ return job
+
+
+def _steps(job_name: str) -> list[dict[str, object]]:
+ steps = _job(job_name)["steps"]
+ assert isinstance(steps, list)
+ return [step for step in steps if isinstance(step, dict)]
+
+
+def _step_named(job_name: str, name: str) -> dict[str, object]:
+ for step in _steps(job_name):
+ if step.get("name") == name:
+ return step
+ raise AssertionError(f"missing step {name!r} in job {job_name!r}")
+
+
+def test_build_job_requests_attestation_permissions() -> None:
+ permissions = _job("build")["permissions"]
+ assert permissions == {
+ "contents": "read",
+ "id-token": "write",
+ "attestations": "write",
+ }
+
+
+def test_release_job_requests_attestation_permissions() -> None:
+ permissions = _job("release")["permissions"]
+ assert permissions == {
+ "contents": "write",
+ "id-token": "write",
+ "attestations": "write",
+ }
+
+
+def test_build_attests_unix_and_windows_archives_with_pinned_action() -> None:
+ unix = _step_named("build", "Attest Unix archive and wheel")
+ windows = _step_named("build", "Attest Windows archive and wheel")
+
+ assert unix["uses"] == ATTEST_PIN
+ assert unix["id"] == "attest-unix"
+ assert unix["if"] == "runner.os != 'Windows'"
+ assert "dist/release/*.tar.gz" in str(unix["with"]["subject-path"])
+ assert "dist/*.whl" in str(unix["with"]["subject-path"])
+
+ assert windows["uses"] == ATTEST_PIN
+ assert windows["id"] == "attest-windows"
+ assert windows["if"] == "runner.os == 'Windows'"
+ assert "dist/release/*.zip" in str(windows["with"]["subject-path"])
+ assert "dist/*.whl" in str(windows["with"]["subject-path"])
+
+
+def test_build_publishes_and_uploads_per_target_attestation_bundle() -> None:
+ publish = _step_named("build", "Publish attestation bundle")
+ assert "strix-${{ matrix.target }}.intoto.jsonl" in str(publish["run"])
+ assert "attest-unix.outputs.bundle-path" in str(publish["env"]["BUNDLE_PATH"])
+ assert "attest-windows.outputs.bundle-path" in str(publish["env"]["BUNDLE_PATH"])
+
+ upload = next(step for step in _steps("build") if "upload-artifact@" in str(step.get("uses", "")))
+ paths = str(upload["with"]["path"])
+ assert "dist/release/*.intoto.jsonl" in paths
+ assert "dist/release/*.tar.gz" in paths
+ assert "dist/*.whl" in paths
+
+
+def test_release_generates_sha256sums_excluding_provenance_bundles() -> None:
+ generate = _step_named("release", "Generate SHA256SUMS")
+ assert generate["working-directory"] == "release"
+ script = str(generate["run"])
+ assert "SHA256SUMS" in script
+ assert "! -name '*.intoto.jsonl'" in script
+ assert "sha256sum" in script
+
+
+def test_release_attests_and_publishes_sha256sums_bundle() -> None:
+ attest = _step_named("release", "Attest SHA256SUMS")
+ assert attest["uses"] == ATTEST_PIN
+ assert attest["id"] == "attest-sums"
+ assert attest["with"]["subject-path"] == "release/SHA256SUMS"
+
+ publish = _step_named("release", "Publish SHA256SUMS attestation bundle")
+ assert "SHA256SUMS.intoto.jsonl" in str(publish["run"])
+ assert "attest-sums.outputs.bundle-path" in str(publish["env"]["BUNDLE_PATH"])
+
+ create = _step_named("release", "Create Release")
+ assert create["with"]["files"] == "release/**"
+
+
+def test_every_action_use_is_sha_pinned() -> None:
+ text = WORKFLOW.read_text(encoding="utf-8")
+ uses = re.findall(r"^\s+uses:\s+(\S+)", text, flags=re.MULTILINE)
+ assert uses
+ for ref in uses:
+ assert "@" in ref, ref
+ digest = ref.rsplit("@", 1)[1]
+ assert re.fullmatch(r"[0-9a-f]{40}", digest), ref
+
+
+def test_sha256sums_script_hashes_product_files_only(tmp_path: Path) -> None:
+ """Run the release job's checksum recipe against a fixture tree."""
+ release_dir = tmp_path / "release"
+ release_dir.mkdir()
+ (release_dir / "strix-1.0.0-linux-x86_64.tar.gz").write_bytes(b"archive-bytes")
+ (release_dir / "strix_agent-1.0.0-py3-none-any.whl").write_bytes(b"wheel-bytes")
+ (release_dir / "strix-linux-x86_64.intoto.jsonl").write_text(
+ '{"attestation":true}\n',
+ encoding="utf-8",
+ )
+
+ script = str(_step_named("release", "Generate SHA256SUMS")["run"])
+ result = subprocess.run( # noqa: S603
+ ["/bin/bash", "-c", script],
+ cwd=release_dir,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+
+ sums_path = release_dir / "SHA256SUMS"
+ assert sums_path.is_file()
+ lines = [
+ line
+ for line in sums_path.read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ ]
+ names = {line.split()[-1] for line in lines}
+ assert names == {
+ "strix-1.0.0-linux-x86_64.tar.gz",
+ "strix_agent-1.0.0-py3-none-any.whl",
+ }
+ assert "strix-linux-x86_64.intoto.jsonl" not in names
+ assert "SHA256SUMS" not in names
+
+ for line in lines:
+ digest, name = line.split()
+ expected = hashlib.sha256((release_dir / name).read_bytes()).hexdigest()
+ assert digest == expected
+
+
+@pytest.mark.parametrize(
+ ("job_name", "step_name"),
+ [
+ ("build", "Attest Unix archive and wheel"),
+ ("build", "Attest Windows archive and wheel"),
+ ("release", "Attest SHA256SUMS"),
+ ],
+)
+def test_attest_steps_comment_mentions_pinned_version(
+ job_name: str,
+ step_name: str,
+) -> None:
+ # Keep the SHA pin and the human version comment in sync with checkout/etc.
+ text = WORKFLOW.read_text(encoding="utf-8")
+ assert "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2" in text
+ step = _step_named(job_name, step_name)
+ assert step["uses"] == ATTEST_PIN
From 0d2407e71a1267e4595bd36ffcffd99b0f9403de Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:51:44 +0400
Subject: [PATCH 09/14] docs: add manual release checksum and provenance checks
Document verify steps in quickstart and prepend them to generated
release notes (#1267).
---
.github/workflows/build-release.yml | 26 ++++++++++++++++
docs/quickstart.mdx | 45 ++++++++++++++++++++++++++++
tests/test_build_release_workflow.py | 11 +++++++
3 files changed, 82 insertions(+)
diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index c1ab14c8..cc0baab6 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -187,5 +187,31 @@ jobs:
with:
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
generate_release_notes: true
+ # Prepended to the generated changelog. Keep in sync with
+ # docs/quickstart.mdx "Verify a downloaded release".
+ body: |
+ ## Verify this release
+
+ After downloading your platform archive from this release:
+
+ ```bash
+ # Example for linux-x86_64 — set ARCHIVE to the asset you downloaded.
+ TARGET=linux-x86_64
+ ARCHIVE=strix-VERSION-${TARGET}.tar.gz # VERSION without the leading v
+
+ curl -fsSLO "https://github.com/usestrix/strix/releases/download/${{ github.ref_name }}/SHA256SUMS"
+ curl -fsSLO "https://github.com/usestrix/strix/releases/download/${{ github.ref_name }}/strix-${TARGET}.intoto.jsonl"
+
+ sha256sum -c --ignore-missing SHA256SUMS
+
+ gh attestation verify "${ARCHIVE}" \
+ --repo usestrix/strix \
+ --bundle "strix-${TARGET}.intoto.jsonl" \
+ --signer-workflow usestrix/strix/.github/workflows/build-release.yml \
+ --predicate-type https://slsa.dev/provenance/v1 \
+ --deny-self-hosted-runners
+ ```
+
+ Full instructions (including `cosign`): https://docs.strix.ai/quickstart#verify-a-downloaded-release
# Includes SHA256SUMS and *.intoto.jsonl provenance bundles.
files: release/**
diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx
index dcd2e7c5..b0349dd3 100644
--- a/docs/quickstart.mdx
+++ b/docs/quickstart.mdx
@@ -15,6 +15,13 @@ description: "Install Strix and run your first security scan"
```bash
curl -sSL https://strix.ai/install | bash
```
+
+ The installer downloads the release archive, checks `SHA256SUMS`, then verifies
+ Sigstore provenance before extracting. To skip those checks (not recommended):
+
+ ```bash
+ STRIX_INSTALL_SKIP_VERIFY=1 curl -sSL https://strix.ai/install | bash
+ ```
```bash
@@ -23,6 +30,44 @@ description: "Install Strix and run your first security scan"
+## Verify a downloaded release
+
+If you download assets from GitHub Releases yourself:
+
+```bash
+# Replace VERSION and TARGET (e.g. 1.6.2 and linux-x86_64).
+VERSION=1.6.2
+TARGET=linux-x86_64
+ARCHIVE="strix-${VERSION}-${TARGET}.tar.gz"
+
+curl -fsSLO "https://github.com/usestrix/strix/releases/download/v${VERSION}/${ARCHIVE}"
+curl -fsSLO "https://github.com/usestrix/strix/releases/download/v${VERSION}/SHA256SUMS"
+curl -fsSLO "https://github.com/usestrix/strix/releases/download/v${VERSION}/strix-${TARGET}.intoto.jsonl"
+
+# 1) Integrity
+sha256sum -c --ignore-missing SHA256SUMS
+
+# 2) Provenance (GitHub CLI)
+gh attestation verify "${ARCHIVE}" \
+ --repo usestrix/strix \
+ --bundle "strix-${TARGET}.intoto.jsonl" \
+ --signer-workflow usestrix/strix/.github/workflows/build-release.yml \
+ --predicate-type https://slsa.dev/provenance/v1 \
+ --deny-self-hosted-runners
+```
+
+With `cosign` instead of `gh`:
+
+```bash
+cosign verify-blob-attestation \
+ --bundle "strix-${TARGET}.intoto.jsonl" \
+ --new-bundle-format \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+ --certificate-identity-regexp '^https://github.com/usestrix/strix/.github/workflows/build-release.yml' \
+ --type slsaprovenance1 \
+ "${ARCHIVE}"
+```
+
## Configuration
Set your LLM provider:
diff --git a/tests/test_build_release_workflow.py b/tests/test_build_release_workflow.py
index 4ef3ed0d..ec02d625 100644
--- a/tests/test_build_release_workflow.py
+++ b/tests/test_build_release_workflow.py
@@ -165,6 +165,17 @@ def test_sha256sums_script_hashes_product_files_only(tmp_path: Path) -> None:
assert digest == expected
+def test_release_notes_include_manual_verification() -> None:
+ create = _step_named("release", "Create Release")
+ body = str(create["with"]["body"])
+ assert "## Verify this release" in body
+ assert "sha256sum -c --ignore-missing SHA256SUMS" in body
+ assert "gh attestation verify" in body
+ assert "usestrix/strix/.github/workflows/build-release.yml" in body
+ assert "docs.strix.ai/quickstart#verify-a-downloaded-release" in body
+ assert create["with"]["generate_release_notes"] is True
+
+
@pytest.mark.parametrize(
("job_name", "step_name"),
[
From 9ff957b7eb2ccb552bcc586ac1ba675c1621a0ee Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:54:06 +0400
Subject: [PATCH 10/14] docs: add manual release checksum and provenance checks
Document verify steps in quickstart and prepend them to generated
release notes (#1267).
---
tests/test_build_release_workflow.py | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/tests/test_build_release_workflow.py b/tests/test_build_release_workflow.py
index ec02d625..9e7edbd8 100644
--- a/tests/test_build_release_workflow.py
+++ b/tests/test_build_release_workflow.py
@@ -11,9 +11,7 @@ import pytest
import yaml
-WORKFLOW = (
- Path(__file__).resolve().parent.parent / ".github" / "workflows" / "build-release.yml"
-)
+WORKFLOW = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "build-release.yml"
ATTEST_PIN = "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6"
@@ -83,7 +81,9 @@ def test_build_publishes_and_uploads_per_target_attestation_bundle() -> None:
assert "attest-unix.outputs.bundle-path" in str(publish["env"]["BUNDLE_PATH"])
assert "attest-windows.outputs.bundle-path" in str(publish["env"]["BUNDLE_PATH"])
- upload = next(step for step in _steps("build") if "upload-artifact@" in str(step.get("uses", "")))
+ upload = next(
+ step for step in _steps("build") if "upload-artifact@" in str(step.get("uses", ""))
+ )
paths = str(upload["with"]["path"])
assert "dist/release/*.intoto.jsonl" in paths
assert "dist/release/*.tar.gz" in paths
@@ -146,11 +146,7 @@ def test_sha256sums_script_hashes_product_files_only(tmp_path: Path) -> None:
sums_path = release_dir / "SHA256SUMS"
assert sums_path.is_file()
- lines = [
- line
- for line in sums_path.read_text(encoding="utf-8").splitlines()
- if line.strip()
- ]
+ lines = [line for line in sums_path.read_text(encoding="utf-8").splitlines() if line.strip()]
names = {line.split()[-1] for line in lines}
assert names == {
"strix-1.0.0-linux-x86_64.tar.gz",
From dbb0a584668737d892415a5d6ea5329c64163509 Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:57:46 +0400
Subject: [PATCH 11/14] style(tests): apply ruff format to install script tests
---
tests/test_install_script.py | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/tests/test_install_script.py b/tests/test_install_script.py
index c2fd5015..647b2011 100644
--- a/tests/test_install_script.py
+++ b/tests/test_install_script.py
@@ -203,9 +203,7 @@ def test_installer_downloads_and_runs_linux_arm64_release(tmp_path: Path) -> Non
gh_log = (tmp_path / "gh.log").read_text(encoding="utf-8")
assert "--repo usestrix/strix" in gh_log
assert "--bundle strix-linux-arm64.intoto.jsonl" in gh_log
- assert (
- "--signer-workflow usestrix/strix/.github/workflows/build-release.yml" in gh_log
- )
+ assert "--signer-workflow usestrix/strix/.github/workflows/build-release.yml" in gh_log
assert "--deny-self-hosted-runners" in gh_log
assert not (tmp_path / "cosign.log").exists()
@@ -368,9 +366,7 @@ def test_installer_uses_cosign_when_gh_cannot_verify(tmp_path: Path) -> None:
assert "(cosign)" in result.stdout
cosign_log = (tmp_path / "cosign.log").read_text(encoding="utf-8")
assert "--new-bundle-format" in cosign_log
- assert "--certificate-oidc-issuer https://token.actions.githubusercontent.com" in (
- cosign_log
- )
+ assert "--certificate-oidc-issuer https://token.actions.githubusercontent.com" in (cosign_log)
assert "build-release.yml" in cosign_log
assert "--type slsaprovenance1" in cosign_log
assert (home_path / ".strix/bin/strix").exists()
From a91b390b44c3db6c74a7c963cfcb294b4a78fa4f Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 18:09:49 +0400
Subject: [PATCH 12/14] fix(install): delay PATH cleanup until after verified
install
Do not remove pipx or other strix copies before checksum and
provenance succeed, so a failed verify cannot leave the user
with no working binary (#1267).
---
scripts/install.sh | 6 ++++--
tests/test_install_script.py | 34 ++++++++++++++++++++++++++++++++++
2 files changed, 38 insertions(+), 2 deletions(-)
diff --git a/scripts/install.sh b/scripts/install.sh
index a81f4d46..1c3dc73b 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -105,6 +105,9 @@ print_message() {
echo -e "${color}${message}${NC}"
}
+# Remove other copies of strix from PATH (pipx, leftover binaries) only after
+# a verified install has been written to INSTALL_DIR. Calling this earlier
+# would leave the user with no working Strix if checksum/provenance then fail.
check_existing_installation() {
local found_paths=()
while IFS= read -r -d '' path; do
@@ -257,8 +260,6 @@ verify_provenance() {
}
check_version() {
- check_existing_installation
-
if [[ -x "$INSTALL_DIR/strix" ]]; then
installed_version=$("$INSTALL_DIR/strix" --version 2>/dev/null | awk '{print $2}' || echo "")
if [[ "$installed_version" == "$specific_version" ]]; then
@@ -332,6 +333,7 @@ download_and_install() {
cleanup_install_temps
echo -e "${GREEN}✓ Strix installed to $INSTALL_DIR${NC}"
+ check_existing_installation
}
check_docker() {
diff --git a/tests/test_install_script.py b/tests/test_install_script.py
index 647b2011..68b1a0c2 100644
--- a/tests/test_install_script.py
+++ b/tests/test_install_script.py
@@ -235,6 +235,40 @@ def test_installer_rejects_unsupported_architecture(tmp_path: Path) -> None:
assert not (home_path / ".strix").exists()
+def test_installer_leaves_external_strix_on_checksum_failure(tmp_path: Path) -> None:
+ repository_root = Path(__file__).resolve().parents[1]
+ archive_path = _create_release_archive(tmp_path)
+ mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
+ environment, home_path, _curl_log_path = _create_installer_environment(
+ tmp_path,
+ archive_path,
+ mock_bin,
+ )
+ environment["STRIX_TEST_BAD_CHECKSUM"] = "1"
+
+ other_bin = tmp_path / "other-bin"
+ other_bin.mkdir()
+ external = other_bin / "strix"
+ _write_executable(external, "#!/bin/sh\nprintf 'strix 1.0.0\\n'\n")
+ environment["PATH"] = f"{other_bin}:{environment['PATH']}"
+ before = external.read_bytes()
+
+ result = _run_installer(repository_root, environment)
+
+ assert result.returncode != 0
+ assert "Checksum mismatch" in result.stdout
+ assert external.exists()
+ assert external.read_bytes() == before
+ assert not (home_path / ".strix/bin/strix").exists()
+ installed_result = subprocess.run( # noqa: S603
+ [str(external), "--version"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ assert installed_result.stdout.strip() == "strix 1.0.0"
+
+
def test_installer_leaves_existing_install_on_checksum_failure(tmp_path: Path) -> None:
repository_root = Path(__file__).resolve().parents[1]
archive_path = _create_release_archive(tmp_path)
From aaae1a76efa7b2183020abc493354ba5c4abd826 Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 18:14:51 +0400
Subject: [PATCH 13/14] docs(install): put STRIX_INSTALL_SKIP_VERIFY on bash,
not curl
Env vars before curl do not reach the piped bash process, so the
documented override never skipped verification (#1267).
---
docs/quickstart.mdx | 2 +-
scripts/install.sh | 3 ++-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx
index b0349dd3..e5c834b8 100644
--- a/docs/quickstart.mdx
+++ b/docs/quickstart.mdx
@@ -20,7 +20,7 @@ description: "Install Strix and run your first security scan"
Sigstore provenance before extracting. To skip those checks (not recommended):
```bash
- STRIX_INSTALL_SKIP_VERIFY=1 curl -sSL https://strix.ai/install | bash
+ curl -sSL https://strix.ai/install | STRIX_INSTALL_SKIP_VERIFY=1 bash
```
diff --git a/scripts/install.sh b/scripts/install.sh
index 1c3dc73b..c6f98e5e 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -143,7 +143,8 @@ abort_unverified() {
if [[ -x "$INSTALL_DIR/strix" || -x "$INSTALL_DIR/strix.exe" ]]; then
echo -e "${MUTED}Existing Strix installation left unchanged.${NC}"
fi
- echo -e "${RED}Re-run with STRIX_INSTALL_SKIP_VERIFY=1 to override (at your own risk).${NC}"
+ echo -e "${RED}Re-run with: curl -sSL https://strix.ai/install | STRIX_INSTALL_SKIP_VERIFY=1 bash${NC}"
+ echo -e "${MUTED}(at your own risk)${NC}"
exit 1
}
From 1b74f806fc6b79147e56836445f91dcb1f74b12b Mon Sep 17 00:00:00 2001
From: mohammad junayd <111487869+m-jay21@users.noreply.github.com>
Date: Mon, 14 Sep 2026 18:17:09 +0400
Subject: [PATCH 14/14] docs(install): require gh or cosign for curl installer
verify
Call out the provenance verifier prerequisite so curl|bash users
are not surprised by a fail-closed abort (#1267).
---
docs/quickstart.mdx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx
index e5c834b8..48236e6c 100644
--- a/docs/quickstart.mdx
+++ b/docs/quickstart.mdx
@@ -7,6 +7,7 @@ description: "Install Strix and run your first security scan"
- Docker (running)
- An LLM API key from any [supported provider](/llm-providers/overview) (OpenAI, Anthropic, Google, etc.)
+- For the curl installer: [GitHub CLI](https://cli.github.com/) (`gh`) or [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) — used to verify Sigstore provenance before extract
## Installation
@@ -17,7 +18,8 @@ description: "Install Strix and run your first security scan"
```
The installer downloads the release archive, checks `SHA256SUMS`, then verifies
- Sigstore provenance before extracting. To skip those checks (not recommended):
+ Sigstore provenance with `gh` or `cosign` before extracting. To skip those checks
+ (not recommended):
```bash
curl -sSL https://strix.ai/install | STRIX_INSTALL_SKIP_VERIFY=1 bash