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
package/lib/gate.js ADDED
@@ -0,0 +1,720 @@
1
+ /**
2
+ * jev-guard — judge core(DSH-only 项目里仍然保持宿主无关的那一层)。
3
+ *
4
+ * 判定逻辑住在这里,与"谁来调用它"分开:同一个函数既被 DSH 插件在会话里调用,
5
+ * 也被 `bin/guard.mjs` 与六份离线自检调用。**这不是为了支持多个宿主**(2026-09-20 已收窄为
6
+ * DSH 专用,见 docs/DECISIONS.md D11),而是因为判定必须能被**离线复跑** ——
7
+ * 校准、回归、事故复盘全靠这一点。
8
+ *
9
+ * Pipeline, in order:
10
+ * 0. L0 static rules -> block / escalate, no network, cannot be overridden
11
+ * 1. deterministic prefilter -> allow, no network (read-only + rebuildable paths)
12
+ * 2. Jev, ONE factual noul question -> allow / revise / block by two thresholds
13
+ * 3. timeout, network error, aborted caller -> ALLOW (fail-open; the host's own
14
+ * sandbox and approval pipeline still sit in front of execution)
15
+ *
16
+ * 另外一层:**额度耗尽后的降级**(见 `./quota.js`)。这是收费 API,额度用完是必然事件。
17
+ * 旧行为是"每次调用各自 fail-open" —— 功能没错,但会一直打注定失败的请求、日志被淹没、
18
+ * 而且没有任何人会发现阀门已经瞎了。现在改成:持久性失败(quota / auth / no-key)写一份
19
+ * 状态文件、在冷却窗口内不再发请求、窗口到期放**一次**探测;降级期间默认**只跑免费的
20
+ * L0 + 预筛**(`degradePolicy: 'l0-only'`),并把这个事实写进理由、审计与 `guard status`。
21
+ *
22
+ * The question form is not arbitrary: a 114-item calibration run (2026-09-20)
23
+ * measured this exact noul phrasing at 12/12 on destructive-command cases in
24
+ * Chinese, while judgement-style forms were far weaker (action choice 75%,
25
+ * risk band 50%). See docs/MEASUREMENTS.md.
26
+ */
27
+
28
+ import { readFile, stat } from 'node:fs/promises'
29
+ import { dirname as dirname_, isAbsolute, join as joinPath, resolve as resolvePath } from 'node:path'
30
+ import {
31
+ DEGRADING_KINDS, classifyFailure, clearDegraded, enterDegraded, isDegraded, isSticky, probeDue, readDegraded, warningLine,
32
+ } from './quota.js'
33
+ import { FALLBACK_LANG, normalizeLang, t, tIn } from './i18n.js'
34
+ import { staticRule } from './rules.js'
35
+ import { commandToken, consumeToken } from './token.js'
36
+
37
+ /** Question id sent to Jev; it is echoed back as the answer key. */
38
+ export const QUESTION_ID = 'destroys_data'
39
+
40
+ /** Languages the question can be asked in. */
41
+ export const PROMPT_LANGS = Object.freeze(['zh-CN', 'en'])
42
+
43
+ /**
44
+ * The noul question, in the language it should be asked in.
45
+ *
46
+ * **This is not UI text.** `promptLang` defaults to `zh-CN` — the language the
47
+ * thresholds were calibrated in (114 labelled cases; Chinese input measured as good
48
+ * as or better than translated English) — and it is deliberately independent of
49
+ * `lang`, so switching the interface to English does not silently move a measured
50
+ * decision boundary. Changing it is a re-calibration event, not a translation.
51
+ *
52
+ * @param promptLang - `'zh-CN' | 'en'`; anything else falls back to `zh-CN`.
53
+ * @returns the frozen question object.
54
+ */
55
+ export function judgeQuestion(promptLang = FALLBACK_LANG) {
56
+ const lang = normalizeLang(promptLang) ?? FALLBACK_LANG
57
+ return Object.freeze({
58
+ type: 'noul',
59
+ instructions: tIn(lang, 'gate.question.instructions'),
60
+ criteria: Object.freeze({
61
+ true: tIn(lang, 'gate.question.true'),
62
+ false: tIn(lang, 'gate.question.false'),
63
+ }),
64
+ })
65
+ }
66
+
67
+ /** The calibrated default question, kept as a convenience for probes and docs. */
68
+ export const QUESTION = judgeQuestion('zh-CN')
69
+
70
+ export const DEFAULTS = Object.freeze({
71
+ /**
72
+ * Language of everything a user or a model reads: verdict reasons, CLI output,
73
+ * degradation warnings, rule reasons. `'auto'` resolves from `JEV_GUARD_LANG`,
74
+ * then the POSIX locale variables, then the system locale, then `zh-CN`
75
+ * (see lib/i18n.js). It never changes the question asked to Jev — that is
76
+ * `promptLang`, on purpose.
77
+ */
78
+ lang: 'auto',
79
+ /**
80
+ * Language of the question sent to Jev **and** of the state keys it reads.
81
+ * Defaults to the calibrated Chinese question; see {@link judgeQuestion}.
82
+ */
83
+ promptLang: 'zh-CN',
84
+ /** Below this probability: allow. */
85
+ lowThreshold: 0.5,
86
+ /** At or above this probability: block. In between: revise. */
87
+ highThreshold: 0.7,
88
+ timeoutMs: 1800,
89
+ cacheSize: 256,
90
+ model: 'jev-latest',
91
+ endpoint: 'https://api.typesafe.ai/v1/systemone',
92
+ /** Read an invoked script's body into the state (closes the opaque-script blind spot). */
93
+ inlineScripts: true,
94
+ /** Ceiling for inlined script text. */
95
+ maxScriptBytes: 8192,
96
+ /** 额度/密钥类失败后的冷却时长(毫秒);见 ./quota.js。 */
97
+ quotaCooldownMs: 15 * 60 * 1000,
98
+ authCooldownMs: 30 * 60 * 1000,
99
+ /** 降级期间:`'l0-only'`(默认)= 只停联网语义层,L0 + 预筛照跑;`'off'` = 整条阀门暂停。 */
100
+ degradePolicy: 'l0-only',
101
+ /** 降级状态文件;空串 = 默认 `<JEV_GUARD_HOME>/degraded.json`。 */
102
+ degradedPath: '',
103
+ /**
104
+ * 本入口的身份,写进"本地类"降级状态(目前只有 `no-key`)。
105
+ *
106
+ * 为什么需要它:`no-key` 是**本地配置**状况(密钥解析各入口独立 —— DSH 插件走
107
+ * `ctx.credentials` / 环境变量 / 包根 `secrets.json`,CLI 走环境变量 / 那个文件),
108
+ * 而 degraded.json 是**全机共享**的文件。不记身份的话,某条入口读不到密钥就会把
109
+ * **密钥其实是好的**其它入口一起按停。服务侧状态(quota / auth)一律记 `global`。
110
+ */
111
+ scope: 'default',
112
+ /**
113
+ * 把降级、以及"没有解析到密钥"这两件事**在会话里说出来**(DSH 适配器在 `agent/pre-step`
114
+ * 上注入一条 notice)。纯 host 插件没有任何 toast / banner / 启动提示接口 —— 注入会话消息
115
+ * 是唯一能让用户真看到的渠道;设 false 则只保留 logger 与审计日志。
116
+ */
117
+ notifyInSession: true,
118
+ /** 成本估算单价(美元 / 百万输入 token);输出按官方说明免费。 */
119
+ pricePerMTok: 0.042,
120
+ /**
121
+ * 审批策略为 `ask`(宿主真的会弹审批框)时,`revise`(lowThreshold ≤ p < highThreshold)
122
+ * 怎么处置:`'ask'`(默认)= 转人工审批,`'deny'` = 直接拒绝。
123
+ *
124
+ * 为什么默认转人工:灰区本来就"证据不足",而 ask 模式下**人就在场**;直接拒绝等于让
125
+ * 一个 50% 的判断替人做决定。宿主没有应答者时审批是 fail-closed,不会出现无人自动放行。
126
+ */
127
+ reviseInAskMode: 'ask',
128
+ /**
129
+ * 同上,但针对 `block`(p ≥ highThreshold,由 Jev 语义层给出)。
130
+ * **不适用于 L0 的 `deny` 类硬规则**:那条路两种模式都拦死,见 toHostDecision。
131
+ */
132
+ blockInAskMode: 'ask',
133
+ })
134
+
135
+ /* ------------------------------------------------------------------ *
136
+ * 1. deterministic read-only prefilter
137
+ * ------------------------------------------------------------------ */
138
+
139
+ /** Shell metacharacters that make a command more than one simple statement. */
140
+ const META = /[;&|<>`$()\n]/
141
+
142
+ /** First tokens that cannot mutate anything on their own. */
143
+ const PURE_READONLY = new Set([
144
+ 'ls', 'cat', 'head', 'tail', 'wc', 'grep', 'rg', 'ps', 'df', 'du', 'free', 'uptime',
145
+ 'whoami', 'pwd', 'echo', 'printf', 'date', 'which', 'type', 'printenv', 'stat', 'file',
146
+ 'md5sum', 'sha256sum', 'shasum', 'tree', 'jq', 'sort', 'uniq', 'cut', 'tr', 'basename',
147
+ 'dirname', 'realpath', 'readlink', 'id', 'hostname', 'uname', 'lscpu', 'nproc', 'ss',
148
+ 'netstat', 'lsblk', 'lsof', 'timedatectl', 'who', 'groups', 'true', 'false', 'test',
149
+ 'diff', 'cmp', 'column', 'paste', 'fold', 'nl', 'od', 'xxd', 'strings', 'less', 'more',
150
+ 'man', 'info', 'es', 'codegraph', 'vol',
151
+ ])
152
+
153
+ /** Read-only shapes for tools that are only sometimes read-only. */
154
+ const READONLY_SHAPE = [
155
+ /^sed\s+-n\b(?!.*\s-i\b)/, // sed -n without -i
156
+ /^find\s+(?![^|]*-(delete|exec|execdir|ok|okdir|fprint|fprint0|fls)\b)/,
157
+ /^journalctl\s+(?!.*--vacuum)/,
158
+ /^git\s+(-C\s+\S+\s+)?(status|log|diff|show|blame|shortlog|rev-parse|rev-list|ls-files|ls-tree|cat-file|describe|reflog|count-objects|whatchanged|grep|fetch)\b/,
159
+ /^git\s+(-C\s+\S+\s+)?branch\s*(--list|-a|-r|-v|--show-current|--contains)?\s*$/,
160
+ /^git\s+(-C\s+\S+\s+)?(remote\s+(-v|show)|config\s+--(get|list)|stash\s+list|tag\s+-l\b|tag\s+--list|worktree\s+list)\b/,
161
+ /^docker\s+(ps|images|logs|inspect|version|info|stats|top|diff|port|history)\b/,
162
+ /^docker\s+compose\s+(ps|logs|config|top)\b/,
163
+ /^kubectl\s+(get|describe|logs|version|explain|api-resources|top)\b/,
164
+ /^kubectl\s+config\s+(view|current-context)\b/,
165
+ /^systemctl\s+(status|is-active|is-enabled|is-failed|list-units|list-unit-files|show|cat|--version)\b/,
166
+ /^(npm|pnpm|yarn)\s+(ls|list|why|view|outdated|config\s+get|--version|-v)\b/,
167
+ /^(node|python3?|pip3?|deno|bun|rustc|go|cargo|tsc)\s+(--version|-v|list|show|freeze)\s*$/,
168
+ /^cargo\s+(tree|metadata)\b/,
169
+ /^go\s+(version|env|list)\b/,
170
+ ]
171
+
172
+ /** Directories whose deletion is cache/build housekeeping rather than data loss. */
173
+ const SAFE_ROOT = /^(\/tmp\/|\/var\/tmp\/|~\/\.cache\/|\$HOME\/\.cache\/|\/home\/[^/]+\/\.cache\/)/
174
+ const SAFE_SEGMENT = /(\/|^)(node_modules|dist|build|\.next|__pycache__|\.pytest_cache|target\/debug|target\/release)(\/|$)/
175
+ const NEVER_SAFE = new Set(['/', '/*', '~', '$HOME', '.', '..', '*', '~/*'])
176
+
177
+ /**
178
+ * Strip quoted substrings so a `>` inside a grep pattern is not read as a redirect.
179
+ * @param command - raw command text.
180
+ * @returns the command with quoted regions blanked out.
181
+ */
182
+ export function stripQuoted(command) {
183
+ return command.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""')
184
+ }
185
+
186
+ /**
187
+ * Whether a command is provably read-only (safe to allow without asking Jev).
188
+ * Deliberately conservative: anything ambiguous falls through to Jev.
189
+ * @param command - raw command text.
190
+ * @returns a human-readable reason when read-only, otherwise undefined.
191
+ */
192
+ export function readOnlyReason(command) {
193
+ const raw = command.trim()
194
+ if (raw.length === 0) return 'empty command'
195
+ const s = stripQuoted(raw)
196
+ if (META.test(s)) return undefined
197
+ const head = s.split(/\s+/)[0]
198
+ if (PURE_READONLY.has(head)) {
199
+ // `cat`-style readers cannot mutate; `es`/`codegraph` are index readers.
200
+ if (head === 'printenv' || head === 'type' || head === 'command') return `read-only (${head})`
201
+ return `read-only (${head})`
202
+ }
203
+ for (const re of READONLY_SHAPE) if (re.test(s)) return `read-only shape (${head})`
204
+ return undefined
205
+ }
206
+
207
+ /**
208
+ * Whether a `rm` command only targets rebuildable locations.
209
+ * @param command - raw command text.
210
+ * @returns a reason when every target is rebuildable, otherwise undefined.
211
+ */
212
+ export function safeDeleteReason(command) {
213
+ const raw = command.trim()
214
+ if (!/^rm\s/.test(raw)) return undefined
215
+ const s = stripQuoted(raw)
216
+ if (META.test(s)) return undefined
217
+ const parts = s.split(/\s+/).slice(1)
218
+ const targets = []
219
+ for (const p of parts) {
220
+ if (p === 'rm') continue
221
+ if (p.startsWith('-')) continue
222
+ targets.push(p)
223
+ }
224
+ if (targets.length === 0) return undefined
225
+ for (const t of targets) {
226
+ if (NEVER_SAFE.has(t)) return undefined
227
+ if (t.includes('..')) return undefined
228
+ if (SAFE_ROOT.test(t) || SAFE_SEGMENT.test(t)) continue
229
+ return undefined
230
+ }
231
+ return `rm on rebuildable path(s): ${targets.join(' ')}`
232
+ }
233
+
234
+ /**
235
+ * Run both deterministic prefilter rules, including compound commands: when a
236
+ * command is a sequence of `;` / `&&` / `||` / `|` segments and *every* segment
237
+ * is provably harmless on its own, the whole command is harmless. One unsafe
238
+ * segment makes the whole command go to Jev.
239
+ * @param command - raw command text.
240
+ * @returns a reason string when the command is provably harmless, else undefined.
241
+ */
242
+ export function prefilter(command) {
243
+ const direct = readOnlyReason(command) ?? safeDeleteReason(command)
244
+ if (direct) return direct
245
+ const segments = stripQuoted(command.trim()).split(/\s*(?:;|&&|\|\||\||\n)\s*/).filter(Boolean)
246
+ if (segments.length < 2) return undefined
247
+ const reasons = segments.map(s => readOnlyReason(s) ?? safeDeleteReason(s))
248
+ if (reasons.some(r => r === undefined)) return undefined
249
+ return `compound of ${segments.length} harmless segments`
250
+ }
251
+
252
+ /* ------------------------------------------------------------------ *
253
+ * 2. verdict cache
254
+ * ------------------------------------------------------------------ */
255
+
256
+ /** Minimal LRU keyed by exact command text. */
257
+ export class VerdictCache {
258
+ /**
259
+ * @param limit - maximum retained entries.
260
+ */
261
+ constructor(limit = DEFAULTS.cacheSize) {
262
+ this.limit = Math.max(1, limit)
263
+ this.map = new Map()
264
+ }
265
+
266
+ /**
267
+ * @param key - command text.
268
+ * @returns the cached verdict, or undefined.
269
+ */
270
+ get(key) {
271
+ const v = this.map.get(key)
272
+ if (v === undefined) return undefined
273
+ this.map.delete(key)
274
+ this.map.set(key, v)
275
+ return v
276
+ }
277
+
278
+ /**
279
+ * @param key - command text.
280
+ * @param value - verdict to retain.
281
+ */
282
+ set(key, value) {
283
+ this.map.set(key, value)
284
+ while (this.map.size > this.limit) this.map.delete(this.map.keys().next().value)
285
+ }
286
+
287
+ /** @returns current entry count. */
288
+ get size() {
289
+ return this.map.size
290
+ }
291
+ }
292
+
293
+ /* ------------------------------------------------------------------ *
294
+ * 3. state enrichment — the "my own script did something irreversible" case
295
+ * ------------------------------------------------------------------ */
296
+
297
+ /** Interpreters whose file argument can be read and judged by its content. */
298
+ const INTERPRETERS = new Set([
299
+ 'node', 'python', 'python3', 'bash', 'sh', 'zsh', 'dash', 'deno', 'bun',
300
+ 'tsx', 'ts-node', 'ruby', 'perl', 'php', 'lua', 'Rscript',
301
+ ])
302
+
303
+ /** Tokens that look like a script file rather than a flag or a subcommand. */
304
+ const LOOKS_LIKE_SCRIPT = /\.(mjs|cjs|js|ts|mts|cts|jsx|tsx|py|sh|bash|zsh|rb|pl|php|lua)$/i
305
+
306
+ /** Package-manager subcommands that never name a user script. */
307
+ const PM_BUILTINS = new Set([
308
+ 'install', 'add', 'remove', 'rm', 'update', 'up', 'upgrade', 'i', 'ci', 'exec', 'dlx', 'x',
309
+ 'why', 'ls', 'list', 'view', 'outdated', 'config', 'publish', 'pack', 'create', 'init',
310
+ 'link', 'unlink', 'prune', 'store', 'audit', 'licenses', 'import', 'patch', 'deploy',
311
+ ])
312
+
313
+ /**
314
+ * Paths whose contents must never leave the machine, so they are never inlined
315
+ * into the state sent to the API.
316
+ */
317
+ const SENSITIVE_PATH = /(^|\/)(\.env(\..*)?|\.ssh|\.gnupg|\.aws|\.config\/gh|id_rsa.*|id_ed25519.*|.*\.pem|.*\.key|.*credential.*|.*secret.*|.*token.*)$/i
318
+
319
+ /**
320
+ * Split a command on `;` / `&&` / `||` / `|` while keeping the original quoting.
321
+ * @param command - raw command text.
322
+ * @returns the raw segments.
323
+ */
324
+ function splitRaw(command) {
325
+ return command.split(/\s*(?:;|&&|\|\||\|)\s*/).filter(s => s.trim() !== '')
326
+ }
327
+
328
+ /**
329
+ * Quote-aware whitespace tokenizer.
330
+ * @param segment - one raw command segment.
331
+ * @returns tokens with quotes removed.
332
+ */
333
+ function tokenize(segment) {
334
+ const tokens = []
335
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g
336
+ let m
337
+ while ((m = re.exec(segment)) !== null) tokens.push(m[1] ?? m[2] ?? m[3])
338
+ return tokens
339
+ }
340
+
341
+ /**
342
+ * Find the script file a command would execute, if any.
343
+ * @param command - raw command text.
344
+ * @returns the script path as written (possibly relative), or undefined.
345
+ */
346
+ export function scriptTarget(command) {
347
+ const first = splitRaw(command)[0]
348
+ if (first === undefined) return undefined
349
+ const tokens = tokenize(first)
350
+ let i = 0
351
+ while (i < tokens.length && ['sudo', 'env', 'command', 'nohup', 'time'].includes(tokens[i])) i += 1
352
+ const head = tokens[i]
353
+ if (head === undefined) return undefined
354
+
355
+ if (INTERPRETERS.has(head)) {
356
+ for (let j = i + 1; j < tokens.length; j += 1) {
357
+ const t = tokens[j]
358
+ if (t.startsWith('-')) continue
359
+ return LOOKS_LIKE_SCRIPT.test(t) ? t : undefined // `node -e "..."` has no file
360
+ }
361
+ return undefined
362
+ }
363
+
364
+ if (['pnpm', 'npm', 'yarn', 'bunx', 'npx'].includes(head)) {
365
+ for (let j = i + 1; j < tokens.length; j += 1) {
366
+ const t = tokens[j]
367
+ if (t === '--' || ['exec', 'dlx', 'x', 'run'].includes(t)) continue
368
+ if (t.startsWith('-')) continue
369
+ if (INTERPRETERS.has(t)) continue
370
+ return LOOKS_LIKE_SCRIPT.test(t) ? t : undefined
371
+ }
372
+ return undefined
373
+ }
374
+
375
+ if (LOOKS_LIKE_SCRIPT.test(head) && (head.startsWith('./') || head.startsWith('/') || head.includes('/'))) return head
376
+ return undefined
377
+ }
378
+
379
+ /**
380
+ * Find the package.json script a command would run.
381
+ * @param command - raw command text.
382
+ * @returns the script name, or undefined.
383
+ */
384
+ export function packageScriptName(command) {
385
+ const first = splitRaw(command)[0]
386
+ if (first === undefined) return undefined
387
+ const tokens = tokenize(first)
388
+ const head = tokens[0]
389
+ if (head === undefined || !['pnpm', 'npm', 'yarn', 'bun'].includes(head)) return undefined
390
+ if (tokens[1] === 'run' || tokens[1] === 'run-script') return tokens[2]?.startsWith('-') ? undefined : tokens[2]
391
+ const candidate = tokens[1]
392
+ if (candidate === undefined || candidate.startsWith('-') || PM_BUILTINS.has(candidate)) return undefined
393
+ return candidate
394
+ }
395
+
396
+ /**
397
+ * Read a file as text when it is small, textual and not sensitive.
398
+ * @param path - absolute path to read.
399
+ * @param maxBytes - size ceiling.
400
+ * @returns the text, or a short reason why it was skipped.
401
+ */
402
+ async function readSmallText(path, maxBytes, promptLang = FALLBACK_LANG) {
403
+ if (SENSITIVE_PATH.test(path)) return { skipped: tIn(promptLang, 'gate.skip.sensitive') }
404
+ try {
405
+ const info = await stat(path)
406
+ if (!info.isFile()) return { skipped: tIn(promptLang, 'gate.skip.notFile') }
407
+ if (info.size > maxBytes) return { skipped: tIn(promptLang, 'gate.skip.tooLarge', { bytes: info.size }) }
408
+ const text = await readFile(path, 'utf8')
409
+ if (text.includes('\u0000')) return { skipped: tIn(promptLang, 'gate.skip.binary') }
410
+ return { text }
411
+ } catch (error) {
412
+ return { skipped: tIn(promptLang, 'gate.skip.readError', { error: error?.code ?? error?.message ?? error }) }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Resolve the nearest `package.json` scripts entry by walking up from a directory.
418
+ * @param name - script name.
419
+ * @param cwd - starting directory.
420
+ * @returns the script body, or undefined.
421
+ */
422
+ async function lookupPackageScript(name, cwd) {
423
+ let dir = cwd
424
+ for (let depth = 0; depth < 8; depth += 1) {
425
+ try {
426
+ const raw = await readFile(joinPath(dir, 'package.json'), 'utf8')
427
+ const body = JSON.parse(raw)?.scripts?.[name]
428
+ if (typeof body === 'string') return body
429
+ } catch {
430
+ // keep walking up
431
+ }
432
+ const parent = dirname_(dir)
433
+ if (parent === dir) break
434
+ dir = parent
435
+ }
436
+ return undefined
437
+ }
438
+
439
+ /**
440
+ * Build the `state` object sent to Jev, enriching an opaque script invocation
441
+ * with the script body (the one blind spot measured on 2026-09-20: the command
442
+ * text alone scored 0.31 on a migration script that drops a column, while the
443
+ * same call with the body scored 0.83).
444
+ *
445
+ * @param command - raw command text.
446
+ * @param cfg - gate config (`cwd`, `inlineScripts`, `maxScriptBytes`).
447
+ * @returns the state object.
448
+ */
449
+ export async function buildState(command, cfg = {}) {
450
+ // 状态是**发给判定服务的载荷**,所以它的语言是 promptLang 而不是界面语言 lang。
451
+ const promptLang = normalizeLang(cfg.promptLang) ?? FALLBACK_LANG
452
+ const state = { [tIn(promptLang, 'gate.state.command')]: command }
453
+ if (cfg.inlineScripts === false) return state
454
+ const cwd = cfg.cwd ?? process.cwd()
455
+ const maxBytes = cfg.maxScriptBytes ?? 8192
456
+
457
+ const pkgName = packageScriptName(command)
458
+ if (pkgName !== undefined) {
459
+ const body = await lookupPackageScript(pkgName, cwd)
460
+ if (body !== undefined) {
461
+ state[tIn(promptLang, 'gate.state.pkgScript')] = `"${pkgName}": ${body}`
462
+ const inner = scriptTarget(body)
463
+ if (inner !== undefined) {
464
+ const innerPath = isAbsolute(inner) ? inner : resolvePath(cwd, inner)
465
+ const read = await readSmallText(innerPath, maxBytes, promptLang)
466
+ if (read.text !== undefined) {
467
+ state[tIn(promptLang, 'gate.state.pkgScriptBody')] = truncate(read.text, maxBytes, promptLang)
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ const target = scriptTarget(command)
474
+ if (target !== undefined) {
475
+ const path = isAbsolute(target) ? target : resolvePath(cwd, target)
476
+ const read = await readSmallText(path, maxBytes, promptLang)
477
+ const scriptKey = tIn(promptLang, 'gate.state.script')
478
+ if (read.text !== undefined) state[scriptKey] = truncate(read.text, maxBytes, promptLang)
479
+ else if (read.skipped !== undefined) state[scriptKey] = tIn(promptLang, 'gate.state.notRead', { why: read.skipped })
480
+ }
481
+ return state
482
+ }
483
+
484
+ function truncate(text, maxBytes, promptLang = FALLBACK_LANG) {
485
+ return text.length <= maxBytes ? text : `${text.slice(0, maxBytes)}\n${tIn(promptLang, 'gate.truncated')}`
486
+ }
487
+
488
+ /* ------------------------------------------------------------------ *
489
+ * 4. Jev call
490
+ * ------------------------------------------------------------------ */
491
+
492
+ /**
493
+ * Ask Jev the one validated question about one command state.
494
+ * @param state - the state object (see {@link buildState}).
495
+ * @param cfg - resolved gate config (apiKey required).
496
+ * @param signal - optional external cancellation (combined with the timeout).
497
+ * @returns the answer probability plus the model that answered.
498
+ */
499
+ export async function callJev(state, cfg, signal) {
500
+ if (!cfg.apiKey) {
501
+ const error = new Error('no TypeSafe API key resolved')
502
+ error.code = 'no-key'
503
+ throw error
504
+ }
505
+ const timeout = AbortSignal.timeout(cfg.timeoutMs)
506
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout
507
+ const body = {
508
+ model: cfg.model,
509
+ state,
510
+ questions: { [QUESTION_ID]: judgeQuestion(cfg.promptLang) },
511
+ }
512
+ const res = await fetch(cfg.endpoint, {
513
+ method: 'POST',
514
+ headers: { Authorization: `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' },
515
+ body: JSON.stringify(body),
516
+ signal: combined,
517
+ })
518
+ if (!res.ok) {
519
+ // 把状态码与正文挂在异常上:**分类器靠它们区分"额度用完"和"抖了一下"**,
520
+ // 而这两者的处置完全不同(前者停 15 分钟并告警,后者只逐次 fail-open)。
521
+ const text = (await res.text()).slice(0, 300)
522
+ const error = new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
523
+ error.status = res.status
524
+ error.body = text
525
+ throw error
526
+ }
527
+ const json = await res.json()
528
+ const answer = json?.answers?.[QUESTION_ID]
529
+ if (!answer || typeof answer.noul !== 'number') {
530
+ const error = new Error(`unexpected answer shape: ${JSON.stringify(json).slice(0, 200)}`)
531
+ error.code = 'shape'
532
+ throw error
533
+ }
534
+ // 用量只用于**成本可见性**(`guard log --stats` 会累计并折算美元),不参与判定。
535
+ const usage = json?.usage && typeof json.usage === 'object' ? json.usage : undefined
536
+ return { p: answer.noul, model: json.model ?? cfg.model, usage }
537
+ }
538
+
539
+ /* ------------------------------------------------------------------ *
540
+ * 4. verdict
541
+ * ------------------------------------------------------------------ */
542
+
543
+ /**
544
+ * 一次性放行令牌的挂载点。
545
+ *
546
+ * 位置很关键:它在**所有判定之后**,所以缓存过的 block/revise 也会重新检查令牌
547
+ * (否则一条被判过的命令就永远无法凭令牌放行)。但 L0 的 `deny` 规则**不受令牌影响** ——
548
+ * 那是四层设计里的硬地板,只能由人手动执行。
549
+ *
550
+ * @param command - 原始命令文本。
551
+ * @param verdict - 判定结果。
552
+ * @param cfg - 配置(`tokens`、`tokenPath`)。
553
+ * @returns 原判定,或凭令牌改成放行后的判定(附带 token 字段便于审计)。
554
+ */
555
+ async function applyToken(command, verdict, cfg) {
556
+ if (cfg.tokens === false || verdict.action === 'allow') return verdict
557
+ const token = commandToken(command)
558
+ if (verdict.rule?.kind === 'deny') return { ...verdict, token } // L0 硬地板:令牌不越过
559
+ try {
560
+ const grant = await consumeToken(command, { tokenPath: cfg.tokenPath })
561
+ if (!grant.ok) return { ...verdict, token }
562
+ return {
563
+ action: 'allow',
564
+ source: 'token',
565
+ token: grant.token,
566
+ overridden: verdict.action,
567
+ p: verdict.p,
568
+ rule: verdict.rule,
569
+ ms: verdict.ms,
570
+ }
571
+ } catch {
572
+ // 令牌机制出错不能让判定变形:退回原判定(保守方向)
573
+ return { ...verdict, token }
574
+ }
575
+ }
576
+
577
+ /**
578
+ * 降级放行:不联网、不花钱,并把"为什么"带在判定上,让每个宿主都能转达给人。
579
+ *
580
+ * @param state - 降级状态。
581
+ * @param cfg - 配置(取 degradePolicy)。
582
+ * @param now - 当前时间。
583
+ * @param probe - 这次是不是"探测之后仍然失败"的那一次。
584
+ * @returns 判定(总是 allow)。
585
+ */
586
+ function degradedVerdict(state, cfg, now, probe = false) {
587
+ return {
588
+ action: 'allow',
589
+ source: 'degraded',
590
+ errorKind: state.kind,
591
+ degraded: { kind: state.kind, label: state.label, since: state.since, until: state.until, failures: state.failures },
592
+ warning: warningLine(state, now),
593
+ reason: t('quota.reason.degraded', {
594
+ kind: state.kind,
595
+ policy: t(cfg.degradePolicy === 'off' ? 'quota.policy.off' : 'quota.policy.l0-only'),
596
+ }),
597
+ probe,
598
+ ms: 0,
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Decide what to do with one command.
604
+ *
605
+ * @param command - raw command text.
606
+ * @param options - judge config; `apiKey` is required unless the prefilter answers.
607
+ * `degradePolicy: 'off'` 让整条阀门在降级期间暂停(在 L0 之前就返回);
608
+ * `quotaGuard: false` 可整体关掉降级检查(测试用)。
609
+ * @returns a verdict `{ action: 'allow' | 'revise' | 'block' | 'escalate', ... }`.
610
+ */
611
+ export async function evaluateCommand(command, options = {}) {
612
+ const cfg = { ...DEFAULTS, ...options }
613
+ const now = Date.now()
614
+ const cache = cfg.cache ?? (cfg.cache = new VerdictCache(cfg.cacheSize))
615
+
616
+ // 降级状态:先读一次,后面每个分支都用它。
617
+ // 读失败 = 当作没降级(宁可去问一次 API,也不要被一个坏文件卡在降级态里)。
618
+ let degradedState = cfg.quotaGuard === false ? null : await readDegraded(cfg)
619
+
620
+ // 粘性状态("没有解析到密钥")靠**条件消失**结束,不靠时间:密钥一旦能解析到,这份状态
621
+ // 就没有存在理由了,当场清掉。这一步零 HTTP(密钥解析是纯本地的事),所以不必等冷却、
622
+ // 也不必等探测窗口 —— 插上密钥后的第一条命令就恢复成正常判定。
623
+ // 作用域过滤只影响 isDegraded/probeDue 的判断,**不影响**这里的清除:清的是自己那条
624
+ // 状态文件,谁写的都该在密钥出现后消失。
625
+ if (degradedState !== null && isSticky(degradedState) && degradedState.kind === 'no-key' && cfg.apiKey) {
626
+ await clearDegraded(cfg)
627
+ degradedState = null
628
+ }
629
+
630
+ const degradedNow = isDegraded(degradedState, now, cfg.scope)
631
+
632
+ // 0. 配置要求"降级期间整条阀门暂停" → 在 L0 之前就放行(连免费规则也不跑)。
633
+ // 这是显式选择:默认的 'l0-only' 会保留免费的 L0 + 预筛。
634
+ if (degradedNow && cfg.degradePolicy === 'off') return degradedVerdict(degradedState, cfg, now, true)
635
+
636
+ // L0 — hard rules first, and they outrank everything below.
637
+ // 它**不花钱、不联网**,所以降级期间照常工作 —— 这正是默认策略的用意。
638
+ const rule = staticRule(command)
639
+ if (rule) {
640
+ return applyToken(command, {
641
+ action: rule.kind === 'deny' ? 'block' : 'escalate',
642
+ source: 'static-rule',
643
+ rule: { id: rule.id, why: rule.why, kind: rule.kind },
644
+ ms: 0,
645
+ ...(degradedNow
646
+ ? { degraded: { kind: degradedState.kind, until: degradedState.until }, warning: warningLine(degradedState, now) }
647
+ : {}),
648
+ }, cfg)
649
+ }
650
+
651
+ // L1a — provably harmless, no network.
652
+ const fast = prefilter(command)
653
+ if (fast) return { action: 'allow', source: 'prefilter', reason: fast, ms: 0 }
654
+
655
+ const key = cfg.cwd ? `${cfg.cwd}\u0000${command}` : command
656
+ const cached = cache.get(key)
657
+ if (cached) return applyToken(command, { ...cached, source: 'cache', ms: 0 }, cfg)
658
+
659
+ // 降级窗口内(探测还没到期)→ 直接放行,不发请求。缓存过的判定在上面已经生效,
660
+ // 所以降级丢掉的只是"无法再用语义层判新命令",不是整个判定历史。
661
+ if (degradedNow && !probeDue(degradedState, now, cfg.scope)) return degradedVerdict(degradedState, cfg, now)
662
+ // 注意:探测的判据是"有状态且已到期",**不能**写成 `degradedNow && probeDue(...)` ——
663
+ // 状态到期时 isDegraded() 恰好是 false,那样写会让"到期后的那次探测"永远不算探测,
664
+ // 于是恢复与续期全部静默失效(本功能第一版就是这个错,自检当场抓到)。
665
+ const isProbe = degradedState !== null && probeDue(degradedState, now, cfg.scope)
666
+
667
+ const started = Date.now()
668
+ const commandKey = tIn(normalizeLang(cfg.promptLang) ?? FALLBACK_LANG, 'gate.state.command')
669
+ let state = { [commandKey]: command }
670
+ try {
671
+ state = await buildState(command, cfg)
672
+ } catch {
673
+ // enrichment is best-effort: judge the command text alone
674
+ }
675
+
676
+ try {
677
+ const { p, model, usage } = await callJev(state, cfg, cfg.signal)
678
+ // 探测成功 = 服务回来了 → 清掉降级状态。人不需要做任何事。
679
+ if (isProbe) await clearDegraded(cfg)
680
+ const verdict = {
681
+ action: p >= cfg.highThreshold ? 'block' : p >= cfg.lowThreshold ? 'revise' : 'allow',
682
+ source: 'jev',
683
+ p,
684
+ model,
685
+ usage,
686
+ lowThreshold: cfg.lowThreshold,
687
+ highThreshold: cfg.highThreshold,
688
+ enriched: Object.keys(state).filter(k => k !== commandKey),
689
+ ms: Date.now() - started,
690
+ ...(isProbe ? { probe: true, recovered: true } : {}),
691
+ }
692
+ cache.set(key, verdict)
693
+ return applyToken(command, verdict, cfg)
694
+ } catch (error) {
695
+ // fail-open: availability of the valve must never block the agent; the
696
+ // host's sandbox + approval pipeline still sits in front of the execution.
697
+ const { kind, status, detail } = classifyFailure(error)
698
+ const base = {
699
+ action: 'allow',
700
+ source: 'error',
701
+ errorKind: kind,
702
+ error: detail,
703
+ status,
704
+ ms: Date.now() - started,
705
+ }
706
+ // 持久性失败 → 进入降级:停发请求 + 留一份可读状态 + 把告警挂到这次判定上。
707
+ // `scope` 只对本地类(`no-key`)有意义:它决定这份状态压制谁(见 DEFAULTS.scope)。
708
+ if (DEGRADING_KINDS.includes(kind)) {
709
+ const after = await enterDegraded(kind, { cfg, now: Date.now(), detail, status, probe: isProbe, scope: cfg.scope })
710
+ return {
711
+ ...base,
712
+ degraded: { kind, label: after.label, since: after.since, until: after.until, failures: after.failures },
713
+ warning: warningLine(after, Date.now()),
714
+ probe: isProbe,
715
+ }
716
+ }
717
+ return { ...base, ...(isProbe ? { probe: true } : {}) }
718
+ }
719
+ }
720
+