Mask token-like strings in tool output before they reach the conversation

<h1 align="center">Awesome Claude Code Mods</h1>
<a id="showcase"></a>
<table> <tr><td width="33%" align="center" valign="top"><p><a href="https://github.com/therahul-yo/clawdman"><img src="assets/showcase-01.gif" width="100%" alt="Clawdman: An animated companion that reacts to your coding session. Demo · 10×."></a></p><p><strong><a href="https://github.com/therahul-yo/clawdman">Clawdman</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/pi-agent/"><img src="assets/showcase-02.gif" width="100%" alt="Pi Agent: Run pi-powered models as native Claude Code subagents. Demo · 10×."></a></p><p><strong><a href="AgentMods/pi-agent/">Pi Agent</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="FocusMods/breathing-exercises/"><img src="assets/showcase-03.gif" width="100%" alt="Breathing Exercises: A guided breathing animation while Claude works."></a></p><p><strong><a href="FocusMods/breathing-exercises/">Breathing Exercises</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="sources/creative-toolkit/"><img src="assets/showcase-04.png" width="100%" alt="Creative Toolkit: A collection of live panels, session tools and playful mods. Code Pet screenshot."></a></p><p><strong><a href="sources/creative-toolkit/">Creative Toolkit</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/agent-cartoons/"><img src="assets/showcase-05.gif" width="100%" alt="Agent Cartoons: Coding activity becomes a cartoon in a choice of visual styles."></a></p><p><strong><a href="GameMods/agent-cartoons/">Agent Cartoons</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="https://github.com/adamholter/claude-subway-surfers"><img src="assets/showcase-06.gif" width="100%" alt="Subway Surfers Desktop: Gameplay beside Claude Desktop while it works. Desktop demo · 10×."></a></p><p><strong><a href="https://github.com/adamholter/claude-subway-surfers">Subway Surfers Desktop</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="GameMods/dino-game/"><img src="assets/showcase-07.gif" width="100%" alt="Dino Game: Play the T-rex runner above your prompt."></a></p><p><strong><a href="GameMods/dino-game/">Dino Game</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/spell-bar/"><img src="assets/showcase-08.gif" width="100%" alt="Spell Bar: An animated spell bar for your selected effort level."></a></p><p><strong><a href="GameMods/spell-bar/">Spell Bar</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/agent-dashboard/"><img src="assets/showcase-09.gif" width="100%" alt="Agent Dashboard: A live dashboard for context, costs, permissions and agents."></a></p><p><strong><a href="AgentMods/agent-dashboard/">Agent Dashboard</a></strong></p></td></tr> </table>
<a id="browse-by-category"></a>
<h2>Browse by category <img src="assets/category-magnifier.gif" width="52" height="40" alt="Clawd with a magnifying glass"></h2>
Usage Mods · Agent Mods · Planning Mods · File Mods · Git Mods · Safety Mods · Memory Mods · Interface Mods · Prompt Mods · Testing Mods · Web Mods · Focus Mods · Game Mods · Integration Mods
Source packages available here: 82 mods from 38 repositories, with their complete tracked files and notices. Local package links open inside this collection. Other entries currently lead to upstream references while the wider source import is reviewed.
Entries are grouped by their main purpose. Collections can contain several kinds of mods; hosted mod pages are categorized individually.
<a id="usage"></a>
Track model usage, costs, effort and quotas.
<a id="agents"></a>
Coordinate agents and follow their actual work.
hooks/register.ts 147 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { STRINGS, langOf } from './i18n'
5import type { Strings } from './i18n'
6import { maskText } from './mask'
7
8const hits = atom({ plugin: 'secret-mask', key: 'hits' } as const, [])
9const isOff = atom({ plugin: 'secret-mask', key: 'isOff' } as const, false)
10
11// 這幾個工具的結果是 Claude 之後要拿來改檔的原文,遮了會讓 Edit 對不上或寫回壞掉的值
12const EXEMPT = new Set(['Read', 'Edit', 'Write', 'NotebookEdit'])
13
14type Block = { type: string; [field: string]: unknown }
15
16// 記錄失敗也要讓遮蔽照常生效,所以錯誤吞掉只留 debug log
17const record = async ($: EngineInterface, t: Strings, tool: string, where: string, count: number) => {
18 const hit = { tool, where: maskText(where, t.mark).text.slice(0, 60), count }
19
20 try {
21 await update($, hits, list => [...list, hit].slice(-100))
22 $.ui.toast(t.toast(count, tool))
23 } catch {
24 $.ui.log('secret-mask: could not record hit', { to: 'debug' })
25 }
26}
27
28// 遮一個 tool_result block 的內容,回傳新 block 和遮了幾處
29const maskResult = (block: Block, mark: string): { block: Block; count: number } => {
30 const { content } = block
31
32 if (typeof content === 'string') {
33 const masked = maskText(content, mark)
34
35 return { block: { ...block, content: masked.text }, count: masked.count }
36 }
37 if (!Array.isArray(content)) {
38 return { block, count: 0 }
39 }
40
41 let count = 0
42 const parts = (content as Block[]).map(part => {
43 if (part.type !== 'text' || typeof part.text !== 'string') {
44 return part
45 }
46 const masked = maskText(part.text, mark)
47 count += masked.count
48
49 return { ...part, text: masked.text }
50 })
51
52 return { block: { ...block, content: parts }, count }
53}
54
55export const register: Register = (on, options) => {
56 const t = STRINGS[langOf(options)]
57 const commands = new Map<string, string>()
58
59 on('session.start', async ($, e, next) => {
60 await $.command.register({
61 name: 'secret-mask',
62 description: t.description,
63 argumentHint: '[off|on]',
64 })
65
66 return next(e)
67 })
68
69 // Bash 的 stdout / stderr 在來源就換掉,畫面和對話紀錄都看不到原值
70 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
71 commands.set(e.tool_use_id, e.command)
72 const ran = await next(e)
73
74 if (ran.deny !== undefined || ran.isError === true || (await read($, isOff))) {
75 return ran
76 }
77
78 const out = maskText(ran.result.stdout, t.mark)
79 const err = maskText(ran.result.stderr, t.mark)
80 const count = out.count + err.count
81
82 if (count === 0) {
83 return ran
84 }
85 await record($, t, 'Bash', e.command, count)
86
87 return {
88 result: { ...ran.result, stdout: out.text, stderr: err.text },
89 ...(ran.context === undefined ? {} : { context: ran.context }),
90 }
91 })
92
93 // 其他工具和 Bash 的錯誤輸出在寫進對話時遮,模型讀到的是遮過的
94 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
95 const tool = e.origin.kind === 'tool' ? e.origin.tool : 'unknown'
96
97 if (EXEMPT.has(tool) || (await read($, isOff))) {
98 return next(e)
99 }
100
101 let total = 0
102 let where = tool
103 const content = e.message.content.map(block => {
104 if (block.type !== 'tool_result') {
105 return block
106 }
107 const masked = maskResult(block, t.mark)
108
109 if (masked.count > 0 && typeof block.tool_use_id === 'string') {
110 where = commands.get(block.tool_use_id) ?? tool
111 }
112 total += masked.count
113
114 return masked.block
115 })
116
117 if (total === 0) {
118 return next(e)
119 }
120 await record($, t, tool, where, total)
121
122 return next({ ...e, message: { ...e.message, content } })
123 })
124
125 on('command.run', { command: 'secret-mask' }, async ($, e) => {
126 const arg = e.args.trim()
127
128 if (arg === 'off' || arg === 'on') {
129 await update($, isOff, () => arg === 'off')
130
131 return { text: arg === 'off' ? t.off : t.on }
132 }
133
134 const list = await read($, hits)
135 const state = t.state(await read($, isOff))
136
137 if (list.length === 0) {
138 return { text: t.none(state) }
139 }
140
141 const total = list.reduce((sum, hit) => sum + hit.count, 0)
142 const lines = list.map(hit => `- ${hit.tool} ×${hit.count} ${hit.where}`)
143
144 return { text: [t.total(state, total), ...lines].join('\n') }
145 })
146}
147hooks/i18n.ts 45 lines1import type { PluginOptions } from 'claude-code'
2
3export type Lang = 'en' | 'zh-TW' | 'zh-CN'
4
5const LANGS: readonly Lang[] = ['en', 'zh-TW', 'zh-CN']
6
7export const langOf = (options: PluginOptions): Lang => LANGS.find(lang => lang === options.language) ?? 'en'
8
9const en = {
10 mark: '…(masked)',
11 toast: (count: number, tool: string) => `Masked ${count} possible token${count === 1 ? '' : 's'} (${tool})`,
12 description: 'List possible tokens masked in this session; off / on to toggle',
13 off: 'off for this session.',
14 on: 'masking again.',
15 state: (isOff: boolean): string => (isOff ? 'off' : 'on'),
16 none: (state: string) => `masking ${state}, nothing masked in this session yet.`,
17 total: (state: string, total: number) => `masking ${state}, ${total} masked`,
18}
19
20const zhTW: typeof en = {
21 mark: '…(已遮)',
22 toast: (count, tool) => `遮了 ${count} 個疑似 token(${tool})`,
23 description: '列出這個 session 遮過的疑似 token;off / on 切換',
24 off: '這個 session 先不遮。',
25 on: '恢復遮蔽。',
26 state: isOff => (isOff ? '目前關閉' : '目前開啟'),
27 none: state => `${state},這個 session 還沒遮過東西。`,
28 total: (state, total) => `${state},共遮 ${total} 處`,
29}
30
31const zhCN: typeof en = {
32 mark: '…(已脱敏)',
33 toast: (count, tool) => `已脱敏 ${count} 个疑似 token(${tool})`,
34 description: '列出本次会话脱敏过的疑似 token;off / on 切换',
35 off: '本次会话暂停脱敏。',
36 on: '已恢复脱敏。',
37 state: isOff => (isOff ? '当前关闭' : '当前开启'),
38 none: state => `${state},本次会话还没有脱敏过内容。`,
39 total: (state, total) => `${state},共脱敏 ${total} 处`,
40}
41
42export type Strings = typeof en
43
44export const STRINGS: Record<Lang, Strings> = { en, 'zh-TW': zhTW, 'zh-CN': zhCN }
45hooks/mask.ts 56 lines1export type Masked = { text: string; count: number }
2
3// 每條規則兩個 capture:前綴照留,第二個是要遮的值
4const PREFIXED: readonly RegExp[] = [
5 /()(sk-[A-Za-z0-9_-]{20,})/g,
6 /()(gh[pousr]_[A-Za-z0-9]{30,})/g,
7 /()(github_pat_[A-Za-z0-9_]{40,})/g,
8 /()(xox[abprs]-[A-Za-z0-9-]{10,})/g,
9 /()(eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/g,
10 /()(AKIA[0-9A-Z]{16})/g,
11 /()(AIza[0-9A-Za-z_-]{35})/g,
12 /()(ya29\.[0-9A-Za-z_-]{20,})/g,
13 /(Bearer\s+)([A-Za-z0-9._~+/=-]{20,})/gi,
14]
15
16// KEY=值 和 "key": "值" 容易誤傷程式碼,所以值要夠長、同時有字母和數字才遮
17const ASSIGNED: readonly RegExp[] = [
18 /((?:^|[\s;])(?:export\s+)?[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY|ACCESS_KEY)[A-Z0-9_]*=["']?)([A-Za-z0-9_\-+/=]{16,})/gm,
19 /("[A-Za-z0-9_]*(?:token|secret|password|api_?key)[A-Za-z0-9_]*"\s*:\s*")([A-Za-z0-9_\-+/=.]{16,})(?=")/gi,
20]
21
22const PRIVATE_KEY =
23 /(-----BEGIN [A-Z ]*PRIVATE KEY-----)[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g
24
25const looksRandom = (value: string) => /[A-Za-z]/.test(value) && /[0-9]/.test(value)
26
27// 把文字裡疑似 token 的部分換成前 4 碼加標記,回傳遮了幾處
28export const maskText = (input: string, mark: string): Masked => {
29 let count = 0
30 let text = input.replace(PRIVATE_KEY, (_, head: string) => {
31 count += 1
32
33 return `${head}${mark}`
34 })
35
36 const apply = (pattern: RegExp, isStrict: boolean) => {
37 text = text.replace(pattern, (whole: string, prefix: string, secret: string) => {
38 if (isStrict && !looksRandom(secret)) {
39 return whole
40 }
41 count += 1
42
43 return `${prefix}${secret.slice(0, 4)}${mark}`
44 })
45 }
46
47 for (const pattern of PREFIXED) {
48 apply(pattern, false)
49 }
50 for (const pattern of ASSIGNED) {
51 apply(pattern, true)
52 }
53
54 return { text, count }
55}
56types/index.d.ts 8 lines1export type MaskHit = { tool: string; where: string; count: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'secret-mask': { hits: MaskHit[]; isOff: boolean }
6 }
7}
8