feat: add prompt fingerprint receipts
This commit is contained in:
parent
0030455324
commit
09b2483919
|
|
@ -328,6 +328,8 @@ fable-agent plinius godmode "improve explanation quality"
|
||||||
- `--run <id>`
|
- `--run <id>`
|
||||||
- `fable5 memory-b-cell <file>`
|
- `fable5 memory-b-cell <file>`
|
||||||
- `--cache <path>`
|
- `--cache <path>`
|
||||||
|
- `fable5 prompt-fingerprint <file>`
|
||||||
|
- `--output <path>`
|
||||||
- `fable5 skill-health`
|
- `fable5 skill-health`
|
||||||
- `--input <path>`
|
- `--input <path>`
|
||||||
- `--skill <name>`
|
- `--skill <name>`
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,9 @@ export type { ModelAccessProbe, ModelAccessReceipt, ModelAccessReceiptOptions, M
|
||||||
export { createSkillHealthReceipt, writeSkillHealthReceipt } from "./skill-health.js";
|
export { createSkillHealthReceipt, writeSkillHealthReceipt } from "./skill-health.js";
|
||||||
export type { SkillHealthEntry, SkillHealthOptions, SkillHealthReceipt } 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 { createPlanReceipt, writePlanReceipt } from "./plan-receipt.js";
|
||||||
export type { PlanAllowedOutcome, PlanPhase, PlanReceipt, PlanReceiptOptions, PlanTaskType } from "./plan-receipt.js";
|
export type { PlanAllowedOutcome, PlanPhase, PlanReceipt, PlanReceiptOptions, PlanTaskType } from "./plan-receipt.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
15
src/index.ts
15
src/index.ts
|
|
@ -2047,6 +2047,21 @@ fable
|
||||||
if (result.verdict === "known_unsafe") process.exit(1);
|
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
|
fable
|
||||||
.command("skill-health")
|
.command("skill-health")
|
||||||
.description("Write a read-only skill health receipt without auto-patch or deploy authority")
|
.description("Write a read-only skill health receipt without auto-patch or deploy authority")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue