@cairnkeep/cli 2.9.0 → 2.12.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +66 -1
  2. package/README.md +83 -29
  3. package/bin/cairn +6 -165
  4. package/bin/cairn-container +8 -0
  5. package/docs/building-an-overlay.md +42 -5
  6. package/docs/containers.md +10 -0
  7. package/docs/harness-compatibility.md +64 -3
  8. package/docs/learning/CURRICULUM-MAP.md +13 -6
  9. package/docs/learning/FEATURE-GUIDE.md +14 -3
  10. package/docs/learning/README.md +5 -2
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +24 -8
  14. package/docs/learning/lessons/L03-first-project.md +34 -15
  15. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  16. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  17. package/docs/learning/lessons/L21-context-packs.md +1 -1
  18. package/docs/learning/lessons/L22-native-windows.md +83 -0
  19. package/docs/learning/lessons/L23-guided-setup.md +142 -0
  20. package/docs/learning/tracks/operator.md +8 -3
  21. package/docs/learning/tracks/quickstart.md +9 -4
  22. package/docs/native-windows.md +136 -0
  23. package/docs/operating.md +142 -20
  24. package/docs/privacy-and-data-flow.md +41 -1
  25. package/docs/quickstart.md +86 -0
  26. package/docs/storage.md +14 -2
  27. package/examples/eval/bundled-fake.json +1 -1
  28. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  29. package/mcp-memory-server/dist/artifact-store.js +196 -55
  30. package/mcp-memory-server/dist/capability-cli.js +2 -1
  31. package/mcp-memory-server/dist/capability-config.js +4 -3
  32. package/mcp-memory-server/dist/capability-harness.js +34 -7
  33. package/mcp-memory-server/dist/context-pack.js +84 -25
  34. package/mcp-memory-server/dist/eval-cli.js +6 -5
  35. package/mcp-memory-server/dist/eval-plan.js +35 -13
  36. package/mcp-memory-server/dist/eval-process.js +11 -4
  37. package/mcp-memory-server/dist/eval-report.js +7 -5
  38. package/mcp-memory-server/dist/graph-cli.js +7 -1
  39. package/mcp-memory-server/dist/index.js +19 -20
  40. package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
  41. package/mcp-memory-server/dist/note-store.js +12 -3
  42. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  43. package/mcp-memory-server/dist/platform-security.js +102 -0
  44. package/mcp-memory-server/dist/skill-store.js +27 -12
  45. package/mcp-memory-server/package.json +2 -1
  46. package/package.json +6 -4
  47. package/pi/extensions/cairnkeep-memory.ts +61 -0
  48. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  49. package/schemas/cairnkeep-setup.schema.json +36 -0
  50. package/scripts/bootstrap.sh +7 -15
  51. package/scripts/cairn-cli.mjs +119 -0
  52. package/scripts/cairn-container-cli.mjs +121 -0
  53. package/scripts/cairn-hook.mjs +199 -0
  54. package/scripts/completion.sh +17 -6
  55. package/scripts/doctor.sh +58 -0
  56. package/scripts/fix-permissions.mjs +12 -0
  57. package/scripts/harness-registry.mjs +126 -0
  58. package/scripts/memory-wiki-audit.mjs +95 -0
  59. package/scripts/phase26-test-manifest.mjs +26 -0
  60. package/scripts/run-repository-tests.mjs +76 -0
  61. package/scripts/setup-core.mjs +312 -0
  62. package/scripts/setup-reconcile.mjs +332 -0
  63. package/scripts/setup.mjs +342 -0
  64. package/scripts/sync-pi-assets.sh +19 -4
  65. package/scripts/test-bootstrap-untracked.sh +2 -1
  66. package/scripts/test-completion.sh +41 -0
  67. package/scripts/test-doctor.sh +66 -0
  68. package/scripts/test-harness-registry.sh +94 -0
  69. package/scripts/test-learning-docs.sh +52 -3
  70. package/scripts/test-package-install.sh +69 -0
  71. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  72. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  73. package/scripts/test-phase19-eval-lifecycle.sh +12 -2
  74. package/scripts/test-pi-lifecycle.mjs +190 -0
  75. package/scripts/test-setup-compatibility.mjs +187 -0
  76. package/scripts/test-setup-output.sh +131 -0
  77. package/scripts/test-setup-overlay.sh +113 -0
  78. package/scripts/test-setup-preflight.mjs +281 -0
  79. package/scripts/test-setup-reconcile.mjs +257 -0
  80. package/scripts/test-uninstall.sh +50 -0
  81. package/scripts/test-windows-native.mjs +159 -0
  82. package/scripts/uninstall.sh +73 -6
  83. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  84. package/scripts/windows-platform.mjs +733 -0
  85. package/templates/codex-config.toml.template +5 -0
  86. package/templates/start-codex.sh.template +30 -0
@@ -1,12 +1,13 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
4
- import { chmod, cp, mkdir, mkdtemp, open, readdir, readFile, realpath, rename, rm, stat, } from "node:fs/promises";
4
+ import { chmod, cp, mkdir, mkdtemp, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { z } from "zod";
9
9
  import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText } from "./embeddings.js";
10
+ import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
10
11
  const execFileAsync = promisify(execFile);
11
12
  export const CONTEXT_PACK_MANIFEST = "context-pack.json";
12
13
  export const CONTEXT_PACK_MAX_FILE_BYTES = 1024 * 1024;
@@ -73,7 +74,7 @@ function readPackSource(digest) {
73
74
  const directoryInfo = lstatSync(directory);
74
75
  const info = lstatSync(path);
75
76
  if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()
76
- || info.size > 64 * 1024 || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) {
77
+ || info.size > 64 * 1024 || !privatePathIsSafe(path)) {
77
78
  throw new Error(`Context pack source record is unsafe: ${digest}`);
78
79
  }
79
80
  const value = JSON.parse(readFileSync(path, "utf8"));
@@ -193,8 +194,9 @@ async function atomicJson(path, value) {
193
194
  await handle.close();
194
195
  handle = undefined;
195
196
  await chmod(temporary, 0o600);
196
- await rename(temporary, path);
197
- await chmod(path, 0o600);
197
+ hardenPrivatePath(temporary);
198
+ await atomicReplace(temporary, path);
199
+ hardenPrivatePath(path);
198
200
  }
199
201
  finally {
200
202
  if (handle)
@@ -210,13 +212,35 @@ async function makeImmutable(root) {
210
212
  const path = join(directory, entry.name);
211
213
  if (entry.isDirectory())
212
214
  await walk(path);
215
+ else if (process.platform === "win32") {
216
+ hardenPrivatePath(path);
217
+ await execFileAsync("attrib.exe", ["+R", path]);
218
+ }
213
219
  else
214
220
  await chmod(path, 0o400);
215
221
  }
216
222
  };
217
223
  await walk(root);
218
- for (const directory of directories.reverse())
219
- await chmod(directory, 0o500);
224
+ for (const directory of directories.reverse()) {
225
+ if (process.platform === "win32")
226
+ hardenPrivatePath(directory);
227
+ else
228
+ await chmod(directory, 0o500);
229
+ }
230
+ }
231
+ async function makeWritable(root) {
232
+ if (!existsSync(root))
233
+ return;
234
+ await chmod(root, 0o700);
235
+ for (const entry of await readdir(root, { withFileTypes: true })) {
236
+ const path = join(root, entry.name);
237
+ if (entry.isDirectory())
238
+ await makeWritable(path);
239
+ else if (process.platform === "win32")
240
+ await execFileAsync("attrib.exe", ["-R", path]);
241
+ else
242
+ await chmod(path, 0o600);
243
+ }
220
244
  }
221
245
  function rejectCredentialUrl(source) {
222
246
  try {
@@ -232,6 +256,45 @@ function rejectCredentialUrl(source) {
232
256
  throw new Error("Git source URLs may not contain credentials.");
233
257
  }
234
258
  }
259
+ function execFileBuffer(program, args, options) {
260
+ return new Promise((resolvePromise, rejectPromise) => {
261
+ execFile(program, args, { ...options, encoding: null, windowsHide: true }, (error, stdout) => {
262
+ if (error)
263
+ rejectPromise(error);
264
+ else
265
+ resolvePromise(Buffer.from(stdout));
266
+ });
267
+ });
268
+ }
269
+ async function materializeGitTree(repository, destination, commit) {
270
+ const listing = await execFileBuffer("git", ["-C", repository, "ls-tree", "-r", "-z", commit], { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
271
+ const records = new TextDecoder("utf-8", { fatal: true }).decode(listing).split("\0").filter(Boolean);
272
+ if (records.length > CONTEXT_PACK_MAX_ENTRIES + 1)
273
+ throw new Error("Git context pack exceeds the entry limit.");
274
+ let totalBytes = 0;
275
+ for (const record of records) {
276
+ const match = /^(100644|100755|120000) blob ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/.exec(record);
277
+ if (!match || match[1] === "120000")
278
+ throw new Error("Git context packs may contain only regular files.");
279
+ const path = normalizePackPath(match[3]);
280
+ const { stdout: rawSize } = await execFileAsync("git", ["-C", repository, "cat-file", "-s", match[2]], { encoding: "utf8", timeout: 30_000, maxBuffer: 1024 });
281
+ const size = Number(rawSize.trim());
282
+ if (!Number.isSafeInteger(size) || size < 0 || size > CONTEXT_PACK_MAX_FILE_BYTES) {
283
+ throw new Error(`Git context pack file is unsafe or too large: ${path}`);
284
+ }
285
+ totalBytes += size;
286
+ if (totalBytes > CONTEXT_PACK_MAX_TOTAL_BYTES)
287
+ throw new Error("Git context pack exceeds the total size limit.");
288
+ const target = resolve(destination, ...path.split("/"));
289
+ if (!contained(destination, target))
290
+ throw new Error(`Unsafe Git context pack path: ${path}`);
291
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
292
+ const bytes = await execFileBuffer("git", ["-C", repository, "cat-file", "blob", match[2]], { timeout: 30_000, maxBuffer: CONTEXT_PACK_MAX_FILE_BYTES + 1 });
293
+ if (bytes.byteLength !== size)
294
+ throw new Error(`Git context pack blob changed while reading: ${path}`);
295
+ await writeFile(target, bytes, { mode: match[1] === "100755" ? 0o700 : 0o600 });
296
+ }
297
+ }
235
298
  async function materializeSource(source, ref) {
236
299
  if (!ref) {
237
300
  const requested = resolve(source);
@@ -250,9 +313,10 @@ async function materializeSource(source, ref) {
250
313
  const commit = stdout.trim();
251
314
  if (!GIT_COMMIT.test(commit))
252
315
  throw new Error("Git did not resolve the requested ref to a full commit.");
253
- await execFileAsync("git", ["-C", checkout, "checkout", "--quiet", "--detach", commit], { timeout: 60_000, maxBuffer: 1024 * 1024 });
254
- await rm(join(checkout, ".git"), { recursive: true, force: true });
255
- return { root: checkout, cleanup: checkout, source: { kind: "git", url: gitSource, ref, commit } };
316
+ const root = join(checkout, "pack");
317
+ await mkdir(root, { mode: 0o700 });
318
+ await materializeGitTree(checkout, root, commit);
319
+ return { root, cleanup: checkout, source: { kind: "git", url: gitSource, ref, commit } };
256
320
  }
257
321
  catch (error) {
258
322
  await rm(checkout, { recursive: true, force: true });
@@ -275,6 +339,7 @@ export async function installContextPack(sourcePath, options = {}) {
275
339
  const objects = dirname(destination);
276
340
  await mkdir(objects, { recursive: true, mode: 0o700 });
277
341
  const temporary = join(objects, `.${pack.digest}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
342
+ let published = true;
278
343
  try {
279
344
  await cp(pack.root, temporary, { recursive: true, errorOnExist: true, force: false });
280
345
  const copied = await validateContextPack(temporary);
@@ -285,19 +350,23 @@ export async function installContextPack(sourcePath, options = {}) {
285
350
  await rename(temporary, destination);
286
351
  }
287
352
  catch (error) {
288
- if (!["EEXIST", "ENOTEMPTY"].includes(error.code ?? "") || !existsSync(destination))
353
+ const code = error.code ?? "";
354
+ const converged = existsSync(destination)
355
+ && (["EEXIST", "ENOTEMPTY"].includes(code) || (process.platform === "win32" && code === "EPERM"));
356
+ if (!converged)
289
357
  throw error;
290
358
  const concurrent = await validateContextPack(destination);
291
359
  if (concurrent.digest !== pack.digest)
292
360
  throw new Error("Concurrent context pack installation produced a conflicting object.");
361
+ published = false;
293
362
  }
294
363
  }
295
364
  finally {
296
- await chmod(temporary, 0o700).catch(() => undefined);
365
+ await makeWritable(temporary).catch(() => undefined);
297
366
  await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
298
367
  }
299
368
  await atomicJson(join(packBaseDir(), "sources", `${pack.digest}.json`), { schema_version: 1, source: materialized.source });
300
- return { pack: await validateContextPack(destination), source: materialized.source, existing: false };
369
+ return { pack: await validateContextPack(destination), source: materialized.source, existing: !published };
301
370
  }
302
371
  finally {
303
372
  if (materialized.cleanup)
@@ -396,7 +465,7 @@ export function readProjectPointer(options) {
396
465
  return emptyPointer(identity.id);
397
466
  const info = lstatSync(identity.path);
398
467
  if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024
399
- || (process.platform !== "win32" && (info.mode & 0o077) !== 0))
468
+ || !privatePathIsSafe(identity.path))
400
469
  throw new Error("Context pack project pointer is unsafe.");
401
470
  return parseProjectPointer(JSON.parse(readFileSync(identity.path, "utf8")), identity.id);
402
471
  }
@@ -470,7 +539,7 @@ async function allPointers() {
470
539
  const path = join(directory, entry.name);
471
540
  const info = lstatSync(path);
472
541
  if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024
473
- || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) {
542
+ || !privatePathIsSafe(path)) {
474
543
  throw new Error(`Context pack project pointer is unsafe: ${path}`);
475
544
  }
476
545
  const raw = JSON.parse(await readFile(path, "utf8"));
@@ -489,17 +558,7 @@ export async function removeContextPack(selector) {
489
558
  throw new Error("Context pack is enabled by a project and cannot be removed.");
490
559
  }
491
560
  const root = objectRoot(pack.digest);
492
- const unlock = async (directory) => {
493
- await chmod(directory, 0o700);
494
- for (const entry of await readdir(directory, { withFileTypes: true })) {
495
- const path = join(directory, entry.name);
496
- if (entry.isDirectory())
497
- await unlock(path);
498
- else
499
- await chmod(path, 0o600);
500
- }
501
- };
502
- await unlock(root);
561
+ await makeWritable(root);
503
562
  await rm(root, { recursive: true, force: true });
504
563
  await rm(join(packBaseDir(), "sources", `${pack.digest}.json`), { force: true });
505
564
  return pack.digest;
@@ -7,6 +7,7 @@ import { isEvalEnabled, loadEvalPlan } from "./eval-plan.js";
7
7
  import { diagnoseEvalReport, readEvalReport, renderEvalReport, } from "./eval-report.js";
8
8
  import { buildAblationArms, runCapabilityAblation, runTwoPassExperiment, } from "./eval-runner.js";
9
9
  import { canonicalJson, EVAL_SCHEMA_VERSION } from "./eval-schema.js";
10
+ import { privatePathIsSafe } from "./platform-security.js";
10
11
  process.stdout.on("error", (error) => {
11
12
  if (error.code === "EPIPE")
12
13
  process.exit(0);
@@ -126,7 +127,7 @@ async function evalRoot() {
126
127
  return null;
127
128
  throw error;
128
129
  }
129
- if (info.isSymbolicLink() || !info.isDirectory() || (info.mode & 0o077) !== 0) {
130
+ if (info.isSymbolicLink() || !info.isDirectory() || !privatePathIsSafe(root)) {
130
131
  throw new Error("unsafe_eval_root");
131
132
  }
132
133
  const resolvedRoot = await realpath(root);
@@ -185,7 +186,7 @@ async function diagnoseProjectReports(projectRoot) {
185
186
  }
186
187
  if (info.isSymbolicLink() || !info.isDirectory() || await realpath(directory) !== directory)
187
188
  return "unsafe";
188
- if (directory === root && (info.mode & 0o077) !== 0)
189
+ if (directory === root && !privatePathIsSafe(directory))
189
190
  return "unsafe";
190
191
  }
191
192
  const entries = await readdir(root, { withFileTypes: true });
@@ -201,7 +202,7 @@ async function diagnoseProjectReports(projectRoot) {
201
202
  if (!isContained(root, experimentPath) || experimentPath === root)
202
203
  return "unsafe";
203
204
  const experimentInfo = await lstat(experimentPath);
204
- if (experimentInfo.isSymbolicLink() || !experimentInfo.isDirectory() || (experimentInfo.mode & 0o077) !== 0
205
+ if (experimentInfo.isSymbolicLink() || !experimentInfo.isDirectory() || !privatePathIsSafe(experimentPath)
205
206
  || await realpath(experimentPath) !== experimentPath) {
206
207
  return "unsafe";
207
208
  }
@@ -247,7 +248,7 @@ async function safeReportStore(experimentId) {
247
248
  throw new Error("eval_report_not_found");
248
249
  throw error;
249
250
  }
250
- if (directory.isSymbolicLink() || !directory.isDirectory() || (directory.mode & 0o077) !== 0
251
+ if (directory.isSymbolicLink() || !directory.isDirectory() || !privatePathIsSafe(experimentPath)
251
252
  || await realpath(experimentPath) !== experimentPath) {
252
253
  throw new Error("unsafe_experiment_path");
253
254
  }
@@ -261,7 +262,7 @@ async function safeReportStore(experimentId) {
261
262
  throw new Error("eval_report_not_found");
262
263
  throw error;
263
264
  }
264
- if (reportInfo.isSymbolicLink() || !reportInfo.isFile() || (reportInfo.mode & 0o077) !== 0
265
+ if (reportInfo.isSymbolicLink() || !reportInfo.isFile() || !privatePathIsSafe(reportPath)
265
266
  || reportInfo.size > DEFAULT_REPORT_BYTES) {
266
267
  throw new Error("unsafe_eval_report");
267
268
  }
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { constants } from "node:fs";
3
3
  import { accessSync, closeSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync, } from "node:fs";
4
- import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { CAPABILITY_IDS } from "./capability-schema.js";
7
7
  import { EVAL_SCHEMA_VERSION, canonicalDigest, canonicalJson, evalAdapterConfigSchema, evalTaskSetSchema, } from "./eval-schema.js";
@@ -16,6 +16,19 @@ function isContained(root, candidate) {
16
16
  const rel = relative(root, candidate);
17
17
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
18
18
  }
19
+ function isExistingPathContained(root, candidate) {
20
+ const rootInfo = lstatSync(root);
21
+ let current = realpathSync(candidate);
22
+ for (;;) {
23
+ const currentInfo = lstatSync(current);
24
+ if (currentInfo.dev === rootInfo.dev && currentInfo.ino === rootInfo.ino)
25
+ return true;
26
+ const parent = dirname(current);
27
+ if (parent === current)
28
+ return false;
29
+ current = parent;
30
+ }
31
+ }
19
32
  function assertRealDirectory(path, label) {
20
33
  const info = lstatSync(path);
21
34
  if (info.isSymbolicLink() || !info.isDirectory())
@@ -68,12 +81,16 @@ function resolveExactCommit(repository, revision) {
68
81
  return resolved;
69
82
  }
70
83
  function parseCommittedTaskSet(taskSetPath) {
71
- const manifestRepository = assertRealDirectory(git(dirname(taskSetPath), ["rev-parse", "--show-toplevel"], "Resolve task-set repository"), "Task-set repository");
72
- if (!isContained(manifestRepository, taskSetPath))
84
+ const manifestDirectory = dirname(taskSetPath);
85
+ const manifestRepository = assertRealDirectory(git(manifestDirectory, ["rev-parse", "--show-toplevel"], "Resolve task-set repository"), "Task-set repository");
86
+ if (!isExistingPathContained(manifestRepository, taskSetPath)) {
73
87
  throw new Error("The task-set manifest must be contained by its Git repository.");
74
- const manifestPath = relative(manifestRepository, taskSetPath).split(sep).join("/");
75
- if (!manifestPath || manifestPath.startsWith("../"))
88
+ }
89
+ const manifestPrefix = git(manifestDirectory, ["rev-parse", "--show-prefix"], "Resolve task-set repository prefix");
90
+ const manifestPath = `${manifestPrefix}${basename(taskSetPath)}`.split("\\").join("/");
91
+ if (!manifestPath || manifestPath.startsWith("../") || isAbsolute(manifestPath)) {
76
92
  throw new Error("The task-set manifest path is invalid.");
93
+ }
77
94
  git(manifestRepository, ["ls-files", "--error-unmatch", "--", manifestPath], "Require a tracked evaluation task set");
78
95
  const dirty = git(manifestRepository, ["status", "--porcelain", "--untracked-files=all", "--", manifestPath], "Check evaluation task set status");
79
96
  if (dirty !== "")
@@ -111,15 +128,20 @@ function resolvePathProgram(program, base) {
111
128
  function resolveProgram(program, base) {
112
129
  if (isAbsolute(program) || program.includes("/") || program.includes("\\"))
113
130
  return resolvePathProgram(program, base);
131
+ const names = process.platform === "win32" && extname(program) === ""
132
+ ? [program, ...[".EXE", ".COM"].map((extension) => `${program}${extension}`)]
133
+ : [program];
114
134
  for (const directory of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
115
- const candidate = join(directory, program);
116
- try {
117
- accessSync(candidate, constants.X_OK);
118
- if (lstatSync(candidate).isFile() || lstatSync(candidate).isSymbolicLink())
119
- return realpathSync(candidate);
120
- }
121
- catch {
122
- // Continue through PATH without executing the candidate.
135
+ for (const name of names) {
136
+ const candidate = join(directory, name);
137
+ try {
138
+ accessSync(candidate, constants.X_OK);
139
+ if (lstatSync(candidate).isFile() || lstatSync(candidate).isSymbolicLink())
140
+ return realpathSync(candidate);
141
+ }
142
+ catch {
143
+ // Continue through PATH without executing the candidate.
144
+ }
123
145
  }
124
146
  }
125
147
  throw new Error(`Evaluation program was not found on PATH: ${program}`);
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import { evalAdapterRequestSchema, evalAdapterResultSchema, } from "./eval-schema.js";
3
3
  export const EVAL_PROCESS_ERROR_CODES = [
4
4
  "spawn_error",
@@ -33,7 +33,7 @@ export class EvalProcessError extends Error {
33
33
  const DEFAULT_KILL_GRACE_MS = 1_000;
34
34
  const MAX_TIMER_MS = 2_147_483_647;
35
35
  function terminationScope() {
36
- return process.platform === "win32" ? "child" : "process-group";
36
+ return process.platform === "win32" ? "process-tree" : "process-group";
37
37
  }
38
38
  function assertOptions(options) {
39
39
  if (!Number.isSafeInteger(options.timeout_ms) || options.timeout_ms <= 0 || options.timeout_ms > MAX_TIMER_MS) {
@@ -59,7 +59,9 @@ function signalChild(child, signal) {
59
59
  process.kill(-child.pid, signal);
60
60
  return true;
61
61
  }
62
- return child.kill(signal);
62
+ const args = ["/PID", String(child.pid), "/T", ...(signal === "SIGKILL" ? ["/F"] : [])];
63
+ const result = spawnSync("taskkill.exe", args, { stdio: "ignore", windowsHide: true });
64
+ return result.status === 0 || child.kill(signal);
63
65
  }
64
66
  catch {
65
67
  try {
@@ -90,7 +92,12 @@ async function runCommandInternal(options) {
90
92
  let exitSignal = null;
91
93
  let graceTimer;
92
94
  let closeTimer;
93
- const child = spawn(options.command.program, options.command.args, {
95
+ // A shebang does not make a JavaScript file directly executable on
96
+ // Windows. Adapter contracts may point at JS programs, so launch
97
+ // those through the current runtime while preserving the declared
98
+ // command for hashing and provenance.
99
+ const javascriptProgram = /\.(?:cjs|mjs|js)$/i.test(options.command.program);
100
+ const child = spawn(javascriptProgram ? process.execPath : options.command.program, [...(javascriptProgram ? [options.command.program] : []), ...options.command.args], {
94
101
  cwd: options.cwd,
95
102
  env: options.env,
96
103
  shell: false,
@@ -1,9 +1,10 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm, } from "node:fs/promises";
3
+ import { lstat, mkdir, open, readdir, realpath, rename, rm, } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { canonicalDigest, canonicalJson, evalAggregateSchema, evalReportSchema, } from "./eval-schema.js";
6
6
  import { computeDifferenceInDifferences, computePairedEstimate, } from "./eval-statistics.js";
7
+ import { hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
7
8
  const DEFAULT_MAX_REPORT_BYTES = 16 * 1024 * 1024;
8
9
  const MAX_ALLOWED_REPORT_BYTES = 64 * 1024 * 1024;
9
10
  const DEFAULT_MAX_EXPERIMENTS = 10_000;
@@ -36,7 +37,7 @@ async function ensureSafeDirectory(path, privateMode = false) {
36
37
  if (info.isSymbolicLink() || !info.isDirectory())
37
38
  throw new Error("unsafe_report_directory");
38
39
  if (privateMode)
39
- await chmod(path, 0o700);
40
+ hardenPrivatePath(path);
40
41
  return;
41
42
  }
42
43
  catch (error) {
@@ -48,7 +49,7 @@ async function ensureSafeDirectory(path, privateMode = false) {
48
49
  throw new Error("unsafe_report_directory");
49
50
  await ensureSafeDirectory(parent, false);
50
51
  await mkdir(path, { mode: 0o700 });
51
- await chmod(path, 0o700);
52
+ hardenPrivatePath(path);
52
53
  }
53
54
  function assertLimits(maxReportBytes, maxExperiments) {
54
55
  if (!Number.isSafeInteger(maxReportBytes) || maxReportBytes < 1 || maxReportBytes > MAX_ALLOWED_REPORT_BYTES) {
@@ -206,8 +207,9 @@ export async function checkpointEvalReport(store, report, options = {}) {
206
207
  await handle.close();
207
208
  handle = undefined;
208
209
  injectFault(options, "after_close");
210
+ hardenPrivatePath(temporary);
209
211
  await rename(temporary, store.report_path);
210
- await chmod(store.report_path, 0o600);
212
+ hardenPrivatePath(store.report_path);
211
213
  injectFault(options, "after_rename");
212
214
  }
213
215
  finally {
@@ -239,7 +241,7 @@ export async function diagnoseEvalReport(store) {
239
241
  });
240
242
  if (!info)
241
243
  return { state: "absent", code: "report_absent" };
242
- if (info.isSymbolicLink() || !info.isFile() || (info.mode & 0o077) !== 0) {
244
+ if (info.isSymbolicLink() || !info.isFile() || !privatePathIsSafe(store.report_path)) {
243
245
  return { state: "unsafe", code: "report_unsafe" };
244
246
  }
245
247
  const report = await readEvalReport(store);
@@ -90,7 +90,13 @@ function graphifyEnvironment() {
90
90
  return environment;
91
91
  }
92
92
  function executeGraphify(projectRoot, args, timeout) {
93
- const result = spawnSync("graphify", args, {
93
+ const configuredProgram = process.env.CAIRN_GRAPHIFY_BINARY?.trim();
94
+ const graphifyProgram = configuredProgram || "graphify";
95
+ const javascriptProgram = /\.(?:cjs|mjs|js)$/i.test(graphifyProgram);
96
+ const result = spawnSync(javascriptProgram ? process.execPath : graphifyProgram, [
97
+ ...(javascriptProgram ? [graphifyProgram] : []),
98
+ ...args,
99
+ ], {
94
100
  cwd: projectRoot,
95
101
  encoding: "utf8",
96
102
  shell: false,
@@ -761,7 +761,18 @@ async function runContextExplore(args) {
761
761
  }
762
762
  // --- Execution tier: return { ok: false, ... } (runtime problems, D-04) ---
763
763
  if (!evidence) {
764
- const result = await runCommand(binaryPath, ["explore", "--query", query, "--repo-root", repoRoot], (timeoutSeconds ?? 120) * 1000, { ...process.env, NO_COLOR: "1" });
764
+ // Native Windows cannot execute a JavaScript file directly from a
765
+ // shebang. Treat JS helpers as Node programs while keeping compiled
766
+ // token_miser binaries byte-for-byte compatible on every platform.
767
+ const javascriptHelper = /\.(?:cjs|mjs|js)$/i.test(binaryPath);
768
+ const result = await runCommand(javascriptHelper ? process.execPath : binaryPath, [
769
+ ...(javascriptHelper ? [binaryPath] : []),
770
+ "explore",
771
+ "--query",
772
+ query,
773
+ "--repo-root",
774
+ repoRoot,
775
+ ], (timeoutSeconds ?? 120) * 1000, { ...process.env, NO_COLOR: "1" });
765
776
  if (result.timedOut || result.exitCode !== 0) {
766
777
  const payload = {
767
778
  ok: false,
@@ -1718,30 +1729,18 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1718
1729
  }
1719
1730
  // Resolve containment before any database is opened or directory is created.
1720
1731
  const scopePath = resolveScopePath(envelope.scope, scopeOptions);
1721
- const existing = await openScope(envelope.scope, false, scopeOptions);
1722
- let plan;
1723
- try {
1724
- plan = await planMemoryImport(existing, envelope);
1725
- }
1726
- finally {
1727
- await existing?.close();
1728
- }
1729
- if (envelope.dry_run) {
1730
- const payload = await commitMemoryImport(null, plan);
1731
- return {
1732
- content: [{ type: "text", text: asToolText(payload) }],
1733
- structuredContent: payload,
1734
- };
1735
- }
1736
1732
  const payload = await serializeScopeMutation(scopePath, async () => {
1737
- const agent = await openScope(envelope.scope, true, scopeOptions);
1738
- if (!agent)
1739
- throw new Error(`Unable to open scope ${envelope.scope}.`);
1733
+ const agent = await openScope(envelope.scope, !envelope.dry_run, scopeOptions);
1740
1734
  try {
1735
+ const plan = await planMemoryImport(agent, envelope);
1736
+ if (envelope.dry_run)
1737
+ return commitMemoryImport(null, plan);
1738
+ if (!agent)
1739
+ throw new Error(`Unable to open scope ${envelope.scope}.`);
1741
1740
  return await commitMemoryImport(agent, plan);
1742
1741
  }
1743
1742
  finally {
1744
- await agent.close();
1743
+ await agent?.close();
1745
1744
  }
1746
1745
  });
1747
1746
  return {
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { closeSync, constants, existsSync, lstatSync, openSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
- import { chmod, mkdir, open, rename, rm } from "node:fs/promises";
3
+ import { chmod, mkdir, open, rm } from "node:fs/promises";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { MCP_TOOL_CATALOG, MCP_TOOL_NAMES, isCairnToolName } from "./mcp-tool-catalog.js";
6
+ import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
6
7
  const MAX_CONFIG_BYTES = 64 * 1024;
7
8
  function paths(projectRoot) {
8
9
  const root = resolve(projectRoot);
@@ -63,7 +64,7 @@ function readProjectConfig(projectRoot) {
63
64
  const dirInfo = lstatSync(directory);
64
65
  const info = lstatSync(config);
65
66
  if (!dirInfo.isDirectory() || dirInfo.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()
66
- || info.size > MAX_CONFIG_BYTES || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) {
67
+ || info.size > MAX_CONFIG_BYTES || !privatePathIsSafe(config)) {
67
68
  throw new Error("MCP tool profile configuration is unsafe.");
68
69
  }
69
70
  const fd = requireNoFollow(config);
@@ -129,8 +130,9 @@ async function atomicWrite(path, value) {
129
130
  await handle.close();
130
131
  handle = undefined;
131
132
  await chmod(temporary, 0o600);
132
- await rename(temporary, path);
133
- await chmod(path, 0o600);
133
+ hardenPrivatePath(temporary);
134
+ await atomicReplace(temporary, path);
135
+ hardenPrivatePath(path);
134
136
  }
135
137
  finally {
136
138
  if (handle)
@@ -5,6 +5,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:pat
5
5
  import { buildFailureSignature } from "./failure-signature.js";
6
6
  import { NOTE_SCHEMA_VERSION, noteNodeSchema, } from "./note-schema.js";
7
7
  import { memoryImportEnvelopeSchema, nodePathSchema, } from "./node-schema.js";
8
+ import { atomicReplace } from "./platform-security.js";
8
9
  const MANAGED_START = "<!-- cairnkeep:managed:v1:start -->";
9
10
  const MANAGED_END = "<!-- cairnkeep:managed:v1:end -->";
10
11
  const MAX_PROCESSED_SESSIONS_PER_PROJECT = 4096;
@@ -685,7 +686,8 @@ export function planCreateAddressedNote(options) {
685
686
  return { schema_version: 1, operation: "create", notes_root: target.layout.notes_root, project_id: target.layout.project_id, changes: manifestChanges(target.layout.notes_root, manifest, [{ path: target.absolute_path, bytes: renderNote(record) }]), result: { ok: true, scope: "project", key: options.key, created: true, snapshot_key: null, node } };
686
687
  }
687
688
  function historyPath(notesRoot, addressSpace, key) {
688
- return join(notesRoot, ".cairnkeep", "history", addressSpace, ...key.split("/"), `${new Date().toISOString()}-${randomBytes(6).toString("hex")}.json`);
689
+ const portableTimestamp = new Date().toISOString().replaceAll(":", "-");
690
+ return join(notesRoot, ".cairnkeep", "history", addressSpace, ...key.split("/"), `${portableTimestamp}-${randomBytes(6).toString("hex")}.json`);
689
691
  }
690
692
  export function planSupersedeAddressedNote(options) {
691
693
  const target = resolveNoteTarget(options);
@@ -725,7 +727,9 @@ function pendingTransactionDirectories(notesRoot) {
725
727
  return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(root, entry.name)).sort();
726
728
  }
727
729
  function syncFile(path) {
728
- const descriptor = openSync(path, "r");
730
+ // FlushFileBuffers requires a write-capable Windows handle. POSIX accepts
731
+ // the narrower read handle used by the historical implementation.
732
+ const descriptor = openSync(path, process.platform === "win32" ? "r+" : "r");
729
733
  try {
730
734
  fsyncSync(descriptor);
731
735
  }
@@ -734,6 +738,11 @@ function syncFile(path) {
734
738
  }
735
739
  }
736
740
  function syncDirectory(path) {
741
+ // Windows does not permit opening a directory as a file descriptor, so
742
+ // Node cannot fsync it. File contents are still fsynced before the atomic
743
+ // rename; POSIX additionally persists the containing directory entry.
744
+ if (process.platform === "win32")
745
+ return;
737
746
  const descriptor = openSync(path, "r");
738
747
  try {
739
748
  fsyncSync(descriptor);
@@ -839,7 +848,7 @@ export async function commitJournaledNoteMutation(plan) {
839
848
  for (const change of journal.changes) {
840
849
  mkdirSync(dirname(change.path), { recursive: true });
841
850
  if (change.staged)
842
- renameSync(change.staged, change.path);
851
+ await atomicReplace(change.staged, change.path);
843
852
  else
844
853
  rmSync(change.path, { force: true });
845
854
  if (existsSync(change.path))