dsh-jev-guard 0.5.2 → 0.5.4

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.
@@ -27,7 +27,7 @@
27
27
  ## 2. 三臂校准实验(114 个判断,中文 vs 翻译)
28
28
 
29
29
  样本:40 个 case / 114 个判断,覆盖工单分流、危险命令、代码改动、搜索结果打标。
30
- 复现:`~/workspace/jev-calibration/run_calibration.py`(校准脚本的产物,不在本仓库内)。
30
+ 复现:`measurements/calibration-114/run_calibration.py`(在本仓库内,密钥只从环境变量取)—— 那一次的原始记录在 `measurements/calibration-114/`。
31
31
 
32
32
  | 臂 | 准确率 | noul | choice | score |
33
33
  |---|---|---|---|---|
@@ -80,6 +80,8 @@
80
80
  | p 分布 | P50 = 0.01,P90 = 0.13,max = 0.82(极度两极) |
81
81
  | 脚本补齐命中 | 18 条(2.4%),**新增误报 0 条** |
82
82
 
83
+ 原始结果:`measurements/offline-report-737.json`(threshold 0.5)与 `measurements/offline-report-737-inline.json`(threshold 0.6、补齐脚本正文那次;上面的三分取自后者)。语料本身无法重生成;每个数字对到什么,见 `measurements/README.md`。
84
+
83
85
  被拦下的 5 条(阈值 0.7)全部是真实破坏事件:`git reset --hard`、`git checkout --`、
84
86
  真实目录 `rm -rf`×2、`cp 备份→目标`。
85
87
 
@@ -102,6 +104,8 @@
102
104
  `docker compose down`(无 -v)0.35(低分正确,没删卷)·
103
105
  **`terraform apply -auto-approve` 0.48(已知漏网点)** · `npm publish` 0.03。
104
106
 
107
+ 原始结果:`measurements/probe-scripts.json`(18 个用例,两臂全量)与 `measurements/probe-scripts.md`。
108
+
105
109
  ## 5. 其它实测约束
106
110
 
107
111
  | 项 | 值 | 影响 |
@@ -262,8 +266,8 @@ python 源码的字符串里(`c.startswith('mkfs.ext4 …')`),被 `mkfs` 规则
262
266
  | `degradePolicy: 'off'` | 连 L0 也放行(显式选择;默认不是这个) |
263
267
  | 自动恢复 | 冷却到期后放**一次**探测;替身 fetch 实测:成功 → 清状态并记 `probe+recovered`,失败 → 续期(failures+1、probes+1)且不再重复试 |
264
268
 
265
- **离线断言 52 条**(`tools/selftest-quota.mjs`,替身 `fetch` 覆盖 402/401/403/429两种/5xx/超时/网络/无密钥/
266
- 坏状态文件),其中两条是**自己抓到的真 bug**,一并记在这里:
269
+ **离线断言 109 条**(`tools/selftest-quota.mjs`,替身 `fetch` 覆盖 402/401/403(JSON 鉴权 vs 边缘 HTML)/429两种/5xx/超时/网络/无密钥/
270
+ 坏状态文件/删不掉的状态文件),其中两条是**自己抓到的真 bug**,一并记在这里:
267
271
 
268
272
  1. `readDegraded` 用 `Number(until)` 校验 ISO 字符串 → 恒为 NaN → **写进去了却永远读不出来**,
269
273
  整个降级机制静默失效(不抛异常)。
@@ -272,6 +276,25 @@ python 源码的字符串里(`c.startswith('mkfs.ext4 …')`),被 `mkfs` 规则
272
276
 
273
277
  两个都是"静默失效"型错误,都靠"每个分支都写一条断言"才被抓住 —— 与 §7 的教训同源。
274
278
 
279
+ **更新(2026-09-23):`403` 按正文分流 —— 边缘拦截不是密钥被拒。**
280
+ 一个实机部署在 `02:43:19Z` 的一次判定上拿到 `HTTP 403` + Cloudflare 的通用 HTML 错误页
281
+ (183ms —— 是快速的边缘拒绝,不是超时),而 0.2 秒前的那次判定还是成功的;同一把密钥随后经代理与直连都是 `200`。
282
+ 当时的分类器把 `401 || 403` 一行映射到 `auth`,于是那个页面写下**全局** 30 分钟冷却,外加一个"密钥无效或被撤销"
283
+ 的标签 —— 三句都不成立,因为请求根本没到应用层。用故意无效的密钥复打真机,确认了被拒密钥的真实形状:
284
+ `401` + `application/json` + `error_type: authentication_error`,而那个形状照旧降级。现在这个分流由响应形状决定,
285
+ 离线自检把两个方向都钉住了(`403` + HTML → `edge`,不写状态文件,下一条命令照常联网判定;`403` + JSON →
286
+ `auth`,降级)。
287
+
288
+ **同样实测(2026-09-23):删不掉的状态文件。** 只读文件系统这件事不需要真的只读也能复现 —— 在探测请求
289
+ "进行中"把状态文件换成一个目录,随后的 `unlink` 就会以 `EISDIR`/`EPERM` 失败,与 `EROFS` 完全同类。实测:
290
+ 那次判定带着 `clearFailed` 与 errno;下一条命令照常联网判定,而且**不再**被记成一次探测(修复前每次都记
291
+ `probe: true` + `recovered: true`);新写入的状态文件照常降级。
292
+
293
+ **而缓存是判定缓存,不是调用日志(2026-09-23)。** 方法同上:同一条命令在一个进程里判两次(一次真实调用、
294
+ 一次缓存命中),`guard log --stats` 会把它算成两次计价调用、累计 1400 input tokens,而真实只花了 700 ——
295
+ 因为缓存里的判定把 `usage` 连同 `probe`/`recovered` 标记一起回放了(于是 `source: cache` 的记录会自称探测)。
296
+ 现在缓存只存判定本身。
297
+
275
298
  ## 10. 跨平台入口守卫:同一个坑踩了两次(2026-09-20)
276
299
 
277
300
  **现象:** 一个以 `node <路径>` 被直接执行的脚本,在 Windows 上**不输出、退出码 0、不写任何日志** ——
@@ -28,10 +28,10 @@ node tools/report-result.mjs --host dsh --item <item number> --status <pass|fail
28
28
 
29
29
  ```bash
30
30
  node tools/smoke-dsh-adapter.mjs # fake ctx: wiring/assertions/approval policy/audit fields
31
- node tools/smoke-dsh-pipeline.mjs # the real ToolRuntime five-stage pipeline (must be run from inside a DSH checkout)
31
+ node tools/smoke-dsh-pipeline.mjs # the real ToolRuntime five-stage pipeline (needs the throwaway-directory recipe in the file header; a plain `node <path>` cannot resolve @deepseek-ai/*)
32
32
  ```
33
33
 
34
- **Judging:** the smoke tests all pass (including "`policy` and `preset` are recorded in the audit"); the pipeline test gives the expected `ToolExecutionResult`.
34
+ **Judging:** the smoke tests all pass (including "`policy` and `preset` are recorded in the audit"); the pipeline test gives the expected `ToolExecutionResult` — measured 2026-09-23 in this deployment as 6/6 offline and 7/7 with `TYPESAFE_API_KEY` set, including the notice shape checked against DSH's own `snapshotJsonValue`.
35
35
 
36
36
  ### 7 · The false-positive defence line (must be run whenever a rule changes)
37
37
 
@@ -74,10 +74,11 @@ node bin/guard.mjs log --stats
74
74
  ### 13 · Quota degradation (offline)
75
75
 
76
76
  ```bash
77
- node tools/selftest-quota.mjs # stand-in fetch: 402/401/403/two kinds of 429/5xx/timeout/network/bad state file
77
+ node tools/selftest-quota.mjs # stand-in fetch: 402/401/403 (JSON) vs 403 (edge HTML)/two kinds of 429/5xx/timeout/network/bad state file
78
78
  ```
79
79
 
80
- **Judging:** all pass. Three things to confirm specifically: a persistent failure **degrades**, a transient failure **does not degrade**,
80
+ **Judging:** all pass. Four things to confirm specifically: a persistent failure **degrades**, a transient failure **does not degrade**,
81
+ a `403` **splits on its body** (an HTML/WAF page → `edge`, no degradation; JSON → `auth`, degrades),
81
82
  and while degraded L0 still blocks and there are **zero HTTP requests**.
82
83
 
83
84
  ---
@@ -28,10 +28,10 @@ node tools/report-result.mjs --host dsh --item <编号> --status <pass|fail|part
28
28
 
29
29
  ```bash
30
30
  node tools/smoke-dsh-adapter.mjs # 假 ctx:接线/断言/审批策略/审计字段
31
- node tools/smoke-dsh-pipeline.mjs # 真 ToolRuntime 五阶段管线(需在 DSH 检出目录内跑)
31
+ node tools/smoke-dsh-pipeline.mjs # 真 ToolRuntime 五阶段管线(需按文件头那套临时目录配方;直接 `node <路径>` 解析不到 @deepseek-ai/*)
32
32
  ```
33
33
 
34
- **判定:** 冒烟全过(含"审计里记下了 `policy` 与 `preset`");管线测试给出预期的 `ToolExecutionResult`。
34
+ **判定:** 冒烟全过(含"审计里记下了 `policy` 与 `preset`");管线测试给出预期的 `ToolExecutionResult` —— 2026-09-23 在本部署实测:离线 6/6,带 `TYPESAFE_API_KEY` 7/7,其中 notice 形状是拿 DSH 自己的 `snapshotJsonValue` 校验的。
35
35
 
36
36
  ### 7 · 误报防线(改规则时必跑)
37
37
 
@@ -73,10 +73,11 @@ node bin/guard.mjs log --stats
73
73
  ### 13 · 额度降级(离线)
74
74
 
75
75
  ```bash
76
- node tools/selftest-quota.mjs # 替身 fetch:402/401/403/429两种/5xx/超时/网络/坏状态文件
76
+ node tools/selftest-quota.mjs # 替身 fetch:402/401/403(JSON)与 403(边缘 HTML)/429两种/5xx/超时/网络/坏状态文件
77
77
  ```
78
78
 
79
- **判定:** 全过。重点确认三件事:持久性失败**降级**、瞬态失败**不降级**、
79
+ **判定:** 全过。重点确认四件事:持久性失败**降级**、瞬态失败**不降级**、
80
+ 同一个 `403` **按正文分流**(HTML/WAF 页 → `edge`,不降级;JSON → `auth`,降级)、
80
81
  降级期间 L0 仍然拦且**零 HTTP 请求**。
81
82
 
82
83
  ---
package/lib/gate.js CHANGED
@@ -516,12 +516,15 @@ export async function callJev(state, cfg, signal) {
516
516
  signal: combined,
517
517
  })
518
518
  if (!res.ok) {
519
- // 把状态码与正文挂在异常上:**分类器靠它们区分"额度用完"和"抖了一下"**,
520
- // 而这两者的处置完全不同(前者停 15 分钟并告警,后者只逐次 fail-open)。
519
+ // 把状态码、正文与 content-type 挂在异常上:**分类器靠它们区分"额度用完"、"抖了一下"
520
+ // 和"被边缘/WAF 挡下"**,而这三者的处置完全不同(第一个停 15 分钟并告警,后两个只逐次
521
+ // fail-open,但原因不同 —— 见 quota.js 的 looksLikeEdgeBlock)。
521
522
  const text = (await res.text()).slice(0, 300)
522
523
  const error = new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
523
524
  error.status = res.status
524
525
  error.body = text
526
+ // 边缘拦下时给的是 HTML 错误页,而应用层鉴权失败是 JSON —— 这是分辨两者最干净的信号。
527
+ error.contentType = res.headers?.get?.('content-type') ?? ''
525
528
  throw error
526
529
  }
527
530
  const json = await res.json()
@@ -599,6 +602,38 @@ function degradedVerdict(state, cfg, now, probe = false) {
599
602
  }
600
603
  }
601
604
 
605
+ /**
606
+ * 进程内记忆:哪些降级状态文件"探测已经成功、但清不掉"(只读文件系统 / 权限不足)。
607
+ *
608
+ * 为什么需要它(2026-09-23):状态文件是**唯一**的持久记忆,而删不掉它的时候,谁也改不动它。
609
+ * 那份文件的窗口明明已经过期、服务也刚刚用一次成功判定证明自己是活的,可每条命令重读它都会
610
+ * 得到"探测到期" —— 于是每条命令都被当成一次新探测,审计里反复出现 `probe`/`recovered`,
611
+ * 而 `guard status` 永远显示"降级中"。既然删不掉,本进程就**不再拿它当依据**(服务回来了就是
612
+ * 回来了),同时把"清不掉"这件事连同 errno 挂在发现它的那次判定上,别让原因消失。
613
+ *
614
+ * 键是状态文件路径,值是那份过期状态的 `until`(毫秒)。只压制**同一份或更老**的窗口:
615
+ * 万一之后真有一份**新的**状态落盘了(比如文件系统只读是暂时的),新窗口照常生效 ——
616
+ * 记忆绝不能顺手吃掉一次真实的降级。
617
+ */
618
+ const stuckClears = new Map()
619
+
620
+ /**
621
+ * @param state - 刚刚探测成功后清不掉的那份降级状态。
622
+ */
623
+ function markClearStuck(state) {
624
+ if (state?.path) stuckClears.set(state.path, Number(state.until) || 0)
625
+ }
626
+
627
+ /**
628
+ * 这份状态是不是"已知清不掉、且已被一次成功判定证伪"的那一份。
629
+ * @param state - readDegraded() 的结果。
630
+ * @returns true = 不再拿它当降级依据。
631
+ */
632
+ function isObsoleteByStuckClear(state) {
633
+ const stuckUntil = stuckClears.get(state?.path)
634
+ return stuckUntil !== undefined && Number(state.until) <= stuckUntil
635
+ }
636
+
602
637
  /**
603
638
  * Decide what to do with one command.
604
639
  *
@@ -617,6 +652,10 @@ export async function evaluateCommand(command, options = {}) {
617
652
  // 读失败 = 当作没降级(宁可去问一次 API,也不要被一个坏文件卡在降级态里)。
618
653
  let degradedState = cfg.quotaGuard === false ? null : await readDegraded(cfg)
619
654
 
655
+ // 本进程已经确认"服务答得上来、只是那份状态文件删不掉" → 不再拿它当降级依据。
656
+ // 没有这一步,清不掉的状态会让**每条命令**都重新走一次"冷却到期后的探测"(见 stuckClears)。
657
+ if (degradedState !== null && isObsoleteByStuckClear(degradedState)) degradedState = null
658
+
620
659
  // 粘性状态("没有解析到密钥")靠**条件消失**结束,不靠时间:密钥一旦能解析到,这份状态
621
660
  // 就没有存在理由了,当场清掉。这一步零 HTTP(密钥解析是纯本地的事),所以不必等冷却、
622
661
  // 也不必等探测窗口 —— 插上密钥后的第一条命令就恢复成正常判定。
@@ -676,7 +715,12 @@ export async function evaluateCommand(command, options = {}) {
676
715
  try {
677
716
  const { p, model, usage } = await callJev(state, cfg, cfg.signal)
678
717
  // 探测成功 = 服务回来了 → 清掉降级状态。人不需要做任何事。
679
- if (isProbe) await clearDegraded(cfg)
718
+ // 清不掉(只读文件系统 / 权限)不是"恢复失败":这次判定是真的,服务确实答了;但那份
719
+ // 过期状态文件会继续被每一次读取如实报成"已降级"。所以既不静默吞掉,也不假装没发生:
720
+ // 本进程内不再据它判断(见 stuckClears),并把 errno 挂到这次判定上。
721
+ const clearResult = isProbe ? await clearDegraded(cfg) : null
722
+ const stuck = clearResult !== null && !clearResult.ok
723
+ if (stuck) markClearStuck(degradedState)
680
724
  const verdict = {
681
725
  action: p >= cfg.highThreshold ? 'block' : p >= cfg.lowThreshold ? 'revise' : 'allow',
682
726
  source: 'jev',
@@ -688,8 +732,28 @@ export async function evaluateCommand(command, options = {}) {
688
732
  enriched: Object.keys(state).filter(k => k !== commandKey),
689
733
  ms: Date.now() - started,
690
734
  ...(isProbe ? { probe: true, recovered: true } : {}),
735
+ ...(stuck
736
+ ? {
737
+ clearFailed: { path: degradedState.path, code: clearResult.code, error: clearResult.error },
738
+ warning: t('quota.clearFailed.warning', { code: clearResult.code ?? '?', path: degradedState.path }),
739
+ }
740
+ : {}),
691
741
  }
692
- cache.set(key, verdict)
742
+ // 缓存里只放**这份判定本身** —— 同一命令在任何时刻都成立的那些字段。`usage` / `probe` /
743
+ // `recovered` / `clearFailed` / `warning` / `ms` 属于"这一次调用"的附带信息,回放它们
744
+ // 等于把没发生的事再记一遍(2026-09-23):
745
+ // · `usage` 被回放时,`guard log --stats` 会把一次真实调用按缓存命中的次数重复计价
746
+ // (实测 1 次调用、700 tokens 被算成 1400 tokens、成本翻倍);
747
+ // · `probe`/`recovered` 被回放时,一条 `source: cache` 的记录会自称"这次是一次探测"。
748
+ // 所以这里显式列字段(白名单):将来判定形状长大时,新字段默认**不进**缓存,而不是被静默回放。
749
+ cache.set(key, {
750
+ action: verdict.action,
751
+ p: verdict.p,
752
+ model: verdict.model,
753
+ lowThreshold: verdict.lowThreshold,
754
+ highThreshold: verdict.highThreshold,
755
+ enriched: verdict.enriched,
756
+ })
693
757
  return applyToken(command, verdict, cfg)
694
758
  } catch (error) {
695
759
  // fail-open: availability of the valve must never block the agent; the
package/lib/i18n.js CHANGED
@@ -101,6 +101,8 @@ const MESSAGES = {
101
101
  'quota.quota.cliHint.1': '查状态:`guard status`',
102
102
  'quota.auth.label': '判定服务的密钥无效或被撤销',
103
103
  'quota.auth.hint': '检查 secrets.json / 环境变量里的 TYPESAFE_API_KEY(别把密钥贴进对话)。',
104
+ 'quota.edge.label': '请求被边缘/WAF 挡下(不是密钥问题)',
105
+ 'quota.edge.hint': '响应不是判定服务给的,而是 CDN/WAF(常见 Cloudflare)的错误页 —— 请求没到应用层,密钥根本没被检查。逐次放行、不降级;若持续出现,查网络出口是否被拦,并看 `guard log --stats` 的 edge 计数。',
104
106
  'quota.no-key.label': '没有解析到判定服务的密钥',
105
107
  'quota.no-key.hint': '三种录入方式(任选):① 跑 `node bin/guard.mjs key set`(从标准输入读,不进 shell 历史与进程列表);② 设环境变量 `TYPESAFE_API_KEY`;③ 在包根写 `secrets.json`(相对路径的 apiKeyFile 按**包根**解析,与 cwd 无关)。录入后不需要重启。别把密钥贴进对话。',
106
108
  'quota.rate-limit.label': '被判定服务限流(429)',
@@ -121,6 +123,10 @@ const MESSAGES = {
121
123
  + '联网语义判定暂停 {mins} 分钟;{policy}。',
122
124
  'quota.warning.sticky': '⚠️ Jev 安全阀门已降级:{label}(自 {since}Z,已失败 {failures} 次)。'
123
125
  + '联网语义判定暂停;这个状态不靠时间结束,靠它描述的**条件消失**(如密钥一出现);{policy}。',
126
+ 'quota.warning.expired': '⚠️ Jev 安全阀门已降级:{label}(自 {since}Z,已失败 {failures} 次)。'
127
+ + '冷却已到期:下一条命令会放一次探测请求,成功即当场恢复;{policy}。',
128
+ 'quota.clearFailed.warning': '⚠️ 语义判定已恢复(这次探测成功了),但降级状态文件删不掉({code}):{path}。'
129
+ + '只要它还在,之后每一次读取都会把它显示成"已降级" —— 请手工删除,或检查该目录的权限/是否只读挂载。',
124
130
  'quota.status.ok': '✅ Jev 安全阀门:正常',
125
131
  'quota.status.ok.layers': ' L0 静态硬规则 + 预筛 + Jev 语义判定,四态齐全。',
126
132
  'quota.status.ok.noKey': ' ⚠️ 但是:当前没有解析到 API 密钥(`guard judge` 会退回 L0/预筛)。',
@@ -211,6 +217,7 @@ const MESSAGES = {
211
217
  'cli.log.costUnknown': ' 语义判定成本: 未记录(最近的调用没有返回 usage;升级前写入的旧记录也不含)',
212
218
  'cli.status.cleared': '已清除降级状态。下一条命令会重新尝试联网判定(失败会再次进入降级)。',
213
219
  'cli.status.nothingToClear': '当前没有降级状态,无需清除。',
220
+ 'cli.status.clearFailed': '清除失败({code}):降级状态文件还在 —— 别当成"没有降级"。只读文件系统、权限不足都会这样;状态文件读得出却删不掉时,阀门在冷却到期后会**每条命令**都当一次探测重试(各花一次请求)。',
214
221
  'cli.status.stateFile': ' 状态文件: {path}{missing}',
215
222
  'cli.status.stateFileMissing': '(不存在 = 健康)',
216
223
  'cli.status.explainer': ' 说明:降级 = 停用**要花钱的语义判定**;免费的 L0 规则与预筛照常工作(当前 degradePolicy={policy})。',
@@ -302,6 +309,8 @@ const MESSAGES = {
302
309
  'quota.quota.cliHint.1': 'to check: `guard status`',
303
310
  'quota.auth.label': 'the judging service rejected or revoked the key',
304
311
  'quota.auth.hint': 'Check TYPESAFE_API_KEY in secrets.json / the environment (never paste a key into a conversation).',
312
+ 'quota.edge.label': 'the request was blocked at the edge/WAF (not a key problem)',
313
+ 'quota.edge.hint': 'The response did not come from the judging service but from a CDN/WAF (usually Cloudflare) error page — it never reached the application, so the key was never checked. Each call fails open and nothing degrades; if it persists, check whether your egress is being blocked and read the `edge` count in `guard log --stats`.',
305
314
  'quota.no-key.label': 'no key for the judging service could be resolved',
306
315
  'quota.no-key.hint': 'Three ways to set it (pick one): (1) run `node bin/guard.mjs key set` (reads the key from stdin — never shell history or the process list); (2) set the environment variable `TYPESAFE_API_KEY`; (3) write `secrets.json` in the package root (a relative apiKeyFile resolves against the **package root**, independent of cwd). No restart is needed afterwards. Never paste a key into a conversation.',
307
316
  'quota.rate-limit.label': 'rate-limited by the judging service (429)',
@@ -322,6 +331,10 @@ const MESSAGES = {
322
331
  + ' Online semantic judgment paused for {mins} min; {policy}.',
323
332
  'quota.warning.sticky': '⚠️ Jev guard is degraded: {label} (since {since}Z, {failures} failures).'
324
333
  + ' Online semantic judgment is paused; this state does not end with time — it ends when the condition it describes goes away (a key appearing, for instance); {policy}.',
334
+ 'quota.warning.expired': '⚠️ Jev guard is degraded: {label} (since {since}Z, {failures} failures).'
335
+ + ' The cooldown has expired: the next command sends one probe request, and a success restores it right away; {policy}.',
336
+ 'quota.clearFailed.warning': '⚠️ Semantic judgment is back (this probe succeeded), but the degraded-state file could not be removed ({code}): {path}.'
337
+ + ' While it stays there, every later read presents it as "degraded" — remove it by hand, or check that directory permissions / read-only mount.',
325
338
  'quota.status.ok': '✅ Jev guard: healthy',
326
339
  'quota.status.ok.layers': ' L0 static rules + pre-screen + Jev semantic judgment, all four states available.',
327
340
  'quota.status.ok.noKey': ' ⚠️ But: no API key is currently resolved (`guard judge` falls back to L0/pre-screen).',
@@ -412,6 +425,7 @@ const MESSAGES = {
412
425
  'cli.log.costUnknown': ' semantic judgment cost: not recorded (recent calls returned no usage; records written before the upgrade carry none either)',
413
426
  'cli.status.cleared': 'Degradation state cleared. The next command will try the online judge again (a failure re-enters degradation).',
414
427
  'cli.status.nothingToClear': 'Nothing to clear: the valve is not degraded.',
428
+ 'cli.status.clearFailed': 'Could not clear ({code}): the degraded-state file is still there — do not read this as "not degraded". A read-only filesystem or missing directory permissions both look like this; while the file is readable but undeletable, the valve treats **every** command after the cooldown expires as a probe (one request each).',
415
429
  'cli.status.stateFile': ' state file: {path}{missing}',
416
430
  'cli.status.stateFileMissing': ' (absent = healthy)',
417
431
  'cli.status.explainer': ' note: degraded = the **paid semantic judgment** is off; the free L0 rules and pre-screen keep working (currently degradePolicy={policy}).',
package/lib/quota.js CHANGED
@@ -31,6 +31,13 @@
31
31
  * `scope: 'local'` 的本地状态**只压制写下它的那个入口**。这正是"局部问题不该造成全局
32
32
  * 失能"的解法 —— 旧版本的做法是干脆不降级,代价是没人看得见(见 KINDS 里的长注释)。
33
33
  *
34
+ * 6. **`403` 不一定是"我们的密钥坏了"**(2026-09-23,见 docs/DECISIONS.md D16)。401/403 里有
35
+ * 一类响应根本不是那个 JSON 应用发的:CDN/WAF(常见 Cloudflare)在**边缘**就把请求拦了,
36
+ * 回一个 HTML 错误页 —— 密钥连被看过都没有。旧行为把 `401 || 403` 一并归成 `auth`,
37
+ * 于是一次边缘抖动换来 30 分钟全局失能,外加一个误导人的标签"密钥无效或被撤销"。
38
+ * 现在分类器先问"这份响应像不像那个 API 发的",不像就归 `edge`:与第 4 条同一条规矩
39
+ * (瞬态、逐次 fail-open、只留一条分类痕迹),因为它描述的是"路上的机器",不是服务对我们的态度。
40
+ *
34
41
  * @module jev-guard/quota
35
42
  */
36
43
 
@@ -55,6 +62,11 @@ export const DEFAULT_DEGRADED_PATH = process.env.JEV_GUARD_DEGRADED_STATE ?? joi
55
62
  export const KINDS = Object.freeze({
56
63
  quota: Object.freeze({ degraded: true, scope: 'global', cooldownMs: 15 * 60 * 1000 }),
57
64
  auth: Object.freeze({ degraded: true, scope: 'global', cooldownMs: 30 * 60 * 1000 }),
65
+ // 边缘/WAF 拦下(403 或 401 配一个 HTML 错误页,见 classifyFailure 与 D16)。它**不降级**:
66
+ // 那个 HTML 不是判定服务发的,所以它既没告诉我们密钥坏了、也没告诉我们额度没了 —— 它只说明
67
+ // "这一次请求没走到"。按 `auth` 处理会让一次边缘抖动造成 30 分钟全局失能,而且把原因写错。
68
+ // 与 timeout/network 同类:逐次 fail-open,只在 `guard log --stats` 里留下 edge 计数。
69
+ edge: Object.freeze({ degraded: false, cooldownMs: 0 }),
58
70
  // "没解析到密钥"是**本地配置**状况,不是服务状况。它**也要降级**(没有效密钥时必须像
59
71
  // 额度耗尽那样明说,而不是每条命令静默 fail-open),但降级方式与服务侧相反:
60
72
  // · `sticky: true` —— 一次 HTTP 都不发,没有可探测对象,所以不靠冷却到期结束,
@@ -155,7 +167,10 @@ export function cooldownMs(kind, cfg = {}) {
155
167
  * 还定不了就是 `unknown`(不降级,只逐次放行并留痕)。宁可少降级,不要误降级 —— 误降级会让
156
168
  * 阀门在额度充足时也停止防护。
157
169
  *
158
- * @param error - 抛出的异常(带 `status` / `code` / `name` / `message`)。
170
+ * 唯一的"一个状态码对应两个来源"是 401/403(应用层鉴权失败 vs 边缘/WAF 拦截),它靠
171
+ * `looksLikeEdgeBlock()` 分辨 —— 判不准时归 `auth`,因为"提醒人去查密钥"比"静默地少降级"更该发生。
172
+ *
173
+ * @param error - 抛出的异常(带 `status` / `contentType` / `body` / `code` / `name` / `message`)。
159
174
  * @returns `{ kind, status?, detail }`。
160
175
  */
161
176
  export function classifyFailure(error) {
@@ -168,6 +183,11 @@ export function classifyFailure(error) {
168
183
  if (code === 'no-key') return { kind: 'no-key', detail }
169
184
  if (code === 'shape') return { kind: 'shape', detail }
170
185
  if (status === 402) return { kind: 'quota', status, detail }
186
+ // 401/403 这两种码有两个来源,处置完全相反,所以先分辨来源再分类(2026-09-23,D16):
187
+ // · 应用层鉴权失败 —— 密钥无效/被撤销 → `auth`,持久,冷却 30 分钟并提示去换密钥;
188
+ // · 边缘/WAF 把我们挡在门外 —— 请求没到应用层,密钥都没被看过 → `edge`,瞬态,逐次 fail-open。
189
+ // 分辨不了时按 `auth`(宁可多提醒人查钥匙,不要把真正的密钥失效放过)。
190
+ if ((status === 401 || status === 403) && looksLikeEdgeBlock(error, text)) return { kind: 'edge', status, detail }
171
191
  if (status === 401 || status === 403) return { kind: 'auth', status, detail }
172
192
  if (status === 429) {
173
193
  // 429 有两种含义:限流(等一下就好)与额度耗尽(得充钱)。正文能区分才降级。
@@ -265,20 +285,62 @@ export async function enterDegraded(kind, options = {}) {
265
285
 
266
286
  /**
267
287
  * 清除降级状态(探测成功后自动调用;也可由 `guard status --clear` 手动调用)。
288
+ *
289
+ * 返回值刻意把三件事分开(2026-09-23):**清掉了** / **本来就没有** / **清不掉**。
290
+ * 第一版只有 true/false,于是只读文件系统(`EROFS`)、权限不足这类真实故障被报成
291
+ * "当前没有降级状态,无需清除" —— 状态文件还在,人却以为已经清了,真正的原因被吞掉。
292
+ *
268
293
  * @param options - `{ degradedPath }`。
269
- * @returns true = 确实清掉了一个状态文件。
294
+ * @returns `{ removed, ok, code?, error? }`:`removed` = 这次真的删掉了一个文件;
295
+ * `ok` = "现在没有状态文件了"(删掉了,或本来就没有 —— 后者是成功,不是故障);
296
+ * 失败时 `code` 是 errno 名(`EROFS` / `EACCES` / …),`error` 是原始消息。
270
297
  */
271
298
  export async function clearDegraded(options = {}) {
272
299
  const path = resolveDegradedPath(options)
273
300
  try {
274
301
  const { unlink } = await import('node:fs/promises')
275
302
  await unlink(path)
276
- return true
277
- } catch {
278
- return false
303
+ return { removed: true, ok: true }
304
+ } catch (error) {
305
+ // ENOENT = 状态文件本来就不存在,想要的结果已经成立 —— 报失败会误导人。
306
+ if (error?.code === 'ENOENT') return { removed: false, ok: true }
307
+ return {
308
+ removed: false,
309
+ ok: false,
310
+ code: typeof error?.code === 'string' ? error.code : undefined,
311
+ error: String(error?.message ?? error).slice(0, 200),
312
+ }
279
313
  }
280
314
  }
281
315
 
316
+ /**
317
+ * 这个 401/403 是**边缘/WAF 拦的**,还是应用层鉴权失败?
318
+ *
319
+ * 依据只有一个问题:"这份响应像不像那个 JSON 应用发的?"API 只讲 JSON,所以下面任一条成立
320
+ * 就说明拦截发生在应用之前 —— 密钥根本没被检查过:
321
+ *
322
+ * ① `content-type` 明确是 HTML(`text/html`、`application/xhtml+xml`)—— nginx/apache 与
323
+ * Cloudflare 的默认错误页都在这一类;
324
+ * ② 正文里有 HTML 结构(`<!doctype html` / `<html` / `<head` / `<body>`)—— 应用即使
325
+ * 返回 403 也只会给 JSON,给 HTML 的一定不是它;
326
+ * ③ 正文里有边缘厂商的指纹(Cloudflare 的 `cf-ray` / `Attention Required` / `Error code: 102x`,
327
+ * 以及 Sucuri / Akamai / Imperva 的错误页措辞)。
328
+ *
329
+ * 反过来:**正文是 JSON 就一律不算边缘**,照旧按应用层鉴权失败(`auth`)处理 —— 那才是真正的
330
+ * "密钥无效或被撤销",必须让人去换密钥,不能当成路过的抖动。宁可在少见的自定义错误页上多降级
331
+ * 一次(标签会指向"密钥"),也不要漏掉一次真正的密钥失效。
332
+ *
333
+ * @param error - 抛出的异常(带 `contentType` / `body` / `message`)。
334
+ * @param text - 已小写化的 `code + body + message` 拼接串(调用方已算好,避免重复拼)。
335
+ * @returns true = 判为边缘拦截(不降级)。
336
+ */
337
+ function looksLikeEdgeBlock(error, text) {
338
+ if (/html/.test(String(error?.contentType ?? '').toLowerCase())) return true
339
+ if (/<!doctype\s+html|<html[\s>]|<head[\s>]|<body[\s>]/.test(text)) return true
340
+ if (/cloudflare|cf-ray|cf-error|attention required|you have been blocked|error code:\s*1\d{3}|sucuri|akamai|incapsula|imperva/.test(text)) return true
341
+ return false
342
+ }
343
+
282
344
  /**
283
345
  * `until` 的毫秒表示(ISO 字符串;兼容直接存数字的老状态文件)。
284
346
  * @param state - 降级状态。
@@ -351,6 +413,10 @@ export function warningLine(state, now = Date.now()) {
351
413
  }
352
414
  // 粘性状态没有"多少分钟后自动恢复"这句话 —— 对它说"等 0 分钟"等于骗人,所以换一条文案。
353
415
  if (isSticky(state)) return t('quota.warning.sticky', common)
416
+ // 冷却已到期 = 现在等的就是"下一条命令那一次探测"。这时说"暂停 0 分钟"是自相矛盾的:
417
+ // 它既会进每条非 allow 判定的理由,也会被 `guard log` / `guard allow` 打印出来 —— 而
418
+ // 在只读文件系统那种"永远到期"的状态下,那两位会被反复念(2026-09-23)。
419
+ if (retryInMs(state, now) <= 0) return t('quota.warning.expired', common)
354
420
  return t('quota.warning', { ...common, mins: Math.round(retryInMs(state, now) / 60000) })
355
421
  }
356
422
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-jev-guard",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Pre-execution safety valve for DeepSeek Harness: a TypeSafe Jev question decides whether a shell command/script would irreversibly delete or overwrite real data. Four states (allow/revise/block/escalate), mounted on tools/pre-execute. Zero dependencies; WSL/Linux (bash) and Windows (pwsh); one-shot tokens, credit-exhaustion degradation, shared audit log; bilingual (zh-CN / en) messages.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -31,7 +31,7 @@
31
31
  "./package.json": "./package.json"
32
32
  },
33
33
  "bin": {
34
- "jev-guard": "./bin/guard.mjs"
34
+ "jev-guard": "bin/guard.mjs"
35
35
  },
36
36
  "scripts": {
37
37
  "selftest": "node bin/guard.mjs selftest",
@@ -40,7 +40,9 @@ const FILES = [
40
40
  'CHANGELOG.md',
41
41
  'DEPLOY.md',
42
42
  'START-HERE.md',
43
+ 'RELEASING.md',
43
44
  'adapters/README.md',
45
+ 'measurements/README.md',
44
46
  'verification-results/README.md',
45
47
  'docs/ARCHITECTURE.md',
46
48
  'docs/DECISIONS.md',