diff --git a/.github/workflows/agentplugins-npm-publish.yml b/.github/workflows/agentplugins-npm-publish.yml index 3dd1e0f9..1919af28 100644 --- a/.github/workflows/agentplugins-npm-publish.yml +++ b/.github/workflows/agentplugins-npm-publish.yml @@ -12,6 +12,28 @@ on: required: true default: false type: boolean + producer_mode: + description: Legacy publication or isolated prepublication pair staging + required: true + type: choice + default: legacy + options: [legacy, paired-stage] + source_sha: + description: Exact paired source and workflow SHA + type: string + required: false + plugin_kit_version: + description: Paired kit version, exactly 2.0.0 + type: string + required: false + native_inputs: + description: Exact retained canonical I UTF-8 bytes including final newline, comparison only + type: string + required: false + input_artifact: + description: Exact completed I locator JSON, run_id run_attempt artifact_id artifact_sha256 + type: string + required: false permissions: contents: read @@ -22,7 +44,51 @@ concurrency: cancel-in-progress: false jobs: + dispatch_contract: + runs-on: ubuntu-24.04 + timeout-minutes: 2 + permissions: + contents: read + env: + PRODUCER_MODE: ${{ inputs.producer_mode }} + TAG: ${{ inputs.tag }} + SOURCE_SHA: ${{ inputs.source_sha }} + KIT_VERSION: ${{ inputs.plugin_kit_version }} + PUBLISH: ${{ inputs.publish }} + NATIVE_INPUTS: ${{ inputs.native_inputs }} + INPUT_ARTIFACT: ${{ inputs.input_artifact }} + steps: + - name: C1 preflight + shell: bash + run: | + set -euo pipefail + export PATH="/usr/local/bin:${PATH}" + [[ "${GITHUB_EVENT_NAME}" == workflow_dispatch ]] + case "${PRODUCER_MODE}" in + legacy) [[ -z "${SOURCE_SHA}${KIT_VERSION}${NATIVE_INPUTS}${INPUT_ARTIFACT}" ]]; exit 0 ;; + paired-stage) [[ "${PUBLISH}" == false ]] ;; + *) exit 1 ;; + esac + [[ "${GITHUB_ACTIONS}" == true && "${GITHUB_REPOSITORY}" == 777genius/universal-agent-plugins ]] + [[ "${TAG}" =~ ^agentplugins-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ && ${#TAG} -le 47 ]] + [[ "${KIT_VERSION}" == 2.0.0 && "${TAG}" != agentplugins-v2.0.0 ]] + [[ "${SOURCE_SHA}" =~ ^[0-9a-f]{40}$ && ! "${SOURCE_SHA}" =~ ^0+$ ]] + [[ "${GITHUB_SHA}" == "${SOURCE_SHA}" && "${GITHUB_WORKFLOW_SHA}" == "${SOURCE_SHA}" ]] + [[ "${GITHUB_REF}" == "refs/tags/${TAG}" ]] + [[ "${GITHUB_WORKFLOW_REF}" == "777genius/universal-agent-plugins/.github/workflows/agentplugins-npm-publish.yml@refs/tags/${TAG}" ]] + positive() { [[ "$1" =~ ^[1-9][0-9]{0,15}$ && "$1" -le 9007199254740991 ]]; } + positive "${GITHUB_RUN_ID}" + positive "${GITHUB_RUN_ATTEMPT}"; [[ "${GITHUB_RUN_ATTEMPT}" -le 1000 ]] + [[ ${#NATIVE_INPUTS} -gt 0 && ${#NATIVE_INPUTS} -le 32768 && "${NATIVE_INPUTS}" == *$'\n' ]] + locator='^[[:space:]]*\{[[:space:]]*"run_id":[[:space:]]*([1-9][0-9]{0,15}),[[:space:]]*"run_attempt":[[:space:]]*([1-9][0-9]{0,3}),[[:space:]]*"artifact_id":[[:space:]]*([1-9][0-9]{0,15}),[[:space:]]*"artifact_sha256":[[:space:]]*"([0-9a-f]{64})"[[:space:]]*\}[[:space:]]*$' + [[ "${INPUT_ARTIFACT}" =~ $locator ]] + run_id="${BASH_REMATCH[1]}"; artifact_id="${BASH_REMATCH[3]}" + positive "${run_id}"; positive "${artifact_id}" + [[ "${INPUT_ARTIFACT}" =~ $locator ]] + [[ "${BASH_REMATCH[2]}" -le 1000 && ! "${BASH_REMATCH[4]}" =~ ^0+$ ]] prepare: + needs: dispatch_contract + if: ${{ success() && github.event_name == 'workflow_dispatch' && inputs.producer_mode == 'legacy' && needs.dispatch_contract.result == 'success' }} name: Verify release and stage npm package runs-on: ubuntu-24.04 timeout-minutes: 20 @@ -106,7 +172,7 @@ jobs: publish: name: Publish npm package with trusted provenance - if: ${{ inputs.publish == true }} + if: ${{ success() && github.event_name == 'workflow_dispatch' && inputs.producer_mode == 'legacy' && inputs.publish == true && needs.prepare.result == 'success' }} needs: prepare runs-on: ubuntu-24.04 timeout-minutes: 10 @@ -155,7 +221,7 @@ jobs: verify-public: name: Verify public npm provenance and lifecycle - if: ${{ inputs.publish == true }} + if: ${{ success() && github.event_name == 'workflow_dispatch' && inputs.producer_mode == 'legacy' && inputs.publish == true && needs.publish.result == 'success' }} needs: [prepare, publish] runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -233,3 +299,310 @@ jobs: run_agentplugins repair context7 --target opencode --format json | jq -e '.result == "success" and .data.succeeded == 1 and .data.failed == 0 and .data.targets[0].target == "opencode" and .data.targets[0].output.result.mutated == true' >/dev/null jq -e '.mcp.context7' "${repair_file}" > /dev/null run_agentplugins remove context7 --target opencode --external-uninstalled --format json | jq -e '.result == "success" and .data.succeeded == 1 and .data.failed == 0' >/dev/null + + paired_stage: + name: paired_stage + needs: dispatch_contract + if: ${{ success() && github.event_name == 'workflow_dispatch' && inputs.producer_mode == 'paired-stage' && inputs.publish == false && needs.dispatch_contract.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + actions: read + attestations: read + env: + PRODUCER_MODE: ${{ inputs.producer_mode }} + TAG: ${{ inputs.tag }} + SOURCE_SHA: ${{ inputs.source_sha }} + KIT_VERSION: ${{ inputs.plugin_kit_version }} + PUBLISH: ${{ inputs.publish }} + NATIVE_INPUTS: ${{ inputs.native_inputs }} + INPUT_ARTIFACT: ${{ inputs.input_artifact }} + GH_TOKEN: ${{ github.token }} + outputs: + stage_sha256: ${{ steps.stage.outputs.stage_sha256 }} + stage_artifact_id: ${{ steps.upload.outputs.artifact-id }} + stage_artifact_sha256: ${{ steps.upload_evidence.outputs.artifact_sha256 }} + steps: + - name: C1 preflight + shell: bash + run: | + set -euo pipefail + export PATH="/usr/local/bin:${PATH}" + [[ "${GITHUB_EVENT_NAME}" == workflow_dispatch ]] + case "${PRODUCER_MODE}" in + legacy) [[ -z "${SOURCE_SHA}${KIT_VERSION}${NATIVE_INPUTS}${INPUT_ARTIFACT}" ]]; exit 0 ;; + paired-stage) [[ "${PUBLISH}" == false ]] ;; + *) exit 1 ;; + esac + [[ "${GITHUB_ACTIONS}" == true && "${GITHUB_REPOSITORY}" == 777genius/universal-agent-plugins ]] + [[ "${TAG}" =~ ^agentplugins-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ && ${#TAG} -le 47 ]] + [[ "${KIT_VERSION}" == 2.0.0 && "${TAG}" != agentplugins-v2.0.0 ]] + [[ "${SOURCE_SHA}" =~ ^[0-9a-f]{40}$ && ! "${SOURCE_SHA}" =~ ^0+$ ]] + [[ "${GITHUB_SHA}" == "${SOURCE_SHA}" && "${GITHUB_WORKFLOW_SHA}" == "${SOURCE_SHA}" ]] + [[ "${GITHUB_REF}" == "refs/tags/${TAG}" ]] + [[ "${GITHUB_WORKFLOW_REF}" == "777genius/universal-agent-plugins/.github/workflows/agentplugins-npm-publish.yml@refs/tags/${TAG}" ]] + positive() { [[ "$1" =~ ^[1-9][0-9]{0,15}$ && "$1" -le 9007199254740991 ]]; } + positive "${GITHUB_RUN_ID}" + positive "${GITHUB_RUN_ATTEMPT}"; [[ "${GITHUB_RUN_ATTEMPT}" -le 1000 ]] + [[ ${#NATIVE_INPUTS} -gt 0 && ${#NATIVE_INPUTS} -le 32768 && "${NATIVE_INPUTS}" == *$'\n' ]] + locator='^[[:space:]]*\{[[:space:]]*"run_id":[[:space:]]*([1-9][0-9]{0,15}),[[:space:]]*"run_attempt":[[:space:]]*([1-9][0-9]{0,3}),[[:space:]]*"artifact_id":[[:space:]]*([1-9][0-9]{0,15}),[[:space:]]*"artifact_sha256":[[:space:]]*"([0-9a-f]{64})"[[:space:]]*\}[[:space:]]*$' + [[ "${INPUT_ARTIFACT}" =~ $locator ]] + run_id="${BASH_REMATCH[1]}"; artifact_id="${BASH_REMATCH[3]}" + positive "${run_id}"; positive "${artifact_id}" + [[ "${INPUT_ARTIFACT}" =~ $locator ]] + [[ "${BASH_REMATCH[2]}" -le 1000 && ! "${BASH_REMATCH[4]}" =~ ^0+$ ]] + [[ "${GITHUB_JOB}" == paired_stage ]] + - name: C1 checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + - name: C1 setup + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "22.23.2" + package-manager-cache: false + - name: C1 stage + id: stage + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict'); + const c = require('./npm/agentplugins/scripts/dual-authoring-candidate'); + const i = require('./npm/agentplugins/scripts/authoring-native-inputs'); + const s = require('./npm/agentplugins/scripts/stage-authoring-npm'); + const e = process.env, selected = {tag: e.TAG, ref: `refs/tags/${e.TAG}`, source: e.SOURCE_SHA, + versions: {agentplugins: e.TAG.slice('agentplugins-v'.length), 'plugin-kit-ai': e.KIT_VERSION}}; + const root = fs.mkdtempSync(path.join(e.RUNNER_TEMP, 'c1-workflow-')); + const scratch = path.join(root, 'scratch'); fs.mkdirSync(scratch, {mode: 0o700}); + const options = {selected, workflow_sha: e.SOURCE_SHA}; + const input_file = path.join(root, 'native-inputs.json'); + const input = Buffer.from(e.NATIVE_INPUTS, 'utf8'); assert.ok(input.length <= 32768); i.decodeInputs(input); + fs.writeFileSync(input_file, input, {flag: 'wx', mode: 0o400}); + const npm = fs.realpathSync(path.join(path.dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')); + assert.equal(path.basename(npm), 'npm-cli.js'); c.readFile(npm); + Object.assign(options, {input_file, repo: process.cwd(), workParent: scratch, node: process.execPath, npm}); + Object.assign(options, {artifact: JSON.parse(e.INPUT_ARTIFACT), output: path.join(root, 'output'), + producer: {workflow: '.github/workflows/agentplugins-npm-publish.yml', source: e.SOURCE_SHA, ref: selected.ref, + run_id: Number(e.GITHUB_RUN_ID), run_attempt: Number(e.GITHUB_RUN_ATTEMPT)}}); + const file = path.join(root, 'options.json'); fs.writeFileSync(file, c.encode(options), {flag: 'wx', mode: 0o400}); + const result = s.main(['--stage-prepublication', file]); + c.keys(result, ['root', 'record', 'subjects', 'stage_sha256'], 'stage CLI result'); + const names = ['completion.json', ...c.PRODUCTS.map(p => result.record.packs[p].file)]; + assert.equal(result.subjects.length, names.length); + assert.deepEqual(result.subjects.map(r => path.relative(result.root, r.file)).sort(), [...names].sort()); + for (const row of result.subjects) assert.equal(c.digest(c.readFile(row.file, 128 * 1024 * 1024)), row.sha256); + const payload = [...names]; + assert.equal(payload.length, 3); + const pins = (r) => payload.map(n => ({file: n, sha256: c.digest(c.readFile(path.join(r, n), 128 * 1024 * 1024))})); + const actual = pins(result.root); + const result_file = path.join(root, 'result.json'); + fs.writeFileSync(result_file, c.encode({root: result.root, subjects: result.subjects, pins: actual}), {flag: 'wx', mode: 0o400}); + const output = (key, value) => fs.appendFileSync(e.GITHUB_OUTPUT, `${key}< r.file).join('\n')); + output('payload', payload.map(n => path.join(result.root, n)).join('\n')); + output('stage_sha256', c.digest(c.readFile(path.join(result.root, 'completion.json')))); + NODE + - name: C1 upload + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: authoring-public-stage-${{ inputs.source_sha }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.stage.outputs.payload }} + if-no-files-found: error + retention-days: 7 + - name: C1 upload evidence + id: upload_evidence + env: + ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }} + ARTIFACT_SHA256: ${{ steps.upload.outputs.artifact-digest }} + COMPLETION_SHA256: ${{ steps.stage.outputs.stage_sha256 }} + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const fs = require('node:fs'), assert = require('node:assert/strict'), e = process.env; + assert.match(e.ARTIFACT_ID, /^[1-9][0-9]{0,15}$/); + const artifact_id = Number(e.ARTIFACT_ID); assert.ok(Number.isSafeInteger(artifact_id)); + const artifact_sha256 = e.ARTIFACT_SHA256.replace(/^sha256:/, '').toLowerCase(); + assert.match(artifact_sha256, /^[0-9a-f]{64}$/); assert.ok(!/^0+$/.test(artifact_sha256)); + fs.appendFileSync(e.GITHUB_OUTPUT, `artifact_sha256=${artifact_sha256}\n`); + console.log('C1_STAGE ' + JSON.stringify({operation: 'upload', artifact_id, artifact_sha256, stage_sha256: e.COMPLETION_SHA256})); + NODE + + paired_stage_attestation: + name: paired_stage_attestation + needs: paired_stage + if: ${{ success() && github.event_name == 'workflow_dispatch' && inputs.producer_mode == 'paired-stage' && inputs.publish == false && needs.paired_stage.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: npm-agentplugins + permissions: + contents: read + actions: read + id-token: write + attestations: write + env: + PRODUCER_MODE: ${{ inputs.producer_mode }} + TAG: ${{ inputs.tag }} + SOURCE_SHA: ${{ inputs.source_sha }} + KIT_VERSION: ${{ inputs.plugin_kit_version }} + PUBLISH: ${{ inputs.publish }} + NATIVE_INPUTS: ${{ inputs.native_inputs }} + INPUT_ARTIFACT: ${{ inputs.input_artifact }} + GH_TOKEN: ${{ github.token }} + STAGE_ARTIFACT_ID: ${{ needs.paired_stage.outputs.stage_artifact_id }} + STAGE_ARTIFACT_SHA256: ${{ needs.paired_stage.outputs.stage_artifact_sha256 }} + STAGE_SHA256: ${{ needs.paired_stage.outputs.stage_sha256 }} + outputs: + stage_sha256: ${{ steps.recheck.outputs.stage_sha256 }} + steps: + - name: C1 preflight + shell: bash + run: | + set -euo pipefail + export PATH="/usr/local/bin:${PATH}" + [[ "${GITHUB_EVENT_NAME}" == workflow_dispatch ]] + case "${PRODUCER_MODE}" in + legacy) [[ -z "${SOURCE_SHA}${KIT_VERSION}${NATIVE_INPUTS}${INPUT_ARTIFACT}" ]]; exit 0 ;; + paired-stage) [[ "${PUBLISH}" == false ]] ;; + *) exit 1 ;; + esac + [[ "${GITHUB_ACTIONS}" == true && "${GITHUB_REPOSITORY}" == 777genius/universal-agent-plugins ]] + [[ "${TAG}" =~ ^agentplugins-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ && ${#TAG} -le 47 ]] + [[ "${KIT_VERSION}" == 2.0.0 && "${TAG}" != agentplugins-v2.0.0 ]] + [[ "${SOURCE_SHA}" =~ ^[0-9a-f]{40}$ && ! "${SOURCE_SHA}" =~ ^0+$ ]] + [[ "${GITHUB_SHA}" == "${SOURCE_SHA}" && "${GITHUB_WORKFLOW_SHA}" == "${SOURCE_SHA}" ]] + [[ "${GITHUB_REF}" == "refs/tags/${TAG}" ]] + [[ "${GITHUB_WORKFLOW_REF}" == "777genius/universal-agent-plugins/.github/workflows/agentplugins-npm-publish.yml@refs/tags/${TAG}" ]] + positive() { [[ "$1" =~ ^[1-9][0-9]{0,15}$ && "$1" -le 9007199254740991 ]]; } + positive "${GITHUB_RUN_ID}" + positive "${GITHUB_RUN_ATTEMPT}"; [[ "${GITHUB_RUN_ATTEMPT}" -le 1000 ]] + [[ ${#NATIVE_INPUTS} -gt 0 && ${#NATIVE_INPUTS} -le 32768 && "${NATIVE_INPUTS}" == *$'\n' ]] + locator='^[[:space:]]*\{[[:space:]]*"run_id":[[:space:]]*([1-9][0-9]{0,15}),[[:space:]]*"run_attempt":[[:space:]]*([1-9][0-9]{0,3}),[[:space:]]*"artifact_id":[[:space:]]*([1-9][0-9]{0,15}),[[:space:]]*"artifact_sha256":[[:space:]]*"([0-9a-f]{64})"[[:space:]]*\}[[:space:]]*$' + [[ "${INPUT_ARTIFACT}" =~ $locator ]] + run_id="${BASH_REMATCH[1]}"; artifact_id="${BASH_REMATCH[3]}" + positive "${run_id}"; positive "${artifact_id}" + [[ "${INPUT_ARTIFACT}" =~ $locator ]] + [[ "${BASH_REMATCH[2]}" -le 1000 && ! "${BASH_REMATCH[4]}" =~ ^0+$ ]] + [[ "${GITHUB_JOB}" == paired_stage_attestation ]] + positive "${STAGE_ARTIFACT_ID}" + [[ "${STAGE_ARTIFACT_SHA256}" =~ ^[0-9a-f]{64}$ && ! "${STAGE_ARTIFACT_SHA256}" =~ ^0+$ ]] + [[ "${STAGE_SHA256}" =~ ^[0-9a-f]{64}$ && ! "${STAGE_SHA256}" =~ ^0+$ ]] + - name: C1 checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + - name: C1 setup + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "22.23.2" + package-manager-cache: false + - name: C1 stage + id: stage + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict'); + const c = require('./npm/agentplugins/scripts/dual-authoring-candidate'); + const i = require('./npm/agentplugins/scripts/authoring-native-inputs'); + const s = require('./npm/agentplugins/scripts/stage-authoring-npm'); + const e = process.env, selected = {tag: e.TAG, ref: `refs/tags/${e.TAG}`, source: e.SOURCE_SHA, + versions: {agentplugins: e.TAG.slice('agentplugins-v'.length), 'plugin-kit-ai': e.KIT_VERSION}}; + const root = fs.mkdtempSync(path.join(e.RUNNER_TEMP, 'c1-workflow-')); + const scratch = path.join(root, 'scratch'); fs.mkdirSync(scratch, {mode: 0o700}); + const options = {selected, workflow_sha: e.SOURCE_SHA}; + const input_file = path.join(root, 'native-inputs.json'); + const input = Buffer.from(e.NATIVE_INPUTS, 'utf8'); assert.ok(input.length <= 32768); i.decodeInputs(input); + fs.writeFileSync(input_file, input, {flag: 'wx', mode: 0o400}); + const npm = fs.realpathSync(path.join(path.dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')); + assert.equal(path.basename(npm), 'npm-cli.js'); c.readFile(npm); + Object.assign(options, {input_file, repo: process.cwd(), workParent: scratch, node: process.execPath, npm}); + Object.assign(options, {artifact: {run_id: Number(e.GITHUB_RUN_ID), run_attempt: Number(e.GITHUB_RUN_ATTEMPT), + artifact_id: Number(e.STAGE_ARTIFACT_ID), artifact_sha256: e.STAGE_ARTIFACT_SHA256}, stage_sha256: e.STAGE_SHA256}); + const file = path.join(root, 'options.json'); fs.writeFileSync(file, c.encode(options), {flag: 'wx', mode: 0o400}); + const result = s.main(['--validate-unsigned-stage', file]); + c.keys(result, ['root', 'record', 'subjects'], 'stage CLI result'); + const names = ['completion.json', ...c.PRODUCTS.map(p => result.record.packs[p].file)]; + assert.equal(result.subjects.length, names.length); + assert.deepEqual(result.subjects.map(r => path.relative(result.root, r.file)).sort(), [...names].sort()); + for (const row of result.subjects) assert.equal(c.digest(c.readFile(row.file, 128 * 1024 * 1024)), row.sha256); + const payload = [...names]; + assert.equal(payload.length, 3); + const pins = (r) => payload.map(n => ({file: n, sha256: c.digest(c.readFile(path.join(r, n), 128 * 1024 * 1024))})); + const actual = pins(result.root); + const result_file = path.join(root, 'result.json'); + fs.writeFileSync(result_file, c.encode({root: result.root, subjects: result.subjects, pins: actual}), {flag: 'wx', mode: 0o400}); + const output = (key, value) => fs.appendFileSync(e.GITHUB_OUTPUT, `${key}< r.file).join('\n')); + output('payload', payload.map(n => path.join(result.root, n)).join('\n')); + output('stage_sha256', c.digest(c.readFile(path.join(result.root, 'completion.json')))); + NODE + - name: C1 attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 + with: + subject-path: ${{ steps.stage.outputs.subjects }} + - name: C1 recheck + id: recheck + env: + PREVIOUS: ${{ steps.stage.outputs.result_file }} + SIGNING_ROOT: ${{ steps.stage.outputs.root }} + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict'); + const c = require('./npm/agentplugins/scripts/dual-authoring-candidate'); + const i = require('./npm/agentplugins/scripts/authoring-native-inputs'); + const s = require('./npm/agentplugins/scripts/stage-authoring-npm'); + const e = process.env, selected = {tag: e.TAG, ref: `refs/tags/${e.TAG}`, source: e.SOURCE_SHA, + versions: {agentplugins: e.TAG.slice('agentplugins-v'.length), 'plugin-kit-ai': e.KIT_VERSION}}; + const root = fs.mkdtempSync(path.join(e.RUNNER_TEMP, 'c1-workflow-')); + const scratch = path.join(root, 'scratch'); fs.mkdirSync(scratch, {mode: 0o700}); + const options = {selected, workflow_sha: e.SOURCE_SHA}; + const input_file = path.join(root, 'native-inputs.json'); + const input = Buffer.from(e.NATIVE_INPUTS, 'utf8'); assert.ok(input.length <= 32768); i.decodeInputs(input); + fs.writeFileSync(input_file, input, {flag: 'wx', mode: 0o400}); + const npm = fs.realpathSync(path.join(path.dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')); + assert.equal(path.basename(npm), 'npm-cli.js'); c.readFile(npm); + Object.assign(options, {input_file, repo: process.cwd(), workParent: scratch, node: process.execPath, npm}); + Object.assign(options, {artifact: {run_id: Number(e.GITHUB_RUN_ID), run_attempt: Number(e.GITHUB_RUN_ATTEMPT), + artifact_id: Number(e.STAGE_ARTIFACT_ID), artifact_sha256: e.STAGE_ARTIFACT_SHA256}, stage_sha256: e.STAGE_SHA256}); + const file = path.join(root, 'options.json'); fs.writeFileSync(file, c.encode(options), {flag: 'wx', mode: 0o400}); + const result = s.main(['--validate-unsigned-stage', file]); + c.keys(result, ['root', 'record', 'subjects'], 'stage CLI result'); + const names = ['completion.json', ...c.PRODUCTS.map(p => result.record.packs[p].file)]; + assert.equal(result.subjects.length, names.length); + assert.deepEqual(result.subjects.map(r => path.relative(result.root, r.file)).sort(), [...names].sort()); + for (const row of result.subjects) assert.equal(c.digest(c.readFile(row.file, 128 * 1024 * 1024)), row.sha256); + const payload = [...names]; + assert.equal(payload.length, 3); + const pins = (r) => payload.map(n => ({file: n, sha256: c.digest(c.readFile(path.join(r, n), 128 * 1024 * 1024))})); + const actual = pins(result.root); + const previous = JSON.parse(c.readFile(e.PREVIOUS, 1024 * 1024)); + c.keys(previous, ['root', 'subjects', 'pins'], 'original signing comparison'); + assert.equal(previous.root, e.SIGNING_ROOT); + assert.deepEqual([...previous.subjects].sort((a,b) => a.file.localeCompare(b.file)), names.map(n => ({file: path.join(e.SIGNING_ROOT, n), sha256: actual.find(row => row.file === n).sha256})).sort((a,b) => a.file.localeCompare(b.file))); + assert.deepEqual(previous.pins, actual); assert.deepEqual(pins(previous.root), actual); + for (const row of previous.subjects) assert.equal(c.digest(c.readFile(row.file, 128 * 1024 * 1024)), row.sha256); + // Original signing root is retained; independently reacquired bytes never replace it. + result.root = previous.root; result.subjects = previous.subjects; + const result_file = path.join(root, 'result.json'); + fs.writeFileSync(result_file, c.encode({root: result.root, subjects: result.subjects, pins: actual}), {flag: 'wx', mode: 0o400}); + const output = (key, value) => fs.appendFileSync(e.GITHUB_OUTPUT, `${key}< r.file).join('\n')); + output('payload', payload.map(n => path.join(result.root, n)).join('\n')); + output('stage_sha256', c.digest(c.readFile(path.join(result.root, 'completion.json')))); + NODE diff --git a/.github/workflows/agentplugins-release.yml b/.github/workflows/agentplugins-release.yml index 19cfba96..6b5c4af6 100644 --- a/.github/workflows/agentplugins-release.yml +++ b/.github/workflows/agentplugins-release.yml @@ -15,6 +15,7 @@ on: - binary-only - paired-preparation - paired-promotion + - paired-input-provenance source_sha: description: Exact source and workflow SHA (required for paired preparation) required: false @@ -22,16 +23,16 @@ on: description: Explicit plugin-kit version (paired first cut requires 2.0.0) required: false preparation_run: - description: Exact completed preparation run ID (promotion only) + description: Exact completed preparation run ID (promotion or input provenance) required: false preparation_attempt: - description: Exact preparation run attempt (promotion only) + description: Exact preparation run attempt (promotion or input provenance) required: false preparation_artifact: - description: Exact preparation artifact ID (promotion only) + description: Exact preparation artifact ID (promotion or input provenance) required: false preparation_digest: - description: Independently selected preparation ZIP SHA256 (promotion only) + description: Independently selected preparation ZIP SHA256 (promotion or input provenance) required: false promotion_operation: description: Promote exact drafts or reconcile interrupted drafts/partial publication @@ -53,6 +54,47 @@ concurrency: cancel-in-progress: false jobs: + dispatch_contract: + runs-on: ubuntu-24.04 + timeout-minutes: 2 + permissions: + contents: read + env: + PRODUCER_MODE: ${{ inputs.producer_mode }} + TAG: ${{ inputs.tag }} + SOURCE_SHA: ${{ inputs.source_sha }} + KIT_VERSION: ${{ inputs.plugin_kit_version }} + PREPARATION_RUN: ${{ inputs.preparation_run }} + PREPARATION_ATTEMPT: ${{ inputs.preparation_attempt }} + PREPARATION_ARTIFACT: ${{ inputs.preparation_artifact }} + PREPARATION_DIGEST: ${{ inputs.preparation_digest }} + PROMOTION_OPERATION: ${{ inputs.promotion_operation }} + PROMOTION_RECORD: ${{ inputs.promotion_record }} + steps: + - name: C1 preflight + shell: bash + run: | + set -euo pipefail + export PATH="/usr/local/bin:${PATH}" + [[ "${GITHUB_EVENT_NAME}" == workflow_dispatch ]] + case "${PRODUCER_MODE}" in + binary-only|paired-preparation|paired-promotion) exit 0 ;; + paired-input-provenance) [[ -z "${PROMOTION_RECORD}" && "${PROMOTION_OPERATION}" == promote ]] ;; + *) exit 1 ;; + esac + [[ "${GITHUB_ACTIONS}" == true && "${GITHUB_REPOSITORY}" == 777genius/universal-agent-plugins ]] + [[ "${TAG}" =~ ^agentplugins-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ && ${#TAG} -le 47 ]] + [[ "${KIT_VERSION}" == 2.0.0 && "${TAG}" != agentplugins-v2.0.0 ]] + [[ "${SOURCE_SHA}" =~ ^[0-9a-f]{40}$ && ! "${SOURCE_SHA}" =~ ^0+$ ]] + [[ "${GITHUB_SHA}" == "${SOURCE_SHA}" && "${GITHUB_WORKFLOW_SHA}" == "${SOURCE_SHA}" ]] + [[ "${GITHUB_REF}" == "refs/tags/${TAG}" ]] + [[ "${GITHUB_WORKFLOW_REF}" == "777genius/universal-agent-plugins/.github/workflows/agentplugins-release.yml@refs/tags/${TAG}" ]] + positive() { [[ "$1" =~ ^[1-9][0-9]{0,15}$ && "$1" -le 9007199254740991 ]]; } + positive "${GITHUB_RUN_ID}" + positive "${GITHUB_RUN_ATTEMPT}"; [[ "${GITHUB_RUN_ATTEMPT}" -le 1000 ]] + positive "${PREPARATION_RUN}"; positive "${PREPARATION_ARTIFACT}" + positive "${PREPARATION_ATTEMPT}"; [[ "${PREPARATION_ATTEMPT}" -le 1000 ]] + [[ "${PREPARATION_DIGEST}" =~ ^[0-9a-f]{64}$ && ! "${PREPARATION_DIGEST}" =~ ^0+$ ]] validate: if: ${{ inputs.producer_mode == 'binary-only' }} runs-on: ubuntu-latest @@ -685,3 +727,281 @@ jobs: run: | case "${PROMOTION_OPERATION}" in promote|reconcile) ;; *) exit 1 ;; esac node npm/agentplugins/scripts/authoring-promotion.js "${PROMOTION_OPERATION}" "${PROMOTION_ROOT}/options.json" + + paired_input_admission: + name: paired_input_admission + needs: dispatch_contract + if: ${{ success() && github.event_name == 'workflow_dispatch' && inputs.producer_mode == 'paired-input-provenance' && needs.dispatch_contract.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + actions: read + env: + PRODUCER_MODE: ${{ inputs.producer_mode }} + TAG: ${{ inputs.tag }} + SOURCE_SHA: ${{ inputs.source_sha }} + KIT_VERSION: ${{ inputs.plugin_kit_version }} + PREPARATION_RUN: ${{ inputs.preparation_run }} + PREPARATION_ATTEMPT: ${{ inputs.preparation_attempt }} + PREPARATION_ARTIFACT: ${{ inputs.preparation_artifact }} + PREPARATION_DIGEST: ${{ inputs.preparation_digest }} + PROMOTION_OPERATION: ${{ inputs.promotion_operation }} + PROMOTION_RECORD: ${{ inputs.promotion_record }} + GH_TOKEN: ${{ github.token }} + outputs: + input_sha256: ${{ steps.stage.outputs.input_sha256 }} + steps: + - name: C1 preflight + shell: bash + run: | + set -euo pipefail + export PATH="/usr/local/bin:${PATH}" + [[ "${GITHUB_EVENT_NAME}" == workflow_dispatch ]] + case "${PRODUCER_MODE}" in + binary-only|paired-preparation|paired-promotion) exit 0 ;; + paired-input-provenance) [[ -z "${PROMOTION_RECORD}" && "${PROMOTION_OPERATION}" == promote ]] ;; + *) exit 1 ;; + esac + [[ "${GITHUB_ACTIONS}" == true && "${GITHUB_REPOSITORY}" == 777genius/universal-agent-plugins ]] + [[ "${TAG}" =~ ^agentplugins-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ && ${#TAG} -le 47 ]] + [[ "${KIT_VERSION}" == 2.0.0 && "${TAG}" != agentplugins-v2.0.0 ]] + [[ "${SOURCE_SHA}" =~ ^[0-9a-f]{40}$ && ! "${SOURCE_SHA}" =~ ^0+$ ]] + [[ "${GITHUB_SHA}" == "${SOURCE_SHA}" && "${GITHUB_WORKFLOW_SHA}" == "${SOURCE_SHA}" ]] + [[ "${GITHUB_REF}" == "refs/tags/${TAG}" ]] + [[ "${GITHUB_WORKFLOW_REF}" == "777genius/universal-agent-plugins/.github/workflows/agentplugins-release.yml@refs/tags/${TAG}" ]] + positive() { [[ "$1" =~ ^[1-9][0-9]{0,15}$ && "$1" -le 9007199254740991 ]]; } + positive "${GITHUB_RUN_ID}" + positive "${GITHUB_RUN_ATTEMPT}"; [[ "${GITHUB_RUN_ATTEMPT}" -le 1000 ]] + positive "${PREPARATION_RUN}"; positive "${PREPARATION_ARTIFACT}" + positive "${PREPARATION_ATTEMPT}"; [[ "${PREPARATION_ATTEMPT}" -le 1000 ]] + [[ "${PREPARATION_DIGEST}" =~ ^[0-9a-f]{64}$ && ! "${PREPARATION_DIGEST}" =~ ^0+$ ]] + [[ "${GITHUB_JOB}" == paired_input_admission ]] + - name: C1 checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + - name: C1 setup + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "22.23.2" + package-manager-cache: false + - name: C1 stage + id: stage + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict'); + const c = require('./npm/agentplugins/scripts/dual-authoring-candidate'); + const i = require('./npm/agentplugins/scripts/authoring-native-inputs'); + const s = require('./npm/agentplugins/scripts/stage-authoring-npm'); + const e = process.env, selected = {tag: e.TAG, ref: `refs/tags/${e.TAG}`, source: e.SOURCE_SHA, + versions: {agentplugins: e.TAG.slice('agentplugins-v'.length), 'plugin-kit-ai': e.KIT_VERSION}}; + const root = fs.mkdtempSync(path.join(e.RUNNER_TEMP, 'c1-workflow-')); + const scratch = path.join(root, 'scratch'); fs.mkdirSync(scratch, {mode: 0o700}); + const options = {selected, workflow_sha: e.SOURCE_SHA}; + Object.assign(options, {preparation: {run_id: Number(e.PREPARATION_RUN), run_attempt: Number(e.PREPARATION_ATTEMPT), + artifact_id: Number(e.PREPARATION_ARTIFACT), artifact_sha256: e.PREPARATION_DIGEST}, repo: process.cwd(), scratch}); + const file = path.join(root, 'options.json'); fs.writeFileSync(file, c.encode(options), {flag: 'wx', mode: 0o400}); + const result = i.main(['--produce-inputs', file]); + c.keys(result, ['root', 'input', 'subjects'], 'input CLI result'); + const names = ['candidate/candidate.json', 'pair-prepared.json', ...c.PRODUCTS.flatMap(p => [...c.TARGETS.map(t => `${p}/${result.input.products[p].assets[t].file}`), `${p}/release-manifest.json`, `${p}/checksums.txt`]), 'native-inputs.json']; + assert.equal(result.subjects.length, names.length); + assert.deepEqual(result.subjects.map(r => path.relative(result.root, r.file)).sort(), [...names].sort()); + for (const row of result.subjects) assert.equal(c.digest(c.readFile(row.file, 128 * 1024 * 1024)), row.sha256); + const payload = [...names, 'preparation-run.json', 'candidate-identity.json']; + assert.equal(payload.length, 21); + const pins = (r) => payload.map(n => ({file: n, sha256: c.digest(c.readFile(path.join(r, n), 128 * 1024 * 1024))})); + const actual = pins(result.root); + const result_file = path.join(root, 'result.json'); + fs.writeFileSync(result_file, c.encode({root: result.root, subjects: result.subjects, pins: actual}), {flag: 'wx', mode: 0o400}); + const output = (key, value) => fs.appendFileSync(e.GITHUB_OUTPUT, `${key}< r.file).join('\n')); + output('payload', payload.map(n => path.join(result.root, n)).join('\n')); + output('input_sha256', c.digest(i.encodeInputs(result.input))); + NODE + + paired_input_attestation: + name: paired_input_attestation + needs: paired_input_admission + if: ${{ success() && github.event_name == 'workflow_dispatch' && inputs.producer_mode == 'paired-input-provenance' && needs.paired_input_admission.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: agentplugins-release + permissions: + contents: read + actions: read + id-token: write + attestations: write + env: + PRODUCER_MODE: ${{ inputs.producer_mode }} + TAG: ${{ inputs.tag }} + SOURCE_SHA: ${{ inputs.source_sha }} + KIT_VERSION: ${{ inputs.plugin_kit_version }} + PREPARATION_RUN: ${{ inputs.preparation_run }} + PREPARATION_ATTEMPT: ${{ inputs.preparation_attempt }} + PREPARATION_ARTIFACT: ${{ inputs.preparation_artifact }} + PREPARATION_DIGEST: ${{ inputs.preparation_digest }} + PROMOTION_OPERATION: ${{ inputs.promotion_operation }} + PROMOTION_RECORD: ${{ inputs.promotion_record }} + GH_TOKEN: ${{ github.token }} + outputs: + input_sha256: ${{ steps.recheck.outputs.input_sha256 }} + input_artifact_id: ${{ steps.upload.outputs.artifact-id }} + input_artifact_sha256: ${{ steps.upload_evidence.outputs.artifact_sha256 }} + steps: + - name: C1 preflight + shell: bash + run: | + set -euo pipefail + export PATH="/usr/local/bin:${PATH}" + [[ "${GITHUB_EVENT_NAME}" == workflow_dispatch ]] + case "${PRODUCER_MODE}" in + binary-only|paired-preparation|paired-promotion) exit 0 ;; + paired-input-provenance) [[ -z "${PROMOTION_RECORD}" && "${PROMOTION_OPERATION}" == promote ]] ;; + *) exit 1 ;; + esac + [[ "${GITHUB_ACTIONS}" == true && "${GITHUB_REPOSITORY}" == 777genius/universal-agent-plugins ]] + [[ "${TAG}" =~ ^agentplugins-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ && ${#TAG} -le 47 ]] + [[ "${KIT_VERSION}" == 2.0.0 && "${TAG}" != agentplugins-v2.0.0 ]] + [[ "${SOURCE_SHA}" =~ ^[0-9a-f]{40}$ && ! "${SOURCE_SHA}" =~ ^0+$ ]] + [[ "${GITHUB_SHA}" == "${SOURCE_SHA}" && "${GITHUB_WORKFLOW_SHA}" == "${SOURCE_SHA}" ]] + [[ "${GITHUB_REF}" == "refs/tags/${TAG}" ]] + [[ "${GITHUB_WORKFLOW_REF}" == "777genius/universal-agent-plugins/.github/workflows/agentplugins-release.yml@refs/tags/${TAG}" ]] + positive() { [[ "$1" =~ ^[1-9][0-9]{0,15}$ && "$1" -le 9007199254740991 ]]; } + positive "${GITHUB_RUN_ID}" + positive "${GITHUB_RUN_ATTEMPT}"; [[ "${GITHUB_RUN_ATTEMPT}" -le 1000 ]] + positive "${PREPARATION_RUN}"; positive "${PREPARATION_ARTIFACT}" + positive "${PREPARATION_ATTEMPT}"; [[ "${PREPARATION_ATTEMPT}" -le 1000 ]] + [[ "${PREPARATION_DIGEST}" =~ ^[0-9a-f]{64}$ && ! "${PREPARATION_DIGEST}" =~ ^0+$ ]] + [[ "${GITHUB_JOB}" == paired_input_attestation ]] + - name: C1 checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + - name: C1 setup + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "22.23.2" + package-manager-cache: false + - name: C1 stage + id: stage + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict'); + const c = require('./npm/agentplugins/scripts/dual-authoring-candidate'); + const i = require('./npm/agentplugins/scripts/authoring-native-inputs'); + const s = require('./npm/agentplugins/scripts/stage-authoring-npm'); + const e = process.env, selected = {tag: e.TAG, ref: `refs/tags/${e.TAG}`, source: e.SOURCE_SHA, + versions: {agentplugins: e.TAG.slice('agentplugins-v'.length), 'plugin-kit-ai': e.KIT_VERSION}}; + const root = fs.mkdtempSync(path.join(e.RUNNER_TEMP, 'c1-workflow-')); + const scratch = path.join(root, 'scratch'); fs.mkdirSync(scratch, {mode: 0o700}); + const options = {selected, workflow_sha: e.SOURCE_SHA}; + Object.assign(options, {preparation: {run_id: Number(e.PREPARATION_RUN), run_attempt: Number(e.PREPARATION_ATTEMPT), + artifact_id: Number(e.PREPARATION_ARTIFACT), artifact_sha256: e.PREPARATION_DIGEST}, repo: process.cwd(), scratch}); + const file = path.join(root, 'options.json'); fs.writeFileSync(file, c.encode(options), {flag: 'wx', mode: 0o400}); + const result = i.main(['--produce-inputs', file]); + c.keys(result, ['root', 'input', 'subjects'], 'input CLI result'); + const names = ['candidate/candidate.json', 'pair-prepared.json', ...c.PRODUCTS.flatMap(p => [...c.TARGETS.map(t => `${p}/${result.input.products[p].assets[t].file}`), `${p}/release-manifest.json`, `${p}/checksums.txt`]), 'native-inputs.json']; + assert.equal(result.subjects.length, names.length); + assert.deepEqual(result.subjects.map(r => path.relative(result.root, r.file)).sort(), [...names].sort()); + for (const row of result.subjects) assert.equal(c.digest(c.readFile(row.file, 128 * 1024 * 1024)), row.sha256); + const payload = [...names, 'preparation-run.json', 'candidate-identity.json']; + assert.equal(payload.length, 21); + const pins = (r) => payload.map(n => ({file: n, sha256: c.digest(c.readFile(path.join(r, n), 128 * 1024 * 1024))})); + const actual = pins(result.root); + const result_file = path.join(root, 'result.json'); + fs.writeFileSync(result_file, c.encode({root: result.root, subjects: result.subjects, pins: actual}), {flag: 'wx', mode: 0o400}); + const output = (key, value) => fs.appendFileSync(e.GITHUB_OUTPUT, `${key}< r.file).join('\n')); + output('payload', payload.map(n => path.join(result.root, n)).join('\n')); + output('input_sha256', c.digest(i.encodeInputs(result.input))); + NODE + - name: C1 attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 + with: + subject-path: ${{ steps.stage.outputs.subjects }} + - name: C1 recheck + id: recheck + env: + PREVIOUS: ${{ steps.stage.outputs.result_file }} + SIGNING_ROOT: ${{ steps.stage.outputs.root }} + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict'); + const c = require('./npm/agentplugins/scripts/dual-authoring-candidate'); + const i = require('./npm/agentplugins/scripts/authoring-native-inputs'); + const s = require('./npm/agentplugins/scripts/stage-authoring-npm'); + const e = process.env, selected = {tag: e.TAG, ref: `refs/tags/${e.TAG}`, source: e.SOURCE_SHA, + versions: {agentplugins: e.TAG.slice('agentplugins-v'.length), 'plugin-kit-ai': e.KIT_VERSION}}; + const root = fs.mkdtempSync(path.join(e.RUNNER_TEMP, 'c1-workflow-')); + const scratch = path.join(root, 'scratch'); fs.mkdirSync(scratch, {mode: 0o700}); + const options = {selected, workflow_sha: e.SOURCE_SHA}; + Object.assign(options, {preparation: {run_id: Number(e.PREPARATION_RUN), run_attempt: Number(e.PREPARATION_ATTEMPT), + artifact_id: Number(e.PREPARATION_ARTIFACT), artifact_sha256: e.PREPARATION_DIGEST}, repo: process.cwd(), scratch}); + const file = path.join(root, 'options.json'); fs.writeFileSync(file, c.encode(options), {flag: 'wx', mode: 0o400}); + const result = i.main(['--produce-inputs', file]); + c.keys(result, ['root', 'input', 'subjects'], 'input CLI result'); + const names = ['candidate/candidate.json', 'pair-prepared.json', ...c.PRODUCTS.flatMap(p => [...c.TARGETS.map(t => `${p}/${result.input.products[p].assets[t].file}`), `${p}/release-manifest.json`, `${p}/checksums.txt`]), 'native-inputs.json']; + assert.equal(result.subjects.length, names.length); + assert.deepEqual(result.subjects.map(r => path.relative(result.root, r.file)).sort(), [...names].sort()); + for (const row of result.subjects) assert.equal(c.digest(c.readFile(row.file, 128 * 1024 * 1024)), row.sha256); + const payload = [...names, 'preparation-run.json', 'candidate-identity.json']; + assert.equal(payload.length, 21); + const pins = (r) => payload.map(n => ({file: n, sha256: c.digest(c.readFile(path.join(r, n), 128 * 1024 * 1024))})); + const actual = pins(result.root); + const previous = JSON.parse(c.readFile(e.PREVIOUS, 1024 * 1024)); + c.keys(previous, ['root', 'subjects', 'pins'], 'original signing comparison'); + assert.equal(previous.root, e.SIGNING_ROOT); + assert.deepEqual([...previous.subjects].sort((a,b) => a.file.localeCompare(b.file)), names.map(n => ({file: path.join(e.SIGNING_ROOT, n), sha256: actual.find(row => row.file === n).sha256})).sort((a,b) => a.file.localeCompare(b.file))); + assert.deepEqual(previous.pins, actual); assert.deepEqual(pins(previous.root), actual); + for (const row of previous.subjects) assert.equal(c.digest(c.readFile(row.file, 128 * 1024 * 1024)), row.sha256); + // Original signing root is retained; independently reacquired bytes never replace it. + result.root = previous.root; result.subjects = previous.subjects; + const result_file = path.join(root, 'result.json'); + fs.writeFileSync(result_file, c.encode({root: result.root, subjects: result.subjects, pins: actual}), {flag: 'wx', mode: 0o400}); + const output = (key, value) => fs.appendFileSync(e.GITHUB_OUTPUT, `${key}< r.file).join('\n')); + output('payload', payload.map(n => path.join(result.root, n)).join('\n')); + output('input_sha256', c.digest(i.encodeInputs(result.input))); + NODE + - name: C1 upload + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: authoring-input-provenance-${{ inputs.source_sha }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.recheck.outputs.payload }} + if-no-files-found: error + retention-days: 7 + - name: C1 upload evidence + id: upload_evidence + env: + ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }} + ARTIFACT_SHA256: ${{ steps.upload.outputs.artifact-digest }} + COMPLETION_SHA256: ${{ steps.recheck.outputs.input_sha256 }} + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const fs = require('node:fs'), assert = require('node:assert/strict'), e = process.env; + assert.match(e.ARTIFACT_ID, /^[1-9][0-9]{0,15}$/); + const artifact_id = Number(e.ARTIFACT_ID); assert.ok(Number.isSafeInteger(artifact_id)); + const artifact_sha256 = e.ARTIFACT_SHA256.replace(/^sha256:/, '').toLowerCase(); + assert.match(artifact_sha256, /^[0-9a-f]{64}$/); assert.ok(!/^0+$/.test(artifact_sha256)); + fs.appendFileSync(e.GITHUB_OUTPUT, `artifact_sha256=${artifact_sha256}\n`); + NODE diff --git a/cli/plugin-kit-ai/cmd/agentplugins/release_workflow_test.go b/cli/plugin-kit-ai/cmd/agentplugins/release_workflow_test.go index 928d7c03..d5865854 100644 --- a/cli/plugin-kit-ai/cmd/agentplugins/release_workflow_test.go +++ b/cli/plugin-kit-ai/cmd/agentplugins/release_workflow_test.go @@ -1,12 +1,14 @@ package main import ( + "fmt" "go/ast" "go/parser" "go/token" "os" "os/exec" "path/filepath" + "reflect" "regexp" "runtime" "strconv" @@ -83,25 +85,34 @@ type producerWorkflow struct { } `yaml:"workflow_run"` Dispatch struct { Inputs map[string]struct { - Default string `yaml:"default"` - Options []string `yaml:"options"` + Default string `yaml:"default"` + Type string `yaml:"type"` + Required bool `yaml:"required"` + Options []string `yaml:"options"` } `yaml:"inputs"` } `yaml:"workflow_dispatch"` } `yaml:"on"` Permissions map[string]string `yaml:"permissions"` Jobs map[string]struct { If string `yaml:"if"` + Name string `yaml:"name"` + Runner string `yaml:"runs-on"` + Timeout int `yaml:"timeout-minutes"` + Outputs map[string]string `yaml:"outputs"` Environment any `yaml:"environment"` Needs any `yaml:"needs"` Uses string `yaml:"uses"` Permissions map[string]string `yaml:"permissions"` Env map[string]string `yaml:"env"` Steps []struct { - Name string `yaml:"name"` - Run string `yaml:"run"` - Uses string `yaml:"uses"` - With map[string]any `yaml:"with"` - Env map[string]string `yaml:"env"` + Name string `yaml:"name"` + ID string `yaml:"id"` + If string `yaml:"if"` + Continue bool `yaml:"continue-on-error"` + Run string `yaml:"run"` + Uses string `yaml:"uses"` + With map[string]any `yaml:"with"` + Env map[string]string `yaml:"env"` } `yaml:"steps"` } `yaml:"jobs"` } @@ -174,16 +185,19 @@ func TestReleaseWorkflowRunnerCacheContext(t *testing.T) { func TestReleasePairedPreparationReadOnlyGraph(t *testing.T) { w := readProducerWorkflow(t, "agentplugins-release.yml") mode := w.On.Dispatch.Inputs["producer_mode"] - if mode.Default != "binary-only" || strings.Join(mode.Options, ",") != "binary-only,paired-preparation,paired-promotion" { + if mode.Default != "binary-only" || strings.Join(mode.Options, ",") != "binary-only,paired-preparation,paired-promotion,paired-input-provenance" { t.Fatal("default binary-only dispatch contract changed") } if len(w.Permissions) != 1 || w.Permissions["contents"] != "read" { t.Fatal("workflow must default to contents-read") } - if len(w.Jobs) != 8 { + if len(w.Jobs) != 11 { t.Fatal("review every new producer job for preparation reachability") } for name, job := range w.Jobs { + if name == "dispatch_contract" || strings.HasPrefix(name, "paired_input_") { + continue + } if name == "paired-promotion-admission" || name == "paired-sign-and-promote" { if job.If != "${{ github.event_name == 'workflow_dispatch' && inputs.producer_mode == 'paired-promotion' }}" { t.Fatalf("%s loses explicit promotion isolation", name) @@ -321,17 +335,35 @@ func TestReleasePairedRouteCannotTriggerDownstreamPublication(t *testing.T) { // Parse the restricted boolean expression grammar, rejecting unknown syntax. // Testing a failed event must evaluate the whole OR/AND graph, not find a token. func downstreamCondition(t *testing.T, expression, event, conclusion string) bool { + t.Helper() + return workflowCondition(t, expression, map[string]any{"github.event_name": event, "github.event.workflow_run.conclusion": conclusion, + "vars.NPM_PUBLISH_READY": "true", "vars.PYPI_TRUSTED_PUBLISHING_READY": "true", "inputs.producer_mode": "paired-promotion"}) +} +func workflowCondition(t *testing.T, expression string, values map[string]any) bool { t.Helper() expression = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(expression), "${{"), "}}")) - values := map[string]string{"github.event_name": event, "github.event.workflow_run.conclusion": conclusion, - "vars.NPM_PUBLISH_READY": "true", "vars.PYPI_TRUSTED_PUBLISHING_READY": "true", "inputs.producer_mode": "paired-promotion"} + if expression == "" { + expression = "success()" + } + for _, fn := range []string{"success", "always", "failure", "cancelled"} { + if value, ok := values[fn]; ok { + expression = strings.ReplaceAll(expression, fn+"()", strconv.FormatBool(value.(bool))) + } + } words := regexp.MustCompile(`'[^']*'|[a-zA-Z_][a-zA-Z0-9_.]*`) expression = words.ReplaceAllStringFunc(expression, func(s string) string { if strings.HasPrefix(s, "'") { return strconv.Quote(s[1 : len(s)-1]) } if value, ok := values[s]; ok { - return strconv.Quote(value) + switch v := value.(type) { + case string: + return strconv.Quote(v) + case bool: + return strconv.FormatBool(v) + default: + t.Fatal("unsupported typed context") + } } if s == "true" || s == "false" { return s @@ -662,3 +694,379 @@ func TestN2ExcludedNativeExecutionRemainsFailure(t *testing.T) { } } } + +// Fixed C1 graph expectations; fixtures prove source reachability, not hosted +// permission enforcement, cryptographic acceptance or genuine provider custody. +func c1Needs(w producerWorkflow, name string) []string { + switch n := w.Jobs[name].Needs.(type) { + case nil: + return nil + case string: + return []string{n} + case []any: + result := []string{} + for _, v := range n { + result = append(result, v.(string)) + } + return result + default: + panic("unknown needs shape") + } +} +func c1Permissions(w producerWorkflow, name string) map[string]string { + if w.Jobs[name].Permissions != nil { + return w.Jobs[name].Permissions + } + return w.Permissions +} +func c1Scripts(w producerWorkflow, name string) string { + var s strings.Builder + for _, step := range w.Jobs[name].Steps { + s.WriteString(step.Run) + } + return s.String() +} +func c1Contract(w producerWorkflow, name string, stage, signer bool) error { + job, ok := w.Jobs[name] + if !ok { + return fmt.Errorf("missing %s", name) + } + need, mode, env, timeout := "dispatch_contract", "paired-input-provenance", "agentplugins-release", 20 + if stage { + mode, env = "paired-stage", "npm-agentplugins" + timeout = 30 + } + if signer { + timeout = 20 + if stage { + need = "paired_stage" + } else { + need = "paired_input_admission" + } + } + condition := "${{ success() && github.event_name == 'workflow_dispatch' && inputs.producer_mode == '" + mode + "'" + if stage { + condition += " && inputs.publish == false" + } + condition += " && needs." + need + ".result == 'success' }}" + if job.If != condition || !reflect.DeepEqual(c1Needs(w, name), []string{need}) { + return fmt.Errorf("%s mode/status/needs", name) + } + permissions := map[string]string{"contents": "read", "actions": "read"} + if signer { + permissions["id-token"] = "write" + permissions["attestations"] = "write" + } else if stage { + permissions["attestations"] = "read" + } + if !reflect.DeepEqual(c1Permissions(w, name), permissions) { + return fmt.Errorf("%s effective permissions", name) + } + if (signer && job.Environment != env) || (!signer && job.Environment != nil) || job.Runner != "ubuntu-24.04" || job.Timeout != timeout { + return fmt.Errorf("%s execution boundary", name) + } + if len(job.Steps) < 3 || job.Steps[0].Name != "C1 preflight" || job.Steps[0].Uses != "" { + return fmt.Errorf("%s preflight ordering", name) + } + attest, uploads, admission, recheck := -1, 0, -1, -1 + for index, step := range job.Steps { + if step.Continue || (step.If != "" && step.If != "${{ success() }}") { + return fmt.Errorf("%s step bypass", name) + } + if step.ID == "stage" { + admission = index + } + if step.ID == "recheck" { + recheck = index + } + if strings.HasPrefix(step.Uses, "actions/attest@") { + attest = index + if step.Uses != "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6" || step.With["subject-path"] != "${{ steps.stage.outputs.subjects }}" { + return fmt.Errorf("exact subject signing") + } + } + if strings.HasPrefix(step.Uses, "actions/upload-artifact@") { + uploads++ + if step.Uses != "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" || step.ID != "upload" || step.With["if-no-files-found"] != "error" || step.With["retention-days"] != 7 || step.With["overwrite"] != nil { + return fmt.Errorf("immutable upload") + } + id := "stage" + if signer { + id = "recheck" + } + if step.With["path"] != "${{ steps."+id+".outputs.payload }}" { + return fmt.Errorf("fixed upload payload") + } + } + } + if signer && (attest <= admission || admission < 0 || recheck <= attest) { + return fmt.Errorf("independent admission/sign/recheck order") + } + if !signer && attest != -1 { + return fmt.Errorf("unexpected signing") + } + expectedUploads := 0 + if stage != signer { + expectedUploads = 1 + } + if uploads != expectedUploads { + return fmt.Errorf("upload lifecycle") + } + body := c1Scripts(w, name) + for _, forbidden := range []string{"npm publish", "npm install", "--read-stage", "allow_incomplete", "needs.paired_input_admission.outputs", "needs.paired_stage.outputs.accepted"} { + if strings.Contains(body, forbidden) { + return fmt.Errorf("forbidden C1 effect or trust substitution: %s", forbidden) + } + } + if signer && (!strings.Contains(body, "assert.equal(previous.root, e.SIGNING_ROOT)") || !strings.Contains(body, "pins(previous.root)") || !strings.Contains(body, "result.root = previous.root") || !strings.Contains(body, "fs.mkdtempSync")) { + return fmt.Errorf("original signing root recheck") + } + return nil +} +func TestC1InputProvenanceWorkflowContract(t *testing.T) { + w := readProducerWorkflow(t, "agentplugins-release.yml") + if len(w.Jobs) != 11 || len(w.On.Dispatch.Inputs) != 10 || w.On.Dispatch.Inputs["producer_mode"].Type != "choice" || !w.On.Dispatch.Inputs["producer_mode"].Required { + t.Fatal("closed release inputs/jobs") + } + for _, name := range []string{"paired_input_admission", "paired_input_attestation"} { + if err := c1Contract(w, name, false, name == "paired_input_attestation"); err != nil { + t.Fatal(err) + } + body := c1Scripts(w, name) + for _, text := range []string{"--produce-inputs", "preparation:", "workflow_sha: e.SOURCE_SHA", "assert.equal(payload.length, 21)", "'native-inputs.json'", "'preparation-run.json', 'candidate-identity.json'"} { + if !strings.Contains(body, text) { + t.Fatalf("%s missing %s", name, text) + } + } + } + if len(w.Jobs["paired_input_admission"].Outputs) != 1 || len(w.Jobs["paired_input_attestation"].Outputs) != 3 { + t.Fatal("selector-only outputs") + } +} +func TestC1PublicStageWorkflowContract(t *testing.T) { + w := readProducerWorkflow(t, "agentplugins-npm-publish.yml") + if len(w.Jobs) != 6 || len(w.On.Dispatch.Inputs) != 7 || strings.Join(w.On.Dispatch.Inputs["producer_mode"].Options, ",") != "legacy,paired-stage" || w.On.Dispatch.Inputs["producer_mode"].Default != "legacy" || w.On.Dispatch.Inputs["publish"].Type != "boolean" { + t.Fatal("closed stage inputs/jobs") + } + for _, name := range []string{"paired_stage", "paired_stage_attestation"} { + if err := c1Contract(w, name, true, name == "paired_stage_attestation"); err != nil { + t.Fatal(err) + } + body := c1Scripts(w, name) + if !strings.Contains(body, "assert.equal(payload.length, 3)") || !strings.Contains(body, "input_file") || !strings.Contains(body, "Buffer.from(e.NATIVE_INPUTS, 'utf8')") { + t.Fatal("three retained files and Buffer transport") + } + } + if strings.Count(c1Scripts(w, "paired_stage"), "--stage-prepublication") != 1 || strings.Count(c1Scripts(w, "paired_stage_attestation"), "--validate-unsigned-stage") != 2 { + t.Fatal("pack once and independent same-run validation") + } + for name, expected := range map[string][]string{"prepare": {"dispatch_contract"}, "publish": {"prepare"}, "verify-public": {"prepare", "publish"}} { + if !reflect.DeepEqual(c1Needs(w, name), expected) { + t.Fatalf("legacy needs changed %s", name) + } + } + for _, name := range []string{"prepare", "publish", "verify-public"} { + if !strings.Contains(w.Jobs[name].If, "inputs.producer_mode == 'legacy'") || !strings.Contains(w.Jobs[name].If, "github.event_name == 'workflow_dispatch'") || len(c1Needs(w, name)) == 0 { + t.Fatal("legacy isolation", name) + } + } +} +func TestC1WorkflowFailureReachability(t *testing.T) { + for _, file := range []string{"agentplugins-release.yml", "agentplugins-npm-publish.yml"} { + w := readProducerWorkflow(t, file) + modes := []string{"binary-only", "paired-preparation", "paired-promotion", "paired-input-provenance", "legacy", "paired-stage", "unknown"} + legacyPermissions := map[string]map[string]string{ + "dispatch_contract": {"contents": "read"}, "validate": {"checks": "read", "contents": "read", "pull-requests": "read"}, + "build": {"contents": "read"}, "stage-draft": {"contents": "write", "id-token": "write", "attestations": "write", "artifact-metadata": "write"}, + "platform-proof": {"contents": "read", "attestations": "read"}, "promote-release": {"contents": "write", "attestations": "read"}, + "paired-preparation": {"contents": "read"}, "paired-promotion-admission": {"contents": "read", "actions": "read"}, + "paired-sign-and-promote": {"contents": "write", "actions": "read", "id-token": "write", "attestations": "write", "artifact-metadata": "write"}, + "prepare": {"contents": "read", "attestations": "read"}, "publish": {"contents": "read", "id-token": "write"}, + "verify-public": {"contents": "read", "attestations": "read"}, + } + for name, job := range w.Jobs { + if expected, ok := legacyPermissions[name]; ok && !reflect.DeepEqual(c1Permissions(w, name), expected) { + t.Fatalf("effective legacy permissions %s", name) + } + if name == "dispatch_contract" { + continue + } + for _, mode := range modes { + for _, event := range []string{"workflow_dispatch", "workflow_run"} { + for _, status := range []string{"success", "failure", "cancelled", "skipped", ""} { + for _, publish := range []bool{true, false} { + values := map[string]any{"github.event_name": event, "inputs.producer_mode": mode, "inputs.publish": publish, + "success": status == "success", "failure": status == "failure", "cancelled": status == "cancelled", "always": true} + for dependency := range w.Jobs { + values["needs."+dependency+".result"] = status + } + // GitHub's implicit success() applies when no status function is present. + reachable := workflowCondition(t, job.If, values) + if !strings.Contains(job.If, "success()") { + reachable = reachable && status == "success" + } + for _, dep := range c1Needs(w, name) { + reachable = reachable && values["needs."+dep+".result"] == "success" + } + if (status != "success") && reachable { + t.Fatalf("%s reachable after %s", name, status) + } + if strings.HasPrefix(name, "paired_input_") || strings.HasPrefix(name, "paired_stage") { + expected := status == "success" && event == "workflow_dispatch" && ((strings.HasPrefix(name, "paired_input_") && mode == "paired-input-provenance") || (strings.HasPrefix(name, "paired_stage") && mode == "paired-stage" && !publish)) + if reachable != expected { + t.Fatalf("%s reachability %s %s %s %v", name, mode, event, status, publish) + } + for _, step := range job.Steps { + for _, state := range []string{"failure", "cancelled", "skipped", ""} { + stopped := map[string]any{"success": false, "failure": state == "failure", "cancelled": state == "cancelled", "always": true} + if workflowCondition(t, step.If, stopped) { + t.Fatalf("sensitive step survives %s", state) + } + } + + if workflowCondition(t, step.If, values) && reachable && step.Continue { + t.Fatal("sensitive step tolerates failure") + } + } + if err := c1Contract(w, name, strings.HasPrefix(name, "paired_stage"), strings.HasSuffix(name, "attestation")); err != nil { + t.Fatal(err) + } + } + if file == "agentplugins-npm-publish.yml" && (name == "prepare" || name == "publish" || name == "verify-public") { + expected := status == "success" && event == "workflow_dispatch" && mode == "legacy" && (name == "prepare" || publish) + if reachable != expected { + t.Fatalf("legacy reachability %s %s %s %s %v", name, mode, event, status, publish) + } + } + if mode == "paired-stage" && (name == "prepare" || name == "publish" || name == "verify-public") && reachable { + t.Fatal("paired stage reaches legacy") + } + } + } + } + } + } + name := "paired_input_attestation" + if file == "agentplugins-npm-publish.yml" { + name = "paired_stage_attestation" + } + original := w.Jobs[name] + for _, mutation := range []string{"mode", "or true", "always", "needs", "output authorization", "permissions", "continue"} { + job := original + job.Steps = append(job.Steps[:0:0], job.Steps...) + switch mutation { + case "mode": + job.If = "${{ success() }}" + case "or true": + job.If = strings.TrimSuffix(job.If, " }}") + " || true }}" + case "always": + job.If = "${{ always() }}" + case "needs": + job.Needs = nil + case "output authorization": + job.If = "${{ needs.paired_stage.outputs.accepted == 'true' }}" + case "permissions": + job.Permissions = map[string]string{"contents": "write"} + case "continue": + job.Steps[0].Continue = true + } + w.Jobs[name] = job + if c1Contract(w, name, file == "agentplugins-npm-publish.yml", true) == nil { + t.Fatal("mutation accepted", mutation) + } + } + w.Jobs[name] = original + } +} +func TestC1WorkflowPreflightNoEffects(t *testing.T) { + for _, file := range []string{"agentplugins-release.yml", "agentplugins-npm-publish.yml"} { + w := readProducerWorkflow(t, file) + stage := file == "agentplugins-npm-publish.yml" + mode := "paired-input-provenance" + if stage { + mode = "paired-stage" + } + good := map[string]string{"PRODUCER_MODE": mode, "TAG": "agentplugins-v0.1.54", "KIT_VERSION": "2.0.0", "SOURCE_SHA": strings.Repeat("a", 40), + "GITHUB_EVENT_NAME": "workflow_dispatch", "GITHUB_ACTIONS": "true", "GITHUB_REPOSITORY": "777genius/universal-agent-plugins", + "GITHUB_SHA": strings.Repeat("a", 40), "GITHUB_WORKFLOW_SHA": strings.Repeat("a", 40), "GITHUB_REF": "refs/tags/agentplugins-v0.1.54", + "GITHUB_WORKFLOW_REF": "777genius/universal-agent-plugins/.github/workflows/" + file + "@refs/tags/agentplugins-v0.1.54", + "GITHUB_RUN_ID": "21", "GITHUB_RUN_ATTEMPT": "2", "PUBLISH": "false", "NATIVE_INPUTS": "{}\n", + "INPUT_ARTIFACT": `{"run_id":11,"run_attempt":1,"artifact_id":31,"artifact_sha256":"` + strings.Repeat("b", 64) + `"}`, + "PREPARATION_RUN": "11", "PREPARATION_ATTEMPT": "1", "PREPARATION_ARTIFACT": "31", "PREPARATION_DIGEST": strings.Repeat("b", 64), "PROMOTION_RECORD": "", "PROMOTION_OPERATION": "promote"} + names := []string{"dispatch_contract", "paired_input_admission", "paired_input_attestation"} + if stage { + names = []string{"dispatch_contract", "paired_stage", "paired_stage_attestation"} + } + for _, name := range names { + good["GITHUB_JOB"] = name + good["STAGE_ARTIFACT_ID"] = "901" + good["STAGE_ARTIFACT_SHA256"] = strings.Repeat("c", 64) + good["STAGE_SHA256"] = strings.Repeat("d", 64) + job := w.Jobs[name] + if len(job.Steps) == 0 { + t.Fatal("missing preflight") + } + for _, step := range job.Steps { + if step.Run != "" { + cmd := exec.Command("/bin/bash", "-n") + cmd.Stdin = strings.NewReader(step.Run) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("shell syntax %s: %v %s", name, err, out) + } + } + } + cases := []map[string]string{{}} + for _, key := range []string{"PRODUCER_MODE", "TAG", "KIT_VERSION", "SOURCE_SHA", "GITHUB_EVENT_NAME", "GITHUB_ACTIONS", "GITHUB_REPOSITORY", "GITHUB_SHA", "GITHUB_WORKFLOW_SHA", "GITHUB_REF", "GITHUB_WORKFLOW_REF", "GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT"} { + for _, bad := range []string{"", "invalid", "$(touch injected)", "bad\nvalue"} { + cases = append(cases, map[string]string{key: bad}) + } + } + if stage { + for _, bad := range []map[string]string{{"PUBLISH": "true"}, {"INPUT_ARTIFACT": "{}"}, {"INPUT_ARTIFACT": strings.ReplaceAll(good["INPUT_ARTIFACT"], `"run_attempt":1`, `"run_attempt":1001`)}, {"NATIVE_INPUTS": ""}, {"PRODUCER_MODE": "legacy"}} { + cases = append(cases, bad) + } + } else { + for _, key := range []string{"PREPARATION_RUN", "PREPARATION_ATTEMPT", "PREPARATION_ARTIFACT", "PREPARATION_DIGEST", "PROMOTION_RECORD", "PROMOTION_OPERATION"} { + cases = append(cases, map[string]string{key: "invalid"}) + } + } + if name != "dispatch_contract" { + cases = append(cases, map[string]string{"GITHUB_JOB": "publish"}) + } + if name == "paired_stage_attestation" { + for _, key := range []string{"STAGE_ARTIFACT_ID", "STAGE_ARTIFACT_SHA256", "STAGE_SHA256"} { + cases = append(cases, map[string]string{key: "invalid"}) + } + } + for index, changes := range cases { + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + if err := os.Mkdir(bin, 0700); err != nil { + t.Fatal(err) + } + for _, tool := range []string{"node", "git", "npm", "gh", "tar", "python3", "curl"} { + if err := os.WriteFile(filepath.Join(bin, tool), []byte("#!/bin/bash\necho effect >> \"$MARKER\"\nexit 93\n"), 0700); err != nil { + t.Fatal(err) + } + } + cmd := exec.Command("/bin/bash", "-c", job.Steps[0].Run) + cmd.Dir = dir + cmd.Env = []string{"PATH=/usr/local/bin:" + bin + ":/usr/bin:/bin", "MARKER=" + filepath.Join(dir, "effect")} + for key, value := range good { + if replacement, ok := changes[key]; ok { + value = replacement + } + cmd.Env = append(cmd.Env, key+"="+value) + } + output, err := cmd.CombinedOutput() + if (err == nil) != (index == 0) { + t.Fatalf("%s preflight case %v: %v %s", name, changes, err, output) + } + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 1 || entries[0].Name() != "bin" { + t.Fatalf("preflight produced effects: %v %v", entries, err) + } + } + } + } +} diff --git a/npm/agentplugins/scripts/authoring-native-inputs.js b/npm/agentplugins/scripts/authoring-native-inputs.js index 413c68aa..e08942c5 100644 --- a/npm/agentplugins/scripts/authoring-native-inputs.js +++ b/npm/agentplugins/scripts/authoring-native-inputs.js @@ -1,10 +1,12 @@ "use strict"; -// Structural consistency only: these codecs do NOT establish authenticated -// provenance, signing, acquisition, eligibility or acceptance. Returned objects -// and bytes are data, never authority. No files, assets or receipts are opened. +// Codecs and subject enumeration are structural only. The bounded producer +// prepares unsigned I; only readInputs invokes the fixed signature boundary. +// Neither operation grants qualification, publication or execution permission. const c = require("./dual-authoring-candidate"); -const { TextDecoder } = require("node:util"); +const fs = require("node:fs"); +const path = require("node:path"); +const { TextDecoder, isDeepStrictEqual: equal } = require("node:util"); const INPUT_SCHEMA = "authoring-native-inputs/v1"; const DESCRIPTOR_SCHEMA = "dual-authoring-public-npm/v2"; @@ -193,6 +195,238 @@ function decodeDescriptor(body, inputBytes, product) { return value; } +const agree = (a, b, label) => { if (!equal(a, b)) throw new Error(`C1 provenance ${label} mismatch`); }; +function artifact(value) { + fields(value, ["run_id", "run_attempt", "artifact_id", "artifact_sha256"], "input artifact"); + return { run_id: positive(value.run_id, Number.MAX_SAFE_INTEGER, "input run"), + run_attempt: positive(value.run_attempt, 1000, "input attempt"), + artifact_id: positive(value.artifact_id, Number.MAX_SAFE_INTEGER, "input artifact ID"), + artifact_sha256: hash(value.artifact_sha256, "input artifact") }; +} +function operationOptions(value, reading) { + fields(value, ["input", "selected", "workflow_sha", "scratch", ...(reading ? ["artifact"] : [])], "provenance options"); + const input = decodeInputs(value.input), body = Buffer.from(value.input); + fields(value.selected, ["tag", "ref", "source", "versions"], "selected inputs"); + fields(value.selected.versions, c.PRODUCTS, "selected versions"); + const selected = { tag: input.products.agentplugins.tag, ref: `refs/tags/${input.products.agentplugins.tag}`, + source: input.identity.commit, versions: input.identity.versions }; + agree(value.selected, selected, "selected source/ref/versions"); + fixed(value.workflow_sha, input.identity.commit, "integrated workflow source F"); + if (Object.values(input.identity.versions).some(v => v.length > 32)) fail("bounded provider versions required"); + if (input.producer.run_id === input.preparation.artifact.run_id) fail("separate provenance and preparation runs required"); + const pin = reading ? artifact(value.artifact) : null; + if (reading) { + agree([pin.run_id, pin.run_attempt], [input.producer.run_id, input.producer.run_attempt], "I artifact producer attempt"); + if (pin.artifact_id === input.preparation.artifact.artifact_id) fail("I and preparation artifacts must differ"); + } + c.safeDirectory(value.scratch); + return { body, input, selected, workflow_sha: value.workflow_sha, scratch: value.scratch, artifact: pin }; +} +function projectionPins(input) { + return { identity: input.identity, candidate_sha256: input.candidate_sha256, pair_marker_sha256: input.pair_marker_sha256, + products: Object.fromEntries(c.PRODUCTS.map(p => [p, { + manifest_sha256: input.products[p].manifest_sha256, checksums_sha256: input.products[p].checksums_sha256 }])) }; +} +function preparationSnapshot(root, body) { + const input = decodeInputs(body); + const verified = require("./authoring-release").verifyProjectedPair(root, projectionPins(input)); + agree(verified.subjects.length, 18, "original subject count"); + for (const p of c.PRODUCTS) agree(verified.manifest.products[p].assets, input.products[p].assets, "outer/inner pins"); + const prepared = require("./authoring-promotion").readInputPreparation(root, body); + return { subjects: verified.subjects, preparation: prepared.preparation, + metadata_sha256: c.digest(c.readFile(path.join(root, "candidate-identity.json"), MAX_INPUT_BYTES)) }; +} + +/** Structural enumeration of exactly 18 original subjects plus exact I bytes. + * Keep rows, including both manifest/checksum basenames; this is NOT admission. */ +function inputSubjects(root, inputBytes) { + decodeInputs(inputBytes); + const snapshot = preparationSnapshot(root, inputBytes); + const file = path.join(root, INPUT_FILE); + agree(c.readFile(file, MAX_INPUT_BYTES), inputBytes, "retained I bytes"); + return [...snapshot.subjects, { file, sha256: c.digest(inputBytes) }]; +} + +/** Acquire the exact completed preparation and prepare unsigned I for the + * future protected provenance job. No signatures, OIDC or upload are performed. + * Its returned nineteen rows are signing candidates, never authenticated proof. */ +function produceInputs(value) { + const o = operationOptions(value, false), p = require("./authoring-promotion"); + const pin = o.input.preparation.artifact; + p.checkInputTags(o.body, o.scratch); + const before = p.inspectArtifact(pin, WORKFLOW, o.input.identity.commit, o.scratch); + const prepared = p.acquireInputPreparation(o.body, o.scratch); + const snapshot = preparationSnapshot(prepared.root, o.body); + p.checkInputTags(o.body, o.scratch); + agree(p.inspectArtifact(pin, WORKFLOW, o.input.identity.commit, o.scratch), before, "preparation provider changed"); + agree(preparationSnapshot(prepared.root, o.body), snapshot, "preparation changed before I completion"); + agree(operationOptions(value, false), o, "caller inputs changed before I completion"); + const file = path.join(prepared.root, INPUT_FILE); + fs.writeFileSync(file, o.body, { flag: "wx", mode: 0o444 }); + const subjects = inputSubjects(prepared.root, o.body); + agree(preparationSnapshot(prepared.root, o.body), snapshot, "preparation changed at I completion"); + agree(operationOptions(value, false), o, "caller inputs changed at I completion"); + return { root: prepared.root, input: o.input, subjects }; +} + +/** Source wiring for a completed provenance artifact. Positive integrated use + * is UNAVAILABLE: the existing checked reader supports native/preparation only. + * A separately accepted 21-entry input-provenance interface is required. Never + * substitute preparation kind, append files, or fall back to another extractor. */ +function readInputs(value) { + const o = operationOptions(value, true), p = require("./authoring-promotion"); + p.checkInputTags(o.body, o.scratch); + const before = p.inspectArtifact(o.artifact, WORKFLOW, o.input.identity.commit, o.scratch); + const work = fs.mkdtempSync(path.join(o.scratch, "input-provenance-")); + const file = p.acquireArtifact(o.artifact, WORKFLOW, o.input.identity.commit, work); + const files = ["preparation-run.json", "candidate-identity.json", "candidate/candidate.json", "pair-prepared.json", + ...c.PRODUCTS.flatMap(p => [...c.TARGETS.map(t => `${p}/${o.input.products[p].assets[t].file}`), + `${p}/release-manifest.json`, `${p}/checksums.txt`]), INPUT_FILE]; + const root = p.extractArtifact(file, o.artifact, "input-provenance", files, path.join(work, "frozen"), work); + agree(c.readFile(path.join(root, INPUT_FILE), MAX_INPUT_BYTES), o.body, "independently pinned I bytes"); + const snapshot = preparationSnapshot(root, o.body); + const prepPin = o.input.preparation.artifact; + const prepBefore = p.inspectArtifact(prepPin, WORKFLOW, o.input.identity.commit, o.scratch); + p.checkPreparationRef(prepPin, o.selected, o.scratch); + p.checkPreparationRef(o.artifact, o.selected, o.scratch); + const prepared = p.acquireInputPreparation(o.body, o.scratch); + const original = preparationSnapshot(prepared.root, o.body); + const relative = (s, base) => ({ ...s, + subjects: s.subjects.map(row => ({ file: path.relative(base, row.file), sha256: row.sha256 })) }); + agree(relative(snapshot, root), relative(original, prepared.root), "original preparation custody"); + const subjects = inputSubjects(root, o.body); + const multiset = subjects.map(s => ({ name: path.basename(s.file), digest: { sha256: s.sha256 } })); + for (const subject of subjects) p.verifySubject(subject.file, { + name: path.basename(subject.file), sha256: subject.sha256, source: o.input.identity.commit, + workflow_sha: o.workflow_sha, ref: o.selected.ref, run_id: o.input.producer.run_id, + run_attempt: o.input.producer.run_attempt, subjects: multiset + }, o.scratch); + p.checkInputTags(o.body, o.scratch); + agree(p.inspectArtifact(o.artifact, WORKFLOW, o.input.identity.commit, o.scratch), before, "I provider changed"); + agree(p.inspectArtifact(prepPin, WORKFLOW, o.input.identity.commit, o.scratch), prepBefore, "preparation provider changed"); + agree(preparationSnapshot(prepared.root, o.body), original, "original preparation changed"); + agree(preparationSnapshot(root, o.body), snapshot, "provenance preparation changed"); + agree(inputSubjects(root, o.body), subjects, "provenance subjects changed"); + p.checkPreparationRef(prepPin, o.selected, o.scratch); + p.checkPreparationRef(o.artifact, o.selected, o.scratch); + agree(operationOptions(value, true), o, "caller inputs changed during authentication"); + return { root, input: o.input, subjects }; +} + +/** Derive I only from the checked original preparation and independently bound + * current provenance caller. Both fixed jobs repeat this operation. */ +function produceInputsFromPreparation(value) { + fields(value, ["selected", "workflow_sha", "preparation", "repo", "scratch"], "preparation producer options"); + const p = require("./authoring-promotion"), packing = require("./stage-dual-authoring-npm"); + const selected = p.workflowSelection(value.selected, value.workflow_sha); + const pin = artifact(value.preparation); + for (const name of ["repo", "scratch"]) c.safeDirectory(value[name]); + const executing = path.resolve(__dirname, "../../.."); + for (const root of [value.repo, executing]) { + if (root === value.scratch || root.startsWith(value.scratch + path.sep) || value.scratch.startsWith(root + path.sep)) { + fail("provenance source/scratch overlap"); + } + } + const env = { PATH: "/usr/local/bin:/usr/bin:/bin", HOME: value.scratch, LC_ALL: "C.UTF-8" }; + const toolFiles = [process.execPath, "/usr/bin/git", "/usr/bin/gh", fs.realpathSync("/usr/bin/python3")]; + const tools = () => toolFiles.map(file => ({ file, sha256: c.digest(c.readFile(file)) })); + const toolPins = tools(), callerArgs = [...process.execArgv]; + const source = packing.blobs(value.repo, selected.source, env, "stage"); + const caller = p.inspectInputCaller(selected, value.workflow_sha, value.scratch); + if (caller.run_id === pin.run_id) fail("separate original preparation required"); + const before = p.inspectArtifact(pin, WORKFLOW, selected.source, value.scratch); + p.checkPreparationRef(pin, selected, value.scratch); + const work = fs.mkdtempSync(path.join(value.scratch, "derive-inputs-")); + const archive = p.acquireArtifact(pin, WORKFLOW, selected.source, work); + const files = ["preparation-run.json", "candidate-identity.json", "candidate/candidate.json", "pair-prepared.json", + ...c.PRODUCTS.flatMap(product => [...c.TARGETS.map(target => + `${product}/${c.assetName(product, selected.versions[product], target)}`), + `${product}/release-manifest.json`, `${product}/checksums.txt`])]; + const root = p.extractArtifact(archive, pin, "preparation", files, path.join(work, "frozen"), work); + const snapshot = () => files.map(file => ({ file, sha256: c.digest(c.readFile(path.join(root, file), MAX_NATIVE_BYTES)) })); + const originals = snapshot(); + const candidateBytes = c.readFile(path.join(root, "candidate/candidate.json"), MAX_INPUT_BYTES); + const candidate = JSON.parse(candidateBytes); + const id = identity({ repository: c.REPOSITORY, commit: selected.source, engine_revision: selected.source, versions: selected.versions }); + const tentative = { schema: INPUT_SCHEMA, identity: id, authoring_mode: MODE, asset_scope: SCOPE, + candidate_sha256: c.digest(candidateBytes), pair_marker_sha256: c.digest(c.readFile(path.join(root, "pair-prepared.json"), MAX_INPUT_BYTES)), + products: Object.fromEntries(c.PRODUCTS.map(product => [product, { + tag: (product === "agentplugins" ? "agentplugins-v" : "v") + selected.versions[product], + manifest_sha256: c.digest(c.readFile(path.join(root, product, "release-manifest.json"), MAX_INPUT_BYTES)), + checksums_sha256: c.digest(c.readFile(path.join(root, product, "checksums.txt"), MAX_INPUT_BYTES)), + assets: candidate.products?.[product]?.assets }])), + preparation: { sha256: c.digest(c.readFile(path.join(root, "preparation-run.json"), MAX_INPUT_BYTES)), artifact: pin }, + producer: caller }; + const body = encodeInputs(tentative); + preparationSnapshot(root, body); // candidate, projections, metadata and original receipt + const result = produceInputs({ input: body, selected, workflow_sha: value.workflow_sha, scratch: value.scratch }); + const retained = [...inputSubjects(result.root, body), ...["preparation-run.json", "candidate-identity.json"].map(file => + ({ file: path.join(result.root, file), sha256: c.digest(c.readFile(path.join(result.root, file), MAX_INPUT_BYTES)) }))]; + agree(retained.filter(row => path.basename(row.file) !== INPUT_FILE).map(row => + ({ file: path.relative(result.root, row.file), sha256: row.sha256 })).sort((a, b) => a.file.localeCompare(b.file)), + [...originals].sort((a, b) => a.file.localeCompare(b.file)), "derived versus revalidated original payload"); + agree(snapshot(), originals, "derivation root changed"); + agree(p.inspectArtifact(pin, WORKFLOW, selected.source, value.scratch), before, "original provider changed"); + p.checkPreparationRef(pin, selected, value.scratch); + agree(p.inspectInputCaller(selected, value.workflow_sha, value.scratch), caller, "current provenance caller"); + agree(packing.blobs(value.repo, selected.source, env, "stage"), source, "provenance source changed"); + agree(p.workflowSelection(value.selected, value.workflow_sha), selected, "provenance selection changed"); + agree(tools(), toolPins, "provenance tools changed"); + agree(process.execArgv, callerArgs, "provenance interpreter arguments"); + return result; +} + +// Fixed file transport for the two C1 modules. Options remain comparison pins; +// reading a file never turns its I bytes into authenticated input provenance. +function inputFileOptions(file, names) { + if (typeof file !== "string" || !path.isAbsolute(file) || path.resolve(file) !== file || /[\x00-\x1f]/.test(file)) { + fail("normalized absolute options file required"); + } + const raw = c.readFile(file, MAX_INPUT_BYTES); + const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)); + fields(value, names, "CLI options"); + // Canonical options prevent duplicate keys and ambiguous transport spelling. + if (!raw.equals(c.encode(value))) fail("canonical options required"); + let input, inputFile; + if (names.includes("input_file")) { + inputFile = value.input_file; + if (typeof inputFile !== "string" || !path.isAbsolute(inputFile) || path.resolve(inputFile) !== inputFile || + /[\x00-\x1f]/.test(inputFile) || inputFile === file) fail("normalized distinct input_file required"); + input = c.readFile(inputFile, MAX_INPUT_BYTES); + decodeInputs(input); + delete value.input_file; + value.input = input; + } + const files = [file, ...(inputFile ? [inputFile] : [])]; + for (const root of [path.resolve(__dirname, "../../.."), value.repo, + ...[value.node, value.npm].filter(v => typeof v === "string").map(v => path.dirname(v))].filter(Boolean)) { + for (const candidate of files) if (candidate === root || candidate.startsWith(root + path.sep)) fail("CLI transport overlaps source/tools"); + } + return { value, recheck() { + agree(c.readFile(file, MAX_INPUT_BYTES), raw, "CLI options bytes"); + if (inputFile) agree(c.readFile(inputFile, MAX_INPUT_BYTES), input, "CLI comparison I bytes"); + } }; +} + +function main(args) { + if (args.length !== 2 || !["--produce-inputs", "--read-inputs"].includes(args[0])) { + fail("usage: authoring-native-inputs.js --produce-inputs|--read-inputs "); + } + const producing = args[0] === "--produce-inputs"; + const transport = inputFileOptions(args[1], producing ? + ["selected", "workflow_sha", "preparation", "repo", "scratch"] : + ["input_file", "selected", "workflow_sha", "scratch", "artifact"]); + const result = producing ? produceInputsFromPreparation(transport.value) : readInputs(transport.value); + transport.recheck(); + return result; +} + module.exports = Object.freeze({ encodeInputs, decodeInputs, encodeDescriptor, decodeDescriptor, + produceInputs, readInputs, inputSubjects, produceInputsFromPreparation, inputFileOptions, main, INPUT_SCHEMA, DESCRIPTOR_SCHEMA, INPUT_FILE, MODE, SCOPE, WORKFLOW, PACKAGES, MAX_INPUT_BYTES, MAX_DESCRIPTOR_BYTES, MAX_NATIVE_BYTES }); + +if (require.main === module) { + try { process.stdout.write(c.encode(main(process.argv.slice(2)))); } + catch (error) { process.stderr.write(`C1 inputs: ${error.message}\n`); process.exitCode = 1; } +} diff --git a/npm/agentplugins/scripts/authoring-promotion.js b/npm/agentplugins/scripts/authoring-promotion.js index df8930bf..6c271247 100644 --- a/npm/agentplugins/scripts/authoring-promotion.js +++ b/npm/agentplugins/scripts/authoring-promotion.js @@ -186,17 +186,173 @@ function inspectArtifact(pin, workflow, source, cwd) { function acquireArtifact(pin, workflow, source, cwd) { cliVersion(cwd); const before = inspectArtifact(pin, workflow, source, cwd); + return downloadArtifactBytes(pin, before.item, cwd, () => + exact(inspectArtifact(pin, workflow, source, cwd), before, "artifact changed during acquisition")); +} +// Same private bounded byte path after distinct completed/current admission. +function downloadArtifactBytes(pin, item, cwd, recheck) { const file = path.join(cwd, `artifact-${pin.artifact_id}.zip`); if (fs.existsSync(file)) fail("artifact destination already exists"); - // gh api follows the provider's supported artifact ZIP redirect. No free URL. - // Binary output is bounded in memory, never extracted or executed here. const zip = gh(["api", "--hostname", "github.com", `repos/${REPOSITORY}/actions/artifacts/${pin.artifact_id}/zip`], cwd, 2 * 1024 * LIMIT, null); - if (zip.length !== before.item.size_in_bytes || c.digest(zip) !== pin.artifact_sha256) fail("artifact ZIP digest/size mismatch"); - exact(inspectArtifact(pin, workflow, source, cwd), before, "artifact changed during acquisition"); + if (zip.length !== item.size_in_bytes || c.digest(zip) !== pin.artifact_sha256) fail("artifact ZIP digest/size mismatch"); + recheck(); fs.writeFileSync(file, zip, { flag: "wx", mode: 0o400 }); return file; } +// Fixed C1 provider adapters. Log mapping and runner/process custody require +// independent genuine acceptance before positive execution. These checks do not +// confer that acceptance, and unsupported checked-ZIP kinds remain closed. +const STAGE_WORKFLOW = ".github/workflows/agentplugins-npm-publish.yml"; +function workflowSelection(value, workflowSha) { + c.keys(value, ["tag", "ref", "source", "versions"], "workflow selection"); + c.keys(value.versions, c.PRODUCTS, "selected versions"); + const identityValue = { repository: REPOSITORY, commit: value.source, engine_revision: value.source, versions: value.versions }; + identity(identityValue); sha(value.source, 40); + if (value.source.length !== 40 || Object.values(value.versions).some(v => typeof v !== "string" || v.length > 32 || /[\r\n]/.test(v))) fail("bounded workflow identity required"); + exact(value.versions["plugin-kit-ai"], "2.0.0", "first kit version"); + exact([value.tag, value.ref, workflowSha], [tag(identityValue, "agentplugins"), `refs/tags/${tag(identityValue, "agentplugins")}`, value.source], "selected workflow ref/source"); + return { tag: value.tag, ref: value.ref, source: value.source, versions: { ...value.versions } }; +} +function callerNumber(name, maximum = Number.MAX_SAFE_INTEGER) { + const text = process.env[name]; + if (typeof text !== "string" || !/^[1-9][0-9]{0,15}$/.test(text)) fail("exact caller integer required"); + return integer(Number(text), maximum); +} +function currentCaller(selected, workflow, jobs) { + const expected = { GITHUB_ACTIONS: "true", GITHUB_EVENT_NAME: "workflow_dispatch", GITHUB_REPOSITORY: REPOSITORY, + GITHUB_SHA: selected.source, GITHUB_WORKFLOW_SHA: selected.source, GITHUB_REF: selected.ref, + GITHUB_WORKFLOW_REF: `${REPOSITORY}/${workflow}@${selected.ref}` }; + exact(Object.fromEntries(Object.keys(expected).map(k => [k, process.env[k]])), expected, "current workflow caller"); + if (!jobs.includes(process.env.GITHUB_JOB)) fail("fixed C1 caller job required"); + return { workflow, source: selected.source, run_id: callerNumber("GITHUB_RUN_ID"), run_attempt: callerNumber("GITHUB_RUN_ATTEMPT", 1000) }; +} +function attemptAtRef(pin, workflow, selected, cwd, status) { + const run = api(`actions/runs/${integer(pin.run_id)}/attempts/${integer(pin.run_attempt, 1000)}`, cwd); + exact([run.id, run.run_attempt, run.repository?.full_name, run.head_repository?.full_name, + run.head_sha, run.path, run.event, run.head_branch, run.status, run.conclusion], + [pin.run_id, pin.run_attempt, REPOSITORY, REPOSITORY, selected.source, workflow, + "workflow_dispatch", selected.tag, status, status === "completed" ? "success" : null], "provider invocation/ref"); + return run; +} +function attemptJobs(pin, selected, cwd) { + const response = api(`actions/runs/${pin.run_id}/attempts/${pin.run_attempt}/jobs?per_page=100`, cwd); + if (!Array.isArray(response.jobs) || response.jobs.length > 100 || response.total_count !== response.jobs.length) fail("complete bounded attempt jobs required"); + for (const job of response.jobs) { + integer(job.id); + exact([job.run_id, job.run_attempt, job.head_sha, job.head_branch], + [pin.run_id, pin.run_attempt, selected.source, selected.tag], "provider job attempt/ref"); + } + if (new Set(response.jobs.map(j => j.id)).size !== response.jobs.length) fail("duplicate provider job ID"); + return response.jobs; +} +function oneJob(jobs, name, status) { + const found = jobs.filter(job => job.name === name); + if (found.length !== 1 || found[0].status !== status || found[0].conclusion !== (status === "completed" ? "success" : null)) { + fail("exact fixed successful producer/current job required"); + } + return found[0]; +} +function checkPreparationRef(pin, selected, cwd) { + return attemptAtRef(pin, WORKFLOW, selected, cwd, "completed"); +} +function inspectInputCaller(selected, workflowSha, cwd) { + selected = workflowSelection(selected, workflowSha); + const caller = currentCaller(selected, WORKFLOW, ["paired_input_admission", "paired_input_attestation"]); + cliVersion(cwd); + attemptAtRef(caller, WORKFLOW, selected, cwd, "in_progress"); + const jobs = attemptJobs(caller, selected, cwd); + oneJob(jobs, process.env.GITHUB_JOB, "in_progress"); + if (process.env.GITHUB_JOB === "paired_input_attestation") oneJob(jobs, "paired_input_admission", "completed"); + for (const p of c.PRODUCTS) checkTag({ identity: { commit: selected.source, versions: selected.versions } }, p, cwd); + return caller; +} +function inspectStageCaller(selected, workflowSha, cwd) { + selected = workflowSelection(selected, workflowSha); + const caller = currentCaller(selected, STAGE_WORKFLOW, ["paired_stage"]); + cliVersion(cwd); + attemptAtRef(caller, STAGE_WORKFLOW, selected, cwd, "in_progress"); + oneJob(attemptJobs(caller, selected, cwd), "paired_stage", "in_progress"); + for (const p of c.PRODUCTS) checkTag({ identity: { commit: selected.source, versions: selected.versions } }, p, cwd); + return { ...caller, ref: selected.ref }; +} +function stageJobEvidence(pin, selected, cwd) { + const job = oneJob(attemptJobs(pin, selected, cwd), "paired_stage", "completed"); + // Names are fixed in YAML; provider records expose step names, not YAML IDs. + const names = ["C1 preflight", "C1 checkout", "C1 setup", "C1 stage", "C1 upload", "C1 upload evidence"]; + if (!Array.isArray(job.steps)) fail("retained producer steps required"); + const allowed = ["Set up job", ...names, "Post C1 setup", "Post C1 checkout", "Complete job"]; + if (job.steps.some(step => !allowed.includes(step.name))) fail("unreviewed producer step"); + let last = 0; + for (const name of names) { + const rows = job.steps.filter(step => step.name === name); + if (rows.length !== 1 || rows[0].status !== "completed" || rows[0].conclusion !== "success" || + !Number.isSafeInteger(rows[0].number) || rows[0].number <= last) fail("fixed ordered successful stage steps required"); + last = rows[0].number; + } + const log = gh(["api", "--hostname", "github.com", `repos/${REPOSITORY}/actions/jobs/${job.id}/logs`], cwd, 4 * LIMIT); + const records = []; + for (const line of log.split("\n")) { + // Only standalone timestamped log records; echoed command source is not evidence. + const match = /^\d{4}-\d\d-\d\dT[0-9:.]+Z C1_STAGE (.*)\r?$/.exec(line); + if (match) records.push(JSON.parse(match[1])); + } + if (records.length !== 5) fail("five ordered retained stage operation records required"); + const [start, agent, kit, completion, upload] = records; + c.keys(start, ["operation", "source", "ref", "run_id", "run_attempt", "input_sha256", "input_artifact"], "stage start transcript"); + exact([start.operation, start.source, start.ref, start.run_id, start.run_attempt], + ["start", selected.source, selected.ref, pin.run_id, pin.run_attempt], "stage start invocation"); + sha(start.input_sha256); locator(start.input_artifact); + for (const [i, row] of [agent, kit].entries()) { + c.keys(row, ["operation", "product", "pack"], "stage pack transcript"); + exact([row.operation, row.product], ["pack", c.PRODUCTS[i]], "ordered two packs"); + } + c.keys(completion, ["operation", "stage_sha256"], "stage completion transcript"); + exact(completion.operation, "completion", "completed stage operation"); sha(completion.stage_sha256); + c.keys(upload, ["operation", "artifact_id", "artifact_sha256", "stage_sha256"], "stage upload transcript"); + exact(upload, { operation: "upload", artifact_id: pin.artifact_id, artifact_sha256: pin.artifact_sha256, + stage_sha256: completion.stage_sha256 }, "retained upload selector"); + return { job, records }; +} +function inspectCurrentStage(value) { + c.keys(value, ["artifact", "selected", "workflow_sha", "scratch"], "current stage options"); + const pin = locator(value.artifact), selected = workflowSelection(value.selected, value.workflow_sha); + c.safeDirectory(value.scratch); + const caller = currentCaller(selected, STAGE_WORKFLOW, ["paired_stage_attestation"]); + exact([pin.run_id, pin.run_attempt], [caller.run_id, caller.run_attempt], "current stage locator"); + cliVersion(value.scratch); + attemptAtRef(pin, STAGE_WORKFLOW, selected, value.scratch, "in_progress"); + oneJob(attemptJobs(pin, selected, value.scratch), "paired_stage_attestation", "in_progress"); + const evidence = stageJobEvidence(pin, selected, value.scratch); + const item = api(`actions/artifacts/${pin.artifact_id}`, value.scratch); + exact([item.id, item.expired, item.digest, item.workflow_run?.id, item.workflow_run?.head_sha, item.name], + [pin.artifact_id, false, `sha256:${pin.artifact_sha256}`, pin.run_id, selected.source, + `authoring-public-stage-${selected.source}-${pin.run_id}-${pin.run_attempt}`], "current stage artifact custody"); + integer(item.size_in_bytes, 2 * 1024 * LIMIT); + const created = Date.parse(item.created_at), started = Date.parse(evidence.job.started_at), ended = Date.parse(evidence.job.completed_at); + if (![created, started, ended].every(Number.isFinite) || created < started || created > ended) fail("artifact outside producer upload interval"); + // Incidental timestamps and growing unrelated jobs are deliberately omitted. + return { item: { id: item.id, digest: item.digest, size_in_bytes: item.size_in_bytes, name: item.name }, + job_id: evidence.job.id, records: evidence.records }; +} +function acquireCurrentStage(value) { + const before = inspectCurrentStage(value); + return downloadArtifactBytes(value.artifact, before.item, value.scratch, () => + exact(inspectCurrentStage(value), before, "current stage custody changed")); +} +function checkStageEvidence(artifact, selected, workflowSha, record, stageSha, cwd) { + selected = workflowSelection(selected, workflowSha); locator(artifact); + const evidence = stageJobEvidence(artifact, selected, cwd); + const expected = [ + { operation: "start", source: record.producer.source, ref: record.producer.ref, run_id: record.producer.run_id, + run_attempt: record.producer.run_attempt, input_sha256: record.native_inputs.sha256, input_artifact: record.native_inputs.artifact }, + ...c.PRODUCTS.map(product => ({ operation: "pack", product, pack: record.packs[product] })), + { operation: "completion", stage_sha256: stageSha }, + { operation: "upload", artifact_id: artifact.artifact_id, artifact_sha256: artifact.artifact_sha256, stage_sha256: stageSha }]; + exact(evidence.records, expected, "retained operation transcript versus exact S"); + return { job_id: evidence.job.id, records: evidence.records }; +} + // Extract only the file already checked by acquireArtifact. Python opens once, // snapshots and rehashes it, then parses/extracts that same immutable byte array. // There is no second provider download, archive-name selection or unzip command. @@ -230,10 +386,18 @@ function projectedPins(record) { manifest_sha256: record.products[p].manifest_sha256, checksums_sha256: record.products[p].checksums_sha256 }])) }; } function acquirePreparation(pin, record, scratch) { - record = recordShape(record); c.safeDirectory(scratch); locator(pin); + return acquirePreparationBinding(pin, recordShape(record), scratch); +} +// Shared preparation intake below Q validation. Only the Q wrapper above and +// the canonical I adapter below supply this private binding; no synthetic Q. +function acquirePreparationBinding(pin, record, scratch, receiptSha256) { + c.safeDirectory(scratch); locator(pin); const work = fs.mkdtempSync(path.join(scratch, "preparation-")); const file = acquireArtifact(pin, WORKFLOW, record.identity.commit, work); const root = extractArtifact(file, pin, "preparation", preparationFiles(record), path.join(work, "frozen"), work); + return readPreparationBinding(root, pin, record, receiptSha256); +} +function readPreparationBinding(root, pin, record, receiptSha256) { frozenSubjects(root, record); const metadataBody = c.readFile(path.join(root, "candidate-identity.json"), LIMIT); const metadata = JSON.parse(metadataBody); @@ -252,11 +416,27 @@ function acquirePreparation(pin, record, scratch) { } const preparation = { sha256: c.digest(c.readFile(path.join(root, "preparation-run.json"), LIMIT)), producer: invocationFor(pin, WORKFLOW, record.identity.commit) }; + if (receiptSha256 !== undefined) exact(preparation.sha256, receiptSha256, "exact I preparation receipt"); require("./authoring-native-qualification").readPreparation(root, projectedPins(record), preparation); // Metadata cannot substitute for the receipt or the frozen subject pins. // Independently acquired provider bytes, not this file's claims, bind attempt. return { root, preparation }; } +// These are fixed structural/custody adapters, not signature admission. The +// existing Q wrapper retains its recordShape requirement and return encoding. +function acquireInputPreparation(inputBytes, scratch) { + const input = require("./authoring-native-inputs").decodeInputs(inputBytes); + return acquirePreparationBinding(input.preparation.artifact, input, scratch, input.preparation.sha256); +} +function readInputPreparation(root, inputBytes) { + const input = require("./authoring-native-inputs").decodeInputs(inputBytes); + return readPreparationBinding(root, input.preparation.artifact, input, input.preparation.sha256); +} +function checkInputTags(inputBytes, cwd) { + const input = require("./authoring-native-inputs").decodeInputs(inputBytes); + c.safeDirectory(cwd); cliVersion(cwd); + for (const product of c.PRODUCTS) checkTag(input, product, cwd); +} function nativeContract(lane) { if (!LANES.slice(0, 12).includes(lane.lane) || lane.schema !== NATIVE_SCHEMA || lane.workflow !== NATIVE_WORKFLOW) fail(`NATIVE_EVIDENCE_INTEGRATION_REQUIRED: unsupported contracts [${lane.lane}:${lane.schema}]`); @@ -305,6 +485,11 @@ function admitNativeEvidence(record, preparationPin, scratch) { // is structural; only verifySubject calls the cryptographic boundary. B must // never promote supplied JSON into authenticated proof by calling this mapper. function mapVerifiedOutput(output, expected) { + return mapWorkflowOutput(output, expected, WORKFLOW); +} +// Private policy selection only. Cryptographic output fields and their mapping +// are unchanged; callers cannot supply a workflow or a verifier. +function mapWorkflowOutput(output, expected, workflow) { if (typeof output !== "string" || Buffer.byteLength(output) > 4 * LIMIT) fail("bounded verifier output required"); const results = JSON.parse(output); if (!Array.isArray(results) || results.length !== 1) fail("one verified attestation required"); @@ -327,7 +512,7 @@ function mapVerifiedOutput(output, expected) { exact(order(statement.subject), order(normalized), "verified subject set"); const build = statement.predicate?.buildDefinition; if (build?.buildType !== "https://actions.github.io/buildtypes/workflow/v1") fail("verified Actions build type mismatch"); - exact(build.externalParameters?.workflow, { ref: expected.ref, repository: URL, path: WORKFLOW }, "verified workflow"); + exact(build.externalParameters?.workflow, { ref: expected.ref, repository: URL, path: workflow }, "verified workflow"); exact(build.resolvedDependencies, [{ uri: `git+${URL}@${expected.ref}`, digest: { gitCommit: expected.source } }], "verified source"); const run = statement.predicate?.runDetails; exact(run?.metadata?.invocationId, `${URL}/actions/runs/${expected.run_id}/attempts/${expected.run_attempt}`, "verified invocation"); @@ -335,6 +520,17 @@ function mapVerifiedOutput(output, expected) { return statement; } function verifySubject(file, expected, cwd) { + return verifyWorkflowSubject(file, expected, cwd, WORKFLOW); +} +function verifyStageSubject(file, expected, cwd) { + exact(expected.workflow_sha, expected.source, "stage signer revision F"); + if (!Array.isArray(expected.subjects) || expected.subjects.length !== 3 || + expected.subjects.filter(s => s.name === "completion.json").length !== 1 || + expected.subjects.filter(s => /^universal-agent-plugins-[0-9]+\.[0-9]+\.[0-9]+\.tgz$/.test(s.name)).length !== 1 || + expected.subjects.filter(s => s.name === "plugin-kit-ai-2.0.0.tgz").length !== 1) fail("exact three stage subjects required"); + return verifyWorkflowSubject(file, expected, cwd, ".github/workflows/agentplugins-npm-publish.yml"); +} +function verifyWorkflowSubject(file, expected, cwd, workflow) { c.keys(expected, ["name", "sha256", "source", "workflow_sha", "ref", "run_id", "run_attempt", "subjects"], "verification expectations"); sha(expected.sha256); sha(expected.source, 40); sha(expected.workflow_sha, 40); integer(expected.run_id); integer(expected.run_attempt, 1000); @@ -342,11 +538,11 @@ function verifySubject(file, expected, cwd) { if (c.digest(c.readFile(file)) !== expected.sha256) fail("subject changed before signature verification"); cliVersion(cwd); const output = gh(["attestation", "verify", file, "--repo", REPOSITORY, - "--signer-workflow", SIGNER, "--signer-digest", expected.workflow_sha, + "--signer-workflow", workflow === WORKFLOW ? SIGNER : `github.com/${REPOSITORY}/${workflow}`, "--signer-digest", expected.workflow_sha, "--source-digest", expected.source, "--source-ref", expected.ref, "--cert-oidc-issuer", "https://token.actions.githubusercontent.com", "--deny-self-hosted-runners", "--predicate-type", SLSA, "--format", "json"], cwd); - const statement = mapVerifiedOutput(output, expected); + const statement = mapWorkflowOutput(output, expected, workflow); if (c.digest(c.readFile(file)) !== expected.sha256) fail("subject changed after signature verification"); return statement; } @@ -525,6 +721,7 @@ if (require.main === module) { try { process.stdout.write(JSON.stringify(main(process.argv.slice(2))) + "\n"); } catch (error) { process.stderr.write(`authoring promotion: ${error.message}\n`); process.exitCode = 1; } } -module.exports = { SCHEMA, WORKFLOW, GH_VERSION, LANES, encodeRecord, decodeRecord, validateSelection, admitRecord, requireNativeContracts, +module.exports = { inspectStageCaller, workflowSelection, inspectInputCaller, checkPreparationRef, acquireCurrentStage, inspectCurrentStage, checkStageEvidence, SCHEMA, WORKFLOW, GH_VERSION, LANES, encodeRecord, decodeRecord, validateSelection, admitRecord, requireNativeContracts, inspectArtifact, acquireArtifact, extractArtifact, acquirePreparation, checkNativeContracts, admitNativeEvidence, - mapVerifiedOutput, verifySubject, frozenSubjects, releasePins, inspectPair, promote }; + acquireInputPreparation, readInputPreparation, checkInputTags, + mapVerifiedOutput, verifySubject, verifyStageSubject, frozenSubjects, releasePins, inspectPair, promote }; diff --git a/npm/agentplugins/scripts/npm-public-contract.js b/npm/agentplugins/scripts/npm-public-contract.js index 6735d907..70710370 100644 --- a/npm/agentplugins/scripts/npm-public-contract.js +++ b/npm/agentplugins/scripts/npm-public-contract.js @@ -41,28 +41,47 @@ function validateExpected(version, integrity, shasum) { if (!SHASUM.test(shasum)) fail("shasum must be an exact lowercase SHA-1 value"); } -function validatePackJSON(value, version) { +// Fixed packing identities only; registry/provenance policy remains agent-only. +function validateProductPackJSON(value, product, version) { + const name = product === "agentplugins" ? PACKAGE_NAME : + product === "plugin-kit-ai" ? "plugin-kit-ai" : null; + if (!name) fail("unknown fixed npm product"); + if (typeof version !== "string" || version.match(VERSION)?.[0] !== version) { + fail("version must be an exact stable semantic version"); + } let record; if (Array.isArray(value)) { if (value.length !== 1) fail("npm pack JSON must contain exactly one record"); [record] = value; } else if (value && typeof value === "object") { const keys = Object.keys(value); - if (keys.length !== 1 || keys[0] !== PACKAGE_NAME) { + if (keys.length !== 1 || keys[0] !== name) { fail("npm pack JSON must contain exactly one package-named record"); } - record = value[PACKAGE_NAME]; + record = value[name]; } else { fail("npm pack JSON must contain exactly one record"); } - if (!record || record.name !== PACKAGE_NAME || record.version !== version || - record.filename !== `${PACKAGE_NAME}-${version}.tgz`) { + if (!record || typeof record !== "object" || Array.isArray(record) || record.name !== name || + record.version !== version || + record.filename !== `${name}-${version}.tgz`) { fail("npm pack JSON package identity does not match the release"); } + if (typeof record.integrity !== "string" || typeof record.shasum !== "string") { + fail("npm pack JSON integrity and shasum must be strings"); + } + if (record.shasum.length !== 40) fail("shasum must be an exact lowercase SHA-1 value"); validateExpected(version, record.integrity, record.shasum); + if ("sha512-" + Buffer.from(record.integrity.slice(7), "base64").toString("base64") !== record.integrity) { + fail("npm pack JSON integrity must be canonical SHA-512 SRI"); + } return record; } +function validatePackJSON(value, version) { + return validateProductPackJSON(value, "agentplugins", version); +} + function validatePublicMetadata(metadata, version, integrity, shasum) { validateExpected(version, integrity, shasum); if (Array.isArray(metadata)) { @@ -261,6 +280,7 @@ module.exports = { validateDownloadedTarball, validateAuditSignatures, validatePackJSON, + validateProductPackJSON, validatePublicMetadata, validateSLSAAttestation }; diff --git a/npm/agentplugins/scripts/stage-authoring-npm.js b/npm/agentplugins/scripts/stage-authoring-npm.js index e42c3eca..dbaf68ca 100644 --- a/npm/agentplugins/scripts/stage-authoring-npm.js +++ b/npm/agentplugins/scripts/stage-authoring-npm.js @@ -1,14 +1,17 @@ #!/usr/bin/env node "use strict"; -// Preparation only. B must authenticate real signed promotion inputs before it -// can introduce public staging. This entrypoint never generates qualification. +// Preparation and C1 stage source interfaces. Neither qualifies packs. +// Positive stage execution requires separately accepted artifact/workflow tools. const fs = require("node:fs"); const path = require("node:path"); const c = require("./dual-authoring-candidate"); const adapter = require("./authoring-release"); const packing = require("./stage-dual-authoring-npm"); const runtime = require("../lib/public-authoring"); +const inputs = require("./authoring-native-inputs"); +const crypto = require("node:crypto"); +const { TextDecoder } = require("node:util"); const PREFIX = "npm/agentplugins/"; const COMMON = Object.freeze(["lib/verifier.js", "lib/public-authoring.js", "scripts/dual-authoring-candidate.js"]); const ownFiles = product => ["LICENSE", "README.md", "package.json", `bin/${product}.js`, "lib/platform.js", @@ -17,6 +20,19 @@ const ALLOWLIST = Object.freeze([...new Set([...COMMON.map(n => PREFIX + n), ...c.PRODUCTS.flatMap(p => ownFiles(p).map(n => `npm/${p}/${n}`)), ...["stage-authoring-npm.js", "stage-dual-authoring-npm.js", "stage-dual-authoring-candidate.js", "authoring-release.js"].map(n => PREFIX + "scripts/" + n)])]); +// Separate future stage provenance inventory. Never extend the legacy +// preparation wrapper_blobs receipt or ship these producer helpers in a pack. +const STAGE_ALLOWLIST = Object.freeze([...ALLOWLIST, + ...["authoring-native-inputs.js", "authoring-promotion.js", "authoring-native-qualification.js", + "platform-proof.js", "npm-public-contract.js"].map(n => PREFIX + "scripts/" + n), + "scripts/read-authoring-evidence-zip.py", ".github/workflows/agentplugins-release.yml", + ".github/workflows/agentplugins-npm-publish.yml"]); +const STAGE_SCHEMA = "dual-authoring-public-stage/v1"; +const STAGE_WORKFLOW = ".github/workflows/agentplugins-npm-publish.yml"; +const MAX_STAGE_BYTES = 1024 * 1024; +const ASSERTIONS = Object.freeze(["authenticated_native_inputs", "exact_preparation_binding", "exact_source_blobs", + "exact_generated_closures", "exact_pack_entries_modes_bytes", "both_products_complete", "shared_runtime_bytes_equal", + "pack_once", "inputs_unchanged", "no_native_execution", "no_publication"]); const write = (file, bytes, mode = 0o644) => { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); fs.writeFileSync(file, bytes, { flag: "wx", mode }); @@ -40,6 +56,528 @@ function packageFiles(product, source, manifestBytes, candidate) { return files; } +// These fixed pure contracts establish byte/shape consistency ONLY. In +// particular, parsing S's assertions cannot establish that any assertion is true. +// Authentication belongs to the separate integrated operations below. +function stageFields(value, names, label) { + if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) { + throw new Error(`${label}: ordinary data object required`); + } + const own = Reflect.ownKeys(value); + if (own.length !== names.length || own.some(k => typeof k !== "string" || !names.includes(k))) { + throw new Error(`${label}: unexpected or missing fields`); + } + for (const key of own) { + const d = Object.getOwnPropertyDescriptor(value, key); + if (!d.enumerable || !("value" in d)) throw new Error(`${label}: enumerable data fields required`); + } + c.keys(value, names, label); +} +function stageEqual(value, expected, label) { + if (value !== expected) throw new Error(`${label}: stage binding mismatch`); + return value; +} +function stageHash(value, length = 64) { + if (typeof value !== "string" || value.length !== length || !/^[0-9a-f]+$/.test(value) || /^0+$/.test(value)) { + throw new Error("stage nonzero lowercase digest required"); + } + return value; +} +function stageInteger(value, maximum = Number.MAX_SAFE_INTEGER) { + if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) throw new Error("stage positive bounded integer required"); + return value; +} +function stageBytes(value, maximum) { + if (!Buffer.isBuffer(value) || value.length === 0 || value.length > maximum) throw new Error("stage nonempty bounded Buffer required"); + return value; +} +const stageClosure = product => [...ownFiles(product), ...COMMON, "bin/package.json", "lib/package.json", + "scripts/package.json", "public-release.json", "release-manifest.json", inputs.INPUT_FILE].sort(); + +function stageDescriptors(input, inputBytes) { + // Preflight BOTH 64 KiB limits, even when I itself fits its 1 MiB contract. + // Stable versions keep the accepted I policy; no artificial version ceiling. + return Object.fromEntries(c.PRODUCTS.map(product => [product, inputs.encodeDescriptor({ + schema: inputs.DESCRIPTOR_SCHEMA, product, npm_package: inputs.PACKAGES[product], identity: input.identity, + authoring_mode: input.authoring_mode, asset_scope: input.asset_scope, candidate_sha256: input.candidate_sha256, + release_manifest_sha256: input.products[product].manifest_sha256, + input_binding: { file: inputs.INPUT_FILE, sha256: c.digest(inputBytes) } + }, inputBytes, product)])); +} + +function stageBlob(value, withBytes) { + stageFields(value, ["git_blob", "mode", "sha256", ...(withBytes ? ["bytes"] : [])], "stage blob"); + const pin = { git_blob: stageHash(value.git_blob, 40), mode: value.mode, sha256: stageHash(value.sha256) }; + if (!["100644", "100755"].includes(pin.mode)) throw new Error("stage regular Git mode required"); + if (withBytes) { + const body = stageBytes(value.bytes, inputs.MAX_NATIVE_BYTES); + stageEqual(c.digest(body), pin.sha256, "source SHA256"); + const blob = crypto.createHash("sha1").update(`blob ${body.length}\0`).update(body).digest("hex"); + stageEqual(blob, pin.git_blob, "source Git blob"); + } + return pin; +} + +/** Pure final-format pair constructor. Supplied blobs and canonical projections + * are byte contracts, NOT authenticated source/provenance. The integrated C1 + * producer must authenticate I/all eighteen inputs and committed F first, then + * reuse packPackage once per product and completeRecord after revalidation. */ +function pairedPackageFiles(source, manifests, inputBytes) { + const input = inputs.decodeInputs(inputBytes); + const descriptors = stageDescriptors(input, inputBytes); + stageFields(source, STAGE_ALLOWLIST, "stage source closure"); + for (const name of STAGE_ALLOWLIST) stageBlob(source[name], true); + stageFields(manifests, c.PRODUCTS, "paired manifest bytes"); + for (const product of c.PRODUCTS) { + const body = stageBytes(manifests[product], inputs.MAX_INPUT_BYTES), id = input.identity; + // Exact existing authoring-release productManifest encoding, using the + // already validated I assets. No candidate rebuild, archive or second reader. + const expected = c.encode({ schema_version: 3, status: "CANDIDATE", product, repository: id.repository, + tag: input.products[product].tag, version: id.versions[product], commit: id.commit, engine_revision: id.engine_revision, + versions: id.versions, candidate_sha256: input.candidate_sha256, authoring_mode: input.authoring_mode, + asset_scope: input.asset_scope, assets: input.products[product].assets, + release_eligible: false, platform_acceptance: false, attested: false }); + if (!body.equals(expected)) throw new Error("stage projection bytes differ from I"); + stageEqual(c.digest(body), input.products[product].manifest_sha256, "selected manifest hash"); + const checksums = Buffer.from([...Object.values(input.products[product].assets).map(a => `${a.sha256} ${a.file}`), + `${c.digest(body)} release-manifest.json`].join("\n") + "\n"); + stageEqual(c.digest(checksums), input.products[product].checksums_sha256, "projection checksums hash"); + const baseBytes = source[`npm/${product}/package.json`].bytes; + const base = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(baseBytes)); + if (!base || Array.isArray(base) || typeof base !== "object") throw new Error("source package object required"); + stageEqual(base.name, inputs.PACKAGES[product], "source package name"); + c.keys(base.bin, [product], "source bin"); + stageEqual(base.bin[product], `bin/${product}.js`, "source bin"); + c.keys(base.engines, ["node"], "source engines"); + stageEqual(base.engines.node, product === "agentplugins" ? ">=22" : ">=18", "source Node support"); + const scripts = product === "agentplugins" ? { test: "node --test" } : { postinstall: "node ./lib/install.js" }; + c.keys(base.scripts, Object.keys(scripts), "source scripts"); + for (const name of Object.keys(scripts)) stageEqual(base.scripts[name], scripts[name], "source script"); + } + const pair = {}; + for (const product of c.PRODUCTS) { + const files = packageFiles(product, source, manifests[product], { + identity: input.identity, manifestDigest: input.candidate_sha256 }); + files["public-release.json"] = descriptors[product]; + files[inputs.INPUT_FILE] = inputBytes; + files["package.json"] = c.encode({ ...JSON.parse(files["package.json"]), private: false, files: stageClosure(product) }); + // Own the returned buffers, including each product's I and shared runtime. + pair[product] = Object.fromEntries(Object.entries(files).map(([name, body]) => [name, Buffer.from(body)])); + } + return pair; +} + +function stageRecord(value, inputBytes) { + const input = inputs.decodeInputs(inputBytes); + stageFields(value, ["schema", "identity", "authoring_mode", "asset_scope", "candidate_sha256", "pair_marker_sha256", + "projection_pins", "native_inputs", "wrapper_blobs", "generated", "packs", "tools", "producer", "assertions"], "stage"); + // Reuse the accepted descriptor identity validation (including closed nested + // fields) instead of relaxing candidate identity or introducing a new policy. + const descriptors = stageDescriptors({ ...input, identity: value.identity }, inputBytes); + stageEqual(value.schema, STAGE_SCHEMA, "schema"); + stageEqual(value.authoring_mode, input.authoring_mode, "mode"); + stageEqual(value.asset_scope, input.asset_scope, "scope"); + stageEqual(value.candidate_sha256, input.candidate_sha256, "candidate"); + stageEqual(value.pair_marker_sha256, input.pair_marker_sha256, "pair marker"); + stageFields(value.projection_pins, c.PRODUCTS, "projection pins"); + const projection_pins = {}; + for (const product of c.PRODUCTS) { + const pin = value.projection_pins[product]; + stageFields(pin, ["manifest_sha256", "checksums_sha256"], "projection pin"); + projection_pins[product] = { + manifest_sha256: stageEqual(pin.manifest_sha256, input.products[product].manifest_sha256, "manifest"), + checksums_sha256: stageEqual(pin.checksums_sha256, input.products[product].checksums_sha256, "checksums") }; + } + const native = value.native_inputs; + stageFields(native, ["sha256", "artifact"], "native inputs"); + stageFields(native.artifact, ["run_id", "run_attempt", "artifact_id", "artifact_sha256"], "input artifact"); + const a = native.artifact; + const native_inputs = { sha256: stageEqual(native.sha256, c.digest(inputBytes), "I bytes"), artifact: { + run_id: stageEqual(a.run_id, input.producer.run_id, "I producer run"), + run_attempt: stageEqual(a.run_attempt, input.producer.run_attempt, "I producer attempt"), + artifact_id: stageInteger(a.artifact_id), artifact_sha256: stageHash(a.artifact_sha256) } }; + stageFields(value.wrapper_blobs, STAGE_ALLOWLIST, "stage wrapper blobs"); + const wrapper_blobs = Object.fromEntries(STAGE_ALLOWLIST.map(n => [n, stageBlob(value.wrapper_blobs[n], false)])); + stageFields(value.generated, c.PRODUCTS, "generated pair"); + const generated = {}; + for (const product of c.PRODUCTS) { + const g = value.generated[product]; + stageFields(g, stageClosure(product), "generated closure"); + generated[product] = Object.fromEntries(stageClosure(product).map(n => [n, stageHash(g[n])])); + for (const name of ownFiles(product).filter(n => n !== "package.json")) { + stageEqual(g[name], wrapper_blobs[`npm/${product}/${name}`].sha256, "generated source file"); + } + for (const name of COMMON) stageEqual(g[name], wrapper_blobs[PREFIX + name].sha256, "shared runtime"); + for (const dir of ["bin", "lib", "scripts"]) { + stageEqual(g[`${dir}/package.json`], c.digest(c.encode({ type: "commonjs" })), "CommonJS scope"); + } + stageEqual(g[inputs.INPUT_FILE], native_inputs.sha256, "generated I"); + stageEqual(g["release-manifest.json"], projection_pins[product].manifest_sha256, "generated manifest"); + stageEqual(g["public-release.json"], c.digest(descriptors[product]), "generated descriptor"); + } + stageFields(value.packs, c.PRODUCTS, "stage packs"); + const packs = {}; + for (const product of c.PRODUCTS) { + const p = value.packs[product]; + stageFields(p, ["file", "sha256", "size", "integrity", "shasum"], "pack"); + if (typeof p.integrity !== "string" || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(p.integrity) || + p.integrity.length !== 95 || Buffer.from(p.integrity.slice(7), "base64").toString("base64") !== p.integrity.slice(7)) { + throw new Error("canonical SHA512 SRI required"); + } + packs[product] = { file: stageEqual(p.file, `${inputs.PACKAGES[product]}-${input.identity.versions[product]}.tgz`, "tarball name"), + sha256: stageHash(p.sha256), size: stageInteger(p.size, inputs.MAX_NATIVE_BYTES), + integrity: p.integrity, shasum: stageHash(p.shasum, 40) }; + } + stageFields(value.tools, ["node", "npm", "git", "tar", "gh"], "stage tools"); + const tools = {}; + for (const name of ["node", "npm", "git", "tar", "gh"]) { + const t = value.tools[name]; + stageFields(t, ["version", "sha256"], "tool"); + if (typeof t.version !== "string" || !t.version.length || t.version.length > MAX_STAGE_BYTES || + t.version.trim() !== t.version || /[\x00-\x1f\x7f]/.test(t.version)) throw new Error("stage tool version string required"); + tools[name] = { version: t.version, sha256: stageHash(t.sha256) }; + } + const p = value.producer; + stageFields(p, ["workflow", "source", "ref", "run_id", "run_attempt"], "stage producer"); + const producer = { workflow: stageEqual(p.workflow, STAGE_WORKFLOW, "stage workflow"), + source: stageEqual(p.source, input.identity.commit, "stage source"), + ref: stageEqual(p.ref, `refs/tags/${input.products.agentplugins.tag}`, "stage ref"), + run_id: stageInteger(p.run_id), run_attempt: stageInteger(p.run_attempt, 1000) }; + stageFields(value.assertions, ASSERTIONS, "stage assertions"); + const assertions = Object.fromEntries(ASSERTIONS.map(n => [n, stageEqual(value.assertions[n], true, "assertion syntax")])); + return { schema: STAGE_SCHEMA, identity: input.identity, authoring_mode: input.authoring_mode, asset_scope: input.asset_scope, + candidate_sha256: input.candidate_sha256, pair_marker_sha256: input.pair_marker_sha256, projection_pins, + native_inputs, wrapper_blobs, generated, packs, tools, producer, assertions }; +} + +/** Canonical S codec only. Does not attest/assert truth or authorize effects. */ +function encodeStage(value, inputBytes) { + return stageBytes(c.encode(stageRecord(value, inputBytes)), MAX_STAGE_BYTES); +} + +/** Canonical S codec only, NOT authenticated readStage. Retained tarballs, + * source at F, tool provision, signatures and operation evidence are external. */ +function decodeStage(body, inputBytes) { + stageBytes(body, MAX_STAGE_BYTES); + const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(body); + let depth = 0, quoted = false, escaped = false; + for (const ch of text) { + if (quoted) { + if (escaped) escaped = false; + else if (ch === "\\") escaped = true; + else if (ch === '"') quoted = false; + } else if (ch === '"') quoted = true; + else if (ch === "[") throw new Error("stage arrays are outside the fixed contract"); + else if (ch === "{" && ++depth > 4) throw new Error("stage object depth limit"); + else if (ch === "}") depth--; + } + const value = stageRecord(JSON.parse(text), inputBytes); + if (!body.equals(stageBytes(c.encode(value), MAX_STAGE_BYTES))) throw new Error("noncanonical stage bytes"); + return value; +} + +// Integrated source operations below use only fixed external boundaries. The +// checked artifact reader's input-provenance/public-stage kinds and protected +// workflows remain separately required before authentic positive execution. +const promotion = require("./authoring-promotion"); +const cp = require("node:child_process"); +const { isDeepStrictEqual: stageSame } = require("node:util"); +const agreeStage = (a, b, label) => { + if (!stageSame(a, b)) throw new Error(`C1 stage ${label} changed or mismatched`); +}; +function stageLocator(value) { + stageFields(value, ["run_id", "run_attempt", "artifact_id", "artifact_sha256"], "stage artifact locator"); + return { run_id: stageInteger(value.run_id), run_attempt: stageInteger(value.run_attempt, 1000), + artifact_id: stageInteger(value.artifact_id), artifact_sha256: stageHash(value.artifact_sha256) }; +} +function stageInvocation(value, input) { + stageFields(value, ["workflow", "source", "ref", "run_id", "run_attempt"], "stage invocation"); + const result = { workflow: STAGE_WORKFLOW, source: input.identity.commit, + ref: `refs/tags/${input.products.agentplugins.tag}`, run_id: stageInteger(value.run_id), + run_attempt: stageInteger(value.run_attempt, 1000) }; + agreeStage(value, result, "producer source/ref/workflow"); + if ([input.producer.run_id, input.preparation.artifact.run_id].includes(result.run_id)) { + throw new Error("separate stage and input/preparation runs required"); + } + return result; +} +function stageCaller(producer) { + const expected = { GITHUB_ACTIONS: "true", GITHUB_REPOSITORY: c.REPOSITORY, GITHUB_SHA: producer.source, + GITHUB_REF: producer.ref, GITHUB_RUN_ID: String(producer.run_id), GITHUB_RUN_ATTEMPT: String(producer.run_attempt), + GITHUB_WORKFLOW_SHA: producer.source, GITHUB_WORKFLOW_REF: `${c.REPOSITORY}/${STAGE_WORKFLOW}@${producer.ref}` }; + agreeStage(Object.fromEntries(Object.keys(expected).map(k => [k, process.env[k]])), expected, "workflow caller"); + return expected; +} +function stageOptions(value, reading) { + stageFields(value, ["input", "selected", "workflow_sha", "artifact", "repo", "workParent", "node", "npm", + ...(reading ? ["stage_sha256"] : ["producer", "output"])], "stage operation options"); + const body = Buffer.from(stageBytes(value.input, inputs.MAX_INPUT_BYTES)), input = inputs.decodeInputs(body); + stageDescriptors(input, body); // both bounded descriptors before any effects + // Match the existing readInputs provider boundary without changing pure I/S + // or descriptor limits. Provider-incompatible versions fail before effects. + if (Object.values(input.identity.versions).some(v => v.length > 32)) throw new Error("bounded provider versions required"); + stageFields(value.selected, ["tag", "ref", "source", "versions"], "stage selection"); + stageFields(value.selected.versions, c.PRODUCTS, "stage versions"); + const selected = { tag: input.products.agentplugins.tag, ref: `refs/tags/${input.products.agentplugins.tag}`, + source: input.identity.commit, versions: input.identity.versions }; + agreeStage(value.selected, selected, "selected identity"); + stageEqual(value.workflow_sha, input.identity.commit, "workflow revision F"); + const artifact = stageLocator(value.artifact); + if (input.producer.run_id === input.preparation.artifact.run_id) throw new Error("separate provenance run required"); + if (!reading) { + agreeStage([artifact.run_id, artifact.run_attempt], [input.producer.run_id, input.producer.run_attempt], "input attempt"); + if (artifact.artifact_id === input.preparation.artifact.artifact_id) throw new Error("separate input artifact required"); + } + if (reading && [input.producer.run_id, input.preparation.artifact.run_id].includes(artifact.run_id)) { + throw new Error("separate completed stage run required"); + } + const producer = reading ? null : stageInvocation(value.producer, input); + const stage_sha256 = reading ? stageHash(value.stage_sha256) : null; + for (const key of ["repo", "workParent"]) c.safeDirectory(value[key]); + const executing = path.resolve(__dirname, "../../.."); + for (const root of [value.repo, executing]) { + if (root === value.workParent || root.startsWith(value.workParent + path.sep) || value.workParent.startsWith(root + path.sep)) { + throw new Error("stage source/scratch roots overlap"); + } + } + for (const key of ["node", "npm"]) { + if (typeof value[key] !== "string" || !path.isAbsolute(value[key]) || path.resolve(value[key]) !== value[key]) { + throw new Error("absolute trusted stage tool required"); + } + c.readFile(value[key]); + } + if (!reading) { + stageCaller(producer); + // Existence is checked separately at reservation, so caller rechecks work + // after the output has been reserved without weakening initial placement. + const output = value.output; + if (typeof output !== "string" || !path.isAbsolute(output) || path.resolve(output) !== output || + !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(path.basename(output))) throw new Error("safe stage output required"); + c.safeDirectory(path.dirname(output)); + for (const root of [value.repo, executing, value.workParent, path.dirname(value.node), path.dirname(value.npm)]) { + if (output === root || output.startsWith(root + path.sep) || root.startsWith(output + path.sep)) throw new Error("stage output overlaps input"); + } + } + return { body, input, selected, workflow_sha: value.workflow_sha, artifact, producer, stage_sha256, + repo: value.repo, workParent: value.workParent, node: value.node, npm: value.npm, output: reading ? null : value.output }; +} +function stageTools(o, context) { + const command = (exe, args) => cp.execFileSync(exe, args, { env: context.env, cwd: context.root, + timeout: 30000, maxBuffer: MAX_STAGE_BYTES }).toString().trim(); + const paths = { node: o.node, npm: o.npm, git: "/usr/bin/git", tar: "/usr/bin/tar", gh: "/usr/bin/gh" }; + const tools = Object.fromEntries(Object.entries(paths).map(([name, file]) => [name, { + version: (name === "npm" ? command(o.node, [o.npm, "--version"]) : command(file, ["--version"])).split("\n")[0], + sha256: c.digest(c.readFile(file)) }])); + if (!tools.gh.version.startsWith(`gh version ${promotion.GH_VERSION} (`)) throw new Error("fixed stage gh provision required"); + return tools; +} +function toolSnapshot(o) { + // Invocation-local pins supplement S's portable five-tool fields. Include the + // running interpreter and the fixed checked-reader interpreter; no extra S keys. + return Object.fromEntries([...new Set([o.node, o.npm, process.execPath, "/usr/bin/git", "/usr/bin/tar", "/usr/bin/gh", + fs.realpathSync("/usr/bin/python3")])].map(file => [file, c.digest(c.readFile(file))])); +} +function sourcePins(source) { + return Object.fromEntries(Object.entries(source).map(([n, { bytes, ...pin }]) => [n, pin])); +} +function inputFiles(input) { + return ["preparation-run.json", "candidate-identity.json", "candidate/candidate.json", "pair-prepared.json", + ...c.PRODUCTS.flatMap(p => [...c.TARGETS.map(t => `${p}/${input.products[p].assets[t].file}`), + `${p}/release-manifest.json`, `${p}/checksums.txt`]), inputs.INPUT_FILE]; +} +function stageInputSnapshot(root, body) { + const input = inputs.decodeInputs(body); + const subjects = inputs.inputSubjects(root, body); // existing projected + receipt reader + if (subjects.length !== 19) throw new Error("exact nineteen input subjects required"); + const files = Object.fromEntries(inputFiles(input).map(n => [n, c.readFile(path.join(root, n), inputs.MAX_NATIVE_BYTES)])); + agreeStage(files[inputs.INPUT_FILE], body, "same retained I bytes"); + return { files, subjects: subjects.map(s => ({ file: path.relative(root, s.file), sha256: s.sha256 })) }; +} +function manifestsFrom(snapshot) { + return Object.fromEntries(c.PRODUCTS.map(p => [p, snapshot.files[`${p}/release-manifest.json`]])); +} +function generatedPins(pair) { + for (const name of [...COMMON, inputs.INPUT_FILE]) agreeStage(pair.agentplugins[name], pair["plugin-kit-ai"][name], "shared generated bytes"); + return Object.fromEntries(c.PRODUCTS.map(p => [p, + Object.fromEntries(Object.entries(pair[p]).map(([n, b]) => [n, c.digest(b)]))])); +} +function retainedPack(root, product, input) { + const file = `${inputs.PACKAGES[product]}-${input.identity.versions[product]}.tgz`; + const bytes = c.readFile(path.join(root, file), inputs.MAX_NATIVE_BYTES); + return { file, ...c.metadata(bytes), integrity: "sha512-" + crypto.createHash("sha512").update(bytes).digest("base64"), + shasum: crypto.createHash("sha1").update(bytes).digest("hex") }; +} +function stageProviders(o, inputArtifact, cwd) { + promotion.checkInputTags(o.body, cwd); + return [promotion.inspectArtifact(inputArtifact, inputs.WORKFLOW, o.input.identity.commit, cwd), + promotion.inspectArtifact(o.input.preparation.artifact, inputs.WORKFLOW, o.input.identity.commit, cwd)]; +} +function stageReadInputs(o, artifact, scratch) { + return inputs.readInputs({ input: o.body, selected: o.selected, workflow_sha: o.workflow_sha, artifact, scratch }); +} +function checkGeneratedRoot(root, files) { + const walk = (dir, prefix = "") => fs.readdirSync(dir).flatMap(n => { + const relative = prefix + n, file = path.join(dir, n), st = fs.lstatSync(file); + if (st.isDirectory() && !st.isSymbolicLink()) return walk(file, relative + "/"); + return [relative]; + }); + agreeStage(walk(root).sort(), Object.keys(files).sort(), "generated file inventory"); + for (const [name, body] of Object.entries(files)) { + agreeStage(c.readFile(path.join(root, name)), body, "generated file bytes"); + stageEqual(fs.lstatSync(path.join(root, name)).mode & 0o777, /^bin\/[^/]+\.js$/.test(name) ? 0o755 : 0o644, "generated mode"); + } +} + +/** Produce unsigned S only after authenticated I and two verified packs. Never + * signs, publishes, qualifies or launches a native input. Failed work is retained. + * Protected positive execution is unavailable until the fixed reader/workflows + * and genuine tool/verifier prerequisites have been independently accepted. */ +function stagePrepublication(value) { + const o = stageOptions(value, false); + c.outputPlacement(o.output, [o.repo, o.workParent, path.resolve(__dirname, "../../.."), path.dirname(o.node), path.dirname(o.npm)]); + const context = packing.npmContext(o.workParent); + context.env.PATH = "/usr/local/bin:/usr/bin:/bin"; + const source = packing.blobs(o.repo, o.input.identity.commit, context.env, "stage"); + const toolPins = toolSnapshot(o), tools = stageTools(o, context), callerArgs = [...process.execArgv]; + agreeStage(promotion.inspectStageCaller(o.selected, o.workflow_sha, context.root), o.producer, "provider stage caller"); + const providers = stageProviders(o, o.artifact, context.root); + const admitted = stageReadInputs(o, o.artifact, context.root); + const before = stageInputSnapshot(admitted.root, o.body); + const snapshot = path.join(context.root, "stage-inputs"); + c.outputPlacement(snapshot, [admitted.root, o.repo]); + fs.mkdirSync(snapshot, { mode: 0o700 }); + for (const [n, b] of Object.entries(before.files)) write(path.join(snapshot, n), b, 0o444); + agreeStage(stageInputSnapshot(snapshot, o.body), before, "owned input snapshot"); + const pair = pairedPackageFiles(source, manifestsFrom(before), o.body), generated = generatedPins(pair); + // Authentication may have changed caller/source/tools; no pack until recheck. + agreeStage(stageOptions(value, false), o, "caller before packing"); + agreeStage(packing.blobs(o.repo, o.input.identity.commit, context.env, "stage"), source, "source before packing"); + agreeStage(toolSnapshot(o), toolPins, "tools before packing"); + fs.mkdirSync(o.output, { mode: 0o700 }); + process.stderr.write("C1_STAGE " + JSON.stringify({ operation: "start", source: o.producer.source, ref: o.producer.ref, + run_id: o.producer.run_id, run_attempt: o.producer.run_attempt, input_sha256: c.digest(o.body), input_artifact: o.artifact }) + "\n"); + const packs = {}; + for (const product of c.PRODUCTS) { + const root = path.join(o.output, product); fs.mkdirSync(root, { mode: 0o700 }); + for (const [n, b] of Object.entries(pair[product])) write(path.join(root, n), b, /^bin\/[^/]+\.js$/.test(n) ? 0o755 : 0o644); + const packed = packing.packPackage(product, pair[product], root, { node: o.node, npm: o.npm, + output: o.output, identity: o.input.identity }, context); + const retained = retainedPack(o.output, product, o.input), { shasum, ...legacy } = retained; + agreeStage(packed, legacy, "pack return versus retained bytes"); + packs[product] = retained; // actual SHA1, without changing v1 pack return/receipts + process.stderr.write("C1_STAGE " + JSON.stringify({ operation: "pack", product, pack: retained }) + "\n"); + } + for (const product of c.PRODUCTS) { + checkGeneratedRoot(path.join(o.output, product), pair[product]); + packing.verifyPack(path.join(o.output, packs[product].file), pair[product], path.join(context.root, `retained-${product}`), context.env); + } + agreeStage(stageProviders(o, o.artifact, context.root), providers, "input providers"); + agreeStage(stageInputSnapshot(admitted.root, o.body), before, "authenticated inputs after packing"); + agreeStage(stageInputSnapshot(snapshot, o.body), before, "snapshot after packing"); + agreeStage(packing.blobs(o.repo, o.input.identity.commit, context.env, "stage"), source, "source after packing"); + agreeStage(toolSnapshot(o), toolPins, "tools after packing"); + agreeStage(process.execArgv, callerArgs, "caller interpreter arguments"); + agreeStage(stageOptions(value, false), o, "caller before completion"); + agreeStage(promotion.inspectStageCaller(o.selected, o.workflow_sha, context.root), o.producer, "provider stage completion"); + for (const product of c.PRODUCTS) { + checkGeneratedRoot(path.join(o.output, product), pair[product]); + agreeStage(retainedPack(o.output, product, o.input), packs[product], "retained pair before completion"); + } + const record = { schema: STAGE_SCHEMA, identity: o.input.identity, authoring_mode: o.input.authoring_mode, + asset_scope: o.input.asset_scope, candidate_sha256: o.input.candidate_sha256, pair_marker_sha256: o.input.pair_marker_sha256, + projection_pins: Object.fromEntries(c.PRODUCTS.map(p => [p, { manifest_sha256: o.input.products[p].manifest_sha256, + checksums_sha256: o.input.products[p].checksums_sha256 }])), + native_inputs: { sha256: c.digest(o.body), artifact: o.artifact }, wrapper_blobs: sourcePins(source), generated, packs, tools, + producer: o.producer, assertions: Object.fromEntries(ASSERTIONS.map(n => [n, true])) }; + const completed = decodeStage(encodeStage(record, o.body), o.body); + packing.completeRecord(o.output, completed); + process.stderr.write("C1_STAGE " + JSON.stringify({ operation: "completion", + stage_sha256: c.digest(c.readFile(path.join(o.output, "completion.json"), MAX_STAGE_BYTES)) }) + "\n"); + return completed; +} + +/** Authenticate an independently pinned completed S artifact, then retrieve its + * referenced I through readInputs. The required input Buffer is a comparison + * pin, never an authentication flag. Check both retained packs without packing. + * Returns staging evidence only, never qualification or execution permission. */ +function retainedContext(value) { + const o = stageOptions(value, true), context = packing.npmContext(o.workParent); + context.env.PATH = "/usr/local/bin:/usr/bin:/bin"; + return { o, context, source: packing.blobs(o.repo, o.input.identity.commit, context.env, "stage"), toolPins: toolSnapshot(o) }; +} +function recheckSubjects(result) { + for (const row of result.subjects) pinFile(row.file, row.sha256, 128 * 1024 * 1024); +} +function readStage(value) { + const { o, context, source, toolPins } = retainedContext(value); + const before = promotion.inspectArtifact(o.artifact, STAGE_WORKFLOW, o.input.identity.commit, context.root); + const archive = promotion.acquireArtifact(o.artifact, STAGE_WORKFLOW, o.input.identity.commit, context.root); + const retained = openRetainedStage(o, context, archive); + const { record, subjects } = retained; + const multiset = subjects.map(s => ({ name: path.basename(s.file), digest: { sha256: s.sha256 } })); + for (const subject of subjects) promotion.verifyStageSubject(subject.file, { name: path.basename(subject.file), + sha256: subject.sha256, source: record.producer.source, workflow_sha: o.workflow_sha, ref: record.producer.ref, + run_id: record.producer.run_id, run_attempt: record.producer.run_attempt, subjects: multiset }, context.root); + const result = validateRetainedStage(value, o, context, source, toolPins, retained); + agreeStage(promotion.inspectArtifact(o.artifact, STAGE_WORKFLOW, o.input.identity.commit, context.root), before, "completed stage provider"); + agreeStage(packing.blobs(o.repo, o.input.identity.commit, context.env, "stage"), source, "source after signatures"); + agreeStage(toolSnapshot(o), toolPins, "tools after signatures"); + agreeStage(stageOptions(value, true), o, "caller after signatures"); + recheckSubjects(result); + return result; +} +/** Fixed same-run unsigned acquisition, only for paired_stage_attestation. + * It cannot weaken the completed reader or accept a caller's staging root. */ +function validateUnsignedStage(value) { + const { o, context, source, toolPins } = retainedContext(value); + const custody = { artifact: o.artifact, selected: o.selected, workflow_sha: o.workflow_sha, scratch: context.root }; + const before = promotion.inspectCurrentStage(custody); + const archive = promotion.acquireCurrentStage(custody); + const result = validateRetainedStage(value, o, context, source, toolPins, openRetainedStage(o, context, archive)); + stageCaller(result.record.producer); + agreeStage(promotion.inspectCurrentStage(custody), before, "current stage provider"); + recheckSubjects(result); + return result; +} +// Both entrypoints share precisely the retained byte checks. This private +// function has no completed/authenticated switches or injected verifier. +function openRetainedStage(o, context, archive) { + const names = ["completion.json", ...c.PRODUCTS.map(p => `${inputs.PACKAGES[p]}-${o.input.identity.versions[p]}.tgz`)]; + const root = promotion.extractArtifact(archive, o.artifact, "public-stage", names, path.join(context.root, "stage"), context.root); + const body = pinFile(path.join(root, "completion.json"), o.stage_sha256, MAX_STAGE_BYTES), record = decodeStage(body, o.body); + const invocation = stageInvocation(record.producer, o.input); + agreeStage([invocation.run_id, invocation.run_attempt], [o.artifact.run_id, o.artifact.run_attempt], "stage artifact attempt"); + if ([record.native_inputs.artifact.artifact_id, o.input.preparation.artifact.artifact_id].includes(o.artifact.artifact_id)) { + throw new Error("separate stage artifact required"); + } + const subjects = names.map(name => ({ file: path.join(root, name), sha256: name === "completion.json" ? o.stage_sha256 : + record.packs[c.PRODUCTS.find(p => record.packs[p].file === name)].sha256 })); + return { root, record, subjects, body }; +} +function validateRetainedStage(value, o, context, source, toolPins, retained) { + const { root, record, subjects, body } = retained; + const evidence = promotion.checkStageEvidence(o.artifact, o.selected, o.workflow_sha, record, o.stage_sha256, context.root); + const providers = stageProviders(o, record.native_inputs.artifact, context.root); + const admitted = stageReadInputs(o, record.native_inputs.artifact, context.root); + const snapshot = stageInputSnapshot(admitted.root, o.body); + const pair = pairedPackageFiles(source, manifestsFrom(snapshot), o.body); + agreeStage(record.wrapper_blobs, sourcePins(source), "authenticated source closure"); + agreeStage(record.generated, generatedPins(pair), "authenticated generated closures"); + for (const product of c.PRODUCTS) { + agreeStage(retainedPack(root, product, o.input), record.packs[product], "authenticated retained pack"); + packing.verifyPack(path.join(root, record.packs[product].file), pair[product], path.join(context.root, `read-${product}`), context.env); + } + agreeStage(promotion.checkStageEvidence(o.artifact, o.selected, o.workflow_sha, record, o.stage_sha256, context.root), evidence, "stage operation evidence"); + agreeStage(stageProviders(o, record.native_inputs.artifact, context.root), providers, "input providers"); + agreeStage(stageInputSnapshot(admitted.root, o.body), snapshot, "reader inputs"); + agreeStage(packing.blobs(o.repo, o.input.identity.commit, context.env, "stage"), source, "reader source"); + agreeStage(toolSnapshot(o), toolPins, "reader tools"); + agreeStage(stageOptions(value, true), o, "reader caller"); + agreeStage(c.readFile(path.join(root, "completion.json"), MAX_STAGE_BYTES), body, "retained S"); + for (const product of c.PRODUCTS) agreeStage(retainedPack(root, product, o.input), record.packs[product], "reader retained pair"); + return { root, record, subjects }; +} + function pinFile(file, expected, maximum = 1024 * 1024) { if (typeof expected !== "string" || !/^[0-9a-f]{64}$/.test(expected)) throw new Error("independent preparation digest required"); const bytes = c.readFile(file, maximum); @@ -119,12 +657,32 @@ function prepare(options) { packing.completeRecord(options.output, record); return record; } +function main(args) { + if (args.length !== 2) throw new Error("one operation and absolute options file required"); + if (args[0] === "--prepare") { + if (!path.isAbsolute(args[1])) throw new Error("absolute preparation options required"); + return prepare(JSON.parse(c.readFile(args[1], 1024 * 1024))); + } + if (!["--stage-prepublication", "--read-stage", "--validate-unsigned-stage"].includes(args[0])) throw new Error("unknown C1 stage operation"); + const producing = args[0] === "--stage-prepublication"; + const transport = inputs.inputFileOptions(args[1], ["input_file", "selected", "workflow_sha", "artifact", "repo", "workParent", "node", "npm", + ...(producing ? ["producer", "output"] : ["stage_sha256"])]); + let result; + if (producing) { + const record = stagePrepublication(transport.value), root = transport.value.output; + const stage_sha256 = c.digest(c.readFile(path.join(root, "completion.json"), MAX_STAGE_BYTES)); + const subjects = [{ file: path.join(root, "completion.json"), sha256: stage_sha256 }, + ...c.PRODUCTS.map(product => ({ file: path.join(root, record.packs[product].file), sha256: record.packs[product].sha256 }))]; + result = { root, record, subjects, stage_sha256 }; + } else result = args[0] === "--read-stage" ? readStage(transport.value) : validateUnsignedStage(transport.value); + transport.recheck(); + return result; +} // Public blob verification re-enters this module while the CLI is preparing. -module.exports = { prepare, packageFiles, ALLOWLIST, COMMON }; +module.exports = { prepare, packageFiles, ALLOWLIST, COMMON, + encodeStage, decodeStage, pairedPackageFiles, STAGE_ALLOWLIST, stagePrepublication, readStage, validateUnsignedStage, main }; if (require.main === module) { - try { - if (process.argv.length !== 4 || process.argv[2] !== "--prepare" || !path.isAbsolute(process.argv[3])) throw new Error("usage: stage-authoring-npm.js --prepare "); - process.stdout.write(c.encode(prepare(JSON.parse(c.readFile(process.argv[3], 1024 * 1024))))); - } catch (e) { process.stderr.write(`public npm preparation: ${e.message}\n`); process.exitCode = 1; } + try { process.stdout.write(c.encode(main(process.argv.slice(2)))); } + catch (e) { process.stderr.write(`public npm preparation: ${e.message}\n`); process.exitCode = 1; } } diff --git a/npm/agentplugins/scripts/stage-dual-authoring-npm.js b/npm/agentplugins/scripts/stage-dual-authoring-npm.js index cf679a0c..11495eb1 100644 --- a/npm/agentplugins/scripts/stage-dual-authoring-npm.js +++ b/npm/agentplugins/scripts/stage-dual-authoring-npm.js @@ -8,6 +8,7 @@ const cp = require("node:child_process"); const crypto = require("node:crypto"); const c = require("./dual-authoring-candidate"); const producer = require("./stage-dual-authoring-candidate"); +const { validateProductPackJSON } = require("./npm-public-contract"); const MODE = "release-cli-contract-v1"; const PACKAGES = { agentplugins: "universal-agent-plugins", "plugin-kit-ai": "plugin-kit-ai" }; const COMMON = ["lib/verifier.js", "scripts/dual-authoring-candidate.js", @@ -39,14 +40,17 @@ function npmContext(workParent) { } function blobs(repo, commit, env, closure = "private") { - if (!["private", "public"].includes(closure)) throw new Error("unknown fixed npm closure"); - const allowlist = closure === "private" ? ALLOWLIST : require("./stage-authoring-npm").ALLOWLIST; + if (!["private", "public", "stage"].includes(closure)) throw new Error("unknown fixed npm closure"); + const allowlist = closure === "private" ? ALLOWLIST : require("./stage-authoring-npm")[closure === "stage" ? "STAGE_ALLOWLIST" : "ALLOWLIST"]; c.safeDirectory(repo); if (run("/usr/bin/git", ["rev-parse", "HEAD"], env, repo).toString().trim() !== commit) { throw new Error("expected source must equal checkout HEAD"); } const result = {}; - for (const name of allowlist) { + // Check the newly executing helper at the same commit without extending the + // historical private/public preparation wrapper_blobs receipt inventories. + const packHelper = PREFIX + "scripts/npm-public-contract.js"; + for (const name of [...new Set([...allowlist, packHelper])]) { const entry = run("/usr/bin/git", ["ls-tree", "-z", commit, "--", name], env, repo).toString(); const match = /^(100644|100755) blob ([0-9a-f]{40})\t([^\0]+)\0$/.exec(entry); if (!match || match[3] !== name) throw new Error(`required regular Git blob missing: ${name}`); @@ -55,14 +59,26 @@ function blobs(repo, commit, env, closure = "private") { } // The code doing verification/generation must itself be this committed code. // A dirty caller may not manufacture an exact-source claim using old blobs. - for (const name of ["scripts/stage-dual-authoring-npm.js", "scripts/stage-dual-authoring-candidate.js", + for (const name of ["scripts/npm-public-contract.js", "scripts/stage-dual-authoring-npm.js", "scripts/stage-dual-authoring-candidate.js", "scripts/dual-authoring-candidate.js", ...(closure === "public" ? ["scripts/stage-authoring-npm.js", "scripts/authoring-release.js", "lib/public-authoring.js"] : [])]) { if (!c.readFile(path.resolve(__dirname, "..", name)).equals(result[PREFIX + name].bytes)) { throw new Error(`executing stager differs from committed source: ${name}`); } } - return result; + if (closure === "stage") { + // Every listed checkout byte AND every executing-tree byte must be F. Keep + // legacy preparation inventories/checks unchanged; stage has its own set. + const executing = path.resolve(__dirname, "../../.."); + for (const root of new Set([repo, executing])) for (const name of allowlist) { + const file = path.join(root, name), pin = result[name]; + if (!c.readFile(file).equals(pin.bytes) || + (fs.lstatSync(file).mode & 0o777) !== (pin.mode === "100755" ? 0o755 : 0o644)) { + throw new Error(`stage source differs from committed F: ${name}`); + } + } + } + return Object.fromEntries(allowlist.map(name => [name, result[name]])); } function packageFiles(product, source, manifestBytes, options) { @@ -108,14 +124,17 @@ function verifyPack(tarball, files, destination, env) { // One exact pack algorithm for both fixed closures; private defaults are intact. function packPackage(product, files, root, options, context) { + if (!c.PRODUCTS.includes(product)) throw new Error("unknown fixed npm product"); const result = JSON.parse(run(options.node, [options.npm, "pack", "--ignore-scripts", "--offline", "--json", "--pack-destination", options.output], context.env, root)); const filename = `${PACKAGES[product]}-${options.identity.versions[product]}.tgz`; - if (result.length !== 1 || result[0].filename !== filename) throw new Error("unexpected npm pack result"); + const record = validateProductPackJSON(result, product, options.identity.versions[product]); const tarball = path.join(options.output, filename), bytes = c.readFile(tarball); - verifyPack(tarball, files, path.join(context.root, product), context.env); const integrity = "sha512-" + crypto.createHash("sha512").update(bytes).digest("base64"); - if (result[0].integrity !== integrity) throw new Error("npm integrity differs from actual pack"); + const shasum = crypto.createHash("sha1").update(bytes).digest("hex"); + if (record.integrity !== integrity) throw new Error("npm integrity differs from actual pack"); + if (record.shasum !== shasum) throw new Error("npm shasum differs from actual pack"); + verifyPack(tarball, files, path.join(context.root, product), context.env); return { file: filename, ...c.metadata(bytes), integrity }; } diff --git a/npm/agentplugins/test/authoring-native-inputs.test.js b/npm/agentplugins/test/authoring-native-inputs.test.js index fe245333..f8bd8f50 100644 --- a/npm/agentplugins/test/authoring-native-inputs.test.js +++ b/npm/agentplugins/test/authoring-native-inputs.test.js @@ -305,12 +305,385 @@ test("structural consistency only: constructor and decoder byte limits include e assert.throws(() => encodeDescriptor(descriptor(over, overBytes, "agentplugins"), overBytes, "agentplugins")); }); -test("structural consistency only: only four pure codec operations, no effectful or trust API", () => { +test("structural consistency only: four codecs stay pure beside separately named custody operations", () => { assert.deepEqual(Object.entries(contract).filter(([, v]) => typeof v === "function").map(([k]) => k), - ["encodeInputs", "decodeInputs", "encodeDescriptor", "decodeDescriptor"]); + ["encodeInputs", "decodeInputs", "encodeDescriptor", "decodeDescriptor", "produceInputs", "readInputs", "inputSubjects", "produceInputsFromPreparation", "inputFileOptions", "main"]); assert.ok(Object.isFrozen(contract)); const f = fixture(), snapshot = json(f), input = encodeInputs(f); const d = descriptor(f, input, "agentplugins"), before = json(d); encodeDescriptor(d, input, "agentplugins"); decodeInputs(input); assert.deepEqual(json(f), snapshot); assert.deepEqual(json(d), before); assert.deepEqual(input, snapshot); }); + + +// C1 tests are orchestration unit evidence only. Provider acquisition and +// signatures are stubbed module operations; these fixtures never authenticate. +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const cp = require("node:child_process"); +const promotion = require("../scripts/authoring-promotion"); +const release = require("../scripts/authoring-release"); +const qualification = require("../scripts/authoring-native-qualification"); + +function c1Fixture(t) { + t.mock.method(cp, "spawnSync", () => assert.fail("C1 tests must never launch a subprocess")); + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "provenance-")); + const root = path.join(sandbox, "prepared"), provenance = path.join(sandbox, "provenance"), scratch = path.join(sandbox, "scratch"); + for (const dir of [root, provenance, scratch]) fs.mkdirSync(dir); + const input = fixture(), inner = new Map(); + const write = (rel, bytes) => { + const file = path.join(root, rel); fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, bytes); return c.metadata(bytes); + }; + const manifest = { schema: c.SCHEMA, status: "CANDIDATE", identity: input.identity, asset_scope: input.asset_scope, + build: { method: "controlled-git-archive-go-build/v1", go_version: "go1.25.13", go_sha256: sha(80), + source_archive_sha256: sha(81), authoring_mode: input.authoring_mode }, products: {}, release_eligible: false }; + for (const product of products) { + for (const target of targets) { + const a = input.products[product].assets[target]; + const binary = Buffer.from("NONEXECUTABLE UNIT FIXTURE " + product + "/" + target); + const outer = product === "agentplugins" ? binary : Buffer.from("OPAQUE OUTER FIXTURE " + target); + a.binary = { file: a.binary.file, ...c.metadata(binary) }; + Object.assign(a, write(product + "/" + a.file, outer)); + inner.set(c.digest(outer), { file: a.binary.file, binary }); + } + manifest.products[product] = { version: input.identity.versions[product], assets: input.products[product].assets }; + } + // Stub the existing unpack operation only. No ZIP/tar internals are tested. + t.mock.method(c, "unpack", (bytes, name) => { + const row = inner.get(c.digest(bytes)); assert.ok(row); assert.equal(row.file, name); return row.binary; + }); + input.candidate_sha256 = write("candidate/candidate.json", c.encode(manifest)).sha256; + const pins = { identity: input.identity, candidate_sha256: input.candidate_sha256, pair_marker_sha256: "", products: {} }; + for (const product of products) { + const p = input.products[product]; + const m = { schema_version: 3, status: "CANDIDATE", product, repository: c.REPOSITORY, tag: p.tag, + version: input.identity.versions[product], commit: input.identity.commit, engine_revision: input.identity.commit, + versions: input.identity.versions, candidate_sha256: input.candidate_sha256, authoring_mode: input.authoring_mode, + asset_scope: input.asset_scope, assets: p.assets, release_eligible: false, platform_acceptance: false, attested: false }; + p.manifest_sha256 = write(product + "/release-manifest.json", c.encode(m)).sha256; + const checks = Buffer.from([...Object.values(p.assets).map(a => a.sha256 + " " + a.file), + p.manifest_sha256 + " release-manifest.json"].join("\n") + "\n"); + p.checksums_sha256 = write(product + "/checksums.txt", checks).sha256; + pins.products[product] = { manifest_sha256: p.manifest_sha256, checksums_sha256: p.checksums_sha256 }; + } + const marker = { schema: "authoring-release-pair/v1", status: "CANDIDATE", identity: input.identity, + candidate_sha256: input.candidate_sha256, authoring_mode: input.authoring_mode, asset_scope: input.asset_scope, + products: pins.products, release_eligible: false, platform_acceptance: false, attested: false }; + pins.pair_marker_sha256 = input.pair_marker_sha256 = write("pair-prepared.json", c.encode(marker)).sha256; + const invocation = { repository: c.REPOSITORY, workflow: contract.WORKFLOW, source: input.identity.commit, + workflow_sha: input.identity.commit, run_id: input.preparation.artifact.run_id, run_attempt: input.preparation.artifact.run_attempt }; + input.preparation.sha256 = qualification.writePreparation(root, pins, invocation).sha256; + write("candidate-identity.json", c.encode({ identity: input.identity, status: "CANDIDATE", + manifest_sha256: input.candidate_sha256, output: "/historical/not-opened", local_build_evidence: "/historical/not-opened-either", + release_eligible: false, platform_acceptance: false, attested: false })); + const original = release.verifyProjectedPair(root, pins).subjects; + const files = [...original.map(s => path.relative(root, s.file)), "preparation-run.json", "candidate-identity.json"]; + for (const file of files) { + fs.mkdirSync(path.dirname(path.join(provenance, file)), { recursive: true }); + fs.copyFileSync(path.join(root, file), path.join(provenance, file)); + } + const body = encodeInputs(input); + fs.writeFileSync(path.join(provenance, contract.INPUT_FILE), body); + const selected = { tag: input.products.agentplugins.tag, ref: "refs/tags/" + input.products.agentplugins.tag, + source: input.identity.commit, versions: copy(input.identity.versions) }; + const options = { input: body, selected, workflow_sha: input.identity.commit, scratch }; + const artifact = { run_id: input.producer.run_id, run_attempt: input.producer.run_attempt, + artifact_id: 501, artifact_sha256: sha(90) }; + const reading = { ...options, artifact }; + const calls = []; + t.mock.method(promotion, "checkPreparationRef", () => ({fixture_only: "completed original ref"})); + t.mock.method(promotion, "checkInputTags", (bytes, cwd) => { + calls.push("tags"); assert.deepEqual(bytes, body); assert.equal(cwd, scratch); + }); + t.mock.method(promotion, "inspectArtifact", (pin, workflow, source, cwd) => { + calls.push("inspect"); assert.equal(workflow, contract.WORKFLOW); assert.equal(source, input.identity.commit); + assert.equal(cwd, scratch); assert.ok([artifact.artifact_id, input.preparation.artifact.artifact_id].includes(pin.artifact_id)); + return { fixture_only: copy(pin) }; + }); + t.mock.method(promotion, "acquireInputPreparation", (bytes, cwd) => { + calls.push("preparation"); assert.equal(cwd, scratch); return promotion.readInputPreparation(root, bytes); + }); + t.mock.method(promotion, "acquireArtifact", (pin, workflow, source, cwd) => { + calls.push("acquire"); assert.deepEqual(pin, artifact); assert.equal(workflow, contract.WORKFLOW); + assert.equal(source, input.identity.commit); return path.join(cwd, "artifact-501.zip"); + }); + t.mock.method(promotion, "extractArtifact", (file, pin, kind, closure, output, cwd) => { + calls.push("extract"); assert.equal(file, path.join(cwd, "artifact-501.zip")); assert.deepEqual(pin, artifact); + assert.equal(kind, "input-provenance"); assert.equal(output, path.join(cwd, "frozen")); + assert.deepEqual([...closure].sort(), [...files, contract.INPUT_FILE].sort()); assert.equal(closure.length, 21); + // A simulated future interface, NOT the current checked reader. + return provenance; + }); + t.mock.method(promotion, "verifySubject", (file, expected, cwd) => { + calls.push({ file, expected: copy(expected) }); assert.equal(cwd, scratch); + assert.equal(c.digest(fs.readFileSync(file)), expected.sha256); + assert.equal(expected.source, input.identity.commit); assert.equal(expected.workflow_sha, input.identity.commit); + assert.equal(expected.ref, selected.ref); assert.equal(expected.run_id, input.producer.run_id); + assert.equal(expected.run_attempt, input.producer.run_attempt); + }); + return { root, provenance, scratch, input, body, options, reading, original, files, pins, calls }; +} + +test("C1 provenance producer and reader agree with honest simulated custody, never authentic admission", t => { + const f = c1Fixture(t), produced = contract.produceInputs(f.options); + assert.deepEqual(produced.input, f.input); assert.equal(produced.subjects.length, 19); + assert.equal(f.calls.filter(x => typeof x === "object").length, 0, "producer does not verify or sign unsigned I"); + assert.deepEqual(fs.readFileSync(path.join(f.root, contract.INPUT_FILE)), f.body); + const read = contract.readInputs(f.reading); + assert.deepEqual(read.input, produced.input); + const multiset = list => list.map(s => ({ name: path.basename(s.file), digest: { sha256: s.sha256 } })); + assert.deepEqual(multiset(read.subjects), multiset(produced.subjects)); + const signatures = f.calls.filter(x => typeof x === "object"); + assert.equal(signatures.length, 19); + for (const call of signatures) assert.deepEqual(call.expected.subjects, multiset(read.subjects)); + for (const name of ["release-manifest.json", "checksums.txt"]) { + const rows = signatures[0].expected.subjects.filter(s => s.name === name); + assert.equal(rows.length, 2); assert.notEqual(rows[0].digest.sha256, rows[1].digest.sha256); + } + assert.ok(!signatures[0].expected.subjects.some(s => s.name === "authoring-promotion.json")); + assert.deepEqual(Object.keys(read), ["root", "input", "subjects"]); +}); + +test("C1 provenance rejects malformed closed options and independent identity disagreements before effects", t => { + const f = c1Fixture(t); + const mutations = [o => o.input = null, o => o.input = Buffer.from('{}\n'), o => o.input = Buffer.concat([o.input, Buffer.from('\n')]), + o => o.verifier = () => true, o => o.success = true, o => o.selected.source = "b".repeat(40), + o => o.selected.ref = "refs/heads/main", o => o.selected.tag = "v2.0.0", o => o.workflow_sha = "b".repeat(40), + o => o.selected.versions.agentplugins = "0.1.98", o => o.scratch = "relative"]; + for (const reading of [false, true]) for (const mutate of mutations) { + const o = { ...f.options, input: Buffer.from(f.body), selected: copy(f.options.selected), ...(reading ? { artifact: copy(f.reading.artifact) } : {}) }; + mutate(o); assert.throws(() => (reading ? contract.readInputs : contract.produceInputs)(o)); + } + for (const mutate of [o => o.artifact.run_attempt++, o => o.artifact.run_id++, o => o.artifact.artifact_id = 0, + o => o.artifact.artifact_sha256 = "0".repeat(64), o => o.artifact.artifact_id = f.input.preparation.artifact.artifact_id, + o => o.artifact.claim = true]) { + const o = { ...f.reading, artifact: copy(f.reading.artifact) }; mutate(o); assert.throws(() => contract.readInputs(o)); + } + for (const mutate of [i => i.identity.repository = "fork/repo", i => i.producer.workflow = ".github/workflows/other.yml", + i => i.producer.source = "b".repeat(40), i => i.products["plugin-kit-ai"].tag = "v2.0.1", + i => delete i.products.agentplugins.assets["linux-amd64"], i => i.qualification = null, + i => i.preparation.artifact.run_attempt = 1001, i => i.authoring_mode = "wrong", i => i.asset_scope = "wrong"]) { + const input = copy(f.input); mutate(input); + assert.throws(() => contract.produceInputs({ ...f.options, input: json(input) })); + assert.throws(() => contract.readInputs({ ...f.reading, input: json(input) })); + } + assert.deepEqual(f.calls, []); assert.equal(fs.existsSync(path.join(f.root, contract.INPUT_FILE)), false); + assert.deepEqual(fs.readdirSync(f.scratch), []); +}); + +test("C1 provenance checked-artifact dependency rejects with no fallback or signature calls", t => { + const f = c1Fixture(t); + t.mock.method(promotion, "extractArtifact", (_file, _pin, kind, files) => { + assert.equal(kind, "input-provenance"); assert.equal(files.length, 21); + throw Error("simulated current checked interface rejects unsupported kind"); + }); + assert.throws(() => contract.readInputs(f.reading), /unsupported kind/); + assert.deepEqual(f.calls, ["tags", "inspect", "acquire"]); +}); + +for (const kind of ["receipt", "attempt", "metadata", "outer", "inner", "I"]) { + test("C1 provenance rejects " + kind + " disagreement before signatures or I output", t => { + const f = c1Fixture(t); + if (kind === "receipt") { + f.input.preparation.sha256 = sha(97); f.options.input = f.reading.input = encodeInputs(f.input); + // Identity-only stub: receipt validation itself stays the real reader. + t.mock.method(promotion, "checkInputTags", () => {}); + } else if (kind === "attempt") { + // Keep the existing read-only receipt intact; select a disagreeing attempt. + f.input.preparation.artifact.run_attempt++; f.options.input = encodeInputs(f.input); + t.mock.method(promotion, "checkInputTags", () => {}); + } else if (kind === "metadata") { + const file = path.join(f.root, "candidate-identity.json"), metadata = JSON.parse(fs.readFileSync(file)); + metadata.attested = true; fs.writeFileSync(file, c.encode(metadata)); + } else if (kind === "outer") fs.appendFileSync(f.original[3].file, "CHANGED"); + else if (kind === "inner") { + f.input.products["plugin-kit-ai"].assets["linux-amd64"].binary.sha256 = sha(99); + f.options.input = encodeInputs(f.input); t.mock.method(promotion, "checkInputTags", () => {}); + } else fs.appendFileSync(path.join(f.provenance, contract.INPUT_FILE), "\n"); + if (kind === "I") assert.throws(() => contract.readInputs(f.reading), /I bytes/); + else assert.throws(() => contract.produceInputs(f.options)); + assert.equal(fs.existsSync(path.join(f.root, contract.INPUT_FILE)), false); + assert.equal(f.calls.filter(x => typeof x === "object").length, 0); + }); +} + +for (const operation of ["produce", "read"]) for (const changed of ["caller", "source", "subject", "metadata", "provider", "tag"]) { + test("C1 provenance " + operation + " rejects late " + changed + " changes", t => { + const f = c1Fixture(t); let tags = 0, inspections = 0; + const selectedOptions = operation === "read" ? f.reading : f.options; + t.mock.method(promotion, "checkInputTags", () => { + if (++tags !== 2) return; + if (changed === "caller") selectedOptions.input[1] ^= 1; + if (changed === "source") selectedOptions.workflow_sha = "b".repeat(40); + if (changed === "subject") fs.appendFileSync(operation === "read" ? path.join(f.provenance, f.files[0]) : f.original[0].file, "changed"); + if (changed === "metadata") { + const file = path.join(operation === "read" ? f.provenance : f.root, "candidate-identity.json"); + const value = JSON.parse(fs.readFileSync(file)); value.output = "/different-history"; fs.writeFileSync(file, c.encode(value)); + } + if (changed === "tag") throw Error("simulated moved release tag"); + }); + t.mock.method(promotion, "inspectArtifact", pin => ({ pin: copy(pin), observation: changed === "provider" && ++inspections > (operation === "read" ? 2 : 1) ? "changed" : "same" })); + assert.throws(() => (operation === "read" ? contract.readInputs : contract.produceInputs)(selectedOptions)); + if (operation === "produce") assert.equal(fs.existsSync(path.join(f.root, contract.INPUT_FILE)), false); + }); +} + +test("C1 provenance unsigned verifier rejection never returns reader admission", t => { + const f = c1Fixture(t); let calls = 0; + t.mock.method(promotion, "verifySubject", () => { calls++; throw Error("unsigned fixture rejected"); }); + assert.throws(() => contract.readInputs(f.reading), /unsigned fixture rejected/); assert.equal(calls, 1); +}); + +test("C1 provenance rechecks earlier subjects and I after the last signature operation", t => { + const f = c1Fixture(t); let calls = 0; + t.mock.method(promotion, "verifySubject", () => { + if (++calls === 19) fs.appendFileSync(path.join(f.provenance, f.files[0]), "changed after first verification"); + }); + assert.throws(() => contract.readInputs(f.reading)); assert.equal(calls, 19); +}); + +test("C1 provenance reader rechecks exact I bytes after all nineteen simulated verifications", t => { + const f = c1Fixture(t); let calls = 0; + t.mock.method(promotion, "verifySubject", () => { + if (++calls === 19) fs.appendFileSync(path.join(f.provenance, contract.INPUT_FILE), "\n"); + }); + assert.throws(() => contract.readInputs(f.reading), /retained I bytes/); assert.equal(calls, 19); +}); + +test("C1 provenance producer checks unchanged preparation after writing I without returning completion", t => { + const f = c1Fixture(t), write = fs.writeFileSync; + t.mock.method(fs, "writeFileSync", (file, bytes, options) => { + write(file, bytes, options); + if (file === path.join(f.root, contract.INPUT_FILE)) fs.appendFileSync(f.original[0].file, "late fixture change"); + }); + assert.throws(() => contract.produceInputs(f.options)); + // A failed local candidate is retained; it is never an uploaded completed run. + assert.equal(f.calls.filter(x => typeof x === "object").length, 0); +}); + +test("C1 provenance reader compares preparation metadata from independently acquired bytes", t => { + const f = c1Fixture(t), file = path.join(f.provenance, "candidate-identity.json"); + const metadata = JSON.parse(fs.readFileSync(file)); metadata.output = "/different-historical-location"; + fs.writeFileSync(file, c.encode(metadata)); + assert.throws(() => contract.readInputs(f.reading), /original preparation custody/); + assert.equal(f.calls.filter(x => typeof x === "object").length, 0); +}); + +test("C1 provenance enumeration rejects missing original row and changed I without authenticating", t => { + const f = c1Fixture(t), verify = release.verifyProjectedPair; + t.mock.method(release, "verifyProjectedPair", (root, pins) => { + const result = verify(root, pins); return { ...result, subjects: result.subjects.slice(1) }; + }); + assert.throws(() => contract.inputSubjects(f.provenance, f.body), /original subject count/); + assert.deepEqual(f.calls, []); +}); + +test("C1 provenance producer completion collision preserves the existing file", t => { + const f = c1Fixture(t), file = path.join(f.root, contract.INPUT_FILE), previous = Buffer.from("existing output"); + fs.writeFileSync(file, previous); + assert.throws(() => contract.produceInputs(f.options), /EEXIST/); + assert.deepEqual(fs.readFileSync(file), previous); +}); + +function workflowPreparation(t) { + const f = c1Fixture(t), packing = require('../scripts/stage-dual-authoring-npm'); + const originalRead = c.readFile; + const tools = new Set([process.execPath, '/usr/bin/git', '/usr/bin/gh', fs.realpathSync('/usr/bin/python3')]); + t.mock.method(c, 'readFile', (file, max) => tools.has(file) ? Buffer.from(`tool fixture ${file}`) : originalRead(file, max)); + const repo = path.join(path.dirname(f.scratch), 'repo'); fs.mkdirSync(repo); + const derivation = path.join(path.dirname(f.scratch), 'derivation'); fs.mkdirSync(derivation); + for (const name of f.files) { + fs.mkdirSync(path.dirname(path.join(derivation, name)), {recursive: true}); + fs.copyFileSync(path.join(f.root, name), path.join(derivation, name)); + } + const options = {selected: f.options.selected, workflow_sha: f.options.workflow_sha, + preparation: f.input.preparation.artifact, repo, scratch: f.scratch}; + t.mock.method(packing, 'blobs', () => ({fixture_only: 'source pins'})); + t.mock.method(promotion, 'inspectInputCaller', () => copy(f.input.producer)); + t.mock.method(promotion, 'checkPreparationRef', () => ({fixture_only: 'original tag invocation'})); + t.mock.method(promotion, 'acquireArtifact', (pin, workflow, source, work) => { + assert.deepEqual(pin, options.preparation); assert.equal(workflow, contract.WORKFLOW); + assert.equal(source, options.selected.source); return path.join(work, `artifact-${pin.artifact_id}.zip`); + }); + t.mock.method(promotion, 'extractArtifact', (archive, pin, kind, files) => { + assert.equal(kind, 'preparation'); assert.deepEqual([...files].sort(), [...f.files].sort()); + assert.equal(path.basename(archive), `artifact-${pin.artifact_id}.zip`); return derivation; + }); + return {...f, derivation, producerOptions: options}; +} +test('C1 workflow derives I from checked original preparation and revalidates without Q', t => { + const f = workflowPreparation(t), file = path.join(f.scratch, 'options.json'); + fs.writeFileSync(file, c.encode(f.producerOptions)); + const result = contract.main(['--produce-inputs', file]); + assert.deepEqual(result.input, f.input); assert.equal(result.subjects.length, 19); + assert.deepEqual(fs.readFileSync(path.join(result.root, 'native-inputs.json')), f.body); + assert.deepEqual(Object.keys(result).sort(), ['input', 'root', 'subjects']); + assert.equal(f.calls.filter(x => typeof x === 'object').length, 0); +}); +for (const name of ['candidate/candidate.json', 'candidate-identity.json', 'pair-prepared.json', 'preparation-run.json', + 'agentplugins/release-manifest.json', 'plugin-kit-ai/checksums.txt']) { + test(`C1 workflow contradictory original ${name} rejects derivation`, t => { + const f = workflowPreparation(t); + // Preparation receipts are immutable; substitute contradictory comparison + // bytes at the existing read seam, never overwrite a read-only receipt. + const read = c.readFile; + t.mock.method(c, 'readFile', (file, max) => { + const body = read(file, max); + return file === path.join(f.derivation, name) ? Buffer.concat([body, Buffer.from('\n')]) : body; + }); + assert.throws(() => contract.produceInputsFromPreparation(f.producerOptions)); + assert.equal(fs.existsSync(path.join(f.root, 'native-inputs.json')), false); + }); +} +test('C1 workflow file transport passes exact Buffer to completed I authentication', t => { + const f = c1Fixture(t), input_file = path.join(f.scratch, 'comparison.json'), file = path.join(f.scratch, 'options.json'); + fs.writeFileSync(input_file, f.body); + const {input, ...options} = f.reading; + fs.writeFileSync(file, c.encode({...options, input_file})); + const result = contract.main(['--read-inputs', file]); + assert.deepEqual(result.input, f.input); + assert.equal(f.calls.filter(x => typeof x === 'object').length, 19); +}); +for (const defect of ['object', 'Buffer JSON', 'unknown field', 'relative file', 'duplicate key', 'unknown flag', 'extra flag']) { + test(`C1 workflow ${defect} transport fails before provider effects`, t => { + const f = c1Fixture(t), file = path.join(f.scratch, 'options.json'); + const {input, ...options} = f.reading; + const value = {...options, input_file: path.join(f.scratch, 'comparison.json')}; + fs.writeFileSync(value.input_file, f.body); + if (defect === 'object') value.input_file = f.input; + if (defect === 'Buffer JSON') {delete value.input_file; value.input = f.body;} + if (defect === 'unknown field') value.authenticated = true; + if (defect === 'relative file') value.input_file = 'comparison.json'; + let bytes = c.encode(value); + if (defect === 'duplicate key') bytes = Buffer.from(bytes.toString().replace('{', '{"input_file":"ignored",')); + fs.writeFileSync(file, bytes); + const args = [defect === 'unknown flag' ? '--produce' : '--read-inputs', file]; + if (defect === 'extra flag') args.push('--read-inputs'); + assert.throws(() => contract.main(args)); assert.equal(f.calls.length, 0); + }); +} +test('C1 workflow comparison file change during authentication rejects CLI success', t => { + const f = c1Fixture(t), input_file = path.join(f.scratch, 'comparison.json'), file = path.join(f.scratch, 'options.json'); + fs.writeFileSync(input_file, f.body); const {input, ...options} = f.reading; + fs.writeFileSync(file, c.encode({...options, input_file})); + t.mock.method(promotion, 'verifySubject', () => fs.writeFileSync(input_file, Buffer.from('changed'))); + assert.throws(() => contract.main(['--read-inputs', file]), /CLI comparison/); +}); + +for (const defect of ['source', 'caller', 'tools', 'original ref', 'reacquired original']) { + test(`C1 workflow derivation rejects changed ${defect} before a usable result`, t => { + const f = workflowPreparation(t); let checks = 0; + if (defect === 'source') t.mock.method(require('../scripts/stage-dual-authoring-npm'), 'blobs', () => ({fixture_only: ++checks})); + if (defect === 'caller') t.mock.method(promotion, 'inspectInputCaller', () => ({...f.input.producer, run_attempt: f.input.producer.run_attempt + checks++})); + if (defect === 'original ref') t.mock.method(promotion, 'checkPreparationRef', () => {throw Error('foreign original tag ref');}); + if (defect === 'tools') { + const original = c.readFile; + t.mock.method(c, 'readFile', (file, max) => file === '/usr/bin/gh' ? Buffer.from(`changed tool ${checks++}`) : original(file,max)); + } + if (defect === 'reacquired original') t.mock.method(promotion, 'acquireInputPreparation', () => {throw Error('original preparation contradiction');}); + assert.throws(() => contract.produceInputsFromPreparation(f.producerOptions)); + assert.equal(f.calls.filter(v => typeof v === 'object').length, 0, 'no signing or authentication result from producer fixture'); + }); +} diff --git a/npm/agentplugins/test/authoring-promotion.test.js b/npm/agentplugins/test/authoring-promotion.test.js index 2896ac62..658e2326 100644 --- a/npm/agentplugins/test/authoring-promotion.test.js +++ b/npm/agentplugins/test/authoring-promotion.test.js @@ -724,3 +724,298 @@ test('N2 provider-bound native semantic mutations reject with zero protected eff assert.ok(calls().some(call => call.args.at(-1) === endpoint(`actions/runs/${missing.run_id}/attempts/${missing.run_attempt}`))); noProtectedCalls(calls); }); + + +// C1 interface-only tests. Load a private test copy to stub existing module +// operations below the public wrappers. No production injection API is added, +// and no subprocess, artifact acquisition or signature is executed. +function c1PromotionInterface(t) { + t.mock.method(cp, "spawnSync", () => assert.fail("C1 interface test cannot spawn")); + const filename = require.resolve("../scripts/authoring-promotion"); + const Module = require("node:module"), local = new Module(filename, module); + local.filename = filename; local.paths = module.paths; + local._compile(fs.readFileSync(filename, "utf8") + String.raw` +const c1Calls = []; +const c1Responses = new Map(); +acquireArtifact = (pin, workflow, source, cwd) => { + c1Calls.push({operation:"acquire",pin,workflow,source,cwd}); + return path.join(cwd,"artifact-"+pin.artifact_id+".zip"); +}; +extractArtifact = (file,pin,kind,files,output,cwd) => { + c1Calls.push({operation:"extract",file,pin,kind,files,output,cwd}); return output; +}; +readPreparationBinding = (root,pin,record,receiptSha256) => { + c1Calls.push({operation:"read",root,pin,record,receiptSha256}); + return {root,preparation:{sha256:receiptSha256 ?? "Q-computed-receipt",producer:invocationFor(pin,WORKFLOW,record.identity.commit)}}; +}; +cliVersion = cwd => c1Calls.push({operation:"version",cwd}); +api = (endpoint,cwd) => { c1Calls.push({operation:"api",endpoint,cwd}); return c1Responses.get(endpoint) ?? {sha:"a".repeat(40)}; }; +module.exports.c1Calls = c1Calls; +module.exports.c1Responses = c1Responses; +`, filename); + const scratch = fs.mkdtempSync(path.join(os.tmpdir(),"q-interface-")); + const input = { schema:"authoring-native-inputs/v1",identity:structuredClone(ID),authoring_mode:"release-cli-contract-v1", + asset_scope:"six-platform-pair",candidate_sha256:hash("candidate"),pair_marker_sha256:hash("pair"),products:{}, + preparation:{sha256:hash("receipt"),artifact:structuredClone(pin)}, + producer:{workflow:p.WORKFLOW,source:ID.commit,run_id:41,run_attempt:3} }; + for (const product of c.PRODUCTS) { + const assets = {}; + for (const target of c.TARGETS) { + const binary = {file:c.executableName(product,target),sha256:hash(product+target),size:10}; + assets[target] = {file:c.assetName(product,ID.versions[product],target), + sha256:product === "agentplugins" ? binary.sha256 : hash("outer"+target),size:10,binary}; + } + input.products[product] = {tag:(product === "agentplugins" ? "agentplugins-v" : "v")+ID.versions[product], + manifest_sha256:hash(product+"manifest"),checksums_sha256:hash(product+"checksums"),assets}; + } + const {preparation:_prep,...common} = input; + const record = {...common,schema:p.SCHEMA,qualification:{lanes:p.LANES.map((lane,i) => { + const pairs = lane === "public-packed-pair" ? c.PRODUCTS.flatMap(product => c.TARGETS.map(target => [product,target])) : [lane.split("/")]; + return {lane,schema:"fixture-terminal/v1",sha256:hash("terminal"+i),workflow:".github/workflows/fixture-only.yml", + source:ID.commit,artifact:{...pin,artifact_id:100+i},subjects:pairs.map(([product,target]) => ({product,target, + sha256:input.products[product].assets[target].sha256,binary_sha256:input.products[product].assets[target].binary.sha256}))}; + })}}; + return {adapter:local.exports,scratch,input,record,body:require("../scripts/authoring-native-inputs").encodeInputs(input)}; +} + +test("C1 provenance preparation adapters share exact existing twenty-entry same-byte interface", t => { + const f = c1PromotionInterface(t), a = f.adapter; + const result = a.acquireInputPreparation(f.body,f.scratch); + assert.deepEqual(a.c1Calls.map(c => c.operation),["acquire","extract","read"]); + const [acquired,extracted,read] = a.c1Calls; + assert.deepEqual(acquired.pin,f.input.preparation.artifact); + assert.equal(acquired.workflow,p.WORKFLOW); assert.equal(acquired.source,ID.commit); + assert.equal(extracted.file,path.join(acquired.cwd,"artifact-31.zip")); + assert.equal(extracted.cwd,acquired.cwd); assert.equal(extracted.kind,"preparation"); + assert.equal(extracted.files.length,20); assert.equal(new Set(extracted.files).size,20); + assert.ok(extracted.files.includes("candidate/candidate.json")); + assert.ok(!extracted.files.includes("native-inputs.json")); assert.ok(!extracted.files.includes("authoring-promotion.json")); + assert.equal(read.root,extracted.output); assert.deepEqual(read.record,f.input); + assert.equal(read.receiptSha256,f.input.preparation.sha256); + assert.deepEqual(Object.keys(result),["root","preparation"]); + assert.deepEqual(result.preparation,{sha256:f.input.preparation.sha256,producer:{repository:c.REPOSITORY, + workflow:p.WORKFLOW,source:ID.commit,workflow_sha:ID.commit,run_id:pin.run_id,run_attempt:pin.run_attempt}}); +}); + +test("C1 provenance Q wrapper preserves full record validation, bytes and receipt return contract", t => { + const f = c1PromotionInterface(t), before = p.encodeRecord(f.record), a = f.adapter; + const result = a.acquirePreparation(pin,f.record,f.scratch); + const read = a.c1Calls.at(-1); + assert.deepEqual(read.record,p.decodeRecord(before)); assert.equal(read.receiptSha256,undefined); + assert.deepEqual(p.encodeRecord(f.record),before); + assert.deepEqual(result,{root:read.root,preparation:{sha256:"Q-computed-receipt",producer:{repository:c.REPOSITORY, + workflow:p.WORKFLOW,source:ID.commit,workflow_sha:ID.commit,run_id:pin.run_id,run_attempt:pin.run_attempt}}}); + a.c1Calls.length = 0; + assert.throws(() => a.acquirePreparation(pin,f.input,f.scratch)); + const missing = structuredClone(f.record); missing.qualification.lanes.pop(); + assert.throws(() => a.acquirePreparation(pin,missing,f.scratch),/missing required lanes/); + assert.deepEqual(a.c1Calls,[]); + assert.equal(p.LANES.length,13); + assert.throws(() => p.requireNativeContracts(f.record.qualification.lanes),/NATIVE_EVIDENCE_INTEGRATION_REQUIRED/); + assert.throws(() => p.requireNativeContracts(f.record.qualification.lanes.slice(0,12)),/public-packed-pair/); + for (const product of c.PRODUCTS) { + assert.ok(p.releasePins(f.record,product).some(row => row.name === "authoring-promotion.json")); + assert.ok(!p.releasePins(f.record,product).some(row => row.name === "native-inputs.json")); + } +}); + +test("C1 provenance fixed tag adapter reuses both existing derived release-tag endpoints", t => { + const f = c1PromotionInterface(t); f.adapter.checkInputTags(f.body,f.scratch); + assert.deepEqual(f.adapter.c1Calls,[{operation:"version",cwd:f.scratch}, + {operation:"api",endpoint:"commits/agentplugins-v0.1.54",cwd:f.scratch}, + {operation:"api",endpoint:"commits/v2.0.0",cwd:f.scratch}]); +}); + +test("C1 provenance malformed preparation adapter input rejects before any intake operation", t => { + const f = c1PromotionInterface(t); + for (const bytes of [null,Buffer.from("{}\n"),Buffer.concat([f.body,Buffer.from("\n")])]) { + assert.throws(() => f.adapter.acquireInputPreparation(bytes,f.scratch)); + assert.throws(() => f.adapter.readInputPreparation(f.scratch,bytes)); + assert.throws(() => f.adapter.checkInputTags(bytes,f.scratch)); + } + assert.deepEqual(f.adapter.c1Calls,[]); assert.deepEqual(fs.readdirSync(f.scratch),[]); +}); + +test("C1 provenance fixed completed-attempt inspector rejects foreign or stale provider metadata at interface level", t => { + const f = c1PromotionInterface(t), a = f.adapter; + const run = { id:pin.run_id,run_attempt:pin.run_attempt,status:"completed",conclusion:"success", + repository:{full_name:c.REPOSITORY},head_repository:{full_name:c.REPOSITORY},head_sha:ID.commit,path:p.WORKFLOW }; + const item = { id:pin.artifact_id,expired:false,digest:"sha256:"+pin.artifact_sha256, + workflow_run:{id:pin.run_id,head_sha:ID.commit},name:"fixture-only",size_in_bytes:123 }; + const select = (r,i) => { + a.c1Responses.set("actions/runs/21/attempts/2",r); + a.c1Responses.set("actions/artifacts/31",i); a.c1Calls.length = 0; + }; + select(run,item); + assert.deepEqual(a.inspectArtifact(pin,p.WORKFLOW,ID.commit,f.scratch),{run,item}); + assert.deepEqual(a.c1Calls.map(c => c.endpoint),["actions/runs/21/attempts/2","actions/artifacts/31"]); + for (const mutate of [r => r.id++,r => r.run_attempt++,r => r.status = "in_progress",r => r.conclusion = "failure", + r => r.repository.full_name = "fork/repo",r => r.head_repository.full_name = "fork/repo", + r => r.head_sha = "b".repeat(40),r => r.path = ".github/workflows/other.yml"]) { + const changed = structuredClone(run); mutate(changed); select(changed,item); + assert.throws(() => a.inspectArtifact(pin,p.WORKFLOW,ID.commit,f.scratch),/exact successful/); + assert.equal(a.c1Calls.length,1); + } + for (const mutate of [i => i.id++,i => i.expired = true,i => i.digest = "sha256:"+hash("different"), + i => i.workflow_run.id++,i => i.workflow_run.head_sha = "b".repeat(40),i => i.size_in_bytes = 0]) { + const changed = structuredClone(item); mutate(changed); select(run,changed); + assert.throws(() => a.inspectArtifact(pin,p.WORKFLOW,ID.commit,f.scratch)); + assert.ok(a.c1Calls.every(c => c.operation === "api")); + } + assert.deepEqual(fs.readdirSync(f.scratch),[]); +}); + +test("C1 provenance fixed tag adapter rejects a moved second product tag", t => { + const f = c1PromotionInterface(t); + f.adapter.c1Responses.set("commits/v2.0.0",{sha:"b".repeat(40)}); + assert.throws(() => f.adapter.checkInputTags(f.body,f.scratch),/moved release tag/); + assert.ok(f.adapter.c1Calls.every(c => ["version","api"].includes(c.operation))); +}); + +// New fixed-stage adapter tests mock the existing process interface IN MEMORY. +// No verifier execution or authentic signature compatibility is claimed. +test("C1 stage integration fixed npm signer uses existing verification interface with exact three subjects", t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "c1-stage-signer-")); + const rows = ["completion.json", "universal-agent-plugins-0.1.54.tgz", "plugin-kit-ai-2.0.0.tgz"].map(name => { + const file = path.join(root, name), body = Buffer.from(`unsigned interface fixture ${name}`); + fs.writeFileSync(file, body); return { name, file, digest: { sha256: c.digest(body) } }; + }); + let workflow = ".github/workflows/agentplugins-npm-publish.yml", calls = [], active; + t.mock.method(cp, "spawnSync", (exe, args, options) => { + assert.equal(exe, "/usr/bin/gh"); assert.equal(options.env.PATH, "/usr/local/bin:/usr/bin:/bin"); + calls.push(args); + if (args[0] === "--version") return { status: 0, stdout: `gh version ${p.GH_VERSION} (interface fixture)\n` }; + assert.deepEqual(args.slice(0, 3), ["attestation", "verify", active.file]); + assert.equal(args[args.indexOf("--signer-workflow") + 1], `github.com/${c.REPOSITORY}/.github/workflows/agentplugins-npm-publish.yml`); + assert.equal(args[args.indexOf("--signer-digest") + 1], ID.commit); + assert.equal(args[args.indexOf("--source-digest") + 1], ID.commit); + assert.equal(args[args.indexOf("--source-ref") + 1], selected.ref); + const statement = verified(active.expected); // existing output fixture shape only + statement[0].verificationResult.statement.predicate.buildDefinition.externalParameters.workflow.path = workflow; + return { status: 0, stdout: JSON.stringify(statement) }; + }); + for (const row of rows) { + active = { file: row.file, expected: { name: row.name, sha256: row.digest.sha256, source: ID.commit, + workflow_sha: ID.commit, ref: selected.ref, run_id: 501, run_attempt: 4, + subjects: rows.map(({ name, digest }) => ({ name, digest })) } }; + assert.equal(p.verifyStageSubject(active.file, active.expected, root)._type, "https://in-toto.io/Statement/v1"); + } + assert.equal(calls.filter(a => a[0] === "attestation").length, 3); + workflow = p.WORKFLOW; + assert.throws(() => p.verifyStageSubject(active.file, active.expected, root), /verified workflow/); + for (const mutate of [e => e.workflow_sha = "b".repeat(40), e => e.subjects.pop(), + e => e.subjects[0].name = "authoring-promotion.json", e => e.workflow = p.WORKFLOW, + e => e.ref = "refs/heads/main", e => e.sha256 = hash("different bytes")]) { + const expected = structuredClone(active.expected); mutate(expected); const before = calls.length; + assert.throws(() => p.verifyStageSubject(active.file, expected, root)); assert.equal(calls.length, before); + } +}); + +// New fixed workflow interfaces only. Provider/log bytes below are explicitly +// mocked orchestration evidence, never genuine custody or N2 acceptance. +function c1WorkflowProvider(t) { + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-workflow-provider-')); + const artifact = {run_id: 701, run_attempt: 3, artifact_id: 801, artifact_sha256: hash('opaque checked byte fixture')}; + const workflow = '.github/workflows/agentplugins-npm-publish.yml'; + const fields = {GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REPOSITORY: c.REPOSITORY, + GITHUB_SHA: selected.source, GITHUB_WORKFLOW_SHA: selected.source, GITHUB_REF: selected.ref, + GITHUB_WORKFLOW_REF: `${c.REPOSITORY}/${workflow}@${selected.ref}`, GITHUB_RUN_ID: '701', GITHUB_RUN_ATTEMPT: '3', + GITHUB_JOB: 'paired_stage_attestation'}; + for (const [key, value] of Object.entries(fields)) { + const prior = process.env[key]; process.env[key] = value; + t.after(() => {if (prior === undefined) delete process.env[key]; else process.env[key] = prior;}); + } + const run = {id: 701, run_attempt: 3, status: 'in_progress', conclusion: null, event: 'workflow_dispatch', + repository: {full_name: c.REPOSITORY}, head_repository: {full_name: c.REPOSITORY}, head_sha: selected.source, + path: workflow, head_branch: selected.tag}; + const job = {id: 901, run_id: 701, run_attempt: 3, head_sha: selected.source, head_branch: selected.tag, + name: 'paired_stage', status: 'completed', conclusion: 'success', started_at: '2026-09-09T01:00:00Z', + completed_at: '2026-09-09T01:10:00Z', steps: ['C1 preflight', 'C1 checkout', 'C1 setup', 'C1 stage', 'C1 upload', 'C1 upload evidence'] + .map((name, i) => ({name, number: i + 2, status: 'completed', conclusion: 'success'}))}; + const signing = {...job, id: 902, name: 'paired_stage_attestation', status: 'in_progress', conclusion: null}; + const jobs = {total_count: 2, jobs: [job, signing]}; + const item = {id: 801, expired: false, digest: `sha256:${artifact.artifact_sha256}`, + workflow_run: {id: 701, head_sha: selected.source}, size_in_bytes: Buffer.byteLength('opaque checked byte fixture'), + name: `authoring-public-stage-${selected.source}-701-3`, created_at: '2026-09-09T01:05:00Z'}; + const records = [ + {operation: 'start', source: selected.source, ref: selected.ref, run_id: 701, run_attempt: 3, + input_sha256: hash('I'), input_artifact: {run_id: 601, run_attempt: 2, artifact_id: 701, artifact_sha256: hash('I zip')}}, + {operation: 'pack', product: 'agentplugins', pack: {fixture_only: 'agent'}}, + {operation: 'pack', product: 'plugin-kit-ai', pack: {fixture_only: 'kit'}}, + {operation: 'completion', stage_sha256: hash('S')}, + {operation: 'upload', artifact_id: 801, artifact_sha256: artifact.artifact_sha256, stage_sha256: hash('S')}]; + const calls = []; + t.mock.method(cp, 'spawnSync', (exe, args, options) => { + assert.equal(exe, '/usr/bin/gh'); calls.push(args); + let stdout; + const endpoint = args.at(-1); + if (args[0] === '--version') stdout = `gh version ${p.GH_VERSION} (mocked)\n`; + else if (endpoint.endsWith('/zip')) stdout = Buffer.from('opaque checked byte fixture'); + else if (endpoint.endsWith('/logs')) stdout = records.map(r => `2026-09-09T01:06:00.000Z C1_STAGE ${JSON.stringify(r)}\n`).join(''); + else if (endpoint.endsWith('/jobs?per_page=100')) stdout = JSON.stringify(jobs); + else if (endpoint.includes('/attempts/')) stdout = JSON.stringify(run); + else if (endpoint.includes('/commits/')) stdout = JSON.stringify({sha: selected.source}); + else if (endpoint.endsWith('/artifacts/801')) stdout = JSON.stringify(item); + else assert.fail(`unexpected provider request ${endpoint}`); + return {status: 0, stdout}; + }); + return {scratch, artifact, workflow, run, job, signing, jobs, item, records, calls, + options: {artifact, selected, workflow_sha: selected.source, scratch}}; +} +test('C1 workflow current unsigned custody downloads exact same checked bytes; completed reader stays closed', t => { + const f = c1WorkflowProvider(t); + assert.throws(() => p.inspectArtifact(f.artifact, f.workflow, selected.source, f.scratch), /successful workflow/); + const file = p.acquireCurrentStage(f.options); + assert.deepEqual(fs.readFileSync(file), Buffer.from('opaque checked byte fixture')); + assert.equal(f.calls.filter(args => args.at(-1).endsWith('/zip')).length, 1); + assert.throws(() => p.acquireCurrentStage(f.options), /already exists/); + assert.equal(f.calls.filter(args => args.at(-1).endsWith('/zip')).length, 1); +}); +for (const defect of ['failed', 'cancelled', 'skipped', 'incomplete', 'old attempt', 'foreign run', 'foreign ref', + 'unknown caller', 'ambiguous producer', 'artifact ID', 'artifact name', 'artifact digest', 'expired', 'upload time', + 'missing log', 'duplicate pack', 'reordered packs', 'wrong upload', 'wrong completion', 'extra step', 'step failed', 'partial jobs']) { + test(`C1 workflow fixed current-stage rejects ${defect} before download`, t => { + const f = c1WorkflowProvider(t); + if (['failed', 'cancelled', 'skipped'].includes(defect)) f.job.conclusion = defect; + if (defect === 'incomplete') f.job.status = 'in_progress'; + if (defect === 'old attempt') f.run.run_attempt--; + if (defect === 'foreign run') f.options.artifact = {...f.artifact, run_id: 700}; + if (defect === 'foreign ref') f.run.head_branch = 'main'; + if (defect === 'unknown caller') process.env.GITHUB_JOB = 'publish'; + if (defect === 'ambiguous producer') {f.jobs.jobs.push({...f.job, id: 903}); f.jobs.total_count++;} + if (defect === 'artifact ID') f.item.id++; + if (defect === 'artifact name') f.item.name += '-other'; + if (defect === 'artifact digest') f.item.digest = `sha256:${hash('other')}`; + if (defect === 'expired') f.item.expired = true; + if (defect === 'upload time') f.item.created_at = '2026-09-10T00:00:00Z'; + if (defect === 'missing log') f.records.pop(); + if (defect === 'duplicate pack') f.records.splice(2, 0, f.records[1]); + if (defect === 'reordered packs') [f.records[1], f.records[2]] = [f.records[2], f.records[1]]; + if (defect === 'wrong upload') f.records[4].artifact_id++; + if (defect === 'wrong completion') f.records[4].stage_sha256 = hash('different'); + if (defect === 'extra step') f.job.steps.push({name: 'npm publish', number: 20}); + if (defect === 'step failed') f.job.steps[3].conclusion = 'failure'; + if (defect === 'partial jobs') f.jobs.total_count++; + assert.throws(() => p.acquireCurrentStage(f.options)); + assert.equal(f.calls.filter(args => args.at(-1).endsWith('/zip')).length, 0); + assert.deepEqual(fs.readdirSync(f.scratch), []); + }); +} +test('C1 workflow current-stage cannot accept completed toggle, caller root or verifier', t => { + const f = c1WorkflowProvider(t); + for (const key of ['allow_incomplete', 'authenticated', 'root', 'verify', 'producer']) { + assert.throws(() => p.acquireCurrentStage({...f.options, [key]: true})); + } + assert.equal(f.calls.length, 0); +}); +test('C1 workflow provenance signer independently requires live provider caller and successful admission', t => { + const f = c1WorkflowProvider(t); + process.env.GITHUB_JOB = 'paired_input_attestation'; + process.env.GITHUB_WORKFLOW_REF = `${c.REPOSITORY}/${p.WORKFLOW}@${selected.ref}`; + f.run.path = p.WORKFLOW; f.job.name = 'paired_input_admission'; f.signing.name = 'paired_input_attestation'; + const caller = p.inspectInputCaller(selected, selected.source, f.scratch); + assert.deepEqual(caller, {workflow: p.WORKFLOW, source: selected.source, run_id: 701, run_attempt: 3}); + f.job.conclusion = 'failure'; + assert.throws(() => p.inspectInputCaller(selected, selected.source, f.scratch)); + assert.equal(f.calls.filter(args => args.at(-1).endsWith('/zip')).length, 0); +}); diff --git a/npm/agentplugins/test/authoring-public-stage.test.js b/npm/agentplugins/test/authoring-public-stage.test.js new file mode 100644 index 00000000..e33c2d9f --- /dev/null +++ b/npm/agentplugins/test/authoring-public-stage.test.js @@ -0,0 +1,839 @@ +"use strict"; + +// Pure contracts and mocked source orchestration only. No authentic custody, +// signatures, npm pack, native launch, network or qualification is tested. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const c = require("../scripts/dual-authoring-candidate"); +const inputs = require("../scripts/authoring-native-inputs"); +const stage = require("../scripts/stage-authoring-npm"); +const runtime = require("../lib/public-authoring"); +const products = ["agentplugins", "plugin-kit-ai"]; +const targets = ["darwin-amd64", "darwin-arm64", "linux-amd64", "linux-arm64", "windows-amd64", "windows-arm64"]; +const prefix = "npm/agentplugins/"; +const sha = n => n.toString(16).padStart(64, "0"); +const json = v => Buffer.from(JSON.stringify(v, null, 2) + "\n"); +const clone = v => JSON.parse(JSON.stringify(v)); +const inventory = p => ["LICENSE", "README.md", "package.json", `bin/${p}.js`, "bin/package.json", + "lib/package.json", "lib/platform.js", "lib/verifier.js", "lib/public-authoring.js", + p === "agentplugins" ? "lib/bootstrap.js" : "lib/install.js", "scripts/package.json", + "scripts/dual-authoring-candidate.js", "public-release.json", "release-manifest.json", "native-inputs.json"].sort(); +const assertions = ["authenticated_native_inputs", "exact_preparation_binding", "exact_source_blobs", + "exact_generated_closures", "exact_pack_entries_modes_bytes", "both_products_complete", "shared_runtime_bytes_equal", + "pack_once", "inputs_unchanged", "no_native_execution", "no_publication"]; + +function blob(bytes, mode = "100644") { + return { bytes, git_blob: crypto.createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex"), + mode, sha256: c.digest(bytes) }; +} +function fixture(version = "0.1.99") { + const input = { schema: "authoring-native-inputs/v1", identity: { + repository: "777genius/universal-agent-plugins", commit: "a".repeat(40), engine_revision: "a".repeat(40), + versions: { agentplugins: version, "plugin-kit-ai": "2.0.0" } }, + authoring_mode: "release-cli-contract-v1", asset_scope: "six-platform-pair", + candidate_sha256: sha(30), pair_marker_sha256: sha(31), products: {}, + preparation: { sha256: sha(32), artifact: { run_id: 101, run_attempt: 2, artifact_id: 301, artifact_sha256: sha(33) } }, + producer: { workflow: ".github/workflows/agentplugins-release.yml", source: "a".repeat(40), run_id: 201, run_attempt: 3 } }; + const manifests = {}; + products.forEach((product, pi) => { + const v = input.identity.versions[product]; + const p = input.products[product] = { tag: pi ? "v2.0.0" : `agentplugins-v${v}`, + manifest_sha256: sha(40 + pi), checksums_sha256: sha(50 + pi), assets: {} }; + targets.forEach((target, ti) => { + const extension = target.startsWith("windows-") ? ".exe" : ""; + const binary = { file: product + extension, sha256: sha(1 + pi * 6 + ti), size: 100 + ti }; + p.assets[target] = { file: `${product}_${v}_${target.replace("-", "_")}${pi ? ".tar.gz" : extension}`, + sha256: pi ? sha(60 + ti) : binary.sha256, size: pi ? 200 + ti : binary.size, binary }; + }); + manifests[product] = json({ schema_version: 3, status: "CANDIDATE", product, + repository: input.identity.repository, tag: p.tag, version: v, commit: input.identity.commit, + engine_revision: input.identity.engine_revision, versions: clone(input.identity.versions), + candidate_sha256: input.candidate_sha256, authoring_mode: input.authoring_mode, asset_scope: input.asset_scope, + assets: clone(p.assets), release_eligible: false, platform_acceptance: false, attested: false }); + p.manifest_sha256 = c.digest(manifests[product]); + p.checksums_sha256 = c.digest(Buffer.from([...Object.values(p.assets).map(a => `${a.sha256} ${a.file}`), + `${p.manifest_sha256} release-manifest.json`].join("\n") + "\n")); + }); + const source = Object.fromEntries(stage.STAGE_ALLOWLIST.map(n => [n, blob(Buffer.from(`unit source: ${n}\n`), + n.includes("/bin/") ? "100755" : "100644")])); + for (const p of products) { + source[`npm/${p}/package.json`] = blob(json({ name: inputs.PACKAGES[p], version: "0.0.0-development", + description: `unit metadata ${p}`, license: "Apache-2.0", homepage: "https://example.invalid/unit", + repository: { type: "git", url: "https://example.invalid/unit.git" }, keywords: ["preserve", p], + engines: { node: p === "agentplugins" ? ">=22" : ">=18" }, publishConfig: { access: "public" }, + files: ["old-file"], bin: { [p]: `bin/${p}.js` }, + scripts: p === "agentplugins" ? { test: "node --test" } : { postinstall: "node ./lib/install.js" } })); + } + return { input, source, manifests, inputBytes: inputs.encodeInputs(input) }; +} +function stageFixture() { + const f = fixture(); + const pair = stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes); + const value = { schema: "dual-authoring-public-stage/v1", identity: clone(f.input.identity), + authoring_mode: f.input.authoring_mode, asset_scope: f.input.asset_scope, candidate_sha256: f.input.candidate_sha256, + pair_marker_sha256: f.input.pair_marker_sha256, + projection_pins: Object.fromEntries(products.map(p => [p, { + manifest_sha256: f.input.products[p].manifest_sha256, checksums_sha256: f.input.products[p].checksums_sha256 }])), + native_inputs: { sha256: c.digest(f.inputBytes), artifact: { run_id: 201, run_attempt: 3, + artifact_id: 401, artifact_sha256: sha(71) } }, + wrapper_blobs: Object.fromEntries(Object.entries(f.source).map(([n, { bytes, ...pin }]) => [n, pin])), + generated: Object.fromEntries(products.map(p => [p, Object.fromEntries(inventory(p).map(n => [n, c.digest(pair[p][n])]))])), + packs: Object.fromEntries(products.map((p, i) => [p, { file: `${inputs.PACKAGES[p]}-${f.input.identity.versions[p]}.tgz`, + sha256: sha(80 + i), size: 300 + i, integrity: "sha512-" + Buffer.alloc(64, i + 1).toString("base64"), + shasum: (90 + i).toString(16).padStart(40, "0") }])), + tools: Object.fromEntries(["node", "npm", "git", "tar", "gh"].map((n, i) => [n, { version: `unit-${i}.0`, sha256: sha(100 + i) }])), + producer: { workflow: ".github/workflows/agentplugins-npm-publish.yml", source: f.input.identity.commit, + ref: `refs/tags/agentplugins-v${f.input.identity.versions.agentplugins}`, run_id: 501, run_attempt: 1 }, + assertions: Object.fromEntries(assertions.map(n => [n, true])) }; + return { ...f, pair, value }; +} +function objectPaths(v, prefix = []) { + return [prefix, ...Object.entries(v).flatMap(([k, child]) => + child && typeof child === "object" ? objectPaths(child, [...prefix, k]) : [])]; +} +const at = (v, keys) => keys.reduce((obj, key) => obj[key], v); +function reverse(v) { + return v && typeof v === "object" ? Object.fromEntries(Object.entries(v).reverse().map(([k, x]) => [k, reverse(x)])) : v; +} +function rejectStage(f, mutate) { + const v = clone(f.value); mutate(v); + assert.throws(() => stage.encodeStage(v, f.inputBytes)); + assert.throws(() => stage.decodeStage(json(v), f.inputBytes)); +} + +test("C1 pure pair: exact two-product closure, metadata and identical I/runtime bytes", () => { + const f = fixture(), pair = stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes); + assert.deepEqual(Object.keys(pair), products); + for (const p of products) { + assert.deepEqual(Object.keys(pair[p]).sort(), inventory(p)); + const pkg = JSON.parse(pair[p]["package.json"]), base = JSON.parse(f.source[`npm/${p}/package.json`].bytes); + assert.deepEqual(pkg, { ...base, version: f.input.identity.versions[p], private: false, files: inventory(p) }); + const d = inputs.decodeDescriptor(pair[p]["public-release.json"], f.inputBytes, p); + assert.equal(d.input_binding.sha256, c.digest(f.inputBytes)); + assert.deepEqual(pair[p]["release-manifest.json"], f.manifests[p]); + assert.deepEqual(pair[p]["native-inputs.json"], f.inputBytes); + for (const n of ["LICENSE", "README.md", `bin/${p}.js`, "lib/platform.js", + p === "agentplugins" ? "lib/bootstrap.js" : "lib/install.js"]) { + assert.deepEqual(pair[p][n], f.source[`npm/${p}/${n}`].bytes); + } + for (const dir of ["bin", "lib", "scripts"]) assert.deepEqual(pair[p][`${dir}/package.json`], json({ type: "commonjs" })); + for (const claim of ["qualification", "signed_subject", "self_sha256", "stage_sha256", "execution_sha256"]) { + assert.equal(Object.hasOwn(d, claim), false); + assert.equal(Object.hasOwn(JSON.parse(pair[p]["native-inputs.json"]), claim), false); + } + } + for (const n of [...stage.COMMON, "native-inputs.json"]) { + assert.deepEqual(pair.agentplugins[n], pair["plugin-kit-ai"][n]); + assert.notStrictEqual(pair.agentplugins[n], pair["plugin-kit-ai"][n]); + } +}); + +test("C1 pure pair: caller bytes and objects unchanged and returned buffers independently owned", () => { + const f = fixture(), before = json(f); + const pair = stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes); + assert.deepEqual(json(f), before); + for (const p of products) for (const bytes of Object.values(pair[p])) bytes.fill(0); + assert.deepEqual(json(f), before); + const fresh = stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes); + assert.deepEqual(fresh.agentplugins["native-inputs.json"], f.inputBytes); +}); + +test("C1 pure pair: validate both manifests, selected hashes, assets and checksum pins", () => { + for (const p of products) { + for (const mutate of [m => m.product = "wrong", m => m.commit = "b".repeat(40), m => m.version = "2.0.1", + m => m.assets["linux-amd64"].binary.sha256 = sha(500), m => m.release_eligible = true, + m => m.qualification = null, m => delete m.attested]) { + const f = fixture(), m = JSON.parse(f.manifests[p]); mutate(m); f.manifests[p] = json(m); + f.input.products[p].manifest_sha256 = c.digest(f.manifests[p]); + assert.throws(() => stage.pairedPackageFiles(f.source, f.manifests, inputs.encodeInputs(f.input))); + } + for (const key of ["manifest_sha256", "checksums_sha256"]) { + const f = fixture(); f.input.products[p][key] = sha(500); + assert.throws(() => stage.pairedPackageFiles(f.source, f.manifests, inputs.encodeInputs(f.input)), /hash/); + } + for (const replacement of [null, "{}", new Uint8Array([123, 125]), Buffer.alloc(0), Buffer.alloc(1024 * 1024 + 1)]) { + const f = fixture(); f.manifests[p] = replacement; + assert.throws(() => stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes), /Buffer/); + } + const f = fixture(); f.manifests[p] = Buffer.from(f.manifests[p].toString().trim()); + f.input.products[p].manifest_sha256 = c.digest(f.manifests[p]); + assert.throws(() => stage.pairedPackageFiles(f.source, f.manifests, inputs.encodeInputs(f.input)), /projection/); + } + for (const field of ["agentplugins", "plugin-kit-ai", "extra"]) { + const f = fixture(); + if (field === "extra") f.manifests.extra = Buffer.from("extra"); else delete f.manifests[field]; + assert.throws(() => stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes)); + } +}); + +test("C1 pure pair: exact source inventory and byte/pin/metadata contracts", () => { + for (const name of stage.STAGE_ALLOWLIST) { + const f = fixture(); delete f.source[name]; + assert.throws(() => stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes)); + } + const path = "npm/plugin-kit-ai/package.json"; + for (const mutate of [s => s.extra = s[path], s => s[path].sha256 = sha(900), + s => s[path].git_blob = "b".repeat(40), s => s[path].mode = "120000", s => s[path].bytes = "{}", + s => s[path].bytes = new Uint8Array([1]), s => s[path].bytes = Buffer.alloc(0), + s => s[path].accepted = true]) { + const f = fixture(); mutate(f.source); + assert.throws(() => stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes)); + } + for (const p of products) for (const mutate of [b => b.name = "other", b => b.engines.node = ">=16", + b => b.scripts.postinstall = "run something", b => b.bin[p] = "wrong.js", b => b.bin.other = "bin/other.js"]) { + const f = fixture(), n = `npm/${p}/package.json`, base = JSON.parse(f.source[n].bytes); mutate(base); + f.source[n] = blob(json(base)); + assert.throws(() => stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes)); + } + for (const body of [Buffer.from("null\n"), Buffer.from("[]\n"), Buffer.from([0xff]), Buffer.from("{broken")]) { + const f = fixture(); f.source[path] = blob(body); + assert.throws(() => stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes)); + } +}); + +test("C1 pure pair: valid 1 MiB I still fails descriptor overflow before touching source", () => { + const base = fixture("1.0.0").inputBytes.length; + const n = Math.floor((inputs.MAX_INPUT_BYTES - base) / 8); + const f = fixture("1" + "0".repeat(n) + ".0.0"); + const remaining = inputs.MAX_INPUT_BYTES - f.inputBytes.length; + f.input.preparation.artifact.run_id = Number("1" + "0".repeat(2 + remaining)); + f.inputBytes = inputs.encodeInputs(f.input); + assert.equal(f.inputBytes.length, inputs.MAX_INPUT_BYTES); + assert.deepEqual(inputs.decodeInputs(f.inputBytes), f.input); + let reads = 0; + const source = new Proxy({}, { ownKeys() { reads++; throw new Error("source touched"); } }); + assert.throws(() => stage.pairedPackageFiles(source, f.manifests, f.inputBytes), /bounded Buffer/); + assert.equal(reads, 0); +}); + +test("C1 pure pair: both descriptor boundaries, including a pair where only agent overflows", () => { + // Agent's npm package spelling makes its descriptor nine bytes larger. + const f = fixture("1.0.0"), small = stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes); + const overhead = small.agentplugins["public-release.json"].length; + const large = fixture("1" + "0".repeat(inputs.MAX_DESCRIPTOR_BYTES - overhead) + ".0.0"); + const d = JSON.parse(small.agentplugins["public-release.json"]); + d.identity = large.input.identity; d.release_manifest_sha256 = large.input.products.agentplugins.manifest_sha256; + d.input_binding.sha256 = c.digest(large.inputBytes); + assert.equal(inputs.encodeDescriptor(d, large.inputBytes, "agentplugins").length, inputs.MAX_DESCRIPTOR_BYTES); + const kit = JSON.parse(small["plugin-kit-ai"]["public-release.json"]); + kit.identity = large.input.identity; kit.release_manifest_sha256 = large.input.products["plugin-kit-ai"].manifest_sha256; + kit.input_binding.sha256 = c.digest(large.inputBytes); + assert.equal(inputs.encodeDescriptor(kit, large.inputBytes, "plugin-kit-ai").length, inputs.MAX_DESCRIPTOR_BYTES - 9); + const pair = stage.pairedPackageFiles(large.source, large.manifests, large.inputBytes); + assert.equal(pair.agentplugins["public-release.json"].length, inputs.MAX_DESCRIPTOR_BYTES); + assert.equal(pair["plugin-kit-ai"]["public-release.json"].length, inputs.MAX_DESCRIPTOR_BYTES - 9); + const over = fixture(large.input.identity.versions.agentplugins.replace("1", "10")); + kit.identity = over.input.identity; kit.release_manifest_sha256 = over.input.products["plugin-kit-ai"].manifest_sha256; + kit.input_binding.sha256 = c.digest(over.inputBytes); + assert.equal(inputs.encodeDescriptor(kit, over.inputBytes, "plugin-kit-ai").length, inputs.MAX_DESCRIPTOR_BYTES - 8); + assert.throws(() => stage.pairedPackageFiles(over.source, over.manifests, over.inputBytes), /bounded Buffer/); +}); + +test("C1 pure S: canonical two-product roundtrip, explicit inventory and no mutation", () => { + const f = stageFixture(), before = json(f.value), encoded = stage.encodeStage(f.value, f.inputBytes); + assert.deepEqual(encoded, before); + assert.deepEqual(stage.decodeStage(encoded, f.inputBytes), f.value); + assert.deepEqual(stage.encodeStage(reverse(f.value), f.inputBytes), encoded); + assert.throws(() => stage.decodeStage(json(reverse(f.value)), f.inputBytes), /noncanonical/); + assert.deepEqual(Object.keys(f.value), ["schema", "identity", "authoring_mode", "asset_scope", "candidate_sha256", + "pair_marker_sha256", "projection_pins", "native_inputs", "wrapper_blobs", "generated", "packs", "tools", "producer", "assertions"]); + assert.deepEqual(Object.keys(f.value.assertions), assertions); + const decoded = stage.decodeStage(encoded, f.inputBytes); decoded.identity.versions.agentplugins = "9.0.0"; + assert.deepEqual(json(f.value), before); + assert.deepEqual(stage.decodeStage(encoded, f.inputBytes), f.value); +}); + +test("C1 pure S: every object rejects missing, unknown, hidden, inherited and accessor fields", () => { + const f = stageFixture(); + for (const path of objectPaths(f.value)) { + for (const key of Object.keys(at(f.value, path))) rejectStage(f, v => { delete at(v, path)[key]; }); + rejectStage(f, v => { at(v, path).unexpected = true; }); + for (const value of [null, [], "object", 1, false]) { + if (path.length) rejectStage(f, v => { at(v, path.slice(0, -1))[path.at(-1)] = value; }); + else assert.throws(() => stage.encodeStage(value, f.inputBytes)); + } + for (const add of [o => Object.defineProperty(o, "trusted", { value: true }), + o => o[Symbol("trusted")] = true, o => Object.setPrototypeOf(o, { accepted: true })]) { + const v = clone(f.value); add(at(v, path)); assert.throws(() => stage.encodeStage(v, f.inputBytes)); + } + const v = clone(f.value), o = at(v, path), key = Object.keys(o)[0]; + let calls = 0; + Object.defineProperty(o, key, { enumerable: true, get() { calls++; return true; } }); + assert.throws(() => stage.encodeStage(v, f.inputBytes)); assert.equal(calls, 0); + } +}); + +test("C1 pure S: fixed I identity, projection, artifact attempt, workflow/source/ref bindings", () => { + const f = stageFixture(); + for (const mutate of [v => v.schema = "dual-authoring-public-preparation/v1", v => v.authoring_mode = "vertical-slice-v1", + v => v.asset_scope = "host-pair", v => v.identity.repository = "fork/repo", v => v.identity.commit = "b".repeat(40), + v => v.identity.engine_revision = "b".repeat(40), v => v.identity.versions.agentplugins = "0.1.98", + v => v.identity.versions["plugin-kit-ai"] = "2.0.1", v => v.candidate_sha256 = sha(999), v => v.pair_marker_sha256 = sha(999), + v => v.native_inputs.sha256 = sha(999), v => v.native_inputs.artifact.run_id++, v => v.native_inputs.artifact.run_attempt++, + v => v.producer.workflow = inputs.WORKFLOW, v => v.producer.source = "b".repeat(40), + v => v.producer.ref = "refs/heads/main", v => v.producer.ref = "refs/tags/v2.0.0"]) rejectStage(f, mutate); + for (const p of products) for (const k of ["manifest_sha256", "checksums_sha256"]) { + rejectStage(f, v => { v.projection_pins[p][k] = sha(999); }); + } + for (const mutate of [i => i.preparation.sha256 = sha(999), i => i.preparation.artifact.artifact_id++, + i => i.products["plugin-kit-ai"].checksums_sha256 = sha(999), i => i.pair_marker_sha256 = sha(999), + i => i.producer.run_attempt++]) { + const input = clone(f.input); mutate(input); const bytes = inputs.encodeInputs(input); + assert.throws(() => stage.encodeStage(f.value, bytes)); + assert.throws(() => stage.decodeStage(json(f.value), bytes)); + } + for (const bad of [null, f.input, f.inputBytes.toString(), json(reverse(f.input)), Buffer.alloc(0)]) { + assert.throws(() => stage.encodeStage(f.value, bad)); + assert.throws(() => stage.decodeStage(json(f.value), bad)); + assert.throws(() => stage.pairedPackageFiles(f.source, f.manifests, bad)); + } +}); + +test("C1 pure S: exact generated set and source/I/manifest/descriptor/shared scope digests", () => { + const f = stageFixture(); + for (const p of products) { + for (const n of inventory(p).filter(n => n !== "package.json")) rejectStage(f, v => { v.generated[p][n] = sha(999); }); + for (const n of ["../escape", "qualification.json", "authoring-promotion.json", "completion.json", "extra"]) { + rejectStage(f, v => { v.generated[p][n] = sha(999); }); + } + rejectStage(f, v => { v.generated[p]["public-release.json"] = f.value.generated[products.find(x => x !== p)]["public-release.json"]; }); + } + rejectStage(f, v => { v.wrapper_blobs[prefix + "lib/verifier.js"].sha256 = sha(999); }); + rejectStage(f, v => { v.wrapper_blobs[prefix + "lib/verifier.js"].mode = "120000"; }); +}); + +test("C1 pure S: digest syntax, positive safe IDs, attempts, tarball names and canonical SRI", () => { + const f = stageFixture(); + const digestPaths = objectPaths(f.value).flatMap(p => Object.keys(at(f.value, p)) + .filter(k => k.endsWith("sha256") || k === "git_blob" || k === "shasum").map(k => [...p, k])); + for (const path of digestPaths) { + const n = at(f.value, path).length; + for (const bad of [null, 1, "0".repeat(n), "A".repeat(n), "f".repeat(n - 1), "f".repeat(n) + "\n"]) { + rejectStage(f, v => { at(v, path.slice(0, -1))[path.at(-1)] = bad; }); + } + } + for (const path of [["producer", "run_id"], ["producer", "run_attempt"], ["native_inputs", "artifact", "artifact_id"], + ...products.map(p => ["packs", p, "size"])]) { + for (const bad of [0, -1, 1.5, "1", true, null, Number.MAX_SAFE_INTEGER + 1]) { + rejectStage(f, v => { at(v, path.slice(0, -1))[path.at(-1)] = bad; }); + } + } + rejectStage(f, v => v.producer.run_attempt = 1001); + for (const p of products) { + rejectStage(f, v => v.packs[p].size = inputs.MAX_NATIVE_BYTES + 1); + for (const file of ["../package.tgz", `${p}-9.0.0.tgz`, "package.tar.gz", null]) rejectStage(f, v => v.packs[p].file = file); + const good = f.value.packs[p].integrity; + for (const integrity of [null, "sha256-" + good.slice(7), good + "\n", good.slice(0, -1), good + " sha512-other", + "sha512-" + "A".repeat(85) + "B=="]) rejectStage(f, v => v.packs[p].integrity = integrity); + } + for (const path of [["producer", "run_id"], ["native_inputs", "artifact", "artifact_id"]]) { + const v = clone(f.value); at(v, path.slice(0, -1))[path.at(-1)] = Number.MAX_SAFE_INTEGER; + assert.deepEqual(stage.decodeStage(stage.encodeStage(v, f.inputBytes), f.inputBytes), v); + } + const v = clone(f.value); v.producer.run_attempt = 1000; + assert.deepEqual(stage.decodeStage(stage.encodeStage(v, f.inputBytes), f.inputBytes), v); +}); + +test("C1 pure S: closed tools and assertions; syntax does not establish authenticated truth", () => { + const f = stageFixture(); + for (const name of ["node", "npm", "git", "tar", "gh"]) { + for (const version of [null, 123, "", " ", "version\n", "a\0b"]) rejectStage(f, v => v.tools[name].version = version); + rejectStage(f, v => v.tools[name].path = "/usr/bin/tool"); + } + for (const name of assertions) for (const bad of [false, null, 1, "true"]) rejectStage(f, v => v.assertions[name] = bad); + for (const name of ["qualification", "attested", "release_eligible", "platform_acceptance", "execution", "self_sha256", + "artifact", "workflow", "verifier", "authenticatedInputs"]) rejectStage(f, v => v[name] = true); + // No bytes here establish these unobservable assertions or hash claims. A + // different well-formed pack digest can pass this codec; only readStage with + // retained packs/authenticated custody may validate it in subsequent C1. + const v = clone(f.value); v.packs.agentplugins.sha256 = sha(999); + assert.deepEqual(stage.decodeStage(stage.encodeStage(v, f.inputBytes), f.inputBytes), v); + assert.equal(typeof stage.readStage, "function"); + assert.equal(typeof stage.stagePrepublication, "function"); +}); + +test("C1 pure S: canonical UTF-8, duplicate keys, nesting, spelling and exact 1 MiB boundary", () => { + const f = stageFixture(), body = stage.encodeStage(f.value, f.inputBytes), text = body.toString(); + for (const bad of [text, new Uint8Array(body), null, Buffer.alloc(0), Buffer.alloc(1024 * 1024 + 1), + Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), body]), Buffer.concat([body, Buffer.from([0xff])]), + Buffer.from(text.trim()), Buffer.from(text + "\n"), Buffer.from(text + "{}"), + Buffer.from(text.replace('"schema":', '"schema": null, "schema":')), + Buffer.from(text.replace('"schema"', '"sch\\u0065ma"')), + Buffer.from(text.replace('"run_id": 501', '"run_id": 5.01e2')), + Buffer.from(text.replaceAll("\n", "\r\n")), Buffer.from('{"x":'.repeat(5) + '0' + '}'.repeat(5))]) { + assert.throws(() => stage.decodeStage(bad, f.inputBytes)); + } + const v = clone(f.value); + v.tools.node.version += "x".repeat(1024 * 1024 - body.length); + const exact = stage.encodeStage(v, f.inputBytes); assert.equal(exact.length, 1024 * 1024); + assert.deepEqual(stage.decodeStage(exact, f.inputBytes), v); + v.tools.node.version += "x"; + assert.throws(() => stage.encodeStage(v, f.inputBytes), /bounded Buffer/); + assert.throws(() => stage.decodeStage(json(v), f.inputBytes), /bounded Buffer/); +}); + +test("C1 pure v1 regression: same descriptor, metadata, null/private and all other bytes", () => { + const f = fixture(), pair = stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes); + for (const p of products) { + const v1 = stage.packageFiles(p, f.source, f.manifests[p], { identity: f.input.identity, manifestDigest: f.input.candidate_sha256 }); + assert.deepEqual(Object.keys(v1).sort(), inventory(p).filter(n => n !== "native-inputs.json")); + assert.deepEqual(v1["public-release.json"], json({ schema: "dual-authoring-public-npm/v1", product: p, + npm_package: inputs.PACKAGES[p], identity: f.input.identity, authoring_mode: "release-cli-contract-v1", + asset_scope: "six-platform-pair", candidate_sha256: f.input.candidate_sha256, + release_manifest_sha256: c.digest(f.manifests[p]), qualification: null })); + const base = JSON.parse(f.source[`npm/${p}/package.json`].bytes); + assert.deepEqual(v1["package.json"], json({ ...base, version: f.input.identity.versions[p], private: true, + files: inventory(p).filter(n => n !== "native-inputs.json") })); + for (const n of Object.keys(v1).filter(n => !["package.json", "public-release.json"].includes(n))) assert.deepEqual(v1[n], pair[p][n]); + } +}); + +test("C1 pure inventories: separate exact stage additions and unchanged legacy exports", () => { + const own = p => ["LICENSE", "README.md", "package.json", `bin/${p}.js`, "lib/platform.js", + p === "agentplugins" ? "lib/bootstrap.js" : "lib/install.js"].map(n => `npm/${p}/${n}`); + assert.deepEqual(stage.COMMON, ["lib/verifier.js", "lib/public-authoring.js", "scripts/dual-authoring-candidate.js"]); + const legacy = [...stage.COMMON.map(n => prefix + n), ...products.flatMap(own), + ...["stage-authoring-npm.js", "stage-dual-authoring-npm.js", "stage-dual-authoring-candidate.js", "authoring-release.js"] + .map(n => prefix + "scripts/" + n)]; + assert.deepEqual(stage.ALLOWLIST, legacy); + assert.deepEqual(stage.STAGE_ALLOWLIST, [...legacy, + ...["authoring-native-inputs.js", "authoring-promotion.js", "authoring-native-qualification.js", "platform-proof.js", + "npm-public-contract.js"].map(n => prefix + "scripts/" + n), "scripts/read-authoring-evidence-zip.py", + ".github/workflows/agentplugins-release.yml", ".github/workflows/agentplugins-npm-publish.yml"]); + assert.ok(Object.isFrozen(stage.STAGE_ALLOWLIST)); + assert.deepEqual(Object.keys(stage), ["prepare", "packageFiles", "ALLOWLIST", "COMMON", + "encodeStage", "decodeStage", "pairedPackageFiles", "STAGE_ALLOWLIST", "stagePrepublication", "readStage", "validateUnsignedStage", "main"]); +}); + +test("C1 pure runtime regression: existing loadRelease rejects v2 and v1/null using only in-memory reads", t => { + const f = fixture(), pair = stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes); + let files, reads; + t.mock.method(c, "safeDirectory", root => root); + t.mock.method(c, "readFile", file => { + const name = file.slice("/unit-package/".length); reads.push(name); + assert.ok(Object.hasOwn(files, name), `unexpected read ${file}`); return files[name]; + }); + for (const p of products) { + files = pair[p]; reads = []; + assert.throws(() => runtime.loadRelease(p, "/unit-package", "linux-amd64"), /public release: unexpected or missing fields/); + assert.deepEqual(reads, ["public-release.json"]); + // Real metadata contract for v1 is closed; supply its required fixture keys. + files = stage.packageFiles(p, f.source, f.manifests[p], { identity: f.input.identity, manifestDigest: f.input.candidate_sha256 }); + const pkg = JSON.parse(files["package.json"]); pkg.bugs = { url: "https://example.invalid/unit" }; + if (p === "agentplugins") { pkg.os = ["darwin", "linux", "win32"]; pkg.cpu = ["x64", "arm64"]; } + files["package.json"] = json(pkg); reads = []; + assert.throws(() => runtime.loadRelease(p, "/unit-package", "linux-amd64"), /not qualified: preparation package/); + assert.deepEqual(reads, ["public-release.json", "package.json", "release-manifest.json"]); + } +}); + +// SOURCE orchestration fixtures: every acquisition/signature/pack/tool seam is +// mocked. Only fresh os.tmpdir roots receive files; none is authentic admission. +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const cp = require("node:child_process"); +const Module = require("node:module"); +const packing = require("../scripts/stage-dual-authoring-npm"); +const promotion = require("../scripts/authoring-promotion"); +function fixtureEnv(t, name, value) { + const prior = process.env[name]; process.env[name] = value; + t.after(() => { if (prior === undefined) delete process.env[name]; else process.env[name] = prior; }); +} +function integrationFixture(t, hook = () => {}) { + const f = fixture(), base = fs.mkdtempSync(path.join(os.tmpdir(), "c1-stage-integration-")); + const paths = Object.fromEntries(["repo", "scratch", "tools", "incoming"].map(n => { + const dir = path.join(base, n); fs.mkdirSync(dir); return [n, dir]; + })); + const put = (root, n, b, mode = 0o644) => { + const file = path.join(root, n); fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, b, { mode }); return file; + }; + const fixedFiles = { "native-inputs.json": f.inputBytes, "preparation-run.json": Buffer.from("receipt fixture"), + "candidate-identity.json": Buffer.from("metadata fixture"), "candidate/candidate.json": Buffer.from("candidate fixture"), + "pair-prepared.json": Buffer.from("pair fixture") }; + for (const p of products) { + fixedFiles[`${p}/release-manifest.json`] = f.manifests[p]; + fixedFiles[`${p}/checksums.txt`] = Buffer.from("checksum fixture"); + for (const a of Object.values(f.input.products[p].assets)) fixedFiles[`${p}/${a.file}`] = Buffer.from(`NOT NATIVE: ${p}/${a.file}`); + } + for (const [n, b] of Object.entries(fixedFiles)) put(paths.incoming, n, b); + const calls = [], options = { input: Buffer.from(f.inputBytes), selected: { tag: f.input.products.agentplugins.tag, + ref: `refs/tags/${f.input.products.agentplugins.tag}`, source: f.input.identity.commit, versions: clone(f.input.identity.versions) }, + workflow_sha: f.input.identity.commit, artifact: { run_id: 201, run_attempt: 3, artifact_id: 401, artifact_sha256: sha(71) }, + repo: paths.repo, workParent: paths.scratch, node: put(paths.tools, "node", Buffer.from("node fixture")), + npm: put(paths.tools, "npm", Buffer.from("npm fixture")), output: path.join(base, "output"), + producer: { workflow: ".github/workflows/agentplugins-npm-publish.yml", source: f.input.identity.commit, + ref: `refs/tags/${f.input.products.agentplugins.tag}`, run_id: 501, run_attempt: 4 } }; + for (const [k, v] of Object.entries({ GITHUB_ACTIONS: "true", GITHUB_REPOSITORY: c.REPOSITORY, + GITHUB_SHA: options.producer.source, GITHUB_REF: options.producer.ref, GITHUB_RUN_ID: "501", GITHUB_RUN_ATTEMPT: "4", + GITHUB_WORKFLOW_SHA: options.producer.source, + GITHUB_WORKFLOW_REF: `${c.REPOSITORY}/${options.producer.workflow}@${options.producer.ref}` })) fixtureEnv(t, k, v); + const originalRead = c.readFile; + const fixedTools = new Set(["/usr/bin/git", "/usr/bin/tar", "/usr/bin/gh", fs.realpathSync("/usr/bin/python3"), process.execPath]); + t.mock.method(c, "readFile", (file, max) => fixedTools.has(file) ? Buffer.from(`tool fixture: ${file}`) : originalRead(file, max)); + t.mock.method(cp, "execFileSync", (exe, args) => { + calls.push(["tool", exe, args]); assert.deepEqual(args.at(-1), "--version"); + return Buffer.from(exe === "/usr/bin/gh" ? `gh version ${promotion.GH_VERSION} (fixture)\n` : "fixture version\n"); + }); + t.mock.method(cp, "spawnSync", () => { throw new Error("unexpected external process"); }); + const event = (name, data) => { calls.push([name, data]); hook(name, data, { f, paths, options, calls, put }); }; + t.mock.method(packing, "blobs", (repo, commit, env, closure) => { + assert.equal(repo, paths.repo); assert.equal(commit, f.input.identity.commit); assert.equal(closure, "stage"); + assert.equal(env.PATH, "/usr/local/bin:/usr/bin:/bin"); event("blobs"); + return Object.fromEntries(Object.entries(f.source).map(([n, pin]) => [n, { ...pin, bytes: Buffer.from(pin.bytes) }])); + }); + t.mock.method(promotion, "checkInputTags", body => { assert.deepEqual(body, f.inputBytes); event("tags"); }); + t.mock.method(promotion, "inspectArtifact", (pin, workflow, source) => { + assert.equal(source, f.input.identity.commit); event("inspect", { pin, workflow }); return clone({ pin, workflow, source }); + }); + t.mock.method(promotion, "inspectStageCaller", () => { event("stage-caller"); return clone(options.producer); }); + t.mock.method(promotion, "checkStageEvidence", () => { event("stage-evidence"); return {fixture_only: "ordered provider transcript"}; }); + t.mock.method(promotion, "inspectCurrentStage", o => { event("current-custody", o); return {fixture_only: clone(o.artifact)}; }); + t.mock.method(promotion, "acquireCurrentStage", o => { + event("acquire-current", o); return put(o.scratch, `artifact-${o.artifact.artifact_id}.zip`, Buffer.from("checked current ZIP fixture")); + }); + t.mock.method(promotion, "acquireArtifact", (pin, workflow, source, cwd) => { + assert.equal(workflow, options.producer.workflow); assert.equal(source, f.input.identity.commit); + event("acquire-stage", pin); return put(cwd, `artifact-${pin.artifact_id}.zip`, Buffer.from("checked ZIP interface fixture")); + }); + t.mock.method(promotion, "extractArtifact", (file, pin, kind, names, output, cwd) => { + assert.equal(file, path.join(cwd, `artifact-${pin.artifact_id}.zip`)); assert.equal(kind, "public-stage"); + assert.deepEqual(names, ["completion.json", ...products.map(p => `${inputs.PACKAGES[p]}-${f.input.identity.versions[p]}.tgz`)]); + fs.mkdirSync(output); for (const n of names) put(output, n, fs.readFileSync(path.join(options.output, n))); + event("extract-stage", output); return output; + }); + t.mock.method(promotion, "verifyStageSubject", (file, expected) => { + assert.equal(expected.workflow_sha, f.input.identity.commit); assert.equal(expected.source, f.input.identity.commit); + assert.equal(expected.ref, options.producer.ref); assert.equal(expected.run_id, 501); assert.equal(expected.run_attempt, 4); + assert.equal(expected.subjects.length, 3); assert.equal(c.digest(originalRead(file)), expected.sha256); + event("signer", { file, expected }); + }); + const adapter = { ...inputs, readInputs(o) { + assert.deepEqual(o.input, f.inputBytes); assert.deepEqual(o.artifact, options.artifact); + assert.deepEqual(o.selected, options.selected); assert.equal(o.workflow_sha, f.input.identity.commit); + event("read-I", o); return { root: paths.incoming, input: inputs.decodeInputs(o.input), subjects: [] }; + }, inputSubjects(root, body) { + assert.deepEqual(body, f.inputBytes); + for (const [n, b] of Object.entries(fixedFiles)) assert.deepEqual(originalRead(path.join(root, n)), b, `input fixture changed: ${n}`); + event("input-snapshot", root); + return Object.keys(fixedFiles).filter(n => !["preparation-run.json", "candidate-identity.json"].includes(n)) + .map(n => ({ file: path.join(root, n), sha256: c.digest(fixedFiles[n]) })); + } }; + // Override existing external imports in a test-only module instance. Production + // exports/options contain no injection API; the accepted frozen I exports stay frozen. + const filename = require.resolve("../scripts/stage-authoring-npm"), loaded = new Module(filename, module); + loaded.filename = filename; loaded.paths = Module._nodeModulePaths(path.dirname(filename)); + const normalRequire = loaded.require.bind(loaded); + loaded.require = name => name === "./authoring-native-inputs" ? adapter : normalRequire(name); + loaded._compile(fs.readFileSync(filename, "utf8"), filename); + const api = loaded.exports, pair = stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes); + t.mock.method(packing, "packPackage", (product, files, root, o, context) => { + assert.deepEqual(files, pair[product]); assert.equal(o.node, options.node); assert.equal(o.npm, options.npm); + assert.equal(context.env.npm_config_ignore_scripts, "true"); assert.equal(context.env.npm_config_offline, "true"); + const body = Buffer.from(`RETAINED PACK FIXTURE: ${product}`), file = `${inputs.PACKAGES[product]}-${f.input.identity.versions[product]}.tgz`; + put(o.output, file, body); event("pack", { product, files, root }); + return { file, ...c.metadata(body), integrity: "sha512-" + crypto.createHash("sha512").update(body).digest("base64") }; + }); + t.mock.method(packing, "verifyPack", (file, files, dest) => { + const p = file.includes("universal-agent-plugins-") ? "agentplugins" : "plugin-kit-ai"; + assert.deepEqual(files, pair[p]); assert.ok(dest.startsWith(paths.scratch + path.sep)); event("verify-pack", { file, files, dest }); + }); + const readOptions = () => ({ input: Buffer.from(f.inputBytes), selected: clone(options.selected), workflow_sha: options.workflow_sha, + artifact: { run_id: 501, run_attempt: 4, artifact_id: 601, artifact_sha256: sha(80) }, repo: paths.repo, + workParent: paths.scratch, node: options.node, npm: options.npm, + stage_sha256: c.digest(fs.readFileSync(path.join(options.output, "completion.json"))) }); + return { ...f, base, paths, options, api, calls, put, readOptions, pair }; +} + +test("C1 stage integration producer and reader agree on I, three subjects and retained SHA1 without reader repack", t => { + const f = integrationFixture(t), record = f.api.stagePrepublication(f.options); + assert.deepEqual(stage.decodeStage(fs.readFileSync(path.join(f.options.output, "completion.json")), f.inputBytes), record); + assert.equal(f.calls.filter(x => x[0] === "pack").length, 2); + assert.deepEqual(f.calls.filter(x => x[0] === "pack").map(x => x[1].product), products); + assert.ok(f.calls.findIndex(x => x[0] === "read-I") < f.calls.findIndex(x => x[0] === "pack")); + for (const p of products) { + const bytes = fs.readFileSync(path.join(f.options.output, record.packs[p].file)); + assert.equal(record.packs[p].shasum, crypto.createHash("sha1").update(bytes).digest("hex")); + assert.equal(record.packs[p].sha256, c.digest(bytes)); assert.equal(record.packs[p].size, bytes.length); + } + const result = f.api.readStage(f.readOptions()); assert.deepEqual(result.record, record); + assert.equal(result.subjects.length, 3); assert.equal(f.calls.filter(x => x[0] === "signer").length, 3); + assert.equal(f.calls.filter(x => x[0] === "read-I").length, 2); + assert.equal(f.calls.filter(x => x[0] === "pack").length, 2); + const signer = f.calls.findIndex(x => x[0] === "signer"), readerI = f.calls.findLastIndex(x => x[0] === "read-I"); + assert.ok(signer < readerI); +}); + +test("C1 stage integration malformed options fail before scratch, authentication or packing", t => { + const f = integrationFixture(t); + for (const mutate of [o => o.trusted = true, o => o.input = null, o => o.input = Buffer.from("{}\n"), + o => o.selected.source = "b".repeat(40), o => o.selected.versions.agentplugins = "9.0.0", + o => o.selected.ref = "refs/heads/main", o => o.workflow_sha = "b".repeat(40), + o => o.artifact.run_attempt++, o => o.artifact.artifact_sha256 = "0".repeat(64), + o => o.producer.workflow = inputs.WORKFLOW, o => o.producer.source = "b".repeat(40), + o => o.producer.ref = "refs/tags/v2.0.0", o => o.producer.run_id = 201, o => o.producer.run_attempt = 1001, + o => o.output = o.repo, o => o.workParent = o.repo, o => o.node = "relative", o => o.verifier = () => true]) { + const o = { ...clone(f.options), input: Buffer.from(f.inputBytes) }; mutate(o); + assert.throws(() => f.api.stagePrepublication(o)); assert.equal(f.calls.length, 0); + assert.deepEqual(fs.readdirSync(f.paths.scratch), []); assert.equal(fs.existsSync(f.options.output), false); + } + for (const mutate of [o => o.stage_sha256 = null, o => o.artifact.run_attempt = 1001, o => o.selected.ref = "refs/heads/main", + o => o.authenticated = true]) { + const { output, producer, ...o } = { ...clone(f.options), input: Buffer.from(f.inputBytes), stage_sha256: sha(92) }; + mutate(o); assert.throws(() => f.api.readStage(o)); assert.equal(f.calls.length, 0); + } +}); + +test("C1 stage integration producer binds actual workflow caller before effects", t => { + const f = integrationFixture(t); fixtureEnv(t, "GITHUB_WORKFLOW_SHA", "b".repeat(40)); + assert.throws(() => f.api.stagePrepublication(f.options), /workflow caller/); assert.equal(f.calls.length, 0); +}); + +for (const defect of ["auth", "source-before", "source-after", "input", "I", "snapshot", "tool", "caller", "arguments", "half-pair", + "second-modified", "first-late", "verified-late", "generated-late", "generated", "generated-mode", "generated-extra", "provider", "collision"]) { + test(`C1 stage integration producer rejects ${defect} with no new accepted S`, t => { + let fired = false, blobs = 0; + const f = integrationFixture(t, (event, data, state) => { + const { options, paths, put } = state; + if (event === "blobs") blobs++; + if (defect === "auth" && event === "read-I") throw new Error("fixture authentication rejection"); + if ((defect === "source-before" && event === "blobs" && blobs === 1) || + (defect === "source-after" && event === "blobs" && blobs === 3)) throw new Error("source closure changed fixture"); + if (event === "pack" && data.product === "plugin-kit-ai" && !fired) { + fired = true; + if (defect === "input") put(paths.incoming, "pair-prepared.json", Buffer.from("changed input")); + if (defect === "I") put(paths.incoming, "native-inputs.json", Buffer.from("changed I")); + if (defect === "snapshot") { + const snap = state.calls.find(x => x[0] === "input-snapshot" && x[1].endsWith("stage-inputs"))[1]; + // Do not overwrite sealed fixture files: introduce an unexpected entry; + // the mocked existing input interface detects it in the hook below. + put(snap, "unexpected", Buffer.from("changed snapshot")); + } + if (defect === "tool") fs.appendFileSync(options.npm, "changed"); + if (defect === "caller") options.selected.source = "b".repeat(40); + if (defect === "arguments") { + const prior = process.execArgv; process.execArgv = ["--changed-fixture"]; t.after(() => { process.execArgv = prior; }); + } + if (defect === "half-pair") throw new Error("failed second pack fixture"); + if (defect === "second-modified") fs.appendFileSync(path.join(options.output, "plugin-kit-ai-2.0.0.tgz"), "changed"); + if (defect === "first-late") fs.appendFileSync(path.join(options.output, `universal-agent-plugins-${f.input.identity.versions.agentplugins}.tgz`), "changed"); + if (defect === "generated") fs.appendFileSync(path.join(data.root, "README.md"), "changed"); + if (defect === "generated-mode") fs.chmodSync(path.join(data.root, "README.md"), 0o755); + if (defect === "generated-extra") put(data.root, "unexpected", Buffer.from("extra")); + if (defect === "collision") put(options.output, "completion.json", Buffer.from("existing owner bytes")); + } + if (event === "verify-pack" && data.file.endsWith("plugin-kit-ai-2.0.0.tgz")) { + if (defect === "verified-late") fs.appendFileSync(path.join(options.output, `universal-agent-plugins-${f.input.identity.versions.agentplugins}.tgz`), "late"); + if (defect === "generated-late") fs.appendFileSync(path.join(options.output, "agentplugins/README.md"), "late"); + } + if (defect === "snapshot" && event === "input-snapshot" && fired && data.endsWith("stage-inputs")) { + assert.ok(fs.existsSync(path.join(data, "unexpected"))); throw new Error("changed snapshot fixture"); + } + if (defect === "provider" && event === "inspect" && fired) throw new Error("changed completed attempt fixture"); + }); + assert.throws(() => f.api.stagePrepublication(f.options)); + const marker = path.join(f.options.output, "completion.json"); + if (defect === "collision") assert.equal(fs.readFileSync(marker, "utf8"), "existing owner bytes"); + else assert.equal(fs.existsSync(marker), false); + assert.ok(f.calls.filter(x => x[0] === "pack").length <= 2); + }); +} + +for (const defect of ["digest", "attempt", "signature", "source-pins", "generated", "S-I", "I-custody", "input-change", "source-change", + "tarball", "late-tarball", "late-S", "caller-change", "S-canonical", "stage-provider"]) { + test(`C1 stage integration reader rejects ${defect} without repacking`, t => { + let reading = false, verified = 0, readerOptions, readerRoot; + const f = integrationFixture(t, (event, data, state) => { + if (!reading) return; + if (event === "extract-stage") readerRoot = data; + if (defect === "signature" && event === "signer") throw new Error("fixed signer rejected fixture"); + if (defect === "I-custody" && event === "read-I") throw new Error("I custody rejected fixture"); + if (event === "verify-pack") { + verified++; + if (verified === 2) { + if (defect === "late-tarball") fs.appendFileSync(path.join(readerRoot, `universal-agent-plugins-${f.input.identity.versions.agentplugins}.tgz`), "late"); + if (defect === "late-S") fs.appendFileSync(path.join(readerRoot, "completion.json"), "late"); + if (defect === "input-change") state.put(state.paths.incoming, "candidate-identity.json", Buffer.from("late input")); + if (defect === "caller-change") readerOptions.selected.source = "b".repeat(40); + } + } + if (defect === "source-change" && event === "blobs" && verified === 2) throw new Error("reader source changed fixture"); + if (defect === "stage-provider" && event === "inspect" && verified === 2) throw new Error("completed stage changed fixture"); + }); + const record = f.api.stagePrepublication(f.options); + const marker = path.join(f.options.output, "completion.json"); + // Producer completion is read-only. Reader mutations use a distinct owned + // artifact fixture, never chmod/overwrite that completion or prior receipts. + if (["source-pins", "generated", "S-I", "S-canonical"].includes(defect)) { + const original = promotion.extractArtifact; + t.mock.method(promotion, "extractArtifact", (...args) => { + const output = original(...args), changed = clone(record); + if (defect === "source-pins") changed.wrapper_blobs[prefix + "scripts/authoring-promotion.js"].sha256 = sha(991); + if (defect === "generated") changed.generated.agentplugins["package.json"] = sha(992); + if (defect === "S-I") changed.native_inputs.sha256 = sha(993); + const bytes = defect === "S-canonical" ? Buffer.from(JSON.stringify(changed)) : json(changed); + fs.writeFileSync(path.join(output, "completion.json"), bytes); readerOptions.stage_sha256 = c.digest(bytes); + return output; + }); + } + readerOptions = f.readOptions(); + if (defect === "digest") readerOptions.stage_sha256 = sha(999); + if (defect === "attempt") readerOptions.artifact.run_attempt++; + if (defect === "tarball") fs.appendFileSync(path.join(f.options.output, record.packs.agentplugins.file), "changed pack"); + reading = true; assert.throws(() => f.api.readStage(readerOptions)); + assert.equal(f.calls.filter(x => x[0] === "pack").length, 2); + assert.deepEqual(fs.readFileSync(marker), stage.encodeStage(record, f.inputBytes)); + }); +} + +test("C1 stage integration existing blobs checks every committed, checkout and executing entry including mode and HEAD", t => { + const f = fixture(), root = fs.mkdtempSync(path.join(os.tmpdir(), "c1-stage-blobs-")); + const executing = path.resolve(__dirname, "../../.."), normalRead = c.readFile, normalStat = fs.lstatSync; + let changed, absent, changedMode, head = f.input.identity.commit, reads = [], commands = []; + t.mock.method(cp, "execFileSync", (exe, args, options) => { + assert.equal(exe, "/usr/bin/git"); assert.equal(options.cwd, root); commands.push(args); + if (args[0] === "rev-parse") return Buffer.from(head + "\n"); + if (args[0] === "ls-tree") { + const n = args.at(-1), pin = f.source[n]; + return Buffer.from(n === absent ? "" : `${pin.mode} blob ${pin.git_blob}\t${n}\0`); + } + assert.equal(args[0], "cat-file"); + return Object.values(f.source).find(pin => pin.git_blob === args.at(-1)).bytes; + }); + t.mock.method(c, "readFile", (file, max) => { + const base = file.startsWith(root + path.sep) ? root : file.startsWith(executing + path.sep) ? executing : null; + const n = base && path.relative(base, file); + if (!n || !Object.hasOwn(f.source, n)) return normalRead(file, max); + reads.push(file); return file === changed ? Buffer.from("changed fixture") : Buffer.from(f.source[n].bytes); + }); + t.mock.method(fs, "lstatSync", (...args) => { + const file = args[0], base = file.startsWith(root + path.sep) ? root : file.startsWith(executing + path.sep) ? executing : null; + const n = base && path.relative(base, file); + if (n && Object.hasOwn(f.source, n)) return { mode: file === changedMode ? 0o600 : f.source[n].mode === "100755" ? 0o755 : 0o644 }; + return normalStat(...args); + }); + assert.deepEqual(packing.blobs(root, head, {}, "stage"), f.source); + for (const n of stage.STAGE_ALLOWLIST) for (const base of [root, executing]) assert.ok(reads.includes(path.join(base, n))); + for (const n of stage.STAGE_ALLOWLIST) { + absent = n; assert.throws(() => packing.blobs(root, head, {}, "stage"), /required regular Git blob missing/); absent = undefined; + for (const base of [root, executing]) { + changed = path.join(base, n); assert.throws(() => packing.blobs(root, head, {}, "stage"), /differs from committed/); changed = undefined; + changedMode = path.join(base, n); assert.throws(() => packing.blobs(root, head, {}, "stage"), /differs from committed/); changedMode = undefined; + } + } + head = "b".repeat(40); commands = []; + assert.throws(() => packing.blobs(root, f.input.identity.commit, {}, "stage"), /checkout HEAD/); + assert.equal(commands.length, 1); +}); + +test("C1 stage integration existing pack engine validates entries, modes, bytes, response SRI and SHA1 without receipt changes", t => { + const f = fixture(), pair = stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "c1-stage-pack-engine-")); + let current, flaw, count = 0; + const output = path.join(root, "output"); fs.mkdirSync(output); + const context = { root: path.join(root, "verify"), env: { PATH: "/usr/local/bin:/usr/bin:/bin" } }; fs.mkdirSync(context.root); + t.mock.method(cp, "execFileSync", (exe, args) => { + if (exe === "/fixture-node") { + assert.deepEqual(args, ["/fixture-npm", "pack", "--ignore-scripts", "--offline", "--json", "--pack-destination", output]); + count++; + const bytes = Buffer.from(`PACK INTERFACE ${current} ${count}`), name = inputs.PACKAGES[current], version = f.input.identity.versions[current]; + const filename = `${name}-${version}.tgz`; fs.writeFileSync(path.join(output, filename), bytes); + const row = { id: `${name}@${version}`, name, version, filename, size: bytes.length, + integrity: "sha512-" + crypto.createHash("sha512").update(bytes).digest("base64"), + shasum: crypto.createHash("sha1").update(bytes).digest("hex") }; + if (flaw === "SRI") row.integrity = "sha512-" + Buffer.alloc(64, 1).toString("base64"); + if (flaw === "SHA1") row.shasum = "a".repeat(40); + if (flaw === "identity") row.name = "wrong-product"; + return json(count % 2 ? [row] : { [name]: row }); + } + assert.equal(exe, "/usr/bin/tar"); + const entries = inventory(current).map(n => "package/" + n); + if (args[0] === "-tzf") return Buffer.from([...entries, ...(flaw === "entries" ? ["package/extra"] : [])].join("\n") + "\n"); + if (args[0] === "-tvzf") return Buffer.from(entries.map(n => + (flaw === "mode" ? "lrwxrwxrwx " : /^package\/bin\/[^/]+\.js$/.test(n) ? "-rwxr-xr-x " : "-rw-r--r-- ") + n).join("\n") + "\n"); + assert.equal(args[0], "-xzf"); const destination = args[args.indexOf("-C") + 1]; + for (const [n, b] of Object.entries(pair[current])) { + const file = path.join(destination, "package", n); fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, flaw === "bytes" && n === "README.md" ? Buffer.from("wrong bytes") : b, + { mode: /^bin\/[^/]+\.js$/.test(n) ? 0o755 : 0o644 }); + } + return Buffer.alloc(0); + }); + for (const product of products) for (const defect of [null, "SRI", "SHA1", "identity", "entries", "mode", "bytes"]) { + current = product; flaw = defect; + // verifyPack uses exclusive extraction directories; every trial owns a new context. + context.root = fs.mkdtempSync(path.join(root, "trial-")); + const run = () => packing.packPackage(product, pair[product], root, + { node: "/fixture-node", npm: "/fixture-npm", output, identity: f.input.identity }, context); + if (defect) assert.throws(run); + else { + const packed = run(); assert.deepEqual(Object.keys(packed), ["file", "sha256", "size", "integrity"]); + assert.equal(packed.sha256, c.digest(fs.readFileSync(path.join(output, packed.file)))); + } + } + assert.equal(count, 14); +}); + +function workflowTransport(f, options, basename) { + const {input, ...rest} = options; + const input_file = f.put(f.base, `${basename}-I.json`, input); + return f.put(f.base, `${basename}.json`, c.encode({...rest, input_file})); +} +test('C1 workflow CLI producer emits closed result and unsigned validator never packs or checks S signatures', t => { + const f = integrationFixture(t); + const produced = f.api.main(['--stage-prepublication', workflowTransport(f, f.options, 'produce')]); + assert.deepEqual(Object.keys(produced), ['root', 'record', 'subjects', 'stage_sha256']); + assert.equal(produced.subjects.length, 3); assert.equal(f.calls.filter(([n]) => n === 'pack').length, 2); + const result = f.api.main(['--validate-unsigned-stage', workflowTransport(f, f.readOptions(), 'unsigned')]); + assert.deepEqual(result.record, produced.record); assert.equal(result.subjects.length, 3); + assert.equal(f.calls.filter(([n]) => n === 'pack').length, 2); + assert.equal(f.calls.filter(([n]) => n === 'signer').length, 0); + assert.ok(f.calls.some(([n]) => n === 'read-I')); assert.ok(f.calls.some(([n]) => n === 'stage-evidence')); + const completed = f.api.main(['--read-stage', workflowTransport(f, f.readOptions(), 'completed')]); + assert.deepEqual(completed.record, result.record); + assert.equal(f.calls.filter(([n]) => n === 'signer').length, 3); + assert.equal(f.calls.filter(([n]) => n === 'pack').length, 2); +}); +for (const defect of ['current custody', 'I signature', 'operation evidence', 'completion.json', 'agent pack', 'kit pack', 'generated pin']) { + test(`C1 workflow unsigned validator rejects ${defect} without pack or S signing`, t => { + let reading = false; + const f = integrationFixture(t, (name) => { + if (reading && ((defect === 'I signature' && name === 'read-I') || + (defect === 'operation evidence' && name === 'stage-evidence') || + (defect === 'current custody' && name === 'current-custody'))) throw Error(defect); + }); + f.api.stagePrepublication(f.options); const options = f.readOptions(); reading = true; + if (defect.endsWith('pack')) { + const product = defect === 'agent pack' ? 'agentplugins' : 'plugin-kit-ai'; + fs.appendFileSync(path.join(f.options.output, `${inputs.PACKAGES[product]}-${f.input.identity.versions[product]}.tgz`), 'changed'); + } + if (defect === 'completion.json' || defect === 'generated pin') { + const original = c.readFile; + const retained = fs.readFileSync(path.join(f.options.output, 'completion.json')); + const record = JSON.parse(retained); record.generated.agentplugins['README.md'] = sha(19); + const changed = defect === 'completion.json' ? Buffer.concat([retained, Buffer.from('changed')]) : c.encode(record); + if (defect === 'generated pin') options.stage_sha256 = c.digest(changed); + t.mock.method(c, 'readFile', (file, max) => path.basename(file) === 'completion.json' ? changed : original(file, max)); + } + assert.throws(() => f.api.validateUnsignedStage(options)); + assert.equal(f.calls.filter(([n]) => n === 'pack').length, 2); + assert.equal(f.calls.filter(([n]) => n === 'signer').length, 0); + }); +} +test('C1 workflow stage CLI rejects object/path transport, unknown operations and input mutation', t => { + const f = integrationFixture(t), file = workflowTransport(f, f.options, 'options'); + for (const args of [['--unknown', file], ['--read-stage', file, file], ['--read-stage', 'relative']]) assert.throws(() => f.api.main(args)); + const options = JSON.parse(fs.readFileSync(file)); options.input_file = {type: 'Buffer', data: [1]}; + fs.writeFileSync(file, c.encode(options)); assert.throws(() => f.api.main(['--stage-prepublication', file])); + assert.equal(f.calls.length, 0); +}); +test('C1 workflow second pack failure cannot emit completion transcript or S', t => { + const transcript = []; + t.mock.method(process.stderr, 'write', chunk => {transcript.push(String(chunk)); return true;}); + const f = integrationFixture(t, (name, data) => {if (name === 'pack' && data.product === 'plugin-kit-ai') throw Error('second pack failed');}); + assert.throws(() => f.api.stagePrepublication(f.options), /second pack/); + assert.equal(fs.existsSync(path.join(f.options.output, 'completion.json')), false); + assert.equal(transcript.filter(line => line.includes('"operation":"completion"')).length, 0); +}); diff --git a/npm/agentplugins/test/npm-public-contract.test.js b/npm/agentplugins/test/npm-public-contract.test.js index 031278b9..881de637 100644 --- a/npm/agentplugins/test/npm-public-contract.test.js +++ b/npm/agentplugins/test/npm-public-contract.test.js @@ -2,6 +2,7 @@ const assert = require("node:assert/strict"); const crypto = require("node:crypto"); +const cp = require("node:child_process"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); @@ -11,6 +12,7 @@ const { validateAuditSignatures, validateDownloadedTarball, validatePackJSON, + validateProductPackJSON, validatePublicMetadata, validateSLSAAttestation } = require("../scripts/npm-public-contract"); @@ -128,7 +130,7 @@ test("public npm metadata and downloaded pack bind the staged package identity", /publisher identity is not GitHub Actions/ ); const root = fs.mkdtempSync(path.join(os.tmpdir(), "npm-public-contract-")); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); + t.after(() => cp.execFileSync("rm", ["-r", "--", root])); fs.writeFileSync(path.join(root, value.pack[0].filename), value.body); assert.equal( validateDownloadedTarball(value.pack, root, value.version, value.integrity, value.shasum), @@ -141,7 +143,7 @@ test("npm 12 object-shaped pack JSON preserves the exact package identity", (t) const npm12 = { "universal-agent-plugins": structuredClone(value.pack[0]) }; assert.equal(validatePackJSON(npm12, value.version), npm12["universal-agent-plugins"]); const root = fs.mkdtempSync(path.join(os.tmpdir(), "npm-public-contract-npm12-")); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); + t.after(() => cp.execFileSync("rm", ["-r", "--", root])); fs.writeFileSync(path.join(root, value.pack[0].filename), value.body); assert.equal( validateDownloadedTarball(npm12, root, value.version, value.integrity, value.shasum), @@ -276,7 +278,7 @@ test("public npm metadata fails closed for every reviewed identity field", () => test("downloaded public npm bytes and pack JSON reject staged digest mismatches", (t) => { const value = fixture(); const root = fs.mkdtempSync(path.join(os.tmpdir(), "npm-public-contract-negative-")); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); + t.after(() => cp.execFileSync("rm", ["-r", "--", root])); fs.writeFileSync(path.join(root, value.pack[0].filename), Buffer.from("tampered")); assert.throws( () => validateDownloadedTarball(value.pack, root, value.version, value.integrity, value.shasum), @@ -289,3 +291,175 @@ test("downloaded public npm bytes and pack JSON reject staged digest mismatches" /pack identity/ ); }); + +const products = { agentplugins: "universal-agent-plugins", "plugin-kit-ai": "plugin-kit-ai" }; +const responseForms = { + array: record => [record], + object: record => ({ [record.name]: record }) +}; +function productRecord(product) { + const record = fixture().pack[0]; + record.name = products[product]; + record.filename = `${record.name}-${record.version}.tgz`; + return record; +} + +for (const product of Object.keys(products)) for (const [form, wrap] of Object.entries(responseForms)) { + test(`C1 fixed pack ${product} ${form}: identity, shape and digest syntax`, () => { + const record = productRecord(product); + // npm carries additional informational fields; these are not a new schema. + record.files = [{ path: "package.json", size: 123, mode: 420 }]; + assert.equal(validateProductPackJSON(wrap(record), product, record.version), record); + if (product === "agentplugins") assert.equal(validatePackJSON(wrap(record), record.version), record); + else assert.throws(() => validatePackJSON(wrap(record), record.version)); + for (const mutation of [ + x => { x.name = "lookalike"; }, + x => { x.version = "1.2.4"; }, + x => { x.filename = "../" + x.filename; }, + x => { x.filename = x.filename.replace("1.2.3", "1.2.4"); }, + x => { x.integrity = undefined; }, + x => { x.integrity = 42; }, + x => { x.integrity = "sha256-" + "a".repeat(86) + "=="; }, + x => { x.integrity += "\n"; }, + x => { x.integrity = "sha512-" + "A".repeat(85) + "B=="; }, + x => { x.shasum = undefined; }, + x => { x.shasum = 42; }, + x => { x.shasum = "A".repeat(40); }, + x => { x.shasum = "a".repeat(39); }, + x => { x.shasum += "\n"; } + ]) { + const bad = structuredClone(record); mutation(bad); + assert.throws(() => validateProductPackJSON(wrap(bad), product, record.version)); + } + for (const value of [null, false, "pack", [], [record, record], [null], {}, + { lookalike: record }, { [record.name]: record, extra: record }, + { [record.name]: [record] }]) { + assert.throws(() => validateProductPackJSON(value, product, record.version)); + } + for (const version of [null, 123, "01.2.3", "1.2.3-beta.1", "1.2.3+build", "1.2.3\n"]) { + assert.throws(() => validateProductPackJSON(wrap(record), product, version)); + } + for (const unknown of ["universal-agent-plugins", "other", "toString", null]) { + assert.throws(() => validateProductPackJSON(wrap(record), unknown, record.version), /unknown fixed npm product/); + } + }); +} + +test("C1 blob checks bind the helper while preserving both historical receipt inventories", t => { + const packing = require("../scripts/stage-dual-authoring-npm"); + const publicPacking = require("../scripts/stage-authoring-npm"); + const c = require("../scripts/dual-authoring-candidate"); + const repo = path.resolve(__dirname, "../../.."); + const helper = "npm/agentplugins/scripts/npm-public-contract.js"; + const commit = "a".repeat(40); + const readFile = c.readFile; + let fault; + const requested = []; + // Simulated committed-source interface, not an accepted Git successor. + t.mock.method(cp, "execFileSync", (exe, args) => { + assert.equal(exe, "/usr/bin/git"); + if (args[0] === "rev-parse") return Buffer.from(commit + "\n"); + if (args[0] === "ls-tree") { + const name = args.at(-1); requested.push(name); + if (fault === "missing" && name === helper) return Buffer.from(""); + const bytes = fs.readFileSync(path.join(repo, name)); + const hash = crypto.createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex"); + bodies.set(hash, bytes); + return Buffer.from(`100644 blob ${hash}\t${name}\0`); + } + assert.equal(args[0], "cat-file"); return bodies.get(args[2]); + }); + const bodies = new Map(); + t.mock.method(c, "readFile", (file, ...rest) => fault === "dirty" && file === path.join(repo, helper) ? + Buffer.from("changed executing helper") : readFile(file, ...rest)); + for (const [closure, allowlist] of [["private", packing.ALLOWLIST], ["public", publicPacking.ALLOWLIST]]) { + assert.equal(allowlist.includes(helper), false); + const source = packing.blobs(repo, commit, {}, closure); + assert.deepEqual(Object.keys(source), [...allowlist]); + assert.ok(requested.includes(helper)); + for (const name of allowlist) assert.deepEqual(source[name].bytes, fs.readFileSync(path.join(repo, name))); + for (fault of ["missing", "dirty"]) { + assert.throws(() => packing.blobs(repo, commit, {}, closure), /required regular Git blob missing|executing stager differs/); + } + fault = undefined; + assert.throws(() => packing.blobs(repo, "b".repeat(40), {}, closure), /checkout HEAD/); + } +}); + +// Explicit offline tool provision only. Retain small fixtures under TMPDIR for +// evidence; never import the native-building private-npm fixture suite. +test("C1 packPackage real offline packs: both forms/products, same receipts, rejection before completion", { + skip: !process.env.UAP_C1_PACK_NPM +}, t => { + const packing = require("../scripts/stage-dual-authoring-npm"); + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "c1-pack-consumer-")); + const npm = process.env.UAP_C1_PACK_NPM; + assert.ok(path.isAbsolute(npm)); + const execFile = cp.execFileSync; + const actualRecords = {}, productBytes = {}; + for (const product of Object.keys(products)) for (const [form, wrap] of Object.entries(responseForms)) { + const base = path.join(scratch, `${product}-${form}`); fs.mkdirSync(base); + const context = packing.npmContext(base); + const root = path.join(base, "package"); fs.mkdirSync(root); + const output = path.join(base, "output"); fs.mkdirSync(output); + const files = { "package.json": Buffer.from(JSON.stringify({ + name: products[product], version: "1.2.3", private: true, + scripts: { prepack: "exit 91", prepare: "exit 92", postpack: "exit 93", postinstall: "exit 94" } + }) + "\n"), "README.md": Buffer.from("Offline fixed pack consumer fixture.\n") }; + for (const [name, body] of Object.entries(files)) fs.writeFileSync(path.join(root, name), body, { mode: 0o644 }); + let calls = 0; + const mock = t.mock.method(cp, "execFileSync", (exe, args, options) => { + if (exe !== process.execPath || args[0] !== npm) return execFile(exe, args, options); + calls++; + assert.deepEqual(args.slice(1), ["pack", "--ignore-scripts", "--offline", "--json", "--pack-destination", output]); + const response = JSON.parse(execFile(exe, args, options)); + const record = validateProductPackJSON(response, product, "1.2.3"); + actualRecords[product] = record; + return Buffer.from(JSON.stringify(wrap(record))); + }); + const options = { node: process.execPath, npm, output, identity: { versions: { [product]: "1.2.3" } } }; + const receipt = packing.packPackage(product, files, root, options, context); + mock.mock.restore(); + assert.equal(calls, 1); + const body = fs.readFileSync(path.join(output, receipt.file)); + assert.deepEqual(receipt, { file: `${products[product]}-1.2.3.tgz`, + sha256: crypto.createHash("sha256").update(body).digest("hex"), size: body.length, + integrity: "sha512-" + crypto.createHash("sha512").update(body).digest("base64") }); + if (productBytes[product]) assert.deepEqual(body, productBytes[product]); + productBytes[product] = body; + assert.equal(actualRecords[product].shasum, crypto.createHash("sha1").update(body).digest("hex")); + assert.deepEqual(fs.readFileSync(path.join(context.root, product, "package/README.md")), files["README.md"]); + // Same bytes and real response, with one field corrupted. No extraction or + // downstream completion is allowed even when the other digest is correct. + for (const field of ["name", "version", "filename", "integrity", "shasum"]) { + const bad = { ...actualRecords[product], [field]: field === "integrity" ? + "sha512-" + Buffer.alloc(64).toString("base64") : field === "shasum" ? "0".repeat(40) : "wrong" }; + let tarCalls = 0; + const rejection = t.mock.method(cp, "execFileSync", (exe, args) => { + if (exe === process.execPath && args[0] === npm) return Buffer.from(JSON.stringify(wrap(bad))); + tarCalls++; throw new Error("unexpected downstream tool"); + }); + const marker = path.join(output, "completion.json"); + assert.throws(() => { + const pack = packing.packPackage(product, files, root, options, context); + packing.completeRecord(output, { pack }); + }, /package identity|package-named record|differs from actual pack/); + rejection.mock.restore(); + assert.equal(tarCalls, 0); assert.equal(fs.existsSync(marker), false); + } + fs.writeFileSync(path.join(output, receipt.file), Buffer.from("changed tarball bytes")); + const changed = t.mock.method(cp, "execFileSync", (exe, args) => { + assert.equal(exe, process.execPath); assert.equal(args[0], npm); + return Buffer.from(JSON.stringify(wrap(actualRecords[product]))); + }); + assert.throws(() => packing.packPackage(product, files, root, options, context), /integrity differs from actual pack/); + changed.mock.restore(); + } +}); + +test("C1 packPackage rejects unknown products before invoking npm", t => { + const packing = require("../scripts/stage-dual-authoring-npm"); + const run = t.mock.method(cp, "execFileSync", () => { throw new Error("unexpected tool"); }); + assert.throws(() => packing.packPackage("toString", {}, "", {}, {}), /unknown fixed npm product/); + assert.equal(run.mock.callCount(), 0); +});