@sema-agent/settings-schema 14.4.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 (59) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/README.md +22 -1
  3. package/conformance/VERSION +1 -1
  4. package/dist/api/scopes.d.ts +1 -1
  5. package/dist/api/scopes.js +1 -1
  6. package/dist/atomic-write.d.ts +11 -0
  7. package/dist/atomic-write.js +38 -5
  8. package/dist/bundle.d.ts +1 -1
  9. package/dist/bundle.js +1 -1
  10. package/dist/config-d-file.d.ts +4 -0
  11. package/dist/config-d-file.js +11 -4
  12. package/dist/config-dir-io.js +12 -2
  13. package/dist/config-dir-migration.d.ts +44 -0
  14. package/dist/config-dir-migration.js +91 -36
  15. package/dist/config-fns.js +3 -9
  16. package/dist/conformance/canonical.js +4 -7
  17. package/dist/conformance/corpus.js +3 -3
  18. package/dist/conformance/derive.js +6 -5
  19. package/dist/conformance/run.js +2 -1
  20. package/dist/definition-copy.d.ts +23 -0
  21. package/dist/definition-copy.js +52 -0
  22. package/dist/file-store.d.ts +1 -31
  23. package/dist/file-store.js +3 -30
  24. package/dist/fleet.js +2 -1
  25. package/dist/fs-failure.d.ts +17 -2
  26. package/dist/fs-failure.js +55 -15
  27. package/dist/hooks-config.d.ts +2 -2
  28. package/dist/hooks-config.js +112 -31
  29. package/dist/http-hook-allowlists.d.ts +7 -3
  30. package/dist/http-hook-allowlists.js +102 -16
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +3 -1
  33. package/dist/mcp-request-entry.d.ts +9 -6
  34. package/dist/mcp-request-entry.js +225 -93
  35. package/dist/node.d.ts +2 -1
  36. package/dist/node.js +2 -1
  37. package/dist/one-time-migrations.js +36 -41
  38. package/dist/plain-json-object.d.ts +31 -0
  39. package/dist/plain-json-object.js +52 -0
  40. package/dist/portable-domains.d.ts +31 -0
  41. package/dist/portable-domains.js +30 -0
  42. package/dist/safety-merge-spec.d.ts +88 -6
  43. package/dist/safety-merge-spec.js +233 -17
  44. package/dist/scheduler-store.js +3 -2
  45. package/dist/settings-units.d.ts +85 -0
  46. package/dist/settings-units.js +175 -0
  47. package/dist/types.d.ts +4 -0
  48. package/dist/types.js +61 -53
  49. package/dist/url-credential.d.ts +41 -0
  50. package/dist/url-credential.js +95 -0
  51. package/dist/url-shape.d.ts +11 -5
  52. package/dist/url-shape.js +11 -5
  53. package/dist/workers-rows.d.ts +2 -1
  54. package/dist/workers-rows.js +5 -4
  55. package/docs/API-SURFACE.md +30 -4
  56. package/docs/CONSUMER-INTEGRATION.md +2 -0
  57. package/llms-full.txt +24 -2
  58. package/llms.txt +6 -2
  59. package/package.json +2 -3
@@ -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
+ };
@@ -0,0 +1,175 @@
1
+ /**
2
+ * SETTINGS UNITS (14.6.0) — the settings-document unit table: which keys of a settings file carry a merge contract, where each
3
+ * sits in the document, which {@link SAFETY_MERGE_SPEC} category it maps into, and how its raw value READS. Every resolver of a
4
+ * settings file (the shell reads it for each of its sources; a profile loader is a second projection) reads THIS table instead of
5
+ * keeping a key→category map of its own for these ten units. Unit names are the shell's established ten; the two permission-mode
6
+ * kill-switches map to their own `killSwitches` member rows (the table has no managed-only floor for them).
7
+ *
8
+ * NOT in this table: the two http-hook allowlist keys (`allowedHttpHookUrls` / `httpHookAllowedEnvVars`). They carry a merge contract
9
+ * too (the `httpHookAllowlists` category of {@link SAFETY_MERGE_SPEC}) but are keyed and read by `./http-hook-allowlists` on its own
10
+ * (`HTTP_HOOK_ALLOWLIST_KEYS`, `readHttpHookAllowlistLayer`), and read a malformed value differently: there it is `[]` plus a
11
+ * `malformed` mark (the failure direction is "no hook may run"), while a unit reading answers `unreadable`. Folding the two tables
12
+ * into one is a design question (which reading is right), not a drive-by — until it is settled a consumer that wants the allowlist
13
+ * layers reads that module, not this table.
14
+ *
15
+ * How a unit is addressed (`reads`):
16
+ * · `whole` — the unit's value is one value under its category: compared by the category's `tighten`
17
+ * (the only thing a category's `tighten` answers — safety-merge-spec header);
18
+ * · `member` — the unit IS one declared member of its category (the two kill-switches): compared by that member's comparator;
19
+ * · `members` — the unit is an object of declared members (`permissions`, `sandbox`): every question names ONE declared member,
20
+ * compared and read by that member's row ({@link memberTighten}; its value class `boolPole` / `list`). The unit's whole
21
+ * value and its undeclared members have no comparator and no reading here — asking is a RangeError.
22
+ *
23
+ * How a unit READS (`reading`, on every `whole` / `member` row; a `members` unit reads each member by the member row's value class):
24
+ * · `stringList` — an array whose every element is a non-empty string (`[]` reads); a bare string is NOT wrapped into an array;
25
+ * · `permissionMode` — a word of the `permissionMode` category's order other than `bypassPermissions`; `"manual"` reads as
26
+ * `"default"` (the alias); `"bypassPermissions"` is `launch_only` — a launch flag, never a settings-file value;
27
+ * · `killSwitch` — a boolean; `"disable"` (CC's spelling of ON) reads as `true`; `false` is the loose pole — a value, not absence;
28
+ * · `hooks` — what the one hooks judge (`classifyHooksConfig`) classifies as `loaded`;
29
+ * · `envMap` — a plain object whose keys are non-empty and whose values are all strings;
30
+ * · `nonEmptyString` — a non-empty string;
31
+ * and a member's value class: `boolPole` ⇒ a boolean, `list` ⇒ the `stringList` reading.
32
+ * Everything else is `unreadable` — no default arm. Two laws for every unit: `undefined` and JSON `null` are `unreadable` (absence
33
+ * is not a value; "an absent landing spot takes the write" is the loader's business, before it calls the reader), and the reader
34
+ * only recognizes a shape, never rewrites it — an accepted value comes back as the same reference; the only rewrites are the two
35
+ * spellings above (`"manual"` ⇒ `"default"`, `"disable"` ⇒ `true`).
36
+ * WHATEVER READS, COMPARES: a reading has two halves — its shape / spelling half (the table above) and, as its last step, the
37
+ * unit comparator's OWN domain (`tightenVerdict(unitTighten(unit, member), value, value)` must be `equal`). The domain has one
38
+ * owner, the comparator; a reading only adds what it needs on top (non-empty list elements, the hooks judge, …). So a value the
39
+ * shape half accepts but the comparator cannot place — a hooks document the hooks judge loads while a key it strips hides data
40
+ * nested deeper than `MAX_HASH_DEPTH`, or an `undefined` — is `unreadable`, never "read but not comparable".
41
+ * The env DOCUMENT's literal grammar (boolean words, csv + empty-set sentinel, a "≤ 0 means off" cap) is not here: it belongs to
42
+ * the engine's env catalog, whose one implementation is the server's.
43
+ */
44
+ import { classifyHooksConfig } from "./hooks-config.js";
45
+ import { isPlainJsonObject } from "./plain-json-object.js";
46
+ import { SAFETY_MERGE_SPEC, resolveMemberTighten, tightenVerdict } from "./safety-merge-spec.js";
47
+ /** The settings-document units (see the file header). `path` is the unit's place in a settings document. */
48
+ export const SETTINGS_UNITS = {
49
+ permissions: { path: ["permissions"], category: "permissions", reads: "members" },
50
+ permissionMode: { path: ["permissions", "defaultMode"], category: "permissionMode", reads: "whole", reading: "permissionMode" },
51
+ additionalDirectories: { path: ["permissions", "additionalDirectories"], category: "sandboxNetwork", reads: "whole", reading: "stringList" },
52
+ sandbox: { path: ["sandbox"], category: "sandboxNetwork", reads: "members" },
53
+ disableBypassPermissionsMode: { path: ["permissions", "disableBypassPermissionsMode"], category: "killSwitches", reads: "member", member: "disableBypassPermissionsMode", reading: "killSwitch" },
54
+ disableAutoMode: { path: ["permissions", "disableAutoMode"], category: "killSwitches", reads: "member", member: "disableAutoMode", reading: "killSwitch" },
55
+ hooks: { path: ["hooks"], category: "hooks", reads: "whole", reading: "hooks" },
56
+ env: { path: ["env"], category: "env", reads: "whole", reading: "envMap" },
57
+ model: { path: ["model"], category: "model", reads: "whole", reading: "nonEmptyString" },
58
+ outputStyle: { path: ["outputStyle"], category: "ui", reads: "whole", reading: "nonEmptyString" },
59
+ };
60
+ /** The one place a unit + optional member resolves to what is being asked about — the row and its comparator. Fail-closed
61
+ * and loud on every misaddressing: an unknown unit (own-property lookup — `"__proto__"` is unknown, not Object.prototype),
62
+ * a `members` unit without a member, a `whole` unit with one, a `member` unit given another one, an undeclared member
63
+ * (refused by the member row lookup in {@link resolveMemberTighten}). */
64
+ function unitTarget(unit, member) {
65
+ if (!Object.hasOwn(SETTINGS_UNITS, unit)) {
66
+ throw new RangeError(`SETTINGS_UNITS: unknown unit '${unit}' (units: ${Object.keys(SETTINGS_UNITS).join(", ")})`);
67
+ }
68
+ const row = SETTINGS_UNITS[unit];
69
+ const spec = SAFETY_MERGE_SPEC[row.category];
70
+ switch (row.reads) {
71
+ case "whole":
72
+ if (member !== undefined)
73
+ throw new RangeError(`SETTINGS_UNITS: unit '${unit}' is read whole — it has no member '${member}'`);
74
+ return { row, rule: spec.tighten };
75
+ case "member":
76
+ if (member !== undefined)
77
+ throw new RangeError(`SETTINGS_UNITS: unit '${unit}' already names its member ('${row.member}' of '${row.category}') — pass no member`);
78
+ return { row, rule: resolveMemberTighten(spec, row.member, row.category) };
79
+ case "members":
80
+ if (member === undefined) {
81
+ throw new RangeError(`SETTINGS_UNITS: unit '${unit}' is read per declared member of '${row.category}' (members: ${Object.keys(spec.members ?? {}).join(", ")}) — its whole value and its undeclared members have no comparator`);
82
+ }
83
+ return { row, rule: resolveMemberTighten(spec, member, row.category) };
84
+ default: {
85
+ const unknown = row;
86
+ throw new RangeError(`SETTINGS_UNITS: unit '${unit}' has an unknown addressing '${String(unknown.reads)}'`);
87
+ }
88
+ }
89
+ }
90
+ /** The comparator of one unit — or, for a `members` unit, of one DECLARED member of it: `whole` ⇒ the category's `tighten`;
91
+ * `member` ⇒ that member's comparator; `members` ⇒ the named member's comparator ({@link resolveMemberTighten}). Compare two values
92
+ * with `tightenVerdict`. A `members` unit without a member (its whole value), an undeclared member (e.g. `sandbox`'s
93
+ * `failIfUnavailable`), a `whole` unit given a member, a `member` unit given another one, an unknown unit ⇒ RangeError. */
94
+ export function unitTighten(unit, member) {
95
+ return unitTarget(unit, member).rule;
96
+ }
97
+ const UNREADABLE = { ok: false, reason: "unreadable" };
98
+ const LAUNCH_ONLY = { ok: false, reason: "launch_only" };
99
+ /** `stringList`: an array whose every element is a non-empty string (a hole reads as `undefined`, so a sparse array is not one). */
100
+ function isNonEmptyStringList(v) {
101
+ if (!Array.isArray(v))
102
+ return false;
103
+ for (let i = 0; i < v.length; i++) {
104
+ const x = v[i];
105
+ if (typeof x !== "string" || x.length === 0)
106
+ return false;
107
+ }
108
+ return true;
109
+ }
110
+ /** `envMap`: a plain object (prototype `Object.prototype` or `null`), non-empty keys, string values. */
111
+ function isEnvMap(v) {
112
+ if (!isPlainJsonObject(v))
113
+ return false;
114
+ return Object.keys(v).every((k) => k.length > 0 && typeof v[k] === "string");
115
+ }
116
+ /** What a declared member's value class adds on top of its comparator's domain — read through the comparator
117
+ * {@link resolveMemberTighten} derived from the row, so the seat is interpreted in one place: `list` ⇒ setUnion / setNarrower,
118
+ * whose domain takes any strings, so the `stringList` reading adds "every element non-empty"; `boolPole` ⇒ boolStricter, whose
119
+ * domain IS the boolean — nothing to add. A member comparator is only ever one of those three. */
120
+ function memberShapeReads(unit, rule, value) {
121
+ switch (rule.rule) {
122
+ case "boolStricter":
123
+ return true;
124
+ case "setUnion":
125
+ case "setNarrower":
126
+ return isNonEmptyStringList(value);
127
+ default:
128
+ throw new RangeError(`SETTINGS_UNITS: unit '${unit}' resolved to the comparator '${rule.rule}', which no member value class derives`);
129
+ }
130
+ }
131
+ /** A reading's shape / spelling half (the file header's table): the value the raw input reads as, or a refusal. The domain half
132
+ * is the unit comparator's, applied by {@link readSettingsUnit} after this. */
133
+ function shapedReading(unit, row, rule, raw) {
134
+ if (row.reads === "members")
135
+ return memberShapeReads(unit, rule, raw) ? { ok: true, value: raw } : UNREADABLE;
136
+ switch (row.reading) {
137
+ case "stringList":
138
+ return isNonEmptyStringList(raw) ? { ok: true, value: raw } : UNREADABLE;
139
+ case "permissionMode":
140
+ // the words are the category's order (the comparator's enumRank domain); the reading owns the two spellings around it
141
+ if (raw === "bypassPermissions")
142
+ return LAUNCH_ONLY;
143
+ return { ok: true, value: raw === "manual" ? "default" : raw };
144
+ case "killSwitch":
145
+ // CC spells ON "disable"; the boolean itself is the member comparator's domain
146
+ return { ok: true, value: raw === "disable" ? true : raw };
147
+ case "hooks":
148
+ return classifyHooksConfig({ hooks: raw }).outcome === "loaded" ? { ok: true, value: raw } : UNREADABLE;
149
+ case "envMap":
150
+ return isEnvMap(raw) ? { ok: true, value: raw } : UNREADABLE;
151
+ case "nonEmptyString":
152
+ return typeof raw === "string" && raw.length > 0 ? { ok: true, value: raw } : UNREADABLE;
153
+ default: {
154
+ const unknown = row.reading;
155
+ throw new RangeError(`SETTINGS_UNITS: unit '${unit}' has an unknown reading '${String(unknown)}'`);
156
+ }
157
+ }
158
+ }
159
+ /**
160
+ * Read one unit's raw value out of a settings document (14.6.0) — the settings half of "how an existing value is normalized
161
+ * before it is compared". The reading of every unit is the file header's table; anything outside it is `unreadable`, and
162
+ * `undefined` / `null` are `unreadable` for every unit. An accepted value is returned as is (same reference) — the only rewrites
163
+ * are `"manual"` ⇒ `"default"` (permissionMode) and `"disable"` ⇒ `true` (kill-switches); `"bypassPermissions"` is `launch_only`.
164
+ * The last step of every reading is the unit comparator's own domain: whatever reads, compares (file header).
165
+ * Addressing is {@link unitTighten}'s (same RangeErrors: a `members` unit needs a declared member, …).
166
+ */
167
+ export function readSettingsUnit(unit, raw, member) {
168
+ const t = unitTarget(unit, member);
169
+ if (raw === undefined || raw === null)
170
+ return UNREADABLE;
171
+ const shaped = shapedReading(unit, t.row, t.rule, raw);
172
+ if (!shaped.ok)
173
+ return shaped;
174
+ return tightenVerdict(t.rule, shaped.value, shaped.value) === "equal" ? shaped : UNREADABLE;
175
+ }
package/dist/types.d.ts CHANGED
@@ -57,6 +57,10 @@ export declare function urlQueryFragmentHasSecret(u: URL): boolean;
57
57
  * echoed; a fragment's also names its migration, {@link nonSecretUrlIssue}); `params` carries the sentence (`template` / `operands`,
58
58
  * no operands) and `flaw`, the rule word.
59
59
  *
60
+ * Scope of the secret test: it judges the VALUE's shape (a value that looks like a secret, whatever the parameter is called) — the sibling
61
+ * name-shaped judge `findUrlCredential` (`./url-credential`) asks the other question (a parameter whose NAME is on the credential-name table,
62
+ * whatever the value looks like). Neither contains the other: `?key=abc` passes here and is a hit there; `?x=sk-live-…` is the reverse.
63
+ *
60
64
  * This is the seat of every URL in a STORED document the one-time migration rewrites: the domain documents (models `baseUrl`, mcp `url`,
61
65
  * `sessionMirror.engineUrl`, workers `gitApiBaseUrl`) and `config.d/remote-exec.json` (k8s `apiUrl`, `s3Snapshot.endpoint`). A URL
62
66
  * that is not stored data — the web-admin credential envelope's `issuer`, a wire input — uses {@link NON_SECRET_URL_NO_MIGRATION}.