Stops prompts that contain API keys or passwords (puts a redacted copy back in the input box) and drops the same message re-sent within a few minutes.

Stops prompts carrying secrets and drops accidental duplicate sends.
只看人输入的 prompt(终端 composer 和远程 bridge),其它来源原样放行。
sk- API key、GitHub token、AWS key、Slack token、JWT / Supabase key、私钥块,以及「密码是 X」「password: X」(值为 6 位以上非中文字符,所以「密码是什么」「密码是否正确」不算)时,不发出;把去掉密钥的版本放回输入框,提示改用环境变量或 secret store。#allow-secret ,标记会先去掉再发出。无。规则在 hooks/scan.ts 的 PATTERNS / PASSWORD,要加新密钥格式改那里。
claude plugin test .
/plugin marketplace add AbyssCN/claude-lead-harness
/plugin install prompt-guard@claude-lead-harnesshooks/register.ts 42 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { normalize, redact, scan } from './scan'
5
6const last = atom({ plugin: 'prompt-guard', key: 'last' } as const, null)
7
8// Prompts a person typed (terminal or phone). Others pass untouched.
9const FROM_PERSON = new Set(['composer', 'bridge'])
10// Typed on purpose to let a secret through: the marker is taken off first.
11const ALLOW = /^#allow-secret\s+/
12const DUPLICATE_MS = 5 * 60_000
13
14export const register: Register = on => {
15 on('prompt.submit', async ($, e, next) => {
16 if (!FROM_PERSON.has(e.origin.kind)) return next(e)
17
18 if (ALLOW.test(e.text)) return next({ ...e, text: e.text.replace(ALLOW, '') })
19
20 const hits = scan(e.text)
21 if (hits.length > 0) {
22 const labels = [...new Set(hits.map(h => h.label))].join('、')
23 await $.prompt.fill({ text: redact(e.text, hits), mode: 'replace' })
24 return {
25 drop: `prompt-guard:消息里有${labels},没有发出。去掉密钥的版本已放回输入框;密钥请放密码管理器 / secret store 或环境变量,在 prompt 里写「用环境变量 X」。确实要发,在开头加 #allow-secret。`,
26 }
27 }
28
29 const now = await $.clock.now()
30 const text = normalize(e.text)
31 const prev = await read($, last)
32 if (prev && prev.text === text && now - prev.at < DUPLICATE_MS && text.length > 0) {
33 const seconds = Math.round((now - prev.at) / 1000)
34 return { drop: `prompt-guard:这条 ${seconds} 秒前刚发过,已经在处理或排队,这次没有重复发送。要再发一次,改一个字即可。` }
35 }
36
37 await update($, last, () => ({ text, at: now }))
38 return next(e)
39 // A bug here must never eat a prompt: let it through.
40 }).catch(($, e, next) => next(e))
41}
42hooks/scan.ts 46 lines1// Pure helpers, kept apart so the tests can call them directly.
2
3export type Hit = { label: string; start: number; end: number }
4
5const PATTERNS: { label: string; re: RegExp }[] = [
6 { label: 'Stripe 密钥', re: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g },
7 { label: 'MiniMax 密钥', re: /\bsk-(?:cp)-[A-Za-z0-9_-]{20,}/g },
8 { label: 'Anthropic 密钥', re: /\bsk-(?:ant)-[A-Za-z0-9_-]{20,}/g },
9 { label: 'API 密钥', re: /\bsk-[A-Za-z0-9]{32,}/g },
10 { label: 'GitHub token', re: /\b(?:ghp|gho|ghs)_[A-Za-z0-9]{30,}|\bgithub_pat_[A-Za-z0-9_]{30,}/g },
11 { label: 'AWS key', re: /\bAKIA[0-9A-Z]{16}\b/g },
12 { label: 'Slack token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
13 { label: 'JWT / Supabase key', re: /\beyJ[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{15,}/g },
14 { label: '私钥', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g },
15]
16
17// 「密码是 X」「password: X」: the value is 6+ non-CJK characters, so
18// 「密码是否正确」「密码是什么」 pass through.
19const PASSWORD = /(密码|口令|password|passwd|salasana)\s*(?:是|为|[::=])\s*([^\s㐀-鿿,,。??!!、))]{6,})/gi
20
21export function scan(text: string): Hit[] {
22 const hits: Hit[] = []
23 for (const { label, re } of PATTERNS) {
24 for (const m of text.matchAll(re)) hits.push({ label, start: m.index!, end: m.index! + m[0].length })
25 }
26 for (const m of text.matchAll(PASSWORD)) {
27 const value = m[2] ?? ''
28 const start = m.index! + m[0].length - value.length
29 hits.push({ label: '密码', start, end: start + value.length })
30 }
31 return hits.sort((a, b) => a.start - b.start)
32}
33
34export function redact(text: string, hits: Hit[]): string {
35 let out = ''
36 let at = 0
37 for (const h of hits) {
38 if (h.start < at) continue
39 out += text.slice(at, h.start) + `[已移除的${h.label}]`
40 at = h.end
41 }
42 return out + text.slice(at)
43}
44
45export const normalize = (text: string): string => text.replace(/\s+/g, ' ').trim()
46types/index.d.ts 8 lines1export type LastPrompt = { text: string; at: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'prompt-guard': { last: LastPrompt | null }
6 }
7}
8