Skip to main content
D

dsh-secret-card

dacenss/dsh-secret-card

dsh 插件 · 密钥安全输入卡片:AI 需要用户提供密钥(API Key / Token / 密码)时只负责描述「该写到哪个文件、哪个键」;密钥由用户在弹窗卡片中直接输入,由本插件写入配置文件并可选验证是否生效,密钥全程不进入会话记录,AI 只拿到「写入与验证结果」。

Install

dsh plugin --profile web add github:dacenss/dsh-secret-card

README

dsh-secret-card

npm 包名 dsh-secret-card · 源码与问题反馈:https://github.com/dacenss/dsh-secret-card · MIT · English: README.en.md

npm version downloads license

dsh 插件 · 密钥安全输入卡片。

AI 在配置过程里需要密钥(API Key / Token / 密码 / Webhook 密钥)时,通用做法是让 用户把密钥贴进对话 —— 于是密钥永久留在会话记录里。本插件换一条路:AI 只描述 「密钥该写到哪」,密钥由用户在弹出的卡片里直接输入,由插件写进配置文件,AI 全程 看不到明文,只拿到「写入成功了吗 / 密钥生效了吗」这两个结论。

密钥输入卡片:AI 只指定写入文件与键名,密钥由用户在卡片里直接输入,不进对话

安装

进入 DSH 的 profile 目录,用 DSH 自带的 pnpm 安装:

cd $env:USERPROFILE\.dsh\profiles\<profile 名,通常是 desktop>
pnpm add dsh-secret-card

再把 dsh-secret-card 追加进该 profile package.json 的 dsh.profile.bundles 数组,然后重启 DSH。

不确定 profile 名的话,看 $env:USERPROFILE\.dsh\profiles\ 下有哪几个目录; 装了插件市场的话也可以直接从界面里添加。

cordis.patch.yml 会把插件插进名册,客户端产物 client/bundle.js 已随 npm 包 预构建,装完即可用,不需要额外构建步骤。

它解决什么

以前的体验有了它之后
「请把 API Key 发我」→ 密钥留在对话里AI 说「请在弹出的卡片里输入」→ 密钥进文件,不进对话
AI 读配置文件来「确认写对了」→ 密钥再次进入模型上下文AI 只收到脱敏结果,不含密钥
密钥是否有效,要把密钥发给第三方服务试 → 又进一次上下文插件带着密钥去验证,只回「生效 / 不生效」

安全声明

密钥由用户在本机弹出的卡片里输入,由插件直接写入配置文件;AI 全程接触不到明文,密钥也不经过任何第三方服务器。

  • 密钥唯一一次「过网」,是从页面到本机宿主的同源请求(POST /api/dsh-secret-card/fill),不出本机;
  • 密钥出现在工具参数、消息、文件名、shell 命令里一律拒绝 —— 系统提示层明令禁止,代码层兜底拦截;
  • 验证密钥是否生效时由插件带着密钥去请求,只把「生效 / 不生效」还给 AI,响应体、响应头、命令输出都不外传。

边界:它做不到的部分

  • AI 事后仍可能读回配置文件看到明文 —— 这是提示层约束(系统提示明令禁止 + 返回值带 note),协议层无法物理阻止。敏感文件请用文件权限 / 加密卷进一步保护。
  • 备份文件 <file>.bak-YYYYMMDD-HHmmssSSS 与原文同敏感级,清理时请一并处理。
  • fingerprint 只是写入内容的哈希前 8 位,用于肉眼核对,不足以反推密钥。

密钥不会出现在哪里(逐项保证)

  • 会话记录 / messages —— 密钥从不出现在任何 message,工具返回值与 output.render 均脱敏
  • ctx.logger —— 只打 requestId / 文件 / 键名 / 状态,单测断言日志序列化不含密钥
  • SSE 广播 —— 固定白名单字段,发送前断言不含密钥
  • HTTP 响应体 —— /fill 只回 status / validation,不 echo
  • 进程列表 —— 命令验证的 %%SECRET%% 只允许出现在 stdin,argv 出现即拒绝

报告安全问题

发现安全问题请通过 GitHub Issues 报告,不要在公开 Issue 里贴密钥明文。

工作方式

AI 调用 secret_card(file, key, format, label, hint, validation?)
   │  参数里没有任何密钥内容
   ▼
宿主生成 requestId,SSE 推给页面
   ▼
会话列输入框上方弹出卡片 ← 用户输入密钥
   │  POST /api/dsh-secret-card/fill {requestId, secret}   ← 密钥唯一一次过网(同源)
   ▼
宿主:写入配置文件(先备份、临时文件 + rename 原子落盘、读回校验)
   │  可选:带着密钥去请求验证端点 / 执行 stdin 传密钥的命令
   ▼
resolve pending → 工具返回脱敏 JSON → AI 看到
{status, validation, validationDetail, file, key, backup, fingerprint, note}

支持的写入格式(行级替换,保留注释与风格)

格式写法
.env / .env.*KEY=value(值含空白或引号、美元、井号、反引号、反斜杠等特殊字符时才写作 KEY="value" 并转义)
.json"KEY": "value"(不做 parse/stringify,注释、键顺序、缩进、尾逗号原样保留)
.yaml / .ymlKEY: value(值含特殊字符时输出 KEY: "value" 引号形式)
.tomlKEY = "value"
  • 键已存在 → 覆盖(overwrite:false 时拒绝并返回 key_exists)
  • 键不存在 → 追加到文件尾 / JSON 里插到最外层最后一个键之后
  • 只处理根级键;目标文件必须已存在,且后缀在设置的白名单内(默认 .env env .json .yaml .yml .toml)

取值方怎么读回真值

写入结果里的 quoted 字段说明本次落盘是否带引号,渲染表格的「落盘写法」一栏也会写明:

  • quoted:false —— 文件里就是 KEY=值 本身,直接取等号后面的部分即可
  • quoted:true —— 文件里是 KEY="值",读取时先剥掉首尾双引号,再按 \\ → \、\" → "、\r → 回车、\n → 换行 还原转义

可选的「密钥是否生效」验证

validation 参数是 JSON 字符串,%%SECRET%% 是唯一占位符(不用常见的一对花括号占位,是因为宿主系统提示模板会把成对花括号当变量引用、导致整段注册失败):

// HTTP 验证(推荐)
{"kind":"http","method":"GET","url":"https://api.example.com/v1/verify",
 "header":{"Authorization":"Bearer %%SECRET%%"},"expectStatus":200,"expectBodyContains":"ok"}

// 命令验证(默认关闭,需在设置里打开)
{"kind":"command","argv":["npm","run","check"],"stdinTemplate":"%%SECRET%%\n"}

返回给 AI 的只有 passed / failed / skipped 加一行原因(如 http 403、exit 0、 denied_host),响应体、响应头、退出输出都不外传。

SSRF 护栏:只允许 http/https;不跟随重定向;目标主机命中拒绝名单(本机、内网、 云元数据地址,默认 localhost 127.0.0.1 ::1 0.0.0.0 169.254.169.254 metadata.google.internal,可在设置里增删)时直接拒绝。

给模型的约束(系统提示自动注入)

插件通过 ctx.systemPrompt.section 注入一段说明:需要密钥必须调 secret_card, 禁止把密钥写进参数/消息/文件名/shell 命令,禁止要求用户在对话里贴密钥,禁止读回 刚写入的文件值,一次只处理一个密钥(busy 是「还有一张卡片开着」的意思)。

配置项(设置页可改)

键默认说明
enabledtrue停用后工具直接返回 disabled
timeoutMs180000卡片等待输入的最长时间,到期返回 timeout
backuptrue写入前备份原文件
backupKeep3同一文件保留几份备份
allowedSuffixes见上允许写入的文件后缀白名单
allowCommandValidationfalse是否允许 AI 提供命令验证
denyHosts见上HTTP 验证禁止访问的主机

工具返回值契约

{
  "status": "written | already-written | cancelled | timeout | aborted | busy | failed",
  "reason": "…(仅 failed/busy 有)",
  "validation": "passed | failed | skipped",
  "validationDetail": "http 204",
  "file": "C:\\proj\\.env",
  "key": "OPENAI_API_KEY",
  "backup": ".env.bak-20261007-073004821",
  "fingerprint": "sha256:1a2b3c4d",
  "note": "密钥已由用户在卡片中直接输入并写入文件;本结果不含密钥明文,也不要去读该文件的值。"
}

卡片停在哪、怎么提醒

  • 卡片挂在会话列里输入框的正上方(宿主 conversation.input.dock / conversation.composer 插槽),与内置问答卡片同一套几何与圆角;弹出时输入框整块让位,收起只留标题。
  • 卡片一到达就向宿主登记一条「待应答」(ctx.uiSession.registerPendingInteraction, kind 为 question):会话列表亮黄点、装了什么提醒插件都会通知。登记和卡片显不显示无关—— 用户此刻不在会话页、卡片在排队时,黄点和提醒照样亮。
  • 卡片关闭、写入完成或宿主主动取消时立刻撤下登记,不会留下僵尸黄点。
  • 宿主没有这些插槽时退回页面底部停靠,绝不抢非会话页面。

已知限制

  • 只支持根级键;嵌套 JSON/YAML 的深层路径需要后续版本。
  • 卡片是单例的:一次只处理一个密钥,重复请求会合并到同一张卡。

本地开发

客户端是纯 DOM 实现,不依赖 React,因此 build 脚本只是把源码原样嵌进 window.__ModuleLoader__.load({ id, factory }) 外壳。

npm run check           # node --check 宿主与客户端源码
npm run build:client    # client/index.js → client/bundle.js
npm test                # 单元 41 项 + 集成 8 项 + 文案红线 1 项(共 50 项)

用 file: 协议把源码装进 profile 的话,改完源码必须手动同步(pnpm 对已装的 file 依赖不会重新拷贝),然后重启 DSH:

npm run build:client
pwsh scripts\install-local.ps1              # 默认同步进 desktop profile
pwsh scripts\install-local.ps1 -Profile web # 其他 profile 用 -Profile 指定

脚本按自身所在位置推导工作区与 profile 目录,不写死任何路径。

改给模型看的文案时有一条红线:不能出现成对的双花括号(两个连续左花括号包 一段名字、再两个连续右花括号收尾)。宿主的系统提示模板会把它当变量引用,大写 变量名会让整段 section 注册失败、本轮运行直接报错 malformed prompt variable reference。密钥占位符因此用 %%SECRET%%, test/copy-guard.test.mjs 里有回归断言守着这条线。

Related plugins