@dnalec/dsh-auto-approve 0.1.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/src/rules.mjs ADDED
@@ -0,0 +1,900 @@
1
+ /**
2
+ * 判定管道纯函数(无 I/O)。
3
+ *
4
+ * 关键词只匹配工具名 + command + 路径 + workdir,不匹配 justification、description、文件正文。
5
+ * 允许桶不匹配工具名,避免把 bash/write 整类放行。
6
+ * 审核模型只归类;动作以本表为准。`other` 必须存在,解析失败视为 other。
7
+ * allowlist.version 只增不改历史语义,用 prevVersion < N 做一次性迁移。
8
+ * 解析失败抛错,由调用方转人工;不要把失败当成 other(用户可能把 other 改成 allow)。
9
+ */
10
+
11
+ export const DEFAULT_DENY_KEYWORDS = [
12
+ 'rm -rf', 'rm -fr', 'rm -r -f', 'rm --recursive --force', 'sudo rm',
13
+ 'Remove-Item -Recurse -Force', 'Remove-Item -Force -Recurse',
14
+ 'git push --force', 'git push -f', 'push --force',
15
+ 'drop table', 'drop database', 'drop schema', 'delete from', 'truncate table',
16
+ 'mkfs', 'dd of=', 'Format-Volume', 'Clear-Disk',
17
+ 'chmod 777 /', 'chmod -R 777',
18
+ 'terraform destroy', 'docker system prune',
19
+ 'shutdown -h', 'shutdown now',
20
+ 'systemctl poweroff', 'systemctl reboot', 'systemctl halt',
21
+ 'Stop-Computer', 'Restart-Computer',
22
+ ]
23
+
24
+ /** 旧预置:误伤太大,或只对理由/路径有意义。升级时从拒绝桶拿掉。 */
25
+ export const RETIRED_DEFAULT_KEYWORDS = [
26
+ '格式化', 'docker rm', 'truncate ',
27
+ '清空数据库', '删除数据库', 'force-push', 'force push',
28
+ 'git reset --hard', 'git clean -fd', 'rsync --delete', 'mkfs.ext',
29
+ 'shutdown', 'reboot',
30
+ ]
31
+
32
+ /** 预置词默认进拒绝桶。 */
33
+ export const DEFAULT_REJECT_KEYWORDS = DEFAULT_DENY_KEYWORDS
34
+
35
+ /** 改自动审批配置的路径兜底,避免只靠模型选 approval-config。 */
36
+ export const DEFAULT_APPROVAL_CONFIG_KEYWORDS = [
37
+ 'auto-approve/allowlist',
38
+ 'approval-bridge/config.json',
39
+ 'approval-bridge/qqbot.json',
40
+ '.dsh/auto-approve',
41
+ '.dsh/approval-bridge',
42
+ ]
43
+
44
+ /** 常见凭据路径。关键词层兜底,不把 .env / 私钥只交给模型。 */
45
+ export const DEFAULT_SECRET_PATH_KEYWORDS = [
46
+ '.env',
47
+ 'id_rsa',
48
+ 'id_ed25519',
49
+ '.pem',
50
+ '.netrc',
51
+ ]
52
+
53
+ /** 出厂拒绝词:灾难命令 + 审批配置路径 + 凭据路径。恢复默认必须用这个,不能只用 DEFAULT_DENY_KEYWORDS。 */
54
+ export function shippedRejectKeywords() {
55
+ const out = DEFAULT_REJECT_KEYWORDS.slice()
56
+ const seen = new Set(out)
57
+ for (const w of [...DEFAULT_APPROVAL_CONFIG_KEYWORDS, ...DEFAULT_SECRET_PATH_KEYWORDS]) {
58
+ if (seen.has(w)) continue
59
+ out.push(w)
60
+ seen.add(w)
61
+ }
62
+ return out
63
+ }
64
+
65
+ export function normalizeCriteriaAction(value) {
66
+ if (value === 'reject' || value === 'allow' || value === 'human') return value
67
+ if (value === '拒绝') return 'reject'
68
+ if (value === '允许') return 'allow'
69
+ if (value === '人工') return 'human'
70
+ return 'human'
71
+ }
72
+
73
+ export const JUDGE_PROMPT_LANGS = ['zh', 'en']
74
+
75
+ /** 审核提示词语言。非法值回落到 zh,与现有出厂表一致。 */
76
+ export function normalizeJudgePromptLang(value) {
77
+ return value === 'en' ? 'en' : 'zh'
78
+ }
79
+
80
+ /**
81
+ * 默认审核表(中文)。风险类 reject;safe 才能 allow;other 是兜底 human。
82
+ * description 会写进审核提示词,改文案等于改模型标准。
83
+ */
84
+ export const DEFAULT_CRITERIA_ZH = [
85
+ { id: 'deletion', label: '删除/覆盖不可再生数据', description: '以命令/路径/内容为准:删除、清空或不可逆覆盖用户数据、备份、历史或未提交内容;单文件常规源码/文档编辑不算', action: 'reject' },
86
+ { id: 'credential', label: '凭据/密钥/授权修改', description: '以路径或写入内容为准:密钥、token、证书私钥、.env、authorized_keys、kubeconfig 及授权/登录配置', action: 'reject' },
87
+ { id: 'remote', label: '远程系统/生产环境/数据库', description: '以实际命令为准:对远程主机/生产/数据库做写入,ssh/kubectl/云 CLI 的变更,或对外发布(publish/部署);只读查询不算', action: 'reject' },
88
+ { id: 'system', label: '系统级路径/配置', description: '以命令/路径为准:/etc、/usr、/boot、/root、服务与防火墙、关机/重启,以及 crontab、shell rc、用户启动项', action: 'reject' },
89
+ { id: 'bulk', label: '批量不可回补操作', description: '以实际命令为准:递归/通配/循环的批量删除或覆盖,以及格式化、dd、git reset --hard、git clean、rsync --delete', action: 'reject' },
90
+ { id: 'approval-config', label: '自动审批配置', description: '以路径/命令为准:修改 ~/.dsh 下自动审批的 allowlist、插件配置,或其它会改审批规则/门控的配置', action: 'reject' },
91
+ { id: 'safe', label: '安全/常规可回补', description: '以命令/路径/内容为准:能确认是常规可回补操作(源码、文档、测试、构建产物、可撤销编辑)。拿不准不要选此项', action: 'allow' },
92
+ { id: 'other', label: '其他', description: '以上风险类都不符合,且不能确认是否安全', action: 'human' },
93
+ ]
94
+
95
+ /** 默认审核表(英文)。id / action 与中文包相同。 */
96
+ export const DEFAULT_CRITERIA_EN = [
97
+ { id: 'deletion', label: 'Delete/overwrite irreplaceable data', description: 'Based on command/path/content: delete, empty, or irreversibly overwrite user data, backups, history, or uncommitted work; ordinary single-file source or docs edits do not count', action: 'reject' },
98
+ { id: 'credential', label: 'Credentials/keys/auth changes', description: 'Based on path or write content: secrets, tokens, private keys, .env, authorized_keys, kubeconfig, and auth/login config', action: 'reject' },
99
+ { id: 'remote', label: 'Remote/production/database', description: 'Based on the actual command: writes to remote hosts, production, or databases; ssh/kubectl/cloud CLI mutations; or publishing/deploying. Read-only queries do not count', action: 'reject' },
100
+ { id: 'system', label: 'System paths/config', description: 'Based on command/path: /etc, /usr, /boot, /root, services and firewall, shutdown/reboot, plus crontab, shell rc, and user startup items', action: 'reject' },
101
+ { id: 'bulk', label: 'Bulk irreversible operations', description: 'Based on the actual command: recursive/glob/loop bulk delete or overwrite, plus format, dd, git reset --hard, git clean, rsync --delete', action: 'reject' },
102
+ { id: 'approval-config', label: 'Auto-approve configuration', description: 'Based on path/command: changing the auto-approve allowlist or plugin config under ~/.dsh, or other files that change approval rules/gating', action: 'reject' },
103
+ { id: 'safe', label: 'Safe/routine reversible', description: 'Based on command/path/content: confirmed routine reversible work (source, docs, tests, build artifacts, undoable edits). Do not pick this if unsure', action: 'allow' },
104
+ { id: 'other', label: 'Other', description: 'None of the risk rows apply, and safety cannot be confirmed', action: 'human' },
105
+ ]
106
+
107
+ /** 兼容旧引用:空配置与迁移仍用中文出厂表。 */
108
+ export const DEFAULT_CRITERIA = DEFAULT_CRITERIA_ZH
109
+
110
+ export function shippedCriteria(lang) {
111
+ return normalizeJudgePromptLang(lang) === 'en' ? DEFAULT_CRITERIA_EN : DEFAULT_CRITERIA_ZH
112
+ }
113
+
114
+ export function cloneShippedCriteria(lang) {
115
+ return shippedCriteria(lang).map((c) => ({
116
+ id: c.id, label: c.label, description: c.description, action: c.action,
117
+ }))
118
+ }
119
+
120
+ export const CATEGORY_LABELS = {
121
+ deletion: '删除/覆盖不可再生数据',
122
+ credential: '凭据/密钥/授权修改',
123
+ remote: '远程系统/生产环境/数据库',
124
+ system: '系统级路径/配置',
125
+ bulk: '批量不可回补操作',
126
+ 'approval-config': '自动审批配置',
127
+ safe: '安全/常规可回补',
128
+ other: '其他',
129
+ }
130
+
131
+ export function normalizePresetSandbox(value) {
132
+ return value === 'read-only' ? 'read-only' : 'workspace-write'
133
+ }
134
+
135
+ export function slugCriterionId(value) {
136
+ const t = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
137
+ return t.slice(0, 32)
138
+ }
139
+
140
+ export function normalizeCriterion(raw) {
141
+ const row = raw && typeof raw === 'object' ? raw : {}
142
+ const id = slugCriterionId(row.id || row.label)
143
+ if (!id) return null
144
+ const action = normalizeCriteriaAction(row.action)
145
+ return {
146
+ id,
147
+ label: String(row.label || id).trim() || id,
148
+ description: String(row.description || '').trim(),
149
+ action,
150
+ }
151
+ }
152
+
153
+ /** 保证 `other` 始终在表末可解析。硬类别数组是旧格式。 */
154
+ export function normalizeCriteria(raw, hardCategories) {
155
+ const out = []
156
+ const seen = new Set()
157
+ const push = (row) => {
158
+ const n = normalizeCriterion(row)
159
+ if (!n || seen.has(n.id)) return
160
+ seen.add(n.id)
161
+ out.push(n)
162
+ }
163
+ if (Array.isArray(raw) && raw.length > 0) {
164
+ for (const row of raw) {
165
+ if (typeof row === 'string') {
166
+ const def = DEFAULT_CRITERIA.find((c) => c.id === row)
167
+ push(def || { id: row, label: row, action: 'human' })
168
+ } else {
169
+ push(row)
170
+ }
171
+ }
172
+ } else if (Array.isArray(hardCategories) && hardCategories.length > 0) {
173
+ for (const id of hardCategories) {
174
+ const def = DEFAULT_CRITERIA.find((c) => c.id === id)
175
+ push(def || { id, label: CATEGORY_LABELS[id] || id, action: 'human' })
176
+ }
177
+ } else {
178
+ for (const row of DEFAULT_CRITERIA) push(row)
179
+ }
180
+ if (!seen.has('other')) push(DEFAULT_CRITERIA.find((c) => c.id === 'other'))
181
+ return out
182
+ }
183
+
184
+ /**
185
+ * 读盘后规范化。version 表示「已应用完哪一步迁移」。
186
+ * 不要在迁移里无条件覆盖用户改过的 action,除非该步就是改默认动作。
187
+ */
188
+ export function normalizeAllowlist(raw) {
189
+ const cfg = raw && typeof raw === 'object' ? { ...raw } : {}
190
+ const prevVersion = Number(cfg.version) || 0
191
+ const legacyDeny = Array.isArray(cfg.denyKeywords) ? cfg.denyKeywords : null
192
+ cfg.rejectKeywords = Array.isArray(cfg.rejectKeywords) ? cfg.rejectKeywords.slice() : []
193
+ cfg.humanKeywords = Array.isArray(cfg.humanKeywords) ? cfg.humanKeywords.slice() : []
194
+ cfg.allowKeywords = Array.isArray(cfg.allowKeywords) ? cfg.allowKeywords.slice() : []
195
+ if (prevVersion < 5) {
196
+ const pool = cfg.humanKeywords.length
197
+ ? cfg.humanKeywords
198
+ : (legacyDeny && legacyDeny.length ? legacyDeny : [])
199
+ const allowSet = new Set(cfg.allowKeywords)
200
+ const rejectSet = new Set(cfg.rejectKeywords)
201
+ for (const w of pool) {
202
+ if (!w || allowSet.has(w) || rejectSet.has(w)) continue
203
+ cfg.rejectKeywords.push(w)
204
+ rejectSet.add(w)
205
+ }
206
+ cfg.humanKeywords = cfg.humanKeywords.filter((w) => !rejectSet.has(w))
207
+ }
208
+ if (!cfg.rejectKeywords.length && !cfg.humanKeywords.length && !cfg.allowKeywords.length) {
209
+ cfg.rejectKeywords = shippedRejectKeywords()
210
+ }
211
+ if (prevVersion < 9) {
212
+ const owned = new Set([...cfg.rejectKeywords, ...cfg.humanKeywords, ...cfg.allowKeywords])
213
+ for (const w of DEFAULT_REJECT_KEYWORDS) {
214
+ if (owned.has(w)) continue
215
+ cfg.rejectKeywords.push(w)
216
+ owned.add(w)
217
+ }
218
+ const retired = new Set(RETIRED_DEFAULT_KEYWORDS)
219
+ cfg.rejectKeywords = cfg.rejectKeywords.filter((w) => !retired.has(w))
220
+ }
221
+ cfg.denyKeywords = cfg.humanKeywords
222
+ cfg.criteria = normalizeCriteria(cfg.criteria, cfg.hardCategories)
223
+ if (prevVersion < 7) {
224
+ const other = cfg.criteria.find((c) => c.id === 'other')
225
+ if (other && other.action === 'human') other.action = 'allow'
226
+ }
227
+ if (prevVersion < 10) {
228
+ for (const def of DEFAULT_CRITERIA) {
229
+ const hit = cfg.criteria.find((c) => c.id === def.id)
230
+ if (hit) {
231
+ hit.label = def.label
232
+ hit.description = def.description
233
+ }
234
+ }
235
+ }
236
+ if (prevVersion < 11) {
237
+ if (!cfg.criteria.some((c) => c.id === 'safe')) {
238
+ const def = DEFAULT_CRITERIA.find((c) => c.id === 'safe')
239
+ const otherIdx = cfg.criteria.findIndex((c) => c.id === 'other')
240
+ const row = { id: def.id, label: def.label, description: def.description, action: def.action }
241
+ if (otherIdx >= 0) cfg.criteria.splice(otherIdx, 0, row)
242
+ else cfg.criteria.push(row)
243
+ }
244
+ const other = cfg.criteria.find((c) => c.id === 'other')
245
+ if (other && other.action === 'allow') other.action = 'human'
246
+ for (const def of DEFAULT_CRITERIA) {
247
+ const hit = cfg.criteria.find((c) => c.id === def.id)
248
+ if (hit) {
249
+ hit.label = def.label
250
+ hit.description = def.description
251
+ }
252
+ }
253
+ }
254
+ if (prevVersion < 12) {
255
+ const risk = new Set(['deletion', 'credential', 'remote', 'system', 'bulk'])
256
+ for (const row of cfg.criteria) {
257
+ if (risk.has(row.id) && row.action === 'human') row.action = 'reject'
258
+ }
259
+ }
260
+ if (prevVersion < 13) {
261
+ if (!cfg.criteria.some((c) => c.id === 'approval-config')) {
262
+ const def = DEFAULT_CRITERIA.find((c) => c.id === 'approval-config')
263
+ const row = { id: def.id, label: def.label, description: def.description, action: def.action }
264
+ const safeIdx = cfg.criteria.findIndex((c) => c.id === 'safe')
265
+ if (safeIdx >= 0) cfg.criteria.splice(safeIdx, 0, row)
266
+ else {
267
+ const otherIdx = cfg.criteria.findIndex((c) => c.id === 'other')
268
+ if (otherIdx >= 0) cfg.criteria.splice(otherIdx, 0, row)
269
+ else cfg.criteria.push(row)
270
+ }
271
+ }
272
+ }
273
+ if (prevVersion < 14) {
274
+ const def = DEFAULT_CRITERIA.find((c) => c.id === 'approval-config')
275
+ const hit = cfg.criteria.find((c) => c.id === 'approval-config')
276
+ if (hit && def) {
277
+ hit.label = def.label
278
+ hit.description = def.description
279
+ }
280
+ }
281
+ if (prevVersion < 16) {
282
+ const owned = new Set([...cfg.rejectKeywords, ...cfg.humanKeywords, ...cfg.allowKeywords])
283
+ for (const w of DEFAULT_APPROVAL_CONFIG_KEYWORDS) {
284
+ if (owned.has(w)) continue
285
+ cfg.rejectKeywords.push(w)
286
+ owned.add(w)
287
+ }
288
+ }
289
+ if (prevVersion < 17) {
290
+ const owned = new Set([...cfg.rejectKeywords, ...cfg.humanKeywords, ...cfg.allowKeywords])
291
+ for (const w of DEFAULT_SECRET_PATH_KEYWORDS) {
292
+ if (owned.has(w)) continue
293
+ cfg.rejectKeywords.push(w)
294
+ owned.add(w)
295
+ }
296
+ }
297
+ cfg.version = 17
298
+ cfg.judgeTimeoutMs = Number(cfg.judgeTimeoutMs) > 0 ? Number(cfg.judgeTimeoutMs) : 20000
299
+ delete cfg.allowRules
300
+ delete cfg.denyRules
301
+ delete cfg.learning
302
+ delete cfg.riskyThreshold
303
+ delete cfg.hardCategories
304
+ return cfg
305
+ }
306
+
307
+ export function defaultPluginConfig() {
308
+ return {
309
+ onlyAutoApprovePreset: true,
310
+ presetSandbox: 'workspace-write',
311
+ judgePromptLang: 'zh',
312
+ judge: { provider: '', model: '', reasoningEffort: '', timeoutMs: 20000 },
313
+ notify: { enabled: true, chatId: '', userId: '', timeoutSecs: 120 },
314
+ }
315
+ }
316
+
317
+ export function cloneAllowlist(cfg) {
318
+ const a = cfg && typeof cfg === 'object' ? cfg : {}
319
+ return {
320
+ version: a.version,
321
+ rejectKeywords: Array.isArray(a.rejectKeywords) ? a.rejectKeywords.slice() : [],
322
+ humanKeywords: Array.isArray(a.humanKeywords) ? a.humanKeywords.slice() : [],
323
+ allowKeywords: Array.isArray(a.allowKeywords) ? a.allowKeywords.slice() : [],
324
+ denyKeywords: Array.isArray(a.humanKeywords) ? a.humanKeywords.slice() : [],
325
+ criteria: Array.isArray(a.criteria) ? a.criteria.map((c) => ({ ...c })) : [],
326
+ judgeTimeoutMs: a.judgeTimeoutMs,
327
+ }
328
+ }
329
+
330
+ export function copyAllowlistInto(target, src) {
331
+ if (!target || !src) return
332
+ target.version = src.version
333
+ target.rejectKeywords = src.rejectKeywords
334
+ target.humanKeywords = src.humanKeywords
335
+ target.allowKeywords = src.allowKeywords
336
+ target.denyKeywords = src.humanKeywords
337
+ target.criteria = src.criteria
338
+ target.judgeTimeoutMs = src.judgeTimeoutMs
339
+ }
340
+
341
+ /** 界面超时写 allowlist;yaml 的 judge.timeoutMs 只作缺省。 */
342
+ export function effectiveJudgeTimeoutMs(allowlist, pluginCfg) {
343
+ const a = Number(allowlist && allowlist.judgeTimeoutMs)
344
+ if (Number.isFinite(a) && a > 0) return a
345
+ const p = Number(pluginCfg && pluginCfg.judge && pluginCfg.judge.timeoutMs)
346
+ if (Number.isFinite(p) && p > 0) return p
347
+ return 20000
348
+ }
349
+
350
+ const KEYWORD_KINDS = new Set(['rejectKeywords', 'humanKeywords', 'allowKeywords', 'denyKeywords'])
351
+
352
+ function keywordKind(kind) {
353
+ return kind === 'denyKeywords' ? 'humanKeywords' : kind
354
+ }
355
+
356
+ function actionToKeywordBucket(action) {
357
+ const a = normalizeCriteriaAction(action)
358
+ if (a === 'reject') return 'rejectKeywords'
359
+ if (a === 'allow') return 'allowKeywords'
360
+ return 'humanKeywords'
361
+ }
362
+
363
+ function moveKeyword(allowlist, kind, str) {
364
+ for (const k of ['rejectKeywords', 'humanKeywords', 'allowKeywords']) {
365
+ const list = allowlist[k]
366
+ if (!Array.isArray(list)) continue
367
+ for (let i = list.length - 1; i >= 0; i--) if (String(list[i]) === str) list.splice(i, 1)
368
+ }
369
+ if (!Array.isArray(allowlist[kind])) allowlist[kind] = []
370
+ allowlist[kind].push(str)
371
+ allowlist.denyKeywords = allowlist.humanKeywords
372
+ }
373
+
374
+ /**
375
+ * 只改 draft,不写盘。调用方保存成功后再 copyAllowlistInto 回活对象。
376
+ */
377
+ export function mutateAllowlistOp(allowlist, op, kind, value) {
378
+ if (kind === 'criteria') {
379
+ if (op === 'set') {
380
+ const row = value && typeof value === 'object' ? value : {}
381
+ const id = String(row.id || '').trim()
382
+ const hit = (allowlist.criteria || []).find((c) => c.id === id)
383
+ if (!hit) return { ok: false, error: '未找到该审核项' }
384
+ if (row.action !== undefined) hit.action = normalizeCriteriaAction(row.action)
385
+ if (row.label !== undefined) hit.label = String(row.label || hit.label).trim() || hit.label
386
+ if (row.description !== undefined) hit.description = String(row.description || '').trim()
387
+ return { ok: true, set: true, auditLine: `CONFIG criteria ${id} → ${hit.action}` }
388
+ }
389
+ if (op === 'reset') {
390
+ const lang = normalizeJudgePromptLang(
391
+ value && typeof value === 'object' ? value.lang : value,
392
+ )
393
+ allowlist.criteria = cloneShippedCriteria(lang)
394
+ return { ok: true, reset: true, auditLine: `CONFIG criteria reset defaults lang=${lang}` }
395
+ }
396
+ if (op === 'add') {
397
+ const n = normalizeCriterion(value)
398
+ if (!n) return { ok: false, error: '需要 id 或 label' }
399
+ if ((allowlist.criteria || []).some((c) => c.id === n.id)) return { ok: false, error: 'id 已存在' }
400
+ allowlist.criteria.push(n)
401
+ return { ok: true, added: true, auditLine: `CONFIG criteria + ${n.id}` }
402
+ }
403
+ if (op === 'remove') {
404
+ const id = String((value && value.id) || value || '').trim()
405
+ if (id === 'other') return { ok: false, error: '「其他」不可删除' }
406
+ const before = (allowlist.criteria || []).length
407
+ allowlist.criteria = (allowlist.criteria || []).filter((c) => c.id !== id)
408
+ if (allowlist.criteria.length === before) return { ok: false, error: '未找到该审核项' }
409
+ return { ok: true, removed: true, auditLine: `CONFIG criteria - ${id}` }
410
+ }
411
+ return { ok: false, error: 'criteria 使用 add/set/remove/reset' }
412
+ }
413
+
414
+ if (kind === 'judgeTimeoutMs') {
415
+ if (op !== 'set') return { ok: false, error: `${kind} 使用 set 操作` }
416
+ const n = Number(value)
417
+ if (!Number.isFinite(n) || n <= 0) return { ok: false, error: '无效数值' }
418
+ allowlist.judgeTimeoutMs = n
419
+ return { ok: true, set: true, value: n, auditLine: `CONFIG ${kind} → ${n}` }
420
+ }
421
+
422
+ if (kind === 'keywords') {
423
+ if (op === 'reset') {
424
+ allowlist.rejectKeywords = shippedRejectKeywords()
425
+ allowlist.humanKeywords = []
426
+ allowlist.allowKeywords = []
427
+ allowlist.denyKeywords = allowlist.humanKeywords
428
+ return { ok: true, reset: true, auditLine: 'CONFIG keywords reset defaults' }
429
+ }
430
+ const row = value && typeof value === 'object' ? value : { text: value }
431
+ const str = String(row.text || row.keyword || '').trim()
432
+ if (!str) return { ok: false, error: '关键词不能为空' }
433
+ if (op === 'add' || op === 'set') {
434
+ const from = String(row.from || '').trim()
435
+ if (op === 'set' && from && from !== str) {
436
+ let found = false
437
+ for (const k of ['rejectKeywords', 'humanKeywords', 'allowKeywords']) {
438
+ const list = allowlist[k]
439
+ if (!Array.isArray(list)) continue
440
+ for (let i = list.length - 1; i >= 0; i--) {
441
+ if (String(list[i]) === from) { list.splice(i, 1); found = true }
442
+ }
443
+ }
444
+ if (!found) return { ok: false, error: '未找到该关键词' }
445
+ }
446
+ const bucket = actionToKeywordBucket(row.action)
447
+ moveKeyword(allowlist, bucket, str)
448
+ return {
449
+ ok: true,
450
+ added: op === 'add',
451
+ set: op === 'set',
452
+ auditLine: `CONFIG keywords ${from && from !== str ? from + ' → ' : ''}${str} → ${bucket}`,
453
+ }
454
+ }
455
+ if (op === 'remove') {
456
+ let found = false
457
+ for (const k of ['rejectKeywords', 'humanKeywords', 'allowKeywords']) {
458
+ const list = allowlist[k]
459
+ if (!Array.isArray(list)) continue
460
+ for (let i = list.length - 1; i >= 0; i--) {
461
+ if (String(list[i]) === str) { list.splice(i, 1); found = true }
462
+ }
463
+ }
464
+ allowlist.denyKeywords = allowlist.humanKeywords
465
+ if (!found) return { ok: false, error: '未找到该关键词' }
466
+ return { ok: true, removed: true, auditLine: `CONFIG keywords - ${str}` }
467
+ }
468
+ return { ok: false, error: 'keywords 使用 add/set/remove/reset' }
469
+ }
470
+
471
+ if (KEYWORD_KINDS.has(kind)) {
472
+ const bucket = keywordKind(kind)
473
+ const str = String(value || '').trim()
474
+ if (!str) return { ok: false, error: '值不能为空' }
475
+ if (op === 'add') {
476
+ moveKeyword(allowlist, bucket, str)
477
+ return { ok: true, added: true, auditLine: `CONFIG ${bucket} + ${str}` }
478
+ }
479
+ if (op === 'remove') {
480
+ const list = allowlist[bucket]
481
+ if (!Array.isArray(list)) return { ok: false, error: `未知规则类型: ${bucket}` }
482
+ const before = list.length
483
+ for (let i = list.length - 1; i >= 0; i--) if (String(list[i]) === str) list.splice(i, 1)
484
+ allowlist.denyKeywords = allowlist.humanKeywords
485
+ if (list.length === before) return { ok: false, error: '未找到匹配的规则' }
486
+ return { ok: true, removed: true, auditLine: `CONFIG ${bucket} - ${str}` }
487
+ }
488
+ return { ok: false, error: `未知操作: ${op}` }
489
+ }
490
+
491
+ return { ok: false, error: `未知规则类型: ${kind}` }
492
+ }
493
+
494
+ export function mergePluginConfig(base, overlay) {
495
+ const d = defaultPluginConfig()
496
+ const b = base && typeof base === 'object' ? base : {}
497
+ const o = overlay && typeof overlay === 'object' ? overlay : {}
498
+ const judge = { ...d.judge, ...(b.judge || {}), ...(o.judge || {}) }
499
+ const notify = { ...d.notify, ...(b.notify || {}), ...(o.notify || {}) }
500
+ return {
501
+ onlyAutoApprovePreset: o.onlyAutoApprovePreset ?? b.onlyAutoApprovePreset ?? d.onlyAutoApprovePreset,
502
+ presetSandbox: normalizePresetSandbox(o.presetSandbox ?? b.presetSandbox ?? d.presetSandbox),
503
+ judgePromptLang: normalizeJudgePromptLang(o.judgePromptLang ?? b.judgePromptLang ?? d.judgePromptLang),
504
+ judge,
505
+ notify,
506
+ }
507
+ }
508
+
509
+ /** DSH 升级文案:`escalate sandbox to <mode>: <justification>`。mode 只作展示,不短路。 */
510
+ export function parseReason(reason) {
511
+ const m = String(reason || '').match(/escalate\s+sandbox\s+to\s+([^\s:]+):?\s*([\s\S]*)/i)
512
+ if (m) return { mode: m[1], justification: (m[2] || '').trim() }
513
+ return { mode: '', justification: String(reason || '') }
514
+ }
515
+
516
+ /**
517
+ * 词边界 / 命令形态匹配。中文关键词用包含;英文按非字母数字边界,空白可伸缩。
518
+ * 不要整句裸 includes(避免 format/revoke 一类误伤)。
519
+ */
520
+ export function looksDeny(text, keywords = DEFAULT_DENY_KEYWORDS) {
521
+ const hay = String(text || '')
522
+ if (!hay) return false
523
+ for (const raw of keywords) {
524
+ const keyword = String(raw || '')
525
+ if (!keyword) continue
526
+ if (/[\u4e00-\u9fff]/.test(keyword)) {
527
+ if (hay.includes(keyword)) return true
528
+ continue
529
+ }
530
+ const escaped = keyword
531
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
532
+ .replace(/\s+/g, '\\s+')
533
+ const re = new RegExp(`(?:^|[^a-z0-9_])${escaped}(?=$|[^a-z0-9_])`, 'i')
534
+ if (re.test(hay)) return true
535
+ }
536
+ return false
537
+ }
538
+
539
+ /**
540
+ * 拒绝 > 人工 > 允许。返回 { action, bucket } 或 null。
541
+ */
542
+ export function matchKeywordBuckets(text, cfg, allowText) {
543
+ const reject = (cfg && cfg.rejectKeywords) || []
544
+ const human = (cfg && cfg.humanKeywords) || []
545
+ const allow = (cfg && cfg.allowKeywords) || []
546
+ if (looksDeny(text, reject)) return { action: 'reject', bucket: 'reject' }
547
+ if (looksDeny(text, human)) return { action: 'human', bucket: 'human' }
548
+ const allowHay = allowText != null ? allowText : text
549
+ if (looksDeny(allowHay, allow)) return { action: 'allow', bucket: 'allow' }
550
+ return null
551
+ }
552
+
553
+ export function lookupCriteria(criteria, id) {
554
+ const list = criteria || []
555
+ const hit = list.find((c) => c && c.id === id)
556
+ if (hit) return hit
557
+ return list.find((c) => c && c.id === 'other') || { id: 'other', label: '其他', action: 'human' }
558
+ }
559
+
560
+ const TOOL_ARG_KEYS = [
561
+ 'command', 'file_path', 'path', 'old_string', 'new_string', 'content', 'description', 'workdir',
562
+ 'code', 'url', 'query', 'script', 'sql', 'prompt', 'input', 'text', 'body', 'message', 'pattern', 'selector',
563
+ ]
564
+ const TOOL_ARG_LIMITS = {
565
+ command: 8000,
566
+ file_path: 500,
567
+ path: 500,
568
+ old_string: 4000,
569
+ new_string: 4000,
570
+ content: 4000,
571
+ description: 500,
572
+ workdir: 500,
573
+ code: 4000,
574
+ url: 1000,
575
+ query: 2000,
576
+ script: 4000,
577
+ sql: 2000,
578
+ prompt: 2000,
579
+ input: 2000,
580
+ text: 2000,
581
+ body: 2000,
582
+ message: 1000,
583
+ pattern: 500,
584
+ selector: 500,
585
+ }
586
+
587
+ const EXTRA_CARD_KEYS_ZH = [
588
+ ['code', '代码'],
589
+ ['url', 'URL'],
590
+ ['query', '查询'],
591
+ ['script', '脚本'],
592
+ ['sql', 'SQL'],
593
+ ['prompt', '提示词'],
594
+ ['input', '输入'],
595
+ ['text', '文本'],
596
+ ['body', '正文'],
597
+ ['message', '消息'],
598
+ ['pattern', '模式'],
599
+ ['selector', '选择器'],
600
+ ]
601
+
602
+ const EXTRA_CARD_KEYS_EN = [
603
+ ['code', 'Code'],
604
+ ['url', 'URL'],
605
+ ['query', 'Query'],
606
+ ['script', 'Script'],
607
+ ['sql', 'SQL'],
608
+ ['prompt', 'Prompt'],
609
+ ['input', 'Input'],
610
+ ['text', 'Text'],
611
+ ['body', 'Body'],
612
+ ['message', 'Message'],
613
+ ['pattern', 'Pattern'],
614
+ ['selector', 'Selector'],
615
+ ]
616
+
617
+ /**
618
+ * 只抽网页工具卡片同款叶子字段。禁止 JSON.stringify live exec。
619
+ * justification 故意不抽:关键词和卡片都以命令/路径/内容为准。
620
+ */
621
+ /** 缓存上限。超过仍算截断,禁止自动放行。 */
622
+ export const RAW_ARG_LIMIT = 256 * 1024
623
+
624
+ /**
625
+ * 缓存用:尽量保留原文。展示/送审再 clip。
626
+ */
627
+ export function pickToolArgs(raw) {
628
+ if (!raw || typeof raw !== 'object') return {}
629
+ const out = {}
630
+ for (const key of TOOL_ARG_KEYS) {
631
+ if (typeof raw[key] !== 'string' || !raw[key]) continue
632
+ out[key] = raw[key].length > RAW_ARG_LIMIT ? raw[key].slice(0, RAW_ARG_LIMIT) : raw[key]
633
+ }
634
+ return out
635
+ }
636
+
637
+ /** 任一字段超过送审限额(或顶到 RAW 上限)则不能当完整卡片自动放行。 */
638
+ export function toolArgsTruncated(args) {
639
+ const a = args || {}
640
+ for (const key of TOOL_ARG_KEYS) {
641
+ if (typeof a[key] !== 'string' || !a[key]) continue
642
+ const lim = TOOL_ARG_LIMITS[key] || 1000
643
+ if (a[key].length > lim) return true
644
+ }
645
+ return false
646
+ }
647
+
648
+ export function clipToolArgsForJudge(args) {
649
+ const a = args || {}
650
+ const out = {}
651
+ for (const key of TOOL_ARG_KEYS) {
652
+ if (typeof a[key] !== 'string' || !a[key]) continue
653
+ const lim = TOOL_ARG_LIMITS[key] || 1000
654
+ out[key] = a[key].length > lim ? a[key].slice(0, lim) + '…' : a[key]
655
+ }
656
+ return out
657
+ }
658
+
659
+ const EVENT_ARG_LIMITS = {
660
+ command: 2000,
661
+ file_path: 500,
662
+ path: 500,
663
+ old_string: 1500,
664
+ new_string: 1500,
665
+ content: 1500,
666
+ description: 400,
667
+ }
668
+
669
+ /** 写入审批事件的工具卡片叶子字段(再截短,避免 jsonl 膨胀)。 */
670
+ export function clipToolArgsForEvent(args) {
671
+ const a = args || {}
672
+ const out = {}
673
+ for (const key of TOOL_ARG_KEYS) {
674
+ if (typeof a[key] !== 'string' || !a[key]) continue
675
+ const lim = EVENT_ARG_LIMITS[key] || 800
676
+ out[key] = a[key].length > lim ? a[key].slice(0, lim) + '…' : a[key]
677
+ }
678
+ return out
679
+ }
680
+
681
+ /** description 不算有效载荷:只有它等于没看见要审的操作。 */
682
+ export function hasToolPayload(args) {
683
+ const a = args || {}
684
+ return Boolean(
685
+ a.command || a.file_path || a.path || a.old_string || a.new_string || a.content
686
+ || a.code || a.url || a.script || a.sql || a.prompt,
687
+ )
688
+ }
689
+
690
+ export const CALL_CACHE_LIMIT = 256
691
+
692
+ /** 有 session 时带上,避免 assembler 的 call-0 在会话间撞车。 */
693
+ export function callCacheKey(sessionId, callId) {
694
+ const id = String(callId || '')
695
+ if (!id) return ''
696
+ const sid = String(sessionId || '')
697
+ return sid ? sid + ':' + id : id
698
+ }
699
+
700
+ export function rememberCachedCall(map, sessionId, callId, args) {
701
+ if (!map || typeof map.set !== 'function') return
702
+ const key = callCacheKey(sessionId, callId)
703
+ if (!key) return
704
+ map.set(key, args)
705
+ while (map.size > CALL_CACHE_LIMIT) {
706
+ const first = map.keys().next().value
707
+ if (first === undefined) break
708
+ map.delete(first)
709
+ }
710
+ }
711
+
712
+ /**
713
+ * 优先取 session:callId;没有再回落 callId。两条都删,避免密钥片段留在 Map 里。
714
+ */
715
+ export function takeCachedCall(map, sessionId, callId) {
716
+ if (!map || typeof map.get !== 'function') return { found: false, args: {} }
717
+ const id = String(callId || '')
718
+ const sid = String(sessionId || '')
719
+ const scoped = sid && id ? sid + ':' + id : ''
720
+ const bare = id
721
+ let found = false
722
+ let args
723
+ if (scoped && map.has(scoped)) {
724
+ args = map.get(scoped)
725
+ map.delete(scoped)
726
+ found = true
727
+ }
728
+ if (bare && map.has(bare)) {
729
+ if (!found) {
730
+ args = map.get(bare)
731
+ found = true
732
+ }
733
+ map.delete(bare)
734
+ }
735
+ return { found, args: args && typeof args === 'object' ? args : {} }
736
+ }
737
+
738
+ /**
739
+ * 关键词干草。`reason` 参数保留以免改签名,但故意不用:升级理由会误伤允许桶。
740
+ */
741
+ export function formatKeywordHay(toolName, reason, args) {
742
+ const a = args || {}
743
+ return [
744
+ toolName,
745
+ a.command,
746
+ a.file_path,
747
+ a.path,
748
+ a.workdir,
749
+ ].filter(Boolean).join('\n')
750
+ }
751
+
752
+ /** 允许桶不看工具名,避免 `bash`/`write` 整类放行。 */
753
+ export function formatAllowKeywordHay(args) {
754
+ const a = args || {}
755
+ return [
756
+ a.command,
757
+ a.file_path,
758
+ a.path,
759
+ a.workdir,
760
+ ].filter(Boolean).join('\n')
761
+ }
762
+
763
+ /** 给审核模型看的卡片。含内容/cwd;模型理由只作补充。语言与提示词框架一致。 */
764
+ export function formatJudgeCard(toolName, mode, justification, args, cwd, lang) {
765
+ const a = clipToolArgsForJudge(args)
766
+ const en = normalizeJudgePromptLang(lang) === 'en'
767
+ const none = en ? '(none)' : '(无说明)'
768
+ const sandbox = mode || (en ? '(not a sandbox escalation)' : '(非越界审批)')
769
+ const lines = en
770
+ ? [`Tool: ${toolName}`, `Target sandbox: ${sandbox}`]
771
+ : [`工具: ${toolName}`, `目标沙箱模式: ${sandbox}`]
772
+ if (cwd) lines.push((en ? 'Working directory: ' : '工作目录: ') + cwd)
773
+ if (a.workdir && a.workdir !== cwd) lines.push((en ? 'Command working directory: ' : '命令工作目录: ') + a.workdir)
774
+ if (a.command) lines.push(en ? 'Command:' : '命令:', a.command)
775
+ if (a.file_path || a.path) lines.push((en ? 'Path: ' : '路径: ') + (a.file_path || a.path))
776
+ if (a.description) lines.push((en ? 'Description: ' : '描述: ') + a.description)
777
+ if (a.old_string) lines.push(en ? 'Original:' : '原文:', a.old_string)
778
+ if (a.new_string) lines.push(en ? 'Replacement:' : '改成:', a.new_string)
779
+ if (a.content) lines.push(en ? 'Write contents:' : '写入内容:', a.content)
780
+ const extra = en ? EXTRA_CARD_KEYS_EN : EXTRA_CARD_KEYS_ZH
781
+ for (const pair of extra) {
782
+ if (a[pair[0]]) lines.push(pair[1] + ':', a[pair[0]])
783
+ }
784
+ lines.push((en ? 'Model justification: ' : '模型理由: ') + (justification || none))
785
+ if (en) lines.push('', 'Classify. Output exactly two lines:', 'Category: <id>', 'Reason: <one sentence>')
786
+ else lines.push('', '请归类。只输出两行:', '类别: <id>', '理由: <一句话>')
787
+ return lines.join('\n')
788
+ }
789
+
790
+ /** 分类提示。强调选 approval-config 而不是 safe 去改 ~/.dsh 门控。语言只换框架,表行用传入 criteria 原文。 */
791
+ export function buildJudgePrompt(criteria, lang) {
792
+ const rows = Array.isArray(criteria) && criteria.length ? criteria : shippedCriteria(lang)
793
+ const en = normalizeJudgePromptLang(lang) === 'en'
794
+ const lines = rows.map((c) => {
795
+ const desc = c.description ? (en ? '. ' : '。') + c.description : ''
796
+ return `- ${c.id}${en ? ': ' : ':'}${c.label}${desc}`
797
+ })
798
+ if (en) {
799
+ return [
800
+ 'You are an approval classifier. You see the same tool card a human would. You are not the allow/reject/human decision maker.',
801
+ 'Assign the operation to exactly one row in the criteria table. Do not output allow, reject, or human.',
802
+ '',
803
+ 'Trust command / path / original / replacement / write contents. The model justification may be incomplete or wrong; it does not override the command.',
804
+ 'Sandbox mode only describes the fence: workspace-write may write inside the workspace; danger-full-access may write outside it. Do not change the class because of the mode name.',
805
+ 'Ordinary source/docs/test/build edits outside the workspace are not deletion, credential, remote, system, or bulk by themselves.',
806
+ 'If it looks like deletion, credentials, remote, system, or bulk, pick that class.',
807
+ 'Changing the auto-approve allowlist, plugin config, or other approval-gating config under ~/.dsh is approval-config, not safe.',
808
+ 'Pick safe only when you can confirm a routine reversible operation.',
809
+ 'If none of the above fit and you cannot confirm safety, pick other. Do not pick safe when unsure.',
810
+ '',
811
+ 'Criteria:',
812
+ ...lines,
813
+ '',
814
+ 'Output exactly two lines and nothing else:',
815
+ 'Category: <id from the table>',
816
+ 'Reason: <one sentence>',
817
+ ].join('\n')
818
+ }
819
+ return [
820
+ '你是审批分类器,代替人看同一张工具卡片。不是放行/拒绝的决策者。',
821
+ '根据审核表把操作归到恰好一行。不要输出允许、拒绝或人工。',
822
+ '',
823
+ '以「命令 / 路径 / 原文 / 改成 / 写入内容」为准。模型理由可能不完整或与实际不符,不能代替命令。',
824
+ '沙箱模式只说明围栏范围:workspace-write 写工作区;danger-full-access 可写工作区外。不要因为模式名就改分类。',
825
+ '工作区外常规源码/文档/测试/构建编辑本身不算删除/凭据/远程/系统/批量。',
826
+ '像删除/凭据/远程/系统/批量就选该类。',
827
+ '修改 ~/.dsh 下自动审批 allowlist、插件配置或其它审批门控配置选 approval-config,不要当成 safe。',
828
+ '只有能确认是常规可回补操作才选 safe。',
829
+ '以上都不符合且不能确认是否安全时选 other。不要因为拿不准就选 safe。',
830
+ '',
831
+ '审核表:',
832
+ ...lines,
833
+ '',
834
+ '只输出两行,不要其它内容:',
835
+ '类别: <上面的 id>',
836
+ '理由: <一句话>',
837
+ ].join('\n')
838
+ }
839
+
840
+ /**
841
+ * 只认「类别: id」。模糊匹配跳过 other 和 action===allow 的 id,避免把 safe 当兜底。
842
+ * 解析失败抛错,由调用方转人工。
843
+ */
844
+ export function parseJudgeClassify(text, criteria) {
845
+ const rows = Array.isArray(criteria) && criteria.length ? criteria : DEFAULT_CRITERIA
846
+ const ids = new Set(rows.map((c) => c.id))
847
+ const raw = String(text || '').trim()
848
+ if (!raw) throw new Error('审核模型输出为空')
849
+ const idMatch = raw.match(/(?:^|\n)\s*(?:类别|分类|category)\s*[::]\s*([a-z0-9_-]+)/i)
850
+ let id = idMatch ? String(idMatch[1]).toLowerCase() : ''
851
+ if (!id || !ids.has(id)) {
852
+ for (const row of rows) {
853
+ if (row.id === 'other' || row.action === 'allow') continue
854
+ const re = new RegExp('(?:^|[^a-z0-9_])' + row.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:$|[^a-z0-9_])', 'i')
855
+ if (re.test(raw)) { id = row.id; break }
856
+ }
857
+ }
858
+ if (!id || !ids.has(id)) {
859
+ throw new Error('审核模型输出无法解析: ' + JSON.stringify(raw.slice(0, 120)))
860
+ }
861
+ const reasonMatch = raw.match(/(?:^|\n)\s*(?:理由|reason)\s*[::]\s*(.+)/i)
862
+ const reason = reasonMatch ? String(reasonMatch[1]).trim().slice(0, 200) : ''
863
+ const row = lookupCriteria(rows, id)
864
+ return { criterion: row.id, label: row.label, action: row.action, reason }
865
+ }
866
+
867
+ export function parseJudgeOutput(text) {
868
+ return parseJudgeClassify(text, DEFAULT_CRITERIA)
869
+ }
870
+
871
+ /** 插入 permission.presets 下的一块。sandbox 只能是 workspace-write | read-only。 */
872
+ export function autoApprovePresetYaml(sandbox = 'workspace-write') {
873
+ const mode = normalizePresetSandbox(sandbox)
874
+ return ` auto-approve:
875
+ sandbox: ${mode}
876
+ approval: ask
877
+ name: 自动审批
878
+ description: 审核模型预判写入/命令是否不可回补:安全自动批准,有风险转人工审批。
879
+ `
880
+ }
881
+
882
+ export const AUTO_APPROVE_PRESET_YAML = autoApprovePresetYaml('workspace-write')
883
+
884
+ export const FULL_PERMISSION_BLOCK = `
885
+ # ── 自动审批模式(dsh-auto-approve)─────────────────────────
886
+ - id: permission
887
+ name: '@deepseek-ai/dsh-permission-presets'
888
+ config:
889
+ presets:
890
+ read-only:
891
+ sandbox: read-only
892
+ approval: ask
893
+ workspace-write:
894
+ sandbox: workspace-write
895
+ approval: ask
896
+ danger-full-access:
897
+ sandbox: danger-full-access
898
+ approval: never
899
+ `
900
+