SLOPSHOPPER

secret-guard

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

newguardcommandtoastprompt
v0.1.0MITupdated 2026-10-03builtbyjia/claude-code-mods/secret-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
› fix the failing auth test and add an audit log call ╭──────────────────╮ │ secret-guard │ ⏺ Read(src/auth.ts) │ 已擋下:這個指令會碰到 .env │ ⎿ Read 6 lines ╰──────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by secret-guard: secret-guard 擋下了這個動作:這個指令會碰到 .env,裡面可能有金鑰,內容不能送到雲端。請不要改用別的方式讀取;需要其中的設定時,請使用者自己處理,或 ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard ⎿ secret-guard: secret-guard 目前開啟(/guard on、/guard off 切換,只影響這個 session) ⎿ secret-guard: 擋下的檔案與指令:1 次 ⎿ secret-guard: 遮蔽的內容:還沒有 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code Mods

我為自己的開發流程寫的四個 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

需求

  • Claude Code 2.1.287 以上
  • 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,並讀過原始碼。

ding-dong

回合依時長分成四種語氣(一到三分鐘、三到十分鐘、十分鐘以上、出錯),每種隨機抽一句文案,副標題顯示專案資料夾、耗時與工具次數。你自己中斷的回合不會通知。

通知預設由系統的 osascript 發出。想讓通知圖示變成橘色像素吉祥物,執行一次:

./ding-dong/notifier/build.sh

它會在本機編譯出 DingDong.app,之後通知就由這個小程式發出。文案與門檻秒數都在 ding-dong/hooks/register.ts 最上方。

ship-it

/ship 會在 prompt 正上方畫出一塊深色的橫式面板,吉祥物沿著軌道走向正在執行的階段,每個階段顯示耗時;失敗時流程停下,並列出最後幾行錯誤輸出。面板右上角的「關」可以收起來,再輸入一次 /ship 就會重新出現。

「現在是哪個階段」不是偵測出來的:mod 自己依序執行每個階段的指令,正在跑的那個指令就是目前階段,指令結束時的結束碼是 0 才會往下一個走。吉祥物在兩個節點之間的位置只是依時間估算的動畫,不代表真實進度。

階段的來源依序是:

  1. 專案裡的 .claude/ship.json
  2. package.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 模擬五個階段,不會動到專案。

secret-guard

三層防護:

  1. Prompt:送出前把 API key、token、私鑰換成 [已遮蔽:GitHub token] 這類標記。
  2. 工具輸出:檔案內容或指令輸出裡的金鑰,在送給模型之前遮蔽。
  3. 敏感檔案:擋下讀寫 .env、私鑰、.npmrc 等檔案,以及 cat .env、printenv 這類指令;.env.example 這類範本檔放行。

已知限制:

  • 靠格式比對,認不出格式的密碼不會被遮蔽。
  • Shell 指令的檢查是字面比對,可以被繞過,不能當作唯一的防線。
  • 模型看到的是標記,所以它無法修改被遮蔽的那一行。
  • 圖片與 PDF 的內容不在遮蔽範圍內。

pii-mask

預設關閉。用 /pii on 開啟後,prompt 和工具輸出裡的個資會先換成 <PERSON_1>、<TW_MOBILE_1> 這類代號才送給模型;同一個原文永遠對應同一個代號。對照表存在本機,只會畫在 /pii 打開的面板上,不會進入對話。

模型產出的檔案裡如果有代號,用 /pii restore 檔案路徑 換回原文,結果另存成 .restored 檔,原檔不動。

能辨識的類型:

類型辨識方式
手機、市話、Email格式比對
身分證、居留證、信用卡格式比對加檢查碼
統一編號前面寫明「統編」或「統一編號」的八位數字
地址正式寫法(縣市、鄉鎮市區、路街)
公司以「有限公司」「企業社」「商行」「事務所」結尾
人名「姓名:」「客戶:」「聯絡人:」這類欄位後面的名字,或用 /pii add 加進名單的詞

已知限制:

  • 沒有語言模型,自由書寫的文字裡不在名單上的人名、公司、口語地址不會被辨識。
  • 圖片、PDF 的內容不在處理範圍內。
  • 模型看到的是代號,所以它無法修改含有個資的那一行原始檔案。
  • 這是降低意外外洩的工具,不是保證。處理真正敏感的資料前,先用 /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。

Source 1 files
hooks/register.ts 241 lines
1import 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