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

<h1>secret-mod</h1>
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.
< Save > < Cancel >. Enter moves to the next field.kilo/<name>. The value goes to security through stdin, never through argv.list reads names, accounts and comments from the keychain.run starts a command with the secrets as env vars and masks every value in the output.ask with the same name updates an item and keeps its account. delete asks you first.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
The mod adds 4 tools, listed as mcp__secret-mod__<name>:
| Tool | Does |
|---|---|
ask | Shows the form for kilo/<name>. It returns at once, and a message starts the next turn when you save or cancel |
run | Runs zsh -c <command> with the secrets as env vars, output masked |
list | Lists the kilo/ items with their env var names |
delete | Deletes 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 var | Value |
|---|---|
NYCU_PORTAL | the stored value |
NYCU_PORTAL_ACCOUNT | the account |
NYCU_PORTAL_<FIELD> | each field, when the item holds several (stored as JSON) |
security find-generic-password -w). Add a deny rule in settings.json to block it.mcp__secret-mod__ask, run, list, delete; AbovePrompt form/plugin marketplace add zyx1121/secret-mod, then /plugin install secret-mod@secret-modCLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .bunx -p typescript tsc -p tsconfig.json && CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .kilo/<name>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.
hooks/register.tsx 336 lines1import { 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}
336hooks/secrets.ts 46 lines1// 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}
46types/index.d.ts 22 lines1export 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