@foperate/secret-env-dsh 0.3.2 → 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/client.mjs CHANGED
@@ -1,7 +1,9 @@
1
- // DSH 适配器的 CLI 协议客户端(protocol 1):只经子进程与主包交互,不 import 主包代码。
2
- // 契约:stdin 一行 JSON,stdout 一行 JSON;退出码 0 成功 / 2 业务拒绝(JSON 带 code)/ 3 协议不匹配。
3
- // 值只经 stdin 进入,响应不含值。协议见 docs/PLAN-DSH-DECOUPLING.md。
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
4
  import { spawn, spawnSync } from "node:child_process";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
5
7
 
6
8
  export const PROTOCOL = 1;
7
9
 
@@ -22,14 +24,34 @@ function toJson(out, err, code, cmd) {
22
24
  let json = null;
23
25
  try { json = text ? JSON.parse(text) : null; } catch { json = null; }
24
26
  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 });
27
+ throw new CliError(`rsenv ${cmd} 返回非 JSON(exit=${code})${err ? `:${err.trim().slice(0, MAX_STDERR)}` : ""}`, { code: "bad-output", exit: code });
26
28
  }
27
- if (code !== 0) throw new CliError(json.error ?? `secret-env ${cmd} 失败(exit=${code})`, { code: json.code ?? "cli", exit: code });
29
+ if (code !== 0) throw new CliError(json.error ?? `rsenv ${cmd} 失败(exit=${code})`, { code: json.code ?? "cli", exit: code });
28
30
  return json;
29
31
  }
30
32
 
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
+ // 二进制解析链(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 解锁密钥库`;
33
55
 
34
56
  function run(cmd, payload, { timeout = timeoutMs } = {}) {
35
57
  return new Promise((resolve, reject) => {
@@ -44,7 +66,7 @@ export function createClient({ bin = process.env.SENV_BIN || "secret-env", timeo
44
66
  const timer = setTimeout(() => {
45
67
  done = true;
46
68
  child.kill("SIGKILL");
47
- reject(new CliError(`secret-env ${cmd} 超时(${timeout}ms)`, { code: "timeout" }));
69
+ reject(new CliError(`rsenv ${cmd} 超时(${timeout}ms)`, { code: "timeout" }));
48
70
  }, timeout);
49
71
  const finish = (fn, value) => {
50
72
  if (done) return;
@@ -59,7 +81,7 @@ export function createClient({ bin = process.env.SENV_BIN || "secret-env", timeo
59
81
  try { finish(resolve, toJson(out, err, code, cmd)); }
60
82
  catch (e) { finish(reject, e); }
61
83
  });
62
- child.stdin.end(payload === null ? undefined : JSON.stringify({ protocol: PROTOCOL, ...payload }));
84
+ child.stdin.end(payload === null ? undefined : JSON.stringify(payload));
63
85
  });
64
86
  }
65
87
 
@@ -74,16 +96,17 @@ export function createClient({ bin = process.env.SENV_BIN || "secret-env", timeo
74
96
  bin,
75
97
  info: () => run("info", null),
76
98
  infoSync: () => runSync("info"),
77
- inject: (payload) => run("inject", payload),
78
- store: (payload) => run("store", payload),
99
+ // 命令门(钩子时刻):stdin {command} → {action: pass|inject|reject, token?, names?, reason?}
100
+ // 注意:parse 的 reject 以 JSON action 表达(退出码仍 0),业务拒绝在 index.js 判定。
101
+ parse: (payload) => run("parse", payload),
79
102
  };
80
103
  }
81
104
 
82
- // 版本握手:插件只认自己实现的协议版本
105
+ // 版本握手:插件只认自己实现的协议版本(rsenv info 的 protocol 字段)
83
106
  export function requireProtocol(info) {
84
107
  const got = info?.protocol;
85
108
  if (got !== PROTOCOL) {
86
- throw new CliError(`secret-env 协议版本不匹配:插件需要 ${PROTOCOL},CLI 返回 ${got ?? "无"}(请把主包与插件升到匹配版本)`, { code: "protocol" });
109
+ throw new CliError(`rsenv 协议版本不匹配:插件需要 ${PROTOCOL},CLI 返回 ${got ?? "无"}(请把 rsenv 与插件升到匹配版本)`, { code: "protocol" });
87
110
  }
88
111
  return info;
89
112
  }
package/entry.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // DSH 登记条目的纯逻辑:消息/选项文案与答案解释。
2
- // 条目构造与终端兜底命令已下沉到 CLI 协议(store 的 ready / command 形态),本文件不 import 主包代码。
2
+ // 条目构造与终端兜底命令本地构造(senv set 形态);值通道=宿主问答卡片或用户终端,不经模型。
3
3
  // PROVIDERS 只用于文案与工具 schema 兜底:权威列表来自 info.providers(协议)。
4
4
  export const PROVIDERS = ["keychain", "bitwarden", "viking", "onepassword"];
5
5
 
@@ -21,10 +21,10 @@ export const T = {
21
21
  cardQuestion:
22
22
  "secret-env 新条目登记:请选择密钥值录入方式。\n\n注意:这里提交的内容会原样写入凭据库、不经过模型,因此必须直接填写密钥本身——不要填名字、描述、占位符或「我的 token」之类的说明(模型无法校验或补全)。\n\n· 直接填写:在下方输入框填写密钥值——值明文显示在卡片中(介意旁观/录屏请改选命令行),提交后直接写入凭据库,不会进入模型上下文。\n· 命令行:选择「我希望通过命令行来完成输入」,我会给你一条在终端执行的命令(隐藏输入)。",
23
23
  commandLabel: "我希望通过命令行来完成输入",
24
- commandDescription: "随后给出一条 senv add 命令,在终端执行时隐藏输入密钥值。",
24
+ commandDescription: "随后给出一条 senv set 命令,在终端执行时隐藏输入密钥值。",
25
25
  missingName: "缺少 name(归一化后为空)",
26
26
  badProvider: `provider 只能是 ${PROVIDERS.join(" / ")}`,
27
- exists: "已存在同名条目,请换名后重调(维护已有条目请在终端用 senv add 不带 --provider)",
27
+ exists: "已存在同名条目,请换名后重调(维护已有条目请在终端用 senv set 不带 --provider)",
28
28
  noAgent: "无法定位调用方 agent,不能弹问答卡片",
29
29
  },
30
30
  en: {
@@ -39,10 +39,10 @@ export const T = {
39
39
  cardQuestion:
40
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
41
  commandLabel: "I'll enter it via the command line",
42
- commandDescription: "Returns a senv add command that hides the input in your terminal.",
42
+ commandDescription: "Returns a senv set command that hides the input in your terminal.",
43
43
  missingName: "Missing name (empty after normalization)",
44
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)",
45
+ exists: "An entry with this name already exists — pick another name (to maintain an existing entry run senv set without --provider)",
46
46
  noAgent: "Cannot resolve the calling agent; the question card is unavailable",
47
47
  },
48
48
  }[lang];
package/guards.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // DSH 守卫判定(纯函数,供单测直接调用):文件通道 + 无注入通道的拒绝;不做密钥解析。
2
2
  // 规则数据来自 CLI 的 info 快照(guardPaths)+ 本地 scanRefs 副本(同步判定,不 spawn 子进程)。
3
- // bash 的命令文本守卫由 CLI 的 inject 统一执行(三端同语义,2026-11-06)——这里只管
3
+ // bash 的命令文本守卫由 CLI 的 parse 统一执行(三端同语义,2026-11-07)——这里只管
4
4
  // 文件工具(同步)与 pwsh/terminal_send(没有注入通道,必须在本地拦)。
5
- // 与 ZCode 的 guardPaths 同源:secrets.json(含 master 口令明文)/ vault.kdbx 对 agent 一律只读不可触。
5
+ // 与 ZCode 的 guardPaths 同源:配置/密钥库文件对 agent 一律只读不可触(具体清单来自 info 快照,按后端而异)。
6
6
  import { matchGuardPaths, scanRefs } from "./rules.mjs";
7
7
 
8
8
  const FILE_TOOLS = ["read", "read_image", "write", "edit"];
@@ -12,7 +12,7 @@ const COMMAND_CHANNELS = new Map([
12
12
  ]);
13
13
 
14
14
  const protectedReason = (fragment) =>
15
- `secret-env:操作涉及受保护文件(片段「${fragment}」)——secrets.json / vault.kdbx 含 master 口令明文与全部凭据映射,禁止 Agent 读写。配置维护请在终端运行 secret-env add/remove,或亲手编辑。`;
15
+ `secret-env:操作涉及受保护文件(片段「${fragment}」)——密钥库/审计/daemon 文件含全部凭据映射与审计记录,禁止 Agent 读写。配置维护请在你的终端用 CLI 的 set/remove,或亲手操作。`;
16
16
 
17
17
  // 返回拒绝理由(字符串)或 undefined(放行)。exec 形态见 DSH ToolDispatchExecution:{ name, arguments, callId, agent, signal }。
18
18
  // snapshot = { entries, guardPaths, config },由 info 协议构建(见 index.js buildSnapshot)。
@@ -60,24 +60,23 @@ export function evaluateCall(exec, snapshot) {
60
60
  }
61
61
  }
62
62
  if (tokens.length > 0) {
63
- return `secret-env:${name} 通道不注入密钥(只支持 bash 工具)。需要凭据请改用 bash,或在终端用 srun/senv 执行。`;
63
+ return `secret-env:${name} 通道不注入密钥(只支持 bash 工具)。需要凭据请改用 bash;宿主外执行可用可选 rsenv 后端的 run 命令。`;
64
64
  }
65
65
  }
66
66
 
67
- // 等价 ZCode SessionStart 的 `secret-env list --inject`:注入已配置名字清单与用法约定(无真值)。
67
+ // 等价 ZCode SessionStart 的清单注入:已配置名字与用法约定(无真值)。
68
68
  export function promptText(snapshot) {
69
69
  const entries = snapshot?.entries ?? [];
70
70
  if (entries.length === 0) {
71
- return "secret-env:当前没有已配置的密钥条目。命令中不要写密钥明文;需要时请用户在终端运行 secret-env add 配置。";
71
+ return "secret-env:当前没有已配置的密钥条目。命令中不要写密钥明文;需要时请用户在终端用 CLI 的 set 命令配置。";
72
72
  }
73
73
  return [
74
74
  "secret-env:已配置的密钥(值永远不会出现在上下文;在 bash 命令文本里显式引用即可注入子进程环境):",
75
75
  ...entries.map(
76
- ({ name, aliases = [], description }) =>
77
- `- ${name}${aliases.length ? `(别名:${aliases.join("、")})` : ""}${description ? `:${description}` : ""}`,
76
+ ({ name, desc }) => `- ${name}${desc ? `:${desc}` : ""}`,
78
77
  ),
79
78
  '用法:写 $NAME 或 ${NAME}(双引号内),例如 curl -H "Authorization: Bearer $GH_TOKEN" ...;隐式读取环境变量的工具写成 NAME=$NAME cmd。',
80
79
  "禁止:单引号包引用(拒绝)、echo/打印凭据、同一命令里出现 env/printenv/裸 set/export。",
81
- "terminal_send 与 pwsh 通道不注入密钥,需要凭据请用 bash。禁止读写 ~/.config/secret-env 与 vault.kdbx。",
80
+ "terminal_send 与 pwsh 通道不注入密钥,需要凭据请用 bash。禁止读写受保护文件(清单见 info 守卫快照:配置/密钥库/审计/套接字)。",
82
81
  ].join("\n");
83
82
  }
package/index.js CHANGED
@@ -1,16 +1,17 @@
1
- // secret-env DSH 真身适配器(协议客户端形态,2026-11-06 起):
2
- // 1) tools/execute 包装 bash:调 CLI `inject`(命令文本守卫 + 注入后的命令;值只在子进程里写 env 文件,不进上下文);
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 一次性消费),不进上下文;
3
5
  // 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。
6
+ // 3) systemPrompt 注入已配置名字清单与用法约定(来自 info 快照)。
7
+ // 挂账:add_secret 工具未恢复——问答卡片收值 + `senv set` stdin 入库的流程待单独一轮实现与验证。
8
+ // 不 import 主包代码:只经 CLI 协议(client.mjs)。
9
+ // 运行时中立:node only,不用 Bun 专属 API。
8
10
  // 注:工具定义直接构造 JSON Schema 交给 ctx.tools.register,不 import defineTool(插件经 pnpm link
9
11
  // 从仓库/包路径加载时,Node 按真实路径解析不到 profile 的 @deepseek-ai/dsh-tools)。
10
12
  import { appendFileSync } from "node:fs";
11
13
  import { tmpdir } from "node:os";
12
14
  import { join } from "node:path";
13
- import { T, PROVIDERS, interpretAnswer } from "./entry.mjs";
14
15
  import { evaluateCall, promptText } from "./guards.mjs";
15
16
  import { PROTOCOL, createClient, requireProtocol } from "./client.mjs";
16
17
 
@@ -19,27 +20,18 @@ const log = (msg) => { try { appendFileSync(LOG, `[${new Date().toISOString()}]
19
20
 
20
21
  const REFRESH_MS = 30 * 1000; // 快照过期时间:guard 每次调用顺手触发后台刷新(非阻塞)
21
22
 
22
- // info(协议)→ 本地快照:config 供同步 scanRefs 用,entries 供清单文案,guardPaths 供文件守卫
23
+ // rsenv info(协议)→ 本地快照:config 供同步 scanRefs 用,entries 供清单文案,guardPaths 供文件守卫
23
24
  export function buildSnapshot(info) {
24
25
  const config = new Map();
25
26
  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 });
27
+ config.set(entry.name, { canonical: entry.name, description: entry.desc });
28
28
  }
29
- return { entries: info?.entries ?? [], guardPaths: info?.rules?.guardPaths ?? [], config };
29
+ return { entries: info?.entries ?? [], guardPaths: info?.guard_paths ?? [], config };
30
30
  }
31
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"];
32
+ export const inject = ["tools", "systemPrompt"];
41
33
 
42
- // options.client 仅供离线测试注入假客户端;生产不传,走 createClient()($SENV_BIN → PATH secret-env)
34
+ // options.client 仅供离线测试注入假客户端;生产不传,走 createClient()(解析链:SENV_BIN > $backend > senv)
43
35
  export function apply(ctx, options = {}) {
44
36
  const client = options.client ?? createClient();
45
37
  const disposers = [];
@@ -88,129 +80,35 @@ export function apply(ctx, options = {}) {
88
80
  text: () => promptText(snapshot),
89
81
  }));
90
82
 
91
- // bash 注入 + 命令文本守卫:一律经 CLI 的 inject(三端同语义,2026-11-06)——
83
+ // bash 命令门:一律经 CLI 的 parse(三端同语义,2026-11-07)——
92
84
  // 未配置条目也要走一次,否则受保护路径的命令会漏拦。
93
85
  // exec.arguments 的替换依赖 dispatchToolBody 现场读取同一 exec 对象。
94
- // DSH 官方契约只承诺 wrappers 可改 exec.signal;这是既有探针验证过的实现细节,DSH 收紧时需改显式 srun/senv。
86
+ // DSH 官方契约只承诺 wrappers 可改 exec.signal;这是既有探针验证过的实现细节,DSH 收紧时需改显式 srun/rsenv run。
95
87
  disposers.push(ctx.on("tools/execute", async function (exec, next) {
96
88
  if (exec?.name !== "bash") return next();
97
89
  const command = exec?.arguments?.command;
98
90
  if (typeof command !== "string" || !command) return next();
99
91
 
100
- const session = String(exec.callId ?? exec.id ?? "dsh");
101
92
  let result;
102
93
  try {
103
- result = await client.inject({ command, session });
94
+ result = await client.parse({ command });
104
95
  } catch (error) {
105
96
  throw new Error(`secret-env:${error.message}`); // 业务拒绝与 CLI 故障都报错(宁可失败)
106
97
  }
107
- if (!result?.envFile) return next(); // 无引用 = 零注入
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 / 空判定 = 零注入
108
102
 
109
- exec.arguments = { ...exec.arguments, command: result.command };
110
- log(`inject via cli key=${session} file=${result.envFile}`);
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(",")}`);
111
107
  void refresh(); // 解析成功可能伴随配置变化(极少),顺手刷新
112
108
  return next();
113
109
  }));
114
110
 
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}`);
111
+ log(`applied protocol=${PROTOCOL} entries=${snapshot.entries.length}`);
214
112
  return () => {
215
113
  for (const dispose of disposers) dispose?.();
216
114
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@foperate/secret-env-dsh",
3
- "version": "0.3.2",
4
- "description": "secret-env 的 DeepSeek Harness(DSH)bundle:bash 注入、文件/通道守卫、密钥清单与 add_secret 工具。运行时经 PATH 上的全局 CLI 走宿主协议(protocol 1),本包零依赖。",
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
5
  "type": "module",
6
6
  "main": "./index.js",
7
7
  "exports": {
@@ -1,88 +1,76 @@
1
1
  ---
2
2
  name: secret-env
3
- description: 密钥注入约定——执行需要凭据的 Bash 命令时如何让 secret-env 插件把凭据送进子进程环境(模型上下文永不见值)。写涉及 GH_TOKEN/API key/DATABASE_URL 等凭据的命令前必读。
3
+ description: 凭据引用与注入——密钥值永远不进模型上下文;命令守卫与一次性 token 注入链、按 backend 分轨的命令面与恢复指令
4
4
  ---
5
5
 
6
- # secret-env:密钥显式引用约定
6
+ # secret-env(rsenv / senv)使用约定
7
7
 
8
- 本项目的密钥从私有库(Keychain / OpenViking / Bitwarden)直接注入 Bash 子进程环境。
9
- **你(模型)从头到尾只写变量名,永远不写、也永远看不到真值。**
10
- 注入使用周知原名(如 `GH_TOKEN`),命令文本零改写——拦截层只在命令前加 source 前缀。
8
+ 本机用的是两个同协议实现之一,插件按 `rsenv info` 的 `backend` 字段区分,**行为差异只看 backend,不猜命令名**:
11
9
 
12
- > 宿主适配:ZCode 的拦截层是 PreToolUse 钩子,DSH 的是 host 插件,opencode 的是 V2 插件(`execute.before` 钩子);三者注入语义一致,下文统一称「拦截层」。
10
+ - **rsenv**:Rust 单二进制,turso 加密库,daemon 承载加密库与生命周期(login/logout)
11
+ - **senv**:bun/node CLI,keepassxc + secrets.json 后端,轻量 daemon 只做 token 内存槽(按需拉起,无 login)
13
12
 
14
- ## 两条注入触发(拦截层只认显式文本)
13
+ ## 核心不变量
15
14
 
16
- 1. **占位符**:命令文本里写 `{{secret:NAME}}`(NAME 必须已配置)
17
- 2. **显式 $VAR**:命令文本里出现已配置的周知变量名
15
+ - **你(agent)永远接触不到凭据明文**。命令文本里引用 `$NAME`,宿主钩子调 `<CLI> parse` 守卫并签发一次性 token,改写成注入链后值经 `eval` 进入子进程环境——不进 argv、不进你的上下文。
16
+ - 密码/口令类输入**一律让用户在终端完成**(`rsenv login`/`rsenv init`、`senv set` 的隐藏输入都是交互式的),不要试图替用户输入或经对话转述口令。
18
17
 
19
- ## 铁律
18
+ ## 注入机制(parse / inject 两段,两端同构)
20
19
 
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` 会把值送进你的上下文(出口防线尚未实现),等于绕过整个机制。
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 内存活,不跨命令持久。
36
23
 
37
- ## 已配置条目
24
+ ## 常用命令(按 backend 分轨)
38
25
 
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:// 凭据解析时自动使用),命令中无需引用它。
26
+ **rsenv(backend=rsenv):**
40
27
 
41
- 用户需要接入新密钥库或添加凭据条目时,按下面的流程走(provider 准备步骤见 [references/providers.md](references/providers.md))。
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 管理面(无值通道) |
42
38
 
43
- ## 登记新凭据:先问答,默认交终端执行
39
+ **senv(backend=senv):**
44
40
 
45
- 登记分两步:**你只负责元数据,密钥值永远不经过你的上下文**。
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 管理面 |
46
48
 
47
- **第 1 步:元数据问答**——用宿主自带的问答工具(ZCode 的 `AskUserQuestion`、DSH 的 `ask_user_question`;都没有时直接在对话里问),一次问清三个字段:
49
+ ## daemon(两端形态不同)
48
50
 
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。
51
+ **rsenv**:daemon 承载加密库——只由 `rsenv login` 拉起(用户终端、隐藏输入口令;幂等)。**login 请在完整用户 shell 里执行**(provider 工具环境继承 login 终端的 PATH)。CLI 不会自行拉起 daemon:不在时取值命令直接失败,错误信息含恢复指令。`rsenv logout`:清 key 与槽位、停 daemon。`rsenv init`:仅首次建库。
52
52
 
53
- 用户选完由**你**把答案解析成定稿:well-known 选项→该名字+已知别名;自由输入→按逗号拆出 name 和别名。
53
+ **senv**:daemon 只做 token 内存槽,`parse` 按需自动拉起(有 socket 直接用,崩了自动重拉),**无需 login、无需管理**;agent 无感知,不必向用户提它。
54
54
 
55
- **第 2 步(默认兜底,所有宿主可用):构造完整导入命令,交用户在终端执行**
55
+ ## agent 行为约定(与 CLI 错误信息一一对应)
56
56
 
57
- ```bash
58
- # 例:bitwarden 条目
59
- senv add -n "github token" --provider bitwarden --item "GitHub" --alias gh-token --desc "GitHub PAT"
60
- ```
57
+ CLI 报 `vault is locked: ask the user to run "rsenv login" in their terminal` →
58
+ 告知用户:在终端跑 `rsenv login`,输 vault 口令,然后重试命令。**不要自己跑 login**(口令必须由用户输入,你输了也没有 vault 口令)。
61
59
 
62
- 命令的密钥值输入是隐藏的、不经过上下文,执行完登记即完成。转交时只需要给命令 + 一句「在终端运行,按提示输入密钥值」——提醒提示符要的是密钥原值,不是名字或说明文字。
60
+ CLI 报 `vault is not initialized: ask the user to run "rsenv init" in their terminal` →
61
+ 告知用户:首次使用需 `rsenv init` 建库设口令(终端交互,口令两次确认)。
63
62
 
64
- **增强分支(仅当宿主满足条件)**:部分宿主能提供表单收值(值同样不进上下文)。先判宿主,再读对应 reference(与本 SKILL.md 同目录的 `references/`):
63
+ CLI 报解析失败并列出「现有条目」→ 用清单里的名字重试,或让用户在终端用 `set` 登记缺失条目(agent 不经手值)。
65
64
 
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 步兜底。
65
+ ## 守卫行为(为什么某些命令被拒)
69
66
 
70
- 只有 reference 明确说明「该工具可用」时才走表单路径;否则一律兜底。**不要自己发明工具参数,也不要让用户把密钥值发给你。**
67
+ - 单引号内引用 `$NAME` → 整条拒绝(完整性无法保证),改用双引号或不引用。
68
+ - 命令含引用且命令位置有 `printenv`/`set`/`export`(带引用参数)/裸 `env` → 倾泻拒绝,换目标文件输出等具体消费形态。
69
+ - 命令文本涉及受保护文件(info 的 guard_paths:rsenv=vault/audit/sock;senv=secrets.json/vault.kdbx/senv.sock)→ 拒绝,配置维护请在你的终端用 `set/remove`。
71
70
 
72
- ## 常见配方
71
+ ## 安全边界(对 agent 的说明)
73
72
 
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 的报错为工具错误结果。
73
+ - 本工具保证的是**数据流**:值不进命令文本/argv、不进你的上下文与日志(两端值都只存 daemon 内存,不落盘)。
74
+ - 它**不**限制"你如何使用密钥":用在哪条命令、发往哪个端点、用多少次。这属于纪律与凭据权限问题,不在本工具的可强制范围。
75
+ - 以上守卫都是**防误触**的文本级规则,不是安全边界:**不要试图用脚本嵌套、编码、二次解析等方式绕过守卫**——即使技术上可行,也属于越权使用,用户会发现。
76
+ - 若某个任务需要"限制能力",正确做法是让用户改用**短时/最小权限凭据**或按用途拆分的条目,而不是在命令里变通。
@@ -13,7 +13,7 @@ DSH 的收值路径是**插件内部调用问答卡片**,不是 MCP:
13
13
  → 工具结果只返回 { status, envName, uri }(脱敏,值不进模型上下文)
14
14
  ```
15
15
 
16
- - **不用 MCP elicitation**:DSH 的 MCP 客户端不声明 elicitation 能力;`senv-mcp` 的 `add_secret` 在 DSH 不可用。
16
+ - **不用 MCP elicitation**:DSH 的 MCP 客户端不声明 elicitation 能力;`senv mcp` 的 `add_secret` 在 DSH 不可用。
17
17
  - **即使将来经 MCP**:也不会用「提供选项」的 elicitation,而是由程序调用另一个 MCP 工具直接消费输入值。
18
18
  - 卡片是无掩码文本输入:值在屏幕上可见,但**不进入模型上下文**;工具结果永远脱敏。
19
19
 
@@ -1,6 +1,6 @@
1
- # ZCode 分支:senv-mcp 的 add_secret(表单收值)
1
+ # ZCode 分支:`senv mcp` 的 add_secret(表单收值)
2
2
 
3
- 适用前提:宿主是 ZCode(`ZCODE_APP_VERSION` 存在),且工具列表里有 `add_secret`(senv-mcp,由 ZCode 插件壳 `.zcode-plugin/plugin.json` 的 `mcpServers` 声明)。工具不可用时回到 SKILL.md 的默认兜底(构造 `senv add` 命令交终端)。
3
+ 适用前提:宿主是 ZCode(`ZCODE_APP_VERSION` 存在),且工具列表里有 `add_secret`(`senv mcp`,由 ZCode 插件壳 `.zcode-plugin/plugin.json` 的 `mcpServers` 声明)。工具不可用时回到 SKILL.md 的默认兜底(构造 `senv add` 命令交终端)。
4
4
 
5
5
  ## 流程
6
6