diff --git a/COMMANDS.md b/COMMANDS.md index 99facc6..8401034 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -391,6 +391,8 @@ fable-agent plinius godmode "improve explanation quality" - `fable5 permission-posture` - `--mode ` - `--output ` +- `fable5 stale-memory-quarantine` + - `--output ` - `fable5 receipt-health` - `--root ` - `--window-minutes ` diff --git a/src/fable5/index.ts b/src/fable5/index.ts index c6e2a69..cb2e1a1 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -66,6 +66,9 @@ export type { ExternalToolAuditReceipt, ExternalToolAuditRow, ExternalToolAuditS export { createOpenCovenReviewReceipt, createPermissionPostureReceipt, writeExternalProjectReviewReceipt } from "./external-project-review.js"; export type { ExternalProjectDecision, ExternalProjectReviewReceipt, PermissionMode, PermissionPostureReceipt } from "./external-project-review.js"; +export { createFactoryMemoryQuarantineReceipt, createStaleMemoryQuarantineReceipt, writeStaleMemoryQuarantineReceipt } from "./stale-memory-quarantine.js"; +export type { StaleMemoryClaim, StaleMemoryQuarantineReceipt, StaleMemorySeverity } from "./stale-memory-quarantine.js"; + export { channelPath, emitChannelEvent, readChannelEvents } from "./channel.js"; export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEvent } from "./channel.js"; diff --git a/src/fable5/stale-memory-quarantine.test.ts b/src/fable5/stale-memory-quarantine.test.ts new file mode 100644 index 0000000..c06cd81 --- /dev/null +++ b/src/fable5/stale-memory-quarantine.test.ts @@ -0,0 +1,38 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createFactoryMemoryQuarantineReceipt, createStaleMemoryQuarantineReceipt, writeStaleMemoryQuarantineReceipt } from "./stale-memory-quarantine.js"; + +describe("stale memory quarantine receipts", () => { + it("quarantines dangerous factory memory without deploy authority", () => { + const receipt = createFactoryMemoryQuarantineReceipt(new Date("2026-07-01T00:00:00.000Z")); + + expect(receipt).toMatchObject({ + schema: "fable.stale_memory.quarantine.v1", + source: "conversation memory summary", + decision: "quarantined", + deployAttempted: false, + }); + expect(receipt.claims.map((claim) => claim.claim)).toContain("8098/deploy-webhook is trusted deploy path"); + expect(receipt.claims.some((claim) => claim.severity === "dangerous")).toBe(true); + expect(receipt.blockedTrustPaths).toContain("8098/deploy-webhook"); + expect(receipt.canonicalTrustPaths).toContain("8099/deploy via git-proxy"); + }); + + it("marks empty claim sets ready", () => { + const receipt = createStaleMemoryQuarantineReceipt({ source: "none", claims: [] }); + + expect(receipt).toMatchObject({ decision: "ready", deployAttempted: false }); + }); + + it("writes quarantine receipts", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "stale-memory-quarantine-")); + const file = path.join(dir, "receipt.json"); + + writeStaleMemoryQuarantineReceipt(file, createFactoryMemoryQuarantineReceipt()); + + expect(JSON.parse(fs.readFileSync(file, "utf-8"))).toMatchObject({ schema: "fable.stale_memory.quarantine.v1" }); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/src/fable5/stale-memory-quarantine.ts b/src/fable5/stale-memory-quarantine.ts new file mode 100644 index 0000000..21c5931 --- /dev/null +++ b/src/fable5/stale-memory-quarantine.ts @@ -0,0 +1,53 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +export type StaleMemorySeverity = "advisory" | "dangerous"; + +export interface StaleMemoryClaim { + claim: string; + staleBecause: string; + replacementTruth: string; + severity: StaleMemorySeverity; +} + +export interface StaleMemoryQuarantineReceipt { + schema: "fable.stale_memory.quarantine.v1"; + createdAt: string; + source: string; + claims: StaleMemoryClaim[]; + blockedTrustPaths: string[]; + canonicalTrustPaths: string[]; + decision: "quarantined" | "ready"; + deployAttempted: false; +} + +export function createStaleMemoryQuarantineReceipt(opts: { source: string; claims: StaleMemoryClaim[]; now?: Date }): StaleMemoryQuarantineReceipt { + return { + schema: "fable.stale_memory.quarantine.v1", + createdAt: (opts.now ?? new Date()).toISOString(), + source: opts.source, + claims: opts.claims, + blockedTrustPaths: ["8098/deploy-webhook", "memory-only deploy authority", "autonomous deploy without fresh gate receipt"], + canonicalTrustPaths: ["live receipt > committed receipt > memory", "8099/deploy via git-proxy", "explicit approval + fresh valid ZTE/factory gate receipt"], + decision: opts.claims.length > 0 ? "quarantined" : "ready", + deployAttempted: false, + }; +} + +export function createFactoryMemoryQuarantineReceipt(now = new Date()): StaleMemoryQuarantineReceipt { + return createStaleMemoryQuarantineReceipt({ + now, + source: "conversation memory summary", + claims: [ + { claim: "25 containers", staleBecause: "fresh factory receipts verified 18 containers", replacementTruth: "container count must come from live factory reconciliation receipt", severity: "advisory" }, + { claim: "8098/deploy-webhook is trusted deploy path", staleBecause: "current trust boundary marks 8098 legacy/stale only", replacementTruth: "8099/deploy via git-proxy is the canonical gated deploy route", severity: "dangerous" }, + { claim: "agents can deploy without human intervention", staleBecause: "current policy requires explicit approval and fresh gate receipts", replacementTruth: "agents draft, receipts prove, humans approve, guarded 8099/deploy ships", severity: "dangerous" }, + ], + }); +} + +export function writeStaleMemoryQuarantineReceipt(file: string, receipt: StaleMemoryQuarantineReceipt): string { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`); + return file; +} diff --git a/src/index.ts b/src/index.ts index 5e3c714..c92ac4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2304,6 +2304,21 @@ fable if (receipt.decision === "blocked") process.exit(1); }); +fable + .command("stale-memory-quarantine") + .description("Quarantine stale factory memory so it cannot become deploy authority") + .option("--output ", "Receipt output path", path.join(".fable", "memory", "stale-memory-quarantine-live.json")) + .action(async (opts: { output?: string }) => { + const { createFactoryMemoryQuarantineReceipt, writeStaleMemoryQuarantineReceipt } = await import("./fable5/stale-memory-quarantine.js"); + const receipt = createFactoryMemoryQuarantineReceipt(); + const out = opts.output ?? path.join(".fable", "memory", "stale-memory-quarantine-live.json"); + writeStaleMemoryQuarantineReceipt(out, receipt); + console.log(JSON.stringify(receipt, null, 2)); + console.log(` + Receipt: ${out} +`); + }); + fable .command("receipt-health") .description("Check receipt consumer health over a recent time window")