dsh-jev-guard 0.5.1

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +285 -0
  2. package/CHANGELOG.zh-CN.md +271 -0
  3. package/DEPLOY.md +202 -0
  4. package/DEPLOY.zh-CN.md +200 -0
  5. package/LICENSE +21 -0
  6. package/README.md +316 -0
  7. package/README.zh-CN.md +315 -0
  8. package/START-HERE.md +97 -0
  9. package/START-HERE.zh-CN.md +97 -0
  10. package/adapters/README.md +37 -0
  11. package/adapters/README.zh-CN.md +37 -0
  12. package/adapters/dsh/index.js +502 -0
  13. package/bin/guard.mjs +634 -0
  14. package/config.example.json +52 -0
  15. package/cordis.patch.yml +120 -0
  16. package/docs/AGENT-TASK-dsh.md +134 -0
  17. package/docs/AGENT-TASK-dsh.zh-CN.md +131 -0
  18. package/docs/ARCHITECTURE.md +118 -0
  19. package/docs/ARCHITECTURE.zh-CN.md +117 -0
  20. package/docs/DECISIONS.md +469 -0
  21. package/docs/DECISIONS.zh-CN.md +449 -0
  22. package/docs/DSH-INTEGRATION.md +178 -0
  23. package/docs/DSH-INTEGRATION.zh-CN.md +171 -0
  24. package/docs/MEASUREMENTS.md +433 -0
  25. package/docs/MEASUREMENTS.zh-CN.md +450 -0
  26. package/docs/USER-INTERVENTION.md +141 -0
  27. package/docs/USER-INTERVENTION.zh-CN.md +143 -0
  28. package/docs/VERIFICATION.md +279 -0
  29. package/docs/VERIFICATION.zh-CN.md +278 -0
  30. package/lib/audit.js +228 -0
  31. package/lib/gate.js +720 -0
  32. package/lib/i18n.js +575 -0
  33. package/lib/quota.js +389 -0
  34. package/lib/rules.js +174 -0
  35. package/lib/token.js +154 -0
  36. package/lib/verdict.js +285 -0
  37. package/package.json +82 -0
  38. package/tools/check-doc-pairs.mjs +158 -0
  39. package/tools/extract-commands.mjs +156 -0
  40. package/tools/gate-cli.mjs +240 -0
  41. package/tools/probe-prompt-lang.mjs +238 -0
  42. package/tools/probe-scripts.mjs +143 -0
  43. package/tools/report-result.mjs +146 -0
  44. package/tools/selftest-audit.mjs +93 -0
  45. package/tools/selftest-entry.mjs +177 -0
  46. package/tools/selftest-i18n.mjs +177 -0
  47. package/tools/selftest-quota.mjs +260 -0
  48. package/tools/selftest-reason.mjs +266 -0
  49. package/tools/selftest-rules.mjs +107 -0
  50. package/tools/selftest-token.mjs +100 -0
  51. package/tools/smoke-dsh-adapter.mjs +295 -0
  52. package/tools/smoke-dsh-pipeline.mjs +146 -0
@@ -0,0 +1,278 @@
1
+ # DSH 验收清单
2
+
3
+ > [English](VERIFICATION.md) | **简体中文**
4
+
5
+ 这份清单是**这份包在 DSH 上"到底验过什么"的账本**,也是**换一台机器时该怎么重验**的步骤。
6
+ 每条都写了:怎么验、判定标准、以及它在 `verification-results/` 里的记录编号。
7
+
8
+ **验收铁律(三条事故换来的):**
9
+
10
+ 1. **看副作用,不看"没报错"。** "命令真的被拒"+"日志真的有那条记录"才算过。
11
+ 本包出现过三层静默失效:脚本一声不响退出 0、命令照跑、日志空白(见 `MEASUREMENTS.md` §10)。
12
+ 2. **两个平台各跑一遍。** Windows 与 WSL 的路径/引号/模块解析规则不同,一个平台过不代表另一个过。
13
+ 3. **记录要带时间戳与原文。** `at` / `token` / `source` 这几个字段是唯一能把"我说它拦了"与"它真的拦了"分开的东西。
14
+
15
+ 记录方式(写完自动汇总到 `SUMMARY.md`):
16
+
17
+ ```bash
18
+ node tools/report-result.mjs --host dsh --item <编号> --status <pass|fail|partial|blocked|skipped> \
19
+ --evidence "证据(带时间戳/令牌/关键输出)" --notes "补充或疑问"
20
+ # 卡住时:--status blocked --question "你的问题"
21
+ ```
22
+
23
+ ---
24
+
25
+ ## A. 判定层(不装 DSH 也能验,纯离线 + 一次联网)
26
+
27
+ ### 6-pre · 适配器冒烟 + 真实工具管线
28
+
29
+ ```bash
30
+ node tools/smoke-dsh-adapter.mjs # 假 ctx:接线/断言/审批策略/审计字段
31
+ node tools/smoke-dsh-pipeline.mjs # 真 ToolRuntime 五阶段管线(需在 DSH 检出目录内跑)
32
+ ```
33
+
34
+ **判定:** 冒烟全过(含"审计里记下了 `policy` 与 `preset`");管线测试给出预期的 `ToolExecutionResult`。
35
+
36
+ ### 7 · 误报防线(改规则时必跑)
37
+
38
+ ```bash
39
+ node tools/selftest-rules.mjs
40
+ # 双探针:命令文本里提到危险短语、以及以 2>/dev/null 结尾的普通命令,都不得被拦
41
+ echo "git push --force origin main" > /tmp/jev-anchor-test.txt
42
+ node bin/guard.mjs judge 'ls -la /var/log 2>/dev/null'
43
+ ```
44
+
45
+ **判定:** 探针不被拦(散文归 Jev 判,实测 p≈0.02–0.08)。
46
+
47
+ **2026-09-20 扩充 —— 锚定必须**两个方向**都测**(48 例:25 旧 + 22 矩阵 + 1 性能):
48
+
49
+ | 方向 | 要钉住的形态 | 期望 |
50
+ |---|---|---|
51
+ | 防假阳 | 引号里的参数、注释、变量赋值、python `-c`/heredoc 里的字符串、grep 参数、`c.startswith('mkfs.ext4 …')` 这类**代码字符串** | 不命中(交给 Jev) |
52
+ | 防漏判 | 多行 heredoc / 多行 `bash -c "` 里的真命令;`\| xargs`、`timeout 30`、`nice -n 5`、`find … -exec`、多级包装 | **命中对应规则** |
53
+ | 防误伤散文 | `xargs 删除 mkfs.ext4 …`(包装器后面是中文) | 不命中 |
54
+ | 防灾难性回溯 | 4KB 纯包装器前缀(最坏输入) | < 50ms(实测 0.6ms) |
55
+
56
+ > **只看假阳会漏掉一半问题。** 第一轮修正只测了"散文不该命中",于是谁也没发现
57
+ > 锚定缺 `m` 标志导致**多行脚本里的真命令全部漏判**(见 [`MEASUREMENTS.md`](./MEASUREMENTS.md) §7.5
58
+ > 与 [`DECISIONS.md`](./DECISIONS.md) D2)。改规则时**两个方向都要跑**。
59
+ > 另外一个总是不变的量:`staticRule` 打印的 `RULE_STATS.anywhere` 必须**恒为 2**
60
+ > (`redirect-to-device`、`fork-bomb`);它变大就意味着又有一条规则退回了全文匹配。
61
+
62
+ ### 8 · 审计日志(离线)+ 8-fix(运行实例)
63
+
64
+ ```bash
65
+ node tools/selftest-audit.mjs # 掩码/追加/轮转/汇总/空白 logPath
66
+ node tools/selftest-i18n.mjs # 双语文案:目录完整性/占位符/英文残留/promptLang 不随界面语言
67
+ node bin/guard.mjs log --tail 5 # 运行实例里真的有记录
68
+ node bin/guard.mjs log --stats
69
+ ```
70
+
71
+ **判定:** 离线全过;**并且**运行实例里能读到真实记录(曾经出现过"阀门在工作、日志一条没有")。
72
+
73
+ ### 13 · 额度降级(离线)
74
+
75
+ ```bash
76
+ node tools/selftest-quota.mjs # 替身 fetch:402/401/403/429两种/5xx/超时/网络/坏状态文件
77
+ ```
78
+
79
+ **判定:** 全过。重点确认三件事:持久性失败**降级**、瞬态失败**不降级**、
80
+ 降级期间 L0 仍然拦且**零 HTTP 请求**。
81
+
82
+ ---
83
+
84
+ ## B. 装进 DSH 之后
85
+
86
+ ### 6 · 安装后 probe 被拦
87
+
88
+ 跑一条**必然被拦**的命令(不花钱):
89
+
90
+ ```bash
91
+ # 在 DSH 会话里让 AI 执行:git push --force origin main
92
+ node bin/guard.mjs log --tail 1
93
+ ```
94
+
95
+ **判定:** 命令真的被拒,理由含 `命中硬规则 git-force-push`;`guard.log` 里出现该记录。
96
+ **不通过时先读** `DSH-INTEGRATION.md` §5(三层静默失效)。
97
+
98
+ ### 9 · 一次性令牌闭环
99
+
100
+ 1. 让 AI 执行一条会被拦的真实命令(例如 `rm -rf <一个演示目录>`)。
101
+ 2. 理由里应有 `ALLOW-XXXXXXXXXX` + 一行**绝对路径**的授权命令。
102
+ 3. **人**在自己的终端里粘贴那一行(非 TTY 会被拒 —— 那是正确行为)。
103
+ 4. 让 AI **重试一字不差的同一条命令**。
104
+
105
+ **判定:** 命令真的被执行、令牌文件变空、`guard.log` 出现 `source: token` 与 `overridden: <原动作>`。
106
+ 另外验绑定:把命令换一个字 → **仍然被拦**,且公示的是**另一个**令牌。
107
+
108
+ ### 10 · 授权入口与理由文案
109
+
110
+ ```bash
111
+ echo | node bin/guard.mjs allow 'rm -rf /tmp/x' # 非 TTY:应被拒并打印整行命令
112
+ node tools/selftest-reason.mjs # 28+ 例:绝对路径/不截断/引号转义/策略分叉
113
+ ```
114
+
115
+ **判定:** 非 TTY 拒绝且给出可复制的整行;`selftest-reason` 全过。
116
+ **Windows 追加:** 理由里的引号必须是 **PowerShell** 形式(`''` 转义);`--command-file` 可用。
117
+
118
+ ### 14 · 降级在真实会话里可见
119
+
120
+ 注入一份降级状态(故障注入),再跑两条命令:
121
+
122
+ ```bash
123
+ # 写一份 kind=quota 的 ~/.jev-guard/degraded.json(until 设在未来)
124
+ # 然后:mkfs.ext4 /dev/whatever → L0 拒绝,理由尾部应带 ⚠️ 降级告警
125
+ # touch /tmp/whatever → source=degraded、ms=0(零请求)
126
+ node bin/guard.mjs status --clear # 收工:清掉注入的状态
127
+ ```
128
+
129
+ **判定:** 告警出现在**拒绝理由**里、审计里有 `level: warn` 一条、非 L0 命令为 `source=degraded` 且 `ms=0`;
130
+ `status --clear` 后回到"✅ 正常"(退出码 0)。
131
+
132
+ ### 16 · 跨平台入口守卫(WSL **与** Windows 各跑一遍)
133
+
134
+ ```bash
135
+ node tools/selftest-entry.mjs # WSL
136
+ # Windows(若 DSH/Windows 或本机有 node.exe):
137
+ "C:\Program Files\nodejs\node.exe" T:\dsh-jev-guard\tools\selftest-entry.mjs
138
+ ```
139
+
140
+ **判定:** 两个平台都全过。**只有 Windows 能暴露**"盘符 + 反斜杠的 argv[1]"那一类问题;
141
+ 若只跑 WSL,请把它标成 `partial` 而不是 `pass`。
142
+
143
+ ### 17 · 平台相关的 shell 引号(Windows)
144
+
145
+ ```bash
146
+ node tools/selftest-reason.mjs # 含真实 PowerShell 往返 + "POSIX 形式在 PS 里解析失败"的反例
147
+ ```
148
+
149
+ **判定:** 全过。手工复核:把理由里那一行粘进 **PowerShell**,`--list` 应出现公示的那个令牌。
150
+
151
+ ### 21 · 改名 `dsh-jev-guard` 后重启激活核对
152
+
153
+ 改名、审计新增 `preset` 字段、降级/审批文案修正、移除 `serve`/`mcp` 这些改动**都要重启 DSH 才生效**。
154
+ 重启后按顺序核三件,再补一次实拦:
155
+
156
+ ```bash
157
+ node bin/guard.mjs status # ① 退出码 0 且打印 "✅ ... 正常"
158
+ dsh --profile <你的> --dump-config | grep -A2 jev-guard # ② bundle 的 id 与 name 都是 dsh-jev-guard
159
+ tail -n 1 ~/.jev-guard/guard.log # ③ 新记录应同时含 policy 与 preset
160
+ ```
161
+
162
+ **判定:** ① 与 ② 必过(**有 `degraded.json` 时 `status` 退出码是 3**,那是降级不是故障)。
163
+ ③ 要**重启之后**的新记录里出现 `preset`(如 `danger-full-access` / `workspace-write`)——
164
+ 这是"跑的是改名后的新适配器"的硬证据,旧版没有这个字段;`policy` 同理。
165
+
166
+ 最后交一条**本来就该被拦**的命令做端到端复验(挑效果无害的那种,例如 `truncate -s 0` 一个 /tmp 探针文件),
167
+ 确认三件事:拦截理由照常给出、审计里出现对应记录(`action=escalate` / `decision=deny` /
168
+ `source=static-rule` + 同一个令牌)、且**命令确实没被执行**(探针文件不存在 = 拦在事前,不是事后告警)。
169
+
170
+ ### 22 · 判定动作随审批模式分叉(`ask` 转人工 / `never` 拦死 / L0 绝对闸门)
171
+
172
+ 改的是 [`DECISIONS.md`](./DECISIONS.md) **D13**。先跑两个离线的,它们覆盖路由矩阵本身:
173
+
174
+ ```bash
175
+ node tools/selftest-reason.mjs # 56 例:含 revise/block × ask/never × L0 的路由矩阵
176
+ node tools/smoke-dsh-adapter.mjs # 10 组:含"L0 硬规则连试 4 次始终是 deny(不被预算升级成弹窗)"
177
+ ```
178
+
179
+ 然后**真机两边都要跑**(策略切换在会话里就能改,不必重启):
180
+
181
+ | 场景 | 交什么命令 | 期望 |
182
+ |---|---|---|
183
+ | `ask` + 灰区 | 一条落在 50–70% 的命令(看 `guard.log` 里的 `p`) | **弹审批框**;理由抬头是"需要人工确认",且带三种降级模板;**不出现**令牌授权行 |
184
+ | `never` + 同一条 | 同上 | **直接拒绝**,附令牌授权行 |
185
+ | `ask` + L0 硬规则 | `git push --force origin main`(在无 remote 或安全仓库里) | **直接拒绝、不弹窗**;审计 `decision=deny` |
186
+ | `ask` + L0 硬规则连试 4 次 | 同上,重复提交 | 仍然**一次都不弹**(预算不升级硬规则);审计里 `attempts` 递增到 4 |
187
+
188
+ **判定:** 离线两套全过 + 真机四行都符合。**只跑 `never` 一侧不算过**(路由分叉正是这次改的东西),
189
+ 标 `partial`。批准一次之后记得确认:被批准的那条命令**确实执行了**(`kind: 'ask'` 经宿主审批后放行),
190
+ 说明转人工不是"拦截换了个说法"。
191
+
192
+ ### 23 · 双语文案与语言开关
193
+
194
+ 机制见 [`DECISIONS.md`](./DECISIONS.md) **D14**,实测见 [`MEASUREMENTS.md`](./MEASUREMENTS.md) §14。
195
+
196
+ ```bash
197
+ node tools/selftest-i18n.mjs # 24 例:两语言同键/占位符一致/英文无残留中文/问话不受界面语言影响
198
+ node tools/selftest-entry.mjs # 20 例:含 --lang / JEV_GUARD_LANG / "开关的值不是位置参数"
199
+ ```
200
+
201
+ 真机四条(每条都要**两种语言各看一眼**):
202
+
203
+ | 场景 | 命令 | 期望 |
204
+ |---|---|---|
205
+ | 默认语言 | `node bin/guard.mjs status` | 未显式设置时 = `zh-CN`(不看系统 locale;见 D14 里 WSL `en-US` 兜底值那次教训) |
206
+ | 显式切换 | `node bin/guard.mjs status --lang en` | 全英文;`--lang zh-CN` 全中文 |
207
+ | 环境变量 | `JEV_GUARD_LANG=en node bin/guard.mjs rules` | 规则清单理由变英文(规则 id 不变) |
208
+ | 判定不变 | 同一批命令各语言跑一次 `judge --json` | `action` / `p` / `source` **逐字段一致**,只有理由文案不同 |
209
+
210
+ **判定:** 离线两套全过 + 真机四条符合。**只跑一种语言不算过** —— 这一项验的正是"两种语言下判定一致、
211
+ 文案各自正确"。另需确认:改 `lang` **不得**改变 `guard.log` 里的 action/decision(可用同一批命令前后对比)。
212
+
213
+ > `promptLang` 不在本项的通过条件里:它不是文案开关而是一个判定参数,切它属于重标定,
214
+ > 见 MEASUREMENTS §14 —— 拿 `tools/probe-prompt-lang.mjs --repeat 3` 重新量过才算数。
215
+
216
+ ---
217
+
218
+ ## C. 人工介入三通道(任何机器都要跑)
219
+
220
+ 三条通道的机制与各性质见 [`USER-INTERVENTION.md`](./USER-INTERVENTION.md)。
221
+
222
+ ### U1 · 一次性令牌通道(**宿主无关**的那条)
223
+
224
+ 1. 制造一次拦截,记下理由里公示的令牌。
225
+ 2. 在人自己的终端里粘贴授权行;`node bin/guard.mjs allow --list` 应出现该令牌。
226
+ 3. 让 AI 重试**一字不差**的同一条命令 → 放行、令牌消失、`guard.log` 记 `source=token`。
227
+
228
+ ### U2 · 宿主审批通道(DSH 有,**必测**)
229
+
230
+ 1. 把会话切到带审批的模式(`approval: ask`)。
231
+ 2. 触发一次 `escalate` 类拦截(命中 L0 `ask` 规则的命令,如 `truncate -s 0 <演示文件>`)。
232
+ 3. **人**应真的看到审批弹窗,且弹窗里的理由**就是阀门的原文**(硬规则 id + why),并且
233
+ **不再附**"复制到终端授权"那一行(人就在窗口前面)。
234
+
235
+ **判定:** 弹窗出现且带原文;点允许后命令执行(`outcome=allowed-once`)。
236
+ 会话日志里能查到成对的 `approval/asked` + `approval/decided`。
237
+
238
+ ### U3 · 人工手动执行 ≠ 给 AI 授权(反直觉,但必须验)
239
+
240
+ 1. 让人在终端里**直接**执行那条被拦的命令(不走令牌、不走弹窗)。
241
+ 2. 观察两件事:审计里那条命令的判定记录**零新增**;让 AI 重试同一条命令 → **仍然被拦**。
242
+
243
+ **判定:** "零新增 + 仍被拦" = 通过。失败意味着存在未察觉的授权泄漏。
244
+
245
+ > 统计审计时注意一个陷阱:`guard.log` 记录的是**每条经过判定的命令文本**,
246
+ > 所以用子串 `grep` 统计某条命令时,**自己的检查命令**(里面引用了那段文本)也会被数进去。
247
+ > 请用「`command` 字段以该命令开头」精确过滤。
248
+
249
+ ---
250
+
251
+ ## D. 编号速查
252
+
253
+ | 编号 | 验的是什么 | 记录 |
254
+ |---|---|---|
255
+ | 6-pre | 适配器冒烟 + 真实工具管线 | ✅ pass |
256
+ | 6 | 安装后 probe 被拦 | ✅ pass |
257
+ | 7 | 误报防线 | ✅ pass |
258
+ | 8 / 8-fix | 审计日志(离线 / 运行实例) | ✅ pass |
259
+ | 9 | 令牌闭环 | ✅ pass |
260
+ | 10 | 授权入口与理由文案 | ✅ pass |
261
+ | 11 | 人工三通道(用户手工验收) | ✅ pass |
262
+ | 12 | 宿主审批通道 | ✅ pass |
263
+ | 13 | 额度降级(离线 + CLI) | ✅ pass |
264
+ | 14 | 降级在真实会话可见 | ✅ pass |
265
+ | 15 | `ask` 分支文案分叉 | ✅ pass |
266
+ | 16 | 跨平台入口守卫(WSL + Windows) | 见 `SUMMARY.md` |
267
+ | 17 | 平台相关 shell 引号(Windows) | 见 `SUMMARY.md` |
268
+ | 18 | 收窄为 DSH 专用 | 见 `SUMMARY.md` |
269
+ | 19 | 包内清除非 DSH 痕迹 | 见 `SUMMARY.md` |
270
+ | 20 | 包内现状核对(只描述 DSH) | 见 `SUMMARY.md` |
271
+ | 21 | 改名 `dsh-jev-guard` 后重启激活核对 | 见 `SUMMARY.md` |
272
+ | 22 | 判定动作随审批模式分叉(`ask` 转人工 / `never` 拦死 / L0 绝对闸门) | 见 `SUMMARY.md` |
273
+ | 23 | 双语文案与语言开关(两种语言下判定一致) | 见 `SUMMARY.md` |
274
+ | U1–U3 | 人工介入三通道 | 记在 11 / 12 |
275
+
276
+ 历史:本清单早期还有几条"别的执行通道能不能承载拦截"的前置验证(编号 1–5),已随
277
+ "只支持 DSH"的决定作废 —— 那些实现**已从本包移除**,可迁移的教训保留在
278
+ [`MEASUREMENTS.md`](./MEASUREMENTS.md) §12 与 [`DECISIONS.md`](./DECISIONS.md) D11。
package/lib/audit.js ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * 决策审计日志 —— 让"阀门到底做了什么"可以被看见。
3
+ *
4
+ * 背景(2026-09-20 实测):DSH 的 logger 阈值把插件的 info 级日志过滤掉了,
5
+ * `dsh-web.log` 里一条 `jev-guard` 都没有。结果运行期只能靠"命令被拦"这种间接
6
+ * 现象判断它还活着,想回答"今天它拦了什么/放行了什么/有没有 fail-open"完全做不到。
7
+ *
8
+ * 这个模块把**每一个判定**追加写到 `<JEV_GUARD_HOME>/guard.log`(默认
9
+ * `~/.jev-guard/guard.log`),JSONL 格式,写它的有:
10
+ *
11
+ * DSH 插件(会话里每次工具调用)· CLI(`guard judge` / 离线复核脚本)
12
+ *
13
+ * 一份文件而不是"每个入口一份",是为了让"它到底做了什么"只有一个答案 ——
14
+ * 尤其是离线复核与真实会话混在一起看的时候。
15
+ * 三条设计约束:
16
+ * 1. **绝不抛错、绝不阻塞判定** —— 日志写失败不能影响安全决策(串行队列 + 全 catch)。
17
+ * 2. **写入前掩码** —— 命令文本可能内联密钥(实测你的历史里有这种写法),所以
18
+ * 任何像 `sk-…`/`ghp_…`/`tvly-…` 的片段都会被改写成 `sk-****尾4`。
19
+ * 3. **有上限** —— 超过 `logMaxBytes` 就轮转到 `guard.log.1`,不会无限增长。
20
+ *
21
+ * 查看方式:`node bin/guard.mjs log --tail 20` / `--stats`。
22
+ *
23
+ * @module jev-guard/audit
24
+ */
25
+
26
+ import { appendFile, mkdir, rename, stat } from 'node:fs/promises'
27
+ import { homedir } from 'node:os'
28
+ import { dirname, join } from 'node:path'
29
+
30
+ /** 日志目录;可用环境变量覆盖(测试/多实例用)。 */
31
+ export const HOME_DIR = process.env.JEV_GUARD_HOME ?? join(homedir(), '.jev-guard')
32
+
33
+ /** 默认日志路径;`JEV_GUARD_AUDIT_LOG` 可覆盖。 */
34
+ export const DEFAULT_LOG_PATH = process.env.JEV_GUARD_AUDIT_LOG ?? join(HOME_DIR, 'guard.log')
35
+
36
+ /** 默认单文件上限(4 MiB)。 */
37
+ export const DEFAULT_LOG_MAX_BYTES = Number(process.env.JEV_GUARD_LOG_MAX_BYTES ?? 4 * 1024 * 1024)
38
+
39
+ /** 看起来像密钥的片段(与报告掩码器保持同一套形态)。 */
40
+ const SECRET_RE = /\b(?:sk|ghp|gho|glpat|tvly|xoxb|as_sk|apikey)[-_A-Za-z0-9]{10,}/gi
41
+
42
+ /**
43
+ * 把密钥形态的片段改写为 `sk-****尾4`,其余原样。
44
+ * @param value - 任意文本。
45
+ * @returns 掩码后的文本。
46
+ */
47
+ export function maskSecrets(value) {
48
+ return String(value ?? '').replace(SECRET_RE, m => `${m.slice(0, 3)}****${m.slice(-4)}`)
49
+ }
50
+
51
+ /** 串行队列:保证并发判定时写入不交错,也避免每次都重新 stat。 */
52
+ let queue = Promise.resolve()
53
+
54
+ /** 最近一次写入失败的原因(供 `guard log` 解释"为什么一条记录都没有")。 */
55
+ let lastError = null
56
+
57
+ /**
58
+ * 把配置里的 logPath 解析成实际路径。
59
+ *
60
+ * 注意:**空串 / 纯空白必须当作"未配置"**。这是实测踩过的坑:`??` 只对 null/undefined
61
+ * 生效,于是配置模板里表示"用默认"的 `logPath: ''` 会变成一个空路径,写入全部失败、
62
+ * 又被 catch 静默吞掉 —— 表面现象是"阀门在工作,但日志一条都没有"(2026-09-20 实拦)。
63
+ *
64
+ * @param options - `{ logPath }`。
65
+ * @returns 实际使用的日志路径。
66
+ */
67
+ export function resolveLogPath(options = {}) {
68
+ const configured = options?.logPath
69
+ return typeof configured === 'string' && configured.trim() !== '' ? configured : DEFAULT_LOG_PATH
70
+ }
71
+
72
+ /** @returns 最近一次写入失败的原因,或 null。 */
73
+ export function lastLogError() {
74
+ return lastError
75
+ }
76
+
77
+ /**
78
+ * 追加一条审计记录。绝不抛错、绝不阻塞判定。
79
+ *
80
+ * @param entry - 记录内容,常用字段:tool / action / source / p / rule / model / ms /
81
+ * enriched / cwd / decision / command / error。
82
+ * @param cfg - 可选 `{ logPath, logMaxBytes }`(空 logPath 视为未配置)。
83
+ * @returns 一个在写入(或失败)后 resolve 的 promise —— 调用方不需要 await。
84
+ */
85
+ export function record(entry, cfg = {}) {
86
+ const path = resolveLogPath(cfg)
87
+ const maxBytes = Number(cfg.logMaxBytes ?? DEFAULT_LOG_MAX_BYTES)
88
+ const line = `${JSON.stringify({
89
+ at: new Date().toISOString(),
90
+ ...entry,
91
+ ...(entry?.command === undefined ? {} : { command: maskSecrets(entry.command).slice(0, 500) }),
92
+ })}\n`
93
+
94
+ queue = queue
95
+ .then(async () => {
96
+ await mkdir(dirname(path), { recursive: true })
97
+ try {
98
+ const info = await stat(path)
99
+ if (info.size > maxBytes) await rename(path, `${path}.1`)
100
+ } catch {
101
+ // 文件不存在(或轮转失败)时直接追加即可
102
+ }
103
+ await appendFile(path, line)
104
+ lastError = null
105
+ })
106
+ .catch(error => {
107
+ // 静默是有代价的:这次就是被静默掩盖了整整一轮。留下线索给 `guard log` 显示。
108
+ lastError = `${path}: ${String(error?.code ?? error?.message ?? error)}`
109
+ })
110
+ return queue
111
+ }
112
+
113
+ /**
114
+ * 等待所有已排队的写入落盘。
115
+ *
116
+ * 为什么需要它:`record()` 是 fire-and-forget(不能阻塞判定),但进程若在写入完成前
117
+ * 退出,末尾几条记录就会丢 —— 本包的冒烟测试里实测发生过。所以**宿主退出路径**
118
+ * (DSH 的 `dispose`、CLI 结束、包装器退出前)应当 await 一次。
119
+ *
120
+ * @returns 队列尾部的 promise(写入失败也 resolve,不抛)。
121
+ */
122
+ export function flush() {
123
+ return queue
124
+ }
125
+
126
+ /**
127
+ * 读取日志尾部若干条。
128
+ * @param options - `{ logPath, tail }`。
129
+ * @returns 解析后的记录数组(最新的在最后;无法解析的行被跳过)。
130
+ */
131
+ export async function readTail(options = {}) {
132
+ const path = resolveLogPath(options)
133
+ const tail = options.tail ?? 20
134
+ const { readFile } = await import('node:fs/promises')
135
+ let text = ''
136
+ try {
137
+ text = await readFile(path, 'utf8')
138
+ } catch {
139
+ return []
140
+ }
141
+ const lines = text.split('\n').filter(l => l.trim() !== '')
142
+ const out = []
143
+ for (const line of lines.slice(-tail)) {
144
+ try {
145
+ out.push(JSON.parse(line))
146
+ } catch {
147
+ // 跳过损坏行
148
+ }
149
+ }
150
+ return out
151
+ }
152
+
153
+ /**
154
+ * 汇总日志统计。
155
+ * @param options - `{ logPath, since, pricePerMTok }}`,since 为毫秒时间戳(默认 24 小时前)。
156
+ * @returns `{ total, byAction, bySource, byRule, byErrorKind, failOpen, degraded, probes, tokens,
157
+ * inputTokens, costUsd, priced, warnings, firstAt, lastAt, lastDegraded }`。
158
+ */
159
+ export async function summarize(options = {}) {
160
+ const path = resolveLogPath(options)
161
+ const since = options.since ?? Date.now() - 24 * 3600 * 1000
162
+ const pricePerMTok = Number(options.pricePerMTok ?? 0.042)
163
+ const { readFile } = await import('node:fs/promises')
164
+ let text = ''
165
+ try {
166
+ text = await readFile(path, 'utf8')
167
+ } catch {
168
+ return {
169
+ total: 0, byAction: {}, bySource: {}, byRule: {}, byErrorKind: {}, failOpen: 0,
170
+ degraded: 0, probes: 0, tokens: 0, inputTokens: 0, costUsd: 0, priced: 0,
171
+ warnings: 0, firstAt: null, lastAt: null, lastDegraded: null,
172
+ }
173
+ }
174
+ const byAction = {}
175
+ const bySource = {}
176
+ const byRule = {}
177
+ const byErrorKind = {}
178
+ let total = 0
179
+ let failOpen = 0
180
+ let degraded = 0
181
+ let probes = 0
182
+ let tokens = 0
183
+ let inputTokens = 0
184
+ let priced = 0
185
+ let warnings = 0
186
+ let firstAt = null
187
+ let lastAt = null
188
+ let lastDegraded = null
189
+ for (const line of text.split('\n')) {
190
+ if (line.trim() === '') continue
191
+ let r
192
+ try {
193
+ r = JSON.parse(line)
194
+ } catch {
195
+ continue
196
+ }
197
+ const at = Date.parse(r.at ?? '')
198
+ if (Number.isFinite(at) && at < since) continue
199
+ total += 1
200
+ // level:'warn' 的记录是"给状态留的痕迹",不参与动作/来源计数,单独统计。
201
+ if (r.level === 'warn') warnings += 1
202
+ else {
203
+ byAction[r.action ?? '?'] = (byAction[r.action ?? '?'] ?? 0) + 1
204
+ bySource[r.source ?? '?'] = (bySource[r.source ?? '?'] ?? 0) + 1
205
+ }
206
+ if (r.rule) byRule[r.rule] = (byRule[r.rule] ?? 0) + 1
207
+ if (r.source === 'error') failOpen += 1
208
+ if (r.source === 'degraded') degraded += 1
209
+ if (r.errorKind) byErrorKind[r.errorKind] = (byErrorKind[r.errorKind] ?? 0) + 1
210
+ if (r.probe) probes += 1
211
+ if (r.source === 'token') tokens += 1
212
+ if (r.degraded?.kind) lastDegraded = { kind: r.degraded.kind, at: r.at }
213
+ const usage = r.usage
214
+ if (usage && Number.isFinite(Number(usage.input_tokens))) {
215
+ inputTokens += Number(usage.input_tokens)
216
+ priced += 1
217
+ }
218
+ if (r.at) {
219
+ if (firstAt === null) firstAt = r.at
220
+ lastAt = r.at
221
+ }
222
+ }
223
+ return {
224
+ total, byAction, bySource, byRule, byErrorKind, failOpen, degraded, probes, tokens,
225
+ inputTokens, costUsd: (inputTokens / 1_000_000) * pricePerMTok, priced, warnings,
226
+ firstAt, lastAt, lastDegraded,
227
+ }
228
+ }