skill-family-harness-node 0.8.3 → 0.9.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 CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ <!-- release-skill:changelog:start version=0.9.0 locale=en baseline=sha256:e5f4e3bb6ab343e4ccfb7dcdbba8b9ab4576b4cf285c30bb3597d577be5eb391 -->
4
+ ## [0.9.0] - 2026-08-24
5
+
6
+ Harness 0.9.0 adds stable identity-bound reads and fixed-set no-replace publication with a fixed four-platform native closure.
7
+
8
+ ### Added
9
+
10
+ - Adds createFilesystemRootBinding and readFileBound with handle-relative no-follow acquisition and optional byte digest guarding.
11
+ - Adds the stable fixed-set-publication subpath with native no-replace publication and terminal indeterminate receipts.
12
+ - Adds the candidate validate-many-by-schema-id mechanism to the existing Quickstart dispatcher.
13
+
14
+ ### Changed
15
+
16
+ - Keeps protected deletion excluded and preserves the existing 21-item capability registry.
17
+
18
+ ### Upgrade Notes
19
+
20
+ Pin Contracts, Harness, and Engineering Kit to exactly 0.9.0. Batch validation and the Quickstart Bundle remain candidate; filesystem binding and fixed-set publication are stable.
21
+ <!-- release-skill:changelog:end version=0.9.0 locale=en -->
22
+
23
+
24
+ <!-- release-skill:changelog:start version=0.8.4 locale=en baseline=sha256:dfb053d7719d3c7cb5b7d8dbe7a2349597696614651ef4ae04451c34cd7bbc02 -->
25
+ ## [0.8.4] - 2026-08-24
26
+
27
+ Lockstep Foundation 0.8.4 version update with no new Harness mechanism or public API.
28
+
29
+ ### Changed
30
+
31
+ - Moves the package version to 0.8.4 together with Contracts and Engineering Kit.
32
+ - Keeps all 21 Harness capabilities and public exports unchanged; source-authority receipt validation belongs to Contracts.
33
+
34
+ ### Upgrade Notes
35
+
36
+ Consumers must pin all three Foundation packages to exactly 0.8.4. No Harness API migration is required.
37
+ <!-- release-skill:changelog:end version=0.8.4 locale=en -->
38
+
39
+
3
40
  <!-- release-skill:changelog:start version=0.8.3 locale=en baseline=sha256:449943bedf3698888fec4dcb09a0460f337b9406d5258946a3d3ffcd23143b8d -->
4
41
  ## [0.8.3] - 2026-08-23
5
42
 
@@ -1,5 +1,42 @@
1
1
  # 变更日志
2
2
 
3
+ <!-- release-skill:changelog:start version=0.9.0 locale=zh-CN baseline=sha256:7f3ff503831b8fb52f20293a4af4003ff116b7f9d820a0c2da1bd34a0b261248 -->
4
+ ## [0.9.0] - 2026-08-24
5
+
6
+ Harness 0.9.0 新增稳定身份绑定读取与固定集合不替换发布,并携带固定四平台原生闭包。
7
+
8
+ ### 新增
9
+
10
+ - 新增 createFilesystemRootBinding 与 readFileBound,使用句柄相对不跟随符号链接获取,并支持可选字节摘要保护。
11
+ - 新增稳定 fixed-set-publication 子路径,使用原生不替换发布并保留终态 indeterminate 回执。
12
+ - 在既有 Quickstart dispatcher 中增加 validate-many-by-schema-id candidate 机制。
13
+
14
+ ### 变更
15
+
16
+ - 身份保护删除继续排除,既有 21 项能力登记保持不变。
17
+
18
+ ### 升级说明
19
+
20
+ 三个 Foundation 包必须精确锁定 0.9.0。批量校验与 Quickstart Bundle 仍为 candidate;文件系统绑定与固定集合发布为 stable。
21
+ <!-- release-skill:changelog:end version=0.9.0 locale=zh-CN -->
22
+
23
+
24
+ <!-- release-skill:changelog:start version=0.8.4 locale=zh-CN baseline=sha256:e8d7be87a26ef296ee4ae27e2325ac8ff1dc33fc69e5f4a65520656f9860bb91 -->
25
+ ## [0.8.4] - 2026-08-24
26
+
27
+ 随 Foundation 0.8.4 锁步升版;Harness 不新增机制或公共 API。
28
+
29
+ ### 变更
30
+
31
+ - 包版本与 Contracts、Engineering Kit 一同升至 0.8.4。
32
+ - 21 项 Harness 能力与公共导出保持不变;source-authority receipt 校验归 Contracts。
33
+
34
+ ### 升级说明
35
+
36
+ 消费者必须把三个 Foundation 包精确锁定到 0.8.4;Harness API 无需迁移。
37
+ <!-- release-skill:changelog:end version=0.8.4 locale=zh-CN -->
38
+
39
+
3
40
  <!-- release-skill:changelog:start version=0.8.3 locale=zh-CN baseline=sha256:b84695a286aca7c1a9f90844f8682cde07d32276cebc11583568bc5ef11753fc -->
4
41
  ## [0.8.3] - 2026-08-23
5
42
 
package/README.md CHANGED
@@ -4,28 +4,28 @@
4
4
 
5
5
  # skill-family-harness-node
6
6
 
7
- <!-- release-skill:release-version: 0.8.3 -->
7
+ <!-- release-skill:release-version: 0.9.0 -->
8
8
 
9
9
  The **single default Node implementation** of the Contracts mechanism protocol. This is a thin runtime: it only implements the mechanism protocol, introduces no business semantics, and does not provide a second-language implementation.
10
10
 
11
11
  <!-- release-skill:managed:start id=latest-release -->
12
- **0.8.3** (2026-08-23)
12
+ **0.9.0** (2026-08-24)
13
13
 
14
- Path containment now tolerates one exact anchor-removal race while preserving fail-closed escape checks.
14
+ Harness 0.9.0 adds stable identity-bound reads and fixed-set no-replace publication with a fixed four-platform native closure.
15
15
 
16
- **Changed**
16
+ **Added**
17
17
 
18
- - A second ENOENT and every non-ENOENT failure remain closed; symlink swaps, out-of-root targets, and all existing containment checks remain rejected.
19
- - Keeps the hook private to the paths module test surface and adds no public export, general retry policy, lock layer, ledger, or runner.
20
- - Moves the package version to 0.8.3 together with Contracts and Engineering Kit.
18
+ - Adds createFilesystemRootBinding and readFileBound with handle-relative no-follow acquisition and optional byte digest guarding.
19
+ - Adds the stable fixed-set-publication subpath with native no-replace publication and terminal indeterminate receipts.
20
+ - Adds the candidate validate-many-by-schema-id mechanism to the existing Quickstart dispatcher.
21
21
 
22
- **Fixed**
22
+ **Changed**
23
23
 
24
- - When realpath of the selected existing anchor fails with ENOENT because another process removed it, resolveContained recomputes the deepest existing ancestor exactly once.
24
+ - Keeps protected deletion excluded and preserves the existing 21-item capability registry.
25
25
 
26
26
  **Upgrade Notes**
27
27
 
28
- Consumers must pin all three Foundation packages to exactly 0.8.3. Concurrent lock acquisition can now survive the current owner removing the selected lock-file anchor; no Harness API migration is required.
28
+ Pin Contracts, Harness, and Engineering Kit to exactly 0.9.0. Batch validation and the Quickstart Bundle remain candidate; filesystem binding and fixed-set publication are stable.
29
29
  <!-- release-skill:managed:end id=latest-release -->
30
30
 
31
31
  ## Problem It Solves
@@ -39,14 +39,14 @@ The Harness consumes `skill-family-contracts` (a workspace dependency), reusing
39
39
  ## Installation and Minimal Example
40
40
 
41
41
  ```sh
42
- npm install skill-family-harness-node@0.8.3
42
+ npm install skill-family-harness-node@0.9.0
43
43
  npm info skill-family-harness-node --help
44
44
  ```
45
45
 
46
46
  The minimal example shows validating a contract document inside Node:
47
47
 
48
48
  ```js
49
- // Run from an empty directory: npm install skill-family-harness-node@0.8.3
49
+ // Run from an empty directory: npm install skill-family-harness-node@0.9.0
50
50
  import { validateContractDocument } from "skill-family-harness-node";
51
51
 
52
52
  const document = {
package/README.zh-CN.md CHANGED
@@ -5,28 +5,28 @@
5
5
 
6
6
  # skill-family-harness-node
7
7
 
8
- <!-- release-skill:release-version: 0.8.3 -->
8
+ <!-- release-skill:release-version: 0.9.0 -->
9
9
 
10
10
  Contracts 机制协议的**唯一默认 Node 实现**。这是一个薄运行时(thin runtime):只实现机制协议,不引入业务语义,不做第二语言实现。
11
11
 
12
12
  <!-- release-skill:managed:start id=latest-release -->
13
- **0.8.3** (2026-08-23)
13
+ **0.9.0** (2026-08-24)
14
14
 
15
- 路径收容现在可处理一次精确的锚点并发删除,同时保持越界检查失败关闭。
15
+ Harness 0.9.0 新增稳定身份绑定读取与固定集合不替换发布,并携带固定四平台原生闭包。
16
16
 
17
- **变更**
17
+ **新增**
18
18
 
19
- - 第二次 ENOENT 与所有非 ENOENT 错误仍失败关闭;符号链接替换、根目录外目标和既有收容检查仍全部拒绝。
20
- - 测试钩子只留在 paths 模块的测试表面,不新增公共导出、通用重试策略、锁层、账本或 runner。
21
- - 包版本与 Contracts、Engineering Kit 一同升至 0.8.3。
19
+ - 新增 createFilesystemRootBinding readFileBound,使用句柄相对不跟随符号链接获取,并支持可选字节摘要保护。
20
+ - 新增稳定 fixed-set-publication 子路径,使用原生不替换发布并保留终态 indeterminate 回执。
21
+ - 在既有 Quickstart dispatcher 中增加 validate-many-by-schema-id candidate 机制。
22
22
 
23
- **修复**
23
+ **变更**
24
24
 
25
- - 当另一进程删除已选锚点,导致该锚点的 realpath 返回 ENOENT 时,resolveContained 仅重新计算一次最深既存祖先。
25
+ - 身份保护删除继续排除,既有 21 项能力登记保持不变。
26
26
 
27
27
  **升级说明**
28
28
 
29
- 消费者必须把三个 Foundation 包精确锁定到 0.8.3。并发获取锁时,即使当前持有者删除已选锁文件锚点,也能完成一次安全重求;Harness API 无需迁移。
29
+ 三个 Foundation 包必须精确锁定 0.9.0。批量校验与 Quickstart Bundle 仍为 candidate;文件系统绑定与固定集合发布为 stable。
30
30
  <!-- release-skill:managed:end id=latest-release -->
31
31
 
32
32
  ## 解决的问题
@@ -40,14 +40,14 @@ Harness 消费 `skill-family-contracts`(工作区依赖),复用其方言
40
40
  ## 安装和最小示例
41
41
 
42
42
  ```sh
43
- npm install skill-family-harness-node@0.8.3
43
+ npm install skill-family-harness-node@0.9.0
44
44
  npm info skill-family-harness-node --help
45
45
  ```
46
46
 
47
47
  最小示例演示在 Node 内校验一份契约文档:
48
48
 
49
49
  ```js
50
- // 从空目录运行:npm install skill-family-harness-node@0.8.3
50
+ // 从空目录运行:npm install skill-family-harness-node@0.9.0
51
51
  import { validateContractDocument } from "skill-family-harness-node";
52
52
 
53
53
  const document = {
@@ -22,6 +22,8 @@ function errorResponse(cause) {
22
22
  error: {
23
23
  name: typeof cause?.name === "string" ? cause.name : "Error",
24
24
  message: cause?.message ?? String(cause),
25
+ ...(typeof cause?.code === "string" ? { code: cause.code } : {}),
26
+ ...(cause?.details !== undefined ? { details: cause.details } : {}),
25
27
  },
26
28
  };
27
29
  }
@@ -1,4 +1,4 @@
1
- import { canonicalJson, digestDocument, isRegisteredErrorCode } from "skill-family-contracts";
1
+ import { canonicalJson, ContractsError, digestDocument, isRegisteredErrorCode } from "skill-family-contracts";
2
2
  import {
3
3
  QUICKSTART_PROTOCOL,
4
4
  findNonJsonValue,
@@ -452,6 +452,7 @@ export async function verifyHarnessSurfaceInventory({ root, inventory, detectors
452
452
 
453
453
  const FOUNDATION_MECHANISM_OPERATIONS = Object.freeze([
454
454
  "validate-by-schema-id",
455
+ "validate-many-by-schema-id",
455
456
  "canonical-json",
456
457
  "digest-document",
457
458
  "resource-closure",
@@ -518,7 +519,48 @@ async function invokeReadFileStrict(params) {
518
519
  * several Foundation modules directly. validateBySchemaId is injected only
519
520
  * by the trusted offline Bundle; callers cannot name arbitrary functions.
520
521
  */
521
- export async function invokeFoundationMechanism(request, { validateBySchemaId } = {}) {
522
+ function assertBatchRequest(request) {
523
+ if (Object.keys(request).sort().join(",") !== "operation,params" ||
524
+ request.operation !== "validate-many-by-schema-id" ||
525
+ request.params === null || typeof request.params !== "object" || Array.isArray(request.params) ||
526
+ Object.keys(request.params).sort().join(",") !== "requests" ||
527
+ !Array.isArray(request.params.requests) || request.params.requests.length === 0) {
528
+ throw new TypeError("invokeFoundationMechanism: validate-many-by-schema-id request is malformed");
529
+ }
530
+ for (const item of request.params.requests) {
531
+ if (item === null || typeof item !== "object" || Array.isArray(item) ||
532
+ Object.keys(item).sort().join(",") !== "document,schemaId" ||
533
+ typeof item.schemaId !== "string" || item.schemaId.length === 0) {
534
+ throw new TypeError("invokeFoundationMechanism: validate-many-by-schema-id item is malformed");
535
+ }
536
+ }
537
+ }
538
+
539
+ function invokeValidationBatch(request, { validateBySchemaId, listValidatableSchemaIds } = {}) {
540
+ assertBatchRequest(request);
541
+ if (typeof validateBySchemaId !== "function" || typeof listValidatableSchemaIds !== "function") {
542
+ throw new TypeError("invokeFoundationMechanism: validate-many-by-schema-id requires the trusted Bundle validator index");
543
+ }
544
+ const schemaIds = listValidatableSchemaIds();
545
+ if (!Array.isArray(schemaIds) || schemaIds.some((schemaId) => typeof schemaId !== "string")) {
546
+ throw new TypeError("invokeFoundationMechanism: trusted Bundle validator index is unavailable");
547
+ }
548
+ const known = new Set(schemaIds);
549
+ for (const item of request.params.requests) {
550
+ if (!known.has(item.schemaId)) {
551
+ throw new ContractsError("SFC1002", `unknown schema $id: ${item.schemaId}`, { schemaId: item.schemaId });
552
+ }
553
+ }
554
+ return {
555
+ results: request.params.requests.map((item, inputIndex) => ({
556
+ inputIndex,
557
+ schemaId: item.schemaId,
558
+ result: validateBySchemaId(item.schemaId, item.document),
559
+ })),
560
+ };
561
+ }
562
+
563
+ export async function invokeFoundationMechanism(request, { validateBySchemaId, listValidatableSchemaIds } = {}) {
522
564
  if (request === null || typeof request !== "object" || Array.isArray(request)) {
523
565
  throw new TypeError("invokeFoundationMechanism: request must be an object");
524
566
  }
@@ -530,6 +572,9 @@ export async function invokeFoundationMechanism(request, { validateBySchemaId }
530
572
  if (params === null || typeof params !== "object" || Array.isArray(params)) {
531
573
  throw new TypeError("invokeFoundationMechanism: params must be an object");
532
574
  }
575
+ if (operation === "validate-many-by-schema-id") {
576
+ return invokeValidationBatch(request, { validateBySchemaId, listValidatableSchemaIds });
577
+ }
533
578
  if (operation === "validate-by-schema-id") {
534
579
  if (typeof validateBySchemaId !== "function") {
535
580
  throw new TypeError("invokeFoundationMechanism: validate-by-schema-id requires the trusted Bundle validator");
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "dependencies": {
11
11
  "@pnpm/lockfile.fs": "1001.1.35",
12
12
  "ipaddr.js": "2.5.0",
13
- "skill-family-contracts": "0.8.3",
13
+ "skill-family-contracts": "0.9.0",
14
14
  "yaml": "2.9.0"
15
15
  },
16
16
  "description": "Thin Node.js mechanism runtime for Skill Family engineering contracts.",
@@ -21,7 +21,8 @@
21
21
  "exports": {
22
22
  ".": "./src/index.mjs",
23
23
  "./candidate/quickstart-profile": "./candidate/quickstart-profile.mjs",
24
- "./candidate/rename-directory-no-replace": "./candidate/rename-directory-no-replace/rename-directory-no-replace.mjs"
24
+ "./candidate/rename-directory-no-replace": "./candidate/rename-directory-no-replace/rename-directory-no-replace.mjs",
25
+ "./fixed-set-publication": "./src/fixed-set-publication.mjs"
25
26
  },
26
27
  "files": [
27
28
  "src",
@@ -45,7 +46,7 @@
45
46
  "url": "https://github.com/ifoohoo/skill-family-harness-node.git"
46
47
  },
47
48
  "type": "module",
48
- "version": "0.8.3",
49
+ "version": "0.9.0",
49
50
  "scripts": {
50
51
  "check": "node --test",
51
52
  "test": "node --test"
@@ -0,0 +1,17 @@
1
+ version: 0.8.4
2
+ date: 2026-08-24
3
+ locales:
4
+ en:
5
+ summary: Lockstep Foundation 0.8.4 version update with no new Harness mechanism or public API.
6
+ changes:
7
+ changed:
8
+ - Moves the package version to 0.8.4 together with Contracts and Engineering Kit.
9
+ - Keeps all 21 Harness capabilities and public exports unchanged; source-authority receipt validation belongs to Contracts.
10
+ upgradeNotes: Consumers must pin all three Foundation packages to exactly 0.8.4. No Harness API migration is required.
11
+ zh-CN:
12
+ summary: 随 Foundation 0.8.4 锁步升版;Harness 不新增机制或公共 API。
13
+ changes:
14
+ changed:
15
+ - 包版本与 Contracts、Engineering Kit 一同升至 0.8.4。
16
+ - 21 项 Harness 能力与公共导出保持不变;source-authority receipt 校验归 Contracts。
17
+ upgradeNotes: 消费者必须把三个 Foundation 包精确锁定到 0.8.4;Harness API 无需迁移。
@@ -0,0 +1,23 @@
1
+ version: 0.9.0
2
+ date: 2026-08-24
3
+ locales:
4
+ en:
5
+ summary: Harness 0.9.0 adds stable identity-bound reads and fixed-set no-replace publication with a fixed four-platform native closure.
6
+ changes:
7
+ added:
8
+ - Adds createFilesystemRootBinding and readFileBound with handle-relative no-follow acquisition and optional byte digest guarding.
9
+ - Adds the stable fixed-set-publication subpath with native no-replace publication and terminal indeterminate receipts.
10
+ - Adds the candidate validate-many-by-schema-id mechanism to the existing Quickstart dispatcher.
11
+ changed:
12
+ - Keeps protected deletion excluded and preserves the existing 21-item capability registry.
13
+ upgradeNotes: Pin Contracts, Harness, and Engineering Kit to exactly 0.9.0. Batch validation and the Quickstart Bundle remain candidate; filesystem binding and fixed-set publication are stable.
14
+ zh-CN:
15
+ summary: Harness 0.9.0 新增稳定身份绑定读取与固定集合不替换发布,并携带固定四平台原生闭包。
16
+ changes:
17
+ added:
18
+ - 新增 createFilesystemRootBinding 与 readFileBound,使用句柄相对不跟随符号链接获取,并支持可选字节摘要保护。
19
+ - 新增稳定 fixed-set-publication 子路径,使用原生不替换发布并保留终态 indeterminate 回执。
20
+ - 在既有 Quickstart dispatcher 中增加 validate-many-by-schema-id candidate 机制。
21
+ changed:
22
+ - 身份保护删除继续排除,既有 21 项能力登记保持不变。
23
+ upgradeNotes: 三个 Foundation 包必须精确锁定 0.9.0。批量校验与 Quickstart Bundle 仍为 candidate;文件系统绑定与固定集合发布为 stable。
@@ -0,0 +1,241 @@
1
+ import { constants as FS_CONSTANTS } from "node:fs";
2
+ import { lstat, open, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { digestDocument } from "skill-family-contracts";
6
+ import { digestBytes } from "./closure.mjs";
7
+ import { HARNESS_ERROR_KINDS, HarnessError, mechanismError } from "./errors.mjs";
8
+ import { loadNativeBoundReadAddon } from "./native/loader.mjs";
9
+
10
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
11
+ const ROOT_BINDING_KIND = "trusted-filesystem-root-binding";
12
+ const ROOT_BINDING_BASIS = "canonical-realpath-device-inode-type-mode-v1";
13
+
14
+ function supportedPlatform() {
15
+ if (process.platform === "darwin" && ["arm64", "x64"].includes(process.arch)) {
16
+ return true;
17
+ }
18
+ if (process.platform === "linux" && ["arm64", "x64"].includes(process.arch) &&
19
+ typeof process.report?.getReport?.()?.header?.glibcVersionRuntime === "string") {
20
+ return true;
21
+ }
22
+ return false;
23
+ }
24
+
25
+ function assertSupportedPlatform() {
26
+ if (!supportedPlatform()) {
27
+ throw mechanismError(
28
+ HARNESS_ERROR_KINDS.UNSUPPORTED_PLATFORM,
29
+ "bound filesystem reads are unsupported on this runtime",
30
+ );
31
+ }
32
+ }
33
+
34
+ function assertCanonicalAbsolute(value, label) {
35
+ if (typeof value !== "string" || !path.isAbsolute(value) ||
36
+ value.includes("\\") || value.includes("\0") || path.normalize(value) !== value) {
37
+ throw new TypeError(`${label} must be a normalized absolute path`);
38
+ }
39
+ }
40
+
41
+ function assertRootBindingShape(binding) {
42
+ if (binding === null || typeof binding !== "object" || Array.isArray(binding) ||
43
+ Object.keys(binding).sort().join(",") !== "basis,digest,digestAlgorithm,kind" ||
44
+ binding.kind !== ROOT_BINDING_KIND ||
45
+ binding.digestAlgorithm !== "sha256" ||
46
+ binding.basis !== ROOT_BINDING_BASIS ||
47
+ typeof binding.digest !== "string" || !SHA256_PATTERN.test(binding.digest)) {
48
+ throw new TypeError("rootBinding is not a filesystem-root-binding value");
49
+ }
50
+ }
51
+
52
+ function identityFromStats(stats) {
53
+ return {
54
+ type: "directory",
55
+ mode: Number(stats.mode & 0o777n),
56
+ device: String(stats.dev),
57
+ inode: String(stats.ino),
58
+ };
59
+ }
60
+
61
+ function makeBinding(canonicalPath, identity) {
62
+ return Object.freeze({
63
+ kind: ROOT_BINDING_KIND,
64
+ digestAlgorithm: "sha256",
65
+ basis: ROOT_BINDING_BASIS,
66
+ digest: digestDocument({
67
+ canonicalRealpath: canonicalPath,
68
+ device: identity.device,
69
+ inode: identity.inode,
70
+ mode: identity.mode,
71
+ type: identity.type,
72
+ }),
73
+ });
74
+ }
75
+
76
+ function openFlags(directory) {
77
+ const noFollow = FS_CONSTANTS.O_NOFOLLOW;
78
+ if (noFollow === undefined || FS_CONSTANTS.O_DIRECTORY === undefined) {
79
+ throw mechanismError(
80
+ HARNESS_ERROR_KINDS.UNSUPPORTED_PLATFORM,
81
+ "bound filesystem reads require O_NOFOLLOW and O_DIRECTORY",
82
+ );
83
+ }
84
+ return FS_CONSTANTS.O_RDONLY | noFollow | (directory ? FS_CONSTANTS.O_DIRECTORY : 0) |
85
+ (FS_CONSTANTS.O_CLOEXEC ?? 0);
86
+ }
87
+
88
+ function mapOpenFailure(cause, relPath, label) {
89
+ if (cause instanceof HarnessError) throw cause;
90
+ if (cause?.code === "ELOOP") {
91
+ throw mechanismError(
92
+ HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY,
93
+ `${label} became a symbolic link during bound read`,
94
+ { input: relPath },
95
+ );
96
+ }
97
+ if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") {
98
+ throw mechanismError(
99
+ HARNESS_ERROR_KINDS.MISSING_RESOURCE,
100
+ `${label} does not exist during bound read`,
101
+ { input: relPath },
102
+ );
103
+ }
104
+ throw mechanismError(
105
+ HARNESS_ERROR_KINDS.READ_FAILED,
106
+ `${label} could not be opened: ${cause?.code ?? "unknown"}`,
107
+ { input: relPath },
108
+ );
109
+ }
110
+
111
+ async function captureRoot(root) {
112
+ assertCanonicalAbsolute(root, "root");
113
+ let before;
114
+ try {
115
+ before = await lstat(root, { bigint: true });
116
+ } catch (cause) {
117
+ throw mechanismError(HARNESS_ERROR_KINDS.INVALID_ROOT, "root cannot be inspected", {
118
+ code: cause?.code,
119
+ });
120
+ }
121
+ if (before.isSymbolicLink() || !before.isDirectory()) {
122
+ throw mechanismError(HARNESS_ERROR_KINDS.INVALID_ROOT, "root must be one real directory");
123
+ }
124
+ let canonical;
125
+ try {
126
+ canonical = await realpath(root);
127
+ } catch (cause) {
128
+ throw mechanismError(HARNESS_ERROR_KINDS.INVALID_ROOT, "root canonical path cannot be resolved", {
129
+ code: cause?.code,
130
+ });
131
+ }
132
+ if (canonical !== root) {
133
+ throw mechanismError(HARNESS_ERROR_KINDS.INVALID_ROOT, "root must already be its canonical realpath");
134
+ }
135
+ let handle;
136
+ try {
137
+ handle = await open(canonical, openFlags(true));
138
+ } catch (cause) {
139
+ mapOpenFailure(cause, "", "root");
140
+ }
141
+ try {
142
+ const opened = await handle.stat({ bigint: true });
143
+ if (!opened.isDirectory() || opened.dev !== before.dev || opened.ino !== before.ino ||
144
+ opened.mode !== before.mode) {
145
+ throw mechanismError(HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY, "root identity changed during capture");
146
+ }
147
+ return { canonical, identity: identityFromStats(opened), handle };
148
+ } catch (cause) {
149
+ await handle.close();
150
+ throw cause;
151
+ }
152
+ }
153
+
154
+ export async function createFilesystemRootBinding(root) {
155
+ assertSupportedPlatform();
156
+ const captured = await captureRoot(root);
157
+ try {
158
+ return makeBinding(captured.canonical, captured.identity);
159
+ } finally {
160
+ await captured.handle.close();
161
+ }
162
+ }
163
+
164
+ function assertRelativePath(relPath) {
165
+ if (typeof relPath !== "string" || relPath.length === 0 || relPath.includes("\0") ||
166
+ relPath.includes("\\") || path.posix.isAbsolute(relPath)) {
167
+ throw mechanismError(HARNESS_ERROR_KINDS.INVALID_PATH, "bound read path is not a contained relative path");
168
+ }
169
+ const segments = relPath.split("/");
170
+ if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
171
+ throw mechanismError(HARNESS_ERROR_KINDS.PATH_TRAVERSAL, "bound read path contains an unsafe segment");
172
+ }
173
+ if (segments.some((segment) => /^[A-Za-z]:/u.test(segment))) {
174
+ throw mechanismError(HARNESS_ERROR_KINDS.WINDOWS_DRIVE_PATH, "bound read path contains a drive path");
175
+ }
176
+ return segments;
177
+ }
178
+
179
+ export async function readFileBound(root, relPath, { rootBinding, encoding, expectedSha256 } = {}) {
180
+ assertSupportedPlatform();
181
+ assertCanonicalAbsolute(root, "root");
182
+ assertRelativePath(relPath);
183
+ assertRootBindingShape(rootBinding);
184
+ if (encoding !== undefined && encoding !== "utf8") {
185
+ throw new TypeError('readFileBound: encoding must be "utf8" or undefined');
186
+ }
187
+ if (expectedSha256 !== undefined &&
188
+ (typeof expectedSha256 !== "string" || !SHA256_PATTERN.test(expectedSha256))) {
189
+ throw new TypeError("readFileBound: expectedSha256 must be a lowercase sha256 hex digest");
190
+ }
191
+
192
+ const segments = relPath.split("/");
193
+ const captured = await captureRoot(root);
194
+ try {
195
+ const currentBinding = makeBinding(captured.canonical, captured.identity);
196
+ if (currentBinding.digest !== rootBinding.digest) {
197
+ throw mechanismError(
198
+ HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY,
199
+ "approved root binding does not match the current root",
200
+ );
201
+ }
202
+ const { addon } = await loadNativeBoundReadAddon();
203
+ const native = addon.readFileBoundNative(captured.canonical, segments);
204
+ if (!native?.ok) {
205
+ const cause = {
206
+ code: native?.errorCode === (process.platform === "darwin" ? 62 : 40) ? "ELOOP" : native?.errorCode === 2 ? "ENOENT" :
207
+ native?.errorCode === 20 ? "ENOTDIR" : `errno-${native?.errorCode ?? "unknown"}`,
208
+ };
209
+ if (cause.code === "ENOTDIR" && segments.length > 1) {
210
+ throw mechanismError(HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY, "an intermediate component is not a real directory", {
211
+ input: relPath,
212
+ });
213
+ }
214
+ mapOpenFailure(cause, relPath, segments.length > 1 ? "bound path" : "leaf");
215
+ }
216
+ if (native.rootDevice !== captured.identity.device || native.rootInode !== captured.identity.inode ||
217
+ Number(native.rootMode) !== captured.identity.mode) {
218
+ throw mechanismError(HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY, "root identity changed during bound read", {
219
+ input: relPath,
220
+ });
221
+ }
222
+ const bytes = Buffer.from(native.bytes);
223
+ const sha256 = digestBytes(bytes);
224
+ if (expectedSha256 !== undefined && sha256 !== expectedSha256) {
225
+ throw mechanismError(
226
+ HARNESS_ERROR_KINDS.CONTENT_GUARD_REJECTED,
227
+ "bound read digest differs from the expected frozen digest",
228
+ { input: relPath, expectedSha256, actualSha256: sha256 },
229
+ );
230
+ }
231
+ return Object.freeze({
232
+ path: path.join(captured.canonical, ...segments),
233
+ content: encoding === "utf8" ? bytes.toString("utf8") : bytes,
234
+ sha256,
235
+ bytes: bytes.length,
236
+ mode: Number(native.leafMode),
237
+ });
238
+ } finally {
239
+ await captured.handle.close().catch(() => {});
240
+ }
241
+ }
package/src/errors.mjs CHANGED
@@ -42,6 +42,7 @@ export const HARNESS_ERROR_KINDS = Object.freeze({
42
42
  WORKSPACE_DISPOSED: "workspace-disposed",
43
43
  CLOSURE_CONFLICT: "closure-conflict",
44
44
  UNSUPPORTED_POLICY: "unsupported-policy",
45
+ UNSUPPORTED_PLATFORM: "unsupported-platform",
45
46
  EXECUTION_FAILED: "execution-failed",
46
47
  INVALID_RESULT: "invalid-result",
47
48
  STORE_CLOSED: "store-closed",
@@ -0,0 +1,5 @@
1
+ import { loadNativeBoundReadAddon } from "./native/loader.mjs";
2
+
3
+ export async function loadNativeAddon() {
4
+ return loadNativeBoundReadAddon();
5
+ }