@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.
@@ -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, error: 'permission 条目缺少 presets 键,请手动添加' }
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,不匹配 justification、description、文件正文。
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 的变更,或对外发布(publish/部署);只读查询不算', action: 'reject' },
88
- { id: 'system', label: '系统级路径/配置', description: '以命令/路径为准:/etc、/usr、/boot、/root、服务与防火墙、关机/重启,以及 crontab、shell rc、用户启动项', action: 'reject' },
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: '以命令/路径/内容为准:能确认是常规可回补操作(源码、文档、测试、构建产物、可撤销编辑)。拿不准不要选此项', action: 'allow' },
92
- { id: 'other', label: '其他', description: '以上风险类都不符合,且不能确认是否安全', action: 'human' },
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 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' },
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: 'None of the risk rows apply, and safety cannot be confirmed', action: 'human' },
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
- cfg.version = 17
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 { ok: false, error: '未找到该审核项' }
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 { ok: false, error: '需要 id 或 label' }
399
- if ((allowlist.criteria || []).some((c) => c.id === n.id)) return { ok: false, error: 'id 已存在' }
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 { ok: false, error: '「其他」不可删除' }
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 { ok: false, error: '未找到该审核项' }
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 { ok: false, error: 'criteria 使用 add/set/remove/reset' }
458
+ return fail('err.criteriaOp')
412
459
  }
413
460
 
414
461
  if (kind === 'judgeTimeoutMs') {
415
- if (op !== 'set') return { ok: false, error: `${kind} 使用 set 操作` }
462
+ if (op !== 'set') return fail('err.opMustSet', { kind })
416
463
  const n = Number(value)
417
- if (!Number.isFinite(n) || n <= 0) return { ok: false, error: '无效数值' }
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 { ok: false, error: '关键词不能为空' }
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 { ok: false, error: '未找到该关键词' }
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 { ok: false, error: '未找到该关键词' }
512
+ if (!found) return fail('err.keywordNotFound')
466
513
  return { ok: true, removed: true, auditLine: `CONFIG keywords - ${str}` }
467
514
  }
468
- return { ok: false, error: 'keywords 使用 add/set/remove/reset' }
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 { ok: false, error: '值不能为空' }
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 { ok: false, error: `未知规则类型: ${bucket}` }
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 { ok: false, error: '未找到匹配的规则' }
532
+ if (list.length === before) return fail('err.ruleNotFound')
486
533
  return { ok: true, removed: true, auditLine: `CONFIG ${bucket} - ${str}` }
487
534
  }
488
- return { ok: false, error: `未知操作: ${op}` }
535
+ return fail('err.unknownOp', { op: String(op || '') })
489
536
  }
490
537
 
491
- return { ok: false, error: `未知规则类型: ${kind}` }
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: '其他', action: 'human' }
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
- * 缓存用:尽量保留原文。展示/送审再 clip。
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' || !raw[key]) continue
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' || !a[key]) continue
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' || !a[key]) continue
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
- export function formatKeywordHay(toolName, reason, args) {
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.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)
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[pair[0]]) lines.push(pair[1] + ':', a[pair[0]])
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 none of the above fit and you cannot confirm safety, pick other. Do not pick safe when unsure.',
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
- '以上都不符合且不能确认是否安全时选 other。不要因为拿不准就选 safe。',
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) throw new Error('审核模型输出为空')
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
- throw new Error('审核模型输出无法解析: ' + JSON.stringify(raw.slice(0, 120)))
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
- * 规则在 ~/.dsh/auto-approve/;凭据/插件配置在 ~/.dsh/approval-bridge/(旧目录名,不要挪)。
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 = 规则/审计;approval-bridge = QQ 凭据与插件配置。 */
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(bridge, 'config.json'),
29
- qqbot: join(bridge, 'qqbot.json'),
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
- }