@dnalec/dsh-auto-approve 0.1.3 → 0.2.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/CHANGELOG.md +21 -0
- package/README.md +34 -93
- package/README.zh.md +23 -85
- package/client.js +106 -217
- package/locales.mjs +164 -128
- package/package.json +5 -9
- package/src/index.mjs +179 -458
- package/src/preset-patch.mjs +3 -3
- package/src/rules.mjs +155 -54
- package/src/util.mjs +5 -69
- package/src/provisioning.mjs +0 -131
- package/src/qqbot.mjs +0 -313
- package/src/tickets.mjs +0 -277
package/src/preset-patch.mjs
CHANGED
|
@@ -110,7 +110,7 @@ export function ensureAutoApprovePreset(patchPath, sandbox = 'workspace-write')
|
|
|
110
110
|
if (i > permIdx && /^- /.test(lines[i]) && !/^ {2,}- /.test(lines[i])) break
|
|
111
111
|
}
|
|
112
112
|
if (presetsIdx === -1) {
|
|
113
|
-
return { ok: false, status: 'no-presets-key', needRestart: false,
|
|
113
|
+
return { ok: false, status: 'no-presets-key', needRestart: false, code: 'err.noPresetsKey' }
|
|
114
114
|
}
|
|
115
115
|
let insertAt = presetsIdx
|
|
116
116
|
for (let i = presetsIdx + 1; i < lines.length; i++) {
|
|
@@ -123,7 +123,7 @@ export function ensureAutoApprovePreset(patchPath, sandbox = 'workspace-write')
|
|
|
123
123
|
writeFileSync(patchPath, lines.join('\n'), 'utf8')
|
|
124
124
|
return { ok: true, status: 'added-preset', needRestart: true }
|
|
125
125
|
} catch (e) {
|
|
126
|
-
return { ok: false, status: 'error', needRestart: false, error: String((e && e.message) || e) }
|
|
126
|
+
return { ok: false, status: 'error', needRestart: false, code: 'err.preset', details: { error: String((e && e.message) || e) } }
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -141,7 +141,7 @@ export function setAutoApproveSandbox(patchPath, sandbox) {
|
|
|
141
141
|
writeFileSync(patchPath, replaced.text, 'utf8')
|
|
142
142
|
return { ok: true, status: 'updated', needRestart: true, sandbox: mode }
|
|
143
143
|
} catch (e) {
|
|
144
|
-
return { ok: false, status: 'error', needRestart: false, sandbox: mode, error: String((e && e.message) || e) }
|
|
144
|
+
return { ok: false, status: 'error', needRestart: false, sandbox: mode, code: 'err.preset', details: { error: String((e && e.message) || e) } }
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
package/src/rules.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 判定管道纯函数(无 I/O)。
|
|
3
3
|
*
|
|
4
|
-
* 关键词只匹配工具名 + command + 路径 + workdir
|
|
4
|
+
* 关键词只匹配工具名 + command + 路径 + workdir(含会话 cwd;相对路径会拼到 cwd/workdir 上),不匹配 justification、description、文件正文。
|
|
5
5
|
* 允许桶不匹配工具名,避免把 bash/write 整类放行。
|
|
6
6
|
* 审核模型只归类;动作以本表为准。`other` 必须存在,解析失败视为 other。
|
|
7
7
|
* allowlist.version 只增不改历史语义,用 prevVersion < N 做一次性迁移。
|
|
@@ -83,25 +83,25 @@ export function normalizeJudgePromptLang(value) {
|
|
|
83
83
|
*/
|
|
84
84
|
export const DEFAULT_CRITERIA_ZH = [
|
|
85
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
|
|
88
|
-
{ id: 'system', label: '系统级路径/配置', description: '以命令/路径为准:/etc、/usr、/boot、/root
|
|
86
|
+
{ id: 'credential', label: '凭据/密钥/授权修改', description: '以路径或写入内容为准:密钥、token、证书私钥、.env、authorized_keys、kubeconfig、~/.aws、带 token 的 .npmrc、docker config.json 及授权/登录配置', action: 'reject' },
|
|
87
|
+
{ id: 'remote', label: '远程系统/生产环境/数据库', description: '以实际命令为准:对远程主机/生产/数据库做写入,ssh/kubectl/云 CLI 的变更,以及 npm/pypi publish 或生产部署;只读查询和普通 git push 不算', action: 'reject' },
|
|
88
|
+
{ id: 'system', label: '系统级路径/配置', description: '以命令/路径为准:/etc、/usr、/boot、/root、/var、/opt、Windows 系统目录、服务与防火墙、关机/重启,以及 crontab、shell rc、用户启动项;包管理器往系统前缀安装也算', action: 'reject' },
|
|
89
89
|
{ id: 'bulk', label: '批量不可回补操作', description: '以实际命令为准:递归/通配/循环的批量删除或覆盖,以及格式化、dd、git reset --hard、git clean、rsync --delete', action: 'reject' },
|
|
90
90
|
{ id: 'approval-config', label: '自动审批配置', description: '以路径/命令为准:修改 ~/.dsh 下自动审批的 allowlist、插件配置,或其它会改审批规则/门控的配置', action: 'reject' },
|
|
91
|
-
{ id: 'safe', label: '安全/常规可回补', description: '
|
|
92
|
-
{ id: 'other', label: '
|
|
91
|
+
{ id: 'safe', label: '安全/常规可回补', description: '以命令/路径/内容为准:能确认是常规可回补操作(源码、文档、测试、构建产物、安装项目依赖、可撤销单文件编辑)。发包、提权、外发数据不要选。拿不准不要选此项', action: 'allow' },
|
|
92
|
+
{ id: 'other', label: '其他(拿不准)', description: '风险类和 safe 都不符合,或拿不准。看起来无害但无法确认可回补的,也选这项', action: 'human' },
|
|
93
93
|
]
|
|
94
94
|
|
|
95
95
|
/** 默认审核表(英文)。id / action 与中文包相同。 */
|
|
96
96
|
export const DEFAULT_CRITERIA_EN = [
|
|
97
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
|
|
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' },
|
|
98
|
+
{ id: 'credential', label: 'Credentials/keys/auth changes', description: 'Based on path or write content: secrets, tokens, private keys, .env, authorized_keys, kubeconfig, ~/.aws, .npmrc with tokens, docker config.json, 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; npm/pypi publish or production deploys. Read-only queries and ordinary git push do not count', action: 'reject' },
|
|
100
|
+
{ id: 'system', label: 'System paths/config', description: 'Based on command/path: /etc, /usr, /boot, /root, /var, /opt, Windows system directories, services and firewall, shutdown/reboot, plus crontab, shell rc, and user startup items; package-manager installs into a system prefix also count', action: 'reject' },
|
|
101
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
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: '
|
|
103
|
+
{ id: 'safe', label: 'Safe/routine reversible', description: 'Based on command/path/content: confirmed routine reversible work (source, docs, tests, build artifacts, installing project dependencies, undoable single-file edits). Do not pick this for publishing, privilege escalation, or sending data out. Do not pick this if unsure', action: 'allow' },
|
|
104
|
+
{ id: 'other', label: 'Other (unsure)', description: 'Neither a risk row nor safe fits, or you are unsure. Also pick this when it looks harmless but reversibility cannot be confirmed', action: 'human' },
|
|
105
105
|
]
|
|
106
106
|
|
|
107
107
|
/** 兼容旧引用:空配置与迁移仍用中文出厂表。 */
|
|
@@ -125,7 +125,7 @@ export const CATEGORY_LABELS = {
|
|
|
125
125
|
bulk: '批量不可回补操作',
|
|
126
126
|
'approval-config': '自动审批配置',
|
|
127
127
|
safe: '安全/常规可回补',
|
|
128
|
-
other: '
|
|
128
|
+
other: '其他(拿不准)',
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
export function normalizePresetSandbox(value) {
|
|
@@ -294,7 +294,42 @@ export function normalizeAllowlist(raw) {
|
|
|
294
294
|
owned.add(w)
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
|
-
|
|
297
|
+
if (prevVersion < 18) {
|
|
298
|
+
// 刷出厂文案:只替换仍是旧中/英原文的行,不改用户自定义描述,不碰 action。
|
|
299
|
+
const prevCopy = {
|
|
300
|
+
zh: {
|
|
301
|
+
credential: { description: '以路径或写入内容为准:密钥、token、证书私钥、.env、authorized_keys、kubeconfig 及授权/登录配置' },
|
|
302
|
+
remote: { description: '以实际命令为准:对远程主机/生产/数据库做写入,ssh/kubectl/云 CLI 的变更,或对外发布(publish/部署);只读查询不算' },
|
|
303
|
+
system: { description: '以命令/路径为准:/etc、/usr、/boot、/root、服务与防火墙、关机/重启,以及 crontab、shell rc、用户启动项' },
|
|
304
|
+
safe: { description: '以命令/路径/内容为准:能确认是常规可回补操作(源码、文档、测试、构建产物、可撤销编辑)。拿不准不要选此项' },
|
|
305
|
+
other: { label: '其他', description: '以上风险类都不符合,且不能确认是否安全' },
|
|
306
|
+
},
|
|
307
|
+
en: {
|
|
308
|
+
credential: { description: 'Based on path or write content: secrets, tokens, private keys, .env, authorized_keys, kubeconfig, and auth/login config' },
|
|
309
|
+
remote: { 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' },
|
|
310
|
+
system: { description: 'Based on command/path: /etc, /usr, /boot, /root, services and firewall, shutdown/reboot, plus crontab, shell rc, and user startup items' },
|
|
311
|
+
safe: { description: 'Based on command/path/content: confirmed routine reversible work (source, docs, tests, build artifacts, undoable edits). Do not pick this if unsure' },
|
|
312
|
+
other: { label: 'Other', description: 'None of the risk rows apply, and safety cannot be confirmed' },
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
const packs = { zh: DEFAULT_CRITERIA_ZH, en: DEFAULT_CRITERIA_EN }
|
|
316
|
+
for (const lang of ['zh', 'en']) {
|
|
317
|
+
const oldRows = prevCopy[lang]
|
|
318
|
+
const neu = packs[lang]
|
|
319
|
+
for (const id of Object.keys(oldRows)) {
|
|
320
|
+
const hit = cfg.criteria.find((c) => c.id === id)
|
|
321
|
+
const def = neu.find((c) => c.id === id)
|
|
322
|
+
const old = oldRows[id]
|
|
323
|
+
if (!hit || !def) continue
|
|
324
|
+
const sameLabel = Boolean(old.label && hit.label === old.label)
|
|
325
|
+
const sameDesc = Boolean(old.description && hit.description === old.description)
|
|
326
|
+
if (!sameLabel && !sameDesc) continue
|
|
327
|
+
hit.label = def.label
|
|
328
|
+
hit.description = def.description
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
cfg.version = 18
|
|
298
333
|
cfg.judgeTimeoutMs = Number(cfg.judgeTimeoutMs) > 0 ? Number(cfg.judgeTimeoutMs) : 20000
|
|
299
334
|
delete cfg.allowRules
|
|
300
335
|
delete cfg.denyRules
|
|
@@ -310,10 +345,8 @@ export function defaultPluginConfig() {
|
|
|
310
345
|
presetSandbox: 'workspace-write',
|
|
311
346
|
judgePromptLang: 'zh',
|
|
312
347
|
judge: { provider: '', model: '', reasoningEffort: '', timeoutMs: 20000 },
|
|
313
|
-
notify: { enabled: true, chatId: '', userId: '', timeoutSecs: 120 },
|
|
314
348
|
}
|
|
315
349
|
}
|
|
316
|
-
|
|
317
350
|
export function cloneAllowlist(cfg) {
|
|
318
351
|
const a = cfg && typeof cfg === 'object' ? cfg : {}
|
|
319
352
|
return {
|
|
@@ -371,6 +404,20 @@ function moveKeyword(allowlist, kind, str) {
|
|
|
371
404
|
allowlist.denyKeywords = allowlist.humanKeywords
|
|
372
405
|
}
|
|
373
406
|
|
|
407
|
+
|
|
408
|
+
export function fail(code, details) {
|
|
409
|
+
const out = { ok: false, code }
|
|
410
|
+
if (details && typeof details === 'object' && Object.keys(details).length) out.details = details
|
|
411
|
+
return out
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function codedThrow(code, details) {
|
|
415
|
+
const err = new Error(code)
|
|
416
|
+
err.code = code
|
|
417
|
+
if (details && typeof details === 'object') err.details = details
|
|
418
|
+
throw err
|
|
419
|
+
}
|
|
420
|
+
|
|
374
421
|
/**
|
|
375
422
|
* 只改 draft,不写盘。调用方保存成功后再 copyAllowlistInto 回活对象。
|
|
376
423
|
*/
|
|
@@ -380,7 +427,7 @@ export function mutateAllowlistOp(allowlist, op, kind, value) {
|
|
|
380
427
|
const row = value && typeof value === 'object' ? value : {}
|
|
381
428
|
const id = String(row.id || '').trim()
|
|
382
429
|
const hit = (allowlist.criteria || []).find((c) => c.id === id)
|
|
383
|
-
if (!hit) return
|
|
430
|
+
if (!hit) return fail('err.criterionNotFound')
|
|
384
431
|
if (row.action !== undefined) hit.action = normalizeCriteriaAction(row.action)
|
|
385
432
|
if (row.label !== undefined) hit.label = String(row.label || hit.label).trim() || hit.label
|
|
386
433
|
if (row.description !== undefined) hit.description = String(row.description || '').trim()
|
|
@@ -395,26 +442,26 @@ export function mutateAllowlistOp(allowlist, op, kind, value) {
|
|
|
395
442
|
}
|
|
396
443
|
if (op === 'add') {
|
|
397
444
|
const n = normalizeCriterion(value)
|
|
398
|
-
if (!n) return
|
|
399
|
-
if ((allowlist.criteria || []).some((c) => c.id === n.id)) return
|
|
445
|
+
if (!n) return fail('err.criterionNeedId')
|
|
446
|
+
if ((allowlist.criteria || []).some((c) => c.id === n.id)) return fail('err.criterionIdExists')
|
|
400
447
|
allowlist.criteria.push(n)
|
|
401
448
|
return { ok: true, added: true, auditLine: `CONFIG criteria + ${n.id}` }
|
|
402
449
|
}
|
|
403
450
|
if (op === 'remove') {
|
|
404
451
|
const id = String((value && value.id) || value || '').trim()
|
|
405
|
-
if (id === 'other') return
|
|
452
|
+
if (id === 'other') return fail('err.criterionOtherLocked')
|
|
406
453
|
const before = (allowlist.criteria || []).length
|
|
407
454
|
allowlist.criteria = (allowlist.criteria || []).filter((c) => c.id !== id)
|
|
408
|
-
if (allowlist.criteria.length === before) return
|
|
455
|
+
if (allowlist.criteria.length === before) return fail('err.criterionNotFound')
|
|
409
456
|
return { ok: true, removed: true, auditLine: `CONFIG criteria - ${id}` }
|
|
410
457
|
}
|
|
411
|
-
return
|
|
458
|
+
return fail('err.criteriaOp')
|
|
412
459
|
}
|
|
413
460
|
|
|
414
461
|
if (kind === 'judgeTimeoutMs') {
|
|
415
|
-
if (op !== 'set') return
|
|
462
|
+
if (op !== 'set') return fail('err.opMustSet', { kind })
|
|
416
463
|
const n = Number(value)
|
|
417
|
-
if (!Number.isFinite(n) || n <= 0) return
|
|
464
|
+
if (!Number.isFinite(n) || n <= 0) return fail('err.invalidNumber')
|
|
418
465
|
allowlist.judgeTimeoutMs = n
|
|
419
466
|
return { ok: true, set: true, value: n, auditLine: `CONFIG ${kind} → ${n}` }
|
|
420
467
|
}
|
|
@@ -429,7 +476,7 @@ export function mutateAllowlistOp(allowlist, op, kind, value) {
|
|
|
429
476
|
}
|
|
430
477
|
const row = value && typeof value === 'object' ? value : { text: value }
|
|
431
478
|
const str = String(row.text || row.keyword || '').trim()
|
|
432
|
-
if (!str) return
|
|
479
|
+
if (!str) return fail('err.keywordEmpty')
|
|
433
480
|
if (op === 'add' || op === 'set') {
|
|
434
481
|
const from = String(row.from || '').trim()
|
|
435
482
|
if (op === 'set' && from && from !== str) {
|
|
@@ -441,7 +488,7 @@ export function mutateAllowlistOp(allowlist, op, kind, value) {
|
|
|
441
488
|
if (String(list[i]) === from) { list.splice(i, 1); found = true }
|
|
442
489
|
}
|
|
443
490
|
}
|
|
444
|
-
if (!found) return
|
|
491
|
+
if (!found) return fail('err.keywordNotFound')
|
|
445
492
|
}
|
|
446
493
|
const bucket = actionToKeywordBucket(row.action)
|
|
447
494
|
moveKeyword(allowlist, bucket, str)
|
|
@@ -462,33 +509,33 @@ export function mutateAllowlistOp(allowlist, op, kind, value) {
|
|
|
462
509
|
}
|
|
463
510
|
}
|
|
464
511
|
allowlist.denyKeywords = allowlist.humanKeywords
|
|
465
|
-
if (!found) return
|
|
512
|
+
if (!found) return fail('err.keywordNotFound')
|
|
466
513
|
return { ok: true, removed: true, auditLine: `CONFIG keywords - ${str}` }
|
|
467
514
|
}
|
|
468
|
-
return
|
|
515
|
+
return fail('err.keywordsOp')
|
|
469
516
|
}
|
|
470
517
|
|
|
471
518
|
if (KEYWORD_KINDS.has(kind)) {
|
|
472
519
|
const bucket = keywordKind(kind)
|
|
473
520
|
const str = String(value || '').trim()
|
|
474
|
-
if (!str) return
|
|
521
|
+
if (!str) return fail('err.valueEmpty')
|
|
475
522
|
if (op === 'add') {
|
|
476
523
|
moveKeyword(allowlist, bucket, str)
|
|
477
524
|
return { ok: true, added: true, auditLine: `CONFIG ${bucket} + ${str}` }
|
|
478
525
|
}
|
|
479
526
|
if (op === 'remove') {
|
|
480
527
|
const list = allowlist[bucket]
|
|
481
|
-
if (!Array.isArray(list)) return {
|
|
528
|
+
if (!Array.isArray(list)) return fail('err.unknownKind', { kind: bucket })
|
|
482
529
|
const before = list.length
|
|
483
530
|
for (let i = list.length - 1; i >= 0; i--) if (String(list[i]) === str) list.splice(i, 1)
|
|
484
531
|
allowlist.denyKeywords = allowlist.humanKeywords
|
|
485
|
-
if (list.length === before) return
|
|
532
|
+
if (list.length === before) return fail('err.ruleNotFound')
|
|
486
533
|
return { ok: true, removed: true, auditLine: `CONFIG ${bucket} - ${str}` }
|
|
487
534
|
}
|
|
488
|
-
return {
|
|
535
|
+
return fail('err.unknownOp', { op: String(op || '') })
|
|
489
536
|
}
|
|
490
537
|
|
|
491
|
-
return {
|
|
538
|
+
return fail('err.unknownKind', { kind: String(kind || '') })
|
|
492
539
|
}
|
|
493
540
|
|
|
494
541
|
export function mergePluginConfig(base, overlay) {
|
|
@@ -496,16 +543,25 @@ export function mergePluginConfig(base, overlay) {
|
|
|
496
543
|
const b = base && typeof base === 'object' ? base : {}
|
|
497
544
|
const o = overlay && typeof overlay === 'object' ? overlay : {}
|
|
498
545
|
const judge = { ...d.judge, ...(b.judge || {}), ...(o.judge || {}) }
|
|
499
|
-
const notify = { ...d.notify, ...(b.notify || {}), ...(o.notify || {}) }
|
|
500
546
|
return {
|
|
501
547
|
onlyAutoApprovePreset: o.onlyAutoApprovePreset ?? b.onlyAutoApprovePreset ?? d.onlyAutoApprovePreset,
|
|
502
548
|
presetSandbox: normalizePresetSandbox(o.presetSandbox ?? b.presetSandbox ?? d.presetSandbox),
|
|
503
549
|
judgePromptLang: normalizeJudgePromptLang(o.judgePromptLang ?? b.judgePromptLang ?? d.judgePromptLang),
|
|
504
550
|
judge,
|
|
505
|
-
notify,
|
|
506
551
|
}
|
|
507
552
|
}
|
|
508
553
|
|
|
554
|
+
/** 从旧 approval-bridge/config.json 只抽出判定字段,丢掉 notify / channels。 */
|
|
555
|
+
export function pickMigratablePluginConfig(raw) {
|
|
556
|
+
if (!raw || typeof raw !== 'object') return null
|
|
557
|
+
const out = {}
|
|
558
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'onlyAutoApprovePreset')) out.onlyAutoApprovePreset = raw.onlyAutoApprovePreset
|
|
559
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'presetSandbox')) out.presetSandbox = raw.presetSandbox
|
|
560
|
+
if (Object.prototype.hasOwnProperty.call(raw, 'judgePromptLang')) out.judgePromptLang = raw.judgePromptLang
|
|
561
|
+
if (raw.judge && typeof raw.judge === 'object') out.judge = raw.judge
|
|
562
|
+
return Object.keys(out).length ? out : null
|
|
563
|
+
}
|
|
564
|
+
|
|
509
565
|
/** DSH 升级文案:`escalate sandbox to <mode>: <justification>`。mode 只作展示,不短路。 */
|
|
510
566
|
export function parseReason(reason) {
|
|
511
567
|
const m = String(reason || '').match(/escalate\s+sandbox\s+to\s+([^\s:]+):?\s*([\s\S]*)/i)
|
|
@@ -554,7 +610,7 @@ export function lookupCriteria(criteria, id) {
|
|
|
554
610
|
const list = criteria || []
|
|
555
611
|
const hit = list.find((c) => c && c.id === id)
|
|
556
612
|
if (hit) return hit
|
|
557
|
-
return list.find((c) => c && c.id === 'other') || { id: 'other', label: '
|
|
613
|
+
return list.find((c) => c && c.id === 'other') || { id: 'other', label: '其他(拿不准)', action: 'human' }
|
|
558
614
|
}
|
|
559
615
|
|
|
560
616
|
const TOOL_ARG_KEYS = [
|
|
@@ -622,13 +678,13 @@ const EXTRA_CARD_KEYS_EN = [
|
|
|
622
678
|
export const RAW_ARG_LIMIT = 256 * 1024
|
|
623
679
|
|
|
624
680
|
/**
|
|
625
|
-
*
|
|
681
|
+
* 缓存用:尽量保留原文(含空字符串:write content='' 是截断文件)。展示/送审再 clip。
|
|
626
682
|
*/
|
|
627
683
|
export function pickToolArgs(raw) {
|
|
628
684
|
if (!raw || typeof raw !== 'object') return {}
|
|
629
685
|
const out = {}
|
|
630
686
|
for (const key of TOOL_ARG_KEYS) {
|
|
631
|
-
if (typeof raw[key] !== 'string'
|
|
687
|
+
if (typeof raw[key] !== 'string') continue
|
|
632
688
|
out[key] = raw[key].length > RAW_ARG_LIMIT ? raw[key].slice(0, RAW_ARG_LIMIT) : raw[key]
|
|
633
689
|
}
|
|
634
690
|
return out
|
|
@@ -649,7 +705,7 @@ export function clipToolArgsForJudge(args) {
|
|
|
649
705
|
const a = args || {}
|
|
650
706
|
const out = {}
|
|
651
707
|
for (const key of TOOL_ARG_KEYS) {
|
|
652
|
-
if (typeof a[key] !== 'string'
|
|
708
|
+
if (typeof a[key] !== 'string') continue
|
|
653
709
|
const lim = TOOL_ARG_LIMITS[key] || 1000
|
|
654
710
|
out[key] = a[key].length > lim ? a[key].slice(0, lim) + '…' : a[key]
|
|
655
711
|
}
|
|
@@ -671,7 +727,7 @@ export function clipToolArgsForEvent(args) {
|
|
|
671
727
|
const a = args || {}
|
|
672
728
|
const out = {}
|
|
673
729
|
for (const key of TOOL_ARG_KEYS) {
|
|
674
|
-
if (typeof a[key] !== 'string'
|
|
730
|
+
if (typeof a[key] !== 'string') continue
|
|
675
731
|
const lim = EVENT_ARG_LIMITS[key] || 800
|
|
676
732
|
out[key] = a[key].length > lim ? a[key].slice(0, lim) + '…' : a[key]
|
|
677
733
|
}
|
|
@@ -683,7 +739,8 @@ export function hasToolPayload(args) {
|
|
|
683
739
|
const a = args || {}
|
|
684
740
|
return Boolean(
|
|
685
741
|
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
|
|
742
|
+
|| a.code || a.url || a.script || a.sql || a.prompt
|
|
743
|
+
|| a.query || a.input || a.text || a.body || a.message || a.pattern || a.selector,
|
|
687
744
|
)
|
|
688
745
|
}
|
|
689
746
|
|
|
@@ -737,15 +794,45 @@ export function takeCachedCall(map, sessionId, callId) {
|
|
|
737
794
|
|
|
738
795
|
/**
|
|
739
796
|
* 关键词干草。`reason` 参数保留以免改签名,但故意不用:升级理由会误伤允许桶。
|
|
797
|
+
* cwd 只进拒绝/人工干草,不进允许桶:目录名不能把该目录下所有工具放行。
|
|
740
798
|
*/
|
|
741
|
-
|
|
799
|
+
function isAbsoluteKeywordPath(p) {
|
|
800
|
+
const s = String(p || '')
|
|
801
|
+
if (!s) return true
|
|
802
|
+
if (s.startsWith('/') || s.startsWith('\\') || s.startsWith('~')) return true
|
|
803
|
+
return /^[a-zA-Z]:[\\/]/.test(s)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function joinKeywordPath(base, p) {
|
|
807
|
+
const root = String(base || '').replace(/[/\\]+$/, '')
|
|
808
|
+
const rel = String(p || '')
|
|
809
|
+
if (!root || !rel || isAbsoluteKeywordPath(rel)) return ''
|
|
810
|
+
const sep = root.includes('\\') && !root.includes('/') ? '\\' : '/'
|
|
811
|
+
return root + sep + rel
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export function formatKeywordHay(toolName, reason, args, cwd) {
|
|
742
815
|
const a = args || {}
|
|
816
|
+
const extras = []
|
|
817
|
+
const bases = []
|
|
818
|
+
if (cwd) {
|
|
819
|
+
extras.push(cwd)
|
|
820
|
+
bases.push(cwd)
|
|
821
|
+
}
|
|
822
|
+
if (a.workdir && a.workdir !== cwd) bases.push(a.workdir)
|
|
823
|
+
for (const base of bases) {
|
|
824
|
+
for (const p of [a.file_path, a.path]) {
|
|
825
|
+
const joined = joinKeywordPath(base, p)
|
|
826
|
+
if (joined) extras.push(joined)
|
|
827
|
+
}
|
|
828
|
+
}
|
|
743
829
|
return [
|
|
744
830
|
toolName,
|
|
745
831
|
a.command,
|
|
746
832
|
a.file_path,
|
|
747
833
|
a.path,
|
|
748
834
|
a.workdir,
|
|
835
|
+
...extras,
|
|
749
836
|
].filter(Boolean).join('\n')
|
|
750
837
|
}
|
|
751
838
|
|
|
@@ -760,7 +847,15 @@ export function formatAllowKeywordHay(args) {
|
|
|
760
847
|
].filter(Boolean).join('\n')
|
|
761
848
|
}
|
|
762
849
|
|
|
763
|
-
/** 给审核模型看的卡片。含内容/cwd
|
|
850
|
+
/** 给审核模型看的卡片。含内容/cwd;模型理由只作补充。语言与提示词框架一致。空字符串也要展示(截断写入)。 */
|
|
851
|
+
function cardArg(val, en) {
|
|
852
|
+
return val === '' ? (en ? '(empty)' : '(空)') : val
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function hasCardArg(a, key) {
|
|
856
|
+
return typeof a[key] === 'string'
|
|
857
|
+
}
|
|
858
|
+
|
|
764
859
|
export function formatJudgeCard(toolName, mode, justification, args, cwd, lang) {
|
|
765
860
|
const a = clipToolArgsForJudge(args)
|
|
766
861
|
const en = normalizeJudgePromptLang(lang) === 'en'
|
|
@@ -770,16 +865,20 @@ export function formatJudgeCard(toolName, mode, justification, args, cwd, lang)
|
|
|
770
865
|
? [`Tool: ${toolName}`, `Target sandbox: ${sandbox}`]
|
|
771
866
|
: [`工具: ${toolName}`, `目标沙箱模式: ${sandbox}`]
|
|
772
867
|
if (cwd) lines.push((en ? 'Working directory: ' : '工作目录: ') + cwd)
|
|
773
|
-
if (a
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
if (a
|
|
777
|
-
if (a
|
|
778
|
-
|
|
779
|
-
|
|
868
|
+
if (hasCardArg(a, 'workdir') && a.workdir !== cwd) {
|
|
869
|
+
lines.push((en ? 'Command working directory: ' : '命令工作目录: ') + cardArg(a.workdir, en))
|
|
870
|
+
}
|
|
871
|
+
if (hasCardArg(a, 'command')) lines.push(en ? 'Command:' : '命令:', cardArg(a.command, en))
|
|
872
|
+
if (hasCardArg(a, 'file_path') || hasCardArg(a, 'path')) {
|
|
873
|
+
lines.push((en ? 'Path: ' : '路径: ') + cardArg(a.file_path || a.path, en))
|
|
874
|
+
}
|
|
875
|
+
if (hasCardArg(a, 'description')) lines.push((en ? 'Description: ' : '描述: ') + cardArg(a.description, en))
|
|
876
|
+
if (hasCardArg(a, 'old_string')) lines.push(en ? 'Original:' : '原文:', cardArg(a.old_string, en))
|
|
877
|
+
if (hasCardArg(a, 'new_string')) lines.push(en ? 'Replacement:' : '改成:', cardArg(a.new_string, en))
|
|
878
|
+
if (hasCardArg(a, 'content')) lines.push(en ? 'Write contents:' : '写入内容:', cardArg(a.content, en))
|
|
780
879
|
const extra = en ? EXTRA_CARD_KEYS_EN : EXTRA_CARD_KEYS_ZH
|
|
781
880
|
for (const pair of extra) {
|
|
782
|
-
if (a
|
|
881
|
+
if (hasCardArg(a, pair[0])) lines.push(pair[1] + ':', cardArg(a[pair[0]], en))
|
|
783
882
|
}
|
|
784
883
|
lines.push((en ? 'Model justification: ' : '模型理由: ') + (justification || none))
|
|
785
884
|
if (en) lines.push('', 'Classify. Output exactly two lines:', 'Category: <id>', 'Reason: <one sentence>')
|
|
@@ -805,8 +904,9 @@ export function buildJudgePrompt(criteria, lang) {
|
|
|
805
904
|
'Ordinary source/docs/test/build edits outside the workspace are not deletion, credential, remote, system, or bulk by themselves.',
|
|
806
905
|
'If it looks like deletion, credentials, remote, system, or bulk, pick that class.',
|
|
807
906
|
'Changing the auto-approve allowlist, plugin config, or other approval-gating config under ~/.dsh is approval-config, not safe.',
|
|
907
|
+
'Ordinary git push is not remote.',
|
|
808
908
|
'Pick safe only when you can confirm a routine reversible operation.',
|
|
809
|
-
'If
|
|
909
|
+
'If neither a risk row nor safe fits, or you are unsure, pick other. Do not pick safe when unsure.',
|
|
810
910
|
'',
|
|
811
911
|
'Criteria:',
|
|
812
912
|
...lines,
|
|
@@ -825,8 +925,9 @@ export function buildJudgePrompt(criteria, lang) {
|
|
|
825
925
|
'工作区外常规源码/文档/测试/构建编辑本身不算删除/凭据/远程/系统/批量。',
|
|
826
926
|
'像删除/凭据/远程/系统/批量就选该类。',
|
|
827
927
|
'修改 ~/.dsh 下自动审批 allowlist、插件配置或其它审批门控配置选 approval-config,不要当成 safe。',
|
|
928
|
+
'普通 git push 不要选 remote。',
|
|
828
929
|
'只有能确认是常规可回补操作才选 safe。',
|
|
829
|
-
'
|
|
930
|
+
'风险类和 safe 都不符合,或拿不准时选 other。不要因为拿不准就选 safe。',
|
|
830
931
|
'',
|
|
831
932
|
'审核表:',
|
|
832
933
|
...lines,
|
|
@@ -845,7 +946,7 @@ export function parseJudgeClassify(text, criteria) {
|
|
|
845
946
|
const rows = Array.isArray(criteria) && criteria.length ? criteria : DEFAULT_CRITERIA
|
|
846
947
|
const ids = new Set(rows.map((c) => c.id))
|
|
847
948
|
const raw = String(text || '').trim()
|
|
848
|
-
if (!raw)
|
|
949
|
+
if (!raw) codedThrow('err.judgeEmpty')
|
|
849
950
|
const idMatch = raw.match(/(?:^|\n)\s*(?:类别|分类|category)\s*[::]\s*([a-z0-9_-]+)/i)
|
|
850
951
|
let id = idMatch ? String(idMatch[1]).toLowerCase() : ''
|
|
851
952
|
if (!id || !ids.has(id)) {
|
|
@@ -856,7 +957,7 @@ export function parseJudgeClassify(text, criteria) {
|
|
|
856
957
|
}
|
|
857
958
|
}
|
|
858
959
|
if (!id || !ids.has(id)) {
|
|
859
|
-
|
|
960
|
+
codedThrow('err.judgeParse')
|
|
860
961
|
}
|
|
861
962
|
const reasonMatch = raw.match(/(?:^|\n)\s*(?:理由|reason)\s*[::]\s*(.+)/i)
|
|
862
963
|
const reason = reasonMatch ? String(reasonMatch[1]).trim().slice(0, 200) : ''
|
package/src/util.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 路径、JSON、审计、事件日志。无副作用:调用方传入目录。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 规则、审计、插件配置在 ~/.dsh/auto-approve/。
|
|
5
|
+
* 0.1.x 把插件配置放在 ~/.dsh/approval-bridge/config.json;缺失新文件时只迁移判定字段。
|
|
5
6
|
* tryLoadJson 区分缺失与损坏:损坏时调用方不得用默认值覆写磁盘。
|
|
6
7
|
*/
|
|
7
8
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
@@ -14,23 +15,21 @@ export function dshHome() {
|
|
|
14
15
|
return process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
/** auto-approve =
|
|
18
|
+
/** auto-approve = 规则/审计/插件配置;legacyPluginConfig 仅作 0.1.x 迁移源。 */
|
|
18
19
|
export function pathsFor(home = dshHome()) {
|
|
19
20
|
const auto = join(home, 'auto-approve')
|
|
20
21
|
const bridge = join(home, 'approval-bridge')
|
|
21
22
|
return {
|
|
22
23
|
home,
|
|
23
24
|
auto,
|
|
24
|
-
bridge,
|
|
25
25
|
allowlist: join(auto, 'allowlist.json'),
|
|
26
26
|
audit: join(auto, 'audit.log'),
|
|
27
27
|
events: join(auto, 'events.jsonl'),
|
|
28
|
-
pluginConfig: join(
|
|
29
|
-
|
|
28
|
+
pluginConfig: join(auto, 'config.json'),
|
|
29
|
+
legacyPluginConfig: join(bridge, 'config.json'),
|
|
30
30
|
profilePatch: join(home, 'profiles', 'web', 'cordis.patch.yml'),
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
|
|
34
33
|
export function ensureDir(dir) {
|
|
35
34
|
try { mkdirSync(dir, { recursive: true }) } catch { /* ignore */ }
|
|
36
35
|
}
|
|
@@ -83,16 +82,6 @@ export function saveJson(path, data) {
|
|
|
83
82
|
}
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
/** 凭据文件:0600。目录也尽量收紧。 */
|
|
87
|
-
export function saveSecretJson(path, data) {
|
|
88
|
-
try {
|
|
89
|
-
writeAtomic(path, JSON.stringify(data, null, 2) + '\n', 0o600)
|
|
90
|
-
} catch (error) {
|
|
91
|
-
console.error(`[${NAME}] 写入凭据 ${path} 失败`, error)
|
|
92
|
-
throw error
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
85
|
export function appendLine(path, line) {
|
|
97
86
|
try {
|
|
98
87
|
ensureDir(dirname(path))
|
|
@@ -187,56 +176,3 @@ export function maxEventId(eventsPath) {
|
|
|
187
176
|
} catch { /* missing */ }
|
|
188
177
|
return max
|
|
189
178
|
}
|
|
190
|
-
|
|
191
|
-
export function maskSecret(value) {
|
|
192
|
-
const s = String(value || '')
|
|
193
|
-
if (!s) return ''
|
|
194
|
-
if (s.length <= 4) return '****'
|
|
195
|
-
return s.slice(0, 2) + '****' + s.slice(-2)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* 网页审批框用的独立 signal:父 signal 中止时跟着中止;
|
|
200
|
-
* 主动 abort() 只关网页,不碰父 signal(避免整单变成 cancelled)。
|
|
201
|
-
* @param {AbortSignal | undefined} parent
|
|
202
|
-
*/
|
|
203
|
-
export function forkAbortSignal(parent) {
|
|
204
|
-
const child = new AbortController()
|
|
205
|
-
const forward = () => {
|
|
206
|
-
if (child.signal.aborted) return
|
|
207
|
-
try { child.abort(parent && parent.reason) } catch { /* ignore */ }
|
|
208
|
-
}
|
|
209
|
-
if (parent) {
|
|
210
|
-
if (parent.aborted) forward()
|
|
211
|
-
else parent.addEventListener('abort', forward, { once: true })
|
|
212
|
-
}
|
|
213
|
-
return {
|
|
214
|
-
signal: child.signal,
|
|
215
|
-
abort() {
|
|
216
|
-
if (parent) {
|
|
217
|
-
try { parent.removeEventListener('abort', forward) } catch { /* ignore */ }
|
|
218
|
-
}
|
|
219
|
-
forward()
|
|
220
|
-
},
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** 把 req.signal 换成网页专用 signal;失败则 false(网页框可能关不掉)。 */
|
|
225
|
-
export function replaceRequestSignal(req, signal) {
|
|
226
|
-
if (!req || typeof req !== 'object') return false
|
|
227
|
-
try {
|
|
228
|
-
req.signal = signal
|
|
229
|
-
if (req.signal === signal) return true
|
|
230
|
-
} catch { /* readonly */ }
|
|
231
|
-
try {
|
|
232
|
-
Object.defineProperty(req, 'signal', {
|
|
233
|
-
configurable: true,
|
|
234
|
-
enumerable: true,
|
|
235
|
-
writable: true,
|
|
236
|
-
value: signal,
|
|
237
|
-
})
|
|
238
|
-
return req.signal === signal
|
|
239
|
-
} catch {
|
|
240
|
-
return false
|
|
241
|
-
}
|
|
242
|
-
}
|