Credential vault for Claude Code: macOS Keychain secrets, per-directory grants, injected env, redacted output, pane with import/export

让 Claude Code 使用数据库、服务器、集群的凭证,却看不到密码。 Let Claude Code use your database, server and cluster credentials without ever seeing the passwords.
把密码、私钥、kubeconfig 贴进对话,明文会永久留在聊天记录里,Claude 也常常因此拒绝执行。装上 claude-vault 之后:
«vault:名称.字段»;需要 macOS 和支持 Mod 的 Claude Code。
claude --plugin-dir /path/to/claude-vault
想每次启动都自动加载(包括桌面版),在 ~/.claude/settings.json 里加上:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-vault" } }
/vault。桌面版也可以点输入框上方的「🔐 Vault」按钮,Claude 正在工作时也能立即打开。之后在这个目录里直接跟 Claude 说"查一下 prod-db 的订单表"就行。
vault_exec 工具(Claude 默认会用):指定凭证名和命令,psql、kubectl、ssh 等客户端不用加任何参数就能连上。prod-db 有 $PROD_DB_HOST、$PROD_DB_PASSWORD;Supabase 账号还有登录后得到的 $<名称>_TOKEN,多个角色可以在同一条命令里一起用。#vault:prod-db:同时注入 PGPASSWORD 这类客户端标准变量。每个凭证有哪些变量,在编辑页的「环境变量」里能看到。
.cvault 文件,里面包含配置和密码。.cvault 文件,输入口令,预览后确认。冲突的凭证可以选择覆盖、跳过或另存一份。导入后需要重新授权。| 命令 | 作用 | |
|---|---|---|
/vault | 打开面板 | |
/vault list | 列出凭证和授权状态 | |
| `/vault grant <名称> [read\ | write]` | 授权给当前目录 |
/vault revoke <名称> | 撤销授权 | |
/vault export、/vault import | 导出、导入 |
桌面版的命令菜单里它显示为 /vault:vault,选它和直接输入 /vault 效果一样。
面板快捷键:n 新建 · e 编辑 · g 授权管理 · x 导出 · i 导入 · l 审计日志 · c 一键清理 · b 返回
| 类型 | 需要填写 | 密码 | 客户端自动识别的变量 |
|---|---|---|---|
| PostgreSQL | 主机、用户 | 密码 | PGHOST PGUSER PGPASSWORD 等 |
| MySQL | 主机、用户 | 密码 | MYSQL_HOST MYSQL_PWD 等 |
| Redis | 主机 | 密码 | REDISCLI_AUTH |
| MongoDB | — | 连接串 | MONGODB_URI |
| SSH | 主机、用户 | 私钥文件或密码 | GIT_SSH_COMMAND,密码登录无需 sshpass |
| Kubernetes | — | kubeconfig 文件 | KUBECONFIG |
| Supabase 账号 | 项目地址、登录邮箱 | 密码、anon key | 执行时自动登录,注入 $<名称>_TOKEN(访问令牌) |
| HTTP API Token | Base URL | Token | — |
| 自定义 | — | 自定义 | 在编辑页「高级」里自定义 |
claude plugin validate .
claude plugin test .
接收 $ 的函数必须写在 hooks/register.tsx 顶层,其他文件只放纯函数。请使用与当前 Claude Code 同版本的 claude 命令做校验。
Pasting passwords, keys or kubeconfigs into a chat leaves them in the transcript for good, and Claude often refuses to run such commands. With claude-vault:
«vault:name.field»;Requires macOS and a Claude Code build that supports mods.
claude --plugin-dir /path/to/claude-vault
To load it every time (desktop app included), add to ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-vault" } }
/vault. In the desktop app you can also click the "🔐 Vault" button above the input box, which opens the pane at once even while Claude is working.From then on, just ask Claude, e.g. "check the orders table on prod-db".
vault_exec tool (Claude's default): name the credential and the command; clients like psql, kubectl and ssh connect without any flags.prod-db gets $PROD_DB_HOST and $PROD_DB_PASSWORD; a Supabase account also gets $<NAME>_TOKEN from its login, and several roles can be used in one command.#vault:prod-db: also injects the client's standard variables such as PGPASSWORD.The edit page lists every variable a credential provides.
.cvault file holding the configuration and secrets..cvault file, enter the passphrase, review, confirm. For conflicts, choose overwrite, skip, or keep a copy. Imported credentials need to be granted again.| Command | Effect | |
|---|---|---|
/vault | open the pane | |
/vault list | list credentials and grants | |
| `/vault grant <name> [read\ | write]` | grant to the current directory |
/vault revoke <name> | revoke a grant | |
/vault export, /vault import | export, import |
In the desktop app's command menu it is listed as /vault:vault; picking it is the same as typing /vault.
Pane hotkeys: n new · e edit · g grants · x export · i import · l audit log · c cleanup · b back
| Type | You fill in | Secret | Variables clients read |
|---|---|---|---|
| PostgreSQL | host, user | password | PGHOST PGUSER PGPASSWORD … |
| MySQL | host, user | password | MYSQL_HOST MYSQL_PWD … |
| Redis | host | password | REDISCLI_AUTH |
| MongoDB | — | connection URI | MONGODB_URI |
| SSH | host, user | key file or password | GIT_SSH_COMMAND; password login needs no sshpass |
| Kubernetes | — | kubeconfig file | KUBECONFIG |
| Supabase account | project URL, email | password, anon key | logs in when a command runs and injects $<NAME>_TOKEN (access token) |
| HTTP API token | base URL | token | — |
| Custom | — | your own | define them under "Advanced" on the edit page |
claude plugin validate .
claude plugin test .
Functions that take $ must live at the top level of hooks/register.tsx; other files hold pure code only. Validate with a claude CLI that matches the running Claude Code version.
hooks/register.tsx 1248 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { VaultDirGrants, VaultForm, VaultGrant, VaultImportPreview, VaultMode, VaultProbe, VaultProfile, VaultUsage, VaultView } from '../types'
4import { hasSecretsDeep, redact, redactDeep, remember } from './redact'
5import { dumpReason, isAllowFile, isVaultPath, peekReason, writeReason } from './guard'
6import { HEADER, ITER, envelope, isSealed, parsePlain, sealedBody, unwrap } from './transfer'
7import type { Bundle } from './transfer'
8import { renderPane } from './ui'
9import { aliasKey, effectiveGrants, namedClash, selectProfiles, varsOf } from './select'
10import { envProblem, envProblems, envToText, exampleFor, isDefaultMapping, missingFields, namedVars, parseEnv, secretProblem, templateOf, variantOf } from './templates'
11import type { Actions } from './ui'
12
13// ---------- keychain ----------
14
15const SERVICE = 'claude-vault'
16const SAFE = /^[A-Za-z0-9._-]+$/
17
18const b64encode = (text: string) => {
19 const bytes = new TextEncoder().encode(text)
20 let bin = ''
21 for (const b of bytes) bin += String.fromCharCode(b)
22 return btoa(bin)
23}
24
25const b64decode = (b64: string) =>
26 new TextDecoder().decode(Uint8Array.from(atob(b64), c => c.charCodeAt(0)))
27
28const account = (profile: string, field: string) => {
29 const acct = `${profile}.${field}`
30 if (!SAFE.test(acct)) throw new Error(`invalid account name: ${acct}`)
31 return acct
32}
33
34// Values already read, by account. The redaction table holds the same plaintexts, so this adds
35// no exposure; it is dropped whenever a secret, profile or grant changes in any session.
36const secretCache = new Map<string, string | undefined>()
37
38async function getSecret($: EngineInterface, acct: string): Promise<string | undefined> {
39 if (secretCache.has(acct)) return secretCache.get(acct)
40 const v = await readSecret($, acct)
41 secretCache.set(acct, v)
42 return v
43}
44
45async function readItem($: EngineInterface, acct: string): Promise<string | undefined> {
46 const r = await $.process.run(['/usr/bin/security', 'find-generic-password', '-s', SERVICE, '-a', acct, '-w'])
47 return r.exitCode === 0 ? r.stdout.replace(/\n$/, '') : undefined
48}
49
50async function readSecret($: EngineInterface, acct: string): Promise<string | undefined> {
51 const raw = await readItem($, acct)
52 if (raw === undefined) return undefined
53 // Values the mod writes are prefixed so multi-line secrets survive `security -w`; a long one
54 // is split over `<acct>.partN` items (see setSecret).
55 const split = /^parts:(\d+)$/.exec(raw)
56 if (split) {
57 const parts = await Promise.all(Array.from({ length: Number(split[1]) }, (_, i) => readItem($, partAccount(acct, i + 1))))
58 if (parts.some(x => x === undefined)) return undefined
59 return b64decode(parts.join(''))
60 }
61 return raw.startsWith('b64:') ? b64decode(raw.slice(4)) : raw
62}
63
64const partAccount = (acct: string, i: number) => `${acct}.part${i}`
65
66// `security -i` reads lines of at most 4096 bytes and silently drops the rest, so a long value
67// (a kubeconfig) is stored in pieces well under that.
68const PART = 3000
69
70// Removes the pieces of a split value from `from` on, until one is missing.
71async function deleteParts($: EngineInterface, acct: string, from = 1) {
72 for (let i = from; ; i++) {
73 const r = await $.process.run(['/usr/bin/security', 'delete-generic-password', '-s', SERVICE, '-a', partAccount(acct, i)])
74 if (r.exitCode !== 0) return
75 }
76}
77
78async function hasSecret($: EngineInterface, acct: string): Promise<boolean> {
79 const r = await $.process.run(['/usr/bin/security', 'find-generic-password', '-s', SERVICE, '-a', acct])
80 return r.exitCode === 0
81}
82
83// The value travels on stdin to `security -i`, never on argv, so `ps` cannot see it.
84async function setSecret($: EngineInterface, acct: string, value: string): Promise<void> {
85 if (!SAFE.test(acct)) throw new Error(`invalid account name: ${acct}`)
86 const enc = b64encode(value)
87 const item = (a: string, data: string) => `add-generic-password -U -s ${SERVICE} -a ${a} -l "Claude Vault ${a}" -w ${data}\n`
88 const pieces = enc.length > PART ? Array.from({ length: Math.ceil(enc.length / PART) }, (_, i) => enc.slice(i * PART, (i + 1) * PART)) : []
89 // the pieces first, the item that points at them last
90 const lines = pieces.map((c, i) => item(partAccount(acct, i + 1), c)).join('') + item(acct, pieces.length ? `parts:${pieces.length}` : `b64:${enc}`)
91 const r = await $.process.run(['/usr/bin/security', '-i'], { stdin: lines })
92 if (r.exitCode !== 0 || /error/i.test(r.stderr)) throw new Error('keychain write failed')
93 await deleteParts($, acct, pieces.length + 1)
94 secretCache.delete(acct)
95 // read it back: a value the Keychain shortened must not pass for a stored one
96 if ((await readSecret($, acct)) !== value) throw new Error('keychain write incomplete')
97 await bumpRevision($)
98}
99
100async function deleteSecret($: EngineInterface, acct: string): Promise<void> {
101 await $.process.run(['/usr/bin/security', 'delete-generic-password', '-s', SERVICE, '-a', acct])
102 await deleteParts($, acct)
103 secretCache.delete(acct)
104 await bumpRevision($)
105}
106
107// ---------- sync between sessions ----------
108// Every session keeps the profiles and grants in its own state. A change made in one session
109// writes a new revision; the others notice it (a timer, the pane opening, a tool call) and
110// reload from disk.
111let seenRevision = ''
112let seenAuditMtime = 0
113
114async function bumpRevision($: EngineInterface) {
115 const { dir } = await paths($)
116 seenRevision = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
117 await $.fs.write(`${dir}/revision`, seenRevision)
118}
119
120async function syncFromDisk($: EngineInterface, opts: { usage?: boolean } = {}) {
121 const { dir, audit: auditFile } = await paths($)
122 const rev = await $.fs.read(`${dir}/revision`).catch(() => '')
123 if (rev && rev !== seenRevision) {
124 seenRevision = rev
125 secretCache.clear()
126 await refreshProfiles($)
127 await refreshGrants($)
128 }
129 // usage counts only show in the pane: parse the audit log only for it
130 if (!opts.usage) return
131 const mtime = (await $.fs.stat(auditFile).catch(() => undefined))?.mtimeMs ?? 0
132 if (mtime !== seenAuditMtime) {
133 seenAuditMtime = mtime
134 await loadUsage($)
135 }
136}
137
138const paneIsOpen = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === PANE)
139
140// ---------- native dialogs ----------
141
142// Native macOS dialogs via osascript. Prompt text goes in argv, never spliced into the script.
143const osa = async ($: EngineInterface, lines: string[], args: string[]) => {
144 const argv = ['/usr/bin/osascript']
145 for (const l of ['on run argv', ...lines, 'end run']) argv.push('-e', l)
146 const r = await $.process.run([...argv, ...args], { timeoutMs: 300_000 })
147 return r.exitCode === 0 ? r.stdout.replace(/\n$/, '') : undefined
148}
149
150const askHidden = ($: EngineInterface, prompt: string) =>
151 osa($, [
152 'return text returned of (display dialog (item 1 of argv) default answer "" with hidden answer with title "Claude Vault" with icon caution)',
153 ], [prompt])
154
155const confirmDanger = async ($: EngineInterface, prompt: string, action: string) =>
156 (await osa($, [
157 'return button returned of (display dialog (item 1 of argv) buttons {"取消", (item 2 of argv)} default button "取消" cancel button "取消" with title "Claude Vault" with icon stop)',
158 ], [prompt, action])) === action
159
160const chooseFile = ($: EngineInterface, prompt: string) =>
161 osa($, ['return POSIX path of (choose file with prompt (item 1 of argv))'], [prompt])
162
163const chooseFileName = ($: EngineInterface, prompt: string, defaultName: string) =>
164 osa($, ['return POSIX path of (choose file name with prompt (item 1 of argv) default name (item 2 of argv))'], [prompt, defaultName])
165
166// ---------- profiles & files ----------
167
168type Allow = Record<string, { mode: VaultMode; ttlMinutes?: number }>
169
170const paths = async ($: EngineInterface) => {
171 const home = (await $.env.get('HOME')) ?? ''
172 const dir = `${home}/.claude/vault`
173 return { home, dir, profiles: `${dir}/profiles.json`, grants: `${dir}/grants.json`, audit: `${dir}/audit.log`, run: `${dir}/run` }
174}
175
176const allowPath = (cwd: string) => `${cwd}/.claude/vault.json`
177
178
179const NAME = /^[a-z0-9][a-z0-9_-]{0,40}$/
180
181async function loadProfiles($: EngineInterface): Promise<Record<string, VaultProfile>> {
182 const { profiles } = await paths($)
183 try {
184 return JSON.parse(await $.fs.read(profiles)) as Record<string, VaultProfile>
185 } catch {
186 return {}
187 }
188}
189
190async function saveProfiles($: EngineInterface, all: Record<string, VaultProfile>) {
191 const { profiles } = await paths($)
192 await writePrivate($, profiles, JSON.stringify(all, null, 2) + '\n')
193 await bumpRevision($)
194}
195
196async function loadAllow($: EngineInterface, cwd: string): Promise<{ allow: Allow; raw: string | null }> {
197 try {
198 const raw = await $.fs.read(allowPath(cwd))
199 const parsed = JSON.parse(raw) as { allow?: Allow }
200 return { allow: parsed.allow ?? {}, raw }
201 } catch {
202 return { allow: {}, raw: null }
203 }
204}
205
206// ---------- directory grants ----------
207// Grants belong to a directory (and everything under it) and last until revoked. They live in
208// ~/.claude/vault/grants.json, outside any repository, so a cloned project cannot grant itself.
209
210async function loadGrantFile($: EngineInterface): Promise<VaultDirGrants> {
211 const { grants } = await paths($)
212 try {
213 const parsed = JSON.parse(await $.fs.read(grants)) as { dirs?: VaultDirGrants }
214 return parsed.dirs ?? {}
215 } catch {
216 return {}
217 }
218}
219
220async function saveGrantFile($: EngineInterface, dirs: VaultDirGrants) {
221 const { grants } = await paths($)
222 const clean = Object.fromEntries(Object.entries(dirs).filter(([, e]) => Object.keys(e).length))
223 await writePrivate($, grants, JSON.stringify({ version: 1, dirs: clean }, null, 2) + '\n')
224 await bumpRevision($)
225}
226
227// The session's directory as a real path, so a symlinked spelling meets the same grants.
228async function currentDir($: EngineInterface) {
229 const raw = (await $.session.cwd().catch(() => '')) || cwd
230 const real = (await $.fs.stat(raw, { resolve: true }).catch(() => undefined))?.realPath
231 return real || raw
232}
233
234let lastRawCwd = ''
235
236async function refreshGrants($: EngineInterface) {
237 lastRawCwd = (await $.session.cwd().catch(() => '')) || cwd
238 const dir = await currentDir($)
239 cwd = dir
240 const all = await loadGrantFile($)
241 await update($, dirA, () => dir)
242 await update($, allGrantsA, () => all)
243 await update($, grantsA, () => effectiveGrants(all, dir))
244 await syncStatus($)
245}
246
247// Grants or revokes `name` for `dir` (default: the session's directory); mode 'off' revokes.
248async function setGrant($: EngineInterface, name: string, mode: VaultMode | 'off', dir?: string) {
249 const where = dir ?? (await currentDir($))
250 const all = await loadGrantFile($)
251 const entries = { ...(all[where] ?? {}) }
252 if (mode === 'off') delete entries[name]
253 else entries[name] = { mode, at: now() }
254 all[where] = entries
255 await saveGrantFile($, all)
256 await audit($, { event: mode === 'off' ? 'revoke' : 'grant', profile: name, mode, dir: where })
257 await refreshGrants($)
258 if (mode !== 'off') await warmRedaction($)
259}
260
261// Applies `fn` to every directory's entries (rename, delete) and saves.
262async function editAllGrants($: EngineInterface, fn: (entries: Record<string, { mode: VaultMode; at: number }>) => void) {
263 const all = await loadGrantFile($)
264 for (const entries of Object.values(all)) fn(entries)
265 await saveGrantFile($, all)
266 await refreshGrants($)
267}
268
269// A project's old `.claude/vault.json` allowlist becomes grants on its directory, once, and only
270// when the person had trusted that exact file.
271async function migrateLegacyAllowlist($: EngineInterface) {
272 const { allow, raw } = await loadAllow($, cwd)
273 if (raw === null) return
274 const migrated = ((await $.store.get('migratedAllowlists')) ?? {}) as Record<string, boolean>
275 if (migrated[cwd]) return
276 const trusted = ((await $.store.get('trusted')) ?? {}) as Record<string, string>
277 if (trusted[cwd] === (await sha256(raw))) {
278 const all = await loadGrantFile($)
279 const entries = { ...(all[cwd] ?? {}) }
280 for (const [n, a] of Object.entries(allow)) if (!entries[n]) entries[n] = { mode: a.mode === 'write' ? 'write' : 'read', at: now() }
281 all[cwd] = entries
282 await saveGrantFile($, all)
283 await audit($, { event: 'migrate-allowlist', profiles: Object.keys(allow), dir: cwd })
284 }
285 await $.store.set('migratedAllowlists', { ...migrated, [cwd]: true })
286}
287
288async function sha256(text: string) {
289 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
290 return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
291}
292
293// Creates the file with mode 0600 (umask 077); the content travels on stdin.
294async function writePrivate($: EngineInterface, path: string, content: string) {
295 const r = await $.process.run(
296 ['/bin/sh', '-c', 'umask 077; mkdir -p "$(dirname "$1")" && cat > "$1"', 'sh', path],
297 { stdin: content },
298 )
299 if (r.exitCode !== 0) throw new Error(`write failed: ${path}`)
300}
301
302// Temp files (env files, {secretfile:} files) that belong to a command still running. They are
303// removed when that command ends; the sweep only takes files nobody owns (a reload, a crash),
304// and only once they are older than any command may run (vault_exec and Bash stop at 10 minutes).
305const liveFiles = new Set<string>()
306const ORPHAN_AGE_MS = 15 * 60_000
307
308async function removeFiles($: EngineInterface, files: string[]) {
309 if (!files.length) return
310 await $.process.run(['/bin/rm', '-f', ...files])
311 for (const f of files) liveFiles.delete(f)
312}
313
314async function sweepRun($: EngineInterface) {
315 const { run } = await paths($)
316 const entries = await $.fs.list(run).catch(() => [])
317 const cutoff = now() - ORPHAN_AGE_MS
318 const orphans = entries
319 .filter(x => x.kind === 'file' && x.mtimeMs < cutoff)
320 .map(x => `${run}/${x.name}`)
321 .filter(f => !liveFiles.has(f))
322 await removeFiles($, orphans)
323}
324
325const rand = () => [...crypto.getRandomValues(new Uint8Array(12))].map(b => b.toString(16).padStart(2, '0')).join('')
326
327type Resolved = { env: Record<string, string>; files: string[]; missing: string[]; blocked: string[]; errors: string[] }
328
329// Supabase access tokens by profile, reused until shortly before they expire. Module memory only:
330// a reload or a new secret logs in again.
331const tokenCache = new Map<string, { token: string; until: number }>()
332
333// Password grant against Supabase Auth. The password travels in the request body on stdin.
334async function supabaseToken($: EngineInterface, name: string, url: string, email: string, password: string, anon: string) {
335 const key = `${name}\n${url}\n${email}`
336 const hit = tokenCache.get(key)
337 if (hit && hit.until > now()) return { token: hit.token }
338 const base = url.replace(/\/+$/, '')
339 if (!/^https?:\/\//.test(base)) return { error: `项目地址 ${base} 不是 http(s) 地址` }
340 const r = await $.process.run(
341 ['/bin/sh', '-c', 'curl -sS --max-time 15 -X POST "$1/auth/v1/token?grant_type=password" -H "apikey: $SB_ANON" -H "Content-Type: application/json" --data-binary @-', 'sh', base],
342 { stdin: JSON.stringify({ email, password }), env: { SB_ANON: anon }, timeoutMs: 20_000 },
343 )
344 let body: { access_token?: string; expires_in?: number; error_description?: string; msg?: string; error?: string } = {}
345 try { body = JSON.parse(r.stdout) } catch {}
346 if (!body.access_token) {
347 const why = body.error_description || body.msg || body.error || r.stderr.trim() || `退出码 ${r.exitCode}`
348 return { error: `Supabase 登录失败(${name}):${redact(why).slice(0, 160)}` }
349 }
350 remember(`${name}.token`, body.access_token)
351 tokenCache.set(key, { token: body.access_token, until: now() + Math.max(60, (body.expires_in ?? 3600) - 120) * 1000 })
352 return { token: body.access_token }
353}
354
355// The SSH_ASKPASS helper: prints $SSHPASS, which only the one ssh child carries. No secret in the file.
356async function askpassPath($: EngineInterface) {
357 const { dir } = await paths($)
358 const file = `${dir}/bin/askpass`
359 if (!(await $.fs.exists(file))) {
360 await writePrivate($, file, '#!/bin/sh\nprintf \'%s\\n\' "$SSHPASS"\n')
361 await $.process.run(['/bin/chmod', '700', file])
362 }
363 return file
364}
365
366// Expands a profile's env templates; secrets come from the Keychain and are remembered for redaction.
367async function resolveEnv($: EngineInterface, name: string, p: VaultProfile, only?: Set<string>): Promise<Resolved> {
368 const { run } = await paths($)
369 const env: Record<string, string> = {}
370 const files: string[] = []
371 const missing: string[] = []
372 const blocked: string[] = []
373 const errors: string[] = []
374 const own = new Set(Object.keys(namedVars(name, p, true)))
375 const cache = new Map<string, string | undefined>()
376 const secret = async (field: string) => {
377 if (!cache.has(field)) {
378 const v = await getSecret($, account(name, field))
379 if (v !== undefined) {
380 remember(`${name}.${field}`, v)
381 const bad = secretProblem(p.type, field, v)
382 if (bad) errors.push(`${name}.${field} ${bad},请在 /vault 面板里重新设置`)
383 }
384 cache.set(field, v)
385 }
386 return cache.get(field)
387 }
388
389 for (const [key, tpl] of Object.entries(p.env)) {
390 if (!own.has(key) && envProblem(key, tpl)) { blocked.push(key); continue }
391 if (only && !only.has(key)) continue
392 let out = ''
393 let ok = true
394 for (const part of tpl.split(/(\{[^}]+\})/)) {
395 const m = /^\{(secret|secretfile):([A-Za-z0-9_-]+)\}$/.exec(part)
396 if (m) {
397 const v = await secret(m[2])
398 if (v === undefined) { ok = false; missing.push(`${name}.${m[2]}`); break }
399 if (m[1] === 'secret') out += v
400 else {
401 const file = `${run}/${rand()}`
402 liveFiles.add(file)
403 await writePrivate($, file, v.endsWith('\n') ? v : v + '\n')
404 files.push(file)
405 out += file
406 }
407 continue
408 }
409 if (part === '{askpass}') { out += await askpassPath($); continue }
410 if (part === '{supabase_token}') {
411 const pw = await secret('password')
412 const anon = await secret('anon_key')
413 if (pw === undefined || anon === undefined) { ok = false; if (pw === undefined) missing.push(`${name}.password`); if (anon === undefined) missing.push(`${name}.anon_key`); break }
414 const t = await supabaseToken($, name, p.host ?? '', p.user ?? '', pw, anon)
415 if ('error' in t) { ok = false; errors.push(t.error!); break }
416 out += t.token
417 continue
418 }
419 const f = /^\{(host|port|user|database)\}$/.exec(part)
420 out += f ? String(p[f[1] as 'host'] ?? '') : part
421 }
422 if (ok) env[key] = out
423 }
424 return { env, files, missing, blocked, errors: [...new Set(errors)] }
425}
426
427// Loads the secrets of every granted profile into the redaction table, so output stays masked
428// after a reload (the table lives in module memory) and before a profile's first use.
429async function warmRedaction($: EngineInterface) {
430 const profiles = (await read($, profilesA)) as Record<string, VaultProfile>
431 const wanted = Object.keys(await activeGrants($)).flatMap(n => (profiles[n]?.secrets ?? []).map(f => [n, f] as const))
432 await Promise.all(wanted.map(async ([n, f]) => {
433 const v = await getSecret($, account(n, f))
434 if (v !== undefined) remember(`${n}.${f}`, v)
435 }))
436}
437
438// Runs a command with one profile's variables (its own and its client's). Refuses before running
439// when a secret is missing or a client variable is not allowed; temp files go when it ends.
440async function runWithProfile($: EngineInterface, name: string, p: VaultProfile, command: string, timeoutMs: number) {
441 const r = await resolveEnv($, name, { ...p, env: varsOf(name, p) })
442 try {
443 if (r.blocked.length) return { deny: `vault: profile ${name} 的客户端变量 ${r.blocked.join(', ')} 不允许设置,请在 /vault 面板里修改。` }
444 if (r.missing.length) return { deny: `vault: 钥匙串里缺少密文 ${r.missing.join(', ')},请在 /vault 面板里设置。` }
445 if (r.errors.length) return { deny: `vault: ${r.errors.join(';')}` }
446 const started = now()
447 const ran = await $.process.run(['/bin/bash', '-c', command], { env: r.env, timeoutMs })
448 return { ...ran, ms: now() - started }
449 } finally {
450 await removeFiles($, r.files)
451 }
452}
453
454// Keychain accounts under the vault's service, from attribute dumps only (no secret is printed).
455async function vaultAccounts($: EngineInterface): Promise<string[]> {
456 const r = await $.process.run(['/usr/bin/security', 'dump-keychain'], { timeoutMs: 60_000 })
457 if (r.exitCode !== 0) return []
458 const out: string[] = []
459 for (const block of r.stdout.split(/^keychain: /m)) {
460 if (!/"svce"<blob>="claude-vault"/.test(block)) continue
461 const acct = /"acct"<blob>="([^"]*)"/.exec(block)?.[1]
462 if (acct && SAFE.test(acct)) out.push(acct)
463 }
464 return [...new Set(out)]
465}
466
467async function strayRunFiles($: EngineInterface) {
468 const { run } = await paths($)
469 const entries = await $.fs.list(run).catch(() => [])
470 return entries.filter(x => x.kind === 'file').map(x => `${run}/${x.name}`).filter(f => !liveFiles.has(f))
471}
472
473async function deleteProfile($: EngineInterface, n: string) {
474 const all = await loadProfiles($)
475 for (const f of all[n]?.secrets ?? []) await deleteSecret($, account(n, f))
476 delete all[n]
477 await saveProfiles($, all)
478 await editAllGrants($, entries => { delete entries[n] })
479 await audit($, { event: 'delete-profile', profile: n })
480}
481
482// What a full wipe would remove, for the confirmation.
483async function wipeSummary($: EngineInterface) {
484 const profiles = await loadProfiles($)
485 // the dump finds leftovers too; the profiles' own accounts are added in case it found nothing
486 const own = Object.entries(profiles).flatMap(([n, p]) => (p.secrets ?? []).map(f => `${n}.${f}`)).filter(a => SAFE.test(a))
487 const accounts = [...new Set([...(await vaultAccounts($)), ...own])]
488 const all = await loadGrantFile($)
489 const grants = Object.values(all).reduce((n, e) => n + Object.keys(e).length, 0)
490 const stray = await strayRunFiles($)
491 return { profiles: Object.keys(profiles), accounts, dirs: Object.keys(all).length, grants, stray }
492}
493
494// Removes everything the vault keeps: every Keychain item under its service (profile secrets and
495// leftovers alike), profiles, grants on every directory, temp files, probe results and the audit log.
496async function wipeAll($: EngineInterface) {
497 const sum = await wipeSummary($)
498 for (const a of sum.accounts) await deleteSecret($, a)
499 await saveProfiles($, {})
500 await saveGrantFile($, {})
501 await removeFiles($, sum.stray)
502 const { audit: file } = await paths($)
503 await writePrivate($, file, '')
504 await update($, probesA, () => ({}))
505 await update($, usageA, () => ({}))
506 await update($, selectedA, () => '')
507 await audit($, { event: 'wipe-all', profiles: sum.profiles.length, keychain: sum.accounts.length, grants: sum.grants })
508 await refreshProfiles($)
509 await refreshGrants($)
510 return sum
511}
512
513// ---------- export encryption (system openssl; the passphrase rides in the child env, never argv) ----------
514async function seal($: EngineInterface, bundle: Bundle, pass: string): Promise<string> {
515 const json = JSON.stringify(bundle)
516 const r = await $.process.run(
517 ['/usr/bin/openssl', 'enc', '-aes-256-cbc', '-pbkdf2', '-iter', ITER, '-md', 'sha256', '-salt', '-a', '-A', '-pass', 'env:VAULT_EXPORT_PASS'],
518 { stdin: envelope(json, await sha256(json)), env: { VAULT_EXPORT_PASS: pass }, timeoutMs: 120_000 },
519 )
520 if (r.exitCode !== 0) throw new Error('openssl encrypt failed')
521 return `${HEADER}\n${r.stdout.trim()}\n`
522}
523
524async function open($: EngineInterface, text: string, pass: string): Promise<Bundle> {
525 const r = await $.process.run(
526 ['/usr/bin/openssl', 'enc', '-d', '-aes-256-cbc', '-pbkdf2', '-iter', ITER, '-md', 'sha256', '-a', '-A', '-pass', 'env:VAULT_EXPORT_PASS'],
527 { stdin: sealedBody(text) + '\n', env: { VAULT_EXPORT_PASS: pass }, timeoutMs: 120_000 },
528 )
529 if (r.exitCode !== 0) throw new Error('decrypt failed')
530 const env = unwrap(r.stdout)
531 if ((await sha256(env.bundle)) !== env.digest) throw new Error('digest mismatch')
532 return JSON.parse(env.bundle) as Bundle
533}
534
535const PANE = 'vault'
536
537const profilesA = atom({ plugin: 'vault', key: 'profiles' } as const, {})
538const storedA = atom({ plugin: 'vault', key: 'stored' } as const, {})
539const grantsA = atom({ plugin: 'vault', key: 'grants' } as const, {})
540const dirA = atom({ plugin: 'vault', key: 'dir' } as const, '')
541const allGrantsA = atom({ plugin: 'vault', key: 'allGrants' } as const, {})
542const viewA = atom({ plugin: 'vault', key: 'view' } as const, 'list')
543const selectedA = atom({ plugin: 'vault', key: 'selected' } as const, '')
544const formA = atom({ plugin: 'vault', key: 'form' } as const, null)
545const exportSelA = atom({ plugin: 'vault', key: 'exportSel' } as const, [])
546const importA = atom({ plugin: 'vault', key: 'importPreview' } as const, null)
547const confirmDeleteA = atom({ plugin: 'vault', key: 'confirmDelete' } as const, '')
548const noticeA = atom({ plugin: 'vault', key: 'notice' } as const, '')
549const auditA = atom({ plugin: 'vault', key: 'audit' } as const, [])
550const probesA = atom({ plugin: 'vault', key: 'probes' } as const, {})
551const probingA = atom({ plugin: 'vault', key: 'probing' } as const, '')
552const usageA = atom({ plugin: 'vault', key: 'usage' } as const, {})
553
554let cwd = ''
555// decrypted import bundle: module memory only, never $.state
556let pendingBundle: Bundle | null = null
557
558const now = () => Date.now()
559function notice($: EngineInterface, text: string) {
560 const tagged = /^[✔✖⚠ℹ]/.test(text) ? text
561 : /失败|错误|不一致|至少|只能|没有|损坏|缺少/.test(text) ? `✖ ${text}`
562 : /取消/.test(text) ? `ℹ ${text}`
563 : `✔ ${text}`
564 if (tagged.startsWith('✔')) {
565 $.clock.after(6000, () => void update($, noticeA, (n: string) => (n === tagged ? '' : n)))
566 }
567 return update($, noticeA, () => tagged)
568}
569
570// Appends run one after another: the log is read, extended and rewritten, so two concurrent
571// appends (parallel tool calls, the expiry timer) would otherwise drop one of the lines.
572let auditQueue: Promise<void> = Promise.resolve()
573let appendsSinceTrim = 0
574
575async function audit($: EngineInterface, entry: Record<string, unknown>) {
576 const write = async () => {
577 const { audit: file } = await paths($)
578 const line = redact(JSON.stringify({ t: new Date().toISOString(), cwd, ...entry })) + '\n'
579 await $.process.run(['/bin/sh', '-c', 'umask 077; mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', file], { stdin: line })
580 if (++appendsSinceTrim >= 200) {
581 appendsSinceTrim = 0
582 const all = await $.fs.read(file).catch(() => '')
583 const lines = all.split('\n').filter(Boolean)
584 if (lines.length > 2000) await writePrivate($, file, lines.slice(-2000).join('\n') + '\n')
585 }
586 }
587 const done = auditQueue.then(write, write)
588 auditQueue = done.catch(() => {})
589 await done
590 const used = entry.event === 'exec' ? [entry.profile] : entry.event === 'bash' ? (entry.profiles as string[]) : []
591 if (used.length) {
592 const at = now()
593 await update($, usageA, (u: Record<string, VaultUsage>) => {
594 const next = { ...u }
595 for (const n of used as string[]) next[n] = { count: (next[n]?.count ?? 0) + 1, last: at }
596 return next
597 })
598 }
599}
600
601// Usage per profile from the audit log's exec and bash events.
602async function loadUsage($: EngineInterface) {
603 const { audit: file } = await paths($)
604 const usage: Record<string, VaultUsage> = {}
605 let text = ''
606 try { text = await $.fs.read(file) } catch {}
607 for (const line of text.split('\n')) {
608 if (!line) continue
609 try {
610 const j = JSON.parse(line) as { t: string; event: string; profile?: string; profiles?: string[] }
611 const names = j.event === 'exec' && j.profile ? [j.profile] : j.event === 'bash' ? (j.profiles ?? []) : []
612 const at = Date.parse(j.t)
613 for (const n of names) usage[n] = { count: (usage[n]?.count ?? 0) + 1, last: Math.max(usage[n]?.last ?? 0, at) }
614 } catch {}
615 }
616 await update($, usageA, () => usage)
617}
618
619async function refreshProfiles($: EngineInterface) {
620 const profiles = await loadProfiles($)
621 const pairs = Object.entries(profiles).flatMap(([n, p]) => p.secrets.map(f => [n, f] as const))
622 const found = await Promise.all(pairs.map(([n, f]) => hasSecret($, account(n, f))))
623 const stored: Record<string, boolean> = Object.fromEntries(pairs.map(([n, f], i) => [`${n}.${f}`, found[i]]))
624 await update($, profilesA, () => profiles)
625 await update($, storedA, () => stored)
626 return profiles
627}
628
629// The grants in force here. The session's directory can change mid-session: re-read it then.
630async function activeGrants($: EngineInterface) {
631 const raw = (await $.session.cwd().catch(() => '')) || cwd
632 if (raw !== lastRawCwd) await refreshGrants($)
633 return (await read($, grantsA)) as Record<string, VaultGrant>
634}
635
636async function syncStatus($: EngineInterface) {
637 const names = Object.keys((await read($, grantsA)) as Record<string, VaultGrant>)
638 $.ui.status(names.length ? `🔐 vault: ${names.join(', ')}` : undefined)
639}
640
641// ---------- tools the model sees ----------
642// Registered once per load with fixed text: the tool list must not change mid-session, so the
643// grant state is read through vault_list instead of being baked into the descriptions.
644async function registerTools($: EngineInterface) {
645 await $.tool.register({
646 name: 'vault_list',
647 description:
648 'List the credential profiles the vault mod manages (databases, servers, clusters) and which of them are granted to ' +
649 'the current directory. For each profile: name, type, host, port, user, database, description, SSH auth method, grant ' +
650 '({mode: "read" | "write", directory} or false), its own variable names and its client variable names. Secret values ' +
651 'are never returned. Call it to choose a profile and the exact variable names before vault_exec or a Bash command that ' +
652 'needs credentials. Grants are given and revoked by the user in the /vault pane; no tool can change them.',
653 inputSchema: { type: 'object', properties: {} },
654 })
655 await $.tool.register({
656 name: 'vault_exec',
657 description:
658 'Run a bash command with one credential profile injected as environment variables, so it can reach a database, server ' +
659 'or cluster without the secret entering the conversation. Use it instead of asking the user for a password or key. ' +
660 'The profile must be granted to the current directory (vault_list shows grants); otherwise the call is refused and the ' +
661 'user grants it in the /vault pane. ' +
662 'Injected: the profile\'s own variables, named after it (profile prod-db: $PROD_DB_HOST, $PROD_DB_USER, $PROD_DB_PASSWORD; ' +
663 'a file secret such as an SSH key or kubeconfig as $<NAME>_<SECRET>_FILE, a 0600 temp file removed when the command ends), ' +
664 'plus the client\'s standard variables (PGPASSWORD, KUBECONFIG, SSH askpass, ...) so psql, kubectl or ssh need no flags. ' +
665 'Variables exist only for this command and its child processes. ' +
666 'Returns "exit code: N" followed by stdout and stderr, each capped at 4 MiB. A secret value in the output is replaced ' +
667 'by «vault:<profile>.<field>»; that marker is expected, not an error. ' +
668 'Refused: commands that read the Keychain or the vault directory, or dump the environment (env, printenv, set); and, ' +
669 'under a read grant, commands that look like writes to a database, Redis or kubectl. A read grant does not restrict ' +
670 'commands run over SSH. ' +
671 'The Bash tool gets the same injection: referencing $<NAME>_* injects that profile\'s own variables, and a first line ' +
672 '"#vault:<profile>" also injects its client variables (two profiles of one client cannot share a #vault: line). ' +
673 'Bash commands with run_in_background cannot be injected.',
674 inputSchema: {
675 type: 'object',
676 properties: {
677 profile: { type: 'string', description: 'Name of a profile granted to the current directory, as vault_list lists it.' },
678 command: { type: 'string', description: 'Bash command, run with bash -c; the profile\'s variables are in its environment.' },
679 cwd: { type: 'string', description: 'Directory to run in, absolute or relative to the session\'s working directory. Defaults to the session\'s working directory.' },
680 timeoutSec: { type: 'number', description: 'Seconds before the command is killed and an error is returned. Default 120, maximum 600.' },
681 },
682 required: ['profile', 'command'],
683 },
684 })
685}
686
687async function checkUse($: EngineInterface, name: string, command: string) {
688 await syncFromDisk($)
689 const profiles = (await read($, profilesA)) as Record<string, VaultProfile>
690 const p = profiles[name]
691 if (!p) return { deny: `vault: 没有名为 ${name} 的 profile。` }
692 const g = (await activeGrants($))[name]
693 if (!g) return { deny: `vault: profile ${name} 未授权给当前目录。请让用户在 /vault 面板里授权(授权对该目录及其子目录长期有效)。` }
694 const w = g.mode === 'read' ? writeReason(p, command) : undefined
695 if (w) return { deny: w }
696 return { p, g }
697}
698
699// ---------- Bash: guard, transparent injection, redaction ----------
700const shq = (v: string) => `'${v.replace(/'/g, `'\\''`)}'`
701
702// ---------- UI actions ----------
703const blankForm = (type = 'postgres'): VaultForm => {
704 const t = templateOf(type)
705 const v = t.variants[0]
706 return {
707 name: '', description: '', type, variant: v.key, host: '', port: t.port ? String(t.port) : '', user: '', database: '',
708 secrets: v.secrets.map(x => x.name).join(','), env: envToText(v.env),
709 advanced: type === 'custom',
710 }
711}
712
713function actions($: EngineInterface): Actions {
714 const acts: Actions = {
715 probe: async n => {
716 const p = ((await read($, profilesA)) as Record<string, VaultProfile>)[n]
717 const v = p && variantOf(p.type, p.variant)
718 if (!p || !v?.probe) return notice($, `${n} 的类型不支持测试连接`)
719 await update($, probingA, () => n)
720 let result: VaultProbe
721 try {
722 const ran = await runWithProfile($, n, p, exampleFor(v.probe, n), 30_000)
723 if ('deny' in ran) result = { ok: false, at: now(), ms: 0, message: ran.deny.replace(/^vault: /, '') }
724 else {
725 const tool = exampleFor(v.probe, n).split(' ')[0]
726 const err = (ran.stderr || ran.stdout).trim()
727 result = {
728 ok: ran.exitCode === 0, at: now(), ms: ran.ms,
729 message: ran.exitCode === 0 ? '' : ran.exitCode === 127 ? `本机未安装 ${tool}` : redact(err).slice(0, 200) || `退出码 ${ran.exitCode}`,
730 }
731 }
732 } catch (err) {
733 result = { ok: false, at: now(), ms: 0, message: redact(String(err)).slice(0, 200) }
734 }
735 await update($, probesA, (ps: Record<string, VaultProbe>) => ({ ...ps, [n]: result }))
736 await update($, probingA, () => '')
737 await audit($, { event: 'probe', profile: n, ok: result.ok, ms: result.ms })
738 },
739 copy: async (text, surface) => {
740 const r = await $.ui.copy({ text, surface })
741 await notice($, r.isCopied ? '已复制到剪贴板' : '✖ 复制失败(当前界面不支持剪贴板)')
742 },
743 go: async (view: VaultView) => {
744 await update($, noticeA, () => '')
745 if (view === 'audit') {
746 const { audit: file } = await paths($)
747 let lines: string[] = []
748 try { lines = (await $.fs.read(file)).split('\n').filter(Boolean).slice(-40).reverse() } catch {}
749 await update($, auditA, () => lines.map(l => {
750 try { const j = JSON.parse(l); return `${j.t.slice(5, 19).replace('T', ' ')} ${j.event} ${j.profile ?? (j.profiles ?? []).join(',')} ${j.command ?? ''}` } catch { return l }
751 }))
752 }
753 if (view === 'export') {
754 const names = Object.keys((await read($, profilesA)) as object)
755 await update($, exportSelA, () => names)
756 }
757 await update($, viewA, () => view)
758 },
759 select: n => void update($, selectedA, () => n),
760 setGrant: async (n, mode) => {
761 if (mode === 'off') {
762 // an inherited grant lives on the directory above: revoke it where it was given
763 const g = ((await read($, grantsA)) as Record<string, VaultGrant>)[n]
764 const here = await currentDir($)
765 await setGrant($, n, 'off', g?.dir ?? here)
766 return notice($, g && g.dir !== here
767 ? `已撤销 ${n} 在 ${g.dir.replace(/^\/Users\/[^/]+/, '~')} 的授权(它的所有子目录同时失效)`
768 : `已撤销 ${n} 在当前目录的授权`)
769 }
770 await setGrant($, n, mode)
771 const p = ((await read($, profilesA)) as Record<string, VaultProfile>)[n]
772 const stored = (await read($, storedA)) as Record<string, boolean>
773 const unset = (p?.secrets ?? []).filter(x => !stored[`${n}.${x}`])
774 const label = mode === 'write' ? '读写' : '只读'
775 await notice($, unset.length ? `⚠ 已授权 ${n}(${label}),但还缺少密文:${unset.join('、')}` : `已授权 ${n}(${label})给当前目录及其子目录`)
776 },
777 revokeAt: async (dir, n) => {
778 await setGrant($, n, 'off', dir)
779 await notice($, `已撤销 ${n} @ ${dir.replace(/^\/Users\/[^/]+/, '~')}`)
780 },
781 wipeAll: async () => {
782 const sum = await wipeSummary($)
783 if (!sum.profiles.length && !sum.accounts.length && !sum.grants && !sum.stray.length) return notice($, '没有需要清理的内容')
784 const lines = [
785 `凭证 ${sum.profiles.length} 个${sum.profiles.length ? `:${sum.profiles.join('、')}` : ''}`,
786 `钥匙串里的 vault 密文 ${sum.accounts.length} 条`,
787 `目录授权 ${sum.grants} 条(${sum.dirs} 个目录)`,
788 '以及临时文件、测试结果和审计日志',
789 ]
790 const ok = await confirmDanger($,
791 `将永久删除 Vault 的全部数据:\n\n${lines.join('\n')}\n\n无法撤销。需要恢复的话,请先「导出」备份。`, '全部删除')
792 if (!ok) return notice($, '已取消')
793 const done = await wipeAll($)
794 await update($, viewA, () => 'list')
795 await notice($, `已全部清理:删除 ${done.profiles.length} 个凭证、${done.accounts.length} 条钥匙串密文、${done.grants} 条授权`)
796 },
797 newProfile: async () => { await update($, formA, () => blankForm()); await update($, confirmDeleteA, () => ''); await update($, viewA, () => 'edit') },
798 editProfile: async n => {
799 const p = ((await read($, profilesA)) as Record<string, VaultProfile>)[n]
800 if (!p) return
801 await update($, formA, () => ({
802 original: n, name: n, description: p.description ?? '', type: p.type, variant: variantOf(p.type, p.variant).key, host: p.host ?? '', port: p.port ? String(p.port) : '', user: p.user ?? '',
803 database: p.database ?? '', secrets: p.secrets.join(','), env: envToText(p.env),
804 advanced: !isDefaultMapping(p),
805 }))
806 await update($, confirmDeleteA, () => '')
807 await update($, viewA, () => 'edit')
808 },
809 formSet: patch => void update($, formA, (f: VaultForm | null) => (f ? { ...f, ...patch } : f)),
810 formType: type => void update($, formA, (f: VaultForm | null) => {
811 const b = blankForm(type)
812 if (!f) return b
813 // keep only the values the new type still has a field for
814 const keep = new Set(templateOf(type).fields.map(x => x.key))
815 return {
816 ...f, type, variant: b.variant, secrets: b.secrets, env: b.env, advanced: b.advanced,
817 host: keep.has('host') ? f.host : '', user: keep.has('user') ? f.user : '',
818 database: keep.has('database') ? f.database : '',
819 port: keep.has('port') ? (f.port && f.port !== String(templateOf(f.type).port ?? '') ? f.port : b.port) : '',
820 }
821 }),
822 saveForm: async () => {
823 const f = (await read($, formA)) as VaultForm | null
824 if (!f) return
825 const name = f.name.trim()
826 if (!NAME.test(name)) return notice($, '名称只能用小写字母、数字、- 和 _,且以字母或数字开头')
827 const missing = missingFields(f.type, f)
828 if (missing.length) return notice($, `请填写必填项:${missing.join('、')}`)
829 if (f.port && !/^\d{1,5}$/.test(f.port.trim())) return notice($, '端口必须是数字')
830 const t = templateOf(f.type)
831 const v = variantOf(f.type, f.variant)
832 const fields = new Set(t.fields.map(x => x.key))
833 const val = (k: 'host' | 'user' | 'database') => (fields.has(k) && f[k].trim()) || undefined
834 const custom = f.advanced || f.type === 'custom'
835 const secrets = custom
836 ? f.secrets.split(',').map(x => x.trim()).filter(x => /^[A-Za-z0-9_-]+$/.test(x))
837 : v.secrets.map(x => x.name)
838 if (custom && !secrets.length) return notice($, '至少需要一个密文字段')
839 const problems = custom ? envProblems(parseEnv(f.env)) : []
840 if (problems.length) return notice($, problems.join(';'))
841 const profile: VaultProfile = {
842 type: f.type, variant: t.variants.length > 1 ? v.key : undefined,
843 description: f.description.trim() || undefined, host: val('host'), user: val('user'), database: val('database'),
844 port: fields.has('port') && f.port.trim() ? Number(f.port) : undefined,
845 secrets, env: custom ? parseEnv(f.env) : { ...v.env },
846 }
847 const all = await loadProfiles($)
848 const others = Object.fromEntries(Object.entries(all).filter(([o]) => o !== f.original))
849 const same = Object.keys(others).find(o => o !== name && aliasKey(o) === aliasKey(name))
850 if (same) return notice($, `名称 ${name} 和已有的 ${same} 会生成相同的变量前缀 ${aliasKey(name)}_,请换一个名称`)
851 const clash = namedClash(name, profile, others)
852 if (clash) return notice($, `变量 ${clash.variable} 和已有的 ${clash.other} 重名,请换一个名称或密文字段名`)
853 const renamed = f.original && f.original !== name ? f.original : ''
854 if (renamed) {
855 // Keychain items are keyed `<name>.<field>`: carry every secret over to the new name
856 for (const field of all[renamed]?.secrets ?? []) {
857 const old = await getSecret($, account(renamed, field))
858 if (old === undefined) continue
859 if (secrets.includes(field)) await setSecret($, account(name, field), old)
860 await deleteSecret($, account(renamed, field))
861 }
862 delete all[renamed]
863 }
864 all[name] = profile
865 await saveProfiles($, all)
866 if (renamed) {
867 await editAllGrants($, entries => {
868 if (entries[renamed]) { entries[name] = entries[renamed]; delete entries[renamed] }
869 })
870 await update($, selectedA, (sel: string) => (sel === renamed ? name : sel))
871 await audit($, { event: 'rename-profile', profile: name, from: renamed })
872 }
873 await audit($, { event: 'save-profile', profile: name })
874 await refreshProfiles($)
875 await syncStatus($)
876 await update($, formA, () => ({ ...f, name, original: name }))
877 const stored = (await read($, storedA)) as Record<string, boolean>
878 const unset = profile.secrets.filter(x => !stored[`${name}.${x}`])
879 await notice($, `已保存 ${name}${unset.length ? `,还需设置:${unset.join('、')}` : ''}`)
880 // a new profile goes straight on to its first secret, saving a click
881 if (!f.original && unset.length) {
882 const def = v.secrets.find(d => d.name === unset[0])
883 await (def?.file ? acts.setSecretFromFile(name, unset[0]) : acts.setSecret(name, unset[0]))
884 }
885 },
886 setSecret: async (n, field) => {
887 const v = await askHidden($, `请输入 ${n}.${field} 的值(只保存到 macOS 钥匙串,Claude 看不到)`)
888 if (v === undefined || v === '') return notice($, '已取消')
889 const type = ((await read($, profilesA)) as Record<string, VaultProfile>)[n]?.type ?? ''
890 const bad = secretProblem(type, field, v)
891 if (bad) return notice($, `✖ 没有保存:${bad}`)
892 for (const k of [...tokenCache.keys()]) if (k.startsWith(`${n}\n`)) tokenCache.delete(k)
893 remember(`${n}.${field}`, v)
894 await setSecret($, account(n, field), v)
895 await audit($, { event: 'set-secret', profile: n, field })
896 await refreshProfiles($)
897 await notice($, `${n}.${field} 已写入钥匙串`)
898 },
899 setSecretFromFile: async (n, field) => {
900 const file = await chooseFile($, `选择 ${n}.${field} 的内容文件(如私钥、kubeconfig)`)
901 if (!file) return notice($, '已取消')
902 const v = await $.fs.read(file)
903 const type = ((await read($, profilesA)) as Record<string, VaultProfile>)[n]?.type ?? ''
904 const bad = secretProblem(type, field, v)
905 if (bad) return notice($, `✖ 没有保存:${bad}`)
906 for (const k of [...tokenCache.keys()]) if (k.startsWith(`${n}\n`)) tokenCache.delete(k)
907 remember(`${n}.${field}`, v)
908 await setSecret($, account(n, field), v)
909 await audit($, { event: 'set-secret-file', profile: n, field })
910 await refreshProfiles($)
911 await notice($, `${n}.${field} 已从文件写入钥匙串(${v.length} 字节,原文件请自行妥善处理)`)
912 },
913 askDelete: n => void update($, confirmDeleteA, () => n),
914 doDelete: async n => {
915 await deleteProfile($, n)
916 await refreshProfiles($)
917 await update($, viewA, () => 'list')
918 await notice($, `已删除 ${n}`)
919 },
920 toggleExport: n => void update($, exportSelA, (s: string[]) => (s.includes(n) ? s.filter(x => x !== n) : [...s, n])),
921 doExport: async () => {
922 const sel = (await read($, exportSelA)) as string[]
923 const all = (await read($, profilesA)) as Record<string, VaultProfile>
924 const profiles = Object.fromEntries(sel.filter(n => all[n]).map(n => [n, all[n]]))
925 if (!Object.keys(profiles).length) return notice($, '没有选择任何 profile')
926 const day = new Date().toISOString().slice(0, 10).replace(/-/g, '')
927 // ask for the passphrase first: cancelling there leaves no half-made file behind
928 const pass = await askHidden($, '设置导出口令(至少 12 位,导入时需要)')
929 if (!pass) return notice($, '已取消')
930 if (pass.length < 12) return notice($, '口令至少 12 位')
931 if ((await askHidden($, '再次输入导出口令')) !== pass) return notice($, '两次口令不一致')
932 const file = await chooseFileName($, '导出到', `claude-vault-${day}.cvault`)
933 if (!file) return notice($, '已取消')
934 const bundle: Bundle = { version: 1, exportedAt: new Date().toISOString(), profiles }
935 const secrets: Record<string, string> = {}
936 for (const [n, p] of Object.entries(profiles)) for (const f of p.secrets) {
937 const v = await getSecret($, account(n, f))
938 if (v !== undefined) { secrets[`${n}.${f}`] = v; remember(`${n}.${f}`, v) }
939 }
940 await writePrivate($, file, await seal($, { ...bundle, secrets }, pass))
941 await audit($, { event: 'export', profiles: Object.keys(profiles), file, secrets: Object.keys(secrets).length })
942 await update($, viewA, () => 'list')
943 await notice($, `已导出 ${Object.keys(profiles).length} 个 profile → ${file}`)
944 },
945 startImport: async () => {
946 const file = await chooseFile($, '选择要导入的 .cvault 文件')
947 if (!file) return notice($, '已取消')
948 let text = ''
949 try { text = await $.fs.read(file) } catch { return notice($, '读取文件失败') }
950 let bundle: Bundle
951 const encrypted = isSealed(text)
952 try {
953 if (encrypted) {
954 const pass = await askHidden($, `输入 ${file.split('/').pop()} 的导出口令`)
955 if (!pass) return notice($, '已取消')
956 bundle = await open($, text, pass)
957 for (const [k, v] of Object.entries(bundle.secrets ?? {})) remember(k, v)
958 } else bundle = parsePlain(text)
959 } catch {
960 return notice($, '口令错误或文件损坏')
961 }
962 pendingBundle = bundle
963 const current = (await read($, profilesA)) as Record<string, VaultProfile>
964 const preview: VaultImportPreview = {
965 file, encrypted,
966 items: Object.entries(bundle.profiles).filter(([n]) => NAME.test(n)).map(([n, p]) => {
967 const status = !current[n] ? 'new' : JSON.stringify(current[n]) === JSON.stringify(p) ? 'same' : 'conflict'
968 return {
969 name: n, status, action: status === 'new' ? 'add' : status === 'same' ? 'overwrite' : 'skip',
970 secretCount: Object.keys(bundle.secrets ?? {}).filter(k => k.startsWith(n + '.')).length,
971 clientVars: Object.keys(p.env ?? {}),
972 problems: envProblems(p.env ?? {}),
973 } as VaultImportPreview['items'][number]
974 }),
975 }
976 await update($, importA, () => preview)
977 await update($, viewA, () => 'import')
978 },
979 importAction: (n, action) => void update($, importA, (p: VaultImportPreview | null) =>
980 p ? { ...p, items: p.items.map(i => (i.name === n ? { ...i, action: action as typeof i.action } : i)) } : p),
981 confirmImport: async () => {
982 const p = (await read($, importA)) as VaultImportPreview | null
983 const bundle = pendingBundle
984 if (!p || !bundle) return
985 const all = await loadProfiles($)
986 const done: string[] = []
987 const clashes: string[] = []
988 for (const it of p.items) {
989 if (it.action === 'skip') continue
990 if (envProblems(bundle.profiles[it.name].env ?? {}).length) { clashes.push(`${it.name}(含不允许的客户端变量)`); continue }
991 const target = it.action === 'rename' ? `${it.name}-imported` : it.name
992 if (Object.keys(all).some(o => o !== target && aliasKey(o) === aliasKey(target)) || namedClash(target, bundle.profiles[it.name], all)) { clashes.push(target); continue }
993 all[target] = bundle.profiles[it.name]
994 for (const f of bundle.profiles[it.name].secrets) {
995 const v = bundle.secrets?.[`${it.name}.${f}`]
996 if (v !== undefined) await setSecret($, account(target, f), v)
997 }
998 done.push(target)
999 }
1000 await saveProfiles($, all)
1001 pendingBundle = null
1002 await update($, importA, () => null)
1003 await audit($, { event: 'import', profiles: done, file: p.file, encrypted: p.encrypted })
1004 await refreshProfiles($)
1005 await update($, viewA, () => 'list')
1006 await notice($, clashes.length
1007 ? `⚠ 已导入 ${done.length} 个;跳过:${clashes.join('、')}`
1008 : `已导入 ${done.length} 个 profile(未授权任何会话)`)
1009 },
1010 cancelImport: async () => { pendingBundle = null; await update($, importA, () => null); await update($, viewA, () => 'list') },
1011 close: () => void $.ui.close({ id: PANE }),
1012 }
1013 return acts
1014}
1015
1016
1017// Opening the pane reloads what other sessions changed, and does not take the keyboard from
1018// the prompt: a click on the pane gives it the keys.
1019async function openVaultPane($: EngineInterface, view: VaultView = 'list') {
1020 await syncFromDisk($, { usage: true })
1021 await actions($).go(view)
1022 await $.ui.open({ id: PANE, title: '🔐 Vault' })
1023}
1024
1025// /vault and its subcommands. Answered here, so the command file the plugin ships (which
1026// lets the desktop app list the command before this module registers it) never runs.
1027async function vaultCommand($: EngineInterface, e: { args: string; origin: { kind: string } }) {
1028 const [sub = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
1029 // The person types at the terminal (composer), in the desktop app (sdk, its host) or on the
1030 // phone (bridge). Other sessions, channels, schedules and agents cannot grant or export.
1031 const byPerson = ['composer', 'sdk', 'bridge'].includes(e.origin.kind)
1032 const openPane = (view?: VaultView) => openVaultPane($, view)
1033 if (sub !== '' && sub !== 'list' && !byPerson) return { text: '该子命令只能由用户本人在输入框执行。' }
1034 switch (sub) {
1035 case '': await openPane(); return {}
1036 case 'grant': {
1037 const [name, mode] = rest
1038 if (!name || !((await read($, profilesA)) as Record<string, VaultProfile>)[name]) return { text: `没有 profile: ${name ?? ''}` }
1039 const m = mode === 'write' ? 'write' : 'read'
1040 await setGrant($, name, m)
1041 return { text: `已授权 ${name}(${m === 'write' ? '读写' : '只读'})给 ${cwd} 及其子目录,撤销前一直有效` }
1042 }
1043 case 'revoke': {
1044 if (!rest[0]) return { text: '用法: /vault revoke <profile>' }
1045 const g = ((await read($, grantsA)) as Record<string, VaultGrant>)[rest[0]]
1046 if (!g) return { text: `${rest[0]} 在当前目录没有授权` }
1047 await setGrant($, rest[0], 'off', g.dir)
1048 return { text: `已撤销 ${rest[0]} 在 ${g.dir} 的授权` }
1049 }
1050 case 'list': {
1051 const profiles = (await read($, profilesA)) as Record<string, VaultProfile>
1052 const g = await activeGrants($)
1053 return { text: Object.keys(profiles).map(n => `${g[n] ? `✅ ${g[n].mode}` : ' ─ '} ${n} (${profiles[n].type})`).join('\n') || '(空)' }
1054 }
1055 case 'export': await openPane('export'); return {}
1056 case 'import': await openPane(); await actions($).startImport(); return { text: '导入:请在弹窗中选择文件。' }
1057 default: return { text: '用法: /vault [grant <profile> [read|write]|revoke <profile>|list|export|import]' }
1058 }
1059}
1060
1061export const register: Register = on => {
1062 on('tool.call', { tool: 'mcp__vault__vault_list' }, async $ => {
1063 await syncFromDisk($)
1064 const profiles = (await read($, profilesA)) as Record<string, VaultProfile>
1065 const grants = await activeGrants($)
1066 const out = Object.entries(profiles).map(([n, p]) => ({
1067 name: n, type: p.type, host: p.host, port: p.port, user: p.user, database: p.database,
1068 granted: grants[n] ? { mode: grants[n].mode, directory: grants[n].dir } : false,
1069 description: p.description,
1070 auth: p.type === 'ssh' ? variantOf(p.type, p.variant).label : undefined,
1071 variables: Object.keys(namedVars(n, p)),
1072 clientVariables: Object.keys(p.env),
1073 }))
1074 return { result: JSON.stringify({ profiles: out }, null, 2) }
1075 })
1076
1077 on('tool.call', { tool: 'mcp__vault__vault_exec' }, async ($, e) => {
1078 const { profile, command, cwd: dir, timeoutSec } = e as unknown as { profile: string; command: string; cwd?: string; timeoutSec?: number }
1079 const peek = peekReason(command) ?? dumpReason(command)
1080 if (peek) return { deny: peek }
1081 const c = await checkUse($, profile, command)
1082 if ('deny' in c) return { deny: c.deny! }
1083 const timeoutMs = Math.min(600, timeoutSec ?? 120) * 1000
1084 try {
1085 const ran = await runWithProfile($, profile, c.p, dir ? `cd ${shq(dir)} && ${command}` : command, timeoutMs)
1086 if ('deny' in ran) return { deny: ran.deny }
1087 await audit($, { event: 'exec', profile, command: command.slice(0, 120), exit: ran.exitCode, ms: ran.ms })
1088 const text = `exit code: ${ran.exitCode}\n--- stdout ---\n${ran.stdout}${ran.stderr ? `\n--- stderr ---\n${ran.stderr}` : ''}`
1089 return { result: redact(text) }
1090 } catch (err) {
1091 await audit($, { event: 'exec', profile, command: command.slice(0, 120), error: String(err).slice(0, 200) })
1092 return { result: redact(`vault_exec failed: ${String(err)}`) }
1093 }
1094 })
1095
1096 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1097 const command = (e as unknown as { command: string }).command
1098 const peek = peekReason(command)
1099 if (peek) return { deny: peek }
1100
1101 await syncFromDisk($)
1102 const profiles = (await read($, profilesA)) as Record<string, VaultProfile>
1103 const granted = new Set(Object.keys(await activeGrants($)))
1104 const chosen = selectProfiles(command, profiles, granted)
1105 if ('deny' in chosen) return { deny: chosen.deny }
1106 const wanted = chosen.picks
1107 if (wanted.size === 0) return next(e)
1108
1109 // a background command outlives this call, and with it the temp files it reads
1110 if ((e as unknown as { run_in_background?: boolean }).run_in_background) {
1111 return { deny: 'vault: 后台运行的 Bash 命令不能注入凭证(命令返回后临时文件就会被删除)。请去掉 run_in_background,或改用 vault_exec。' }
1112 }
1113
1114 const dump = dumpReason(command)
1115 if (dump) return { deny: dump }
1116
1117 const env: Record<string, string> = {}
1118 const files: string[] = []
1119 for (const [n, vars] of wanted) {
1120 const c = await checkUse($, n, command)
1121 if ('deny' in c) { await removeFiles($, files); return { deny: c.deny! } }
1122 const r = await resolveEnv($, n, { ...c.p, env: vars })
1123 files.push(...r.files)
1124 if (r.blocked.length) { await removeFiles($, files); return { deny: `vault: profile ${n} 的客户端变量 ${r.blocked.join(', ')} 不允许设置,请在 /vault 面板里修改。` } }
1125 if (r.missing.length) { await removeFiles($, files); return { deny: `vault: 钥匙串里缺少密文 ${r.missing.join(', ')}。` } }
1126 if (r.errors.length) { await removeFiles($, files); return { deny: `vault: ${r.errors.join(';')}` } }
1127 Object.assign(env, r.env)
1128 }
1129
1130 const { run } = await paths($)
1131 const envFile = `${run}/${crypto.randomUUID()}.env`
1132 liveFiles.add(envFile)
1133 await writePrivate($, envFile, Object.entries(env).map(([k, v]) => `export ${k}=${shq(v)}`).join('\n') + '\n')
1134 files.push(envFile)
1135 const rewritten = `{ . ${shq(envFile)}; rm -f ${shq(envFile)}; }\n${command}`
1136 const started = now()
1137 try {
1138 const ran = await next({ ...e, command: rewritten } as typeof e)
1139 await audit($, { event: 'bash', profiles: [...wanted.keys()], command: command.slice(0, 120), ms: now() - started })
1140 return ran
1141 } finally {
1142 await removeFiles($, files)
1143 }
1144 })
1145
1146 // every tool: keep the model's file tools out of the vault, and redact any result carrying a known secret
1147 on('tool.call', async ($, e, next) => {
1148 const args = e as unknown as Record<string, unknown>
1149 const { dir } = await paths($)
1150 for (const key of ['file_path', 'path', 'notebook_path']) {
1151 const v = args[key]
1152 if (typeof v !== 'string') continue
1153 const real = (await $.fs.stat(v, { resolve: true }).catch(() => undefined))?.realPath ?? v
1154 if (isVaultPath(v, dir) || isVaultPath(real, dir)) return { deny: 'vault: 凭证存储目录不允许被工具访问。' }
1155 if (e.tool !== 'Read' && e.tool !== 'Grep' && e.tool !== 'Glob' && (isAllowFile(v) || isAllowFile(real))) {
1156 return { deny: 'vault: .claude/vault.json 只能由用户在 /vault 面板中修改。' }
1157 }
1158 }
1159 const ran = await next(e)
1160 if ('deny' in ran && ran.deny !== undefined) return ran
1161 if (!hasSecretsDeep(ran.result) && !(typeof ran.text === 'string' && hasSecretsDeep(ran.text))) return ran
1162 return { result: redactDeep(ran.result), ...(ran.context ? { context: ran.context } : {}) } as typeof ran
1163 })
1164
1165 // last line of defence: every row the conversation keeps
1166 on('session.append', ($, e, next) => {
1167 const content = (e.message as { content?: unknown }).content
1168 if (content === undefined || !hasSecretsDeep(content)) return next(e)
1169 return next({ ...e, message: { ...e.message, content: redactDeep(content) } } as typeof e)
1170 })
1171
1172 // ---------- lifecycle, command, pane ----------
1173 on('session.start', async ($, e, next) => {
1174 cwd = e.cwd
1175 await $.command.register({
1176 name: 'vault',
1177 description: '凭证保险库:管理数据库/服务器/集群凭证与目录授权',
1178 argumentHint: '[grant <p> [read|write]|revoke <p>|list|export|import]',
1179 // typed while Claude is working, it opens the pane at once instead of waiting for the turn
1180 immediate: true,
1181 })
1182 // what is on disk now counts as seen, so the first sync does not load it all a second time
1183 const { dir } = await paths($)
1184 seenRevision = await $.fs.read(`${dir}/revision`).catch(() => '')
1185 await refreshProfiles($)
1186 await migrateLegacyAllowlist($)
1187 await refreshGrants($)
1188 await registerTools($)
1189 // grants survive a reload in $.state, the redaction table does not: reload it without holding
1190 // up the first prompt (a command that injects a secret remembers it on its own)
1191 void warmRedaction($)
1192 void sweepRun($)
1193 $.clock.every(60_000, () => void sweepRun($))
1194 // tool calls sync on demand; the timer only keeps an open pane current
1195 $.clock.every(3_000, async () => { if (await paneIsOpen($)) await syncFromDisk($, { usage: true }) })
1196 // A reload leaves an open pane drawn by the previous module, whose buttons no longer answer
1197 // (the desktop logs "ui_press not handled"): redraw it so its handlers are this module's.
1198 if (await paneIsOpen($)) {
1199 await loadUsage($)
1200 $.ui.invalidate('ui.render')hooks/redact.ts 55 lines1// Plaintexts seen this module lifetime. Never written to $.state or $.store.
2const known = new Map<string, string>()
3// `known` longest first, so a full value wins over its substrings; rebuilt only after a change
4let ordered: [string, string][] | null = null
5
6const variants = (v: string) => {
7 const out = [v]
8 try { out.push(btoa(v)) } catch {}
9 const enc = encodeURIComponent(v)
10 if (enc !== v) out.push(enc)
11 return out
12}
13
14export const remember = (label: string, value: string) => {
15 if (value.length < 4) return
16 ordered = null
17 for (const v of variants(value)) known.set(v, label)
18 // multi-line secrets (keys, kubeconfigs): also mask each substantial line
19 for (const line of value.split('\n')) if (line.trim().length >= 16) known.set(line.trim(), label)
20}
21
22export const hasSecrets = (text: string) => {
23 for (const v of known.keys()) if (text.includes(v)) return true
24 return false
25}
26
27export const redact = (text: string) => {
28 if (!known.size) return text
29 ordered ??= [...known].sort((a, b) => b[0].length - a[0].length)
30 for (const [v, label] of ordered) {
31 if (text.includes(v)) text = text.split(v).join(`«vault:${label}»`)
32 }
33 return text
34}
35
36export const redactDeep = <T>(value: T): T => {
37 if (typeof value === 'string') return redact(value) as T
38 if (Array.isArray(value)) return value.map(redactDeep) as T
39 if (value && typeof value === 'object') {
40 const out: Record<string, unknown> = {}
41 for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v)
42 return out as T
43 }
44 return value
45}
46
47// Detection over every string of a value, as `redactDeep` replaces. Checking `JSON.stringify`
48// instead misses secrets holding `"`, `\` or newlines, whose JSON form differs from the raw text.
49export const hasSecretsDeep = (value: unknown): boolean => {
50 if (typeof value === 'string') return hasSecrets(value)
51 if (Array.isArray(value)) return value.some(hasSecretsDeep)
52 if (value && typeof value === 'object') return Object.values(value).some(hasSecretsDeep)
53 return false
54}
55hooks/guard.ts 41 lines1import type { VaultProfile } from '../types'
2
3const PEEK = [
4 /\bsecurity\b[^|;&]*\b(find-generic-password|find-internet-password|dump-keychain|export|-i\b)/,
5 /\.claude\/vault(\/|$|[\s'"`;|&)])/,
6 /(^|\s)-[sl]\s*['"]?claude-vault['"]?(\s|$)/,
7]
8
9const DUMP = /(^|[;&|]\s*|\s)(env|printenv|export\s+-p|declare\s+-p|set)\s*($|[;&|>])/
10
11export const peekReason = (command: string) =>
12 PEEK.some(r => r.test(command))
13 ? 'vault: 这条命令会直接读取凭证存储(钥匙串/vault 目录),已拒绝。请通过已授权的 profile 使用凭证。'
14 : undefined
15
16export const dumpReason = (command: string) =>
17 DUMP.test(command) ? 'vault: 注入凭证的命令里不允许整体导出环境变量 (env/printenv/set)。' : undefined
18
19export const isVaultPath = (path: string, vaultDir: string) =>
20 path.startsWith(vaultDir + '/') || path === vaultDir || /(^|\/)\.claude\/vault(\/|$)/.test(path)
21
22export const isAllowFile = (path: string) => /(^|\/)\.claude\/vault\.json$/.test(path)
23
24const WRITES: Record<string, RegExp> = {
25 postgres: /\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|merge|copy\s+\w+\s+from|vacuum|reindex)\b/i,
26 mysql: /\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|replace|load\s+data|rename)\b/i,
27 mongo: /\b(insert\w*|update\w*|delete\w*|drop\w*|remove|replaceOne|bulkWrite|createIndex|renameCollection)\b/,
28 redis: /\b(set|setex|mset|del|unlink|expire|hset|hdel|lpush|rpush|lpop|rpop|sadd|srem|zadd|zrem|flushall|flushdb|config\s+set|rename)\b/i,
29 // REST and RPC calls that change data; a GET or HEAD reads
30 supabase: /(-X|--request)\s*['"]?(POST|PUT|PATCH|DELETE)\b/i,
31 kube: /\bkubectl\b[^|;&]*\b(delete|apply|create|edit|patch|replace|scale|rollout\s+(restart|undo)|drain|cordon|uncordon|taint|label|annotate|exec|cp|set|run|expose|autoscale)\b|\bhelm\b[^|;&]*\b(install|upgrade|uninstall|rollback)\b/,
32}
33
34// Best-effort only: real read-only guarantees come from a read-only DB user or RBAC role.
35export const writeReason = (profile: VaultProfile, command: string) => {
36 const r = WRITES[profile.type]
37 return r && r.test(command)
38 ? `vault: 该 profile 对当前目录只授权了只读,命令疑似写操作(匹配 ${profile.type} 写规则),已拒绝。需要写权限请让用户在 /vault 面板里把当前目录的授权改为「读写」。`
39 : undefined
40}
41hooks/transfer.ts 33 lines1import type { VaultProfile } from '../types'
2
3export type Bundle = {
4 version: 1
5 exportedAt: string
6 profiles: Record<string, VaultProfile>
7 secrets?: Record<string, string>
8}
9
10// .cvault = header line + base64 of `openssl enc -aes-256-cbc -pbkdf2 -iter 600000` over an envelope
11// whose embedded SHA-256 detects a wrong passphrase or a tampered file.
12export const HEADER = 'CLAUDE-VAULT-V1'
13export const ITER = '600000'
14
15export const isSealed = (text: string) => text.startsWith(HEADER + '\n')
16
17export const sealedBody = (text: string) => text.slice(HEADER.length + 1).trim()
18
19export const envelope = (bundleJson: string, digest: string) =>
20 JSON.stringify({ magic: 'claude-vault', digest, bundle: bundleJson })
21
22export const unwrap = (plain: string) => {
23 const env = JSON.parse(plain) as { magic: string; digest: string; bundle: string }
24 if (env.magic !== 'claude-vault') throw new Error('bad envelope')
25 return env
26}
27
28export const parsePlain = (text: string): Bundle => {
29 const b = JSON.parse(text) as Bundle
30 if (!b || typeof b.profiles !== 'object') throw new Error('not a vault template')
31 return { version: 1, exportedAt: b.exportedAt ?? '', profiles: b.profiles }
32}
33hooks/ui.tsx 528 lines1import type { VaultDirGrants, VaultForm, VaultGrant, VaultImportPreview, VaultMode, VaultProbe, VaultProfile, VaultUsage, VaultView } from '../types'
2import { TEMPLATES, envToText, exampleFor, namedVars, parseEnv, prefixOf, templateOf, variantOf } from './templates'
3import type { FieldKey, SecretDef } from './templates'
4
5export type PaneState = {
6 cwd: string
7 cols: number
8 profiles: Record<string, VaultProfile>
9 stored: Record<string, boolean>
10 grants: Record<string, VaultGrant>
11 dir: string
12 allGrants: VaultDirGrants
13 view: VaultView
14 selected: string
15 form: VaultForm | null
16 exportSel: string[]
17 importPreview: VaultImportPreview | null
18 confirmDelete: string
19 notice: string
20 audit: string[]
21 probes: Record<string, VaultProbe>
22 probing: string
23 usage: Record<string, VaultUsage>
24 surface?: string
25 now: number
26}
27
28export type Actions = {
29 go: (view: VaultView) => void
30 select: (name: string) => void
31 setGrant: (name: string, mode: VaultMode | 'off') => void
32 revokeAt: (dir: string, name: string) => void
33 wipeAll: () => void
34 newProfile: () => void
35 editProfile: (name: string) => void
36 formSet: (patch: Partial<VaultForm>) => void
37 formType: (type: string) => void
38 saveForm: () => void
39 setSecret: (name: string, field: string) => void
40 setSecretFromFile: (name: string, field: string) => void
41 askDelete: (name: string) => void
42 doDelete: (name: string) => void
43 toggleExport: (name: string) => void
44 doExport: () => void
45 startImport: () => void
46 importAction: (name: string, action: string) => void
47 confirmImport: () => void
48 cancelImport: () => void
49 close: () => void
50 probe: (name: string) => void
51 copy: (text: string, surface?: any) => void
52}
53
54const TYPE_ICON: Record<string, string> = Object.fromEntries(Object.entries(TEMPLATES).map(([k, t]) => [k, t.icon]))
55
56const target = (p: VaultProfile) =>
57 p.host ? `${p.user ? p.user + '@' : ''}${p.host}${p.port ? ':' + p.port : ''}` : ''
58
59const secretState = (name: string, p: VaultProfile, stored: Record<string, boolean>) => {
60 const have = p.secrets.filter(f => stored[`${name}.${f}`]).length
61 if (p.secrets.length === 0) return { text: '无密文', color: 'gray' }
62 if (have === p.secrets.length) return { text: '密文已存', color: 'green' }
63 if (have === 0) return { text: '缺少密文', color: 'red' }
64 return { text: `密文 ${have}/${p.secrets.length}`, color: 'yellow' }
65}
66
67const ago = (now: number, at: number) => {
68 const m = Math.max(0, Math.round((now - at) / 60000))
69 if (m < 1) return '刚刚'
70 if (m < 60) return `${m} 分钟前`
71 const h = Math.round(m / 60)
72 return h < 48 ? `${h} 小时前` : `${Math.round(h / 24)} 天前`
73}
74
75const tilde = (path: string) => path.replace(/^\/Users\/[^/]+/, '~')
76
77const MODE_LABEL = { off: '不授权', read: '只读', write: '读写' } as const
78
79const noticeTone = (n: string) =>
80 n.startsWith('✖') ? 'red' : n.startsWith('⚠') ? 'yellow' : n.startsWith('✔') ? 'green' : 'cyan'
81
82export function renderPane(el: unknown, s: PaneState, a: Actions) {
83 const { Box, Text, Button, Input, Select, Code } = el as any
84 const narrow = (s.cols || 80) < 64
85
86 // ---------- primitives ----------
87 const Badge = ({ text, color }: { text: string; color: string }) => (
88 <Text color={color} bold>{`● ${text}`}</Text>
89 )
90 const Card = ({ k, title, tone = 'gray', children }: { k: string; title?: string; tone?: string; children?: unknown }) => (
91 <Box key={k} flexDirection="column" borderStyle="round" borderColor={tone} paddingX={1} marginTop={1}>
92 {title ? <Text bold color="cyan">{title}</Text> : null}
93 {children}
94 </Box>
95 )
96 const Row = ({ label, children }: { label: string; children?: unknown }) => (
97 <Box gap={1} alignItems="center">
98 <Box width={10} flexShrink={0}><Text dimColor>{label}</Text></Box>
99 <Box flexGrow={1}>{children}</Box>
100 </Box>
101 )
102 const Toolbar = ({ children }: { children?: unknown }) => (
103 <Box gap={1} flexWrap="wrap" marginTop={1}>{children}</Box>
104 )
105
106 // ---------- header ----------
107 const granted = Object.keys(s.grants)
108 const dirCount = Object.keys(s.allGrants).length
109 const total = Object.keys(s.profiles).length
110 const crumbs: Record<VaultView, string> = {
111 list: '凭证', edit: s.form?.original ? `编辑 ${s.form.original}` : '新建凭证',
112 export: '导出', import: '导入', audit: '审计日志', grants: '授权管理',
113 }
114 const header = (
115 <Box key="hdr" flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
116 <Box justifyContent="space-between" flexWrap="wrap">
117 <Box gap={1}>
118 <Text bold color="cyan">🔐 Vault</Text>
119 <Text dimColor>›</Text>
120 <Text bold>{crumbs[s.view]}</Text>
121 </Box>
122 {narrow ? null : <Text dimColor wrap="truncate-start">{tilde(s.dir || s.cwd)}</Text>}
123 </Box>
124 <Box gap={2} flexWrap="wrap">
125 <Badge text={`当前目录已授权 ${granted.length}/${total}`} color={granted.length ? 'green' : 'gray'} />
126 <Text dimColor>{`授权对目录及子目录长期有效 · 共 ${dirCount} 个目录有授权`}</Text>
127 </Box>
128 </Box>
129 )
130 const notice = s.notice ? (
131 <Box key="notice" paddingX={1} marginTop={1}><Text color={noticeTone(s.notice)}>{s.notice}</Text></Box>
132 ) : null
133
134 // ---------- edit ----------
135 if (s.view === 'edit' && s.form) {
136 const f = s.form
137 const editing = f.original ?? ''
138 const t = templateOf(f.type)
139 const v = variantOf(f.type, f.variant)
140 const custom = f.advanced || f.type === 'custom'
141 const secretNames = custom ? f.secrets.split(',').map(x => x.trim()).filter(Boolean) : v.secrets.map(d => d.name)
142 const secretDefs: SecretDef[] = secretNames.map(name => v.secrets.find(d => d.name === name) ?? { name, label: name })
143 const input = (key: string, value: string, prop: keyof VaultForm, placeholder = '') => (
144 <Input key={key} value={value} placeholder={placeholder}
145 onInput={(x: string) => a.formSet({ [prop]: x } as Partial<VaultForm>)}
146 onSubmit={(x: string) => a.formSet({ [prop]: x } as Partial<VaultForm>)} />
147 )
148 const Field = ({ label, required, hint, children }: { label: string; required?: boolean; hint?: string; children?: unknown }) => (
149 <Box flexDirection="column" marginTop={1}>
150 <Box gap={1}>
151 <Text bold>{label}</Text>
152 {required ? <Text color="red">*</Text> : null}
153 {hint ? <Text dimColor>{hint}</Text> : null}
154 </Box>
155 {children}
156 </Box>
157 )
158 const describe = (tpl: string) =>
159 tpl.replace(/\{secretfile:([\w-]+)\}/g, (_, n) => `🔒 ${n}(临时文件路径)`)
160 .replace(/\{secret:([\w-]+)\}/g, (_, n) => `🔒 ${n}(来自钥匙串)`)
161 .replace(/\{askpass\}/g, '自动应答密码的 askpass 程序')
162 .replace(/\{(host|port|user|database)\}/g, (_, k: FieldKey) => f[k] || '(空)')
163 const displayName = f.name.trim() || 'name'
164 const named = Object.entries(namedVars(displayName, { type: f.type, variant: v.key, secrets: secretNames, host: f.host, port: f.port, user: f.user, database: f.database }, true))
165 const client = Object.entries(custom ? parseEnv(f.env) : v.env)
166 const example = exampleFor(v.example, displayName)
167 const usage = `vault_exec(profile: "${displayName}", command: ${JSON.stringify(example)})`
168 const VarRow = ({ k, tpl }: { k: string; tpl: string }) => (
169 <Box gap={1}>
170 <Box width={24} flexShrink={0}><Text color="cyan">{k}</Text></Box>
171 <Text dimColor wrap="truncate-end">{describe(tpl)}</Text>
172 </Box>
173 )
174
175 return (
176 <Box flexDirection="column">
177 {header}
178 {notice}
179
180 <Card k="c-type" title="类型">
181 <Box gap={1} flexWrap="wrap">
182 {Object.entries(TEMPLATES).map(([k, tp]) => (
183 <Button key={`t-${k}`} label={`${tp.icon} ${tp.label}`} variant={k === f.type ? 'primary' : 'secondary'}
184 onPress={() => k !== f.type && a.formType(k)} />
185 ))}
186 </Box>
187 <Text dimColor>{t.summary}</Text>
188 </Card>
189
190 <Card k="c-conn" title="基本信息">
191 <Field label="名称" required hint={`小写字母、数字、- 和 _;变量前缀 ${prefixOf(displayName)}_`}>{input('f-name', f.name, 'name', 'prod-db')}</Field>
192 <Field label="描述" hint="可选,Claude 会据此选择 profile">{input('f-desc', f.description, 'description', '生产库只读账号,用于查询订单数据')}</Field>
193 {t.fields.map(fd => (
194 <Field label={fd.label} required={fd.required} hint={fd.hint}>
195 {input(`f-${fd.key}`, f[fd.key], fd.key, fd.placeholder ?? '')}
196 </Field>
197 ))}
198 </Card>
199
200 <Card k="c-sec" title={t.variants.length > 1 ? '认证' : '密文'}>
201 {t.variants.length > 1 ? (
202 <Box flexDirection="column">
203 <Box gap={1} alignItems="center" flexWrap="wrap">
204 <Text bold>认证方式</Text>
205 {t.variants.map(x => (
206 <Button key={`var-${x.key}`} label={x.label} variant={x.key === v.key ? 'primary' : 'secondary'}
207 onPress={() => x.key !== v.key && a.formSet({ variant: x.key, advanced: false, secrets: x.secrets.map(d => d.name).join(','), env: envToText(x.env) })} />
208 ))}
209 </Box>
210 {v.hint ? <Text dimColor>{v.hint}</Text> : null}
211 </Box>
212 ) : null}
213 {secretDefs.map(d => {
214 const ok = !!(editing && s.stored[`${editing}.${d.name}`])
215 return (
216 <Box key={`f-row-${d.name}`} flexDirection="column" marginTop={1}>
217 <Box gap={1} alignItems="center" flexWrap="wrap">
218 <Box width={14} flexShrink={0}><Text bold>{d.label}</Text></Box>
219 {editing
220 ? <Badge text={ok ? '已存入钥匙串' : '未设置'} color={ok ? 'green' : 'red'} />
221 : <Text dimColor>创建后可设置</Text>}
222 {editing && d.file
223 ? <Button key={`f-file-${d.name}`} label={ok ? '重新选择文件' : '从文件读取'} variant={ok ? 'secondary' : 'primary'} onPress={() => a.setSecretFromFile(editing, d.name)} />
224 : null}
225 {editing && !d.file
226 ? <Button key={`f-set-${d.name}`} label={ok ? '重新设置' : '设置'} variant={ok ? 'secondary' : 'primary'} onPress={() => a.setSecret(editing, d.name)} />
227 : null}
228 {editing && !d.file
229 ? <Button key={`f-file-${d.name}`} label="从文件读取" onPress={() => a.setSecretFromFile(editing, d.name)} />
230 : null}
231 </Box>
232 {d.hint ? <Text dimColor>{d.hint}</Text> : null}
233 </Box>
234 )
235 })}
236 <Text dimColor>密文只通过系统掩码输入框或文件写入 macOS 钥匙串,不会显示,也不会进入对话。</Text>
237 </Card>
238
239 <Card k="c-env" title="环境变量">
240 <Text bold>专属变量</Text>
241 <Text dimColor>以名称为前缀,每个 profile 独有,互不冲突;在 Bash 里直接引用即可注入</Text>
242 {named.map(([k, tpl]) => <VarRow k={k} tpl={tpl} />)}
243 <Box marginTop={1}><Text bold>客户端变量</Text></Box>
244 <Text dimColor>客户端工具自动识别的标准变量,只在 vault_exec 或命令首行 #vault:{displayName} 时注入</Text>
245 {client.length ? client.map(([k, tpl]) => <VarRow k={k} tpl={tpl} />) : <Text dimColor>(无)</Text>}
246 {custom ? (
247 <Box flexDirection="column">
248 <Field label="密文字段" hint="逗号分隔">{input('f-secrets', f.secrets, 'secrets', 'password,token')}</Field>
249 <Field label="客户端变量映射" hint="KEY=模板,用 ; 分隔">{input('f-env', f.env, 'env', 'API_TOKEN={secret:token}; API_HOST={host}')}</Field>
250 <Text dimColor>{'占位符:{host} {port} {user} {database} {secret:字段} {secretfile:字段}'}</Text>
251 </Box>
252 ) : null}
253 {f.type !== 'custom' ? (
254 <Box marginTop={1}>
255 <Button key="f-adv" plain label={custom ? '↺ 恢复为模板' : '⚙ 自定义密文字段和客户端变量(高级)'}
256 onPress={() => a.formSet(custom
257 ? { advanced: false, secrets: v.secrets.map(x => x.name).join(','), env: envToText(v.env) }
258 : { advanced: true })} />
259 </Box>
260 ) : null}
261 </Card>
262
263 <Card k="c-ex" title="Claude 的用法">
264 <Code source={usage} language="text" />
265 <Box gap={1} marginTop={1} flexWrap="wrap">
266 <Button key="f-copy" label="复制用法" onPress={() => a.copy(usage, s.surface)} />
267 {editing && v.probe
268 ? <Button key="f-probe" label={s.probing === editing ? '测试中…' : '测试连接'} onPress={() => s.probing !== editing && a.probe(editing)} />
269 : null}
270 {editing && s.probes[editing]
271 ? <Text color={s.probes[editing].ok ? 'green' : 'red'} wrap="truncate-end">
272 {s.probes[editing].ok ? `✔ 连接正常 · ${s.probes[editing].ms}ms` : `✖ ${s.probes[editing].message}`}
273 </Text>
274 : null}
275 </Box>
276 <Text dimColor>或在 Bash 中直接引用专属变量;需要客户端变量时在首行写 #vault:{displayName}</Text>
277 </Card>
278
279 <Toolbar>
280 <Button key="f-save" label={editing ? '保存修改' : '创建'} hotkey="s" variant="primary" onPress={() => a.saveForm()} />
281 <Button key="f-cancel" label="返回" hotkey="b" role="dismiss" onPress={() => a.go('list')} />
282 {editing ? (s.confirmDelete === editing
283 ? <Button key="f-del-yes" label="确认删除(同时删除钥匙串)" onPress={() => a.doDelete(editing)} />
284 : <Button key="f-del" label="删除" onPress={() => a.askDelete(editing)} />) : null}
285 </Toolbar>
286 </Box>
287 )
288 }
289
290 // ---------- export ----------
291 if (s.view === 'export') {
292 const names = Object.keys(s.profiles).sort()
293 return (
294 <Box flexDirection="column">
295 {header}
296 {notice}
297 <Card k="c-pick" title={`选择 profile(${s.exportSel.length}/${names.length})`}>
298 {names.length === 0 ? <Text dimColor>还没有 profile。</Text> : null}
299 {names.map(n => (
300 <Box key={`x-row-${n}`} gap={1} alignItems="center">
301 <Button key={`x-${n}`} plain label={`${s.exportSel.includes(n) ? '☑' : '☐'} ${n}`} onPress={() => a.toggleExport(n)} />
302 <Text dimColor>{TYPE_ICON[s.profiles[n].type] ?? ''} {s.profiles[n].type}</Text>
303 </Box>
304 ))}
305 </Card>
306 <Text dimColor>导出为 .cvault 加密包,包含配置和密文;需要设置口令(至少 12 位),导入时输入同一个口令。</Text>
307 <Toolbar>
308 <Button key="x-go" label={`导出 ${s.exportSel.length} 项`} variant="primary" hotkey="x" onPress={() => s.exportSel.length && a.doExport()} />
309 <Button key="x-back" label="返回" hotkey="b" role="dismiss" onPress={() => a.go('list')} />
310 </Toolbar>
311 </Box>
312 )
313 }
314
315 // ---------- import ----------
316 if (s.view === 'import' && s.importPreview) {
317 const p = s.importPreview
318 const st = { new: { t: '新增', c: 'green' }, conflict: { t: '冲突', c: 'yellow' }, same: { t: '相同', c: 'gray' } }
319 return (
320 <Box flexDirection="column">
321 {header}
322 {notice}
323 <Card k="c-file" title="文件">
324 <Box gap={2} flexWrap="wrap">
325 <Text bold>{p.file.split('/').pop() ?? p.file}</Text>
326 <Badge text={p.encrypted ? '加密包' : '仅元数据'} color={p.encrypted ? 'green' : 'gray'} />
327 </Box>
328 </Card>
329 <Card k="c-items" title={`逐条确认(${p.items.length})`}>
330 {p.items.map(it => (
331 <Box key={`i-row-${it.name}`} gap={1} alignItems="center" flexWrap="wrap">
332 <Box width={18} flexShrink={0}><Text bold>{it.name}</Text></Box>
333 <Box width={8} flexShrink={0}><Badge text={st[it.status].t} color={st[it.status].c} /></Box>
334 <Box width={8} flexShrink={0}><Text dimColor>{`密文 ${it.secretCount}`}</Text></Box>
335 <Select key={`i-${it.name}`} value={it.action}
336 options={it.status === 'new'
337 ? [{ value: 'add', label: '导入' }, { value: 'skip', label: '跳过' }]
338 : [{ value: 'overwrite', label: '覆盖' }, { value: 'skip', label: '跳过' }, { value: 'rename', label: '另存为 -imported' }]}
339 onSelect={(v: string) => a.importAction(it.name, v)} />
340 </Box>
341 ))}
342 {p.items.filter(it => it.clientVars.length || it.problems.length).map(it => (
343 <Box key={`i-vars-${it.name}`} flexDirection="column" marginTop={1}>
344 <Text dimColor wrap="truncate-end">{`${it.name} 的客户端变量:${it.clientVars.join(' ') || '(无)'}`}</Text>
345 {it.problems.map((why, i) => <Text key={`i-pb-${it.name}-${i}`} color="red">{`✖ ${why},导入时会跳过`}</Text>)}
346 </Box>
347 ))}
348 <Text dimColor>导入不会授权任何目录,需要时在列表卡片上授权。</Text>
349 </Card>
350 <Toolbar>
351 <Button key="i-go" label="确认导入" variant="primary" onPress={() => a.confirmImport()} />
352 <Button key="i-cancel" label="取消" role="dismiss" onPress={() => a.cancelImport()} />
353 </Toolbar>
354 </Box>
355 )
356 }
357
358 // ---------- grants ----------
359 if (s.view === 'grants') {
360 const dirs = Object.keys(s.allGrants).sort()
361 const here = s.dir || s.cwd
362 return (
363 <Box flexDirection="column">
364 {header}
365 {notice}
366 {dirs.length === 0 ? (
367 <Card k="g-empty" title="还没有任何授权">
368 <Text dimColor>在列表卡片上点「只读」或「读写」,就会授权给当前目录及其子目录。</Text>
369 </Card>
370 ) : null}
371 {dirs.map(d => {
372 const entries = s.allGrants[d]
373 const applies = here === d || here.startsWith(d.endsWith('/') ? d : `${d}/`)
374 return (
375 <Card key={`g-${d}`} k={`g-${d}`} title={tilde(d)} tone={applies ? 'green' : 'gray'}>
376 {applies ? <Text color="green">{here === d ? '● 当前目录' : '● 当前目录的上级,对当前目录生效'}</Text> : null}
377 {Object.entries(entries).sort().map(([n, g]) => (
378 <Box key={`g-${d}-${n}`} gap={1} alignItems="center">
379 <Box width={20} flexShrink={0}><Text bold>{n}</Text></Box>
380 <Box width={6} flexShrink={0}><Text color={g.mode === 'write' ? 'yellow' : 'cyan'}>{MODE_LABEL[g.mode]}</Text></Box>
381 <Text dimColor>{`授权于 ${ago(s.now, g.at)}`}</Text>
382 {s.profiles[n] ? null : <Text color="red">profile 已删除</Text>}
383 <Button key={`g-rv-${d}-${n}`} label="撤销" onPress={() => a.revokeAt(d, n)} />
384 </Box>
385 ))}
386 </Card>
387 )
388 })}
389 <Toolbar>
390 <Button key="g-back" label="返回" hotkey="b" role="dismiss" onPress={() => a.go('list')} />
391 </Toolbar>
392 </Box>
393 )
394 }
395
396 // ---------- audit ----------
397 if (s.view === 'audit') {
398 const tone = (ev: string) =>
399 /delete|revoke/.test(ev) ? 'red' : /grant|trust/.test(ev) ? 'green' : /export|import/.test(ev) ? 'magenta' : 'cyan'
400 return (
401 <Box flexDirection="column">
402 {header}
403 {notice}
404 <Card k="c-audit" title={`最近 ${s.audit.length} 条`}>
405 {s.audit.length === 0 ? <Text dimColor>暂无记录。</Text> : null}
406 {s.audit.map((l, i) => {
407 const [d, t, event = '', ...rest] = l.split(' ')
408 return (
409 <Box key={`a-${i}`} gap={1}>
410 <Box width={15} flexShrink={0}><Text dimColor>{`${d} ${t}`}</Text></Box>
411 <Box width={14} flexShrink={0}><Text color={tone(event)} bold>{event}</Text></Box>
412 <Text wrap="truncate-end">{rest.join(' ')}</Text>
413 </Box>
414 )
415 })}
416 </Card>
417 <Toolbar>
418 <Button key="a-back" label="返回" hotkey="b" role="dismiss" onPress={() => a.go('list')} />
419 </Toolbar>
420 </Box>
421 )
422 }
423
424 // ---------- list ----------
425 const names = Object.keys(s.profiles).sort()
426 return (
427 <Box flexDirection="column">
428 {header}
429 {notice}
430 {names.length === 0 ? (
431 <Card k="c-empty" title="还没有凭证">
432 <Text><Text color="cyan" bold>1 </Text>点「新建」,选类型模板,填主机和用户</Text>
433 <Text><Text color="cyan" bold>2 </Text>在编辑页点「设置」,用系统掩码框把密文写进钥匙串</Text>
434 <Text><Text color="cyan" bold>3 </Text>在卡片上点「只读」或「读写」授权给当前目录,Claude 就能通过 vault_exec 使用</Text>
435 <Text dimColor>已有备份?点「导入」选择 .cvault 或模板 .json</Text>
436 </Card>
437 ) : (
438 names.map(n => {
439 const p = s.profiles[n]
440 const g = s.grants[n]
441 const inherited = g && g.dir !== (s.dir || s.cwd)
442 const sec = secretState(n, p, s.stored)
443 const sel = s.selected === n
444 const probe = s.probes[n]
445 const use = s.usage[n]
446 const canProbe = !!variantOf(p.type, p.variant).probe
447 const missingSecret = sec.color === 'red' || sec.color === 'yellow'
448 const grantBadge = g
449 ? { text: `已授权 · ${MODE_LABEL[g.mode]}`, color: g.mode === 'write' ? 'yellow' : 'green' }
450 : { text: '未授权', color: 'gray' }
451 const info = (
452 <Box flexDirection="column" flexGrow={1}>
453 <Box gap={1} alignItems="center">
454 <Text>{TYPE_ICON[p.type] ?? '🧩'}</Text>
455 <Button key={`sel-${n}`} plain label={n} onPress={() => a.select(n)} />
456 <Text dimColor>{p.type}{p.type === 'ssh' ? ` · ${variantOf(p.type, p.variant).label}` : ''}</Text>
457 </Box>
458 <Text dimColor wrap="truncate-end">{target(p) || '—'}</Text>
459 {p.description ? <Text wrap="truncate-end">{p.description}</Text> : null}
460 {probe
461 ? <Text color={probe.ok ? 'green' : 'red'} wrap="truncate-end">
462 {probe.ok ? `✔ 连接正常 · ${probe.ms}ms · ${ago(s.now, probe.at)}` : `✖ ${probe.message}`}
463 </Text>
464 : null}
465 <Text dimColor>{use ? `最近使用 ${ago(s.now, use.last)} · 共 ${use.count} 次` : '尚未被 Claude 使用'}</Text>
466 </Box>
467 )
468 const side = (
469 <Box flexDirection="column" alignItems={narrow ? 'flex-start' : 'flex-end'} flexShrink={0}>
470 <Box gap={2}>
471 <Badge text={sec.text} color={sec.color} />
472 <Badge text={grantBadge.text} color={grantBadge.color} />
473 </Box>
474 <Box gap={1}>
475 {canProbe && !missingSecret
476 ? <Button key={`pr-${n}`} label={s.probing === n ? '测试中…' : '测试连接'} onPress={() => s.probing !== n && a.probe(n)} />
477 : null}
478 <Button key={`ed-${n}`} label="编辑" onPress={() => a.editProfile(n)} />
479 {missingSecret
480 ? <Button key={`gr-${n}`} label="先设置密文" variant="primary" onPress={() => a.editProfile(n)} />
481 : null}
482 </Box>
483 </Box>
484 )
485 const current = g?.mode ?? 'off'
486 const access = (
487 <Box gap={1} alignItems="center" flexWrap="wrap" marginTop={1}>
488 <Box width={10} flexShrink={0}><Text dimColor>当前目录</Text></Box>
489 {missingSecret && !g ? (
490 <Text color="yellow">缺少密文,设置后才能授权</Text>
491 ) : (
492 (['off', 'read', 'write'] as const).map(m => (
493 <Button key={`gt-${n}-${m}`} label={MODE_LABEL[m]} variant={current === m ? 'primary' : 'secondary'}
494 onPress={() => current !== m && a.setGrant(n, m)} />
495 ))
496 )}
497 {inherited ? <Text dimColor wrap="truncate-start">{`继承自 ${tilde(g.dir)}`}</Text> : null}
498 {p.type === 'ssh' && g?.mode === 'read'
499 ? <Text color="yellow">只读不会拦截 SSH 上执行的命令,建议在服务器上用受限账号</Text>
500 : null}
501 </Box>
502 )
503 return (
504 <Box key={`card-${n}`} flexDirection="column" borderStyle="round" borderColor={sel ? 'cyan' : g ? 'green' : 'gray'}
505 hover={{ borderColor: 'cyan' }} paddingX={1} marginTop={1}>
506 <Box gap={2} flexDirection={narrow ? 'column' : 'row'} justifyContent="space-between">
507 {info}
508 {side}
509 </Box>
510 {access}
511 </Box>
512 )
513 })
514 )}
515
516 <Toolbar>
517 <Button key="new" label="+ 新建" hotkey="n" variant="primary" onPress={() => a.newProfile()} />
518 <Button key="import" label="导入" hotkey="i" onPress={() => a.startImport()} />
519 <Button key="export" label="导出" hotkey="x" onPress={() => a.go('export')} />
520 <Button key="grants" label="授权管理" hotkey="g" onPress={() => a.go('grants')} />
521 <Button key="audit" label="审计日志" hotkey="l" onPress={() => a.go('audit')} />
522 <Button key="cleanup" label="🧹 一键清理" hotkey="c" onPress={() => a.wipeAll()} />
523 <Button key="close" label="关闭" role="dismiss" onPress={() => a.close()} />
524 </Toolbar>
525 </Box>
526 )
527}
528hooks/select.ts 96 lines1import type { VaultDirGrants, VaultGrant, VaultProfile } from '../types'
2import { isSecretTemplate, namedVars, prefixOf } from './templates'
3
4export const aliasKey = prefixOf
5
6const isSecretTpl = isSecretTemplate
7
8/** Everything a profile injects when chosen explicitly: its own `<NAME>_*` variables plus the client's. */
9export const varsOf = (name: string, p: VaultProfile): Record<string, string> => ({ ...namedVars(name, p), ...p.env })
10
11export type Selection = Map<string, Record<string, string>>
12
13export type SelectResult = { picks: Selection } | { deny: string }
14
15/** The `<NAME>_*` variables two profiles would both define (a prefix like `prod` + secret `db_password`). */
16export const namedClash = (name: string, p: VaultProfile, others: Record<string, VaultProfile>) => {
17 const mine = Object.keys(namedVars(name, p, true))
18 for (const [o, op] of Object.entries(others)) {
19 if (o === name) continue
20 const theirs = new Set(Object.keys(namedVars(o, op, true)))
21 const hit = mine.find(k => theirs.has(k))
22 if (hit) return { other: o, variable: hit }
23 }
24 return undefined
25}
26
27/**
28 * Decides which granted profiles a Bash command draws on, and which variables each injects.
29 *
30 * - `#vault:a,b` names profiles outright: each brings its `<NAME>_*` variables and its client
31 * variables (PGPASSWORD, KUBECONFIG, ...). Unknown or ungranted names are refused, and two named
32 * profiles whose client variables overlap (two postgres profiles) are refused.
33 * - Otherwise a `$<NAME>_*` reference picks that profile and injects the referenced variable plus
34 * the profile's non-secret `<NAME>_*` variables. These names are unique per profile, so implicit
35 * use never conflicts; client variables are never injected implicitly. A reference to a
36 * `<NAME>_*` variable of an ungranted profile is refused; any other variable is left alone.
37 */
38export function selectProfiles(command: string, profiles: Record<string, VaultProfile>, granted: Set<string>): SelectResult {
39 const picks: Selection = new Map()
40 // only the command's first line names profiles: the same text further down (a heredoc, a commit
41 // message, a script's comments) is content, not an instruction to the vault
42 const header = /^\s*#\s*vault:\s*([\w,-]+)/.exec(command)
43
44 if (header) {
45 const names = [...new Set(header[1].split(',').map(n => n.trim()).filter(Boolean))]
46 const owners = new Map<string, string[]>()
47 for (const n of names) {
48 if (!profiles[n]) return { deny: `vault: 没有名为 ${n} 的 profile。` }
49 if (!granted.has(n)) return { deny: `vault: profile ${n} 未授权给当前目录。请让用户在 /vault 面板里授权。` }
50 const vars = varsOf(n, profiles[n])
51 for (const k of Object.keys(vars)) owners.set(k, [...(owners.get(k) ?? []), n])
52 picks.set(n, vars)
53 }
54 for (const [k, list] of owners) {
55 if (list.length > 1) {
56 return {
57 deny: `vault: ${list.join('、')} 都会设置 ${k},不能在同一条命令里一起用 #vault: 指定。` +
58 `请分开执行,或去掉 #vault:,直接用各自的专属变量($${prefixOf(list[0])}_*、$${prefixOf(list[1])}_*,实际名称见 vault_list)。`,
59 }
60 }
61 }
62 return { picks }
63 }
64
65 const refs = new Set([...command.matchAll(/\$\{?([A-Za-z_][A-Za-z0-9_]*)/g)].map(m => m[1]))
66 if (!refs.size) return { picks }
67
68 for (const ref of refs) {
69 const owner = Object.keys(profiles).find(n => ref in namedVars(n, profiles[n]))
70 if (!owner) continue
71 if (!granted.has(owner)) return { deny: `vault: $${ref} 属于 profile ${owner},它未授权给当前目录。请让用户在 /vault 面板里授权。` }
72 const named = namedVars(owner, profiles[owner])
73 const sel = picks.get(owner) ?? Object.fromEntries(Object.entries(named).filter(([, t]) => !isSecretTpl(t)))
74 sel[ref] = named[ref]
75 picks.set(owner, sel)
76 }
77 return { picks }
78}
79
80/**
81 * The grants in force for a directory: those given to it or to any directory above it. Where
82 * several apply to one profile, the nearest directory's wins (a subdirectory can narrow or widen
83 * what its parent allows).
84 */
85export const effectiveGrants = (all: VaultDirGrants, dir: string): Record<string, VaultGrant> => {
86 const out: Record<string, VaultGrant> = {}
87 for (const [d, entries] of Object.entries(all)) {
88 const under = dir === d || dir.startsWith(d.endsWith('/') ? d : `${d}/`)
89 if (!under) continue
90 for (const [n, g] of Object.entries(entries)) {
91 if (!out[n] || d.length > out[n].dir.length) out[n] = { mode: g.mode, dir: d, at: g.at }
92 }
93 }
94 return out
95}
96hooks/templates.ts 297 lines1import type { VaultProfile } from '../types'
2
3export type FieldKey = 'host' | 'port' | 'user' | 'database'
4
5/** A connection field the type's form shows. `suffix` names its per-profile variable (`<NAME>_<suffix>`). */
6export type FieldDef = { key: FieldKey; label: string; placeholder?: string; required?: boolean; hint?: string; suffix?: string }
7
8/** A secret the profile holds. A `file` secret reaches commands as a 0600 temp file path. */
9export type SecretDef = { name: string; label: string; file?: boolean; hint?: string }
10
11/**
12 * One way of authenticating with the type (SSH: key or password). `env` is the client's own
13 * standard variables (PGPASSWORD, KUBECONFIG, ...), injected only when the profile is chosen
14 * explicitly; the per-profile `<NAME>_*` variables are derived from fields and secrets.
15 */
16export type Variant = {
17 key: string; label: string; secrets: SecretDef[]; env: Record<string, string>; example: string; hint?: string
18 /** A read-only command that succeeds (exit 0) when the connection works; `${P}` as in `example`. */
19 probe?: string
20 /** Per-profile variables computed when a command runs (`TOKEN: '{supabase_token}'` → `<PREFIX>_TOKEN`). */
21 derived?: Record<string, string>
22}
23
24export type Template = { label: string; icon: string; summary: string; fields: FieldDef[]; port?: number; variants: Variant[] }
25
26const one = (v: Omit<Variant, 'key' | 'label'>): Variant[] => [{ key: 'default', label: '默认', ...v }]
27
28export const TEMPLATES: Record<string, Template> = {
29 postgres: {
30 label: 'PostgreSQL', icon: '🐘', summary: 'psql / pg_dump 直接可用', port: 5432,
31 fields: [
32 { key: 'host', label: '主机', placeholder: 'db.internal 或 10.0.0.5', required: true },
33 { key: 'port', label: '端口', placeholder: '5432' },
34 { key: 'user', label: '用户', placeholder: 'readonly_user', required: true, hint: '建议使用只读账号' },
35 { key: 'database', label: '数据库', placeholder: 'app' },
36 ],
37 variants: one({
38 secrets: [{ name: 'password', label: '密码' }],
39 env: { PGHOST: '{host}', PGPORT: '{port}', PGUSER: '{user}', PGDATABASE: '{database}', PGPASSWORD: '{secret:password}' },
40 example: `psql -c 'select now()'`,
41 probe: `psql -X -A -t -c 'select 1'`,
42 }),
43 },
44 mysql: {
45 label: 'MySQL', icon: '🐬', summary: 'mysql 客户端直接可用', port: 3306,
46 fields: [
47 { key: 'host', label: '主机', placeholder: 'db.internal', required: true },
48 { key: 'port', label: '端口', placeholder: '3306' },
49 { key: 'user', label: '用户', placeholder: 'readonly_user', required: true, hint: '建议使用只读账号' },
50 { key: 'database', label: '数据库', placeholder: 'app' },
51 ],
52 variants: one({
53 secrets: [{ name: 'password', label: '密码' }],
54 env: { MYSQL_HOST: '{host}', MYSQL_TCP_PORT: '{port}', MYSQL_PWD: '{secret:password}' },
55 example: `mysql -u "\${P}_USER" "\${P}_DATABASE" -e 'select 1'`,
56 probe: `mysql -u "\${P}_USER" -e 'select 1'`,
57 }),
58 },
59 redis: {
60 label: 'Redis', icon: '🟥', summary: 'redis-cli 免输密码', port: 6379,
61 fields: [
62 { key: 'host', label: '主机', placeholder: 'cache.internal', required: true },
63 { key: 'port', label: '端口', placeholder: '6379' },
64 { key: 'user', label: 'ACL 用户', placeholder: '留空表示 default' },
65 { key: 'database', label: 'DB 编号', placeholder: '0', suffix: 'DB' },
66 ],
67 variants: one({
68 secrets: [{ name: 'password', label: '密码' }],
69 env: { REDISCLI_AUTH: '{secret:password}' },
70 example: `redis-cli -h "\${P}_HOST" -p "\${P}_PORT" ping`,
71 probe: `redis-cli -h "\${P}_HOST" -p "\${P}_PORT" ping | grep -q PONG`,
72 }),
73 },
74 mongo: {
75 label: 'MongoDB', icon: '🍃', summary: '整条连接串作为密文保存',
76 fields: [
77 { key: 'host', label: '集群', placeholder: 'cluster0.example.net(仅用于展示)', suffix: 'CLUSTER' },
78 { key: 'database', label: '数据库', placeholder: 'app' },
79 ],
80 variants: one({
81 secrets: [{ name: 'uri', label: '连接串', hint: 'mongodb+srv://user:REDACTED@host/db' }],
82 env: { MONGODB_URI: '{secret:uri}' },
83 example: `mongosh "\${P}_URI" --eval 'db.runCommand({ ping: 1 })'`,
84 probe: `mongosh "\${P}_URI" --quiet --eval 'db.runCommand({ ping: 1 }).ok'`,
85 }),
86 },
87 ssh: {
88 label: 'SSH 服务器', icon: '🖥', summary: '支持私钥或密码登录', port: 22,
89 fields: [
90 { key: 'host', label: '主机', placeholder: 'bastion.example.com', required: true },
91 { key: 'port', label: '端口', placeholder: '22' },
92 { key: 'user', label: '用户', placeholder: 'deploy', required: true },
93 ],
94 variants: [
95 {
96 key: 'key', label: '私钥',
97 secrets: [{ name: 'key', label: '私钥', file: true, hint: '用「从文件读取」选择私钥文件;命令执行时写入 0600 临时文件,结束即删除' }],
98 env: { GIT_SSH_COMMAND: 'ssh -i {secretfile:key} -o IdentitiesOnly=yes -p {port}' },
99 example: `ssh -i "\${P}_KEY_FILE" -o IdentitiesOnly=yes -p "\${P}_PORT" "\${P}_USER@\${P}_HOST" uptime`,
100 probe: `ssh -i "\${P}_KEY_FILE" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new -p "\${P}_PORT" "\${P}_USER@\${P}_HOST" true`,
101 },
102 {
103 key: 'password', label: '密码',
104 secrets: [{ name: 'password', label: '密码' }],
105 env: { SSHPASS: '{secret:password}', SSH_ASKPASS: '{askpass}', SSH_ASKPASS_REQUIRE: 'force' },
106 example: `ssh -o StrictHostKeyChecking=accept-new -p "\${P}_PORT" "\${P}_USER@\${P}_HOST" uptime`,
107 probe: `ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1 -p "\${P}_PORT" "\${P}_USER@\${P}_HOST" true`,
108 hint: '通过 SSH_ASKPASS 自动应答密码(需要 OpenSSH 8.4+),不需要安装 sshpass;只在 vault_exec 或 #vault: 指定时生效',
109 },
110 ],
111 },
112 kube: {
113 label: 'Kubernetes', icon: '☸', summary: 'kubeconfig 写入临时文件并设置 KUBECONFIG',
114 fields: [
115 { key: 'host', label: '集群', placeholder: 'prod-cluster(仅用于展示)', suffix: 'CLUSTER' },
116 { key: 'database', label: '命名空间', placeholder: 'default', suffix: 'NAMESPACE' },
117 ],
118 variants: one({
119 secrets: [{ name: 'kubeconfig', label: 'kubeconfig', file: true, hint: '用「从文件读取」选择 kubeconfig 文件' }],
120 env: { KUBECONFIG: '{secretfile:kubeconfig}' },
121 example: `kubectl -n "\${\${P}_NAMESPACE:-default}" get pods`,
122 probe: `kubectl get --raw /version --request-timeout=8s`,
123 }),
124 },
125 supabase: {
126 label: 'Supabase 账号', icon: '🟩', summary: '用账号密码登录 Supabase,注入访问令牌;密码和令牌都不会出现在对话里',
127 fields: [
128 { key: 'host', label: '项目地址', placeholder: 'https://xxxx.supabase.co', required: true, suffix: 'URL' },
129 { key: 'user', label: '登录邮箱', placeholder: 'ops3.accountant@example.test', required: true, suffix: 'EMAIL' },
130 ],
131 variants: one({
132 secrets: [
133 { name: 'password', label: '密码' },
134 { name: 'anon_key', label: 'anon key', hint: '项目的公开 anon key(Supabase 控制台 → Project Settings → API),登录接口需要它' },
135 ],
136 env: { SUPABASE_URL: '{host}', SUPABASE_ANON_KEY: '{secret:anon_key}', SUPABASE_ACCESS_TOKEN: '{supabase_token}' },
137 derived: { TOKEN: '{supabase_token}' },
138 example: `curl -s "\${P}_URL/rest/v1/<表名>?select=*&limit=5" -H "apikey: \${P}_ANON_KEY" -H "Authorization: Bearer \${P}_TOKEN"`,
139 probe: `curl -fsS --max-time 8 -o /dev/null "\${P}_URL/auth/v1/user" -H "apikey: \${P}_ANON_KEY" -H "Authorization: Bearer \${P}_TOKEN"`,
140 hint: '每次执行命令时由 Mod 用账号密码登录,令牌只在这一条命令里有效;同一条命令可以同时使用多个账号',
141 }),
142 },
143 'http-token': {
144 label: 'HTTP API Token', icon: '🔑', summary: 'API 地址和 Token,配合 curl 使用',
145 fields: [
146 { key: 'host', label: 'Base URL', placeholder: 'https://api.example.com', required: true, suffix: 'URL' },
147 ],
148 variants: one({
149 secrets: [{ name: 'token', label: 'Token' }],
150 env: {},
151 example: `curl -H "Authorization: Bearer \${P}_TOKEN" "\${P}_URL/health"`,
152 probe: `curl -fsS -o /dev/null --max-time 8 -H "Authorization: Bearer \${P}_TOKEN" "\${P}_URL"`,
153 }),
154 },
155 custom: {
156 label: '自定义', icon: '🧩', summary: '自由定义字段、密文和客户端变量',
157 fields: [
158 { key: 'host', label: '主机' },
159 { key: 'port', label: '端口' },
160 { key: 'user', label: '用户' },
161 { key: 'database', label: '库名' },
162 ],
163 variants: one({
164 secrets: [{ name: 'secret', label: '密文' }],
165 env: {},
166 example: `some-cli --token "\${P}_SECRET"`,
167 }),
168 },
169}
170
171export const templateOf = (type: string) => TEMPLATES[type] ?? TEMPLATES.custom
172
173export const variantOf = (type: string, variant?: string) => {
174 const t = templateOf(type)
175 return t.variants.find(v => v.key === variant) ?? t.variants[0]
176}
177
178/** The variable prefix a profile name gives: `prod-db` → `PROD_DB`. */
179export const prefixOf = (name: string) => name.toUpperCase().replace(/[^A-Z0-9]/g, '_')
180
181/** `${P}` in an example becomes `$PREFIX` (and `${${P}_X:-d}` becomes `${PREFIX_X:-d}`). */
182export const exampleFor = (example: string, name: string) =>
183 example.replace(/\$\{\$\{P\}/g, '${' + prefixOf(name)).replace(/\$\{P\}/g, '$' + prefixOf(name))
184
185/**
186 * The per-profile variables: `<PREFIX>_<FIELD>` for each connection field of its type that has a
187 * value (or every field with `all`), and `<PREFIX>_<SECRET>` (`_FILE` for a file secret) per secret.
188 */
189export const namedVars = (name: string, p: Pick<VaultProfile, 'type' | 'variant' | 'secrets'> & Partial<Record<FieldKey, unknown>>, all = false) => {
190 const P = prefixOf(name)
191 const t = templateOf(p.type)
192 const defs = variantOf(p.type, p.variant).secrets
193 const out: Record<string, string> = {}
194 for (const f of t.fields) {
195 if (all || (p[f.key] !== undefined && p[f.key] !== '')) out[`${P}_${f.suffix ?? f.key.toUpperCase()}`] = `{${f.key}}`
196 }
197 for (const s of p.secrets) {
198 const file = defs.find(d => d.name === s)?.file
199 out[`${P}_${prefixOf(s)}${file ? '_FILE' : ''}`] = file ? `{secretfile:${s}}` : `{secret:${s}}`
200 }
201 for (const [suffix, tpl] of Object.entries(variantOf(p.type, p.variant).derived ?? {})) out[`${P}_${suffix}`] = tpl
202 return out
203}
204
205/** Templates whose value is secret: Keychain values and anything derived from them. */
206export const isSecretTemplate = (tpl: string) => /\{(secret|secretfile|supabase_token)\b/.test(tpl)
207
208export const envToText = (env: Record<string, string>) =>
209 Object.entries(env).map(([k, v]) => `${k}=${v}`).join('; ')
210
211export const parseEnv = (text: string) => {
212 const env: Record<string, string> = {}
213 for (const part of text.split(/[;\n]/)) {
214 const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(part)
215 if (m) env[m[1]] = m[2]
216 }
217 return env
218}
219
220/** A profile uses its template unchanged unless its secrets or client variables differ. */
221export const isDefaultMapping = (p: Pick<VaultProfile, 'type' | 'variant' | 'secrets' | 'env'>) => {
222 const v = variantOf(p.type, p.variant)
223 return p.type !== 'custom'
224 && JSON.stringify(p.secrets) === JSON.stringify(v.secrets.map(s => s.name))
225 && JSON.stringify(p.env) === JSON.stringify(v.env)
226}
227
228/** Required fields of the type missing in the form, by label. */
229export const missingFields = (type: string, values: Record<FieldKey, string>) =>
230 templateOf(type).fields.filter(f => f.required && !values[f.key].trim()).map(f => f.label)
231
232// Variables a client mapping may never set: each makes the shell, the dynamic loader or an
233// interpreter run code of the setter's choosing (BASH_ENV runs a file on every `bash -c`).
234const DANGEROUS = /^(BASH_ENV|ENV|BASH_FUNC_.*|SHELLOPTS|BASHOPTS|PROMPT_COMMAND|PS[0-4]|IFS|CDPATH|PATH|HOME|SHELL|TMPDIR|ZDOTDIR|LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT|DYLD_.*|NODE_OPTIONS|NODE_PATH|PYTHONSTARTUP|PYTHONPATH|PYTHONHOME|PERL5OPT|PERL5LIB|RUBYOPT|RUBYLIB|JAVA_TOOL_OPTIONS|_JAVA_OPTIONS)$/
235
236// Variables that name a program to run: allowed only with a value one of the templates itself uses.
237const PROGRAM_VARS = new Set(['SSH_ASKPASS', 'GIT_SSH_COMMAND', 'GIT_SSH', 'GIT_ASKPASS', 'EDITOR', 'VISUAL', 'PAGER', 'GIT_PAGER', 'GIT_EXTERNAL_DIFF', 'LESSOPEN', 'LESSCLOSE', 'SUDO_ASKPASS'])
238
239const templateValues = (key: string) =>
240 new Set(Object.values(TEMPLATES).flatMap(t => t.variants.map(v => v.env[key]).filter((x): x is string => x !== undefined)))
241
242/** Why a client variable may not be set this way, or undefined when it may. */
243export const envProblem = (key: string, tpl: string): string | undefined => {
244 if (DANGEROUS.test(key)) return `${key} 会让 shell 或解释器执行任意代码,不允许设置`
245 if (PROGRAM_VARS.has(key) && !templateValues(key).has(tpl)) return `${key} 会指定要执行的程序,只能使用模板自带的值`
246 return undefined
247}
248
249/** Every problem of a client mapping, as `KEY: reason` lines. */
250export const envProblems = (env: Record<string, string>) =>
251 Object.entries(env).flatMap(([k, v]) => {
252 const why = envProblem(k, v)
253 return why ? [why] : []
254 })
255
256/**
257 * What is wrong with a secret's content for its type, or undefined. Catches truncated pastes:
258 * a kubeconfig whose user has a certificate but no key, a private key without its end line.
259 */
260export const secretProblem = (type: string, field: string, value: string): string | undefined => {
261 const v = value.trim()
262 if (!v) return '内容为空'
263 if (type === 'kube' && field === 'kubeconfig') return kubeconfigProblem(v)
264 if (type === 'ssh' && field === 'key') {
265 if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(v)) return '不是私钥文件:缺少 BEGIN PRIVATE KEY 行'
266 if (!/-----END [A-Z ]*PRIVATE KEY-----\s*$/.test(v)) return '私钥不完整:缺少 END PRIVATE KEY 行(多半是粘贴被截断)'
267 return undefined
268 }
269 if (type === 'supabase' && field === 'anon_key' && !/^[\w-]+\.[\w-]+\.[\w-]+$/.test(v) && !/^sb_publishable_/.test(v)) {
270 return 'anon key 格式不对:应为 eyJ… 开头的 JWT 或 sb_publishable_ 开头的密钥'
271 }
272 return undefined
273}
274
275// A kubeconfig in YAML or JSON. Each user with a client certificate needs its key, inline or as
276// a path; inline key data that is too short or not base64 was cut off.
277const kubeconfigProblem = (v: string): string | undefined => {
278 const truncated = 'kubeconfig 不完整:有 client-certificate-data 却没有 client-key-data(多半是粘贴被截断)'
279 const shortKey = 'kubeconfig 不完整:client-key-data 内容被截断'
280 const badKey = (data: string) => data.length < 100 || !/^[A-Za-z0-9+/=]+$/.test(data)
281 if (v.startsWith('{')) {
282 let doc: { clusters?: unknown; users?: { user?: Record<string, unknown> }[] }
283 try { doc = JSON.parse(v) } catch { return 'kubeconfig 不完整:JSON 无法解析' }
284 if (!Array.isArray(doc.clusters) || !Array.isArray(doc.users)) return 'kubeconfig 不完整:缺少 clusters 或 users 段'
285 for (const u of doc.users) {
286 const user = u?.user ?? {}
287 if (user['client-certificate-data'] && !user['client-key-data'] && !user['client-key']) return truncated
288 if (typeof user['client-key-data'] === 'string' && badKey(user['client-key-data'])) return shortKey
289 }
290 return undefined
291 }
292 if (!/^\s*clusters\s*:/m.test(v) || !/^\s*users\s*:/m.test(v)) return 'kubeconfig 不完整:缺少 clusters 或 users 段'
293 if (/client-certificate-data\s*:/.test(v) && !/client-key-data\s*:/.test(v) && !/client-key\s*:/.test(v)) return truncated
294 for (const m of v.matchAll(/client-key-data\s*:\s*["']?([^"'\s]*)["']?/g)) if (badKey(m[1])) return shortKey
295 return undefined
296}
297types/index.d.ts 81 lines1export type VaultMode = 'read' | 'write'
2
3export type VaultProfile = {
4 type: string
5 variant?: string
6 description?: string
7 host?: string
8 port?: number
9 user?: string
10 database?: string
11 secrets: string[]
12 env: Record<string, string>
13 modes?: VaultMode[]
14 note?: string
15}
16
17/** A profile granted to a directory and everything under it, until revoked. */
18export type VaultGrant = { mode: VaultMode; dir: string; at: number }
19
20/** Grants by directory (real path), then by profile name. */
21export type VaultDirGrants = Record<string, Record<string, { mode: VaultMode; at: number }>>
22
23export type VaultView = 'list' | 'edit' | 'export' | 'import' | 'audit' | 'grants'
24
25export type VaultForm = {
26 original?: string
27 name: string
28 description: string
29 type: string
30 variant: string
31 host: string
32 port: string
33 user: string
34 database: string
35 secrets: string
36 env: string
37 advanced: boolean
38}
39
40export type VaultImportItem = {
41 name: string
42 status: 'new' | 'conflict' | 'same'
43 action: 'add' | 'overwrite' | 'skip' | 'rename'
44 secretCount: number
45 clientVars: string[]
46 problems: string[]
47}
48
49export type VaultProbe = { ok: boolean; at: number; ms: number; message: string }
50
51export type VaultUsage = { count: number; last: number }
52
53export type VaultImportPreview = {
54 file: string
55 encrypted: boolean
56 items: VaultImportItem[]
57}
58
59declare module 'claude-code' {
60 interface PluginState {
61 vault: {
62 profiles: Record<string, VaultProfile>
63 stored: Record<string, boolean>
64 grants: Record<string, VaultGrant>
65 dir: string
66 allGrants: VaultDirGrants
67 view: VaultView
68 selected: string
69 form: VaultForm | null
70 exportSel: string[]
71 importPreview: VaultImportPreview | null
72 confirmDelete: string
73 notice: string
74 audit: string[]
75 probes: Record<string, VaultProbe>
76 probing: string
77 usage: Record<string, VaultUsage>
78 }
79 }
80}
81