fix: Add production mutation approval gate
This commit is contained in:
parent
08ea92b072
commit
6a33357df5
3 changed files with 369 additions and 12 deletions
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
199
src/resources/extensions/sf/production-mutation-approval.ts
Normal file
199
src/resources/extensions/sf/production-mutation-approval.ts
Normal 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}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue