@sema-agent/client-core 0.30.0 → 0.30.2

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.
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * armedGateRegistry.ts — per-session 呈现台账:哪些决断卡在**这个宿主进程的哪个会话**里真的
3
3
  * 呈现过(A-028.3,#244 族A 包半场,2026-08-12;源形 = cli `src/sema/armedGateRegistry.ts`,
4
- * 上收时由模块级单 Set 改为 sessionSlot per-session 键,支持多会话宿主)。
4
+ * 上收时由模块级单 Set 改为 sessionSlot per-session 键,支持多会话宿主。二段(#244 F1 换装批,
5
+ * 2026-08-14):壳侧剩余两位 —— **arm 事件源**(呈现回执)与 **plan_review 呈现分代** —— 一并
6
+ * 上收,壳侧台账本体退役)。
5
7
  *
6
8
  * ── 为什么需要它 ────────────────────────────────────────────────────────────────────────────
7
9
  * 409 自愈重开臂(`adapter/activeRunSelfHeal.ts` 的分诊消费方)此前对每一张 pending 卡都说
@@ -20,25 +22,49 @@
20
22
  * {@link registerArmedGateFromQuestionId} 剥前缀取键(= callKey,与卡键同域)。
21
23
  * · plan_review:`planReviewQuestionId(taskId)` 整串作键(保留前缀,与 taskId 直接作 ask 键
22
24
  * 永不撞域);重开腿铸 `…#reopen-*`,归一化剥尾 —— 首呈与重开落同一键。
25
+ * 🔴 A-024.4(#250,#244 F1 起分代在包):wire 上没有 gate 实例位([3664] pendingGate 只有
26
+ * kind/decidePath),同 run 推进到第二只 plan 门时,恒定键会让 firstSight 恒 false ⇒ 对一张
27
+ * 从未呈现过的新卡编造「被关过」历史。plan 键因此按**呈现代数**分代(见
28
+ * {@link planReviewArmedKeyFor} / {@link notePlanReviewAnsweredFor}):0 代键与 canonical
29
+ * 字节同形,≥1 代接 `#g<N>` 尾;决断性作答推代(消费当代门),下一只门读回首见。
23
30
  *
24
31
  * ── 登记点(「没呈现就不算 arm」)───────────────────────────────────────────────────────────
25
32
  * ① 宿主呈现面:overlay 收到可渲染 question 帧时 / 卡口真把卡 enqueue 进渲染队列时,宿主调
26
33
  * {@link registerArmedGateFromQuestionId} / {@link registerArmedGate}(auto-allow/deny/
27
34
  * 失败臂不登记)。包的 publish 点**不**代登记 —— publish 无 overlay 时是 no-op,代登记 =
28
35
  * 把「发过帧」谎报成「呈现过」。
29
- * ② 重开腿:先读(firstSight 判决)后写(铸卡即 arm)—— 同一张卡第二次撞 409 才说 reopened。
30
- * 包内 `planReviewWire.reopenPlanReviewCard` 与壳侧 ask 重开腿都按此序。
31
- * ③ 消费点:plan_review 决断**递交后**清键({@link clearArmedGate};A-024.4 —— run 的下一
32
- * plan gate 必须读回首见,键粒度是 taskId,不清就把新门谎成复见)。ask 族键粒度是
33
- * callId( gate 唯一),无此问题,不清。
36
+ * ② 重开腿:先读(firstSight 判决)后写。🔴 回执模式(`planReviewWire`
37
+ * `presentationReceiptMs`,#250 件1 语义)下重开腿**零登记**:登记时点 = 宿主呈现面的真
38
+ * 入队点( 的调用),零回执还登记 = 下次谎称复见。
39
+ * 消费点:plan_review 的**决断性**作答(approve/reject)经 {@link notePlanReviewAnswered}
40
+ * 族推代(当代键同时清掉,`clearArmedGate` 的旧语义在 0 代保持逐字兼容);dismissal
41
+ * (Esc/空答)不推代 —— 门没被消费,下次重开要照实说「reopened」。ask 族键粒度是
42
+ * callId(每 gate 唯一),无此问题,不消费。
43
+ *
44
+ * ── arm 事件源(#250 件1「呈现回执」;#244 F1 上收)────────────────────────────────────────
45
+ * `registerArmedGate` 是「卡真呈现了」的唯一汇聚点,所以重开腿的回执 = 等它的**调用事件**
46
+ * ({@link waitForGateArmed})。事件按每次调用发(不是集合成员测试)—— 复见重开的第二次呈现
47
+ * 同样要有回执;集合本身 arm 一次恒真,当不了事件源。{@link onGateArmed} 是**无窗**订阅位
48
+ * (#269 tool 门臂问的是「这条链的整个寿命里这张卡到底呈现过没有」,带窗回执答不了它)。
49
+ * 尝试级回执:{@link registerArmedGateFromQuestionId} 对**原始帧 id**(带 `#reopen-*` 尾)在
50
+ * 归一键之外事件级加发一枪(不入 Set)—— 同 gate 两次在飞重开时,一次真入队只唤对应那次尝试。
51
+ * 🔴 承重前提(server [3664]③):local 部署形 attach 重连恒零卡帧补发 —— 回执必须宿主进程内闭环。
34
52
  *
35
53
  * per-session 语义:sessionSlot 注册表(desktop session-host 多引擎会话互不串账);零参 API =
36
54
  * DEFAULT_SESSION_KEY 兼容层,单会话宿主(cli)装配一行不动。callId 全局唯一,跨 /clear 不撞键。
37
55
  */
38
56
  import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
39
- import { armedKeyFromQuestionId } from './gateIdentity.js';
57
+ import { armedKeyFromQuestionId, PLAN_REVIEW_QUESTION_ID_PREFIX, planReviewQuestionId, REOPEN_ID_TAIL } from './gateIdentity.js';
40
58
  // 🔴 模块级单例(singleton-manifest 登记):per-session 键 → 已呈现身份键集。
41
59
  const armedGatesByKey = createSessionSlot();
60
+ // 🔴 同为模块级单例:per-session 键 → 监听器集(两份实例 ⇒ 登记面与等待面各持一半,回执恒 miss)。
61
+ const armedListenersByKey = createSessionSlot();
62
+ // ── plan_review 呈现分代(A-024.4;#244 F1 上收,语义照壳现实现)──────────────────────────────
63
+ /** per-session:taskId → 已消费的 plan 门数(= 当前代数;0 代键保持与 canonical 字节同形)。 */
64
+ const planReviewGenByKey = createSessionSlot();
65
+ /** per-session:已记过账的 plan 问答卡 id(答卡两条通路 —— overlay answerAndRelease 与 responder
66
+ * —— 会对同一张卡各报一次,按完整 questionId 去重,恰推一代)。 */
67
+ const notedPlanReviewIdsByKey = createSessionSlot();
42
68
  function setFor(sessionKey) {
43
69
  let s = armedGatesByKey.get(sessionKey);
44
70
  if (!s) {
@@ -47,14 +73,52 @@ function setFor(sessionKey) {
47
73
  }
48
74
  return s;
49
75
  }
50
- /** 登记一个已呈现的决断卡身份键(空/非法输入静默忽略 —— 登记面绝不炸渲染链) */
76
+ function listenersFor(sessionKey) {
77
+ let s = armedListenersByKey.get(sessionKey);
78
+ if (!s) {
79
+ s = new Set();
80
+ armedListenersByKey.set(sessionKey, s);
81
+ }
82
+ return s;
83
+ }
84
+ function genMapFor(sessionKey) {
85
+ let m = planReviewGenByKey.get(sessionKey);
86
+ if (!m) {
87
+ m = new Map();
88
+ planReviewGenByKey.set(sessionKey, m);
89
+ }
90
+ return m;
91
+ }
92
+ function notedIdsFor(sessionKey) {
93
+ let s = notedPlanReviewIdsByKey.get(sessionKey);
94
+ if (!s) {
95
+ s = new Set();
96
+ notedPlanReviewIdsByKey.set(sessionKey, s);
97
+ }
98
+ return s;
99
+ }
100
+ /** 事件级发一枪(不动 Set)。监听器抛错不许炸登记面 —— 回执面是诊断/判决辅助面。 */
101
+ function emitGateArmedEventFor(sessionKey, key) {
102
+ for (const listener of [...listenersFor(sessionKey)]) {
103
+ try {
104
+ listener(key);
105
+ }
106
+ catch {
107
+ /* 绝不反噬呈现链 */
108
+ }
109
+ }
110
+ }
111
+ /** 登记一个已呈现的决断卡身份键(空/非法输入静默忽略 —— 登记面绝不炸渲染链)。
112
+ * 每次调用都发事件(复见重开的第二次呈现也要有回执;Set 成员级当不了事件源)。 */
51
113
  export function registerArmedGate(key) {
52
114
  registerArmedGateFor(DEFAULT_SESSION_KEY, key);
53
115
  }
54
116
  /** W1 带 key 变体(多会话宿主每会话一键,互不串账)。 */
55
117
  export function registerArmedGateFor(sessionKey, key) {
56
- if (typeof key === 'string' && key.length > 0)
57
- setFor(sessionKey).add(key);
118
+ if (typeof key !== 'string' || key.length === 0)
119
+ return;
120
+ setFor(sessionKey).add(key);
121
+ emitGateArmedEventFor(sessionKey, key);
58
122
  }
59
123
  /** 该身份键的卡在本进程本会话呈现过吗?(false = 首见;文案分形的唯一判据) */
60
124
  export function wasGateArmed(key) {
@@ -66,7 +130,8 @@ export function wasGateArmedFor(sessionKey, key) {
66
130
  return false;
67
131
  return armedGatesByKey.get(sessionKey)?.has(key) === true;
68
132
  }
69
- /** 消费一个身份键(A-024.4:plan_review 决断递交后清键 —— taskId 的下一个 plan gate 读回首见)。 */
133
+ /** 消费一个身份键(plan 族的决断消费请优先走 {@link notePlanReviewAnswered} —— 它同时推代;
134
+ * 本口保留给「只清账不推代」的宿主场景与 0.30.0 存量消费方,语义不变)。 */
70
135
  export function clearArmedGate(key) {
71
136
  clearArmedGateFor(DEFAULT_SESSION_KEY, key);
72
137
  }
@@ -75,8 +140,92 @@ export function clearArmedGateFor(sessionKey, key) {
75
140
  if (typeof key === 'string' && key.length > 0)
76
141
  armedGatesByKey.get(sessionKey)?.delete(key);
77
142
  }
143
+ /**
144
+ * 订阅 arm 事件(#269 上收):返回退订钩,调用方**必须**在自己的生命周期末调它(监听器挂在
145
+ * 模块级长存表上,漏退 = 闭包泄漏)。
146
+ *
147
+ * 🔴 为什么不复用 {@link waitForGateArmed}:那是**带窗**的一次性回执(超时即 resolve false),
148
+ * 它答的是「看门狗窗内呈现了没有」;#269 的 tool 门臂另外要问一个**无窗**的事实 ——「这条链的
149
+ * 整个寿命里,这张卡到底有没有呈现过」。用带窗的那只当事实源,会在「迟到卡」(窗后才入队)上
150
+ * 给出 false ⇒ 把一次真呈现误判成「行从未出生」⇒ 回环再铸一张卡,同一个待决项两张可按的卡。
151
+ */
152
+ export function onGateArmed(listener) {
153
+ return onGateArmedFor(DEFAULT_SESSION_KEY, listener);
154
+ }
155
+ /** W1 带 key 变体。 */
156
+ export function onGateArmedFor(sessionKey, listener) {
157
+ const listeners = listenersFor(sessionKey);
158
+ listeners.add(listener);
159
+ return () => {
160
+ listeners.delete(listener);
161
+ };
162
+ }
163
+ /**
164
+ * 呈现回执的缺省看门狗窗(#250 件1;重开臂共用单源,免得每臂各持一个会漂的数)。要盖住的最慢
165
+ * 真实成功路:tool 门臂整链重跑一次(attempt→2s 退避→attempt,各含一跳 approvals.list)与
166
+ * question 臂 overlay 的 PermissionRequest hooks 窗。
167
+ */
168
+ const GATE_ARMED_WAIT_MS = 5000;
169
+ let gateArmedWaitOverrideMs = null;
170
+ /** 测试用:缩短回执看门狗(null 复位)。 */
171
+ export function _setGateArmedWaitMsForTest(ms) {
172
+ gateArmedWaitOverrideMs = ms;
173
+ }
174
+ /** 回执看门狗现值(重开臂的缺省等待窗)。 */
175
+ export function gateArmedWaitMs() {
176
+ return gateArmedWaitOverrideMs ?? GATE_ARMED_WAIT_MS;
177
+ }
178
+ /**
179
+ * 等「这几个键里任意一个被 arm」的**事件**(#250 件1 呈现回执)。resolve true = 回执到手;
180
+ * false = 超时(呈现链没走到真入队 —— hook 自动应答/去重丢帧/enqueue 失败/处理器僵死)。
181
+ *
182
+ * 🔴 timer **不 unref**(包 `abortableSleep` 同判据):调用方(重开臂)正 await 本 promise 收
183
+ * verdict —— 回执窗里这只 timer 可能是事件循环里唯一的活,unref 会让 `-p` 车道进程在 await
184
+ * 中途直接退出,诚实的 reopen-failed 行一并蒸发。代价上限 = 一次 timeoutMs 的进程存活延长,
185
+ * 换判决必达。(浏览器宿主无 unref 概念,裸 setTimeout 两端行为一致 —— portability 零分支。)
186
+ */
187
+ export function waitForGateArmed(keys, timeoutMs) {
188
+ return waitForGateArmedFor(DEFAULT_SESSION_KEY, keys, timeoutMs);
189
+ }
190
+ /** W1 带 key 变体。 */
191
+ export function waitForGateArmedFor(sessionKey, keys, timeoutMs) {
192
+ const wanted = new Set(keys.filter((k) => typeof k === 'string' && k.length > 0));
193
+ if (wanted.size === 0)
194
+ return Promise.resolve(false);
195
+ const listeners = listenersFor(sessionKey);
196
+ return new Promise((resolve) => {
197
+ const listener = (key) => {
198
+ if (!wanted.has(key))
199
+ return;
200
+ listeners.delete(listener);
201
+ clearTimeout(timer);
202
+ resolve(true);
203
+ };
204
+ listeners.add(listener);
205
+ const timer = setTimeout(() => {
206
+ listeners.delete(listener);
207
+ resolve(false);
208
+ }, timeoutMs);
209
+ });
210
+ }
211
+ /**
212
+ * question 帧 id → 台账键(登记口用):基础归一(剥 ask 前缀/剥重开尾)= `gateIdentity.
213
+ * armedKeyFromQuestionId`;plan_review 形在其上叠**呈现代数**(A-024.4)—— 登记与查询都落在
214
+ * **当代**键上。不认识的形原样入册(未来新 id 形至多多占一个键,不误伤既有词汇)。
215
+ */
216
+ function armedKeyForQuestionIdFor(sessionKey, questionId) {
217
+ const base = armedKeyFromQuestionId(questionId);
218
+ if (base.startsWith(PLAN_REVIEW_QUESTION_ID_PREFIX)) {
219
+ return planReviewArmedKeyFor(sessionKey, base.slice(PLAN_REVIEW_QUESTION_ID_PREFIX.length));
220
+ }
221
+ return base;
222
+ }
78
223
  /** 宿主呈现面登记口:收到可渲染 question 帧即记(键归一见 gateIdentity;坏形静默忽略 ——
79
- * id 是 wire/合成位,入参按边界收 unknown,本函数就是窄化动作本身)。 */
224
+ * id 是 wire/合成位,入参按边界收 unknown,本函数就是窄化动作本身)。
225
+ *
226
+ * 尝试级回执(#250 codex 轮二 [medium]):原始帧 id 自带 `#reopen-*` 尝试序号,是现成的
227
+ * attempt token —— 归一键之外**事件级**再发一枪原始 id(不入 Set,台账词汇保持归一键)。
228
+ * 重开臂锚它,同 gate 两次在飞重开时一次真入队只唤对应那次尝试,不再同键互唤。 */
80
229
  export function registerArmedGateFromQuestionId(questionId) {
81
230
  registerArmedGateFromQuestionIdFor(DEFAULT_SESSION_KEY, questionId);
82
231
  }
@@ -84,9 +233,72 @@ export function registerArmedGateFromQuestionId(questionId) {
84
233
  export function registerArmedGateFromQuestionIdFor(sessionKey, questionId) {
85
234
  if (typeof questionId !== 'string' || questionId.length === 0)
86
235
  return;
87
- registerArmedGateFor(sessionKey, armedKeyFromQuestionId(questionId));
236
+ // 🔴 canonical 复用的去重记号过期(codex #244 F1 轮一 [medium]):arm 臂对同 run 每只 plan gate
237
+ // 都复用 `plan-review:<taskId>` 这个 canonical id —— 决断记账按完整 questionId 去重,若记号不随
238
+ // 卡换代过期,第二只 canonical 卡的决断会撞上第一只留下的记号 ⇒ 不推代 ⇒ 第三只门的重开又谎报
239
+ // 「被关过」。**新 canonical 卡的呈现**就是「上一张同 id 卡已消解、这是新实例」的宿主侧可见时刻,
240
+ // 在此把旧记号过期;reopen 尾 id 进程内唯一,记号永不相撞,不用过期。方向:同一张卡「两路各报
241
+ // 一次」之间夹进一次同 id 重呈会双推代 —— 误差落首见话术(零历史断言),诚实安全侧。
242
+ if (questionId.startsWith(PLAN_REVIEW_QUESTION_ID_PREFIX) && !questionId.includes(REOPEN_ID_TAIL)) {
243
+ notedPlanReviewIdsByKey.get(sessionKey)?.delete(questionId);
244
+ }
245
+ const normalized = armedKeyForQuestionIdFor(sessionKey, questionId);
246
+ registerArmedGateFor(sessionKey, normalized);
247
+ if (questionId !== normalized)
248
+ emitGateArmedEventFor(sessionKey, questionId);
249
+ }
250
+ /**
251
+ * plan_review 的台账键(当代)。0 代 = `planReviewQuestionId(taskId)`(canonical 原形 ——
252
+ * overlay 对**原卡**(done 帧首扎)的登记落进同一键);≥1 代 = 同形 + `#g<N>` 尾。
253
+ */
254
+ export function planReviewArmedKey(taskId) {
255
+ return planReviewArmedKeyFor(DEFAULT_SESSION_KEY, taskId);
256
+ }
257
+ /** W1 带 key 变体。 */
258
+ export function planReviewArmedKeyFor(sessionKey, taskId) {
259
+ const gen = planReviewGenByKey.get(sessionKey)?.get(taskId) ?? 0;
260
+ const canonical = planReviewQuestionId(taskId);
261
+ return gen > 0 ? `${canonical}#g${String(gen)}` : canonical;
262
+ }
263
+ /**
264
+ * 记「一张 plan 问答卡被答掉了」⇒ 当代门已被消费,同 run 的**下一只** plan 门是新实例,落新键。
265
+ *
266
+ * ── 为什么锚在「答卡」而不是「决断送达」(局限成文,A-024.4)────────────────────────────────
267
+ * wire 上没有 gate 实例位,宿主能看见的最晚可靠时刻是作答(送达成败在 responder 返回之后才
268
+ * 揭晓)。所以按「答卡」推代。方向代价:决断若终败(重试后仍没送达),门 1 其实还 pending,
269
+ * 下一次重开会判成首见 ——「呈上了一张卡」零历史断言,诚实方向安全;反向(不推代)才会编造
270
+ * 「被关过」。当代键同时从台账清掉(`clearArmedGate` 的 0.30.0 语义在 0 代逐字保持:决断递交后
271
+ * `wasGateArmed(canonical)` 读回 false)。
272
+ *
273
+ * 🔴 决断性门控请走 {@link notePlanReviewAnsweredIfDecisive}(`planReviewWire`):Esc/空答是
274
+ * dismissal 不是决断 —— 那时推代会把「被关过」翻成「首见」,恰是 A-024.4 在最常见路径(Esc
275
+ * 关卡)上要保住的区分。本口只做记账,不做分类。
276
+ */
277
+ export function notePlanReviewAnswered(questionId) {
278
+ notePlanReviewAnsweredFor(DEFAULT_SESSION_KEY, questionId);
279
+ }
280
+ /** W1 带 key 变体。 */
281
+ export function notePlanReviewAnsweredFor(sessionKey, questionId) {
282
+ if (typeof questionId !== 'string' || !questionId.startsWith(PLAN_REVIEW_QUESTION_ID_PREFIX))
283
+ return;
284
+ const noted = notedIdsFor(sessionKey);
285
+ if (noted.has(questionId))
286
+ return;
287
+ noted.add(questionId);
288
+ const base = questionId.slice(PLAN_REVIEW_QUESTION_ID_PREFIX.length);
289
+ const cut = base.indexOf(REOPEN_ID_TAIL);
290
+ const taskId = cut >= 0 ? base.slice(0, cut) : base;
291
+ if (taskId.length === 0)
292
+ return;
293
+ // 当代键先清(0 代 = canonical,保持 0.30.0 clear-on-decide 的读面语义)再推代。
294
+ clearArmedGateFor(sessionKey, planReviewArmedKeyFor(sessionKey, taskId));
295
+ const gens = genMapFor(sessionKey);
296
+ gens.set(taskId, (gens.get(taskId) ?? 0) + 1);
88
297
  }
89
- /** 测试用:清全部会话的台账。 */
298
+ /** 测试用:清全部会话的台账(含监听器/代数/答卡记账 —— 半清会让跨用例判决互相污染)。 */
90
299
  export function _resetArmedGateRegistryForTest() {
91
300
  armedGatesByKey.clear();
301
+ armedListenersByKey.clear();
302
+ planReviewGenByKey.clear();
303
+ notedPlanReviewIdsByKey.clear();
92
304
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * hitl/localAllowRule.ts — durable park 腿「不再询问」第三态的**本地落规则判定骨架**
3
+ * (#244 F2 上收件,clay 硬排期;源形 = cli `src/sema/localAllowRuleWrite.ts` 的
4
+ * `parseLocalAllowRule` + 窄化五步。settings 写入、内存权限态、托管策略读口全部留宿主)。
5
+ *
6
+ * ── 为什么这半场是三端公共的 ────────────────────────────────────────────────────────────────
7
+ * durable park 腿的候选走行上的 `PendingCheckpoint.ruleSuggestions` → 卡入参**只读键**
8
+ * `ruleSuggestionsReadOnly`,而 `/decide` 的体(SDK `AskDecisionBody`)是**闭集、无规则位**——
9
+ * 兑付口只能是**客户端本地** settings。哪些候选可本地兑付、写下去的 canonical 字节是什么,
10
+ * 是纯**形**判定:desktop/web 接到同一份行供给时要的正是同一把窄化(各写各的必漂,而漏一步
11
+ * 就是跨会话任意代码放行 —— [3925] P0 解释器族绕过案的教训)。
12
+ *
13
+ * ── 🔴 五道窄化(本地写入独有;wire 兑付腿没有,那条只搬字节给 server)────────────────────────
14
+ * 1. **整工具规则不写**。`Bash` / `Bash(*)` 解析后退化成整工具 allow,比这只 ask 的动词宽得多
15
+ * (CC `suppressAlwaysAllowRule` 同理;durable 行上没有这一位过境,按**收紧方向**自守)。
16
+ * 2. **规则得是给这只 ask 的那个工具的**(`expectedToolName`)。规则文本是**引擎**铸的,可能用
17
+ * 引擎侧 wire 工具名(`file_write(/tmp/x)` 解析得干干净净,写进 settings 后**永远匹配不上**)
18
+ * —— 落盘不报错、也不生效的死规则,比拒绝更坏。
19
+ * 3. **没有字面锚的规则不写**。`Bash( * )` / `Bash(**)` 等拼法 ruleContent **非空**却与被禁的
20
+ * `Bash(*)` 完全等效(宿主匹配器先 trim 再把未转义 `*` 展成 `.*`)。判据锚在「这条规则还有
21
+ * 没有约束力」:去掉全部**未转义**通配符后必须还剩字面字符(转义星 `\*` 是字面星号,照留)。
22
+ * 5. **解释器/包装器裸前缀不写**(`Bash(bash:*)` / `Bash(sudo:*)` …):过得了窄化③(有字面锚)
23
+ * 却等于放行任意命令(`bash -c "任何东西"`)。谓词经 {@link LocalAllowRuleDeps} 注入 ——
24
+ * 策略表本体(BARE_SHELL_PREFIXES / dangerousPatterns)留宿主,绝不在包内自建第二份名单。
25
+ * 5b. **canonical 危险规则谓词叠加**([3925] P0 跟修):⑤ 只覆盖 shell/wrapper,而
26
+ * python/node/npx/eval/exec/ssh 等**解释器族**的规则形同样等于任意代码放行(`python -c`)。
27
+ * 同经 deps 注入宿主的 `isDangerous{Bash,PowerShell}Permission`(auto-mode 入口同一把谓词)。
28
+ * ⑤ 不撤:它做 basename/.exe 路径归一(`/bin/bash`),canonical 谓词是纯字面形不做归一 ——
29
+ * 两把互补,各管一族拼法。
30
+ * (④ 托管策略是**可变量**,刻意不在本骨架里 —— 解析必须纯:同一串输入永远同一个结论,否则
31
+ * 已上屏的 option value 会反查不到,用户的选择被丢掉。策略由宿主在渲染面与写入面各自查。)
32
+ *
33
+ * 🔴 **纯函数 / 只看形**:不读 settings、不读宿主状态、不读策略;deps 注入的谓词也必须是
34
+ * 纯函数(pattern 表 + 启动期常量),否则「渲档位」与「按 value 反查候选」两处调用会漂。
35
+ * 🔴 **错误文案是契约面**:cli 常驻套(localRuleSuggestionsCard,128+ 断言)对拒绝集逐字锚 ——
36
+ * 上收前后拒绝集必须逐字相等,改一处文案就是改三端的可观察行为。
37
+ * 🔴 UNTRUSTED:`rule` 是 wire 串(core `inlineUntrusted` + server `redactSecrets` 之后)。它只被
38
+ * 喂给宿主自己的规则解析器与 settings 写口,不回喂模型、不进工具入参、不当代码执行。
39
+ */
40
+ import type { PermissionRuleValue } from '@sema-agent/agent-types';
41
+ export type { PermissionRuleValue };
42
+ /**
43
+ * 宿主注入面(parkOwnership deps 同形:缺一不可的**纯函数**切片,策略表本体留宿主)。
44
+ * · `parseRule` / `formatRule`:宿主的规则语法(CC `Tool(content)` 形 + 转义 + legacy 工具名
45
+ * 归一)。解析失败**抛**(骨架把它折成 `ok:false`);`formatRule` 的产物 = canonical 字节
46
+ * (真正会被写进 settings 的那串,渲染面必须渲它而不是 wire 原文)。
47
+ * · `bashRuleContentHasDangerousBarePrefix`:窄化⑤ 的谓词(宿主 BARE_SHELL_PREFIXES 表,
48
+ * 含 basename/.exe 路径归一)。
49
+ * · `isDangerousBashPermission` / `isDangerousPowerShellPermission`:窄化⑤b 的 canonical 危险
50
+ * 规则谓词(宿主 dangerousPatterns 表;谓词自己按 toolName 分派,非 Bash/PowerShell 恒 false)。
51
+ */
52
+ export interface LocalAllowRuleDeps {
53
+ parseRule(rule: string): PermissionRuleValue;
54
+ formatRule(value: PermissionRuleValue): string;
55
+ bashRuleContentHasDangerousBarePrefix(ruleContent: string): boolean;
56
+ isDangerousBashPermission(toolName: string, ruleContent: string): boolean;
57
+ isDangerousPowerShellPermission(toolName: string, ruleContent: string): boolean;
58
+ }
59
+ /**
60
+ * 解析结局。`ok:false` = 这条候选**不可本地兑付**(渲染面据此不渲该档)。
61
+ * `canonical` = 真正会被写进 settings 的那串字节(`deps.formatRule` 的产物)——
62
+ * 🔴 渲染面**必须渲它**而不是 wire 原文:两者在带括号的命令上会差一层转义,渲原文就等于
63
+ * 「展示的规则和落地的规则不是同一串」。
64
+ */
65
+ export type LocalAllowRuleParse = {
66
+ ok: true;
67
+ value: PermissionRuleValue;
68
+ canonical: string;
69
+ } | {
70
+ ok: false;
71
+ error: string;
72
+ };
73
+ /**
74
+ * 规则原文 → 宿主规则空间的 `PermissionRuleValue`,带文件头注的**形**类窄化(①②③⑤⑤b)。
75
+ *
76
+ * @param expectedToolName 这只 ask 自己的工具名(卡的 `tool.name`)。给了就必须相等 —— 见头注
77
+ * 窄化②:引擎侧 wire 工具名(`file_write` 之类)解析得干净但在宿主的规则空间里是死规则。
78
+ * **缺省不校**:纵深第二道(写口)不知道也不该猜「这只 ask 是哪个工具」,那是卡面的知识。
79
+ */
80
+ export declare function parseLocalAllowRule(rule: string, expectedToolName: string | undefined, deps: LocalAllowRuleDeps): LocalAllowRuleParse;
@@ -0,0 +1,96 @@
1
+ /** 规则空间里 Bash 工具的名字(窄化⑤ 只对这一族适用;⑤b canonical 谓词管 PS 族)。 */
2
+ const BASH_TOOL_NAME_FOR_RULES = 'Bash';
3
+ /**
4
+ * 规则原文 → 宿主规则空间的 `PermissionRuleValue`,带文件头注的**形**类窄化(①②③⑤⑤b)。
5
+ *
6
+ * @param expectedToolName 这只 ask 自己的工具名(卡的 `tool.name`)。给了就必须相等 —— 见头注
7
+ * 窄化②:引擎侧 wire 工具名(`file_write` 之类)解析得干净但在宿主的规则空间里是死规则。
8
+ * **缺省不校**:纵深第二道(写口)不知道也不该猜「这只 ask 是哪个工具」,那是卡面的知识。
9
+ */
10
+ export function parseLocalAllowRule(rule, expectedToolName, deps) {
11
+ if (typeof rule !== 'string' || rule === '') {
12
+ return { ok: false, error: 'rule text is empty' };
13
+ }
14
+ let value;
15
+ try {
16
+ value = deps.parseRule(rule);
17
+ }
18
+ catch (e) {
19
+ return { ok: false, error: `rule text did not parse (${String(e)})` };
20
+ }
21
+ if (typeof value.toolName !== 'string' || value.toolName === '') {
22
+ return { ok: false, error: 'rule text carries no tool name' };
23
+ }
24
+ // 窄化①:整工具 allow 比这只 ask 的动词宽得多(CC suppressAlwaysAllowRule 同理)。
25
+ // `Bash(*)` 也走这里 —— 解析器把它归一成整工具形,所以判据只有一条「有没有 ruleContent」。
26
+ if (value.ruleContent === undefined || value.ruleContent === '') {
27
+ return {
28
+ ok: false,
29
+ error: 'candidate is a whole-tool allow rule (broader than this ask) — not offered locally',
30
+ };
31
+ }
32
+ // 窄化②:规则得是给**这只 ask 的那个工具**的(否则是一条落盘不报错、也永远不生效的死规则)。
33
+ if (expectedToolName !== undefined && value.toolName !== expectedToolName) {
34
+ return {
35
+ ok: false,
36
+ error: `candidate names tool "${value.toolName}" but this ask is for "${expectedToolName}" — a rule for another tool would never match`,
37
+ };
38
+ }
39
+ // 窄化③:去掉未转义通配符后必须还剩字面字符,否则这条规则与整工具放行等效(见头注 3)。
40
+ if (!hasLiteralAnchor(value.ruleContent)) {
41
+ return {
42
+ ok: false,
43
+ error: 'candidate is all wildcard (matches every command for this tool) — equivalent to a whole-tool allow, not offered locally',
44
+ };
45
+ }
46
+ // 窄化⑤:解释器/包装器裸前缀(`bash:*` / `env:*` / `sudo:*` …)——有字面锚但等于任意命令放行。
47
+ // 谓词表属主在宿主(bashPermissions 的 BARE_SHELL_PREFIXES),经 deps 注入,绝不自建第二份名单。
48
+ if (value.toolName === BASH_TOOL_NAME_FOR_RULES &&
49
+ deps.bashRuleContentHasDangerousBarePrefix(value.ruleContent)) {
50
+ return {
51
+ ok: false,
52
+ error: 'candidate is a bare interpreter/wrapper prefix (bash/sh/env/sudo/…) — persisting it would authorize arbitrary commands, not offered locally',
53
+ };
54
+ }
55
+ // 窄化⑤b([3925] P0 跟修):canonical 危险规则谓词叠加 —— python/node/npx/eval/exec/ssh 等
56
+ // 解释器族的五种规则形同样等于任意代码放行(`python -c`)。谓词自己按 toolName 分派
57
+ // (非 Bash/PowerShell 恒 false),纯函数,不破坏本函数「同输入恒同结论」的承重性质。
58
+ if (deps.isDangerousBashPermission(value.toolName, value.ruleContent) ||
59
+ deps.isDangerousPowerShellPermission(value.toolName, value.ruleContent)) {
60
+ return {
61
+ ok: false,
62
+ error: 'candidate allow-rule matches a code-execution interpreter/wrapper pattern (python/node/eval/ssh/…) — persisting it would authorize arbitrary code, not offered locally',
63
+ };
64
+ }
65
+ return { ok: true, value, canonical: deps.formatRule(value) };
66
+ }
67
+ /**
68
+ * 规则内容里还有没有**字面**约束(窄化③ 的判据)。
69
+ *
70
+ * 只剥**未转义**的 `*`:转义星 `\*` 是字面星号(与宿主匹配器 `hasWildcards` 的判据同源),
71
+ * 剥掉它会把「命令就叫 `*`」这条有约束力的规则误判成通配。剥完 **trim**,空 ⇒ 这条规则对该工具的
72
+ * 任何命令都成立。
73
+ *
74
+ * 末尾的 `trim()` 是承重的,不是顺手:宿主匹配器自己入口也先 `pattern.trim()`,所以「只剩空白」
75
+ * 与「什么都不剩」在匹配语义上是同一件事 —— `Bash(* *)` / `Bash( ** )` 这类「看起来有字符」的
76
+ * 拼法因此同样被拦下(cli 常驻套 ①-17 五种拼法全红)。
77
+ * ⚠️ 仍是**收紧方向**的近似:带一个字面非空白字符的极宽规则(如 `Bash(*a*)`)有字面锚、会放行。
78
+ * 真正按匹配器语义算「这条规则有多宽」要把 per-form 的判定搬过来,记为 follow-up;当前判据不漏
79
+ * 「等效于 `Bash(*)`」的那一族,而那一族才是题面。
80
+ */
81
+ function hasLiteralAnchor(ruleContent) {
82
+ let out = '';
83
+ for (let i = 0; i < ruleContent.length; i++) {
84
+ const ch = ruleContent[i];
85
+ if (ch === '\\' && i + 1 < ruleContent.length) {
86
+ // 转义序列整段保留(`\*` 的星号是字面量,`\\` 是字面反斜杠)。
87
+ out += ruleContent[i + 1];
88
+ i++;
89
+ continue;
90
+ }
91
+ if (ch === '*')
92
+ continue; // 未转义通配符:剥掉
93
+ out += ch;
94
+ }
95
+ return out.trim() !== '';
96
+ }
@@ -36,7 +36,8 @@ export interface ParkOwnershipDeps {
36
36
  /** 会话腿真源(缺省 = `hostSessionFor(sessionKey).currentSessionId()`;空串按缺席归一,
37
37
  * 判不出 ≠ 命中)。 */
38
38
  currentSessionId?: () => string | undefined;
39
- /** own-run 台账腿(缺省 = 包 `isOwnEngineRun`,**仅默认会话键**下启用,理由见 sessionKey 注)。
39
+ /** own-run 台账腿(**并联**:默认会话键下注入腿与包 `isOwnEngineRun` 缺省腿任一命中即 owned
40
+ * —— 注入是**补腿不是换腿**,cli 1.0.76 扫码 P1 跟修;非默认键下缺省腿整条跳过,见 sessionKey 注)。
40
41
  * 🔴 键域 = `rowIdTail(行 taskId)`(fleet 台账登记时就过 rowIdTail,本判据同域取键后才调本口)。
41
42
  * 🔴 成文局限(复审二轮裁定,不改行为):默认键下的缺省腿是**进程级**证据,不区分同一宿主
42
43
  * 进程内的会话代际 —— `/clear` 前登记的 run 在新会话语境下仍判 owned。这是单会话宿主的
@@ -11,9 +11,17 @@ export function pendingRowIsOwnedByThisSession(row, deps) {
11
11
  // ① 本宿主亲手驱动的 run / 由它闭包进来的自家子代 —— 不依赖会话 id。
12
12
  // 进程级缺省腿只在默认会话键下启用(多会话宿主必须注入会话粒度的口,见 deps.sessionKey 注)。
13
13
  const rowTaskId = typeof row.taskId === 'string' ? row.taskId : '';
14
- const ownRun = deps?.isOwnRun ?? (sessionKey === DEFAULT_SESSION_KEY ? isOwnEngineRun : undefined);
15
- if (ownRun !== undefined && rowTaskId.length > 0 && ownRun(rowIdTail(rowTaskId)))
16
- return true;
14
+ // 🔴 注入腿与缺省台账腿是**并联**不是顶替(cli 1.0.76 扫码 P1 的包 API 半场,主板 [3925]):
15
+ // 此前 `deps?.isOwnRun ?? isOwnEngineRun` 让「端为补一条自己的正向腿」变成「顺手关掉进程内
16
+ // 台账腿」——补一条断一条,消费端得记得自 OR 才不踩(cli 复核读口实翻)。并联只多不少地
17
+ // 要求正向证明,fail-closed 方向不变;非默认键下缺省腿照旧整条跳过(sessionKey 注)。
18
+ if (rowTaskId.length > 0) {
19
+ const tail = rowIdTail(rowTaskId);
20
+ if (deps?.isOwnRun !== undefined && deps.isOwnRun(tail))
21
+ return true;
22
+ if (sessionKey === DEFAULT_SESSION_KEY && isOwnEngineRun(tail))
23
+ return true;
24
+ }
17
25
  // ② 行自带 sessionId === 本会话当前引擎会话。任一侧缺席/空串 = 判不出,不是命中。
18
26
  const currentSessionId = deps?.currentSessionId ?? (() => hostSessionFor(sessionKey)?.currentSessionId());
19
27
  const sessionId = currentSessionId();