SLOPSHOPPER

secret-mask

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

newguardcommandtoast
★ 2v0.1.0MITupdated 2026-10-03homieyangg/claude-code-mods/secret-mask
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-mask
› fix the failing auth test and add an audit log call ╭────────────────────────────────╮ │ secret-mask │ ⏺ Read(src/auth.ts) │ Masked 1 possible token (Bash) │ ⎿ Read 6 lines ╰────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /secret-mask ⎿ secret-mask: masking on, 1 masked ⎿ secret-mask: - Bash ×1 cat .env ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

English | 繁體中文 | 简体中文

Three mods for Claude Code, built as function hook plugins. Requires Claude Code 2.1.287 or later.

plan-bar, leftovers and secret-mask in one session

ModWhat it doesCommand
plan-barProgress bars above the prompt while Claude works through a multi-step plan/plans
leftoversKeeps a ledger of services, containers, backups and repos Claude left behind/leftovers
secret-maskMasks token-like strings in tool output before the model sees them/secret-mask

Install

From this repo as a marketplace:

/plugin marketplace add homieyangg/claude-code-mods
/plugin install plan-bar@claude-code-mods
/plugin install leftovers@claude-code-mods
/plugin install secret-mask@claude-code-mods
/reload-plugins

Or clone it and load the folders directly. Edits reload while a session is running.

git clone https://github.com/homieyangg/claude-code-mods ~/claude-code-mods
claude --plugin-dir ~/claude-code-mods/plan-bar --plugin-dir ~/claude-code-mods/leftovers

To load them in every session, add the folders to ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/claude-code-mods/plan-bar:~/claude-code-mods/leftovers:~/claude-code-mods/secret-mask"
  }
}

plan-bar

plan-bar demo

Each plan gets one row above the prompt: the current stage, a bar split by stage, and a percentage. The row turns yellow while Claude is waiting on you and red when a task fails. A short sound plays when a stage finishes, when the plan waits, and when it ends.

Claude drives the bars itself. The mod registers two tools, plan_set and plan_update, and adds a short note to the system prompt asking Claude to use them for work with three or more steps.

Command
/plansList the current plans
/plans demoRun a 20 second demo
/plans clearRemove all plans
/plans sound off / onTurn sounds off or on

leftovers

leftovers demo

leftovers watches the Bash commands Claude runs, locally and over ssh, and writes down anything that keeps running or stays on disk after the session:

KindRecorded from
launchdlaunchctl bootstrap, launchctl load
systemdsystemctl enable, systemctl start
croncrontab edits
dockerdocker run -d, docker compose up -d
backgroundnohup, tmux new -d
repogh repo create, git worktree add
backupcopies or moves to .bak, .orig, .old

It also tracks git repos Claude edited and shows the ones with uncommitted changes.

Above the prompt, a yellow dot shows how many are left next to the first item; click the count to expand the rest. /leftovers shows the full list grouped by machine. Clean up asks Claude to remove the item using the undo command it recorded, Done drops it from the ledger. Uncommitted repos get See changes and Ignore. When Claude runs the undo command itself (docker rm -f, systemctl disable, git worktree remove and so on) the item drops off on its own. The ledger is kept across sessions.

leftovers list

Command
/leftoversShow the list
/leftovers drop <n>Remove item n from the ledger
/leftovers clearEmpty the ledger

secret-mask

secret-mask demo

When a Bash command prints something that looks like a credential, secret-mask replaces it with its first four characters and …(masked) before the output reaches the model. Results from other tools, such as MCP tools and web fetches, are masked when they are written to the conversation. A toast says how many were masked, and /secret-mask lists them.

It recognizes:

  • API keys starting with sk-
  • GitHub tokens: ghp_, gho_, ghu_, ghs_, ghr_, github_pat_
  • Slack tokens: xoxa-, xoxb-, xoxp-, xoxr-, xoxs-
  • JWTs, AWS access key IDs (AKIA…), Google API keys (AIza…) and OAuth tokens (ya29.…)
  • Bearer <token> headers and PEM private key blocks
  • NAME=value and JSON fields whose name contains TOKEN, SECRET, PASSWORD, API_KEY, PRIVATE_KEY or ACCESS_KEY, when the value is at least 16 characters and mixes letters and digits

This is pattern matching, so it will miss things. Results from Read, Edit, Write and NotebookEdit are left alone, because Claude needs the real file contents to edit them. Treat it as a guard against accidents, not as a way to hand Claude a file full of secrets.

Command
/secret-maskList what was masked in this session
/secret-mask off / onPause or resume masking for this session

Clicking the buttons

The buttons above the prompt and in /leftovers take mouse clicks only in Claude Code's fullscreen mode. The default renderer does not turn on mouse reporting, so clicks never reach it. Turn fullscreen on in /config, or add "tui": "fullscreen" to ~/.claude/settings.json. In the default mode, press ctrl+x then tab to move into the row above the prompt, tab to pick a button and Enter to press it.

Language

Each mod has a language option: en (default), zh-TW or zh-CN. Change it in /config, or in ~/.claude/settings.json:

{
  "pluginConfigs": {
    "leftovers@claude-code-mods": { "options": { "language": "zh-TW" } }
  }
}

Use leftovers@inline as the key when the mod is loaded with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS.

/plugin install notes that the option is not set yet. You can ignore that; it falls back to en.

Development

claude plugin validate ./leftovers
claude plugin test ./leftovers

The demo recordings are made with VHS. media/tapes/setup-demo.sh builds a separate Claude Code config in ~/.claude-demo and a sample project in ~/acme-app; log in once with CLAUDE_CONFIG_DIR=~/.claude-demo claude auth login, then run media/tapes/record.sh plan-bar leftovers secret-mask hero.

License

MIT

Source 4 files
hooks/register.ts 147 lines
1import { 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}
147
hooks/i18n.ts 45 lines
1import 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 }
45
hooks/mask.ts 56 lines
1export 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}
56
types/index.d.ts 8 lines
1export 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