@foperate/secret-env-dsh 0.0.0-stage → 0.4.0
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 +112 -0
- package/cordis.patch.yml +4 -0
- package/entry.mjs +57 -0
- package/guards.mjs +82 -0
- package/icon.svg +6 -0
- package/index.js +115 -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 +76 -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,112 @@
|
|
|
1
|
+
// DSH 适配器的 secret-env CLI 协议客户端(protocol 1):只经子进程与 CLI 交互,不 import 主包代码。
|
|
2
|
+
// 契约:stdin 一行 JSON,stdout 一行 JSON;rsenv 侧退出码 0 成功 / 2 业务拒绝。
|
|
3
|
+
// 值不经过本插件:parse 只回 {action, token?},消费由改写命令里的 rsenv inject 完成(eval 链)。
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
export const PROTOCOL = 1;
|
|
9
|
+
|
|
10
|
+
export class CliError extends Error {
|
|
11
|
+
constructor(message, { code = "cli", exit } = {}) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "CliError";
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.exit = exit;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MAX_STDERR = 200;
|
|
20
|
+
|
|
21
|
+
// 统一解析:非 JSON / 非零退出都转成 CliError(宁可失败,不静默放行)
|
|
22
|
+
function toJson(out, err, code, cmd) {
|
|
23
|
+
const text = (out ?? "").trim();
|
|
24
|
+
let json = null;
|
|
25
|
+
try { json = text ? JSON.parse(text) : null; } catch { json = null; }
|
|
26
|
+
if (json === null) {
|
|
27
|
+
throw new CliError(`rsenv ${cmd} 返回非 JSON(exit=${code})${err ? `:${err.trim().slice(0, MAX_STDERR)}` : ""}`, { code: "bad-output", exit: code });
|
|
28
|
+
}
|
|
29
|
+
if (code !== 0) throw new CliError(json.error ?? `rsenv ${cmd} 失败(exit=${code})`, { code: json.code ?? "cli", exit: code });
|
|
30
|
+
return json;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 二进制解析链(HOST-PROTOCOL §解析):SENV_BIN 显式指定 > secrets.json $backend 键 > 默认 senv。
|
|
34
|
+
function configuredBackend() {
|
|
35
|
+
try {
|
|
36
|
+
const cfg = JSON.parse(readFileSync(join(process.env.HOME ?? "", ".config/secret-env", "secrets.json"), "utf8"));
|
|
37
|
+
return cfg?.$backend === "rsenv" ? "rsenv" : null; // 其余值一律视为未配置(senv 是缺省)
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveBin() {
|
|
42
|
+
if (process.env.SENV_BIN) return process.env.SENV_BIN;
|
|
43
|
+
const order = configuredBackend() === "rsenv" ? ["rsenv", "secret-env"] : ["secret-env", "rsenv"];
|
|
44
|
+
for (const name of order) {
|
|
45
|
+
for (const dir of (process.env.PATH ?? "").split(":")) {
|
|
46
|
+
if (!dir) continue;
|
|
47
|
+
try { if (existsSync(`${dir}/${name}`)) return `${dir}/${name}`; } catch {}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return order[0]; // 双缺省兜底:后续 spawn ENOENT 走报错路径
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createClient({ bin = resolveBin(), timeoutMs = 8000 } = {}) {
|
|
54
|
+
const hint = `请安装 secret-env CLI(senv 或 rsenv)并确认 ${bin} 在 PATH 上;rsenv 后端还需在其终端运行 rsenv login 解锁密钥库`;
|
|
55
|
+
|
|
56
|
+
function run(cmd, payload, { timeout = timeoutMs } = {}) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
let child;
|
|
59
|
+
try {
|
|
60
|
+
child = spawn(bin, [cmd], { stdio: ["pipe", "pipe", "pipe"] });
|
|
61
|
+
} catch (e) {
|
|
62
|
+
reject(new CliError(`无法启动 ${bin}:${e.message}——${hint}`, { code: "spawn" }));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let out = "", err = "", done = false;
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
done = true;
|
|
68
|
+
child.kill("SIGKILL");
|
|
69
|
+
reject(new CliError(`rsenv ${cmd} 超时(${timeout}ms)`, { code: "timeout" }));
|
|
70
|
+
}, timeout);
|
|
71
|
+
const finish = (fn, value) => {
|
|
72
|
+
if (done) return;
|
|
73
|
+
done = true;
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
fn(value);
|
|
76
|
+
};
|
|
77
|
+
child.on("error", (e) => finish(reject, new CliError(`无法启动 ${bin}(${e.code ?? e.message})——${hint}`, { code: "spawn" })));
|
|
78
|
+
child.stdout.on("data", (d) => { out += d; });
|
|
79
|
+
child.stderr.on("data", (d) => { err += d; });
|
|
80
|
+
child.on("close", (code) => {
|
|
81
|
+
try { finish(resolve, toJson(out, err, code, cmd)); }
|
|
82
|
+
catch (e) { finish(reject, e); }
|
|
83
|
+
});
|
|
84
|
+
child.stdin.end(payload === null ? undefined : JSON.stringify(payload));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 同步版只给 info 用:DSH 的 guard 是同步回调,插件启动时必须有快照才能判定。
|
|
89
|
+
function runSync(cmd) {
|
|
90
|
+
const r = spawnSync(bin, [cmd], { encoding: "utf8", timeout: timeoutMs });
|
|
91
|
+
if (r.error) throw new CliError(`无法启动 ${bin}(${r.error.code ?? r.error.message})——${hint}`, { code: "spawn" });
|
|
92
|
+
return toJson(r.stdout ?? "", r.stderr ?? "", r.status ?? -1, cmd);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
bin,
|
|
97
|
+
info: () => run("info", null),
|
|
98
|
+
infoSync: () => runSync("info"),
|
|
99
|
+
// 命令门(钩子时刻):stdin {command} → {action: pass|inject|reject, token?, names?, reason?}
|
|
100
|
+
// 注意:parse 的 reject 以 JSON action 表达(退出码仍 0),业务拒绝在 index.js 判定。
|
|
101
|
+
parse: (payload) => run("parse", payload),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 版本握手:插件只认自己实现的协议版本(rsenv info 的 protocol 字段)
|
|
106
|
+
export function requireProtocol(info) {
|
|
107
|
+
const got = info?.protocol;
|
|
108
|
+
if (got !== PROTOCOL) {
|
|
109
|
+
throw new CliError(`rsenv 协议版本不匹配:插件需要 ${PROTOCOL},CLI 返回 ${got ?? "无"}(请把 rsenv 与插件升到匹配版本)`, { code: "protocol" });
|
|
110
|
+
}
|
|
111
|
+
return info;
|
|
112
|
+
}
|
package/cordis.patch.yml
ADDED
package/entry.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// DSH 登记条目的纯逻辑:消息/选项文案与答案解释。
|
|
2
|
+
// 条目构造与终端兜底命令本地构造(senv set 形态);值通道=宿主问答卡片或用户终端,不经模型。
|
|
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 set 命令,在终端执行时隐藏输入密钥值。",
|
|
25
|
+
missingName: "缺少 name(归一化后为空)",
|
|
26
|
+
badProvider: `provider 只能是 ${PROVIDERS.join(" / ")}`,
|
|
27
|
+
exists: "已存在同名条目,请换名后重调(维护已有条目请在终端用 senv set 不带 --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 set 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 set 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,82 @@
|
|
|
1
|
+
// DSH 守卫判定(纯函数,供单测直接调用):文件通道 + 无注入通道的拒绝;不做密钥解析。
|
|
2
|
+
// 规则数据来自 CLI 的 info 快照(guardPaths)+ 本地 scanRefs 副本(同步判定,不 spawn 子进程)。
|
|
3
|
+
// bash 的命令文本守卫由 CLI 的 parse 统一执行(三端同语义,2026-11-07)——这里只管
|
|
4
|
+
// 文件工具(同步)与 pwsh/terminal_send(没有注入通道,必须在本地拦)。
|
|
5
|
+
// 与 ZCode 的 guardPaths 同源:配置/密钥库文件对 agent 一律只读不可触(具体清单来自 info 快照,按后端而异)。
|
|
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}」)——密钥库/审计/daemon 文件含全部凭据映射与审计记录,禁止 Agent 读写。配置维护请在你的终端用 CLI 的 set/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;宿主外执行可用可选 rsenv 后端的 run 命令。`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 等价 ZCode SessionStart 的清单注入:已配置名字与用法约定(无真值)。
|
|
68
|
+
export function promptText(snapshot) {
|
|
69
|
+
const entries = snapshot?.entries ?? [];
|
|
70
|
+
if (entries.length === 0) {
|
|
71
|
+
return "secret-env:当前没有已配置的密钥条目。命令中不要写密钥明文;需要时请用户在终端用 CLI 的 set 命令配置。";
|
|
72
|
+
}
|
|
73
|
+
return [
|
|
74
|
+
"secret-env:已配置的密钥(值永远不会出现在上下文;在 bash 命令文本里显式引用即可注入子进程环境):",
|
|
75
|
+
...entries.map(
|
|
76
|
+
({ name, desc }) => `- ${name}${desc ? `:${desc}` : ""}`,
|
|
77
|
+
),
|
|
78
|
+
'用法:写 $NAME 或 ${NAME}(双引号内),例如 curl -H "Authorization: Bearer $GH_TOKEN" ...;隐式读取环境变量的工具写成 NAME=$NAME cmd。',
|
|
79
|
+
"禁止:单引号包引用(拒绝)、echo/打印凭据、同一命令里出现 env/printenv/裸 set/export。",
|
|
80
|
+
"terminal_send 与 pwsh 通道不注入密钥,需要凭据请用 bash。禁止读写受保护文件(清单见 info 守卫快照:配置/密钥库/审计/套接字)。",
|
|
81
|
+
].join("\n");
|
|
82
|
+
}
|
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,115 @@
|
|
|
1
|
+
// secret-env DSH 真身适配器(协议客户端,HOST-PROTOCOL 契约;后端由解析链决定 senv/rsenv):
|
|
2
|
+
// 1) tools/execute 包装 bash:调 CLI `parse`(命令文本守卫 + 取值 + 签发一次性 token),
|
|
3
|
+
// inject 回复 → 改写为 eval 链 `_senv_inj="$(<bin> inject <token>)" && eval ... && { 原命令; }`(HOST-PROTOCOL §注入链);
|
|
4
|
+
// 值不出 daemon(token 一次性消费),不进上下文;
|
|
5
|
+
// 2) tools.guard 同步守卫:本地规则副本 + info 快照(受保护路径、名字清单),不 spawn 子进程;
|
|
6
|
+
// 3) systemPrompt 注入已配置名字清单与用法约定(来自 info 快照)。
|
|
7
|
+
// 挂账:add_secret 工具未恢复——问答卡片收值 + `senv set` stdin 入库的流程待单独一轮实现与验证。
|
|
8
|
+
// 不 import 主包代码:只经 CLI 协议(client.mjs)。
|
|
9
|
+
// 运行时中立:node only,不用 Bun 专属 API。
|
|
10
|
+
// 注:工具定义直接构造 JSON Schema 交给 ctx.tools.register,不 import defineTool(插件经 pnpm link
|
|
11
|
+
// 从仓库/包路径加载时,Node 按真实路径解析不到 profile 的 @deepseek-ai/dsh-tools)。
|
|
12
|
+
import { appendFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { evaluateCall, promptText } from "./guards.mjs";
|
|
16
|
+
import { PROTOCOL, createClient, requireProtocol } from "./client.mjs";
|
|
17
|
+
|
|
18
|
+
const LOG = join(tmpdir(), "secret-env-dsh.log");
|
|
19
|
+
const log = (msg) => { try { appendFileSync(LOG, `[${new Date().toISOString()}] ${msg}\n`); } catch {} };
|
|
20
|
+
|
|
21
|
+
const REFRESH_MS = 30 * 1000; // 快照过期时间:guard 每次调用顺手触发后台刷新(非阻塞)
|
|
22
|
+
|
|
23
|
+
// rsenv info(协议)→ 本地快照:config 供同步 scanRefs 用,entries 供清单文案,guardPaths 供文件守卫
|
|
24
|
+
export function buildSnapshot(info) {
|
|
25
|
+
const config = new Map();
|
|
26
|
+
for (const entry of info?.entries ?? []) {
|
|
27
|
+
config.set(entry.name, { canonical: entry.name, description: entry.desc });
|
|
28
|
+
}
|
|
29
|
+
return { entries: info?.entries ?? [], guardPaths: info?.guard_paths ?? [], config };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const inject = ["tools", "systemPrompt"];
|
|
33
|
+
|
|
34
|
+
// options.client 仅供离线测试注入假客户端;生产不传,走 createClient()(解析链:SENV_BIN > $backend > senv)
|
|
35
|
+
export function apply(ctx, options = {}) {
|
|
36
|
+
const client = options.client ?? createClient();
|
|
37
|
+
const disposers = [];
|
|
38
|
+
|
|
39
|
+
// 启动即同步拉快照:guard 是同步回调,没有快照就无法判定。失败 = 插件不加载(宁可失败,不静默放行)。
|
|
40
|
+
let info;
|
|
41
|
+
try {
|
|
42
|
+
info = requireProtocol(client.infoSync());
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new Error(`secret-env 无法初始化:${error.message}`);
|
|
45
|
+
}
|
|
46
|
+
let snapshot = buildSnapshot(info);
|
|
47
|
+
let lastRefresh = Date.now();
|
|
48
|
+
let refreshing = false;
|
|
49
|
+
|
|
50
|
+
async function refresh() {
|
|
51
|
+
if (refreshing) return;
|
|
52
|
+
refreshing = true;
|
|
53
|
+
try {
|
|
54
|
+
const next = requireProtocol(await client.info());
|
|
55
|
+
info = next;
|
|
56
|
+
snapshot = buildSnapshot(next);
|
|
57
|
+
lastRefresh = Date.now();
|
|
58
|
+
log(`snapshot refresh entries=${snapshot.entries.length}`);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
log(`snapshot refresh failed: ${error.message}`);
|
|
61
|
+
} finally {
|
|
62
|
+
refreshing = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const maybeRefresh = () => { if (Date.now() - lastRefresh > REFRESH_MS) void refresh(); };
|
|
66
|
+
|
|
67
|
+
// 守卫在 pre-execute / approval 之后、dispatch 之前同步执行;返回理由即拒绝。
|
|
68
|
+
disposers.push(ctx.tools.guard((exec) => {
|
|
69
|
+
maybeRefresh();
|
|
70
|
+
const reason = evaluateCall(exec, snapshot);
|
|
71
|
+
if (reason !== undefined) log(`deny tool=${exec?.name} reason=${reason.slice(0, 160)}`);
|
|
72
|
+
return reason;
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
// 清单 + 约定:等价 ZCode SessionStart 注入;配置变化在下次 assembly 反映。
|
|
76
|
+
disposers.push(ctx.systemPrompt.section({
|
|
77
|
+
name: "secret-env",
|
|
78
|
+
order: 900,
|
|
79
|
+
interpolate: false,
|
|
80
|
+
text: () => promptText(snapshot),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
// bash 命令门:一律经 CLI 的 parse(三端同语义,2026-11-07)——
|
|
84
|
+
// 未配置条目也要走一次,否则受保护路径的命令会漏拦。
|
|
85
|
+
// exec.arguments 的替换依赖 dispatchToolBody 现场读取同一 exec 对象。
|
|
86
|
+
// DSH 官方契约只承诺 wrappers 可改 exec.signal;这是既有探针验证过的实现细节,DSH 收紧时需改显式 srun/rsenv run。
|
|
87
|
+
disposers.push(ctx.on("tools/execute", async function (exec, next) {
|
|
88
|
+
if (exec?.name !== "bash") return next();
|
|
89
|
+
const command = exec?.arguments?.command;
|
|
90
|
+
if (typeof command !== "string" || !command) return next();
|
|
91
|
+
|
|
92
|
+
let result;
|
|
93
|
+
try {
|
|
94
|
+
result = await client.parse({ command });
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw new Error(`secret-env:${error.message}`); // 业务拒绝与 CLI 故障都报错(宁可失败)
|
|
97
|
+
}
|
|
98
|
+
if (result?.action === "reject") {
|
|
99
|
+
throw new Error(`secret-env:${result.reason ?? "command rejected by guard"}`);
|
|
100
|
+
}
|
|
101
|
+
if (result?.action !== "inject" || !result.token) return next(); // pass / 空判定 = 零注入
|
|
102
|
+
|
|
103
|
+
// 一次性 token 经 eval 链消费;&& 短路保 fail-closed(token 已消费/GC/daemon 重启 → 原命令不执行)
|
|
104
|
+
const rewritten = `_senv_inj="$(${client.bin} inject ${result.token})" && eval "$_senv_inj" && { ${command}; }`;
|
|
105
|
+
exec.arguments = { ...exec.arguments, command: rewritten };
|
|
106
|
+
log(`parse → token injection names=${(result.names ?? []).join(",")}`);
|
|
107
|
+
void refresh(); // 解析成功可能伴随配置变化(极少),顺手刷新
|
|
108
|
+
return next();
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
log(`applied protocol=${PROTOCOL} entries=${snapshot.entries.length}`);
|
|
112
|
+
return () => {
|
|
113
|
+
for (const dispose of disposers) dispose?.();
|
|
114
|
+
};
|
|
115
|
+
}
|
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.4.0",
|
|
4
|
+
"description": "rsenv 的 DeepSeek Harness(DSH)bundle:bash 命令门(rsenv parse + 一次性 token eval 链)、文件/通道守卫、密钥清单。运行时经 PATH 上的 rsenv 走协议(protocol 1),本包零依赖。add_secret 挂账(等 rsenv 元数据入库子命令)。",
|
|
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,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: secret-env
|
|
3
|
+
description: 凭据引用与注入——密钥值永远不进模型上下文;命令守卫与一次性 token 注入链、按 backend 分轨的命令面与恢复指令
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# secret-env(rsenv / senv)使用约定
|
|
7
|
+
|
|
8
|
+
本机用的是两个同协议实现之一,插件按 `rsenv info` 的 `backend` 字段区分,**行为差异只看 backend,不猜命令名**:
|
|
9
|
+
|
|
10
|
+
- **rsenv**:Rust 单二进制,turso 加密库,daemon 承载加密库与生命周期(login/logout)
|
|
11
|
+
- **senv**:bun/node CLI,keepassxc + secrets.json 后端,轻量 daemon 只做 token 内存槽(按需拉起,无 login)
|
|
12
|
+
|
|
13
|
+
## 核心不变量
|
|
14
|
+
|
|
15
|
+
- **你(agent)永远接触不到凭据明文**。命令文本里引用 `$NAME`,宿主钩子调 `<CLI> parse` 守卫并签发一次性 token,改写成注入链后值经 `eval` 进入子进程环境——不进 argv、不进你的上下文。
|
|
16
|
+
- 密码/口令类输入**一律让用户在终端完成**(`rsenv login`/`rsenv init`、`senv set` 的隐藏输入都是交互式的),不要试图替用户输入或经对话转述口令。
|
|
17
|
+
|
|
18
|
+
## 注入机制(parse / inject 两段,两端同构)
|
|
19
|
+
|
|
20
|
+
- **parse**(钩子时刻,插件壳调用):命令守卫(文件提及/单引号/倾泻黑名单)+ 解析全部值 → **一次性 token**。值只存在于 daemon 内存(两端同构),你经手的只有 token。
|
|
21
|
+
- **inject**:`<CLI> inject <token>` 消费槽位输出 `export NAME='…'` 行,由同 shell 的 `eval` 消费。改写形态(两端逐字一致):`_senv_inj="$($SENV_BIN inject <token>)" && eval "$_senv_inj" && { 原命令; }`——token 已消费/失效则短路,原命令不执行(fail-closed)。**token 一次性,重放不可能;不要自己拼这个链,宿主钩子会做**。
|
|
22
|
+
- 值只在子进程 env 内存活,不跨命令持久。
|
|
23
|
+
|
|
24
|
+
## 常用命令(按 backend 分轨)
|
|
25
|
+
|
|
26
|
+
**rsenv(backend=rsenv):**
|
|
27
|
+
|
|
28
|
+
| 命令 | 说明 |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `rsenv list` | 条目清单(名字+描述,**无值**) |
|
|
31
|
+
| `rsenv get NAME` | 终端取值打印(用户自查用。**agent 不要调用**——会把明文带进上下文) |
|
|
32
|
+
| `rsenv set NAME`(值经 stdin) | 入库 native 条目;`--desc` 加描述 |
|
|
33
|
+
| `rsenv set NAME --uri provider://路径` | 入库 provider 引用条目,无需 stdin |
|
|
34
|
+
| `rsenv remove NAME` / `rsenv import-secrets [路径]` | 删除 / 旧 secrets.json 迁移 |
|
|
35
|
+
| `rsenv run NAME... -- cmd args` | 具名注入执行器(手动场景) |
|
|
36
|
+
| `rsenv passwd` | 换口令(终端交互,整库闲置重加密) |
|
|
37
|
+
| `rsenv info` / `rsenv mcp` | 守卫快照 / stdio MCP 管理面(无值通道) |
|
|
38
|
+
|
|
39
|
+
**senv(backend=senv):**
|
|
40
|
+
|
|
41
|
+
| 命令 | 说明 |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `senv list` | 条目清单(名字+描述,**无值**) |
|
|
44
|
+
| `senv set NAME --provider keychain`(值经 stdin 或 TTY 隐藏输入) | 入库;`--provider viking/bitwarden/onepassword` 各带 --path/--item |
|
|
45
|
+
| `senv set NAME --uri provider://路径` | 登记 provider 引用条目 |
|
|
46
|
+
| `senv remove NAME` | 删除条目(`--purge` 支持 keychain) |
|
|
47
|
+
| `senv info` / `senv mcp` | 守卫快照 / MCP 管理面 |
|
|
48
|
+
|
|
49
|
+
## daemon(两端形态不同)
|
|
50
|
+
|
|
51
|
+
**rsenv**:daemon 承载加密库——只由 `rsenv login` 拉起(用户终端、隐藏输入口令;幂等)。**login 请在完整用户 shell 里执行**(provider 工具环境继承 login 终端的 PATH)。CLI 不会自行拉起 daemon:不在时取值命令直接失败,错误信息含恢复指令。`rsenv logout`:清 key 与槽位、停 daemon。`rsenv init`:仅首次建库。
|
|
52
|
+
|
|
53
|
+
**senv**:daemon 只做 token 内存槽,`parse` 按需自动拉起(有 socket 直接用,崩了自动重拉),**无需 login、无需管理**;agent 无感知,不必向用户提它。
|
|
54
|
+
|
|
55
|
+
## agent 行为约定(与 CLI 错误信息一一对应)
|
|
56
|
+
|
|
57
|
+
CLI 报 `vault is locked: ask the user to run "rsenv login" in their terminal` →
|
|
58
|
+
告知用户:在终端跑 `rsenv login`,输 vault 口令,然后重试命令。**不要自己跑 login**(口令必须由用户输入,你输了也没有 vault 口令)。
|
|
59
|
+
|
|
60
|
+
CLI 报 `vault is not initialized: ask the user to run "rsenv init" in their terminal` →
|
|
61
|
+
告知用户:首次使用需 `rsenv init` 建库设口令(终端交互,口令两次确认)。
|
|
62
|
+
|
|
63
|
+
CLI 报解析失败并列出「现有条目」→ 用清单里的名字重试,或让用户在终端用 `set` 登记缺失条目(agent 不经手值)。
|
|
64
|
+
|
|
65
|
+
## 守卫行为(为什么某些命令被拒)
|
|
66
|
+
|
|
67
|
+
- 单引号内引用 `$NAME` → 整条拒绝(完整性无法保证),改用双引号或不引用。
|
|
68
|
+
- 命令含引用且命令位置有 `printenv`/`set`/`export`(带引用参数)/裸 `env` → 倾泻拒绝,换目标文件输出等具体消费形态。
|
|
69
|
+
- 命令文本涉及受保护文件(info 的 guard_paths:rsenv=vault/audit/sock;senv=secrets.json/vault.kdbx/senv.sock)→ 拒绝,配置维护请在你的终端用 `set/remove`。
|
|
70
|
+
|
|
71
|
+
## 安全边界(对 agent 的说明)
|
|
72
|
+
|
|
73
|
+
- 本工具保证的是**数据流**:值不进命令文本/argv、不进你的上下文与日志(两端值都只存 daemon 内存,不落盘)。
|
|
74
|
+
- 它**不**限制"你如何使用密钥":用在哪条命令、发往哪个端点、用多少次。这属于纪律与凭据权限问题,不在本工具的可强制范围。
|
|
75
|
+
- 以上守卫都是**防误触**的文本级规则,不是安全边界:**不要试图用脚本嵌套、编码、二次解析等方式绕过守卫**——即使技术上可行,也属于越权使用,用户会发现。
|
|
76
|
+
- 若某个任务需要"限制能力",正确做法是让用户改用**短时/最小权限凭据**或按用途拆分的条目,而不是在命令里变通。
|
|
@@ -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`。
|