skill-family-harness-node 0.1.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/CODE_OF_CONDUCT.md +131 -0
- package/CONTRIBUTING.md +69 -0
- package/LICENSE +202 -0
- package/README.md +64 -0
- package/SECURITY.md +34 -0
- package/package.json +35 -0
- package/src/atomic.mjs +114 -0
- package/src/closure.mjs +153 -0
- package/src/errors.mjs +75 -0
- package/src/index.mjs +52 -0
- package/src/paths.mjs +285 -0
- package/src/request.mjs +274 -0
- package/src/validation.mjs +116 -0
- package/src/workspace.mjs +119 -0
package/src/closure.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
|
|
4
|
+
import { resolveContained, readFileContained } from "./paths.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resource closure and digests.
|
|
8
|
+
*
|
|
9
|
+
* A closure bounds the read/write scope of an operation: the normalized,
|
|
10
|
+
* containment-verified set of input and output resources, each with a
|
|
11
|
+
* deterministic sha256 content digest, plus one digest over the whole set.
|
|
12
|
+
*
|
|
13
|
+
* Determinism rules: entries are de-duplicated on their normalized relative
|
|
14
|
+
* path, sorted by UTF-16 code units, and serialized with a fixed key order;
|
|
15
|
+
* equal resource sets always produce the equal closure digest.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const CLOSURE_KIND = "skill-family.resource-closure";
|
|
19
|
+
const CLOSURE_SCHEMA_VERSION = 1;
|
|
20
|
+
const DIGEST_ALGORITHM = "sha256";
|
|
21
|
+
const ROLES = Object.freeze(["input", "output"]);
|
|
22
|
+
|
|
23
|
+
/** sha256 hex digest of raw bytes. */
|
|
24
|
+
export function digestBytes(bytes) {
|
|
25
|
+
return createHash(DIGEST_ALGORITHM).update(bytes).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeResourcePath(relPath) {
|
|
29
|
+
if (typeof relPath !== "string" || relPath.length === 0 || relPath.includes("\0")) {
|
|
30
|
+
throw new TypeError("computeResourceClosure: every resource path must be a non-empty string");
|
|
31
|
+
}
|
|
32
|
+
const normalized = path.posix.normalize(relPath.replaceAll("\\", "/"));
|
|
33
|
+
if (normalized === ".." || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
|
|
34
|
+
// Lexical escape inside a closure declaration: reject before any IO.
|
|
35
|
+
throw mechanismError(
|
|
36
|
+
HARNESS_ERROR_KINDS.PATH_TRAVERSAL,
|
|
37
|
+
"closure resource path leaves the workspace root",
|
|
38
|
+
{ input: relPath },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return normalized === "." ? "" : normalized;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Computes the resource closure of `resources` relative to `root`.
|
|
46
|
+
*
|
|
47
|
+
* resources: [{ path, role }] with role "input" | "output".
|
|
48
|
+
* - input entries must exist; their bytes are read through the containment
|
|
49
|
+
* layer and digested (missing input => SFC2004 missing-resource).
|
|
50
|
+
* - output entries may not exist yet; existing outputs are digested,
|
|
51
|
+
* absent outputs are recorded with exists=false and digest=null.
|
|
52
|
+
*
|
|
53
|
+
* Returns { schemaVersion, kind, digestAlgorithm, digest, resources } where
|
|
54
|
+
* resources is the sorted array of { path, role, exists, sha256 }.
|
|
55
|
+
*/
|
|
56
|
+
export async function computeResourceClosure({ root, resources } = {}) {
|
|
57
|
+
if (!root || typeof root !== "string") {
|
|
58
|
+
throw new TypeError("computeResourceClosure: root must be a directory path string");
|
|
59
|
+
}
|
|
60
|
+
if (!Array.isArray(resources)) {
|
|
61
|
+
throw new TypeError("computeResourceClosure: resources must be an array");
|
|
62
|
+
}
|
|
63
|
+
const byPath = new Map();
|
|
64
|
+
for (const entry of resources) {
|
|
65
|
+
if (!entry || typeof entry !== "object") {
|
|
66
|
+
throw new TypeError("computeResourceClosure: every resource entry must be an object");
|
|
67
|
+
}
|
|
68
|
+
if (!ROLES.includes(entry.role)) {
|
|
69
|
+
throw new TypeError(
|
|
70
|
+
`computeResourceClosure: role must be one of ${ROLES.join(", ")}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const normalized = normalizeResourcePath(entry.path);
|
|
74
|
+
if (normalized === "") {
|
|
75
|
+
throw mechanismError(
|
|
76
|
+
HARNESS_ERROR_KINDS.INVALID_PATH,
|
|
77
|
+
"closure resource path must address a resource strictly inside the workspace root",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const existing = byPath.get(normalized);
|
|
81
|
+
if (existing && existing.role !== entry.role) {
|
|
82
|
+
throw mechanismError(
|
|
83
|
+
HARNESS_ERROR_KINDS.CLOSURE_CONFLICT,
|
|
84
|
+
`resource declared with conflicting roles: ${normalized}`,
|
|
85
|
+
{ path: normalized },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (!existing) {
|
|
89
|
+
byPath.set(normalized, { path: normalized, role: entry.role });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const records = [];
|
|
94
|
+
for (const normalized of [...byPath.keys()].sort()) {
|
|
95
|
+
const record = byPath.get(normalized);
|
|
96
|
+
// Containment proof: resolve through the same layer as every other
|
|
97
|
+
// filesystem access (traversal, symlink, and realpath checks included).
|
|
98
|
+
await resolveContained(root, normalized);
|
|
99
|
+
if (record.role === "input") {
|
|
100
|
+
const bytes = await readFileContained(root, normalized);
|
|
101
|
+
records.push({ path: normalized, role: record.role, exists: true, sha256: digestBytes(bytes) });
|
|
102
|
+
} else {
|
|
103
|
+
let bytes;
|
|
104
|
+
try {
|
|
105
|
+
bytes = await readFileContained(root, normalized);
|
|
106
|
+
} catch (cause) {
|
|
107
|
+
if (cause && cause.details && cause.details.kind === HARNESS_ERROR_KINDS.MISSING_RESOURCE) {
|
|
108
|
+
records.push({ path: normalized, role: record.role, exists: false, sha256: null });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
throw cause;
|
|
112
|
+
}
|
|
113
|
+
records.push({ path: normalized, role: record.role, exists: true, sha256: digestBytes(bytes) });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const canonical = JSON.stringify({
|
|
118
|
+
kind: CLOSURE_KIND,
|
|
119
|
+
schemaVersion: CLOSURE_SCHEMA_VERSION,
|
|
120
|
+
digestAlgorithm: DIGEST_ALGORITHM,
|
|
121
|
+
resources: records.map((record) => ({
|
|
122
|
+
path: record.path,
|
|
123
|
+
role: record.role,
|
|
124
|
+
exists: record.exists,
|
|
125
|
+
sha256: record.sha256,
|
|
126
|
+
})),
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
schemaVersion: CLOSURE_SCHEMA_VERSION,
|
|
130
|
+
kind: CLOSURE_KIND,
|
|
131
|
+
digestAlgorithm: DIGEST_ALGORITHM,
|
|
132
|
+
digest: digestBytes(Buffer.from(canonical, "utf8")),
|
|
133
|
+
resources: records,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Whether a closure already bounds `relPath` (lexical normalization only, no
|
|
139
|
+
* filesystem access).
|
|
140
|
+
*/
|
|
141
|
+
export function closureContains(closure, relPath) {
|
|
142
|
+
if (!closure || !Array.isArray(closure.resources)) {
|
|
143
|
+
throw new TypeError("closureContains: closure must be a computed closure object");
|
|
144
|
+
}
|
|
145
|
+
let normalized;
|
|
146
|
+
try {
|
|
147
|
+
normalized = normalizeResourcePath(relPath);
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if (normalized === "") return false;
|
|
152
|
+
return closure.resources.some((record) => record.path === normalized);
|
|
153
|
+
}
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ContractsError, isRegisteredErrorCode } from "skill-family-contracts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Harness error policy.
|
|
5
|
+
*
|
|
6
|
+
* The harness never invents error codes: every thrown or reported error
|
|
7
|
+
* carries a code from the frozen contracts registry. Mechanism failures use
|
|
8
|
+
* SFC2004 (EXECUTION_FAILED) whose registered meaning is "the mechanism
|
|
9
|
+
* runtime failed while executing a well-formed operation; details carry the
|
|
10
|
+
* mechanism evidence". The mechanism evidence is the stable `details.kind`
|
|
11
|
+
* value enumerated in HARNESS_ERROR_KINDS.
|
|
12
|
+
*
|
|
13
|
+
* Adding a brand-new SFC code would be a contracts change (the registry file
|
|
14
|
+
* lives in skill-family-contracts), which is outside this package's write
|
|
15
|
+
* set; the SFC2004 + details.kind pairing keeps the public surface stable
|
|
16
|
+
* without touching the frozen registry.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Stable mechanism-failure kinds. Each value appears as `details.kind` on an
|
|
21
|
+
* SFC2004-coded HarnessError or operation-result error entry. The set is
|
|
22
|
+
* frozen for the v1 harness; values are strings so they serialize unchanged.
|
|
23
|
+
*/
|
|
24
|
+
export const HARNESS_ERROR_KINDS = Object.freeze({
|
|
25
|
+
INVALID_PATH: "invalid-path",
|
|
26
|
+
ABSOLUTE_PATH: "absolute-path",
|
|
27
|
+
WINDOWS_DRIVE_PATH: "windows-drive-path",
|
|
28
|
+
WINDOWS_PATH: "windows-path",
|
|
29
|
+
UNC_PATH: "unc-path",
|
|
30
|
+
PATH_TRAVERSAL: "path-traversal",
|
|
31
|
+
SYMLINK_ESCAPE: "symlink-escape",
|
|
32
|
+
REALPATH_ESCAPE: "realpath-escape",
|
|
33
|
+
INVALID_ROOT: "invalid-root",
|
|
34
|
+
ATOMIC_WRITE_FAILED: "atomic-write-failed",
|
|
35
|
+
READ_FAILED: "read-failed",
|
|
36
|
+
MISSING_RESOURCE: "missing-resource",
|
|
37
|
+
WORKSPACE_CREATE_FAILED: "workspace-create-failed",
|
|
38
|
+
WORKSPACE_DISPOSE_FAILED: "workspace-dispose-failed",
|
|
39
|
+
WORKSPACE_DISPOSED: "workspace-disposed",
|
|
40
|
+
CLOSURE_CONFLICT: "closure-conflict",
|
|
41
|
+
UNSUPPORTED_POLICY: "unsupported-policy",
|
|
42
|
+
EXECUTION_FAILED: "execution-failed",
|
|
43
|
+
INVALID_RESULT: "invalid-result",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Coded error for mechanism failures. The code must already exist in the
|
|
48
|
+
* frozen contracts registry; construction with anything else is a
|
|
49
|
+
* programming error and throws a TypeError immediately.
|
|
50
|
+
*/
|
|
51
|
+
export class HarnessError extends ContractsError {
|
|
52
|
+
constructor(code, message, details) {
|
|
53
|
+
if (!isRegisteredErrorCode(code)) {
|
|
54
|
+
throw new TypeError(
|
|
55
|
+
`HarnessError refuses unregistered error code: ${String(code)}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
super(code, message, details);
|
|
59
|
+
this.name = "HarnessError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Builds the canonical mechanism-failure error: SFC2004 with a stable
|
|
65
|
+
* details.kind. Extra structured evidence may be merged into details but can
|
|
66
|
+
* never override the kind.
|
|
67
|
+
*/
|
|
68
|
+
export function mechanismError(kind, message, extraDetails) {
|
|
69
|
+
const values = Object.values(HARNESS_ERROR_KINDS);
|
|
70
|
+
if (!values.includes(kind)) {
|
|
71
|
+
throw new TypeError(`mechanismError: unknown harness error kind: ${String(kind)}`);
|
|
72
|
+
}
|
|
73
|
+
const details = { ...(extraDetails ?? {}), kind };
|
|
74
|
+
return new HarnessError("SFC2004", message, details);
|
|
75
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-family-harness-node: the default Node mechanism runtime for Skill
|
|
3
|
+
* Family engineering contracts.
|
|
4
|
+
*
|
|
5
|
+
* The harness is a thin runtime: it consumes skill-family-contracts (envelopes,
|
|
6
|
+
* schemas, dialect routing, kernel protocol, stable error codes, fixtures)
|
|
7
|
+
* and implements mechanism only — validator caching, contained filesystem
|
|
8
|
+
* access, atomic writes, temporary workspaces, resource closure, and the
|
|
9
|
+
* operation-request -> operation-result pipeline. It owns no business
|
|
10
|
+
* semantics, no orchestration, no git, no network, and no second language.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const HARNESS_CAPABILITIES = Object.freeze([
|
|
14
|
+
"schema-validation",
|
|
15
|
+
"atomic-write",
|
|
16
|
+
"path-containment",
|
|
17
|
+
"temporary-workspace",
|
|
18
|
+
"resource-closure",
|
|
19
|
+
"operation-envelope",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export const HARNESS_EXCLUSIONS = Object.freeze([
|
|
23
|
+
"business-semantics",
|
|
24
|
+
"workflow-orchestration",
|
|
25
|
+
"git-writes",
|
|
26
|
+
"model-calls",
|
|
27
|
+
"release-state",
|
|
28
|
+
"remote-network-access",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export { HarnessError, HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
|
|
32
|
+
|
|
33
|
+
export { classifyPathInput, resolveContained, readFileContained } from "./paths.mjs";
|
|
34
|
+
|
|
35
|
+
export { writeFileAtomic } from "./atomic.mjs";
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
TemporaryWorkspace,
|
|
39
|
+
createTemporaryWorkspace,
|
|
40
|
+
withTemporaryWorkspace,
|
|
41
|
+
} from "./workspace.mjs";
|
|
42
|
+
|
|
43
|
+
export { digestBytes, computeResourceClosure, closureContains } from "./closure.mjs";
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
resolveSchemaContext,
|
|
47
|
+
getValidator,
|
|
48
|
+
validatorCacheSize,
|
|
49
|
+
validateContractDocument,
|
|
50
|
+
} from "./validation.mjs";
|
|
51
|
+
|
|
52
|
+
export { parseRequest, processRequest } from "./request.mjs";
|
package/src/paths.mjs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { lstat, readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Path containment.
|
|
8
|
+
*
|
|
9
|
+
* Every filesystem operation in the harness goes through resolveContained,
|
|
10
|
+
* which rejects three escape classes with stable, distinct kinds (all under
|
|
11
|
+
* the registered SFC2004 code):
|
|
12
|
+
*
|
|
13
|
+
* 1. path-traversal — the lexically resolved target leaves the root
|
|
14
|
+
* (`..` segments, or any absolute input).
|
|
15
|
+
* 2. symlink-escape — the final path component is a symbolic link whose
|
|
16
|
+
* real target lies outside the root.
|
|
17
|
+
* 3. realpath-escape — the canonical (realpath) resolution of the target,
|
|
18
|
+
* including intermediate directories, lies outside the
|
|
19
|
+
* canonical root; this catches symlinked intermediate
|
|
20
|
+
* directories and chained links.
|
|
21
|
+
*
|
|
22
|
+
* Inputs that only make sense on another operating system (Windows drive
|
|
23
|
+
* paths, UNC paths, backslash separators on POSIX) are rejected before any
|
|
24
|
+
* resolution: ambiguous inputs never reach the filesystem.
|
|
25
|
+
*
|
|
26
|
+
* The containment guarantee is evaluated at call time (TOCTOU note: a
|
|
27
|
+
* process that can mutate the workspace between the check and the follow-up
|
|
28
|
+
* filesystem call is outside this mechanism's control; the guarantee is that
|
|
29
|
+
* the harness itself never performs an unchecked access).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const WINDOWS_DRIVE_PATTERN = /^[A-Za-z]:/;
|
|
33
|
+
const WINDOWS_UNC_PATTERN = /^\\\\/;
|
|
34
|
+
const POSIX_UNC_PATTERN = /^\/\//;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Classifies one candidate relative path without touching the filesystem.
|
|
38
|
+
* Returns { ok: true } or { ok: false, kind } where kind is one of the
|
|
39
|
+
* stable HARNESS_ERROR_KINDS values. Pure function of (input, platform), so
|
|
40
|
+
* the Windows-facing rules are testable on any host.
|
|
41
|
+
*/
|
|
42
|
+
export function classifyPathInput(input, platform = process.platform) {
|
|
43
|
+
if (typeof input !== "string") {
|
|
44
|
+
return { ok: false, kind: HARNESS_ERROR_KINDS.INVALID_PATH };
|
|
45
|
+
}
|
|
46
|
+
if (input.length === 0) {
|
|
47
|
+
return { ok: false, kind: HARNESS_ERROR_KINDS.INVALID_PATH };
|
|
48
|
+
}
|
|
49
|
+
if (input.includes("\0")) {
|
|
50
|
+
return { ok: false, kind: HARNESS_ERROR_KINDS.INVALID_PATH };
|
|
51
|
+
}
|
|
52
|
+
if (platform === "win32") {
|
|
53
|
+
// Native separators are fine on Windows; containment is decided by the
|
|
54
|
+
// lexical + real checks. UNC paths address remote machines and are never
|
|
55
|
+
// containable; rooted paths (`\x`, `/x`, `<drive>:\x`) are absolute.
|
|
56
|
+
if (WINDOWS_UNC_PATTERN.test(input)) {
|
|
57
|
+
return { ok: false, kind: HARNESS_ERROR_KINDS.UNC_PATH };
|
|
58
|
+
}
|
|
59
|
+
if (input.startsWith("\\") || input.startsWith("/")) {
|
|
60
|
+
return { ok: false, kind: HARNESS_ERROR_KINDS.ABSOLUTE_PATH };
|
|
61
|
+
}
|
|
62
|
+
if (WINDOWS_DRIVE_PATTERN.test(input)) {
|
|
63
|
+
// `<drive>:\x` is absolute; the root-relative check would reject it too, but
|
|
64
|
+
// the explicit kind keeps the interception observable.
|
|
65
|
+
if (input.charAt(1) === ":" && /[\\/]/.test(input.charAt(2))) {
|
|
66
|
+
return { ok: false, kind: HARNESS_ERROR_KINDS.ABSOLUTE_PATH };
|
|
67
|
+
}
|
|
68
|
+
// Drive-relative forms (`C:foo`) depend on a per-drive working
|
|
69
|
+
// directory that this runtime never tracks: ambiguous, therefore
|
|
70
|
+
// rejected.
|
|
71
|
+
return { ok: false, kind: HARNESS_ERROR_KINDS.WINDOWS_DRIVE_PATH };
|
|
72
|
+
}
|
|
73
|
+
return { ok: true };
|
|
74
|
+
}
|
|
75
|
+
// POSIX (and every non-Windows host): a path is root-relative or nothing.
|
|
76
|
+
if (WINDOWS_DRIVE_PATTERN.test(input)) {
|
|
77
|
+
return { ok: false, kind: HARNESS_ERROR_KINDS.WINDOWS_DRIVE_PATH };
|
|
78
|
+
}
|
|
79
|
+
if (WINDOWS_UNC_PATTERN.test(input)) {
|
|
80
|
+
return { ok: false, kind: HARNESS_ERROR_KINDS.UNC_PATH };
|
|
81
|
+
}
|
|
82
|
+
if (POSIX_UNC_PATTERN.test(input)) {
|
|
83
|
+
return { ok: false, kind: HARNESS_ERROR_KINDS.UNC_PATH };
|
|
84
|
+
}
|
|
85
|
+
if (input.includes("\\")) {
|
|
86
|
+
// Backslash is a Windows separator; on POSIX it would silently become
|
|
87
|
+
// part of a filename. Cross-platform inputs must be unambiguous.
|
|
88
|
+
return { ok: false, kind: HARNESS_ERROR_KINDS.WINDOWS_PATH };
|
|
89
|
+
}
|
|
90
|
+
if (path.posix.isAbsolute(input)) {
|
|
91
|
+
return { ok: false, kind: HARNESS_ERROR_KINDS.ABSOLUTE_PATH };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function insideRoot(rootReal, candidate) {
|
|
97
|
+
const rel = path.relative(rootReal, candidate);
|
|
98
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Anchors may coincide with the root itself (e.g. a top-level file whose
|
|
102
|
+
// deepest existing ancestor is the workspace root); targets may not.
|
|
103
|
+
function insideOrEqualRoot(rootReal, candidate) {
|
|
104
|
+
const rel = path.relative(rootReal, candidate);
|
|
105
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function canonicalRoot(root) {
|
|
109
|
+
let rootReal;
|
|
110
|
+
try {
|
|
111
|
+
rootReal = await realpath(root);
|
|
112
|
+
} catch {
|
|
113
|
+
throw mechanismError(
|
|
114
|
+
HARNESS_ERROR_KINDS.INVALID_ROOT,
|
|
115
|
+
"workspace root does not resolve to an existing path",
|
|
116
|
+
{ root: "<opaque>" },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
let rootStat;
|
|
120
|
+
try {
|
|
121
|
+
rootStat = await stat(rootReal);
|
|
122
|
+
} catch {
|
|
123
|
+
throw mechanismError(
|
|
124
|
+
HARNESS_ERROR_KINDS.INVALID_ROOT,
|
|
125
|
+
"workspace root cannot be inspected",
|
|
126
|
+
{ root: "<opaque>" },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (!rootStat.isDirectory()) {
|
|
130
|
+
throw mechanismError(
|
|
131
|
+
HARNESS_ERROR_KINDS.INVALID_ROOT,
|
|
132
|
+
"workspace root is not a directory",
|
|
133
|
+
{ root: "<opaque>" },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return rootReal;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function deepestExistingAncestor(rootReal, absPath) {
|
|
140
|
+
let current = absPath;
|
|
141
|
+
const missing = [];
|
|
142
|
+
while (current.length >= rootReal.length) {
|
|
143
|
+
try {
|
|
144
|
+
await lstat(current);
|
|
145
|
+
return { anchor: current, missing };
|
|
146
|
+
} catch {
|
|
147
|
+
missing.unshift(path.basename(current));
|
|
148
|
+
const parent = path.dirname(current);
|
|
149
|
+
if (parent === current) break;
|
|
150
|
+
current = parent;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Unreachable for paths lexically inside an existing root; keep a coded
|
|
154
|
+
// failure instead of a crash.
|
|
155
|
+
throw mechanismError(
|
|
156
|
+
HARNESS_ERROR_KINDS.INVALID_ROOT,
|
|
157
|
+
"workspace root disappeared during containment resolution",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolves `relPath` against `root` and proves the target stays inside.
|
|
163
|
+
* Returns the absolute, lexically resolved path (strictly inside the root).
|
|
164
|
+
* Throws HarnessError (SFC2004) with a stable details.kind on any violation.
|
|
165
|
+
*/
|
|
166
|
+
export async function resolveContained(root, relPath) {
|
|
167
|
+
const classification = classifyPathInput(relPath);
|
|
168
|
+
if (!classification.ok) {
|
|
169
|
+
throw mechanismError(
|
|
170
|
+
classification.kind,
|
|
171
|
+
`path rejected before resolution (kind: ${classification.kind})`,
|
|
172
|
+
{ input: typeof relPath === "string" ? relPath : typeof relPath },
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const rootReal = await canonicalRoot(root);
|
|
176
|
+
const resolved = path.resolve(rootReal, relPath);
|
|
177
|
+
if (resolved === rootReal) {
|
|
178
|
+
throw mechanismError(
|
|
179
|
+
HARNESS_ERROR_KINDS.INVALID_PATH,
|
|
180
|
+
"path must address a resource strictly inside the workspace root",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (!insideRoot(rootReal, resolved)) {
|
|
184
|
+
throw mechanismError(
|
|
185
|
+
HARNESS_ERROR_KINDS.PATH_TRAVERSAL,
|
|
186
|
+
"resolved path leaves the workspace root",
|
|
187
|
+
{ input: relPath },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Escape class 2: the final component itself is a symlink pointing out.
|
|
192
|
+
let finalStat;
|
|
193
|
+
try {
|
|
194
|
+
finalStat = await lstat(resolved);
|
|
195
|
+
} catch (cause) {
|
|
196
|
+
if (cause && cause.code !== "ENOENT" && cause.code !== "ENOTDIR") {
|
|
197
|
+
throw mechanismError(HARNESS_ERROR_KINDS.READ_FAILED, `path cannot be inspected: ${cause.code}`);
|
|
198
|
+
}
|
|
199
|
+
finalStat = null;
|
|
200
|
+
}
|
|
201
|
+
if (finalStat && finalStat.isSymbolicLink()) {
|
|
202
|
+
let realTarget;
|
|
203
|
+
try {
|
|
204
|
+
realTarget = await realpath(resolved);
|
|
205
|
+
} catch {
|
|
206
|
+
// A broken symlink has no determinable target: never usable.
|
|
207
|
+
throw mechanismError(
|
|
208
|
+
HARNESS_ERROR_KINDS.SYMLINK_ESCAPE,
|
|
209
|
+
"symbolic link target does not resolve; treated as an escape",
|
|
210
|
+
{ input: relPath },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (!insideRoot(rootReal, realTarget)) {
|
|
214
|
+
throw mechanismError(
|
|
215
|
+
HARNESS_ERROR_KINDS.SYMLINK_ESCAPE,
|
|
216
|
+
"final path component is a symbolic link escaping the workspace root",
|
|
217
|
+
{ input: relPath },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Escape class 3: canonical resolution (any intermediate symlink chain)
|
|
223
|
+
// must stay inside the canonical root.
|
|
224
|
+
const { anchor, missing } = await deepestExistingAncestor(rootReal, resolved);
|
|
225
|
+
let anchorReal;
|
|
226
|
+
try {
|
|
227
|
+
anchorReal = await realpath(anchor);
|
|
228
|
+
} catch {
|
|
229
|
+
throw mechanismError(
|
|
230
|
+
HARNESS_ERROR_KINDS.REALPATH_ESCAPE,
|
|
231
|
+
"anchor directory vanished during canonical resolution",
|
|
232
|
+
{ input: relPath },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (!insideOrEqualRoot(rootReal, anchorReal)) {
|
|
236
|
+
throw mechanismError(
|
|
237
|
+
HARNESS_ERROR_KINDS.REALPATH_ESCAPE,
|
|
238
|
+
"canonical anchor of the target lies outside the workspace root",
|
|
239
|
+
{ input: relPath },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const canonical = path.join(anchorReal, ...missing);
|
|
243
|
+
if (!insideRoot(rootReal, canonical)) {
|
|
244
|
+
throw mechanismError(
|
|
245
|
+
HARNESS_ERROR_KINDS.REALPATH_ESCAPE,
|
|
246
|
+
"canonical target path leaves the workspace root",
|
|
247
|
+
{ input: relPath },
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return resolved;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Contained read. Returns the file content as a Buffer (utf8 string when
|
|
255
|
+
* encoding is "utf8"). Missing resources surface as SFC2004 with the stable
|
|
256
|
+
* missing-resource kind instead of a raw fs error.
|
|
257
|
+
*/
|
|
258
|
+
export async function readFileContained(root, relPath, { encoding } = {}) {
|
|
259
|
+
const target = await resolveContained(root, relPath);
|
|
260
|
+
try {
|
|
261
|
+
return encoding === undefined
|
|
262
|
+
? await readFile(target)
|
|
263
|
+
: await readFile(target, encoding);
|
|
264
|
+
} catch (cause) {
|
|
265
|
+
if (cause && cause.code === "ENOENT") {
|
|
266
|
+
throw mechanismError(
|
|
267
|
+
HARNESS_ERROR_KINDS.MISSING_RESOURCE,
|
|
268
|
+
"contained resource does not exist",
|
|
269
|
+
{ input: relPath },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
if (cause && cause.code === "EISDIR") {
|
|
273
|
+
throw mechanismError(
|
|
274
|
+
HARNESS_ERROR_KINDS.READ_FAILED,
|
|
275
|
+
"contained resource is a directory",
|
|
276
|
+
{ input: relPath },
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
throw mechanismError(
|
|
280
|
+
HARNESS_ERROR_KINDS.READ_FAILED,
|
|
281
|
+
`contained read failed: ${cause && cause.code ? cause.code : "unknown"}`,
|
|
282
|
+
{ input: relPath },
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|