/* RFC-0359 + RFC-0380: notausgang.export and notausgang.validate command handlers. RFC-0380 upgrades validate from shallow existence checks to deep integrity verification with @warpgogol/fingerprint hashing and YAML artifacts. Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file. Do not use ad hoc crypto.createHash — all hashing via @warpgogol/fingerprint (DNA-53). Do not write JSON artifacts — export uses YAML per RFC-0376. RFC-0359: initial notausgang command handlers. RFC-0380: deep integrity verification, @warpgogol/fingerprint hashing, YAML artifacts, refined secret scanning, CheckStatus enum types. RFC-0381: fix bordbuch field names (id, kind, occurredAt), nested pin access (platform.version), optional artifact manifest, fix timestamp validation logic. RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt */ import path from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { resolveSiteWorkspace, type KernelCommandInput, type KernelCommandResult, type KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import { collectFiles } from "@warpgogol/werkstatt-shared/node/fs"; import { fingerprintTree, fingerprintFile } from "@warpgogol/werkstatt-engine/fingerprint/semantic"; import { byteHashFile } from "@warpgogol/werkstatt-engine/fingerprint"; import { notausgangManifestSchema, type NotausgangEvidenceVerdict, type NotausgangFeatureNote, type NotausgangManifest, } from "@warpgogol/werkstatt-engine/schemas"; import { stableJsonHash } from "@warpgogol/werkstatt-engine/fingerprint"; import { generateOperationId } from "../werkstatt/index.ts"; import { atomicMoveDir, atomicWriteFile } from "../werkstatt/atomic.ts"; import { readSystemConfig, resolveCacheClonePath } from "../sternsystem/registry-io.ts"; import { bundleEvidenceArtifacts } from "./evidence-bundle.ts"; import { buildFeatureInventory, renderFeatureNotes } from "./feature-notes.ts"; import { downloadFromR2 } from "../nachweis/nachweis-io.ts"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; function flagString(input: KernelCommandInput, key: string): string | undefined { const v = input.flags[key]; return typeof v === "string" ? v : undefined; } function flagStringArray(input: KernelCommandInput, key: string): string[] { const v = input.flags[key]; if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string"); if (typeof v === "string") return [v]; return []; } const RUNTIME_FILE_PATTERNS = [ "package.json", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "astro.config", "wrangler.", "tsconfig.", "node_modules", ]; const SECRET_PATTERNS = [ /TESTSECRET_[a-zA-Z0-9]{20,}/, /sk_live_[a-zA-Z0-9]{20,}/, /sk_test_[a-zA-Z0-9]{20,}/, /eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/, /xoxb-[a-zA-Z0-9-]+/, /ghp_[a-zA-Z0-9]{36,}/, /AKIA[0-9A-Z]{16}/, ]; const SECRET_SCAN_SKIP_PATHS = ["bordbuch/", "system.pin.yaml", ".hash"]; function shouldSkipForSecretScan(relPath: string): boolean { return SECRET_SCAN_SKIP_PATHS.some( (skip) => relPath === skip || relPath.startsWith(skip) || relPath.endsWith(skip), ); } async function copyDir( src: string, dest: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { if (!(await io.exists(src))) return; await io.mkdir(dest); const entries = await io.readdir(src); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory) { await copyDir(srcPath, destPath); } else { await io.copyFile(srcPath, destPath); } } } function isRuntimeFile(fileName: string): boolean { return RUNTIME_FILE_PATTERNS.some((p) => fileName.startsWith(p) || fileName === p); } async function scanForSecrets( dir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise> { const findings: Array<{ file: string; pattern: string }> = []; for (const fullPath of await collectFiles(dir)) { const relPath = path.relative(dir, fullPath).replace(/\\/g, "/"); if (shouldSkipForSecretScan(relPath)) continue; const content = await io.readFile(fullPath).catch(() => ""); for (const pattern of SECRET_PATTERNS) { if (pattern.test(content)) { findings.push({ file: relPath, pattern: pattern.source }); } } } return findings; } // §5.1: notausgang.export export interface NotausgangExportData { systemId: string; releaseId: string; outputPath: string; integrationNulling: { nulled: string[]; exceptions: Array<{ name: string; reason: string }> }; distHash: string; siteHash: string; bordbuchHash: string; // RFC-1120 evidence: Record; features: NotausgangFeatureNote[]; verifier: NotausgangVerifier; } const NOTAUSGANG_VERIFIER = { script: "verify.py", runtime: "python3-stdlib", manifestFile: "notausgang-manifest.json", } as const; type NotausgangVerifier = typeof NOTAUSGANG_VERIFIER; let templatesDir: string | undefined; /** Lazily resolved — module-level fileURLToPath(import.meta.url) throws under * vitest's module runner (non-file URL scheme) and kills the loader. */ function resolveTemplatesDir(): string { return (templatesDir ??= fileURLToPath(new URL("./templates/", import.meta.url))); } /** sha256sum-format checksum list over every package file except SHA256SUMS.txt itself. */ async function writeSha256Sums(stagingDir: string): Promise { const files = await collectFiles(stagingDir); const lines: string[] = []; for (const abs of files) { const rel = path.relative(stagingDir, abs).replace(/\\/g, "/"); if (rel === "SHA256SUMS.txt") continue; const hex = (await byteHashFile(abs)).replace(/^sha256:/, ""); lines.push(`${hex} ${rel}`); } lines.sort(); await atomicWriteFile(path.join(stagingDir, "SHA256SUMS.txt"), lines.join("\n") + "\n"); } export async function runNotausgangExport( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const systemId = flagString(input, "system"); const releaseId = flagString(input, "release"); const outputFlag = flagString(input, "output"); const keepIntegrations = flagStringArray(input, "keep-integration"); const reasons = flagStringArray(input, "reason"); if (!systemId || !releaseId || !outputFlag) { throw new Error("[notausgang.export] --system, --release, and --output are required"); } const outputPath = path.resolve(workspaceRoot, outputFlag); if (await context.io.exists(outputPath)) { const entries = await context.io.readdir(outputPath); if (entries.length > 0) { throw new Error(`[notausgang.export] output path '${outputFlag}' exists and is non-empty`); } } // Read release manifest const releaseDir = path.join(workspaceRoot, "releases", releaseId); const releaseManifestPath = path.join(releaseDir, "release.yaml"); if (!(await context.io.exists(releaseManifestPath))) { throw new Error(`[notausgang.export] release '${releaseId}' not found`); } const releaseManifestRaw = await context.io.readFile(releaseManifestPath); const releaseManifest = parseYaml(releaseManifestRaw) as Record; if (releaseManifest.state !== "ready") { throw new Error( `[notausgang.export] release '${releaseId}' is not ready (state: ${releaseManifest.state})`, ); } // Read system config for cosmic star and resolve the runnable site workspace. const config = await readSystemConfig(workspaceRoot, systemId); const siteWorkspace = await resolveSiteWorkspace(workspaceRoot, systemId); const operationId = generateOperationId(); const stagingDir = `${outputPath}.tmp-${operationId}`; logger.info(` Staging: ${stagingDir}`); try { await context.io.mkdir(stagingDir); // Copy site data (from the resolved site workspace) const siteSrcDir = path.join(siteWorkspace.directory, "src", "content"); const siteDest = path.join(stagingDir, "site", "src", "content"); if (await context.io.exists(siteSrcDir)) { await copyDir(siteSrcDir, siteDest); } // Copy provenance if present const provenanceDir = path.join(siteWorkspace.directory, "provenance"); if (await context.io.exists(provenanceDir)) { await copyDir(provenanceDir, path.join(stagingDir, "site", "provenance")); } // Copy dist from release const distSrc = path.join(releaseDir, "dist"); if (await context.io.exists(distSrc)) { await copyDir(distSrc, path.join(stagingDir, "dist")); } // Copy artifact manifest if present — convert JSON to YAML (RFC-0376) const artifactManifestSrcPath = path.join(releaseDir, "artifact-manifest.json"); const artifactManifestDestPath = path.join(stagingDir, "artifact-manifest.yaml"); if (await context.io.exists(artifactManifestSrcPath)) { const artifactJson = JSON.parse(await context.io.readFile(artifactManifestSrcPath)); await atomicWriteFile(artifactManifestDestPath, stringifyYaml(artifactJson) + "\n"); } // Copy Bordbuch const cacheDir = resolveCacheClonePath(workspaceRoot, systemId); const bordbuchPath = path.join(cacheDir, "bordbuch", "events.ndjson"); if (await context.io.exists(bordbuchPath)) { await context.io.mkdir(path.join(stagingDir, "bordbuch")); await context.io.copyFile(bordbuchPath, path.join(stagingDir, "bordbuch", "events.ndjson")); } // Copy pin — convert JSON to YAML (RFC-0376) const pinSrcPath = path.join(cacheDir, "system.pin.json"); if (await context.io.exists(pinSrcPath)) { const pinJson = JSON.parse(await context.io.readFile(pinSrcPath)); await atomicWriteFile( path.join(stagingDir, "system.pin.yaml"), stringifyYaml(pinJson) + "\n", ); } // Copy behavior snapshots const snapshotsDir = path.join(stagingDir, "behavior-snapshots"); await context.io.mkdir(snapshotsDir); for (const snapshot of [ "readable-snapshot.json", "production-snapshot.json", "snapshot-diff.json", ]) { const src = path.join(releaseDir, snapshot); if (await context.io.exists(src)) { await context.io.copyFile(src, path.join(snapshotsDir, snapshot)); } } // Write README const readme = `# Notausgang Export: ${systemId} This package contains the complete site for ${systemId} as of release ${releaseId}. ## Serving the site immediately The \`dist/\` directory contains the pre-built production output. Serve it with any static file server: npx serve dist/ Or upload the contents of \`dist/\` to any static hosting provider. ## Rebuilding the site The \`site/\` directory contains the authored content and configuration. To rebuild, you need the Warpgogol platform (not included in this export). ## History The \`bordbuch/events.ndjson\` file contains the complete mission and release history. `; await atomicWriteFile(path.join(stagingDir, "README.md"), readme); // Compute hashes using @warpgogol/fingerprint (DNA-53) const distHash = (await fingerprintTree(path.join(stagingDir, "dist"), { mode: "byte" })).value; const siteHash = (await fingerprintTree(path.join(stagingDir, "site"), { mode: "semantic" })) .value; const bordbuchHash = ( await fingerprintFile(path.join(stagingDir, "bordbuch", "events.ndjson"), { mode: "semantic", }) ).hash; const behaviorSnapshotHash = ( await fingerprintTree(path.join(stagingDir, "behavior-snapshots"), { mode: "semantic" }) ).value; const artifactManifestHash = (await context.io.exists(artifactManifestDestPath)) ? (await fingerprintFile(artifactManifestDestPath, { mode: "byte" })).hash : "sha256:absent"; // Integration nulling const allIntegrations = ["matomo", "uchat", "supabase-crm", "stripe"]; const nulled = allIntegrations.filter((name) => !keepIntegrations.includes(name)); const exceptions = keepIntegrations.map((name, i) => ({ name, reason: reasons[i] ?? "No reason provided", })); // RFC-1120: evidence bundling — eligible artifacts from R2, fail-closed on hash mismatch const evidenceResult = await bundleEvidenceArtifacts({ siteDir: siteWorkspace.directory, stagingDir, systemId, download: downloadFromR2, logger, }); // RFC-1120: feature disclosure — inventory from cache-clone-readable sources const features = await buildFeatureInventory({ siteDir: siteWorkspace.directory, distDir: distSrc, systemConfig: config as Record, }); await atomicWriteFile( path.join(stagingDir, "feature-notes.md"), renderFeatureNotes({ systemId, releaseId, exportedAt: new Date().toISOString(), features, }), ); // RFC-1120: offline verifier + verification guide (copied verbatim) await context.io.copyFile( path.join(resolveTemplatesDir(), "verify.py"), path.join(stagingDir, "verify.py"), ); await context.io.copyFile( path.join(resolveTemplatesDir(), "verification.md"), path.join(stagingDir, "verification.md"), ); // Write manifest as YAML (RFC-0376) + JSON twin for the stdlib verifier (RFC-1120) const manifest = { schemaVersion: "1.0.0", systemId, cosmicStar: config.cosmicStar, releaseId, exportedAt: new Date().toISOString(), platformVersion: releaseManifest.platformVersion ?? "unknown", platformSemanticHash: releaseManifest.platformSemanticHash ?? "sha256:unknown", semver: releaseManifest.semver ?? "0.0.0", source: { releaseManifestHash: await byteHashFile(releaseManifestPath), artifactManifestHash, distArtifactHash: releaseManifest.distArtifactHash ?? "sha256:unknown", siteContentHash: releaseManifest.siteContentHash ?? "sha256:unknown", behaviorSnapshotHash, }, integrationNulling: { nulled, exceptions }, distHash, siteHash, bordbuchHash, evidence: evidenceResult.verdicts, features, verifier: NOTAUSGANG_VERIFIER, }; await atomicWriteFile( path.join(stagingDir, "notausgang-manifest.yaml"), stringifyYaml(manifest) + "\n", ); await atomicWriteFile( path.join(stagingDir, "notausgang-manifest.json"), JSON.stringify(manifest, null, 2) + "\n", ); // RFC-1120: flat checksum list — written last so it covers every other file await writeSha256Sums(stagingDir); // Atomic publish await atomicMoveDir(stagingDir, outputPath, { replace: true }); logger.success(`[notausgang.export] ${systemId} exported to ${outputFlag}`); return { data: { systemId, releaseId, outputPath, integrationNulling: { nulled, exceptions }, distHash, siteHash, bordbuchHash, evidence: evidenceResult.verdicts, features, verifier: NOTAUSGANG_VERIFIER, }, exitCode: 0, summary: `[notausgang.export] ${systemId} exported to ${outputFlag} (${nulled.length} integrations nulled, ${exceptions.length} exceptions)`, nextSteps: [ { action: `Validate the export: pnpm exec werkstatt run notausgang.validate --site ${systemId}`, kind: "optional", }, ], }; } catch (error) { // Cleanup staging on failure if (await context.io.exists(stagingDir)) { await context.io.rm(stagingDir, { recursive: true }).catch(() => {}); } throw error; } } // §5.2: notausgang.validate — deep integrity verification (RFC-0380) export type CheckStatus = "valid" | "invalid" | "missing"; export interface NotausgangViolation { rule: string; message: string; file?: string; } export interface NotausgangValidateData { path: string; manifest: CheckStatus; site: CheckStatus; dist: CheckStatus; bordbuch: CheckStatus; pin: CheckStatus; snapshots: CheckStatus; artifactManifest: CheckStatus; runtimeFilesAbsent: boolean; distHashMatch: boolean; siteHashMatch: boolean; bordbuchHashMatch: boolean; snapshotHashMatch: boolean; artifactHashMatch: boolean; liveKeyScan: string; violations: NotausgangViolation[]; } const BORDBUCH_REQUIRED_FIELDS = ["id", "kind", "occurredAt", "systemId"] as const; async function validateBordbuch( bordbuchPath: string, expectedSystemId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const violations: NotausgangViolation[] = []; const content = await io.readFile(bordbuchPath); const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line === "") continue; const lineNum = i + 1; let parsed: Record; try { parsed = JSON.parse(line); } catch { violations.push({ rule: "bordbuch-line-parse", message: `Line ${lineNum} is not valid JSON`, file: "bordbuch/events.ndjson", }); continue; } // Report each missing field individually — one violation per missing field per line. for (const field of BORDBUCH_REQUIRED_FIELDS) { if (!(field in parsed) || parsed[field] === undefined || parsed[field] === null) { violations.push({ rule: "bordbuch-field-missing", message: `Line ${lineNum} missing required field '${field}'`, file: "bordbuch/events.ndjson", }); continue; } } if (parsed.systemId !== expectedSystemId) { violations.push({ rule: "bordbuch-system-id-mismatch", message: `Line ${lineNum} systemId '${parsed.systemId}' does not match manifest '${expectedSystemId}'`, file: "bordbuch/events.ndjson", }); } if (typeof parsed.occurredAt === "string") { const ts = parsed.occurredAt; if (Number.isNaN(Date.parse(ts)) || !/^\d{4}-\d{2}-\d{2}T/.test(ts)) { violations.push({ rule: "bordbuch-timestamp-invalid", message: `Line ${lineNum} occurredAt '${ts}' is not valid ISO 8601`, file: "bordbuch/events.ndjson", }); } } } return violations; } export async function runNotausgangValidate( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const exportPath = flagString(input, "path"); if (!exportPath) throw new Error("[notausgang.validate] --path is required"); const exportDir = path.resolve(workspaceRoot, exportPath); if (!(await context.io.exists(exportDir))) { throw new Error(`[notausgang.validate] export path '${exportPath}' not found`); } const violations: NotausgangViolation[] = []; // --- Manifest: parse and schema-validate --- const manifestYamlPath = path.join(exportDir, "notausgang-manifest.yaml"); const manifestJsonPath = path.join(exportDir, "notausgang-manifest.json"); let manifestStatus: CheckStatus = "missing"; let manifest: NotausgangManifest | null = null; if (await context.io.exists(manifestYamlPath)) { try { const raw = await context.io.readFile(manifestYamlPath); const parsed = parseYaml(raw); const result = notausgangManifestSchema.safeParse(parsed); if (result.success) { manifest = result.data; manifestStatus = "valid"; } else { manifestStatus = "invalid"; violations.push({ rule: "manifest-schema-invalid", message: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), file: "notausgang-manifest.yaml", }); } } catch (err) { manifestStatus = "invalid"; violations.push({ rule: "manifest-parse-error", message: (err as Error).message, file: "notausgang-manifest.yaml", }); } } else { manifestStatus = "missing"; violations.push({ rule: "manifest-missing", message: "notausgang-manifest.yaml not found", }); } // RFC-1120 NA-MANIFEST-02: JSON twin must exist, parse, and match the YAML manifest let manifestJson: Record | null = null; if (await context.io.exists(manifestJsonPath)) { try { manifestJson = JSON.parse(await context.io.readFile(manifestJsonPath)) as Record< string, unknown >; if (manifest) { for (const field of ["evidence", "features", "verifier"] as const) { const yamlHash = stableJsonHash(manifest[field] ?? null); const jsonHash = stableJsonHash(manifestJson[field] ?? null); if (yamlHash !== jsonHash) { violations.push({ rule: "NA-MANIFEST-02", message: `notausgang-manifest.json '${field}' does not match the YAML twin`, file: "notausgang-manifest.json", }); } } } } catch (err) { violations.push({ rule: "NA-MANIFEST-02", message: `notausgang-manifest.json does not parse: ${(err as Error).message}`, file: "notausgang-manifest.json", }); } } else { violations.push({ rule: "NA-MANIFEST-02", message: "notausgang-manifest.json not found — export must be re-generated (RFC-1120)", file: "notausgang-manifest.json", }); } // --- Legacy artifact-manifest.json check --- const artifactManifestYamlPath = path.join(exportDir, "artifact-manifest.yaml"); const artifactManifestJsonPath = path.join(exportDir, "artifact-manifest.json"); if (await context.io.exists(artifactManifestJsonPath)) { violations.push({ rule: "legacy-json-artifact", message: "artifact-manifest.json found — export must be re-generated as YAML", file: "artifact-manifest.json", }); } // --- Legacy system.pin.json check --- const pinYamlPath = path.join(exportDir, "system.pin.yaml"); const pinJsonPath = path.join(exportDir, "system.pin.json"); if (await context.io.exists(pinJsonPath)) { violations.push({ rule: "legacy-pin-format", message: "system.pin.json found — export must be re-generated with system.pin.yaml", file: "system.pin.json", }); } // --- dist/ hash re-computation --- const distDir = path.join(exportDir, "dist"); let distStatus: CheckStatus = "missing"; let distHashMatch = false; if (await context.io.exists(distDir)) { distStatus = "valid"; if (manifest) { try { const recomputed = (await fingerprintTree(distDir, { mode: "byte" })).value; distHashMatch = recomputed === manifest.distHash; if (!distHashMatch) { violations.push({ rule: "dist-hash-mismatch", message: `dist hash mismatch: expected ${manifest.distHash}, got ${recomputed}`, }); } } catch (err) { distStatus = "invalid"; violations.push({ rule: "dist-hash-error", message: (err as Error).message, }); } } } else { violations.push({ rule: "dist-missing", message: "dist/ directory not found", }); } // --- site/ hash re-computation --- const siteDir = path.join(exportDir, "site"); let siteStatus: CheckStatus = "missing"; let siteHashMatch = false; if (await context.io.exists(siteDir)) { siteStatus = "valid"; if (manifest) { try { const recomputed = (await fingerprintTree(siteDir, { mode: "semantic" })).value; siteHashMatch = recomputed === manifest.siteHash; if (!siteHashMatch) { violations.push({ rule: "site-hash-mismatch", message: `site hash mismatch: expected ${manifest.siteHash}, got ${recomputed}`, }); } } catch (err) { siteStatus = "invalid"; violations.push({ rule: "site-hash-error", message: (err as Error).message, }); } } } else { violations.push({ rule: "site-missing", message: "site/ directory not found", }); } // --- Bordbuch hash + line-by-line validation --- const bordbuchPath = path.join(exportDir, "bordbuch", "events.ndjson"); let bordbuchStatus: CheckStatus = "missing"; let bordbuchHashMatch = false; if (await context.io.exists(bordbuchPath)) { bordbuchStatus = "valid"; if (manifest) { try { const recomputed = (await fingerprintFile(bordbuchPath, { mode: "semantic" })).hash; bordbuchHashMatch = recomputed === manifest.bordbuchHash; if (!bordbuchHashMatch) { violations.push({ rule: "bordbuch-hash-mismatch", message: `bordbuch hash mismatch: expected ${manifest.bordbuchHash}, got ${recomputed}`, file: "bordbuch/events.ndjson", }); } // Line-by-line validation const bordbuchViolations = await validateBordbuch(bordbuchPath, manifest.systemId); if (bordbuchViolations.length > 0) { bordbuchStatus = "invalid"; violations.push(...bordbuchViolations); } } catch (err) { bordbuchStatus = "invalid"; violations.push({ rule: "bordbuch-error", message: (err as Error).message, file: "bordbuch/events.ndjson", }); } } } else { violations.push({ rule: "bordbuch-missing", message: "bordbuch/events.ndjson not found", }); } // --- system.pin.yaml content validation --- let pinStatus: CheckStatus = "missing"; if (await context.io.exists(pinYamlPath)) { pinStatus = "valid"; if (manifest) { try { const pinRaw = await context.io.readFile(pinYamlPath); const pinData = parseYaml(pinRaw) as Record; const pinSystemId = pinData?.systemId; const pinPlatformVersion = (pinData?.platform as Record | undefined) ?.version; if (pinSystemId !== manifest.systemId) { pinStatus = "invalid"; violations.push({ rule: "pin-content-mismatch", message: `systemId mismatch: manifest '${manifest.systemId}', pin '${pinSystemId}'`, file: "system.pin.yaml", }); } if (pinPlatformVersion !== manifest.platformVersion) { pinStatus = "invalid"; violations.push({ rule: "pin-content-mismatch", message: `platformVersion mismatch: manifest '${manifest.platformVersion}', pin '${pinPlatformVersion}'`, file: "system.pin.yaml", }); } } catch (err) { pinStatus = "invalid"; violations.push({ rule: "pin-parse-error", message: (err as Error).message, file: "system.pin.yaml", }); } } } else { violations.push({ rule: "pin-missing", message: "system.pin.yaml not found", }); } // --- Behavior snapshots hash verification --- const snapshotsDir = path.join(exportDir, "behavior-snapshots"); let snapshotsStatus: CheckStatus = "missing"; let snapshotHashMatch = false; if (await context.io.exists(snapshotsDir)) { snapshotsStatus = "valid"; if (manifest) { try { const recomputed = (await fingerprintTree(snapshotsDir, { mode: "semantic" })).value; snapshotHashMatch = recomputed === manifest.source.behaviorSnapshotHash; if (!snapshotHashMatch) { violations.push({ rule: "snapshot-hash-mismatch", message: `behavior snapshot hash mismatch: expected ${manifest.source.behaviorSnapshotHash}, got ${recomputed}`, }); } } catch (err) { snapshotsStatus = "invalid"; violations.push({ rule: "snapshot-hash-error", message: (err as Error).message, }); } } } else { violations.push({ rule: "snapshots-missing", message: "behavior-snapshots/ directory not found", }); } // --- Artifact manifest hash verification --- let artifactManifestStatus: CheckStatus = "missing"; let artifactHashMatch = false; if (await context.io.exists(artifactManifestYamlPath)) { artifactManifestStatus = "valid"; if (manifest) { try { const recomputed = (await fingerprintFile(artifactManifestYamlPath, { mode: "byte" })).hash; artifactHashMatch = recomputed === manifest.source.artifactManifestHash; if (!artifactHashMatch) { violations.push({ rule: "artifact-hash-mismatch", message: `artifact manifest hash mismatch: expected ${manifest.source.artifactManifestHash}, got ${recomputed}`, file: "artifact-manifest.yaml", }); } } catch (err) { artifactManifestStatus = "invalid"; violations.push({ rule: "artifact-hash-error", message: (err as Error).message, }); } } } else { if (manifest?.source?.artifactManifestHash !== "sha256:absent") { violations.push({ rule: "artifact-manifest-missing", message: "artifact-manifest.yaml not found", }); } } // --- RFC-1120: evidence bundle rules --- const evidenceDir = path.join(exportDir, "evidence"); const manifestEvidence = (manifest?.evidence ?? {}) as Record< string, { verdict?: string; artifacts?: Array<{ itemKey?: string; filename?: string; sha256?: string }>; } >; const bundledSources = Object.entries(manifestEvidence).filter( ([, v]) => v?.verdict === "bundled", ); // NA-EVIDENCE-01: every bundled source has evidence// with integrity.txt for (const [sourceId] of bundledSources) { const sourceDir = path.join(evidenceDir, sourceId); const integrityPath = path.join(sourceDir, "integrity.txt"); if (!(await context.io.exists(integrityPath))) { violations.push({ rule: "NA-EVIDENCE-01", message: `manifest records evidence '${sourceId}' as bundled but evidence/${sourceId}/integrity.txt is missing`, file: `evidence/${sourceId}/integrity.txt`, }); } } // NA-EVIDENCE-02: re-verify each integrity.txt line against artifact bytes if (await context.io.exists(evidenceDir)) { for (const entry of await context.io.readdir(evidenceDir)) { if (!entry.isDirectory) continue; const integrityPath = path.join(evidenceDir, entry.name, "integrity.txt"); if (!(await context.io.exists(integrityPath))) continue; const lines = (await context.io.readFile(integrityPath)) .split("\n") .filter((l) => l.trim().length > 0); for (const line of lines) { const sep = line.indexOf(" "); if (sep < 0) { violations.push({ rule: "NA-EVIDENCE-02", message: `malformed integrity.txt line: '${line}'`, file: `evidence/${entry.name}/integrity.txt`, }); continue; } const expected = line.slice(0, sep).trim(); const rel = line.slice(sep + 2).trim(); const artifactPath = path.join(evidenceDir, entry.name, rel); if (!(await context.io.exists(artifactPath))) { violations.push({ rule: "NA-EVIDENCE-02", message: `artifact '${rel}' listed in integrity.txt is missing`, file: `evidence/${entry.name}/${rel}`, }); continue; } const actual = (await byteHashFile(artifactPath)).replace(/^sha256:/, ""); if (actual !== expected) { violations.push({ rule: "NA-EVIDENCE-02", message: `artifact '${rel}' hash mismatch: expected ${expected}, got ${actual}`, file: `evidence/${entry.name}/${rel}`, }); } } } } // NA-FEATURES-01: feature-notes.md present and non-empty const featureNotesPath = path.join(exportDir, "feature-notes.md"); if (!(await context.io.exists(featureNotesPath))) { violations.push({ rule: "NA-FEATURES-01", message: "feature-notes.md not found — export must be re-generated (RFC-1120)", file: "feature-notes.md", }); } else { const content = await context.io.readFile(featureNotesPath); if (content.trim().length === 0) { violations.push({ rule: "NA-FEATURES-01", message: "feature-notes.md is empty", file: "feature-notes.md", }); } } // NA-VERIFIER-01: verifier files present for (const rel of ["verify.py", "verification.md", "SHA256SUMS.txt"]) { if (!(await context.io.exists(path.join(exportDir, rel)))) { violations.push({ rule: "NA-VERIFIER-01", message: `${rel} not found — export must be re-generated (RFC-1120)`, file: rel, }); } } // --- Runtime files check --- let runtimeFilesAbsent = true; if (await context.io.exists(siteDir)) { async function checkRuntime(d: string, io: WorkspaceIO = createDefaultIO().io) { const entries = await io.readdir(d); for (const entry of entries) { if (isRuntimeFile(entry.name)) { runtimeFilesAbsent = false; violations.push({ rule: "runtime-file-present", message: `Runtime file '${entry.name}' found in site/`, file: path.relative(exportDir, path.join(d, entry.name)).replace(/\\/g, "/"), }); return; } if (entry.isDirectory) { await checkRuntime(path.join(d, entry.name)); } } } await checkRuntime(siteDir); } // --- Secret scan --- const secretFindings = await scanForSecrets(exportDir); const liveKeyScan = secretFindings.length === 0 ? "clean" : `${secretFindings.length} findings`; if (secretFindings.length > 0) { for (const f of secretFindings) { violations.push({ rule: "secret-detected", message: `Secret pattern '${f.pattern}' detected`, file: f.file, }); } } const hasViolations = violations.length > 0; if (!hasViolations) { logger.success(`[notausgang.validate] export package valid, no violations detected`); } else { logger.error( `[notausgang.validate] ${violations.length} violation${violations.length === 1 ? "" : "s"} detected:`, ); for (const v of violations) { logger.error(` [${v.rule}] ${v.message}${v.file ? ` (${v.file})` : ""}`); } } return { data: { path: exportDir, manifest: manifestStatus, site: siteStatus, dist: distStatus, bordbuch: bordbuchStatus, pin: pinStatus, snapshots: snapshotsStatus, artifactManifest: artifactManifestStatus, runtimeFilesAbsent, distHashMatch, siteHashMatch, bordbuchHashMatch, snapshotHashMatch, artifactHashMatch, liveKeyScan, violations, }, exitCode: hasViolations ? 1 : 0, summary: `[notausgang.validate] ${hasViolations ? "invalid" : "valid"} — ${violations.length} violation${violations.length === 1 ? "" : "s"}, ${liveKeyScan}`, nextSteps: hasViolations ? [ { action: `Fix the ${violations.length} violation${violations.length === 1 ? "" : "s"} above, then re-run: pnpm exec werkstatt run notausgang.validate --site `, kind: "required", }, ] : undefined, }; }