金鑰守門:遮蔽 prompt 和工具輸出裡的 API key,並擋下讀寫 .env、私鑰這類檔案;/guard 看狀態

我為自己的開發流程寫的四個 Claude Code mod。Mod 是跑在 Claude Code 裡面的 TypeScript 事件處理函式,可以攔截工具呼叫、改寫 prompt,或在介面上畫出自己的區塊。
| Mod | 做什麼 | 指令 |
|---|---|---|
| ding-dong | 回合跑超過一分鐘才結束時,發一則系統通知叫你回來 | /ding 預覽通知 |
| ship-it | 依序執行專案的階段(檢查、測試、打包、部署),在 prompt 上方的深色面板用像素吉祥物顯示進度 | /ship、/ship demo、/ship init |
| secret-guard | 遮蔽 prompt 與工具輸出裡的金鑰,擋下讀寫 .env 與私鑰 | /guard、/guard on、/guard off |
| pii-mask | 把個資換成代號再送給模型,對照表只留在本機,之後可以還原 | /pii、/pii on、/pii add、/pii restore |
ding-dong 的系統通知只支援 macOS,其他平台會改用 Claude Code 內的 toast把這個 repo 加成 marketplace,再安裝想要的 mod:
claude plugin marketplace add builtbyjia/claude-code-mods
claude plugin install ship-it@builtbyjia-mods
只想試用一次的話,clone 下來後用 --plugin-dir 載入,只在那個 session 生效:
claude --plugin-dir ./ship-it
Mod 會用你的權限執行,可以讀寫檔案、執行指令。安裝任何人的 mod 之前(包括這裡的),建議先用
claude plugin validate <資料夾>看它掛了哪些事件、呼叫了哪些 API,並讀過原始碼。
回合依時長分成四種語氣(一到三分鐘、三到十分鐘、十分鐘以上、出錯),每種隨機抽一句文案,副標題顯示專案資料夾、耗時與工具次數。你自己中斷的回合不會通知。
通知預設由系統的 osascript 發出。想讓通知圖示變成橘色像素吉祥物,執行一次:
./ding-dong/notifier/build.sh
它會在本機編譯出 DingDong.app,之後通知就由這個小程式發出。文案與門檻秒數都在 ding-dong/hooks/register.ts 最上方。
/ship 會在 prompt 正上方畫出一塊深色的橫式面板,吉祥物沿著軌道走向正在執行的階段,每個階段顯示耗時;失敗時流程停下,並列出最後幾行錯誤輸出。面板右上角的「關」可以收起來,再輸入一次 /ship 就會重新出現。
「現在是哪個階段」不是偵測出來的:mod 自己依序執行每個階段的指令,正在跑的那個指令就是目前階段,指令結束時的結束碼是 0 才會往下一個走。吉祥物在兩個節點之間的位置只是依時間估算的動畫,不代表真實進度。
階段的來源依序是:
.claude/ship.jsonpackage.json 裡的 lint、test、build、deploy 這四個 script用 /ship init 可以產生設定檔,格式如下:
{
"stages": [
{ "name": "跑測試", "run": "npm test" },
{ "name": "打包", "run": "npm run build" },
{ "name": "部署", "run": "npm run deploy" }
]
}
run 會交給 bash -c 執行,所以只在你信任的專案裡使用 /ship。/ship demo 用 sleep 模擬五個階段,不會動到專案。
三層防護:
[已遮蔽:GitHub token] 這類標記。.env、私鑰、.npmrc 等檔案,以及 cat .env、printenv 這類指令;.env.example 這類範本檔放行。已知限制:
預設關閉。用 /pii on 開啟後,prompt 和工具輸出裡的個資會先換成 <PERSON_1>、<TW_MOBILE_1> 這類代號才送給模型;同一個原文永遠對應同一個代號。對照表存在本機,只會畫在 /pii 打開的面板上,不會進入對話。
模型產出的檔案裡如果有代號,用 /pii restore 檔案路徑 換回原文,結果另存成 .restored 檔,原檔不動。
能辨識的類型:
| 類型 | 辨識方式 |
|---|---|
| 手機、市話、Email | 格式比對 |
| 身分證、居留證、信用卡 | 格式比對加檢查碼 |
| 統一編號 | 前面寫明「統編」或「統一編號」的八位數字 |
| 地址 | 正式寫法(縣市、鄉鎮市區、路街) |
| 公司 | 以「有限公司」「企業社」「商行」「事務所」結尾 |
| 人名 | 「姓名:」「客戶:」「聯絡人:」這類欄位後面的名字,或用 /pii add 加進名單的詞 |
已知限制:
/pii 檢查對照表是否抓齊。每個 mod 是一個獨立的 plugin 資料夾:
<mod>/
├── .claude-plugin/plugin.json
├── hooks/
│ ├── hooks.json
│ └── register.ts
└── tests/
檢查 Claude Code 會從 mod 讀到什麼:
claude plugin validate ./secret-guard
在 mod 的資料夾裡執行測試,不需要開 session:
claude plugin test
用 --plugin-dir 載入時,存檔就會自動重新載入。
通知圖示與 ship-it 裡的像素小生物是我自己畫的,靈感來自 Claude 的橘色吉祥物,不是 Anthropic 的官方素材。這個專案與 Anthropic 沒有從屬關係。
以 MIT 授權釋出,詳見 LICENSE。
hooks/register.ts 241 lines1import type { EngineInterface, Register } from 'claude-code'
2
3type Block = { type: string; [field: string]: unknown }
4type Masked = { text: string; kinds: string[] }
5
6// 格式固定、一看就知道是金鑰的字串:整段遮掉
7const TOKEN_RULES: readonly { kind: string; pattern: RegExp }[] = [
8 { kind: '私鑰', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
9 { kind: 'Anthropic API key', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
10 { kind: 'OpenAI API key', pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g },
11 { kind: 'GitHub token', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g },
12 { kind: 'AWS access key', pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
13 { kind: 'Google API key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
14 { kind: 'Slack token', pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
15 { kind: 'Stripe key', pattern: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g },
16 { kind: 'JWT', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
17]
18
19// .env 風格的一行:名稱裡有 SECRET、TOKEN、PASSWORD、KEY 的大寫變數,只遮等號後面的值。
20// 開頭容許行號或「- 」,因為工具輸出常帶這些前綴
21const ENV_LINE =
22 /^([ \t]*(?:\d+[\t→ ]+)?(?:-[ \t]+)?(?:export[ \t]+)?(?:[A-Z0-9]+_)*(?:SECRET|TOKEN|PASSWORD|PASSWD|KEY|APIKEY)(?:_[A-Z0-9]+)*[ \t]*=[ \t]*["']?)([^\s"'#]+)/gm
23
24// JSON、YAML、程式碼裡帶引號的值,例如 "apiKey": "…";值要像隨機字串才遮,避免誤傷一般程式碼
25const QUOTED_VALUE =
26 /((?:api[_-]?key|secret|token|password|passwd|client[_-]?secret|access[_-]?key)["']?[ \t]*[:=][ \t]*["'])([^"'\s]+)(?=["'])/gi
27
28const ENV_FILE = /(^|\/)\.env(\.[\w-]+)*$/
29const ENV_TEMPLATE = /\.(example|sample|template|dist|defaults)$/
30const KEY_FILE =
31 /(^|\/)(id_(rsa|dsa|ecdsa|ed25519)|\.npmrc|\.netrc|\.pgpass|credentials(\.json)?|[^/]+\.(pem|p12|pfx|keystore|jks))$/
32
33// 這些 door 進來的內容會送給模型:工具結果、附件、其他 session 傳來的訊息等。
34// 模型自己的回覆(response)和你打的 prompt(另外在 prompt.submit 處理)不在這裡
35const MASKED_DOORS = new Set(['tool-result', 'tool-message', 'attachment', 'delivery', 'command', 'hook-context'])
36
37let isOn = true
38let blocked = 0
39const masked = new Map<string, number>()
40
41function tag(kind: string): string {
42 return `[已遮蔽:${kind}]`
43}
44
45function looksRandom(value: string): boolean {
46 return (
47 value.length >= 12 &&
48 /[A-Za-z]/.test(value) &&
49 /\d/.test(value) &&
50 !/^(process\.env|import\.meta|\$\{|<|\[已遮蔽)/.test(value)
51 )
52}
53
54function maskText(text: string): Masked {
55 const kinds: string[] = []
56 let out = text
57
58 for (const rule of TOKEN_RULES) {
59 out = out.replace(rule.pattern, () => {
60 kinds.push(rule.kind)
61
62 return tag(rule.kind)
63 })
64 }
65
66 out = out.replace(ENV_LINE, (whole: string, head: string, value: string) => {
67 if (value.length < 6 || value.startsWith('[已遮蔽')) {
68 return whole
69 }
70
71 kinds.push('環境變數')
72
73 return head + tag('環境變數')
74 })
75
76 out = out.replace(QUOTED_VALUE, (whole: string, head: string, value: string) => {
77 if (!looksRandom(value)) {
78 return whole
79 }
80
81 kinds.push('設定值')
82
83 return head + tag('設定值')
84 })
85
86 return { text: out, kinds }
87}
88
89function maskBlocks(blocks: readonly Block[]): { blocks: Block[]; kinds: string[] } {
90 const kinds: string[] = []
91 const scrub = (text: string): string => {
92 const result = maskText(text)
93 kinds.push(...result.kinds)
94
95 return result.text
96 }
97 const scrubBlock = (block: Block): Block =>
98 block.type === 'text' && typeof block.text === 'string' ? { ...block, text: scrub(block.text) } : block
99
100 const out = blocks.map(block => {
101 if (block.type !== 'tool_result') {
102 return scrubBlock(block)
103 }
104
105 if (typeof block.content === 'string') {
106 return { ...block, content: scrub(block.content) }
107 }
108
109 return Array.isArray(block.content) ? { ...block, content: (block.content as Block[]).map(scrubBlock) } : block
110 })
111
112 return { blocks: out, kinds }
113}
114
115function isProtected(path: string): boolean {
116 return (ENV_FILE.test(path) && !ENV_TEMPLATE.test(path)) || KEY_FILE.test(path)
117}
118
119// 回傳這個 shell 指令為什麼不能跑;沒問題就回傳 null。
120// 這是盡力而為的字面檢查,繞得過去;真正的後盾是工具輸出的遮蔽
121function bashProblem(command: string): string | null {
122 if (/(^|[;&|(]\s*)(printenv|env)\s*($|[;&|)>])/.test(command)) {
123 return '這個指令會印出所有環境變數'
124 }
125
126 const words = command.split(/[\s'"`()<>;|&=,]+/).filter(Boolean)
127
128 for (const [index, word] of words.entries()) {
129 // --env-file=.env 只是讓程式載入設定,內容不會印出來
130 const isEnvFileFlag = /^--?env[-_]?file$/.test(words[index - 1] ?? '')
131
132 if (isProtected(word) && !isEnvFileFlag) {
133 return `這個指令會碰到 ${word}`
134 }
135 }
136
137 return null
138}
139
140function refusal(reason: string): string {
141 return `secret-guard 擋下了這個動作:${reason},裡面可能有金鑰,內容不能送到雲端。請不要改用別的方式讀取;需要其中的設定時,請使用者自己處理,或改看 .env.example 這類範本檔。`
142}
143
144function tally($: EngineInterface, where: string, kinds: readonly string[]): void {
145 for (const kind of kinds) {
146 masked.set(kind, (masked.get(kind) ?? 0) + 1)
147 }
148
149 $.ui.toast(`已遮蔽${where}裡的 ${[...new Set(kinds)].join('、')}`)
150}
151
152function deny($: EngineInterface, reason: string): { deny: string } {
153 blocked += 1
154 $.ui.toast(`已擋下:${reason}`)
155
156 return { deny: refusal(reason) }
157}
158
159function status(): string {
160 const lines = [...masked.entries()].map(([kind, count]) => `・${kind}:${count} 次`)
161
162 return [
163 `secret-guard 目前${isOn ? '開啟' : '關閉'}(/guard on、/guard off 切換,只影響這個 session)`,
164 `擋下的檔案與指令:${blocked} 次`,
165 lines.length > 0 ? `遮蔽的內容:\n${lines.join('\n')}` : '遮蔽的內容:還沒有',
166 ].join('\n')
167}
168
169export const register: Register = on => {
170 on('session.start', async ($, e, next) => {
171 await $.command.register({
172 name: 'guard',
173 description: '金鑰守門的狀態與統計(/guard on、/guard off 切換)',
174 })
175
176 return next(e)
177 })
178
179 on('command.run', { command: 'guard' }, (_, e) => {
180 const args = e.args.trim()
181
182 if (args === 'on' || args === 'off') {
183 isOn = args === 'on'
184 }
185
186 return { text: status() }
187 })
188
189 // 你打的或貼上的 prompt:送出前把金鑰換成標記
190 on('prompt.submit', ($, e, next) => {
191 const result = isOn ? maskText(e.text) : { text: e.text, kinds: [] }
192
193 if (result.kinds.length === 0) {
194 return next(e)
195 }
196
197 tally($, ' prompt ', result.kinds)
198
199 return next({ ...e, text: result.text })
200 })
201
202 // 工具結果等內容:存進對話、送給模型之前把金鑰換成標記
203 on('session.append', ($, e, next) => {
204 if (!isOn || !MASKED_DOORS.has(e.door)) {
205 return next(e)
206 }
207
208 const result = maskBlocks(e.message.content)
209
210 if (result.kinds.length === 0) {
211 return next(e)
212 }
213
214 tally($, '工具輸出', result.kinds)
215
216 return next({ ...e, message: { ...e.message, content: result.blocks } })
217 })
218
219 on('tool.call', { tool: 'Read' }, ($, e, next) =>
220 isOn && isProtected(e.file_path) ? deny($, `讀取 ${e.file_path}`) : next(e),
221 )
222
223 on('tool.call', { tool: 'Edit' }, ($, e, next) =>
224 isOn && isProtected(e.file_path) ? deny($, `修改 ${e.file_path}`) : next(e),
225 )
226
227 on('tool.call', { tool: 'Write' }, ($, e, next) =>
228 isOn && isProtected(e.file_path) ? deny($, `覆寫 ${e.file_path}`) : next(e),
229 )
230
231 on('tool.call', { tool: 'Grep' }, ($, e, next) =>
232 isOn && typeof e.path === 'string' && isProtected(e.path) ? deny($, `搜尋 ${e.path}`) : next(e),
233 )
234
235 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
236 const problem = isOn ? bashProblem(e.command) : null
237
238 return problem === null ? next(e) : deny($, problem)
239 })
240}
241