From 5ead86003f30a2a3c003bf6ec7f93f96841a3b65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 13:51:46 +0000 Subject: [PATCH] feat(cli,core): verify manifest.integrity at plugin publish preflight (#13464) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N --- .../plugin-publish-integrity-preflight.md | 6 + packages/cli/src/commands/plugin/publish.ts | 55 +++++++- packages/cli/src/utils/osplugin.ts | 14 +- packages/cli/test/plugin-publish.test.ts | 86 ++++++++++- packages/core/src/security/index.ts | 13 ++ .../plugin-artifact-integrity.test.ts | 133 ++++++++++++++++++ .../src/security/plugin-artifact-integrity.ts | 121 ++++++++++++++++ packages/spec/liveness/manifest.json | 4 +- packages/spec/src/kernel/manifest.zod.ts | 12 +- 9 files changed, 426 insertions(+), 18 deletions(-) create mode 100644 .changeset/plugin-publish-integrity-preflight.md create mode 100644 packages/core/src/security/plugin-artifact-integrity.test.ts create mode 100644 packages/core/src/security/plugin-artifact-integrity.ts diff --git a/.changeset/plugin-publish-integrity-preflight.md b/.changeset/plugin-publish-integrity-preflight.md new file mode 100644 index 0000000000..e9405d0a06 --- /dev/null +++ b/.changeset/plugin-publish-integrity-preflight.md @@ -0,0 +1,6 @@ +--- +"@objectstack/cli": patch +"@objectstack/core": patch +--- + +`os plugin publish` now verifies the artifact's own declared `manifest.integrity` digests before uploading, and refuses the publish on a digest mismatch, a declared entry with no file, or a packaged file the map does not declare (an absent map still publishes — the field is optional). The pure checker, `verifyIntegrity`, lives in `@objectstack/core` beside the artifact-signature contract. Unpack-time re-verification remains the cloud control plane's obligation (#11331) and is not changed by this release. diff --git a/packages/cli/src/commands/plugin/publish.ts b/packages/cli/src/commands/plugin/publish.ts index 68bcf5f6c2..6d353c0133 100644 --- a/packages/cli/src/commands/plugin/publish.ts +++ b/packages/cli/src/commands/plugin/publish.ts @@ -7,7 +7,11 @@ * Flow: * 1. Read the `.osplugin` bytes + the detached `.sig` (publisher signature). * 2. Extract the compiled `objectstack.plugin.json` from inside the - * artifact (id / version / name / runtime / permissions / integrity). + * artifact (id / version / name / runtime / permissions / integrity), + * then preflight the artifact files against the manifest's declared + * per-file `integrity` digests (ADR-0025 §3.2) — refuse on any + * mismatch / missing / extra file; an absent map skips the check + * (the field is optional). * 3. POST /cloud/packages — ensure the sys_package row exists. * 4. POST /cloud/packages/:id/versions with `artifact_kind: 'plugin'`, * the base64 artifact, the declared manifest, the signature, and the @@ -25,7 +29,15 @@ import { resolve as resolvePath, basename } from 'node:path'; import { Args, Command, Flags } from '@oclif/core'; import { printHeader, printKV, printSuccess, printError, printStep } from '../../utils/format.js'; import { DEFAULT_CLOUD_URL, tryReadCloudConfig } from '../../utils/cloud-config.js'; -import { OSPLUGIN_EXT, sha256Hex, readOspluginManifest } from '../../utils/osplugin.js'; +import { + OSPLUGIN_EXT, + MANIFEST_FILENAME, + SIGNATURE_FILENAME, + sha256Hex, + readTarGz, + type ArchiveFile, +} from '../../utils/osplugin.js'; +import { verifyIntegrity, formatIntegrityViolation } from '@objectstack/core'; interface PostResult { ok: boolean; status: number; body: any; error?: string } @@ -80,8 +92,12 @@ export default class PluginPublish extends Command { // 2. Extract the compiled manifest from inside the artifact. ──────── let manifest: Record; + let archiveFiles: ArchiveFile[]; try { - manifest = readOspluginManifest(bytes); + archiveFiles = readTarGz(bytes); + const entry = archiveFiles.find((f) => f.path === MANIFEST_FILENAME); + if (!entry) throw new Error(`${MANIFEST_FILENAME} not found in artifact`); + manifest = JSON.parse(Buffer.from(entry.data).toString('utf8')) as Record; } catch (err: any) { printError(`Cannot read manifest from artifact: ${err?.message ?? err}`); this.exit(1); @@ -93,6 +109,39 @@ export default class PluginPublish extends Command { if (!id || !version) { printError('Artifact manifest is missing id or version.'); this.exit(1); return; } printStep(`${id}@${version} (${(bytes.byteLength / 1024).toFixed(1)} KB, runtime: ${manifest.runtime ?? 'unset'})`); + // 2b. Integrity preflight (ADR-0025 §3.2) — self-check the artifact + // bytes against the manifest's own declared per-file digests before + // upload. Absent map = permissive by contract (the field is + // `.optional()`; artifacts built before integrity computation stay + // publishable). Unpack-time re-verification remains the cloud control + // plane's obligation (#11331) — this preflight does not discharge it. + const declaredIntegrity = manifest.integrity; + if ( + declaredIntegrity !== undefined && declaredIntegrity !== null + && (typeof declaredIntegrity !== 'object' || Array.isArray(declaredIntegrity)) + ) { + printError('Artifact manifest has a malformed `integrity` map (expected an object of path → digest). Rebuild with `os plugin build`.'); + this.exit(1); + return; + } + const integrityCheck = verifyIntegrity( + archiveFiles, + declaredIntegrity as Record | undefined, + { exempt: [MANIFEST_FILENAME, SIGNATURE_FILENAME] }, + ); + if (!integrityCheck.ok) { + printError(`Integrity preflight failed — the artifact's bytes no longer match its own manifest \`integrity\` digests (${integrityCheck.violations.length} violation${integrityCheck.violations.length === 1 ? '' : 's'}):`); + for (const v of integrityCheck.violations) console.log(` • ${formatIntegrityViolation(v)}`); + console.log('\n Rebuild the artifact with `os plugin build` (then re-sign with `os plugin sign`) so the digests match the packaged files, and publish the fresh artifact.'); + this.exit(1); + return; + } + if (integrityCheck.skipped) { + printStep('No `integrity` map in the manifest — per-file integrity preflight skipped.'); + } else { + printKV(' Integrity', `${integrityCheck.checked} file(s) verified against the manifest digests`); + } + // 3. Detached publisher signature. ───────────────────────────────── const sigPath = resolvePath(process.cwd(), flags.sig ?? `${artifactPath}.sig`); let signature: string | undefined; diff --git a/packages/cli/src/utils/osplugin.ts b/packages/cli/src/utils/osplugin.ts index d44f72ab81..7e7ab77413 100644 --- a/packages/cli/src/utils/osplugin.ts +++ b/packages/cli/src/utils/osplugin.ts @@ -13,10 +13,12 @@ * SIGNATURE ← detached publisher signature (placeholder * until `os plugin sign`; ADR §3.4) * - * The control plane (cloud) stores this blob opaquely and re-verifies the - * per-file `integrity` at install/load time when the runtime unpacks it - * (ADR §3.5 step 5). This module owns the two contracts the runtime and - * cloud must agree on byte-for-byte: + * The control plane (cloud) stores this blob opaquely. The per-file + * `integrity` map is computed here at build time and self-checked by the + * `os plugin publish` preflight; re-verification at install/load-time + * unpack (ADR §3.5 step 5) is the cloud control plane's obligation and is + * not implemented in this repo (#11331). This module owns the two + * contracts the runtime and cloud must agree on byte-for-byte: * * 1. The integrity digest STRING FORMAT — Subresource-Integrity style * `sha256-` (matches ADR-0025 §3.2's example). See @@ -40,7 +42,9 @@ export interface ArchiveFile { /** * Subresource-Integrity-style digest of `bytes`: `sha256-`. * This is the canonical per-file integrity string written into the - * compiled manifest's `integrity` map and re-verified by the runtime. + * compiled manifest's `integrity` map and checked back at the + * `os plugin publish` preflight (unpack-time re-verification is the + * cloud control plane's obligation, #11331). */ export function sriDigest(bytes: Uint8Array): string { return 'sha256-' + createHash('sha256').update(bytes).digest('base64'); diff --git a/packages/cli/test/plugin-publish.test.ts b/packages/cli/test/plugin-publish.test.ts index b13d525739..70a86727d6 100644 --- a/packages/cli/test/plugin-publish.test.ts +++ b/packages/cli/test/plugin-publish.test.ts @@ -9,24 +9,29 @@ import { readOspluginManifest, readTarGz, sha256Hex, + sriDigest, MANIFEST_FILENAME, SIGNATURE_FILENAME, type ArchiveFile, } from '../src/utils/osplugin.js'; import PluginPublish from '../src/commands/plugin/publish.js'; +const distData = new Uint8Array(Buffer.from('export const x=1;\n')); + const manifest = { id: 'com.acme.demo', name: 'Demo', version: '1.2.0', type: 'plugin', runtime: 'sandbox', packaging: 'bundled', main: 'dist/index.mjs', permissions: { services: ['object'] }, - integrity: { 'dist/index.mjs': 'sha256-abc' }, + // Real digest of `distData` — the publish preflight verifies it. + integrity: { 'dist/index.mjs': sriDigest(distData) }, }; -function buildArtifact(): Uint8Array { +function buildArtifact(manifestOverride: Record = manifest, extraFiles: ArchiveFile[] = []): Uint8Array { const files: ArchiveFile[] = [ - { path: 'dist/index.mjs', data: new Uint8Array(Buffer.from('export const x=1;\n')) }, - { path: MANIFEST_FILENAME, data: new Uint8Array(Buffer.from(JSON.stringify(manifest, null, 2))) }, + { path: 'dist/index.mjs', data: distData }, + { path: MANIFEST_FILENAME, data: new Uint8Array(Buffer.from(JSON.stringify(manifestOverride, null, 2))) }, { path: SIGNATURE_FILENAME, data: new Uint8Array(Buffer.from('unsigned\n')) }, + ...extraFiles, ]; return new Uint8Array(createTarGz(files)); } @@ -95,4 +100,77 @@ describe('os plugin publish (end-to-end, mocked cloud)', () => { expect(Buffer.from(calls[1].body.osplugin, 'base64').equals(Buffer.from(blob))).toBe(true); expect(calls[1].body.plugin_manifest).toMatchObject({ id: 'com.acme.demo', runtime: 'sandbox' }); }); + + async function runExpectingRefusal(blob: Uint8Array): Promise<{ output: string; fetchCalls: number }> { + dir = await mkdtemp(join(tmpdir(), 'plugin-publish-')); + const artifactPath = join(dir, 'com.acme.demo-1.2.0.osplugin'); + await writeFile(artifactPath, blob); + process.env.OS_CLOUD_URL = 'http://cloud.test'; + process.env.OS_CLOUD_API_KEY = 'tok_123'; + const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), statusText: 'OK' } as any)); + vi.stubGlobal('fetch', fetchMock); + const lines: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }); + try { + await expect(PluginPublish.run([artifactPath])).rejects.toThrow(/EEXIT: 1/); + } finally { + logSpy.mockRestore(); + } + return { output: lines.join('\n'), fetchCalls: fetchMock.mock.calls.length }; + } + + it('refuses the publish before any upload when a declared digest mismatches', async () => { + const tampered = { ...manifest, integrity: { 'dist/index.mjs': sriDigest(new Uint8Array(Buffer.from('other bytes'))) } }; + const { output, fetchCalls } = await runExpectingRefusal(buildArtifact(tampered)); + expect(fetchCalls).toBe(0); // refused pre-upload — nothing reached the cloud + expect(output).toContain('Integrity preflight failed'); + expect(output).toContain('dist/index.mjs'); + expect(output).toContain('digest mismatch'); + expect(output).toContain('os plugin build'); + }); + + it('refuses when the integrity map declares a file the artifact lacks', async () => { + const withGhost = { ...manifest, integrity: { ...manifest.integrity, 'dist/ghost.mjs': sriDigest(distData) } }; + const { output, fetchCalls } = await runExpectingRefusal(buildArtifact(withGhost)); + expect(fetchCalls).toBe(0); + expect(output).toContain('dist/ghost.mjs'); + expect(output).toContain('absent from the artifact'); + }); + + it('refuses on a packaged file the integrity map does not declare (stale map)', async () => { + const blob = buildArtifact(manifest, [{ path: 'dist/extra.mjs', data: distData }]); + const { output, fetchCalls } = await runExpectingRefusal(blob); + expect(fetchCalls).toBe(0); + expect(output).toContain('dist/extra.mjs'); + expect(output).toContain('not in the integrity map'); + }); + + it('absent integrity map is permissive: publish proceeds with a skip notice (the field is optional)', async () => { + dir = await mkdtemp(join(tmpdir(), 'plugin-publish-')); + const withoutIntegrity: Record = { ...manifest }; + delete withoutIntegrity.integrity; + const blob = buildArtifact(withoutIntegrity); + const artifactPath = join(dir, 'com.acme.demo-1.2.0.osplugin'); + await writeFile(artifactPath, blob); + process.env.OS_CLOUD_URL = 'http://cloud.test'; + process.env.OS_CLOUD_API_KEY = 'tok_123'; + const fetchMock = vi.fn(async (url: string) => { + const data = url.endsWith('/versions') ? { version: '1.2.0' } : { id: 'pkg_1', created: true }; + return { ok: true, status: 200, json: async () => ({ success: true, data }), statusText: 'OK' } as any; + }); + vi.stubGlobal('fetch', fetchMock); + const lines: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }); + try { + await PluginPublish.run([artifactPath]); + } finally { + logSpy.mockRestore(); + } + expect(fetchMock.mock.calls.length).toBe(2); // both uploads still happened + expect(lines.join('\n')).toContain('integrity preflight skipped'); + }); }); diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 31b0d89615..53e7516d30 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -34,6 +34,19 @@ export { verifyPluginArtifact, } from './plugin-artifact-signature.js'; +// Per-file artifact integrity verification (ADR-0025 §3.2) — pure and +// portable like the signature contract above; consumed by the +// `os plugin publish` preflight. Unpack-time re-verification stays the +// cloud control plane's obligation (#11331). +export { + verifyIntegrity, + formatIntegrityViolation, + type IntegrityFile, + type IntegrityViolation, + type IntegrityViolationKind, + type VerifyIntegrityResult, +} from './plugin-artifact-integrity.js'; + // `PluginConfigValidator` / `createPluginConfigValidator` were RETIRED here on // 2026-08-27 (#11982, ADR-0049 enforce-or-remove; recorded in ADR-0025 §3.7). // The kernel never received a plugin's config to validate — factories close diff --git a/packages/core/src/security/plugin-artifact-integrity.test.ts b/packages/core/src/security/plugin-artifact-integrity.test.ts new file mode 100644 index 0000000000..34d9db36c6 --- /dev/null +++ b/packages/core/src/security/plugin-artifact-integrity.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { createHash } from 'node:crypto'; +import { + verifyIntegrity, + formatIntegrityViolation, + type IntegrityFile, +} from './plugin-artifact-integrity.js'; + +function sri(data: Uint8Array, alg = 'sha256'): string { + return `${alg}-${createHash(alg).update(data).digest('base64')}`; +} + +const codeBytes = new Uint8Array(Buffer.from('export const x = 1;\n')); +const assetBytes = new Uint8Array(Buffer.from('body { color: red }\n')); + +function files(): IntegrityFile[] { + return [ + { path: 'dist/index.mjs', data: codeBytes }, + { path: 'assets/app.css', data: assetBytes }, + ]; +} + +describe('verifyIntegrity', () => { + it('passes when every declared digest matches and every file is declared', () => { + const res = verifyIntegrity(files(), { + 'dist/index.mjs': sri(codeBytes), + 'assets/app.css': sri(assetBytes), + }); + expect(res).toEqual({ ok: true, skipped: false, checked: 2, violations: [] }); + }); + + it('refuses on a single-file digest mismatch, naming declared and actual', () => { + const declared = sri(new Uint8Array(Buffer.from('tampered'))); + const res = verifyIntegrity(files(), { + 'dist/index.mjs': declared, + 'assets/app.css': sri(assetBytes), + }); + expect(res.ok).toBe(false); + expect(res.skipped).toBe(false); + expect(res.checked).toBe(2); + expect(res.violations).toEqual([ + { kind: 'digest_mismatch', path: 'dist/index.mjs', declared, actual: sri(codeBytes) }, + ]); + }); + + it('refuses when a declared entry has no corresponding file', () => { + const res = verifyIntegrity([files()[0]], { + 'dist/index.mjs': sri(codeBytes), + 'assets/app.css': sri(assetBytes), + }); + expect(res.ok).toBe(false); + expect(res.violations).toEqual([ + { kind: 'missing_file', path: 'assets/app.css', declared: sri(assetBytes) }, + ]); + }); + + it('refuses on a file the integrity map does not declare (stale-map drift)', () => { + const res = verifyIntegrity(files(), { 'dist/index.mjs': sri(codeBytes) }); + expect(res.ok).toBe(false); + expect(res.violations).toEqual([{ kind: 'extra_file', path: 'assets/app.css' }]); + }); + + it('absent map is a permissive pass (the manifest field is optional): ok + skipped, nothing checked', () => { + for (const absent of [undefined, null] as const) { + const res = verifyIntegrity(files(), absent); + expect(res).toEqual({ ok: true, skipped: true, checked: 0, violations: [] }); + } + }); + + it('exempt paths are outside the map coverage in both directions', () => { + const manifestFile: IntegrityFile = { + path: 'objectstack.plugin.json', + data: new Uint8Array(Buffer.from('{}')), + }; + const res = verifyIntegrity([...files(), manifestFile], { + 'dist/index.mjs': sri(codeBytes), + 'assets/app.css': sri(assetBytes), + // A (mis)declared exempt entry is skipped rather than compared. + 'objectstack.plugin.json': 'sha256-not-checked', + }, { exempt: ['objectstack.plugin.json'] }); + expect(res).toEqual({ ok: true, skipped: false, checked: 2, violations: [] }); + }); + + it('verifies sha384/sha512 SRI digests by their own algorithm', () => { + const res = verifyIntegrity(files(), { + 'dist/index.mjs': sri(codeBytes, 'sha512'), + 'assets/app.css': sri(assetBytes, 'sha384'), + }); + expect(res.ok).toBe(true); + expect(res.checked).toBe(2); + }); + + it('an unrecognized digest shape is a mismatch (compared as sha256), never a silent pass', () => { + const res = verifyIntegrity([files()[0]], { 'dist/index.mjs': 'md5-abc' }); + expect(res.ok).toBe(false); + expect(res.violations[0]).toMatchObject({ + kind: 'digest_mismatch', + path: 'dist/index.mjs', + declared: 'md5-abc', + actual: sri(codeBytes), + }); + }); + + it('reports every violation, deterministically ordered (map order, then sorted extras)', () => { + const res = verifyIntegrity( + [files()[1], { path: 'dist/extra.mjs', data: codeBytes }], + { + 'dist/index.mjs': sri(codeBytes), + 'assets/app.css': sri(codeBytes), // wrong bytes declared + }, + ); + expect(res.ok).toBe(false); + expect(res.violations.map((v) => `${v.kind}:${v.path}`)).toEqual([ + 'missing_file:dist/index.mjs', + 'digest_mismatch:assets/app.css', + 'extra_file:dist/extra.mjs', + ]); + }); +}); + +describe('formatIntegrityViolation', () => { + it('renders one actionable line per kind', () => { + expect( + formatIntegrityViolation({ kind: 'digest_mismatch', path: 'a', declared: 'sha256-x', actual: 'sha256-y' }), + ).toContain('digest mismatch'); + expect(formatIntegrityViolation({ kind: 'missing_file', path: 'a', declared: 'sha256-x' })).toContain( + 'absent from the artifact', + ); + expect(formatIntegrityViolation({ kind: 'extra_file', path: 'a' })).toContain('not in the integrity map'); + }); +}); diff --git a/packages/core/src/security/plugin-artifact-integrity.ts b/packages/core/src/security/plugin-artifact-integrity.ts new file mode 100644 index 0000000000..329b3c123f --- /dev/null +++ b/packages/core/src/security/plugin-artifact-integrity.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Per-file integrity verification for `.osplugin` artifacts (ADR-0025 §3.2). + * + * Checks an unpacked artifact's files against the manifest's declared + * `integrity` map (artifact-relative path → SRI-style `sha256-` + * digest, the format `os plugin build` writes). Like its sibling + * `plugin-artifact-signature.ts`, this module is pure and dependency-free + * (node:crypto only) so it stays byte-for-byte portable to the cloud + * control plane, which owes the unpack-time re-verification leg + * (ADR-0025 §3.5 step 5 — tracked on #11331, NOT discharged by this + * module). The framework caller is the `os plugin publish` preflight: the + * publisher self-checks its own artifact before upload. + * + * Verdict semantics: + * - absent map (`undefined` / `null`) → ok, `skipped: true` — the field + * is `.optional()` in the manifest schema, so artifacts predating + * integrity computation stay publishable (permissive by contract). + * - digest mismatch, declared entry with no file, or file with no + * declared entry → NOT ok, with one structured violation per finding. + * An extra file is refused because a stale map is exactly the drift + * this check exists to catch. + */ + +import { createHash } from 'node:crypto'; + +/** A single unpacked artifact file. `path` is POSIX, archive-relative. */ +export interface IntegrityFile { + path: string; + data: Uint8Array; +} + +export type IntegrityViolationKind = 'digest_mismatch' | 'missing_file' | 'extra_file'; + +/** One structured integrity finding (the rejection envelope's unit). */ +export interface IntegrityViolation { + kind: IntegrityViolationKind; + /** Artifact-relative POSIX path the finding is about. */ + path: string; + /** The digest the manifest declares (absent for `extra_file`). */ + declared?: string; + /** The digest computed from the supplied bytes (absent unless comparable). */ + actual?: string; +} + +export interface VerifyIntegrityResult { + /** Overall verdict: every declared digest matched and no file was unaccounted for. */ + ok: boolean; + /** True when no integrity map was supplied, so nothing was checked (still `ok`). */ + skipped: boolean; + /** Number of declared entries whose digests were computed and compared. */ + checked: number; + violations: IntegrityViolation[]; +} + +/** SRI hash algorithms this verifier can compute (`-`). */ +const SRI_ALGORITHMS = new Set(['sha256', 'sha384', 'sha512']); + +function sriDigestFor(declared: string, data: Uint8Array): string { + const dash = declared.indexOf('-'); + const alg = dash > 0 && SRI_ALGORITHMS.has(declared.slice(0, dash)) ? declared.slice(0, dash) : 'sha256'; + return `${alg}-${createHash(alg).update(data).digest('base64')}`; +} + +/** + * Verify `files` against the manifest's declared `integrity` map. + * + * `options.exempt` names paths outside the map's coverage — the compiled + * manifest itself and the signature placeholder, which `computeIntegrity` + * excludes at build time (the manifest cannot hash itself, and the + * signature signs the manifest) — so their presence is never an + * `extra_file` finding. + */ +export function verifyIntegrity( + files: readonly IntegrityFile[], + integrity: Readonly> | null | undefined, + options: { exempt?: readonly string[] } = {}, +): VerifyIntegrityResult { + if (integrity === null || integrity === undefined) { + return { ok: true, skipped: true, checked: 0, violations: [] }; + } + const exempt = new Set(options.exempt ?? []); + const byPath = new Map(); + for (const f of files) { + if (!exempt.has(f.path)) byPath.set(f.path, f.data); + } + + const violations: IntegrityViolation[] = []; + let checked = 0; + for (const [path, declaredRaw] of Object.entries(integrity)) { + if (exempt.has(path)) continue; + const declared = typeof declaredRaw === 'string' ? declaredRaw : String(declaredRaw); + const data = byPath.get(path); + if (data === undefined) { + violations.push({ kind: 'missing_file', path, declared }); + continue; + } + checked++; + const actual = sriDigestFor(declared, data); + if (actual !== declared) violations.push({ kind: 'digest_mismatch', path, declared, actual }); + } + for (const path of [...byPath.keys()].sort()) { + if (!Object.prototype.hasOwnProperty.call(integrity, path)) { + violations.push({ kind: 'extra_file', path }); + } + } + return { ok: violations.length === 0, skipped: false, checked, violations }; +} + +/** Render one violation as a single human-actionable line. */ +export function formatIntegrityViolation(v: IntegrityViolation): string { + switch (v.kind) { + case 'digest_mismatch': + return `${v.path}: digest mismatch — manifest declares ${v.declared}, artifact bytes hash to ${v.actual}`; + case 'missing_file': + return `${v.path}: declared in the integrity map but absent from the artifact`; + case 'extra_file': + return `${v.path}: present in the artifact but not in the integrity map`; + } +} diff --git a/packages/spec/liveness/manifest.json b/packages/spec/liveness/manifest.json index 2c4abb6390..d9afe6dcb9 100644 --- a/packages/spec/liveness/manifest.json +++ b/packages/spec/liveness/manifest.json @@ -264,9 +264,9 @@ }, "integrity": { "status": "dead", - "verifiedAt": "2026-08-23", + "verifiedAt": "2026-08-30", "evidenceScope": "cross-repo", - "note": "Per-file content digests of the packaged artifact. ZERO reads in objectstack or objectui — nothing computes them at build time and nothing re-verifies them at unpack, although manifest.zod.ts:105-109 says the runtime \"re-verifies\" them when it unpacks the `.osplugin` (ADR-0025 §3.5 step 5). A declared integrity check that never runs is the false-compliance shape in its most literal form: the field's presence in a published manifest is indistinguishable, to a reader, from the digests having been checked. Adjacent machinery that DOES exist and is not this: packages/core/src/security/plugin-artifact-signature.ts verifies an artifact SIGNATURE (and returns `verified=false` rather than throwing when absent) — a different mechanism on a different field. ⚠️ Cloud unmeasured (see `_note`), and the install-time unpack ADR-0025 §3.5 describes is a control-plane path, so the cloud leg must be measured before any removal. Filed as #11331." + "note": "Per-file content digests of the packaged artifact. Status left `dead` per the #13464 mandate: this row is the enforce-or-remove worklist entry for the RULED leg — re-verification at install/load-time unpack (ADR-0025 §3.5 step 5) — which still has zero implementation in this repo and remains the cloud control plane's obligation, tracked on #11331 (cloud leg unmeasured, see `_note`); #13464 does NOT discharge it. What #13464 DID land, correcting this note's earlier census: the map is computed at build (packages/cli/src/commands/plugin/build.ts#PluginBuild via computeIntegrity) and is now read once in-repo — the `os plugin publish` preflight self-checks the artifact bytes against the manifest's own declared digests and refuses the publish on digest mismatch, missing declared file, or extra undeclared file (packages/cli/src/commands/plugin/publish.ts#PluginPublish, calling packages/core/src/security/plugin-artifact-integrity.ts#verifyIntegrity; absent map = permissive pass — the field is `.optional()`). So the false-compliance shape is narrowed, not closed: a publisher can no longer upload an artifact whose bytes contradict its own map, but nothing at unpack re-checks what a marketplace consumer actually installs. Adjacent machinery that DOES exist and is not this: packages/core/src/security/plugin-artifact-signature.ts verifies an artifact SIGNATURE (and returns `verified=false` rather than throwing when absent) — a different mechanism on a different field. If ledger semantics require the new non-test reader to flip this row to `live`, that flip belongs to the #11331 resolution / review chain, not to a rider here." } } } diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index ce6a87a5a0..3eb23c59a6 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -100,8 +100,10 @@ export type PluginPackaging = z.input; /** * Per-file content digests of the packaged artifact (ADR-0025 §3.2), * mapping artifact-relative path → digest string (e.g. "sha256-"). - * Re-verified by the runtime when it unpacks the `.osplugin` (ADR §3.5 - * step 5). + * Computed at build time (`os plugin build`) and self-checked by the + * publisher at the `os plugin publish` preflight. Unpack-time + * re-verification (ADR §3.5 step 5) is the cloud control plane's + * obligation and is not implemented in this repo (#11331). */ export const PluginIntegritySchema = z .record(z.string(), z.string()) @@ -609,8 +611,10 @@ export const ManifestSchema = z.object({ .describe('Dependency packaging strategy (ADR-0025 §3.3)'), /** - * Per-file content digests of the packaged artifact (ADR-0025 §3.2), - * verified at install/load time when the runtime unpacks the `.osplugin`. + * Per-file content digests of the packaged artifact (ADR-0025 §3.2). + * Computed at build, self-checked at the publish preflight; unpack-time + * verification is the cloud control plane's obligation, not yet + * implemented (#11331). */ integrity: PluginIntegritySchema.optional() .describe('Per-file content digests of the plugin artifact (ADR-0025 §3.2)'),