From ec26b560abf512286977d9f99f537d5c73930dcf Mon Sep 17 00:00:00 2001 From: artale Date: Sun, 21 Jun 2026 01:13:49 +0200 Subject: [PATCH] feat: flag glyph covert-channel risks --- src/core/unicode-safety.test.ts | 15 ++++++++++++- src/core/unicode-safety.ts | 28 ++++++++++++++++++++++++ src/index.ts | 12 ++++++---- src/upgrades/content-safety-gate.test.ts | 7 ++++++ src/upgrades/content-safety-gate.ts | 13 +++++++++++ 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/core/unicode-safety.test.ts b/src/core/unicode-safety.test.ts index 6581144..ca6a6aa 100644 --- a/src/core/unicode-safety.test.ts +++ b/src/core/unicode-safety.test.ts @@ -1,5 +1,5 @@ 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", () => { it("strips hidden instruction carriers", () => { @@ -10,4 +10,17 @@ describe("unicode safety", () => { expect(stripHiddenUnicode(text)).toBe("safetext"); 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); + }); }); diff --git a/src/core/unicode-safety.ts b/src/core/unicode-safety.ts index 6b6fbc3..754edc1 100644 --- a/src/core/unicode-safety.ts +++ b/src/core/unicode-safety.ts @@ -1,4 +1,13 @@ 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 { // 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")}`, })); } + +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 }; +} diff --git a/src/index.ts b/src/index.ts index 28e4e1a..85ba804 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3072,6 +3072,7 @@ security .option("--include-fixtures", "Include intentional red-team fixtures/generators") .action(async (target: string, opts: { includeFixtures?: boolean }) => { const { findPromptInjection } = await import("./core/prompt-injection-safety.js"); + const { assessLegibilityRisk } = await import("./core/unicode-safety.js"); const root = path.resolve(target); const files: string[] = []; @@ -3094,10 +3095,13 @@ security walk(root); let hits = 0; for (const file of files) { - const found = findPromptInjection(fs.readFileSync(file, "utf-8")).filter((h) => h.kind !== "instruction-smuggling"); - if (found.length === 0) continue; - hits += found.length; - console.log(`${file}: ${found.map((h) => `${h.kind}:${h.match}@${h.index}`).join(", ")}`); + const content = fs.readFileSync(file, "utf-8"); + const found = findPromptInjection(content).filter((h) => h.kind !== "instruction-smuggling"); + const legibility = assessLegibilityRisk(content); + 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) { diff --git a/src/upgrades/content-safety-gate.test.ts b/src/upgrades/content-safety-gate.test.ts index 0d25186..84b39ed 100644 --- a/src/upgrades/content-safety-gate.test.ts +++ b/src/upgrades/content-safety-gate.test.ts @@ -55,6 +55,13 @@ describe("ContentSafetyGate", () => { 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", () => { const v = gate.evaluate("Give me stock trading advice"); expect(["reformulate", "block", "allow"]).toContain(v.action); diff --git a/src/upgrades/content-safety-gate.ts b/src/upgrades/content-safety-gate.ts index 0be4c66..b14312f 100644 --- a/src/upgrades/content-safety-gate.ts +++ b/src/upgrades/content-safety-gate.ts @@ -1,3 +1,5 @@ +import { assessLegibilityRisk } from "../core/unicode-safety.js"; + /** * 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 for (const [domain, patterns] of this.domainPatterns) { for (const pattern of patterns) {