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