dsh-escalation-review 0.2.0

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.

Potentially problematic release.


This version of dsh-escalation-review might be problematic. Click here for more details.

package/lib/index.js ADDED
@@ -0,0 +1,543 @@
1
+ /**
2
+ * escalation-review —— 只审「沙箱越界」的 LLM reviewer
3
+ *
4
+ * 目标形状:
5
+ * - 沙箱照旧(workspace-write),**只有需要越界的调用**才审 → 项目内操作零额外模型调用
6
+ * - 不记忆授权:每次越界重审("用户说过允许"靠上下文证据命中,不靠缓存)
7
+ * - 评审失败/超时 → 默认**直接拒绝**(fail-closed),文案与「政策拒绝」可区分
8
+ * - 拒绝理由对模型可见(info.reason),可据此决定绕过 / 重试 / 停下来问人
9
+ * - **控制面在配置页里**(改动自动保存,落 profile 的 `cordis.patch.yml`),文件层作兜底;
10
+ * 每次越界时重读生效配置,改完约 2 秒生效、不用重启
11
+ *
12
+ * 架构参考:
13
+ * - DSH `@deepseek-ai/dsh-experimental-auto-review`:`tools/pre-execute` 返回三态决策对象
14
+ * (deny / ask / 放行)、严格 JSON 协议、`BlockAssembler` 消费 LLM 流、失败即拒
15
+ * - Codex `codex-rs/core/src/guardian/`:只挂在 approval 路径上、完整 transcript(声明为
16
+ * 不可信证据)+「保留的用户指令」授权段 + 护栏(截断的证据 ≠ 完整授权)
17
+ *
18
+ * 实现要点:
19
+ * - 触发条件与 `dsh-tool-bash` 完全对齐:`args.sandbox_permissions` 存在且 ≠ 生效模式
20
+ * - 放行时把 (session, callId) 记入内存表,由同插件的 `approval/request` answerer 消费,
21
+ * 使工具自身的 `approveEscalation` 直接拿到 `allowed-once` —— 用户不会被二次询问
22
+ * - 拒绝时**在工具体执行前**返回决策对象,因此文案完全由本插件控制
23
+ * - 模块划分(见各自文件头):`config`(配置控制面)/ `policy`(证据塑形与决策解析,纯函数)/
24
+ * `reviewer`(LLM 流水线,含 BlockAssembler 懒加载)/ `config-schema`(DSH 的 `Config`)/
25
+ * `client`(配置页与 provider/model 选择器)/ `selftest` / `dsh-packages`(link 安装的模块解析)
26
+ * - 本文件只负责控制流:钩子接线、介入开关门控、审批记账、日志、以及对外导出的公共面
27
+ * - **自带加载链诊断**:module-imported / apply-called / services-ready / ready 四道痕迹
28
+ * 写在 $DSH_HOME/escalation-review.log,用来定位"到底哪一环没跑到"
29
+ */
30
+
31
+ import { appendFileSync } from 'node:fs'
32
+ import { homedir } from 'node:os'
33
+ import { join } from 'node:path'
34
+ import { importDshPackage, importSchema } from './dsh-packages.js'
35
+ import { buildSchema } from './config-schema.js'
36
+ import { loadSelfTest } from './selftest-loader.js'
37
+ import { BOOT_LOG, DEFAULT_CONFIG, PACKAGE_DIR, parseConfigText, resolveConfig, sanitize } from './config.js'
38
+ import { assemblerSourceName, review, setAssemblerOverride } from './reviewer.js'
39
+ import { buildPolicy, buildSnapshot, extractJsonText, isEscalation, parseDecision, readDecision, renderSnapshot, safeCall, textOf, truncate } from './policy.js'
40
+ import { recordDenial, recordNonDenial } from './breaker.js'
41
+ import { createProjectionDefinition, createVerdictStore } from './projection.js'
42
+
43
+ // 判定仓库:进程内按 callId 索引,供客户端投影读取(放行那次的理由没有事件通道,只能这样送)
44
+ const verdictStore = createVerdictStore()
45
+ import { sessionKeyOf, turnIdOf } from './session-key.js'
46
+ import { circuitBreakerAction, reviewFailureAction, reviewRejectedAction } from './decisions.js'
47
+
48
+
49
+ // 公共面保持稳定:调用方从 index.js 取这些纯函数
50
+ export { buildPolicy, buildSnapshot, extractJsonText, isEscalation, parseDecision, readDecision, renderSnapshot, textOf, truncate }
51
+
52
+ // 上一次写进日志的"生效 mode":ready 行给的是 apply 时刻的兜底值(实测误导),
53
+ // 所以在真正拿到调用期配置时如实报告
54
+ let loggedEffectiveMode = null
55
+
56
+ export const name = 'escalation-review'
57
+
58
+ // 公共面保持稳定:调用方从 index.js 取这两个纯函数
59
+ export { parseConfigText, resolveConfig }
60
+
61
+ /**
62
+ * 导出一个 schemastery `Config` —— 这是 0.1.7 里"让插件出现在设置/插件页并有表单"的**唯一正道**:
63
+ * 运行时的 settings 服务是 `SettingsForms`,它的类注释就写着 "Project `Config` schemas into forms";
64
+ * `SettingsForms.schema(entry)` 的真实取法是 `entry.fiber.runtime.Config`,
65
+ * 而 cordis 里 `runtime = { …, Config: plugin.Config }`。
66
+ * 表单写回的是该条目的 `config` → 会作为 `apply(ctx, config)` 的第二参传回来。
67
+ *
68
+ * ⚠️ 关键(2026-09-27 实测定位):**必须用异步 `import()` 取 schemastery,不能用 `require()`** ——
69
+ * app 跑在 Electron 主进程里,`require(ESM)` 会失败 ✗(异步 import 才是**已验证可行**的路径:
70
+ * 宿主装配器就是这么加载 `@deepseek-ai/dsh-llm` 的 ✓)。这里用**顶层 await**:
71
+ * loader 是 `await import(插件模块)` 加载的,TLA 安全 ✓。
72
+ * 之前用 `require()` 的版本在 app 里静默拿到 `undefined` → 命名空间不被服务 → 配置页永远不出现。
73
+ */
74
+ let schemaBuilder
75
+ // 投影的 wire.viewSchema 必须是 **zod**(官方 ZodType),不是 schemastery
76
+ let zodBuilder
77
+ try {
78
+ const loaded = await importDshPackage('zod')
79
+ zodBuilder = loaded?.z ?? loaded?.default?.z ?? loaded
80
+ } catch (error) {
81
+ zodBuilder = undefined
82
+ // 拿不到 zod 就不注册投影(历史优先,见下面的自检)
83
+ }
84
+ try {
85
+ schemaBuilder = await importSchema()
86
+ } catch (error) {
87
+ // 此刻 bootLog 依赖的模块级常量还没初始化(TDZ)→ 直接裸写日志文件,避免"错误被吞掉"
88
+ try {
89
+ const home =
90
+ typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.length > 0 ? process.env.DSH_HOME : join(homedir(), '.dsh')
91
+ appendFileSync(
92
+ join(home, 'escalation-review.log'),
93
+ `${JSON.stringify({ ts: new Date().toISOString(), event: 'config-schema-unavailable', message: String(error?.message ?? error), attempts: error?.attempts })}\n`,
94
+ )
95
+ } catch {
96
+ /* 日志写不了也不能影响插件 */
97
+ }
98
+ }
99
+
100
+ export const Config = schemaBuilder === undefined ? undefined : buildSchema(schemaBuilder)
101
+
102
+ // 不用声明式 inject 门控:某个服务缺失时 apply 会静默不跑(最难排查的失败模式)。
103
+ // 改成在 apply 内部动态注入,这样至少能留下痕迹。
104
+ export const inject = []
105
+
106
+ const DENIED_NAME = 'EscalationReviewDeniedError'
107
+ const DENIED_CODE = 'ESCALATION_REVIEW_DENIED'
108
+
109
+ function bootLog(event, detail = {}) {
110
+ try {
111
+ appendFileSync(BOOT_LOG, `${JSON.stringify({ ts: new Date().toISOString(), event, pid: process.pid, ...detail })}\n`)
112
+ } catch {
113
+ /* 诊断痕迹写不进去也不能影响宿主 */
114
+ }
115
+ }
116
+
117
+ bootLog('module-imported', { node: process.version, dir: PACKAGE_DIR })
118
+
119
+ // Config 自检:放在这里(而不是 Config 定义处)是因为那一刻 bootLog 依赖的常量还没初始化(TDZ)。
120
+ // 这一行 + `settings-probe.mine` 就能判定"命名空间有没有被服务"。
121
+ bootLog('config-export-check', {
122
+ type: typeof Config,
123
+ fields: Config !== undefined && Config !== null && Config.dict !== undefined ? Object.keys(Config.dict).length : null,
124
+ })
125
+
126
+
127
+ // ─────────────────────────────────────────────────────────────── 运行时部分
128
+
129
+ function effectiveMode(ctx, exec) {
130
+ const resolved = safeCall(() => ctx.get?.('sandboxPolicy')?.resolve?.({ session: exec?.agent?.session }))
131
+ const mode = resolved?.mode
132
+ return typeof mode === 'string' ? mode : undefined
133
+ }
134
+
135
+ function rememberApproval(store, session, callId) {
136
+ if (session === undefined || session === null) return
137
+ let bucket = store.get(session)
138
+ if (bucket === undefined) {
139
+ bucket = []
140
+ store.set(session, bucket)
141
+ }
142
+ bucket.push({ callId, at: Date.now() })
143
+ if (bucket.length > 32) bucket.splice(0, bucket.length - 32)
144
+ }
145
+
146
+ function consumeApproval(store, session, callId, windowMs) {
147
+ if (session === undefined || session === null) return false
148
+ const bucket = store.get(session)
149
+ if (bucket === undefined || bucket.length === 0) return false
150
+ const now = Date.now()
151
+ for (let i = bucket.length - 1; i >= 0; i -= 1) {
152
+ const entry = bucket[i]
153
+ if (now - entry.at > windowMs) continue
154
+ if (callId !== undefined && entry.callId !== undefined && entry.callId !== callId) continue
155
+ bucket.splice(i, 1)
156
+ return true
157
+ }
158
+ return false
159
+ }
160
+
161
+
162
+
163
+ function makeLog(ctx) {
164
+ let live = { ...DEFAULT_CONFIG, logPath: BOOT_LOG }
165
+ return {
166
+ setConfig(cfg) {
167
+ live = cfg
168
+ },
169
+ write(event, detail = {}) {
170
+ const line = { ts: new Date().toISOString(), event, mode: live.mode, ...detail }
171
+ try {
172
+ appendFileSync(live.logPath, `${JSON.stringify(line)}\n`)
173
+ } catch {
174
+ /* 记录失败不影响判定 */
175
+ }
176
+ try {
177
+ ctx?.logger?.info?.('[escalation-review]', event, detail)
178
+ } catch {
179
+ /* 宿主无 logger 就算了 */
180
+ }
181
+ },
182
+ }
183
+ }
184
+
185
+ let selfTestStarted = false
186
+ let schemaBuilderOverride // 诊断用:注入替代的 schemastery
187
+
188
+ export function apply(ctx, config) {
189
+ const base = config ?? {}
190
+ bootLog('apply-called', { configKeys: Object.keys(base) })
191
+ const log = makeLog(ctx)
192
+
193
+ // 文件层:每次越界重读(loadedConfigFiles / configErrors 只存在于这一层)
194
+ const fromFiles = () => resolveConfig(base)
195
+
196
+ /**
197
+ * Host 设置的「用户覆盖层」= 配置页保存的那一层。
198
+ *
199
+ * ⚠️ 为什么不直接用 `apply(ctx, config)`:实测(2026-09-27)**profile patch 里同 id 的
200
+ * `config:` 不会被合并进"由 bundle patch 插入"的那个条目** —— 保存成功了(patch 里能看到
201
+ * `mode: enforce`),但重启后 apply 拿到的 config 仍是 schema 默认值。
202
+ * 所以这里直接读 `settings.describe()` 里该命名空间的 **user 层**(表单写的就是它),
203
+ * 定时刷新(2s)→ 保存后即时生效,**无需重启**。
204
+ */
205
+ let hostUser = {}
206
+ let hostUserReady = false
207
+ const refreshHostUser = () => {
208
+ try {
209
+ const settings = typeof ctx.get === 'function' ? ctx.get('settings') : undefined
210
+ if (settings === undefined || typeof settings.describe !== 'function') return
211
+ Promise.resolve(settings.describe())
212
+ .then((value) => {
213
+ const list = Array.isArray(value) ? value : (value?.namespaces ?? [])
214
+ const descriptor = Array.isArray(list)
215
+ ? list.find((item) => (item?.ns ?? item?.namespace ?? item?.id) === 'escalation-review')
216
+ : undefined
217
+ hostUser = sanitize(descriptor?.user ?? {})
218
+ if (!hostUserReady) {
219
+ hostUserReady = true
220
+ log.write('host-user-loaded', {
221
+ keys: Object.keys(hostUser),
222
+ mode: hostUser.mode ?? null,
223
+ descriptorKeys: descriptor === undefined ? null : Object.keys(descriptor),
224
+ })
225
+ }
226
+ })
227
+ .catch(() => {
228
+ /* 读不到就沿用文件层 */
229
+ })
230
+ } catch {
231
+ /* 读不到就沿用文件层 */
232
+ }
233
+ }
234
+ refreshHostUser()
235
+ const hostTimer = setInterval(refreshHostUser, 2000)
236
+ if (typeof hostTimer?.unref === 'function') hostTimer.unref()
237
+
238
+ // 生效配置 = 文件层 ← Host 用户覆盖层(最高优先级,且保存后 ~2s 内自动生效)。
239
+ // 归一化 logPath:空字符串会把默认值覆盖成空 → 日志静默写不进去(踩过)。
240
+ const effective = () => {
241
+ const merged = { ...fromFiles(), ...hostUser }
242
+ if (typeof merged.logPath !== 'string' || merged.logPath.length === 0) merged.logPath = BOOT_LOG
243
+ return merged
244
+ }
245
+
246
+ let live
247
+ try {
248
+ live = effective()
249
+ } catch (error) {
250
+ bootLog('config-failed', { message: String(error?.message ?? error) })
251
+ live = { ...DEFAULT_CONFIG, logPath: BOOT_LOG }
252
+ }
253
+ log.setConfig(live)
254
+ const approvals = new WeakMap()
255
+ const refresh = () => {
256
+ live = effective()
257
+ log.setConfig(live)
258
+ return live
259
+ }
260
+
261
+ const SERVICES = ['approval', 'llm', 'sessions', 'tools']
262
+ // 兼容起见:ctx.inject 不存在时直接注册(宿主夹具与旧版 cordis 都能跑)
263
+ const inject = typeof ctx.inject === 'function' ? ctx.inject.bind(ctx) : (_deps, cb) => cb(ctx)
264
+
265
+ /**
266
+ * 介入开关(2026-09-28 起):**不再依赖会话预设**,改由插件自己的配置项 `enabled` 决定,
267
+ * 因此与 profile / 权限预设表完全解耦 —— 打开后就接管**所有**沙箱越界的审批。
268
+ * 默认 false(零介入);`mode: observe` 只评审不动手,`mode: enforce` 才替用户答审批。
269
+ */
270
+ // 历史评审环形缓冲(每会话最多 8 条,照 Codex MAX_PREVIOUS_REVIEWS):让同类动作判例一致;
271
+ // 带上 turnId(= 用户消息版本),便于判定"是否早于当前指令"
272
+ const RECENT_REVIEW_LIMIT = 8
273
+ const recentReviews = new Map()
274
+ const recordReview = (sessionKey, turnId, entry) => {
275
+ try {
276
+ const bucket = recentReviews.get(sessionKey) ?? []
277
+ bucket.push({ turnId, ...entry })
278
+ if (bucket.length > RECENT_REVIEW_LIMIT) bucket.splice(0, bucket.length - RECENT_REVIEW_LIMIT)
279
+ recentReviews.set(sessionKey, bucket)
280
+ } catch {
281
+ /* 记录失败不影响评审 */
282
+ }
283
+ }
284
+ try {
285
+ inject(SERVICES, (scoped) => {
286
+ bootLog('services-ready', { services: SERVICES })
287
+ log.write('services-ready', { hasEffect: typeof scoped?.effect, hasOn: typeof scoped?.on })
288
+ scoped.effect(function* run() {
289
+ log.write('effect-started')
290
+ // 客户端可见的投影:域侧保留判定与理由,客户端读 projectionValues['escalation-review']。
291
+ // 用作用域注入等 sessionProjections(可选服务;声明式注入会让 entry 挂住)。
292
+ try {
293
+ if (zodBuilder !== undefined && zodBuilder !== null && typeof zodBuilder.record === 'function') {
294
+ scoped.inject(['sessionProjections'], (projectionScope) => {
295
+ try {
296
+ // createProjectionDefinition 内部会自检 viewSchema.parse 是函数,不满足就抛 → 不注册
297
+ projectionScope.effect(
298
+ () => projectionScope.sessionProjections.register(createProjectionDefinition({ z: zodBuilder, store: verdictStore })),
299
+ 'escalation-review: review verdict projection',
300
+ )
301
+ log.write('projection-registered', { key: 'escalation-review', schema: 'zod' })
302
+ } catch (error) {
303
+ log.write('projection-register-failed', { message: String(error?.message ?? error) })
304
+ }
305
+ })
306
+ } else {
307
+ log.write('projection-skipped', { note: 'zod unavailable' })
308
+ }
309
+ } catch (error) {
310
+ log.write('projection-register-failed', { message: String(error?.message ?? error) })
311
+ }
312
+
313
+ log.write('intervention-gate', { enabled: live.enabled === true, mode: live.mode, note: 'apply-time snapshot; the effective gate is read per call' })
314
+ log.write('ready', {
315
+ mode: live.mode,
316
+ timeoutMs: live.timeoutMs,
317
+ failMode: live.failMode,
318
+ denyMode: live.denyMode,
319
+ hosts: live.allowedHosts.length,
320
+ logPath: live.logPath,
321
+ assemblerSource: assemblerSourceName(),
322
+ configFiles: live.loadedConfigFiles,
323
+ configErrors: live.configErrors,
324
+ })
325
+
326
+ // 配置表单:宿主根据上面导出的 `Config` 自动投影;客户端半边(lib/client.js)用
327
+ // `ctx.configForms.whileServed(['escalation-review'])` 注册页面 —— 前提是 Host 真的把
328
+ // 这个条目当作"被服务的设置命名空间"暴露出来。下面这条探针只为把这件事变成可见的日志。
329
+
330
+
331
+ // 1) 越界调用:评审 → 放行 / 拒绝(工具体不会执行)
332
+ yield scoped.on(
333
+ 'tools/pre-execute',
334
+ async (exec, next) => {
335
+ const cfg = refresh()
336
+ // 生效配置可追溯(2026-09-28):ready 行的 mode 是 apply 时刻的兜底值,别拿它当真相
337
+ if (loggedEffectiveMode !== cfg.mode) {
338
+ log.write('config-effective', { from: loggedEffectiveMode, to: cfg.mode, note: 'call-time read (settings layer)' })
339
+ loggedEffectiveMode = cfg.mode
340
+ }
341
+ const args = exec?.arguments
342
+ const effective = effectiveMode(scoped, exec)
343
+ if (!isEscalation(args, effective)) return next()
344
+ // 介入判定只由插件自己的配置开关(enabled)决定,与权限预设完全解耦:
345
+ // 关闭时 = 零介入 + 零模型调用;打开后接管**所有**沙箱越界的审批。
346
+ // 介入判定:只由插件的配置开关决定(与权限预设无关)
347
+ const switchOn = cfg.enabled === true
348
+ if (!switchOn) return next()
349
+ // 打开后:mode=enforce → 替用户回答审批;mode=observe → 只评审记录、不动手
350
+ const gated = cfg.mode === 'enforce'
351
+ // 会话/turn 标识:turn 用"用户消息条数"近似(用户一说话就算新 turn,等价 Codex 的 clear_turn)
352
+ const sessionKey = sessionKeyOf(exec)
353
+ const turnId = turnIdOf(exec)
354
+ const started = Date.now()
355
+ let decision
356
+ try {
357
+ // 版本筛选归 policy(它才知道当前证据是否完整);这里只把原料交出去
358
+ decision = await review(scoped, exec, cfg, log, effective, {
359
+ previousReviews: recentReviews.get(sessionKey) ?? [],
360
+ turnId,
361
+ })
362
+ } catch (error) {
363
+ const message = String(error?.message ?? error)
364
+ log.write('reviewer-failed', { mode: cfg.mode,
365
+ tool: exec?.name,
366
+ callId: exec?.callId,
367
+ requested: args?.sandbox_permissions,
368
+ effective,
369
+ ms: Date.now() - started,
370
+ message,
371
+ })
372
+ const failure = reviewFailureAction({
373
+ gated,
374
+ cfg,
375
+ exec,
376
+ message,
377
+ deniedName: DENIED_NAME,
378
+ deniedCode: DENIED_CODE,
379
+ })
380
+ if (failure.kind === 'pass') return next()
381
+ return failure
382
+ }
383
+ log.write('reviewed', { mode: cfg.mode,
384
+ tool: exec?.name,
385
+ callId: exec?.callId,
386
+ enabled: cfg.enabled === true,
387
+ requested: args?.sandbox_permissions,
388
+ effective,
389
+ ms: Date.now() - started,
390
+ risk: decision.risk,
391
+ decision: decision.decision,
392
+ reason: decision.reason,
393
+ rationale: decision.rationale,
394
+ })
395
+ if (cfg.selfTest === true && !selfTestStarted) {
396
+ // 用例是可选的附加模块,默认未安装:取不到就记 selftest-unavailable,只尝试一次,不影响评审
397
+ selfTestStarted = true
398
+ try {
399
+ const selfTest = await loadSelfTest(cfg)
400
+ // 伪造动作自测:不 await(免得拖慢这次真实调用),失败只记录
401
+ log.write('selftest-start', { baseCases: selfTest.SELFTEST_CASES?.length, via: exec?.name })
402
+ void selfTest.runSelfTest({ review, ctx: scoped, exec, cfg, log, effective }).catch((error) =>
403
+ log.write('selftest-error', { message: String(error?.message ?? error) }),
404
+ )
405
+ } catch (error) {
406
+ log.write('selftest-unavailable', { attempts: error?.attempts })
407
+ }
408
+ }
409
+ // 送客户端的判定与理由(客户端卡片按 callId 读投影)
410
+ verdictStore.remember(exec?.callId, {
411
+ risk: decision.risk,
412
+ decision: decision.decision,
413
+ rationale: decision.rationale,
414
+ reason: decision.reason,
415
+ })
416
+ recordReview(sessionKey, turnId, {
417
+ // 版本元组照 Codex:用户消息版本 + 证据是否完整;两者都相等才可复用
418
+ authorizationVersion: decision.authorizationVersion ?? { turn: turnId, complete: false },
419
+ completedAt: new Date().toISOString(),
420
+ tool: exec?.name,
421
+ risk: decision.risk,
422
+ authorization: decision.authorization,
423
+ outcome: decision.decision,
424
+ reason: typeof decision.reason === 'string' ? decision.reason.slice(0, 200) : undefined,
425
+ callId: exec?.callId,
426
+ })
427
+ if (decision.decision === 'allow') {
428
+ recordNonDenial(sessionKey, turnId)
429
+ if (gated) {
430
+ rememberApproval(approvals, exec?.agent?.session, exec?.callId)
431
+ // ⚠️ 审批瀑布是从 **agent 的上下文** 开始收集 answerer 的
432
+ //(dsh-user-approval:`ctx.waterfall(scopeTarget(req.agent, req.agent), 'approval/request', …)`),
433
+ // 所以在自己 scoped ctx 上注册的监听器**根本不会被扫到** → 每次都问用户。
434
+ // 这里按 agent 挂一个**一次性** answerer:只回答这一次越界调用,答完即注销。
435
+ try {
436
+ const agentCtx = exec?.agent?.ctx ?? exec?.agent?.context ?? null
437
+ if (agentCtx !== null && typeof agentCtx.on === 'function') {
438
+ const callId = exec?.callId
439
+ const dispose = agentCtx.on(
440
+ 'approval/request',
441
+ async (req, down) => {
442
+ if (req?.callId !== callId) return down()
443
+ try {
444
+ if (typeof dispose === 'function') dispose()
445
+ } catch {
446
+ /* 注销失败不影响放行 */
447
+ }
448
+ // ⚠️ 2026-09-28 实测结论:往 req.reason 里并判定与理由是**无效**的 —— 官方
449
+ // user-approval 先 `session.append('approval/asked', …)` 再 await decide(),
450
+ // 所以那条事件早已落盘,我改的只是内存对象(日志里一度出现 approval-reason-enriched,
451
+ // 但它并不代表进了日志;实测会话里 approval/asked.reason 没有我的后缀)。
452
+ // 放行那次的"评审理由"因此没有事件通道,要显示它只能走 typert Remote。
453
+ log.write('approval-granted', { mode: cfg.mode, via: 'agent', tool: req?.toolName, callId })
454
+ return 'allowed-once'
455
+ },
456
+ { prepend: true },
457
+ )
458
+ log.write('approval-scope-registered', { via: 'agent.ctx', callId })
459
+ } else {
460
+ log.write('approval-scope-missing', {
461
+ agentKeys: exec?.agent === undefined || exec?.agent === null ? null : Object.keys(exec.agent),
462
+ })
463
+ }
464
+ } catch (error) {
465
+ log.write('approval-scope-error', { message: String(error?.message ?? error) })
466
+ }
467
+ }
468
+ return next()
469
+ }
470
+ if (!gated) return next()
471
+ // 连续拒绝熔断(照 Codex circuit_breaker.rs:连续 3 次 / 窗口内 10 次):
472
+ // 触发后不再拒绝,改为交回人工,避免把整个任务卡死。
473
+ const breaker = recordDenial(sessionKey, turnId)
474
+ if (breaker.interrupt) {
475
+ log.write('circuit-breaker', {
476
+ session: sessionKey,
477
+ turn: turnId,
478
+ consecutive: breaker.consecutive,
479
+ recent: breaker.recent,
480
+ })
481
+ return circuitBreakerAction({ exec, breaker })
482
+ }
483
+ return reviewRejectedAction({ cfg, exec, decision, deniedName: DENIED_NAME, deniedCode: DENIED_CODE })
484
+ },
485
+ { prepend: true },
486
+ )
487
+
488
+ // 2) 审批缝:只放行「本插件刚批准过的那一次越界」,其余全部交给下游(人工)
489
+ yield scoped.on('approval/request', async (req, next) => {
490
+ try {
491
+ if (live.enabled === true && live.mode === 'enforce' && consumeApproval(approvals, req?.agent?.session, req?.callId, live.timeoutMs)) {
492
+ log.write('approval-granted', { mode: live.mode, tool: req?.toolName, callId: req?.callId, reason: req?.reason })
493
+ return 'allowed-once'
494
+ }
495
+ } catch (error) {
496
+ log.write('approval-error', { message: String(error?.message ?? error) })
497
+ }
498
+ return next()
499
+ })
500
+ })
501
+ })
502
+ } catch (error) {
503
+ bootLog('inject-failed', { message: String(error?.message ?? error) })
504
+ log.write('inject-failed', { message: String(error?.message ?? error) })
505
+ }
506
+ }
507
+
508
+ /**
509
+ * ⚠️ 官方形态(2026-09-27 读官方包 `dsh-pwsh-sandbox` 确认):
510
+ * `var X = class X extends 基类 { static inject = […] }`
511
+ * `export { X, X as default }`
512
+ * —— **class + `static Config` + default 导出**是官方宿主半边统一的做法,
513
+ * 而 cordis 的 `runtime.Config` 取自 `plugin.Config`(`ctx.plugin()` 源码),对构造函数还有专门分支:
514
+ * `if (isConstructor(runtime.callback)) { const instance = new runtime.callback(this.ctx, this.config) … }`
515
+ *
516
+ * 本插件的实现仍是 `apply(ctx, config)`,这里只加一个类壳把 `Config` 变成**类的静态属性**,
517
+ * 让任何加载路径(default 导出 / 命名空间 / 类)都能取到它。实现逻辑一行未改。
518
+ */
519
+ class EscalationReview {
520
+ static Config = Config
521
+ static inject = []
522
+ constructor(ctx, config) {
523
+ apply(ctx, config)
524
+ }
525
+ }
526
+
527
+ export { EscalationReview }
528
+ export default EscalationReview
529
+
530
+ /** 诊断接口:供宿主与调用方取用内部状态。 */
531
+ export const __diagnostics = {
532
+ effectiveMode,
533
+ rememberApproval,
534
+ consumeApproval,
535
+ review,
536
+ BOOT_LOG,
537
+ setAssembler(value) {
538
+ setAssemblerOverride(value)
539
+ },
540
+ setSchemaBuilder(value) {
541
+ schemaBuilderOverride = value
542
+ },
543
+ }