diff --git a/COMMANDS.md b/COMMANDS.md index 6bd891f..4d098c5 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -328,6 +328,8 @@ fable-agent plinius godmode "improve explanation quality" - `--run ` - `fable5 memory-b-cell ` - `--cache ` +- `fable5 prompt-fingerprint ` + - `--output ` - `fable5 skill-health` - `--input ` - `--skill ` diff --git a/src/fable5/index.ts b/src/fable5/index.ts index 48857c3..93235fb 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -96,6 +96,9 @@ export type { ModelAccessProbe, ModelAccessReceipt, ModelAccessReceiptOptions, M export { createSkillHealthReceipt, writeSkillHealthReceipt } from "./skill-health.js"; export type { SkillHealthEntry, SkillHealthOptions, SkillHealthReceipt } from "./skill-health.js"; +export { createPromptFingerprintReceipt, writePromptFingerprintReceipt } from "./prompt-fingerprint.js"; +export type { PromptFingerprintFinding, PromptFingerprintReceipt } from "./prompt-fingerprint.js"; + export { createPlanReceipt, writePlanReceipt } from "./plan-receipt.js"; export type { PlanAllowedOutcome, PlanPhase, PlanReceipt, PlanReceiptOptions, PlanTaskType } from "./plan-receipt.js"; diff --git a/src/fable5/prompt-fingerprint.test.ts b/src/fable5/prompt-fingerprint.test.ts new file mode 100644 index 0000000..46659d3 --- /dev/null +++ b/src/fable5/prompt-fingerprint.test.ts @@ -0,0 +1,46 @@ +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 { createPromptFingerprintReceipt, writePromptFingerprintReceipt } from "./prompt-fingerprint.js"; + +describe("prompt fingerprint receipt", () => { + it("blocks hidden unicode prompt carriers", () => { + const receipt = createPromptFingerprintReceipt("prompt", `safe${String.fromCodePoint(0xe0069)}text`); + + expect(receipt).toMatchObject({ decision: "blocked", deployAttempted: false }); + expect(receipt.findings[0]).toMatchObject({ kind: "hidden-unicode", severity: "block" }); + }); + + it("blocks date-line covert marker variants", () => { + const receipt = createPromptFingerprintReceipt("prompt", "Today’s date is 2026/06/30"); + + expect(receipt).toMatchObject({ decision: "blocked" }); + expect(receipt.findings[0]).toMatchObject({ kind: "date-line-marker", severity: "block" }); + }); + + it("reviews telemetry endpoints and opt-out gaps without claiming compromise", () => { + const receipt = createPromptFingerprintReceipt( + "bundle", + "DISABLE_TELEMETRY does not guard Datadog; https://http-intake.logs.us5.datadoghq.com/api/v2/logs", + ); + + expect(receipt.decision).toBe("review"); + expect(receipt.findings.map((finding) => finding.kind)).toContain("telemetry-endpoint"); + expect(receipt.findings.map((finding) => finding.kind)).toContain("telemetry-optout-gap"); + }); + + it("allows ordinary prompts", () => { + expect(createPromptFingerprintReceipt("prompt", "Today's date is 2026-06-30")).toMatchObject({ decision: "clean", findings: [] }); + }); + + it("writes JSON receipts", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "prompt-fingerprint-")); + const file = path.join(dir, "receipt.json"); + + writePromptFingerprintReceipt(file, createPromptFingerprintReceipt("prompt", "clean")); + + expect(JSON.parse(fs.readFileSync(file, "utf-8"))).toMatchObject({ schema: "fable.prompt_fingerprint.receipt.v1" }); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/src/fable5/prompt-fingerprint.ts b/src/fable5/prompt-fingerprint.ts new file mode 100644 index 0000000..b25d7ed --- /dev/null +++ b/src/fable5/prompt-fingerprint.ts @@ -0,0 +1,64 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { findHiddenUnicode, hasHiddenUnicode } from "../core/unicode-safety.js"; + +const DATE_LINE = /Today([\u0027\u2019\u02BC\u02B9])s date is\s+(\d{4})([-/])(\d{2})\3(\d{2})/u; +const TELEMETRY_ENDPOINTS = [ + "https://api.anthropic.com/api/event_logging/v2/batch", + "https://http-intake.logs.us5.datadoghq.com/api/v2/logs", + "https://browser-intake-us5-datadoghq.com/api/v2/logs", +]; + +export interface PromptFingerprintFinding { + kind: "hidden-unicode" | "date-line-marker" | "telemetry-endpoint" | "telemetry-optout-gap"; + severity: "info" | "review" | "block"; + evidence: string; +} + +export interface PromptFingerprintReceipt { + schema: "fable.prompt_fingerprint.receipt.v1"; + createdAt: string; + source: string; + findings: PromptFingerprintFinding[]; + decision: "clean" | "review" | "blocked"; + reasons: string[]; + deployAttempted: false; +} + +export function createPromptFingerprintReceipt(source: string, text: string, now = new Date()): PromptFingerprintReceipt { + const findings: PromptFingerprintFinding[] = []; + if (hasHiddenUnicode(text)) { + findings.push({ kind: "hidden-unicode", severity: "block", evidence: findHiddenUnicode(text).slice(0, 10).map((hit) => `${hit.codePoint}@${hit.index}`).join(", ") }); + } + + const date = DATE_LINE.exec(text); + if (date && (date[1] !== "'" || date[3] !== "-")) { + findings.push({ kind: "date-line-marker", severity: "block", evidence: date[0] }); + } + + for (const endpoint of TELEMETRY_ENDPOINTS) { + if (text.includes(endpoint)) findings.push({ kind: "telemetry-endpoint", severity: "review", evidence: endpoint }); + } + + if (text.includes("DISABLE_TELEMETRY") && text.includes("Datadog") && /does not|not guard|still ON|hole/i.test(text)) { + findings.push({ kind: "telemetry-optout-gap", severity: "review", evidence: "DISABLE_TELEMETRY does not appear to cover all telemetry paths" }); + } + + const blocked = findings.some((finding) => finding.severity === "block"); + const review = findings.some((finding) => finding.severity === "review"); + return { + schema: "fable.prompt_fingerprint.receipt.v1", + createdAt: now.toISOString(), + source, + findings, + decision: blocked ? "blocked" : review ? "review" : "clean", + reasons: findings.map((finding) => `${finding.kind}: ${finding.evidence}`), + deployAttempted: false, + }; +} + +export function writePromptFingerprintReceipt(file: string, receipt: PromptFingerprintReceipt): 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 31c3766..389d6ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2047,6 +2047,21 @@ fable if (result.verdict === "known_unsafe") process.exit(1); }); +fable + .command("prompt-fingerprint ") + .description("Scan a prompt or bundle for covert prompt markers and telemetry fingerprint risks") + .option("--output ", "Receipt output path", path.join(".fable", "security", "prompt-fingerprint-live.json")) + .action(async (file: string, opts: { output?: string }) => { + const { createPromptFingerprintReceipt, writePromptFingerprintReceipt } = await import("./fable5/prompt-fingerprint.js"); + const full = path.resolve(file); + const receipt = createPromptFingerprintReceipt(full, fs.readFileSync(full, "utf-8")); + const out = opts.output ?? path.join(".fable", "security", "prompt-fingerprint-live.json"); + writePromptFingerprintReceipt(out, receipt); + console.log(JSON.stringify(receipt, null, 2)); + console.log(`\n Receipt: ${out}\n`); + if (receipt.decision === "blocked") process.exit(1); + }); + fable .command("skill-health") .description("Write a read-only skill health receipt without auto-patch or deploy authority")