@sema-agent/client-core 0.51.0 → 0.52.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.
package/CHANGELOG.md CHANGED
@@ -40,6 +40,65 @@
40
40
  > 门侧窄豁免同批登记(`KNOWN_HEADING_ERRATA` `version: '0.48.0', releasedAt: '4fae01b'`),
41
41
  > 与本段互钉,均为永久记录。
42
42
 
43
+ ## 0.52.0(2026-09-04)
44
+
45
+ ### hitl:分类器拒绝行带 `ctx`(toolCallId + tool_start args)(L-69⑨;DEBTS-cli)
46
+
47
+ - **病**:`frameRouter` 的 `classifier-deny` 臂只把 `toolName` 交给宿主 ⇒ 端的 /permissions Recent Denials
48
+ 三行同名(三条被拒的 `Bash` 长一个样),retry 粒度只能落到「整个工具」。拿不到 args 的根因在台账:
49
+ `gateLedger` **只在 gated tool_start** 留 args(`gatedStartArgsByCall`),而分类器拒绝的工具通常
50
+ **没被 gate**(引擎侧分类器直接 block,一张 `tool_approval` 帧都不出)⇒ 拒绝当拍 args 已不可得。
51
+ - **改**:
52
+ - `GateLedger` 加三个动词 `noteStart` / `startArgs` / `dropStartArgs`:**每一张** `tool_start` 都留一份
53
+ args 快照,**有界 FIFO**(`START_ARGS_MAX_ENTRIES = 256`,超容量丢最早),`tool_end` 收口当拍释放。
54
+ 🔴 与 `gatedStartArgs` **刻意分两张表**:「被 gate 过」是另一个集合,不许拿「表里有 args」当身份位;
55
+ 两表的生命周期与容量纪律也不同(gated 表整个 turn 不删、条目数天然受限)。
56
+ - `HitlHostSurface.surfaceClassifierDeny(toolName, verdict, ctx?)` 加 **additive 可选**第三参
57
+ `ClassifierDenyContext { toolCallId: string; args?: unknown }`;deny 臂传 `{toolCallId: callId, args}`。
58
+ 老宿主(两参实现)**零改动照跑**;台账缺席 ⇒ `args` 键不铸(诚实缺席,不是 `args: undefined`)。
59
+ - 新导出纯函数 `classifierDenyDisplay(toolName, args)`(三端同形的**行文案**判定):`Bash` ⇒ `command`,
60
+ `Read`/`Write`/`Edit`/`MultiEdit` ⇒ `file_path`,`NotebookEdit` ⇒ **`notebook_path`**(工具入参真形),
61
+ 表外工具 / 非串 / 空串 / 缺席一律回落工具名(空工具名 ⇒ `tool`);长度上界
62
+ `CLASSIFIER_DENY_DISPLAY_MAX = 200`。
63
+ - **异源对抗复审 R1 三条 finding 采纳**:①「有界」的措辞订正成**条目数有界**(存的是引用不是拷贝,同一份
64
+ 入参在这一拍照常进转录面;单条载荷无字节预算 = 成文的在册取舍)· ② `noteStart` 挪到 `markStarted`
65
+ **渲染去重闸之前**(排在闸后 ⇒ durable 重放的刷新在生产路径上永远走不到,被淘汰项再也拿不回快照),
66
+ 并给 `tool_end` 的 `isEnded` 早退臂补上释放 · ③ 截断改**代理对安全**(边界跨 emoji 时少切一个码元,
67
+ 绝不把代理对切成半只),并把「上界量的是原文长度、端消毒后会变长、端要再兜一次底」写进常量头注与 §15c。
68
+ - **R2 采纳的措辞订正(非行为改动)**:`bounded` 的契约从「绝不交出孤代理项」收紧成「截断**不制造**畸形」——
69
+ **输入里本来就有的**孤代理项(`JSON.parse('"\ud800"')` 合法)原样透出,修好它属展示消毒,单源是本包
70
+ 已导出的 `escapeDisplayControlChars`(其头注逐字点名「孤代理项:上游可能本来就送半只,或被列宽截断劈开」);
71
+ 在库里再消一遍 = 第二份字符集。同批把四条分工线钉成判据(含消毒单源真会转义孤代理项的正控)。
72
+ - 🔴 **端必读**:`ctx.args` 是 **UNTRUSTED** wire 值,本包**原样**交出 —— 不渲、不截、不消毒。展示消毒是端的事
73
+ (cli `cleanUntrustedForDisplay`),库里消一遍端再消一遍两份字符集必漂。retry 粒度(重试哪一只调用、
74
+ 按什么规则铸)同样**不在包内**,身份锚 = `ctx.toolCallId`。接入形见
75
+ `docs/INTEGRATION-CLIENTS.md` §15a/§15c。
76
+
77
+ ### hooks wire:第三道 managed 治理门 `strictPluginOnlyCustomization`(L-67④)
78
+
79
+ - **病**:`hooksForWire()` 过了 `disableAllHooks` / `allowManagedHooksOnly` 两道 managed 门,**没过**
80
+ `strictPluginOnlyCustomization`(CC 语义:值为 `true` 或数组含 `"hooks"` ⇒ user/project/local 三源的
81
+ hooks 全屏蔽,只放行 `policySettings.hooks`)。而 `hooksWireCaps.ts` 头注自己写着「the wire must honor
82
+ the SAME gates the local executor honors, or a fleet-managed policy is silently bypassed by the engine
83
+ leg」—— 管理侧锁了 hooks 面之后,壳的本地执行器不再跑用户态 hooks,**引擎腿照投照跑**:禁令只在一半的
84
+ 执行面上成立,而这一半恰好是工具真正执行的那一半。
85
+ - **改**:`hooksForWire()` 读 `policySettings.strictPluginOnlyCustomization`,命中 ⇒ `sources=['policySettings']`
86
+ 且 `/goal` 用户态 overlay 一并禁(与 `allowManagedHooksOnly` **同处置**),`hostLog('debug')` 留痕。
87
+ 判据与 cli 本地执行器 `isRestrictedToPluginOnly('hooks')` **逐条对照、差异为零**:`=== true` 锁 /
88
+ 数组含 `"hooks"` 锁 / 数组不含则不锁 / 其余一切值形当未设(**不** fail-closed 整条腿 —— 相邻两道门的
89
+ fail-closed 守的是「读取抛出」这一未知态,本条守的是「读到了但值是坏形」,两件事别混)。
90
+ `disableAllHooks` 与信任门仍排在本门之前、仍恒赢。
91
+ - **端影响**:纯治理面收紧,不改任何公面签名。只有**管理侧真的设了**这个字段的部署会看到差异(那些部署
92
+ 今天本来就在被绕过)。语义见 `docs/INTEGRATION-CLIENTS.md` §15b。
93
+
94
+ ### 在册缺口(本批新登记,**未修**)
95
+
96
+ - cli 本地执行器还有第四条腿:`disableAllHooks` 出现在**非** managed 来源时降级成「只跑 managed hooks」。
97
+ 本包今天只读 `policySettings.disableAllHooks` ⇒ 该形在引擎腿上不成立。**不做单边近似**的理由:cli 那条腿
98
+ 读的是**合并后**的标量(四源后写覆盖前写),而本包 `SettingsPort` 只有 per-source 读口,「任一来源为 true」
99
+ 会在「user 写 true、local 写 false」上判反;忠实复刻要给 `SettingsPort` 加合并读口 = 公面改动,属另一批。
100
+ 登记在 `src/hooksWireCaps.ts` 头注。
101
+
43
102
  ## 0.51.0(2026-09-03)
44
103
 
45
104
  ### hitl:park 再附着的 hop 预算改「连续非进展轮」计数(L-80;cli [6215]/[6217];#357 复发根治)
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.51.0
38
+ **Version:** 0.52.0
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -24,5 +24,44 @@ export declare function classifierDenyFromToolEnd(ev: {
24
24
  isError?: unknown;
25
25
  output?: unknown;
26
26
  }): ClassifierDenyVerdict | undefined;
27
+ /**
28
+ * {@link classifierDenyDisplay} 的返回上界(字符数,含末尾省略号)。
29
+ *
30
+ * 🔴 为什么必须有界:入参 `args` 是**模型产出**的工具入参(UNTRUSTED),`Bash.command` 与
31
+ * `Write.file_path` 都可以是任意长度的串。呈现面(cli 的 /permissions Recent Denials 是一行、
32
+ * `wrap="truncate"`)自己也会截,但**库不许交出一个没有上限的串** —— 端把它塞进别的容器
33
+ * (通知行、日志、剪贴板)时那道呈现截断就不在了。
34
+ * 🔴 **本上界量的是「交出去的原文长度」,不是端渲出来那一行的最终宽度**(异源对抗复审 R1
35
+ * finding③ 采纳的措辞订正):控制符 / 双向符 / 换行的可见化是**展示消毒**,归端
36
+ * (cli `cleanUntrustedForDisplay`),而消毒会**变长** —— 200 个 ESC 在这里长度是 200,端把它们
37
+ * 逐个转成 `\u001B` 之后是 1200。⇒ **端在自己消毒之后必须再按自己的行宽兜一次底**,别把本常量
38
+ * 读成「拿到手就一定不超过 200 个显示格」。
39
+ * 🔴 那为什么库不干脆先消毒再截(那样最终宽度就守得住)?因为消毒的字符集只能有**一份**:库里消
40
+ * 一遍、端再消一遍,两份字符集必然漂([machine-readable-signal-not-visual-anchor] 同族),而
41
+ * 端那一份还要管别的载体(hook 名 / fleet 行标签)。分工是刻意的:库守「原文不无限长」,
42
+ * 端守「渲出来那一行不超宽」。
43
+ */
44
+ export declare const CLASSIFIER_DENY_DISPLAY_MAX = 200;
45
+ /**
46
+ * 一条分类器拒绝行的**显示名**:三端同形的「这一行说的是哪条命令 / 哪个文件」判定。
47
+ *
48
+ * 为什么在库里(而不是各端自己拼):端要渲的是同一件事(Recent Denials 行 / 通知行 / retry 提示),
49
+ * 判据(读哪个入参键、什么算「在场」、上界多少)三端各写一遍必然各错一遍;而这是**纯判定**、零 IO、
50
+ * 零展示——正合本包的归层。
51
+ *
52
+ * 判据(逐条):
53
+ * · `Bash` ⇒ `args.command`;`Read`/`Write`/`Edit`/`MultiEdit` ⇒ `args.file_path`;
54
+ * `NotebookEdit` ⇒ `args.notebook_path`(见 {@link DISPLAY_ARG_KEY_BY_TOOL});表外工具 ⇒ 工具名;
55
+ * · 取到的值**必须是非空串**(trim 后非空)——非串 / 缺席 / 空串 / 纯空白一律回落工具名。
56
+ * 🔴 回落**不是**「渲一个空行」:一个空的显示名会让那一行看起来像坏了,而工具名至少是真的;
57
+ * · 工具名自身为空 ⇒ `'tool'`(与同文件 {@link classifierDenyNoticeText} 的兜底逐字同款);
58
+ * · 结果长度上界 {@link CLASSIFIER_DENY_DISPLAY_MAX}(超出 ⇒ 截到上界、末位换 `…`)。
59
+ *
60
+ * 🔴 **绝不**在这里做展示消毒(控制符/双向符/换行)——那是端的事,理由见
61
+ * {@link CLASSIFIER_DENY_DISPLAY_MAX} 头注。返回值仍然是 **UNTRUSTED-for-display**。
62
+ * 🔴 本函数**不**回答 retry 粒度:重试哪一只调用、按什么规则铸,是端侧规则面的事
63
+ * (身份锚 = `ClassifierDenyContext.toolCallId`)。
64
+ */
65
+ export declare function classifierDenyDisplay(toolName: string, args: unknown): string;
27
66
  /** 通知行文案(exported for tests;CC 207 三段 text 化)。 */
28
67
  export declare function classifierDenyNoticeText(toolName: string, reason: string | undefined): string;
@@ -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];
@@ -23,7 +23,7 @@
23
23
  | peer:wire 契约 | `@sema-agent/sdk` **>=7.4.0**(value-level,非 type-only;0.48.0 抬版,四条硬理由见 `CHANGELOG.md` 0.48.0 段末的地板影响面账) | `package.json` `peerDependencies` |
24
24
  | peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
25
25
  | runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
26
- | 公开导出面 | **803** 个运行期符号(+ 41 个测试钩;= 工作树当下的值 —— 已发的 `0.49.0` 是 **795**,再加 S-81 五件未发 additive 导出;`0.48.0` 是 **794**,npm `0.47.0` 是 **790**,`0.46.0` 是 **787**,`0.44.0` 是 **783**,`0.43.1`/`0.43.0` 是 **776**,`0.42.0` 是 **771**,`0.41.0` 是 **767**,`0.39.0` 是 **766**,`0.38.0` 是 **764**;`0.37.0` 是 **753**,见 `CHANGELOG.md`) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
26
+ | 公开导出面 | **805** 个运行期符号(+ 41 个测试钩;= 工作树当下的值 —— 已发的 `0.51.0` 是 **803**,再加 L-69⑨ 两件未发 additive 导出;`0.49.0` 是 **795**,再加 S-81 五件未发 additive 导出;`0.48.0` 是 **794**,npm `0.47.0` 是 **790**,`0.46.0` 是 **787**,`0.44.0` 是 **783**,`0.43.1`/`0.43.0` 是 **776**,`0.42.0` 是 **771**,`0.41.0` 是 **767**,`0.39.0` 是 **766**,`0.38.0` 是 **764**;`0.37.0` 是 **753**,见 `CHANGELOG.md`) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
27
27
  | 常驻门 | 以 `scripts/gates-manifest.json` 的 `suites` 长度为准(**本档不抄这个数**) | `scripts/gates-manifest.json`;`npm test` 的名单等值门与它逐名对账 |
28
28
  | 沿革档 | 0.29.0 起建 `CHANGELOG.md`;更早批次记账在 `src/index.ts` 文件头 + `docs/REFACTOR-LEDGER.md` | — |
29
29
 
@@ -113,7 +113,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
113
113
 
114
114
  ## §2 公共导出面地图(按域)
115
115
 
116
- > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**803** 项)。
116
+ > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**805** 项)。
117
117
  > 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
118
118
  > **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
119
119
  > 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
@@ -123,7 +123,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
123
123
 
124
124
  `public-export-baseline.json` 由 **`dist/index.js` 的运行期导出**生成(生成口径自述见
125
125
  `scripts/run-client-core-typeshape-test.mjs`,双向精确集合门在 `scripts/run-public-surface-test.mjs`)。
126
- 实测:803 项 **100% 是运行期导出,零 type-only**。
126
+ 实测:805 项 **100% 是运行期导出,零 type-only**。
127
127
 
128
128
  **推论(端必须知道)**:
129
129
  - barrel 导出的**类型**面比 707 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
@@ -137,12 +137,12 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
137
137
  `WorkflowsGateUnknownDenial` 四形**不在**基线里,`src/selfOrchestrationDenial.ts` 对基线贡献
138
138
  **4** 项运行期导出(三个函数 + `SELF_ORCHESTRATION_RETRY_WITHOUT`)。
139
139
 
140
- 803 项的内部构成(帮助端估读表大小):**237** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
140
+ 805 项的内部构成(帮助端估读表大小):**238** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
141
141
  (矩阵、键集、env 名、锚串)而非可调用物;**5** 项是 PascalCase 运行期值
142
142
  (`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError` / `DecideTransportRetryExhaustedError`);
143
143
  **41** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6;其中 `engineNamespaceKeyFor` 是命名巧合 —— 参数是 baseUrl 不是 sessionKey,见域 14)。
144
144
 
145
- ### 2b. 域图(16 域,逐域计数之和 = 803)
145
+ ### 2b. 域图(16 域,逐域计数之和 = 805)
146
146
 
147
147
  | # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
148
148
  |---|---|---|---|---|---|
@@ -161,7 +161,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
161
161
  | 13 | **座位 IPC 契约** | 33 | `LOCAL_SESSIONS_SPEC` · `SEAT_METHOD_NAMES` · `SEAT_EVENT_TYPES` · `isLocalSessionEvent` · `isToolPermissionRequest` · `toolPermissionRequestId` · `SEAT_VALIDATOR_KEY_COVERAGE` | desktop↔web 座位 IPC 契约的**单一真源**(此前两边各一份、名字零重合 ⇒ 编译器永远不会告诉你它们漂了)。🔴 加 verb 忘了加 `LOCAL_SESSIONS_SPEC` **不报错**:preload 不注册 channel、渲染端读到 `undefined` | `src/seatContract.ts`(**零 import**,纯类型 + 常量 + 纯谓词)。🔴 **证据等级标注(0.42.0,test [5087] 的「语料**种类**缺口」/ cli [5088] 认领件)**:该文件里所有以「CC 如何如何」为形的断言(`212 methods` / `854-channel census` / 方法名逐字保留 / `fQe` 逐字段对照 / 一切 `.vite/build/index.chunk-*.js` 坐标)**证据等级 = 桌面 unpack,本地语料库不可复验** —— 本仓手边可复验的参照语料**只覆盖终端 CLI 形态**的静态产物,拿它去 grep 桌面壳里的符号只会零命中,而零命中在这里**既不证真也不证伪**。复核这些断言**不得**拿本仓语料当反证 |
162
162
  | 14 | **宿主端口与会话槽** | 26 | `installHost` · `installHostFor` · `hostPortMisses(For)` · `DEFAULT_SESSION_KEY` · `hostEnv` · `unrefTimer` · `parseLocaleTag` / `pickUiLanguage`(#244 F4 族D A-028.20:locale tag 手术单源 + UI 语言判定;与 `resolveRegionHint` 双出口成文 —— 语言偏好域 en/zh ≠ 地址可达域 cn/intl/unknown,`zh-Hant` 前者 zh 后者 intl 是设计)· `engineNamespaceKeyFor` / `mergeSessionMapRecord` / `mergeEngineEntry`(A-028.12:会话 id 映射单一键形 + merge 判定;存储经 `SessionMapStorePort` 归端 —— cli 文件锁/原子写,web localStorage)| 进程/端级装配层(settings/fs/queue/timers/session/log/probe),与 per-turn 的 `AdapterContext` **分层**。头注的判定规则:**这个能力每 turn 都会变吗?** 会 ⇒ `ctx`;不会 ⇒ `installHost` | `src/host.ts`、`src/hostEnv.ts`、`src/sessionSlot.ts`、`src/unrefTimer.ts`、`src/env/{localeGeo,localeTag,uiLanguage}.ts`、`src/sessionMap.ts` |
163
163
  | 15 | **控制面与传输** | 82 | `ControlRouter`(+ `ControlSafetyError`)· `makeEngineWireClient` + `resolveWireAuth` · `installEngineWireTarget` · `diagnoseSseIdleTear` / `isSseIdleError` · `attemptActiveRunSelfHeal` + `activeRunBusySignal` + `activeRunSelfHealRow` / `activeRunBusyHeadlessRow` · `kickEngineCapsProbe` / `engineCapTrue` / `invalidateEngineCaps(baseUrl, probe?)`(#307 S25:引擎温切后的 caps 生产失效口 —— kick 自带幂等闸,同 baseUrl 重启后不显式失效就永远读到旧引擎那一版能力位;调用方 = 壳的 respawn/restartEngine。🔴 **推荐两参形**:第二参给替代探测则「推进代际 + 注册新探测」在同一同步块内完成,失效与下一次 kick 之间那个「等待者读到未判」的窗按构造不存在;单参形保留给「只丢缓存、这一刻没有替代探测」的调用方,那种情形下读到未判是诚实结局) · `mapBrainStatusToRetry` · `waitForClaimRelease` + `CLAIM_RELEASED_STATES` / `CLAIM_HELD_STATES` · `atMostOnceFailureClass` / `readSteerDelivery` · `clearRunningChoiceOffer`(Inkglow-1085 P0b①:「Do nothing」登记的清口 —— 端的「重新打开操作菜单」入口;登记在场时 attemptActiveRunSelfHeal 不整卡重弹,not-parked 结局带 `alreadyOffered: true` 判别位,端据此降级渲一行)· `INTERACTIVE_WAY_OUT`(默认出路串单源)· `engineSessionParamFor`(design/285 批 0:`?session=` 派生的 **per-key** 形 —— `hostSessionFor(sessionKey)?.currentSessionId()` + [1501] 空串归一;零参 `engineSessionParam()` = 默认槽兼容层,取值链逐字等价)· `normalizeWirePrincipal`(A-028.10:principal 在场性 trim 原语 —— 全空白=缺席不发头,engineWireTarget 两臂/makeEngineWireClient/壳 livePrincipal 同尺)· `readSessionMemoryStatus` / `classifyMemoryStatusFailure` / `readCaptureOptOut` / `readLastCapture`(S-53 会话记忆姿态读面,0.48.0:失败分诊**码优先**——两个 404 分道 `not_found.session` / `not_found.route`,无码 404 不猜落 failed;五键逐键缺席语义两个合读器,`lastCapture` 三态的判别材料是 `committedCount` 不是本键;IO 归宿主注入 `MemoryStatusClientLike`,详见 §11) · `classifyTurnWireError` / `isWireTransportError` / `isPreStreamDrainingReject` / `isResumeAtRejection` / `drainingRetryDelayMs` / `scenarioDenyFromError` + `WIRE_NETWORK_ERROR_PATTERN`(A-028.11/.13:turn 错误分型判定半场,人话文案与渲染归端) | 上行通道的**监管**半场(submit / steer / kill / 队列命令定序)+ 传输构造、caps 探测、SSE 断流分诊、**409 active-run 自愈** | `src/controlRouter.ts`、`steering.ts`、`sseIdleTriage.ts`、`retryStatus.ts`、`diagnostics.ts`、`engineWireSdk.ts`、`engineWireTarget.ts`、`src/principalWire.ts`、`src/wireErrorTriage.ts`、`engineSessionParam.ts`、`engineCapsCache.ts`、`liveInitToolFace.ts`、`adapter/activeRunSelfHeal.ts`、`src/sessionMemoryStatus.ts` |
164
- | 16 | **引擎词汇表与包自检** | 49 | `CONFIG_REFUSAL_CODES` / `isConfigRefusalCode` · `STOP_CONFLICT_CODES` · `isInterruptedToolEndCode` · `isRewindFamilyCode` · `CLIENT_VERBS` · `compensationSplitViolations` · `DELEGATION_CAP_CODES` / `isDelegationCapCode` / `DELEGATION_CONCURRENCY_CAP` / `DELEGATION_SESSION_CAP`(0.38.0 #318 件④:core 5.48.0 design/323 委派席位到限**两码,处置不对称禁合并** —— 并发帽=**可等**(兄弟结束即有位)/ 会话累计帽=**等也没用**(这条会话的配额用尽))· `CONFIG_DELEGATION_ENTRY_CAPS`(同批入 `CONFIG_REFUSAL_CODES` 识别表)· `delegationCapDispositionOf` / `MCP_SERVER_REVOKED`(0.39.0 载体到货消费件:core 5.50.0 补 `{ error: code, code }` 孪生拼法后两码真上 `tool_end.errorCode`,0.38.0「先立词不落消费分支」的已知局限自此解除;处置轴 `wait-for-slot` / `reuse-existing-or-await-reap` 机器可读(累计帽=retained-window 帐,行回收配额即回,处置=SendMessage 复用,**非**「换会话/永久耗尽」——0.38.0 段该句系勘误),未知 `delegation.*` 码 ⇒ `undefined`;`mcp.server_revoked` = 操作员 mid-session 吊销 server 后的工具面本地闸(被吊销的 server **名**今天不过 wire 境:detail.server 是进程内位,抬升腿只 lift code——归因渲染候 core 补 typed detail,已点名);载体门 = engine-vocab G3 腿锚 core dist 铸点)· `CAPABILITY_SELF_ORCHESTRATION_REQUIRED`(S-81,server 7.57.0:提交面的 selfOrchestration 准入拒绝码。🔴 **复用码** —— 与其它 `capability.*` 501 同体形而处置不同,消费点必须按**恰等**判、绝不放宽成前缀判;判型与「去键重发一次」归 `src/selfOrchestrationDenial.ts`,详见 §13) | 三端分臂共用的**去字面化** `errorCode` 词表(病根正是三端各抄一份字面);编译期 verb 闭合门;搬迁补偿登记表 | `src/engineErrorCodes.ts`(计数以 `scripts/public-export-baseline.json` 为准,别手抄;A-028.11/.13 补 `DRAINING_ERROR_CODE`/`SCENARIO_NOT_ALLOWED_ERROR_CODE`/`RESUME_AT_ERROR_CODE_PREFIX`;#318 件④ 补 `delegation.*` 族四位 + `config.delegation_entry_caps`;0.39.0 补三新码消费件三位);S-81 补 `capability.self_orchestration_required` 一位)、`src/classifierVerdictWire.ts`、`src/compensations.ts`、`src/clientSlice.ts` |
164
+ | 16 | **引擎词汇表与包自检** | 51 | `CONFIG_REFUSAL_CODES` / `isConfigRefusalCode` · `STOP_CONFLICT_CODES` · `isInterruptedToolEndCode` · `isRewindFamilyCode` · `CLIENT_VERBS` · `compensationSplitViolations` · `DELEGATION_CAP_CODES` / `isDelegationCapCode` / `DELEGATION_CONCURRENCY_CAP` / `DELEGATION_SESSION_CAP`(0.38.0 #318 件④:core 5.48.0 design/323 委派席位到限**两码,处置不对称禁合并** —— 并发帽=**可等**(兄弟结束即有位)/ 会话累计帽=**等也没用**(这条会话的配额用尽))· `CONFIG_DELEGATION_ENTRY_CAPS`(同批入 `CONFIG_REFUSAL_CODES` 识别表)· `delegationCapDispositionOf` / `MCP_SERVER_REVOKED`(0.39.0 载体到货消费件:core 5.50.0 补 `{ error: code, code }` 孪生拼法后两码真上 `tool_end.errorCode`,0.38.0「先立词不落消费分支」的已知局限自此解除;处置轴 `wait-for-slot` / `reuse-existing-or-await-reap` 机器可读(累计帽=retained-window 帐,行回收配额即回,处置=SendMessage 复用,**非**「换会话/永久耗尽」——0.38.0 段该句系勘误),未知 `delegation.*` 码 ⇒ `undefined`;`mcp.server_revoked` = 操作员 mid-session 吊销 server 后的工具面本地闸(被吊销的 server **名**今天不过 wire 境:detail.server 是进程内位,抬升腿只 lift code——归因渲染候 core 补 typed detail,已点名);载体门 = engine-vocab G3 腿锚 core dist 铸点)· `CAPABILITY_SELF_ORCHESTRATION_REQUIRED`(S-81,server 7.57.0:提交面的 selfOrchestration 准入拒绝码。🔴 **复用码** —— 与其它 `capability.*` 501 同体形而处置不同,消费点必须按**恰等**判、绝不放宽成前缀判;判型与「去键重发一次」归 `src/selfOrchestrationDenial.ts`,详见 §13) | 三端分臂共用的**去字面化** `errorCode` 词表(病根正是三端各抄一份字面);编译期 verb 闭合门;搬迁补偿登记表 | `src/engineErrorCodes.ts`(计数以 `scripts/public-export-baseline.json` 为准,别手抄;A-028.11/.13 补 `DRAINING_ERROR_CODE`/`SCENARIO_NOT_ALLOWED_ERROR_CODE`/`RESUME_AT_ERROR_CODE_PREFIX`;#318 件④ 补 `delegation.*` 族四位 + `config.delegation_entry_caps`;0.39.0 补三新码消费件三位);S-81 补 `capability.self_orchestration_required` 一位)、`src/classifierVerdictWire.ts`、`src/compensations.ts`、`src/clientSlice.ts` |
165
165
 
166
166
  🔴 **`engineErrorCodes` 的开集纪律**(该文件头注逐字):这些 `ReadonlySet` / 前缀谓词一律是**识别表**,
167
167
  回答的是「我认不认得这个码」,**绝不是**「合法码只有这些」。消费点 `switch` **必须留 `default`**,
@@ -1519,6 +1519,8 @@ CHANGELOG 0.29.0「已知局限」段与相应 JSDoc 都有成文。**别在读
1519
1519
 
1520
1520
  | **P-42** | low @cli @web @desktop | 🆕 **Esc halt 只上收了判定,发射面仍在各端**(0.47.0 件③,刻意的分工不是半成品):`planInteractiveHalt` 给判据与升级码闭集,`POST /v1/runs/:id/interrupt` 的**发射**(以及 `?session=` 供给、超时窗、台账、留痕)仍归端。cli 侧那条「裸 fetch 直拨 interrupt」的网络面豁免,**退役条件就是端接上这个口子**(壳换装不在 0.47.0 批内) | `src/interactiveHalt.ts`;§10 | 按 §10b 的分支表接:判定用本包,发射用端自己的传输腿;🔴 halt 必须排在撕 SSE **之前**(§10c 第 1 条) |
1521
1521
 
1522
+ | **P-43** | med(治理面)@cli @web @desktop | 🆕 **`hooksForWire()` 只守住了本地执行器四条治理腿里的三条**(0.52.0 L-67④ 补了第三条,第四条**在册未修**):cli 仓那份本地执行器(`utils/hooks/hooksConfigSnapshot.ts` 的 `getHooksFromAllowedSources`,**cli 仓坐标不是本仓坐标**)的第四条腿 = `disableAllHooks` 出现在**非** managed 来源(user/project/local)时按 CC 语义降级成「只跑 managed hooks」。本包今天**只读** `policySettings.disableAllHooks` ⇒ 该形在引擎腿上**不成立**(用户把自己的 hooks 关了,引擎照投照跑)。**为什么不做单边近似**:cli 那条腿读的是**合并后**的标量(四源按 policy→user→project→local 后写覆盖前写),而本包 `SettingsPort` 只有 per-source 读口 —— 拿「任一来源为 true」去近似会在「user 写 `true`、local 写 `false`」这一形上**判反**(cli 那边是**不**限制)。忠实复刻要给 `SettingsPort` 加一个合并读口 = **公面改动**(端要跟车实现),属另一批 | `src/hooksWireCaps.ts`(头注「在册缺口」段);§15b/§15d | 端**不要**假定「用户 settings 里的 `disableAllHooks` 会挡住引擎腿的 hooks」——今天只有 **managed(policySettings)** 那一份挡得住。要它落地按 [C162] 令④ 回 C 板提(正位解在包侧:`SettingsPort` 补合并读口 + 本函数补第四条腿) |
1523
+
1522
1524
  ### 7e. 缺口的共同形状(值得单独说)
1523
1525
 
1524
1526
  **P-1 / P-2 / P-3 / P-4 / P-5 / P-6 / P-7 是同一类**:上游(server / SDK)已经把材料铸到 wire 上了,
@@ -2314,3 +2316,133 @@ else {
2314
2316
  最后一轮的 errorCode 带进终帧;为什么该副本工具不可达是引擎的事。plan_review 腿无「无卡直决」形(规则不决 plan),
2315
2317
  `planReviewArm` 不加此臂。
2316
2318
 
2319
+ ---
2320
+
2321
+ ## §15 🆕 分类器拒绝行的现场 + hooks 的第三道 managed 治理门(0.52.0;L-69⑨ / L-67④)
2322
+
2323
+ ### 15a. 件A:`surfaceClassifierDeny` 的 additive 第三参(端怎么接)
2324
+
2325
+ **修前**:引擎侧 auto-mode 分类器 block 掉一次工具调用时,`frameRouter` 的 `classifier-deny` 臂只把
2326
+ `toolName` 与裁决 verdict 交给宿主 ⇒ 端的「最近被拒」面板三行同名(三条被拒的 `Bash` 长一个样),
2327
+ 重试也只能落到「整个 Bash 工具」这个粒度。根因在台账:`gateLedger` 此前**只在 gated `tool_start`**
2328
+ 留 args,而分类器拒绝的工具通常**没被 gate**(引擎侧分类器直接 block,一张 `tool_approval` 帧都不出)。
2329
+
2330
+ **修后**(全部 additive):
2331
+
2332
+ ```ts
2333
+ export interface ClassifierDenyContext {
2334
+ /** 这一只被拒调用的 wire `toolCallId`(retry 粒度的锚;帧上取,恒在场)。 */
2335
+ toolCallId: string
2336
+ /** 这一只调用的 `tool_start` 入参快照;**UNTRUSTED**,缺席 = 台账里没有。 */
2337
+ args?: unknown
2338
+ }
2339
+
2340
+ interface HitlHostSurface {
2341
+ surfaceClassifierDeny(toolName: string, verdict: ClassifierDenyVerdict, ctx?: ClassifierDenyContext): void
2342
+ }
2343
+ ```
2344
+
2345
+ 端侧接法(两步):
2346
+
2347
+ ```ts
2348
+ import { installHitlHostSurface, classifierDenyDisplay } from '@sema-agent/client-core'
2349
+
2350
+ installHitlHostSurface({
2351
+ showNotice, clearNoticeIfCurrent,
2352
+ surfaceClassifierDeny(toolName, verdict, ctx) {
2353
+ // ① 行文案:判定走库(三端同形),**展示消毒走端**。
2354
+ const display = classifierDenyDisplay(toolName, ctx?.args)
2355
+ recentDenials.push({
2356
+ tool: toolName,
2357
+ // 🔴 端的消毒单源(cli: cleanUntrustedForDisplay);库交出来的是**原文**。
2358
+ display: cleanUntrustedForDisplay(display),
2359
+ // ② retry 粒度:身份锚是 toolCallId,规则怎么铸由端定(见 §15d)。
2360
+ callId: ctx?.toolCallId,
2361
+ reason: verdict.reason,
2362
+ })
2363
+ },
2364
+ })
2365
+ ```
2366
+
2367
+ `classifierDenyDisplay(toolName, args)` 的判据(纯函数、零 IO):
2368
+
2369
+ | 工具 | 读的入参键 | 说明 |
2370
+ |---|---|---|
2371
+ | `Bash` | `command` | |
2372
+ | `Read` / `Write` / `Edit` / `MultiEdit` | `file_path` | |
2373
+ | `NotebookEdit` | **`notebook_path`** | 🔴 **不是** `file_path` —— 那是这只工具的入参真形 |
2374
+ | 其余(开集) | —— | 回落工具名 |
2375
+
2376
+ - 取到的值必须是**非空串**(trim 后非空):非串 / 缺席 / 空串 / 纯空白一律回落工具名;
2377
+ - 工具名自身为空 ⇒ `'tool'`;
2378
+ - 工具名归一按「去空白/下划线/连字符 + 小写」(与 `toolNameIsFsWrite` 同姿势),`multi_edit` / `BASH` 都认;
2379
+ - 结果长度上界 `CLASSIFIER_DENY_DISPLAY_MAX`(= 200,导出常量,**别手抄那个数字**),超出截到上界、末位换 `…`。
2380
+
2381
+ ### 15b. 件B:`hooksForWire()` 的第三道 managed 治理门(端不用改,但要知道)
2382
+
2383
+ `hooksForWire()` 此前过了两道 managed 治理门(`disableAllHooks` / `allowManagedHooksOnly`),**漏了**
2384
+ `strictPluginOnlyCustomization`。后果不是「少读一层配置」——管理侧把 hooks 面锁成 plugin-only 之后,
2385
+ 端的**本地执行器**不再跑 user/project/local 的 hooks,而**引擎腿照投照跑**:同一条禁令只在一半的执行面上
2386
+ 成立,而这一半恰好是工具真正执行的那一半。
2387
+
2388
+ 修后判据(与 CC / cli 本地执行器 `isRestrictedToPluginOnly('hooks')` 逐条同形):
2389
+
2390
+ | `policySettings.strictPluginOnlyCustomization` | 结论 |
2391
+ |---|---|
2392
+ | `true` | **锁**(该值锁全部四个可定制面,hooks 在内) |
2393
+ | 数组且含 `"hooks"` | **锁** |
2394
+ | 数组不含 `"hooks"`(如 `["agents"]`) | 不锁(锁的是别的面) |
2395
+ | 缺席 / `false` / 串 / 对象 / 数字 / `[]` | **当未设**(不锁) |
2396
+
2397
+ 「锁」的处置与 `allowManagedHooksOnly` **逐字相同**:`sources = ['policySettings']`,`/goal` 的用户态
2398
+ Stop overlay 一并不投,`hostLog('debug')` 留一行痕。四道门的**顺序**:信任门(最广)→ `disableAllHooks`
2399
+ (恒赢,连 policy 自己的也不投)→ 本门 / `allowManagedHooksOnly`(两者同场时结论同一个,无先后)。
2400
+
2401
+ 🔴 **坏值形不 fail-closed 整条腿**:相邻两道门对 `SettingsPort` **抛出**选 fail-closed(治理策略读不出来 =
2402
+ 未知态 ⇒ 当作有限制);本门守的是「读到了、但值是个坏形」—— 值在手里,方向由字段属主(cli `SettingsSchema`
2403
+ 对该字段的 `.catch(undefined)`,原文口径是 *degrade to unlocked-for-this-field*)定。两件事别混。
2404
+
2405
+ ### 15c. 端必读(三条)
2406
+
2407
+ 1. **老宿主零行为差**(件A):`ctx` 是**可选**第三参。desktop / web 现有的两参
2408
+ `surfaceClassifierDeny(toolName, verdict)` 实现**不需要跟车** —— JS 里多传一个实参不影响两参函数,
2409
+ TS 里「参数少的函数可赋给参数多的签名」是语言规则。反过来**不成立**:端一旦读了 `ctx`,就必须按可选处理。
2410
+ 2. **`ctx.args` 是 UNTRUSTED,库原样交出**:不渲、不截、不消毒。展示消毒(控制符 / 双向符 / 换行可见化)
2411
+ 是**端的单源**(cli `cleanUntrustedForDisplay`)—— 库里消一遍、端再消一遍,两份字符集必然漂。
2412
+ 🔴 **`CLASSIFIER_DENY_DISPLAY_MAX` 量的是「交出去的原文长度」,不是端渲出来那一行的最终宽度**:
2413
+ 端的消毒会**变长**(200 个 ESC 在库里长度是 200,逐个转成 `\u001B` 之后是 1200)⇒ **端消毒之后
2414
+ 必须再按自己的行宽兜一次底**,别把这个常量读成「拿到手就一定不超过 200 个显示格」。
2415
+ 库那一侧保证的是两件、只有两件:原文不无限长;截断**不制造**畸形(边界跨 emoji 代理对时少切一个码元,
2416
+ 绝不把一个代理对切成半只)。🔴 **输入里本来就有的**孤代理项(`JSON.parse('"\ud800"')` 完全合法 ⇒
2417
+ 这种入参真实存在)**原样透出** —— 修好它属展示消毒,单源就是本包导出的 `escapeDisplayControlChars`
2418
+ (`collapseLabel` 的底座),其头注逐字点名这一族。端只要照第 2 条走自己的消毒,这一格就已经守住了。
2419
+ 3. **`ctx.args` 会缺席,那是诚实缺席不是 bug**:台账对每张 `tool_start` 留快照,但那张表**按条目数有界**
2420
+ (`START_ARGS_MAX_ENTRIES = 256`,超容量丢最早)且 `tool_end` 收口即释放;durable re-attach 只消费
2421
+ `runs.events` 的一段,被拒 call 的 `tool_start` 完全可能落在本次连接之外。缺席时 `ctx` **只带
2422
+ `toolCallId`、`args` 键不铸** —— 端按「只有工具名」降级渲,**绝不**据此编一个空 args 出来。
2423
+ 🔴 **「有界」是条目数,不是字节数**(成文的取舍):表里存的是 `ev.args` 的**引用**不是拷贝,而同一份
2424
+ 入参在这一拍照常投影进转录面、本来就活着 ⇒ 表对驻留字节的增量是「一个指针 × 条目数」。单条载荷
2425
+ **没有**字节预算(一次 `Write` 的 `content` 有多大就跟着引用多大一份)——按字节记账要先定义降级形
2426
+ (截断 = 交出一份假入参,不许),属独立一件,在册未做。
2427
+ durable 重放会把 `tool_start` 再送一遍,那是**被淘汰项拿回快照**的机会(库把记账排在渲染去重闸之前),
2428
+ 所以端不必自己缓存 args 去补这一格。
2429
+
2430
+ ### 15d. 射程边界(别把本节读成比它更强)
2431
+
2432
+ - **retry 粒度不在包内**:库交出的是身份(`ctx.toolCallId`)与行文案判定(`classifierDenyDisplay`)。
2433
+ 「重试这一只调用」具体怎么做 —— 铸一条 `Bash(git status:*)` 形的规则?按 callId 重放?只在面板上提示?——
2434
+ 是**端侧规则面**的铸法,各端的规则存储与卡面都不同,库不替它们决定,也不提供「按 callId 重试」的动词。
2435
+ - **本包不改模型面**:`ctx` 只走宿主呈现/记账通道;喂回模型的那一份仍由引擎的 wire 承载,一个字节不碰。
2436
+ - **件B 只补第三道门**:cli 本地执行器还有**第四条**腿 —— `disableAllHooks` 出现在**非** managed 来源时
2437
+ 降级成「只跑 managed hooks」。本包今天只读 `policySettings.disableAllHooks` ⇒ 该形在引擎腿上**不成立**,
2438
+ 这是**在册缺口**(登记在 `src/hooksWireCaps.ts` 头注 + §7d)。不做单边近似的理由:cli 那条腿读的是
2439
+ **合并后**的标量(四源后写覆盖前写),而本包 `SettingsPort` 只有 per-source 读口,拿「任一来源为 true」
2440
+ 去近似会在「user 写 true、local 写 false」这一形上判反(cli 那边是**不**限制)。忠实复刻需要给
2441
+ `SettingsPort` 加一个合并读口 = **公面改动**,属另一批。
2442
+
2443
+ **cli / web / desktop 认领**:cli 侧接点(Recent Denials 行 display + retry 粒度)在其下一批(表态制);
2444
+ web / desktop 无需动作(老宿主零行为差),件B 对三端都是治理面收紧、零签名改动。
2445
+ **实现锚**:`src/hitl/gateLedger.ts`(有界在飞表三动词)、`src/hitl/frameRouter.ts`(deny 臂 + 释放时序)、
2446
+ `src/hitl/hitlHostSurface.ts`(`ClassifierDenyContext`)、`src/classifierVerdictWire.ts`
2447
+ (`classifierDenyDisplay` / `CLASSIFIER_DENY_DISPLAY_MAX`)、`src/hooksWireCaps.ts`(第三道门)。
2448
+ **常驻门**:`scripts/run-client-core-pure-test.mjs`(B6 段 L-67④ 16 条 / B7 段 L-69⑨ 31 条)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",