A seatbelt for secrets: stops credential-shaped strings and your private deny-list of words before Claude writes them into files or shell commands.

A loopkeeper add-on for Claude Code: a seatbelt that stops credential-shaped strings, and a private deny-list of words, from being written into files or shell commands, by Claude or by any subagent. A rule in a prompt can be forgotten; this check runs before every write, so a pasted API key or a word you never want in the repo does not end up in a file, a commit or a shell command.
Use it when
.env lines into prompts and want to be sure no key lands in a file Claude writes.Install: /plugin install lk-secret-guard@loopkeeper, after /plugin marketplace add connectwithub/loopkeeper (more under Install). Requires Claude Code 2.1.294 or later.
Data handling: it keeps one setting (the band on or off) in its own plugin store, and its log and counts, masked, in session state. It reads ~/.claude/secret-guard.json and <project>/.claude/secret-guard.json and writes no file. It runs no process, makes no model calls and sends nothing over the network. A matched value is only ever shown masked and is never stored. It collects no personal data; the saved setting stays until you delete it. Details under What it runs and touches.
Commands: /secret-guard and its options are listed under Commands.
Needs Claude Code 2.1.294 or later.
Before a Write, Edit, MultiEdit, NotebookEdit or Bash call runs, the guard scans only the new text the call would write:
| Tool | Text scanned | | :- | :- | | Write | content | | Edit | new_string (never old_string, so removing a secret is always allowed) | | MultiEdit | each edit's new_string | | NotebookEdit | new_source | | Bash | the whole command, which covers heredocs and echo … >> file |
Each finding has a confidence:
secret-guard: AWS access key detected in src/x.ts (AKIA…456). Secrets must not be written here. …).Allow once or Block. If the session is non-interactive (claude -p, the SDK), there is no one to ask, so a warn is allowed with a toast and logged as allowed. If you dismiss the question in an interactive session, the call is refused.Built-in patterns (the id in brackets is what disabledKinds takes):
| Kind | Action | | :- | :- | | AWS access key AKIA… [aws-access-key] | block | | Anthropic key sk-ant-… [anthropic-key] | block | | OpenAI key sk-…, sk-proj-… [openai-key] | block | | GitHub token ghp_/gho_/ghu_/ghs_/ghr_…, github_pat_… [github-token] | block | | Slack token xoxb-/xoxa-/xoxp-/xoxr-/xoxs-… [slack-token] | block | | Google API key AIza… [google-api-key] | block | | Stripe live key sk_live_…, rk_live_… [stripe-key] | block | | Private key block -----BEGIN … PRIVATE KEY----- [private-key] | block | | Twilio key SK + 32 hex [twilio-key] | warn | | JWT eyJ….eyJ….… [jwt] | warn | | password/passwd/secret/api_key/token = <8+ chars> [generic-secret] | warn |
The generic rule ignores obvious placeholders: values containing xxx, your_, <, ${, example, changeme, placeholder, redacted, process.env, os.environ or getenv, and values made of one repeated character.
Values are always shown masked: the first 4 and last 3 characters (AKIA…456). Shorter values reveal less (ab…z, a…). The raw value is never stored or printed.
From the loopkeeper marketplace, inside a Claude Code session:
/plugin marketplace add connectwithub/loopkeeper
/plugin install lk-secret-guard@loopkeeper
Turn it off and on again with /plugin disable lk-secret-guard@loopkeeper and /plugin enable lk-secret-guard@loopkeeper; /secret-guard off pauses it for one session only. It works with or without the loopkeeper core; with the core on, its hub links here.
To try a checkout without installing: claude --plugin-dir /path/to/loopkeeper/plugins/lk-secret-guard.
The guard never creates a config file. Without one, only the built-in patterns run. To add your own words, create either or both of:
~/.claude/secret-guard.json (global)<project>/.claude/secret-guard.json (project)They are merged in that order. A later file replaces publicPaths, allowPaths and disabledKinds when it sets them. deny and warn are unioned, so a project file can add words but never silently drops the private words in your global file.
{
"deny": ["Project Placeholder", "CLIENT_NAME_HERE"],
"warn": ["internal-codename"],
"publicPaths": ["public/", "README", "docs/", "site/"],
"allowPaths": [".env", ".env.*", "**/*.secret", "node_modules/**", "~/.claude/**", "tests/fixtures/**"],
"disabledKinds": ["jwt"]
}
Placeholder does not match Placeholders; spaces in a phrase match any run of whitespace). Deny blocks; warn asks..env files are where secrets legitimately live.Path entries are kept simple. An entry with * or ? is a glob (** crosses folders; without a / it is matched against the file name). Any other entry matches, case-insensitively, wherever a path segment starts with it: README matches docs/README.md, public/ matches site/public/index.html, .env matches .env.local. ~/ stands for your home directory.
A file that is not valid JSON is ignored, with a toast and a line in the error color in the pane. The message never quotes the file.
| Command | What it does | | :- | :- | | /secret-guard | Opens the Secret guard pane (see Pane): the last block, blocked and allowed counts, on/off, the last 20 decisions (masked), the rules (never the words), the config files found, and a Close button | | /secret-guard off / on | Turns the guard off or on for this session only. While off, an urgent band sits above the prompt. | | /secret-guard reload | Re-reads both config files | | /secret-guard test <text> | Runs the matcher on the text and prints what would be blocked or asked, masked. The word lists are applied as if the path were public. | | /secret-guard band on\|off | Shows or hides the one-line band above the prompt. Saved across sessions. |
The band is one kit line that appears only after the first block or warning in a session: secret-guard ● 2 blocked ● 1 allowed, the label in bold, the blocked count in the error color and the allowed count in the ok color once they are nonzero (quiet at zero). While the guard is off it reads secret-guard ● off for this session /secret-guard on to resume: the chip in the error color and the hint in the default text color, whatever the band setting.
/secret-guard draws one pane with the kit (hooks/kit.ts, a copy of the loopkeeper core's UI kit): bordered, unfilled cards on every surface, with ● text chips in the terminal and rounded pill chips in the Desktop app. Colors are theme keys, so it follows the light or dark theme. At the top a card (kicker secret guard) with the last block (Blocked AWS access key in src/x.ts, then the time, the masked value and the agent, if any), or Nothing blocked yet. Then three tiles: Blocked, Allowed and Rules (the rules in force), and the on/off status with the band setting. Then sections with counts: Recent (the last 20 decisions, newest first: time, an outcome pill blocked (error), allowed (ok) or asked → allowed (warning), the kind, and where with the masked value), Rules (each built-in kind with a built-in pill and block, warn or off; the deny and warn lists as counts with a config pill, never the words), and Config (the files loaded, any ignored file, the allowPaths count and where the word lists apply). Button: c Close.
password= around it, a key split across two writes, or a value that is base64-encoded or built at run time in a Bash command (echo $VAR, printf '%s' …) is not seen.allowPaths exempts files written by the file tools. A Bash command that writes a key into .env is still blocked; write it with the Write tool instead, or turn the guard off for the session.token: SomeLongTypeName; sk- followed by 20 letters (for example a CSS class or slug) looks like an OpenAI key; the JWT rule flags any real-looking JWT, including harmless test tokens; plain path entries like README match any segment that starts with them. Use allowPaths, disabledKinds, or Allow once.secret-guard failed (…). Run /secret-guard off if the guard is broken.PreToolUse hooks from managed settings run before every plugin's hooks module; other PreToolUse hooks run after the guard lets a call through. Another plugin loaded ahead of this one can answer a call before the guard sees it.$.state and last for the session; /clear, /resume and /branch reset them. Only the band setting is saved in the plugin's store.| File | What it holds | | :- | :- | | hooks/register.ts | The hooks: session start, the guarded tool calls, /secret-guard, band and pane | | hooks/patterns.ts | Pure parts: the pattern table, matching, masking, config merging, the messages and the band's parts | | hooks/pane.ts | The pane's view model: outcomes, rule rows, counts | | hooks/kit.ts | The UI kit the band and pane are drawn with: a verbatim copy of the loopkeeper core's hooks/lib/kit.ts (CI checks that the copies match) | | types/index.d.ts | The $.state contract | | tests/*.test.ts, tests/world.ts | Tests and shared stubs; every fake credential is assembled at run time. Run claude plugin test . in this folder |
From claude plugin validate --strict . and the code:
~/.claude/secret-guard.json and <project>/.claude/secret-guard.json; writes none.band (the /secret-guard band on|off choice), in the plugin's store. The log and counts stay in session state, masked.HOME.Allow once / Block) on a warn, toasts, the pane.The directory's validator listed these items for this plugin. Each bullet says what the code does and where.
MCP_FORWARDS_CREDENTIAL_ENV). No credential is read or sent; the two lines the check paired are text. In hooks/patterns.ts (line 52), process.env is an entry in PLACEHOLDER_MARKERS, so a value written as process.env.SOME_KEY counts as a reference rather than a literal secret; the plugin never reads the environment object. In hooks/kit.ts, http://www.w3.org/2000/svg is the SVG namespace inside the markup the UI kit draws, not a request. The plugin starts no programs and makes no network calls. It reads HOME (to find ~/.claude/secret-guard.json) and its two config files, and nothing else on the machine.plugin.json (warnings UNKNOWN_KEY). documentationUrl and supportUrl are listing fields the directory reads. types points at types/index.d.ts, the $.state contract that claude plugin validate checks. Claude Code ignores all three at load time.ICON_MISSING). Added at .claude-plugin/icon.png (512 × 512); its source is docs/assets/icons/lk-secret-guard.svg in the repo.MOD_CALL_NAMED_IN_TEXT). tool.call is named in the header comment of hooks/register.ts (line 4), which describes the guard's tool.call hook (line 86). That hook scans the new text of Write, Edit, MultiEdit, NotebookEdit and Bash calls: it returns next(e) unchanged when nothing matches, refuses the call ({ deny: … }) on a block finding, and asks the user Allow once / Block on a warn finding.MOD_REGISTERS_SURFACES). It registers one slash command, /secret-guard, at session start (line 63). It registers no tools or agents.MOD_HOOKS_POLICY_EVENT). classic.SessionStart with source clear, resume or fork (line 76) reloads the config files and the band setting, because those resets clear $.state. It returns next(e) unchanged and alters nothing that passes through it.command.run hook (note MOD_HOOKS_OPERATION). The hook at line 137 has the matcher { command: 'secret-guard' }, so it runs only for /secret-guard: it opens the pane, turns the guard off or on for the session, reloads the config, saves the band setting, or runs the matcher on test text. It does not see or change any other command.hooks/register.ts 431 lines1// lk-secret-guard: a seatbelt that stops credential-shaped strings and a private
2// deny-list of words from being written into files or shell commands.
3//
4// tool.call (Write, Edit, MultiEdit, NotebookEdit, Bash): scans only the NEW
5// text the call would write. A "block" finding refuses the call; a "warn"
6// finding asks the user (Allow once / Block). Subagents' calls are guarded too.
7// Every decision is logged in $.state with the value masked, never raw.
8//
9// Helpers that take `$` are top-level functions in this file, as the static
10// analysis requires; the pattern table and pure matching live in ./patterns.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, On } from 'claude-code'
14
15import type { GuardConfig, LogRow } from '../types'
16import {
17 askQuestion,
18 bandParts,
19 blockMessage,
20 judge,
21 mergeConfig,
22 parseConfig,
23 scan,
24 targetOf,
25 type Config,
26 type Finding,
27} from './patterns'
28import { TONES, makeKit } from './kit'
29import type { Elements } from './kit'
30import { activeRules, lastBlocked, outcomeOf, ruleRows, wordScope } from './pane'
31
32const PANE_ID = 'secret-guard'
33const LOG_KEEP = 200
34const PANE_ROWS = 20
35/** Secondary text: the theme's quiet key. */
36const QUIET = TONES.muted.fg
37
38const enabledAtom = atom({ plugin: 'lk-secret-guard', key: 'enabled' } as const, true)
39const bandAtom = atom({ plugin: 'lk-secret-guard', key: 'band' } as const, true)
40const logAtom = atom({ plugin: 'lk-secret-guard', key: 'log' } as const, [] as LogRow[])
41const blockedAtom = atom({ plugin: 'lk-secret-guard', key: 'blocked' } as const, 0)
42const allowedAtom = atom({ plugin: 'lk-secret-guard', key: 'allowed' } as const, 0)
43const configAtom = atom({ plugin: 'lk-secret-guard', key: 'config' } as const, null as GuardConfig | null)
44const filesAtom = atom({ plugin: 'lk-secret-guard', key: 'configFiles' } as const, [] as string[])
45const errorsAtom = atom({ plugin: 'lk-secret-guard', key: 'configErrors' } as const, [] as string[])
46
47const USAGE =
48 'usage: /secret-guard (open the pane) · on · off · reload · test <text> · band on|off'
49
50export function register(on: On) {
51 on('session.start', async ($, e, next) => {
52 try {
53 await loadConfig($)
54 } catch {
55 // The guard loads the config again on the first guarded call.
56 }
57 try {
58 await loadBand($)
59 } catch {
60 // The band stays at its default.
61 }
62 try {
63 await $.command.register({
64 name: 'secret-guard',
65 description: 'Secret guard: status pane, on/off, reload config, test a string, band on/off',
66 argumentHint: '[on|off|reload|test <text>|band on|off]',
67 immediate: true,
68 })
69 } catch {
70 // The name is taken; the guard itself still runs.
71 }
72 return next(e)
73 })
74
75 // /clear, /resume and /branch reset $.state, and session.start does not fire again.
76 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
77 try {
78 await loadConfig($)
79 await loadBand($)
80 } catch {
81 // Loaded again lazily on the next guarded call.
82 }
83 return next(e)
84 })
85
86 on('tool.call', { tool: ['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash'] }, async ($, e, next) => {
87 const fields = e as unknown as Record<string, unknown>
88 const target = targetOf(fields)
89 if (target === null || target.texts.length === 0) return next(e)
90 if (!(await read($, enabledAtom))) return next(e)
91
92 const config = await ensureConfig($)
93 const ctx = await pathCtx($)
94 const verdict = judge(target, config, ctx)
95 if (verdict.findings.length === 0) return next(e)
96
97 const agentId = typeof fields.agentId === 'string' && fields.agentId !== '' ? fields.agentId : undefined
98 const first = verdict.findings[0]
99 if (first.action === 'block') {
100 await record($, first, target.where, 'blocked', agentId)
101 return { deny: blockMessage(first, target.where) }
102 }
103
104 const warns = verdict.findings
105 let answer: string | null = null
106 try {
107 answer = await $.ui.ask(askQuestion(first, target.where, warns.length - 1), ['Allow once', 'Block'])
108 } catch {
109 answer = null
110 }
111 if (answer === null) {
112 // Dismissed, or nobody to ask. Non-interactive runs (-p, SDK) allow with a toast;
113 // a person who dismissed the question gets the safe answer.
114 const surfaces = await $.session.surfaces()
115 if (surfaces.length === 0) {
116 $.ui.toast(`possible ${first.kind} in ${target.where} (${first.masked}) allowed: no one to ask`)
117 await record($, first, target.where, 'allowed', agentId)
118 return next(e)
119 }
120 await record($, first, target.where, 'blocked', agentId)
121 return { deny: blockMessage(first, target.where) + ' (The question was dismissed.)' }
122 }
123 if (answer === 'Allow once') {
124 await record($, first, target.where, 'warned-allowed', agentId)
125 return next(e)
126 }
127 await record($, first, target.where, 'blocked', agentId)
128 return { deny: blockMessage(first, target.where) + ' The user chose Block.' }
129 }).catch(async ($, e, next) => {
130 const kind = next.error?.kind ?? 'throw'
131 const message = String(next.error?.message ?? '').replace(/\s+/g, ' ').slice(0, 120)
132 return {
133 deny: `secret-guard failed (${kind}: ${message}), so this call was not run. Retry, or run /secret-guard off if the guard is broken.`,
134 }
135 })
136
137 on('command.run', { command: 'secret-guard' }, async ($, e) => {
138 const args = typeof e.args === 'string' ? e.args.trim() : ''
139 const [sub = '', ...rest] = args.split(/\s+/)
140 const verb = sub.toLowerCase()
141
142 if (verb === '') {
143 await $.ui.open({ id: PANE_ID, title: 'Secret guard', focus: true, closeOnEscape: true })
144 return {}
145 }
146 if (verb === 'off') {
147 await update($, enabledAtom, () => false)
148 return { text: 'off for this session. Writes and commands are not checked. Run /secret-guard on to resume.' }
149 }
150 if (verb === 'on') {
151 await update($, enabledAtom, () => true)
152 return { text: 'on. Writes and commands are checked for credentials and deny-listed words.' }
153 }
154 if (verb === 'reload') {
155 const config = await loadConfig($)
156 const files = await read($, filesAtom)
157 const errors = await read($, errorsAtom)
158 return { text: describeConfig(config, files, errors) }
159 }
160 if (verb === 'band') {
161 const value = (rest[0] ?? '').toLowerCase()
162 if (value !== 'on' && value !== 'off') return { text: 'usage: /secret-guard band on|off' }
163 await $.store.set('band', value === 'on')
164 await update($, bandAtom, () => value === 'on')
165 return { text: `band ${value} (saved for future sessions).` }
166 }
167 if (verb === 'test') {
168 const text = args.replace(/^test\s*/i, '')
169 if (text === '') return { text: 'usage: /secret-guard test <text>' }
170 const config = await ensureConfig($)
171 return { text: describeTest(scan(text, { config, wordsApply: true }), await read($, enabledAtom)) }
172 }
173 return { text: USAGE }
174 })
175
176 // The band, one kit line: a bold label, then the blocked and allowed counts as chips (danger and ok
177 // once nonzero, else quiet); when the guard is off, an `off for this session` chip in danger and how
178 // to resume on the right side, in the default text color (kit.line draws its right side quiet).
179 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
180 const isOn = await read($, enabledAtom)
181 const showBand = await read($, bandAtom)
182 const blocked = await read($, blockedAtom)
183 const allowed = await read($, allowedAtom)
184 if (isOn && (!showBand || blocked + allowed === 0)) return next(e)
185 const parts = bandParts(isOn, blocked, allowed)
186 const ui = $.ui.resolve(e)
187 const { Box } = ui
188 const kit = makeKit(ui as unknown as Elements, e.surface)
189 const drawn = (
190 parts.urgent && parts.right
191 ? Box({
192 flexDirection: 'row',
193 justifyContent: 'space-between',
194 columnGap: 2,
195 children: [kit.line('secret-guard', parts.chips) as ReturnType<typeof Box>, kit.text(parts.right, { wrap: 'truncate' }) as ReturnType<typeof Box>],
196 })
197 : kit.line('secret-guard', parts.chips, parts.right)
198 ) as ReturnType<typeof Box>
199 const line = Box({ key: 'secret-guard-band', flexDirection: 'column', children: [drawn] })
200 const theirs = await next(e)
201 if (theirs === null || theirs === undefined) return line
202 return Box({ flexDirection: 'column', children: [line, theirs] })
203 })
204
205 // The pane, drawn with the kit: the last block as the hero, tiles, then the recent decisions,
206 // the rules and the config files as sections. Values are masked; word lists are only counted.
207 // A page: one row between blocks; the rows inside a section stay together.
208 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
209 if (e.requestId !== PANE_ID) return next(e)
210 const kit = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
211 const isOn = await read($, enabledAtom)
212 const showBand = await read($, bandAtom)
213 const blocked = await read($, blockedAtom)
214 const allowed = await read($, allowedAtom)
215 const config = await read($, configAtom)
216 const files = await read($, filesAtom)
217 const errors = await read($, errorsAtom)
218 const log = await read($, logAtom)
219 const rows = log.slice(-PANE_ROWS).reverse()
220 const last = lastBlocked(log)
221 const dim = (s: string) => kit.text(s, { color: QUIET, wrap: 'truncate' })
222
223 const hero = last
224 ? kit.hero({
225 kicker: 'secret guard',
226 title: `Blocked ${last.kind} in ${last.where}`,
227 sub: [clock(last.at), last.masked, last.agentId ? 'agent ' + last.agentId : ''].filter(Boolean).join(' · '),
228 tone: 'danger',
229 })
230 : kit.hero({
231 kicker: 'secret guard',
232 title: 'Nothing blocked yet',
233 sub: isOn
234 ? 'Checking the new text of Write, Edit, MultiEdit, NotebookEdit and Bash calls'
235 : 'The guard is off for this session · /secret-guard on to resume',
236 tone: isOn ? 'ok' : 'danger',
237 })
238
239 const status = kit.row({
240 key: 'status',
241 left: [
242 kit.text(isOn ? 'Status: ON' : 'Status: OFF for this session', {
243 color: TONES[isOn ? 'ok' : 'danger'].fg,
244 bold: true,
245 wrap: 'truncate',
246 }),
247 ],
248 right: `Band above the prompt: ${showBand ? 'on' : 'off'}`,
249 })
250
251 const recent = kit.stack([
252 kit.section(`Recent · ${rows.length}`, rows.length > 0 ? 'newest first' : undefined),
253 ...(rows.length > 0
254 ? rows.map((r, i) => {
255 const o = outcomeOf(r.action)
256 return kit.row({
257 key: `row-${i}`,
258 left: [
259 dim(clock(r.at)),
260 kit.pill(o.label, o.tone),
261 kit.text(r.kind, { bold: true, wrap: 'truncate' }),
262 kit.text(`in ${r.where} (${r.masked})`, { wrap: 'truncate' }),
263 ],
264 right: r.agentId ? 'agent ' + r.agentId : '',
265 align: 'center',
266 })
267 })
268 : [dim('No decisions yet this session')]),
269 ])
270
271 const rulesList = config === null ? [] : ruleRows(config)
272 const off = rulesList.filter(r => !r.on).length
273 const rules =
274 config === null
275 ? kit.stack([kit.section('Rules'), dim('Not loaded yet: /secret-guard reload reads the config files')])
276 : kit.stack([
277 kit.section(`Rules · ${activeRules(config)}`, off > 0 ? `${off} off` : undefined),
278 ...rulesList.map(r =>
279 kit.row({
280 key: r.key,
281 left: [
282 kit.text(r.name, r.on ? { wrap: 'truncate' } : { color: QUIET, wrap: 'truncate' }),
283 kit.pill(r.source, r.source === 'config' ? 'accent' : 'muted'),
284 ],
285 right: r.right,
286 align: 'center',
287 }),
288 ),
289 ])
290
291 const configSection = kit.stack([
292 kit.section(`Config · ${files.length}`, errors.length > 0 ? `${errors.length} ignored` : undefined),
293 ...(files.length > 0
294 ? files.map((f, i) => kit.row({ key: `file-${i}`, left: [kit.text(f, { wrap: 'truncate' })], right: 'loaded' }))
295 : [dim('No files found (built-in patterns only)')]),
296 ...errors.map((err, i) =>
297 kit.row({ key: `err-${i}`, left: [kit.pill('ignored', 'danger'), kit.text(err, { color: TONES.danger.fg, wrap: 'truncate' })], align: 'center' }),
298 ),
299 config === null ? null : dim(`${config.allowPaths.length} allowPaths · word lists apply ${wordScope(config)}`),
300 ])
301
302 return kit.page([
303 hero,
304 kit.tiles([
305 { label: 'Blocked', value: String(blocked), tone: blocked > 0 ? 'danger' : 'muted' },
306 { label: 'Allowed', value: String(allowed), tone: allowed > 0 ? 'ok' : 'muted' },
307 { label: 'Rules', value: config === null ? '—' : String(activeRules(config)), tone: 'info' },
308 ]),
309 status,
310 recent,
311 rules,
312 configSection,
313 kit.buttons([
314 {
315 key: 'close',
316 label: 'Close',
317 hotkey: 'c',
318 plain: true,
319 onPress: async () => {
320 await $.ui.close({ id: PANE_ID })
321 },
322 },
323 ]),
324 ]) as never
325 })
326}
327
328// ---- Helpers that take $ ----------------------------------------------------
329
330async function pathCtx($: EngineInterface) {
331 const cwd = await $.session.cwd()
332 const home = (await $.env.get('HOME')) ?? ''
333 return { cwd, home }
334}
335
336async function ensureConfig($: EngineInterface): Promise<Config> {
337 const cached = await read($, configAtom)
338 if (cached !== null) return cached
339 return loadConfig($)
340}
341
342/** Reads ~/.claude/secret-guard.json, then <cwd>/.claude/secret-guard.json; later wins. */
343async function loadConfig($: EngineInterface): Promise<Config> {
344 const home = (await $.env.get('HOME')) ?? ''
345 const cwd = (await $.session.cwd()).replace(/\/+$/, '')
346 const candidates: string[] = []
347 if (home !== '') candidates.push(home.replace(/\/+$/, '') + '/.claude/secret-guard.json')
348 const project = cwd + '/.claude/secret-guard.json'
349 if (!candidates.includes(project)) candidates.push(project)
350
351 const layers = []
352 const files: string[] = []
353 const errors: string[] = []
354 for (const path of candidates) {
355 let exists = false
356 try {
357 exists = await $.fs.exists(path)
358 } catch {
359 exists = false
360 }
361 if (!exists) continue
362 try {
363 const text = String(await $.fs.read(path))
364 layers.push(parseConfig(text))
365 files.push(path)
366 } catch {
367 // Never echo the parser's message: it can quote the file, and the file holds private words.
368 errors.push(`${path} is unreadable or not a JSON object`)
369 }
370 }
371 const merged = mergeConfig(layers)
372 await update($, configAtom, () => merged)
373 await update($, filesAtom, () => files)
374 await update($, errorsAtom, () => errors)
375 if (errors.length > 0) {
376 $.ui.toast(`config ignored: ${errors.join('; ')}`)
377 }
378 return merged
379}
380
381async function loadBand($: EngineInterface) {
382 const saved = await $.store.get('band')
383 await update($, bandAtom, () => saved !== false)
384}
385
386async function record(
387 $: EngineInterface,
388 f: Finding,
389 where: string,
390 action: LogRow['action'],
391 agentId: string | undefined,
392) {
393 const row: LogRow = { at: Date.now(), kind: f.kind, where, masked: f.masked, action }
394 if (agentId !== undefined) row.agentId = agentId
395 await update($, logAtom, rows => [...rows, row].slice(-LOG_KEEP))
396 if (action === 'blocked') {
397 await update($, blockedAtom, n => n + 1)
398 } else {
399 await update($, allowedAtom, n => n + 1)
400 }
401}
402
403// ---- Pure text helpers --------------------------------------------------------
404
405function clock(at: number): string {
406 try {
407 return new Date(at).toISOString().slice(11, 19)
408 } catch {
409 return '--:--:--'
410 }
411}
412
413function rulesSummary(c: Config): string {
414 const scope = c.publicPaths === null ? 'everywhere' : `only in ${c.publicPaths.length} public path(s)`
415 const disabled = c.disabledKinds.length > 0 ? ` · disabled: ${c.disabledKinds.join(', ')}` : ''
416 return `Rules: ${c.deny.length} deny word(s), ${c.warn.length} warn word(s), applied ${scope} · ${c.allowPaths.length} allowPaths${disabled}`
417}
418
419function describeConfig(c: Config, files: string[], errors: string[]): string {
420 const found = files.length > 0 ? `reloaded ${files.join(', ')}.` : 'reloaded: no config files found, built-in patterns only.'
421 const bad = errors.length > 0 ? ` Ignored: ${errors.join('; ')}.` : ''
422 return `${found} ${rulesSummary(c)}.${bad}`
423}
424
425function describeTest(findings: Finding[], isOn: boolean): string {
426 const off = isOn ? '' : ' (The guard is off for this session, so nothing would be stopped right now.)'
427 if (findings.length === 0) return `nothing in that text would be flagged.${off}`
428 const lines = findings.map(f => `- would ${f.action === 'block' ? 'BLOCK' : 'ASK (warn)'}: ${f.kind} (${f.masked})`)
429 return `${findings.length} finding(s); word lists applied as if the path were public:\n${lines.join('\n')}${off}`
430}
431hooks/patterns.ts 390 lines1// lk-secret-guard: the pattern table and the pure matching helpers.
2//
3// Nothing in this file touches the mods API, so it can be imported by the
4// hooks module and unit tested directly. Every function is total: bad input
5// yields "no finding", never a throw.
6
7export type Action = 'block' | 'warn'
8
9export type Pattern = {
10 /** Stable id, used by `disabledKinds` in the config file. */
11 id: string
12 /** Human label shown in messages. */
13 kind: string
14 action: Action
15 regex: RegExp
16 /** Capture group holding the value to mask and placeholder-check (0 = whole match). */
17 group?: number
18 /** When true, values that look like placeholders are ignored. */
19 skipPlaceholders?: boolean
20}
21
22/** Built-in credential patterns. Order matters only for which finding is reported first. */
23export const PATTERNS: readonly Pattern[] = [
24 { id: 'private-key', kind: 'private key block', action: 'block', regex: /-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----/g },
25 { id: 'aws-access-key', kind: 'AWS access key', action: 'block', regex: /AKIA[0-9A-Z]{16}/g },
26 { id: 'anthropic-key', kind: 'Anthropic API key', action: 'block', regex: /sk-ant-[A-Za-z0-9_-]{20,}/g },
27 { id: 'openai-key', kind: 'OpenAI API key', action: 'block', regex: /sk-proj-[A-Za-z0-9_-]{20,}/g },
28 { id: 'openai-key', kind: 'OpenAI API key', action: 'block', regex: /sk-[A-Za-z0-9]{20,}/g },
29 { id: 'github-token', kind: 'GitHub token', action: 'block', regex: /gh[pousr]_[A-Za-z0-9]{36,}/g },
30 { id: 'github-token', kind: 'GitHub token', action: 'block', regex: /github_pat_[A-Za-z0-9_]{22,}/g },
31 { id: 'slack-token', kind: 'Slack token', action: 'block', regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
32 { id: 'google-api-key', kind: 'Google API key', action: 'block', regex: /AIza[0-9A-Za-z_-]{35}/g },
33 { id: 'stripe-key', kind: 'Stripe live key', action: 'block', regex: /(?:sk|rk)_live_[0-9a-zA-Z]{20,}/g },
34 { id: 'twilio-key', kind: 'Twilio API key', action: 'warn', regex: /\bSK[0-9a-f]{32}\b/g },
35 { id: 'jwt', kind: 'JWT', action: 'warn', regex: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
36 {
37 id: 'generic-secret',
38 kind: 'hard-coded secret',
39 action: 'warn',
40 regex: /(password|passwd|secret|api[_-]?key|token)\s*[:=]\s*['"]?([^\s'"]{8,})/gi,
41 group: 2,
42 skipPlaceholders: true,
43 },
44]
45
46/** Every id `disabledKinds` accepts. */
47export const KIND_IDS: readonly string[] = [...new Set(PATTERNS.map(p => p.id))]
48
49const PLACEHOLDER_MARKERS = [
50 'xxx', 'your_', '<', '${', 'example', 'changeme', 'placeholder', 'redacted',
51 // References to the environment are not literal secrets.
52 'process.env', 'os.environ', 'getenv',
53]
54
55/** True when a value is an obvious placeholder rather than a real secret. */
56export function isPlaceholder(value: string): boolean {
57 const v = value.toLowerCase()
58 if (PLACEHOLDER_MARKERS.some(m => v.includes(m))) return true
59 if (v.length > 0 && [...v].every(c => c === v[0])) return true
60 return false
61}
62
63/**
64 * Masks a value so the whole of it is never shown: the first 4 and last 3
65 * characters with an ellipsis between. Shorter values reveal less, so that at
66 * most about half of any value is ever visible.
67 */
68export function mask(value: string): string {
69 const s = String(value ?? '')
70 const n = s.length
71 if (n >= 12) return s.slice(0, 4) + '…' + s.slice(-3)
72 if (n >= 6) return s.slice(0, 2) + '…' + s.slice(-1)
73 if (n >= 2) return s.slice(0, 1) + '…'
74 return '…'
75}
76
77export type Config = {
78 deny: string[]
79 warn: string[]
80 /** null when no config file set it: the word lists then apply everywhere. */
81 publicPaths: string[] | null
82 allowPaths: string[]
83 disabledKinds: string[]
84}
85
86export const EMPTY_CONFIG: Config = { deny: [], warn: [], publicPaths: null, allowPaths: [], disabledKinds: [] }
87
88function strings(v: unknown): string[] | undefined {
89 if (!Array.isArray(v)) return undefined
90 return v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.trim())
91}
92
93/** Parses one config file's text. Throws on malformed JSON (the caller records it). */
94export function parseConfig(text: string): Partial<Config> {
95 const raw = JSON.parse(text) as unknown
96 if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
97 throw new Error('the top level must be a JSON object')
98 }
99 const o = raw as Record<string, unknown>
100 const out: Partial<Config> = {}
101 const deny = strings(o.deny)
102 const warn = strings(o.warn)
103 const publicPaths = strings(o.publicPaths)
104 const allowPaths = strings(o.allowPaths)
105 const disabledKinds = strings(o.disabledKinds)
106 if (deny) out.deny = deny
107 if (warn) out.warn = warn
108 if (publicPaths) out.publicPaths = publicPaths
109 if (allowPaths) out.allowPaths = allowPaths
110 if (disabledKinds) out.disabledKinds = disabledKinds
111 return out
112}
113
114/**
115 * Merges config layers, global first, project last. A later file replaces
116 * `publicPaths`, `allowPaths` and `disabledKinds` when it sets them. The
117 * `deny` and `warn` word lists are unioned, so a project file can add words
118 * but can never silently drop the private words in the global file.
119 */
120export function mergeConfig(layers: readonly Partial<Config>[]): Config {
121 const out: Config = { ...EMPTY_CONFIG, deny: [], warn: [], allowPaths: [], disabledKinds: [] }
122 for (const l of layers) {
123 if (l.deny) out.deny = [...new Set([...out.deny, ...l.deny])]
124 if (l.warn) out.warn = [...new Set([...out.warn, ...l.warn])]
125 if (l.publicPaths) out.publicPaths = [...l.publicPaths]
126 if (l.allowPaths) out.allowPaths = [...l.allowPaths]
127 if (l.disabledKinds) out.disabledKinds = [...l.disabledKinds]
128 }
129 return out
130}
131
132// ---- Paths ------------------------------------------------------------------
133
134export type PathCtx = { cwd: string; home: string }
135
136const GLOB_CHARS = /[*?]/
137
138function escapeRe(s: string): string {
139 return s.replace(/[.+^${}()|[\]\\*?]/g, '\\$&')
140}
141
142function globBody(glob: string): string {
143 let re = ''
144 for (let i = 0; i < glob.length; i += 1) {
145 const c = glob[i]
146 if (c === '*' && glob[i + 1] === '*') {
147 if (glob[i + 2] === '/') {
148 re += '(?:.*/)?'
149 i += 2
150 } else {
151 re += '.*'
152 i += 1
153 }
154 } else if (c === '*') {
155 re += '[^/]*'
156 } else if (c === '?') {
157 re += '[^/]'
158 } else {
159 re += escapeRe(c)
160 }
161 }
162 return re
163}
164
165function normalize(p: string): string {
166 return p.replace(/\\/g, '/').replace(/\/+/g, '/')
167}
168
169/** The absolute and cwd-relative forms of a path. */
170export function pathForms(path: string, ctx: PathCtx): { abs: string; rel: string } {
171 let p = normalize(String(path ?? ''))
172 const home = normalize(ctx.home || '')
173 if (home && (p === '~' || p.startsWith('~/'))) p = home + p.slice(1)
174 const cwd = normalize(ctx.cwd || '').replace(/\/$/, '')
175 const abs = p.startsWith('/') ? p : cwd ? cwd + '/' + p.replace(/^\.\//, '') : p
176 const rel = cwd && abs.startsWith(cwd + '/') ? abs.slice(cwd.length + 1) : abs
177 return { abs, rel }
178}
179
180/**
181 * Whether one config entry matches a path. Kept simple:
182 * - an entry with `*` or `?` is a glob (`**` crosses folders). Without a `/` it
183 * is tested against the file name; otherwise against the path relative to
184 * the working directory, the absolute path, or any trailing part of it.
185 * - any other entry matches, case-insensitively, wherever a path segment
186 * starts with it: `README` matches `docs/README.md`, `public/` matches
187 * `site/public/index.html`, `.env` matches `.env` and `.env.local`.
188 * A leading `~/` stands for the home directory.
189 */
190export function pathMatches(entry: string, path: string, ctx: PathCtx): boolean {
191 if (!entry || !path) return false
192 const { abs, rel } = pathForms(path, ctx)
193 let pat = normalize(entry)
194 const home = normalize(ctx.home || '')
195 if (home && (pat === '~' || pat.startsWith('~/'))) pat = home + pat.slice(1)
196 if (GLOB_CHARS.test(pat)) {
197 const body = globBody(pat)
198 if (!pat.includes('/')) {
199 const base = abs.slice(abs.lastIndexOf('/') + 1)
200 return new RegExp('^' + body + '$').test(base)
201 }
202 if (pat.startsWith('/')) return new RegExp('^' + body + '$').test(abs)
203 const anchored = new RegExp('^' + body + '$')
204 const trailing = new RegExp('(?:^|/)' + body + '$')
205 return anchored.test(rel) || trailing.test(abs)
206 }
207 const needle = pat.toLowerCase()
208 if (needle.startsWith('/')) return abs.toLowerCase().startsWith(needle)
209 const hay = '/' + abs.toLowerCase()
210 return hay.includes('/' + needle) || ('/' + rel.toLowerCase()).includes('/' + needle)
211}
212
213export function anyPathMatches(entries: readonly string[], path: string, ctx: PathCtx): boolean {
214 return entries.some(entry => pathMatches(entry, path, ctx))
215}
216
217/** Whether a shell command mentions a path that one of the entries matches. */
218export function commandMentions(entries: readonly string[], command: string, ctx: PathCtx): boolean {
219 const lower = command.toLowerCase()
220 const tokens = command.split(/[\s'"`;|&<>()=]+/).filter(t => t !== '')
221 return entries.some(entry => {
222 if (!GLOB_CHARS.test(entry)) {
223 const plain = entry.replace(/^~\//, '').toLowerCase()
224 if (plain !== '' && lower.includes(plain)) return true
225 }
226 return tokens.some(t => pathMatches(entry, t, ctx))
227 })
228}
229
230// ---- Scanning ---------------------------------------------------------------
231
232export type Finding = {
233 kind: string
234 id: string
235 action: Action
236 masked: string
237}
238
239function wordRegex(phrase: string): RegExp | null {
240 const words = phrase.trim().split(/\s+/).filter(w => w !== '').map(escapeRe)
241 if (words.length === 0) return null
242 return new RegExp('(?<![A-Za-z0-9_])' + words.join('\\s+') + '(?![A-Za-z0-9_])', 'i')
243}
244
245export type ScanOptions = {
246 config: Config
247 /** Whether the deny and warn word lists apply to this write. */
248 wordsApply: boolean
249}
250
251/**
252 * Every finding in `text`, blocks first. Overlapping matches of different
253 * credential patterns are reported once (the earlier, stricter pattern wins).
254 */
255export function scan(text: string, opts: ScanOptions): Finding[] {
256 if (typeof text !== 'string' || text === '') return []
257 const disabled = new Set(opts.config.disabledKinds)
258 const found: Finding[] = []
259 const spans: [number, number][] = []
260 for (const p of PATTERNS) {
261 if (disabled.has(p.id)) continue
262 const re = new RegExp(p.regex.source, p.regex.flags.includes('g') ? p.regex.flags : p.regex.flags + 'g')
263 let m: RegExpExecArray | null
264 while ((m = re.exec(text)) !== null) {
265 if (m[0] === '') {
266 re.lastIndex += 1
267 continue
268 }
269 const start = m.index
270 const end = start + m[0].length
271 if (spans.some(([s, e]) => start < e && end > s)) continue
272 const value = p.group ? m[p.group] ?? m[0] : m[0]
273 if (p.skipPlaceholders && isPlaceholder(value)) continue
274 spans.push([start, end])
275 found.push({ kind: p.kind, id: p.id, action: p.action, masked: mask(value) })
276 }
277 }
278 if (opts.wordsApply) {
279 for (const [list, action, kind] of [
280 [opts.config.deny, 'block', 'deny-listed word'],
281 [opts.config.warn, 'warn', 'warn-listed word'],
282 ] as const) {
283 for (const phrase of list) {
284 const re = wordRegex(phrase)
285 const m = re ? re.exec(text) : null
286 if (m) found.push({ kind, id: action === 'block' ? 'deny-word' : 'warn-word', action, masked: mask(m[0]) })
287 }
288 }
289 }
290 return [...found.filter(f => f.action === 'block'), ...found.filter(f => f.action === 'warn')]
291}
292
293// ---- What a tool call writes ------------------------------------------------
294
295export type Target = {
296 /** The file written, or null for a Bash command. */
297 path: string | null
298 /** Where, as shown in messages: the file path or 'Bash command'. */
299 where: string
300 /** Only the new text being written. */
301 texts: string[]
302}
303
304function str(v: unknown): string | null {
305 return typeof v === 'string' ? v : null
306}
307
308/** The new text a guarded tool call would write, or null for any other call. */
309export function targetOf(e: Record<string, unknown>): Target | null {
310 const tool = str(e.tool)
311 if (tool === 'Bash') {
312 const command = str(e.command)
313 return { path: null, where: 'Bash command', texts: command ? [command] : [] }
314 }
315 const path = str(e.file_path) ?? str(e.notebook_path)
316 const where = path ?? '(unknown file)'
317 if (tool === 'Write') {
318 const content = str(e.content)
319 return { path, where, texts: content ? [content] : [] }
320 }
321 if (tool === 'Edit') {
322 const added = str(e.new_string)
323 return { path, where, texts: added ? [added] : [] }
324 }
325 if (tool === 'MultiEdit') {
326 const edits = Array.isArray(e.edits) ? e.edits : []
327 const texts = edits
328 .map(x => (x !== null && typeof x === 'object' ? str((x as Record<string, unknown>).new_string) : null))
329 .filter((x): x is string => x !== null && x !== '')
330 return { path, where, texts }
331 }
332 if (tool === 'NotebookEdit') {
333 const source = str(e.new_source)
334 return { path, where, texts: source ? [source] : [] }
335 }
336 return null
337}
338
339export type Verdict = { findings: Finding[]; exempt: boolean }
340
341/** Applies allowPaths and publicPaths, then scans each new text. */
342export function judge(target: Target, config: Config, ctx: PathCtx): Verdict {
343 if (target.path !== null && anyPathMatches(config.allowPaths, target.path, ctx)) {
344 return { findings: [], exempt: true }
345 }
346 const pub = config.publicPaths
347 const wordsApply =
348 pub === null
349 ? true
350 : target.path !== null
351 ? anyPathMatches(pub, target.path, ctx)
352 : target.texts.some(t => commandMentions(pub, t, ctx))
353 const findings = target.texts.flatMap(t => scan(t, { config, wordsApply }))
354 return {
355 findings: [...findings.filter(f => f.action === 'block'), ...findings.filter(f => f.action === 'warn')],
356 exempt: false,
357 }
358}
359
360export function blockMessage(f: Finding, where: string): string {
361 return (
362 `secret-guard: ${f.kind} detected in ${where} (${f.masked}). Secrets must not be written here. ` +
363 `If this is intentional, add the path to allowPaths in .claude/secret-guard.json or run /secret-guard off for this session.`
364 )
365}
366
367export function askQuestion(f: Finding, where: string, more: number): string {
368 const extra = more > 0 ? ` and ${more} more` : ''
369 return `secret-guard: possible ${f.kind} in ${where} (${f.masked})${extra}. Allow?`
370}
371
372/**
373 * The band's parts, drawn as kit.line('secret-guard', chips, right): `secret-guard ● 2 blocked ● 1 allowed`
374 * while on (blocked in danger and allowed in ok once nonzero, else quiet), or `secret-guard ● off for this
375 * session /secret-guard on to resume` when off, which is urgent: the right side in the default text color.
376 */
377export type BandParts = { chips: { text: string; tone: 'danger' | 'ok' | 'muted' }[]; right: string; urgent: boolean }
378
379export function bandParts(isOn: boolean, blocked: number, allowed: number): BandParts {
380 if (!isOn) return { chips: [{ text: 'off for this session', tone: 'danger' }], right: '/secret-guard on to resume', urgent: true }
381 return {
382 chips: [
383 { text: `${blocked} blocked`, tone: blocked > 0 ? 'danger' : 'muted' },
384 { text: `${allowed} allowed`, tone: allowed > 0 ? 'ok' : 'muted' },
385 ],
386 right: '',
387 urgent: false,
388 }
389}
390hooks/kit.ts 955 lines1// kit.ts: the shared UI kit (kit v3).
2//
3// The module has no imports and touches no mods API: every helper takes the elements a hook got
4// from `$.ui.resolve(e)` and the surface (`e.surface`) and returns an element tree.
5//
6// const k = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
7// return k.stack([k.hero({ kicker: 'right now', title: 'Reading client.test.js' }), k.tiles([...])], 1)
8//
9// What v2 changed on purpose (v1 painted grey fills and dimmed grey text, unreadable on the desktop):
10// - colors are theme keys (suggestion, success, warning, error, claude, inactive) that the host
11// tunes for light and dark; PALETTE below swaps them for named colors in one line;
12// - no dimColor, no backgroundColor, no yellow, white, gray, black or subtle on content: secondary
13// text is `inactive`, state is a bold `● word` chip (glyph and word, so color is never the only cue);
14// - hero and card are one round frame (paddingX 1), tiles are single-border boxes with the label
15// over a bold value; terminal and desktop draw the same structure (the Svg doodle is desktop-only);
16// - kicker, section and tile labels keep the caller's case (v1 upper-cased kicker and section);
17// - additive: line(label, chips, right?) draws one band line.
18//
19// Calibration (claude plugin test, Claude Code 2.1.294): the test harness validates the tree a
20// ui.render hook returns (a Text with a bogus prop makes `$.ui.mount` reject on both surfaces), but
21// not how the desktop paints a theme key. A Button given `plain: false` throws when built (plain and
22// autoFocus take only `true`), so the kit leaves false and undefined props out; a Box inside a Text
23// is refused. doodle() draws its Svg on the desktop only (a pane holding only an Svg opens empty in
24// the terminal).
25//
26// What v3 adds (strictly additive: every v2 export above renders as before). The desktop paints
27// Svg, and its light theme made the v1 fills unreadable, so v3 draws small pictures as Svg wherever
28// the element table has one (desktop, mobile, vscode) and as text on the terminal:
29// - pill(text, tone?): a fully rounded Svg chip (PILL 'svg', the default); PILL 'fill' draws a
30// theme-key backgroundColor under `inverseText` instead (the only fill in the kit); the v2
31// `● text` chip on the terminal;
32// - header(label, right?): an upper-cased `inactive` section label with a right-hand count;
33// - icon(name, tone?), typeBadge(fileName) and progress(ratio, opts?): Svg icons, file-type badges
34// and a rounded bar; a glyph, a bold label and the v2 bar on the terminal;
35// - buttons() items take an optional `variant` ('primary' | 'secondary');
36// - nav(items, currentKey): a pane's nav bar, the current item bold Text, the others plain Buttons;
37// - pure helpers: pillSvg, shortPath and extOf; TONE_HEX, the Svg colors of icons and bars, and
38// CHIP_HEX, the darker pill fills that keep white text at 4.5:1 or more.
39// Svg markup cannot read theme keys, so it uses fixed mid-tone hexes that read on light and dark
40// grounds (never a color word), and each distinct drawing is built once and cached.
41//
42// What v3.1 adds (spacing; additive): on the desktop, blocks stacked with no gap touch border to
43// border and wrapped rows sit line on line. So every wrapping row (buttons, tiles, nav) gets rowGap 1,
44// buttons get columnGap 2, and two new columns space a pane out:
45// - page(children): a pane's top-level column, one row between blocks;
46// - list(children): a column of repeated cards or blocks, one row between them.
47// Rows inside one card or block stay tight (stack with no gap); stack() keeps its props. Bands that
48// draw buttons() are one row, so the new gaps change nothing there. buttons() items also take
49// `autoFocus` (passed only when true).
50
51export const KIT_VERSION = '3.1.0'
52
53/** How pill() draws on a surface with Svg: 'svg' (a fully rounded chip) or 'fill' (a theme-key
54 * background). makeKit's `pill` option overrides it for one kit. */
55export const PILL: 'fill' | 'svg' = 'svg'
56
57/** kit v3: makeKit's options. `pill` overrides PILL for this kit. */
58export type KitOptions = { pill?: 'fill' | 'svg' }
59
60/** 'theme': theme keys that follow the person's light or dark theme. 'named': named colors, for a
61 * host that does not paint theme keys (never yellow: warn is red plus its glyph). One-line switch. */
62export const PALETTE: 'theme' | 'named' = 'theme'
63
64export type Surface = 'terminal' | 'desktop' | (string & {})
65export type Tone = 'info' | 'ok' | 'warn' | 'danger' | 'muted' | 'accent'
66export type Role = 'main' | 'recon' | 'execute' | 'verify' | 'hard' | 'reviewer' | 'other'
67export type DoodleState = 'running' | 'done' | 'failed' | 'waiting' | 'idle'
68/** The tones of pill() and icon(): the six tones plus violet. */
69export type PillTone = Tone | 'violet'
70export type IconName =
71 | 'eye'
72 | 'pencil'
73 | 'plus'
74 | 'branch'
75 | 'search'
76 | 'folder'
77 | 'folder-open'
78 | 'file'
79 | 'shield'
80 | 'flag'
81 | 'check'
82 | 'dot'
83 | 'clock'
84/** What `$.ui.resolve(e)` returns, loosely typed so the kit compiles without the engine's types. */
85export type Elements = Record<string, (props: Record<string, unknown>) => unknown>
86
87export type Kit = {
88 hero(p: { kicker?: string; title: string; sub?: string; icon?: string; tone?: Tone }): unknown
89 card(p: { title?: string; right?: string; tone?: Tone; children: unknown[] }): unknown
90 section(label: string, right?: string): unknown
91 tiles(items: { label: string; value: string; tone?: Tone }[]): unknown
92 badge(text: string, tone?: Tone): unknown
93 legend(items: { tone: Tone; label: string }[]): unknown
94 bar(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
95 /** One row, left side truncating, right side muted. `align: 'center'` (kit v3) centers a row holding a pill. */
96 row(p: { left: unknown[] | string; right?: unknown[] | string; key?: string; align?: 'center' }): unknown
97 kv(label: string, value: string, tone?: Tone): unknown
98 buttons(
99 items: {
100 key: string
101 label: string
102 hotkey?: string
103 onPress: (e?: unknown) => unknown
104 plain?: boolean
105 /** kit v3: the Button's variant, passed only when set. */
106 variant?: 'primary' | 'secondary'
107 /** kit 3.1: focus this Button when the pane opens; passed only when true. */
108 autoFocus?: boolean
109 }[],
110 ): unknown
111 doodle(role: Role, state?: DoodleState): unknown
112 stack(children: unknown[], gap?: number): unknown
113 /** kit 3.1: a pane's top-level column, one row between its blocks; null children are dropped. */
114 page(children: unknown[]): unknown
115 /** kit 3.1: a column of repeated cards or blocks, one row between them; null children are dropped. */
116 list(children: unknown[]): unknown
117 text(s: string, props?: Record<string, unknown>): unknown
118 /**
119 * One band line: `label ● a ● b right · text`. A chip is `{ text, tone? }`, a string (a muted
120 * chip) or an element such as badge(); at most four are drawn. `right` is a string or a list
121 * joined with ` · `, drawn `inactive` and truncated. One row high.
122 */
123 line(label: string, chips: unknown[], right?: string | string[]): unknown
124 /** kit v3: a rounded chip (Svg surfaces) or the v2 `● text` chip (terminal). */
125 pill(text: string, tone?: Tone | 'violet'): unknown
126 /** kit v3: one row, the label upper-cased, bold and `inactive`; `right` upper-cased, right-aligned. */
127 header(label: string, right?: string): unknown
128 /** kit v3: a 16×16 stroke icon (Svg surfaces) or one glyph in the tone (terminal). */
129 icon(name: IconName, tone?: Tone | 'violet'): unknown
130 /** kit v3: a file-type badge ('JS', 'PDF', …) for a file name; null when the name has no extension. */
131 typeBadge(fileName: string): unknown
132 /** kit v3: a rounded bar (Svg surfaces; `width` in CSS pixels, absent: the slot's width) or bar(). */
133 progress(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
134 /**
135 * kit v3: a pane's nav bar, one row keyed 'nav': the item whose key is `currentKey` as bold Text in the accent
136 * tone (not pressable), the others plain Buttons with their hotkeys (`1: Stage`); one without onPress quiet Text.
137 */
138 nav(items: { key: string; label: string; hotkey?: string; onPress?: () => unknown }[], currentKey: string): unknown
139 surface: Surface
140}
141
142/** The quiet color: secondary text, labels, units, timestamps, frame and tile borders. */
143const QUIET = 'inactive'
144
145const THEME_TONES: Record<Tone, string> = {
146 info: 'suggestion',
147 ok: 'success',
148 warn: 'warning',
149 danger: 'error',
150 muted: QUIET,
151 accent: 'claude',
152}
153
154const NAMED_TONES: Record<Tone, string> = {
155 info: 'blue',
156 ok: 'green',
157 warn: 'red',
158 danger: 'red',
159 muted: QUIET,
160 accent: 'magenta',
161}
162
163const TONE_COLORS = PALETTE === 'named' ? NAMED_TONES : THEME_TONES
164
165/** fg: the color of the tone, a theme key (a named color when PALETTE is 'named'). bg equals fg: v2
166 * draws no fills; the field stays so v1 callers compile. */
167export const TONES: Record<Tone, { fg: string; bg: string }> = {
168 info: { fg: TONE_COLORS.info, bg: TONE_COLORS.info },
169 ok: { fg: TONE_COLORS.ok, bg: TONE_COLORS.ok },
170 warn: { fg: TONE_COLORS.warn, bg: TONE_COLORS.warn },
171 danger: { fg: TONE_COLORS.danger, bg: TONE_COLORS.danger },
172 muted: { fg: TONE_COLORS.muted, bg: TONE_COLORS.muted },
173 accent: { fg: TONE_COLORS.accent, bg: TONE_COLORS.accent },
174}
175
176/**
177 * kit v3: the Svg colors of icons and bars, a mid-tone hex per tone that reads on light and dark grounds
178 * (Svg markup cannot read theme keys). warn and accent share the Claude orange: the theme's warning
179 * color is unreadable as a fill. A pill fills with CHIP_HEX instead.
180 *
181 * info #3B82F6 ok #22A35A warn #D97757 danger #E5484D accent #D97757 muted #8A8F98
182 * violet #8B5CF6
183 */
184export const TONE_HEX: Record<PillTone, string> = {
185 info: '#3B82F6',
186 ok: '#22A35A',
187 warn: '#D97757',
188 danger: '#E5484D',
189 accent: '#D97757',
190 muted: '#8A8F98',
191 violet: '#8B5CF6',
192}
193
194/**
195 * kit v3: a pill's fill per tone, darker than TONE_HEX so its white text has a contrast ratio of at
196 * least 4.5:1 (WCAG AA for small text). The ratios against white:
197 *
198 * info #2563EB 5.17 ok #15803D 5.02 warn #C2410C 5.18 danger #B91C1C 6.47 accent #C2410C 5.18
199 * muted #5B6170 6.20 violet #7C3AED 5.70
200 */
201export const CHIP_HEX: Record<PillTone, string> = {
202 info: '#2563EB',
203 ok: '#15803D',
204 warn: '#C2410C',
205 danger: '#B91C1C',
206 accent: '#C2410C',
207 muted: '#5B6170',
208 violet: '#7C3AED',
209}
210
211/** kit v3: the theme key a pill fills with, per tone (warn is 'claude': a 'warning' fill is unreadable). */
212const PILL_FILLS: Record<PillTone, string> = {
213 info: 'suggestion',
214 ok: 'success',
215 warn: 'claude',
216 danger: 'error',
217 accent: 'claude',
218 muted: QUIET,
219 violet: 'permission',
220}
221
222/** kit v3: the stroke of an icon drawn without a tone. */
223const ICON_STROKE = '#6B7280'
224/** kit v3: the track of progress(): the muted hex at quarter opacity. */
225const TRACK_HEX = '#8A8F98'
226/** kit v3: the text on a light fill (the amber JS badge); light text everywhere else. */
227const INK_DARK = '#1A1A1A'
228const INK_LIGHT = '#FFFFFF'
229
230/** kit v3: icon markup on a 16×16 grid, stroked with `currentColor`'s stand-in `S`. */
231const ICONS: Record<IconName, string> = {
232 eye: '<path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>',
233 pencil: '<path d="M11 2.5 13.5 5 5.5 13H3v-2.5z"/><path d="M9.5 4 12 6.5"/>',
234 plus: '<path d="M8 3v10M3 8h10"/>',
235 branch:
236 '<circle cx="4.5" cy="3.5" r="1.5"/><circle cx="4.5" cy="12.5" r="1.5"/><circle cx="11.5" cy="5" r="1.5"/>' +
237 '<path d="M4.5 5v6M11.5 6.5c0 2.5-2 3.5-4 3.8-1.6.2-3 .7-3 .7"/>',
238 search: '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/>',
239 folder: '<path d="M1.75 3.25h4.5l1.5 1.75h6.5v7.75H1.75z"/>',
240 'folder-open': '<path d="M1.75 12.75v-9.5h4.5l1.5 1.75h5v2"/><path d="M1.75 12.75 3.75 7h10.5l-2 5.75z"/>',
241 file: '<path d="M9 1.75H3v12.5h10v-8.5z"/><path d="M9 1.75v4h4"/>',
242 shield: '<path d="M8 1.75 13 3.75v4c0 3.2-2.2 5.4-5 6.5-2.8-1.1-5-3.3-5-6.5v-4z"/>',
243 flag: '<path d="M3 14.5V2"/><path d="M3 2.5h8.5L9.75 5.5l1.75 3H3"/>',
244 check: '<path d="M3 8.5 6 11.5 13 4.5"/>',
245 dot: '<circle cx="8" cy="8" r="3" fill="S"/>',
246 clock: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/>',
247}
248
249/** kit v3: the terminal glyph of each icon. */
250const ICON_GLYPHS: Record<IconName, string> = {
251 eye: '◉',
252 pencil: '✎',
253 plus: '+',
254 branch: '⎇',
255 search: '⌕',
256 folder: '▸',
257 'folder-open': '▾',
258 file: '·',
259 shield: '⛨',
260 flag: '⚑',
261 check: '✓',
262 dot: '●',
263 clock: '◷',
264}
265
266/** kit v3: file-type badges by extension: the label, its Svg fill and its terminal tone. */
267const FILE_TYPES: { exts: string[]; label: string; fill: string; tone: Tone }[] = [
268 { exts: ['js', 'jsx', 'mjs', 'cjs'], label: 'JS', fill: '#F0C419', tone: 'accent' },
269 { exts: ['ts', 'tsx'], label: 'TS', fill: '#3178C6', tone: 'info' },
270 { exts: ['pdf'], label: 'PDF', fill: '#E5484D', tone: 'danger' },
271 { exts: ['md', 'mdx'], label: 'MD', fill: '#4A7BD0', tone: 'info' },
272 { exts: ['doc', 'docx'], label: 'DOC', fill: '#2B579A', tone: 'info' },
273 { exts: ['csv'], label: 'CSV', fill: '#22A35A', tone: 'ok' },
274 { exts: ['json'], label: 'JSON', fill: '#64748B', tone: 'muted' },
275 { exts: ['yml', 'yaml'], label: 'YML', fill: '#64748B', tone: 'muted' },
276 { exts: ['html'], label: 'HTML', fill: '#E8743B', tone: 'accent' },
277 { exts: ['css'], label: 'CSS', fill: '#4A7BD0', tone: 'info' },
278 { exts: ['py'], label: 'PY', fill: '#3776AB', tone: 'info' },
279 { exts: ['sh'], label: 'SH', fill: '#64748B', tone: 'muted' },
280 { exts: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'], label: 'IMG', fill: '#8B5CF6', tone: 'accent' },
281 { exts: ['txt'], label: 'TXT', fill: '#64748B', tone: 'muted' },
282]
283const OTHER_FILL = '#64748B'
284
285const GLYPHS: Record<Role, string> = {
286 main: '◉',
287 recon: '◈',
288 execute: '▣',
289 verify: '✓',
290 hard: '◆',
291 reviewer: '◎',
292 other: '●',
293}
294
295const STATE_TONES: Record<DoodleState, Tone> = {
296 running: 'info',
297 done: 'ok',
298 failed: 'danger',
299 waiting: 'warn',
300 idle: 'muted',
301}
302
303const STATE_COLORS: Record<DoodleState, string> = {
304 running: TONES[STATE_TONES.running].fg,
305 done: TONES[STATE_TONES.done].fg,
306 failed: TONES[STATE_TONES.failed].fg,
307 waiting: TONES[STATE_TONES.waiting].fg,
308 idle: TONES[STATE_TONES.idle].fg,
309}
310
311// Desktop doodles (svg fills are not text colors, so they stay hex): 12×12 cells, 3 px each. B = body (by role), A = accent (by state), . = empty.
312const BODY_FILL: Record<Role, string> = {
313 main: '#6c63ff',
314 recon: '#1f9eaa',
315 execute: '#3b7ddd',
316 verify: '#2f9e55',
317 hard: '#b5523b',
318 reviewer: '#c08a1e',
319 other: '#7d8590',
320}
321
322const ACCENT_FILL: Record<DoodleState, string> = {
323 running: '#22d3ee',
324 done: '#4ade80',
325 failed: '#f87171',
326 waiting: '#facc15',
327 idle: '#e5e7eb',
328}
329
330const SPRITES: Record<Role, string[]> = {
331 // a crowned blob
332 main: [
333 '............',
334 '..A..AA..A..',
335 '..AAAAAAAA..',
336 '..BBBBBBBB..',
337 '.BBBBBBBBBB.',
338 '.BBAABBAABB.',
339 '.BBAABBAABB.',
340 '.BBBBBBBBBB.',
341 '.BBBAAAABBB.',
342 '.BBBBBBBBBB.',
343 '..BB....BB..',
344 '..BB....BB..',
345 ],
346 // a one-eyed scout drone
347 recon: [
348 '....BBBB....',
349 '..BBBBBBBB..',
350 '.BBBBBBBBBB.',
351 'BBBBAAAABBBB',
352 'BBBAAAAAABBB',
353 'BBBAA..AABBB',
354 'BBBAAAAAABBB',
355 'BBBBAAAABBBB',
356 '.BBBBBBBBBB.',
357 '..BBBBBBBB..',
358 '...B.BB.B...',
359 '..B..BB..B..',
360 ],
361 // a boxy robot with an antenna
362 execute: [
363 '.....AA.....',
364 '.....BB.....',
365 '.BBBBBBBBBB.',
366 '.BBBBBBBBBB.',
367 '.BBAABBAABB.',
368 '.BBAABBAABB.',
369 '.BBBBBBBBBB.',
370 '.BBAAAAAABB.',
371 '.BBBBBBBBBB.',
372 '...BBBBBB...',
373 '..BBB..BBB..',
374 '..BB....BB..',
375 ],
376 // a shield with a check mark
377 verify: [
378 '.BBBBBBBBBB.',
379 'BBBBBBBBBBBB',
380 'BBBBBBBBBBBB',
381 'BBBBBBBBBAAB',
382 'BBBBBBBBAABB',
383 'BAABBBBAABBB',
384 'BBAABBAABBBB',
385 '.BBAAAABBBB.',
386 '.BBBAABBBBB.',
387 '..BBBBBBBB..',
388 '...BBBBBB...',
389 '.....BB.....',
390 ],
391 // a gem golem
392 hard: [
393 '.....BB.....',
394 '....BBBB....',
395 '...BBBBBB...',
396 '..BBBBBBBB..',
397 '.BBAABBAABB.',
398 'BBBAABBAABBB',
399 'BBBBBBBBBBBB',
400 '.BBBBAABBBB.',
401 '..BBBBBBBB..',
402 '...BBBBBB...',
403 '....BBBB....',
404 '.....BB.....',
405 ],
406 // a round face with glasses
407 reviewer: [
408 '...BBBBBB...',
409 '..BBBBBBBB..',
410 '.BBBBBBBBBB.',
411 'BBBBBBBBBBBB',
412 'BAAAABBAAAAB',
413 'BA..AAAA..AB',
414 'BAAAABBAAAAB',
415 'BBBBBBBBBBBB',
416 'BBBBAAAABBBB',
417 '.BBBBBBBBBB.',
418 '..BBBBBBBB..',
419 '...BBBBBB...',
420 ],
421 // a ghost
422 other: [
423 '....BBBB....',
424 '..BBBBBBBB..',
425 '.BBBBBBBBBB.',
426 '.BBBBBBBBBB.',
427 'BBBAABBAABBB',
428 'BBBAABBAABBB',
429 'BBBBBBBBBBBB',
430 'BBBBBBBBBBBB',
431 'BBBBBBBBBBBB',
432 'BBBBBBBBBBBB',
433 'BBB.BBBB.BBB',
434 'B....BB....B',
435 ],
436}
437
438const CELL = 3
439const GRID = 12
440
441// ---------------------------------------------------------------- formatters
442
443/** '≈$0.08', '≈$1.2', '≈$12'; '$0' for zero, '<$0.01' below a cent, '—' when unknown. */
444export function money(usd: number | null | undefined): string {
445 if (typeof usd !== 'number' || !Number.isFinite(usd)) return '—'
446 const v = Math.max(0, usd)
447 if (v === 0) return '$0'
448 if (v < 0.005) return '<$0.01'
449 const cents = Math.round(v * 100) / 100
450 if (cents < 1) return '≈$' + cents.toFixed(2)
451 const dimes = Math.round(v * 10) / 10
452 if (dimes < 10) return '≈$' + dimes.toFixed(1)
453 return '≈$' + String(Math.round(v))
454}
455
456/** '0', '950', '1.5k', '37k', '1.2M', '15M'. */
457export function tokensShort(n: number): string {
458 if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '0'
459 if (n < 1000) return String(Math.round(n))
460 const k = n / 1000
461 if (k < 10) {
462 const r = Math.round(k * 10) / 10
463 if (r < 10) return trimZero(r.toFixed(1)) + 'k'
464 }
465 if (Math.round(k) < 1000) return String(Math.round(k)) + 'k'
466 const m = n / 1_000_000
467 const r = Math.round(m * 10) / 10
468 if (r < 10) return trimZero(r.toFixed(1)) + 'M'
469 return String(Math.round(m)) + 'M'
470}
471
472/** '0:40', '10:05', '1:02:03'. */
473export function clock(ms: number): string {
474 const total = typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
475 const h = Math.floor(total / 3600)
476 const m = Math.floor((total % 3600) / 60)
477 const s = total % 60
478 if (h > 0) return `${h}:${pad2(m)}:${pad2(s)}`
479 return `${m}:${pad2(s)}`
480}
481
482/** '4%', '100%'. Not capped at 100 (a budget can run over); negative and NaN read 0%. */
483export function pct(ratio: number): string {
484 if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio <= 0) return '0%'
485 return String(Math.round(ratio * 100)) + '%'
486}
487
488/** '━━━━╌╌╌╌╌╌' for 0.4 at width 10; the ratio is clamped to 0..1. */
489export function barText(ratio: number, width: number = 10): string {
490 const w = typeof width === 'number' && Number.isFinite(width) && width >= 1 ? Math.floor(width) : 10
491 const filled = Math.round(clamp01(ratio) * w)
492 return '━'.repeat(filled) + '╌'.repeat(w - filled)
493}
494
495function clamp01(ratio: number): number {
496 if (typeof ratio !== 'number' || !Number.isFinite(ratio)) return 0
497 return Math.min(1, Math.max(0, ratio))
498}
499
500function pad2(n: number): string {
501 return n < 10 ? '0' + n : String(n)
502}
503
504function trimZero(s: string): string {
505 return s.endsWith('.0') ? s.slice(0, -2) : s
506}
507
508function toneOf(tone: unknown, fallback: Tone): Tone {
509 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONES, tone) ? (tone as Tone) : fallback
510}
511
512function roleOf(role: unknown): Role {
513 return typeof role === 'string' && Object.prototype.hasOwnProperty.call(GLYPHS, role) ? (role as Role) : 'other'
514}
515
516function stateOf(state: unknown): DoodleState {
517 return typeof state === 'string' && Object.prototype.hasOwnProperty.call(STATE_COLORS, state)
518 ? (state as DoodleState)
519 : 'idle'
520}
521
522/** Drops undefined and false props: some props (Button plain, autoFocus) take only `true`. */
523function clean(props: Record<string, unknown>): Record<string, unknown> {
524 const out: Record<string, unknown> = {}
525 for (const k of Object.keys(props)) {
526 const v = props[k]
527 if (v === undefined || v === false) continue
528 out[k] = v
529 }
530 return out
531}
532
533function present(children: unknown[]): unknown[] {
534 return children.filter(c => c !== null && c !== undefined && c !== false && c !== '')
535}
536
537/** The SVG document of one doodle: a 12×12 sprite at 3 px per cell, runs merged per row. */
538export function doodleSvg(role: Role, state: DoodleState = 'idle'): string {
539 const r = roleOf(role)
540 const s = stateOf(state)
541 const fills: Record<string, string> = { B: BODY_FILL[r], A: ACCENT_FILL[s] }
542 const size = GRID * CELL
543 let rects = ''
544 SPRITES[r].forEach((line, y) => {
545 let x = 0
546 while (x < GRID) {
547 const ch = line[x] ?? '.'
548 let end = x + 1
549 while (end < GRID && line[end] === ch) end++
550 const fill = fills[ch]
551 if (fill) {
552 rects += `<rect x="${x * CELL}" y="${y * CELL}" width="${(end - x) * CELL}" height="${CELL}" fill="${fill}"/>`
553 }
554 x = end
555 }
556 })
557 return (
558 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" ` +
559 `shape-rendering="crispEdges">${rects}</svg>`
560 )
561}
562
563/** The terminal glyph of a role, as doodle() draws it and as the desktop doodle's alt text. */
564export function glyph(role: Role): string {
565 return GLYPHS[roleOf(role)]
566}
567
568// ---------------------------------------------------------------- kit v3: pure helpers and Svg
569
570/** 'ts' for 'src/a.test.TS', '' for 'Makefile', '.gitignore' or 'notes.'. Lower case, no dot. */
571export function extOf(name: string): string {
572 const base = String(name ?? '').split(/[\\/]/).pop() ?? ''
573 const dot = base.lastIndexOf('.')
574 return dot > 0 && dot < base.length - 1 ? base.slice(dot + 1).toLowerCase() : ''
575}
576
577/**
578 * A path for display: inside `root` it is relative ('.' for the root itself), inside `home` it is
579 * '~/' and the rest, anywhere else '…/' and its last two segments, so no absolute path from outside
580 * the project is drawn. A relative path is already short and comes back as given. Trailing slashes
581 * are dropped.
582 */
583export function shortPath(path: string, root: string, home: string): string {
584 const p = trimSlash(path)
585 const r = trimSlash(root)
586 const h = trimSlash(home)
587 if (!p.startsWith('/')) return p
588 if (r && p === r) return '.'
589 if (r && r !== '/' && p.startsWith(r + '/')) return p.slice(r.length + 1)
590 if (h && p === h) return '~'
591 if (h && h !== '/' && p.startsWith(h + '/')) return '~/' + p.slice(h.length + 1)
592 return '…/' + p.split('/').filter(Boolean).slice(-2).join('/')
593}
594
595function trimSlash(s: string): string {
596 const t = String(s ?? '')
597 return t.length > 1 ? t.replace(/\/+$/, '') || '/' : t
598}
599
600const SVG_CACHE = new Map<string, string>()
601const SVG_CACHE_MAX = 512
602
603/** Builds each distinct drawing once; the cache is cleared when it grows past its bound. */
604function cached(key: string, build: () => string): string {
605 const hit = SVG_CACHE.get(key)
606 if (hit !== undefined) return hit
607 if (SVG_CACHE.size >= SVG_CACHE_MAX) SVG_CACHE.clear()
608 const svg = build()
609 SVG_CACHE.set(key, svg)
610 return svg
611}
612
613function xml(s: string): string {
614 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
615}
616
617/** Dark text on a light fill (relative luminance above one half), light text otherwise. */
618function inkOn(hex: string): string {
619 const n = parseInt(hex.slice(1), 16)
620 const lin = (c: number) => {
621 const v = c / 255
622 return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
623 }
624 const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255)
625 return l > 0.5 ? INK_DARK : INK_LIGHT
626}
627
628function pillToneOf(tone: unknown, fallback: PillTone): PillTone {
629 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONE_HEX, tone) ? (tone as PillTone) : fallback
630}
631
632function iconOf(name: unknown): IconName {
633 return typeof name === 'string' && Object.prototype.hasOwnProperty.call(ICONS, name) ? (name as IconName) : 'dot'
634}
635
636const FONT = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
637
638/** A label in a rounded rect: the shape of a pill (height 18) and of a type badge (height 16). */
639function labelSvg(text: string, fill: string, width: number, height: number, rx: number, size: number, ink = inkOn(fill)): string {
640 return (
641 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` +
642 `<rect width="${width}" height="${height}" rx="${rx}" fill="${fill}"/>` +
643 `<text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="central" ${FONT} ` +
644 `font-size="${size}" font-weight="700" fill="${ink}">${xml(text)}</text></svg>`
645 )
646}
647
648/** A pill's height in CSS pixels; its corners are fully rounded (rx = height / 2). */
649const PILL_HEIGHT = 18
650
651/** A pill's width in CSS pixels: about 6.5 per character of 11 px bold text plus 7 each side. */
652function pillWidth(text: string): number {
653 return Math.max(PILL_HEIGHT, Math.round(text.length * 6.5 + 14))
654}
655
656/** The Svg of a pill: a fully rounded rect in the tone's CHIP_HEX fill, white text. */
657export function pillSvg(text: string, tone: Tone | 'violet' = 'muted'): string {
658 const t = pillToneOf(tone, 'muted')
659 const s = String(text)
660 return cached(`pill|${t}|${s}`, () => labelSvg(s, CHIP_HEX[t], pillWidth(s), PILL_HEIGHT, PILL_HEIGHT / 2, 11, INK_LIGHT))
661}
662
663function iconSvg(name: IconName, hex: string): string {
664 return cached(`icon|${name}|${hex}`, () =>
665 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none" ` +
666 `stroke="${hex}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">` +
667 `${ICONS[name].replace(/"S"/g, `"${hex}"`)}</svg>`,
668 )
669}
670
671function fileType(fileName: string): { label: string; fill: string; tone: Tone } | null {
672 const ext = extOf(fileName)
673 if (!ext) return null
674 const known = FILE_TYPES.find(t => t.exts.includes(ext))
675 return known ?? { label: ext.slice(0, 3).toUpperCase(), fill: OTHER_FILL, tone: 'muted' }
676}
677
678/** A type badge is 16 high and about 7 px per letter plus 8 wide. */
679function badgeWidth(label: string): number {
680 return Math.max(16, label.length * 7 + 8)
681}
682
683function badgeSvg(label: string, fill: string): string {
684 return cached(`badge|${label}|${fill}`, () => labelSvg(label, fill, badgeWidth(label), 16, 3, 10))
685}
686
687/** A track and a fill, 6 high. Without a width the markup is 100% wide and stretches to the slot. */
688function progressSvg(ratio: number, hex: string, width: number | undefined): string {
689 const full = width ?? 100
690 const fill = Math.round(clamp01(ratio) * full * 10) / 10
691 return cached(`progress|${fill}|${hex}|${width ?? ''}`, () => {
692 const size = width === undefined ? 'width="100%" height="6" preserveAspectRatio="none"' : `width="${width}" height="6"`
693 return (
694 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${full} 6" ${size}>` +
695 `<rect width="${full}" height="6" rx="3" fill="${TRACK_HEX}" opacity="0.25"/>` +
696 (fill > 0 ? `<rect width="${fill}" height="6" rx="3" fill="${hex}"/>` : '') +
697 `</svg>`
698 )
699 })
700}
701
702// ---------------------------------------------------------------- the kit
703
704const MAX_CHIPS = 4
705
706export function makeKit(ui: Elements, surface: Surface, opts: KitOptions = {}): Kit {
707 const desktop = surface === 'desktop'
708 const pillMode = opts.pill ?? PILL
709
710 const el = (type: string, props: Record<string, unknown>, children?: unknown[]): unknown => {
711 const make = ui[type]
712 const p = clean(props)
713 if (children !== undefined) p.children = present(children)
714 return make(p)
715 }
716 const T = (s: string, props: Record<string, unknown> = {}): unknown => el('Text', props, [s])
717 const color = (tone: Tone | undefined): string | undefined => (tone ? TONES[tone].fg : undefined)
718 const pieces = (part: unknown[] | string, right: boolean): unknown[] =>
719 (typeof part === 'string' ? [part] : part).map(c =>
720 typeof c === 'string' || typeof c === 'number'
721 ? T(String(c), { color: right ? QUIET : undefined, wrap: 'truncate' })
722 : c,
723 )
724 const frame = (children: unknown[], tone: Tone | undefined): unknown =>
725 el('Box', { flexDirection: 'column', borderStyle: 'round', borderColor: color(tone) ?? QUIET, paddingX: 1 }, children)
726 const chip = (text: string, tone: Tone): unknown =>
727 T('● ' + text, { color: TONES[tone].fg, bold: tone !== 'muted', wrap: 'truncate' })
728 // kit v3: Svg wherever the table has it (desktop, mobile, vscode); the terminal draws text.
729 const svgOk = surface !== 'terminal' && typeof ui.Svg === 'function'
730 const svg = (source: string, alt: string, width?: number, height?: number): unknown =>
731 el('Svg', { source, alt: alt || ' ', width, height })
732 /** The theme key of a v3 tone on the terminal: the v2 tone's color, violet 'permission'. */
733 const fg = (tone: PillTone): string => (tone === 'violet' ? PILL_FILLS.violet : TONES[tone].fg)
734
735 const kit: Kit = {
736 surface,
737
738 hero(p) {
739 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
740 const title = (p.icon ? p.icon + ' ' : '') + p.title
741 return frame(
742 [
743 p.kicker ? T(p.kicker, { color: QUIET, wrap: 'truncate' }) : null,
744 T(title, { bold: true, color: color(tone), wrap: 'truncate' }),
745 p.sub ? T(p.sub, { color: QUIET, wrap: 'wrap' }) : null,
746 ],
747 tone,
748 )
749 },
750
751 card(p) {
752 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
753 const head =
754 p.title || p.right
755 ? el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
756 p.title ? T(p.title, { bold: true, wrap: 'truncate' }) : null,
757 p.right ? T(p.right, { color: QUIET, wrap: 'truncate' }) : null,
758 ])
759 : null
760 return frame([head, ...(Array.isArray(p.children) ? p.children : [])], tone)
761 },
762
763 section(label, right) {
764 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
765 T(String(label), { bold: true, wrap: 'truncate' }),
766 right ? T(right, { color: QUIET, wrap: 'truncate' }) : null,
767 ])
768 },
769
770 tiles(items) {
771 return el(
772 'Box',
773 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, rowGap: 1 },
774 items.map(it => {
775 const tone = it.tone ? toneOf(it.tone, 'info') : undefined
776 return el(
777 'Box',
778 { flexDirection: 'column', flexGrow: 1, borderStyle: 'single', borderColor: QUIET, paddingX: 1 },
779 [
780 T(it.label, { color: QUIET, wrap: 'truncate' }),
781 T(it.value, { bold: true, color: color(tone), wrap: 'truncate' }),
782 ],
783 )
784 }),
785 )
786 },
787
788 badge(text, tone) {
789 return chip(text, toneOf(tone, 'muted'))
790 },
791
792 legend(items) {
793 const parts: unknown[] = []
794 items.forEach((it, i) => {
795 if (i > 0) parts.push(' ')
796 parts.push(T('●', { color: TONES[toneOf(it.tone, 'muted')].fg }))
797 parts.push(' ' + it.label)
798 })
799 return el('Text', { wrap: 'wrap' }, parts)
800 },
801
802 bar(ratio, opts = {}) {
803 const t = toneOf(opts.tone, 'info')
804 return el('Text', { wrap: 'truncate' }, [
805 T(barText(ratio, opts.width ?? 10), { color: TONES[t].fg }),
806 T(' ' + pct(ratio), { color: QUIET }),
807 ])
808 },
809
810 row(p) {
811 const left = pieces(p.left, false)
812 const right = p.right === undefined || p.right === '' ? [] : pieces(p.right, true)
813 const alignItems = p.align === 'center' ? 'center' : undefined
814 return el('Box', { key: p.key, flexDirection: 'row', justifyContent: 'space-between', alignItems, columnGap: 1 }, [
815 el('Box', { flexDirection: 'row', flexShrink: 1, alignItems, columnGap: 1 }, left),
816 right.length ? el('Box', { flexDirection: 'row', alignItems, columnGap: 1 }, right) : null,
817 ])
818 },
819
820 kv(label, value, tone) {
821 const t = tone ? toneOf(tone, 'info') : undefined
822 return el('Text', { wrap: 'truncate' }, [
823 T(label + ' ', { color: QUIET }),
824 T(value, { bold: true, color: color(t) }),
825 ])
826 },
827
828 buttons(items) {
829 return el(
830 'Box',
831 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
832 items.map(b =>
833 el('Button', {
834 key: b.key,
835 label: b.label,
836 hotkey: b.hotkey,
837 onPress: b.onPress,
838 plain: b.plain === true ? true : undefined,
839 variant: b.variant === 'primary' || b.variant === 'secondary' ? b.variant : undefined,
840 autoFocus: b.autoFocus === true ? true : undefined,
841 }),
842 ),
843 )
844 },
845
846 doodle(role, state = 'idle') {
847 const r = roleOf(role)
848 const s = stateOf(state)
849 if (desktop && typeof ui.Svg === 'function') {
850 return el('Svg', { alt: GLYPHS[r], width: GRID * CELL, height: GRID * CELL, source: doodleSvg(r, s) })
851 }
852 return T(GLYPHS[r], { color: STATE_COLORS[s], bold: s === 'running' })
853 },
854
855 stack(children, gap = 0) {
856 return el('Box', { flexDirection: 'column', rowGap: gap > 0 ? Math.floor(gap) : undefined }, children)
857 },
858
859 page(children) {
860 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
861 },
862
863 list(children) {
864 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
865 },
866
867 text(s, props = {}) {
868 return el('Text', props, [String(s)])
869 },
870
871 line(label, chips, right) {
872 const drawn = (Array.isArray(chips) ? chips : [])
873 .filter(c => c !== null && c !== undefined && c !== false && c !== '')
874 .slice(0, MAX_CHIPS)
875 .map(c => {
876 if (typeof c === 'string') return chip(c, 'muted')
877 if (isChipSpec(c)) return chip(c.text, toneOf(c.tone, 'muted'))
878 return c
879 })
880 const quiet = (Array.isArray(right) ? right.filter(Boolean).join(' · ') : right ?? '').trim()
881 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 2 }, [
882 el('Box', { flexDirection: 'row', flexShrink: 1, columnGap: 2 }, [
883 T(String(label), { bold: true, wrap: 'truncate' }),
884 ...drawn,
885 ]),
886 quiet ? T(quiet, { color: QUIET, wrap: 'truncate' }) : null,
887 ])
888 },
889
890 // ------------------------------------------------------------ kit v3
891
892 pill(text, tone) {
893 const t = pillToneOf(tone, 'muted')
894 const s = String(text)
895 if (!svgOk) return t === 'violet' ? T('● ' + s, { color: fg(t), bold: true, wrap: 'truncate' }) : chip(s, t)
896 if (pillMode === 'svg') return svg(pillSvg(s, t), s, pillWidth(s), PILL_HEIGHT)
897 return T(` ${s} `, { backgroundColor: PILL_FILLS[t], color: 'inverseText', bold: true, wrap: 'truncate' })
898 },
899
900 header(label, right) {
901 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
902 T(String(label).toUpperCase(), { bold: true, color: QUIET, wrap: 'truncate' }),
903 right ? T(String(right).toUpperCase(), { color: QUIET, wrap: 'truncate' }) : null,
904 ])
905 },
906
907 icon(name, tone) {
908 const n = iconOf(name)
909 const t = tone === undefined ? undefined : pillToneOf(tone, 'muted')
910 if (svgOk) return svg(iconSvg(n, t ? TONE_HEX[t] : ICON_STROKE), n, 16, 16)
911 return T(ICON_GLYPHS[n], { color: t ? fg(t) : QUIET })
912 },
913
914 typeBadge(fileName) {
915 const type = fileType(fileName)
916 if (!type) return null
917 if (svgOk) {
918 return svg(badgeSvg(type.label, type.fill), type.label, badgeWidth(type.label), 16)
919 }
920 return T(type.label, { bold: true, color: TONES[type.tone].fg })
921 },
922
923 progress(ratio, opts = {}) {
924 if (!svgOk) return kit.bar(ratio, opts)
925 const t = toneOf(opts.tone, 'info')
926 const width = typeof opts.width === 'number' && Number.isFinite(opts.width) && opts.width >= 1 ? Math.floor(opts.width) : undefined
927 // Without a width the Svg gets no width prop and markup 100% wide: the user's look confirms the
928 // desktop draws it the full width of its slot.
929 return svg(progressSvg(ratio, TONE_HEX[t], width), pct(ratio), width, 6)
930 },
931
932 nav(items, currentKey) {
933 return el(
934 'Box',
935 { key: 'nav', flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
936 (Array.isArray(items) ? items : []).map(it => {
937 if (it.key === currentKey) return T(String(it.label), { bold: true, color: TONES.accent.fg, wrap: 'truncate' })
938 if (typeof it.onPress !== 'function') return T(String(it.label), { color: QUIET, wrap: 'truncate' })
939 return el('Button', { key: it.key, label: String(it.label), hotkey: it.hotkey, onPress: it.onPress, plain: true })
940 }),
941 )
942 },
943 }
944 return kit
945}
946
947function isChipSpec(c: unknown): c is { text: string; tone?: Tone } {
948 return (
949 typeof c === 'object' &&
950 c !== null &&
951 !('type' in c) &&
952 typeof (c as { text?: unknown }).text === 'string'
953 )
954}
955hooks/pane.ts 54 lines1// secret-guard: the pane's view model. Pure: it reads the log rows and the merged config
2// the hooks already keep and returns strings and tones; register.ts draws them with the kit.
3// Nothing here takes `$`, and nothing here ever reads a deny or warn word: only their counts.
4
5import type { LogRow } from '../types'
6import type { Tone } from './kit'
7import { KIND_IDS, PATTERNS, type Config } from './patterns'
8
9export type Outcome = { label: string; tone: Tone }
10export type RuleRow = { key: string; name: string; source: 'built-in' | 'config'; right: string; on: boolean }
11
12/** The newest decision that blocked a call, or null. */
13export function lastBlocked(rows: readonly LogRow[]): LogRow | null {
14 for (let i = rows.length - 1; i >= 0; i--) if (rows[i].action === 'blocked') return rows[i]
15 return null
16}
17
18/** The outcome badge of a log row: blocked (danger), allowed (ok), asked then allowed once (warn). */
19export function outcomeOf(action: LogRow['action']): Outcome {
20 if (action === 'blocked') return { label: 'blocked', tone: 'danger' }
21 if (action === 'warned-allowed') return { label: 'asked → allowed', tone: 'warn' }
22 return { label: 'allowed', tone: 'ok' }
23}
24
25/** Where the word lists apply, as rulesSummary words it. */
26export function wordScope(c: Config): string {
27 return c.publicPaths === null ? 'everywhere' : `only in ${c.publicPaths.length} public path(s)`
28}
29
30/**
31 * One row per built-in pattern id (on, or off when disabledKinds lists it), then one row per
32 * non-empty word list from the config files, counted and never listed.
33 */
34export function ruleRows(c: Config): RuleRow[] {
35 const disabled = new Set(c.disabledKinds)
36 const rows: RuleRow[] = KIND_IDS.map(id => {
37 const p = PATTERNS.find(x => x.id === id)!
38 const on = !disabled.has(id)
39 return { key: 'rule-' + id, name: p.kind, source: 'built-in', right: on ? p.action : 'off', on }
40 })
41 if (c.deny.length > 0) {
42 rows.push({ key: 'rule-deny-words', name: `${c.deny.length} deny word(s)`, source: 'config', right: `block · ${wordScope(c)}`, on: true })
43 }
44 if (c.warn.length > 0) {
45 rows.push({ key: 'rule-warn-words', name: `${c.warn.length} warn word(s)`, source: 'config', right: `warn · ${wordScope(c)}`, on: true })
46 }
47 return rows
48}
49
50/** The rules in force: rule rows that are on. */
51export function activeRules(c: Config): number {
52 return ruleRows(c).filter(r => r.on).length
53}
54types/index.d.ts 32 lines1export type LogRow = {
2 at: number
3 kind: string
4 where: string
5 masked: string
6 action: 'blocked' | 'allowed' | 'warned-allowed'
7 agentId?: string
8}
9
10export type GuardConfig = {
11 deny: string[]
12 warn: string[]
13 publicPaths: string[] | null
14 allowPaths: string[]
15 disabledKinds: string[]
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'lk-secret-guard': {
21 enabled: boolean
22 band: boolean
23 log: LogRow[]
24 blocked: number
25 allowed: number
26 config: GuardConfig | null
27 configFiles: string[]
28 configErrors: string[]
29 }
30 }
31}
32