skill-family-harness-node 0.4.0 → 0.5.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,29 @@
1
1
  # Changelog
2
2
 
3
+ <!-- release-skill:changelog:start version=0.5.0 locale=en baseline=sha256:03eca87a8c9bea814e741518168696f17c869bdb71baf2046cef4c3596d9ebf3 -->
4
+ ## [0.5.0] - 2026-08-16
5
+
6
+ This release adds the declared read surface assertion (FND-ADR-010) and the structured surface scanner (FND-ADR-011) to the stable Harness surface, growing HARNESS_CAPABILITIES from 16 to 18 entries, and adds three dependency-reviewed runtime libraries.
7
+
8
+ ### Added
9
+
10
+ - Adds assertDeclaredReadSurface (FND-ADR-010): a no-execution, syntax-surface-only assertion that every node:fs named import inside a declared module set stays inside the consumer-declared read surface, with a closed violation vocabulary and a frozen declared-read-surface-result envelope.
11
+ - Adds scanSurfaceStructured (FND-ADR-011): the structured sibling of scanSurface. IP-shaped candidates enter a single standard parse entry (ipaddr.js) with consumer-declared CIDR approval and fail closed when unparseable; scoped and unscoped coordinates, registries and hosts need consumer-declared approval; format adapters (pnpm-lockfile via @pnpm/lockfile.fs with yaml AST comment regions, and tree-json) parse structurally with no position-level exemptions; binary and symlink policies fail closed. The closed nine-rule vocabulary travels in details.rule of the structured-scan-violation mechanism error.
12
+ - Adds three dependency-reviewed runtime libraries for the structured scanner, reviewed per FND-ADR-006 section-4 analogy (dependency-closure pre-review, executor self-review pending independent review): ipaddr.js 2.5.0 (MIT, zero dependencies), yaml 2.9.0 (ISC), and @pnpm/lockfile.fs 1001.1.35 (MIT, 18 transitive dependencies all inside the approved public coordinates).
13
+ - Grows HARNESS_CAPABILITIES from 16 to 18 entries for the two new mechanisms.
14
+
15
+ ### Changed
16
+
17
+ - Documents the relationship of the two policy documents to workspace-private leak policies: a workspace-private leak-policy.json instance document is not a subset, not isomorphic and not a migration target of the surface-scan-policy or structured-scan-policy schemas — the documents share rule vocabulary and fail-closed semantics by design, but their byte-level shapes are independent and must not be compared for compatibility. scanSurface is the execution-core generalization projection: the public, consumer-parameterized form of the same mechanism family, without any private identity, path, or approval-list interpretation of its own.
18
+ - Records the cost of the dependency-review decision honestly: the harness tarball grows with the @pnpm/lockfile.fs closure, the thin-runtime property changes from zero third-party runtime dependencies to three reviewed ones, and the pnpm-lockfile adapter writes one temporary lockfile copy under the OS temp directory (removed afterwards). The engineering-kit offline-consumer verification gates now derive the complete third-party production closure of the three Foundation packages mechanically (identity-deduplicated, npm: alias-aware, range-scoped override selectors) instead of a single-package closure, so the review decision is continuously verified against the real installed bytes.
19
+ - Keeps mechanism purity: no execution of scanned files, no model calls, no network; symlinked entries are never followed.
20
+
21
+ ### Upgrade Notes
22
+
23
+ Version 0.5.0 is the FND-ADR-010/011 harness line. Mechanism imports use the stable capability names published in HARNESS_CAPABILITIES; structured-scan policies must pass the structured-scan-policy contract validation of contract-spec 1.5.0.
24
+ <!-- release-skill:changelog:end version=0.5.0 locale=en -->
25
+
26
+
3
27
  <!-- release-skill:changelog:start version=0.4.0 locale=en baseline=sha256:72d3fe048ced8eb44a84fabe6f1fdd2eb0c938b4f6bb98740474f72aec04d873 -->
4
28
  ## [0.4.0] - 2026-08-16
5
29
 
@@ -1,5 +1,29 @@
1
1
  # 变更日志
2
2
 
3
+ <!-- release-skill:changelog:start version=0.5.0 locale=zh-CN baseline=sha256:003472359596147fe26ad2ba53a82303a217c28cb89a6afacdaa955793001f71 -->
4
+ ## [0.5.0] - 2026-08-16
5
+
6
+ 本版在稳定 Harness 面上新增声明读取表面断言(FND-ADR-010)与结构化表面扫描器(FND-ADR-011),HARNESS_CAPABILITIES 从 16 项增至 18 项,并引入三个经依赖评审的运行时库。
7
+
8
+ ### 新增
9
+
10
+ - 新增 assertDeclaredReadSurface(FND-ADR-010):不执行、仅语法面的断言,声明模块集内的每个 node:fs 具名导入必须落在消费者声明的读取表面内,违规词汇表为闭集,返回冻结的 declared-read-surface-result 信封。
11
+ - 新增 scanSurfaceStructured(FND-ADR-011):scanSurface 的结构化兄弟。IP 形候选统一进入单一标准解析入口(ipaddr.js),按消费者声明的 CIDR 批准,不可解析即失败关闭;坐标(scoped 与非 scoped)、注册表与主机均需消费者声明批准;格式适配器(pnpm-lockfile 经 @pnpm/lockfile.fs + yaml AST 注释区域,tree-json 经 JSON.parse)结构化解析且无位置级豁免;二进制与符号链接策略失败关闭。闭集九规则词汇表经 structured-scan-violation 机制错误的 details.rule 承载。
12
+ - 为结构化扫描器引入三个经依赖评审的运行时库,评审按 FND-ADR-006 第 4 节类比执行(依赖闭包预审,执行者自审,独立复核另行安排):ipaddr.js 2.5.0(MIT,零依赖)、yaml 2.9.0(ISC)、@pnpm/lockfile.fs 1001.1.35(MIT,18 个传递依赖全部在批准公共坐标内)。
13
+ - HARNESS_CAPABILITIES 从 16 项增至 18 项,覆盖两个新机制。
14
+
15
+ ### 变更
16
+
17
+ - 显式说明两个策略文档与工作区私有 leak 策略的关系:工作区私有的 leak-policy.json 实例文档既不是 surface-scan-policy 或 structured-scan-policy schema 的子集、也不同构、更不是迁移目标——两类文档按设计共享规则词汇与失败关闭语义,但字节级形状相互独立,不得比较兼容性。scanSurface 是执行内核通用化投影:同一机制族的公开、消费者参数化形态,自身不解释任何私有身份、路径或批准清单。
18
+ - 如实记录依赖评审决策的成本:harness tarball 随 @pnpm/lockfile.fs 闭包扩大;薄运行时属性从零第三方运行时依赖变为三个经评审依赖;pnpm-lockfile 适配器会在 OS 临时目录写一份临时锁文件副本(用后即删)。engineering-kit 的离线消费者验证门随之把第三方闭包推导从单包闭包机械扩展为三个 Foundation 包的完整生产闭包(真实身份去重、npm: 别名感知、range-scoped override selector),使评审决策持续对着真实安装字节被验证。
19
+ - 保持机制纯度:不执行被扫描文件、无模型调用、无网络;永不跟随符号链接。
20
+
21
+ ### 升级说明
22
+
23
+ 0.5.0 是 FND-ADR-010/011 harness 线。机制导入使用 HARNESS_CAPABILITIES 公布的稳定能力名;structured-scan 策略必须通过契约规格 1.5.0 的 structured-scan-policy 契约校验。
24
+ <!-- release-skill:changelog:end version=0.5.0 locale=zh-CN -->
25
+
26
+
3
27
  <!-- release-skill:changelog:start version=0.4.0 locale=zh-CN baseline=sha256:dafaa1100bf6163a28d53fc982a30e8325d8bd3cab43bbb6daf2e6002befa2c1 -->
4
28
  ## [0.4.0] - 2026-08-16
5
29
 
package/README.md CHANGED
@@ -4,30 +4,31 @@
4
4
 
5
5
  # skill-family-harness-node
6
6
 
7
- <!-- release-skill:release-version: 0.4.0 -->
7
+ <!-- release-skill:release-version: 0.5.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.4.0** (2026-08-16)
12
+ **0.5.0** (2026-08-16)
13
13
 
14
- This release adds five finite-closed-semantics mechanisms and a token-lock primitive to the stable Harness surface (FND-ADR-009), grows HARNESS_CAPABILITIES from 9 to 16 entries, and ships the rename-directory-no-replace native-prebuild candidate.
14
+ This release adds the declared read surface assertion (FND-ADR-010) and the structured surface scanner (FND-ADR-011) to the stable Harness surface, growing HARNESS_CAPABILITIES from 16 to 18 entries, and adds three dependency-reviewed runtime libraries.
15
15
 
16
16
  **Added**
17
17
 
18
- - Adds five finite-closed-semantics mechanisms (FND-ADR-009), namely frozen-baseline materialization with contentGuard, the generic read chokepoint, strategy-driven surface scanning, deterministic token upper-bound estimation, and the generic upper-bound guard.
19
- - Adds the token-lock primitive for exclusive token-file publication and digest verification.
20
- - Grows HARNESS_CAPABILITIES from 9 to 16 entries for the new mechanisms.
21
- - Adds the rename-directory-no-replace candidate, a native-prebuild addon proving no-replace directory renames on darwin-arm64, darwin-x64, linux-arm64-gnu, and linux-x64-gnu, with a release receipt and SBOM.
18
+ - Adds assertDeclaredReadSurface (FND-ADR-010): a no-execution, syntax-surface-only assertion that every node:fs named import inside a declared module set stays inside the consumer-declared read surface, with a closed violation vocabulary and a frozen declared-read-surface-result envelope.
19
+ - Adds scanSurfaceStructured (FND-ADR-011): the structured sibling of scanSurface. IP-shaped candidates enter a single standard parse entry (ipaddr.js) with consumer-declared CIDR approval and fail closed when unparseable; scoped and unscoped coordinates, registries and hosts need consumer-declared approval; format adapters (pnpm-lockfile via @pnpm/lockfile.fs with yaml AST comment regions, and tree-json) parse structurally with no position-level exemptions; binary and symlink policies fail closed. The closed nine-rule vocabulary travels in details.rule of the structured-scan-violation mechanism error.
20
+ - Adds three dependency-reviewed runtime libraries for the structured scanner, reviewed per FND-ADR-006 section-4 analogy (dependency-closure pre-review, executor self-review pending independent review): ipaddr.js 2.5.0 (MIT, zero dependencies), yaml 2.9.0 (ISC), and @pnpm/lockfile.fs 1001.1.35 (MIT, 18 transitive dependencies all inside the approved public coordinates).
21
+ - Grows HARNESS_CAPABILITIES from 16 to 18 entries for the two new mechanisms.
22
22
 
23
23
  **Changed**
24
24
 
25
- - Keeps the Quickstart Profile v2 Task/Result exchange verification delivered in 0.3.0, including real-byte recomputation for path-backed outputs and evidence, duplicate Resource id rejection, and exact evidence binding verification.
26
- - Leaves method selection, retry policy, and domain result interpretation to the consumer.
25
+ - Documents the relationship of the two policy documents to workspace-private leak policies: a workspace-private leak-policy.json instance document is not a subset, not isomorphic and not a migration target of the surface-scan-policy or structured-scan-policy schemas — the documents share rule vocabulary and fail-closed semantics by design, but their byte-level shapes are independent and must not be compared for compatibility. scanSurface is the execution-core generalization projection: the public, consumer-parameterized form of the same mechanism family, without any private identity, path, or approval-list interpretation of its own.
26
+ - Records the cost of the dependency-review decision honestly: the harness tarball grows with the @pnpm/lockfile.fs closure, the thin-runtime property changes from zero third-party runtime dependencies to three reviewed ones, and the pnpm-lockfile adapter writes one temporary lockfile copy under the OS temp directory (removed afterwards). The engineering-kit offline-consumer verification gates now derive the complete third-party production closure of the three Foundation packages mechanically (identity-deduplicated, npm: alias-aware, range-scoped override selectors) instead of a single-package closure, so the review decision is continuously verified against the real installed bytes.
27
+ - Keeps mechanism purity: no execution of scanned files, no model calls, no network; symlinked entries are never followed.
27
28
 
28
29
  **Upgrade Notes**
29
30
 
30
- Version 0.4.0 is released on npm and the public mirror. Pin the candidate subpath to exactly 0.4.0; mechanism imports use the stable capability names published in HARNESS_CAPABILITIES.
31
+ Version 0.5.0 is the FND-ADR-010/011 harness line. Mechanism imports use the stable capability names published in HARNESS_CAPABILITIES; structured-scan policies must pass the structured-scan-policy contract validation of contract-spec 1.5.0.
31
32
  <!-- release-skill:managed:end id=latest-release -->
32
33
 
33
34
  ## Problem It Solves
@@ -41,14 +42,14 @@ The Harness consumes `skill-family-contracts` (a workspace dependency), reusing
41
42
  ## Installation and Minimal Example
42
43
 
43
44
  ```sh
44
- npm install skill-family-harness-node@0.4.0
45
+ npm install skill-family-harness-node@0.5.0
45
46
  npm info skill-family-harness-node --help
46
47
  ```
47
48
 
48
49
  The minimal example shows validating a contract document inside Node:
49
50
 
50
51
  ```js
51
- // Run from an empty directory: npm install skill-family-harness-node@0.4.0
52
+ // Run from an empty directory: npm install skill-family-harness-node@0.5.0
52
53
  import { validateContractDocument } from "skill-family-harness-node";
53
54
 
54
55
  const document = {
package/README.zh-CN.md CHANGED
@@ -5,30 +5,31 @@
5
5
 
6
6
  # skill-family-harness-node
7
7
 
8
- <!-- release-skill:release-version: 0.4.0 -->
8
+ <!-- release-skill:release-version: 0.5.0 -->
9
9
 
10
10
  Contracts 机制协议的**唯一默认 Node 实现**。这是一个薄运行时(thin runtime):只实现机制协议,不引入业务语义,不做第二语言实现。
11
11
 
12
12
  <!-- release-skill:managed:start id=latest-release -->
13
- **0.4.0** (2026-08-16)
13
+ **0.5.0** (2026-08-16)
14
14
 
15
- 本版在稳定 Harness 面上新增五项有限封闭语义机制与 token-lock 原语(FND-ADR-009),HARNESS_CAPABILITIES 从 9 项增至 16 项,并随附 rename-directory-no-replace 原生预编译候选。
15
+ 本版在稳定 Harness 面上新增声明读取表面断言(FND-ADR-010)与结构化表面扫描器(FND-ADR-011),HARNESS_CAPABILITIES 从 16 项增至 18 项,并引入三个经依赖评审的运行时库。
16
16
 
17
17
  **新增**
18
18
 
19
- - 新增五项有限封闭语义机制(FND-ADR-009):冻结基线物化 + contentGuard、通用只读 chokepoint、策略化表面扫描、确定性 token 上界估算与通用上限守卫。
20
- - 新增 token-lock 原语:独占 token 文件发布与摘要复验。
21
- - HARNESS_CAPABILITIES 9 项增至 16 项,覆盖上述新机制。
22
- - 新增 rename-directory-no-replace 候选:覆盖 darwin-arm64、darwin-x64、linux-arm64-gnu、linux-x64-gnu 的原生预编译 no-replace 目录改名 addon,附带发布 receipt 与 SBOM。
19
+ - 新增 assertDeclaredReadSurface(FND-ADR-010):不执行、仅语法面的断言,声明模块集内的每个 node:fs 具名导入必须落在消费者声明的读取表面内,违规词汇表为闭集,返回冻结的 declared-read-surface-result 信封。
20
+ - 新增 scanSurfaceStructured(FND-ADR-011):scanSurface 的结构化兄弟。IP 形候选统一进入单一标准解析入口(ipaddr.js),按消费者声明的 CIDR 批准,不可解析即失败关闭;坐标(scoped 与非 scoped)、注册表与主机均需消费者声明批准;格式适配器(pnpm-lockfile 经 @pnpm/lockfile.fs + yaml AST 注释区域,tree-json 经 JSON.parse)结构化解析且无位置级豁免;二进制与符号链接策略失败关闭。闭集九规则词汇表经 structured-scan-violation 机制错误的 details.rule 承载。
21
+ - 为结构化扫描器引入三个经依赖评审的运行时库,评审按 FND-ADR-006 第 4 节类比执行(依赖闭包预审,执行者自审,独立复核另行安排):ipaddr.js 2.5.0(MIT,零依赖)、yaml 2.9.0(ISC)、@pnpm/lockfile.fs 1001.1.35(MIT,18 个传递依赖全部在批准公共坐标内)。
22
+ - HARNESS_CAPABILITIES 16 项增至 18 项,覆盖两个新机制。
23
23
 
24
24
  **变更**
25
25
 
26
- - 保持 0.3.0 交付的 Quickstart Profile v2 Task/Result 交换校验:path-backed output evidence 重算真实字节、拒绝重复 Resource id、evidence 精确绑定逐项复验。
27
- - 方法选择、重试策略与领域结果解释继续归消费者所有。
26
+ - 显式说明两个策略文档与工作区私有 leak 策略的关系:工作区私有的 leak-policy.json 实例文档既不是 surface-scan-policy structured-scan-policy schema 的子集、也不同构、更不是迁移目标——两类文档按设计共享规则词汇与失败关闭语义,但字节级形状相互独立,不得比较兼容性。scanSurface 是执行内核通用化投影:同一机制族的公开、消费者参数化形态,自身不解释任何私有身份、路径或批准清单。
27
+ - 如实记录依赖评审决策的成本:harness tarball 随 @pnpm/lockfile.fs 闭包扩大;薄运行时属性从零第三方运行时依赖变为三个经评审依赖;pnpm-lockfile 适配器会在 OS 临时目录写一份临时锁文件副本(用后即删)。engineering-kit 的离线消费者验证门随之把第三方闭包推导从单包闭包机械扩展为三个 Foundation 包的完整生产闭包(真实身份去重、npm: 别名感知、range-scoped override selector),使评审决策持续对着真实安装字节被验证。
28
+ - 保持机制纯度:不执行被扫描文件、无模型调用、无网络;永不跟随符号链接。
28
29
 
29
30
  **升级说明**
30
31
 
31
- 0.4.0 已发布到 npm public 镜像仓。candidate 子路径必须精确锁定 0.4.0;机制导入使用 HARNESS_CAPABILITIES 公布的稳定能力名。
32
+ 0.5.0 FND-ADR-010/011 harness 线。机制导入使用 HARNESS_CAPABILITIES 公布的稳定能力名;structured-scan 策略必须通过契约规格 1.5.0 structured-scan-policy 契约校验。
32
33
  <!-- release-skill:managed:end id=latest-release -->
33
34
 
34
35
  ## 解决的问题
@@ -42,14 +43,14 @@ Harness 消费 `skill-family-contracts`(工作区依赖),复用其方言
42
43
  ## 安装和最小示例
43
44
 
44
45
  ```sh
45
- npm install skill-family-harness-node@0.4.0
46
+ npm install skill-family-harness-node@0.5.0
46
47
  npm info skill-family-harness-node --help
47
48
  ```
48
49
 
49
50
  最小示例演示在 Node 内校验一份契约文档:
50
51
 
51
52
  ```js
52
- // 从空目录运行:npm install skill-family-harness-node@0.4.0
53
+ // 从空目录运行:npm install skill-family-harness-node@0.5.0
53
54
  import { validateContractDocument } from "skill-family-harness-node";
54
55
 
55
56
  const document = {
package/package.json CHANGED
@@ -5,7 +5,10 @@
5
5
  "url": "https://github.com/ifoohoo/skill-family-harness-node/issues"
6
6
  },
7
7
  "dependencies": {
8
- "skill-family-contracts": "0.4.0"
8
+ "@pnpm/lockfile.fs": "1001.1.35",
9
+ "ipaddr.js": "2.5.0",
10
+ "skill-family-contracts": "0.5.0",
11
+ "yaml": "2.9.0"
9
12
  },
10
13
  "description": "Thin Node.js mechanism runtime for Skill Family engineering contracts.",
11
14
  "engines": {
@@ -39,7 +42,7 @@
39
42
  "url": "https://github.com/ifoohoo/skill-family-harness-node.git"
40
43
  },
41
44
  "type": "module",
42
- "version": "0.4.0",
45
+ "version": "0.5.0",
43
46
  "scripts": {
44
47
  "check": "node --test",
45
48
  "test": "node --test"
@@ -0,0 +1,29 @@
1
+ version: 0.5.0
2
+ date: 2026-08-16
3
+ locales:
4
+ en:
5
+ summary: This release adds the declared read surface assertion (FND-ADR-010) and the structured surface scanner (FND-ADR-011) to the stable Harness surface, growing HARNESS_CAPABILITIES from 16 to 18 entries, and adds three dependency-reviewed runtime libraries.
6
+ changes:
7
+ added:
8
+ - "Adds assertDeclaredReadSurface (FND-ADR-010): a no-execution, syntax-surface-only assertion that every node:fs named import inside a declared module set stays inside the consumer-declared read surface, with a closed violation vocabulary and a frozen declared-read-surface-result envelope."
9
+ - "Adds scanSurfaceStructured (FND-ADR-011): the structured sibling of scanSurface. IP-shaped candidates enter a single standard parse entry (ipaddr.js) with consumer-declared CIDR approval and fail closed when unparseable; scoped and unscoped coordinates, registries and hosts need consumer-declared approval; format adapters (pnpm-lockfile via @pnpm/lockfile.fs with yaml AST comment regions, and tree-json) parse structurally with no position-level exemptions; binary and symlink policies fail closed. The closed nine-rule vocabulary travels in details.rule of the structured-scan-violation mechanism error."
10
+ - "Adds three dependency-reviewed runtime libraries for the structured scanner, reviewed per FND-ADR-006 section-4 analogy (dependency-closure pre-review, executor self-review pending independent review): ipaddr.js 2.5.0 (MIT, zero dependencies), yaml 2.9.0 (ISC), and @pnpm/lockfile.fs 1001.1.35 (MIT, 18 transitive dependencies all inside the approved public coordinates)."
11
+ - Grows HARNESS_CAPABILITIES from 16 to 18 entries for the two new mechanisms.
12
+ changed:
13
+ - "Documents the relationship of the two policy documents to workspace-private leak policies: a workspace-private leak-policy.json instance document is not a subset, not isomorphic and not a migration target of the surface-scan-policy or structured-scan-policy schemas — the documents share rule vocabulary and fail-closed semantics by design, but their byte-level shapes are independent and must not be compared for compatibility. scanSurface is the execution-core generalization projection: the public, consumer-parameterized form of the same mechanism family, without any private identity, path, or approval-list interpretation of its own."
14
+ - "Records the cost of the dependency-review decision honestly: the harness tarball grows with the @pnpm/lockfile.fs closure, the thin-runtime property changes from zero third-party runtime dependencies to three reviewed ones, and the pnpm-lockfile adapter writes one temporary lockfile copy under the OS temp directory (removed afterwards). The engineering-kit offline-consumer verification gates now derive the complete third-party production closure of the three Foundation packages mechanically (identity-deduplicated, npm: alias-aware, range-scoped override selectors) instead of a single-package closure, so the review decision is continuously verified against the real installed bytes."
15
+ - "Keeps mechanism purity: no execution of scanned files, no model calls, no network; symlinked entries are never followed."
16
+ upgradeNotes: Version 0.5.0 is the FND-ADR-010/011 harness line. Mechanism imports use the stable capability names published in HARNESS_CAPABILITIES; structured-scan policies must pass the structured-scan-policy contract validation of contract-spec 1.5.0.
17
+ zh-CN:
18
+ summary: 本版在稳定 Harness 面上新增声明读取表面断言(FND-ADR-010)与结构化表面扫描器(FND-ADR-011),HARNESS_CAPABILITIES 从 16 项增至 18 项,并引入三个经依赖评审的运行时库。
19
+ changes:
20
+ added:
21
+ - 新增 assertDeclaredReadSurface(FND-ADR-010):不执行、仅语法面的断言,声明模块集内的每个 node:fs 具名导入必须落在消费者声明的读取表面内,违规词汇表为闭集,返回冻结的 declared-read-surface-result 信封。
22
+ - 新增 scanSurfaceStructured(FND-ADR-011):scanSurface 的结构化兄弟。IP 形候选统一进入单一标准解析入口(ipaddr.js),按消费者声明的 CIDR 批准,不可解析即失败关闭;坐标(scoped 与非 scoped)、注册表与主机均需消费者声明批准;格式适配器(pnpm-lockfile 经 @pnpm/lockfile.fs + yaml AST 注释区域,tree-json 经 JSON.parse)结构化解析且无位置级豁免;二进制与符号链接策略失败关闭。闭集九规则词汇表经 structured-scan-violation 机制错误的 details.rule 承载。
23
+ - 为结构化扫描器引入三个经依赖评审的运行时库,评审按 FND-ADR-006 第 4 节类比执行(依赖闭包预审,执行者自审,独立复核另行安排):ipaddr.js 2.5.0(MIT,零依赖)、yaml 2.9.0(ISC)、@pnpm/lockfile.fs 1001.1.35(MIT,18 个传递依赖全部在批准公共坐标内)。
24
+ - HARNESS_CAPABILITIES 从 16 项增至 18 项,覆盖两个新机制。
25
+ changed:
26
+ - 显式说明两个策略文档与工作区私有 leak 策略的关系:工作区私有的 leak-policy.json 实例文档既不是 surface-scan-policy 或 structured-scan-policy schema 的子集、也不同构、更不是迁移目标——两类文档按设计共享规则词汇与失败关闭语义,但字节级形状相互独立,不得比较兼容性。scanSurface 是执行内核通用化投影:同一机制族的公开、消费者参数化形态,自身不解释任何私有身份、路径或批准清单。
27
+ - "如实记录依赖评审决策的成本:harness tarball 随 @pnpm/lockfile.fs 闭包扩大;薄运行时属性从零第三方运行时依赖变为三个经评审依赖;pnpm-lockfile 适配器会在 OS 临时目录写一份临时锁文件副本(用后即删)。engineering-kit 的离线消费者验证门随之把第三方闭包推导从单包闭包机械扩展为三个 Foundation 包的完整生产闭包(真实身份去重、npm: 别名感知、range-scoped override selector),使评审决策持续对着真实安装字节被验证。"
28
+ - 保持机制纯度:不执行被扫描文件、无模型调用、无网络;永不跟随符号链接。
29
+ upgradeNotes: 0.5.0 是 FND-ADR-010/011 harness 线。机制导入使用 HARNESS_CAPABILITIES 公布的稳定能力名;structured-scan 策略必须通过契约规格 1.5.0 的 structured-scan-policy 契约校验。
@@ -0,0 +1,198 @@
1
+ import path from "node:path";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
4
+
5
+ /**
6
+ * Static declared read surface verification (FND-ADR-010).
7
+ *
8
+ * assertDeclaredReadSurface({ root, declaredReaders }) checks, by static
9
+ * source analysis, that every `.mjs`/`.js` module under `root` only reads
10
+ * node:fs through the consumer-declared reader set:
11
+ *
12
+ * - a module that imports named APIs from "node:fs" without being declared
13
+ * violates rule `undeclared-module-imports-fs`;
14
+ * - a declared module importing an API outside its declared set violates
15
+ * rule `fs-api-outside-declared-set`;
16
+ * - any write-family fs API name appearing anywhere in the tree (whole
17
+ * source substring detection, including comments and string literals)
18
+ * violates rule `write-family-fs-api` (conservative approximation: static
19
+ * syntax-level violations are never under-reported).
20
+ *
21
+ * The verdict semantics mirror the audit tree's P3 check one-to-one; the
22
+ * result is shaped as the contracts `declared-read-surface-result` envelope
23
+ * whose scope, rule enumeration and guarantees are the machine contract.
24
+ * Input is fully parameterized: root and declaredReaders are consumer data,
25
+ * the mechanism knows no audit paths, no workspace layout, and no business
26
+ * meaning of any module.
27
+ *
28
+ * Violations are collected as an evidence list (deterministic order:
29
+ * sorted module list, then rule order per module) rather than stopping at
30
+ * the first hit; `ok` is false exactly when the list is non-empty.
31
+ *
32
+ * Mechanism purity: read-only (never writes, never executes the scanned
33
+ * modules, no model call, no network); symlinked entries under the root are
34
+ * never followed and never scanned (conservative: a symlink may not smuggle
35
+ * a module out of the declared surface).
36
+ */
37
+
38
+ const MODULE_EXTENSIONS = [".mjs", ".js"];
39
+ const FS_NAMED_IMPORT_PATTERN = /import\s*\{([^}]*)\}\s*from\s*"node:fs"/g;
40
+
41
+ // Write-family API names are built by composition so this module's own
42
+ // source never contains the full forbidden names as literals (self-hit
43
+ // hygiene, inherited from the audit tree's P3 check). Detection of scanned
44
+ // modules is unaffected: they would spell the full names.
45
+ const WRITE_FS_VERBS = ["writeFile", "appendFile", "mkdir", "rm", "unlink", "rename", "copyFile"];
46
+ const WRITE_FS_APIS = WRITE_FS_VERBS.map((verb) => `${verb}Sync`).concat([
47
+ `${"createWrite"}Stream`,
48
+ ]);
49
+
50
+ export const DECLARED_READ_SURFACE_RULES = Object.freeze({
51
+ UNDECLARED_MODULE_IMPORTS_FS: "undeclared-module-imports-fs",
52
+ FS_API_OUTSIDE_DECLARED_SET: "fs-api-outside-declared-set",
53
+ WRITE_FAMILY_FS_API: "write-family-fs-api",
54
+ });
55
+
56
+ const RESULT_GUARANTEES = Object.freeze([
57
+ "syntax-surface-only",
58
+ "conservative-approximation",
59
+ "no-execution",
60
+ "no-model-calls",
61
+ "no-network-access",
62
+ ]);
63
+
64
+ function invalidInput(reason, extra) {
65
+ return mechanismError(
66
+ HARNESS_ERROR_KINDS.DECLARED_READ_SURFACE_INVALID,
67
+ `assertDeclaredReadSurface: ${reason}`,
68
+ extra,
69
+ );
70
+ }
71
+
72
+ function assertPlainObject(value, label) {
73
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
74
+ throw invalidInput(`${label} must be a plain object`);
75
+ }
76
+ }
77
+
78
+ function isValidDeclaredModulePath(modulePath) {
79
+ if (typeof modulePath !== "string" || modulePath.length === 0) return false;
80
+ if (modulePath.includes("\0")) return false;
81
+ if (modulePath.includes("\\")) return false;
82
+ if (path.posix.isAbsolute(modulePath)) return false;
83
+ if (/^[A-Za-z]:/.test(modulePath)) return false;
84
+ const normalized = path.posix.normalize(modulePath);
85
+ if (normalized === "." || normalized === "..") return false;
86
+ if (normalized.split("/").some((segment) => segment === "..")) return false;
87
+ return normalized === modulePath;
88
+ }
89
+
90
+ function collectModules(root) {
91
+ const modules = [];
92
+ const walk = (directory) => {
93
+ const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
94
+ a.name.localeCompare(b.name),
95
+ );
96
+ for (const entry of entries) {
97
+ if (entry.isSymbolicLink()) continue; // never followed, never scanned
98
+ const fullPath = path.join(directory, entry.name);
99
+ if (entry.isDirectory()) {
100
+ walk(fullPath);
101
+ } else if (entry.isFile() && MODULE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
102
+ modules.push(fullPath);
103
+ }
104
+ }
105
+ };
106
+ walk(root);
107
+ return modules;
108
+ }
109
+
110
+ function namedFsImports(source) {
111
+ return [...source.matchAll(FS_NAMED_IMPORT_PATTERN)].flatMap((match) =>
112
+ match[1].split(",").map((api) => api.trim()).filter(Boolean),
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Static declared read surface verification. Returns the contracts
118
+ * declared-read-surface-result envelope (frozen). Invalid inputs (non-empty
119
+ * root that is not a directory, empty declaredReaders, illegal module path
120
+ * shapes, malformed declared sets) throw SFC2004 with details.kind
121
+ * declared-read-surface-invalid; a detected violation is reported in the
122
+ * result's violations list (ok: false), never as an exception.
123
+ */
124
+ export function assertDeclaredReadSurface({ root, declaredReaders } = {}) {
125
+ if (typeof root !== "string" || root.length === 0) {
126
+ throw invalidInput("root must be a non-empty path");
127
+ }
128
+ assertPlainObject(declaredReaders, "declaredReaders");
129
+ const readerKeys = Object.keys(declaredReaders);
130
+ if (readerKeys.length === 0) {
131
+ throw invalidInput("declaredReaders must declare at least one reader module");
132
+ }
133
+ const declared = new Map();
134
+ for (const key of readerKeys) {
135
+ if (!isValidDeclaredModulePath(key)) {
136
+ throw invalidInput(`declared reader module path has an illegal shape: ${JSON.stringify(key)}`);
137
+ }
138
+ const apis = declaredReaders[key];
139
+ if (!Array.isArray(apis)) {
140
+ throw invalidInput(`declared set for ${key} must be an array of fs API names`);
141
+ }
142
+ for (const api of apis) {
143
+ if (typeof api !== "string" || api.length === 0) {
144
+ throw invalidInput(`declared set for ${key} contains a malformed fs API name`);
145
+ }
146
+ }
147
+ declared.set(key, apis);
148
+ }
149
+
150
+ const rootResolved = path.resolve(root);
151
+ let rootStat;
152
+ try {
153
+ rootStat = statSync(rootResolved);
154
+ } catch {
155
+ throw invalidInput("root does not exist or cannot be inspected", { root: "<opaque>" });
156
+ }
157
+ if (!rootStat.isDirectory()) {
158
+ throw invalidInput("root is not a directory", { root: "<opaque>" });
159
+ }
160
+
161
+ const modules = collectModules(rootResolved);
162
+ const violations = [];
163
+ const pushViolation = (entry) => violations.push(entry);
164
+
165
+ for (const module of modules) {
166
+ const relative = path.relative(rootResolved, module).split(path.sep).join("/");
167
+ const source = readFileSync(module, "utf8");
168
+ const fsImports = namedFsImports(source);
169
+ const allowed = declared.get(relative);
170
+ if (fsImports.length > 0 && allowed === undefined) {
171
+ pushViolation({ module: relative, rule: DECLARED_READ_SURFACE_RULES.UNDECLARED_MODULE_IMPORTS_FS });
172
+ }
173
+ if (allowed !== undefined) {
174
+ for (const api of fsImports) {
175
+ if (!allowed.includes(api)) {
176
+ pushViolation({ module: relative, api, rule: DECLARED_READ_SURFACE_RULES.FS_API_OUTSIDE_DECLARED_SET });
177
+ }
178
+ }
179
+ }
180
+ for (const api of WRITE_FS_APIS) {
181
+ if (source.includes(api)) {
182
+ pushViolation({ module: relative, api, rule: DECLARED_READ_SURFACE_RULES.WRITE_FAMILY_FS_API });
183
+ }
184
+ }
185
+ }
186
+
187
+ return Object.freeze({
188
+ schemaVersion: 1,
189
+ kind: "skill-family.declared-read-surface-result",
190
+ ok: violations.length === 0,
191
+ violations,
192
+ scannedModules: modules.map((module) =>
193
+ path.relative(rootResolved, module).split(path.sep).join("/"),
194
+ ),
195
+ scope: "esm-named-imports-only",
196
+ guarantees: [...RESULT_GUARANTEES],
197
+ });
198
+ }
package/src/errors.mjs CHANGED
@@ -65,6 +65,10 @@ export const HARNESS_ERROR_KINDS = Object.freeze({
65
65
  READ_CHOKEPOINT_REJECTED: "read-chokepoint-rejected",
66
66
  SURFACE_SCAN_VIOLATION: "surface-scan-violation",
67
67
  SCAN_POLICY_INVALID: "scan-policy-invalid",
68
+ DECLARED_READ_SURFACE_VIOLATION: "declared-read-surface-violation",
69
+ DECLARED_READ_SURFACE_INVALID: "declared-read-surface-invalid",
70
+ STRUCTURED_SCAN_VIOLATION: "structured-scan-violation",
71
+ STRUCTURED_SCAN_INVALID: "structured-scan-invalid",
68
72
  UPPER_BOUND_EXCEEDED: "upper-bound-exceeded",
69
73
  });
70
74
 
package/src/index.mjs CHANGED
@@ -25,6 +25,8 @@ export const HARNESS_CAPABILITIES = Object.freeze([
25
25
  "baseline-materialization",
26
26
  "read-chokepoint",
27
27
  "surface-scan",
28
+ "declared-read-surface",
29
+ "structured-scan",
28
30
  "token-estimation",
29
31
  "upper-bound-guard",
30
32
  ]);
@@ -66,6 +68,10 @@ export { createReadChokepoint } from "./chokepoint.mjs";
66
68
 
67
69
  export { scanSurface } from "./surface-scan.mjs";
68
70
 
71
+ export { assertDeclaredReadSurface } from "./declared-read-surface.mjs";
72
+
73
+ export { scanSurfaceStructured, STRUCTURED_SCAN_RULES, isIpv6ShapedRun, extractIpCandidates, normalizeIpToken, classifyIpToken, collectLockfileCommentRegions } from "./structured-scan.mjs";
74
+
69
75
  export { estimateTokenUpperBound } from "./token-estimate.mjs";
70
76
 
71
77
  export {
@@ -0,0 +1,652 @@
1
+ import { findSchemaByObject } from "skill-family-contracts";
2
+ import { mkdtempSync, rmSync, writeFileSync, lstatSync, readlinkSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import ipaddr from "ipaddr.js";
6
+ import { readWantedLockfile } from "@pnpm/lockfile.fs";
7
+ import { parseDocument, visit } from "yaml";
8
+ import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
9
+ import { classifyPathInput, readFileContained, resolveContained } from "./paths.mjs";
10
+ import { validateContractDocument } from "./validation.mjs";
11
+
12
+ const POLICY_SCHEMA_ID = findSchemaByObject("structured-scan-policy").$id;
13
+
14
+ /**
15
+ * Structured surface scanner (FND-ADR-011).
16
+ *
17
+ * scanSurfaceStructured({ root, relPaths, policy }) is the public,
18
+ * consumer-parameterized form of the same mechanism family as scanSurface:
19
+ * it scans consumer-declared files under a consumer-declared root against a
20
+ * consumer-declared structured-scan-policy contract document, and fails
21
+ * closed on the first hit (structured-scan-violation with details.rule from
22
+ * the closed vocabulary). The mechanism owns no private identity, no
23
+ * workspace layout, no approval list: every network, registry, coordinate,
24
+ * adapter, host-key pattern and binary approval is consumer data.
25
+ *
26
+ * Closed rule vocabulary (details.rule):
27
+ * non-public-address CIDR-outside or unparseable IP-shaped token
28
+ * private-npm-scope scoped coordinate outside approvedCoordinates
29
+ * unapproved-dependency-coordinate unscoped coordinate outside approvedCoordinates
30
+ * non-approved-registry URL reference outside approvedRegistries
31
+ * non-reserved-hostname host that is not localhost, not an approved
32
+ * registry host, and not hostKeyPattern-shaped
33
+ * format-adapter-parse-failed a registered adapter could not parse the file
34
+ * format-adapter-unknown a registered adapter name is not implemented
35
+ * binary-unclassified non-text file outside binaryPolicy.approvedPaths
36
+ * symlink-forbidden symlinked entry (readlink records the target
37
+ * text; the target is never followed)
38
+ *
39
+ * Address semantics: lexical candidate boundaries (rules (A)-(E) for
40
+ * IPv6-shaped runs plus dotted-decimal IPv4 shape) feed a single standard
41
+ * parse entry (ipaddr.js); an unparseable candidate fails closed
42
+ * (non-public-address) — no undocumented syntax exemption exists. IPv4-mapped
43
+ * and compatible embedded addresses delegate to the embedded IPv4's CIDR
44
+ * classification. Bias direction is declared in the policy contract:
45
+ * conservative, never silently skipped.
46
+ *
47
+ * Format adapters: files whose relative path matches a registered
48
+ * formatAdapters[].pathPattern are parsed structurally; a parse failure is
49
+ * format-adapter-parse-failed (fail-closed) and an unregistered adapter name
50
+ * is format-adapter-unknown (fail-closed). There is no position-level whole
51
+ * key or whole file exemption: every key and every string leaf value of a
52
+ * parsed document still runs the full structure rules. Built-in adapters:
53
+ * "pnpm-lockfile" (@pnpm/lockfile.fs semantic object plus yaml AST comment
54
+ * regions) and "tree-json" (JSON.parse).
55
+ *
56
+ * Mechanism purity: never executes scanned files, no model call, no network;
57
+ * the only write is a temporary lockfile copy under the OS temp directory
58
+ * (cleaned up) for the pnpm-lockfile adapter. Symlinked entries are never
59
+ * followed.
60
+ */
61
+
62
+ export const STRUCTURED_SCAN_RULES = Object.freeze({
63
+ NON_PUBLIC_ADDRESS: "non-public-address",
64
+ PRIVATE_NPM_SCOPE: "private-npm-scope",
65
+ UNAPPROVED_DEPENDENCY_COORDINATE: "unapproved-dependency-coordinate",
66
+ NON_APPROVED_REGISTRY: "non-approved-registry",
67
+ NON_RESERVED_HOSTNAME: "non-reserved-hostname",
68
+ FORMAT_ADAPTER_PARSE_FAILED: "format-adapter-parse-failed",
69
+ FORMAT_ADAPTER_UNKNOWN: "format-adapter-unknown",
70
+ BINARY_UNCLASSIFIED: "binary-unclassified",
71
+ SYMLINK_FORBIDDEN: "symlink-forbidden",
72
+ });
73
+
74
+ // ---------------------------------------------------------------- patterns
75
+
76
+ const URL_PATTERN = /[A-Za-z][A-Za-z0-9+.-]*:\/\/(\[[^\]/\s]+\]|[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9-]+)+[^/\s"'<>\\:]*)/g;
77
+ const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@([A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,})/g;
78
+ const HOST_KEY_STRING_PATTERN = /"(?:host|hostname|server|endpoint)"\s*:\s*"([^"]+)"/g;
79
+ const HOST_KEY_ARRAY_PATTERN = /"hosts"\s*:\s*\[([^\]]*)\]/g;
80
+ const QUOTED_TOKEN_PATTERN = /"([^"]+)"/g;
81
+ const NPM_SCOPE_PATTERN = /(?<![A-Za-z0-9._%+-])@([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)/g;
82
+ const IP_RUN_PATTERN = /[0-9A-Za-z:.]+(?:%[0-9A-Za-z._~-]+)?/g;
83
+ const IPV4_SHAPED_PATTERN = /\d{1,3}(?:\.\d{1,3}){3}/g;
84
+ const HEX_GROUP_SEGMENT_PATTERN = /^[0-9A-Fa-f]{1,4}$/;
85
+ const ALPHA_SHORT_SEGMENT_PATTERN = /^[A-Za-z]{1,4}$/;
86
+ const HEX_LETTER_PATTERN = /[A-Fa-f]/;
87
+ const DECIMAL_DIGIT_PATTERN = /[0-9]/;
88
+ const LOCKFILE_URL_REFERENCE_PATTERN = /^(https?|git|git\+https|git\+ssh|ssh):/;
89
+ const LOCAL_LOCKFILE_REF_PREFIXES = ["link:", "file:", "path:", "workspace:"];
90
+
91
+ /**
92
+ * Lexical boundary judgement: is a colon-containing run IP-shaped?
93
+ * Rules (A)-(E) are the public lexical boundary: (A) contains "::"; (B) at
94
+ * least four non-empty colon segments; (C) at least three segments and all
95
+ * non-empty segments are 1-4 hex digits with a hex letter present; (D) an
96
+ * empty segment exists, all non-empty segments are hex groups, at least two
97
+ * non-empty segments, with a hex letter present; (E) at least one non-empty
98
+ * segment is a 1-4 letter alpha run while a decimal digit exists elsewhere.
99
+ * Documented non-address classes (pure-decimal colon runs, pure-alpha colon
100
+ * runs, digest-prefix runs, single-group colon labels, semver/plain text)
101
+ * are kept clean by independent positive fixtures; everything that cannot be
102
+ * classified as non-address enters the single parse entry and fails closed
103
+ * when unparseable.
104
+ */
105
+ export function isIpv6ShapedRun(run) {
106
+ if (!run.includes(":")) return false;
107
+ if (run.includes("::")) return true; // (A)
108
+ const segments = run.split(":");
109
+ const nonEmptySegments = segments.filter((segment) => segment.length > 0);
110
+ const hasEmptySegment = nonEmptySegments.length !== segments.length;
111
+ if (nonEmptySegments.length >= 4) return true; // (B)
112
+ const allHexGroups =
113
+ nonEmptySegments.length > 0 && nonEmptySegments.every((segment) => HEX_GROUP_SEGMENT_PATTERN.test(segment));
114
+ if (allHexGroups && nonEmptySegments.length >= 3 && HEX_LETTER_PATTERN.test(run)) return true; // (C)
115
+ if (hasEmptySegment && allHexGroups && nonEmptySegments.length >= 2 && HEX_LETTER_PATTERN.test(run)) return true; // (D)
116
+ if (nonEmptySegments.some((segment) => ALPHA_SHORT_SEGMENT_PATTERN.test(segment)) && DECIMAL_DIGIT_PATTERN.test(run)) return true; // (E)
117
+ return false;
118
+ }
119
+
120
+ /** IP-shaped candidate extraction. Sentence punctuation is stripped before
121
+ * the rules; IPv4 candidates not covered by a colon candidate are added. */
122
+ export function extractIpCandidates(text) {
123
+ const candidates = [];
124
+ for (const match of text.matchAll(IP_RUN_PATTERN)) {
125
+ let run = match[0];
126
+ let index = match.index;
127
+ const leadingDots = run.match(/^\.+/);
128
+ if (leadingDots) {
129
+ index += leadingDots[0].length;
130
+ run = run.slice(leadingDots[0].length);
131
+ }
132
+ const trailingDots = run.match(/\.+$/);
133
+ if (trailingDots) run = run.slice(0, run.length - trailingDots[0].length);
134
+ if (isIpv6ShapedRun(run)) {
135
+ candidates.push({ token: run, index, shape: "ipv6" });
136
+ }
137
+ }
138
+ for (const match of text.matchAll(IPV4_SHAPED_PATTERN)) {
139
+ const coveredByColonCandidate = candidates.some(
140
+ (entry) => match.index >= entry.index && match.index + match[0].length <= entry.index + entry.token.length,
141
+ );
142
+ if (!coveredByColonCandidate) {
143
+ candidates.push({ token: match[0], index: match.index, shape: "ipv4" });
144
+ }
145
+ }
146
+ candidates.sort((a, b) => a.index - b.index);
147
+ return candidates;
148
+ }
149
+
150
+ /** Normalizes a candidate for the parse entry: URL brackets and %zone-id. */
151
+ export function normalizeIpToken(token) {
152
+ let core = String(token);
153
+ if (core.startsWith("[")) {
154
+ const close = core.indexOf("]");
155
+ core = close === -1 ? core.slice(1) : core.slice(1, close);
156
+ }
157
+ const zone = core.indexOf("%");
158
+ if (zone !== -1) core = core.slice(0, zone);
159
+ return core;
160
+ }
161
+
162
+ function withinApprovedNetworks(cidrs, addr) {
163
+ for (const [cidrAddress, cidrRange] of cidrs) {
164
+ if (cidrAddress.kind() !== addr.kind()) continue; // ipaddr.match throws across kinds; classify by family
165
+ if (addr.match(cidrAddress, cidrRange)) return { allowed: true, reason: "approved-network" };
166
+ }
167
+ return { allowed: false, reason: "outside-approved-networks" };
168
+ }
169
+
170
+ function classifyIpv4Octets(cidrs, octets) {
171
+ return withinApprovedNetworks(cidrs, new ipaddr.IPv4(octets));
172
+ }
173
+
174
+ /**
175
+ * Single address classification entry: brackets/zone id/case/IPv4/IPv6 all
176
+ * pass through here. ipaddr.parse failure is fail-closed
177
+ * (unparseable-ip-shaped-token); IPv4-mapped/compatible embeddings delegate
178
+ * to the embedded IPv4's CIDR classification.
179
+ */
180
+ export function classifyIpToken(cidrs, token) {
181
+ const core = normalizeIpToken(token);
182
+ let addr;
183
+ try {
184
+ addr = ipaddr.parse(core);
185
+ } catch {
186
+ return { allowed: false, reason: "unparseable-ip-shaped-token" };
187
+ }
188
+ if (addr.kind() === "ipv6") {
189
+ const bytes = addr.toByteArray();
190
+ const embedded =
191
+ bytes.slice(0, 10).every((byte) => byte === 0) &&
192
+ ((bytes[10] === 0xff && bytes[11] === 0xff) || (bytes[10] === 0 && bytes[11] === 0));
193
+ if (embedded) {
194
+ const verdict = classifyIpv4Octets(cidrs, bytes.slice(12));
195
+ return { allowed: verdict.allowed, reason: `embedded-ipv4:${verdict.reason}` };
196
+ }
197
+ }
198
+ return withinApprovedNetworks(cidrs, addr);
199
+ }
200
+
201
+ function lineOf(bytes, index) {
202
+ let line = 1;
203
+ for (let i = 0; i < index && i < bytes.length; i += 1) {
204
+ if (bytes[i] === 0x0a) line += 1;
205
+ }
206
+ return line;
207
+ }
208
+
209
+ function isIpShapedToken(bare) {
210
+ if (isIpv6ShapedRun(bare)) return true;
211
+ return IPV4_SHAPED_PATTERN.test(bare);
212
+ }
213
+
214
+ function registryHosts(approvedRegistries) {
215
+ const hosts = new Set();
216
+ for (const registry of approvedRegistries) {
217
+ const match = registry.match(/^[a-z][a-z0-9+.-]*:\/\/([^/]+)/i);
218
+ if (match) {
219
+ const bare = match[1].toLowerCase().replace(/\.$/, "");
220
+ const portIndex = bare.indexOf(":");
221
+ hosts.add(portIndex === -1 ? bare : bare.slice(0, portIndex));
222
+ }
223
+ }
224
+ return hosts;
225
+ }
226
+
227
+ // ----------------------------------------------------------- policy loading
228
+
229
+ function loadPolicy(policy) {
230
+ const checked = validateContractDocument(policy, { schemaId: POLICY_SCHEMA_ID });
231
+ if (!checked.valid) {
232
+ throw mechanismError(HARNESS_ERROR_KINDS.STRUCTURED_SCAN_INVALID, "structured-scan policy document is invalid", {
233
+ errors: checked.errors,
234
+ });
235
+ }
236
+
237
+ const cidrs = [];
238
+ for (const cidr of policy.allowedNetworks) {
239
+ let parsed;
240
+ try {
241
+ parsed = ipaddr.parseCIDR(cidr);
242
+ } catch (cause) {
243
+ throw mechanismError(
244
+ HARNESS_ERROR_KINDS.STRUCTURED_SCAN_INVALID,
245
+ `structured-scan policy contains an invalid CIDR: ${cidr}`,
246
+ { cidr, cause: cause && cause.message ? cause.message : String(cause) },
247
+ );
248
+ }
249
+ cidrs.push(parsed);
250
+ }
251
+
252
+ let hostKeyPattern = null;
253
+ if (typeof policy.hostKeyPattern === "string") {
254
+ try {
255
+ hostKeyPattern = new RegExp(policy.hostKeyPattern);
256
+ } catch (cause) {
257
+ throw mechanismError(
258
+ HARNESS_ERROR_KINDS.STRUCTURED_SCAN_INVALID,
259
+ "structured-scan policy hostKeyPattern does not compile",
260
+ { cause: cause && cause.message ? cause.message : String(cause) },
261
+ );
262
+ }
263
+ }
264
+
265
+ const adapters = [];
266
+ for (const adapter of policy.formatAdapters) {
267
+ let pattern;
268
+ try {
269
+ pattern = new RegExp(adapter.pathPattern);
270
+ } catch (cause) {
271
+ throw mechanismError(
272
+ HARNESS_ERROR_KINDS.STRUCTURED_SCAN_INVALID,
273
+ `structured-scan policy adapter ${adapter.name} pathPattern does not compile`,
274
+ { adapter: adapter.name, cause: cause && cause.message ? cause.message : String(cause) },
275
+ );
276
+ }
277
+ adapters.push({ name: adapter.name, pattern });
278
+ }
279
+
280
+ return {
281
+ schemaVersion: policy.schemaVersion,
282
+ kind: policy.kind,
283
+ allowedNetworks: cidrs,
284
+ approvedRegistries: [...policy.approvedRegistries],
285
+ approvedCoordinates: new Set(policy.approvedCoordinates),
286
+ formatAdapters: adapters,
287
+ symlinkPolicy: policy.symlinkPolicy.mode,
288
+ binaryPolicy: { mode: policy.binaryPolicy.mode, approvedPaths: new Set(policy.binaryPolicy.approvedPaths) },
289
+ hostKeyPattern,
290
+ approvedRegistryHosts: registryHosts(policy.approvedRegistries),
291
+ };
292
+ }
293
+
294
+ // ---------------------------------------------------------------- scanning
295
+
296
+ function scanViolation(relPath, rule, evidence) {
297
+ return mechanismError(HARNESS_ERROR_KINDS.STRUCTURED_SCAN_VIOLATION, "structured scan matched a violation", {
298
+ path: relPath,
299
+ rule,
300
+ evidence,
301
+ });
302
+ }
303
+
304
+ function checkHost(ctx, relPath, host, context, text, index) {
305
+ if (typeof host !== "string" || host.length === 0) return;
306
+ let bare = host;
307
+ const atIndex = bare.lastIndexOf("@");
308
+ if (atIndex !== -1) bare = bare.slice(atIndex + 1);
309
+ if (bare.startsWith("[")) {
310
+ const verdict = classifyIpToken(ctx.allowedNetworks, bare);
311
+ if (!verdict.allowed) {
312
+ throw scanViolation(relPath, STRUCTURED_SCAN_RULES.NON_PUBLIC_ADDRESS, `${host}(${verdict.reason})`);
313
+ }
314
+ return;
315
+ }
316
+ const portIndex = bare.indexOf(":");
317
+ if (portIndex !== -1) bare = bare.slice(0, portIndex);
318
+ bare = bare.toLowerCase().replace(/\.$/, "");
319
+ if (bare.length === 0) return;
320
+ if (isIpShapedToken(bare)) {
321
+ const verdict = classifyIpToken(ctx.allowedNetworks, bare);
322
+ if (!verdict.allowed) {
323
+ throw scanViolation(relPath, STRUCTURED_SCAN_RULES.NON_PUBLIC_ADDRESS, `${host}(${verdict.reason})`);
324
+ }
325
+ return;
326
+ }
327
+ if (!bare.includes(".")) {
328
+ if (bare === "localhost") return;
329
+ if (context === "host-key" && ctx.hostKeyPattern?.test(bare)) return;
330
+ throw scanViolation(relPath, STRUCTURED_SCAN_RULES.NON_RESERVED_HOSTNAME, host);
331
+ }
332
+ if (ctx.approvedRegistryHosts.has(bare)) return;
333
+ throw scanViolation(relPath, STRUCTURED_SCAN_RULES.NON_RESERVED_HOSTNAME, host);
334
+ }
335
+
336
+ /** Full structure rules over one text buffer. Throws on first hit. */
337
+ function scanStructure(ctx, relPath, bytes) {
338
+ const text = bytes.toString("utf8");
339
+
340
+ for (const match of text.matchAll(URL_PATTERN)) checkHost(ctx, relPath, match[1], "url", text, match.index);
341
+ for (const match of text.matchAll(EMAIL_PATTERN)) checkHost(ctx, relPath, match[1], "email", text, match.index);
342
+ for (const match of text.matchAll(HOST_KEY_STRING_PATTERN)) checkHost(ctx, relPath, match[1], "host-key", text, match.index);
343
+ for (const arrayMatch of text.matchAll(HOST_KEY_ARRAY_PATTERN)) {
344
+ for (const tokenMatch of arrayMatch[1].matchAll(QUOTED_TOKEN_PATTERN)) {
345
+ checkHost(ctx, relPath, tokenMatch[1], "host-key", text, arrayMatch.index);
346
+ }
347
+ }
348
+
349
+ for (const match of text.matchAll(NPM_SCOPE_PATTERN)) {
350
+ const coordinate = `@${match[1]}/${match[2]}`;
351
+ if (!ctx.approvedCoordinates.has(coordinate)) {
352
+ throw scanViolation(relPath, STRUCTURED_SCAN_RULES.PRIVATE_NPM_SCOPE, match[0]);
353
+ }
354
+ }
355
+
356
+ for (const { token, index } of extractIpCandidates(text)) {
357
+ const verdict = classifyIpToken(ctx.allowedNetworks, token);
358
+ if (!verdict.allowed) {
359
+ throw scanViolation(relPath, STRUCTURED_SCAN_RULES.NON_PUBLIC_ADDRESS, `${token}(${verdict.reason})`);
360
+ }
361
+ }
362
+ }
363
+
364
+ /** Binary judgement (fail-closed): non-text control bytes in the first 8KB. */
365
+ function looksBinary(bytes) {
366
+ const limit = Math.min(bytes.length, 8192);
367
+ for (let i = 0; i < limit; i += 1) {
368
+ const b = bytes[i];
369
+ if (b === 9 || b === 10 || b === 11 || b === 12 || b === 13) continue;
370
+ if (b < 32 || b === 127) return true;
371
+ }
372
+ return false;
373
+ }
374
+
375
+ // ----------------------------------------------------------- format adapters
376
+
377
+ /** yaml AST comment regions; an unparseable document yields none (the
378
+ * semantic parse path reports format-adapter-parse-failed). */
379
+ export function collectLockfileCommentRegions(bytes) {
380
+ const text = Buffer.isBuffer(bytes) ? bytes.toString("utf8") : String(bytes);
381
+ let doc;
382
+ try {
383
+ doc = parseDocument(text);
384
+ } catch {
385
+ return [];
386
+ }
387
+ const regions = [];
388
+ if (doc.commentBefore) regions.push(doc.commentBefore);
389
+ if (doc.comment) regions.push(doc.comment);
390
+ visit(doc, {
391
+ Node(_key, node) {
392
+ if (node.commentBefore) regions.push(node.commentBefore);
393
+ if (node.comment) regions.push(node.comment);
394
+ },
395
+ });
396
+ return regions;
397
+ }
398
+
399
+ function lockfileDependencyName(depPath) {
400
+ let dep = String(depPath);
401
+ if (dep.startsWith("/")) dep = dep.slice(1);
402
+ if (dep.startsWith("@")) {
403
+ const secondAt = dep.indexOf("@", 1);
404
+ return secondAt === -1 ? dep : dep.slice(0, secondAt);
405
+ }
406
+ const firstAt = dep.indexOf("@");
407
+ return firstAt === -1 ? dep : dep.slice(0, firstAt);
408
+ }
409
+
410
+ function isLocalLockfileRef(depPathOrVersion) {
411
+ return (
412
+ typeof depPathOrVersion === "string" &&
413
+ LOCAL_LOCKFILE_REF_PREFIXES.some((prefix) => depPathOrVersion.startsWith(prefix))
414
+ );
415
+ }
416
+
417
+ /**
418
+ * "pnpm-lockfile" adapter: comment regions are scanned first (independent of
419
+ * the semantic parse), then @pnpm/lockfile.fs parses the document (parse
420
+ * failure fails closed); every non-local importer dependency and every
421
+ * packages/snapshots coordinate must be in approvedCoordinates (scoped =
422
+ * private-npm-scope, unscoped = unapproved-dependency-coordinate; orphan
423
+ * entries are judged too); URL references must start with an approved
424
+ * registry; keys and string leaf values continue through the full structure
425
+ * rules. The adapter writes one temporary copy under the OS temp directory
426
+ * for the parser and removes it afterwards.
427
+ */
428
+ async function scanLockfileContent(ctx, relPath, bytes) {
429
+ const commentRegions = collectLockfileCommentRegions(bytes);
430
+ commentRegions.forEach((region, index) => {
431
+ scanStructure(ctx, `${relPath}#yaml-comment-${index + 1}`, Buffer.from(region, "utf8"));
432
+ });
433
+
434
+ let lockfile = null;
435
+ let parseError = null;
436
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), "sf-structured-lockfile-"));
437
+ try {
438
+ writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), bytes);
439
+ lockfile = await readWantedLockfile(tmpDir, { ignoreIncompatible: false });
440
+ } catch (cause) {
441
+ parseError = cause;
442
+ } finally {
443
+ rmSync(tmpDir, { recursive: true, force: true });
444
+ }
445
+ // The semantic parse must yield a lockfile document (a versioned map); a
446
+ // scalar or otherwise non-semantic parse result is a parse failure.
447
+ if (
448
+ parseError ||
449
+ lockfile === null ||
450
+ typeof lockfile !== "object" ||
451
+ typeof lockfile.lockfileVersion !== "string"
452
+ ) {
453
+ throw scanViolation(
454
+ relPath,
455
+ STRUCTURED_SCAN_RULES.FORMAT_ADAPTER_PARSE_FAILED,
456
+ `pnpm-lock.yaml could not be parsed by @pnpm/lockfile.fs, fail-closed(${String(parseError?.message ?? "no semantic lockfile document").slice(0, 200)})`,
457
+ );
458
+ }
459
+
460
+ for (const [importerId, importer] of Object.entries(lockfile.importers ?? {})) {
461
+ for (const section of ["dependencies", "devDependencies", "optionalDependencies"]) {
462
+ for (const [name, entry] of Object.entries(importer?.[section] ?? {})) {
463
+ if (isLocalLockfileRef(entry?.version)) continue;
464
+ if (!ctx.approvedCoordinates.has(name)) {
465
+ throw scanViolation(
466
+ relPath,
467
+ name.startsWith("@") ? STRUCTURED_SCAN_RULES.PRIVATE_NPM_SCOPE : STRUCTURED_SCAN_RULES.UNAPPROVED_DEPENDENCY_COORDINATE,
468
+ `importer ${importerId} dependency ${name}(version ${entry?.version ?? "?"})not in approvedCoordinates`,
469
+ );
470
+ }
471
+ }
472
+ }
473
+ }
474
+
475
+ for (const [mapName, map] of [["packages", lockfile.packages ?? {}], ["snapshots", lockfile.snapshots ?? {}]]) {
476
+ for (const [depPath, entry] of Object.entries(map)) {
477
+ const baseDepPath = depPath.split("(")[0];
478
+ if (isLocalLockfileRef(baseDepPath)) continue;
479
+ const name = lockfileDependencyName(baseDepPath);
480
+ if (!ctx.approvedCoordinates.has(name)) {
481
+ throw scanViolation(
482
+ relPath,
483
+ name.startsWith("@") ? STRUCTURED_SCAN_RULES.PRIVATE_NPM_SCOPE : STRUCTURED_SCAN_RULES.UNAPPROVED_DEPENDENCY_COORDINATE,
484
+ `${mapName} coordinate ${depPath} not in approvedCoordinates(orphan entries are not exempt)`,
485
+ );
486
+ }
487
+ }
488
+ }
489
+
490
+ const walk = (node, segments) => {
491
+ if (Array.isArray(node)) {
492
+ node.forEach((item, index) => walk(item, [...segments, String(index)]));
493
+ return;
494
+ }
495
+ if (node && typeof node === "object") {
496
+ for (const [key, value] of Object.entries(node)) {
497
+ if (typeof key === "string" && key.length > 0) {
498
+ scanStructure(ctx, `${relPath}#/${[...segments, key].join("/")}(key)`, Buffer.from(key, "utf8"));
499
+ }
500
+ walk(value, [...segments, key]);
501
+ }
502
+ return;
503
+ }
504
+ if (typeof node === "string") {
505
+ if (LOCKFILE_URL_REFERENCE_PATTERN.test(node) && !ctx.approvedRegistries.some((registry) => node.startsWith(registry))) {
506
+ throw scanViolation(relPath, STRUCTURED_SCAN_RULES.NON_APPROVED_REGISTRY, `${segments.join("/") || "<root>"} references an unapproved registry: ${node.slice(0, 120)}`);
507
+ }
508
+ scanStructure(ctx, `${relPath}#/${segments.join("/")}`, Buffer.from(node, "utf8"));
509
+ } else if (node !== null && node !== undefined) {
510
+ scanStructure(ctx, `${relPath}#/${segments.join("/")}`, Buffer.from(String(node), "utf8"));
511
+ }
512
+ };
513
+ walk(lockfile, []);
514
+ }
515
+
516
+ /**
517
+ * "tree-json" adapter: JSON.parse; a parse failure is
518
+ * format-adapter-parse-failed (fail-closed). There is no position-level
519
+ * whole-key exemption: every key and every value runs the full structure
520
+ * rules first.
521
+ */
522
+ function scanTreeJsonContent(ctx, relPath, bytes) {
523
+ let parsed;
524
+ try {
525
+ parsed = JSON.parse(bytes.toString("utf8"));
526
+ } catch (cause) {
527
+ throw scanViolation(
528
+ relPath,
529
+ STRUCTURED_SCAN_RULES.FORMAT_ADAPTER_PARSE_FAILED,
530
+ `tree.json could not be JSON.parse'd, fail-closed(${String(cause?.message ?? cause).slice(0, 200)})`,
531
+ );
532
+ }
533
+
534
+ const walk = (node, segments) => {
535
+ if (Array.isArray(node)) {
536
+ node.forEach((item, index) => walk(item, [...segments, String(index)]));
537
+ return;
538
+ }
539
+ if (node && typeof node === "object") {
540
+ for (const [key, value] of Object.entries(node)) {
541
+ if (typeof key === "string" && key.length > 0) {
542
+ scanStructure(ctx, `${relPath}#/${[...segments, key].join("/")}(key)`, Buffer.from(key, "utf8"));
543
+ }
544
+ walk(value, [...segments, key]);
545
+ }
546
+ return;
547
+ }
548
+ if (node !== null && node !== undefined) {
549
+ scanStructure(ctx, `${relPath}#/${segments.join("/")}`, Buffer.from(String(node), "utf8"));
550
+ }
551
+ };
552
+ walk(parsed, []);
553
+ }
554
+
555
+ function findAdapter(ctx, relPath) {
556
+ const normalized = relPath.split(path.sep).join("/");
557
+ for (const adapter of ctx.formatAdapters) {
558
+ if (adapter.pattern.test(normalized)) return adapter;
559
+ }
560
+ return null;
561
+ }
562
+
563
+ const BUILTIN_ADAPTERS = Object.freeze({
564
+ "pnpm-lockfile": scanLockfileContent,
565
+ "tree-json": scanTreeJsonContent,
566
+ });
567
+
568
+ // ------------------------------------------------------------------- entry
569
+
570
+ /**
571
+ * Structured surface scan. Returns a frozen { scanned, bytes, policy } result
572
+ * mirroring scanSurface; a violation throws SFC2004 with details.kind
573
+ * structured-scan-violation and details.rule from the closed vocabulary, and
574
+ * an invalid policy throws structured-scan-invalid.
575
+ */
576
+ export async function scanSurfaceStructured({ root, relPaths, policy, encoding = "utf8" } = {}) {
577
+ if (typeof root !== "string" || root.length === 0) {
578
+ throw new TypeError("scanSurfaceStructured: root must be a non-empty path");
579
+ }
580
+ if (!Array.isArray(relPaths)) {
581
+ throw new TypeError("scanSurfaceStructured: relPaths must be an array of root-relative path strings");
582
+ }
583
+ if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
584
+ throw new TypeError("scanSurfaceStructured: policy must be a structured-scan-policy contract document");
585
+ }
586
+ const ctx = loadPolicy(policy);
587
+
588
+ const scanned = [];
589
+ let bytes = 0;
590
+ for (const relPath of relPaths) {
591
+ if (typeof relPath !== "string" || relPath.length === 0) {
592
+ throw new TypeError("scanSurfaceStructured: every relPath entry must be a non-empty string");
593
+ }
594
+ const normalized = relPath.split(path.sep).join("/");
595
+ const classification = classifyPathInput(relPath);
596
+ if (!classification.ok) {
597
+ throw mechanismError(
598
+ classification.kind,
599
+ `path rejected before resolution (kind: ${classification.kind})`,
600
+ { input: relPath },
601
+ );
602
+ }
603
+ // Symlink entries are judged before any resolution: readlink-no-follow
604
+ // records the target text and the target is never read. This applies to
605
+ // in-tree links and links that would escape alike.
606
+ const fullPath = path.resolve(root, relPath);
607
+ let stat;
608
+ try {
609
+ stat = lstatSync(fullPath);
610
+ } catch (cause) {
611
+ throw mechanismError(
612
+ HARNESS_ERROR_KINDS.STRUCTURED_SCAN_INVALID,
613
+ "scanSurfaceStructured: declared resource cannot be inspected",
614
+ { path: normalized, cause: cause && cause.message ? cause.message : String(cause) },
615
+ );
616
+ }
617
+ if (stat.isSymbolicLink()) {
618
+ let target = "<unreadable>";
619
+ try {
620
+ target = readlinkSync(fullPath);
621
+ } catch {
622
+ // readlink failure is itself evidence: never follow, only record.
623
+ }
624
+ throw scanViolation(normalized, STRUCTURED_SCAN_RULES.SYMLINK_FORBIDDEN, `target: ${target}`);
625
+ }
626
+ await resolveContained(root, relPath);
627
+ const readResult = await readFileContained(root, relPath);
628
+ const raw = Buffer.isBuffer(readResult) ? readResult : Buffer.from(readResult);
629
+ bytes += raw.length;
630
+ scanned.push(normalized);
631
+
632
+ if (looksBinary(raw)) {
633
+ if (!ctx.binaryPolicy.approvedPaths.has(normalized)) {
634
+ throw scanViolation(normalized, STRUCTURED_SCAN_RULES.BINARY_UNCLASSIFIED, "<non-text control byte; fail-closed>");
635
+ }
636
+ continue;
637
+ }
638
+
639
+ const adapter = findAdapter(ctx, normalized);
640
+ if (adapter !== null) {
641
+ const implementation = BUILTIN_ADAPTERS[adapter.name];
642
+ if (!implementation) {
643
+ throw scanViolation(normalized, STRUCTURED_SCAN_RULES.FORMAT_ADAPTER_UNKNOWN, `unimplemented adapter ${adapter.name}, fail-closed`);
644
+ }
645
+ await implementation(ctx, normalized, raw);
646
+ continue;
647
+ }
648
+ scanStructure(ctx, normalized, raw);
649
+ }
650
+
651
+ return Object.freeze({ scanned, bytes, policy: structuredClone(policy) });
652
+ }