@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +531 -0
  2. package/README.md +46 -3
  3. package/conformance/VERSION +1 -1
  4. package/conformance/cases/c84-sandbox-git-block-carried-verbatim/expected.json +153 -0
  5. package/conformance/cases/c84-sandbox-git-block-carried-verbatim/input.json +23 -0
  6. package/conformance/cases/c84-sandbox-git-block-carried-verbatim/meta.json +5 -0
  7. package/conformance/cases/c85-sandbox-git-refused-block-still-carried/expected.json +153 -0
  8. package/conformance/cases/c85-sandbox-git-refused-block-still-carried/input.json +23 -0
  9. package/conformance/cases/c85-sandbox-git-refused-block-still-carried/meta.json +5 -0
  10. package/dist/api/auth-bridge.d.ts +16 -2
  11. package/dist/api/auth-bridge.js +59 -24
  12. package/dist/api/credentials.d.ts +281 -0
  13. package/dist/api/credentials.js +149 -0
  14. package/dist/api/scopes.js +5 -1
  15. package/dist/atomic-write.js +10 -7
  16. package/dist/bundle.d.ts +1 -1
  17. package/dist/bundle.js +1 -1
  18. package/dist/config-d-file.js +33 -15
  19. package/dist/config-dir-migration.js +2 -1
  20. package/dist/config-fns.js +142 -78
  21. package/dist/conformance/canonical.js +7 -10
  22. package/dist/conformance/contract-adapter.js +26 -1
  23. package/dist/conformance/corpus.js +5 -5
  24. package/dist/conformance/derive.js +6 -5
  25. package/dist/conformance/run.js +44 -6
  26. package/dist/cross-session.d.ts +4 -4
  27. package/dist/declared-capture.d.ts +41 -0
  28. package/dist/declared-capture.js +344 -0
  29. package/dist/file-edit.js +11 -15
  30. package/dist/file-store.d.ts +1 -31
  31. package/dist/file-store.js +3 -30
  32. package/dist/fleet.d.ts +11 -0
  33. package/dist/fleet.js +60 -26
  34. package/dist/fs-failure.d.ts +12 -7
  35. package/dist/fs-failure.js +53 -29
  36. package/dist/hash.d.ts +0 -9
  37. package/dist/hash.js +64 -7
  38. package/dist/hooks-config.d.ts +2 -2
  39. package/dist/hooks-config.js +129 -33
  40. package/dist/http-hook-allowlists.d.ts +27 -10
  41. package/dist/http-hook-allowlists.js +189 -25
  42. package/dist/index.d.ts +3 -0
  43. package/dist/index.js +4 -1
  44. package/dist/mcp-request-entry.d.ts +13 -8
  45. package/dist/mcp-request-entry.js +246 -107
  46. package/dist/one-time-migrations.js +207 -95
  47. package/dist/own-key-read.d.ts +247 -0
  48. package/dist/own-key-read.js +449 -0
  49. package/dist/plain-json-object.d.ts +38 -3
  50. package/dist/plain-json-object.js +84 -7
  51. package/dist/portable-domains.d.ts +31 -0
  52. package/dist/portable-domains.js +30 -0
  53. package/dist/remote-exec.js +9 -1
  54. package/dist/safety-merge-spec.d.ts +88 -6
  55. package/dist/safety-merge-spec.js +246 -17
  56. package/dist/sandbox-git.d.ts +64 -0
  57. package/dist/sandbox-git.js +89 -0
  58. package/dist/scheduler-store.js +21 -12
  59. package/dist/settings-units.d.ts +85 -0
  60. package/dist/settings-units.js +177 -0
  61. package/dist/types.d.ts +6 -2
  62. package/dist/types.js +65 -56
  63. package/dist/url-shape.d.ts +27 -5
  64. package/dist/url-shape.js +29 -5
  65. package/dist/web-admin-credential.js +5 -2
  66. package/dist/web-admin-session-url.js +2 -2
  67. package/dist/worker-row-verdict.d.ts +11 -0
  68. package/dist/worker-row-verdict.js +51 -1
  69. package/dist/workers-rows.d.ts +2 -1
  70. package/dist/workers-rows.js +41 -12
  71. package/dist/zod-unwrap.d.ts +6 -0
  72. package/dist/zod-unwrap.js +14 -7
  73. package/docs/API-SURFACE.md +50 -5
  74. package/docs/CONSUMER-INTEGRATION.md +178 -0
  75. package/llms-full.txt +34 -5
  76. package/llms.txt +6 -3
  77. package/package.json +1 -1
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 沙箱 git 平面的 **origin 形** 与 **有效配置块 `sandboxGit`**(14.7.0)。纯、浏览器安全。
3
+ *
4
+ * ## 律(一条)
5
+ * **一个 origin 就是它自己的 WHATWG origin**:串等于 `new URL(串).origin`,且 scheme ∈ {http, https}。规范形 ——
6
+ * 小写 host、去缺省端口(https 的 443、http 的 80)、无尾斜杠、无 userinfo / 路径 / 查询 / 片段 —— 不是一张手写规则表,而是这一个等式的推论:
7
+ * 凡 WHATWG 会改写的串(大写、缺省端口、IPv4 简写、百分号编码的 host、串内制表符、前后空白、反斜杠……)都不等于自己的 origin,所以都不是规范形。
8
+ * 判官只有 {@link isSandboxGitOrigin} 一只;请求体的 `origins`、应答行的 `origin`、有效配置块的 `origin` 全读它(经 {@link SandboxGitOrigin})。
9
+ * 「这个串是不是 http(s) URL」归包内唯一的 URL 形判官 `httpUrlFlaws`(`./url-shape`),本模块只加「等于自己的 origin」这一格。
10
+ * origin 不表达子路径部署(`https://host/gitea` 不是 origin)—— 规范化器不存在:把带路径的串静默削成 origin 会把凭据送到运维没写的地方。
11
+ *
12
+ * ## 有效配置块 `sandboxGit`
13
+ * `/effective` 载荷上的**非域顶层键**(web-admin 按部署登记册投影,不是存储域):可选、无默认 —— 缺席 = 这个部署没有投影这一块。
14
+ * `readEffectiveWire` 对它走 open-world 逐字承运(从不校验、从不丢,`EffectiveWire.sandboxGit` 是 `unknown`);**判它的座位只有 {@link SandboxGitConfig}**,由消费方在用之前判。
15
+ *
16
+ * · `origins[i].origin` —— 本部署代理哪些 origin(规范形,见上);≤ {@link SANDBOX_GIT_MAX_ORIGINS} 项、两两不同。
17
+ * · `origins[i].connectUrl` —— 控制台「连接 Gitea(git)」页的绝对 https URL(非秘密:{@link SandboxGitConnectUrl})。
18
+ *
19
+ * 🔴 **块里 http origin 可读**:它为什么送不出凭据(传输不加密)是逐 origin 应答里的一个词(`insecure_transport`,`./api/credentials` 的判定序表第 1 行)
20
+ * 与能力读数里的一个词,不是块的校验拒。块若因一个 http origin 整块不可读,同块里的 https origin 一并失效,「逐 origin 说拒因」就无从谈起;
21
+ * http origin 只配路由、永不取值,是消费方的事。
22
+ *
23
+ * 解析剥未知键(与 `SessionMirrorPolicy` 同律:加字段恒为加性)。
24
+ */
25
+ import { z } from "zod";
26
+ /** 一次最多代理的 origin 数(请求体、应答、有效配置块同一个上界)。 */
27
+ export declare const SANDBOX_GIT_MAX_ORIGINS = 16;
28
+ /**
29
+ * 判:`raw` 是不是规范形的 http(s) origin。合格 ⇔ 它是层级式 http(s) URL 且**逐字等于**自己的 WHATWG origin。
30
+ * 纯函数、不抛;非串由 {@link SandboxGitOrigin} 的 zod 形先拒。
31
+ */
32
+ export declare function isSandboxGitOrigin(raw: string): boolean;
33
+ /** 一个规范形的 origin(`scheme://host[:port]`,scheme ∈ {http, https})。判官 {@link isSandboxGitOrigin};拒句是定句,不回显值。 */
34
+ export declare const SandboxGitOrigin: z.ZodString;
35
+ export type SandboxGitOrigin = z.infer<typeof SandboxGitOrigin>;
36
+ /**
37
+ * 一张 origin 表:每项是规范形、两两不同、至多 {@link SANDBOX_GIT_MAX_ORIGINS} 项。**空表合法**(有效配置块可以没有 origin);要求至少一项的座位(申领请求)自己 `.min(1)`。
38
+ * 重复的那一项被点名(坐标 = 第二次出现的下标)。
39
+ */
40
+ export declare const SandboxGitOriginList: z.ZodArray<z.ZodString>;
41
+ export type SandboxGitOriginList = z.infer<typeof SandboxGitOriginList>;
42
+ /**
43
+ * 控制台「连接 Gitea(git)」页的绝对 URL:**https**,且是 {@link NON_SECRET_URL_NO_MIGRATION} 那一类(层级式 URL、无 userinfo、无片段、路径与查询里没有秘密形的值)。
44
+ * 它是 wire 输入,不是存量文档里的值,所以走「无迁移指路」那一只。
45
+ */
46
+ export declare const SandboxGitConnectUrl: z.ZodString;
47
+ export type SandboxGitConnectUrl = z.infer<typeof SandboxGitConnectUrl>;
48
+ /** 有效配置块里的一个 origin 条目。 */
49
+ export declare const SandboxGitOriginEntry: z.ZodObject<{
50
+ origin: z.ZodString;
51
+ connectUrl: z.ZodString;
52
+ }, z.core.$strip>;
53
+ export type SandboxGitOriginEntry = z.infer<typeof SandboxGitOriginEntry>;
54
+ /**
55
+ * 有效配置块 `sandboxGit`(`/effective` 载荷的非域顶层键,可选、无默认)。判它的**唯一座位**:消费方在用之前对 `wire.sandboxGit` 跑 `safeParse`。
56
+ * `origins` ≤ {@link SANDBOX_GIT_MAX_ORIGINS} 项,按 `origin` 两两不同(重复的那一项的 `origin` 字段被点名);空表合法。
57
+ */
58
+ export declare const SandboxGitConfig: z.ZodObject<{
59
+ origins: z.ZodArray<z.ZodObject<{
60
+ origin: z.ZodString;
61
+ connectUrl: z.ZodString;
62
+ }, z.core.$strip>>;
63
+ }, z.core.$strip>;
64
+ export type SandboxGitConfig = z.infer<typeof SandboxGitConfig>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 沙箱 git 平面的 **origin 形** 与 **有效配置块 `sandboxGit`**(14.7.0)。纯、浏览器安全。
3
+ *
4
+ * ## 律(一条)
5
+ * **一个 origin 就是它自己的 WHATWG origin**:串等于 `new URL(串).origin`,且 scheme ∈ {http, https}。规范形 ——
6
+ * 小写 host、去缺省端口(https 的 443、http 的 80)、无尾斜杠、无 userinfo / 路径 / 查询 / 片段 —— 不是一张手写规则表,而是这一个等式的推论:
7
+ * 凡 WHATWG 会改写的串(大写、缺省端口、IPv4 简写、百分号编码的 host、串内制表符、前后空白、反斜杠……)都不等于自己的 origin,所以都不是规范形。
8
+ * 判官只有 {@link isSandboxGitOrigin} 一只;请求体的 `origins`、应答行的 `origin`、有效配置块的 `origin` 全读它(经 {@link SandboxGitOrigin})。
9
+ * 「这个串是不是 http(s) URL」归包内唯一的 URL 形判官 `httpUrlFlaws`(`./url-shape`),本模块只加「等于自己的 origin」这一格。
10
+ * origin 不表达子路径部署(`https://host/gitea` 不是 origin)—— 规范化器不存在:把带路径的串静默削成 origin 会把凭据送到运维没写的地方。
11
+ *
12
+ * ## 有效配置块 `sandboxGit`
13
+ * `/effective` 载荷上的**非域顶层键**(web-admin 按部署登记册投影,不是存储域):可选、无默认 —— 缺席 = 这个部署没有投影这一块。
14
+ * `readEffectiveWire` 对它走 open-world 逐字承运(从不校验、从不丢,`EffectiveWire.sandboxGit` 是 `unknown`);**判它的座位只有 {@link SandboxGitConfig}**,由消费方在用之前判。
15
+ *
16
+ * · `origins[i].origin` —— 本部署代理哪些 origin(规范形,见上);≤ {@link SANDBOX_GIT_MAX_ORIGINS} 项、两两不同。
17
+ * · `origins[i].connectUrl` —— 控制台「连接 Gitea(git)」页的绝对 https URL(非秘密:{@link SandboxGitConnectUrl})。
18
+ *
19
+ * 🔴 **块里 http origin 可读**:它为什么送不出凭据(传输不加密)是逐 origin 应答里的一个词(`insecure_transport`,`./api/credentials` 的判定序表第 1 行)
20
+ * 与能力读数里的一个词,不是块的校验拒。块若因一个 http origin 整块不可读,同块里的 https origin 一并失效,「逐 origin 说拒因」就无从谈起;
21
+ * http origin 只配路由、永不取值,是消费方的事。
22
+ *
23
+ * 解析剥未知键(与 `SessionMirrorPolicy` 同律:加字段恒为加性)。
24
+ */
25
+ import { z } from "zod";
26
+ import { buildQuotedIssue } from "./quoted-issue.js";
27
+ import { NON_SECRET_URL_NO_MIGRATION } from "./types.js";
28
+ import { httpUrlFlaws } from "./url-shape.js";
29
+ /** 一次最多代理的 origin 数(请求体、应答、有效配置块同一个上界)。 */
30
+ export const SANDBOX_GIT_MAX_ORIGINS = 16;
31
+ /**
32
+ * 判:`raw` 是不是规范形的 http(s) origin。合格 ⇔ 它是层级式 http(s) URL 且**逐字等于**自己的 WHATWG origin。
33
+ * 纯函数、不抛;非串由 {@link SandboxGitOrigin} 的 zod 形先拒。
34
+ */
35
+ export function isSandboxGitOrigin(raw) {
36
+ if (httpUrlFlaws(raw, []).length > 0)
37
+ return false;
38
+ return new URL(raw).origin === raw;
39
+ }
40
+ /** 一个规范形的 origin(`scheme://host[:port]`,scheme ∈ {http, https})。判官 {@link isSandboxGitOrigin};拒句是定句,不回显值。 */
41
+ export const SandboxGitOrigin = z
42
+ .string()
43
+ .refine(isSandboxGitOrigin, buildQuotedIssue `must be a canonical http(s) origin — scheme://host[:port] equal to its own WHATWG origin: lowercase host, no default port, no userinfo, path, query, fragment or trailing slash`);
44
+ /** 给定下标的 origin 在它之前已经出现过 ⇒ 下标。重复判在**规范形**之上(两个都是规范形的串,相等即同一个 origin)。 */
45
+ function repeatedAt(origins) {
46
+ const seen = new Set();
47
+ const repeats = [];
48
+ origins.forEach((o, i) => {
49
+ if (seen.has(o))
50
+ repeats.push(i);
51
+ seen.add(o);
52
+ });
53
+ return repeats;
54
+ }
55
+ /**
56
+ * 一张 origin 表:每项是规范形、两两不同、至多 {@link SANDBOX_GIT_MAX_ORIGINS} 项。**空表合法**(有效配置块可以没有 origin);要求至少一项的座位(申领请求)自己 `.min(1)`。
57
+ * 重复的那一项被点名(坐标 = 第二次出现的下标)。
58
+ */
59
+ export const SandboxGitOriginList = z
60
+ .array(SandboxGitOrigin)
61
+ .max(SANDBOX_GIT_MAX_ORIGINS)
62
+ .superRefine((origins, ctx) => {
63
+ for (const i of repeatedAt(origins))
64
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate origin`, path: [i] });
65
+ });
66
+ /**
67
+ * 控制台「连接 Gitea(git)」页的绝对 URL:**https**,且是 {@link NON_SECRET_URL_NO_MIGRATION} 那一类(层级式 URL、无 userinfo、无片段、路径与查询里没有秘密形的值)。
68
+ * 它是 wire 输入,不是存量文档里的值,所以走「无迁移指路」那一只。
69
+ */
70
+ export const SandboxGitConnectUrl = NON_SECRET_URL_NO_MIGRATION.refine((u) => URL.canParse(u) && new URL(u).protocol === "https:", buildQuotedIssue `must be an https URL — the connect page is opened by a person and carries their session`);
71
+ /** 有效配置块里的一个 origin 条目。 */
72
+ export const SandboxGitOriginEntry = z.object({
73
+ /** 本部署代理的 origin(规范形)。http 在这里可读(见模块头注)。 */
74
+ origin: SandboxGitOrigin,
75
+ /** 该 origin 的「连接」页,绝对 https URL。 */
76
+ connectUrl: SandboxGitConnectUrl,
77
+ });
78
+ /**
79
+ * 有效配置块 `sandboxGit`(`/effective` 载荷的非域顶层键,可选、无默认)。判它的**唯一座位**:消费方在用之前对 `wire.sandboxGit` 跑 `safeParse`。
80
+ * `origins` ≤ {@link SANDBOX_GIT_MAX_ORIGINS} 项,按 `origin` 两两不同(重复的那一项的 `origin` 字段被点名);空表合法。
81
+ */
82
+ export const SandboxGitConfig = z
83
+ .object({
84
+ origins: z.array(SandboxGitOriginEntry).max(SANDBOX_GIT_MAX_ORIGINS),
85
+ })
86
+ .superRefine((block, ctx) => {
87
+ for (const i of repeatedAt(block.origins.map((e) => e.origin)))
88
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate origin`, path: ["origins", i, "origin"] });
89
+ });
@@ -1,21 +1,30 @@
1
+ import { ownDeclaredValueOf } from './own-key-read.js';
2
+ import { isJsonArray, isPlainJsonObject } from './plain-json-object.js';
1
3
  import { isSessionLifetime } from './scheduler-lifetime.js';
2
4
  import { migrateSchedulerRecords } from './scheduler-store-migration.js';
3
5
  /** Structural validation of one record (required fields + a well-formed `when`). Unknown fields are allowed. */
4
6
  export function isSchedulerRecord(v) {
5
- if (typeof v !== 'object' || v === null)
7
+ if (!isPlainJsonObject(v))
6
8
  return false;
7
- const r = v;
8
- if (typeof r.id !== 'string' || typeof r.scope !== 'string' || typeof r.prompt !== 'string' || typeof r.createdMs !== 'number')
9
+ // a type guard, so every field is read STRICTLY from the record's own keys (own, enumerable, data — the kernel's `ownDeclaredValueOf`): an inherited field is absent
10
+ // (a polluted `Object.prototype.prompt` is not a prompt the record carries); an accessor / hidden field, or one that cannot be read ⇒ not a record — no getter is called,
11
+ // so what the caller reads from the record next is what was judged
12
+ const field = (o, key) => {
13
+ const read = ownDeclaredValueOf(o, key);
14
+ return read.ok ? read.value : undefined;
15
+ };
16
+ if (typeof field(v, 'id') !== 'string' || typeof field(v, 'scope') !== 'string' || typeof field(v, 'prompt') !== 'string' || typeof field(v, 'createdMs') !== 'number')
9
17
  return false;
10
- const w = r.when;
11
- if (typeof w !== 'object' || w === null)
18
+ const w = field(v, 'when');
19
+ if (!isPlainJsonObject(w))
12
20
  return false;
13
- if (w.kind === 'cron')
14
- return typeof w.expr === 'string';
15
- if (w.kind === 'at')
16
- return typeof w.atMs === 'number';
17
- if (w.kind === 'delay')
18
- return typeof w.delaySec === 'number';
21
+ const kind = field(w, 'kind');
22
+ if (kind === 'cron')
23
+ return typeof field(w, 'expr') === 'string';
24
+ if (kind === 'at')
25
+ return typeof field(w, 'atMs') === 'number';
26
+ if (kind === 'delay')
27
+ return typeof field(w, 'delaySec') === 'number';
19
28
  return false;
20
29
  }
21
30
  /** 一条会话链上的记录过期多久算「永驻孤儿」:`lifetime: "session"` ∧ `sessionId` 在场 ∧ `nextRunMs` 已过去这么久 ⇒ 读口丢弃并告警(7 天)。 */
@@ -37,7 +46,7 @@ export function parseSchedulerStore(text, opts) {
37
46
  catch {
38
47
  return { records: [], warnings: [{ kind: 'unreadable-file', why: 'not-json' }] };
39
48
  }
40
- if (!Array.isArray(parsed))
49
+ if (!isJsonArray(parsed))
41
50
  return { records: [], warnings: [{ kind: 'unreadable-file', why: 'not-an-array' }] };
42
51
  const warnings = [];
43
52
  const kept = [];
@@ -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,177 @@
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 { captureJson, captureStringArray, ownEntriesOf } from "./own-key-read.js";
46
+ import { isPlainJsonObject } from "./plain-json-object.js";
47
+ import { SAFETY_MERGE_SPEC, resolveMemberTighten, tightenVerdict } from "./safety-merge-spec.js";
48
+ /** The settings-document units (see the file header). `path` is the unit's place in a settings document. */
49
+ export const SETTINGS_UNITS = {
50
+ permissions: { path: ["permissions"], category: "permissions", reads: "members" },
51
+ permissionMode: { path: ["permissions", "defaultMode"], category: "permissionMode", reads: "whole", reading: "permissionMode" },
52
+ additionalDirectories: { path: ["permissions", "additionalDirectories"], category: "sandboxNetwork", reads: "whole", reading: "stringList" },
53
+ sandbox: { path: ["sandbox"], category: "sandboxNetwork", reads: "members" },
54
+ disableBypassPermissionsMode: { path: ["permissions", "disableBypassPermissionsMode"], category: "killSwitches", reads: "member", member: "disableBypassPermissionsMode", reading: "killSwitch" },
55
+ disableAutoMode: { path: ["permissions", "disableAutoMode"], category: "killSwitches", reads: "member", member: "disableAutoMode", reading: "killSwitch" },
56
+ hooks: { path: ["hooks"], category: "hooks", reads: "whole", reading: "hooks" },
57
+ env: { path: ["env"], category: "env", reads: "whole", reading: "envMap" },
58
+ model: { path: ["model"], category: "model", reads: "whole", reading: "nonEmptyString" },
59
+ outputStyle: { path: ["outputStyle"], category: "ui", reads: "whole", reading: "nonEmptyString" },
60
+ };
61
+ /** The one place a unit + optional member resolves to what is being asked about — the row and its comparator. Fail-closed
62
+ * and loud on every misaddressing: an unknown unit (own-property lookup — `"__proto__"` is unknown, not Object.prototype),
63
+ * a `members` unit without a member, a `whole` unit with one, a `member` unit given another one, an undeclared member
64
+ * (refused by the member row lookup in {@link resolveMemberTighten}). */
65
+ function unitTarget(unit, member) {
66
+ if (!Object.hasOwn(SETTINGS_UNITS, unit)) {
67
+ throw new RangeError(`SETTINGS_UNITS: unknown unit '${unit}' (units: ${Object.keys(SETTINGS_UNITS).join(", ")})`);
68
+ }
69
+ const row = SETTINGS_UNITS[unit];
70
+ const spec = SAFETY_MERGE_SPEC[row.category];
71
+ switch (row.reads) {
72
+ case "whole":
73
+ if (member !== undefined)
74
+ throw new RangeError(`SETTINGS_UNITS: unit '${unit}' is read whole — it has no member '${member}'`);
75
+ return { row, rule: spec.tighten };
76
+ case "member":
77
+ if (member !== undefined)
78
+ throw new RangeError(`SETTINGS_UNITS: unit '${unit}' already names its member ('${row.member}' of '${row.category}') — pass no member`);
79
+ return { row, rule: resolveMemberTighten(spec, row.member, row.category) };
80
+ case "members":
81
+ if (member === undefined) {
82
+ 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`);
83
+ }
84
+ return { row, rule: resolveMemberTighten(spec, member, row.category) };
85
+ default: {
86
+ const unknown = row;
87
+ throw new RangeError(`SETTINGS_UNITS: unit '${unit}' has an unknown addressing '${String(unknown.reads)}'`);
88
+ }
89
+ }
90
+ }
91
+ /** The comparator of one unit — or, for a `members` unit, of one DECLARED member of it: `whole` ⇒ the category's `tighten`;
92
+ * `member` ⇒ that member's comparator; `members` ⇒ the named member's comparator ({@link resolveMemberTighten}). Compare two values
93
+ * with `tightenVerdict`. A `members` unit without a member (its whole value), an undeclared member (e.g. `sandbox`'s
94
+ * `failIfUnavailable`), a `whole` unit given a member, a `member` unit given another one, an unknown unit ⇒ RangeError. */
95
+ export function unitTighten(unit, member) {
96
+ return unitTarget(unit, member).rule;
97
+ }
98
+ const UNREADABLE = { ok: false, reason: "unreadable" };
99
+ const LAUNCH_ONLY = { ok: false, reason: "launch_only" };
100
+ /** `stringList`: an array whose every element is a non-empty string (a hole reads as `undefined`, so a sparse array is not one). */
101
+ function isNonEmptyStringList(v) {
102
+ const list = captureStringArray(v, "strict"); // the package's one string-array reading, strict: the value is handed back as is, so an accessor / hidden index ⇒ not a list (no getter is called)
103
+ return list !== undefined && list.every((x) => x.length > 0);
104
+ }
105
+ /** `envMap`: a plain object (prototype `Object.prototype` or `null`), non-empty keys, string values. The value is handed back AS IS, so its members are read strictly —
106
+ * the package's one whole-container read, strict (`ownEntriesOf`: one `ownKeys`, each own string key's descriptor asked once — symbol keys never; never `[[Get]]`, never a
107
+ * proxy's `get` trap): an accessor member or a non-enumerable member ⇒ not an env map (no getter is called — what the caller reads next is what was judged); unreadable ⇒ not one. */
108
+ function isEnvMap(v) {
109
+ if (!isPlainJsonObject(v))
110
+ return false;
111
+ const members = ownEntriesOf(v);
112
+ return members.ok && members.value.every(([k, x]) => k.length > 0 && typeof x === "string");
113
+ }
114
+ /** What a declared member's value class adds on top of its comparator's domain — read through the comparator
115
+ * {@link resolveMemberTighten} derived from the row, so the seat is interpreted in one place: `list` ⇒ setUnion / setNarrower,
116
+ * whose domain takes any strings, so the `stringList` reading adds "every element non-empty"; `boolPole` ⇒ boolStricter, whose
117
+ * domain IS the boolean — nothing to add. A member comparator is only ever one of those three. */
118
+ function memberShapeReads(unit, rule, value) {
119
+ switch (rule.rule) {
120
+ case "boolStricter":
121
+ return true;
122
+ case "setUnion":
123
+ case "setNarrower":
124
+ return isNonEmptyStringList(value);
125
+ default:
126
+ throw new RangeError(`SETTINGS_UNITS: unit '${unit}' resolved to the comparator '${rule.rule}', which no member value class derives`);
127
+ }
128
+ }
129
+ /** A reading's shape / spelling half (the file header's table): the value the raw input reads as, or a refusal. The domain half
130
+ * is the unit comparator's, applied by {@link readSettingsUnit} after this. */
131
+ function shapedReading(unit, row, rule, raw) {
132
+ if (row.reads === "members")
133
+ return memberShapeReads(unit, rule, raw) ? { ok: true, value: raw } : UNREADABLE;
134
+ switch (row.reading) {
135
+ case "stringList":
136
+ return isNonEmptyStringList(raw) ? { ok: true, value: raw } : UNREADABLE;
137
+ case "permissionMode":
138
+ // the words are the category's order (the comparator's enumRank domain); the reading owns the two spellings around it
139
+ if (raw === "bypassPermissions")
140
+ return LAUNCH_ONLY;
141
+ return { ok: true, value: raw === "manual" ? "default" : raw };
142
+ case "killSwitch":
143
+ // CC spells ON "disable"; the boolean itself is the member comparator's domain
144
+ return { ok: true, value: raw === "disable" ? true : raw };
145
+ case "hooks": {
146
+ // the value is handed back AS IS, so it is read strictly (as every unit is): the whole tree is snapshotted by the kernel's strict whole-container read first —
147
+ // an accessor anywhere or a non-enumerable member ⇒ not readable (no getter is called) — and the classifier judges the snapshot, not the caller's object
148
+ const snapshot = captureJson(raw);
149
+ return snapshot.ok && classifyHooksConfig({ hooks: snapshot.value }).outcome === "loaded" ? { ok: true, value: raw } : UNREADABLE;
150
+ }
151
+ case "envMap":
152
+ return isEnvMap(raw) ? { ok: true, value: raw } : UNREADABLE;
153
+ case "nonEmptyString":
154
+ return typeof raw === "string" && raw.length > 0 ? { ok: true, value: raw } : UNREADABLE;
155
+ default: {
156
+ const unknown = row.reading;
157
+ throw new RangeError(`SETTINGS_UNITS: unit '${unit}' has an unknown reading '${String(unknown)}'`);
158
+ }
159
+ }
160
+ }
161
+ /**
162
+ * Read one unit's raw value out of a settings document (14.6.0) — the settings half of "how an existing value is normalized
163
+ * before it is compared". The reading of every unit is the file header's table; anything outside it is `unreadable`, and
164
+ * `undefined` / `null` are `unreadable` for every unit. An accepted value is returned as is (same reference) — the only rewrites
165
+ * are `"manual"` ⇒ `"default"` (permissionMode) and `"disable"` ⇒ `true` (kill-switches); `"bypassPermissions"` is `launch_only`.
166
+ * The last step of every reading is the unit comparator's own domain: whatever reads, compares (file header).
167
+ * Addressing is {@link unitTighten}'s (same RangeErrors: a `members` unit needs a declared member, …).
168
+ */
169
+ export function readSettingsUnit(unit, raw, member) {
170
+ const t = unitTarget(unit, member);
171
+ if (raw === undefined || raw === null)
172
+ return UNREADABLE;
173
+ const shaped = shapedReading(unit, t.row, t.rule, raw);
174
+ if (!shaped.ok)
175
+ return shaped;
176
+ return tightenVerdict(t.rule, shaped.value, shaped.value) === "equal" ? shaped : UNREADABLE;
177
+ }
package/dist/types.d.ts CHANGED
@@ -1830,11 +1830,11 @@ export interface SkillsManifest {
1830
1830
  * legal hash scheme. */
1831
1831
  export declare const SkillsManifestSchema: z.ZodObject<{
1832
1832
  skills: z.ZodPrefault<z.ZodArray<z.ZodObject<{
1833
+ description: z.ZodDefault<z.ZodString>;
1833
1834
  name: z.ZodString;
1834
1835
  enabled: z.ZodDefault<z.ZodBoolean>;
1835
1836
  notes: z.ZodOptional<z.ZodString>;
1836
1837
  rosters: z.ZodPrefault<z.ZodArray<z.ZodString>>;
1837
- description: z.ZodDefault<z.ZodString>;
1838
1838
  scenarios: z.ZodPrefault<z.ZodArray<z.ZodString>>;
1839
1839
  allowedTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
1840
1840
  disallowedTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -3219,10 +3219,14 @@ export type EffectiveKey = z.infer<typeof EffectiveKey>;
3219
3219
  * `prompts` is the second wire divergence: on the wire it is {@link EffectivePromptsWire} (`{schemaVersion: 2, artifact}` — the one
3220
3220
  * artifact {@link promptArtifactFor} selected for the pulling worker, stamped with its publish provenance), carried verbatim by the
3221
3221
  * read leg and judged by the consumer (that shape, then the engine's verify) — optional (absent = nothing published, or the publish
3222
- * gate is closed), never the storage shape. */
3222
+ * gate is closed), never the storage shape.
3223
+ * `sandboxGit` (14.7.0) is the third wire-only key and the third carried verbatim: the sandbox git plane's origin block — web-admin projects it from the
3224
+ * deployment's registry, it is NOT a stored domain, so `buildEffective` never produces it and `readEffectiveWire` neither validates nor drops it. Optional with
3225
+ * no default (absent = the deployment projects no such block); the ONE seat that judges it is `SandboxGitConfig` (`./sandbox-git`), run by the consumer before use. */
3223
3226
  export type EffectiveWire = Omit<EffectiveConfig, "skills" | "prompts"> & {
3224
3227
  skills: SkillsManifest;
3225
3228
  prompts?: unknown;
3229
+ sandboxGit?: unknown;
3226
3230
  };
3227
3231
  /** Reserved audit "domain" for RBAC/user-management events (not a config domain → no snapshot, not
3228
3232
  * rollback-able). Lets user changes share the one audit stream the Audit page already renders. */