Secret tripwire: masks API keys, tokens, passwords and private keys in tool output before Claude, the transcript or your screen sees them, says so in a toast…

Eleven Claude Code mods (function-hook plugins) built around one workflow: many parallel side sessions, upstream PRs behind CodeRabbit, Codecov and Codex reviews, an Unraid server with about 90 containers, Home Assistant, and a local LLM on an RTX 3070. They are siblings of session-crew and borrow its savvy-progress crabs.
The mod API is early access. These mods were built and tested against Claude Code 2.1.295 (the desktop app's CLI); the declaration file that version ships is the authority.
| Mod | What you get | Commands | |
|---|---|---|---|
| where-am-i | Status line: 🍎 Mac · ha-unraid-management-agent · main · fork of tinkerologie. A note for Claude when, on the Mac, it SSHes to the Unraid host or the container, or runs the tests of a repo that is developed in the container. | /where | |
| roll-call | A pane where each side session's crab holds a sign: one line on where it stands, and a "needs you" lane (questions, approvals, boxes to tick). Lines come from Haiku, the local LLM, or the last message. | `/rollcall [open\ | linked] [text], tool report` |
| fuel-gauge | ⛽ ctx 14% · 5h 26% (34m) · wk 36% in the status line; toasts at 80% and 95% of a plan window and at 85% context; a pane with extra usage and every running side session's context. A band appears only while something runs hot. | /fuel [text] | |
| gpu-crab | While a local-llm tool runs: a crab on a treadmill above the prompt with the model, VRAM, GPU/CPU split (amber when it spills to CPU) and elapsed time; afterwards, a tally of what local models read today instead of the cloud. | /gpu | |
| vitals-strip | Unraid and HA health above the prompt (array and pools, CPU and memory, unhealthy or missing containers, unread alerts, parity, HA repairs). Quiet by default: only what isn't green. | `/vitals [show\ | quiet]` |
| reef | An aquarium pane: each container is a fish coloured by stack, unhealthy ones float belly-up, the GPU is a heater, HA is a lighthouse, side sessions are crabs on the sand. Hover a fish for its name. | /reef [text] | |
| keymaster | Masks API keys, tokens, passwords and private keys in tool output before Claude, the transcript or the screen sees them (‹masked arr-api-key 1a2b3c4d›), with a toast and a count. Withholds output it can't scan. | /keymaster, /keymaster allow <tag> | |
| trailer-guard | Refuses a git commit, gh pr create/edit or git push that would carry Co-Authored-By: Claude or the Claude Code footer into a repo whose remotes include tinkerologie, esphome or home-assistant (configurable). | /trailers | |
| gatekeeper | A pane of your open PRs by lane: needs you (boxes only you tick, green drafts), needs work (red checks, Codecov, CodeRabbit threads, conflicts), waiting. A band when something needs you. | /gates [text], tool gates | |
| codex-corner | A boxing ring above the prompt for Codex adversarial reviews: each run is a round with findings by severity, until the verdict is approve and the bot goes down. Kept per repo and branch. | /codex-card | |
| house-rules | Notes, asks and refusals on tool calls: appdata paths (/mnt/user/appdata vs the nvme8tb pool), CLAUDE.local.md in git, force-push to upstream, HA restarts. Add your own. | /rules, /rules add …, /rules off <id> |
Everything is read-only.
| Mod | Reads |
|---|---|
| roll-call, fuel-gauge, reef | The desktop app's session server (ccd_session_mgmt: list_sessions, list_events, get_usage) |
| vitals-strip | The HA-MCP connector: one ha_eval_template over the Unraid Management Agent integration's entities, and ha_get_overview. Allow both tools in permissions so auto mode doesn't review each read |
| reef | unraid-mcp (system array/metrics, docker list, notification overview, array parity_status) and the HA-MCP connector (ha_get_overview) |
| gpu-crab, reef | Ollama GET /api/ps at the ollamaUrl option (default http://localhost:11434) |
| gatekeeper | gh api graphql, one search query every 5 minutes and shortly after a push |
| where-am-i, trailer-guard, codex-corner | git in the session's checkout |
Server names, URLs and thresholds are options (userConfig) on each mod. The defaults assume nothing about your network: Ollama on localhost, no Home Assistant connector (vitals-strip and reef's lighthouse stay off until you name one), and no repo or SSH rules for where-am-i. Set your values in ~/.claude/settings.json:
"pluginConfigs": {
"gpu-crab": { "options": { "ollamaUrl": "http://192.168.1.50:11434" } },
"vitals-strip": { "options": { "haServer": "<your HA-MCP connector, as /mcp lists it>" } },
"where-am-i": { "options": { "homes": ["my-integration=container"], "containerTargets": ["my-nas"] } }
}
For one terminal session:
claude --plugin-dir ~/dev/crab-workshop/plugins/where-am-i --plugin-dir ~/dev/crab-workshop/plugins/fuel-gauge
For desktop-app sessions, add the folders to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (colon-separated, next to session-crew's), then start a new session:
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/session-crew:/path/to/crab-workshop/plugins/where-am-i:/path/to/crab-workshop/plugins/keymaster"
}
Or install from this folder as a marketplace, so /reload-plugins picks up edits:
claude plugin marketplace add ~/dev/crab-workshop
Then, in a terminal session, /plugin install keymaster@crab-workshop (and so on). Sessions on the UnRAID SSH connection run Claude Code inside the claude-code container, so the mods need the same setup there.
sh scripts/check.sh # every mod: type-check, claude plugin validate, claude plugin test
sh scripts/check.sh gatekeeper # one mod
shared/ holds the crab art (copied from savvy-progress through session-crew; see THIRD_PARTY_NOTICES.md) and the drawing helpers. scripts/sync-shared.sh copies them into each mod's hooks/shared/, because a mod can only import its own files. Edit shared/, never the copies.
AbovePrompt bands and Svg was checked with the test kit only, not by eye in the app.:8043) refused connections from the Mac, so vitals-strip and reef read through unraid-mcp instead./keymaster allow its tag.MIT, see LICENSE. The crab art is third-party; see THIRD_PARTY_NOTICES.md.
hooks/register.ts 82 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Finding } from '../types'
5import { findSecrets, glob, redactDeep, riskyCommand, tag } from './scan'
6
7const findings = atom({ plugin: 'keymaster', key: 'findings' } as const, [])
8
9const STORE_ALLOW = 'keymaster.allow'
10let skip: string[] = []
11let warnCommands = true
12let allowed = new Set<string>()
13
14async function record($: EngineInterface, tool: string, hits: { kind: string; where: string; value: string; tag: string }[]): Promise<number> {
15 const at = await $.clock.now()
16 const list = await update($, findings, l => [...l, ...hits.map(h => ({ kind: h.kind, tool, where: h.where, length: h.value.length, tag: h.tag, at }))].slice(-200))
17 $.ui.status(`🔒 ${list.length} masked`)
18 const kinds = [...new Set(hits.map(h => h.kind))].join(', ')
19 $.ui.toast(`🔒 Keymaster masked ${hits.length} secret${hits.length === 1 ? '' : 's'} (${kinds}) from ${tool.replace(/^mcp__([^_]+(?:_[^_]+)*)__/, '$1 ')} output`)
20 return list.length
21}
22
23async function loadAllowed($: EngineInterface): Promise<void> {
24 const v = (await $.store.get(STORE_ALLOW)) as string[] | undefined
25 allowed = new Set(Array.isArray(v) ? v : [])
26}
27
28export const register: Register = (on, options) => {
29 skip = ((options.skipTools as readonly string[] | undefined) ?? []).map(String)
30 warnCommands = options.warnCommands !== false
31
32 on('session.start', async ($, e, next) => {
33 const started = await next(e)
34 await loadAllowed($)
35 await $.command.register({ name: 'keymaster', description: 'What Keymaster masked this session (kind and place, never the value); allow a false positive by its tag', argumentHint: '[allow <tag> | disallow <tag>]' })
36 return started
37 })
38
39 on('tool.call', async ($, e, next) => {
40 const tool = String(e.tool)
41 if (skip.some(p => glob(p, tool))) return next(e)
42 const r = await next(e)
43 if (r.deny !== undefined) return r
44 const notes: string[] = []
45 if (warnCommands && tool === 'Bash') {
46 const note = riskyCommand(String((e as { command?: unknown }).command ?? ''))
47 if (note) notes.push(`keymaster: ${note}`)
48 }
49 const { value: result, hits } = redactDeep(r.result, tag, allowed)
50 if (!hits.length) {
51 // The text core made from the result should match it; if it alone holds a secret, withhold rather than pass it on.
52 if (typeof r.text === 'string' && findSecrets(r.text).some(h => !allowed.has(tag(h.value))))
53 return { deny: 'Keymaster withheld this output: it holds a key-like value it could not mask in place. Re-run the command so it prints only the fields you need.' }
54 return notes.length ? { ...r, context: [...(r.context ?? []), ...notes] } : r
55 }
56 await record($, tool, hits)
57 const kinds = [...new Set(hits.map(h => h.kind))].join(', ')
58 notes.push(
59 `keymaster: ${hits.length} value${hits.length === 1 ? '' : 's'} (${kinds}) in this output ${hits.length === 1 ? 'is' : 'are'} masked as ‹masked kind tag›. ` +
60 `They are hidden on purpose: don't print them another way (no cat, grep, base64 or env tricks). If one is clearly not a secret, ask David to run /keymaster allow <tag>.`,
61 )
62 return { result: result as typeof r.result, ...(r.isError ? { isError: true as const } : {}), context: [...(r.context ?? []), ...notes] }
63 }).catch(($, e, next) => (next.called ? { deny: 'Keymaster could not scan this output, so it withheld it.' } : next(e)))
64
65 on('command.run', { command: 'keymaster' }, async ($, e) => {
66 const [verb = '', arg = ''] = e.args.trim().split(/\s+/)
67 if (verb === 'allow' || verb === 'disallow') {
68 if (!/^[0-9a-f]{8}$/.test(arg)) return { text: 'Usage: /keymaster allow <tag> (the 8 hex digits in ‹masked kind tag›).' }
69 await loadAllowed($)
70 if (verb === 'allow') allowed.add(arg)
71 else allowed.delete(arg)
72 await $.store.set(STORE_ALLOW, [...allowed])
73 return { text: verb === 'allow' ? `Values tagged ${arg} pass through from now on.` : `Values tagged ${arg} are masked again.` }
74 }
75 const list: Finding[] = await read($, findings)
76 if (!list.length) return { text: `Nothing masked this session.${allowed.size ? ` Allowed tags: ${[...allowed].join(', ')}.` : ''}` }
77 const lines = list.slice(-30).map(f => `${f.tag} ${f.kind.padEnd(16)} ${String(f.length).padStart(4)} chars ${f.tool} → ${f.where}`)
78 return { text: [`Masked this session (${list.length}; the newest 30, values never kept):`, ...lines, allowed.size ? `Allowed tags: ${[...allowed].join(', ')}` : ''].filter(Boolean).join('\n') }
79 })
80}
81
82hooks/scan.ts 139 lines1// Pure detection for keymaster. Each detector names the part of a match that
2// is the secret (group `secret`), so the words around it survive.
3
4export type Hit = { kind: string; start: number; end: number; value: string }
5
6type Detector = { kind: string; re: RegExp }
7
8const ASSIGN_KEYS = [
9 'api[_-]?key', 'apikey', 'api[_-]?token', 'access[_-]?token', 'refresh[_-]?token', 'auth[_-]?token', 'token', 'secret', 'client[_-]?secret',
10 'password', 'passwd', 'pwd', 'passphrase', 'x-plex-token', 'x-api-key', 'access[_-]?key', 'secret[_-]?key', 'private[_-]?key',
11 'encryption[_-]?key', 'webhook[_-]?id', 'bearer', 'mqtt[_-]?pass(?:word)?', 'ha[_-]?token', 'license[_-]?key',
12].join('|')
13
14export const DETECTORS: Detector[] = [
15 { kind: 'private-key', re: /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----(?<secret>[\s\S]+?)-----END (?:[A-Z]+ )?PRIVATE KEY-----/g },
16 { kind: 'jwt', re: /(?<secret>eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/g },
17 { kind: 'github-token', re: /(?<secret>(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,}))/g },
18 { kind: 'anthropic-key', re: /(?<secret>sk-ant-[A-Za-z0-9_-]{20,})/g },
19 { kind: 'openai-key', re: /(?<![A-Za-z0-9])(?<secret>sk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,})/g },
20 { kind: 'aws-key', re: /(?<secret>(?:AKIA|ASIA)[0-9A-Z]{16})/g },
21 { kind: 'slack-token', re: /(?<secret>xox[abprs]-[A-Za-z0-9-]{10,})/g },
22 { kind: 'google-key', re: /(?<secret>AIza[0-9A-Za-z_-]{35})/g },
23 { kind: 'arr-api-key', re: /<ApiKey>(?<secret>[^<\s]{16,})<\/ApiKey>/g },
24 { kind: 'esphome-key', re: /\bkey:\s*["']?(?<secret>[A-Za-z0-9+/]{40,}={0,2})["']?/g },
25 { kind: 'bearer', re: /\b(?:Bearer|Token)\s+(?<secret>[A-Za-z0-9._~+/=-]{16,})/g },
26 { kind: 'url-password', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:(?<secret>[^\s@/]{3,})@/gi },
27 {
28 kind: 'assigned-secret',
29 // name = value, name: value, "name": "value", name=value in a query string; the value is the secret.
30 re: new RegExp(`(?<![A-Za-z0-9])["']?(?:${ASSIGN_KEYS})["']?\\s*[:=]\\s*["']?(?<secret>[^\\s"',;&<>{}()\\[\\]]{8,})`, 'gi'),
31 },
32]
33
34const PLACEHOLDER = /^(?:\$\{?[A-Z_][A-Z0-9_]*\}?|<[^>]*>|\*+|x{3,}|•+|‹masked[^›]*›|redacted|\[redacted\]|null|none|true|false|undefined|changeme|example|your[_-].*|\{\{.*\}\})$/i
35
36/** Whether a candidate value is a real-looking secret rather than a placeholder, path, number or masked text. */
37export function isSecretLike(kind: string, v: string): boolean {
38 if (PLACEHOLDER.test(v)) return false
39 if (v.includes('‹masked')) return false
40 if (kind === 'assigned-secret' || kind === 'bearer') {
41 if (/^[./~]/.test(v) || /^[a-z]+:\/\//i.test(v)) return false // a path or URL
42 if (/^\d+$/.test(v)) return false // a count or port
43 if (/^[a-z]+([_-][a-z]+)*$/.test(v) && v.length < 20) return false // a word like `required`, `file_path`
44 if (/^!secret\b/.test(v)) return false
45 }
46 return true
47}
48
49export function findSecrets(text: string): Hit[] {
50 const hits: Hit[] = []
51 for (const d of DETECTORS) {
52 d.re.lastIndex = 0
53 for (const m of text.matchAll(d.re)) {
54 const value = m.groups?.secret
55 if (!value || m.index === undefined) continue
56 const start = m.index + m[0].indexOf(value)
57 if (!isSecretLike(d.kind, value)) continue
58 hits.push({ kind: d.kind, start, end: start + value.length, value })
59 }
60 }
61 // Earlier and longer first; drop hits inside one already kept.
62 hits.sort((a, b) => a.start - b.start || b.end - a.end)
63 const kept: Hit[] = []
64 for (const h of hits) if (!kept.some(k => h.start < k.end && h.end > k.start)) kept.push(h)
65 return kept
66}
67
68export const mask = (kind: string, tag: string): string => `‹masked ${kind} ${tag}›`
69
70/** Masks every hit not allowed; `tagOf` names each value (a hash prefix). */
71export function redact(text: string, tagOf: (v: string) => string, allowed: ReadonlySet<string>): { text: string; hits: (Hit & { tag: string })[] } {
72 const hits = findSecrets(text)
73 .map(h => ({ ...h, tag: tagOf(h.value) }))
74 .filter(h => !allowed.has(h.tag))
75 if (!hits.length) return { text, hits }
76 let out = ''
77 let at = 0
78 for (const h of hits) {
79 out += text.slice(at, h.start) + mask(h.kind, h.tag)
80 at = h.end
81 }
82 return { text: out + text.slice(at), hits }
83}
84
85/** Walks a value, redacting every string; returns the new value and what it found, with where. */
86export function redactDeep(value: unknown, tagOf: (v: string) => string, allowed: ReadonlySet<string>, path = ''): { value: unknown; hits: (Hit & { tag: string; where: string })[] } {
87 if (typeof value === 'string') {
88 const r = redact(value, tagOf, allowed)
89 return { value: r.text, hits: r.hits.map(h => ({ ...h, where: path || 'output' })) }
90 }
91 if (Array.isArray(value)) {
92 const hits: (Hit & { tag: string; where: string })[] = []
93 const out = value.map((v, i) => {
94 const r = redactDeep(v, tagOf, allowed, path ? `${path}.${i}` : String(i))
95 hits.push(...r.hits)
96 return r.value
97 })
98 return { value: hits.length ? out : value, hits }
99 }
100 if (value && typeof value === 'object') {
101 const hits: (Hit & { tag: string; where: string })[] = []
102 const out: Record<string, unknown> = {}
103 for (const [k, v] of Object.entries(value)) {
104 const r = redactDeep(v, tagOf, allowed, path ? `${path}.${k}` : k)
105 hits.push(...r.hits)
106 out[k] = r.value
107 }
108 return { value: hits.length ? out : value, hits }
109 }
110 return { value, hits: [] }
111}
112
113/** Every string in a value, joined: what gets scanned and hashed. */
114export function strings(value: unknown, out: string[] = []): string[] {
115 if (typeof value === 'string') out.push(value)
116 else if (Array.isArray(value)) value.forEach(v => strings(v, out))
117 else if (value && typeof value === 'object') Object.values(value).forEach(v => strings(v, out))
118 return out
119}
120
121/** A note for commands that tend to print secrets, or null. */
122export function riskyCommand(command: string): string | null {
123 if (/\bdocker\s+(container\s+)?inspect\b/.test(command) && !/--format|-f\s/.test(command)) return 'docker inspect prints every environment variable, keys included; use --format with only the fields you need.'
124 if (/(^|[\s;&|])(printenv|env)(\s*($|[|;&]))/.test(command)) return 'printenv/env prints every variable, tokens included; print only the variable names (env | cut -d= -f1) or test the one you need.'
125 if (/\b(cat|less|head|tail|bat|grep)\b[^|;&]*\.env(\.[a-z]+)?\b/.test(command)) return '.env files hold secrets; read only the names (cut -d= -f1) unless a value is truly needed.'
126 if (/config\.xml\b/.test(command) && /(radarr|sonarr|prowlarr|lidarr|readarr|bazarr)/i.test(command)) return "An Arr config.xml holds the instance's ApiKey; grep for the fields you need with the key left out."
127 return null
128}
129
130export const glob = (pattern: string, s: string): boolean =>
131 new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$').test(s)
132
133/** FNV-1a as 8 hex digits: a stable tag for a value, never the value. */
134export function tag(value: string): string {
135 let h = 2166136261
136 for (let i = 0; i < value.length; i++) h = Math.imul(h ^ value.charCodeAt(i), 16777619) >>> 0
137 return h.toString(16).padStart(8, '0')
138}
139types/index.d.ts 19 lines1export type Finding = {
2 kind: string
3 tool: string
4 /** Where in the output: the field path (stdout, content.0.text). */
5 where: string
6 length: number
7 /** An 8-hex-digit FNV-1a tag of the value (never the value): what /keymaster allow takes. */
8 tag: string
9 at: number
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 keymaster: {
15 findings: Finding[]
16 }
17 }
18}
19