skill-family-harness-node 0.14.0 → 0.16.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 +36 -0
- package/CHANGELOG.zh-CN.md +36 -0
- package/README.md +21 -16
- package/README.zh-CN.md +21 -16
- package/package.json +2 -2
- package/release-notes/0.15.0.yaml +21 -0
- package/release-notes/0.16.0.yaml +17 -0
- package/src/errors.mjs +2 -0
- package/src/executable-identity.mjs +480 -0
- package/src/filesystem-observation.mjs +181 -5
- package/src/index.mjs +5 -0
- package/src/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
<!-- release-skill:changelog:start version=0.16.0 locale=en baseline=sha256:27fd0f5623d5c93b3076af93231101956233f179dd0cab3d1c74dae891668ea8 -->
|
|
4
|
+
## [0.16.0] - 2026-08-31
|
|
5
|
+
|
|
6
|
+
Harness 0.16.0 clarifies the existing rawSink boundary and keeps output redaction outside Foundation.
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
|
|
10
|
+
- Documents rawSink as a protected 0600 raw stdout/stderr sink in a fresh canonical temporary root, not a redaction API.
|
|
11
|
+
- Explicitly rejects rawStreamSink, transform sinks, generic sanitizers, second runners, and new stdout callbacks for a single-consumer redaction request.
|
|
12
|
+
|
|
13
|
+
### Upgrade Notes
|
|
14
|
+
|
|
15
|
+
Consumers that require zero plaintext on the filesystem must own the threat model and implementation; this release adds no redaction or zero-plaintext guarantee.
|
|
16
|
+
<!-- release-skill:changelog:end version=0.16.0 locale=en -->
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
<!-- release-skill:changelog:start version=0.15.0 locale=en baseline=sha256:8c81395a147ed1e995e21ae0074f66d434b87070ea33350fa3e97cbcb5f2fa8e -->
|
|
20
|
+
## [0.15.0] - 2026-08-29
|
|
21
|
+
|
|
22
|
+
Harness 0.15.0 adds executable identity observation and a best-effort Node tree record mode.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Records symlink target bytes without following links on a stable isolated tree.
|
|
27
|
+
- Rechecks the original script after observing its interpreter and orders paths by Unicode code point.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- Keeps omitted and reject symlink policy on the 0.14 native path.
|
|
32
|
+
|
|
33
|
+
### Upgrade Notes
|
|
34
|
+
|
|
35
|
+
Record mode is candidate best effort; it does not provide a transaction snapshot or hostile concurrent-writer safety.
|
|
36
|
+
<!-- release-skill:changelog:end version=0.15.0 locale=en -->
|
|
37
|
+
|
|
38
|
+
|
|
3
39
|
<!-- release-skill:changelog:start version=0.14.0 locale=en baseline=sha256:23ac2de57433409f1dafac90d7bdd130fd10bd399fd188d7d848a49320b86f30 -->
|
|
4
40
|
## [0.14.0] - 2026-08-28
|
|
5
41
|
|
package/CHANGELOG.zh-CN.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# 变更日志
|
|
2
2
|
|
|
3
|
+
<!-- release-skill:changelog:start version=0.16.0 locale=zh-CN baseline=sha256:31d28348db231171b1ea5754c514d7fe3cc336c1063026af8763d92553d225de -->
|
|
4
|
+
## [0.16.0] - 2026-08-31
|
|
5
|
+
|
|
6
|
+
Harness 0.16.0 澄清现有 rawSink 边界,将输出脱敏留在 Foundation 之外。
|
|
7
|
+
|
|
8
|
+
### 变更
|
|
9
|
+
|
|
10
|
+
- 明确 rawSink 只适用于在 fresh canonical 临时根中以 0600 保存 stdout/stderr 原始文件的场景,不是脱敏接口。
|
|
11
|
+
- 明确拒绝为单一消费者脱敏需求新增 rawStreamSink、transform sink、通用 sanitizer、第二个 runner 或新的 stdout 回调。
|
|
12
|
+
|
|
13
|
+
### 升级说明
|
|
14
|
+
|
|
15
|
+
需要硬性保证文件系统中不出现明文的消费者,应自行定义威胁模型并实现;本版本不增加脱敏或零明文保证。
|
|
16
|
+
<!-- release-skill:changelog:end version=0.16.0 locale=zh-CN -->
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
<!-- release-skill:changelog:start version=0.15.0 locale=zh-CN baseline=sha256:945e9d06ed1cc8178ac13f3ec27ee753ec129d2f54ae59b59c6d8f4ec137d821 -->
|
|
20
|
+
## [0.15.0] - 2026-08-29
|
|
21
|
+
|
|
22
|
+
Harness 0.15.0 增加可执行文件身份观察与 Node 树 best-effort 记录模式。
|
|
23
|
+
|
|
24
|
+
### 新增
|
|
25
|
+
|
|
26
|
+
- 在稳定隔离树上记录 symlink 自身与原始 target bytes,不跟随链接。
|
|
27
|
+
- 观察解释器后重查原脚本,并按 Unicode code point 排序路径。
|
|
28
|
+
|
|
29
|
+
### 变更
|
|
30
|
+
|
|
31
|
+
- 省略或 reject symlinkPolicy 时继续使用 0.14 native 路径。
|
|
32
|
+
|
|
33
|
+
### 升级说明
|
|
34
|
+
|
|
35
|
+
record 模式仍为 candidate best-effort,不提供事务快照或恶意并发写者安全保证。
|
|
36
|
+
<!-- release-skill:changelog:end version=0.15.0 locale=zh-CN -->
|
|
37
|
+
|
|
38
|
+
|
|
3
39
|
<!-- release-skill:changelog:start version=0.14.0 locale=zh-CN baseline=sha256:11857b4e61d5111fab3dbc5eb1afcf99fd8fd6fd41f190bad5a5e16c09633e25 -->
|
|
4
40
|
## [0.14.0] - 2026-08-28
|
|
5
41
|
|
package/README.md
CHANGED
|
@@ -4,28 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
# skill-family-harness-node
|
|
6
6
|
|
|
7
|
-
<!-- release-skill:release-version: 0.
|
|
7
|
+
<!-- release-skill:release-version: 0.16.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.
|
|
12
|
+
**0.16.0** (2026-08-31)
|
|
13
13
|
|
|
14
|
-
Harness 0.
|
|
15
|
-
|
|
16
|
-
**Added**
|
|
17
|
-
|
|
18
|
-
- Adds createAtomicWriteFake({ vector }) with deterministic write, replace, and observation facts and no filesystem side effects.
|
|
19
|
-
- Exposes FOUNDATION_PACKAGE_VERSION for lockstep consumers.
|
|
20
|
-
- Returns canonical realpaths from TemporaryWorkspace.create() and fromBaseline(). A root returned by create() can be passed directly to superviseProcess rawSink while it remains fresh and empty. A materialized non-empty baseline root is rejected by rawSink freshness validation.
|
|
14
|
+
Harness 0.16.0 clarifies the existing rawSink boundary and keeps output redaction outside Foundation.
|
|
21
15
|
|
|
22
16
|
**Changed**
|
|
23
17
|
|
|
24
|
-
- Documents
|
|
18
|
+
- Documents rawSink as a protected 0600 raw stdout/stderr sink in a fresh canonical temporary root, not a redaction API.
|
|
19
|
+
- Explicitly rejects rawStreamSink, transform sinks, generic sanitizers, second runners, and new stdout callbacks for a single-consumer redaction request.
|
|
25
20
|
|
|
26
21
|
**Upgrade Notes**
|
|
27
22
|
|
|
28
|
-
|
|
23
|
+
Consumers that require zero plaintext on the filesystem must own the threat model and implementation; this release adds no redaction or zero-plaintext guarantee.
|
|
29
24
|
<!-- release-skill:managed:end id=latest-release -->
|
|
30
25
|
|
|
31
26
|
## Problem It Solves
|
|
@@ -38,7 +33,7 @@ The Harness consumes `skill-family-contracts` (a workspace dependency), reusing
|
|
|
38
33
|
|
|
39
34
|
## Installation and Minimal Example
|
|
40
35
|
|
|
41
|
-
Version 0.
|
|
36
|
+
Version 0.16.0 is a local candidate. Build all three tarballs into one temporary directory and install those exact files for a candidate check:
|
|
42
37
|
|
|
43
38
|
```sh
|
|
44
39
|
pack_dir="$(mktemp -d)"
|
|
@@ -46,13 +41,13 @@ pack_dir="$(mktemp -d)"
|
|
|
46
41
|
(cd packages/skill-family-harness-node && pnpm pack --pack-destination "$pack_dir")
|
|
47
42
|
(cd packages/skill-family-engineering-kit && pnpm pack --pack-destination "$pack_dir")
|
|
48
43
|
mkdir "$pack_dir/consumer" && (cd "$pack_dir/consumer" && npm init -y)
|
|
49
|
-
(cd "$pack_dir/consumer" && npm install "$pack_dir/skill-family-contracts-0.
|
|
44
|
+
(cd "$pack_dir/consumer" && npm install "$pack_dir/skill-family-contracts-0.16.0.tgz" "$pack_dir/skill-family-harness-node-0.16.0.tgz" "$pack_dir/skill-family-engineering-kit-0.16.0.tgz")
|
|
50
45
|
```
|
|
51
46
|
|
|
52
47
|
After publication, use the registry coordinate:
|
|
53
48
|
|
|
54
49
|
```sh
|
|
55
|
-
npm install skill-family-harness-node@0.
|
|
50
|
+
npm install skill-family-harness-node@0.16.0
|
|
56
51
|
npm info skill-family-harness-node --help
|
|
57
52
|
```
|
|
58
53
|
|
|
@@ -122,6 +117,8 @@ The capability remains **candidate**. Pin all three Foundation packages exactly
|
|
|
122
117
|
| `TemporaryWorkspace` / `createTemporaryWorkspace` / `withTemporaryWorkspace` | Auto-cleanup temporary workspace, cleaned up even on exception paths. |
|
|
123
118
|
| `digestBytes` / `computeResourceClosure` / `closureContains` | Resource closure and deterministic sha256 digest. |
|
|
124
119
|
| `superviseProcess` / `validateTimeoutPolicy` | The single bounded subprocess supervisor. Its 0.11.0 `rawSink` option writes raw stdout/stderr bytes only to a fresh canonical private root, then waits for child/stream close, queued writes, fsync, and handle close. The caller must exclusively control the sink namespace for the entire call; handle protection does not prove stable pathname/root identity. |
|
|
120
|
+
| `observeFilesystemTree` | Observe a complete bound tree. Default/reject preserves the existing UTF-16 member order; record uses code-point order and records symlink target bytes without following them. |
|
|
121
|
+
| `observeExecutableIdentity` | Observe a caller-bound executable, its symlink chain, launch bytes, and any script interpreter chain for immediate pre-spawn re-observation. |
|
|
125
122
|
| `parseRequest` / `processRequest` | Parse `operation-request`, output terminal `operation-result`. |
|
|
126
123
|
| `validateReportModel` / `renderReportMarkdown` / `buildBinding` / `checkReport` | Consume a Contracts-validated report model, deterministically render neutral Markdown, and verify source/result/report binding; does not interpret business output. |
|
|
127
124
|
| `normalizeAdapterSource` / `buildAdapterClosure` / `verifyAdapterBuildManifest` / `materializeAdapterBuild` | Generic text-source closure, full-manifest digest re-verification, and atomic landing of the target set; specific Profile/driver is not in the Harness. |
|
|
@@ -183,7 +180,7 @@ Mechanism failures uniformly throw `SFC2004` (EXECUTION_FAILED), with `details.k
|
|
|
183
180
|
### Do not use when
|
|
184
181
|
|
|
185
182
|
- You need to put file-selection business rules into the Foundation (business rules are owned by the caller).
|
|
186
|
-
- You need host identity policy, host
|
|
183
|
+
- You need host identity policy, host-specific lifecycle plans, remote publication, deleting uninstall, or binary adapter source. Host policy and lifecycle plans belong to Engineering Kit.
|
|
187
184
|
- You need domain audit semantics, retry orchestration, or a compatibility-frozen Quickstart API.
|
|
188
185
|
|
|
189
186
|
### Capability selection
|
|
@@ -244,4 +241,12 @@ Mechanism failures uniformly throw `SFC2004` (EXECUTION_FAILED), with `details.k
|
|
|
244
241
|
|
|
245
242
|
The candidate observeFilesystemTree({ root, rootBinding }) reads complete tree facts. Existing superviseProcess accepts optional per-stream raw-byte caps. Observing a payload does not accept it.
|
|
246
243
|
|
|
247
|
-
|
|
244
|
+
`observeFilesystemTree({ root, rootBinding, symlinkPolicy: { mode: "record" } })` is for a stable installation, cache, or projection tree after the host command has completed, with caller isolation and no concurrent namespace writer during the scan. It records each symlink itself as `targetBase64`, `bytes`, and `statMode`, reads only the raw target bytes, and never follows the target; regular files continue to reuse `readFileBound`. Record is a Node/JS best-effort candidate: observed drift fails closed, but the mode does not provide a transaction snapshot, same-UID malicious-concurrency, or ABA guarantee. A stable-tree result remains a normal usable result. Consumers such as release-skill own invocation timing and domain acceptance rules such as npm `.bin`.
|
|
245
|
+
|
|
246
|
+
Member ordering is mode-specific for compatibility: omitted/reject mode retains the 0.14 UTF-16 relational order, while record mode uses Unicode code-point order. The selected order is also the order used to derive `membersDigest`.
|
|
247
|
+
|
|
248
|
+
When the actual threat includes malicious concurrency, return a minimal upstream capability gap to Foundation for a decision. Do not copy a generic walker, native addon, Harness, schema, Registry, runner, or receipt chain into a skill family, and do not independently upgrade ordinary consumers to a four-platform native implementation.
|
|
249
|
+
|
|
250
|
+
The separate candidate `observeExecutableIdentity({ boundRoots, lookup, interpreterPolicy? })` provides a read-only point-in-time observation of only the caller-explicit roots and lookup paths, for an immediate re-observation before launch. When an `/usr/bin/env` shebang resolves an interpreter through explicit `pathEntries`, the observation preserves the interpreter candidate's complete symlink chain rather than collapsing it to the final file. It is not part of `host-adapter` and does not prove wrapper control flow, ambient `PATH`, fd-exec/kernel image, signature trust, cross-call caching, host support/lifecycle, or domain acceptance; the caller owns those semantics. The candidate entry alone does not qualify a host.
|
|
251
|
+
|
|
252
|
+
Version 0.16.0 is a local source candidate and is not published. Consume the three locally verified tarballs; a version marker, unit test or successful install is not complete contract integration, migration completion, or real-host qualification.
|
package/README.zh-CN.md
CHANGED
|
@@ -5,28 +5,23 @@
|
|
|
5
5
|
|
|
6
6
|
# skill-family-harness-node
|
|
7
7
|
|
|
8
|
-
<!-- release-skill:release-version: 0.
|
|
8
|
+
<!-- release-skill:release-version: 0.16.0 -->
|
|
9
9
|
|
|
10
10
|
Contracts 机制协议的**唯一默认 Node 实现**。这是一个薄运行时(thin runtime):只实现机制协议,不引入业务语义,不做第二语言实现。
|
|
11
11
|
|
|
12
12
|
<!-- release-skill:managed:start id=latest-release -->
|
|
13
|
-
**0.
|
|
13
|
+
**0.16.0** (2026-08-31)
|
|
14
14
|
|
|
15
|
-
Harness 0.
|
|
16
|
-
|
|
17
|
-
**新增**
|
|
18
|
-
|
|
19
|
-
- 新增 `createAtomicWriteFake({ vector })`,提供确定性的写入、替换和观察事实且不触碰文件系统。
|
|
20
|
-
- 公开 `FOUNDATION_PACKAGE_VERSION`,供锁步消费者使用。
|
|
21
|
-
- `TemporaryWorkspace.create()` 与 `fromBaseline()` 返回 canonical realpath。`create()` 返回的根目录在保持 fresh 且为空时,可以直接传给 `superviseProcess` 的 `rawSink`。物化后的非空 baseline 根目录会被 `rawSink` 的 freshness 校验拒绝。
|
|
15
|
+
Harness 0.16.0 澄清现有 rawSink 边界,将输出脱敏留在 Foundation 之外。
|
|
22
16
|
|
|
23
17
|
**变更**
|
|
24
18
|
|
|
25
|
-
-
|
|
19
|
+
- 明确 rawSink 只适用于在 fresh canonical 临时根中以 0600 保存 stdout/stderr 原始文件的场景,不是脱敏接口。
|
|
20
|
+
- 明确拒绝为单一消费者脱敏需求新增 rawStreamSink、transform sink、通用 sanitizer、第二个 runner 或新的 stdout 回调。
|
|
26
21
|
|
|
27
22
|
**升级说明**
|
|
28
23
|
|
|
29
|
-
|
|
24
|
+
需要硬性保证文件系统中不出现明文的消费者,应自行定义威胁模型并实现;本版本不增加脱敏或零明文保证。
|
|
30
25
|
<!-- release-skill:managed:end id=latest-release -->
|
|
31
26
|
|
|
32
27
|
## 解决的问题
|
|
@@ -39,7 +34,7 @@ Harness 消费 `skill-family-contracts`(工作区依赖),复用其方言
|
|
|
39
34
|
|
|
40
35
|
## 安装和最小示例
|
|
41
36
|
|
|
42
|
-
0.
|
|
37
|
+
0.16.0 是本地候选版本。候选验证先把三个包分别打入同一个临时目录,再安装这三个精确 tarball:
|
|
43
38
|
|
|
44
39
|
```sh
|
|
45
40
|
pack_dir="$(mktemp -d)"
|
|
@@ -47,13 +42,13 @@ pack_dir="$(mktemp -d)"
|
|
|
47
42
|
(cd packages/skill-family-harness-node && pnpm pack --pack-destination "$pack_dir")
|
|
48
43
|
(cd packages/skill-family-engineering-kit && pnpm pack --pack-destination "$pack_dir")
|
|
49
44
|
mkdir "$pack_dir/consumer" && (cd "$pack_dir/consumer" && npm init -y)
|
|
50
|
-
(cd "$pack_dir/consumer" && npm install "$pack_dir/skill-family-contracts-0.
|
|
45
|
+
(cd "$pack_dir/consumer" && npm install "$pack_dir/skill-family-contracts-0.16.0.tgz" "$pack_dir/skill-family-harness-node-0.16.0.tgz" "$pack_dir/skill-family-engineering-kit-0.16.0.tgz")
|
|
51
46
|
```
|
|
52
47
|
|
|
53
48
|
发布后再使用 registry 坐标:
|
|
54
49
|
|
|
55
50
|
```sh
|
|
56
|
-
npm install skill-family-harness-node@0.
|
|
51
|
+
npm install skill-family-harness-node@0.16.0
|
|
57
52
|
npm info skill-family-harness-node --help
|
|
58
53
|
```
|
|
59
54
|
|
|
@@ -123,6 +118,8 @@ v2 机制会重算每个 path-backed output 和 evidence Resource 的真实字
|
|
|
123
118
|
| `TemporaryWorkspace` / `createTemporaryWorkspace` / `withTemporaryWorkspace` | 自动清理的临时工作区,异常路径也清理。 |
|
|
124
119
|
| `digestBytes` / `computeResourceClosure` / `closureContains` | 资源闭包与确定性 sha256 摘要。 |
|
|
125
120
|
| `superviseProcess` / `validateTimeoutPolicy` | 唯一的受约束子进程监督器。0.11.0 的 `rawSink` 只向 fresh canonical 私有根写原始 stdout/stderr 字节,并等待子进程、流、排队写入、fsync 与句柄全部关闭。调用方必须在整个调用期间独占 sink 命名空间;句柄保护不证明 pathname 或根目录身份始终不变。 |
|
|
121
|
+
| `observeFilesystemTree` | 观察完整绑定树。默认/reject 保留既有 UTF-16 成员顺序;record 使用码点顺序,记录 symlink target bytes 且不跟随目标。 |
|
|
122
|
+
| `observeExecutableIdentity` | 观察调用方绑定的可执行入口、符号链接链、启动字节和脚本解释器链,供每次 spawn 前紧邻重观察。 |
|
|
126
123
|
| `parseRequest` / `processRequest` | 解析 `operation-request`,输出终态 `operation-result`。 |
|
|
127
124
|
| `validateReportModel` / `renderReportMarkdown` / `buildBinding` / `checkReport` | 消费经 Contracts 验证的 report model,确定性渲染中性 Markdown 并校验来源/结果/报告绑定;不解释业务输出。 |
|
|
128
125
|
| `normalizeAdapterSource` / `buildAdapterClosure` / `verifyAdapterBuildManifest` / `materializeAdapterBuild` | 通用文本 source closure、manifest 全摘要复验和目标集合原子落盘;具体 Profile/driver 不在 Harness。 |
|
|
@@ -184,7 +181,7 @@ v2 机制会重算每个 path-backed output 和 evidence Resource 的真实字
|
|
|
184
181
|
### Do not use when
|
|
185
182
|
|
|
186
183
|
- 需要把文件选择的业务规则放入 Foundation(业务规则由调用方拥有)。
|
|
187
|
-
-
|
|
184
|
+
- 需要宿主身份策略、宿主专属生命周期计划、远端发布、删除式 uninstall 或二进制 adapter source。宿主策略与生命周期计划归 Engineering Kit。
|
|
188
185
|
- 需要领域审计语义、重试编排或兼容性已冻结的 Quickstart API。
|
|
189
186
|
|
|
190
187
|
### Capability selection
|
|
@@ -244,4 +241,12 @@ v2 机制会重算每个 path-backed output 和 evidence Resource 的真实字
|
|
|
244
241
|
|
|
245
242
|
新增候选 observeFilesystemTree({ root, rootBinding }) 读取完整树事实;既有 superviseProcess 支持可选每流原始字节上限。观察完成不等于接受载荷。
|
|
246
243
|
|
|
247
|
-
|
|
244
|
+
`observeFilesystemTree({ root, rootBinding, symlinkPolicy: { mode: "record" } })` 适用于宿主命令完成后、调用方已隔离且扫描期间没有并发 namespace writer 的稳定安装树、缓存树或投影树。它记录 symlink 自身的 `targetBase64`、`bytes` 和 `statMode`,只读取原始 target bytes,绝不跟随 target;普通文件仍复用 `readFileBound`。record 是 Node/JS best-effort candidate:发现扫描期间的漂移会失败关闭,但不提供事务快照、同 UID 恶意并发或 ABA 保证,这不改变稳定树结果的正常使用语义。release-skill 等消费者负责调用时机,并继续负责 npm `.bin` 等领域接受规则。
|
|
245
|
+
|
|
246
|
+
成员排序按模式区分,以保留兼容性:省略策略或使用 reject 时沿用 0.14 的 UTF-16 关系顺序,record 使用 Unicode 码点顺序;`membersDigest` 也按对应顺序推导。
|
|
247
|
+
|
|
248
|
+
真实威胁包含恶意并发时,应形成最小上游能力缺口并交回 Foundation 裁决;不要在技能族内复制通用 walker、native addon、Harness、schema、Registry、runner 或 receipt 链,也不要自行升级为四平台原生实现。
|
|
249
|
+
|
|
250
|
+
另一个独立候选 `observeExecutableIdentity({ boundRoots, lookup, interpreterPolicy? })` 只对调用方显式提供的根和查找路径做逐次只读观察,供正式启动前紧邻重观察。`/usr/bin/env` shebang 通过显式 `pathEntries` 找到解释器时,结果保留解释器候选的完整 symlink chain,不折叠成最终文件。它不属于 `host-adapter`,也不证明 wrapper 控制流、ambient `PATH`、fd-exec/内核映像、签名信任、跨调用缓存、宿主支持/生命周期或领域接受;这些语义仍由调用方负责。候选入口存在不等于宿主已获资格。
|
|
251
|
+
|
|
252
|
+
0.16.0 为本地源码候选,尚未发布。消费本地已验证的三包 tarball;版本标记、单元测试或安装成功都不等于契约接入完成、迁移完成或真实宿主资格。
|
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.
|
|
13
|
+
"skill-family-contracts": "0.16.0",
|
|
14
14
|
"yaml": "2.9.0"
|
|
15
15
|
},
|
|
16
16
|
"description": "Thin Node.js mechanism runtime for Skill Family engineering contracts.",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"url": "https://github.com/ifoohoo/skill-family-harness-node.git"
|
|
50
50
|
},
|
|
51
51
|
"type": "module",
|
|
52
|
-
"version": "0.
|
|
52
|
+
"version": "0.16.0",
|
|
53
53
|
"scripts": {
|
|
54
54
|
"check": "node --test",
|
|
55
55
|
"test": "node --test"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
version: 0.15.0
|
|
2
|
+
date: 2026-08-29
|
|
3
|
+
locales:
|
|
4
|
+
en:
|
|
5
|
+
summary: Harness 0.15.0 adds executable identity observation and a best-effort Node tree record mode.
|
|
6
|
+
changes:
|
|
7
|
+
added:
|
|
8
|
+
- Records symlink target bytes without following links on a stable isolated tree.
|
|
9
|
+
- Rechecks the original script after observing its interpreter and orders paths by Unicode code point.
|
|
10
|
+
changed:
|
|
11
|
+
- Keeps omitted and reject symlink policy on the 0.14 native path.
|
|
12
|
+
upgradeNotes: Record mode is candidate best effort; it does not provide a transaction snapshot or hostile concurrent-writer safety.
|
|
13
|
+
zh-CN:
|
|
14
|
+
summary: Harness 0.15.0 增加可执行文件身份观察与 Node 树 best-effort 记录模式。
|
|
15
|
+
changes:
|
|
16
|
+
added:
|
|
17
|
+
- 在稳定隔离树上记录 symlink 自身与原始 target bytes,不跟随链接。
|
|
18
|
+
- 观察解释器后重查原脚本,并按 Unicode code point 排序路径。
|
|
19
|
+
changed:
|
|
20
|
+
- 省略或 reject symlinkPolicy 时继续使用 0.14 native 路径。
|
|
21
|
+
upgradeNotes: record 模式仍为 candidate best-effort,不提供事务快照或恶意并发写者安全保证。
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
version: 0.16.0
|
|
2
|
+
date: 2026-08-31
|
|
3
|
+
locales:
|
|
4
|
+
en:
|
|
5
|
+
summary: Harness 0.16.0 clarifies the existing rawSink boundary and keeps output redaction outside Foundation.
|
|
6
|
+
changes:
|
|
7
|
+
changed:
|
|
8
|
+
- Documents rawSink as a protected 0600 raw stdout/stderr sink in a fresh canonical temporary root, not a redaction API.
|
|
9
|
+
- Explicitly rejects rawStreamSink, transform sinks, generic sanitizers, second runners, and new stdout callbacks for a single-consumer redaction request.
|
|
10
|
+
upgradeNotes: Consumers that require zero plaintext on the filesystem must own the threat model and implementation; this release adds no redaction or zero-plaintext guarantee.
|
|
11
|
+
zh-CN:
|
|
12
|
+
summary: Harness 0.16.0 澄清现有 rawSink 边界,将输出脱敏留在 Foundation 之外。
|
|
13
|
+
changes:
|
|
14
|
+
changed:
|
|
15
|
+
- 明确 rawSink 只适用于在 fresh canonical 临时根中以 0600 保存 stdout/stderr 原始文件的场景,不是脱敏接口。
|
|
16
|
+
- 明确拒绝为单一消费者脱敏需求新增 rawStreamSink、transform sink、通用 sanitizer、第二个 runner 或新的 stdout 回调。
|
|
17
|
+
upgradeNotes: 需要硬性保证文件系统中不出现明文的消费者,应自行定义威胁模型并实现;本版本不增加脱敏或零明文保证。
|
package/src/errors.mjs
CHANGED
|
@@ -59,6 +59,8 @@ export const HARNESS_ERROR_KINDS = Object.freeze({
|
|
|
59
59
|
HOST_BUILD_FAILED: "host-build-failed",
|
|
60
60
|
HOST_PROBE_FAILED: "host-probe-failed",
|
|
61
61
|
UNTRUSTED_EXECUTABLE: "untrusted-executable",
|
|
62
|
+
EXECUTABLE_NOT_FOUND: "executable-not-found",
|
|
63
|
+
UNSUPPORTED_INTERPRETER: "unsupported-interpreter",
|
|
62
64
|
PORTABLE_PATH_COLLISION: "portable-path-collision",
|
|
63
65
|
MANIFEST_MISMATCH: "manifest-mismatch",
|
|
64
66
|
BASELINE_MISMATCH: "baseline-mismatch",
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { lstat, readlink } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { TextDecoder } from "node:util";
|
|
4
|
+
import { digestDocument } from "skill-family-contracts";
|
|
5
|
+
import { digestBytes } from "./closure.mjs";
|
|
6
|
+
import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
|
|
7
|
+
import { createFilesystemRootBinding, readFileBound } from "./bound-read.mjs";
|
|
8
|
+
|
|
9
|
+
const POSIX = path.posix;
|
|
10
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
|
11
|
+
const MAX_SYMLINK_DEPTH = 40;
|
|
12
|
+
const MAX_SYMLINK_BYTES = 1024 * 1024;
|
|
13
|
+
const ROOT_BINDING_KEYS = "basis,digest,digestAlgorithm,kind";
|
|
14
|
+
const INPUT_KEYS = "boundRoots,interpreterPolicy,lookup";
|
|
15
|
+
const LOOKUP_KEYS = {
|
|
16
|
+
"absolute-path": "mode,path",
|
|
17
|
+
"explicit-path-search": "command,mode,pathEntries",
|
|
18
|
+
};
|
|
19
|
+
const WINDOWS_DRIVE = /^[A-Za-z]:/u;
|
|
20
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
21
|
+
|
|
22
|
+
function ownKeys(value) {
|
|
23
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
24
|
+
? Object.keys(value).sort().join(",")
|
|
25
|
+
: "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertObject(value, label) {
|
|
29
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
30
|
+
throw new TypeError(`${label} must be an object`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertCanonicalAbsolute(value, label) {
|
|
35
|
+
if (typeof value !== "string" || value.length === 0 || !POSIX.isAbsolute(value) ||
|
|
36
|
+
value.includes("\\") || value.includes("\0") || POSIX.normalize(value) !== value ||
|
|
37
|
+
(value.length > 1 && value.endsWith("/")) ||
|
|
38
|
+
value.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
39
|
+
throw new TypeError(`${label} must be a normalized POSIX absolute path`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assertBareCommand(value, label) {
|
|
45
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 256 ||
|
|
46
|
+
value.includes("/") || value.includes("\\") || value.includes("\0") ||
|
|
47
|
+
value === "." || value === ".." || WINDOWS_DRIVE.test(value)) {
|
|
48
|
+
throw new TypeError(`${label} must be a bare command without path separators`);
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertRootBindingShape(binding, label) {
|
|
54
|
+
assertObject(binding, label);
|
|
55
|
+
if (ownKeys(binding) !== ROOT_BINDING_KEYS || binding.kind !== "trusted-filesystem-root-binding" ||
|
|
56
|
+
binding.digestAlgorithm !== "sha256" ||
|
|
57
|
+
binding.basis !== "canonical-realpath-device-inode-type-mode-v1" ||
|
|
58
|
+
typeof binding.digest !== "string" || !SHA256_PATTERN.test(binding.digest)) {
|
|
59
|
+
throw new TypeError(`${label} is not a filesystem-root-binding value`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function assertRootEntries(entries) {
|
|
64
|
+
if (!Array.isArray(entries) || entries.length === 0 || entries.length > 64) {
|
|
65
|
+
throw new TypeError("boundRoots must be a non-empty array");
|
|
66
|
+
}
|
|
67
|
+
const roots = [];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (const [index, entry] of entries.entries()) {
|
|
70
|
+
assertObject(entry, `boundRoots[${index}]`);
|
|
71
|
+
if (ownKeys(entry) !== "root,rootBinding") throw new TypeError("boundRoots entries are closed");
|
|
72
|
+
const root = assertCanonicalAbsolute(entry.root, `boundRoots[${index}].root`);
|
|
73
|
+
assertRootBindingShape(entry.rootBinding, `boundRoots[${index}].rootBinding`);
|
|
74
|
+
if (seen.has(root)) throw new TypeError("boundRoots must not contain duplicate roots");
|
|
75
|
+
seen.add(root);
|
|
76
|
+
roots.push({
|
|
77
|
+
root,
|
|
78
|
+
rootBinding: {
|
|
79
|
+
basis: entry.rootBinding.basis,
|
|
80
|
+
digest: entry.rootBinding.digest,
|
|
81
|
+
digestAlgorithm: entry.rootBinding.digestAlgorithm,
|
|
82
|
+
kind: entry.rootBinding.kind,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const ordered = [...roots].sort((left, right) => left.root.length - right.root.length || left.root.localeCompare(right.root));
|
|
87
|
+
for (let parentIndex = 0; parentIndex < ordered.length; parentIndex += 1) {
|
|
88
|
+
const parent = ordered[parentIndex].root;
|
|
89
|
+
for (let childIndex = parentIndex + 1; childIndex < ordered.length; childIndex += 1) {
|
|
90
|
+
if (pathWithinRoot(parent, ordered[childIndex].root)) {
|
|
91
|
+
throw new TypeError("boundRoots must not contain overlapping roots");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return roots;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function pathWithinRoot(root, value) {
|
|
99
|
+
return root === "/" ? value.startsWith("/") : value === root || value.startsWith(`${root}/`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function assertRootReferenceArray(value, label, roots, { optional = true } = {}) {
|
|
103
|
+
if (value === undefined && optional) return undefined;
|
|
104
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 64) {
|
|
105
|
+
throw new TypeError(`${label} must be a non-empty array of bound root references`);
|
|
106
|
+
}
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
return value.map((root, index) => {
|
|
109
|
+
assertCanonicalAbsolute(root, `${label}[${index}]`);
|
|
110
|
+
if (!roots.some((entry) => entry.root === root)) {
|
|
111
|
+
throw new TypeError(`${label}[${index}] must exactly reference a bound root`);
|
|
112
|
+
}
|
|
113
|
+
if (seen.has(root)) throw new TypeError(`${label} must not contain duplicate roots`);
|
|
114
|
+
seen.add(root);
|
|
115
|
+
return root;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateInput(input) {
|
|
120
|
+
assertObject(input, "observeExecutableIdentity input");
|
|
121
|
+
if (ownKeys(input) !== INPUT_KEYS && ownKeys(input) !== "boundRoots,lookup") {
|
|
122
|
+
throw new TypeError("observeExecutableIdentity input has unknown fields");
|
|
123
|
+
}
|
|
124
|
+
const roots = assertRootEntries(input.boundRoots);
|
|
125
|
+
assertObject(input.lookup, "lookup");
|
|
126
|
+
const mode = input.lookup.mode;
|
|
127
|
+
if (!Object.hasOwn(LOOKUP_KEYS, mode) || ownKeys(input.lookup) !== LOOKUP_KEYS[mode]) {
|
|
128
|
+
throw new TypeError("lookup does not match its closed mode shape");
|
|
129
|
+
}
|
|
130
|
+
const lookup = { mode };
|
|
131
|
+
if (mode === "absolute-path") {
|
|
132
|
+
lookup.path = assertCanonicalAbsolute(input.lookup.path, "lookup.path");
|
|
133
|
+
} else {
|
|
134
|
+
lookup.command = assertBareCommand(input.lookup.command, "lookup.command");
|
|
135
|
+
lookup.pathEntries = assertRootReferenceArray(input.lookup.pathEntries, "lookup.pathEntries", roots, { optional: false });
|
|
136
|
+
}
|
|
137
|
+
const requested = input.lookup.requested ?? (mode === "absolute-path" ? lookup.path : lookup.command);
|
|
138
|
+
if (typeof requested !== "string" || requested.length === 0 || requested.length > 4096 ||
|
|
139
|
+
requested.includes("\0") || requested.includes("\r") || requested.includes("\n")) {
|
|
140
|
+
throw new TypeError("lookup.requested must be a bounded string");
|
|
141
|
+
}
|
|
142
|
+
if (requested !== (mode === "absolute-path" ? lookup.path : lookup.command)) {
|
|
143
|
+
throw new TypeError("lookup.requested must equal the lookup target");
|
|
144
|
+
}
|
|
145
|
+
lookup.requested = requested;
|
|
146
|
+
let interpreterPolicy;
|
|
147
|
+
if (input.interpreterPolicy !== undefined) {
|
|
148
|
+
assertObject(input.interpreterPolicy, "interpreterPolicy");
|
|
149
|
+
const keys = ownKeys(input.interpreterPolicy);
|
|
150
|
+
if (keys !== "absoluteRoots" && keys !== "pathEntries" && keys !== "absoluteRoots,pathEntries") {
|
|
151
|
+
throw new TypeError("interpreterPolicy has unknown fields");
|
|
152
|
+
}
|
|
153
|
+
interpreterPolicy = {
|
|
154
|
+
absoluteRoots: assertRootReferenceArray(input.interpreterPolicy.absoluteRoots, "interpreterPolicy.absoluteRoots", roots),
|
|
155
|
+
pathEntries: assertRootReferenceArray(input.interpreterPolicy.pathEntries, "interpreterPolicy.pathEntries", roots),
|
|
156
|
+
};
|
|
157
|
+
if (interpreterPolicy.absoluteRoots === undefined && interpreterPolicy.pathEntries === undefined) {
|
|
158
|
+
throw new TypeError("interpreterPolicy must provide an interpreter root policy");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { roots, lookup, interpreterPolicy };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function rootForPath(roots, value) {
|
|
165
|
+
const matches = roots.filter(({ root }) => pathWithinRoot(root, value));
|
|
166
|
+
if (matches.length !== 1) return null;
|
|
167
|
+
return matches[0];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function relativeToRoot(root, value) {
|
|
171
|
+
const relative = POSIX.relative(root, value);
|
|
172
|
+
if (!relative || relative.startsWith("../") || relative === ".." || POSIX.isAbsolute(relative) || relative.includes("\\")) return null;
|
|
173
|
+
return relative;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function statIdentity(value) {
|
|
177
|
+
return [value.dev, value.ino, value.mode, value.nlink, value.size].map(String).join(":");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function unsafe(message, details = {}) {
|
|
181
|
+
return mechanismError(HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY, message, details);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function untrusted(message, details = {}) {
|
|
185
|
+
return mechanismError(HARNESS_ERROR_KINDS.UNTRUSTED_EXECUTABLE, message, details);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function notFound(message, details = {}) {
|
|
189
|
+
return mechanismError(HARNESS_ERROR_KINDS.EXECUTABLE_NOT_FOUND, message, details);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function unsupportedInterpreter(message, details = {}) {
|
|
193
|
+
return mechanismError(HARNESS_ERROR_KINDS.UNSUPPORTED_INTERPRETER, message, details);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function decodeTarget(bytes, linkPath) {
|
|
197
|
+
if (bytes.length > MAX_SYMLINK_BYTES) throw untrusted("symbolic link target is too large", { input: linkPath });
|
|
198
|
+
let target;
|
|
199
|
+
try {
|
|
200
|
+
target = decoder.decode(bytes);
|
|
201
|
+
} catch {
|
|
202
|
+
throw untrusted("symbolic link target is not valid UTF-8", { input: linkPath });
|
|
203
|
+
}
|
|
204
|
+
if (target.length === 0 || target.includes("\0") || target.includes("\\") || target.includes("\r") || target.includes("\n")) {
|
|
205
|
+
throw untrusted("symbolic link target cannot be represented as a safe path", { input: linkPath });
|
|
206
|
+
}
|
|
207
|
+
return target;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function captureLink(linkPath, before, relative) {
|
|
211
|
+
let first;
|
|
212
|
+
let second;
|
|
213
|
+
let after;
|
|
214
|
+
try {
|
|
215
|
+
first = Buffer.from(await readlink(linkPath, { encoding: "buffer" }));
|
|
216
|
+
after = await lstat(linkPath, { bigint: false });
|
|
217
|
+
second = Buffer.from(await readlink(linkPath, { encoding: "buffer" }));
|
|
218
|
+
} catch (cause) {
|
|
219
|
+
throw unsafe("symbolic link changed while being observed", {
|
|
220
|
+
input: relative,
|
|
221
|
+
boundReadDisposition: "boundary-indeterminate",
|
|
222
|
+
code: cause?.code,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (!sameStatIdentity(before, after) || !first.equals(second)) {
|
|
226
|
+
throw unsafe("symbolic link changed while being observed", {
|
|
227
|
+
input: relative,
|
|
228
|
+
boundReadDisposition: "boundary-indeterminate",
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
path: linkPath,
|
|
233
|
+
targetBase64: first.toString("base64"),
|
|
234
|
+
bytes: first.length,
|
|
235
|
+
target: decodeTarget(first, linkPath),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function sameStatIdentity(left, right) {
|
|
240
|
+
return statIdentity(left) === statIdentity(right);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function safeSegment(segment) {
|
|
244
|
+
return segment.length > 0 && segment !== "." && segment !== ".." && !segment.includes("\\") &&
|
|
245
|
+
!segment.includes("\0") && !WINDOWS_DRIVE.test(segment) && !segment.includes("/");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Resolves an absolute path one component at a time. Every component is
|
|
250
|
+
* lstat'd, and symbolic links are read as bytes without ever opening or
|
|
251
|
+
* recursing through their targets by descriptor. The returned chain is
|
|
252
|
+
* intentionally local to this invocation; no namespace observation is cached.
|
|
253
|
+
*/
|
|
254
|
+
async function resolveEntry(startPath, roots, visited = new Set(), chain = [], depth = 0) {
|
|
255
|
+
if (depth > MAX_SYMLINK_DEPTH) throw unsafe("symbolic link chain exceeds the fixed depth", { input: startPath });
|
|
256
|
+
const root = rootForPath(roots, startPath);
|
|
257
|
+
if (!root) throw unsafe("executable path leaves the union of bound roots", { input: startPath });
|
|
258
|
+
const relative = relativeToRoot(root.root, startPath);
|
|
259
|
+
if (relative === null) throw mechanismError(HARNESS_ERROR_KINDS.SYMLINK_ESCAPE, "executable path is not contained by its bound root", { input: startPath });
|
|
260
|
+
const segments = relative.split("/");
|
|
261
|
+
let cursor = root.root;
|
|
262
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
263
|
+
if (!safeSegment(segments[index])) throw new TypeError("executable path contains an unsafe segment");
|
|
264
|
+
const memberPath = POSIX.join(cursor, segments[index]);
|
|
265
|
+
let before;
|
|
266
|
+
try {
|
|
267
|
+
before = await lstat(memberPath, { bigint: false });
|
|
268
|
+
} catch (cause) {
|
|
269
|
+
if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return { missing: true, chain };
|
|
270
|
+
throw mechanismError(HARNESS_ERROR_KINDS.READ_FAILED, "executable path could not be inspected", { input: memberPath, code: cause?.code });
|
|
271
|
+
}
|
|
272
|
+
if (before.isSymbolicLink()) {
|
|
273
|
+
if (visited.has(memberPath)) throw unsafe("symbolic link chain loops", { input: memberPath });
|
|
274
|
+
const nextVisited = new Set(visited);
|
|
275
|
+
nextVisited.add(memberPath);
|
|
276
|
+
const link = await captureLink(memberPath, before, memberPath);
|
|
277
|
+
const remainder = segments.slice(index + 1);
|
|
278
|
+
const targetPath = POSIX.isAbsolute(link.target)
|
|
279
|
+
? POSIX.resolve(link.target, ...remainder)
|
|
280
|
+
: POSIX.resolve(POSIX.dirname(memberPath), link.target, ...remainder);
|
|
281
|
+
if (!rootForPath(roots, targetPath)) {
|
|
282
|
+
throw mechanismError(HARNESS_ERROR_KINDS.SYMLINK_ESCAPE, "symbolic link target leaves the union of bound roots", { input: memberPath });
|
|
283
|
+
}
|
|
284
|
+
return resolveEntry(targetPath, roots, nextVisited, [...chain, {
|
|
285
|
+
path: link.path,
|
|
286
|
+
targetBase64: link.targetBase64,
|
|
287
|
+
bytes: link.bytes,
|
|
288
|
+
}], depth + 1);
|
|
289
|
+
}
|
|
290
|
+
if (index < segments.length - 1 && !before.isDirectory()) return { missing: true, chain };
|
|
291
|
+
if (index === segments.length - 1) return { path: memberPath, stat: before, chain };
|
|
292
|
+
cursor = memberPath;
|
|
293
|
+
}
|
|
294
|
+
return { missing: true, chain };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isNativeMagic(bytes) {
|
|
298
|
+
if (bytes.length >= 4 && bytes[0] === 0x7f && bytes[1] === 0x45 && bytes[2] === 0x4c && bytes[3] === 0x46) return true;
|
|
299
|
+
if (bytes.length < 4) return false;
|
|
300
|
+
const magic = bytes.readUInt32BE(0);
|
|
301
|
+
return [0xfeedface, 0xcefaedfe, 0xfeedfacf, 0xcffaedfe, 0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseShebang(bytes) {
|
|
305
|
+
if (bytes.length < 2 || bytes[0] !== 0x23 || bytes[1] !== 0x21) return null;
|
|
306
|
+
let line;
|
|
307
|
+
try {
|
|
308
|
+
line = decoder.decode(bytes.subarray(2, bytes.indexOf(0x0a, 2) === -1 ? bytes.length : bytes.indexOf(0x0a, 2))).replace(/\r$/u, "");
|
|
309
|
+
} catch {
|
|
310
|
+
throw unsupportedInterpreter("shebang is not valid UTF-8");
|
|
311
|
+
}
|
|
312
|
+
if (line.length === 0 || /["'=$]/u.test(line)) throw unsupportedInterpreter("shebang uses unsupported shell syntax");
|
|
313
|
+
const tokens = line.trim().split(/[\t ]+/u).filter(Boolean);
|
|
314
|
+
if (tokens.length === 1 && POSIX.isAbsolute(tokens[0]) && !tokens[0].includes("\\") && !tokens[0].includes("\0")) {
|
|
315
|
+
return { path: assertCanonicalAbsolute(POSIX.normalize(tokens[0]), "shebang interpreter"), request: POSIX.normalize(tokens[0]), shebangArgs: [] };
|
|
316
|
+
}
|
|
317
|
+
if (tokens.length === 2 && tokens[0] === "/usr/bin/env") {
|
|
318
|
+
return { command: assertBareCommand(tokens[1], "shebang env interpreter"), request: tokens[1], shebangArgs: [] };
|
|
319
|
+
}
|
|
320
|
+
throw unsupportedInterpreter("shebang form is outside the fixed interpreter grammar");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function observeResolved(resolved, roots) {
|
|
324
|
+
if (resolved.missing) return null;
|
|
325
|
+
if (!resolved.stat.isFile() || Number(resolved.stat.nlink) !== 1 || (Number(resolved.stat.mode) & 0o111) === 0) {
|
|
326
|
+
throw untrusted("executable entry must be one executable ordinary file", { input: resolved.path });
|
|
327
|
+
}
|
|
328
|
+
const root = rootForPath(roots, resolved.path);
|
|
329
|
+
const relPath = relativeToRoot(root.root, resolved.path);
|
|
330
|
+
if (!root || relPath === null) throw mechanismError(HARNESS_ERROR_KINDS.SYMLINK_ESCAPE, "canonical executable leaves bound roots", { input: resolved.path });
|
|
331
|
+
const receipt = await readFileBound(root.root, relPath, { rootBinding: root.rootBinding });
|
|
332
|
+
if (Number(receipt.statMode) !== Number(resolved.stat.mode) || Number(receipt.bytes) !== Number(resolved.stat.size)) {
|
|
333
|
+
throw unsafe("executable changed during bound observation", { input: resolved.path, boundReadDisposition: "boundary-indeterminate" });
|
|
334
|
+
}
|
|
335
|
+
const bytes = Buffer.from(receipt.content);
|
|
336
|
+
const entryKind = isNativeMagic(bytes) ? "native-binary" : bytes[0] === 0x23 && bytes[1] === 0x21 ? "interpreter-script" : "opaque-executable";
|
|
337
|
+
return {
|
|
338
|
+
path: resolved.path,
|
|
339
|
+
symlinkChain: resolved.chain,
|
|
340
|
+
binding: root.rootBinding,
|
|
341
|
+
relPath,
|
|
342
|
+
sha256: digestBytes(bytes),
|
|
343
|
+
bytes: bytes.length,
|
|
344
|
+
statMode: Number(receipt.statMode),
|
|
345
|
+
entryKind,
|
|
346
|
+
content: bytes,
|
|
347
|
+
stat: resolved.stat,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function assertStableEntry(selectedPath, selected, observed, roots) {
|
|
352
|
+
let after;
|
|
353
|
+
let final;
|
|
354
|
+
try {
|
|
355
|
+
after = await resolveEntry(selectedPath, roots);
|
|
356
|
+
if (after.missing || after.path !== selected.path || !sameStatIdentity(after.stat, selected.stat) ||
|
|
357
|
+
chainKey(after.chain) !== chainKey(selected.chain)) {
|
|
358
|
+
throw new Error("executable namespace changed during bound observation");
|
|
359
|
+
}
|
|
360
|
+
// Re-read the resolved entry through the same bound-read chokepoint. A
|
|
361
|
+
// writer can preserve inode, mode, link count, and byte length while
|
|
362
|
+
// replacing its bytes; the digest comparison below closes that gap only
|
|
363
|
+
// for the interval between these two bound reads.
|
|
364
|
+
final = await observeResolved(after, roots);
|
|
365
|
+
if (!final || final.path !== observed.path ||
|
|
366
|
+
chainKey(final.symlinkChain) !== chainKey(observed.symlinkChain) ||
|
|
367
|
+
!sameStatIdentity(final.stat, observed.stat) ||
|
|
368
|
+
final.sha256 !== observed.sha256) {
|
|
369
|
+
throw new Error("executable content or identity changed during bound observation");
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
throw unsafe("executable changed during bound observation", {
|
|
373
|
+
input: selectedPath,
|
|
374
|
+
boundReadDisposition: "boundary-indeterminate",
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function freezeDeep(value) {
|
|
380
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
381
|
+
for (const child of Object.values(value)) freezeDeep(child);
|
|
382
|
+
Object.freeze(value);
|
|
383
|
+
}
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function chainKey(chain) {
|
|
388
|
+
return JSON.stringify(chain);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function verifyRootBindings(roots) {
|
|
392
|
+
await Promise.all(roots.map(async (entry) => {
|
|
393
|
+
const actual = await createFilesystemRootBinding(entry.root);
|
|
394
|
+
if (actual.digest !== entry.rootBinding.digest) {
|
|
395
|
+
throw unsafe("approved root binding does not match the current root", { input: entry.root, boundReadDisposition: "boundary-indeterminate" });
|
|
396
|
+
}
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Observes a deterministic explicit launch image. PATH is entirely supplied
|
|
402
|
+
* by the caller; process.env.PATH and any ambient executable cache are never
|
|
403
|
+
* consulted. Callers must invoke this again immediately before spawning.
|
|
404
|
+
*/
|
|
405
|
+
export async function observeExecutableIdentity(input = {}) {
|
|
406
|
+
const parsed = validateInput(input);
|
|
407
|
+
await verifyRootBindings(parsed.roots);
|
|
408
|
+
let selected;
|
|
409
|
+
let selectedPath;
|
|
410
|
+
if (parsed.lookup.mode === "absolute-path") {
|
|
411
|
+
selected = await resolveEntry(parsed.lookup.path, parsed.roots);
|
|
412
|
+
if (selected.missing) throw notFound("requested executable was not found", { input: parsed.lookup.path });
|
|
413
|
+
selectedPath = parsed.lookup.path;
|
|
414
|
+
} else {
|
|
415
|
+
for (const rootPath of parsed.lookup.pathEntries) {
|
|
416
|
+
const candidate = POSIX.join(rootPath, parsed.lookup.command);
|
|
417
|
+
const resolved = await resolveEntry(candidate, parsed.roots);
|
|
418
|
+
if (resolved.missing || !resolved.stat?.isFile() || Number(resolved.stat.nlink) !== 1 || (Number(resolved.stat.mode) & 0o111) === 0) continue;
|
|
419
|
+
selected = resolved;
|
|
420
|
+
selectedPath = candidate;
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
if (!selected) throw notFound("no executable matched the explicit path search", { input: parsed.lookup.command });
|
|
424
|
+
}
|
|
425
|
+
const canonical = await observeResolved(selected, parsed.roots);
|
|
426
|
+
if (!canonical) throw notFound("requested executable was not found", { input: parsed.lookup.requested });
|
|
427
|
+
await assertStableEntry(selectedPath, selected, canonical, parsed.roots);
|
|
428
|
+
let interpreter = null;
|
|
429
|
+
let launch = { file: canonical.path, argvPrefix: [] };
|
|
430
|
+
if (canonical.entryKind === "interpreter-script") {
|
|
431
|
+
const shebang = parseShebang(canonical.content);
|
|
432
|
+
if (!shebang) throw unsupportedInterpreter("interpreter-script has no usable shebang");
|
|
433
|
+
const policy = parsed.interpreterPolicy;
|
|
434
|
+
if (!policy) throw unsupportedInterpreter("script interpretation requires an explicit interpreter policy");
|
|
435
|
+
let interpreterPath = shebang.path;
|
|
436
|
+
let interpreterResolved;
|
|
437
|
+
if (shebang.command !== undefined) {
|
|
438
|
+
if (!policy.pathEntries) throw unsupportedInterpreter("env shebang requires explicit interpreter path entries");
|
|
439
|
+
for (const rootPath of policy.pathEntries) {
|
|
440
|
+
const candidate = POSIX.join(rootPath, shebang.command);
|
|
441
|
+
const resolved = await resolveEntry(candidate, parsed.roots);
|
|
442
|
+
if (!resolved.missing && resolved.stat?.isFile() && Number(resolved.stat.nlink) === 1 && (Number(resolved.stat.mode) & 0o111) !== 0) {
|
|
443
|
+
interpreterPath = candidate;
|
|
444
|
+
interpreterResolved = resolved;
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!interpreterPath) throw unsupportedInterpreter("env shebang interpreter was not found");
|
|
449
|
+
} else if (!policy.absoluteRoots || !rootForPath(policy.absoluteRoots.map((root) => parsed.roots.find((entry) => entry.root === root)), interpreterPath)) {
|
|
450
|
+
throw unsupportedInterpreter("absolute shebang interpreter is outside its explicit policy roots", { input: interpreterPath });
|
|
451
|
+
}
|
|
452
|
+
interpreterResolved ??= await resolveEntry(interpreterPath, parsed.roots);
|
|
453
|
+
if (interpreterResolved.missing) throw unsupportedInterpreter("shebang interpreter was not found", { input: interpreterPath });
|
|
454
|
+
interpreter = await observeResolved(interpreterResolved, parsed.roots);
|
|
455
|
+
if (!interpreter || interpreter.entryKind !== "native-binary") {
|
|
456
|
+
throw untrusted("shebang interpreter must be a native binary", { input: interpreterPath });
|
|
457
|
+
}
|
|
458
|
+
await assertStableEntry(interpreterPath, interpreterResolved, interpreter, parsed.roots);
|
|
459
|
+
await assertStableEntry(selectedPath, selected, canonical, parsed.roots);
|
|
460
|
+
launch = { file: interpreter.path, argvPrefix: [canonical.path] };
|
|
461
|
+
interpreter = { request: shebang.request, canonicalEntry: { ...interpreter, content: undefined, stat: undefined }, shebangArgs: shebang.shebangArgs };
|
|
462
|
+
delete interpreter.canonicalEntry.content;
|
|
463
|
+
delete interpreter.canonicalEntry.stat;
|
|
464
|
+
}
|
|
465
|
+
await verifyRootBindings(parsed.roots);
|
|
466
|
+
const output = {
|
|
467
|
+
schemaVersion: 1,
|
|
468
|
+
kind: "skill-family.executable-identity-observation",
|
|
469
|
+
lookup: parsed.lookup.mode === "absolute-path"
|
|
470
|
+
? { requested: parsed.lookup.requested, mode: parsed.lookup.mode, path: parsed.lookup.path, selectedPath: parsed.lookup.path }
|
|
471
|
+
: { requested: parsed.lookup.requested, mode: parsed.lookup.mode, pathEntries: parsed.lookup.pathEntries, selectedPath },
|
|
472
|
+
canonicalEntry: { ...canonical, content: undefined, stat: undefined },
|
|
473
|
+
interpreter,
|
|
474
|
+
launch,
|
|
475
|
+
};
|
|
476
|
+
delete output.canonicalEntry.content;
|
|
477
|
+
delete output.canonicalEntry.stat;
|
|
478
|
+
output.observationDigest = digestDocument(output);
|
|
479
|
+
return freezeDeep(output);
|
|
480
|
+
}
|
|
@@ -1,18 +1,176 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { lstat, readdir, readlink } from "node:fs/promises";
|
|
2
3
|
import { digestDocument } from "skill-family-contracts";
|
|
3
4
|
import { digestBytes } from "./closure.mjs";
|
|
4
5
|
import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
|
|
5
|
-
import { assertRelativePath, createFilesystemRootBinding } from "./bound-read.mjs";
|
|
6
|
+
import { assertRelativePath, createFilesystemRootBinding, readFileBound } from "./bound-read.mjs";
|
|
6
7
|
import { loadNativeBoundReadAddon } from "./native/loader.mjs";
|
|
7
8
|
|
|
8
9
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
|
10
|
+
const SYMLINK_POLICY_KEYS = ["mode"];
|
|
11
|
+
|
|
12
|
+
function compareUnicodeCodePoints(left, right) {
|
|
13
|
+
const leftPoints = Array.from(left, (character) => character.codePointAt(0));
|
|
14
|
+
const rightPoints = Array.from(right, (character) => character.codePointAt(0));
|
|
15
|
+
const length = Math.min(leftPoints.length, rightPoints.length);
|
|
16
|
+
for (let index = 0; index < length; index += 1) {
|
|
17
|
+
if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index];
|
|
18
|
+
}
|
|
19
|
+
return leftPoints.length - rightPoints.length;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseSymlinkPolicy(symlinkPolicy) {
|
|
23
|
+
if (symlinkPolicy === undefined) return "reject";
|
|
24
|
+
if (symlinkPolicy === null || typeof symlinkPolicy !== "object" || Array.isArray(symlinkPolicy) ||
|
|
25
|
+
Object.keys(symlinkPolicy).sort().join(",") !== SYMLINK_POLICY_KEYS.join(",") ||
|
|
26
|
+
!["record", "reject"].includes(symlinkPolicy.mode)) {
|
|
27
|
+
throw new TypeError('observeFilesystemTree: symlinkPolicy must be { mode: "record"|"reject" }');
|
|
28
|
+
}
|
|
29
|
+
return symlinkPolicy.mode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function recordBoundary(message, input, cause) {
|
|
33
|
+
throw mechanismError(HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY, message, {
|
|
34
|
+
...(input === undefined ? {} : { input }),
|
|
35
|
+
boundReadDisposition: "boundary-indeterminate",
|
|
36
|
+
...(cause === undefined ? {} : { cause: cause?.message ?? String(cause) }),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sameLinkStat(left, right) {
|
|
41
|
+
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode &&
|
|
42
|
+
left.nlink === right.nlink && left.size === right.size && left.mtimeNs === right.mtimeNs &&
|
|
43
|
+
left.ctimeNs === right.ctimeNs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readStableSymlink(relative, absolute) {
|
|
47
|
+
let before;
|
|
48
|
+
try {
|
|
49
|
+
before = await lstat(absolute, { bigint: true });
|
|
50
|
+
if (!before.isSymbolicLink()) recordBoundary("filesystem tree link changed before it could be recorded", relative);
|
|
51
|
+
const first = Buffer.from(await readlink(absolute, { encoding: "buffer" }));
|
|
52
|
+
const middle = await lstat(absolute, { bigint: true });
|
|
53
|
+
const second = Buffer.from(await readlink(absolute, { encoding: "buffer" }));
|
|
54
|
+
const after = await lstat(absolute, { bigint: true });
|
|
55
|
+
if (!sameLinkStat(before, middle) || !sameLinkStat(before, after) ||
|
|
56
|
+
!first.equals(second)) {
|
|
57
|
+
recordBoundary("filesystem tree symbolic link changed while being read", relative);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
path: relative,
|
|
61
|
+
type: "symlink",
|
|
62
|
+
targetBase64: first.toString("base64"),
|
|
63
|
+
bytes: first.length,
|
|
64
|
+
statMode: Number(after.mode),
|
|
65
|
+
};
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
if (cause?.code === "SFC2004") throw cause;
|
|
68
|
+
recordBoundary("filesystem tree symbolic link could not be recorded", relative, cause);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function collectRecordMembers(root, rootBinding, absolute, relativePrefix, members) {
|
|
73
|
+
let directoryBefore;
|
|
74
|
+
try {
|
|
75
|
+
directoryBefore = await lstat(absolute, { bigint: true });
|
|
76
|
+
if (!directoryBefore.isDirectory()) {
|
|
77
|
+
recordBoundary("filesystem tree directory changed before it could be read", relativePrefix || undefined);
|
|
78
|
+
}
|
|
79
|
+
} catch (cause) {
|
|
80
|
+
recordBoundary("filesystem tree directory could not be inspected", relativePrefix || undefined, cause);
|
|
81
|
+
}
|
|
82
|
+
let entries;
|
|
83
|
+
try {
|
|
84
|
+
entries = await readdir(absolute, { withFileTypes: true });
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
recordBoundary("filesystem tree directory could not be enumerated", relativePrefix || undefined, cause);
|
|
87
|
+
}
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const relative = relativePrefix ? path.posix.join(relativePrefix, entry.name) : entry.name;
|
|
90
|
+
assertRelativePath(relative);
|
|
91
|
+
const child = path.join(root, ...relative.split("/"));
|
|
92
|
+
let stats;
|
|
93
|
+
try {
|
|
94
|
+
stats = await lstat(child, { bigint: true });
|
|
95
|
+
} catch (cause) {
|
|
96
|
+
recordBoundary("filesystem tree member could not be inspected", relative, cause);
|
|
97
|
+
}
|
|
98
|
+
if (stats.isSymbolicLink()) {
|
|
99
|
+
members.push(await readStableSymlink(relative, child));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (stats.isDirectory()) {
|
|
103
|
+
members.push({ path: relative, type: "directory", statMode: Number(stats.mode) });
|
|
104
|
+
await collectRecordMembers(root, rootBinding, child, relative, members);
|
|
105
|
+
let after;
|
|
106
|
+
try {
|
|
107
|
+
after = await lstat(child, { bigint: true });
|
|
108
|
+
} catch (cause) {
|
|
109
|
+
recordBoundary("filesystem tree directory changed while being read", relative, cause);
|
|
110
|
+
}
|
|
111
|
+
if (!sameLinkStat(stats, after)) {
|
|
112
|
+
recordBoundary("filesystem tree directory changed while being read", relative);
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (stats.isFile()) {
|
|
117
|
+
if (stats.nlink !== 1n) {
|
|
118
|
+
recordBoundary("filesystem tree contains a multiply-linked regular file", relative);
|
|
119
|
+
}
|
|
120
|
+
let receipt;
|
|
121
|
+
try {
|
|
122
|
+
receipt = await readFileBound(root, relative, { rootBinding });
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
throw cause;
|
|
125
|
+
}
|
|
126
|
+
const bytes = Buffer.from(receipt.content);
|
|
127
|
+
members.push({
|
|
128
|
+
path: relative,
|
|
129
|
+
type: "file",
|
|
130
|
+
sha256: digestBytes(bytes),
|
|
131
|
+
bytes: bytes.length,
|
|
132
|
+
statMode: Number(receipt.statMode),
|
|
133
|
+
contentBase64: bytes.toString("base64"),
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
recordBoundary("filesystem tree contains a special file", relative);
|
|
138
|
+
}
|
|
139
|
+
let directoryAfter;
|
|
140
|
+
try {
|
|
141
|
+
directoryAfter = await lstat(absolute, { bigint: true });
|
|
142
|
+
} catch (cause) {
|
|
143
|
+
recordBoundary("filesystem tree directory changed while being read", relativePrefix || undefined, cause);
|
|
144
|
+
}
|
|
145
|
+
if (!sameLinkStat(directoryBefore, directoryAfter)) {
|
|
146
|
+
recordBoundary("filesystem tree directory changed while being read", relativePrefix || undefined);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function observeFilesystemTreeRecord(root, rootBinding) {
|
|
151
|
+
const members = [];
|
|
152
|
+
await collectRecordMembers(root, rootBinding, root, "", members);
|
|
153
|
+
return members;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function rejectCaseAliasMembers(members) {
|
|
157
|
+
const paths = new Set();
|
|
158
|
+
for (const member of members) {
|
|
159
|
+
const alias = member.path.toLowerCase();
|
|
160
|
+
if (paths.has(alias)) {
|
|
161
|
+
throw mechanismError(HARNESS_ERROR_KINDS.INVALID_PATH, "filesystem tree contains duplicate or case-alias member paths");
|
|
162
|
+
}
|
|
163
|
+
paths.add(alias);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
9
166
|
|
|
10
167
|
/**
|
|
11
|
-
* Observe every
|
|
12
|
-
*
|
|
13
|
-
*
|
|
168
|
+
* Observe every member of one already bound tree. The default reject path
|
|
169
|
+
* keeps the native 0.14 contract; explicit record mode is a Node/JS
|
|
170
|
+
* best-effort observation of symlinks and reuses readFileBound for files.
|
|
14
171
|
*/
|
|
15
|
-
export async function observeFilesystemTree({ root, rootBinding } = {}) {
|
|
172
|
+
export async function observeFilesystemTree({ root, rootBinding, symlinkPolicy } = {}) {
|
|
173
|
+
const mode = parseSymlinkPolicy(symlinkPolicy);
|
|
16
174
|
if (typeof root !== "string" || !path.isAbsolute(root) || path.normalize(root) !== root) {
|
|
17
175
|
throw new TypeError("observeFilesystemTree: root must be a normalized absolute path");
|
|
18
176
|
}
|
|
@@ -26,6 +184,24 @@ export async function observeFilesystemTree({ root, rootBinding } = {}) {
|
|
|
26
184
|
boundReadDisposition: "boundary-indeterminate",
|
|
27
185
|
});
|
|
28
186
|
}
|
|
187
|
+
if (mode === "record") {
|
|
188
|
+
const members = await observeFilesystemTreeRecord(root, rootBinding);
|
|
189
|
+
rejectCaseAliasMembers(members);
|
|
190
|
+
const endBinding = await createFilesystemRootBinding(root);
|
|
191
|
+
if (endBinding.digest !== rootBinding.digest) {
|
|
192
|
+
throw mechanismError(HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY, "root identity changed during filesystem tree observation", {
|
|
193
|
+
boundReadDisposition: "boundary-indeterminate",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
members.sort((left, right) => compareUnicodeCodePoints(left.path, right.path));
|
|
197
|
+
return Object.freeze({
|
|
198
|
+
schemaVersion: 1,
|
|
199
|
+
kind: "skill-family.filesystem-tree-observation",
|
|
200
|
+
rootBinding,
|
|
201
|
+
members: Object.freeze(members.map((member) => Object.freeze(member))),
|
|
202
|
+
membersDigest: digestDocument(members),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
29
205
|
const { addon } = await loadNativeBoundReadAddon();
|
|
30
206
|
if (typeof addon.observeFilesystemTreeNative !== "function") {
|
|
31
207
|
throw mechanismError(HARNESS_ERROR_KINDS.UNSUPPORTED_PLATFORM, "native filesystem observation is unavailable in this prebuild");
|
package/src/index.mjs
CHANGED
|
@@ -30,6 +30,7 @@ export const HARNESS_CAPABILITIES = Object.freeze([
|
|
|
30
30
|
"token-estimation",
|
|
31
31
|
"upper-bound-guard",
|
|
32
32
|
"supervise-process",
|
|
33
|
+
"executable-identity-observation",
|
|
33
34
|
// Foundation capability completion (FG-1/FG-2): strict authority reads and
|
|
34
35
|
// pre-persistence URL credential redaction.
|
|
35
36
|
"strict-read",
|
|
@@ -55,6 +56,10 @@ export { classifyPathInput, resolveContained, readFileContained } from "./paths.
|
|
|
55
56
|
// descriptor-relative traversal and same-descriptor fstat/read checks.
|
|
56
57
|
export { observeFilesystemTree } from "./filesystem-observation.mjs";
|
|
57
58
|
|
|
59
|
+
// Deterministic, per-call executable identity observation. The caller owns
|
|
60
|
+
// the explicit roots and PATH entries; no ambient PATH or cache is consulted.
|
|
61
|
+
export { observeExecutableIdentity } from "./executable-identity.mjs";
|
|
62
|
+
|
|
58
63
|
// Strict contained read (FG-1): no-follow, regular-file identity assertion,
|
|
59
64
|
// and a post-read sha256 digest receipt for existing authority files.
|
|
60
65
|
export { readFileStrict } from "./strict-read.mjs";
|
package/src/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Exact Foundation release coordinate shared by the public Harness exports. */
|
|
2
|
-
export const FOUNDATION_PACKAGE_VERSION = "0.
|
|
2
|
+
export const FOUNDATION_PACKAGE_VERSION = "0.16.0";
|