Masks API keys and tokens in tool output before Claude or the transcript sees them, and refuses writing them into source files

Claude Code mods: hooks modules that change Claude's interface and behaviour, published as the god-mods plugin marketplace.
In Claude Code:
/plugin marketplace add KZ-HQ/god-mods
/plugin install danger-guard@god-mods
Install the mods you want one by one. Restart Claude Code (a full restart; /reload-plugins does not load new mod code) for them to take effect.
| Mod | What it does |
|---|---|
| agent-spend | /spend [days] [here]: pane of subagent spend by model and agent type, with the costliest agents, from subagent transcripts |
| context-gauge | Band above the prompt: context fill, last turn's cost, session cost and rate limits; /gauge toggles it |
| danger-guard | Holds risky shell commands and writes to protected data, previews what they would change, and asks before they run |
| long-turn-notify | macOS notification when a long turn finishes or Claude needs your input, in terminal sessions |
| merge-gate | Holds gh pr merge until CI passes on the PR head; /gate runs the project's CI steps locally |
| mod-stats | /mod-stats [days]: what each mod did, cost and saved, and how often it faulted |
| mod-faults | Passive: records any god-mod hook that fails, times out or is refused, and toasts at a mod's third fault in a session; /mod-faults. It sees only mods beneath it: put mod-faults@god-mods first in enabledPlugins (that order is the chain order) |
| next-steps | Suggests 2–3 likely next prompts after each turn; press 1/2/3 to draft one; /next off |
| prompt-shaper | Expands your shorthand and attaches git state to each prompt; adds a project-toolchain note to the system prompt |
| secret-scrubber | Masks API keys and tokens in tool output, and refuses to write them into source files |
| test-on-edit | When Claude's answer ends, runs the tests of the files it edited; failures go to Claude with your next prompt |
Installed mods nest in the order of enabledPlugins in ~/.claude/settings.json, first outermost; a plugin can't choose its place. mod-faults only sees faults in mods beneath it, so keep it first:
"enabledPlugins": {
"mod-faults@god-mods": true,
"danger-guard@god-mods": true,
...
}
Plugins installed later are appended, so they land beneath it. Reinstalling mod-faults moves it to the end again; its start-up toast and /mod-faults name any mods it can't see.
Every mod appends local JSON lines to ~/.claude/mods/metrics.jsonl; nothing leaves your machine. Rows (schema v: 2) carry t, mod, event, ver, session and project:
loaded: once per session start, with the mod's versionturn: once per turn, counting each hook's calls, e.g. {"tool.call": 3, "command.run{goal}": 1}verdictRows hold counts, kinds and lengths only, never prompt, command or tool-input text. /mod-stats summarises them. mod-faults adds a fault row per failed hook, naming the mod and event; it no longer writes ~/.claude/logs/god-mods-faults.jsonl, which can be deleted.
Each mod is a folder with .claude-plugin/plugin.json and hooks/register.tsx, plus tests in tests/.
The metrics code lives once in shared/metrics.ts and is inlined into every register.tsx between // <metrics> markers, because the engine only lets $ into functions declared in the same file. Edit the shared file, then:
shared/sync.sh # inline into every mod
shared/sync.sh --check # fail if any copy is stale
Check a mod before committing:
claude plugin validate <mod>
claude plugin test <mod>
Bump the mod's version in its plugin.json and in .claude-plugin/marketplace.json together.
hooks/register.tsx 155 lines1import type { EngineInterface, On, Register } from 'claude-code'
2
3import { SECRET_STORE, isExample, scrub } from './patterns.ts'
4
5type Block = { type: string; text?: string; content?: unknown; [k: string]: unknown }
6
7// A tool_result's content is a string or a list of blocks; only text is touched
8function scrubContent(content: unknown, kinds: Set<string>): unknown {
9 if (typeof content === 'string') {
10 const s = scrub(content)
11 s.kinds.forEach(k => kinds.add(k))
12
13 return s.text
14 }
15 if (Array.isArray(content)) {
16 return content.map((b: Block) => {
17 if (b?.type !== 'text' || typeof b.text !== 'string') return b
18 const s = scrub(b.text)
19 s.kinds.forEach(k => kinds.add(k))
20
21 return { ...b, text: s.text }
22 })
23 }
24
25 return content
26}
27
28// <metrics: generated from shared/metrics.ts by shared/sync.sh>
29// Shared mod metrics (schema v2). Source of truth: ~/.claude/mods/shared/metrics.ts.
30// shared/sync.sh inlines it between the metrics markers in each mod's register.tsx (the engine only lets $
31// into functions declared in the same file). Edit here, run sync.sh, never edit an inlined copy.
32//
33// Rows appended to ~/.claude/mods/metrics.jsonl, all with { v: 2, t, mod, event, ver, session, project }:
34// loaded once per load (logLoaded, or count() in the mod's session.start hook), at session.start: proves which version of the mod is running
35// turn once per turn with activity (logTurns, or count() in the mod's turn.complete hook): { hooks: { <event>: calls } }
36// <other> outcome rows from track(), e.g. merge-gate 'verdict'
37// Privacy: never put prompt, command, tool-input or matched-secret text in a row; lengths, kinds and counts only.
38
39const APPEND = 'mkdir -p "$HOME/.claude/mods" && printf "%s\\n" "$1" >> "$HOME/.claude/mods/metrics.jsonl"'
40
41
42const ctx = { ver: '', session: '', project: '' }
43const stats = new Map<string, number>()
44let writeFailed = false
45
46const message = (err: unknown) => (err instanceof Error ? err.message : String(err)).slice(0, 300)
47
48function write($: EngineInterface, mod: string, event: string, data: Record<string, unknown>): void {
49 void (async () => {
50 const t = new Date(await $.clock.now()).toISOString()
51 if (!ctx.project) ctx.project = (await $.session.cwd()).split('/').filter(Boolean).pop() ?? ''
52 const line = JSON.stringify({ v: 2, t, mod, event, ver: ctx.ver, session: ctx.session, project: ctx.project, ...data })
53 const { exitCode, stderr } = await $.process.run(['/bin/sh', '-c', APPEND, 'mod-metrics', line], { timeoutMs: 3000 })
54 if (exitCode !== 0) throw new Error(stderr.trim() || `exit ${exitCode}`)
55 })().catch((err: unknown) => {
56 if (writeFailed) return
57 writeFailed = true
58 $.ui.toast(`${mod}: metrics write failed (${message(err)})`)
59 })
60}
61
62// An outcome row: something the mod decided or did. Fire-and-forget.
63function track($: EngineInterface, mod: string, event: string, data: Record<string, unknown> = {}): void {
64 write($, mod, event, data)
65}
66
67async function loaded($: EngineInterface): Promise<void> {
68 const mod = $.plugin.name
69 ctx.session = await $.session.id().catch(() => '')
70 ctx.project = ''
71 try {
72 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }
73 ctx.ver = manifest.version ?? ''
74 } catch {
75 ctx.ver = '?'
76 }
77 write($, mod, 'loaded', {})
78}
79
80function flush($: EngineInterface): void {
81 const mod = $.plugin.name
82 if (!stats.size) return
83 const hooks = Object.fromEntries(stats)
84 stats.clear()
85 write($, mod, 'turn', { hooks })
86}
87
88// The engine allows one hook per event per plugin, and counts them by spelling. A mod that hooks
89// session.start or turn.complete itself calls count() in that hook, which writes the row; otherwise
90// it calls the matching helper once in register.
91function logLoaded(on: On): void {
92 on('session.start', async ($, e, next) => {
93 await loaded($)
94
95 return next(e)
96 })
97}
98
99function logTurns(on: On): void {
100 on('turn.complete', async ($, e, next) => {
101 flush($)
102
103 return next(e)
104 })
105}
106
107// First statement of every hook: counts the call into this turn's row. The engine only accepts
108// function literals as hooks, so calls are counted from inside rather than by wrapping.
109// In a session.start hook it also writes the loaded row; in a turn.complete hook it writes the turn row.
110function count($: EngineInterface, event: string): void {
111 stats.set(event, (stats.get(event) ?? 0) + 1)
112 if (event === 'session.start') void loaded($)
113 if (event === 'turn.complete') flush($)
114}
115// </metrics>
116
117export const register: Register = on => {
118 logLoaded(on)
119 logTurns(on)
120
121 // Tool output, before the model reads it or the transcript stores it
122 on('session.append', async ($, e, next) => {
123 count($, 'session.append')
124 if (e.door !== 'tool-result') return next(e)
125 const kinds = new Set<string>()
126 const content = (e.message.content as unknown as Block[]).map(b =>
127 b.type === 'tool_result' ? { ...b, content: scrubContent(b.content, kinds) } : b,
128 )
129 if (kinds.size === 0) return next(e)
130 track($, 'secret-scrubber', 'masked', { kinds: [...kinds] })
131 $.ui.toast(`secret-scrubber: masked ${[...kinds].join(', ')} in tool output`)
132
133 return next({ ...e, message: { ...e.message, content: content as never } })
134 })
135
136 // A key written into source ends up in git; it belongs in an env file
137 on('tool.call', async ($, e, next) => {
138 count($, 'tool.call')
139 if (e.tool !== 'Write' && e.tool !== 'Edit') return next(e)
140 const path = String(e.file_path)
141 if (SECRET_STORE.test(path) || isExample(path)) return next(e)
142 const text = e.tool === 'Write' ? String(e.content) : String(e.new_string)
143 const { kinds } = scrub(text)
144 const real = kinds.filter(k => k !== 'assigned')
145 if (real.length === 0) return next(e)
146 track($, 'secret-scrubber', 'blocked', { kinds: real, tool: e.tool })
147
148 return {
149 deny:
150 `secret-scrubber: this ${e.tool} puts a ${real.join(', ')} key into ${path}. ` +
151 'Read it from an environment variable (.env / .dev.vars, or the platform secret store) instead of writing the value into source.',
152 }
153 })
154}
155hooks/patterns.ts 48 lines1// Pure helpers: no `$`, so tests call them directly.
2
3// Key shapes with a recognisable prefix: few false positives
4const SECRETS: readonly [kind: string, re: RegExp][] = [
5 ['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g],
6 ['anthropic', /\bsk-ant-[A-Za-z0-9_-]{20,}/g],
7 ['openai', /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}/g],
8 ['github', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})/g],
9 ['aws', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g],
10 ['slack', /\bxox[abprs]-[A-Za-z0-9-]{10,}/g],
11 ['stripe', /\b[rs]k_live_[A-Za-z0-9]{20,}/g],
12 ['google', /\bAIza[0-9A-Za-z_-]{35}\b/g],
13 ['mailgun', /\bkey-[0-9a-f]{32}\b/g],
14 ['jwt', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
15]
16
17// `API_KEY=...`, `"password": "..."`: the value only, and only when it looks random
18const ASSIGNED = /\b([A-Za-z_]*(?:api[_-]?key|secret|token|passw(?:or)?d|client[_-]?secret)[A-Za-z_]*)(\s*[:=]\s*["']?)([A-Za-z0-9/+_.-]{20,})/gi
19const looksRandom = (v: string) => /[a-z]/.test(v) && /[A-Z0-9]/.test(v) && !/^(?:true|false|null|undefined|process\.env|\$\{)/i.test(v)
20
21export type Scrubbed = { text: string; kinds: string[] }
22
23export function scrub(text: string): Scrubbed {
24 const kinds = new Set<string>()
25 let out = text
26 for (const [kind, re] of SECRETS) {
27 out = out.replace(re, () => {
28 kinds.add(kind)
29
30 return `[REDACTED ${kind}]`
31 })
32 }
33 out = out.replace(ASSIGNED, (all, name: string, sep: string, value: string) => {
34 if (!looksRandom(value) || value.startsWith('[REDACTED')) return all
35 kinds.add('assigned')
36
37 return `${name}${sep}[REDACTED]`
38 })
39
40 return { text: out, kinds: [...kinds] }
41}
42
43// Files whose job is to hold secrets: writing one there is the right place
44export const SECRET_STORE = /(^|\/)(\.env(\.[\w.-]+)?|\.dev\.vars(\.[\w.-]+)?|\.npmrc|\.netrc|[^/]*\.(pem|key|p12))$/
45
46// Example files show the shape, not a real value
47export const isExample = (path: string) => /(^|\/)[^/]*(example|sample|template)[^/]*$/i.test(path)
48