SLOPSHOPPER

keymaster

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…

newguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · keymaster
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ keymaster │ ⏺ Read(src/auth.ts) │ 🔒 Keymaster masked 2 secrets │ ⎿ Read 6 lines │ (url-password, assigned-secret) from Bash │ ⏺ Update(src/auth.ts) │ output │ ⎿ 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 › /keymaster ⎿ keymaster: Masked this session (2; the newest 30, values never kept): ⎿ keymaster: f181ec53 url-password 7 chars Bash → output ⎿ keymaster: 4e14359b assigned-secret 35 chars Bash → output ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ keymaster: 🔒 2 masked
README

crab-workshop

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.

ModWhat you getCommands
where-am-iStatus 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-callA 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-crabWhile 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-stripUnraid 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]`
reefAn 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]
keymasterMasks 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-guardRefuses 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
gatekeeperA 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-cornerA 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-rulesNotes, 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>

Data sources

Everything is read-only.

ModReads
roll-call, fuel-gauge, reefThe desktop app's session server (ccd_session_mgmt: list_sessions, list_events, get_usage)
vitals-stripThe 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
reefunraid-mcp (system array/metrics, docker list, notification overview, array parity_status) and the HA-MCP connector (ha_get_overview)
gpu-crab, reefOllama GET /api/ps at the ollamaUrl option (default http://localhost:11434)
gatekeepergh api graphql, one search query every 5 minutes and shortly after a push
where-am-i, trailer-guard, codex-cornergit 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"] } }
}

Trying them

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.

Developing

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.

Known limits

  • The desktop Code tab's drawing of AbovePrompt bands and Svg was checked with the test kit only, not by eye in the app.
  • The Unraid Management Agent's own API (:8043) refused connections from the Mac, so vitals-strip and reef read through unraid-mcp instead.
  • keymaster masks what looks like a secret. A real value that slips past its patterns is not caught, and a fake one in a test fixture is masked until you /keymaster allow its tag.
  • gatekeeper reads the first 50 PRs of its search (newest first).

License

MIT, see LICENSE. The crab art is third-party; see THIRD_PARTY_NOTICES.md.

Source 3 files
hooks/register.ts 82 lines
1import { 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
82
hooks/scan.ts 139 lines
1// 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}
139
types/index.d.ts 19 lines
1export 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