feat: flag glyph covert-channel risks

This commit is contained in:
artale 2026-06-21 01:13:49 +02:00
parent 7172570351
commit ec26b560ab
5 changed files with 70 additions and 5 deletions

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { findHiddenUnicode, hasHiddenUnicode, stripHiddenUnicode } from "./unicode-safety.js"; import { assessLegibilityRisk, findHiddenUnicode, hasHiddenUnicode, stripHiddenUnicode } from "./unicode-safety.js";
describe("unicode safety", () => { describe("unicode safety", () => {
it("strips hidden instruction carriers", () => { it("strips hidden instruction carriers", () => {
@ -10,4 +10,17 @@ describe("unicode safety", () => {
expect(stripHiddenUnicode(text)).toBe("safetext"); expect(stripHiddenUnicode(text)).toBe("safetext");
expect(findHiddenUnicode(text).map((hit) => hit.codePoint)).toEqual(["U+E0069", "U+200B", "U+202E"]); expect(findHiddenUnicode(text).map((hit) => hit.codePoint)).toEqual(["U+E0069", "U+200B", "U+202E"]);
}); });
it("flags private-use glyph covert channels", () => {
const risk = assessLegibilityRisk(`function ${String.fromCharCode(0xe000)}() { return ${String.fromCharCode(0xe001)}; }`);
expect(risk.risky).toBe(true);
expect(risk.reason).toContain("private-use");
});
it("allows ordinary non-English text", () => {
const risk = assessLegibilityRisk("Привет мир, обычный текст без кода");
expect(risk.risky).toBe(false);
});
}); });

View File

@ -1,4 +1,13 @@
const HIDDEN_UNICODE = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFE00-\uFE0F\u{E0000}-\u{E007F}]/gu; const HIDDEN_UNICODE = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFE00-\uFE0F\u{E0000}-\u{E007F}]/gu;
const CODE_LIKE = /\b(function|const|let|var|class|import|export|return|if|for|while|=>|docker|curl|bash|npm|node|python)\b|[{}();=<>]/u;
export interface LegibilityRisk {
risky: boolean;
reason: string;
nonAsciiRatio: number;
symbolRatio: number;
privateUseCount: number;
}
export function stripHiddenUnicode(text: string): string { export function stripHiddenUnicode(text: string): string {
// ponytail: strips known invisible instruction carriers; add reporting metadata if UI needs forensics. // ponytail: strips known invisible instruction carriers; add reporting metadata if UI needs forensics.
@ -17,3 +26,22 @@ export function findHiddenUnicode(text: string): { index: number; codePoint: str
codePoint: `U+${match[0].codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`, codePoint: `U+${match[0].codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`,
})); }));
} }
export function assessLegibilityRisk(text: string): LegibilityRisk {
const chars = [...stripHiddenUnicode(text)].filter((c) => !/\s/u.test(c));
if (chars.length === 0) return { risky: false, reason: "empty", nonAsciiRatio: 0, symbolRatio: 0, privateUseCount: 0 };
const nonAscii = chars.filter((c) => c.codePointAt(0)! > 0x7f).length;
const symbols = chars.filter((c) => /[\p{S}\p{Co}]/u.test(c)).length;
const privateUseCount = chars.filter((c) => /\p{Co}/u.test(c)).length;
const nonAsciiRatio = nonAscii / chars.length;
const symbolRatio = symbols / chars.length;
const codeLike = CODE_LIKE.test(text);
// ponytail: conservative covert-channel tripwire; tune only with real false positives.
const risky = privateUseCount > 0 || (codeLike && chars.length >= 20 && nonAsciiRatio > 0.35 && symbolRatio > 0.2);
const reason = privateUseCount > 0
? "private-use glyphs present"
: risky
? "code-like text has low human legibility"
: "legible enough";
return { risky, reason, nonAsciiRatio, symbolRatio, privateUseCount };
}

View File

@ -3072,6 +3072,7 @@ security
.option("--include-fixtures", "Include intentional red-team fixtures/generators") .option("--include-fixtures", "Include intentional red-team fixtures/generators")
.action(async (target: string, opts: { includeFixtures?: boolean }) => { .action(async (target: string, opts: { includeFixtures?: boolean }) => {
const { findPromptInjection } = await import("./core/prompt-injection-safety.js"); const { findPromptInjection } = await import("./core/prompt-injection-safety.js");
const { assessLegibilityRisk } = await import("./core/unicode-safety.js");
const root = path.resolve(target); const root = path.resolve(target);
const files: string[] = []; const files: string[] = [];
@ -3094,10 +3095,13 @@ security
walk(root); walk(root);
let hits = 0; let hits = 0;
for (const file of files) { for (const file of files) {
const found = findPromptInjection(fs.readFileSync(file, "utf-8")).filter((h) => h.kind !== "instruction-smuggling"); const content = fs.readFileSync(file, "utf-8");
if (found.length === 0) continue; const found = findPromptInjection(content).filter((h) => h.kind !== "instruction-smuggling");
hits += found.length; const legibility = assessLegibilityRisk(content);
console.log(`${file}: ${found.map((h) => `${h.kind}:${h.match}@${h.index}`).join(", ")}`); if (found.length === 0 && !legibility.risky) continue;
hits += found.length + (legibility.risky ? 1 : 0);
if (found.length > 0) console.log(`${file}: ${found.map((h) => `${h.kind}:${h.match}@${h.index}`).join(", ")}`);
if (legibility.risky) console.log(`${file}: human-legibility-risk:${legibility.reason}`);
} }
if (hits > 0) { if (hits > 0) {

View File

@ -55,6 +55,13 @@ describe("ContentSafetyGate", () => {
expect(v.action).toBe("allow"); expect(v.action).toBe("allow");
}); });
it("blocks human-unreadable glyph code", () => {
const glyph = String.fromCharCode(0xe000);
const v = gate.evaluate(`function ${glyph}() { return ${glyph}; }`);
expect(v.action).toBe("block");
expect(v.classification.reason).toContain("human-legibility");
});
it("reformulates medium-risk financial advice", () => { it("reformulates medium-risk financial advice", () => {
const v = gate.evaluate("Give me stock trading advice"); const v = gate.evaluate("Give me stock trading advice");
expect(["reformulate", "block", "allow"]).toContain(v.action); expect(["reformulate", "block", "allow"]).toContain(v.action);

View File

@ -1,3 +1,5 @@
import { assessLegibilityRisk } from "../core/unicode-safety.js";
/** /**
* Content Safety Gate classifies tasks by risk domain before they reach the loop. * Content Safety Gate classifies tasks by risk domain before they reach the loop.
* *
@ -139,6 +141,17 @@ export class ContentSafetyGate {
}; };
} }
const legibility = assessLegibilityRisk(task);
if (legibility.risky) {
return {
domain: "cybersecurity_exploit",
level: "high",
confidence: 0.9,
reason: `Task has human-legibility/covert-channel risk: ${legibility.reason}`,
reformulation: "Provide reviewable ASCII/source text before requesting execution, deployment, or code review.",
};
}
// Check each domain for matches // Check each domain for matches
for (const [domain, patterns] of this.domainPatterns) { for (const [domain, patterns] of this.domainPatterns) {
for (const pattern of patterns) { for (const pattern of patterns) {