@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
@@ -29,25 +29,13 @@ import path from "node:path";
29
29
  import { buildEffective, DOMAIN_READ_FALLBACK, parseDomainLoud } from "./config-fns.js";
30
30
  import { configDPath, readConfigDFile, refuseRetiredDomainFiles } from "./config-d-file.js";
31
31
  import { stableHash } from "./hash.js";
32
+ import { PORTABLE_DOMAINS } from "./portable-domains.js";
32
33
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
33
34
  import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
34
35
  /** The retired domain names (`RETIRED_DOMAINS` keys) — read from `config.d/` for their tombstone only. */
35
36
  const RETIRED_DOMAIN_NAMES = Object.keys(RETIRED_DOMAINS);
36
- /** The domains a single-machine TOC carries: what `FileConfigStore` reads out of `config.d/`, and the
37
- * **default**域集 of the bundle helpers (`exportBundle`/`readBundle`). 每个被排除的域必须在
38
- * {@link NON_PORTABLE_DOMAIN_REASONS} 里有一条成文理由(禁裸排除,#282 件5 附带裁定)。
39
- *
40
- * ⚠️ 口径诚实(对抗复审 复审 round1 F4):这是**默认集与本地读取集,不是 API 级硬边界** —— `exportBundle`
41
- * / `readBundle` 的 `domains` 参数可显式传任意 `DomainName`(bundle.ts 不拒非便携域)。把非便携域搬进
42
- * bundle 的那条口子归调用方纪律(以及各自域的导出面),不是本常量能挡住的;要不要把 bundle API 收窄到
43
- * `PortableDomain` 是一个独立的行为面裁定(#282 未决项,需三问),本批不擅自改。
44
- *
45
- * 🔴 **开新顶层域时必须同步登记本表**(成案 = 消费方 #322 真事故):域表是**闭集**,不在这里的
46
- * 域名,`config.d/<域>.json` 不会被读进 effective —— 消费方拿到的是「该域缺席」,和文件不存在**逐字
47
- * 同形**。当年 `limits` 域就是这样在单机腿静默丢了一整片限额发布(远端腿同批可用,所以更难看出来)。
48
- * 今日的兜底:漏登记的文件会被 {@link FileConfigStore} 的 `"unread-config-file"` 警告点名(fail-loud,
49
- * 见 getEffective),但那只在消费方接了 `onWarning` 时才响 —— **登记仍是第一现场的义务**。 */
50
- export const PORTABLE_DOMAINS = ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "governance", "execution", "prompts", "limits"];
37
+ // 便携域表的定义住在纯叶 ./portable-domains.ts(14.6.0;零 node 依赖,主桶模块可 import);这里原名转出口,`./node` 子路径逐名不变。
38
+ export { NON_PORTABLE_DOMAIN_REASONS, PORTABLE_DOMAINS } from "./portable-domains.js";
51
39
  /** `config.d/` 里**合法存在但不是域文档**的文件名(不进 `"unread-config-file"` 告警)。`remote-exec`
52
40
  * 不是 `DOMAIN_SCHEMAS` 成员(resolver 不读它),按 service 表决住在这个目录,由 loadRemoteExec 读
53
41
  * ——**它有自己的读取与校验路径**,所以既不是「未知文件」也不是「域文件」。
@@ -55,21 +43,6 @@ export const PORTABLE_DOMAINS = ["models", "rosters", "skills", "mcp", "plugins"
55
43
  * **导出**(3.0.0):一致性门的语料派生要按同一张表给本地腿的文件分类(域文件 / 专用文件 / 忽略),
56
44
  * 手抄一份必漂 —— 漂了的那一天,门会把一个合法的 `remote-exec.json` 报成未知文件。 */
57
45
  export const NON_DOMAIN_CONFIG_FILES = new Set([`${REMOTE_EXEC_DOCUMENT}.json`]);
58
- /**
59
- * 非便携域的**排除理由表**(#282 件5 附带裁定:禁裸排除)。
60
- *
61
- * `satisfies Record<Exclude<DomainName, PortableDomain>, string>` 让键集在**编译期**双向咬死:新开一个
62
- * 域却不放进 PORTABLE_DOMAINS ⇒ 这里缺行 ⇒ 编译错;把某域改成便携却忘了删行 ⇒ 多余键 ⇒ 编译错。
63
- * (此前是「理由写在注释里」,机器只能做子串匹配——域名出现在文件任何角落都算数,等于没门;对抗复审
64
- * 复审 round1 F3。)理由要写**为什么它不该被单机/跨部署搬运**,带出处。
65
- */
66
- export const NON_PORTABLE_DOMAIN_REASONS = {
67
- workers: "FLEET-only:单机没有 reconciler,`{workers:[]}` 的 schema 默认就是正确语义(缺席不需要 stub)。",
68
- hosts: "FLEET-only:同 workers —— 单机无机器注册表/自动排布,空注册表即正确语义。",
69
- systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 web-admin 侧生命周期,单机 lane 无对应执法点。",
70
- projects: "TENANT IDENTITY DATA,不是可搬运的配置:登记簿随包复制进另一个部署 = 凭空多出的身份登记(design/142 §1.4 威胁模型①「carried-in foreign projectId」换了身 import 的衣服)。要带走走 142 §5 的导出面(service 半场)。",
71
- 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 本就没有那个执法点。",
72
- };
73
46
  export class FileConfigStore {
74
47
  root;
75
48
  opts;
package/dist/fleet.js CHANGED
@@ -20,6 +20,7 @@
20
20
  import { z } from "zod";
21
21
  import { INTEGER } from "./integer.js";
22
22
  import { fnv1a64 } from "./hash.js";
23
+ import { isPlainJsonObject } from "./plain-json-object.js";
23
24
  import { buildQuotedIssue } from "./quoted-issue.js";
24
25
  // ── Web-admin's fleet endpoints (paths, one spelling) ─────────────────────────────────────────────────────
25
26
  /**
@@ -173,7 +174,7 @@ export const RestartSignal = z.object({
173
174
  */
174
175
  function judgeSignalBody(body, pick, schema) {
175
176
  try {
176
- if (body === null || typeof body !== "object" || Array.isArray(body))
177
+ if (!isPlainJsonObject(body))
177
178
  return { kind: "not-an-object" };
178
179
  const parsed = schema.safeParse(pick(body));
179
180
  return parsed.success ? { kind: "read", value: parsed.data } : { kind: "invalid" };
@@ -5,6 +5,8 @@
5
5
  *
6
6
  * 落点({@link FsFailureSite}.`landing`,闭集)按失败发生在哪一步分句 —— 同一个 errno 在不同落点指向不同的东西:
7
7
  * · `dir-create-temp` —— 原子写器在目标所在目录里建临时文件。目录不让建 ⇒ 点名**目录**(文件本身没错);
8
+ * · `chmod` —— 原子写器给临时文件设模式(已有的文件沿用它原来的模式,临时文件建成后、`rename` 之前设)。这一步失败 ⇒ 点名**设模式**这件事(文件系统不让改文件模式;目录没有问题),
9
+ * 不提目录、不提不可变标志;`path` 是被保留模式的**真文件**(chmod 作用在替换它的临时副本上,用户认得的是真文件名);
8
10
  * · `rename` —— 原子写器 `rename` 盖过目标。目录收了新文件、却换不掉这一份 ⇒ 点名**真文件**;
9
11
  * · `remove` —— 删一个文件。删的是目录项:目录不让删 ⇒ 点名文件所在的**目录**(与建临时文件同一个因);
10
12
  * · `read` —— 读一个目录或文件;
@@ -12,22 +14,35 @@
12
14
  * 认得的 errno 给那一步对应的出路,别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。两条与落点无关的律:
13
15
  * · errno 只打一遍 —— 句首括号里一次,Node 句子自带的 `<errno>: ` 前缀去掉;
14
16
  * · `ENAMETOOLONG`(路径某一节或整条超限)既不是目录的错也不是文件的错 ⇒ 一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`,不点名目录、不给权限类出路。
15
- * 目录落点(`dir-create-temp` / `remove`)的 `EPERM` / `EROFS` 出路句尾加「目录可能是只读 / 不可变(chflags / chattr)」—— 改权限位修不好的那两种。
17
+ * 三个落点(`dir-create-temp` / `remove` / `rename`)的权限类出路走**同一张表**、一只函数({@link permissionWayOut}):落点表 {@link WAY_OUT_LANDING}(这个落点认哪些 errno、清标志点名谁、动已有目录项的落点才有粘滞目录一句)×
18
+ * errno 表 {@link WAY_OUT_BY_ERRNO}(穷举;每个 errno 两格 —— 目录权限位对本进程「可写」/「不可写或没读过」)。同一个 `EPERM` 既可能是目录模式不让写,也可能是不可变标志(macOS `chflags uchg` / Linux `chattr +i`),
19
+ * 同一个 `EACCES` 在模式位已经放行时只能是 ACL 条目或安全模块策略 —— 铸点是纯函数,分不清,但调用方在 Node 一侧能读目录模式,把「模式对本进程可写」这个事实经落点的 `directoryWritable` 递进来:
20
+ * 「可写」⇒ chmod 已经没用,`EPERM` 的首选出路 = 清标志(`chflags nouchg` / `chattr -i`),动已有目录项的落点(remove / rename)再接粘滞目录一句(那条属主规则给的就是 `EPERM`),`EACCES` 的出路 = 查 ACL / 安全模块(不接粘滞句);
21
+ * 否则(模式不可写,或调用方没读过模式 —— 注入的替身 I/O 抛的错,包看不到它背后)⇒ 先让目录可写,`EPERM` / `EROFS` 句尾提示「只读 / 不可变」的可能。`EROFS` 的出路不变(只读文件系统清不掉)。
22
+ * `rename` 落点的「可写」不是读出来的而是已证明的({@link RENAME_DIRECTORY_WRITABLE}:走到 rename 时临时文件刚在同一目录建成)—— 一个来源,`EPERM` 与 `EACCES` 同走「可写」那一列,所以 rename 句从不说「make the directory writable」。
16
23
  *
17
24
  * 原子写器抛 {@link FsFailure}(句子在 `message`,`code` 照抄底层 errno,原错误在 `cause`);它在写器内部已经按更细的落点铸过句子,
18
25
  * 命令行 / 迁移用 {@link fsFailureMessage} 取句 —— 已经是 {@link FsFailure} 的原样用,否则按它所在的口铸。
19
26
  */
20
27
  /** 一次失败发生的地方(闭集):落点加那一步交给操作系统的真实路径(`dir-create-temp` 是要建的临时文件,点名的目录是它的父目录;`rename` 是被盖过的真文件;
21
- * `remove` / `write` 是那份文件;`read` 是读的目录或文件)。 */
28
+ * `remove` / `write` 是那份文件;`read` 是读的目录或文件;`chmod` 是被保留模式的真文件〔这一格不是「交给操作系统的路径」口径:chmod 的对象是替换它的临时副本〕)。
29
+ * `directoryWritable`(只有 `dir-create-temp` / `remove` 两个落点有)—— 失败那一刻,目录的权限位对本进程是否可写(Node 一侧读的事实,见 `./atomic-write` 的 `directoryAdmitsWrite`);
30
+ * 缺席 = 没读过(注入的替身 I/O),出路顺序按「不可写」走。`rename` 落点没有这一格:走到 rename 时临时文件刚在同一目录建成,目录可写是已证明的事实,由铸点按 {@link RENAME_DIRECTORY_WRITABLE} 给出。
31
+ */
22
32
  export type FsFailureSite = {
23
33
  readonly landing: "dir-create-temp";
24
34
  readonly path: string;
35
+ readonly directoryWritable?: boolean;
36
+ } | {
37
+ readonly landing: "chmod";
38
+ readonly path: string;
25
39
  } | {
26
40
  readonly landing: "rename";
27
41
  readonly path: string;
28
42
  } | {
29
43
  readonly landing: "remove";
30
44
  readonly path: string;
45
+ readonly directoryWritable?: boolean;
31
46
  } | {
32
47
  readonly landing: "read";
33
48
  readonly path: string;
@@ -5,6 +5,8 @@
5
5
  *
6
6
  * 落点({@link FsFailureSite}.`landing`,闭集)按失败发生在哪一步分句 —— 同一个 errno 在不同落点指向不同的东西:
7
7
  * · `dir-create-temp` —— 原子写器在目标所在目录里建临时文件。目录不让建 ⇒ 点名**目录**(文件本身没错);
8
+ * · `chmod` —— 原子写器给临时文件设模式(已有的文件沿用它原来的模式,临时文件建成后、`rename` 之前设)。这一步失败 ⇒ 点名**设模式**这件事(文件系统不让改文件模式;目录没有问题),
9
+ * 不提目录、不提不可变标志;`path` 是被保留模式的**真文件**(chmod 作用在替换它的临时副本上,用户认得的是真文件名);
8
10
  * · `rename` —— 原子写器 `rename` 盖过目标。目录收了新文件、却换不掉这一份 ⇒ 点名**真文件**;
9
11
  * · `remove` —— 删一个文件。删的是目录项:目录不让删 ⇒ 点名文件所在的**目录**(与建临时文件同一个因);
10
12
  * · `read` —— 读一个目录或文件;
@@ -12,20 +14,58 @@
12
14
  * 认得的 errno 给那一步对应的出路,别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。两条与落点无关的律:
13
15
  * · errno 只打一遍 —— 句首括号里一次,Node 句子自带的 `<errno>: ` 前缀去掉;
14
16
  * · `ENAMETOOLONG`(路径某一节或整条超限)既不是目录的错也不是文件的错 ⇒ 一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`,不点名目录、不给权限类出路。
15
- * 目录落点(`dir-create-temp` / `remove`)的 `EPERM` / `EROFS` 出路句尾加「目录可能是只读 / 不可变(chflags / chattr)」—— 改权限位修不好的那两种。
17
+ * 三个落点(`dir-create-temp` / `remove` / `rename`)的权限类出路走**同一张表**、一只函数({@link permissionWayOut}):落点表 {@link WAY_OUT_LANDING}(这个落点认哪些 errno、清标志点名谁、动已有目录项的落点才有粘滞目录一句)×
18
+ * errno 表 {@link WAY_OUT_BY_ERRNO}(穷举;每个 errno 两格 —— 目录权限位对本进程「可写」/「不可写或没读过」)。同一个 `EPERM` 既可能是目录模式不让写,也可能是不可变标志(macOS `chflags uchg` / Linux `chattr +i`),
19
+ * 同一个 `EACCES` 在模式位已经放行时只能是 ACL 条目或安全模块策略 —— 铸点是纯函数,分不清,但调用方在 Node 一侧能读目录模式,把「模式对本进程可写」这个事实经落点的 `directoryWritable` 递进来:
20
+ * 「可写」⇒ chmod 已经没用,`EPERM` 的首选出路 = 清标志(`chflags nouchg` / `chattr -i`),动已有目录项的落点(remove / rename)再接粘滞目录一句(那条属主规则给的就是 `EPERM`),`EACCES` 的出路 = 查 ACL / 安全模块(不接粘滞句);
21
+ * 否则(模式不可写,或调用方没读过模式 —— 注入的替身 I/O 抛的错,包看不到它背后)⇒ 先让目录可写,`EPERM` / `EROFS` 句尾提示「只读 / 不可变」的可能。`EROFS` 的出路不变(只读文件系统清不掉)。
22
+ * `rename` 落点的「可写」不是读出来的而是已证明的({@link RENAME_DIRECTORY_WRITABLE}:走到 rename 时临时文件刚在同一目录建成)—— 一个来源,`EPERM` 与 `EACCES` 同走「可写」那一列,所以 rename 句从不说「make the directory writable」。
16
23
  *
17
24
  * 原子写器抛 {@link FsFailure}(句子在 `message`,`code` 照抄底层 errno,原错误在 `cause`);它在写器内部已经按更细的落点铸过句子,
18
25
  * 命令行 / 迁移用 {@link fsFailureMessage} 取句 —— 已经是 {@link FsFailure} 的原样用,否则按它所在的口铸。
19
26
  */
20
- /** `dir-create-temp` 里「目录本身不让建文件」的 errno:权限不够、只读文件系统、目录不在。出路 = 让目录可写。 */
21
- const DIRECTORY_NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS", "ENOENT"]);
22
- /** `remove` 里「目录不让删目录项」的 errno(同一个因:目录不可写)。目录不在 / 文件已不在(ENOENT)不是这个因,给 Node 自己的句子。 */
23
- const DIRECTORY_ENTRY_NOT_REMOVABLE = new Set(["EACCES", "EPERM", "EROFS"]);
24
- /** `rename` 里「目录收新文件、却换不掉这一份」的 errno:不可变标志、粘滞目录里别人的文件。出路 = 让文件可被替换。 */
25
- const FILE_NOT_REPLACEABLE = new Set(["EACCES", "EPERM"]);
26
- /** 目录落点(`dir-create-temp` / `remove`)的出路。`EPERM`(目录带不可变标志:macOS `chflags uchg`、Linux `chattr +i`)与 `EROFS`(只读文件系统)靠改权限位修不好,
27
- * 出路句尾提示这一种可能;`EACCES` / `ENOENT` 是纯权限位 / 目录不在,出路就是让目录可写。 */
28
- const directoryWayOut = (code) => (code === "EPERM" || code === "EROFS" ? "make the directory writable; directory may be read-only or immutable (chflags / chattr)" : "make the directory writable");
27
+ /** `rename` 落点上「目录对本进程可写」的事实:走到 rename 时临时文件刚在**同一目录**建成,这是已证明的,不是读出来的 —— 写器不对 rename 落点读模式位(一个来源)。 */
28
+ const RENAME_DIRECTORY_WRITABLE = true;
29
+ const MODE_BITS_ADMIT = "the directory's permission bits already admit this process, so chmod will not help";
30
+ const MAKE_DIRECTORY_WRITABLE = "make the directory writable";
31
+ const READ_ONLY_OR_IMMUTABLE = `${MAKE_DIRECTORY_WRITABLE}; directory may be read-only or immutable (chflags / chattr)`;
32
+ /** errno 表:每个权限类 errno 在目录权限位「可写」(`admits`:chmod 已无用,说还剩什么真因)与「不可写或没读过」(`otherwise`)两格的出路,以及「可写」时是否接粘滞目录一句(`stickyWhenAdmitted`)。
33
+ * `EROFS` / `ENOENT` 的两格同一句 —— 模式位可不可写不改变它们的出路(只读文件系统清不掉;目录不在,先让它在)。 */
34
+ const WAY_OUT_BY_ERRNO = {
35
+ EPERM: {
36
+ admits: (flagSubject) => `clear the immutable flag on ${flagSubject} (macOS: chflags nouchg; Linux: chattr -i) — ${MODE_BITS_ADMIT}`,
37
+ otherwise: READ_ONLY_OR_IMMUTABLE,
38
+ stickyWhenAdmitted: true,
39
+ },
40
+ EACCES: {
41
+ admits: () => `${MODE_BITS_ADMIT}; an ACL entry may still deny this process (macOS: ls -le; Linux: getfacl); so may a security-module policy (SELinux / AppArmor: the audit log names the rule)`,
42
+ otherwise: MAKE_DIRECTORY_WRITABLE,
43
+ stickyWhenAdmitted: false, // 粘滞目录的属主规则返回的是 EPERM(macOS `man 2 unlink` / `rename`;Linux 同,推断),EACCES 的句尾不指向一个产生不了它的因
44
+ },
45
+ EROFS: { admits: () => READ_ONLY_OR_IMMUTABLE, otherwise: READ_ONLY_OR_IMMUTABLE, stickyWhenAdmitted: false },
46
+ ENOENT: { admits: () => MAKE_DIRECTORY_WRITABLE, otherwise: MAKE_DIRECTORY_WRITABLE, stickyWhenAdmitted: false },
47
+ };
48
+ const WAY_OUT_LANDING = {
49
+ "dir-create-temp": { errnos: ["EACCES", "EPERM", "EROFS", "ENOENT"], flagSubject: "the directory" },
50
+ remove: { errnos: ["EACCES", "EPERM", "EROFS"], flagSubject: "the file or its directory", stickyVerb: "remove" },
51
+ rename: { errnos: ["EACCES", "EPERM"], flagSubject: "the file or its directory", stickyVerb: "replace" },
52
+ };
53
+ /** 权限类出路的**唯一一只函数**(三个落点共用,表驱动):`code` 不是这个落点认的权限类 errno ⇒ `undefined`(调用方给 Node 自己的句子)。
54
+ * 组合律一条:errno 表的格(按 `directoryWritable === true` 选「可写」列或「不可写或没读过」列);「可写」∧ 该 errno 带粘滞 ∧ 这个落点有 `stickyVerb` ⇒ 句尾接 `in a sticky directory only the file's owner may <verb> it`。 */
55
+ const permissionWayOut = (landing, code, directoryWritable) => {
56
+ const spec = WAY_OUT_LANDING[landing];
57
+ const errno = spec.errnos.find((e) => e === code);
58
+ if (errno === undefined)
59
+ return undefined;
60
+ const row = WAY_OUT_BY_ERRNO[errno];
61
+ if (directoryWritable !== true)
62
+ return row.otherwise;
63
+ const way = row.admits(spec.flagSubject);
64
+ return row.stickyWhenAdmitted && spec.stickyVerb !== undefined ? `${way}; in a sticky directory only the file's owner may ${spec.stickyVerb} it` : way;
65
+ };
66
+ /** `chmod` 落点认的 errno:`EPERM` —— 创建者对自己刚建的临时文件 chmod 被拒,只能是文件系统 / 挂载不支持改模式。别的 errno 给 Node 自己的句子。 */
67
+ const CHMOD_REFUSED = new Set(["EPERM"]);
68
+ const CHMOD_WAY_OUT = "the filesystem does not allow changing file modes — keep this file on a filesystem that supports POSIX permissions";
29
69
  const errnoOf = (e) => {
30
70
  const code = e?.code;
31
71
  return typeof code === "string" ? code : undefined;
@@ -49,13 +89,13 @@ export function fsFailureSentence(site, cause) {
49
89
  const tag = code === undefined ? "" : ` (${code})`;
50
90
  switch (site.landing) {
51
91
  case "dir-create-temp":
52
- return `cannot write into directory ${parentDirectory(site.path)}${tag}: ${code !== undefined && DIRECTORY_NOT_WRITABLE.has(code) ? directoryWayOut(code) : nodeSentenceOf(cause, code)}`;
92
+ return `cannot write into directory ${parentDirectory(site.path)}${tag}: ${permissionWayOut("dir-create-temp", code, site.directoryWritable) ?? nodeSentenceOf(cause, code)}`;
93
+ case "chmod":
94
+ return `cannot set the mode of ${site.path}${tag}: ${code !== undefined && CHMOD_REFUSED.has(code) ? CHMOD_WAY_OUT : nodeSentenceOf(cause, code)}`;
53
95
  case "rename":
54
- return code !== undefined && FILE_NOT_REPLACEABLE.has(code)
55
- ? `cannot replace ${site.path}${tag}: make the file replaceable — clear an immutable flag; in a sticky directory only the file's owner may replace it`
56
- : `cannot replace ${site.path}${tag}: ${nodeSentenceOf(cause, code)}`;
96
+ return `cannot replace ${site.path}${tag}: ${permissionWayOut("rename", code, RENAME_DIRECTORY_WRITABLE) ?? nodeSentenceOf(cause, code)}`;
57
97
  case "remove":
58
- return `cannot remove ${site.path} from directory ${parentDirectory(site.path)}${tag}: ${code !== undefined && DIRECTORY_ENTRY_NOT_REMOVABLE.has(code) ? directoryWayOut(code) : nodeSentenceOf(cause, code)}`;
98
+ return `cannot remove ${site.path} from directory ${parentDirectory(site.path)}${tag}: ${permissionWayOut("remove", code, site.directoryWritable) ?? nodeSentenceOf(cause, code)}`;
59
99
  case "read":
60
100
  return `cannot read ${site.of === "directory" ? "directory " : ""}${site.path}${tag}: ${nodeSentenceOf(cause, code)}`;
61
101
  case "write":
@@ -167,8 +167,8 @@ export declare const HOOKS_DROPPED_PIECE_CODES: readonly ["hooks-not-object", "u
167
167
  export type HooksDroppedPieceCode = (typeof HOOKS_DROPPED_PIECE_CODES)[number];
168
168
  /**
169
169
  * Why the document was REJECTED (closed; each is "a guard may be in this piece"):
170
- * · `guard-outside-hooks` — a guard sign outside `hooks` (top level, under another key, or the document itself matcher-shaped);
171
- * · `hooks-is-matcher` — `hooks` is an array holding objects / arrays, a single matcher, or a single entry;
170
+ * · `guard-outside-hooks` — a guard sign outside `hooks` (top level, under another key, or the document itself matcher-shaped), or the document could not be read at all (path `[]`);
171
+ * · `hooks-is-matcher` — `hooks` is an array holding objects / arrays, a single matcher, or a single entry, or an object that is not a readable block (a class instance, a value that cannot be inspected) holding — or possibly holding — a guard sign;
172
172
  * · `guard-under-unknown-event` — an unknown event name holding a guard sign;
173
173
  * · `guard-event-not-array` — a guard event whose value is not an array (and not null), or a known event whose non-array value holds a sign;
174
174
  * · `guard-matcher-invalid` — a bad matcher in a guard event, or a matcher holding a sign (in any event);
@@ -46,6 +46,7 @@
46
46
  * contract; cycles are tolerated, a throwing accessor propagates.
47
47
  */
48
48
  import { z } from "zod";
49
+ import { isPlainJsonObject } from "./plain-json-object.js";
49
50
  import { buildQuotedIssue, fixed } from "./quoted-issue.js";
50
51
  // ── event vocabulary ───────────────────────────────────────────────────────────────────────────────────────
51
52
  // The two halves below are where each word came from (CC 2.1.198's own table, then the later additions) — provenance for the
@@ -141,8 +142,8 @@ export const HOOKS_CLASSIFICATION_OUTCOMES = ["loaded", "loaded-partial", "rejec
141
142
  export const HOOKS_DROPPED_PIECE_CODES = ["hooks-not-object", "unknown-event", "event-not-array", "matcher-invalid", "entry-invalid"];
142
143
  /**
143
144
  * Why the document was REJECTED (closed; each is "a guard may be in this piece"):
144
- * · `guard-outside-hooks` — a guard sign outside `hooks` (top level, under another key, or the document itself matcher-shaped);
145
- * · `hooks-is-matcher` — `hooks` is an array holding objects / arrays, a single matcher, or a single entry;
145
+ * · `guard-outside-hooks` — a guard sign outside `hooks` (top level, under another key, or the document itself matcher-shaped), or the document could not be read at all (path `[]`);
146
+ * · `hooks-is-matcher` — `hooks` is an array holding objects / arrays, a single matcher, or a single entry, or an object that is not a readable block (a class instance, a value that cannot be inspected) holding — or possibly holding — a guard sign;
146
147
  * · `guard-under-unknown-event` — an unknown event name holding a guard sign;
147
148
  * · `guard-event-not-array` — a guard event whose value is not an array (and not null), or a known event whose non-array value holds a sign;
148
149
  * · `guard-matcher-invalid` — a bad matcher in a guard event, or a matcher holding a sign (in any event);
@@ -156,12 +157,57 @@ export const HOOKS_REJECTION_CODES = [
156
157
  "guard-matcher-invalid",
157
158
  "guard-entry-invalid",
158
159
  ];
159
- /** A JSON object: not null, not an array, prototype `Object.prototype` or null. */
160
- function hookIsRecord(value) {
161
- if (value === null || typeof value !== "object" || Array.isArray(value))
162
- return false;
163
- const proto = Object.getPrototypeOf(value);
164
- return proto === Object.prototype || proto === null;
160
+ /** The value's shape — six words: `plain` is a JSON object (not null, not an array, prototype null or a prototype whose own prototype is null: any realm's `Object.prototype`),
161
+ * `uninspectable` is a value whose shape cannot be read at all (a proxy that has been revoked, a throwing `getPrototypeOf` trap). This function IS `./plain-json-object`'s `jsonShapeOf`, character for
162
+ * character: the classifier is copied as source by consumers' generators, so its closure cannot import — the package has this one second copy, and the test is what keeps it from drifting. */
163
+ function hookShapeOf(v) {
164
+ try {
165
+ if (v === null)
166
+ return "null";
167
+ if (Array.isArray(v))
168
+ return "array";
169
+ if (typeof v === "function")
170
+ return "non_plain_object";
171
+ if (typeof v !== "object")
172
+ return "primitive";
173
+ const proto = Object.getPrototypeOf(v);
174
+ return proto === null || Object.getPrototypeOf(proto) === null ? "plain" : "non_plain_object";
175
+ }
176
+ catch {
177
+ return "uninspectable"; // 读形状就抛(已撤销的 Proxy 连 `Array.isArray` 都抛、`getPrototypeOf` 陷阱抛):不是「不朴素」,是读不出 —— 读者按最严处置
178
+ }
179
+ }
180
+ /** `./plain-json-object` 的 `isPlainJsonObject`,调各自的形状分类核后逐字同文(闭包里不能 import)。 */
181
+ function hookIsRecord(v) {
182
+ const shape = hookShapeOf(v);
183
+ return shape === "plain";
184
+ }
185
+ /** Is the value object-like — anything but null / a primitive (arrays, functions and values whose shape cannot be read count). The shape is asked first, never a `typeof` split. */
186
+ function hookIsObjectLike(v) {
187
+ const shape = hookShapeOf(v);
188
+ switch (shape) {
189
+ case "null":
190
+ case "primitive":
191
+ return false;
192
+ case "plain":
193
+ case "non_plain_object":
194
+ case "array":
195
+ case "uninspectable":
196
+ return true;
197
+ default: {
198
+ const unhandled = shape;
199
+ throw new Error(`hookIsObjectLike: unhandled shape ${String(unhandled)}`);
200
+ }
201
+ }
202
+ }
203
+ /** `./plain-json-object` 的 `isJsonArray`,逐字同文(闭包里不能 import):`Array.isArray` 的总版 —— 已撤销的 Proxy 读不出 ⇒ 不是数组,不抛。 */
204
+ function hookIsArray(v) {
205
+ try {
206
+ return Array.isArray(v);
207
+ }
208
+ catch {
209
+ return false; // 已撤销的 Proxy:读不出 ⇒ 不是数组
210
+ }
165
211
  }
166
212
  /** The first own key of `node` (in key order) that names a guard event with a value other than null / undefined / `[]` (CC `ei`). */
167
213
  function hookGuardKeyOf(node) {
@@ -169,7 +215,7 @@ function hookGuardKeyOf(node) {
169
215
  if (!HOOK_GUARD_EVENTS.includes(key))
170
216
  continue;
171
217
  const value = node[key];
172
- if (value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0))
218
+ if (value !== null && value !== undefined && !(hookIsArray(value) && value.length === 0))
173
219
  return key;
174
220
  }
175
221
  return undefined;
@@ -179,7 +225,7 @@ function hookLooksLikeMatcher(value) {
179
225
  if (!hookIsRecord(value))
180
226
  return false;
181
227
  const inner = Object.hasOwn(value, "hooks") ? value.hooks : undefined;
182
- if (Array.isArray(inner))
228
+ if (hookIsArray(inner))
183
229
  return inner.length > 0;
184
230
  return hookIsRecord(inner) && (Object.hasOwn(value, "matcher") || typeof inner.type === "string");
185
231
  }
@@ -203,25 +249,48 @@ function hookGuardSignIn(root, rootPath, place) {
203
249
  };
204
250
  while (pending.length > 0) {
205
251
  const [node, frame, at] = pending.pop();
206
- if (node === null || typeof node !== "object" || seen[at].has(node))
252
+ // the shape is asked BEFORE any `typeof` split: a node whose shape cannot be read — a proxy that has been revoked, of a function target included — cannot be shown free of guard signs
253
+ // ⇒ reported as a sign at its own coordinate (fail-closed: the document is rejected); no `typeof` gate of the classifier's own stands in front of the seat
254
+ const shape = hookShapeOf(node);
255
+ let isList = false;
256
+ switch (shape) {
257
+ case "null":
258
+ case "primitive":
259
+ continue; // not an object: nothing to search
260
+ case "uninspectable":
261
+ return pathTo(frame, []);
262
+ case "array":
263
+ isList = true;
264
+ break;
265
+ case "plain":
266
+ case "non_plain_object":
267
+ break; // searched as an object below
268
+ default: {
269
+ const unhandled = shape;
270
+ throw new Error(`hookGuardSignIn: unhandled shape ${String(unhandled)}`);
271
+ }
272
+ }
273
+ const visited = node;
274
+ if (seen[at].has(visited))
207
275
  continue;
208
- seen[at].add(node);
209
- if (Array.isArray(node)) {
210
- for (let i = node.length - 1; i >= 0; i--) {
276
+ seen[at].add(visited);
277
+ if (isList) {
278
+ const list = node;
279
+ for (let i = list.length - 1; i >= 0; i--) {
211
280
  frames.push({ key: i, parent: frame });
212
- pending.push([node[i], frames.length - 1, at === "matcherList" ? "matcher" : "other"]);
281
+ pending.push([list[i], frames.length - 1, at === "matcherList" ? "matcher" : "other"]);
213
282
  }
214
283
  continue;
215
284
  }
216
- const guardKey = hookGuardKeyOf(node);
285
+ const guardKey = hookGuardKeyOf(visited);
217
286
  if (guardKey !== undefined)
218
287
  return pathTo(frame, [guardKey]);
219
- if (at !== "matcher" && hookLooksLikeMatcher(node))
288
+ if (at !== "matcher" && hookLooksLikeMatcher(visited))
220
289
  return pathTo(frame, []);
221
- const keys = Object.keys(node);
290
+ const keys = Object.keys(visited);
222
291
  for (let i = keys.length - 1; i >= 0; i--) {
223
292
  frames.push({ key: keys[i], parent: frame });
224
- pending.push([node[keys[i]], frames.length - 1, "other"]);
293
+ pending.push([visited[keys[i]], frames.length - 1, "other"]);
225
294
  }
226
295
  }
227
296
  return undefined;
@@ -248,7 +317,7 @@ function hookFieldFits(base, value) {
248
317
  case "stringRecord":
249
318
  return hookIsRecord(value) && Object.values(value).every((v) => typeof v === "string");
250
319
  case "stringList":
251
- if (!Array.isArray(value))
320
+ if (!hookIsArray(value))
252
321
  return false;
253
322
  for (let i = 0; i < value.length; i++)
254
323
  if (typeof value[i] !== "string")
@@ -283,7 +352,7 @@ function hookEntryRead(entry) {
283
352
  const base = (optional ? kind.slice(0, -1) : kind);
284
353
  if (!hookFieldFits(base, value))
285
354
  return { field };
286
- out[field] = Array.isArray(value) ? value.slice() : hookIsRecord(value) ? Object.fromEntries(Object.entries(value)) : value;
355
+ out[field] = hookIsArray(value) ? value.slice() : hookIsRecord(value) ? Object.fromEntries(Object.entries(value)) : value;
287
356
  }
288
357
  return { entry: out };
289
358
  }
@@ -301,18 +370,18 @@ function hookMatcherRead(matcher, at, guardEvent, reject, drop) {
301
370
  // the rejection's `field` is the matcher's own guard-event key if it has one, else the first member (document order) whose subtree holds a sign —
302
371
  // the order hookGuardSignIn reports
303
372
  const signField = hookGuardKeyOf(matcher) ??
304
- Object.keys(matcher).find((key) => !(key === "hooks" && Array.isArray(entries)) && hookGuardSignIn(matcher[key], [...at, key], "other") !== undefined);
373
+ Object.keys(matcher).find((key) => !(key === "hooks" && hookIsArray(entries)) && hookGuardSignIn(matcher[key], [...at, key], "other") !== undefined);
305
374
  if (signField !== undefined) {
306
375
  reject("guard-matcher-invalid", at, signField);
307
376
  return undefined;
308
377
  }
309
378
  const entrySigns = [];
310
- if (Array.isArray(entries))
379
+ if (hookIsArray(entries))
311
380
  for (let j = 0; j < entries.length; j++)
312
381
  entrySigns.push(hookGuardSignIn(entries[j], [...at, "hooks", j], "other"));
313
382
  const name = Object.hasOwn(matcher, "matcher") ? matcher.matcher : undefined;
314
- const badField = name !== undefined && typeof name !== "string" ? "matcher" : !Array.isArray(entries) ? "hooks" : undefined;
315
- if (badField !== undefined || !Array.isArray(entries)) {
383
+ const badField = name !== undefined && typeof name !== "string" ? "matcher" : !hookIsArray(entries) ? "hooks" : undefined;
384
+ if (badField !== undefined || !hookIsArray(entries)) {
316
385
  if (guardEvent || entrySigns.some((sign) => sign !== undefined))
317
386
  reject("guard-matcher-invalid", at, badField);
318
387
  else
@@ -340,7 +409,10 @@ function hookMatcherRead(matcher, at, guardEvent, reject, drop) {
340
409
  /** The `hooks` value → what loads of it (undefined when nothing does), reporting its bad pieces / signs into the two sinks. */
341
410
  function hookBlockRead(hooks, reject, drop) {
342
411
  if (!hookIsRecord(hooks)) {
343
- if (Array.isArray(hooks) && hooks.some((x) => x !== null && typeof x === "object"))
412
+ if (hookIsArray(hooks) && hooks.some(hookIsObjectLike))
413
+ reject("hooks-is-matcher", ["hooks"]);
414
+ // an object that is not a readable block (a class instance, a proxy that cannot be inspected) holding — or possibly holding — a guard sign: reject, never drop it (a dropped guard silently disappears)
415
+ else if (hookGuardSignIn(hooks, ["hooks"], "other") !== undefined)
344
416
  reject("hooks-is-matcher", ["hooks"]);
345
417
  else
346
418
  drop("hooks-not-object", ["hooks"]);
@@ -355,14 +427,14 @@ function hookBlockRead(hooks, reject, drop) {
355
427
  const value = hooks[event];
356
428
  const at = ["hooks", event];
357
429
  if (!HOOK_EVENTS.includes(event)) {
358
- if (hookGuardSignIn(value, at, Array.isArray(value) ? "matcherList" : "other") !== undefined)
430
+ if (hookGuardSignIn(value, at, hookIsArray(value) ? "matcherList" : "other") !== undefined)
359
431
  reject("guard-under-unknown-event", at);
360
432
  else
361
433
  drop("unknown-event", at);
362
434
  continue;
363
435
  }
364
436
  const guardEvent = HOOK_GUARD_EVENTS.includes(event);
365
- if (!Array.isArray(value)) {
437
+ if (!hookIsArray(value)) {
366
438
  if ((guardEvent && value !== null) || hookGuardSignIn(value, at, "other") !== undefined)
367
439
  reject("guard-event-not-array", at);
368
440
  else
@@ -392,6 +464,15 @@ function hookBlockRead(hooks, reject, drop) {
392
464
  * the hooks-classify test file (group ⑤) pins that the free names are exactly these.
393
465
  */
394
466
  export function classifyHooksConfig(settingsDoc) {
467
+ try {
468
+ return hookClassify(settingsDoc);
469
+ }
470
+ catch {
471
+ // reading the document threw (a trap, an accessor that throws): nothing in it can be shown free of guard signs ⇒ rejected at the root — ONE `try` for the whole classifier, none per node
472
+ return { outcome: "rejected", hooks: undefined, dropped: [], rejections: [{ code: "guard-outside-hooks", path: [] }] };
473
+ }
474
+ }
475
+ function hookClassify(settingsDoc) {
395
476
  const rejections = [];
396
477
  const dropped = [];
397
478
  const reject = (code, path, field) => {
@@ -401,7 +482,7 @@ export function classifyHooksConfig(settingsDoc) {
401
482
  dropped.push(field === undefined ? { code, path } : { code, path, field });
402
483
  };
403
484
  let hooks;
404
- if (settingsDoc !== null && typeof settingsDoc === "object" && !Array.isArray(settingsDoc)) {
485
+ if (hookIsRecord(settingsDoc)) {
405
486
  const doc = settingsDoc;
406
487
  for (const key of Object.keys(doc)) {
407
488
  const value = doc[key];
@@ -412,7 +493,7 @@ export function classifyHooksConfig(settingsDoc) {
412
493
  // the document itself written as a matcher: its top-level `matcher` key is the evidence, reported at that key's place
413
494
  const sign = key === "matcher" && hookLooksLikeMatcher(doc)
414
495
  ? [key]
415
- : HOOK_GUARD_EVENTS.includes(key) && value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0)
496
+ : HOOK_GUARD_EVENTS.includes(key) && value !== null && value !== undefined && !(hookIsArray(value) && value.length === 0)
416
497
  ? [key]
417
498
  : hookGuardSignIn(value, [key], "other");
418
499
  if (sign !== undefined)
@@ -450,7 +531,7 @@ function hooksFace(carry, strip, take) {
450
531
  }
451
532
  /** The carrier for one entry of type `type`: the value itself when it says that type, else an untyped object (⇒ the issue names `type`). */
452
533
  function entryOfType(value, type) {
453
- return value !== null && typeof value === "object" && value.type === type ? value : {};
534
+ return isPlainJsonObject(value) && value.type === type ? value : {}; // 读不出 / 不是纯数据的值不当「自己说了这个 type」的载体:⇒ `{}`(issue 点名 `type`)
454
535
  }
455
536
  const entryFace = (type) => hooksFace((v) => ({ hooks: { Stop: [{ hooks: [entryOfType(v, type)] }] } }), 5, (h) => h.Stop[0].hooks[0]);
456
537
  export const CommandHookEntry = entryFace("command");
@@ -43,11 +43,15 @@ export interface HttpHookAllowlistLayerRead {
43
43
  readonly malformed: readonly HttpHookAllowlistKey[];
44
44
  }
45
45
  /**
46
- * 从一层的原始设置对象读出两键(唯一读法)。键缺席(或值为 `undefined`)⇒ 不设;字符串数组 ⇒ 原样;其余一切(非数组、含非串条目、`null`)
47
- * ⇒ **读成 `[]`**(CC 同向:坏形名单 = 一个都不许)并在 `malformed` 点名。整份设置不是对象 ⇒ 两键都缺。
46
+ * 从一层的原始设置对象读出两键(唯一读法)。键缺席(或值为 `undefined`)⇒ 不设;字符串数组 ⇒ 原样(过 `captureAllowlist`〔包内私有〕,与判官同一只读法);其余一切(非数组、含非串条目或洞、取值或读元素时抛、`null`)
47
+ * ⇒ **读成 `[]`**(CC 同向:坏形名单 = 一个都不许)并在 `malformed` 点名。整份设置不是对象(`null` / 原始值 / 数组)⇒ 两键都缺。
48
+ * **是对象但不朴素**(类实例 / `Map` / 建在别的对象原型上的记录 / 函数 —— JSON 解析不出来,只能是嵌入方程序拼的)⇒ 同样 fail-closed:两键都读成 `[]` 并点名 `malformed`。
49
+ * 把它当「没有设置」读,等于把一层带名单的托管设置静默丢掉、方向放行(与本模块「坏形 = 一个都不许」的失败方向相反)。
50
+ * 名单键**只靠继承可见**(不是自有键)⇒ 同样 fail-closed:该键读成 `[]` 并点名 `malformed`(自有键盖过继承键;自有键值为 `undefined` 仍是「不设」)。
51
+ * 原型是带成员的 null 原型对象的对象(`Object.create(<null 原型的名单袋>)`)是一例:它按座位的判据读成朴素、按**自有**键读,名单键只在原型上 ⇒ 坏形。它只能由程序造出,JSON 解析不出来(座位头注 `./plain-json-object` 同写)。
48
52
  */
49
53
  export declare function readHttpHookAllowlistLayer(source: SettingsSource, settings: unknown): HttpHookAllowlistLayerRead;
50
- /** 拦下一只 http hook 的原因。`rule`:`empty` = 有一张准入名单是空的(那一层改对之前一只都不许);`url` = 没命中那几张名单里的任何模式。
54
+ /** 拦下一只 http hook 的原因。`rule`:`empty` = 有一张准入名单是空的(那一层改对之前一只都不许;**名单形坏 —— 非串数组、含非串条目或洞、读它或读它的元素时 getter 抛 —— 也读成空**,所以 `empty` 不一定是写成了 `[]`);`url` = 没命中那几张名单里的任何模式。
51
55
  * `sources` = 不放行它的那几张名单所在的层(入参序)。零 URL 回显 —— URL 调用方自己有,按受众给显示形。 */
52
56
  export interface HttpHookUrlBlock {
53
57
  readonly list: "allowedHttpHookUrls";