/*
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",
},
],
};
}