fix: Add production mutation approval gate

This commit is contained in:
Mikael Hugo 2026-04-30 12:17:35 +02:00
parent 08ea92b072
commit 6a33357df5
3 changed files with 369 additions and 12 deletions

View file

@ -45,6 +45,10 @@ import { MergeConflictError } from "../git-service.js";
import { recordLearnedOutcome } from "../learning/runtime.js";
import { sfRoot } from "../paths.js";
import { resolvePersistModelChanges } from "../preferences.js";
import {
ensureProductionMutationApprovalTemplate,
readProductionMutationApprovalStatus,
} from "../production-mutation-approval.js";
import { resetEvidence } from "../safety/evidence-collector.js";
import {
cleanupCheckpoint,
@ -1357,19 +1361,46 @@ export async function runGuards(
...task.expected_output,
].join("\n");
if (requiresHumanProductionMutationApproval(taskText)) {
const msg =
`Production mutation guard: ${activeMilestone.id}/${activeSlice.id}/${activeTask.id} asks to POST unified failover against production. ` +
"Pause for an explicit safe server/VM target, cleanup/rollback path, and operator approval.";
ctx.ui.notify(msg, "error");
deps.sendDesktopNotification(
"SF",
"Production mutation guard paused auto-mode",
"warning",
"safety",
basename(s.originalBasePath || s.basePath),
const approvalUnit = {
milestoneId: activeMilestone.id,
sliceId: activeSlice.id,
taskId: activeTask.id,
taskTitle: task.title,
taskText,
};
const approvalBasePath = s.originalBasePath || s.basePath;
const approval = readProductionMutationApprovalStatus(
approvalBasePath,
approvalUnit,
);
await deps.pauseAuto(ctx, pi);
return { action: "break", reason: "production-mutation-guard" };
if (approval.approved) {
ctx.ui.notify(
`Production mutation approval accepted for ${approvalUnit.milestoneId}/${approvalUnit.sliceId}/${approvalUnit.taskId}: ${approval.path}`,
"warning",
);
} else {
const template = ensureProductionMutationApprovalTemplate(
approvalBasePath,
approvalUnit,
);
const reasons = approval.reasons.length
? ` Missing/invalid fields: ${approval.reasons.join("; ")}.`
: "";
const msg =
`Production mutation guard: ${activeMilestone.id}/${activeSlice.id}/${activeTask.id} asks to POST unified failover against production. ` +
`${template.created ? "Created" : "Reusing"} approval gate at ${template.path}. ` +
`Fill it with an explicit safe server/VM target, cleanup/rollback path, and operator approval, then rerun sf headless auto.${reasons}`;
ctx.ui.notify(msg, "error");
deps.sendDesktopNotification(
"SF",
"Production mutation guard paused auto-mode",
"warning",
"safety",
basename(s.originalBasePath || s.basePath),
);
await deps.pauseAuto(ctx, pi);
return { action: "break", reason: "production-mutation-guard" };
}
}
}
}

View file

@ -0,0 +1,199 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { atomicWriteSync } from "./atomic-write.js";
import { sfRoot } from "./paths.js";
export const PRODUCTION_MUTATION_APPROVAL_SCHEMA_VERSION = 1;
export interface ProductionMutationUnit {
milestoneId: string;
sliceId: string;
taskId: string;
taskTitle: string;
taskText: string;
}
export interface ProductionMutationApproval {
schemaVersion: typeof PRODUCTION_MUTATION_APPROVAL_SCHEMA_VERSION;
unitId: string;
status: "pending" | "approved";
risk: "production-unified-failover-post";
task: {
title: string;
text: string;
};
approval: {
approved: boolean;
approvedBy: string;
approvedAt: string;
safeServerId: string;
safeVmNames: string[];
cleanupPlan: string;
rollbackPlan: string;
notes: string;
};
instructions: string[];
}
export interface ProductionMutationApprovalStatus {
path: string;
approved: boolean;
reasons: string[];
}
function unitId(unit: ProductionMutationUnit): string {
return `${unit.milestoneId}/${unit.sliceId}/${unit.taskId}`;
}
function safePathSegment(value: string): string {
return value.replace(/[^A-Za-z0-9_.-]/g, "_");
}
export function productionMutationApprovalPath(
basePath: string,
unit: Pick<ProductionMutationUnit, "milestoneId" | "sliceId" | "taskId">,
): string {
const fileName = [
safePathSegment(unit.milestoneId),
safePathSegment(unit.sliceId),
safePathSegment(unit.taskId),
].join("-");
return join(
sfRoot(basePath),
"approvals",
"production-mutations",
`${fileName}.json`,
);
}
export function buildProductionMutationApprovalTemplate(
unit: ProductionMutationUnit,
): ProductionMutationApproval {
return {
schemaVersion: PRODUCTION_MUTATION_APPROVAL_SCHEMA_VERSION,
unitId: unitId(unit),
status: "pending",
risk: "production-unified-failover-post",
task: {
title: unit.taskTitle,
text: unit.taskText,
},
approval: {
approved: false,
approvedBy: "",
approvedAt: "",
safeServerId: "",
safeVmNames: [],
cleanupPlan: "",
rollbackPlan: "",
notes: "",
},
instructions: [
"Set status to approved and approval.approved to true only after selecting a safe non-customer-impacting target.",
"Fill approvedBy, approvedAt, safeServerId, safeVmNames, cleanupPlan, and rollbackPlan.",
"Then rerun sf headless auto.",
],
};
}
export function ensureProductionMutationApprovalTemplate(
basePath: string,
unit: ProductionMutationUnit,
): { path: string; created: boolean } {
const path = productionMutationApprovalPath(basePath, unit);
if (existsSync(path)) return { path, created: false };
atomicWriteSync(
path,
JSON.stringify(buildProductionMutationApprovalTemplate(unit), null, 2) +
"\n",
);
return { path, created: true };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function nonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function nonEmptyStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every((item) => nonEmptyString(item))
);
}
export function validateProductionMutationApproval(
data: unknown,
unit: ProductionMutationUnit,
): { approved: boolean; reasons: string[] } {
const reasons: string[] = [];
if (!isRecord(data)) {
return { approved: false, reasons: ["approval file is not a JSON object"] };
}
if (data.schemaVersion !== PRODUCTION_MUTATION_APPROVAL_SCHEMA_VERSION) {
reasons.push(
`schemaVersion must be ${PRODUCTION_MUTATION_APPROVAL_SCHEMA_VERSION}`,
);
}
if (data.unitId !== unitId(unit)) {
reasons.push(`unitId must be ${unitId(unit)}`);
}
if (data.status !== "approved") {
reasons.push("status must be approved");
}
if (data.risk !== "production-unified-failover-post") {
reasons.push("risk must be production-unified-failover-post");
}
const approval = data.approval;
if (!isRecord(approval)) {
reasons.push("approval must be an object");
return { approved: false, reasons };
}
if (approval.approved !== true) {
reasons.push("approval.approved must be true");
}
for (const field of [
"approvedBy",
"approvedAt",
"safeServerId",
"cleanupPlan",
"rollbackPlan",
] as const) {
if (!nonEmptyString(approval[field])) {
reasons.push(`approval.${field} is required`);
}
}
if (!nonEmptyStringArray(approval.safeVmNames)) {
reasons.push("approval.safeVmNames must contain at least one VM name");
}
return { approved: reasons.length === 0, reasons };
}
export function readProductionMutationApprovalStatus(
basePath: string,
unit: ProductionMutationUnit,
): ProductionMutationApprovalStatus {
const path = productionMutationApprovalPath(basePath, unit);
if (!existsSync(path)) {
return { path, approved: false, reasons: ["approval file is missing"] };
}
try {
const data = JSON.parse(readFileSync(path, "utf-8")) as unknown;
const result = validateProductionMutationApproval(data, unit);
return { path, approved: result.approved, reasons: result.reasons };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
path,
approved: false,
reasons: [`approval file is not valid JSON: ${message}`],
};
}
}

View file

@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
ensureProductionMutationApprovalTemplate,
type ProductionMutationApproval,
type ProductionMutationUnit,
productionMutationApprovalPath,
readProductionMutationApprovalStatus,
validateProductionMutationApproval,
} from "../production-mutation-approval.ts";
function tempBase(): string {
return mkdtempSync(join(tmpdir(), "sf-production-approval-"));
}
const unit: ProductionMutationUnit = {
milestoneId: "M004",
sliceId: "S03",
taskId: "T01",
taskTitle: "End-to-End R005 Smoke Test",
taskText: [
"Execute smoke test against production NixOS Hetzner infrastructure.",
"POST to /action/unified-failover with test server_id and vm_names.",
"Verify command row was created with status=pending.",
].join("\n"),
};
test("production mutation approval writes a pending template once", () => {
const basePath = tempBase();
try {
const first = ensureProductionMutationApprovalTemplate(basePath, unit);
assert.equal(first.created, true);
assert.equal(existsSync(first.path), true);
assert.equal(first.path, productionMutationApprovalPath(basePath, unit));
const parsed = JSON.parse(
readFileSync(first.path, "utf-8"),
) as ProductionMutationApproval;
assert.equal(parsed.schemaVersion, 1);
assert.equal(parsed.unitId, "M004/S03/T01");
assert.equal(parsed.status, "pending");
assert.equal(parsed.approval.approved, false);
assert.deepEqual(parsed.approval.safeVmNames, []);
const second = ensureProductionMutationApprovalTemplate(basePath, unit);
assert.equal(second.created, false);
assert.equal(second.path, first.path);
} finally {
rmSync(basePath, { recursive: true, force: true });
}
});
test("production mutation approval rejects incomplete approval files", () => {
const result = validateProductionMutationApproval(
{
schemaVersion: 1,
unitId: "M004/S03/T01",
status: "pending",
risk: "production-unified-failover-post",
approval: {
approved: true,
approvedBy: "",
approvedAt: "",
safeServerId: "",
safeVmNames: [],
cleanupPlan: "",
rollbackPlan: "",
},
},
unit,
);
assert.equal(result.approved, false);
assert.ok(result.reasons.includes("status must be approved"));
assert.ok(result.reasons.includes("approval.approvedBy is required"));
assert.ok(
result.reasons.includes(
"approval.safeVmNames must contain at least one VM name",
),
);
});
test("production mutation approval accepts complete explicit approval", () => {
const basePath = tempBase();
try {
const path = productionMutationApprovalPath(basePath, unit);
const approval: ProductionMutationApproval = {
schemaVersion: 1,
unitId: "M004/S03/T01",
status: "approved",
risk: "production-unified-failover-post",
task: {
title: unit.taskTitle,
text: unit.taskText,
},
approval: {
approved: true,
approvedBy: "operator@example.com",
approvedAt: "2026-04-30T10:00:00.000Z",
safeServerId: "test-server-id",
safeVmNames: ["pilot-smoke-vm"],
cleanupPlan: "Delete the test pending command after verification.",
rollbackPlan: "Abort the test failover and restore the previous route.",
notes: "Smoke target is non-customer-impacting.",
},
instructions: [],
};
ensureProductionMutationApprovalTemplate(basePath, unit);
writeFileSync(path, JSON.stringify(approval, null, 2) + "\n", "utf-8");
const status = readProductionMutationApprovalStatus(basePath, unit);
assert.equal(status.approved, true);
assert.deepEqual(status.reasons, []);
} finally {
rmSync(basePath, { recursive: true, force: true });
}
});