dsh-any-agent 0.5.0 → 0.5.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/src/ids.mjs ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 会话 id / run id 的路径安全校验。
3
+ *
4
+ * 这两个 id 会直接参与 `<state>/runs/<conversation_id>/<run_id>.json` 与
5
+ * `<state>/reviews/<conversation_id>/<run_id>.review.json` 的路径拼接。
6
+ * DSH 生成的是 UUID / `run-<base36>-<n>`,但 MCP 调用方可以传任意字符串;
7
+ * 带 `/`、`\`、`:` 或 `..` 的 id 一旦进入 join(),就能读写状态目录之外的文件。
8
+ *
9
+ * 规则刻意保守:单段、字母数字开头、只允许 `._-`,最长 200。合法的 DSH
10
+ * UUID 和本服务生成的 run_id 都满足;不满足的 id 在触盘之前就拒绝。
11
+ */
12
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
13
+
14
+ export function assertSafeId(value, label = 'id') {
15
+ if (typeof value !== 'string' || value === '.' || value === '..' || !SAFE_ID.test(value)) {
16
+ throw new Error(
17
+ `${label} 非法:只允许字母/数字开头、由字母/数字/._- 组成的单段 id(最长 200): ${JSON.stringify(value)}`,
18
+ );
19
+ }
20
+ return value;
21
+ }
package/src/mcp.mjs CHANGED
@@ -47,11 +47,16 @@ DSH 自己有文件读写、shell、搜索、技能、子代理等全套工具
47
47
  3. 之后按需:
48
48
  dsh_status(conversation_id) 还在跑吗?跑到哪了?写锁在谁手上?
49
49
  dsh_read(conversation_id, cursor) 中途看它当前吐出的内容(增量)
50
- dsh_get(conversation_id, run_id) 验收:取那一次派活的完整结果
51
- 4. dsh_interject / dsh_interrupt 跑偏了就插话改口 / 直接打断
50
+ dsh_get(conversation_id, run_id) 验收:取那一次派活的完整结果 + 审核单
51
+ 4. **dsh_review(conversation_id, run_id, verdict, notes?)** 调用方审核裁决。
52
+ 结果里的 review 字段是审核单(核验清单 + 证据 + review_id)。在把结果当作完成之前,
53
+ 先用你自己的工具独立核验(读改动的文件、看 git diff、跑相关测试),再提交
54
+ verdict=pass / partial / fail。默认是**建议式**:不阻塞后续派活,也不会自动回灌给 DSH;
55
+ 但未审核的结果不应直接转述成"已完成"。fail/partial 必须写 notes 说明理由。
56
+ 5. dsh_interject / dsh_interrupt 跑偏了就插话改口 / 直接打断
52
57
  (会话空闲时 interject 等价于 wait=true,会阻塞到回合结束)
53
- 5. dsh_release 交还会话(释放写锁,之后仍可 resume)
54
- 6. dsh_takeover 写锁被别人占着时的**抢占**(见下)
58
+ 6. dsh_release 交还会话(释放写锁,之后仍可 resume)
59
+ 7. dsh_takeover 写锁被别人占着时的**抢占**(见下)
55
60
 
56
61
  【写锁:一个会话同一时刻只能有一个写者】
57
62
  DSH 的会话目录有跨进程写锁,**持有者活着就永不过期,且没有 API 能直接夺走**。所以:
@@ -75,7 +80,11 @@ dsh_interject —— 一个回合 = 一个哨兵,没有例外)。dsh_send
75
80
  你再读文件即得结果。示例(Bash;直接用返回里的 sentinel_file_posix,别手工转换路径):
76
81
  sent="$sentinel_file_posix"
77
82
  dl=$(( $(date +%s)+2100 )); until [ -f "$sent" ]; do [ $(date +%s) -ge $dl ] && { echo TIMEOUT; exit 1; }; sleep 2; done; echo DONE
78
- 读文件即可拿到 status 和 result;**不受内存窗口限制、也扛本服务重启**。文件里 status=error 表示失败。
83
+ 宿主是 Windows、只有 PowerShell(或 Git Bash 不可用)时,用原生路径 sentinel_file:
84
+ $f='<sentinel_file>'; $dl=(Get-Date).AddSeconds(2100); while(-not (Test-Path -LiteralPath $f)){ if((Get-Date) -gt $dl){ 'TIMEOUT'; exit 1 }; Start-Sleep -Seconds 2 }; 'DONE'
85
+ (路径里有单引号时写成两个单引号;后台收据会直接给出拼好的这一行。)
86
+ 读文件即可拿到 status 和 result(result.review 是审核单);**不受内存窗口限制、也扛本服务重启**。文件里 status=error 表示失败。
87
+ 拿到结果后先按审核单独立核验,再调用 dsh_review 提交裁决;审核状态会回写进哨兵。
79
88
  - 想阻塞拿结果 → 传 **wait=true**;即使等超时也不会丢结果:返回会变成收据(still_running + sentinel_file),
80
89
  回合继续跑,结果照常由哨兵送达。
81
90
  - 临时查一下 → dsh_get(conversation_id, run_id) / dsh_status / dsh_read。
@@ -229,8 +238,10 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
229
238
  case 'tools/call': {
230
239
  const name = params?.name;
231
240
  const args = params?.arguments ?? {};
232
- const fn = handlers[name];
233
- if (!fn) {
241
+ // 只认 handlers 自己的属性:`constructor` / `toString` / `__proto__` 之类的
242
+ // 原型链名字不能被当成工具调用。
243
+ const fn = Object.hasOwn(handlers, name) ? handlers[name] : null;
244
+ if (typeof fn !== 'function') {
234
245
  ok(id, { content: [{ type: 'text', text: `未知工具: ${name}` }], isError: true });
235
246
  return;
236
247
  }
package/src/redact.mjs ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 凭证脱敏。
3
+ *
4
+ * 审批上下文、哨兵文件和调用方审核记录都会落盘保留一段时间,
5
+ * 里面可能夹带完整命令、URL 里的密码、`api_key=…` 之类的字面量。
6
+ * 这里只做一件事:把常见凭证形状替换成 `[REDACTED]`,保持其余内容可读、可审核。
7
+ *
8
+ * 注意:这是**形状匹配**,不是数据分类器。它挡的是常见误写,不是"绝对安全"。
9
+ */
10
+
11
+ const SECRET_KEY =
12
+ /^(?:api[_-]?key|authorization|proxy[_-]?authorization|cookie|set[_-]?cookie|password|passwd|secret|token|access[_-]?token|refresh[_-]?token|credentials?|headers|env|environment)$/i;
13
+
14
+ export function sanitizeApprovalContext(value, key = '') {
15
+ if (SECRET_KEY.test(key)) return '[REDACTED]';
16
+ if (typeof value === 'string') {
17
+ return value
18
+ .replace(/\b(?:sk|ak)-[A-Za-z0-9_-]{8,}/g, '[REDACTED]')
19
+ .replace(/\bBearer\s+[^\s'";]+/gi, 'Bearer [REDACTED]')
20
+ .replace(
21
+ /((?:api[_-]?key|password|passwd|secret|(?:access[_-]?)?token)\s*(?:=|:)\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s;]+)/gi,
22
+ '$1[REDACTED]',
23
+ )
24
+ .replace(/(--(?:api[_-]?key|password|passwd|secret|token)\s+)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s;]+)/gi, '$1[REDACTED]')
25
+ .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED]@');
26
+ }
27
+ if (Array.isArray(value)) return value.map((item) => sanitizeApprovalContext(item));
28
+ if (value && typeof value === 'object') {
29
+ return Object.fromEntries(
30
+ Object.entries(value).map(([name, item]) => [name, sanitizeApprovalContext(item, name)]),
31
+ );
32
+ }
33
+ return value ?? null;
34
+ }
package/src/review.mjs ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * 调用方审核(caller review)。
3
+ *
4
+ * 背景:dsh-any-agent 返回的是**另一个 agent 的自述**("我改好了 / 测试通过了")。
5
+ * 调用方(Claude Code、Codex、Cursor…)最常见的失败模式是把它直接转述给用户,
6
+ * 而不做任何独立核验。这个模块把"审核"变成返回载荷的一部分:
7
+ *
8
+ * - 每个回合结束时生成一张审核单(review ticket):核验清单 + 证据摘要 + dsh_review 入口;
9
+ * - 审核单随结果正文、structuredContent、异步哨兵一起返回;
10
+ * - 调用方用 dsh_review 提交 pass / partial / fail,裁决落盘并回写到内存 run 与哨兵。
11
+ *
12
+ * 设计边界(刻意保持简单、可预测):
13
+ * - 默认 advisory:审核单很显眼,但**不阻塞**后续 dsh_send;
14
+ * - 只记录裁决,**不自动**把审核意见回灌给 DSH(是否重派由调用方决定);
15
+ * - 审核单不含思考正文;落盘前套用与审批上下文相同的凭证脱敏。
16
+ */
17
+
18
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { DEFAULT_REVIEW, REVIEWS_DIR, REVIEW_MODES } from './config.mjs';
21
+ import { sanitizeApprovalContext } from './redact.mjs';
22
+ import { assertSafeId } from './ids.mjs';
23
+
24
+ /** dsh_review 允许的裁决。 */
25
+ export const REVIEW_VERDICTS = ['pass', 'partial', 'fail'];
26
+
27
+ const MAX_NOTES = 8_000;
28
+ const MAX_FINDING = 2_000;
29
+ const MAX_FINDINGS = 50;
30
+ const MAX_PROMPT_PREVIEW = 200;
31
+
32
+ /** 把来自参数/环境变量的审核模式收敛到受支持的值;非法值回落到 fallback。 */
33
+ export function normalizeReviewMode(value, fallback = DEFAULT_REVIEW) {
34
+ if (value === undefined || value === null || value === '') return fallback;
35
+ if (value === true) return 'advisory';
36
+ if (value === false) return 'off';
37
+ const v = String(value).trim().toLowerCase();
38
+ if (v === '0' || v === 'false' || v === 'off' || v === 'no') return 'off';
39
+ if (v === '1' || v === 'true' || v === 'on' || v === 'advisory' || v === 'yes') return 'advisory';
40
+ return REVIEW_MODES.includes(v) ? v : fallback;
41
+ }
42
+
43
+ export function reviewIdForRun(runId) {
44
+ return `review-${runId}`;
45
+ }
46
+
47
+ /** 审核记录的落盘路径(按会话分目录,与哨兵同样的命名空间思路)。 */
48
+ export function reviewFilePath(conversationId, runId) {
49
+ assertSafeId(conversationId, 'conversation_id');
50
+ assertSafeId(runId, 'run_id');
51
+ return join(REVIEWS_DIR, conversationId, `${runId}.review.json`);
52
+ }
53
+
54
+ function toolNames(result) {
55
+ return [...new Set((result?.tools_used ?? []).map((t) => t?.name ?? t?.title).filter(Boolean))];
56
+ }
57
+
58
+ /**
59
+ * 生成审核清单。清单是**通用的**,因为服务端不知道调用方能用哪些工具;
60
+ * 但它会随结果形状变化(有没有改动、有没有错误、用了哪些工具)。
61
+ */
62
+ export function buildReviewChecklist({ result = {}, permission = null } = {}) {
63
+ const tools = toolNames(result);
64
+ const items = [
65
+ '逐条对照原任务:答复是真正完成了要求,还是只描述计划 / 只完成了一部分?',
66
+ '不要只复述 DSH 的答复:至少做一项独立核验(读改动的文件、查看 git diff、运行相关测试或命令)。',
67
+ ];
68
+ if (tools.length) {
69
+ items.push(
70
+ `核对工具执行结果:本轮用到 [${tools.join(', ')}],确认关键命令/写入的退出状态与产出符合预期。`,
71
+ );
72
+ }
73
+ if (result.workspace_changed) {
74
+ const n = result.diff_stat?.changed_files;
75
+ items.push(
76
+ `检查改动范围:工作区发生了变化${Number.isFinite(n) ? `(当前有 ${n} 个文件有改动)` : ''},确认都在预期内,没有越界修改。`,
77
+ );
78
+ } else if (tools.length) {
79
+ items.push('如果 DSH 声称做了修改,确认工作区确实发生了变化(workspace_changed=false 可能意味着没有真正落地)。');
80
+ }
81
+ if (result.error || (result.stop_reason && result.stop_reason !== 'end_turn')) {
82
+ items.push(
83
+ `回合没有正常结束(stop_reason=${result.stop_reason ?? 'null'}${
84
+ result.error ? `;error=${String(result.error).slice(0, 160)}` : ''
85
+ }),确认影响并决定是否重派。`,
86
+ );
87
+ }
88
+ const autoTotal = Number(result.auto_approvals_total) || 0;
89
+ if (autoTotal > 0) {
90
+ const esc = Number(result.auto_escalations_total) || 0;
91
+ items.push(
92
+ `本轮有 ${autoTotal} 次审批被自动放行(on_approval=auto-allow)` +
93
+ (esc > 0 ? `,其中 ${esc} 次申请了沙箱提权(如 danger-full-access),这些操作已在 ${permission ?? '权限档'} 之外执行` : '') +
94
+ ':对照 result.auto_approvals 逐条确认是否在预期内;需要逐次把关就用 on_approval=ask 新建会话。',
95
+ );
96
+ }
97
+ if (permission) items.push(`确认操作没有超出会话权限档(${permission})。`);
98
+ items.push('裁决:pass(可直接采用)/ partial(部分可用,需补充)/ fail(不可用,需重做);fail/partial 必须在 notes 里说明理由。');
99
+ return items;
100
+ }
101
+
102
+ /**
103
+ * 构造审核单。回合结束时带完整 result;wait=false 的收据里带 pending 版本
104
+ * (此时还不知道结果,只提示"跑完后要审核")。
105
+ */
106
+ export function buildReviewTicket({
107
+ conversationId,
108
+ runId,
109
+ promptPreview = null,
110
+ result = null,
111
+ mode = 'advisory',
112
+ permission = null,
113
+ pending = false,
114
+ } = {}) {
115
+ const evidence = {
116
+ stop_reason: result?.stop_reason ?? null,
117
+ error: result?.error ? String(result.error).slice(0, 500) : null,
118
+ workspace_changed: result?.workspace_changed ?? null,
119
+ changed_files: result?.diff_stat?.changed_files ?? null,
120
+ tools: toolNames(result),
121
+ auto_approvals: Number.isFinite(result?.auto_approvals_total) ? result.auto_approvals_total : null,
122
+ auto_escalations: Number.isFinite(result?.auto_escalations_total) ? result.auto_escalations_total : null,
123
+ elapsed_ms: result?.elapsed_ms ?? null,
124
+ };
125
+ return {
126
+ review_id: reviewIdForRun(runId),
127
+ mode,
128
+ status: 'pending',
129
+ verdict: null,
130
+ run_id: runId,
131
+ conversation_id: conversationId,
132
+ prompt_preview: promptPreview ? String(promptPreview).slice(0, MAX_PROMPT_PREVIEW) : null,
133
+ checklist: buildReviewChecklist({ result: result ?? {}, permission }),
134
+ evidence,
135
+ submit: {
136
+ tool: 'dsh_review',
137
+ arguments: {
138
+ conversation_id: conversationId,
139
+ run_id: runId,
140
+ verdict: 'pass|partial|fail',
141
+ notes: '审核理由',
142
+ },
143
+ },
144
+ instructions: pending
145
+ ? '回合还在后台跑。等哨兵文件出现、拿到结果后,先按清单独立核验,再调用 dsh_review 提交裁决。'
146
+ : '在把这次结果当作完成之前,先按清单独立核验,再调用 dsh_review 提交裁决。建议式审核:不阻塞后续 dsh_send,但未审核的结果不应直接当作完成。',
147
+ };
148
+ }
149
+
150
+ function clampText(value, max) {
151
+ if (value === undefined || value === null) return null;
152
+ const s = String(value);
153
+ return s.length > max ? `${s.slice(0, max)}…` : s;
154
+ }
155
+
156
+ function cleanFindings(findings) {
157
+ if (!Array.isArray(findings)) return [];
158
+ return findings
159
+ .slice(0, MAX_FINDINGS)
160
+ .map((f) => clampText(f, MAX_FINDING))
161
+ .filter((f) => typeof f === 'string' && f.trim() !== '');
162
+ }
163
+
164
+ /**
165
+ * 写审核记录(原子落盘)。
166
+ *
167
+ * 失败会抛出:审核本身就是这次操作的目的,静默失败比报错更糟 ——
168
+ * 调用方会以为裁决已经记录,而磁盘上什么都没有。
169
+ */
170
+ export function writeReviewRecord({ ticket, verdict, notes, findings, reviewedAt }) {
171
+ const file = reviewFilePath(ticket.conversation_id, ticket.run_id);
172
+ const payload = {
173
+ version: 1,
174
+ review_id: ticket.review_id ?? reviewIdForRun(ticket.run_id),
175
+ conversation_id: ticket.conversation_id,
176
+ run_id: ticket.run_id,
177
+ mode: ticket.mode ?? 'advisory',
178
+ verdict,
179
+ status: verdict,
180
+ notes: sanitizeApprovalContext(clampText(notes, MAX_NOTES), 'notes'),
181
+ findings: sanitizeApprovalContext(cleanFindings(findings)),
182
+ reviewed_at: reviewedAt,
183
+ review_file: file,
184
+ prompt_preview: sanitizeApprovalContext(ticket.prompt_preview ?? null, 'prompt_preview'),
185
+ // 清单里可能拼进 result.error(例如 `error=... token=sk-...`),落盘前必须逐项脱敏。
186
+ // 哨兵走的是 sanitizeResultForSentinel,这里若漏掉,审核记录会成为第二个明文泄漏点。
187
+ checklist: Array.isArray(ticket.checklist)
188
+ ? ticket.checklist.map((item) => sanitizeApprovalContext(item))
189
+ : [],
190
+ evidence: sanitizeApprovalContext(ticket.evidence ?? null),
191
+ };
192
+ try {
193
+ mkdirSync(dirname(file), { recursive: true });
194
+ const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
195
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
196
+ renameSync(tmp, file);
197
+ } catch (e) {
198
+ throw new Error(`写审核记录失败(${file}): ${e.message}`);
199
+ }
200
+ return { file, payload };
201
+ }
202
+
203
+ /** 读已有审核记录;不存在/损坏时返回 null(由调用方决定是否继续)。 */
204
+ export function readReviewRecord(conversationId, runId) {
205
+ try {
206
+ return JSON.parse(readFileSync(reviewFilePath(conversationId, runId), 'utf8'));
207
+ } catch {
208
+ return null;
209
+ }
210
+ }