@sema-agent/client-core 0.76.2 → 0.77.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.
@@ -0,0 +1,464 @@
1
+ /**
2
+ * hitl/sessionPolicyWire.ts — **会话策略写端口**:per-session 工具规则(`GET` / `PUT /v1/sessions/:id/policy`)
3
+ * 的三端共用窄读 + 收紧编排(0.77.0 CC-105)。
4
+ *
5
+ * ── 它与常驻规则那一面是**两个面**,不是一个面的两半 ────────────────────────────────────────────
6
+ * 常驻规则面是**跨会话**的治理清单(按内容寻址、可列举、可撤销);本面是**一条会话**上的一份记录:
7
+ * · 作用域 = 这条会话(键还是这条会话的属主,不是调用者;非属主读到的是「没有这条会话」);
8
+ * · 方向 = **只许收紧**,放宽方向由引擎拒(本包不复述那条判据,见下);
9
+ * · 并发 = 记录上盖着一枚单调版本号,写入带上它做乐观并发(不带 = 不做并发校验,本包从不这么发)。
10
+ * 所以它**不住进**常驻规则那只门面;但失败分类与回执读口的**姿势**照它:错误按状态 + 机器码结构读
11
+ * (不 `instanceof`:注入面可能是假件,打包后 `instanceof` 也不该是承重判据),回执只认已过窄化的形。
12
+ *
13
+ * ── 🔴 整份替换,所以「追加一条限制」必须把旧的一起发 ───────────────────────────────────────────
14
+ * 写入口的语义是**整份记录替换**,不是逐字段合并:提交体里省掉的字段等于把那个字段的限制**清空**。
15
+ * 于是「只发新增的那条」在引擎眼里是一次**放宽**,当场被拒。本编排因此是「读 → 已有 ∪ 新增 → 整份回写」:
16
+ * · 并集的**键**就是规则记录自己的那几只桶,键集由引擎型面**派生**(下面的 `RULE_FIELD_PRESENCE`
17
+ * `satisfies` 表)——引擎加一只桶 ⇒ 本文件**编译红**,而不是「静默丢一桶 + 回执说已生效」;
18
+ * · 每只桶内按**逐字去重**并集(旧的在前、新的在后,原样字节,不做规范化)。路径一类的规范拼写归引擎,
19
+ * 壳替它改写会让两端对「同一条限制」各有一份判据;
20
+ * · 🔴 旧记录里**在场的空桶照样保留成空桶**:一只「设了但是空」的白名单与「没设」在语义上是相反的两件事
21
+ * (前者= 什么都不放行,后者 = 不设这类限制),把它丢掉就是一次放宽。
22
+ *
23
+ * ── 🔴 收紧到底怎么判,判官只有一个 ─────────────────────────────────────────────────────────────
24
+ * 「哪些差异算放宽」的判据在引擎侧(逐桶方向各不相同:禁止名单删条目算放宽;白名单加条目、整只白名单
25
+ * 消失算放宽;目录限制解除或多出一个不在原限制之内的目录算放宽)。本包**不铸第二份**:按「已有 ∪ 新增」
26
+ * 写,判定留给引擎,收到放宽拒绝**原样上报、不吞不重试**。
27
+ * ⚠️ 并集写在结构上消不掉**删除方向**的放宽(旧条目一条不少),但消不掉**加宽方向**的:
28
+ * 端把一条放宽方向的条目(例如往白名单或目录限制里加一项)放进 `add`,并集里就真多一项,引擎会判放宽并拒。
29
+ * **本包不预判这件事** —— 出现放宽拒绝时,事实就是「端喂了放宽方向的条目」,措辞据此指路。
30
+ * 🔴 **但那条拒绝只对普通调用方成立**:上游的方向门是**身份门**,不是字段门 —— 被部署判为运维身份的调用方
31
+ * 写入时方向判据**根本不跑**,于是同一份「已有 ∪ 新增」在运维身份上会**被接受**,而其中往白名单 / 目录限制里
32
+ * 加的那一项就是一次真的放宽。所以本编排的 `written` 只承诺一件事:**记录现在等于原有的加上送来的**;
33
+ * 它**不**承诺「这条会话变严了」,措辞也一个字都不这么说(方向的判官只有引擎一个,而它对运维身份不设门)。
34
+ * 端要的若是「无论谁调用都只会更严」,那需要一个不受身份豁免的写模式,而这条 wire 上今天没有。
35
+ *
36
+ * ── 🔴 生效时机 ────────────────────────────────────────────────────────────────────────────────
37
+ * 够新的引擎在**每一道工具门**重读这份记录 ⇒ 一条 run 跑着的时候写下的收紧,**同一条 run 的下一道工具门**
38
+ * 就拒;更早的引擎只在起手读一次,收紧要等下一条 run。本包**不据此做任何判据**(它是引擎版本的函数,
39
+ * 而这条 wire 上没有位回答它),只在措辞里如实说「下一道工具门」并且不承诺「立刻打断正在跑的那一步」。
40
+ *
41
+ * ── 🔴 三种「没读到」不许互折 ──────────────────────────────────────────────────────────────────
42
+ * 窄读三向:**真空**(记录在、一条限制都没有 —— 这台引擎对一条还没写过策略的会话就这么答,版本号为 0)/
43
+ * **读不懂**(信封、版本号、任一只桶的形不对)/ **调用失败**(带 typed 分类)。读不懂**绝不**折成「空策略」——
44
+ * 折了之后并集写会把一份读不懂的旧记录当成空的整份覆盖上去,那是一次**静默的整面放宽**。
45
+ * 同样,写回执读不懂**绝不**答「已写」:那一次很可能真的落了盘,答案是「不知道」,不是「成功」也不是「失败」。
46
+ *
47
+ * ── UNTRUSTED ─────────────────────────────────────────────────────────────────────────────────
48
+ * 规则文本是部署侧/用户侧的内容,只当字节搬运与渲染,绝不当代码用;桶内逐条只判「是不是字符串」,
49
+ * 内容文法归引擎。
50
+ */
51
+ import { hostLog } from '../host.js';
52
+ // ── 桶名全集(派生,不是手抄)────────────────────────────────────────────────────────────────
53
+ /** 🔴 **唯一**目的是让桶名集合从引擎的型面派生:引擎加一只桶 ⇒ 这里缺键 ⇒ **编译红**;删一只 ⇒ 多键 ⇒ 同样编译红。
54
+ * 值无语义。这条纪律在整份替换的语义下是承重的:一只没被并集带上的桶 = 一次静默放宽。 */
55
+ const RULE_FIELD_PRESENCE = {
56
+ toolAllow: true,
57
+ toolDeny: true,
58
+ allowDirs: true,
59
+ commandAllow: true,
60
+ commandDeny: true,
61
+ };
62
+ /**
63
+ * 规则记录的桶名全集。**派生自上表**,不是另一份手抄件。
64
+ * 🔴 形制:`Object.freeze` 的数组,**不是**只在类型面只读的 `readonly T[]` —— 后者一行 `.splice()` 就能改,
65
+ * 而公面消费者拿到的正是这个实例。
66
+ */
67
+ export const SESSION_POLICY_RULE_FIELDS = Object.freeze(Object.keys(RULE_FIELD_PRESENCE));
68
+ /** 一只桶最多认多少条。超过 ⇒ 判**读不懂**,而不是截断:一份被截短的授权记录整份回写就是一次放宽。 */
69
+ const MAX_RULE_ENTRIES = 10_000;
70
+ function errShape(e) {
71
+ const o = (e ?? {});
72
+ return {
73
+ ...(typeof o.status === 'number' ? { status: o.status } : {}),
74
+ ...(typeof o.errorCode === 'string' ? { errorCode: o.errorCode } : {}),
75
+ message: typeof o.message === 'string' && o.message !== '' ? o.message : String(e),
76
+ };
77
+ }
78
+ /**
79
+ * 抛出来的东西 → 处置分类。
80
+ * 🔴 **并发冲突按机器码逐字认,不按 409 状态认**:同一个 409 上骑着两个码(并发不符 / 会话无属主),
81
+ * 而后者重试多少次都是同一个答案。没带码的 409 一律落通用臂 —— 猜一个码去重试,代价是无穷重试。
82
+ */
83
+ export function classifySessionPolicyFailure(e) {
84
+ const { status, errorCode, message } = errShape(e);
85
+ // 🔴 出处先于状态码:没有机器码 ⇒ 证明不了是引擎在答(见 `no-verdict` 的注)。状态码本身在这里不是证据 ——
86
+ // 一页 HTML 405 与引擎的 405 状态码相同、出处相反,前者可能骑在一次已经落盘的写上。
87
+ if (errorCode === undefined || errorCode === '')
88
+ return { kind: 'no-verdict', message };
89
+ if (status === 501 || errorCode === 'capability.session_store_required') {
90
+ return { kind: 'face-unavailable', message };
91
+ }
92
+ if (errorCode === 'loosen_forbidden')
93
+ return { kind: 'loosen-forbidden', message };
94
+ // 🔴 与上一行**同一个状态码**、不同的机器码:规则店读不出记录的存活标记而拒盖新版本号。
95
+ // 没有这一行时它会落进通用臂,措辞就会去劝人改自己送的条目 —— 而条目一个字都没错。
96
+ if (errorCode === 'corrupt')
97
+ return { kind: 'store-corrupt', message };
98
+ if (errorCode === 'conflict')
99
+ return { kind: 'cas-conflict', message };
100
+ if (errorCode === 'conflict.session_ownerless')
101
+ return { kind: 'session-ownerless', message };
102
+ if (errorCode !== undefined && errorCode.startsWith('auth.'))
103
+ return { kind: 'unauthorized', message };
104
+ if (status === 404)
105
+ return { kind: 'session-not-found', message };
106
+ if (status === 401)
107
+ return { kind: 'unauthorized', message };
108
+ if (status === 400 || status === 413 || status === 422)
109
+ return { kind: 'request-rejected', message };
110
+ // 🔴 末两行的分界是**「引擎到底答没答」**,不是「认不认得这个码」:
111
+ // · 没有状态码(一个带码却没状态的对象不是传输层交出来的形)⇒ 引擎的裁决**没到手**;
112
+ // · 5xx ⇒ 引擎答了一句「我这边炸了」,那句话**证明不了**提交没落盘(提交成功而回程失败是同一形)。
113
+ // 这两种都归 `no-verdict`;剩下的(引擎带码答了一个客户端侧拒绝)才允许说「什么都没写」。
114
+ if (status === undefined || status >= 500)
115
+ return { kind: 'no-verdict', message };
116
+ return { kind: 'error', message };
117
+ }
118
+ /**
119
+ * 一只桶的逐条窄读:`length` 恰读一次、每个下标恰读一次(变化的数组读两遍会让一条限制静默消失)。
120
+ * 🔴 **下标必须是数组自己的**(`hasOwn`):稀疏数组的洞在原型被污染时会读出一个别人放进去的值,
121
+ * 而这里读出来的每一条都会被并集**原样写回**去 —— 那就等于把一条 wire 上根本不存在的限制写成真的。
122
+ */
123
+ function narrowBucket(raw, field) {
124
+ if (!Array.isArray(raw))
125
+ return { ok: false, detail: `rule field "${field}" is not an array` };
126
+ const n = raw.length;
127
+ if (!Number.isSafeInteger(n) || n < 0)
128
+ return { ok: false, detail: `rule field "${field}" reports an unusable length` };
129
+ if (n > MAX_RULE_ENTRIES) {
130
+ return { ok: false, detail: `rule field "${field}" reports ${n} entries (over the readable ceiling) — refusing to walk or truncate an authorization record` };
131
+ }
132
+ const list = [];
133
+ for (let i = 0; i < n; i++) {
134
+ if (!Object.hasOwn(raw, i))
135
+ return { ok: false, detail: `rule field "${field}" has a hole at entry ${i}` };
136
+ const x = raw[i];
137
+ if (typeof x !== 'string')
138
+ return { ok: false, detail: `rule field "${field}" entry ${i} is not a string` };
139
+ list.push(x);
140
+ }
141
+ return { ok: true, list };
142
+ }
143
+ /**
144
+ * `{ rules }` 信封 → 承重键(版本号 + 五只桶)。
145
+ * 🔴 **半坏就是坏**:一只桶好、另一只桶形不对 ⇒ 整份判读不懂。丢掉坏的那只再整份回写 = 把那只桶清空 =
146
+ * 一次放宽,而调用方会看到一个 200。
147
+ */
148
+ function narrowStoredRecord(body) {
149
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
150
+ return { ok: false, why: 'envelope', detail: 'policy response is not an object' };
151
+ }
152
+ // 🔴 承重键**逐个按 own-property 读**(异源对抗复审 R2 [medium],真病):`src[field]` 会沿原型链取值,
153
+ // 于是一次原型污染(或一只被注入的假 facade 交出带继承键的对象)能让 wire 上**没有**的那只桶
154
+ // 出现在读数里 —— 而写口是整份替换,并集会把它当成一条真限制写回去。方向上这不是「多读了一格」,
155
+ // 是**凭空造出一条授权规则**。信封、版本号、五只桶、数组下标一律同律。
156
+ if (!Object.hasOwn(body, 'rules')) {
157
+ return { ok: false, why: 'envelope', detail: 'policy response carries no rules record of its own' };
158
+ }
159
+ const envelope = body.rules;
160
+ if (envelope === null || typeof envelope !== 'object' || Array.isArray(envelope)) {
161
+ return { ok: false, why: 'envelope', detail: 'policy response carries no readable rules record' };
162
+ }
163
+ if (!Object.hasOwn(envelope, 'rev')) {
164
+ return { ok: false, why: 'rev', detail: 'policy record carries no version stamp of its own' };
165
+ }
166
+ const src = envelope;
167
+ const rev = src.rev;
168
+ if (typeof rev !== 'number' || !Number.isSafeInteger(rev) || rev < 0) {
169
+ return { ok: false, why: 'rev', detail: 'policy record carries no readable version stamp' };
170
+ }
171
+ const rules = {};
172
+ for (const field of SESSION_POLICY_RULE_FIELDS) {
173
+ if (!Object.hasOwn(envelope, field))
174
+ continue;
175
+ const raw = src[field];
176
+ if (raw === undefined)
177
+ continue;
178
+ const read = narrowBucket(raw, field);
179
+ if (!read.ok)
180
+ return { ok: false, why: 'rule_field', detail: read.detail };
181
+ rules[field] = read.list;
182
+ }
183
+ return { ok: true, rev, rules };
184
+ }
185
+ /**
186
+ * 读这条会话当前的规则记录。
187
+ * 🔴 记录**真的空**(一只桶都没有、版本号 0)是这台引擎对「还没写过策略的会话」的诚实答案,
188
+ * 它与「读不懂」是两个结局;调用方据此知道下一次写该带哪一个版本号。
189
+ */
190
+ export async function readSessionPolicy(facade, sessionId, opts) {
191
+ let body;
192
+ try {
193
+ body = await facade.getPolicy(sessionId, opts);
194
+ }
195
+ catch (e) {
196
+ return { kind: 'failed', failure: classifySessionPolicyFailure(e) };
197
+ }
198
+ let read;
199
+ try {
200
+ read = narrowStoredRecord(body);
201
+ }
202
+ catch {
203
+ // 抛出的 getter / 被撤销的代理:与「形不对」同一处置,绝不外抛到调用方的编排里。
204
+ read = { ok: false, why: 'envelope', detail: 'policy response could not be read' };
205
+ }
206
+ if (!read.ok)
207
+ return { kind: 'malformed', why: read.why, detail: read.detail };
208
+ return { kind: 'present', rev: read.rev, rules: read.rules };
209
+ }
210
+ // ── 并集(整份替换下的「追加一条限制」)────────────────────────────────────────────────────────
211
+ /**
212
+ * 「已有 ∪ 新增」。**只加不减**:旧记录的每一只桶、每一条条目都原样留在结果里(在场的空桶保留成空桶),
213
+ * 新增的条目逐字去重后接在后面。两边都没有的桶**不铸**(凭空铸一只空桶 = 凭空加一条限制)。
214
+ * 🔴 条目字节原样搬运:不 trim、不规范化路径、不排序 —— 那些都是引擎的判据,壳再做一遍就是第二份。
215
+ */
216
+ export function tightenedSessionRules(prior, add) {
217
+ // 🔴 两侧都过读腿那**同一只**窄化器(`hasOwn` 桶名 + `hasOwn` 下标 + 逐条是字符串),不信任任何一侧:
218
+ // `hasOwn` 只护得住桶名,桶内若直接 `for…of`,走的是数组迭代器 —— 稀疏数组的洞会沿原型链读出别人放进去的值,
219
+ // 而并集结果是**整份写回去**的(读腿堵住的那条原型污染路径不许在写腿重新打开)。
220
+ // 坏的一侧是调用方的入参 ⇒ 抛具名错误(不是引擎的 request_rejected,不许混进那一臂)。
221
+ const p = narrowRulesInput(prior, 'prior');
222
+ const a = narrowRulesInput(add, 'add');
223
+ const out = {};
224
+ for (const field of SESSION_POLICY_RULE_FIELDS) {
225
+ // 窄化副本是普通对象:读桶仍按自有键(一次原型污染会让每只普通对象都「带着」一只桶)。
226
+ const pl = Object.hasOwn(p, field) ? p[field] : undefined;
227
+ const al = Object.hasOwn(a, field) ? a[field] : undefined;
228
+ if (pl === undefined && al === undefined)
229
+ continue;
230
+ const merged = [];
231
+ const seen = new Set();
232
+ for (const x of pl ?? []) {
233
+ if (seen.has(x))
234
+ continue;
235
+ seen.add(x);
236
+ merged.push(x);
237
+ }
238
+ for (const x of al ?? []) {
239
+ if (seen.has(x))
240
+ continue;
241
+ seen.add(x);
242
+ merged.push(x);
243
+ }
244
+ out[field] = merged;
245
+ }
246
+ return out;
247
+ }
248
+ /**
249
+ * 调用方交来的一份规则记录 → 已窄化的副本(每只在场桶都是自有的、稠密的、逐条字符串的新数组)。
250
+ * 🔴 与 wire 回体的窄读是**同一只**桶级判据(`narrowBucket`);差别只在处置:回体读不懂是「不知道」,
251
+ * 入参读不懂是调用方的编程错误 ⇒ `TypeError` 点名是哪一侧、哪只桶(不回显条目内容)。
252
+ * 显式 `undefined` 的桶 = 缺席;原型链上的桶不算在场(它们也上不了 wire)。
253
+ */
254
+ function narrowRulesInput(raw, side) {
255
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
256
+ throw new TypeError(`tightenedSessionRules: "${side}" must be a rules record object`);
257
+ }
258
+ const out = {};
259
+ for (const field of SESSION_POLICY_RULE_FIELDS) {
260
+ if (!Object.hasOwn(raw, field))
261
+ continue;
262
+ const bucket = raw[field];
263
+ if (bucket === undefined)
264
+ continue;
265
+ const read = narrowBucket(bucket, field);
266
+ if (!read.ok)
267
+ throw new TypeError(`tightenedSessionRules: "${side}" ${read.detail}`);
268
+ out[field] = read.list;
269
+ }
270
+ return out;
271
+ }
272
+ /**
273
+ * 写回执对提交体的**对账**:回执读得懂之后,还要证明它说的是**这一次**提交。
274
+ * 上游契约:成功回执 = 规范化后的提交体 + 严格大于并发键的新版本号(戳 = max(当前, 高水位) + 1)。
275
+ * 所以:版本号没推进 / 送出去的桶不在回执里 / 送出去的条目不在回执桶里 ⇒ 回执证明不了这次落了盘。
276
+ * 目录桶由引擎做词法规范化(回执条目可以与送出的字节不同),那一只**只核在场**,逐条判据归引擎。
277
+ * 回执桶是提交的超集不算失配(`written` 承诺的是「记录含有原有的加上送来的」,不是逐字相等)。
278
+ * 返回 `null` = 对上了;否则一句可渲染的失配说明。
279
+ */
280
+ function receiptMismatch(sent, receipt, sentRev, receiptRev) {
281
+ if (receiptRev <= sentRev) {
282
+ return `policy write receipt did not advance the version stamp (sent with ${sentRev}, receipt says ${receiptRev})`;
283
+ }
284
+ for (const field of SESSION_POLICY_RULE_FIELDS) {
285
+ const s = Object.hasOwn(sent, field) ? sent[field] : undefined;
286
+ if (s === undefined)
287
+ continue;
288
+ const r = Object.hasOwn(receipt, field) ? receipt[field] : undefined;
289
+ if (r === undefined)
290
+ return `rule field "${field}" was sent but is missing from the write receipt`;
291
+ if (field === 'allowDirs')
292
+ continue;
293
+ const have = new Set(r);
294
+ for (const x of s) {
295
+ if (!have.has(x))
296
+ return `rule field "${field}" in the write receipt is missing an entry that was sent`;
297
+ }
298
+ }
299
+ return null;
300
+ }
301
+ /** 成因闭集的运行期镜像(冻结)。 */
302
+ export const SESSION_POLICY_TIGHTEN_UNKNOWN_WHY = Object.freeze([
303
+ 'face_unavailable',
304
+ 'session_not_found',
305
+ 'session_ownerless',
306
+ 'unauthorized',
307
+ 'request_rejected',
308
+ 'store_corrupt',
309
+ 'read_unreadable',
310
+ 'read_failed',
311
+ 'write_refused',
312
+ 'write_indeterminate',
313
+ 'write_receipt_unreadable',
314
+ 'write_receipt_mismatch',
315
+ ]);
316
+ function unknownFrom(failure, leg) {
317
+ const message = failure.message;
318
+ switch (failure.kind) {
319
+ case 'face-unavailable':
320
+ return { kind: 'unknown', why: 'face_unavailable', message };
321
+ case 'session-not-found':
322
+ return { kind: 'unknown', why: 'session_not_found', message };
323
+ case 'session-ownerless':
324
+ return { kind: 'unknown', why: 'session_ownerless', message };
325
+ case 'unauthorized':
326
+ return { kind: 'unknown', why: 'unauthorized', message };
327
+ case 'request-rejected':
328
+ return { kind: 'unknown', why: 'request_rejected', message };
329
+ case 'store-corrupt':
330
+ return { kind: 'unknown', why: 'store_corrupt', message };
331
+ // 🔴 读腿没有副作用 ⇒ 无论引擎答没答,「什么都没写」都是真的,两种失败合成 read_failed;
332
+ // 写腿必须把两者**分开**:引擎带码答了拒绝 ⇒ 确实没写;裁决没到手(含没码的 4xx)⇒ 可能已经落盘,不许说成没送到。
333
+ case 'no-verdict':
334
+ return { kind: 'unknown', why: leg === 'read' ? 'read_failed' : 'write_indeterminate', message };
335
+ // 下面两条在这条腿上不该出现(调用点已先行分流);真出现了 = 引擎在这条腿上说了另一条腿的话,
336
+ // 照通用臂报,绝不在这里替它改判。
337
+ case 'loosen-forbidden':
338
+ case 'cas-conflict':
339
+ case 'error':
340
+ return { kind: 'unknown', why: leg === 'read' ? 'read_failed' : 'write_refused', message };
341
+ }
342
+ }
343
+ async function attemptTighten(facade, req, opts) {
344
+ const read = await readSessionPolicy(facade, req.sessionId, opts);
345
+ if (read.kind === 'failed')
346
+ return { done: unknownFrom(read.failure, 'read') };
347
+ if (read.kind === 'malformed') {
348
+ // 🔴 读不懂 ⇒ 停手。把它当空记录并集回写,等于用一份「什么限制都没有」的记录整份覆盖真记录。
349
+ return { done: { kind: 'unknown', why: 'read_unreadable', message: read.detail } };
350
+ }
351
+ const rules = tightenedSessionRules(read.rules, req.add);
352
+ let receipt;
353
+ try {
354
+ receipt = await facade.putPolicy(req.sessionId, { rules, expectedRev: read.rev }, opts);
355
+ }
356
+ catch (e) {
357
+ const failure = classifySessionPolicyFailure(e);
358
+ if (failure.kind === 'loosen-forbidden')
359
+ return { done: { kind: 'loosen_forbidden', message: failure.message } };
360
+ if (failure.kind === 'cas-conflict')
361
+ return { retryAtRev: read.rev };
362
+ return { done: unknownFrom(failure, 'write') };
363
+ }
364
+ let narrowed;
365
+ try {
366
+ narrowed = narrowStoredRecord(receipt);
367
+ }
368
+ catch {
369
+ narrowed = { ok: false, why: 'envelope', detail: 'policy write receipt could not be read' };
370
+ }
371
+ if (!narrowed.ok) {
372
+ // 🔴 这一臂**不是**失败:写很可能落了盘,只是回执说不清。答「不知道」,绝不答「已写」。
373
+ return { done: { kind: 'unknown', why: 'write_receipt_unreadable', message: narrowed.detail } };
374
+ }
375
+ // 🔴 回执只验形就认 `written` 是谎报:一份形好、但版本号没推进或不含送出条目的回执(版本偏斜 / 错误回包 /
376
+ // 中间层缓存)证明不了这次提交落了盘。对账不过 ⇒ 与「回执读不懂」同一处置方向(可能已生效,先重读)。
377
+ const mismatch = receiptMismatch(rules, narrowed.rules, read.rev, narrowed.rev);
378
+ if (mismatch !== null)
379
+ return { done: { kind: 'unknown', why: 'write_receipt_mismatch', message: mismatch } };
380
+ return { done: { kind: 'written', rev: narrowed.rev } };
381
+ }
382
+ /**
383
+ * 给这条会话**追加**一组限制:读当前记录 → 「已有 ∪ 新增」→ 带当前版本号整份回写。
384
+ *
385
+ * 🔴 **只重读重写一次**(结构上,不靠计数器:本函数里没有循环)。撞上并发键说明有别的写者在同一条
386
+ * 会话上推进了记录;重来一趟是为了把对方刚写进去的限制也一并带上(并集只加不减,所以重来是**收敛**的)。
387
+ * 第二趟仍撞 ⇒ 如实上报 `conflict`:第三趟不比第二趟更有理由成功,而一个自己会无限重试的写腿在争用下
388
+ * 会把一条会话的规则记录变成两个写者的角力场。
389
+ * 🔴 能力位明说没有这一面(或这份二进制比这一位还老)⇒ **一个请求都不发**;从没观测过 ⇒ **照发** ——
390
+ * 「本进程没探过能力面」是本进程的缺陷,不是引擎的否定,把它当否定会让一次用户已经点过的动作凭空落空。
391
+ * 那两条的分家写在 `sessionPolicyFaceAvailable` 的头注里(渲不渲入口 vs 动不动网络是两个问题)。
392
+ */
393
+ export async function tightenSessionPolicy(facade, req, opts) {
394
+ // 入参形先于一切(能力位判据、网络):坏的 `add` 是调用方的编程错误,在任何往返之前抛,不借引擎的 request_rejected。
395
+ // 🔴 入口窄化出的副本就是**发出去的那一份**,两趟(首趟 / CAS 重试)共用;`sessionId` / 能力位同样只读一次 ——
396
+ // 编排里有 await,调用方在 GET 等待期间复用 / 清空表单数组、或访问器第二次给别的值,都不许改动已声明的限制
397
+ // (否则已声明的拒绝会静默消失,而回执对账只能核被改过的提交体)。
398
+ const snapshot = {
399
+ sessionId: req.sessionId,
400
+ add: narrowRulesInput(req.add, 'add'),
401
+ capability: req.capability,
402
+ };
403
+ if (snapshot.capability.kind === 'not_reported')
404
+ return { kind: 'capability_absent', why: 'not_reported' };
405
+ if (snapshot.capability.kind === 'absent')
406
+ return { kind: 'capability_absent', why: 'absent' };
407
+ const first = await attemptTighten(facade, snapshot, opts);
408
+ if ('done' in first)
409
+ return first.done;
410
+ hostLog('debug', 'sessionPolicyWire: the session rule record moved between this read and this write — re-reading once so the other writer’s entries are carried into the union, then writing again');
411
+ const second = await attemptTighten(facade, snapshot, opts);
412
+ if ('done' in second)
413
+ return second.done;
414
+ return { kind: 'conflict', rev: second.retryAtRev };
415
+ }
416
+ // ── 措辞(唯一真源;端只拼接不另写)────────────────────────────────────────────────────────────
417
+ /**
418
+ * 一次收紧结局的人话,**唯一措辞真源**。
419
+ * 🔴 三条不许说反:① 放宽被拒那一句要指向「送出去的那组条目里有放宽方向的」,不许说成引擎坏了或没权限;
420
+ * ② 并发那一句要说「记录又被别人动了」并把下一步交出去,不许说成已写;③ 回执读不懂那一句**必须**
421
+ * 说「可能已经生效」——把它说成失败,人会再写一遍,而那一遍会把已经生效的那条当成新增重发。
422
+ * 生效时机只说「下一道工具门」,不承诺打断正在跑的那一步。
423
+ */
424
+ export function sessionPolicyTightenNotice(outcome) {
425
+ switch (outcome.kind) {
426
+ case 'written':
427
+ return `Session rules saved (version ${outcome.rev}) — the record now holds everything it already had plus the entries sent here, and it applies from this session’s next tool gate onward; the step already running is not interrupted.`;
428
+ case 'conflict':
429
+ return 'Session rules were changed by someone else while this tightening was being written, twice in a row — nothing was written. Re-read the current rules and try again, or let the other writer finish first.';
430
+ case 'loosen_forbidden':
431
+ return 'The engine refused this write because it would relax the session rules: at least one entry sent here widens what is allowed rather than narrowing it. Only entries that add restrictions are accepted on this path.';
432
+ case 'capability_absent':
433
+ return outcome.why === 'absent'
434
+ ? 'This deployment does not keep per-session tool rules, so there is nothing to tighten here.'
435
+ : 'This engine does not say whether it keeps per-session tool rules — it predates that answer — so the tightening entry stays hidden rather than being written blind.';
436
+ case 'unknown':
437
+ switch (outcome.why) {
438
+ case 'face_unavailable':
439
+ return 'This deployment does not keep per-session tool rules, so nothing was written.';
440
+ case 'session_not_found':
441
+ return 'This session is not readable from here — it may not exist, or it may belong to someone else; nothing was written.';
442
+ case 'session_ownerless':
443
+ return 'This session has no owner, so a rule written for it could never be read back; nothing was written. Attach an identity to the session and try again.';
444
+ case 'unauthorized':
445
+ return 'This call could not present a verifiable identity, so nothing was written.';
446
+ case 'request_rejected':
447
+ return 'The engine rejected the shape of this rule record, so nothing was written. Fix the entries before sending them again.';
448
+ case 'store_corrupt':
449
+ return 'The engine could not establish which version this session’s rules are at and refused to stamp a new one, so nothing was written. The entries sent here are not the problem — this needs an operator to look at the rule store.';
450
+ case 'read_unreadable':
451
+ return 'The session’s current rules could not be read, so nothing was written — writing over a record that cannot be read would replace real restrictions with an empty one.';
452
+ case 'read_failed':
453
+ return 'The session’s current rules could not be fetched, so nothing was written.';
454
+ case 'write_refused':
455
+ return 'The engine refused this write, so nothing was written. Re-read the current rules before trying again.';
456
+ case 'write_indeterminate':
457
+ return 'The write never came back with an answer from the engine — it may or may not have taken effect. Re-read the current rules before writing anything else.';
458
+ case 'write_receipt_unreadable':
459
+ return 'The tightening was sent but the engine’s answer could not be read — it may already be in effect. Re-read the current rules before writing anything else.';
460
+ case 'write_receipt_mismatch':
461
+ return 'The tightening was sent but the engine’s answer does not account for it (the version did not advance, or the entries sent here are not in the returned record) — it may already be in effect. Re-read the current rules before writing anything else.';
462
+ }
463
+ }
464
+ }
package/dist/index.d.ts CHANGED
@@ -160,6 +160,7 @@ export * from './executionLaneCapability.js';
160
160
  export * from './approvalsStreamLiveCapability.js';
161
161
  export * from './peerLaneCapability.js';
162
162
  export * from './permissionRulesWriteCapability.js';
163
+ export * from './sessionPolicyCapability.js';
163
164
  export * from './memoryComplianceCapability.js';
164
165
  export * from './memoryOriginCapability.js';
165
166
  export * from './memoryEntriesWire.js';
@@ -282,6 +283,7 @@ export * from './hitl/askParkRowRouting.js';
282
283
  export * from './hitl/resumeRunningCard.js';
283
284
  export * from './hitl/persistedRulesWire.js';
284
285
  export * from './hitl/localAllowRule.js';
286
+ export * from './hitl/sessionPolicyWire.js';
285
287
  export * from './hitl/approvalsFeed.js';
286
288
  export * from './hitl/livePendingAsk.js';
287
289
  export * from './hitl/crashConverged.js';
package/dist/index.js CHANGED
@@ -201,6 +201,10 @@ export * from './peerLaneCapability.js';
201
201
  // · permissionRulesWriteCapability(0.76.1 CC-102):capabilities.permissionRulesWrite 四态读面 —— 键缺席 = 这份二进制比单步写口老(藏入口)、
202
202
  // 键在场按值判「这次调用够不够得着」(与撤销面那只部署级存在性信号刻意不同源,别互相套用读法)。布尔便利口 permissionRulesWriteAvailable 是本位唯一派生点。
203
203
  export * from './permissionRulesWriteCapability.js';
204
+ // · sessionPolicyCapability(0.77.0 CC-105):capabilities.sessionPolicy 四态读面 —— per-session 工具规则那一对读写口在不在;
205
+ // 键缺席 = 这份二进制比这一位老(说不出,不等于没有)、`false` = 本部署没有会话规则店(正面事实)、畸形删格绝不折否。
206
+ // 「渲不渲写入口」三态判据单源 sessionPolicyFaceAvailable;「动不动网络」那一问在 hitl/sessionPolicyWire 的编排里,刻意分家。
207
+ export * from './sessionPolicyCapability.js';
204
208
  // · memoryComplianceCapability(0.76.1 CC-97a):capabilities.memoryCompliance 四态读面(出处问询 / 抹除两口)——
205
209
  // 键缺席 = 老引擎判不出、`false` = 明确的否、畸形删格绝不折否;两口可用性三态判据 + 记忆治理面 501 码单源。
206
210
  export * from './memoryComplianceCapability.js';
@@ -529,6 +533,14 @@ export * from './hitl/resumeRunningCard.js';
529
533
  // 包内绝不自建第二份名单。🔴 安全面等值契约:拒绝集文案是三端可观察行为,cli 常驻套逐字锚。
530
534
  export * from './hitl/persistedRulesWire.js';
531
535
  export * from './hitl/localAllowRule.js';
536
+ // ── 0.77.0 CC-105:会话策略写端口(per-session 工具规则的窄读 + 收紧编排)────────────────────────
537
+ // · sessionPolicyWire:与上面那条**持久规则**面是两个面(那面跨会话、按内容寻址、可撤销;本面是一条会话上的
538
+ // 一份记录、只许收紧、带乐观并发键),故不并进那个文件;失败分类与回执读口的姿势照它。
539
+ // 🔴 写口语义是**整份替换**:省掉的桶等于清空 ⇒ 「追加一条限制」必须「已有 ∪ 新增」整份回写,
540
+ // 桶名集合由引擎型面派生(加桶即编译红,而不是静默丢一桶 + 回执说已生效)。
541
+ // 🔴 收紧判据只有引擎一个判官:放宽方向被拒**原样上报、不吞不重试**;窄读「读不懂」绝不折成「空策略」;
542
+ // 写回执读不懂答 `unknown` 绝不答已写;并发冲突**只重读重写一次**(结构上无循环),第二次仍冲突即如实上报。
543
+ export * from './hitl/sessionPolicyWire.js';
532
544
  // B7 ③(census G20,**行为改动**不是搬迁):pending-approvals 推送 feed(stream 优先 / 断流回落
533
545
  // 轮询 / 定期再试)。🔴 它**不替换** D-1 的取件 —— 那三处必须继续走权威 `list()`(见文件头)。
534
546
  export * from './hitl/approvalsFeed.js';
@@ -233,6 +233,13 @@ export function readMemoryExportRows(body) {
233
233
  * ⑤ 全过 ⇒ `none_in_this_scope`(带 scope 串)。
234
234
  */
235
235
  export function scopeExternalOriginVerdict(reading, asked) {
236
+ // 🔴 入口先核 `asked`(0.77.0;外部验收方 F-A):此前只有「干净答案」那条路才读 `asked.originAware`,JS 宿主漏传第二形参会一路顺跑、
237
+ // 第一次遇到干净的店才炸,而那恰是这道围栏存在的理由。防御方向不改(缺席绝不折成「干净」),改的是把裸属性访问的 TypeError
238
+ // 换成具名、可解释的错误,并且**每条路**都在入口就问一次 —— 不让「什么时候炸」取决于回体形状。
239
+ if (asked === null || typeof asked !== 'object' || typeof asked.originAware !== 'boolean') {
240
+ throw new TypeError('scopeExternalOriginVerdict: second argument { originAware: boolean } is required — this export read withholds origin-tagged ' +
241
+ 'entries unless the request declared originAware=1, and an empty answer without that declaration must never be read as "clean"');
242
+ }
236
243
  if (reading.kind !== 'present')
237
244
  return { kind: 'unknown', why: 'unreadable' };
238
245
  const visible = reading.rows.filter((r) => r.originMark === 'marked').length;
@@ -32,7 +32,11 @@ import { createEngineCapReader } from './engineCapReader.js';
32
32
  export function projectPeerLaneCapability(caps) {
33
33
  if (caps === null || typeof caps !== 'object')
34
34
  return undefined;
35
- if (!('peerLane' in caps))
35
+ // 0.77.0 整族改齐:① 非对象 / 数组 caps 不是能力表 ⇒ 畸形(投影答 undefined ⇒ 删格 ⇒ 读口答 unobserved),不再答 not_reported
36
+ // (那是把「取不到」冒充「报了但没提」);② 在场判据改 hasOwn —— 原型链上的同名键永远不会被序列化上 wire,读成在场 = 凭空造一格。
37
+ if (caps === null || typeof caps !== 'object' || Array.isArray(caps))
38
+ return undefined;
39
+ if (!Object.hasOwn(caps, 'peerLane'))
36
40
  return { kind: 'not_reported' };
37
41
  // 🔴 只读一次:在场判据与取值同一次读(变化的 getter 不会让两处判断看到两个不同的值)。
38
42
  const v = caps.peerLane;
@@ -35,7 +35,11 @@ import { createEngineCapReader } from './engineCapReader.js';
35
35
  export function projectPermissionRulesWriteCapability(caps) {
36
36
  if (caps === null || typeof caps !== 'object')
37
37
  return undefined;
38
- if (!('permissionRulesWrite' in caps))
38
+ // 0.77.0 整族改齐:① 非对象 / 数组 caps 不是能力表 ⇒ 畸形(投影答 undefined ⇒ 删格 ⇒ 读口答 unobserved),不再答 not_reported
39
+ // (那是把「取不到」冒充「报了但没提」);② 在场判据改 hasOwn —— 原型链上的同名键永远不会被序列化上 wire,读成在场 = 凭空造一格。
40
+ if (caps === null || typeof caps !== 'object' || Array.isArray(caps))
41
+ return undefined;
42
+ if (!Object.hasOwn(caps, 'permissionRulesWrite'))
39
43
  return { kind: 'not_reported' };
40
44
  // 🔴 只读一次:在场判据与取值同一次读。
41
45
  const v = caps.permissionRulesWrite;