SLOPSHOPPER

loopkeeper Secret Guard

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

newpanebandguardcommandtoast
★ 1v0.3.1MITupdated 2026-10-12connectwithub/loopkeeper/plugins/lk-secret-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lk-secret-guard
│ ┃ Secret guard ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ secret guard │ ⏺ Read(src/auth.ts) │ ┃ │ Nothing blocked yet │ ⎿ Read 6 lines │ ┃ │ Checking the new text of Write, Edit, │ ⏺ Update(src/auth.ts) │ ┃ │ MultiEdit, NotebookEdit and Bash calls │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ┌──────────────────────────────────────────┐ │ ┃ │ Blocked │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 0 │ │ ┃ └──────────────────────────────────────────┘ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Status: ON Band above the prompt: on › /secret-guard │ ┃ │ ┃ Recent · 0 │ ┃ No decisions yet this session │ ┃ │ ┃ Rules · 11 │ ┃ private key block ● built-in block │ ┃ AWS access key ● built-in block │ ┃ Anthropic API key ● built-in block │ ┃ OpenAI API key ● built-in block │ ┃ GitHub token ● built-in block │ ┃ Slack token ● built-in block │ ┃ Google API key ● built-in block ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Secret guard
╭──────────────────────────────────────────────────────────╮ │ secret guard │ │ Nothing blocked yet │ │ Checking the new text of Write, Edit, MultiEdit, │ │ NotebookEdit and Bash calls │ ╰──────────────────────────────────────────────────────────╯ ┌──────────────────────────────────────────────────────────┐ │ Blocked │ │ 0 │ └──────────────────────────────────────────────────────────┘ Status: ON Band above the prompt: on Recent · 0 No decisions yet this session Rules · 11 private key block ● built-in block AWS access key ● built-in block Anthropic API key ● built-in block OpenAI API key ● built-in block GitHub token ● built-in block Slack token ● built-in block Google API key ● built-in block Stripe live key ● built-in block Twilio API key ● built-in warn JWT ● built-in warn hard-coded secret ● built-in warn Config · 0 No files found (built-in patterns only) 0 allowPaths · word lists apply everywhere c: Close
README

lk-secret-guard

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

  • You paste logs, configs or .env lines into prompts and want to be sure no key lands in a file Claude writes.
  • Some names (a client, a project, an internal host) must never appear in a public repo: put them on the private deny-list.
  • Subagents write files you don't read line by line.

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.

What it does

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:

  • block: the call is refused, and Claude reads why (secret-guard: AWS access key detected in src/x.ts (AKIA…456). Secrets must not be written here. …).
  • warn: you are asked 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.

Install

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.

Config file

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:

  1. ~/.claude/secret-guard.json (global)
  2. <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"]
}
  • deny / warn: words or phrases, matched case-insensitively as whole words (Placeholder does not match Placeholders; spaces in a phrase match any run of whitespace). Deny blocks; warn asks.
  • publicPaths: when set, the word lists apply only to writes whose path matches one of these, and to Bash commands that mention one. When absent, the word lists apply everywhere. Credential patterns always apply everywhere.
  • allowPaths: files exempt from all checks, credential patterns included. .env files are where secrets legitimately live.
  • disabledKinds: pattern ids to turn off.

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.

Commands

| 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.

Pane

/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.

Limits

  • Pattern-based. It catches credential shapes, not every secret. A password with no 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.
  • Bash is never path-exempt. 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.
  • False positives. The generic rule can flag test fixtures, sample configs and type annotations such as 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.
  • Fail closed. If the guard itself crashes or times out on a call, the call is refused with secret-guard failed (…). Run /secret-guard off if the guard is broken.
  • Order. Settings 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. The log, counts and on/off switch live in $.state and last for the session; /clear, /resume and /branch reset them. Only the band setting is saved in the plugin's store.
  • It does not inspect what tools read, what MCP tools send, or what Claude says in chat.

Files

| 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 |

What it runs and touches

From claude plugin validate --strict . and the code:

  • Processes: none. Network: none. Model calls: none.
  • Files: reads ~/.claude/secret-guard.json and <project>/.claude/secret-guard.json; writes none.
  • Stored data: one key, band (the /secret-guard band on|off choice), in the plugin's store. The log and counts stay in session state, masked.
  • Environment: reads HOME.
  • Other: a question (Allow once / Block) on a warn, toasts, the pane.

Notes for directory reviewers

The directory's validator listed these items for this plugin. Each bullet says what the code does and where.

  • "Uses a credential from the user's machine" (policy hold 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.
  • Fields in 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 (warning ICON_MISSING). Added at .claude-plugin/icon.png (512 × 512); its source is docs/assets/icons/lk-secret-guard.svg in the repo.
  • A call named in text (note 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.
  • Its command (note MOD_REGISTERS_SURFACES). It registers one slash command, /secret-guard, at session start (line 63). It registers no tools or agents.
  • Classic hook (note 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.
  • The 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.
Source 5 files
hooks/register.ts 431 lines
1// 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}
431
hooks/patterns.ts 390 lines
1// 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}
390
hooks/kit.ts 955 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
955
hooks/pane.ts 54 lines
1// 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}
54
types/index.d.ts 32 lines
1export 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