dsh-jev-guard 0.5.1

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +285 -0
  2. package/CHANGELOG.zh-CN.md +271 -0
  3. package/DEPLOY.md +202 -0
  4. package/DEPLOY.zh-CN.md +200 -0
  5. package/LICENSE +21 -0
  6. package/README.md +316 -0
  7. package/README.zh-CN.md +315 -0
  8. package/START-HERE.md +97 -0
  9. package/START-HERE.zh-CN.md +97 -0
  10. package/adapters/README.md +37 -0
  11. package/adapters/README.zh-CN.md +37 -0
  12. package/adapters/dsh/index.js +502 -0
  13. package/bin/guard.mjs +634 -0
  14. package/config.example.json +52 -0
  15. package/cordis.patch.yml +120 -0
  16. package/docs/AGENT-TASK-dsh.md +134 -0
  17. package/docs/AGENT-TASK-dsh.zh-CN.md +131 -0
  18. package/docs/ARCHITECTURE.md +118 -0
  19. package/docs/ARCHITECTURE.zh-CN.md +117 -0
  20. package/docs/DECISIONS.md +469 -0
  21. package/docs/DECISIONS.zh-CN.md +449 -0
  22. package/docs/DSH-INTEGRATION.md +178 -0
  23. package/docs/DSH-INTEGRATION.zh-CN.md +171 -0
  24. package/docs/MEASUREMENTS.md +433 -0
  25. package/docs/MEASUREMENTS.zh-CN.md +450 -0
  26. package/docs/USER-INTERVENTION.md +141 -0
  27. package/docs/USER-INTERVENTION.zh-CN.md +143 -0
  28. package/docs/VERIFICATION.md +279 -0
  29. package/docs/VERIFICATION.zh-CN.md +278 -0
  30. package/lib/audit.js +228 -0
  31. package/lib/gate.js +720 -0
  32. package/lib/i18n.js +575 -0
  33. package/lib/quota.js +389 -0
  34. package/lib/rules.js +174 -0
  35. package/lib/token.js +154 -0
  36. package/lib/verdict.js +285 -0
  37. package/package.json +82 -0
  38. package/tools/check-doc-pairs.mjs +158 -0
  39. package/tools/extract-commands.mjs +156 -0
  40. package/tools/gate-cli.mjs +240 -0
  41. package/tools/probe-prompt-lang.mjs +238 -0
  42. package/tools/probe-scripts.mjs +143 -0
  43. package/tools/report-result.mjs +146 -0
  44. package/tools/selftest-audit.mjs +93 -0
  45. package/tools/selftest-entry.mjs +177 -0
  46. package/tools/selftest-i18n.mjs +177 -0
  47. package/tools/selftest-quota.mjs +260 -0
  48. package/tools/selftest-reason.mjs +266 -0
  49. package/tools/selftest-rules.mjs +107 -0
  50. package/tools/selftest-token.mjs +100 -0
  51. package/tools/smoke-dsh-adapter.mjs +295 -0
  52. package/tools/smoke-dsh-pipeline.mjs +146 -0
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 拒绝理由自检:那行"交给用户去终端里执行"的授权命令,必须真的能整行复制粘贴。
4
+ *
5
+ * 为什么单独有这份自检:这条理由的读者是**用户**,而且他是在**自己的终端**里粘贴 ——
6
+ * 工作目录是任意的。曾经这里打印的是相对路径 `node bin/guard.mjs`,只有用户恰好站在包目录
7
+ * 里才能用;而授权命令里的命令文本一旦被截断或引号转义写错,用户拿到的令牌就与 AI 重试的
8
+ * 那条命令对不上,表现为"授权了还是被拦"。两者都不会报错,只会静静地失效 —— 所以要断言。
9
+ *
10
+ * 全部离线:用合成 verdict,不联网、不碰 ~/.jev-guard。
11
+ *
12
+ * @module jev-guard/tools/selftest-reason
13
+ */
14
+
15
+ import { execFileSync } from 'node:child_process'
16
+ import { existsSync } from 'node:fs'
17
+ import { isAbsolute, join } from 'node:path'
18
+ import { dirname, resolve } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { setLang } from '../lib/i18n.js'
21
+ import { explain, reviseGuidance, shellName, shellQuote, toHostDecision } from '../lib/verdict.js'
22
+
23
+ // 本文件的断言写的是**中文文案本身**(哪些句子必须出现 / 必须不出现),所以先把语言钉死 ——
24
+ // 否则同一份自检在英文 locale 的机器上会无缘无故地红。英文那一侧由
25
+ // `tools/selftest-i18n.mjs` 覆盖:它断言目录完整性、两语言键集合一致,以及英文理由的形状。
26
+ setLang('zh-CN')
27
+
28
+ let failed = 0
29
+ let checks = 0
30
+
31
+ /**
32
+ * @param label - 用例名。
33
+ * @param ok - 断言结果。
34
+ * @param detail - 失败细节。
35
+ */
36
+ function expect(label, ok, detail = '') {
37
+ checks += 1
38
+ if (!ok) failed += 1
39
+ process.stdout.write(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ` ${detail}`}\n`)
40
+ }
41
+
42
+ /**
43
+ * 打印一条"跳过"说明(不计入断言数)。
44
+ * 为什么要专门区分:一个"在任何平台都假装通过"的断言比没有断言更糟 ——
45
+ * 它会让 reviewer 以为某件事验过了。
46
+ * @param text - 说明文本。
47
+ */
48
+ function note(text) {
49
+ process.stdout.write(`note ${text}\n`)
50
+ }
51
+
52
+ /** 可用来做 POSIX 引号往返的 shell;Windows 上没有,那就跳过并说明。 */
53
+ const POSIX_SHELL = process.platform === 'win32' ? undefined : 'bash'
54
+
55
+ /** 与 adapters/dsh/index.js 里同样的推导,用于断言默认路径落在包内。 */
56
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
57
+
58
+ /** 一个"被 Jev 判为危险"的合成判定 —— 非 L0,所以理由里应当带令牌提示。 */
59
+ const riskVerdict = { action: 'block', source: 'jev', p: 0.78, model: 'jev-1.13.0', ms: 290 }
60
+ /** 一个 L0 硬规则判定 —— 永不允许,理由里**不应**出现令牌提示。 */
61
+ const l0Verdict = { action: 'block', source: 'static-rule', rule: { id: 'mkfs', why: '格式化文件系统', kind: 'deny' } }
62
+
63
+ const CMD = 'rm -rf /home/user/jev-guard-probe-dir'
64
+
65
+ // 1) 授权行用的是绝对路径,且落在包内的 bin/guard.mjs
66
+ const hint = explain(CMD, riskVerdict, { policy: 'never', token: 'ALLOW-0A2DB6157F' })
67
+ const m = hint.match(/ node (\S+) allow ([\s\S]*?) —— 然后重试同一条命令/)
68
+ expect('理由里出现授权行', m !== null, hint)
69
+ const cliPath = m?.[1] ?? ''
70
+ expect('授权行给出的是绝对路径', isAbsolute(cliPath), cliPath)
71
+ expect('绝对路径指向包内的 bin/guard.mjs', cliPath === join(PKG_ROOT, 'bin', 'guard.mjs'), cliPath)
72
+ expect('授权行以 node 开头(可直接粘贴)', / node \S+ allow /.test(hint))
73
+ expect('令牌原文出现在理由里', hint.includes('ALLOW-0A2DB6157F'))
74
+ expect('用户被明确告知"AI 自己跑会被拒"', hint.includes('授权只在交互终端生效'))
75
+
76
+ // 2) 引号转义必须能原样解析回同一条命令(用户粘进 shell 的结果 = AI 重试的那条命令)
77
+ //
78
+ // ⚠️ 这一段用 **bash** 做往返,只在 POSIX 平台适用。Windows 上应当验的是 **PowerShell** 形式
79
+ // (见第 10 节),这里显式**跳过并说明**,而不是让它悄悄失败或悄悄通过 ——
80
+ // 一个"在任何平台都假装通过"的断言比没有断言更糟。
81
+ const quoted = m?.[2] ?? ''
82
+ if (POSIX_SHELL) {
83
+ const back = execFileSync(POSIX_SHELL, ['-c', `printf %s ${quoted}`], { encoding: 'utf8' })
84
+ expect('粘贴后解析回的命令与原文逐字一致', back === CMD, JSON.stringify(back))
85
+ } else {
86
+ note('绕过 bash 的往返用例已跳过(本平台没有 POSIX shell;Windows 走第 10 节的 PowerShell 往返)')
87
+ }
88
+
89
+ // 3) 命令里带单引号 / 双引号 / 中文时同样成立(shellQuote 的单引号转义)
90
+ const tricky = 'rm -rf /home/user/jev-guard-probe-dir && echo "it\'s 探针,含单引号与中文"'
91
+ const trickyHint = explain(tricky, riskVerdict, { policy: 'never', token: 'ALLOW-15537AF182' })
92
+ const trickyQuoted = trickyHint.match(/ allow ([\s\S]*?) —— 然后重试同一条命令/)?.[1] ?? ''
93
+ if (POSIX_SHELL) {
94
+ const trickyBack = execFileSync(POSIX_SHELL, ['-c', `printf %s ${trickyQuoted}`], { encoding: 'utf8' })
95
+ expect('含引号/中文的命令也能原样解析回来', trickyBack === tricky, JSON.stringify(trickyBack))
96
+ } else {
97
+ note('含引号/中文的 bash 往返已跳过(同上)')
98
+ }
99
+
100
+ // 4) 长命令**不得**被截断:令牌是对完整命令文本求哈希的,截断后用户授权就匹配不上。
101
+ // (理由末尾" 命令:<文本>"那一段允许截断到 300 字符,但授权行不行。)
102
+ const longCmd = `rm -rf /home/user/jev-guard-probe-dir/${'sub-dir-'.repeat(50)}`
103
+ const longHint = explain(longCmd, riskVerdict, { policy: 'never', token: 'ALLOW-DEADBEEF00' })
104
+ const longQuoted = longHint.match(/ allow ([\s\S]*?) —— 然后重试同一条命令/)?.[1] ?? ''
105
+ expect('长命令的授权行未被截断', longQuoted.includes('sub-dir-'.repeat(50)) && longQuoted.length > 300, String(longQuoted.length))
106
+ if (POSIX_SHELL) {
107
+ const longBack = execFileSync(POSIX_SHELL, ['-c', `printf %s ${longQuoted}`], { encoding: 'utf8' })
108
+ expect('长命令也能原样解析回来', longBack === longCmd, String(longBack.length))
109
+ } else {
110
+ expect('长命令的授权行长度与原文一致(不经 shell 直接比对)', longQuoted.length >= longCmd.length, `${longQuoted.length} vs ${longCmd.length}`)
111
+ }
112
+
113
+ // 5) L0 硬规则:令牌越过不了硬地板,所以理由里**不该**出现授权行(免得用户白试)
114
+ const l0Hint = explain('mkfs.ext4 /dev/sdb1', l0Verdict, { policy: 'never', token: 'ALLOW-0A2DB6157F' })
115
+ expect('L0 拦截的理由里没有授权提示', !l0Hint.includes('allow ') && !l0Hint.includes('ALLOW-0A2DB6157F'), l0Hint)
116
+ expect('L0 拦截的理由仍说明不是用户手动拒绝', l0Hint.includes('不是用户手动拒绝'))
117
+
118
+ // 6) token 缺失时不给出授权行(没什么可授权的)
119
+ expect('没有令牌时不出现授权提示', !explain(CMD, riskVerdict, { policy: 'never' }).includes('allow '))
120
+
121
+ // 7) 宿主侧映射与理由同源:deny 的 reason 就是 explain 的输出
122
+ const deny = toHostDecision(CMD, riskVerdict, 'never', { token: 'ALLOW-0A2DB6157F' })
123
+ expect('never 策略下映射为 deny', deny.kind === 'deny')
124
+ expect('deny 的理由同样带绝对路径授权行', deny.reason.includes(join(PKG_ROOT, 'bin', 'guard.mjs')) && deny.reason.includes('ALLOW-0A2DB6157F'))
125
+ expect('allow 判定不产生理由', toHostDecision('ls', { action: 'allow', source: 'prefilter' }, 'never').kind === 'allow')
126
+
127
+ // 8) 策略分叉:同一个 escalate,在 never 下是"没有弹窗 + 给令牌",在 ask 下是"已弹审批窗 + 不给令牌"。
128
+ // 实测教训(2026-09-20):ask 模式的弹窗里原样显示了 never 模式那句"本会话没有审批提示",
129
+ // 还附了一条让用户去终端授权的命令 —— 指着眼前的弹窗说没有弹窗。
130
+ const TRUNC = 'truncate -s 0 /home/user/jev-guard-demo/notes.txt'
131
+ /** 一个"必须人工确认"的 L0 ask 判定(kind='ask',与 kind='deny' 的硬地板相对)。 */
132
+ const askRuleVerdict = { action: 'escalate', source: 'static-rule', rule: { id: 'truncate-file', why: '把文件截断/覆盖为空', kind: 'ask' }, token: 'ALLOW-5031AC2085' }
133
+
134
+ const askEsc = toHostDecision(TRUNC, askRuleVerdict, 'ask', { token: 'ALLOW-5031AC2085' })
135
+ expect('ask 策略下 escalate 映射为 ask(宿主会弹审批框)', askEsc.kind === 'ask', askEsc.kind)
136
+ expect('ask 策略的理由说明宿主已发起审批请求', askEsc.reason.includes('审批请求'), askEsc.reason.slice(0, 140))
137
+ expect('ask 策略的理由不再说"本会话没有审批提示"', !askEsc.reason.includes('本会话没有审批提示'))
138
+ expect('ask 策略下不再附令牌授权行(人就在弹窗前面)', !askEsc.reason.includes('ALLOW-5031AC2085'), askEsc.reason.slice(0, 200))
139
+ expect('ask 策略的理由里没有"复制到终端"那句', !askEsc.reason.includes('allow '))
140
+
141
+ const neverEsc = toHostDecision(TRUNC, askRuleVerdict, 'never', { token: 'ALLOW-5031AC2085' })
142
+ expect('never 策略下 escalate 映射为 deny', neverEsc.kind === 'deny', neverEsc.kind)
143
+ expect('never 策略仍然说没有审批提示', neverEsc.reason.includes('本会话没有审批提示'))
144
+ expect('never 策略仍然附令牌授权行', neverEsc.reason.includes('ALLOW-5031AC2085'))
145
+
146
+ // 8b) 模式相关路由(2026-09-20 用户决定,见 docs/DECISIONS.md D13)。
147
+ // 改动前:只有 escalate 会看审批策略,revise / block 一律直接拒绝。审计证据 —— 一天里
148
+ // **9 条 revise 拒绝发生在 approval=ask 模式下**(全是 cp / sed -i / git add 这类维护动作),
149
+ // 人就在场却拿不到弹窗,等于让一个 50% 的判断替人做决定。
150
+ // 改动后:ask 模式下 revise 与 Jev 高分的 block 都转人工审批;never 模式(全自动,没人可问)
151
+ // 保持直接拒绝。**例外**是 L0 的 deny 类硬规则:两种模式都拦死,不弹窗也不发令牌。
152
+ const reviseVerdict = { action: 'revise', source: 'jev', p: 0.58, model: 'jev-1.13.0', ms: 240 }
153
+
154
+ const askBlock = toHostDecision(CMD, riskVerdict, 'ask', { token: 'ALLOW-0A2DB6157F' })
155
+ expect('ask 策略下 Jev 高分的 block 转人工审批', askBlock.kind === 'ask', askBlock.kind)
156
+ expect('ask 策略下 block 的理由说明已发起审批请求', askBlock.reason.includes('审批请求'), askBlock.reason.slice(0, 140))
157
+ expect('ask 策略下 block 不再附令牌授权行(人就在弹窗前面)', !askBlock.reason.includes('ALLOW-0A2DB6157F'))
158
+
159
+ const neverBlock = toHostDecision(CMD, riskVerdict, 'never', { token: 'ALLOW-0A2DB6157F' })
160
+ expect('never 策略下 Jev 高分的 block 仍然是 deny', neverBlock.kind === 'deny', neverBlock.kind)
161
+ expect('never 策略下 block 仍附令牌授权行', neverBlock.reason.includes('ALLOW-0A2DB6157F'))
162
+
163
+ const askRevise = toHostDecision(CMD, reviseVerdict, 'ask', { token: 'ALLOW-0A2DB6157F' })
164
+ expect('ask 策略下 revise 转人工审批', askRevise.kind === 'ask', askRevise.kind)
165
+ expect('ask 策略下 revise 的抬头改成"需要人工确认"', askRevise.reason.includes('需要人工确认'), askRevise.reason.slice(0, 70))
166
+ expect('ask 策略下 revise 不再附令牌授权行', !askRevise.reason.includes('ALLOW-0A2DB6157F'))
167
+
168
+ const neverRevise = toHostDecision(CMD, reviseVerdict, 'never', { token: 'ALLOW-0A2DB6157F' })
169
+ expect('never 策略下 revise 仍然是 deny', neverRevise.kind === 'deny', neverRevise.kind)
170
+ expect('never 策略下 revise 仍附令牌授权行', neverRevise.reason.includes('ALLOW-0A2DB6157F'))
171
+ expect('never 策略下 revise 的抬头仍是"暂缓"', neverRevise.reason.includes('[暂缓'), neverRevise.reason.slice(0, 70))
172
+
173
+ // 例外:L0 的 deny 类硬规则 = 绝对闸门。两种模式都拦死,既不弹窗也不给令牌。
174
+ const askL0 = toHostDecision('mkfs.ext4 /dev/sdb1', l0Verdict, 'ask', { token: 'ALLOW-0A2DB6157F' })
175
+ expect('ask 策略下 L0 硬拒绝**不**转人工(绝对闸门)', askL0.kind === 'deny', askL0.kind)
176
+ expect('L0 硬拒绝的理由里没有令牌授权行', !askL0.reason.includes('ALLOW-0A2DB6157F'))
177
+ expect('L0 硬拒绝的理由仍说"禁止自动执行"', askL0.reason.includes('禁止自动执行'), askL0.reason.slice(0, 170))
178
+
179
+ // 两个路由开关可以单独关掉(配置层就能回退,不必改代码)
180
+ const askReviseOff = toHostDecision(CMD, reviseVerdict, 'ask', { reviseInAskMode: 'deny', token: 'ALLOW-0A2DB6157F' })
181
+ expect('reviseInAskMode=deny 时退回直接拒绝', askReviseOff.kind === 'deny', askReviseOff.kind)
182
+ const askBlockOff = toHostDecision(CMD, riskVerdict, 'ask', { blockInAskMode: 'deny', token: 'ALLOW-0A2DB6157F' })
183
+ expect('blockInAskMode=deny 时退回直接拒绝', askBlockOff.kind === 'deny', askBlockOff.kind)
184
+
185
+ // explain 的抬头也要跟着**路由结果**走(弹窗里显示的就是这句)
186
+ const routedHead = explain(CMD, reviseVerdict, { policy: 'ask', routedToHuman: true })
187
+ expect('已转人工时 revise 的抬头是"需要人工确认"', routedHead.includes('[需要人工确认]'), routedHead.slice(0, 70))
188
+ const deniedHead = explain(CMD, reviseVerdict, { policy: 'ask', routedToHuman: false })
189
+ expect('未转人工时 revise 的抬头仍是"暂缓"', deniedHead.includes('[暂缓'), deniedHead.slice(0, 70))
190
+
191
+ // revise 的三种降级模板**两种出路都要带**:转人工时是弹窗正文,被拒时是给模型的教案。
192
+ const routedGuidance = reviseGuidance(CMD, reviseVerdict, { policy: 'ask', routedToHuman: true })
193
+ expect('转人工的 revise 理由仍带三种降级模板', routedGuidance.includes('可以尝试的更安全形式'), routedGuidance.slice(-200))
194
+ expect('转人工的 revise 理由带令牌以外的降级建议', routedGuidance.includes('只读/演练') || routedGuidance.includes('作用域'))
195
+
196
+ // 9) shellQuote:单引号包裹 + 按**平台**分叉的转义(POSIX `'\''` vs PowerShell `''`)
197
+ expect("shellQuote(POSIX) 转义单引号", shellQuote("a'b", 'linux') === "'a'\\''b'", shellQuote("a'b", 'linux'))
198
+ expect('shellQuote(POSIX) 包裹普通文本', shellQuote('ls -la', 'linux') === "'ls -la'", shellQuote('ls -la', 'linux'))
199
+ expect("shellQuote(win32) 用双写单引号转义", shellQuote("a'b", 'win32') === "'a''b'", shellQuote("a'b", 'win32'))
200
+ expect('shellQuote(win32) 包裹普通文本', shellQuote('ls -la', 'win32') === "'ls -la'", shellQuote('ls -la', 'win32'))
201
+ expect('两种平台的引号**确实不同**(不能互相套用)',
202
+ shellQuote("a'b", 'win32') !== shellQuote("a'b", 'linux'))
203
+ expect('shellName 说得出该开哪个终端', shellName('win32') === 'PowerShell' && shellName('linux').includes('POSIX'), shellName('linux'))
204
+
205
+ // 授权行必须跟着平台走 —— 否则 Windows 用户拿到的是**语法不通**的一行(实测见下)
206
+ const winReason = explain("rm -rf /tmp/it's", riskVerdict, { policy: 'never', token: 'ALLOW-0A2DB6157F', platform: 'win32' })
207
+ expect('win32 平台的理由里用 PowerShell 形式', winReason.includes("'rm -rf /tmp/it''s'"), winReason.slice(winReason.indexOf('命令:') - 120, winReason.indexOf('命令:')))
208
+ expect('win32 平台的理由里点明用 PowerShell', winReason.includes('PowerShell'))
209
+ const posixReason = explain("rm -rf /tmp/it's", riskVerdict, { policy: 'never', token: 'ALLOW-0A2DB6157F', platform: 'linux' })
210
+ expect('linux 平台的理由里仍用 POSIX 形式', posixReason.includes("'rm -rf /tmp/it'\\''s'"), posixReason.slice(0, 80))
211
+
212
+ // 10) 引号的**真机往返**:把转义后的形式喂给真正的 shell,要求逐字节还原。
213
+ // 本机(WSL)有 bash;若还能找到 Windows PowerShell,就顺带把 win32 那一半也验了 ——
214
+ // 这正是"Windows 上引号是另一套语法"这件事的决定性证据。
215
+ const backPosix = execFileSync('bash', ['-c', `printf %s ${shellQuote("a'b c", 'linux')}`], { encoding: 'utf8' })
216
+ expect('POSIX 形式过 bash 往返还原', backPosix === "a'b c", JSON.stringify(backPosix))
217
+
218
+ /**
219
+ * 找一个**能真正执行**的 Windows PowerShell。
220
+ *
221
+ * 候选同时覆盖两种视角,而且**用"跑一下试试"来判断可用性**,不是只看文件在不在:
222
+ * · 在 Windows 上跑本文件时,`pwsh` / `powershell` 就在 PATH 上;
223
+ * · 在 WSL 上跑本文件时,要走 `/mnt/c/...` 下的 .exe(互操作)。
224
+ * 只看 `/mnt/c/...` 会让 Windows 侧的运行**反而跳过**这条最该在 Windows 上做的断言 ——
225
+ * 本自检第一次在 Windows 上跑就是这样跳过的。
226
+ *
227
+ * @returns 可执行名/路径,或 undefined。
228
+ */
229
+ function findWindowsPowerShell() {
230
+ const candidates = [
231
+ process.env.JEV_GUARD_PWSH,
232
+ 'pwsh',
233
+ 'powershell',
234
+ '/mnt/c/Program Files/PowerShell/7/pwsh.exe',
235
+ '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe',
236
+ ].filter(Boolean)
237
+ for (const candidate of candidates) {
238
+ try {
239
+ execFileSync(candidate, ['-NoProfile', '-Command', '[Console]::Out.Write(1)'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
240
+ return candidate
241
+ } catch {
242
+ // 试下一个
243
+ }
244
+ }
245
+ return undefined
246
+ }
247
+
248
+ const pwsh = findWindowsPowerShell()
249
+ if (pwsh) {
250
+ const roundTrip = text => execFileSync(pwsh, ['-NoProfile', '-Command', `[Console]::Out.Write(${shellQuote(text, 'win32')})`], { encoding: 'utf8' })
251
+ const backWin = roundTrip("a'b c")
252
+ expect('PowerShell 形式过真实 PowerShell 往返还原', backWin === "a'b c", JSON.stringify(backWin))
253
+ // 反例:旧的 POSIX 形式在 PowerShell 里**语法都不成立** —— 这就是修复的理由
254
+ let posixInPwshFailed = false
255
+ try {
256
+ execFileSync(pwsh, ['-NoProfile', '-Command', `[Console]::Out.Write(${shellQuote("a'b", 'linux')})`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
257
+ } catch {
258
+ posixInPwshFailed = true
259
+ }
260
+ expect('反例成立:POSIX 形式在 PowerShell 里解析失败(所以必须分叉)', posixInPwshFailed)
261
+ } else {
262
+ expect('PowerShell 往返用例(本机找不到 PowerShell,跳过)', true)
263
+ }
264
+
265
+ process.stdout.write(`\n${failed === 0 ? '全部通过' : `${failed} 项失败`}(${checks} 例)\n`)
266
+ process.exit(failed === 0 ? 0 : 1)
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * L0 规则自检:验证 `where: 'command'` 的锚定语义。
4
+ *
5
+ * 为什么单独一个文件:测试用例里必须出现危险命令的**字面量**,而把它们写进
6
+ * bash 命令文本会被阀门自己拦下(2026-09-20 真发生过两次)。把字面量放进文件、
7
+ * 用 `node tools/selftest-rules.mjs` 调用,就不会经过命令文本扫描。
8
+ *
9
+ * 2026-09-20 第二次修正后,这里同时钉住两个**方向相反**的偏差(同一个根因:锚定只做了一半):
10
+ * · 假阳:命令开头型规则原来全是全文匹配 → "引号里的数据 / 注释 / 赋值 / 代码字符串"也命中。
11
+ * · 漏判:锚定的 `^` 没有 `m` 标志 → 多行脚本(heredoc)与多行 `-c` 里的真命令看不见;
12
+ * 包装器列表也只有 sudo/env/command/nohup/time → `| xargs …`、`timeout 30 …` 全漏。
13
+ * A 组防假阳,B 组防漏判,C 组防"包装器把散文也当成命令",D 组防灾难性回溯。
14
+ *
15
+ * @module jev-guard/tools/selftest-rules
16
+ */
17
+
18
+ import { staticRule, RULE_STATS } from '../lib/rules.js'
19
+
20
+ /** `[命令, 期望命中的规则 id 或不命中(null), 说明]` */
21
+ const CASES = [
22
+ ['git push --force origin main', 'git-force-push', '命令位置:行首'],
23
+ ['cd /tmp && git push --force origin main', 'git-force-push', '命令位置:&& 之后'],
24
+ ['sudo git push -f origin main', 'git-force-push', '命令位置:sudo 包装'],
25
+ ['git push --force-with-lease origin main', null, '--force-with-lease 是安全形式,不该命中'],
26
+ ['echo "git push --force origin main" > /tmp/x', null, '散文:把命令当文本写进参数 → 交给 Jev'],
27
+ ['node tools/report-result.mjs --evidence "远端强推被拦"', null, '散文:中文描述,不该命中'],
28
+
29
+ ['rm -rf /', 'rm-root', '命令位置:行首的根目录删除'],
30
+ ['cd / && rm -rf /', 'rm-root', '命令位置:&& 之后'],
31
+ ['echo "rm -rf / 会毁掉机器" > notes.txt', null, '散文:文档里提到它 → 交给 Jev'],
32
+
33
+ ['find / -name "*.log" -delete', 'find-delete', '命令位置'],
34
+ ['df -h && rm -rf ~', 'rm-rf-home-root', '命令位置:&& 之后(整个家目录)'],
35
+ ['rm -rf ~/Photos', null, '普通子目录:规则只锚"整个家目录",这里交给 Jev 判'],
36
+ ['ls -la', null, '无关命令'],
37
+ ['df -h', null, '无关命令'],
38
+ // 2026-09-20 实拦案例:原先的 `>\s*[^\s|]+\s*$` 分支会把「以重定向结尾」的正常命令
39
+ // 误判成"把文件截断为空",例如下面这几条。现在只认显式的空写形态。
40
+ ['node bin/guard.mjs selftest 2>/dev/null', null, '普通重定向:不该命中'],
41
+ ['echo hello > /tmp/out.txt', null, '普通重定向:不该命中'],
42
+ ['cat log.txt > /tmp/copy.txt', null, '普通重定向:不该命中'],
43
+ ['echo "" > start.sh', 'truncate-file', '显式空写:命中'],
44
+ ['truncate -s 0 /home/user/notes.md', 'truncate-file', '显式截断:命中'],
45
+ ['DROP TABLE users', 'drop-database', '命令位置'],
46
+ ['psql -c "DROP TABLE users"', null, '藏在引号里 → 交给 Jev(实测 p 很高)'],
47
+ ['git reset --hard HEAD~1', 'git-reset-hard', '命令位置'],
48
+ ['kubectl delete namespace production', 'kubectl-delete-ns', '命令位置'],
49
+ ['dd if=/dev/zero of=/dev/sda bs=1M', 'block-device-write', '命令位置'],
50
+ ['mkfs.ext4 /dev/sdb1', 'mkfs', '命令位置'],
51
+
52
+ // ── A 组:数据位不该命中(2026-09-20 第二次修正前,这里全是假阳)────────────────
53
+ ['echo "mkfs.ext4 /dev/sdb1"', null, 'A 数据:双引号参数里提到 → 交给 Jev'],
54
+ ["printf '%s\\n' 'mkfs.ext4 /dev/sdb1'", null, 'A 数据:单引号参数'],
55
+ ['python3 -c "print(\'mkfs.ext4 /dev/sdb1\')"', null, 'A 数据:python -c 里的字符串'],
56
+ ["python3 - <<'PY'\nprint('mkfs.ext4 /dev/sdb1')\nPY", null, 'A 数据:python heredoc 里的字符串(实测假阳原形)'],
57
+ ['c.startswith(\'mkfs.ext4 /dev/sdb1\')', null, 'A 数据:代码里的字符串比较(实测假阳原形)'],
58
+ ['# mkfs.ext4 /dev/sdb1', null, 'A 数据:注释'],
59
+ ["X='mkfs.ext4 /dev/sdb1'", null, 'A 数据:变量赋值'],
60
+ ['grep -rn mkfs.ext4 /dev/sdb1 README.md', null, 'A 数据:grep 参数里提到'],
61
+ ['echo "dd if=/dev/zero of=/dev/sda bs=1M"', null, 'A 数据:引号里的块设备写入'],
62
+
63
+ // ── B 组:真执行必须命中,尤其是多行与包装器(这些在修正前会漏)──────────────────
64
+ ["bash - <<'SH'\ngit push --force origin main\nSH", 'git-force-push', 'B 真执行:多行 heredoc(缺 m 标志时漏)'],
65
+ ['bash -c "\ngit push --force origin main\n"', 'git-force-push', 'B 真执行:多行 bash -c'],
66
+ ["bash - <<'SH'\nrm -rf /\nSH", 'rm-root', 'B 真执行:多行 heredoc'],
67
+ ["bash - <<'SH'\nmkfs.ext4 /dev/sdb1\nSH", 'mkfs', 'B 真执行:多行 heredoc'],
68
+ ["psql - <<'SQL'\nDROP DATABASE prod;\nSQL", 'drop-database', 'B 真执行:多行 heredoc(缺 m 标志时漏)'],
69
+ ['echo x | xargs git push --force origin main', 'git-force-push', 'B 真执行:| xargs 包装'],
70
+ ['echo x | xargs -0 mkfs.ext4 /dev/sdb1', 'mkfs', 'B 真执行:xargs 带选项'],
71
+ ['timeout 30 rm -rf /', 'rm-root', 'B 真执行:timeout 包装'],
72
+ ['nice -n 5 git push --force origin main', 'git-force-push', 'B 真执行:nice 带选项'],
73
+ ['find . -type f -exec mkfs.ext4 /dev/sdb1 {} \\;', 'mkfs', 'B 真执行:find -exec'],
74
+ ['sudo timeout 60 xargs -0 mkfs.ext4 /dev/sdb1', 'mkfs', 'B 真执行:多级包装'],
75
+
76
+ // ── C 组:包装器吞参数只认 ASCII 词/flag/路径,中文散文不该被顺带命中 ─────────────
77
+ ['xargs 删除 mkfs.ext4 /dev/sdb1', null, 'C 散文:包装器后面是中文 → 交给 Jev'],
78
+ ['用 mkfs.ext4 /dev/sdb1 格式化会丢数据', null, 'C 散文:行首不是命令'],
79
+ ]
80
+
81
+ let failed = 0
82
+ process.stdout.write(
83
+ `L0 规则:deny ${RULE_STATS.deny} / ask ${RULE_STATS.ask} / 锚定到命令位置 ${RULE_STATS.anchored}`
84
+ + ` / 全文匹配例外 ${RULE_STATS.anywhere}(应为 2:redirect-to-device 与 fork-bomb)\n\n`,
85
+ )
86
+
87
+ for (const [command, wantId, note] of CASES) {
88
+ const hit = staticRule(command)
89
+ const gotId = hit ? hit.id : null
90
+ const ok = gotId === wantId
91
+ if (!ok) failed += 1
92
+ process.stdout.write(`${ok ? 'ok ' : 'FAIL'} ${String(gotId ?? '—').padEnd(20)} ${command.replace(/\n/g, '⏎').slice(0, 62).padEnd(62)} ${note}\n`)
93
+ }
94
+
95
+ // ── D 组:包装器允许"吞一串参数",必须保证不会灾难性回溯 ──────────────────────────
96
+ // 4KB 的纯包装器前缀(没有规则命中)是最坏输入:正则要尝试各种切分。
97
+ const longInput = `xargs ${'-0 a/b=c '.repeat(400)}ls -la`
98
+ const started = performance.now()
99
+ staticRule(longInput)
100
+ const elapsed = performance.now() - started
101
+ const perfOk = elapsed < 50
102
+ if (!perfOk) failed += 1
103
+ process.stdout.write(`\n${perfOk ? 'ok ' : 'FAIL'} 长输入无灾难性回溯(4KB 包装器前缀,${elapsed.toFixed(1)}ms < 50ms)\n`)
104
+
105
+ const total = CASES.length + 1
106
+ process.stdout.write(failed === 0 ? `\n全部通过(${total} 例,含 1 例性能)\n` : `\n${failed} 例失败 / 共 ${total} 例\n`)
107
+ process.exit(failed === 0 ? 0 : 1)
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 一次性放行令牌自检:绑定命令、一次性、不越过 L0、与 gate 的接线。
4
+ *
5
+ * 全部在临时目录里做(显式 tokenPath),不碰 ~/.jev-guard。
6
+ *
7
+ * @module jev-guard/tools/selftest-token
8
+ */
9
+
10
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+ import { evaluateCommand } from '../lib/gate.js'
14
+ import { explain } from '../lib/verdict.js'
15
+ import { canonicalize, commandToken, consumeToken, grantToken, readTokens, resolveTokenPath, revokeToken } from '../lib/token.js'
16
+
17
+ let failed = 0
18
+ let checks = 0
19
+
20
+ /**
21
+ * @param label - 用例名。
22
+ * @param ok - 断言结果。
23
+ * @param detail - 失败细节。
24
+ */
25
+ function expect(label, ok, detail = '') {
26
+ checks += 1
27
+ if (!ok) failed += 1
28
+ process.stdout.write(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ` ${detail}`}\n`)
29
+ }
30
+
31
+ const dir = await mkdtemp(join(tmpdir(), 'jev-guard-token-'))
32
+ const tokenPath = join(dir, 'allow.txt')
33
+
34
+ const CMD = 'rm -f /home/user/notes.md'
35
+ const OTHER = 'rm -f /home/user/other.md'
36
+
37
+ // 1) 令牌与命令绑定
38
+ expect('同一命令得到同一令牌', commandToken(CMD) === commandToken(CMD))
39
+ expect('空白差异不影响令牌', commandToken('rm -f /x') === commandToken('rm -f /x'))
40
+ expect('不同命令令牌不同', commandToken(CMD) !== commandToken(OTHER))
41
+ expect('令牌形态正确', /^ALLOW-[0-9A-F]{10}$/.test(commandToken(CMD)), commandToken(CMD))
42
+ expect('规范化折叠空白', canonicalize(' a \n b ') === 'a b')
43
+
44
+ // 2) 路径解析:空串回落默认(与 audit 同一教训)
45
+ expect('空 tokenPath → 默认路径', resolveTokenPath({ tokenPath: '' }).endsWith('allow.txt'))
46
+ expect('显式 tokenPath 生效', resolveTokenPath({ tokenPath }) === tokenPath)
47
+
48
+ // 3) 写令牌 → 消费一次 → 第二次失败
49
+ await grantToken(CMD, { tokenPath, note: '自检' })
50
+ expect('令牌已写入', (await readTokens(tokenPath)).includes(commandToken(CMD)))
51
+ const first = await consumeToken(CMD, { tokenPath })
52
+ expect('第一次消费成功', first.ok === true)
53
+ const second = await consumeToken(CMD, { tokenPath })
54
+ expect('第二次消费失败(一次性)', second.ok === false)
55
+ expect('消费后文件里已无该令牌', !(await readTokens(tokenPath)).includes(commandToken(CMD)))
56
+ const persisted = await readFile(tokenPath, 'utf8')
57
+ expect('文件里留有注释便于人工核对', persisted.includes('#') || persisted === '')
58
+
59
+ // 4) 撤销
60
+ await grantToken(CMD, { tokenPath })
61
+ const revoked = await revokeToken(commandToken(CMD), { tokenPath })
62
+ expect('撤销成功', revoked.removed === 1)
63
+ expect('撤销后无令牌', (await readTokens(tokenPath)).length === 0)
64
+
65
+ // 5) 与 gate 接线:L0 硬规则不受令牌影响;可放行的判定可被令牌放行
66
+ const cfg = { apiKey: process.env.TYPESAFE_API_KEY, tokenPath, tokens: true }
67
+
68
+ const l0 = await evaluateCommand('mkfs.ext4 /dev/sdb1', cfg)
69
+ expect('L0 deny 仍然拦截(令牌不越过硬地板)', l0.action === 'block' && l0.source === 'static-rule')
70
+ await grantToken('mkfs.ext4 /dev/sdb1', { tokenPath })
71
+ const l0After = await evaluateCommand('mkfs.ext4 /dev/sdb1', cfg)
72
+ expect('即使给了令牌,L0 deny 依旧拦截', l0After.action === 'block', `实得 ${l0After.action}/${l0After.source}`)
73
+ // L0 deny 连"令牌提示"都不该出现在理由里 —— 给了也放行不了,别误导调用方。
74
+ const l0Reason = explain('mkfs.ext4 /dev/sdb1', l0After, { policy: 'never', token: l0After.token })
75
+ expect('L0 deny 的理由里不出现令牌提示', !l0Reason.includes('ALLOW-'), l0Reason.slice(0, 120))
76
+
77
+ if (process.env.TYPESAFE_API_KEY) {
78
+ // 需要联网的路径:确保同一条命令先被判为 revise/block,再凭令牌放行
79
+ const offline = { ...cfg, timeoutMs: 5000 }
80
+ const probe = 'rm -rf /home/user/jev-guard-token-probe-dir'
81
+ const judged = await evaluateCommand(probe, offline)
82
+ if (judged.action !== 'allow') {
83
+ await grantToken(probe, { tokenPath })
84
+ const withToken = await evaluateCommand(probe, offline)
85
+ expect('令牌把 revise/block 改成放行', withToken.action === 'allow' && withToken.source === 'token',
86
+ `判定=${judged.action} 实得=${withToken.action}/${withToken.source}`)
87
+ expect('放行记录里带被覆盖的原动作', withToken.overridden === judged.action, String(withToken.overridden))
88
+ const replay = await evaluateCommand(probe, offline)
89
+ expect('令牌用掉后不能重放', replay.action !== 'allow' || replay.source !== 'token',
90
+ `实得 ${replay.action}/${replay.source}`)
91
+ } else {
92
+ process.stdout.write(`note 探针命令被判为 allow(p=${judged.p}),跳过"令牌覆盖判定"用例\n`)
93
+ }
94
+ } else {
95
+ process.stdout.write('note 没有 TYPESAFE_API_KEY,跳过联网用例\n')
96
+ }
97
+
98
+ await rm(dir, { recursive: true, force: true })
99
+ process.stdout.write(failed === 0 ? `\n全部通过(${checks} 例)\n` : `\n${failed} 例失败 / 共 ${checks} 例\n`)
100
+ process.exit(failed === 0 ? 0 : 1)