@cairnkeep/cli 2.22.2 → 2.22.4

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +67 -1
  2. package/containers/healthcheck.mjs +27 -33
  3. package/containers/quadlet/cairnkeep.container +1 -1
  4. package/docs/containers.md +12 -0
  5. package/docs/domain-knowledge.md +15 -0
  6. package/docs/learning/CURRICULUM-MAP.md +1 -1
  7. package/docs/learning/FEATURE-GUIDE.md +1 -1
  8. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  9. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  10. package/docs/learning/lessons/L02-installation.md +2 -2
  11. package/docs/learning/lessons/L03-first-project.md +1 -1
  12. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  13. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  14. package/docs/learning/lessons/L21-context-packs.md +1 -1
  15. package/docs/learning/lessons/L22-native-windows.md +2 -2
  16. package/docs/learning/lessons/L23-guided-setup.md +8 -1
  17. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  18. package/docs/learning/lessons/L25-playbooks.md +1 -1
  19. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  20. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  21. package/docs/learning/lessons/L28-security-assurance.md +52 -2
  22. package/docs/learning/video-scripts/V28-security-assurance.md +45 -0
  23. package/docs/native-windows.md +10 -0
  24. package/docs/operating.md +19 -1
  25. package/docs/privacy-and-data-flow.md +6 -0
  26. package/docs/releasing.md +3 -2
  27. package/docs/security-assurance.md +67 -0
  28. package/examples/eval/bundled-fake.json +1 -1
  29. package/mcp-memory-server/dist/capability-registry.js +2 -15
  30. package/mcp-memory-server/dist/context-pack-retrieval.js +6 -6
  31. package/mcp-memory-server/dist/domain-retrieval-provider.js +100 -45
  32. package/mcp-memory-server/dist/eval-plan.js +15 -22
  33. package/mcp-memory-server/dist/eval-runner.js +17 -10
  34. package/mcp-memory-server/dist/eval-workspace.js +82 -23
  35. package/mcp-memory-server/dist/failure-signature.js +29 -8
  36. package/mcp-memory-server/dist/graph-cli.js +25 -10
  37. package/mcp-memory-server/dist/okf.js +4 -9
  38. package/mcp-memory-server/dist/playbook-receipt.js +3 -17
  39. package/mcp-memory-server/dist/skill-store.js +10 -26
  40. package/mcp-memory-server/dist/stable-file.js +85 -0
  41. package/mcp-memory-server/dist/work-evidence-store.js +7 -5
  42. package/omp/extensions/cairnkeep-capture.ts +29 -17
  43. package/package.json +1 -1
  44. package/scripts/lib/opencode-v2-plugin-harness.mjs +20 -3
  45. package/scripts/lib/stable-file.mjs +75 -0
  46. package/scripts/playbook-instructions.mjs +42 -20
  47. package/scripts/setup-core.mjs +5 -36
  48. package/scripts/setup-reconcile.mjs +32 -32
  49. package/scripts/setup.mjs +25 -34
  50. package/scripts/test-ci-sharding.sh +5 -0
  51. package/scripts/test-container-health.mjs +56 -0
  52. package/scripts/test-container.sh +8 -1
  53. package/scripts/test-learning-docs.sh +8 -0
  54. package/scripts/test-omp-staged-security.mjs +110 -0
  55. package/scripts/test-runtime-file-security.mjs +311 -0
  56. package/scripts/test-setup-diagnosis.mjs +97 -0
  57. package/scripts/test-windows-native.mjs +5 -0
  58. package/scripts/verify-omp-capture.mjs +1 -0
  59. package/scripts/windows-platform.mjs +54 -22
@@ -1,10 +1,11 @@
1
- import { createHash } from "node:crypto";
2
- import { constants, existsSync } from "node:fs";
3
- import { chmod, lstat, mkdir, mkdtemp, open, readFile, realpath, writeFile, } from "node:fs/promises";
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { closeSync, constants, existsSync, fchmodSync, fsyncSync, linkSync, lstatSync, openSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { chmod, lstat, mkdir, mkdtemp, realpath, writeFile, } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { canonicalDigest } from "./eval-schema.js";
7
7
  import { EvalProcessError, runBoundedCommand, } from "./eval-process.js";
8
+ import { readStableFile, readStableJson } from "./stable-file.js";
8
9
  const GIT_TIMEOUT_MS = 120_000;
9
10
  const GIT_OUTPUT_LIMIT = 64 * 1024;
10
11
  const CLEANUP_TIMEOUT_MS = 120_000;
@@ -53,24 +54,13 @@ async function validateBundledBinding(plan) {
53
54
  }
54
55
  if (canonicalDigest(plan.task_set) !== plan.task_set_digest)
55
56
  throw new Error("task_set_digest_mismatch");
56
- let handle;
57
57
  let binding;
58
58
  try {
59
- handle = await open(plan.source.binding_path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
60
- const info = await handle.stat();
61
- if (!info.isFile() || info.size > BINDING_LIMIT)
62
- throw new Error("unsafe_bundled_binding");
63
- const bytes = await handle.readFile();
64
- if (bytes.byteLength > BINDING_LIMIT)
65
- throw new Error("unsafe_bundled_binding");
66
- binding = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
59
+ binding = readStableJson(plan.source.binding_path, { label: "Bundled evaluation binding", maxBytes: BINDING_LIMIT });
67
60
  }
68
61
  catch {
69
62
  throw new Error("invalid_bundled_binding");
70
63
  }
71
- finally {
72
- await handle?.close().catch(() => undefined);
73
- }
74
64
  if (!binding || typeof binding !== "object" || Array.isArray(binding))
75
65
  throw new Error("invalid_bundled_binding");
76
66
  const record = binding;
@@ -109,8 +99,10 @@ async function materializeBundledSource(plan, sourcePath) {
109
99
  const info = await lstat(destination);
110
100
  if (info.isSymbolicLink() || !info.isFile())
111
101
  throw new Error("bundled_path_unsafe");
112
- if (!Buffer.from(file.content, "utf8").equals(await readFile(destination)))
102
+ const expected = Buffer.from(file.content, "utf8");
103
+ if (!expected.equals(readStableFile(destination, { label: "Bundled evaluation source", maxBytes: Math.max(1, expected.byteLength) }).bytes)) {
113
104
  throw new Error("bundled_source_mismatch");
105
+ }
114
106
  }
115
107
  if (canonicalDigest(plan.task_set) !== plan.task_set_digest)
116
108
  throw new Error("task_set_digest_mismatch");
@@ -194,17 +186,18 @@ export async function runTaskPreparation(workspace, options = {}) {
194
186
  });
195
187
  }
196
188
  export async function applyEvalWorkspaceOverlay(workspace, overlay) {
189
+ const source = await assertPrivateDirectory(workspace.source_path);
197
190
  const segments = overlay.relative_path.split("/");
198
191
  if (overlay.relative_path.startsWith("/") || overlay.relative_path.endsWith("/")
199
192
  || overlay.relative_path.includes("\\") || segments.some((segment) => segment === "" || segment === "." || segment === "..")
200
193
  || /[\u0000-\u001f\u007f]/.test(overlay.relative_path)) {
201
194
  throw new Error("workspace_overlay_path_invalid");
202
195
  }
203
- const target = resolve(workspace.source_path, overlay.relative_path);
204
- if (!isContained(workspace.source_path, target) || target === workspace.source_path) {
196
+ const target = resolve(source, overlay.relative_path);
197
+ if (!isContained(source, target) || target === source) {
205
198
  throw new Error("workspace_overlay_path_escape");
206
199
  }
207
- let cursor = workspace.source_path;
200
+ let cursor = source;
208
201
  for (const segment of segments.slice(0, -1)) {
209
202
  cursor = join(cursor, segment);
210
203
  try {
@@ -228,19 +221,85 @@ export async function applyEvalWorkspaceOverlay(workspace, overlay) {
228
221
  throw error;
229
222
  }
230
223
  const parent = await realpath(dirname(target));
231
- const source = await realpath(workspace.source_path);
232
224
  if (!isContained(source, parent))
233
225
  throw new Error("workspace_overlay_parent_escape");
234
226
  const bytes = Buffer.from(overlay.content, "utf8");
235
227
  const digest = createHash("sha256").update(bytes).digest("hex");
236
228
  if (digest !== overlay.digest)
237
229
  throw new Error("workspace_overlay_digest_mismatch");
238
- await writeFile(target, bytes, { mode: 0o600 });
239
- await chmod(target, 0o600);
240
- const storedDigest = createHash("sha256").update(await readFile(target)).digest("hex");
230
+ publishOverlay(source, target, bytes);
231
+ const storedDigest = createHash("sha256").update(readStableFile(target, { label: "Evaluation overlay", maxBytes: Math.max(1, bytes.byteLength) }).bytes).digest("hex");
241
232
  if (storedDigest !== overlay.digest)
242
233
  throw new Error("workspace_overlay_write_mismatch");
243
234
  }
235
+ function sameTarget(left, right) {
236
+ return right.isFile() && !right.isSymbolicLink()
237
+ && left.dev === right.dev && left.ino === right.ino && left.size === right.size
238
+ && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs && left.mode === right.mode;
239
+ }
240
+ function overlayTarget(path) {
241
+ try {
242
+ const info = lstatSync(path);
243
+ if (!info.isFile() || info.isSymbolicLink())
244
+ throw new Error("workspace_overlay_target_unsafe");
245
+ return info;
246
+ }
247
+ catch (error) {
248
+ if (error.code === "ENOENT")
249
+ return null;
250
+ throw error;
251
+ }
252
+ }
253
+ function publishOverlay(source, target, bytes) {
254
+ const root = lstatSync(source);
255
+ const parent = lstatSync(dirname(target));
256
+ const before = overlayTarget(target);
257
+ const temporary = join(dirname(target), `.cairn-overlay-${process.pid}-${randomBytes(12).toString("hex")}.tmp`);
258
+ function unchangedBoundary() {
259
+ const rootAfter = lstatSync(source);
260
+ const parentAfter = lstatSync(dirname(target));
261
+ if (!rootAfter.isDirectory() || rootAfter.isSymbolicLink()
262
+ || !parentAfter.isDirectory() || parentAfter.isSymbolicLink()
263
+ || root.dev !== rootAfter.dev || root.ino !== rootAfter.ino
264
+ || parent.dev !== parentAfter.dev || parent.ino !== parentAfter.ino) {
265
+ throw new Error("workspace_overlay_parent_unsafe");
266
+ }
267
+ }
268
+ let descriptor;
269
+ let ownsTemporary = false;
270
+ try {
271
+ unchangedBoundary();
272
+ descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL
273
+ | (constants.O_NOFOLLOW ?? 0) | (process.platform === "win32" ? 0 : constants.O_NONBLOCK ?? 0), 0o600);
274
+ ownsTemporary = true;
275
+ unchangedBoundary();
276
+ writeFileSync(descriptor, bytes);
277
+ fchmodSync(descriptor, 0o600);
278
+ fsyncSync(descriptor);
279
+ closeSync(descriptor);
280
+ descriptor = undefined;
281
+ if (!readStableFile(temporary, { label: "Evaluation overlay temporary", maxBytes: Math.max(1, bytes.byteLength) }).bytes.equals(bytes)) {
282
+ throw new Error("workspace_overlay_write_mismatch");
283
+ }
284
+ unchangedBoundary();
285
+ const current = overlayTarget(target);
286
+ if (before ? !current || !sameTarget(before, current) : current !== null) {
287
+ throw new Error("workspace_overlay_target_changed");
288
+ }
289
+ // Atomic rename never follows the destination file. Exclusive linking
290
+ // also refuses a new destination that appeared after absence was checked.
291
+ if (before)
292
+ renameSync(temporary, target);
293
+ else
294
+ linkSync(temporary, target);
295
+ }
296
+ finally {
297
+ if (descriptor !== undefined)
298
+ closeSync(descriptor);
299
+ if (ownsTemporary)
300
+ rmSync(temporary, { force: true });
301
+ }
302
+ }
244
303
  function verifierReason(code) {
245
304
  return `verifier_${code}`;
246
305
  }
@@ -46,7 +46,30 @@ function normalizeVolatile(value, root) {
46
46
  .replace(/[\t\u00a0 ]+/g, " ")
47
47
  .trim();
48
48
  }
49
- function parseFrames(text, root) {
49
+ // Match each delimited path once. A repeated path-segment group is ambiguous
50
+ // because a segment itself may contain slashes, causing exponential retries.
51
+ function sourcePath(text) {
52
+ for (const match of text.matchAll(/(?:^|[\s,])(\/[^\s,:]*):\d+(?::\d+)?/gm)) {
53
+ if (/\/(?:src|pkg|lib|test|tests)\/[^\s,:]+$/i.test(match[1]))
54
+ return match[1];
55
+ }
56
+ return undefined;
57
+ }
58
+ function withoutSourceSuffix(message) {
59
+ const comma = message.lastIndexOf(",");
60
+ if (comma < 0)
61
+ return message;
62
+ const suffix = message.slice(comma + 1).trimStart();
63
+ if (!suffix.endsWith(":<line>"))
64
+ return message;
65
+ const path = suffix.slice(0, -":<line>".length);
66
+ if (path.toLowerCase() === "<root>" || (!/[\s,]/.test(path)
67
+ && /(?:^|\/)(?:src|pkg|lib|test|tests)\/[^\s,:]+$/i.test(path))) {
68
+ return message.slice(0, comma);
69
+ }
70
+ return message;
71
+ }
72
+ function parseFrames(text, root, source) {
50
73
  const frames = [];
51
74
  const add = (fn, file) => {
52
75
  const normalizedFunction = fn.replace(/^async\s+/, "").trim();
@@ -81,9 +104,8 @@ function parseFrames(text, root) {
81
104
  }
82
105
  match = line.match(/^\d+:\s+(?:0x[0-9a-f]+\s+-\s+)?(.+)$/i);
83
106
  if (match) {
84
- const source = text.match(/(?:^|[\s,])((?:\/[^\s,:]+)*\/(?:src|pkg|lib|test|tests)\/[^\s,:]+):\d+(?::\d+)?/im);
85
107
  if (source)
86
- add(match[1], source[1]);
108
+ add(match[1], source);
87
109
  }
88
110
  }
89
111
  return frames.slice(0, 16);
@@ -96,8 +118,7 @@ function selectMessage(text, root) {
96
118
  const first = lines.find((line) => !/^(?:at\s+|File\s+|stack backtrace:|\d+:\s+(?:<address>|0x))/i.test(line));
97
119
  if (!first)
98
120
  return "unknown failure";
99
- return first
100
- .replace(/,\s*(?:<root>|\/?(?:[^\s,]+\/)*(?:src|pkg|lib|test|tests)\/[^\s,:]+):<line>$/i, "")
121
+ return withoutSourceSuffix(first)
101
122
  .replace(/\s+/g, " ")
102
123
  .trim()
103
124
  .slice(0, 4096);
@@ -114,10 +135,10 @@ function familyFor(message) {
114
135
  }
115
136
  export function buildFailureSignature(text, options = {}) {
116
137
  const normalizedMessage = selectMessage(text, options.root);
117
- const frames = parseFrames(text, options.root);
138
+ const source = sourcePath(text);
139
+ const frames = parseFrames(text, options.root, source);
118
140
  const explicitComponent = options.component ? safePath(options.component, options.root) : "";
119
- const sourceInMessage = text.match(/(?:^|[\s,])((?:\/[^\s,:]+)*\/(?:src|pkg|lib|test|tests)\/[^\s,:]+):\d+(?::\d+)?/im);
120
- const component = explicitComponent || frames[0]?.file || (sourceInMessage ? safePath(sourceInMessage[1], options.root) : "");
141
+ const component = explicitComponent || frames[0]?.file || (source ? safePath(source, options.root) : "");
121
142
  const family = familyFor(normalizedMessage);
122
143
  const normalizedForHash = normalizedMessage.toLocaleLowerCase("en-US");
123
144
  const stackDigest = frames.length > 0 ? hash("stack", frames) : "";
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { randomBytes } from "node:crypto";
4
- import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
6
  import { resolveCapabilityStatus } from "./capability-config.js";
7
7
  import { isGraphCapabilityCompatible } from "./capability-registry.js";
8
+ import { readStableFile } from "./stable-file.js";
8
9
  const INSPECTION_TIMEOUT_MS = 30_000;
9
10
  const BUILD_TIMEOUT_MS = 300_000;
10
11
  const MAX_GRAPH_BYTES = 256 * 1024 * 1024;
@@ -33,14 +34,22 @@ function plural(count, singular) {
33
34
  return `${count} ${singular}${count === 1 ? "" : "s"}`;
34
35
  }
35
36
  async function readBoundedFile(path, maximumBytes) {
36
- const info = await lstat(path);
37
- if (!info.isFile() || info.isSymbolicLink() || info.size > maximumBytes) {
38
- throw new Error("graph artifact is missing, unsafe, or too large.");
37
+ try {
38
+ return readStableFile(path, { label: "graph artifact", maxBytes: maximumBytes }).bytes;
39
+ }
40
+ catch (error) {
41
+ // Optional artifacts need a missing-file result, not an unsafe-file
42
+ // result. This post-failure diagnosis never authorizes another open.
43
+ try {
44
+ await lstat(path);
45
+ }
46
+ catch (inspectionError) {
47
+ if (inspectionError.code === "ENOENT") {
48
+ throw Object.assign(new Error("graph artifact not found."), { code: "ENOENT" });
49
+ }
50
+ }
51
+ throw error;
39
52
  }
40
- const bytes = await readFile(path);
41
- if (bytes.byteLength > maximumBytes)
42
- throw new Error("graph artifact is too large.");
43
- return bytes;
44
53
  }
45
54
  function parseGraph(bytes) {
46
55
  const value = JSON.parse(bytes.toString("utf8"));
@@ -59,8 +68,14 @@ async function readGraph(path) {
59
68
  return parseGraph(await readBoundedFile(path, MAX_GRAPH_BYTES));
60
69
  }
61
70
  catch (error) {
62
- if (error.code === "ENOENT") {
63
- throw new Error("no published graph found; run /graphify build first.");
71
+ // This check explains a failed read; it never authorizes a later open.
72
+ try {
73
+ await lstat(path);
74
+ }
75
+ catch (inspectionError) {
76
+ if (inspectionError.code === "ENOENT") {
77
+ throw new Error("no published graph found; run /graphify build first.");
78
+ }
64
79
  }
65
80
  throw error;
66
81
  }
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { existsSync, lstatSync } from "node:fs";
3
- import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, readdir, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
5
5
  import { parseDocument, stringify } from "yaml";
6
6
  import { z } from "zod";
@@ -8,6 +8,7 @@ import { portablePathCollisionKey, portableRelativePathIssue } from "./path-secu
8
8
  import { hardenPrivatePath } from "./platform-security.js";
9
9
  import { readSharedNoteForExport } from "./note-store.js";
10
10
  import { redactLocalValue } from "./trajectory-redaction.js";
11
+ import { readStableFile } from "./stable-file.js";
11
12
  const MAX_FILE_BYTES = 1024 * 1024;
12
13
  const MAX_ENTRIES = 1024;
13
14
  const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
@@ -256,10 +257,7 @@ export async function indexOkfBundle(directory, selectedPaths, declaredVersion)
256
257
  const absolute = resolve(root, ...entry.raw_path.split("/"));
257
258
  if (!contained(root, absolute))
258
259
  throw new Error(`Unsafe OKF path: ${path}`);
259
- const info = lstatSync(absolute);
260
- if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_FILE_BYTES)
261
- throw new Error(`OKF file is unsafe or too large: ${path}`);
262
- const bytes = await readFile(absolute);
260
+ const { bytes } = readStableFile(absolute, { label: "OKF file", maxBytes: MAX_FILE_BYTES });
263
261
  content.set(path, bytes);
264
262
  const text = utf8(bytes, path);
265
263
  const name = basename(path);
@@ -377,10 +375,7 @@ async function exportMaterial(options) {
377
375
  const absolute = resolve(projectRoot, ...source.split("/"));
378
376
  if (!contained(projectRoot, absolute))
379
377
  throw new Error(`Unsafe OKF export source: ${source}`);
380
- const sourceInfo = lstatSync(absolute);
381
- if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink() || sourceInfo.size > MAX_FILE_BYTES)
382
- throw new Error(`OKF export source is unsafe or too large: ${source}`);
383
- const bytes = await readFile(absolute);
378
+ const { bytes } = readStableFile(absolute, { label: "OKF export source", maxBytes: MAX_FILE_BYTES });
384
379
  const text = utf8(bytes, source);
385
380
  const redacted = redactLocalValue(text, projectRoot);
386
381
  replacements += redacted.replacement_count;
@@ -1,10 +1,11 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { constants, existsSync, readdirSync } from "node:fs";
2
+ import { existsSync, readdirSync } from "node:fs";
3
3
  import { link, lstat, mkdir, open, realpath, rm } from "node:fs/promises";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { z } from "zod";
6
6
  import { hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
7
7
  import { playbookProjectIdentity, resolvePlaybookStatus } from "./playbook.js";
8
+ import { readStableJson } from "./stable-file.js";
8
9
  import { PLAYBOOK_SCHEMA_VERSION, playbookActionIdSchema, playbookActorSchema, playbookEventSchema, playbookOutcomeSchema, playbookReasonSchema, } from "./playbook-schema.js";
9
10
  const MAX_RECEIPT_BYTES = 8 * 1024;
10
11
  const MAX_RECEIPTS = 10_000;
@@ -64,22 +65,7 @@ async function ensureStore(projectRoot) {
64
65
  return target.receipts;
65
66
  }
66
67
  async function safeRead(path) {
67
- const info = await lstat(path);
68
- if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_RECEIPT_BYTES || !privatePathIsSafe(path)) {
69
- throw new Error("Unsafe playbook receipt.");
70
- }
71
- let handle;
72
- try {
73
- handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
74
- const opened = await handle.stat();
75
- if (!opened.isFile() || opened.size > MAX_RECEIPT_BYTES || opened.dev !== info.dev || opened.ino !== info.ino) {
76
- throw new Error("Playbook receipt changed during validation.");
77
- }
78
- return receiptSchema.parse(JSON.parse((await handle.readFile()).toString("utf8")));
79
- }
80
- finally {
81
- await handle?.close().catch(() => undefined);
82
- }
68
+ return receiptSchema.parse(readStableJson(path, { label: "Playbook receipt", maxBytes: MAX_RECEIPT_BYTES, private: true }));
83
69
  }
84
70
  async function atomicReceipt(path, value) {
85
71
  const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
@@ -6,6 +6,7 @@ import { EvalProcessError, runBoundedJsonProcess } from "./eval-process.js";
6
6
  import { noteNodeSchema } from "./node-schema.js";
7
7
  import { getNoteLayout, listAddressedNotes } from "./note-store.js";
8
8
  import { hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
9
+ import { readStableJson, readStableText } from "./stable-file.js";
9
10
  import { MAX_SKILL_BYTES, SKILL_SCHEMA_VERSION, skillAdapterConfigSchema, skillApplicationSchema, skillCandidateSchema, skillEvaluationSchema, skillProposalRequestSchema, skillProposalResponseSchema, skillProposalSchema, skillRelativePathSchema, } from "./skill-schema.js";
10
11
  const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
11
12
  const PROTECTED_ADAPTER_ENVIRONMENT = new Set([
@@ -114,17 +115,7 @@ function atomicWrite(path, value) {
114
115
  hardenPrivatePath(path);
115
116
  }
116
117
  function readJson(path) {
117
- const info = lstatSync(path);
118
- if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_ARTIFACT_BYTES || !privatePathIsSafe(path)) {
119
- throw new Error(`Unsafe skill artifact: ${path}`);
120
- }
121
- const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
122
- try {
123
- return JSON.parse(readFileSync(descriptor, "utf8"));
124
- }
125
- finally {
126
- closeSync(descriptor);
127
- }
118
+ return readStableJson(path, { label: "Skill artifact", maxBytes: MAX_ARTIFACT_BYTES, private: true });
128
119
  }
129
120
  function listJson(directory, parse) {
130
121
  if (!existsSync(directory))
@@ -261,18 +252,13 @@ function assertSafeTarget(projectRoot, relativePath) {
261
252
  if (!isContained(root, target) || target === root)
262
253
  throw new Error("Skill target escapes the project.");
263
254
  let cursor = root;
264
- for (const segment of canonicalPath.split("/")) {
255
+ for (const segment of canonicalPath.split("/").slice(0, -1)) {
265
256
  cursor = join(cursor, segment);
266
257
  const info = lstatSync(cursor);
267
258
  if (info.isSymbolicLink())
268
259
  throw new Error("Skill target crosses a symlink.");
269
260
  }
270
- const info = lstatSync(target);
271
- if (!info.isFile() || info.size > MAX_SKILL_BYTES)
272
- throw new Error("Skill target must be a regular file no larger than 256 KiB.");
273
- const content = readFileSync(target, "utf8");
274
- if (Buffer.byteLength(content, "utf8") > MAX_SKILL_BYTES)
275
- throw new Error("Skill target exceeds 256 KiB.");
261
+ const { text: content, stat: info } = readStableText(target, { label: "Skill target", maxBytes: MAX_SKILL_BYTES });
276
262
  return { path: target, content, digest: sha256(content), mode: info.mode & 0o777 };
277
263
  }
278
264
  export function readSkillTarget(projectRoot, relativePath) {
@@ -520,7 +506,7 @@ export function applySkillProposal(options) {
520
506
  hardenPrivatePath(backupPath);
521
507
  try {
522
508
  atomicWriteTarget(target.path, proposal.candidate_content, proposal.target_mode);
523
- if (sha256(readFileSync(target.path)) !== proposal.candidate_content_digest) {
509
+ if (assertSafeTarget(store.project_root, proposal.target_path).digest !== proposal.candidate_content_digest) {
524
510
  throw new Error("Applied skill digest verification failed.");
525
511
  }
526
512
  atomicWrite(artifactPath(store.applications, application.id), application);
@@ -529,7 +515,7 @@ export function applySkillProposal(options) {
529
515
  const reason = error instanceof Error ? error.message : String(error);
530
516
  let currentDigest = null;
531
517
  try {
532
- currentDigest = sha256(readFileSync(target.path));
518
+ currentDigest = assertSafeTarget(store.project_root, proposal.target_path).digest;
533
519
  }
534
520
  catch {
535
521
  // The private backup remains available for manual recovery.
@@ -538,7 +524,7 @@ export function applySkillProposal(options) {
538
524
  let restored = false;
539
525
  try {
540
526
  atomicWriteTarget(target.path, target.content, target.mode);
541
- if (sha256(readFileSync(target.path)) !== target.digest)
527
+ if (assertSafeTarget(store.project_root, proposal.target_path).digest !== target.digest)
542
528
  throw new Error("restore_digest_mismatch");
543
529
  restored = true;
544
530
  }
@@ -576,16 +562,14 @@ export function rollbackSkillApplication(options) {
576
562
  if (target.digest !== application.applied_digest)
577
563
  throw new Error("Skill target changed after application; rollback requires manual resolution.");
578
564
  const backup = resolve(store.project_root, application.backup_path);
579
- const backupInfo = lstatSync(backup);
580
- if (!isContained(store.backups, backup) || backupInfo.isSymbolicLink() || !backupInfo.isFile()
581
- || backupInfo.size > MAX_SKILL_BYTES || !privatePathIsSafe(backup)) {
565
+ if (!isContained(store.backups, backup)) {
582
566
  throw new Error("Skill backup is missing or unsafe.");
583
567
  }
584
- const original = readFileSync(backup, "utf8");
568
+ const { text: original } = readStableText(backup, { label: "Skill backup", maxBytes: MAX_SKILL_BYTES, private: true });
585
569
  if (sha256(original) !== application.before_digest)
586
570
  throw new Error("Skill backup digest does not match the application ledger.");
587
571
  atomicWriteTarget(target.path, original, application.target_mode);
588
- if (sha256(readFileSync(target.path)) !== application.before_digest) {
572
+ if (assertSafeTarget(store.project_root, application.target_path).digest !== application.before_digest) {
589
573
  throw new Error("Skill rollback digest verification failed; the application remains marked as applied.");
590
574
  }
591
575
  const rolledBack = skillApplicationSchema.parse({
@@ -0,0 +1,85 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { posixPrivateMetadataIsSafe, privatePathIsSafe } from "./platform-security.js";
4
+ function sameFile(left, right) {
5
+ return right.isFile() && !right.isSymbolicLink()
6
+ && left.dev === right.dev && left.ino === right.ino && left.size === right.size
7
+ && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs
8
+ && left.uid === right.uid && left.gid === right.gid && left.mode === right.mode;
9
+ }
10
+ /** Bind bounded runtime reads to the inspected file, not a later pathname. */
11
+ export function readStableFile(path, options) {
12
+ const { label, maxBytes } = options;
13
+ let descriptor;
14
+ try {
15
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
16
+ throw new Error();
17
+ // Opening never relies on a prior pathname check. Nonblocking/no-ctty
18
+ // flags make special-file admission bounded before descriptor inspection.
19
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
20
+ | (constants.O_NOCTTY ?? 0) | (process.platform === "win32" ? 0 : constants.O_NONBLOCK ?? 0));
21
+ const opened = fstatSync(descriptor);
22
+ if (!opened.isFile() || opened.size > maxBytes)
23
+ throw new Error();
24
+ const parent = lstatSync(dirname(path));
25
+ const named = lstatSync(path);
26
+ if (named.isSymbolicLink() || parent.isSymbolicLink())
27
+ throw new Error(`${label} crosses a symlink.`);
28
+ if (!parent.isDirectory() || !sameFile(opened, named))
29
+ throw new Error();
30
+ if (options.private) {
31
+ if (process.platform === "win32" ? !privatePathIsSafe(path) : !posixPrivateMetadataIsSafe(opened))
32
+ throw new Error();
33
+ if (!sameFile(opened, lstatSync(path)))
34
+ throw new Error();
35
+ }
36
+ // One extra byte detects growth without following it or growing allocation.
37
+ const buffer = Buffer.allocUnsafe(opened.size + 1);
38
+ let offset = 0;
39
+ while (offset < buffer.length) {
40
+ const count = readSync(descriptor, buffer, offset, buffer.length - offset, null);
41
+ if (count === 0)
42
+ break;
43
+ offset += count;
44
+ }
45
+ const after = fstatSync(descriptor);
46
+ const parentAfter = lstatSync(dirname(path));
47
+ if (!sameFile(opened, after) || offset !== opened.size
48
+ || !parentAfter.isDirectory() || parentAfter.isSymbolicLink()
49
+ || parent.dev !== parentAfter.dev || parent.ino !== parentAfter.ino
50
+ || (options.private && process.platform === "win32" && !privatePathIsSafe(path))
51
+ || !sameFile(opened, lstatSync(path)))
52
+ throw new Error();
53
+ return { bytes: buffer.subarray(0, offset), stat: opened };
54
+ }
55
+ catch (error) {
56
+ // Do not quote paths, private payloads or operating-system diagnostics.
57
+ if (error instanceof Error && error.message === `${label} crosses a symlink.`)
58
+ throw error;
59
+ if (error.code === "ELOOP")
60
+ throw new Error(`${label} crosses a symlink.`);
61
+ throw new Error(`${label} is unsafe, changed or unreadable.`);
62
+ }
63
+ finally {
64
+ if (descriptor !== undefined)
65
+ closeSync(descriptor);
66
+ }
67
+ }
68
+ export function readStableText(path, options) {
69
+ const { bytes, stat } = readStableFile(path, options);
70
+ try {
71
+ return { text: new TextDecoder("utf-8", { fatal: true }).decode(bytes), stat };
72
+ }
73
+ catch {
74
+ throw new Error(`${options.label} contains invalid UTF-8.`);
75
+ }
76
+ }
77
+ export function readStableJson(path, options) {
78
+ const { text } = readStableText(path, options);
79
+ try {
80
+ return JSON.parse(text);
81
+ }
82
+ catch {
83
+ throw new Error(`${options.label} contains invalid JSON.`);
84
+ }
85
+ }
@@ -1,10 +1,11 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { execFileSync, spawnSync } from "node:child_process";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { getArtifactLimits, isArtifactStoreEnabled } from "./artifact-schema.js";
6
6
  import { putArtifact } from "./artifact-store.js";
7
- import { hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
7
+ import { hardenPrivatePath } from "./platform-security.js";
8
+ import { readStableJson } from "./stable-file.js";
8
9
  import { WORK_EVIDENCE_SCHEMA_VERSION, completeWorkEvidenceSchema, contextUsageReceiptInputSchema, getWorkEvidenceLimits, isWorkEvidenceEnabled, isWorkEvidencePatchEnabled, pendingWorkEvidenceSchema, storedWorkEvidenceSchema, workEvidenceIdSchema, workEvidenceLinkSchema, } from "./work-evidence-schema.js";
9
10
  const MAX_RECORD_BYTES = 1024 * 1024;
10
11
  const STALE_PENDING_MS = 24 * 60 * 60 * 1000;
@@ -97,11 +98,12 @@ function atomicJson(path, value) {
97
98
  hardenPrivatePath(path);
98
99
  }
99
100
  function readJson(path) {
100
- const info = lstatSync(path);
101
- if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_RECORD_BYTES || !privatePathIsSafe(path)) {
101
+ try {
102
+ return readStableJson(path, { label: "Work-evidence record", maxBytes: MAX_RECORD_BYTES, private: true });
103
+ }
104
+ catch {
102
105
  throw new Error("Unsafe work-evidence record.");
103
106
  }
104
- return JSON.parse(readFileSync(path, "utf8"));
105
107
  }
106
108
  function gitBuffer(root, args, maxBuffer = 16 * 1024 * 1024) {
107
109
  return execFileSync("git", ["-C", root, ...args], {