SLOPSHOPPER

prompt-shield

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

newprompt
A shopper browsing a rack in a slop shop
README

prompt-shield

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.textcommit/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

使用

  1. 本地试跑(仓库根目录下执行):
claude --plugin-dir mods/prompt-shield
  1. 跑测试:
claude plugin test mods/prompt-shield
  1. 校验:
claude plugin validate mods/prompt-shield
  1. 全局安装见仓库根 README.md;想一次装全改装聚合包:
claude plugin install plus@claudecode-plus-mod --scope user

只装这一个 Mod:

claude plugin install prompt-shield@claudecode-plus-mod --scope user
  1. 需要在 transcript 里直接看到每次剥离时,给插件传 verbose:
{ "verbose": true }

默认走 debug 通道,只在需要排查时查看,不打扰正常会话。

验证记录

stripInvisible 已用 Node 实测:零宽/Bidi/Tags 字符被移除,ASCII、 中文、U+2028 行分隔符保留;测试文件本身经字节级扫描确认不含任何 字面不可见字符(用 String.fromCodePoint 构造脏数据,避免测试源被污染)。

Source 2 files
hooks/register.ts 85 lines
1import 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}
85
hooks/sanitize.ts 28 lines
1/**
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