From b8a76e986d254b0735b9ff88ef94c1138b9da7e4 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 5 Sep 2026 09:27:28 -0400 Subject: [PATCH 1/2] release: bind recovery to current controls --- .github/workflows/npm-stage.yml | 54 +++++++++++--------- .github/workflows/release.yml | 70 +++++++++++++++++++++++--- AGENTS.md | 2 +- docs/publishing.md | 24 +++++---- scripts/check-workflow-yaml.ts | 3 ++ scripts/npm-release-workflow.test.ts | 75 ++++++++++++++++++++++++++-- 6 files changed, 183 insertions(+), 45 deletions(-) diff --git a/.github/workflows/npm-stage.yml b/.github/workflows/npm-stage.yml index e85d65c..4e076ee 100644 --- a/.github/workflows/npm-stage.yml +++ b/.github/workflows/npm-stage.yml @@ -379,9 +379,12 @@ jobs: const repository = process.env.GITHUB_REPOSITORY ?? ""; const currentRunId = process.env.GITHUB_RUN_ID ?? ""; const resolvedStageVersion = process.env.RESOLVED_STAGE_VERSION ?? ""; - const legacyStages = new Map([ + const legacyStageJobs = new Map([ ["33269920554", Object.freeze({ headSha: "e12d3fd05ffaa722ac1c43a8ecaa7d21fece679a", + jobConclusion: "success", + jobId: 99146963354, + mutationConclusion: "success", runAttempt: 1, version: "0.17.3", })], @@ -485,32 +488,29 @@ jobs: if (intents.length > 1 || resolutions.length > 1) { throw new Error(`npm-stage run ${runId} has ambiguous intent history`); } - const match = /^Stage exact package v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/u.exec(job.name); - if (intents.length === 0 && resolutions.length === 0) { - const legacy = legacyStages.get(String(runId)); - if ( - job.conclusion === "success" - && job.name === "Stage exact package" - ) { - if ( - legacy === undefined - || job.head_sha !== legacy.headSha - || job.run_attempt !== legacy.runAttempt - ) { - throw new Error(`Successful npm-stage run ${runId} lacks a version-bound intent`); - } - reserve(legacy.version, runId); - continue; - } - const legacyMutation = job.steps.filter((step) => ( + if (job.name === "Stage exact package") { + const legacy = legacyStageJobs.get(String(runId)); + const legacyMutations = job.steps.filter((step) => ( step?.name === "Revalidate current main and stage exact package" - && (step?.conclusion === "success" || step?.conclusion === "failure") )); - if (match !== null && legacyMutation.length === 1) { - reserve(match[1], runId); + if ( + legacy === undefined + || job.id !== legacy.jobId + || job.head_sha !== legacy.headSha + || job.run_attempt !== legacy.runAttempt + || job.status !== "completed" + || job.conclusion !== legacy.jobConclusion + || legacyMutations.length !== 1 + || legacyMutations[0]?.conclusion !== legacy.mutationConclusion + || intents.length !== 0 + || resolutions.length !== 0 + ) { + throw new Error(`npm-stage run ${runId} has an unsealed generic stage job`); } + reserve(legacy.version, runId); continue; } + const match = /^Stage exact package v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/u.exec(job.name); if (match === null) { throw new Error(`npm-stage run ${runId} lacks a version-bound stage job`); } @@ -523,6 +523,14 @@ jobs: parseVersion(resolutionMatch[1], `Cleared version from run ${runId}`); increment(resolutionCounts, resolutionMatch[1]); } + const terminalWrites = job.steps.filter((step) => ( + step?.name === "Revalidate current main and stage exact package" + && step?.conclusion !== null + && step?.conclusion !== "skipped" + )); + if (terminalWrites.length > 1 || (terminalWrites.length === 1 && intents.length !== 1)) { + throw new Error(`npm-stage run ${runId} has a terminal write without one durable intent`); + } } }; // A rerun is in progress and therefore absent from the completed-run @@ -887,7 +895,7 @@ jobs: throw new Error("Packed package.json tar header is invalid"); } const name = tarText(header, 0, 100); - const prefix = tarText(header, 345, 155); + const prefix = tarText(header, 345, header[475] === 0 ? 130 : 155); const path = prefix === "" ? name : `${prefix}/${name}`; if ( path.startsWith("/") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c032c7..e2b5ff4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false + ref: main - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" @@ -129,15 +130,25 @@ jobs: echo "::error::Release tag did not resolve to one commit" exit 1 fi - if [[ "$GITHUB_SHA" != "$tag_commit" || \ - "$checked_out_head" != "$tag_commit" ]]; then - echo "::error::Tag does not match the checked release commit $tag_commit" + if [[ "$GITHUB_SHA" != "$tag_commit" ]]; then + echo "::error::Tag event does not match release commit $tag_commit" + exit 1 + fi + if [[ "$checked_out_head" != "$default_head" ]]; then + echo "::error::Reviewed checkout is not exact current $DEFAULT_BRANCH" exit 1 fi if ! git merge-base --is-ancestor "$tag_commit" "$default_head"; then echo "::error::Tag $release_tag is not reachable from current $DEFAULT_BRANCH" exit 1 fi + if ! git diff --quiet --no-ext-diff --no-textconv \ + "$tag_commit" "$default_head" -- \ + .github/workflows/release.yml \ + .github/workflows/npm-stage.yml; then + echo "::error::Tagged and current release workflow controls differ" + exit 1 + fi package_manifest="$RUNNER_TEMP/kb-release-package.json" git show "$tag_commit:package.json" > "$package_manifest" @@ -201,7 +212,7 @@ jobs: fi printf 'default_branch=%s\nsource_sha=%s\ntag=%s\nworkflow_sha=%s\n' \ - "$DEFAULT_BRANCH" "$tag_commit" "$release_tag" "$checked_out_head" \ + "$DEFAULT_BRANCH" "$tag_commit" "$release_tag" "$default_head" \ >> "$GITHUB_OUTPUT" - name: Materialize exact tagged source id: source @@ -409,6 +420,11 @@ jobs: VERIFIED_TAG: ${{ needs.verify.outputs.verified_tag }} WORKFLOW_SHA: ${{ needs.verify.outputs.workflow_sha }} steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + ref: main - name: Reauthorize current release attempt env: EXPECTED_ACTOR_ID: "894119" @@ -507,14 +523,49 @@ jobs: fi if [[ "$GITHUB_EVENT_NAME" != push || \ "$GITHUB_REF" != "refs/tags/$VERIFIED_TAG" || \ - "$GITHUB_SHA" != "$VERIFIED_SOURCE_SHA" || \ - "$GITHUB_SHA" != "$WORKFLOW_SHA" ]]; then + "$GITHUB_SHA" != "$VERIFIED_SOURCE_SHA" ]]; then echo "::error::Protected tag release event changed after verification" exit 1 fi + verify_current_release_controls() { + local advertised_main imported_main + advertised_main="$(gh api "/repos/$GITHUB_REPOSITORY/commits/$DEFAULT_BRANCH" --jq '.sha')" + git fetch --no-tags --force origin \ + "refs/heads/$DEFAULT_BRANCH:refs/remotes/kb-release-current/$DEFAULT_BRANCH" + imported_main="$(git rev-parse "refs/remotes/kb-release-current/$DEFAULT_BRANCH")" + if [[ ! "$advertised_main" =~ ^[a-f0-9]{40}$ || \ + "$imported_main" != "$advertised_main" ]]; then + echo "::error::Could not import one exact current $DEFAULT_BRANCH authority" >&2 + return 1 + fi + if ! git merge-base --is-ancestor "$VERIFIED_SOURCE_SHA" "$imported_main" || \ + ! git merge-base --is-ancestor "$WORKFLOW_SHA" "$imported_main"; then + echo "::error::Release source or reviewed workflow is no longer on current $DEFAULT_BRANCH" >&2 + return 1 + fi + if ! git diff --quiet --no-ext-diff --no-textconv \ + "$VERIFIED_SOURCE_SHA" "$imported_main" -- \ + .github/workflows/release.yml \ + .github/workflows/npm-stage.yml; then + echo "::error::Tagged and current release workflow controls differ" >&2 + return 1 + fi + if ! git diff --quiet --no-ext-diff --no-textconv \ + "$WORKFLOW_SHA" "$imported_main" -- \ + scripts/package-artifact.ts \ + scripts/npm-package-identity.ts \ + scripts/npm-release-attestation.ts \ + scripts/package-smoke.ts \ + scripts/prepare-npm-package.ts; then + echo "::error::Current release verifier controls changed after verification" >&2 + return 1 + fi + printf '%s\n' "$imported_main" + } + current_tag_sha="$(gh api "/repos/$GITHUB_REPOSITORY/commits/$VERIFIED_TAG" --jq '.sha')" - current_default_sha="$(gh api "/repos/$GITHUB_REPOSITORY/commits/$DEFAULT_BRANCH" --jq '.sha')" + current_default_sha="$(verify_current_release_controls)" if [[ "$current_tag_sha" != "$VERIFIED_SOURCE_SHA" ]]; then echo "::error::Tag $VERIFIED_TAG moved to $current_tag_sha after verification" exit 1 @@ -613,6 +664,11 @@ jobs: printf -v expected_release_body \ 'Automated immutable release for @hraness/kb@%s.\n\nSource commit: %s\nWorkflow run: %s' \ "$verified_version" "$VERIFIED_SOURCE_SHA" "$GITHUB_RUN_ID" + final_default_sha="$(verify_current_release_controls)" + if [[ "$final_default_sha" != "$current_default_sha" ]]; then + echo "::error::Current $DEFAULT_BRANCH moved during final release authorization" + exit 1 + fi release_json="$(mktemp "$RUNNER_TEMP/kb-release.XXXXXX")" if ! gh release create "$VERIFIED_TAG" \ --verify-tag \ diff --git a/AGENTS.md b/AGENTS.md index baecabf..e4f8dc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ - Pair concrete behavior tests with property tests for parsing, resolution, ordering, path confinement, and round-trip laws. - Run `bun test src/benchmark.test.ts src/evaluation.test.ts src/evaluation-kb.test.ts src/search.test.ts src/sdk.test.ts` when changing rank fusion, retrieval defaults, frozen-corpus execution, or built-in evaluation adapters. The six-case synthetic rank-fusion fixture is a deterministic regression, not a retrieval-quality or performance benchmark. Keep real-corpus manifests versioned, judgments independent of rankings, raw lane evidence intact, and performance claims tied to named hardware and measured runs. Run `bun run check` before handing off a change; it must leave committed `dist/` and `bun.lock` unchanged. - Follow `docs/publishing.md` for the historical bootstrap and later releases. Trust only `.github/workflows/npm-stage.yml` with `npm stage publish` permission bound to the exact `npm-stage` environment. Keep that environment restricted solely to the selected default branch `main`, with administrator bypass disabled, no required deployment reviewers, and no secrets. Pushes and default dispatches must build and upload the exact candidate without OIDC; only an intentional current-main stable-train dispatch with boolean `publish_to_npm=true` may admit the minimal staging job. Its first step must use only `actions: read` plus `id-token: write` and reauthorize the current run attempt against owner `User` ID `894119`, both actor identities, active workflow ID/name/path, exact public repository ID `1308971873`, protected `main`, and the verified source SHA. Independently reject npm's packed top-level `tag` override and every noncanonical `publishConfig`, re-read public `latest`, and reject any unresolved version-bound Actions intent newer than `latest` before staging only the reviewed tarball through pinned npm's scrubbed clean default `latest`; do not pass an explicit tag because that disables npm's built-in higher-version guard. Record a successful intent step immediately before mutation and scan every retained attempt so an ambiguous runner failure remains locked. npm's short-lived trust assertion cannot list stages, so resolve provider state out of band; a failed, interrupted, or rejected stage may release only its exact durable intent through the exceptional owner-authorized `resolved_stage_version` input and matching successful resolution step. Leave that input empty normally and do not claim this workflow prevents out-of-band stages. Disallow traditional publishing tokens and preserve `contentPolicy.class=dual-use` plus the root `DISCLOSURE` in every package. npm's separate public promotion remains human-gated by two-factor authentication; batch that unavoidable promotion into intentional stable releases. -- Use two exact active rulesets matching `refs/tags/v*`: **Immutable version tags** restricts update and deletion with an empty bypass list, while **Release tag creation** restricts creation and has owner `User` ID `894119` as its sole always-bypass actor. Never grant generic GitHub Actions integration ID `15368`, an administrator, a repository role, a team, or another integration this bypass; never combine creation with update/delete or create probe tags. Publish and verify the exact staged npm artifact first, approve its public promotion with human 2FA, then let the owner-authenticated operator create the exact annotated stable `v` tag on `main`. The protected tag workflow must bind the actor and event sender to owner `User` ID `894119` and public repository ID `1308971873` before checkout, then verify the tag, source, registry artifact, and immutable Latest Release. Before any GitHub Release mutation, require exact npm `dist-tags.latest`, nonempty canonical registry signatures, and pinned npm `11.19.0` cryptographic verification of the exact publish and SLSA provenance attestations, including the registry tarball SHA-512, staging workflow identity, public repository and owner IDs, sole main source commit, `workflow_dispatch` event, GitHub-hosted builder, and canonical invocation. Accept an existing Release only when its exact title and source/run receipt match this workflow and its creator is immutable `github-actions[bot]` ID `41898282`. Never move a tag, republish npm, or start a second stable release before the first completes. +- Use two exact active rulesets matching `refs/tags/v*`: **Immutable version tags** restricts update and deletion with an empty bypass list, while **Release tag creation** restricts creation and has owner `User` ID `894119` as its sole always-bypass actor. Never grant generic GitHub Actions integration ID `15368`, an administrator, a repository role, a team, or another integration this bypass; never combine creation with update/delete or create probe tags. Publish and verify the exact staged npm artifact first, approve its public promotion with human 2FA, then let the owner-authenticated operator create the exact annotated stable `v` tag on `main`. The protected tag workflow must bind the actor and event sender to owner `User` ID `894119` and public repository ID `1308971873` before checkout, then verify the tag, source, registry artifact, and immutable Latest Release. Run release verifiers from exact current `main`; require the tag-triggered Release and staging workflows to match current `main`, and revalidate the complete verifier closure immediately before mutation. Before any GitHub Release mutation, require exact npm `dist-tags.latest`, nonempty canonical registry signatures, and pinned npm `11.19.0` cryptographic verification of the exact publish and SLSA provenance attestations, including the registry tarball SHA-512, staging workflow identity, public repository and owner IDs, sole main source commit, `workflow_dispatch` event, GitHub-hosted builder, and canonical invocation. Accept an existing Release only when its exact title and source/run receipt match this workflow and its creator is immutable `github-actions[bot]` ID `41898282`. Never move a tag, republish npm, or start a second stable release before the first completes. - Treat the user's request to change this repository as standing authorization for routine task-owned commits, pushes, pull requests, merges, releases, deployments, and production verification after the repository's required validation, review, identity, and rollout gates pass. Do not ask for another confirmation at each delivery step. diff --git a/docs/publishing.md b/docs/publishing.md index dd5cf63..2e4ef31 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -182,7 +182,8 @@ before staging, it independently parses the packed manifest, rejects npm's top-level `tag` override, rejects an unresolved prior mutation intent from durable all-attempt Actions history, fetches current `main` into a new bare Git directory, -then rehashes all three files and invokes only `npm stage publish` against +uses npm/node-tar-compatible USTAR prefix semantics, then rehashes all three +files and invokes only `npm stage publish` against `https://registry.npmjs.org`. It rejects ambient tag configuration, runs from an empty directory with empty user/global npm config, and proves pinned npm's clean default `latest` before invocation. Leaving the tag implicit preserves @@ -211,14 +212,19 @@ resolves that tag from GitHub, requires its commit to remain reachable from current `main`, reads the name and version from the tagged `package.json`, and checks and builds the tagged source in a detached worktree. That explicit tagged `bun run check` is the only historical build boundary. Afterward, the -workflow rebinds the release helpers to their reviewed Git blobs in the current -workflow checkout and invokes those files by absolute path while retaining the -tagged tree as the package working directory. Bun loads no tag-owned config or -environment file. The package step uses `npm pack --ignore-scripts`, so it does -not run the tag's `prepack` or another historical lifecycle script. The current -helpers import their current core-only archive inspector. They do not import a -script from the tagged tree. They compare the rebuilt package with the public -npm package by canonical content and registry metadata. The pinned signature +workflow checks out exact current `main`, requires the tag-triggered Release +workflow and staging workflow to be byte-identical there, rebinds the release +helpers to reviewed current-main Git blobs, and invokes those files by absolute +path while retaining the tagged tree only as the package working directory. +Bun loads no tag-owned config or environment file. The package step uses +`npm pack --ignore-scripts`, so it does not run the tag's `prepack` or another +historical lifecycle script. The current helpers import their current core-only +archive inspector. They do not import a script from the tagged tree. They +compare the rebuilt package with the public npm package by canonical content +and registry metadata. Immediately before GitHub Release creation, the write +job imports authenticated current `main` twice, requires it not to move, repeats +the tag-to-main workflow closure, and proves every verifier helper is unchanged +from the exact main commit used by the read-only job. The pinned signature audit must cryptographically validate both registry and Sigstore evidence. The decoded attestations must bind the downloaded tarball SHA-512 to the exact npm publish predicate and to SLSA provenance for diff --git a/scripts/check-workflow-yaml.ts b/scripts/check-workflow-yaml.ts index e9bd51c..4aad98f 100644 --- a/scripts/check-workflow-yaml.ts +++ b/scripts/check-workflow-yaml.ts @@ -249,6 +249,9 @@ export function validateNpmStageWorkflow(source: string, label: string): void { "Record exclusive stable-stage intent", "Record cleared stable-stage intent", "jobs?filter=all&per_page=100", + "has a terminal write without one durable intent", + "has an unsealed generic stage job", + "jobId: 99146963354", 'execute("npm", [', "dist-tags.latest", ]) { diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 7925068..c331082 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -195,6 +195,22 @@ async function runWorkflowScript( return Object.freeze({ exitCode, stderr, stdout }); } +async function writeReleaseControlGitMock(binaryDirectory: string): Promise { + const path = join(binaryDirectory, "git"); + await writeFile(path, [ + "#!/bin/bash", + "set -euo pipefail", + 'case "$*" in', + ' "fetch --no-tags --force origin "*) exit 0 ;;', + ' "rev-parse refs/remotes/kb-release-current/main") printf \'%s\\n\' "$MOCK_SOURCE_SHA" ;;', + ' "merge-base --is-ancestor "*) exit 0 ;;', + ' "diff --quiet --no-ext-diff --no-textconv "*) [[ "${MOCK_CONTROL_DRIFT:-false}" != true ]] ;;', + ' *) echo "unexpected git invocation: $*" >&2; exit 2 ;;', + "esac", + ].join("\n")); + await chmod(path, 0o755); +} + describe("package smoke version policy", () => { test("requires the Oh adoption preparer only from its stable introduction", () => { expect(requiresOhAdoptionPreparerExport("0.17.1")).toBe(false); @@ -409,6 +425,9 @@ describe("npm release workflows", () => { "Record cleared stable-stage intent v${{ inputs.resolved_stage_version }}", "Record exclusive stable-stage intent", "jobs?filter=all&per_page=100", + "has a terminal write without one durable intent", + "has an unsealed generic stage job", + "jobId: 99146963354", "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", "Downloaded npm artifact must contain exactly the tarball, npm-pack.json, and npm-package.sha256", 'expected_tarball_name="hraness-kb-$EXPECTED_VERSION.tgz"', @@ -422,6 +441,7 @@ describe("npm release workflows", () => { 'createHash("sha256")', 'gunzipSync(archiveBytes', 'header.subarray(257, 265).equals(ustarSignature)', + "header[475] === 0 ? 130 : 155", 'Object.hasOwn(manifest, "tag")', 'JSON.stringify(Object.keys(publishConfig).sort()) !== JSON.stringify(["access", "registry"])', 'git init --quiet --bare "$current_main"', @@ -775,7 +795,26 @@ describe("npm release workflows", () => { })); const unboundHistory = await runWorkflowScript(script, environment); expect(unboundHistory.exitCode).not.toBe(0); - expect(unboundHistory.stderr).toContain("lacks a version-bound intent"); + expect(unboundHistory.stderr).toContain("has an unsealed generic stage job"); + + for (const terminalConclusion of ["failure", "cancelled", "timed_out"] as const) { + await writeFile(jobsPath, JSON.stringify({ + total_count: 1, + jobs: [{ + name: "Stage exact package v0.19.1", + conclusion: terminalConclusion, + steps: [{ + name: "Revalidate current main and stage exact package", + conclusion: terminalConclusion, + }], + }], + })); + const unreservedMutation = await runWorkflowScript(script, environment); + expect(unreservedMutation.exitCode).not.toBe(0); + expect(unreservedMutation.stderr).toContain( + "has a terminal write without one durable intent", + ); + } await Promise.all([ writeFile(runsPath, JSON.stringify({ @@ -791,11 +830,16 @@ describe("npm release workflows", () => { writeFile(jobsPath, JSON.stringify({ total_count: 1, jobs: [{ - name: "Stage exact package", conclusion: "success", head_sha: "e12d3fd05ffaa722ac1c43a8ecaa7d21fece679a", + id: 99146963354, + name: "Stage exact package", run_attempt: 1, - steps: [], + status: "completed", + steps: [{ + name: "Revalidate current main and stage exact package", + conclusion: "success", + }], }], })), ]); @@ -1096,6 +1140,7 @@ describe("npm release workflows", () => { "esac", ].join("\n"), ); + await writeReleaseControlGitMock(binaryDirectory); await chmod(join(binaryDirectory, "gh"), 0o755); const environment = { PATH: `${binaryDirectory}:${process.env.PATH ?? ""}`, @@ -1113,6 +1158,15 @@ describe("npm release workflows", () => { WORKFLOW_SHA: sourceSha, }; + const controlDrift = await runWorkflowScript(script, { + ...environment, + MOCK_CONTROL_DRIFT: "true", + }); + expect(controlDrift.exitCode).not.toBe(0); + expect(controlDrift.stderr).toContain( + "Tagged and current release workflow controls differ", + ); + const oversizedTag = await runWorkflowScript(script, { ...environment, MOCK_TAGS: "v0.20.0\nv9007199254740992.0.0", @@ -1172,6 +1226,7 @@ describe("npm release workflows", () => { "esac", ].join("\n"), ); + await writeReleaseControlGitMock(binaryDirectory); await Promise.all([ chmod(join(binaryDirectory, "npm"), 0o755), chmod(join(binaryDirectory, "gh"), 0o755), @@ -1233,9 +1288,11 @@ describe("npm release workflows", () => { 'event.sender?.type !== "User"', 'event.repository?.visibility !== "public"', "REF_PROTECTED: ${{ github.ref_protected }}", + "ref: main", 'release_ref="refs/kb-release-tags/$release_tag"', "Release tag must be annotated", 'git merge-base --is-ancestor "$tag_commit" "$default_head"', + "Tagged and current release workflow controls differ", "Tag $release_tag is not the newest stable tag", 'git worktree add --detach "$source_tree" "$SOURCE_SHA"', 'current_prepare="$GITHUB_WORKSPACE/scripts/prepare-npm-package.ts"', @@ -1258,6 +1315,10 @@ describe("npm release workflows", () => { '--registry-latest-json "$registry_latest_json"', 'npm view "@hraness/kb" dist-tags.latest', 'current_tag_sha="$(gh api', + "verify_current_release_controls", + "Current release verifier controls changed after verification", + 'scripts/npm-release-attestation.ts', + 'scripts/prepare-npm-package.ts', 'compare/$VERIFIED_SOURCE_SHA...$current_default_sha', 'EXPECTED_ACTIONS_BOT_ID="41898282"', "Automated immutable release for @hraness/kb@", @@ -1331,8 +1392,10 @@ describe("npm release workflows", () => { "owner ID `307125679`", "clean default `latest`", "top-level `tag`", - "rebinds the release helpers to their reviewed Git blobs", - "invokes those files by absolute path", + "rebinds the release\nhelpers to reviewed current-main Git blobs", + "checks out exact current `main`", + "repeats\nthe tag-to-main workflow closure", + "and invokes those files by absolute\npath", "`npm pack --ignore-scripts`", npmRegistry, ] as const) expect(guide).toContain(required); @@ -1363,6 +1426,8 @@ describe("npm release workflows", () => { expect(agents).toContain("do not claim this workflow prevents out-of-band stages"); expect(agents).toContain("sole main source commit"); expect(agents).toContain("The protected tag workflow must bind the actor and event sender"); + expect(agents).toContain("Run release verifiers from exact current `main`"); + expect(agents).toContain("revalidate the complete verifier closure immediately before mutation"); expect(agents).toContain("public repository ID `1308971873`"); }); From beab4b59dce8c157774d6fe5f7eb1be1bcedf726 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 5 Sep 2026 10:45:22 -0400 Subject: [PATCH 2/2] release: align staging history and tar parsing --- .github/workflows/npm-stage.yml | 36 +++- AGENTS.md | 2 +- docs/publishing.md | 11 +- scripts/check-workflow-yaml.test.ts | 17 ++ scripts/check-workflow-yaml.ts | 16 +- scripts/npm-release-workflow.test.ts | 295 +++++++++++++++++++++------ scripts/package-artifact.ts | 13 +- 7 files changed, 308 insertions(+), 82 deletions(-) diff --git a/.github/workflows/npm-stage.yml b/.github/workflows/npm-stage.yml index 4e076ee..e9fa9b4 100644 --- a/.github/workflows/npm-stage.yml +++ b/.github/workflows/npm-stage.yml @@ -475,7 +475,11 @@ jobs: ) { throw new Error(`npm-stage run ${runId} contains an invalid job`); } - if (!job.name.startsWith("Stage exact package")) continue; + const terminalWrites = job.steps.filter((step) => ( + step?.name === "Revalidate current main and stage exact package" + && step?.conclusion !== null + && step?.conclusion !== "skipped" + )); const intents = job.steps.filter((step) => ( step?.name === "Record exclusive stable-stage intent" && step?.conclusion === "success" @@ -485,6 +489,9 @@ jobs: && step.name.startsWith("Record cleared stable-stage intent") && step?.conclusion === "success" )); + const hasSafePositiveStepNumber = (step) => ( + Number.isSafeInteger(step?.number) && step.number > 0 + ); if (intents.length > 1 || resolutions.length > 1) { throw new Error(`npm-stage run ${runId} has ambiguous intent history`); } @@ -510,6 +517,25 @@ jobs: reserve(legacy.version, runId); continue; } + if ( + terminalWrites.length > 1 + || (terminalWrites.length === 1 && ( + intents.length !== 1 + || !hasSafePositiveStepNumber(terminalWrites[0]) + || !hasSafePositiveStepNumber(intents[0]) + || intents[0].number !== terminalWrites[0].number - 1 + )) + ) { + throw new Error( + `npm-stage run ${runId} has a terminal write without one immediately preceding durable intent`, + ); + } + if (!job.name.startsWith("Stage exact package")) { + if (terminalWrites.length > 0 || intents.length > 0 || resolutions.length > 0) { + throw new Error(`npm-stage run ${runId} contains staging controls outside a version-bound stage job`); + } + continue; + } const match = /^Stage exact package v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/u.exec(job.name); if (match === null) { throw new Error(`npm-stage run ${runId} lacks a version-bound stage job`); @@ -523,14 +549,6 @@ jobs: parseVersion(resolutionMatch[1], `Cleared version from run ${runId}`); increment(resolutionCounts, resolutionMatch[1]); } - const terminalWrites = job.steps.filter((step) => ( - step?.name === "Revalidate current main and stage exact package" - && step?.conclusion !== null - && step?.conclusion !== "skipped" - )); - if (terminalWrites.length > 1 || (terminalWrites.length === 1 && intents.length !== 1)) { - throw new Error(`npm-stage run ${runId} has a terminal write without one durable intent`); - } } }; // A rerun is in progress and therefore absent from the completed-run diff --git a/AGENTS.md b/AGENTS.md index e4f8dc7..b1dc48d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ - Keep `portfolio-inventory.json` byte-canonical and consistent with the public package identity, version, repository, direct `@hraness/*` dependency edges, and Hraness-owned dependencies pinned by exact immutable GitHub specifiers. - Pair concrete behavior tests with property tests for parsing, resolution, ordering, path confinement, and round-trip laws. - Run `bun test src/benchmark.test.ts src/evaluation.test.ts src/evaluation-kb.test.ts src/search.test.ts src/sdk.test.ts` when changing rank fusion, retrieval defaults, frozen-corpus execution, or built-in evaluation adapters. The six-case synthetic rank-fusion fixture is a deterministic regression, not a retrieval-quality or performance benchmark. Keep real-corpus manifests versioned, judgments independent of rankings, raw lane evidence intact, and performance claims tied to named hardware and measured runs. Run `bun run check` before handing off a change; it must leave committed `dist/` and `bun.lock` unchanged. -- Follow `docs/publishing.md` for the historical bootstrap and later releases. Trust only `.github/workflows/npm-stage.yml` with `npm stage publish` permission bound to the exact `npm-stage` environment. Keep that environment restricted solely to the selected default branch `main`, with administrator bypass disabled, no required deployment reviewers, and no secrets. Pushes and default dispatches must build and upload the exact candidate without OIDC; only an intentional current-main stable-train dispatch with boolean `publish_to_npm=true` may admit the minimal staging job. Its first step must use only `actions: read` plus `id-token: write` and reauthorize the current run attempt against owner `User` ID `894119`, both actor identities, active workflow ID/name/path, exact public repository ID `1308971873`, protected `main`, and the verified source SHA. Independently reject npm's packed top-level `tag` override and every noncanonical `publishConfig`, re-read public `latest`, and reject any unresolved version-bound Actions intent newer than `latest` before staging only the reviewed tarball through pinned npm's scrubbed clean default `latest`; do not pass an explicit tag because that disables npm's built-in higher-version guard. Record a successful intent step immediately before mutation and scan every retained attempt so an ambiguous runner failure remains locked. npm's short-lived trust assertion cannot list stages, so resolve provider state out of band; a failed, interrupted, or rejected stage may release only its exact durable intent through the exceptional owner-authorized `resolved_stage_version` input and matching successful resolution step. Leave that input empty normally and do not claim this workflow prevents out-of-band stages. Disallow traditional publishing tokens and preserve `contentPolicy.class=dual-use` plus the root `DISCLOSURE` in every package. npm's separate public promotion remains human-gated by two-factor authentication; batch that unavoidable promotion into intentional stable releases. +- Follow `docs/publishing.md` for the historical bootstrap and later releases. Trust only `.github/workflows/npm-stage.yml` with `npm stage publish` permission bound to the exact `npm-stage` environment. Keep that environment restricted solely to the selected default branch `main`, with administrator bypass disabled, no required deployment reviewers, and no secrets. Pushes and default dispatches must build and upload the exact candidate without OIDC; only an intentional current-main stable-train dispatch with boolean `publish_to_npm=true` may admit the minimal staging job. Its first step must use only `actions: read` plus `id-token: write` and reauthorize the current run attempt against owner `User` ID `894119`, both actor identities, active workflow ID/name/path, exact public repository ID `1308971873`, protected `main`, and the verified source SHA. Independently reject npm's packed top-level `tag` override and every noncanonical `publishConfig`, re-read public `latest`, and reject any unresolved version-bound Actions intent newer than `latest` before staging only the reviewed tarball through pinned npm's scrubbed clean default `latest`; do not pass an explicit tag because that disables npm's built-in higher-version guard. Record a successful intent step immediately before mutation and scan every retained attempt so an ambiguous runner failure remains locked. Recognize attempted terminal mutation steps before trusting any job display name, and accept each mutation only when its single successful intent has the immediately preceding safe positive Actions step number. npm's short-lived trust assertion cannot list stages, so resolve provider state out of band; a failed, interrupted, or rejected stage may release only its exact durable intent through the exceptional owner-authorized `resolved_stage_version` input and matching successful resolution step. Leave that input empty normally and do not claim this workflow prevents out-of-band stages. Disallow traditional publishing tokens and preserve `contentPolicy.class=dual-use` plus the root `DISCLOSURE` in every package. npm's separate public promotion remains human-gated by two-factor authentication; batch that unavoidable promotion into intentional stable releases. - Use two exact active rulesets matching `refs/tags/v*`: **Immutable version tags** restricts update and deletion with an empty bypass list, while **Release tag creation** restricts creation and has owner `User` ID `894119` as its sole always-bypass actor. Never grant generic GitHub Actions integration ID `15368`, an administrator, a repository role, a team, or another integration this bypass; never combine creation with update/delete or create probe tags. Publish and verify the exact staged npm artifact first, approve its public promotion with human 2FA, then let the owner-authenticated operator create the exact annotated stable `v` tag on `main`. The protected tag workflow must bind the actor and event sender to owner `User` ID `894119` and public repository ID `1308971873` before checkout, then verify the tag, source, registry artifact, and immutable Latest Release. Run release verifiers from exact current `main`; require the tag-triggered Release and staging workflows to match current `main`, and revalidate the complete verifier closure immediately before mutation. Before any GitHub Release mutation, require exact npm `dist-tags.latest`, nonempty canonical registry signatures, and pinned npm `11.19.0` cryptographic verification of the exact publish and SLSA provenance attestations, including the registry tarball SHA-512, staging workflow identity, public repository and owner IDs, sole main source commit, `workflow_dispatch` event, GitHub-hosted builder, and canonical invocation. Accept an existing Release only when its exact title and source/run receipt match this workflow and its creator is immutable `github-actions[bot]` ID `41898282`. Never move a tag, republish npm, or start a second stable release before the first completes. diff --git a/docs/publishing.md b/docs/publishing.md index 2e4ef31..5d8e9ac 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -181,9 +181,14 @@ SHA-512, and the independent SHA-256 manifest before mutation. Immediately before staging, it independently parses the packed manifest, rejects npm's top-level `tag` override, rejects an unresolved prior mutation intent from durable all-attempt -Actions history, fetches current `main` into a new bare Git directory, -uses npm/node-tar-compatible USTAR prefix semantics, then rehashes all three -files and invokes only `npm stage publish` against +Actions history, and inspects every recognized attempted terminal mutation +before it trusts a job display name. A terminal mutation is bound only when its +single successful durable intent has the immediately preceding safe positive +Actions step number. The job fetches current `main` into a new bare +Git directory, then parses the archive with the same exact eight-byte USTAR +magic/version signature and byte-475 130/155-byte prefix discriminator used by +the current source/release archive verifier. It rehashes all three files and +invokes only `npm stage publish` against `https://registry.npmjs.org`. It rejects ambient tag configuration, runs from an empty directory with empty user/global npm config, and proves pinned npm's clean default `latest` before invocation. Leaving the tag implicit preserves diff --git a/scripts/check-workflow-yaml.test.ts b/scripts/check-workflow-yaml.test.ts index edf1646..13dfb08 100644 --- a/scripts/check-workflow-yaml.test.ts +++ b/scripts/check-workflow-yaml.test.ts @@ -53,6 +53,23 @@ jobs: )).toThrow("must recheck current default-branch HEAD"); }); + test("inspects terminal npm mutations before trusting a stage-job display name", async () => { + const path = resolve(import.meta.dir, "../.github/workflows/npm-stage.yml"); + const source = await readFile(path, "utf8"); + const delayed = source + .replace("const terminalWrites =", "const delayedTerminalWrites =") + .replace( + " const match = /^Stage exact package v", + " const terminalWrites = delayedTerminalWrites;\n" + + " const match = /^Stage exact package v", + ); + expect(delayed).not.toBe(source); + expect(() => validateNpmStageWorkflow(source, "npm-stage.yml")).not.toThrow(); + expect(() => validateNpmStageWorkflow(delayed, "npm-stage.yml")).toThrow( + "must inspect terminal writes before trusting a job display name", + ); + }); + test("keeps npm staging version-selected, environment-bound, tokenless, artifact-bound, and stage-only", async () => { const path = resolve(import.meta.dir, "../.github/workflows/npm-stage.yml"); const source = await readFile(path, "utf8"); diff --git a/scripts/check-workflow-yaml.ts b/scripts/check-workflow-yaml.ts index 4aad98f..505e6e6 100644 --- a/scripts/check-workflow-yaml.ts +++ b/scripts/check-workflow-yaml.ts @@ -249,9 +249,12 @@ export function validateNpmStageWorkflow(source: string, label: string): void { "Record exclusive stable-stage intent", "Record cleared stable-stage intent", "jobs?filter=all&per_page=100", - "has a terminal write without one durable intent", + "has a terminal write without one immediately preceding durable intent", "has an unsealed generic stage job", "jobId: 99146963354", + "Number.isSafeInteger(step?.number)", + "intents[0].number !== terminalWrites[0].number - 1", + "contains staging controls outside a version-bound stage job", 'execute("npm", [', "dist-tags.latest", ]) { @@ -259,6 +262,17 @@ export function validateNpmStageWorkflow(source: string, label: string): void { throw new Error(`${label} pending-stage guard is missing ${required}`); } } + const terminalInspectionIndex = pendingStageStep.run.indexOf("const terminalWrites ="); + const jobDisplayNameFilterIndex = pendingStageStep.run.indexOf( + 'if (!job.name.startsWith("Stage exact package"))', + ); + if ( + terminalInspectionIndex < 0 + || jobDisplayNameFilterIndex < 0 + || terminalInspectionIndex >= jobDisplayNameFilterIndex + ) { + throw new Error(`${label} must inspect terminal writes before trusting a job display name`); + } if (steps.some((step) => typeof step.uses === "string" && (step.uses.startsWith("actions/checkout@") || step.uses.startsWith("oven-sh/setup-bun@")))) { diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index c331082..2aa39de 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -49,13 +49,39 @@ function sha256(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } -async function injectPackedTopLevelTag( +async function persistPackedTarMutation( artifactDirectory: string, tarballName: string, + tar: Buffer, + metadata: Array>, ): Promise { const tarballPath = join(artifactDirectory, tarballName); const metadataPath = join(artifactDirectory, "npm-pack.json"); const digestPath = join(artifactDirectory, "npm-package.sha256"); + if (metadata.length !== 1 || metadata[0] === undefined) { + throw new Error("Test npm-pack.json is invalid"); + } + const archive = gzipSync(tar); + metadata[0].size = archive.byteLength; + metadata[0].integrity = integrity(archive); + metadata[0].shasum = sha1(archive); + const metadataBytes = Buffer.from(`${JSON.stringify(metadata)}\n`, "utf8"); + await Promise.all([ + writeFile(tarballPath, archive), + writeFile(metadataPath, metadataBytes), + writeFile( + digestPath, + `${sha256(archive)} ${tarballName}\n${sha256(metadataBytes)} npm-pack.json\n`, + ), + ]); +} + +async function injectPackedTopLevelTag( + artifactDirectory: string, + tarballName: string, +): Promise { + const tarballPath = join(artifactDirectory, tarballName); + const metadataPath = join(artifactDirectory, "npm-pack.json"); const tar = gunzipSync(await readFile(tarballPath)); let offset = 0; let replaced = false; @@ -87,24 +113,8 @@ async function injectPackedTopLevelTag( offset += Math.ceil(size / 512) * 512; } if (!replaced) throw new Error("Packed manifest was not mutated"); - - const archive = gzipSync(tar); const metadata = JSON.parse(await readFile(metadataPath, "utf8")) as Array>; - if (metadata.length !== 1 || metadata[0] === undefined) { - throw new Error("Test npm-pack.json is invalid"); - } - metadata[0].size = archive.byteLength; - metadata[0].integrity = integrity(archive); - metadata[0].shasum = sha1(archive); - const metadataBytes = Buffer.from(`${JSON.stringify(metadata)}\n`, "utf8"); - await Promise.all([ - writeFile(tarballPath, archive), - writeFile(metadataPath, metadataBytes), - writeFile( - digestPath, - `${sha256(archive)} ${tarballName}\n${sha256(metadataBytes)} npm-pack.json\n`, - ), - ]); + await persistPackedTarMutation(artifactDirectory, tarballName, tar, metadata); } async function corruptPackedUstarVersion( @@ -113,7 +123,6 @@ async function corruptPackedUstarVersion( ): Promise { const tarballPath = join(artifactDirectory, tarballName); const metadataPath = join(artifactDirectory, "npm-pack.json"); - const digestPath = join(artifactDirectory, "npm-package.sha256"); const tar = gunzipSync(await readFile(tarballPath)); const signature = Buffer.from([0x75, 0x73, 0x74, 0x61, 0x72, 0x00, 0x30, 0x30]); if (!tar.subarray(257, 265).equals(signature)) { @@ -122,23 +131,70 @@ async function corruptPackedUstarVersion( tar[263] = 0x78; tar[264] = 0x78; writeHeaderChecksum(tar, 0); - const archive = gzipSync(tar); const metadata = JSON.parse(await readFile(metadataPath, "utf8")) as Array>; - if (metadata.length !== 1 || metadata[0] === undefined) { - throw new Error("Test npm-pack.json is invalid"); + await persistPackedTarMutation(artifactDirectory, tarballName, tar, metadata); +} + +async function injectPackedExtendedPrefixTraversal( + artifactDirectory: string, + tarballName: string, +): Promise { + const tarballPath = join(artifactDirectory, tarballName); + const metadataPath = join(artifactDirectory, "npm-pack.json"); + const tar = gunzipSync(await readFile(tarballPath)); + const metadata = JSON.parse(await readFile(metadataPath, "utf8")) as Array>; + const record = metadata[0]; + if (record === undefined || !Array.isArray(record.files)) { + throw new Error("Test npm-pack.json lacks its file inventory"); } - metadata[0].size = archive.byteLength; - metadata[0].integrity = integrity(archive); - metadata[0].shasum = sha1(archive); - const metadataBytes = Buffer.from(`${JSON.stringify(metadata)}\n`, "utf8"); - await Promise.all([ - writeFile(tarballPath, archive), - writeFile(metadataPath, metadataBytes), - writeFile( - digestPath, - `${sha256(archive)} ${tarballName}\n${sha256(metadataBytes)} npm-pack.json\n`, - ), - ]); + const target = record.files.find((value) => ( + typeof value === "object" + && value !== null + && typeof (value as Record).path === "string" + && String((value as Record).path).startsWith("dist/") + && ![ + "dist/cli.js", + "dist/evaluation-builder.js", + "dist/index.js", + ].includes(String((value as Record).path)) + )) as Record | undefined; + if (target === undefined || typeof target.path !== "string") { + throw new Error("Test npm-pack.json lacks a mutable dist file"); + } + + let offset = 0; + let mutated = false; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) break; + const size = readTarOctal(tar, offset + 124); + const field = (start: number, length: number): string => { + const bytes = header.subarray(start, start + length); + const zero = bytes.indexOf(0); + return (zero < 0 ? bytes : bytes.subarray(0, zero)).toString("ascii"); + }; + const name = field(0, 100); + const prefix = field(345, header[475] === 0 ? 130 : 155); + const path = prefix === "" ? name : `${prefix}/${name}`; + if (path === `package/${target.path}`) { + const safePrefix = `package/dist/${"a".repeat(117)}`; + if (Buffer.byteLength(safePrefix, "ascii") !== 130) { + throw new Error("Test USTAR prefix fixture has the wrong width"); + } + header.fill(0, 0, 100); + header.write("fixture.js", 0, "ascii"); + header.fill(0, 345, 500); + header.write(safePrefix, 345, "ascii"); + header.write("/../hostile", 475, "ascii"); + target.path = `${safePrefix.slice("package/".length)}/fixture.js`; + writeHeaderChecksum(tar, offset); + mutated = true; + break; + } + offset += 512 + Math.ceil(size / 512) * 512; + } + if (!mutated) throw new Error("Test package lacks the selected tar entry"); + await persistPackedTarMutation(artifactDirectory, tarballName, tar, metadata); } function requireOwnerReleaseAuthorization(workflow: string): void { @@ -425,9 +481,12 @@ describe("npm release workflows", () => { "Record cleared stable-stage intent v${{ inputs.resolved_stage_version }}", "Record exclusive stable-stage intent", "jobs?filter=all&per_page=100", - "has a terminal write without one durable intent", + "has a terminal write without one immediately preceding durable intent", "has an unsealed generic stage job", "jobId: 99146963354", + "Number.isSafeInteger(step?.number)", + "intents[0].number !== terminalWrites[0].number - 1", + "contains staging controls outside a version-bound stage job", "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", "Downloaded npm artifact must contain exactly the tarball, npm-pack.json, and npm-package.sha256", 'expected_tarball_name="hraness-kb-$EXPECTED_VERSION.tgz"', @@ -742,7 +801,11 @@ describe("npm release workflows", () => { jobs: [{ name: "Stage exact package v0.19.0", conclusion: "success", - steps: [{ name: "Record exclusive stable-stage intent", conclusion: "success" }], + steps: [{ + name: "Record exclusive stable-stage intent", + conclusion: "success", + number: 12, + }], }], })); const released = await runWorkflowScript(script, environment); @@ -754,8 +817,12 @@ describe("npm release workflows", () => { name: "Stage exact package v0.19.1", conclusion: "failure", steps: [ - { name: "Record exclusive stable-stage intent", conclusion: "success" }, - { name: "Revalidate current main and stage exact package", conclusion: "failure" }, + { name: "Record exclusive stable-stage intent", conclusion: "success", number: 12 }, + { + name: "Revalidate current main and stage exact package", + conclusion: "failure", + number: 13, + }, ], }], })); @@ -777,13 +844,21 @@ describe("npm release workflows", () => { name: "Stage exact package v0.19.2", conclusion: "failure", steps: [ - { name: "Record cleared stable-stage intent v0.19.1", conclusion: "success" }, - { name: "Record exclusive stable-stage intent", conclusion: "skipped" }, + { + name: "Record cleared stable-stage intent v0.19.1", + conclusion: "success", + number: 4, + }, + { name: "Record exclusive stable-stage intent", conclusion: "skipped", number: 12 }, ], }, { name: "Stage exact package v0.19.1", conclusion: "failure", - steps: [{ name: "Record exclusive stable-stage intent", conclusion: "success" }], + steps: [{ + name: "Record exclusive stable-stage intent", + conclusion: "success", + number: 12, + }], }], })); const durableResolution = await runWorkflowScript(script, environment); @@ -806,16 +881,77 @@ describe("npm release workflows", () => { steps: [{ name: "Revalidate current main and stage exact package", conclusion: terminalConclusion, + number: 13, }], }], })); const unreservedMutation = await runWorkflowScript(script, environment); expect(unreservedMutation.exitCode).not.toBe(0); expect(unreservedMutation.stderr).toContain( - "has a terminal write without one durable intent", + "has a terminal write without one immediately preceding durable intent", ); } + await writeFile(jobsPath, JSON.stringify({ + total_count: 1, + jobs: [{ + name: "Renamed untrusted mutation job", + conclusion: "failure", + steps: [{ + name: "Revalidate current main and stage exact package", + conclusion: "failure", + number: 13, + }], + }], + })); + const renamedMutation = await runWorkflowScript(script, environment); + expect(renamedMutation.exitCode).not.toBe(0); + expect(renamedMutation.stderr).toContain( + "has a terminal write without one immediately preceding durable intent", + ); + + await writeFile(jobsPath, JSON.stringify({ + total_count: 1, + jobs: [{ + name: "Stage exact package v0.19.1", + conclusion: "failure", + steps: [ + { + name: "Revalidate current main and stage exact package", + conclusion: "failure", + number: 12, + }, + { name: "Record exclusive stable-stage intent", conclusion: "success", number: 13 }, + ], + }], + })); + const reversedIntent = await runWorkflowScript(script, environment); + expect(reversedIntent.exitCode).not.toBe(0); + expect(reversedIntent.stderr).toContain( + "has a terminal write without one immediately preceding durable intent", + ); + + await writeFile(jobsPath, JSON.stringify({ + total_count: 1, + jobs: [{ + name: "Stage exact package v0.19.1", + conclusion: "failure", + steps: [ + { name: "Record exclusive stable-stage intent", conclusion: "success", number: 0 }, + { + name: "Revalidate current main and stage exact package", + conclusion: "failure", + number: 1, + }, + ], + }], + })); + const unsafeStepNumber = await runWorkflowScript(script, environment); + expect(unsafeStepNumber.exitCode).not.toBe(0); + expect(unsafeStepNumber.stderr).toContain( + "has a terminal write without one immediately preceding durable intent", + ); + await Promise.all([ writeFile(runsPath, JSON.stringify({ total_count: 1, @@ -839,6 +975,7 @@ describe("npm release workflows", () => { steps: [{ name: "Revalidate current main and stage exact package", conclusion: "success", + number: 13, }], }], })), @@ -854,8 +991,12 @@ describe("npm release workflows", () => { conclusion: "failure", name: "Stage exact package v0.19.1", steps: [ - { name: "Record exclusive stable-stage intent", conclusion: "success" }, - { name: "Revalidate current main and stage exact package", conclusion: "failure" }, + { name: "Record exclusive stable-stage intent", conclusion: "success", number: 12 }, + { + name: "Revalidate current main and stage exact package", + conclusion: "failure", + number: 13, + }, ], }, { conclusion: null, @@ -920,34 +1061,51 @@ describe("npm release workflows", () => { } }); - test("the source-free staging boundary rejects a USTAR version/path differential", async () => { + test("the source/release and source-free parsers reject shared hostile USTAR fixtures", async () => { const workflow = await readFile(stageWorkflowUrl, "utf8"); const script = workflowStepScript(workflow, "Rebind downloaded package"); const manifest = JSON.parse(await readFile(manifestUrl, "utf8")) as { readonly version: string }; - const root = await mkdtemp(join(tmpdir(), "kb-stage-ustar-version-")); - const artifactDirectory = join(root, "kb-npm-stage"); const tarballName = `hraness-kb-${manifest.version}.tgz`; - try { - await run([ - process.execPath, - "run", - "./scripts/prepare-npm-package.ts", - artifactDirectory, - ], repository); - await corruptPackedUstarVersion(artifactDirectory, tarballName); - const rejected = await runWorkflowScript(script, { - EXPECTED_SOURCE_SHA: "a".repeat(40), - EXPECTED_TARBALL_NAME: tarballName, - EXPECTED_VERSION: manifest.version, - GITHUB_OUTPUT: join(root, "github-output.txt"), - RUNNER_TEMP: root, - }); - expect(rejected.exitCode).not.toBe(0); - expect(rejected.stderr).toContain("Packed package.json tar header is invalid"); - } finally { - await rm(root, { recursive: true, force: true }); + for (const fixture of [ + { + mutate: corruptPackedUstarVersion, + name: "version", + sourceError: "exact USTAR magic/version", + stageError: "Packed package.json tar header is invalid", + }, + { + mutate: injectPackedExtendedPrefixTraversal, + name: "extended-prefix", + sourceError: "unsafe path", + stageError: "Packed package.json tar path is unsafe", + }, + ] as const) { + const root = await mkdtemp(join(tmpdir(), `kb-stage-ustar-${fixture.name}-`)); + const artifactDirectory = join(root, "kb-npm-stage"); + const tarball = join(artifactDirectory, tarballName); + try { + await run([ + process.execPath, + "run", + "./scripts/prepare-npm-package.ts", + artifactDirectory, + ], repository); + await fixture.mutate(artifactDirectory, tarballName); + await expect(inspectPackageArtifact(tarball)).rejects.toThrow(fixture.sourceError); + const rejected = await runWorkflowScript(script, { + EXPECTED_SOURCE_SHA: "a".repeat(40), + EXPECTED_TARBALL_NAME: tarballName, + EXPECTED_VERSION: manifest.version, + GITHUB_OUTPUT: join(root, "github-output.txt"), + RUNNER_TEMP: root, + }); + expect(rejected.exitCode).not.toBe(0); + expect(rejected.stderr).toContain(fixture.stageError); + } finally { + await rm(root, { recursive: true, force: true }); + } } - }); + }, 120_000); test("hostile actor or sender drift cannot reach the protected release workflow", async () => { const workflow = await readFile(releaseWorkflowUrl, "utf8"); @@ -1344,6 +1502,8 @@ describe("npm release workflows", () => { for (const required of [ "contentSha256", "contentSha512", + "header.subarray(257, 265).equals(ustarSignature)", + "header[475] === 0 ? 130 : 155", "Unsupported package tar entry type", "Package tar contains data after its zero trailer", "maxOutputLength", @@ -1421,6 +1581,7 @@ describe("npm release workflows", () => { expect(agents).toContain("clean default `latest`"); expect(agents).toContain("pinned npm `11.19.0`"); expect(agents).toContain("Record a successful intent step immediately before mutation"); + expect(agents).toContain("safe positive Actions step number"); expect(agents).toContain("scan every retained attempt"); expect(agents).toContain("cannot list stages"); expect(agents).toContain("do not claim this workflow prevents out-of-band stages"); diff --git a/scripts/package-artifact.ts b/scripts/package-artifact.ts index a50b6d9..27e5442 100644 --- a/scripts/package-artifact.ts +++ b/scripts/package-artifact.ts @@ -5,6 +5,7 @@ import { gunzipSync } from "node:zlib"; const blockSize = 512; const packagePrefix = "package/"; const maximumTarBytes = 6_500_000; +const ustarSignature = Buffer.from([0x75, 0x73, 0x74, 0x61, 0x72, 0x00, 0x30, 0x30]); const packageBudget = Object.freeze({ entryCount: { min: 190, max: 420 }, @@ -215,9 +216,19 @@ export async function inspectPackageArtifact( break; } verifyHeaderChecksum(header, offset); + if (!header.subarray(257, 265).equals(ustarSignature)) { + throw new Error( + `Package tar header at byte ${String(offset)} lacks the exact USTAR magic/version`, + ); + } const name = readString(header, 0, 100, `entry name at byte ${String(offset)}`); - const prefix = readString(header, 345, 155, `entry prefix at byte ${String(offset)}`); + const prefix = readString( + header, + 345, + header[475] === 0 ? 130 : 155, + `entry prefix at byte ${String(offset)}`, + ); const path = prefix.length > 0 ? `${prefix}/${name}` : name; const size = readOctal(header, 124, 12, `entry size for ${path}`); const mode = readOctal(header, 100, 8, `entry mode for ${path}`);