@sema-agent/settings-schema 14.5.0 → 14.7.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.
- package/CHANGELOG.md +531 -0
- package/README.md +46 -3
- package/conformance/VERSION +1 -1
- package/conformance/cases/c84-sandbox-git-block-carried-verbatim/expected.json +153 -0
- package/conformance/cases/c84-sandbox-git-block-carried-verbatim/input.json +23 -0
- package/conformance/cases/c84-sandbox-git-block-carried-verbatim/meta.json +5 -0
- package/conformance/cases/c85-sandbox-git-refused-block-still-carried/expected.json +153 -0
- package/conformance/cases/c85-sandbox-git-refused-block-still-carried/input.json +23 -0
- package/conformance/cases/c85-sandbox-git-refused-block-still-carried/meta.json +5 -0
- package/dist/api/auth-bridge.d.ts +16 -2
- package/dist/api/auth-bridge.js +59 -24
- package/dist/api/credentials.d.ts +281 -0
- package/dist/api/credentials.js +149 -0
- package/dist/api/scopes.js +5 -1
- package/dist/atomic-write.js +10 -7
- package/dist/bundle.d.ts +1 -1
- package/dist/bundle.js +1 -1
- package/dist/config-d-file.js +33 -15
- package/dist/config-dir-migration.js +2 -1
- package/dist/config-fns.js +142 -78
- package/dist/conformance/canonical.js +7 -10
- package/dist/conformance/contract-adapter.js +26 -1
- package/dist/conformance/corpus.js +5 -5
- package/dist/conformance/derive.js +6 -5
- package/dist/conformance/run.js +44 -6
- package/dist/cross-session.d.ts +4 -4
- package/dist/declared-capture.d.ts +41 -0
- package/dist/declared-capture.js +344 -0
- package/dist/file-edit.js +11 -15
- package/dist/file-store.d.ts +1 -31
- package/dist/file-store.js +3 -30
- package/dist/fleet.d.ts +11 -0
- package/dist/fleet.js +60 -26
- package/dist/fs-failure.d.ts +12 -7
- package/dist/fs-failure.js +53 -29
- package/dist/hash.d.ts +0 -9
- package/dist/hash.js +64 -7
- package/dist/hooks-config.d.ts +2 -2
- package/dist/hooks-config.js +129 -33
- package/dist/http-hook-allowlists.d.ts +27 -10
- package/dist/http-hook-allowlists.js +189 -25
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -1
- package/dist/mcp-request-entry.d.ts +13 -8
- package/dist/mcp-request-entry.js +246 -107
- package/dist/one-time-migrations.js +207 -95
- package/dist/own-key-read.d.ts +247 -0
- package/dist/own-key-read.js +449 -0
- package/dist/plain-json-object.d.ts +38 -3
- package/dist/plain-json-object.js +84 -7
- package/dist/portable-domains.d.ts +31 -0
- package/dist/portable-domains.js +30 -0
- package/dist/remote-exec.js +9 -1
- package/dist/safety-merge-spec.d.ts +88 -6
- package/dist/safety-merge-spec.js +246 -17
- package/dist/sandbox-git.d.ts +64 -0
- package/dist/sandbox-git.js +89 -0
- package/dist/scheduler-store.js +21 -12
- package/dist/settings-units.d.ts +85 -0
- package/dist/settings-units.js +177 -0
- package/dist/types.d.ts +6 -2
- package/dist/types.js +65 -56
- package/dist/url-shape.d.ts +27 -5
- package/dist/url-shape.js +29 -5
- package/dist/web-admin-credential.js +5 -2
- package/dist/web-admin-session-url.js +2 -2
- package/dist/worker-row-verdict.d.ts +11 -0
- package/dist/worker-row-verdict.js +51 -1
- package/dist/workers-rows.d.ts +2 -1
- package/dist/workers-rows.js +41 -12
- package/dist/zod-unwrap.d.ts +6 -0
- package/dist/zod-unwrap.js +14 -7
- package/docs/API-SURFACE.md +50 -5
- package/docs/CONSUMER-INTEGRATION.md +178 -0
- package/llms-full.txt +34 -5
- package/llms.txt +6 -3
- package/package.json +1 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** The domains a single-machine TOC carries: what `FileConfigStore` reads out of `config.d/`, and the
|
|
2
|
+
* **default**域集 of the bundle helpers (`exportBundle`/`readBundle`). 每个被排除的域必须在
|
|
3
|
+
* {@link NON_PORTABLE_DOMAIN_REASONS} 里有一条成文理由(禁裸排除,#282 件5 附带裁定)。
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ 口径诚实(对抗复审 复审 round1 F4):这是**默认集与本地读取集,不是 API 级硬边界** —— `exportBundle`
|
|
6
|
+
* / `readBundle` 的 `domains` 参数可显式传任意 `DomainName`(bundle.ts 不拒非便携域)。把非便携域搬进
|
|
7
|
+
* bundle 的那条口子归调用方纪律(以及各自域的导出面),不是本常量能挡住的;要不要把 bundle API 收窄到
|
|
8
|
+
* `PortableDomain` 是一个独立的行为面裁定(#282 未决项,需三问),本批不擅自改。
|
|
9
|
+
*
|
|
10
|
+
* 🔴 **开新顶层域时必须同步登记本表**(成案 = 消费方 #322 真事故):域表是**闭集**,不在这里的
|
|
11
|
+
* 域名,`config.d/<域>.json` 不会被读进 effective —— 消费方拿到的是「该域缺席」,和文件不存在**逐字
|
|
12
|
+
* 同形**。当年 `limits` 域就是这样在单机腿静默丢了一整片限额发布(远端腿同批可用,所以更难看出来)。
|
|
13
|
+
* 今日的兜底:漏登记的文件会被 `FileConfigStore`(`./node`)的 `"unread-config-file"` 警告点名(fail-loud,
|
|
14
|
+
* 见 getEffective),但那只在消费方接了 `onWarning` 时才响 —— **登记仍是第一现场的义务**。 */
|
|
15
|
+
export declare const PORTABLE_DOMAINS: readonly ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "governance", "execution", "prompts", "limits"];
|
|
16
|
+
export type PortableDomain = (typeof PORTABLE_DOMAINS)[number];
|
|
17
|
+
/**
|
|
18
|
+
* 非便携域的**排除理由表**(#282 件5 附带裁定:禁裸排除)。
|
|
19
|
+
*
|
|
20
|
+
* `satisfies Record<Exclude<DomainName, PortableDomain>, string>` 让键集在**编译期**双向咬死:新开一个
|
|
21
|
+
* 域却不放进 PORTABLE_DOMAINS ⇒ 这里缺行 ⇒ 编译错;把某域改成便携却忘了删行 ⇒ 多余键 ⇒ 编译错。
|
|
22
|
+
* (此前是「理由写在注释里」,机器只能做子串匹配——域名出现在文件任何角落都算数,等于没门;对抗复审
|
|
23
|
+
* 复审 round1 F3。)理由要写**为什么它不该被单机/跨部署搬运**,带出处。
|
|
24
|
+
*/
|
|
25
|
+
export declare const NON_PORTABLE_DOMAIN_REASONS: {
|
|
26
|
+
readonly workers: "FLEET-only:单机没有 reconciler,`{workers:[]}` 的 schema 默认就是正确语义(缺席不需要 stub)。";
|
|
27
|
+
readonly hosts: "FLEET-only:同 workers —— 单机无机器注册表/自动排布,空注册表即正确语义。";
|
|
28
|
+
readonly systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 web-admin 侧生命周期,单机 lane 无对应执法点。";
|
|
29
|
+
readonly projects: "TENANT IDENTITY DATA,不是可搬运的配置:登记簿随包复制进另一个部署 = 凭空多出的身份登记(design/142 §1.4 威胁模型①「carried-in foreign projectId」换了身 import 的衣服)。要带走走 142 §5 的导出面(service 半场)。";
|
|
30
|
+
readonly entitlement: "per-PRINCIPAL 商务/治理数据(tier·预算上限·kill-switch·allowlist),与 projects 同威胁模型(#282 件5 附带裁定,此前是唯一裸排除项):(a) 搬走 = 把一个租户的商务档复制进另一部署;(b) 反向也不成立 —— 单机 TOC 无 principal 轴(无鉴权语境、无 `/effective?principal=` 解析面),本地 entitlement.json 只会造出一份**永不被执法**的假管控面(真执法点在 service / web-admin 边界)。缺席语义如实:本地 buildEffective 给它 schema 默认值(空表),这**不等于**把远端 kill-switch/预算上限搬到了本地 —— 单机 lane 本就没有那个执法点。";
|
|
31
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** The domains a single-machine TOC carries: what `FileConfigStore` reads out of `config.d/`, and the
|
|
2
|
+
* **default**域集 of the bundle helpers (`exportBundle`/`readBundle`). 每个被排除的域必须在
|
|
3
|
+
* {@link NON_PORTABLE_DOMAIN_REASONS} 里有一条成文理由(禁裸排除,#282 件5 附带裁定)。
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ 口径诚实(对抗复审 复审 round1 F4):这是**默认集与本地读取集,不是 API 级硬边界** —— `exportBundle`
|
|
6
|
+
* / `readBundle` 的 `domains` 参数可显式传任意 `DomainName`(bundle.ts 不拒非便携域)。把非便携域搬进
|
|
7
|
+
* bundle 的那条口子归调用方纪律(以及各自域的导出面),不是本常量能挡住的;要不要把 bundle API 收窄到
|
|
8
|
+
* `PortableDomain` 是一个独立的行为面裁定(#282 未决项,需三问),本批不擅自改。
|
|
9
|
+
*
|
|
10
|
+
* 🔴 **开新顶层域时必须同步登记本表**(成案 = 消费方 #322 真事故):域表是**闭集**,不在这里的
|
|
11
|
+
* 域名,`config.d/<域>.json` 不会被读进 effective —— 消费方拿到的是「该域缺席」,和文件不存在**逐字
|
|
12
|
+
* 同形**。当年 `limits` 域就是这样在单机腿静默丢了一整片限额发布(远端腿同批可用,所以更难看出来)。
|
|
13
|
+
* 今日的兜底:漏登记的文件会被 `FileConfigStore`(`./node`)的 `"unread-config-file"` 警告点名(fail-loud,
|
|
14
|
+
* 见 getEffective),但那只在消费方接了 `onWarning` 时才响 —— **登记仍是第一现场的义务**。 */
|
|
15
|
+
export const PORTABLE_DOMAINS = ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "governance", "execution", "prompts", "limits"];
|
|
16
|
+
/**
|
|
17
|
+
* 非便携域的**排除理由表**(#282 件5 附带裁定:禁裸排除)。
|
|
18
|
+
*
|
|
19
|
+
* `satisfies Record<Exclude<DomainName, PortableDomain>, string>` 让键集在**编译期**双向咬死:新开一个
|
|
20
|
+
* 域却不放进 PORTABLE_DOMAINS ⇒ 这里缺行 ⇒ 编译错;把某域改成便携却忘了删行 ⇒ 多余键 ⇒ 编译错。
|
|
21
|
+
* (此前是「理由写在注释里」,机器只能做子串匹配——域名出现在文件任何角落都算数,等于没门;对抗复审
|
|
22
|
+
* 复审 round1 F3。)理由要写**为什么它不该被单机/跨部署搬运**,带出处。
|
|
23
|
+
*/
|
|
24
|
+
export const NON_PORTABLE_DOMAIN_REASONS = {
|
|
25
|
+
workers: "FLEET-only:单机没有 reconciler,`{workers:[]}` 的 schema 默认就是正确语义(缺席不需要 stub)。",
|
|
26
|
+
hosts: "FLEET-only:同 workers —— 单机无机器注册表/自动排布,空注册表即正确语义。",
|
|
27
|
+
systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 web-admin 侧生命周期,单机 lane 无对应执法点。",
|
|
28
|
+
projects: "TENANT IDENTITY DATA,不是可搬运的配置:登记簿随包复制进另一个部署 = 凭空多出的身份登记(design/142 §1.4 威胁模型①「carried-in foreign projectId」换了身 import 的衣服)。要带走走 142 §5 的导出面(service 半场)。",
|
|
29
|
+
entitlement: "per-PRINCIPAL 商务/治理数据(tier·预算上限·kill-switch·allowlist),与 projects 同威胁模型(#282 件5 附带裁定,此前是唯一裸排除项):(a) 搬走 = 把一个租户的商务档复制进另一部署;(b) 反向也不成立 —— 单机 TOC 无 principal 轴(无鉴权语境、无 `/effective?principal=` 解析面),本地 entitlement.json 只会造出一份**永不被执法**的假管控面(真执法点在 service / web-admin 边界)。缺席语义如实:本地 buildEffective 给它 schema 默认值(空表),这**不等于**把远端 kill-switch/预算上限搬到了本地 —— 单机 lane 本就没有那个执法点。",
|
|
30
|
+
};
|
package/dist/remote-exec.js
CHANGED
|
@@ -31,6 +31,8 @@ import { INTEGER } from "./integer.js";
|
|
|
31
31
|
import { buildQuotedIssue } from "./quoted-issue.js";
|
|
32
32
|
import { ENV_NAME, K8S_QUANTITY, NON_SECRET_URL, TCP_PORT, containsSecretToken, hasNul } from "./types.js";
|
|
33
33
|
import { retiredLaneWordRefusal } from "./execution-lanes.js";
|
|
34
|
+
import { ownValueOf } from "./own-key-read.js";
|
|
35
|
+
import { isPlainJsonObject } from "./plain-json-object.js";
|
|
34
36
|
/** The name of this contract's document in a `config.d` directory (`config.d/remote-exec.json`) —— the one spelling: the local reader /
|
|
35
37
|
* writer, the directory migration and the command line name the document by it. It is not a config domain (not in `DOMAIN_SCHEMAS`). */
|
|
36
38
|
export const REMOTE_EXEC_DOCUMENT = "remote-exec";
|
|
@@ -138,5 +140,11 @@ export const RemoteExecSpec = z.discriminatedUnion("provider", [
|
|
|
138
140
|
}),
|
|
139
141
|
], {
|
|
140
142
|
// A retired lane word as `provider` (no matching discriminator) gets the tombstone's way out; any other miss keeps zod's sentence.
|
|
141
|
-
|
|
143
|
+
// `provider` is read from the input's OWN keys (the input is the caller's object: an inherited / polluted `provider` is not the word they wrote; a proxy that cannot be read has no word to refuse)
|
|
144
|
+
error: (iss) => {
|
|
145
|
+
if (iss.code !== "invalid_union" || !isPlainJsonObject(iss.input))
|
|
146
|
+
return undefined;
|
|
147
|
+
const provider = ownValueOf(iss.input, "provider");
|
|
148
|
+
return provider.ok ? retiredLaneWordRefusal(provider.value) : undefined;
|
|
149
|
+
},
|
|
142
150
|
});
|
|
@@ -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).
|
|
8
|
-
*
|
|
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
|
|
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).
|
|
8
|
-
*
|
|
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, isAccessorDescriptor, isolatedObject } from "./own-key-read.js";
|
|
70
|
+
import { jsonShapeOf } 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,179 @@ 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
|
|
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 or non-enumerable element (the verdict is about the caller's own value: an accessor is not a stored value, it may answer
|
|
187
|
+
* differently on the next read — the `none` rule's domain says the same; no getter is called), a proxy that cannot be read (revoked, a trap that throws) is not one:
|
|
188
|
+
* all through the package's one string-array reading, strict ({@link captureStringArray}), so the comparator never throws on them and never splits a string. */
|
|
189
|
+
function stringSetOf(v) {
|
|
190
|
+
const list = captureStringArray(v, "strict");
|
|
191
|
+
return list === undefined ? undefined : new Set(list);
|
|
192
|
+
}
|
|
193
|
+
/** `superset ⇒ stricter` for setUnion, the mirror for setNarrower. */
|
|
194
|
+
function setVerdict(existing, candidate, supersetIsStricter) {
|
|
195
|
+
const a = stringSetOf(existing);
|
|
196
|
+
const b = stringSetOf(candidate);
|
|
197
|
+
if (a === undefined || b === undefined)
|
|
198
|
+
return "unreadable";
|
|
199
|
+
const candidateCoversExisting = [...a].every((x) => b.has(x));
|
|
200
|
+
const existingCoversCandidate = [...b].every((x) => a.has(x));
|
|
201
|
+
if (candidateCoversExisting && existingCoversCandidate)
|
|
202
|
+
return "equal";
|
|
203
|
+
if (candidateCoversExisting)
|
|
204
|
+
return supersetIsStricter ? "stricter" : "looser";
|
|
205
|
+
if (existingCoversCandidate)
|
|
206
|
+
return supersetIsStricter ? "looser" : "stricter";
|
|
207
|
+
return "unordered";
|
|
208
|
+
}
|
|
209
|
+
/** numericLower's domain: a finite number, or `+Infinity` (= no limit, the loosest). NaN / `-Infinity` / a non-number are not in it. */
|
|
210
|
+
function isNumericLowerValue(v) {
|
|
211
|
+
return typeof v === "number" && (Number.isFinite(v) || v === Number.POSITIVE_INFINITY);
|
|
212
|
+
}
|
|
213
|
+
const NOT_STRICT_JSON = { ok: false };
|
|
214
|
+
function strictJsonCopy(v, depth = 0) {
|
|
215
|
+
if (depth > MAX_HASH_DEPTH)
|
|
216
|
+
return NOT_STRICT_JSON;
|
|
217
|
+
const shape = jsonShapeOf(v); // the shape is asked ONCE, of the seat (total): no `typeof` split, no second prototype read
|
|
218
|
+
switch (shape) {
|
|
219
|
+
case "null":
|
|
220
|
+
return { ok: true, value: v };
|
|
221
|
+
case "primitive":
|
|
222
|
+
if (typeof v === "string" || typeof v === "boolean")
|
|
223
|
+
return { ok: true, value: v };
|
|
224
|
+
return typeof v === "number" && Number.isFinite(v) ? { ok: true, value: v } : NOT_STRICT_JSON; // undefined / symbol / bigint / NaN / ±Infinity
|
|
225
|
+
case "array": {
|
|
226
|
+
const array = v;
|
|
227
|
+
const length = array.length;
|
|
228
|
+
if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0)
|
|
229
|
+
return NOT_STRICT_JSON;
|
|
230
|
+
const out = [];
|
|
231
|
+
for (let i = 0; i < length; i++) {
|
|
232
|
+
const member = strictJsonStored(Object.getOwnPropertyDescriptor(array, i), depth);
|
|
233
|
+
if (!member.ok)
|
|
234
|
+
return NOT_STRICT_JSON;
|
|
235
|
+
Object.defineProperty(out, i, dataDescriptor(member.value));
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, value: out };
|
|
238
|
+
}
|
|
239
|
+
case "plain": {
|
|
240
|
+
const record = v;
|
|
241
|
+
const out = isolatedObject();
|
|
242
|
+
for (const key of Reflect.ownKeys(record)) {
|
|
243
|
+
if (typeof key === "symbol")
|
|
244
|
+
continue; // never serialized
|
|
245
|
+
const desc = Object.getOwnPropertyDescriptor(record, key);
|
|
246
|
+
if (desc !== undefined && desc.enumerable !== true)
|
|
247
|
+
continue; // never serialized (what `Object.keys` leaves out)
|
|
248
|
+
const member = strictJsonStored(desc, depth);
|
|
249
|
+
if (!member.ok)
|
|
250
|
+
return NOT_STRICT_JSON;
|
|
251
|
+
Object.defineProperty(out, key, dataDescriptor(member.value));
|
|
252
|
+
}
|
|
253
|
+
return { ok: true, value: out };
|
|
254
|
+
}
|
|
255
|
+
case "non_plain_object": // function / Date / Map / class instance
|
|
256
|
+
case "uninspectable": // a proxy that cannot be inspected: outside the domain
|
|
257
|
+
return NOT_STRICT_JSON;
|
|
258
|
+
default: {
|
|
259
|
+
const unhandled = shape;
|
|
260
|
+
throw new Error(`strictJsonCopy: unhandled shape ${String(unhandled)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** 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. */
|
|
265
|
+
function strictJsonStored(desc, depth) {
|
|
266
|
+
if (desc === undefined || isAccessorDescriptor(desc))
|
|
267
|
+
return NOT_STRICT_JSON; // 访问器不是存着的值(描述符的字段只认自有的,见内核)
|
|
268
|
+
return strictJsonCopy(desc.value, depth + 1);
|
|
269
|
+
}
|
|
270
|
+
/** `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
|
|
271
|
+
* 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
|
|
272
|
+
* domain ⇒ `unreadable`, never a throw. */
|
|
273
|
+
function noneVerdict(existing, candidate) {
|
|
274
|
+
try {
|
|
275
|
+
const a = strictJsonCopy(existing);
|
|
276
|
+
const b = strictJsonCopy(candidate);
|
|
277
|
+
if (!a.ok || !b.ok)
|
|
278
|
+
return "unreadable";
|
|
279
|
+
return stableStringify(a.value) === stableStringify(b.value) ? "equal" : "unordered"; // serializes the COPIES — the descriptor view the walk saw
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return "unreadable";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* THE comparator (14.6.0): where `candidate` lands relative to `existing` under `rule`. One implementation for every
|
|
287
|
+
* consumer — the shell's resolver, the server's catalog column, the profile loader — so "stricter" has one reading.
|
|
288
|
+
* It reads ORDER only: each rule has a normalized domain, and a side outside it is `unreadable` (spellings are normalized
|
|
289
|
+
* by the value's owner first — `readSettingsUnit` for a settings unit):
|
|
290
|
+
* · `enumRank` — a word of `order`; the higher rank is stricter, the same word `equal`;
|
|
291
|
+
* · `boolStricter` — a boolean (strict equality, never truthiness: the string `"false"` is unreadable, not `true`);
|
|
292
|
+
* the `strict` pole is stricter;
|
|
293
|
+
* · `setUnion` — an array of strings, as a SET (order and duplicates do not count): a proper superset is stricter,
|
|
294
|
+
* a proper subset looser, the same set `equal`, neither containing the other `unordered`;
|
|
295
|
+
* · `setNarrower` — the same domain, the opposite direction;
|
|
296
|
+
* · `numericLower` — a finite number or `+Infinity` (no limit, the loosest): the smaller is stricter;
|
|
297
|
+
* · `none` — a strict JSON value no deeper than {@link MAX_HASH_DEPTH}: `equal` when `stableStringify` agrees
|
|
298
|
+
* (key order does not count), else `unordered`;
|
|
299
|
+
* · `policyDenyWins` — a RangeError: it is the fold law BETWEEN a category's members, not an order on one value —
|
|
300
|
+
* compare a member through {@link memberTighten}.
|
|
301
|
+
* Both sides must be present: absence is not a value (`undefined` ⇒ `unreadable`); "an absent landing spot takes the
|
|
302
|
+
* write" is the loading law's business, not the comparator's. A rule word outside the closed set (a JavaScript /
|
|
303
|
+
* deserialized caller) is a RangeError, never a silent verdict.
|
|
304
|
+
*/
|
|
305
|
+
export function tightenVerdict(rule, existing, candidate) {
|
|
306
|
+
switch (rule.rule) {
|
|
307
|
+
case "enumRank": {
|
|
308
|
+
const a = typeof existing === "string" ? rule.order.indexOf(existing) : -1;
|
|
309
|
+
const b = typeof candidate === "string" ? rule.order.indexOf(candidate) : -1;
|
|
310
|
+
return a < 0 || b < 0 ? "unreadable" : orderVerdict(b - a);
|
|
311
|
+
}
|
|
312
|
+
case "boolStricter":
|
|
313
|
+
if (typeof existing !== "boolean" || typeof candidate !== "boolean")
|
|
314
|
+
return "unreadable";
|
|
315
|
+
return existing === candidate ? "equal" : candidate === rule.strict ? "stricter" : "looser";
|
|
316
|
+
case "setUnion":
|
|
317
|
+
return setVerdict(existing, candidate, true);
|
|
318
|
+
case "setNarrower":
|
|
319
|
+
return setVerdict(existing, candidate, false);
|
|
320
|
+
case "numericLower":
|
|
321
|
+
if (!isNumericLowerValue(existing) || !isNumericLowerValue(candidate))
|
|
322
|
+
return "unreadable";
|
|
323
|
+
return existing === candidate ? "equal" : candidate < existing ? "stricter" : "looser";
|
|
324
|
+
case "none":
|
|
325
|
+
return noneVerdict(existing, candidate);
|
|
326
|
+
case "policyDenyWins":
|
|
327
|
+
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)");
|
|
328
|
+
default: {
|
|
329
|
+
const unknown = rule;
|
|
330
|
+
throw new RangeError(`tightenVerdict: unknown rule '${String(unknown.rule)}' — not a TightenRule word`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
144
334
|
export const MergeCategorySpec = z.object({
|
|
145
335
|
mergeShape: MergeShape,
|
|
146
336
|
minTrust: MinTrust,
|
|
@@ -167,9 +357,9 @@ export const SAFETY_MERGE_SPEC = {
|
|
|
167
357
|
mergeShape: "denyFirst", minTrust: "global", tighten: { rule: "policyDenyWins" }, enforcement: "gate", fail: "closed",
|
|
168
358
|
seam: "toolPolicy + deny-narrowing (SessionPolicyStore)", coreAxis: "permissionDecision",
|
|
169
359
|
members: {
|
|
170
|
-
allow: { direction: "loosen", minTrust: "global" },
|
|
171
|
-
deny: { direction: "tighten", minTrust: "local" },
|
|
172
|
-
ask: { direction: "tighten", minTrust: "local" },
|
|
360
|
+
allow: { direction: "loosen", minTrust: "global", list: true },
|
|
361
|
+
deny: { direction: "tighten", minTrust: "local", list: true },
|
|
362
|
+
ask: { direction: "tighten", minTrust: "local", list: true },
|
|
173
363
|
},
|
|
174
364
|
},
|
|
175
365
|
// Order covers the FULL shell-addressable mode vocabulary — a word missing here is not "unknown", it makes every
|
|
@@ -203,7 +393,7 @@ export const SAFETY_MERGE_SPEC = {
|
|
|
203
393
|
// row carries `boolPole {strict, minTrust:"local"}` and keeps its ROW floor at the loosen floor "global" —
|
|
204
394
|
// value-aware consumers admit the strict pole at "local"; pole-blind consumers (whose schema strips boolPole)
|
|
205
395
|
// 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".
|
|
396
|
+
// loosening write admitted). The two deny arrays narrow in every write, so they are plain tighten@"local" (value class `list`).
|
|
207
397
|
// DELIBERATELY NOT members (⇒ the 1.1.0 header law: an undeclared member stays under the category minTrust
|
|
208
398
|
// "global" — exactly the consumer's fail-closed withhold arm, so FUTURE sub-keys are withheld by default):
|
|
209
399
|
// · the loosen face — network.allowedDomains / network.allowUnixSockets / filesystem.allowWrite /
|
|
@@ -230,8 +420,8 @@ export const SAFETY_MERGE_SPEC = {
|
|
|
230
420
|
"network.allowAllUnixSockets": { direction: "tighten", minTrust: "global", boolPole: { strict: false, minTrust: "local" } },
|
|
231
421
|
"network.allowLocalBinding": { direction: "tighten", minTrust: "global", boolPole: { strict: false, minTrust: "local" } },
|
|
232
422
|
"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" },
|
|
423
|
+
"filesystem.denyWrite": { direction: "tighten", minTrust: "local", list: true },
|
|
424
|
+
"filesystem.denyRead": { direction: "tighten", minTrust: "local", list: true },
|
|
235
425
|
},
|
|
236
426
|
},
|
|
237
427
|
hooks: { mergeShape: "runAll", minTrust: "project", tighten: { rule: "none" }, enforcement: "gate", fail: "closed", seam: "hooks (single source or adapter-composed)" },
|
|
@@ -286,6 +476,16 @@ export function membersOf(category) {
|
|
|
286
476
|
const spec = categorySpec(category);
|
|
287
477
|
return (spec.members === undefined ? [] : Object.keys(spec.members));
|
|
288
478
|
}
|
|
479
|
+
/** A declared member's row — the two misuse arms (whole-key category, undeclared member) that admission and comparison share. */
|
|
480
|
+
function memberRow(spec, member, categoryLabel) {
|
|
481
|
+
if (spec.members === undefined) {
|
|
482
|
+
throw new RangeError(`SAFETY_MERGE_SPEC: category '${categoryLabel}' has no member-level admission — its minTrust applies to the whole key`);
|
|
483
|
+
}
|
|
484
|
+
if (!Object.hasOwn(spec.members, member)) {
|
|
485
|
+
throw new RangeError(`SAFETY_MERGE_SPEC: unknown member '${member}' of category '${categoryLabel}' (members: ${Object.keys(spec.members).join(", ")})`);
|
|
486
|
+
}
|
|
487
|
+
return spec.members[member];
|
|
488
|
+
}
|
|
289
489
|
/**
|
|
290
490
|
* Resolve one member's admission off a category spec VALUE (the single implementation; {@link memberAdmission}
|
|
291
491
|
* binds it to {@link SAFETY_MERGE_SPEC}). Fallback law: a member row without `minTrust` inherits the category's.
|
|
@@ -298,13 +498,7 @@ export function membersOf(category) {
|
|
|
298
498
|
* @param categoryLabel only decorates the error message (the spec value carries no name of its own).
|
|
299
499
|
*/
|
|
300
500
|
export function resolveMemberAdmission(spec, member, categoryLabel = "(unnamed category)", value) {
|
|
301
|
-
|
|
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];
|
|
501
|
+
const row = memberRow(spec, member, categoryLabel);
|
|
308
502
|
const rowFloor = row.minTrust ?? spec.minTrust;
|
|
309
503
|
if (row.boolPole !== undefined) {
|
|
310
504
|
const floor = value === row.boolPole.strict ? row.boolPole.minTrust : rowFloor;
|
|
@@ -323,3 +517,38 @@ export function memberAdmission(category, member, value) {
|
|
|
323
517
|
export function memberMinTrust(category, member, value) {
|
|
324
518
|
return memberAdmission(category, member, value).minTrust;
|
|
325
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Derive one member's comparator off a category spec VALUE (14.6.0; the single implementation — {@link memberTighten} binds
|
|
522
|
+
* it to {@link SAFETY_MERGE_SPEC}). The comparator follows from the row's VALUE CLASS and direction, never from the category's
|
|
523
|
+
* `tighten` (that answers only a `whole` unit's value — the file header):
|
|
524
|
+
* · `boolPole` ⇒ `{ rule: "boolStricter", strict: boolPole.strict }`;
|
|
525
|
+
* · `list` ⇒ `setUnion` for a `tighten` member (a superset narrows more), `setNarrower` for a `loosen` member (a subset grants less).
|
|
526
|
+
* A row declaring NEITHER value class or BOTH is a RangeError (nothing to derive / two readings) — never a default; so are the
|
|
527
|
+
* misuse arms {@link resolveMemberAdmission} has (a whole-key category, an undeclared member).
|
|
528
|
+
* @param categoryLabel only decorates the error message.
|
|
529
|
+
*/
|
|
530
|
+
export function resolveMemberTighten(spec, member, categoryLabel = "(unnamed category)") {
|
|
531
|
+
const row = memberRow(spec, member, categoryLabel);
|
|
532
|
+
const isList = row.list === true;
|
|
533
|
+
if ((row.boolPole !== undefined) === isList) {
|
|
534
|
+
const which = isList ? "both value classes (boolPole and list)" : "no value class (neither boolPole nor list)";
|
|
535
|
+
throw new RangeError(`SAFETY_MERGE_SPEC: member '${member}' of category '${categoryLabel}' declares ${which} — its comparator cannot be derived`);
|
|
536
|
+
}
|
|
537
|
+
if (row.boolPole !== undefined)
|
|
538
|
+
return { rule: "boolStricter", strict: row.boolPole.strict };
|
|
539
|
+
switch (row.direction) {
|
|
540
|
+
case "tighten":
|
|
541
|
+
return { rule: "setUnion" };
|
|
542
|
+
case "loosen":
|
|
543
|
+
return { rule: "setNarrower" };
|
|
544
|
+
default: {
|
|
545
|
+
const unknown = row.direction;
|
|
546
|
+
throw new RangeError(`SAFETY_MERGE_SPEC: member '${member}' of category '${categoryLabel}' has an unknown direction '${String(unknown)}'`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/** A declared member's comparator from the published table (see {@link resolveMemberTighten}); compare two of its values with
|
|
551
|
+
* {@link tightenVerdict}. Unknown category or member ⇒ RangeError (never the category rule as a fallback). */
|
|
552
|
+
export function memberTighten(category, member) {
|
|
553
|
+
return resolveMemberTighten(categorySpec(category), member, category);
|
|
554
|
+
}
|