@sema-agent/client-core 0.51.0 → 0.53.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.
@@ -94,6 +94,117 @@ export function classifierDenyFromToolEnd(ev) {
94
94
  const reason = tail.startsWith(': ') ? tail.slice(2).trim() : undefined;
95
95
  return { message, ...(reason !== undefined && reason.length > 0 ? { reason } : {}) };
96
96
  }
97
+ // ── L-69⑨(0.52.0):被拒行的**行文案判定**(三端同形)────────────────────────────────────────
98
+ /**
99
+ * {@link classifierDenyDisplay} 的返回上界(字符数,含末尾省略号)。
100
+ *
101
+ * 🔴 为什么必须有界:入参 `args` 是**模型产出**的工具入参(UNTRUSTED),`Bash.command` 与
102
+ * `Write.file_path` 都可以是任意长度的串。呈现面(cli 的 /permissions Recent Denials 是一行、
103
+ * `wrap="truncate"`)自己也会截,但**库不许交出一个没有上限的串** —— 端把它塞进别的容器
104
+ * (通知行、日志、剪贴板)时那道呈现截断就不在了。
105
+ * 🔴 **本上界量的是「交出去的原文长度」,不是端渲出来那一行的最终宽度**(异源对抗复审 R1
106
+ * finding③ 采纳的措辞订正):控制符 / 双向符 / 换行的可见化是**展示消毒**,归端
107
+ * (cli `cleanUntrustedForDisplay`),而消毒会**变长** —— 200 个 ESC 在这里长度是 200,端把它们
108
+ * 逐个转成 `\u001B` 之后是 1200。⇒ **端在自己消毒之后必须再按自己的行宽兜一次底**,别把本常量
109
+ * 读成「拿到手就一定不超过 200 个显示格」。
110
+ * 🔴 那为什么库不干脆先消毒再截(那样最终宽度就守得住)?因为消毒的字符集只能有**一份**:库里消
111
+ * 一遍、端再消一遍,两份字符集必然漂([machine-readable-signal-not-visual-anchor] 同族),而
112
+ * 端那一份还要管别的载体(hook 名 / fleet 行标签)。分工是刻意的:库守「原文不无限长」,
113
+ * 端守「渲出来那一行不超宽」。
114
+ */
115
+ export const CLASSIFIER_DENY_DISPLAY_MAX = 200;
116
+ /**
117
+ * 每只工具「拿哪个入参当行文案」的表(逐字锚 CC/sema 的工具入参键名,实测直证 sema-cli
118
+ * `src/tools/*`:`BashTool` = `command`、`FileReadTool`/`FileWriteTool`/`FileEditTool` = `file_path`、
119
+ * `NotebookEditTool` = **`notebook_path`**)。
120
+ *
121
+ * 🔴 `NotebookEdit` 的键**不是** `file_path`(它是 `notebook_path`)—— 写成 `file_path` 不会报错,
122
+ * 只会让这只工具**永远**落回工具名兜底,而那正好和「没实现」长得一模一样(静默无效)。
123
+ * 🔴 键名归一按 `toolNameKey`(去空白/下划线/连字符 + 小写),与本仓 `toolNameIsFsWrite` /
124
+ * `toolNameIsShellExec` 的归一姿势逐字同款 —— 引擎侧工具名的书写形不由本包决定。
125
+ * 🔴 表**外**的工具一律回落工具名:这是一张**识别表**,不是「合法工具只有这些」
126
+ * (`engineErrorCodes` 的开集纪律同族)。
127
+ */
128
+ const DISPLAY_ARG_KEY_BY_TOOL = new Map([
129
+ ['bash', 'command'],
130
+ ['read', 'file_path'],
131
+ ['fileread', 'file_path'],
132
+ ['write', 'file_path'],
133
+ ['filewrite', 'file_path'],
134
+ ['edit', 'file_path'],
135
+ ['fileedit', 'file_path'],
136
+ ['multiedit', 'file_path'],
137
+ ['notebookedit', 'notebook_path'],
138
+ ]);
139
+ /** 工具名归一(与 `hitl/toolApprovalWire.ts` 的两个谓词逐字同款)。 */
140
+ function toolNameKey(name) {
141
+ return name.replace(/[\s_-]+/g, '').toLowerCase();
142
+ }
143
+ /**
144
+ * 一条分类器拒绝行的**显示名**:三端同形的「这一行说的是哪条命令 / 哪个文件」判定。
145
+ *
146
+ * 为什么在库里(而不是各端自己拼):端要渲的是同一件事(Recent Denials 行 / 通知行 / retry 提示),
147
+ * 判据(读哪个入参键、什么算「在场」、上界多少)三端各写一遍必然各错一遍;而这是**纯判定**、零 IO、
148
+ * 零展示——正合本包的归层。
149
+ *
150
+ * 判据(逐条):
151
+ * · `Bash` ⇒ `args.command`;`Read`/`Write`/`Edit`/`MultiEdit` ⇒ `args.file_path`;
152
+ * `NotebookEdit` ⇒ `args.notebook_path`(见 {@link DISPLAY_ARG_KEY_BY_TOOL});表外工具 ⇒ 工具名;
153
+ * · 取到的值**必须是非空串**(trim 后非空)——非串 / 缺席 / 空串 / 纯空白一律回落工具名。
154
+ * 🔴 回落**不是**「渲一个空行」:一个空的显示名会让那一行看起来像坏了,而工具名至少是真的;
155
+ * · 工具名自身为空 ⇒ `'tool'`(与同文件 {@link classifierDenyNoticeText} 的兜底逐字同款);
156
+ * · 结果长度上界 {@link CLASSIFIER_DENY_DISPLAY_MAX}(超出 ⇒ 截到上界、末位换 `…`)。
157
+ *
158
+ * 🔴 **绝不**在这里做展示消毒(控制符/双向符/换行)——那是端的事,理由见
159
+ * {@link CLASSIFIER_DENY_DISPLAY_MAX} 头注。返回值仍然是 **UNTRUSTED-for-display**。
160
+ * 🔴 本函数**不**回答 retry 粒度:重试哪一只调用、按什么规则铸,是端侧规则面的事
161
+ * (身份锚 = `ClassifierDenyContext.toolCallId`)。
162
+ */
163
+ export function classifierDenyDisplay(toolName, args) {
164
+ const fallback = toolName.length > 0 ? toolName : 'tool';
165
+ const key = DISPLAY_ARG_KEY_BY_TOOL.get(toolNameKey(toolName));
166
+ if (key === undefined)
167
+ return bounded(fallback);
168
+ if (args === null || typeof args !== 'object')
169
+ return bounded(fallback);
170
+ const raw = args[key];
171
+ if (typeof raw !== 'string' || raw.trim().length === 0)
172
+ return bounded(fallback);
173
+ return bounded(raw);
174
+ }
175
+ /**
176
+ * 上界裁剪(超限 ⇒ 截到上界、末位换 `…`;与 {@link classifierDenyNoticeText} 的截法同形)。
177
+ *
178
+ * 🔴 **本函数守的是「截断不制造畸形」,不是「输出一定 well-formed」**(措辞由异源对抗复审 R2
179
+ * finding 订正 —— 上一版写成「绝不交出孤代理项」是**过度声称**)。两件事必须分开:
180
+ * · **截断制造的**孤高代理项 = 本函数的责任。`slice` 按 UTF-16 码元切,边界正好落在一个 emoji /
181
+ * 星平面字符的**代理对中间**时,交出去的是一个本来不存在的畸形串 —— 那是本层自己弄坏的,
182
+ * 必须自己不弄坏(少切一个码元、结果短一格)。
183
+ * · **输入里本来就有的**孤代理项(高或低)= **原样透出**,与本文件其余 UNTRUSTED 位一个待遇。
184
+ * `JSON.parse('"\ud800"')` 完全合法,所以这种入参是真实存在的;而把它「修好」就是**展示消毒**
185
+ * —— 那一份在本包里已有唯一真源:{@link escapeDisplayControlChars}(`fleetTaskDesc.ts`,公面导出),
186
+ * 它的头注逐字点名这一族:「孤代理项:上游可能本来就送半只,或被列宽截断劈开」。在这里再消一遍
187
+ * = 第二份字符集,正是那条单源纪律要防的事。
188
+ * 🔴 因此只回退**末位的高代理项**这一形:那是本函数唯一可能自己造出来的畸形(切的永远是前缀,
189
+ * 所以断口只在尾部)。末位是低代理项 ⇒ 要么它的高位还在串里(配对完整),要么它在输入里本来就是
190
+ * 孤的(上一条,原样透出)。
191
+ * 🔴 组合记号 / 变体选择符 / ZWJ 序列被切开只是「显示成两个字」,仍是合法 Unicode —— 本函数不做
192
+ * 字素簇分段(那要一整份 grapheme 表,且属于**渲染**面,与本文件的分工线一致)。
193
+ */
194
+ function bounded(s) {
195
+ if (s.length <= CLASSIFIER_DENY_DISPLAY_MAX)
196
+ return s;
197
+ const keep = CLASSIFIER_DENY_DISPLAY_MAX - 1;
198
+ let cut = s.slice(0, keep);
199
+ const last = cut.charCodeAt(cut.length - 1);
200
+ // 🔴 只有「末位是高代理项 **且** 原串紧接着的那个码元是低代理项」才是被截断劈开的合法代理对(本层
201
+ // 自己造的畸形,少切一格);末位高代理项后面跟的不是低代理项 ⇒ 它在输入里本来就是孤的 ⇒ 原样透出
202
+ // (对抗复审 r3 [medium]:无条件回退会静默删掉输入自带的孤高代理项,违反上面的分工线)。
203
+ const next = s.charCodeAt(keep);
204
+ if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff)
205
+ cut = cut.slice(0, -1);
206
+ return `${cut}\u2026`;
207
+ }
97
208
  /** 通知行文案(exported for tests;CC 207 三段 text 化)。 */
98
209
  export function classifierDenyNoticeText(toolName, reason) {
99
210
  let r = reason ?? '';
@@ -378,7 +378,16 @@ const TOOL_END_ARMS = [
378
378
  return undefined;
379
379
  led.markEnded(callId);
380
380
  led.dropHeld(callId);
381
- surfaceForCurrentSession()?.surfaceClassifierDeny(typeof ev.toolName === 'string' ? ev.toolName : 'tool', verdict);
381
+ // L-69⑨(0.52.0):把**这一只 call 的现场**一并交给宿主 —— 修前只给 `toolName`,于是端的
382
+ // Recent Denials 三行同名(三条被拒的 Bash 一个样)、retry 粒度只能落到「整个工具」。
383
+ // 🔴 args 是 **UNTRUSTED** wire 值:本层原样搬运(不渲不截不消毒),展示消毒归端。
384
+ // 🔴 args 取自**在飞表**(`noteStart`,每张 tool_start 都记、有界、收口即删)——`gatedStartArgs`
385
+ // 在这里读不到:分类器 deny 的工具通常**没被 gate**(引擎侧分类器直接 block,一张
386
+ // tool_approval 帧都不出),那张表里根本没有它。
387
+ // 🔴 缺席不铸键(`exactOptionalPropertyTypes`):台账里没有 ⇒ `ctx` 只带 `toolCallId`,
388
+ // 端读到的是诚实缺席,不是「args 是 undefined」。
389
+ const startArgs = led.startArgs().get(callId);
390
+ surfaceForCurrentSession()?.surfaceClassifierDeny(typeof ev.toolName === 'string' ? ev.toolName : 'tool', verdict, { toolCallId: callId, ...(startArgs !== undefined ? { args: startArgs } : {}) });
382
391
  hostLog('debug', `liveHitlAskWire: classifier deny verdict on ${callId} (${String(ev.toolName)}) — labeled render + notice`);
383
392
  return { kind: 'yield', events: [{ ...ev, output: verdict.message, structured: undefined }] };
384
393
  },
@@ -528,6 +537,15 @@ async function routeToolApprovalFrame(ev, ctx) {
528
537
  return { kind: 'skip' };
529
538
  }
530
539
  function routeToolStart(ev, callId, led) {
540
+ // L-69⑨(0.52.0):**所有** tool_start 都留一份 args 快照(有界在飞表,收口即删)。
541
+ // 🔴 与下面那张 gated 表刻意**不合表**:「表里有」绝不等于「被 gate 过」——两张表的成员关系、
542
+ // 生命周期、容量纪律都不同(逐条理由见 `gateLedger.ts` 的 `noteStart` 头注)。
543
+ // 这一份服务的是分类器 deny 这类**没走 gate**的收口现场(gated 表里根本没有它们)。
544
+ // 🔴 **必须排在 `markStarted` 闸之前**(异源对抗复审 R1 finding② 采纳):那道闸是**渲染**去重
545
+ // (durable 重放的已渲 call 不再上屏),而记账不是渲染。排在闸之后 ⇒ 重放帧一律早退 ⇒
546
+ // `noteStart` 的「先删再写」刷新在**生产路径上永远走不到**,只有直调台账的测试碰得着;
547
+ // 而重放恰恰是被有界表淘汰过的那些 call 唯一一次把快照拿回来的机会。
548
+ led.noteStart(callId, ev);
531
549
  if (!led.markStarted(callId))
532
550
  return { kind: 'skip' }; // durable 重放的已渲 call
533
551
  if (isGatedToolName(ev.toolName)) {
@@ -545,13 +563,24 @@ function routeToolStart(ev, callId, led) {
545
563
  return { kind: 'yield', events: [ev] };
546
564
  }
547
565
  function routeToolEnd(ev, callId, led) {
548
- if (led.isEnded(callId))
566
+ if (led.isEnded(callId)) {
567
+ // L-69⑨(0.52.0):已收口的 call **无论走哪条路**都不该留在在飞表里。这一行是上面
568
+ // 「noteStart 排在渲染去重闸之前」的对偶:durable 重放会把 start 再送一遍(于是快照被重新
569
+ // 加回来),而重放的 end 在这里早退 ⇒ 少了这一行,那一条就要一直挂到被容量淘汰为止。
570
+ led.dropStartArgs(callId);
549
571
  return { kind: 'skip' }; // durable 重放/park 复写的已收口 call
572
+ }
550
573
  led.dropFsCall(callId);
551
574
  for (const arm of TOOL_END_ARMS) {
552
575
  const action = arm.run(ev, callId, led);
553
- if (action)
576
+ if (action) {
577
+ // L-69⑨(0.52.0):在飞窗到此为止 —— 收口帧已经判完(臂**在这一行之前**跑,分类器 deny 臂
578
+ // 因此还读得到 args),这一条快照从此没有消费者。
579
+ // 🔴 释放点必须在**臂之后**:挪到循环之前 ⇒ deny 臂拿到的恒是缺席(修前形);挪到 `return`
580
+ // 的调用方 ⇒ 五条臂各自 return,释放就要抄五遍(而漏抄的那条就是泄漏点)。
581
+ led.dropStartArgs(callId);
554
582
  return action;
583
+ }
555
584
  }
556
585
  // 不可达:兜底臂 generic-close 无条件命中。真走到这里 = 有人给它加了早退条件,
557
586
  // 那样 tool_end 会被整帧静默丢弃(用户看不到任何收口),必须当场喊出来而不是 `skip`。
@@ -61,6 +61,29 @@ export interface GateLedger {
61
61
  * 审批 payload,且台账持帧延长了整帧生命周期。载荷仍是 UNTRUSTED unknown,台账只搬运不解释。 */
62
62
  noteGatedStart(callId: string, ev: AgentEvent): void;
63
63
  gatedStartArgs(): ReadonlyMap<string, unknown>;
64
+ /**
65
+ * 每一张 `tool_start` 的 `args` 快照,**不论这只工具有没有被 gate**。
66
+ *
67
+ * 🔴 为什么必须与 {@link gatedStartArgs} **分成两张表**(而不是「合表 + 用有没有 args 判 gated」):
68
+ * 「被 gate 过」是**另一个集合**,两者的成员关系没有任何蕴含 —— 分类器 deny 的工具通常
69
+ * **没有**被 gate(引擎侧分类器直接 block,一张 `tool_approval` 帧都不出),而 gate 过的工具
70
+ * 也可能整场没有 args。把「表里有这一条」读成「它被 gate 过」,等于拿一个搬运位当身份位
71
+ * ([provenance-needs-wire-fact-not-inference] 同族);合表之后总有人会这么读,所以不合。
72
+ * 两表的**生命周期**也刻意不同,见下:
73
+ * · {@link gatedStartArgs}:审批 payload 的一手源,**整个 turn 不删**(park / durable 重放
74
+ * 之后卡面还要读它),条目数被「真被 gate 的工具数」天然约束;
75
+ * · 本表:每一张 tool_start 都进,所以**必须有界**(见 {@link START_ARGS_MAX_ENTRIES}),
76
+ * 且 `tool_end` 收口当拍就删(见 {@link dropStartArgs})—— 它只服务「这只 call 还没收口」
77
+ * 那段窗口内的消费者(今天唯一一个:分类器 deny 臂给宿主的 `ctx.args`)。
78
+ *
79
+ * 🔴 存的是 **tool_start 当拍的 `args` 快照**而非帧引用,理由与 {@link noteGatedStart} 逐字同款。
80
+ * 载荷是 **UNTRUSTED** wire 值:台账只搬运,不解释、不渲染、不截断。
81
+ */
82
+ noteStart(callId: string, ev: AgentEvent): void;
83
+ /** 本 call 的 tool_start args 快照;没记过 / 已收口 / 已被有界淘汰 ⇒ 表里读不到。 */
84
+ startArgs(): ReadonlyMap<string, unknown>;
85
+ /** `tool_end` 收口当拍释放这一条(在飞窗结束)。不在表里 ⇒ no-op。 */
86
+ dropStartArgs(callId: string): void;
64
87
  isEnded(callId: string): boolean;
65
88
  markEnded(callId: string): void;
66
89
  /**
@@ -237,5 +260,36 @@ export interface GateLedger {
237
260
  /** 判据链上出现了真进展(park 真被决断 / 重探把新坐标的卡呈出去了)⇒ 连续计数归零。 */
238
261
  resetAlreadyResolvedGate(): void;
239
262
  }
263
+ /**
264
+ * {@link GateLedger.noteStart} 那张在飞表的**容量上限**(L-69⑨,0.52.0)。
265
+ *
266
+ * 🔴 为什么必须有界:本表的写口是**每一张** `tool_start`,而它的删口(`tool_end` 收口)在两种真实
267
+ * 形态下不会来 —— ①一个 turn 里 `tool_end` 先于本连接开始(durable re-attach 只消费 `runs.events`
268
+ * 的一段);②run 被中止 / 连接断掉,在飞 call 的收口帧永远不到。无界表在长流多工具的 turn 上
269
+ * 条目数会一直涨。
270
+ *
271
+ * 🔴 **上限量的是条目数(= 保留的引用数),不是字节数**(异源对抗复审 R1 finding① 采纳的措辞订正)。
272
+ * 本表存的是 `ev.args` 的**引用**,不是拷贝 —— 而同一个 `ev` 在这一拍**照常 yield 下去**
273
+ * (`frameRouter.routeToolStart` 返回 `{kind:'yield', events:[ev]}`),投影成 `SDKMessage` 之后
274
+ * 那份入参在端的转录面上本来就活着。所以本表对**驻留字节**的增量是「一个指针 × 条目数」,
275
+ * 它不复制载荷、也不是这条链上第一个持有它的容器:同文件的 {@link GateLedger.noteGatedStart}
276
+ * 从本批之前就持着同一形的引用,而且**整个 turn 不删、连条目数都不设上限**。
277
+ * ⚠️ 仍然如实说清**没有**守住的那一格:单条载荷本身**没有**字节预算(一次 `Write` 的
278
+ * `content` 有多大,这里就跟着多引用多大一份多久)。真要按字节记账得先定义「多大算大」并同批给出
279
+ * 超限时的降级形(截断 = 交出一份假入参,不许;整条不留 = 那只 call 的 deny 现场退回诚实缺席),
280
+ * 属独立一件;本批**不做单边近似**,如实登记在此。
281
+ * 🔴 为什么是 **256** 而不是更小:上限守的是「条目数无界增长」,而它必须**远大于**同一时刻真正在飞的
282
+ * call 数,否则淘汰会打到还没收口的 call 身上(那正是本表要服务的对象)。引擎侧同一 turn 的并发
283
+ * 工具批今天是个位数量级,单批 `tool_start` 连发也在两位数以内;256 给出的是两个数量级的余量。
284
+ * 🔴 淘汰**按 FIFO 丢最早**(`Map` 的插入序):最早入表的那一条是**最久没有收口**的,它要么已经因为
285
+ * 上面两种形态永远不会收口,要么它的消费窗(收口当拍)早就过去了 —— 丢它的代价最小。绝不按
286
+ * 「随便丢一个」或整表清空(整表清空会把同批在飞的兄弟一起打掉)。
287
+ * 🔴 **淘汰打到仍在飞的 call 时会怎样(成文的取舍,不是漏)**:那只 call 后来被分类器拒绝的话,
288
+ * 宿主拿到的 `ctx` 只有 `toolCallId`、没有 `args` —— 即**诚实缺席**,端按可选处理照常降级渲
289
+ * (契约见 `docs/INTEGRATION-CLIENTS.md` §15c 第 3 条)。这条路上唯一的替代品是「不淘汰」,
290
+ * 而那就是无界表。durable 重放会把 `tool_start` 再送一遍,那是被淘汰项**拿回快照**的机会 ——
291
+ * 所以 `frameRouter.routeToolStart` 把本动词排在渲染去重闸**之前**(见该站点注)。
292
+ */
293
+ export declare const START_ARGS_MAX_ENTRIES = 256;
240
294
  /** 造一份 turn 级 gate 台账(**不是单例**,见文件头注)。 */
241
295
  export declare function createGateLedger(): GateLedger;
@@ -9,6 +9,37 @@
9
9
  * additive 键保留兼容(裁定原文)。
10
10
  */
11
11
  export const SEMA_COLLATERAL_ABORT_KEY = '_sema_collateral_abort';
12
+ /**
13
+ * {@link GateLedger.noteStart} 那张在飞表的**容量上限**(L-69⑨,0.52.0)。
14
+ *
15
+ * 🔴 为什么必须有界:本表的写口是**每一张** `tool_start`,而它的删口(`tool_end` 收口)在两种真实
16
+ * 形态下不会来 —— ①一个 turn 里 `tool_end` 先于本连接开始(durable re-attach 只消费 `runs.events`
17
+ * 的一段);②run 被中止 / 连接断掉,在飞 call 的收口帧永远不到。无界表在长流多工具的 turn 上
18
+ * 条目数会一直涨。
19
+ *
20
+ * 🔴 **上限量的是条目数(= 保留的引用数),不是字节数**(异源对抗复审 R1 finding① 采纳的措辞订正)。
21
+ * 本表存的是 `ev.args` 的**引用**,不是拷贝 —— 而同一个 `ev` 在这一拍**照常 yield 下去**
22
+ * (`frameRouter.routeToolStart` 返回 `{kind:'yield', events:[ev]}`),投影成 `SDKMessage` 之后
23
+ * 那份入参在端的转录面上本来就活着。所以本表对**驻留字节**的增量是「一个指针 × 条目数」,
24
+ * 它不复制载荷、也不是这条链上第一个持有它的容器:同文件的 {@link GateLedger.noteGatedStart}
25
+ * 从本批之前就持着同一形的引用,而且**整个 turn 不删、连条目数都不设上限**。
26
+ * ⚠️ 仍然如实说清**没有**守住的那一格:单条载荷本身**没有**字节预算(一次 `Write` 的
27
+ * `content` 有多大,这里就跟着多引用多大一份多久)。真要按字节记账得先定义「多大算大」并同批给出
28
+ * 超限时的降级形(截断 = 交出一份假入参,不许;整条不留 = 那只 call 的 deny 现场退回诚实缺席),
29
+ * 属独立一件;本批**不做单边近似**,如实登记在此。
30
+ * 🔴 为什么是 **256** 而不是更小:上限守的是「条目数无界增长」,而它必须**远大于**同一时刻真正在飞的
31
+ * call 数,否则淘汰会打到还没收口的 call 身上(那正是本表要服务的对象)。引擎侧同一 turn 的并发
32
+ * 工具批今天是个位数量级,单批 `tool_start` 连发也在两位数以内;256 给出的是两个数量级的余量。
33
+ * 🔴 淘汰**按 FIFO 丢最早**(`Map` 的插入序):最早入表的那一条是**最久没有收口**的,它要么已经因为
34
+ * 上面两种形态永远不会收口,要么它的消费窗(收口当拍)早就过去了 —— 丢它的代价最小。绝不按
35
+ * 「随便丢一个」或整表清空(整表清空会把同批在飞的兄弟一起打掉)。
36
+ * 🔴 **淘汰打到仍在飞的 call 时会怎样(成文的取舍,不是漏)**:那只 call 后来被分类器拒绝的话,
37
+ * 宿主拿到的 `ctx` 只有 `toolCallId`、没有 `args` —— 即**诚实缺席**,端按可选处理照常降级渲
38
+ * (契约见 `docs/INTEGRATION-CLIENTS.md` §15c 第 3 条)。这条路上唯一的替代品是「不淘汰」,
39
+ * 而那就是无界表。durable 重放会把 `tool_start` 再送一遍,那是被淘汰项**拿回快照**的机会 ——
40
+ * 所以 `frameRouter.routeToolStart` 把本动词排在渲染去重闸**之前**(见该站点注)。
41
+ */
42
+ export const START_ARGS_MAX_ENTRIES = 256;
12
43
  /** 造一份 turn 级 gate 台账(**不是单例**,见文件头注)。 */
13
44
  export function createGateLedger() {
14
45
  const startedCalls = new Set();
@@ -16,6 +47,8 @@ export function createGateLedger() {
16
47
  /** 只存帧:出身不在入表当拍冻结(那一拍 gate 身份还没到),改在 `flushHeld` 当拍求值。 */
17
48
  const heldAskEnds = new Map();
18
49
  const gatedStartArgsByCall = new Map();
50
+ /** 每一张 tool_start 的 args 快照(在飞窗;有界 FIFO,见 {@link START_ARGS_MAX_ENTRIES})。 */
51
+ const startArgsByCall = new Map();
19
52
  const deniedCalls = new Set();
20
53
  const pendingFsCalls = [];
21
54
  const resolvedAnswers = new Map();
@@ -140,6 +173,27 @@ export function createGateLedger() {
140
173
  gatedStartArgs() {
141
174
  return gatedStartArgsByCall;
142
175
  },
176
+ noteStart(callId, ev) {
177
+ // 🔴 **先删再写**:同一 callId 重放(durable 重放的 tool_start 在别的入口被 markStarted 挡掉,
178
+ // 但本动词不依赖那道闸)必须刷新它在 FIFO 里的位置,否则「覆盖」会让它保持旧的插入序、
179
+ // 在还活着的时候被当成最老的一条淘汰掉。`Map.set` 对已有键**不**改插入序,这一行是承重的。
180
+ startArgsByCall.delete(callId);
181
+ startArgsByCall.set(callId, ev.args);
182
+ // 有界:超容量丢**最早**入表的那一条(`Map` 迭代序 = 插入序)。while 而不是 if —— 上限被
183
+ // 调小时(或将来某次批量写入)一次要淘汰多条,if 会让表永久停在超限状态。
184
+ while (startArgsByCall.size > START_ARGS_MAX_ENTRIES) {
185
+ const oldest = startArgsByCall.keys().next();
186
+ if (oldest.done === true)
187
+ break;
188
+ startArgsByCall.delete(oldest.value);
189
+ }
190
+ },
191
+ startArgs() {
192
+ return startArgsByCall;
193
+ },
194
+ dropStartArgs(callId) {
195
+ startArgsByCall.delete(callId);
196
+ },
143
197
  isEnded(callId) {
144
198
  return endedCalls.has(callId);
145
199
  },
@@ -7,14 +7,41 @@ export interface HitlNotice {
7
7
  priority: 'immediate';
8
8
  timeoutMs: number;
9
9
  }
10
+ /**
11
+ * 分类器 deny 裁决的**这一只 call 的现场**(L-69⑨,0.52.0;{@link HitlHostSurface.surfaceClassifierDeny}
12
+ * 的 **additive 可选**第三参)。
13
+ *
14
+ * 🔴 为什么要有它:修前宿主只拿到 `toolName`,于是端的 Recent Denials 面板三行同名(三条被拒的
15
+ * `Bash` 长一个样),而端要做的两件事 —— **行文案**(这一行说的是哪条命令 / 哪个文件)与
16
+ * **retry 粒度**(重试这一只调用,而不是「整个 Bash 工具」)—— 都需要这一只 call 的身份与入参。
17
+ * 🔴 `args` 是 **UNTRUSTED** wire 值(模型产出的工具入参):本包**原样**交给宿主,不渲染、不截断、
18
+ * 不消毒 —— 展示消毒是端的事(cli 有 `cleanUntrustedForDisplay`)。要拿它铸**行文案**请用
19
+ * `classifierDenyDisplay(toolName, args)`(`src/classifierVerdictWire.ts`,三端同形的判定)。
20
+ * 🔴 `args` **可缺席**:分类器拒绝的工具通常没被 gate,而在飞 args 表是**有界**的(见
21
+ * `gateLedger.START_ARGS_MAX_ENTRIES`),长流上早期 call 的快照可能已被淘汰。缺席 = 诚实缺席,
22
+ * 端按「只有工具名」降级渲,**绝不**据此编一个空 args 出来。
23
+ */
24
+ export interface ClassifierDenyContext {
25
+ /** 这一只被拒调用的 wire `toolCallId`(retry 粒度的锚;帧上取,恒在场)。 */
26
+ toolCallId: string;
27
+ /** 这一只调用的 `tool_start` 入参快照;**UNTRUSTED**,缺席 = 台账里没有(见上)。 */
28
+ args?: unknown;
29
+ }
10
30
  /** HITL 的宿主副作用面 —— 通知上屏 + 分类器 deny 的端侧记账。 */
11
31
  export interface HitlHostSurface {
12
32
  /** 立即顶到 current(壳:`notifications.current = notice`,queue 不动)。 */
13
33
  showNotice(notice: HitlNotice): void;
14
34
  /** **仅当** current 仍是这个 key 时清掉(壳原文的 if-still-mine 语义 —— 否则会误清别人的通知)。 */
15
35
  clearNoticeIfCurrent(key: string): void;
16
- /** 分类器 deny 裁决的宿主副作用:footer 通知 + /permissions Recent Denials 记账(壳资产)。 */
17
- surfaceClassifierDeny(toolName: string, verdict: ClassifierDenyVerdict): void;
36
+ /**
37
+ * 分类器 deny 裁决的宿主副作用:footer 通知 + /permissions Recent Denials 记账(壳资产)
38
+ *
39
+ * 🔴 `ctx` 是 **additive 可选**第三参(0.52.0,L-69⑨):老宿主的两参实现**零改动照跑**
40
+ * —— JS 里多传一个实参不影响两参函数,TS 里「参数少的函数可赋给参数多的签名」是语言规则
41
+ * (函数参数双变/协变),desktop/web 现有 `surfaceClassifierDeny(t, v)` 实现不需要跟车。
42
+ * ⚠️ 反过来**不成立**:端一旦读了 `ctx`,就必须按可选处理(库对老路径/缺席场景不承诺在场)。
43
+ */
44
+ surfaceClassifierDeny(toolName: string, verdict: ClassifierDenyVerdict, ctx?: ClassifierDenyContext): void;
18
45
  }
19
46
  /** 装 HITL 宿主面(传 null 卸)。返回还原函数。 */
20
47
  export declare function installHitlHostSurface(surface: HitlHostSurface | null): () => void;
@@ -45,7 +45,24 @@
45
45
  * - workspace trust: interactive sessions with the trust dialog unaccepted ship NO hooks
46
46
  * (shouldSkipHookDueToTrust — the rc.36 "hooks 全被 trust 门禁" invariant, engine leg included);
47
47
  * - `disableAllHooks` (managed/policy settings): ships NO hooks;
48
- * - `allowManagedHooksOnly`: ONLY the policy-settings hooks ship; user/project/local are blocked.
48
+ * - `allowManagedHooksOnly`: ONLY the policy-settings hooks ship; user/project/local are blocked;
49
+ * - `strictPluginOnlyCustomization` covering the "hooks" surface (L-67④, 0.52.0): same disposition as
50
+ * `allowManagedHooksOnly` — ONLY the policy-settings hooks ship. Verbatim parity with the local
51
+ * executor's `isRestrictedToPluginOnly('hooks')` is argued clause-by-clause on
52
+ * {@link hooksLockedToPluginOnly}.
53
+ *
54
+ * 🔴 **在册缺口(同形族扫所得,本批刻意未修 —— 别把它读成「已经守住了」)**:cli 本地执行器
55
+ * (`src/utils/hooks/hooksConfigSnapshot.ts:getHooksFromAllowedSources`)还有**第四条**腿 ——
56
+ * `disableAllHooks` 出现在**非** managed 的来源(user/project/local)时,按 CC 语义降级成
57
+ * 「只跑 managed hooks」(非 managed 设置不能禁掉 managed hooks,但能禁掉自己)。本文件今天
58
+ * **只读 `policySettings.disableAllHooks`** ⇒ 这一形在引擎腿上不成立(用户把自己的 hooks 关了,
59
+ * 引擎照投照跑)。**为什么本批不修**:cli 那条腿读的是**合并后**的标量
60
+ * (`getSettings_DEPRECATED().disableAllHooks`,四源按 policy→user→project→local 后写覆盖前写),
61
+ * 而本包的 `SettingsPort` 只有 per-source 读口 —— 拿「任一来源为 true」去近似合并结果会在
62
+ * 「user 写 true、local 写 false」这一形上判反(cli 那边是**不**限制)。忠实复刻需要给
63
+ * `SettingsPort` 加一个合并读口 = **公面改动**,属另一批(端要跟车实现)。
64
+ * ⇒ 登记在此,不做单边近似([honest-absence-not-fabricated-zero]:守不住就说守不住,
65
+ * 别用一个会判反的近似冒充守住了)。
49
66
  *
50
67
  * Projection semantics (CC settings merge, verbatim shapes):
51
68
  * - Sources: policy → user → project → local settings files, per-event arrays CONCATENATED in that
@@ -72,6 +89,41 @@ import { MAX_HOOK_NOTICE_TEXT_CHARS } from './notifications.js';
72
89
  import { goalStopHookMatcher, __resetGoalStopHookForTests } from './goalStopHook.js';
73
90
  export { GOAL_STOP_HOOK_WIRE_ENV, CC_STOP_SEMANTICS_MIN_SERVER, ccStopSemanticsFromVersion, engineCcStopSemantics, isGoalStopHookWireArmed, setWireSessionStopHook, getWireSessionStopHook, getWireSessionStopHookCcSemantics, buildGoalStopHookPrompt, } from './goalStopHook.js';
74
91
  const EDITABLE_HOOK_SOURCES = ['userSettings', 'projectSettings', 'localSettings'];
92
+ /**
93
+ * managed `strictPluginOnlyCustomization` 对 **hooks 面**的锁判定(L-67④,0.52.0)。
94
+ *
95
+ * 🔴 病(修前):`hooksForWire()` 过了 `disableAllHooks` / `allowManagedHooksOnly` 两道 managed 治理门,
96
+ * **没过**这一道 —— 而本文件头注自己写着「the wire must honor the SAME gates the local executor
97
+ * honors, or a fleet-managed policy is silently bypassed by the engine leg」。后果:管理侧把 hooks
98
+ * 面锁成 plugin-only 之后,壳的**本地执行器**不再跑 user/project/local 的 hooks,而**引擎腿**照投
99
+ * 照跑 —— 禁令只在一半的执行面上成立,而这一半恰好是工具真正执行的那一半。
100
+ *
101
+ * 🔴 与 cli 本地执行器 `src/utils/settings/pluginOnlyPolicy.ts:isRestrictedToPluginOnly('hooks')`
102
+ * **逐条对照,差异为零**:
103
+ * · `=== true`(布尔真)⇒ 锁(cli 原文 `if (policy === true) return true`:`true` 锁**全部四个面**,
104
+ * hooks 在内)。⚠️ 本条是必须实现的一形 —— 只认数组形会把「一个字就锁全部」的管理写法整个放过,
105
+ * 而那正是本件要堵的洞;
106
+ * · 数组且含 `'hooks'` ⇒ 锁;数组不含 ⇒ **不锁**(锁的是别的面:skills/agents/mcp,与 hooks 无关);
107
+ * · 其余一切值形(缺席 / `false` / 串 / 对象 / 数字)⇒ **不锁**(当未设),**不** fail-closed 整条腿。
108
+ *
109
+ * 🔴 「非数组非 true ⇒ 当未设」为什么与 cli 差异为零(而不是本包自己另立一条宽口):cli 侧读到这个字段
110
+ * 之前先过 `SettingsSchema`,该字段的 `.preprocess(...).catch(undefined)`(cli `settings/types.ts`
111
+ * 逐字注释:「Non-array invalid values ("skills" string, {object}) … .catch drops the field to
112
+ * undefined instead. Degrades to unlocked-for-this-field, never to everything-broken.」)已经把脏值形
113
+ * 丢成 `undefined` ⇒ 到达 `isRestrictedToPluginOnly` 的值形只有 boolean / 字符串数组两种,本函数对这
114
+ * 两种与 cli **逐条同判**;而对**没过 schema** 的脏值形(别的宿主可能把盘上原文直接交上来),本函数
115
+ * 的处置方向与 cli 那条 schema 腿**同向**(degrade to unlocked-for-this-field)。
116
+ * 🔴 与相邻两道门的 fail-closed 纪律**不矛盾**:那两条 fail-closed 守的是「读取**抛出**」(治理策略
117
+ * 读不出来 = 当作有限制);本条守的是「读到了、但值是个坏形」—— 值在手里,方向由字段属主(schema)
118
+ * 定,不是未知态。两件事别混。
119
+ */
120
+ function hooksLockedToPluginOnly(policyValue) {
121
+ if (policyValue === true)
122
+ return true;
123
+ if (Array.isArray(policyValue))
124
+ return policyValue.includes('hooks');
125
+ return false;
126
+ }
75
127
  /**
76
128
  * REF-CC-155(midband-03,fix-outright):此前这里是静默 `catch { return null }` —— 零 hostLog、零
77
129
  * 注释说明「为什么这次失败可以死」。settings 读取失败(格式损坏 / 端内部错误)会被悄悄当成
@@ -136,7 +188,19 @@ export function hooksForWire() {
136
188
  hostLog('debug', 'hooksWireCaps: disableAllHooks (managed) — no hooks projected to the engine');
137
189
  return undefined;
138
190
  }
139
- const managedOnly = policy?.allowManagedHooksOnly === true;
191
+ // L-67④(0.52.0):第三道 managed 治理门 —— `strictPluginOnlyCustomization` 含 hooks 面。
192
+ // 🔴 **顺序与 `allowManagedHooksOnly` 无关**(两者同时在场时无论谁先判,结论都是同一个
193
+ // `['policySettings']`,处置逐字相同 —— 它们是**同一个动作**的两个理由,不是两级策略);
194
+ // 真正有顺序的只有 `disableAllHooks`:它在上面**先**判且**恒赢**(连 policy 自己的都不投),
195
+ // 本门只在它没命中时才有机会求值。
196
+ const pluginOnlyHooks = hooksLockedToPluginOnly(policy?.strictPluginOnlyCustomization);
197
+ if (pluginOnlyHooks) {
198
+ hostLog('debug', 'hooksWireCaps: strictPluginOnlyCustomization locks the hooks surface (managed) — only policySettings hooks projected to the engine');
199
+ }
200
+ // 🔴 `managedOnly` 这个名字下面还被 `/goal` overlay 的分道复用(用户态 goal 钩子必须与
201
+ // EDITABLE_HOOK_SOURCES 同进退)——所以本门**合流进同一个量**,而不是只改 `sources`:
202
+ // 只改 sources 会让 plugin-only 锁下 user 态的 goal 钩子照投,那是同一个洞换了个入口。
203
+ const managedOnly = policy?.allowManagedHooksOnly === true || pluginOnlyHooks;
140
204
  const sources = managedOnly
141
205
  ? ['policySettings']
142
206
  : ['policySettings', ...EDITABLE_HOOK_SOURCES];