Keeps secret values out of the chat: write {{secret:NAME}} in a Bash command and the value is filled in at execution time from the OS keyring, and any known…

Claude Code mods you can install one at a time. guard-rules, secret-box, prompt-gate and skill-pick also run on Codex and OpenCode, and statusline and delegation-board on OpenCode; what works on which host, and why.
Mods are plugins built on the hooks-module API. They run with your permissions and are not sandboxed: read a mod before installing it. Each mod lists what leaves your machine (egress) in its README and in mods.json.
claude plugin marketplace add iuliandita/mods claude plugin install <mod>@iuliandita-mods
Run it in a terminal. It works for Claude Code and loads in Claude Desktop sessions too.
These calls install from the GitHub marketplace. From a clone, scripts/mods install <mod> registers that clone as the marketplace instead (or --source <owner/repo>).
For Codex and OpenCode, see the mod's README and INSTALL.md. OpenCode hook plugins use opencode.json; its terminal UI plugins use the global cli.json instead. Prompt advisers make model calls even in their default dry mode; check the host's costs and privacy limits before installing them.
guard-rules: blocks AI attribution in commits and PRs, direct commits to protected branches, commits under the wrong git identity, and mutating kubectl, helm and terraform calls on protected or unclassified environments.secret-box: keeps secret values out of the chat; a secret placeholder in a Bash command is filled in at execution time from the OS keyring.prompt-gate: before the main model spends a turn on a vague prompt, a small model says so and asks one short question. With a TypeSafe API key in the environment the prompt goes to a third party (Jev); jevPaths restricts where.skill-pick: names the 1 to 5 installed skills that fit each prompt. With a TypeSafe API key the prompt and skill descriptions go to a third party (Jev); jevPaths restricts where.delegation-board: a live pane of the session's subagents and teammates: type, model, effort, state, elapsed time and tokens; in Claude Desktop, a compact band above the prompt.statusline: a two-row status line above the prompt in the terminal and Claude Desktop: model and effort, git state, context against your auto-compact window, 5h and 7d limits with pace, cache, cost and time. In a git repo with a GitHub or GitLab remote it runs gh or glab for PR and issue counts; forge=off turns that off.mods.json has the full catalog: egress, modes, requirements, and the install command per mod. All six mods have recorded Claude terminal checks; the compatibility page lists the tested behavior and versions. Other Claude surfaces remain unverified.
Put your own mods in local/mods/<name>/ (gitignored) and install them with scripts/mods install <name> --local.
MIT
Made by iuliandita. Other projects.
See installation and recovery, contributing, documentation, and private security reporting.
agents provides global prompts and role definitions; skills provides individually installed task guidance. Installing one collection does not install the others.
hooks/register.ts 267 lines1import type { EngineInterface as Engine, Register } from 'claude-code'
2
3import { makeScrubber, MIN_LEN, placeholderNames, quote, rewriteCommand, scrubBlocks, scrubToolAnswer, withhold, type Block, type Scrubber } from './scrub'
4
5type Mode = 'off' | 'dry' | 'on'
6
7// An unreadable option counts as on, so a typo cannot switch the box off.
8const parseMode = (v: unknown): Mode => (v === 'off' || v === 'dry' ? v : 'on')
9
10function wrapperOf($: Engine): string {
11 return `${$.plugin.root}/bin/secret-box`
12}
13
14const LOAD_FAILED = (wrapper: string) =>
15 `secret-box: the stored secrets could not be loaded, so the command was blocked before it ran (fail closed). Check that secret-tool works and the keyring is unlocked (run "${quote(wrapper)} names" in a terminal), or set mode to off or dry in the plugin options. The placeholders were left as written and no value was read.`
16
17const RETRY_MS = 30_000
18// The engine rejects an $.fs.read over 4 MiB.
19const MAX_SAVED_BYTES = 4 * 1024 * 1024
20
21type State = {
22 mode: Mode
23 known: Map<string, string> | null
24 scrubber: Scrubber | null
25 inflight: Promise<boolean> | null
26 failedAt: number | null
27 warned: boolean
28 fileWarned: boolean
29}
30
31async function nowMs($: Engine): Promise<number> {
32 try {
33 return await $.clock.now()
34 } catch {
35 return Date.now()
36 }
37}
38
39// Reads every stored value through the wrapper. Never throws and never puts a value in a message.
40async function fetchValues($: Engine, s: State): Promise<boolean> {
41 try {
42 const r = await $.process.run([wrapperOf($), 'values'], { timeoutMs: 10_000 })
43 if (r.exitCode !== 0 || r.isStdoutTruncated) return false
44 const raw: unknown = JSON.parse(r.stdout)
45 if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return false
46 const values = new Map<string, string>()
47 for (const [name, value] of Object.entries(raw)) if (typeof value === 'string') values.set(name, value)
48 s.known = values
49 s.scrubber = makeScrubber(values)
50 return true
51 } catch {
52 return false
53 }
54}
55
56function load($: Engine, s: State): Promise<boolean> {
57 s.inflight ??= (async () => {
58 try {
59 const ok = await fetchValues($, s)
60 s.failedAt = ok ? null : await nowMs($)
61 if (!ok && s.known === null && !s.warned) {
62 s.warned = true
63 $.ui.toast('secret-box: scrubbing is off, could not load secrets')
64 }
65 return ok
66 } finally {
67 s.inflight = null
68 }
69 })()
70 return s.inflight
71}
72
73// Waits for a load in flight; after a failed load, tries again at most once per RETRY_MS.
74async function scrubberFor($: Engine, s: State): Promise<Scrubber | null> {
75 if (s.known !== null) return s.scrubber
76 if (s.inflight) await s.inflight
77 else if (s.failedAt === null || (await nowMs($)) - s.failedAt >= RETRY_MS) await load($, s)
78 return s.scrubber
79}
80
81// The engine saves a long output as a direct child of a directory named tool-results.
82function inToolResults(p: string | undefined): boolean {
83 if (typeof p !== 'string' || !p.startsWith('/')) return false
84 const parts = p.slice(1).split('/')
85 if (parts.some((x) => x === '' || x === '.' || x === '..')) return false
86 return parts.length >= 2 && parts[parts.length - 2] === 'tool-results'
87}
88
89async function warnFile($: Engine, s: State, reason: string): Promise<void> {
90 if (s.fileWarned) return
91 s.fileWarned = true
92 try {
93 await $.ui.toast(`secret-box: could not scrub the saved output file (${reason}); it may contain a secret`)
94 } catch {
95 // a toast must never surface as an error
96 }
97}
98
99// Rewrites the file a Bash result points at (the saved full output) with known values replaced.
100// Never throws and never puts a path or text in a message; a failure leaves the file as it is and
101// toasts once per session.
102async function scrubSavedFile($: Engine, s: State, scrub: Scrubber, answer: unknown): Promise<void> {
103 try {
104 const rec = (answer as { result?: unknown } | null)?.result
105 if (rec === null || typeof rec !== 'object') return
106 const paths = new Set<string>()
107 for (const k of ['persistedOutputPath', 'rawOutputPath']) {
108 const p = (rec as Record<string, unknown>)[k]
109 if (typeof p === 'string' && p !== '') paths.add(p)
110 }
111 for (const path of paths) {
112 let reason = 'unreadable'
113 try {
114 const st = await $.fs.stat(path, { resolve: true })
115 if (st.kind !== 'file' || st.isLink || !inToolResults(path) || !inToolResults(st.realPath)) {
116 reason = 'unexpected-path'
117 throw new Error(reason)
118 }
119 if (st.size > MAX_SAVED_BYTES) {
120 reason = 'too-large'
121 throw new Error(reason)
122 }
123 const text: unknown = await $.fs.read(path)
124 if (typeof text !== 'string') throw new Error('binary')
125 reason = 'scrub-failed'
126 const r = scrub(text)
127 if (r.text === text) continue
128 reason = 'write-failed'
129 await $.fs.write(path, r.text)
130 } catch {
131 await warnFile($, s, reason)
132 }
133 }
134 } catch {
135 // the file step must never throw to the engine
136 }
137}
138
139// The values a command ran with stay in its own scrub even when the reload after it dropped or changed them.
140function scrubberAfter(s: State, before: ReadonlyMap<string, string> | null, names: string[]): Scrubber | null {
141 const stale = names.flatMap((n) => {
142 const v = before?.get(n)
143 return v !== undefined && s.known?.get(n) !== v ? [[n, v] as const] : []
144 })
145 return stale.length === 0 ? s.scrubber : makeScrubber([...(s.known ?? []), ...stale])
146}
147
148function status($: Engine, s: State, reloaded: boolean | null): string {
149 const wrapper = wrapperOf($)
150 const known = s.known
151 const names = known ? [...known.keys()].sort() : []
152 const short = known ? names.filter((n) => (known.get(n) as string).length < MIN_LEN) : []
153 const lines = [`secret-box: mode ${s.mode}`]
154 if (s.mode === 'off') lines.push('secrets: not loaded (mode is off)')
155 else if (known === null) lines.push('secrets: could not be loaded (check secret-tool and that the keyring is unlocked)')
156 else lines.push(`secrets: ${names.length ? names.join(', ') : 'none stored'}${reloaded === false ? ' (reload failed, showing the last load)' : ''}`)
157 if (short.length) lines.push(`not scrubbed (shorter than ${MIN_LEN} characters): ${short.join(', ')}`)
158 lines.push(`wrapper: ${wrapper}`, `add one with: ${quote(wrapper)} set NAME (in a terminal; use {{secret:NAME}} in commands)`)
159 return lines.join('\n')
160}
161
162export const register: Register = (on, options) => {
163 const s: State = { mode: parseMode(options.mode), known: null, scrubber: null, inflight: null, failedAt: null, warned: false, fileWarned: false }
164
165 on('session.start', async ($, e, next) => {
166 await $.command.register({
167 name: 'secret-box',
168 description: 'Show secret-box status, or reload the stored secrets.',
169 argumentHint: '[reload]',
170 })
171 if (s.mode !== 'off') await load($, s)
172 return next(e)
173 })
174
175 on('command.run', { command: 'secret-box' }, async ($, e) => {
176 const arg = e.args.trim()
177 if (arg !== '' && arg !== 'reload') return { text: 'secret-box: usage is /secret-box or /secret-box reload.' }
178 const reloaded = arg === 'reload' && s.mode !== 'off' ? await load($, s) : null
179 return { text: status($, s, reloaded) }
180 }).catch(() => ({ text: 'secret-box: the command failed. Check the debug log.' }))
181
182 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
183 if (s.mode === 'off') return next(e)
184 const names = placeholderNames(e.command)
185 if (names.length === 0) {
186 if (s.mode !== 'on') return next(e)
187 const scrub = await scrubberFor($, s)
188 const answer = await next(e)
189 if (!scrub) return answer
190 const clean = scrubToolAnswer(answer, scrub)
191 await scrubSavedFile($, s, scrub, clean)
192 return clean
193 }
194 if (s.mode === 'dry') {
195 $.ui.toast(`secret-box (dry run): would resolve ${names.join(', ')} at execution time`)
196 return next(e)
197 }
198 const wrapper = wrapperOf($)
199 try {
200 await $.fs.stat(wrapper)
201 } catch {
202 return { deny: `secret-box: the helper ${wrapper} was not found, so the command was blocked (fail closed). Reinstall the plugin.` }
203 }
204 const known = s.known
205 if ((known === null || names.some((n) => !known.has(n))) && !(await load($, s))) {
206 return { deny: LOAD_FAILED(wrapper) }
207 }
208 const before = s.known
209 const result = await next({ ...e, command: rewriteCommand(wrapper, e.command) })
210 await load($, s)
211 const scrub = scrubberAfter(s, before, names)
212 if (!scrub) return result
213 const clean = scrubToolAnswer(result, scrub)
214 await scrubSavedFile($, s, scrub, clean)
215 return clean
216 }).catch(async ($, e, next) => {
217 if (next.called) {
218 const answer = await next(e)
219 return s.mode === 'on' && s.scrubber ? scrubToolAnswer(answer, s.scrubber) : answer
220 }
221 if (s.mode !== 'off' && e.tool === 'Bash' && placeholderNames((e as { command?: string }).command ?? '').length > 0) {
222 return { deny: 'secret-box: the hook failed, so the command was blocked before it ran (fail closed). Check the debug log, or set mode to off or dry in the plugin options. The placeholders were left as written and no value was read.' }
223 }
224 return next(e)
225 })
226
227 // Every door: any row kind can carry a value (an @-mentioned file, a diff, another agent's message).
228 on('session.append', async ($, e, next) => {
229 if (s.mode === 'off') return next(e)
230 const scrub = await scrubberFor($, s)
231 if (!scrub) return next(e)
232 const r = scrubBlocks(e.message.content, scrub)
233 if (r.names.length === 0) return next(e)
234 if (s.mode === 'dry') {
235 $.ui.toast(`secret-box (dry run): would replace ${r.names.join(', ')} in a ${e.door} row`)
236 return next(e)
237 }
238 return next({ ...e, message: { ...e.message, content: r.content } })
239 }).catch((_$, e, next) => {
240 if (next.called || s.mode !== 'on' || !s.scrubber) return next(e)
241 let content: Block[]
242 try {
243 content = scrubBlocks(e.message.content, s.scrubber).content
244 } catch {
245 content = withhold(Array.isArray(e.message.content) ? e.message.content : [])
246 }
247 return next({ ...e, message: { ...e.message, content } })
248 })
249
250 on('session.send', async ($, e, next) => {
251 if (s.mode === 'off') return next(e)
252 const scrub = await scrubberFor($, s)
253 if (!scrub) return next(e)
254 const r = scrub(e.text)
255 if (r.names.length === 0) return next(e)
256 if (s.mode === 'dry') {
257 $.ui.toast(`secret-box (dry run): would replace ${r.names.join(', ')} in a sent message`)
258 return next(e)
259 }
260 return next({ ...e, text: r.text })
261 }).catch(($, e, next) =>
262 next.called || s.mode !== 'on'
263 ? next(e)
264 : { isDelivered: false as const, reason: 'secret-box: the message could not be checked for secrets, so it was not sent (fail closed). Check the debug log.' },
265 )
266}
267hooks/scrub.ts 219 lines1// Pure helpers: placeholder detection, shell quoting, and value scrubbing. No engine calls here.
2
3export const MIN_LEN = 6
4
5const PLACEHOLDER = /\{\{secret:([A-Za-z0-9._-]+)\}\}/g
6
7export type Block = { type: string; [field: string]: unknown }
8export type Scrubber = (text: string) => { text: string; names: string[] }
9
10export function placeholderNames(command: string): string[] {
11 return [...new Set([...command.matchAll(PLACEHOLDER)].map((m) => m[1] as string))]
12}
13
14// POSIX single quoting; leaves a plain word bare, like shlex.quote.
15export function quote(s: string): string {
16 if (s !== '' && /^[A-Za-z0-9_@%+=:,./-]+$/.test(s)) return s
17 return `'${s.replaceAll("'", "'\\''")}'`
18}
19
20// `scrub` asks the helper to filter known values out of the command's own output, for hosts whose hooks cannot.
21export function rewriteCommand(wrapper: string, command: string, scrub = false): string {
22 return `${quote(wrapper)} exec ${scrub ? '--scrub ' : ''}-- ${quote(command)}`
23}
24
25function utf8(s: string): number[] {
26 const out: number[] = []
27 for (const ch of s) {
28 const c = ch.codePointAt(0) as number
29 if (c < 0x80) out.push(c)
30 else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63))
31 else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63))
32 else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63))
33 }
34 return out
35}
36
37const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
38
39// The module has no Buffer or guaranteed btoa, so encode by hand.
40function base64Bytes(b: number[]): string {
41 let out = ''
42 for (let i = 0; i < b.length; i += 3) {
43 const n = ((b[i] as number) << 16) | ((b[i + 1] ?? 0) << 8) | (b[i + 2] ?? 0)
44 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
45 out += i + 1 < b.length ? B64[(n >> 6) & 63]! : '='
46 out += i + 2 < b.length ? B64[n & 63]! : '='
47 }
48 return out
49}
50
51export const base64 = (s: string): string => base64Bytes(utf8(s))
52
53// The value alone, plus the part of its base64 that stays the same when 1 or 2 bytes come before it
54// (as in `user:<value>`): the leading chars that mix in those bytes and the trailing partial group
55// are cut, in both the standard and the URL-safe alphabet.
56function base64Forms(base: string): string[] {
57 const bytes = utf8(base)
58 const whole = base64Bytes(bytes)
59 const out = [whole, whole.replace(/=+$/, '')]
60 for (const k of [0, 1, 2]) {
61 const raw = base64Bytes([...[0, 0].slice(0, k), ...bytes]).replace(/=+$/, '')
62 out.push(raw.slice([0, 2, 3][k] as number, Math.floor(((bytes.length + k) * 8) / 6)))
63 }
64 return [...out, ...out.map((f) => f.replaceAll('+', '-').replaceAll('/', '_'))]
65}
66
67const lowerHex = (s: string) => s.replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase())
68const strictUrl = (s: string) => s.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
69
70// Exported so the Python helper's port of this list can be checked against it.
71export function forms(value: string): string[] {
72 const out = new Set<string>()
73 for (const base of new Set([value, value.trim()])) {
74 if (base.length < MIN_LEN) continue
75 out.add(base)
76 const json = JSON.stringify(base).slice(1, -1)
77 out.add(json)
78 // ensure_ascii encoders (python json.dumps) write each non-ASCII UTF-16 unit as \uXXXX
79 if (/[^\x00-\x7f]/.test(base)) {
80 for (const hex of [(n: number) => n.toString(16), (n: number) => n.toString(16).toUpperCase()]) {
81 out.add(json.replace(/[^\x00-\x7f]/g, (c) => `\\u${hex(c.charCodeAt(0)).padStart(4, '0')}`))
82 }
83 }
84 try {
85 const url = encodeURIComponent(base)
86 for (const u of [url, strictUrl(url)]) for (const f of [u, u.replaceAll('%20', '+')]) out.add(f).add(lowerHex(f))
87 } catch {
88 // a lone surrogate has no URL form
89 }
90 for (const f of base64Forms(base)) out.add(f)
91 }
92 return [...out]
93}
94
95const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
96
97// One pass with the longest form first, so a replacement is never scanned again.
98// Takes name/value pairs; a name may appear more than once (an old and a new value).
99export function makeScrubber(values: Iterable<readonly [string, string]>): Scrubber | null {
100 const byForm = new Map<string, string>()
101 for (const [name, value] of [...values].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
102 for (const f of forms(value)) if (!byForm.has(f)) byForm.set(f, name)
103 }
104 if (byForm.size === 0) return null
105 const re = new RegExp([...byForm.keys()].sort((a, b) => b.length - a.length || (a < b ? -1 : 1)).map(escape).join('|'), 'g')
106 return (text) => {
107 const names = new Set<string>()
108 const out = text.replace(re, (hit) => {
109 const name = byForm.get(hit) as string
110 names.add(name)
111 return `{{secret:${name}}}`
112 })
113 return { text: out, names: [...names] }
114 }
115}
116
117// Rewrites text blocks and a tool_result's content; every other block stays as it was.
118export function scrubBlocks(blocks: readonly Block[], scrub: Scrubber): { content: Block[]; names: string[] } {
119 const names = new Set<string>()
120 const text = (s: string) => {
121 const r = scrub(s)
122 r.names.forEach((n) => names.add(n))
123 return r.text
124 }
125 const walk = (b: Block): Block => {
126 if (b.type === 'text' && typeof b.text === 'string') {
127 const t = text(b.text)
128 return t === b.text ? b : { ...b, text: t }
129 }
130 if (b.type === 'tool_result') {
131 if (typeof b.content === 'string') {
132 const t = text(b.content)
133 return t === b.content ? b : { ...b, content: t }
134 }
135 if (Array.isArray(b.content)) {
136 const inner = (b.content as Block[]).map(walk)
137 return inner.some((x, i) => x !== (b.content as Block[])[i]) ? { ...b, content: inner } : b
138 }
139 }
140 return b
141 }
142 return { content: blocks.map(walk), names: [...names] }
143}
144
145const WITHHELD = '[secret-box: this content was withheld because scrubbing failed]'
146
147// Last resort when scrubbing itself failed: keep the row's shape, drop what it said.
148export function withhold(blocks: readonly Block[]): Block[] {
149 return blocks.map((b) => {
150 if (b.type === 'text') return { ...b, text: WITHHELD }
151 if (b.type === 'tool_result') return { ...b, content: WITHHELD }
152 return b
153 })
154}
155
156const PATH_FIELDS = new Set(['persistedOutputPath', 'rawOutputPath'])
157// Kept as they are even when scrubbing failed: a backgrounded command needs its id, a block its type.
158const KEEP_ON_FAILURE = new Set([...PATH_FIELDS, 'backgroundTaskId', 'type'])
159const MAX_DEPTH = 12
160
161type KeyFn = (k: string, depth: number) => string
162
163// Walks strings, arrays and objects; a subtree deeper than MAX_DEPTH or one that loops back on itself
164// is replaced by the notice, never returned as it was. Keys in `keep` are not touched. Other keys go
165// through `fk`; one that then collides with a key already there gets a `#2`, `#3`... suffix, so no
166// entry is lost. Key order stays.
167function scrubDeep(v: unknown, f: (s: string) => string, fk: KeyFn, keep: ReadonlySet<string>, mark: () => void, depth = 0, path = new Set<object>()): unknown {
168 if (typeof v === 'string') return f(v)
169 if (v === null || typeof v !== 'object') return v
170 if (depth > MAX_DEPTH || path.has(v)) {
171 mark()
172 return WITHHELD
173 }
174 path.add(v)
175 try {
176 if (Array.isArray(v)) return v.map((x) => scrubDeep(x, f, fk, keep, mark, depth + 1, path))
177 const entries = Object.entries(v)
178 const used = new Set(entries.map(([k]) => k).filter((k) => keep.has(k)))
179 return Object.fromEntries(
180 entries.map(([k, x]) => {
181 if (keep.has(k)) return [k, x]
182 const base = fk(k, depth)
183 let key = base
184 for (let n = 2; used.has(key); n++) key = `${base}#${n}`
185 used.add(key)
186 return [key, scrubDeep(x, f, fk, keep, mark, depth + 1, path)]
187 }),
188 )
189 } finally {
190 path.delete(v)
191 }
192}
193
194// A Bash tool.call answer carries the output in `result` (the structured record the transcript keeps,
195// or the error text as a string) and in `text`, with more text in hints, blocks and `context`.
196// Every string under the answer is scrubbed, and so are the keys of the data the tool produced (below
197// the top two levels); the engine's own keys of the answer and its record (the top two levels) are
198// never renamed, or the record would stop validating. The path fields (`persistedOutputPath`,
199// `rawOutputPath`) are never altered; the file they name is the caller's to scrub. An answer
200// with nothing to replace comes back as the same object. If scrubbing throws, the strings are replaced
201// by a notice rather than returned raw; the background task id, `type` keys and paths stay, and so do
202// the engine's own keys of the answer and its record (the top two levels); deeper keys are withheld.
203export function scrubToolAnswer<T>(answer: T, scrub: Scrubber): T {
204 const a = answer as { deny?: unknown } | null
205 if (a === null || typeof a !== 'object' || a.deny !== undefined) return answer
206 try {
207 let changed = false
208 const f = (s: string) => {
209 const r = scrub(s)
210 if (r.names.length > 0) changed = true
211 return r.text
212 }
213 const out = scrubDeep(a, f, (k, depth) => (depth <= 1 ? k : f(k)), PATH_FIELDS, () => (changed = true))
214 return (changed ? out : answer) as T
215 } catch {
216 return scrubDeep(a, () => WITHHELD, (k, depth) => (depth <= 1 ? k : WITHHELD), KEEP_ON_FAILURE, () => {}) as T
217 }
218}
219