@sema-agent/settings-schema 14.5.0 → 14.6.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/README.md +15 -0
  3. package/conformance/VERSION +1 -1
  4. package/dist/atomic-write.js +10 -7
  5. package/dist/bundle.d.ts +1 -1
  6. package/dist/bundle.js +1 -1
  7. package/dist/config-dir-migration.js +2 -1
  8. package/dist/config-fns.js +2 -2
  9. package/dist/conformance/canonical.js +4 -7
  10. package/dist/conformance/corpus.js +3 -3
  11. package/dist/conformance/derive.js +6 -5
  12. package/dist/conformance/run.js +2 -1
  13. package/dist/definition-copy.d.ts +23 -0
  14. package/dist/definition-copy.js +52 -0
  15. package/dist/file-store.d.ts +1 -31
  16. package/dist/file-store.js +3 -30
  17. package/dist/fleet.js +2 -1
  18. package/dist/fs-failure.d.ts +12 -7
  19. package/dist/fs-failure.js +53 -29
  20. package/dist/hooks-config.d.ts +2 -2
  21. package/dist/hooks-config.js +112 -31
  22. package/dist/http-hook-allowlists.d.ts +7 -3
  23. package/dist/http-hook-allowlists.js +102 -16
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +2 -1
  26. package/dist/mcp-request-entry.d.ts +9 -6
  27. package/dist/mcp-request-entry.js +225 -93
  28. package/dist/plain-json-object.d.ts +28 -3
  29. package/dist/plain-json-object.js +48 -7
  30. package/dist/portable-domains.d.ts +31 -0
  31. package/dist/portable-domains.js +30 -0
  32. package/dist/safety-merge-spec.d.ts +88 -6
  33. package/dist/safety-merge-spec.js +233 -17
  34. package/dist/scheduler-store.js +3 -2
  35. package/dist/settings-units.d.ts +85 -0
  36. package/dist/settings-units.js +175 -0
  37. package/dist/types.js +57 -53
  38. package/dist/url-shape.d.ts +11 -5
  39. package/dist/url-shape.js +11 -5
  40. package/dist/workers-rows.d.ts +2 -1
  41. package/dist/workers-rows.js +5 -4
  42. package/docs/API-SURFACE.md +17 -3
  43. package/llms-full.txt +12 -2
  44. package/llms.txt +4 -2
  45. package/package.json +1 -1
@@ -46,6 +46,7 @@
46
46
  * contract; cycles are tolerated, a throwing accessor propagates.
47
47
  */
48
48
  import { z } from "zod";
49
+ import { isPlainJsonObject } from "./plain-json-object.js";
49
50
  import { buildQuotedIssue, fixed } from "./quoted-issue.js";
50
51
  // ── event vocabulary ───────────────────────────────────────────────────────────────────────────────────────
51
52
  // The two halves below are where each word came from (CC 2.1.198's own table, then the later additions) — provenance for the
@@ -141,8 +142,8 @@ export const HOOKS_CLASSIFICATION_OUTCOMES = ["loaded", "loaded-partial", "rejec
141
142
  export const HOOKS_DROPPED_PIECE_CODES = ["hooks-not-object", "unknown-event", "event-not-array", "matcher-invalid", "entry-invalid"];
142
143
  /**
143
144
  * Why the document was REJECTED (closed; each is "a guard may be in this piece"):
144
- * · `guard-outside-hooks` — a guard sign outside `hooks` (top level, under another key, or the document itself matcher-shaped);
145
- * · `hooks-is-matcher` — `hooks` is an array holding objects / arrays, a single matcher, or a single entry;
145
+ * · `guard-outside-hooks` — a guard sign outside `hooks` (top level, under another key, or the document itself matcher-shaped), or the document could not be read at all (path `[]`);
146
+ * · `hooks-is-matcher` — `hooks` is an array holding objects / arrays, a single matcher, or a single entry, or an object that is not a readable block (a class instance, a value that cannot be inspected) holding — or possibly holding — a guard sign;
146
147
  * · `guard-under-unknown-event` — an unknown event name holding a guard sign;
147
148
  * · `guard-event-not-array` — a guard event whose value is not an array (and not null), or a known event whose non-array value holds a sign;
148
149
  * · `guard-matcher-invalid` — a bad matcher in a guard event, or a matcher holding a sign (in any event);
@@ -156,12 +157,57 @@ export const HOOKS_REJECTION_CODES = [
156
157
  "guard-matcher-invalid",
157
158
  "guard-entry-invalid",
158
159
  ];
159
- /** A JSON object: not null, not an array, prototype `Object.prototype` or null. */
160
- function hookIsRecord(value) {
161
- if (value === null || typeof value !== "object" || Array.isArray(value))
162
- return false;
163
- const proto = Object.getPrototypeOf(value);
164
- return proto === Object.prototype || proto === null;
160
+ /** The value's shape — six words: `plain` is a JSON object (not null, not an array, prototype null or a prototype whose own prototype is null: any realm's `Object.prototype`),
161
+ * `uninspectable` is a value whose shape cannot be read at all (a proxy that has been revoked, a throwing `getPrototypeOf` trap). This function IS `./plain-json-object`'s `jsonShapeOf`, character for
162
+ * character: the classifier is copied as source by consumers' generators, so its closure cannot import — the package has this one second copy, and the test is what keeps it from drifting. */
163
+ function hookShapeOf(v) {
164
+ try {
165
+ if (v === null)
166
+ return "null";
167
+ if (Array.isArray(v))
168
+ return "array";
169
+ if (typeof v === "function")
170
+ return "non_plain_object";
171
+ if (typeof v !== "object")
172
+ return "primitive";
173
+ const proto = Object.getPrototypeOf(v);
174
+ return proto === null || Object.getPrototypeOf(proto) === null ? "plain" : "non_plain_object";
175
+ }
176
+ catch {
177
+ return "uninspectable"; // 读形状就抛(已撤销的 Proxy 连 `Array.isArray` 都抛、`getPrototypeOf` 陷阱抛):不是「不朴素」,是读不出 —— 读者按最严处置
178
+ }
179
+ }
180
+ /** `./plain-json-object` 的 `isPlainJsonObject`,调各自的形状分类核后逐字同文(闭包里不能 import)。 */
181
+ function hookIsRecord(v) {
182
+ const shape = hookShapeOf(v);
183
+ return shape === "plain";
184
+ }
185
+ /** Is the value object-like — anything but null / a primitive (arrays, functions and values whose shape cannot be read count). The shape is asked first, never a `typeof` split. */
186
+ function hookIsObjectLike(v) {
187
+ const shape = hookShapeOf(v);
188
+ switch (shape) {
189
+ case "null":
190
+ case "primitive":
191
+ return false;
192
+ case "plain":
193
+ case "non_plain_object":
194
+ case "array":
195
+ case "uninspectable":
196
+ return true;
197
+ default: {
198
+ const unhandled = shape;
199
+ throw new Error(`hookIsObjectLike: unhandled shape ${String(unhandled)}`);
200
+ }
201
+ }
202
+ }
203
+ /** `./plain-json-object` 的 `isJsonArray`,逐字同文(闭包里不能 import):`Array.isArray` 的总版 —— 已撤销的 Proxy 读不出 ⇒ 不是数组,不抛。 */
204
+ function hookIsArray(v) {
205
+ try {
206
+ return Array.isArray(v);
207
+ }
208
+ catch {
209
+ return false; // 已撤销的 Proxy:读不出 ⇒ 不是数组
210
+ }
165
211
  }
166
212
  /** The first own key of `node` (in key order) that names a guard event with a value other than null / undefined / `[]` (CC `ei`). */
167
213
  function hookGuardKeyOf(node) {
@@ -169,7 +215,7 @@ function hookGuardKeyOf(node) {
169
215
  if (!HOOK_GUARD_EVENTS.includes(key))
170
216
  continue;
171
217
  const value = node[key];
172
- if (value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0))
218
+ if (value !== null && value !== undefined && !(hookIsArray(value) && value.length === 0))
173
219
  return key;
174
220
  }
175
221
  return undefined;
@@ -179,7 +225,7 @@ function hookLooksLikeMatcher(value) {
179
225
  if (!hookIsRecord(value))
180
226
  return false;
181
227
  const inner = Object.hasOwn(value, "hooks") ? value.hooks : undefined;
182
- if (Array.isArray(inner))
228
+ if (hookIsArray(inner))
183
229
  return inner.length > 0;
184
230
  return hookIsRecord(inner) && (Object.hasOwn(value, "matcher") || typeof inner.type === "string");
185
231
  }
@@ -203,25 +249,48 @@ function hookGuardSignIn(root, rootPath, place) {
203
249
  };
204
250
  while (pending.length > 0) {
205
251
  const [node, frame, at] = pending.pop();
206
- if (node === null || typeof node !== "object" || seen[at].has(node))
252
+ // the shape is asked BEFORE any `typeof` split: a node whose shape cannot be read — a proxy that has been revoked, of a function target included — cannot be shown free of guard signs
253
+ // ⇒ reported as a sign at its own coordinate (fail-closed: the document is rejected); no `typeof` gate of the classifier's own stands in front of the seat
254
+ const shape = hookShapeOf(node);
255
+ let isList = false;
256
+ switch (shape) {
257
+ case "null":
258
+ case "primitive":
259
+ continue; // not an object: nothing to search
260
+ case "uninspectable":
261
+ return pathTo(frame, []);
262
+ case "array":
263
+ isList = true;
264
+ break;
265
+ case "plain":
266
+ case "non_plain_object":
267
+ break; // searched as an object below
268
+ default: {
269
+ const unhandled = shape;
270
+ throw new Error(`hookGuardSignIn: unhandled shape ${String(unhandled)}`);
271
+ }
272
+ }
273
+ const visited = node;
274
+ if (seen[at].has(visited))
207
275
  continue;
208
- seen[at].add(node);
209
- if (Array.isArray(node)) {
210
- for (let i = node.length - 1; i >= 0; i--) {
276
+ seen[at].add(visited);
277
+ if (isList) {
278
+ const list = node;
279
+ for (let i = list.length - 1; i >= 0; i--) {
211
280
  frames.push({ key: i, parent: frame });
212
- pending.push([node[i], frames.length - 1, at === "matcherList" ? "matcher" : "other"]);
281
+ pending.push([list[i], frames.length - 1, at === "matcherList" ? "matcher" : "other"]);
213
282
  }
214
283
  continue;
215
284
  }
216
- const guardKey = hookGuardKeyOf(node);
285
+ const guardKey = hookGuardKeyOf(visited);
217
286
  if (guardKey !== undefined)
218
287
  return pathTo(frame, [guardKey]);
219
- if (at !== "matcher" && hookLooksLikeMatcher(node))
288
+ if (at !== "matcher" && hookLooksLikeMatcher(visited))
220
289
  return pathTo(frame, []);
221
- const keys = Object.keys(node);
290
+ const keys = Object.keys(visited);
222
291
  for (let i = keys.length - 1; i >= 0; i--) {
223
292
  frames.push({ key: keys[i], parent: frame });
224
- pending.push([node[keys[i]], frames.length - 1, "other"]);
293
+ pending.push([visited[keys[i]], frames.length - 1, "other"]);
225
294
  }
226
295
  }
227
296
  return undefined;
@@ -248,7 +317,7 @@ function hookFieldFits(base, value) {
248
317
  case "stringRecord":
249
318
  return hookIsRecord(value) && Object.values(value).every((v) => typeof v === "string");
250
319
  case "stringList":
251
- if (!Array.isArray(value))
320
+ if (!hookIsArray(value))
252
321
  return false;
253
322
  for (let i = 0; i < value.length; i++)
254
323
  if (typeof value[i] !== "string")
@@ -283,7 +352,7 @@ function hookEntryRead(entry) {
283
352
  const base = (optional ? kind.slice(0, -1) : kind);
284
353
  if (!hookFieldFits(base, value))
285
354
  return { field };
286
- out[field] = Array.isArray(value) ? value.slice() : hookIsRecord(value) ? Object.fromEntries(Object.entries(value)) : value;
355
+ out[field] = hookIsArray(value) ? value.slice() : hookIsRecord(value) ? Object.fromEntries(Object.entries(value)) : value;
287
356
  }
288
357
  return { entry: out };
289
358
  }
@@ -301,18 +370,18 @@ function hookMatcherRead(matcher, at, guardEvent, reject, drop) {
301
370
  // the rejection's `field` is the matcher's own guard-event key if it has one, else the first member (document order) whose subtree holds a sign —
302
371
  // the order hookGuardSignIn reports
303
372
  const signField = hookGuardKeyOf(matcher) ??
304
- Object.keys(matcher).find((key) => !(key === "hooks" && Array.isArray(entries)) && hookGuardSignIn(matcher[key], [...at, key], "other") !== undefined);
373
+ Object.keys(matcher).find((key) => !(key === "hooks" && hookIsArray(entries)) && hookGuardSignIn(matcher[key], [...at, key], "other") !== undefined);
305
374
  if (signField !== undefined) {
306
375
  reject("guard-matcher-invalid", at, signField);
307
376
  return undefined;
308
377
  }
309
378
  const entrySigns = [];
310
- if (Array.isArray(entries))
379
+ if (hookIsArray(entries))
311
380
  for (let j = 0; j < entries.length; j++)
312
381
  entrySigns.push(hookGuardSignIn(entries[j], [...at, "hooks", j], "other"));
313
382
  const name = Object.hasOwn(matcher, "matcher") ? matcher.matcher : undefined;
314
- const badField = name !== undefined && typeof name !== "string" ? "matcher" : !Array.isArray(entries) ? "hooks" : undefined;
315
- if (badField !== undefined || !Array.isArray(entries)) {
383
+ const badField = name !== undefined && typeof name !== "string" ? "matcher" : !hookIsArray(entries) ? "hooks" : undefined;
384
+ if (badField !== undefined || !hookIsArray(entries)) {
316
385
  if (guardEvent || entrySigns.some((sign) => sign !== undefined))
317
386
  reject("guard-matcher-invalid", at, badField);
318
387
  else
@@ -340,7 +409,10 @@ function hookMatcherRead(matcher, at, guardEvent, reject, drop) {
340
409
  /** The `hooks` value → what loads of it (undefined when nothing does), reporting its bad pieces / signs into the two sinks. */
341
410
  function hookBlockRead(hooks, reject, drop) {
342
411
  if (!hookIsRecord(hooks)) {
343
- if (Array.isArray(hooks) && hooks.some((x) => x !== null && typeof x === "object"))
412
+ if (hookIsArray(hooks) && hooks.some(hookIsObjectLike))
413
+ reject("hooks-is-matcher", ["hooks"]);
414
+ // an object that is not a readable block (a class instance, a proxy that cannot be inspected) holding — or possibly holding — a guard sign: reject, never drop it (a dropped guard silently disappears)
415
+ else if (hookGuardSignIn(hooks, ["hooks"], "other") !== undefined)
344
416
  reject("hooks-is-matcher", ["hooks"]);
345
417
  else
346
418
  drop("hooks-not-object", ["hooks"]);
@@ -355,14 +427,14 @@ function hookBlockRead(hooks, reject, drop) {
355
427
  const value = hooks[event];
356
428
  const at = ["hooks", event];
357
429
  if (!HOOK_EVENTS.includes(event)) {
358
- if (hookGuardSignIn(value, at, Array.isArray(value) ? "matcherList" : "other") !== undefined)
430
+ if (hookGuardSignIn(value, at, hookIsArray(value) ? "matcherList" : "other") !== undefined)
359
431
  reject("guard-under-unknown-event", at);
360
432
  else
361
433
  drop("unknown-event", at);
362
434
  continue;
363
435
  }
364
436
  const guardEvent = HOOK_GUARD_EVENTS.includes(event);
365
- if (!Array.isArray(value)) {
437
+ if (!hookIsArray(value)) {
366
438
  if ((guardEvent && value !== null) || hookGuardSignIn(value, at, "other") !== undefined)
367
439
  reject("guard-event-not-array", at);
368
440
  else
@@ -392,6 +464,15 @@ function hookBlockRead(hooks, reject, drop) {
392
464
  * the hooks-classify test file (group ⑤) pins that the free names are exactly these.
393
465
  */
394
466
  export function classifyHooksConfig(settingsDoc) {
467
+ try {
468
+ return hookClassify(settingsDoc);
469
+ }
470
+ catch {
471
+ // reading the document threw (a trap, an accessor that throws): nothing in it can be shown free of guard signs ⇒ rejected at the root — ONE `try` for the whole classifier, none per node
472
+ return { outcome: "rejected", hooks: undefined, dropped: [], rejections: [{ code: "guard-outside-hooks", path: [] }] };
473
+ }
474
+ }
475
+ function hookClassify(settingsDoc) {
395
476
  const rejections = [];
396
477
  const dropped = [];
397
478
  const reject = (code, path, field) => {
@@ -401,7 +482,7 @@ export function classifyHooksConfig(settingsDoc) {
401
482
  dropped.push(field === undefined ? { code, path } : { code, path, field });
402
483
  };
403
484
  let hooks;
404
- if (settingsDoc !== null && typeof settingsDoc === "object" && !Array.isArray(settingsDoc)) {
485
+ if (hookIsRecord(settingsDoc)) {
405
486
  const doc = settingsDoc;
406
487
  for (const key of Object.keys(doc)) {
407
488
  const value = doc[key];
@@ -412,7 +493,7 @@ export function classifyHooksConfig(settingsDoc) {
412
493
  // the document itself written as a matcher: its top-level `matcher` key is the evidence, reported at that key's place
413
494
  const sign = key === "matcher" && hookLooksLikeMatcher(doc)
414
495
  ? [key]
415
- : HOOK_GUARD_EVENTS.includes(key) && value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0)
496
+ : HOOK_GUARD_EVENTS.includes(key) && value !== null && value !== undefined && !(hookIsArray(value) && value.length === 0)
416
497
  ? [key]
417
498
  : hookGuardSignIn(value, [key], "other");
418
499
  if (sign !== undefined)
@@ -450,7 +531,7 @@ function hooksFace(carry, strip, take) {
450
531
  }
451
532
  /** The carrier for one entry of type `type`: the value itself when it says that type, else an untyped object (⇒ the issue names `type`). */
452
533
  function entryOfType(value, type) {
453
- return value !== null && typeof value === "object" && value.type === type ? value : {};
534
+ return isPlainJsonObject(value) && value.type === type ? value : {}; // 读不出 / 不是纯数据的值不当「自己说了这个 type」的载体:⇒ `{}`(issue 点名 `type`)
454
535
  }
455
536
  const entryFace = (type) => hooksFace((v) => ({ hooks: { Stop: [{ hooks: [entryOfType(v, type)] }] } }), 5, (h) => h.Stop[0].hooks[0]);
456
537
  export const CommandHookEntry = entryFace("command");
@@ -43,11 +43,15 @@ export interface HttpHookAllowlistLayerRead {
43
43
  readonly malformed: readonly HttpHookAllowlistKey[];
44
44
  }
45
45
  /**
46
- * 从一层的原始设置对象读出两键(唯一读法)。键缺席(或值为 `undefined`)⇒ 不设;字符串数组 ⇒ 原样;其余一切(非数组、含非串条目、`null`)
47
- * ⇒ **读成 `[]`**(CC 同向:坏形名单 = 一个都不许)并在 `malformed` 点名。整份设置不是对象 ⇒ 两键都缺。
46
+ * 从一层的原始设置对象读出两键(唯一读法)。键缺席(或值为 `undefined`)⇒ 不设;字符串数组 ⇒ 原样(过 `captureAllowlist`〔包内私有〕,与判官同一只读法);其余一切(非数组、含非串条目或洞、取值或读元素时抛、`null`)
47
+ * ⇒ **读成 `[]`**(CC 同向:坏形名单 = 一个都不许)并在 `malformed` 点名。整份设置不是对象(`null` / 原始值 / 数组)⇒ 两键都缺。
48
+ * **是对象但不朴素**(类实例 / `Map` / 建在别的对象原型上的记录 / 函数 —— JSON 解析不出来,只能是嵌入方程序拼的)⇒ 同样 fail-closed:两键都读成 `[]` 并点名 `malformed`。
49
+ * 把它当「没有设置」读,等于把一层带名单的托管设置静默丢掉、方向放行(与本模块「坏形 = 一个都不许」的失败方向相反)。
50
+ * 名单键**只靠继承可见**(不是自有键)⇒ 同样 fail-closed:该键读成 `[]` 并点名 `malformed`(自有键盖过继承键;自有键值为 `undefined` 仍是「不设」)。
51
+ * 原型是带成员的 null 原型对象的对象(`Object.create(<null 原型的名单袋>)`)是一例:它按座位的判据读成朴素、按**自有**键读,名单键只在原型上 ⇒ 坏形。它只能由程序造出,JSON 解析不出来(座位头注 `./plain-json-object` 同写)。
48
52
  */
49
53
  export declare function readHttpHookAllowlistLayer(source: SettingsSource, settings: unknown): HttpHookAllowlistLayerRead;
50
- /** 拦下一只 http hook 的原因。`rule`:`empty` = 有一张准入名单是空的(那一层改对之前一只都不许);`url` = 没命中那几张名单里的任何模式。
54
+ /** 拦下一只 http hook 的原因。`rule`:`empty` = 有一张准入名单是空的(那一层改对之前一只都不许;**名单形坏 —— 非串数组、含非串条目或洞、读它或读它的元素时 getter 抛 —— 也读成空**,所以 `empty` 不一定是写成了 `[]`);`url` = 没命中那几张名单里的任何模式。
51
55
  * `sources` = 不放行它的那几张名单所在的层(入参序)。零 URL 回显 —— URL 调用方自己有,按受众给显示形。 */
52
56
  export interface HttpHookUrlBlock {
53
57
  readonly list: "allowedHttpHookUrls";
@@ -15,6 +15,8 @@
15
15
  * 判官出结构不出句:放行 ⇒ `undefined`;拦下 ⇒ {@link HttpHookUrlBlock}(哪张名单 / 为什么 / 出自哪几层),用户面那一句由各宿主按它铸。
16
16
  */
17
17
  import { z } from "zod";
18
+ import { captureStringArray } from "./definition-copy.js";
19
+ import { jsonShapeOf } from "./plain-json-object.js";
18
20
  import { layerAdmission } from "./settings-sources.js";
19
21
  import { urlAllowPatternMatches } from "./url-pattern.js";
20
22
  /** 两键(闭集,声明序)。 */
@@ -30,26 +32,107 @@ export const HttpHookAllowlistSettings = z
30
32
  })
31
33
  .passthrough();
32
34
  /**
33
- * 从一层的原始设置对象读出两键(唯一读法)。键缺席(或值为 `undefined`)⇒ 不设;字符串数组 ⇒ 原样;其余一切(非数组、含非串条目、`null`)
34
- * ⇒ **读成 `[]`**(CC 同向:坏形名单 = 一个都不许)并在 `malformed` 点名。整份设置不是对象 ⇒ 两键都缺。
35
+ * 一张名单的**唯一读法**(读器与判官共用,两边永远同答):**取值 + 复制 + 检视整段在一个 try 里**,名单键只读一次;名单本身走包内的串数组捕获读法
36
+ * `captureStringArray`(`./definition-copy`:按下标 / `hasOwn` / 一次读 / 非串即坏 / 定义语义写入,不走迭代协议,交副本)—— 与请求面 `args` 同一只读法。
37
+ * 键没写(取到 `undefined`)⇒ `undefined`(不设);是串数组 ⇒ 一份已校验的捕获副本;其余一切 ⇒ 读成 `[]` 并标 `malformed`(fail-closed:一个都不许)——
38
+ * 取值抛(键本身是会抛的 getter)、不是串数组(含稀疏数组的洞 —— 不顺原型链读;`length` 不是安全非负整数;读元素时抛),一个也不抛出去。
39
+ */
40
+ function captureAllowlist(holder, key) {
41
+ try {
42
+ const v = holder[key];
43
+ if (v === undefined)
44
+ return undefined;
45
+ const list = captureStringArray(v); // 串数组的唯一读法(`./definition-copy`):按下标 / hasOwn / 一次读 / 非串即坏 / 定义语义写入,交副本
46
+ return list === undefined ? { list: [], malformed: true } : { list, malformed: false };
47
+ }
48
+ catch {
49
+ return { list: [], malformed: true };
50
+ }
51
+ }
52
+ function presenceOf(bag, key) {
53
+ try {
54
+ return Object.hasOwn(bag, key) ? "own" : key in bag ? "inherited" : "absent";
55
+ }
56
+ catch {
57
+ return "unreadable";
58
+ }
59
+ }
60
+ /**
61
+ * 从一层的原始设置对象读出两键(唯一读法)。键缺席(或值为 `undefined`)⇒ 不设;字符串数组 ⇒ 原样(过 `captureAllowlist`〔包内私有〕,与判官同一只读法);其余一切(非数组、含非串条目或洞、取值或读元素时抛、`null`)
62
+ * ⇒ **读成 `[]`**(CC 同向:坏形名单 = 一个都不许)并在 `malformed` 点名。整份设置不是对象(`null` / 原始值 / 数组)⇒ 两键都缺。
63
+ * **是对象但不朴素**(类实例 / `Map` / 建在别的对象原型上的记录 / 函数 —— JSON 解析不出来,只能是嵌入方程序拼的)⇒ 同样 fail-closed:两键都读成 `[]` 并点名 `malformed`。
64
+ * 把它当「没有设置」读,等于把一层带名单的托管设置静默丢掉、方向放行(与本模块「坏形 = 一个都不许」的失败方向相反)。
65
+ * 名单键**只靠继承可见**(不是自有键)⇒ 同样 fail-closed:该键读成 `[]` 并点名 `malformed`(自有键盖过继承键;自有键值为 `undefined` 仍是「不设」)。
66
+ * 原型是带成员的 null 原型对象的对象(`Object.create(<null 原型的名单袋>)`)是一例:它按座位的判据读成朴素、按**自有**键读,名单键只在原型上 ⇒ 坏形。它只能由程序造出,JSON 解析不出来(座位头注 `./plain-json-object` 同写)。
35
67
  */
36
68
  export function readHttpHookAllowlistLayer(source, settings) {
37
- const bag = typeof settings === "object" && settings !== null && !Array.isArray(settings) ? settings : {};
38
- const malformed = [];
39
- const lists = {};
69
+ const shape = jsonShapeOf(settings);
70
+ let bag = Object.create(null); // 空袋没有原型:不存在 / 非对象的层看不见 `Object.prototype` 上的污染键,仍「两键都缺」
71
+ let refuseBoth = false; // 是对象但不朴素 ⇒ 两键都读成 [](fail-closed)
72
+ switch (shape) {
73
+ case "plain":
74
+ bag = settings;
75
+ break;
76
+ case "non_plain_object":
77
+ case "uninspectable": // 读形状就抛的对象(已撤销的 Proxy)同样 fail-closed:读不出就不能当「没有设置」放行
78
+ refuseBoth = true;
79
+ break;
80
+ case "array":
81
+ case "null":
82
+ case "primitive":
83
+ break; // 整份设置不是对象 ⇒ 两键都缺
84
+ default: {
85
+ const unhandled = shape;
86
+ throw new Error(`readHttpHookAllowlistLayer: unhandled shape ${String(unhandled)}`);
87
+ }
88
+ }
89
+ const bad = Object.create(null); // 读成 [] 的坏形键(零原型的标记袋;`malformed` 数组出口只用定义语义造,见下)
90
+ // 累加器也零原型:`{}` 继承 `Object.prototype`,原型上被污染成只读(只有 getter)的同名键会让下面的赋值抛(读器该 fail-closed,不是抛)
91
+ const lists = Object.create(null);
40
92
  for (const key of HTTP_HOOK_ALLOWLIST_KEYS) {
41
- if (!Object.hasOwn(bag, key) || bag[key] === undefined)
42
- continue;
43
- const v = bag[key];
44
- if (Array.isArray(v) && v.every((x) => typeof x === "string"))
45
- lists[key] = [...v];
46
- else {
93
+ if (refuseBoth) {
47
94
  lists[key] = [];
48
- malformed.push(key);
95
+ bad[key] = true;
96
+ continue;
97
+ }
98
+ // 对袋的每一次触碰(`in` / `hasOwn` / 取值)都经守护:先问这个键在袋里怎么在场(一只守护的探针),再取值(`captureAllowlist`,自带守护)
99
+ const presence = presenceOf(bag, key);
100
+ switch (presence) {
101
+ case "absent":
102
+ continue;
103
+ case "inherited": // 名单键只靠继承可见 ⇒ 不能当「没有名单」放行
104
+ case "unreadable": // 袋读不出(`has` / `getOwnPropertyDescriptor` 陷阱抛)⇒ 同样 fail-closed
105
+ lists[key] = [];
106
+ bad[key] = true;
107
+ continue;
108
+ case "own": {
109
+ const read = captureAllowlist(bag, key); // 取值也在 try 里、只读一次:键是会抛的自有 getter ⇒ 坏形,不是抛出读器
110
+ if (read === undefined)
111
+ continue;
112
+ lists[key] = read.list;
113
+ if (read.malformed)
114
+ bad[key] = true;
115
+ break;
116
+ }
117
+ default: {
118
+ const unhandled = presence;
119
+ throw new Error(`readHttpHookAllowlistLayer: unhandled presence ${String(unhandled)}`);
120
+ }
49
121
  }
50
122
  }
123
+ // `malformed` 用 filter 造(定义语义)—— `push` 会命中 `Object.prototype` 上继承的数字键 setter、吞掉点名;层是普通对象(返回值的运行期形状与 v14.5.0 同),`...lists` 是定义语义拷贝,污染下不抛
124
+ const malformed = HTTP_HOOK_ALLOWLIST_KEYS.filter((key) => bad[key] === true);
51
125
  return { layer: { source, ...lists }, malformed };
52
126
  }
127
+ /**
128
+ * 一层的某张名单:按 `[[Get]]` 读(名单放在原型上的手造层 —— 类 getter、`Object.create(defaults)` —— 照旧生效,与 v14.5.0 同答),
129
+ * 过 `captureAllowlist`〔包内私有〕—— 与读器**同一只读法**,产出一份已校验的捕获副本,判官只读这份副本(元素只读一次)。
130
+ * 读到 `undefined` ⇒ 没写(`undefined`);取值时 getter 抛、或读到的不是串数组(污染出非数组、含非串条目或洞、读 `length` / 元素的 getter 或 Proxy 陷阱抛)⇒ 读成 `[]`(fail-closed:一个都不许),不抛。
131
+ * 原型污染出来的名单只会让准入名单取交后更窄 —— 方向是拦,不是放;读器造的缺席层也是普通对象,污染下同样被收紧(fail-closed,判决的 `sources` 点名该层)。
132
+ */
133
+ function listOf(layer, key) {
134
+ return captureAllowlist(layer, key)?.list;
135
+ }
53
136
  /** 进判决的那几层(入参序):`layerAdmission` 答 `admitted` 的层。每一层都问 —— 闭集外的源 / 工作区读数在这里响亮抛,哪怕那一层没写名单。 */
54
137
  function admittedLayers(layers, workspace) {
55
138
  return layers.filter((l) => layerAdmission(l.source, workspace, "httpHookAllowlists") === "admitted");
@@ -59,15 +142,18 @@ function admittedLayers(layers, workspace) {
59
142
  * 没有准入层写这张名单 ⇒ 不限(`undefined`)。`workspace` = 宿主对工作区信任的读数(读口抛或闭集外按 `declined` 交),与 `layerAdmission` 同一个入参。
60
143
  */
61
144
  export function httpHookUrlVerdict(url, layers, workspace) {
62
- const refusing = admittedLayers(layers, workspace).filter((l) => l.allowedHttpHookUrls !== undefined && !l.allowedHttpHookUrls.some((p) => urlAllowPatternMatches(url, p)));
145
+ const refusing = admittedLayers(layers, workspace).flatMap((l) => {
146
+ const list = listOf(l, "allowedHttpHookUrls");
147
+ return list !== undefined && !list.some((p) => urlAllowPatternMatches(url, p)) ? [{ source: l.source, empty: list.length === 0 }] : [];
148
+ });
63
149
  if (refusing.length === 0)
64
150
  return undefined;
65
- return { list: "allowedHttpHookUrls", rule: refusing.some((l) => l.allowedHttpHookUrls.length === 0) ? "empty" : "url", sources: refusing.map((l) => l.source) };
151
+ return { list: "allowedHttpHookUrls", rule: refusing.some((r) => r.empty) ? "empty" : "url", sources: refusing.map((r) => r.source) };
66
152
  }
67
153
  /** 一只 http hook 实际可插值进请求头的环境变量:它自己的 `allowedEnvVars` 与每一张准入的 `httpHookAllowedEnvVars` 取交(保持 hook 自己的序)。 */
68
154
  export function httpHookEnvVarsAllowed(hookVars, layers, workspace) {
69
155
  const lists = admittedLayers(layers, workspace)
70
- .filter((l) => l.httpHookAllowedEnvVars !== undefined)
71
- .map((l) => l.httpHookAllowedEnvVars);
156
+ .map((l) => listOf(l, "httpHookAllowedEnvVars"))
157
+ .filter((list) => list !== undefined);
72
158
  return hookVars.filter((v) => lists.every((list) => list.includes(v)));
73
159
  }
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ export * from "./reader.js";
18
18
  export * from "./cross-domain.js";
19
19
  export * from "./remote-exec.js";
20
20
  export * from "./safety-merge-spec.js";
21
+ export * from "./settings-units.js";
21
22
  export * from "./scheduler-store.js";
22
23
  export * from "./cross-session.js";
23
24
  export * from "./hooks.js";
package/dist/index.js CHANGED
@@ -17,7 +17,8 @@ export * from "./config-fns.js";
17
17
  export * from "./reader.js";
18
18
  export * from "./cross-domain.js"; // pure half only (no fleet placement)
19
19
  export * from "./remote-exec.js";
20
- export * from "./safety-merge-spec.js"; // #14 per-category safety merge-spec (settings resolver authority)
20
+ export * from "./safety-merge-spec.js"; // #14 per-category safety merge-spec (settings resolver authority) + 14.6.0 THE comparator tightenVerdict / TIGHTEN_VERDICTS and the member comparator memberTighten / resolveMemberTighten (derived from a member row's value class)
21
+ export * from "./settings-units.js"; // 14.6.0: SETTINGS_UNITS — the settings-document unit table (path / category / addressing / reading) + unitTighten (a unit's comparator) + readSettingsUnit (a unit's raw-value reading; spellings normalized, everything else unreadable), pure/browser-safe
21
22
  export * from "./scheduler-store.js"; // R7 self-wake scheduler store CONTRACT (pure: type + parse/serialize + SchedulerStore interface; fs binding in ./node)
22
23
  export * from "./cross-session.js"; // CC-parity `settings.json` cross-session keys (crossSessionInbound / dialogExpiry) — pure/browser-safe
23
24
  export * from "./hooks.js"; // CC-parity hooks contract (33-event set + owner map + per-event input/output shapes + the config surface: classifyHooksConfig and its zod faces HooksConfig / HookEntry …) & stop attribution (stoppedBy) — pure/browser-safe
@@ -1,11 +1,14 @@
1
1
  import { type McpContentOrigin } from "./types.js";
2
- /** 值的**种类**(不回显值本身)。 */
2
+ /** 值的**种类**(不回显值本身)。总(不抛):经 {@link inspectSafely}。 */
3
3
  export declare function describeValueShape(v: unknown): string;
4
- /** 「不是字符串数组」的种类:数组里混了非串元素说那一句,否则回落到 {@link describeValueShape}。 */
4
+ /** 「不是字符串数组」的种类:数组里混了非串元素说那一句,否则回落到 {@link describeValueShape}。总(不抛):经 {@link inspectSafely}。 */
5
5
  export declare function describeNotStringArray(v: unknown): string;
6
- /** 「不是字符串值的对象」的种类:对象里混了非串值说那一句,否则回落到 {@link describeValueShape}。 */
6
+ /** 「不是字符串值的对象」的种类:对象里混了非串值说那一句,否则回落到 {@link describeValueShape}。总(不抛):经 {@link inspectSafely}。 */
7
7
  export declare function describeNotStringRecord(v: unknown): string;
8
- /** 字符串数组(空数组合法)。 */
8
+ /**
9
+ * 字符串数组(空数组合法)。与传输读器读 `args` 是**同一只读法**(`./definition-copy` 的 `captureStringArray`:按下标、自有下标、一次读、非串即坏、不走迭代协议):
10
+ * 稀疏数组(洞)、`length` 读不出安全非负整数的数组、读元素时抛的数组都不是 —— 洞不顺原型链读到 `Object.prototype` / `Array.prototype` 上的数字键。
11
+ */
9
12
  export declare function isStringArray(v: unknown): v is string[];
10
13
  /**
11
14
  * 纯数据的「名 → 串」记录(空对象合法;数组、`Map`、类实例、自有 `__proto__` 成员、有非串成员的对象都不是)。与传输读器读 `env` / `headers` 是**同一个**定义
@@ -58,7 +61,7 @@ export type RequestMcpTransport = {
58
61
  * 条目内一处坏形:`path` = 条目内的键路径(`transport.url`),`reason` = 判官的原因句(只说形与词表,不回显值)。
59
62
  *
60
63
  * `env` / `headers` 这两张「名 → 串」记录上的坏形另带两格机读位,调用方分流不必解析句子:`fault` = 闭集词 ——
61
- * · `not_plain_record`:这个键的值不是纯数据记录(不是对象 / 数组 / `null`,或原型不是 `Object.prototype` 与 `null`:`Map`、`Set`、类实例);整张记录不交;
64
+ * · `not_plain_record`:这个键的值不是纯数据记录(不是对象 / 数组 / `null`,或不是朴素 JSON 对象 —— 原型链比座位的判据长:`Map`、`Set`、类实例);整张记录不交;
62
65
  * · `member_not_string`:有一个成员的值不是串(或访问器抛了);整张记录不交,`member` 点名它;
63
66
  * · `member_name_reserved`:有一个成员叫 `__proto__`(引擎的声明收据拒这个名)。成员的值是串 ⇒ 只剔这个成员、其余照交,`path` 是成员级(`transport.env.__proto__`);
64
67
  * 成员的值也不是串 ⇒ 整张记录不交,`path` 是键级,句子一次说全两件事 —— 名坏优先、值坏并列,所以词仍是 `member_name_reserved`。
@@ -84,7 +87,7 @@ export type RequestMcpTransportRead = {
84
87
  * · 硬坏形(`transport` 不是对象 / `kind` 出闭集 / stdio 缺 `command` / http 的 `url` 不是 http(s))⇒ `ok:false`,`path` 是读器点名的键路径;
85
88
  * · 软坏形(`args` 不是串数组 / `env` `headers` 不是纯数据的串记录 / `principalHeader` 非串)⇒ 读器**丢这个键**(重放存量提交体时的升级前读法),并把它记进 `lost`
86
89
  * (受理新提交的门据它响亮拒)。`assertionHeader` 不在 `lost` 里:两个身份头的键组合与 `assertionHeader` 的值形由调用方在读之前对原始传输判。
87
- * · `env` / `headers` 只收**纯数据**的记录({@link readPlainStringRecord}):原型必须是 `Object.prototype` 或 `null`(`Map`、`Set`、类实例整张不收),每个自有成员的值都是串;
90
+ * · `env` / `headers` 只收**纯数据**的记录({@link readPlainStringRecord}):必须是朴素 JSON 对象(`./plain-json-object` 的判据;`Map`、`Set`、类实例整张不收),每个自有成员的值都是串;
88
91
  * 交出的是**副本**(不是调用方的对象,改原对象不影响读后的形)。名叫 `__proto__` 的成员(JSON 文本铸得出这个自有键,对象字面量写不出)不进副本、单独记进 `lost`。
89
92
  * 坏形的 `lost` 条目带机读位(见 {@link McpEntryKeyIssue})。
90
93
  * url 的拒句不回显值:它常规地带凭据(userinfo)。