@foperate/secret-env-dsh 0.0.0-stage → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -2
- package/client.mjs +89 -0
- package/cordis.patch.yml +4 -0
- package/entry.mjs +57 -0
- package/guards.mjs +83 -0
- package/icon.svg +6 -0
- package/index.js +217 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +42 -4
- package/rules.mjs +48 -0
- package/skills/secret-env/SKILL.md +88 -0
- package/skills/secret-env/references/dsh.md +61 -0
- package/skills/secret-env/references/providers.md +112 -0
- package/skills/secret-env/references/zcode.md +22 -0
package/README.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @foperate/secret-env-dsh
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
secret-env 的 DeepSeek Harness(DSH)bundle:把密钥从私有库注入 bash 子进程环境,模型上下文永不见值。
|
|
4
|
+
|
|
5
|
+
**独立包**:本包零依赖,不 import 主包代码;运行时经 PATH 上的全局 CLI `secret-env` 走宿主协议
|
|
6
|
+
(`protocol 1`:`info` / `inject` / `store`,stdin/stdout JSON,值只经 stdin 且不回显)。
|
|
7
|
+
|
|
8
|
+
## 安装
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
# 1) 全局 CLI(真身;注入、守卫、入库都在这里)
|
|
12
|
+
bun add -g @foperate/secret-env # 或 npm i -g @foperate/secret-env
|
|
13
|
+
|
|
14
|
+
# 2) DSH bundle(本包;装进当前 profile)
|
|
15
|
+
# plugin_manager → action: install_bundle, target: @foperate/secret-env-dsh@<version>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
skill 由 DSH 从 `~/.dsh/skills` 发现,指向本包的 `skills/secret-env`:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
ln -sfn "$HOME/.dsh/profiles/<profile>/node_modules/@foperate/secret-env-dsh/skills/secret-env" \
|
|
22
|
+
"$HOME/.dsh/skills/secret-env"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
安装或升级后重启 DSH(插件代码在启动时加载)。
|
|
26
|
+
|
|
27
|
+
## 组成
|
|
28
|
+
|
|
29
|
+
| 文件 | 作用 |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `index.js` | 装配:`tools/execute` bash 注入、`tools.guard` 同步守卫、systemPrompt 清单、`add_secret` 工具 |
|
|
32
|
+
| `client.mjs` | 协议客户端(`$SENV_BIN` → PATH `secret-env`),超时/退出码/非 JSON 转 `CliError` |
|
|
33
|
+
| `guards.mjs` | 文件工具与 pwsh/terminal_send 的同步守卫(bash 守卫在 CLI 的 `inject` 里) |
|
|
34
|
+
| `rules.mjs` | `scanRefs` 同步副本(与主包 `core.mjs` 同源,跨仓库测试校验) |
|
|
35
|
+
| `entry.mjs` | DSH 专属文案与卡片答案判定 |
|
|
36
|
+
| `cordis.patch.yml` | DSH bundle 补丁(插入 `@foperate/secret-env-dsh` 行) |
|
|
37
|
+
| `skills/secret-env/` | skill 副本(与主包 `skills/secret-env` 逐字节一致) |
|
|
38
|
+
|
|
39
|
+
## 兼容
|
|
40
|
+
|
|
41
|
+
- 需要主包 CLI 支持 `protocol 1`(`secret-env info` 可验证);协议不匹配时插件拒绝加载(宁可失败)。
|
|
42
|
+
- 已知:`ctx.tools.guard` 同步回调限制决定了守卫不能走子进程;bash 守卫与注入统一在 CLI,
|
|
43
|
+
GUI 内文件/通道守卫在本包,`rules-sync` 测试保证两处判定同源。
|
package/client.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// DSH 适配器的 CLI 协议客户端(protocol 1):只经子进程与主包交互,不 import 主包代码。
|
|
2
|
+
// 契约:stdin 一行 JSON,stdout 一行 JSON;退出码 0 成功 / 2 业务拒绝(JSON 带 code)/ 3 协议不匹配。
|
|
3
|
+
// 值只经 stdin 进入,响应不含值。协议见 docs/PLAN-DSH-DECOUPLING.md。
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export const PROTOCOL = 1;
|
|
7
|
+
|
|
8
|
+
export class CliError extends Error {
|
|
9
|
+
constructor(message, { code = "cli", exit } = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "CliError";
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.exit = exit;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MAX_STDERR = 200;
|
|
18
|
+
|
|
19
|
+
// 统一解析:非 JSON / 非零退出都转成 CliError(宁可失败,不静默放行)
|
|
20
|
+
function toJson(out, err, code, cmd) {
|
|
21
|
+
const text = (out ?? "").trim();
|
|
22
|
+
let json = null;
|
|
23
|
+
try { json = text ? JSON.parse(text) : null; } catch { json = null; }
|
|
24
|
+
if (json === null) {
|
|
25
|
+
throw new CliError(`secret-env ${cmd} 返回非 JSON(exit=${code})${err ? `:${err.trim().slice(0, MAX_STDERR)}` : ""}`, { code: "bad-output", exit: code });
|
|
26
|
+
}
|
|
27
|
+
if (code !== 0) throw new CliError(json.error ?? `secret-env ${cmd} 失败(exit=${code})`, { code: json.code ?? "cli", exit: code });
|
|
28
|
+
return json;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createClient({ bin = process.env.SENV_BIN || "secret-env", timeoutMs = 8000 } = {}) {
|
|
32
|
+
const hint = `请全局安装 @foperate/secret-env(bun add -g · npm i -g)并确认 ${bin} 在 PATH 上`;
|
|
33
|
+
|
|
34
|
+
function run(cmd, payload, { timeout = timeoutMs } = {}) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
let child;
|
|
37
|
+
try {
|
|
38
|
+
child = spawn(bin, [cmd], { stdio: ["pipe", "pipe", "pipe"] });
|
|
39
|
+
} catch (e) {
|
|
40
|
+
reject(new CliError(`无法启动 ${bin}:${e.message}——${hint}`, { code: "spawn" }));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let out = "", err = "", done = false;
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
done = true;
|
|
46
|
+
child.kill("SIGKILL");
|
|
47
|
+
reject(new CliError(`secret-env ${cmd} 超时(${timeout}ms)`, { code: "timeout" }));
|
|
48
|
+
}, timeout);
|
|
49
|
+
const finish = (fn, value) => {
|
|
50
|
+
if (done) return;
|
|
51
|
+
done = true;
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
fn(value);
|
|
54
|
+
};
|
|
55
|
+
child.on("error", (e) => finish(reject, new CliError(`无法启动 ${bin}(${e.code ?? e.message})——${hint}`, { code: "spawn" })));
|
|
56
|
+
child.stdout.on("data", (d) => { out += d; });
|
|
57
|
+
child.stderr.on("data", (d) => { err += d; });
|
|
58
|
+
child.on("close", (code) => {
|
|
59
|
+
try { finish(resolve, toJson(out, err, code, cmd)); }
|
|
60
|
+
catch (e) { finish(reject, e); }
|
|
61
|
+
});
|
|
62
|
+
child.stdin.end(payload === null ? undefined : JSON.stringify({ protocol: PROTOCOL, ...payload }));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 同步版只给 info 用:DSH 的 guard 是同步回调,插件启动时必须有快照才能判定。
|
|
67
|
+
function runSync(cmd) {
|
|
68
|
+
const r = spawnSync(bin, [cmd], { encoding: "utf8", timeout: timeoutMs });
|
|
69
|
+
if (r.error) throw new CliError(`无法启动 ${bin}(${r.error.code ?? r.error.message})——${hint}`, { code: "spawn" });
|
|
70
|
+
return toJson(r.stdout ?? "", r.stderr ?? "", r.status ?? -1, cmd);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
bin,
|
|
75
|
+
info: () => run("info", null),
|
|
76
|
+
infoSync: () => runSync("info"),
|
|
77
|
+
inject: (payload) => run("inject", payload),
|
|
78
|
+
store: (payload) => run("store", payload),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 版本握手:插件只认自己实现的协议版本
|
|
83
|
+
export function requireProtocol(info) {
|
|
84
|
+
const got = info?.protocol;
|
|
85
|
+
if (got !== PROTOCOL) {
|
|
86
|
+
throw new CliError(`secret-env 协议版本不匹配:插件需要 ${PROTOCOL},CLI 返回 ${got ?? "无"}(请把主包与插件升到匹配版本)`, { code: "protocol" });
|
|
87
|
+
}
|
|
88
|
+
return info;
|
|
89
|
+
}
|
package/cordis.patch.yml
ADDED
package/entry.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// DSH 登记条目的纯逻辑:消息/选项文案与答案解释。
|
|
2
|
+
// 条目构造与终端兜底命令已下沉到 CLI 协议(store 的 ready / command 形态),本文件不 import 主包代码。
|
|
3
|
+
// PROVIDERS 只用于文案与工具 schema 兜底:权威列表来自 info.providers(协议)。
|
|
4
|
+
export const PROVIDERS = ["keychain", "bitwarden", "viking", "onepassword"];
|
|
5
|
+
|
|
6
|
+
const lang = (() => {
|
|
7
|
+
const l = process.env.LC_ALL ?? process.env.LANG ?? "";
|
|
8
|
+
return l.toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
9
|
+
})();
|
|
10
|
+
|
|
11
|
+
export const T = {
|
|
12
|
+
zh: {
|
|
13
|
+
toolDescription:
|
|
14
|
+
"会话内交互登记新凭据:元数据由本工具入参传入;密钥值由宿主问答卡片直接向用户收集并写入凭据库,永远不进入模型上下文——不要试图把密钥值作为参数传入,也不要让用户发给你。返回 status:stored=已入库 / command-issued=已给出终端命令 / declined=用户取消 / invalid=元数据有误 / error=执行失败。",
|
|
15
|
+
paramName: "人读名(如 github token),归一化为全大写下划线环境变量名",
|
|
16
|
+
paramProvider: "keychain(默认)/ bitwarden / viking / onepassword",
|
|
17
|
+
paramDetail: "条目定位:bitwarden/onepassword=条目名[/字段];viking=类目/目标[/字段];keychain 留空自动命名",
|
|
18
|
+
paramAlias: "别名(可选,逗号分隔)",
|
|
19
|
+
paramDescription: "描述(可选。会进入模型上下文,不得含敏感信息)",
|
|
20
|
+
cardHeader: "录入密钥值",
|
|
21
|
+
cardQuestion:
|
|
22
|
+
"secret-env 新条目登记:请选择密钥值录入方式。\n\n注意:这里提交的内容会原样写入凭据库、不经过模型,因此必须直接填写密钥本身——不要填名字、描述、占位符或「我的 token」之类的说明(模型无法校验或补全)。\n\n· 直接填写:在下方输入框填写密钥值——值明文显示在卡片中(介意旁观/录屏请改选命令行),提交后直接写入凭据库,不会进入模型上下文。\n· 命令行:选择「我希望通过命令行来完成输入」,我会给你一条在终端执行的命令(隐藏输入)。",
|
|
23
|
+
commandLabel: "我希望通过命令行来完成输入",
|
|
24
|
+
commandDescription: "随后给出一条 senv add 命令,在终端执行时隐藏输入密钥值。",
|
|
25
|
+
missingName: "缺少 name(归一化后为空)",
|
|
26
|
+
badProvider: `provider 只能是 ${PROVIDERS.join(" / ")}`,
|
|
27
|
+
exists: "已存在同名条目,请换名后重调(维护已有条目请在终端用 senv add 不带 --provider)",
|
|
28
|
+
noAgent: "无法定位调用方 agent,不能弹问答卡片",
|
|
29
|
+
},
|
|
30
|
+
en: {
|
|
31
|
+
toolDescription:
|
|
32
|
+
"Register a new credential interactively in this session: metadata comes from the tool arguments; the secret value is collected by the host's question card and written straight to the credential store — it never enters model context. Do not pass the secret value as an argument or ask the user to send it. Returns status: stored / command-issued / declined / invalid / error.",
|
|
33
|
+
paramName: "Human-readable name (e.g. github token); normalized to UPPER_SNAKE env var",
|
|
34
|
+
paramProvider: "keychain (default) / bitwarden / viking / onepassword",
|
|
35
|
+
paramDetail: "Entry locator: bitwarden/onepassword=item[/field]; viking=category/target[/field]; keychain=leave blank",
|
|
36
|
+
paramAlias: "Aliases (optional, comma-separated)",
|
|
37
|
+
paramDescription: "Description (optional; enters model context — no sensitive info)",
|
|
38
|
+
cardHeader: "Enter the secret value",
|
|
39
|
+
cardQuestion:
|
|
40
|
+
"secret-env new entry: choose how to enter the secret.\n\nNote: whatever is submitted here is written straight to the credential store without passing through the model, so it must be the secret value itself — not a name, description, placeholder, or note like \"my token\" (the model cannot validate or complete it).\n\n· Type it: fill the field below — the value is visible on the card (avoid if others can see your screen), and is written straight to the credential store without entering model context.\n· Command line: choose \"I'll enter it via the command line\" to get a command for your terminal (hidden input).",
|
|
41
|
+
commandLabel: "I'll enter it via the command line",
|
|
42
|
+
commandDescription: "Returns a senv add command that hides the input in your terminal.",
|
|
43
|
+
missingName: "Missing name (empty after normalization)",
|
|
44
|
+
badProvider: `provider must be one of ${PROVIDERS.join(" / ")}`,
|
|
45
|
+
exists: "An entry with this name already exists — pick another name (to maintain an existing entry run senv add without --provider)",
|
|
46
|
+
noAgent: "Cannot resolve the calling agent; the question card is unavailable",
|
|
47
|
+
},
|
|
48
|
+
}[lang];
|
|
49
|
+
|
|
50
|
+
// 单选问答:custom 覆盖 selected(DSH 语义)。返回 value / command / declined。
|
|
51
|
+
export function interpretAnswer(item, commandLabel) {
|
|
52
|
+
const custom = typeof item?.custom === "string" ? item.custom.trim() : "";
|
|
53
|
+
if (custom !== "") return { kind: "value", value: custom };
|
|
54
|
+
const selected = Array.isArray(item?.selected) ? item.selected : [];
|
|
55
|
+
if (selected.includes(commandLabel)) return { kind: "command" };
|
|
56
|
+
return { kind: "declined" };
|
|
57
|
+
}
|
package/guards.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// DSH 守卫判定(纯函数,供单测直接调用):文件通道 + 无注入通道的拒绝;不做密钥解析。
|
|
2
|
+
// 规则数据来自 CLI 的 info 快照(guardPaths)+ 本地 scanRefs 副本(同步判定,不 spawn 子进程)。
|
|
3
|
+
// bash 的命令文本守卫由 CLI 的 inject 统一执行(三端同语义,2026-11-06)——这里只管
|
|
4
|
+
// 文件工具(同步)与 pwsh/terminal_send(没有注入通道,必须在本地拦)。
|
|
5
|
+
// 与 ZCode 的 guardPaths 同源:secrets.json(含 master 口令明文)/ vault.kdbx 对 agent 一律只读不可触。
|
|
6
|
+
import { matchGuardPaths, scanRefs } from "./rules.mjs";
|
|
7
|
+
|
|
8
|
+
const FILE_TOOLS = ["read", "read_image", "write", "edit"];
|
|
9
|
+
const COMMAND_CHANNELS = new Map([
|
|
10
|
+
["pwsh", "command"],
|
|
11
|
+
["terminal_send", "text"],
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const protectedReason = (fragment) =>
|
|
15
|
+
`secret-env:操作涉及受保护文件(片段「${fragment}」)——secrets.json / vault.kdbx 含 master 口令明文与全部凭据映射,禁止 Agent 读写。配置维护请在终端运行 secret-env add/remove,或亲手编辑。`;
|
|
16
|
+
|
|
17
|
+
// 返回拒绝理由(字符串)或 undefined(放行)。exec 形态见 DSH ToolDispatchExecution:{ name, arguments, callId, agent, signal }。
|
|
18
|
+
// snapshot = { entries, guardPaths, config },由 info 协议构建(见 index.js buildSnapshot)。
|
|
19
|
+
export function evaluateCall(exec, snapshot) {
|
|
20
|
+
if (exec === null || typeof exec !== "object") return;
|
|
21
|
+
const name = exec.name;
|
|
22
|
+
if (typeof name !== "string" || name.length === 0) return;
|
|
23
|
+
const args = exec.arguments !== null && typeof exec.arguments === "object" ? exec.arguments : {};
|
|
24
|
+
const fragments = (snapshot?.guardPaths ?? []).filter((f) => typeof f === "string" && f.length > 0);
|
|
25
|
+
|
|
26
|
+
if (FILE_TOOLS.includes(name)) {
|
|
27
|
+
for (const key of ["file_path", "path"]) {
|
|
28
|
+
const fragment = matchGuardPaths(args[key], fragments);
|
|
29
|
+
if (fragment !== undefined) return protectedReason(fragment);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (name === "apply_patch") {
|
|
34
|
+
const fragment = matchGuardPaths(args.patch, fragments);
|
|
35
|
+
return fragment === undefined ? undefined : protectedReason(fragment);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const commandKey = COMMAND_CHANNELS.get(name);
|
|
39
|
+
if (commandKey === undefined) return;
|
|
40
|
+
const command = args[commandKey];
|
|
41
|
+
if (typeof command !== "string" || command.length === 0) return;
|
|
42
|
+
|
|
43
|
+
const fragment = matchGuardPaths(command, fragments);
|
|
44
|
+
if (fragment !== undefined) return protectedReason(fragment);
|
|
45
|
+
|
|
46
|
+
// pwsh / terminal_send 没有注入通道——引用已配置密钥时直接拒绝,避免变量为空静默执行(宁可失败)。
|
|
47
|
+
let tokens = [];
|
|
48
|
+
try {
|
|
49
|
+
tokens = scanRefs(command, snapshot.config, (reason) => { throw new Error(reason); });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return error instanceof Error ? error.message : String(error);
|
|
52
|
+
}
|
|
53
|
+
if (name === "pwsh") {
|
|
54
|
+
// PowerShell 的读法 $env:NAME(大小写不敏感)不被 scanRefs 的 $NAME 覆盖。
|
|
55
|
+
for (const [alias, info] of snapshot.config) {
|
|
56
|
+
if (new RegExp(`\\$env:${alias}(?![A-Za-z0-9_])`, "i").test(command)) {
|
|
57
|
+
tokens = [{ token: `$env:${alias}`, name: info.canonical }];
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (tokens.length > 0) {
|
|
63
|
+
return `secret-env:${name} 通道不注入密钥(只支持 bash 工具)。需要凭据请改用 bash,或在终端用 srun/senv 执行。`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 等价 ZCode SessionStart 的 `secret-env list --inject`:注入已配置名字清单与用法约定(无真值)。
|
|
68
|
+
export function promptText(snapshot) {
|
|
69
|
+
const entries = snapshot?.entries ?? [];
|
|
70
|
+
if (entries.length === 0) {
|
|
71
|
+
return "secret-env:当前没有已配置的密钥条目。命令中不要写密钥明文;需要时请用户在终端运行 secret-env add 配置。";
|
|
72
|
+
}
|
|
73
|
+
return [
|
|
74
|
+
"secret-env:已配置的密钥(值永远不会出现在上下文;在 bash 命令文本里显式引用即可注入子进程环境):",
|
|
75
|
+
...entries.map(
|
|
76
|
+
({ name, aliases = [], description }) =>
|
|
77
|
+
`- ${name}${aliases.length ? `(别名:${aliases.join("、")})` : ""}${description ? `:${description}` : ""}`,
|
|
78
|
+
),
|
|
79
|
+
'用法:写 $NAME 或 ${NAME}(双引号内),例如 curl -H "Authorization: Bearer $GH_TOKEN" ...;隐式读取环境变量的工具写成 NAME=$NAME cmd。',
|
|
80
|
+
"禁止:单引号包引用(拒绝)、echo/打印凭据、同一命令里出现 env/printenv/裸 set/export。",
|
|
81
|
+
"terminal_send 与 pwsh 通道不注入密钥,需要凭据请用 bash。禁止读写 ~/.config/secret-env 与 vault.kdbx。",
|
|
82
|
+
].join("\n");
|
|
83
|
+
}
|
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Secret Env">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#2f6feb"/>
|
|
3
|
+
<circle cx="24" cy="25" r="8" fill="none" stroke="#ffffff" stroke-width="4"/>
|
|
4
|
+
<path d="M29.5 30.5 L46 47" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
|
|
5
|
+
<path d="M38 39.5 L44 33.5 M41.5 43 L47.5 37" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
|
|
6
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// secret-env DSH 真身适配器(协议客户端形态,2026-11-06 起):
|
|
2
|
+
// 1) tools/execute 包装 bash:调 CLI `inject`(命令文本守卫 + 注入后的命令;值只在子进程里写 env 文件,不进上下文);
|
|
3
|
+
// 2) tools.guard 同步守卫:本地规则副本 + info 快照(受保护路径、名字清单),不 spawn 子进程;
|
|
4
|
+
// 3) systemPrompt 注入已配置名字清单与用法约定(来自 info 快照);
|
|
5
|
+
// 4) add_secret 工具:元数据由模型传,ctx.userQuestions 卡片收值后经 CLI `store` 入库(值走 stdin)。
|
|
6
|
+
// 不 import 主包代码:只经 CLI 协议(client.mjs,见 docs/PLAN-DSH-DECOUPLING.md)。
|
|
7
|
+
// 运行时中立:与 core.mjs 同规,不用 Bun 专属 API。
|
|
8
|
+
// 注:工具定义直接构造 JSON Schema 交给 ctx.tools.register,不 import defineTool(插件经 pnpm link
|
|
9
|
+
// 从仓库/包路径加载时,Node 按真实路径解析不到 profile 的 @deepseek-ai/dsh-tools)。
|
|
10
|
+
import { appendFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { T, PROVIDERS, interpretAnswer } from "./entry.mjs";
|
|
14
|
+
import { evaluateCall, promptText } from "./guards.mjs";
|
|
15
|
+
import { PROTOCOL, createClient, requireProtocol } from "./client.mjs";
|
|
16
|
+
|
|
17
|
+
const LOG = join(tmpdir(), "secret-env-dsh.log");
|
|
18
|
+
const log = (msg) => { try { appendFileSync(LOG, `[${new Date().toISOString()}] ${msg}\n`); } catch {} };
|
|
19
|
+
|
|
20
|
+
const REFRESH_MS = 30 * 1000; // 快照过期时间:guard 每次调用顺手触发后台刷新(非阻塞)
|
|
21
|
+
|
|
22
|
+
// info(协议)→ 本地快照:config 供同步 scanRefs 用,entries 供清单文案,guardPaths 供文件守卫
|
|
23
|
+
export function buildSnapshot(info) {
|
|
24
|
+
const config = new Map();
|
|
25
|
+
for (const entry of info?.entries ?? []) {
|
|
26
|
+
config.set(entry.name, { canonical: entry.name, description: entry.description });
|
|
27
|
+
for (const alias of entry.aliases ?? []) config.set(alias, { canonical: entry.name });
|
|
28
|
+
}
|
|
29
|
+
return { entries: info?.entries ?? [], guardPaths: info?.rules?.guardPaths ?? [], config };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// CLI 的 store 错误码 → 宿主文案
|
|
33
|
+
function storeError(error) {
|
|
34
|
+
if (error?.code === "exists") return { status: "invalid", message: T.exists };
|
|
35
|
+
if (error?.code === "unknown-provider") return { status: "invalid", message: T.badProvider };
|
|
36
|
+
if (error?.code === "missing-name") return { status: "invalid", message: T.missingName };
|
|
37
|
+
return { status: "error", message: error?.message ?? String(error) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const inject = ["tools", "systemPrompt", "userQuestions"];
|
|
41
|
+
|
|
42
|
+
// options.client 仅供离线测试注入假客户端;生产不传,走 createClient()($SENV_BIN → PATH secret-env)
|
|
43
|
+
export function apply(ctx, options = {}) {
|
|
44
|
+
const client = options.client ?? createClient();
|
|
45
|
+
const disposers = [];
|
|
46
|
+
|
|
47
|
+
// 启动即同步拉快照:guard 是同步回调,没有快照就无法判定。失败 = 插件不加载(宁可失败,不静默放行)。
|
|
48
|
+
let info;
|
|
49
|
+
try {
|
|
50
|
+
info = requireProtocol(client.infoSync());
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Error(`secret-env 无法初始化:${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
let snapshot = buildSnapshot(info);
|
|
55
|
+
let lastRefresh = Date.now();
|
|
56
|
+
let refreshing = false;
|
|
57
|
+
|
|
58
|
+
async function refresh() {
|
|
59
|
+
if (refreshing) return;
|
|
60
|
+
refreshing = true;
|
|
61
|
+
try {
|
|
62
|
+
const next = requireProtocol(await client.info());
|
|
63
|
+
info = next;
|
|
64
|
+
snapshot = buildSnapshot(next);
|
|
65
|
+
lastRefresh = Date.now();
|
|
66
|
+
log(`snapshot refresh entries=${snapshot.entries.length}`);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
log(`snapshot refresh failed: ${error.message}`);
|
|
69
|
+
} finally {
|
|
70
|
+
refreshing = false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const maybeRefresh = () => { if (Date.now() - lastRefresh > REFRESH_MS) void refresh(); };
|
|
74
|
+
|
|
75
|
+
// 守卫在 pre-execute / approval 之后、dispatch 之前同步执行;返回理由即拒绝。
|
|
76
|
+
disposers.push(ctx.tools.guard((exec) => {
|
|
77
|
+
maybeRefresh();
|
|
78
|
+
const reason = evaluateCall(exec, snapshot);
|
|
79
|
+
if (reason !== undefined) log(`deny tool=${exec?.name} reason=${reason.slice(0, 160)}`);
|
|
80
|
+
return reason;
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
// 清单 + 约定:等价 ZCode SessionStart 注入;配置变化在下次 assembly 反映。
|
|
84
|
+
disposers.push(ctx.systemPrompt.section({
|
|
85
|
+
name: "secret-env",
|
|
86
|
+
order: 900,
|
|
87
|
+
interpolate: false,
|
|
88
|
+
text: () => promptText(snapshot),
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
// bash 注入 + 命令文本守卫:一律经 CLI 的 inject(三端同语义,2026-11-06)——
|
|
92
|
+
// 未配置条目也要走一次,否则受保护路径的命令会漏拦。
|
|
93
|
+
// exec.arguments 的替换依赖 dispatchToolBody 现场读取同一 exec 对象。
|
|
94
|
+
// DSH 官方契约只承诺 wrappers 可改 exec.signal;这是既有探针验证过的实现细节,DSH 收紧时需改显式 srun/senv。
|
|
95
|
+
disposers.push(ctx.on("tools/execute", async function (exec, next) {
|
|
96
|
+
if (exec?.name !== "bash") return next();
|
|
97
|
+
const command = exec?.arguments?.command;
|
|
98
|
+
if (typeof command !== "string" || !command) return next();
|
|
99
|
+
|
|
100
|
+
const session = String(exec.callId ?? exec.id ?? "dsh");
|
|
101
|
+
let result;
|
|
102
|
+
try {
|
|
103
|
+
result = await client.inject({ command, session });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new Error(`secret-env:${error.message}`); // 业务拒绝与 CLI 故障都报错(宁可失败)
|
|
106
|
+
}
|
|
107
|
+
if (!result?.envFile) return next(); // 无引用 = 零注入
|
|
108
|
+
|
|
109
|
+
exec.arguments = { ...exec.arguments, command: result.command };
|
|
110
|
+
log(`inject via cli key=${session} file=${result.envFile}`);
|
|
111
|
+
void refresh(); // 解析成功可能伴随配置变化(极少),顺手刷新
|
|
112
|
+
return next();
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
disposers.push(ctx.tools.register({
|
|
116
|
+
name: "add_secret",
|
|
117
|
+
description: T.toolDescription,
|
|
118
|
+
parameters: {
|
|
119
|
+
type: "object",
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
properties: {
|
|
122
|
+
name: { type: "string", description: T.paramName },
|
|
123
|
+
provider: { type: "string", enum: PROVIDERS, description: T.paramProvider },
|
|
124
|
+
detail: { type: "string", description: T.paramDetail },
|
|
125
|
+
alias: { type: "string", description: T.paramAlias },
|
|
126
|
+
description: { type: "string", description: T.paramDescription },
|
|
127
|
+
},
|
|
128
|
+
required: ["name"],
|
|
129
|
+
},
|
|
130
|
+
output: {
|
|
131
|
+
schema: {
|
|
132
|
+
type: "object",
|
|
133
|
+
additionalProperties: false,
|
|
134
|
+
properties: {
|
|
135
|
+
status: { type: "string", enum: ["stored", "command-issued", "declined", "invalid", "error"] },
|
|
136
|
+
envName: { type: "string" },
|
|
137
|
+
uri: { type: "string" },
|
|
138
|
+
command: { type: "string" },
|
|
139
|
+
message: { type: "string" },
|
|
140
|
+
},
|
|
141
|
+
required: ["status"],
|
|
142
|
+
},
|
|
143
|
+
render: (_args, value) => [{ type: "text", text: JSON.stringify(value) }],
|
|
144
|
+
},
|
|
145
|
+
async execute(args, exec) {
|
|
146
|
+
if (exec.agent === undefined) return { status: "error", message: T.noAgent };
|
|
147
|
+
|
|
148
|
+
const name = String(args.name ?? "").trim();
|
|
149
|
+
if (!name) return { status: "invalid", message: T.missingName };
|
|
150
|
+
|
|
151
|
+
const provider = args.provider === undefined || args.provider === "" ? "keychain" : args.provider;
|
|
152
|
+
if (!PROVIDERS.includes(provider)) return { status: "invalid", message: T.badProvider };
|
|
153
|
+
|
|
154
|
+
const picked = {
|
|
155
|
+
name,
|
|
156
|
+
provider,
|
|
157
|
+
detail: String(args.detail ?? "").trim(),
|
|
158
|
+
alias: String(args.alias ?? "").trim(),
|
|
159
|
+
description: String(args.description ?? "").trim(),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// 校验阶段(不入库):判重与命名归一化由 CLI 回答,宿主不重复实现
|
|
163
|
+
try {
|
|
164
|
+
await client.store(picked);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return storeError(error);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let item;
|
|
170
|
+
try {
|
|
171
|
+
const answer = await ctx.userQuestions.ask({
|
|
172
|
+
agent: exec.agent,
|
|
173
|
+
signal: exec.signal,
|
|
174
|
+
wait: { callId: exec.callId },
|
|
175
|
+
questions: [{
|
|
176
|
+
id: "value",
|
|
177
|
+
header: T.cardHeader,
|
|
178
|
+
question: T.cardQuestion,
|
|
179
|
+
options: [{ label: T.commandLabel, description: T.commandDescription }],
|
|
180
|
+
}],
|
|
181
|
+
});
|
|
182
|
+
item = answer?.answers?.[0];
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const code = error?.code;
|
|
185
|
+
if (code === "ASK_CANCELLED" || code === "ASK_ABORTED" || code === "ASK_TIMED_OUT" || code === "NO_PROVIDER") {
|
|
186
|
+
return { status: "declined" };
|
|
187
|
+
}
|
|
188
|
+
return { status: "error", message: error?.message ?? String(error) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const decision = interpretAnswer(item, T.commandLabel);
|
|
192
|
+
if (decision.kind === "declined") return { status: "declined" };
|
|
193
|
+
if (decision.kind === "command") {
|
|
194
|
+
try {
|
|
195
|
+
const fallback = await client.store({ ...picked, commandOnly: true });
|
|
196
|
+
return { status: "command-issued", command: fallback.command };
|
|
197
|
+
} catch (error) {
|
|
198
|
+
return storeError(error);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const stored = await client.store({ ...picked, value: decision.value });
|
|
204
|
+
log(`add_secret stored env=${stored.envName}`);
|
|
205
|
+
void refresh();
|
|
206
|
+
return { status: "stored", envName: stored.envName, uri: stored.uri };
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return storeError(error);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
log(`applied protocol=${PROTOCOL} version=${info.version} entries=${snapshot.entries.length}`);
|
|
214
|
+
return () => {
|
|
215
|
+
for (const dispose of disposers) dispose?.();
|
|
216
|
+
};
|
|
217
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "meta": { "title": "Secret Env", "description": "Inject credentials into shell commands without exposing values to the model." } }
|
package/locale/zh.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "meta": { "title": "Secret Env", "description": "把密钥注入 shell 子进程环境,模型上下文永远看不到值。" } }
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foperate/secret-env-dsh",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "secret-env 的 DeepSeek Harness(DSH)bundle:bash 注入、文件/通道守卫、密钥清单与 add_secret 工具。运行时经 PATH 上的全局 CLI 走宿主协议(protocol 1),本包零依赖。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "whitemay",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/whitemay/zcode-plugin-secret-env.git",
|
|
16
|
+
"directory": "adapters/dsh"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"dsh",
|
|
20
|
+
"deepseek-harness",
|
|
21
|
+
"secret-env",
|
|
22
|
+
"credential-injection"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
"index.js",
|
|
26
|
+
"entry.mjs",
|
|
27
|
+
"guards.mjs",
|
|
28
|
+
"rules.mjs",
|
|
29
|
+
"client.mjs",
|
|
30
|
+
"cordis.patch.yml",
|
|
31
|
+
"locale/",
|
|
32
|
+
"icon.svg",
|
|
33
|
+
"skills/",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/rules.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// DSH 适配器内的同步判定规则:scanRefs 与 matchGuardPaths。
|
|
2
|
+
// 为什么复制而不是 import 主包:`ctx.tools.guard` 是同步回调(每次工具调用都跑,不能 await 子进程),
|
|
3
|
+
// 扫描算法必须留在插件内;规则**数据**(guardPaths)仍由 CLI 的 info 提供,不在这里硬编码。
|
|
4
|
+
// 同步来源:core.mjs 的 scanRefs(2026-11-06 复制)。改任一边都要同步另一边,并跑 tests/rules-sync.mjs。
|
|
5
|
+
// 返回每个引用 token 的 {token, start, inSingle, name};deny 为宿主拒绝回调(占位符未配置时调用)。
|
|
6
|
+
export function scanRefs(command, config, deny) {
|
|
7
|
+
const tokens = []; // {token, start, inSingle, name}
|
|
8
|
+
const unknownPlaceholders = [];
|
|
9
|
+
|
|
10
|
+
// 逐字符扫描建立单引号区间(shell 语义:单引号内无转义)
|
|
11
|
+
const singleRanges = [];
|
|
12
|
+
let inSingle = false, rangeStart = -1;
|
|
13
|
+
for (let i = 0; i < command.length; i++) {
|
|
14
|
+
const c = command[i];
|
|
15
|
+
if (c === "'") {
|
|
16
|
+
if (inSingle) { singleRanges.push([rangeStart, i]); inSingle = false; }
|
|
17
|
+
else { inSingle = true; rangeStart = i; }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const inSingleQuote = (i) => singleRanges.some(([a, b]) => i > a && i < b);
|
|
21
|
+
|
|
22
|
+
// ① 占位符 {{secret:NAME}}
|
|
23
|
+
const phRe = /\{\{secret:([A-Za-z0-9_]+)\}\}/g;
|
|
24
|
+
let m;
|
|
25
|
+
while ((m = phRe.exec(command))) {
|
|
26
|
+
const name = m[1];
|
|
27
|
+
if (!config.has(name)) { unknownPlaceholders.push(name); continue; }
|
|
28
|
+
tokens.push({ token: m[0], start: m.index, name, inSingle: inSingleQuote(m.index) });
|
|
29
|
+
}
|
|
30
|
+
if (unknownPlaceholders.length) {
|
|
31
|
+
deny(`secret-env:占位符 {{secret:${unknownPlaceholders.join("、")}}} 未在 secrets.json 配置。宁可失败:请修正名字或先配置。`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ② 显式 $NAME / ${NAME}(词边界;\$ 转义跳过)
|
|
35
|
+
for (const [alias, info] of config) {
|
|
36
|
+
const re = new RegExp(`(?<![A-Za-z0-9_\\$])\\$\{?${alias}(?![A-Za-z0-9_])`, "g");
|
|
37
|
+
while ((m = re.exec(command))) {
|
|
38
|
+
if (m.index > 0 && command[m.index - 1] === "\\") continue;
|
|
39
|
+
tokens.push({ token: m[0], start: m.index, name: info.canonical, inSingle: inSingleQuote(m.index) });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return tokens;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 受保护片段匹配(规则数据来自 CLI info 的 rules.guardPaths)
|
|
46
|
+
export function matchGuardPaths(text, fragments) {
|
|
47
|
+
return typeof text === "string" ? fragments.find((f) => text.includes(f)) : undefined;
|
|
48
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: secret-env
|
|
3
|
+
description: 密钥注入约定——执行需要凭据的 Bash 命令时如何让 secret-env 插件把凭据送进子进程环境(模型上下文永不见值)。写涉及 GH_TOKEN/API key/DATABASE_URL 等凭据的命令前必读。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# secret-env:密钥显式引用约定
|
|
7
|
+
|
|
8
|
+
本项目的密钥从私有库(Keychain / OpenViking / Bitwarden)直接注入 Bash 子进程环境。
|
|
9
|
+
**你(模型)从头到尾只写变量名,永远不写、也永远看不到真值。**
|
|
10
|
+
注入使用周知原名(如 `GH_TOKEN`),命令文本零改写——拦截层只在命令前加 source 前缀。
|
|
11
|
+
|
|
12
|
+
> 宿主适配:ZCode 的拦截层是 PreToolUse 钩子,DSH 的是 host 插件,opencode 的是 V2 插件(`execute.before` 钩子);三者注入语义一致,下文统一称「拦截层」。
|
|
13
|
+
|
|
14
|
+
## 两条注入触发(拦截层只认显式文本)
|
|
15
|
+
|
|
16
|
+
1. **占位符**:命令文本里写 `{{secret:NAME}}`(NAME 必须已配置)
|
|
17
|
+
2. **显式 $VAR**:命令文本里出现已配置的周知变量名
|
|
18
|
+
|
|
19
|
+
## 铁律
|
|
20
|
+
|
|
21
|
+
- **命令里禁止写死任何密钥明文**。需要凭据时写成 `"$GH_TOKEN"` 或 `{{secret:GH_TOKEN}}`。
|
|
22
|
+
- **禁止单引号包引用**:`'$GH_TOKEN'` 内不会展开,拦截层会直接拒绝整条命令。一律双引号:
|
|
23
|
+
```bash
|
|
24
|
+
curl -H "Authorization: Bearer $GH_TOKEN" ...
|
|
25
|
+
psql "$DATABASE_URL" -c '...'
|
|
26
|
+
```
|
|
27
|
+
- **隐式使用必须显式化**:拦截层只扫命令文本,隐式读环境变量的工具(`gh`、`git push` 等)扫不到、不会注入。必须让命令文本出现引用,例如变量名前缀或同义别名均可——副作用都是把真值以原名送进环境:
|
|
28
|
+
```bash
|
|
29
|
+
GH_TOKEN=$GH_TOKEN gh pr list # 同名显式化
|
|
30
|
+
GITHUB_TOKEN=${GH_TOKEN} git push ... # 工具认别的名字,别名映射
|
|
31
|
+
USE_TOKEN=${GH_TOKEN} some-tool ... # 任意名字皆可,注入的仍是原名 GH_TOKEN
|
|
32
|
+
```
|
|
33
|
+
注意:拦截层按命令中**实际引用的名字**注入——引用 `$GITHUB_TOKEN` 就注入 `GITHUB_TOKEN`,引用 `$GH_TOKEN` 就注入 `GH_TOKEN`。需要哪个名字就在命令里写哪个名字的引用。
|
|
34
|
+
- **倾泻意图放独立调用**:查看环境变量、`set -e` 选项、`export FOO=1` 这类与密钥无关的操作,放在**单独一次 Bash 调用**里,不要和带密钥引用的命令拼在同一条。原因:① 每次调用是全新 shell,注入变量不跨调用存活,独立调 `env` 看不到任何密钥,天然安全;② 同一条命令里「密钥引用 + env/printenv/裸 set/裸 export」会被拦截层直接拒绝(同命令倾泻)。
|
|
35
|
+
- **不要 echo/打印凭据**:`echo $GH_TOKEN` 会把值送进你的上下文(出口防线尚未实现),等于绕过整个机制。
|
|
36
|
+
|
|
37
|
+
## 已配置条目
|
|
38
|
+
|
|
39
|
+
会话启动时,宿主会把已配置的**名字清单**(含别名,无真值)注入上下文——ZCode 经 SessionStart 钩子,DSH 经系统提示 section(opencode 暂无清单注入,以 `senv list` 或 `~/.config/secret-env/secrets.json` 为准)——以那份清单为准。配置本体在 `~/.config/secret-env/secrets.json`(周知名 → {uri, aliases?},文件里也只有名字和 uri,没有真值)。配置为空时纯旁路、什么都不注入。清单里的 `BW_SESSION` 是基础设施条目(Bitwarden vault 解锁钥匙,bitwarden:// 凭据解析时自动使用),命令中无需引用它。
|
|
40
|
+
|
|
41
|
+
用户需要接入新密钥库或添加凭据条目时,按下面的流程走(provider 准备步骤见 [references/providers.md](references/providers.md))。
|
|
42
|
+
|
|
43
|
+
## 登记新凭据:先问答,默认交终端执行
|
|
44
|
+
|
|
45
|
+
登记分两步:**你只负责元数据,密钥值永远不经过你的上下文**。
|
|
46
|
+
|
|
47
|
+
**第 1 步:元数据问答**——用宿主自带的问答工具(ZCode 的 `AskUserQuestion`、DSH 的 `ask_user_question`;都没有时直接在对话里问),一次问清三个字段:
|
|
48
|
+
|
|
49
|
+
- **key 题**:从上下文推断 well-known 候选作为选项(同一凭据的多个周知名都列出,如 Hugging Face → `HF_TOKEN`、`HUGGINGFACE_TOKEN`,标注互为别名);用户也可自由输入,格式 `KEY, alias1, alias2`(逗号分隔,别名可省)。问题文案须说明该格式。
|
|
50
|
+
- **desc 题**:选项 1=你组织的一段描述,选项 2=不需要描述。文案须警示「描述会进入模型上下文,不得含敏感信息」。
|
|
51
|
+
- **provider 题**:keychain(KeePassXC 库)/ bitwarden(Bitwarden)/ viking(OpenViking)/ onepassword(1Password),无从推断时默认 keychain。
|
|
52
|
+
|
|
53
|
+
用户选完由**你**把答案解析成定稿:well-known 选项→该名字+已知别名;自由输入→按逗号拆出 name 和别名。
|
|
54
|
+
|
|
55
|
+
**第 2 步(默认兜底,所有宿主可用):构造完整导入命令,交用户在终端执行**
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# 例:bitwarden 条目
|
|
59
|
+
senv add -n "github token" --provider bitwarden --item "GitHub" --alias gh-token --desc "GitHub PAT"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
命令的密钥值输入是隐藏的、不经过上下文,执行完登记即完成。转交时只需要给命令 + 一句「在终端运行,按提示输入密钥值」——提醒提示符要的是密钥原值,不是名字或说明文字。
|
|
63
|
+
|
|
64
|
+
**增强分支(仅当宿主满足条件)**:部分宿主能提供表单收值(值同样不进上下文)。先判宿主,再读对应 reference(与本 SKILL.md 同目录的 `references/`):
|
|
65
|
+
|
|
66
|
+
- 若环境存在 `ZCODE_APP_VERSION`(ZCode)→ 读 [references/zcode.md](references/zcode.md):senv-mcp 的 `add_secret` 两问题流程。
|
|
67
|
+
- 若环境存在 `DSH_PROFILE`(DSH)→ 读 [references/dsh.md](references/dsh.md):当前仍走第 2 步兜底;插件版 `add_secret` 落地后按该文档更新。
|
|
68
|
+
- 无法判别宿主或对应 reference 不可用(含 opencode 等其它宿主)→ 直接走第 2 步兜底。
|
|
69
|
+
|
|
70
|
+
只有 reference 明确说明「该工具可用」时才走表单路径;否则一律兜底。**不要自己发明工具参数,也不要让用户把密钥值发给你。**
|
|
71
|
+
|
|
72
|
+
## 常见配方
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# GitHub
|
|
76
|
+
GH_TOKEN=$GH_TOKEN gh pr list
|
|
77
|
+
curl -s -H "Authorization: Bearer {{secret:GH_TOKEN}}" https://api.github.com/user
|
|
78
|
+
|
|
79
|
+
# 数据库
|
|
80
|
+
psql "$DATABASE_URL" -c '\dt'
|
|
81
|
+
|
|
82
|
+
# OpenAI
|
|
83
|
+
OPENAI_API_KEY={{secret:OPENAI_API_KEY}} bun run script.ts
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 失败即信号
|
|
87
|
+
|
|
88
|
+
拦截层报错时按提示修正命令文本(多半是单引号或未配置的名字),**不要尝试绕过拦截层**(如把值写进文件再 cat、从代码里硬编码读取)。ZCode 的报错为 exit 2 + stderr;DSH 的报错为工具错误结果。
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# DSH 分支:问答卡片直接消费(不走 MCP elicitation)
|
|
2
|
+
|
|
3
|
+
适用前提:宿主是 DSH(`DSH_PROFILE` 存在;系统提示里出现 secret-env 已配置清单,即由 DSH host 插件注入)。
|
|
4
|
+
|
|
5
|
+
## 设计
|
|
6
|
+
|
|
7
|
+
DSH 的收值路径是**插件内部调用问答卡片**,不是 MCP:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
模型 → DSH 插件工具 add_secret(只传元数据)
|
|
11
|
+
→ 插件 ctx.userQuestions.ask() 弹卡片(用户输入密钥值)
|
|
12
|
+
→ 答案回到插件代码,程序化消费(写 keychain / Bitwarden / viking + 登记 secrets.json)
|
|
13
|
+
→ 工具结果只返回 { status, envName, uri }(脱敏,值不进模型上下文)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **不用 MCP elicitation**:DSH 的 MCP 客户端不声明 elicitation 能力;`senv-mcp` 的 `add_secret` 在 DSH 不可用。
|
|
17
|
+
- **即使将来经 MCP**:也不会用「提供选项」的 elicitation,而是由程序调用另一个 MCP 工具直接消费输入值。
|
|
18
|
+
- 卡片是无掩码文本输入:值在屏幕上可见,但**不进入模型上下文**;工具结果永远脱敏。
|
|
19
|
+
|
|
20
|
+
## 工具 `add_secret`(已实现)
|
|
21
|
+
|
|
22
|
+
参数(全部元数据,由模型在第 1 步问答后传入):
|
|
23
|
+
|
|
24
|
+
| 参数 | 必填 | 说明 |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `name` | 是 | 人读名(归一化为全大写下划线环境变量名) |
|
|
27
|
+
| `provider` | 否 | keychain / bitwarden / viking / onepassword(默认 keychain) |
|
|
28
|
+
| `detail` | 否 | bitwarden/onepassword 条目名[/字段];viking=类目/目标[/字段];keychain 留空自动命名 |
|
|
29
|
+
| `alias` | 否 | 别名,逗号分隔 |
|
|
30
|
+
| `description` | 否 | 描述(会进模型上下文,不得含敏感信息) |
|
|
31
|
+
|
|
32
|
+
执行:名称归一化 + 重名校验(不合法直接返回 `invalid`,不弹卡)→ `ctx.userQuestions.ask()` 弹收值卡 → 程序化入库。
|
|
33
|
+
|
|
34
|
+
返回(永不含值):
|
|
35
|
+
|
|
36
|
+
- `{ status: "stored", envName, uri }`
|
|
37
|
+
- `{ status: "command-issued", command }`(用户在卡片里选了命令行)
|
|
38
|
+
- `{ status: "declined" }`(用户取消/跳过;不是失败。若用户直接关闭卡片面板,宿主可能把整个工具调用标记为 aborted——同样未落库)
|
|
39
|
+
- `{ status: "invalid", message }`(缺名/重名/provider 非法)
|
|
40
|
+
- `{ status: "error", message }`(入库失败等)
|
|
41
|
+
|
|
42
|
+
## 卡片交互
|
|
43
|
+
|
|
44
|
+
**必须填原值**:卡片提交的内容会原样写入凭据库、不经过模型——用户必须直接填密钥本身,不能填名字、描述、占位符或说明文字(否则这些文字会被当成密钥存进去,模型也无法校验或补全)。
|
|
45
|
+
|
|
46
|
+
一张卡,两个输入位置:
|
|
47
|
+
|
|
48
|
+
1. **显式选项**「我希望通过命令行来完成输入」——选中即返回 `command-issued`,给用户在终端执行的命令(隐藏输入);
|
|
49
|
+
2. 卡片自动附加的**自由输入行**——用户在这里填密钥值,提交后直接入库。
|
|
50
|
+
|
|
51
|
+
判断规则(单选:`custom` 覆盖 `selected`):
|
|
52
|
+
|
|
53
|
+
- `custom` 非空 → 消费该值,入库,返回 `stored`
|
|
54
|
+
- `custom` 为空且 `selected` 含命令行选项 → 返回命令
|
|
55
|
+
- 两者都空 → `declined`
|
|
56
|
+
|
|
57
|
+
这对应 ZCode 版第 2 屏形态裁断:ZCode 先试 password 掩码表单、被拒后降级到「命令 / 明文表单」选择屏;DSH 没有密码掩码形态,用「选项 + 自定义输入」一张卡完成同样的分流。
|
|
58
|
+
|
|
59
|
+
## 兜底
|
|
60
|
+
|
|
61
|
+
若 `add_secret` 工具不可用(旧版插件/安装异常),登记回到 SKILL.md 的默认路径:问完元数据后构造完整 `senv add ...` 命令交用户在终端执行。
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# 密钥库接入指南(面向用户/操作者)
|
|
2
|
+
|
|
3
|
+
各 provider 的准备工作与添加凭据条目的步骤。配置登记统一在 `~/.config/secret-env/secrets.json`(周知名 → {uri, aliases?, description?},文件里只有名字和 uri,无真值)。推荐用 `secret-env add` 交互登记(值经隐藏输入,不经过 Agent 上下文)。
|
|
4
|
+
|
|
5
|
+
## URI 一般化寻址(2026-11-04 定案)
|
|
6
|
+
|
|
7
|
+
所有 provider 统一 `scheme://item[/field]`:
|
|
8
|
+
|
|
9
|
+
- **一级寻址(缺省)= 主密钥**:keychain → password;bitwarden → password;viking → 条目主值
|
|
10
|
+
- **二级寻址 = 次级字段**:
|
|
11
|
+
- `keychain://<service>/account` — keychain account 属性(同 service 多 account 条目存在时报歧义错,不静默取第一条)
|
|
12
|
+
- `viking://<category>/<target>/<field>` — privacy 条目字段(既有能力)
|
|
13
|
+
- `bitwarden://<item>/username`、`/totp`、`/password` — 保留键,走 bw get 子命令;其余字段名按自定义字段在条目 `fields[]` 里按 name 匹配(保留键优先,撞名改字段名消歧)
|
|
14
|
+
- 字段段 URI 内不允许 `/` 与空格;bw 字段名含空格时建议改用无空格命名
|
|
15
|
+
- 账户信息(username/account/成对凭证的 id 侧)是 agent 真实需求(构造 DSN/`-u` 参数/成对 API 凭证),次级寻址为此而设
|
|
16
|
+
|
|
17
|
+
## keychain://(macOS 系统密钥库,本机最简)
|
|
18
|
+
|
|
19
|
+
准备:无需安装,macOS 自带 `security` 命令。
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
secret-env add -n "github token" --provider system # 隐藏输入收值 → 钥匙串 → 登记
|
|
23
|
+
secret-env add -n "aws creds" --provider system --user "AKIA..." # 可选:account 属性存指定值(缺省 = 当前系统用户)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
更新值:同命令重跑覆盖(`--provider system` 对已存在条目强制重新收值;不带则进维护模式只改别名/描述)。
|
|
27
|
+
|
|
28
|
+
## viking://(OpenViking privacy 层,跨设备)
|
|
29
|
+
|
|
30
|
+
准备:`ov` CLI 已配置(`~/.openviking/ovcli.conf`)且服务可达。
|
|
31
|
+
|
|
32
|
+
写侧自治:`add` 收值后经 `ov privacy upsert` 写入(值走 0600 临时文件,不落 argv),再登记映射:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
secret-env add -n openai --provider viking --path credential/openai/api_key # 收值 → 写 privacy 字段 → 登记
|
|
36
|
+
secret-env add -n openai --provider viking --path credential/openai # 无 field 段:主值落 value 字段
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
更新值:同命令重跑覆盖,或直接在 ov 侧更新(secrets.json 不动)。
|
|
40
|
+
|
|
41
|
+
## bitwarden://(Bitwarden CLI,跨设备/团队共享)
|
|
42
|
+
|
|
43
|
+
准备(一次性):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm i -g @bitwarden/cli # 或 brew install bitwarden-cli
|
|
47
|
+
bw login # 登录(自建服务器先 bw config server <url>)
|
|
48
|
+
bw unlock # 解锁,末尾输出 export BW_SESSION="..."
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
登记 BW_SESSION(两级解析的基础设施条目,session key 存钥匙串):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
security add-generic-password -U -s bw-session -a "$USER" -w
|
|
55
|
+
# 交互提示时粘贴 unlock 输出里引号内的 session key(不带引号)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
secrets.json 中登记 `"BW_SESSION": { "uri": "keychain://bw-session" }`(读写任何 bitwarden:// 条目时自动经配置链解析并注入 bw 子进程环境;其 uri 不可为 bitwarden://)。
|
|
59
|
+
|
|
60
|
+
写侧自治:`add` 收值后经 bw 写入 vault(JSON 走 stdin,不落 argv),再登记映射:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
secret-env add -n myvar --provider bitwarden --item "条目名" # 收值 → password 位(条目不存在则新建 login 条目)
|
|
64
|
+
secret-env add -n myvar.key --provider bitwarden --item "条目名" --field "api key" # 收值 → 自定义字段(条目须已存在)
|
|
65
|
+
secret-env add -n myvar --provider bitwarden --item "条目名" --user "someone" # 顺路写 username 位
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
要点:
|
|
69
|
+
|
|
70
|
+
- 写的落位与读镜像:password 位 ↔ 一级寻址;`--field`/username ↔ 二级寻址;`--field` 不隐式建条目
|
|
71
|
+
- 条目名需唯一;重名条目用 id(`bw get item <名> | jq -r .id` 查,界面里 Web Vault 地址栏 `itemId=` 可见)
|
|
72
|
+
- 名字含空格/中文无需转义(字段名含空格不建议用于 URI)
|
|
73
|
+
- **session key 生命周期**:每次 `bw unlock` 换新值 → 重跑上面的 `security add-generic-password` 覆盖;过期后 bitwarden 凭据读写报 bw 原始错误(locked/not unlocked 字样即此因)
|
|
74
|
+
|
|
75
|
+
## onepassword://(1Password CLI,跨设备)
|
|
76
|
+
|
|
77
|
+
> ⚠️ 本 provider 尚未真机验证(实现于无 op CLI 的机器),首次使用如有报错以 op 原始错误为准。
|
|
78
|
+
|
|
79
|
+
准备(一次性):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
brew install 1password-cli
|
|
83
|
+
op signin # 或开启 1Password 桌面 App 的 biometric 集成后免 signin
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
可选:登记 OP_SESSION(与 BW_SESSION 同构的基础设施条目):
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
security add-generic-password -U -s op-session -a "$USER" -w # 粘贴 op signin --raw 输出的 session token
|
|
90
|
+
# secrets.json 登记 "OP_SESSION": { "uri": "keychain://op-session" }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
(不登记则依赖 biometric 或宿主环境的 OP_SESSION_<shorthand> 变量。)
|
|
94
|
+
|
|
95
|
+
写侧自治(与 bitwarden 镜像):
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
secret-env add -n myvar --provider onepassword --item "条目名" # 收值 → password 位(不存在则新建)
|
|
99
|
+
secret-env add -n myvar.key --provider onepassword --item "条目名" --field "api key" # 收值 → 自定义字段
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
要点:一级 = password;`/username` 与自定义字段 label 可二级寻址;totp 暂不支持。
|
|
103
|
+
|
|
104
|
+
## 验证
|
|
105
|
+
|
|
106
|
+
配置后在新命令里引用(值不出现在输出):
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
printf '%s' "$MY_VAR" | wc -c # 长度非 0 即注入成功
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
清单核对:`secret-env list`(显示名字与 uri,无真值)。
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# ZCode 分支:senv-mcp 的 add_secret(表单收值)
|
|
2
|
+
|
|
3
|
+
适用前提:宿主是 ZCode(`ZCODE_APP_VERSION` 存在),且工具列表里有 `add_secret`(senv-mcp,由 ZCode 插件壳 `.zcode-plugin/plugin.json` 的 `mcpServers` 声明)。工具不可用时回到 SKILL.md 的默认兜底(构造 `senv add` 命令交终端)。
|
|
4
|
+
|
|
5
|
+
## 流程
|
|
6
|
+
|
|
7
|
+
**第 1 步:元数据问答**——同 SKILL.md 的「第 1 步」,用 ZCode 的 `AskUserQuestion` 一次问清 key / desc / provider 三题,解析成定稿(name + 别名 / 描述 / provider / 条目定位)。
|
|
8
|
+
|
|
9
|
+
**第 2 步:调用 `add_secret`** 传定稿入参(`name` 必填;`provider` / `detail` / `alias` / `description` 可选)。
|
|
10
|
+
|
|
11
|
+
工具随后弹收值卡:
|
|
12
|
+
|
|
13
|
+
- 输入框收密钥(输入时可见,但**不进入模型上下文**);
|
|
14
|
+
- 用户若选「手工通过命令行输入」或取消,返回里带现成 `command`——转交用户在终端执行即可。**`declined` 不是失败**,是用户选了终端通道;
|
|
15
|
+
- 返回 `status:"invalid"` 时按 `message` 里的原因(缺名 / 重名 / provider 非法)回到第 1 步重问;
|
|
16
|
+
- `status:"stored"` 即收尾。
|
|
17
|
+
|
|
18
|
+
## 要点
|
|
19
|
+
|
|
20
|
+
- 密钥值永远不要作为参数传给 `add_secret`,也不要让用户发给你。
|
|
21
|
+
- 工具结果永远脱敏(`stored` 只带 `envName` / `uri`;`command-issued` 只带命令)。
|
|
22
|
+
- ZCode 支持 MCP elicitation;DSH 不支持——DSH 见 [dsh.md](dsh.md),不要在对 DSH 的对话里调用不存在的 `add_secret`。
|