/* RFC-0563: gitmesh.verify command handler. Verifies commit signatures in the local clone against the operator's public key from werkstatt.identity.json. Implements incremental verification — only new commits since the last verification are checked. Does not abort on first invalid signature; reports all invalid signatures in one pass. Do not implement signature creation — that is RFC-0560. Do not implement key rotation — that is a future RFC-0558 extension. Do not perform network I/O — this is a local-only verification. Verification checks commit signatures against the local clone — no remote calls. RFC-0563: initial implementation — gitmesh.verify handler with incremental verification. RFC-0563 fix: add diagnostics arrays (RFC-0086), document deferred RFC-0560 key comparison dependency. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared */ import { join } from "node:path"; import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-shared/kernel"; import type { GitMeshVerifyResult } from "./types.ts"; import { loadGitMeshConfig } from "./config.ts"; import { gitLogSignatureStatus, gitRevParseHead } from "./git-ops.ts"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; const LAST_VERIFIED_FILE = ".git/gitmesh.last-verified"; const IDENTITY_FILENAME = "werkstatt.identity.json"; interface IdentityFile { publicKey?: string; publicKeys?: string[]; } async function loadIdentityPublicKeys( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { // RFC-0560 dependency: git's %G? signature status checks git's keyring, not the // operator's specific public key. The publicKeys loaded here will be used to // compare against signing keys once RFC-0560's trailer format is implemented. // Until then, gitmesh.verify relies on git's GPG/SSH signature verification. const identityPath = join(workspaceRoot, IDENTITY_FILENAME); const raw = await io.readFile(identityPath); const identity: IdentityFile = JSON.parse(raw); const keys: string[] = []; if (typeof identity.publicKey === "string") { keys.push(identity.publicKey); } if (Array.isArray(identity.publicKeys)) { keys.push(...identity.publicKeys.filter((k): k is string => typeof k === "string")); } return keys; } export async function runGitMeshVerify( _input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot } = context; // Load config try { await loadGitMeshConfig(workspaceRoot); } catch { return { data: { totalCommits: 0, signedCommits: 0, unsignedCommits: 0, invalidSignatures: 0, verified: false, diagnostics: ["gitmesh.verify: no werkstatt.gitmesh.json found — run gitmesh.sync first"], }, exitCode: 1, summary: "[gitmesh.verify] no werkstatt.gitmesh.json found — run gitmesh.sync first", nextSteps: [ { action: "Run gitmesh.sync first to configure gitmesh, then re-run gitmesh.verify", kind: "required", }, ], }; } // Load identity let publicKeys: string[]; try { publicKeys = await loadIdentityPublicKeys(workspaceRoot); } catch { return { data: { totalCommits: 0, signedCommits: 0, unsignedCommits: 0, invalidSignatures: 0, verified: false, diagnostics: [ `gitmesh.verify: no ${IDENTITY_FILENAME} found — cannot verify signatures without operator public key`, ], }, exitCode: 1, summary: `[gitmesh.verify] no ${IDENTITY_FILENAME} found — cannot verify signatures without operator public key`, nextSteps: [ { action: "Run identity.bootstrap first (RFC-0558), then re-run gitmesh.verify", kind: "required", }, ], }; } if (publicKeys.length === 0) { return { data: { totalCommits: 0, signedCommits: 0, unsignedCommits: 0, invalidSignatures: 0, verified: false, diagnostics: [`gitmesh.verify: ${IDENTITY_FILENAME} contains no public keys`], }, exitCode: 1, summary: `[gitmesh.verify] ${IDENTITY_FILENAME} contains no public keys`, nextSteps: [ { action: `Add public keys to ${IDENTITY_FILENAME}, then re-run gitmesh.verify`, kind: "required", }, ], }; } const headSha = await gitRevParseHead(workspaceRoot, context.io).catch(() => ""); if (!headSha) { return { data: { totalCommits: 0, signedCommits: 0, unsignedCommits: 0, invalidSignatures: 0, verified: false, diagnostics: ["gitmesh.verify: cannot resolve HEAD — not a git repository?"], }, exitCode: 1, summary: "[gitmesh.verify] cannot resolve HEAD — not a git repository?", nextSteps: [ { action: "Ensure the workspace is a git repository, then re-run gitmesh.verify", kind: "required", }, ], }; } // Determine range (incremental verification) let range: string; let lastVerifiedSha = ""; try { lastVerifiedSha = (await context.io.readFile(join(workspaceRoot, LAST_VERIFIED_FILE))).trim(); range = `${lastVerifiedSha}..${headSha}`; } catch { // Full verification — all commits range = "--all"; } const commits = await gitLogSignatureStatus(range, workspaceRoot, context.io); let signedCommits = 0; let unsignedCommits = 0; let invalidSignatures = 0; for (const commit of commits) { const status = commit.signatureStatus; if (status === "G") { signedCommits++; } else if (status === "U" || status === "N") { unsignedCommits++; } else { // B (bad), X (expired), Y (key missing), E (expired key), R (revoked) invalidSignatures++; } } const totalCommits = commits.length; const verified = unsignedCommits === 0 && invalidSignatures === 0; // Write last-verified SHA await context.io.writeFile(join(workspaceRoot, LAST_VERIFIED_FILE), headSha); const diagnostics: string[] = []; if (unsignedCommits > 0) { diagnostics.push(`gitmesh.verify: ${unsignedCommits} unsigned commit(s) found`); } if (invalidSignatures > 0) { diagnostics.push(`gitmesh.verify: ${invalidSignatures} invalid signature(s) found`); } if (range === "--all") { diagnostics.push( "gitmesh.verify: full verification (first run) — may be slow on large repositories", ); } return { data: { totalCommits, signedCommits, unsignedCommits, invalidSignatures, verified, diagnostics: diagnostics.length > 0 ? diagnostics : undefined, }, exitCode: verified ? 0 : 1, summary: `[gitmesh.verify] ${totalCommits} commit(s) checked, ${signedCommits} signed, ${unsignedCommits} unsigned, ${invalidSignatures} invalid` + (lastVerifiedSha ? ` (incremental from ${lastVerifiedSha.slice(0, 8)})` : " (full verification)"), nextSteps: verified ? undefined : [ { action: "Review the unsigned/invalid commits listed above, then re-run gitmesh.verify", kind: "required", }, ], }; }