dsh-jev-guard 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@
5
5
  This project follows a "record the facts by date" approach: every entry states clearly **what changed, why, and how it was verified**.
6
6
  The complete design trade-offs are in [`docs/DECISIONS.md`](./docs/DECISIONS.md), the measured data in [`docs/MEASUREMENTS.md`](./docs/MEASUREMENTS.md).
7
7
 
8
+ ## [0.5.3] — 2026-09-23
9
+
10
+ **A `403` no longer means "your key is bad": an edge block is its own class now, and it does not degrade.** This is a behaviour change — one failure category was split in two, and the misreporting in `guard status --clear` was fixed. `lib/`, `bin/`, `adapters/` and the default config are otherwise unchanged from 0.5.2.
11
+
12
+ **What went wrong.** On 2026-09-23 a live deployment failed one judgment with `HTTP 403` carrying Cloudflare's generic HTML error page — it came back 183 ms after the request went out, 0.2 seconds after a judgment that had just succeeded. `classifyFailure()` mapped `401 || 403` to `auth` in a single line, so that page wrote a **global** 30-minute cooldown (every entry point) and labelled the cause "the judging service rejected or revoked the key". None of that was true: the request never reached the application, the key was never read, and the same key answered `200` immediately before and after, both through the proxy and directly. The real shape of a rejected key — re-measured against the live API with a deliberately invalid key — is `401` plus `application/json` carrying `error_type: authentication_error`.
13
+
14
+ **What changed:**
15
+
16
+ 1. **A new `edge` class.** A `401`/`403` whose response does not look like it came from the JSON API — a `text/html` content-type, an HTML body, or a WAF fingerprint (`cf-ray`, `Attention Required`, `Error code: 10xx`, Sucuri/Akamai/Imperva) — is classified `edge`: no state file, no cooldown, per-call fail-open, one `errorKind: edge` in `guard log --stats`. A `403` whose body **is** JSON still degrades as `auth`, so a genuinely revoked key is still reported as one.
17
+ 2. **`clearDegraded()` no longer swallows failures.** It returns `{ removed, ok, code }`, keeping "removed it", "there was nothing to remove" and "could not remove it (errno)" apart. When the state file is merely undeletable (a read-only filesystem reports `EROFS`), `guard status --clear` prints that errno and exits 1 instead of claiming there was nothing to clear.
18
+ 3. **New copy in both languages**: `quota.edge.label|hint` and `cli.status.clearFailed`.
19
+ 4. **`guard status --file X` now reports on `X`.** The read and the delete already honoured `--file`, but the "state file" line printed the default path — so the status you read and the file you were inspecting could be two different files.
20
+ 5. **A state file that cannot be deleted no longer turns every command into a probe.** The service had already answered a successful probe, so the valve keeps judging online — but the file stayed behind, every later read called it "probe due", and every command was recorded as `probe`/`recovered`. That verdict now carries `clearFailed` (with the errno) plus a warning instead, the stale window stops driving decisions inside that process, and the DSH adapter records one `level: 'warn'` audit entry so the cause stays findable. A **newer** state file still takes effect as before.
21
+ 6. **The cache stores the judgment, not the call.** `usage`, `probe`, `recovered`, `clearFailed` and `warning` describe one call rather than the command, so replaying them recorded things that had not happened: `guard log --stats` priced one real call once per cache hit (measured — one call of 700 tokens counted as 1400, doubling the cost figure), and a cache-hit line could claim `source: cache` and `probe: true` at the same time. These fields are no longer written to the cache.
22
+ 7. **The expired-cooldown warning no longer says "paused for 0 minutes".** Once the window has expired, what is actually pending is the next command's probe, and the line now says that. Pure copy; no behaviour changes.
23
+
24
+ **Acceptance**: the seven offline suites pass (`selftest-quota` grew from 78 to 109 assertions), `node bin/guard.mjs selftest` is 12/12, and the DSH adapter smoke test is 23/23. Both `403` shapes were confirmed in both directions: a `403` + Cloudflare HTML is classified `edge` (fail-open, **no** state file, and the next command is judged online again — asserted end to end, not only at the classifier), while a `403` + JSON stays `auth` and degrades for 30 minutes. Against the live API a deliberately invalid key returns `401` + `application/json` → `auth`, which is what keeps this fix from quietly dropping real key failures. Every fix here was also verified by breaking it on purpose: with the probe suppression disabled the next command is recorded as `probe`/`recovered` again; with an over-eager memory a freshly written state file stops degrading; with the whole verdict back in the cache a hit replays `usage` and claims to be a probe; and with the expired-cooldown branch removed the warning says "paused for 0 minutes" again. Each mutation turns the matching assertion red. The real-tool-pipeline integration test also runs in this deployment now: 6/6 offline and 7/7 with `TYPESAFE_API_KEY`, including the notice shape checked against this DSH's own `snapshotJsonValue`. What 0.4.1 recorded as "not covered here" was the recipe rather than a missing workspace — bare `@deepseek-ai/*` resolve from the file's own location while the notice check resolves from the cwd, so the test's header now carries a throwaway-directory recipe that satisfies both. The test also pins its audit log and state file to a temp directory, so running it no longer writes into the real `~/.jev-guard/`.
25
+
8
26
  ## [0.5.2] — 2026-09-22
9
27
 
10
28
  **Releases now publish themselves from a tag.** The plugin's behaviour is unchanged: `bin/`, `lib/`, `adapters/`, `cordis.patch.yml` and the default config are identical to 0.5.1. Only the way a release reaches the registry changed.
@@ -5,6 +5,24 @@
5
5
  本项目遵循「按日期记录事实」的写法:每条都写清**改了什么、为什么、以及怎么验证的**。
6
6
  完整的设计取舍见 [`docs/DECISIONS.md`](./docs/DECISIONS.md),实测数据见 [`docs/MEASUREMENTS.md`](./docs/MEASUREMENTS.md)。
7
7
 
8
+ ## [0.5.3] — 2026-09-23
9
+
10
+ **`403` 不再等于"你的密钥坏了":边缘拦截现在是一个独立分类,而且不降级。** 这是一处行为变更 —— 一个失败分类被拆成两个,并修掉了 `guard status --clear` 的误报。除此之外 `lib/`、`bin/`、`adapters/` 与默认配置相对 0.5.2 未变。
11
+
12
+ **出了什么事。** 2026-09-23,一个实机部署的一次判定拿到 `HTTP 403` + Cloudflare 的通用 HTML 错误页 —— 它在请求发出后 183ms 就回来了,而 0.2 秒前的那次判定刚刚成功。`classifyFailure()` 把 `401 || 403` 一行映射为 `auth`,于是那个页面写下**全局**(对所有入口)30 分钟冷却,并把原因标成"判定服务的密钥无效或被撤销"。这三句都不成立:请求没到应用层、密钥没被读过,而同一把密钥在前后紧邻的时刻经代理与直连都是 `200`。被拒密钥的真实形状 —— 用故意无效的密钥重打真机量出来的 —— 是 `401` 加 `application/json`,里面写着 `error_type: authentication_error`。
13
+
14
+ **改了什么:**
15
+
16
+ 1. **新增 `edge` 分类。** `401`/`403` 的响应只要不像那个 JSON 应用发的 —— content-type 是 `text/html`、正文是 HTML、或带 WAF 指纹(`cf-ray`、`Attention Required`、`Error code: 10xx`、Sucuri/Akamai/Imperva)—— 就归 `edge`:不写状态文件、没有冷却、逐次 fail-open,并在 `guard log --stats` 里记一条 `errorKind: edge`。而正文**是** JSON 的 `403` 照旧按 `auth` 降级,所以真正被撤销的密钥仍然会被报成密钥问题。
17
+ 2. **`clearDegraded()` 不再吞掉失败。** 它返回 `{ removed, ok, code }`,把"删掉了"、"本来就没有"、"删不掉(带 errno)"三件事分开。状态文件只是删不掉时(只读文件系统会报 `EROFS`),`guard status --clear` 会打印那个 errno 并以退出码 1 结束,而不是说一句"无需清除"。
18
+ 3. **双语新增文案**:`quota.edge.label|hint` 与 `cli.status.clearFailed`。
19
+ 4. **`guard status --file X` 现在如实指向 `X`。** 读与删早就认 `--file`,但"状态文件"那一行打印的是默认路径 —— 你读到的状态和你正在查的文件可能是两个文件。
20
+ 5. **删不掉的状态文件不再把每条命令都变成一次探测。** 服务已经用一次成功的探测证明自己活着,所以阀门照常联网判定 —— 但文件还留在那里,之后每一次读取都得到"探测到期",于是每条命令都被记成 `probe`/`recovered`。现在那次判定改带 `clearFailed`(含 errno)与一句告警,那份过期窗口在本进程内不再参与判断,DSH 适配器另落一条 `level: 'warn'` 审计记录让原因可寻。而**更新的**状态文件照常生效。
21
+ 6. **缓存里存的是判定,不是那一次调用。** `usage`、`probe`、`recovered`、`clearFailed`、`warning` 说的是"这一次调用"而不是这条命令,回放它们等于把没发生的事记下来:`guard log --stats` 会把一次真实调用按缓存命中的次数重复计价(实测:一次 700 tokens 的调用被算成 1400,成本翻倍),而一条缓存命中的记录能同时写着 `source: cache` 与 `probe: true`。这些字段不再进缓存。
22
+ 7. **冷却过期后的告警不再说"暂停 0 分钟"。** 窗口过期时真正等着的就是"下一条命令那次探测",那句话现在如实这么说。纯文案,行为不变。
23
+
24
+ **验收**:七套离线自检全过(`selftest-quota` 从 78 条涨到 109 条断言)、`node bin/guard.mjs selftest` 12/12、DSH 适配器冒烟 23/23。两种 `403` 形状都做了双向确认:`403` + Cloudflare HTML 被判为 `edge`(fail-open、**不写**状态文件、下一条命令照常联网判定 —— 端到端断言,不只是分类器那一层),而 `403` + JSON 仍是 `auth` 并降级 30 分钟。打真机时,故意无效的密钥返回 `401` + `application/json` → `auth`,这正是本次修复不会顺手放过真实密钥失效的依据。本次每一处修复也都用植入式验证确认过:关掉探测抑制,下一条命令又会被记成 `probe`/`recovered`;把记忆做得过宽,新写入的状态文件就不再降级;让缓存照旧存整份判定,缓存命中会回放 `usage` 并自称探测;删掉"冷却已过期"那条分支,告警又说回"暂停 0 分钟"。每种改法都会让对应断言变红。真实工具管线集成测试现在也能在本部署里跑:离线 6/6,带 `TYPESAFE_API_KEY` 7/7,其中 notice 形状是拿本机 DSH 自己的 `snapshotJsonValue` 校验的。0.4.1 里记的"本次未覆盖"其实是**配方**问题、不是缺工作区 —— 裸 `@deepseek-ai/*` 按文件自身位置解析,而 notice 检定按 cwd 解析,所以测试头部现在给了一套同时满足两者的临时目录配方。测试还把审计日志与状态文件钉在临时目录里,跑它不再写入真实 `~/.jev-guard/`。
25
+
8
26
  ## [0.5.2] — 2026-09-22
9
27
 
10
28
  **发布这件事现在由 tag 自己完成。** 插件行为未变:`bin/`、`lib/`、`adapters/`、`cordis.patch.yml` 与默认配置相对 0.5.1 完全一致。变的只是"一个版本怎么到达 registry"。
package/DEPLOY.md CHANGED
@@ -142,7 +142,7 @@ Summary:
142
142
  | 6 | The token closed loop | authorise → retry the same one → allowed once → the token disappears, with `source: token` in the record |
143
143
  | 7 | The authorisation entry point is only on an interactive terminal | a non-TTY is refused and prints the whole copyable command line |
144
144
  | 8 | The approval prompt (policy `ask`) | the prompt appears and carries the valve's reason text as it stands; after clicking allow the command runs |
145
- | 9 | Quota degradation | 402/401 → degradation, zero requests, `guard status` exit code 3, L0 still blocks |
145
+ | 9 | Quota degradation | 402/401 → degradation, zero requests, `guard status` exit code 3, L0 still blocks; a `403` carrying an HTML page → `edge`, **no** degradation |
146
146
  | 10 | A human running it by hand ≠ granting the AI permission | zero new audit entries, and an AI retry is **still blocked** |
147
147
 
148
148
  ## 4. Runtime
@@ -181,8 +181,10 @@ node bin/guard.mjs status # health status (exit code 3 while degraded
181
181
  | The plugin is installed but nothing is blocked | the plugin is not mounted, or the package path is wrong | run `selftest-entry` + see whether `guard.log` has records; read `DSH-INTEGRATION.md` §5 |
182
182
  | `source: error`, with the reason `HTTP 401` | the key is invalid or revoked | change the key; **in the meantime the valve has already degraded automatically for 30 minutes** (it sends no more requests), so once it is fixed either wait for the cooldown to expire and it recovers by itself, or `guard status --clear` |
183
183
  | `source: error`, with the reason `HTTP 402` | the credit is used up | same as the line above (this one **degrades** rather than retrying every time, which saves money) |
184
+ | `source: error`, with the reason `HTTP 403` and an HTML body | a CDN/WAF blocked the request at the edge; it never reached the judging service | nothing to fix on your side: this is classified `edge` and does **not** degrade. If it keeps happening, your egress is being blocked — read the `edge` count in `guard log --stats` |
184
185
  | `source: degraded` | inside a degradation window | `guard status` will say which class it is + how much is left; the free L0 + pre-screen still work |
185
186
  | `source: error`, with the reason `fetch failed` | the network/proxy is unreachable | check that `https://api.typesafe.ai` is reachable. It does **not degrade** (transient), but it accumulates in the "failure breakdown" |
187
+ | `guard status` keeps saying degraded, or a warning says the state file could not be removed | the state file sits in a read-only directory, so neither the cooldown nor the recovery can ever be written down | delete `~/.jev-guard/degraded.json` by hand, or fix that directory's permissions. The valve is otherwise healthy and judging online; until that file is gone, each new process repeats one probe |
186
188
  | A dangerous command was not blocked | not in L0 and `p < lowThreshold` | look at the `p` in the `judge` output; if necessary lower `lowThreshold` or add an L0 rule for that class of command |
187
189
  | Everything is blocked and no work can be done | the threshold is too low or L0 is too aggressive | look at the `rule.id` from `judge` first; edit `lib/rules.js` or raise `lowThreshold` |
188
190
  | The authorisation line pasted into cmd.exe reports a syntax error | cmd does not accept POSIX/PowerShell quoting | switch to `guard allow --command-file cmd.txt` |
package/DEPLOY.zh-CN.md CHANGED
@@ -140,7 +140,7 @@ dsh plugin --profile <profile> add /mnt/t/dsh-jev-guard # Windows 侧换成
140
140
  | 6 | 令牌闭环 | 授权 → 重试同一条 → 放行一次 → 令牌消失,记录里 `source: token` |
141
141
  | 7 | 授权入口只在交互终端 | 非 TTY 被拒并打印可复制的整行命令 |
142
142
  | 8 | 审批弹窗(策略 `ask`) | 弹窗出现且带阀门理由原文;点允许后命令执行 |
143
- | 9 | 额度降级 | 402/401 → 降级、零请求、`guard status` 退出码 3、L0 仍拦 |
143
+ | 9 | 额度降级 | 402/401 → 降级、零请求、`guard status` 退出码 3、L0 仍拦;`403` 带 HTML 页 → `edge`,**不**降级 |
144
144
  | 10 | 人工手动执行 ≠ 给 AI 授权 | 审计零新增,且 AI 重试**仍然被拦** |
145
145
 
146
146
  ## 4. 运行期
@@ -179,8 +179,10 @@ node bin/guard.mjs status # 健康状态(降级时退出码 3)
179
179
  | 装了插件但什么都不拦 | 插件没挂上,或包路径不对 | 跑 `selftest-entry` + 看 `guard.log` 有没有记录;读 `DSH-INTEGRATION.md` §5 |
180
180
  | `source: error`,理由是 `HTTP 401` | 密钥无效或被撤销 | 换密钥;**同时阀门已自动降级 30 分钟**(不再发请求),修好后等冷却到期自动恢复,或 `guard status --clear` |
181
181
  | `source: error`,理由是 `HTTP 402` | 额度用尽 | 同上一行(这条会**降级**而不是逐次重试,省钱) |
182
+ | `source: error`,理由是 `HTTP 403` 且正文是 HTML | CDN/WAF 在边缘把请求挡了,它没到判定服务 | 你这边不用做什么:这被分类为 `edge`,**不降级**。若持续出现,说明出口被拦了 —— 看 `guard log --stats` 的 `edge` 计数 |
182
183
  | `source: degraded` | 处在降级窗口内 | `guard status` 会说明是哪一类 + 还剩多久;免费的 L0 + 预筛仍在工作 |
183
184
  | `source: error`,理由是 `fetch failed` | 网络/代理不通 | 检查 `https://api.typesafe.ai` 可达性。**不会降级**(瞬态),但会累计在"失败分类"里 |
185
+ | `guard status` 一直显示已降级,或告警说状态文件删不掉 | 状态文件所在目录只读,冷却与恢复都写不进去 | 手工删除 `~/.jev-guard/degraded.json`,或修好该目录权限。除此之外阀门是健康的、照常联网判定;那个文件消失之前,每个新进程会重复一次探测 |
184
186
  | 危险命令没被拦 | 不在 L0 且 `p < lowThreshold` | 看 `judge` 输出的 `p`;必要时调低 `lowThreshold` 或给该类命令加 L0 规则 |
185
187
  | 全被拦,干不了活 | 阈值过低或 L0 太激进 | 先看 `judge` 的 `rule.id`;编辑 `lib/rules.js` 或调高 `lowThreshold` |
186
188
  | 授权行粘到 cmd.exe 里报语法错 | cmd 不认 POSIX/PowerShell 的引号 | 改用 `guard allow --command-file cmd.txt` |
package/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
  [![topic: dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-4B6BFB.svg)](https://github.com/topics/dsh-plugin)
8
8
  [![platform](https://img.shields.io/badge/platform-WSL%20%7C%20Windows-2f2f2f.svg)](#platform-support)
9
9
  [![version](https://img.shields.io/github/v/tag/7starsseeker/dsh-jev-guard?label=version&style=flat)](https://github.com/7starsseeker/dsh-jev-guard/tags)
10
+ [![npm](https://img.shields.io/npm/v/dsh-jev-guard?label=npm&style=flat)](https://www.npmjs.com/package/dsh-jev-guard)
10
11
  [![selftest](https://img.shields.io/github/actions/workflow/status/7starsseeker/dsh-jev-guard/selftest.yml?label=selftest)](https://github.com/7starsseeker/dsh-jev-guard/actions/workflows/selftest.yml)
11
12
  [![last commit](https://img.shields.io/github/last-commit/7starsseeker/dsh-jev-guard)](https://github.com/7starsseeker/dsh-jev-guard/commits/main)
12
13
  [![stars](https://img.shields.io/github/stars/7starsseeker/dsh-jev-guard?style=flat)](https://github.com/7starsseeker/dsh-jev-guard/stargazers)
@@ -218,7 +219,7 @@ The judging service is **pay-per-use**, so running out of credit is a certainty.
218
219
  |---|---|---|
219
220
  | Credit exhausted / key invalid (`402` / `401`) | **Degrades**: writes `~/.jev-guard/degraded.json` and stops sending requests for the cooldown window (to save money), running only the **free L0 + pre-screen** by default | `guard status` (exit code 3) · one `⚠️` line in the refusal reason · `source: degraded` and `level: warn` in the audit · stderr of the CLI |
220
221
  | Cooldown expires | Automatically sends **one** probe request: success restores normal operation (you do nothing), failure keeps it degraded | `guard status` shows how long is left |
221
- | Timeout / network hiccup / 5xx / 429 rate limit / no key | **No degradation** — each call is simply allowed (fail-open), but it is categorized and recorded | the "failure breakdown" line of `guard log --stats` |
222
+ | Timeout / network hiccup / 5xx / 429 rate limit / no key / **an edge block** (a CDN/WAF answering `403` with an HTML page: the request never reached the judging service, so the key was never checked) | **No degradation** — each call is simply allowed (fail-open), but it is categorized and recorded | the "failure breakdown" line of `guard log --stats` |
222
223
 
223
224
  ```bash
224
225
  node bin/guard.mjs status --clear # don't want to wait out the cooldown: retry once now (a failure re-enters degradation)
package/README.zh-CN.md CHANGED
@@ -7,6 +7,7 @@
7
7
  [![topic: dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-4B6BFB.svg)](https://github.com/topics/dsh-plugin)
8
8
  [![platform](https://img.shields.io/badge/platform-WSL%20%7C%20Windows-2f2f2f.svg)](#平台支持)
9
9
  [![version](https://img.shields.io/github/v/tag/7starsseeker/dsh-jev-guard?label=version&style=flat)](https://github.com/7starsseeker/dsh-jev-guard/tags)
10
+ [![npm](https://img.shields.io/npm/v/dsh-jev-guard?label=npm&style=flat)](https://www.npmjs.com/package/dsh-jev-guard)
10
11
  [![selftest](https://img.shields.io/github/actions/workflow/status/7starsseeker/dsh-jev-guard/selftest.yml?label=selftest)](https://github.com/7starsseeker/dsh-jev-guard/actions/workflows/selftest.yml)
11
12
  [![last commit](https://img.shields.io/github/last-commit/7starsseeker/dsh-jev-guard)](https://github.com/7starsseeker/dsh-jev-guard/commits/main)
12
13
  [![stars](https://img.shields.io/github/stars/7starsseeker/dsh-jev-guard?style=flat)](https://github.com/7starsseeker/dsh-jev-guard/stargazers)
@@ -218,7 +219,7 @@ node bin/guard.mjs allow --revoke ALLOW-… # 撤销
218
219
  |---|---|---|
219
220
  | 额度耗尽 / 密钥失效(`402` / `401`) | **降级**:写 `~/.jev-guard/degraded.json`,冷却窗口内不再发请求(省钱),默认只跑**免费的 L0 + 预筛** | `guard status`(退出码 3)· 拒绝理由里的一句 `⚠️` · 审计里的 `source: degraded` 与 `level: warn` · CLI 的 stderr |
220
221
  | 冷却到期 | 自动放**一次**探测请求:成功即恢复(你不用做任何事),失败继续降级 | `guard status` 会显示还剩多久 |
221
- | 超时 / 网络抖 / 5xx / 429 限流 / 无密钥 | **不降级**,只逐次放行(fail-open),但会被分类记录 | `guard log --stats` 的"失败分类"一行 |
222
+ | 超时 / 网络抖 / 5xx / 429 限流 / 无密钥 / **被边缘拦下**(CDN/WAF 用 `403` 回了一个 HTML 页:请求根本没到判定服务,密钥也没被检查过) | **不降级**,只逐次放行(fail-open),但会被分类记录 | `guard log --stats` 的"失败分类"一行 |
222
223
 
223
224
  ```bash
224
225
  node bin/guard.mjs status --clear # 不想等冷却:立刻重试一次(失败会再次进入降级)
@@ -338,6 +338,8 @@ export function apply(ctx, config = {}) {
338
338
  const stats = { allowed: 0, revised: 0, blocked: 0, escalated: 0, prefilters: 0, cacheHits: 0, ruleHits: 0, errors: 0, degraded: 0 }
339
339
  /** 已吼过的降级窗口(= kind + until),避免每个命令刷一遍屏。 */
340
340
  let lastDegradedKey = ''
341
+ /** 已吼过的"清不掉的状态文件"(= 路径 + errno),同上。 */
342
+ let lastStuckClearKey = ''
341
343
 
342
344
  ctx.logger?.info?.(
343
345
  'jev-guard: gating %s (low=%s high=%s timeout=%sms key=%s lang=%s promptLang=%s)',
@@ -384,6 +386,20 @@ export function apply(ctx, config = {}) {
384
386
  }, cfg)
385
387
  }
386
388
 
389
+ // 探测成功后状态文件却删不掉(只读文件系统 / 权限不足):服务确实回来了,但那份文件会
390
+ // 被每一次读取继续显示成"已降级"。它是人的环境问题,不是阀门的问题,所以走同一条
391
+ // "有人能发现"的路:host 日志一条 warn + 审计一条 level:'warn'(带上 errno 与路径)。
392
+ // 同一个(路径, errno)只吼一次 —— 否则每条命令都会重报同一件陈年旧事。
393
+ const stuckKey = verdict.clearFailed ? `${verdict.clearFailed.path}:${verdict.clearFailed.code ?? ''}` : ''
394
+ if (stuckKey && stuckKey !== lastStuckClearKey) {
395
+ lastStuckClearKey = stuckKey
396
+ ctx.logger?.warn?.('jev-guard: %s', verdict.warning)
397
+ void record({
398
+ level: 'warn', tool: exec.name, source: verdict.source, clearFailed: verdict.clearFailed,
399
+ warning: verdict.warning, cwd, command, session: exec.agent?.session?.id,
400
+ }, cfg)
401
+ }
402
+
387
403
  const sessionKey = `${exec.agent?.session?.id ?? 'no-session'}:${fingerprint(command)}`
388
404
  let effective = verdict
389
405
 
package/bin/guard.mjs CHANGED
@@ -369,18 +369,24 @@ async function cmdLog() {
369
369
  async function cmdStatus() {
370
370
  const cfg = await loadConfig()
371
371
  cfg.apiKey = await resolveKey(cfg)
372
+ // `--file` 必须一路走到底:读、清、以及**显示**的那行都用同一个路径。原来显示那行漏了它,
373
+ // 于是 `guard status --file X` 读的是 X、却把默认路径打印出来 —— 状态明明来自别处,
374
+ // 人却去查了另一个文件(2026-09-23)。
375
+ const file = arg('--file', cfg.degradedPath)
372
376
 
373
377
  if (flag('--clear')) {
374
- const removed = await clearDegraded({ degradedPath: arg('--file', cfg.degradedPath) })
375
- process.stdout.write(`${t(removed ? 'cli.status.cleared' : 'cli.status.nothingToClear')}\n`)
376
- return 0
378
+ const res = await clearDegraded({ degradedPath: file })
379
+ // 三种结果分开说:清掉了 / 本来就没有 / **清不掉**。最后一种原来会被并进"本来就没有",
380
+ // 于是只读文件系统、权限不足这类真实原因被"无需清除"这句给盖住了(2026-09-23)。
381
+ process.stdout.write(`${t(!res.ok ? 'cli.status.clearFailed' : res.removed ? 'cli.status.cleared' : 'cli.status.nothingToClear', { code: res.code ?? '' })}\n`)
382
+ return res.ok ? 0 : 1
377
383
  }
378
384
 
379
- const state = await readDegraded({ degradedPath: arg('--file', cfg.degradedPath) })
385
+ const state = await readDegraded({ degradedPath: file })
380
386
  const now = Date.now()
381
387
  process.stdout.write(`${statusText(state, now, { apiKeyPresent: Boolean(cfg.apiKey) })}\n`)
382
388
  process.stdout.write(`${t('cli.status.stateFile', {
383
- path: resolveDegradedPath({ degradedPath: cfg.degradedPath }),
389
+ path: resolveDegradedPath({ degradedPath: file }),
384
390
  missing: state ? '' : t('cli.status.stateFileMissing'),
385
391
  })}\n`)
386
392
  process.stdout.write(`${t('cli.status.explainer', { policy: cfg.degradePolicy ?? 'l0-only' })}\n`)
@@ -20,16 +20,9 @@ interception point, and the behaviour has already been confirmed from the source
20
20
  | Credential | `refs.TYPESAFE_API_KEY` in `~/.dsh/.credentials.yaml` | written and validated as legal YAML (backup `.bak-before-typesafe-*`) |
21
21
  | Rollback point | manual snapshot `20260920-124708-6f5f` | the clean state before the install |
22
22
 
23
- How to run the real-pipeline integration test (you must be inside the deepseek-harness directory tree, otherwise `@deepseek-ai/*` will not resolve):
23
+ How to run the real-pipeline integration test: it needs two things at once, and no single directory of a pnpm workspace checkout has both. Bare `@deepseek-ai/*` resolve **from the file's own location** (so the file must sit in a package directory such as `apps/cli`), while the notice check (item 6) reads `./packages/util/values/lib/index.js` **relative to the cwd** (so the cwd must be the checkout root). Build a throwaway directory that satisfies both — the verified recipe is in the header of `tools/smoke-dsh-pipeline.mjs`, and copying the file into that directory is part of it: `node <absolute path>` resolves the bare specifiers against `/mnt/t/jev-guard/tools/` and dies with `ERR_MODULE_NOT_FOUND`.
24
24
 
25
- ```bash
26
- cp /mnt/t/dsh-jev-guard/tools/smoke-dsh-pipeline.mjs <DSH checkout>/packages/core/agent-loop/.tmp-guard-pipeline.mjs
27
- cd <DSH checkout>/packages/core/agent-loop
28
- JEV_GUARD_ROOT=/mnt/t/dsh-jev-guard TYPESAFE_API_KEY=... node .tmp-guard-pipeline.mjs
29
- rm .tmp-guard-pipeline.mjs
30
- ```
31
-
32
- **Only one thing is left: run the item 6 probe after the restart.**
25
+ **Measured 2026-09-23: 6/6 offline, 7/7 with a key.** The recipe in this document's earlier revision is superseded — from the checkout root the import itself fails, and from `packages/core/agent-loop` item 6 reports a **false FAIL**.
33
26
 
34
27
  ## Known behaviour (confirmed at the source level, no need to re-verify)
35
28
 
@@ -20,16 +20,9 @@ DSH 是**唯一**被支持的宿主,而且它不需要赌:有原生的 `tools/pr
20
20
  | 凭据 | `~/.dsh/.credentials.yaml` 的 `refs.TYPESAFE_API_KEY` | 已写入并校验 YAML 合法(备份 `.bak-before-typesafe-*`) |
21
21
  | 回退点 | 手动快照 `20260920-124708-6f5f` | 安装前的干净状态 |
22
22
 
23
- 跑真实管线集成测试的方法(必须在 deepseek-harness 目录树内,否则解析不到 `@deepseek-ai/*`):
23
+ 跑真实管线集成测试的方法:它同时需要两件事,而 pnpm 工作区检出里没有任何一个目录同时满足。裸 `@deepseek-ai/*` 是**按文件自身所在目录**解析的(所以本文件必须待在 `apps/cli` 这类包目录里),而第 6 项 notice 检定读的是**相对 cwd** 的 `./packages/util/values/lib/index.js`(所以 cwd 必须是检出根)。做法是搭一个临时目录同时满足两者 —— 已验证的配方在 `tools/smoke-dsh-pipeline.mjs` 文件头,其中"把文件复制进那个目录"是配方的一部分:用 `node <绝对路径>` 跑,裸说明符会按 `/mnt/t/jev-guard/tools/` 解析,直接以 `ERR_MODULE_NOT_FOUND` 崩掉。
24
24
 
25
- ```bash
26
- cp /mnt/t/dsh-jev-guard/tools/smoke-dsh-pipeline.mjs <DSH 检出>/packages/core/agent-loop/.tmp-guard-pipeline.mjs
27
- cd <DSH 检出>/packages/core/agent-loop
28
- JEV_GUARD_ROOT=/mnt/t/dsh-jev-guard TYPESAFE_API_KEY=... node .tmp-guard-pipeline.mjs
29
- rm .tmp-guard-pipeline.mjs
30
- ```
31
-
32
- **剩下只有一件事:重启后跑第 6 项 probe。**
25
+ **2026-09-23 实测:离线 6/6,带密钥 7/7。** 本文档早先那版配方已作废 —— 从检出根跑连 import 都过不去,从 `packages/core/agent-loop` 跑则第 6 项**假报 FAIL**。
33
26
 
34
27
  ## 已知行为(源码级确认,不必重新验证)
35
28
 
package/docs/DECISIONS.md CHANGED
@@ -186,7 +186,7 @@ Per D1, this is not a security-boundary problem (it never defended against delib
186
186
 
187
187
  **Evidence:** on 2026-09-20 a real API was hit with an invalid key: `HTTP 401` → classified `auth` → degraded for 30 minutes,
188
188
  the state file wrote out the real error body, the second call was `source: degraded` with **zero requests**, and `guard status` exited 3.
189
- There are also 54 offline assertions (`tools/selftest-quota.mjs`, with a stand-in fetch covering 402/401/429/5xx/timeout/network/bad state file).
189
+ There are also 109 offline assertions (`tools/selftest-quota.mjs`, with a stand-in fetch covering 402/401/403 (JSON auth vs edge HTML)/two kinds of 429/5xx/timeout/network/no key/a corrupt state file/a state file that cannot be removed).
190
190
 
191
191
  ---
192
192
 
@@ -446,8 +446,9 @@ installable at all (zero dependencies, no build step, source install with no bui
446
446
  `ctx.credentials` → environment → `apiKeyFile`, sharing the CLI's path rule (a relative path resolves
447
447
  against the package root, independent of cwd).
448
448
 
449
- **Evidence.** `tools/selftest-quota.mjs` (78 cases) covers the sticky state, the never-probe rule, scope
450
- isolation in both directions, and the clear-on-key. `tools/smoke-dsh-adapter.mjs` (21 assertions, now
449
+ **Evidence.** `tools/selftest-quota.mjs` (109 cases) covers the sticky state, the never-probe rule, scope
450
+ isolation in both directions, the clear-on-key, and (since 2026-09-23) a state file that cannot be removed.
451
+ `tools/smoke-dsh-adapter.mjs` (23 assertions, now
451
452
  hermetic — it no longer writes into the real `~/.jev-guard/`) covers the notice being **appended** rather
452
453
  than replacing, the empty-batch guard, the four-key `source` shape and the summary bound, and the file
453
454
  fallback. `tools/selftest-entry.mjs` runs `guard key set` for real (including the interactive path through a
@@ -467,3 +468,33 @@ it fires per state transition, never per step.
467
468
  **Rule of thumb:** when a plugin "must tell the user something" and ships no UI, first ask what the host
468
469
  already renders — a conversation notice is durable, attributed and model-visible; inventing a UI surface is
469
470
  a different project with a different dependency budget.
471
+
472
+ ---
473
+
474
+ ## D16 · A `403` is split by its **body**: an HTML/WAF page is an **edge** block and does not degrade; JSON stays `auth` (2026-09-23)
475
+
476
+ **Decision:** `401`/`403` no longer map to `auth` wholesale. A response that does not look like it came from the JSON API —
477
+ a `text/html` content-type, an HTML body (`<!doctype html`), or a WAF fingerprint (Cloudflare's `cf-ray` /
478
+ `Attention Required` / `Error code: 10xx`, and Sucuri, Akamai, Imperva) — is classified into a new, **non-degrading** class
479
+ `edge`: no state file, no cooldown, per-call fail-open, recorded as `errorKind: edge`. A 401/403 whose body **is** JSON stays
480
+ `auth` and degrades for 30 minutes, because that is what a rejected key actually looks like.
481
+
482
+ **Why:** 2026-09-23 02:43:19Z, on a live deployment: one judgment came back `403` carrying Cloudflare's generic HTML error
483
+ page (183 ms — a quick edge rejection, not a timeout). The old single line filed it as `auth`, wrote a **global** 30-minute
484
+ cooldown and told the user "the judging service rejected or revoked the key". All three of those statements were wrong: the
485
+ request never reached the application, the key was never read, and the same key answered `200` 0.2 seconds earlier and
486
+ 6 minutes later. The shape of a genuinely rejected key was measured against the live API with a deliberately invalid key:
487
+ `401` with `application/json` and `error_type: authentication_error` — and that shape still degrades.
488
+
489
+ **Why no cooldown at all, rather than a short one:** an edge block is not a statement about the service's attitude toward us;
490
+ it may last a second or an hour and we cannot tell which. A cooldown would switch the semantic layer off for every command in
491
+ the meantime on the strength of a guess, while per-call fail-open costs one edge round-trip and keeps the class visible in
492
+ `guard log --stats`. That is the rule D9 already applies to timeouts and 5xx.
493
+
494
+ **When the state file cannot be deleted** (a read-only filesystem — the sandbox case that produced this report) the probe
495
+ itself still succeeds, so the valve keeps judging online; what must not happen is the bookkeeping turning every command into
496
+ a "probe". That verdict therefore carries `clearFailed` with the errno, the stale window stops driving decisions inside that
497
+ process, and the adapter records one `level: 'warn'` audit entry so the cause stays findable; `clearDegraded()` returns the
498
+ errno instead of swallowing it, and `guard status --clear` prints it and exits 1 rather than claiming "nothing to clear".
499
+ What cannot be fixed from inside is a **new process**: it reads the same old file, and since nobody can delete it, one more
500
+ probe happens. That is the honest limit of a filesystem that refuses to forget.
@@ -186,7 +186,7 @@ D8 里"把宿主逻辑挡在 `lib/` 之外"这条**仍然有效**,但理由在 D
186
186
 
187
187
  **依据:** 2026-09-20 用无效密钥打真实 API 实测:`HTTP 401` → 分类 `auth` → 降级 30 分钟、
188
188
  状态文件写出真实错误体、第二次调用 `source: degraded` 且**零请求**、`guard status` 退出码 3。
189
- 另有 54 条离线断言(`tools/selftest-quota.mjs`,替身 fetch 覆盖 402/401/429/5xx/超时/网络/坏状态文件)。
189
+ 另有 109 条离线断言(`tools/selftest-quota.mjs`,替身 fetch 覆盖 402/401/403(JSON 鉴权 vs 边缘 HTML)/429两种/5xx/超时/网络/无密钥/坏状态文件/删不掉的状态文件)。
190
190
 
191
191
  ---
192
192
 
@@ -431,8 +431,8 @@ escalate —— 对带 L0 `deny` 规则的硬命中也一样。那意味着 `ask
431
431
  现在适配器的顺序是 `ctx.credentials` → 环境变量 → `apiKeyFile`,与 CLI 共用同一条路径规则
432
432
  (相对路径按包根解析,与 cwd 无关)。
433
433
 
434
- **证据。** `tools/selftest-quota.mjs`(78 例)覆盖粘性状态、永不探测的规则、双向的作用域隔离、以及
435
- "密钥出现即清除"。`tools/smoke-dsh-adapter.mjs`(21 组断言,现在已密闭 —— 它不再往真实的
434
+ **证据。** `tools/selftest-quota.mjs`(109 例)覆盖粘性状态、永不探测的规则、双向的作用域隔离、
435
+ "密钥出现即清除",以及(2026-09-23 起)删不掉的状态文件。`tools/smoke-dsh-adapter.mjs`(23 组断言,现在已密闭 —— 它不再往真实的
436
436
  `~/.jev-guard/` 写东西)覆盖 notice 是**追加**而非替换、空批次守卫、四键 `source` 形状与摘要上限、
437
437
  以及文件回退层。`tools/selftest-entry.mjs` 真的跑了一遍 `guard key set`(包括通过伪 TTY 走的交互路径),
438
438
  并断言文件被写出、可解析、POSIX 上权限为 0600、且从不回显。
@@ -447,3 +447,29 @@ escalate —— 对带 L0 `deny` 规则的硬命中也一样。那意味着 `ask
447
447
 
448
448
  **经验法则:** 当一个插件"必须告诉用户点什么"而它没有任何 UI 时,先问宿主已经在渲染什么 —— 会话内
449
449
  notice 是持久的、带来源标注的、模型可见的;而自己造一个 UI 界面是另一个项目,有另一份依赖预算。
450
+
451
+ ---
452
+
453
+ ## D16 · `403` 按**正文**分流:HTML/WAF 页是 **edge**(不降级),JSON 仍是 `auth`(2026-09-23)
454
+
455
+ **决定:** `401`/`403` 不再一律归 `auth`。凡是**不像那个 JSON 应用发的**响应 —— content-type 是 `text/html`、
456
+ 正文里有 HTML 结构(`<!doctype html`)、或带 WAF 指纹(Cloudflare 的 `cf-ray` / `Attention Required` /
457
+ `Error code: 10xx`,以及 Sucuri、Akamai、Imperva)—— 归入新的、**不降级**的 `edge` 类:不写状态文件、没有冷却、
458
+ 逐次 fail-open,记为 `errorKind: edge`。而正文**是** JSON 的 401/403 仍是 `auth`,冷却 30 分钟 —— 那才是密钥被拒的真实形状。
459
+
460
+ **依据:** 2026-09-23 02:43:19Z,实机:一次判定拿到 `403` + Cloudflare 的通用 HTML 错误页(183ms —— 快速的边缘拒绝,
461
+ 不是超时)。旧的那一行把它归成 `auth`,写下**全局** 30 分钟冷却,并告诉用户"判定服务的密钥无效或被撤销"。
462
+ 三句话全错:请求没到应用层、密钥没被读过、而同一把密钥在 0.2 秒前和 6 分钟后都是 `200`。
463
+ 真正被拒的密钥长什么样,则是用故意无效的密钥打真机量出来的:`401` + `application/json` +
464
+ `error_type: authentication_error` —— 那个形状照旧降级。
465
+
466
+ **为什么干脆不给冷却(而不是给一个短的):** 边缘拦截不是"服务对我们的态度";它可能持续一秒,也可能持续一小时,
467
+ 我们分不出来。冷却等于凭一个猜测把语义层对所有命令关掉;而逐次 fail-open 只花一次边缘往返,并且让这一类在
468
+ `guard log --stats` 里可见。这正是 D9 已经用在超时与 5xx 上的规矩。
469
+
470
+ **状态文件删不掉时**(只读文件系统 —— 就是产生这份报告的那个沙箱),探测本身仍然成功,所以阀门照常联网判定;
471
+ 不能发生的是"记账把每条命令都变成一次探测"。所以那次判定带着 `clearFailed` 与 errno,那份过期窗口在该进程内不再
472
+ 参与判断,适配器另落一条 `level: 'warn'` 审计记录让原因可寻;`clearDegraded()` 把 errno 交出来而不是吞掉,
473
+ `guard status --clear` 会打印它并以退出码 1 结束,而不是说一句"无需清除"。从进程内部修不了的只剩一件事:
474
+ **新起的进程**会读到同一份旧文件,而既然谁也删不掉它,那次探测就会再发生一次 —— 这是一个"不肯忘记"的
475
+ 文件系统的诚实上限。
@@ -67,7 +67,8 @@ Details, measured evidence, and the nature of each of the three channels are in
67
67
 
68
68
  | Failure category | Valve behaviour | `source` in the audit |
69
69
  |---|---|---|
70
- | `quota` (402 / quota wording) / `auth` (401/403) | **Degrades**: writes `~/.jev-guard/degraded.json`, sends no more requests within the cooldown window, by default only the free L0 + pre-screen runs | First time: `error` + `degraded`; afterwards: `degraded` |
70
+ | `quota` (402 / quota wording) / `auth` (401/403 with a JSON body) | **Degrades**: writes `~/.jev-guard/degraded.json`, sends no more requests within the cooldown window, by default only the free L0 + pre-screen runs | First time: `error` + `degraded`; afterwards: `degraded` |
71
+ | `edge` (401/403 carrying an HTML/WAF error page) | **Does not degrade**: that response did not come from the judging service — a CDN/WAF blocked the request at the edge, so the key was never checked. Each call fails open and is recorded as `edge` | `error` |
71
72
  | `no-key` (no key could be resolved) | **Degrades, stickily and scoped**: no HTTP is sent at all, the state never expires with time (there is nothing to probe) and is cleared the moment a key resolves; it records the identity of the entry that reported it, so it suppresses only that entry | First time: `error` + `degraded`; afterwards: `degraded` |
72
73
  | `timeout` / `network` / `server` / `rate-limit` | **Does not degrade**, fail-open each time, recorded classified by `errorKind` | `error` |
73
74
 
@@ -76,6 +77,11 @@ Details, measured evidence, and the nature of each of the three channels are in
76
77
  **D15 keeps that objection and answers it with scope instead of silence**: a local state records *who wrote it*, and an entry
77
78
  obeys only its own (`'cli'` / `'dsh-adapter'`). Service-side states stay `global`.
78
79
 
80
+ `edge` is the same lesson one layer further out (D16, 2026-09-23): a `403` **whose body is not JSON** is not the judging
81
+ service talking, it is a CDN/WAF error page — filing it under `auth` turned one edge hiccup into half an hour of global
82
+ silence plus a label pointing at the wrong cause. The split is decided by the response shape (content-type / HTML body /
83
+ WAF fingerprints), and anything that does look like JSON stays `auth`.
84
+
79
85
  ### 4b. The in-session notice: the only way a host-only plugin can speak to the user
80
86
 
81
87
  DSH gives a plugin without `dsh.client` **no** toast, banner or startup notice — every settings/Plugins seat is a browser-side
@@ -65,7 +65,8 @@
65
65
 
66
66
  | 失败类别 | 阀门行为 | 审计里的 `source` |
67
67
  |---|---|---|
68
- | `quota`(402 / 额度字样)/ `auth`(401/403) | **降级**:写 `~/.jev-guard/degraded.json`,冷却窗口内不再发请求,默认只跑免费的 L0 + 预筛 | 第一次:`error` + `degraded`;之后:`degraded` |
68
+ | `quota`(402 / 额度字样)/ `auth`(401/403,正文是 JSON) | **降级**:写 `~/.jev-guard/degraded.json`,冷却窗口内不再发请求,默认只跑免费的 L0 + 预筛 | 第一次:`error` + `degraded`;之后:`degraded` |
69
+ | `edge`(401/403,带 HTML/WAF 错误页) | **不降级**:这份响应不是判定服务发的 —— CDN/WAF 在边缘就把请求拦了,密钥根本没被检查。逐次 fail-open,分类记为 `edge` | `error` |
69
70
  | `no-key`(解析不到密钥) | **降级,且粘性 + 带作用域**:一次 HTTP 都不发,状态不随时间到期(没有可探测对象),密钥一出现即清除;写入时记下"是哪条入口报告的",所以只压制那一条入口 | 第一次:`error` + `degraded`;之后:`degraded` |
70
71
  | `timeout` / `network` / `server` / `rate-limit` | **不降级**,逐次 fail-open,以 `errorKind` 分类记录 | `error` |
71
72
 
@@ -73,6 +74,10 @@
73
74
  一条路径读不到密钥,不该把别的路径也按停。**D15 保留这条反对意见,但用作用域而不是沉默来回答**:本地状态
74
75
  记下*是谁写的*,而一条入口只遵守属于它自己的那份(`'cli'` / `'dsh-adapter'`)。服务侧状态仍是 `global`。
75
76
 
77
+ `edge` 是同一教训再往外一层的对应物(D16,2026-09-23):**正文不是 JSON** 的 `403` 不是判定服务在说话,而是
78
+ CDN/WAF 的错误页 —— 把它归进 `auth`,一次边缘抖动就换来半小时全局失能,外加一个指向错误原因的标签。
79
+ 两者的分界由响应形状决定(content-type / HTML 正文 / WAF 指纹),而像 JSON 的一律仍是 `auth`。
80
+
76
81
  ### 4b. 会话内 notice:纯 host 插件唯一能对用户说话的渠道
77
82
 
78
83
  对于没有 `dsh.client` 的插件,DSH 不给任何 toast / banner / 启动提示 —— 设置页与 Plugins 页的每个位置都是
@@ -244,8 +244,8 @@ The judging service is paid, so "out of quota" has to be designed for as a **cer
244
244
  | `degradePolicy: 'off'` | even L0 allows it through (an explicit choice; this is not the default) |
245
245
  | Automatic recovery | when the cooldown expires it fires **one** probe; measured with a stub fetch: on success → clears the state and records `probe+recovered`, on failure → extends it (failures+1, probes+1) and does not try again |
246
246
 
247
- **52 offline assertions** (`tools/selftest-quota.mjs`, with a stub `fetch` covering 402/401/403/two kinds of 429/5xx/timeout/network/no key/
248
- a corrupt state file), two of which are **real bugs it caught itself**, recorded here as well:
247
+ **109 offline assertions** (`tools/selftest-quota.mjs`, with a stub `fetch` covering 402/401/403 (JSON auth vs edge HTML)/two kinds of 429/5xx/timeout/network/no key/
248
+ a corrupt state file/a state file that cannot be removed), two of which are **real bugs it caught itself**, recorded here as well:
249
249
 
250
250
  1. `readDegraded` validated the ISO string with `Number(until)` → always NaN → **written into the file yet never readable**,
251
251
  the whole degradation mechanism failed silently (without throwing).
@@ -254,6 +254,27 @@ a corrupt state file), two of which are **real bugs it caught itself**, recorded
254
254
 
255
255
  Both are "silent failure" bugs, caught only because "an assertion was written for every branch" — the same lesson as §7.
256
256
 
257
+ **Update (2026-09-23): a `403` splits by body — an edge block is not a rejected key.**
258
+ A live deployment failed one judgment at `02:43:19Z` with `HTTP 403` carrying Cloudflare's generic HTML error page
259
+ (183 ms — a quick edge rejection, not a timeout), 0.2 seconds after a judgment that had succeeded; the same key then
260
+ answered `200` both through the proxy and directly. The classifier of the day mapped `401 || 403` to `auth` in a single line,
261
+ so that page wrote a **global** 30-minute cooldown plus a label saying the key was invalid or revoked — none of which was
262
+ true, since the request never reached the application. Re-probing the live API with a deliberately invalid key confirms what
263
+ a rejected key really looks like: `401` + `application/json` + `error_type: authentication_error`, which still degrades. The
264
+ fork is now decided by the response shape, and the offline suite pins both directions (`403` + HTML → `edge`, no state file,
265
+ the next command judged online again; `403` + JSON → `auth`, degraded).
266
+
267
+ **Also measured (2026-09-23): a state file that cannot be deleted.** The read-only-filesystem case is reproduced without a
268
+ read-only filesystem, by swapping the state file for a directory while the probe request is in flight — the `unlink` that
269
+ follows fails with `EISDIR`/`EPERM`, exactly as `EROFS` would. Measured: that verdict carries `clearFailed` with the errno,
270
+ the next command is judged online and is **not** recorded as a probe again (before the fix it was `probe: true` +
271
+ `recovered: true` every time), and a freshly written state file still degrades as usual.
272
+
273
+ **And the cache holds a judgment, not a call log (2026-09-23).** Same method: one command judged twice inside one process —
274
+ one real call plus one cache hit — was counted by `guard log --stats` as two priced calls carrying 1400 input tokens, for a
275
+ call that spent 700. The cached verdict was replaying its `usage` together with its `probe`/`recovered` markers, so a
276
+ `source: cache` line could claim to be a probe at the same time. The cache now stores the judgment only.
277
+
257
278
  ## 10. The cross-platform entry guard: the same pitfall stepped on twice (2026-09-20)
258
279
 
259
280
  **Symptom:** a script executed directly as `node <path>` on Windows **prints nothing, exits 0 and writes no log at all** —
@@ -262,8 +262,8 @@ python 源码的字符串里(`c.startswith('mkfs.ext4 …')`),被 `mkfs` 规则
262
262
  | `degradePolicy: 'off'` | 连 L0 也放行(显式选择;默认不是这个) |
263
263
  | 自动恢复 | 冷却到期后放**一次**探测;替身 fetch 实测:成功 → 清状态并记 `probe+recovered`,失败 → 续期(failures+1、probes+1)且不再重复试 |
264
264
 
265
- **离线断言 52 条**(`tools/selftest-quota.mjs`,替身 `fetch` 覆盖 402/401/403/429两种/5xx/超时/网络/无密钥/
266
- 坏状态文件),其中两条是**自己抓到的真 bug**,一并记在这里:
265
+ **离线断言 109 条**(`tools/selftest-quota.mjs`,替身 `fetch` 覆盖 402/401/403(JSON 鉴权 vs 边缘 HTML)/429两种/5xx/超时/网络/无密钥/
266
+ 坏状态文件/删不掉的状态文件),其中两条是**自己抓到的真 bug**,一并记在这里:
267
267
 
268
268
  1. `readDegraded` 用 `Number(until)` 校验 ISO 字符串 → 恒为 NaN → **写进去了却永远读不出来**,
269
269
  整个降级机制静默失效(不抛异常)。
@@ -272,6 +272,25 @@ python 源码的字符串里(`c.startswith('mkfs.ext4 …')`),被 `mkfs` 规则
272
272
 
273
273
  两个都是"静默失效"型错误,都靠"每个分支都写一条断言"才被抓住 —— 与 §7 的教训同源。
274
274
 
275
+ **更新(2026-09-23):`403` 按正文分流 —— 边缘拦截不是密钥被拒。**
276
+ 一个实机部署在 `02:43:19Z` 的一次判定上拿到 `HTTP 403` + Cloudflare 的通用 HTML 错误页
277
+ (183ms —— 是快速的边缘拒绝,不是超时),而 0.2 秒前的那次判定还是成功的;同一把密钥随后经代理与直连都是 `200`。
278
+ 当时的分类器把 `401 || 403` 一行映射到 `auth`,于是那个页面写下**全局** 30 分钟冷却,外加一个"密钥无效或被撤销"
279
+ 的标签 —— 三句都不成立,因为请求根本没到应用层。用故意无效的密钥复打真机,确认了被拒密钥的真实形状:
280
+ `401` + `application/json` + `error_type: authentication_error`,而那个形状照旧降级。现在这个分流由响应形状决定,
281
+ 离线自检把两个方向都钉住了(`403` + HTML → `edge`,不写状态文件,下一条命令照常联网判定;`403` + JSON →
282
+ `auth`,降级)。
283
+
284
+ **同样实测(2026-09-23):删不掉的状态文件。** 只读文件系统这件事不需要真的只读也能复现 —— 在探测请求
285
+ "进行中"把状态文件换成一个目录,随后的 `unlink` 就会以 `EISDIR`/`EPERM` 失败,与 `EROFS` 完全同类。实测:
286
+ 那次判定带着 `clearFailed` 与 errno;下一条命令照常联网判定,而且**不再**被记成一次探测(修复前每次都记
287
+ `probe: true` + `recovered: true`);新写入的状态文件照常降级。
288
+
289
+ **而缓存是判定缓存,不是调用日志(2026-09-23)。** 方法同上:同一条命令在一个进程里判两次(一次真实调用、
290
+ 一次缓存命中),`guard log --stats` 会把它算成两次计价调用、累计 1400 input tokens,而真实只花了 700 ——
291
+ 因为缓存里的判定把 `usage` 连同 `probe`/`recovered` 标记一起回放了(于是 `source: cache` 的记录会自称探测)。
292
+ 现在缓存只存判定本身。
293
+
275
294
  ## 10. 跨平台入口守卫:同一个坑踩了两次(2026-09-20)
276
295
 
277
296
  **现象:** 一个以 `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
  ---