@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.
- package/CHANGELOG.md +66 -1
- package/README.md +83 -29
- package/bin/cairn +6 -165
- package/bin/cairn-container +8 -0
- package/docs/building-an-overlay.md +42 -5
- package/docs/containers.md +10 -0
- package/docs/harness-compatibility.md +64 -3
- package/docs/learning/CURRICULUM-MAP.md +13 -6
- package/docs/learning/FEATURE-GUIDE.md +14 -3
- package/docs/learning/README.md +5 -2
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +24 -8
- package/docs/learning/lessons/L03-first-project.md +34 -15
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +83 -0
- package/docs/learning/lessons/L23-guided-setup.md +142 -0
- package/docs/learning/tracks/operator.md +8 -3
- package/docs/learning/tracks/quickstart.md +9 -4
- package/docs/native-windows.md +136 -0
- package/docs/operating.md +142 -20
- package/docs/privacy-and-data-flow.md +41 -1
- package/docs/quickstart.md +86 -0
- package/docs/storage.md +14 -2
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
- package/mcp-memory-server/dist/artifact-store.js +196 -55
- package/mcp-memory-server/dist/capability-cli.js +2 -1
- package/mcp-memory-server/dist/capability-config.js +4 -3
- package/mcp-memory-server/dist/capability-harness.js +34 -7
- package/mcp-memory-server/dist/context-pack.js +84 -25
- package/mcp-memory-server/dist/eval-cli.js +6 -5
- package/mcp-memory-server/dist/eval-plan.js +35 -13
- package/mcp-memory-server/dist/eval-process.js +11 -4
- package/mcp-memory-server/dist/eval-report.js +7 -5
- package/mcp-memory-server/dist/graph-cli.js +7 -1
- package/mcp-memory-server/dist/index.js +19 -20
- package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
- package/mcp-memory-server/dist/note-store.js +12 -3
- package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
- package/mcp-memory-server/dist/platform-security.js +102 -0
- package/mcp-memory-server/dist/skill-store.js +27 -12
- package/mcp-memory-server/package.json +2 -1
- package/package.json +6 -4
- package/pi/extensions/cairnkeep-memory.ts +61 -0
- package/schemas/cairnkeep-setup-policy.schema.json +55 -0
- package/schemas/cairnkeep-setup.schema.json +36 -0
- package/scripts/bootstrap.sh +7 -15
- package/scripts/cairn-cli.mjs +119 -0
- package/scripts/cairn-container-cli.mjs +121 -0
- package/scripts/cairn-hook.mjs +199 -0
- package/scripts/completion.sh +17 -6
- package/scripts/doctor.sh +58 -0
- package/scripts/fix-permissions.mjs +12 -0
- package/scripts/harness-registry.mjs +126 -0
- package/scripts/memory-wiki-audit.mjs +95 -0
- package/scripts/phase26-test-manifest.mjs +26 -0
- package/scripts/run-repository-tests.mjs +76 -0
- package/scripts/setup-core.mjs +312 -0
- package/scripts/setup-reconcile.mjs +332 -0
- package/scripts/setup.mjs +342 -0
- package/scripts/sync-pi-assets.sh +19 -4
- package/scripts/test-bootstrap-untracked.sh +2 -1
- package/scripts/test-completion.sh +41 -0
- package/scripts/test-doctor.sh +66 -0
- package/scripts/test-harness-registry.sh +94 -0
- package/scripts/test-learning-docs.sh +52 -3
- package/scripts/test-package-install.sh +69 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-phase18-capability-lifecycle.sh +1 -1
- package/scripts/test-phase19-eval-lifecycle.sh +12 -2
- package/scripts/test-pi-lifecycle.mjs +190 -0
- package/scripts/test-setup-compatibility.mjs +187 -0
- package/scripts/test-setup-output.sh +131 -0
- package/scripts/test-setup-overlay.sh +113 -0
- package/scripts/test-setup-preflight.mjs +281 -0
- package/scripts/test-setup-reconcile.mjs +257 -0
- package/scripts/test-uninstall.sh +50 -0
- package/scripts/test-windows-native.mjs +159 -0
- package/scripts/uninstall.sh +73 -6
- package/scripts/verify-pi-mcp-bridge.mjs +588 -0
- package/scripts/windows-platform.mjs +733 -0
- package/templates/codex-config.toml.template +5 -0
- 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 || (
|
|
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
|
-
|
|
197
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
await
|
|
255
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|| (
|
|
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
|
-
|| (
|
|
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
|
-
|
|
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() || (
|
|
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 && (
|
|
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() || (
|
|
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() || (
|
|
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() || (
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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" ? "
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() || (
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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 || (
|
|
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
|
-
|
|
133
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
851
|
+
await atomicReplace(change.staged, change.path);
|
|
843
852
|
else
|
|
844
853
|
rmSync(change.path, { force: true });
|
|
845
854
|
if (existsSync(change.path))
|