From 9389b271cf4c8787bb9c92eba12686731368c0b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 00:38:42 -0700 Subject: [PATCH 01/14] test(release): define artifact manifest integrity contract --- tests/unit/release-artifact-manifest.test.mjs | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 tests/unit/release-artifact-manifest.test.mjs diff --git a/tests/unit/release-artifact-manifest.test.mjs b/tests/unit/release-artifact-manifest.test.mjs new file mode 100644 index 00000000..fa1e7c5c --- /dev/null +++ b/tests/unit/release-artifact-manifest.test.mjs @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + ReleaseArtifactManifestError, + buildReleaseArtifactManifest, + runReleaseArtifactManifestCli, + verifyReleaseArtifactManifest, +} from '../../scripts/release/release_artifact_manifest.mjs'; + +const SOURCE_REVISION = '0123456789abcdef0123456789abcdef01234567'; + +async function withTempDir(run) { + const dir = await mkdtemp(join(tmpdir(), 'scopeweave-release-manifest-')); + try { + await run(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function captureStream() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + return true; + }, + value() { + return value; + }, + }; +} + +async function expectManifestError(promise, code) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof ReleaseArtifactManifestError); + assert.equal(error.code, code); + return true; + }); +} + +await withTempDir(async (dir) => { + const browserPath = join(dir, 'browser.tar'); + const serverPath = join(dir, 'server.tar'); + await writeFile(browserPath, Buffer.from('browser-artifact\n')); + await writeFile(serverPath, Buffer.from('server-artifact\n')); + + const artifactInputs = [ + { name: 'server/server.tar', path: serverPath }, + { name: 'browser/browser.tar', path: browserPath }, + ]; + + const first = await buildReleaseArtifactManifest({ + sourceRevision: SOURCE_REVISION, + artifacts: artifactInputs, + }); + const second = await buildReleaseArtifactManifest({ + sourceRevision: SOURCE_REVISION, + artifacts: [...artifactInputs].reverse(), + }); + + assert.deepEqual(first, second, 'manifest must be deterministic regardless of input order'); + assert.equal(first.schema_version, 'scopeweave.release_artifact_manifest.v1'); + assert.equal(first.source_revision, SOURCE_REVISION); + assert.deepEqual(first.artifacts.map((entry) => entry.name), [ + 'browser/browser.tar', + 'server/server.tar', + ]); + for (const entry of first.artifacts) { + assert.match(entry.digest.sha256, /^[0-9a-f]{64}$/); + assert.ok(Number.isSafeInteger(entry.byte_length)); + assert.ok(entry.byte_length > 0); + } + assert.match(first.manifest_digest.sha256, /^[0-9a-f]{64}$/); + + const verified = await verifyReleaseArtifactManifest({ + manifest: first, + sourceRevision: SOURCE_REVISION, + artifacts: artifactInputs, + }); + assert.deepEqual(verified, { + ok: true, + source_revision: SOURCE_REVISION, + artifact_count: 2, + manifest_sha256: first.manifest_digest.sha256, + }); + + await writeFile(browserPath, Buffer.from('tampered-browser-artifact\n')); + await expectManifestError( + verifyReleaseArtifactManifest({ + manifest: first, + sourceRevision: SOURCE_REVISION, + artifacts: artifactInputs, + }), + 'artifact_digest_mismatch', + ); + + await expectManifestError( + verifyReleaseArtifactManifest({ + manifest: first, + sourceRevision: '89abcdef0123456789abcdef0123456789abcdef', + artifacts: artifactInputs, + }), + 'source_revision_mismatch', + ); +}); + +await withTempDir(async (dir) => { + const targetPath = join(dir, 'target.bin'); + const linkPath = join(dir, 'link.bin'); + await writeFile(targetPath, 'artifact'); + await symlink(targetPath, linkPath); + + await expectManifestError( + buildReleaseArtifactManifest({ + sourceRevision: SOURCE_REVISION, + artifacts: [{ name: 'artifact.bin', path: linkPath }], + }), + 'artifact_symlink_not_allowed', + ); + + await expectManifestError( + buildReleaseArtifactManifest({ + sourceRevision: SOURCE_REVISION, + artifacts: [ + { name: 'artifact.bin', path: targetPath }, + { name: 'artifact.bin', path: targetPath }, + ], + }), + 'artifact_name_duplicate', + ); + + await expectManifestError( + buildReleaseArtifactManifest({ + sourceRevision: SOURCE_REVISION, + artifacts: [{ name: '../artifact.bin', path: targetPath }], + }), + 'artifact_name_invalid', + ); + + await expectManifestError( + buildReleaseArtifactManifest({ + sourceRevision: 'not-a-git-sha', + artifacts: [{ name: 'artifact.bin', path: targetPath }], + }), + 'source_revision_invalid', + ); +}); + +await withTempDir(async (dir) => { + const artifactPath = join(dir, 'scopeweave-server.tar'); + const manifestPath = join(dir, 'release-manifest.json'); + await writeFile(artifactPath, 'server-release-artifact'); + + const generateOut = captureStream(); + const generateErr = captureStream(); + const generateCode = await runReleaseArtifactManifestCli({ + argv: [ + 'generate', + '--source-revision', + SOURCE_REVISION, + '--artifact', + `server/scopeweave-server.tar=${artifactPath}`, + ], + cwd: dir, + stdout: generateOut, + stderr: generateErr, + }); + assert.equal(generateCode, 0); + assert.equal(generateErr.value(), ''); + const generated = JSON.parse(generateOut.value()); + assert.equal(generated.source_revision, SOURCE_REVISION); + assert.equal(generated.artifacts.length, 1); + assert.equal(generated.artifacts[0].name, 'server/scopeweave-server.tar'); + assert.ok(!generateOut.value().includes(dir), 'manifest must not disclose build-runner paths'); + await writeFile(manifestPath, `${JSON.stringify(generated)}\n`); + + const verifyOut = captureStream(); + const verifyErr = captureStream(); + const verifyCode = await runReleaseArtifactManifestCli({ + argv: [ + 'verify', + '--source-revision', + SOURCE_REVISION, + '--manifest', + manifestPath, + '--artifact', + `server/scopeweave-server.tar=${artifactPath}`, + ], + cwd: dir, + stdout: verifyOut, + stderr: verifyErr, + }); + assert.equal(verifyCode, 0); + assert.equal(verifyErr.value(), ''); + const verification = JSON.parse(verifyOut.value()); + assert.equal(verification.ok, true); + assert.equal(verification.source_revision, SOURCE_REVISION); + assert.equal(verification.artifact_count, 1); + + const malformedOut = captureStream(); + const malformedErr = captureStream(); + const malformedCode = await runReleaseArtifactManifestCli({ + argv: ['generate', '--source-revision', SOURCE_REVISION, '--artifact', `../bad=${artifactPath}`], + cwd: dir, + stdout: malformedOut, + stderr: malformedErr, + }); + assert.equal(malformedCode, 2); + assert.equal(malformedOut.value(), ''); + assert.deepEqual(JSON.parse(malformedErr.value()), { + ok: false, + error: 'artifact_name_invalid', + action: 'fix_release_manifest_input', + }); + assert.ok(!malformedErr.value().includes(dir), 'operator errors must not disclose build-runner paths'); + + await writeFile(manifestPath, '{bad json'); + const badManifestOut = captureStream(); + const badManifestErr = captureStream(); + const badManifestCode = await runReleaseArtifactManifestCli({ + argv: [ + 'verify', + '--source-revision', + SOURCE_REVISION, + '--manifest', + manifestPath, + '--artifact', + `server/scopeweave-server.tar=${artifactPath}`, + ], + cwd: dir, + stdout: badManifestOut, + stderr: badManifestErr, + }); + assert.equal(badManifestCode, 2); + assert.equal(badManifestOut.value(), ''); + assert.deepEqual(JSON.parse(badManifestErr.value()), { + ok: false, + error: 'manifest_json_invalid', + action: 'regenerate_release_manifest', + }); +}); + +console.log('release artifact manifest tests passed'); From e9d4dbb6d793036cca7bde5dc98f35a4281ea860 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 00:39:16 -0700 Subject: [PATCH 02/14] test(release): register manifest contract in CI coverage --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 8cefdc74..48d99ca1 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,12 @@ "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", + "ops:release-manifest": "node scripts/release/release_artifact_manifest.mjs", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/release-artifact-manifest.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=scripts/release/release_artifact_manifest.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/release-artifact-manifest.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", From d8ff72275fa0f7e7170bbff25c22acb7d52619c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 00:41:27 -0700 Subject: [PATCH 03/14] test(release): remove unused RED-stage import --- tests/unit/release-artifact-manifest.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/release-artifact-manifest.test.mjs b/tests/unit/release-artifact-manifest.test.mjs index fa1e7c5c..a596defc 100644 --- a/tests/unit/release-artifact-manifest.test.mjs +++ b/tests/unit/release-artifact-manifest.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; From a0b0c6f7b22ee10efcd6ec2525486a621b486c61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 00:43:33 -0700 Subject: [PATCH 04/14] feat(release): implement deterministic artifact manifest --- scripts/release/release_artifact_manifest.mjs | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 scripts/release/release_artifact_manifest.mjs diff --git a/scripts/release/release_artifact_manifest.mjs b/scripts/release/release_artifact_manifest.mjs new file mode 100644 index 00000000..a45fb5cc --- /dev/null +++ b/scripts/release/release_artifact_manifest.mjs @@ -0,0 +1,423 @@ +import { constants as fsConstants } from 'node:fs'; +import { createHash, timingSafeEqual } from 'node:crypto'; +import { lstat, open, readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const SCHEMA_VERSION = 'scopeweave.release_artifact_manifest.v1'; +const MAX_ARTIFACTS = 256; +const MAX_ARTIFACT_NAME_LENGTH = 240; +const MAX_MANIFEST_BYTES = 1024 * 1024; +const SHA256_HEX = /^[0-9a-f]{64}$/; +const SOURCE_REVISION = /^[0-9a-f]{40}$/; +const ARTIFACT_NAME_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._@+-]*$/; + +/** + * Stable, non-secret failure raised by the release-artifact manifest boundary. + * Callers should branch on `code` rather than exposing filesystem error text. + */ +export class ReleaseArtifactManifestError extends Error { + /** + * @param {string} code Stable machine-readable failure code. + */ + constructor(code) { + super(code); + this.name = 'ReleaseArtifactManifestError'; + this.code = code; + } +} + +function fail(code) { + throw new ReleaseArtifactManifestError(code); +} + +function compareArtifactNames(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function validateSourceRevision(value) { + if (typeof value !== 'string' || !SOURCE_REVISION.test(value)) { + fail('source_revision_invalid'); + } + return value; +} + +function validateArtifactName(value) { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_ARTIFACT_NAME_LENGTH + || value.includes('\\') + || value.startsWith('/') + || value.endsWith('/') + || value.includes('\u0000') + ) { + fail('artifact_name_invalid'); + } + + const segments = value.split('/'); + if ( + segments.some((segment) => ( + segment === '' + || segment === '.' + || segment === '..' + || !ARTIFACT_NAME_SEGMENT.test(segment) + )) + ) { + fail('artifact_name_invalid'); + } + return value; +} + +function validateArtifactInputs(artifacts) { + if (!Array.isArray(artifacts) || artifacts.length === 0 || artifacts.length > MAX_ARTIFACTS) { + fail('artifact_set_invalid'); + } + + const names = new Set(); + return artifacts.map((artifact) => { + if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) { + fail('artifact_input_invalid'); + } + const name = validateArtifactName(artifact.name); + if (names.has(name)) { + fail('artifact_name_duplicate'); + } + names.add(name); + if (typeof artifact.path !== 'string' || artifact.path.length === 0 || artifact.path.includes('\u0000')) { + fail('artifact_path_invalid'); + } + return { name, path: artifact.path }; + }); +} + +function stableStatMatches(left, right) { + return left.dev === right.dev + && left.ino === right.ino + && left.mode === right.mode + && left.size === right.size + && left.mtimeNs === right.mtimeNs + && left.ctimeNs === right.ctimeNs; +} + +async function statWithoutPathDisclosure(path, errorCode) { + try { + return await lstat(path, { bigint: true }); + } catch { + fail(errorCode); + } +} + +async function openRegularFile(path, options = {}) { + const { + symlinkCode = 'artifact_symlink_not_allowed', + invalidCode = 'artifact_not_regular_file', + unreadableCode = 'artifact_unreadable', + } = options; + + const before = await statWithoutPathDisclosure(path, unreadableCode); + if (before.isSymbolicLink()) fail(symlinkCode); + if (!before.isFile()) fail(invalidCode); + + let handle; + try { + const noFollow = Number.isInteger(fsConstants.O_NOFOLLOW) ? fsConstants.O_NOFOLLOW : 0; + handle = await open(path, fsConstants.O_RDONLY | noFollow); + } catch (error) { + if (error?.code === 'ELOOP') fail(symlinkCode); + fail(unreadableCode); + } + + try { + const opened = await handle.stat({ bigint: true }); + if (!opened.isFile()) fail(invalidCode); + if (!stableStatMatches(before, opened)) fail('artifact_changed_during_read'); + return { handle, opened }; + } catch (error) { + await handle.close().catch(() => {}); + if (error instanceof ReleaseArtifactManifestError) throw error; + fail(unreadableCode); + } +} + +async function hashArtifactFile(path) { + const { handle, opened } = await openRegularFile(path); + const maxSafe = BigInt(Number.MAX_SAFE_INTEGER); + if (opened.size > maxSafe) { + await handle.close().catch(() => {}); + fail('artifact_size_unsupported'); + } + + const digest = createHash('sha256'); + try { + const stream = handle.createReadStream({ autoClose: false }); + for await (const chunk of stream) { + digest.update(chunk); + } + const after = await handle.stat({ bigint: true }); + if (!stableStatMatches(opened, after)) fail('artifact_changed_during_read'); + return { + byte_length: Number(after.size), + digest: { sha256: digest.digest('hex') }, + }; + } catch (error) { + if (error instanceof ReleaseArtifactManifestError) throw error; + fail('artifact_unreadable'); + } finally { + await handle.close().catch(() => {}); + } +} + +function payloadForDigest(manifest) { + return { + schema_version: manifest.schema_version, + source_revision: manifest.source_revision, + artifacts: manifest.artifacts, + }; +} + +function digestPayload(payload) { + return createHash('sha256').update(JSON.stringify(payload), 'utf8').digest('hex'); +} + +function exactKeys(value, expected) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]); +} + +function validateManifestShape(manifest) { + if (!exactKeys(manifest, ['schema_version', 'source_revision', 'artifacts', 'manifest_digest'])) { + fail('manifest_schema_invalid'); + } + if (manifest.schema_version !== SCHEMA_VERSION) fail('manifest_schema_invalid'); + if (typeof manifest.source_revision !== 'string' || !SOURCE_REVISION.test(manifest.source_revision)) { + fail('manifest_schema_invalid'); + } + if (!Array.isArray(manifest.artifacts) || manifest.artifacts.length === 0 || manifest.artifacts.length > MAX_ARTIFACTS) { + fail('manifest_schema_invalid'); + } + if (!exactKeys(manifest.manifest_digest, ['sha256']) || !SHA256_HEX.test(manifest.manifest_digest.sha256)) { + fail('manifest_schema_invalid'); + } + + let previousName = null; + for (const entry of manifest.artifacts) { + if (!exactKeys(entry, ['name', 'byte_length', 'digest'])) fail('manifest_schema_invalid'); + try { + validateArtifactName(entry.name); + } catch { + fail('manifest_schema_invalid'); + } + if ( + !Number.isSafeInteger(entry.byte_length) + || entry.byte_length < 0 + || !exactKeys(entry.digest, ['sha256']) + || !SHA256_HEX.test(entry.digest.sha256) + ) { + fail('manifest_schema_invalid'); + } + if (previousName !== null && compareArtifactNames(previousName, entry.name) >= 0) { + fail('manifest_schema_invalid'); + } + previousName = entry.name; + } + + const expectedDigest = digestPayload(payloadForDigest(manifest)); + const actualBuffer = Buffer.from(manifest.manifest_digest.sha256, 'hex'); + const expectedBuffer = Buffer.from(expectedDigest, 'hex'); + if (!timingSafeEqual(actualBuffer, expectedBuffer)) fail('manifest_digest_mismatch'); + return manifest; +} + +/** + * Hash built release artifacts and bind their logical identities to one exact Git revision. + * The result is deterministic unsigned integrity metadata; it is not a provenance attestation. + * + * @param {{sourceRevision: string, artifacts: Array<{name: string, path: string}>}} input Build inputs. + * @returns {Promise} Canonical manifest with a self-digest over its unsigned payload. + */ +export async function buildReleaseArtifactManifest({ sourceRevision, artifacts }) { + const revision = validateSourceRevision(sourceRevision); + const inputs = validateArtifactInputs(artifacts); + const entries = []; + + for (const artifact of inputs) { + const evidence = await hashArtifactFile(artifact.path); + entries.push({ name: artifact.name, ...evidence }); + } + entries.sort((left, right) => compareArtifactNames(left.name, right.name)); + + const payload = { + schema_version: SCHEMA_VERSION, + source_revision: revision, + artifacts: entries, + }; + return { + ...payload, + manifest_digest: { sha256: digestPayload(payload) }, + }; +} + +/** + * Verify a manifest, its exact source revision, and the current bytes of every declared artifact. + * Verification fails closed when the manifest set and supplied artifact set are not identical. + * + * @param {{manifest: object, sourceRevision: string, artifacts: Array<{name: string, path: string}>}} input Verification inputs. + * @returns {Promise<{ok: true, source_revision: string, artifact_count: number, manifest_sha256: string}>} Stable verification receipt. + */ +export async function verifyReleaseArtifactManifest({ manifest, sourceRevision, artifacts }) { + const revision = validateSourceRevision(sourceRevision); + const checkedManifest = validateManifestShape(manifest); + if (checkedManifest.source_revision !== revision) fail('source_revision_mismatch'); + + const inputs = validateArtifactInputs(artifacts) + .sort((left, right) => compareArtifactNames(left.name, right.name)); + if (inputs.length !== checkedManifest.artifacts.length) fail('artifact_set_mismatch'); + + for (let index = 0; index < inputs.length; index += 1) { + const input = inputs[index]; + const expected = checkedManifest.artifacts[index]; + if (input.name !== expected.name) fail('artifact_set_mismatch'); + const actual = await hashArtifactFile(input.path); + if (actual.digest.sha256 !== expected.digest.sha256) fail('artifact_digest_mismatch'); + if (actual.byte_length !== expected.byte_length) fail('artifact_size_mismatch'); + } + + return { + ok: true, + source_revision: revision, + artifact_count: inputs.length, + manifest_sha256: checkedManifest.manifest_digest.sha256, + }; +} + +function parseArtifactArgument(value, cwd) { + if (typeof value !== 'string') fail('cli_usage_invalid'); + const separator = value.indexOf('='); + if (separator <= 0 || separator === value.length - 1) fail('cli_usage_invalid'); + const name = value.slice(0, separator); + const localPath = value.slice(separator + 1); + validateArtifactName(name); + return { name, path: resolve(cwd, localPath) }; +} + +function parseCliArguments(argv, cwd) { + if (!Array.isArray(argv) || (argv[0] !== 'generate' && argv[0] !== 'verify')) { + fail('cli_usage_invalid'); + } + const command = argv[0]; + let sourceRevision = null; + let manifestPath = null; + const artifacts = []; + + for (let index = 1; index < argv.length; index += 1) { + const flag = argv[index]; + const value = argv[index + 1]; + if (flag === '--source-revision' && sourceRevision === null && value !== undefined) { + sourceRevision = value; + index += 1; + continue; + } + if (flag === '--manifest' && manifestPath === null && value !== undefined) { + manifestPath = resolve(cwd, value); + index += 1; + continue; + } + if (flag === '--artifact' && value !== undefined) { + artifacts.push(parseArtifactArgument(value, cwd)); + index += 1; + continue; + } + fail('cli_usage_invalid'); + } + + if (sourceRevision === null || artifacts.length === 0) fail('cli_usage_invalid'); + validateSourceRevision(sourceRevision); + if (command === 'generate' && manifestPath !== null) fail('cli_usage_invalid'); + if (command === 'verify' && manifestPath === null) fail('cli_usage_invalid'); + return { command, sourceRevision, manifestPath, artifacts }; +} + +async function readManifest(path) { + const stat = await statWithoutPathDisclosure(path, 'manifest_file_invalid'); + if (stat.isSymbolicLink() || !stat.isFile() || stat.size > BigInt(MAX_MANIFEST_BYTES)) { + fail('manifest_file_invalid'); + } + + let text; + try { + text = await readFile(path, { encoding: 'utf8' }); + } catch { + fail('manifest_file_invalid'); + } + if (Buffer.byteLength(text, 'utf8') > MAX_MANIFEST_BYTES) fail('manifest_file_invalid'); + + try { + return JSON.parse(text); + } catch { + fail('manifest_json_invalid'); + } +} + +function errorAction(code) { + if (code === 'manifest_json_invalid' || code === 'manifest_schema_invalid' || code === 'manifest_digest_mismatch') { + return 'regenerate_release_manifest'; + } + if (code === 'artifact_digest_mismatch' || code === 'artifact_size_mismatch' || code === 'source_revision_mismatch') { + return 'rebuild_release_artifacts'; + } + if (code === 'unexpected_release_manifest_error') return 'inspect_release_manifest_tool'; + return 'fix_release_manifest_input'; +} + +/** + * Run the release-manifest operator CLI without leaking local build paths in machine-readable errors. + * `generate` prints a manifest; `verify` prints a verification receipt. The caller owns redirection/storage. + * + * @param {{argv?: string[], cwd?: string, stdout?: {write: Function}, stderr?: {write: Function}}} options Runtime adapters. + * @returns {Promise} Process-style exit code: 0 success, 2 deterministic validation failure. + */ +export async function runReleaseArtifactManifestCli(options = {}) { + const { + argv = process.argv.slice(2), + cwd = process.cwd(), + stdout = process.stdout, + stderr = process.stderr, + } = options; + + try { + const parsed = parseCliArguments(argv, cwd); + if (parsed.command === 'generate') { + const manifest = await buildReleaseArtifactManifest({ + sourceRevision: parsed.sourceRevision, + artifacts: parsed.artifacts, + }); + stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); + return 0; + } + + const manifest = await readManifest(parsed.manifestPath); + const verification = await verifyReleaseArtifactManifest({ + manifest, + sourceRevision: parsed.sourceRevision, + artifacts: parsed.artifacts, + }); + stdout.write(`${JSON.stringify(verification)}\n`); + return 0; + } catch (error) { + const code = error instanceof ReleaseArtifactManifestError + ? error.code + : 'unexpected_release_manifest_error'; + stderr.write(`${JSON.stringify({ ok: false, error: code, action: errorAction(code) })}\n`); + return 2; + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null; +if (invokedPath === import.meta.url) { + process.exitCode = await runReleaseArtifactManifestCli(); +} From 77953851edb6030516a530b8a4095f4ccfe0290b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 00:45:36 -0700 Subject: [PATCH 05/14] docs(release): record artifact integrity decision and evidence --- .../release-artifact-integrity-manifest.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/doctoring/release-artifact-integrity-manifest.md diff --git a/docs/doctoring/release-artifact-integrity-manifest.md b/docs/doctoring/release-artifact-integrity-manifest.md new file mode 100644 index 00000000..abd9d640 --- /dev/null +++ b/docs/doctoring/release-artifact-integrity-manifest.md @@ -0,0 +1,90 @@ +# Release artifact integrity manifest + +## Status + +**Active PR only; not protected-shipped truth.** This control is introduced by ScopeWeave PR #616. Protected `develop` does not contain it until normal reviewed integration completes. + +## Decision and scope + +ScopeWeave release candidates need a deterministic receipt that answers a narrow operator question before a tag or release is created: **do these exact built artifact bytes still match the exact source revision they were produced from?** + +`scripts/release/release_artifact_manifest.mjs` generates an unsigned JSON manifest containing: + +- schema version `scopeweave.release_artifact_manifest.v1`; +- one exact 40-hex Git source revision; +- a sorted set of operator-assigned logical artifact names; +- the byte length and SHA-256 digest of each regular artifact file; and +- a SHA-256 self-digest over the canonical unsigned manifest payload. + +The verifier fails closed when the source revision, artifact set, artifact bytes, lengths, manifest schema, or manifest self-digest differ. Artifact logical names are bounded relative identifiers, duplicate names are rejected, and symlink inputs are rejected so a manifest cannot silently describe a redirected filesystem target. The manifest records logical names only and never records local build-runner paths. + +## What this control proves—and what it does not + +A successful verification is **integrity evidence**, not provenance authentication. It proves that the locally supplied files still hash to the values bound into the supplied manifest and that the manifest names one exact source revision. It does **not** prove who built the files, which workflow produced them, that the named source revision was actually used by a trusted builder, or that the manifest itself was signed by an authorized identity. + +Accordingly, do not use the manifest to claim a SLSA level, signed provenance, SOC 2 compliance, certification, or trustworthy-builder identity. SLSA v1.2 defines provenance as verifiable information about where, when, and how an artifact was produced, and GitHub artifact attestations provide cryptographically verifiable build-provenance/SBOM claims for artifacts produced in GitHub Actions. ScopeWeave's local manifest is intentionally complementary: it provides deterministic byte/source binding that can be checked before the independent provenance gate. + +## Release decision contract + +A release operator should proceed only when all of the following are true on the **same integrated protected revision**: + +1. normal protected-branch review, required checks, security gates, coverage/docstrings, migration/recovery, accessibility, compatibility, package/build, and operational acceptance are passing for that revision; +2. release artifacts are built from that exact protected revision; +3. an artifact manifest is generated for every artifact that will be distributed; +4. the manifest verifies against the unchanged artifacts and the same exact source revision; +5. applicable GitHub/SLSA artifact provenance and SBOM attestations are generated and independently verified; and +6. source/artifact hashes recorded in the release evidence match the assets that are actually published. + +A failed manifest verification is a **stop-release** signal. Rebuild the artifact set or regenerate the manifest from the correct unchanged artifacts; do not edit digest fields by hand to make verification pass. + +## Operator examples + +Generate a manifest from built artifacts and redirect the deterministic JSON to an evidence file: + +```bash +npm run ops:release-manifest -- generate \ + --source-revision "$(git rev-parse HEAD)" \ + --artifact browser/scopeweave-static.tar=dist/scopeweave-static.tar \ + --artifact server/scopeweave-server.tar=dist/scopeweave-server.tar \ + > release-artifact-manifest.json +``` + +Verify the same bytes before publication: + +```bash +npm run ops:release-manifest -- verify \ + --source-revision "$(git rev-parse HEAD)" \ + --manifest release-artifact-manifest.json \ + --artifact browser/scopeweave-static.tar=dist/scopeweave-static.tar \ + --artifact server/scopeweave-server.tar=dist/scopeweave-server.tar +``` + +Success prints a small machine-readable receipt containing `ok`, `source_revision`, `artifact_count`, and `manifest_sha256`. Deterministic failures return exit code `2` with a stable `error` and an operator-oriented `action`; local filesystem paths and raw filesystem exception text are not emitted. + +## Threat and failure notes + +- **Artifact replacement after build:** digest verification fails. +- **Source/artifact mix-up:** exact source-revision comparison fails. +- **Manifest field tampering:** the manifest self-digest fails unless the attacker can also replace the entire unsigned manifest; signed provenance remains the authority for authenticity. +- **Symlink substitution:** direct symlink inputs are rejected, and the hashing boundary compares file identity/metadata before and after the read to detect mutation during hashing. +- **Missing/extra artifacts:** verification requires an identical named artifact set. +- **Runner-path disclosure:** only logical artifact names are serialized; error envelopes use stable codes rather than raw filesystem messages. +- **Maliciously large manifest input:** verify-mode manifest JSON is bounded to 1 MiB before parsing. + +## Verification evidence required for PR #616 + +The repository regression must preserve a real RED→GREEN history: + +- RED: the registered unit job fails while the production manifest module is absent; +- GREEN: the same registered unit job passes on the implementation head; +- realistic tests cover deterministic ordering, tampering, source mismatch, symlink rejection, duplicate/traversal-style names, CLI generate/verify, path non-disclosure, and malformed manifest JSON; +- the production module remains registered in owned `c8` coverage execution and all public exports retain beginner-readable JSDoc; and +- exact-current-head repository, security, dependency/supply-chain, browser, review, and live-governance evidence is re-fetched before integration. + +## References (APA 7th) + +GitHub. (2026). *Using artifact attestations to establish provenance for builds*. GitHub Docs. https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations + +Supply-chain Levels for Software Artifacts. (2025). *SLSA specification (Version 1.2)*. The Linux Foundation. https://slsa.dev/spec/v1.2/ + +Supply-chain Levels for Software Artifacts. (2025). *Provenance (SLSA specification Version 1.2)*. The Linux Foundation. https://slsa.dev/spec/v1.2/provenance From de21f1937b072ade695914ebc066177af493af9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 00:46:30 -0700 Subject: [PATCH 06/14] docs(release): record artifact integrity manifest --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..d0775465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added a deterministic release-artifact integrity manifest and verifier that + binds built files to one exact source revision with SHA-256 digests, rejects + symlink/name/path confusion, emits stable non-secret operator failures, and + remains explicitly separate from signed provenance/attestation claims. - Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS estimation coverage, dependency risk, and procurement package section checks. - Preserved PM-analysis research papers, NASA WBS handbook, BCP 14, and JSON @@ -48,7 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 missing or invalid subject/expiry, or a missing, Boolean, fractional, negative, unsafe, or otherwise invalid token-version claim before user lookup. - Added cross-device regression coverage proving that `logout-all` rejects stale - tokens on bearer, calendar, SSE, and attachment-view transports while the + tokens on bearer, calendar, SSE, and attachment-view URL transports while the replacement token continues through the same authentication boundary. ### Changed From ec720063c5bf540671e3969289295e87e8ba0a19 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:03:59 -0700 Subject: [PATCH 07/14] test(release): require canonical nested manifest serialization --- tests/unit/release-artifact-manifest.test.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/unit/release-artifact-manifest.test.mjs b/tests/unit/release-artifact-manifest.test.mjs index a596defc..0b4703fe 100644 --- a/tests/unit/release-artifact-manifest.test.mjs +++ b/tests/unit/release-artifact-manifest.test.mjs @@ -88,6 +88,25 @@ await withTempDir(async (dir) => { manifest_sha256: first.manifest_digest.sha256, }); + const reorderedArtifactFields = { + ...first, + artifacts: first.artifacts.map((entry) => ({ + digest: { sha256: entry.digest.sha256 }, + byte_length: entry.byte_length, + name: entry.name, + })), + }; + const reorderedVerified = await verifyReleaseArtifactManifest({ + manifest: reorderedArtifactFields, + sourceRevision: SOURCE_REVISION, + artifacts: artifactInputs, + }); + assert.deepEqual( + reorderedVerified, + verified, + 'canonical manifest digest must not depend on JSON object key insertion order', + ); + await writeFile(browserPath, Buffer.from('tampered-browser-artifact\n')); await expectManifestError( verifyReleaseArtifactManifest({ From a01066b528e6ec5217e7c1cff50b23762d9a88af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:05:56 -0700 Subject: [PATCH 08/14] fix(release): canonicalize manifest artifact digest payload --- scripts/release/release_artifact_manifest.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/release/release_artifact_manifest.mjs b/scripts/release/release_artifact_manifest.mjs index a45fb5cc..609f3243 100644 --- a/scripts/release/release_artifact_manifest.mjs +++ b/scripts/release/release_artifact_manifest.mjs @@ -174,7 +174,11 @@ function payloadForDigest(manifest) { return { schema_version: manifest.schema_version, source_revision: manifest.source_revision, - artifacts: manifest.artifacts, + artifacts: manifest.artifacts.map((entry) => ({ + name: entry.name, + byte_length: entry.byte_length, + digest: { sha256: entry.digest.sha256 }, + })), }; } @@ -258,7 +262,7 @@ export async function buildReleaseArtifactManifest({ sourceRevision, artifacts } }; return { ...payload, - manifest_digest: { sha256: digestPayload(payload) }, + manifest_digest: { sha256: digestPayload(payloadForDigest(payload)) }, }; } From 1689f57aa6a97603d188e963b36777af1fee9946 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:19:35 -0700 Subject: [PATCH 09/14] test(release): reproduce manifest path replacement race --- tests/unit/release-artifact-manifest.test.mjs | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/unit/release-artifact-manifest.test.mjs b/tests/unit/release-artifact-manifest.test.mjs index 0b4703fe..05836b1e 100644 --- a/tests/unit/release-artifact-manifest.test.mjs +++ b/tests/unit/release-artifact-manifest.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, rename, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -220,6 +220,39 @@ await withTempDir(async (dir) => { assert.equal(verification.source_revision, SOURCE_REVISION); assert.equal(verification.artifact_count, 1); + let replacementHookCalls = 0; + const replacedManifestOut = captureStream(); + const replacedManifestErr = captureStream(); + const replacedManifestCode = await runReleaseArtifactManifestCli({ + argv: [ + 'verify', + '--source-revision', + SOURCE_REVISION, + '--manifest', + manifestPath, + '--artifact', + `server/scopeweave-server.tar=${artifactPath}`, + ], + cwd: dir, + stdout: replacedManifestOut, + stderr: replacedManifestErr, + afterManifestOpen: async () => { + replacementHookCalls += 1; + await rename(manifestPath, `${manifestPath}.opened`); + await writeFile(manifestPath, '{"replacement":true}\n'); + }, + }); + assert.equal(replacementHookCalls, 1, 'manifest replacement regression must run after the verified file is opened'); + assert.equal(replacedManifestCode, 2); + assert.equal(replacedManifestOut.value(), ''); + assert.deepEqual(JSON.parse(replacedManifestErr.value()), { + ok: false, + error: 'manifest_file_invalid', + action: 'fix_release_manifest_input', + }); + assert.ok(!replacedManifestErr.value().includes(dir), 'manifest replacement errors must not disclose build-runner paths'); + + await writeFile(manifestPath, `${JSON.stringify(generated)}\n`); const malformedOut = captureStream(); const malformedErr = captureStream(); const malformedCode = await runReleaseArtifactManifestCli({ From dbd5a429532d175eccde3ca9e43cef882e64761c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:59:02 -0700 Subject: [PATCH 10/14] fix(release): bind manifest reads to opened file --- scripts/release/release_artifact_manifest.mjs | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/scripts/release/release_artifact_manifest.mjs b/scripts/release/release_artifact_manifest.mjs index 609f3243..b6b34f77 100644 --- a/scripts/release/release_artifact_manifest.mjs +++ b/scripts/release/release_artifact_manifest.mjs @@ -1,6 +1,6 @@ import { constants as fsConstants } from 'node:fs'; import { createHash, timingSafeEqual } from 'node:crypto'; -import { lstat, open, readFile } from 'node:fs/promises'; +import { lstat, open } from 'node:fs/promises'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -115,6 +115,7 @@ async function openRegularFile(path, options = {}) { symlinkCode = 'artifact_symlink_not_allowed', invalidCode = 'artifact_not_regular_file', unreadableCode = 'artifact_unreadable', + changedCode = 'artifact_changed_during_read', } = options; const before = await statWithoutPathDisclosure(path, unreadableCode); @@ -133,7 +134,7 @@ async function openRegularFile(path, options = {}) { try { const opened = await handle.stat({ bigint: true }); if (!opened.isFile()) fail(invalidCode); - if (!stableStatMatches(before, opened)) fail('artifact_changed_during_read'); + if (!stableStatMatches(before, opened)) fail(changedCode); return { handle, opened }; } catch (error) { await handle.close().catch(() => {}); @@ -346,24 +347,50 @@ function parseCliArguments(argv, cwd) { return { command, sourceRevision, manifestPath, artifacts }; } -async function readManifest(path) { - const stat = await statWithoutPathDisclosure(path, 'manifest_file_invalid'); - if (stat.isSymbolicLink() || !stat.isFile() || stat.size > BigInt(MAX_MANIFEST_BYTES)) { - fail('manifest_file_invalid'); - } +async function readManifest(path, options = {}) { + const { afterOpen } = options; + const { handle, opened } = await openRegularFile(path, { + symlinkCode: 'manifest_file_invalid', + invalidCode: 'manifest_file_invalid', + unreadableCode: 'manifest_file_invalid', + changedCode: 'manifest_file_invalid', + }); - let text; try { - text = await readFile(path, { encoding: 'utf8' }); - } catch { - fail('manifest_file_invalid'); - } - if (Buffer.byteLength(text, 'utf8') > MAX_MANIFEST_BYTES) fail('manifest_file_invalid'); + if (opened.size > BigInt(MAX_MANIFEST_BYTES)) fail('manifest_file_invalid'); + + if (afterOpen !== undefined) { + if (typeof afterOpen !== 'function') fail('manifest_file_invalid'); + try { + await afterOpen(); + } catch { + fail('manifest_file_invalid'); + } + } - try { - return JSON.parse(text); - } catch { - fail('manifest_json_invalid'); + const pathAfterOpen = await statWithoutPathDisclosure(path, 'manifest_file_invalid'); + if (!stableStatMatches(opened, pathAfterOpen)) fail('manifest_file_invalid'); + + let text; + try { + text = await handle.readFile({ encoding: 'utf8' }); + } catch { + fail('manifest_file_invalid'); + } + + const afterRead = await handle.stat({ bigint: true }); + if (!stableStatMatches(opened, afterRead)) fail('manifest_file_invalid'); + const pathAfterRead = await statWithoutPathDisclosure(path, 'manifest_file_invalid'); + if (!stableStatMatches(afterRead, pathAfterRead)) fail('manifest_file_invalid'); + if (Buffer.byteLength(text, 'utf8') > MAX_MANIFEST_BYTES) fail('manifest_file_invalid'); + + try { + return JSON.parse(text); + } catch { + fail('manifest_json_invalid'); + } + } finally { + await handle.close().catch(() => {}); } } @@ -381,8 +408,9 @@ function errorAction(code) { /** * Run the release-manifest operator CLI without leaking local build paths in machine-readable errors. * `generate` prints a manifest; `verify` prints a verification receipt. The caller owns redirection/storage. + * `afterManifestOpen` is an optional deterministic test seam invoked after the manifest file is opened. * - * @param {{argv?: string[], cwd?: string, stdout?: {write: Function}, stderr?: {write: Function}}} options Runtime adapters. + * @param {{argv?: string[], cwd?: string, stdout?: {write: Function}, stderr?: {write: Function}, afterManifestOpen?: Function}} options Runtime adapters. * @returns {Promise} Process-style exit code: 0 success, 2 deterministic validation failure. */ export async function runReleaseArtifactManifestCli(options = {}) { @@ -391,6 +419,7 @@ export async function runReleaseArtifactManifestCli(options = {}) { cwd = process.cwd(), stdout = process.stdout, stderr = process.stderr, + afterManifestOpen, } = options; try { @@ -404,7 +433,7 @@ export async function runReleaseArtifactManifestCli(options = {}) { return 0; } - const manifest = await readManifest(parsed.manifestPath); + const manifest = await readManifest(parsed.manifestPath, { afterOpen: afterManifestOpen }); const verification = await verifyReleaseArtifactManifest({ manifest, sourceRevision: parsed.sourceRevision, From d60c9bf0e4a84dfe6f81f50ba3570e2f15d4b650 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 05:29:19 -0700 Subject: [PATCH 11/14] test(release): reproduce artifact path replacement race --- .../unit/release-artifact-path-race.test.mjs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/unit/release-artifact-path-race.test.mjs diff --git a/tests/unit/release-artifact-path-race.test.mjs b/tests/unit/release-artifact-path-race.test.mjs new file mode 100644 index 00000000..ebe3442e --- /dev/null +++ b/tests/unit/release-artifact-path-race.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + buildReleaseArtifactManifest, + runReleaseArtifactManifestCli, +} from '../../scripts/release/release_artifact_manifest.mjs'; + +const SOURCE_REVISION = '0123456789abcdef0123456789abcdef01234567'; + +function captureStream() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + return true; + }, + value() { + return value; + }, + }; +} + +const dir = await mkdtemp(join(tmpdir(), 'scopeweave-release-artifact-race-')); +try { + const artifactPath = join(dir, 'scopeweave-server.tar'); + const openedArtifactPath = join(dir, 'scopeweave-server.opened.tar'); + const manifestPath = join(dir, 'release-manifest.json'); + await writeFile(artifactPath, 'server-release-artifact'); + + const manifest = await buildReleaseArtifactManifest({ + sourceRevision: SOURCE_REVISION, + artifacts: [{ name: 'server/scopeweave-server.tar', path: artifactPath }], + }); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + + let artifactOpenHookCalls = 0; + const stdout = captureStream(); + const stderr = captureStream(); + const exitCode = await runReleaseArtifactManifestCli({ + argv: [ + 'verify', + '--source-revision', + SOURCE_REVISION, + '--manifest', + manifestPath, + '--artifact', + `server/scopeweave-server.tar=${artifactPath}`, + ], + cwd: dir, + stdout, + stderr, + afterArtifactOpen: async () => { + artifactOpenHookCalls += 1; + await rename(artifactPath, openedArtifactPath); + await writeFile(artifactPath, 'replacement-release-artifact'); + }, + }); + + assert.equal( + artifactOpenHookCalls, + 1, + 'artifact replacement regression must run after the verified artifact is opened', + ); + assert.equal(exitCode, 2, 'verification must fail closed when the artifact pathname is replaced after open'); + assert.equal(stdout.value(), ''); + assert.deepEqual(JSON.parse(stderr.value()), { + ok: false, + error: 'artifact_changed_during_read', + action: 'rebuild_release_artifacts', + }); + assert.ok(!stderr.value().includes(dir), 'artifact replacement errors must not disclose build-runner paths'); +} finally { + await rm(dir, { recursive: true, force: true }); +} + +console.log('release artifact pathname replacement regression passed'); From d3018a44d55a8ce6fadda67a6b53315ab289904a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 05:32:07 -0700 Subject: [PATCH 12/14] test(release): run artifact replacement regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 48d99ca1..f62c13d6 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ "ops:release-manifest": "node scripts/release/release_artifact_manifest.mjs", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/release-artifact-manifest.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/release-artifact-manifest.test.mjs && node tests/unit/release-artifact-path-race.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=scripts/release/release_artifact_manifest.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/release-artifact-manifest.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/release-artifact-manifest.test.mjs && node tests/unit/release-artifact-path-race.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", From ea9472e90bdb9de1365f69e385aeb98bdedb0178 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 05:51:27 -0700 Subject: [PATCH 13/14] fix(release): detect artifact path replacement during verification --- scripts/release/release_artifact_manifest.mjs | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/scripts/release/release_artifact_manifest.mjs b/scripts/release/release_artifact_manifest.mjs index b6b34f77..8183b36d 100644 --- a/scripts/release/release_artifact_manifest.mjs +++ b/scripts/release/release_artifact_manifest.mjs @@ -143,7 +143,8 @@ async function openRegularFile(path, options = {}) { } } -async function hashArtifactFile(path) { +async function hashArtifactFile(path, options = {}) { + const { afterOpen } = options; const { handle, opened } = await openRegularFile(path); const maxSafe = BigInt(Number.MAX_SAFE_INTEGER); if (opened.size > maxSafe) { @@ -153,12 +154,26 @@ async function hashArtifactFile(path) { const digest = createHash('sha256'); try { + if (afterOpen !== undefined) { + if (typeof afterOpen !== 'function') fail('artifact_changed_during_read'); + try { + await afterOpen(); + } catch { + fail('artifact_changed_during_read'); + } + } + + const pathAfterOpen = await statWithoutPathDisclosure(path, 'artifact_changed_during_read'); + if (!stableStatMatches(opened, pathAfterOpen)) fail('artifact_changed_during_read'); + const stream = handle.createReadStream({ autoClose: false }); for await (const chunk of stream) { digest.update(chunk); } const after = await handle.stat({ bigint: true }); if (!stableStatMatches(opened, after)) fail('artifact_changed_during_read'); + const pathAfterRead = await statWithoutPathDisclosure(path, 'artifact_changed_during_read'); + if (!stableStatMatches(after, pathAfterRead)) fail('artifact_changed_during_read'); return { byte_length: Number(after.size), digest: { sha256: digest.digest('hex') }, @@ -270,11 +285,12 @@ export async function buildReleaseArtifactManifest({ sourceRevision, artifacts } /** * Verify a manifest, its exact source revision, and the current bytes of every declared artifact. * Verification fails closed when the manifest set and supplied artifact set are not identical. + * `afterArtifactOpen` is an optional deterministic test seam invoked after each artifact is opened. * - * @param {{manifest: object, sourceRevision: string, artifacts: Array<{name: string, path: string}>}} input Verification inputs. + * @param {{manifest: object, sourceRevision: string, artifacts: Array<{name: string, path: string}>, afterArtifactOpen?: Function}} input Verification inputs. * @returns {Promise<{ok: true, source_revision: string, artifact_count: number, manifest_sha256: string}>} Stable verification receipt. */ -export async function verifyReleaseArtifactManifest({ manifest, sourceRevision, artifacts }) { +export async function verifyReleaseArtifactManifest({ manifest, sourceRevision, artifacts, afterArtifactOpen }) { const revision = validateSourceRevision(sourceRevision); const checkedManifest = validateManifestShape(manifest); if (checkedManifest.source_revision !== revision) fail('source_revision_mismatch'); @@ -287,7 +303,7 @@ export async function verifyReleaseArtifactManifest({ manifest, sourceRevision, const input = inputs[index]; const expected = checkedManifest.artifacts[index]; if (input.name !== expected.name) fail('artifact_set_mismatch'); - const actual = await hashArtifactFile(input.path); + const actual = await hashArtifactFile(input.path, { afterOpen: afterArtifactOpen }); if (actual.digest.sha256 !== expected.digest.sha256) fail('artifact_digest_mismatch'); if (actual.byte_length !== expected.byte_length) fail('artifact_size_mismatch'); } @@ -398,7 +414,12 @@ function errorAction(code) { if (code === 'manifest_json_invalid' || code === 'manifest_schema_invalid' || code === 'manifest_digest_mismatch') { return 'regenerate_release_manifest'; } - if (code === 'artifact_digest_mismatch' || code === 'artifact_size_mismatch' || code === 'source_revision_mismatch') { + if ( + code === 'artifact_changed_during_read' + || code === 'artifact_digest_mismatch' + || code === 'artifact_size_mismatch' + || code === 'source_revision_mismatch' + ) { return 'rebuild_release_artifacts'; } if (code === 'unexpected_release_manifest_error') return 'inspect_release_manifest_tool'; @@ -408,9 +429,9 @@ function errorAction(code) { /** * Run the release-manifest operator CLI without leaking local build paths in machine-readable errors. * `generate` prints a manifest; `verify` prints a verification receipt. The caller owns redirection/storage. - * `afterManifestOpen` is an optional deterministic test seam invoked after the manifest file is opened. + * `afterManifestOpen` and `afterArtifactOpen` are optional deterministic test seams invoked after opening the corresponding file. * - * @param {{argv?: string[], cwd?: string, stdout?: {write: Function}, stderr?: {write: Function}, afterManifestOpen?: Function}} options Runtime adapters. + * @param {{argv?: string[], cwd?: string, stdout?: {write: Function}, stderr?: {write: Function}, afterManifestOpen?: Function, afterArtifactOpen?: Function}} options Runtime adapters. * @returns {Promise} Process-style exit code: 0 success, 2 deterministic validation failure. */ export async function runReleaseArtifactManifestCli(options = {}) { @@ -420,6 +441,7 @@ export async function runReleaseArtifactManifestCli(options = {}) { stdout = process.stdout, stderr = process.stderr, afterManifestOpen, + afterArtifactOpen, } = options; try { @@ -438,6 +460,7 @@ export async function runReleaseArtifactManifestCli(options = {}) { manifest, sourceRevision: parsed.sourceRevision, artifacts: parsed.artifacts, + afterArtifactOpen, }); stdout.write(`${JSON.stringify(verification)}\n`); return 0; From b427cd455f41e7c09ae20fbbf400d42c4f61d4bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 05:56:22 -0700 Subject: [PATCH 14/14] docs(release): record pathname identity repair evidence --- .../release-artifact-integrity-manifest.md | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/doctoring/release-artifact-integrity-manifest.md b/docs/doctoring/release-artifact-integrity-manifest.md index abd9d640..ddd4f977 100644 --- a/docs/doctoring/release-artifact-integrity-manifest.md +++ b/docs/doctoring/release-artifact-integrity-manifest.md @@ -16,7 +16,7 @@ ScopeWeave release candidates need a deterministic receipt that answers a narrow - the byte length and SHA-256 digest of each regular artifact file; and - a SHA-256 self-digest over the canonical unsigned manifest payload. -The verifier fails closed when the source revision, artifact set, artifact bytes, lengths, manifest schema, or manifest self-digest differ. Artifact logical names are bounded relative identifiers, duplicate names are rejected, and symlink inputs are rejected so a manifest cannot silently describe a redirected filesystem target. The manifest records logical names only and never records local build-runner paths. +The verifier fails closed when the source revision, artifact set, artifact bytes, lengths, manifest schema, or manifest self-digest differ. Artifact logical names are bounded relative identifiers, duplicate names are rejected, and symlink inputs are rejected so a manifest cannot silently describe a redirected filesystem target. Manifest and artifact verification bind reads to opened file handles and compare their file identity/metadata with the pathname before accepting the result, including a second pathname identity check after artifact hashing. The manifest records logical names only and never records local build-runner paths. ## What this control proves—and what it does not @@ -59,27 +59,31 @@ npm run ops:release-manifest -- verify \ --artifact server/scopeweave-server.tar=dist/scopeweave-server.tar ``` -Success prints a small machine-readable receipt containing `ok`, `source_revision`, `artifact_count`, and `manifest_sha256`. Deterministic failures return exit code `2` with a stable `error` and an operator-oriented `action`; local filesystem paths and raw filesystem exception text are not emitted. +Success prints a small machine-readable receipt containing `ok`, `source_revision`, `artifact_count`, and `manifest_sha256`. Deterministic failures return exit code `2` with a stable `error` and an operator-oriented `action`; local filesystem paths and raw filesystem exception text are not emitted. An `artifact_changed_during_read` result directs the operator to rebuild the release artifacts rather than retrying against a potentially replaced pathname. ## Threat and failure notes - **Artifact replacement after build:** digest verification fails. +- **Artifact pathname replacement during verification:** the verifier hashes the opened handle, compares its identity with the live pathname after open and again after hashing, and fails closed with `artifact_changed_during_read` if the pathname no longer resolves to that same file. +- **Manifest pathname replacement during verification:** manifest JSON is read from the opened handle, and pathname identity is checked after open and after read; replacement fails closed as `manifest_file_invalid`. - **Source/artifact mix-up:** exact source-revision comparison fails. - **Manifest field tampering:** the manifest self-digest fails unless the attacker can also replace the entire unsigned manifest; signed provenance remains the authority for authenticity. -- **Symlink substitution:** direct symlink inputs are rejected, and the hashing boundary compares file identity/metadata before and after the read to detect mutation during hashing. +- **Symlink substitution:** direct symlink inputs are rejected with `O_NOFOLLOW` where the platform exposes it, in addition to file-type and identity checks. - **Missing/extra artifacts:** verification requires an identical named artifact set. - **Runner-path disclosure:** only logical artifact names are serialized; error envelopes use stable codes rather than raw filesystem messages. - **Maliciously large manifest input:** verify-mode manifest JSON is bounded to 1 MiB before parsing. ## Verification evidence required for PR #616 -The repository regression must preserve a real RED→GREEN history: +The repository regression history now includes multiple real RED→GREEN stages rather than relying on assertion-only success: -- RED: the registered unit job fails while the production manifest module is absent; -- GREEN: the same registered unit job passes on the implementation head; -- realistic tests cover deterministic ordering, tampering, source mismatch, symlink rejection, duplicate/traversal-style names, CLI generate/verify, path non-disclosure, and malformed manifest JSON; -- the production module remains registered in owned `c8` coverage execution and all public exports retain beginner-readable JSDoc; and -- exact-current-head repository, security, dependency/supply-chain, browser, review, and live-governance evidence is re-fetched before integration. +- the initial contract was introduced RED before the production module existed and then implemented; +- predecessor RED `ec720063c5bf540671e3969289295e87e8ba0a19` demonstrated that manifest self-digest verification depended on nested artifact-object key insertion order; its production fix canonicalized the digest payload; +- predecessor RED `d3018a44d55a8ce6fadda67a6b53315ab289904a` registered the artifact-path replacement regression in normal unit/coverage execution and failed Server Tests run `33072327214`, `unit-and-api` job `98517475339`, because the artifact-open seam was not invoked (`0 !== 1`); +- production repair `ea9472e90bdb9de1365f69e385aeb98bdedb0178` wires the artifact-open seam through verification, checks live pathname identity around the opened handle, and maps a detected replacement to `rebuild_release_artifacts`; and +- hosted Server Tests run `33073885033`, `unit-and-api` job `98522928574`, is GREEN and explicitly records `release artifact pathname replacement regression passed`; cloud E2E, Fuzz, Dependency Review, OSV, Security Scan, and SAST are also run-level GREEN on the same pull-request event. + +That hosted GREEN is **behavioral evidence, not exact-head merge authority**: Server Tests checked out synthetic merge `623d5765181ae52d133313f4bf942141932aad9d`, not immutable contributor head `ea9472e90bdb9de1365f69e385aeb98bdedb0178`. PR #523 owns the ScopeWeave exact-head Server Tests/100%-owned-coverage control and `ContextualWisdomLab/.github#1222` owns the reusable central SAST/Security exact-head defect. PR #616 must remain Draft until those controls protect and regenerate authoritative evidence on one unchanged exact contributor head, all public exports retain beginner-readable JSDoc, valid current-head review findings are zero, and the live independent-approval requirement is satisfied. ## References (APA 7th)