diff --git a/COMMANDS.md b/COMMANDS.md index 834e264..9432daf 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -276,6 +276,11 @@ fable-agent plinius godmode "improve explanation quality" - `fable5 flue` - `fable5 channels` - `--run ` +- `fable5 memory-b-cell ` + - `--cache ` +- `fable5 receipt-health` + - `--root ` + - `--window-minutes ` - `fable5 heartbeat` - `--id ` - `--name ` diff --git a/docs/biomimicry-positioning.md b/docs/biomimicry-positioning.md index 3a31e7c..282900c 100644 --- a/docs/biomimicry-positioning.md +++ b/docs/biomimicry-positioning.md @@ -27,6 +27,7 @@ The product claim stays narrow: fable-agent is not a new organism or orchestrato | Innate immune response | Fast input triage | `plinius safety`, content safety gate, adaptive reframing detection | | Pattern recognition receptors | Structural threat detection | AST capability scanner, glyph/covert-channel scanner | | Adaptive immunity | Learned risk memory | receipts, hallucination detections, RSI verification receipts | +| Memory B cell | Pattern cache for repeat encounters | `.fable/memory-b-cells.json`, `fable.memory_b_cell.v1` | | Nervous system | Runtime telemetry | channel events, typed receipts, attestations | | Motor reflex | Controlled action | guarded `8099/deploy via git-proxy` decision path | @@ -43,3 +44,13 @@ The product claim stays narrow: fable-agent is not a new organism or orchestrato Biological systems survive by sensing, verifying, remembering, and acting through constrained channels. fable-agent applies that pattern to agent work: scan, verify, attest, and only then deploy. ``` + +## Live implementation checklist + +| Biological primitive | Implementation | Status | +|---|---|---| +| Immune system | 5-layer verification pipeline | implemented | +| Memory B cell | Pattern cache: first encounter scans, repeat encounter cache hits | implemented | +| Apoptosis | Deploy gate / kill switch remains fail-closed | implemented via guarded deploy checks | +| Homeostasis | Receipt consumer freshness check | implemented via `fable5 receipt-health` | +| Nervous system | Receipt/channel/attestation event surface | implemented via `.runs//channel.jsonl` and attestations | diff --git a/docs/receipts/memory-b-cell-20260621.md b/docs/receipts/memory-b-cell-20260621.md new file mode 100644 index 0000000..3de64f1 --- /dev/null +++ b/docs/receipts/memory-b-cell-20260621.md @@ -0,0 +1,32 @@ +# Memory B Cell Pattern Cache Receipt — 2026-06-21 + +## Result + +```text +PASS memory B cell cache proof +PASS receipt consumer health proof +``` + +## Proof commands + +```bash +node dist/index.js fable5 memory-b-cell TEMP/memory-b-cell-proof/safe.ts --cache .fable/memory-b-cell-proof.json +node dist/index.js fable5 memory-b-cell TEMP/memory-b-cell-proof/safe.ts --cache .fable/memory-b-cell-proof.json +node dist/index.js fable5 memory-b-cell TEMP/memory-b-cell-proof/unsafe.ts --cache .fable/memory-b-cell-proof.json +node dist/index.js fable5 memory-b-cell TEMP/memory-b-cell-proof/unsafe.ts --cache .fable/memory-b-cell-proof.json +node dist/index.js fable5 receipt-health --root .fable --window-minutes 10080 +``` + +## Observed behavior + +| Encounter | Pattern | Verdict | Evidence | +|---|---|---|---| +| First encounter | clean TypeScript export | `known_safe` | full AST scan, `decision=allow` | +| Repeat encounter | same clean code | `known_safe` | `cache_hit` | +| First encounter | `node:child_process` / `execSync` | `known_unsafe` | full AST scan, `shell_exec=true`, `decision=review` | +| Repeat encounter | same subprocess pattern | `known_unsafe` | `cache_hit` | +| Homeostasis | `.fable` receipt freshness | `healthy` | 50 receipts, newest inside window | + +## Boundary + +Memory B cell cache accelerates repeat pattern recognition. It does **not** replace deploy proof. Guarded deploy decisions still require receipts and use `8099/deploy via git-proxy`; `8098 deploy-webhook` remains legacy/stale for fable-agent deploy decisions. diff --git a/src/fable5/attestation.ts b/src/fable5/attestation.ts index 977ed12..93ed9e8 100644 --- a/src/fable5/attestation.ts +++ b/src/fable5/attestation.ts @@ -1,8 +1,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { spawnSync } from "node:child_process"; -import { scanAstFile, type AstSafetyReport } from "../core/ast-safety.js"; +import type { AstSafetyReport } from "../core/ast-safety.js"; import { assessLegibilityRisk } from "../core/unicode-safety.js"; +import { assessMemoryBCell, type MemoryBCellResult } from "./memory-b-cell.js"; export type VerificationStatus = "verified" | "partial" | "failed" | "unverified"; export type RiskLevel = "low" | "medium" | "high"; @@ -29,6 +30,7 @@ export interface VerificationAttestation { observed_changes: string[]; checks: VerificationCheck[]; risk_findings: RiskFinding[]; + memory_b_cells: MemoryBCellResult[]; human_review: { required: boolean; reasons: string[] }; deploy_route: "8099/deploy via git-proxy"; verification_status: VerificationStatus; @@ -41,6 +43,7 @@ export interface VerifyTargetOptions { out?: string; now?: Date; requestedBy?: "human" | "agent" | "ci"; + memoryCachePath?: string; runner?: (command: string, args: string[], cwd?: string) => { status: number | null; stdout: string; stderr: string }; } @@ -49,6 +52,7 @@ export function verifyTarget(opts: VerifyTargetOptions): VerificationAttestation const runner = opts.runner ?? runCommand; const checks: VerificationCheck[] = []; const risk_findings: RiskFinding[] = []; + const memory_b_cells: MemoryBCellResult[] = []; if (!fs.existsSync(target)) throw new Error(`target does not exist: ${opts.target}`); @@ -62,10 +66,16 @@ export function verifyTarget(opts: VerifyTargetOptions): VerificationAttestation const content = fs.readFileSync(file, "utf-8"); const legibility = assessLegibilityRisk(content); if (legibility.risky) risk_findings.push({ kind: "human_legibility", severity: "high", file, detail: legibility.reason }); - if (/\.(ts|tsx|js|jsx)$/i.test(file)) addAstFindings(scanAstFile(file), risk_findings); + if (/\.(ts|tsx|js|jsx)$/i.test(file)) { + const cell = assessMemoryBCell(file, content, opts.memoryCachePath, opts.now); + memory_b_cells.push(cell); + if (cell.report) addAstFindings(cell.report, risk_findings); + if (!cell.report && cell.verdict === "known_unsafe") addMemoryFindings(cell, risk_findings); + } } checks.push({ name: "glyph_legibility", status: risk_findings.some((f) => f.kind === "human_legibility") ? "failed" : "passed" }); - checks.push({ name: "ast_capabilities", status: "passed", detail: `${risk_findings.filter((f) => ["shell_exec", "network", "fs_write", "eval_like"].includes(f.kind)).length} capability finding(s)` }); + checks.push({ name: "memory_b_cell_cache", status: "passed", detail: `${memory_b_cells.filter((c) => c.encounter === "cache_hit").length} hit(s), ${memory_b_cells.filter((c) => c.encounter === "first_encounter").length} first encounter(s)` }); + checks.push({ name: "ast_capabilities", status: "passed", detail: `${risk_findings.filter((f) => ["shell_exec", "network", "fs_write", "eval_like", "memory_b_cell"].includes(f.kind)).length} capability finding(s)` }); const pkg = findPackageJson(target); if (pkg) { @@ -90,6 +100,7 @@ export function verifyTarget(opts: VerifyTargetOptions): VerificationAttestation observed_changes: changed.files, checks, risk_findings, + memory_b_cells, human_review: { required: reviewReasons.length > 0, reasons: reviewReasons }, deploy_route: "8099/deploy via git-proxy", verification_status: failed ? "failed" : skipped ? "partial" : "verified", @@ -125,6 +136,11 @@ function addAstFindings(report: AstSafetyReport, findings: RiskFinding[]): void if (report.private_glyphs) findings.push({ kind: "private_glyphs", severity: "high", file: report.file, detail: report.legibility_reason }); } +function addMemoryFindings(cell: MemoryBCellResult, findings: RiskFinding[]): void { + const kinds = ["shell_exec", "network", "fs_write", "eval_like", "private_glyphs"].filter((k) => cell.reason.includes(k)); + for (const kind of kinds.length > 0 ? kinds : ["memory_b_cell"]) findings.push({ kind, severity: kind === "network" || kind === "fs_write" ? "medium" : "high", file: cell.file, detail: `memory B cell cache hit: ${cell.reason}` }); +} + function reviewReasonsFor(findings: RiskFinding[], changed: string[]): string[] { const reasons = findings.filter((f) => f.severity === "high").map((f) => `${f.kind}${f.file ? ` in ${path.relative(process.cwd(), f.file)}` : ""}`); if (changed.some((f) => /(^|\/)(deploy|security|src\/core|src\/fable5)\//.test(f.trim().replace(/^[A-Z? ]+\s+/, "").replace(/\\/g, "/")))) reasons.push("sensitive path changed"); diff --git a/src/fable5/index.ts b/src/fable5/index.ts index fc8943e..7d8cfdf 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -62,6 +62,12 @@ export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEven export { sendMissionHeartbeat, writeMissionHeartbeatReceipt } from "./mission-heartbeat.js"; export type { MissionHeartbeatOptions, MissionHeartbeatReceipt, MissionHeartbeatStatus } from "./mission-heartbeat.js"; +export { assessMemoryBCell, memoryBCellStats } from "./memory-b-cell.js"; +export type { MemoryBCellEntry, MemoryBCellResult, MemoryBCellStats } from "./memory-b-cell.js"; + +export { checkReceiptHealth } from "./receipt-consumer.js"; +export type { ReceiptHealth } from "./receipt-consumer.js"; + export { createDuelEvalReceipt, createEvalTraceReceipt, gradeSetRecovery, tallyDuelReceipts, writeDuelEvalReceipt, writeEvalTraceReceipt } from "./eval-trace.js"; export type { DuelEvalReceipt, DuelTally, EvalTraceCommand, EvalTraceReceipt, SetGradeReceipt } from "./eval-trace.js"; diff --git a/src/fable5/memory-b-cell.test.ts b/src/fable5/memory-b-cell.test.ts new file mode 100644 index 0000000..7272e81 --- /dev/null +++ b/src/fable5/memory-b-cell.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 { assessMemoryBCell, memoryBCellStats } from "./memory-b-cell.js"; + +function tmpRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "memory-b-cell-")); +} + +describe("memory B cell pattern cache", () => { + it("learns safe patterns and returns instant cache hits", () => { + const cache = path.join(tmpRoot(), "cache.json"); + const source = "export const add = (a: number, b: number) => a + b;\n"; + + const first = assessMemoryBCell("safe.ts", source, cache, new Date("2026-06-21T00:00:00.000Z")); + const second = assessMemoryBCell("safe.ts", source, cache, new Date("2026-06-21T00:01:00.000Z")); + + expect(first.encounter).toBe("first_encounter"); + expect(first.verdict).toBe("known_safe"); + expect(second.encounter).toBe("cache_hit"); + expect(second.verdict).toBe("known_safe"); + expect(memoryBCellStats(cache)).toMatchObject({ total: 1, known_safe: 1, known_unsafe: 0 }); + }); + + it("learns unsafe subprocess patterns", () => { + const cache = path.join(tmpRoot(), "cache.json"); + const source = "import { execSync } from 'node:child_process'; execSync('echo hi');\n"; + + const first = assessMemoryBCell("unsafe.ts", source, cache); + const second = assessMemoryBCell("unsafe.ts", source, cache); + + expect(first.verdict).toBe("known_unsafe"); + expect(first.reason).toContain("shell_exec"); + expect(second.encounter).toBe("cache_hit"); + expect(memoryBCellStats(cache)).toMatchObject({ total: 1, known_safe: 0, known_unsafe: 1 }); + }); +}); diff --git a/src/fable5/memory-b-cell.ts b/src/fable5/memory-b-cell.ts new file mode 100644 index 0000000..48e0259 --- /dev/null +++ b/src/fable5/memory-b-cell.ts @@ -0,0 +1,94 @@ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { scanAstSource, type AstSafetyReport } from "../core/ast-safety.js"; + +export type MemoryBCellEncounter = "first_encounter" | "cache_hit"; +export type MemoryBCellVerdict = "known_safe" | "known_unsafe"; + +export interface MemoryBCellEntry { + key: string; + verdict: MemoryBCellVerdict; + reason: string; + seen: number; + updatedAt: string; +} + +export interface MemoryBCellResult { + schema: "fable.memory_b_cell.v1"; + file: string; + key: string; + encounter: MemoryBCellEncounter; + verdict: MemoryBCellVerdict; + reason: string; + report?: AstSafetyReport; +} + +export interface MemoryBCellStats { + schema: "fable.memory_b_cell.stats.v1"; + total: number; + known_safe: number; + known_unsafe: number; +} + +interface CacheFile { entries: Record } + +export const DEFAULT_MEMORY_B_CELL_CACHE = path.join(".fable", "memory-b-cells.json"); + +export function assessMemoryBCell(file: string, source: string, cachePath = DEFAULT_MEMORY_B_CELL_CACHE, now = new Date()): MemoryBCellResult { + const key = patternKey(source); + const cache = readCache(cachePath); + const hit = cache.entries[key]; + if (hit) { + hit.seen += 1; + hit.updatedAt = now.toISOString(); + writeCache(cachePath, cache); + return { schema: "fable.memory_b_cell.v1", file, key, encounter: "cache_hit", verdict: hit.verdict, reason: hit.reason }; + } + + const report = scanAstSource(file, source); + const verdict: MemoryBCellVerdict = report.decision === "allow" ? "known_safe" : "known_unsafe"; + const reason = report.decision === "allow" ? "clean structural scan" : riskyReason(report); + cache.entries[key] = { key, verdict, reason, seen: 1, updatedAt: now.toISOString() }; + writeCache(cachePath, cache); + return { schema: "fable.memory_b_cell.v1", file, key, encounter: "first_encounter", verdict, reason, report }; +} + +export function memoryBCellStats(cachePath = DEFAULT_MEMORY_B_CELL_CACHE): MemoryBCellStats { + const entries = Object.values(readCache(cachePath).entries); + return { + schema: "fable.memory_b_cell.stats.v1", + total: entries.length, + known_safe: entries.filter((e) => e.verdict === "known_safe").length, + known_unsafe: entries.filter((e) => e.verdict === "known_unsafe").length, + }; +} + +function patternKey(source: string): string { + // ponytail: content hash cache; upgrade to normalized AST fingerprints after real false misses. + return crypto.createHash("sha256").update(source).digest("hex"); +} + +function riskyReason(report: AstSafetyReport): string { + const risks = [ + report.shell_exec && "shell_exec", + report.network && "network", + report.fs_write && "fs_write", + report.eval_like && "eval_like", + report.private_glyphs && "private_glyphs", + ].filter(Boolean); + return risks.length > 0 ? `risky capabilities: ${risks.join(", ")}` : "structural review required"; +} + +function readCache(cachePath: string): CacheFile { + try { + return JSON.parse(fs.readFileSync(cachePath, "utf-8")) as CacheFile; + } catch { + return { entries: {} }; + } +} + +function writeCache(cachePath: string, cache: CacheFile): void { + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`); +} diff --git a/src/fable5/receipt-consumer.test.ts b/src/fable5/receipt-consumer.test.ts new file mode 100644 index 0000000..521aedb --- /dev/null +++ b/src/fable5/receipt-consumer.test.ts @@ -0,0 +1,23 @@ +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 { checkReceiptHealth } from "./receipt-consumer.js"; + +describe("receipt consumer health", () => { + it("is healthy when a receipt landed inside the window", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "receipt-health-")); + const file = path.join(dir, "latest.json"); + fs.writeFileSync(file, "{}\n"); + const now = new Date(); + fs.utimesSync(file, now, now); + + expect(checkReceiptHealth(dir, 15, now).status).toBe("healthy"); + }); + + it("degrades when there are no recent receipts", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "receipt-health-")); + + expect(checkReceiptHealth(dir, 15, new Date("2026-06-21T00:00:00.000Z"))).toMatchObject({ status: "degraded", receipts: 0 }); + }); +}); diff --git a/src/fable5/receipt-consumer.ts b/src/fable5/receipt-consumer.ts new file mode 100644 index 0000000..716a8a9 --- /dev/null +++ b/src/fable5/receipt-consumer.ts @@ -0,0 +1,35 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +export interface ReceiptHealth { + schema: "fable.receipt_health.v1"; + status: "healthy" | "degraded"; + checkedAt: string; + windowMinutes: number; + receipts: number; + newest?: string; + reason?: string; +} + +export function checkReceiptHealth(root = ".fable", windowMinutes = 15, now = new Date()): ReceiptHealth { + const files = fs.existsSync(root) ? walk(root).filter((f) => f.endsWith(".json") || f.endsWith(".jsonl")) : []; + const newestMs = files.map((f) => fs.statSync(f).mtimeMs).sort((a, b) => b - a)[0]; + const newest = newestMs ? new Date(newestMs).toISOString() : undefined; + const healthy = newestMs !== undefined && now.getTime() - newestMs <= windowMinutes * 60_000; + return { + schema: "fable.receipt_health.v1", + status: healthy ? "healthy" : "degraded", + checkedAt: now.toISOString(), + windowMinutes, + receipts: files.length, + newest, + reason: healthy ? undefined : `no receipt newer than ${windowMinutes} minute(s)`, + }; +} + +function walk(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + return entry.isDirectory() ? walk(full) : [full]; + }); +} diff --git a/src/index.ts b/src/index.ts index ae296ca..879856b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1832,6 +1832,30 @@ fable console.log(``); }); +fable + .command("memory-b-cell ") + .description("Assess a file through the memory B cell pattern cache") + .option("--cache ", "Cache path", path.join(".fable", "memory-b-cells.json")) + .action(async (file: string, opts: { cache?: string }) => { + const { assessMemoryBCell } = await import("./fable5/memory-b-cell.js"); + const full = path.resolve(file); + const result = assessMemoryBCell(full, fs.readFileSync(full, "utf-8"), opts.cache); + console.log(JSON.stringify(result, null, 2)); + if (result.verdict === "known_unsafe") process.exit(1); + }); + +fable + .command("receipt-health") + .description("Check receipt consumer health over a recent time window") + .option("--root ", "Receipt root", ".fable") + .option("--window-minutes ", "Freshness window", (v) => Number(v), 15) + .action(async (opts: { root?: string; windowMinutes?: number }) => { + const { checkReceiptHealth } = await import("./fable5/receipt-consumer.js"); + const health = checkReceiptHealth(opts.root ?? ".fable", opts.windowMinutes ?? 15); + console.log(JSON.stringify(health, null, 2)); + if (health.status !== "healthy") process.exit(1); + }); + fable .command("heartbeat") .description("Write a mission-control heartbeat locally and optionally POST it remotely")