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.
- package/CHANGELOG.md +285 -0
- package/CHANGELOG.zh-CN.md +271 -0
- package/DEPLOY.md +202 -0
- package/DEPLOY.zh-CN.md +200 -0
- package/LICENSE +21 -0
- package/README.md +316 -0
- package/README.zh-CN.md +315 -0
- package/START-HERE.md +97 -0
- package/START-HERE.zh-CN.md +97 -0
- package/adapters/README.md +37 -0
- package/adapters/README.zh-CN.md +37 -0
- package/adapters/dsh/index.js +502 -0
- package/bin/guard.mjs +634 -0
- package/config.example.json +52 -0
- package/cordis.patch.yml +120 -0
- package/docs/AGENT-TASK-dsh.md +134 -0
- package/docs/AGENT-TASK-dsh.zh-CN.md +131 -0
- package/docs/ARCHITECTURE.md +118 -0
- package/docs/ARCHITECTURE.zh-CN.md +117 -0
- package/docs/DECISIONS.md +469 -0
- package/docs/DECISIONS.zh-CN.md +449 -0
- package/docs/DSH-INTEGRATION.md +178 -0
- package/docs/DSH-INTEGRATION.zh-CN.md +171 -0
- package/docs/MEASUREMENTS.md +433 -0
- package/docs/MEASUREMENTS.zh-CN.md +450 -0
- package/docs/USER-INTERVENTION.md +141 -0
- package/docs/USER-INTERVENTION.zh-CN.md +143 -0
- package/docs/VERIFICATION.md +279 -0
- package/docs/VERIFICATION.zh-CN.md +278 -0
- package/lib/audit.js +228 -0
- package/lib/gate.js +720 -0
- package/lib/i18n.js +575 -0
- package/lib/quota.js +389 -0
- package/lib/rules.js +174 -0
- package/lib/token.js +154 -0
- package/lib/verdict.js +285 -0
- package/package.json +82 -0
- package/tools/check-doc-pairs.mjs +158 -0
- package/tools/extract-commands.mjs +156 -0
- package/tools/gate-cli.mjs +240 -0
- package/tools/probe-prompt-lang.mjs +238 -0
- package/tools/probe-scripts.mjs +143 -0
- package/tools/report-result.mjs +146 -0
- package/tools/selftest-audit.mjs +93 -0
- package/tools/selftest-entry.mjs +177 -0
- package/tools/selftest-i18n.mjs +177 -0
- package/tools/selftest-quota.mjs +260 -0
- package/tools/selftest-reason.mjs +266 -0
- package/tools/selftest-rules.mjs +107 -0
- package/tools/selftest-token.mjs +100 -0
- package/tools/smoke-dsh-adapter.mjs +295 -0
- package/tools/smoke-dsh-pipeline.mjs +146 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jev-guard — DSH adapter(native Cordis plugin)。
|
|
3
|
+
*
|
|
4
|
+
* A native hook in DSH is just an ordinary plugin subscribing to a canonical
|
|
5
|
+
* lifecycle event, so this file is thin on purpose: 判定在 `lib/`(与调用方无关),
|
|
6
|
+
* 这一半只做四件事:解析凭据、从工具调用里取出命令、应用重试预算、
|
|
7
|
+
* 在 `tools/pre-execute` 上返回 typed PreToolDecision(allow / ask / deny 瀑布)。
|
|
8
|
+
*
|
|
9
|
+
* 它住在 `adapters/dsh/` 而不是 `lib/`,是**结构性声明**:判定不该知道谁在调用它 ——
|
|
10
|
+
* 这样同一套判定才能被 CLI 与六份自检**离线复跑**(校准、回归、事故复盘全靠这一点)。
|
|
11
|
+
* `lib/` 里没有任何 DSH 概念(没有 Cordis、没有 ctx、没有 PreToolDecision)。
|
|
12
|
+
* 机制与映射见 docs/DSH-INTEGRATION.md。
|
|
13
|
+
*
|
|
14
|
+
* @module jev-guard/dsh
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from 'node:crypto'
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { dirname, isAbsolute, join } from 'node:path'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import { flush, record } from '../../lib/audit.js'
|
|
22
|
+
import { DEFAULTS, evaluateCommand } from '../../lib/gate.js'
|
|
23
|
+
import { setLang, getLang, t } from '../../lib/i18n.js'
|
|
24
|
+
import { DEGRADING_KINDS, appliesTo, isSticky, kindLabel, readDegraded } from '../../lib/quota.js'
|
|
25
|
+
import { RetryBudget, fingerprint, reviseGuidance, toHostDecision } from '../../lib/verdict.js'
|
|
26
|
+
|
|
27
|
+
/** Package root (adapters/dsh/ → ../..), so every adapter reads the same config.json. */
|
|
28
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
29
|
+
|
|
30
|
+
/** CLI 入口绝对路径:写进拒绝理由里,让用户能直接复制粘贴授权命令(不用自己找路径)。 */
|
|
31
|
+
const CLI_PATH = join(ROOT, 'bin', 'guard.mjs')
|
|
32
|
+
|
|
33
|
+
/** 插件 id:同时是 cordis 的 `name` 与会话 notice 的 `source.plugin`(去重靠它认自己的话)。 */
|
|
34
|
+
const PLUGIN_ID = 'jev-guard'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 本入口在降级状态里的身份(见 DEFAULTS.scope)。
|
|
38
|
+
* 与 CLI 的 `'cli'` 必须不同:两边各自解析密钥,本地类状态(`no-key`)只该压制写下它的那条入口。
|
|
39
|
+
*/
|
|
40
|
+
const ADAPTER_SCOPE = 'dsh-adapter'
|
|
41
|
+
|
|
42
|
+
/** notice 摘要的字符上限;与 DSH 的 CONTEXT_SUMMARY_MAX_CHARS 一致(超出会被折叠行截断)。 */
|
|
43
|
+
const SUMMARY_MAX = 120
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read config.json so there is ONE place to tune thresholds for every surface.
|
|
47
|
+
* The cordis patch config still wins over it, and DEFAULTS lose to both.
|
|
48
|
+
* @returns the file config, or an empty object.
|
|
49
|
+
*/
|
|
50
|
+
function loadConfigFile() {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(join(ROOT, 'config.json'), 'utf8'))
|
|
53
|
+
} catch {
|
|
54
|
+
return {}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
59
|
+
export const name = PLUGIN_ID
|
|
60
|
+
|
|
61
|
+
/** The tool registry we gate and the credential seam we resolve the key from. */
|
|
62
|
+
export const inject = ['tools', 'credentials']
|
|
63
|
+
|
|
64
|
+
/** Tool names gated by default. */
|
|
65
|
+
const DEFAULT_TOOLS = ['bash', 'pwsh']
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read the command out of a tool call, tolerating a string or object argument bag.
|
|
69
|
+
* @param exec - the pre-execute pipeline view of the call.
|
|
70
|
+
* @returns the command text, or undefined when this call carries none.
|
|
71
|
+
*/
|
|
72
|
+
function extractCommand(exec) {
|
|
73
|
+
const args = exec?.arguments
|
|
74
|
+
const pick = bag => (typeof bag?.command === 'string' ? bag.command : undefined)
|
|
75
|
+
if (args && typeof args === 'object') return pick(args)
|
|
76
|
+
if (typeof args === 'string') {
|
|
77
|
+
try {
|
|
78
|
+
return pick(JSON.parse(args))
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the TypeSafe key, highest precedence first:
|
|
88
|
+
*
|
|
89
|
+
* 1. `ctx.credentials.resolve(ref)` — DSH 自己的凭据存储(轮换后无需重启);
|
|
90
|
+
* 2. the process environment;
|
|
91
|
+
* 3. `apiKeyFile`(默认包根 `secrets.json`)—— `guard key set` 写的就是这一份。
|
|
92
|
+
*
|
|
93
|
+
* 第三层是必须的:适配器原先是**纯凭据层 + 环境变量**,而 `guard key set` 写的是文件 ——
|
|
94
|
+
* 少了这一层,"用 CLI 录入密钥"对 DSH 用户就是一句空话。现在 CLI、适配器、文档三处
|
|
95
|
+
* 共用同一条路径规则:相对路径按**包根**解析,与 cwd 无关。
|
|
96
|
+
*
|
|
97
|
+
* 值永不打印、永不进日志;下面只记"哪一层命中"。
|
|
98
|
+
*
|
|
99
|
+
* @param ctx - plugin context carrying the credential seam.
|
|
100
|
+
* @param ref - environment-variable-style reference name.
|
|
101
|
+
* @param cfg - effective config (`apiKeyFile` 决定第三层读哪个文件)。
|
|
102
|
+
* @returns the secret value, or undefined when unconfigured.
|
|
103
|
+
*/
|
|
104
|
+
async function resolveKey(ctx, ref, cfg = {}) {
|
|
105
|
+
try {
|
|
106
|
+
const resolved = await ctx.credentials?.resolve?.(ref)
|
|
107
|
+
if (resolved?.value) return resolved.value
|
|
108
|
+
} catch (error) {
|
|
109
|
+
ctx.logger?.debug?.('jev-guard: credential resolution failed for %s: %s', ref, String(error?.message ?? error))
|
|
110
|
+
}
|
|
111
|
+
if (process.env[ref]) return process.env[ref]
|
|
112
|
+
try {
|
|
113
|
+
const configured = typeof cfg.apiKeyFile === 'string' && cfg.apiKeyFile.trim() !== '' ? cfg.apiKeyFile : undefined
|
|
114
|
+
const file = configured === undefined
|
|
115
|
+
? join(ROOT, 'secrets.json')
|
|
116
|
+
: (isAbsolute(configured) ? configured : join(ROOT, configured))
|
|
117
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
118
|
+
const value = parsed[ref] ?? parsed.apiKey
|
|
119
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim()
|
|
120
|
+
} catch {
|
|
121
|
+
// 读不到就算了:预筛与 L0 仍然工作,语义层会因为 no-key 进入降级并在会话里说出来。
|
|
122
|
+
}
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The session's effective approval policy.
|
|
128
|
+
*
|
|
129
|
+
* `danger-full-access` is `{ sandbox: 'danger-full-access', approval: 'never' }`,
|
|
130
|
+
* and under `'never'` the approval service resolves EVERY ask to `rejected` — so
|
|
131
|
+
* an `ask` there becomes a denial whose reason claims "the user rejected tool
|
|
132
|
+
* bash". With `'never'` we therefore deny directly and explain why.
|
|
133
|
+
*
|
|
134
|
+
* @param ctx - plugin context.
|
|
135
|
+
* @param agent - the agent on whose behalf the call runs.
|
|
136
|
+
* @returns `'ask'` or `'never'`.
|
|
137
|
+
*/
|
|
138
|
+
function effectivePolicy(ctx, agent) {
|
|
139
|
+
try {
|
|
140
|
+
const events = agent?.session?.snapshotEvents?.() ?? []
|
|
141
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
142
|
+
const event = events[i]
|
|
143
|
+
if (event?.type === 'approval/policy' && event?.data?.policy) return event.data.policy
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// fall through to the deployment default
|
|
147
|
+
}
|
|
148
|
+
return ctx.get?.('approval')?.config?.policy ?? 'ask'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 会话当前的**权限 preset**(沙箱档位),例如 `workspace-write` / `danger-full-access`。
|
|
153
|
+
*
|
|
154
|
+
* 为什么要记它:DSH 的 preset 决定"阀门后面还有没有别的兜底"。`danger-full-access` 意味着
|
|
155
|
+
* 没有文件沙箱、审批策略也是 `never` —— 那时**这条阀门就是唯一一层**,一条判错的代价最大。
|
|
156
|
+
* 判定逻辑不需要它(阈值不该随档位偷偷变),但审计里必须有:事后回看一条高危决策时,
|
|
157
|
+
* 第一个要问的问题就是"当时后面还有没有沙箱"。这也是 DSH 专用之后才拿得到的信息。
|
|
158
|
+
*
|
|
159
|
+
* @param ctx - plugin context.
|
|
160
|
+
* @param agent - the agent on whose behalf the call runs.
|
|
161
|
+
* @returns preset 名,或 undefined(读不到就不记,不编一个)。
|
|
162
|
+
*/
|
|
163
|
+
function effectivePreset(ctx, agent) {
|
|
164
|
+
try {
|
|
165
|
+
const events = agent?.session?.snapshotEvents?.() ?? []
|
|
166
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
167
|
+
const event = events[i]
|
|
168
|
+
if (event?.type === 'permission/preset' && event?.data?.preset) return event.data.preset
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// 读不到就算了:preset 只是审计字段,不能因为它影响判定
|
|
172
|
+
}
|
|
173
|
+
return undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 造一条 DSH 会话消息(`notice` 形态)。
|
|
178
|
+
*
|
|
179
|
+
* 形状必须与 DSH 的 `UserMessage` 严格一致,而且 `source` **只带这四个键**:
|
|
180
|
+
* · 多一个键会在**下次恢复会话**时被旧格式校验判成损坏(`SessionPersistenceCorruptionError`)
|
|
181
|
+
* —— 也就是说这里写错能让用户的会话打不开;
|
|
182
|
+
* · 值为 `undefined` 的键不该出现(会破坏快照的"可移植原样"性质)—— 这是防御性规则,
|
|
183
|
+
* 本插件的四个键永远都被填上。
|
|
184
|
+
* 这两条都是**实测过**的:`tools/smoke-dsh-pipeline.mjs` 会把本函数的产物交给 DSH 自己的
|
|
185
|
+
* `snapshotJsonValue`(真实 `Session.append` 之前跑的那一步)校验一遍。
|
|
186
|
+
*
|
|
187
|
+
* `id` 用 Node 自带的 `randomUUID`:DSH 的 id 是编译期 branded string,运行时就是一个普通字符串,
|
|
188
|
+
* 所以我们不必为了这个字段去依赖它的包(本插件保持零依赖)。
|
|
189
|
+
*
|
|
190
|
+
* @param text - 正文(给模型看的完整内容)。
|
|
191
|
+
* @param summary - 折叠行的标题(≤{@link SUMMARY_MAX} 字符)。
|
|
192
|
+
* @returns 可直接追加进 pre-step 决策的消息对象。
|
|
193
|
+
*
|
|
194
|
+
* 导出是**刻意的**:形状是本插件与 DSH 之间最容易出错的一份契约(写错能让会话打不开),
|
|
195
|
+
* 所以它必须能被一个"在 DSH 自己的模块里跑"的测试直接拿去校验。
|
|
196
|
+
*/
|
|
197
|
+
export function noticeMessage(text, summary) {
|
|
198
|
+
return {
|
|
199
|
+
id: randomUUID(),
|
|
200
|
+
role: 'user',
|
|
201
|
+
content: [{ type: 'text', text }],
|
|
202
|
+
source: {
|
|
203
|
+
kind: 'plugin',
|
|
204
|
+
plugin: PLUGIN_ID,
|
|
205
|
+
form: 'notice',
|
|
206
|
+
summary: summary.length <= SUMMARY_MAX ? summary : `${summary.slice(0, SUMMARY_MAX - 1)}…`,
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 这条会话里我们已经说过哪些 notice(摘要集合)。
|
|
213
|
+
*
|
|
214
|
+
* 为什么不只在内存里记一个 `Set`:DSH 重启或会话恢复之后插件是一个全新实例,内存标记归零,
|
|
215
|
+
* 于是已经写在会话历史里的提示会被**再说一遍**。会话历史是持久的,真相在那儿 ——
|
|
216
|
+
* 扫自己发过的 notice 才是跨重启可靠的去重依据。
|
|
217
|
+
*
|
|
218
|
+
* @param agent - the agent proposing the step.
|
|
219
|
+
* @returns 已出现过的摘要集合。
|
|
220
|
+
*/
|
|
221
|
+
function announcedSummaries(agent) {
|
|
222
|
+
const seen = new Set()
|
|
223
|
+
try {
|
|
224
|
+
for (const message of agent?.session?.deriveMessages?.() ?? []) {
|
|
225
|
+
const source = message?.source
|
|
226
|
+
if (source?.kind === 'plugin' && source.plugin === PLUGIN_ID && typeof source.summary === 'string') {
|
|
227
|
+
seen.add(source.summary)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// 读不到历史就当"没说过":宁可再说一遍,也不能漏掉"没有密钥"这条要求。
|
|
232
|
+
}
|
|
233
|
+
return seen
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 现在该不该说点什么(且还没说过)。
|
|
238
|
+
*
|
|
239
|
+
* 四种情形:① 本地可见的降级态是"没有密钥" → 要求录入;② 其它降级态 → 说明降级;
|
|
240
|
+
* ③ 会话第一步且解析不到密钥(状态文件还没写) → 同样要求录入;④ 说过降级而现在已经恢复 → 宣布恢复。
|
|
241
|
+
* 每种情形一句话、每个会话一次;成对出现(降级 → 恢复),不刷屏。
|
|
242
|
+
*
|
|
243
|
+
* @param ctx - plugin context.
|
|
244
|
+
* @param cfg - effective config.
|
|
245
|
+
* @param payload - the `agent/pre-step` payload.
|
|
246
|
+
* @returns `{ text, summary }`,或 null(无话可说 / 已经说过)。
|
|
247
|
+
*/
|
|
248
|
+
async function pendingNotice(ctx, cfg, payload) {
|
|
249
|
+
const seen = announcedSummaries(payload?.agent)
|
|
250
|
+
const policy = t(cfg.degradePolicy === 'off' ? 'quota.policy.off' : 'quota.policy.l0-only')
|
|
251
|
+
const noKeySummary = t('notice.no-key.summary')
|
|
252
|
+
const recoveredSummary = t('notice.recovered.summary')
|
|
253
|
+
// 可枚举的降级摘要:只有这样才不用往消息里塞"机器标记"就能认出自己说过哪种状态。
|
|
254
|
+
const degradedSummaries = new Set(
|
|
255
|
+
DEGRADING_KINDS.map(kind => t('notice.degraded.summary', { label: kindLabel(kind), policy })),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
const state = await readDegraded(cfg)
|
|
259
|
+
// 只认**本入口**该遵守的状态:CLI 写下的 no-key 不该在 DSH 会话里喊(反之亦然)。
|
|
260
|
+
const active = state !== null && appliesTo(state, cfg.scope) ? state : null
|
|
261
|
+
|
|
262
|
+
// 状态文件还没写时的"首次没有密钥":只在会话第一步解析一次密钥,免得每一步都去问凭据层。
|
|
263
|
+
// 会话中途失去密钥走另一条路 —— 那次受管命令判成 no-key 会写下状态,下一步就有人说话了。
|
|
264
|
+
let missingKey = false
|
|
265
|
+
if (active === null && payload?.turn === 1 && payload?.step === 1) {
|
|
266
|
+
missingKey = (await resolveKey(ctx, cfg.apiKeyEnv, cfg)) === undefined
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let summary = null
|
|
270
|
+
if (active !== null && active.kind === 'no-key') summary = noKeySummary
|
|
271
|
+
else if (active !== null) summary = t('notice.degraded.summary', { label: kindLabel(active.kind), policy })
|
|
272
|
+
else if (missingKey) summary = noKeySummary
|
|
273
|
+
else if (!seen.has(recoveredSummary) && [...seen].some(s => s === noKeySummary || degradedSummaries.has(s))) {
|
|
274
|
+
// 只有这条会话确实说过"降级 / 没有密钥"才宣布恢复,否则每个健康会话开场都要多一句废话。
|
|
275
|
+
summary = recoveredSummary
|
|
276
|
+
}
|
|
277
|
+
if (summary === null || seen.has(summary)) return null
|
|
278
|
+
|
|
279
|
+
if (summary === noKeySummary) {
|
|
280
|
+
return { summary, text: `${summary}\n\n${t('notice.no-key.body', { cli: CLI_PATH })}` }
|
|
281
|
+
}
|
|
282
|
+
if (summary === recoveredSummary) {
|
|
283
|
+
return { summary, text: `${summary}\n\n${t('notice.recovered.body')}` }
|
|
284
|
+
}
|
|
285
|
+
// 恢复方式这句话与 `guard status` 共用同一条文案(粘性态没有倒计时,见 lib/quota.js)。
|
|
286
|
+
const recovery = active !== null && isSticky(active)
|
|
287
|
+
? t('quota.status.degraded.sticky').trim()
|
|
288
|
+
: t('quota.status.degraded.recovery', {
|
|
289
|
+
mins: Math.round(Math.max(0, Number(active?.until ?? 0) - Date.now()) / 60000),
|
|
290
|
+
})
|
|
291
|
+
return {
|
|
292
|
+
summary,
|
|
293
|
+
text: `${summary}\n\n${t('notice.degraded.body', { label: kindLabel(active.kind), recovery, policy, cli: CLI_PATH })}`,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Register the guard.
|
|
299
|
+
* @param ctx - plugin context.
|
|
300
|
+
* @param config - optional overrides; every field falls back to {@link DEFAULTS}.
|
|
301
|
+
*/
|
|
302
|
+
export function apply(ctx, config = {}) {
|
|
303
|
+
const cfg = {
|
|
304
|
+
...DEFAULTS,
|
|
305
|
+
tools: DEFAULT_TOOLS,
|
|
306
|
+
apiKeyEnv: 'TYPESAFE_API_KEY',
|
|
307
|
+
retryLimit: 2,
|
|
308
|
+
...loadConfigFile(),
|
|
309
|
+
...config,
|
|
310
|
+
// 入口身份由代码定死:配置文件把它改乱,作用域隔离就失效了(见 ADAPTER_SCOPE)。
|
|
311
|
+
scope: ADAPTER_SCOPE,
|
|
312
|
+
}
|
|
313
|
+
// 语言在这里定一次:拒绝理由 / 弹窗正文 / 一次性令牌提示都是人读的文案。
|
|
314
|
+
// `lang: 'auto'`(默认)按 JEV_GUARD_LANG → locale 环境变量 → 系统 locale → zh-CN 解析。
|
|
315
|
+
// 注意它**不影响**发给 Jev 的那句问话 —— 那是 promptLang,默认仍是标定用的中文。
|
|
316
|
+
setLang(cfg.lang)
|
|
317
|
+
|
|
318
|
+
const cache = new (class {
|
|
319
|
+
constructor(limit) {
|
|
320
|
+
this.limit = limit
|
|
321
|
+
this.map = new Map()
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
get(k) {
|
|
325
|
+
const v = this.map.get(k)
|
|
326
|
+
if (v === undefined) return undefined
|
|
327
|
+
this.map.delete(k)
|
|
328
|
+
this.map.set(k, v)
|
|
329
|
+
return v
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
set(k, v) {
|
|
333
|
+
this.map.set(k, v)
|
|
334
|
+
while (this.map.size > this.limit) this.map.delete(this.map.keys().next().value)
|
|
335
|
+
}
|
|
336
|
+
})(cfg.cacheSize)
|
|
337
|
+
const budget = new RetryBudget(cfg.retryLimit)
|
|
338
|
+
const stats = { allowed: 0, revised: 0, blocked: 0, escalated: 0, prefilters: 0, cacheHits: 0, ruleHits: 0, errors: 0, degraded: 0 }
|
|
339
|
+
/** 已吼过的降级窗口(= kind + until),避免每个命令刷一遍屏。 */
|
|
340
|
+
let lastDegradedKey = ''
|
|
341
|
+
|
|
342
|
+
ctx.logger?.info?.(
|
|
343
|
+
'jev-guard: gating %s (low=%s high=%s timeout=%sms key=%s lang=%s promptLang=%s)',
|
|
344
|
+
cfg.tools.join(','), cfg.lowThreshold, cfg.highThreshold, cfg.timeoutMs, cfg.apiKeyEnv,
|
|
345
|
+
getLang(), cfg.promptLang,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
349
|
+
try {
|
|
350
|
+
if (!cfg.tools.includes(exec.name)) return next()
|
|
351
|
+
const command = extractCommand(exec)
|
|
352
|
+
if (command === undefined) return next()
|
|
353
|
+
|
|
354
|
+
const apiKey = await resolveKey(ctx, cfg.apiKeyEnv, cfg)
|
|
355
|
+
const policy = effectivePolicy(ctx, exec.agent)
|
|
356
|
+
// 沙箱档位:只进审计,不参与判定(阈值不随档位偷偷变;见 effectivePreset 的说明)。
|
|
357
|
+
const preset = effectivePreset(ctx, exec.agent)
|
|
358
|
+
const cwd = typeof exec.arguments?.workdir === 'string'
|
|
359
|
+
? exec.arguments.workdir
|
|
360
|
+
: (exec.agent?.session?.cwd ?? process.cwd())
|
|
361
|
+
const verdict = await evaluateCommand(command, { ...cfg, cwd, cache, apiKey, signal: exec.signal })
|
|
362
|
+
|
|
363
|
+
if (verdict.source === 'prefilter') stats.prefilters += 1
|
|
364
|
+
if (verdict.source === 'cache') stats.cacheHits += 1
|
|
365
|
+
if (verdict.source === 'static-rule') stats.ruleHits += 1
|
|
366
|
+
if (verdict.source === 'degraded') stats.degraded += 1
|
|
367
|
+
if (verdict.source === 'error') {
|
|
368
|
+
stats.errors += 1
|
|
369
|
+
ctx.logger?.warn?.('jev-guard: fail-open after %s (%sms): %s', verdict.error, verdict.ms, command.slice(0, 160))
|
|
370
|
+
}
|
|
371
|
+
// 降级告警:额度/密钥出问题时**必须有人能发现**。旧行为是静默 fail-open,
|
|
372
|
+
// 日志里一堆 error 但没人看得出"它已经不在防护了"。这里三件事一起做:
|
|
373
|
+
// ① host 日志一条 warn(尽力而为);② 审计里留一条 level:'warn' 的记录(可靠);
|
|
374
|
+
// ③ 非 allow 判定的理由里已经带了同一句(见 lib/verdict.js 的 warn)。
|
|
375
|
+
// 每个降级窗口最多吼一次,免得刷屏;人可以用 `guard status` 看全貌。
|
|
376
|
+
const degradedKey = verdict.degraded ? `${verdict.degraded.kind}:${verdict.degraded.until}` : ''
|
|
377
|
+
if (degradedKey && degradedKey !== lastDegradedKey) {
|
|
378
|
+
lastDegradedKey = degradedKey
|
|
379
|
+
ctx.logger?.warn?.('jev-guard: DEGRADED %s', verdict.warning)
|
|
380
|
+
void record({
|
|
381
|
+
level: 'warn', tool: exec.name, source: verdict.source, errorKind: verdict.errorKind,
|
|
382
|
+
degraded: verdict.degraded, warning: verdict.warning, cwd, command,
|
|
383
|
+
session: exec.agent?.session?.id,
|
|
384
|
+
}, cfg)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const sessionKey = `${exec.agent?.session?.id ?? 'no-session'}:${fingerprint(command)}`
|
|
388
|
+
let effective = verdict
|
|
389
|
+
|
|
390
|
+
if (verdict.action === 'allow') {
|
|
391
|
+
stats.allowed += 1
|
|
392
|
+
budget.clear(sessionKey)
|
|
393
|
+
ctx.logger?.debug?.('jev-guard: allow via %s (p=%s, %sms)', verdict.source, verdict.p, verdict.ms)
|
|
394
|
+
// 审计:DSH 的 logger 会过滤 info 级,所以真实凭据落在 guard.log 里。
|
|
395
|
+
void record({
|
|
396
|
+
tool: exec.name, action: 'allow', decision: 'allow', source: verdict.source,
|
|
397
|
+
p: verdict.p, model: verdict.model, ms: verdict.ms, rule: verdict.rule?.id,
|
|
398
|
+
enriched: verdict.enriched, cwd, error: verdict.error, errorKind: verdict.errorKind,
|
|
399
|
+
policy, preset,
|
|
400
|
+
degraded: verdict.degraded, usage: verdict.usage, probe: verdict.probe,
|
|
401
|
+
recovered: verdict.recovered, command,
|
|
402
|
+
overridden: verdict.overridden, token: verdict.token,
|
|
403
|
+
session: exec.agent?.session?.id,
|
|
404
|
+
}, cfg)
|
|
405
|
+
return next()
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Blocked or merely uncertain: count attempts so a model that keeps
|
|
409
|
+
// rephrasing the same destruction eventually reaches a human instead of
|
|
410
|
+
// looping forever.
|
|
411
|
+
const { attempts, exhausted } = budget.hit(sessionKey)
|
|
412
|
+
// 例外:带 L0 `deny` 规则的硬命中**不能**借重试预算转人工 —— 那会让弹窗里的"允许"
|
|
413
|
+
// 越过硬地板(令牌不能越过 L0,审批同样不能,见 D5/D13)。硬命中照旧记 attempts 供审计。
|
|
414
|
+
const hardRule = verdict.rule?.kind === 'deny'
|
|
415
|
+
if (exhausted && verdict.action !== 'escalate' && hardRule !== true) {
|
|
416
|
+
effective = { ...verdict, action: 'escalate', source: `${verdict.source}+retry-budget` }
|
|
417
|
+
}
|
|
418
|
+
if (effective.action === 'revise') stats.revised += 1
|
|
419
|
+
else if (effective.action === 'block') stats.blocked += 1
|
|
420
|
+
else stats.escalated += 1
|
|
421
|
+
|
|
422
|
+
ctx.logger?.info?.(
|
|
423
|
+
'jev-guard: %s command (policy=%s, p=%s, attempts=%d, %sms, enriched=%s) %s',
|
|
424
|
+
effective.action, policy, verdict.p, attempts, verdict.ms,
|
|
425
|
+
(verdict.enriched ?? []).join('+') || 'none', command.slice(0, 160),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
const decision = toHostDecision(command, effective, policy, {
|
|
429
|
+
token: verdict.token,
|
|
430
|
+
cliPath: CLI_PATH,
|
|
431
|
+
reviseInAskMode: cfg.reviseInAskMode,
|
|
432
|
+
blockInAskMode: cfg.blockInAskMode,
|
|
433
|
+
})
|
|
434
|
+
// 审计:被拦/被问/升级都要留痕(含命中规则与重试次数)。decision.kind 已经能区分
|
|
435
|
+
// "转人工(ask)"与"直接拒(deny)" —— 同一条 revise 在两种审批模式下会写在这里不同的值。
|
|
436
|
+
void record({
|
|
437
|
+
tool: exec.name, action: effective.action, decision: decision.kind, source: effective.source,
|
|
438
|
+
p: verdict.p, model: verdict.model, ms: verdict.ms, rule: verdict.rule?.id,
|
|
439
|
+
enriched: verdict.enriched, attempts, policy, preset, cwd, command,
|
|
440
|
+
errorKind: verdict.errorKind, degraded: verdict.degraded, warning: verdict.warning,
|
|
441
|
+
usage: verdict.usage, probe: verdict.probe,
|
|
442
|
+
overridden: verdict.overridden, token: verdict.token,
|
|
443
|
+
session: exec.agent?.session?.id,
|
|
444
|
+
}, cfg)
|
|
445
|
+
// revise 的两条出路都带上三种降级模板:转人工时它是**弹窗正文**(让做决定的人看清
|
|
446
|
+
// 还能怎么改),被直接拒时它是给模型的教案。routedToHuman 决定开头那几句怎么说。
|
|
447
|
+
if (effective.action === 'revise') {
|
|
448
|
+
return {
|
|
449
|
+
...decision,
|
|
450
|
+
reason: reviseGuidance(command, effective, {
|
|
451
|
+
policy, token: verdict.token, cliPath: CLI_PATH, routedToHuman: decision.kind === 'ask',
|
|
452
|
+
}),
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return decision
|
|
456
|
+
} catch (error) {
|
|
457
|
+
// Never let the valve break a turn: an unexpected failure delegates to the
|
|
458
|
+
// normal pipeline, where the sandbox and approval policy still apply.
|
|
459
|
+
stats.errors += 1
|
|
460
|
+
ctx.logger?.warn?.('jev-guard: unexpected failure, delegating: %s', String(error?.stack ?? error))
|
|
461
|
+
void record({ action: 'allow', decision: 'allow', source: 'adapter-error', error: String(error?.message ?? error), cwd: process.cwd() }, cfg)
|
|
462
|
+
return next()
|
|
463
|
+
}
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
// ── 会话内提示(纯 host 插件唯一能让用户真看到的渠道;见 docs/DECISIONS.md D15)──────────
|
|
467
|
+
//
|
|
468
|
+
// 为什么用 `agent/pre-step`:DSH 的 Settings / Plugins 页都由浏览器侧(`dsh.client`)注册占位,
|
|
469
|
+
// 纯 host 插件**没有任何** toast / banner / 启动提示接口;而注入一条 `notice` 消息会渲染成对话
|
|
470
|
+
// 里的一行(折叠标题 = summary,展开是正文)、**写进会话历史**、并且进入模型上下文 —— 于是
|
|
471
|
+
// "这个阀门现在是瞎的"既被人看见,也被模型知道。先例是同为 host-only 的
|
|
472
|
+
// `packages/guard/repeat-tool-reminder` 与 `packages/core/agent/src/model-selection.ts`。
|
|
473
|
+
if (cfg.notifyInSession !== false) {
|
|
474
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
475
|
+
const decision = await next()
|
|
476
|
+
try {
|
|
477
|
+
// 两条纪律,违反任何一条都会伤害宿主:
|
|
478
|
+
// · 必须 `await next()` 之后再**追加** —— 决策里的 `messages` 是**替换**整个批次,
|
|
479
|
+
// 直接返回自己的数组会把用户这条消息吞掉;
|
|
480
|
+
// · 空批次不要塞消息 —— 那会让循环白白多跑一次模型请求(DSH 自己的 model-selection
|
|
481
|
+
// 用的就是这条守卫)。
|
|
482
|
+
if (decision?.kind !== 'enter' || payload?.signal?.aborted) return decision
|
|
483
|
+
if (decision.messages.length === 0 && (payload.step === 1 || payload.messages?.length > 0)) return decision
|
|
484
|
+
const notice = await pendingNotice(ctx, cfg, payload)
|
|
485
|
+
if (notice === null) return decision
|
|
486
|
+
ctx.logger?.debug?.('jev-guard: in-session notice (%s)', notice.summary)
|
|
487
|
+
return { ...decision, messages: [...decision.messages, noticeMessage(notice.text, notice.summary)] }
|
|
488
|
+
} catch (error) {
|
|
489
|
+
// 这个监听器抛错会让**整次提案失败**(DSH 的行为),所以必须自己兜住:
|
|
490
|
+
// 说不上话是小事,把用户的一次对话搞坏是大事。
|
|
491
|
+
ctx.logger?.debug?.('jev-guard: notice skipped: %s', String(error?.message ?? error))
|
|
492
|
+
return decision
|
|
493
|
+
}
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
ctx.on('dispose', () => {
|
|
498
|
+
ctx.logger?.info?.('jev-guard: stopped (%j)', stats)
|
|
499
|
+
// 退出路径上等一次审计写入,避免进程结束时丢尾部记录。
|
|
500
|
+
void flush()
|
|
501
|
+
})
|
|
502
|
+
}
|