Strip invisible fingerprint characters from outgoing prompts and audit what the model actually receives.

Claude Code Mod:在提示词离开发送前,剥离可携带隐藏签名的不可见字符,并记下剥离审计日志。
零宽字符(U+200B–U+200D)、BOM(U+FEFF)、双向控制符(U+202A–U+202E、 U+2066–U+2069)、变体选择符(U+FE00–U+FE0F)、Tags 块(U+E0000–U+E007F, U+E0100–U+E01EF)等不可见字符,可以在用户看不到的情况下改变模型实际 收到的文本,或充当隐形指纹。无论这些字符是哪里混入的,本 Mod 在四个 外发点统一清洗:
| 钩子 | 处理 |
|---|---|
prompt.submit | 用户输入正文 + 随附 context,下行改写 |
prompt.context | 首条用户消息的上下文块(claudeMd 等) |
prompt.attachment | 注入的提醒/附件文本,next 之后清洗 |
attribution.text | commit/PR 等署名文本,next 之后清洗 |
正常中文、Emoji、换行(U+2028/U+2029 视为合法换行,保留)不受影响。
mods/prompt-shield/
.claude-plugin/plugin.json
hooks/hooks.json
hooks/register.ts
hooks/sanitize.ts
hooks/index.ts
tests/register.test.ts
claude --plugin-dir mods/prompt-shield
claude plugin test mods/prompt-shield
claude plugin validate mods/prompt-shield
README.md;想一次装全改装聚合包:claude plugin install plus@claudecode-plus-mod --scope user
只装这一个 Mod:
claude plugin install prompt-shield@claudecode-plus-mod --scope user
verbose:{ "verbose": true }
默认走 debug 通道,只在需要排查时查看,不打扰正常会话。
stripInvisible 已用 Node 实测:零宽/Bidi/Tags 字符被移除,ASCII、 中文、U+2028 行分隔符保留;测试文件本身经字节级扫描确认不含任何 字面不可见字符(用 String.fromCodePoint 构造脏数据,避免测试源被污染)。
hooks/register.ts 85 lines1import type { On } from 'claude-code'
2
3import { preview, removedCount, stripInvisible } from './sanitize'
4
5type ShieldOptions = {
6 verbose?: boolean
7}
8
9function report($: unknown, message: string, verbose: boolean): void {
10 const ui = ($ as { ui: { log: (text: string, opts?: object) => void } }).ui
11 if (verbose) {
12 ui.log(message)
13 } else {
14 ui.log(message, { to: 'debug' })
15 }
16}
17
18/**
19 * Registers prompt hygiene hooks: prompt.submit/context/attachment and
20 * attribution.text are scanned on the way out, invisible characters that can
21 * carry a hidden signature are removed, the cleaned value continues down.
22 * Every hook fires on engine events alone, so the mod works from session
23 * start with no model call needed; session.start only announces readiness.
24 *
25 * @param on the engine's registrar
26 * @param options the plugin's options; `verbose` surfaces each strip in the transcript
27 */
28export function register(on: On, options: ShieldOptions = {}): void {
29 const verbose = options.verbose === true
30
31 on('session.start', ($, e, next) => {
32 report($, '[prompt-shield] active, watching prompt.submit, prompt.context, prompt.attachment, attribution.text', verbose)
33 return next(e)
34 })
35
36 on('prompt.submit', ($, e, next) => {
37 const text = stripInvisible(e.text)
38 const dropped = removedCount(e.text, text)
39 const context = e.context?.map((entry) => stripInvisible(entry))
40 const contextDropped = (e.context ?? []).reduce(
41 (sum, entry, i) => sum + removedCount(entry, context?.[i] ?? entry),
42 0,
43 )
44 if (dropped > 0 || contextDropped > 0) {
45 report($, `[prompt-shield] prompt.submit stripped ${dropped + contextDropped} invisible chars: ${preview(e.text)}`, verbose)
46 }
47 return next({ ...e, text, context })
48 })
49
50 on('prompt.context', async ($, e, next) => {
51 const blocks = e.blocks.map((block) => {
52 const text = stripInvisible(block.text)
53 const dropped = removedCount(block.text, text)
54 if (dropped > 0) {
55 report($, `[prompt-shield] prompt.context block "${block.name}" stripped ${dropped} invisible chars`, verbose)
56 }
57 return { ...block, text }
58 })
59 return next({ ...e, blocks })
60 })
61
62 on('prompt.attachment', async ($, e, next) => {
63 const out = await next(e)
64 if (out.text === null) {
65 return out
66 }
67 const text = stripInvisible(out.text)
68 const dropped = removedCount(out.text, text)
69 if (dropped > 0) {
70 report($, `[prompt-shield] prompt.attachment "${e.type}" stripped ${dropped} invisible chars`, verbose)
71 }
72 return { text }
73 })
74
75 on('attribution.text', async ($, e, next) => {
76 const out = await next(e)
77 const text = stripInvisible(out.text)
78 const dropped = removedCount(out.text, text)
79 if (dropped > 0) {
80 report($, `[prompt-shield] attribution.text "${e.kind}" stripped ${dropped} invisible chars`, verbose)
81 }
82 return { text }
83 })
84}
85hooks/sanitize.ts 28 lines1/**
2 * @param text raw text about to leave the client
3 * @returns text with invisible fingerprint characters removed
4 */
5export function stripInvisible(text: string): string {
6 return text
7 .replace(/[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF\uFE00-\uFE0F]/g, '')
8 .replace(/[\uE0000-\uE007F\uE0100-\uE01EF]/gu, '');
9}
10
11/**
12 * @param before original text
13 * @param after sanitized text
14 * @returns number of characters removed
15 */
16export function removedCount(before: string, after: string): number {
17 return before.length - after.length;
18}
19
20/**
21 * @param text raw text
22 * @returns one-line preview with control characters escaped, for audit logs
23 */
24export function preview(text: string): string {
25 const escaped = text.replace(/[\u0000-\u001F\u007F-\u009F]/g, (c) => `\\u{${c.codePointAt(0)?.toString(16)}}`);
26 return escaped.length > 120 ? `${escaped.slice(0, 120)}…` : escaped;
27}
28