dsh-jev-guard 0.5.3 → 0.5.5
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 +30 -0
- package/CHANGELOG.zh-CN.md +30 -0
- package/DEPLOY.md +3 -2
- package/DEPLOY.zh-CN.md +3 -2
- package/README.md +45 -4
- package/README.zh-CN.md +41 -4
- package/bin/guard.mjs +6 -1
- package/config.example.json +2 -1
- package/cordis.patch.yml +8 -1
- package/docs/DECISIONS.md +75 -0
- package/docs/DECISIONS.zh-CN.md +63 -0
- package/docs/MEASUREMENTS.md +11 -3
- package/docs/MEASUREMENTS.zh-CN.md +11 -4
- package/docs/VERIFICATION.md +1 -1
- package/docs/VERIFICATION.zh-CN.md +1 -1
- package/lib/i18n.js +8 -0
- package/lib/quota.js +33 -0
- package/package.json +1 -1
- package/tools/check-doc-pairs.mjs +1 -0
- package/tools/selftest-quota.mjs +32 -5
- package/tools/smoke-dsh-adapter.mjs +14 -3
- package/tools/smoke-dsh-pipeline.mjs +25 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@
|
|
|
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.5] — 2026-10-15
|
|
9
|
+
|
|
10
|
+
**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`.
|
|
11
|
+
|
|
12
|
+
**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).
|
|
13
|
+
|
|
14
|
+
**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).
|
|
15
|
+
|
|
16
|
+
**What changed:**
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
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.
|
|
20
|
+
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`.
|
|
21
|
+
|
|
22
|
+
**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.
|
|
23
|
+
|
|
24
|
+
## [0.5.4] — 2026-09-30
|
|
25
|
+
|
|
26
|
+
**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.
|
|
27
|
+
|
|
28
|
+
**What happened.** Running against a real session on the Windows desktop build of DSH 0.2.0-rc.2: whenever a command reached the **online semantic judgment**, `node bin/guard.mjs judge …` printed the right verdict (`block 0.91 jev`) and then, on the way out, hit libuv's `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76`. The process aborted with `0xC0000409`, so the exit code went from the real one (`3` for a block) to `-1073740791`, plus a libuv assertion on stderr that means nothing to the user. The cause is not in the judgment: at that instant the process still holds undici's keep-alive sockets (measured — `process._getActiveHandles()` returns `["Socket","Socket"]` alongside one pending `FSReqCallback`), and forcing an exit while they are closing trips that Windows libuv assertion. **The verdict was always right; the exit path was wrong** — which is exactly why this is nearly invisible if you only read stdout.
|
|
29
|
+
|
|
30
|
+
**What changed:**
|
|
31
|
+
|
|
32
|
+
1. **`bin/guard.mjs` and the two smoke scripts set `process.exitCode` instead of calling `process.exit()`.** The loop drains and the process leaves with the verdict's real code, no assertion. Measured: `guard judge` returns to exit code 3 on a `block`, and letting the loop drain costs about a second.
|
|
33
|
+
2. **`JEV_GUARD_ROOT` in `tools/smoke-dsh-pipeline.mjs` now takes a plain filesystem path.** The value was interpolated straight into `import()`, and the ESM loader parses a specifier as a URL first: Windows' `T:\…` came out as protocol `t:` and failed with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. It is converted to a `file://` URL now, so `/mnt/t/…` and `T:\…` both work as given, and a value that already is a URL is left alone.
|
|
34
|
+
3. **The "no key" section of `tools/smoke-dsh-adapter.mjs` now really runs with no key.** The adapter resolves credentials in the order `ctx.credentials` → `apiKeyEnv` (default `TYPESAFE_API_KEY`) → `apiKeyFile`, and those cases only mocked the first layer — so a real key in the environment made the premise false. With one present, three notice assertions failed falsely (measured: "no key → injected a notice" reported `["user-1"]`, i.e. nothing was injected at all). The environment variable is cleared for those cases and restored before the online ones.
|
|
35
|
+
|
|
36
|
+
**Acceptance** (Windows, Node 24.12.0, on the DSH 0.2.0-rc.2 desktop host): `node bin/guard.mjs selftest` 12/12; the seven offline suites 20 / 30 / 34 / 109 / 54 / 48 / 17 assertions, all passing (`selftest-entry` and `selftest-reason` branch on platform and are one and two cases shorter on Windows than on Linux); the DSH adapter smoke test **23/23 without a key and 25/25 with one**, where the with-key run used to be "3 failures plus an abort"; the real tool-pipeline integration test 6/6 offline and 7/7 with `TYPESAFE_API_KEY`, exit code 0. `guard judge` on a live `block` verdict measured exit code 3. Each fix was confirmed in both directions: with `process.exit()` restored, the CLI and the with-key adapter smoke ended at `-1073740791` every time (the pipeline script reproduced twice in a row); a bare `JEV_GUARD_ROOT` raised `ERR_UNSUPPORTED_ESM_URL_SCHEME` until the conversion was added; and with the environment variable left in place the notice section failed its three assertions. Measured on Windows; the change itself is platform-neutral (setting `exitCode` rather than forcing an exit) and Linux is covered by the CI workflow, which runs both.
|
|
37
|
+
|
|
8
38
|
## [0.5.3] — 2026-09-23
|
|
9
39
|
|
|
10
40
|
**A `403` no longer means "your key is bad": an edge block is its own class now, and it does not degrade.** This is a behaviour change — one failure category was split in two, and the misreporting in `guard status --clear` was fixed. `lib/`, `bin/`, `adapters/` and the default config are otherwise unchanged from 0.5.2.
|
package/CHANGELOG.zh-CN.md
CHANGED
|
@@ -5,6 +5,36 @@
|
|
|
5
5
|
本项目遵循「按日期记录事实」的写法:每条都写清**改了什么、为什么、以及怎么验证的**。
|
|
6
6
|
完整的设计取舍见 [`docs/DECISIONS.md`](./docs/DECISIONS.md),实测数据见 [`docs/MEASUREMENTS.md`](./docs/MEASUREMENTS.md)。
|
|
7
7
|
|
|
8
|
+
## [0.5.5] — 2026-10-15
|
|
9
|
+
|
|
10
|
+
**默认模型从浮动别名改为固定版本号;另有两类原本会消失在 `unknown` 里的失败,现在各有分类、会降级、而且会说出来。** 前者改变新装默认拿哪个模型判定;后者是 `lib/quota.js` 的行为变更。
|
|
11
|
+
|
|
12
|
+
**为什么固定版本号。** 插件原来发出去的是 `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**。
|
|
13
|
+
|
|
14
|
+
**为什么新增两个分类。** 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)。
|
|
15
|
+
|
|
16
|
+
**改了什么:**
|
|
17
|
+
|
|
18
|
+
1. **`region`(HTTP 451)= 会降级的 30 分钟冷却,作用域全局。** 正文是判定服务自己的 JSON,所以这不是 `edge` 拦截:请求到了服务、被它的地区策略拒了。形状与 `auth` 相同,而且 `guard status` 现在会说出原因,而不是报一切正常。
|
|
19
|
+
2. **`request`(HTTP 400/422)= 会降级的 15 分钟冷却,作用域本地。** 被拒的是请求本身 —— `model` 写错、正文不合法 —— 重试没有意义,要改的是配置。它跟着 `no-key` 的作用域规矩(D15):只暂停写下状态的那条入口的联网判定,别的入口照常工作。
|
|
20
|
+
3. **两类都有双语文案**(`quota.region.label|hint`、`quota.request.label|hint`),而且每条 hint 都说清了该做什么:`region` 先查代理与出口 IP,`request` 去改 `model`。
|
|
21
|
+
|
|
22
|
+
**验收**(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 条跨过阈值带。
|
|
23
|
+
|
|
24
|
+
## [0.5.4] — 2026-09-30
|
|
25
|
+
|
|
26
|
+
**Windows 上,一次判定结论完全正确的联网判定,会以崩溃退出码结束。** 现在退出改为让事件循环自然排空;另外修掉两个冒烟脚本在 Windows 上的实测偏差。判定路径一行未动 —— `lib/` 与 `adapters/` 相对 0.5.3 完全一致。
|
|
27
|
+
|
|
28
|
+
**出了什么事。** 在 Windows 桌面版 DSH 0.2.0-rc.2 的真实会话上跑出来:只要那条命令走到**联网语义判定**,`node bin/guard.mjs judge …` 会先打印出正确的判定(`block 0.91 jev`),然后在退出那一刻撞上 libuv 的 `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76`,进程以 `0xC0000409` abort —— 退出码从真值(block 是 `3`)变成 `-1073740791`,stderr 上还多一行与用户无关的断言。根因不在判定里:退出这一刻进程仍持有 undici 的 keep-alive socket(实测 `process._getActiveHandles()` 拿到 `["Socket","Socket"]` 外加一个未决的 `FSReqCallback`),在它们正被关闭时强退,Windows 的 libuv 就会崩在这个断言上。**判定的结论一直是对的,错的是退出路径** —— 也正因如此,只盯着 stdout 看的话这条 bug 几乎不可见。
|
|
29
|
+
|
|
30
|
+
**改了什么:**
|
|
31
|
+
|
|
32
|
+
1. **`bin/guard.mjs` 与两个冒烟脚本改用 `process.exitCode`,不再调用 `process.exit()`。** 事件循环自然排空后退出,退出码是判定的真值,断言不再出现。实测:`guard judge` 的 block 判定退出码恢复为 3,自然排空的额外耗时在 1 秒量级。
|
|
33
|
+
2. **`tools/smoke-dsh-pipeline.mjs` 的 `JEV_GUARD_ROOT` 现在接受裸文件系统路径。** 这个值原本被直接拼进 `import()`,而 ESM 加载器先按 URL 解析说明符:Windows 的 `T:\…` 被读成协议 `t:`,报 `ERR_UNSUPPORTED_ESM_URL_SCHEME`。现在统一转成 `file://` URL,`/mnt/t/…` 与 `T:\…` 都能照原样用,而本身就是 URL 的值原样保留。
|
|
34
|
+
3. **`tools/smoke-dsh-adapter.mjs` 的「没有密钥」段落现在真的在没有密钥的条件下跑。** 适配器的凭据解析顺序是 `ctx.credentials` → `apiKeyEnv`(默认 `TYPESAFE_API_KEY`)→ `apiKeyFile`,而那段用例只 mock 掉了第一层 —— 环境变量里若有真钥匙,这个前提就不成立。带着钥匙跑时,notice 的 3 组断言会假失败(实测:「没有密钥 → 注入了一条 notice」报出 `["user-1"]`,即根本没有注入)。现在这几段用例期间环境变量被摘掉,联网用例之前再放回。
|
|
35
|
+
|
|
36
|
+
**验收**(Windows、Node 24.12.0,宿主为 DSH 0.2.0-rc.2 桌面版):`node bin/guard.mjs selftest` 12/12;七套离线自检 20 / 30 / 34 / 109 / 54 / 48 / 17 条断言全过(`selftest-entry` 与 `selftest-reason` 按平台分支,Windows 上比 Linux 少一例与两例);DSH 适配器冒烟**无密钥 23/23、带密钥 25/25** —— 修复前带密钥跑是「3 组失败 + 一次 abort」;真实工具管线集成测试离线 6/6、带 `TYPESAFE_API_KEY` 7/7,退出码 0。`guard judge` 的联网 block 判定实测退出码 3。三处修复都做了双向确认:把 `process.exit()` 放回去,CLI 与带密钥的适配器冒烟每次都停在 `-1073740791`(管线脚本连跑两次都复现);`JEV_GUARD_ROOT` 用裸路径时在加上转换之前一直报 `ERR_UNSUPPORTED_ESM_URL_SCHEME`;环境变量留着不放,notice 那段的三组断言就继续失败。本轮在 Windows 上实测;改动本身是平台中性的(设 `exitCode` 而不是强退),Linux 侧由同时跑两个平台的 CI 工作流覆盖。
|
|
37
|
+
|
|
8
38
|
## [0.5.3] — 2026-09-23
|
|
9
39
|
|
|
10
40
|
**`403` 不再等于"你的密钥坏了":边缘拦截现在是一个独立分类,而且不降级。** 这是一处行为变更 —— 一个失败分类被拆成两个,并修掉了 `guard status --clear` 的误报。除此之外 `lib/`、`bin/`、`adapters/` 与默认配置相对 0.5.2 未变。
|
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** —
|
|
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` |
|
|
36
36
|
|
|
37
37
|
## 2. Install and configure
|
|
38
38
|
|
|
@@ -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
|
-
| 网络 |
|
|
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**
|
|
35
|
+
| DSH 版本 | **0.1.6-alpha.2**、**0.1.7-rc.2** 与 **0.2.0-rc.2** 均已完整验证;跑了哪些见 README 的「已验证的宿主版本」段。`package.json` 未声明宿主要求,所以插件市场不会因版本不同而阻拦安装 | `dsh --version` |
|
|
36
36
|
|
|
37
37
|
## 2. 安装与配置
|
|
38
38
|
|
|
@@ -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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
[](#platform-support)
|
|
9
9
|
[](https://github.com/7starsseeker/dsh-jev-guard/tags)
|
|
10
10
|
[](https://www.npmjs.com/package/dsh-jev-guard)
|
|
11
|
+
[](https://www.npmjs.com/package/dsh-jev-guard)
|
|
11
12
|
[](https://github.com/7starsseeker/dsh-jev-guard/actions/workflows/selftest.yml)
|
|
12
13
|
[](https://github.com/7starsseeker/dsh-jev-guard/commits/main)
|
|
13
14
|
[](https://github.com/7starsseeker/dsh-jev-guard/stargazers)
|
|
@@ -41,6 +42,7 @@ command text ──▶ L0 static hard rules (offline · cannot be overridden)
|
|
|
41
42
|
- [Behavior under the two approval policies](#behavior-under-the-two-approval-policies)
|
|
42
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)
|
|
43
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)
|
|
44
46
|
- [Visibility: audit log and status](#visibility-audit-log-and-status)
|
|
45
47
|
- [Platform support](#platform-support)
|
|
46
48
|
- [Self-checks and verification](#self-checks-and-verification)
|
|
@@ -69,7 +71,13 @@ Three layers, always in this order:
|
|
|
69
71
|
|
|
70
72
|
Requires **Node ≥ 20** (it uses the global `fetch`). **Zero runtime dependencies** — no `npm install` needed.
|
|
71
73
|
|
|
72
|
-
**Verified host
|
|
74
|
+
**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`.
|
|
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.
|
|
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
|
+
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.
|
|
73
81
|
|
|
74
82
|
```bash
|
|
75
83
|
# 1. Put this repository somewhere permanent, e.g. T:\dsh-jev-guard (/mnt/t/dsh-jev-guard in WSL)
|
|
@@ -155,7 +163,7 @@ Code comments and the labels inside `tools/` stay Chinese on purpose: they are r
|
|
|
155
163
|
|
|
156
164
|
| Key | Default | Description |
|
|
157
165
|
|---|---|---|
|
|
158
|
-
| `model` | `jev-
|
|
166
|
+
| `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 |
|
|
159
167
|
| `endpoint` | `https://api.typesafe.ai/v1/systemone` | Judging service endpoint |
|
|
160
168
|
| `apiKeyEnv` / `apiKeyFile` | `TYPESAFE_API_KEY` / `secrets.json` | Key sources; **relative paths resolve against the package root** (independent of cwd) |
|
|
161
169
|
| `lang` | `auto` | Language of every human/model-facing message (see [Languages](#languages)); `auto` reads the environment only, falling back to `zh-CN` |
|
|
@@ -168,7 +176,7 @@ Code comments and the labels inside `tools/` stay Chinese on purpose: they are r
|
|
|
168
176
|
| `retryLimit` | `2` | How many times the same command may be blocked before it escalates (`escalate`, i.e. handed to a human) |
|
|
169
177
|
| `tokens` / `tokenPath` | `true` / `~/.jev-guard/allow.txt` | One-shot allow tokens |
|
|
170
178
|
| `logPath` / `logMaxBytes` | `~/.jev-guard/guard.log` / 4 MiB | Shared audit log (JSONL, rotated when over the limit) |
|
|
171
|
-
| `quotaCooldownMs` / `authCooldownMs` | 15 min / 30 min | Cooldown after quota- and key-class failures |
|
|
179
|
+
| `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 |
|
|
172
180
|
| `degradePolicy` | `l0-only` | Which layer survives degradation: `l0-only` (stop only the paid semantic layer) or `off` (suspend the whole valve) |
|
|
173
181
|
| `pricePerMTok` | `0.042` | Unit price for cost estimation (USD per million input tokens; output is free per the vendor's documentation) |
|
|
174
182
|
|
|
@@ -219,7 +227,10 @@ The judging service is **pay-per-use**, so running out of credit is a certainty.
|
|
|
219
227
|
|---|---|---|
|
|
220
228
|
| 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 |
|
|
221
229
|
| 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 |
|
|
222
|
-
|
|
|
230
|
+
| 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` |
|
|
231
|
+
| 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` |
|
|
232
|
+
| 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 |
|
|
233
|
+
| 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` |
|
|
223
234
|
|
|
224
235
|
```bash
|
|
225
236
|
node bin/guard.mjs status --clear # don't want to wait out the cooldown: retry once now (a failure re-enters degradation)
|
|
@@ -227,6 +238,35 @@ node bin/guard.mjs status --clear # don't want to wait out the cooldown: retr
|
|
|
227
238
|
|
|
228
239
|
If you want "once the credit is gone, don't interfere at all" (L0 included), set `degradePolicy: "off"`.
|
|
229
240
|
|
|
241
|
+
## If the service refuses your region: the region lock
|
|
242
|
+
|
|
243
|
+
The judging service is **not reachable from every region**: a request sent from a blocked egress comes back `HTTP 451` with
|
|
244
|
+
the service's own body, `{"title":"Typesafe is not available in your region.","status":451}` — refused before
|
|
245
|
+
authentication and before the body is validated. Measured on 2026-10-15 through the production path: the **same request
|
|
246
|
+
body** returns `422` (a body-validation error, i.e. it arrived) from an allowed egress and `451` from a blocked one.
|
|
247
|
+
|
|
248
|
+
What that means, and what it does not:
|
|
249
|
+
|
|
250
|
+
- **It is not a plugin fault, and not a key problem.** The key is never checked; a fresh, valid key fails identically.
|
|
251
|
+
- **It is a property of the egress, shared by every entry point on the machine** — the DSH host process, the CLI, a
|
|
252
|
+
scheduled job — which is why the `region` class degrades **globally** for 30 minutes instead of per entry point.
|
|
253
|
+
- **It is loud now.** Before 0.5.5 the whole condition was classified `unknown`, and `unknown` degrades nothing: the valve
|
|
254
|
+
failed open on every command, kept re-sending requests the service had just refused (83 in one measured session, all
|
|
255
|
+
`source: error`), and `guard status` still printed "normal". Now `guard status` names the cause and the warning tells you
|
|
256
|
+
to check the proxy and the egress IP.
|
|
257
|
+
|
|
258
|
+
**How to run in a blocked region:** route the **DSH host process** through an egress in an allowed region. What bites on
|
|
259
|
+
Windows is that this routing is often applied **per executable**: a proxy configured only for your shell leaves
|
|
260
|
+
`DeepSeek Harness.exe` going out directly — measured here on 2026-10-15, a proxied shell resolved to a US exit while the
|
|
261
|
+
host binary, running the *same* script, resolved to a Chinese one and got `451`. Verify from inside the host rather than
|
|
262
|
+
from a terminal: `node bin/guard.mjs judge 'ls'` should come back `source: jev`, and `guard log --stats` should show no
|
|
263
|
+
`region` count.
|
|
264
|
+
|
|
265
|
+
**The honest limit:** the valve cannot fix your egress. Under a region lock its job is to stop spending requests it has
|
|
266
|
+
been told will be refused, and to say so instead of reporting health for a session in which nothing was judged. The
|
|
267
|
+
`region` cooldown probes once per 30 minutes, so it recovers by itself as soon as the egress changes — see
|
|
268
|
+
[`docs/DECISIONS.md`](./docs/DECISIONS.md) D17.
|
|
269
|
+
|
|
230
270
|
## Visibility: audit log and status
|
|
231
271
|
|
|
232
272
|
Every judgment appends one JSONL line to `~/.jev-guard/guard.log` (rotated past 4 MiB; keys are masked before the command is written):
|
|
@@ -280,6 +320,7 @@ adapters/dsh/index.js The native DSH Cordis plugin (the only adapter)
|
|
|
280
320
|
cordis.patch.yml DSH bundle patch (mount declaration + every tunable)
|
|
281
321
|
tools/ Offline self-checks, smoke tests, verification helpers
|
|
282
322
|
docs/ Mechanics, trade-offs, measurements, acceptance checklist
|
|
323
|
+
measurements/ Raw records behind docs/MEASUREMENTS.md (not in the npm package)
|
|
283
324
|
```
|
|
284
325
|
|
|
285
326
|
## Security and privacy
|
package/README.zh-CN.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
[](#平台支持)
|
|
9
9
|
[](https://github.com/7starsseeker/dsh-jev-guard/tags)
|
|
10
10
|
[](https://www.npmjs.com/package/dsh-jev-guard)
|
|
11
|
+
[](https://www.npmjs.com/package/dsh-jev-guard)
|
|
11
12
|
[](https://github.com/7starsseeker/dsh-jev-guard/actions/workflows/selftest.yml)
|
|
12
13
|
[](https://github.com/7starsseeker/dsh-jev-guard/commits/main)
|
|
13
14
|
[](https://github.com/7starsseeker/dsh-jev-guard/stargazers)
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
- [两种审批策略下的行为](#两种审批策略下的行为)
|
|
42
43
|
- [被拦了怎么办:人的三条介入通道](#被拦了怎么办人的三条介入通道)
|
|
43
44
|
- [额度用完会怎样:降级而不是静默失效](#额度用完会怎样降级而不是静默失效)
|
|
45
|
+
- [服务拒绝你的地区时:地区锁](#服务拒绝你的地区时地区锁)
|
|
44
46
|
- [看得见:审计日志与状态](#看得见审计日志与状态)
|
|
45
47
|
- [平台支持](#平台支持)
|
|
46
48
|
- [自检与验收](#自检与验收)
|
|
@@ -69,7 +71,13 @@
|
|
|
69
71
|
|
|
70
72
|
要求 **Node ≥ 20**(用到全局 `fetch`)。**零运行时依赖**,不需要 `npm install`。
|
|
71
73
|
|
|
72
|
-
**已验证的宿主版本:DSH 0.1.6-alpha.2。**
|
|
74
|
+
**已验证的宿主版本: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` 下写下审计日志。
|
|
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 装配路径都放行。
|
|
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
|
+
这些版本都**刻意不在 `package.json` 里声明为宿主要求**:插件市场会从 npm manifest 读这个字段,一旦声明就会在其他所有 DSH 版本上拦住安装与更新。未列入的版本是**没验过,而不是被禁止**;换版本后请重跑下面的自检。
|
|
73
81
|
|
|
74
82
|
```bash
|
|
75
83
|
# 1. 把本仓库放到一个固定的位置,例如 T:\dsh-jev-guard(WSL 里是 /mnt/t/dsh-jev-guard)
|
|
@@ -155,7 +163,7 @@ node bin/guard.mjs status # 期望:✅ 正常(降级时退出码为 3)
|
|
|
155
163
|
|
|
156
164
|
| 键 | 默认 | 说明 |
|
|
157
165
|
|---|---|---|
|
|
158
|
-
| `model` | `jev-
|
|
166
|
+
| `model` | `jev-1.13.0` | 判定模型,**固定版本号**。服务的别名会在一个已发布的插件脚下移动,而本仓的阈值是在 `1.13.0` 上标定的:2026-10-15 实测,`jev-latest` 仍解析到 `jev-1.13.0`,而 `jev-preview` 解析到图像模式的 `jev-1.14.0` —— 后者**故意不跟进**:每一次判定都只是一个文本问题,图像模式模型会背上一个本插件永远不调用的能力;而且上游自己说它还不够进生产 |
|
|
159
167
|
| `endpoint` | `https://api.typesafe.ai/v1/systemone` | 判定服务地址 |
|
|
160
168
|
| `apiKeyEnv` / `apiKeyFile` | `TYPESAFE_API_KEY` / `secrets.json` | 密钥来源;**相对路径按包根解析**(与 cwd 无关) |
|
|
161
169
|
| `lang` | `auto` | 全部给人/模型看的文案的语言(见[语言](#语言));`auto` 只读环境变量,兜底 `zh-CN` |
|
|
@@ -168,7 +176,7 @@ node bin/guard.mjs status # 期望:✅ 正常(降级时退出码为 3)
|
|
|
168
176
|
| `retryLimit` | `2` | 同一条命令被拦多少次后升级为 `escalate`(交人处理) |
|
|
169
177
|
| `tokens` / `tokenPath` | `true` / `~/.jev-guard/allow.txt` | 一次性放行令牌 |
|
|
170
178
|
| `logPath` / `logMaxBytes` | `~/.jev-guard/guard.log` / 4 MiB | 共享审计日志(JSONL,超限轮转) |
|
|
171
|
-
| `quotaCooldownMs` / `authCooldownMs` | 15 min / 30 min |
|
|
179
|
+
| `quotaCooldownMs` / `authCooldownMs` | 15 min / 30 min | 额度、密钥类失败后的降级冷却。0.5.5 新增的两个分类是固定的、不可配:`region`(`451`)30 分钟、`request`(`400`/`422`)15 分钟 |
|
|
172
180
|
| `degradePolicy` | `l0-only` | 降级时保留哪一层:`l0-only`(只停要花钱的语义层)或 `off`(整条阀门暂停) |
|
|
173
181
|
| `pricePerMTok` | `0.042` | 成本估算单价(美元/百万输入 token;输出按官方说明免费) |
|
|
174
182
|
|
|
@@ -219,7 +227,10 @@ node bin/guard.mjs allow --revoke ALLOW-… # 撤销
|
|
|
219
227
|
|---|---|---|
|
|
220
228
|
| 额度耗尽 / 密钥失效(`402` / `401`) | **降级**:写 `~/.jev-guard/degraded.json`,冷却窗口内不再发请求(省钱),默认只跑**免费的 L0 + 预筛** | `guard status`(退出码 3)· 拒绝理由里的一句 `⚠️` · 审计里的 `source: degraded` 与 `level: warn` · CLI 的 stderr |
|
|
221
229
|
| 冷却到期 | 自动放**一次**探测请求:成功即恢复(你不用做任何事),失败继续降级 | `guard status` 会显示还剩多久 |
|
|
222
|
-
|
|
|
230
|
+
| 这个出口不在服务的地区内(`451`,正文是服务自己的 JSON) | **降级 30 分钟,作用域全局** —— 请求到了服务、被它的地区策略拒了,而这台机器上每条入口共用同一个出口 | `guard status` 点名原因(退出码 3)· 告警让你先查代理与出口 IP · `guard log --stats` 里的 `errorKind: region` |
|
|
231
|
+
| 请求本身被拒(`400` / `422`,例如 `Unknown model: …`) | **降级 15 分钟,只作用于写下状态的那条入口** —— 重试没有意义,要改的是 `model` | `guard status` 点名原因 · 告警指向 `model` · `guard log --stats` 里的 `errorKind: request` |
|
|
232
|
+
| 没配密钥 | **粘性降级**(D15):零 HTTP 请求,密钥一解析到就当场结束 | 会话里的一句 `⚠️` notice · `guard key status` 退出码 3 |
|
|
233
|
+
| 超时 / 网络抖 / 5xx / 429 限流 / **被边缘拦下**(CDN/WAF 用 `403` 回了一个 HTML 页:请求根本没到判定服务,密钥也没被检查过) | **不降级**,只逐次放行(fail-open),但会被分类记录 | `guard log --stats` 的"失败分类"一行 |
|
|
223
234
|
|
|
224
235
|
```bash
|
|
225
236
|
node bin/guard.mjs status --clear # 不想等冷却:立刻重试一次(失败会再次进入降级)
|
|
@@ -227,6 +238,31 @@ node bin/guard.mjs status --clear # 不想等冷却:立刻重试一次(失败
|
|
|
227
238
|
|
|
228
239
|
想"额度没了就彻底别插手"(连 L0 也停)就设 `degradePolicy: "off"`。
|
|
229
240
|
|
|
241
|
+
## 服务拒绝你的地区时:地区锁
|
|
242
|
+
|
|
243
|
+
判定服务**并非从任何地区都能访问**:从一个被拦的出口发出去的请求,回来的是 `HTTP 451`,正文是服务自己的那一段 ——
|
|
244
|
+
`{"title":"Typesafe is not available in your region.","status":451}` —— 而且它在鉴权之前、在请求正文校验之前就被拒了。
|
|
245
|
+
2026-10-15 走生产路径实测:**同一个请求体**从允许的出口得到的是 `422`(正文校验错误 —— 说明请求到了),从被拦的出口
|
|
246
|
+
得到的是 `451`。
|
|
247
|
+
|
|
248
|
+
这件事意味着什么、又不意味着什么:
|
|
249
|
+
|
|
250
|
+
- **不是插件故障,也不是密钥问题。** 密钥根本没被检查过;换一把全新的、有效的密钥,结果一模一样。
|
|
251
|
+
- **它是出口的属性,而这台机器上每条入口共用同一个出口** —— DSH 宿主进程、CLI、计划任务都一样。这也是 `region`
|
|
252
|
+
这一类降级是**全局** 30 分钟、而不是只停一条入口的原因。
|
|
253
|
+
- **它现在是"响的"。** 0.5.5 之前,这一整套状况被归进 `unknown`,而 `unknown` 什么都不降级:阀门对每条命令都
|
|
254
|
+
fail-open、把服务刚刚拒过的请求一遍遍再发出去(实测一场会话 83 条,全是 `source: error`),而 `guard status`
|
|
255
|
+
一直打印"正常"。现在 `guard status` 会点名原因,告警会告诉你去查代理与出口 IP。
|
|
256
|
+
|
|
257
|
+
**在被拦的地区怎么跑起来:** 让 **DSH 宿主进程**走一个允许地区的出口。Windows 上坑人的地方在于:这种分流往往是**按
|
|
258
|
+
可执行文件**生效的 —— 只给你的终端配了代理,`DeepSeek Harness.exe` 仍然直连出网:本机 2026-10-15 实测,同一份脚本
|
|
259
|
+
在配好代理的终端里拿到了美国出口,而由宿主二进制运行时拿到的是中国出口,于是吃了 `451`。验证要在宿主内部做、不是
|
|
260
|
+
在终端里做:`node bin/guard.mjs judge 'ls'` 应当回 `source: jev`,而 `guard log --stats` 里不该有 `region` 计数。
|
|
261
|
+
|
|
262
|
+
**诚实的上限:** 阀门修不了你的出口。地区锁之下它的职责是:不再花请求去撞一扇已经被告知会关的门,并且**说出来**,
|
|
263
|
+
而不是对一场什么都没判定的会话报"健康"。`region` 冷却每 30 分钟探一次,所以出口一换它自己就恢复了 —— 见
|
|
264
|
+
[`docs/DECISIONS.md`](./docs/DECISIONS.md) D17。
|
|
265
|
+
|
|
230
266
|
## 看得见:审计日志与状态
|
|
231
267
|
|
|
232
268
|
每个判定都会追加一行 JSONL 到 `~/.jev-guard/guard.log`(超 4 MiB 轮转,命令写入前掩码密钥):
|
|
@@ -281,6 +317,7 @@ adapters/dsh/index.js DSH 原生 Cordis 插件(唯一的适配器)
|
|
|
281
317
|
cordis.patch.yml DSH bundle patch(装载声明 + 全部可调参数)
|
|
282
318
|
tools/ 离线自检、冒烟测试、验证辅助
|
|
283
319
|
docs/ 机制、取舍、实测、验收清单
|
|
320
|
+
measurements/ docs/MEASUREMENTS.md 背后的原始记录(不进 npm 包)
|
|
284
321
|
```
|
|
285
322
|
|
|
286
323
|
## 安全与隐私
|
package/bin/guard.mjs
CHANGED
|
@@ -637,4 +637,9 @@ const code = sub === 'selftest' ? selftest()
|
|
|
637
637
|
: sub === 'key' ? await cmdKey()
|
|
638
638
|
: sub === 'judge' ? await cmdJudge()
|
|
639
639
|
: (process.stderr.write(`${t('cli.usage')}\n`), 2)
|
|
640
|
-
process.exit(code
|
|
640
|
+
// **不要**写成 `process.exit(code)`。`judge` 走联网语义判定时,退出这一刻进程里还留着
|
|
641
|
+
// undici 的 keep-alive socket;Windows 上在这个时刻强退会撞进 libuv 的
|
|
642
|
+
// `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76`,
|
|
643
|
+
// 进程以 0xC0000409 abort —— 判定本身完全正确,退出码却从真值变成 -1073740791,stderr 上
|
|
644
|
+
// 还多一行和用户无关的 libuv 断言(2026-09-30 实测)。设 `exitCode` 让事件循环自然排空即可。
|
|
645
|
+
process.exitCode = code ?? 0
|
package/config.example.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "复制成 config.json 后按需修改。config.json 与 secrets.json 不进版本库。",
|
|
3
|
-
"
|
|
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
|
-
|
|
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 的设置页都由浏览器侧注册占位),
|
package/docs/DECISIONS.md
CHANGED
|
@@ -498,3 +498,78 @@ 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.
|
package/docs/DECISIONS.zh-CN.md
CHANGED
|
@@ -473,3 +473,66 @@ 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
|
+
互不依赖。如果上游转正的是一个标定更好的文本模型,那是另一个决定,走同样的流程:重新拟合、重跑语料、再抬版本号。
|
package/docs/MEASUREMENTS.md
CHANGED
|
@@ -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`
|
|
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 |
|
|
@@ -27,7 +27,7 @@ These numbers are not estimates — they were produced on **this machine** on 20
|
|
|
27
27
|
## 2. Three-arm calibration experiment (114 judgments, Chinese vs translated)
|
|
28
28
|
|
|
29
29
|
Sample: 40 cases / 114 judgments, covering ticket triage, dangerous commands, code changes, search-result labelling.
|
|
30
|
-
Reproduce:
|
|
30
|
+
Reproduce: `measurements/calibration-114/run_calibration.py` (in this repository; it reads the key from the environment) — the raw records of that run are in `measurements/calibration-114/`.
|
|
31
31
|
|
|
32
32
|
| Arm | Accuracy | noul | choice | score |
|
|
33
33
|
|---|---|---|---|---|
|
|
@@ -78,6 +78,8 @@ Reproduce: `node tools/extract-commands.mjs --stats`, then run `node tools/gate-
|
|
|
78
78
|
| p distribution | P50 = 0.01, P90 = 0.13, max = 0.82 (extremely polarised) |
|
|
79
79
|
| Hits added by filling in the script body | 18 entries (2.4%), **0 new false positives** |
|
|
80
80
|
|
|
81
|
+
Raw results: `measurements/offline-report-737.json` (threshold 0.5) and `measurements/offline-report-737-inline.json` (threshold 0.6 with the script bodies filled in — the three-way split above is that run's). The corpus itself cannot be regenerated; `measurements/README.md` says what every figure here reconciles to.
|
|
82
|
+
|
|
81
83
|
All 5 entries blocked (threshold 0.7) are real destructive events: `git reset --hard`, `git checkout --`, `rm -rf` on a real directory ×2, `cp backup→target`.
|
|
82
84
|
|
|
83
85
|
## 4. Probing the script blind spot (18 cases)
|
|
@@ -95,6 +97,8 @@ All 5 entries blocked (threshold 0.7) are real destructive events: `git reset --
|
|
|
95
97
|
|
|
96
98
|
Other single measurements: `truncate -s 0` 0.95 · `find -delete` 0.92 · inline `node -e rmSync` 0.91 · `dd of=~/data.db` 0.88 · `rsync --delete` 0.88 · `kubectl delete ns` 0.80 · `git clean -fdx` 0.65 · `git checkout .` 0.64 · `sudo rm -rf /var/lib/docker` 0.65 · `docker compose down` (no -v) 0.35 (low is correct, no volume deleted) · **`terraform apply -auto-approve` 0.48 (a known blind spot)** · `npm publish` 0.03.
|
|
97
99
|
|
|
100
|
+
Raw results: `measurements/probe-scripts.json` (all 18 cases, both arms) and `measurements/probe-scripts.md`.
|
|
101
|
+
|
|
98
102
|
## 5. Other measured constraints
|
|
99
103
|
|
|
100
104
|
| Item | Value | Impact |
|
|
@@ -244,7 +248,7 @@ The judging service is paid, so "out of quota" has to be designed for as a **cer
|
|
|
244
248
|
| `degradePolicy: 'off'` | even L0 allows it through (an explicit choice; this is not the default) |
|
|
245
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 |
|
|
246
250
|
|
|
247
|
-
**
|
|
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/
|
|
248
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:
|
|
249
253
|
|
|
250
254
|
1. `readDegraded` validated the ISO string with `Number(until)` → always NaN → **written into the file yet never readable**,
|
|
@@ -362,6 +366,10 @@ Two fixes:
|
|
|
362
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),
|
|
363
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.
|
|
364
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.
|
|
365
373
|
|
|
366
374
|
**The pattern:** for any combination of "shared state + independent per-entry preconditions", ask "will this local failure get written into global state".
|
|
367
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`
|
|
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 + 最长问题) | 官方文档 |
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
## 2. 三臂校准实验(114 个判断,中文 vs 翻译)
|
|
28
28
|
|
|
29
29
|
样本:40 个 case / 114 个判断,覆盖工单分流、危险命令、代码改动、搜索结果打标。
|
|
30
|
-
|
|
30
|
+
复现:`measurements/calibration-114/run_calibration.py`(在本仓库内,密钥只从环境变量取)—— 那一次的原始记录在 `measurements/calibration-114/`。
|
|
31
31
|
|
|
32
32
|
| 臂 | 准确率 | noul | choice | score |
|
|
33
33
|
|---|---|---|---|---|
|
|
@@ -80,6 +80,8 @@
|
|
|
80
80
|
| p 分布 | P50 = 0.01,P90 = 0.13,max = 0.82(极度两极) |
|
|
81
81
|
| 脚本补齐命中 | 18 条(2.4%),**新增误报 0 条** |
|
|
82
82
|
|
|
83
|
+
原始结果:`measurements/offline-report-737.json`(threshold 0.5)与 `measurements/offline-report-737-inline.json`(threshold 0.6、补齐脚本正文那次;上面的三分取自后者)。语料本身无法重生成;每个数字对到什么,见 `measurements/README.md`。
|
|
84
|
+
|
|
83
85
|
被拦下的 5 条(阈值 0.7)全部是真实破坏事件:`git reset --hard`、`git checkout --`、
|
|
84
86
|
真实目录 `rm -rf`×2、`cp 备份→目标`。
|
|
85
87
|
|
|
@@ -102,6 +104,8 @@
|
|
|
102
104
|
`docker compose down`(无 -v)0.35(低分正确,没删卷)·
|
|
103
105
|
**`terraform apply -auto-approve` 0.48(已知漏网点)** · `npm publish` 0.03。
|
|
104
106
|
|
|
107
|
+
原始结果:`measurements/probe-scripts.json`(18 个用例,两臂全量)与 `measurements/probe-scripts.md`。
|
|
108
|
+
|
|
105
109
|
## 5. 其它实测约束
|
|
106
110
|
|
|
107
111
|
| 项 | 值 | 影响 |
|
|
@@ -262,7 +266,7 @@ python 源码的字符串里(`c.startswith('mkfs.ext4 …')`),被 `mkfs` 规则
|
|
|
262
266
|
| `degradePolicy: 'off'` | 连 L0 也放行(显式选择;默认不是这个) |
|
|
263
267
|
| 自动恢复 | 冷却到期后放**一次**探测;替身 fetch 实测:成功 → 清状态并记 `probe+recovered`,失败 → 续期(failures+1、probes+1)且不再重复试 |
|
|
264
268
|
|
|
265
|
-
**离线断言
|
|
269
|
+
**离线断言 113 条**(`tools/selftest-quota.mjs`,替身 `fetch` 覆盖 402/401/403(JSON 鉴权 vs 边缘 HTML)/451(`region`,全局降级 30 分钟)与 400·422(`request`,本地降级 15 分钟)/429两种/5xx/超时/网络/无密钥/
|
|
266
270
|
坏状态文件/删不掉的状态文件),其中两条是**自己抓到的真 bug**,一并记在这里:
|
|
267
271
|
|
|
268
272
|
1. `readDegraded` 用 `Number(until)` 校验 ISO 字符串 → 恒为 NaN → **写进去了却永远读不出来**,
|
|
@@ -377,7 +381,10 @@ WSL 上验不出这三层里的任何一层。这次用 `C:\Program Files\nodejs
|
|
|
377
381
|
1. **路径语义**:相对路径一律按**包根**解析(绝对路径原样使用),与 cwd 无关。
|
|
378
382
|
2. **`no-key` 不再降级**:它是**本地配置**状况而不是服务状况 —— 一次 HTTP 都不发(降级省不下任何东西),
|
|
379
383
|
却可能只影响一个入口。分类照记、告警照发(`guard status` 会直接说没解析到密钥),但不进降级态。
|
|
380
|
-
降级集合因此收窄为 `{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
|
+
负责的状况,不许写进共享状态。
|
|
381
388
|
|
|
382
389
|
**规律:** 凡是"共享状态 + 各入口前提独立"的组合,都要问一句"这个局部故障会不会被写进全局状态"。
|
|
383
390
|
|
package/docs/VERIFICATION.md
CHANGED
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-jev-guard",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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",
|
package/tools/selftest-quota.mjs
CHANGED
|
@@ -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)
|
|
109
|
-
// · quota / auth = 服务侧状况 → 冷却式:到期放一次探测;
|
|
110
|
-
// · no-key = 本地配置状况 → **粘性**:没密钥时零 HTTP、没有可探测对象,所以靠"密钥出现"
|
|
111
|
-
//
|
|
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)
|
|
@@ -180,6 +180,10 @@ async function main() {
|
|
|
180
180
|
const d = mockContext(undefined) // 没有凭据
|
|
181
181
|
applySmoke(d.ctx)
|
|
182
182
|
const handlerNoKey = d.handlers.get('tools/pre-execute')
|
|
183
|
+
// 从这里到「联网用例」之前,环境变量必须一直是空的。适配器的凭据解析顺序是
|
|
184
|
+
// ctx.credentials → `apiKeyEnv`(默认 `TYPESAFE_API_KEY`)→ `apiKeyFile`,而这一段测的正是
|
|
185
|
+
// **没有密钥**时的行为:环境里留着真钥匙,前提就直接不成立(2026-09-30 实测:带着
|
|
186
|
+
// `TYPESAFE_API_KEY` 跑本文件时,notice 那 3 组断言会全部假失败)。摘掉一次,联网用例前再放回。
|
|
183
187
|
process.env.TYPESAFE_API_KEY = ''
|
|
184
188
|
// 命令必须绕开预筛(/tmp 之类会被当"可重建内容"直接放行,那样根本到不了需要密钥的语义层)。
|
|
185
189
|
expect('无密钥 + 需要语义判定 → allow(fail-open)',
|
|
@@ -189,7 +193,6 @@ async function main() {
|
|
|
189
193
|
expectTrue('无密钥 → 状态是粘性的、且作用域只限本入口',
|
|
190
194
|
nkState?.sticky === true && nkState?.scope === 'dsh-adapter',
|
|
191
195
|
JSON.stringify({ sticky: nkState?.sticky, scope: nkState?.scope }))
|
|
192
|
-
if (apiKey) process.env.TYPESAFE_API_KEY = apiKey
|
|
193
196
|
await rm(degradedPath, { force: true })
|
|
194
197
|
|
|
195
198
|
// ---- 探测成功、但状态文件清不掉(只读文件系统):原因必须有人能发现(2026-09-23)----
|
|
@@ -290,7 +293,9 @@ async function main() {
|
|
|
290
293
|
JSON.stringify(withFileKey.messages?.map(m => m.id)))
|
|
291
294
|
|
|
292
295
|
// ---- 联网用例(有密钥时才跑) ----
|
|
296
|
+
// 「没有密钥」的场景到此结束,把环境变量还回去。
|
|
293
297
|
if (apiKey) {
|
|
298
|
+
process.env.TYPESAFE_API_KEY = apiKey
|
|
294
299
|
const e = mockContext(apiKey)
|
|
295
300
|
applySmoke(e.ctx)
|
|
296
301
|
const h = e.handlers.get('tools/pre-execute')
|
|
@@ -327,10 +332,16 @@ async function main() {
|
|
|
327
332
|
expectTrue('审计里记下了权限 preset(danger-full-access)', auditText.includes('"preset":"danger-full-access"'), auditText.slice(0, 200))
|
|
328
333
|
await rm(audited, { force: true })
|
|
329
334
|
|
|
330
|
-
await flush() //
|
|
335
|
+
await flush() // 等审计日志落盘,否则退出时会丢掉尾部记录
|
|
331
336
|
await rm(dir, { recursive: true, force: true }) // 整个测试的产物都在这个临时目录里
|
|
332
337
|
process.stdout.write(`\n${failures === 0 ? `全部通过(${checks} 组断言)` : `${failures} 组失败 / 共 ${checks} 组`}\n`)
|
|
333
|
-
|
|
338
|
+
|
|
339
|
+
// **不要**改成 `process.exit(code)`。联网用例跑完后进程里还留着 undici 的 keep-alive
|
|
340
|
+
// socket,Windows 上在这个时刻强退会撞进 libuv 的
|
|
341
|
+
// `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76`,
|
|
342
|
+
// 进程以 0xC0000409 abort,退出码从 0 变成 -1073740791(2026-09-30 实测)。设 `exitCode`
|
|
343
|
+
// 让事件循环自然排空即可,实测退出码与耗时都正常。
|
|
344
|
+
process.exitCode = failures === 0 ? 0 : 1
|
|
334
345
|
}
|
|
335
346
|
|
|
336
347
|
await main()
|
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
* cp <本文件> $T/ && cd $T && JEV_GUARD_ROOT=/mnt/t/jev-guard node smoke-dsh-pipeline.mjs
|
|
22
22
|
* # 加 TYPESAFE_API_KEY 则第 4 项会真的走一次联网判定(7/7)
|
|
23
23
|
*
|
|
24
|
+
* Windows 上同一件事(2026-09-30 实测:离线 6/6、带密钥 7/7):临时目录同样要备齐 ① 和 ②
|
|
25
|
+
* (目录联接,或直接用 npm 装宿主包),然后 `JEV_GUARD_ROOT` **给裸路径就行** —— 脚本自己
|
|
26
|
+
* 转成 `file://` URL,不用手写 `file:///T:/...`:
|
|
27
|
+
*
|
|
28
|
+
* $T = <临时目录>; $env:JEV_GUARD_ROOT = "T:\dsh-jev-guard"; node smoke-dsh-pipeline.mjs
|
|
29
|
+
*
|
|
24
30
|
* 三个**别照抄**的旧配方:从检出根直接跑会以 `ERR_MODULE_NOT_FOUND` 崩在下面的 import 上;
|
|
25
31
|
* 从 `packages/core/agent-loop` 跑则第 6 项会假报 FAIL(那个 cwd 下没有 `./packages/...`);
|
|
26
32
|
* 用绝对路径跑本文件(不复制)同样在 import 那一步就崩 —— 裸说明符不认 cwd。
|
|
@@ -39,7 +45,15 @@ import { join } from 'node:path'
|
|
|
39
45
|
import { pathToFileURL } from 'node:url'
|
|
40
46
|
|
|
41
47
|
const ROOT = process.env.JEV_GUARD_ROOT ?? '/mnt/t/dsh-jev-guard'
|
|
42
|
-
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `ROOT` 作为**模块说明符**。裸文件系统路径不能直接拼进 `import()`:ESM 加载器先按 URL
|
|
51
|
+
* 解析,Windows 的 `T:\jev-guard` 会被读成协议 `t:`,报 `ERR_UNSUPPORTED_ESM_URL_SCHEME`
|
|
52
|
+
* (2026-09-30 实测)。所以统一转成 `file://` URL —— POSIX 的 `/mnt/t/...` 与 Windows 的
|
|
53
|
+
* `T:\...` 都适用;已经是 URL 的原样保留,免得把 `file:///T:/...` 二次转义。
|
|
54
|
+
*/
|
|
55
|
+
const ROOT_URL = /^[a-z][a-z0-9+.-]*:\/\//i.test(ROOT) ? ROOT : pathToFileURL(ROOT).href
|
|
56
|
+
const mod = await import(`${ROOT_URL}/adapters/dsh/index.js`)
|
|
43
57
|
|
|
44
58
|
/** 审计日志与降级状态都落在这里 —— 跑测试不该改动本机真实的阀门状态。 */
|
|
45
59
|
const SANDBOX = await mkdtemp(join(tmpdir(), 'jev-guard-pipeline-'))
|
|
@@ -162,9 +176,9 @@ async function main() {
|
|
|
162
176
|
|
|
163
177
|
// 收尾与 smoke-dsh-adapter 同规矩:先等审计队列落盘(record() 是 fire-and-forget,
|
|
164
178
|
// 不等它 process.exit 会丢尾部记录),再把整个临时目录删掉。
|
|
165
|
-
// 审计模块从
|
|
179
|
+
// 审计模块从 ROOT_URL 动态导入 —— 本文件可能被复制到临时目录里运行,相对路径在那里是无效的。
|
|
166
180
|
try {
|
|
167
|
-
const audit = await import(`${
|
|
181
|
+
const audit = await import(`${ROOT_URL}/lib/audit.js`)
|
|
168
182
|
await audit.flush()
|
|
169
183
|
} catch {
|
|
170
184
|
// 清理失败不该改变测试结论
|
|
@@ -172,7 +186,14 @@ async function main() {
|
|
|
172
186
|
await rm(SANDBOX, { recursive: true, force: true }).catch(() => {})
|
|
173
187
|
|
|
174
188
|
process.stdout.write(`\n${failures === 0 ? `全部通过(${checks} 组断言)` : `${failures} 组失败 / 共 ${checks} 组`}\n`)
|
|
175
|
-
|
|
189
|
+
|
|
190
|
+
// **不要**改成 `process.exit(code)`。联网用例跑完后进程里还留着 undici 的 keep-alive
|
|
191
|
+
// socket(以及一个未决的 fs 请求);Windows 上在这个时刻强退会撞进 libuv 的
|
|
192
|
+
// `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76`,
|
|
193
|
+
// 进程以 0xC0000409 abort,退出码从 0 变成 -1073740791 —— 一次"全部通过"被报成失败
|
|
194
|
+
// (2026-09-30 实测,连跑两次都复现)。设 `exitCode` 让事件循环自然排空即可:同一份用例
|
|
195
|
+
// 自然退出的实测耗时约 1.0s,退出码 0,连跑三次稳定。
|
|
196
|
+
process.exitCode = failures === 0 ? 0 : 1
|
|
176
197
|
}
|
|
177
198
|
|
|
178
199
|
await main()
|