@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
@@ -4,8 +4,12 @@
4
4
  * law. This formalizes settings-core's v2-design §3 merge-spec into a published-data contract; the SEMANTIC
5
5
  * author is settings-core, web-admin maintains the formalized artifact here (this package), the axis VALUE domains
6
6
  * are core's (@sema-agent/core SAFETY_AXIS_VOCABULARY — validated by a dev-only conformance test, never a runtime
7
- * import). Each consumer keeps its OWN key→category map (client's SemaSettings KEY_CONTRACTS, web-admin's
8
- * config-field→category map); THIS file is only the shared category spec they all cite — no third copy of the law.
7
+ * import). The settings-document UNIT table — which keys of a settings file carry a merge contract, where each sits,
8
+ * which category below it maps into, and how its raw value reads — is published by this package too, as
9
+ * `SETTINGS_UNITS` (`./settings-units.ts`, 14.6.0): a consumer reads it instead of keeping a key→category map of its
10
+ * own for those ten units (the http-hook allowlist keys are the one exception: `./http-hook-allowlists` keys and reads them
11
+ * itself, with its own malformed-value reading — see the `./settings-units` header). THIS file is the category spec every unit
12
+ * cites — no second copy of the law.
9
13
  *
10
14
  * Per-category behavior is fully determined by `mergeShape` + `minTrust` + `tighten` + `enforcement` + `fail` — and,
11
15
  * for a MEMBERIZED category (one that declares `members`, 1.1.0), by its member rows: each member's own floor and
@@ -14,6 +18,16 @@
14
18
  * admitted or rejected as one value under its `minTrust`. A resolver reads this table and has NO per-key
15
19
  * special-casing.
16
20
  *
21
+ * COMPARISON (14.6.0) — "which of two values is stricter" has ONE implementation, {@link tightenVerdict}, and it reads
22
+ * ORDER only, never spelling (an empty-set sentinel, a "≤ 0 means off" cap, CC's `"disable"`, a mode alias are the value
23
+ * owner's reading — `readSettingsUnit` for a settings unit).
24
+ * A category's `tighten` answers ONLY the value of a `whole` unit mapped into it (today: `permissionMode`'s enumRank,
25
+ * `additionalDirectories`' setNarrower, `none` for the rest).
26
+ * A DECLARED member's comparator is derived from its row's value class ({@link memberTighten}: `boolPole` ⇒ boolStricter
27
+ * at that pole; `list` ⇒ setUnion for a tighten member, setNarrower for a loosen one). A memberized category's whole value
28
+ * and its UNDECLARED members have NO comparator — they only have the category floor's admission (the paragraph above);
29
+ * asking for one is a RangeError, never a fallback to the category rule.
30
+ *
17
31
  * MEMBER-LEVEL ADMISSION (1.1.0 — the WRITTEN layer of the permission-rule dual-channel ruling, [ref] ④;
18
32
  * the executing layer is core's `prepareCcImport` door + `RuleAddOrigin`, the resolver/shell halves are cli's):
19
33
  * A category may declare `members`, each with its own admission floor and DIRECTION. Today only `permissions`
@@ -53,8 +67,8 @@
53
67
  import { z } from "zod";
54
68
  /** How values from different layers/principals combine for a category. */
55
69
  export declare const MergeShape: z.ZodEnum<{
56
- concat: "concat";
57
70
  replace: "replace";
71
+ concat: "concat";
58
72
  denyFirst: "denyFirst";
59
73
  runAll: "runAll";
60
74
  overrideByName: "overrideByName";
@@ -121,7 +135,14 @@ export type MemberDirection = z.infer<typeof MemberDirection>;
121
135
  * must be the LOOSEN floor (the category's), so a consumer that predates this field (its schema strips
122
136
  * `boolPole`) reads the row as tighten@<loosen-floor> and withholds BOTH poles from low-trust layers —
123
137
  * fail-closed (it loses only the convenience of low-trust tightening, it can never admit a loosening write).
124
- * Resolution is value-aware: pass the candidate value to {@link memberAdmission} / {@link memberMinTrust}. */
138
+ * Resolution is value-aware: pass the candidate value to {@link memberAdmission} / {@link memberMinTrust}.
139
+ *
140
+ * `list` (14.6.0) — the VALUE-CLASS seat of a member whose value is a string list (`permissions.allow` / `deny` / `ask`,
141
+ * `sandboxNetwork.filesystem.denyWrite` / `denyRead`). A row declares exactly ONE value class — `boolPole` (a boolean)
142
+ * or `list` — and the member's comparator is derived from it ({@link resolveMemberTighten}); neither or both is a
143
+ * RangeError there, never a guess (a future numeric member is neither, so it can never be silently read as a list —
144
+ * its value class has to be designed first). The seat carries no floor, so admission reads exactly as before, and a
145
+ * consumer whose schema predates it strips it. */
125
146
  export declare const MemberAdmission: z.ZodObject<{
126
147
  direction: z.ZodEnum<{
127
148
  loosen: "loosen";
@@ -146,6 +167,7 @@ export declare const MemberAdmission: z.ZodObject<{
146
167
  task: "task";
147
168
  }>;
148
169
  }, z.core.$strip>>;
170
+ list: z.ZodOptional<z.ZodLiteral<true>>;
149
171
  }, z.core.$strip>;
150
172
  export type MemberAdmission = z.infer<typeof MemberAdmission>;
151
173
  /** The RESOLVED admission a consumer branches on — the category fallback already applied, so `minTrust` is never
@@ -180,7 +202,12 @@ export type FailMode = z.infer<typeof FailMode>;
180
202
  * from LOOSEST→STRICTEST — the category's whole vocabulary: a word outside it is refused, and a ONE-word order (11.0.0:
181
203
  * `shellGate`, whose engine vocabulary is the single floor word) is the degenerate rank where the only merge is identity;
182
204
  * `boolStricter.strict` is the bool value that is the tighter one; `setNarrower` = a subset is tighter; `policyDenyWins` =
183
- * deny beats allow; `none` = not rank-tightenable (replace / conflict-reject). */
205
+ * deny beats allow (a fold law BETWEEN a category's members, not an order on one value); `none` = not rank-tightenable
206
+ * (replace / conflict-reject); `setUnion` (14.6.0) = a superset is tighter; `numericLower` (14.6.0) = a smaller number is
207
+ * tighter, `+Infinity` (no limit) the loosest. A parameter may only change the ORDER (`strict`, `order`), so the two
208
+ * appended words take none: a literal that reads as "empty set" or "no limit" is a spelling, normalized by the value's
209
+ * owner before it reaches the comparator. Executed by {@link tightenVerdict}. The two words are appended at the tail
210
+ * (README 发版纪律: a consumer holding an exhaustive table over `rule` goes compile-red at pickup — the pickup cost). */
184
211
  export declare const TightenRule: z.ZodDiscriminatedUnion<[z.ZodObject<{
185
212
  rule: z.ZodLiteral<"none">;
186
213
  }, z.core.$strip>, z.ZodObject<{
@@ -193,12 +220,43 @@ export declare const TightenRule: z.ZodDiscriminatedUnion<[z.ZodObject<{
193
220
  }, z.core.$strip>, z.ZodObject<{
194
221
  rule: z.ZodLiteral<"enumRank">;
195
222
  order: z.ZodArray<z.ZodString>;
223
+ }, z.core.$strip>, z.ZodObject<{
224
+ rule: z.ZodLiteral<"setUnion">;
225
+ }, z.core.$strip>, z.ZodObject<{
226
+ rule: z.ZodLiteral<"numericLower">;
196
227
  }, z.core.$strip>], "rule">;
197
228
  export type TightenRule = z.infer<typeof TightenRule>;
229
+ /** Where `candidate` lands relative to `existing` under a {@link TightenRule} (14.6.0; closed, in this order):
230
+ * `equal` · `stricter` · `looser` · `unordered` (comparable values, neither side contains the other — two different sets,
231
+ * two different `none` values) · `unreadable` (a side is outside the rule's normalized domain — absent, misspelled,
232
+ * the wrong type). */
233
+ export declare const TIGHTEN_VERDICTS: readonly ["equal", "stricter", "looser", "unordered", "unreadable"];
234
+ export type TightenVerdict = (typeof TIGHTEN_VERDICTS)[number];
235
+ /**
236
+ * THE comparator (14.6.0): where `candidate` lands relative to `existing` under `rule`. One implementation for every
237
+ * consumer — the shell's resolver, the server's catalog column, the profile loader — so "stricter" has one reading.
238
+ * It reads ORDER only: each rule has a normalized domain, and a side outside it is `unreadable` (spellings are normalized
239
+ * by the value's owner first — `readSettingsUnit` for a settings unit):
240
+ * · `enumRank` — a word of `order`; the higher rank is stricter, the same word `equal`;
241
+ * · `boolStricter` — a boolean (strict equality, never truthiness: the string `"false"` is unreadable, not `true`);
242
+ * the `strict` pole is stricter;
243
+ * · `setUnion` — an array of strings, as a SET (order and duplicates do not count): a proper superset is stricter,
244
+ * a proper subset looser, the same set `equal`, neither containing the other `unordered`;
245
+ * · `setNarrower` — the same domain, the opposite direction;
246
+ * · `numericLower` — a finite number or `+Infinity` (no limit, the loosest): the smaller is stricter;
247
+ * · `none` — a strict JSON value no deeper than {@link MAX_HASH_DEPTH}: `equal` when `stableStringify` agrees
248
+ * (key order does not count), else `unordered`;
249
+ * · `policyDenyWins` — a RangeError: it is the fold law BETWEEN a category's members, not an order on one value —
250
+ * compare a member through {@link memberTighten}.
251
+ * Both sides must be present: absence is not a value (`undefined` ⇒ `unreadable`); "an absent landing spot takes the
252
+ * write" is the loading law's business, not the comparator's. A rule word outside the closed set (a JavaScript /
253
+ * deserialized caller) is a RangeError, never a silent verdict.
254
+ */
255
+ export declare function tightenVerdict(rule: TightenRule, existing: unknown, candidate: unknown): TightenVerdict;
198
256
  export declare const MergeCategorySpec: z.ZodObject<{
199
257
  mergeShape: z.ZodEnum<{
200
- concat: "concat";
201
258
  replace: "replace";
259
+ concat: "concat";
202
260
  denyFirst: "denyFirst";
203
261
  runAll: "runAll";
204
262
  overrideByName: "overrideByName";
@@ -223,6 +281,10 @@ export declare const MergeCategorySpec: z.ZodObject<{
223
281
  }, z.core.$strip>, z.ZodObject<{
224
282
  rule: z.ZodLiteral<"enumRank">;
225
283
  order: z.ZodArray<z.ZodString>;
284
+ }, z.core.$strip>, z.ZodObject<{
285
+ rule: z.ZodLiteral<"setUnion">;
286
+ }, z.core.$strip>, z.ZodObject<{
287
+ rule: z.ZodLiteral<"numericLower">;
226
288
  }, z.core.$strip>], "rule">;
227
289
  enforcement: z.ZodEnum<{
228
290
  gate: "gate";
@@ -268,6 +330,7 @@ export declare const MergeCategorySpec: z.ZodObject<{
268
330
  task: "task";
269
331
  }>;
270
332
  }, z.core.$strip>>;
333
+ list: z.ZodOptional<z.ZodLiteral<true>>;
271
334
  }, z.core.$strip>>>;
272
335
  }, z.core.$strip>;
273
336
  export type MergeCategorySpec = z.infer<typeof MergeCategorySpec>;
@@ -288,14 +351,17 @@ export declare const SAFETY_MERGE_SPEC: {
288
351
  readonly allow: {
289
352
  readonly direction: "loosen";
290
353
  readonly minTrust: "global";
354
+ readonly list: true;
291
355
  };
292
356
  readonly deny: {
293
357
  readonly direction: "tighten";
294
358
  readonly minTrust: "local";
359
+ readonly list: true;
295
360
  };
296
361
  readonly ask: {
297
362
  readonly direction: "tighten";
298
363
  readonly minTrust: "local";
364
+ readonly list: true;
299
365
  };
300
366
  };
301
367
  };
@@ -429,10 +495,12 @@ export declare const SAFETY_MERGE_SPEC: {
429
495
  readonly "filesystem.denyWrite": {
430
496
  readonly direction: "tighten";
431
497
  readonly minTrust: "local";
498
+ readonly list: true;
432
499
  };
433
500
  readonly "filesystem.denyRead": {
434
501
  readonly direction: "tighten";
435
502
  readonly minTrust: "local";
503
+ readonly list: true;
436
504
  };
437
505
  };
438
506
  };
@@ -608,3 +676,17 @@ export declare function memberAdmission<C extends SafetyMergeCategory>(category:
608
676
  /** A member's admission FLOOR from the published table — the word a resolver compares the layer's trust against
609
677
  * (`isAtLeast(layer, floor)` ⇒ admitted; the ladder is {@link trustRank}). Convenience over {@link memberAdmission}. */
610
678
  export declare function memberMinTrust<C extends SafetyMergeCategory>(category: C, member: SafetyMergeMember<C>, value?: unknown): MinTrust;
679
+ /**
680
+ * Derive one member's comparator off a category spec VALUE (14.6.0; the single implementation — {@link memberTighten} binds
681
+ * it to {@link SAFETY_MERGE_SPEC}). The comparator follows from the row's VALUE CLASS and direction, never from the category's
682
+ * `tighten` (that answers only a `whole` unit's value — the file header):
683
+ * · `boolPole` ⇒ `{ rule: "boolStricter", strict: boolPole.strict }`;
684
+ * · `list` ⇒ `setUnion` for a `tighten` member (a superset narrows more), `setNarrower` for a `loosen` member (a subset grants less).
685
+ * A row declaring NEITHER value class or BOTH is a RangeError (nothing to derive / two readings) — never a default; so are the
686
+ * misuse arms {@link resolveMemberAdmission} has (a whole-key category, an undeclared member).
687
+ * @param categoryLabel only decorates the error message.
688
+ */
689
+ export declare function resolveMemberTighten(spec: MergeCategorySpec, member: string, categoryLabel?: string): TightenRule;
690
+ /** A declared member's comparator from the published table (see {@link resolveMemberTighten}); compare two of its values with
691
+ * {@link tightenVerdict}. Unknown category or member ⇒ RangeError (never the category rule as a fallback). */
692
+ export declare function memberTighten<C extends SafetyMergeCategory>(category: C, member: SafetyMergeMember<C>): TightenRule;
@@ -4,8 +4,12 @@
4
4
  * law. This formalizes settings-core's v2-design §3 merge-spec into a published-data contract; the SEMANTIC
5
5
  * author is settings-core, web-admin maintains the formalized artifact here (this package), the axis VALUE domains
6
6
  * are core's (@sema-agent/core SAFETY_AXIS_VOCABULARY — validated by a dev-only conformance test, never a runtime
7
- * import). Each consumer keeps its OWN key→category map (client's SemaSettings KEY_CONTRACTS, web-admin's
8
- * config-field→category map); THIS file is only the shared category spec they all cite — no third copy of the law.
7
+ * import). The settings-document UNIT table — which keys of a settings file carry a merge contract, where each sits,
8
+ * which category below it maps into, and how its raw value reads — is published by this package too, as
9
+ * `SETTINGS_UNITS` (`./settings-units.ts`, 14.6.0): a consumer reads it instead of keeping a key→category map of its
10
+ * own for those ten units (the http-hook allowlist keys are the one exception: `./http-hook-allowlists` keys and reads them
11
+ * itself, with its own malformed-value reading — see the `./settings-units` header). THIS file is the category spec every unit
12
+ * cites — no second copy of the law.
9
13
  *
10
14
  * Per-category behavior is fully determined by `mergeShape` + `minTrust` + `tighten` + `enforcement` + `fail` — and,
11
15
  * for a MEMBERIZED category (one that declares `members`, 1.1.0), by its member rows: each member's own floor and
@@ -14,6 +18,16 @@
14
18
  * admitted or rejected as one value under its `minTrust`. A resolver reads this table and has NO per-key
15
19
  * special-casing.
16
20
  *
21
+ * COMPARISON (14.6.0) — "which of two values is stricter" has ONE implementation, {@link tightenVerdict}, and it reads
22
+ * ORDER only, never spelling (an empty-set sentinel, a "≤ 0 means off" cap, CC's `"disable"`, a mode alias are the value
23
+ * owner's reading — `readSettingsUnit` for a settings unit).
24
+ * A category's `tighten` answers ONLY the value of a `whole` unit mapped into it (today: `permissionMode`'s enumRank,
25
+ * `additionalDirectories`' setNarrower, `none` for the rest).
26
+ * A DECLARED member's comparator is derived from its row's value class ({@link memberTighten}: `boolPole` ⇒ boolStricter
27
+ * at that pole; `list` ⇒ setUnion for a tighten member, setNarrower for a loosen one). A memberized category's whole value
28
+ * and its UNDECLARED members have NO comparator — they only have the category floor's admission (the paragraph above);
29
+ * asking for one is a RangeError, never a fallback to the category rule.
30
+ *
17
31
  * MEMBER-LEVEL ADMISSION (1.1.0 — the WRITTEN layer of the permission-rule dual-channel ruling, [ref] ④;
18
32
  * the executing layer is core's `prepareCcImport` door + `RuleAddOrigin`, the resolver/shell halves are cli's):
19
33
  * A category may declare `members`, each with its own admission floor and DIRECTION. Today only `permissions`
@@ -51,6 +65,9 @@
51
65
  * ladder anyway; only {@link trustRank} is.
52
66
  */
53
67
  import { z } from "zod";
68
+ import { MAX_HASH_DEPTH, stableStringify } from "./hash.js";
69
+ import { captureStringArray, dataDescriptor } from "./definition-copy.js";
70
+ import { isJsonArray, isPlainJsonObject } from "./plain-json-object.js";
54
71
  /** How values from different layers/principals combine for a category. */
55
72
  export const MergeShape = z.enum(["denyFirst", "replace", "runAll", "overrideByName", "concat"]);
56
73
  /** The trust-tier VOCABULARY as a RUNTIME VALUE — the ONE source of the ladder's words, in the same form as
@@ -117,11 +134,19 @@ export const MemberDirection = z.enum(["loosen", "tighten"]);
117
134
  * must be the LOOSEN floor (the category's), so a consumer that predates this field (its schema strips
118
135
  * `boolPole`) reads the row as tighten@<loosen-floor> and withholds BOTH poles from low-trust layers —
119
136
  * fail-closed (it loses only the convenience of low-trust tightening, it can never admit a loosening write).
120
- * Resolution is value-aware: pass the candidate value to {@link memberAdmission} / {@link memberMinTrust}. */
137
+ * Resolution is value-aware: pass the candidate value to {@link memberAdmission} / {@link memberMinTrust}.
138
+ *
139
+ * `list` (14.6.0) — the VALUE-CLASS seat of a member whose value is a string list (`permissions.allow` / `deny` / `ask`,
140
+ * `sandboxNetwork.filesystem.denyWrite` / `denyRead`). A row declares exactly ONE value class — `boolPole` (a boolean)
141
+ * or `list` — and the member's comparator is derived from it ({@link resolveMemberTighten}); neither or both is a
142
+ * RangeError there, never a guess (a future numeric member is neither, so it can never be silently read as a list —
143
+ * its value class has to be designed first). The seat carries no floor, so admission reads exactly as before, and a
144
+ * consumer whose schema predates it strips it. */
121
145
  export const MemberAdmission = z.object({
122
146
  direction: MemberDirection,
123
147
  minTrust: MinTrust.optional(),
124
148
  boolPole: z.object({ strict: z.boolean(), minTrust: MinTrust }).optional(),
149
+ list: z.literal(true).optional(),
125
150
  });
126
151
  /** WHERE the category is enforced (STRENGTH) — MUST stay in lockstep with `EffectiveKey.enforcement` (types.ts).
127
152
  * gate=data-plane engine, server=control-plane/TOB boundary, resolver=merger correctness, client=UI only. */
@@ -133,14 +158,166 @@ export const FailMode = z.enum(["closed", "open"]);
133
158
  * from LOOSEST→STRICTEST — the category's whole vocabulary: a word outside it is refused, and a ONE-word order (11.0.0:
134
159
  * `shellGate`, whose engine vocabulary is the single floor word) is the degenerate rank where the only merge is identity;
135
160
  * `boolStricter.strict` is the bool value that is the tighter one; `setNarrower` = a subset is tighter; `policyDenyWins` =
136
- * deny beats allow; `none` = not rank-tightenable (replace / conflict-reject). */
161
+ * deny beats allow (a fold law BETWEEN a category's members, not an order on one value); `none` = not rank-tightenable
162
+ * (replace / conflict-reject); `setUnion` (14.6.0) = a superset is tighter; `numericLower` (14.6.0) = a smaller number is
163
+ * tighter, `+Infinity` (no limit) the loosest. A parameter may only change the ORDER (`strict`, `order`), so the two
164
+ * appended words take none: a literal that reads as "empty set" or "no limit" is a spelling, normalized by the value's
165
+ * owner before it reaches the comparator. Executed by {@link tightenVerdict}. The two words are appended at the tail
166
+ * (README 发版纪律: a consumer holding an exhaustive table over `rule` goes compile-red at pickup — the pickup cost). */
137
167
  export const TightenRule = z.discriminatedUnion("rule", [
138
168
  z.object({ rule: z.literal("none") }),
139
169
  z.object({ rule: z.literal("policyDenyWins") }),
140
170
  z.object({ rule: z.literal("setNarrower") }),
141
171
  z.object({ rule: z.literal("boolStricter"), strict: z.boolean() }),
142
172
  z.object({ rule: z.literal("enumRank"), order: z.array(z.string()).min(1) }),
173
+ z.object({ rule: z.literal("setUnion") }),
174
+ z.object({ rule: z.literal("numericLower") }),
143
175
  ]);
176
+ /** Where `candidate` lands relative to `existing` under a {@link TightenRule} (14.6.0; closed, in this order):
177
+ * `equal` · `stricter` · `looser` · `unordered` (comparable values, neither side contains the other — two different sets,
178
+ * two different `none` values) · `unreadable` (a side is outside the rule's normalized domain — absent, misspelled,
179
+ * the wrong type). */
180
+ export const TIGHTEN_VERDICTS = ["equal", "stricter", "looser", "unordered", "unreadable"];
181
+ /** `diff` > 0 ⇒ the candidate is higher on the strictness order. */
182
+ function orderVerdict(diff) {
183
+ return diff > 0 ? "stricter" : diff < 0 ? "looser" : "equal";
184
+ }
185
+ /** A string list as a set (order and duplicates do not count); `undefined` when the value is not an array of strings
186
+ * — a sparse array, an accessor that throws, a proxy that cannot be read (revoked, a trap that throws) is not one: all through the package's one string-array
187
+ * reading ({@link captureStringArray}), so the comparator never throws on them and never splits a string. */
188
+ function stringSetOf(v) {
189
+ const list = captureStringArray(v);
190
+ return list === undefined ? undefined : new Set(list);
191
+ }
192
+ /** `superset ⇒ stricter` for setUnion, the mirror for setNarrower. */
193
+ function setVerdict(existing, candidate, supersetIsStricter) {
194
+ const a = stringSetOf(existing);
195
+ const b = stringSetOf(candidate);
196
+ if (a === undefined || b === undefined)
197
+ return "unreadable";
198
+ const candidateCoversExisting = [...a].every((x) => b.has(x));
199
+ const existingCoversCandidate = [...b].every((x) => a.has(x));
200
+ if (candidateCoversExisting && existingCoversCandidate)
201
+ return "equal";
202
+ if (candidateCoversExisting)
203
+ return supersetIsStricter ? "stricter" : "looser";
204
+ if (existingCoversCandidate)
205
+ return supersetIsStricter ? "looser" : "stricter";
206
+ return "unordered";
207
+ }
208
+ /** numericLower's domain: a finite number, or `+Infinity` (= no limit, the loosest). NaN / `-Infinity` / a non-number are not in it. */
209
+ function isNumericLowerValue(v) {
210
+ return typeof v === "number" && (Number.isFinite(v) || v === Number.POSITIVE_INFINITY);
211
+ }
212
+ const NOT_STRICT_JSON = { ok: false };
213
+ function strictJsonCopy(v, depth = 0) {
214
+ if (depth > MAX_HASH_DEPTH)
215
+ return NOT_STRICT_JSON;
216
+ if (v === null || typeof v === "string" || typeof v === "boolean")
217
+ return { ok: true, value: v };
218
+ if (typeof v === "number")
219
+ return Number.isFinite(v) ? { ok: true, value: v } : NOT_STRICT_JSON;
220
+ if (typeof v !== "object")
221
+ return NOT_STRICT_JSON; // undefined / function / symbol / bigint
222
+ if (isJsonArray(v)) {
223
+ const length = v.length;
224
+ if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0)
225
+ return NOT_STRICT_JSON;
226
+ const out = [];
227
+ for (let i = 0; i < length; i++) {
228
+ const member = strictJsonStored(Object.getOwnPropertyDescriptor(v, i), depth);
229
+ if (!member.ok)
230
+ return NOT_STRICT_JSON;
231
+ Object.defineProperty(out, i, dataDescriptor(member.value));
232
+ }
233
+ return { ok: true, value: out };
234
+ }
235
+ if (!isPlainJsonObject(v))
236
+ return NOT_STRICT_JSON; // Date / Map / class instance
237
+ const out = Object.create(null);
238
+ for (const key of Reflect.ownKeys(v)) {
239
+ if (typeof key === "symbol")
240
+ continue; // never serialized
241
+ const desc = Object.getOwnPropertyDescriptor(v, key);
242
+ if (desc !== undefined && desc.enumerable !== true)
243
+ continue; // never serialized (what `Object.keys` leaves out)
244
+ const member = strictJsonStored(desc, depth);
245
+ if (!member.ok)
246
+ return NOT_STRICT_JSON;
247
+ Object.defineProperty(out, key, dataDescriptor(member.value));
248
+ }
249
+ return { ok: true, value: out };
250
+ }
251
+ /** One member, judged by the descriptor read ONCE: a stored value (a hole has no descriptor; an accessor has no `value`) that is itself strict JSON. */
252
+ function strictJsonStored(desc, depth) {
253
+ if (desc === undefined || !Object.hasOwn(desc, "value"))
254
+ return NOT_STRICT_JSON; // 描述符本身也只读自有键:`Object.prototype.value` 被污染时访问器描述符不会「有 value」
255
+ return strictJsonCopy(desc.value, depth + 1);
256
+ }
257
+ /** `none`: compares the DESCRIPTOR VIEW, read once ({@link strictJsonCopy}), and serializes only the copies — member values never go through `[[Get]]`. The ONE guard around the
258
+ * whole comparison catches what the walk itself cannot read (a proxy that has been revoked, a trap that throws on `ownKeys` / a descriptor / an array's `length`): that is outside the
259
+ * domain ⇒ `unreadable`, never a throw. */
260
+ function noneVerdict(existing, candidate) {
261
+ try {
262
+ const a = strictJsonCopy(existing);
263
+ const b = strictJsonCopy(candidate);
264
+ if (!a.ok || !b.ok)
265
+ return "unreadable";
266
+ return stableStringify(a.value) === stableStringify(b.value) ? "equal" : "unordered"; // serializes the COPIES — the descriptor view the walk saw
267
+ }
268
+ catch {
269
+ return "unreadable";
270
+ }
271
+ }
272
+ /**
273
+ * THE comparator (14.6.0): where `candidate` lands relative to `existing` under `rule`. One implementation for every
274
+ * consumer — the shell's resolver, the server's catalog column, the profile loader — so "stricter" has one reading.
275
+ * It reads ORDER only: each rule has a normalized domain, and a side outside it is `unreadable` (spellings are normalized
276
+ * by the value's owner first — `readSettingsUnit` for a settings unit):
277
+ * · `enumRank` — a word of `order`; the higher rank is stricter, the same word `equal`;
278
+ * · `boolStricter` — a boolean (strict equality, never truthiness: the string `"false"` is unreadable, not `true`);
279
+ * the `strict` pole is stricter;
280
+ * · `setUnion` — an array of strings, as a SET (order and duplicates do not count): a proper superset is stricter,
281
+ * a proper subset looser, the same set `equal`, neither containing the other `unordered`;
282
+ * · `setNarrower` — the same domain, the opposite direction;
283
+ * · `numericLower` — a finite number or `+Infinity` (no limit, the loosest): the smaller is stricter;
284
+ * · `none` — a strict JSON value no deeper than {@link MAX_HASH_DEPTH}: `equal` when `stableStringify` agrees
285
+ * (key order does not count), else `unordered`;
286
+ * · `policyDenyWins` — a RangeError: it is the fold law BETWEEN a category's members, not an order on one value —
287
+ * compare a member through {@link memberTighten}.
288
+ * Both sides must be present: absence is not a value (`undefined` ⇒ `unreadable`); "an absent landing spot takes the
289
+ * write" is the loading law's business, not the comparator's. A rule word outside the closed set (a JavaScript /
290
+ * deserialized caller) is a RangeError, never a silent verdict.
291
+ */
292
+ export function tightenVerdict(rule, existing, candidate) {
293
+ switch (rule.rule) {
294
+ case "enumRank": {
295
+ const a = typeof existing === "string" ? rule.order.indexOf(existing) : -1;
296
+ const b = typeof candidate === "string" ? rule.order.indexOf(candidate) : -1;
297
+ return a < 0 || b < 0 ? "unreadable" : orderVerdict(b - a);
298
+ }
299
+ case "boolStricter":
300
+ if (typeof existing !== "boolean" || typeof candidate !== "boolean")
301
+ return "unreadable";
302
+ return existing === candidate ? "equal" : candidate === rule.strict ? "stricter" : "looser";
303
+ case "setUnion":
304
+ return setVerdict(existing, candidate, true);
305
+ case "setNarrower":
306
+ return setVerdict(existing, candidate, false);
307
+ case "numericLower":
308
+ if (!isNumericLowerValue(existing) || !isNumericLowerValue(candidate))
309
+ return "unreadable";
310
+ return existing === candidate ? "equal" : candidate < existing ? "stricter" : "looser";
311
+ case "none":
312
+ return noneVerdict(existing, candidate);
313
+ case "policyDenyWins":
314
+ throw new RangeError("tightenVerdict: policyDenyWins is the fold law between a category's members (deny beats allow), not an order on one value — compare a member through memberTighten(category, member)");
315
+ default: {
316
+ const unknown = rule;
317
+ throw new RangeError(`tightenVerdict: unknown rule '${String(unknown.rule)}' — not a TightenRule word`);
318
+ }
319
+ }
320
+ }
144
321
  export const MergeCategorySpec = z.object({
145
322
  mergeShape: MergeShape,
146
323
  minTrust: MinTrust,
@@ -167,9 +344,9 @@ export const SAFETY_MERGE_SPEC = {
167
344
  mergeShape: "denyFirst", minTrust: "global", tighten: { rule: "policyDenyWins" }, enforcement: "gate", fail: "closed",
168
345
  seam: "toolPolicy + deny-narrowing (SessionPolicyStore)", coreAxis: "permissionDecision",
169
346
  members: {
170
- allow: { direction: "loosen", minTrust: "global" },
171
- deny: { direction: "tighten", minTrust: "local" },
172
- ask: { direction: "tighten", minTrust: "local" },
347
+ allow: { direction: "loosen", minTrust: "global", list: true },
348
+ deny: { direction: "tighten", minTrust: "local", list: true },
349
+ ask: { direction: "tighten", minTrust: "local", list: true },
173
350
  },
174
351
  },
175
352
  // Order covers the FULL shell-addressable mode vocabulary — a word missing here is not "unknown", it makes every
@@ -203,7 +380,7 @@ export const SAFETY_MERGE_SPEC = {
203
380
  // row carries `boolPole {strict, minTrust:"local"}` and keeps its ROW floor at the loosen floor "global" —
204
381
  // value-aware consumers admit the strict pole at "local"; pole-blind consumers (whose schema strips boolPole)
205
382
  // read tighten@global and withhold BOTH poles from low-trust layers (fail-closed: convenience lost, never a
206
- // loosening write admitted). The two deny arrays narrow in every write, so they are plain tighten@"local".
383
+ // loosening write admitted). The two deny arrays narrow in every write, so they are plain tighten@"local" (value class `list`).
207
384
  // DELIBERATELY NOT members (⇒ the 1.1.0 header law: an undeclared member stays under the category minTrust
208
385
  // "global" — exactly the consumer's fail-closed withhold arm, so FUTURE sub-keys are withheld by default):
209
386
  // · the loosen face — network.allowedDomains / network.allowUnixSockets / filesystem.allowWrite /
@@ -230,8 +407,8 @@ export const SAFETY_MERGE_SPEC = {
230
407
  "network.allowAllUnixSockets": { direction: "tighten", minTrust: "global", boolPole: { strict: false, minTrust: "local" } },
231
408
  "network.allowLocalBinding": { direction: "tighten", minTrust: "global", boolPole: { strict: false, minTrust: "local" } },
232
409
  "filesystem.allowManagedReadPathsOnly": { direction: "tighten", minTrust: "global", boolPole: { strict: true, minTrust: "local" } },
233
- "filesystem.denyWrite": { direction: "tighten", minTrust: "local" },
234
- "filesystem.denyRead": { direction: "tighten", minTrust: "local" },
410
+ "filesystem.denyWrite": { direction: "tighten", minTrust: "local", list: true },
411
+ "filesystem.denyRead": { direction: "tighten", minTrust: "local", list: true },
235
412
  },
236
413
  },
237
414
  hooks: { mergeShape: "runAll", minTrust: "project", tighten: { rule: "none" }, enforcement: "gate", fail: "closed", seam: "hooks (single source or adapter-composed)" },
@@ -286,6 +463,16 @@ export function membersOf(category) {
286
463
  const spec = categorySpec(category);
287
464
  return (spec.members === undefined ? [] : Object.keys(spec.members));
288
465
  }
466
+ /** A declared member's row — the two misuse arms (whole-key category, undeclared member) that admission and comparison share. */
467
+ function memberRow(spec, member, categoryLabel) {
468
+ if (spec.members === undefined) {
469
+ throw new RangeError(`SAFETY_MERGE_SPEC: category '${categoryLabel}' has no member-level admission — its minTrust applies to the whole key`);
470
+ }
471
+ if (!Object.hasOwn(spec.members, member)) {
472
+ throw new RangeError(`SAFETY_MERGE_SPEC: unknown member '${member}' of category '${categoryLabel}' (members: ${Object.keys(spec.members).join(", ")})`);
473
+ }
474
+ return spec.members[member];
475
+ }
289
476
  /**
290
477
  * Resolve one member's admission off a category spec VALUE (the single implementation; {@link memberAdmission}
291
478
  * binds it to {@link SAFETY_MERGE_SPEC}). Fallback law: a member row without `minTrust` inherits the category's.
@@ -298,13 +485,7 @@ export function membersOf(category) {
298
485
  * @param categoryLabel only decorates the error message (the spec value carries no name of its own).
299
486
  */
300
487
  export function resolveMemberAdmission(spec, member, categoryLabel = "(unnamed category)", value) {
301
- if (spec.members === undefined) {
302
- throw new RangeError(`SAFETY_MERGE_SPEC: category '${categoryLabel}' has no member-level admission — its minTrust applies to the whole key`);
303
- }
304
- if (!Object.hasOwn(spec.members, member)) {
305
- throw new RangeError(`SAFETY_MERGE_SPEC: unknown member '${member}' of category '${categoryLabel}' (members: ${Object.keys(spec.members).join(", ")})`);
306
- }
307
- const row = spec.members[member];
488
+ const row = memberRow(spec, member, categoryLabel);
308
489
  const rowFloor = row.minTrust ?? spec.minTrust;
309
490
  if (row.boolPole !== undefined) {
310
491
  const floor = value === row.boolPole.strict ? row.boolPole.minTrust : rowFloor;
@@ -323,3 +504,38 @@ export function memberAdmission(category, member, value) {
323
504
  export function memberMinTrust(category, member, value) {
324
505
  return memberAdmission(category, member, value).minTrust;
325
506
  }
507
+ /**
508
+ * Derive one member's comparator off a category spec VALUE (14.6.0; the single implementation — {@link memberTighten} binds
509
+ * it to {@link SAFETY_MERGE_SPEC}). The comparator follows from the row's VALUE CLASS and direction, never from the category's
510
+ * `tighten` (that answers only a `whole` unit's value — the file header):
511
+ * · `boolPole` ⇒ `{ rule: "boolStricter", strict: boolPole.strict }`;
512
+ * · `list` ⇒ `setUnion` for a `tighten` member (a superset narrows more), `setNarrower` for a `loosen` member (a subset grants less).
513
+ * A row declaring NEITHER value class or BOTH is a RangeError (nothing to derive / two readings) — never a default; so are the
514
+ * misuse arms {@link resolveMemberAdmission} has (a whole-key category, an undeclared member).
515
+ * @param categoryLabel only decorates the error message.
516
+ */
517
+ export function resolveMemberTighten(spec, member, categoryLabel = "(unnamed category)") {
518
+ const row = memberRow(spec, member, categoryLabel);
519
+ const isList = row.list === true;
520
+ if ((row.boolPole !== undefined) === isList) {
521
+ const which = isList ? "both value classes (boolPole and list)" : "no value class (neither boolPole nor list)";
522
+ throw new RangeError(`SAFETY_MERGE_SPEC: member '${member}' of category '${categoryLabel}' declares ${which} — its comparator cannot be derived`);
523
+ }
524
+ if (row.boolPole !== undefined)
525
+ return { rule: "boolStricter", strict: row.boolPole.strict };
526
+ switch (row.direction) {
527
+ case "tighten":
528
+ return { rule: "setUnion" };
529
+ case "loosen":
530
+ return { rule: "setNarrower" };
531
+ default: {
532
+ const unknown = row.direction;
533
+ throw new RangeError(`SAFETY_MERGE_SPEC: member '${member}' of category '${categoryLabel}' has an unknown direction '${String(unknown)}'`);
534
+ }
535
+ }
536
+ }
537
+ /** A declared member's comparator from the published table (see {@link resolveMemberTighten}); compare two of its values with
538
+ * {@link tightenVerdict}. Unknown category or member ⇒ RangeError (never the category rule as a fallback). */
539
+ export function memberTighten(category, member) {
540
+ return resolveMemberTighten(categorySpec(category), member, category);
541
+ }
@@ -1,14 +1,15 @@
1
+ import { isPlainJsonObject } from './plain-json-object.js';
1
2
  import { isSessionLifetime } from './scheduler-lifetime.js';
2
3
  import { migrateSchedulerRecords } from './scheduler-store-migration.js';
3
4
  /** Structural validation of one record (required fields + a well-formed `when`). Unknown fields are allowed. */
4
5
  export function isSchedulerRecord(v) {
5
- if (typeof v !== 'object' || v === null)
6
+ if (!isPlainJsonObject(v))
6
7
  return false;
7
8
  const r = v;
8
9
  if (typeof r.id !== 'string' || typeof r.scope !== 'string' || typeof r.prompt !== 'string' || typeof r.createdMs !== 'number')
9
10
  return false;
10
11
  const w = r.when;
11
- if (typeof w !== 'object' || w === null)
12
+ if (!isPlainJsonObject(w))
12
13
  return false;
13
14
  if (w.kind === 'cron')
14
15
  return typeof w.expr === 'string';
@@ -0,0 +1,85 @@
1
+ import { type TightenRule } from "./safety-merge-spec.js";
2
+ /** The settings-document units (see the file header). `path` is the unit's place in a settings document. */
3
+ export declare const SETTINGS_UNITS: {
4
+ readonly permissions: {
5
+ readonly path: readonly ["permissions"];
6
+ readonly category: "permissions";
7
+ readonly reads: "members";
8
+ };
9
+ readonly permissionMode: {
10
+ readonly path: readonly ["permissions", "defaultMode"];
11
+ readonly category: "permissionMode";
12
+ readonly reads: "whole";
13
+ readonly reading: "permissionMode";
14
+ };
15
+ readonly additionalDirectories: {
16
+ readonly path: readonly ["permissions", "additionalDirectories"];
17
+ readonly category: "sandboxNetwork";
18
+ readonly reads: "whole";
19
+ readonly reading: "stringList";
20
+ };
21
+ readonly sandbox: {
22
+ readonly path: readonly ["sandbox"];
23
+ readonly category: "sandboxNetwork";
24
+ readonly reads: "members";
25
+ };
26
+ readonly disableBypassPermissionsMode: {
27
+ readonly path: readonly ["permissions", "disableBypassPermissionsMode"];
28
+ readonly category: "killSwitches";
29
+ readonly reads: "member";
30
+ readonly member: "disableBypassPermissionsMode";
31
+ readonly reading: "killSwitch";
32
+ };
33
+ readonly disableAutoMode: {
34
+ readonly path: readonly ["permissions", "disableAutoMode"];
35
+ readonly category: "killSwitches";
36
+ readonly reads: "member";
37
+ readonly member: "disableAutoMode";
38
+ readonly reading: "killSwitch";
39
+ };
40
+ readonly hooks: {
41
+ readonly path: readonly ["hooks"];
42
+ readonly category: "hooks";
43
+ readonly reads: "whole";
44
+ readonly reading: "hooks";
45
+ };
46
+ readonly env: {
47
+ readonly path: readonly ["env"];
48
+ readonly category: "env";
49
+ readonly reads: "whole";
50
+ readonly reading: "envMap";
51
+ };
52
+ readonly model: {
53
+ readonly path: readonly ["model"];
54
+ readonly category: "model";
55
+ readonly reads: "whole";
56
+ readonly reading: "nonEmptyString";
57
+ };
58
+ readonly outputStyle: {
59
+ readonly path: readonly ["outputStyle"];
60
+ readonly category: "ui";
61
+ readonly reads: "whole";
62
+ readonly reading: "nonEmptyString";
63
+ };
64
+ };
65
+ export type SettingsUnitName = keyof typeof SETTINGS_UNITS;
66
+ /** The comparator of one unit — or, for a `members` unit, of one DECLARED member of it: `whole` ⇒ the category's `tighten`;
67
+ * `member` ⇒ that member's comparator; `members` ⇒ the named member's comparator ({@link resolveMemberTighten}). Compare two values
68
+ * with `tightenVerdict`. A `members` unit without a member (its whole value), an undeclared member (e.g. `sandbox`'s
69
+ * `failIfUnavailable`), a `whole` unit given a member, a `member` unit given another one, an unknown unit ⇒ RangeError. */
70
+ export declare function unitTighten(unit: SettingsUnitName, member?: string): TightenRule;
71
+ /**
72
+ * Read one unit's raw value out of a settings document (14.6.0) — the settings half of "how an existing value is normalized
73
+ * before it is compared". The reading of every unit is the file header's table; anything outside it is `unreadable`, and
74
+ * `undefined` / `null` are `unreadable` for every unit. An accepted value is returned as is (same reference) — the only rewrites
75
+ * are `"manual"` ⇒ `"default"` (permissionMode) and `"disable"` ⇒ `true` (kill-switches); `"bypassPermissions"` is `launch_only`.
76
+ * The last step of every reading is the unit comparator's own domain: whatever reads, compares (file header).
77
+ * Addressing is {@link unitTighten}'s (same RangeErrors: a `members` unit needs a declared member, …).
78
+ */
79
+ export declare function readSettingsUnit(unit: SettingsUnitName, raw: unknown, member?: string): {
80
+ readonly ok: true;
81
+ readonly value: unknown;
82
+ } | {
83
+ readonly ok: false;
84
+ readonly reason: "unreadable" | "launch_only";
85
+ };