SLOPSHOPPER

vault

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

newpanebandguardcommandstatus
★ 1v0.1.0no licenseupdated 2026-10-07yocloud-code/claude-vault
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vault
│ ┃ 🔐 Vault ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ 🔐 Vault › 凭证 ⏺ Read(src/auth.ts) │ ┃ │ ● 当前目录已授权 0/0 授权对目录及子目录长 ⎿ Read 6 lines │ ┃ │ 个目录有授权 ⏺ Update(src/auth.ts) │ ┃ ╰─────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ 还没有凭证 │ │ ┃ │ 1 点「新建」,选类型模板,填主机和用户 │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 2 │ │ ┃ │ 在编辑页点「设置」,用系统掩码框把密文写 │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ 进钥匙串 │ │ ┃ │ 3 │ › /vault │ ┃ │ 在卡片上点「只读」或「读写」授权给当前目 │ │ ┃ │ 录,Claude 就能通过 vault_exec 使用 │ │ ┃ │ 已有备份?点「导入」选择 .cvault 或模板 │ │ ┃ │ .json │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ [ + 新建 ] [ 导入 ] [ 导出 ] [ 授权管理 ] [ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 🔐 Vault
╭─────────────────────────────────────────────────────────── │ 🔐 Vault › 凭证 │ ● 当前目录已授权 0/0 授权对目录及子目录长期有效 · 共 0 个 ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ 还没有凭证 │ │ 1 点「新建」,选类型模板,填主机和用户 │ │ 2 在编辑页点「设置」,用系统掩码框把密文写进钥匙串 │ │ 3 在卡片上点「只读」或「读写」授权给当前目录,Claude │ │ 就能通过 vault_exec 使用 │ │ 已有备份?点「导入」选择 .cvault 或模板 .json │ ╰──────────────────────────────────────────────────────────╯ [ + 新建 ] [ 导入 ] [ 导出 ] [ 授权管理 ] [ 审计日志 ] [ 🧹
README

claude-vault

让 Claude Code 使用数据库、服务器、集群的凭证,却看不到密码。 Let Claude Code use your database, server and cluster credentials without ever seeing the passwords.

中文 · English


中文

它解决什么问题

把密码、私钥、kubeconfig 贴进对话,明文会永久留在聊天记录里,Claude 也常常因此拒绝执行。装上 claude-vault 之后:

  • 密码只存在 macOS 钥匙串里,Claude 只知道凭证的名字和用途;
  • 命令执行时由 Mod 把凭证注入成环境变量,输出里的密码会自动替换成 «vault:名称.字段»;
  • 你决定哪个目录可以用哪个凭证,只读还是读写。

安装

需要 macOS 和支持 Mod 的 Claude Code。

claude --plugin-dir /path/to/claude-vault

想每次启动都自动加载(包括桌面版),在 ~/.claude/settings.json 里加上:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-vault" } }

快速上手

  1. 打开面板:输入 /vault。桌面版也可以点输入框上方的「🔐 Vault」按钮,Claude 正在工作时也能立即打开。
  2. 新建凭证:点「+ 新建」,选类型(PostgreSQL、SSH、Kubernetes……),填主机、用户等信息,写一句描述(Claude 会据此选择凭证),点「创建」。
  3. 输入密码:创建后会自动弹出系统的密码输入框(私钥、kubeconfig 会弹出文件选择),内容直接存进钥匙串,界面上永远不会显示。
  4. 测试连接:点卡片上的「测试连接」,确认能连上。
  5. 授权:在卡片的「当前目录」一行点「只读」或「读写」。授权对当前目录及其子目录长期有效,点「不授权」撤销。

之后在这个目录里直接跟 Claude 说"查一下 prod-db 的订单表"就行。

Claude 怎么用凭证

  • vault_exec 工具(Claude 默认会用):指定凭证名和命令,psql、kubectl、ssh 等客户端不用加任何参数就能连上。
  • 在 Bash 里引用专属变量:每个凭证都有以名称为前缀的变量,例如 prod-db 有 $PROD_DB_HOST、$PROD_DB_PASSWORD;Supabase 账号还有登录后得到的 $<名称>_TOKEN,多个角色可以在同一条命令里一起用。
  • 命令首行写 #vault:prod-db:同时注入 PGPASSWORD 这类客户端标准变量。

每个凭证有哪些变量,在编辑页的「环境变量」里能看到。

授权

  • 授权给的是目录,不是会话;没有时长,撤销前一直有效。
  • 子目录继承上级目录的授权,也可以单独设置,离得最近的那条生效。
  • 「授权管理」列出所有目录的授权,可以逐条撤销。
  • 授权记录保存在你的用户目录里,不在项目仓库中:别人的项目不能给自己授权,Claude 也改不了授权。
  • 「只读」会拦截数据库、Redis、kubectl 的常见写操作,但对 SSH 无效。真正的只读请使用只读账号。

备份、恢复与清理

  • 导出:勾选凭证,设置口令(至少 12 位),生成一个加密的 .cvault 文件,里面包含配置和密码。
  • 导入:选择 .cvault 文件,输入口令,预览后确认。冲突的凭证可以选择覆盖、跳过或另存一份。导入后需要重新授权。
  • 一键清理:清空全部凭证、钥匙串里的 vault 密码、所有授权和日志。会弹出确认框,无法撤销,请先导出备份。钥匙串里其他应用的密码不受影响。

命令

命令作用
/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 TokenBase URLToken—
自定义—自定义在编辑页「高级」里自定义
claude plugin validate .
claude plugin test .

接收 $ 的函数必须写在 hooks/register.tsx 顶层,其他文件只放纯函数。请使用与当前 Claude Code 同版本的 claude 命令做校验。


English

What it solves

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:

  • secrets live only in the macOS Keychain; Claude only knows each credential's name and purpose;
  • the mod injects credentials into commands as environment variables, and any secret in the output is replaced with «vault:name.field»;
  • you decide which directory may use which credential, read-only or read-write.

Install

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

Getting started

  1. Open the pane: type /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.
  2. Create a credential: click "+ 新建" (New), pick a type (PostgreSQL, SSH, Kubernetes, …), fill in host and user, add a short description (Claude uses it to pick the right credential), and click "创建" (Create).
  3. Enter the secret: a native password dialog opens right away (a file picker for keys and kubeconfigs). The value goes straight to the Keychain and is never shown.
  4. Test it: click "测试连接" (Test connection) on the card.
  5. Grant it: on the card's "当前目录" (current directory) row, click "只读" (read) or "读写" (write). The grant covers this directory and its subdirectories until you revoke it with "不授权" (none).

From then on, just ask Claude, e.g. "check the orders table on prod-db".

How Claude uses credentials

  • The vault_exec tool (Claude's default): name the credential and the command; clients like psql, kubectl and ssh connect without any flags.
  • Profile variables in Bash: every credential has variables prefixed with its name, e.g. 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.
  • A first line #vault:prod-db: also injects the client's standard variables such as PGPASSWORD.

The edit page lists every variable a credential provides.

Grants

  • Grants belong to directories, not sessions, and never expire until revoked.
  • Subdirectories inherit their parent's grants and can override them; the nearest one wins.
  • "授权管理" (Grants) lists every directory's grants and revokes them one by one.
  • Grants are stored in your home directory, outside any repository: a project cannot grant itself, and Claude cannot change grants.
  • "Read" blocks common writes to databases, Redis and kubectl, but not over SSH. Use a read-only account for real read-only access.

Backup, restore and cleanup

  • Export: tick credentials, set a passphrase (12+ characters), and get one encrypted .cvault file holding the configuration and secrets.
  • Import: pick the .cvault file, enter the passphrase, review, confirm. For conflicts, choose overwrite, skip, or keep a copy. Imported credentials need to be granted again.
  • One-click cleanup ("🧹 一键清理"): wipes every credential, every vault secret in the Keychain, all grants and logs. It asks for confirmation and cannot be undone, so export first. Other apps' Keychain items are untouched.

Commands

CommandEffect
/vaultopen the pane
/vault listlist credentials and grants
`/vault grant <name> [read\write]`grant to the current directory
/vault revoke <name>revoke a grant
/vault export, /vault importexport, 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

Supported types

TypeYou fill inSecretVariables clients read
PostgreSQLhost, userpasswordPGHOST PGUSER PGPASSWORD …
MySQLhost, userpasswordMYSQL_HOST MYSQL_PWD …
RedishostpasswordREDISCLI_AUTH
MongoDB—connection URIMONGODB_URI
SSHhost, userkey file or passwordGIT_SSH_COMMAND; password login needs no sshpass
Kubernetes—kubeconfig fileKUBECONFIG
Supabase accountproject URL, emailpassword, anon keylogs in when a command runs and injects $<NAME>_TOKEN (access token)
HTTP API tokenbase URLtoken—
Custom—your owndefine 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.

Source 8 files
hooks/register.tsx 1248 lines
1import { 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 lines
1// 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}
55
hooks/guard.ts 41 lines
1import 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}
41
hooks/transfer.ts 33 lines
1import 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}
33
hooks/ui.tsx 528 lines
1import 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}
528
hooks/select.ts 96 lines
1import 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}
96
hooks/templates.ts 297 lines
1import 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}
297
types/index.d.ts 81 lines
1export 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