@crewhaus/tool-lifecycle 0.7.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.
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Walking and fingerprinting a directory tree.
3
+ *
4
+ * Two tools need to say "this is exactly what moved, and it is still the same
5
+ * bytes afterwards": `HarnessRetire` (which archives a harness's `.crewhaus`
6
+ * and cannot get it back) and `StoreMigrate` (which must be able to report a
7
+ * half-finished migration rather than a boolean). Both answers come from
8
+ * here.
9
+ *
10
+ * Three properties the callers depend on:
11
+ *
12
+ * 1. A walk that hit anything it could not read comes back `complete:
13
+ * false` with the reason. A manifest with a hole in it must never be
14
+ * presented as a verified one — that is the failure this package exists
15
+ * to avoid, at the exact moment the original is about to be destroyed.
16
+ * 2. Symlinks are recorded, never followed. Following them would walk out
17
+ * of the tree (and could loop), and the archive move preserves the link
18
+ * itself, so the link's TARGET is not what is being fingerprinted.
19
+ * 3. The budgets are checked while walking, not after. A tree that blows
20
+ * the file or byte budget stops the walk and says so, rather than
21
+ * hashing a hundred gigabytes because nobody looked first.
22
+ */
23
+ import { createHash } from "node:crypto";
24
+ import { lstatSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
25
+ import * as path from "node:path";
26
+ import { fail } from "./result";
27
+ const toPosix = (p) => (path.sep === "/" ? p : p.split(path.sep).join("/"));
28
+ /**
29
+ * Walk `root` depth-first in sorted order.
30
+ *
31
+ * Sorted because a result that depends on `readdir` order is a result that
32
+ * differs between two machines looking at the same directory, and because the
33
+ * manifest this produces is compared byte-for-byte against a later walk.
34
+ */
35
+ export function walkTree(root, opts) {
36
+ const entries = [];
37
+ const unreadable = [];
38
+ let fileCount = 0;
39
+ let totalBytes = 0;
40
+ let oldestMtimeMs;
41
+ let newestMtimeMs;
42
+ let budgetExceeded;
43
+ const visit = (abs, rel) => {
44
+ if (budgetExceeded !== undefined)
45
+ return;
46
+ let names;
47
+ try {
48
+ names = readdirSync(abs).sort();
49
+ }
50
+ catch (err) {
51
+ unreadable.push({
52
+ rel: rel === "" ? "." : rel,
53
+ reason: `directory could not be listed (${err.code ?? "unknown error"})`,
54
+ });
55
+ return;
56
+ }
57
+ for (const name of names) {
58
+ if (budgetExceeded !== undefined)
59
+ return;
60
+ const childAbs = path.join(abs, name);
61
+ const childRel = toPosix(rel === "" ? name : `${rel}/${name}`);
62
+ let stat;
63
+ try {
64
+ // lstat, not stat: a symlink is recorded as itself. `stat` would
65
+ // report its target's size and could be made to point anywhere.
66
+ stat = lstatSync(childAbs);
67
+ }
68
+ catch (err) {
69
+ unreadable.push({
70
+ rel: childRel,
71
+ reason: `could not be stat'd (${err.code ?? "unknown error"})`,
72
+ });
73
+ continue;
74
+ }
75
+ if (stat.isDirectory()) {
76
+ visit(childAbs, childRel);
77
+ continue;
78
+ }
79
+ if (stat.isSymbolicLink()) {
80
+ let target;
81
+ try {
82
+ target = readlinkSync(childAbs);
83
+ }
84
+ catch (err) {
85
+ unreadable.push({
86
+ rel: childRel,
87
+ reason: `symlink could not be read (${err.code ?? "unknown error"})`,
88
+ });
89
+ continue;
90
+ }
91
+ entries.push({ rel: childRel, kind: "symlink", bytes: 0, target });
92
+ continue;
93
+ }
94
+ if (!stat.isFile()) {
95
+ // A socket or a fifo is not data and cannot be hashed; it is recorded
96
+ // so the caller knows the archive will not be a faithful copy.
97
+ entries.push({ rel: childRel, kind: "other", bytes: 0 });
98
+ unreadable.push({ rel: childRel, reason: "not a regular file (not hashed)" });
99
+ continue;
100
+ }
101
+ fileCount += 1;
102
+ totalBytes += stat.size;
103
+ oldestMtimeMs =
104
+ oldestMtimeMs === undefined ? stat.mtimeMs : Math.min(oldestMtimeMs, stat.mtimeMs);
105
+ newestMtimeMs =
106
+ newestMtimeMs === undefined ? stat.mtimeMs : Math.max(newestMtimeMs, stat.mtimeMs);
107
+ if (fileCount > opts.maxFiles) {
108
+ budgetExceeded = `the tree holds more than ${opts.maxFiles} files`;
109
+ return;
110
+ }
111
+ if (totalBytes > opts.maxBytes) {
112
+ budgetExceeded = `the tree holds more than ${opts.maxBytes} bytes`;
113
+ return;
114
+ }
115
+ if (!opts.hash) {
116
+ entries.push({ rel: childRel, kind: "file", bytes: stat.size });
117
+ continue;
118
+ }
119
+ try {
120
+ entries.push({
121
+ rel: childRel,
122
+ kind: "file",
123
+ bytes: stat.size,
124
+ sha256: createHash("sha256").update(readFileSync(childAbs)).digest("hex"),
125
+ });
126
+ }
127
+ catch (err) {
128
+ unreadable.push({
129
+ rel: childRel,
130
+ reason: `could not be read for hashing (${err.code ?? "unknown error"})`,
131
+ });
132
+ entries.push({ rel: childRel, kind: "file", bytes: stat.size });
133
+ }
134
+ }
135
+ };
136
+ try {
137
+ visit(root, "");
138
+ }
139
+ catch (err) {
140
+ return fail("unreadable", `"${root}" could not be walked: ${err.message}`);
141
+ }
142
+ if (budgetExceeded !== undefined) {
143
+ return fail("too-large", `${budgetExceeded} — raise the budget explicitly if that is really what you meant to touch`);
144
+ }
145
+ return {
146
+ ok: true,
147
+ value: {
148
+ root,
149
+ entries,
150
+ fileCount,
151
+ totalBytes,
152
+ ...(oldestMtimeMs !== undefined ? { oldestMtimeMs } : {}),
153
+ ...(newestMtimeMs !== undefined ? { newestMtimeMs } : {}),
154
+ unreadable,
155
+ complete: unreadable.length === 0,
156
+ },
157
+ };
158
+ }
159
+ /**
160
+ * Check a tree against a manifest taken earlier.
161
+ *
162
+ * Used twice: after `HarnessRetire` moves `.crewhaus` into the archive (did
163
+ * every byte arrive?), and after `StoreMigrate` copies a store (is the
164
+ * destination really the source?). An entry the manifest never hashed is
165
+ * reported as unverifiable rather than counted as verified.
166
+ */
167
+ export function verifyAgainst(destRoot, manifest, manifestComplete = true) {
168
+ const mismatched = [];
169
+ let verified = 0;
170
+ for (const entry of manifest) {
171
+ const abs = path.join(destRoot, ...entry.rel.split("/"));
172
+ let stat;
173
+ try {
174
+ stat = lstatSync(abs);
175
+ }
176
+ catch (err) {
177
+ mismatched.push({
178
+ rel: entry.rel,
179
+ problem: "missing",
180
+ detail: `not present at the destination (${err.code ?? "unknown error"})`,
181
+ });
182
+ continue;
183
+ }
184
+ if (entry.kind === "symlink") {
185
+ if (!stat.isSymbolicLink()) {
186
+ mismatched.push({ rel: entry.rel, problem: "kind", detail: "was a symlink, is not now" });
187
+ continue;
188
+ }
189
+ // The link's TARGET is the only content a symlink has, so verifying
190
+ // that a symlink is still a symlink verifies nothing about it. A
191
+ // repointed link would otherwise have counted towards `verified` at the
192
+ // moment the original was being destroyed.
193
+ let target;
194
+ try {
195
+ target = readlinkSync(abs);
196
+ }
197
+ catch (err) {
198
+ mismatched.push({
199
+ rel: entry.rel,
200
+ problem: "unreadable",
201
+ detail: `symlink could not be read at the destination (${err.code ?? "unknown error"})`,
202
+ });
203
+ continue;
204
+ }
205
+ if (entry.target === undefined) {
206
+ mismatched.push({
207
+ rel: entry.rel,
208
+ problem: "unreadable",
209
+ detail: "no target was recorded for this symlink, so it cannot be verified",
210
+ });
211
+ continue;
212
+ }
213
+ if (target !== entry.target) {
214
+ mismatched.push({
215
+ rel: entry.rel,
216
+ problem: "sha256",
217
+ detail: `pointed at "${entry.target}" before, "${target}" now`,
218
+ });
219
+ continue;
220
+ }
221
+ verified += 1;
222
+ continue;
223
+ }
224
+ if (entry.kind !== "file") {
225
+ mismatched.push({
226
+ rel: entry.rel,
227
+ problem: "unreadable",
228
+ detail: "not a regular file — nothing was hashed, so nothing can be verified",
229
+ });
230
+ continue;
231
+ }
232
+ if (stat.size !== entry.bytes) {
233
+ mismatched.push({
234
+ rel: entry.rel,
235
+ problem: "size",
236
+ detail: `${entry.bytes} bytes before, ${stat.size} now`,
237
+ });
238
+ continue;
239
+ }
240
+ if (entry.sha256 === undefined) {
241
+ mismatched.push({
242
+ rel: entry.rel,
243
+ problem: "unreadable",
244
+ detail: "no hash was recorded for this file, so its contents cannot be verified",
245
+ });
246
+ continue;
247
+ }
248
+ let digest;
249
+ try {
250
+ digest = createHash("sha256").update(readFileSync(abs)).digest("hex");
251
+ }
252
+ catch (err) {
253
+ mismatched.push({
254
+ rel: entry.rel,
255
+ problem: "unreadable",
256
+ detail: `could not be read at the destination (${err.code ?? "unknown error"})`,
257
+ });
258
+ continue;
259
+ }
260
+ if (digest !== entry.sha256) {
261
+ mismatched.push({ rel: entry.rel, problem: "sha256", detail: "contents differ" });
262
+ continue;
263
+ }
264
+ verified += 1;
265
+ }
266
+ return {
267
+ checked: manifest.length,
268
+ verified,
269
+ mismatched,
270
+ ok: manifestComplete && mismatched.length === 0,
271
+ };
272
+ }
273
+ /** sha256 of one file, or the reason it could not be hashed. */
274
+ export function hashFile(abs) {
275
+ try {
276
+ return { ok: true, value: createHash("sha256").update(readFileSync(abs)).digest("hex") };
277
+ }
278
+ catch (err) {
279
+ const code = err.code;
280
+ return code === "ENOENT"
281
+ ? fail("missing", "file does not exist")
282
+ : fail("unreadable", `file could not be read (${code ?? "unknown error"})`);
283
+ }
284
+ }
@@ -0,0 +1,42 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ /** Raised when a caller-supplied path resolves outside the workspace root. */
3
+ export declare class ToolPermissionError extends CrewhausError {
4
+ readonly name = "ToolPermissionError";
5
+ readonly toolName: string;
6
+ readonly path: string;
7
+ constructor(toolName: string, attemptedPath: string);
8
+ }
9
+ /**
10
+ * A validated path, in both forms the tools need.
11
+ *
12
+ * `real` has every in-workspace symlink already resolved and is what I/O
13
+ * should use — operating on it closes most of the check-to-use window.
14
+ * `abs` is the lexical resolution, which is what a tool must use when the
15
+ * question is about the link ITSELF (`Stat` reporting a symlink target,
16
+ * `RemovePath` deleting a link rather than its destination).
17
+ */
18
+ export type SafePath = {
19
+ readonly abs: string;
20
+ readonly real: string;
21
+ /** The path as the caller wrote it, for echoing back in results. */
22
+ readonly given: string;
23
+ /** Slash-separated path relative to the workspace root. */
24
+ readonly rel: string;
25
+ };
26
+ /** The workspace root, resolved. Every path is contained within it. */
27
+ export declare function workspaceRoot(): string;
28
+ export declare function resolveSafe(toolName: string, rel: string, root?: string): SafePath;
29
+ /** A path relative to the workspace root, with `/` separators on every OS. */
30
+ export declare function toPosix(p: string): string;
31
+ /**
32
+ * True when `candidate` is `parent` or lives underneath it. Both must already
33
+ * be absolute. Used to keep an archive entry, or a symlink inside an
34
+ * extracted tree, from reaching outside its destination.
35
+ */
36
+ export declare function isInside(parent: string, candidate: string): boolean;
37
+ /**
38
+ * Reject an archive member name that would escape its destination: an
39
+ * absolute path, a Windows drive prefix, or any `..` segment. Checked on the
40
+ * NAME, before extraction, because after extraction the damage is done.
41
+ */
42
+ export declare function archiveEntryEscapes(name: string): boolean;
package/dist/paths.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Path containment — the gate every caller-supplied path in this package
3
+ * passes through before any syscall touches it.
4
+ *
5
+ * The approach is the one `@crewhaus/tool-fs` uses, and for the same reasons:
6
+ * a lexical check alone is fooled by a symlink that lives inside the
7
+ * workspace and points outside it (CWE-59), so the real path is checked too.
8
+ * The workspace root is `process.cwd()`, matching tool-fs, so a harness that
9
+ * trusts one tool's boundary gets the same boundary here.
10
+ */
11
+ import { lstatSync, readlinkSync, realpathSync } from "node:fs";
12
+ import * as path from "node:path";
13
+ import { CrewhausError } from "@crewhaus/errors";
14
+ /** Raised when a caller-supplied path resolves outside the workspace root. */
15
+ export class ToolPermissionError extends CrewhausError {
16
+ name = "ToolPermissionError";
17
+ toolName;
18
+ path;
19
+ constructor(toolName, attemptedPath) {
20
+ super("tool", `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`);
21
+ this.toolName = toolName;
22
+ this.path = attemptedPath;
23
+ }
24
+ }
25
+ /** The workspace root, resolved. Every path is contained within it. */
26
+ export function workspaceRoot() {
27
+ return path.resolve(process.cwd());
28
+ }
29
+ /**
30
+ * Resolve `rel` inside the workspace, or throw `ToolPermissionError`.
31
+ *
32
+ * The leaf is allowed not to exist — every tool here that creates something
33
+ * needs to validate a destination before it is made — so the deepest
34
+ * EXISTING ancestor is realpath'd and the missing tail re-appended. Any
35
+ * failure other than that walk fails closed.
36
+ */
37
+ /**
38
+ * True when the NAME exists, whether or not it leads anywhere.
39
+ *
40
+ * `existsSync` FOLLOWS symlinks, so it answers false for a link whose target
41
+ * is missing — and that is exactly the case that matters: a dangling link is
42
+ * still a door, because `open(…, "w")` through `evil -> /outside/x` CREATES
43
+ * `/outside/x`. Probing with `lstat` keeps the link's name in the part of the
44
+ * path that gets resolved, rather than in the "does not exist yet" tail that
45
+ * is re-appended to the root verbatim and so passes the containment check.
46
+ */
47
+ function nameExists(p) {
48
+ try {
49
+ lstatSync(p);
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /**
57
+ * Where `target` would actually land, with every symlink on the way already
58
+ * followed — including one whose own target does not exist yet.
59
+ *
60
+ * `realpathSync` gives up with ENOENT on a dangling link, which would leave
61
+ * that link unresolved and let it stand in for a plain missing file. So the
62
+ * deepest ancestor that exists as a NAME is resolved, a dangling one is
63
+ * followed a hop by hand, and the components that do not exist are appended.
64
+ * The result is the path an `open` would create, which is the only path worth
65
+ * checking containment against.
66
+ */
67
+ function resolveLocation(target, depth = 0) {
68
+ if (depth > 40)
69
+ throw new Error(`symlink chain at "${target}" is too long to resolve`);
70
+ let probe = target;
71
+ const tail = [];
72
+ while (!nameExists(probe)) {
73
+ tail.unshift(path.basename(probe));
74
+ const parent = path.dirname(probe);
75
+ if (parent === probe)
76
+ break; // reached the filesystem root
77
+ probe = parent;
78
+ }
79
+ let probeReal;
80
+ try {
81
+ probeReal = realpathSync(probe);
82
+ }
83
+ catch (err) {
84
+ if (err.code !== "ENOENT")
85
+ throw err;
86
+ // The name is there but `realpath` cannot finish it: a symlink with a
87
+ // missing target. `readlinkSync` throws EINVAL on anything else, which
88
+ // fails closed.
89
+ const link = readlinkSync(probe);
90
+ // A RELATIVE target resolves against the directory that actually CONTAINS
91
+ // the link, which is not the link's lexical parent when that parent is
92
+ // itself reached through a symlink. `<root>/dirlink/x -> ../y` with
93
+ // `dirlink` pointing out of the root really lands at `<elsewhere>/y`, but
94
+ // measured from the lexical parent it reads as `<root>/y` — an in-root
95
+ // path the caller's path does not lead to. So the parent is made real
96
+ // first. An absolute target ignores the base.
97
+ const base = realpathSync(path.dirname(probe));
98
+ probeReal = resolveLocation(path.resolve(base, link), depth + 1);
99
+ }
100
+ return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
101
+ }
102
+ export function resolveSafe(toolName, rel, root = workspaceRoot()) {
103
+ const rootResolved = path.resolve(root);
104
+ const abs = path.resolve(rootResolved, rel);
105
+ // 1) Lexical containment — rejects `..` and absolute escapes. The trailing
106
+ // separator avoids the `/root` vs `/root-sibling` prefix pitfall.
107
+ if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
108
+ throw new ToolPermissionError(toolName, rel);
109
+ }
110
+ let real;
111
+ try {
112
+ const rootReal = realpathSync(rootResolved);
113
+ real = resolveLocation(abs);
114
+ // 2) Symlink-aware containment. The real path must land inside the real
115
+ // root, so an in-workspace symlink pointing at /etc is refused here.
116
+ if (real !== rootReal && !real.startsWith(`${rootReal}${path.sep}`)) {
117
+ throw new ToolPermissionError(toolName, rel);
118
+ }
119
+ }
120
+ catch (err) {
121
+ if (err instanceof ToolPermissionError)
122
+ throw err;
123
+ throw new ToolPermissionError(toolName, rel);
124
+ }
125
+ return {
126
+ abs,
127
+ real,
128
+ given: rel,
129
+ rel: toPosix(path.relative(rootResolved, abs)),
130
+ };
131
+ }
132
+ /** A path relative to the workspace root, with `/` separators on every OS. */
133
+ export function toPosix(p) {
134
+ return path.sep === "/" ? p : p.split(path.sep).join("/");
135
+ }
136
+ /**
137
+ * True when `candidate` is `parent` or lives underneath it. Both must already
138
+ * be absolute. Used to keep an archive entry, or a symlink inside an
139
+ * extracted tree, from reaching outside its destination.
140
+ */
141
+ export function isInside(parent, candidate) {
142
+ const p = path.resolve(parent);
143
+ const c = path.resolve(candidate);
144
+ return c === p || c.startsWith(`${p}${path.sep}`);
145
+ }
146
+ /**
147
+ * Reject an archive member name that would escape its destination: an
148
+ * absolute path, a Windows drive prefix, or any `..` segment. Checked on the
149
+ * NAME, before extraction, because after extraction the damage is done.
150
+ */
151
+ export function archiveEntryEscapes(name) {
152
+ const normalized = name.replace(/\\/g, "/");
153
+ if (normalized.startsWith("/"))
154
+ return true;
155
+ if (/^[A-Za-z]:/.test(normalized))
156
+ return true;
157
+ return normalized.split("/").some((segment) => segment === "..");
158
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@crewhaus/tool-lifecycle",
3
+ "version": "0.7.0",
4
+ "type": "module",
5
+ "description": "Deterministic harness lifecycle: retirement, store migration, retention enforcement and knowledge sync",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "bun test src"
16
+ },
17
+ "dependencies": {
18
+ "@crewhaus/errors": "0.7.0",
19
+ "@crewhaus/harness-lifecycle": "0.7.0",
20
+ "@crewhaus/tool-builder": "0.7.0",
21
+ "@crewhaus/tool-catalog": "0.7.0",
22
+ "zod": "^3.23.8"
23
+ },
24
+ "devDependencies": {
25
+ "@crewhaus/tool-executor": "workspace:*"
26
+ },
27
+ "license": "Apache-2.0",
28
+ "author": {
29
+ "name": "Max Meier",
30
+ "email": "max@crewhaus.ai",
31
+ "url": "https://crewhaus.ai"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/crewhaus/factory.git",
36
+ "directory": "packages/tool-lifecycle"
37
+ },
38
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-lifecycle#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/crewhaus/factory/issues"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
46
+ }