Compare commits

..
41 Commits
Author SHA1 Message Date
DongHyeonka 4dc033cf33 refactor: adapter 구현중.. 2026-08-13 16:02:21 +09:00
DongHyeonka 30ceac23c1 fix: harden provider and promotion evidence 2026-08-02 16:28:24 +09:00
DongHyeonka 42ffb79997 fix: reject empty removal fixture scans 2026-08-02 15:02:57 +09:00
DongHyeonka f49d147b01 fix: harden CI evidence and removal contracts 2026-08-02 14:48:04 +09:00
DongHyeonka 1bb2cc4a20 refactor: generate CI workflow from gate contracts 2026-08-02 13:53:25 +09:00
DongHyeonka 777ce5c9ed docs: plan platform-owned frontend delivery 2026-08-02 13:50:40 +09:00
DongHyeonka 8565b96782 docs: define platform-owned frontend assurance delivery 2026-08-02 13:08:51 +09:00
DongHyeonka d2eb320936 test: harden HTTP scenario execution evidence 2026-08-02 11:36:27 +09:00
DongHyeonka e08d8c2dd8 docs: record HTTP deadline follow-up 2026-08-02 11:18:02 +09:00
DongHyeonka abdd90ad5d test: execute the HTTP scenario catalog 2026-08-02 11:17:28 +09:00
DongHyeonka 76bf9f1aa3 test: lock V8 coverage counter semantics 2026-08-02 10:12:34 +09:00
DongHyeonka 5cc6b8a51c docs: design V8 coverage counter contract 2026-08-02 09:44:13 +09:00
DongHyeonka e0373de4d9 docs: clarify counter-bearing module scope 2026-08-02 09:36:34 +09:00
DongHyeonka 5cecbb9820 refactor: align coverage counter provenance 2026-08-02 09:31:28 +09:00
DongHyeonka 6e05a35790 fix: reject empty coverage counters 2026-08-02 09:10:35 +09:00
DongHyeonka 8d6fbb97e9 fix: close coverage evidence races 2026-08-02 08:43:44 +09:00
DongHyeonka 67cd37659d fix: harden repository coverage evidence 2026-08-02 08:20:25 +09:00
DongHyeonka 5a73f7a1b5 fix: measure repository-wide risk coverage 2026-08-02 07:52:46 +09:00
DongHyeonka f487823442 fix: enforce exact local evidence defaults 2026-08-02 07:11:33 +09:00
DongHyeonka 1b4b0c2821 fix: recompute local promotion evidence 2026-08-02 07:02:39 +09:00
DongHyeonka 92e5cace5c fix: close immutable promotion trust gaps 2026-08-02 06:39:41 +09:00
DongHyeonka 7c5ed80407 fix: promote immutable verified release bundles 2026-08-02 06:08:06 +09:00
DongHyeonka 100a3bb6ba fix: make security fixtures fail closed 2026-08-02 05:40:58 +09:00
DongHyeonka 76d0ab0f62 fix: cover every tracked release input 2026-08-02 05:26:36 +09:00
DongHyeonka d6c98489ee fix: fail closed on release input discovery 2026-08-02 05:11:30 +09:00
DongHyeonka 381d5549e2 fix: preserve artifact writer failures 2026-08-02 04:46:33 +09:00
DongHyeonka c9f5887cac refactor: validate generated evidence artifacts 2026-08-02 04:33:01 +09:00
DongHyeonka 2c3cab2518 fix: select rollback artifact pairs atomically 2026-08-02 04:03:14 +09:00
DongHyeonka 172a26b8bd fix: fail closed in release drill verification 2026-08-02 03:54:58 +09:00
DongHyeonka 990603e24a fix: unify release runtime coherence verification 2026-08-02 03:38:49 +09:00
DongHyeonka 184bd98d92 fix: preserve reconciliation authorities 2026-08-02 03:15:22 +09:00
DongHyeonka d9afccdd60 fix: retain uncertain optimistic mutations 2026-08-02 02:42:56 +09:00
DongHyeonka 15645541b7 fix: reject credential idempotency headers 2026-08-02 01:33:07 +09:00
DongHyeonka fa2f699125 fix: reject invalid keyed mutation intents 2026-08-02 01:21:50 +09:00
DongHyeonka cbcc7b5ed7 fix: preserve logical mutation intent 2026-08-02 01:07:19 +09:00
DongHyeonka 53d181fbe4 fix: baseline invalidation registry contracts 2026-08-02 00:28:17 +09:00
DongHyeonka 0eb23875cb fix: harden invalidation registry governance 2026-08-01 23:59:11 +09:00
DongHyeonka 73a50426d6 fix: index many-to-many query invalidation 2026-08-01 23:17:09 +09:00
DongHyeonka 853c2e3f30 fix: align bound query keys with invalidation prefixes 2026-08-01 22:11:04 +09:00
DongHyeonka 92c3d438ab docs: plan refactoring review remediation 2026-08-01 21:59:19 +09:00
DongHyeonka a49c76b5b2 docs: define refactoring review remediation 2026-08-01 19:43:33 +09:00
228 changed files with 47332 additions and 4020 deletions
+297 -41
View File
@@ -1,3 +1,4 @@
# GENERATED FILE — edit config/ci/gates.json and run `corepack pnpm generate:ci-workflow`.
name: frontend-quality-gates
on:
@@ -31,7 +32,7 @@ env:
jobs:
merge_gate:
name: ${{ matrix.gate }} / ${{ matrix.name }}
name: "${{ matrix.gate }} / ${{ matrix.name }}"
if: ${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}
runs-on: ubuntu-latest
timeout-minutes: 45
@@ -51,71 +52,320 @@ jobs:
- { gate: FE-GATE-010, name: architecture, browser: false }
- { gate: FE-GATE-011, name: build, browser: false }
- { gate: FE-GATE-013, name: security, browser: false }
- { gate: FE-GATE-020, name: sample-removal, browser: false }
- { gate: FE-GATE-020, name: removability, browser: false }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Install Playwright browsers
if: ${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload gate evidence
- name: Upload merge gate evidence
if: always()
uses: actions/upload-artifact@v4
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: ${{ matrix.gate }}-${{ gitea.run_id }}
name: "${{ matrix.gate }}-${{ gitea.run_id }}"
path: artifacts/
if-no-files-found: error
release_gate:
name: ${{ matrix.gate }} / ${{ matrix.name }}
name: "${{ matrix.gate }} / ${{ matrix.name }}"
needs: merge_gate
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
HOSTING_BASE_URL: ${{ vars.HOSTING_BASE_URL }}
HOSTING_BASE_URL: "${{ vars.HOSTING_BASE_URL }}"
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-012, name: bundle, browser: false }
- { gate: FE-GATE-014, name: config-compatibility, browser: false }
- { gate: FE-GATE-015, name: release-coherence, browser: false }
- { gate: FE-GATE-019, name: hosting-header, browser: false }
- { gate: FE-GATE-026, name: lab-performance, browser: true }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Install Playwright browsers
if: ${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload gate evidence
- name: Upload release gate evidence
if: always()
uses: actions/upload-artifact@v4
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: ${{ matrix.gate }}-${{ gitea.run_id }}
name: "${{ matrix.gate }}-${{ gitea.run_id }}"
path: artifacts/
if-no-files-found: error
production_gate:
name: ${{ matrix.gate }} / ${{ matrix.name }}
immutable_build:
name: "FE-GATE-015 / immutable-release-candidate"
needs: release_gate
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }}
archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }}
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Build candidate once and verify local evidence
run: corepack pnpm ci:gate -- FE-GATE-015
- name: Archive and validate the exact candidate file set
id: candidate
run: |
mkdir -p .release
tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" \
dist \
pnpm-lock.yaml \
artifacts/performance/bundle.json \
artifacts/quality/vite-module-inventory.json \
artifacts/release/build-manifest.json \
artifacts/release/checksums.txt \
artifacts/release/dependency-inventory.json \
artifacts/release/provenance.json \
artifacts/release/verification.json \
artifacts/release/sbom.cdx.json \
artifacts/security/dependency-diff.json \
artifacts/security/license-report.json \
artifacts/security/local-evidence-assessment.json \
artifacts/security/scan.sarif \
artifacts/security/supply-chain-coherence.json \
artifacts/security/supply-chain-verification.json \
artifacts/security/vulnerability-report.json \
config/security/dependency-baseline.approval.json \
config/security/dependency-baseline.json \
config/security/dependency-change-evidence.json \
config/security/dependency-policy.json \
config/security/secret-scan-policy.json \
config/security/vulnerability-exceptions.json \
config/security/vulnerability-policy.json \
schemas/artifacts/build-manifest.schema.json \
schemas/artifacts/dependency-inventory.schema.json \
schemas/artifacts/supply-chain-verification.schema.json \
scripts/contracts/release-artifacts.ts \
scripts/create-release-candidate.ts \
scripts/generate-supply-chain.ts \
scripts/lib/build-manifest-outputs.ts \
scripts/lib/json-schema.ts \
scripts/lib/local-policy-evidence.ts \
scripts/lib/local-release-evidence.ts \
scripts/lib/release-candidate.ts \
scripts/lib/release-input-evidence.ts \
scripts/lib/release-runtime-coherence.ts \
scripts/lib/repository-file-inventory.ts \
scripts/lib/secret-scan-evaluator.ts \
scripts/lib/secret-scan-policy.ts \
scripts/lib/secret-scan.ts \
scripts/lib/supply-chain.ts \
scripts/lib/validated-json-artifact.ts \
src/contracts/release-artifacts.ts \
src/features/installed-contract-contributions.ts \
src/features/installed-feature-contracts.ts \
artifacts/release/release-candidate.json
node scripts/verify-ci-candidate-archive.ts --archive ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --github-output "$GITHUB_OUTPUT"
- name: Upload release candidate
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
if-no-files-found: error
vulnerability_provider:
name: external-vulnerability-provider
needs: immutable_build
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
invocation_nonce: ${{ steps.supervise_vulnerability.outputs.invocation_nonce }}
env:
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
CI_RUN_ID: "${{ gitea.run_id }}"
CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}"
EXPECTED_SOURCE_REVISION: "${{ gitea.sha }}"
VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}"
VULNERABILITY_PROVIDER_COMMAND: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}"
VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Download release candidate
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: .release/vulnerability-candidate
- name: Run and validate external vulnerability provider in one trusted supervisor
id: supervise_vulnerability
run: node scripts/run-and-validate-provider.ts --kind vulnerability
- name: Confirm sealed vulnerability provider evidence
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
- name: Upload vulnerability provider evidence
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: provider-evidence/vulnerability-report.json
if-no-files-found: error
provenance_provider:
name: external-provenance-provider
needs: immutable_build
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
invocation_nonce: ${{ steps.supervise_provenance.outputs.invocation_nonce }}
env:
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
CI_RUN_ID: "${{ gitea.run_id }}"
CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}"
EXPECTED_SOURCE_REVISION: "${{ gitea.sha }}"
PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}"
PROVENANCE_PROVIDER_COMMAND: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}"
PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Download release candidate
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: .release/provenance-candidate
- name: Run and validate external provenance provider in one trusted supervisor
id: supervise_provenance
run: node scripts/run-and-validate-provider.ts --kind provenance
- name: Confirm sealed provenance provider evidence
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
- name: Upload provenance provider evidence
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: provider-evidence/provenance-attestation.json
if-no-files-found: error
promotion:
name: promote-verified-immutable-candidate
needs: [immutable_build, vulnerability_provider, provenance_provider]
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
CI_RUN_ID: "${{ gitea.run_id }}"
CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}"
VULNERABILITY_REPORT_PATH: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json"
PROVENANCE_ATTESTATION_PATH: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json"
VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}"
PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}"
VULNERABILITY_INVOCATION_NONCE: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}"
PROVENANCE_INVOCATION_NONCE: "${{ needs.provenance_provider.outputs.invocation_nonce }}"
steps:
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Download release candidate
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: .release/candidate
- name: Download vulnerability provider evidence
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
name: "vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: .release/vulnerability
- name: Download provenance provider evidence
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: .release/provenance
- name: Finalize verified promotion from inode-bound captured inputs
id: finalize
run: node scripts/stage-verified-promotion.ts
- name: Upload promoted release
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
path: |
${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz
${{ steps.finalize.outputs.staging_root }}/vulnerability-report.json
${{ steps.finalize.outputs.staging_root }}/provenance-attestation.json
${{ steps.finalize.outputs.staging_root }}/provider-verification.json
${{ steps.finalize.outputs.staging_root }}/promotion-verification.json
if-no-files-found: error
- name: Always remove private promotion staging
if: always()
env:
PROMOTION_STAGING_ROOT: ${{ steps.finalize.outputs.staging_root }}
PROMOTION_CLEANUP_TOKEN: ${{ steps.finalize.outputs.cleanup_token }}
PROMOTION_RUNNER_TEMP_DEV: ${{ steps.finalize.outputs.runner_temp_dev }}
PROMOTION_RUNNER_TEMP_INO: ${{ steps.finalize.outputs.runner_temp_ino }}
PROMOTION_STAGING_DEV: ${{ steps.finalize.outputs.staging_dev }}
PROMOTION_STAGING_INO: ${{ steps.finalize.outputs.staging_ino }}
run: |
if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then
node scripts/cleanup-verified-promotion.ts
fi
production_gate:
name: "${{ matrix.gate }} / ${{ matrix.name }}"
needs: promotion
if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}
runs-on: ubuntu-latest
timeout-minutes: 45
@@ -130,72 +380,78 @@ jobs:
- { gate: FE-GATE-024, name: runbook-telemetry }
- { gate: FE-GATE-025, name: runbook-release-rollback }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload gate evidence
- name: Upload production gate evidence
if: always()
uses: actions/upload-artifact@v4
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: ${{ matrix.gate }}-${{ gitea.run_id }}
name: "${{ matrix.gate }}-${{ gitea.run_id }}"
path: artifacts/
if-no-files-found: error
field_gate:
name: FE-GATE-018 / field-web-vitals
name: "FE-GATE-018 / field-web-vitals"
needs: production_gate
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
FIELD_WEB_VITALS_INPUT: ${{ vars.FIELD_WEB_VITALS_INPUT }}
MIN_ELIGIBLE_SAMPLES: ${{ vars.MIN_ELIGIBLE_SAMPLES }}
FIELD_WEB_VITALS_INPUT: "${{ vars.FIELD_WEB_VITALS_INPUT }}"
MIN_ELIGIBLE_SAMPLES: "${{ vars.MIN_ELIGIBLE_SAMPLES }}"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Run blocking gate
run: corepack pnpm ci:gate -- FE-GATE-018
- name: Upload gate evidence
- name: Upload field gate evidence
if: always()
uses: actions/upload-artifact@v4
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: FE-GATE-018-${{ gitea.run_id }}
name: "FE-GATE-018-${{ gitea.run_id }}"
path: artifacts/
if-no-files-found: error
documentation_gate:
name: FE-GATE-017 / diagram-review
name: "FE-GATE-017 / diagram-review"
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }}
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Run documentation gate
run: corepack pnpm ci:gate -- FE-GATE-017
- name: Upload gate evidence
- name: Upload documentation gate evidence
if: always()
uses: actions/upload-artifact@v4
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
with:
name: FE-GATE-017-${{ gitea.run_id }}
name: "FE-GATE-017-${{ gitea.run_id }}"
path: artifacts/
if-no-files-found: error
@@ -0,0 +1,110 @@
# Task 5 Report: Retain and reconcile uncertain optimistic mutations
## Status
Task 5 is implemented. Mutation settlement now follows explicit effect certainty, preserves unknown optimistic projections as ordered uncertain layers, and exposes one-at-a-time reconciliation bound to the original mutation record. Missing post-dispatch certainty is fail-safe `MAYBE_APPLIED`; only controller-owned pre-dispatch failures are marked `NOT_STARTED`.
## RED evidence
- Initial focused command: `corepack pnpm exec vitest run tests/unit/optimistic-layer-runtime.test.ts tests/component/application-query.test.tsx`.
- Initial result: exit `1`, 2 files, 11 failed / 28 passed. Missing lease/controller APIs failed directly; `APPLIED_CONFIRMED` and `MAYBE_APPLIED` were rolled back; effectless failures retained generic retry semantics.
- Review-driven RED: the three-file focused command including `tests/component/async-surface.test.tsx` exited `1` with 7 failed / 56 passed. It exposed applied-confirmed retry actions, active-submit reset, double reconciliation, stale-scope queue retention, overlay priority, and the incorrect refreshing copy.
- A final isolated RED proved a synchronous `NOT_APPLIED` double action could consume two FIFO records in one event turn.
- The production-form RED exited `1` with 2 failed / 8 passed: applied reconciliation left the original command dirty/retryable, while an `APPLIED_CONFIRMED` failure rendered generic unavailable.
- The final durability/lifecycle RED failed 2 / 2: an anonymous non-optimistic legacy channel did not survive remount, and render-time registry allocation exhausted the definition cap during an abandoned server render.
## Implementation
1. `OptimisticLayerLease` now supports `markUncertain()` and `reconcile("APPLIED" | "NOT_APPLIED")`. Layers are `pending | uncertain | committed`; only a committed prefix collapses into the base, while projection continues to apply every later layer in order.
2. Reconciliation is single-settlement and idempotent. `APPLIED` converts the uncertain layer to committed; `NOT_APPLIED` removes only that layer; both then collapse/reproject later committed or pending layers. Scope expiry removes stale cache instead of restoring it.
3. Legacy optimistic mutations use the same reusable always-current ordered runtime. This prevents an old manual snapshot from erasing a later successful mutation or authoritative projection. The runtime also supports optimistic entries whose base data was absent and removes them on a not-applied rollback.
4. The mutation bridge derives effect before touching optimistic state:
- `NOT_STARTED` / `NOT_APPLIED`: rollback;
- `APPLIED_CONFIRMED`: commit, then best-effort invalidate;
- `MAYBE_APPLIED`: retain as uncertain, do not invalidate, and enqueue explicit reconciliation.
5. Missing or `NOT_APPLICABLE` command effects, returned failures after dispatch, and thrown execution failures normalize to `MAYBE_APPLIED`. Unknown effects are non-retryable with `contact-support`; applied-confirmed failures are non-retryable with no resend action. Controller-owned stale scope, duplicate admission, identity/preparation, and other known pre-dispatch failures carry `NOT_STARTED`.
6. Unknown records retain their original intent, scope, layer lease, invalidation topics, and coordinator. A FIFO queue prevents parallel `ALLOW_PARALLEL` failures from overwriting each other. Reconciliation is locked through the event turn so a double action cannot consume the next intent, and it does not reset a newer active submit.
7. Scope abort discards every queued record from that scope, settles only its local stale layers, performs no invalidation, and cannot later overwrite new-generation cache data.
8. Async state adds the mutually exclusive `mutation-effect-unknown` overlay with priority `unknown > conflict > pending > stale-degraded > refreshing`. `AsyncSurface` uses dedicated safe copy and only `APPLIED` / `NOT_APPLIED` actions; it does not expose generic retry or mark the surface busy.
9. Unknown-effect admissions live in a bounded QueryClient-owned registry, so bound and legacy controllers can remount without losing reconciliation state. Channels include definition version and generation, validate the exact scope owner, count active admissions globally in O(1), release after late settlement, and preserve FIFO order even when executions finish in reverse.
10. Channel creation and scope-abort listener registration occur only in a committed React effect. An abandoned/server render performs no registry mutation and consumes no channel capacity. Non-optimistic legacy callers must provide a stable `definitionId`; optimistic legacy callers also include their query identity.
11. The reference create form blocks all generic resubmission while effect certainty is unknown. `NOT_APPLIED` preserves input and re-enables submission; `APPLIED` reconciliation and `APPLIED_CONFIRMED` settlement use the form's success-equivalent reset path so the same create command cannot be resent.
## Test coverage
- Certainty matrix for all four mutation effects, missing effect, `NOT_APPLICABLE`, thrown execution, ambiguous conflicts, and non-retry semantics.
- Applied-confirmed commit-before-invalidate ordering and retained commit when invalidation fails.
- Out-of-order later commits behind uncertain layers; both reconciliation outcomes; duplicate/reversed lease transitions; external cache projection; expired scope.
- Bound and legacy reconciliation, no-layer legacy fallback, parallel unknown queues, same-turn double actions, active newer submit preservation, scope-wide stale cleanup, and no-prior-cache rollback.
- Bound and non-optimistic legacy remount durability, unrelated legacy isolation, generation isolation, exact scope-owner collision handling, abandoned-render capacity, late empty-channel cleanup, QueryClient-global admission bounds, reverse completion, and fence-during-invalidation races.
- Production create-form coverage for the `MAYBE_APPLIED` block, `NOT_APPLIED` input preservation, and success-equivalent `APPLIED` / `APPLIED_CONFIRMED` settlement.
- Unknown overlay derivation, mutual-exclusion priority, dedicated localized copy, non-busy state, and reconciliation-only actions.
- The negative async-overlay type fixture now includes `mutationEffectUnknown: false`, so it continues to fail for the intended pending/conflict exclusivity violation.
## Files changed
- `src/presentation/adapters/query/optimistic-layer-runtime.ts`
- `src/presentation/adapters/query/application-query.ts`
- `src/application/view-models/async-state.ts`
- `src/contracts/errors.ts`
- `src/presentation/components/async-surface.tsx`
- `src/presentation/forms/form-contracts.ts`
- `src/presentation/forms/use-app-form.ts`
- `src/presentation/i18n/catalog.ts`
- `src/features/reference-feature/presentation/reference-resource-form-page.tsx`
- `tests/unit/optimistic-layer-runtime.test.ts`
- `tests/component/application-query.test.tsx`
- `tests/component/async-surface.test.tsx`
- `tests/features/reference-feature/reference-page.test.tsx`
- `tests/fixtures/typecheck/invalid-async-overlay.ts`
The AsyncSurface, catalog, UI test, and type-fixture additions are a narrow scope expansion required to avoid rendering the new indicator as a background refresh and to preserve the overlay type contract.
## Verification
- Final focused command (error classification, optimistic runtime, mutation bridge, async surface, form facade, and production reference form): 6 files / 100 tests — PASS.
- `corepack pnpm check:types` — PASS for app, node, test, recipes, web worker, and service worker.
- `corepack pnpm lint` — PASS with zero warnings.
- `corepack pnpm test:all` — PASS: runtime schema 40, unit 741, component 123, integration 23, reference feature 24, recipes 17.
- `git diff --check` — PASS.
- `corepack pnpm run check:types:fixture:async-overlay` — expected non-zero; TypeScript rejects `mutationConflict: true` when `mutationPending: true`, confirming the negative fixture still reaches its intended invariant.
## Self-review decisions
- The plan-prescribed `reconcileUnknownEffect(resolution)` API remains intact. Rather than introduce a public token incompatible with that interface, the controller retains intent-bound FIFO records and serializes reconciliation through the current event turn. A repeated action after the first promise settles is an explicit action on the next visible unknown record.
- Scope cleanup removes stale local projection without claiming or invalidating a server outcome. A stale generation cannot use its former record after the queue is discarded.
- Legacy manual snapshot restoration was removed because it could erase later successful work. Shared ordered layers are the minimal mechanism that gives legacy and bound mutations the same re-projection guarantees.
- A non-optimistic legacy mutation has no cache key from which a durable logical identity can be inferred. Its type contract therefore requires a stable caller-supplied `definitionId`; this preserves remount durability without merging unrelated controllers.
- Registry mutation was moved out of render into the committed effect lifecycle. The server-render regression fills the nominal definition count with abandoned renders, then proves a committed mutation can still acquire and execute.
- Browser/Playwright gates were not run; this task changed no browser-only integration. The jsdom component tests cover the new accessible status and actions.
## Final review
The scoped reviewer completed two fix rounds covering durable ownership, FIFO/races, global bounds, scope fences, and production form settlement. The final verdict reported no findings, independently passed 4 files / 84 tests, confirmed `git diff --check`, and assessed the change ready to merge.
## Runtime final-review fix round 3
The runtime-wide final review identified three additional Task 5 authorities. This round addresses only those findings; the provider-neutral HTTP operation port remains deferred to its separately owned remediation plans.
### RED evidence
- Composite optimistic admission: `tests/unit/optimistic-layer-runtime.test.ts` failed 2 / 8 cases because a base-valid candidate that threw only after the prior layer returned a lease and orphaned both rollback and reconciliation authority.
- Candidate replay: the isolated admission test failed with candidate updater call count `2` instead of `1`; replay through `project()` could still delete the entry after successful preflight.
- Form reconciliation: `tests/component/form-foundation.test.tsx` failed 3 / 8 cases. The hook admitted a second submit during unknown effect, settled edited value B instead of submitted snapshot A, and exposed no explicit not-applied release authority.
- Production namespace parity: the mounted reference-page regression failed because `REFERENCE_RESOURCE_QUERY_NAMESPACE` was not exported; production list/detail hooks could only duplicate its id/version literals.
### Implementation
1. `OptimisticLayerRuntime.begin()` now computes the complete ordered projection before admission. A composite failure returns pessimistic fallback `null` without changing the existing entry, cache projection, layer IDs, or earlier lease authority. The admitted candidate is written from that precomputed value, so its updater runs exactly once during admission.
2. `useAppForm` retains the exact parsed values for a `MAYBE_APPLIED` submission. The ref is the hook-level admission lock until `settleApplied`, `settleNotApplied`, or `reset` releases it; later edits preserve the unknown result and cannot trigger another command. With `resetOnSuccess: false`, APPLIED makes submitted A the baseline while edited B remains dirty. Success, applied-confirmed, validation/conflict/unavailable outcomes, reset, and explicit not-applied settlement clear the retained snapshot.
3. The reference form routes both reconciliation outcomes into the corresponding form settlement authority.
4. `REFERENCE_RESOURCE_QUERY_NAMESPACE` is exported from the governed feature contract. Both production list and detail query definitions consume its fields, while the mounted-key regression compares both real query prefixes with the installed invalidation edge.
### Verification
- Focused runtime/form/reference command: 5 files / 87 tests — PASS.
- `corepack pnpm check:types` — PASS for app, node, test, recipes, web worker, and service worker.
- `corepack pnpm lint` — PASS with zero warnings.
- `corepack pnpm test:all` — PASS: runtime schema 40, unit 744, component 126, integration 23, reference feature 25, recipes 17.
- `git diff --check` — PASS.
- Scoped re-review by the existing Task 5 reviewer: no findings, ready to merge. The reviewer independently passed the 5-file scoped suite (96 / 96), confirmed `git diff --check`, verified all three reconciliation authorities plus candidate single-invocation, and confirmed the deferred HTTP adapter remained untouched.
+2680 -411
View File
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"snapshotDigest": "53468eada148a7afc821b0885b8a53bf47978bfd17adf26b424054beedcce608",
"snapshotDigest": "96b95ef1d50cce36e9fca8a98776a9e2e9e3a5dca24a6288ad83bf29c94aebd8",
"owner": "frontend-platform",
"reason": "Make the reference feature route access policy explicitly session-required",
"approvedAt": "2026-08-01T07:18:30.000Z"
"reason": "Baseline canonical invalidation graph and topic-version contracts after FE-REG-QUERY retirement",
"approvedAt": "2026-08-01T15:15:45.537Z"
}
+342 -66
View File
@@ -5,7 +5,7 @@
"registryId": "FE-REG-ROUTE",
"owner": "feature-frontend-routing-release-recovery-runtime",
"source": "src/features/installed-feature-contracts.ts",
"rowCount": 9,
"rowCount": 10,
"contract": {
"requiredFields": [
"routeId",
@@ -121,6 +121,19 @@
"searchSchema": null,
"title": "인증 연동"
},
"EXAMPLES_PLATFORM": {
"access": "public",
"chunkId": "route-examples-platform",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "플랫폼 구성",
"navigationOrder": 15,
"paramsSchema": null,
"path": "/examples/platform",
"routeId": "EXAMPLES_PLATFORM",
"searchSchema": null,
"title": "플랫폼 구성"
},
"EXAMPLES_STATES": {
"access": "public",
"chunkId": "route-examples-states",
@@ -218,7 +231,7 @@
"registryId": "FE-REG-ROUTE-RUNTIME",
"owner": "feature-frontend-routing-release-recovery-runtime",
"source": "src/features/installed-feature-contracts.ts",
"rowCount": 9,
"rowCount": 10,
"contract": {
"requiredFields": [
"routeId",
@@ -275,6 +288,12 @@
"routeId": "EXAMPLES_AUTH",
"searchCodec": "none"
},
"EXAMPLES_PLATFORM": {
"moduleId": "platform-overview-page",
"paramsCodec": "none",
"routeId": "EXAMPLES_PLATFORM",
"searchCodec": "none"
},
"EXAMPLES_STATES": {
"moduleId": "state-gallery-page",
"paramsCodec": "none",
@@ -408,41 +427,101 @@
"rows": {
"CREATE_REFERENCE_RESOURCE": {
"auth": "external-session",
"authProfileId": "REFERENCE_EXTERNAL_BEARER",
"contractVersion": 2,
"csrfProfileId": "NO_CSRF_BEARER",
"idempotency": "keyed",
"idempotencyKeyPolicy": "REQUIRED",
"mapperId": "ReferenceResourceMapper",
"maxEncodedSearchBytes": 0,
"maxResponseBytes": 32768,
"method": "POST",
"operationId": "CREATE_REFERENCE_RESOURCE",
"owner": "feature-frontend-reference-feature-vertical-slice",
"path": "/api/reference-resources",
"pathParameterNames": [],
"pathSchema": "NoRequest",
"protocol": "REST",
"providerId": "PRIMARY_API",
"replayPolicy": "KEYED_COMMAND",
"requestSchema": "CreateReferenceResourceCommand",
"requestSource": "body",
"responseMediaTypes": [
"application/json"
],
"responseSchema": "ReferenceResourcePayload",
"retry": "runtime",
"semantics": "COMMAND",
"successStatuses": [
200,
201
],
"timeoutMs": null
},
"GET_REFERENCE_RESOURCE": {
"auth": "external-session",
"authProfileId": "REFERENCE_EXTERNAL_BEARER",
"contractVersion": 2,
"csrfProfileId": "NO_CSRF_BEARER",
"idempotency": "safe",
"idempotencyKeyPolicy": "NONE",
"mapperId": "ReferenceResourceMapper",
"maxEncodedSearchBytes": 0,
"maxResponseBytes": 32768,
"method": "GET",
"operationId": "GET_REFERENCE_RESOURCE",
"owner": "feature-frontend-form-page-platform",
"owner": "feature-frontend-reference-feature-vertical-slice",
"path": "/api/reference-resources/{resourceId}",
"pathParameterNames": [
"resourceId"
],
"pathSchema": "ReferenceResourceParams",
"protocol": "REST",
"providerId": "PRIMARY_API",
"replayPolicy": "SAFE",
"requestSchema": "NoRequest",
"requestSource": "none",
"responseMediaTypes": [
"application/json"
],
"responseSchema": "ReferenceResourcePayload",
"retry": "runtime",
"semantics": "QUERY",
"successStatuses": [
200
],
"timeoutMs": null
},
"LIST_REFERENCE_RESOURCES": {
"auth": "external-session",
"authProfileId": "REFERENCE_EXTERNAL_BEARER",
"contractVersion": 2,
"csrfProfileId": "NO_CSRF_BEARER",
"idempotency": "safe",
"idempotencyKeyPolicy": "NONE",
"mapperId": "ReferenceResourceListMapper",
"maxEncodedSearchBytes": 4096,
"maxResponseBytes": 262144,
"method": "GET",
"operationId": "LIST_REFERENCE_RESOURCES",
"owner": "feature-frontend-reference-feature-vertical-slice",
"path": "/api/reference-resources",
"pathParameterNames": [],
"pathSchema": "NoRequest",
"protocol": "REST",
"providerId": "PRIMARY_API",
"replayPolicy": "SAFE",
"requestSchema": "ReferenceResourceListQuery",
"requestSource": "search",
"responseMediaTypes": [
"application/json"
],
"responseSchema": "ReferenceResourceListPayload",
"retry": "runtime",
"semantics": "QUERY",
"successStatuses": [
200
],
"timeoutMs": null
}
}
@@ -491,9 +570,12 @@
"rows": {
"CreateReferenceResourceCommand": {
"boundary": "api-request",
"direction": "REQUEST",
"owner": "feature-frontend-reference-feature-vertical-slice",
"runtime": "zod",
"schemaId": "CreateReferenceResourceCommand"
"schemaId": "CreateReferenceResourceCommand",
"schemaVersion": 1,
"unknownFieldPolicy": "REJECT_UNKNOWN"
},
"none": {
"boundary": "route-params",
@@ -503,9 +585,12 @@
},
"NoRequest": {
"boundary": "api-request",
"direction": "REQUEST",
"owner": "feature-frontend-reference-feature-vertical-slice",
"runtime": "zod",
"schemaId": "NoRequest"
"schemaId": "NoRequest",
"schemaVersion": 1,
"unknownFieldPolicy": "REJECT_UNKNOWN"
},
"NotFoundSplat": {
"boundary": "route-params",
@@ -515,27 +600,39 @@
},
"ReferenceResourceListPayload": {
"boundary": "api-response",
"direction": "RESPONSE",
"owner": "feature-frontend-reference-feature-vertical-slice",
"runtime": "zod",
"schemaId": "ReferenceResourceListPayload"
"schemaId": "ReferenceResourceListPayload",
"schemaVersion": 1,
"unknownFieldPolicy": "STRIP_UNKNOWN"
},
"ReferenceResourceListQuery": {
"boundary": "route-search-api-request",
"direction": "REQUEST",
"owner": "feature-frontend-reference-feature-vertical-slice",
"runtime": "zod",
"schemaId": "ReferenceResourceListQuery"
"schemaId": "ReferenceResourceListQuery",
"schemaVersion": 1,
"unknownFieldPolicy": "REJECT_UNKNOWN"
},
"ReferenceResourceParams": {
"boundary": "route-params",
"direction": "REQUEST",
"owner": "feature-frontend-reference-feature-vertical-slice",
"runtime": "zod",
"schemaId": "ReferenceResourceParams"
"schemaId": "ReferenceResourceParams",
"schemaVersion": 1,
"unknownFieldPolicy": "REJECT_UNKNOWN"
},
"ReferenceResourcePayload": {
"boundary": "api-response",
"direction": "RESPONSE",
"owner": "feature-frontend-reference-feature-vertical-slice",
"runtime": "zod",
"schemaId": "ReferenceResourcePayload"
"schemaId": "ReferenceResourcePayload",
"schemaVersion": 1,
"unknownFieldPolicy": "STRIP_UNKNOWN"
}
}
},
@@ -585,12 +682,6 @@
"phase": "runtime",
"required": true
},
"API_CONTRACT_VERSION": {
"classification": "public",
"defaultValue": null,
"phase": "runtime",
"required": true
},
"APP_ENV": {
"classification": "public",
"defaultValue": null,
@@ -603,6 +694,12 @@
"phase": "runtime",
"required": true
},
"CAPABILITY_OVERRIDES": {
"classification": "public",
"defaultValue": null,
"phase": "runtime",
"required": false
},
"CONFIG_SCHEMA_VERSION": {
"classification": "public",
"defaultValue": null,
@@ -795,7 +892,7 @@
"registryId": "FE-REG-ERROR",
"owner": "feature-frontend-error-classification-boundary-contract",
"source": "src/contracts/errors.ts",
"rowCount": 31,
"rowCount": 38,
"contract": {
"requiredFields": [
"kind",
@@ -1041,6 +1138,24 @@
"telemetryEvent": "release.mismatch.detected",
"userMessageKey": "error.deploy_mismatch"
},
"DUPLICATE_IN_FLIGHT": {
"action": "none",
"defaultRetryable": false,
"kind": "DUPLICATE_IN_FLIGHT",
"redaction": [
"authorization",
"body",
"cause",
"headers",
"query",
"stack",
"storageValue",
"url"
],
"severity": "info",
"telemetryEvent": "api.request.failed",
"userMessageKey": "error.duplicate_in_flight"
},
"ENVELOPE_MISMATCH": {
"action": "contact-support",
"defaultRetryable": false,
@@ -1077,6 +1192,24 @@
"telemetryEvent": "api.request.failed",
"userMessageKey": "error.forbidden"
},
"IDENTITY_INTERN_LIMIT_EXCEEDED": {
"action": "retry",
"defaultRetryable": false,
"kind": "IDENTITY_INTERN_LIMIT_EXCEEDED",
"redaction": [
"authorization",
"body",
"cause",
"headers",
"query",
"stack",
"storageValue",
"url"
],
"severity": "warning",
"telemetryEvent": "api.request.failed",
"userMessageKey": "error.identity_intern_limit_exceeded"
},
"MALFORMED_JSON": {
"action": "contact-support",
"defaultRetryable": false,
@@ -1095,6 +1228,24 @@
"telemetryEvent": "api.request.failed",
"userMessageKey": "error.malformed_json"
},
"MAPPING_CONTRACT_VIOLATION": {
"action": "contact-support",
"defaultRetryable": false,
"kind": "MAPPING_CONTRACT_VIOLATION",
"redaction": [
"authorization",
"body",
"cause",
"headers",
"query",
"stack",
"storageValue",
"url"
],
"severity": "error",
"telemetryEvent": "api.request.failed",
"userMessageKey": "error.mapping_contract_violation"
},
"NETWORK_UNREACHABLE": {
"action": "retry",
"defaultRetryable": true,
@@ -1131,6 +1282,24 @@
"telemetryEvent": "api.request.failed",
"userMessageKey": "error.not_found"
},
"PAGINATION_CONTRACT_VIOLATION": {
"action": "contact-support",
"defaultRetryable": false,
"kind": "PAGINATION_CONTRACT_VIOLATION",
"redaction": [
"authorization",
"body",
"cause",
"headers",
"query",
"stack",
"storageValue",
"url"
],
"severity": "error",
"telemetryEvent": "api.request.failed",
"userMessageKey": "error.pagination_contract_violation"
},
"QUERY_CACHE_FAILURE": {
"action": "retry",
"defaultRetryable": false,
@@ -1257,6 +1426,42 @@
"telemetryEvent": "api.request.failed",
"userMessageKey": "error.request_timeout"
},
"RESPONSE_BODY_LIMIT": {
"action": "contact-support",
"defaultRetryable": false,
"kind": "RESPONSE_BODY_LIMIT",
"redaction": [
"authorization",
"body",
"cause",
"headers",
"query",
"stack",
"storageValue",
"url"
],
"severity": "error",
"telemetryEvent": "api.request.failed",
"userMessageKey": "error.response_body_limit"
},
"RESULT_LIMIT_EXCEEDED": {
"action": "contact-support",
"defaultRetryable": false,
"kind": "RESULT_LIMIT_EXCEEDED",
"redaction": [
"authorization",
"body",
"cause",
"headers",
"query",
"stack",
"storageValue",
"url"
],
"severity": "error",
"telemetryEvent": "api.request.failed",
"userMessageKey": "error.result_limit_exceeded"
},
"SCHEMA_MISMATCH": {
"action": "contact-support",
"defaultRetryable": false,
@@ -1275,6 +1480,24 @@
"telemetryEvent": "api.request.failed",
"userMessageKey": "error.schema_mismatch"
},
"SCOPE_GENERATION_CHANGED": {
"action": "none",
"defaultRetryable": false,
"kind": "SCOPE_GENERATION_CHANGED",
"redaction": [
"authorization",
"body",
"cause",
"headers",
"query",
"stack",
"storageValue",
"url"
],
"severity": "info",
"telemetryEvent": "api.request.failed",
"userMessageKey": "error.scope_generation_changed"
},
"SERVER_FAILURE": {
"action": "retry",
"defaultRetryable": true,
@@ -1404,68 +1627,116 @@
}
},
{
"registryId": "FE-REG-QUERY",
"registryId": "FE-REG-QUERY-INVALIDATION",
"owner": "feature-frontend-server-state-caching-contract",
"source": "src/features/installed-feature-contracts.ts",
"rowCount": 1,
"contract": {
"requiredFields": [
"namespace",
"serialization",
"identity",
"invalidation",
"invalidationTopic",
"crossContext",
"version",
"persistence"
"topicId",
"namespace.namespaceId",
"namespace.namespaceVersion"
],
"fieldTypes": {
"namespace": "array",
"serialization": "string",
"identity": "string",
"invalidation": "string",
"invalidationTopic": "string",
"crossContext": "string",
"version": "integer",
"persistence": "string"
},
"uniqueFields": [
"namespace",
"invalidationTopic"
],
"allowedValues": {
"crossContext": [
"invalidate-only"
],
"persistence": [
"disabled"
]
"topicId": "string",
"namespace.namespaceId": "string",
"namespace.namespaceVersion": "integer"
},
"uniqueFields": [],
"allowedValues": {},
"references": [],
"keyField": null,
"breakingFields": [
"namespace",
"serialization",
"identity",
"invalidationTopic",
"crossContext",
"version",
"persistence"
"topics",
"namespaces",
"edges"
],
"rowsPath": "edges",
"rowKeyFields": [
"topicId",
"namespace.namespaceId",
"namespace.namespaceVersion"
],
"uniqueFieldSets": [
[
"topicId",
"namespace.namespaceId",
"namespace.namespaceVersion"
]
],
"snapshotProjection": {
"singletonRowKey": "invalidation-graph",
"canonicalArrayKeyFields": {
"topics": [
"$value"
],
"namespaces": [
"namespaceId",
"namespaceVersion"
],
"edges": [
"topicId",
"namespace.namespaceId",
"namespace.namespaceVersion"
]
}
}
},
"rows": {
"invalidation-graph": {
"edges": [
{
"namespace": {
"namespaceId": "reference-resource",
"namespaceVersion": 1
},
"topicId": "qinv.01k10f7m3w9p6r2c8v5n4x"
}
],
"namespaces": [
{
"namespaceId": "reference-resource",
"namespaceVersion": 1
}
],
"topics": [
"qinv.01k10f7m3w9p6r2c8v5n4x"
]
}
}
},
{
"registryId": "FE-REG-QUERY-INVALIDATION-TOPIC-VERSION",
"owner": "feature-frontend-server-state-caching-contract",
"source": "src/features/installed-feature-contracts.ts",
"rowCount": 1,
"contract": {
"requiredFields": [
"topicId",
"topicVersion"
],
"fieldTypes": {
"topicId": "string",
"topicVersion": "integer"
},
"uniqueFields": [
"topicId"
],
"allowedValues": {},
"references": [],
"keyField": null,
"breakingFields": [
"topicId",
"topicVersion"
],
"rowKeyFields": [
"topicId"
]
},
"rows": {
"REFERENCE_RESOURCE": {
"crossContext": "invalidate-only",
"identity": "no-pii-token-or-raw-url",
"invalidation": "reference resource namespace after successful mutation",
"invalidationTopic": "qinv.01k10f7m3w9p6r2c8v5n4x",
"namespace": [
"reference-resource",
1
],
"persistence": "disabled",
"serialization": "canonical-object-order",
"version": 1
"[\"qinv.01k10f7m3w9p6r2c8v5n4x\"]": {
"topicId": "qinv.01k10f7m3w9p6r2c8v5n4x",
"topicVersion": 1
}
}
},
@@ -1655,7 +1926,7 @@
"registryId": "FE-REG-RELEASE",
"owner": "feature-frontend-release-cache-rollback-contract",
"source": "src/contracts/release-tokens.ts",
"rowCount": 8,
"rowCount": 9,
"contract": {
"requiredFields": [
"token",
@@ -1681,7 +1952,7 @@
},
"rows": {
"apiContractVersion": {
"compatibilityRole": "schema compatibility",
"compatibilityRole": "legacy V1 scalar; superseded by contractSetDigest",
"source": "frontend/backend agreement",
"token": "apiContractVersion"
},
@@ -1715,6 +1986,11 @@
"source": "runtime config schema",
"token": "configSchemaVersion"
},
"contractSetDigest": {
"compatibilityRole": "release coherence for multi-package contracts",
"source": "compiled external contract package set",
"token": "contractSetDigest"
},
"releaseId": {
"compatibilityRole": "rollback target",
"source": "deploy system",
@@ -9,6 +9,14 @@
"rollback": "Restore the V1 writer in scripts/generate-build-manifest.ts and the scalar key in public/config.json; the V1 reader is still present.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-QUERY:*:*:removed",
"versionBump": "Query invalidation composition moves from the legacy flat query registry to the bounded many-to-many invalidation graph.",
"migration": "Installed feature contracts now contribute topics, namespace identities, edges, and separate wire versions; bootstrap validates and indexes those contributions before constructing coordinators.",
"compatibilityWindow": "Cross-context envelopes remain opaque topic/version pairs and release cache epochs isolate mixed releases; no query keys or cached values cross contexts.",
"rollback": "Restore the flat QUERY_REGISTRY composition and its coordinator adapter together with the prior governance entry.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-QUERY:$contract:allowedValues:contract-field-changed",
"versionBump": "Cross-context invalidation wire protocol starts at version 1.",
+55 -33
View File
@@ -380,50 +380,72 @@
"breakingFields": ["kind", "userMessageKey", "action", "telemetryEvent"]
},
{
"registryId": "FE-REG-QUERY",
"registryId": "FE-REG-QUERY-INVALIDATION",
"path": "src/features/installed-feature-contracts.ts",
"exportName": "QUERY_REGISTRY",
"exportName": "INVALIDATION_REGISTRY",
"rowsPath": "edges",
"rowKeyFields": [
"topicId",
"namespace.namespaceId",
"namespace.namespaceVersion"
],
"owner": "feature-frontend-server-state-caching-contract",
"requiredFields": [
"namespace",
"serialization",
"identity",
"invalidation",
"invalidationTopic",
"crossContext",
"version",
"persistence"
"topicId",
"namespace.namespaceId",
"namespace.namespaceVersion"
],
"fieldTypes": {
"namespace": "array",
"serialization": "string",
"identity": "string",
"invalidation": "string",
"invalidationTopic": "string",
"crossContext": "string",
"version": "integer",
"persistence": "string"
"topicId": "string",
"namespace.namespaceId": "string",
"namespace.namespaceVersion": "integer"
},
"uniqueFields": ["namespace", "invalidationTopic"],
"allowedValues": {
"crossContext": ["invalidate-only"],
"persistence": ["disabled"]
"uniqueFieldSets": [
[
"topicId",
"namespace.namespaceId",
"namespace.namespaceVersion"
]
],
"snapshotProjection": {
"singletonRowKey": "invalidation-graph",
"canonicalArrayKeyFields": {
"topics": ["$value"],
"namespaces": ["namespaceId", "namespaceVersion"],
"edges": [
"topicId",
"namespace.namespaceId",
"namespace.namespaceVersion"
]
}
},
"consumers": [
{
"path": "src/features/reference-feature/contracts/reference-feature-contract.ts",
"token": "referenceQueryKeys"
"path": "src/bootstrap/runtime-adapters.ts",
"token": "indexInvalidationRegistry(INVALIDATION_REGISTRY)"
}
],
"breakingFields": [
"namespace",
"serialization",
"identity",
"invalidationTopic",
"crossContext",
"version",
"persistence"
]
"breakingFields": ["topics", "namespaces", "edges"]
},
{
"registryId": "FE-REG-QUERY-INVALIDATION-TOPIC-VERSION",
"path": "src/features/installed-feature-contracts.ts",
"exportName": "INVALIDATION_TOPIC_VERSIONS",
"rowKeyFields": ["topicId"],
"owner": "feature-frontend-server-state-caching-contract",
"requiredFields": ["topicId", "topicVersion"],
"fieldTypes": {
"topicId": "string",
"topicVersion": "integer"
},
"uniqueFields": ["topicId"],
"consumers": [
{
"path": "src/bootstrap/runtime-adapters.ts",
"token": "indexInvalidationTopicVersions("
}
],
"breakingFields": ["topicId", "topicVersion"]
},
{
"registryId": "FE-REG-TELEMETRY",
+22 -1
View File
@@ -9,13 +9,34 @@
"public",
"schemas",
".storybook",
".gitea/workflows/quality-gates.yml",
".dependency-cruiser.json",
".nvmrc",
".npmrc",
"eslint.config.ts",
"index.html",
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.base.json",
"tsconfig.node.json",
"tsconfig.recipes.json",
"tsconfig.service-worker.json",
"tsconfig.test.json",
"tsconfig.web-worker.json",
"vite.config.ts",
"vite.service-worker.config.ts",
"vitest.config.ts",
"playwright.config.ts"
"playwright.config.ts",
"playwright.capabilities.config.ts",
"playwright.dev.config.ts",
"playwright.storybook.config.ts",
"playwright.visual.config.ts"
],
"generatedRoots": ["dist", "artifacts/release"],
"optionalRoots": ["artifacts/release"],
"excludedPaths": [
"tests/fixtures/security/secret-detection/forbidden"
],
+91 -80
View File
@@ -1,119 +1,130 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"repositoryBaseline": 285,
"generatedPaths": [],
"summary": {
"lines": 80,
"statements": 78,
"functions": 85,
"lines": 75,
"statements": 73,
"functions": 80,
"branches": 68
},
"criticalModules": [
{
"path": "src/adapters/http/bounded-body-reader.ts",
"owner": "http-runtime",
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 90 }
},
{
"path": "src/adapters/http/bounded-json.ts",
"owner": "http-runtime",
"minimum": { "lines": 85, "statements": 84, "functions": 95, "branches": 78 }
},
{
"path": "src/adapters/http/http-execution-v3.ts",
"owner": "http-runtime",
"minimum": { "lines": 75, "statements": 73, "functions": 70, "branches": 52 }
},
{
"path": "src/adapters/http/request-builder.ts",
"owner": "http-runtime",
"minimum": { "lines": 85, "statements": 85, "functions": 95, "branches": 82 }
},
{
"path": "src/adapters/http/retry-policy.ts",
"minimum": {
"lines": 80,
"statements": 78,
"functions": 95,
"branches": 78
}
"owner": "http-runtime",
"minimum": { "lines": 80, "statements": 78, "functions": 95, "branches": 78 }
},
{
"path": "src/adapters/query-cache/server-state-scope-runtime.ts",
"owner": "server-state-runtime",
"minimum": { "lines": 85, "statements": 85, "functions": 85, "branches": 75 }
},
{
"path": "src/adapters/service-worker/service-worker-lifecycle.ts",
"owner": "service-worker-runtime",
"minimum": { "lines": 64, "statements": 60, "functions": 65, "branches": 43 }
},
{
"path": "src/adapters/storage/browser-storage-adapter.ts",
"minimum": {
"lines": 60,
"statements": 60,
"functions": 70,
"branches": 60
}
"owner": "storage-runtime",
"minimum": { "lines": 60, "statements": 60, "functions": 70, "branches": 60 }
},
{
"path": "src/adapters/telemetry/best-effort-telemetry.ts",
"minimum": {
"lines": 85,
"statements": 85,
"functions": 70,
"branches": 75
}
"owner": "telemetry-runtime",
"minimum": { "lines": 85, "statements": 85, "functions": 70, "branches": 75 }
},
{
"path": "src/application/create-application.ts",
"minimum": {
"lines": 90,
"statements": 90,
"functions": 80,
"branches": 68
}
"owner": "application-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 80, "branches": 68 }
},
{
"path": "src/application/policies/compatibility.ts",
"minimum": {
"lines": 95,
"statements": 95,
"functions": 95,
"branches": 75
}
"owner": "application-policy",
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 75 }
},
{
"path": "src/application/policies/performance-budgets.ts",
"minimum": {
"lines": 80,
"statements": 80,
"functions": 80,
"branches": 40
}
"owner": "application-policy",
"minimum": { "lines": 80, "statements": 80, "functions": 80, "branches": 40 }
},
{
"path": "src/application/policies/promotion-readiness.ts",
"minimum": {
"lines": 95,
"statements": 95,
"functions": 95,
"branches": 95
}
"owner": "release-runtime",
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 95 }
},
{
"path": "src/application/use-cases/decide-chunk-recovery.ts",
"minimum": {
"lines": 90,
"statements": 90,
"functions": 95,
"branches": 85
}
"owner": "application-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 95, "branches": 85 }
},
{
"path": "src/bootstrap/load-release-manifest.ts",
"owner": "release-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
},
{
"path": "src/bootstrap/read-bounded-boot-json.ts",
"owner": "bootstrap-runtime",
"minimum": { "lines": 71, "statements": 66, "functions": 48, "branches": 57 }
},
{
"path": "src/contracts/diagnostics.ts",
"minimum": {
"lines": 68,
"statements": 68,
"functions": 95,
"branches": 58
}
"owner": "diagnostics-contracts",
"minimum": { "lines": 68, "statements": 68, "functions": 95, "branches": 58 }
},
{
"path": "src/features/reference-feature/adapters/reference-http-gateway.ts",
"minimum": {
"lines": 90,
"statements": 90,
"functions": 90,
"branches": 90
}
"owner": "reference-feature",
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 90 }
},
{
"path": "src/presentation/adapters/query/application-query.ts",
"minimum": {
"lines": 90,
"statements": 90,
"functions": 90,
"branches": 80
"owner": "presentation-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
}
},
{
"path": "scripts/lib/registry-compatibility.ts",
"minimum": {
"lines": 80,
"statements": 80,
"functions": 85,
"branches": 60
}
}
]
],
"highRiskPaths": [
"src/adapters/http/bounded-body-reader.ts",
"src/adapters/http/bounded-json.ts",
"src/adapters/http/http-execution-v3.ts",
"src/adapters/http/request-builder.ts",
"src/adapters/http/retry-policy.ts",
"src/adapters/query-cache/server-state-scope-runtime.ts",
"src/adapters/service-worker/service-worker-lifecycle.ts",
"src/adapters/storage/browser-storage-adapter.ts",
"src/adapters/telemetry/best-effort-telemetry.ts",
"src/application/create-application.ts",
"src/application/policies/compatibility.ts",
"src/application/policies/performance-budgets.ts",
"src/application/policies/promotion-readiness.ts",
"src/application/use-cases/decide-chunk-recovery.ts",
"src/bootstrap/load-release-manifest.ts",
"src/bootstrap/read-bounded-boot-json.ts",
"src/contracts/diagnostics.ts",
"src/features/reference-feature/adapters/reference-http-gateway.ts",
"src/presentation/adapters/query/application-query.ts"
],
"waivers": []
}
+4 -3
View File
@@ -1,11 +1,12 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"scenarioCatalogs": [
{
"owner": "reference-feature",
"path": "tests/mocks/scenarios/catalog.ts",
"arrayExport": "HTTP_SCENARIO_IDS",
"minimumEntries": 19
"expectationExport": "HTTP_SCENARIO_EXPECTATIONS",
"receiptPath": "artifacts/tests/http-scenario-executions.json",
"receiptSchemaVersion": 1
}
],
"sourceContracts": [
@@ -36,19 +36,26 @@ finding과 서명 성공으로 표현하면 local 검증과 release promotion을
5. inventory를 CycloneDX 1.6 SBOM으로 투영한다. component 수, lockfile digest,
SRI, license와 dependency edge가 inventory와 일치해야 한다.
6. local in-toto/SLSA 형태 provenance statement는 source set, lockfile, SBOM과
`dist` digest를 연결하되 `LOCAL_UNSIGNED`로 표시한다. 외부 attestation은
provider, signer와 동일 dist subject digest가 있어야 한다.
7. vulnerability adapter는 `VULNERABILITY_REPORT_PATH`가 가리키는
machine-readable provider report를 검증한다. report의 lock digest, provider,
severity와 exception owner/reviewer/reason/expiry가 유효해야 한다.
8. provider report가 없으면 local inventory/license/SBOM/coherence는 `PASS`,
promotion은 `FAIL_UNVERIFIED`다. 빈 finding을 만들어 vulnerability PASS로
표시하지 않는다.
9. secret scan은 source, scripts, tests, tracked config/schema, public, `dist`
`dist` digest를 연결하되 `LOCAL_UNSIGNED`로 표시한다. 이 문서는 외부
provenance를 대신할 수 없다.
7. `immutable_build`는 raw `pnpm-lock.yaml`, `dist`, build/module inventory와
모든 local verification evidence를 한 번만 archive한다. Candidate manifest는
raw lock bytes SHA-256, dependency inventory lock digest와 manifest
`lockfileSha256`의 exact 일치를 요구한다.
8. provider job은 동일 archive를 각각 받아 외부 command를 실행한다.
Vulnerability report는 raw lock digest와 `distSha256`, provenance attestation은
`{name: "dist", digest.sha256}`를 포함한다. 두 문서 모두 strict schema
별도 trust path/key ID로 선택한 실제 Ed25519 public key 서명을 통과해야 한다.
9. provider report나 trusted key가 없으면 local
inventory/license/SBOM/coherence는 `PASS`, promotion은
`FAIL_UNVERIFIED`다. 저장소 generator나 fixture가 production용 빈 finding 또는
signed PASS를 만들지 않는다.
10. secret scan은 source, scripts, tests, tracked config/schema, public, `dist`
generated release metadata를 검사한다. allowlist는 test path에만 허용하며
owner, reason과 expiry가 필요하다. 발견한 secret 원문은 artifact에 쓰지 않고
rule, path, line과 fingerprint만 남긴다.
10. `SOURCE_DATE_EPOCH`를 지원하고 같은 source/lock/config의 production build를
11. `SOURCE_DATE_EPOCH`를 지원하고 supply-chain timestamp도 build manifest의
동일 epoch에 결합한다. 같은 source/lock/config의 production build를
두 번 실행해 전체 dist digest 일치를 검증한 뒤 일반 build를 복원한다.
## 실행 경계와 증적
@@ -61,8 +68,10 @@ package.json + frozen pnpm-lock.yaml + installed graph
source/config/lock + production dist
-> local provenance statement
-> optional vulnerability/attestation provider inputs
-> LOCAL PASS | promotion PASS/FAIL_UNVERIFIED
-> immutable archive + candidate manifest + distSha256
-> external vulnerability provider + external provenance provider
-> read-only local revalidation + signature/digest verification
-> promotion PASS | FAIL_UNVERIFIED
```
- policy: `config/security/`
@@ -82,8 +91,9 @@ source/config/lock + production dist
- ordering-only digest, removal, integrity tamper, baseline tamper, high-risk
self approval, denied license, critical vulnerability와 만료 exception,
provider/digest 오류, SBOM/provenance 불일치 fixture를 검사한다.
- synthetic provider/attestation fixture는 promotion `PASS`를 증명한 후 기본
`FAIL_UNVERIFIED` 상태를 복원한다.
- isolated temporary candidate/PEM/report fixture는 실제 environment path wiring을
통해 valid immutable 입력만 promotion `PASS`임을 증명한다. Production artifact를
덮어쓰거나 generator를 provider 모드로 재실행하지 않는다.
- frozen install은 manifest/lock mismatch fixture를 실제 pnpm으로 거절한다.
- source/config/dist 각각의 synthetic secret fixture가 실제 scan을 실패시키고
scoped test allowlist만 통과한다.
@@ -97,7 +107,7 @@ attestation transparency/retention과 비밀 관리를 결정해야 한다.
## Rollback
외부 scanner/attestor adapter는 환경 입력을 제거하면 즉시
외부 scanner/attestor command, report path 또는 trusted key 설정을 제거하면 즉시
`FAIL_UNVERIFIED`로 돌아간다. local inventory, lock integrity, license, SBOM,
secret, reproducibility와 actual diff gate는 유지한다. scanner 장애를 이유로
promotion을 PASS로 변경하지 않는다.
+248 -5
View File
@@ -1,9 +1,48 @@
# CI quality-gate orchestration
`config/ci/gates.json` is the executable registry for all 26 gates. The Gitea
`config/ci/gates.json` is the executable registry for all 26 gates and the sole
workflow model. `.gitea/workflows/quality-gates.yml` is generated from that
strict v2 contract; it is not an independent configuration authority. Update
the registry, run `corepack pnpm generate:ci-workflow`, and commit the generated
bytes together. `corepack pnpm check:ci-workflow` rejects missing, stale, CRLF,
or otherwise byte-different output. `corepack pnpm check:ci` runs artifact-schema
checking, the typed contract checker, and this workflow drift check. The Gitea
adapter runs each gate as an independent matrix check with full fan-out and no
soft-fail wiring.
Contract loading validates every registered command entry against the
authoritative root package-script graph and lifecycle/install policy before the
runner enters its execution callback. Foreign cwd/workspace script dispatch,
argument-sensitive dispatchers, and ineffective lifecycle suppression therefore
fail preflight even when the later `check:ci` command would never run.
For npm, that root-only boundary also parses options following an explicit
`run`/`run-script` target or implicit `test`/`start`/`stop`/`restart` target:
`--workspace`, `-w`, `--workspaces`, and `--prefix` are foreign manifest scope
and are rejected before execution. The first literal `--` ends npm option
parsing, so later tokens are ordinary script arguments. pnpm and Yarn differ:
their options following the selected script name are forwarded to that script;
their cwd/workspace selectors are rejected only where the manager consumes
them before script selection.
The npm graph includes existing root-manifest `pre<script>` and `post<script>`
hooks around every explicit `run`/`run-script` dependency and implicit
`start`/`stop`/`restart`/`test` dependency. Hooks are omitted only when ordered
npm options establish an unambiguous effective `--ignore-scripts` value before
the first literal `--`; bare and explicitly true forms omit hooks, while false,
negative, contradictory, malformed, and post-delimiter forms keep traversal or
fail closed. Case-insensitive `npm_config_workspace`, `npm_config_workspaces`,
`npm_config_prefix`, `npm_config_userconfig`, and `npm_config_globalconfig`
assignments are rejected whenever the tokenized command invokes npm. Direct
assignments, static paths whose basename is `env`, exact `command`/`exec`
prefix chains, prior exports, and `set -a` assignments share one prefix grammar.
Only modeled non-scope `env -i`, `env -u`, `env --unset`, and `env --` forms are
allowed; cwd-changing or unknown options fail closed. Dynamic assignment names,
unmodeled environment mutation, npm `--userconfig`/`--globalconfig`, and
unquoted pathname expansion before the npm argument delimiter are rejected.
The gate runner checks the same inherited environment names before loading the
contract or entering the execution callback, so they cannot reach a child gate
process.
The dependency graph is:
```text
@@ -32,10 +71,137 @@ full commit ID와 commit timestamp를 한 번 읽어 `SOURCE_DATE_EPOCH`를 유
release manifest도 같은 build ID, commit SHA, release ID와 timestamp를 가져야
한다.
Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다. 이
workflow를 required check로 전환하기 전에 staging instance에서
`permissions`, `gitea.run_attempt`, `actions/upload-artifact@v4`를 포함한 한
번의 전체 provider smoke를 통과시켜야 한다. 모든 setup step은
Release tier의 `immutable_build``dist`의 hidden `.vite` entries, raw
`pnpm-lock.yaml`, build/module inventory와 local release/security evidence를 한
번 생성해 run ID/attempt로 구분된 archive로 게시한다. Raw lock bytes의 SHA-256은
dependency inventory와 candidate manifest 양쪽의 `lockfileSha256`과 같아야 한다.
Producer는 게시 전에 manifest가 선언한 exact member set과 각 byte digest를
검증하고 archive SHA-256을 job output으로 고정한다. Vulnerability/provenance
provider job은 같은 archive를 각각 내려받아 output SHA-256과 exact member set을
확인한 뒤 checkout과 분리된 root에만 추출하고, `CANDIDATE_LOCKFILE_PATH`
`CANDIDATE_DIST_SHA256`를 외부 command에 전달한다. Provider command 실행 후에는
archive와 추출된 candidate를 다시 검증하고, strict provider JSON과 candidate
digest가 일치할 때만 report를 업로드한다. Promotion은 같은 archive와 두 report를
별도 경로로 내려받고 SHA/member 검증을 마친 뒤 격리된 root에 추출하여 local
evidence를 read-only로 다시 계산하고 Ed25519 signature/digest를 확인한다.
Promotion job에는 build/rebuild command가 없으며 검증한 archive 자체를 변경 없이
그대로 승격한다. Local evidence 관계 검증에 필요한 policy와 verifier source도
archive member로 고정되며, checkout 밖의 격리된 cwd에서도 archive path와 기대
digest만으로 candidate-internal check를 재계산하고 archived secret-scan
policy/rule/SARIF/zero-finding/digest 관계를 검증한다. Archive에 없는 checkout
source를 다시 scan했다고 주장하지 않는다.
Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다. Linux
runner에는 실행 가능한 `/usr/bin/bwrap`, `/usr/bin/prlimit`,
`/usr/bin/systemd-run`, `/usr/bin/systemctl`, `bwrap --size` 지원, active user
bus, systemd user manager 254 이상, unified cgroup v2와 delegated
memory/pids/CPU controller가 모두 필요하다. 실행 파일, user manager/version,
trust/archive/path 같은 host-side preflight 실패는 raw report 생성 전에 차단된다.
`bwrap --size` 수용 여부와 실제 delegated controller/limit 값은 owned raw inode를
만든 뒤 scope 안에서만 확정할 수 있으며, 이 단계의 실패는 해당 inode를
identity-bound cleanup하고 fail closed한다.
각 provider는 고유한 collected user scope에서 실행된다. Supervisor는 실행 전에
실제 cgroup membership과 `memory.max=1073741824`, `memory.swap.max=0`,
`pids.max=64`, `cpu.max="100000 100000"`을 확인한다. 내부 process에는 core 0,
file-size 8,388,607 bytes, open FD 64, CPU 1,200초 상한도 적용된다.
같은 UID 전체에 합산되는 `RLIMIT_NPROC`로 provider별 32개를 보장한다고 주장하지
않으며 aggregate PID authority는 cgroup `TasksMax=64`다.
Bubblewrap는 network namespace를 분리해 완전 offline으로 실행하고 workspace,
verified candidate, `.git` mask, `/tmp`, `/etc`, `/proc`, `/dev`를 read-only로
유지한다. Archive 검증·추출과 sandbox/trust preflight가 끝난 뒤 supervisor가
생성하고 inode를 고정한 정확한 raw report 파일 하나만 read-write bind된다.
주변 `untrusted` directory 전체는 writable이 아니다. 실행 전 또는 provider
실패 시, 그리고 성공적으로 sealed evidence를 게시한 뒤에도 supervisor가 소유한
inode만 atomic quarantine을 거쳐 제거하므로 빈 stale report 없이 재시도할 수 있다.
Provider command와 provider-prefixed environment는 bounded length-prefixed bwrap vector로
`systemd-run` stdin에 전달되어 supervisor/systemd/bwrap wrapper argv나 unit
metadata에 노출되지 않는다. 단, 최종 provider executable의 일반 argv는 같은
UID의 process inspection에 보일 수 있으므로 command 문자열과 인자에 token,
password, private-key material을 넣으면 안 된다. Credential은 반드시 해당 종류의
`VULNERABILITY_PROVIDER_*` 또는 `PROVENANCE_PROVIDER_*` environment로만 전달하고
`*_COMMAND`에는 넣지 않는다. 정상 provider 종료까지 같은 stdin을 parent-liveness
pipe로 열어 두며 supervisor hard death의 EOF를 받은 in-scope wrapper는 provider
process group 전체를 종료하고 dev/inode가 일치하는 raw report만 정리한다.
별도의 trusted guardian child는 provider scope 밖에서 filesystem transaction 전체를
소유한다. Client는 spawn 전에 canonical raw/evidence directory를
`O_DIRECTORY|O_NOFOLLOW`로 열고 identity를 확인한 뒤 provider kind와 nonce에서
canonical raw/final 및 nonce-private raw-staging/sealed-temp exact leaf를 확정한다.
Client가 두 private file을 `O_RDWR|O_CREAT|O_EXCL|O_NOFOLLOW` mode `0600`으로
미리 할당하고 dev/inode를 기록한다. Directory descriptor는 guardian fd 3/4,
private file descriptor는 fd 5/6으로 상속되며 guardian argv에는 Node와 helper
경로만 있다. 최초 canonical v2 frame에는 provider kind, absolute deadline,
32-byte nonce만 전달한다.
Guardian bootstrap은 procfs link를 읽기 전에 fd 5/6을 fstat한다. Procfs pathname은
canonical directory의 exact direct-child grammar를 만족하고 descriptor-relative
lstat이 이미 확보한 fd identity/type/mode/size/link count와 일치할 때만 해당
identity의 cleanup alias가 된다. Guard frame 검증 뒤 raw staging을 fixed raw leaf에
no-overwrite hard link하고 두 alias의 link count 2를 확인한 다음 private alias를
제거하고 raw directory를 sync한다. Canonical raw가 같은 identity와 link count 1로
남은 뒤에만 READY를 응답한다. READY 전 종료 시 client는 pre-spawn raw identity로
raw staging/canonical을, sealed identity로 temp/final을 각각 확인해 일치하는 alias만
정리한다. 현재 canonical pathname을 새 ownership identity로 승격하지 않으므로 외부
file과 concurrent same-kind winner를 보존하면서 같은 workspace를 즉시 재시도할 수
있다. Supervisor는 READY identity와 canonical target도 정확히 확인한다.
검증된 JSON bytes는 pinned temp inode에만 기록하고 `0400` 적용과 file sync를 마친다.
Guardian은 authenticated publish의 size/hash/identity를 재검증하고 같은 directory에서
no-replace `link(temp, final)`, temp unlink, directory sync를 수행한 뒤 PUBLISHED를
응답한다. `GITHUB_OUTPUT` append 이후 commit은 raw inode를 제거하고
`commitPending`으로만 전이하며, 깨끗한 control EOF까지 확인해야 PASS가 된다. 그 전의
EOF, deadline, 잘못된 frame/nonce, commit 뒤 추가 byte 또는 guardian 조기 종료는
identity가 일치하는 raw/temp/final을 모두 정리하고 fail closed한다. Provider wall
timeout 30분에 post-processing 10분을 더한 guardian lease 상한은 40분이다.
Client가 private allocation 뒤 guardian spawn 전에 hard stop되면 empty mode-`0600`
nonce-private leaf만 남을 수 있다. 기록되지 않은 pathname은 ownership 근거가 아니므로
자동 sweeping하지 않는다. 이 private leaf는 fixed raw/final name을 점유하지 않아
same-kind retry를 막지 않는다.
`GITHUB_OUTPUT`은 runner가 소유한 regular file이라는 실행기 계약을 전제로 한다.
Sealing/output I/O의 OS-level cancellation을 보장하지 않는다. Provider scope가 이미
수집된 뒤 guardian이 종료되면 scope-active latch는 늦은 kill을 시작하지 않고 lifecycle
error만 기록한다. 이후 publish/commit의 awaited failure가 identity가 고정된
raw/temp/final fallback을 모두 정리하고 fail closed한다.
Provider stdout/stderr는 credential을 포함할 수 있는 untrusted bytes이므로 CI log로
재전송하거나 보관하지 않고, byte 수만 합산해 1 MiB 상한을 적용한다. Guardian의
stderr/control fd가 닫혀 진단 출력이 `EPIPE`/`EBADF`가 되어도 cleanup 뒤 nonzero
종료는 생략되지 않는다. Provider wall-clock 상한은 30분이다. Wall
timeout, aggregate output 초과, parent-liveness loss, 실행 중 guardian loss는 provider process group을
명시적으로 SIGKILL한다. 일반 command 실패와 FD/CPU RLIMIT 종료는 실제 exit/signal로
systemd completion을 거치며, 모든 경로에서 wrapper 종료와 systemd unit/cgroup
collection을 확인한다. Adapter는 사전에 배치된 offline data와 supervisor candidate binding만
읽어 정확한 report inode에 기록해야 한다. Scope/cgroup limit drift, residual unit,
workspace·host home/toolcache·sealed evidence 접근 의존성은 모두 blocking failure다.
Workflow가 실행하는 action은 `scripts/contracts/ci-gates.ts`의 단일 typed,
runtime-frozen registry에서만 resolve된다. `uses:`에는 repository 별칭, tag,
branch 또는 short SHA를 허용하지 않고 다음 absolute upstream URL과 full 40-hex
commit만 사용한다.
- `https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5`
(`v4.3.1`)
- `https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020`
(`v4.4.0`)
- `https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7`
(patched `v4` branch)
- `https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7`
(patched `v4` branch)
Gitea 1.22 공식 Actions 문서는 artifact v4 호환 경로로 이
`ChristopherHX` patched upload/download fork를 권고한다. 이 권고는 실제
설치 환경의 호환성을 증명하지 않으므로, 지원 baseline은 계속 Gitea 1.26.4+
및 Gitea Runner 1.0.0+이며 action 변경은 registry, semantic tests, snapshot과
generated workflow를 함께 갱신해야 한다.
이 workflow를 required check로 전환하기 전에 staging instance에서
`permissions`, `gitea.run_attempt`, 위 digest-pinned patched artifact action을
포함한 한 번의 실제 end-to-end provider smoke를 반드시 통과시켜야 한다.
mock/local generation 결과는 required-check enablement evidence가 아니다. 모든 setup step은
`node-version-file: .nvmrc`를 사용하므로 CI Node.js 버전은 `.nvmrc`의 exact
pin과 같다. CI contract는 `.nvmrc`가 full semantic version인지, 모든 job이 이
파일을 사용하는지 함께 검사한다. `ubuntu-latest` runner label은
@@ -132,6 +298,83 @@ Repository variables required by higher tiers:
- `HOSTING_BASE_URL` for live header verification
- `FIELD_WEB_VITALS_INPUT` for the privacy-approved field sample document
- `MIN_ELIGIBLE_SAMPLES` after the baseline decision
- `VULNERABILITY_PROVIDER_COMMAND` and `PROVENANCE_PROVIDER_COMMAND` for the
independently operated scanner and attestor commands
- `VULNERABILITY_PUBLIC_KEY_PATH`, `VULNERABILITY_KEY_ID`,
`PROVENANCE_PUBLIC_KEY_PATH`, and `PROVENANCE_KEY_ID` for separately managed
trusted Ed25519 verification material
두 provider role은 서로 다른 key ID뿐 아니라 canonical DER-SPKI public-key bytes도
사용해야 한다. 동일 key를 서로 다른 ID로 재등록한 구성도 finalizer가 거절한다.
If any external provider command, report, trust path, or key ID is absent,
promotion remains unavailable with `FAIL_UNVERIFIED`; there is no local
generator/restore fallback.
Each provider command must atomically emit strict provider evidence v2 from
the supervisor bindings for evidence type, issued/expires timestamps, run
ID/attempt, `PROVIDER_INVOCATION_NONCE`, source identity, candidate digests,
key ID, and DER-SPKI key fingerprint. Promotion receives the two supervisor
job outputs as `VULNERABILITY_INVOCATION_NONCE` and
`PROVENANCE_INVOCATION_NONCE`; do not replace them with constants or values
parsed from provider reports.
Promotion verification/staging step과 promoted-release upload action 사이에는
어떤 step도 둘 수 없고 upload에는 `if: always()`를 사용할 수 없다. 이 인접성은
실패한 검증의 publication을 막지만 staging path를 upload action에
cryptographically hand off하지는 않는다. 따라서 promotion runner는 trusted,
exclusive, single-tenant여야 하고 staging step 종료부터 바로 다음 upload action이
파일을 여는 시점까지 같은 UID로 실행되는 provider command나 다른 process가
살아남지 않는다는 운영 가정을 만족해야 한다. 업로드된 artifact의 downstream
consumer도 artifact service나 transfer action을 신뢰 경계 밖으로 보고, candidate
manifest와 signed provider evidence에 바인딩된 digest를 다운로드 후 다시
검증해야 한다. 현재 producer-side adjacency 자체는 consumer-side digest
revalidation을 대신하지 않는다.
Promotion job에는 job-level `if`가 없다. 기본 `needs` 성공 의미론으로 immutable
build, vulnerability provider, provenance provider 세 job이 모두 성공해야 하며,
`always()``cancelled()`로 cancellation을 덮어쓰지 않는다. Bare `always()`
step cleanup에만 사용된다. 다만 cancellation 시 cleanup 실행 여부는 workflow
정적 타입이나 단위 테스트로 증명하지 않았으며 runner/native smoke에서 확인해야
하는 신뢰 경계다.
Finalizer output은 `RUNNER_TEMP` 아래 random private directory이며 exact-five
upload는 `${{ steps.finalize.outputs.staging_root }}` 아래 다음 다섯 canonical
pathname만 사용한다: `release-candidate.tar.gz`, `vulnerability-report.json`,
`provenance-attestation.json`, `provider-verification.json`,
`promotion-verification.json`. 바로 다음
`always()` cleanup은 staging path/token, runner-temp device/inode와 staging-leaf
device/inode 여섯 output을 모두 요구한다. cleanup은 pin한 leaf descriptor에서
exact-five name만 unlink하고 non-recursive `rmdir`만 사용하므로 교체된 directory나
canary tree를 recursive 삭제하지 않는다. stable `.release/promoted-staging`
directory를 만들거나 재사용하지 않는다. exact five는 captured archive 한 개,
captured report 두 개와 process 안에서 생성한 provider/promotion verification v3
두 개이며 promotion
record는 provider record, local assessment, report hashes와 run/source/candidate/
nonces/key identities/trust-policy hash 및 signed `secretScanAttestation`을 함께
bind한다. 이 attestation은 PASS와 local-assessment/source-set/policy/SARIF/
scan-input digest를 포함한다. Supervisor/finalizer는 captured archive에서 기대
tuple을 유도해 exact equality를 확인하지만, 실제로 같은 source-set 전체를
독립 스캔하고 forged empty SARIF에 서명하지 않을 책임은 trusted vulnerability
provider에 있다. Staging은 restrictive
umask와 무관하게 directory `0700`, file `0400`을 강제하고, 모든 write 뒤 live
time으로 exact-five signature/freshness를 다시 확인한 뒤에만 output을 공개한다.
Descriptor-relative 정리는 ancestor/leaf 교체와 symlink를 fail-closed로 처리하지만
upload action의 same-UID pathname reopen 또는 atomic
`renameat2` handoff를 보장하지 않는다. staging Gitea smoke/native adapter 확인
전에는 그 경계를 닫았다고 보고하지 않는다. 실제 smoke는 exact-five
upload-download와 success, validation failure, upload failure, cancellation 각각의
cleanup을 관찰해야 한다. 현재 repository에는 native uploader나 `renameat2`
보장이 없다.
또한 portable Node의 `mkdir`와 최초 pathname `lstat`는 atomic하지 않다. 구현은
mkdir 직후 metadata를 저장하고 이후 `O_DIRECTORY|O_NOFOLLOW` descriptor의
device/inode와 비교한 뒤에만 permission을 바꾸지만, 최초 lstat보다 앞서 성공한
malicious same-UID 교체는 native/privilege 경계로 남는다. 따라서 `RUNNER_TEMP`
private `0700` ancestor와 exclusive single-tenant runner가 필수다.
실패 cleanup도 created device/inode와 opened descriptor가 일치한 뒤에만 활성화된다.
불일치 descriptor는 close만 수행하며 현재 visible replacement pathname은 unlink나
`rmdir`하지 않는다. 공격자가 original directory를 다른 이름이나 parent 밖으로
이동한 경우 portable Node parent scan으로 안전하게 회수할 수 없으므로, 공격자를
배제한 trusted runner/native cleanup 또는 격리된 test fixture가 잔여 directory를
후처리해야 한다.
Branch protection must mark each `FE-GATE-* / <name>` check required for its
declared tier. This repository cannot configure server-side protection by
+255 -10
View File
@@ -2,7 +2,17 @@
## Local blocking controls
- `pnpm install --frozen-lockfile` and a real manifest/lock mismatch fixture
- `pnpm install --frozen-lockfile --ignore-scripts` and a real manifest/lock
mismatch fixture; contract loading applies the root-only graph and lifecycle
policy to every registered command before the gate runner can spawn one, and
rejects nested installs without effective `--ignore-scripts`; npm script
traversal includes existing pre/post hooks unless an ordered bare or explicit
true `--ignore-scripts` suppresses them. Workspace/prefix and indirect
user/global config authority are rejected in npm options, direct or dynamic
assignments, exact `command`/`exec`/`env` prefix chains, cross-segment shell
state, and the inherited runner environment. Unknown or cwd-changing `env`
options and unquoted pre-delimiter pathname expansion fail closed, while the
explicitly modeled non-scope `env` options remain usable
- all direct and transitive lockfile rows with package SHA-512 integrity
- production/development, direct/transitive and platform-optional classification
- package-manifest license allow/deny policy
@@ -19,8 +29,8 @@ The canonical commands are:
```bash
corepack pnpm verify:lockfile
corepack pnpm verify:reproducible-build
corepack pnpm build:release
corepack pnpm verify:supply-chain
corepack pnpm build:release-candidate
corepack pnpm verify:local-evidence
corepack pnpm check:supply-chain:fixtures
```
@@ -31,16 +41,251 @@ rejected.
## External promotion controls
The vulnerability adapter reads the file named by
`VULNERABILITY_REPORT_PATH`. It requires a provider, the exact lockfile digest,
severity findings and valid independent, unexpired exception evidence.
`PROVENANCE_ATTESTATION_PATH` must name a provider, signer and the exact built
dist subject digest.
Promotion reads the provider files named by `VULNERABILITY_REPORT_PATH` and
`PROVENANCE_ATTESTATION_PATH`. The vulnerability report must bind both the
exact lockfile digest and candidate `distSha256`; the provenance attestation
must name `dist` with that same digest. Both documents use strict schemas and
Ed25519 signatures verified with separately configured trusted public keys and
key IDs (`VULNERABILITY_PUBLIC_KEY_PATH`, `VULNERABILITY_KEY_ID`,
`PROVENANCE_PUBLIC_KEY_PATH`, and `PROVENANCE_KEY_ID`). Keys of another curve,
including Ed448, are rejected even if a document labels its algorithm
`Ed25519`. The two roles must use different key IDs and different canonical
DER-SPKI key bytes; giving the same key two IDs is rejected.
`immutable_build` archives the raw `pnpm-lock.yaml`, `dist` (including hidden
`.vite` files), the build manifest, module inventory, release verification,
secret-scan result, and local supply-chain evidence once. The candidate
manifest hashes the raw lockfile bytes and requires that digest to equal the
dependency inventory's `lockfileSha256`. Before upload, the producer validates
the manifest-bound exact archive member set and every member digest, then
publishes the archive SHA-256 as an immutable job output. The two provider jobs
download this same archive separately, compare that output digest, validate the
exact member set before extracting only into isolated roots, and receive
`CANDIDATE_LOCKFILE_PATH` and `CANDIDATE_DIST_SHA256`; configured
`VULNERABILITY_PROVIDER_COMMAND` and `PROVENANCE_PROVIDER_COMMAND` must emit the
signed reports. After each external command returns, provider upload validation
rechecks the unchanged archive and extracted candidate, parses the provider JSON
with its strict schema, and binds its dist and lockfile digests before upload.
If either provider input is absent, local verification remains meaningful but
`artifacts/security/supply-chain-verification.json` records
`promotionStatus: FAIL_UNVERIFIED`. `verify:supply-chain:promotion` then exits
non-zero. Scanner or signing outages are not converted to an empty PASS.
`promotionStatus: FAIL_UNVERIFIED`. The finalizer and downstream
`verify:promotion` exact-five validator then exit non-zero. Promotion derives
the candidate file set and digests only from the captured tar bytes, then
read-only revalidates archived executable schemas, archived policy/verifier
source bytes, raw lockfile, module inventory, build outputs, release coherence,
SBOM, provenance and supply-chain coherence. Candidate-internal checks are
recomputed; for the checkout-dependent secret scan, promotion independently
checks the archived policy, exact rule set, strict SARIF, zero findings and all
manifest/assessment digest bindings. It does not claim to rescan source bytes
that are not candidate members. It never rebuilds or rewrites candidate
evidence and never falls back to the checkout tree.
Promotion uploads the already verified archive itself with the two provider
reports and generated verification records; it does not create a replacement
archive from extracted files. Scanner or signing outages are not converted to
an empty PASS.
The generated workflow is also a supply-chain control. `config/ci/gates.json`
is its sole typed authority. Run `corepack pnpm generate:ci-workflow` after a
contract change and `corepack pnpm check:ci-workflow` (or the encompassing
`corepack pnpm check:ci`) to reject byte drift in the checked-in Gitea adapter.
Action resolution is separately closed over one typed, runtime-frozen registry
in `scripts/contracts/ci-gates.ts`. Every generated `uses:` value is an absolute
upstream URL pinned to a full commit SHA:
- `https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5`
(`v4.3.1`)
- `https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020`
(`v4.4.0`)
- `https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7`
(patched `v4` branch)
- `https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7`
(patched `v4` branch)
Unknown actions, relative repositories, tags/branches and short SHAs are
rejected. Gitea 1.22's official Actions documentation recommends the
`ChristopherHX` patched artifact forks for v4 compatibility; the supported
deployment baseline is nevertheless Gitea 1.26.4+ with Gitea Runner 1.0.0+.
A real end-to-end provider smoke on the staging Gitea instance remains
mandatory before any generated job becomes a required check.
External provider supervision is fail-closed and requires a Linux runner with
executable `/usr/bin/bwrap`, `/usr/bin/prlimit`, `/usr/bin/systemd-run`, and
`/usr/bin/systemctl`, bubblewrap support for `--size`, an active user bus, a
systemd user manager version 254 or newer, unified cgroup v2, and delegated
memory, pids, and CPU controllers. Executable access, user-manager/version,
trust, archive, and path failures are rejected before raw creation. Bubblewrap
`--size` acceptance and effective delegated controller values can only be
verified after the owned raw inode exists inside a new scope; failures there
remove that inode by identity and fail closed. Each invocation runs in a unique collected user scope and verifies
its effective cgroup membership and limits before bubblewrap starts: memory is
exactly 1 GiB, swap is zero, `TasksMax` is 64, and CPU quota is 100% per 100 ms.
The inner process also has zero core size, a 8,388,607-byte file-size limit,
64 open files and at most 1,200 CPU seconds. `TasksMax=64` is the authoritative
aggregate PID boundary; no per-provider `RLIMIT_NPROC=32` claim is made because
that limit is counted across the runner's same-UID process population.
Bubblewrap uses a private network namespace (`--unshare-net`), mounts the
workspace and verified candidate read-only, hides `.git`, and read-only binds
the trusted `process.execPath` at `/tmp/node`. The supervisor creates and pins
the exact configured raw-report inode only after sandbox/trust/archive
preflight; that inode is the only provider evidence path mounted read-write.
The provider cannot write the surrounding `untrusted` directory, workspace,
candidate, host home/toolcache, sealed evidence path, or general temporary
filesystem. Pre-execution and provider failures remove only the supervisor-
owned raw inode so the same job can retry without a stale empty report.
The complete bwrap argument/environment vector, including the provider command
and provider-prefixed environment, is carried in a bounded length-prefixed frame over
`systemd-run` stdin rather
than placed in the supervisor, systemd, or bwrap wrapper argv. This prevents
credentials from entering unit metadata and wrapper command lines. The final
provider executable and its ordinary arguments remain visible to same-UID
process inspection, so commands must never contain tokens or secrets. The same
stdin remains open as a parent-liveness channel until normal provider exit; EOF
caused by supervisor death makes the in-scope wrapper kill the provider process
group and remove only the dev/inode-matched raw report. Supply
credentials only through the provider-kind prefix
(`VULNERABILITY_PROVIDER_*` or `PROVENANCE_PROVIDER_*`, excluding `*_COMMAND`).
A separate trusted guardian starts outside the provider scope and owns the
filesystem transaction. Before spawn, the client opens and identity-checks the
canonical raw and evidence directories with `O_DIRECTORY|O_NOFOLLOW`, derives
the exact canonical and nonce-private leaves, and exclusively allocates empty
mode-`0600` raw-staging and sealed-temp files. It records both dev/inode pairs
before spawn and inherits the directory descriptors as guardian fd 3/fd 4 and
the private file descriptors as fd 5/fd 6. Its argv contains only the trusted
Node and helper paths. A bounded canonical v2 request carries only provider
kind, an absolute deadline, and a random 32-byte control nonce.
At bootstrap the guardian fstats fd 5/fd 6 before reading their procfs links.
Each procfs target is accepted only as a direct-child alias whose exact grammar,
descriptor-relative lstat, type, mode, size, link count, and dev/inode match the
already-recorded descriptor identity. The guardian publishes raw staging to the
fixed raw leaf with a no-overwrite hard link, verifies both aliases at link
count two, removes the private raw alias, syncs the raw directory, and verifies
the canonical raw alias at link count one before authenticated READY. If startup
ends before READY is accepted, the client cleans raw staging/canonical only when
they match its pre-spawn raw identity and sealed temp/final only when they match
its pre-spawn sealed identity. It never derives cleanup ownership by opening a
current canonical pathname, so an external file or a concurrent same-kind
winner is preserved. Provider execution starts only after the supervisor
confirms that the returned identities and canonical targets match exactly.
After evidence validation, the supervisor writes schema-validated bytes to the
pinned temp inode, changes it to `0400`, fsyncs it, and sends authenticated
size/hash/identity metadata. The guardian verifies the held descriptor and
pathname, publishes without replacement using same-directory `link`, removes
the temp name, fsyncs the directory, and returns authenticated PUBLISHED. Only
after successful `GITHUB_OUTPUT` append does the supervisor send commit. Commit
removes the raw inode and enters `commitPending`; clean control EOF is the sole
success terminal and preserves the sealed final. EOF without that terminal,
deadline expiry, malformed/trailing control data, a wrong nonce, or premature
guardian exit cleans every matching raw/temp/final identity and fails closed.
Guardian loss while the provider scope is active also triggers whole-scope kill
and collection.
`GITHUB_OUTPUT` is assumed to be a runner-owned regular file. This protocol
does not claim OS-level cancellation of sealing or output I/O. If the guardian
exits after scope collection, the scope-active latch records the lifecycle error
without starting a late kill. Publication or terminal commit observes the
nonzero exit and the client removes every identity-pinned raw/temp/final
fallback before failing closed. The lease is bounded by the 30-minute provider
wall limit plus a fixed ten-minute post-processing allowance.
A client hard stop after private allocation but before guardian spawn can leave
only empty mode-`0600` nonce-private leaves. Automatic sweeping is intentionally
omitted because an unrecorded pathname does not prove ownership; these private
leaves cannot occupy the fixed raw or final names and do not block a retry.
Provider stdout and stderr are untrusted secret-bearing bytes. The supervisor
does not retain or forward them to CI logs; it counts them only to enforce one
1 MiB aggregate limit. Guardian diagnostics are best effort, so closed stderr
or control descriptors cannot bypass cleanup or the required nonzero exit. The
provider wall-clock limit is 30 minutes. Wall timeout, output overflow, parent-liveness loss,
and active-scope guardian loss explicitly SIGKILL the whole provider process group. Ordinary command and
RLIMIT failures complete through systemd with their concrete exit/signal; every
path still waits for wrapper closure and requires the systemd unit/cgroup to be
collected before returning. Provider adapters must therefore operate entirely from pre-populated
offline data, consume the supplied candidate bindings, and write exactly the
pinned report inode. Missing prerequisites, cgroup drift, stale or misplaced
outputs, post-command candidate drift, and residual scope cleanup all stop
publication.
Provider documents are strict schema v2. Their Ed25519 signature covers the
supervisor-supplied evidence type, validity window, run ID/attempt, independent
32-byte invocation nonce, archived source identity, and all four candidate
digests. Each provider job exposes its supervisor-generated nonce as a job
output; promotion treats those outputs as the independent expected values and
never lets a report define its own expected nonce. A report from another
attempt, source, archive, nonce, or key fingerprint is fail-closed even when it
has been correctly re-signed.
The immutable archive contains a strict producer-local assessment plus the
policy and verifier source bytes needed to validate its archived relationships.
Promotion recomputes candidate-internal checks and validates the captured
secret-scan policy/rules/SARIF/digest relationships from an isolated extraction
root; it does not reopen checkout policy or source paths or claim to rescan
unarchived checkout source. The finalizer captures the archive,
both reports, and both public keys once, generates both verification v3 records
in memory, and writes exactly five mode-`0400` files beneath a random
mode-`0700` directory in `RUNNER_TEMP`, independently of a restrictive runner
umask. The exact five are the captured archive, captured vulnerability report,
captured provenance attestation, generated provider-verification v3, and
generated promotion-verification v3. Before returning, the finalizer validates
those exact bytes again with live-time provider signature/freshness checks. The
promotion record binds the exact provider-record hash, local-assessment hash,
both report hashes, run/source/candidate identities, both nonces, both key
IDs/fingerprints, canonical trust-policy hash, and the vulnerability provider's
signed `secretScanAttestation`. That strict attestation says `PASS` and binds
the captured local-assessment, source-set, secret-scan policy, SARIF, and actual
scan-input digests. The supervisor derives the expected tuple from the captured
archive and exact equality is rechecked at upload and finalization. The trusted
vulnerability provider remains responsible for independently scanning that
source set and refusing to sign a forged empty SARIF or incomplete scan input;
the signature proves the provider made the claim, not that an untrusted
provider performed the scan honestly. It never creates or reuses
`.release/promoted-staging`.
The final promotion verification/staging step must be immediately adjacent to
the promoted-release upload, and that upload must not use `always()`. This
reduces the post-verification mutation window but does not seal a pathname
across two action steps. The runner is therefore required to be trusted,
exclusive and single-tenant, with no provider command or other same-UID process
surviving from staging into the immediately following upload. The artifact
service and transfer actions also remain outside the candidate's cryptographic
identity: every downstream consumer must revalidate the downloaded archive,
manifest member digests and signed provider evidence. Producer-side adjacency
does not provide consumer-side digest revalidation.
The promotion job has no job-level `if`: ordinary `needs` success semantics
require immutable build and both provider jobs to succeed, and cancellation is
not overridden with `always()` or `cancelled()`. Cleanup alone uses bare `always()`
and is guarded by all six finalizer outputs: staging path, token, parent
device/inode, and staging-leaf device/inode. Cleanup opens the pinned leaf,
requires the exact five names, unlinks only those known files through the
descriptor, and uses a non-recursive `rmdir`; an exchanged directory or canary
is never recursively removed.
The immediately following upload action still reopens pathnames. The
descriptor-relative staging and cleanup code does not claim an atomic
`renameat2` handoff or close a malicious same-UID Gitea upload adapter; the
staging Gitea smoke/native platform adapter remains the required closure for
that boundary. That smoke must exercise exact-five upload and download plus
cleanup on success, validation failure, upload failure, and cancellation. No
native uploader or `renameat2` guarantee exists in this repository today.
Portable Node also cannot make `mkdir` plus the first pathname `lstat` atomic.
The implementation compares the immediate post-`mkdir` identity with the
subsequent `O_DIRECTORY|O_NOFOLLOW` descriptor before changing permissions, but
a malicious same-UID actor that wins before that first `lstat` remains part of
the native/privilege boundary. The private `0700` runner-temp ancestor and
single-tenant runner requirement are therefore security controls, not merely
hardening.
Failure cleanup is armed only after that created device/inode matches the opened
descriptor. If the opened descriptor is a replacement, it is closed without
unlinking or removing anything; the visible replacement is likewise untouched.
Portable Node cannot safely rediscover an attacker-moved original directory by
scanning the parent, so that residual must be removed by the isolated test
fixture or trusted runner/native cleanup after the attacker is excluded.
Approved vulnerability exceptions require vulnerability/package identity,
owner, a different reviewer, reason and expiry. Expired or self-approved
@@ -0,0 +1,158 @@
# HTTP Worker and Adapter Remediation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove duplicate HTTP authorities, harden Service Worker activation bounds and identity, and decompose large browser adapters only after shared golden behavior is locked.
**Architecture:** Installed contract contributions are the HTTP source of truth and expose provider-neutral typed outcomes. Worker protocol V2 exchanges a canonical full-identity digest. Browser adapter facades remain stable while shared persisted schemas and cohesive internal modules are extracted.
**Tech Stack:** TypeScript 7, Fetch API, Service Worker API, IndexedDB, OPFS, React 19, Vitest 4, Playwright.
## Global Constraints
- Contract and application layers never import concrete adapter outcome types.
- Production exports accept only `BoundQuery` and `BoundMutation` after migration.
- Runtime timeout is a global ceiling applied over descriptor deadlines.
- Worker marker reads are bounded even without `Content-Length` and cancel oversized/non-terminating streams.
- Public adapter facades and product-default optional capability selection remain unchanged.
- Extraction follows characterization tests; file length alone does not justify a split.
---
### Task 1: Installed HTTP contract as single source of truth
**Files:**
- Modify: `src/features/reference-feature/contracts/reference-feature-contract.ts`
- Modify: `src/features/reference-feature/contracts/reference-schemas.ts`
- Modify: `src/contracts/external-contract-runtime.ts`
- Modify: `src/contracts/api-operations.ts`
- Modify: `src/contracts/rest-profiles.ts`
- Modify: `src/contracts/schema-registry.ts`
- Modify: `src/features/installed-feature-contracts.ts`
- Modify: `src/bootstrap/runtime-adapters.ts`
- Modify: `tests/features/reference-feature/reference-contract.test.ts`
- Modify: `tests/runtime-schema/http-schema.test.ts`
- Modify: `tests/integration/http-execution-contract.test.ts`
- Modify: `tests/unit/http-execution-v3.test.ts`
- [ ] Add parity tests showing method/path/validators/retry/effect/deadline/byte bounds come from one contribution; `createdAt` accepts omitted or RFC3339 datetime and rejects arbitrary strings.
- [ ] Run focused tests and confirm RED on duplicated descriptors and permissive date schema.
- [ ] Make the installed contribution authoritative; generate temporary legacy views from it and migrate all production callers before deleting the legacy registries/codecs.
- [ ] Apply `REQUEST_TIMEOUT_MS` as `min(runtimeCeiling, descriptorDeadline)` without replacing shorter descriptor deadlines.
- [ ] Keep the deadline authoritative through response admission and body reads: a deadline-owned abort after headers must still return `TRANSPORT_FAILURE / TIMEOUT`, perform one attempt, and never enter retry sleep instead of being masked as `RESPONSE_STREAM_FAILURE`.
- [ ] Treat credential resolution as a credential-only patch boundary: reject attempts to inject or overwrite `Idempotency-Key` (including case variants) or transport/query authority, and cover hostile patches in the executor regression table.
- [ ] Prove diagnostic privacy against the full runtime seam with non-empty identity references; assertions over an empty diagnostic projection do not count as evidence that intent/key/query identity is absent.
- [ ] Re-run focused tests, prove `rg` has zero production callers of removed registries, and commit with `git commit -m "refactor: consolidate installed HTTP contracts"`.
### Task 2: Provider-neutral typed operation outcomes
**Files:**
- Create: `src/contracts/operation-outcome.ts`
- Create: `src/application/ports/contract-operation-executor.ts`
- Modify: `src/features/reference-feature/adapters/reference-http-gateway.ts`
- Modify: `src/bootstrap/runtime-adapters.ts`
- Modify: `src/adapters/http/http-execution-v3.ts`
- Modify: `tests/features/reference-feature/reference-contract.test.ts`
- Modify: `tests/unit/runtime-adapters.test.ts`
- [ ] Add compile/runtime tests that unknown operation IDs and mismatched input/output types fail, and that the feature gateway has no import from `src/adapters/http`.
- [ ] Run focused tests/typecheck and confirm RED because the port is `operationId: string`, `input: unknown`, and concrete `HttpExecutionOutcome` leaks inward.
- [ ] Derive `InstalledOperationMap` from installed contracts, expose generic `execute<K extends keyof Map>(operationId: K, input: Map[K]["input"], context)` and map HTTP outcomes to provider-neutral contract outcomes at the adapter boundary.
- [ ] Re-run tests, typecheck, and architecture; commit with `git commit -m "refactor: type installed contract operations"`.
### Task 3: Bound-only server-state exports
**Files:**
- Modify: `src/presentation/adapters/query/application-query.ts`
- Modify: `src/presentation/adapters/query/index.ts`
- Modify: `src/features/reference-feature/presentation/use-reference-feature.ts`
- Create: `tests/helpers/legacy-application-query-harness.tsx`
- Modify: `tests/component/application-query.test.tsx`
- [ ] Add type tests that production hooks reject raw query keys and raw mutation executors while bound definitions still compile.
- [ ] Run typecheck and confirm current overloads accept raw forms.
- [ ] Move legacy raw harness behavior under `tests/helpers`; remove `LegacyMutationOptions` and the raw query union from production exports; migrate feature callers to `bindQuery`/bound mutations.
- [ ] Run focused component tests and typecheck; commit with `git commit -m "refactor: expose bound server-state hooks only"`.
### Task 4: Bounded Service Worker marker reader
**Files:**
- Create: `src/adapters/service-worker/bounded-worker-response.ts`
- Modify: `src/adapters/service-worker/service-worker-lifecycle.ts`
- Modify: `tests/unit/service-worker-runtime.test.ts`
- [ ] Add tests for oversized declared length, headerless oversized chunks, invalid UTF-8, malformed JSON, and a non-terminating stream. Assert reader cancellation and bounded completion.
- [ ] Run `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` and confirm RED because lifecycle calls `response.text()`.
- [ ] Implement realm-safe stream reads up to `maxBytes + 1`, fatal `TextDecoder`, explicit cancellation, and strict marker parsing. Never call `Response.text()` for protocol data.
- [ ] Re-run tests and commit with `git commit -m "fix: bound Service Worker activation markers"`.
### Task 5: Service Worker protocol V2 full identity
**Files:**
- Modify: `src/contracts/service-worker.ts`
- Modify: `src/adapters/service-worker/service-worker-protocol.ts`
- Modify: `src/adapters/service-worker/service-worker-entry.ts`
- Modify: `src/adapters/service-worker/service-worker-lifecycle.ts`
- Modify: `src/adapters/service-worker/service-worker-page-controller.ts`
- Modify: `src/bootstrap/register-service-worker.ts`
- Modify: `scripts/generate-build-manifest.ts`
- Modify: `tests/unit/service-worker-runtime.test.ts`
- Modify: `tests/unit/service-worker-build-input.test.ts`
- [ ] Add a tuple-mutation table for protocol/cache schema/build/release/contract/static set; each mutation must change the digest and reject activation. Add a valid ACTIVE fixture build that recomputes static set digest from asset entries.
- [ ] Run focused tests and confirm RED because protocol V1 compares partial fields.
- [ ] Set `SERVICE_WORKER_PROTOCOL_VERSION = 2`, define canonical sorted identity serialization, compute SHA-256 over every identity field, and exchange/validate the digest on every page-worker message.
- [ ] Keep default capability selection `null`; use ACTIVE only in the explicit fixture build.
- [ ] Re-run focused tests and the supported fixture build; commit with `git commit -m "fix: bind Service Worker activation to full identity"`.
### Task 6: Shared IndexedDB persisted-row schema
**Files:**
- Create: `src/adapters/storage/indexeddb/indexeddb-persisted-schema.ts`
- Modify: `src/adapters/storage/indexeddb/indexeddb-types.ts`
- Modify: `src/adapters/storage/indexeddb/indexeddb-runtime.ts`
- Modify: `src/adapters/storage/indexeddb/indexeddb-maintenance.ts`
- Create: `tests/fixtures/indexeddb/persisted-rows.ts`
- Modify: `tests/unit/indexeddb-runtime.test.ts`
- Modify: `tests/unit/indexeddb-maintenance.test.ts`
- [ ] Before extraction, run the same accepted/rejected record, receipt, retention, and budget golden rows through runtime and maintenance and assert identical verdicts.
- [ ] Confirm RED on at least one drift fixture using the duplicate current guards.
- [ ] Move persisted types/guards into the shared module; runtime and maintenance import it without behavior changes.
- [ ] Re-run both large suites and commit with `git commit -m "refactor: share IndexedDB persisted schemas"`.
### Task 7: Cohesive browser adapter decomposition
**Files:**
- Modify: `src/adapters/storage/opfs/opfs-worker-runtime.ts`
- Create: `src/adapters/storage/opfs/opfs-worker-bootstrap.ts`
- Create: `src/adapters/storage/opfs/opfs-worker-message-host.ts`
- Create: `src/adapters/storage/opfs/opfs-worker-core.ts`
- Create: `src/adapters/storage/opfs/opfs-worker-lock.ts`
- Create: `src/adapters/storage/opfs/opfs-physical-io.ts`
- Modify: `src/adapters/cache-storage/public-response-cache-adapter.ts`
- Create: `src/adapters/cache-storage/public-cache-manifest.ts`
- Create: `src/adapters/cache-storage/cache-lock.ts`
- Modify: `src/adapters/browser-files/download-delivery-adapter.ts`
- Create: `src/adapters/browser-files/download-browser-managed.ts`
- Create: `src/adapters/browser-files/download-picker-stream.ts`
- Create: `src/adapters/browser-files/download-object-url.ts`
- Modify: `tests/unit/opfs-worker-runtime.test.ts`
- Modify: `tests/unit/public-response-cache.test.ts`
- Modify: `tests/unit/browser-file-download.test.ts`
- [ ] Add golden facade tests for all success/failure/cancellation/lock-loss branches before moving code; snapshot externally observable operation order and error kinds.
- [ ] Run the three focused suites and capture GREEN characterization evidence.
- [ ] Extract OPFS bootstrap, host, core state machine, Web Lock, and physical I/O without changing public exports. Do not split the core state machine further.
- [ ] Extract public-cache manifest codec/digest and generic lock logic behind the same facade.
- [ ] Extract browser-managed, picker streaming, and object-URL download strategies behind the same delivery facade.
- [ ] Re-run the same golden suites after each extraction. Any failure is a refactor regression, not a fixture update.
- [ ] Commit each adapter independently with `refactor: decompose OPFS worker adapter`, `refactor: extract public cache internals`, and `refactor: extract download delivery strategies`.
### Task 8: HTTP/worker/adapter verification
- [ ] Run all focused tests named in Tasks 17.
- [ ] Run `corepack pnpm check:architecture`, `corepack pnpm check:types`, and `corepack pnpm lint`.
- [ ] Run `corepack pnpm test:all`.
- [ ] Run supported Service Worker, IndexedDB, OPFS, public-cache, and download Playwright capability specs.
- [ ] Run `git diff --check` and report unsupported browser gates without claiming success.
@@ -0,0 +1,124 @@
# Quality and Architecture Remediation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make coverage, scenario, CI, and architecture gates measure executable production behavior and fail when their measured universe is empty or incomplete.
**Architecture:** One typed gate schema drives both the local runner and generated workflow. Coverage separates repository inventory from instrumented totals. The Babel/resolver graph is the sole architecture authority while TypeScript 7 is unsupported by dependency-cruiser.
**Tech Stack:** TypeScript 7, Node.js 24, Babel parser, Vitest 4, Playwright, Gitea Actions.
## Global Constraints
- A zero-file or zero-module result is failure, never success.
- High-risk changed modules need explicit coverage ownership or an owned, expiring waiver.
- Scenario declarations count only when a table-driven test executes all required assertions.
- Checked-in workflow content is generated deterministically from the same gate model used locally.
- Every enforcement change begins with a failing fixture.
---
### Task 1: Repository-aware risk coverage
**Files:**
- Modify: `vitest.config.ts`
- Modify: `config/testing/risk-coverage.json`
- Modify: `scripts/check-risk-coverage.ts`
- Modify: `tests/fixtures/coverage/below-threshold.json`
- Create: `tests/fixtures/coverage/repository-omission.json`
- Create: `tests/unit/risk-coverage.test.ts`
- [ ] Add tests asserting `selectedTotal`, `repositoryTotal`, uncovered repository modules, and changed high-risk ownership. A summary covering 14 files while production inventory is larger must fail.
- [ ] Run `corepack pnpm exec vitest run tests/unit/risk-coverage.test.ts` and confirm RED because only selected totals exist.
- [ ] Enumerate every production `.ts`/`.tsx` module under `src`, exclude declarations/stories/generated files explicitly, and emit both totals. Expand coverage instrumentation to `src/**/*.{ts,tsx}` with documented exclusions.
- [ ] Seed the critical registry with HTTP V3, bounded request/response readers, boot bounds, Service Worker lifecycle, scope generation, and release loading. Validate waiver owner, reason, and future expiry.
- [ ] Run focused tests and `corepack pnpm test:coverage`; commit with `git commit -m "fix: measure repository-wide risk coverage"`.
### Task 2: Executable HTTP scenario catalog
**Files:**
- Modify: `tests/mocks/scenarios/catalog.ts`
- Create: `tests/integration/http-scenario-catalog.test.ts`
- Modify: `tests/mocks/handlers/reference-resources.ts`
- Modify: `scripts/check-test-evidence.ts`
- Modify: `config/testing/test-evidence.json`
- [ ] Define typed expectations for status/outcome/effect/retry/fetch count/media type/body bound/scope fence for every declared scenario.
- [ ] Add a table-driven test that executes each operation/scenario pair through `ContractHttpExecutor` and asserts every expectation field. Add a deliberately declared-but-unexecuted fixture and make the evidence checker reject it.
- [ ] Run `corepack pnpm exec vitest run tests/integration/http-scenario-catalog.test.ts && node scripts/check-test-evidence.ts` and confirm RED because the current gate counts source tokens.
- [ ] Export execution receipts from the test artifact and make the checker compare exact catalog IDs to exact executed IDs; source-token counts become diagnostics only.
- [ ] Re-run tests/checker and commit with `git commit -m "test: execute the HTTP scenario catalog"`.
### Task 3: Shared CI gate schema and deterministic workflow generation
**Files:**
- Create: `scripts/contracts/ci-gates.ts`
- Create: `scripts/generate-ci-workflow.ts`
- Modify: `scripts/run-ci-gate.ts`
- Modify: `config/ci/gates.json`
- Modify: `.gitea/workflows/quality-gates.yml`
- Create: `tests/unit/ci-workflow-generation.test.ts`
- [ ] Add invalid gate fixtures for unknown fields, duplicate IDs, missing artifact schemas, unknown dependencies, and cycles. Add a snapshot test for the full generated workflow plus `--check` drift.
- [ ] Run `corepack pnpm exec vitest run tests/unit/ci-workflow-generation.test.ts` and confirm RED because no shared parser/generator exists.
- [ ] Parse gates once with strict Zod schemas. Generate every job, dependency, command, environment mapping, timeout, artifact upload/download, and schema validation deterministically.
- [ ] Replace regex/token workflow checks with `node scripts/generate-ci-workflow.ts --check`; generated YAML must match byte-for-byte.
- [ ] Re-run tests and check mode; commit with `git commit -m "refactor: generate CI workflow from gate contracts"`.
### Task 3b: Semantic validation for every CI evidence format
**Files:**
- Modify: `scripts/contracts/release-artifacts.ts`
- Modify: producer scripts for the remaining generic JSON evidence
- Modify: `scripts/lib/ci-artifact-validator.ts`
- Modify: `config/ci/gates.json`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
- [x] Inventory every artifact still mapped to `generic-json-object` and export/reuse the producer's strict schema, including cross-field status/failure/count invariants. Do not treat a non-empty JSON object as semantic evidence.
- [x] Replace substring-only JUnit/HTML acceptance with bounded well-formed document validation. Reject DTD/entities, malformed nesting, duplicate/invalid roots, and trailing non-whitespace content.
- [x] Add invalid-but-pattern-matching fixtures for all structured kinds and a table proving every configured artifact resolves to a semantic validator.
- [ ] Run focused artifact tests, `corepack pnpm check:ci`, types, lint, and diff checks; commit separately so this evidence-quality closeout is independently reviewable.
### Task 4: One authoritative architecture graph
**Files:**
- Modify: `scripts/check-architecture.ts`
- Modify: `config/architecture/layers.json`
- Modify: `.dependency-cruiser.json`
- Create: `tests/fixtures/architecture/forbidden/contracts-import-application.ts`
- Create: `tests/fixtures/architecture/forbidden/feature-adapter-imports-global-adapter.ts`
- Create: `tests/unit/architecture-policy.test.ts`
- [ ] Add fixtures proving contracts cannot import application/runtime layers, feature adapters cannot import concrete global adapters, unresolved imports fail, cycles fail, and a zero-module root fails.
- [ ] Run `corepack pnpm exec vitest run tests/unit/architecture-policy.test.ts` and confirm missing rules/zero-module behavior fail.
- [ ] Make the Babel parser plus Node/TS resolver graph authoritative. Keep dependency-cruiser output informational while it sees zero TS7 modules, and explicitly fail authoritative counts of zero modules or zero dependencies in a non-empty source tree.
- [ ] Add the two dependency-direction rules to the typed layer policy and ensure aliases/extensions resolve identically to TypeScript.
- [ ] Run focused tests and `corepack pnpm check:architecture`; commit with `git commit -m "fix: enforce architecture with the TS7 graph"`.
### Task 5: Test hygiene and production read/write E2E
**Files:**
- Modify: `vitest.config.ts`
- Modify: `tests/setup.ts`
- Modify: `playwright.config.ts`
- Modify: `playwright.dev.config.ts`
- Modify: `playwright.storybook.config.ts`
- Modify: `playwright.visual.config.ts`
- Create: `tests/e2e/reference-resource-write.spec.ts`
- Modify: `scripts/check-test-evidence.ts`
- [ ] Add a fixture containing `.only` and a leaking fake timer; assert the gate rejects/isolation restores them. Assert every Playwright config resolves `forbidOnly: true`.
- [ ] Add E2E that loads a real mocked GET response, submits POST, verifies request body/header contract, verifies response-rendered resource, then reloads and verifies read-after-write.
- [ ] Run focused Vitest and Playwright tests and confirm RED for inherited configs/current shallow E2E.
- [ ] Enable Vitest sequence hook that rejects `.only`, restore real timers in common `afterEach`, and centralize a Playwright base config with `forbidOnly: true` inherited by all configs.
- [ ] Make E2E evidence require both observed response and observed mutation receipt.
- [ ] Re-run supported tests; commit with `git commit -m "test: harden test isolation and read-write E2E"`.
### Task 6: Quality verification
- [ ] Run `corepack pnpm check:architecture`.
- [ ] Run `corepack pnpm test:coverage`.
- [ ] Run `node scripts/check-test-evidence.ts`.
- [ ] Run `node scripts/generate-ci-workflow.ts --check`.
- [ ] Run `corepack pnpm test:all`, `corepack pnpm check:types`, `corepack pnpm lint`, and `git diff --check`.
- [ ] Run browser/E2E gates only when the environment supports them and report exact commands separately.
@@ -0,0 +1,110 @@
# Release Evidence Remediation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build one immutable release bundle and fail promotion unless every artifact, provider report, schema, and digest proves it describes those exact bytes.
**Architecture:** Executable Zod contracts validate artifacts at every writer boundary. One fail-closed tracked-file inventory feeds security and provenance. CI creates the candidate once, scans that candidate, then promotes it without rebuilding.
**Tech Stack:** TypeScript 7, Node.js 24, Zod 4, Vite 8, Gitea Actions, Vitest 4.
## Global Constraints
- Repository code validates but never fabricates external vulnerability or signed provenance evidence.
- Missing evidence, unreadable required roots, tool crashes, signals, timeouts, and digest mismatch fail closed.
- V2 release identity is its exact contract package set and recomputed set digest; no scalar version is synthesized.
- Promotion consumes the same archived `dist` and `distSha256` produced by `immutable_build`.
- All production changes start with a failing fixture or unit test.
---
### Task 1: One V1/V2 runtime coherence verifier
**Files:**
- Create: `scripts/lib/release-runtime-coherence.ts`
- Modify: `scripts/verify-release.ts`
- Modify: `scripts/drill-runbook.ts`
- Modify: `src/contracts/release-tokens.ts`
- Modify: `tests/unit/release-coherence.test.ts`
- Modify: `tests/unit/release-artifacts.test.ts`
- [ ] Add a shared matrix covering V1 scalar success/mismatch and V2 package add/remove/version/digest tampering. Assert verifier and rollback drill return identical verdicts.
- [ ] Run `corepack pnpm exec vitest run tests/unit/release-coherence.test.ts tests/unit/release-artifacts.test.ts` and confirm RED because the drill compares only scalar release tokens.
- [ ] Implement async `verifyReleaseRuntimeCoherence({ release, runtime, contractPackages })`; V1 delegates to legacy scalar policy, V2 checks exact sorted package tuples then recomputes `contractSet.setDigest`.
- [ ] Remove V2 synthetic `0`/legacy scalar projection from `release-tokens.ts`; call the shared verifier from both scripts.
- [ ] Re-run the focused tests and commit with `git commit -m "fix: unify release runtime coherence verification"`.
### Task 2: Validated artifact writers and generated JSON schemas
**Files:**
- Create: `scripts/lib/validated-json-artifact.ts`
- Create: `scripts/generate-artifact-schemas.ts`
- Modify: `scripts/contracts/release-artifacts.ts`
- Modify: `scripts/generate-build-manifest.ts`
- Modify: `scripts/generate-supply-chain.ts`
- Modify: `scripts/collect-web-vitals-evidence.ts`
- Modify: `scripts/test-performance.ts`
- Modify: `scripts/verify-release.ts`
- Modify: `scripts/drill-runbook.ts`
- Modify: `schemas/artifacts/build-manifest.schema.json`
- Modify: `schemas/artifacts/dependency-inventory.schema.json`
- Modify: `schemas/artifacts/registry-snapshot.schema.json`
- Modify: `schemas/artifacts/supply-chain-verification.schema.json`
- Create: `tests/unit/validated-json-artifact.test.ts`
- Modify: `tests/unit/release-artifacts.test.ts`
- Modify: `tests/unit/json-schema.test.ts`
- [ ] Add tests proving invalid values do not touch the destination, a valid write is atomic, and `generate-artifact-schemas.ts --check` reports checked-in drift.
- [ ] Run focused tests and confirm RED because writers call `writeFile` directly and schemas are hand-maintained.
- [ ] Implement `writeValidatedJsonArtifact({ path, schema, value })`: parse first, write a sibling temporary file, rename atomically, and clean only its explicit temp file on failure.
- [ ] Route every listed writer through the helper. Generate draft-2020-12 schemas deterministically with `additionalProperties: false` and stable final newline.
- [ ] Add `generate:artifact-schemas` and `check:artifact-schemas` scripts; run generation then check mode.
- [ ] Run `corepack pnpm exec vitest run tests/unit/validated-json-artifact.test.ts tests/unit/release-artifacts.test.ts tests/unit/json-schema.test.ts` and commit with `git commit -m "refactor: validate generated evidence artifacts"`.
### Task 3: Manifest outputs and fail-closed repository inventory
**Files:**
- Create: `scripts/lib/repository-file-inventory.ts`
- Create: `scripts/lib/build-manifest-outputs.ts`
- Modify: `scripts/generate-supply-chain.ts`
- Modify: `scripts/security-scan.ts`
- Modify: `scripts/verify-release.ts`
- Modify: `config/security/secret-scan-policy.json`
- Modify: `tests/unit/supply-chain.test.ts`
- Create: `tests/unit/repository-file-inventory.test.ts`
- Modify: `tests/unit/release-artifacts.test.ts`
- [ ] Add fixtures for missing required root, optional `ENOENT`, unreadable file, untracked omission, path traversal, module-inventory tamper, and hash mismatch.
- [ ] Run focused tests and confirm current discovery skips read failures and verification accepts a stale `moduleInventoryHash`.
- [ ] Build inventory from `git ls-files -z` plus explicitly generated inputs; normalize and confine every path under repository root. Only configured optional roots may ignore exact `ENOENT`.
- [ ] Make provenance and secret scan consume the same inventory. Add `index.html`, Vite configs, all TS configs, `.nvmrc`, package/lock files, scripts, schemas, configs, and `.gitea/workflows/quality-gates.yml` to mandatory policy coverage.
- [ ] Implement `verifyBuildManifestOutputs` to confine declared output paths, read module inventory bytes, and compare raw SHA-256 to `moduleInventoryHash`.
- [ ] Re-run focused tests and commit with `git commit -m "fix: fail closed on release input discovery"`.
### Task 4: Immutable candidate, provider evidence, and promotion
**Files:**
- Modify: `package.json`
- Modify: `scripts/generate-supply-chain.ts`
- Modify: `scripts/verify-supply-chain-artifacts.ts`
- Modify: `scripts/verify-supply-chain-promotion.ts`
- Modify: `scripts/check-supply-chain-provider-fixtures.ts`
- Modify: `tests/unit/supply-chain.test.ts`
- Modify: `.gitea/workflows/quality-gates.yml`
- [ ] Add fixtures for absent provider evidence, valid matching digest, wrong digest, and post-attestation byte change. Assert only the valid immutable fixture passes promotion.
- [ ] Run `corepack pnpm exec vitest run tests/unit/supply-chain.test.ts && corepack pnpm check:supply-chain:provider-fixtures` and confirm RED for promotion wiring.
- [ ] Split scripts into `build:release-candidate`, `verify:local-evidence`, `verify:provider-evidence`, and `verify:promotion`; remove any build command from promotion.
- [ ] `immutable_build` archives `dist`, build manifest, module inventory, and local evidence together and publishes `distSha256`. Provider jobs download that archive and emit reports bound to the digest.
- [ ] Promotion downloads the same archive plus provider reports, exports `VULNERABILITY_REPORT_PATH` and `PROVENANCE_ATTESTATION_PATH`, verifies all schemas/signatures/digests, and uploads/deploys the unchanged bundle.
- [ ] Verify missing external evidence remains `FAIL_UNVERIFIED`; do not add a repository-generated passing provider fixture to production flow.
- [ ] Re-run fixtures and the workflow contract check, then commit with `git commit -m "fix: promote immutable verified release bundles"`.
### Task 5: Release/evidence verification
- [ ] Run `corepack pnpm check:artifact-schemas`.
- [ ] Run `corepack pnpm exec vitest run tests/unit/release-artifacts.test.ts tests/unit/release-coherence.test.ts tests/unit/validated-json-artifact.test.ts tests/unit/repository-file-inventory.test.ts tests/unit/supply-chain.test.ts tests/unit/json-schema.test.ts`.
- [ ] Run `corepack pnpm check:supply-chain:fixtures` and `corepack pnpm check:supply-chain:provider-fixtures`.
- [ ] Run the candidate build and local release verification with deterministic local environment values.
- [ ] Confirm promotion fails specifically with `FAIL_UNVERIFIED` when real external evidence paths are absent.
- [ ] Run `corepack pnpm check:types`, `corepack pnpm lint`, and `git diff --check`.
@@ -0,0 +1,281 @@
# Runtime Correctness Remediation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make topic invalidation hit every real bound query and make each logical mutation preserve a unique intent and effect-aware optimistic state.
**Architecture:** Contracts own query-key and mutation-intent shapes. Bootstrap indexes feature invalidation contributions once. Presentation creates one intent per admitted logical submit, while HTTP consumes that intent and optimistic settlement follows the returned effect certainty.
**Tech Stack:** TypeScript 7, React 19, TanStack Query 5, Zod 4, Vitest 4.
## Global Constraints
- Query persistence remains disabled; the key-version change has no persisted migration.
- Query keys and invalidation prefixes are created only by `src/contracts/query-keys.ts`.
- Cross-context messages carry topics, never namespace IDs, query keys, input identities, intent IDs, or idempotency keys.
- A logical mutation creates one intent after duplicate admission and reuses it for every physical attempt.
- A missing `KEYED` idempotency key fails before credentials, fetch admission, or diagnostics containing caller data.
- `MAYBE_APPLIED` never rolls back, commits, invalidates, or retries automatically.
- Every production behavior change is preceded by a focused failing test.
---
### Task 1: Query-key V2 and namespace identities
**Files:**
- Modify: `src/contracts/query-keys.ts`
- Modify: `src/contracts/server-state.ts`
- Modify: `tests/component/application-query.test.tsx`
- Modify: `tests/unit/query-invalidation-registry.test.ts`
**Interfaces:**
- Adds `QUERY_KEY_SCHEMA_VERSION = 2`.
- Adds `QueryNamespaceIdentity = { namespaceId: string; namespaceVersion: number }`.
- Adds `defineQueryNamespaceIdentity`, `createBoundQueryKey`, `createQueryInvalidationPrefix`, and `queryNamespaceIdentityKey`.
- Changes `bindQuery` to delegate key construction to `createBoundQueryKey`.
- [ ] **Step 1: Add failing key/prefix parity tests**
```ts
const namespace = defineQueryNamespaceIdentity("reference-resource", 1);
const bound = bindQuery(definition, input, scope);
expect(bound.queryKey).toEqual([
"query", 2, "reference-resource", 1,
scope.fingerprint, definition.definitionVersion, bound.identity.token,
]);
expect(bound.queryKey.slice(0, 4)).toEqual(
createQueryInvalidationPrefix(namespace),
);
```
Also reject empty/control-character IDs, non-positive versions, and excessive UTF-8 length.
- [ ] **Step 2: Run RED**
Run: `corepack pnpm exec vitest run tests/component/application-query.test.tsx tests/unit/query-invalidation-registry.test.ts`
Expected: missing helper exports and current V1 key order mismatch.
- [ ] **Step 3: Implement the shared constructors**
`createBoundQueryKey` must return exactly:
```ts
Object.freeze([
"query", QUERY_KEY_SCHEMA_VERSION,
namespace.namespaceId, namespace.namespaceVersion,
scopeFingerprint, definitionVersion, identityToken,
]);
```
`createQueryInvalidationPrefix` returns the first four entries. `bindQuery` constructs the namespace identity from the definition rather than duplicating the tuple.
- [ ] **Step 4: Run GREEN**
Run: `corepack pnpm exec vitest run tests/component/application-query.test.tsx tests/unit/query-invalidation-registry.test.ts`
- [ ] **Step 5: Commit**
```bash
git add src/contracts/query-keys.ts src/contracts/server-state.ts tests/component/application-query.test.tsx tests/unit/query-invalidation-registry.test.ts
git commit -m "fix: align bound query keys with invalidation prefixes"
```
### Task 2: Many-to-many invalidation in production composition
**Files:**
- Modify: `src/contracts/query-invalidation.ts`
- Modify: `src/features/reference-feature/contracts/reference-feature-contract.ts`
- Modify: `src/features/installed-feature-contracts.ts`
- Modify: `src/adapters/query-cache/tanstack-cache-coordinator.ts`
- Modify: `src/bootstrap/runtime-adapters.ts`
- Modify: `tests/unit/query-invalidation-registry.test.ts`
- Modify: `tests/unit/tanstack-cache-coordinator.test.ts`
- Modify: `tests/features/reference-feature/reference-contract.test.ts`
- Modify: `tests/unit/runtime-adapters.test.ts`
**Interfaces:**
- `InvalidationRegistry.namespaces` and edges use `QueryNamespaceIdentity`.
- `indexInvalidationRegistry` returns every namespace identity for each topic.
- `createTanStackCacheCoordinator` consumes `InvalidationRegistryIndex`; topic versions remain a separate bounded map used only by cross-context transport.
- Installed features export `INVALIDATION_REGISTRY`, composed once at bootstrap.
- [ ] **Step 1: Add failing real-key invalidation and fan-out tests**
Seed `QueryClient` with real `bindQuery(...).queryKey` values, map one topic to two namespaces, call local and remote invalidation, and assert both matching queries are invalidated while an unrelated namespace is not. Assert the published event contains only topic/version.
- [ ] **Step 2: Run RED**
Run: `corepack pnpm exec vitest run tests/unit/query-invalidation-registry.test.ts tests/unit/tanstack-cache-coordinator.test.ts tests/features/reference-feature/reference-contract.test.ts tests/unit/runtime-adapters.test.ts`
Expected: the coordinator accepts the legacy flat registry and invalidates prefixes that do not match bound keys.
- [ ] **Step 3: Compose and index contributions once**
Feature contribution shape:
```ts
invalidation: Object.freeze({
topics: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
namespaces: [defineQueryNamespaceIdentity("reference-resource", 1)],
edges: [{
topicId: REFERENCE_RESOURCE_INVALIDATION_TOPIC,
namespace: defineQueryNamespaceIdentity("reference-resource", 1),
}],
})
```
`installed-feature-contracts.ts` concatenates these bounded contributions. `runtime-adapters.ts` calls `indexInvalidationRegistry(INVALIDATION_REGISTRY)` exactly once, derives transport topic versions, and passes the index to each generation's coordinator.
- [ ] **Step 4: Make the coordinator invalidate every indexed prefix**
For each topic, iterate `namespacesForTopic.get(topic)`, create the V2 prefix with `createQueryInvalidationPrefix`, and call `invalidateQueries({ exact: false, refetchType: "active" })`. Sequence-gap handling visits all indexed topics without duplicating namespace work.
- [ ] **Step 5: Remove legacy authorities**
Delete `QUERY_REGISTRY` from `src/contracts/query-keys.ts`, the flat installed `QUERY_REGISTRY`, `InstalledQueryInvalidationDefinition`, and feature-owned concrete TanStack namespace tuples after `rg` shows zero callers.
- [ ] **Step 6: Run GREEN**
Run: `corepack pnpm exec vitest run tests/unit/query-invalidation-registry.test.ts tests/unit/tanstack-cache-coordinator.test.ts tests/features/reference-feature/reference-contract.test.ts tests/unit/runtime-adapters.test.ts`
- [ ] **Step 7: Commit**
```bash
git add src/contracts/query-invalidation.ts src/features/reference-feature/contracts/reference-feature-contract.ts src/features/installed-feature-contracts.ts src/adapters/query-cache/tanstack-cache-coordinator.ts src/bootstrap/runtime-adapters.ts tests/unit/query-invalidation-registry.test.ts tests/unit/tanstack-cache-coordinator.test.ts tests/features/reference-feature/reference-contract.test.ts tests/unit/runtime-adapters.test.ts
git commit -m "fix: index many-to-many query invalidation"
```
### Task 3: Application-owned mutation intent
**Files:**
- Create: `src/contracts/mutation-intent.ts`
- Create: `src/application/ports/mutation-intent-factory.ts`
- Create: `src/adapters/platform/browser-mutation-intent-factory.ts`
- Modify: `src/contracts/server-state.ts`
- Modify: `src/presentation/adapters/query/application-query.ts`
- Create: `src/presentation/adapters/query/mutation-intent-provider.tsx`
- Modify: `src/presentation/adapters/query/server-state-generation-provider.tsx`
- Modify: `src/bootstrap/runtime-adapters.ts`
- Modify: `src/features/reference-feature/adapters/reference-http-gateway.ts`
- Modify: `src/adapters/http/http-effect-certainty.ts`
- Modify: `src/adapters/http/http-execution-v3.ts`
- Modify: `tests/component/application-query.test.tsx`
- Modify: `tests/unit/runtime-adapters.test.ts`
- Modify: `tests/unit/http-execution-v3.test.ts`
- Modify: `tests/integration/http-execution-contract.test.ts`
**Interfaces:**
- `MutationIntent` has the exact approved immutable shape.
- `MutationIntentFactory.create({ operationId, canonicalInputIdentity, requiresIdempotencyKey })` returns one intent.
- `BoundMutation.execute` context adds `intent: MutationIntent`.
- `HttpExecutionContext.intent` consumes the application intent without regenerating it.
- [ ] **Step 1: Add failing lifecycle tests**
Assert two independent submits receive different intent/key pairs; a `JOIN_IDENTICAL` waiter shares the admitted submit; physical HTTP retry sees the same key; queries have no intent header; diagnostics and URLs contain neither intent ID nor key.
- [ ] **Step 2: Run RED**
Run: `corepack pnpm exec vitest run tests/component/application-query.test.tsx tests/unit/runtime-adapters.test.ts tests/unit/http-execution-v3.test.ts tests/integration/http-execution-contract.test.ts`
Expected: bound mutation context has no intent and bootstrap produces resettable `http-key-N` values.
- [ ] **Step 3: Define intent validation and browser factory**
Validate bounded non-empty strings and finite non-negative monotonic timestamps. Use `crypto.randomUUID()` independently for `intentId` and required idempotency key; permit deterministic injected factories in tests.
- [ ] **Step 4: Create intent after duplicate admission**
Keep canonical identity calculation before duplicate lookup. Only the execution that wins admission calls the factory. Pass the same frozen intent through `mutation.mutateAsync({ input, intent })` and every bound mutation/feature gateway call.
- [ ] **Step 5: Remove adapter-local sequence identity**
Delete `contractExecutionSequence`, `http-intent-N`, `http-key-N`, and the unused HTTP-layer `MutationIntent` factory. Bootstrap passes the supplied intent into `ContractHttpExecutor` unchanged.
- [ ] **Step 6: Run GREEN**
Run the command from Step 2 and expect all intent lifecycle assertions to pass.
- [ ] **Step 7: Commit**
```bash
git add src/contracts/mutation-intent.ts src/application/ports/mutation-intent-factory.ts src/adapters/platform/browser-mutation-intent-factory.ts src/contracts/server-state.ts src/presentation/adapters/query/application-query.ts src/presentation/adapters/query/mutation-intent-provider.tsx src/presentation/adapters/query/server-state-generation-provider.tsx src/bootstrap/runtime-adapters.ts src/features/reference-feature/adapters/reference-http-gateway.ts src/adapters/http/http-effect-certainty.ts src/adapters/http/http-execution-v3.ts tests/component/application-query.test.tsx tests/unit/runtime-adapters.test.ts tests/unit/http-execution-v3.test.ts tests/integration/http-execution-contract.test.ts
git commit -m "fix: preserve logical mutation intent"
```
### Task 4: Fail KEYED commands before dispatch
**Files:**
- Modify: `src/adapters/http/http-execution-v3.ts`
- Modify: `tests/unit/http-execution-v3.test.ts`
- Modify: `tests/integration/http-execution-contract.test.ts`
- [ ] Add tests for absent, empty, control-character, and over-budget keys. Spy on `attachCredentials` and `fetch`; both must remain at zero and the result must be `CONTRACT_VIOLATION` with `effect: "NOT_STARTED"`.
- [ ] Run `corepack pnpm exec vitest run tests/unit/http-execution-v3.test.ts tests/integration/http-execution-contract.test.ts` and confirm RED because KEYED commands currently dispatch without a key.
- [ ] Add `MISSING_IDEMPOTENCY_KEY` to the request violation union and validate before credential resolution. Reject a key on `NONE`/query descriptors as the same pre-dispatch contract class.
- [ ] Re-run the focused tests and confirm GREEN, including same-key physical retry.
- [ ] Commit with `git commit -m "fix: reject invalid keyed mutation intents"`.
### Task 5: Effect-aware optimistic settlement and reconciliation
**Files:**
- Modify: `src/presentation/adapters/query/optimistic-layer-runtime.ts`
- Modify: `src/presentation/adapters/query/application-query.ts`
- Modify: `src/application/view-models/async-state.ts`
- Modify: `src/contracts/errors.ts`
- Modify: `tests/unit/optimistic-layer-runtime.test.ts`
- Modify: `tests/component/application-query.test.tsx`
**Interfaces:**
- `OptimisticLayerLease` adds `markUncertain()` and `reconcile("APPLIED" | "NOT_APPLIED")`.
- Layer status becomes `pending | uncertain | committed`; collapse stops before unresolved uncertain layers.
- Controller adds `reconcileUnknownEffect(resolution)` tied to the original intent.
- Async state adds `mutation-effect-unknown`.
- [ ] **Step 1: Add failing certainty matrix tests**
Cover `NOT_STARTED`, `NOT_APPLIED`, `APPLIED_CONFIRMED`, and `MAYBE_APPLIED`; out-of-order later commits; applied/not-applied reconciliation; scope closure. Assert unknown effect does not call `invalidate` or expose generic retry.
- [ ] **Step 2: Run RED**
Run: `corepack pnpm exec vitest run tests/unit/optimistic-layer-runtime.test.ts tests/component/application-query.test.tsx`
Expected: current catch path rolls every failure back.
- [ ] **Step 3: Derive settlement before touching optimistic state**
Use `failure.effect ?? "NOT_STARTED"` only for failures known to be pre-dispatch. The mutation bridge switches explicitly:
```ts
switch (effect) {
case "NOT_STARTED":
case "NOT_APPLIED": rollback(); break;
case "APPLIED_CONFIRMED": commit(); scheduleInvalidation(); break;
case "MAYBE_APPLIED": markUncertain(); exposeReconciliation(); break;
}
```
- [ ] **Step 4: Preserve ordered uncertain layers**
Projection still applies uncertain layers. `collapse` may consume committed layers only until the first pending/uncertain layer. Reconciliation converts uncertain to committed or removes it, then reprojects all later layers.
- [ ] **Step 5: Run GREEN**
Run the command from Step 2 and expect all certainty and ordering cases to pass.
- [ ] **Step 6: Commit**
```bash
git add src/presentation/adapters/query/optimistic-layer-runtime.ts src/presentation/adapters/query/application-query.ts src/application/view-models/async-state.ts src/contracts/errors.ts tests/unit/optimistic-layer-runtime.test.ts tests/component/application-query.test.tsx
git commit -m "fix: retain uncertain optimistic mutations"
```
### Task 6: Runtime correctness verification
- [ ] Run `corepack pnpm exec vitest run tests/unit/query-invalidation-registry.test.ts tests/unit/tanstack-cache-coordinator.test.ts tests/unit/http-execution-v3.test.ts tests/unit/optimistic-layer-runtime.test.ts tests/component/application-query.test.tsx tests/integration/http-execution-contract.test.ts tests/features/reference-feature/reference-contract.test.ts tests/unit/runtime-adapters.test.ts`.
- [ ] Run `corepack pnpm check:types`.
- [ ] Run `corepack pnpm lint`.
- [ ] Run `corepack pnpm test:all`.
- [ ] Run `git diff --check`.
- [ ] Record any browser-only gate as unverified unless its Playwright command actually ran.
@@ -0,0 +1,614 @@
# CI/CD Frontend Assurance and Delivery Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `cicd-platform` the sole owner of frontend workflow orchestration, remote projection of test-assurance plans, deterministic candidate production, supply-chain/provider evidence, immutable publication, and exact-candidate promotion.
**Architecture:** `cicdctl` compiles typed stages and consumes the digest-pinned test-assurance v3 external contract without interpreting test reports. One source revision produces one verified candidate; artifact-bound testing and every supply-chain stage consume that candidate by SHA-256. `release-control` is the only mutating publication/promotion boundary. The centrally installed required workflow remains a four-step pinned bootstrap and is never copied into a product repository.
**Tech Stack:** Go, JSON Schema 2020-12, Gitea Actions, Node.js/pnpm runners, canonical SHA-256, Ed25519, content-addressed static publication.
## Global Constraints
- Repository: `/home/donghyeon/workspace/desktop-server-git/cicd-platform`; every command runs from its isolated worktree root.
- Start only from the immutable Test Assurance Task 10 commit, its v3 distribution digest, and its v3 schema-bundle digest. No current v2 binary may satisfy this dependency.
- Resolve Go 1.26.5 from `toolchains/toolchain-lock.yaml` into `.platform/toolchains/go-1.26.5/bin/go`, verify the distribution SHA-256 before extraction, and set `CICD_GO_BIN` to that absolute path. The host currently has no `go`; PATH fallback is forbidden.
- CI may choose runner placement and parallelism but may not change test selection, timeout, retry, artifact requirements, result status, or obligation satisfaction.
- Test raw reports remain opaque to CI; only testctl v3 plans, normalized results, evidence, assessments, IDs, digests, attempts, and exit codes are consumed.
- The platform path is the only candidate producer in shadow. Legacy product release/promotion commands may only compare bytes and evidence read-only.
- The first deterministic build is the candidate. The isolated comparison build is destroyed and cannot be promoted.
- Exact new IDs are `ci-test-assurance`, `ci-dependency-vulnerability`, `ci-artifact-signing`, `ci-static-artifact-supply-chain`, and `ci-static-site-publish`.
- Existing `ci-sbom` and `ci-provenance` gain static-archive subjects without weakening container subjects.
- All new capabilities remain P1/shadow until named Gitea, runner, scanner, signer, and provider evidence supports P2.
- Each P1 transition is atomic: canonical ID, descriptor/policy/acceptance/runbook, provider registration, readiness registry row, fixture, immutable evidence, `docs/decisions/readiness/<capability>-P1.yaml`, and `Makefile` capability run land in the same commit. A P0 capability may not have an active provider.
- Rollback selects a previous signed platform catalog and immutable subject through platform control; it never restores a copied workflow or a legacy writer.
---
### Task 1: Define typed stage, artifact, and release-identity contracts
- [ ] Materialize the locked Go toolchain before writing tests. Run from the isolated CICD worktree (network download requires the normal escalation approval):
```bash
mkdir -p .platform/downloads .platform/toolchains/go-1.26.5
curl --fail --location --proto '=https' --tlsv1.3 https://go.dev/dl/go1.26.5.linux-amd64.tar.gz --output .platform/downloads/go1.26.5.linux-amd64.tar.gz
printf '%s %s\n' '5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053' '.platform/downloads/go1.26.5.linux-amd64.tar.gz' | sha256sum --check
tar -xzf .platform/downloads/go1.26.5.linux-amd64.tar.gz --strip-components=1 -C .platform/toolchains/go-1.26.5
export CICD_GO_BIN="$PWD/.platform/toolchains/go-1.26.5/bin/go"
"$CICD_GO_BIN" version
```
Expected: exact `go version go1.26.5 linux/amd64`. Stop on download/digest mismatch; never use `/usr/bin/go` or another PATH binary.
**Files:**
- Modify: `packages/contracts/models.go`
- Modify: `packages/contracts/schema.go`
- Preserve unchanged: `contracts/schemas/execution-plan.schema.json` and `contracts/schemas/release-manifest.schema.json` v1 contracts
- Create: `contracts/schemas/execution-plan-v2.schema.json`
- Create: `contracts/schemas/release-manifest-v2.schema.json`
- Create: `contracts/schemas/promotion-subject.schema.json`
- Modify: `contracts/schemas/delivery-platform.schema.json`
- Create: `contracts/schemas/artifact-reference.schema.json`
- Create: `contracts/schemas/candidate-bundle.schema.json`
- Create: `contracts/schemas/test-assurance-result.schema.json`
- Create: `contracts/schemas/supply-chain-evidence.schema.json`
- Create: `contracts/schemas/publication-record.schema.json`
- Sync generated copies under: `packages/contracts/schemas/`
- Modify: `packages/contracts/schema_test.go`
- Modify: `packages/canonicalid/id.go`
- Modify: `packages/canonicalid/id_test.go`
- Create: `contracts/examples/valid/execution-plan-v2.json`
- Create: `contracts/examples/valid/release-manifest-v2.json`
- Create: `contracts/examples/valid/promotion-subject.json`
- Create: `contracts/examples/invalid/13-v2-stage-with-shell-payload.json`
- Create: `contracts/examples/invalid/14-release-manifest-with-promotion-state.json`
- Modify: `contracts/snapshots/` only by adding a new v2 snapshot set; do not rewrite `contracts/snapshots/v1/`
- Modify: `contracts/readiness-cards.yaml`
- Create P0 directories: `capabilities/ci-test-assurance/`, `capabilities/ci-dependency-vulnerability/`, `capabilities/ci-artifact-signing/`, `capabilities/ci-static-artifact-supply-chain/`, `capabilities/ci-static-site-publish/`
**Interfaces:**
```go
type StageInvocation struct {
Kind string // internal | platform-adapter | external-contract
Engine string // cicdctl | release-control | test-assurance | provider
AdapterID string
Operation string
Payload json.RawMessage // validated by the adapter/operation-specific schema
WorkItemID string
Inputs []ArtifactReference
Outputs []ArtifactDeclaration
}
type TestAssuranceReference struct {
APIVersion string
ManifestDigest string
PolicyDigest string
ExecutionPhase string
PlanDigest string
EvidenceDigest string
AssessmentDigest string
InputArtifacts []ArtifactReference
}
type PromotionSubject struct {
ReleaseManifestDigest string
TargetEnvironment string
ApprovalID string
ApprovalPolicyDigest string
PublicationRecordDigest string
ExpectedGitRevision string
ExpectedTreeDigest string
}
```
`ReleaseManifestV2` is immutable and carries separate source and artifact `TestAssuranceReference` values plus source revision, candidate archive/member-manifest digests, dependency-vulnerability/SBOM/provenance/signature digests, and platform policy/catalog/toolchain digests. Target environment, approval, publication, and Git CAS belong only to `PromotionSubject`; promotion history never rewrites the signed release manifest. The combined signed release identity is `ReleaseManifestV2 + PromotionSubject`.
Canonical kinds are fixed: `ci-test-assurance` is `KindComposite`; `ci-dependency-vulnerability`, `ci-artifact-signing`, and `ci-static-site-publish` are `KindArtifact`; `ci-static-artifact-supply-chain` is `KindComposite`.
- [ ] Add RED tests rejecting free-form shell payload, missing invocation on a planned v2 stage, mutable artifact reference, duplicate output ID, test-assurance v2 reference, source/artifact reference aliasing, wrong candidate digest, release manifest missing either assessment, promotion fields inside the immutable manifest, and target/approval missing from `PromotionSubject`.
- [ ] Run:
```bash
"$CICD_GO_BIN" test ./packages/contracts ./packages/canonicalid
make GO="$CICD_GO_BIN" contracts
```
Expected: RED because the typed contracts do not exist.
- [ ] Implement v2 contracts and document-version mappings while retaining v1 dual-read behavior. `StageInvocation.Payload` is a discriminated typed payload validated by `(AdapterID, Operation)`; the adapter passes its internal argv directly through `exec.CommandContext` and never invokes a shell.
- [ ] Run:
```bash
make GO="$CICD_GO_BIN" contracts-sync
make GO="$CICD_GO_BIN" contracts
"$CICD_GO_BIN" test ./packages/contracts ./packages/canonicalid
```
Expected: PASS.
- [ ] Commit:
```bash
git add packages/contracts packages/canonicalid contracts capabilities/ci-test-assurance capabilities/ci-dependency-vulnerability capabilities/ci-artifact-signing capabilities/ci-static-artifact-supply-chain capabilities/ci-static-site-publish
git commit -m "feat(contracts): define frontend delivery identities"
```
---
### Task 2: Execute typed stages with verified artifact fan-out
**Files:**
- Create: `apps/cicdctl/internal/execution/adapter.go`
- Create: `apps/cicdctl/internal/execution/engine.go`
- Create: `apps/cicdctl/internal/execution/artifact_store.go`
- Create: `apps/cicdctl/internal/execution/local_artifact_store.go`
- Create: `apps/cicdctl/internal/execution/engine_test.go`
- Create: `apps/cicdctl/internal/execution/artifact_store_test.go`
- Modify: `apps/cicdctl/internal/execution/scheduler.go`
- Modify: `apps/cicdctl/internal/execution/completeness.go`
- Modify: `apps/cicdctl/internal/app/run_command.go`
**Interfaces:**
```go
type InvocationAdapter interface {
AdapterID() string
Execute(context.Context, InvocationRequest) (InvocationOutcome, error)
}
type ArtifactStore interface {
Put(context.Context, ArtifactInput) (contracts.ArtifactReference, error)
MaterializeVerified(context.Context, contracts.ArtifactReference, string) error
}
```
- [ ] Add RED tests proving source checkout is read-only, independent stages do not share mutable output paths, mutation after `Put` fails, missing adapters are platform defects, failed dependencies block descendants, and a missing terminal result cannot pass.
- [ ] Run:
```bash
"$CICD_GO_BIN" test ./apps/cicdctl/internal/execution ./apps/cicdctl/internal/app -count=1
```
Expected: RED because no invocation engine/artifact store exists.
- [ ] Implement bounded scheduler waves over existing state-machine rules. Every downstream artifact is materialized to a private directory and rehashed before adapter invocation.
- [ ] Add `cicdctl run execute --plan --checkout --results --artifact-root` with atomic result writes.
- [ ] Re-run `"$CICD_GO_BIN" test ./apps/cicdctl/internal/execution ./apps/cicdctl/internal/app -count=1`; expected PASS.
- [ ] Commit:
```bash
git add apps/cicdctl/internal/execution apps/cicdctl/internal/app
git commit -m "feat(cicdctl): execute typed artifact-bound stages"
```
---
### Task 3: Integrate the digest-pinned test-assurance v3 contract
**Files:**
- Create: `apps/cicdctl/internal/adapters/testassurance/contract.go`
- Create: `apps/cicdctl/internal/adapters/testassurance/runner.go`
- Create: `apps/cicdctl/internal/adapters/testassurance/projector.go`
- Create: `apps/cicdctl/internal/adapters/testassurance/contract_test.go`
- Create: `apps/cicdctl/internal/adapters/testassurance/runner_test.go`
- Create: `apps/cicdctl/internal/adapters/testassurance/projector_test.go`
- Modify: `apps/cicdctl/internal/planner/plan.go`
- Modify: `apps/cicdctl/internal/capabilities/capabilities.go`
- Modify: `apps/cicdctl/internal/app/run_command.go`
- Create imported immutable schema bundle: `toolchains/external-contracts/test-assurance-v3/`
- Create: `toolchains/external-contracts/test-assurance-v3/contract-lock.json`
- Create: `contracts/schemas/external-contract-lock.schema.json`
- Create: `packages/contracts/schemas/external-contract-lock.schema.json`
- Modify: `toolchains/platform-release-catalog.yaml`
- Modify: `contracts/schemas/platform-release-catalog.schema.json`
- Modify: `packages/contracts/schemas/platform-release-catalog.schema.json`
- Modify: `packages/contracts/schema.go`
- Modify: `packages/contracts/schema_test.go`
- Modify: `contracts/examples/valid/platform-release-catalog.json`
- Create/complete: `capabilities/ci-test-assurance/descriptor.yaml`, `capabilities/ci-test-assurance/policy.yaml`, `capabilities/ci-test-assurance/acceptance.yaml`, `capabilities/ci-test-assurance/runbook-index.yaml`
- Create: `docs/decisions/readiness/ci-test-assurance-P1.yaml`
- Create evidence under: `docs/decisions/readiness/evidence/ci-test-assurance/`
- Modify: `contracts/readiness-cards.yaml`
- Modify: `Makefile`
**External calls:**
```text
testctl validate
testctl lock
testctl compile
testctl select
testctl plan
testctl execute-one
testctl normalize
testctl bundle
testctl assess
```
All calls use argv arrays and a distribution/schema digest from the signed platform catalog. `contract-lock.json` binds test-assurance source revision, v3 schema major, distribution digest, and schema-bundle digest. The catalog schema adds a typed `test-assurance` engine reference; without that engine, `ci-test-assurance` compilation fails.
`projector.go` preserves the external plan bytes/digest unchanged and creates a separate CI projection whose nodes reference only `{externalPlanDigest, workItemId}` plus runner placement and CI dependency edges. It never writes a modified testctl plan.
The source request is planned before the build with no input artifacts. After `ci-frontend` freezes the first candidate, CI creates an artifact request containing its exact `{artifactId, mediaType, sha256}`. Testctl, not CI, matches that request to repository `ArtifactSuiteTemplate` declarations and materializes executable v3 suites/work items. CI rejects any unresolved template, placeholder digest, or work item whose input tuple differs from the candidate reference.
- [ ] Add RED tests for source-revision/schema/distribution digest mismatch, absent catalog engine, non-v3 output, altered external plan bytes, altered timeout/retry, missing work-item result, source/artifact plan mixing, opaque evidence preservation, and exact testctl exit-code mapping from the published external contract.
- [ ] Run:
```bash
"$CICD_GO_BIN" test ./apps/cicdctl/internal/adapters/testassurance ./apps/cicdctl/internal/planner ./apps/cicdctl/internal/capabilities -count=1
```
Expected: RED.
- [ ] Implement the adapter without importing JUnit, Playwright, coverage, HTTP, accessibility, or visual parsing code. Invoke v3 artifact work as `testctl execute-one --plan <plan> --work-item-id <id> --artifact-map <map> --output <dir>`.
- [ ] Raise `ci-test-assurance` to P1/shadow only after a local v3 source→artifact conformance fixture completes.
- [ ] Re-run `"$CICD_GO_BIN" test ./apps/cicdctl/internal/adapters/testassurance ./apps/cicdctl/internal/planner ./apps/cicdctl/internal/capabilities -count=1` and `make GO="$CICD_GO_BIN" registry`; expected PASS.
- [ ] Commit:
```bash
git add apps/cicdctl toolchains/external-contracts/test-assurance-v3 toolchains/platform-release-catalog.yaml contracts/schemas/platform-release-catalog.schema.json contracts/examples/valid/platform-release-catalog.json packages/contracts capabilities/ci-test-assurance contracts/readiness-cards.yaml docs/decisions/readiness/ci-test-assurance-P1.yaml docs/decisions/readiness/evidence/ci-test-assurance Makefile
git commit -m "feat(test-assurance): project external v3 test plans"
```
---
### Task 4: Split Node ownership and freeze the first deterministic candidate
**Files:**
- Modify: `apps/cicdctl/internal/manifest/model.go`
- Modify: `apps/cicdctl/internal/manifest/compiler.go`
- Modify: `apps/cicdctl/internal/manifest/compiler_test.go`
- Modify: `apps/cicdctl/internal/templates/nodetypescript/resolver.go`
- Modify: `apps/cicdctl/internal/templates/nodetypescript/resolver_test.go`
- Modify: `apps/cicdctl/internal/templates/frontend/resolver.go`
- Modify: `apps/cicdctl/internal/templates/frontend/resolver_test.go`
- Create: `apps/cicdctl/internal/artifacts/candidate/builder.go`
- Create: `apps/cicdctl/internal/artifacts/candidate/archive.go`
- Create: `apps/cicdctl/internal/artifacts/candidate/builder_test.go`
- Create: `apps/cicdctl/internal/artifacts/candidate/archive_test.go`
- Modify: `capabilities/ci-node-typescript/*`
- Modify: `capabilities/ci-frontend/*`
- Create: `docs/decisions/readiness/ci-frontend-P1.yaml`
- Create: `docs/decisions/readiness/evidence/ci-frontend-P1.bundle.json`
- Modify: `contracts/readiness-cards.yaml`
- Modify: `Makefile`
**Manifest config:**
```go
type NodeTypeScriptConfig struct {
LintScript string
TypecheckScript string
TestOwner string // empty or ci-test-assurance
}
type FrontendConfig struct {
BuildScript string
OutputDirectory string
SizeBudgetBytes int64
ForbiddenEnvironment []string
}
```
Package manager/version/install mode are repository facts derived from `package.json.packageManager` and `pnpm-lock.yaml`; capability config cannot override them. The execution-plan compiler permits exactly one `candidate-producer` operation for a selected release output. A second producer, a legacy product candidate command, or a writer without platform writer identity/operation ID/idempotency key is a contract error.
- [ ] Add RED tests proving capability config is decoded, pnpm is derived and uses frozen install, manifest package-manager override is rejected, lint/typecheck remain in CI, unit/coverage stages disappear when test owner is `ci-test-assurance`, source revision is built twice in isolated workspaces, only the first byte-identical candidate is retained, and a plan with zero/two candidate producers is rejected.
- [ ] Add archive adversarial tests for empty output, traversal, symlink, host path, undeclared member, duplicate path, environment leak, size overflow, and changed tree digest.
- [ ] Run `"$CICD_GO_BIN" test ./apps/cicdctl/internal/manifest ./apps/cicdctl/internal/templates/nodetypescript ./apps/cicdctl/internal/templates/frontend ./apps/cicdctl/internal/artifacts/candidate -count=1`; expected RED.
- [ ] Implement strict config decoding and canonical archive/member manifest generation. Destroy the verification workspace before returning the candidate reference.
- [ ] Re-run `"$CICD_GO_BIN" test ./apps/cicdctl/internal/manifest ./apps/cicdctl/internal/templates/nodetypescript ./apps/cicdctl/internal/templates/frontend ./apps/cicdctl/internal/artifacts/candidate -count=1` and `make GO="$CICD_GO_BIN" registry`; expected PASS.
- [ ] Commit:
```bash
git add apps/cicdctl/internal/manifest apps/cicdctl/internal/templates apps/cicdctl/internal/artifacts/candidate capabilities/ci-node-typescript capabilities/ci-frontend contracts/readiness-cards.yaml docs/decisions/readiness/ci-frontend-P1.yaml docs/decisions/readiness/evidence/ci-frontend-P1.bundle.json Makefile
git commit -m "feat(frontend): freeze one deterministic static candidate"
```
---
### Task 5: Bind dependency-vulnerability evidence to source and candidate
**Files:**
- Create: `apps/cicdctl/internal/artifacts/vulnerability/contract.go`
- Create: `apps/cicdctl/internal/artifacts/vulnerability/adapter.go`
- Create: `apps/cicdctl/internal/artifacts/vulnerability/validator.go`
- Create: `apps/cicdctl/internal/artifacts/vulnerability/adapter_test.go`
- Create: `apps/cicdctl/internal/artifacts/vulnerability/validator_test.go`
- Modify: `apps/cicdctl/internal/capabilities/capabilities.go`
- Create/complete: `capabilities/ci-dependency-vulnerability/descriptor.yaml`, `capabilities/ci-dependency-vulnerability/policy.yaml`, `capabilities/ci-dependency-vulnerability/acceptance.yaml`, `capabilities/ci-dependency-vulnerability/runbook-index.yaml`
- Create: `docs/decisions/readiness/ci-dependency-vulnerability-P1.yaml`
- Create evidence under: `docs/decisions/readiness/evidence/ci-dependency-vulnerability/`
- Modify: `contracts/readiness-cards.yaml`
- Modify: `Makefile`
**Evidence identity:** source revision, lockfile digest, candidate subject digest, provider ID, scanner/tool digest, vulnerability DB snapshot digest/time, invocation digest, normalized finding set, and evidence signature.
The adapter executes a digest-pinned provider engine through a typed contract and validates its output. It contains no scanner HTTP client and receives no provider credential; network/credential handling stays inside the provider trust boundary.
- [ ] Add RED cases for absent report, wrong lockfile/candidate, stale DB, provider crash, malformed report, invalid signature, and zero findings without valid invocation metadata.
- [ ] Run `"$CICD_GO_BIN" test ./apps/cicdctl/internal/artifacts/vulnerability -count=1`; expected RED.
- [ ] Implement a typed provider profile selected by manifest ID and direct process execution of the pinned engine; arbitrary repository commands and in-process provider clients are forbidden.
- [ ] Raise to P1/shadow with signed local fixtures; keep P2 blocked on a named real scanner/provider.
- [ ] Re-run `"$CICD_GO_BIN" test ./apps/cicdctl/internal/artifacts/vulnerability ./apps/cicdctl/internal/capabilities -count=1` and `make GO="$CICD_GO_BIN" registry`; expected PASS.
- [ ] Commit:
```bash
git add apps/cicdctl/internal/artifacts/vulnerability apps/cicdctl/internal/capabilities capabilities/ci-dependency-vulnerability contracts/readiness-cards.yaml docs/decisions/readiness/ci-dependency-vulnerability-P1.yaml docs/decisions/readiness/evidence/ci-dependency-vulnerability Makefile
git commit -m "feat(security): bind dependency findings to candidates"
```
---
### Task 6: Generalize SBOM and provenance to static archives
**Files:**
- Create: `apps/cicdctl/internal/artifacts/subject/subject.go`
- Create: `apps/cicdctl/internal/artifacts/subject/subject_test.go`
- Create: `apps/cicdctl/internal/artifacts/sbom/contract.go`
- Create: `apps/cicdctl/internal/artifacts/sbom/validator.go`
- Create: `apps/cicdctl/internal/artifacts/sbom/sbom_test.go`
- Create: `apps/cicdctl/internal/artifacts/provenance/contract.go`
- Create: `apps/cicdctl/internal/artifacts/provenance/validator.go`
- Create: `apps/cicdctl/internal/artifacts/provenance/provenance_test.go`
- Modify: `capabilities/ci-sbom/*`
- Modify: `capabilities/ci-provenance/*`
- Modify: `apps/cicdctl/internal/capabilities/capabilities.go`
- Create: `docs/decisions/readiness/ci-sbom-P1.yaml`
- Create: `docs/decisions/readiness/ci-provenance-P1.yaml`
- Create: `docs/decisions/readiness/evidence/ci-sbom-P1.bundle.json`
- Create: `docs/decisions/readiness/evidence/ci-provenance-P1.bundle.json`
- Modify: `contracts/readiness-cards.yaml`
- Modify: `Makefile`
**Interface:** `ImmutableSubject` is a tagged union of container image or static archive. Both evidence types bind subject kind/digest; static provenance additionally binds source revision, build invocation, member-manifest digest, platform/toolchain digests, and determinism evidence. Digest-pinned external SBOM/provenance engines generate documents; cicdctl validates and binds returned evidence but implements no provider network client.
- [ ] Add RED tests for empty/incomplete SBOM, duplicate package identity, wrong subject kind/digest, missing build invocation, changed source revision, changed member manifest, and fabricated provenance.
- [ ] Run `"$CICD_GO_BIN" test ./apps/cicdctl/internal/artifacts/subject ./apps/cicdctl/internal/artifacts/sbom ./apps/cicdctl/internal/artifacts/provenance -count=1`; expected RED.
- [ ] Implement output-kind adapters while preserving every existing container test unchanged.
- [ ] Re-run `"$CICD_GO_BIN" test ./apps/cicdctl/internal/artifacts/subject ./apps/cicdctl/internal/artifacts/sbom ./apps/cicdctl/internal/artifacts/provenance ./apps/cicdctl/internal/capabilities -count=1`, `make GO="$CICD_GO_BIN" contracts`, and `make GO="$CICD_GO_BIN" registry`; expected PASS.
- [ ] Commit:
```bash
git add apps/cicdctl/internal/artifacts/subject apps/cicdctl/internal/artifacts/sbom apps/cicdctl/internal/artifacts/provenance apps/cicdctl/internal/capabilities capabilities/ci-sbom capabilities/ci-provenance contracts/readiness-cards.yaml docs/decisions/readiness/ci-sbom-P1.yaml docs/decisions/readiness/ci-provenance-P1.yaml docs/decisions/readiness/evidence/ci-sbom-P1.bundle.json docs/decisions/readiness/evidence/ci-provenance-P1.bundle.json Makefile
git commit -m "feat(supply-chain): support static archive subjects"
```
---
### Task 7: Sign the artifact and aggregate reference-only supply-chain evidence
**Files:**
- Create: `apps/cicdctl/internal/artifacts/signing/request.go`
- Create: `apps/cicdctl/internal/artifacts/signing/verifier.go`
- Create: `apps/cicdctl/internal/artifacts/signing/signing_test.go`
- Create: `apps/cicdctl/internal/artifacts/supplychain/aggregate.go`
- Create: `apps/cicdctl/internal/artifacts/supplychain/aggregate_test.go`
- Modify: `apps/cicdctl/internal/capabilities/capabilities.go`
- Create/complete: `capabilities/ci-artifact-signing/descriptor.yaml`, `capabilities/ci-artifact-signing/policy.yaml`, `capabilities/ci-artifact-signing/acceptance.yaml`, `capabilities/ci-artifact-signing/runbook-index.yaml`
- Create/complete: `capabilities/ci-static-artifact-supply-chain/descriptor.yaml`, `capabilities/ci-static-artifact-supply-chain/policy.yaml`, `capabilities/ci-static-artifact-supply-chain/acceptance.yaml`, `capabilities/ci-static-artifact-supply-chain/runbook-index.yaml`
- Create: `docs/decisions/readiness/ci-artifact-signing-P1.yaml`
- Create: `docs/decisions/readiness/ci-static-artifact-supply-chain-P1.yaml`
- Create evidence under: `docs/decisions/readiness/evidence/ci-artifact-signing/` and `docs/decisions/readiness/evidence/ci-static-artifact-supply-chain/`
- Modify: `contracts/readiness-cards.yaml`
- Modify: `Makefile`
**Interfaces:**
```go
type SigningRequest struct { SubjectDigest, KeyID, OperationID string }
func VerifyStaticSupplyChain(
candidate contracts.CandidateBundle,
dependencyVulnerability, sbom, provenance, signature contracts.EvidenceRef,
) (contracts.SupplyChainEvidenceSet, error)
```
- [ ] Add RED tests proving the signer receives only identity data, not source/candidate bytes; reject wrong subject, expired key, missing/duplicate evidence kind, invalid signature, altered candidate, and copied/rewritten child evidence.
- [ ] Generate ephemeral Ed25519 test keys only.
- [ ] Run `"$CICD_GO_BIN" test ./apps/cicdctl/internal/artifacts/signing ./apps/cicdctl/internal/artifacts/supplychain -count=1`; expected RED.
- [ ] Implement signing on the `isolated-signer` trust partition and a composite containing immutable child references only.
- [ ] Raise both to P1/shadow; keep P2 blocked on real signer evidence.
- [ ] Re-run `"$CICD_GO_BIN" test ./apps/cicdctl/internal/artifacts/signing ./apps/cicdctl/internal/artifacts/supplychain ./apps/cicdctl/internal/capabilities -count=1` and `make GO="$CICD_GO_BIN" registry`; expected PASS.
- [ ] Commit:
```bash
git add apps/cicdctl/internal/artifacts/signing apps/cicdctl/internal/artifacts/supplychain apps/cicdctl/internal/capabilities capabilities/ci-artifact-signing capabilities/ci-static-artifact-supply-chain contracts/readiness-cards.yaml docs/decisions/readiness/ci-artifact-signing-P1.yaml docs/decisions/readiness/ci-static-artifact-supply-chain-P1.yaml docs/decisions/readiness/evidence/ci-artifact-signing docs/decisions/readiness/evidence/ci-static-artifact-supply-chain Makefile
git commit -m "feat(supply-chain): sign static candidates and aggregate evidence"
```
---
### Task 8: Publish static candidates without rebuilding
**Files:**
- Create: `apps/release-control/internal/publish/publisher.go`
- Create: `apps/release-control/internal/publish/local.go`
- Create: `apps/release-control/internal/publish/service.go`
- Create: `apps/release-control/internal/publish/publisher_test.go`
- Create: `apps/release-control/internal/publish/local_test.go`
- Modify: `apps/release-control/internal/app/app.go`
- Create/complete: `capabilities/ci-static-site-publish/descriptor.yaml`, `capabilities/ci-static-site-publish/policy.yaml`, `capabilities/ci-static-site-publish/acceptance.yaml`, `capabilities/ci-static-site-publish/runbook-index.yaml`
- Create: `docs/decisions/readiness/ci-static-site-publish-P1.yaml`
- Create evidence under: `docs/decisions/readiness/evidence/ci-static-site-publish/`
- Modify: `contracts/readiness-cards.yaml`
- Modify: `Makefile`
**Interface:**
```go
type PublishRequest struct {
OperationID, SubjectDigest, CandidateDigest, ReleaseManifestDigest string
}
type PublicationRecord struct {
OperationID, ProviderID, ImmutableURI, SubjectDigest, ServedContentDigest, State string
}
```
- [ ] Add RED tests for idempotent put, same digest already present, different digest collision, response loss after mutation, failed reconciliation, served-content mismatch, mutable URI, and any attempted build/repackage operation.
- [ ] Run `"$CICD_GO_BIN" test ./apps/release-control/internal/publish ./apps/release-control/internal/app -count=1`; expected RED.
- [ ] Implement a content-addressed local P1 publisher. Response loss returns `INDETERMINATE`; retry requires reconciliation by operation ID.
- [ ] Require provider subject and served-content digests both equal the approved candidate.
- [ ] Re-run `"$CICD_GO_BIN" test ./apps/release-control/internal/publish ./apps/release-control/internal/app -count=1` and `make GO="$CICD_GO_BIN" registry`; expected PASS.
- [ ] Raise to P1/shadow; keep real provider P2 blocked.
- [ ] Commit:
```bash
git add apps/release-control/internal/publish apps/release-control/internal/app capabilities/ci-static-site-publish contracts/readiness-cards.yaml docs/decisions/readiness/ci-static-site-publish-P1.yaml docs/decisions/readiness/evidence/ci-static-site-publish Makefile
git commit -m "feat(release-control): publish immutable static candidates"
```
---
### Task 9: Promote the exact signed static candidate
**Files:**
- Modify: `apps/release-control/internal/candidate/candidate.go`
- Modify: `apps/release-control/internal/candidate/candidate_test.go`
- Modify: `apps/release-control/internal/approval/approval.go`
- Modify: `apps/release-control/internal/approval/approval_test.go`
- Modify: `apps/release-control/internal/gitops/cas.go`
- Modify: `apps/release-control/internal/gitops/cas_test.go`
- Create: `apps/release-control/internal/promotion/service.go`
- Create: `apps/release-control/internal/promotion/service_test.go`
- Modify: `apps/release-control/internal/app/app.go`
- Modify: `capabilities/delivery-release-control/*`
- Create: `docs/decisions/readiness/delivery-release-control-P1.yaml`
- Create: `docs/decisions/readiness/evidence/delivery-release-control-P1.bundle.json`
- Modify: `contracts/readiness-cards.yaml`
- Modify: `Makefile`
**Promotion request:** signed release manifest, confirmed publication, source and artifact test-assurance references, supply-chain evidence index, approval subject/expiry, expected Git revision/tree digest, and operation ID. No source path, build command, or mutable provider URL is accepted.
```go
type PromotionRequest struct {
OperationID string
ReleaseManifest contracts.ReleaseManifestV2
Subject contracts.PromotionSubject
Publication contracts.PublicationRecord
EvidenceIndexDigest string
}
```
The compiler selects exactly one environment adapter by output profile: static archives require the static-site desired-state adapter, while container images require the existing Kubernetes/GitOps adapter. Zero or multiple environment adapters is invalid.
- [ ] Add RED tests rejecting failed/missing source or artifact assessment, changed candidate, unconfirmed publication, served-content mismatch, unsigned evidence, stale approval, stale Git base, direct mutable URL, rebuild/repackage request, and zero/multiple/wrong-kind environment adapters.
- [ ] Preserve and run existing real local Git CAS/response-loss tests.
- [ ] Implement static desired-state promotion without an unconditional Kubernetes dependency; container releases keep their environment adapter.
- [ ] Raise `delivery-release-control` only to P1/shadow using local Git and local content-addressed publication.
- [ ] Run `"$CICD_GO_BIN" test ./apps/release-control/internal/candidate ./apps/release-control/internal/approval ./apps/release-control/internal/gitops ./apps/release-control/internal/publish ./apps/release-control/internal/promotion ./apps/release-control/internal/app -count=1`; expected PASS.
- [ ] Commit:
```bash
git add apps/release-control capabilities/delivery-release-control contracts/readiness-cards.yaml docs/decisions/readiness/delivery-release-control-P1.yaml docs/decisions/readiness/evidence/delivery-release-control-P1.bundle.json Makefile
git commit -m "feat(release-control): promote verified static subjects"
```
---
### Task 10: Keep the centrally installed required workflow thin
**Files:**
- Create: `apps/cicdctl/cmd/sourcectl/main.go`
- Create: `apps/cicdctl/cmd/platform-bootstrap/main.go`
- Create: `apps/cicdctl/internal/bootstrap/catalog.go`
- Create: `apps/cicdctl/internal/bootstrap/pipeline.go`
- Create: `apps/cicdctl/internal/bootstrap/status.go`
- Create tests under: `apps/cicdctl/internal/bootstrap/`
- Modify: `.gitea/workflows/required-delivery-guard.yaml`
- Modify: `tools/contractctl/internal/workflow/required_status.go`
- Modify: `tools/contractctl/internal/workflow/required_status_test.go`
- Modify: `Makefile`
- Create: `images/platform-bootstrap/Dockerfile`
- Create: `images/platform-bootstrap/entrypoint.sh`
- Create: `images/platform-bootstrap/README.md`
- Modify: `toolchains/platform-release-catalog.yaml`
**Workflow sequence:** exact source checkout → signed catalog verification → pinned bootstrap execution → one terminal sentinel publication. All language/test/build/provider/promotion stages are compiled inside the platform plan, not written in YAML. The only required status name is exactly `platform/delivery-pipeline`.
- [ ] Add RED tests for exactly one stable required status, no language/build logic, no floating action references, digest-pinned binaries, a sentinel on every exit path, and absence of any rule requiring a product-repository workflow copy.
- [ ] Run:
```bash
"$CICD_GO_BIN" test ./tools/contractctl/internal/workflow ./apps/cicdctl/internal/bootstrap ./apps/cicdctl/internal/app -count=1
```
Expected: RED until bootstrap binaries and status finalization exist.
- [ ] Implement the four-step bootstrap and atomic sentinel finalization.
- [ ] Re-run `"$CICD_GO_BIN" test ./tools/contractctl/internal/workflow ./apps/cicdctl/internal/bootstrap ./apps/cicdctl/internal/app -count=1`; expected PASS.
- [ ] Commit:
```bash
git add apps/cicdctl/cmd apps/cicdctl/internal/bootstrap .gitea/workflows/required-delivery-guard.yaml tools/contractctl/internal/workflow images/platform-bootstrap Makefile toolchains/platform-release-catalog.yaml
git commit -m "feat(workflow): run the pinned delivery platform"
```
---
### Task 11: Prove the complete frontend vertical in shadow
**Files:**
- Create: `fixtures/frontend-delivery-vertical/` with a minimal pnpm frontend, both consumer manifests, v3 source/artifact testctl fixtures, deterministic build, adversarial reports, local signer, local static publisher, and local Git desired state
- Create: `apps/cicdctl/internal/reports/shadow_parity.go`
- Create: `apps/cicdctl/internal/reports/shadow_parity_test.go`
- Create: `contracts/schemas/shadow-parity.schema.json`
- Sync: `packages/contracts/schemas/shadow-parity.schema.json`
- Modify: `packages/contracts/schema.go`
- Modify: `packages/contracts/schema_test.go`
- Create: `contracts/snapshots/v2/shadow-parity.schema.json`
- Create: `contracts/examples/valid/shadow-parity.json`
- Create: `docs/migration/frontend-template.md`
- Create P1 evidence under: `docs/decisions/readiness/evidence/`
- Modify: `docs/decisions/blocked-tasks.md`
- Modify: `contracts/readiness-cards.yaml`
- Modify: `Makefile`
- Modify fixture registry files consumed by: `tools/fixturectl/`
- Modify: `README.md`
**End-to-end order:** source test plan/assessment → one deterministic candidate → artifact test plan/assessment → vulnerability/SBOM/provenance/signature → static supply-chain composite → content-addressed publication → Git CAS promotion → evidence-index sentinel.
- [ ] Add RED vertical tests plus faults for missing work item, changed candidate, wrong-subject provider result, missing signature, response loss, concurrent Git writer, expired approval, and missing sentinel.
- [ ] Add parity comparison over source revision; selected suites/counts/outcomes; coverage universe; HTTP scenario IDs; three browser outcomes; candidate/member digests; provider/supply-chain digests; and promotion readiness. Exclude timestamps, durations, temp paths, and runner IDs.
- [ ] Verify one-writer behavior: the platform fixture produces the only candidate; legacy probes receive read-only references and cannot publish/promote.
- [ ] Run before evidence updates:
```bash
make GO="$CICD_GO_BIN" capabilities
```
Expected: RED because the P1 chain lacks complete evidence.
- [ ] Add only locally observed P1/shadow evidence. Keep P2 blocked with named missing Gitea/runner/scanner/signer/provider prerequisites.
- [ ] Run:
```bash
make GO="$CICD_GO_BIN" contracts
make GO="$CICD_GO_BIN" registry
make GO="$CICD_GO_BIN" capabilities
make GO="$CICD_GO_BIN" boundary
make GO="$CICD_GO_BIN" verify
git diff --check
```
Expected: PASS at P1/shadow; no P2/active claim.
- [ ] Commit:
```bash
git add fixtures/frontend-delivery-vertical apps/cicdctl/internal/reports contracts packages/contracts Makefile docs README.md
git commit -m "test(vertical): prove frontend delivery in shadow"
```
## Handoff to the frontend consumer
The consumer migration may begin from the immutable Task 11 platform release. The product manifest pins its signed catalog version and selects all required capabilities. Central workflow/status installation remains an environment/platform operation. Product files never copy the workflow, provider orchestration, test normalizers, or promotion engine.
@@ -0,0 +1,133 @@
# Counter-bearing Coverage Provenance Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Align repository coverage provenance names and static classification with the counters that Vitest/V8 actually emits, without making claims about JavaScript runtime executability.
**Architecture:** The inventory parser classifies source files only by whether their top-level AST contains statements known to receive V8 counters. Coverage evaluation requires exact agreement between that static counter-bearing/counterless partition and producer rows, while policy-sensitive modules must remain counter-bearing. The JSON artifact exposes the same terminology as the inventory and diagnostics.
**Tech Stack:** TypeScript 7, Node.js 24, `@babel/eslint-parser`, Vitest 4, V8 coverage.
## Global Constraints
- Runtime declarations/initializers and direct execution statements are counter-bearing.
- Type-only modules, `import type {}`, `import {}`, bare side-effect imports, value imports, named value re-exports, and star value re-exports are counterless under the observed Vitest/V8 producer.
- Counterless does not mean non-executable; code, artifacts, diagnostics, tests, and documentation must not make that claim.
- Exact all-zero rows are accepted only for statically counterless modules.
- Critical and high-risk policy modules cannot be counterless.
- All source edits use `apply_patch` and behavior changes follow RED-GREEN TDD.
---
### Task 1: Lock the Vitest/V8 classifier contract with RED tests
**Files:**
- Modify: `tests/unit/risk-coverage.test.ts`
**Interfaces:**
- Consumes: `buildProductionModuleInventory()` and `evaluateRiskCoverage()`.
- Produces: expectations for `counterBearingModules`, `counterlessModules`, `counterBearingTotal`, `instrumentedCounterBearingTotal`, `counterlessTotal`, and `counterlessModules`.
- [x] **Step 1: Rename the test inventory helper and artifact assertions to the desired API.**
```ts
function inventory(
files: readonly string[],
generatedExclusions: readonly string[] = [],
counterlessModules: readonly string[] = [],
): ProductionModuleInventory {
return {
files,
preExclusionTotal: files.length + generatedExclusions.length,
generatedExclusions,
counterBearingModules: files.filter((file) => !counterlessModules.includes(file)),
counterlessModules,
};
}
```
- [x] **Step 2: Add a real-source inventory regression table.**
```ts
const counterlessSources = {
"import-type-empty.ts": "import type {} from './a.ts';\n",
"import-value-empty.ts": "import {} from './a.ts';\n",
"import-side-effect.ts": "import './a.ts';\n",
"import-value.ts": "import { a } from './a.ts';\n",
"reexport-named.ts": "export { a } from './a.ts';\n",
"reexport-star.ts": "export * from './a.ts';\n",
};
```
Assert every key appears in `counterlessModules`, while `export const runtimeValue = 1` and `void globalThis` appear in `counterBearingModules`.
- [x] **Step 3: Run the focused test and verify RED.**
Run: `./node_modules/.bin/vitest run tests/unit/risk-coverage.test.ts --reporter=dot`
Expected: TypeScript/test failures because the counter-bearing API fields do not exist and the current bare import classifier is executable-labelled.
### Task 2: Rename and align static coverage provenance
**Files:**
- Modify: `scripts/lib/risk-coverage.ts`
- Modify: `tests/unit/risk-coverage.test.ts`
**Interfaces:**
- Consumes: Babel `Program.body` nodes and parsed Istanbul/V8 counters.
- Produces: `hasCoverageCounterBearingStatements(source, relativePath)`, a complete `counterBearingModules`/`counterlessModules` partition, and consistently named `RiskCoverageResult` fields.
- [x] **Step 1: Implement the minimal classifier needed by the RED cases.**
`ImportDeclaration`, `ExportAllDeclaration`, and export declarations without a local declaration return `false`; `importKind === "type"` therefore remains counterless even with an empty specifier list. Runtime declarations/initializers and direct statements return `true`.
- [x] **Step 2: Rename inventory, evaluator sets, totals, diagnostics, and policy guards.**
Use these exact artifact fields: `counterBearingTotal`, `instrumentedCounterBearingTotal`, `counterlessTotal`, `counterlessModules`. Use diagnostics containing `counter-bearing`, `counterless`, and `policy-sensitive module cannot be counterless`; remove executable/non-executable terminology from the risk-coverage implementation and tests.
- [x] **Step 3: Run focused GREEN verification.**
Run: `./node_modules/.bin/vitest run tests/unit/risk-coverage.test.ts tests/unit/risk-coverage-files.test.ts tests/unit/bounded-body-reader.test.ts --reporter=dot`
Expected: all focused tests pass and both static partition directions remain fail-closed.
### Task 3: Refresh documentation, repository evidence, and removal evidence
**Files:**
- Modify: `docs/testing/frontend-platform-testing-strategy.md`
- Modify: `.superpowers/sdd/2026-08-01-quality-architecture-remediation/task-1-report.md`
- Modify: `.superpowers/sdd/2026-08-01-quality-architecture-remediation/progress.md`
**Interfaces:**
- Consumes: root and sample-removal checker output after Task 2.
- Produces: documented V8 counter-bearing semantics and current 285/285 plus 268/268 evidence.
- [x] **Step 1: Document that counterless imports/re-exports may execute at runtime but receive no file counters in the observed producer.**
- [x] **Step 2: Run relevant verification.**
```sh
./node_modules/.bin/tsc --noEmit -p tsconfig.node.json
./node_modules/.bin/tsc --noEmit -p tsconfig.test.json
./node_modules/.bin/eslint scripts/lib/risk-coverage.ts tests/unit/risk-coverage.test.ts --max-warnings=0
node scripts/check-risk-coverage.ts
corepack pnpm test:sample-removal
git diff --check
```
Expected root checker: `Risk coverage: PASS (285/285 production modules, 80 thresholds)`.
Expected removal checker: `Risk coverage: PASS (268/268 production modules, 76 thresholds)`; the already-known dependency-cruiser architecture diagnostic may remain the sole removal failure.
- [x] **Step 3: Commit the independently verified follow-up.**
```sh
git add docs/superpowers/plans/2026-08-02-counter-bearing-coverage-provenance.md docs/testing/frontend-platform-testing-strategy.md scripts/lib/risk-coverage.ts tests/unit/risk-coverage.test.ts
git commit -m "refactor: align coverage counter provenance"
```
## Self-review
- Spec coverage: terminology, import/re-export edge cases, policy diagnostics, artifact fields, root/removal evidence, report, and ledger are each assigned above.
- Placeholder scan: no deferred implementation or unspecified test step remains.
- Type consistency: inventory and result names use `counterBearing*`/`counterless*` throughout; the classifier is `hasCoverageCounterBearingStatements`.
@@ -0,0 +1,380 @@
# Frontend Thin Platform Consumer Migration Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Use `superpowers:using-git-worktrees` before editing.
**Goal:** Convert `clean-architecture-frontend-template` into a thin consumer of `test-assurance-platform` and `cicd-platform` while preserving product source, tests, fixtures, raw product artifact codecs, and all origin-unknown worktree changes.
**Architecture:** Product manifests declare risks, obligations, suites, components, outputs, and platform capabilities. Product scripts execute one bounded product purpose and emit raw artifacts. Test assurance owns selection through assessment; CI/CD owns workflow through promotion. Shadow mode has one candidate writer—the platform—and legacy release/promotion code is read-only until removed. The central required workflow is installed by the platform and is never copied into this repository.
**Tech Stack:** TypeScript 7.0.2, Node.js 24.14.0, pnpm 11.17.0, Vitest 4.1.10, Playwright 1.62.0, YAML/JSON Schema, `testctl` v3, `cicdctl`.
## Global Constraints
- Repository: `/home/donghyeon/workspace/desktop-server-git/clean-architecture-frontend-template`; every command runs from its isolated migration worktree root unless it is explicitly marked read-only against the original dirty worktree.
- Begin only from a clean worktree whose HEAD contains the approved spec amendment and all three 2026-08-02 plans in one immutable planning commit; never modify or clean the original dirty worktree.
- Consume the immutable Test Assurance Task 10 release and CI/CD Task 11 P1/shadow release from the two companion plans.
- Resolve `CICDCTL_BIN` and `TESTCTL_BIN` to absolute executable paths from the signed CICD catalog, and resolve `CICDCTL_DIGEST` and `TESTCTL_DIGEST` from the same catalog. Before every plan/manifest command, `sha256sum` must equal the signed value; PATH fallback is forbidden.
- Do not claim P2/active or delete the legacy path until actual Gitea/runner/scanner/signer/provider evidence and required-status installation are observed.
- Use only the exact capability IDs approved in the design.
- Keep production code, product tests/assertions, mocks, scenarios, fixtures, Vitest/Playwright config, V8 instrumentation inputs, and runtime/release artifact codecs.
- Remove local workflow compilation, risk selection, waiver/normalization/assessment, test scheduling, provider invocation, signing, retention, candidate publication, and promotion engines after cutover.
- `check:types` remains a CI Node responsibility. `check:architecture` alone maps to `architecture-typescript`.
- Artifact browser suites consume the platform candidate; their Playwright `webServer` must never rebuild it.
- The product does not add `.gitea/workflows/required-delivery-guard.yaml` or any equivalent copied central workflow.
- Rollback changes only the signed platform catalog/version pin and promotes a previous immutable subject through `release-control`; it never re-enables a legacy writer.
---
### Task 1: Preserve and classify the existing dirty Task 3 work
**Files:**
- Create in the clean migration worktree: `docs/migration/task3-wip-provenance.json`
- Create: `docs/migration/task3-wip-disposition.md`
- Read only: original dirty worktree tracked diff and untracked files
**Ledger contract:** Each entry records `path`, `workingBlobSha256`, and an ordered `hunks` array. Every hunk records `oldRange`, `newRange`, `diffSha256`, `owner` (`product`, `test-assurance`, `cicd`, `unrelated-or-unknown`), `provenance`, and `disposition` (`preserve-in-original`, `reimplement-in-platform`, `retain-product-codec`, `remove-after-cutover`). Mixed-responsibility files therefore have multiple hunk owners. The ledger does not copy source content or create a patch archive.
- [ ] Verify the migration worktree entry commit before inspecting WIP:
```bash
git show HEAD:docs/superpowers/specs/2026-08-02-platform-owned-frontend-assurance-delivery-design.md
git show HEAD:docs/superpowers/plans/2026-08-02-frontend-platform-consumer-migration.md
git show HEAD:docs/superpowers/plans/2026-08-02-test-assurance-frontend-capabilities.md
git show HEAD:docs/superpowers/plans/2026-08-02-cicd-frontend-assurance-delivery.md
git status --short
```
Expected: all four documents exist and migration-worktree status is empty.
- [ ] Capture `git status --short`, `git diff --name-status`, `git diff --numstat`, `git diff | sha256sum`, per-hunk unified diffs, and `git hash-object` for each present untracked/modified file without writing to the original worktree.
- [ ] Add a failing migration test in `tests/unit/platform-migration-provenance.test.ts` that requires every hunk to have one owner/provenance/disposition, permits mixed owners per file, and rejects a blanket `frontend`/`keep-all` classification.
- [ ] Run:
```bash
corepack pnpm exec vitest run tests/unit/platform-migration-provenance.test.ts
```
Expected: RED because the ledger does not exist.
- [ ] Create the ledger and disposition document with `apply_patch`. Explicitly classify workflow/gate/test-normalization/provider/promotion WIP to its platform owner and origin-unknown files as preserved in the original worktree.
- [ ] Recompute every original working-blob and hunk digest, re-run the test, and compare original status plus digests with the capture; expected PASS with no drift.
- [ ] Commit only the ledger, disposition, and test:
```bash
git add docs/migration/task3-wip-provenance.json docs/migration/task3-wip-disposition.md tests/unit/platform-migration-provenance.test.ts
git commit -m "docs: preserve Task 3 migration provenance"
```
---
### Task 2: Declare the CI/CD platform consumer
**Files:**
- Create: `delivery-platform.yaml`
- Create: `tests/unit/delivery-platform-manifest.test.ts`
**Selected capabilities:**
```text
ci-standard-core
ci-node-typescript
ci-frontend
ci-test-assurance
ci-dependency-vulnerability
ci-sbom
ci-provenance
ci-artifact-signing
ci-static-artifact-supply-chain
ci-static-site-publish
delivery-release-control
```
The manifest declares one frontend component rooted at `.`, one static-site output rooted at `dist`, pnpm frozen install, `lint`, `check:types`, `build`, the size/environment/determinism policy, and a signed `platformVersion` that exists in the released CI/CD catalog. It does not contain a workflow DAG, provider command, test command, or mutable tool image.
- [ ] Add RED tests that invoke `$CICDCTL_BIN manifest compile` after digest verification and reject missing capabilities, a floating platform version, product-owned test stages, arbitrary shell/provider commands, or a second candidate output.
- [ ] Run:
```bash
corepack pnpm exec vitest run tests/unit/delivery-platform-manifest.test.ts
```
Expected: RED because `delivery-platform.yaml` is absent.
- [ ] Add the manifest only; do not add a package script that could resolve an arbitrary PATH binary or local compilation logic.
- [ ] Compile the manifest with `"$CICDCTL_BIN" manifest compile --manifest delivery-platform.yaml --source-revision "$PLATFORM_SOURCE_REVISION" --output artifacts/platform/effective-project.json`; expected PASS at shadow activation.
- [ ] Commit:
```bash
git add delivery-platform.yaml tests/unit/delivery-platform-manifest.test.ts
git commit -m "chore(platform): declare frontend delivery capabilities"
```
---
### Task 3: Declare risks, obligations, change surfaces, and source suites
**Files:**
- Create: `test-assurance.yaml`
- Create generated: `test-assurance.lock.json`
- Create: `config/test-assurance/risks/frontend.json`
- Create: `config/test-assurance/obligations/frontend.json`
- Create: `config/test-assurance/suites/source.json`
- Create: `config/test-assurance/suites/artifact-templates.json`
- Create: `config/test-assurance/change-surfaces/frontend.json`
- Create: `config/test-assurance/legacy-command-disposition.json`
- Create: `scripts/run-contract-negative-fixtures.ts`
- Create: `scripts/reporters/vitest-discovery-reporter.ts`
- Create: `scripts/write-production-module-inventory.ts`
- Modify: `scripts/check-architecture.ts`
- Modify: `vitest.config.ts`
- Create: `tests/unit/test-assurance-source-manifest.test.ts`
- Create: `tests/unit/raw-source-reporters.test.ts`
- Create: `tests/unit/legacy-command-migration-completeness.test.ts`
- Modify: `package.json`
**Source suite mapping:**
| Suite ID | Product argv | Capability |
|---|---|---|
| `runtime-schema` | `corepack pnpm test:runtime-schema` | `unit-typescript-vitest` |
| `unit` | `corepack pnpm test:unit` | `unit-typescript-vitest` |
| `reference-feature` | `corepack pnpm test:reference-feature` | `unit-typescript-vitest` |
| `optional-recipes` | `corepack pnpm test:recipes` | `unit-typescript-vitest` |
| `component` | `corepack pnpm test:component` | `component-react-vitest` |
| `integration` | `corepack pnpm test:integration` | `integration-http-msw` |
| `http-scenarios` | `corepack pnpm test:http-scenario-evidence` | `integration-http-msw` |
| `architecture` | `corepack pnpm check:architecture` | `architecture-typescript` |
| `coverage` | `corepack pnpm test:coverage` | `coverage-v8` |
| `contract-negative-fixtures` | `corepack pnpm test:contract-negative-fixtures` | `unit-typescript-vitest` |
Every source suite declares `executionPhase: SOURCE`, an empty `requiredInputArtifacts`, a bounded argv array, timeouts, environment allowlist, and exact raw artifacts. `check:types` is absent because CI owns it. `test-assurance.yaml` references `artifact-templates.json` through `artifactSuiteTemplateFiles`; those templates are completed in Task 4.
- [ ] Add RED tests for exact capability IDs, one purpose per suite, no aggregate `test:all`, no `check:types`, no shell string, no `--passWithNoTests` on required suites, exact report paths, and source phase with zero input artifacts.
- [ ] Parse every unique command and argument tuple in legacy `config/ci/gates.json` and require exactly one disposition: `ci-node`, `test-assurance-suite`, `cicd-release-or-security`, `product-dev-only`, or `retired-with-platform-evidence`. Missing or duplicate classification fails.
- [ ] Add `test:contract-negative-fixtures` as one bounded product harness: it runs every expected-fail type/coverage/design-system/i18n/diagnostics/registry/route fixture, asserts the exact expected exit code and diagnostic identity from the disposition file, and exits 0 only when all negative contracts fail for the intended reason.
- [ ] Classify `test:browser-capabilities` into the three artifact browser suites, `test:storybook` as a retained source test harness until an explicit platform disposition is proven, and `playwright.dev.config.ts` as `product-dev-only`; no legacy gate command disappears without platform evidence.
- [ ] Add RED reporter tests requiring Vitest discovery IDs/counts alongside JUnit, a canonical production-module inventory alongside V8 coverage, an architecture graph/violations report, and typed HTTP receipts. These are raw observations only and contain no PASS/waiver/threshold decision.
- [ ] Run the focused test; expected RED.
- [ ] Add declarations. Remove `--passWithNoTests` from required source suite scripts while retaining optional behavior only for suites whose obligation explicitly permits no applicable tests. Wire the Vitest reporter into each required Vitest suite, make `check:architecture` emit the typed graph/violation JSON, and rewrite `test:coverage` to emit V8 summary plus production inventory without calling the local risk/threshold assessor.
- [ ] Use released `testctl validate`, `lock`, and `compile` to generate the lock; never type toolchain digests by hand.
- [ ] Re-run the focused test and:
```bash
"$TESTCTL_BIN" validate --repository . --out artifacts/platform/source-validation.json
"$TESTCTL_BIN" lock --repository . --out test-assurance.lock.json
"$TESTCTL_BIN" compile --repository . --lock test-assurance.lock.json --out artifacts/platform/compiled-policy.json
```
Expected: PASS with v3 and exact Node/pnpm/TypeScript/Vitest/Playwright pins.
- [ ] Commit:
```bash
git add test-assurance.yaml test-assurance.lock.json config/test-assurance scripts/reporters/vitest-discovery-reporter.ts scripts/write-production-module-inventory.ts scripts/check-architecture.ts scripts/run-contract-negative-fixtures.ts vitest.config.ts tests/unit/test-assurance-source-manifest.test.ts tests/unit/raw-source-reporters.test.ts tests/unit/legacy-command-migration-completeness.test.ts package.json
git commit -m "chore(test-assurance): declare frontend source suites"
```
---
### Task 4: Make browser suites consume the immutable candidate
**Files:**
- Modify: `playwright.config.ts`
- Modify: `playwright.capabilities.config.ts`
- Modify: `playwright.visual.config.ts`
- Modify: `playwright.storybook.config.ts` only if it remains a required artifact suite
- Create: `scripts/serve-test-candidate.ts`
- Create: `scripts/reporters/playwright-evidence-reporter.ts`
- Create: `tests/support/browser/mutation-evidence.ts`
- Modify: `tests/e2e/reference-form.spec.ts`
- Modify: `package.json`
- Modify: `config/test-assurance/suites/artifact-templates.json`
- Modify: `config/test-assurance/obligations/frontend.json`
- Create: `tests/unit/artifact-suite-contract.test.ts`
**Artifact suite mapping:**
| Suite ID | Product argv | Capability |
|---|---|---|
| `e2e-chromium` | `corepack pnpm test:e2e:chromium` | `e2e-playwright-chromium` |
| `e2e-firefox` | `corepack pnpm test:e2e:firefox` | `e2e-playwright-firefox` |
| `e2e-webkit` | `corepack pnpm test:e2e:webkit` | `e2e-playwright-webkit` |
| `accessibility` | `corepack pnpm test:a11y` | `accessibility-web` |
| `visual-regression` | `corepack pnpm test:visual` | `visual-regression-web` |
All five repository templates declare `executionPhase: ARTIFACT` and one input declaration containing `artifactId: frontend-site` and `mediaType: application/vnd.delivery.static-site.v1+tar`. Templates contain no `sha256` field. After the platform builds the candidate, CI creates an artifact `ExecutionRequest` containing the actual candidate SHA-256; testctl materializes executable v3 `SuiteDefinition` and `WorkItem` documents with that exact digest.
- [ ] Add RED tests requiring separate Chromium/Firefox/WebKit commands and artifacts, rejecting a SHA/digest placeholder in committed templates, rejecting build commands in Playwright `webServer`, requiring candidate-root environment input, and checking typed Playwright JSON/JUnit, trace/screenshot/console/network indexes, browser provider identity, write mutation receipts, accessibility/manual-review identity, and visual baseline/diff identity.
- [ ] Run the focused test; expected RED because browser configuration currently rebuilds the site.
- [ ] Implement `serve-test-candidate.ts` as a bounded read-only static server over the executor-verified candidate directory. It reads the candidate root from the allowlisted environment and never verifies or substitutes the platform digest itself.
- [ ] Change `playwright.config.ts` and `playwright.capabilities.config.ts` to call that server and add per-browser package scripts using `--project`; each browser suite includes both `tests/e2e/` and `tests/browser-capabilities/`. Add the product-owned Playwright reporter and mutation-evidence helper so tests emit traces, screenshots, console/network indexes, response/mutation/reload receipts, accessibility findings/manual records, and visual baselines/diffs without assessing them. Extend `reference-form.spec.ts` with the existing production-shaped create handler: observe the successful HTTP response, read the created resource, reload, read it again, and write one typed receipt keyed by the test/scenario ID.
- [ ] Validate/lock/compile with testctl and run each product suite against a local candidate materialized by the platform fixture.
- [ ] Commit:
```bash
git add playwright.config.ts playwright.capabilities.config.ts playwright.visual.config.ts playwright.storybook.config.ts scripts/serve-test-candidate.ts scripts/reporters/playwright-evidence-reporter.ts tests/support/browser/mutation-evidence.ts tests/e2e/reference-form.spec.ts package.json config/test-assurance tests/unit/artifact-suite-contract.test.ts
git commit -m "refactor(browser): test the immutable platform candidate"
```
---
### Task 5: Separate raw reporters and freeze legacy assurance as read-only
**Files:**
- Retain/refactor: `scripts/run-http-scenario-evidence.ts`
- Retain/refactor: `scripts/lib/http-scenario-evidence.ts`
- Retain/refactor: `scripts/write-a11y-report.ts`
- Create: `scripts/lib/manual-a11y-record.ts`
- Retain: product V8 instrumentation and module-inventory code in `vite.config.ts`, `vitest.config.ts`, and product codecs
- Retain read-only until Task 8: `scripts/check-test-evidence.ts`, `scripts/verify-browser-capability-evidence.ts`, `scripts/check-risk-coverage.ts`, `scripts/lib/risk-coverage.ts`, `scripts/lib/local-policy-evidence.ts`, `scripts/lib/manual-a11y-evidence.ts`, `scripts/verify-a11y-manual.ts`
- Create: `scripts/run-legacy-assurance-probe.ts`
- Modify: `package.json`
- Create: `tests/unit/raw-product-evidence-contract.test.ts`
- Create: `tests/unit/legacy-assurance-readonly.test.ts`
**Boundary:** Product emitters may validate their own artifact schema and cross-fields, but the new manifests may not invoke local obligation satisfaction, waiver, quarantine, retry/flaky, coverage threshold, scenario completeness, browser matrix completeness, or evidence freshness logic. Legacy assessors remain callable only through `run-legacy-assurance-probe.ts`, which writes comparison output to a dedicated read-only shadow namespace and has no candidate/provider/promotion operation.
- [ ] Add RED boundary tests proving raw emitters contain no verdict semantics, new manifests never invoke a legacy assessor, and every legacy assessor is reachable only from the comparison probe.
- [ ] Run `corepack pnpm exec vitest run tests/unit/raw-product-evidence-contract.test.ts tests/unit/legacy-assurance-readonly.test.ts`; expected RED before the boundary is enforced.
- [ ] Move any reusable false-green fixture to the Test Assurance platform implementation commit; in this repository keep only the product input fixture needed to reproduce the report.
- [ ] Extract production-module inventory generation from `check-risk-coverage.ts` and raw manual-review codecs from `manual-a11y-evidence.ts`; keep threshold/completeness logic unchanged solely for the read-only probe until parity.
- [ ] Update suite declarations to point directly at raw artifacts and ensure no automated workflow invokes the legacy probe.
- [ ] Re-run `corepack pnpm exec vitest run tests/unit/raw-product-evidence-contract.test.ts tests/unit/legacy-assurance-readonly.test.ts tests/unit/test-assurance-source-manifest.test.ts tests/unit/artifact-suite-contract.test.ts`; expected PASS.
- [ ] Commit:
```bash
git add scripts/run-http-scenario-evidence.ts scripts/lib/http-scenario-evidence.ts scripts/write-a11y-report.ts scripts/lib/manual-a11y-record.ts scripts/write-production-module-inventory.ts scripts/run-legacy-assurance-probe.ts package.json tests/unit/raw-product-evidence-contract.test.ts tests/unit/legacy-assurance-readonly.test.ts config/test-assurance
git commit -m "refactor(testing): isolate raw and legacy assurance paths"
```
---
### Task 6: Run one-writer shadow parity
**Files:**
- Create: `docs/operations/platform-shadow-parity.md`
- Create: `docs/operations/evidence/platform-shadow-readiness.json` only from an actual platform run
- Create: `tests/unit/platform-shadow-contract.test.ts`
- Modify: `delivery-platform.yaml` and `test-assurance.lock.json` only to pin the released shadow versions
**Parity identity:** source revision, delivery manifest/catalog digests, test manifest/lock/policy digests, selected suite IDs, discovered/executed counts, terminal classifications, coverage production-module universe, HTTP declared/executed IDs, three browser results, candidate/member digests, source and artifact plan/evidence/assessment digests, supply-chain/provider digests, and promotion readiness. Exclude timestamp, duration, temp path, and runner identity.
- [ ] Add RED tests proving there is no product-local central workflow, the platform is the only command allowed to build/freeze a candidate in shadow, legacy release/promotion commands are read-only probes, and no product script invokes a provider or mutates desired state.
- [ ] Before shadow execution, query actual Gitea registration/status state and require: legacy `quality-gates.yml` registration `disabled`; legacy required status `detached`; central workflow installed externally with activation `shadow`; no product `required-delivery-guard.yaml`; legacy candidate/provider/promotion invocation count `0`; platform candidate writer count exactly `1`. Record actual workflow/status IDs and writer identity.
- [ ] Run the released platform fixture against this exact source revision for a passing run and deliberate failures: zero discovery, missing report, retry-only pass, missing browser, changed candidate, wrong provider digest, and response loss.
- [ ] Have the platform runner invoke `run-legacy-assurance-probe.ts` read-only and compare its test classifications with platform assessment. Do not register/run the legacy workflow and do not run legacy candidate creation, publication, or promotion.
- [ ] Record the actual signed parity report digest and environment identities only after the run exists. If the required external environment is unavailable, leave this task incomplete and retain shadow activation.
- [ ] Re-run focused contract tests; expected PASS for repository constraints even if P2 evidence remains blocked.
- [ ] Commit the shadow contract and runbook before external evidence:
```bash
git add docs/operations/platform-shadow-parity.md tests/unit/platform-shadow-contract.test.ts delivery-platform.yaml test-assurance.lock.json
git commit -m "test(shadow): define parity and one-writer contract"
```
- [ ] Only after the real run creates `docs/operations/evidence/platform-shadow-readiness.json`, verify its digest/signature and commit that file alone as `test(shadow): record platform parity evidence`. If the environment is unavailable, do not create or stage the file and leave Task 6 incomplete.
---
### Task 7: Activate the platform and prove platform-only rollback
**Files:**
- Modify: `delivery-platform.yaml` only to select the signed active catalog version
- Modify: `test-assurance.lock.json` only through the verified testctl binary
- Modify: `docs/operations/release-cache-rollback.md`
- Modify: `docs/operations/platform-shadow-parity.md`
- Create: `tests/unit/platform-rollback-contract.test.ts`
- Create from a real drill only: `docs/operations/evidence/platform-cutover-rollback.json`
**Cutover gate:** all ten frontend test capabilities are R1 or higher; every selected CI/CD capability is P1/shadow or higher; source revision and manifest/catalog/lock digests match; the passing run and every named fault fixture have zero parity mismatch; legacy writer count is zero and platform writer count is one; named P2 Gitea/runner/scanner/signer/provider evidence exists; central status `platform/delivery-pipeline` is installed; the previous signed platform pin rollback drill verifies served-content digest; and the WIP ledger has zero unclassified hunks.
**Rollback sequence:** pause new promotions → reconcile every indeterminate operation ID → pin the previous signed platform catalog/version → verify and use the previous `CICDCTL_BIN`/`TESTCTL_BIN` digests → compile both manifests → promote the previous stable immutable subject through `release-control` → verify served-content digest → resume. No product workflow, legacy writer, rebuild, repackage, or mutable tag is permitted.
- [ ] Add RED tests rejecting rollback text/code that restores `quality-gates.yml`, invokes `ci:gate`, enables a legacy writer, rebuilds a candidate, or omits signed catalog, executable, and subject digest checks.
- [ ] Activate the externally installed central workflow/status only after every cutover-gate predicate is machine-verified; do not delete repository files in this task.
- [ ] Run a staging rollback to the previous signed platform pin and previous immutable subject, then roll forward again. Record operation IDs, catalog/executable/release/served-content digests, Gitea status ID, writer identity, and reconciliation outcome.
- [ ] Run `corepack pnpm exec vitest run tests/unit/platform-shadow-contract.test.ts tests/unit/platform-rollback-contract.test.ts`; expected PASS.
- [ ] Commit runbook/test first. Commit `platform-cutover-rollback.json` separately only after a real signed drill exists; otherwise leave Task 7 incomplete.
---
### Task 8: Remove local assurance and delivery engines after rollback evidence
**Entry gate:** Task 7 has a signed evidence digest and served-content equality; removing local engines is forbidden before it.
**Files:**
- Delete: `.gitea/workflows/quality-gates.yml`
- Delete: `config/ci/gates.json`
- Delete: `scripts/generate-ci-workflow.ts`, `scripts/check-ci-contract.ts`, `scripts/run-ci-gate.ts`, `scripts/contracts/ci-gates.ts`
- Delete: `scripts/lib/ci-contract-report.ts`, `scripts/lib/ci-gate-log.ts`, `scripts/lib/ci-step-result.ts`, `scripts/lib/ci-artifact-validator.ts`, `scripts/lib/ci-candidate-archive-cli.ts`, `scripts/lib/ci-candidate-archive.ts`, `scripts/lib/package-script-graph.ts`
- Delete: `scripts/check-test-evidence.ts`, `scripts/verify-browser-capability-evidence.ts`, `scripts/check-risk-coverage.ts`, `scripts/lib/risk-coverage.ts`, `scripts/lib/local-policy-evidence.ts`, `scripts/verify-a11y-manual.ts`, `scripts/run-legacy-assurance-probe.ts`
- Delete after raw codec extraction: `scripts/lib/manual-a11y-evidence.ts`
- Delete: `scripts/create-release-candidate.ts`, `scripts/verify-reproducible-build.ts`, `scripts/verify-ci-candidate-archive.ts`, `scripts/verify-release-candidate.ts`, `scripts/run-and-validate-provider.ts`, `scripts/stage-verified-promotion.ts`, `scripts/verify-provider-evidence.ts`, `scripts/verify-supply-chain-promotion.ts`, `scripts/lib/release-candidate.ts`, `scripts/lib/promotion-stager.ts`, `scripts/lib/promotion-verifier.ts`, `scripts/lib/provider-evidence.ts`, `scripts/lib/provider-upload-validator.ts`
- Delete: `scripts/security-scan.ts`, `scripts/generate-supply-chain.ts`, `scripts/verify-supply-chain-artifacts.ts`, `scripts/verify-archived-local-evidence.ts`, `scripts/check-supply-chain-provider-fixtures.ts`, `scripts/lib/local-release-evidence.ts`, `scripts/lib/release-input-evidence.ts`, `scripts/lib/supply-chain.ts`
- Delete: `tests/unit/ci-workflow-generation.test.ts`, `tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap`, `tests/unit/ci-step-result.test.ts`, and `tests/fixtures/ci-contract/`
- Preserve/refactor: `scripts/contracts/release-artifacts.ts`, `scripts/generate-build-manifest.ts`, `scripts/generate-artifact-schemas.ts`, `scripts/lib/build-manifest-outputs.ts`, `scripts/lib/release-runtime-coherence.ts`, `scripts/lib/manual-a11y-record.ts`, and their product-schema tests
- Modify: `package.json`
- Modify: `docs/operations/ci-quality-gates.md`
- Modify: `docs/security/supply-chain.md`
- Create: `tests/unit/platform-engine-removal.test.ts`
- [ ] Recompute original dirty-worktree blob/hunk digests and stop on drift. Compare the current migration diff to the exact allowed path/hunk set in the ledger before deleting anything.
- [ ] Add a RED removal test that rejects workflow-DAG types, Gitea expressions, artifact upload/download orchestration, test normalizers/assessors, provider/promotion mutation, exact job counts, `ci:gate`, and workflow generator/checker scripts in product paths.
- [ ] Extract the raw manual accessibility record codec to `scripts/lib/manual-a11y-record.ts`, then delete only the assessment/expiry-completeness engine. Delete all other listed platform-owned files hunk-by-hunk; preserve product behavior, assertions, fixtures, and raw artifact codecs.
- [ ] Remove `generate:ci-workflow`, `check:ci-workflow`, `ci:gate`, local `check:ci`, legacy assessor, provider/promotion, supply-chain, and candidate orchestration scripts from `package.json`. Keep build, lint/typecheck, one-purpose suites, raw reporters, and developer-only commands.
- [ ] Run `corepack pnpm exec vitest run tests/unit/platform-engine-removal.test.ts tests/unit/delivery-platform-manifest.test.ts tests/unit/test-assurance-source-manifest.test.ts tests/unit/artifact-suite-contract.test.ts`, then `corepack pnpm check:types` and `corepack pnpm lint`; expected PASS.
- [ ] Verify `git diff --name-status` exactly matches the ledger's Task 8 allowlist. Stage only the explicit files listed in this task; never use `git add scripts`, `git add tests`, or `git add -A`.
- [ ] Commit as `refactor(platform): remove copied assurance and delivery engines`.
---
### Task 9: Finalize documentation, provenance, and consumer verification
**Files:**
- Modify: `docs/operations/release-cache-rollback.md`
- Modify: `docs/operations/platform-shadow-parity.md`
- Modify: `docs/security/supply-chain.md`
- Modify: `README.md`
- Modify and close: `docs/migration/task3-wip-provenance.json`
- Modify: `docs/migration/task3-wip-disposition.md`
- [ ] Mark every WIP hunk `retained`, `reimplemented-in-platform`, `removed-after-cutover`, or `preserved-in-original`; require zero open disposition and reverify original dirty-worktree hashes.
- [ ] Verify both binaries before invoking them:
```bash
printf '%s %s\n' "$CICDCTL_DIGEST" "$CICDCTL_BIN" | sha256sum --check
printf '%s %s\n' "$TESTCTL_DIGEST" "$TESTCTL_BIN" | sha256sum --check
```
- [ ] Run final repository verification:
```bash
corepack pnpm install --frozen-lockfile
corepack pnpm check:types
corepack pnpm lint
corepack pnpm test:all
corepack pnpm exec vitest run tests/unit/delivery-platform-manifest.test.ts tests/unit/test-assurance-source-manifest.test.ts tests/unit/artifact-suite-contract.test.ts tests/unit/platform-engine-removal.test.ts tests/unit/platform-rollback-contract.test.ts
"$TESTCTL_BIN" validate --repository . --out artifacts/platform/final-validation.json
"$CICDCTL_BIN" manifest compile --manifest delivery-platform.yaml --source-revision "$PLATFORM_SOURCE_REVISION" --output artifacts/platform/effective-project.json
git diff --check
```
Expected: PASS. `PLATFORM_SOURCE_REVISION` is the exact 40-hex revision recorded by the platform run, not a branch or mutable lookup. Product tree contains manifests and product tests, not copied platform engines.
- [ ] Commit the two migration ledger files and exact modified docs/README as `docs(platform): finalize consumer cutover`.
## Cross-repository execution order
1. Complete and release the Test Assurance plan through R1/v3 conformance.
2. Complete and release the CI/CD plan through P1/shadow vertical conformance.
3. Execute frontend Tasks 15 and validate both manifests locally.
4. Execute Task 6 only with external one-writer state verified.
5. Execute Task 7 only after every named P2 gate exists and prove rollback before deletion.
6. Execute Tasks 89 only after signed rollback evidence. Until then, the correct state is shadow with legacy comparison code retained and no false active claim.
@@ -0,0 +1,598 @@
# Promotion Security Review Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the captured candidate archive and a strict exact-five bundle validator the only promotion authority while closing evidence, lifecycle, staging, output, freshness, and workflow gaps found by security review.
**Architecture:** Candidate identity and local verification are derived exclusively from the inode-captured tar stream. The finalizer revalidates archived subordinate evidence, provider signatures, freshness, role-separated trust, and then creates and validates an exact-five bundle before returning descriptor- and inode-bound cleanup metadata. Provider execution, CLI output publication, and workflow gating expose small injectable boundaries so failure and cleanup behavior can be tested directly.
**Tech Stack:** Node.js 24, TypeScript, Zod, Vitest, GNU tar, bubblewrap, Gitea Actions workflow generation.
## Global Constraints
- Work sequentially on the current clean `develop` HEAD and produce one review-fix commit.
- Every production change follows focused RED, observed expected failure, minimal GREEN, and regression verification.
- Candidate verification performs no checkout reads; test subprocesses from outside the checkout with contradictory canaries.
- Preserve real GNU tar and bubblewrap coverage; do not claim native uploader or atomic `renameat2`/`unlinkat` semantics.
- Gitea 1.26.4 and act_runner 1.0.0 exact-five upload/download/cancel behavior remains an explicitly documented external smoke boundary.
---
### Task 1: Canonical Captured Archive and Archived Local Authority
**Files:**
- Modify: `scripts/lib/release-candidate.ts`
- Modify: `scripts/lib/local-release-evidence.ts`
- Modify: `scripts/lib/ci-candidate-archive.ts`
- Delete: `scripts/lib/promotion-verifier.ts`
- Delete: `scripts/verify-provider-evidence.ts`
- Delete: `scripts/verify-supply-chain-promotion.ts`
- Modify: `tests/unit/security-followup.test.ts`
- Modify: `tests/unit/supply-chain.test.ts`
- Modify: `tests/integration/security-followup-archive.test.ts`
**Interfaces:**
- Consumes: captured tar bytes plus expected SHA-256.
- Produces: `withVerifiedCapturedCandidate()` callback data derived only from the extracted, exact-member, digest-verified tar; `verifyArchivedLocalEvidence()` independently recomputes all feasible archived checks.
- [x] Add failing tests for invalid tar, archive/tree mismatch, contradictory archived subordinate FAIL, exact archived policy bytes, and checkout-independent execution.
- [x] Run focused tests and record the expected RED diagnostics in the durable task report.
- [x] Archive the exact policy/verifier inputs required for independent release, supply-chain, dependency, license, vulnerability, and secret-scan checks.
- [x] Re-run producer checks against the extracted archive and require their result to agree with the assessment and member identities.
- [x] Remove the obsolete standalone PASS issuers and route all fixture checking through real captured tar/finalizer validation.
- [ ] Run focused archive, supply-chain, and integration tests to GREEN.
### Task 2: Exact-Five Validator and Role-Separated Trust
**Files:**
- Create: `scripts/lib/exact-promotion-bundle.ts`
- Create: `scripts/verify-exact-promotion-bundle.ts`
- Modify: `scripts/lib/provider-evidence.ts`
- Modify: `scripts/lib/promotion-stager.ts`
- Modify: `scripts/contracts/promotion-artifacts.ts`
- Modify: `tests/unit/security-followup.test.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Consumes: exactly five captured byte buffers and two trusted Ed25519 identities.
- Produces: `verifyExactPromotionBundle()` that requires literal verifier identity/version, provider and subordinate PASS states, exact hashes, and equal run/source/candidate/provider/trust fields.
- [x] Add failing tests for provider FAIL/absence, arbitrary provider hash, swapped roles, shared-field mismatch, archive/report digest mismatch, and identical role keys.
- [x] Run focused tests and record RED.
- [x] Implement strict exact-five parsing/cross-record validation and expose a downstream CLI command.
- [x] Reject equal DER-SPKI fingerprints and equal role key identity before evaluation/finalization.
- [x] Invoke exact-five validation inside the finalizer before publication; the full real-build fixture rerun remains sandbox-blocked below.
### Task 3: Provider Lifecycle and Freshness
**Files:**
- Modify: `scripts/lib/provider-supervisor.ts`
- Create: `scripts/lib/provider-process-runner.ts`
- Modify: `scripts/run-and-validate-provider.ts`
- Modify: `tests/unit/security-followup.test.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Consumes: a spawned bubblewrap child, injected timeout/clock, captured report.
- Produces: a runner that SIGKILLs on timeout but rejects only after `close`, and supervision that samples freshness after provider/report capture.
- [x] Add failing stubborn-descendant/short-timeout and sequence-clock expiry tests.
- [x] Run focused tests and record RED.
- [x] Extract the process runner, wait for close after timeout, and preserve the timeout diagnostic.
- [x] Issue provider timestamps immediately before execution, validate with a fresh clock after capture, and reject crossing expiry.
- [x] Run focused lifecycle tests, including real stubborn descendants, to GREEN; the shared real-build/bubblewrap fixture remains sandbox-blocked below.
### Task 4: Inode-Pinned Staging and Output-Failure Cleanup
**Files:**
- Modify: `scripts/lib/promotion-stager.ts`
- Create: `scripts/lib/stage-verified-promotion-cli.ts`
- Modify: `scripts/stage-verified-promotion.ts`
- Modify: `scripts/cleanup-verified-promotion.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Produces: `FinalizedPromotion.stagingIdentity` and a testable CLI function whose append failure invokes cleanup from the in-memory result.
- [x] Add failing tests for leaf replacement during writes, final visibility mismatch, partial-failure cleanup, GITHUB_OUTPUT open/write failure, and expiry during staging.
- [x] Run focused tests and record RED.
- [x] Open the created leaf with `O_DIRECTORY|O_NOFOLLOW`, write through `/proc/self/fd/<leafFd>`, pin dev/ino, require visible identity equality, and propagate identity through cleanup.
- [x] Force directory/file modes with `fchmod(0700/0400)` independent of a restrictive owner-preserving umask.
- [x] Extract CLI dependencies; on any post-finalization output failure call direct cleanup before rethrowing.
- [x] Revalidate evidence freshness before sealing and immediately before publication; isolated lifecycle/mode/output tests are GREEN and the shared real-build fixture remains sandbox-blocked below.
### Task 5: Workflow and Install Policy
**Files:**
- Modify: `package.json`
- Modify: `scripts/check-ci-contract.ts`
- Modify: `scripts/contracts/ci-gates.ts`
- Modify: `scripts/generate-ci-workflow.ts`
- Modify: `config/ci/gates.json`
- Modify: `.gitea/workflows/quality-gates.yml`
- Modify: `tests/unit/ci-workflow-generation.test.ts`
- Modify: `tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap`
**Interfaces:**
- Produces: promotion job `if: ${{ always() && needs.immutable_build.result == 'success' && needs.vulnerability_provider.result == 'success' && needs.provenance_provider.result == 'success' }}` and install-bearing script graph enforcement.
- [x] Add failing contract/generator tests for the job condition, upload without `always()`, missing cleanup outputs, and nested install scripts lacking `--ignore-scripts`.
- [x] Run focused tests and record RED.
- [x] Add `--ignore-scripts` to `verify:lockfile` and recursively reject each reachable install invocation without it.
- [x] Extend the typed job condition and render the explicit cancellation-resistant exact-needs predicate.
- [x] Regenerate workflow/snapshot and run workflow contract/byte tests to GREEN.
### Task 6: Fixtures, Documentation, Full Verification, and Commit
**Files:**
- Modify: `scripts/check-supply-chain-provider-fixtures.ts`
- Modify: `docs/security/supply-chain.md`
- Modify: `docs/operations/ci-quality-gates.md`
- Modify: `.superpowers/sdd/2026-08-01-release-evidence-remediation/task-4-report.md` (ignored durable report)
- [x] Replace plaintext candidate fixtures with a real tar and canonical captured-archive/exact-five validation.
- [x] Rewrite operator docs around the sole captured-archive/exact-five authority and retain the Gitea/runner external-smoke residual.
- [ ] Run focused fixtures, archive integration, workflow snapshot/bytes, full unit, types, lint, `check:ci`, and diff checks; escalate only a sandbox-caused EPERM.
- [x] Append all RED/GREEN and verification evidence/constraints to the durable report.
- [ ] Invoke verification-before-completion, review the complete diff, commit once, and report commit/range/status.
---
## Review-Fix Wave D: Sealed Bytes, Replay Context, Scan Trust, and Cancellation
**Constraint:** Work only in the existing uncommitted tree. Do not write `.git`, stage, or commit. Each task follows a focused RED→GREEN cycle and records sandbox `EPERM` separately from product failures.
### Task D1: Seal the actual staged inode bytes
**Files:** `scripts/lib/promotion-stager.ts`, `tests/unit/security-followup.test.ts`, `tests/unit/ci-artifact-contract.test.ts`
**Interface:** The staging writer captures each canonical file through the already-open leaf FD using `O_NOFOLLOW`; it requires a regular single-link inode, mode `0400`, stable dev/ino/size, and the declared SHA-256. `verifyExactPromotionBundle` receives only these captured staged buffers immediately before return.
- [x] Add RED tests for unlink/recreate and chmod/mutation after a file write.
- [x] Implement bounded descriptor-relative capture and exact-five seal validation.
- [x] Run focused staging tests to GREEN.
### Task D2: Bind downstream verification to external expected identity
**Files:** `scripts/lib/exact-promotion-bundle.ts`, `scripts/verify-exact-promotion-bundle.ts`, `tests/unit/ci-artifact-contract.test.ts`
**Interface:** `verifyExactPromotionBundle` requires `expected.run.id`, `expected.run.attempt`, `expected.sourceRevision`, and `expected.archiveSha256`; optional bundle/dist/lock/source-set digests are compared when supplied. The CLI obtains these values from dedicated environment variables and never derives them from the bundle.
- [x] Add a RED signed other-run replay test.
- [x] Implement external expected-context comparison in library and CLI.
- [x] Run exact-bundle tests to GREEN where the sandbox permits.
### Task D3: Pin mkdir-to-open identity
**Files:** `scripts/lib/promotion-stager.ts`, `tests/unit/security-followup.test.ts`, `docs/security/supply-chain.md`, `docs/operations/ci-quality-gates.md`
**Interface:** A post-mkdir/pre-open test hook can replace the leaf. The implementation compares mkdir-returned pathname metadata with the `O_DIRECTORY|O_NOFOLLOW` handle `fstat` before any write; it never uses pathname chmod.
- [x] Add a RED pre-open replacement test.
- [x] Compare created and opened metadata and reject replacement.
- [x] Document the residual portable Node same-UID pre-lstat/native-privilege boundary.
### Task D4: Conservatively parse install invocations
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`
**Interface:** A bounded shell/token parser recognizes `pnpm install|i`, `npm install|ci|i`, and `yarn install` after supported manager-global options with split or `=` values. Any reachable package-manager invocation that cannot be classified is rejected.
- [x] Add the eight required global-option/alias RED cases plus malformed fail-closed cases.
- [x] Implement tokenization and manager-specific invocation classification.
- [x] Run install-policy tests to GREEN.
### Task D5: Signed secret-scan attestation
**Files:** `scripts/lib/provider-evidence.ts`, `scripts/lib/provider-supervisor.ts`, `scripts/lib/provider-upload-validator.ts`, `scripts/lib/promotion-stager.ts`, `scripts/lib/exact-promotion-bundle.ts`, relevant unit/integration tests and docs.
**Interface:** Vulnerability evidence v2 contains a strict `secretScanAttestation` with `status: PASS`, local-assessment, source-set, policy, SARIF, and scan-input digests. The supervisor derives the expected tuple from captured archive members, exports it to the provider, and upload/final verification requires exact equality under the Ed25519 signature.
- [x] Add RED forged-empty-SARIF and attestation-mismatch tests.
- [x] Derive one captured-archive scan context and bind it through supervisor, signed schema, finalizer records, and exact validation.
- [x] Run provider/security tests to GREEN where the sandbox permits.
### Task D6: Cancellation-safe workflow and exact upload paths
**Files:** `scripts/contracts/ci-gates.ts`, `scripts/generate-ci-workflow.ts`, `config/ci/gates.json`, generated workflow/snapshot, workflow tests, and operations/security docs.
**Interface:** Promotion uses a typed dependency-success/no-job-if variant, so cancellation cannot be overridden by job-level `always()`. Step cleanup retains bare `always()` for ordinary failures. Upload documentation names the five canonical paths under `staging_root` and states cancellation cleanup remains a runner/native smoke boundary.
- [x] Add RED generator/contract assertions for no promotion job `if` and retained cleanup `always()`.
- [x] Regenerate workflow and snapshot after the typed condition change.
- [x] Correct operator/security wording and run workflow/CI checks to GREEN.
### Task D7: Verification
- [x] Run focused suites after each GREEN, then affected/full unit tests, all TypeScript targets, ESLint, `check:ci`, generated-byte check, and both diff checks.
- [x] Append exact PASS totals and sandbox-blocked commands to the ignored durable report.
- [x] Report modified files and remaining native/Gitea/unsandboxed verification boundaries; do not attempt git staging or commit.
## Wave E: Unified parser and downstream boundary review
### Task E1: One tokenized manager parser
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`
**Interface:** One parse result reports manager invocations, package-script dependencies, unsupported controls, and effective lifecycle suppression. Both graph traversal and install policy consume it. Single `&`, unknown manager grammar, and malformed options fail closed. The last valid `--ignore-scripts` assignment controls the effective value; false, contradictory, valueless, and malformed assignments are unsafe. Lifecycle-capable mutation builtins are never implicit repository scripts and require effective suppression.
- [x] Add RED tables for single-ampersand segmentation, false/override/malformed suppression, global-option run/implicit dependencies, yarn/corepack reachability, and builtin/script-name collisions.
- [x] Replace the regex traversal and separate install scan with one parser result.
- [x] Run the parser-focused and full workflow-generation suites.
### Task E2: Evaluator-owned secret-scan equality
**Files:** `scripts/lib/provider-evidence.ts`, `tests/unit/security-followup.test.ts`, finalizer tests.
**Interface:** `evaluatePromotionEvidence` itself compares the parsed vulnerability report's signed `secretScanAttestation` with `expected.secretScanAttestation`. A mismatch makes vulnerability and overall promotion status `FAIL_UNVERIFIED`, including the production finalizer path.
- [x] Add a RED evaluator mismatch test.
- [x] Implement exact equality before vulnerability PASS assignment.
- [x] Run security/provider-focused tests.
### Task E3: Downstream CLI exact-five contract
**Files:** `tests/unit/ci-artifact-contract.test.ts`, `tests/unit/security-followup.test.ts`, `scripts/verify-exact-promotion-bundle.ts` if required.
**Interface:** A real finalizer-produced canonical exact-five directory passes the downstream CLI when all required external expected values and trust keys are supplied. Every required expected variable missing or mismatched exits non-zero. Optional digests remain exact when present.
- [x] Add RED happy-path and required-env negative coverage using real finalizer output where sandbox execution permits.
- [x] Make only the minimal CLI/library changes needed for GREEN.
- [x] Separate child-process sandbox blockers from library assertions.
### Task E4: Verification
- [x] Run focused parser/security/CLI suites, TypeScript, ESLint, `check:ci`, and `git diff --check`.
- [x] Run full unit if feasible and report nested-process `EPERM` separately.
- [x] Do not stage or commit.
## Wave F: Manager parser boundary hardening
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`, stage, or commit. Add behavior tests before production changes and keep unsupported manager grammar fail-closed.
### Task F1: Workspace dispatch and authoritative script lookup
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`
**Interface:** The parser receives the authoritative root `scripts` record. Explicit `run` resolves a root script; pnpm/yarn implicit dispatch resolves only a known root script. Yarn `workspace` and `workspaces` dispatch are unsupported because the root graph does not load workspace package scripts. Builtin aliases are canonicalized before root-script lookup.
- [x] Add RED policy and graph tables for the three Yarn workspace dispatchers, pnpm `ln`, and unknown manager subcommands.
- [x] Remove workspace dispatchers from safe builtins, pass known root scripts into the parser, and canonicalize `pnpm ln` to lifecycle `link` before implicit lookup.
- [x] Run the focused dependency/lifecycle cases to GREEN.
### Task F2: Shell comments and lifecycle option state
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`
**Interface:** Unquoted `#` in a manager-bearing command is unsupported control syntax; quoted `#` remains ordinary token content. Lifecycle options are parsed in order into a canonical suppression state covering `--ignore-scripts`, `--no-ignore-scripts`, and `--config.ignore-scripts`; conflicting, malformed, unknown, or ineffective states fail closed. Other option-like lifecycle arguments require an explicit manager allowlist.
- [x] Add RED comment, negative suppression, supported positive, and unknown lifecycle-option tables.
- [x] Implement comment-aware tokenization and one ordered lifecycle argument parser.
- [x] Preserve the checked-in `--frozen-lockfile --ignore-scripts` path and run focused tests to GREEN.
### Task F3: Verification
- [x] Validate every checked-in package script through graph/install consumers without false positives.
- [x] Run the full workflow-generation file and related security tests.
- [x] Run all TypeScript targets, ESLint, `check:ci`, and `git diff --check`; report nested-process `EPERM` separately.
- [x] Update the durable report; do not stage or commit.
## Wave G: Verified cleanup and complete gate/parser preflight
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`, stage, or commit. Every production change follows a focused failing behavior test.
### Task G1: Verified-FD-only failure cleanup
**Files:** `scripts/lib/promotion-stager.ts`, `tests/unit/security-followup.test.ts`, security/operations documentation.
**Interface:** `openedIdentityVerified` becomes true only after the opened directory descriptor matches the post-`mkdir` device/inode. Failure cleanup may unlink canonical files or `rmdir` only through that verified descriptor and a still-matching visible identity. A mismatched opened descriptor and any visible replacement are close-only; a moved original directory remains for fixture/operator cleanup because portable Node cannot safely recover it.
- [x] Change the pre-open replacement regression to require both the replacement canary and displaced original directory to survive the failure.
- [x] Run the focused test to RED against parent-directory identity scanning.
- [x] Remove unverified inode discovery/recovery and gate descriptor cleanup on explicit identity verification.
- [x] Run staging race and cleanup tests to GREEN and document the native residual.
### Task G2: Contract-wide lifecycle preflight
**Files:** `scripts/contracts/ci-gates.ts`, `scripts/run-ci-gate.ts`, optional focused runner helper, `tests/unit/ci-workflow-generation.test.ts`.
**Interface:** `loadCiGateContract` runs `validateInstallScriptPolicy` over every unique contract command script after script existence and graph checks. The runner enters its execution callback only after this loader succeeds, enabling a no-execute regression without relying on a nested child process.
- [x] Add RED loader tables for contradictory npm suppression, pnpm config false, and `pnpm ln`, plus a production runner-boundary no-execute spy.
- [x] Enforce contract-command install policy and route runner execution through the preflight boundary.
- [x] Run loader/runner preflight tests to GREEN.
### Task G3: Foreign manifest scope by command class
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Manager-global options that change cwd, manifest or workspace scope are recorded during parsing. Explicit and implicit package-script dispatch with any such option is unsupported under the root-only graph. Lifecycle commands remain classifiable and are accepted only when their own ordered suppression/option grammar is safe.
- [x] Add RED policy+graph tables for pnpm filter/dir/`-C`, npm workspace/prefix, and Yarn cwd dispatch.
- [x] Add positive externally scoped lifecycle cases with verified suppression.
- [x] Track scope options and reject only package-script dispatch; run focused tests to GREEN.
### Task G4: Argument-sensitive builtin grammar and verification
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`, durable report.
**Interface:** Broad command-name-only safe builtins are replaced by exact per-manager read-only invocations. Init/explore/Yarn npm namespaces are unsupported. Audit is accepted only as an exact bare read-only command; `fix` and all unknown arguments are rejected.
- [x] Add RED policy+graph coverage for npm init/explore/audit-fix and Yarn npm publish, plus a bare-audit positive.
- [x] Replace permissive builtin lookup with exact argument grammar.
- [x] Audit every current package script for graph/policy false positives.
- [x] Run staging/parser/no-execute/workflow/security suites, all TypeScript targets, ESLint, `check:ci`, and `git diff --check`; record sandbox `EPERM` separately and do not stage or commit.
## Wave H: npm post-script scope-option boundary
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`, stage, or commit. Reproduce every reviewer command in a failing test before changing the parser.
### Task H1: Explicit and implicit npm dispatch arguments
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`.
**Interface:** After an explicit `npm run`/`run-script` dependency or an implicit npm lifecycle script, manager options before the first literal `--` are parsed using an exact harmless allowlist. Workspace/prefix selectors (`--workspace`, `-w`, `--workspaces`, `--prefix`, including supported attached/equal forms) and unknown manager options fail closed. Tokens after the first literal `--` are script arguments and cannot change the authoritative manifest scope.
- [x] Add RED policy-and-graph coverage for all seven reviewer inputs, short/equal forms, unknown pre-delimiter options, the literal `--` boundary, and ordinary current-tree dispatch.
- [x] Implement one npm post-script argument parser shared by explicit and implicit dispatch.
- [x] Run focused parser tests to GREEN.
### Task H2: Contract loader and runner boundary
**Files:** `tests/unit/ci-workflow-generation.test.ts`, contract preflight only if the RED test exposes a separate integration defect.
**Interface:** Every reviewer input is rejected by contract loading while the referenced root scripts exist and are otherwise safe. `withCiGatePreflight` must not enter its callback for any rejected command.
- [x] Add a table-driven loader/no-callback regression for the same seven reviewer inputs.
- [x] Run focused preflight tests to GREEN.
### Task H3: Verification
- [x] Re-audit current package scripts through graph and policy consumers.
- [x] Run workflow/security suites, all TypeScript targets, ESLint, `check:ci`, and `git diff --check`.
- [x] Record results in the durable report and do not stage or commit.
**Verification evidence:** The focused npm parser/preflight selection passed
31/31. The workflow file passed 208/210; its two remaining tests reached the
known nested-spawn sandbox boundary and reported `EPERM`. Security, supply-chain,
and local-promotion tests passed 64/64. All six TypeScript targets, ESLint,
`check:ci`, and `git diff --check` passed. Auditing the checked-in package found
zero policy failures across 109 scripts and zero graph failures across 108
entries (excluding the intentionally direct runner entry `ci:gate`). A broader
artifact-contract run passed 102 assertions and blocked 23 fixture cases at the
same nested `git ls-files` `EPERM` boundary. No `.git` write was performed.
Local pnpm 11.17 execution showed post-script `--filter`/`--dir` tokens arriving
in the root script's argv, and official Yarn run documentation defines all
parameters after the script name as script arguments. Those pre-existing
negative expectations were therefore corrected to positive regressions; only
npm receives the new post-script manager-option grammar.
## Wave I: npm hook closure and environment scope
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`,
stage, or commit. Add focused behavior tests and observe RED before each
production change.
### Task I1: npm pre/main/post dependency closure
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Explicit npm `run`/`run-script` and implicit
`start`/`stop`/`restart`/`test` return existing root-manifest lifecycle hooks in
`pre`, main, `post` order. Hooks are omitted only when ordered manager/tail
suppression is unambiguously effective before the first literal `--`; bare,
false, negative, contradictory, malformed, or post-delimiter suppression keeps
hook traversal active or fails closed.
- [x] Add RED policy/graph tables for nested, test, and restart pre/post hooks.
- [x] Add RED suppression positives and false/negative/contradictory/delimiter negatives.
- [x] Make npm tail parsing update the invocation suppression state and expand dependencies.
- [x] Run hook/parser tests to GREEN.
### Task I2: Tokenized npm scope environment
**Files:** `scripts/lib/package-script-graph.ts`, `scripts/contracts/ci-gates.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Case-insensitive assignments to `npm_config_workspace`,
`npm_config_workspaces`, or `npm_config_prefix` fail closed when their shell
segment executes npm. Direct assignment, `env`, `/usr/bin/env`, and an exported
assignment inherited by a later npm segment are covered without raw-substring
false positives for quoted text. `withCiGatePreflight` also rejects the same
sensitive keys inherited through `process.env` before entering its callback.
- [x] Add RED policy/graph coverage for all reviewer assignment forms and quoted/current-tree positives.
- [x] Add RED loader/no-callback coverage for command assignments and inherited process environment.
- [x] Implement token/segment assignment state and the preflight environment boundary.
- [x] Run environment/parser/preflight tests to GREEN.
### Task I3: Verification
- [x] Audit every current script through graph and policy consumers.
- [x] Run focused parser/preflight, workflow/security, all TypeScript targets,
ESLint, `check:ci`, and `git diff --check`.
- [x] Update durable operations/security documentation and record sandbox-only
nested spawn failures separately; do not stage or commit.
**Verification evidence:** Hook closure began RED 9/9 and GREEN 9/9;
ordered suppression began with 6 expected failures and finished GREEN 22/22;
wrapper/export/inherited environment coverage began with 7 expected failures
and finished GREEN 28/28. A final all-command-class environment RED 3/3
closed scoped lifecycle and builtin invocations. The combined Wave I focused
selection passed 59/59. The complete workflow file passed 269/271; its only
two failures were the existing nested child-spawn `EPERM` fixtures. Security,
supply-chain, and local-promotion tests passed 64/64. All six TypeScript
targets, ESLint, `check:ci`, and `git diff --check` passed. The checked-in tree
had zero policy failures across 109 scripts, zero graph failures across 108
entries after excluding the intentional direct runner entry `ci:gate`, and no
sensitive inherited npm scope environment. No `.git` write was performed.
## Wave J: coherent npm environment state and hook semantics
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`,
stage, or commit. Add each reviewer form as a failing regression before changing
the parser.
### Task J1: Stateful shell npm-scope environment analysis
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Shell segments carry a conservative npm-scope environment state
to later reachable npm invocations. The parser recognizes any static path whose
basename is `env`, optionally behind `command`, and rejects case-insensitive
scope assignments in direct or env-wrapper contexts. Static assignment/export
and `set -a` transitions are modeled across segments. Dynamic assignment names
and environment mutations that cannot be modeled accurately (`set +a`,
`unset`, `export -n`, `eval`, dot/source) make later npm dispatch unsupported.
Quoted harmless text and non-scope static assignments remain accepted; analysis
uses token and segment structure rather than raw substring matching.
- [x] Add RED policy/graph tables for every reviewer state transition, env path,
command wrapper, dynamic assignment name, and unsupported mutation.
- [x] Implement a shared tokenized shell-environment state machine and immediate
npm invocation environment inspection.
- [x] Add harmless quoted/static positive regressions and run focused tests GREEN.
### Task J2: Actual npm lifecycle-hook suppression semantics
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Existing pre/main/post hooks are traversed for `run`,
`run-script`, `start`, `stop`, `restart`, and `test`. Bare `--ignore-scripts`
means true and omits hooks just like explicit true. False, negative,
contradictory, malformed, and post-delimiter forms retain hook traversal or fail
closed according to the existing ordered grammar.
- [x] Add hook safety/order coverage for run-script, start, and stop.
- [x] Move bare suppression forms to positive regressions and retain all false,
negative, contradictory, and delimiter negatives.
- [x] Remove the explicitly-valued distinction and run focused tests GREEN.
### Task J3: Contract boundary and verification
- [x] Run every environment reviewer command through policy, graph, contract
loading, and `withCiGatePreflight`, asserting the callback is never entered.
- [x] Retain the inherited process-environment regression and audit the current
package tree for policy/graph false positives.
- [x] Run workflow/security suites, all TypeScript targets, ESLint, `check:ci`,
and `git diff --check`; record sandbox-only failures and do not stage or commit.
**Verification evidence:** The initial Wave J selection produced 38 expected
failures across loader/policy/graph environment cases and bare hook suppression,
then passed 84/84 after implementation. A separate unsupported dynamic env-wrapper
expansion regression went RED 2/2 and GREEN 2/2; the final combined selection
passed 86/86. The full workflow file passed 327/329, with only the existing two
nested child-spawn `EPERM` fixtures failing at the sandbox boundary. Security,
supply-chain, local-promotion, and promotion-readiness tests passed 71/71. All
six TypeScript targets, ESLint, `check:ci`, and `git diff --check` passed. The
checked-in tree had zero policy failures across 109 scripts, zero graph failures
across 108 entries after excluding the intentional direct runner entry `ci:gate`,
and no sensitive inherited npm scope environment. No `.git` write was performed.
## Wave K: common shell-prefix grammar
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`,
stage, or commit. Every wrapper/prefix reviewer command must be RED in all four
public enforcement paths before production changes.
### Task K1: Shared prefix parser and state-builtin targeting
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** A single token-based prefix helper consumes leading static
assignments, then exact `command`/`exec` wrapper chains and their supported
separator syntax. It reports the effective command token/index, whether parsing
is uncertain, and the leading assignments. Both immediate npm env inspection
and persistent `export`/`set` state updates use this result. `command --` is
accepted; unknown `command` options and unmodeled `exec` options before npm/env
fail closed. Static paths retain basename-`env` behavior.
- [x] Add common policy/graph RED cases for `exec env`, `exec /bin/env`,
`command exec env`, `command -- env`, assignment-prefixed `export`, and
assignment-prefixed `set -a`.
- [x] Reuse the same reviewer table through `loadCiGateContract` and
`withCiGatePreflight`, asserting rejection and no callback entry.
- [x] Implement the shared prefix parser, route immediate env inspection and
state-builtin updates through it, and run the reviewer selection GREEN.
- [x] Preserve split assignment/export ordering, dynamic LHS, quoted text,
harmless `MESSAGE=...`, and supported command-wrapper positives.
### Task K2: Hook selection and final verification
**Files:** `tests/unit/ci-workflow-generation.test.ts`, durable report.
- [x] Ensure the final focused selection explicitly includes the bare npm hook
suppression table as well as prefix/environment policy and runner tests.
- [x] Run the full workflow file and security/supply/local-promotion suites;
classify only the known nested-spawn sandbox failures separately.
- [x] Audit all current scripts through policy and graph, then run all TypeScript
targets, ESLint, `check:ci`, and `git diff --check`; do not stage or commit.
**Verification evidence:** The nine shared shell-prefix reviewer commands began
RED in both enforcement tables, producing 18 expected failures across
loader/runner and policy/graph, then passed 18/18 after the common parser was
connected. The prefix negatives plus harmless positives passed 39/39. The final
focused selection explicitly combined prefix cases, dynamic environment cases,
effective/bare npm hook suppression, and harmless positives and passed 106/106.
The complete workflow file passed 353/355; its only two failures were the known
nested child-spawn `EPERM` fixtures. Security, supply-chain, local-promotion, and
promotion-readiness tests passed 71/71. The current package tree had zero policy
failures across 109 scripts, zero graph failures across 108 entries after
excluding `ci:gate`, and no sensitive inherited npm scope environment. All six
TypeScript targets, ESLint, `check:ci`, and `git diff --check` passed. The shared
workspace was preserved and no `.git` write was performed.
## Wave L: structural manager-prefix gap rejection
**Constraint:** Continue in the shared uncommitted tree. Do not write `.git`,
stage, or commit. Generalize the existing parser; do not add wrapper names to an
allowlist.
### Task L1: Reject unmodeled tokens before package managers
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** The common shell-prefix result identifies the first effective
command after modeled assignments and `command`/`exec` wrappers. When manager
scanning later finds a package manager, every token between that effective
command position and the manager position must belong to a grammar explicitly
consumed by immediate env or corepack parsing. Otherwise the invocation is
unsupported. This structural rule covers `nice`, absolute-path `nice`, `nohup`,
and future unknown wrappers without naming them.
- [x] Add policy/graph RED coverage for the four env-wrapper reviewer commands
and direct unknown-wrapper manager commands (`nice npm`, `time pnpm`).
- [x] Reuse the env-wrapper reviewer commands through contract loading and
`withCiGatePreflight`, asserting the callback remains false.
- [x] Implement one structural gap check in manager parsing and run RED cases
GREEN without adding wrapper names.
- [x] Retain modeled assignment, `command`/`exec`/env/corepack, current-tree,
quoted echo, and harmless assignment positives.
### Task L2: Verification
- [x] Run a focused selection containing structural negatives and all modeled
prefix/environment positives.
- [x] Run the workflow and security/supply/local-promotion suites, current-tree
policy/graph/environment audit, all TypeScript targets, ESLint, `check:ci`, and
`git diff --check`; record sandbox-only failures and do not stage or commit.
**Verification evidence:** The six unmodeled-prefix reviewer commands began RED
in both enforcement tables, producing 12 expected loader/runner and policy/graph
failures, then passed 12/12 after one structural prefix-gap check was added. The
unmodeled negatives plus harmless/modeled positives passed 36/36. The final
focused Wave HL environment/prefix and effective/bare hook selection passed
121/121. The full workflow file passed 368/370, with only the two known nested
child-spawn `EPERM` fixtures failing at the sandbox boundary. Security,
supply-chain, local-promotion, and promotion-readiness tests passed 71/71. The
current package tree had zero policy failures across 109 scripts, zero graph
failures across 108 entries after excluding `ci:gate`, and no sensitive inherited
npm scope environment. All six TypeScript targets, ESLint, `check:ci`, and
`git diff --check` passed. No wrapper-name allowlist was added, the workspace was
preserved, and no `.git` write was performed.
@@ -0,0 +1,499 @@
# Provider Evidence Guardian Transaction Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make guardian startup cleanup derive authority only from identities allocated before spawn while preserving no-replace raw/sealed publication and immediate safe retry.
**Architecture:** Before spawn, the client pins the canonical raw/evidence directories and exclusively allocates nonce-private raw-staging/sealed-temp inodes whose handles and identities it retains. The guardian inherits directory fd 3/fd 4 and private-file fd 5/fd 6, binds strictly validated aliases to those inherited identities, and transfers raw authority with a no-replace hard link before authenticated READY. The client and guardian clean only pre-recorded identities; neither promotes a pathname-discovered inode to ownership.
**Tech Stack:** Node.js 24 TypeScript, Vitest, Linux file identities and procfs, systemd user scopes, bubblewrap, cgroup v2.
## Global Constraints
- Tasks 1-7 are the historical round-four/five record. Round-six Task 8 runs only in `/tmp/guardian-race-fix-y05lvLi1/repo` on top of `d781692`; never modify the original workspace, `/tmp/task3-integration-mU4L7J2u`, or the security-finalizer repository.
- Use RED-GREEN-REFACTOR for every production behavior change.
- Guardian argv contains only `process.execPath` and the trusted guardian script; its environment is empty, fd 3/fd 4 are the identity-pinned raw/evidence directories, and fd 5/fd 6 are the identity-pinned private raw/sealed allocations.
- Every request/ack is canonical length-prefixed JSON with exact ordered fields, strict UTF-8, no NUL, total bounds, a 32-byte nonce, and constant-time authentication.
- Canonical raw and sealed paths are derived from guardian `cwd` and provider kind; paths and identities are not accepted in the guard request.
- Provider wall timeout is at most 30 minutes and post-processing allowance is exactly 10 minutes; the guardian maximum lease is 40 minutes.
- Publication is no-replace and directory-durable. Abort/death/deadline cleans every raw/temp/final path that still names a pinned owned inode.
- Preserve all cleanup failures with the primary failure using `AggregateError`.
- Do not add PID-exhaustion loops or claim `RLIMIT_NPROC` enforcement.
- Do not run or report live systemd/bwrap tests as passing while the approval limit prevents execution.
- Never forward raw provider stdout/stderr bytes to supervisor or CI logs.
---
### Task 1: Versioned Transaction Protocol
**Files:**
- Modify: `scripts/lib/provider-guardian-protocol.ts`
- Modify: `tests/unit/task3-selective-integration.test.ts`
**Interfaces:**
- Produces:
```ts
type ProviderGuardianGuard = Readonly<{
kind: "vulnerability" | "provenance";
nonce: Buffer;
deadlineEpochMs: number;
}>;
type ProviderGuardianReady = Readonly<{
nonce: Buffer;
rawDev: number;
rawIno: number;
sealedTempLeaf: string;
sealedDev: number;
sealedIno: number;
}>;
type ProviderGuardianPublish = Readonly<{
nonce: Buffer;
sealedDev: number;
sealedIno: number;
size: number;
sha256: string;
}>;
function encodeProviderGuardianGuard(input: ProviderGuardianGuard): Buffer;
function decodeProviderGuardianReady(payload: Buffer, nonce: Buffer): ProviderGuardianReady;
function encodeProviderGuardianPublish(input: ProviderGuardianPublish): Buffer;
function decodeProviderGuardianPublished(payload: Buffer, nonce: Buffer): void;
function encodeProviderGuardianCommit(nonce: Buffer): Buffer;
```
- [ ] **Step 1: Write failing exact-protocol tests**
Assert that guard contains no path or identity, READY returns authenticated identities, publish binds exact identity/size/SHA-256, PUBLISHED authenticates the same nonce, and duplicate/reordered/trailing/oversized/invalid UTF-8/NUL/short-nonce frames fail.
```ts
expect(JSON.parse(encodeProviderGuardianGuard(guard).subarray(4).toString())).toEqual({
type: "guard", version: 2, kind: "vulnerability",
nonce: nonce.toString("hex"), deadlineEpochMs,
});
expect(() => decodeProviderGuardianReady(duplicateNoncePayload, nonce)).toThrow(/canonical|fields/u);
```
- [ ] **Step 2: Run focused RED**
Run: `node_modules/.bin/vitest run tests/unit/task3-selective-integration.test.ts --reporter=default --maxWorkers=1`
Expected: FAIL because the v2 guard/READY/publish/PUBLISHED APIs do not exist and the old guard still accepts identities.
- [ ] **Step 3: Implement the minimal v2 codecs**
Use one bounded prefix/strict decode utility, exact ordered key arrays, canonical re-encoding, lowercase 64-hex nonces/SHA-256, safe positive integers, and `timingSafeEqual` for every acknowledgement/authentication comparison.
- [ ] **Step 4: Run focused GREEN**
Run the Step 2 command and require the protocol tests to pass.
### Task 2: Guardian-Owned Raw and Sealed Transaction
**Files:**
- Modify: `scripts/lib/provider-raw-guardian.ts`
- Modify: `scripts/lib/provider-raw-cleanup.ts`
- Modify: `tests/unit/task3-selective-integration.test.ts`
**Interfaces:**
- Consumes: Task 1 codecs.
- Produces: real process state machine `guard -> READY -> publish -> PUBLISHED -> commitPending -> EOF success`.
- [ ] **Step 1: Write failing real-process creation tests**
Cover no-frame and partial-frame EOF with no files, authenticated READY-created raw/temp identities and modes, full-frame parent EOF cleanup before commit, deadline cleanup, and a near-timeout successful transaction.
```ts
child.stdin.end(partialFrame);
await completion;
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(await lstat(rawPath)).toMatchObject({ mode: expect.any(Number) });
```
- [ ] **Step 2: Verify creation RED**
Run the focused test and require failure because the existing guardian expects supervisor-created identity and emits line-based READY.
- [ ] **Step 3: Implement exclusive creation and READY**
Derive canonical leaves, create raw and random sealed sibling temp with `O_EXCL|O_NOFOLLOW`, set raw/temp `0600`, fstat identities, close raw, keep temp handle, and emit bounded READY. On every error, attempt all owned cleanup before nonzero exit.
- [ ] **Step 4: Write failing publish/state tests**
Write validated bytes to the pinned temp, request publish, require PUBLISHED and final mode/hash/identity, then verify commit waits for EOF. Send one later trailing byte after commit and require final cleanup/nonzero exit. Kill the parent after PUBLISHED and require raw/temp/final absence.
- [ ] **Step 5: Implement no-replace durable publish and serialized terminal cleanup**
Verify held descriptor/path identity, `nlink=1`, `0400`, size, and SHA-256. Use `link(temp, final)`, `unlink(temp)`, final lstat identity, and parent-directory fsync. Serialize frame and EOF handling so a publish/death race cannot bypass cleanup. Commit removes raw and sets `commitPending`; only clean EOF exits zero.
- [ ] **Step 6: Run real-process GREEN**
Run focused tests and require zero raw/temp/final/process residuals in every failure case.
### Task 3: Authenticated Client Lease and Fallback Cleanup
**Files:**
- Modify: `scripts/lib/provider-guardian-client.ts`
- Modify: `scripts/lib/validated-json-artifact.ts`
- Modify: `tests/unit/task3-selective-integration.test.ts`
- Modify: `tests/unit/validated-json-artifact.test.ts`
**Interfaces:**
- Produces:
```ts
type ProviderGuardianLease = Readonly<{
pid: number;
rawPath: string;
rawIdentity: Readonly<{ dev: number; ino: number }>;
sealedPath: string;
sealedTempPath: string;
sealedIdentity: Readonly<{ dev: number; ino: number }>;
prematureExit: Promise<Error>;
publish(bytes: Buffer): Promise<void>;
commit(): Promise<void>;
abort(): Promise<void>;
}>;
function serializeValidatedJsonArtifact(input: ValidatedJsonArtifactInput): Buffer;
```
- [ ] **Step 1: Write failing client transaction tests**
Require exact guardian argv and empty environment, READY identity capture, pinned temp write/fsync/mode, PUBLISHED wait, exactly-one terminal action, post-READY guardian SIGKILL cleanup of raw/temp/final, and cleanup error aggregation.
- [ ] **Step 2: Verify client RED**
Run focused and validated-writer tests. Expect missing publish/identity/serializer APIs.
- [ ] **Step 3: Implement serialization and lease**
Extract the existing schema-parse/pretty-JSON/newline serialization without changing `writeValidatedJsonArtifact`. Open the returned temp with `O_NOFOLLOW`, fstat identity, truncate/write/chmod `0400`/fsync/fstat/close, send authenticated publish metadata, and wait for PUBLISHED. Fallback cleanup attempts raw, temp, and final using READY identities and aggregates failures.
- [ ] **Step 4: Run client GREEN**
Run the Step 2 tests and require exact bytes, identities, cleanup, and no residual child.
### Task 4: Supervisor Transaction and Scope-Active Latch
**Files:**
- Modify: `scripts/run-and-validate-provider.ts`
- Modify: `tests/unit/task3-selective-integration.test.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Consumes: Task 3 lease and serializer.
- Produces: guardian-owned raw/provider execution, awaited publication, output append, commit/EOF, and scope-confined kill ownership.
- [ ] **Step 1: Write failing supervisor ordering/latch tests**
Require no `createProviderOutput`, lease start before provider, lease raw identity passed to scope, serialized bytes published before output append, commit after output append, and postprocess allowance included in lease. Add a pure scope-latch unit boundary or static contract proving the guardian callback can call `killProviderUnit` only while `scopeActive` is true.
- [ ] **Step 2: Verify supervisor RED**
Run focused tests and expect the old create/write/cleanup ordering assertions to fail.
- [ ] **Step 3: Integrate the lease transaction**
Start guardian in `executeProvider`, use READY raw path/identity for provider bind and capture, publish serialized validated evidence through the lease, append output, then commit. Remove supervisor raw creation and normal sealed writer publication. Keep only identity-bound lease fallback cleanup.
Set `PROVIDER_POSTPROCESS_TIMEOUT_MS = 600_000` and request `providerWallTimeoutMs + PROVIDER_POSTPROCESS_TIMEOUT_MS`.
- [ ] **Step 4: Implement scope-active guardian exit ownership**
Race an awaited scope-completion promise against termination. The guardian callback records its error and invokes termination only while `scopeActive`; the same function sets the latch false exactly once when kill/collection or normal collection completes. The callback never throws or creates an unobserved kill promise after the latch closes.
- [ ] **Step 5: Run supervisor GREEN**
Run focused tests and type/lint checks. Live systemd tests remain unexecuted and are not reported as passing.
### Task 5: Regression Fixtures and Documentation
**Files:**
- Modify: `tests/unit/task3-selective-integration.test.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
- Modify: `docs/operations/ci-quality-gates.md`
- Modify: `docs/security/supply-chain.md`
- Modify: `docs/superpowers/specs/2026-08-02-provider-raw-guardian-design.md`
- [ ] **Step 1: Complete real-process regressions**
Cover no/partial frame, parent kill near READY, post-READY guardian kill, PUBLISHED parent death, publish/commit race, later-chunk commit trailing data, deadline/near-timeout, same-workspace retry, and zero guardian/raw/temp/final residuals.
- [ ] **Step 2: Specify live regressions**
Add active-scope guardian kill, post-scope/precommit guardian kill, supervisor hard death after PUBLISHED with same-workspace retry, and detached descendant attempts for both an external marker and raw append. Every case requires zero cgroup/process/file residuals. Do not execute these tests under the current approval limit.
- [ ] **Step 3: Correct operations and security docs**
Document guardian-owned creation/publication, READY/PUBLISHED identities, ten-minute postprocess lease, commitPending/EOF success, no-replace link publication, scopeActive kill ownership, regular-file `GITHUB_OUTPUT`, and explicit live-test limitation.
- [ ] **Step 4: Fresh verification**
Run focused real-process tests, validated artifact tests, direct Node/test/recipe TypeScript configs, full lint, artifact schemas, CI contract, generated workflow byte check, and `git diff --check`. Record broad-suite sandbox `EPERM` separately and never convert unexecuted live tests into PASS.
- [ ] **Step 5: Review and commit round four**
Confirm only the isolated worktree changed, no protocol secret/path enters argv, cleanup checks both sealed names by identity, and only the temp `node_modules` symlink remains untracked. Create a separate round-four implementation commit above the design/plan commit.
### Task 6: Round-Five Pre-READY Recovery Authority
**Files:**
- Modify: `scripts/lib/provider-guardian-protocol.ts`
- Modify: `scripts/lib/provider-guardian-client.ts`
- Modify: `scripts/lib/provider-raw-guardian.ts`
- Test: `tests/unit/provider-guardian-transaction.test.ts`
**Interfaces:**
- Produces: `providerGuardianSealedTempLeaf(kind, nonce): string`, inherited raw/evidence directory fds 3/4, and descriptor-relative startup/lease cleanup.
- [x] **Step 1: Write failing pre-READY hard-death tests**
Start the real client without awaiting READY, observe its direct guardian child,
kill the guardian when either deterministic transaction leaf first appears, and
require startup rejection, zero raw/temp/final residuals, and a successful
same-workspace `startProviderGuardian(...).abort()` retry. Also require the temp
leaf computed before spawn to equal READY exactly and inherited fd 3/fd 4 to
remain directories during the lease.
- [x] **Step 2: Run focused RED**
Run: `node_modules/.bin/vitest run tests/unit/provider-guardian-transaction.test.ts --reporter=default --maxWorkers=1`
Expected: FAIL because the client has neither pre-spawn directory handles nor a
deterministic temp leaf and cannot clean a guardian killed before READY.
- [x] **Step 3: Implement pinned descriptor recovery**
Open and verify the canonical raw/evidence directories with
`O_DIRECTORY|O_NOFOLLOW`; derive the temp leaf from provider kind and the first
16 nonce bytes; spawn with those handles at fd 3/fd 4. Use only
`/proc/self/fd/<fd>/<leaf>` for guardian creation, publication, sync, and cleanup.
On startup failure, open each exact leaf through the still-live client
descriptor, fstat a regular single-link inode, close the discovery handle, and
run identity-bound quarantine/unlink. Aggregate primary, cleanup, and directory
close errors. Retain both handles until commit/abort terminates.
- [x] **Step 4: Run focused GREEN**
Run the Step 2 command and require the pre-READY kill/retry and all round-four
transaction tests to pass.
### Task 7: Round-Five Log Privacy and Terminal Fail-Closed Behavior
**Files:**
- Modify: `scripts/run-and-validate-provider.ts`
- Modify: `scripts/lib/provider-raw-guardian.ts`
- Test: `tests/unit/provider-guardian-transaction.test.ts`
- Test: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Consumes: Task 6 descriptor-pinned transaction.
- Produces: bounded discard of provider output and nonzero guardian termination even when diagnostic fds are closed.
- [x] **Step 1: Write failing privacy and closed-stderr tests**
Run a successful provider that both receives and prints a unique
`VULNERABILITY_PROVIDER_*` credential, then assert the credential is absent from
supervisor stdout/stderr while the sealed signed evidence succeeds. Replace the
FD-limit provider's stderr marker expectations with evidence/side-channel state.
Spawn a real guardian with stderr's read side destroyed, establish owned files,
then abort or send invalid input and require zero files plus a nonzero exit.
- [x] **Step 2: Run targeted RED**
Run the focused guardian and selected CI artifact tests. Expect credential
disclosure and the existing raw provider stderr marker assertions to fail the
new contract; the EPIPE case can exit without the required nonzero terminal.
The executable non-live RED observed six expected failures: missing deterministic
leaf/fd inheritance/output limiter, retained pre-READY raw, and closed-stderr
exit 0. The live credential-printing fixture is authored but remains NOT RUN.
- [x] **Step 3: Implement minimal privacy and terminal fixes**
Continue counting provider stdout/stderr bytes against the aggregate output
limit but discard captured bytes instead of retaining or forwarding them. Make
guardian fd-close and stderr diagnostics best effort, run cleanup first, and
place `process.exit(exitCode)` or self-`SIGKILL` in an unconditional final
branch that cannot be skipped by `EPIPE`/`EBADF`.
- [x] **Step 4: Run targeted GREEN and regression verification**
Run focused guardian tests, selected non-live privacy tests, Node/test
TypeScript, affected ESLint, docs readiness, and `git diff --check`. Do not run
live systemd/bwrap tests under the approval limit.
- [x] **Step 5: Commit round five implementation**
Commit production, tests, and operational/security documentation separately
above this round-five design/plan commit. Record live systemd/bwrap as NOT RUN.
Round-five verification record:
- Focused real-process/unit GREEN: 4 files, 52 tests passed.
- Direct Node and test TypeScript projects: PASS.
- Affected ESLint with zero warnings: PASS.
- Documentation readiness: `PASS_SCOPED`.
- `git diff --check`: PASS.
- Live systemd/bwrap credential, FD-limit, cgroup, and hard-death fixtures:
**NOT RUN** because the active approval limit forbids those executions.
### Task 8: Round-Six Pre-READY Inode Ownership
**Files:**
- Modify: `scripts/lib/provider-guardian-protocol.ts`
- Modify: `scripts/lib/provider-guardian-client.ts`
- Modify: `scripts/lib/provider-raw-guardian.ts`
- Test: `tests/unit/provider-guardian-transaction.test.ts`
- Modify: `docs/security/supply-chain.md`
- Modify: `docs/operations/ci-quality-gates.md`
- Modify: `docs/superpowers/specs/2026-08-02-provider-raw-guardian-design.md`
**Interfaces:**
- Produces:
```ts
function providerGuardianRawStagingLeaf(
kind: ProviderGuardianKind,
nonce: Buffer,
): string;
type RecoveryAuthority = Readonly<{
rawDirectoryHandle: FileHandle;
evidenceDirectoryHandle: FileHandle;
rawStagingHandle: FileHandle;
sealedTempHandle: FileHandle;
rawIdentity: Readonly<{ dev: number; ino: number }>;
sealedIdentity: Readonly<{ dev: number; ino: number }>;
rawStagingPinnedPath: string;
rawPinnedPath: string;
sealedTempPinnedPath: string;
sealedPinnedPath: string;
}>;
```
- [ ] **Step 1: Write the deterministic external-canary RED**
Create a temporary guardian fixture that writes a spawn marker and remains
alive without producing READY. Start the real client, wait for that marker (so
`assertRecoveryLeavesMissing` has completed), create a fixed-raw canary, kill
the direct guardian, and require startup rejection without canary deletion or
mutation.
```ts
const canaryBytes = Buffer.from("external-canary\n");
const canaryHandle = await open(rawPath, constants.O_CREAT | constants.O_EXCL |
constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
await canaryHandle.writeFile(canaryBytes);
const canaryIdentity = await canaryHandle.stat();
await canaryHandle.close();
process.kill(guardianPid, "SIGKILL");
await expect(starting).rejects.toThrow(/provider guardian/u);
expect(await readFile(rawPath)).toEqual(canaryBytes);
expect(await lstat(rawPath)).toMatchObject({
dev: canaryIdentity.dev,
ino: canaryIdentity.ino,
});
```
- [ ] **Step 2: Run the canary RED and confirm the ownership bug**
Run:
`node_modules/.bin/vitest run tests/unit/provider-guardian-transaction.test.ts -t "preserves an external raw canary" --reporter=default --maxWorkers=1`
Expected: FAIL with `ENOENT` when reading the canary because
`discoverAndCleanupOwnedLeaf` opens the current raw pathname and promotes the
external inode to cleanup authority.
- [ ] **Step 3: Add private-leaf derivation and client allocations**
Derive raw staging and sealed temp from the same first 16 nonce bytes:
```ts
return `.${baseLeaf(kind)}.guardian-${nonce.subarray(0, 16).toString("hex")}.raw.tmp`;
```
Through the pinned directory paths, create raw staging and sealed temp with
`O_RDWR|O_CREAT|O_EXCL|O_NOFOLLOW`, mode `0600`; require regular file, link count
one, mode `0600`, and size zero; store identities before spawn. Spawn with fd
3-fd 6. If allocation, validation, or spawn fails, identity-clean every private
alias and close every opened handle while preserving primary and cleanup/close
errors in one `AggregateError`.
- [ ] **Step 4: Add concurrency, bootstrap, and link-before-READY RED tests**
Add real-process tests that require:
```ts
// no/partial frame: bootstrap-owned private aliases are removed
child.stdin!.end(partialFrame);
await expect(readdir(rawDirectory)).resolves.toEqual([]);
// same kind: exactly one READY lease, loser never removes winner raw
const results = await Promise.allSettled([startProviderGuardian(input), startProviderGuardian(input)]);
expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1);
expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1);
// canonical raw link exists but READY has not been accepted
process.kill(guardianPid, "SIGKILL");
await expect(starting).rejects.toThrow(/provider guardian/u);
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
```
The link-before-READY test watches only the fixed raw basename, obtains the
direct child pid before the event, and kills on that exact link event so private
allocation events cannot satisfy the synchronization point. Each test performs
a same-workspace retry and requires no owned private/canonical residue.
- [ ] **Step 5: Implement guardian bootstrap identity binding**
At process bootstrap, fstat fd 5/fd 6 and read `/proc/self/fd/5|6`. Accept an
alias only if `dirname(readlink)` is the canonical expected directory, basename
is a direct child matching the exact raw-staging or sealed-temp lowercase-hex
grammar, both names encode the same kind/nonce prefix, and descriptor-relative
lstat equals the inherited fd identity/type/mode/size/link count. Store the fd
identity before reading any pathname; the pathname only becomes an alias for
that identity.
On valid guard, require exact `providerGuardianRawStagingLeaf(kind, nonce)` and
`providerGuardianSealedTempLeaf(kind, nonce)` matches. Use
`link(rawStaging, rawCanonical)` without replacement, check both aliases equal
the inherited raw identity with link count two, unlink raw staging, fsync fd 3,
and check raw canonical remains the same identity with link count one before
READY. Use the inherited sealed identity for READY and publication.
- [ ] **Step 6: Replace discovery cleanup and close all private fds**
Delete `discoverAndCleanupOwnedLeaf`. Client pre-READY and fallback cleanup
attempts raw staging/canonical with only `recovery.rawIdentity`, then sealed
temp/final with only `recovery.sealedIdentity`. Guardian no/partial-frame and
terminal cleanup uses only its bootstrap fd identities and bound aliases.
On success and every failure branch, attempt all cleanup first, close guardian
fd 5/fd 6 duplicates and client fd 3-fd 6 handles exactly once, and append every
close failure to the existing aggregate. Never open a current leaf to obtain a
new cleanup identity.
- [ ] **Step 7: Run focused GREEN and regressions**
Run:
```bash
node_modules/.bin/vitest run tests/unit/provider-guardian-transaction.test.ts --reporter=default --maxWorkers=1
node_modules/.bin/vitest run tests/unit/provider-output-limiter.test.ts tests/unit/ci-artifact-contract.test.ts --reporter=default --maxWorkers=1
node_modules/.bin/tsc --project tsconfig.node.json
node_modules/.bin/tsc --project tsconfig.test.json
node_modules/.bin/eslint scripts/lib/provider-guardian-protocol.ts scripts/lib/provider-guardian-client.ts scripts/lib/provider-raw-guardian.ts tests/unit/provider-guardian-transaction.test.ts --max-warnings=0
node scripts/verify-documentation-readiness.ts
git diff --check
```
Require focused tests, Node/test TypeScript, affected ESLint, documentation
readiness, and whitespace verification to pass. Live systemd/bwrap fixtures
remain **NOT RUN** under the current approval limit.
- [ ] **Step 8: Review and commit round six**
Confirm the original workspace, `/tmp/task3-integration-mU4L7J2u`, and the
security-finalizer repository are unchanged; only the temporary `node_modules`
symlink is untracked. Commit production/tests/docs together above design commit
`0b1a1db` and report the isolated path, commit SHA, RED evidence, and fresh GREEN
evidence.
@@ -0,0 +1,82 @@
# Security Finalizer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Finalize a captured immutable candidate into one private random exact-five staging directory with strict v3 verification records and deterministic cleanup.
**Architecture:** `finalizeVerifiedPromotion` captures the archive, provider reports, and public keys before validation, evaluates only those captured bytes against archived local evidence, generates both v3 records in memory, and publishes five read-only files under a descriptor-relative runner-temp directory. The generated workflow consumes the returned staging path immediately and always invokes the token-bound cleanup CLI.
**Tech Stack:** Node.js 24, TypeScript, Zod, Vitest, bubblewrap-independent filesystem primitives, generated Gitea Actions YAML.
## Global Constraints
- Never accept pre-existing provider- or promotion-verification JSON as an input.
- Stage exactly archive, vulnerability report, provenance attestation, provider verification v3, and promotion verification v3.
- Use injected time and randomness for deterministic tests.
- Use a random runner-temp directory at mode `0700`, files at `0400`, and `O_EXCL | O_NOFOLLOW` creation.
- Do not claim that TypeScript closes the Gitea upload action pathname-reopen issue or guarantees `renameat2` semantics.
---
### Task 1: Exact-five finalizer contract
**Files:**
- Modify: `tests/unit/ci-artifact-contract.test.ts`
- Modify: `scripts/lib/promotion-stager.ts`
- Modify: `scripts/contracts/promotion-artifacts.ts`
**Interfaces:**
- Consumes: `finalizeVerifiedPromotion(input, dependencies)` with captured archive/report/key inputs.
- Produces: `{ stagingRoot, cleanupToken, files }` where `files` is the canonical exact-five name/digest list.
- [ ] **Step 1: Write failing tests** for no pre-existing records, strict distinct v3 roles, exact provider-record and local-assessment hashes, full run/source/candidate/nonces/key/trust bindings, key rotation, captured-source mutation, and no output on failures.
- [ ] **Step 2: Run RED:** `corepack pnpm exec vitest run tests/unit/ci-artifact-contract.test.ts -t "verified promotion finalizer" --maxWorkers=1` and retain the first contract failure.
- [ ] **Step 3: Implement minimal finalizer changes** so all validation and record generation consume captured bytes and both PASS records are created only after local/provider PASS.
- [ ] **Step 4: Run GREEN:** rerun the focused Vitest command and require zero failures.
### Task 2: Private staging and cleanup
**Files:**
- Modify: `tests/unit/ci-artifact-contract.test.ts`
- Modify: `scripts/lib/promotion-stager.ts`
- Modify: `scripts/cleanup-verified-promotion.ts`
**Interfaces:**
- Consumes: injected `randomBytes`, runner-temp root, cleanup token.
- Produces: descriptor-relative random staging at `0700`, exact files at `0400`, and token-bound cleanup.
- [ ] **Step 1: Write failing tests** for deterministic naming, modes, stable-path absence, exclusive no-follow creation, parent/leaf substitution, success cleanup, and failure cleanup.
- [ ] **Step 2: Run RED:** use the Task 1 focused Vitest command and retain the first filesystem-boundary failure.
- [ ] **Step 3: Implement minimal private publication and cleanup changes** using `/proc/self/fd` where available, bounded writes, identity rechecks, and removal of owned partial roots.
- [ ] **Step 4: Run GREEN:** rerun the focused Vitest command and require zero failures.
### Task 3: Workflow handoff
**Files:**
- Modify: `config/ci/gates.json`
- Modify: `scripts/contracts/ci-gates.ts`
- Modify: `scripts/stage-verified-promotion.ts`
- Modify: `.gitea/workflows/quality-gates.yml`
- Modify: `tests/unit/ci-workflow-generation.test.ts`
**Interfaces:**
- Consumes: finalizer step outputs `staging_root` and `cleanup_token`.
- Produces: setup, three downloads, finalizer, immediate non-`always()` exact-five upload, and `always()` cleanup ordering.
- [ ] **Step 1: Write/update failing workflow assertions** that reject standalone extraction, stable staging paths, missing `--ignore-scripts`, upload indirection, or cleanup ordering drift.
- [ ] **Step 2: Run RED:** `node scripts/generate-ci-workflow.ts --check` and the workflow snapshot test.
- [ ] **Step 3: Update the CI contract/config and regenerate YAML** with the finalizer output path and cleanup environment.
- [ ] **Step 4: Run GREEN:** require workflow byte check and snapshot test PASS.
### Task 4: Full verification and durable report
**Files:**
- Modify: `.superpowers/sdd/2026-08-01-quality-architecture-remediation/task-3-report.md`
**Interfaces:**
- Consumes: focused finalizer, provider, workflow, type, and lint evidence.
- Produces: durable RED/GREEN evidence and a commit-ready report without overclaiming platform handoff guarantees.
- [ ] **Step 1: Run verification:** focused finalizer/provider tests, `check:supply-chain:provider-fixtures`, workflow `--check`, `check:types`, and `lint`.
- [ ] **Step 2: Append exact RED/GREEN commands and outcomes** to the task report, including the remaining Gitea upload and `renameat2` limitations.
- [ ] **Step 3: Inspect diff/status** and report completion before committing.
@@ -0,0 +1,572 @@
# Test Assurance Frontend Capability Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extend `test-assurance-platform` so it is the sole authority for selecting, planning, executing, normalizing, evidencing, and assessing every frontend product suite, including tests bound to an immutable static-site candidate.
**Architecture:** The platform keeps v2 JVM documents readable, emits v3 documents for new work, and requires an explicit `SOURCE` or `ARTIFACT` phase plus exact input-artifact identities throughout the execution/evidence chain. A normalizer registry dispatches strict report adapters by capability. Each frontend capability has an independent catalog contract and readiness card; no aggregate frontend readiness is introduced.
**Tech Stack:** Java 21, Gradle Kotlin DSL, Jackson, JSON Schema 2020-12, Node.js 24.14.0, pnpm 11.17.0, TypeScript 7.0.2, Vitest 4.1.10, Playwright 1.62.0.
## Global Constraints
- Repository: `/home/donghyeon/workspace/desktop-server-git/test-assurance-platform`; every command runs from its isolated worktree root.
- Test assurance owns test meaning; it does not create Gitea jobs, allocate remote runners, publish builds, or promote releases.
- New frontend suites require `test-assurance.platform/v3`; there is no implicit phase or host-toolchain fallback.
- `requiredInputArtifacts` is an ordered canonical list of `{artifactId, mediaType, sha256}`. Paths are execution-local data and never artifact identity.
- v2 and v3 evidence cannot be bundled or assessed together.
- Missing, empty, oversized, malformed, symlinked, mismatched, zero-discovery, all-skipped, or retry-only-green evidence fails closed.
- The exact capability IDs are `unit-typescript-vitest`, `component-react-vitest`, `integration-http-msw`, `architecture-typescript`, `coverage-v8`, `e2e-playwright-chromium`, `e2e-playwright-firefox`, `e2e-playwright-webkit`, `accessibility-web`, and `visual-regression-web`.
---
### Task 1: Introduce the v3 artifact-bound execution identity
**Files:**
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/ApiVersion.java`
- Create: `modules/assurance-model/src/main/java/io/testassurance/model/ExecutionPhase.java`
- Create: `modules/assurance-model/src/main/java/io/testassurance/model/InputArtifact.java`
- Create: `modules/assurance-model/src/main/java/io/testassurance/model/ArtifactInputDeclaration.java`
- Create: `modules/assurance-model/src/main/java/io/testassurance/model/ArtifactSuiteTemplate.java`
- Create: `modules/assurance-model/src/main/java/io/testassurance/model/InputArtifactMap.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/SuiteDefinition.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/WorkItem.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/ExecutionRequest.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/ExecutionPlan.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/RawSuiteResult.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/RawResultSet.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/NormalizedSuiteResult.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/EvidenceBundle.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/Assessment.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/RepositoryManifest.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/CompiledPolicy.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/json/JsonReader.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/json/JsonWriter.java`
- Create: `modules/assurance-model/src/main/java/io/testassurance/model/LegacyV2Decoder.java`
- Modify: `modules/assurance-model/src/test/java/io/testassurance/model/ModelRoundTripTest.java`
- Modify: `modules/assurance-model/src/test/java/io/testassurance/model/RepositoryFixtures.java`
- Preserve unchanged: all 26 current v2 files under `schemas/*.schema.json`
- Create: 27 complete v3 wire schemas under `schemas/v3/`, one for every current kind plus `artifact-suite-template.schema.json`
- Create: `schemas/v3/input-artifact-map.schema.json` and register it as an execution-only v3 kind
- Modify: `modules/assurance-schema/src/main/java/io/testassurance/schema/SchemaRegistry.java`
- Modify: `modules/assurance-schema/src/test/java/io/testassurance/schema/SchemaRegistryTest.java`
- Modify: `tools/validate_package.py`
- Modify: `machine/example-schema-map.json`
- Modify: `machine/adversarial-schema-map.json`
- Create: `examples/v3/artifact-suite-template.json`
- Create: `examples/v3/input-artifact-map.json`
- Modify: `modules/assurance-schema/src/main/java/io/testassurance/schema/SchemaRegistry.java`
- Modify: `modules/assurance-schema/src/test/java/io/testassurance/schema/SchemaRegistryTest.java`
- Create: `conformance/adversarial/v3-artifact-suite-missing-input.json`
- Create: `conformance/adversarial/v3-source-suite-with-input.json`
- Create: `conformance/adversarial/v2-v3-evidence-mix.json`
**Interfaces:**
```java
public enum ExecutionPhase { SOURCE, ARTIFACT }
public record InputArtifact(String artifactId, String mediaType, String sha256) {
public InputArtifact {
Identifier.require(artifactId, "inputArtifact.artifactId");
Values.requireText(mediaType, "inputArtifact.mediaType");
Digests.require(sha256, "inputArtifact.sha256");
}
}
public record ArtifactInputDeclaration(String artifactId, String mediaType) {}
public record ArtifactSuiteTemplate(
String id,
String capabilityId,
List<String> command,
int caseTimeoutSeconds,
int suiteTimeoutSeconds,
List<String> requiredArtifacts,
List<String> environmentAllowlist,
List<ArtifactInputDeclaration> requiredInputArtifacts,
Optional<ObjectNode> parameters) {}
public final class ApiVersion {
public static final String V2 = "test-assurance.platform/v2";
public static final String V3 = "test-assurance.platform/v3";
public static final String CURRENT = V3;
}
```
All nine execution-chain records expose `apiVersion()`, `executionPhase()`, and `requiredInputArtifacts()`; `SOURCE` requires an empty input list and `ARTIFACT` requires at least one item. `JsonWriter.envelope(String kind, String apiVersion)` requires an explicit version. Existing `toJson()` methods remain v2-compatible; every new v3 output path calls `toJson(ApiVersion.V3)`. `LegacyV2Decoder` reads the complete v2 repository graph explicitly and ordinary v3 readers never guess a phase. `ExecutionRequest.applicationArtifactDigest` exists only in the frozen v2 decoder and is absent from v3.
In v3, `RawSuiteResult.artifacts` is `List<ArtifactReference>` rather than path strings. The executor computes each digest immediately after the child exits and before results become visible to normalization. This makes raw artifact identity, normalized references, and evidence chain equality directly verifiable.
The committed repository cannot know a future candidate digest. Therefore the approved design amendment adds `RepositoryManifest.artifactSuiteTemplateFiles`: those files declare only artifact ID and media type and are not executable wire `SuiteDefinition` documents. During artifact planning, testctl matches them to concrete `ExecutionRequest.requiredInputArtifacts` and materializes v3 `SuiteDefinition`/`WorkItem` values containing the actual SHA-256. The compiler rejects a digest or digest placeholder in a template. `LegacyV2Decoder` is the only API that maps an approved v2 JVM graph into the legacy SOURCE compatibility path.
- [ ] Add model and schema tests for valid SOURCE v3, valid materialized ARTIFACT v3, absent phase, blank digest, duplicate artifact ID, SOURCE with artifacts, ARTIFACT without artifacts, a template containing any SHA field, v2 JVM graph acceptance, v2 frontend rejection, v2/v3 evidence mixing rejection, and byte-identical frozen v2 schema files.
- [ ] Run:
```bash
./gradlew :modules:assurance-model:test :modules:assurance-schema:test
```
Expected: RED because `ExecutionPhase`, `InputArtifact`, explicit-version envelopes, and the v3 schema registry do not exist.
- [ ] Implement explicit-version codecs and a complete v3 schema set. `SchemaRegistry` keys every schema by `(apiVersion, kind)`. `tools/validate_package.py` validates 26 v2 wire schemas, 28 v3 wire/execution schemas, and 11 report schemas as separate inventories rather than one hard-coded total.
- [ ] Run `./gradlew :modules:assurance-model:test :modules:assurance-schema:test`; expected PASS.
- [ ] Commit:
```bash
git add modules/assurance-model modules/assurance-schema schemas conformance/adversarial tools/validate_package.py machine examples/v3
git commit -m "feat(contracts): add artifact-bound execution v3"
```
---
### Task 2: Propagate phase and input identity through compile, selection, and planning
**Files:**
- Modify: `modules/assurance-compiler/src/main/java/io/testassurance/compiler/PolicyCompiler.java`
- Modify: `modules/assurance-compiler/src/test/java/io/testassurance/compiler/PolicyCompilerTest.java`
- Modify: `modules/assurance-selector/src/main/java/io/testassurance/selector/ChangeSelector.java`
- Modify: `modules/assurance-selector/src/test/java/io/testassurance/selector/ChangeSelectorTest.java`
- Modify: `modules/assurance-plan/src/main/java/io/testassurance/plan/PlanEngine.java`
- Modify: `modules/assurance-plan/src/main/java/io/testassurance/plan/ResourceProfiles.java`
- Modify: `modules/assurance-plan/src/test/java/io/testassurance/plan/PlanEngineTest.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/CompiledPolicy.java`
- Modify: `modules/assurance-model/src/main/java/io/testassurance/model/SelectionResult.java`
**Contract:** The compiler rejects any new frontend capability on v2. The selector preserves the suite phase without changing selected obligations. The planner copies the exact ordered artifact tuple from the request into every selected ARTIFACT work item and rejects a request whose tuple differs from the suite declaration. Plan digest calculation includes phase and artifact tuple.
`PolicyCompiler` stores source `SuiteDefinition` values and artifact `ArtifactSuiteTemplate` values separately. `PlanEngine` exposes `materializeArtifactSuite(ArtifactSuiteTemplate, ExecutionRequest)`; it requires an exact one-to-one match on artifact ID/media type, supplies the request SHA-256, and returns an executable v3 `SuiteDefinition`. No template or unresolved artifact can be serialized into `ExecutionPlan`.
- [ ] Add RED tests named `frontendCapabilityRequiresV3`, `artifactTemplateRejectsDigestPlaceholder`, `artifactTemplateMaterializesConcreteSuite`, `selectorPreservesExecutionPhase`, `artifactPlanRejectsWrongCandidateDigest`, `sourcePlanRejectsArtifactInput`, and `planDigestChangesWithArtifactDigest`.
- [ ] Run:
```bash
./gradlew :modules:assurance-compiler:test :modules:assurance-selector:test :modules:assurance-plan:test
```
Expected: RED because the current compiler and planner do not carry phase/input identity.
- [ ] Implement exact propagation. Do not infer phase from capability name or from `applicationArtifactDigest`.
- [ ] Run `./gradlew :modules:assurance-compiler:test :modules:assurance-selector:test :modules:assurance-plan:test`; expected PASS.
- [ ] Commit:
```bash
git add modules/assurance-compiler modules/assurance-selector modules/assurance-plan modules/assurance-model
git commit -m "feat(plan): preserve source and artifact execution identity"
```
---
### Task 3: Register the frontend capability family and immutable toolchains
**Files:**
- Modify: `machine/capability-catalog.json`
- Modify: `machine/toolchain-catalog.json`
- Modify: `gradle/libs.versions.toml`
- Modify: `modules/assurance-catalog/src/main/java/io/testassurance/catalog/CapabilityCatalog.java`
- Modify: `modules/assurance-catalog/src/main/java/io/testassurance/catalog/CapabilityContract.java`
- Modify: `modules/assurance-catalog/src/main/java/io/testassurance/catalog/ToolchainCatalog.java`
- Create: `modules/assurance-catalog/src/main/java/io/testassurance/catalog/FrontendToolchainInspector.java`
- Create: `modules/assurance-catalog/src/test/java/io/testassurance/catalog/FrontendToolchainInspectorTest.java`
- Modify: `modules/assurance-catalog/src/test/java/io/testassurance/catalog/CatalogsTest.java`
- Modify: `modules/assurance-catalog/src/test/java/io/testassurance/catalog/ReadinessCardsTest.java`
- Modify: `modules/assurance-plan/src/main/java/io/testassurance/plan/ResourceProfiles.java`
- Modify: `modules/testctl-cli/src/main/java/io/testassurance/cli/Commands.java`
- Modify: `modules/testctl-cli/src/test/java/io/testassurance/cli/TestctlMainTest.java`
- Modify: `tools/validate_package.py`
- Modify: `docs/04-test-type-contracts.md`
- Modify: `README.md`
- Create: `readiness/unit-typescript-vitest.json`
- Create: `readiness/component-react-vitest.json`
- Create: `readiness/integration-http-msw.json`
- Create: `readiness/architecture-typescript.json`
- Create: `readiness/coverage-v8.json`
- Modify: `readiness/e2e-playwright-chromium.json`
- Create: `readiness/e2e-playwright-firefox.json`
- Create: `readiness/e2e-playwright-webkit.json`
- Create: `readiness/accessibility-web.json`
- Create: `readiness/visual-regression-web.json`
**Catalog entries:** The catalog grows from 16 to 25 capabilities: nine new entries plus the upgraded existing Chromium entry form the ten-capability frontend family. Every capability declares contract revision v3, execution phase, required raw artifact IDs, case/suite timeout bounds, isolation/resource claims, maximum evidence age, false-green rule IDs, and minimum readiness. Toolchain entries pin version plus observed SHA-256 for Node.js 24.14.0, pnpm 11.17.0, TypeScript 7.0.2, Vitest 4.1.10, Playwright 1.62.0, and independent Chromium/Firefox/WebKit payload IDs.
`FrontendToolchainInspector` reads `.nvmrc`, `package.json.packageManager`, exact devDependency versions, and the root importer in `pnpm-lock.yaml`. It rejects semver ranges, workspace/host fallbacks, missing artifact digests, and lock resolution drift with `TA-LOCK-002`/`TA-LOCK-003`. `Commands.lock()` uses this inspector for any selected frontend capability.
| Capability | Phase | Required raw artifacts |
|---|---|---|
| `unit-typescript-vitest` | SOURCE | JUnit XML, Vitest discovery JSON |
| `component-react-vitest` | SOURCE | JUnit XML, Vitest discovery JSON |
| `integration-http-msw` | SOURCE | JUnit XML, Vitest discovery JSON, typed HTTP scenario receipts |
| `architecture-typescript` | SOURCE | TypeScript architecture graph/violations JSON |
| `coverage-v8` | SOURCE | V8 coverage summary, production-module inventory |
| `e2e-playwright-chromium` | ARTIFACT | Playwright report, provider identity, trace/screenshot/console/network indexes, mutation receipts |
| `e2e-playwright-firefox` | ARTIFACT | Playwright report, provider identity, trace/screenshot/console/network indexes, mutation receipts |
| `e2e-playwright-webkit` | ARTIFACT | Playwright report, provider identity, trace/screenshot/console/network indexes, mutation receipts |
| `accessibility-web` | ARTIFACT | Playwright report, provider identity, accessibility findings, manual-review records |
| `visual-regression-web` | ARTIFACT | Playwright report, provider identity, baseline identity, image-diff results |
- [ ] Add tests that require exactly 25 catalog capabilities and one readiness card per capability, and fail on a missing digest, Node 22 fallback, semver range, pnpm lock drift, capability aliasing, a combined browser readiness card, or missing Firefox/WebKit resource profile.
- [ ] Run:
```bash
./gradlew :modules:assurance-catalog:test :modules:assurance-plan:test
python3 tools/validate_package.py
```
Expected: RED because the capabilities and toolchains are absent.
- [ ] Add independent catalog/resource entries and keep all nine new cards plus Chromium at R0 with explicit `nonGuarantees`. R1 is set only in Task 10 after real adapter and conformance evidence exists.
- [ ] Run `./gradlew :modules:assurance-catalog:test :modules:assurance-plan:test` and `python3 tools/validate_package.py`; expected PASS.
- [ ] Commit:
```bash
git add machine readiness gradle/libs.versions.toml modules/assurance-catalog modules/assurance-plan modules/testctl-cli tools/validate_package.py docs/04-test-type-contracts.md README.md
git commit -m "feat(catalog): register frontend assurance capabilities"
```
---
### Task 4: Replace hard-coded JUnit normalization with a strict registry
**Files:**
- Modify: `modules/assurance-normalizer/src/main/java/io/testassurance/normalizer/SuiteResultNormalizer.java`
- Modify: `modules/assurance-normalizer/src/main/java/io/testassurance/normalizer/NormalizationContext.java`
- Create: `modules/assurance-normalizer/src/main/java/io/testassurance/normalizer/NormalizerRegistry.java`
- Create: `modules/assurance-normalizer/src/main/java/io/testassurance/normalizer/NormalizationArtifactReader.java`
- Create: `modules/assurance-normalizer/src/test/java/io/testassurance/normalizer/NormalizerRegistryTest.java`
- Create: `modules/assurance-normalizer/src/test/java/io/testassurance/normalizer/NormalizationArtifactReaderTest.java`
- Create: `modules/assurance-schema/src/main/java/io/testassurance/schema/ReportSchemaRegistry.java`
- Create: `modules/assurance-schema/src/test/java/io/testassurance/schema/ReportSchemaRegistryTest.java`
- Create report schemas under `schemas/reports/`: `vitest-discovery-report.schema.json`, `http-scenario-receipt-set.schema.json`, `v8-coverage-summary.schema.json`, `production-module-inventory.schema.json`, `typescript-architecture-report.schema.json`, `playwright-suite-report.schema.json`, `browser-provider-report.schema.json`, `browser-mutation-receipt-set.schema.json`, `accessibility-report.schema.json`, `accessibility-manual-review.schema.json`, `visual-regression-report.schema.json`
- Modify: `modules/assurance-schema/build.gradle.kts`
- Modify: `modules/testctl-cli/src/main/java/io/testassurance/cli/Commands.java`
- Modify: `modules/testctl-cli/src/test/java/io/testassurance/cli/TestctlMainTest.java`
- Modify: `modules/testctl-cli/build.gradle.kts`
**Interfaces:**
```java
public interface SuiteResultNormalizer {
String adapterId();
Set<String> capabilityIds();
NormalizedSuiteResult normalize(NormalizationContext context);
}
public final class NormalizerRegistry {
public NormalizerRegistry(Collection<SuiteResultNormalizer> normalizers);
public SuiteResultNormalizer requireFor(String capabilityId);
}
public final class NormalizationArtifactReader {
public NormalizationArtifactReader(ReportSchemaRegistry schemas);
public byte[] readRequired(Path outputRoot, String repositoryRelativePath, long maximumBytes);
public JsonNode readRequiredJson(Path outputRoot, String repositoryRelativePath, long maximumBytes, ReportSchemaRegistry.ReportKind reportKind);
public ArtifactReference reference(Path outputRoot, String repositoryRelativePath, String mediaType, long maximumBytes);
}
```
The registry rejects duplicate adapter IDs, duplicate capability ownership, and zero matches. `Commands.normalize()` asks the registry for exactly one adapter and no longer constructs `JunitXmlNormalizer` directly. The common reader uses `NOFOLLOW_LINKS`, checks a regular file before reading, bounds bytes, rejects empty or malformed UTF-8, canonicalizes repository-relative paths, and validates JSON against `ReportSchemaRegistry` before adapters inspect fields.
- [ ] Add RED tests for no match, two owners, stable registration order, adapter exception mapping, preservation of v3 input artifacts, path escape, symlink, empty file, oversized file, malformed UTF-8, unknown report schema, and `additionalProperties` rejection.
- [ ] Run:
```bash
./gradlew :modules:assurance-normalizer:test :modules:testctl-cli:test
```
Expected: RED because dispatch is hard-coded.
- [ ] Implement registry wiring with explicit constructors in `testctl-cli`; do not use classpath scanning.
- [ ] Run `./gradlew :modules:assurance-normalizer:test :modules:testctl-cli:test`; expected PASS.
- [ ] Commit:
```bash
git add modules/assurance-normalizer modules/assurance-schema modules/testctl-cli schemas/reports
git commit -m "refactor(normalizer): dispatch by capability contract"
```
---
### Task 5: Normalize Vitest results and V8 production coverage
**Files:**
- Create: `adapters/vitest/build.gradle.kts`
- Create: `adapters/vitest/src/main/java/io/testassurance/adapter/vitest/VitestJunitNormalizer.java`
- Create: `adapters/vitest/src/main/java/io/testassurance/adapter/vitest/VitestDiscoveryReport.java`
- Create: `adapters/vitest/src/test/java/io/testassurance/adapter/vitest/VitestJunitNormalizerTest.java`
- Create fixture directories: `adapters/vitest/src/test/resources/valid/`, `adapters/vitest/src/test/resources/zero-tests/`, `adapters/vitest/src/test/resources/all-skipped/`, `adapters/vitest/src/test/resources/retry-green/`, `adapters/vitest/src/test/resources/missing-discovery/`, `adapters/vitest/src/test/resources/malformed/`, `adapters/vitest/src/test/resources/oversized/`, `adapters/vitest/src/test/resources/symlink/`
- Create: `adapters/v8-coverage/build.gradle.kts`
- Create: `adapters/v8-coverage/src/main/java/io/testassurance/adapter/coverage/V8CoverageNormalizer.java`
- Create: `adapters/v8-coverage/src/main/java/io/testassurance/adapter/coverage/ProductionModuleInventory.java`
- Create: `adapters/v8-coverage/src/test/java/io/testassurance/adapter/coverage/V8CoverageNormalizerTest.java`
- Create fixture directories: `adapters/v8-coverage/src/test/resources/valid/`, `adapters/v8-coverage/src/test/resources/missing-module/`, `adapters/v8-coverage/src/test/resources/zero-universe/`, `adapters/v8-coverage/src/test/resources/path-mismatch/`, `adapters/v8-coverage/src/test/resources/malformed/`, `adapters/v8-coverage/src/test/resources/oversized/`, `adapters/v8-coverage/src/test/resources/symlink/`
- Modify: `settings.gradle.kts`
- Modify: `build.gradle.kts`
- Modify: `modules/testctl-cli/build.gradle.kts`
- Modify: `modules/testctl-cli/src/main/java/io/testassurance/cli/Commands.java`
**Module dependencies:** Both modules expose the model/normalizer APIs, implement report schema support, and are added to root `javaModules`. Vitest additionally depends on `:adapters:junit-gradle` to reuse secure XML parsing.
**Rules:** `VitestJunitNormalizer` owns only `unit-typescript-vitest` and `component-react-vitest`; it exports `VitestReportReader` for HTTP composition. `V8CoverageNormalizer` alone owns `coverage-v8`. Missing production module is `INCOMPLETE_DELETED_TEST`; non-empty inventory with zero counter-bearing modules is `INCOMPLETE_ZERO_TESTS`; summary/inventory count or digest mismatch is `INVALID_RESULT`; configured threshold miss is `FAIL_PRODUCT`.
- [ ] Write all adapter tests before implementation and confirm RED:
```bash
./gradlew :adapters:vitest:test :adapters:v8-coverage:test
```
- [ ] Implement bounded regular-file reads, fatal UTF-8, secure XML, canonical repository-relative paths, cross-file count reconciliation, and no symlink following.
- [ ] Register the three capability owners in the CLI registry.
- [ ] Re-run focused tests plus `:modules:testctl-cli:test`; expected PASS.
- [ ] Commit:
```bash
git add adapters/vitest adapters/v8-coverage settings.gradle.kts build.gradle.kts modules/testctl-cli
git commit -m "feat(normalizers): add vitest and v8 coverage evidence"
```
---
### Task 6: Normalize HTTP scenarios and TypeScript architecture
**Files:**
- Create: `adapters/http-scenario/build.gradle.kts`
- Create: `adapters/http-scenario/src/main/java/io/testassurance/adapter/http/HttpScenarioNormalizer.java`
- Create: `adapters/http-scenario/src/test/java/io/testassurance/adapter/http/HttpScenarioNormalizerTest.java`
- Create fixture directories: `adapters/http-scenario/src/test/resources/valid/`, `adapters/http-scenario/src/test/resources/missing-receipt/`, `adapters/http-scenario/src/test/resources/duplicate-receipt/`, `adapters/http-scenario/src/test/resources/unknown-scenario/`, `adapters/http-scenario/src/test/resources/status-mismatch/`, `adapters/http-scenario/src/test/resources/malformed/`
- Create: `adapters/typescript-architecture/build.gradle.kts`
- Create: `adapters/typescript-architecture/src/main/java/io/testassurance/adapter/architecture/TypeScriptArchitectureNormalizer.java`
- Create: `adapters/typescript-architecture/src/test/java/io/testassurance/adapter/architecture/TypeScriptArchitectureNormalizerTest.java`
- Create fixture directories: `adapters/typescript-architecture/src/test/resources/valid/`, `adapters/typescript-architecture/src/test/resources/empty-graph/`, `adapters/typescript-architecture/src/test/resources/unresolved-import/`, `adapters/typescript-architecture/src/test/resources/cycle/`, `adapters/typescript-architecture/src/test/resources/violation/`, `adapters/typescript-architecture/src/test/resources/path-escape/`, `adapters/typescript-architecture/src/test/resources/malformed/`
- Modify: `settings.gradle.kts`
- Modify: `build.gradle.kts`
- Modify: `modules/testctl-cli/build.gradle.kts`
- Modify: `modules/testctl-cli/src/main/java/io/testassurance/cli/Commands.java`
**Module dependencies:** Both modules are added to root `javaModules`; HTTP depends on `:adapters:vitest`, and both depend on model, normalizer, and schema modules.
**Typed inputs:** `HttpScenarioNormalizer` is the sole owner of `integration-http-msw` and composes `VitestReportReader`. It compares exact declared/executed IDs; a missing receipt is `INCOMPLETE_DELETED_TEST`, duplicate/unknown receipt or catalog mismatch is `INVALID_RESULT`, and unmatched/catch-all behavior is `FAIL_TEST`. Architecture requires non-empty modules/dependencies; empty graph is `INCOMPLETE_ZERO_TESTS`, malformed/parser mismatch is `ERROR_TOOLING` or `INVALID_RESULT`, and unresolved import/cycle/rule violation is `FAIL_PRODUCT`.
- [ ] Add fixtures/tests and run focused Gradle tests; expected RED.
- [ ] Implement bounded `ReportSchemaRegistry` parsing, exact scenario/graph cross-field reconciliation, and sole capability ownership for both adapters.
- [ ] Re-run focused tests and CLI tests; expected PASS.
- [ ] Commit:
```bash
git add adapters/http-scenario adapters/typescript-architecture settings.gradle.kts build.gradle.kts modules/testctl-cli
git commit -m "feat(normalizers): add http and architecture evidence"
```
---
### Task 7: Upgrade Playwright into three independent browser capabilities
**Files:**
- Create: `adapters/playwright/src/main/java/io/testassurance/adapter/playwright/PlaywrightResultNormalizer.java`
- Create: `adapters/playwright/src/main/java/io/testassurance/adapter/playwright/BrowserArtifactIndex.java`
- Create: `adapters/playwright/src/main/java/io/testassurance/adapter/playwright/MutationReceipt.java`
- Modify: `adapters/playwright/src/main/java/io/testassurance/adapter/playwright/BrowserRunPolicy.java`
- Create: `adapters/playwright/src/test/java/io/testassurance/adapter/playwright/PlaywrightResultNormalizerTest.java`
- Create fixture directories: `adapters/playwright/src/test/resources/chromium/`, `adapters/playwright/src/test/resources/firefox/`, `adapters/playwright/src/test/resources/webkit/`, `adapters/playwright/src/test/resources/missing-project/`, `adapters/playwright/src/test/resources/retry-green/`, `adapters/playwright/src/test/resources/zero-tests/`, `adapters/playwright/src/test/resources/missing-trace/`, `adapters/playwright/src/test/resources/console-error/`, `adapters/playwright/src/test/resources/network-error/`, `adapters/playwright/src/test/resources/valid-mutation/`, `adapters/playwright/src/test/resources/missing-mutation-read/`, `adapters/playwright/src/test/resources/malformed/`
- Modify: `modules/testctl-cli/src/main/java/io/testassurance/cli/Commands.java`
**Rules:** The selected browser capability must match the report project exactly. Required trace/screenshot/console/network indexes bind repository-relative files by digest. Retry-only pass is FLAKY, not PASS. Write journeys require observed response, mutation read, and reload read receipts bound to the same scenario/candidate.
- [ ] Add RED tests for each browser and every false-green case.
- [ ] Run:
```bash
./gradlew :adapters:playwright:test
```
Expected: RED because only policy helpers exist.
- [ ] Implement one normalizer parameterized by three independently registered capability IDs; do not aggregate readiness or results.
- [ ] Run focused and CLI tests; expected PASS.
- [ ] Commit:
```bash
git add adapters/playwright modules/testctl-cli
git commit -m "feat(playwright): normalize independent browser evidence"
```
---
### Task 8: Add accessibility and visual-regression evidence
**Files:**
- Create: `adapters/accessibility-web/build.gradle.kts`
- Create: `adapters/accessibility-web/src/main/java/io/testassurance/adapter/accessibility/AccessibilityNormalizer.java`
- Create: `adapters/accessibility-web/src/test/java/io/testassurance/adapter/accessibility/AccessibilityNormalizerTest.java`
- Create fixture directories: `adapters/accessibility-web/src/test/resources/valid/`, `adapters/accessibility-web/src/test/resources/violation/`, `adapters/accessibility-web/src/test/resources/missing-provider/`, `adapters/accessibility-web/src/test/resources/missing-manual-review/`, `adapters/accessibility-web/src/test/resources/expired-review/`, `adapters/accessibility-web/src/test/resources/malformed/`
- Create: `adapters/visual-regression-web/build.gradle.kts`
- Create: `adapters/visual-regression-web/src/main/java/io/testassurance/adapter/visual/VisualRegressionNormalizer.java`
- Create: `adapters/visual-regression-web/src/test/java/io/testassurance/adapter/visual/VisualRegressionNormalizerTest.java`
- Create fixture directories: `adapters/visual-regression-web/src/test/resources/valid/`, `adapters/visual-regression-web/src/test/resources/diff/`, `adapters/visual-regression-web/src/test/resources/missing-baseline/`, `adapters/visual-regression-web/src/test/resources/wrong-baseline-digest/`, `adapters/visual-regression-web/src/test/resources/missing-provider/`, `adapters/visual-regression-web/src/test/resources/malformed/`
- Modify: `settings.gradle.kts`
- Modify: `build.gradle.kts`
- Modify: `modules/testctl-cli/build.gradle.kts`
- Modify: `modules/testctl-cli/src/main/java/io/testassurance/cli/Commands.java`
**Module dependencies:** Both modules are added to root `javaModules` and depend on model, normalizer, schema, and Playwright evidence types.
**Rules:** Accessibility evidence carries scanner/provider identity, rule-set digest, findings, and required manual-review records. Manual records require `reviewedAt`, `expiresAt`, `sourceRevision`, `candidateSha256`, `reviewerId`, and `rulesetDigest`; expiry is evaluated against the raw result's `finishedAt`, never wall-clock time. Visual evidence carries baseline ID/digest, provider identity, current image digest, diff digest, and threshold outcome. Absent identities can never PASS.
- [ ] Add RED tests `rejectsAccessibilityWithoutProvider`, `rejectsExpiredManualReviewAtFinishedAt`, `reportsUnwaivedAccessibilityViolation`, `rejectsVisualWithoutBaseline`, `rejectsWrongVisualProvider`, and `reportsVisualDiffOverThreshold`, each backed by the named fixture directory above.
- [ ] Implement and register both adapters.
- [ ] Run adapter and CLI tests; expected PASS.
- [ ] Commit:
```bash
git add adapters/accessibility-web adapters/visual-regression-web settings.gradle.kts build.gradle.kts modules/testctl-cli
git commit -m "feat(normalizers): add accessibility and visual evidence"
```
---
### Task 9: Enforce artifact materialization in bounded execution
**Files:**
- Modify: `modules/assurance-executor/src/main/java/io/testassurance/executor/WorkItemExecutor.java`
- Modify: `modules/assurance-executor/src/main/java/io/testassurance/executor/LocalProcessExecutor.java`
- Create: `modules/assurance-executor/src/main/java/io/testassurance/executor/InputArtifactVerifier.java`
- Modify: `modules/assurance-executor/src/test/java/io/testassurance/executor/LocalProcessExecutorTest.java`
- Modify: `modules/testctl-cli/src/main/java/io/testassurance/cli/Commands.java`
- Modify: `modules/testctl-cli/src/test/java/io/testassurance/cli/TestctlMainTest.java`
- Modify: `docs/06-testctl-external-executor-contract.md`
**External contract:** `testctl execute-one` retains `--plan`, `--work-item-id`, and `--output`, and v3 adds `--artifact-map`. The artifact map resolves artifact IDs to local regular archive files, while expected media type and SHA-256 come only from the signed v3 work item. Verification occurs before the product command starts.
The compatible public syntax is:
```text
testctl execute-one --plan <plan.json> --work-item-id <sha256> --artifact-map <input-artifact-map.json> --output <work-dir>
testctl execute-plan --plan <plan.json> --artifact-map <input-artifact-map.json> --output <run-dir>
```
SOURCE plans reject `--artifact-map`; ARTIFACT plans require it. V3 maps only regular archive files—directories must already be represented by a canonical archive subject. `InputArtifactVerifier` copies each file into a private staging directory while hashing it, then exports only the verified copy path to the child.
- [ ] Add RED tests for missing artifact, wrong digest, wrong media type, symlink, path escape, directory input, mutation between verification and use, SOURCE receiving an artifact map, ARTIFACT missing a map, unknown work-item ID, and work item not belonging to the plan digest.
- [ ] Run executor and CLI tests; expected RED.
- [ ] Implement verification with opened handles or verified private copies, bounded hashing, and exact environment projection; never trust a caller-supplied digest.
- [ ] Re-run tests; expected PASS.
- [ ] Commit:
```bash
git add modules/assurance-executor modules/testctl-cli docs/06-testctl-external-executor-contract.md
git commit -m "feat(executor): verify artifact-bound work inputs"
```
---
### Task 10: Prove the v3 chain, frontend fixture, and independent R1 readiness
**Files:**
- Modify: `modules/assurance-normalizer/src/main/java/io/testassurance/normalizer/FalseGreenRules.java`
- Modify: `modules/assurance-evidence/src/main/java/io/testassurance/evidence/EvidenceBundler.java`
- Modify: `modules/assurance-evidence/src/test/java/io/testassurance/evidence/EvidenceBundlerTest.java`
- Modify: `modules/assurance-assessor/src/main/java/io/testassurance/assessor/Assessor.java`
- Modify: `modules/assurance-assessor/src/test/java/io/testassurance/assessor/AssessorTest.java`
- Create: `modules/assurance-catalog/src/main/java/io/testassurance/catalog/ReadinessCatalog.java`
- Create: `modules/assurance-catalog/src/test/java/io/testassurance/catalog/ReadinessCatalogTest.java`
- Modify: `modules/assurance-catalog/build.gradle.kts`
- Modify: `modules/testctl-cli/src/main/java/io/testassurance/cli/ConformanceRunner.java`
- Modify: `modules/testctl-cli/src/main/java/io/testassurance/cli/Commands.java`
- Modify: `modules/testctl-cli/src/test/java/io/testassurance/cli/TestctlMainTest.java`
- Create: `conformance/golden-v3/source/case.json`
- Create: `conformance/golden-v3/source/01-execution-request.json`
- Create: `conformance/golden-v3/source/02-execution-plan.json`
- Create: `conformance/golden-v3/source/03-raw-result-set.json`
- Create: `conformance/golden-v3/source/normalized.json`
- Create: `conformance/golden-v3/source/04-evidence-bundle.json`
- Create: `conformance/golden-v3/source/05-assessment.json`
- Create: `conformance/golden-v3/artifact/case.json`
- Create: `conformance/golden-v3/artifact/01-execution-request.json`
- Create: `conformance/golden-v3/artifact/02-execution-plan.json`
- Create: `conformance/golden-v3/artifact/03-raw-result-set.json`
- Create: `conformance/golden-v3/artifact/normalized.json`
- Create: `conformance/golden-v3/artifact/04-evidence-bundle.json`
- Create: `conformance/golden-v3/artifact/05-assessment.json`
- Create: `conformance/adversarial/v3-wrong-candidate-normalized-result.json`
- Create: `conformance/adversarial/v3-missing-browser-result.json`
- Create: `fixtures/frontend-reference/test-assurance.yaml`
- Create: `fixtures/frontend-reference/test-assurance.lock.json`
- Create: `fixtures/frontend-reference/.nvmrc`
- Create: `fixtures/frontend-reference/package.json`
- Create: `fixtures/frontend-reference/pnpm-lock.yaml`
- Create: `fixtures/frontend-reference/config/test-assurance/risks/frontend.json`
- Create: `fixtures/frontend-reference/config/test-assurance/obligations/frontend.json`
- Create: `fixtures/frontend-reference/config/test-assurance/suites/source.json`
- Create: `fixtures/frontend-reference/config/test-assurance/suites/artifact-templates.json`
- Create: `fixtures/frontend-reference/config/test-assurance/change-surfaces.json`
- Create: `fixtures/frontend-reference/artifacts/frontend-static.tar`
- Create: `fixtures/frontend-reference/results/source-valid/raw-result-set.json`
- Create: `fixtures/frontend-reference/results/source-valid/normalized.json`
- Create: `fixtures/frontend-reference/results/artifact-valid/raw-result-set.json`
- Create: `fixtures/frontend-reference/results/artifact-valid/normalized.json`
- Create: `fixtures/frontend-reference/results/adversarial-zero-discovery/raw-result-set.json`
- Create: `fixtures/frontend-reference/results/adversarial-missing-browser/raw-result-set.json`
- Create: `fixtures/frontend-reference/results/adversarial-wrong-candidate/raw-result-set.json`
- Modify: all ten frontend files under `readiness/`
- Modify: `docs/03-manifest-schema-contracts.md`
- Modify: `docs/04-test-type-contracts.md`
- Modify: `docs/06-testctl-external-executor-contract.md`
- Modify: `docs/07-evidence-flaky-contract.md`
- Modify: `docs/09-implementation-handoff-contract.md`
- Modify: `README.md`
- Modify: `validation.json`
**Identity rule:** Bundling requires exact equality of API major, request digest, plan digest, execution phase, ordered input artifacts, work-item IDs, attempt identities, and raw artifact digests. Assessment emits the same tuple plus `readinessDigest` and never converts an integrity error into an unsatisfied product obligation. `ReadinessCatalog` packages the independent cards into the distribution; `Commands.assess()` supplies it to `AssessmentInputs`, and the assessor applies the higher of capability-contract minimum and obligation minimum readiness.
- [ ] Add RED chain tests for v2/v3 mixing, source/artifact mixing, wrong candidate, missing work item, duplicate attempt, retry-only green, absent terminal result, empty readiness map, readiness digest drift, and an aggregate frontend readiness card.
- [ ] Run:
```bash
./gradlew :modules:assurance-evidence:test :modules:assurance-assessor:test :modules:assurance-catalog:test :modules:testctl-cli:test
```
Expected: RED until the entire tuple is checked.
- [ ] Implement fail-closed chain verification, readiness binding, and a `ConformanceRunner` case format that validates the five canonical stages plus listed `normalizedFiles`.
- [ ] Build the frontend fixture with all ten capabilities. Compute the artifact SHA from the committed `frontend-static.tar` bytes and use it consistently in artifact request, materialized suites, plan, raw results, normalized results, bundle, and assessment; no repeated-character or hand-entered digest is allowed.
- [ ] Run focused tests; expected PASS.
- [ ] Install the CLI and run both golden cases:
```bash
./gradlew :modules:testctl-cli:installDist
modules/testctl-cli/build/install/testctl-cli/bin/testctl conformance --case conformance/golden-v3/source/case.json
modules/testctl-cli/build/install/testctl-cli/bin/testctl conformance --case conformance/golden-v3/artifact/case.json
```
- [ ] Run the fixture twice, once for SOURCE and once for ARTIFACT, through `validate → lock → compile → select → plan → normalize → bundle → assess`; assert five selected source capability IDs and five selected artifact capability IDs with separate plan/evidence/assessment digests.
- [ ] Update each frontend card from R0 to R1 only when its own adapter fixture and chain evidence digest exists. Keep missing evidence at R0 with explicit `nonGuarantees`.
- [ ] Add documentation checks naming every capability, exact toolchain pin, v3 CLI argument, phase/template/materialization rule, false-green rule, readiness digest, and v2 compatibility limit.
- [ ] Run full verification:
```bash
./gradlew clean build
python3 tools/validate_package.py
git diff --check
```
Expected: PASS. If a toolchain artifact cannot be observed locally, keep affected cards at R0; do not fabricate a digest.
- [ ] Commit:
```bash
git add modules/assurance-normalizer modules/assurance-evidence modules/assurance-assessor modules/assurance-catalog modules/testctl-cli conformance fixtures/frontend-reference readiness docs README.md validation.json
git commit -m "test(conformance): publish frontend v3 readiness"
```
## Handoff to CI/CD
The consuming CI/CD plan may begin only from the immutable commit produced by Task 11. It imports the v3 schemas and CLI distribution by digest, invokes only documented argument-array commands, treats normalized/evidence/assessment documents as opaque test semantics, and carries both source and artifact plan/evidence/assessment digests into release identity.
@@ -0,0 +1,176 @@
# V8 Coverage Counter Contract Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Publish risk-coverage artifact schema version 3 and continuously verify the installed Vitest/V8 producer's counter-bearing/counterless row semantics in an isolated child run.
**Architecture:** A real CLI contract test owns the serialized artifact assertion. A standalone producer checker copies fixed source templates into one OS-temp root, creates its child config and report there, validates an exact JSON summary, bounds child diagnostics, and removes the owned root in `finally`. `test:coverage` invokes the checker before repository coverage, which also carries it into CI and sample removal.
**Tech Stack:** TypeScript 7, Node.js 24 child processes and filesystem APIs, Vitest 4, V8 coverage.
## Global Constraints
- Policy schema remains version 2; serialized risk-coverage artifact schema becomes version 3.
- Child root, config, and reports directory are all below one owned OS temporary directory.
- Main Vitest must not discover the child `.fixture.ts` file.
- Child exit, summary absence, malformed/missing/additional rows, counterless nonzero drift, and runtime all-zero drift fail closed.
- Child stdout/stderr included in diagnostics is bounded.
- Cleanup uses `finally` and targets only the exact owned temporary root.
- Source edits use `apply_patch`; behavior changes follow RED-GREEN TDD.
---
### Task 1: Version the real serialized artifact
**Files:**
- Modify: `tests/unit/risk-coverage.test.ts`
- Modify: `scripts/check-risk-coverage.ts`
**Interfaces:**
- Consumes: the real `check-risk-coverage.ts` CLI, current policy structure, and an exact temporary coverage summary.
- Produces: serialized artifact schema version 3 with `counterBearingTotal`, `instrumentedCounterBearingTotal`, `counterlessTotal`, and `counterlessModules` only.
- [x] **Step 1: Add the actual CLI serialization contract test.**
Create a temporary repository with the 19 current policy paths, write each as `export const covered = true`, set the cloned policy baseline to 19, write one full counter row per module, run the CLI with `process.execPath`, and assert:
```ts
expect(artifact).toMatchObject({
schemaVersion: 3,
counterBearingTotal: 19,
instrumentedCounterBearingTotal: 19,
counterlessTotal: 0,
counterlessModules: [],
});
expect(artifact).not.toHaveProperty("executableTotal");
expect(artifact).not.toHaveProperty("instrumentedExecutableTotal");
expect(artifact).not.toHaveProperty("nonExecutableTotal");
expect(artifact).not.toHaveProperty("nonExecutableModules");
```
- [x] **Step 2: Run the single test and verify RED.**
Run: `./node_modules/.bin/vitest run tests/unit/risk-coverage.test.ts -t "publishes artifact schema version 3" --reporter=dot`
Expected: FAIL because the actual artifact contains `schemaVersion: 2`.
- [x] **Step 3: Change only the serialized envelope to version 3.**
Change `schemaVersion: 2` to `schemaVersion: 3` in the value passed to `writeRiskCoverageArtifactAtomic`; do not change policy parsing.
- [x] **Step 4: Re-run the single test and verify GREEN.**
Run the Step 2 command and expect one passing test.
### Task 2: Lock actual Vitest/V8 counter semantics
**Files:**
- Create: `tests/fixtures/v8-coverage-counter-semantics/counter-semantics.fixture.ts`
- Create: `tests/fixtures/v8-coverage-counter-semantics/src/runtime.ts`
- Create: `tests/fixtures/v8-coverage-counter-semantics/src/import-type-empty.ts`
- Create: `tests/fixtures/v8-coverage-counter-semantics/src/import-empty.ts`
- Create: `tests/fixtures/v8-coverage-counter-semantics/src/import-side-effect.ts`
- Create: `tests/fixtures/v8-coverage-counter-semantics/src/import-value.ts`
- Create: `tests/fixtures/v8-coverage-counter-semantics/src/reexport-named.ts`
- Create: `tests/fixtures/v8-coverage-counter-semantics/src/reexport-star.ts`
- Create: `tests/fixtures/v8-coverage-counter-semantics/src/type-only.ts`
- Create: `tests/unit/v8-coverage-counter-semantics.test.ts`
- Create: `scripts/lib/v8-coverage-counter-semantics.ts`
- Create: `scripts/check-v8-coverage-counter-semantics.ts`
- Modify: `vitest.config.ts`
**Interfaces:**
- Produces: `assertV8CoverageCounterSemantics(summary, fixtureRoot)` and `checkV8CoverageCounterSemantics(options?)`.
- Consumes: fixed fixture templates, owned temp paths, a shell-free Vitest child result, and `coverage-summary.json`.
- [x] **Step 1: Add fixture templates and failing checker tests.**
The fixture test imports the seven counterless modules and observes the direct/named/star runtime values. The unit tests use literal summaries to require exact rows and mutate them for missing row, extra row, counterless nonzero, and runtime all-zero failures. Runner tests inject child exit and successful-without-summary results and require bounded diagnostics plus removal of the owned root.
- [x] **Step 2: Run the new unit file and verify RED.**
Run: `./node_modules/.bin/vitest run tests/unit/v8-coverage-counter-semantics.test.ts --reporter=dot`
Expected: FAIL because `scripts/lib/v8-coverage-counter-semantics.ts` does not exist.
- [x] **Step 3: Implement exact summary validation and owned child execution.**
The default runner executes:
```ts
execFile(process.execPath, [
path.join(repositoryRoot, "node_modules/vitest/vitest.mjs"),
"run",
"--config",
configPath,
"--coverage",
"--reporter=dot",
"--no-color",
], { cwd: ownedRoot, timeout: 30_000, maxBuffer: 256 * 1024 });
```
The generated config has `root`, `include`, `coverage.reportsDirectory`, and `coverage.include` paths inside the owned root. Always remove the root in `finally`.
- [x] **Step 4: Add a behavioral main-discovery assertion.**
Run main `vitest list` filtered to the fixture directory with `--filesOnly --passWithNoTests`; require empty stdout. Add an explicit fixture-directory exclude in `vitest.config.ts`.
- [x] **Step 5: Run the new unit file and standalone checker for GREEN.**
Run:
```sh
./node_modules/.bin/vitest run tests/unit/v8-coverage-counter-semantics.test.ts --reporter=dot
node scripts/check-v8-coverage-counter-semantics.ts
```
Expected checker output: `V8 coverage counter semantics: PASS (1 counter-bearing, 7 counterless)`.
### Task 3: Wire coverage/CI and refresh documentation
**Files:**
- Modify: `package.json`
- Modify: `docs/testing/frontend-platform-testing-strategy.md`
- Modify: `.superpowers/sdd/2026-08-01-quality-architecture-remediation/task-1-report.md`
- Modify: `.superpowers/sdd/2026-08-01-quality-architecture-remediation/progress.md`
**Interfaces:**
- Consumes: `check:v8-coverage-counter-semantics` and existing FE-GATE-005 `test:coverage` step.
- Produces: package/CI/sample-removal execution and current 19-module/80-threshold documentation.
- [x] **Step 1: Add the package checker and prepend it to `test:coverage`.**
```json
"check:v8-coverage-counter-semantics": "node scripts/check-v8-coverage-counter-semantics.ts",
"test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && vitest run ..."
```
- [x] **Step 2: Synchronize documentation.**
Replace stale `12개 high-risk module` and `52개 scoped threshold` with `19개` and `80개`; document artifact schema 3 and policy schema 2 separately.
- [x] **Step 3: Run full relevant verification.**
```sh
./node_modules/.bin/vitest run tests/unit/risk-coverage.test.ts tests/unit/risk-coverage-files.test.ts tests/unit/v8-coverage-counter-semantics.test.ts tests/unit/bounded-body-reader.test.ts --reporter=dot
./node_modules/.bin/tsc --noEmit -p tsconfig.node.json
./node_modules/.bin/tsc --noEmit -p tsconfig.test.json
./node_modules/.bin/eslint scripts/check-risk-coverage.ts scripts/check-v8-coverage-counter-semantics.ts scripts/lib/v8-coverage-counter-semantics.ts tests/unit/risk-coverage.test.ts tests/unit/v8-coverage-counter-semantics.test.ts vitest.config.ts --max-warnings=0
corepack pnpm check:v8-coverage-counter-semantics
node scripts/check-risk-coverage.ts
corepack pnpm test:sample-removal
git diff --check
```
- [x] **Step 4: Commit the verified closeout.**
```sh
git add package.json vitest.config.ts scripts/check-risk-coverage.ts scripts/check-v8-coverage-counter-semantics.ts scripts/lib/v8-coverage-counter-semantics.ts tests/fixtures/v8-coverage-counter-semantics tests/unit/risk-coverage.test.ts tests/unit/v8-coverage-counter-semantics.test.ts docs/testing/frontend-platform-testing-strategy.md docs/superpowers/plans/2026-08-02-v8-coverage-counter-contract.md
git commit -m "test: lock V8 coverage counter semantics"
```
## Self-review
- Spec coverage: artifact versioning, actual producer rows, discovery isolation, every fail-closed path, bounded diagnostics, cleanup, coverage/CI linkage, sample-removal preservation, and documentation counts are assigned.
- Placeholder scan: no deferred implementation remains.
- Type consistency: parser and runner names match in tests, script, and plan.
@@ -0,0 +1,322 @@
# Refactoring Review Remediation Design
## Purpose
Complete the existing runtime-integrity work, preserve the current uncommitted
implementation snapshot, and then close the correctness and evidence gaps found
by the repository-wide module, script, schema, and test review.
This design supplements `2026-08-01-runtime-integrity-refactor-design.md`. It
does not replace the existing release/boot or scope-owned server-state plans.
Those changes remain the baseline on which the remediation phases build.
## Chosen approach
Use a staged, in-place compatibility migration with test-first behavior fixes.
- Preserve every pre-existing dirty-worktree change. Do not reset, restore, or
replace the current implementation with a clean-branch rewrite.
- Complete and verify the existing release/boot and scope-generation work
before changing its public contracts.
- Add a failing regression test before every production behavior change.
- Fix active correctness and release blockers before structural extraction.
- Keep public facades stable while removing duplicate internal authorities.
- Do not enable the Service Worker capability until its bounded marker reader,
full identity handshake, and ACTIVE fixture build are verified.
A big-bang rewrite is rejected because query ownership, HTTP execution,
release evidence, and optional runtimes have independent failure modes. A
P0/P1-only patch is also rejected because it would leave duplicate registries
and misleading quality gates that can recreate the same defects.
## Program phases
The work is delivered as independently testable sub-projects in this order.
1. Existing implementation baseline
2. Query invalidation correctness
3. Mutation intent and effect settlement
4. Immutable release promotion
5. Release/evidence contract enforcement
6. Quality and architecture gate accuracy
7. HTTP contract and layer consolidation
8. Service Worker hardening
9. Characterized adapter decomposition
Each phase must leave type checking, linting, and its focused tests green. A
later phase may depend only on explicit interfaces produced by an earlier one.
## Existing implementation baseline
The current dirty snapshot contains the in-progress Release Manifest V2, exact
boot pairing, scope-generation ownership, HTTP V3 executor, and optional runtime
foundation. It is the source of truth for this program.
Before remediation begins, run the focused suites from the existing
release/boot and scope-owned server-state plans. Resolve failures in those
plans without changing the remediation contracts below. Record browser- or
artifact-producing gates separately when they cannot run in the current
environment.
## Query invalidation architecture
One query-key module owns both bound keys and invalidation prefixes. Query-key
schema version 2 is:
```text
["query", 2, namespaceId, namespaceVersion, scopeFingerprint,
definitionVersion, identityToken]
```
The invalidation prefix for the same namespace is exactly:
```text
["query", 2, namespaceId, namespaceVersion]
```
Features declare namespace identities and topic-to-namespace edges; they never
copy TanStack key shapes. Bootstrap composes one `InvalidationRegistry`, calls
`indexInvalidationRegistry` once, and passes the resulting many-to-many index
to the cache coordinator. Cross-context messages continue to carry opaque
topics only. The receiving coordinator resolves every namespace for that
topic locally.
The legacy flat query registry and the empty registry in `query-keys.ts` are
removed after static usage reaches zero. Query persistence is disabled, so the
key-version change requires no persisted-cache migration.
## Mutation intent architecture
`MutationIntent` is an application-level command identity, not an HTTP adapter
detail. The contracts layer defines its immutable shape, the application layer
defines `MutationIntentFactory`, and a browser adapter implements the factory
with `crypto.randomUUID()`.
```ts
type MutationIntent = Readonly<{
intentId: string;
operationId: string;
canonicalInputIdentity: string;
idempotencyKey?: string;
createdAtMonotonicMs: number;
}>;
```
`useApplicationMutation` creates exactly one intent after duplicate admission
and before optimistic projection. The intent travels through the bound mutation
execution context and feature input to `ContractHttpExecutor`. Every physical
retry and reconciliation of that logical submit reuses the same intent. A new
submit always creates a new intent.
For a `KEYED` descriptor, the executor requires a bounded, non-empty
idempotency key before credential resolution or fetch admission. Missing or
invalid keys return a pre-dispatch contract violation with `NOT_STARTED` and
perform zero fetches. Query operations never emit an idempotency header. Intent
IDs and keys are forbidden in URLs, query keys, diagnostics, and telemetry.
## Mutation effect settlement
Command failures carry mandatory effect certainty. The mutation bridge derives
settlement before changing optimistic state:
```text
NOT_STARTED | NOT_APPLIED -> rollback
APPLIED_CONFIRMED -> commit, then invalidate
MAYBE_APPLIED -> retain uncertain layer; do not retry or invalidate
```
`OptimisticLayerLease` gains an uncertain state and an explicit reconciliation
operation. An uncertain layer remains ordered with later layers and cannot be
collapsed into the base until inspection resolves it as applied or not applied.
The application async state exposes `mutation-effect-unknown`, and the
controller exposes a reconciliation action associated with the original
intent. Unknown effect never silently maps to conflict, success, or generic
retryable failure.
## Immutable release promotion
The release workflow has one byte-producing authority.
1. `immutable_build` creates `dist`, build manifest, module inventory, and the
local supply-chain documents once.
2. The job publishes one immutable bundle and its `distSha256`.
3. Provider jobs scan and attest that exact digest.
4. The promotion job downloads the bundle and provider evidence, supplies
`VULNERABILITY_REPORT_PATH` and `PROVENANCE_ATTESTATION_PATH`, and verifies
schemas, signatures, and digest identity.
5. Promotion consumes the verified bundle without rebuilding it.
The current `build:release` command is split into byte production, local
evidence generation, provider verification, and promotion verification.
Missing provider evidence remains `FAIL_UNVERIFIED`; it is never converted to a
local pass. An attestation for a different digest, or any rebuild after
attestation, fails promotion.
This repository validates but does not fabricate external provider evidence.
The CI environment must supply a vulnerability report and signed provenance
attestation produced for the published candidate digest. Until that external
integration is configured, candidate build and local verification may pass but
promotion remains intentionally unavailable.
## Release and evidence contracts
One async `verifyReleaseRuntimeCoherence` policy is used by `verify-release`
and the rollback runbook. V1 verifies the legacy scalar tuple. V2 verifies the
exact package set and recomputes the contract-set digest. V2 never synthesizes
an API contract version.
All machine-readable evidence writers call a common validated writer before
touching the destination:
```ts
writeValidatedJsonArtifact({ path, schema, value }): Promise<void>
```
Executable Zod schemas are authoritative. Checked JSON schemas are generated
views and a `--check` command fails on drift. CI maps every evidence path to an
executable schema and validates content before upload, rather than checking
existence only.
`verifyBuildManifestOutputs` confines declared paths to their approved roots,
parses the module inventory, and compares its raw SHA-256 with
`moduleInventoryHash`.
Repository file discovery is fail-closed. Required roots fail on absence,
permissions, or read errors; optional roots permit only explicit `ENOENT`.
Provenance and secret scanning share the tracked repository inventory so build
inputs such as `index.html`, Vite configs, TypeScript configs, `.nvmrc`, and the
provider workflow cannot be silently omitted.
## Quality and architecture gates
Coverage reports distinguish `selectedTotal` from `repositoryTotal`.
`repositoryTotal` contains every production TypeScript module and starts with a
non-decreasing baseline. A critical-module registry immediately includes HTTP
V3, request/response bounds, boot bounds, service-worker lifecycle, scope
generation, and release loading. A changed high-risk module must have a policy
entry or an owned, expiring waiver.
The HTTP scenario catalog is executable input to table-driven contract tests.
Declaring a scenario without executing its status, effect, retry, fetch-count,
media-type, body-bound, and scope-fence expectations does not satisfy the gate.
`config/ci/gates.json` is parsed by one shared schema.
`scripts/generate-ci-workflow.ts` deterministically emits the complete provider
workflow, and its `--check` mode fails when the checked-in workflow differs.
Token and regex presence checks are not authoritative. Playwright configurations inherit
`forbidOnly: true`, Vitest rejects `.only`, and fake timers are restored by the
common test setup.
The Babel/Node resolver graph is the sole authoritative architecture analyzer
while dependency-cruiser cannot parse TypeScript 7. A zero-module graph fails.
Rules prohibit contracts from importing outer application/runtime layers and
feature adapters from importing global concrete adapters.
## HTTP contract consolidation
The installed contract contribution is the single source for method, path,
input/output validators, retry semantics, effect classification, deadlines,
and byte bounds. The reference DTO schema is defined once and requires a valid
datetime when `createdAt` is present. Legacy registries and codecs are generated
from the contribution during the compatibility window, then removed after
production and tests have no callers.
Provider-neutral operation outcomes and typed operation maps live in contracts
or application ports. Feature adapters do not import `HttpExecutionOutcome`
from a concrete HTTP adapter and do not accept `operationId: string` paired with
`input: unknown`. Runtime `REQUEST_TIMEOUT_MS` is a global maximum applied on
top of descriptor deadlines.
Raw query and mutation overloads are removed from production exports after
callers migrate to `BoundQuery` and `BoundMutation`. Test-only legacy harnesses
remain outside the production public index until their tests migrate.
## Service Worker hardening
Activation markers are read through a realm-safe bounded response reader. It
checks declared length, reads at most `maxBytes + 1`, cancels an oversized or
non-terminating stream, decodes fatal UTF-8, and validates the marker record.
`response.text()` is not used for bounded worker protocol data.
Page and worker exchange a canonical identity digest covering protocol version,
cache schema version, build, release, contract set, and static asset set. This
wire-shape change increments the Service Worker protocol version to 2. Every
tuple-field mutation changes the digest and rejects activation.
The ACTIVE fixture build validates asset entries and recomputes the static set
digest without changing the product's default `null` selection.
## Adapter decomposition
Structural extraction begins only after behavior is characterized.
- IndexedDB runtime and maintenance share one persisted-row schema containing
record, receipt, retention, budget types, guards, and golden fixtures.
- OPFS worker keeps its public facade while browser bootstrap, message host,
core state machine, Web Lock lease, and physical I/O move into focused files.
- Public response cache extracts manifest codec/digest and generic lock logic.
- Download delivery extracts browser-managed, picker-streaming, and object-URL
strategies behind the existing facade.
Resumable upload, websocket, reconnect, stream coordinator, HTTP V3, and
Browser RPC remain intact unless a behavior test demonstrates an independent
change reason. File length alone is not a split criterion.
## Error handling
- Active correctness failures use stable failure kinds and preserve effect
certainty.
- Mandatory release evidence, file inventory, schema, or digest failures fail
closed.
- Optional diagnostics cannot change command, cache, or release outcomes.
- Cleanup continues through all participants but publishes no READY state after
any mandatory failure.
- Tool crashes, null exit status, signals, and timeouts never count as expected
negative-fixture rejection.
## Testing and verification
Every behavior change follows red-green-refactor. The minimum regression set
includes:
- Actual `bindQuery` keys for local, remote, fan-out, and generation-isolated
invalidation.
- Independent runtime intents, same-submit retry identity, missing-key
pre-dispatch rejection, and secret-free diagnostics.
- Optimistic NOT_APPLIED, APPLIED_CONFIRMED, and MAYBE_APPLIED settlement,
including out-of-order layers and reconciliation.
- V1/V2 release coherence and every contract-set tamper in both verifier and
rollback drill.
- Provider evidence absence, digest mismatch, post-attestation rebuild, and a
valid immutable promotion fixture.
- Required-root and unreadable-file failures, schema drift, invalid evidence,
and module-inventory hash mismatch.
- Complete V3 response/effect scenario execution and production read/write E2E.
- Headerless oversized and non-terminating Service Worker marker streams.
- Shared persisted-row acceptance/rejection across IndexedDB runtime and
maintenance before extraction.
Repository completion requires fresh evidence from type checking, linting,
non-browser tests, coverage, architecture, build, release verification, CI
contract checks, browser capabilities, E2E, accessibility, visual tests where
the environment supports them, schema parity, and diff hygiene. Unsupported
browser gates are reported explicitly and are never claimed as passing.
## Acceptance criteria
- The existing release/boot and scope-generation work passes its original
focused suites before remediation contracts are changed.
- Mutation success invalidates every matching bound query locally and remotely.
- No two independent logical commands reuse an idempotency key; missing KEYED
intent prevents network admission.
- MAYBE_APPLIED never causes arbitrary optimistic rollback, commit, retry, or
invalidation.
- Release promotion verifies and promotes the exact same immutable bytes.
- V2 rollback verification cannot bypass contract-set package or digest checks.
- Required files, artifact schemas, and manifest output hashes fail closed.
- Coverage and scenario gates measure production behavior rather than declared
subsets or source tokens.
- Production uses one executable HTTP contract registry and respects clean
architecture dependency direction.
- Service Worker activation is bounded and full-identity coherent before the
capability can become ACTIVE.
- Adapter splits preserve their public facade and pass shared golden tests.
@@ -0,0 +1,464 @@
# Platform-owned Frontend Assurance and Delivery Design
## Purpose
This design moves test assurance and CI/CD orchestration to the two platforms
that own those responsibilities while keeping product behavior and product
tests in the frontend template. It covers the complete vertical path from risk
selection through immutable static-site promotion.
The three repositories are:
- `/home/donghyeon/workspace/desktop-server-git/test-assurance-platform`
- `/home/donghyeon/workspace/desktop-server-git/cicd-platform`
- `/home/donghyeon/workspace/desktop-server-git/clean-architecture-frontend-template`
The selected approach is platform-native integration. A CI-first translation
bridge and a test-assurance-only migration were rejected because each would
leave one platform temporarily owning semantics assigned to the other.
## Authority boundaries
### Frontend product repository
The frontend repository owns:
- production source and product contracts;
- test implementations, fixtures, mocks, scenario catalogs, and assertions;
- package scripts that run one declared suite;
- raw product-specific reports and codecs for product artifacts;
- repository declarations: risks, obligations, suites, change surfaces, build
components, outputs, and selected platform capabilities.
It does not own remote scheduling, pipeline DAG generation, evidence
assessment, provider orchestration, retention, signing, or promotion.
### Test assurance platform
The test assurance platform owns:
- risk-to-obligation selection;
- scheduler-neutral execution plans;
- suite execution contracts and bounded local execution;
- report normalization and false-green prevention;
- evidence digest chains and obligation assessment;
- flaky, quarantine, waiver, and capability-readiness semantics.
It does not own runner allocation, workflow fan-out/fan-in, build publication,
release approval, or promotion.
### CI/CD platform
The CI/CD platform owns:
- required workflows, source materialization, runner trust, and toolchain pins;
- remote projection of test-assurance work items;
- install, lint, typecheck, deterministic build, and artifact publication;
- immutable candidate assembly and artifact identity;
- vulnerability, SBOM, provenance, signature, and provider evidence;
- release approval, promotion, retention, and terminal status publication.
It consumes test-assurance results without reinterpreting their test meaning.
## End-to-end authority flow
```text
frontend declarations and tests
-> testctl validate/lock/compile/select/plan
-> CI projects source work items to untrusted runners
-> testctl normalize/bundle/assess source results
-> CI builds one immutable frontend candidate
-> CI supplies the candidate digest to artifact-bound work items
-> testctl normalize/bundle/assess artifact results
-> CI obtains vulnerability/SBOM/provenance/signature evidence
-> release-control promotes the exact assessed candidate
```
Every transition carries canonical identities. No stage may replace a missing
identity with a path, timestamp, branch name, or mutable tag.
## Test assurance platform changes
### Frontend capability family
Add the following independent capabilities:
- `unit-typescript-vitest`
- `component-react-vitest`
- `integration-http-msw`
- `architecture-typescript`
- `coverage-v8`
- `e2e-playwright-firefox`
- `e2e-playwright-webkit`
- `accessibility-web`
- `visual-regression-web`
The existing `e2e-playwright-chromium` capability is upgraded to the same
contract revision. Browser capabilities retain separate readiness cards; no
aggregate frontend-readiness card or score is introduced.
Each capability defines its own artifacts, timeouts, isolation, false-green
rules, max evidence age, and minimum readiness. A repository obligation may
require all three browser capabilities without merging their readiness.
### Toolchain catalog
Create a new catalog revision containing the frontend template's supported
toolchain:
- Node.js `24.14.0`
- pnpm `11.17.0`
- TypeScript `7.0.2`
- Vitest `4.1.10`
- Playwright `1.62.0`
The exact installed versions in `package.json`, `.nvmrc`, and the lockfile are
validated against the catalog. The lock generator records immutable artifact
digests. A missing digest or version mismatch is `TA-LOCK-003`/exit 30 and does
not fall back to the host toolchain.
### Adapters and normalizers
Add adapters and normalizers for:
- Vitest JUnit XML and Vitest-discovered test counts;
- V8 coverage summary plus the repository production-module inventory;
- typed HTTP scenario execution receipts;
- TypeScript architecture graph and violation JSON;
- Playwright JSON/JUnit, trace, screenshot, console, and network summaries;
- accessibility findings and manual-review records;
- visual baseline identity and image-diff results;
- production-shaped read/write E2E mutation receipts.
Normalizers validate regular non-symlink files, bounded byte sizes, fatal UTF-8,
strict schemas, canonical repository-relative paths, and report-specific
cross-field invariants.
### False-green rules
The following outcomes can never normalize to PASS:
- zero discovered tests or an all-skipped required suite;
- a pass created only by retry;
- a missing, empty, malformed, oversized, or mismatched report;
- a declared HTTP scenario without an exact executed receipt;
- a missing required browser project;
- a non-empty source tree with zero architecture modules or dependencies;
- an unresolved import or dependency cycle;
- a production module absent from the coverage universe;
- a browser write test without observed response, mutation, and reload reads;
- an accessibility or visual result whose baseline/provider identity is absent.
Negative product fixtures may be schema-valid FAIL evidence; command outcome and
assessment, not artifact shape alone, determine satisfaction.
### Artifact-bound execution contract
The current v2 execution schemas cannot bind a work item to an immutable build
input. The platform therefore adds v3 execution contracts rather than silently
changing v2 semantics.
`SuiteDefinition` and `WorkItem` gain required fields:
```text
executionPhase: SOURCE | ARTIFACT
requiredInputArtifacts[]:
artifactId
mediaType
sha256
```
An artifact-bound `ExecutionRequest` carries the same artifact references. The
plan, raw result set, normalized result, evidence bundle, and assessment all
bind the input artifact tuple. Evidence from v2 and v3 cannot be merged.
The platform dual-reads existing v2 JVM manifests during migration. New
frontend capabilities require v3, and all new platform outputs are v3. There is
no implicit phase default.
### Artifact suite declaration amendment
A repository cannot know the SHA-256 of a candidate that CI has not built yet.
The repository contract therefore separates a static declaration from an
executable suite:
- `ArtifactSuiteTemplate` is repository-owned and declares the suite command,
raw artifacts, `executionPhase: ARTIFACT`, and required input artifact IDs
and media types. Its schema forbids `sha256` and digest placeholders.
- `SuiteDefinition` remains the executable v3 contract required above. For an
artifact suite it always contains the concrete `requiredInputArtifacts`
tuple including SHA-256.
- after `ci-frontend` freezes the candidate, CI creates the artifact
`ExecutionRequest` with that candidate tuple; testctl matches it to the
template and materializes the executable `SuiteDefinition` and `WorkItem`.
CI supplies artifact identity but does not construct or reinterpret test suite
semantics. An unmatched artifact ID/media type, an unresolved template, or a
digest in a committed template stops planning. Templates never enter raw,
normalized, evidence, or assessment documents.
## CI/CD platform changes
### Test-assurance integration
Add `ci-test-assurance`. It depends on `ci-standard-core` and invokes a
digest-pinned `testctl` distribution using argument arrays only.
Its responsibilities are:
1. validate and lock the repository assurance manifest;
2. compile policy and create source and artifact execution requests;
3. obtain deterministic plans;
4. project `execute-one` work items to the required trust partition;
5. preserve plan digest, work-item ID, attempt, exit code, and raw artifacts;
6. call testctl normalization, bundling, and assessment;
7. expose only canonical assessment and evidence digests downstream.
CI may choose runner parallelism but may not change selection, retry, timeout,
expected artifacts, status, or obligation satisfaction. Missing work-item
results are platform defects, never successful no-ops.
### Node and frontend capabilities
`ci-node-typescript` owns reproducible pnpm install, declared lint, and declared
typecheck. It no longer executes or assesses unit/coverage suites when
`ci-test-assurance` is selected.
`ci-frontend` owns one deterministic build of the selected source revision and
verifies:
- a non-empty static output;
- byte-identical rebuild evidence in an isolated verification workspace;
- absence of undeclared environment and build-host values;
- declared size budgets;
- a canonical tree digest and archive manifest.
The candidate consumed after this point is the first verified candidate. The
verification build proves determinism but is never promoted.
### Release capability family
Add the following capabilities:
- `ci-dependency-vulnerability`
- `ci-artifact-signing`
- `ci-static-artifact-supply-chain`
- `ci-static-site-publish`
Generalize existing `ci-sbom` and `ci-provenance` subject contracts so that a
static archive is a supported immutable subject without weakening their
container behavior. `ci-static-artifact-supply-chain` is a composite that
references, rather than copies, child evidence.
`delivery-release-control` promotes the exact candidate whose digest appears in
the signed release identity. It may not build, modify, or repackage the
candidate. Static-site publication returns provider and served-content digests;
both must equal the approved subject before promotion succeeds.
### Release identity
The signed release identity is split into two immutable documents so that one
candidate can be promoted to more than one environment without rewriting its
candidate manifest:
- `ReleaseManifestV2` binds the candidate, test, supply-chain, policy,
toolchain, and platform identities;
- `PromotionSubject` binds the release-manifest digest to the target
environment, approval identity/policy, confirmed publication, and expected
Git CAS state.
Together they bind:
```text
source revision
candidate archive SHA-256 and canonical member manifest
test-assurance manifest and policy digests
source plan, evidence, and assessment digests
artifact plan, evidence, and assessment digests
dependency vulnerability, SBOM, and provenance digests
signature key ID and signature digest
CI policy, capability-registry, toolchain, and platform-catalog digests
target environment and approval identity
```
Promotion history is an append-only record and is not a mutable field inside
`ReleaseManifestV2`.
A report for another candidate, a rebuilt candidate, a changed policy, an
expired approval, or an unconfirmed provider mutation blocks promotion.
### Required workflow
The centrally installed required workflow remains a thin bootstrap. It contains
no language, test, build, provider, or promotion logic. It materializes the
exact source revision, verifies the signed platform catalog, invokes pinned
`cicdctl`, and publishes one terminal sentinel.
Product repositories do not copy this workflow.
## Frontend consumer contract
The frontend repository adds:
- `delivery-platform.yaml`;
- `test-assurance.yaml`;
- a generated `test-assurance.lock.json`;
- risk, obligation, suite, and change-surface documents under
`config/test-assurance/`.
`delivery-platform.yaml` selects the core, Node, frontend, test-assurance,
dependency vulnerability, SBOM, provenance, signing, static supply-chain,
static publication, and release-control capabilities. It pins the signed
platform version.
Suite definitions reference existing product-owned package scripts. One suite
definition executes one bounded test purpose and declares its raw artifacts.
The repository does not wrap several assurance decisions in one script.
The product retains:
- production runtime and adapter tests;
- Vitest and Playwright configuration;
- mocks, scenarios, fixtures, and browser assertions;
- V8 instrumentation configuration;
- product artifact codecs such as runtime/release manifest schemas.
It removes after cutover:
- the copied `.gitea/workflows/quality-gates.yml`;
- `config/ci/gates.json` and its runner/generator/checker;
- local risk selection, waiver, normalization, and assessment engines;
- local provider, retention, promotion, and CI evidence orchestration;
- package scripts whose only purpose is to reproduce platform policy.
## Treatment of completed and in-progress frontend work
Runtime production changes and their product tests remain unchanged.
Repository-wide V8 instrumentation, HTTP scenario execution, and browser
read/write assertions remain as product test inputs. Their local selection,
reconciliation, evidence assessment, and waiver logic moves to test assurance.
Product runtime/release artifact codecs remain local. Supply-chain provider
policy, archive transfer, signing, retention, and promotion move to CI/CD.
The uncommitted Task 3 worktree is not reset or overwritten. Before migration,
every changed path is classified as product-owned, test-assurance-owned,
CI/CD-owned, or unrelated/user-owned. Reusable validators and adversarial tests
move to their owning platform through explicit patches. Unrelated and
origin-unknown changes are preserved.
## Migration sequence
### Phase 1: Test assurance capability readiness
Implement v3 contracts, frontend capabilities, adapters, normalizers, locks,
positive fixtures, adversarial fixtures, and independent readiness cards. Each
new capability reaches at least R1 before a consumer may select it in shadow.
### Phase 2: CI/CD integration and immutable release
Implement pinned testctl integration, source/artifact work-item projection,
frontend candidate identity, provider evidence, signing, static publication,
and release-control binding. Each CI capability receives independent P1
evidence; activation remains shadow.
### Phase 3: Consumer declarations
Add both manifests and the assurance declarations to the frontend repository.
Map existing product suites to capabilities without deleting the legacy path.
Validate all manifests with the released platform binaries.
### Phase 4: Shadow parity
Run legacy and platform paths against the same source revision. Compare:
- selected suites and discovered/executed counts;
- PASS, FAIL, FLAKY, INCOMPLETE, and platform-defect classification;
- coverage production-module universe;
- HTTP scenario declared/executed identities;
- Chromium, Firefox, and WebKit results;
- candidate archive and member digests;
- provider evidence and promotion readiness.
The platform path is the only candidate producer in shadow. Legacy release and
promotion commands become read-only comparison probes. No two writers may
publish or promote.
Before shadow execution, the legacy workflow registration is disabled and its
required status is detached. The centrally installed workflow runs in shadow,
and its runner may invoke legacy test/evidence commands only as read-only
comparison probes. Observed state records zero legacy candidate/provider/
promotion invocations and exactly one platform candidate writer.
### Phase 5: Cutover
After parity and fault tests pass, activate the platform capabilities, attach
the central required workflow/status, and remove copied workflow and local
policy engines. Product tests and declared suite commands remain.
## Failure and rollback semantics
- Unsupported capability or unavailable toolchain is explicit UNSUPPORTED or
INCOMPLETE, not a skipped pass.
- Schema-major mismatch stops before execution and never invokes a local
fallback.
- Missing or corrupt raw results stop normalization.
- Provider timeout, response loss, or digest mismatch blocks promotion and is
reconciled by operation ID where mutation may have occurred.
- A candidate, source assessment, or artifact assessment digest change
invalidates approval.
- A missing terminal sentinel blocks the required status.
Rollback changes only the repository's signed `platformVersion`/catalog pin to
the previous proven release and restores the previous capability activation.
It never restores a copied product workflow. Release rollback promotes the
previous stable immutable subject through release-control.
## Verification strategy
### Test assurance platform
- schema positive and adversarial corpus;
- compiler/selector/plan determinism;
- each normalizer's valid, malformed, missing, empty, oversized, symlink, and
cross-field cases;
- zero-discovery, all-skipped, retry, browser-matrix, coverage-omission,
architecture-empty/cycle, scenario-omission, and mutation-receipt fixtures;
- v2/v3 isolation and digest-chain tests;
- full conformance chain and Gradle verification.
### CI/CD platform
- manifest compiler and capability dependency/activation tests;
- pinned testctl invocation and exit-code preservation;
- fan-out completeness and missing-result fault tests;
- deterministic static candidate and exact-member archive tests;
- provider timeout, invalid signature, changed digest, response-loss, and stale
approval tests;
- required workflow thinness and exact required-status tests;
- registry, boundary, fixture, fault, and readiness verification.
### Frontend consumer
- manifest validation with released platform binaries;
- existing focused product tests;
- platform shadow run against the actual repository;
- semantic parity and digest reports;
- one-writer and rollback drills;
- removal tests proving the template works without copied workflow or local
policy engines.
## Acceptance criteria
- Test selection and assessment have one authority: test assurance.
- CI orchestration and promotion have one authority: CI/CD.
- The frontend repository contains product tests and declarations, not copied
platform engines.
- Every required frontend capability has independent readiness evidence.
- Source and artifact test evidence bind the exact revision and candidate.
- The promoted static site is byte-identical to the assessed, signed candidate.
- Missing evidence, unsupported capability, platform failure, and provider
uncertainty cannot produce a passing required status.
- Cutover and rollback require no copied workflow logic in the product
repository.
@@ -0,0 +1,222 @@
# Provider Evidence Guardian Transaction Design
## Goal
Make one guardian process own the provider evidence filesystem transaction from
raw creation through sealed publication. A supervisor or provider hard death
must leave neither canonical raw evidence nor a guardian-owned sealed temp/final
inode, and the same workspace must be immediately retryable. Only the complete
authenticated `publish -> PUBLISHED -> commit -> EOF` sequence preserves the
canonical sealed artifact.
## Chosen Ownership Boundary
The guardian owns filesystem identity and publication. The supervisor retains
archive, trust, schema, signature, and evidence validation. This avoids two
unsafe alternatives:
- Keeping raw-only guardianship would leave the sealed rename-to-supervisor-death
cleanup gap.
- Moving evidence validation into the guardian would duplicate security policy
and make the helper unnecessarily privileged and complex.
The client opens the canonical `provider-evidence/untrusted` and
`provider-evidence` directories with `O_DIRECTORY|O_NOFOLLOW` before spawning
the guardian. Those identity-pinned directory descriptors are inherited as fd 3
and fd 4; they are never encoded in argv or the environment. The canonical raw
and final leaves are fixed by provider kind. Before spawn, the client exclusively
creates a nonce-private raw staging inode and the nonce-private sealed temp
inode, records both identities, and inherits their open descriptors as fd 5 and
fd 6. The guardian validates each inherited descriptor against its
descriptor-relative pathname, then publishes raw staging to the fixed raw leaf
with a no-replace hard link. Startup recovery authority is therefore an inode
identity acquired before spawn, never an identity discovered later from an
expected pathname. Both processes perform transaction I/O through
`/proc/self/fd/<fd>/<leaf>` so pathname substitution cannot redirect creation or
recovery into another directory. No raw path, sealed path, identity, provider
command, or credential is an argv value. The initial request contains only the
version, kind, random control nonce, and absolute deadline.
## Transaction Invariants
1. Before a complete valid guard frame, the guardian has not published a
canonical filesystem object. The client may have allocated only zero-byte,
mode `0600`, nonce-private raw staging and sealed temp inodes whose identities
it already holds. EOF with no frame or a partial frame removes both allocations.
2. Before spawning, the client validates that both pinned descriptors name the
expected canonical directories; computes fixed raw/final leaves and
nonce-private raw-staging/sealed-temp leaves; and creates the private leaves
with `O_CREAT|O_EXCL|O_NOFOLLOW`, mode `0600`, size zero, and link count one.
It retains both handles and inherits them as fd 5/fd 6 in addition to directory
fd 3/fd 4.
3. At bootstrap, the guardian fstats fd 5/fd 6, reads only their
`/proc/self/fd/5|6` link targets, and accepts each basename only when it is a
direct child of the canonical fd 3/fd 4 directory and matches the exact
provider-kind/32-lowercase-hex private-leaf grammar. It then requires
descriptor-relative lstat of that basename to match the already-fstat fd
identity, type, mode, size, and link count. This binds a deletion alias to an
inherited identity; it never promotes a pathname-discovered identity to
ownership. The two basenames must encode the same kind and nonce prefix.
4. After guard validation, the guardian verifies that the received kind/nonce
derives those exact bootstrapped private leaves. It verifies fd 5/fd 6 remain
regular zero-byte single-link `0600` files and exactly match the derived
private pathnames. It
uses `link(raw staging, canonical raw)` without replacement, verifies both
names have the inherited raw identity and link count two, unlinks the private
raw name, fsyncs the raw directory, and verifies the canonical raw link count
is one. READY is emitted only after this authority transfer succeeds.
5. READY is authenticated by the request nonce and returns raw dev/inode plus
sealed temp leaf/dev/inode. The supervisor starts the provider only after it
validates this exact bounded response with constant-time nonce equality.
6. The supervisor writes only schema-validated sealed bytes to the temp inode.
It opens with `O_NOFOLLOW`, checks dev/inode before and after writing, applies
mode `0400`, writes the complete bounded bytes, fsyncs, and closes.
7. Publish metadata contains the nonce, sealed dev/inode, byte length, and
SHA-256. The guardian checks the held descriptor and temp pathname identity,
regular-file type, link count, exact mode/size/hash, and canonical final-path
absence.
8. Publication uses atomic no-replace `link(temp, final)`, then unlinks temp and
fsyncs the parent directory. If death occurs between link and unlink, both
names refer to the same pinned inode and both are cleanup candidates.
9. PUBLISHED is authenticated and is emitted only after final pathname identity
and directory durability are verified.
10. Commit is legal only after PUBLISHED. It removes the pinned raw inode and
enters `commitPending`; it does not exit. EOF with no pending bytes is the
sole success terminal and preserves only the sealed final inode.
11. Any data after commit, including a separate later chunk, is a protocol error.
EOF/abort/deadline/protocol failure before the success terminal cleans raw,
temp, and final only when each path still names the guardian-owned identity.
12. If the guardian dies before READY is accepted, the client attempts cleanup
of raw staging, canonical raw, sealed temp, and sealed final aliases using
only the two identities recorded before spawn. A current pathname is never
opened and promoted to an owned identity. A competing canary or same-kind
transaction therefore survives every startup failure.
13. Cleanup attempts every owned target and reports cleanup failures together
with the primary failure using `AggregateError` at the supervisor boundary.
Client fd 3-fd 6 handles and guardian fd 5/fd 6 duplicates are closed on
every success and failure branch; close errors join the same aggregate rather
than skipping remaining cleanup.
Client-side exclusive private allocation is the startup ownership token. The
guardian accepts that token only after inherited-fd, descriptor-relative
pathname, type, mode, size, and link-count checks. Every cleanup identity is
recorded at allocation or authenticated READY; pathname discovery never creates
authority. Creation, validation, link, unlink, chmod, fstat, close, publish,
sync, and cleanup failures all fail closed.
## Bounded Authenticated Protocol
Every control or acknowledgement message is a four-byte big-endian length plus
canonical JSON with an exact ordered field set, strict UTF-8, no NUL, and a
total payload bound. Unknown, duplicate, reordered, oversized, truncated, or
trailing fields are rejected.
The state sequence is:
```text
guard -> READY(raw identity, sealed temp identity)
-> publish(size, sha256, sealed identity)
-> PUBLISHED(sealed identity)
-> commit
-> EOF success
```
All messages carry the same 32-byte random nonce. READY and PUBLISHED are
validated with `timingSafeEqual`; publish and commit are authenticated the same
way. Commit merely changes state, so a byte delivered in a later chunk before
EOF remains observable and causes fail-closed cleanup.
The maximum initial lease is the provider wall timeout plus a fixed ten-minute
post-processing allowance. The provider timeout remains bounded at 30 minutes,
so the guardian maximum is 40 minutes. Near-provider-timeout tests must show
that valid publication still has post-processing time, while an expired lease
cleans all owned objects.
## Supervisor and Scope Exit Ownership
The lease exposes raw/temp/final identities, `publish(bytes)`, `commit()`,
`abort()`, and a non-rejecting premature-exit promise. The client knows all
possible leaves and both startup identities before spawn and retains its pinned
directory and private-file handles until the lease terminates. Before READY it
cleans only aliases that still match those recorded identities. After READY it
checks the guardian response against the same identities and fallback-cleans
raw, temp, and final by identity if the guardian dies.
Provider waiting owns an explicit `scopeActive` latch. A guardian exit starts
whole-scope kill and collection only while that latch is true. Once the scope
completion path has collected the unit, the callback records a lifecycle error
but cannot start an unawaited kill. Publication and terminal commit observe the
guardian exit through their normal awaited failure path and clean sealed state.
Provider stdout and stderr are untrusted secret-bearing byte streams. The
supervisor counts and bounds them for resource enforcement but never forwards
their raw bytes into supervisor/CI stdout or stderr, on either success or
failure. Functional provider assertions use signed evidence or a non-log side
channel. Sealing/output I/O is allowed to settle; the design does not claim
OS-level cancellation. `GITHUB_OUTPUT` is a runner-owned regular file. After
output append succeeds, commit makes the guardian remove raw and EOF completes
the transaction.
Guardian diagnostics are best-effort only. A closed stderr or control descriptor
must not turn a fail-closed branch into a resolved operation or exit zero:
diagnostic and fd-close failures are absorbed after cleanup, and a nonzero exit
or requested fatal signal is issued unconditionally.
## Failure and Recovery
- No/partial guard EOF: no canonical raw or sealed object is published. The
guardian removes both nonce-private allocations through aliases that bootstrap
already bound to inherited fd identities, without needing kind/nonce from a
complete control frame.
- A competing canonical raw canary or another same-kind attempt causes
no-replace link failure. The loser removes only its private identities and
never removes the winner or canary.
- Guardian death after linking raw but before READY: the client uses its
pre-recorded raw identity to clean both private and canonical aliases and its
pre-recorded sealed identity for temp/final aliases, then retries the same
workspace immediately.
- Parent death after creation but before READY: stdout/control pipe failure or
EOF makes the still-running guardian clean both owned objects.
- Guardian death after READY: the supervisor knows raw and sealed identities and
cleans raw, temp, and final fallbacks.
- Supervisor death after PUBLISHED: guardian EOF cleans raw and the published
final inode, including the link/unlink intermediate state.
- Publish or commit race: serialized guardian state completes the current file
operation, then applies EOF/protocol failure cleanup; success requires clean
EOF after commitPending.
- Cleanup failure: remaining targets are still attempted and every error is
preserved; PASS is impossible.
There is one bounded crash window before spawn: if the client itself is killed
after private allocation but before the guardian is created, zero-byte `0600`
nonce-private leaves can remain. They contain no provider or credential bytes
and cannot occupy the fixed canonical raw/final names, so they do not block an
immediate same-kind retry. Automatic pathname sweeping is intentionally omitted
because an unproven stale pathname is not deletion authority.
After each observable managed-process failure, tests require canonical raw,
private staging/temp, canonical final, guardian, and provider cgroup residual
counts to be zero before retrying the same workspace successfully. The
documented pre-spawn client hard-death window is the sole residual exception.
## Verification
Real-process tests cover no/partial frames, a competing raw canary, same-kind
concurrency, guardian `SIGKILL` after raw link but before READY followed by
same-workspace retry, parent death around READY, valid
READY identities, EOF/deadline cleanup, publish/PUBLISHED, post-scope guardian
death, supervisor death after publication, commit trailing bytes in a later
chunk, closed-stderr fail-closed termination, near-timeout publication, and no
residual guardian/files. A provider that successfully prints a supplied
credential is verified not to expose it through supervisor stdout/stderr. Live fixtures
also specify active-scope guardian kill, detached-child external marker/raw
append suppression, cgroup collection, and same-workspace retry. Live
systemd/bwrap execution remains explicitly unverified when the approval limit
prevents running it.
The external-canary regression waits for a test guardian spawn marker before
creating the fixed raw file, proving that the initial absence check has already
completed. The fixed raw bytes and dev/inode must remain unchanged after startup
rejection. The pre-READY link regression watches only the fixed raw basename,
kills the exact direct child on that link event, and requires identity-bound
cleanup plus an immediate same-workspace retry.
@@ -0,0 +1,47 @@
# V8 Coverage Counter Contract Design
## Goal
Version the serialized risk-coverage artifact independently from its policy and lock the repository's counter-bearing/counterless classifier to the output of the installed Vitest/V8 producer.
## Artifact contract
`config/testing/risk-coverage.json` remains policy schema version 2. `scripts/check-risk-coverage.ts` changes only its serialized output envelope to schema version 3 because the artifact fields were renamed from executable/non-executable terminology to `counterBearingTotal`, `instrumentedCounterBearingTotal`, `counterlessTotal`, and `counterlessModules`.
The contract test runs the real CLI against an owned temporary repository. It reuses the current policy, materializes its 19 policy-sensitive source paths as counter-bearing modules, writes an exact consistent coverage summary, and reads the published JSON artifact. It requires output schema version 3, the exact counter-bearing fields, and absence of every legacy executable/non-executable field.
## Producer microfixture
Repository fixtures under `tests/fixtures/v8-coverage-counter-semantics/` contain only source and a child test template. The child test file uses a `.fixture.ts` suffix and the main Vitest discovery exclusion is verified behaviorally so it cannot recursively join the repository suite.
At runtime, `scripts/check-v8-coverage-counter-semantics.ts` creates one owned directory below the operating-system temporary directory. It copies the fixed fixture into that directory and writes the child Vitest config there. The child process uses that directory as its root and writes its JSON summary below that same directory; it never writes repository coverage or artifact paths.
The fixture contains these exact source rows:
- `runtime.ts`: a runtime declaration/initializer; at least one standard counter total must be positive.
- `import-type-empty.ts`: `import type {}` only.
- `import-empty.ts`: `import {}` only.
- `import-side-effect.ts`: a bare side-effect import only.
- `import-value.ts`: a value import only.
- `reexport-named.ts`: a named value re-export only.
- `reexport-star.ts`: a star value re-export only.
- `type-only.ts`: type declarations only.
Every row except `runtime.ts` must contain exact `0/0/0/100` lines, statements, functions, and branches counters. The checker rejects a missing summary, missing or additional row, malformed counter, counterless nonzero drift, or runtime all-zero drift.
## Process and failure handling
The child Vitest process is launched without a shell or network. Exit failure is converted to a bounded diagnostic containing truncated stdout/stderr. File or JSON failures identify the missing or invalid summary without exposing unbounded child output. An outer `finally` removes only the exact owned temporary root for success and every failure path.
Pure summary validation is exported from a focused library and covered with literal mutation fixtures. Runner tests inject child exit or successful-without-summary behavior and assert cleanup. The real standalone checker executes in `test:coverage` before the repository coverage run, so FE-GATE-005 and sample removal both consume it through the existing package script contract.
## Documentation and evidence
The testing strategy is synchronized to the current 19 high-risk modules and 80 thresholds, documents output schema version 3, and retains policy schema version 2. Final evidence includes the focused unit/contract tests, node/test TypeScript, changed-file lint, standalone producer checker, root risk checker, sample removal, and diff validation.
## Self-review
- No placeholder or deferred choice remains.
- Policy schema 2 and artifact schema 3 are explicitly separate.
- All child-owned paths are below one temporary root and cleanup has one owner.
- Main discovery, subprocess failure, missing summary, exact rows, zero/nonzero drift, and bounded diagnostics have explicit verification paths.
@@ -129,10 +129,26 @@ Chromium, Firefox, WebKit과 compact project로 실행한다. 빠른 Vite 개발
V8 text/JSON/LCOV를 생성하고 전체 기준과 retry/storage/telemetry/application
composition/compatibility/performance/promotion/chunk/diagnostics/reference HTTP
operation/query-mutation/registry compatibility 12개 high-risk module에 52
operation/query-mutation/registry compatibility 19개 high-risk module에 80
scoped threshold를 적용한다. critical module 누락 또는 threshold 미달 fixture는
merge gate를 실패시킨다.
Repository inventory의 정적 provenance는 runtime 실행 가능 여부가 아니라 현재
Vitest/V8 producer가 file counter를 생성하는 문장의 존재 여부를 나타낸다. 따라서
artifact는 `counterBearingTotal`, `instrumentedCounterBearingTotal`,
`counterlessTotal`, `counterlessModules`를 기록한다. 실제 microfixture에서
`import type {}`, `import {}`, bare side-effect import, value import, named/star value
re-export만 있는 모듈은 모두 exact all-zero row였고, 선언/초기화 또는 직접 실행문이
있는 모듈은 counter-bearing이었다. Counterless import/re-export도 module evaluation
과 side effect를 유발할 수 있으므로 이를 non-executable로 해석하지 않는다. Gate는
static counterless 집합과 exact all-zero row 집합의 일치를 요구하고 critical/high-risk
policy module이 counterless이면 실패시킨다.
Coverage policy 입력은 schema version 2를 유지한다. 반면 위 counter-bearing 필드를
직렬화하는 risk-coverage 결과 artifact는 schema version 3이다. 두 version은 서로
독립적인 계약이며, 실제 CLI contract test가 artifact의 새 필드와 legacy 필드 부재를
검증한다.
남은 범위는 실제 device/browser farm, cloud visual approval, 외부 인증·telemetry
provider와 production field data다. 이 증거가 없을 때 저장소 내부 test를
`PRODUCTION_READY`의 대체물로 사용하지 않는다.
+17 -6
View File
@@ -11,7 +11,7 @@
"scripts": {
"dev": "vite",
"build": "node scripts/build-frontend.ts",
"build:release": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security",
"build:release-candidate": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security && corepack pnpm verify:release && node scripts/verify-supply-chain-artifacts.ts && node scripts/create-release-candidate.ts",
"preview": "vite preview",
"lint": "eslint src scripts tests recipes .storybook vite.config.ts vitest.config.ts playwright*.config.ts --max-warnings=0",
"check:architecture": "node scripts/check-architecture.ts",
@@ -45,6 +45,8 @@
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
"test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml",
"test:http-scenario-catalog": "vitest run tests/integration/http-scenario-catalog.test.ts --reporter=default --maxWorkers=1",
"test:http-scenario-evidence": "node scripts/run-http-scenario-evidence.ts",
"test:recipes": "vitest run tests/recipes --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/optional-recipes.xml --passWithNoTests",
"test:e2e": "playwright test",
"test:e2e:dev": "playwright test --config playwright.dev.config.ts",
@@ -56,6 +58,9 @@
"test:visual": "playwright test --config playwright.visual.config.ts",
"test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots",
"check:test-evidence": "node scripts/check-test-evidence.ts",
"check:test-evidence:browser": "node scripts/check-test-evidence.ts --skip-scenario-executions",
"check:http-scenario-evidence": "node scripts/check-test-evidence.ts --scenario-only --artifact artifacts/quality/http-scenario-evidence.json",
"check:http-scenario-evidence:fixture": "node scripts/check-test-evidence.ts --scenario-only --source-root tests/fixtures/test-evidence/scenarios/source --policy tests/fixtures/test-evidence/scenarios/policy.json --catalog tests/fixtures/test-evidence/scenarios/catalog.json --receipt tests/fixtures/test-evidence/scenarios/receipt.json --artifact artifacts/quality/http-scenario-evidence-fixture.json",
"check:test-evidence:source": "node scripts/check-test-evidence.ts --source-only --artifact artifacts/quality/test-evidence-source.json",
"check:test-evidence:fixture": "node scripts/check-test-evidence.ts --source-root tests/fixtures/test-evidence/forbidden --artifact artifacts/quality/test-evidence-fixture.json",
"test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.ts",
@@ -65,20 +70,24 @@
"test:browser-file-storage-removal": "node scripts/test-browser-file-storage-runtime-removal.ts",
"test:realtime-removal": "node scripts/test-realtime-runtime-removal.ts",
"test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests",
"test:coverage": "vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature --coverage --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts",
"check:v8-coverage-counter-semantics": "node scripts/check-v8-coverage-counter-semantics.ts",
"test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature --coverage --maxWorkers=4 --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts",
"check:coverage:fixture": "node scripts/check-risk-coverage.ts --summary tests/fixtures/coverage/below-threshold.json --artifact artifacts/quality/risk-coverage-fixture.json",
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes",
"verify:lockfile": "corepack pnpm install --frozen-lockfile",
"verify:lockfile": "corepack pnpm install --frozen-lockfile --ignore-scripts",
"check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts",
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
"check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check",
"generate:supply-chain": "node scripts/generate-supply-chain.ts",
"verify:supply-chain": "node scripts/verify-supply-chain-artifacts.ts",
"verify:local-evidence": "node scripts/verify-release-candidate.ts && node scripts/verify-release.ts && node scripts/verify-supply-chain-artifacts.ts && node scripts/verify-archived-local-evidence.ts && node scripts/verify-release-candidate.ts",
"verify:promotion": "node scripts/verify-exact-promotion-bundle.ts",
"update:dependency-baseline": "node scripts/update-dependency-baseline.ts",
"check:supply-chain:fixtures": "node scripts/check-supply-chain-fixtures.ts",
"check:supply-chain:provider-fixtures": "node scripts/check-supply-chain-provider-fixtures.ts",
"verify:supply-chain:promotion": "node scripts/verify-supply-chain-promotion.ts",
"verify:reproducible-build": "node scripts/verify-reproducible-build.ts",
"scan:security": "node scripts/security-scan.ts",
"scan:security:fixture": "node scripts/security-scan.ts --policy tests/fixtures/security/secret-detection/forbidden-policy.json --artifact artifacts/security/scan-fixture.sarif",
"check:security:fixtures": "node scripts/check-security-fixtures.ts",
"check:browser-security": "node scripts/check-browser-security.ts",
"check:browser-file-storage-boundaries": "node scripts/check-browser-file-storage-boundaries.ts",
"check:realtime-boundaries": "node scripts/check-realtime-boundaries.ts",
@@ -101,7 +110,9 @@
"drill:runbook": "node scripts/drill-runbook.ts",
"drill:runbooks": "corepack pnpm drill:runbook -- FE-RB-001 && corepack pnpm drill:runbook -- FE-RB-002 && corepack pnpm drill:runbook -- FE-RB-003 && corepack pnpm drill:runbook -- FE-RB-004 && corepack pnpm drill:runbook -- FE-RB-005",
"ci:gate": "node scripts/run-ci-gate.ts",
"check:ci": "node scripts/check-ci-contract.ts",
"generate:ci-workflow": "node scripts/generate-ci-workflow.ts",
"check:ci-workflow": "node scripts/generate-ci-workflow.ts --check",
"check:ci": "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && corepack pnpm check:ci-workflow",
"verify:documentation": "node scripts/verify-documentation-readiness.ts",
"build:app-only": "vite build && node scripts/generate-build-manifest.ts",
"generate:contract-set": "node scripts/generate-contract-set.ts",
+56 -56
View File
@@ -1,51 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"schemaVersion": {
"type": "number",
"const": 1
},
"buildId": {
"type": "string",
"minLength": 1
},
"commitSha": {
"type": "string",
"minLength": 1
},
"releaseId": {
"type": "string",
"minLength": 1
},
"moduleInventoryHash": {
"type": "string",
"minLength": 1
},
"generatedAt": {
"type": "string",
"minLength": 1
},
"buildContext": {
"type": "object",
"additionalProperties": false,
"properties": {
"nodeVersion": {
"type": "string",
"minLength": 1
"minLength": 1,
"type": "string"
},
"packageManagerVersion": {
"type": "string",
"minLength": 1
"minLength": 1,
"type": "string"
},
"runnerImage": {
"type": "string",
"minLength": 1
"minLength": 1,
"type": "string"
},
"sourceDateEpoch": {
"anyOf": [
{
"type": "string",
"minLength": 1
"minLength": 1,
"type": "string"
},
{
"type": "null"
@@ -59,37 +35,53 @@
"runnerImage",
"sourceDateEpoch"
],
"additionalProperties": false
"type": "object"
},
"buildId": {
"minLength": 1,
"type": "string"
},
"commitSha": {
"minLength": 1,
"type": "string"
},
"generatedAt": {
"minLength": 1,
"type": "string"
},
"moduleInventoryHash": {
"minLength": 1,
"type": "string"
},
"outputs": {
"type": "object",
"additionalProperties": false,
"properties": {
"directory": {
"type": "string",
"minLength": 1
},
"viteManifest": {
"type": "string",
"minLength": 1
"minLength": 1,
"type": "string"
},
"moduleInventory": {
"type": "string",
"minLength": 1
"minLength": 1,
"type": "string"
},
"routeChunks": {
"type": "object",
"propertyNames": {
"type": "string",
"minLength": 1
},
"additionalProperties": {
"type": "string",
"minLength": 1
}
"minLength": 1,
"type": "string"
},
"propertyNames": {
"minLength": 1,
"type": "string"
},
"type": "object"
},
"runtimeConfigSchema": {
"type": "string",
"minLength": 1
"minLength": 1,
"type": "string"
},
"viteManifest": {
"minLength": 1,
"type": "string"
}
},
"required": [
@@ -99,7 +91,15 @@
"routeChunks",
"runtimeConfigSchema"
],
"additionalProperties": false
"type": "object"
},
"releaseId": {
"minLength": 1,
"type": "string"
},
"schemaVersion": {
"const": 1,
"type": "number"
}
},
"required": [
@@ -112,5 +112,5 @@
"buildContext",
"outputs"
],
"additionalProperties": false
"type": "object"
}
@@ -1,31 +1,49 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://clean-architecture-frontend.local/schemas/dependency-inventory.schema.json",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"packageManager",
"lockfileSha256",
"dependencyCount",
"directDependencyCount",
"dependencies"
],
"properties": {
"schemaVersion": { "const": 2 },
"packageManager": { "type": "string", "minLength": 1 },
"lockfileSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"dependencyCount": { "type": "integer", "minimum": 1 },
"directDependencyCount": { "type": "integer", "minimum": 1 },
"dependencies": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"dependencies": {
"items": {
"minLength": 1,
"type": "string"
},
"type": "array"
},
"direct": {
"type": "boolean"
},
"integrity": {
"pattern": "^sha512-",
"type": "string"
},
"license": {
"minLength": 1,
"type": "string"
},
"name": {
"minLength": 1,
"type": "string"
},
"optional": {
"type": "boolean"
},
"scope": {
"enum": [
"production",
"development"
],
"type": "string"
},
"version": {
"minLength": 1,
"type": "string"
}
},
"required": [
"name",
"version",
@@ -36,25 +54,41 @@
"integrity",
"dependencies"
],
"properties": {
"name": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 },
"direct": { "type": "boolean" },
"scope": {
"enum": ["production", "development"]
"type": "object"
},
"optional": { "type": "boolean" },
"license": { "type": "string", "minLength": 1 },
"integrity": {
"type": "string",
"pattern": "^sha512-"
"minItems": 1,
"type": "array"
},
"dependencies": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}
}
"dependencyCount": {
"exclusiveMinimum": 0,
"maximum": 9007199254740991,
"type": "integer"
},
"directDependencyCount": {
"exclusiveMinimum": 0,
"maximum": 9007199254740991,
"type": "integer"
},
"lockfileSha256": {
"pattern": "^[a-f0-9]{64}$",
"type": "string"
},
"packageManager": {
"minLength": 1,
"type": "string"
},
"schemaVersion": {
"const": 2,
"type": "number"
}
},
"required": [
"schemaVersion",
"packageManager",
"lockfileSha256",
"dependencyCount",
"directDependencyCount",
"dependencies"
],
"type": "object"
}
+416 -28
View File
@@ -1,6 +1,262 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"$defs": {
"__schema0": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {
"$ref": "#/$defs/__schema0"
},
"type": "array"
},
{
"additionalProperties": {
"$ref": "#/$defs/__schema0"
},
"propertyNames": {
"type": "string"
},
"type": "object"
}
]
},
"__schema1": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {
"$ref": "#/$defs/__schema1"
},
"type": "array"
},
{
"additionalProperties": {
"$ref": "#/$defs/__schema1"
},
"propertyNames": {
"type": "string"
},
"type": "object"
}
]
},
"__schema2": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {
"$ref": "#/$defs/__schema2"
},
"type": "array"
},
{
"additionalProperties": {
"$ref": "#/$defs/__schema2"
},
"propertyNames": {
"type": "string"
},
"type": "object"
}
]
}
},
"anyOf": [
{
"additionalProperties": false,
"properties": {
"baselineDigest": {
"anyOf": [
{
"pattern": "^[a-f0-9]{64}$",
"type": "string"
},
{
"type": "null"
}
]
},
"compatibility": {
"additionalProperties": false,
"properties": {
"changes": {
"items": {
"additionalProperties": false,
"properties": {
"after": {
"$ref": "#/$defs/__schema1"
},
"before": {
"$ref": "#/$defs/__schema0"
},
"changeId": {
"minLength": 1,
"type": "string"
},
"field": {
"minLength": 1,
"type": "string"
},
"impact": {
"enum": [
"none",
"additive",
"behavior-change",
"breaking"
],
"type": "string"
},
"kind": {
"minLength": 1,
"type": "string"
},
"registryId": {
"minLength": 1,
"type": "string"
},
"rowName": {
"minLength": 1,
"type": "string"
}
},
"required": [
"changeId",
"registryId",
"rowName",
"field",
"kind",
"impact"
],
"type": "object"
},
"type": "array"
},
"impact": {
"enum": [
"not-evaluated",
"none",
"additive",
"behavior-change",
"breaking"
],
"type": "string"
}
},
"required": [
"impact",
"changes"
],
"type": "object"
},
"currentDigest": {
"pattern": "^[a-f0-9]{64}$",
"type": "string"
},
"failures": {
"items": {
"type": "string"
},
"maxItems": 0,
"type": "array"
},
"generatedAt": {
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"registries": {
"items": {
"additionalProperties": false,
"properties": {
"contract": {
"additionalProperties": {
"$ref": "#/$defs/__schema2"
},
"propertyNames": {
"type": "string"
},
"type": "object"
},
"owner": {
"minLength": 1,
"type": "string"
},
"registryId": {
"minLength": 1,
"type": "string"
},
"rowCount": {
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
},
"rows": {
"additionalProperties": {
"$ref": "#/$defs/__schema2"
},
"propertyNames": {
"type": "string"
},
"type": "object"
},
"source": {
"minLength": 1,
"type": "string"
}
},
"required": [
"registryId",
"owner",
"source",
"rowCount",
"contract",
"rows"
],
"type": "object"
},
"maxItems": 11,
"minItems": 11,
"type": "array"
},
"schemaVersion": {
"const": 2,
"type": "number"
}
},
"required": [
"schemaVersion",
"generatedAt",
@@ -10,35 +266,150 @@
"failures",
"registries"
],
"properties": {
"schemaVersion": { "const": 2 },
"generatedAt": { "type": "string", "format": "date-time" },
"baselineDigest": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
"type": "object"
},
"currentDigest": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
{
"additionalProperties": false,
"properties": {
"baselineDigest": {
"anyOf": [
{
"pattern": "^[a-f0-9]{64}$",
"type": "string"
},
{
"type": "null"
}
]
},
"compatibility": {
"type": "object",
"required": ["impact", "changes"],
"additionalProperties": false,
"properties": {
"impact": {
"enum": ["none", "additive", "behavior-change", "breaking"]
},
"changes": { "type": "array" }
},
"additionalProperties": false
},
"failures": { "type": "array", "maxItems": 0 },
"registries": {
"type": "array",
"minItems": 10,
"maxItems": 10,
"changes": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"after": {
"$ref": "#/$defs/__schema1"
},
"before": {
"$ref": "#/$defs/__schema0"
},
"changeId": {
"minLength": 1,
"type": "string"
},
"field": {
"minLength": 1,
"type": "string"
},
"impact": {
"enum": [
"none",
"additive",
"behavior-change",
"breaking"
],
"type": "string"
},
"kind": {
"minLength": 1,
"type": "string"
},
"registryId": {
"minLength": 1,
"type": "string"
},
"rowName": {
"minLength": 1,
"type": "string"
}
},
"required": [
"changeId",
"registryId",
"rowName",
"field",
"kind",
"impact"
],
"type": "object"
},
"type": "array"
},
"impact": {
"enum": [
"not-evaluated",
"none",
"additive",
"behavior-change",
"breaking"
],
"type": "string"
}
},
"required": [
"impact",
"changes"
],
"type": "object"
},
"currentDigest": {
"pattern": "^[a-f0-9]{64}$",
"type": "string"
},
"failures": {
"items": {
"type": "string"
},
"minItems": 1,
"type": "array"
},
"generatedAt": {
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"registries": {
"items": {
"additionalProperties": false,
"properties": {
"contract": {
"additionalProperties": {
"$ref": "#/$defs/__schema2"
},
"propertyNames": {
"type": "string"
},
"type": "object"
},
"owner": {
"minLength": 1,
"type": "string"
},
"registryId": {
"minLength": 1,
"type": "string"
},
"rowCount": {
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
},
"rows": {
"additionalProperties": {
"$ref": "#/$defs/__schema2"
},
"propertyNames": {
"type": "string"
},
"type": "object"
},
"source": {
"minLength": 1,
"type": "string"
}
},
"required": [
"registryId",
"owner",
@@ -46,9 +417,26 @@
"rowCount",
"contract",
"rows"
]
}
],
"type": "object"
},
"type": "array"
},
"schemaVersion": {
"const": 2,
"type": "number"
}
},
"additionalProperties": false
"required": [
"schemaVersion",
"generatedAt",
"baselineDigest",
"currentDigest",
"compatibility",
"failures",
"registries"
],
"type": "object"
}
]
}
@@ -1,8 +1,130 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://clean-architecture-frontend.local/schemas/supply-chain-verification.schema.json",
"type": "object",
"additionalProperties": false,
"properties": {
"dependencyDiff": {
"additionalProperties": false,
"properties": {
"added": {
"items": {
"minLength": 1,
"type": "string"
},
"type": "array"
},
"changed": {
"items": {
"minLength": 1,
"type": "string"
},
"type": "array"
},
"removed": {
"items": {
"minLength": 1,
"type": "string"
},
"type": "array"
},
"upgrades": {
"items": {
"additionalProperties": false,
"properties": {
"from": {
"minLength": 1,
"type": "string"
},
"name": {
"minLength": 1,
"type": "string"
},
"to": {
"minLength": 1,
"type": "string"
}
},
"required": [
"name",
"from",
"to"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"added",
"removed",
"changed",
"upgrades"
],
"type": "object"
},
"distSha256": {
"pattern": "^[a-f0-9]{64}$",
"type": "string"
},
"failures": {
"items": {
"type": "string"
},
"type": "array"
},
"highRiskReview": {
"items": {
"minLength": 1,
"type": "string"
},
"type": "array"
},
"localStatus": {
"enum": [
"PASS",
"FAIL"
],
"type": "string"
},
"lockfileSha256": {
"pattern": "^[a-f0-9]{64}$",
"type": "string"
},
"promotionStatus": {
"enum": [
"PASS",
"FAIL_UNVERIFIED"
],
"type": "string"
},
"provenanceAttestationStatus": {
"enum": [
"PASS",
"FAIL_UNVERIFIED"
],
"type": "string"
},
"sbomSha256": {
"pattern": "^[a-f0-9]{64}$",
"type": "string"
},
"schemaVersion": {
"const": 1,
"type": "number"
},
"sourceSetSha256": {
"pattern": "^[a-f0-9]{64}$",
"type": "string"
},
"vulnerabilityStatus": {
"enum": [
"PASS",
"FAIL",
"FAIL_UNVERIFIED"
],
"type": "string"
}
},
"required": [
"schemaVersion",
"localStatus",
@@ -17,39 +139,5 @@
"provenanceAttestationStatus",
"failures"
],
"properties": {
"schemaVersion": { "const": 1 },
"localStatus": { "enum": ["PASS", "FAIL"] },
"promotionStatus": {
"enum": ["PASS", "FAIL_UNVERIFIED"]
},
"lockfileSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"sourceSetSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"distSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"sbomSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"dependencyDiff": { "type": "object" },
"highRiskReview": { "type": "array" },
"vulnerabilityStatus": {
"enum": ["PASS", "FAIL", "FAIL_UNVERIFIED"]
},
"provenanceAttestationStatus": {
"enum": ["PASS", "FAIL_UNVERIFIED"]
},
"failures": {
"type": "array",
"items": { "type": "string" }
}
}
"type": "object"
}
+9 -5
View File
@@ -1,10 +1,13 @@
import { parseAsync } from "@babel/core";
import { spawnSync } from "node:child_process";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir } from "node:fs/promises";
import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createRequire } from "node:module";
import { architectureDependencyReportArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type PathRule = Readonly<{ path?: string; pathNot?: string }>;
type ArchitectureRule = Readonly<{
name: string;
@@ -156,10 +159,11 @@ dependencyReport.staticImportGraph = {
typeScriptOnlySourcePolicy: typeScriptOnlyPolicy,
};
await writeFile(
qualityArtifact,
`${JSON.stringify(dependencyReport, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: qualityArtifact,
schema: architectureDependencyReportArtifactSchema,
value: dependencyReport,
});
let architectureFailed = false;
+14 -10
View File
@@ -1,10 +1,13 @@
import { readFile, writeFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.ts";
import {
bundleOutputInventoryArtifactSchema,
bundlePerformanceArtifactSchema,
} from "./contracts/release-artifacts.ts";
import { classifyViteJavascript } from "./lib/classify-vite-bundle.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type BundleOutput = { path: string; gzipBytes: number };
type BundleReport = { outputs: BundleOutput[]; [key: string]: unknown };
type ViteManifest = Record<
string,
{ file: string; isEntry?: boolean; imports?: string[] }
@@ -14,9 +17,9 @@ type BundleBudgets = {
lazyChunkGzipBytes: number;
};
const report = JSON.parse(
await readFile("artifacts/performance/bundle.json", "utf8"),
) as BundleReport;
const report = bundleOutputInventoryArtifactSchema.parse(
JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8")) as unknown,
);
const viteManifest = JSON.parse(
await readFile("dist/.vite/manifest.json", "utf8"),
) as ViteManifest;
@@ -87,10 +90,11 @@ const completedReport = {
passed,
};
await writeFile(
"artifacts/performance/bundle.json",
`${JSON.stringify(completedReport, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/performance/bundle.json",
schema: bundlePerformanceArtifactSchema,
value: completedReport,
});
if (!passed) {
process.stderr.write(
`Bundle budget or manifest integrity failed: ${[
+103 -274
View File
@@ -1,313 +1,142 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import {
evaluatePromotionReadiness,
PROMOTION_FORMULA,
type GateResult,
} from "../src/application/policies/promotion-readiness.ts";
import {
loadCiGateContract,
indexCiGateContract,
} from "./contracts/ci-gates.ts";
import { generateCiWorkflow, renderCiWorkflow } from "./generate-ci-workflow.ts";
import {
ciContractReportSchema,
} from "./lib/ci-contract-report.ts";
import {
validateInstallScriptPolicy,
validatePackageScriptGraph,
} from "./lib/package-script-graph.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type GateDefinition = Readonly<{
steps?: readonly unknown[];
evidence?: readonly string[];
retentionClass?: string;
}>;
type CiContractDocument = Readonly<{
providerAdapter: string;
stages: Readonly<Record<string, Readonly<{ gates?: readonly string[] }>>>;
gates: Readonly<Record<string, GateDefinition>>;
retention: Readonly<{ durationStatus: unknown }>;
}>;
const document = parseCiContractDocument(
JSON.parse(await readFile("config/ci/gates.json", "utf8")),
);
const workflow = await readFile(document.providerAdapter, "utf8");
const nodeVersion = (await readFile(".nvmrc", "utf8")).trim();
const gateRunner = await readFile("scripts/run-ci-gate.ts", "utf8");
const drillRunner = await readFile("scripts/drill-runbook.ts", "utf8");
const buildManifestGenerator = await readFile(
"scripts/generate-build-manifest.ts",
"utf8",
);
const removalFixtureMode = process.argv.includes("--reduced-removal-fixture");
const contract = await loadCiGateContract(process.cwd(), {
mode: removalFixtureMode ? "removal-fixture" : "canonical",
});
const index = indexCiGateContract(contract);
const [packageDocument, nodeVersion] = await Promise.all([
readFile("package.json", "utf8").then((value) => JSON.parse(value) as { scripts?: Record<string, string> }),
readFile(".nvmrc", "utf8").then((value) => value.trim()),
]);
const packageScripts = packageDocument.scripts ?? {};
const failures: string[] = [];
if (!/^\d+\.\d+\.\d+$/.test(nodeVersion)) {
if (!/^\d+\.\d+\.\d+$/u.test(nodeVersion)) {
failures.push(".nvmrc must contain one exact Node.js semantic version");
}
const setupNodeCount =
workflow.match(/uses:\s*actions\/setup-node@v4/g)?.length ?? 0;
const nodeVersionFileCount =
workflow.match(/node-version-file:\s*\.nvmrc/g)?.length ?? 0;
if (setupNodeCount === 0 || nodeVersionFileCount !== setupNodeCount) {
failures.push("every setup-node step must use node-version-file: .nvmrc");
for (const script of [
"build:release-candidate",
"verify:local-evidence",
"verify:promotion",
"generate:ci-workflow",
"check:ci-workflow",
"check:ci",
]) {
if (!packageScripts[script]) failures.push(`package script missing ${script}`);
}
if (/node-version\s*:/.test(workflow) || /NODE_VERSION\s*:/.test(workflow)) {
failures.push("workflow must not override the exact .nvmrc Node.js pin");
for (const removedScript of [
"build:release",
"verify:supply-chain",
"verify:supply-chain:promotion",
]) {
if (packageScripts[removedScript]) failures.push(`legacy package script remains ${removedScript}`);
}
const stageFormula: Readonly<Record<string, readonly string[]>> = {
merge: PROMOTION_FORMULA.MERGE_READY,
release: PROMOTION_FORMULA.RELEASE_READY,
production: PROMOTION_FORMULA.PROD_PROMOTION_READY,
field: PROMOTION_FORMULA.FIELD_SLO_READY,
documentation: PROMOTION_FORMULA.DOCUMENTATION_READY,
};
if (/\b(?:build|rebuild)(?::[\w-]+)?\b/u.test(packageScripts["verify:promotion"] ?? "")) {
failures.push("verify:promotion must not build or rebuild candidate bytes");
}
failures.push(...validatePackageScriptGraph(packageScripts, "check:ci"));
failures.push(
...validateInstallScriptPolicy(
packageScripts,
[...new Set(contract.commands.map(({ script }) => script))],
),
);
for (const [stage, expectedGates] of Object.entries(stageFormula)) {
const actual = document.stages[stage]?.gates;
if (JSON.stringify(actual) !== JSON.stringify(expectedGates)) {
failures.push(`${stage} gate formula drift`);
const immutable = index.gates.get("FE-GATE-015");
const immutableCommands = immutable?.commandIds.map((id) => index.commands.get(id)?.script);
if (JSON.stringify(immutableCommands) !== JSON.stringify(["build:release-candidate", "verify:local-evidence"])) {
failures.push("FE-GATE-015 must build candidate bytes once and verify local evidence only");
}
const architecture = index.gates.get("FE-GATE-010");
if (!architecture?.commandIds.some((id) => index.commands.get(id)?.script === "check:ci")) {
failures.push("a blocking gate must execute check:ci");
}
if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.path === "artifacts/quality/ci-contract.json")) {
failures.push("FE-GATE-010 must publish the typed CI contract report");
}
const configuredGateIds = Object.keys(document.gates).sort();
const expectedGateIds = Array.from(
{ length: 26 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
);
if (JSON.stringify(configuredGateIds) !== JSON.stringify(expectedGateIds)) {
failures.push("gate registry must contain FE-GATE-001..026 exactly once");
}
for (const [gateId, gate] of Object.entries(document.gates)) {
if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) {
failures.push(`${gateId} lacks command, evidence, or retention wiring`);
}
for (const [index, step] of (gate.steps ?? []).entries()) {
if (!isRecord(step) || (step.expect !== "pass" && step.expect !== "fail")) {
failures.push(`${gateId}[${index}] has an invalid step expectation`);
continue;
}
if (step.expect === "pass") {
if (
step.expectedExitCode !== undefined ||
step.expectedDiagnosticId !== undefined
) {
failures.push(
`${gateId}[${index}] passing step declares a negative fixture identity`,
const passingResults: Record<string, GateResult> = Object.fromEntries(
expectedGateIds.map((gateId) => [gateId, "PASS"]),
);
}
continue;
}
if (
typeof step.expectedExitCode !== "number" ||
!Number.isSafeInteger(step.expectedExitCode) ||
step.expectedExitCode < 1 ||
step.expectedExitCode > 255
) {
failures.push(`${gateId}[${index}] lacks an exact expected exit code`);
}
const diagnosticId = step.expectedDiagnosticId;
if (
typeof diagnosticId !== "string" ||
diagnosticId.trim().length === 0 ||
diagnosticId.length > 256 ||
["\r", "\n", "\0"].some(
(character) =>
typeof diagnosticId === "string" && diagnosticId.includes(character),
)
) {
failures.push(
`${gateId}[${index}] lacks a bounded expected diagnostic identity`,
);
}
}
}
const runbookGateEvidence = Object.freeze({
"FE-GATE-016": "artifacts/runbooks/FE-RB-005/record.json",
"FE-GATE-021": "artifacts/runbooks/FE-RB-001/record.json",
"FE-GATE-022": "artifacts/runbooks/FE-RB-002/record.json",
"FE-GATE-023": "artifacts/runbooks/FE-RB-003/record.json",
"FE-GATE-024": "artifacts/runbooks/FE-RB-004/record.json",
"FE-GATE-025": "artifacts/runbooks/FE-RB-005/record.json",
});
for (const [gateId, evidencePath] of Object.entries(runbookGateEvidence)) {
const evidence = document.gates[gateId]?.evidence;
if (
!Array.isArray(evidence) ||
evidence.length !== 1 ||
evidence[0] !== evidencePath
) {
failures.push(`${gateId} runbook evidence path drift`);
}
}
if (
!drillRunner.includes(
"const artifactDirectory = `artifacts/runbooks/${runbookId}`",
) ||
drillRunner.includes(
"artifacts/runbooks/${runbookId}/${release.releaseId}",
)
) {
failures.push(
"runbook evidence path must be stable while releaseId stays in the record",
);
}
const forbiddenWorkflowPatterns = [
/continue-on-error\s*:/,
/retention-days\s*:/,
/allow_failure\s*:/,
];
for (const pattern of forbiddenWorkflowPatterns) {
if (pattern.test(workflow)) {
failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`);
}
}
const jobTimeoutCount = workflow.match(/timeout-minutes:\s*45/g)?.length ?? 0;
if (jobTimeoutCount !== 5) {
failures.push("every CI gate job must declare timeout-minutes: 45");
}
if (/if-no-files-found:\s*warn/.test(workflow)) {
failures.push("CI evidence upload must fail when artifacts are absent");
}
for (const requiredToken of [
"merge_gate:",
"release_gate:",
"production_gate:",
"field_gate:",
"documentation_gate:",
"needs: merge_gate",
"needs: release_gate",
"needs: production_gate",
"actions/upload-artifact@v4",
"if: always()",
"permissions:",
"contents: read",
'CI: "true"',
'VITE_BUILD_ID: "gitea-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
'VITE_COMMIT_SHA: "${{ gitea.sha }}"',
'RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"',
]) {
if (!workflow.includes(requiredToken)) {
failures.push(`workflow missing ${requiredToken}`);
}
}
for (const requiredToken of [
"ciCheckoutIdentityFailures",
"ciBuildEnvironmentFailures",
"SOURCE_DATE_EPOCH",
'"--format=%H%n%ct"',
"env: gateEnvironment",
"classifyGateStepResult",
"timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS",
]) {
if (!gateRunner.includes(requiredToken)) {
failures.push(`CI gate runner missing ${requiredToken}`);
}
}
for (const requiredToken of [
"assertCiBuildEnvironment(process.env)",
"releaseId",
"sourceDateEpoch",
]) {
if (!buildManifestGenerator.includes(requiredToken)) {
failures.push(`build manifest generator missing ${requiredToken}`);
}
}
const passingResults: Record<string, GateResult> = {};
for (const gateId of expectedGateIds) passingResults[gateId] = "PASS";
const allPass = evaluatePromotionReadiness(passingResults);
const negativeFixtures: Array<{
readiness: keyof typeof PROMOTION_FORMULA;
failedGate: string;
passed: boolean;
}> = [];
for (const readiness of Object.keys(PROMOTION_FORMULA) as Array<
keyof typeof PROMOTION_FORMULA
>) {
const gateIds = PROMOTION_FORMULA[readiness];
const failedGate = gateIds[0];
if (!failedGate) throw new Error(`${readiness} has no configured gates`);
const result = evaluatePromotionReadiness({
...passingResults,
[failedGate]: "FAIL",
});
const passed = result[readiness] === false;
for (const readiness of Object.keys(PROMOTION_FORMULA) as Array<keyof typeof PROMOTION_FORMULA>) {
const failedGate = PROMOTION_FORMULA[readiness][0];
if (!failedGate) throw new TypeError(`${readiness} has no configured gates`);
const evaluated = evaluatePromotionReadiness({ ...passingResults, [failedGate]: "FAIL" });
const passed = evaluated[readiness] === false;
negativeFixtures.push({ readiness, failedGate, passed });
if (!passed) failures.push(`${readiness} did not fail closed`);
}
if (!Object.values(allPass).every(Boolean)) {
failures.push("all-PASS formula did not produce every readiness state");
}
if (!Object.values(allPass).every(Boolean)) failures.push("all-PASS formula did not produce every readiness state");
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
providerAdapter: document.providerAdapter,
const renderedWorkflow = renderCiWorkflow(contract);
const workflowCheck = await generateCiWorkflow({
root: process.cwd(),
contract,
check: true,
});
if (!workflowCheck.matches) {
failures.push(
`generated workflow drift at byte ${workflowCheck.firstDifferenceByte ?? "missing"}, line ${workflowCheck.firstDifferenceLine ?? "missing"}`,
);
}
let checkedWorkflowBytes = Buffer.from(renderedWorkflow, "utf8");
try {
checkedWorkflowBytes = await readFile(workflowCheck.target);
} catch (error) {
failures.push(`generated workflow is unreadable: ${error instanceof Error ? error.message : String(error)}`);
}
const report = ciContractReportSchema.parse({
schemaVersion: 2,
nodeVersion,
gateCount: configuredGateIds.length,
noDowngrade: failures.every(
(failure) => !failure.includes("downgrade"),
),
durationStatus: document.retention.durationStatus,
gateCount: contract.gates.length,
commandDefinitionCount: contract.commands.length,
commandReferenceCount: contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0),
artifactCount: contract.artifacts.length,
jobCount: contract.jobs.length,
workflowSha256: createHash("sha256").update(checkedWorkflowBytes).digest("hex"),
durationStatus: contract.retention.durationStatus,
negativeFixtures,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/ci-contract.json",
`${JSON.stringify(report, null, 2)}\n`,
);
});
await writeValidatedJsonArtifact({
path: "artifacts/quality/ci-contract.json",
schema: ciContractReportSchema,
value: report,
});
if (failures.length > 0) {
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write("CI contract: 26 blocking gates and 4-tier graph PASS\n");
function parseCiContractDocument(value: unknown): CiContractDocument {
if (!isRecord(value)) throw new TypeError("CI gate config must be an object");
if (
typeof value.providerAdapter !== "string" ||
!isRecord(value.stages) ||
!isRecord(value.gates) ||
!isRecord(value.retention)
) {
throw new TypeError("CI gate config is missing required registries");
}
const stages: Record<string, { gates?: readonly string[] }> = {};
for (const [stage, candidate] of Object.entries(value.stages)) {
if (!isRecord(candidate)) throw new TypeError(`Invalid CI stage: ${stage}`);
if (
candidate.gates !== undefined &&
(!Array.isArray(candidate.gates) ||
!candidate.gates.every((gate) => typeof gate === "string"))
) {
throw new TypeError(`Invalid gate list for CI stage: ${stage}`);
}
stages[stage] = {
gates: candidate.gates as readonly string[] | undefined,
};
}
const gates: Record<string, GateDefinition> = {};
for (const [gateId, candidate] of Object.entries(value.gates)) {
if (!isRecord(candidate)) throw new TypeError(`Invalid CI gate: ${gateId}`);
if (
candidate.evidence !== undefined &&
(!Array.isArray(candidate.evidence) ||
!candidate.evidence.every((path) => typeof path === "string"))
) {
throw new TypeError(`Invalid evidence list for CI gate: ${gateId}`);
}
gates[gateId] = {
steps: Array.isArray(candidate.steps) ? candidate.steps : undefined,
evidence: candidate.evidence as readonly string[] | undefined,
retentionClass:
typeof candidate.retentionClass === "string"
? candidate.retentionClass
: undefined,
};
}
return {
providerAdapter: value.providerAdapter,
stages,
gates,
retention: { durationStatus: value.retention.durationStatus },
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
process.stdout.write("CI contract: 26 gates, strict v2 graph and generated workflow model PASS\n");
+8 -9
View File
@@ -1,6 +1,8 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile } from "node:fs/promises";
import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.ts";
import { compatibilityFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type CompatibilitySchema = Readonly<{
required?: readonly string[];
@@ -32,10 +34,10 @@ for (const [family, cases] of Object.entries(fixtures.families)) {
}
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/compatibility.json",
`${JSON.stringify(
{
await writeValidatedJsonArtifact({
path: "artifacts/release/compatibility.json",
schema: compatibilityFixturesArtifactSchema,
value: {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
rules: [
@@ -47,10 +49,7 @@ await writeFile(
],
results,
},
null,
2,
)}\n`,
);
});
if (results.some((result) => !result.passed)) {
process.stderr.write("Compatibility fixture classification failed.\n");
+11 -7
View File
@@ -1,4 +1,7 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir } from "node:fs/promises";
import { designSystemReportArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import path from "node:path";
import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts";
@@ -118,7 +121,7 @@ for (const file of sources) {
}
}
const report = {
const report = designSystemReportArtifactSchema.parse({
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
checkedTokenCount:
@@ -127,14 +130,15 @@ const report = {
REQUIRED_COMPONENT_TOKENS.length,
failures,
passed: failures.length === 0,
};
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
await writeValidatedJsonArtifact({
path: fixtureMode
? "artifacts/quality/design-system-fixture.json"
: "artifacts/quality/design-system.json",
`${JSON.stringify(report, null, 2)}\n`,
);
schema: designSystemReportArtifactSchema,
value: report,
});
if (failures.length > 0) {
process.stderr.write(`Design system contract failed:\n${failures.join("\n")}\n`);
+11 -7
View File
@@ -1,4 +1,7 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir } from "node:fs/promises";
import { diagnosticsReportArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import path from "node:path";
import { DIAGNOSTIC_EVENT_REGISTRY } from "../src/contracts/diagnostics.ts";
@@ -89,7 +92,7 @@ for (const file of sources) {
}
}
const report = {
const report = diagnosticsReportArtifactSchema.parse({
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
telemetryEventCount: Object.keys(TELEMETRY_REGISTRY).length,
@@ -97,14 +100,15 @@ const report = {
checkedFiles: sources.length,
failures,
passed: failures.length === 0,
};
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
await writeValidatedJsonArtifact({
path: fixtureMode
? "artifacts/quality/diagnostics-fixture.json"
: "artifacts/quality/diagnostics.json",
`${JSON.stringify(report, null, 2)}\n`,
);
schema: diagnosticsReportArtifactSchema,
value: report,
});
if (failures.length > 0) {
process.stderr.write(
`Diagnostics contract failed:\n${failures.join("\n")}\n`,
+11 -7
View File
@@ -1,4 +1,7 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir } from "node:fs/promises";
import { i18nReportArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import path from "node:path";
import { EN_MESSAGES, KO_MESSAGES, MESSAGE_CATALOGS } from "../src/presentation/i18n/catalog.ts";
@@ -77,7 +80,7 @@ for (const file of sources) {
}
}
const report = {
const report = i18nReportArtifactSchema.parse({
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
localeCount: Object.keys(MESSAGE_CATALOGS).length + 1,
@@ -85,14 +88,15 @@ const report = {
checkedFiles: sources.length,
failures,
passed: failures.length === 0,
};
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
await writeValidatedJsonArtifact({
path: fixtureMode
? "artifacts/quality/i18n-fixture.json"
: "artifacts/quality/i18n.json",
`${JSON.stringify(report, null, 2)}\n`,
);
schema: i18nReportArtifactSchema,
value: report,
});
if (failures.length > 0) {
process.stderr.write(`I18n contract failed:\n${failures.join("\n")}\n`);
+9 -6
View File
@@ -1,11 +1,13 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { measureOptionalRecipeBundle } from "./lib/optional-recipe-bundle.ts";
import { optionalRecipeFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
import {
scanOptionalRecipeSources,
scanProductionBundle,
validateRecipeCatalog,
} from "./lib/optional-recipes.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type ReferenceRuntimeRecipe = Readonly<{
id: string;
@@ -176,17 +178,18 @@ const results = [
),
},
];
const report = {
const report = optionalRecipeFixturesArtifactSchema.parse({
schemaVersion: 1,
results,
bundleBudgetFixtures,
passed: results.every(({ passed }) => passed),
};
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/optional-recipe-fixtures.json",
`${JSON.stringify(report, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/quality/optional-recipe-fixtures.json",
schema: optionalRecipeFixturesArtifactSchema,
value: report,
});
if (!report.passed) {
process.stderr.write(
`Optional recipe negative fixtures failed: ${results
+10 -4
View File
@@ -1,5 +1,6 @@
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { mkdir, readFile, stat } from "node:fs/promises";
import { optionalRecipesArtifactSchema } from "./contracts/release-artifacts.ts";
import {
measureOptionalRecipeBundle,
type OptionalRecipeBundleMeasurement,
@@ -10,6 +11,7 @@ import {
validateRecipeCatalog,
} from "./lib/optional-recipes.ts";
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type GateViolation = Readonly<{
ruleId: string;
@@ -141,7 +143,7 @@ const violations: GateViolation[] = [
...referenceRuntimeBundleConfigurationViolations,
...referenceRuntimeBundleMeasurementViolations,
];
const report = {
const report = optionalRecipesArtifactSchema.parse({
schemaVersion: 1,
decisionId: "VD-10",
selectedCapabilities: [],
@@ -169,9 +171,13 @@ const report = {
: "NOT_BUILT",
violations,
passed: violations.length === 0,
};
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
await writeValidatedJsonArtifact({
path: artifactPath,
schema: optionalRecipesArtifactSchema,
value: report,
});
if (violations.length > 0) {
process.stderr.write(
+9 -6
View File
@@ -1,14 +1,16 @@
import { mkdir, writeFile } from "node:fs/promises";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { realtimeBoundariesArtifactSchema } from "./contracts/release-artifacts.ts";
import { scanRealtimeBoundaries } from "./lib/realtime-boundaries.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const sourceRoot = argument("--source-root") ?? "src";
const artifact =
argument("--artifact") ??
"artifacts/quality/realtime-boundaries.json";
const violations = await scanRealtimeBoundaries(sourceRoot);
const report = Object.freeze({
const report = realtimeBoundariesArtifactSchema.parse({
schemaVersion: 1,
sourceRoot,
violations,
@@ -16,10 +18,11 @@ const report = Object.freeze({
});
await mkdir(path.dirname(artifact), { recursive: true });
await writeFile(
artifact,
`${JSON.stringify(report, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: artifact,
schema: realtimeBoundariesArtifactSchema,
value: report,
});
if (violations.length > 0) {
for (const violation of violations) {
+253 -21
View File
@@ -3,7 +3,6 @@ import {
mkdir,
readFile,
readdir,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -16,6 +15,11 @@ import {
verifyRegistryBaselineApproval,
} from "./lib/registry-compatibility.ts";
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
import {
registryGovernanceRunArtifactSchema,
registrySnapshotArtifactSchema,
} from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type RegistryRow = Record<string, unknown>;
type RegistryRows = Record<string, RegistryRow>;
@@ -25,16 +29,24 @@ type RegistryReference = Readonly<{
targetField: string;
}>;
type RegistryConsumer = Readonly<{ path: string; token: string }>;
type RegistrySnapshotProjection = Readonly<{
singletonRowKey: string;
canonicalArrayKeyFields: Readonly<Record<string, readonly string[]>>;
}>;
type RegistrySpecification = Readonly<{
registryId: string;
owner: string;
path: string;
exportName: string;
declaredRows?: unknown;
rowsPath?: string;
rowKeyFields?: readonly string[];
requiredFields: readonly string[];
fieldTypes?: Readonly<Record<string, string>>;
keyField?: string;
uniqueFields?: readonly string[];
uniqueFieldSets?: readonly (readonly string[])[];
snapshotProjection?: RegistrySnapshotProjection;
allowedValues?: Readonly<Record<string, readonly unknown[]>>;
references?: readonly RegistryReference[];
breakingFields?: readonly string[];
@@ -70,8 +82,9 @@ const governancePath =
const artifactPath =
argumentValue("--artifact", "artifacts/quality/registries.json") ??
"artifacts/quality/registries.json";
const usesRepositoryGovernance = governancePath === defaultGovernancePath;
const usesRepositoryBaseline =
governancePath === defaultGovernancePath &&
usesRepositoryGovernance &&
!process.argv.includes("--no-baseline");
const baselinePath = argumentValue(
"--baseline",
@@ -145,6 +158,178 @@ function matchesDeclaredType(value: unknown, declaration: string): boolean {
);
}
type FieldLookup = Readonly<{
found: boolean;
value: unknown;
}>;
function lookupField(value: unknown, fieldPath: string): FieldLookup {
let current = value;
for (const segment of fieldPath.split(".")) {
if (
!current ||
typeof current !== "object" ||
Array.isArray(current) ||
!Object.hasOwn(current, segment)
) {
return Object.freeze({ found: false, value: undefined });
}
current = (current as Record<string, unknown>)[segment];
}
return Object.freeze({ found: true, value: current });
}
function projectRegistryRows(
specification: RegistrySpecification,
exportedValue: unknown,
): RegistryRows | null {
const selected = specification.rowsPath
? lookupField(exportedValue, specification.rowsPath)
: Object.freeze({ found: true, value: exportedValue });
if (!selected.found) {
failures.push(
`${specification.registryId} is missing rows path ${specification.rowsPath}`,
);
return null;
}
if (!Array.isArray(selected.value)) {
if (
!selected.value ||
typeof selected.value !== "object"
) {
failures.push(`${specification.registryId} is not an object registry`);
return null;
}
return selected.value as RegistryRows;
}
const projected: RegistryRows = {};
const keyOccurrences = new Map<string, number>();
for (const [index, row] of selected.value.entries()) {
if (!row || typeof row !== "object" || Array.isArray(row)) {
projected[String(index)] = row as RegistryRow;
continue;
}
const keyValues = (specification.rowKeyFields ?? []).map(
(field) => lookupField(row, field).value,
);
const baseKey =
keyValues.length > 0
? JSON.stringify(keyValues.map(canonicalizeRegistryValue))
: String(index);
const occurrence = keyOccurrences.get(baseKey) ?? 0;
keyOccurrences.set(baseKey, occurrence + 1);
projected[occurrence === 0 ? baseKey : `${baseKey}#${occurrence + 1}`] =
row as RegistryRow;
}
return projected;
}
function snapshotKeyField(
value: unknown,
fieldPath: string,
): FieldLookup {
return fieldPath === "$value"
? Object.freeze({ found: true, value })
: lookupField(value, fieldPath);
}
function replaceProjectedField(
row: RegistryRow,
fieldPath: string,
value: unknown,
): boolean {
const segments = fieldPath.split(".");
const finalSegment = segments.pop();
if (!finalSegment) return false;
let current = row;
for (const segment of segments) {
const next = current[segment];
if (!next || typeof next !== "object" || Array.isArray(next)) {
return false;
}
current = next as RegistryRow;
}
current[finalSegment] = value;
return true;
}
function projectSnapshotRows(
specification: RegistrySpecification,
exportedValue: unknown,
validatedRows: RegistryRows,
): RegistryRows | null {
const projection = specification.snapshotProjection;
if (!projection) return validatedRows;
if (
!exportedValue ||
typeof exportedValue !== "object" ||
Array.isArray(exportedValue)
) {
failures.push(
`${specification.registryId} snapshot projection requires an object export`,
);
return null;
}
const canonicalRow = canonicalizeRegistryValue(exportedValue) as RegistryRow;
for (const [fieldPath, keyFields] of Object.entries(
projection.canonicalArrayKeyFields,
)) {
const selected = lookupField(exportedValue, fieldPath);
if (!selected.found || !Array.isArray(selected.value)) {
failures.push(
`${specification.registryId} snapshot field ${fieldPath} must be an array`,
);
return null;
}
const keyedItems: Array<{ identity: string; value: unknown }> = [];
const identities = new Set<string>();
for (const [index, item] of selected.value.entries()) {
const keyValues = keyFields.map((field) => snapshotKeyField(item, field));
const missingIndex = keyValues.findIndex((field) => !field.found);
if (missingIndex >= 0) {
failures.push(
`${specification.registryId} snapshot field ${fieldPath}[${index}] is missing key ${keyFields[missingIndex]}`,
);
return null;
}
const identity = JSON.stringify(
keyValues.map((field) => canonicalizeRegistryValue(field.value)),
);
if (identities.has(identity)) {
failures.push(
`${specification.registryId} snapshot field ${fieldPath} duplicates key ${identity}`,
);
return null;
}
identities.add(identity);
keyedItems.push({
identity,
value: canonicalizeRegistryValue(item),
});
}
keyedItems.sort((left, right) =>
left.identity.localeCompare(right.identity),
);
if (
!replaceProjectedField(
canonicalRow,
fieldPath,
keyedItems.map((item) => item.value),
)
) {
failures.push(
`${specification.registryId} snapshot field ${fieldPath} cannot be projected`,
);
return null;
}
}
return Object.freeze({
[projection.singletonRowKey]: canonicalRow,
});
}
async function filesBelow(directory: string): Promise<string[]> {
try {
const entries = await readdir(directory, { withFileTypes: true });
@@ -180,12 +365,10 @@ for (const specification of governance.registries) {
if (!rows) failures.push(`missing registry source ${specification.path}`);
}
if (!rows || typeof rows !== "object" || Array.isArray(rows)) {
failures.push(`${specification.registryId} is not an object registry`);
const registryRows = projectRegistryRows(specification, rows);
if (!registryRows) {
continue;
}
const registryRows = rows as RegistryRows;
rowsByRegistry.set(specification.registryId, registryRows);
sourcesByRegistry.set(
specification.registryId,
@@ -198,7 +381,7 @@ for (const specification of governance.registries) {
continue;
}
for (const field of specification.requiredFields) {
if (!(field in row)) {
if (!lookupField(row, field).found) {
failures.push(`${specification.registryId}.${rowName} missing ${field}`);
}
}
@@ -206,17 +389,20 @@ for (const specification of governance.registries) {
specification.fieldTypes ?? {},
)) {
if (
field in row &&
!matchesDeclaredType(row[field], String(declaredType))
lookupField(row, field).found &&
!matchesDeclaredType(
lookupField(row, field).value,
String(declaredType),
)
) {
failures.push(
`${specification.registryId}.${rowName}.${field} expected ${declaredType}, received ${runtimeType(row[field])}`,
`${specification.registryId}.${rowName}.${field} expected ${declaredType}, received ${runtimeType(lookupField(row, field).value)}`,
);
}
}
if (
specification.keyField &&
row[specification.keyField] !== rowName
lookupField(row, specification.keyField).value !== rowName
) {
failures.push(
`${specification.registryId}.${rowName}.${specification.keyField} must match its registry key`,
@@ -228,8 +414,9 @@ for (const specification of governance.registries) {
const values = new Map<string, string>();
for (const [rowName, row] of Object.entries(registryRows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const value = row[field];
if (value === undefined) continue;
const selected = lookupField(row, field);
if (!selected.found) continue;
const value = selected.value;
const identity = JSON.stringify(canonicalizeRegistryValue(value));
if (values.has(identity)) {
failures.push(
@@ -241,16 +428,38 @@ for (const specification of governance.registries) {
}
}
for (const fields of specification.uniqueFieldSets ?? []) {
const values = new Map<string, string>();
for (const [rowName, row] of Object.entries(registryRows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const selected = fields.map((field) => lookupField(row, field));
if (selected.some((field) => !field.found)) continue;
const identity = JSON.stringify(
selected.map((field) => canonicalizeRegistryValue(field.value)),
);
const previous = values.get(identity);
if (previous) {
failures.push(
`${specification.registryId}.${rowName} duplicates ${fields.join("+")}=${identity} from ${previous}`,
);
} else {
values.set(identity, rowName);
}
}
}
for (const [field, allowed] of Object.entries(
specification.allowedValues ?? {},
)) {
for (const [rowName, row] of Object.entries(registryRows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
if (
!allowed.some((value) => Object.is(value, row[field]))
!allowed.some((value) =>
Object.is(value, lookupField(row, field).value),
)
) {
failures.push(
`${specification.registryId}.${rowName}.${field} has unknown value ${String(row[field])}`,
`${specification.registryId}.${rowName}.${field} has unknown value ${String(lookupField(row, field).value)}`,
);
}
}
@@ -264,14 +473,28 @@ for (const specification of governance.registries) {
references: specification.references ?? [],
keyField: specification.keyField ?? null,
breakingFields: specification.breakingFields ?? [],
...(specification.rowsPath
? { rowsPath: specification.rowsPath }
: {}),
...(specification.rowKeyFields
? { rowKeyFields: specification.rowKeyFields }
: {}),
...(specification.uniqueFieldSets
? { uniqueFieldSets: specification.uniqueFieldSets }
: {}),
...(specification.snapshotProjection
? { snapshotProjection: specification.snapshotProjection }
: {}),
});
const snapshotRows = projectSnapshotRows(specification, rows, registryRows);
if (!snapshotRows) continue;
snapshots.push({
registryId: specification.registryId,
owner: specification.owner,
source: sourcePath ?? specification.path,
rowCount: Object.keys(registryRows).length,
rowCount: Object.keys(snapshotRows).length,
contract,
rows: canonicalizeRegistryValue(registryRows),
rows: canonicalizeRegistryValue(snapshotRows),
});
}
@@ -289,12 +512,12 @@ for (const specification of governance.registries) {
const targetValues = new Set(
Object.values(targetRows)
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
.map((row) => row[reference.targetField])
.map((row) => lookupField(row, reference.targetField).value)
.filter((value) => value !== undefined && value !== null),
);
for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const value = row[reference.field];
const value = lookupField(row, reference.field).value;
if (
value !== undefined &&
value !== null &&
@@ -346,7 +569,10 @@ for (const specification of governance.registries) {
) {
continue;
}
const identity = row[specification.consumerIdentityField];
const identity = lookupField(
row,
specification.consumerIdentityField,
).value;
if (
(typeof identity !== "string" &&
typeof identity !== "number") ||
@@ -454,7 +680,13 @@ if (usesRepositoryBaseline && failures.length === 0) {
}
}
await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
await writeValidatedJsonArtifact({
path: artifactPath,
schema: usesRepositoryGovernance
? registrySnapshotArtifactSchema
: registryGovernanceRunArtifactSchema,
value: report,
});
if (failures.length > 0) {
process.stderr.write(`Registry governance failed:\n${failures.join("\n")}\n`);
@@ -1,10 +1,12 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile } from "node:fs/promises";
import { registryCompatibilityFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
import {
diffRegistrySnapshots,
validateBreakingEvidence,
verifyRegistryBaselineApproval,
} from "./lib/registry-compatibility.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const fixtures = JSON.parse(
await readFile(
@@ -50,10 +52,11 @@ results.push({
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/registry-compatibility-fixtures.json",
`${JSON.stringify({ schemaVersion: 1, results }, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/quality/registry-compatibility-fixtures.json",
schema: registryCompatibilityFixturesArtifactSchema,
value: { schemaVersion: 1, results },
});
if (results.some((result) => !result.passed)) {
process.stderr.write("Registry compatibility fixture failed.\n");
process.exit(1);
+59 -81
View File
@@ -1,100 +1,78 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
type CoverageMetrics = Record<string, { pct: number }>;
type CoveragePolicy = Readonly<{
summary: Record<string, number>;
criticalModules: readonly Readonly<{
path: string;
minimum: Record<string, number>;
}>[];
}>;
type CoverageSummary = Record<string, CoverageMetrics>;
type CoverageResult = Readonly<{
scope: string;
metric: string;
threshold: number;
received: number | undefined;
passed: boolean;
}>;
import {
readRiskCoverageInput,
writeRiskCoverageArtifactAtomic,
} from "./lib/risk-coverage-files.ts";
import {
buildProductionModuleInventory,
evaluateRiskCoverage,
parseRepositoryRiskCoveragePolicy,
} from "./lib/risk-coverage.ts";
function argumentValue(name: string, fallback: string): string {
function argumentValue(name: string, fallback?: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 && process.argv[index + 1]
? process.argv[index + 1]
: fallback;
return index >= 0 ? process.argv[index + 1] : fallback;
}
const policyPath = argumentValue(
function requiredArgument(name: string, fallback: string): string {
const value = argumentValue(name, fallback);
if (!value) throw new TypeError(`${name} requires a path`);
return value;
}
const repositoryRoot = path.resolve(
requiredArgument("--repository-root", process.cwd()),
);
const policyInput = await readRiskCoverageInput({
repositoryRoot,
relativePath: requiredArgument(
"--policy",
"config/testing/risk-coverage.json",
);
const summaryPath = argumentValue(
),
label: "policy",
});
const summaryInput = await readRiskCoverageInput({
repositoryRoot,
relativePath: requiredArgument(
"--summary",
"artifacts/tests/coverage/coverage-summary.json",
);
const artifactPath = argumentValue(
),
label: "summary",
});
const artifactPath = requiredArgument(
"--artifact",
"artifacts/quality/risk-coverage.json",
);
const policy = JSON.parse(
await readFile(policyPath, "utf8"),
) as CoveragePolicy;
const summary = JSON.parse(
await readFile(summaryPath, "utf8"),
) as CoverageSummary;
const failures: string[] = [];
const results: CoverageResult[] = [];
function evaluate(
scope: string,
actual: CoverageMetrics,
minimum: Record<string, number>,
): void {
for (const [metric, threshold] of Object.entries(minimum)) {
const received = actual?.[metric]?.pct;
const passed =
typeof received === "number" &&
Number.isFinite(received) &&
received >= threshold;
results.push({ scope, metric, threshold, received, passed });
if (!passed) {
failures.push(
`${scope}.${metric} expected >= ${threshold}, received ${String(received)}`,
const policy = parseRepositoryRiskCoveragePolicy(
JSON.parse(policyInput.text) as unknown,
);
}
}
}
const inventory = await buildProductionModuleInventory({
repositoryRoot,
generatedPaths: policy.generatedPaths,
});
const result = evaluateRiskCoverage({
repositoryRoot,
inventory,
policy,
summary: JSON.parse(summaryInput.text) as unknown,
});
await writeRiskCoverageArtifactAtomic({
repositoryRoot,
relativePath: artifactPath,
inputPaths: [policyInput.relativePath, summaryInput.relativePath],
value: {
schemaVersion: 3,
policy: policyInput.relativePath,
summary: summaryInput.relativePath,
...result,
},
});
evaluate("total", summary.total, policy.summary);
for (const modulePolicy of policy.criticalModules) {
const key = Object.keys(summary).find(
(candidate) =>
candidate !== "total" &&
candidate.replaceAll("\\", "/").endsWith(`/${modulePolicy.path}`),
);
if (!key) {
failures.push(`critical module missing from coverage: ${modulePolicy.path}`);
continue;
}
evaluate(modulePolicy.path, summary[key], modulePolicy.minimum);
}
const artifact = {
schemaVersion: 1,
policy: policyPath,
summary: summaryPath,
status: failures.length === 0 ? "PASS" : "FAIL",
results,
failures,
};
await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`);
if (failures.length > 0) {
process.stderr.write(`Risk coverage failed:\n- ${failures.join("\n- ")}\n`);
if (result.failures.length > 0) {
process.stderr.write(`Risk coverage failed:\n- ${result.failures.join("\n- ")}\n`);
process.exit(1);
}
process.stdout.write(
`Risk coverage: PASS (${results.length} scoped thresholds)\n`,
`Risk coverage: PASS (${result.selectedTotal}/${result.repositoryTotal} production modules, ${result.results.length} thresholds)\n`,
);
+10
View File
@@ -0,0 +1,10 @@
import {
checkNonmatchingSecurityIncludeFixture,
checkSecurityFixtures,
} from "./lib/security-fixture-check.ts";
await checkNonmatchingSecurityIncludeFixture();
await checkSecurityFixtures();
process.stdout.write(
"Security fixtures: unmatched include rejected; 3 forbidden files detected\n",
);
+8 -5
View File
@@ -1,5 +1,6 @@
import { mkdir, writeFile } from "node:fs/promises";
import { mkdir } from "node:fs/promises";
import { supplyChainFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
import {
diffDependencyInventories,
isValidSha512Integrity,
@@ -9,6 +10,7 @@ import {
validateVulnerabilityReport,
verifySupplyChainCoherence,
} from "./lib/supply-chain.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const integrity = `sha512-${Buffer.alloc(64, 1).toString("base64")}`;
const baseDependency = {
@@ -137,10 +139,11 @@ const results = [
},
];
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/supply-chain-fixtures.json",
`${JSON.stringify({ schemaVersion: 1, results }, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/security/supply-chain-fixtures.json",
schema: supplyChainFixturesArtifactSchema,
value: { schemaVersion: 1, results },
});
if (results.some((result) => !result.passed)) {
process.stderr.write("Supply-chain negative fixture failed.\n");
process.exit(1);
+360 -106
View File
@@ -1,121 +1,375 @@
import { spawnSync } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import {
createHash,
generateKeyPairSync,
sign,
type KeyObject,
} from "node:crypto";
import {
cp,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
type Document = Record<string, unknown>;
import {
localEvidenceAssessmentArtifactSchema,
supplyChainProviderFixturesArtifactSchema,
} from "./contracts/release-artifacts.ts";
import { PROMOTED_FILE_NAMES } from "./contracts/promotion-artifacts.ts";
import {
captureCiCandidateArchive,
} from "./lib/ci-candidate-archive.ts";
import {
cleanupFinalizedPromotion,
finalizeVerifiedPromotion,
} from "./lib/promotion-stager.ts";
import { verifyExactPromotionBundle } from "./lib/exact-promotion-bundle.ts";
import {
providerEvidenceSignaturePayload,
providerPublicKeyFingerprint,
} from "./lib/provider-evidence.ts";
import {
LOCAL_EVIDENCE_ASSESSMENT_PATH,
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
releaseCandidateManifestSchema,
} from "./lib/release-candidate.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
function isRecord(value: unknown): value is Document {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
async function readDocument(file: string): Promise<Document> {
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
if (!isRecord(parsed)) throw new Error(`${file} must be a JSON object`);
return parsed;
}
const fixtureDirectory = path.resolve(".tmp/supply-chain-provider-fixture");
await rm(fixtureDirectory, { recursive: true, force: true });
await mkdir(fixtureDirectory, { recursive: true });
const inventory = await readDocument(
"artifacts/release/dependency-inventory.json",
);
const verification = await readDocument(
"artifacts/security/supply-chain-verification.json",
);
const vulnerabilityPath = path.join(
fixtureDirectory,
"vulnerability-report.json",
);
const attestationPath = path.join(fixtureDirectory, "attestation.json");
await writeFile(
vulnerabilityPath,
`${JSON.stringify(
{
schemaVersion: 1,
provider: "fixture-scanner",
scannedLockfileSha256: inventory.lockfileSha256,
generatedAt: "2026-07-26T00:00:00.000Z",
findings: [],
const repositoryRoot = process.cwd();
const fixtureRoot = await mkdtemp(path.join(tmpdir(), "provider-exact-five-fixture-"));
try {
await cp(repositoryRoot, fixtureRoot, {
recursive: true,
filter: (source) => {
const relative = path.relative(repositoryRoot, source);
if (!relative) return true;
const first = relative.split(path.sep)[0];
return ![".release", "artifacts", "dist", "node_modules"].includes(first ?? "");
},
null,
2,
)}\n`,
);
await writeFile(
attestationPath,
`${JSON.stringify(
{
schemaVersion: 1,
provider: "fixture-attestor",
signer: "fixture-workload-identity",
subject: {
name: "dist",
digest: { sha256: verification.distSha256 },
},
},
null,
2,
)}\n`,
);
const providerRun = spawnSync(
"node",
["scripts/generate-supply-chain.ts"],
{
});
await cp(path.join(repositoryRoot, "artifacts"), path.join(fixtureRoot, "artifacts"), {
recursive: true,
});
await rm(path.join(fixtureRoot, "artifacts/release"), { recursive: true, force: true });
await symlink(path.join(repositoryRoot, "node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
cwd: repositoryRoot,
encoding: "utf8",
});
if (git.status !== 0) throw new Error(`provider fixture git identity failed: ${git.stderr}`);
const [revision, sourceDateEpoch] = git.stdout.trim().split(/\r?\n/u);
if (!revision || !sourceDateEpoch) throw new Error("provider fixture git identity is incomplete");
const build = spawnSync("corepack", ["pnpm", "build:release-candidate"], {
cwd: fixtureRoot,
encoding: "utf8",
timeout: 120_000,
maxBuffer: 32 * 1024 * 1024,
env: {
...process.env,
VULNERABILITY_REPORT_PATH: vulnerabilityPath,
PROVENANCE_ATTESTATION_PATH: attestationPath,
CI: "true",
VITE_BUILD_ID: "provider-exact-five-fixture",
VITE_COMMIT_SHA: revision,
RELEASE_ID: "provider-exact-five-fixture",
SOURCE_DATE_EPOCH: sourceDateEpoch,
CI_RUNNER_IMAGE: `fixture@sha256:${"a".repeat(64)}`,
},
encoding: "utf8",
});
if (build.status !== 0) throw new Error(`${build.stdout}\n${build.stderr}`);
const manifest = releaseCandidateManifestSchema.parse(
JSON.parse(
await readFile(path.join(fixtureRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"),
) as unknown,
);
const assessment = localEvidenceAssessmentArtifactSchema.parse(
JSON.parse(
await readFile(path.join(fixtureRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH), "utf8"),
) as unknown,
);
const archivePath = path.join(fixtureRoot, "candidate.tar.gz");
const tar = spawnSync(
"/usr/bin/tar",
[
"--sort=name",
"--mtime=@0",
"--owner=0",
"--group=0",
"--numeric-owner",
"-czf",
archivePath,
"dist",
...RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
],
{ cwd: fixtureRoot, encoding: "utf8" },
);
if (tar.status !== 0) throw new Error(`provider fixture real tar failed: ${tar.stderr}`);
const archiveBytes = await readFile(archivePath);
const archiveSha256 = sha256(archiveBytes);
const now = Date.parse("2026-08-02T01:00:00.000Z");
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const vulnerabilityKeyId = "fixture-vulnerability-key";
const provenanceKeyId = "fixture-provenance-key";
const vulnerabilityNonce = "1".repeat(64);
const provenanceNonce = "2".repeat(64);
const context = {
run: { id: "fixture-run", attempt: 1 },
source: {
revision: assessment.source.revision,
sourceSetSha256: assessment.source.sourceSetSha256,
},
);
let promotionStatus = "MISSING";
if (providerRun.status === 0) {
const providerVerification = await readDocument(
"artifacts/security/supply-chain-verification.json",
);
promotionStatus =
typeof providerVerification.promotionStatus === "string"
? providerVerification.promotionStatus
: "MISSING";
}
const restore = spawnSync(
"node",
["scripts/generate-supply-chain.ts"],
{ encoding: "utf8" },
);
await rm(fixtureDirectory, { recursive: true, force: true });
const passed =
providerRun.status === 0 &&
promotionStatus === "PASS" &&
restore.status === 0;
await writeFile(
"artifacts/security/supply-chain-provider-fixtures.json",
`${JSON.stringify(
candidate: {
archiveSha256,
bundleSha256: manifest.bundleSha256,
distSha256: manifest.distSha256,
lockfileSha256: manifest.lockfileSha256,
},
} as const;
const vulnerability = signedEvidence(
{
schemaVersion: 2,
evidenceType: "vulnerability-report",
provider: "fixture-vulnerability-provider",
issuedAt: new Date(now).toISOString(),
expiresAt: new Date(now + 60 * 60 * 1_000).toISOString(),
run: { ...context.run, invocationNonce: vulnerabilityNonce },
source: context.source,
candidate: context.candidate,
secretScanAttestation: {
status: "PASS",
localEvidenceAssessmentSha256: sha256(
await readFile(path.join(fixtureRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH)),
),
sourceSetSha256: assessment.source.sourceSetSha256,
policySha256: assessment.secretScan.policySha256,
sarifSha256: assessment.secretScan.sarifSha256,
scanInputSha256: assessment.secretScan.scanInputSha256,
},
findings: [],
},
vulnerabilityKeyId,
vulnerabilityKeys.publicKey,
vulnerabilityKeys.privateKey,
);
const provenance = signedEvidence(
{
schemaVersion: 2,
evidenceType: "provenance-attestation",
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
issuedAt: new Date(now).toISOString(),
expiresAt: new Date(now + 60 * 60 * 1_000).toISOString(),
run: { ...context.run, invocationNonce: provenanceNonce },
source: context.source,
candidate: context.candidate,
subject: { name: "dist", digest: { sha256: manifest.distSha256 } },
},
provenanceKeyId,
provenanceKeys.publicKey,
provenanceKeys.privateKey,
);
await Promise.all([
write(fixtureRoot, "provider/vulnerability.json", `${JSON.stringify(vulnerability)}\n`),
write(fixtureRoot, "provider/provenance.json", `${JSON.stringify(provenance)}\n`),
write(
fixtureRoot,
"provider/vulnerability.pem",
vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }),
),
write(
fixtureRoot,
"provider/provenance.pem",
provenanceKeys.publicKey.export({ type: "spki", format: "pem" }),
),
]);
const runnerTempRoot = path.join(fixtureRoot, "runner-temp");
await mkdir(runnerTempRoot, { mode: 0o700 });
const finalized = await finalizeVerifiedPromotion(
{
repositoryRoot: fixtureRoot,
archivePath,
expectedArchiveSha256: archiveSha256,
vulnerabilityReportPath: "provider/vulnerability.json",
provenanceAttestationPath: "provider/provenance.json",
vulnerabilityPublicKeyPath: "provider/vulnerability.pem",
vulnerabilityKeyId,
provenancePublicKeyPath: "provider/provenance.pem",
provenanceKeyId,
expectedRun: { id: context.run.id, attempt: 1, sourceRevision: revision },
vulnerabilityInvocationNonce: vulnerabilityNonce,
provenanceInvocationNonce: provenanceNonce,
runnerTempRoot,
},
{
nowEpochMs: () => now,
randomBytes: (bytes) => Buffer.alloc(bytes, 0x4a),
afterCapture: async () => {
await writeFile(path.join(fixtureRoot, "dist/index.html"), "contradictory external tree\n");
},
},
);
const validImmutable =
finalized.files.length === 5 &&
(await readdir(finalized.stagingRoot)).length === 5;
const exactFiles = Object.fromEntries(
await Promise.all(
PROMOTED_FILE_NAMES.map(async (name) => [
name,
await readFile(path.join(finalized.stagingRoot, name)),
] as const),
),
);
const bundleTrust = {
vulnerabilityTrust: {
keyId: vulnerabilityKeyId,
publicKey: vulnerabilityKeys.publicKey,
publicKeyFingerprint: providerPublicKeyFingerprint(vulnerabilityKeys.publicKey),
},
provenanceTrust: {
keyId: provenanceKeyId,
publicKey: provenanceKeys.publicKey,
publicKeyFingerprint: providerPublicKeyFingerprint(provenanceKeys.publicKey),
},
expected: {
run: context.run,
sourceRevision: context.source.revision,
sourceSetSha256: context.source.sourceSetSha256,
archiveSha256: context.candidate.archiveSha256,
bundleSha256: context.candidate.bundleSha256,
distSha256: context.candidate.distSha256,
lockfileSha256: context.candidate.lockfileSha256,
},
nowEpochMs: () => now,
} as const;
const absentFiles = { ...exactFiles } as Partial<typeof exactFiles>;
delete absentFiles["provider-verification.json"];
const absent = await captureRejection(async () => {
await verifyExactPromotionBundle(absentFiles, bundleTrust);
});
const invalidPath = path.join(fixtureRoot, "invalid.tar.gz");
const invalidBytes = Buffer.from("arbitrary non-tar bytes\n");
await writeFile(invalidPath, invalidBytes);
const invalidTar = await captureRejection(async () => {
await finalizeVerifiedPromotion(
{
repositoryRoot: fixtureRoot,
archivePath: invalidPath,
expectedArchiveSha256: sha256(invalidBytes),
vulnerabilityReportPath: "provider/vulnerability.json",
provenanceAttestationPath: "provider/provenance.json",
vulnerabilityPublicKeyPath: "provider/vulnerability.pem",
vulnerabilityKeyId,
provenancePublicKeyPath: "provider/provenance.pem",
provenanceKeyId,
expectedRun: { id: context.run.id, attempt: 1, sourceRevision: revision },
vulnerabilityInvocationNonce: vulnerabilityNonce,
provenanceInvocationNonce: provenanceNonce,
runnerTempRoot,
},
{ nowEpochMs: () => now },
);
});
const wrongDigest = await captureRejection(async () => {
await captureCiCandidateArchive({ archivePath, expectedSha256: "0".repeat(64) });
});
const passed =
validImmutable && absent.rejected && invalidTar.rejected && wrongDigest.rejected;
await cleanupFinalizedPromotion({
runnerTempRoot,
stagingRoot: finalized.stagingRoot,
cleanupToken: finalized.cleanupToken,
runnerTempIdentity: finalized.runnerTempIdentity,
stagingIdentity: finalized.stagingIdentity,
});
await mkdir(path.join(repositoryRoot, "artifacts/security"), { recursive: true });
await writeValidatedJsonArtifact({
path: path.join(repositoryRoot, "artifacts/security/supply-chain-provider-fixtures.json"),
schema: supplyChainProviderFixturesArtifactSchema,
value: {
schemaVersion: 1,
providerAccepted: providerRun.status === 0,
promotionStatus,
unverifiedDefaultRestored: restore.status === 0,
actualDefaultVerifier: {
status: validImmutable ? "PASS" : "FAIL_UNVERIFIED",
failures: validImmutable ? [] : ["canonical exact-five finalizer failed"],
},
fixtures: {
absent: {
status: absent.rejected ? "FAIL_UNVERIFIED" : "PASS",
failures: [absent.failure],
},
validImmutable: { status: validImmutable ? "PASS" : "FAIL_UNVERIFIED", failures: [] },
wrongDigest: {
status: wrongDigest.rejected ? "FAIL_UNVERIFIED" : "PASS",
failures: [wrongDigest.failure],
},
invalidTar: {
status: invalidTar.rejected ? "FAIL_UNVERIFIED" : "PASS",
failures: [invalidTar.failure],
},
},
externalTreeCanary: {
status: validImmutable ? "PASS" : "FAIL_UNVERIFIED",
failures: validImmutable ? [] : ["external tree canary influenced captured archive"],
},
passingFixtureCount: validImmutable ? 1 : 0,
status: passed ? "PASS" : "FAIL",
},
});
if (!passed) throw new Error("canonical exact-five provider fixtures failed closed incorrectly");
process.stdout.write("Supply-chain provider real-tar signed exact-five fixtures: PASS\n");
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
function signedEvidence(
value: Record<string, unknown>,
keyId: string,
publicKey: KeyObject,
privateKey: KeyObject,
) {
return {
...value,
signature: {
algorithm: "Ed25519",
keyId,
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
value: sign(
null,
2,
)}\n`,
);
if (!passed) {
const detail =
providerRun.stderr ||
restore.stderr ||
providerRun.stdout ||
restore.stdout ||
`providerStatus=${String(providerRun.status)}, promotionStatus=${promotionStatus}, restoreStatus=${String(restore.status)}`;
process.stderr.write(
`Supply-chain provider fixture failed: ${detail}\n`,
);
process.exit(1);
providerEvidenceSignaturePayload(value),
privateKey,
).toString("base64"),
},
};
}
async function write(root: string, relative: string, value: string | Buffer): Promise<void> {
const absolute = path.join(root, relative);
await mkdir(path.dirname(absolute), { recursive: true });
await writeFile(absolute, value);
}
async function captureRejection(
operation: () => Promise<void>,
): Promise<Readonly<{ rejected: boolean; failure: string }>> {
try {
await operation();
return Object.freeze({ rejected: false, failure: "fixture unexpectedly passed" });
} catch (error) {
return Object.freeze({
rejected: true,
failure: error instanceof Error ? error.message : String(error),
});
}
}
function sha256(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
process.stdout.write(
"Supply-chain provider fixture: verified PASS and unconfigured default restored\n",
);
+300 -112
View File
@@ -1,24 +1,45 @@
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir, stat } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
type ScenarioCatalogContribution = Readonly<{
owner: string;
path: string;
arrayExport: string;
minimumEntries: number;
}>;
import { z } from "zod";
type SourceContractContribution = Readonly<{
owner: string;
path: string;
requiredTokens: readonly string[];
}>;
import {
computeHttpScenarioCatalogDigest,
httpScenarioExpectationSchema,
httpScenarioReceiptSchema,
sameScenarioJson,
type HttpScenarioExpectation,
type HttpScenarioReceipt,
} from "./lib/http-scenario-evidence.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { testEvidenceReportSchema } from "./lib/test-evidence-artifact.ts";
type TestEvidencePolicy = Readonly<{
schemaVersion: number;
scenarioCatalogs: readonly unknown[];
sourceContracts: readonly unknown[];
}>;
const scenarioContributionSchema = z
.object({
owner: z.string().min(1),
path: z.string().min(1),
expectationExport: z.string().min(1),
receiptPath: z.string().min(1),
receiptSchemaVersion: z.number().int().positive(),
})
.strict();
const sourceContractSchema = z
.object({
owner: z.string().min(1),
path: z.string().min(1),
requiredTokens: z.array(z.string().min(1)),
})
.strict();
const policySchema = z
.object({
schemaVersion: z.literal(2),
scenarioCatalogs: z.array(scenarioContributionSchema),
sourceContracts: z.array(sourceContractSchema),
})
.strict();
function argumentValue(name: string, fallback: string): string {
const index = process.argv.indexOf(name);
@@ -27,6 +48,11 @@ function argumentValue(name: string, fallback: string): string {
: fallback;
}
function optionalArgumentValue(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const sourceRoot = argumentValue("--source-root", "tests");
const artifactPath = argumentValue(
"--artifact",
@@ -36,13 +62,21 @@ const policyPath = argumentValue(
"--policy",
"config/testing/test-evidence.json",
);
const explicitCatalogPath = optionalArgumentValue("--catalog");
const explicitReceiptPath = optionalArgumentValue("--receipt");
const fixtureMode = sourceRoot !== "tests";
const sourceOnly = process.argv.includes("--source-only");
const scenarioOnly = process.argv.includes("--scenario-only");
const skipScenarioExecutions = process.argv.includes(
"--skip-scenario-executions",
);
const failures: string[] = [];
const facts = {
scannedFiles: 0,
visualBaselines: 0,
sharedScenarios: 0,
declaredScenarioExecutions: 0,
executedScenarioExecutions: 0,
};
async function filesBelow(target: string): Promise<string[]> {
@@ -59,10 +93,191 @@ async function filesBelow(target: string): Promise<string[]> {
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function duplicates(values: readonly string[]): string[] {
const seen = new Set<string>();
const duplicate = new Set<string>();
for (const value of values) {
if (seen.has(value)) duplicate.add(value);
seen.add(value);
}
return [...duplicate].sort();
}
function isSorted(values: readonly string[]): boolean {
return values.every(
(value, index) => index === 0 || values[index - 1]!.localeCompare(value) <= 0,
);
}
async function readJson(target: string): Promise<unknown> {
return JSON.parse(await readFile(target, "utf8"));
}
async function loadCatalogExport(
target: string,
exportName: string,
): Promise<unknown> {
if (target.endsWith(".json")) {
const document = await readJson(target);
return (document as Readonly<Record<string, unknown>>)[exportName];
}
const module = (await import(
`${pathToFileURL(path.resolve(target)).href}?test-evidence=${Date.now()}`
)) as Readonly<Record<string, unknown>>;
return module[exportName];
}
async function checkScenarioContribution(
contribution: z.output<typeof scenarioContributionSchema>,
): Promise<void> {
const catalogPath = explicitCatalogPath ?? contribution.path;
const receiptPath = explicitReceiptPath ?? contribution.receiptPath;
let expectations: readonly HttpScenarioExpectation[];
try {
expectations = z
.array(httpScenarioExpectationSchema)
.parse(
await loadCatalogExport(catalogPath, contribution.expectationExport),
);
} catch (error) {
failures.push(
`[TEST-EVIDENCE-CATALOG-SCHEMA] ${catalogPath}: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
facts.declaredScenarioExecutions += expectations.length;
facts.sharedScenarios += new Set(
expectations.map((entry) => entry.scenarioId),
).size;
const declarationIds = expectations.map((entry) => entry.executionId);
if (declarationIds.length === 0) {
failures.push(`[TEST-EVIDENCE-CATALOG-ZERO] ${catalogPath}: no declarations`);
}
const duplicateDeclarations = duplicates(declarationIds);
if (duplicateDeclarations.length > 0) {
failures.push(
`[TEST-EVIDENCE-DECLARATION-DUPLICATE] ${catalogPath}: ${duplicateDeclarations.join(", ")}`,
);
}
for (const entry of expectations) {
if (entry.executionId !== `${entry.operationId}::${entry.scenarioId}`) {
failures.push(
`[TEST-EVIDENCE-EXECUTION-ID-SCHEMA] ${catalogPath}: ${entry.executionId}`,
);
}
}
let receipt: HttpScenarioReceipt;
try {
const parsedReceipt = httpScenarioReceiptSchema.safeParse(
await readJson(receiptPath),
);
if (!parsedReceipt.success) {
if (
parsedReceipt.error.issues.some((issue) =>
issue.message.includes("must be sorted by execution ID"),
)
) {
failures.push(
`[TEST-EVIDENCE-EXECUTION-ID-UNSORTED] ${receiptPath}`,
);
}
if (
parsedReceipt.error.issues.some((issue) =>
issue.message.includes("row IDs must exactly equal executed IDs"),
)
) {
failures.push(`[TEST-EVIDENCE-EXECUTION-ROW-DRIFT] ${receiptPath}`);
}
throw parsedReceipt.error;
}
receipt = parsedReceipt.data;
} catch (error) {
failures.push(
`[TEST-EVIDENCE-RECEIPT-SCHEMA] ${receiptPath}: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
facts.executedScenarioExecutions += receipt.rows.length;
if (receipt.schemaVersion !== contribution.receiptSchemaVersion) {
failures.push(
`[TEST-EVIDENCE-RECEIPT-VERSION] ${receiptPath}: expected ${contribution.receiptSchemaVersion}, observed ${receipt.schemaVersion}`,
);
}
if (receipt.rows.length === 0 || receipt.executedIds.length === 0) {
failures.push(`[TEST-EVIDENCE-EXECUTION-ZERO] ${receiptPath}: no executions`);
}
if (receipt.catalogTotal !== declarationIds.length) {
failures.push(
`[TEST-EVIDENCE-CATALOG-TOTAL] ${receiptPath}: expected ${declarationIds.length}, observed ${receipt.catalogTotal}`,
);
}
const duplicateExecutedIds = duplicates(receipt.executedIds);
const rowIds = receipt.rows.map((row) => row.executionId);
const duplicateRows = duplicates(rowIds);
if (duplicateExecutedIds.length > 0 || duplicateRows.length > 0) {
failures.push(
`[TEST-EVIDENCE-EXECUTION-ID-DUPLICATE] ${receiptPath}: ${[...new Set([...duplicateExecutedIds, ...duplicateRows])].sort().join(", ")}`,
);
}
if (!isSorted(receipt.executedIds) || !isSorted(rowIds)) {
failures.push(`[TEST-EVIDENCE-EXECUTION-ID-UNSORTED] ${receiptPath}`);
}
if (!sameScenarioJson(receipt.executedIds, rowIds)) {
failures.push(`[TEST-EVIDENCE-EXECUTION-ROW-DRIFT] ${receiptPath}`);
}
const declared = new Set(declarationIds);
const executed = new Set(receipt.executedIds);
const missing = [...declared].filter((executionId) => !executed.has(executionId)).sort();
const extra = [...executed].filter((executionId) => !declared.has(executionId)).sort();
if (missing.length > 0) {
failures.push(
`[TEST-EVIDENCE-EXECUTION-ID-MISSING] ${receiptPath}: ${missing.join(", ")}`,
);
}
if (extra.length > 0) {
failures.push(
`[TEST-EVIDENCE-EXECUTION-ID-EXTRA] ${receiptPath}: ${extra.join(", ")}`,
);
}
const expectedDigest = computeHttpScenarioCatalogDigest(
receipt.schemaVersion,
expectations,
);
if (receipt.catalogDigest !== expectedDigest) {
failures.push(
`[TEST-EVIDENCE-CATALOG-DIGEST] ${receiptPath}: expected ${expectedDigest}, observed ${receipt.catalogDigest}`,
);
}
const declarationsById = new Map(
expectations.map((entry) => [entry.executionId, entry] as const),
);
for (const row of receipt.rows) {
const declaration = declarationsById.get(row.executionId);
if (!declaration) continue;
if (!sameScenarioJson(row.expected, declaration.expected)) {
failures.push(
`[TEST-EVIDENCE-EXPECTED-DRIFT] ${receiptPath}: ${row.executionId}`,
);
}
if (row.testDeadlineOverrideMs !== declaration.testDeadlineOverrideMs) {
failures.push(
`[TEST-EVIDENCE-DEADLINE-DRIFT] ${receiptPath}: ${row.executionId}`,
);
}
if (!sameScenarioJson(row.observed, row.expected)) {
failures.push(
`[TEST-EVIDENCE-OBSERVATION-MISMATCH] ${receiptPath}: ${row.executionId}`,
);
}
}
}
if (!scenarioOnly) {
const sourceFiles = (await filesBelow(sourceRoot)).filter(
(file) => fixtureMode || !file.split(path.sep).includes("fixtures"),
);
@@ -70,8 +285,7 @@ for (const file of sourceFiles) {
if (!/\.(?:ts|tsx|fixture|txt)$/.test(file)) continue;
const source = await readFile(file, "utf8");
facts.scannedFiles += 1;
const skipPattern =
/\b(?:test|it|describe)(?:\.describe)?\.(?:skip|fixme)\s*\(/g;
const skipPattern = /\b(?:test|it|describe)(?:\.describe)?\.(?:skip|fixme)\s*\(/g;
if (skipPattern.test(source)) {
const quarantine =
/quarantine\(owner=[^)]+,\s*defect=[^)]+,\s*expires=\d{4}-\d{2}-\d{2}\)/;
@@ -85,8 +299,62 @@ for (const file of sourceFiles) {
failures.push(`${file}: screenshot mask may not cover the whole UI`);
}
}
}
if (!fixtureMode) {
let parsedPolicy: z.output<typeof policySchema> | undefined;
if (!fixtureMode || scenarioOnly) {
try {
parsedPolicy = policySchema.parse(await readJson(policyPath));
} catch (error) {
failures.push(
`[TEST-EVIDENCE-POLICY-SCHEMA] ${policyPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
if (parsedPolicy) {
for (const contract of parsedPolicy.sourceContracts) {
let source: string;
try {
source = await readFile(contract.path, "utf8");
} catch (error) {
failures.push(
`${contract.path}: cannot read source contract (${error instanceof Error ? error.message : String(error)})`,
);
continue;
}
for (const token of contract.requiredTokens) {
if (!source.includes(token)) {
failures.push(
`${contract.path}: ${contract.owner} evidence contract is missing ${token}`,
);
}
}
}
const shouldCheckScenarioExecutions =
scenarioOnly ||
(!sourceOnly && !skipScenarioExecutions && !fixtureMode);
if (shouldCheckScenarioExecutions) {
if (parsedPolicy.scenarioCatalogs.length === 0) {
failures.push("[TEST-EVIDENCE-CATALOG-ZERO] policy has no scenario catalogs");
}
if (
(explicitCatalogPath !== undefined || explicitReceiptPath !== undefined) &&
parsedPolicy.scenarioCatalogs.length !== 1
) {
failures.push(
"[TEST-EVIDENCE-FIXTURE-SCOPE] explicit catalog/receipt requires exactly one contribution",
);
} else {
for (const contribution of parsedPolicy.scenarioCatalogs) {
await checkScenarioContribution(contribution);
}
}
}
}
if (!fixtureMode && !scenarioOnly) {
const e2eConfig = await readFile("playwright.config.ts", "utf8");
for (const token of [
"pnpm build",
@@ -113,89 +381,6 @@ if (!fixtureMode) {
}
}
let evidencePolicy: unknown;
try {
evidencePolicy = JSON.parse(await readFile(policyPath, "utf8"));
} catch (error) {
failures.push(
`${policyPath}: cannot read test evidence policy (${error instanceof Error ? error.message : String(error)})`,
);
}
const policy = evidencePolicy as Partial<TestEvidencePolicy> | undefined;
if (
policy?.schemaVersion !== 1 ||
!Array.isArray(policy.scenarioCatalogs) ||
!Array.isArray(policy.sourceContracts)
) {
failures.push(`${policyPath}: invalid test evidence policy`);
} else {
for (const candidate of policy.scenarioCatalogs) {
const contribution = candidate as Partial<ScenarioCatalogContribution>;
const minimumEntries = contribution.minimumEntries;
if (
typeof contribution?.owner !== "string" ||
typeof contribution?.path !== "string" ||
typeof contribution?.arrayExport !== "string" ||
typeof minimumEntries !== "number" ||
!Number.isInteger(minimumEntries) ||
minimumEntries < 1
) {
failures.push(`${policyPath}: invalid scenario catalog contribution`);
continue;
}
let source: string;
try {
source = await readFile(contribution.path, "utf8");
} catch (error) {
failures.push(
`${contribution.path}: cannot read scenario catalog (${error instanceof Error ? error.message : String(error)})`,
);
continue;
}
const arrayPattern = new RegExp(
`${escapeRegExp(contribution.arrayExport)}\\s*=\\s*Object\\.freeze\\(\\[([\\s\\S]*?)\\]\\s*as const\\)`,
);
const entryCount = (
arrayPattern.exec(source)?.[1]?.match(/"[^"]+"/g) ?? []
).length;
facts.sharedScenarios += entryCount;
if (entryCount < minimumEntries) {
failures.push(
`${contribution.path}: ${contribution.owner} requires at least ${minimumEntries} shared scenarios`,
);
}
}
for (const candidate of policy.sourceContracts) {
const contract = candidate as Partial<SourceContractContribution>;
const requiredTokens = contract.requiredTokens;
if (
typeof contract?.owner !== "string" ||
typeof contract?.path !== "string" ||
!Array.isArray(requiredTokens) ||
requiredTokens.some((token: unknown) => typeof token !== "string")
) {
failures.push(`${policyPath}: invalid source contract contribution`);
continue;
}
let source: string;
try {
source = await readFile(contract.path, "utf8");
} catch (error) {
failures.push(
`${contract.path}: cannot read source contract (${error instanceof Error ? error.message : String(error)})`,
);
continue;
}
for (const token of requiredTokens as readonly string[]) {
if (!source.includes(token)) {
failures.push(
`${contract.path}: ${contract.owner} evidence contract is missing ${token}`,
);
}
}
}
}
const baselineFiles = (await filesBelow("tests/visual/__snapshots__")).filter(
(file) => file.endsWith(".png"),
);
@@ -223,14 +408,13 @@ if (!fixtureMode) {
}
}
const requiredBuiltFiles = [
for (const required of [
"dist/index.html",
"dist/config.json",
"dist/release-manifest.json",
"dist/runtime-config.schema.json",
"dist/.vite/manifest.json",
];
for (const required of requiredBuiltFiles) {
]) {
if ((await filesBelow(required)).length === 0) {
failures.push(`built-dist contract missing ${required}`);
}
@@ -245,18 +429,22 @@ if (!fixtureMode) {
}
const report = {
schemaVersion: 1,
schemaVersion: 2 as const,
sourceRoot,
status: failures.length === 0 ? "PASS" : "FAIL",
status: failures.length === 0 ? ("PASS" as const) : ("FAIL" as const),
facts,
failures,
};
await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
await writeValidatedJsonArtifact({
path: artifactPath,
schema: testEvidenceReportSchema,
value: report,
});
if (failures.length > 0) {
process.stderr.write(`Test evidence failed:\n- ${failures.join("\n- ")}\n`);
process.exit(1);
}
process.stdout.write(
`Test evidence: PASS (${facts.scannedFiles} files, ${facts.visualBaselines} baselines, ${facts.sharedScenarios} scenarios)\n`,
`Test evidence: PASS (${facts.scannedFiles} files, ${facts.visualBaselines} baselines, ${facts.declaredScenarioExecutions} declared/${facts.executedScenarioExecutions} executed scenarios)\n`,
);
@@ -0,0 +1,12 @@
import { checkV8CoverageCounterSemantics } from "./lib/v8-coverage-counter-semantics.ts";
try {
const result = await checkV8CoverageCounterSemantics();
process.stdout.write(
`V8 coverage counter semantics: PASS (${result.counterBearingModules.length} counter-bearing, ${result.counterlessModules.length} counterless)\n`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`V8 coverage counter semantics failed: ${message}\n`);
process.exitCode = 1;
}
+29
View File
@@ -0,0 +1,29 @@
import { cleanupFinalizedPromotion } from "./lib/promotion-stager.ts";
const required = (name: string): string => {
const value = process.env[name];
if (!value) throw new TypeError(`promotion cleanup environment is missing ${name}`);
return value;
};
const requiredIdentity = (name: string): number => {
const value = Number(required(name));
if (!Number.isSafeInteger(value) || value <= 0) {
throw new TypeError(`promotion cleanup environment has invalid ${name}`);
}
return value;
};
await cleanupFinalizedPromotion({
runnerTempRoot: required("RUNNER_TEMP"),
stagingRoot: required("PROMOTION_STAGING_ROOT"),
cleanupToken: required("PROMOTION_CLEANUP_TOKEN"),
runnerTempIdentity: {
dev: requiredIdentity("PROMOTION_RUNNER_TEMP_DEV"),
ino: requiredIdentity("PROMOTION_RUNNER_TEMP_INO"),
},
stagingIdentity: {
dev: requiredIdentity("PROMOTION_STAGING_DEV"),
ino: requiredIdentity("PROMOTION_STAGING_INO"),
},
});
process.stdout.write("Promotion staging cleanup: PASS\n");
+8 -5
View File
@@ -1,10 +1,12 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile } from "node:fs/promises";
import {
evaluateFieldBudget,
percentile75,
} from "../src/application/policies/performance-budgets.ts";
import { fieldWebVitalsArtifactSchema } from "./contracts/release-artifacts.ts";
import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const inputPath =
process.env.FIELD_WEB_VITALS_INPUT ||
@@ -94,10 +96,11 @@ const report = {
};
await mkdir("artifacts/performance", { recursive: true });
await writeFile(
"artifacts/performance/field-web-vitals.json",
`${JSON.stringify(report, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/performance/field-web-vitals.json",
schema: fieldWebVitalsArtifactSchema,
value: report,
});
if (!passed) {
process.stderr.write(
`Field Web Vitals: ${status} (approved threshold decision and valid 28-day production evidence are required)\n`,
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
export const PROMOTED_FILE_NAMES = Object.freeze([
"release-candidate.tar.gz",
"vulnerability-report.json",
"provenance-attestation.json",
"provider-verification.json",
"promotion-verification.json",
] as const);
export type PromotedFileName = (typeof PROMOTED_FILE_NAMES)[number];
export const PROMOTED_UPLOAD_PATHS = Object.freeze(
PROMOTED_FILE_NAMES.map((name) => `\${{ steps.finalize.outputs.staging_root }}/${name}`),
);
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
import { mkdir } from "node:fs/promises";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { localEvidenceAssessmentArtifactSchema } from "./contracts/release-artifacts.ts";
import { createLocalEvidenceAssessment } from "./lib/local-release-evidence.ts";
import {
createReleaseCandidateManifest,
LOCAL_EVIDENCE_ASSESSMENT_PATH,
RELEASE_CANDIDATE_MANIFEST_PATH,
releaseCandidateManifestSchema,
} from "./lib/release-candidate.ts";
const assessment = await createLocalEvidenceAssessment();
await mkdir("artifacts/security", { recursive: true });
await writeValidatedJsonArtifact({
path: LOCAL_EVIDENCE_ASSESSMENT_PATH,
schema: localEvidenceAssessmentArtifactSchema,
value: assessment,
});
const manifest = await createReleaseCandidateManifest();
await mkdir("artifacts/release", { recursive: true });
await writeValidatedJsonArtifact({
path: RELEASE_CANDIDATE_MANIFEST_PATH,
schema: releaseCandidateManifestSchema,
value: manifest,
});
process.stdout.write(
`Immutable release candidate: ${manifest.distSha256} (${manifest.files.length} files)\n`,
);
+201 -47
View File
@@ -1,4 +1,5 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { access, mkdir, readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { shouldRetry } from "../src/adapters/http/retry-policy.ts";
import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.ts";
@@ -6,8 +7,17 @@ import { verifyCompatibilityTuple } from "../src/application/policies/compatibil
import type { StoragePort } from "../src/application/ports/storage-port.ts";
import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.ts";
import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.ts";
import { compareReleaseToRuntime } from "../src/contracts/release-tokens.ts";
import type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts";
import {
parseReleaseArtifact,
parseRuntimeConfigArtifact,
type ReleaseArtifact,
} from "../src/contracts/release-artifacts.ts";
import { projectTelemetryEvent } from "../src/contracts/telemetry.ts";
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts";
import { runbookRecordArtifactSchema } from "./contracts/release-artifacts.ts";
import { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type RecoveryAssertion = Readonly<{
assertion: string;
@@ -38,40 +48,42 @@ type RunbookDocument = Readonly<{
runbooks: Record<string, RunbookSpecification>;
}>;
type ReleaseManifest = Record<string, unknown> & {
buildId: string;
configSchemaVersion: string;
apiContractVersion: string;
assetManifestHash: string;
releaseId: string;
};
export type JsonArtifactReader = (path: string) => Promise<unknown>;
const runbookId = process.argv
.slice(2)
.find((argument) => /^FE-RB-00[1-5]$/.test(argument));
const document = JSON.parse(
await readFile("config/runbooks/runbooks.json", "utf8"),
) as RunbookDocument;
const specification = runbookId ? document.runbooks[runbookId] : undefined;
if (!runbookId || !specification) {
process.stderr.write("Usage: drill:runbook -- FE-RB-001..FE-RB-005\n");
process.exit(2);
export type RollbackArtifactPaths = Readonly<{
primaryRelease: string;
fallbackRelease: string;
primaryRuntime: string;
fallbackRuntime: string;
}>;
export type RollbackCoherenceOptions = Readonly<{
readArtifact?: JsonArtifactReader;
contractPackages?: readonly InstalledContractPackageIdentity[];
paths?: RollbackArtifactPaths;
}>;
const DEFAULT_ROLLBACK_PATHS = Object.freeze({
primaryRelease: "dist/release-manifest.json",
fallbackRelease: "public/release-manifest.json",
primaryRuntime: "dist/config.json",
fallbackRuntime: "public/config.json",
});
async function readJsonArtifact(path: string): Promise<unknown> {
return JSON.parse(await readFile(path, "utf8"));
}
async function releaseManifest(): Promise<ReleaseManifest> {
for (const candidate of [
"dist/release-manifest.json",
"public/release-manifest.json",
]) {
try {
return JSON.parse(
await readFile(candidate, "utf8"),
) as ReleaseManifest;
} catch {
// Continue to the source fallback.
}
}
throw new Error("Release manifest is unavailable.");
async function releaseManifest(
readArtifact: JsonArtifactReader = readJsonArtifact,
paths: RollbackArtifactPaths = DEFAULT_ROLLBACK_PATHS,
): Promise<ReleaseArtifact> {
const value = await readPrimaryOrFallback(
readArtifact,
paths.primaryRelease,
paths.fallbackRelease,
);
return parseReleaseArtifact(value);
}
const validConfig = {
@@ -237,16 +249,8 @@ async function drillTelemetry(): Promise<DrillResult> {
}
async function drillRollback(): Promise<DrillResult> {
const release = await releaseManifest();
const runtime = JSON.parse(
await readFile(
(await access("dist/config.json").then(() => true).catch(() => false))
? "dist/config.json"
: "public/config.json",
"utf8",
),
);
const coherent = compareReleaseToRuntime(release, runtime);
const verified = await verifyRollbackReleaseCoherence();
const { release, coherence: coherent } = verified;
const mixed = verifyCompatibilityTuple({
frontend: {
buildId: "build-a",
@@ -284,6 +288,48 @@ const drillById: Record<string, () => Promise<DrillResult>> = {
"FE-RB-004": drillTelemetry,
"FE-RB-005": drillRollback,
};
export async function verifyRollbackReleaseCoherence(
options: RollbackCoherenceOptions = {},
) {
const readArtifact = options.readArtifact ?? readJsonArtifact;
const paths = options.paths ?? DEFAULT_ROLLBACK_PATHS;
const artifactPair = await selectRollbackArtifactPair(
readArtifact,
paths,
);
const release = parseReleaseArtifact(artifactPair.release);
const runtimeArtifact = parseRuntimeConfigArtifact(artifactPair.runtime);
const runtime = {
...runtimeArtifact,
BUILD_ID: requireIdentity(runtimeArtifact.BUILD_ID, "runtime BUILD_ID"),
RELEASE_ID: requireIdentity(
runtimeArtifact.RELEASE_ID,
"runtime RELEASE_ID",
),
};
const coherence = await verifyReleaseRuntimeCoherence({
release,
runtime,
contractPackages:
options.contractPackages ?? EXPECTED_CONTRACT_SET_PACKAGES,
});
return Object.freeze({ release, runtime, coherence });
}
async function main(): Promise<void> {
const runbookId = process.argv
.slice(2)
.find((argument) => /^FE-RB-00[1-5]$/.test(argument));
const document = JSON.parse(
await readFile("config/runbooks/runbooks.json", "utf8"),
) as RunbookDocument;
const specification = runbookId ? document.runbooks[runbookId] : undefined;
if (!runbookId || !specification) {
process.stderr.write("Usage: drill:runbook -- FE-RB-001..FE-RB-005\n");
process.exit(2);
}
const drill = await drillById[runbookId]();
const escalationPathAsserted = specification.escalation.length >= 2;
const passed =
@@ -310,10 +356,11 @@ const record = {
};
const artifactDirectory = `artifacts/runbooks/${runbookId}`;
await mkdir(artifactDirectory, { recursive: true });
await writeFile(
`${artifactDirectory}/record.json`,
`${JSON.stringify(record, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: `${artifactDirectory}/record.json`,
schema: runbookRecordArtifactSchema,
value: record,
});
if (!passed) {
process.stderr.write(`${runbookId} drill failed.\n`);
process.exit(1);
@@ -323,3 +370,110 @@ process.stdout.write(
drill.providerVerificationRequired ? "still required" : "not required"
})\n`,
);
}
function requireIdentity(value: string | undefined, label: string): string {
if (value === undefined || value.length === 0) {
throw new TypeError(`${label} must be a non-empty string`);
}
return value;
}
async function selectRollbackArtifactPair(
readArtifact: JsonArtifactReader,
paths: RollbackArtifactPaths,
): Promise<Readonly<{ release: unknown; runtime: unknown }>> {
const primary = await readArtifactPair(
readArtifact,
paths.primaryRelease,
paths.primaryRuntime,
);
if (
primary.release.status === "fulfilled" &&
primary.runtime.status === "fulfilled"
) {
return Object.freeze({
release: primary.release.value,
runtime: primary.runtime.value,
});
}
const releaseMissing =
primary.release.status === "rejected" &&
hasErrorCode(primary.release.reason, "ENOENT");
const runtimeMissing =
primary.runtime.status === "rejected" &&
hasErrorCode(primary.runtime.reason, "ENOENT");
if (releaseMissing && runtimeMissing) {
const fallback = await readArtifactPair(
readArtifact,
paths.fallbackRelease,
paths.fallbackRuntime,
);
if (fallback.release.status === "rejected") {
throw fallback.release.reason;
}
if (fallback.runtime.status === "rejected") {
throw fallback.runtime.reason;
}
return Object.freeze({
release: fallback.release.value,
runtime: fallback.runtime.value,
});
}
if (primary.release.status === "rejected" && !releaseMissing) {
throw primary.release.reason;
}
if (primary.runtime.status === "rejected" && !runtimeMissing) {
throw primary.runtime.reason;
}
throw new Error("primary rollback artifact pair is incomplete");
}
async function readArtifactPair(
readArtifact: JsonArtifactReader,
releasePath: string,
runtimePath: string,
): Promise<
Readonly<{
release: PromiseSettledResult<unknown>;
runtime: PromiseSettledResult<unknown>;
}>
> {
const [release, runtime] = await Promise.allSettled([
Promise.resolve().then(() => readArtifact(releasePath)),
Promise.resolve().then(() => readArtifact(runtimePath)),
]);
return Object.freeze({ release, runtime });
}
async function readPrimaryOrFallback(
readArtifact: JsonArtifactReader,
primary: string,
fallback: string,
): Promise<unknown> {
try {
return await readArtifact(primary);
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
return readArtifact(fallback);
}
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
error.code === code,
);
}
const invokedPath = process.argv[1];
if (
invokedPath !== undefined &&
import.meta.url === pathToFileURL(invokedPath).href
) {
await main();
}
+131
View File
@@ -0,0 +1,131 @@
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { z } from "zod";
import {
buildManifestArtifactSchema,
dependencyInventoryArtifactSchema,
jsonSchemaDocumentArtifactSchema,
registrySnapshotArtifactSchema,
supplyChainVerificationArtifactSchema,
} from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const ARTIFACT_SCHEMA_DEFINITIONS = Object.freeze([
{
relativePath: "schemas/artifacts/build-manifest.schema.json",
id: undefined,
schema: buildManifestArtifactSchema,
},
{
relativePath: "schemas/artifacts/dependency-inventory.schema.json",
id: "https://clean-architecture-frontend.local/schemas/dependency-inventory.schema.json",
schema: dependencyInventoryArtifactSchema,
},
{
relativePath: "schemas/artifacts/registry-snapshot.schema.json",
id: undefined,
schema: registrySnapshotArtifactSchema,
},
{
relativePath: "schemas/artifacts/supply-chain-verification.schema.json",
id: "https://clean-architecture-frontend.local/schemas/supply-chain-verification.schema.json",
schema: supplyChainVerificationArtifactSchema,
},
] as const);
type GenerateArtifactSchemasOptions = Readonly<{
root?: string;
check?: boolean;
}>;
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalize(item)]),
);
}
return value;
}
function generatedSchema(
definition: (typeof ARTIFACT_SCHEMA_DEFINITIONS)[number],
): Readonly<Record<string, unknown>> {
const schema = z.toJSONSchema(definition.schema, {
target: "draft-2020-12",
});
return canonicalize({
...schema,
...(definition.id === undefined ? {} : { $id: definition.id }),
}) as Readonly<Record<string, unknown>>;
}
export async function generateArtifactSchemas(
options: GenerateArtifactSchemasOptions = {},
): Promise<void> {
const root = path.resolve(options.root ?? process.cwd());
const documents = ARTIFACT_SCHEMA_DEFINITIONS.map((definition) => ({
definition,
document: generatedSchema(definition),
}));
const drift: string[] = [];
for (const { definition, document } of documents) {
const destination = path.join(root, definition.relativePath);
const validatedDocument = jsonSchemaDocumentArtifactSchema.parse(document);
const expected = `${JSON.stringify(validatedDocument, null, 2)}\n`;
if (options.check) {
let actual: string | null = null;
try {
actual = await readFile(destination, "utf8");
} catch {
// A missing or unreadable checked-in schema is drift.
}
if (actual !== expected) drift.push(definition.relativePath);
continue;
}
await mkdir(path.dirname(destination), { recursive: true });
await writeValidatedJsonArtifact({
path: destination,
schema: jsonSchemaDocumentArtifactSchema,
value: document,
});
}
if (drift.length > 0) {
throw new Error(`Artifact JSON Schema drift:\n- ${drift.join("\n- ")}`);
}
}
function argumentValue(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const invokedPath = process.argv[1];
if (
invokedPath !== undefined &&
import.meta.url === pathToFileURL(invokedPath).href
) {
try {
await generateArtifactSchemas({
root: argumentValue("--root"),
check: process.argv.includes("--check"),
});
process.stdout.write(
process.argv.includes("--check")
? "Artifact JSON Schemas: PASS\n"
: "Artifact JSON Schemas: GENERATED\n",
);
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
}
}
+43 -44
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, readFile, rm } from "node:fs/promises";
import process from "node:process";
import { z } from "zod";
@@ -9,14 +9,18 @@ import {
} from "../src/features/installed-feature-contracts.ts";
import {
buildManifestArtifactSchema,
jsonSchemaDocumentArtifactSchema,
moduleInventoryArtifactSchema,
releaseManifestV2ArtifactSchema,
runtimeConfigV2ArtifactSchema,
} from "../src/contracts/release-artifacts.ts";
} from "./contracts/release-artifacts.ts";
import { buildContractSet } from "./generate-contract-set.ts";
import {
assertCiBuildEnvironment,
buildDate,
} from "./lib/build-environment.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { CANONICAL_VITE_MANIFEST_PATH } from "./lib/build-manifest-outputs.ts";
assertCiBuildEnvironment(process.env);
type ViteManifestEntry = Readonly<{
@@ -35,18 +39,21 @@ const releaseId = process.env.RELEASE_ID ?? "local-release";
const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`;
const buildTime = buildDate(process.env);
const builtAt = buildTime.toISOString();
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
const viteManifest = await readFile(CANONICAL_VITE_MANIFEST_PATH, "utf8");
const viteManifestObject = parseViteManifest(JSON.parse(viteManifest));
const moduleInventory = await readFile(
"dist/.vite/module-inventory.json",
"utf8",
);
parseModuleInventory(JSON.parse(moduleInventory));
const moduleInventoryDocument = moduleInventoryArtifactSchema.parse(
JSON.parse(moduleInventory),
);
const moduleInventoryBytes = `${JSON.stringify(moduleInventoryDocument, null, 2)}\n`;
const assetManifestHash = createHash("sha256")
.update(viteManifest)
.digest("hex");
const moduleInventoryHash = createHash("sha256")
.update(moduleInventory)
.update(moduleInventoryBytes)
.digest("hex");
const runtimeConfig = runtimeConfigV2ArtifactSchema.parse(
JSON.parse(await readFile("dist/config.json", "utf8")),
@@ -84,7 +91,7 @@ const manifest = buildManifestArtifactSchema.parse({
},
outputs: {
directory: "dist",
viteManifest: "dist/.vite/manifest.json",
viteManifest: CANONICAL_VITE_MANIFEST_PATH,
moduleInventory: "artifacts/quality/vite-module-inventory.json",
routeChunks,
runtimeConfigSchema: "dist/runtime-config.schema.json",
@@ -108,28 +115,37 @@ const releaseManifest = releaseManifestV2ArtifactSchema.parse({
await mkdir("artifacts/release", { recursive: true });
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/vite-module-inventory.json",
moduleInventory,
);
await writeValidatedJsonArtifact({
path: "artifacts/quality/vite-module-inventory.json",
schema: moduleInventoryArtifactSchema,
value: moduleInventoryDocument,
});
await rm("dist/.vite/module-inventory.json");
await writeFile("dist/config.json", `${JSON.stringify(runtimeConfig, null, 2)}\n`);
await writeFile(
"dist/release-manifest.json",
`${JSON.stringify(releaseManifest, null, 2)}\n`,
);
await writeFile(
"dist/runtime-config.schema.json",
`${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`,
);
await writeFile(
"artifacts/release/runtime-config.schema.json",
`${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`,
);
await writeFile(
"artifacts/release/build-manifest.json",
`${JSON.stringify(manifest, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: "dist/config.json",
schema: runtimeConfigV2ArtifactSchema,
value: runtimeConfig,
});
await writeValidatedJsonArtifact({
path: "dist/release-manifest.json",
schema: releaseManifestV2ArtifactSchema,
value: releaseManifest,
});
await writeValidatedJsonArtifact({
path: "dist/runtime-config.schema.json",
schema: jsonSchemaDocumentArtifactSchema,
value: runtimeConfigJsonSchema,
});
await writeValidatedJsonArtifact({
path: "artifacts/release/runtime-config.schema.json",
schema: jsonSchemaDocumentArtifactSchema,
value: runtimeConfigJsonSchema,
});
await writeValidatedJsonArtifact({
path: "artifacts/release/build-manifest.json",
schema: buildManifestArtifactSchema,
value: manifest,
});
function parsePackageMetadata(value: unknown): Readonly<{
version: string;
@@ -165,23 +181,6 @@ function parseViteManifest(
return entries;
}
function parseModuleInventory(value: unknown): void {
if (
!isRecord(value) ||
value.schemaVersion !== 1 ||
!Array.isArray(value.chunks) ||
value.chunks.some(
(chunk) =>
!isRecord(chunk) ||
typeof chunk.fileName !== "string" ||
!Array.isArray(chunk.modules) ||
chunk.modules.some((moduleId) => typeof moduleId !== "string"),
)
) {
throw new TypeError("Vite module inventory is invalid");
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
+498
View File
@@ -0,0 +1,498 @@
import { randomUUID } from "node:crypto";
import { constants, type Stats } from "node:fs";
import {
mkdir,
open,
readFile,
rename,
rm,
} from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import {
indexCiGateContract,
loadCiGateContract,
resolveCiStepActionUses,
type CiGateContract,
type CiWorkflowJob,
type CiWorkflowStep,
} from "./contracts/ci-gates.ts";
import {
assertSafeExistingPublishPath,
assertSafePublishLeaf,
ensureSafePublishDirectory,
} from "./lib/ci-gate-log.ts";
export type GenerateCiWorkflowOptions = Readonly<{
root: string;
contract?: CiGateContract;
check: boolean;
contractMode?: "canonical" | "removal-fixture";
}>;
export type GenerateCiWorkflowResult = Readonly<{
target: string;
written: boolean;
matches: boolean;
firstDifferenceByte: number | null;
firstDifferenceLine: number | null;
}>;
export type CiWorkflowFileSystem = Readonly<{
mkdir(directory: string): Promise<unknown>;
readFile(target: string): Promise<Buffer>;
open(target: string, flags: number, mode: number): Promise<{
writeFile(content: string, encoding: "utf8"): Promise<unknown>;
chmod(mode: number): Promise<unknown>;
sync(): Promise<unknown>;
close(): Promise<unknown>;
}>;
openDirectory(target: string): Promise<{ sync(): Promise<unknown>; close(): Promise<unknown> }>;
rename(source: string, destination: string): Promise<unknown>;
rm(target: string): Promise<unknown>;
}>;
const defaultFileSystem: CiWorkflowFileSystem = Object.freeze({
mkdir: async (directory) => mkdir(directory, { recursive: true }),
readFile: async (target) => readFile(target),
open: async (target, flags, mode) => open(target, flags, mode),
openDirectory: async (target) => open(target, constants.O_RDONLY),
rename: async (source, destination) => rename(source, destination),
rm: async (target) => rm(target, { force: true }),
});
export function renderCiWorkflow(contract: CiGateContract): string {
const index = indexCiGateContract(contract);
const transfers = new Map<string, { name: string }>();
for (const job of contract.jobs) {
for (const step of job.steps) {
if (step.kind === "upload") transfers.set(step.transferId, { name: step.name });
}
}
const lines = [
"# GENERATED FILE — edit config/ci/gates.json and run `corepack pnpm generate:ci-workflow`.",
"name: frontend-quality-gates",
"",
"on:",
" push:",
" branches: [develop]",
' tags: ["v*"]',
" pull_request:",
" workflow_dispatch:",
" inputs:",
" stage:",
" description: Highest promotion tier to evaluate",
" required: true",
" default: merge",
" type: choice",
" options:",
" - merge",
" - release",
" - production",
" - field",
" - documentation",
"",
"permissions:",
" contents: read",
"",
"env:",
' CI: "true"',
' VITE_BUILD_ID: "gitea-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
' VITE_COMMIT_SHA: "${{ gitea.sha }}"',
' RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
' CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"',
"",
"jobs:",
];
for (const [jobIndex, job] of contract.jobs.entries()) {
if (jobIndex > 0) lines.push("");
lines.push(...renderJob(job, index, transfers));
}
return `${lines.join("\n").replace(/\n+$/u, "")}\n`;
}
function renderJob(
job: CiWorkflowJob,
index: ReturnType<typeof indexCiGateContract>,
transfers: ReadonlyMap<string, Readonly<{ name: string }>>,
): string[] {
const lines = [` ${yamlKey(job.id)}:`, ` name: ${yamlScalar(job.displayName)}`];
if (job.needs.length === 1) lines.push(` needs: ${yamlKey(job.needs[0]!)}`);
if (job.needs.length > 1) lines.push(` needs: [${job.needs.map(yamlKey).join(", ")}]`);
const condition = renderCondition(job.condition);
if (condition) lines.push(` if: ${condition}`);
lines.push(" runs-on: ubuntu-latest", ` timeout-minutes: ${job.timeoutMinutes}`);
if (job.kind === "immutable") {
const archive = job.steps.find((step) => step.kind === "archive-candidate");
if (!archive || archive.kind !== "archive-candidate") throw new TypeError("immutable job lacks archive step");
lines.push(
" outputs:",
` ${archive.distOutputName}: \${{ steps.${archive.stepId}.outputs.${archive.distOutputName} }}`,
` ${archive.archiveOutputName}: \${{ steps.${archive.stepId}.outputs.${archive.archiveOutputName} }}`,
);
}
if (job.kind === "provider") {
const supervisor = job.steps.find((step) => step.kind === "run-provider");
if (!supervisor || supervisor.kind !== "run-provider") {
throw new TypeError("provider job lacks supervisor step");
}
lines.push(
" outputs:",
` invocation_nonce: \${{ steps.${supervisor.stepId}.outputs.invocation_nonce }}`,
);
}
if (job.environment.length > 0) {
lines.push(" env:");
for (const binding of job.environment) {
lines.push(` ${binding.name}: ${yamlScalar(binding.value)}`);
}
}
if (job.kind === "gate-matrix") {
lines.push(" strategy:", " fail-fast: false", " matrix:", " include:");
const includesBrowser = job.steps.some(({ kind }) => kind === "browser-install");
for (const gateId of job.gateIds) {
const gate = index.gates.get(gateId);
if (!gate) throw new TypeError(`unknown gate while rendering: ${gateId}`);
const browser = job.browserGateIds.includes(gateId);
lines.push(
` - { gate: ${gateId}, name: ${gate.name}${includesBrowser ? `, browser: ${browser ? "true" : "false"}` : ""} }`,
);
}
}
lines.push(" steps:");
for (const step of job.steps) lines.push(...renderStep(job, step, transfers));
return lines;
}
function renderStep(
job: CiWorkflowJob,
step: CiWorkflowStep,
transfers: ReadonlyMap<string, Readonly<{ name: string }>>,
): string[] {
switch (step.kind) {
case "checkout":
return [
` - uses: ${requiredStepActionUses(step.kind)}`,
" with:",
" persist-credentials: false",
];
case "setup-node":
return [
` - uses: ${requiredStepActionUses(step.kind)}`,
" with:",
" node-version-file: .nvmrc",
];
case "frozen-install":
return [
" - name: Frozen install",
" run: |",
" corepack enable",
" corepack pnpm install --frozen-lockfile --ignore-scripts",
];
case "browser-install":
return [
" - name: Install Playwright browsers",
...(job.kind === "gate-matrix" ? [" if: ${{ matrix.browser }}"] : []),
" run: corepack pnpm exec playwright install --with-deps chromium firefox webkit",
];
case "run-gate": {
const gateId = job.kind === "gate-matrix" ? "${{ matrix.gate }}" : job.gateIds[0];
if (!gateId) throw new TypeError(`run-gate step lacks ownership: ${job.id}`);
const name = job.id === "documentation_gate" ? "Run documentation gate" : job.id === "immutable_build" ? "Build candidate once and verify local evidence" : "Run blocking gate";
return [` - name: ${name}`, ` run: corepack pnpm ci:gate -- ${gateId}`];
}
case "archive-candidate": {
const archive = shellDoubleQuoted(step.archivePath);
const lines = [
" - name: Archive and validate the exact candidate file set",
` id: ${yamlKey(step.stepId)}`,
" run: |",
" mkdir -p .release",
` tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ${archive} \\`,
];
step.members.forEach((member, memberIndex) => {
lines.push(` ${shellWord(member)}${memberIndex === step.members.length - 1 ? "" : " \\"}`);
});
lines.push(
` node scripts/verify-ci-candidate-archive.ts --archive ${archive} --github-output "$GITHUB_OUTPUT"`,
);
return lines;
}
case "download":
return [
` - name: Download ${humanize(step.transferId)}`,
` uses: ${requiredStepActionUses(step.kind)}`,
" with:",
` name: ${yamlScalar(requiredTransferName(transfers, step.transferId))}`,
` path: ${yamlScalar(step.path)}`,
];
case "extract":
return [
" - name: Verify and extract the candidate through one inode-bound operation",
` run: node scripts/verify-ci-candidate-archive.ts --archive ${shellDoubleQuoted(step.archivePath)} --extract-to ${shellDoubleQuoted(step.targetRoot)}`,
];
case "run-provider": {
return [
` - name: Run and validate external ${step.provider} provider in one trusted supervisor`,
` id: ${yamlKey(step.stepId)}`,
` run: node scripts/run-and-validate-provider.ts --kind ${step.provider}`,
];
}
case "validate-provider-evidence":
return [
` - name: Confirm sealed ${step.provider} provider evidence`,
' run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"',
];
case "verify-promotion":
return [
" - name: Finalize verified promotion from inode-bound captured inputs",
` id: ${yamlKey(step.stepId)}`,
" run: node scripts/stage-verified-promotion.ts",
];
case "cleanup-promotion":
return [
" - name: Always remove private promotion staging",
" if: always()",
" env:",
` PROMOTION_STAGING_ROOT: \${{ steps.${step.finalizerStepId}.outputs.staging_root }}`,
` PROMOTION_CLEANUP_TOKEN: \${{ steps.${step.finalizerStepId}.outputs.cleanup_token }}`,
` PROMOTION_RUNNER_TEMP_DEV: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_dev }}`,
` PROMOTION_RUNNER_TEMP_INO: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_ino }}`,
` PROMOTION_STAGING_DEV: \${{ steps.${step.finalizerStepId}.outputs.staging_dev }}`,
` PROMOTION_STAGING_INO: \${{ steps.${step.finalizerStepId}.outputs.staging_ino }}`,
" run: |",
' if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then',
" node scripts/cleanup-verified-promotion.ts",
" fi",
];
case "upload": {
const lines = [
` - name: Upload ${humanize(step.transferId)}`,
...(step.always ? [" if: always()"] : []),
` uses: ${requiredStepActionUses(step.kind)}`,
" with:",
` name: ${yamlScalar(step.name)}`,
];
if (step.paths.length === 1) lines.push(` path: ${yamlScalar(step.paths[0]!)}`);
else {
lines.push(" path: |");
for (const target of step.paths) lines.push(` ${target}`);
}
lines.push(" if-no-files-found: error");
return lines;
}
}
}
function requiredStepActionUses(stepKind: string): string {
const uses = resolveCiStepActionUses(stepKind);
if (!uses) throw new TypeError(`workflow step has no registered CI action: ${stepKind}`);
return uses;
}
function renderCondition(condition: CiWorkflowJob["condition"]): string | null {
const expressions: Record<CiWorkflowJob["condition"], string | null> = {
always: null,
"needs-success": null,
merge: "${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}",
release: "${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}",
production: "${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}",
field: "${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }}",
documentation: "${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }}",
};
return expressions[condition];
}
function requiredTransferName(
transfers: ReadonlyMap<string, Readonly<{ name: string }>>,
transferId: string,
): string {
const transfer = transfers.get(transferId);
if (!transfer) throw new TypeError(`download transfer has no typed producer: ${transferId}`);
return transfer.name;
}
function yamlKey(value: string): string {
if (!/^[A-Za-z0-9_-]+$/u.test(value)) throw new TypeError(`unsafe YAML key: ${value}`);
return value;
}
function yamlScalar(value: string): string {
if (/^[A-Za-z0-9._/-]+$/u.test(value)) return value;
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n")}"`;
}
function shellWord(value: string): string {
if (!/^[A-Za-z0-9._/-]+$/u.test(value)) throw new TypeError(`unsafe shell word: ${value}`);
return value;
}
function shellDoubleQuoted(value: string): string {
const expressions: string[] = [];
const withoutExpressions = value.replace(/\$\{\{ [A-Za-z0-9_.-]+ \}\}/gu, (expression) => {
expressions.push(expression);
return `__CI_EXPRESSION_${expressions.length - 1}__`;
});
if (withoutExpressions.includes("$")) {
throw new TypeError(`unapproved shell interpolation in workflow value: ${value}`);
}
let escaped = withoutExpressions
.replaceAll("\\", "\\\\")
.replaceAll('"', '\\"')
.replaceAll("`", "\\`");
expressions.forEach((expression, index) => {
escaped = escaped.replace(`__CI_EXPRESSION_${index}__`, expression);
});
return `"${escaped}"`;
}
function humanize(value: string): string {
return value.replaceAll("-", " ");
}
export function createCiWorkflowGenerator(
dependencies: Readonly<{
fileSystem?: CiWorkflowFileSystem;
createNonce?: () => string;
}> = {},
) {
const fileSystem = dependencies.fileSystem ?? defaultFileSystem;
const createNonce = dependencies.createNonce ?? randomUUID;
return async function generate(options: GenerateCiWorkflowOptions): Promise<GenerateCiWorkflowResult> {
const root = path.resolve(options.root);
const contract = options.contract ?? (await loadCiGateContract(root, {
mode: options.contractMode ?? "canonical",
}));
const target = path.resolve(root, contract.providerAdapter);
if (path.relative(root, target).startsWith("..") || path.relative(root, target) === "") {
throw new TypeError(`workflow target escapes repository root: ${contract.providerAdapter}`);
}
const expected = Buffer.from(renderCiWorkflow(contract), "utf8");
let actual: Buffer | null = null;
const existingPathIsSafe =
fileSystem === defaultFileSystem
? await assertSafeExistingPublishPath(root, target)
: true;
if (existingPathIsSafe) {
try {
actual = await fileSystem.readFile(target);
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
}
}
const difference = firstDifference(expected, actual);
if (options.check || difference === null) {
return Object.freeze({
target,
written: false,
matches: difference === null,
firstDifferenceByte: difference?.byte ?? null,
firstDifferenceLine: difference?.line ?? null,
});
}
let parentIdentity: Stats | undefined;
if (fileSystem === defaultFileSystem) {
parentIdentity = await ensureSafePublishDirectory(root, path.dirname(target));
await assertSafePublishLeaf(target, contract.providerAdapter);
} else {
await fileSystem.mkdir(path.dirname(target));
}
const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${createNonce()}.tmp`);
let ownsTemporary = false;
try {
const handle = await fileSystem.open(
temporary,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o644,
);
ownsTemporary = true;
let failure: unknown;
try {
await handle.writeFile(expected.toString("utf8"), "utf8");
await handle.chmod(0o644);
await handle.sync();
} catch (error) {
failure = error;
}
try {
await handle.close();
} catch (error) {
failure ??= error;
}
if (failure) throw failure;
if (fileSystem === defaultFileSystem && parentIdentity) {
const current = await ensureSafePublishDirectory(root, path.dirname(target));
if (
parentIdentity.dev <= 0 ||
parentIdentity.ino <= 0 ||
current.dev !== parentIdentity.dev ||
current.ino !== parentIdentity.ino
) {
throw new TypeError("CI workflow parent directory identity changed");
}
await assertSafePublishLeaf(target, contract.providerAdapter);
}
await fileSystem.rename(temporary, target);
ownsTemporary = false;
const directory = await fileSystem.openDirectory(path.dirname(target));
try {
try {
await directory.sync();
} catch (error) {
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
}
} finally {
await directory.close();
}
} catch (error) {
if (ownsTemporary) {
try {
await fileSystem.rm(temporary);
} catch {
// The owned sibling temp is the only cleanup target; preserve the publish failure.
}
}
throw error;
}
return Object.freeze({ target, written: true, matches: true, firstDifferenceByte: null, firstDifferenceLine: null });
};
}
export const generateCiWorkflow = createCiWorkflowGenerator();
function firstDifference(expected: Buffer, actual: Buffer | null): { byte: number; line: number } | null {
if (actual?.equals(expected)) return null;
const limit = Math.min(expected.byteLength, actual?.byteLength ?? 0);
let byte = 0;
while (byte < limit && expected[byte] === actual?.[byte]) byte += 1;
const line = expected.subarray(0, byte).toString("utf8").split("\n").length;
return { byte, line };
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
const check = process.argv.includes("--check");
const contractMode = process.argv.includes("--reduced-removal-fixture")
? "removal-fixture" as const
: "canonical" as const;
try {
const result = await generateCiWorkflow({ root: process.cwd(), check, contractMode });
if (!result.matches) {
process.stderr.write(
`CI workflow drift: ${result.target} differs at byte ${result.firstDifferenceByte ?? 0}, line ${result.firstDifferenceLine ?? 1}\n`,
);
process.exitCode = 1;
} else {
process.stdout.write(check ? "CI workflow bytes: PASS\n" : "CI workflow generated atomically\n");
}
} catch (error) {
process.stderr.write(`CI workflow generation failed: ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}
+111 -245
View File
@@ -1,27 +1,44 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { gzipSync } from "node:zlib";
import {
mkdir,
readFile,
readdir,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import {
diffDependencyInventories,
bundleOutputInventoryArtifactSchema,
buildManifestArtifactSchema,
dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema,
licenseReportArtifactSchema,
provenanceArtifactSchema,
sbomArtifactSchema,
supplyChainVerificationArtifactSchema,
vulnerabilityReportArtifactSchema,
} from "./contracts/release-artifacts.ts";
import {
flattenPnpmDependencyTree,
isValidSha512Integrity,
parsePnpmLockfilePackages,
supplyChainDigest,
validateDependencyReview,
validateLicensePolicy,
validateVulnerabilityReport,
verifySupplyChainCoherence,
type DependencyInventoryDiff,
} from "./lib/supply-chain.ts";
import {
createLocalVulnerabilityReport,
distChecksumsText,
LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS,
recomputeDependencyEvidence,
recomputeLicenseEvidence,
} from "./lib/local-policy-evidence.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { digestReleaseInputFiles } from "./lib/release-input-evidence.ts";
import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
} from "./lib/repository-file-inventory.ts";
import { collectDistOutputs, distSha256 } from "./lib/release-candidate.ts";
import { deterministicSupplyChainGeneratedAt } from "./lib/supply-chain-time.ts";
type Document = Record<string, unknown>;
@@ -48,35 +65,10 @@ async function jsonDocument(file: string): Promise<Document> {
return documentValue(parsed, file);
}
async function filesWithin(directory: string): Promise<string[]> {
try {
const entries = await readdir(directory, { withFileTypes: true });
const nested: string[][] = await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesWithin(target) : [target];
}),
);
return nested.flat().sort();
} catch {
return [];
}
}
async function sha256File(file: string): Promise<string> {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
async function digestFileSet(files: string[]): Promise<string> {
const rows = await Promise.all(
files.sort().map(async (file) => ({
path: file.replaceAll("\\", "/"),
sha256: await sha256File(file),
})),
);
return supplyChainDigest(rows);
}
async function optionalJson(file: string): Promise<Document | null> {
try {
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
@@ -151,34 +143,25 @@ export async function buildDependencyInventory() {
}
const packageJson = await jsonDocument("package.json");
const outputFiles = await filesWithin("dist");
if (outputFiles.length === 0) {
throw new Error("dist is missing; run the production build first");
}
const outputs = await Promise.all(
outputFiles.map(async (outputFile) => {
const content = await readFile(outputFile);
const metadata = await stat(outputFile);
return {
path: outputFile.replaceAll("\\", "/"),
bytes: metadata.size,
gzipBytes: gzipSync(content).byteLength,
sha256: createHash("sha256").update(content).digest("hex"),
};
}),
const buildManifest = buildManifestArtifactSchema.parse(
await jsonDocument("artifacts/release/build-manifest.json"),
);
const distDigest = supplyChainDigest(
outputs.map(({ path: outputPath, bytes, sha256 }) => ({
path: outputPath,
bytes,
sha256,
})),
const secretScanPolicy = documentValue(
JSON.parse(await readFile("config/security/secret-scan-policy.json", "utf8")),
"secret scan policy",
);
const inventoryPolicy = parseRepositoryFileInventoryPolicy(secretScanPolicy);
const repositoryInventory = await buildRepositoryFileInventory({
trackedRoots: inventoryPolicy.trackedRoots,
generatedRoots: inventoryPolicy.generatedRoots,
optionalRoots: inventoryPolicy.optionalRoots,
});
const outputs = await collectDistOutputs();
const distDigest = distSha256(outputs);
const inventory = await buildDependencyInventory();
const licensePolicy = JSON.parse(
await readFile("config/security/dependency-policy.json", "utf8"),
);
const licenseResult = validateLicensePolicy(inventory, licensePolicy);
const baseline = await optionalJson(
"config/security/dependency-baseline.json",
@@ -193,102 +176,24 @@ const dependencyEvidence = JSON.parse(
),
);
const skipsBaseline = process.argv.includes("--no-baseline");
const baselineFailures: string[] = [];
let dependencyDiff: DependencyInventoryDiff = Object.freeze({
added: Object.freeze([]),
removed: Object.freeze([]),
changed: Object.freeze([]),
upgrades: Object.freeze([]),
});
let reviewResult: ReturnType<typeof validateDependencyReview> = Object.freeze({
passed: skipsBaseline,
highRisk: Object.freeze([]),
failures: Object.freeze(
skipsBaseline ? [] : ["dependency baseline unavailable"],
),
});
if (baseline && baselineApproval) {
const actualBaselineDigest = supplyChainDigest(baseline);
if (
baselineApproval.schemaVersion !== 1 ||
baselineApproval.snapshotDigest !== actualBaselineDigest ||
typeof baselineApproval.owner !== "string" ||
!baselineApproval.owner
) {
baselineFailures.push("dependency baseline approval digest mismatch");
}
dependencyDiff = diffDependencyInventories(baseline, inventory);
reviewResult = validateDependencyReview(
dependencyDiff,
const dependencyPolicy = recomputeDependencyEvidence({
inventory,
dependencyEvidence,
);
} else if (!skipsBaseline) {
baselineFailures.push("dependency baseline and approval are required");
}
baseline,
baselineApproval,
dependencyChangeEvidence: dependencyEvidence,
skipBaseline: skipsBaseline,
});
const licenseEvidence = recomputeLicenseEvidence({
inventory,
policy: licensePolicy,
});
const vulnerabilityPolicy = JSON.parse(
await readFile("config/security/vulnerability-policy.json", "utf8"),
);
const vulnerabilityExceptions = JSON.parse(
await readFile("config/security/vulnerability-exceptions.json", "utf8"),
);
const vulnerabilityInput = process.env.VULNERABILITY_REPORT_PATH
? await optionalJson(process.env.VULNERABILITY_REPORT_PATH)
: null;
const vulnerabilityResult = vulnerabilityInput
? validateVulnerabilityReport(
vulnerabilityInput,
vulnerabilityPolicy,
vulnerabilityExceptions,
const vulnerabilityReport = createLocalVulnerabilityReport(
inventory.lockfileSha256,
)
: {
passed: false,
failures: ["external vulnerability provider report is missing"],
blocking: [],
};
const vulnerabilityReport = {
schemaVersion: 1,
provider: vulnerabilityInput?.provider ?? "UNCONFIGURED",
scannedLockfileSha256:
vulnerabilityInput?.scannedLockfileSha256 ?? inventory.lockfileSha256,
status: vulnerabilityInput
? vulnerabilityResult.passed
? "PASS"
: "FAIL"
: "FAIL_UNVERIFIED",
findings: vulnerabilityInput?.findings ?? [],
exceptionsApplied:
vulnerabilityInput && vulnerabilityResult.passed
? vulnerabilityExceptions.exceptions
: [],
failures: vulnerabilityResult.failures,
blocking: vulnerabilityResult.blocking,
};
);
const sourceFiles = (
await Promise.all(
[
"src",
"scripts",
"config",
"public",
"schemas",
"package.json",
"pnpm-lock.yaml",
"vite.config.ts",
].map(async (target) => {
try {
const metadata = await stat(target);
return metadata.isDirectory() ? filesWithin(target) : [target];
} catch {
return [];
}
}),
)
).flat();
const sourceSetSha256 = await digestFileSet(sourceFiles);
const sourceFiles = [...repositoryInventory.trackedFiles];
const sourceSetSha256 = await digestReleaseInputFiles(sourceFiles);
const components = inventory.dependencies.map((dependency) => ({
type: "library",
@@ -382,62 +287,30 @@ const coherence = verifySupplyChainCoherence(
distDigest,
);
const attestationInput = process.env.PROVENANCE_ATTESTATION_PATH
? await optionalJson(process.env.PROVENANCE_ATTESTATION_PATH)
: null;
const attestation = isRecord(attestationInput?.subject)
? attestationInput.subject
: {};
const attestationSubject = isRecord(attestation.digest)
? attestation.digest
: {};
const attestationPassed =
attestationSubject.sha256 === distDigest &&
typeof attestationInput?.provider === "string" &&
Boolean(attestationInput.provider) &&
typeof attestationInput?.signer === "string" &&
Boolean(attestationInput.signer);
const localFailures = [
...licenseResult.failures,
...baselineFailures,
...reviewResult.failures,
...licenseEvidence.failures,
...dependencyPolicy.failures,
...coherence.failures,
];
if (vulnerabilityInput && !vulnerabilityResult.passed) {
localFailures.push(
...vulnerabilityResult.failures,
...vulnerabilityResult.blocking,
);
}
const localPassed = localFailures.length === 0;
const promotionPassed =
localPassed && vulnerabilityResult.passed && attestationPassed;
const verification = {
schemaVersion: 1,
localStatus: localPassed ? "PASS" : "FAIL",
promotionStatus: promotionPassed ? "PASS" : "FAIL_UNVERIFIED",
...LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS,
lockfileSha256: inventory.lockfileSha256,
sourceSetSha256,
distSha256: distDigest,
sbomSha256: supplyChainDigest(sbom),
dependencyDiff,
highRiskReview: reviewResult.highRisk,
vulnerabilityStatus: vulnerabilityReport.status,
provenanceAttestationStatus: attestationPassed
? "PASS"
: "FAIL_UNVERIFIED",
dependencyDiff: dependencyPolicy.dependencyDiff,
highRiskReview: dependencyPolicy.highRisk,
failures: localFailures,
};
await mkdir("artifacts/performance", { recursive: true });
await mkdir("artifacts/release", { recursive: true });
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/performance/bundle.json",
`${JSON.stringify(
{
const bundleReport = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
generatedAt: deterministicSupplyChainGeneratedAt({
generatedAt: buildManifest.generatedAt,
sourceDateEpoch: buildManifest.buildContext.sourceDateEpoch,
}),
context: {
nodeVersion: process.version,
packageManager: String(packageJson.packageManager ?? ""),
@@ -445,64 +318,57 @@ await writeFile(
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
},
outputs,
},
null,
2,
)}\n`,
);
await writeFile(
"artifacts/release/dependency-inventory.json",
`${JSON.stringify(inventory, null, 2)}\n`,
);
await writeFile(
"artifacts/release/sbom.cdx.json",
`${JSON.stringify(sbom, null, 2)}\n`,
);
await writeFile(
"artifacts/release/provenance.json",
`${JSON.stringify(provenance, null, 2)}\n`,
);
};
const dependencyDiffReport = dependencyPolicy.report;
const licenseReport = licenseEvidence.report;
await mkdir("artifacts/performance", { recursive: true });
await mkdir("artifacts/release", { recursive: true });
await mkdir("artifacts/security", { recursive: true });
await writeValidatedJsonArtifact({
path: "artifacts/performance/bundle.json",
schema: bundleOutputInventoryArtifactSchema,
value: bundleReport,
});
await writeValidatedJsonArtifact({
path: "artifacts/release/dependency-inventory.json",
schema: dependencyInventoryArtifactSchema,
value: inventory,
});
await writeValidatedJsonArtifact({
path: "artifacts/release/sbom.cdx.json",
schema: sbomArtifactSchema,
value: sbom,
});
await writeValidatedJsonArtifact({
path: "artifacts/release/provenance.json",
schema: provenanceArtifactSchema,
value: provenance,
});
await writeFile(
"artifacts/release/checksums.txt",
`${outputs.map((output) => `${output.sha256} ${output.path}`).join("\n")}\n`,
);
await writeFile(
"artifacts/security/dependency-diff.json",
`${JSON.stringify(
{
schemaVersion: 2,
baselineDigest: baseline ? supplyChainDigest(baseline) : null,
currentDigest: supplyChainDigest(inventory),
...dependencyDiff,
highRisk: reviewResult.highRisk,
reviewFailures: reviewResult.failures,
},
null,
2,
)}\n`,
);
await writeFile(
"artifacts/security/license-report.json",
`${JSON.stringify(
{
schemaVersion: 1,
status: licenseResult.passed ? "PASS" : "FAIL",
dependencyCount: inventory.dependencyCount,
results: licenseResult.results,
failures: licenseResult.failures,
},
null,
2,
)}\n`,
);
await writeFile(
"artifacts/security/vulnerability-report.json",
`${JSON.stringify(vulnerabilityReport, null, 2)}\n`,
);
await writeFile(
"artifacts/security/supply-chain-verification.json",
`${JSON.stringify(verification, null, 2)}\n`,
distChecksumsText(outputs),
);
await writeValidatedJsonArtifact({
path: "artifacts/security/dependency-diff.json",
schema: dependencyDiffArtifactSchema,
value: dependencyDiffReport,
});
await writeValidatedJsonArtifact({
path: "artifacts/security/license-report.json",
schema: licenseReportArtifactSchema,
value: licenseReport,
});
await writeValidatedJsonArtifact({
path: "artifacts/security/vulnerability-report.json",
schema: vulnerabilityReportArtifactSchema,
value: vulnerabilityReport,
});
await writeValidatedJsonArtifact({
path: "artifacts/security/supply-chain-verification.json",
schema: supplyChainVerificationArtifactSchema,
value: verification,
});
if (!localPassed) {
process.stderr.write(
+66
View File
@@ -0,0 +1,66 @@
import type { SpawnSyncOptionsWithStringEncoding } from "node:child_process";
export const PNPM_SCRIPT_TIMEOUT_MS = 60_000;
export const PNPM_SCRIPT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
type BoundedChildInvocation = Readonly<{
command: string;
arguments: readonly string[];
options: SpawnSyncOptionsWithStringEncoding;
}>;
type PnpmScriptResult = Readonly<{
status: number | null;
signal: NodeJS.Signals | null;
error?: Error;
}>;
export function createBoundedPnpmScriptInvocation(input: Readonly<{
nodePath: string;
pnpmCli: string;
script: string;
environment: NodeJS.ProcessEnv;
}>): BoundedChildInvocation {
return createBoundedChildInvocation({
command: input.nodePath,
arguments: [input.pnpmCli, "run", input.script],
environment: input.environment,
});
}
export function createBoundedChildInvocation(input: Readonly<{
command: string;
arguments: readonly string[];
environment: NodeJS.ProcessEnv;
cwd?: string;
}>): BoundedChildInvocation {
return Object.freeze({
command: input.command,
arguments: Object.freeze([...input.arguments]),
options: Object.freeze({
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
encoding: "utf8",
env: input.environment,
killSignal: "SIGTERM",
maxBuffer: PNPM_SCRIPT_MAX_OUTPUT_BYTES,
timeout: PNPM_SCRIPT_TIMEOUT_MS,
}),
});
}
export function formatPnpmScriptFailure(
script: string,
result: PnpmScriptResult,
): string {
const code =
result.error && "code" in result.error &&
typeof result.error.code === "string"
? result.error.code
: null;
const message = result.error?.message.trim().replace(/\s+/g, " ") ?? null;
const error =
result.error === undefined
? "none"
: `${code ?? result.error.name}: ${message || "no message"}`;
return `${script} failed: exit=${String(result.status)}, signal=${result.signal ?? "none"}, error=${error}`;
}
+188
View File
@@ -0,0 +1,188 @@
import { createHash } from "node:crypto";
import { lstat, readFile, realpath } from "node:fs/promises";
import path from "node:path";
import type { BuildManifestArtifact } from "../../src/contracts/release-artifacts.ts";
import { moduleInventoryArtifactSchema } from "../contracts/release-artifacts.ts";
type VerifyBuildManifestOutputsDependencies = Readonly<{
repositoryRoot?: string;
readBytes?: (target: string) => Promise<Buffer>;
realpathPath?: (target: string) => Promise<string>;
assertRegularFile?: (target: string) => Promise<void>;
assertDirectory?: (target: string) => Promise<void>;
}>;
export const CANONICAL_VITE_MANIFEST_PATH = "dist/.vite/manifest.json";
function isSafeRelativePath(value: string): boolean {
return (
value.length > 0 &&
!path.posix.isAbsolute(value) &&
!path.win32.isAbsolute(value) &&
!value.includes("\\") &&
!value.includes("\0") &&
path.posix.normalize(value) === value &&
value !== ".." &&
!value.startsWith("../")
);
}
function isWithinRoot(root: string, target: string): boolean {
const relative = path.relative(root, target);
return (
relative === "" ||
(relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative))
);
}
function belongsToApprovedRoot(value: string, approvedRoot: string): boolean {
return value.startsWith(`${approvedRoot}/`);
}
async function defaultAssertRegularFile(target: string): Promise<void> {
const metadata = await lstat(target);
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new TypeError("not a regular file");
}
}
async function defaultAssertDirectory(target: string): Promise<void> {
const metadata = await lstat(target);
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new TypeError("not a directory");
}
}
export async function verifyBuildManifestOutputs(
manifest: BuildManifestArtifact,
dependencies: VerifyBuildManifestOutputsDependencies = {},
): Promise<string[]> {
const repositoryRoot = path.resolve(dependencies.repositoryRoot ?? process.cwd());
const readBytes = dependencies.readBytes ?? readFile;
const realpathPath = dependencies.realpathPath ?? realpath;
const assertRegularFile = dependencies.assertRegularFile ?? defaultAssertRegularFile;
const assertDirectory = dependencies.assertDirectory ?? defaultAssertDirectory;
const mismatches: string[] = [];
const resolvedRoot = await realpathPath(repositoryRoot);
const approvedRoots = new Map<string, Promise<string | null>>();
function resolveApprovedRoot(relativeRoot: string): Promise<string | null> {
const existing = approvedRoots.get(relativeRoot);
if (existing) return existing;
const pending = (async () => {
const absoluteRoot = path.resolve(repositoryRoot, relativeRoot);
try {
await assertDirectory(absoluteRoot);
const resolvedApprovedRoot = await realpathPath(absoluteRoot);
return isWithinRoot(resolvedRoot, resolvedApprovedRoot)
? resolvedApprovedRoot
: null;
} catch {
return null;
}
})();
approvedRoots.set(relativeRoot, pending);
return pending;
}
async function confinedPath(
label: string,
relativePath: string,
kind: "file" | "directory",
approvedRoot?: string,
): Promise<string | null> {
if (
!isSafeRelativePath(relativePath) ||
(approvedRoot !== undefined &&
!belongsToApprovedRoot(relativePath, approvedRoot))
) {
mismatches.push(`buildManifest:${label}:path`);
return null;
}
const absolutePath = path.resolve(repositoryRoot, relativePath);
if (!isWithinRoot(repositoryRoot, absolutePath)) {
mismatches.push(`buildManifest:${label}:path`);
return null;
}
try {
if (kind === "file") await assertRegularFile(absolutePath);
else await assertDirectory(absolutePath);
const resolvedPath = await realpathPath(absolutePath);
const resolvedApprovedRoot = approvedRoot
? await resolveApprovedRoot(approvedRoot)
: resolvedRoot;
if (
resolvedApprovedRoot === null ||
!isWithinRoot(resolvedRoot, resolvedPath) ||
!isWithinRoot(resolvedApprovedRoot, resolvedPath)
) {
mismatches.push(`buildManifest:${label}:path`);
return null;
}
return absolutePath;
} catch {
mismatches.push(`buildManifest:${label}:missing`);
return null;
}
}
if (manifest.outputs.directory !== "dist") {
mismatches.push("buildManifest:directory:path");
} else {
await confinedPath("directory", manifest.outputs.directory, "directory");
}
if (manifest.outputs.viteManifest !== CANONICAL_VITE_MANIFEST_PATH) {
mismatches.push("buildManifest:viteManifest:path");
} else {
await confinedPath(
"viteManifest",
manifest.outputs.viteManifest,
"file",
"dist",
);
}
await confinedPath(
"runtimeConfigSchema",
manifest.outputs.runtimeConfigSchema,
"file",
"dist",
);
for (const [chunkId, chunkPath] of Object.entries(manifest.outputs.routeChunks)) {
if (!isSafeRelativePath(chunkPath)) {
mismatches.push(`buildManifest:routeChunk:${chunkId}:path`);
continue;
}
await confinedPath(
`routeChunk:${chunkId}`,
path.posix.join(manifest.outputs.directory, chunkPath),
"file",
"dist",
);
}
const moduleInventoryPath = await confinedPath(
"moduleInventory",
manifest.outputs.moduleInventory,
"file",
"artifacts/quality",
);
if (moduleInventoryPath) {
try {
const bytes = await readBytes(moduleInventoryPath);
const digest = createHash("sha256").update(bytes).digest("hex");
if (digest !== manifest.moduleInventoryHash) {
mismatches.push("buildManifest:moduleInventoryHash");
}
try {
moduleInventoryArtifactSchema.parse(JSON.parse(bytes.toString("utf8")));
} catch {
mismatches.push("buildManifest:moduleInventory:invalid");
}
} catch {
mismatches.push("buildManifest:moduleInventory:missing");
}
}
return mismatches;
}
+740
View File
@@ -0,0 +1,740 @@
import { constants, type Stats } from "node:fs";
import { lstat, open, realpath } from "node:fs/promises";
import path from "node:path";
import { z, type ZodType } from "zod";
import type {
CiGateArtifact,
CiGateArtifactSchema,
} from "../contracts/ci-gates.ts";
import { ciContractReportSchema } from "./ci-contract-report.ts";
import {
architectureDependencyReportArtifactSchema,
automatedA11yArtifactSchema,
buildManifestArtifactSchema,
bundlePerformanceArtifactSchema,
compatibilityFixturesArtifactSchema,
dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema,
designSystemReportArtifactSchema,
diagnosticsReportArtifactSchema,
documentationReviewArtifactSchema,
fieldWebVitalsArtifactSchema,
hostingHeadersArtifactSchema,
i18nReportArtifactSchema,
jsonSchemaDocumentArtifactSchema,
labPerformanceArtifactSchema,
licenseReportArtifactSchema,
manualA11yReportArtifactSchema,
moduleInventoryArtifactSchema,
optionalRecipeFixturesArtifactSchema,
optionalRecipesArtifactSchema,
provenanceArtifactSchema,
realtimeBoundariesArtifactSchema,
registryGovernanceRunArtifactSchema,
registryCompatibilityFixturesArtifactSchema,
registrySnapshotArtifactSchema,
releaseVerificationArtifactSchema,
reproducibleBuildArtifactSchema,
runbookRecordArtifactSchema,
sbomArtifactSchema,
supplyChainFixturesArtifactSchema,
supplyChainProviderFixturesArtifactSchema,
supplyChainVerificationArtifactSchema,
vulnerabilityReportArtifactSchema,
} from "../contracts/release-artifacts.ts";
import { httpScenarioReceiptSchema } from "./http-scenario-evidence.ts";
import { supplyChainCoherenceReportSchema } from "./local-release-evidence.ts";
import {
providerVerificationArtifactSchema,
provenanceProviderAttestationSchema,
vulnerabilityProviderReportSchema,
} from "./provider-evidence.ts";
import { releaseCandidateManifestSchema } from "./release-candidate.ts";
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
import { secretScanSarifSchema } from "./secret-scan-evaluator.ts";
import { testEvidenceReportSchema } from "./test-evidence-artifact.ts";
const coverageCounterSchema = z
.object({
total: z.number().int().nonnegative(),
covered: z.number().int().nonnegative(),
skipped: z.number().int().nonnegative(),
pct: z.number().min(0).max(100),
})
.strict()
.superRefine((counter, context) => {
if (counter.covered + counter.skipped > counter.total) {
context.addIssue({ code: "custom", message: "coverage counter exceeds total" });
}
const expected = counter.total === 0
? 100
: Math.floor((counter.covered / counter.total) * 10_000) / 100;
if (counter.pct !== expected) {
context.addIssue({ code: "custom", path: ["pct"], message: "coverage pct is not exact" });
}
});
const coverageSummarySchema = z
.record(
z.string(),
z
.object({
lines: coverageCounterSchema,
statements: coverageCounterSchema,
functions: coverageCounterSchema,
branches: coverageCounterSchema,
})
.strict(),
)
.refine((value) => "total" in value, "coverage summary lacks total");
const riskCoverageArtifactSchema = z
.object({
schemaVersion: z.literal(3),
policy: z.string().min(1),
summary: z.string().min(1),
status: z.enum(["PASS", "FAIL"]),
selectedTotal: z.number().int().nonnegative(),
repositoryTotal: z.number().int().positive(),
counterBearingTotal: z.number().int().nonnegative(),
instrumentedCounterBearingTotal: z.number().int().nonnegative(),
counterlessTotal: z.number().int().nonnegative(),
counterlessModules: z.array(z.string()),
preExclusionTotal: z.number().int().positive(),
generatedExclusionCount: z.number().int().nonnegative(),
generatedExclusions: z.array(z.string()),
ownershipScope: z.literal("ALL_POLICY_HIGH_RISK"),
ownedHighRiskPaths: z.array(z.string()),
waivedHighRiskPaths: z.array(z.string()),
uncoveredModules: z.array(z.string()),
results: z
.array(
z
.object({
scope: z.string().min(1),
metric: z.enum(["lines", "statements", "functions", "branches"]),
threshold: z.number().min(0).max(100),
received: z.number().min(0).max(100),
passed: z.boolean(),
})
.strict(),
)
.min(4),
failures: z.array(z.string()),
})
.strict()
.superRefine((artifact, context) => {
const fail = (path: PropertyKey[], message: string) =>
context.addIssue({ code: "custom", path, message });
if (artifact.counterBearingTotal + artifact.counterlessTotal !== artifact.repositoryTotal) {
fail(["counterBearingTotal"], "counter partition must equal repositoryTotal");
}
if (artifact.instrumentedCounterBearingTotal > artifact.counterBearingTotal) {
fail(["instrumentedCounterBearingTotal"], "instrumented counters exceed counter-bearing total");
}
if (artifact.counterlessModules.length !== artifact.counterlessTotal) {
fail(["counterlessModules"], "counterless list length drift");
}
if (artifact.generatedExclusions.length !== artifact.generatedExclusionCount) {
fail(["generatedExclusions"], "generated exclusion list length drift");
}
if (
artifact.preExclusionTotal !==
artifact.repositoryTotal + artifact.generatedExclusionCount
) {
fail(["preExclusionTotal"], "pre-exclusion inventory total drift");
}
if (
artifact.selectedTotal > artifact.repositoryTotal ||
artifact.uncoveredModules.length !== artifact.repositoryTotal - artifact.selectedTotal
) {
fail(["selectedTotal"], "selected/uncovered repository totals drift");
}
if (
(artifact.status === "PASS") !==
(artifact.failures.length === 0 && artifact.results.every(({ passed }) => passed))
) {
fail(["status"], "status must agree with failures and threshold results");
}
artifact.results.forEach((result, index) => {
if (result.passed !== (result.received >= result.threshold)) {
fail(["results", index, "passed"], "threshold result is inconsistent");
}
});
for (const [field, values] of [
["counterlessModules", artifact.counterlessModules],
["generatedExclusions", artifact.generatedExclusions],
["ownedHighRiskPaths", artifact.ownedHighRiskPaths],
["waivedHighRiskPaths", artifact.waivedHighRiskPaths],
["uncoveredModules", artifact.uncoveredModules],
] as const) {
if (new Set(values).size !== values.length) fail([field], "path list contains duplicates");
}
const owned = new Set(artifact.ownedHighRiskPaths);
if (artifact.waivedHighRiskPaths.some((modulePath) => owned.has(modulePath))) {
fail(["waivedHighRiskPaths"], "owned and waived high-risk paths overlap");
}
const resultsByScope = new Map<string, Set<string>>();
artifact.results.forEach(({ scope, metric }, index) => {
const metrics = resultsByScope.get(scope) ?? new Set<string>();
if (metrics.has(metric)) {
fail(["results", index, "metric"], "threshold metric is duplicated within scope");
}
metrics.add(metric);
resultsByScope.set(scope, metrics);
});
for (const [scope, metrics] of resultsByScope) {
if (metrics.size !== 4) {
fail(["results"], `threshold scope must contain all four metrics: ${scope}`);
}
}
});
type ExecutableJsonSchemaId = Extract<
CiGateArtifactSchema,
Readonly<{ kind: "json" }>
>["executableSchemaId"];
const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> = Object.freeze({
"automated-a11y": automatedA11yArtifactSchema,
"manual-a11y": manualA11yReportArtifactSchema,
"architecture-dependency-report": architectureDependencyReportArtifactSchema,
"design-system-contract": designSystemReportArtifactSchema,
"i18n-contract": i18nReportArtifactSchema,
"diagnostics-contract": diagnosticsReportArtifactSchema,
"realtime-boundaries": realtimeBoundariesArtifactSchema,
"optional-recipes": optionalRecipesArtifactSchema,
"optional-recipe-fixtures": optionalRecipeFixturesArtifactSchema,
"registry-compatibility-fixtures": registryCompatibilityFixturesArtifactSchema,
"reproducible-build": reproducibleBuildArtifactSchema,
"supply-chain-fixtures": supplyChainFixturesArtifactSchema,
"supply-chain-provider-fixtures": supplyChainProviderFixturesArtifactSchema,
"compatibility-fixtures": compatibilityFixturesArtifactSchema,
"documentation-review": documentationReviewArtifactSchema,
"hosting-headers": hostingHeadersArtifactSchema,
"coverage-summary-v8": coverageSummarySchema,
"risk-coverage-v3": riskCoverageArtifactSchema,
"build-manifest": buildManifestArtifactSchema,
"module-inventory": moduleInventoryArtifactSchema,
"dependency-inventory": dependencyInventoryArtifactSchema,
"registry-snapshot": registrySnapshotArtifactSchema,
"registry-governance-run": registryGovernanceRunArtifactSchema,
"bundle-performance": bundlePerformanceArtifactSchema,
sbom: sbomArtifactSchema,
provenance: provenanceArtifactSchema,
"dependency-diff": dependencyDiffArtifactSchema,
"license-report": licenseReportArtifactSchema,
"vulnerability-report": vulnerabilityReportArtifactSchema,
"field-web-vitals": fieldWebVitalsArtifactSchema,
"lab-performance": labPerformanceArtifactSchema,
"release-verification": releaseVerificationArtifactSchema,
"runbook-record": runbookRecordArtifactSchema,
"supply-chain-verification": supplyChainVerificationArtifactSchema,
"release-candidate": releaseCandidateManifestSchema,
"supply-chain-coherence": supplyChainCoherenceReportSchema,
"http-scenario-receipt": httpScenarioReceiptSchema,
"test-evidence-report": testEvidenceReportSchema,
"provider-vulnerability": vulnerabilityProviderReportSchema,
"provider-provenance": provenanceProviderAttestationSchema,
"provider-verification": providerVerificationArtifactSchema,
"ci-contract-report": ciContractReportSchema,
});
export function hasCiArtifactSemanticValidator(
schema: CiGateArtifactSchema,
): boolean {
return schema.kind !== "json" || schema.executableSchemaId in executableJsonSchemas;
}
type ReadHandle = Readonly<{
stat(): Promise<Stats>;
read(
buffer: Buffer,
offset: number,
length: number,
position: number,
): Promise<Readonly<{ bytesRead: number }>>;
close(): Promise<unknown>;
}>;
type ValidatorDependencies = Readonly<{
lstatPath?: typeof lstat;
realpathPath?: typeof realpath;
openFile?: (target: string, flags: number) => Promise<ReadHandle>;
}>;
export async function validateCiArtifact(
input: Readonly<{
root: string;
artifact: CiGateArtifact;
schema: CiGateArtifactSchema;
}>,
dependencies: ValidatorDependencies = {},
): Promise<void> {
const relative = normalizeRepositoryRelativePath(input.artifact.path, "CI artifact path");
assertExtensionCoherence(relative, input.schema.kind);
const bytes = await readBoundedRegularFile(
{ root: input.root, relativePath: relative, maxBytes: input.schema.maxBytes },
dependencies,
);
if (input.schema.kind === "candidate-archive") return;
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
if (!text.trim()) throw new TypeError(`CI artifact is empty: ${relative}`);
switch (input.schema.kind) {
case "text":
return;
case "markdown":
if (!/^#|\[[^\]]+\]|\S/u.test(text)) throw new TypeError(`invalid Markdown artifact: ${relative}`);
return;
case "html":
assertWellFormedHtml(text, relative);
return;
case "junit":
assertWellFormedJUnitXml(text, relative);
return;
case "sarif":
secretScanSarifSchema.parse(JSON.parse(text) as unknown);
return;
case "json-schema":
jsonSchemaDocumentArtifactSchema.parse(JSON.parse(text) as unknown);
return;
case "json": {
const schema = executableJsonSchemas[input.schema.executableSchemaId];
if (!schema) throw new TypeError(`unknown executable artifact schema: ${input.schema.executableSchemaId}`);
schema.parse(JSON.parse(text) as unknown);
return;
}
}
}
export async function readBoundedRegularFile(
input: Readonly<{ root: string; relativePath: string; maxBytes: number }>,
dependencies: ValidatorDependencies = {},
): Promise<Buffer> {
const root = path.resolve(input.root);
const relative = normalizeRepositoryRelativePath(input.relativePath, "bounded file path");
const maxBytes = input.maxBytes;
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 268_435_456) {
throw new RangeError("bounded file maximum must be within 1..268435456");
}
const lstatPath = dependencies.lstatPath ?? lstat;
const realpathPath = dependencies.realpathPath ?? realpath;
const openFile = dependencies.openFile ?? (async (target, flags) => open(target, flags));
const rootMetadata = await lstatPath(root);
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
throw new TypeError("bounded file root is unsafe");
}
const rootRealpath = await realpathPath(root);
let ancestor = root;
const segments = relative.split("/");
for (const segment of segments.slice(0, -1)) {
ancestor = path.join(ancestor, segment);
const metadata = await lstatPath(ancestor);
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new TypeError(`CI artifact ancestor is unsafe: ${relative}`);
}
}
const absolute = path.join(root, relative);
const before = await lstatPath(absolute);
if (before.isSymbolicLink() || !before.isFile()) {
throw new TypeError(`CI artifact is not a regular file: ${relative}`);
}
if (before.size <= 0 || before.size > maxBytes) {
throw new RangeError(`CI artifact size is outside 1..${maxBytes}: ${relative}`);
}
const resolved = await realpathPath(absolute);
const outside = path.relative(rootRealpath, resolved);
if (outside === ".." || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) {
throw new TypeError(`CI artifact escapes repository: ${relative}`);
}
const handle = await openFile(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const opened = await handle.stat();
assertSameIdentity(before, opened, relative);
const bytes = await readHandleBounded(handle, before.size, maxBytes, relative);
const after = await handle.stat();
assertSameIdentity(opened, after, relative);
if (bytes.byteLength <= 0 || bytes.byteLength > maxBytes || after.size !== bytes.byteLength) {
throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`);
}
return bytes;
} finally {
await handle.close();
}
}
async function readHandleBounded(
handle: ReadHandle,
expectedSize: number,
maxBytes: number,
relative: string,
): Promise<Buffer> {
const captured = Buffer.allocUnsafe(Math.min(maxBytes + 1, expectedSize + 1));
let offset = 0;
while (offset < captured.byteLength) {
const { bytesRead } = await handle.read(
captured,
offset,
captured.byteLength - offset,
offset,
);
if (bytesRead === 0) break;
offset += bytesRead;
}
if (offset !== expectedSize) {
throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`);
}
return captured.subarray(0, offset);
}
const MAX_DOCUMENT_DEPTH = 256;
const MAX_DOCUMENT_UNITS = 100_000;
function assertWellFormedJUnitXml(source: string, relative: string): void {
const invalid = () => new TypeError(`invalid JUnit artifact: ${relative}`);
if (/<!DOCTYPE\b|<!ENTITY\b/iu.test(source)) throw invalid();
if (!hasOnlyXmlCharacters(source)) throw invalid();
const stack: string[] = [];
let root: string | undefined;
let rootClosed = false;
let declarationSeen = false;
let units = 0;
let cursor = 0;
while (cursor < source.length) {
const open = source.indexOf("<", cursor);
const text = source.slice(cursor, open < 0 ? source.length : open);
if (stack.length === 0 && text.trim()) throw invalid();
if ((text.includes("]]>") || !hasValidXmlEntities(text)) && text.length > 0) throw invalid();
if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid();
if (open < 0) break;
if (source.startsWith("<!--", open)) {
const close = source.indexOf("-->", open + 4);
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 3;
continue;
}
if (source.startsWith("<![CDATA[", open)) {
const close = source.indexOf("]]>", open + 9);
if (stack.length === 0 || close < 0) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 3;
continue;
}
if (source.startsWith("<?", open)) {
const close = source.indexOf("?>", open + 2);
if (root || close < 0) throw invalid();
const processingInstruction = source.slice(open, close + 2);
const match = /^<\?([A-Za-z_][\w:.-]*)(?:\s+[\s\S]*?)?\?>$/u.exec(
processingInstruction,
);
if (!match) throw invalid();
if (match[1]!.toLowerCase() === "xml") {
if (
declarationSeen ||
source.slice(0, open).trim() ||
!/^<\?xml\s+version\s*=\s*(["'])1\.0\1(?:\s+encoding\s*=\s*(["'])UTF-8\2)?\s*\?>$/u.test(
processingInstruction,
)
) {
throw invalid();
}
declarationSeen = true;
}
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 2;
continue;
}
if (source.startsWith("<!", open)) throw invalid();
const close = markupEnd(source, open + 1);
if (close < 0) throw invalid();
const tag = source.slice(open, close + 1);
const closing = /^<\/([A-Za-z_][\w:.-]*)\s*>$/u.exec(tag);
if (closing) {
if (stack.pop() !== closing[1]) throw invalid();
if (stack.length === 0) rootClosed = true;
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
continue;
}
const opening = /^<([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?)>$/u.exec(tag);
if (!opening || rootClosed || !hasValidXmlAttributes(opening[2] ?? "")) throw invalid();
root ??= opening[1];
if (opening[3] !== "/") {
if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid();
stack.push(opening[1]!);
}
else if (stack.length === 0) rootClosed = true;
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
}
if (stack.length > 0 || !rootClosed || (root !== "testsuite" && root !== "testsuites")) {
throw invalid();
}
}
function hasValidXmlAttributes(source: string): boolean {
let remaining = source;
const names = new Set<string>();
while (remaining.length > 0) {
if (!remaining.trim()) return true;
const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/u.exec(remaining);
if (!match || names.has(match[1]!)) return false;
if (!hasValidXmlEntities(match[2] ?? match[3] ?? "")) return false;
names.add(match[1]!);
remaining = remaining.slice(match[0].length);
}
return true;
}
function hasOnlyXmlCharacters(source: string): boolean {
for (const character of source) {
const codePoint = character.codePointAt(0)!;
if (
codePoint !== 0x09 &&
codePoint !== 0x0a &&
codePoint !== 0x0d &&
(codePoint < 0x20 ||
(codePoint > 0xd7ff && codePoint < 0xe000) ||
(codePoint > 0xfffd && codePoint < 0x10000) ||
codePoint > 0x10ffff)
) {
return false;
}
}
return true;
}
function hasValidXmlEntities(source: string): boolean {
let cursor = 0;
while (cursor < source.length) {
const ampersand = source.indexOf("&", cursor);
if (ampersand < 0) return true;
const semicolon = source.indexOf(";", ampersand + 1);
if (semicolon < 0) return false;
const entity = source.slice(ampersand + 1, semicolon);
if (!["amp", "lt", "gt", "apos", "quot"].includes(entity)) {
const decimal = /^#([0-9]+)$/u.exec(entity);
const hexadecimal = /^#x([a-fA-F0-9]+)$/u.exec(entity);
if (!decimal && !hexadecimal) return false;
const codePoint = Number.parseInt((decimal ?? hexadecimal)![1]!, decimal ? 10 : 16);
if (
!Number.isSafeInteger(codePoint) ||
(codePoint !== 0x09 &&
codePoint !== 0x0a &&
codePoint !== 0x0d &&
(codePoint < 0x20 ||
(codePoint > 0xd7ff && codePoint < 0xe000) ||
(codePoint > 0xfffd && codePoint < 0x10000) ||
codePoint > 0x10ffff))
) {
return false;
}
}
cursor = semicolon + 1;
}
return true;
}
const HTML_VOID_ELEMENTS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
const HTML_RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
function assertWellFormedHtml(source: string, relative: string): void {
const invalid = () => new TypeError(`invalid HTML artifact: ${relative}`);
if (/<!ENTITY\b|<!DOCTYPE\s+html\s+[^>]*\[/iu.test(source)) throw invalid();
const stack: string[] = [];
let cursor = 0;
let units = 0;
let doctypeSeen = false;
let rootSeen = false;
let rootClosed = false;
let playwrightPayloadSeen = false;
while (cursor < source.length) {
const rawElement = stack.at(-1);
if (rawElement && HTML_RAW_TEXT_ELEMENTS.has(rawElement)) {
const closingStart = source.toLowerCase().indexOf(`</${rawElement}`, cursor);
if (closingStart < 0) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = closingStart;
}
const open = source.indexOf("<", cursor);
const text = source.slice(cursor, open < 0 ? source.length : open);
if (stack.length === 0 && text.trim()) throw invalid();
if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid();
if (open < 0) break;
if (source.startsWith("<!--", open)) {
const close = source.indexOf("-->", open + 4);
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 3;
continue;
}
const declarationEnd = source.indexOf(">", open + 2);
if (source.slice(open, open + 9).toLowerCase() === "<!doctype") {
if (
declarationEnd < 0 ||
doctypeSeen ||
rootSeen ||
source.slice(open, declarationEnd + 1).toLowerCase() !== "<!doctype html>"
) {
throw invalid();
}
doctypeSeen = true;
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = declarationEnd + 1;
continue;
}
if (source.startsWith("<!", open) || source.startsWith("<?", open)) throw invalid();
const close = markupEnd(source, open + 1);
if (close < 0) throw invalid();
const tag = source.slice(open, close + 1);
const closing = /^<\/([A-Za-z][A-Za-z0-9:-]*)\s*>$/u.exec(tag);
if (closing) {
const name = closing[1]!.toLowerCase();
if (stack.pop() !== name) throw invalid();
if (stack.length === 0) {
if (name === "html") rootClosed = true;
else if (name === "template" && playwrightPayloadSeen) {
// Playwright emits its base64 report template after </html>; HTML5
// reparents this token into the document body. It is the sole
// permitted generated-report sidecar and does not create a new root.
} else {
throw invalid();
}
}
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
continue;
}
const opening = /^<([A-Za-z][A-Za-z0-9:-]*)([\s\S]*?)(\/?)>$/u.exec(tag);
if (!opening) throw invalid();
const name = opening[1]!.toLowerCase();
const attributes = parseHtmlAttributes(opening[2] ?? "");
if (!attributes || name.includes(":")) throw invalid();
if (!rootSeen) {
if (name !== "html" || opening[3] === "/") throw invalid();
rootSeen = true;
} else if (name === "html") {
throw invalid();
}
if (rootClosed && stack.length === 0) {
if (
playwrightPayloadSeen ||
name !== "template" ||
attributes.get("id") !== "playwrightReportBase64" ||
opening[3] === "/"
) {
throw invalid();
}
playwrightPayloadSeen = true;
}
if (!HTML_VOID_ELEMENTS.has(name) && opening[3] !== "/") {
if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid();
stack.push(name);
} else if (stack.length === 0 && name === "html") {
rootClosed = true;
}
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
}
if (stack.length > 0 || !rootSeen || !rootClosed) throw invalid();
}
function markupEnd(source: string, start: number): number {
let quote: "\"" | "'" | undefined;
for (let index = start; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (character === quote) quote = undefined;
} else if (character === "\"" || character === "'") {
quote = character;
} else if (character === ">") {
return index;
}
}
return -1;
}
function parseHtmlAttributes(source: string): ReadonlyMap<string, string> | null {
const attributes = new Map<string, string>();
let cursor = 0;
while (cursor < source.length) {
const whitespace = /^\s+/u.exec(source.slice(cursor));
if (!whitespace) return source.slice(cursor).trim() ? null : attributes;
cursor += whitespace[0].length;
if (cursor >= source.length) return attributes;
const nameMatch = /^[A-Za-z_:][A-Za-z0-9:._-]*/u.exec(source.slice(cursor));
if (!nameMatch) return null;
const name = nameMatch[0].toLowerCase();
if (attributes.has(name) || name.includes(":")) return null;
cursor += nameMatch[0].length;
const spacing = /^\s*/u.exec(source.slice(cursor))![0];
cursor += spacing.length;
let value = "";
if (source[cursor] === "=") {
cursor += 1;
cursor += /^\s*/u.exec(source.slice(cursor))![0].length;
const quote = source[cursor];
if (quote === "\"" || quote === "'") {
const end = source.indexOf(quote, cursor + 1);
if (end < 0) return null;
value = source.slice(cursor + 1, end);
if (value.includes("<")) return null;
cursor = end + 1;
} else {
const unquoted = /^[^\s"'`=<>]+/u.exec(source.slice(cursor));
if (!unquoted) return null;
value = unquoted[0];
cursor += value.length;
}
}
attributes.set(name, value);
}
return attributes;
}
function assertSameIdentity(before: Stats, after: Stats, relative: string): void {
if (
!Number.isSafeInteger(before.dev) ||
!Number.isSafeInteger(before.ino) ||
before.dev <= 0 ||
before.ino <= 0 ||
before.dev !== after.dev ||
before.ino !== after.ino ||
!after.isFile()
) {
throw new TypeError(`CI artifact file identity changed: ${relative}`);
}
}
function assertExtensionCoherence(relative: string, kind: CiGateArtifactSchema["kind"]): void {
const valid =
kind === "json" || kind === "json-schema"
? relative.endsWith(".json")
: kind === "sarif"
? relative.endsWith(".sarif")
: kind === "junit"
? relative.endsWith(".xml")
: kind === "html"
? relative.endsWith(".html")
: kind === "markdown"
? relative.endsWith(".md")
: kind === "candidate-archive"
? relative.endsWith(".tar.gz")
: !/\.(?:json|sarif|xml|html|md|tar\.gz)$/u.test(relative);
if (!valid) throw new TypeError(`CI artifact extension/kind mismatch: ${relative} (${kind})`);
}
+26
View File
@@ -0,0 +1,26 @@
export const CANDIDATE_ARCHIVE_USAGE =
"Usage: verify-ci-candidate-archive --archive <path> [--extract-to <path>] [--github-output <path>]\n";
export function parseCandidateArchiveArguments(arguments_: readonly string[]): Readonly<{
archivePath: string;
extractTo?: string;
githubOutput?: string;
}> | null {
const allowed = new Set(["--archive", "--extract-to", "--github-output"]);
const values = new Map<string, string>();
for (let index = 0; index < arguments_.length; index += 2) {
const flag = arguments_[index];
const value = arguments_[index + 1];
if (!flag || !allowed.has(flag) || values.has(flag) || !value || value.startsWith("--")) {
return null;
}
values.set(flag, value);
}
const archivePath = values.get("--archive");
if (!archivePath) return null;
return Object.freeze({
archivePath,
...(values.has("--extract-to") ? { extractTo: values.get("--extract-to")! } : {}),
...(values.has("--github-output") ? { githubOutput: values.get("--github-output")! } : {}),
});
}
+663
View File
@@ -0,0 +1,663 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import {
lstat,
mkdir,
mkdtemp,
open,
readFile,
readdir,
rename,
rm,
unlink,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
distSha256,
releaseCandidateManifestSchema,
type ReleaseCandidateManifest,
} from "./release-candidate.ts";
import { supplyChainDigest } from "./supply-chain.ts";
import {
assertSafePublishLeaf,
ensureSafePublishDirectory,
} from "./ci-gate-log.ts";
const MAX_ARCHIVE_BYTES = 268_435_456;
const MAX_CANDIDATE_FILES = 4_096;
const MAX_ARCHIVE_MEMBERS = 8_192;
const MAX_MEMBER_PATH_BYTES = 1_024;
const TAR_EXECUTABLE = "/usr/bin/tar";
const TAR_ENVIRONMENT = Object.freeze({ PATH: "/usr/bin:/bin", LC_ALL: "C", LANG: "C" });
export type CapturedCandidateArchive = Readonly<{
bytes: Buffer;
archiveSha256: string;
}>;
export async function captureCiCandidateArchive(input: Readonly<{
archivePath: string;
expectedSha256: string;
}>): Promise<CapturedCandidateArchive> {
if (!/^[a-f0-9]{64}$/u.test(input.expectedSha256)) {
throw new TypeError("expected candidate archive SHA-256 is invalid");
}
const absolute = path.resolve(input.archivePath);
const before = await lstat(absolute);
if (!before.isFile() || before.isSymbolicLink()) {
throw new TypeError("candidate archive must be a regular non-symlink file");
}
if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) {
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
}
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
let bytes: Buffer;
try {
assertSameIdentity(before, await handle.stat());
bytes = await readCapturedArchive(handle, before.size);
assertSameIdentity(before, await handle.stat());
} finally {
await handle.close();
}
const archiveSha256 = createHash("sha256").update(bytes).digest("hex");
if (archiveSha256 !== input.expectedSha256) {
throw new Error("candidate archive SHA-256 mismatch");
}
return Object.freeze({ bytes, archiveSha256 });
}
export async function withVerifiedCapturedCandidate<T>(input: Readonly<{
captured: CapturedCandidateArchive;
verify: (view: Readonly<{
extractionRoot: string;
manifest: ReleaseCandidateManifest;
}>) => Promise<T>;
}>): Promise<T> {
let result: T | undefined;
await verifyCapturedCiCandidateArchive(
input.captured.bytes,
input.captured.archiveSha256,
{
verifyExtracted: async (extractionRoot, manifest) => {
result = await input.verify({ extractionRoot, manifest });
},
},
);
return result as T;
}
export async function verifyCiCandidateArchive(
input: Readonly<{
archivePath: string;
expectedSha256?: string;
extractTo?: string;
repositoryRoot?: string;
}>,
dependencies: Readonly<{ afterArchiveRead?: () => Promise<void> }> = {},
): Promise<Readonly<{
archiveSha256: string;
memberCount: number;
manifest: ReleaseCandidateManifest;
}>> {
if (input.expectedSha256 && !/^[a-f0-9]{64}$/u.test(input.expectedSha256)) {
throw new TypeError("expected candidate archive SHA-256 is invalid");
}
const absolute = path.resolve(input.archivePath);
const before = await lstat(absolute);
if (!before.isFile() || before.isSymbolicLink()) {
throw new TypeError("candidate archive must be a regular non-symlink file");
}
if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) {
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
}
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
let archive: Buffer;
try {
assertSameIdentity(before, await handle.stat());
archive = await readCapturedArchive(handle, before.size);
assertSameIdentity(before, await handle.stat());
} finally {
await handle.close();
}
if (archive.byteLength !== before.size) {
throw new Error("candidate archive changed size during capture");
}
await dependencies.afterArchiveRead?.();
const archiveSha256 = createHash("sha256").update(archive).digest("hex");
if (input.expectedSha256 && archiveSha256 !== input.expectedSha256) {
throw new Error("candidate archive SHA-256 mismatch");
}
const extractionTarget = input.extractTo ? path.resolve(input.extractTo) : undefined;
let extractionRoot: string;
let extractionParentIdentity: Awaited<ReturnType<typeof ensureSafePublishDirectory>> | undefined;
if (extractionTarget) {
if (!input.repositoryRoot) {
throw new TypeError("repositoryRoot is required when publishing an extracted candidate");
}
const repositoryRoot = path.resolve(input.repositoryRoot);
extractionParentIdentity = await ensureSafePublishDirectory(
repositoryRoot,
path.dirname(extractionTarget),
);
await assertSafePublishLeaf(extractionTarget, input.extractTo);
extractionRoot = await mkdtemp(
path.join(path.dirname(extractionTarget), `.${path.basename(extractionTarget)}.verified-`),
);
} else {
extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-candidate-archive-"));
}
let published = false;
try {
const captured = await materializeCapturedArchive(archive);
try {
const preflightManifest = preflightArchiveHandle(captured.handle);
extractArchiveHandle(captured.handle, extractionRoot);
const verified = await verifyExtractedTree(extractionRoot, preflightManifest);
if (extractionTarget) {
const repositoryRoot = path.resolve(input.repositoryRoot!);
const currentParentIdentity = await ensureSafePublishDirectory(
repositoryRoot,
path.dirname(extractionTarget),
);
if (
!extractionParentIdentity ||
extractionParentIdentity.dev <= 0 ||
extractionParentIdentity.ino <= 0 ||
currentParentIdentity.dev !== extractionParentIdentity.dev ||
currentParentIdentity.ino !== extractionParentIdentity.ino
) {
throw new Error("verified extraction parent identity changed");
}
await assertSafePublishLeaf(extractionTarget, input.extractTo);
if (await pathExists(extractionTarget)) {
throw new Error(`verified extraction target already exists: ${input.extractTo}`);
}
await rename(extractionRoot, extractionTarget);
published = true;
}
return Object.freeze({
archiveSha256,
memberCount: verified.memberCount,
manifest: verified.manifest,
});
} finally {
await captured.handle.close();
await rm(captured.root, { recursive: true, force: true });
}
} finally {
if (!published) await rm(extractionRoot, { recursive: true, force: true });
}
}
export async function verifyCapturedCiCandidateArchive(
archive: Buffer,
expectedSha256: string,
dependencies: Readonly<{
verifyExtracted?: (
extractionRoot: string,
manifest: ReleaseCandidateManifest,
) => Promise<void>;
}> = {},
): Promise<Readonly<{
archiveSha256: string;
memberCount: number;
manifest: ReleaseCandidateManifest;
}>> {
if (archive.byteLength <= 0 || archive.byteLength > MAX_ARCHIVE_BYTES) {
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
}
if (!/^[a-f0-9]{64}$/u.test(expectedSha256)) {
throw new TypeError("expected candidate archive SHA-256 is invalid");
}
const archiveSha256 = createHash("sha256").update(archive).digest("hex");
if (archiveSha256 !== expectedSha256) {
throw new Error("candidate archive SHA-256 mismatch");
}
const captured = await materializeCapturedArchive(archive);
const extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-captured-candidate-"));
try {
const manifest = preflightArchiveHandle(captured.handle);
extractArchiveHandle(captured.handle, extractionRoot);
const verified = await verifyExtractedTree(extractionRoot, manifest);
await dependencies.verifyExtracted?.(extractionRoot, verified.manifest);
return Object.freeze({
archiveSha256,
memberCount: verified.memberCount,
manifest: verified.manifest,
});
} finally {
await rm(extractionRoot, { recursive: true, force: true });
await captured.handle.close();
await rm(captured.root, { recursive: true, force: true });
}
}
function preflightArchiveHandle(archiveHandle: FileHandle): ReleaseCandidateManifest {
const listed = spawnSync(
TAR_EXECUTABLE,
["--list", "--verbose", "--numeric-owner", "--full-time", "--gzip", "--file", "/proc/self/fd/3"],
{
encoding: "utf8",
maxBuffer: 16_777_216,
timeout: 10_000,
env: TAR_ENVIRONMENT,
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
},
);
if (listed.status !== 0 || listed.signal || listed.error) {
throw new Error(
`candidate archive listing failed: ${listed.stderr || listed.error?.message || listed.signal}`,
);
}
const seen = new Set<string>();
const regularMembers = new Set<string>();
const directoryMembers = new Set<string>();
let totalBytes = 0;
const lines = listed.stdout.split(/\r?\n/u).filter(Boolean);
if (lines.length === 0 || lines.length > MAX_ARCHIVE_MEMBERS) {
throw new RangeError(`candidate archive member count is outside 1..${MAX_ARCHIVE_MEMBERS}`);
}
for (const line of lines) {
const match = /^(?<mode>.{10})\s+\d+\/\d+\s+(?<bytes>\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:\s+[+-]\d{4})?\s+(?<path>.+)$/u.exec(line);
if (!match?.groups) throw new Error(`candidate archive listing is unparseable: ${line}`);
const member = match.groups.path!.endsWith("/")
? match.groups.path!.slice(0, -1)
: match.groups.path!;
assertSafeMemberPath(member);
if (seen.has(member)) throw new Error(`candidate archive duplicate member: ${member}`);
seen.add(member);
const mode = match.groups.mode!;
if (!mode.startsWith("-") && !mode.startsWith("d")) {
throw new Error(`candidate archive contains non-regular member: ${member}`);
}
if (mode.startsWith("-")) {
const memberBytes = Number(match.groups.bytes);
if (
member === RELEASE_CANDIDATE_MANIFEST_PATH &&
memberBytes > 8_388_608
) {
throw new RangeError("candidate manifest exceeds 8388608 bytes");
}
totalBytes += memberBytes;
if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_ARCHIVE_BYTES) {
throw new RangeError("candidate archive expanded bytes exceed the bound");
}
regularMembers.add(member);
} else {
directoryMembers.add(member);
}
}
const manifest = readManifestFromArchive(archiveHandle);
validateManifestSemantics(manifest);
const expectedFiles = new Set([
...manifest.files.map(({ path: member }) => member),
RELEASE_CANDIDATE_MANIFEST_PATH,
]);
for (const member of expectedFiles) assertSafeMemberPath(member);
const expectedDirectories = new Set(
directoryAncestors([...expectedFiles]).filter(
(member) => member === "dist" || member.startsWith("dist/"),
),
);
if (
JSON.stringify([...regularMembers].sort(asciiCompare)) !==
JSON.stringify([...expectedFiles].sort(asciiCompare)) ||
JSON.stringify([...directoryMembers].sort(asciiCompare)) !==
JSON.stringify([...expectedDirectories].sort(asciiCompare))
) {
throw new Error("candidate archive exact member set drift before extraction");
}
return manifest;
}
function extractArchiveHandle(archiveHandle: FileHandle, extractionRoot: string): void {
const extracted = spawnSync(
TAR_EXECUTABLE,
[
"--extract",
"--gzip",
"--file",
"/proc/self/fd/3",
"--directory",
extractionRoot,
"--no-same-owner",
"--no-same-permissions",
],
{
encoding: "utf8",
maxBuffer: 1_048_576,
timeout: 30_000,
env: TAR_ENVIRONMENT,
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
},
);
if (extracted.status !== 0 || extracted.signal || extracted.error) {
throw new Error(
`candidate archive isolated extraction failed: ${extracted.stderr || extracted.error?.message || extracted.signal}`,
);
}
}
function validateManifestSemantics(manifest: ReleaseCandidateManifest): void {
if (manifest.files.length === 0 || manifest.files.length > MAX_CANDIDATE_FILES) {
throw new RangeError(`candidate manifest exceeds ${MAX_CANDIDATE_FILES} files`);
}
const canonicalFiles = [...manifest.files].sort((left, right) =>
asciiCompare(left.path, right.path),
);
if (JSON.stringify(manifest.files) !== JSON.stringify(canonicalFiles)) {
throw new Error("candidate manifest files are not in canonical ASCII order");
}
const expectedFiles = new Map<string, Readonly<{ bytes: number; sha256: string }>>();
let declaredBytes = 0;
for (const file of manifest.files) {
assertSafeMemberPath(file.path);
if (expectedFiles.has(file.path)) {
throw new Error(`candidate manifest duplicate file: ${file.path}`);
}
declaredBytes += file.bytes;
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ARCHIVE_BYTES) {
throw new RangeError("candidate manifest declared bytes exceed the archive bound");
}
expectedFiles.set(file.path, { bytes: file.bytes, sha256: file.sha256 });
}
const evidencePaths = [...expectedFiles.keys()]
.filter((member) => !member.startsWith("dist/"))
.sort(asciiCompare);
if (
JSON.stringify(evidencePaths) !==
JSON.stringify([...RELEASE_CANDIDATE_EVIDENCE_PATHS].sort(asciiCompare))
) {
throw new Error("candidate manifest evidence member set drift");
}
const distFiles = manifest.files.filter(({ path: member }) => member.startsWith("dist/"));
if (distFiles.length === 0) throw new Error("candidate manifest has no dist files");
const lockfile = expectedFiles.get("pnpm-lock.yaml");
if (!lockfile || lockfile.sha256 !== manifest.lockfileSha256) {
throw new Error("candidate manifest lockfile digest summary mismatch");
}
if (
distSha256(distFiles.map((file) => ({ ...file, gzipBytes: 0 }))) !==
manifest.distSha256
) {
throw new Error("candidate manifest dist digest summary mismatch");
}
if (supplyChainDigest(manifest.files) !== manifest.bundleSha256) {
throw new Error("candidate manifest bundle digest summary mismatch");
}
}
async function verifyExtractedTree(
extractionRoot: string,
preflightManifest: ReleaseCandidateManifest,
): Promise<Readonly<{ memberCount: number; manifest: ReleaseCandidateManifest }>> {
const entries = await walkExtractedTree(extractionRoot);
if (entries.length === 0 || entries.length > MAX_ARCHIVE_MEMBERS) {
throw new RangeError(`candidate archive member count is outside 1..${MAX_ARCHIVE_MEMBERS}`);
}
const manifest = releaseCandidateManifestSchema.parse(
JSON.parse(
await readFile(path.join(extractionRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"),
) as unknown,
);
if (JSON.stringify(manifest) !== JSON.stringify(preflightManifest)) {
throw new Error("candidate manifest changed between preflight and extraction");
}
if (manifest.files.length === 0 || manifest.files.length > MAX_CANDIDATE_FILES) {
throw new RangeError(`candidate manifest exceeds ${MAX_CANDIDATE_FILES} files`);
}
const canonicalFiles = [...manifest.files].sort((left, right) =>
asciiCompare(left.path, right.path),
);
if (JSON.stringify(manifest.files) !== JSON.stringify(canonicalFiles)) {
throw new Error("candidate manifest files are not in canonical ASCII order");
}
const expectedFiles = new Map<string, Readonly<{ bytes: number; sha256: string }>>();
let declaredBytes = 0;
for (const file of manifest.files) {
assertSafeMemberPath(file.path);
if (expectedFiles.has(file.path)) throw new Error(`candidate manifest duplicate file: ${file.path}`);
declaredBytes += file.bytes;
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ARCHIVE_BYTES) {
throw new RangeError("candidate manifest declared bytes exceed the archive bound");
}
expectedFiles.set(file.path, { bytes: file.bytes, sha256: file.sha256 });
}
const evidencePaths = [...expectedFiles.keys()]
.filter((member) => !member.startsWith("dist/"))
.sort(asciiCompare);
if (
JSON.stringify(evidencePaths) !==
JSON.stringify([...RELEASE_CANDIDATE_EVIDENCE_PATHS].sort(asciiCompare))
) {
throw new Error("candidate manifest evidence member set drift");
}
const distFiles = manifest.files.filter(({ path: member }) => member.startsWith("dist/"));
if (distFiles.length === 0) throw new Error("candidate manifest has no dist files");
const lockfile = expectedFiles.get("pnpm-lock.yaml");
if (!lockfile || lockfile.sha256 !== manifest.lockfileSha256) {
throw new Error("candidate manifest lockfile digest summary mismatch");
}
if (
distSha256(distFiles.map((file) => ({ ...file, gzipBytes: 0 }))) !==
manifest.distSha256
) {
throw new Error("candidate manifest dist digest summary mismatch");
}
if (supplyChainDigest(manifest.files) !== manifest.bundleSha256) {
throw new Error("candidate manifest bundle digest summary mismatch");
}
const expectedFilePaths = new Set([
...expectedFiles.keys(),
RELEASE_CANDIDATE_MANIFEST_PATH,
]);
const expectedDirectories = new Set(directoryAncestors([...expectedFilePaths]));
for (const entry of entries) {
assertSafeMemberPath(entry.path);
if (entry.type === "directory") {
if (!expectedDirectories.has(entry.path)) {
throw new Error(`candidate archive contains unexpected directory: ${entry.path}`);
}
} else if (!expectedFilePaths.has(entry.path)) {
throw new Error(`candidate archive contains unexpected file: ${entry.path}`);
}
}
const actualFiles = new Set(
entries.filter(({ type }) => type === "file").map(({ path: member }) => member),
);
for (const expected of expectedFilePaths) {
if (!actualFiles.has(expected)) throw new Error(`candidate archive is missing file: ${expected}`);
}
for (const [member, expected] of expectedFiles) {
const bytes = await readFile(path.join(extractionRoot, member));
if (bytes.byteLength !== expected.bytes) {
throw new Error(`candidate archive member size mismatch: ${member}`);
}
if (createHash("sha256").update(bytes).digest("hex") !== expected.sha256) {
throw new Error(`candidate archive member digest mismatch: ${member}`);
}
}
return Object.freeze({ memberCount: entries.length, manifest });
}
async function walkExtractedTree(
root: string,
relativeDirectory = "",
): Promise<ReadonlyArray<Readonly<{ path: string; type: "file" | "directory" }>>> {
const children = await readdir(path.join(root, relativeDirectory), {
withFileTypes: true,
});
const entries: Array<Readonly<{ path: string; type: "file" | "directory" }>> = [];
for (const child of children.sort((left, right) => asciiCompare(left.name, right.name))) {
const relative = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name;
assertSafeMemberPath(relative);
const metadata = await lstat(path.join(root, relative));
if (metadata.isSymbolicLink()) {
throw new Error(`candidate archive contains non-regular member: ${relative}`);
}
if (metadata.isDirectory() && child.isDirectory()) {
entries.push(Object.freeze({ path: relative, type: "directory" }));
entries.push(...(await walkExtractedTree(root, relative)));
} else if (metadata.isFile() && child.isFile()) {
if (metadata.nlink !== 1) {
throw new Error(`candidate archive contains hard-linked member: ${relative}`);
}
entries.push(Object.freeze({ path: relative, type: "file" }));
} else {
throw new Error(`candidate archive contains non-regular member: ${relative}`);
}
if (entries.length > MAX_ARCHIVE_MEMBERS) {
throw new RangeError(`candidate archive exceeds ${MAX_ARCHIVE_MEMBERS} members`);
}
}
return entries;
}
function assertSameIdentity(
before: Awaited<ReturnType<typeof lstat>>,
after: Awaited<ReturnType<typeof lstat>>,
): void {
if (
!after.isFile() ||
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size
) {
throw new Error("candidate archive file identity changed");
}
}
async function readCapturedArchive(
handle: FileHandle,
expectedSize: number,
): Promise<Buffer> {
const captured = Buffer.allocUnsafe(expectedSize + 1);
let offset = 0;
while (offset < captured.byteLength) {
const { bytesRead } = await handle.read(
captured,
offset,
captured.byteLength - offset,
offset,
);
if (bytesRead === 0) break;
offset += bytesRead;
}
if (offset !== expectedSize) {
throw new Error("candidate archive changed size during bounded capture");
}
return captured.subarray(0, offset);
}
function readManifestFromArchive(archiveHandle: FileHandle): ReleaseCandidateManifest {
const extracted = spawnSync(
TAR_EXECUTABLE,
[
"--extract",
"--gzip",
"--to-stdout",
"--file",
"/proc/self/fd/3",
"--",
RELEASE_CANDIDATE_MANIFEST_PATH,
],
{
maxBuffer: 8_388_609,
timeout: 10_000,
env: TAR_ENVIRONMENT,
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
},
);
if (extracted.status !== 0 || extracted.signal || extracted.error) {
throw new Error(
`candidate manifest preflight failed: ${String(extracted.stderr) || extracted.error?.message || extracted.signal}`,
);
}
const bytes = Buffer.from(extracted.stdout);
if (bytes.byteLength === 0 || bytes.byteLength > 8_388_608) {
throw new RangeError("candidate manifest preflight size is outside 1..8388608");
}
const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
return releaseCandidateManifestSchema.parse(JSON.parse(source) as unknown);
}
async function materializeCapturedArchive(
archive: Buffer,
): Promise<Readonly<{ root: string; handle: FileHandle }>> {
const root = await mkdtemp(path.join(tmpdir(), "ci-captured-archive-"));
const file = path.join(root, "candidate.tar.gz");
let handle: FileHandle | undefined;
try {
handle = await open(
file,
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
);
await handle.writeFile(archive);
await handle.sync();
await unlink(file);
return Object.freeze({ root, handle });
} catch (error) {
if (handle) await handle.close().catch(() => undefined);
await rm(root, { recursive: true, force: true });
throw error;
}
}
function assertSafeMemberPath(member: string): void {
if (
!member ||
member.startsWith("-") ||
Buffer.byteLength(member, "utf8") > MAX_MEMBER_PATH_BYTES ||
member.includes("\\") ||
[...member].some((character) => {
const codePoint = character.codePointAt(0)!;
return codePoint <= 0x1f || codePoint === 0x7f;
}) ||
path.posix.isAbsolute(member) ||
path.posix.normalize(member) !== member ||
member === ".." ||
member.startsWith("../") ||
member.includes("/../")
) {
throw new TypeError(`candidate archive contains unsafe member path: ${member}`);
}
}
function directoryAncestors(files: readonly string[]): string[] {
const directories = new Set<string>();
for (const file of files) {
let directory = path.posix.dirname(file);
while (directory !== ".") {
directories.add(directory);
directory = path.posix.dirname(directory);
}
}
return [...directories];
}
function asciiCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
async function pathExists(target: string): Promise<boolean> {
try {
await lstat(target);
return true;
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return false;
throw error;
}
}
+45
View File
@@ -0,0 +1,45 @@
import { z } from "zod";
export const ciContractReportSchema = z
.object({
schemaVersion: z.literal(2),
nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/u),
gateCount: z.literal(26),
commandDefinitionCount: z.number().int().positive(),
commandReferenceCount: z.number().int().positive(),
artifactCount: z.number().int().positive(),
jobCount: z.literal(9),
workflowSha256: z.string().regex(/^[a-f0-9]{64}$/u),
durationStatus: z.string().min(1),
negativeFixtures: z.array(
z
.object({
readiness: z.enum([
"MERGE_READY",
"RELEASE_READY",
"PROD_PROMOTION_READY",
"FIELD_SLO_READY",
"DOCUMENTATION_READY",
]),
failedGate: z.string().regex(/^FE-GATE-\d{3}$/u),
passed: z.boolean(),
})
.strict(),
),
failures: z.array(z.string()),
passed: z.boolean(),
})
.strict()
.superRefine((report, context) => {
const fail = (path: PropertyKey[], message: string) =>
context.addIssue({ code: "custom", path, message });
if ((report.passed === true) !== (report.failures.length === 0)) {
fail(["passed"], "passed must agree with failures");
}
if (
report.negativeFixtures.length !== 5 ||
report.negativeFixtures.some((fixture) => !fixture.passed)
) {
fail(["negativeFixtures"], "every readiness negative fixture must pass");
}
});
+187
View File
@@ -0,0 +1,187 @@
import { randomUUID } from "node:crypto";
import { constants, type Stats } from "node:fs";
import { lstat, mkdir, open, rename, rm } from "node:fs/promises";
import path from "node:path";
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
export async function writeCiGateLogAtomic(input: Readonly<{
root: string;
relativePath: string;
content: string;
maxBytes?: number;
}>): Promise<void> {
const root = path.resolve(input.root);
const relative = normalizeRepositoryRelativePath(input.relativePath, "CI gate log path");
const target = path.join(root, relative);
const maxBytes = input.maxBytes ?? 67_108_864;
const contentBytes = Buffer.byteLength(input.content, "utf8");
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || contentBytes < 1 || contentBytes > maxBytes) {
throw new RangeError(`CI gate log size is outside 1..${maxBytes}: ${relative}`);
}
const parentIdentity = await ensureSafePublishDirectory(root, path.dirname(target));
await assertSafePublishLeaf(target, relative);
const temporary = path.join(
path.dirname(target),
`.${path.basename(target)}.${randomUUID()}.tmp`,
);
let ownsTemporary = false;
try {
const handle = await open(
temporary,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o644,
);
ownsTemporary = true;
let failure: unknown;
try {
await handle.writeFile(input.content, "utf8");
await handle.sync();
} catch (error) {
failure = error;
}
try {
await handle.close();
} catch (error) {
failure ??= error;
}
if (failure) throw failure;
await assertDirectoryIdentity(path.dirname(target), parentIdentity, relative);
await assertSafePublishLeaf(target, relative);
await rename(temporary, target);
ownsTemporary = false;
const directory = await open(path.dirname(target), constants.O_RDONLY);
try {
try {
await directory.sync();
} catch (error) {
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
}
} finally {
await directory.close();
}
} catch (error) {
if (ownsTemporary) {
try {
await rm(temporary, { force: true });
} catch {
// Preserve the publication failure and clean only the owned sibling temp.
}
}
throw error;
}
}
export async function ensureSafePublishDirectory(
rootInput: string,
directoryInput: string,
): Promise<Stats> {
const root = path.resolve(rootInput);
const directory = path.resolve(directoryInput);
const relativeDirectory = path.relative(root, directory);
if (
relativeDirectory === ".." ||
relativeDirectory.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeDirectory)
) {
throw new TypeError("CI publish directory escapes root");
}
const rootMetadata = await lstat(root);
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
throw new TypeError("CI gate log root is unsafe");
}
let ancestor = root;
for (const segment of relativeDirectory.split(path.sep).filter(Boolean)) {
ancestor = path.join(ancestor, segment);
let metadata;
try {
metadata = await lstat(ancestor);
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
try {
await mkdir(ancestor);
} catch (mkdirError) {
if (!hasErrorCode(mkdirError, "EEXIST")) throw mkdirError;
}
metadata = await lstat(ancestor);
}
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new TypeError(`CI publish ancestor is unsafe: ${relativeDirectory}`);
}
}
return lstat(directory);
}
export async function assertSafePublishLeaf(
target: string,
label = target,
): Promise<void> {
try {
const metadata = await lstat(target);
if (metadata.isSymbolicLink() || !metadata.isFile()) {
throw new TypeError(`CI publish leaf is unsafe: ${label}`);
}
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
}
}
export async function assertSafeExistingPublishPath(
rootInput: string,
targetInput: string,
): Promise<boolean> {
const root = path.resolve(rootInput);
const target = path.resolve(targetInput);
const relative = path.relative(root, target);
if (
relative === "" ||
relative === ".." ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
throw new TypeError("CI publish target escapes root");
}
const rootMetadata = await lstat(root);
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
throw new TypeError("CI publish root is unsafe");
}
const segments = relative.split(path.sep).filter(Boolean);
let current = root;
for (const [index, segment] of segments.entries()) {
current = path.join(current, segment);
let metadata: Stats;
try {
metadata = await lstat(current);
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return false;
throw error;
}
const leaf = index === segments.length - 1;
if (metadata.isSymbolicLink() || (leaf ? !metadata.isFile() : !metadata.isDirectory())) {
throw new TypeError(`CI publish path is unsafe: ${relative}`);
}
}
return true;
}
async function assertDirectoryIdentity(
directory: string,
expected: Stats,
label: string,
): Promise<void> {
const actual = await lstat(directory);
if (
actual.isSymbolicLink() ||
!actual.isDirectory() ||
expected.dev <= 0 ||
expected.ino <= 0 ||
actual.dev !== expected.dev ||
actual.ino !== expected.ino
) {
throw new TypeError(`CI publish directory identity changed: ${label}`);
}
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
+353
View File
@@ -0,0 +1,353 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import {
PROMOTED_FILE_NAMES,
type PromotedFileName,
} from "../contracts/promotion-artifacts.ts";
import {
PROMOTION_VERIFIER_ID,
PROMOTION_VERIFIER_VERSION,
assertDistinctProviderTrust,
evaluatePromotionEvidence,
providerVerificationArtifactSchema,
provenanceProviderAttestationSchema,
vulnerabilityProviderReportSchema,
trustPolicySha256,
type ProviderTrust,
} from "./provider-evidence.ts";
import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts";
import { LOCAL_EVIDENCE_ASSESSMENT_PATH } from "./release-candidate.ts";
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
export type ExactPromotionBundle = Readonly<
Partial<Record<PromotedFileName, Buffer>>
>;
export type ExactPromotionExpectedContext = Readonly<{
run: Readonly<{ id: string; attempt: number }>;
sourceRevision: string;
archiveSha256: string;
sourceSetSha256?: string;
bundleSha256?: string;
distSha256?: string;
lockfileSha256?: string;
}>;
export async function verifyExactPromotionBundle(
files: ExactPromotionBundle,
options: Readonly<{
vulnerabilityTrust: ProviderTrust;
provenanceTrust: ProviderTrust;
expected: ExactPromotionExpectedContext;
nowEpochMs?: () => number;
}>,
): Promise<Readonly<{ status: "PASS" }>> {
assertDistinctProviderTrust(options);
assertExternalExpectedContext(options.expected);
const names = Object.keys(files).sort(asciiCompare);
const expectedNames = [...PROMOTED_FILE_NAMES].sort(asciiCompare);
if (JSON.stringify(names) !== JSON.stringify(expectedNames)) {
throw new Error("promotion bundle must contain the exact five canonical files");
}
for (const name of PROMOTED_FILE_NAMES) {
if (!Buffer.isBuffer(files[name])) {
throw new TypeError(`promotion bundle file is missing or not captured bytes: ${name}`);
}
}
const archiveBytes = files["release-candidate.tar.gz"]!;
const vulnerabilityBytes = files["vulnerability-report.json"]!;
const provenanceBytes = files["provenance-attestation.json"]!;
const providerBytes = files["provider-verification.json"]!;
const promotionBytes = files["promotion-verification.json"]!;
const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson(
vulnerabilityBytes,
"vulnerability report",
));
const provenance = provenanceProviderAttestationSchema.parse(parseJson(
provenanceBytes,
"provenance attestation",
));
const provider = providerVerificationArtifactSchema.parse(parseJson(
providerBytes,
"provider verification",
));
const promotion = providerVerificationArtifactSchema.parse(parseJson(
promotionBytes,
"promotion verification",
));
if (
provider.artifactType !== "provider-verification" ||
promotion.artifactType !== "promotion-verification"
) {
throw new Error("promotion verification artifact role mismatch");
}
for (const [label, record] of [
["provider", provider],
["promotion", promotion],
] as const) {
if (
record.verifier.id !== PROMOTION_VERIFIER_ID ||
record.verifier.version !== PROMOTION_VERIFIER_VERSION
) {
throw new Error(`${label} verification literal verifier identity mismatch`);
}
if (record.status !== "PASS" || record.failures.length !== 0) {
throw new Error(`${label} verification must be PASS without failures`);
}
}
if (
provider.vulnerabilityStatus !== "PASS" ||
provider.provenanceAttestationStatus !== "PASS"
) {
throw new Error("provider verification subordinate statuses must both be PASS");
}
if (promotion.localEvidenceStatus !== "PASS") {
throw new Error("promotion local evidence subordinate status must be PASS");
}
assertEqual("shared verifiedAt", provider.verifiedAt, promotion.verifiedAt);
assertEqual("shared run", provider.run, promotion.run);
assertEqual("shared source", provider.source, promotion.source);
assertEqual("shared candidate", provider.candidate, promotion.candidate);
assertEqual(
"shared provider evidence",
provider.providerEvidence,
promotion.providerEvidence,
);
assertEqual(
"shared trust policy",
provider.trustPolicySha256,
promotion.trustPolicySha256,
);
assertEqual("external expected run", provider.run, options.expected.run);
assertEqual(
"external expected source revision",
provider.source.revision,
options.expected.sourceRevision,
);
assertEqual(
"external expected archive digest",
provider.candidate.archiveSha256,
options.expected.archiveSha256,
);
for (const [label, actual, expected] of [
["source set", provider.source.sourceSetSha256, options.expected.sourceSetSha256],
["bundle", provider.candidate.bundleSha256, options.expected.bundleSha256],
["dist", provider.candidate.distSha256, options.expected.distSha256],
["lockfile", provider.candidate.lockfileSha256, options.expected.lockfileSha256],
] as const) {
if (expected !== undefined) {
assertEqual(`external expected ${label} digest`, actual, expected);
}
}
const anchoredTrustPolicySha256 = trustPolicySha256(options);
if (provider.trustPolicySha256 !== anchoredTrustPolicySha256) {
throw new Error("verification trust policy does not match anchored provider keys");
}
if (promotion.providerVerificationSha256 !== sha256(providerBytes)) {
throw new Error("promotion provider verification byte hash mismatch");
}
if (
provider.candidate.archiveSha256 !== sha256(archiveBytes) ||
provider.providerEvidence.vulnerabilityReportSha256 !== sha256(vulnerabilityBytes) ||
provider.providerEvidence.provenanceAttestationSha256 !== sha256(provenanceBytes)
) {
if (provider.candidate.archiveSha256 !== sha256(archiveBytes)) {
throw new Error("candidate archive actual digest mismatch");
}
if (
provider.providerEvidence.vulnerabilityReportSha256 !==
sha256(vulnerabilityBytes)
) {
throw new Error("vulnerability report actual digest mismatch");
}
throw new Error("provenance attestation actual digest mismatch");
}
for (const [label, evidence, nonce, keyId, fingerprint] of [
[
"vulnerability",
vulnerability,
provider.providerEvidence.vulnerabilityInvocationNonce,
provider.providerEvidence.vulnerabilityKeyId,
provider.providerEvidence.vulnerabilityKeyFingerprint,
],
[
"provenance",
provenance,
provider.providerEvidence.provenanceInvocationNonce,
provider.providerEvidence.provenanceKeyId,
provider.providerEvidence.provenanceKeyFingerprint,
],
] as const) {
assertEqual(`${label} run`, { id: evidence.run.id, attempt: evidence.run.attempt }, provider.run);
assertEqual(`${label} source`, evidence.source, provider.source);
assertEqual(`${label} candidate`, evidence.candidate, provider.candidate);
if (
evidence.run.invocationNonce !== nonce ||
evidence.signature.keyId !== keyId ||
evidence.signature.publicKeyFingerprint !== fingerprint
) {
throw new Error(`${label} provider evidence nonce or trust role mismatch`);
}
}
if (provenance.subject.digest.sha256 !== provider.candidate.distSha256) {
throw new Error("provenance subject dist digest mismatch");
}
if (vulnerability.findings.length !== 0) {
throw new Error("vulnerability report is not PASS");
}
assertEqual(
"signed secret scan attestation",
vulnerability.secretScanAttestation,
provider.providerEvidence.secretScanAttestation,
);
let assessmentSha256: string | null = null;
const localIdentityHolder: {
current: null | Readonly<{
sourceRevision: string;
sourceSetSha256: string;
assessmentSha256: string;
secretScan: Readonly<{
policySha256: string;
sarifSha256: string;
scanInputSha256: string;
}>;
}>;
} = { current: null };
await verifyCapturedCiCandidateArchive(
archiveBytes,
provider.candidate.archiveSha256,
{
verifyExtracted: async (extractionRoot, manifest) => {
assertEqual("archive candidate", {
bundleSha256: manifest.bundleSha256,
distSha256: manifest.distSha256,
lockfileSha256: manifest.lockfileSha256,
}, {
bundleSha256: provider.candidate.bundleSha256,
distSha256: provider.candidate.distSha256,
lockfileSha256: provider.candidate.lockfileSha256,
});
assessmentSha256 = sha256(
await readFile(path.join(extractionRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH)),
);
const local = await verifyArchivedLocalEvidence({
extractionRoot,
expectedManifest: manifest,
});
if (local.status !== "PASS" || !local.identity) {
throw new Error(
`exact-five archived local verification is not PASS: ${local.failures.join(", ")}`,
);
}
localIdentityHolder.current = local.identity;
},
},
);
if (assessmentSha256 !== promotion.localEvidenceAssessmentSha256) {
throw new Error("promotion local evidence assessment actual digest mismatch");
}
if (
!localIdentityHolder.current ||
localIdentityHolder.current.sourceRevision !== provider.source.revision ||
localIdentityHolder.current.sourceSetSha256 !== provider.source.sourceSetSha256 ||
localIdentityHolder.current.assessmentSha256 !== promotion.localEvidenceAssessmentSha256
) {
throw new Error("exact-five archived local identity mismatch");
}
assertEqual("archived secret scan attestation", {
status: "PASS",
localEvidenceAssessmentSha256: localIdentityHolder.current.assessmentSha256,
sourceSetSha256: localIdentityHolder.current.sourceSetSha256,
policySha256: localIdentityHolder.current.secretScan.policySha256,
sarifSha256: localIdentityHolder.current.secretScan.sarifSha256,
scanInputSha256: localIdentityHolder.current.secretScan.scanInputSha256,
}, vulnerability.secretScanAttestation);
const reevaluated = evaluatePromotionEvidence({
expected: {
run: provider.run,
source: provider.source,
candidate: provider.candidate,
vulnerabilityInvocationNonce:
provider.providerEvidence.vulnerabilityInvocationNonce,
provenanceInvocationNonce:
provider.providerEvidence.provenanceInvocationNonce,
secretScanAttestation: provider.providerEvidence.secretScanAttestation,
},
localStatus: "PASS",
vulnerabilityReport: vulnerability,
provenanceAttestation: provenance,
vulnerabilityTrust: options.vulnerabilityTrust,
provenanceTrust: options.provenanceTrust,
nowEpochMs: options.nowEpochMs,
});
if (
reevaluated.status !== "PASS" ||
reevaluated.vulnerabilityStatus !== "PASS" ||
reevaluated.provenanceAttestationStatus !== "PASS"
) {
throw new Error(
`exact-five provider signature/freshness revalidation is not PASS: ${reevaluated.failures.join(", ")}`,
);
}
return Object.freeze({ status: "PASS" as const });
}
function assertExternalExpectedContext(
expected: ExactPromotionExpectedContext,
): void {
if (
!expected ||
typeof expected.run?.id !== "string" ||
expected.run.id.length === 0 ||
!Number.isSafeInteger(expected.run.attempt) ||
expected.run.attempt < 1 ||
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(expected.sourceRevision) ||
!isSha256(expected.archiveSha256)
) {
throw new TypeError("external expected promotion context is invalid or incomplete");
}
for (const digest of [
expected.sourceSetSha256,
expected.bundleSha256,
expected.distSha256,
expected.lockfileSha256,
]) {
if (digest !== undefined && !isSha256(digest)) {
throw new TypeError("external optional expected promotion digest is invalid");
}
}
}
function isSha256(value: unknown): value is string {
return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
}
function parseJson(bytes: Buffer, label: string): unknown {
try {
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
} catch {
throw new TypeError(`${label} is not strict UTF-8 JSON`);
}
}
function assertEqual(label: string, left: unknown, right: unknown): void {
if (JSON.stringify(left) !== JSON.stringify(right)) {
throw new Error(`${label} mismatch`);
}
}
function sha256(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
function asciiCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
+281
View File
@@ -0,0 +1,281 @@
import { createHash } from "node:crypto";
import { z } from "zod";
export type AttemptStatus = number | "NETWORK_REJECTION" | "PENDING_ABORT";
export type RetryReason = "NETWORK_FAILURE" | "HTTP_429" | "HTTP_503";
export type BodyDisposition =
| "FULLY_READ_WITHIN_BOUND"
| "CANCELLED_WITHOUT_READ"
| "REJECTED_LIMIT"
| "NO_RESPONSE";
const attemptStatusSchema = z.union([
z.number().int().min(100).max(599),
z.literal("NETWORK_REJECTION"),
z.literal("PENDING_ABORT"),
]);
export const httpScenarioAssertionGroupsSchema = z
.object({
status: z
.object({
attempts: z.array(attemptStatusSchema).min(1),
final: attemptStatusSchema,
})
.strict(),
outcome: z.object({ kind: z.string().min(1), detail: z.string().nullable() }).strict(),
effect: z.object({ outcome: z.string().min(1), observer: z.string().min(1) }).strict(),
retry: z
.object({
count: z.number().int().nonnegative(),
reasons: z.array(z.enum(["NETWORK_FAILURE", "HTTP_429", "HTTP_503"])),
})
.strict(),
fetch: z
.object({
count: z.number().int().positive(),
observerAttempts: z.number().int().positive(),
agrees: z.boolean(),
})
.strict(),
media: z.object({ attempts: z.array(z.string().nullable()).min(1), final: z.string().nullable() }).strict(),
body: z
.object({
attempts: z
.array(
z
.object({
disposition: z.enum([
"FULLY_READ_WITHIN_BOUND",
"CANCELLED_WITHOUT_READ",
"REJECTED_LIMIT",
"NO_RESPONSE",
]),
pulledBytes: z.number().int().nonnegative(),
ceiling: z.number().int().nonnegative(),
})
.strict(),
)
.min(1),
})
.strict(),
scope: z
.object({
start: z.literal("CURRENT"),
end: z.enum(["CURRENT", "STALE"]),
signal: z.enum(["ACTIVE", "ABORTED"]),
cancellationOwner: z.enum(["NONE", "CALLER", "SCOPE_FENCE", "DEADLINE"]),
})
.strict(),
})
.strict()
.superRefine((groups, context) => {
const fail = (path: (string | number)[], message: string) => {
context.addIssue({ code: "custom", path, message });
};
if (groups.status.final !== groups.status.attempts.at(-1)) {
fail(["status", "final"], "must equal the final attempt status");
}
if (groups.retry.count !== groups.retry.reasons.length) {
fail(["retry", "count"], "must equal retry reasons length");
}
if (groups.retry.count !== groups.fetch.count - 1) {
fail(["retry", "count"], "must equal the non-final fetch attempt count");
}
for (const [index, reason] of groups.retry.reasons.entries()) {
const status = groups.status.attempts[index];
const expectedReason =
status === "NETWORK_REJECTION"
? "NETWORK_FAILURE"
: status === 429
? "HTTP_429"
: status === 503
? "HTTP_503"
: null;
if (reason !== expectedReason) {
fail(
["retry", "reasons", index],
"must match the corresponding non-final attempt status",
);
}
}
if (groups.fetch.count !== groups.status.attempts.length) {
fail(["fetch", "count"], "must equal status attempts length");
}
if (groups.fetch.count !== groups.media.attempts.length) {
fail(["media", "attempts"], "must equal fetch count");
}
if (groups.fetch.count !== groups.body.attempts.length) {
fail(["body", "attempts"], "must equal fetch count");
}
if (groups.fetch.observerAttempts !== groups.fetch.count) {
fail(["fetch", "observerAttempts"], "must equal physical fetch count");
}
if (!groups.fetch.agrees) {
fail(["fetch", "agrees"], "must prove physical/observer agreement");
}
if (groups.media.final !== groups.media.attempts.at(-1)) {
fail(["media", "final"], "must equal the final attempt media essence");
}
});
export type HttpScenarioAssertionGroups = Readonly<{
status: Readonly<{ attempts: readonly AttemptStatus[]; final: AttemptStatus }>;
outcome: Readonly<{ kind: string; detail: string | null }>;
effect: Readonly<{ outcome: string; observer: string }>;
retry: Readonly<{ count: number; reasons: readonly RetryReason[] }>;
fetch: Readonly<{
count: number;
observerAttempts: number;
agrees: boolean;
}>;
media: Readonly<{
attempts: readonly (string | null)[];
final: string | null;
}>;
body: Readonly<{
attempts: readonly Readonly<{
disposition: BodyDisposition;
pulledBytes: number;
ceiling: number;
}>[];
}>;
scope: Readonly<{
start: "CURRENT";
end: "CURRENT" | "STALE";
signal: "ACTIVE" | "ABORTED";
cancellationOwner: "NONE" | "CALLER" | "SCOPE_FENCE" | "DEADLINE";
}>;
}>;
export const httpScenarioExpectationSchema = z
.object({
executionId: z.string().min(1),
operationId: z.string().min(1),
scenarioId: z.string().min(1),
expected: httpScenarioAssertionGroupsSchema,
testDeadlineOverrideMs: z.number().int().positive().nullable(),
})
.strict()
.superRefine((entry, context) => {
if (entry.executionId !== `${entry.operationId}::${entry.scenarioId}`) {
context.addIssue({
code: "custom",
path: ["executionId"],
message: "must equal operationId::scenarioId",
});
}
});
export type HttpScenarioExpectation = Readonly<{
executionId: string;
operationId: string;
scenarioId: string;
expected: HttpScenarioAssertionGroups;
testDeadlineOverrideMs: number | null;
}>;
export const httpScenarioReceiptRowSchema = z
.object({
executionId: z.string().min(1),
expected: httpScenarioAssertionGroupsSchema,
observed: httpScenarioAssertionGroupsSchema,
testDeadlineOverrideMs: z.number().int().positive().nullable(),
})
.strict();
export const httpScenarioReceiptSchema = z
.object({
schemaVersion: z.number().int().positive(),
catalogDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
catalogTotal: z.number().int().nonnegative(),
executedIds: z.array(z.string().min(1)),
rows: z.array(httpScenarioReceiptRowSchema),
})
.strict()
.superRefine((receipt, context) => {
const rowIds = receipt.rows.map((row) => row.executionId);
const sortedIds = [...receipt.executedIds].sort((left, right) =>
left.localeCompare(right),
);
const sortedRowIds = [...rowIds].sort((left, right) =>
left.localeCompare(right),
);
if (!sameScenarioJson(receipt.executedIds, sortedIds)) {
context.addIssue({
code: "custom",
path: ["executedIds"],
message: "must be sorted by execution ID",
});
}
if (!sameScenarioJson(rowIds, sortedRowIds)) {
context.addIssue({
code: "custom",
path: ["rows"],
message: "must be sorted by execution ID",
});
}
if (!sameScenarioJson(receipt.executedIds, rowIds)) {
context.addIssue({
code: "custom",
path: ["rows"],
message: "row IDs must exactly equal executed IDs",
});
}
});
export type HttpScenarioReceipt = Readonly<{
schemaVersion: number;
catalogDigest: string;
catalogTotal: number;
executedIds: readonly string[];
rows: readonly Readonly<{
executionId: string;
expected: HttpScenarioAssertionGroups;
observed: HttpScenarioAssertionGroups;
testDeadlineOverrideMs: number | null;
}>[];
}>;
export function stableScenarioJson(value: unknown): string {
const normalize = (candidate: unknown): unknown => {
if (Array.isArray(candidate)) return candidate.map(normalize);
if (candidate && typeof candidate === "object") {
return Object.fromEntries(
Object.entries(candidate as Readonly<Record<string, unknown>>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => [key, normalize(nested)]),
);
}
return candidate;
};
return JSON.stringify(normalize(value));
}
export function sameScenarioJson(left: unknown, right: unknown): boolean {
return stableScenarioJson(left) === stableScenarioJson(right);
}
export function computeHttpScenarioCatalogDigest(
schemaVersion: number,
expectations: readonly HttpScenarioExpectation[],
): `sha256:${string}` {
const tuples = [...expectations]
.sort((left, right) => left.executionId.localeCompare(right.executionId))
.map((entry) => [
entry.executionId,
entry.expected.status,
entry.expected.outcome,
entry.expected.effect,
entry.expected.retry,
entry.expected.fetch,
entry.expected.media,
entry.expected.body,
entry.expected.scope,
entry.testDeadlineOverrideMs,
]);
return `sha256:${createHash("sha256")
.update(stableScenarioJson([schemaVersion, ...tuples]))
.digest("hex")}`;
}
+206
View File
@@ -0,0 +1,206 @@
import {
dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema,
licenseReportArtifactSchema,
supplyChainVerificationArtifactSchema,
vulnerabilityReportArtifactSchema,
} from "../contracts/release-artifacts.ts";
import type { DistOutput } from "./release-candidate.ts";
import {
diffDependencyInventories,
supplyChainDigest,
validateDependencyReview,
validateLicensePolicy,
} from "./supply-chain.ts";
type Document = Record<string, unknown>;
export const LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS = Object.freeze({
promotionStatus: "FAIL_UNVERIFIED" as const,
vulnerabilityStatus: "FAIL_UNVERIFIED" as const,
provenanceAttestationStatus: "FAIL_UNVERIFIED" as const,
});
export function verifyLocalSupplyChainDefaults(stored: unknown): string[] {
const parsed = supplyChainVerificationArtifactSchema.safeParse(stored);
if (
!parsed.success ||
parsed.data.promotionStatus !==
LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS.promotionStatus ||
parsed.data.vulnerabilityStatus !==
LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS.vulnerabilityStatus ||
parsed.data.provenanceAttestationStatus !==
LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS.provenanceAttestationStatus
) {
return [
"supply-chain verification provider defaults are not local FAIL_UNVERIFIED",
];
}
return [];
}
export function createLocalVulnerabilityReport(lockfileSha256: string) {
return vulnerabilityReportArtifactSchema.parse({
schemaVersion: 1,
provider: "UNCONFIGURED",
scannedLockfileSha256: lockfileSha256,
status: "FAIL_UNVERIFIED",
findings: [],
exceptionsApplied: [],
failures: ["external vulnerability provider report is missing"],
blocking: [],
});
}
export function compareStoredLocalVulnerabilityReport(
lockfileSha256: string,
stored: unknown,
): string[] {
const parsed = vulnerabilityReportArtifactSchema.safeParse(stored);
if (
!parsed.success ||
supplyChainDigest(parsed.data) !==
supplyChainDigest(createLocalVulnerabilityReport(lockfileSha256))
) {
return [
"local vulnerability report does not match exact unconfigured defaults",
];
}
return [];
}
export function recomputeDependencyEvidence(input: Readonly<{
inventory: unknown;
baseline: unknown;
baselineApproval: unknown;
dependencyChangeEvidence: unknown;
skipBaseline?: boolean;
}>) {
const inventory = dependencyInventoryArtifactSchema.parse(input.inventory);
const baseline = asDocument(input.baseline);
const approval = asDocument(input.baselineApproval);
const dependencyChangeEvidence = asDocument(input.dependencyChangeEvidence);
const failures: string[] = [];
const skipBaseline = input.skipBaseline === true;
let diff: ReturnType<typeof diffDependencyInventories> = Object.freeze({
added: Object.freeze([]),
removed: Object.freeze([]),
changed: Object.freeze([]),
upgrades: Object.freeze([]),
});
let review: ReturnType<typeof validateDependencyReview> = Object.freeze({
passed: skipBaseline,
highRisk: Object.freeze([]),
failures: Object.freeze(
skipBaseline ? [] : ["dependency baseline unavailable"],
),
});
const baselineDigest = baseline ? supplyChainDigest(baseline) : null;
if (baseline && approval) {
diff = diffDependencyInventories(baseline, inventory);
review = validateDependencyReview(
diff,
inventory,
dependencyChangeEvidence ?? {},
);
if (
approval.schemaVersion !== 1 ||
approval.snapshotDigest !== baselineDigest ||
typeof approval.owner !== "string" ||
approval.owner.trim().length === 0
) {
failures.push("dependency baseline approval digest mismatch");
}
} else if (!skipBaseline) {
failures.push("dependency baseline and approval are required");
}
failures.push(...review.failures);
const report = dependencyDiffArtifactSchema.parse({
schemaVersion: 2,
baselineDigest,
currentDigest: supplyChainDigest(inventory),
...diff,
highRisk: review.highRisk,
reviewFailures: review.failures,
});
return Object.freeze({
report,
dependencyDiff: diff,
highRisk: review.highRisk,
failures: Object.freeze(failures),
});
}
export function compareStoredDependencyEvidence(
recomputed: ReturnType<typeof recomputeDependencyEvidence>,
stored: unknown,
): string[] {
const parsed = dependencyDiffArtifactSchema.safeParse(stored);
if (
!parsed.success ||
supplyChainDigest(parsed.data) !== supplyChainDigest(recomputed.report)
) {
return ["stored dependency diff does not match recomputed policy evidence"];
}
return [...recomputed.failures];
}
export function recomputeLicenseEvidence(input: Readonly<{
inventory: unknown;
policy: unknown;
}>) {
const inventory = dependencyInventoryArtifactSchema.parse(input.inventory);
const policy = asDocument(input.policy) ?? {};
const result = validateLicensePolicy(inventory, policy);
const report = licenseReportArtifactSchema.parse({
schemaVersion: 1,
status: result.passed ? "PASS" : "FAIL",
dependencyCount: inventory.dependencyCount,
results: result.results,
failures: result.failures,
});
return Object.freeze({
report,
failures: result.failures,
});
}
export function compareStoredLicenseEvidence(
recomputed: ReturnType<typeof recomputeLicenseEvidence>,
stored: unknown,
): string[] {
const parsed = licenseReportArtifactSchema.safeParse(stored);
if (
!parsed.success ||
supplyChainDigest(parsed.data) !== supplyChainDigest(recomputed.report)
) {
return ["stored license report does not match recomputed policy evidence"];
}
return [...recomputed.failures];
}
export function distChecksumsText(outputs: readonly DistOutput[]): string {
return `${[...outputs]
.sort((left, right) => left.path.localeCompare(right.path))
.map((output) => `${output.sha256} ${output.path}`)
.join("\n")}\n`;
}
export function verifyStoredDistChecksums(
outputs: readonly DistOutput[],
stored: string,
): string[] {
return stored === distChecksumsText(outputs)
? []
: ["stored dist checksums do not match current outputs"];
}
function asDocument(value: unknown): Document | null {
return value !== null &&
typeof value === "object" &&
!Array.isArray(value)
? (value as Document)
: null;
}
File diff suppressed because it is too large Load Diff
+994
View File
@@ -0,0 +1,994 @@
type PackageManager = "pnpm" | "npm" | "yarn";
type ManagerParseResult = Readonly<{
dependencies: readonly string[];
unsafeLifecycle: boolean;
unsupportedManagerSyntax: boolean;
}>;
type SuppressionState = {
effective: boolean | undefined;
contradictory: boolean;
malformed: boolean;
};
type ShellNpmScopeEnvironmentState = {
autoExport: boolean;
forbidden: boolean;
uncertain: boolean;
};
type ShellCommandPrefix = Readonly<{
assignments: readonly Readonly<{
dynamicName: boolean;
name: string | null;
}>[];
commandIndex: number;
uncertain: boolean;
}>;
type EnvironmentCommandPrefix = Readonly<{
assignmentNames: readonly string[];
uncertain: boolean;
}>;
type TokenizedShellSegment = Readonly<{
tokens: readonly string[];
expansionTokens: readonly boolean[];
}>;
const managerNames = new Set<PackageManager>(["pnpm", "npm", "yarn"]);
const managerOptionsWithValue = new Set([
"-C", "--cache", "--cache-folder", "--config-dir", "--cwd", "--dir", "--filter",
"--global-dir", "--globalconfig", "--home", "--lockfile-dir", "--mutex", "--prefix",
"--registry", "--store-dir", "--userconfig", "--workspace", "--workspace-dir",
]);
const managerBooleanOptions = new Set([
"--color", "--global", "--no-color", "--offline", "--prefer-offline", "--silent",
"--use-stderr", "--verbose", "-g", "-s",
]);
const npmScriptDispatchBooleanOptions = new Set([
"--foreground-scripts", "--if-present", "--ignore-scripts",
]);
const npmScriptDispatchScopeOptions = new Set([
"--prefix", "--workspace", "--workspaces",
]);
const npmDispatchScopeEnvironmentNames = new Set([
"npm_config_globalconfig", "npm_config_prefix", "npm_config_userconfig",
"npm_config_workspace", "npm_config_workspaces",
]);
const npmIndirectConfigAuthorityOptions = new Set([
"--globalconfig", "--userconfig",
]);
const manifestScopeOptions: Readonly<Record<PackageManager, ReadonlySet<string>>> = {
pnpm: new Set(["-C", "--dir", "--filter", "--workspace-dir"]),
npm: new Set(["--prefix", "--workspace"]),
yarn: new Set(["--cwd"]),
};
const lifecycleMutationCommands = new Set([
"add", "ci", "dedupe", "i", "install", "link", "pack", "prune", "publish",
"rebuild", "remove", "rm", "uninstall", "unlink", "up", "update", "upgrade",
]);
const managerBuiltinAliases: Readonly<
Record<PackageManager, ReadonlyMap<string, string>>
> = {
pnpm: new Map([["ln", "link"]]),
npm: new Map(),
yarn: new Map(),
};
const unsupportedBuiltinDispatchers: Readonly<
Record<PackageManager, ReadonlySet<string>>
> = {
pnpm: new Set(["dlx", "exec"]),
npm: new Set(["exec"]),
yarn: new Set(["dlx", "exec", "workspace", "workspaces"]),
};
const lifecycleBooleanOptions: Readonly<
Record<PackageManager, ReadonlySet<string>>
> = {
pnpm: new Set([
"--dry-run", "--force", "--frozen-lockfile", "--lockfile-only",
"--no-optional", "--prefer-frozen-lockfile", "--recursive",
"--workspace-root", "-D", "-P", "-r", "-w",
]),
npm: new Set([
"--audit", "--dry-run", "--force", "--foreground-scripts", "--fund",
"--package-lock-only",
]),
yarn: new Set([
"--check-cache", "--frozen-lockfile", "--ignore-engines",
"--ignore-optional", "--immutable", "--immutable-cache", "--inline-builds",
"--no-lockfile", "--non-interactive", "--pure-lockfile",
]),
};
const lifecycleOptionsWithValue: Readonly<
Record<PackageManager, ReadonlySet<string>>
> = {
pnpm: new Set(["--child-concurrency", "--modules-dir", "--reporter"]),
npm: new Set(["--include", "--install-strategy", "--omit"]),
yarn: new Set(["--mode", "--modules-folder", "--production"]),
};
const knownBuiltinCommands: Readonly<Record<PackageManager, ReadonlySet<string>>> = {
pnpm: new Set([
"audit", "config", "deploy", "dlx", "exec", "fetch", "help", "list", "ls",
"outdated", "root", "server", "setup", "store", "view", "why",
]),
npm: new Set([
"access", "audit", "bugs", "cache", "completion", "config", "diff", "docs",
"doctor", "exec", "explore", "fund", "help", "help-search", "hook", "init",
"list", "login", "logout", "ls", "org", "outdated", "owner", "ping", "pkg",
"prefix", "profile", "query", "repo", "root", "search", "star", "stars",
"team", "token", "unstar", "version", "view", "whoami",
]),
yarn: new Set([
"cache", "config", "constraints", "dedupe", "dlx", "exec", "help", "info",
"npm", "plugin", "set", "stage", "version", "why",
]),
};
const exactBareSafeBuiltinCommands: Readonly<
Record<PackageManager, ReadonlySet<string>>
> = {
pnpm: new Set(["audit"]),
npm: new Set(["audit"]),
yarn: new Set(),
};
const npmImplicitScripts = new Set(["restart", "start", "stop", "test"]);
export function validatePackageScriptGraph(
scripts: Readonly<Record<string, string>>,
entryScript: string,
): string[] {
const failures: string[] = [];
const visiting = new Set<string>();
const visited = new Set<string>();
const stack: string[] = [];
const visit = (scriptName: string): void => {
if (visiting.has(scriptName)) {
const start = stack.indexOf(scriptName);
failures.push(`package script cycle: ${[...stack.slice(start), scriptName].join(" -> ")}`);
return;
}
if (visited.has(scriptName)) return;
const command = scripts[scriptName];
if (command === undefined) {
failures.push(`package script missing: ${scriptName}`);
return;
}
visiting.add(scriptName);
stack.push(scriptName);
if (/\bscripts\/run-ci-gate(?:\.[cm]?[jt]s)?\b/u.test(command)) {
failures.push(`${scriptName} must not invoke the CI gate runner`);
}
const parsed = parseManagerCommands(command, scripts);
if (parsed.unsupportedManagerSyntax) {
failures.push(`package script manager invocation is not safely parseable: ${scriptName}`);
}
for (const dependency of parsed.dependencies) {
if (dependency === "ci:gate") {
failures.push(`${scriptName} must not invoke ci:gate`);
} else if (!(dependency in scripts)) {
failures.push(`package script missing: ${scriptName} -> ${dependency}`);
} else {
visit(dependency);
}
}
stack.pop();
visiting.delete(scriptName);
visited.add(scriptName);
};
visit(entryScript);
return [...new Set(failures)];
}
export function validateInstallScriptPolicy(
scripts: Readonly<Record<string, string>>,
entryScripts: readonly string[],
): string[] {
const failures: string[] = [];
const visited = new Set<string>();
const visit = (scriptName: string): void => {
if (visited.has(scriptName)) return;
visited.add(scriptName);
const command = scripts[scriptName];
if (command === undefined) {
failures.push(`package script missing: ${scriptName}`);
return;
}
const parsed = parseManagerCommands(command, scripts);
if (parsed.unsafeLifecycle || parsed.unsupportedManagerSyntax) {
failures.push(
`install-bearing package script must use --ignore-scripts: ${scriptName}`,
);
}
for (const dependency of parsed.dependencies) {
if (dependency !== "ci:gate") visit(dependency);
}
};
for (const entryScript of entryScripts) visit(entryScript);
return [...new Set(failures)];
}
export function validateNpmScopeEnvironment(
environment: Readonly<Record<string, string | undefined>>,
): string[] {
return Object.keys(environment)
.filter((name) => npmDispatchScopeEnvironmentNames.has(name.toLowerCase()))
.map((name) => `npm scope environment is not allowed: ${name}`);
}
function parseManagerCommands(
command: string,
scripts: Readonly<Record<string, string>>,
): ManagerParseResult {
const tokenized = tokenizeShellSegments(command);
const dependencies: string[] = [];
let unsafeLifecycle = false;
let unsupportedManagerSyntax = false;
if (!tokenized) {
return Object.freeze({
dependencies: Object.freeze([]),
unsafeLifecycle: false,
unsupportedManagerSyntax: containsManagerReference(command),
});
}
unsupportedManagerSyntax ||= tokenized.unsupportedControl && containsManagerReference(command);
const npmScopeEnvironmentState: ShellNpmScopeEnvironmentState = {
autoExport: false,
forbidden: false,
uncertain: false,
};
for (const segment of tokenized.segments) {
const { tokens, expansionTokens } = segment;
updateShellNpmScopeEnvironmentState(segment, npmScopeEnvironmentState);
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index]!;
if (token === "corepack") {
if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) {
unsupportedManagerSyntax = true;
break;
}
const wrapped = tokens[index + 1];
if (!wrapped || !isPackageManager(wrapped)) {
unsupportedManagerSyntax = true;
break;
}
const parsed = parseManagerInvocation(
wrapped,
tokens,
expansionTokens,
index + 2,
scripts,
hasUnsafeNpmScopeEnvironment(
npmScopeEnvironmentState,
tokens,
expansionTokens,
index,
),
);
dependencies.push(...parsed.dependencies);
unsafeLifecycle ||= parsed.unsafeLifecycle;
unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax;
break;
}
if (isPackageManager(token)) {
if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) {
unsupportedManagerSyntax = true;
break;
}
const parsed = parseManagerInvocation(
token,
tokens,
expansionTokens,
index + 1,
scripts,
hasUnsafeNpmScopeEnvironment(
npmScopeEnvironmentState,
tokens,
expansionTokens,
index,
),
);
dependencies.push(...parsed.dependencies);
unsafeLifecycle ||= parsed.unsafeLifecycle;
unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax;
break;
}
if (containsManagerReference(token)) {
unsupportedManagerSyntax = true;
break;
}
}
}
return Object.freeze({
dependencies: Object.freeze([...new Set(dependencies)]),
unsafeLifecycle,
unsupportedManagerSyntax,
});
}
function parseManagerInvocation(
manager: PackageManager,
tokens: readonly string[],
expansionTokens: readonly boolean[],
start: number,
scripts: Readonly<Record<string, string>>,
hasNpmScopeEnvironment: boolean,
): ManagerParseResult {
let cursor = start;
let changesManifestScope = false;
let hasNpmConfigAuthority = false;
let consumedManagerSyntax = false;
const suppression: SuppressionState = {
effective: undefined,
contradictory: false,
malformed: false,
};
while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) {
consumedManagerSyntax = true;
const option = tokens[cursor]!;
const parsedSuppression = consumeSuppressionOption(
manager,
tokens,
cursor,
suppression,
);
if (parsedSuppression.recognized) {
if (parsedSuppression.unsupported) return unsupportedResult();
cursor = parsedSuppression.nextIndex;
continue;
}
const equals = option.indexOf("=");
const name = equals < 0 ? option : option.slice(0, equals);
if (managerOptionsWithValue.has(name)) {
changesManifestScope ||= manifestScopeOptions[manager].has(name);
hasNpmConfigAuthority ||=
manager === "npm" && npmIndirectConfigAuthorityOptions.has(name);
if (equals >= 0) {
if (option.slice(equals + 1).length === 0) return unsupportedResult();
} else {
cursor += 1;
if (cursor >= tokens.length || tokens[cursor]!.startsWith("-")) {
return unsupportedResult();
}
}
} else if (managerBooleanOptions.has(name)) {
if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) {
return unsupportedResult();
}
} else if (option !== "--") {
return unsupportedResult();
}
cursor += 1;
}
const subcommand = tokens[cursor];
if (!subcommand) return unsupportedResult();
if (manager === "npm" && (hasNpmScopeEnvironment || hasNpmConfigAuthority)) {
return unsupportedResult();
}
const argumentsAfterCommand = tokens.slice(cursor + 1);
if (subcommand === "run" || subcommand === "run-script") {
const dependency = argumentsAfterCommand[0];
if (!dependency || dependency.startsWith("-")) return unsupportedResult();
if (changesManifestScope) return unsupportedResult();
if (
manager === "npm" &&
(expansionTokens.slice(start, cursor + 2).some(Boolean) ||
!areNpmScriptDispatchArgumentsSupported(
argumentsAfterCommand.slice(1),
expansionTokens.slice(cursor + 2),
suppression,
))
) {
return unsupportedResult();
}
return manager === "npm"
? npmScriptDependencyResult(dependency, scripts, suppression)
: dependencyResult(dependency);
}
const canonicalSubcommand = managerBuiltinAliases[manager].get(subcommand) ?? subcommand;
if (unsupportedBuiltinDispatchers[manager].has(canonicalSubcommand)) {
return unsupportedResult();
}
if (lifecycleMutationCommands.has(canonicalSubcommand)) {
const lifecycleArgumentsSupported = parseLifecycleArguments(
manager,
argumentsAfterCommand,
suppression,
);
return Object.freeze({
dependencies: Object.freeze([]),
unsafeLifecycle:
!lifecycleArgumentsSupported || !hasEffectiveLifecycleSuppression(suppression),
unsupportedManagerSyntax: !lifecycleArgumentsSupported,
});
}
if (knownBuiltinCommands[manager].has(canonicalSubcommand)) {
return exactBareSafeBuiltinCommands[manager].has(canonicalSubcommand) &&
!consumedManagerSyntax &&
argumentsAfterCommand.length === 0
? emptyResult()
: unsupportedResult();
}
const isKnownRootScript = Object.prototype.hasOwnProperty.call(scripts, subcommand);
const supportsImplicit = /^[A-Za-z0-9:_-]+$/u.test(subcommand) && (
((manager === "pnpm" || manager === "yarn") && isKnownRootScript) ||
(manager === "npm" && npmImplicitScripts.has(subcommand))
);
if (supportsImplicit) {
if (changesManifestScope) return unsupportedResult();
if (
manager === "npm" &&
(expansionTokens.slice(start, cursor + 1).some(Boolean) ||
!areNpmScriptDispatchArgumentsSupported(
argumentsAfterCommand,
expansionTokens.slice(cursor + 1),
suppression,
))
) {
return unsupportedResult();
}
return manager === "npm"
? npmScriptDependencyResult(subcommand, scripts, suppression)
: dependencyResult(subcommand);
}
return unsupportedResult();
}
function areNpmScriptDispatchArgumentsSupported(
tokens: readonly string[],
expansionTokens: readonly boolean[],
suppression: SuppressionState,
): boolean {
let index = 0;
while (index < tokens.length) {
const token = tokens[index]!;
if (token === "--") return true;
if (expansionTokens[index]) return false;
if (!token.startsWith("-")) {
index += 1;
continue;
}
const parsedSuppression = consumeSuppressionOption(
"npm",
tokens,
index,
suppression,
);
if (parsedSuppression.recognized) {
if (parsedSuppression.unsupported) return false;
index = parsedSuppression.nextIndex;
continue;
}
const equals = token.indexOf("=");
const name = equals < 0 ? token : token.slice(0, equals);
const isShortWorkspaceOption = token === "-w" || /^-w(?:=)?.+/u.test(token);
if (npmScriptDispatchScopeOptions.has(name) || isShortWorkspaceOption) {
return false;
}
if (!npmScriptDispatchBooleanOptions.has(name)) return false;
if (equals >= 0 && !/^(?:true|false)$/u.test(token.slice(equals + 1))) {
return false;
}
index += 1;
}
return true;
}
function hasUnsafeNpmScopeEnvironment(
state: Readonly<ShellNpmScopeEnvironmentState>,
tokens: readonly string[],
expansionTokens: readonly boolean[],
commandIndex: number,
): boolean {
return state.forbidden || state.uncertain ||
hasUnsafeImmediateNpmScopeEnvironment(tokens, expansionTokens, commandIndex);
}
function hasUnsafeManagerCommandPrefix(
tokens: readonly string[],
expansionTokens: readonly boolean[],
commandIndex: number,
): boolean {
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
if (prefix.uncertain) return true;
let cursor = prefix.commandIndex;
if (cursor === commandIndex) return false;
if (cursor > commandIndex ||
!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) {
return true;
}
return parseEnvironmentCommandPrefix(
tokens,
expansionTokens,
cursor,
commandIndex,
).uncertain;
}
function hasUnsafeImmediateNpmScopeEnvironment(
tokens: readonly string[],
expansionTokens: readonly boolean[],
commandIndex: number,
): boolean {
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
if (prefix.uncertain || prefix.assignments.some(
({ name }) => name !== null && isNpmScopeEnvironmentName(name),
)) return true;
let cursor = prefix.commandIndex;
if (cursor === commandIndex) return false;
if (!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) return false;
const environmentPrefix = parseEnvironmentCommandPrefix(
tokens,
expansionTokens,
cursor,
commandIndex,
);
return environmentPrefix.uncertain || environmentPrefix.assignmentNames.some(
(name) => isNpmScopeEnvironmentName(name),
);
}
function parseEnvironmentCommandPrefix(
tokens: readonly string[],
expansionTokens: readonly boolean[],
start: number,
commandIndex: number,
): EnvironmentCommandPrefix {
const assignmentNames: string[] = [];
let cursor = start + 1;
let uncertain = false;
while (cursor < commandIndex && tokens[cursor]!.startsWith("-")) {
const option = tokens[cursor]!;
if (expansionTokens[cursor]) uncertain = true;
if (option === "--") {
cursor += 1;
break;
}
if (option === "-i" || option === "--ignore-environment") {
cursor += 1;
continue;
}
if (option === "-u" || option === "--unset") {
cursor += 1;
if (cursor >= commandIndex || tokens[cursor]!.startsWith("-")) {
uncertain = true;
break;
}
uncertain ||= expansionTokens[cursor] ?? false;
cursor += 1;
continue;
}
if (/^--unset=.+/u.test(option)) {
cursor += 1;
continue;
}
uncertain = true;
cursor += 1;
}
while (cursor < commandIndex) {
const token = tokens[cursor]!;
if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false)) {
uncertain = true;
}
const assignmentName = parseEnvironmentAssignmentName(token);
if (assignmentName) assignmentNames.push(assignmentName);
else uncertain = true;
cursor += 1;
}
return Object.freeze({
assignmentNames: Object.freeze(assignmentNames),
uncertain,
});
}
function parseShellCommandPrefix(
tokens: readonly string[],
expansionTokens: readonly boolean[],
): ShellCommandPrefix {
const assignments: Array<{
dynamicName: boolean;
name: string | null;
}> = [];
let cursor = 0;
let uncertain = false;
while (cursor < tokens.length) {
const token = tokens[cursor]!;
const name = parseAssignmentName(token);
const dynamicName = hasDynamicAssignmentName(
token,
expansionTokens[cursor] ?? false,
);
if (!name && !dynamicName) break;
assignments.push({ dynamicName, name });
uncertain ||= dynamicName;
cursor += 1;
}
while (cursor < tokens.length) {
const wrapper = tokens[cursor];
if (expansionTokens[cursor]) {
uncertain = true;
break;
}
if (wrapper !== "command" && wrapper !== "exec") break;
cursor += 1;
while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) {
const option = tokens[cursor]!;
if (option === "--") {
cursor += 1;
break;
}
if (wrapper === "command" && option === "-p") {
cursor += 1;
continue;
}
uncertain = true;
cursor += 1;
if (wrapper === "exec" && option === "-a" && cursor < tokens.length) {
cursor += 1;
}
}
}
if (expansionTokens[cursor]) uncertain = true;
return Object.freeze({
assignments: Object.freeze(assignments.map((assignment) => Object.freeze(assignment))),
commandIndex: cursor,
uncertain,
});
}
function updateShellNpmScopeEnvironmentState(
segment: TokenizedShellSegment,
state: ShellNpmScopeEnvironmentState,
): void {
const { tokens, expansionTokens } = segment;
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
state.uncertain ||= prefix.uncertain;
const command = tokens[prefix.commandIndex];
if (!command) {
for (const assignment of prefix.assignments) {
if (state.autoExport && assignment.name &&
isNpmScopeEnvironmentName(assignment.name)) {
state.forbidden = true;
}
}
return;
}
if (command === "eval" || command === "." || command === "source") {
state.uncertain = true;
return;
}
if (command === "unset" || command === "typeset" || command === "declare" ||
command === "local" || command === "readonly") {
state.uncertain = true;
return;
}
if (command === "set") {
if (tokens.slice(prefix.commandIndex + 1).includes("-a")) state.autoExport = true;
if (tokens.slice(prefix.commandIndex + 1).includes("+a")) {
state.autoExport = false;
state.uncertain = true;
}
return;
}
if (command === "export") {
let cursor = prefix.commandIndex + 1;
for (; cursor < tokens.length; cursor += 1) {
const token = tokens[cursor]!;
if (token === "--") continue;
if (token === "-n" || token.startsWith("-")) {
state.uncertain = true;
continue;
}
const assignmentName = parseAssignmentName(token);
const bareName = /^[A-Za-z_][A-Za-z0-9_]*$/u.test(token) ? token : null;
if (assignmentName || bareName) {
if (isNpmScopeEnvironmentName(assignmentName ?? bareName!)) {
state.forbidden = true;
}
} else if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false) ||
expansionTokens[cursor]) {
state.uncertain = true;
}
}
return;
}
}
function isEnvironmentCommand(token: string | undefined, hasExpansion: boolean): boolean {
if (!token || hasExpansion) return false;
return token.split("/").at(-1) === "env";
}
function parseEnvironmentAssignmentName(token: string): string | null {
const equals = token.indexOf("=");
return equals > 0 ? token.slice(0, equals) : null;
}
function isNpmScopeEnvironmentName(name: string): boolean {
return npmDispatchScopeEnvironmentNames.has(name.toLowerCase());
}
function hasDynamicAssignmentName(token: string, hasExpansion: boolean): boolean {
const equals = token.indexOf("=");
return hasExpansion && equals > 0 && parseAssignmentName(token) === null;
}
function parseAssignmentName(token: string): string | null {
return /^([A-Za-z_][A-Za-z0-9_]*)=/u.exec(token)?.[1] ?? null;
}
function parseLifecycleArguments(
manager: PackageManager,
tokens: readonly string[],
suppression: SuppressionState,
): boolean {
let cursor = 0;
while (cursor < tokens.length) {
const token = tokens[cursor]!;
if (!token.startsWith("-")) {
cursor += 1;
continue;
}
const parsedSuppression = consumeSuppressionOption(
manager,
tokens,
cursor,
suppression,
);
if (parsedSuppression.recognized) {
if (parsedSuppression.unsupported) return false;
cursor = parsedSuppression.nextIndex;
continue;
}
const parsedOption = consumeAllowedLifecycleOption(manager, tokens, cursor);
if (parsedOption === null) return false;
cursor = parsedOption;
}
return true;
}
function consumeSuppressionOption(
manager: PackageManager,
tokens: readonly string[],
index: number,
state: SuppressionState,
): Readonly<{ recognized: boolean; unsupported: boolean; nextIndex: number }> {
const token = tokens[index]!;
if (token === "--no-ignore-scripts") {
recordSuppression(state, false);
return { recognized: true, unsupported: false, nextIndex: index + 1 };
}
const equalsForms = ["--ignore-scripts=", "--config.ignore-scripts="] as const;
for (const prefix of equalsForms) {
if (!token.startsWith(prefix)) continue;
if (prefix.startsWith("--config.") && manager !== "pnpm") {
state.malformed = true;
return { recognized: true, unsupported: true, nextIndex: index + 1 };
}
const raw = token.slice(prefix.length);
if (raw !== "true" && raw !== "false") {
state.malformed = true;
return { recognized: true, unsupported: true, nextIndex: index + 1 };
}
recordSuppression(state, raw === "true");
return { recognized: true, unsupported: false, nextIndex: index + 1 };
}
if (token !== "--ignore-scripts" && token !== "--config.ignore-scripts") {
return { recognized: false, unsupported: false, nextIndex: index };
}
if (token === "--config.ignore-scripts" && manager !== "pnpm") {
state.malformed = true;
return { recognized: true, unsupported: true, nextIndex: index + 1 };
}
const next = tokens[index + 1];
if (next === "true" || next === "false") {
const supportsSplitValue = manager === "npm" || manager === "pnpm";
if (!supportsSplitValue) {
state.malformed = true;
return { recognized: true, unsupported: true, nextIndex: index + 2 };
}
recordSuppression(state, next === "true");
return { recognized: true, unsupported: false, nextIndex: index + 2 };
}
recordSuppression(state, true);
return { recognized: true, unsupported: false, nextIndex: index + 1 };
}
function recordSuppression(
state: SuppressionState,
value: boolean,
): void {
if (state.effective !== undefined && state.effective !== value) {
state.contradictory = true;
}
state.effective = value;
}
function hasEffectiveLifecycleSuppression(state: SuppressionState): boolean {
return state.effective === true && !state.contradictory && !state.malformed;
}
function consumeAllowedLifecycleOption(
manager: PackageManager,
tokens: readonly string[],
index: number,
): number | null {
const option = tokens[index]!;
const equals = option.indexOf("=");
const name = equals < 0 ? option : option.slice(0, equals);
const booleanOption =
managerBooleanOptions.has(name) || lifecycleBooleanOptions[manager].has(name);
if (booleanOption) {
if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) return null;
return index + 1;
}
const valuedOption =
managerOptionsWithValue.has(name) || lifecycleOptionsWithValue[manager].has(name);
if (!valuedOption) return null;
if (equals >= 0) return option.slice(equals + 1).length > 0 ? index + 1 : null;
const value = tokens[index + 1];
if (!value || value.startsWith("-")) return null;
return index + 2;
}
function dependencyResult(dependency: string): ManagerParseResult {
return dependenciesResult([dependency]);
}
function npmScriptDependencyResult(
dependency: string,
scripts: Readonly<Record<string, string>>,
suppression: SuppressionState,
): ManagerParseResult {
if (hasEffectiveLifecycleSuppression(suppression)) {
return dependenciesResult([dependency]);
}
return dependenciesResult(
[`pre${dependency}`, dependency, `post${dependency}`]
.filter((scriptName) => scriptName === dependency || scriptName in scripts),
);
}
function dependenciesResult(dependencies: readonly string[]): ManagerParseResult {
return Object.freeze({
dependencies: Object.freeze([...dependencies]),
unsafeLifecycle: false,
unsupportedManagerSyntax: false,
});
}
function emptyResult(): ManagerParseResult {
return Object.freeze({
dependencies: Object.freeze([]),
unsafeLifecycle: false,
unsupportedManagerSyntax: false,
});
}
function unsupportedResult(): ManagerParseResult {
return Object.freeze({
dependencies: Object.freeze([]),
unsafeLifecycle: false,
unsupportedManagerSyntax: true,
});
}
function isPackageManager(value: string): value is PackageManager {
return managerNames.has(value as PackageManager);
}
function containsManagerReference(value: string): boolean {
return /(?:^|[^A-Za-z0-9_-])(?:corepack|pnpm|npm|yarn)(?:[^A-Za-z0-9_-]|$)/u
.test(value);
}
function tokenizeShellSegments(command: string): Readonly<{
segments: readonly TokenizedShellSegment[];
unsupportedControl: boolean;
}> | null {
const segments: Array<{ tokens: string[]; expansionTokens: boolean[] }> = [
{ tokens: [], expansionTokens: [] },
];
let token = "";
let tokenHasExpansion = false;
let quote: "'" | '"' | null = null;
let escaping = false;
let unsupportedControl = false;
const pushToken = (): void => {
if (token.length > 0) {
segments.at(-1)!.tokens.push(token);
segments.at(-1)!.expansionTokens.push(tokenHasExpansion);
}
token = "";
tokenHasExpansion = false;
};
const pushSegment = (): void => {
pushToken();
if (segments.at(-1)!.tokens.length > 0) {
segments.push({ tokens: [], expansionTokens: [] });
}
};
for (let index = 0; index < command.length; index += 1) {
const character = command[index]!;
if (escaping) {
token += character;
escaping = false;
continue;
}
if (character === "\\" && quote !== "'") {
escaping = true;
continue;
}
if (quote) {
if (character === quote) quote = null;
else {
if (quote === '"' && character === "$") tokenHasExpansion = true;
token += character;
}
continue;
}
if (character === "'" || character === '"') {
quote = character;
continue;
}
if (character === "`" || (character === "$" && command[index + 1] === "(")) {
unsupportedControl = true;
if (character === "$") tokenHasExpansion = true;
token += character;
continue;
}
if (character === "$" || character === "*" || character === "?" || character === "[") {
tokenHasExpansion = true;
}
if (character === "#") {
unsupportedControl = true;
pushToken();
while (
index + 1 < command.length &&
command[index + 1] !== "\n" &&
command[index + 1] !== "\r"
) {
index += 1;
}
continue;
}
if (character === "<" || character === ">" || character === "(" || character === ")") {
unsupportedControl = true;
token += character;
continue;
}
if (/\s/u.test(character)) {
pushToken();
if (character === "\n" || character === "\r") pushSegment();
continue;
}
if (character === ";" || character === "|" || character === "&") {
pushSegment();
if (command[index + 1] === character) index += 1;
continue;
}
token += character;
}
if (quote || escaping) return null;
pushToken();
return Object.freeze({
segments: Object.freeze(
segments
.filter((segment) => segment.tokens.length > 0)
.map((segment) => Object.freeze({
tokens: Object.freeze(segment.tokens),
expansionTokens: Object.freeze(segment.expansionTokens),
})),
),
unsupportedControl,
});
}
+709
View File
@@ -0,0 +1,709 @@
import {
createHash,
createPublicKey,
randomBytes as cryptoRandomBytes,
} from "node:crypto";
import { constants } from "node:fs";
import {
lstat,
mkdir,
open,
readdir,
rm,
rmdir,
stat,
} from "node:fs/promises";
import path from "node:path";
import {
PROMOTED_FILE_NAMES,
type PromotedFileName,
} from "../contracts/promotion-artifacts.ts";
import {
evaluatePromotionEvidence,
assertDistinctProviderTrust,
providerPublicKeyFingerprint,
providerVerificationArtifactSchema,
PROMOTION_VERIFIER_ID,
PROMOTION_VERIFIER_VERSION,
provenanceProviderAttestationSchema,
trustPolicySha256,
vulnerabilityProviderReportSchema,
type ProviderTrust,
} from "./provider-evidence.ts";
import { verifyExactPromotionBundle } from "./exact-promotion-bundle.ts";
import {
captureCiCandidateArchive,
withVerifiedCapturedCandidate,
} from "./ci-candidate-archive.ts";
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
export type StagedFile = Readonly<{
name: PromotedFileName;
bytes: Buffer;
sha256: string;
}>;
export type FinalizedPromotion = Readonly<{
stagingRoot: string;
cleanupToken: string;
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
stagingIdentity: Readonly<{ dev: number; ino: number }>;
files: readonly Readonly<{ name: PromotedFileName; sha256: string }>[];
}>;
export async function finalizeVerifiedPromotion(input: Readonly<{
repositoryRoot: string;
archivePath: string;
expectedArchiveSha256: string;
vulnerabilityReportPath: string;
provenanceAttestationPath: string;
vulnerabilityPublicKeyPath: string;
vulnerabilityKeyId: string;
provenancePublicKeyPath: string;
provenanceKeyId: string;
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
vulnerabilityInvocationNonce: string;
provenanceInvocationNonce: string;
runnerTempRoot: string;
}>, dependencies: Readonly<{
captureArchive?: typeof captureCiCandidateArchive;
nowEpochMs?: () => number;
randomBytes?: (bytes: number) => Buffer;
afterCapture?: () => Promise<void>;
beforePublish?: () => Promise<void>;
afterStagingWrite?: () => Promise<void>;
afterFileWrite?: (name: PromotedFileName) => Promise<void>;
beforeSeal?: () => Promise<void>;
afterMkdirBeforeOpen?: (stagingRoot: string) => Promise<void>;
}> = {}): Promise<FinalizedPromotion> {
const root = path.resolve(input.repositoryRoot);
const capturedArchive = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
archivePath: input.archivePath,
expectedSha256: input.expectedArchiveSha256,
});
const [vulnerabilityBytes, provenanceBytes, vulnerabilityKeyBytes, provenanceKeyBytes] =
await Promise.all([
capture(root, input.vulnerabilityReportPath, 16_777_216),
capture(root, input.provenanceAttestationPath, 16_777_216),
capture(root, input.vulnerabilityPublicKeyPath, 1_048_576),
capture(root, input.provenancePublicKeyPath, 1_048_576),
]);
await dependencies.afterCapture?.();
const vulnerabilityTrust = capturedTrust(
input.vulnerabilityKeyId,
vulnerabilityKeyBytes,
);
const provenanceTrust = capturedTrust(
input.provenanceKeyId,
provenanceKeyBytes,
);
assertDistinctProviderTrust({ vulnerabilityTrust, provenanceTrust });
const vulnerabilityReport = vulnerabilityProviderReportSchema.parse(
parseJson(vulnerabilityBytes),
);
const provenanceAttestation = provenanceProviderAttestationSchema.parse(
parseJson(provenanceBytes),
);
const nowEpochMs = dependencies.nowEpochMs ?? Date.now;
const generated = await withVerifiedCapturedCandidate({
captured: capturedArchive,
verify: async ({ extractionRoot, manifest }) => {
const local = await verifyArchivedLocalEvidence({
extractionRoot,
expectedManifest: manifest,
});
if (local.status !== "PASS" || !local.identity) {
throw new Error(
`captured local evidence failed final verification: ${local.failures.join(", ")}`,
);
}
if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) {
throw new Error("captured source revision differs from expected promotion revision");
}
const expected = {
run: { id: input.expectedRun.id, attempt: input.expectedRun.attempt },
source: {
revision: local.identity.sourceRevision,
sourceSetSha256: local.identity.sourceSetSha256,
},
candidate: {
archiveSha256: capturedArchive.archiveSha256,
bundleSha256: manifest.bundleSha256,
distSha256: manifest.distSha256,
lockfileSha256: manifest.lockfileSha256,
},
secretScanAttestation: {
status: "PASS" as const,
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
sourceSetSha256: local.identity.sourceSetSha256,
policySha256: local.identity.secretScan.policySha256,
sarifSha256: local.identity.secretScan.sarifSha256,
scanInputSha256: local.identity.secretScan.scanInputSha256,
},
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
provenanceInvocationNonce: input.provenanceInvocationNonce,
} as const;
const reevaluated = evaluatePromotionEvidence({
expected,
localStatus: local.status,
vulnerabilityReport,
provenanceAttestation,
vulnerabilityTrust,
provenanceTrust,
nowEpochMs,
});
if (reevaluated.status !== "PASS") {
throw new Error(
`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`,
);
}
const providerEvidence = {
vulnerabilityReportSha256: sha256(vulnerabilityBytes),
provenanceAttestationSha256: sha256(provenanceBytes),
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
provenanceInvocationNonce: input.provenanceInvocationNonce,
vulnerabilityKeyId: vulnerabilityTrust.keyId,
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
provenanceKeyId: provenanceTrust.keyId,
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
secretScanAttestation: expected.secretScanAttestation,
} as const;
const trustDigest = trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
const verifiedAt = new Date(nowEpochMs()).toISOString();
const common = {
schemaVersion: 3 as const,
verifiedAt,
status: "PASS" as const,
verifier: {
id: PROMOTION_VERIFIER_ID,
version: PROMOTION_VERIFIER_VERSION,
},
run: expected.run,
source: expected.source,
candidate: expected.candidate,
providerEvidence,
trustPolicySha256: trustDigest,
failures: [] as const,
};
const providerRecord = providerVerificationArtifactSchema.parse({
...common,
artifactType: "provider-verification",
vulnerabilityStatus: reevaluated.vulnerabilityStatus,
provenanceAttestationStatus: reevaluated.provenanceAttestationStatus,
});
const providerRecordBytes = canonicalJsonBytes(providerRecord);
const promotionRecord = providerVerificationArtifactSchema.parse({
...common,
artifactType: "promotion-verification",
localEvidenceStatus: local.status,
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
providerVerificationSha256: sha256(providerRecordBytes),
});
return Object.freeze({
providerRecordBytes,
promotionRecordBytes: canonicalJsonBytes(promotionRecord),
exactExpected: Object.freeze({
run: expected.run,
sourceRevision: expected.source.revision,
sourceSetSha256: expected.source.sourceSetSha256,
archiveSha256: expected.candidate.archiveSha256,
bundleSha256: expected.candidate.bundleSha256,
distSha256: expected.candidate.distSha256,
lockfileSha256: expected.candidate.lockfileSha256,
}),
});
},
});
const stagedFiles: readonly StagedFile[] = Object.freeze([
staged("release-candidate.tar.gz", capturedArchive.bytes),
staged("vulnerability-report.json", vulnerabilityBytes),
staged("provenance-attestation.json", provenanceBytes),
staged("provider-verification.json", generated.providerRecordBytes),
staged("promotion-verification.json", generated.promotionRecordBytes),
]);
if (
JSON.stringify(stagedFiles.map(({ name }) => name)) !==
JSON.stringify(PROMOTED_FILE_NAMES)
) {
throw new Error("promotion exact-five canonical file order drift");
}
await dependencies.beforePublish?.();
await verifyExactPromotionBundle(
Object.fromEntries(stagedFiles.map(({ name, bytes }) => [name, bytes])),
{
vulnerabilityTrust,
provenanceTrust,
expected: generated.exactExpected,
nowEpochMs,
},
);
return publishPrivatePromotionStaging(
input.runnerTempRoot,
input.expectedRun,
stagedFiles,
dependencies.randomBytes ?? cryptoRandomBytes,
dependencies.afterStagingWrite,
dependencies.afterFileWrite,
async (capturedFiles) => {
await verifyExactPromotionBundle(
capturedFiles,
{
vulnerabilityTrust,
provenanceTrust,
expected: generated.exactExpected,
nowEpochMs,
},
);
},
dependencies.afterMkdirBeforeOpen,
dependencies.beforeSeal,
);
}
export const stageVerifiedPromotion = finalizeVerifiedPromotion;
export async function cleanupFinalizedPromotion(input: Readonly<{
runnerTempRoot: string;
stagingRoot: string;
cleanupToken: string;
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
stagingIdentity: Readonly<{ dev: number; ino: number }>;
}>, dependencies: Readonly<{
beforeRemove?: () => Promise<void>;
}> = {}): Promise<void> {
const parent = path.resolve(input.runnerTempRoot);
const expected = path.join(parent, input.cleanupToken);
if (
!/^[A-Za-z0-9._-]+-[a-f0-9]{32}$/u.test(input.cleanupToken) ||
path.resolve(input.stagingRoot) !== expected ||
!Number.isSafeInteger(input.runnerTempIdentity.dev) ||
input.runnerTempIdentity.dev <= 0 ||
!Number.isSafeInteger(input.runnerTempIdentity.ino) ||
input.runnerTempIdentity.ino <= 0
|| !Number.isSafeInteger(input.stagingIdentity.dev)
|| input.stagingIdentity.dev <= 0
|| !Number.isSafeInteger(input.stagingIdentity.ino)
|| input.stagingIdentity.ino <= 0
) {
throw new TypeError("promotion cleanup root/token mismatch");
}
const parentHandle = await open(
parent,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
try {
const openedParent = await parentHandle.stat();
assertRunnerTempIdentity(openedParent, input.runnerTempIdentity);
const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`;
const descriptorMetadata = await stat(descriptorRoot);
if (!descriptorMetadata.isDirectory()) {
throw new Error("descriptor-relative cleanup is unavailable");
}
const descriptorExpected = path.join(descriptorRoot, input.cleanupToken);
let metadata;
try {
metadata = await lstat(descriptorExpected);
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return;
throw error;
}
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new TypeError("promotion cleanup leaf is unsafe");
}
assertStagingIdentity(metadata, input.stagingIdentity);
const stagingHandle = await open(
descriptorExpected,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
try {
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
const names = (await readdir(stagingDescriptorRoot)).sort(asciiCompare);
if (
JSON.stringify(names) !==
JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare))
) {
throw new Error("promotion cleanup leaf does not contain the exact five files");
}
await dependencies.beforeRemove?.();
const visibleParent = await lstat(parent);
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
for (const name of PROMOTED_FILE_NAMES) {
await rm(path.join(stagingDescriptorRoot, name), { force: false });
}
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
assertStagingIdentity(await lstat(descriptorExpected), input.stagingIdentity);
await rmdir(descriptorExpected);
} finally {
await stagingHandle.close();
}
const afterParent = await lstat(parent);
assertRunnerTempIdentity(afterParent, input.runnerTempIdentity);
} finally {
await parentHandle.close();
}
}
export async function publishPrivatePromotionStaging(
runnerTempRoot: string,
run: Readonly<{ id: string; attempt: number }>,
files: readonly StagedFile[],
randomBytes: (bytes: number) => Buffer,
afterStagingWrite?: () => Promise<void>,
afterFileWrite?: (name: PromotedFileName) => Promise<void>,
sealStagedFiles?: (files: Readonly<Record<PromotedFileName, Buffer>>) => Promise<void>,
afterMkdirBeforeOpen?: (stagingRoot: string) => Promise<void>,
beforeSeal?: () => Promise<void>,
): Promise<FinalizedPromotion> {
if (
JSON.stringify(files.map(({ name }) => name)) !==
JSON.stringify(PROMOTED_FILE_NAMES) ||
files.some(
({ bytes, sha256: digest }) =>
!Buffer.isBuffer(bytes) ||
!/^[a-f0-9]{64}$/u.test(digest) ||
sha256(bytes) !== digest,
)
) {
throw new TypeError("private promotion staging requires the canonical exact-five bytes");
}
const parentPath = path.resolve(runnerTempRoot);
const before = await lstat(parentPath);
if (!before.isDirectory() || before.isSymbolicLink()) {
throw new TypeError("runner temporary root must be a real directory");
}
const parentHandle = await open(
parentPath,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
const tokenBytes = randomBytes(16);
if (tokenBytes.byteLength !== 16) {
await parentHandle.close();
throw new TypeError("promotion staging nonce must contain exactly 128 random bits");
}
const safeRun = run.id.replaceAll(/[^A-Za-z0-9._-]/gu, "_").slice(0, 64) || "run";
const cleanupToken = `promotion-${safeRun}-${run.attempt}-${tokenBytes.toString("hex")}`;
const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`;
const descriptorStaging = path.join(descriptorRoot, cleanupToken);
const visibleStaging = path.join(parentPath, cleanupToken);
let ownsStaging = false;
let stagingHandle: Awaited<ReturnType<typeof open>> | undefined;
let createdStagingIdentity: Readonly<{ dev: number; ino: number }> | undefined;
let stagingIdentity: Readonly<{ dev: number; ino: number }> | undefined;
let openedIdentityVerified = false;
const cleanup = async (primaryFailure?: unknown): Promise<void> => {
const cleanupFailures: unknown[] = [];
const attemptCleanup = async (operation: () => Promise<void>): Promise<void> => {
try {
await operation();
} catch (error) {
cleanupFailures.push(error);
}
};
if (ownsStaging && openedIdentityVerified && stagingHandle && stagingIdentity) {
const ownedIdentity = stagingIdentity;
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
const removals = await Promise.allSettled(
files.map(({ name }) => rm(path.join(stagingDescriptorRoot, name), { force: true })),
);
cleanupFailures.push(
...removals.flatMap((result) =>
result.status === "rejected" ? [result.reason] : [],
),
);
await attemptCleanup(async () => {
let visible;
try {
visible = await lstat(descriptorStaging);
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return;
throw error;
}
if (
visible.isDirectory() &&
!visible.isSymbolicLink() &&
visible.dev === ownedIdentity.dev &&
visible.ino === ownedIdentity.ino
) {
await rmdir(descriptorStaging);
}
});
}
if (stagingHandle) {
const ownedHandle = stagingHandle;
await attemptCleanup(async () => ownedHandle.close());
}
await attemptCleanup(async () => parentHandle.close());
if (cleanupFailures.length > 0) {
throw new AggregateError(
primaryFailure === undefined
? cleanupFailures
: [primaryFailure, ...cleanupFailures],
primaryFailure instanceof Error
? `${primaryFailure.message}; promotion staging cleanup also failed`
: "promotion staging cleanup failed",
{ cause: cleanupFailures.at(-1) },
);
}
};
let finalizedPromotion: FinalizedPromotion;
try {
const procMetadata = await stat(descriptorRoot);
if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable");
await mkdir(descriptorStaging, { mode: 0o700 });
ownsStaging = true;
const createdStaging = await lstat(descriptorStaging);
if (!createdStaging.isDirectory() || createdStaging.isSymbolicLink()) {
throw new Error("created promotion staging leaf is unsafe");
}
createdStagingIdentity = Object.freeze({
dev: createdStaging.dev,
ino: createdStaging.ino,
});
await afterMkdirBeforeOpen?.(visibleStaging);
const openedHandle = await open(
descriptorStaging,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
let openedStaging;
try {
openedStaging = await openedHandle.stat();
if (!openedStaging.isDirectory()) {
throw new Error("promotion staging descriptor is not a directory");
}
if (
openedStaging.dev !== createdStagingIdentity.dev ||
openedStaging.ino !== createdStagingIdentity.ino
) {
throw new Error("promotion staging leaf identity changed between mkdir and open");
}
openedIdentityVerified = true;
} catch (error) {
try {
await openedHandle.close();
} catch (closeError) {
throw new AggregateError(
[error, closeError],
error instanceof Error
? `${error.message}; rejected staging descriptor close also failed`
: "rejected staging descriptor and close both failed",
{ cause: closeError },
);
}
throw error;
}
stagingHandle = openedHandle;
await stagingHandle.chmod(0o700);
stagingIdentity = Object.freeze({ dev: openedStaging.dev, ino: openedStaging.ino });
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
assertStagingIdentity(await stat(stagingDescriptorRoot), stagingIdentity);
for (const file of files) {
const handle = await open(
path.join(stagingDescriptorRoot, file.name),
constants.O_WRONLY |
constants.O_CREAT |
constants.O_EXCL |
constants.O_NOFOLLOW,
0o400,
);
try {
await handle.chmod(0o400);
await handle.writeFile(file.bytes);
await handle.sync();
} finally {
await handle.close();
}
await afterFileWrite?.(file.name);
}
await syncHandle(stagingHandle);
await syncHandle(parentHandle);
await afterStagingWrite?.();
await beforeSeal?.();
const capturedFiles = await captureStagedFiles(stagingHandle, files);
await sealStagedFiles?.(capturedFiles);
const after = await lstat(parentPath);
if (
after.dev !== before.dev ||
after.ino !== before.ino ||
after.isSymbolicLink() ||
!after.isDirectory()
) {
throw new Error("runner temporary parent identity changed during staging");
}
const visible = await lstat(visibleStaging);
if (!visible.isDirectory() || visible.isSymbolicLink()) {
throw new Error("promotion staging visibility identity mismatch");
}
assertStagingIdentity(visible, stagingIdentity);
ownsStaging = false;
finalizedPromotion = Object.freeze({
stagingRoot: visibleStaging,
cleanupToken,
runnerTempIdentity: Object.freeze({ dev: before.dev, ino: before.ino }),
stagingIdentity,
files: Object.freeze(
files.map(({ name, sha256: digest }) => Object.freeze({ name, sha256: digest })),
),
});
} catch (error) {
await cleanup(error);
throw error;
}
await cleanup();
return finalizedPromotion;
}
async function captureStagedFiles(
stagingHandle: Awaited<ReturnType<typeof open>>,
declaredFiles: readonly StagedFile[],
): Promise<Readonly<Record<PromotedFileName, Buffer>>> {
const descriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
const names = (await readdir(descriptorRoot)).sort(asciiCompare);
if (
JSON.stringify(names) !==
JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare))
) {
throw new Error("staged promotion seal requires exactly the canonical five files");
}
const declared = new Map(declaredFiles.map((file) => [file.name, file] as const));
const captured = {} as Record<PromotedFileName, Buffer>;
for (const name of PROMOTED_FILE_NAMES) {
const expected = declared.get(name)!;
const handle = await open(
path.join(descriptorRoot, name),
constants.O_RDONLY | constants.O_NOFOLLOW,
);
try {
const before = await handle.stat();
const maxBytes = name === "release-candidate.tar.gz" ? 268_435_456 : 16_777_216;
if (
!before.isFile() ||
before.nlink !== 1 ||
(before.mode & 0o777) !== 0o400 ||
before.size <= 0 ||
before.size > maxBytes
) {
throw new Error(
`staged promotion file must be regular, single-link, bounded, and mode 0400: ${name}`,
);
}
const bytes = await handle.readFile();
const after = await handle.stat();
if (
after.dev !== before.dev ||
after.ino !== before.ino ||
after.size !== before.size ||
after.nlink !== 1 ||
(after.mode & 0o777) !== 0o400 ||
bytes.byteLength !== before.size
) {
throw new Error(`staged promotion file inode or size changed during seal: ${name}`);
}
if (sha256(bytes) !== expected.sha256) {
throw new Error(`staged promotion file digest mismatch during seal: ${name}`);
}
captured[name] = bytes;
} finally {
await handle.close();
}
}
return Object.freeze(captured);
}
function asciiCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function assertStagingIdentity(
metadata: Readonly<{
dev: number;
ino: number;
isDirectory: () => boolean;
isSymbolicLink?: () => boolean;
}>,
expected: Readonly<{ dev: number; ino: number }>,
): void {
if (
metadata.dev !== expected.dev ||
metadata.ino !== expected.ino ||
!metadata.isDirectory() ||
metadata.isSymbolicLink?.()
) {
throw new Error("promotion staging leaf identity changed");
}
}
function assertRunnerTempIdentity(
metadata: Readonly<{ dev: number; ino: number; isDirectory: () => boolean; isSymbolicLink?: () => boolean }>,
expected: Readonly<{ dev: number; ino: number }>,
): void {
if (
metadata.dev !== expected.dev ||
metadata.ino !== expected.ino ||
!metadata.isDirectory() ||
metadata.isSymbolicLink?.()
) {
throw new Error("runner temporary parent identity changed during cleanup");
}
}
function capturedTrust(keyId: string, bytes: Buffer): ProviderTrust {
const publicKey = createPublicKey(
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
);
return Object.freeze({
keyId,
publicKey,
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
});
}
async function capture(root: string, configuredPath: string, maxBytes: number): Promise<Buffer> {
const absolute = path.resolve(root, configuredPath);
const relative = path.relative(root, absolute);
const outside =
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
return readBoundedRegularFile({
root: outside ? path.dirname(absolute) : root,
relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"),
maxBytes,
});
}
function staged(name: PromotedFileName, bytes: Buffer): StagedFile {
return Object.freeze({ name, bytes, sha256: sha256(bytes) });
}
function canonicalJsonBytes(value: unknown): Buffer {
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function parseJson(bytes: Buffer): unknown {
try {
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
} catch {
throw new TypeError("captured provider evidence is not valid UTF-8 JSON");
}
}
function sha256(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
async function syncHandle(handle: Awaited<ReturnType<typeof open>>): Promise<void> {
try {
await handle.sync();
} catch (error) {
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
}
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
+169
View File
@@ -0,0 +1,169 @@
export type ProviderKind = "vulnerability" | "provenance";
const MEMORY_MAX = 1_073_741_824;
const TASKS_MAX = 64;
const STOP_TIMEOUT_MS = 5_000;
const RUNTIME_GRACE_MS = 10_000;
const UNIT_NAME = /^ca-provider-(?:vulnerability|provenance)-[1-9][0-9]*-[0-9a-f]{24}\.scope$/u;
const UNIT_NONCE = /^[0-9a-f]{24}$/u;
const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
const ENFORCEMENT_GATE = [
'cgroup_path=""',
"while IFS=: read -r hierarchy controllers candidate; do",
' if [ "$hierarchy" = 0 ] && [ -z "$controllers" ]; then cgroup_path=$candidate; fi',
"done < /proc/self/cgroup",
'if [ -z "$cgroup_path" ]; then',
" printf '%s\\n' 'provider cgroup enforcement failed: unified cgroup v2 membership is required' >&2",
" exit 125",
"fi",
'case "$cgroup_path" in',
' */"$0") ;;',
" *)",
" printf '%s\\n' 'provider cgroup enforcement failed: unit membership is invalid' >&2",
" exit 125",
" ;;",
"esac",
"cgroup_root=/sys/fs/cgroup$cgroup_path",
"require_cgroup_value() {",
' actual=$(/bin/cat "$cgroup_root/$1") || {',
" printf 'provider cgroup enforcement failed: cannot read %s\\n' \"$1\" >&2",
" exit 125",
" }",
' if [ "$actual" != "$2" ]; then',
" printf 'provider cgroup enforcement failed: %s is %s, expected %s\\n' \"$1\" \"$actual\" \"$2\" >&2",
" exit 125",
" fi",
"}",
`require_cgroup_value memory.max ${MEMORY_MAX}`,
"require_cgroup_value memory.swap.max 0",
`require_cgroup_value pids.max ${TASKS_MAX}`,
"require_cgroup_value cpu.max '100000 100000'",
'exec "$@"',
].join("\n");
export function formatProviderCgroupUnitName(
kind: ProviderKind,
supervisorPid: number,
nonce: string,
): string {
if (!Number.isSafeInteger(supervisorPid) || supervisorPid <= 0 || !UNIT_NONCE.test(nonce)) {
throw new TypeError("provider cgroup unit identity is invalid");
}
const unit = `ca-provider-${kind}-${supervisorPid}-${nonce}.scope`;
assertUnit(unit);
return unit;
}
export function systemdRunProviderArguments(
unit: string,
timeoutMs: number,
cpuSeconds: number,
nodeExecutable: string,
wrapperScript: string,
reportPath: string,
reportDev: number,
reportIno: number,
): string[] {
assertUnit(unit);
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > Number.MAX_SAFE_INTEGER - RUNTIME_GRACE_MS) {
throw new TypeError("provider cgroup runtime is invalid");
}
if (!Number.isSafeInteger(cpuSeconds) || cpuSeconds <= 0) {
throw new TypeError("provider cgroup CPU limit is invalid");
}
if (
!nodeExecutable.startsWith("/") || !wrapperScript.startsWith("/") ||
!reportPath.startsWith("/") || reportPath.includes("\0") ||
!Number.isSafeInteger(reportDev) || reportDev <= 0 ||
!Number.isSafeInteger(reportIno) || reportIno <= 0
) {
throw new TypeError("provider scope wrapper path is invalid");
}
return [
"--user",
"--scope",
"--collect",
"--quiet",
"--expand-environment=no",
`--unit=${unit}`,
`--property=MemoryMax=${MEMORY_MAX}`,
"--property=MemorySwapMax=0",
`--property=TasksMax=${TASKS_MAX}`,
"--property=CPUQuota=100%",
"--property=CPUQuotaPeriodSec=100ms",
"--property=KillMode=control-group",
"--property=SendSIGKILL=yes",
`--property=TimeoutStopSec=${STOP_TIMEOUT_MS}ms`,
`--property=RuntimeMaxSec=${timeoutMs + RUNTIME_GRACE_MS}ms`,
"--",
"/bin/sh",
"-eu",
"-c",
ENFORCEMENT_GATE,
unit,
nodeExecutable,
wrapperScript,
String(cpuSeconds),
reportPath,
String(reportDev),
String(reportIno),
];
}
export type ProviderScopeFrame = Readonly<{
bwrapInput: Buffer;
reportPath: string;
reportDev: number;
reportIno: number;
}>;
export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer {
if (
!Buffer.isBuffer(input.bwrapInput) || input.bwrapInput.byteLength === 0 ||
!input.reportPath.startsWith("/") || input.reportPath.includes("\0") ||
!Number.isSafeInteger(input.reportDev) || input.reportDev <= 0 ||
!Number.isSafeInteger(input.reportIno) || input.reportIno <= 0
) {
throw new TypeError("provider scope frame is invalid");
}
const payload = Buffer.from(JSON.stringify({
bwrapInputBase64: input.bwrapInput.toString("base64"),
reportPath: input.reportPath,
reportDev: input.reportDev,
reportIno: input.reportIno,
}));
const frame = Buffer.allocUnsafe(4 + payload.byteLength);
frame.writeUInt32BE(payload.byteLength, 0);
payload.copy(frame, 4);
return frame;
}
export function encodeProviderBwrapInput(
arguments_: readonly string[],
environment: Readonly<Record<string, string | undefined>>,
): Buffer {
if (arguments_.some((argument) => argument.includes("\0"))) {
throw new TypeError("provider bwrap argument is invalid");
}
const entries = Object.entries(environment).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
);
if (entries.some(([name, value]) =>
!ENVIRONMENT_NAME.test(name) || value === undefined || value.includes("\0")
)) {
throw new TypeError("provider bwrap environment is invalid");
}
const input = ["--clearenv"];
for (const [name, value] of entries) input.push("--setenv", name, value ?? "");
input.push(...arguments_);
return Buffer.from(`${input.join("\0")}\0`);
}
export function systemctlKillProviderArguments(unit: string): string[] {
assertUnit(unit);
return ["--user", "kill", "--kill-whom=all", "--signal=SIGKILL", unit];
}
function assertUnit(unit: string): void {
if (!UNIT_NAME.test(unit)) throw new TypeError("provider cgroup unit name is invalid");
}
+494
View File
@@ -0,0 +1,494 @@
import { createHash, verify, type KeyObject } from "node:crypto";
import { z } from "zod";
import {
canonicalizeSupplyChainValue,
supplyChainDigest,
} from "./supply-chain.ts";
export const PROVIDER_FUTURE_SKEW_MS = 5 * 60 * 1_000;
export const PROVIDER_MAX_LIFETIME_MS = 2 * 60 * 60 * 1_000;
export const PROMOTION_VERIFIER_ID =
"clean-architecture-frontend-template/promotion-verifier";
export const PROMOTION_VERIFIER_VERSION = "3";
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
const fingerprint = z.string().regex(/^sha256:[a-f0-9]{64}$/u);
const revision = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u);
const nonce = z.string().regex(/^[a-f0-9]{64}$/u);
const nonEmptyString = z.string().min(1);
const timestamp = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u)
.refine((value) => new Date(value).toISOString() === value);
const runSchema = z
.object({ id: z.string().min(1).max(128), attempt: z.int().min(1).max(1_000) })
.strict();
const sourceSchema = z
.object({ revision, sourceSetSha256: sha256 })
.strict();
const candidateSchema = z
.object({
archiveSha256: sha256,
bundleSha256: sha256,
distSha256: sha256,
lockfileSha256: sha256,
})
.strict();
const providerRunSchema = runSchema.extend({ invocationNonce: nonce }).strict();
export const secretScanAttestationSchema = z
.object({
status: z.literal("PASS"),
localEvidenceAssessmentSha256: sha256,
sourceSetSha256: sha256,
policySha256: sha256,
sarifSha256: sha256,
scanInputSha256: sha256,
})
.strict();
const signatureSchema = z
.object({
algorithm: z.literal("Ed25519"),
keyId: nonEmptyString,
publicKeyFingerprint: fingerprint,
value: z.string().regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u),
})
.strict();
const providerCommon = {
schemaVersion: z.literal(2),
provider: nonEmptyString,
issuedAt: timestamp,
expiresAt: timestamp,
run: providerRunSchema,
source: sourceSchema,
candidate: candidateSchema,
signature: signatureSchema,
} as const;
export const vulnerabilityProviderReportSchema = z
.object({
...providerCommon,
evidenceType: z.literal("vulnerability-report"),
secretScanAttestation: secretScanAttestationSchema,
findings: z.array(z.record(z.string(), z.json())),
})
.strict();
export const provenanceProviderAttestationSchema = z
.object({
...providerCommon,
evidenceType: z.literal("provenance-attestation"),
signer: nonEmptyString,
subject: z
.object({ name: z.literal("dist"), digest: z.object({ sha256 }).strict() })
.strict(),
})
.strict();
const verificationCommon = {
schemaVersion: z.literal(3),
verifiedAt: timestamp,
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
verifier: z
.object({ id: nonEmptyString, version: nonEmptyString })
.strict(),
run: runSchema,
source: sourceSchema,
candidate: candidateSchema,
providerEvidence: z
.object({
vulnerabilityReportSha256: sha256,
provenanceAttestationSha256: sha256,
vulnerabilityInvocationNonce: nonce,
provenanceInvocationNonce: nonce,
vulnerabilityKeyId: nonEmptyString,
vulnerabilityKeyFingerprint: fingerprint,
provenanceKeyId: nonEmptyString,
provenanceKeyFingerprint: fingerprint,
secretScanAttestation: secretScanAttestationSchema,
})
.strict(),
trustPolicySha256: sha256,
failures: z.array(z.string()),
} as const;
const providerVerificationV3Schema = z
.object({
...verificationCommon,
artifactType: z.literal("provider-verification"),
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
})
.strict();
const promotionVerificationV3Schema = z
.object({
...verificationCommon,
artifactType: z.literal("promotion-verification"),
localEvidenceStatus: z.enum(["PASS", "FAIL"]),
localEvidenceAssessmentSha256: sha256,
providerVerificationSha256: sha256,
})
.strict();
export const providerVerificationArtifactSchema = z
.discriminatedUnion("artifactType", [
providerVerificationV3Schema,
promotionVerificationV3Schema,
])
.superRefine((record, context) => {
const subordinatePass =
record.artifactType === "provider-verification"
? record.vulnerabilityStatus === "PASS" &&
record.provenanceAttestationStatus === "PASS"
: record.localEvidenceStatus === "PASS";
const coherentPass = subordinatePass && record.failures.length === 0;
if ((record.status === "PASS") !== coherentPass) {
context.addIssue({
code: "custom",
path: ["status"],
message: "verification PASS must agree with subordinate statuses and failures",
});
}
if (record.status === "FAIL_UNVERIFIED" && record.failures.length === 0) {
context.addIssue({
code: "custom",
path: ["failures"],
message: "failed verification requires a failure diagnostic",
});
}
});
export type ProviderVerificationArtifactType = z.infer<
typeof providerVerificationArtifactSchema
>["artifactType"];
export type ProviderTrust = Readonly<{
keyId: string;
publicKey: KeyObject;
publicKeyFingerprint: string;
}>;
export type ExpectedPromotionContext = Readonly<{
run: Readonly<{ id: string; attempt: number }>;
source: Readonly<{ revision: string; sourceSetSha256: string }>;
candidate: Readonly<{
archiveSha256: string;
bundleSha256: string;
distSha256: string;
lockfileSha256: string;
}>;
vulnerabilityInvocationNonce: string;
provenanceInvocationNonce: string;
secretScanAttestation: z.infer<typeof secretScanAttestationSchema>;
}>;
export type PromotionEvidenceResult = Readonly<{
status: "PASS" | "FAIL_UNVERIFIED";
vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED";
provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED";
failures: readonly string[];
}>;
export function validateProviderEvidence(input: Readonly<{
kind: "vulnerability" | "provenance";
value: unknown;
expected: ExpectedPromotionContext;
trust: ProviderTrust | null;
nowEpochMs?: () => number;
}>): Readonly<{
evidence: unknown | null;
status: "PASS" | "FAIL_UNVERIFIED";
failures: readonly string[];
}> {
const failures: string[] = [];
const now = (input.nowEpochMs ?? Date.now)();
if (input.kind === "vulnerability") {
const parsed = vulnerabilityProviderReportSchema.safeParse(input.value);
if (!parsed.success) {
return Object.freeze({
evidence: null,
status: "FAIL_UNVERIFIED",
failures: Object.freeze([
"external vulnerability provider report is missing or invalid",
]),
});
}
validateCommonContext(
"vulnerability report",
parsed.data,
input.expected,
input.expected.vulnerabilityInvocationNonce,
input.trust,
now,
failures,
);
if (
JSON.stringify(parsed.data.secretScanAttestation) !==
JSON.stringify(input.expected.secretScanAttestation)
) {
failures.push("vulnerability report secret scan attestation mismatch");
}
if (parsed.data.findings.length > 0) {
failures.push("vulnerability report contains findings");
}
return Object.freeze({
evidence: parsed.data,
status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED",
failures: Object.freeze(failures),
});
}
const parsed = provenanceProviderAttestationSchema.safeParse(input.value);
if (!parsed.success) {
return Object.freeze({
evidence: null,
status: "FAIL_UNVERIFIED",
failures: Object.freeze([
"external signed provenance attestation is missing or invalid",
]),
});
}
validateCommonContext(
"provenance attestation",
parsed.data,
input.expected,
input.expected.provenanceInvocationNonce,
input.trust,
now,
failures,
);
if (parsed.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
failures.push("provenance attestation subject dist digest mismatch");
}
return Object.freeze({
evidence: parsed.data,
status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED",
failures: Object.freeze(failures),
});
}
export function providerEvidenceSignaturePayload(value: unknown): Buffer {
if (!isRecord(value)) return Buffer.from("null", "utf8");
const { signature: _signature, ...payload } = value;
return Buffer.from(JSON.stringify(canonicalizeSupplyChainValue(payload)), "utf8");
}
export function providerPublicKeyFingerprint(publicKey: KeyObject): string {
if (publicKey.asymmetricKeyType !== "ed25519") {
throw new TypeError("provider trust key must be Ed25519");
}
return `sha256:${createHash("sha256")
.update(publicKey.export({ type: "spki", format: "der" }))
.digest("hex")}`;
}
export function createTrustPolicy(input: Readonly<{
vulnerabilityTrust: ProviderTrust;
provenanceTrust: ProviderTrust;
}>) {
assertDistinctProviderTrust(input);
return Object.freeze({
algorithm: "Ed25519" as const,
vulnerability: Object.freeze({
keyId: input.vulnerabilityTrust.keyId,
publicKeyFingerprint: input.vulnerabilityTrust.publicKeyFingerprint,
}),
provenance: Object.freeze({
keyId: input.provenanceTrust.keyId,
publicKeyFingerprint: input.provenanceTrust.publicKeyFingerprint,
}),
issuedAtFutureSkewMs: PROVIDER_FUTURE_SKEW_MS,
maximumLifetimeMs: PROVIDER_MAX_LIFETIME_MS,
});
}
export function assertDistinctProviderTrust(input: Readonly<{
vulnerabilityTrust: ProviderTrust;
provenanceTrust: ProviderTrust;
}>): void {
const vulnerabilityFingerprint = providerPublicKeyFingerprint(
input.vulnerabilityTrust.publicKey,
);
const provenanceFingerprint = providerPublicKeyFingerprint(
input.provenanceTrust.publicKey,
);
if (
input.vulnerabilityTrust.keyId === input.provenanceTrust.keyId ||
vulnerabilityFingerprint === provenanceFingerprint ||
input.vulnerabilityTrust.publicKeyFingerprint ===
input.provenanceTrust.publicKeyFingerprint
) {
throw new TypeError("provider trust roles require distinct key identities and DER-SPKI fingerprints");
}
}
export function trustPolicySha256(input: Readonly<{
vulnerabilityTrust: ProviderTrust;
provenanceTrust: ProviderTrust;
}>): string {
return supplyChainDigest(createTrustPolicy(input));
}
export function evaluatePromotionEvidence(input: Readonly<{
expected: ExpectedPromotionContext;
localStatus: unknown;
vulnerabilityReport: unknown;
provenanceAttestation: unknown;
vulnerabilityTrust: ProviderTrust | null;
provenanceTrust: ProviderTrust | null;
nowEpochMs?: () => number;
}>): PromotionEvidenceResult {
const failures: string[] = [];
if (input.localStatus !== "PASS") {
failures.push("local supply-chain evidence is not PASS");
}
const now = (input.nowEpochMs ?? Date.now)();
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
const vulnerability = vulnerabilityProviderReportSchema.safeParse(
input.vulnerabilityReport,
);
if (!vulnerability.success) {
failures.push("external vulnerability provider report is missing or invalid");
} else {
const before = failures.length;
validateCommonContext(
"vulnerability report",
vulnerability.data,
input.expected,
input.expected.vulnerabilityInvocationNonce,
input.vulnerabilityTrust,
now,
failures,
);
if (
JSON.stringify(vulnerability.data.secretScanAttestation) !==
JSON.stringify(input.expected.secretScanAttestation)
) {
failures.push("vulnerability report secret scan attestation mismatch");
}
if (vulnerability.data.findings.length > 0) {
failures.push("vulnerability report contains findings");
}
if (failures.length === before && input.localStatus === "PASS") {
vulnerabilityStatus = "PASS";
}
}
const provenance = provenanceProviderAttestationSchema.safeParse(
input.provenanceAttestation,
);
if (!provenance.success) {
failures.push("external signed provenance attestation is missing or invalid");
} else {
const before = failures.length;
validateCommonContext(
"provenance attestation",
provenance.data,
input.expected,
input.expected.provenanceInvocationNonce,
input.provenanceTrust,
now,
failures,
);
if (provenance.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
failures.push("provenance attestation subject dist digest mismatch");
}
if (failures.length === before && input.localStatus === "PASS") {
provenanceAttestationStatus = "PASS";
}
}
return Object.freeze({
status:
failures.length === 0 &&
vulnerabilityStatus === "PASS" &&
provenanceAttestationStatus === "PASS"
? "PASS"
: "FAIL_UNVERIFIED",
vulnerabilityStatus,
provenanceAttestationStatus,
failures: Object.freeze(failures),
});
}
function validateCommonContext(
label: "vulnerability report" | "provenance attestation",
evidence: z.infer<
| typeof vulnerabilityProviderReportSchema
| typeof provenanceProviderAttestationSchema
>,
expected: ExpectedPromotionContext,
expectedNonce: string,
trust: ProviderTrust | null,
now: number,
failures: string[],
): void {
if (
evidence.run.id !== expected.run.id ||
evidence.run.attempt !== expected.run.attempt
) {
failures.push(`${label} run identity mismatch`);
}
if (evidence.run.invocationNonce !== expectedNonce) {
failures.push(`${label} invocation nonce mismatch`);
}
if (
evidence.source.revision !== expected.source.revision ||
evidence.source.sourceSetSha256 !== expected.source.sourceSetSha256
) {
failures.push(`${label} source identity mismatch`);
}
if (JSON.stringify(evidence.candidate) !== JSON.stringify(expected.candidate)) {
failures.push(`${label} candidate identity mismatch`);
}
validateEvidenceTime(label, evidence.issuedAt, evidence.expiresAt, now, failures);
if (
!trust ||
evidence.signature.keyId !== trust.keyId ||
evidence.signature.publicKeyFingerprint !== trust.publicKeyFingerprint
) {
failures.push(`${label} trust identity mismatch`);
return;
}
try {
if (
providerPublicKeyFingerprint(trust.publicKey) !== trust.publicKeyFingerprint ||
!verify(
null,
providerEvidenceSignaturePayload(evidence),
trust.publicKey,
Buffer.from(evidence.signature.value, "base64"),
)
) {
failures.push(`${label} signature verification failed`);
}
} catch {
failures.push(`${label} signature verification failed`);
}
}
function validateEvidenceTime(
label: string,
issuedAt: string,
expiresAt: string,
now: number,
failures: string[],
): void {
const issued = Date.parse(issuedAt);
const expires = Date.parse(expiresAt);
if (issued > now + PROVIDER_FUTURE_SKEW_MS) {
failures.push(`${label} issuedAt exceeds allowed future skew`);
}
if (expires <= now) failures.push(`${label} is expired`);
if (expires <= issued) failures.push(`${label} validity window is not positive`);
if (expires - issued > PROVIDER_MAX_LIFETIME_MS) {
failures.push(`${label} validity window exceeds two hours`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
+763
View File
@@ -0,0 +1,763 @@
import { spawn } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { constants } from "node:fs";
import { lstat, open, type FileHandle } from "node:fs/promises";
import path from "node:path";
import {
decodeProviderGuardianPublished,
decodeProviderGuardianReady,
encodeProviderGuardianCommit,
encodeProviderGuardianGuard,
encodeProviderGuardianPublish,
MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES,
MAX_PROVIDER_GUARDIAN_LEASE_MS,
MAX_PROVIDER_SEALED_BYTES,
providerGuardianRawStagingLeaf,
providerGuardianSealedTempLeaf,
type ProviderGuardianKind,
} from "./provider-guardian-protocol.ts";
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
const RESPONSE_TIMEOUT_MS = 5_000;
const CLOSE_TIMEOUT_MS = 5_000;
const MAX_CONTROL_OUTPUT_BYTES = 4_096;
type OwnedIdentity = Readonly<{ dev: number; ino: number }>;
type RecoveryAuthority = Readonly<{
rawDirectoryHandle: FileHandle;
evidenceDirectoryHandle: FileHandle;
rawStagingHandle: FileHandle;
sealedTempHandle: FileHandle;
rawIdentity: OwnedIdentity;
sealedIdentity: OwnedIdentity;
rawStagingPinnedPath: string;
rawPinnedPath: string;
sealedTempPinnedPath: string;
sealedPinnedPath: string;
}>;
export type ProviderGuardianLease = Readonly<{
pid: number;
rawPath: string;
rawIdentity: OwnedIdentity;
sealedPath: string;
sealedTempPath: string;
sealedIdentity: OwnedIdentity;
prematureExit: Promise<Error>;
publish(bytes: Buffer): Promise<void>;
commit(): Promise<void>;
abort(): Promise<void>;
}>;
export type StartProviderGuardianInput = Readonly<{
kind: ProviderGuardianKind;
workspaceRoot: string;
leaseMs: number;
guardianScript: string;
}>;
export type ProviderScopeGuardianLatch = Readonly<{
activeFailure: Promise<Error>;
close(): Promise<void>;
failure(): Error | undefined;
}>;
export function createProviderScopeGuardianLatch(
guardianExit: Promise<Error>,
): ProviderScopeGuardianLatch {
let active = true;
let closing: Promise<void> | undefined;
let observedFailure: Error | undefined;
let signalActiveFailure!: (error: Error) => void;
const activeFailure = new Promise<Error>((resolve) => { signalActiveFailure = resolve; });
void guardianExit.then((error) => {
observedFailure = error;
if (active) signalActiveFailure(error);
});
return Object.freeze({
activeFailure,
close: () => {
closing ??= Promise.resolve().then(() => { active = false; });
return closing;
},
failure: () => observedFailure,
});
}
export function assertProviderGuardianLeasePaths(
lease: Readonly<{ rawPath: string; sealedPath: string }>,
expected: Readonly<{ rawPath: string; sealedPath: string }>,
): void {
if (lease.rawPath !== expected.rawPath) {
throw new Error("provider guardian returned a noncanonical raw path");
}
if (lease.sealedPath !== expected.sealedPath) {
throw new Error("provider guardian returned a noncanonical sealed path");
}
}
type GuardianResult = Readonly<{
code: number | null;
error?: Error;
signal: NodeJS.Signals | null;
}>;
export async function startProviderGuardian(
input: StartProviderGuardianInput,
): Promise<ProviderGuardianLease> {
if (
(input.kind !== "vulnerability" && input.kind !== "provenance") ||
!path.isAbsolute(input.workspaceRoot) || !path.isAbsolute(input.guardianScript) ||
!Number.isSafeInteger(input.leaseMs) || input.leaseMs <= 0 ||
input.leaseMs > MAX_PROVIDER_GUARDIAN_LEASE_MS
) {
throw new TypeError("provider guardian client input is invalid");
}
const rawLeaf = input.kind === "vulnerability"
? "vulnerability-report.json"
: "provenance-attestation.json";
const evidenceRoot = path.resolve(input.workspaceRoot, "provider-evidence");
const rawDirectory = path.join(evidenceRoot, "untrusted");
const rawPath = path.join(evidenceRoot, "untrusted", rawLeaf);
const sealedPath = path.join(evidenceRoot, rawLeaf);
const nonce = randomBytes(32);
const rawStagingLeaf = providerGuardianRawStagingLeaf(input.kind, nonce);
const sealedTempLeaf = providerGuardianSealedTempLeaf(input.kind, nonce);
const sealedTempPath = path.join(evidenceRoot, sealedTempLeaf);
const recovery = await openRecoveryAuthority({
rawDirectory,
evidenceRoot,
rawLeaf,
rawStagingLeaf,
sealedLeaf: rawLeaf,
sealedTempLeaf,
});
let child: ReturnType<typeof spawn>;
try {
await assertRecoveryLeavesMissing(recovery);
child = spawn(process.execPath, [input.guardianScript], {
cwd: input.workspaceRoot,
env: {},
stdio: [
"pipe",
"pipe",
"pipe",
recovery.rawDirectoryHandle.fd,
recovery.evidenceDirectoryHandle.fd,
recovery.rawStagingHandle.fd,
recovery.sealedTempHandle.fd,
],
});
} catch (error) {
return await closeRecoveryAndThrow(recovery, error);
}
if (!child.pid || !child.stdin || !child.stdout || !child.stderr) {
child.kill("SIGKILL");
return await closeRecoveryAndThrow(
recovery,
new Error("provider guardian process pipes are unavailable"),
);
}
let state: "starting" | "guarding" | "publishing" | "published" |
"committing" | "aborting" | "terminated" = "starting";
let stderr = Buffer.alloc(0);
let inputError: Error | undefined;
child.stdin.once("error", (error) => { inputError = error; });
child.stderr.on("data", (chunk: Buffer | string) => {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (stderr.byteLength < MAX_CONTROL_OUTPUT_BYTES) {
stderr = Buffer.concat([stderr, bytes.subarray(0, MAX_CONTROL_OUTPUT_BYTES - stderr.byteLength)]);
}
});
const completion = guardianCompletion(child);
let signalPrematureExit!: (error: Error) => void;
const prematureExit = new Promise<Error>((resolve) => { signalPrematureExit = resolve; });
void completion.then((result) => {
if (state === "guarding" || state === "publishing" || state === "published") {
signalPrematureExit(guardianCloseError(result, stderr));
}
});
let ready: ReturnType<typeof decodeProviderGuardianReady>;
try {
const readyResponse = waitForFrame(child.stdout, completion, "READY");
child.stdin.write(encodeProviderGuardianGuard({
kind: input.kind,
nonce,
deadlineEpochMs: Date.now() + input.leaseMs,
}));
ready = decodeProviderGuardianReady(await readyResponse, nonce);
if (
ready.sealedTempLeaf !== sealedTempLeaf ||
ready.rawDev !== recovery.rawIdentity.dev ||
ready.rawIno !== recovery.rawIdentity.ino ||
ready.sealedDev !== recovery.sealedIdentity.dev ||
ready.sealedIno !== recovery.sealedIdentity.ino
) {
throw new TypeError("provider guardian READY identity is invalid for its allocation");
}
await assertPinnedLeafIdentity(recovery.rawPinnedPath, {
dev: ready.rawDev,
ino: ready.rawIno,
}, 0o600);
await assertPinnedLeafIdentity(recovery.sealedTempPinnedPath, {
dev: ready.sealedDev,
ino: ready.sealedIno,
}, 0o600);
await assertPinnedLeafMissing(recovery.rawStagingPinnedPath);
if (child.exitCode !== null || child.signalCode !== null) {
throw guardianCloseError(await completion, stderr);
}
state = "guarding";
} catch (error) {
state = "aborting";
child.stdin.end();
const failures = [toError(error)];
try {
await waitForClose(completion, child);
} catch (closeError) {
failures.push(toError(closeError));
}
try {
await cleanupStartupRecovery(recovery);
} catch (cleanupError) {
failures.push(toError(cleanupError));
}
try {
await closeRecoveryAuthority(recovery);
} catch (closeError) {
failures.push(toError(closeError));
}
state = "terminated";
if (failures.length > 1) {
throw new AggregateError(failures, "provider guardian startup failed", { cause: error });
}
throw failures[0]!;
}
const rawIdentity = Object.freeze({ dev: ready.rawDev, ino: ready.rawIno });
const sealedIdentity = Object.freeze({ dev: ready.sealedDev, ino: ready.sealedIno });
const fallback = Object.freeze({
rawStagingPath: recovery.rawStagingPinnedPath,
rawPath: recovery.rawPinnedPath,
rawIdentity,
sealedPath: recovery.sealedPinnedPath,
sealedTempPath: recovery.sealedTempPinnedPath,
sealedIdentity,
});
const publish = async (bytes: Buffer): Promise<void> => {
if (state !== "guarding") throw new Error("provider guardian lease is not ready to publish");
if (!Buffer.isBuffer(bytes) || bytes.byteLength <= 0 || bytes.byteLength > MAX_PROVIDER_SEALED_BYTES) {
throw new TypeError("provider guardian sealed bytes are invalid");
}
state = "publishing";
try {
await writePinnedSealedBytes(recovery.sealedTempHandle, sealedIdentity, bytes);
const publishedResponse = waitForFrame(child.stdout!, completion, "PUBLISHED");
child.stdin!.write(encodeProviderGuardianPublish({
nonce,
sealedDev: sealedIdentity.dev,
sealedIno: sealedIdentity.ino,
size: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"),
}));
decodeProviderGuardianPublished(
await publishedResponse,
nonce,
sealedIdentity,
);
if (inputError) throw inputError;
state = "published";
} catch (error) {
state = "guarding";
throw error;
}
};
const commit = async (): Promise<void> => {
if (state !== "published") throw new Error("provider guardian lease is not ready to commit");
state = "committing";
child.stdin!.write(encodeProviderGuardianCommit(nonce));
child.stdin!.end();
let result: GuardianResult;
try {
result = await waitForClose(completion, child);
} catch (error) {
state = "terminated";
return await cleanupFallbackCloseAndThrow(fallback, recovery, error);
}
state = "terminated";
if (inputError) return await cleanupFallbackCloseAndThrow(fallback, recovery, inputError);
if (result.error || result.code !== 0 || result.signal !== null) {
return await cleanupFallbackCloseAndThrow(
fallback,
recovery,
guardianCloseError(result, stderr),
);
}
await closeRecoveryAuthority(recovery);
};
const abort = async (): Promise<void> => {
if (state !== "guarding" && state !== "published") {
throw new Error("provider guardian lease already terminated");
}
state = "aborting";
child.stdin!.end();
let closeError: unknown;
try {
await waitForClose(completion, child);
} catch (error) {
closeError = error;
}
state = "terminated";
if (closeError) return await cleanupFallbackCloseAndThrow(fallback, recovery, closeError);
await cleanupFallbackAndClose(fallback, recovery);
};
return Object.freeze({
pid: child.pid,
rawPath,
rawIdentity,
sealedPath,
sealedTempPath,
sealedIdentity,
prematureExit,
publish,
commit,
abort,
});
}
async function writePinnedSealedBytes(
handle: FileHandle,
identity: OwnedIdentity,
bytes: Buffer,
): Promise<void> {
assertPinnedMetadata(await handle.stat(), identity, 0o600, 0);
await handle.truncate(0);
await handle.writeFile(bytes);
await handle.chmod(0o400);
await handle.sync();
assertPinnedMetadata(await handle.stat(), identity, 0o400, bytes.byteLength);
}
function assertPinnedMetadata(
metadata: Awaited<ReturnType<Awaited<ReturnType<typeof open>>["stat"]>>,
identity: OwnedIdentity,
mode: number,
size: number,
): void {
if (
!metadata.isFile() || Number(metadata.dev) !== identity.dev ||
Number(metadata.ino) !== identity.ino || Number(metadata.nlink) !== 1 ||
(Number(metadata.mode) & 0o777) !== mode || Number(metadata.size) !== size
) {
throw new TypeError("provider guardian sealed temp identity changed");
}
}
function guardianCompletion(child: ReturnType<typeof spawn>): Promise<GuardianResult> {
return new Promise((resolve) => {
child.once("error", (error) => resolve({ code: null, error, signal: null }));
child.once("close", (code, signal) => resolve({ code, signal }));
});
}
async function waitForFrame(
stdout: NodeJS.ReadableStream,
completion: Promise<GuardianResult>,
label: string,
): Promise<Buffer> {
let timer: NodeJS.Timeout | undefined;
let pending = Buffer.alloc(0);
const response = new Promise<Buffer>((resolve, reject) => {
const onData = (chunk: Buffer | string): void => {
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
if (pending.byteLength > MAX_CONTROL_OUTPUT_BYTES) {
reject(new Error(`provider guardian ${label} output exceeded its bound`));
return;
}
if (pending.byteLength < 4) return;
const payloadBytes = pending.readUInt32BE(0);
if (payloadBytes <= 0 || payloadBytes > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
reject(new Error(`provider guardian ${label} frame length is invalid`));
return;
}
if (pending.byteLength < payloadBytes + 4) return;
if (pending.byteLength !== payloadBytes + 4) {
reject(new Error(`provider guardian ${label} output has trailing bytes`));
return;
}
resolve(pending.subarray(4));
};
stdout.on("data", onData);
});
try {
return await Promise.race([
response,
completion.then((result) => { throw guardianCloseError(result, Buffer.alloc(0)); }),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error(`provider guardian ${label} timed out`)),
RESPONSE_TIMEOUT_MS,
);
}),
]);
} finally {
if (timer) clearTimeout(timer);
stdout.removeAllListeners("data");
}
}
async function waitForClose(
completion: Promise<GuardianResult>,
child: ReturnType<typeof spawn>,
): Promise<GuardianResult> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
completion,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("provider guardian did not close within its bound"));
}, CLOSE_TIMEOUT_MS);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
type FallbackIdentity = Readonly<{
rawStagingPath: string;
rawPath: string;
rawIdentity: OwnedIdentity;
sealedPath: string;
sealedTempPath: string;
sealedIdentity: OwnedIdentity;
}>;
async function cleanupFallback(input: FallbackIdentity): Promise<void> {
const failures: Error[] = [];
for (const target of [
{ path: input.rawStagingPath, identity: input.rawIdentity },
{ path: input.rawPath, identity: input.rawIdentity },
{ path: input.sealedTempPath, identity: input.sealedIdentity },
{ path: input.sealedPath, identity: input.sealedIdentity },
]) {
try {
await cleanupOwnedProviderReport({
reportPath: target.path,
reportDev: target.identity.dev,
reportIno: target.identity.ino,
});
} catch (error) {
failures.push(toError(error));
}
}
if (failures.length > 0) {
throw new AggregateError(failures, "provider guardian fallback cleanup failed", {
cause: failures[0],
});
}
}
async function cleanupFallbackCloseAndThrow(
fallback: FallbackIdentity,
recovery: RecoveryAuthority,
primaryError: unknown,
): Promise<never> {
const failures = [toError(primaryError)];
try {
await cleanupFallback(fallback);
} catch (cleanupError) {
failures.push(toError(cleanupError));
}
try {
await closeRecoveryAuthority(recovery);
} catch (closeError) {
failures.push(toError(closeError));
}
if (failures.length > 1) {
throw new AggregateError(failures, "provider guardian failure and recovery failed", {
cause: failures[0],
});
}
throw failures[0]!;
}
async function cleanupFallbackAndClose(
fallback: FallbackIdentity,
recovery: RecoveryAuthority,
): Promise<void> {
const failures: Error[] = [];
try {
await cleanupFallback(fallback);
} catch (cleanupError) {
failures.push(toError(cleanupError));
}
try {
await closeRecoveryAuthority(recovery);
} catch (closeError) {
failures.push(toError(closeError));
}
if (failures.length > 0) {
throw new AggregateError(failures, "provider guardian abort recovery failed", {
cause: failures[0],
});
}
}
async function openRecoveryAuthority(input: Readonly<{
rawDirectory: string;
evidenceRoot: string;
rawLeaf: string;
rawStagingLeaf: string;
sealedLeaf: string;
sealedTempLeaf: string;
}>): Promise<RecoveryAuthority> {
let rawDirectoryHandle: FileHandle | undefined;
let evidenceDirectoryHandle: FileHandle | undefined;
let rawStagingHandle: FileHandle | undefined;
let sealedTempHandle: FileHandle | undefined;
let rawIdentity: OwnedIdentity | undefined;
let sealedIdentity: OwnedIdentity | undefined;
let rawStagingPinnedPath: string | undefined;
let rawPinnedPath: string | undefined;
let sealedTempPinnedPath: string | undefined;
let sealedPinnedPath: string | undefined;
try {
rawDirectoryHandle = await open(
input.rawDirectory,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
await assertPinnedDirectory(rawDirectoryHandle, input.rawDirectory, "raw");
evidenceDirectoryHandle = await open(
input.evidenceRoot,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
await assertPinnedDirectory(evidenceDirectoryHandle, input.evidenceRoot, "evidence");
rawStagingPinnedPath =
`/proc/self/fd/${rawDirectoryHandle.fd}/${input.rawStagingLeaf}`;
rawPinnedPath = `/proc/self/fd/${rawDirectoryHandle.fd}/${input.rawLeaf}`;
sealedTempPinnedPath =
`/proc/self/fd/${evidenceDirectoryHandle.fd}/${input.sealedTempLeaf}`;
sealedPinnedPath = `/proc/self/fd/${evidenceDirectoryHandle.fd}/${input.sealedLeaf}`;
rawStagingHandle = await open(
rawStagingPinnedPath,
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
);
const rawMetadata = await rawStagingHandle.stat();
rawIdentity = Object.freeze({ dev: rawMetadata.dev, ino: rawMetadata.ino });
assertAllocatedPrivateMetadata(rawMetadata, rawIdentity, "raw staging");
await assertPinnedLeafIdentity(rawStagingPinnedPath, rawIdentity, 0o600);
sealedTempHandle = await open(
sealedTempPinnedPath,
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
);
const sealedMetadata = await sealedTempHandle.stat();
sealedIdentity = Object.freeze({ dev: sealedMetadata.dev, ino: sealedMetadata.ino });
assertAllocatedPrivateMetadata(sealedMetadata, sealedIdentity, "sealed temp");
await assertPinnedLeafIdentity(sealedTempPinnedPath, sealedIdentity, 0o600);
return Object.freeze({
rawDirectoryHandle,
evidenceDirectoryHandle,
rawStagingHandle,
sealedTempHandle,
rawIdentity,
sealedIdentity,
rawStagingPinnedPath,
rawPinnedPath,
sealedTempPinnedPath,
sealedPinnedPath,
});
} catch (error) {
const failures = [toError(error)];
for (const target of [
{ path: rawStagingPinnedPath, identity: rawIdentity },
{ path: rawPinnedPath, identity: rawIdentity },
{ path: sealedTempPinnedPath, identity: sealedIdentity },
{ path: sealedPinnedPath, identity: sealedIdentity },
]) {
if (!target.path || !target.identity) continue;
try {
await cleanupOwnedProviderReport({
reportPath: target.path,
reportDev: target.identity.dev,
reportIno: target.identity.ino,
});
} catch (cleanupError) {
failures.push(toError(cleanupError));
}
}
for (const handle of [
sealedTempHandle,
rawStagingHandle,
evidenceDirectoryHandle,
rawDirectoryHandle,
]) {
if (!handle) continue;
try { await handle.close(); } catch (closeError) { failures.push(toError(closeError)); }
}
if (failures.length > 1) {
throw new AggregateError(failures, "provider guardian recovery setup failed", {
cause: error,
});
}
throw failures[0]!;
}
}
function assertAllocatedPrivateMetadata(
metadata: Awaited<ReturnType<FileHandle["stat"]>>,
identity: OwnedIdentity,
label: string,
): void {
if (
!metadata.isFile() || Number(metadata.dev) !== identity.dev ||
Number(metadata.ino) !== identity.ino || Number(metadata.nlink) !== 1 ||
(Number(metadata.mode) & 0o777) !== 0o600 || Number(metadata.size) !== 0
) {
throw new TypeError(`provider guardian ${label} allocation is invalid`);
}
}
async function assertPinnedDirectory(
handle: FileHandle,
canonicalPath: string,
label: string,
): Promise<void> {
const [descriptorMetadata, pathMetadata] = await Promise.all([
handle.stat(),
lstat(canonicalPath),
]);
if (
!descriptorMetadata.isDirectory() || !pathMetadata.isDirectory() ||
pathMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathMetadata.dev ||
descriptorMetadata.ino !== pathMetadata.ino
) {
throw new TypeError(`provider guardian ${label} recovery directory identity changed`);
}
}
async function assertRecoveryLeavesMissing(recovery: RecoveryAuthority): Promise<void> {
await assertPinnedLeafMissing(recovery.rawPinnedPath);
await assertPinnedLeafMissing(recovery.sealedPinnedPath);
assertAllocatedPrivateMetadata(
await recovery.rawStagingHandle.stat(),
recovery.rawIdentity,
"raw staging",
);
assertAllocatedPrivateMetadata(
await recovery.sealedTempHandle.stat(),
recovery.sealedIdentity,
"sealed temp",
);
await assertPinnedLeafIdentity(
recovery.rawStagingPinnedPath,
recovery.rawIdentity,
0o600,
);
await assertPinnedLeafIdentity(
recovery.sealedTempPinnedPath,
recovery.sealedIdentity,
0o600,
);
}
async function assertPinnedLeafMissing(target: string): Promise<void> {
try {
await lstat(target);
throw new Error("provider guardian transaction leaf already exists");
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
}
}
async function assertPinnedLeafIdentity(
target: string,
identity: OwnedIdentity,
mode: number,
): Promise<void> {
const metadata = await lstat(target);
if (
!metadata.isFile() || metadata.isSymbolicLink() || metadata.dev !== identity.dev ||
metadata.ino !== identity.ino || metadata.nlink !== 1 ||
(metadata.mode & 0o777) !== mode || metadata.size !== 0
) {
throw new TypeError("provider guardian READY identity changed");
}
}
async function cleanupStartupRecovery(recovery: RecoveryAuthority): Promise<void> {
await cleanupFallback({
rawStagingPath: recovery.rawStagingPinnedPath,
rawPath: recovery.rawPinnedPath,
rawIdentity: recovery.rawIdentity,
sealedTempPath: recovery.sealedTempPinnedPath,
sealedPath: recovery.sealedPinnedPath,
sealedIdentity: recovery.sealedIdentity,
});
}
async function closeRecoveryAuthority(recovery: RecoveryAuthority): Promise<void> {
const failures: Error[] = [];
for (const handle of [
recovery.rawStagingHandle,
recovery.sealedTempHandle,
recovery.rawDirectoryHandle,
recovery.evidenceDirectoryHandle,
]) {
try { await handle.close(); } catch (error) { failures.push(toError(error)); }
}
if (failures.length > 0) {
throw new AggregateError(failures, "provider guardian recovery directory close failed", {
cause: failures[0],
});
}
}
async function closeRecoveryAndThrow(
recovery: RecoveryAuthority,
primaryError: unknown,
): Promise<never> {
const failures = [toError(primaryError)];
try {
await cleanupStartupRecovery(recovery);
} catch (cleanupError) {
failures.push(toError(cleanupError));
}
try {
await closeRecoveryAuthority(recovery);
} catch (closeError) {
failures.push(toError(closeError));
}
if (failures.length > 1) {
throw new AggregateError(failures,
"provider guardian failure and recovery close failed", { cause: failures[0] });
}
throw failures[0]!;
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
function guardianCloseError(result: GuardianResult, stderr: Buffer): Error {
if (result.error) return result.error;
const detail = stderr.toString("utf8").trim();
return new Error(
`provider guardian failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}${detail ? `, output=${detail}` : ""}`,
);
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
+396
View File
@@ -0,0 +1,396 @@
import { timingSafeEqual } from "node:crypto";
export type ProviderGuardianKind = "vulnerability" | "provenance";
export const MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES = 4_092;
export const MAX_PROVIDER_GUARDIAN_LEASE_MS = 40 * 60 * 1_000;
export const MAX_PROVIDER_SEALED_BYTES = 8_388_608;
const V2_GUARD_KEYS = ["type", "version", "kind", "nonce", "deadlineEpochMs"] as const;
const READY_KEYS = [
"type", "version", "nonce", "rawDev", "rawIno",
"sealedTempLeaf", "sealedDev", "sealedIno",
] as const;
const PUBLISH_KEYS = [
"type", "version", "nonce", "sealedDev", "sealedIno", "size", "sha256",
] as const;
const PUBLISHED_KEYS = ["type", "version", "nonce", "sealedDev", "sealedIno"] as const;
const COMMIT_KEYS = ["type", "version", "nonce"] as const;
export type ProviderGuardianGuard = Readonly<{
kind: ProviderGuardianKind;
nonce: Buffer;
deadlineEpochMs: number;
}>;
export type ProviderGuardianReady = Readonly<{
nonce: Buffer;
rawDev: number;
rawIno: number;
sealedTempLeaf: string;
sealedDev: number;
sealedIno: number;
}>;
export type ProviderGuardianPublish = Readonly<{
nonce: Buffer;
sealedDev: number;
sealedIno: number;
size: number;
sha256: string;
}>;
export type ProviderGuardianPublished = Readonly<{
nonce: Buffer;
sealedDev: number;
sealedIno: number;
}>;
export function providerGuardianSealedTempLeaf(
kind: ProviderGuardianKind,
nonce: Buffer,
): string {
const rawLeaf = baseLeaf(kind);
assertV2Nonce(nonce);
return `.${rawLeaf}.guardian-${nonce.subarray(0, 16).toString("hex")}.tmp`;
}
export function providerGuardianRawStagingLeaf(
kind: ProviderGuardianKind,
nonce: Buffer,
): string {
const rawLeaf = baseLeaf(kind);
assertV2Nonce(nonce);
return `.${rawLeaf}.guardian-${nonce.subarray(0, 16).toString("hex")}.raw.tmp`;
}
export function encodeProviderGuardianGuard(input: ProviderGuardianGuard): Buffer {
assertV2Guard(input);
return prefixFrame(encodeV2GuardPayload(input));
}
export function decodeProviderGuardianGuard(
payload: Buffer,
options: Readonly<{ nowEpochMs: number; maxLeaseMs: number }>,
): ProviderGuardianGuard {
const value = parseRecord(payload, V2_GUARD_KEYS, "guard");
const guard: ProviderGuardianGuard = {
kind: value.kind as ProviderGuardianKind,
nonce: parseV2Nonce(value.nonce),
deadlineEpochMs: value.deadlineEpochMs as number,
};
if (value.type !== "guard" || value.version !== 2) {
throw new TypeError("provider guardian guard version is invalid");
}
assertV2Guard(guard);
if (
!Number.isSafeInteger(options.nowEpochMs) ||
!Number.isSafeInteger(options.maxLeaseMs) || options.maxLeaseMs <= 0 ||
guard.deadlineEpochMs <= options.nowEpochMs ||
guard.deadlineEpochMs > options.nowEpochMs + options.maxLeaseMs
) {
throw new TypeError("provider guardian guard deadline is invalid");
}
assertCanonical(payload, encodeV2GuardPayload(guard), "guard");
return guard;
}
export function encodeProviderGuardianReady(input: ProviderGuardianReady): Buffer {
assertReady(input);
return prefixFrame(encodeReadyPayload(input));
}
export function decodeProviderGuardianReady(
payload: Buffer,
expectedNonce: Buffer,
): ProviderGuardianReady {
assertV2Nonce(expectedNonce);
const value = parseRecord(payload, READY_KEYS, "READY");
const ready: ProviderGuardianReady = {
nonce: parseV2Nonce(value.nonce),
rawDev: value.rawDev as number,
rawIno: value.rawIno as number,
sealedTempLeaf: value.sealedTempLeaf as string,
sealedDev: value.sealedDev as number,
sealedIno: value.sealedIno as number,
};
if (value.type !== "ready" || value.version !== 2) {
throw new TypeError("provider guardian READY version is invalid");
}
assertReady(ready);
assertAuthenticatedNonce(ready.nonce, expectedNonce, "READY");
assertCanonical(payload, encodeReadyPayload(ready), "READY");
return ready;
}
export function encodeProviderGuardianPublish(input: ProviderGuardianPublish): Buffer {
assertPublish(input);
return prefixFrame(encodePublishPayload(input));
}
export function decodeProviderGuardianPublish(
payload: Buffer,
expectedNonce: Buffer,
): ProviderGuardianPublish {
assertV2Nonce(expectedNonce);
const value = parseRecord(payload, PUBLISH_KEYS, "publish");
const publish: ProviderGuardianPublish = {
nonce: parseV2Nonce(value.nonce),
sealedDev: value.sealedDev as number,
sealedIno: value.sealedIno as number,
size: value.size as number,
sha256: value.sha256 as string,
};
if (value.type !== "publish" || value.version !== 2) {
throw new TypeError("provider guardian publish version is invalid");
}
assertPublish(publish);
assertAuthenticatedNonce(publish.nonce, expectedNonce, "publish");
assertCanonical(payload, encodePublishPayload(publish), "publish");
return publish;
}
export function encodeProviderGuardianPublished(input: ProviderGuardianPublished): Buffer {
assertPublished(input);
return prefixFrame(encodePublishedPayload(input));
}
export function decodeProviderGuardianPublished(
payload: Buffer,
expectedNonce: Buffer,
expectedIdentity: Readonly<{ dev: number; ino: number }>,
): void {
assertV2Nonce(expectedNonce);
const value = parseRecord(payload, PUBLISHED_KEYS, "PUBLISHED");
const published: ProviderGuardianPublished = {
nonce: parseV2Nonce(value.nonce),
sealedDev: value.sealedDev as number,
sealedIno: value.sealedIno as number,
};
if (value.type !== "published" || value.version !== 2) {
throw new TypeError("provider guardian PUBLISHED version is invalid");
}
assertPublished(published);
assertAuthenticatedNonce(published.nonce, expectedNonce, "PUBLISHED");
if (published.sealedDev !== expectedIdentity.dev || published.sealedIno !== expectedIdentity.ino) {
throw new TypeError("provider guardian PUBLISHED identity is invalid");
}
assertCanonical(payload, encodePublishedPayload(published), "PUBLISHED");
}
function encodeV2GuardPayload(input: ProviderGuardianGuard): Buffer {
return Buffer.from(JSON.stringify({
type: "guard",
version: 2,
kind: input.kind,
nonce: input.nonce.toString("hex"),
deadlineEpochMs: input.deadlineEpochMs,
}));
}
export function encodeProviderGuardianCommit(nonce: Buffer): Buffer {
assertV2Nonce(nonce);
return prefixFrame(encodeCommitPayload(nonce));
}
export function decodeProviderGuardianCommit(payload: Buffer, expectedNonce: Buffer): void {
assertPayloadSize(payload);
assertV2Nonce(expectedNonce);
const decoded = decodeUtf8(payload);
let value: unknown;
try {
value = JSON.parse(decoded);
} catch {
throw new TypeError("provider guardian commit JSON is invalid");
}
if (!isRecord(value) || !hasExactKeys(value, COMMIT_KEYS)) {
throw new TypeError("provider guardian commit fields are invalid");
}
const nonce = parseV2Nonce(value.nonce);
if (
value.type !== "commit" || value.version !== 2 ||
nonce.byteLength !== expectedNonce.byteLength ||
!timingSafeEqual(nonce, expectedNonce)
) {
throw new TypeError("provider guardian commit authentication failed");
}
if (!payload.equals(encodeCommitPayload(nonce))) {
throw new TypeError("provider guardian commit is not canonical");
}
}
function encodeCommitPayload(nonce: Buffer): Buffer {
return Buffer.from(JSON.stringify({
type: "commit",
version: 2,
nonce: nonce.toString("hex"),
}));
}
function encodeReadyPayload(input: ProviderGuardianReady): Buffer {
return Buffer.from(JSON.stringify({
type: "ready",
version: 2,
nonce: input.nonce.toString("hex"),
rawDev: input.rawDev,
rawIno: input.rawIno,
sealedTempLeaf: input.sealedTempLeaf,
sealedDev: input.sealedDev,
sealedIno: input.sealedIno,
}));
}
function encodePublishPayload(input: ProviderGuardianPublish): Buffer {
return Buffer.from(JSON.stringify({
type: "publish",
version: 2,
nonce: input.nonce.toString("hex"),
sealedDev: input.sealedDev,
sealedIno: input.sealedIno,
size: input.size,
sha256: input.sha256,
}));
}
function encodePublishedPayload(input: ProviderGuardianPublished): Buffer {
return Buffer.from(JSON.stringify({
type: "published",
version: 2,
nonce: input.nonce.toString("hex"),
sealedDev: input.sealedDev,
sealedIno: input.sealedIno,
}));
}
function prefixFrame(payload: Buffer): Buffer {
if (payload.byteLength <= 0 || payload.byteLength > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
throw new TypeError("provider guardian frame size is invalid");
}
const frame = Buffer.allocUnsafe(payload.byteLength + 4);
frame.writeUInt32BE(payload.byteLength, 0);
payload.copy(frame, 4);
return frame;
}
function assertV2Guard(input: ProviderGuardianGuard): void {
if (
(input.kind !== "vulnerability" && input.kind !== "provenance") ||
!isV2Nonce(input.nonce) ||
!Number.isSafeInteger(input.deadlineEpochMs) || input.deadlineEpochMs <= 0
) {
throw new TypeError("provider guardian guard fields are invalid");
}
}
function baseLeaf(kind: ProviderGuardianKind): string {
if (kind === "vulnerability") return "vulnerability-report.json";
if (kind === "provenance") return "provenance-attestation.json";
throw new TypeError("provider guardian kind is invalid");
}
function assertReady(input: ProviderGuardianReady): void {
if (
!isV2Nonce(input.nonce) ||
!isIdentityPart(input.rawDev) || !isIdentityPart(input.rawIno) ||
typeof input.sealedTempLeaf !== "string" ||
!/^\.(?:vulnerability-report|provenance-attestation)\.json\.guardian-[0-9a-f]{32}\.tmp$/u
.test(input.sealedTempLeaf) ||
!isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno)
) {
throw new TypeError("provider guardian READY fields are invalid");
}
}
function assertPublish(input: ProviderGuardianPublish): void {
if (
!isV2Nonce(input.nonce) ||
!isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno) ||
!Number.isSafeInteger(input.size) || input.size <= 0 || input.size > MAX_PROVIDER_SEALED_BYTES ||
typeof input.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(input.sha256)
) {
throw new TypeError("provider guardian publish fields are invalid");
}
}
function assertPublished(input: ProviderGuardianPublished): void {
if (
!isV2Nonce(input.nonce) ||
!isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno)
) {
throw new TypeError("provider guardian PUBLISHED fields are invalid");
}
}
function assertV2Nonce(nonce: Buffer): void {
if (!isV2Nonce(nonce)) throw new TypeError("provider guardian nonce is invalid");
}
function isV2Nonce(nonce: Buffer): boolean {
return Buffer.isBuffer(nonce) && nonce.byteLength === 32;
}
function parseV2Nonce(value: unknown): Buffer {
return typeof value === "string" && /^[0-9a-f]{64}$/u.test(value)
? Buffer.from(value, "hex")
: Buffer.alloc(0);
}
function isIdentityPart(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) > 0;
}
function assertAuthenticatedNonce(received: Buffer, expected: Buffer, label: string): void {
if (received.byteLength !== expected.byteLength || !timingSafeEqual(received, expected)) {
throw new TypeError(`provider guardian ${label} authentication failed`);
}
}
function parseRecord(
payload: Buffer,
expectedKeys: readonly string[],
label: string,
): Record<string, unknown> {
assertPayloadSize(payload);
let value: unknown;
try {
value = JSON.parse(decodeUtf8(payload));
} catch (error) {
if (error instanceof TypeError && /provider guardian/u.test(error.message)) throw error;
throw new TypeError(`provider guardian ${label} JSON is invalid`, { cause: error });
}
if (!isRecord(value) || !hasExactKeys(value, expectedKeys)) {
throw new TypeError(`provider guardian ${label} fields are invalid`);
}
return value;
}
function assertCanonical(payload: Buffer, canonical: Buffer, label: string): void {
if (!payload.equals(canonical)) {
throw new TypeError(`provider guardian ${label} frame is not canonical`);
}
}
function assertPayloadSize(payload: Buffer): void {
if (!Buffer.isBuffer(payload) || payload.byteLength <= 0 || payload.byteLength > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
throw new TypeError("provider guardian frame size is invalid");
}
}
function decodeUtf8(payload: Buffer): string {
let decoded: string;
try {
decoded = new TextDecoder("utf-8", { fatal: true }).decode(payload);
} catch {
throw new TypeError("provider guardian frame UTF-8 is invalid");
}
if (decoded.includes("\0")) throw new TypeError("provider guardian frame contains NUL");
return decoded;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
const keys = Object.keys(value);
return keys.length === expected.length && keys.every((key, index) => key === expected[index]);
}
+27
View File
@@ -0,0 +1,27 @@
export type ProviderOutputLimiter = Readonly<{
consume(chunk: Buffer | string): void;
bytes(): number;
}>;
export function createProviderOutputLimiter(
maxBytes: number,
onExceeded: () => void,
): ProviderOutputLimiter {
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || typeof onExceeded !== "function") {
throw new TypeError("provider output limiter input is invalid");
}
let observedBytes = 0;
let exceeded = false;
return Object.freeze({
consume: (chunk: Buffer | string) => {
if (exceeded) return;
const bytes = Buffer.isBuffer(chunk) ? chunk.byteLength : Buffer.byteLength(chunk);
observedBytes += bytes;
if (observedBytes > maxBytes) {
exceeded = true;
onExceeded();
}
},
bytes: () => observedBytes,
});
}
+103
View File
@@ -0,0 +1,103 @@
import { spawn, type ChildProcess } from "node:child_process";
export type ProviderProcessInput = Readonly<{
executable: string;
arguments: readonly string[];
environment: NodeJS.ProcessEnv;
timeoutMs: number;
}>;
type ProviderChild = Pick<ChildProcess, "kill" | "once" | "pid">;
export async function runProviderProcess(
input: ProviderProcessInput,
dependencies: Readonly<{
spawnChild?: (input: ProviderProcessInput) => ProviderChild;
setTimer?: (callback: () => void, milliseconds: number) => ReturnType<typeof setTimeout>;
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
killProcessGroup?: (child: ProviderChild) => void;
}> = {},
): Promise<void> {
if (!Number.isSafeInteger(input.timeoutMs) || input.timeoutMs <= 0) {
throw new TypeError("provider process timeout must be a positive integer");
}
const child = (dependencies.spawnChild ?? defaultSpawn)(input);
const setTimer = dependencies.setTimer ?? setTimeout;
const clearTimer = dependencies.clearTimer ?? clearTimeout;
const killProcessGroup = dependencies.killProcessGroup ?? defaultKillProcessGroup;
await new Promise<void>((resolve, reject) => {
let settled = false;
let timedOut = false;
const killFailures: unknown[] = [];
const settle = (error?: Error): void => {
if (settled) return;
settled = true;
clearTimer(timer);
error ? reject(error) : resolve();
};
const timer = setTimer(() => {
timedOut = true;
try {
killProcessGroup(child);
} catch (groupError) {
killFailures.push(groupError);
try {
child.kill("SIGKILL");
} catch (fallbackError) {
killFailures.push(fallbackError);
}
}
}, input.timeoutMs);
child.once("error", (error: Error) => {
if (!timedOut) settle(error);
});
child.once("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (timedOut) {
const timeoutError = new Error(
"sandboxed external provider command timed out after process close",
);
settle(
killFailures.length === 0
? timeoutError
: new AggregateError(
[timeoutError, ...killFailures],
"sandboxed external provider timed out and process-group kill failed before close",
{ cause: killFailures.at(-1) },
),
);
} else if (code === 0 && signal === null) {
settle();
} else {
settle(
new Error(
`sandboxed external provider failed: exit=${code ?? "none"}, signal=${signal ?? "none"}`,
),
);
}
});
});
}
function defaultSpawn(input: ProviderProcessInput): ProviderChild {
return spawn(input.executable, [...input.arguments], {
env: input.environment,
stdio: "inherit",
detached: true,
});
}
function defaultKillProcessGroup(child: ProviderChild): void {
if (child.pid && child.pid > 0) {
try {
process.kill(-child.pid, "SIGKILL");
return;
} catch (error) {
if (!hasErrorCode(error, "ESRCH")) throw error;
}
}
child.kill("SIGKILL");
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
+40
View File
@@ -0,0 +1,40 @@
import { randomBytes } from "node:crypto";
import { lstat, rename, unlink } from "node:fs/promises";
export type ProviderRawIdentity = Readonly<{
reportPath: string;
reportDev: number;
reportIno: number;
}>;
export async function cleanupOwnedProviderReport(
identity: ProviderRawIdentity,
): Promise<boolean> {
try {
const metadata = await lstat(identity.reportPath);
if (!matchesReportIdentity(metadata, identity)) return false;
const quarantine = `${identity.reportPath}.parent-loss-${process.pid}-${randomBytes(16).toString("hex")}`;
await rename(identity.reportPath, quarantine);
const quarantinedMetadata = await lstat(quarantine);
if (!matchesReportIdentity(quarantinedMetadata, identity)) {
throw new Error("provider raw output identity changed during parent-loss cleanup");
}
await unlink(quarantine);
return true;
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return false;
throw error;
}
}
function matchesReportIdentity(
metadata: Awaited<ReturnType<typeof lstat>>,
identity: ProviderRawIdentity,
): boolean {
return metadata.isFile() && !metadata.isSymbolicLink() &&
metadata.dev === identity.reportDev && metadata.ino === identity.reportIno;
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
+641
View File
@@ -0,0 +1,641 @@
import { createHash } from "node:crypto";
import {
closeSync,
fstatSync,
fsyncSync,
lstatSync,
readlinkSync,
readSync,
type Stats,
writeSync,
createReadStream,
} from "node:fs";
import { link, lstat, unlink } from "node:fs/promises";
import path from "node:path";
import {
decodeProviderGuardianCommit,
decodeProviderGuardianGuard,
decodeProviderGuardianPublish,
encodeProviderGuardianPublished,
encodeProviderGuardianReady,
MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES,
MAX_PROVIDER_GUARDIAN_LEASE_MS,
providerGuardianRawStagingLeaf,
providerGuardianSealedTempLeaf,
type ProviderGuardianGuard,
type ProviderGuardianKind,
} from "./provider-guardian-protocol.ts";
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
type OwnedIdentity = Readonly<{ dev: number; ino: number }>;
type BootstrapAuthority = Readonly<{
kind: ProviderGuardianKind;
noncePrefix: string;
rawStagingLeaf: string;
rawStagingPath: string;
rawPath: string;
rawIdentity: OwnedIdentity;
sealedTempLeaf: string;
sealedTempPath: string;
sealedPath: string;
sealedIdentity: OwnedIdentity;
}>;
type BoundPrivateAuthority = Readonly<{
identity: OwnedIdentity;
leaf: string;
noncePrefix: string;
path: string;
stem: string;
}>;
type GuardianTransaction = Readonly<{ guard: ProviderGuardianGuard }>;
const RAW_DIRECTORY_FD = 3;
const EVIDENCE_DIRECTORY_FD = 4;
const RAW_STAGING_FD = 5;
const SEALED_TEMP_FD = 6;
const RAW_DIRECTORY_PATH = `/proc/self/fd/${RAW_DIRECTORY_FD}`;
const EVIDENCE_DIRECTORY_PATH = `/proc/self/fd/${EVIDENCE_DIRECTORY_FD}`;
const RAW_STAGING_FD_PATH = `/proc/self/fd/${RAW_STAGING_FD}`;
const SEALED_TEMP_FD_PATH = `/proc/self/fd/${SEALED_TEMP_FD}`;
const RAW_STAGING_PATTERN =
/^\.(vulnerability-report|provenance-attestation)\.json\.guardian-([0-9a-f]{32})\.raw\.tmp$/u;
const SEALED_TEMP_PATTERN =
/^\.(vulnerability-report|provenance-attestation)\.json\.guardian-([0-9a-f]{32})\.tmp$/u;
let privateFdsClosed = false;
const bootstrap = await initializeBootstrap();
let pending = Buffer.alloc(0);
let expectedBytes: number | undefined;
let state: "starting" | "guarding" | "published" | "commitPending" = "starting";
let transaction: GuardianTransaction | undefined;
let terminal = false;
let deadline: NodeJS.Timeout | undefined;
let operations = Promise.resolve();
const liveness = createReadStream("", { fd: 0, autoClose: false });
liveness.on("data", consumeChunk);
liveness.once("end", () => {
enqueue(async () => {
if (state === "commitPending" && pending.byteLength === 0 && expectedBytes === undefined) {
await succeedOnCommittedEof();
return;
}
if (state === "starting" && pending.byteLength > 0) {
await failClosed(126, "provider guardian frame is truncated");
return;
}
await failClosed(125, "provider guardian liveness EOF");
});
});
liveness.once("error", (error) => {
enqueue(async () => failClosed(125, `provider guardian liveness error: ${error.message}`));
});
async function initializeBootstrap(): Promise<BootstrapAuthority> {
const rawDirectory = path.resolve(process.cwd(), "provider-evidence/untrusted");
const evidenceDirectory = path.resolve(process.cwd(), "provider-evidence");
const failures: Error[] = [];
const rawDirectoryValid = captureInheritedDirectory(
RAW_DIRECTORY_FD,
rawDirectory,
"raw",
failures,
);
const evidenceDirectoryValid = captureInheritedDirectory(
EVIDENCE_DIRECTORY_FD,
evidenceDirectory,
"evidence",
failures,
);
const rawDescriptor = capturePrivateDescriptor(RAW_STAGING_FD, "raw staging", failures);
const sealedDescriptor = capturePrivateDescriptor(SEALED_TEMP_FD, "sealed temp", failures);
const rawAuthority = rawDescriptor && rawDirectoryValid
? capturePrivateAlias({
descriptorMetadata: rawDescriptor,
descriptorTarget: RAW_STAGING_FD_PATH,
expectedDirectory: rawDirectory,
descriptorDirectory: RAW_DIRECTORY_PATH,
grammar: RAW_STAGING_PATTERN,
label: "raw staging",
}, failures)
: undefined;
const sealedAuthority = sealedDescriptor && evidenceDirectoryValid
? capturePrivateAlias({
descriptorMetadata: sealedDescriptor,
descriptorTarget: SEALED_TEMP_FD_PATH,
expectedDirectory: evidenceDirectory,
descriptorDirectory: EVIDENCE_DIRECTORY_PATH,
grammar: SEALED_TEMP_PATTERN,
label: "sealed temp",
}, failures)
: undefined;
if (!rawAuthority || !sealedAuthority) {
return await failBootstrap(rawAuthority, sealedAuthority, failures);
}
try {
if (
rawAuthority.stem !== sealedAuthority.stem ||
rawAuthority.noncePrefix !== sealedAuthority.noncePrefix
) {
throw new TypeError("provider guardian inherited private aliases disagree");
}
const kind = providerKindFromStem(rawAuthority.stem);
const rawLeaf = kind === "vulnerability"
? "vulnerability-report.json"
: "provenance-attestation.json";
return Object.freeze({
kind,
noncePrefix: rawAuthority.noncePrefix,
rawStagingLeaf: rawAuthority.leaf,
rawStagingPath: rawAuthority.path,
rawPath: `${RAW_DIRECTORY_PATH}/${rawLeaf}`,
rawIdentity: rawAuthority.identity,
sealedTempLeaf: sealedAuthority.leaf,
sealedTempPath: sealedAuthority.path,
sealedPath: `${EVIDENCE_DIRECTORY_PATH}/${rawLeaf}`,
sealedIdentity: sealedAuthority.identity,
});
} catch (error) {
failures.push(toError(error));
return await failBootstrap(rawAuthority, sealedAuthority, failures);
}
}
function captureInheritedDirectory(
fd: number,
canonicalPath: string,
label: string,
failures: Error[],
): boolean {
try {
assertInheritedDirectory(fd, canonicalPath, label);
return true;
} catch (error) {
failures.push(toError(error));
return false;
}
}
function capturePrivateDescriptor(
fd: number,
label: string,
failures: Error[],
): Stats | undefined {
try {
return fstatSync(fd);
} catch (error) {
failures.push(new Error(`provider guardian inherited ${label} fd is invalid`, {
cause: error,
}));
return undefined;
}
}
function capturePrivateAlias(
input: Readonly<{
descriptorMetadata: Stats;
descriptorTarget: string;
expectedDirectory: string;
descriptorDirectory: string;
grammar: RegExp;
label: string;
}>,
failures: Error[],
): BoundPrivateAuthority | undefined {
try {
return bindPrivateAlias(input);
} catch (error) {
failures.push(toError(error));
return undefined;
}
}
function bindPrivateAlias(input: Readonly<{
descriptorMetadata: Stats;
descriptorTarget: string;
expectedDirectory: string;
descriptorDirectory: string;
grammar: RegExp;
label: string;
}>): BoundPrivateAuthority {
const descriptorTarget = readlinkSync(input.descriptorTarget);
if (path.dirname(descriptorTarget) !== input.expectedDirectory) {
throw new TypeError(
`provider guardian inherited ${input.label} alias is outside its directory`,
);
}
const leaf = path.basename(descriptorTarget);
const match = input.grammar.exec(leaf);
if (!match) {
throw new TypeError(`provider guardian inherited ${input.label} alias is invalid`);
}
const boundPath = `${input.descriptorDirectory}/${leaf}`;
const pathnameMetadata = lstatSync(boundPath);
assertPrivateMetadata(input.descriptorMetadata, pathnameMetadata, input.label);
return Object.freeze({
identity: Object.freeze({
dev: input.descriptorMetadata.dev,
ino: input.descriptorMetadata.ino,
}),
leaf,
noncePrefix: match[2]!,
path: boundPath,
stem: match[1]!,
});
}
async function failBootstrap(
rawAuthority: BoundPrivateAuthority | undefined,
sealedAuthority: BoundPrivateAuthority | undefined,
failures: Error[],
): Promise<never> {
for (const authority of [rawAuthority, sealedAuthority]) {
if (!authority) continue;
await cleanupOwnedPath(authority.path, authority.identity, failures);
}
closePrivateFds(failures);
writeAggregateDiagnostic("provider guardian bootstrap failed", failures);
process.exit(126);
}
function assertPrivateMetadata(
descriptorMetadata: Stats,
pathnameMetadata: Stats,
label: string,
): void {
if (
!descriptorMetadata.isFile() || !pathnameMetadata.isFile() ||
pathnameMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathnameMetadata.dev ||
descriptorMetadata.ino !== pathnameMetadata.ino || descriptorMetadata.nlink !== 1 ||
pathnameMetadata.nlink !== 1 || (descriptorMetadata.mode & 0o777) !== 0o600 ||
(pathnameMetadata.mode & 0o777) !== 0o600 || descriptorMetadata.size !== 0 ||
pathnameMetadata.size !== 0
) {
throw new TypeError(`provider guardian inherited ${label} identity is invalid`);
}
}
function providerKindFromStem(stem: string): ProviderGuardianKind {
if (stem === "vulnerability-report") return "vulnerability";
if (stem === "provenance-attestation") return "provenance";
throw new TypeError("provider guardian inherited private kind is invalid");
}
function consumeChunk(chunk: Buffer | string): void {
if (terminal) return;
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
if (expectedBytes === undefined && pending.byteLength >= 4) {
expectedBytes = pending.readUInt32BE(0);
if (expectedBytes <= 0 || expectedBytes > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
enqueue(async () => failClosed(126, "provider guardian frame length is invalid"));
return;
}
}
if (expectedBytes !== undefined && pending.byteLength === expectedBytes + 4) {
const payload = pending.subarray(4);
pending = Buffer.alloc(0);
expectedBytes = undefined;
enqueue(async () => handleFrame(payload));
} else if (expectedBytes !== undefined && pending.byteLength > expectedBytes + 4) {
enqueue(async () => failClosed(126, "provider guardian frame has trailing bytes"));
}
}
function enqueue(operation: () => Promise<void>): void {
operations = operations.then(operation).catch(async (error) => {
await failClosed(126, error instanceof Error ? error.message : String(error));
});
}
async function handleFrame(payload: Buffer): Promise<void> {
if (state === "starting") {
await establishTransaction(payload);
} else if (state === "guarding") {
await publishSealedArtifact(payload);
} else if (state === "published") {
await prepareCommit(payload);
} else {
await failClosed(126, "provider guardian received data after commit");
}
}
async function establishTransaction(payload: Buffer): Promise<void> {
const nowEpochMs = Date.now();
const guard = decodeProviderGuardianGuard(payload, {
nowEpochMs,
maxLeaseMs: MAX_PROVIDER_GUARDIAN_LEASE_MS,
});
if (
guard.kind !== bootstrap.kind ||
guard.nonce.subarray(0, 16).toString("hex") !== bootstrap.noncePrefix ||
providerGuardianRawStagingLeaf(guard.kind, guard.nonce) !== bootstrap.rawStagingLeaf ||
providerGuardianSealedTempLeaf(guard.kind, guard.nonce) !== bootstrap.sealedTempLeaf
) {
throw new TypeError("provider guardian guard does not match inherited private aliases");
}
assertBoundPrivateLeaf(
RAW_STAGING_FD,
bootstrap.rawStagingPath,
bootstrap.rawIdentity,
"raw staging",
);
assertBoundPrivateLeaf(
SEALED_TEMP_FD,
bootstrap.sealedTempPath,
bootstrap.sealedIdentity,
"sealed temp",
);
transaction = Object.freeze({ guard });
await link(bootstrap.rawStagingPath, bootstrap.rawPath);
assertOwnedPathMetadata(bootstrap.rawStagingPath, bootstrap.rawIdentity, 2, 0o600, 0,
"raw staging link");
assertOwnedPathMetadata(bootstrap.rawPath, bootstrap.rawIdentity, 2, 0o600, 0,
"canonical raw link");
await unlink(bootstrap.rawStagingPath);
fsyncSync(RAW_DIRECTORY_FD);
assertOwnedPathMetadata(bootstrap.rawPath, bootstrap.rawIdentity, 1, 0o600, 0,
"canonical raw");
assertBoundPrivateLeaf(
SEALED_TEMP_FD,
bootstrap.sealedTempPath,
bootstrap.sealedIdentity,
"sealed temp",
);
const remainingLeaseMs = guard.deadlineEpochMs - Date.now();
if (remainingLeaseMs <= 0) {
throw new TypeError("provider guardian guard deadline expired during startup");
}
deadline = setTimeout(() => {
enqueue(async () => failClosed(124, "provider guardian lease deadline expired", true));
}, remainingLeaseMs);
writeSync(1, encodeProviderGuardianReady({
nonce: guard.nonce,
rawDev: bootstrap.rawIdentity.dev,
rawIno: bootstrap.rawIdentity.ino,
sealedTempLeaf: bootstrap.sealedTempLeaf,
sealedDev: bootstrap.sealedIdentity.dev,
sealedIno: bootstrap.sealedIdentity.ino,
}));
state = "guarding";
}
function assertBoundPrivateLeaf(
fd: number,
target: string,
identity: OwnedIdentity,
label: string,
): void {
const descriptorMetadata = fstatSync(fd);
const pathnameMetadata = lstatSync(target);
assertOwnedMetadata(descriptorMetadata, identity, 1, 0o600, 0, label);
assertOwnedMetadata(pathnameMetadata, identity, 1, 0o600, 0, label);
if (pathnameMetadata.isSymbolicLink()) {
throw new TypeError(`provider guardian ${label} alias became symbolic`);
}
}
async function publishSealedArtifact(payload: Buffer): Promise<void> {
if (!transaction) {
throw new Error("provider guardian transaction identity is unavailable");
}
const publication = decodeProviderGuardianPublish(payload, transaction.guard.nonce);
if (
publication.sealedDev !== bootstrap.sealedIdentity.dev ||
publication.sealedIno !== bootstrap.sealedIdentity.ino
) {
throw new TypeError("provider guardian publish identity is invalid");
}
assertOwnedMetadata(
fstatSync(SEALED_TEMP_FD),
bootstrap.sealedIdentity,
1,
0o400,
publication.size,
"sealed publish descriptor",
);
const pathnameMetadata = await lstat(bootstrap.sealedTempPath);
assertOwnedMetadata(
pathnameMetadata,
bootstrap.sealedIdentity,
1,
0o400,
publication.size,
"sealed publish pathname",
);
if (pathnameMetadata.isSymbolicLink()) {
throw new TypeError("provider guardian sealed publish pathname became symbolic");
}
const actualSha256 = hashInheritedFile(SEALED_TEMP_FD, publication.size);
if (actualSha256 !== publication.sha256) {
throw new TypeError("provider guardian publish hash is invalid");
}
try {
await lstat(bootstrap.sealedPath);
throw new Error("provider guardian sealed output already exists");
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
}
await link(bootstrap.sealedTempPath, bootstrap.sealedPath);
await unlink(bootstrap.sealedTempPath);
fsyncSync(EVIDENCE_DIRECTORY_FD);
const finalMetadata = await lstat(bootstrap.sealedPath);
assertOwnedMetadata(
finalMetadata,
bootstrap.sealedIdentity,
1,
0o400,
publication.size,
"sealed final",
);
if (finalMetadata.isSymbolicLink()) {
throw new TypeError("provider guardian sealed final became symbolic");
}
state = "published";
writeSync(1, encodeProviderGuardianPublished({
nonce: transaction.guard.nonce,
sealedDev: bootstrap.sealedIdentity.dev,
sealedIno: bootstrap.sealedIdentity.ino,
}));
}
function assertOwnedPathMetadata(
target: string,
identity: OwnedIdentity,
expectedLinks: number,
expectedMode: number,
expectedSize: number,
label: string,
): void {
const metadata = lstatSync(target);
assertOwnedMetadata(metadata, identity, expectedLinks, expectedMode, expectedSize, label);
if (metadata.isSymbolicLink()) {
throw new TypeError(`provider guardian ${label} became symbolic`);
}
}
function assertOwnedMetadata(
metadata: Stats,
identity: OwnedIdentity,
expectedLinks: number,
expectedMode: number,
expectedSize: number,
label: string,
): void {
if (
!metadata.isFile() || metadata.dev !== identity.dev || metadata.ino !== identity.ino ||
metadata.nlink !== expectedLinks || (metadata.mode & 0o777) !== expectedMode ||
metadata.size !== expectedSize
) {
throw new TypeError(`provider guardian ${label} metadata is invalid`);
}
}
function hashInheritedFile(fd: number, size: number): string {
const digest = createHash("sha256");
const buffer = Buffer.allocUnsafe(Math.min(65_536, size));
let position = 0;
while (position < size) {
const requested = Math.min(buffer.byteLength, size - position);
const bytesRead = readSync(fd, buffer, 0, requested, position);
if (bytesRead <= 0) throw new Error("provider guardian sealed publish read was truncated");
digest.update(buffer.subarray(0, bytesRead));
position += bytesRead;
}
return digest.digest("hex");
}
async function prepareCommit(payload: Buffer): Promise<void> {
if (!transaction) throw new Error("provider guardian transaction identity is unavailable");
decodeProviderGuardianCommit(payload, transaction.guard.nonce);
const removedRaw = await cleanupOwnedProviderReport({
reportPath: bootstrap.rawPath,
reportDev: bootstrap.rawIdentity.dev,
reportIno: bootstrap.rawIdentity.ino,
});
if (!removedRaw) throw new Error("provider guardian raw output disappeared before commit");
state = "commitPending";
}
async function succeedOnCommittedEof(): Promise<void> {
const closeErrors: Error[] = [];
closePrivateFds(closeErrors);
if (closeErrors.length > 0) {
await failClosed(
126,
"provider guardian private descriptor close failed",
false,
closeErrors,
);
return;
}
terminal = true;
if (deadline) clearTimeout(deadline);
liveness.removeAllListeners();
liveness.destroy();
closeControlInputBestEffort();
process.exit(0);
}
async function failClosed(
exitCode: number,
message: string,
forceSignal = false,
priorErrors: readonly Error[] = [],
): Promise<void> {
if (terminal) return;
terminal = true;
if (deadline) clearTimeout(deadline);
liveness.removeAllListeners();
liveness.destroy();
const failures = [new Error(message), ...priorErrors];
await cleanupOwnedPath(bootstrap.rawStagingPath, bootstrap.rawIdentity, failures);
await cleanupOwnedPath(bootstrap.rawPath, bootstrap.rawIdentity, failures);
await cleanupOwnedPath(bootstrap.sealedTempPath, bootstrap.sealedIdentity, failures);
await cleanupOwnedPath(bootstrap.sealedPath, bootstrap.sealedIdentity, failures);
closePrivateFds(failures);
closeControlInputBestEffort();
writeAggregateDiagnostic("provider guardian failed", failures);
if (forceSignal) {
try {
process.kill(process.pid, "SIGKILL");
} finally {
process.exit(exitCode);
}
}
process.exit(exitCode);
}
async function cleanupOwnedPath(
target: string,
identity: OwnedIdentity,
errors: Error[],
): Promise<void> {
try {
await cleanupOwnedProviderReport({
reportPath: target,
reportDev: identity.dev,
reportIno: identity.ino,
});
} catch (error) {
errors.push(toError(error));
}
}
function closePrivateFds(errors: Error[]): void {
if (privateFdsClosed) return;
privateFdsClosed = true;
for (const fd of [RAW_STAGING_FD, SEALED_TEMP_FD]) {
try {
closeSync(fd);
} catch (error) {
errors.push(toError(error));
}
}
}
function closeControlInputBestEffort(): void {
try {
closeSync(0);
} catch {
// Terminal cleanup and the exit status must not depend on a diagnostic fd.
}
}
function writeAggregateDiagnostic(label: string, failures: readonly Error[]): void {
const aggregate = failures.length > 1
? new AggregateError(failures, label, { cause: failures[0] })
: failures[0];
const detail = aggregate instanceof AggregateError
? aggregate.errors.map((error) => toError(error).message).join("; ")
: aggregate?.message ?? label;
try {
writeSync(2, `${label}: ${detail}\n`);
} catch {
// A closed parent-side pipe must not convert fail-closed termination to exit 0.
}
}
function assertInheritedDirectory(fd: number, canonicalPath: string, label: string): void {
const descriptorMetadata = fstatSync(fd);
const pathMetadata = lstatSync(canonicalPath);
if (
!descriptorMetadata.isDirectory() || !pathMetadata.isDirectory() ||
pathMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathMetadata.dev ||
descriptorMetadata.ino !== pathMetadata.ino
) {
throw new TypeError(`provider guardian inherited ${label} fd is not a directory`);
}
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
+166
View File
@@ -0,0 +1,166 @@
import { spawn } from "node:child_process";
import { closeSync, createReadStream, writeSync } from "node:fs";
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
const MAX_FRAME_BYTES = 16_777_216;
const reportIdentity = parseReportIdentity(process.argv.slice(2));
let pending = Buffer.alloc(0);
let expectedBytes: number | undefined;
let provider: ReturnType<typeof spawn> | undefined;
let providerClosed = false;
let livenessLost = false;
const liveness = createReadStream("", { fd: 0, autoClose: false });
liveness.on("data", (chunk: Buffer | string) => {
if (provider) {
terminateForProtocolFailure("provider scope received trailing protocol bytes");
return;
}
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
if (expectedBytes === undefined && pending.byteLength >= 4) {
expectedBytes = pending.readUInt32BE(0);
if (expectedBytes <= 0 || expectedBytes > MAX_FRAME_BYTES) {
terminateForProtocolFailure("provider scope frame length is invalid");
return;
}
}
if (expectedBytes !== undefined && pending.byteLength === expectedBytes + 4) {
launchProvider(pending.subarray(4));
pending = Buffer.alloc(0);
} else if (expectedBytes !== undefined && pending.byteLength > expectedBytes + 4) {
terminateForProtocolFailure("provider scope frame has trailing bytes");
}
});
liveness.once("end", () => terminateForParentLoss());
liveness.once("error", () => terminateForParentLoss());
function launchProvider(payload: Buffer): void {
const frame = parseFrame(payload);
if (
frame.reportPath !== reportIdentity.reportPath ||
frame.reportDev !== reportIdentity.reportDev ||
frame.reportIno !== reportIdentity.reportIno
) {
throw new TypeError("provider scope frame identity does not match its launch identity");
}
const bwrapInput = Buffer.from(frame.bwrapInputBase64, "base64");
provider = spawn("/usr/bin/bwrap", ["--args", "0"], {
detached: true,
stdio: ["pipe", "inherit", "inherit"],
});
provider.stdin?.end(bwrapInput);
provider.once("error", (error) => finishProvider(frame, null, null, error));
provider.once("close", (code, signal) => finishProvider(frame, code, signal));
}
async function finishProvider(
frame: ReturnType<typeof parseFrame>,
code: number | null,
signal: NodeJS.Signals | null,
error?: Error,
): Promise<void> {
if (providerClosed) return;
providerClosed = true;
if (livenessLost) await cleanupOwnedProviderReport(frame);
closeLivenessInput();
if (error) {
writeSync(2, `${error.message}\n`);
process.exit(1);
}
if (signal) process.exit(128 + signalNumber(signal));
process.exit(code ?? 1);
}
function terminateForParentLoss(): void {
if (livenessLost) return;
livenessLost = true;
if (!provider || providerClosed) {
void cleanupAfterParentLossAndExit();
return;
}
try {
process.kill(-provider.pid!, "SIGKILL");
} catch (error) {
if (!hasErrorCode(error, "ESRCH")) throw error;
}
}
async function cleanupAfterParentLossAndExit(): Promise<void> {
try {
await cleanupOwnedProviderReport(reportIdentity);
} catch (error) {
writeSync(2, `${error instanceof Error ? error.message : String(error)}\n`);
}
closeLivenessInput();
process.exit(125);
}
function terminateForProtocolFailure(message: string): void {
writeSync(2, `${message}\n`);
terminateForParentLoss();
}
function closeLivenessInput(): void {
liveness.removeAllListeners();
liveness.destroy();
try {
closeSync(0);
} catch (error) {
if (!hasErrorCode(error, "EBADF")) throw error;
}
}
function parseFrame(payload: Buffer): Readonly<{
bwrapInputBase64: string;
reportPath: string;
reportDev: number;
reportIno: number;
}> {
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload)) as Record<string, unknown>;
if (
typeof value.bwrapInputBase64 !== "string" ||
typeof value.reportPath !== "string" || !value.reportPath.startsWith("/") ||
!Number.isSafeInteger(value.reportDev) || Number(value.reportDev) <= 0 ||
!Number.isSafeInteger(value.reportIno) || Number(value.reportIno) <= 0
) {
throw new TypeError("provider scope frame payload is invalid");
}
return {
bwrapInputBase64: value.bwrapInputBase64,
reportPath: value.reportPath,
reportDev: Number(value.reportDev),
reportIno: Number(value.reportIno),
};
}
function parseReportIdentity(arguments_: readonly string[]): Readonly<{
cpuSeconds: number;
reportPath: string;
reportDev: number;
reportIno: number;
}> {
const [cpuValue, reportPath, devValue, inoValue, ...trailing] = arguments_;
const cpuSeconds = Number(cpuValue);
const reportDev = Number(devValue);
const reportIno = Number(inoValue);
if (
trailing.length > 0 ||
!Number.isSafeInteger(cpuSeconds) || cpuSeconds <= 0 ||
typeof reportPath !== "string" || !reportPath.startsWith("/") || reportPath.includes("\0") ||
!Number.isSafeInteger(reportDev) || reportDev <= 0 ||
!Number.isSafeInteger(reportIno) || reportIno <= 0
) {
throw new TypeError("provider scope launch identity is invalid");
}
return { cpuSeconds, reportPath, reportDev, reportIno };
}
function signalNumber(signal: NodeJS.Signals): number {
return signal === "SIGKILL" ? 9 : signal === "SIGXCPU" ? 24 : 1;
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
+163
View File
@@ -0,0 +1,163 @@
import { randomBytes as cryptoRandomBytes } from "node:crypto";
import {
captureCiCandidateArchive,
withVerifiedCapturedCandidate,
type CapturedCandidateArchive,
} from "./ci-candidate-archive.ts";
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
import type { ExpectedPromotionContext, ProviderTrust } from "./provider-evidence.ts";
import { validateProviderUpload } from "./provider-upload-validator.ts";
export type ProviderInvocation = Readonly<{
candidateRoot: string;
environment: Readonly<Record<string, string>>;
}>;
export async function superviseProviderEvidence(input: Readonly<{
kind: "vulnerability" | "provenance";
archivePath: string;
expectedArchiveSha256: string;
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
trust: ProviderTrust;
executeProvider: (invocation: ProviderInvocation) => Promise<void>;
captureReport: () => Promise<Buffer>;
}>, dependencies: Readonly<{
captureArchive?: typeof captureCiCandidateArchive;
withVerifiedCandidate?: typeof withVerifiedCapturedCandidate;
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
validateUpload?: typeof validateProviderUpload;
randomBytes?: (bytes: number) => Buffer;
nowEpochMs?: () => number;
}> = {}): Promise<Readonly<{
evidence: unknown;
invocationNonce: string;
expectedContext: ExpectedPromotionContext;
}>> {
const captured = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
archivePath: input.archivePath,
expectedSha256: input.expectedArchiveSha256,
});
const nonceBytes = (dependencies.randomBytes ?? cryptoRandomBytes)(32);
if (nonceBytes.byteLength !== 32) {
throw new TypeError("provider invocation nonce must contain exactly 32 bytes");
}
const invocationNonce = nonceBytes.toString("hex");
const nowEpochMs = dependencies.nowEpochMs ?? Date.now;
const result = await (dependencies.withVerifiedCandidate ?? withVerifiedCapturedCandidate)({
captured,
verify: async ({ extractionRoot, manifest }) => {
const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({
extractionRoot,
expectedManifest: manifest,
});
if (local.status !== "PASS" || !local.identity) {
throw new Error(
`provider candidate local assessment failed: ${local.failures.join("; ")}`,
);
}
if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) {
throw new Error("provider candidate source revision mismatch");
}
const expectedContext: ExpectedPromotionContext = Object.freeze({
run: Object.freeze({ id: input.expectedRun.id, attempt: input.expectedRun.attempt }),
source: Object.freeze({
revision: local.identity.sourceRevision,
sourceSetSha256: local.identity.sourceSetSha256,
}),
candidate: Object.freeze({
archiveSha256: captured.archiveSha256,
bundleSha256: manifest.bundleSha256,
distSha256: manifest.distSha256,
lockfileSha256: manifest.lockfileSha256,
}),
secretScanAttestation: Object.freeze({
status: "PASS" as const,
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
sourceSetSha256: local.identity.sourceSetSha256,
policySha256: local.identity.secretScan.policySha256,
sarifSha256: local.identity.secretScan.sarifSha256,
scanInputSha256: local.identity.secretScan.scanInputSha256,
}),
vulnerabilityInvocationNonce:
input.kind === "vulnerability" ? invocationNonce : "0".repeat(64),
provenanceInvocationNonce:
input.kind === "provenance" ? invocationNonce : "0".repeat(64),
});
const issuedNow = nowEpochMs();
const issuedAt = new Date(issuedNow).toISOString();
const expiresAt = new Date(issuedNow + 60 * 60 * 1_000).toISOString();
await input.executeProvider({
candidateRoot: extractionRoot,
environment: providerInvocationEnvironment({
kind: input.kind,
expectedContext,
invocationNonce,
issuedAt,
expiresAt,
trust: input.trust,
}),
});
const capturedReport = await input.captureReport();
const evidence = await (dependencies.validateUpload ?? validateProviderUpload)({
kind: input.kind,
verifiedManifest: manifest,
archiveSha256: captured.archiveSha256,
candidateRoot: extractionRoot,
capturedReport,
expectedContext,
trust: input.trust,
nowEpochMs,
});
return Object.freeze({ evidence, invocationNonce, expectedContext });
},
});
return result;
}
export function providerInvocationEnvironment(input: Readonly<{
kind: "vulnerability" | "provenance";
expectedContext: ExpectedPromotionContext;
invocationNonce: string;
issuedAt: string;
expiresAt: string;
trust: ProviderTrust;
}>): Readonly<Record<string, string>> {
return Object.freeze({
PROVIDER_EVIDENCE_SCHEMA_VERSION: "2",
PROVIDER_EVIDENCE_TYPE:
input.kind === "vulnerability"
? "vulnerability-report"
: "provenance-attestation",
PROVIDER_ISSUED_AT: input.issuedAt,
PROVIDER_EXPIRES_AT: input.expiresAt,
PROVIDER_INVOCATION_NONCE: input.invocationNonce,
PROVIDER_KEY_ID: input.trust.keyId,
PROVIDER_PUBLIC_KEY_FINGERPRINT: input.trust.publicKeyFingerprint,
CI_RUN_ID: input.expectedContext.run.id,
CI_RUN_ATTEMPT: String(input.expectedContext.run.attempt),
SOURCE_REVISION: input.expectedContext.source.revision,
SOURCE_SET_SHA256: input.expectedContext.source.sourceSetSha256,
CANDIDATE_ROOT: "/candidate",
CANDIDATE_LOCKFILE_PATH: "/candidate/pnpm-lock.yaml",
CANDIDATE_ARCHIVE_SHA256: input.expectedContext.candidate.archiveSha256,
CANDIDATE_BUNDLE_SHA256: input.expectedContext.candidate.bundleSha256,
CANDIDATE_DIST_SHA256: input.expectedContext.candidate.distSha256,
CANDIDATE_LOCKFILE_SHA256: input.expectedContext.candidate.lockfileSha256,
SECRET_SCAN_STATUS: input.expectedContext.secretScanAttestation.status,
SECRET_SCAN_LOCAL_EVIDENCE_ASSESSMENT_SHA256:
input.expectedContext.secretScanAttestation.localEvidenceAssessmentSha256,
SECRET_SCAN_SOURCE_SET_SHA256:
input.expectedContext.secretScanAttestation.sourceSetSha256,
SECRET_SCAN_POLICY_SHA256:
input.expectedContext.secretScanAttestation.policySha256,
SECRET_SCAN_SARIF_SHA256:
input.expectedContext.secretScanAttestation.sarifSha256,
SECRET_SCAN_INPUT_SHA256:
input.expectedContext.secretScanAttestation.scanInputSha256,
});
}
export type CaptureArchiveDependency = (
input: Readonly<{ archivePath: string; expectedSha256: string }>,
) => Promise<CapturedCandidateArchive>;
+42
View File
@@ -0,0 +1,42 @@
import { createPublicKey } from "node:crypto";
import path from "node:path";
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
import {
providerPublicKeyFingerprint,
type ProviderTrust,
} from "./provider-evidence.ts";
export async function readProviderTrust(
configuredRoot: string,
publicKeyPath: string | undefined,
keyId: string | undefined,
): Promise<ProviderTrust | null> {
if (!publicKeyPath || !keyId?.trim()) return null;
try {
const root = path.resolve(configuredRoot);
const absolute = path.resolve(root, publicKeyPath);
const relative = path.relative(root, absolute);
const outside =
relative === ".." ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative);
const bytes = await readBoundedRegularFile({
root: outside ? path.dirname(absolute) : root,
relativePath: outside
? path.basename(absolute)
: relative.replaceAll(path.sep, "/"),
maxBytes: 1_048_576,
});
const publicKey = createPublicKey(
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
);
return Object.freeze({
keyId,
publicKey,
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
});
} catch {
return null;
}
}
+59
View File
@@ -0,0 +1,59 @@
import {
validateProviderEvidence,
type ExpectedPromotionContext,
type ProviderTrust,
} from "./provider-evidence.ts";
import {
verifyReleaseCandidate,
type ReleaseCandidateManifest,
} from "./release-candidate.ts";
export async function validateProviderUpload(input: Readonly<{
kind: "vulnerability" | "provenance";
verifiedManifest: ReleaseCandidateManifest;
archiveSha256: string;
candidateRoot: string;
capturedReport: Buffer;
expectedContext: ExpectedPromotionContext;
trust: ProviderTrust;
nowEpochMs?: () => number;
}>): Promise<unknown> {
if (
input.expectedContext.candidate.archiveSha256 !== input.archiveSha256 ||
input.expectedContext.candidate.bundleSha256 !== input.verifiedManifest.bundleSha256 ||
input.expectedContext.candidate.distSha256 !== input.verifiedManifest.distSha256 ||
input.expectedContext.candidate.lockfileSha256 !== input.verifiedManifest.lockfileSha256
) {
throw new Error("provider supervisor expected candidate context mismatch");
}
const verifiedCandidate = await verifyReleaseCandidate(
input.verifiedManifest,
input.candidateRoot,
);
if (verifiedCandidate.failures.length > 0) {
throw new Error(
`provider input candidate root changed: ${verifiedCandidate.failures.join("; ")}`,
);
}
let report: unknown;
try {
report = JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(input.capturedReport),
) as unknown;
} catch {
throw new TypeError("provider output is not canonical UTF-8 JSON");
}
const evaluated = validateProviderEvidence({
kind: input.kind,
value: report,
expected: input.expectedContext,
trust: input.trust,
nowEpochMs: input.nowEpochMs,
});
if (evaluated.status !== "PASS" || !evaluated.evidence) {
throw new Error(
`provider evidence context validation failed: ${evaluated.failures.join("; ")}`,
);
}
return evaluated.evidence;
}
+261
View File
@@ -0,0 +1,261 @@
import { createHash } from "node:crypto";
import { lstat, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { gzipSync } from "node:zlib";
import { z } from "zod";
import { supplyChainDigest } from "./supply-chain.ts";
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
const candidateFileSchema = z
.object({
path: z.string().min(1),
bytes: z.int().nonnegative(),
sha256,
})
.strict();
export const releaseCandidateManifestSchema = z
.object({
schemaVersion: z.literal(1),
distSha256: sha256,
lockfileSha256: sha256,
bundleSha256: sha256,
files: z.array(candidateFileSchema).min(1),
})
.strict();
export type ReleaseCandidateManifest = z.infer<
typeof releaseCandidateManifestSchema
>;
export const RELEASE_CANDIDATE_MANIFEST_PATH =
"artifacts/release/release-candidate.json";
export const LOCAL_EVIDENCE_ASSESSMENT_PATH =
"artifacts/security/local-evidence-assessment.json";
export const LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS = Object.freeze([
"scripts/contracts/release-artifacts.ts",
"scripts/create-release-candidate.ts",
"scripts/generate-supply-chain.ts",
"scripts/lib/build-manifest-outputs.ts",
"scripts/lib/json-schema.ts",
"scripts/lib/local-policy-evidence.ts",
"scripts/lib/local-release-evidence.ts",
"scripts/lib/release-candidate.ts",
"scripts/lib/release-input-evidence.ts",
"scripts/lib/release-runtime-coherence.ts",
"scripts/lib/repository-file-inventory.ts",
"scripts/lib/secret-scan-evaluator.ts",
"scripts/lib/secret-scan-policy.ts",
"scripts/lib/secret-scan.ts",
"scripts/lib/supply-chain.ts",
"scripts/lib/validated-json-artifact.ts",
"src/contracts/release-artifacts.ts",
"src/features/installed-contract-contributions.ts",
"src/features/installed-feature-contracts.ts",
] as const);
export const LOCAL_EVIDENCE_POLICY_INPUT_PATHS = Object.freeze([
"config/security/dependency-baseline.approval.json",
"config/security/dependency-baseline.json",
"config/security/dependency-change-evidence.json",
"config/security/dependency-policy.json",
"config/security/secret-scan-policy.json",
"config/security/vulnerability-exceptions.json",
"config/security/vulnerability-policy.json",
"schemas/artifacts/build-manifest.schema.json",
"schemas/artifacts/dependency-inventory.schema.json",
"schemas/artifacts/supply-chain-verification.schema.json",
...LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
] as const);
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
"pnpm-lock.yaml",
"artifacts/performance/bundle.json",
"artifacts/quality/vite-module-inventory.json",
"artifacts/release/build-manifest.json",
"artifacts/release/checksums.txt",
"artifacts/release/dependency-inventory.json",
"artifacts/release/provenance.json",
"artifacts/release/verification.json",
"artifacts/release/sbom.cdx.json",
"artifacts/security/dependency-diff.json",
"artifacts/security/license-report.json",
LOCAL_EVIDENCE_ASSESSMENT_PATH,
"artifacts/security/scan.sarif",
"artifacts/security/supply-chain-coherence.json",
"artifacts/security/supply-chain-verification.json",
"artifacts/security/vulnerability-report.json",
...LOCAL_EVIDENCE_POLICY_INPUT_PATHS,
]);
export type DistOutput = Readonly<{
path: string;
bytes: number;
gzipBytes: number;
sha256: string;
}>;
export async function collectDistOutputs(
repositoryRoot = process.cwd(),
): Promise<DistOutput[]> {
const distRoot = path.resolve(repositoryRoot, "dist");
const files = await regularFilesWithin(distRoot);
if (files.length === 0) {
throw new Error("dist is missing or empty; run the production build first");
}
return Promise.all(
files.map(async (absolutePath) => {
const content = await readFile(absolutePath);
return Object.freeze({
path: path
.relative(repositoryRoot, absolutePath)
.replaceAll(path.sep, "/"),
bytes: content.byteLength,
gzipBytes: gzipSync(content).byteLength,
sha256: createHash("sha256").update(content).digest("hex"),
});
}),
);
}
export function distSha256(outputs: readonly DistOutput[]): string {
return supplyChainDigest(
outputs.map(({ path: outputPath, bytes, sha256 }) => ({
path: outputPath,
bytes,
sha256,
})),
);
}
export async function createReleaseCandidateManifest(
repositoryRoot = process.cwd(),
): Promise<ReleaseCandidateManifest> {
const outputs = await collectDistOutputs(repositoryRoot);
const evidence = await Promise.all(
RELEASE_CANDIDATE_EVIDENCE_PATHS.map((file) =>
digestRequiredFile(repositoryRoot, file),
),
);
const files = [
...outputs.map(({ path: outputPath, bytes, sha256 }) => ({
path: outputPath,
bytes,
sha256,
})),
...evidence,
].sort((left, right) => asciiCompare(left.path, right.path));
const dependencyInventory = JSON.parse(
await readFile(
path.resolve(repositoryRoot, "artifacts/release/dependency-inventory.json"),
"utf8",
),
) as { lockfileSha256?: unknown };
const rawLockfileSha256 = evidence.find(
(file) => file.path === "pnpm-lock.yaml",
)?.sha256;
if (
typeof rawLockfileSha256 !== "string" ||
dependencyInventory.lockfileSha256 !== rawLockfileSha256
) {
throw new Error(
"raw pnpm-lock digest mismatch with dependency inventory",
);
}
return releaseCandidateManifestSchema.parse({
schemaVersion: 1,
distSha256: distSha256(outputs),
lockfileSha256: rawLockfileSha256,
bundleSha256: supplyChainDigest(files),
files,
});
}
function asciiCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
export async function verifyReleaseCandidate(
value: unknown,
repositoryRoot = process.cwd(),
): Promise<Readonly<{
manifest: ReleaseCandidateManifest | null;
currentDistSha256: string | null;
failures: readonly string[];
}>> {
const parsed = releaseCandidateManifestSchema.safeParse(value);
if (!parsed.success) {
return Object.freeze({
manifest: null,
currentDistSha256: null,
failures: Object.freeze(["release candidate manifest schema mismatch"]),
});
}
const failures: string[] = [];
let actual: ReleaseCandidateManifest | null = null;
try {
actual = await createReleaseCandidateManifest(repositoryRoot);
} catch (error) {
failures.push(
`release candidate inputs unreadable: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (actual) {
if (parsed.data.distSha256 !== actual.distSha256) {
failures.push("release candidate dist digest mismatch");
}
if (parsed.data.lockfileSha256 !== actual.lockfileSha256) {
failures.push("release candidate lockfile digest mismatch");
}
if (parsed.data.bundleSha256 !== actual.bundleSha256) {
failures.push("release candidate bundle digest mismatch");
}
if (JSON.stringify(parsed.data.files) !== JSON.stringify(actual.files)) {
failures.push("release candidate file set or file digest mismatch");
}
}
return Object.freeze({
manifest: parsed.data,
currentDistSha256: actual?.distSha256 ?? null,
failures: Object.freeze(failures),
});
}
async function digestRequiredFile(repositoryRoot: string, file: string) {
const absolutePath = path.resolve(repositoryRoot, file);
const relative = path.relative(repositoryRoot, absolutePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`candidate path escapes repository root: ${file}`);
}
const metadata = await lstat(absolutePath);
if (!metadata.isFile()) {
throw new Error(`candidate input is not a regular file: ${file}`);
}
const content = await readFile(absolutePath);
return Object.freeze({
path: file,
bytes: content.byteLength,
sha256: createHash("sha256").update(content).digest("hex"),
});
}
async function regularFilesWithin(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries.sort((left, right) =>
asciiCompare(left.name, right.name),
)) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...(await regularFilesWithin(target)));
} else if (entry.isFile()) {
files.push(target);
} else {
throw new Error(`dist contains a non-regular entry: ${target}`);
}
}
return files;
}
+19
View File
@@ -0,0 +1,19 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { supplyChainDigest } from "./supply-chain.ts";
export async function digestReleaseInputFiles(
files: readonly string[],
readBytes: (file: string) => Promise<Buffer> = readFile,
): Promise<string> {
const rows = await Promise.all(
[...files].sort().map(async (file) => ({
path: file,
sha256: createHash("sha256")
.update(await readBytes(file))
.digest("hex"),
})),
);
return supplyChainDigest(rows);
}
+85
View File
@@ -0,0 +1,85 @@
import { isVersionCompatible } from "../../src/application/policies/compatibility.ts";
import { verifyContractSet } from "../../src/contracts/contract-set.ts";
import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts";
import type { ReleaseArtifact } from "../../src/contracts/release-artifacts.ts";
import { compareReleaseToRuntime } from "../../src/contracts/release-tokens.ts";
export type ReleaseRuntimeCoherenceInput = Readonly<{
release: ReleaseArtifact;
runtime: Readonly<{
BUILD_ID: string;
CONFIG_SCHEMA_VERSION: string;
API_CONTRACT_VERSION?: string;
RELEASE_ID: string;
}>;
contractPackages: readonly InstalledContractPackageIdentity[];
}>;
export type ReleaseRuntimeCoherence = Readonly<{
compatible: boolean;
mismatches: readonly string[];
warnings: readonly string[];
}>;
/**
* Verifies the runtime identity using the release schema's own contract model.
* V1 retains the scalar compatibility policy. V2 has no scalar projection: its
* identity is the exact compiled package tuple set and canonical set digest.
*/
export async function verifyReleaseRuntimeCoherence(
input: ReleaseRuntimeCoherenceInput,
): Promise<ReleaseRuntimeCoherence> {
if (input.release.schemaVersion === 1) {
if (input.runtime.API_CONTRACT_VERSION === undefined) {
return compareWithoutContractScalar(input, ["apiContractVersion"]);
}
return compareReleaseToRuntime(input.release, {
...input.runtime,
API_CONTRACT_VERSION: input.runtime.API_CONTRACT_VERSION,
});
}
const comparison = compareWithoutContractScalar(input);
const contractSet = await verifyContractSet({
expected: input.contractPackages,
manifest: input.release.contractSet,
});
if (contractSet.ok) return comparison;
const mismatches = Object.freeze([
...comparison.mismatches,
contractSet.code,
]);
return Object.freeze({
compatible: false,
mismatches,
warnings: comparison.warnings,
});
}
function compareWithoutContractScalar(
input: Pick<ReleaseRuntimeCoherenceInput, "release" | "runtime">,
initialMismatches: readonly string[] = [],
): ReleaseRuntimeCoherence {
const mismatches = [...initialMismatches];
if (input.release.buildId !== input.runtime.BUILD_ID) {
mismatches.push("buildId");
}
if (
!isVersionCompatible(
input.release.configSchemaVersion,
input.runtime.CONFIG_SCHEMA_VERSION,
)
) {
mismatches.push("configSchemaVersion");
}
const warnings =
input.release.releaseId === input.runtime.RELEASE_ID
? []
: ["releaseId"];
return Object.freeze({
compatible: mismatches.length === 0,
mismatches: Object.freeze(mismatches),
warnings: Object.freeze(warnings),
});
}
+239
View File
@@ -0,0 +1,239 @@
import { spawnSync } from "node:child_process";
import {
cp,
mkdir,
readFile,
readdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import {
loadCiGateContract,
parseCiGateContract,
} from "../contracts/ci-gates.ts";
import { generateCiWorkflow } from "../generate-ci-workflow.ts";
export const REMOVAL_FIXTURE_COPY_TARGETS = Object.freeze([
"src", "tests", "recipes", "scripts", "schemas", "config", "public",
".gitea", ".storybook", "index.html", "package.json", "tsconfig.base.json",
"tsconfig.json", "tsconfig.app.json", "tsconfig.node.json", "tsconfig.test.json",
"tsconfig.recipes.json", "tsconfig.web-worker.json", "tsconfig.service-worker.json",
"vite.service-worker.config.ts", "vite.config.ts", "vitest.config.ts",
"playwright.config.ts", "playwright.capabilities.config.ts", "playwright.dev.config.ts",
"playwright.storybook.config.ts", "playwright.visual.config.ts", "eslint.config.ts",
".dependency-cruiser.json", ".nvmrc",
] as const);
export function requireRemovalFixtureEnvironment(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required for removal verification`);
return value;
}
export async function prepareRemovalFixture(
root: string,
copyTargets: readonly string[] = REMOVAL_FIXTURE_COPY_TARGETS,
): Promise<void> {
await rm(root, { recursive: true, force: true });
await mkdir(root, { recursive: true });
for (const target of copyTargets) {
await cp(target, path.join(root, target), { recursive: true });
}
await symlink(path.resolve("node_modules"), path.join(root, "node_modules"), "dir");
}
export function runRemovalFixturePnpm(
root: string,
pnpmCli: string,
script: string,
extra: readonly string[] = [],
): boolean {
return spawnSync(process.execPath, [pnpmCli, script, ...extra], {
cwd: root,
stdio: "inherit",
env: { ...process.env, CI_CONTRACT_MODE: "removal-fixture" },
}).status === 0;
}
export async function filesBelow(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
return (await Promise.all(entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesBelow(target) : [target];
}))).flat();
}
function isWithin(target: string, root: string): boolean {
const relative = path.relative(root, target);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
export async function runtimeImportGraph(
root: string,
runtimeSourceRoots: readonly string[],
): Promise<Readonly<{ dependentTests: readonly string[]; importingFiles: readonly string[] }>> {
const files = (await filesBelow(root))
.filter((file) => /\.(?:[cm]?ts|tsx)$/u.test(file))
.map((file) => path.resolve(file));
if (files.length === 0) {
throw new Error("removal fixture scanned module universe is empty");
}
const sourceSet = new Set(files);
const runtimeRoots = runtimeSourceRoots.map((entry) => path.resolve(root, entry));
const imports = new Map<string, readonly string[]>();
for (const file of files) {
const source = await readFile(file, "utf8");
const specifiers = [...source.matchAll(/(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu)]
.map((match) => match[1])
.filter((specifier): specifier is string => typeof specifier === "string" && specifier.startsWith("."));
imports.set(file, specifiers.map((specifier) => {
const base = path.resolve(path.dirname(file), specifier);
return [base, `${base}.ts`, `${base}.tsx`, `${base}.mts`, `${base}.cts`, path.join(base, "index.ts"), path.join(base, "index.tsx")]
.find((candidate) => sourceSet.has(candidate)) ?? base;
}));
}
const memo = new Map<string, boolean>();
const reachesRuntime = (file: string, visiting = new Set<string>()): boolean => {
if (runtimeRoots.some((runtimeRoot) => isWithin(file, runtimeRoot))) return true;
const known = memo.get(file);
if (known !== undefined) return known;
if (visiting.has(file)) return false;
visiting.add(file);
const reaches = (imports.get(file) ?? []).some((dependency) =>
runtimeRoots.some((runtimeRoot) => isWithin(dependency, runtimeRoot)) ||
(sourceSet.has(dependency) && reachesRuntime(dependency, visiting))
);
visiting.delete(file);
memo.set(file, reaches);
return reaches;
};
const testsRoot = path.resolve(root, "tests");
return Object.freeze({
dependentTests: Object.freeze(files.filter((file) => isWithin(file, testsRoot) && reachesRuntime(file))),
importingFiles: Object.freeze(files.filter((file) =>
!runtimeRoots.some((runtimeRoot) => isWithin(file, runtimeRoot)) &&
(imports.get(file) ?? []).some((dependency) =>
runtimeRoots.some((runtimeRoot) => isWithin(dependency, runtimeRoot))
)
)),
});
}
export async function removeRuntimeDependentTests(
root: string,
runtimeSourceRoots: readonly string[],
): Promise<number> {
const graph = await runtimeImportGraph(root, runtimeSourceRoots);
if (graph.dependentTests.length === 0) {
throw new Error("removal fixture: no runtime-dependent tests discovered");
}
await Promise.all(graph.dependentTests.map((file) => rm(file, { force: true })));
return graph.dependentTests.length;
}
export async function assertNoRuntimeImports(
root: string,
runtimeSourceRoots: readonly string[],
capability: string,
): Promise<void> {
const graph = await runtimeImportGraph(root, runtimeSourceRoots);
if (graph.importingFiles.length > 0) {
throw new Error(`Removed ${capability} runtime is still imported by: ${graph.importingFiles.map((file) => path.relative(root, file)).join(", ")}`);
}
}
export function pruneScriptOrchestration(
scripts: Record<string, string>,
orchestrationScript: string,
removedScripts: ReadonlySet<string>,
): void {
const command = scripts[orchestrationScript];
if (!command) return;
scripts[orchestrationScript] = command.split(" && ").filter((segment) =>
![...removedScripts].some((removed) =>
new RegExp(`(?:^|\\s)(?:corepack\\s+)?pnpm\\s+${removed.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}(?:\\s|$)`, "u").test(segment)
)
).join(" && ");
}
export async function regenerateRemovalFixtureWorkflow(root: string): Promise<void> {
const contract = await loadCiGateContract(root, { mode: "removal-fixture" });
await generateCiWorkflow({ root, contract, check: false });
}
export async function pruneRemovalFixtureCiContract(options: Readonly<{
root: string;
removedScripts: ReadonlySet<string>;
removedEvidencePathFragments: readonly string[];
}>): Promise<void> {
const packagePath = path.join(options.root, "package.json");
const gatesPath = path.join(options.root, "config/ci/gates.json");
const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as {
scripts: Record<string, string>;
};
for (const script of options.removedScripts) delete packageDocument.scripts[script];
packageDocument.scripts["check:ci-workflow"] =
"node scripts/generate-ci-workflow.ts --check --reduced-removal-fixture";
packageDocument.scripts["check:ci"] =
"corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts --reduced-removal-fixture && corepack pnpm check:ci-workflow";
const contract = structuredClone(
parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))),
);
const removedCommandIds = new Set(
contract.commands
.filter(({ script }) => options.removedScripts.has(script))
.map(({ id }) => id),
);
const missing = [...options.removedScripts].filter(
(script) => !contract.commands.some((command) => command.script === script),
);
if (missing.length > 0) {
throw new Error(`removal fixture CI command set is incomplete: ${missing.join(", ")}`);
}
const removedArtifactIds = new Set(
contract.artifacts
.filter(({ path: artifactPath }) =>
options.removedEvidencePathFragments.some((fragment) => artifactPath.includes(fragment))
)
.map(({ id }) => id),
);
for (const fragment of options.removedEvidencePathFragments) {
if (!contract.artifacts.some(({ path: artifactPath }) => artifactPath.includes(fragment))) {
throw new Error(`removal fixture CI evidence is missing: ${fragment}`);
}
}
contract.commands = contract.commands.filter(({ id }) => !removedCommandIds.has(id));
contract.artifacts = contract.artifacts
.filter(({ id }) => !removedArtifactIds.has(id))
.map((artifact) => artifact.production === "command-generated"
? {
...artifact,
producerCommandIds: artifact.producerCommandIds.filter(
(commandId) => !removedCommandIds.has(commandId),
),
}
: artifact)
.filter((artifact) =>
artifact.production !== "command-generated" || artifact.producerCommandIds.length > 0
);
const retainedArtifactIds = new Set(contract.artifacts.map(({ id }) => id));
for (const gate of contract.gates) {
gate.commandIds = gate.commandIds.filter((commandId) => !removedCommandIds.has(commandId));
gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter((artifactId) =>
retainedArtifactIds.has(artifactId)
);
}
const referencedSchemaIds = new Set(contract.artifacts.map(({ schemaId }) => schemaId));
contract.artifactSchemas = contract.artifactSchemas.filter(({ id }) =>
referencedSchemaIds.has(id)
);
const validated = parseCiGateContract(contract, { mode: "removal-fixture" });
await Promise.all([
writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`),
writeFile(gatesPath, `${JSON.stringify(validated, null, 2)}\n`),
]);
}
+327
View File
@@ -0,0 +1,327 @@
import { spawnSync } from "node:child_process";
import {
lstat,
open,
readdir,
realpath,
} from "node:fs/promises";
import type { Stats } from "node:fs";
import path from "node:path";
export type GitFileListResult = Readonly<{
error?: Error;
status: number | null;
signal: NodeJS.Signals | null;
stdout: Buffer;
stderr: Buffer;
}>;
export type RepositoryFileInventory = Readonly<{
trackedFiles: readonly string[];
generatedFiles: readonly string[];
files: readonly string[];
}>;
export type RepositoryFileInventoryPolicy = Readonly<{
trackedRoots: readonly string[];
generatedRoots: readonly string[];
optionalRoots: readonly string[];
}>;
type InventoryOptions = Readonly<{
repositoryRoot?: string;
trackedRoots: readonly string[];
generatedRoots?: readonly string[];
optionalRoots?: readonly string[];
runGit?: (repositoryRoot: string) => GitFileListResult;
lstatPath?: (target: string) => Promise<Stats>;
realpathPath?: (target: string) => Promise<string>;
assertReadable?: (target: string) => Promise<void>;
}>;
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function requiredStringArray(
value: unknown,
label: string,
options: Readonly<{ allowEmpty: boolean }>,
): readonly string[] {
if (
!Array.isArray(value) ||
(!options.allowEmpty && value.length === 0) ||
value.some((entry) => typeof entry !== "string" || entry.length === 0)
) {
throw new TypeError(`${label} must be an array of non-empty strings`);
}
if (new Set(value).size !== value.length) {
throw new TypeError(`${label} must not contain duplicate roots`);
}
return Object.freeze([...value] as string[]);
}
export function parseRepositoryFileInventoryPolicy(
value: unknown,
): RepositoryFileInventoryPolicy {
if (!isRecord(value)) {
throw new TypeError("repository inventory policy must be an object");
}
return Object.freeze({
trackedRoots: requiredStringArray(value.trackedRoots, "trackedRoots", {
allowEmpty: false,
}),
generatedRoots: requiredStringArray(
value.generatedRoots,
"generatedRoots",
{ allowEmpty: true },
),
optionalRoots:
value.optionalRoots === undefined
? Object.freeze([])
: requiredStringArray(value.optionalRoots, "optionalRoots", {
allowEmpty: true,
}),
});
}
function defaultGitFileList(repositoryRoot: string): GitFileListResult {
const result = spawnSync("git", ["ls-files", "-z"], {
cwd: repositoryRoot,
encoding: "buffer",
maxBuffer: 64 * 1024 * 1024,
});
return {
...(result.error ? { error: result.error } : {}),
status: result.status,
signal: result.signal,
stdout: result.stdout ?? Buffer.alloc(0),
stderr: result.stderr ?? Buffer.alloc(0),
};
}
async function defaultAssertReadable(target: string): Promise<void> {
const handle = await open(target, "r");
await handle.close();
}
export function normalizeRepositoryRelativePath(
value: string,
label = "repository path",
): string {
if (
value.length === 0 ||
path.posix.isAbsolute(value) ||
path.win32.isAbsolute(value) ||
value.includes("\\") ||
value.includes("\0") ||
value.endsWith("/")
) {
throw new TypeError(`${label} must be a repository-relative POSIX path`);
}
const normalized = path.posix.normalize(value);
if (
normalized === "." ||
normalized === ".." ||
normalized.startsWith("../") ||
normalized !== value
) {
throw new TypeError(`${label} must be a repository-relative POSIX path`);
}
return normalized;
}
function isWithinRoot(root: string, target: string): boolean {
const relative = path.relative(root, target);
return (
relative === "" ||
(relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative))
);
}
function parseGitFileList(result: GitFileListResult): string[] {
if (
result.error ||
result.status !== 0 ||
result.signal !== null ||
result.stderr.byteLength > 0
) {
const detail = result.error?.message ?? result.stderr.toString("utf8").trim();
throw new Error(
`git ls-files failed${result.signal ? ` (${result.signal})` : ""}${detail ? `: ${detail}` : ""}`,
);
}
if (result.stdout.byteLength === 0) return [];
if (result.stdout.at(-1) !== 0) {
throw new TypeError("git ls-files returned output without a terminal NUL");
}
let decoded: string;
try {
decoded = utf8Decoder.decode(result.stdout);
} catch (error) {
throw new TypeError("git ls-files returned malformed UTF-8", {
cause: error,
});
}
const rows = decoded.slice(0, -1).split("\0");
if (rows.some((row) => row.length === 0)) {
throw new TypeError("git ls-files returned an empty NUL-delimited path");
}
const normalized = rows.map((row) =>
normalizeRepositoryRelativePath(row, "git ls-files path"),
);
if (new Set(normalized).size !== normalized.length) {
throw new TypeError("git ls-files returned a duplicate path");
}
return normalized;
}
function hasErrorCode(error: unknown, code: string): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === code
);
}
export async function buildRepositoryFileInventory(
options: InventoryOptions,
): Promise<RepositoryFileInventory> {
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
const lstatPath = options.lstatPath ?? lstat;
const realpathPath = options.realpathPath ?? realpath;
const assertReadable = options.assertReadable ?? defaultAssertReadable;
const trackedRoots = options.trackedRoots.map((root) =>
normalizeRepositoryRelativePath(root, "tracked root"),
);
const generatedRoots = (options.generatedRoots ?? []).map((root) =>
normalizeRepositoryRelativePath(root, "generated root"),
);
const optionalRoots = new Set(
(options.optionalRoots ?? []).map((root) =>
normalizeRepositoryRelativePath(root, "optional root"),
),
);
for (const root of optionalRoots) {
if (!generatedRoots.includes(root)) {
throw new TypeError(`optional root is not generated: ${root}`);
}
}
const resolvedRepositoryRoot = await realpathPath(repositoryRoot);
async function validateRegularFile(relativePath: string): Promise<void> {
const absolutePath = path.resolve(repositoryRoot, relativePath);
if (!isWithinRoot(repositoryRoot, absolutePath)) {
throw new TypeError(`repository inventory path escapes root: ${relativePath}`);
}
const metadata = await lstatPath(absolutePath);
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new TypeError(`repository inventory path is not a regular file: ${relativePath}`);
}
const resolvedPath = await realpathPath(absolutePath);
if (!isWithinRoot(resolvedRepositoryRoot, resolvedPath)) {
throw new TypeError(`repository inventory symlink escapes root: ${relativePath}`);
}
try {
await assertReadable(absolutePath);
} catch (error) {
throw new Error(`repository inventory file is unreadable: ${relativePath}`, {
cause: error,
});
}
}
async function validateRoot(
relativeRoot: string,
optional: boolean,
): Promise<Stats | null> {
const absoluteRoot = path.resolve(repositoryRoot, relativeRoot);
try {
const metadata = await lstatPath(absoluteRoot);
const resolvedRoot = await realpathPath(absoluteRoot);
if (!isWithinRoot(resolvedRepositoryRoot, resolvedRoot)) {
throw new TypeError(`repository root escapes repository: ${relativeRoot}`);
}
if (metadata.isSymbolicLink() || (!metadata.isFile() && !metadata.isDirectory())) {
throw new TypeError(`repository root is not a regular file or directory: ${relativeRoot}`);
}
return metadata;
} catch (error) {
if (optional && hasErrorCode(error, "ENOENT")) return null;
throw new Error(`required repository root is unavailable: ${relativeRoot}`, {
cause: error,
});
}
}
for (const root of trackedRoots) {
await validateRoot(root, false);
}
const trackedFiles = parseGitFileList(
(options.runGit ?? defaultGitFileList)(repositoryRoot),
).sort();
for (const root of trackedRoots) {
if (!trackedFiles.some((file) => file === root || file.startsWith(`${root}/`))) {
throw new Error(`required tracked file inventory is empty: ${root}`);
}
}
for (const file of trackedFiles) {
await validateRegularFile(file);
}
const generatedFiles: string[] = [];
async function collectGenerated(relativeTarget: string): Promise<void> {
const absoluteTarget = path.resolve(repositoryRoot, relativeTarget);
const metadata = await lstatPath(absoluteTarget);
if (metadata.isSymbolicLink()) {
throw new TypeError(`generated inventory path is a symlink: ${relativeTarget}`);
}
if (metadata.isFile()) {
await validateRegularFile(relativeTarget);
generatedFiles.push(relativeTarget);
return;
}
if (!metadata.isDirectory()) {
throw new TypeError(`generated inventory path is not regular: ${relativeTarget}`);
}
const entries = await readdir(absoluteTarget, { withFileTypes: true });
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
await collectGenerated(
normalizeRepositoryRelativePath(
`${relativeTarget}/${entry.name}`,
"generated inventory path",
),
);
}
}
for (const root of generatedRoots) {
const metadata = await validateRoot(root, optionalRoots.has(root));
if (metadata) await collectGenerated(root);
}
if (new Set(generatedFiles).size !== generatedFiles.length) {
throw new TypeError("generated inventory contains duplicate paths");
}
const uniqueTracked = [...trackedFiles].sort();
const uniqueGenerated = [...generatedFiles].sort();
const trackedSet = new Set(uniqueTracked);
const collisions = uniqueGenerated.filter((file) => trackedSet.has(file));
if (collisions.length > 0) {
throw new TypeError(
`tracked and generated inventory paths collide: ${collisions.join(", ")}`,
);
}
return Object.freeze({
trackedFiles: Object.freeze(uniqueTracked),
generatedFiles: Object.freeze(uniqueGenerated),
files: Object.freeze([...uniqueTracked, ...uniqueGenerated].sort()),
});
}
+346
View File
@@ -0,0 +1,346 @@
import { randomUUID } from "node:crypto";
import { constants, type Stats } from "node:fs";
import {
lstat,
mkdir,
open,
realpath,
rename,
rm,
} from "node:fs/promises";
import path from "node:path";
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
type WritableHandle = Readonly<{
writeFile(data: string): Promise<unknown>;
sync(): Promise<unknown>;
close(): Promise<unknown>;
}>;
type DirectoryHandle = Readonly<{
sync(): Promise<unknown>;
close(): Promise<unknown>;
}>;
export type RiskCoverageArtifactFileSystem = Readonly<{
openFile(target: string, flags: number, mode: number): Promise<WritableHandle>;
openDirectory(target: string): Promise<DirectoryHandle>;
rename(source: string, destination: string): Promise<unknown>;
rm(target: string, options: Readonly<{ force: true }>): Promise<unknown>;
}>;
type WriterDependencies = Readonly<{
createNonce?: () => string;
fileSystem?: RiskCoverageArtifactFileSystem;
}>;
type ReadableHandle = Readonly<{
stat(): Promise<Stats>;
readFile(encoding: "utf8"): Promise<string>;
close(): Promise<unknown>;
}>;
type InputDependencies = Readonly<{
lstatPath?: (target: string) => Promise<Stats>;
realpathPath?: (target: string) => Promise<string>;
openFile?: (target: string, flags: number) => Promise<ReadableHandle>;
}>;
const defaultFileSystem: RiskCoverageArtifactFileSystem = Object.freeze({
openFile: async (target, flags, mode) => {
const handle = await open(target, flags, mode);
return {
writeFile: async (data) => handle.writeFile(data, "utf8"),
sync: async () => handle.sync(),
close: async () => handle.close(),
};
},
openDirectory: async (target) => {
const handle = await open(target, constants.O_RDONLY);
return {
sync: async () => handle.sync(),
close: async () => handle.close(),
};
},
rename,
rm,
});
function hasErrorCode(error: unknown, code: string): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === code
);
}
function isWithin(root: string, target: string): boolean {
const relative = path.relative(root, target);
return (
relative === "" ||
(relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative))
);
}
function hasStableIdentity(metadata: Stats): boolean {
return (
Number.isSafeInteger(metadata.dev) &&
Number.isSafeInteger(metadata.ino) &&
metadata.dev > 0 &&
metadata.ino > 0
);
}
function sameFileIdentity(before: Stats, after: Stats): boolean {
if (!hasStableIdentity(before) || !hasStableIdentity(after)) {
throw new TypeError("stable file identity unavailable");
}
return before.dev === after.dev && before.ino === after.ino;
}
async function rejectSymlinkAncestors(
repositoryRoot: string,
relativePath: string,
label: string,
lstatPath: (target: string) => Promise<Stats>,
): Promise<void> {
let current = repositoryRoot;
let currentRelative = "";
const directory = path.posix.dirname(relativePath);
if (directory === ".") return;
for (const segment of directory.split("/")) {
current = path.join(current, segment);
currentRelative = currentRelative ? `${currentRelative}/${segment}` : segment;
const metadata = await lstatPath(current);
if (metadata.isSymbolicLink()) {
throw new TypeError(`${label} ancestor is a symlink: ${currentRelative}`);
}
if (!metadata.isDirectory()) {
throw new TypeError(`${label} ancestor is not a directory: ${currentRelative}`);
}
}
}
function assertDirectory(metadata: Stats, relativePath: string): void {
if (metadata.isSymbolicLink()) {
throw new TypeError(`artifact ancestor is a symlink: ${relativePath}`);
}
if (!metadata.isDirectory()) {
throw new TypeError(`artifact ancestor is not a directory: ${relativePath}`);
}
}
export async function readRiskCoverageInput(input: Readonly<{
repositoryRoot: string;
relativePath: string;
label: string;
}>, dependencies: InputDependencies = {}): Promise<Readonly<{
relativePath: string;
absolutePath: string;
text: string;
}>> {
const lstatPath = dependencies.lstatPath ?? lstat;
const realpathPath = dependencies.realpathPath ?? realpath;
const openFile = dependencies.openFile ??
((target: string, flags: number) => open(target, flags));
const relativePath = normalizeRepositoryRelativePath(
input.relativePath,
`${input.label} path`,
);
const repositoryRoot = path.resolve(input.repositoryRoot);
const repositoryRealpath = await realpathPath(repositoryRoot);
const absolutePath = path.resolve(repositoryRoot, relativePath);
await rejectSymlinkAncestors(
repositoryRoot,
relativePath,
input.label,
lstatPath,
);
const metadata = await lstatPath(absolutePath);
if (metadata.isSymbolicLink()) {
throw new TypeError(`${input.label} path is a symlink: ${relativePath}`);
}
if (!metadata.isFile()) {
throw new TypeError(`${input.label} path is not a regular file: ${relativePath}`);
}
const resolvedPath = await realpathPath(absolutePath);
if (!isWithin(repositoryRealpath, resolvedPath)) {
throw new TypeError(`${input.label} path is outside repository: ${relativePath}`);
}
const handle = await openFile(
absolutePath,
constants.O_RDONLY | constants.O_NOFOLLOW,
);
try {
const openedMetadata = await handle.stat();
if (!openedMetadata.isFile()) {
throw new TypeError(`${input.label} path is not a regular file: ${relativePath}`);
}
if (!sameFileIdentity(metadata, openedMetadata)) {
throw new TypeError(`${input.label} path changed during validation: ${relativePath}`);
}
const text = await handle.readFile("utf8");
return Object.freeze({ relativePath, absolutePath, text });
} finally {
await handle.close();
}
}
export async function resolveRiskCoverageArtifactPath(input: Readonly<{
repositoryRoot: string;
relativePath: string;
inputPaths: readonly string[];
}>): Promise<string> {
const relativePath = normalizeRepositoryRelativePath(
input.relativePath,
"artifact path",
);
if (!relativePath.startsWith("artifacts/quality/")) {
throw new TypeError("artifact path must be below artifacts/quality");
}
const normalizedInputs = input.inputPaths.map((inputPath) =>
normalizeRepositoryRelativePath(inputPath, "input path"),
);
if (normalizedInputs.includes(relativePath)) {
throw new TypeError(`artifact path must not overwrite an input: ${relativePath}`);
}
const repositoryRoot = path.resolve(input.repositoryRoot);
const repositoryRealpath = await realpath(repositoryRoot);
const relativeDirectory = path.posix.dirname(relativePath);
let currentDirectory = repositoryRoot;
let currentRelative = "";
for (const segment of relativeDirectory.split("/")) {
currentDirectory = path.join(currentDirectory, segment);
currentRelative = currentRelative ? `${currentRelative}/${segment}` : segment;
let metadata: Stats;
try {
metadata = await lstat(currentDirectory);
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
try {
await mkdir(currentDirectory);
} catch (mkdirError) {
if (!hasErrorCode(mkdirError, "EEXIST")) throw mkdirError;
}
metadata = await lstat(currentDirectory);
}
assertDirectory(metadata, currentRelative);
const resolvedDirectory = await realpath(currentDirectory);
if (!isWithin(repositoryRealpath, resolvedDirectory)) {
throw new TypeError(`artifact ancestor is outside repository: ${currentRelative}`);
}
}
const absolutePath = path.resolve(repositoryRoot, relativePath);
let destinationMetadata: Stats | undefined;
try {
destinationMetadata = await lstat(absolutePath);
if (destinationMetadata.isSymbolicLink()) {
throw new TypeError(`artifact path is a symlink: ${relativePath}`);
}
if (!destinationMetadata.isFile()) {
throw new TypeError(`artifact path is not a regular file: ${relativePath}`);
}
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
}
if (destinationMetadata) {
if (!hasStableIdentity(destinationMetadata)) {
throw new TypeError("artifact stable file identity unavailable");
}
const destinationRealpath = await realpath(absolutePath);
for (const inputPath of normalizedInputs) {
const inputAbsolutePath = path.resolve(repositoryRoot, inputPath);
const inputRealpath = await realpath(inputAbsolutePath);
const inputMetadata = await lstat(inputAbsolutePath);
if (!hasStableIdentity(inputMetadata)) {
throw new TypeError(`input stable file identity unavailable: ${inputPath}`);
}
if (
inputRealpath === destinationRealpath ||
(inputMetadata.dev === destinationMetadata.dev &&
inputMetadata.ino === destinationMetadata.ino)
) {
throw new TypeError(`artifact path is the same file as an input: ${inputPath}`);
}
}
}
return absolutePath;
}
export async function writeRiskCoverageArtifactAtomic(
input: Readonly<{
repositoryRoot: string;
relativePath: string;
inputPaths: readonly string[];
value: unknown;
}>,
dependencies: WriterDependencies = {},
): Promise<void> {
const serialized = JSON.stringify(input.value, null, 2);
if (serialized === undefined) {
throw new TypeError("risk coverage artifact is not JSON serializable");
}
const destination = await resolveRiskCoverageArtifactPath(input);
const temporaryPath = path.join(
path.dirname(destination),
`.${path.basename(destination)}.${(dependencies.createNonce ?? randomUUID)()}.tmp`,
);
const fileSystem = dependencies.fileSystem ?? defaultFileSystem;
let ownsTemporaryFile = false;
try {
const handle = await fileSystem.openFile(
temporaryPath,
constants.O_WRONLY |
constants.O_CREAT |
constants.O_EXCL |
constants.O_NOFOLLOW,
0o600,
);
ownsTemporaryFile = true;
let primaryFailure: unknown;
try {
await handle.writeFile(`${serialized}\n`);
await handle.sync();
} catch (error) {
primaryFailure = error;
}
try {
await handle.close();
} catch (error) {
primaryFailure ??= error;
}
if (primaryFailure !== undefined) throw primaryFailure;
await fileSystem.rename(temporaryPath, destination);
ownsTemporaryFile = false;
const directoryHandle = await fileSystem.openDirectory(path.dirname(destination));
try {
try {
await directoryHandle.sync();
} catch (error) {
// Windows and some filesystems do not support fsync on directory handles.
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) {
throw error;
}
}
} finally {
await directoryHandle.close();
}
} catch (error) {
if (ownsTemporaryFile) {
try {
await fileSystem.rm(temporaryPath, { force: true });
} catch {
// Preserve the publication failure and clean only our nonce-owned path.
}
}
throw error;
}
}
+901
View File
@@ -0,0 +1,901 @@
import { constants, type Dirent, type Stats } from "node:fs";
import {
lstat,
open,
readdir,
realpath,
} from "node:fs/promises";
import path from "node:path";
import babelParser from "@babel/eslint-parser";
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
const coverageMetrics = [
"lines",
"statements",
"functions",
"branches",
] as const;
const teamIdPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const maximumWaiverDurationMs = 90 * 24 * 60 * 60 * 1_000;
export const REQUIRED_HIGH_RISK_PATHS = Object.freeze([
"src/adapters/http/http-execution-v3.ts",
"src/adapters/http/request-builder.ts",
"src/adapters/http/bounded-body-reader.ts",
"src/adapters/http/bounded-json.ts",
"src/bootstrap/read-bounded-boot-json.ts",
"src/adapters/service-worker/service-worker-lifecycle.ts",
"src/adapters/query-cache/server-state-scope-runtime.ts",
"src/bootstrap/load-release-manifest.ts",
] as const);
type CoverageMetric = (typeof coverageMetrics)[number];
type Thresholds = Readonly<Record<CoverageMetric, number>>;
type CoverageCounter = Readonly<{
total: number;
covered: number;
skipped: number;
pct: number;
}>;
type CoverageMetrics = Readonly<Record<CoverageMetric, CoverageCounter>>;
export type RiskCoveragePolicy = Readonly<{
schemaVersion: 2;
repositoryBaseline: number;
generatedPaths: readonly string[];
summary: Thresholds;
criticalModules: readonly Readonly<{
path: string;
owner: string;
minimum: Thresholds;
}>[];
highRiskPaths: readonly string[];
waivers: readonly Readonly<{
path: string;
owner: string;
reason: string;
expiresAt: string;
}>[];
}>;
export type ProductionModuleInventory = Readonly<{
files: readonly string[];
preExclusionTotal: number;
generatedExclusions: readonly string[];
counterBearingModules: readonly string[];
counterlessModules: readonly string[];
}>;
export type RiskCoverageResult = Readonly<{
status: "PASS" | "FAIL";
selectedTotal: number;
repositoryTotal: number;
counterBearingTotal: number;
instrumentedCounterBearingTotal: number;
counterlessTotal: number;
counterlessModules: readonly string[];
preExclusionTotal: number;
generatedExclusionCount: number;
generatedExclusions: readonly string[];
ownershipScope: "ALL_POLICY_HIGH_RISK";
ownedHighRiskPaths: readonly string[];
waivedHighRiskPaths: readonly string[];
uncoveredModules: readonly string[];
results: readonly Readonly<{
scope: string;
metric: CoverageMetric;
threshold: number;
received: number;
passed: boolean;
}>[];
failures: readonly string[];
}>;
type ReadableFileHandle = Readonly<{
stat(): Promise<Stats>;
readFile(encoding: "utf8"): Promise<string>;
close(): Promise<unknown>;
}>;
type InventoryOptions = Readonly<{
repositoryRoot?: string;
generatedPaths?: readonly string[];
readDirectory?: (target: string) => Promise<Dirent[]>;
lstatPath?: (target: string) => Promise<Stats>;
realpathPath?: (target: string) => Promise<string>;
openFile?: (target: string, flags: number) => Promise<ReadableFileHandle>;
}>;
class FileIdentityChangedError extends Error {}
class StableFileIdentityUnavailableError extends Error {}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function assertExactKeys(
value: Record<string, unknown>,
allowed: readonly string[],
label: string,
): void {
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
if (unknown.length > 0) {
throw new TypeError(`${label} has unknown fields: ${unknown.sort().join(", ")}`);
}
}
function exactSourcePath(value: unknown, label: string): string {
if (
typeof value !== "string" ||
["*", "?", "[", "]", "{", "}"].some((character) => value.includes(character))
) {
throw new TypeError(`${label} must be an exact repository-relative POSIX path`);
}
const normalized = normalizeRepositoryRelativePath(value, label);
if (!normalized.startsWith("src/") || !/\.tsx?$/u.test(normalized)) {
throw new TypeError(`${label} must identify a TypeScript module below src`);
}
return normalized;
}
function uniquePaths(
value: unknown,
label: string,
options: Readonly<{ allowEmpty: boolean }> = { allowEmpty: true },
): readonly string[] {
if (!Array.isArray(value) || (!options.allowEmpty && value.length === 0)) {
throw new TypeError(
`${label} must be an array${options.allowEmpty ? "" : " with at least one path"}`,
);
}
const paths = value.map((entry) => exactSourcePath(entry, `${label} entry`));
if (new Set(paths).size !== paths.length) {
throw new TypeError(`${label} contains a duplicate path`);
}
return Object.freeze(paths);
}
function teamId(value: unknown, label: string): string {
if (typeof value !== "string" || !teamIdPattern.test(value)) {
throw new TypeError(`${label} must be a canonical team id`);
}
return value;
}
function thresholds(value: unknown, label: string): Thresholds {
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
assertExactKeys(value, coverageMetrics, label);
if (coverageMetrics.some((metric) => !(metric in value))) {
throw new TypeError(`${label} must define all coverage metrics`);
}
const parsed = {} as Record<CoverageMetric, number>;
for (const metric of coverageMetrics) {
const threshold = value[metric];
if (
typeof threshold !== "number" ||
!Number.isFinite(threshold) ||
threshold <= 0 ||
threshold > 100
) {
throw new TypeError(
`${label}.${metric} minimum must be a finite number greater than 0 and at most 100`,
);
}
parsed[metric] = threshold;
}
return Object.freeze(parsed);
}
function waiverReason(value: unknown, label: string): string {
if (typeof value !== "string" || value.length < 12 || value.length > 240) {
throw new TypeError(`${label} must contain 12 to 240 characters`);
}
if (value !== value.trim()) {
throw new TypeError(`${label} must not contain surrounding whitespace`);
}
if (
[...value].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 31 || codePoint === 127;
})
) {
throw new TypeError(`${label} must not contain control characters`);
}
return value;
}
export function parseRiskCoveragePolicy(
value: unknown,
options: Readonly<{ now?: number }> = {},
): RiskCoveragePolicy {
if (!isRecord(value)) throw new TypeError("risk coverage policy must be an object");
assertExactKeys(
value,
[
"schemaVersion",
"repositoryBaseline",
"generatedPaths",
"summary",
"criticalModules",
"highRiskPaths",
"waivers",
],
"risk coverage policy",
);
if (value.schemaVersion !== 2) {
throw new TypeError("risk coverage policy schemaVersion must be 2");
}
if (
typeof value.repositoryBaseline !== "number" ||
!Number.isSafeInteger(value.repositoryBaseline) ||
value.repositoryBaseline <= 0
) {
throw new TypeError("repositoryBaseline must be a positive safe integer");
}
const generatedPaths = uniquePaths(value.generatedPaths, "generatedPaths");
const highRiskPaths = uniquePaths(value.highRiskPaths, "highRiskPaths", {
allowEmpty: false,
});
if (!Array.isArray(value.criticalModules) || value.criticalModules.length === 0) {
throw new TypeError("criticalModules must be a non-empty array");
}
const criticalModules = value.criticalModules.map((candidate, index) => {
if (!isRecord(candidate)) {
throw new TypeError(`criticalModules[${index}] must be an object`);
}
assertExactKeys(candidate, ["path", "owner", "minimum"], `criticalModules[${index}]`);
return Object.freeze({
path: exactSourcePath(candidate.path, `criticalModules[${index}].path`),
owner: teamId(candidate.owner, `criticalModules[${index}].owner`),
minimum: thresholds(candidate.minimum, `criticalModules[${index}].minimum`),
});
});
if (new Set(criticalModules.map((entry) => entry.path)).size !== criticalModules.length) {
throw new TypeError("criticalModules contains a duplicate path");
}
if (!Array.isArray(value.waivers)) throw new TypeError("waivers must be an array");
const currentTime = options.now ?? Date.now();
if (!Number.isFinite(currentTime)) throw new TypeError("policy time must be finite");
const waivers = value.waivers.map((candidate, index) => {
if (!isRecord(candidate)) throw new TypeError(`waivers[${index}] must be an object`);
assertExactKeys(candidate, ["path", "owner", "reason", "expiresAt"], `waivers[${index}]`);
const waiverPath = exactSourcePath(candidate.path, `waivers[${index}].path`);
const expiresAt = candidate.expiresAt;
if (typeof expiresAt !== "string") {
throw new TypeError(`waivers[${index}].expiresAt must be a canonical UTC ISO timestamp`);
}
const expiry = Date.parse(expiresAt);
if (!Number.isFinite(expiry) || new Date(expiry).toISOString() !== expiresAt) {
throw new TypeError(`waivers[${index}].expiresAt must be a canonical UTC ISO timestamp`);
}
if (expiry <= currentTime) throw new TypeError(`waivers[${index}] is expired`);
if (expiry - currentTime > maximumWaiverDurationMs) {
throw new TypeError(`waivers[${index}] expiry must be within 90 days`);
}
if (!highRiskPaths.includes(waiverPath)) {
throw new TypeError(`waivers[${index}] is stale because ${waiverPath} is not high-risk`);
}
return Object.freeze({
path: waiverPath,
owner: teamId(candidate.owner, `waivers[${index}].owner`),
reason: waiverReason(candidate.reason, `waivers[${index}].reason`),
expiresAt,
});
});
if (new Set(waivers.map((entry) => entry.path)).size !== waivers.length) {
throw new TypeError("waivers contains a duplicate path");
}
const criticalPaths = new Set(criticalModules.map((entry) => entry.path));
const waiverPaths = new Set(waivers.map((entry) => entry.path));
for (const highRiskPath of highRiskPaths) {
if (criticalPaths.has(highRiskPath) && waiverPaths.has(highRiskPath)) {
throw new TypeError(
`high-risk module cannot have both a critical owner and waiver: ${highRiskPath}`,
);
}
if (!criticalPaths.has(highRiskPath) && !waiverPaths.has(highRiskPath)) {
throw new TypeError(`high-risk module has no owner or waiver: ${highRiskPath}`);
}
}
return Object.freeze({
schemaVersion: 2,
repositoryBaseline: value.repositoryBaseline,
generatedPaths,
summary: thresholds(value.summary, "summary"),
criticalModules: Object.freeze(criticalModules),
highRiskPaths,
waivers: Object.freeze(waivers),
});
}
export function parseRepositoryRiskCoveragePolicy(
value: unknown,
options: Readonly<{ now?: number }> = {},
): RiskCoveragePolicy {
const policy = parseRiskCoveragePolicy(value, options);
for (const requiredPath of REQUIRED_HIGH_RISK_PATHS) {
if (!policy.highRiskPaths.includes(requiredPath)) {
throw new TypeError(`required high-risk path is missing: ${requiredPath}`);
}
if (policy.generatedPaths.includes(requiredPath)) {
throw new TypeError(
`required high-risk path cannot be generated-excluded: ${requiredPath}`,
);
}
}
return policy;
}
function isWithin(root: string, target: string): boolean {
const relative = path.relative(root, target);
return (
relative === "" ||
(relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative))
);
}
function hasStableIdentity(metadata: Stats): boolean {
return (
Number.isSafeInteger(metadata.dev) &&
Number.isSafeInteger(metadata.ino) &&
metadata.dev > 0 &&
metadata.ino > 0
);
}
function sameFileIdentity(before: Stats, after: Stats): boolean {
if (!hasStableIdentity(before) || !hasStableIdentity(after)) {
throw new StableFileIdentityUnavailableError(
"stable file identity unavailable",
);
}
return before.dev === after.dev && before.ino === after.ino;
}
export function isProductionModulePath(relativePath: string): boolean {
return (
/\.tsx?$/u.test(relativePath) &&
!/\.d\.ts$/u.test(relativePath) &&
!/\.stories\.tsx?$/u.test(relativePath)
);
}
type TypeScriptAstNode = Readonly<{
type?: unknown;
body?: unknown;
declaration?: unknown;
declare?: unknown;
const?: unknown;
importKind?: unknown;
}>;
function statementIsCoverageCounterBearing(value: unknown): boolean {
if (!isRecord(value) || typeof value.type !== "string") {
throw new TypeError("TypeScript parser returned an invalid statement");
}
const statement = value as TypeScriptAstNode;
if (
[
"EmptyStatement",
"TSDeclareFunction",
"TSInterfaceDeclaration",
"TSNamespaceExportDeclaration",
"TSTypeAliasDeclaration",
].includes(statement.type as string)
) {
return false;
}
if (statement.type === "ImportDeclaration") {
return false;
}
if (
statement.type === "ExportAllDeclaration" ||
statement.type === "TSExportAssignment"
) {
return statement.type === "TSExportAssignment";
}
if (
statement.type === "ExportNamedDeclaration" ||
statement.type === "ExportDefaultDeclaration"
) {
return statement.declaration !== null && statement.declaration !== undefined
? statementIsCoverageCounterBearing(statement.declaration)
: false;
}
if (
statement.type === "FunctionDeclaration" ||
statement.type === "VariableDeclaration" ||
statement.type === "ClassDeclaration" ||
statement.type === "TSModuleDeclaration"
) {
return statement.declare !== true &&
(statement.type !== "FunctionDeclaration" || statement.body !== null);
}
if (statement.type === "TSEnumDeclaration") {
return statement.declare !== true && statement.const !== true;
}
if (statement.type === "TSImportEqualsDeclaration") {
return statement.importKind !== "type" && statement.declare !== true;
}
return true;
}
export function hasCoverageCounterBearingStatements(
source: string,
relativePath: string,
): boolean {
let parsed: unknown;
try {
parsed = babelParser.parse(source, {
sourceType: "module",
requireConfigFile: false,
filePath: relativePath,
babelOptions: {
parserOpts: {
plugins: [
"typescript",
...(relativePath.endsWith(".tsx") ? ["jsx"] : []),
],
},
},
});
} catch (error) {
throw new TypeError(`production module has invalid TypeScript syntax: ${relativePath}`, {
cause: error,
});
}
if (!isRecord(parsed) || !Array.isArray(parsed.body)) {
throw new TypeError("TypeScript parser returned an invalid program");
}
return parsed.body.some(statementIsCoverageCounterBearing);
}
export async function buildProductionModuleInventory(
options: InventoryOptions = {},
): Promise<ProductionModuleInventory> {
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
const sourceRoot = path.join(repositoryRoot, "src");
const readDirectory = options.readDirectory ??
((target: string) => readdir(target, { withFileTypes: true }));
const lstatPath = options.lstatPath ?? lstat;
const realpathPath = options.realpathPath ?? realpath;
const openFile = options.openFile ??
((target: string, flags: number) => open(target, flags));
const generatedPaths = uniquePaths(options.generatedPaths ?? [], "generatedPaths");
const generated = new Set(generatedPaths);
const repositoryRealpath = await realpathPath(repositoryRoot);
const sourceMetadata = await lstatPath(sourceRoot);
if (!sourceMetadata.isDirectory() || sourceMetadata.isSymbolicLink()) {
throw new TypeError("production source root is not a regular directory: src");
}
const sourceRealpath = await realpathPath(sourceRoot);
if (!isWithin(repositoryRealpath, sourceRealpath)) {
throw new TypeError("production source root is outside repository");
}
const allModules: string[] = [];
const counterBearingModules: string[] = [];
const counterlessModules: string[] = [];
async function visit(relativeDirectory: string): Promise<void> {
const absoluteDirectory = path.join(repositoryRoot, relativeDirectory);
const entries = await readDirectory(absoluteDirectory);
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const relativeTarget = normalizeRepositoryRelativePath(
`${relativeDirectory}/${entry.name}`,
"production inventory path",
);
const absoluteTarget = path.join(repositoryRoot, relativeTarget);
const metadata = await lstatPath(absoluteTarget);
if (metadata.isSymbolicLink() || entry.isSymbolicLink()) {
throw new TypeError(`production inventory path is a symlink: ${relativeTarget}`);
}
if (metadata.isDirectory()) {
await visit(relativeTarget);
continue;
}
if (!isProductionModulePath(relativeTarget)) continue;
if (!metadata.isFile()) {
throw new TypeError(`production inventory path is not a regular file: ${relativeTarget}`);
}
const resolvedTarget = await realpathPath(absoluteTarget);
if (!isWithin(repositoryRealpath, resolvedTarget)) {
throw new TypeError(`production inventory path is outside repository: ${relativeTarget}`);
}
let handle: ReadableFileHandle | undefined;
let source: string;
try {
handle = await openFile(
absoluteTarget,
constants.O_RDONLY | constants.O_NOFOLLOW,
);
const openedMetadata = await handle.stat();
if (!openedMetadata.isFile()) {
throw new TypeError("opened target is not a regular file");
}
if (!sameFileIdentity(metadata, openedMetadata)) {
throw new FileIdentityChangedError("opened file identity changed");
}
source = await handle.readFile("utf8");
} catch (error) {
if (error instanceof StableFileIdentityUnavailableError) {
throw new Error(
`production inventory stable file identity unavailable: ${relativeTarget}`,
{ cause: error },
);
}
if (error instanceof FileIdentityChangedError) {
throw new Error(
`production inventory file changed during validation: ${relativeTarget}`,
{ cause: error },
);
}
throw new Error(`production inventory file is unreadable: ${relativeTarget}`, {
cause: error,
});
} finally {
await handle?.close();
}
allModules.push(relativeTarget);
if (hasCoverageCounterBearingStatements(source, relativeTarget)) {
counterBearingModules.push(relativeTarget);
} else {
counterlessModules.push(relativeTarget);
}
}
}
await visit("src");
for (const generatedPath of generatedPaths) {
if (!allModules.includes(generatedPath)) {
throw new TypeError(`generated path is stale or not a production module: ${generatedPath}`);
}
}
const inventory = allModules.filter((file) => !generated.has(file)).sort();
if (inventory.length === 0) throw new Error("production module inventory is empty");
if (new Set(inventory).size !== inventory.length) {
throw new TypeError("production module inventory contains a duplicate path");
}
return Object.freeze({
files: Object.freeze(inventory),
preExclusionTotal: allModules.length,
generatedExclusions: Object.freeze([...generatedPaths].sort()),
counterBearingModules: Object.freeze(
counterBearingModules.filter((file) => !generated.has(file)).sort(),
),
counterlessModules: Object.freeze(
counterlessModules.filter((file) => !generated.has(file)).sort(),
),
});
}
export function normalizeCoverageProducerPath(input: Readonly<{
repositoryRoot: string;
rawPath: string;
platform?: "posix" | "win32";
}>): string {
const platform = input.platform ?? (process.platform === "win32" ? "win32" : "posix");
const pathApi = platform === "win32" ? path.win32 : path.posix;
const { rawPath } = input;
if (rawPath.includes("\0")) throw new TypeError("coverage path contains NUL");
if (platform === "posix" && rawPath.includes("\\")) {
throw new TypeError("coverage path must use POSIX separators");
}
if (platform === "win32" && rawPath.includes("\\") && !pathApi.isAbsolute(rawPath)) {
throw new TypeError("relative coverage path must use POSIX separators");
}
if (pathApi.isAbsolute(rawPath)) {
const root = pathApi.resolve(input.repositoryRoot);
const relative = pathApi.relative(root, pathApi.resolve(rawPath));
if (
!relative ||
relative === ".." ||
relative.startsWith(`..${pathApi.sep}`) ||
pathApi.isAbsolute(relative)
) {
throw new TypeError(`coverage path is outside repository: ${rawPath}`);
}
return normalizeRepositoryRelativePath(
relative.split(pathApi.sep).join("/"),
"coverage path",
);
}
return normalizeRepositoryRelativePath(rawPath, "coverage path");
}
function expectedPct(total: number, covered: number): number {
return total === 0 ? 100 : Math.floor((covered / total) * 10_000) / 100;
}
function parseCoverageCounter(value: unknown, label: string): CoverageCounter {
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
const fields = ["total", "covered", "skipped", "pct"] as const;
assertExactKeys(value, fields, label);
if (fields.some((field) => !(field in value))) {
throw new TypeError(`${label} must define total, covered, skipped, and pct`);
}
for (const count of ["total", "covered", "skipped"] as const) {
if (
typeof value[count] !== "number" ||
!Number.isSafeInteger(value[count]) ||
value[count] < 0
) {
throw new TypeError(`${label}.${count} must be a nonnegative safe integer`);
}
}
const total = value.total as number;
const covered = value.covered as number;
const skipped = value.skipped as number;
if (covered + skipped > total) {
throw new TypeError(`${label} covered plus skipped must not exceed total`);
}
const pct = value.pct;
const calculatedPct = expectedPct(total, covered);
if (typeof pct !== "number" || !Number.isFinite(pct) || pct !== calculatedPct) {
throw new TypeError(`${label}.pct must equal ${calculatedPct}`);
}
return Object.freeze({ total, covered, skipped, pct });
}
function parseCoverageMetrics(value: unknown, label: string): CoverageMetrics {
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
const unknownMetrics = Object.keys(value).filter(
(metric) => metric !== "branchesTrue" && !coverageMetrics.includes(metric as CoverageMetric),
);
if (unknownMetrics.length > 0) {
throw new TypeError(
`${label} has unknown coverage metric keys: ${unknownMetrics.sort().join(", ")}`,
);
}
if (value.branchesTrue !== undefined) {
parseCoverageCounter(value.branchesTrue, `${label}.branchesTrue`);
}
const parsed = {} as Record<CoverageMetric, CoverageCounter>;
for (const metric of coverageMetrics) {
parsed[metric] = parseCoverageCounter(value[metric], `${label}.${metric}`);
}
return Object.freeze(parsed);
}
function aggregateCoverage(selected: readonly CoverageMetrics[]): CoverageMetrics {
const aggregate = {} as Record<CoverageMetric, CoverageCounter>;
for (const metric of coverageMetrics) {
let total = 0;
let covered = 0;
let skipped = 0;
for (const metrics of selected) {
total += metrics[metric].total;
covered += metrics[metric].covered;
skipped += metrics[metric].skipped;
if (![total, covered, skipped].every(Number.isSafeInteger)) {
throw new TypeError(`recomputed coverage ${metric} count exceeds safe integer range`);
}
}
aggregate[metric] = Object.freeze({
total,
covered,
skipped,
pct: expectedPct(total, covered),
});
}
return Object.freeze(aggregate);
}
function assertMatchingTotal(
producer: CoverageMetrics,
recomputed: CoverageMetrics,
): void {
for (const metric of coverageMetrics) {
const actual = producer[metric];
const expected = recomputed[metric];
if (
actual.total !== expected.total ||
actual.covered !== expected.covered ||
actual.skipped !== expected.skipped ||
actual.pct !== expected.pct
) {
throw new TypeError(
`coverage total.${metric} does not match recomputed inventory total`,
);
}
}
}
export function evaluateRiskCoverage(input: Readonly<{
repositoryRoot?: string;
inventory: ProductionModuleInventory;
policy: RiskCoveragePolicy;
summary: unknown;
}>): RiskCoverageResult {
const repositoryRoot = path.resolve(input.repositoryRoot ?? process.cwd());
const inventory = input.inventory.files.map((file) => exactSourcePath(file, "inventory path"));
if (inventory.length === 0) throw new TypeError("production module inventory is empty");
if (new Set(inventory).size !== inventory.length) {
throw new TypeError("production module inventory contains a duplicate path");
}
const counterBearingModules = input.inventory.counterBearingModules.map((file) =>
exactSourcePath(file, "counter-bearing inventory path"),
);
const counterlessModules = input.inventory.counterlessModules.map((file) =>
exactSourcePath(file, "counterless inventory path"),
);
const counterBearingSet = new Set(counterBearingModules);
const counterlessSet = new Set(counterlessModules);
const partition = [...counterBearingModules, ...counterlessModules].sort();
if (
counterBearingSet.size !== counterBearingModules.length ||
counterlessSet.size !== counterlessModules.length ||
counterBearingModules.some((file) => counterlessSet.has(file)) ||
partition.join("\n") !== [...inventory].sort().join("\n")
) {
throw new TypeError("production inventory counter-bearing provenance is inconsistent");
}
const generatedExclusions = input.inventory.generatedExclusions.map((file) =>
exactSourcePath(file, "generated exclusion"),
);
if (
new Set(generatedExclusions).size !== generatedExclusions.length ||
input.inventory.preExclusionTotal !== inventory.length + generatedExclusions.length ||
!Number.isSafeInteger(input.inventory.preExclusionTotal)
) {
throw new TypeError("production inventory provenance is inconsistent");
}
const policyGenerated = [...input.policy.generatedPaths].sort();
if (generatedExclusions.join("\n") !== policyGenerated.join("\n")) {
throw new TypeError("production inventory generated exclusions do not match policy");
}
if (!isRecord(input.summary) || !("total" in input.summary)) {
throw new TypeError("coverage summary must contain total metrics");
}
const totalMetrics = parseCoverageMetrics(input.summary.total, "coverage total");
const selected = new Map<string, CoverageMetrics>();
for (const [rawPath, rawMetrics] of Object.entries(input.summary)) {
if (rawPath === "total") continue;
const normalized = normalizeCoverageProducerPath({ repositoryRoot, rawPath });
if (selected.has(normalized)) throw new TypeError(`duplicate coverage path: ${normalized}`);
selected.set(normalized, parseCoverageMetrics(rawMetrics, `coverage ${normalized}`));
}
const inventorySet = new Set(inventory);
const generatedSet = new Set(generatedExclusions);
for (const selectedPath of selected.keys()) {
if (!inventorySet.has(selectedPath) && !generatedSet.has(selectedPath)) {
throw new TypeError(`unexpected coverage path outside inventory: ${selectedPath}`);
}
}
const inventoryMetrics = inventory.flatMap((file) => {
const metrics = selected.get(file);
return metrics ? [metrics] : [];
});
const zeroCoverageModules = inventory.filter((file) => {
const metrics = selected.get(file);
return (
metrics !== undefined &&
coverageMetrics.every((metric) => metrics[metric].total === 0)
);
});
const zeroCoverageSet = new Set(zeroCoverageModules);
const zeroCounterBearingModules = zeroCoverageModules.filter((file) =>
counterBearingSet.has(file),
);
const counterlessWithCounters = counterlessModules.filter((file) => {
const metrics = selected.get(file);
return metrics !== undefined && !zeroCoverageSet.has(file);
});
const instrumentedCounterBearingTotal = counterBearingModules.filter((file) => {
const metrics = selected.get(file);
return metrics !== undefined && !zeroCoverageSet.has(file);
}).length;
assertMatchingTotal(totalMetrics, aggregateCoverage([...selected.values()]));
const recomputedInventoryMetrics = aggregateCoverage(inventoryMetrics);
const failures: string[] = [];
const results: Array<{
scope: string;
metric: CoverageMetric;
threshold: number;
received: number;
passed: boolean;
}> = [];
function evaluate(scope: string, actual: CoverageMetrics, minimum: Thresholds): void {
for (const metric of coverageMetrics) {
const threshold = minimum[metric];
const received = actual[metric].pct;
const hasCoverageTotal = actual[metric].total > 0;
const passed = hasCoverageTotal && received >= threshold;
results.push({ scope, metric, threshold, received, passed });
if (!hasCoverageTotal) {
failures.push(`${scope}.${metric} coverage total must be greater than 0`);
} else if (!passed) {
failures.push(`${scope}.${metric} expected >= ${threshold}, received ${received}`);
}
}
}
evaluate("total", recomputedInventoryMetrics, input.policy.summary);
if (inventory.length < input.policy.repositoryBaseline) {
failures.push(
`repository module baseline expected >= ${input.policy.repositoryBaseline}, received ${inventory.length}`,
);
}
const uncoveredModules = inventory
.filter(
(file) =>
!selected.has(file) ||
(counterBearingSet.has(file) && zeroCoverageSet.has(file)),
)
.sort();
failures.push(
...inventory
.filter((file) => !selected.has(file))
.map((file) => `production module missing from coverage: ${file}`),
...zeroCounterBearingModules.map(
(file) => `counter-bearing module has zero coverage totals: ${file}`,
),
...counterlessWithCounters.map(
(file) => `counterless module has coverage counters: ${file}`,
),
);
for (const modulePolicy of input.policy.criticalModules) {
if (!inventorySet.has(modulePolicy.path)) {
failures.push(`critical module is outside production inventory: ${modulePolicy.path}`);
continue;
}
if (counterlessSet.has(modulePolicy.path)) {
failures.push(
`critical policy-sensitive module cannot be counterless: ${modulePolicy.path}`,
);
}
const actual = selected.get(modulePolicy.path);
if (!actual) {
failures.push(`critical module missing from coverage: ${modulePolicy.path}`);
continue;
}
evaluate(modulePolicy.path, actual, modulePolicy.minimum);
}
const criticalPaths = new Set(input.policy.criticalModules.map((entry) => entry.path));
const waiverPaths = new Set(input.policy.waivers.map((entry) => entry.path));
for (const highRiskPath of input.policy.highRiskPaths) {
if (!inventorySet.has(highRiskPath)) {
failures.push(`high-risk module is outside production inventory: ${highRiskPath}`);
}
if (counterlessSet.has(highRiskPath)) {
failures.push(
`high-risk policy-sensitive module cannot be counterless: ${highRiskPath}`,
);
}
}
for (const waiver of input.policy.waivers) {
if (!inventorySet.has(waiver.path)) failures.push(`coverage waiver is stale: ${waiver.path}`);
}
const ownedHighRiskPaths = input.policy.highRiskPaths
.filter((modulePath) => criticalPaths.has(modulePath))
.sort();
const waivedHighRiskPaths = input.policy.highRiskPaths
.filter((modulePath) => waiverPaths.has(modulePath))
.sort();
return Object.freeze({
status: failures.length === 0 ? "PASS" : "FAIL",
selectedTotal: inventory.length - uncoveredModules.length,
repositoryTotal: inventory.length,
counterBearingTotal: counterBearingModules.length,
instrumentedCounterBearingTotal,
counterlessTotal: counterlessModules.length,
counterlessModules: Object.freeze([...counterlessModules].sort()),
preExclusionTotal: input.inventory.preExclusionTotal,
generatedExclusionCount: generatedExclusions.length,
generatedExclusions: Object.freeze([...generatedExclusions].sort()),
ownershipScope: "ALL_POLICY_HIGH_RISK",
ownedHighRiskPaths: Object.freeze(ownedHighRiskPaths),
waivedHighRiskPaths: Object.freeze(waivedHighRiskPaths),
uncoveredModules: Object.freeze(uncoveredModules),
results: Object.freeze(results),
failures: Object.freeze(failures),
});
}
+283
View File
@@ -0,0 +1,283 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
} from "./repository-file-inventory.ts";
import {
findSecretMatches,
secretScanRules,
type SecretAllowlistEntry,
type SecretFinding,
} from "./secret-scan.ts";
import {
parseSecretScanIncludedPaths,
selectIncludedInventoryFiles,
} from "./secret-scan-policy.ts";
import { supplyChainDigest } from "./supply-chain.ts";
const nonEmptyString = z.string().min(1);
const sarifRuleSchema = z
.object({
id: nonEmptyString,
shortDescription: z.object({ text: nonEmptyString }).strict(),
})
.strict();
const sarifResultSchema = z
.object({
ruleId: nonEmptyString,
message: z.object({ text: nonEmptyString }).strict(),
partialFingerprints: z
.object({ primaryLocationLineHash: nonEmptyString })
.strict()
.optional(),
locations: z
.array(
z
.object({
physicalLocation: z
.object({
artifactLocation: z.object({ uri: nonEmptyString }).strict(),
region: z.object({ startLine: z.int().positive() }).strict(),
})
.strict(),
})
.strict(),
)
.optional(),
})
.strict();
export const secretScanSarifSchema = z
.object({
version: z.literal("2.1.0"),
$schema: z.literal("https://json.schemastore.org/sarif-2.1.0.json"),
runs: z
.array(
z
.object({
tool: z
.object({
driver: z
.object({
name: z.literal("ca-frontend-secret-scan"),
rules: z.array(sarifRuleSchema),
})
.strict(),
})
.strict(),
results: z.array(sarifResultSchema),
})
.strict(),
)
.length(1),
})
.strict();
type AllowlistEntry = SecretAllowlistEntry &
Readonly<{ owner: string; reason: string }>;
export type SecretScanPolicy = Readonly<{
excludedPaths: readonly string[];
trackedRoots: readonly string[];
generatedRoots: readonly string[];
optionalRoots: readonly string[];
includedPaths: readonly string[] | null;
allowlist: readonly AllowlistEntry[];
}>;
export function parseSecretScanPolicy(value: unknown): SecretScanPolicy {
const document = isRecord(value) ? value : {};
const inventoryPolicy = parseRepositoryFileInventoryPolicy(value);
const allowlist = Array.isArray(document.allowlist)
? document.allowlist.map((rawEntry) => {
const entry = isRecord(rawEntry) ? rawEntry : {};
return Object.freeze({
path: typeof entry.path === "string" ? entry.path : "",
ruleId: typeof entry.ruleId === "string" ? entry.ruleId : "",
owner: typeof entry.owner === "string" ? entry.owner : "",
reason: typeof entry.reason === "string" ? entry.reason : "",
expiresAt:
typeof entry.expiresAt === "string" ? entry.expiresAt : "",
});
})
: [];
return Object.freeze({
excludedPaths: Object.freeze(strings(document.excludedPaths)),
trackedRoots: inventoryPolicy.trackedRoots,
generatedRoots: inventoryPolicy.generatedRoots,
optionalRoots: inventoryPolicy.optionalRoots,
includedPaths: parseSecretScanIncludedPaths(document.includedPaths),
allowlist: Object.freeze(allowlist),
});
}
export async function evaluateRepositorySecretScan(input: Readonly<{
repositoryRoot?: string;
policyPath?: string;
now?: number;
}>) {
const repositoryRoot = path.resolve(input.repositoryRoot ?? process.cwd());
const policy = parseSecretScanPolicy(
JSON.parse(
await readFile(
path.resolve(
repositoryRoot,
input.policyPath ?? "config/security/secret-scan-policy.json",
),
"utf8",
),
) as unknown,
);
const inventory = await buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: policy.trackedRoots,
generatedRoots: policy.generatedRoots,
optionalRoots: policy.optionalRoots,
});
return evaluateSecretScan({
policy,
inventoryFiles: inventory.files,
readText: (file) => readFile(path.join(repositoryRoot, file), "utf8"),
now: input.now,
});
}
export async function evaluateSecretScan(input: Readonly<{
policy: SecretScanPolicy;
inventoryFiles: readonly string[];
readText: (file: string) => Promise<string>;
now?: number;
}>) {
const now = input.now ?? Date.now();
const findings: SecretFinding[] = [];
const policyFailures: string[] = [];
for (const entry of input.policy.allowlist) {
const expiry = Date.parse(entry.expiresAt);
if (
!entry.path.startsWith("tests/") ||
!entry.owner.trim() ||
!entry.reason.trim() ||
!Number.isFinite(expiry) ||
expiry <= now
) {
policyFailures.push(
`invalid or expired secret allowlist entry: ${entry.path}:${entry.ruleId}`,
);
}
}
const scanFiles = selectIncludedInventoryFiles(
input.inventoryFiles,
input.policy.includedPaths,
);
const excluded = new Set(
input.policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")),
);
const scanInputs: Readonly<{ path: string; bytes: number; sha256: string }>[] = [];
for (const scanFile of [...new Set(scanFiles)].sort()) {
const normalized = scanFile.replaceAll("\\", "/");
if (
[...excluded].some(
(entry) =>
normalized === entry || normalized.startsWith(`${entry}/`),
) ||
/\.(?:png|jpe?g|gif|webp|woff2?|zip|gz|sarif)$/iu.test(normalized)
) {
continue;
}
const content = await input.readText(scanFile);
const bytes = Buffer.from(content, "utf8");
scanInputs.push(Object.freeze({
path: normalized,
bytes: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"),
}));
findings.push(
...findSecretMatches(normalized, content, {
allowlist: input.policy.allowlist,
now,
}),
);
}
const sarif = secretScanSarifSchema.parse({
version: "2.1.0",
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
runs: [
{
tool: {
driver: {
name: "ca-frontend-secret-scan",
rules: secretScanRules().map((pattern) => ({
id: pattern.id,
shortDescription: { text: "Potential credential material" },
})),
},
},
results: [
...findings.map((finding) => ({
ruleId: finding.ruleId,
message: { text: "Potential secret material must be removed." },
partialFingerprints: {
primaryLocationLineHash: finding.fingerprint,
},
locations: [
{
physicalLocation: {
artifactLocation: { uri: finding.file },
region: { startLine: finding.line },
},
},
],
})),
...policyFailures.map((failure) => ({
ruleId: "invalid-allowlist",
message: { text: failure },
})),
],
},
],
});
return Object.freeze({
findings: Object.freeze(findings),
policyFailures: Object.freeze(policyFailures),
scanFiles: Object.freeze([...scanFiles]),
scanInputs: Object.freeze(scanInputs),
scanInputSha256: supplyChainDigest(scanInputs),
sarif,
});
}
export function verifyStoredSecretScan(
evaluation: Awaited<ReturnType<typeof evaluateSecretScan>>,
stored: unknown,
): string[] {
const failures: string[] = [];
const blockingCount =
evaluation.findings.length + evaluation.policyFailures.length;
if (blockingCount > 0) {
failures.push(
`recomputed secret scan contains ${blockingCount} blocking result(s)`,
);
}
const parsed = secretScanSarifSchema.safeParse(stored);
if (
!parsed.success ||
supplyChainDigest(parsed.data) !== supplyChainDigest(evaluation.sarif)
) {
failures.push("stored secret scan SARIF does not match recomputed results");
}
return failures;
}
function strings(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
+59
View File
@@ -0,0 +1,59 @@
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
export function parseSecretScanIncludedPaths(
value: unknown,
): readonly string[] | null {
if (value === undefined) return null;
if (
!Array.isArray(value) ||
value.length === 0 ||
value.some(
(entry) =>
typeof entry !== "string" ||
entry.length === 0 ||
entry.trim() !== entry,
)
) {
throw new TypeError(
"includedPaths must be a non-empty array of repository-relative POSIX paths",
);
}
const normalized = value.map((entry) =>
normalizeRepositoryRelativePath(entry as string, "included path"),
);
if (new Set(normalized).size !== normalized.length) {
throw new TypeError("includedPaths must not contain duplicate paths");
}
return Object.freeze(normalized);
}
export function selectIncludedInventoryFiles(
inventoryFiles: readonly string[],
includedPaths: readonly string[] | null,
): readonly string[] {
if (includedPaths === null) return Object.freeze([...inventoryFiles]);
const validatedIncludedPaths = parseSecretScanIncludedPaths(includedPaths);
if (validatedIncludedPaths === null) {
throw new TypeError("includedPaths unexpectedly omitted");
}
for (const includedPath of validatedIncludedPaths) {
if (
!inventoryFiles.some(
(file) =>
file === includedPath || file.startsWith(`${includedPath}/`),
)
) {
throw new Error(
`secret scan included path matches no inventory file: ${includedPath}`,
);
}
}
return Object.freeze(
inventoryFiles.filter((file) =>
validatedIncludedPaths.some(
(includedPath) =>
file === includedPath || file.startsWith(`${includedPath}/`),
),
),
);
}
+73
View File
@@ -0,0 +1,73 @@
import { createHash } from "node:crypto";
export type SecretFinding = Readonly<{
ruleId: string;
file: string;
line: number;
fingerprint: string;
}>;
export type SecretAllowlistEntry = Readonly<{
path: string;
ruleId: string;
expiresAt: string;
}>;
const secretPatterns: readonly Readonly<{
id: string;
expression: RegExp;
}>[] = [
{
id: "private-key",
expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g,
},
{ id: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g },
{ id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g },
{
id: "assigned-secret",
expression:
/(?<![\w])(["']?)(?:client_secret|password|private_key)\1(?![\w])\s*[:=]\s*["'][^"'${}]{12,}["']/gi,
},
];
export function secretScanRules(): readonly Readonly<{
id: string;
expression: RegExp;
}>[] {
return secretPatterns;
}
export function findSecretMatches(
file: string,
content: string,
options: Readonly<{
allowlist?: readonly SecretAllowlistEntry[];
now?: number;
}> = {},
): SecretFinding[] {
const allowlist = options.allowlist ?? [];
const now = options.now ?? Date.now();
const findings: SecretFinding[] = [];
for (const pattern of secretPatterns) {
pattern.expression.lastIndex = 0;
for (const match of content.matchAll(pattern.expression)) {
const isAllowed = allowlist.some(
(entry) =>
entry.path === file &&
entry.ruleId === pattern.id &&
Date.parse(entry.expiresAt) > now,
);
if (isAllowed) continue;
const matchIndex = match.index ?? 0;
findings.push({
ruleId: pattern.id,
file,
line: content.slice(0, matchIndex).split(/\r?\n/u).length,
fingerprint: createHash("sha256")
.update(`${pattern.id}:${file}:${String(matchIndex)}`)
.digest("hex"),
});
}
}
return findings;
}
+170
View File
@@ -0,0 +1,170 @@
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
type ScanResult = Readonly<{
error?: Error;
status: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
}>;
type SecurityFixtureCheckDependencies = Readonly<{
createTempDirectory?: () => Promise<string>;
runScan?: (artifactPath: string, policyPath: string) => ScanResult;
readArtifact?: (artifactPath: string) => Promise<string>;
cleanup?: (directory: string) => Promise<void>;
}>;
type Document = Record<string, unknown>;
function record(value: unknown, label: string): Document {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${label} must be an object`);
}
return value as Document;
}
function defaultScan(artifactPath: string, policyPath: string): ScanResult {
const scan = spawnSync(
"node",
[
"scripts/security-scan.ts",
"--policy",
policyPath,
"--artifact",
artifactPath,
],
{ encoding: "utf8" },
);
return {
...(scan.error ? { error: scan.error } : {}),
status: scan.status,
signal: scan.signal,
stdout: scan.stdout ?? "",
stderr: scan.stderr ?? "",
};
}
function assertExactFindings(rawArtifact: string): void {
const sarif = record(JSON.parse(rawArtifact), "security fixture SARIF");
const runs = Array.isArray(sarif.runs) ? sarif.runs : [];
const run = record(runs[0], "security fixture SARIF run");
const results = Array.isArray(run.results) ? run.results : [];
const actual = results
.map((rawResult) => {
const result = record(rawResult, "security fixture result");
const locations = Array.isArray(result.locations) ? result.locations : [];
const location = record(locations[0], "security fixture location");
const physical = record(
location.physicalLocation,
"security fixture physical location",
);
const artifactLocation = record(
physical.artifactLocation,
"security fixture artifact location",
);
return `${String(artifactLocation.uri)}:${String(result.ruleId)}`;
})
.sort();
const root = "tests/fixtures/security/secret-detection/forbidden";
const expected = [
`${root}/config.json:assigned-secret`,
`${root}/dist.ts:assigned-secret`,
`${root}/source.ts:aws-access-key`,
].sort();
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`security fixture findings mismatch: expected ${expected.join(", ")}; received ${actual.join(", ")}`,
);
}
}
export async function checkSecurityFixtures(
dependencies: SecurityFixtureCheckDependencies = {},
): Promise<void> {
const createTempDirectory =
dependencies.createTempDirectory ??
(() => mkdtemp(path.join(tmpdir(), "ca-security-fixture-")));
const runScan = dependencies.runScan ?? defaultScan;
const readArtifact = dependencies.readArtifact ?? ((target) => readFile(target, "utf8"));
const cleanup =
dependencies.cleanup ??
((directory) => rm(directory, { recursive: true, force: true }));
const directory = await createTempDirectory();
const artifactPath = path.join(directory, "scan-fixture.sarif");
try {
const scan = runScan(
artifactPath,
"tests/fixtures/security/secret-detection/forbidden-policy.json",
);
const expectedDiagnostic = "Security scan found 3 blocking result(s).";
if (
scan.error ||
scan.status !== 1 ||
scan.signal !== null ||
scan.stderr !== `${expectedDiagnostic}\n`
) {
throw new Error(
`forbidden security fixture did not fail exactly: ${scan.error?.message ?? scan.stderr}`,
);
}
assertExactFindings(await readArtifact(artifactPath));
} finally {
await cleanup(directory);
}
}
export async function checkNonmatchingSecurityIncludeFixture(
dependencies: SecurityFixtureCheckDependencies = {},
): Promise<void> {
const createTempDirectory =
dependencies.createTempDirectory ??
(() => mkdtemp(path.join(tmpdir(), "ca-security-include-fixture-")));
const runScan = dependencies.runScan ?? defaultScan;
const readArtifact =
dependencies.readArtifact ?? ((target) => readFile(target, "utf8"));
const cleanup =
dependencies.cleanup ??
((directory) => rm(directory, { recursive: true, force: true }));
const directory = await createTempDirectory();
const artifactPath = path.join(directory, "scan-fixture.sarif");
try {
const includedPath =
"tests/fixtures/security/secret-detection/misspelled";
const scan = runScan(
artifactPath,
"tests/fixtures/security/secret-detection/nonmatching-policy.json",
);
if (
scan.error ||
scan.status !== 1 ||
scan.signal !== null ||
!scan.stderr.includes(
`secret scan included path matches no inventory file: ${includedPath}`,
)
) {
throw new Error(
`nonmatching security include fixture did not fail closed: ${scan.error?.message ?? scan.stderr}`,
);
}
try {
await readArtifact(artifactPath);
} catch (error) {
if (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "ENOENT"
) {
return;
}
throw error;
}
throw new Error("nonmatching security include fixture wrote an artifact");
} finally {
await cleanup(directory);
}
}

Some files were not shown because too many files have changed in this diff Show More