SLOPSHOPPER

secret-mod

Ask for credentials in a form, keep them in the macOS keychain, and run commands with them without showing the values to Claude

bandguardtoasttoolprocess
v0.1.0MITupdated 2026-10-17zyx1121/secret-mod
A shopper browsing a rack in a slop shop
README

<h1>secret-mod</h1>

CI Claude Code macOS License

English | 繁體中文 | 简体中文

When Claude needs a password or a token, it asks for it with a tool. You type the value into a form above the prompt.

╔═[ WinFra GitLab personal access token ]═══════════════ NEW ═╗
║ account : [_______________________________________________] ║
║ token   : [_______________________________________________] ║
╟─────────────────────────────────────────────────────────────╢
║                    < Save >    < Cancel >                   ║
╚═════════════════════════════════════════════════════════════╝

The value goes to the login keychain, and Claude gets only the name.

Features

  • Form above the prompt: a retro dialog with a double frame, the purpose as its title, one line per field and < Save > < Cancel >. Enter moves to the next field.
  • Keychain storage: each secret is a generic password kilo/<name>. The value goes to security through stdin, never through argv.
  • Index without values: list reads names, accounts and comments from the keychain.
  • Use without seeing: run starts a command with the secrets as env vars and masks every value in the output.
  • Update and delete: ask with the same name updates an item and keeps its account. delete asks you first.

Quick start

Function hooks are in early access. Turn them on in your shell profile:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

Then install the mod in Claude Code:

/plugin marketplace add zyx1121/secret-mod
/plugin install secret-mod@secret-mod

Usage

The mod adds 4 tools, listed as mcp__secret-mod__<name>:

ToolDoes
askShows the form for kilo/<name>. It returns at once, and a message starts the next turn when you save or cancel
runRuns zsh -c <command> with the secrets as env vars, output masked
listLists the kilo/ items with their env var names
deleteDeletes kilo/<name> after you confirm

The form takes the keyboard after a click or ctrl+x tab.

For the secret nycu-portal, run sets:

Env varValue
NYCU_PORTALthe stored value
NYCU_PORTAL_ACCOUNTthe account
NYCU_PORTAL_<FIELD>each field, when the item holds several (stored as JSON)

Limits

  • Masking stops the value itself, not what a command derives from it. A hash or a length still reaches Claude.
  • Claude can still read the keychain with Bash (security find-generic-password -w). Add a deny rule in settings.json to block it.
  • The form shows what you type in plain text.
  • macOS only.

For AI agents

  • Summary: Claude Code function-hooks mod that collects credentials in a form, keeps them in the macOS login keychain and runs commands with them without returning the values to the model.
  • Type: plugin
  • Interfaces: tools mcp__secret-mod__ask, run, list, delete; AbovePrompt form
  • Install: /plugin marketplace add zyx1121/secret-mod, then /plugin install secret-mod@secret-mod
  • Run: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .
  • Verify: bunx -p typescript tsc -p tsconfig.json && CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .
  • Config: none
  • Secrets: stored by the mod in the login keychain under kilo/<name>
  • Limits: macOS. Masking does not cover derived output.

Development

bunx -p typescript tsc -p tsconfig.json                    # typecheck
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .   # the engine's test harness

types/claude-code.d.ts is the engine contract that Claude Code writes for mods. When the engine changes, copy the new version and let the typecheck show what moved.

License

MIT

Source 3 files
hooks/register.tsx 336 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { SecretMeta, SecretRequest } from '../types'
5import { envFor, envPrefix, FIELD, keychainAttr, mask, metaKey, PREFIX, toHex } from './secrets'
6
7// Values never leave this module: the model gets names, env var names and
8// masked output only.
9
10const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/
11const MAX_OUTPUT = 30_000
12
13const pending = atom({ plugin: 'secret-mod', key: 'pending' } as const, [] as SecretRequest[])
14
15// Typed values of the open form, keyed by request id then field.
16const drafts: Record<string, Record<string, string>> = {}
17
18const text = (s: string) => ({ result: s })
19const fail = (s: string) => ({ result: s, isError: true as const })
20const quote = (s: string) => `"${s.replace(/["\\\n]/g, ' ')}"`
21
22type Item = { name: string; account: string; comment: string }
23
24// `security dump-keychain` without -d prints attributes only, never values.
25async function listItems($: any): Promise<Item[]> {
26  const { stdout } = await $.process.run(['security', 'dump-keychain'], { timeoutMs: 20_000 })
27  const items: Item[] = []
28  for (const block of stdout.split(/^keychain: /m)) {
29    if (!/^class: "genp"/m.test(block)) continue
30    const attr = (k: string) => keychainAttr(block, k)
31    const service = attr('svce')
32    if (!service.startsWith(PREFIX)) continue
33    items.push({ name: service.slice(PREFIX.length), account: attr('acct'), comment: attr('icmt') })
34  }
35  return items.sort((a, b) => a.name.localeCompare(b.name))
36}
37
38async function readValue($: any, name: string): Promise<string | undefined> {
39  const r = await $.process.run(['security', 'find-generic-password', '-s', PREFIX + name, '-w'])
40  return r.exitCode === 0 ? r.stdout.replace(/\n$/, '') : undefined
41}
42
43async function readAccount($: any, name: string): Promise<string> {
44  const r = await $.process.run(['security', 'find-generic-password', '-s', PREFIX + name])
45  return keychainAttr(r.stdout, 'acct')
46}
47
48async function save($: any, req: SecretRequest): Promise<string | undefined> {
49  const draft = drafts[req.id] ?? {}
50  const account = req.account ? (draft.account ?? '').trim() : 'token'
51  if (req.account && !account) return 'Fill in the account.'
52  const missing = req.fields.filter(f => !draft[f])
53  if (missing.length) return `Fill in ${missing.join(', ')}.`
54
55  const [only] = req.fields
56  const value =
57    req.fields.length === 1 && only
58      ? draft[only] ?? ''
59      : JSON.stringify(Object.fromEntries(req.fields.map(f => [f, draft[f]])))
60  // The value goes through stdin as hex, never through argv.
61  const line = [
62    'add-generic-password -U',
63    `-s ${quote(PREFIX + req.name)}`,
64    `-a ${quote(account)}`,
65    `-l ${quote(`${PREFIX}${req.name}`)}`,
66    `-j ${quote(req.purpose)}`,
67    `-X ${toHex(value)}`,
68  ].join(' ')
69  const r = await $.process.run(['security', '-i'], { stdin: line + '\n' })
70  if (r.exitCode !== 0 || /error/i.test(r.stdout + r.stderr)) return 'The keychain refused the item.'
71
72  const meta: SecretMeta = {
73    account,
74    fields: req.fields,
75    purpose: req.purpose,
76    updatedAt: new Date().toISOString(),
77  }
78  await $.store.set(metaKey(req.name), meta)
79  return undefined
80}
81
82async function finish($: any, req: SecretRequest, message: string) {
83  delete drafts[req.id]
84  await update($, pending, list => list.filter(r => r.id !== req.id))
85  await $.prompt.submit({ text: message })
86}
87
88export const register: Register = on => {
89  on('session.start', async ($, e, next) => {
90    await $.tool.register({
91      name: 'ask',
92      description:
93        'Ask Loki to type a credential into a form. The value is saved to the macOS login keychain as kilo/<name>; you never see it. Returns at once: a message arrives when Loki saves or cancels, so end your turn after calling. Then use the run tool with the name.',
94      inputSchema: {
95        type: 'object',
96        properties: {
97          name: { type: 'string', description: 'System name, lowercase with hyphens, e.g. "gitlab-winfra"' },
98          purpose: { type: 'string', description: 'One plain sentence saying what this credential is, in the language Loki uses (e.g. "WinFra GitLab 的個人存取權杖"). It is the form title and the keychain comment. Do not repeat the name' },
99          account: { type: 'boolean', description: 'Also ask for the login identity (email, username). Default true' },
100          fields: {
101            type: 'array',
102            items: { type: 'string' },
103            description: 'Secret field names, lowercase, e.g. ["password"] or ["token"]. Default ["password"]',
104          },
105        },
106        required: ['name', 'purpose'],
107      },
108    })
109    await $.tool.register({
110      name: 'run',
111      description:
112        'Run a shell command (zsh -c) with keychain secrets as env vars. For secret "nycu-portal": NYCU_PORTAL is the stored value, NYCU_PORTAL_ACCOUNT the account, and NYCU_PORTAL_<FIELD> each field of a multi-field secret. Secret values in the output are replaced by [kilo/<name>]. Never print a value on purpose.',
113      inputSchema: {
114        type: 'object',
115        properties: {
116          command: { type: 'string' },
117          secrets: { type: 'array', items: { type: 'string' }, description: 'Names without the kilo/ prefix' },
118          cwd: { type: 'string' },
119          timeout_ms: { type: 'number', description: 'Default 120000, at most 600000' },
120        },
121        required: ['command', 'secrets'],
122      },
123    })
124    await $.tool.register({
125      name: 'list',
126      description: 'List the kilo/ secrets in the keychain: name, account, purpose, and the env var names run sets. Never values.',
127      inputSchema: { type: 'object', properties: {} },
128    })
129    await $.tool.register({
130      name: 'delete',
131      description: 'Delete kilo/<name> from the keychain. Loki confirms in a dialog first. To change a value, call ask with the same name instead.',
132      inputSchema: {
133        type: 'object',
134        properties: { name: { type: 'string', description: 'Name without the kilo/ prefix' } },
135        required: ['name'],
136      },
137    })
138    return next(e)
139  })
140
141  on('tool.call', { tool: 'mcp__secret-mod__ask' }, async ($, e: any) => {
142    const name = String(e.name ?? '').replace(/^kilo\//, '')
143    if (!NAME.test(name)) return fail('name must be lowercase letters, digits and hyphens.')
144    const fields: string[] = Array.isArray(e.fields) && e.fields.length ? e.fields.map(String) : ['password']
145    if (!fields.every(f => FIELD.test(f)) || new Set(fields).size !== fields.length)
146      return fail('fields must be unique lowercase names like "password" or "api_key".')
147    const req: SecretRequest = {
148      id: `${name}-${Date.now().toString(36)}`,
149      name,
150      purpose: String(e.purpose ?? ''),
151      account: e.account !== false,
152      fields,
153      exists: (await readValue($, name)) !== undefined,
154    }
155    // An update keeps the stored account unless Loki edits it.
156    drafts[req.id] = req.exists && req.account ? { account: await readAccount($, name) } : {}
157    await update($, pending, list => [...list, req])
158    $.ui.toast(`Secret form for ${PREFIX}${name} is above the prompt: click it or press ctrl+x tab.`)
159    return text(
160      `Form opened for ${PREFIX}${name}. End your turn now: a message arrives when Loki saves or cancels.`,
161    )
162  }).catch(() => fail('secret-mod: the form did not open.'))
163
164  on('tool.call', { tool: 'mcp__secret-mod__delete' }, async ($: any, e: any) => {
165    const name = String(e.name ?? '').replace(/^kilo\//, '')
166    if (!NAME.test(name)) return fail('name must be lowercase letters, digits and hyphens.')
167    if ((await readValue($, name)) === undefined) return fail(`No keychain item ${PREFIX}${name}.`)
168    let answer = 'Keep'
169    try {
170      answer = await $.ui.ask(`Delete ${PREFIX}${name} from the keychain?`, { options: ['Keep', 'Delete'], header: 'Secret' })
171    } catch {}
172    if (answer !== 'Delete') return text(`Loki kept ${PREFIX}${name}.`)
173    const r = await $.process.run(['security', 'delete-generic-password', '-s', PREFIX + name])
174    if (r.exitCode !== 0) return fail(`The keychain refused to delete ${PREFIX}${name}.`)
175    await $.store.delete(metaKey(name))
176    return text(`Deleted ${PREFIX}${name}.`)
177  }).catch(() => fail('secret-mod: the delete failed.'))
178
179  on('tool.call', { tool: 'mcp__secret-mod__list' }, async ($: any) => {
180    const items = await listItems($)
181    if (items.length === 0) return text('No kilo/ secrets yet.')
182    const lines: string[] = []
183    for (const it of items) {
184      const meta = (await $.store.get(metaKey(it.name))) as SecretMeta | undefined
185      const p = envPrefix(it.name)
186      const env = [p, ...(it.account ? [`${p}_ACCOUNT`] : [])]
187      if (meta && meta.fields.length > 1) env.push(...meta.fields.map(f => `${p}_${f.toUpperCase()}`))
188      lines.push(`- ${it.name} (account: ${it.account || '-'}) ${it.comment}\n  env: ${env.join(' ')}`)
189    }
190    return text(lines.join('\n'))
191  }).catch(() => fail('secret-mod: listing the keychain failed.'))
192
193  on('tool.call', { tool: 'mcp__secret-mod__run' }, async ($: any, e: any) => {
194    const names: string[] = Array.isArray(e.secrets) ? e.secrets.map((s: unknown) => String(s).replace(/^kilo\//, '')) : []
195    const env: Record<string, string> = {}
196    const masks: { name: string; values: string[] }[] = []
197    for (const name of names) {
198      if (!NAME.test(name)) return fail(`Bad secret name: ${name}`)
199      const value = await readValue($, name)
200      if (value === undefined) return fail(`No keychain item ${PREFIX}${name}. Use the ask tool first.`)
201      const vars = envFor(name, value, await readAccount($, name))
202      Object.assign(env, vars)
203      masks.push({ name, values: Object.entries(vars).filter(([k]) => !k.endsWith('_ACCOUNT')).map(([, v]) => v) })
204    }
205    const timeoutMs = Math.min(Math.max(Number(e.timeout_ms) || 120_000, 1_000), 600_000)
206    let r
207    try {
208      r = await $.process.run(['/bin/zsh', '-c', String(e.command)], { env, cwd: e.cwd, timeoutMs })
209    } catch (err) {
210      return fail(mask(`The command did not finish: ${String(err)}`, masks))
211    }
212    const clip = (s: string) => (s.length > MAX_OUTPUT ? `${s.slice(0, MAX_OUTPUT)}\n[cut ${s.length - MAX_OUTPUT} chars]` : s)
213    // Mask the streams alone, so a short value cannot eat the exit line.
214    const stdout = mask(r.stdout, masks)
215    const stderr = mask(r.stderr, masks)
216    const out = [`exit ${r.exitCode}`, stdout && `stdout:\n${clip(stdout)}`, stderr && `stderr:\n${clip(stderr)}`]
217      .filter(Boolean)
218      .join('\n')
219    return r.exitCode === 0 ? text(out) : fail(out)
220  }).catch(() => fail('secret-mod: the run failed before the command started.'))
221
222  // The form is drawn inline in the band above the prompt, over other mods' bands.
223  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
224    if (e.props.hasSurvey) return next(e)
225    const list = await read($, pending)
226    const req = list[0]
227    if (!req) return next(e)
228    const { Box, Text, Input, Button } = $.ui.resolve(e) as any
229    const band = e.requestId
230    const draft = (drafts[req.id] ??= {})
231    const keys = [...(req.account ? ['account'] : []), ...req.fields]
232
233    const submit = async () => {
234      const problem = await save($, req)
235      if (problem) return $.ui.toast(problem)
236      const p = envPrefix(req.name)
237      await finish(
238        $,
239        req,
240        `secret-mod: Loki saved ${PREFIX}${req.name}. Use the run tool with secrets ["${req.name}"]; env ${p}${req.account ? `, ${p}_ACCOUNT` : ''}${req.fields.length > 1 ? ', ' + req.fields.map(f => `${p}_${f.toUpperCase()}`).join(', ') : ''}. Continue the task.`,
241      )
242    }
243    const cancel = () => finish($, req, `secret-mod: Loki cancelled the form for ${PREFIX}${req.name}.`)
244    const nextField = (k: string) => {
245      const i = keys.indexOf(k)
246      const after = keys[i + 1]
247      if (after) void $.ui.focus({ requestId: band, key: after })
248      else void submit()
249    }
250
251    // Other mods' bands may share this row, so the frame takes the width it is
252    // given: the edges are fixed, the runs of line between them stretch.
253    const span = Math.max(40, e.props.bodyColumns ?? 80)
254    const labelWidth = Math.max(...keys.map(k => k.length)) + 1
255    const tag = ` ${req.exists ? 'UPDATE' : 'NEW'}${list.length > 1 ? ` +${list.length - 1}` : ''} `
256    const line = (ch: string) => (
257      <Box flexGrow={1} width={1} height={1} overflow="hidden">
258        <Text color="cyan">{ch.repeat(span)}</Text>
259      </Box>
260    )
261    const fixed = (s: string) => (
262      <Box flexShrink={0}>
263        <Text color="cyan">{s}</Text>
264      </Box>
265    )
266    const edge = (key: string, left: string, fill: string, right: string, middle?: unknown) => (
267      <Box key={key} flexDirection="row">
268        {fixed(left)}
269        {middle}
270        {line(fill)}
271        {fixed(right)}
272      </Box>
273    )
274    const row = (key: string, child: unknown) => (
275      <Box key={key} flexDirection="row">
276        {fixed('║ ')}
277        <Box flexGrow={1} width={1}>
278          {child}
279        </Box>
280        {fixed(' ║')}
281      </Box>
282    )
283    const title = (
284      <Box flexShrink={1} minWidth={5}>
285        <Text color="cyan" wrap="truncate-end">{`[ ${req.purpose || req.name} ]`}</Text>
286      </Box>
287    )
288
289    // A retro dialog in the manner of DOS and dialog(1): double frame, title in
290    // the top edge, one line per field, < buttons >.
291    return (
292      <Box flexDirection="column" flexGrow={1} flexShrink={1}>
293        {edge('top', '╔═', '═', `${tag}═╗`, title)}
294        {keys.map((k, i) =>
295          row(
296            `row-${k}`,
297            <Box flexDirection="row" flexGrow={1}>
298              <Box width={labelWidth} flexShrink={0}>
299                <Text bold>{k}</Text>
300              </Box>
301              <Text>: [</Text>
302              <Box flexGrow={1} width={1} height={1} overflow="hidden">
303                <Input
304                  key={k}
305                  value={draft[k] ?? ''}
306                  placeholder={'_'.repeat(span)}
307                  autoFocus={i === 0 ? true : undefined}
308                  submitLabel={i < keys.length - 1 ? 'next' : 'save'}
309                  onInput={(v: string) => {
310                    draft[k] = v
311                  }}
312                  onSubmit={(v: string) => {
313                    draft[k] = v
314                    nextField(k)
315                  }}
316                />
317              </Box>
318              <Text>]</Text>
319            </Box>,
320          ),
321        )}
322        {edge('rule', '╟', '─', '╢')}
323        {row(
324          'buttons',
325          <Box flexDirection="row" justifyContent="center" gap={4} flexGrow={1}>
326            <Button key="save" plain variant="primary" label="< Save >" onPress={() => void submit()} />
327            <Button key="cancel" plain label="< Cancel >" onPress={() => void cancel()} />
328          </Box>,
329        )}
330        {edge('bottom', '╚', '═', '╝')}
331        {await next(e)}
332      </Box>
333    )
334  })
335}
336
hooks/secrets.ts 46 lines
1// Pure helpers: env var names, masking, keychain encoding.
2
3export const PREFIX = 'kilo/'
4export const FIELD = /^[a-z][a-z0-9_]{0,31}$/
5
6export const envPrefix = (name: string) => name.toUpperCase().replace(/-/g, '_')
7export const metaKey = (name: string) => `meta:${name}`
8export const toHex = (s: string) =>
9  Array.from(new TextEncoder().encode(s), b => b.toString(16).padStart(2, '0')).join('')
10
11// Single-field items keep the raw value; multi-field items keep a JSON object.
12export function envFor(name: string, value: string, account: string): Record<string, string> {
13  const p = envPrefix(name)
14  const env: Record<string, string> = { [p]: value }
15  if (account) env[`${p}_ACCOUNT`] = account
16  try {
17    const parsed = JSON.parse(value)
18    if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
19      for (const [k, v] of Object.entries(parsed)) {
20        if (typeof v === 'string' && FIELD.test(k.toLowerCase())) env[`${p}_${k.toUpperCase()}`] = v
21      }
22    }
23  } catch {}
24  return env
25}
26
27export function mask(out: string, secrets: { name: string; values: string[] }[]): string {
28  let masked = out
29  for (const s of secrets) {
30    for (const v of [...s.values].sort((a, b) => b.length - a.length)) {
31      if (v.length > 0) masked = masked.split(v).join(`[${PREFIX}${s.name}]`)
32    }
33  }
34  return masked
35}
36
37
38// `security` prints an attribute as "text", or as 0xHEX when it holds non-ASCII bytes.
39export function keychainAttr(block: string, key: string): string {
40  const m = block.match(new RegExp(`"${key}"<blob>=(?:0x([0-9A-Fa-f]+)|"([^"]*)")`))
41  if (!m) return ''
42  if (m[2] !== undefined) return m[2]
43  const bytes = new Uint8Array((m[1] ?? '').match(/../g)!.map(h => parseInt(h, 16)))
44  return new TextDecoder().decode(bytes)
45}
46
types/index.d.ts 22 lines
1export type SecretRequest = {
2  id: string
3  name: string
4  purpose: string
5  account: boolean
6  fields: string[]
7  exists: boolean
8}
9
10export type SecretMeta = {
11  account: string
12  fields: string[]
13  purpose: string
14  updatedAt: string
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'secret-mod': { pending: SecretRequest[] }
20  }
21}
22