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
package/lib/quota.js ADDED
@@ -0,0 +1,389 @@
1
+ /**
2
+ * 额度与降级 —— 付费判定 API 一定会用完,用完以后阀门必须**变安静,但不装死**。
3
+ *
4
+ * 背景与设计取舍:
5
+ *
6
+ * 1. **额度耗尽不是异常,是必然。** 这是一个收费 API,`HTTP 402` / 余额不足 / 密钥被撤销
7
+ * 迟早会发生。旧行为是"每次调用各自 fail-open 并写一条 `source: error`" —— 功能上没错,
8
+ * 但有三个问题:①每个命令都要打一次注定失败的请求(慢);②日志里堆满 error 记录,
9
+ * 看不出"是偶发抖动还是已经彻底不可用";③**没有任何人会发现它在瞎**。
10
+ *
11
+ * 2. **所以状态是持久的、可读的、有失效时间的。** 一旦判定失败属于"持久性"类
12
+ * (`quota` / `auth` / `no-key`),就写一份 `<JEV_GUARD_HOME>/degraded.json`,
13
+ * 在冷却窗口内**不再发请求**(省钱、省时间),窗口到期后放**一次**探测请求过去:
14
+ * · 探测成功 → 自动恢复(不需要人做任何事);
15
+ * · 探测失败 → 继续降级(失败一次只花一次请求,不会每命令都试)。
16
+ *
17
+ * 3. **降级不等于整条阀门失效。** 默认 `degradePolicy: 'l0-only'` —— 免费的 L0 静态规则
18
+ * 与预筛照常工作,停的只是"要花钱联网"的 L1 语义层。这不是自作主张:那一层不花钱、
19
+ * 不联网、确定性强,正好覆盖最坏的一类(`mkfs` / `dd of=/dev/*` / `git push --force`)。
20
+ * 想连它一起停,设 `degradePolicy: 'off'`。
21
+ *
22
+ * 4. **瞬态失败不降级。** 超时 / 网络抖 / 5xx / 429 只是这一次不通,不该把阀门按下去 15 分钟;
23
+ * 它们照旧逐次 fail-open,但**会被分类记录**,于是 `guard log --stats` 能回答
24
+ * "今天 fail-open 了几次、各是什么原因"。
25
+ *
26
+ * 5. **两种降级:有对象可探测的,和没有的**(2026-09-20,见 docs/DECISIONS.md D15)。
27
+ * · `quota` / `auth` 是**服务侧**状况 → 冷却式:到期放一次探测请求,成功即自动恢复。
28
+ * · `no-key` 是**本地配置**状况 → **粘性**:没密钥时一次 HTTP 都不发,没有"可探测对象",
29
+ * 所以它不靠时间结束,而是靠"密钥出现了"结束(见 gate.js 里读到密钥即自动清除)。
30
+ * 两者都受**作用域**约束:`scope: 'global'` 的服务侧状态压制所有入口;
31
+ * `scope: 'local'` 的本地状态**只压制写下它的那个入口**。这正是"局部问题不该造成全局
32
+ * 失能"的解法 —— 旧版本的做法是干脆不降级,代价是没人看得见(见 KINDS 里的长注释)。
33
+ *
34
+ * @module jev-guard/quota
35
+ */
36
+
37
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
38
+ import { dirname, join } from 'node:path'
39
+ import { HOME_DIR } from './audit.js'
40
+ import { t } from './i18n.js'
41
+
42
+ /** 降级状态文件;`JEV_GUARD_DEGRADED_STATE` 可覆盖(测试/多实例用)。 */
43
+ export const DEFAULT_DEGRADED_PATH = process.env.JEV_GUARD_DEGRADED_STATE ?? join(HOME_DIR, 'degraded.json')
44
+
45
+ /**
46
+ * 失败分类表 —— **只放判定用的东西**。
47
+ *
48
+ * `degraded: true` 的类别 = "这不是抖一下,是人得处理点什么" → 写状态、停联网判定。
49
+ * `cooldownMs` 既是"停止发请求"的时长,也是"下次探测"的间隔。
50
+ *
51
+ * 给人看的 label / hint 不在这里,在 `lib/i18n.js` 的 `quota.<kind>.label|hint`
52
+ * (以前这里还挂着一组 `cliHints`,从头到尾没有任何调用方读取 —— 2026-09-20 清掉,
53
+ * 免得它成为一份不受 i18n 覆盖的隐藏文案)。
54
+ */
55
+ export const KINDS = Object.freeze({
56
+ quota: Object.freeze({ degraded: true, scope: 'global', cooldownMs: 15 * 60 * 1000 }),
57
+ auth: Object.freeze({ degraded: true, scope: 'global', cooldownMs: 30 * 60 * 1000 }),
58
+ // "没解析到密钥"是**本地配置**状况,不是服务状况。它**也要降级**(没有效密钥时必须像
59
+ // 额度耗尽那样明说,而不是每条命令静默 fail-open),但降级方式与服务侧相反:
60
+ // · `sticky: true` —— 一次 HTTP 都不发,没有可探测对象,所以不靠冷却到期结束,
61
+ // 而是靠"密钥出现了"结束(gate.js 读到密钥即自动清除,零请求)。
62
+ // · `scope: 'local'` —— 密钥解析各入口独立(DSH 插件走 ctx.credentials / 环境变量 /
63
+ // 包根 secrets.json,CLI 走环境变量 / 那个文件),而 degraded.json 是**全局共享**的
64
+ // 文件。若写成全局状态,某条入口读不到密钥就会把**密钥其实是好的**其它入口一起按停
65
+ // —— 那是"一个局部问题造成全局失能"。作用域把它的影响限制在写下它的入口内。
66
+ 'no-key': Object.freeze({ degraded: true, sticky: true, scope: 'local', cooldownMs: 0 }),
67
+ 'rate-limit': Object.freeze({ degraded: false, cooldownMs: 60 * 1000 }),
68
+ server: Object.freeze({ degraded: false, cooldownMs: 30 * 1000 }),
69
+ timeout: Object.freeze({ degraded: false, cooldownMs: 0 }),
70
+ network: Object.freeze({ degraded: false, cooldownMs: 0 }),
71
+ shape: Object.freeze({ degraded: false, cooldownMs: 0 }),
72
+ unknown: Object.freeze({ degraded: false, cooldownMs: 0 }),
73
+ })
74
+
75
+ /**
76
+ * 一个类别的可读名字(当前语言)。
77
+ * @param kind - 分类名。
78
+ * @returns 给人看的一行;未知类别原样回显分类名。
79
+ */
80
+ export function kindLabel(kind) {
81
+ const key = `quota.${kind}.label`
82
+ return t(key) === key ? String(kind ?? '') : t(key)
83
+ }
84
+
85
+ /**
86
+ * 一个类别的处置建议(当前语言)。
87
+ * @param kind - 分类名。
88
+ * @returns 一行建议;未知类别退回 `unknown` 的建议。
89
+ */
90
+ export function kindHint(kind) {
91
+ const key = `quota.${kind}.hint`
92
+ return t(key) === key ? t('quota.unknown.hint') : t(key)
93
+ }
94
+
95
+ /** 会触发"降级状态"的分类。 */
96
+ export const DEGRADING_KINDS = Object.freeze(Object.keys(KINDS).filter(k => KINDS[k].degraded))
97
+
98
+ /**
99
+ * 一个分类的作用域:`'global'`(服务侧,压制所有入口)或 `'local'`(本地配置,只压制写它的入口)。
100
+ * @param kind - 分类名。
101
+ * @returns 作用域名;未知分类按 `global`(宁可多压制一个未知状态,也不要让它漏过)。
102
+ */
103
+ export function kindScope(kind) {
104
+ return KINDS[kind]?.scope === 'local' ? 'local' : 'global'
105
+ }
106
+
107
+ /**
108
+ * 这个状态是不是"粘性"的(= 不靠时间结束,靠条件消失结束)。
109
+ *
110
+ * 同时看状态自身与分类表:老状态文件里没有 `sticky` 字段,按分类表认出来才不会误判成
111
+ * "冷却已经过期了,可以再去问一次" —— 那会让没密钥的部署每个命令都重进一次判定。
112
+ *
113
+ * @param state - 降级状态。
114
+ * @returns true = 忽略 `until`,永不探测。
115
+ */
116
+ export function isSticky(state) {
117
+ if (!state) return false
118
+ return state.sticky === true || KINDS[state.kind]?.sticky === true
119
+ }
120
+
121
+ /**
122
+ * 这个状态是否适用于某个入口(作用域匹配)。
123
+ * `global` 状态压制所有入口;`local` 状态只压制写下它的入口。
124
+ * @param state - 降级状态。
125
+ * @param scope - 入口身份(如 `'dsh-adapter'` / `'cli'`);不传 = 只认服务侧状态。
126
+ * @returns true = 该入口应当遵守这个状态。
127
+ */
128
+ export function appliesTo(state, scope = undefined) {
129
+ if (!state) return false
130
+ const written = typeof state.scope === 'string' && state.scope !== ''
131
+ ? state.scope
132
+ : (kindScope(state.kind) === 'local' ? 'local' : 'global')
133
+ return written === 'global' || written === scope
134
+ }
135
+
136
+ /**
137
+ * 按失败类别返回冷却时长(可被配置覆盖)。
138
+ * @param kind - 分类名。
139
+ * @param cfg - 可选 `{ quotaCooldownMs, authCooldownMs }`。
140
+ * @returns 毫秒。
141
+ */
142
+ export function cooldownMs(kind, cfg = {}) {
143
+ if (kind === 'quota') return Number(cfg.quotaCooldownMs ?? KINDS.quota.cooldownMs)
144
+ if (kind === 'auth') return Number(cfg.authCooldownMs ?? KINDS.auth.cooldownMs)
145
+ // 粘性分类靠"条件消失"结束,不靠时间 —— 冷却时长对它没有意义。
146
+ if (KINDS[kind]?.sticky === true) return 0
147
+ return Number(KINDS[kind]?.cooldownMs ?? 0)
148
+ }
149
+
150
+ /**
151
+ * 把人话/异常翻译成分类。
152
+ *
153
+ * 为什么用关键词兜底:额度耗尽的 HTTP 形状各家不同(有 402,也有 429 + "insufficient credits"),
154
+ * 我们**不能假设**自己猜对了。所以规则是:状态码能定就定;定不了就看正文关键词;
155
+ * 还定不了就是 `unknown`(不降级,只逐次放行并留痕)。宁可少降级,不要误降级 —— 误降级会让
156
+ * 阀门在额度充足时也停止防护。
157
+ *
158
+ * @param error - 抛出的异常(带 `status` / `code` / `name` / `message`)。
159
+ * @returns `{ kind, status?, detail }`。
160
+ */
161
+ export function classifyFailure(error) {
162
+ const status = Number(error?.status) || undefined
163
+ const code = typeof error?.code === 'string' ? error.code : undefined
164
+ const name = String(error?.name ?? '')
165
+ const text = `${code ?? ''} ${error?.body ?? ''} ${error?.message ?? ''}`.toLowerCase()
166
+ const detail = String(error?.message ?? error).slice(0, 300)
167
+
168
+ if (code === 'no-key') return { kind: 'no-key', detail }
169
+ if (code === 'shape') return { kind: 'shape', detail }
170
+ if (status === 402) return { kind: 'quota', status, detail }
171
+ if (status === 401 || status === 403) return { kind: 'auth', status, detail }
172
+ if (status === 429) {
173
+ // 429 有两种含义:限流(等一下就好)与额度耗尽(得充钱)。正文能区分才降级。
174
+ return { kind: /quota|credit|insufficient|balance|payment|billing|exceed/.test(text) ? 'quota' : 'rate-limit', status, detail }
175
+ }
176
+ if (status !== undefined && status >= 500) return { kind: 'server', status, detail }
177
+ if (name === 'TimeoutError' || name === 'AbortError') return { kind: 'timeout', detail }
178
+ if (/quota|credit|insufficient|balance|payment|billing/.test(text)) return { kind: 'quota', status, detail }
179
+ if (/enotfound|econnrefused|econnreset|etimedout|fetch failed|network|socket/.test(text)) return { kind: 'network', detail }
180
+ return { kind: 'unknown', status, detail }
181
+ }
182
+
183
+ /**
184
+ * 解析降级状态文件路径(空串/纯空白 = 未配置 → 默认路径,与 audit 的教训一致)。
185
+ * @param options - `{ degradedPath }`。
186
+ * @returns 实际路径。
187
+ */
188
+ export function resolveDegradedPath(options = {}) {
189
+ const configured = options?.degradedPath
190
+ return typeof configured === 'string' && configured.trim() !== '' ? configured : DEFAULT_DEGRADED_PATH
191
+ }
192
+
193
+ /**
194
+ * 读降级状态。**任何异常都当作"没有降级"**(读不出来时宁可去问一次 API,也不要卡在降级态)。
195
+ *
196
+ * 校验必须走 `untilMs()` 而不是 `Number(until)`:`until` 存的是 ISO 字符串,`Number()` 对它
197
+ * 恒为 NaN,于是"写进去了却永远读不出来"。本模块第一版就是这样 —— 不抛异常、不报错,
198
+ * 只是整个降级机制静默失效(冒烟测试当场抓到)。
199
+ *
200
+ * @param options - `{ degradedPath }`。
201
+ * @returns 状态对象,或 null。
202
+ */
203
+ export async function readDegraded(options = {}) {
204
+ const path = resolveDegradedPath(options)
205
+ try {
206
+ const raw = JSON.parse(await readFile(path, 'utf8'))
207
+ if (!raw || typeof raw !== 'object' || typeof raw.kind !== 'string') return null
208
+ const until = untilMs(raw)
209
+ if (!Number.isFinite(until)) return null
210
+ // 归一成毫秒数,调用方不必再关心存的是字符串还是数字。
211
+ return { ...raw, until, path }
212
+ } catch {
213
+ return null
214
+ }
215
+ }
216
+
217
+ /**
218
+ * 进入(或续期)降级状态。
219
+ * @param kind - 失败分类。
220
+ * @param options - `{ error, detail, status, cfg, now, scope, probe }`;`scope` 是入口身份,
221
+ * 只对 `local` 分类有意义(它决定这份状态会压制谁)。
222
+ * @returns 写入后的状态对象(即使写盘失败也返回,调用方照常降级)。
223
+ */
224
+ export async function enterDegraded(kind, options = {}) {
225
+ const now = Number(options.now ?? Date.now())
226
+ const cfg = options.cfg ?? {}
227
+ const path = resolveDegradedPath(cfg)
228
+ const cooldown = cooldownMs(kind, cfg)
229
+ const sticky = KINDS[kind]?.sticky === true
230
+ // 服务侧状态一律 global(它是整个服务的事);本地状态记下"谁写的",作用域过滤据此
231
+ // 把它限制在那条入口内。调用方没给身份时记 'local' —— 那个值压制不了任何入口,
232
+ // 也就是"宁可少压制,不要误压制"。
233
+ const scope = kindScope(kind) === 'local' ? String(options.scope ?? 'local') : 'global'
234
+ const previous = await readDegraded(cfg)
235
+ // 计数只在"同类别 + 同入口"时延续,否则两份无关的失败会被累加成一条假历史。
236
+ const sameKind = previous?.kind === kind && (previous.scope ?? 'global') === scope
237
+ const state = {
238
+ kind,
239
+ scope,
240
+ sticky,
241
+ // 落盘的 label 只是给"人肉读 degraded.json"用的快照;**显示**一律现查 kindLabel(),
242
+ // 否则语言一换,旧文件里另一种语言的标签会被原样打印出来。
243
+ label: kindLabel(kind) || kind,
244
+ since: sameKind ? previous.since : new Date(now).toISOString(),
245
+ until: new Date(now + cooldown).toISOString(),
246
+ cooldownMs: cooldown,
247
+ failures: (sameKind ? Number(previous.failures ?? 0) : 0) + 1,
248
+ probes: Number(previous?.probes ?? 0) + (options.probe ? 1 : 0),
249
+ status: options.status,
250
+ detail: String(options.detail ?? options.error ?? '').slice(0, 300),
251
+ policy: cfg.degradePolicy ?? 'l0-only',
252
+ at: new Date(now).toISOString(),
253
+ }
254
+ try {
255
+ await mkdir(dirname(path), { recursive: true })
256
+ const tmp = `${path}.tmp`
257
+ await writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`)
258
+ await rename(tmp, path)
259
+ } catch {
260
+ // 写不进去也要降级(内存里这次仍然按降级处理),只是下次可能会再试一次 API。
261
+ }
262
+ // 与 readDegraded 保持同一形状:`until` 对外一律是毫秒数(存储里是 ISO,便于人肉阅读)。
263
+ return { ...state, until: now + cooldown, path }
264
+ }
265
+
266
+ /**
267
+ * 清除降级状态(探测成功后自动调用;也可由 `guard status --clear` 手动调用)。
268
+ * @param options - `{ degradedPath }`。
269
+ * @returns true = 确实清掉了一个状态文件。
270
+ */
271
+ export async function clearDegraded(options = {}) {
272
+ const path = resolveDegradedPath(options)
273
+ try {
274
+ const { unlink } = await import('node:fs/promises')
275
+ await unlink(path)
276
+ return true
277
+ } catch {
278
+ return false
279
+ }
280
+ }
281
+
282
+ /**
283
+ * `until` 的毫秒表示(ISO 字符串;兼容直接存数字的老状态文件)。
284
+ * @param state - 降级状态。
285
+ * @returns 毫秒时间戳,或 NaN。
286
+ */
287
+ function untilMs(state) {
288
+ const raw = state?.until
289
+ if (typeof raw === 'number') return raw
290
+ return Date.parse(String(raw ?? ''))
291
+ }
292
+
293
+ /**
294
+ * 现在是否处于降级窗口内。
295
+ * @param state - readDegraded() 的结果。
296
+ * @param now - 当前毫秒时间戳。
297
+ * @param scope - 本入口的身份;本地状态只压制写下它的入口(不传 = 只认服务侧状态)。
298
+ * @returns true = 应当跳过联网判定。
299
+ */
300
+ export function isDegraded(state, now = Date.now(), scope = undefined) {
301
+ if (!state) return false
302
+ if (!appliesTo(state, scope)) return false
303
+ // 粘性状态没有"窗口"这回事:它一直有效,直到它描述的条件消失(见 isSticky)。
304
+ if (isSticky(state)) return true
305
+ const until = untilMs(state)
306
+ return Number.isFinite(until) ? until > now : false
307
+ }
308
+
309
+ /**
310
+ * 降级窗口是否已到期(= 下一次调用可以作为探测请求)。
311
+ * @param state - readDegraded() 的结果。
312
+ * @param now - 当前毫秒时间戳。
313
+ * @param scope - 本入口的身份(同 isDegraded)。
314
+ * @returns true = 放一次探测请求。
315
+ */
316
+ export function probeDue(state, now = Date.now(), scope = undefined) {
317
+ if (!state) return false
318
+ if (!appliesTo(state, scope)) return false
319
+ // 粘性状态**永不探测**:没密钥时一次 HTTP 都不发,没有"可探测对象"可放。
320
+ // 放它过去只会让每条命令都重新撞一次 no-key,把降级变成刷屏。
321
+ if (isSticky(state)) return false
322
+ const until = untilMs(state)
323
+ return Number.isFinite(until) ? until <= now : true
324
+ }
325
+
326
+ /**
327
+ * 距离自动探测还有多少毫秒(粘性状态恒为 0:它不等时间)。
328
+ * @param state - 降级状态。
329
+ * @param now - 当前时间。
330
+ * @returns 毫秒(最小 0)。
331
+ */
332
+ export function retryInMs(state, now = Date.now()) {
333
+ if (isSticky(state)) return 0
334
+ const until = untilMs(state)
335
+ return Number.isFinite(until) ? Math.max(0, until - now) : 0
336
+ }
337
+
338
+ /**
339
+ * 一行告警文本(会出现在拒绝理由、CLI 输出、审计记录里)。
340
+ * @param state - 降级状态。
341
+ * @param now - 当前时间。
342
+ * @returns 单行文本。
343
+ */
344
+ export function warningLine(state, now = Date.now()) {
345
+ if (!state) return ''
346
+ const common = {
347
+ label: kindLabel(state.kind) || state.label,
348
+ since: String(state.since ?? '').slice(11, 19),
349
+ failures: state.failures,
350
+ policy: t(state.policy === 'off' ? 'quota.policy.off' : 'quota.policy.l0-only'),
351
+ }
352
+ // 粘性状态没有"多少分钟后自动恢复"这句话 —— 对它说"等 0 分钟"等于骗人,所以换一条文案。
353
+ if (isSticky(state)) return t('quota.warning.sticky', common)
354
+ return t('quota.warning', { ...common, mins: Math.round(retryInMs(state, now) / 60000) })
355
+ }
356
+
357
+ /**
358
+ * 多行状态报告(`guard status` 与适配器的日志都用它)。
359
+ * @param state - 降级状态,或 null(健康)。
360
+ * @param now - 当前时间。
361
+ * @param extra - `{ judgeUrl, apiKeyPresent }` 之类的补充信息。
362
+ * @returns 可直接打印的文本。
363
+ */
364
+ export function statusText(state, now = Date.now(), extra = {}) {
365
+ if (!state) {
366
+ return [
367
+ t('quota.status.ok'),
368
+ t('quota.status.ok.layers'),
369
+ extra.apiKeyPresent === false ? t('quota.status.ok.noKey') : '',
370
+ ].filter(Boolean).join('\n')
371
+ }
372
+ const mins = Math.round(retryInMs(state, now) / 60000)
373
+ const sticky = isSticky(state)
374
+ return [
375
+ t('quota.status.degraded.title', { kind: state.kind }),
376
+ t('quota.status.degraded.reason', { label: kindLabel(state.kind) || state.label }),
377
+ t('quota.status.degraded.counters', { since: state.since, failures: state.failures, probes: state.probes ?? 0 }),
378
+ // 粘性状态不是在"等一个窗口",所以不打印倒计时,直接说清它什么时候结束。
379
+ sticky ? t('quota.status.degraded.sticky') : t('quota.status.degraded.recovery', { mins }),
380
+ t('quota.status.degraded.now', {
381
+ now: t(state.policy === 'off' ? 'quota.status.degraded.now.off' : 'quota.status.degraded.now.l0-only'),
382
+ }),
383
+ t('quota.status.degraded.action', { hint: kindHint(state.kind) }),
384
+ // 本地状态只影响写下它的那条入口 —— 不说清楚,别的入口会以为自己也坏了。
385
+ state.scope && state.scope !== 'global' ? t('quota.status.degraded.scope', { scope: state.scope }) : '',
386
+ state.detail ? t('quota.status.degraded.detail', { detail: state.detail }) : '',
387
+ t('quota.status.degraded.retry'),
388
+ ].filter(Boolean).join('\n')
389
+ }
package/lib/rules.js ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * L0 — deterministic hard rules.
3
+ *
4
+ * This layer exists because the semantic judge (L1) depends on the network, on a
5
+ * third-party model, and on the caller's honesty. Anything that must NEVER happen
6
+ * has to be decided here: no network, no model, no override, same answer every
7
+ * time. Rules are intentionally conservative and pattern-based; they are the
8
+ * cheapest layer and the one that keeps working when everything else is down.
9
+ *
10
+ * Verdicts:
11
+ * 'deny' — never allow (the operation destroys something unrecoverable, or
12
+ * destroys the ability to recover).
13
+ * 'ask' — always confirm with a human, regardless of what L1 thinks.
14
+ *
15
+ * Edit this file to taste: every rule carries an `id`, a regex, a `why`, and
16
+ * optionally `where: 'command'`.
17
+ */
18
+
19
+ /**
20
+ * @typedef {{ id: string, re: RegExp, why: Record<'zh-CN'|'en', string>, where?: 'command' | 'anywhere' }} Rule
21
+ *
22
+ * `why` 是**双语对象**而不是一句话:这条理由会出现在拒绝理由、`guard rules` 清单与
23
+ * 审计复盘里,读者可能是中文会话的人,也可能是英文部署里的人或模型。两种语言都写在
24
+ * 规则自己旁边(而不是集中到 i18n 目录),因为一条规则是一个自洽单元 ——
25
+ * 改正则的人顺手就能改理由。缺语言的规则会被 `tools/selftest-i18n.mjs` 判失败。
26
+ *
27
+ * `where: 'command'`(**默认应当是这个**)表示规则只在 COMMAND POSITION 命中:行首、或紧跟
28
+ * `;` `&` `|` `(` `$(` 与反引号之后、或紧跟 `bash -c "` 这类"把字符串当脚本执行"的引号之后;
29
+ * 中间允许一串包装器(`sudo` / `timeout 30` / `xargs -0` / `find … -exec` …)。
30
+ *
31
+ * `where: 'anywhere'` 是**显式例外**:只有那两个模式结构上无法锚定(以运算符 `>` 或
32
+ * 纯语法 `:(){…};:` 开头)的规则才该用它。写别的规则时不要用 —— 全文匹配会在"引号里的
33
+ * 数据、注释、变量赋值、代码字符串"里命中,把操作者自己的正当操作拦下来(2026-09-20 实测)。
34
+ *
35
+ * Why anchoring exists: on 2026-09-20 this valve blocked its own operator twice — once
36
+ * for passing a rule description as a CLI argument, once for writing a test case
37
+ * inside a bash heredoc. Both times the dangerous phrase was *prose*, not a call.
38
+ * Real invocations sit at a command position; commands hidden inside quotes are
39
+ * still covered by Jev (measured p for prose: 0.02–0.08; for the real thing via
40
+ * Jev: 0.8–1.0), so nothing meaningful is lost by anchoring.
41
+ *
42
+ * 2026-09-20 第二次修正(两个方向的偏,同一个根因 —— 锚定只做了一半):
43
+ * · 假阳:21 条 deny 里只有 7 条锚定,`mkfs` 这类命令开头型规则仍在全文匹配,
44
+ * 于是 `python3 -c "print('mkfs.ext4 /dev/sdb1')"` 这种**数据**也会被 deny。
45
+ * · 漏判:锚定用的 `^` **没有 `m` 标志**,所以"命令位置"实际只等于整串开头 ——
46
+ * 多行脚本(heredoc)与多行 `-c` 里的真命令全看不见,而 L0 的全部意义就是
47
+ * 在降级(l0-only,没有 Jev 兜底)时抓住这一类。
48
+ * 两者一起修:命令开头型规则统一锚定,锚定加 `m`,包装器列表扩到常见前缀。
49
+ * 边界矩阵在 `tools/selftest-rules.mjs`(18 种形态)。
50
+ */
51
+
52
+ import { FALLBACK_LANG, getLang } from './i18n.js'
53
+
54
+ /** `bash -c "…"`:把引号里的字符串当脚本执行 —— 那里面是一个命令位置。 */
55
+ const SHELL_C = String.raw`(?:bash|sh|zsh|dash|ksh|pwsh|powershell|cmd)(?:\.exe)?\s+-[a-z]*c\s*["']`
56
+
57
+ /**
58
+ * 包装器:`sudo mkfs`、`timeout 30 mkfs`、`xargs -0 mkfs`、`nice -n 5 mkfs`、`find … -exec mkfs`。
59
+ * 吞掉的"参数"只允许 ASCII 词/flag/路径字符 —— 刻意的:中文散文里出现 `xargs … mkfs`
60
+ * 这种句子不该命中(散文交给 Jev,p 只有 0.02–0.08)。
61
+ */
62
+ const WRAPPERS = String.raw`(?:sudo|doas|env|command|nohup|time|nice|ionice|setsid|stdbuf|watch|timeout|xargs|parallel|find)\b\s*(?:[-\w./=:$~]+\s+)*`
63
+
64
+ /** 命令位置前缀:行首(`m` 标志下即每行行首)、分隔符之后、`$(` 之后、`-c "` 之后,再加可选包装器。 */
65
+ const COMMAND_POSITION = String.raw`(?:^|[;&|(` + '`' + String.raw`]\s*|\$\(\s*|` + SHELL_C + String.raw`)(?:` + WRAPPERS + String.raw`)?`
66
+
67
+ /** 缓存:同一条规则只构造一次"命令位置"版本的正则。 */
68
+ const anchoredCache = new WeakMap()
69
+
70
+ /**
71
+ * 取一条规则实际用于匹配的正则。
72
+ * @param rule - 规则对象。
73
+ * @returns 用于测试的正则。
74
+ */
75
+ function ruleRegex(rule) {
76
+ if (rule.where !== 'command') return rule.re
77
+ let re = anchoredCache.get(rule)
78
+ if (re === undefined) {
79
+ // `m` 必须加:否则 `^` 只匹配整串开头,多行命令里第二行起的命令位置全部失效。
80
+ const flags = rule.re.flags.includes('m') ? rule.re.flags : `${rule.re.flags}m`
81
+ re = new RegExp(COMMAND_POSITION + rule.re.source, flags)
82
+ anchoredCache.set(rule, re)
83
+ }
84
+ return re
85
+ }
86
+
87
+ /** Operations with no legitimate "oops I meant that" story. */
88
+ export const DENY_RULES = /** @type {Rule[]} */ ([
89
+ { id: 'block-device-write', where: 'command', re: /\bdd\b[^\n]*\bof=\/dev\/(sd|nvme|vd|hd|mmcblk)/i, why: { 'zh-CN': '直接写块设备,会不可逆地覆盖磁盘内容', en: 'writes straight to a block device, irreversibly overwriting the disk' } },
90
+ { id: 'mkfs', where: 'command', re: /\bmkfs(\.\w+)?\b[^\n]*\/dev\//i, why: { 'zh-CN': '格式化文件系统', en: 'formats a filesystem' } },
91
+ // 仅有的两条 anywhere:模式以运算符 / 纯语法开头,锚定到"命令位置"没有意义。
92
+ { id: 'redirect-to-device', where: 'anywhere', re: />\s*\/dev\/(sd|nvme|vd|hd)/i, why: { 'zh-CN': '把输出重定向进块设备(模式以 > 开头,无法锚定)', en: 'redirects output into a block device (the pattern starts with >, so it cannot be anchored)' } },
93
+ { id: 'no-preserve-root', where: 'command', re: /rm\s+[^\n]*--no-preserve-root/i, why: { 'zh-CN': '带 --no-preserve-root 的递归删除', en: 'recursive delete with --no-preserve-root' } },
94
+ { id: 'rm-root', where: 'command', re: /rm\s+-[a-z]*\s*\/(\s|$)/i, why: { 'zh-CN': '删除文件系统根目录', en: 'deletes the filesystem root' } },
95
+ { id: 'fork-bomb', where: 'anywhere', re: /:\(\)\s*\{[^\n]*\}\s*;\s*:/, why: { 'zh-CN': 'fork 炸弹,会耗死机器(纯语法模式,无法锚定)', en: 'fork bomb, exhausts the machine (a pure-syntax pattern, cannot be anchored)' } },
96
+ { id: 'drop-database', where: 'command', re: /\bDROP\s+(DATABASE|SCHEMA|TABLE)\b/i, why: { 'zh-CN': '删除数据库/表', en: 'drops a database/table' } },
97
+ { id: 'truncate-database', where: 'command', re: /\bTRUNCATE\s+TABLE\b/i, why: { 'zh-CN': '清空表数据', en: 'empties a table' } },
98
+ { id: 'git-force-push', where: 'command', re: /git\s+push[^\n]*(\s--force(?!-with-lease)\b|\s-f\b)/i, why: { 'zh-CN': '强制推送到远端,会抹掉远端历史(此项远端不可恢复)', en: 'force-pushes to the remote, erasing remote history (this one is unrecoverable on the remote)' } },
99
+ { id: 'git-history-rewrite', where: 'command', re: /git\s+(filter-branch|filter-repo)\b/i, why: { 'zh-CN': '重写 git 历史', en: 'rewrites git history' } },
100
+ { id: 'shred-keys', where: 'command', re: /\bshred\b[^\n]*(\.ssh|\/dev\/)/i, why: { 'zh-CN': '不可恢复地销毁私钥/设备', en: 'irreversibly destroys a private key / a device' } },
101
+ { id: 'chmod-root', where: 'command', re: /chmod\s+-R\s+[0-7]{3,4}\s+\/(\s|$)/i, why: { 'zh-CN': '递归改根目录权限', en: 'recursively changes permissions on the root directory' } },
102
+ { id: 'shadow-copy-delete', where: 'command', re: /\bvssadmin\b[^\n]*delete\s+shadows/i, why: { 'zh-CN': '删除卷影副本,会毁掉还原点', en: 'deletes shadow copies, destroying restore points' } },
103
+ { id: 'backup-delete', where: 'command', re: /\bwbadmin\b[^\n]*delete\b/i, why: { 'zh-CN': '删除系统备份', en: 'deletes system backups' } },
104
+ { id: 'cipher-wipe', where: 'command', re: /\bcipher\s+\/w/i, why: { 'zh-CN': '不可恢复地擦除磁盘空闲空间', en: 'irreversibly wipes the free space on a disk' } },
105
+ // 原来的 `^\s*` 在加上外层锚定后会与"分隔符之后也算命令位置"打架(`cd /x && diskpart` 不命中),故去掉内层 ^。
106
+ { id: 'diskpart', where: 'command', re: /\bdiskpart\b/i, why: { 'zh-CN': '磁盘分区操作,极易误伤整盘', en: 'disk partitioning; very easy to hit the whole disk by mistake' } },
107
+ { id: 'wsl-unregister', where: 'command', re: /wsl(\.exe)?\s+--unregister/i, why: { 'zh-CN': '注销 WSL 发行版,会删除整个发行版文件系统', en: 'unregisters a WSL distribution, deleting its entire filesystem' } },
108
+ { id: 'kubectl-delete-ns', where: 'command', re: /kubectl\s+delete\s+(ns|namespace|pv|pvc)\b/i, why: { 'zh-CN': '删除命名空间/持久卷,会连带删除其中的数据', en: 'deletes a namespace/persistent volume, taking the data inside with it' } },
109
+ { id: 'ps-clear-disk', where: 'command', re: /\b(Clear-Disk|Format-Volume|Initialize-Disk)\b/i, why: { 'zh-CN': 'PowerShell 磁盘级破坏操作', en: 'a PowerShell disk-level destructive operation' } },
110
+ { id: 'ps-remove-item-drive-root', where: 'command', re: /Remove-Item[^\n]*-Recurse[^\n]*\b[A-Za-z]:\\\s*($|[^*])/i, why: { 'zh-CN': '递归删除整个盘符根目录', en: 'recursively deletes a whole drive root' } },
111
+ { id: 'rm-rf-home-root', where: 'command', re: /rm\s+-[a-z]*r[a-z]*f?\s+~\/?(\s|$)/i, why: { 'zh-CN': '删除整个家目录', en: 'deletes the entire home directory' } },
112
+ ])
113
+
114
+ /** Operations that are legitimate but never silent: a human must see them once. */
115
+ export const ASK_RULES = /** @type {Rule[]} */ ([
116
+ { id: 'terraform-destroy', where: 'command', re: /terraform\s+(destroy|apply\s+-auto-approve)\b/i, why: { 'zh-CN': 'terraform 会不可逆地改动真实基础设施', en: 'terraform changes real infrastructure irreversibly' } },
117
+ { id: 'kubectl-delete', where: 'command', re: /kubectl\s+delete\b/i, why: { 'zh-CN': '删除 Kubernetes 资源', en: 'deletes Kubernetes resources' } },
118
+ { id: 'docker-volume-rm', where: 'command', re: /docker\s+(volume\s+(rm|prune)|compose\s+down\s+-v)/i, why: { 'zh-CN': '删除数据卷,容器里的数据会一起没', en: 'deletes a data volume, taking the data inside the container with it' } },
119
+ { id: 'docker-prune-all', where: 'command', re: /docker\s+system\s+prune\s+-a/i, why: { 'zh-CN': '清理全部未使用镜像/卷', en: 'prunes every unused image/volume' } },
120
+ { id: 'rm-rf-git-dir', where: 'command', re: /rm\s+-[a-z]*r[a-z]*f?[^\n]*\.git\b/i, why: { 'zh-CN': '删除 .git,会丢掉全部版本历史', en: 'deletes .git, losing all version history' } },
121
+ { id: 'git-clean-fdx', where: 'command', re: /git\s+clean\s+-[a-z]*[fdx]/i, why: { 'zh-CN': 'git clean 会删除未跟踪文件', en: 'git clean deletes untracked files' } },
122
+ { id: 'git-reset-hard', where: 'command', re: /git\s+reset\s+--hard/i, why: { 'zh-CN': '丢弃未提交的改动', en: 'discards uncommitted changes' } },
123
+ { id: 'git-checkout-discard', where: 'command', re: /git\s+(checkout|restore)\s+(--\s|\.\s*$)/i, why: { 'zh-CN': '覆盖工作区改动', en: 'overwrites working-tree changes' } },
124
+ { id: 'find-delete', where: 'command', re: /\bfind\b[^\n]*-(delete|exec\s+rm)/i, why: { 'zh-CN': 'find 批量删除', en: 'bulk deletion through find' } },
125
+ { id: 'rsync-delete', where: 'command', re: /\brsync\b[^\n]*--delete/i, why: { 'zh-CN': 'rsync --delete 会让目标端变成源的镜像', en: 'rsync --delete makes the destination a mirror of the source' } },
126
+ { id: 'sql-delete-without-where', where: 'command', re: /\bDELETE\s+FROM\s+\w+\s*;?\s*$/i, why: { 'zh-CN': 'DELETE 没有 WHERE,会清空整表', en: 'DELETE without WHERE empties the whole table' } },
127
+ { id: 'truncate-file', where: 'command', re: /truncate\s+-s\s*0\b|(?::|true|echo)\s+(?:""|'')\s*>\s*[^\s|&]+/i, why: { 'zh-CN': '把文件截断/覆盖为空(只认显式形态;普通重定向交给 Jev 判)', en: 'truncates/overwrites a file down to empty (explicit forms only; plain redirection goes to Jev)' } },
128
+ { id: 'remote-code-exec', where: 'command', re: /(curl|wget|iwr|Invoke-WebRequest|Invoke-RestMethod)[^\n]*\|\s*(bash|sh|zsh|iex|Invoke-Expression)/i, why: { 'zh-CN': '把远端脚本直接喂给 shell 执行', en: 'pipes a remote script straight into a shell' } },
129
+ { id: 'publish-irreversible', where: 'command', re: /\b(npm|cargo|twine|poetry)\s+publish\b/i, why: { 'zh-CN': '发布到公共仓库,基本不可撤回', en: 'publishes to a public registry; effectively irreversible' } },
130
+ { id: 'chown-recursive-root', where: 'command', re: /chown\s+-R[^\n]*\s\/(\s|$)/i, why: { 'zh-CN': '递归改根目录属主', en: 'recursively changes ownership of the root directory' } },
131
+ { id: 'systemd-mask-critical', where: 'command', re: /systemctl\s+(mask|disable)\s+(ssh|network|networking|systemd-networkd)/i, why: { 'zh-CN': '停用关键系统服务,可能把机器锁在外面', en: 'disables a critical system service; can lock you out of the machine' } },
132
+ ])
133
+
134
+ /**
135
+ * 一条规则在指定语言下的理由(`why` 是双语对象,见上面的 typedef)。
136
+ *
137
+ * 缺该语言时退回 `zh-CN`(目录语言的兜底),再缺就退回规则 id —— 理由绝不会是
138
+ * `undefined`,因为它会被拼进给人和模型看的拒绝理由里。
139
+ *
140
+ * @param rule - 规则对象。
141
+ * @param lang - 语言;默认取当前生效的语言。
142
+ * @returns 该语言下的理由文本。
143
+ */
144
+ export function ruleWhy(rule, lang = getLang()) {
145
+ const table = rule?.why
146
+ if (typeof table === 'string') return table // 兼容老形状(单语言字符串)
147
+ return table?.[lang] ?? table?.[FALLBACK_LANG] ?? String(rule?.id ?? '')
148
+ }
149
+
150
+ /**
151
+ * Evaluate L0 for one command.
152
+ * @param command - raw command text.
153
+ * @returns the matching rule verdict, or undefined when L0 has no opinion. `why` is
154
+ * already localised, so callers can paste it straight into a reason.
155
+ */
156
+ export function staticRule(command) {
157
+ const text = String(command ?? '')
158
+ for (const rule of DENY_RULES) {
159
+ if (ruleRegex(rule).test(text)) return { kind: 'deny', id: rule.id, why: ruleWhy(rule) }
160
+ }
161
+ for (const rule of ASK_RULES) {
162
+ if (ruleRegex(rule).test(text)) return { kind: 'ask', id: rule.id, why: ruleWhy(rule) }
163
+ }
164
+ return undefined
165
+ }
166
+
167
+ /** Rule counts, for diagnostics. */
168
+ export const RULE_STATS = Object.freeze({
169
+ deny: DENY_RULES.length,
170
+ ask: ASK_RULES.length,
171
+ anchored: [...DENY_RULES, ...ASK_RULES].filter(r => r.where === 'command').length,
172
+ /** 全文匹配的例外(应当恒为 2:redirect-to-device 与 fork-bomb)。 */
173
+ anywhere: [...DENY_RULES, ...ASK_RULES].filter(r => r.where !== 'command').length,
174
+ })