feat: add prompt fingerprint receipts

This commit is contained in:
artale 2026-07-01 05:23:44 +02:00
parent 0030455324
commit 09b2483919
5 changed files with 130 additions and 0 deletions

View File

@ -328,6 +328,8 @@ fable-agent plinius godmode "improve explanation quality"
- `--run <id>`
- `fable5 memory-b-cell <file>`
- `--cache <path>`
- `fable5 prompt-fingerprint <file>`
- `--output <path>`
- `fable5 skill-health`
- `--input <path>`
- `--skill <name>`

View File

@ -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";

View File

@ -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", "Todays 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 });
});
});

View File

@ -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;
}

View File

@ -2047,6 +2047,21 @@ fable
if (result.verdict === "known_unsafe") process.exit(1);
});
fable
.command("prompt-fingerprint <file>")
.description("Scan a prompt or bundle for covert prompt markers and telemetry fingerprint risks")
.option("--output <path>", "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")