@cairnkeep/cli 2.22.3 → 2.22.5

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 (66) hide show
  1. package/CHANGELOG.md +79 -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 +1 -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 +72 -2
  22. package/docs/learning/tracks/operator.md +6 -1
  23. package/docs/learning/video-scripts/V28-security-assurance.md +65 -0
  24. package/docs/native-windows.md +10 -0
  25. package/docs/operating.md +10 -0
  26. package/docs/privacy-and-data-flow.md +6 -0
  27. package/docs/releasing.md +61 -2
  28. package/docs/security-assurance.md +82 -0
  29. package/examples/eval/bundled-fake.json +1 -1
  30. package/mcp-memory-server/dist/capability-registry.js +2 -15
  31. package/mcp-memory-server/dist/context-pack-retrieval.js +6 -6
  32. package/mcp-memory-server/dist/domain-retrieval-provider.js +100 -45
  33. package/mcp-memory-server/dist/eval-plan.js +15 -22
  34. package/mcp-memory-server/dist/eval-runner.js +17 -10
  35. package/mcp-memory-server/dist/eval-workspace.js +82 -23
  36. package/mcp-memory-server/dist/failure-signature.js +29 -8
  37. package/mcp-memory-server/dist/graph-cli.js +25 -10
  38. package/mcp-memory-server/dist/note-store.js +24 -4
  39. package/mcp-memory-server/dist/okf.js +4 -9
  40. package/mcp-memory-server/dist/playbook-receipt.js +3 -17
  41. package/mcp-memory-server/dist/skill-store.js +10 -26
  42. package/mcp-memory-server/dist/stable-file.js +85 -0
  43. package/mcp-memory-server/dist/work-evidence-store.js +7 -5
  44. package/omp/extensions/cairnkeep-capture.ts +29 -17
  45. package/package.json +1 -1
  46. package/scripts/lib/opencode-v2-plugin-harness.mjs +20 -3
  47. package/scripts/lib/release-verification.mjs +131 -0
  48. package/scripts/lib/stable-file.mjs +75 -0
  49. package/scripts/playbook-instructions.mjs +42 -20
  50. package/scripts/setup-core.mjs +5 -36
  51. package/scripts/setup-reconcile.mjs +32 -32
  52. package/scripts/setup.mjs +7 -31
  53. package/scripts/test-ci-sharding.sh +5 -0
  54. package/scripts/test-container-health.mjs +56 -0
  55. package/scripts/test-container.sh +8 -1
  56. package/scripts/test-learning-docs.sh +8 -0
  57. package/scripts/test-omp-staged-security.mjs +110 -0
  58. package/scripts/test-release-verification.mjs +109 -0
  59. package/scripts/test-release-verification.sh +4 -0
  60. package/scripts/test-runtime-file-security.mjs +311 -0
  61. package/scripts/test-setup-reconcile.mjs +4 -2
  62. package/scripts/test-windows-native.mjs +4 -0
  63. package/scripts/verify-omp-capture.mjs +1 -0
  64. package/scripts/verify-phase19-runtime-evidence.mjs +43 -3
  65. package/scripts/verify-published-release.mjs +138 -0
  66. package/scripts/windows-platform.mjs +54 -22
@@ -0,0 +1,311 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import { syncBuiltinESMExports } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { gzipSync } from "node:zlib";
8
+
9
+ import { readSetupPolicy, buildSetupPlan } from "./setup-core.mjs";
10
+ import { reconcileSetupPlan } from "./setup-reconcile.mjs";
11
+ import { diagnoseSetup } from "./setup.mjs";
12
+ import { reconcilePlaybookInstructions } from "./playbook-instructions.mjs";
13
+ import { createTar, runWindowsCommand } from "./windows-platform.mjs";
14
+ import { readStableFile, readStableText } from "./lib/stable-file.mjs";
15
+
16
+ const root = fs.mkdtempSync(join(tmpdir(), "cairn-cli-file-security-"));
17
+ const failures = [];
18
+ const original = {
19
+ lstat: fs.lstatSync, open: fs.openSync, close: fs.closeSync,
20
+ read: fs.readSync, readFile: fs.readFileSync,
21
+ rename: fs.renameSync, write: fs.writeFileSync,
22
+ };
23
+ let swap = 0;
24
+ function restore() {
25
+ fs.lstatSync = original.lstat;
26
+ fs.openSync = original.open;
27
+ fs.closeSync = original.close;
28
+ fs.readSync = original.read;
29
+ fs.readFileSync = original.readFile;
30
+ fs.renameSync = original.rename;
31
+ fs.writeFileSync = original.write;
32
+ syncBuiltinESMExports();
33
+ }
34
+ function write(path, value, mode = 0o600) {
35
+ fs.mkdirSync(dirname(path), { recursive: true });
36
+ fs.writeFileSync(path, value, { mode });
37
+ }
38
+ async function control(name, operation) {
39
+ try { await operation(); }
40
+ catch (error) { failures.push(`${name}: ${error.message}`); }
41
+ }
42
+ async function substitute(path, occurrence, replacement, operation) {
43
+ const candidate = join(root, "swaps", `${++swap}.candidate`);
44
+ const inspected = join(root, "swaps", `${swap}.inspected`);
45
+ write(candidate, replacement, fs.statSync(path).mode & 0o777);
46
+ let calls = 0;
47
+ let injected = false;
48
+ fs.lstatSync = (target, ...args) => {
49
+ const info = original.lstat(target, ...args);
50
+ if (target === path && ++calls === occurrence) {
51
+ injected = true;
52
+ fs.renameSync(path, inspected);
53
+ fs.renameSync(candidate, path);
54
+ }
55
+ return info;
56
+ };
57
+ syncBuiltinESMExports();
58
+ try { await operation(); }
59
+ finally { restore(); assert.equal(injected, true, "the actual reader must encounter replacement"); }
60
+ }
61
+ async function grow(path, operation) {
62
+ const inspectedSize = fs.statSync(path).size;
63
+ const descriptors = new Set();
64
+ let injected = false;
65
+ let unbounded = false;
66
+ const allocations = [];
67
+ function change() {
68
+ if (!injected) { injected = true; fs.appendFileSync(path, " ".repeat(2 * 1024 * 1024)); }
69
+ }
70
+ fs.openSync = (target, ...args) => {
71
+ const fd = original.open(target, ...args);
72
+ if (target === path) descriptors.add(fd);
73
+ return fd;
74
+ };
75
+ fs.closeSync = (fd) => { descriptors.delete(fd); return original.close(fd); };
76
+ fs.readSync = (fd, buffer, ...args) => {
77
+ if (descriptors.has(fd)) { change(); allocations.push(buffer.byteLength); }
78
+ return original.read(fd, buffer, ...args);
79
+ };
80
+ fs.readFileSync = (target, ...args) => {
81
+ if (target === path || descriptors.has(target)) { unbounded = true; change(); }
82
+ return original.readFile(target, ...args);
83
+ };
84
+ syncBuiltinESMExports();
85
+ try { await operation(); }
86
+ finally {
87
+ restore();
88
+ assert.equal(injected, true, "the actual reader must encounter growth");
89
+ assert.equal(unbounded, false, "CLI readers must not use an unbounded readFile result");
90
+ assert.ok(allocations.every((size) => size <= inspectedSize + 1));
91
+ assert.equal(descriptors.size, 0, "failed reads must close descriptors");
92
+ }
93
+ }
94
+
95
+ try {
96
+ const missingProject = join(root, "not-created-yet");
97
+ assert.equal(reconcilePlaybookInstructions(missingProject, { check: true }).status, "would-created");
98
+ assert.equal(fs.existsSync(missingProject), false, "instruction preflight must not create a new project");
99
+ assert.throws(() => reconcilePlaybookInstructions(missingProject), /ENOENT/);
100
+ const helperFixture = join(root, "helper.txt");
101
+ write(helperFixture, "bounded", 0o644);
102
+ assert.equal(readStableText(helperFixture, { label: "Fixture", maxBytes: 7 }).text, "bounded");
103
+ fs.readSync = (descriptor, buffer, offset, length, position) => original.read(descriptor, buffer, offset, Math.min(2, length), position);
104
+ syncBuiltinESMExports();
105
+ try { assert.equal(readStableText(helperFixture, { label: "Fixture", maxBytes: 7 }).text, "bounded", "partial reads must assemble complete content"); }
106
+ finally { restore(); }
107
+ let readAttempts = 0;
108
+ fs.readSync = (...args) => { readAttempts += 1; return original.read(...args); };
109
+ syncBuiltinESMExports();
110
+ try {
111
+ assert.throws(() => readStableFile(helperFixture, { label: "Fixture", maxBytes: 6 }), /unsafe/);
112
+ if (process.platform !== "win32") assert.throws(() => readStableFile("/dev/null", { label: "Fixture", maxBytes: 7 }), /unsafe/);
113
+ assert.equal(readAttempts, 0, "oversized and special files must be rejected before content reads");
114
+ } finally { restore(); }
115
+ write(helperFixture, Buffer.from([0xff]), 0o644);
116
+ assert.throws(() => readStableText(helperFixture, { label: "Fixture", maxBytes: 7 }), /UTF-8/);
117
+ if (process.platform !== "win32") assert.throws(() => readStableFile(helperFixture, { label: "Fixture", maxBytes: 7, privateMode: 0o600 }), /unsafe/);
118
+
119
+ const policyPath = join(root, "policy.json");
120
+ write(policyPath, '{"schema_version":1,"defaults":{},"constraints":{}}', 0o644);
121
+ assert.equal(readSetupPolicy(policyPath).schema_version, 1);
122
+ await control("setup policy growth", () => grow(policyPath, () => {
123
+ assert.throws(() => readSetupPolicy(policyPath), /unsafe|changed|limit/i);
124
+ }));
125
+
126
+ const project = join(root, "project");
127
+ fs.mkdirSync(project);
128
+ const plan = buildSetupPlan({
129
+ target: project,
130
+ preflight: { targetState: "empty", gitExecutable: "available", repository: "work-tree" },
131
+ choices: { git: "existing", harnesses: ["claude"], memory: "local", confirmed: true },
132
+ });
133
+ const created = await reconcileSetupPlan(plan);
134
+ assert.equal(diagnoseSetup(project).status, "complete");
135
+ const rerun = await reconcileSetupPlan(plan, { previousState: created.state });
136
+ assert.deepEqual(rerun.counts, { created: 0, updated: 0, unchanged: plan.assets.length, skipped: 0 },
137
+ "native mode representation must not discard generated-file ownership");
138
+ const statePath = join(project, ".ai", "cairnkeep.json");
139
+ const stateBytes = fs.readFileSync(statePath);
140
+ await control("setup diagnosis state growth", () => grow(statePath, () => {
141
+ assert.equal(diagnoseSetup(project).status, "incomplete");
142
+ }));
143
+ write(statePath, stateBytes);
144
+ const owned = join(project, ".ai", "start-claude.sh");
145
+ const ownedBytes = fs.readFileSync(owned);
146
+ write(owned, "# Caller-owned launcher.\n", 0o755);
147
+ await control("setup diagnosis owned replacement", () => substitute(owned, 1, ownedBytes, () => {
148
+ assert.equal(diagnoseSetup(project).status, "incomplete", "replacement must not restore recorded ownership");
149
+ }));
150
+ write(owned, "# Caller-owned launcher.\n", 0o755);
151
+ await control("setup reconciliation ownership replacement", () => substitute(owned, 2, ownedBytes, async () => {
152
+ await assert.rejects(() => reconcileSetupPlan(plan, { previousState: created.state }), /unsafe|changed/i);
153
+ }));
154
+ write(owned, ownedBytes, 0o755);
155
+ await control("setup mutation ownership recheck", async () => {
156
+ const update = { ...plan, assets: [ { ...plan.assets.find((asset) => asset.path === ".ai/start-claude.sh"), bytes: Buffer.from("# Generated update.\n") } ] };
157
+ let observed;
158
+ let inspected = false;
159
+ let injected = false;
160
+ fs.openSync = (path, ...args) => {
161
+ const descriptor = original.open(path, ...args);
162
+ if (path === owned) observed = descriptor;
163
+ return descriptor;
164
+ };
165
+ fs.closeSync = (descriptor) => {
166
+ if (descriptor === observed) inspected = true;
167
+ return original.close(descriptor);
168
+ };
169
+ fs.lstatSync = (path, ...args) => {
170
+ if (inspected && !injected && path === dirname(owned)) {
171
+ injected = true;
172
+ original.write(owned, "# Concurrent caller rule.\n");
173
+ }
174
+ return original.lstat(path, ...args);
175
+ };
176
+ syncBuiltinESMExports();
177
+ try {
178
+ await assert.rejects(() => reconcileSetupPlan(update, { previousState: created.state }), /changed|unsafe/i);
179
+ assert.equal(injected, true);
180
+ assert.equal(fs.readFileSync(owned, "utf8"), "# Concurrent caller rule.\n");
181
+ } finally { restore(); write(owned, ownedBytes, 0o755); }
182
+ });
183
+
184
+ const agents = join(project, "AGENTS.md");
185
+ write(agents, "# User rules\n\nRetain this line.\n", 0o640);
186
+ assert.equal(reconcilePlaybookInstructions(project, { check: true }).status, "would-updated");
187
+ await control("playbook instructions growth", () => grow(agents, () => {
188
+ assert.throws(() => reconcilePlaybookInstructions(project, { check: true }), /unsafe|changed|bounded/i);
189
+ }));
190
+ write(agents, "# User rules\n\nRetain this line.\n", 0o640);
191
+ fs.chmodSync(agents, 0o640);
192
+ assert.equal(reconcilePlaybookInstructions(project).status, "updated");
193
+ assert.match(fs.readFileSync(agents, "utf8"), /Retain this line/);
194
+ if (process.platform !== "win32") assert.equal(fs.statSync(agents).mode & 0o777, 0o640);
195
+ assert.equal(reconcilePlaybookInstructions(project).status, "unchanged");
196
+ await control("instruction mutation ownership recheck", () => {
197
+ write(agents, "# Prior instructions\n", 0o640);
198
+ let injected = false;
199
+ fs.writeFileSync = (path, ...args) => {
200
+ if (!injected && typeof path === "string" && basename(path).startsWith(".AGENTS.md.")) {
201
+ injected = true;
202
+ original.write(agents, "# Concurrent caller rule.\n");
203
+ }
204
+ return original.write(path, ...args);
205
+ };
206
+ fs.openSync = (path, ...args) => {
207
+ if (!injected && typeof path === "string" && basename(path).startsWith(".AGENTS.md.")) {
208
+ injected = true;
209
+ original.write(agents, "# Concurrent caller rule.\n");
210
+ }
211
+ return original.open(path, ...args);
212
+ };
213
+ syncBuiltinESMExports();
214
+ try {
215
+ assert.throws(() => reconcilePlaybookInstructions(project), /changed|unsafe/i);
216
+ assert.equal(injected, true);
217
+ assert.equal(fs.readFileSync(agents, "utf8"), "# Concurrent caller rule.\n");
218
+ assert.equal(fs.readdirSync(project).some((name) => name.startsWith(".AGENTS.md.")), false);
219
+ } finally { restore(); }
220
+ });
221
+ const unapproved = join(root, "unapproved");
222
+ fs.mkdirSync(unapproved);
223
+ write(join(unapproved, "AGENTS.md"), "# Private rule\n", 0o640);
224
+ if (process.platform !== "win32") {
225
+ const linkedProject = join(root, "linked-project");
226
+ fs.symlinkSync(unapproved, linkedProject, "dir");
227
+ await control("playbook project symlink", () => {
228
+ assert.throws(() => reconcilePlaybookInstructions(linkedProject), /unsafe|symlink|directory/i);
229
+ assert.equal(fs.readFileSync(join(unapproved, "AGENTS.md"), "utf8"), "# Private rule\n");
230
+ });
231
+ }
232
+
233
+ const archive = join(root, "memory.tgz");
234
+ const archiveBytes = gzipSync(createTar([["project.db", Buffer.from("New database fixture.")]]));
235
+ write(archive, archiveBytes);
236
+ const memory = join(root, "memory");
237
+ const database = join(memory, "project.db");
238
+ write(database, "Prior database fixture.");
239
+ const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
240
+ const previousBase = process.env.CAIRN_AGENTFS_BASE_DIR;
241
+ process.env.CAIRN_AGENTFS_BASE_DIR = memory;
242
+ try {
243
+ await control("Windows archive growth", () => grow(archive, async () => {
244
+ await assert.rejects(() => runWindowsCommand({ command: "memory", args: ["import", archive], root: repository }), /unsafe|changed/i);
245
+ }));
246
+ write(archive, archiveBytes);
247
+ fs.renameSync = (source, destination) => {
248
+ if (destination === database) throw Object.assign(new Error("Synthetic denied replacement."), { code: "EPERM" });
249
+ return original.rename(source, destination);
250
+ };
251
+ syncBuiltinESMExports();
252
+ await control("Windows denied replacement retention", async () => {
253
+ try {
254
+ await assert.rejects(() => runWindowsCommand({ command: "memory", args: ["import", archive], root: repository }), /denied/i);
255
+ assert.equal(fs.readFileSync(database, "utf8"), "Prior database fixture.", "replacement failure must retain the live database");
256
+ assert.deepEqual(fs.readdirSync(memory).filter((name) => name.includes(".tmp-")), [], "failed replacement must clean its own temporary files");
257
+ } finally { restore(); }
258
+ });
259
+ write(database, "Prior database fixture.");
260
+ let denied = 0;
261
+ fs.renameSync = (source, destination) => {
262
+ if (destination === database && denied++ < 2) {
263
+ assert.equal(fs.readFileSync(database, "utf8"), "Prior database fixture.");
264
+ throw Object.assign(new Error("Synthetic transient sharing violation."), { code: "EBUSY" });
265
+ }
266
+ return original.rename(source, destination);
267
+ };
268
+ syncBuiltinESMExports();
269
+ try {
270
+ await runWindowsCommand({ command: "memory", args: ["import", archive], root: repository });
271
+ assert.equal(denied, 3);
272
+ assert.equal(fs.readFileSync(database, "utf8"), "New database fixture.");
273
+ } finally { restore(); }
274
+ write(database, "Prior database fixture.");
275
+ if (process.platform !== "win32") {
276
+ const outside = join(root, "outside-target");
277
+ write(outside, "Outside sentinel.");
278
+ let injected = false;
279
+ let collidingTemporary;
280
+ function substituteTemporary(path) {
281
+ if (!injected && typeof path === "string" && dirname(path) === memory
282
+ && basename(path).startsWith("project.db.tmp-")) {
283
+ injected = true;
284
+ collidingTemporary = path;
285
+ fs.symlinkSync(outside, path);
286
+ }
287
+ }
288
+ fs.openSync = (path, ...args) => { substituteTemporary(path); return original.open(path, ...args); };
289
+ fs.writeFileSync = (path, ...args) => { substituteTemporary(path); return original.write(path, ...args); };
290
+ syncBuiltinESMExports();
291
+ await control("Windows exclusive temporary publication", async () => {
292
+ try {
293
+ await assert.rejects(() => runWindowsCommand({ command: "memory", args: ["import", archive], root: repository }), /exist|unsafe/i);
294
+ assert.equal(injected, true);
295
+ assert.equal(fs.readFileSync(outside, "utf8"), "Outside sentinel.", "temporary symlink must not receive private content");
296
+ assert.equal(fs.readFileSync(database, "utf8"), "Prior database fixture.");
297
+ assert.equal(fs.lstatSync(collidingTemporary).isSymbolicLink(), true, "a collision must not delete an unowned temporary name");
298
+ } finally { restore(); }
299
+ });
300
+ }
301
+ } finally {
302
+ if (previousBase === undefined) delete process.env.CAIRN_AGENTFS_BASE_DIR;
303
+ else process.env.CAIRN_AGENTFS_BASE_DIR = previousBase;
304
+ }
305
+
306
+ assert.deepEqual(failures, [], "CLI file boundaries must fail closed without changing ownership");
307
+ } finally {
308
+ restore();
309
+ fs.rmSync(root, { recursive: true, force: true });
310
+ }
311
+ console.log("PASS: bounded setup/instruction/archive reads and denied/exclusive Windows publication");
@@ -18,6 +18,7 @@ import {
18
18
  import { tmpdir } from "node:os";
19
19
  import { dirname, join, resolve } from "node:path";
20
20
  import { fileURLToPath, pathToFileURL } from "node:url";
21
+ import { readStableFile } from "./lib/stable-file.mjs";
21
22
 
22
23
  const EXPECTED_RED_EXIT = 86;
23
24
  const RED_MARKER = "PHASE26_RED:SETUP_RECONCILE_MISSING";
@@ -172,8 +173,9 @@ async function testOwnershipBranches(reconciler, fixture, baseline) {
172
173
 
173
174
  writeFileSync(ownedPath, "operator-owned\n");
174
175
  chmodSync(ownedPath, 0o700);
175
- const userBytes = readFileSync(ownedPath);
176
- const userMtime = statSync(ownedPath).mtimeMs;
176
+ const userSnapshot = readStableFile(ownedPath, { label: "Operator-owned setup fixture", maxBytes: 1024 * 1024 });
177
+ const userBytes = userSnapshot.bytes;
178
+ const userMtime = userSnapshot.stat.mtimeMs;
177
179
  const diverged = await reconciler.reconcileSetupPlan(plan(fixture.target, [fixture.first]), { previousState: updated.state });
178
180
  assertCounts(diverged, { created: 0, updated: 0, unchanged: 0, skipped: 1 });
179
181
  assert.deepEqual(readFileSync(ownedPath), userBytes);
@@ -21,6 +21,10 @@ import { HARNESS_IDS } from "./harness-registry.mjs";
21
21
  const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
22
22
  // Runs on real Windows and POSIX, independently of shell-only doctor checks.
23
23
  await import("./test-setup-diagnosis.mjs");
24
+ await import("./test-runtime-file-security.mjs");
25
+ await import("./test-container-health.mjs");
26
+ await import("./test-omp-staged-security.mjs");
27
+ await import("./test-release-verification.mjs");
24
28
  const sandbox = mkdtempSync(join(tmpdir(), "cairn-windows-native-"));
25
29
  const originalBase = process.env.CAIRN_AGENTFS_BASE_DIR;
26
30
  const originalPackBase = process.env.CAIRN_PACK_BASE_DIR;
@@ -79,6 +79,7 @@ function writeFakeInfra(sandbox) {
79
79
  writeFileSync(stub, STUB_SERVER);
80
80
  chmodSync(stub, 0o755);
81
81
  copyFileSync(stageWrapper, join(fakeRoot, "scripts", "lib", "omp-capture-stage.mjs"));
82
+ copyFileSync(join(root, "scripts", "lib", "stable-file.mjs"), join(fakeRoot, "scripts", "lib", "stable-file.mjs"));
82
83
  return fakeRoot;
83
84
  }
84
85
 
@@ -2,6 +2,8 @@
2
2
  import assert from "node:assert/strict";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
+ import fs from "node:fs";
6
+ import { syncBuiltinESMExports } from "node:module";
5
7
  import {
6
8
  existsSync,
7
9
  lstatSync,
@@ -15,6 +17,7 @@ import {
15
17
  import { tmpdir } from "node:os";
16
18
  import { dirname, isAbsolute, join, resolve } from "node:path";
17
19
  import { fileURLToPath } from "node:url";
20
+ import { readStableFile } from "./lib/stable-file.mjs";
18
21
 
19
22
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
20
23
  const EVIDENCE_RELATIVE = join(
@@ -250,9 +253,7 @@ function assertNoSentinels(bytes, label) {
250
253
  function readOwnedFile(root, relative) {
251
254
  const label = safeRelative(relative, "evidence file");
252
255
  const path = join(root, label);
253
- const info = lstatSync(path);
254
- if (!info.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 * 1024) throw new Error(`${label}: unsafe evidence file`);
255
- const bytes = readFileSync(path);
256
+ const { bytes } = readStableFile(path, { label: "Runtime evidence file", maxBytes: 16 * 1024 * 1024 });
256
257
  assertNoSentinels(bytes, label);
257
258
  return bytes;
258
259
  }
@@ -762,6 +763,44 @@ function expectFailure(label, operation) {
762
763
  assert.throws(operation, undefined, label);
763
764
  }
764
765
 
766
+ function testEvidenceReadRaces(root) {
767
+ const path = join(root, "read-race.log");
768
+ const original = { readFileSync: fs.readFileSync, readSync: fs.readSync, lstatSync: fs.lstatSync };
769
+ let injected = false;
770
+ const grow = () => {
771
+ if (!injected) { injected = true; fs.appendFileSync(path, "changed-after-inspection\n"); }
772
+ };
773
+ writeFileSync(path, "original\n");
774
+ fs.readFileSync = (candidate, ...args) => { if (candidate === path) grow(); return original.readFileSync(candidate, ...args); };
775
+ fs.readSync = (...args) => { grow(); return original.readSync(...args); };
776
+ syncBuiltinESMExports();
777
+ try {
778
+ assert.throws(() => readOwnedFile(root, "read-race.log"), /unsafe|changed/,
779
+ "evidence reader accepted bytes changed after inspection");
780
+ assert.equal(injected, true, "evidence growth race was not exercised");
781
+ } finally { Object.assign(fs, original); syncBuiltinESMExports(); }
782
+
783
+ writeFileSync(path, "original\n");
784
+ const replacement = join(root, "replacement.log");
785
+ writeFileSync(replacement, "replacement\n");
786
+ injected = false;
787
+ fs.lstatSync = (candidate, ...args) => {
788
+ const result = original.lstatSync(candidate, ...args);
789
+ if (candidate === path && !injected) {
790
+ injected = true;
791
+ renameSync(path, join(root, "old.log"));
792
+ renameSync(replacement, path);
793
+ }
794
+ return result;
795
+ };
796
+ syncBuiltinESMExports();
797
+ try {
798
+ assert.throws(() => readOwnedFile(root, "read-race.log"), /unsafe|changed/,
799
+ "evidence reader accepted a pathname replacement");
800
+ assert.equal(injected, true, "evidence replacement race was not exercised");
801
+ } finally { Object.assign(fs, original); syncBuiltinESMExports(); }
802
+ }
803
+
765
804
  function selfTest() {
766
805
  const sourceCommit = requireSuccess(git(ROOT, ["rev-parse", "HEAD"]), "read self-test source commit");
767
806
  requireCommit(sourceCommit);
@@ -787,6 +826,7 @@ function selfTest() {
787
826
  "published contract mutation did not change its digest");
788
827
  const root = mkdtempSync(join(tmpdir(), "cairn-phase19-evidence-"));
789
828
  try {
829
+ testEvidenceReadRaces(root);
790
830
  const valid = join(root, "valid");
791
831
  writeFixture(valid, sourceCommit);
792
832
  verifyEvidence(valid, sourceCommit);
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { readStableFile } from "./lib/stable-file.mjs";
9
+ import { releaseAttestationArguments, validateReleaseOptions, verifyPublishedRelease } from "./lib/release-verification.mjs";
10
+
11
+ const usage = "Usage: node scripts/verify-published-release.mjs --version X.Y.Z --commit SHA --tree SHA --ci-run ID --security-run ID --publish-run ID --out FILE [--repo OWNER/REPO] [--gh PATH] [--npm PATH]";
12
+ function optionsFrom(args) {
13
+ const options = { repo: "cairnkeep/cairnkeep", gh: "gh", npm: "npm" };
14
+ const keys = new Map([["--version", "version"], ["--commit", "commit"], ["--tree", "tree"], ["--ci-run", "ciRun"],
15
+ ["--security-run", "securityRun"], ["--publish-run", "publishRun"], ["--repo", "repo"], ["--gh", "gh"], ["--npm", "npm"], ["--out", "out"]]);
16
+ const seen = new Set();
17
+ for (let i = 0; i < args.length; i += 2) {
18
+ const key = keys.get(args[i]);
19
+ if (!key || seen.has(key) || !args[i + 1] || args[i + 1].startsWith("--")) throw new Error(usage);
20
+ seen.add(key); options[key] = args[i + 1];
21
+ }
22
+ if (!options.out) throw new Error(usage);
23
+ validateReleaseOptions(options);
24
+ return options;
25
+ }
26
+
27
+ function run(command, args, options = {}) {
28
+ const result = spawnSync(command, args, { encoding: "utf8", timeout: 60000, maxBuffer: 16 * 1024 * 1024, ...options });
29
+ if (result.error || result.status !== 0) {
30
+ const operation = ["api", "run", "release", "install", "audit", "attestation"].includes(args[0]) ? args[0] : "required";
31
+ throw new Error(`Release verification failed: ${operation} command unavailable, timed out or failed.`);
32
+ }
33
+ return result.stdout;
34
+ }
35
+
36
+ async function bytesFrom(url, maxBytes, headers = {}, blobRedirects = false) {
37
+ const signal = AbortSignal.timeout(30000);
38
+ let response;
39
+ for (let redirects = 0; redirects <= 3; redirects++) {
40
+ response = await fetch(url, { headers, redirect: "manual", signal });
41
+ if (![301, 302, 303, 307, 308].includes(response.status)) break;
42
+ const next = new URL(response.headers.get("location"), url);
43
+ await response.body?.cancel();
44
+ if (!blobRedirects || redirects === 3 || next.protocol !== "https:"
45
+ || next.hostname !== "pkg-containers.githubusercontent.com" || next.username || next.password || next.port) {
46
+ throw new Error("Release verification failed: unexpected registry redirect.");
47
+ }
48
+ url = next.href; headers = {}; // Never forward registry authorization.
49
+ }
50
+ if (!response.ok || !response.body) throw new Error("Release verification failed: remote response unavailable.");
51
+ const declared = Number(response.headers.get("content-length"));
52
+ if (Number.isFinite(declared) && declared > maxBytes) { await response.body.cancel(); throw new Error("Release verification failed: remote byte limit."); }
53
+ const reader = response.body.getReader();
54
+ const chunks = []; let total = 0;
55
+ try {
56
+ while (true) {
57
+ const { done, value } = await reader.read(); if (done) break;
58
+ total += value.byteLength;
59
+ if (total > maxBytes) throw new Error("Release verification failed: remote byte limit.");
60
+ chunks.push(Buffer.from(value));
61
+ }
62
+ return Buffer.concat(chunks, total);
63
+ } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
64
+ }
65
+ const parse = bytes => JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
66
+ const digest = bytes => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
67
+
68
+ async function main(args) {
69
+ if (args.length === 1 && ["--help", "-h"].includes(args[0])) { console.log(usage); return; }
70
+ const options = optionsFrom(args);
71
+ const { gh, npm, out, ...expected } = options;
72
+ const output = resolve(out);
73
+ try { fs.lstatSync(output); throw new Error("Release verification failed: output already exists."); }
74
+ catch (error) { if (error.code !== "ENOENT") throw error; }
75
+ const scratch = fs.mkdtempSync(join(tmpdir(), "cairn-release-verification-"));
76
+ fs.chmodSync(scratch, 0o700);
77
+ const ghRun = args => run(gh, args);
78
+ const readDirectory = directory => new Map(fs.readdirSync(directory).map(name => [name,
79
+ readStableFile(join(directory, name), { label: "Release verification input", maxBytes: 64 * 1024 * 1024 }).bytes]));
80
+ const tokens = new Map();
81
+ try {
82
+ const io = {
83
+ api: async suffix => JSON.parse(ghRun(["api", `repos/${expected.repo}/${suffix}`])),
84
+ candidate: async artifact => {
85
+ const directory = join(scratch, "candidate");
86
+ ghRun(["run", "download", expected.ciRun, "--repo", expected.repo, "--name", artifact.name, "--dir", directory]);
87
+ return readDirectory(directory);
88
+ },
89
+ release: async () => {
90
+ const directory = join(scratch, "release");
91
+ ghRun(["release", "download", `v${expected.version}`, "--repo", expected.repo, "--dir", directory]);
92
+ return readDirectory(directory);
93
+ },
94
+ registry: async () => parse(await bytesFrom(`https://registry.npmjs.org/@cairnkeep%2fcli/${expected.version}`, 8 * 1024 * 1024)),
95
+ tarball: async url => bytesFrom(url, 64 * 1024 * 1024),
96
+ verifyNpm: async tarball => {
97
+ const prefix = join(scratch, "npm");
98
+ run(npm, ["install", "--prefix", prefix, `@cairnkeep/cli@${expected.version}`, "--ignore-scripts", "--no-audit", "--no-fund", "--registry", "https://registry.npmjs.org"]);
99
+ const lock = parse(readStableFile(join(prefix, "package-lock.json"), { label: "Verification install lock", maxBytes: 8 * 1024 * 1024 }).bytes);
100
+ if (lock.packages?.["node_modules/@cairnkeep/cli"]?.integrity !== `sha512-${createHash("sha512").update(tarball).digest("base64")}`) {
101
+ throw new Error("Release verification failed: audited package/candidate binding.");
102
+ }
103
+ const audited = run(npm, ["audit", "signatures", "--prefix", prefix, "--registry", "https://registry.npmjs.org"]);
104
+ return /[1-9][0-9]* packages have verified registry signatures/.test(audited)
105
+ && /[1-9][0-9]* packages have verified attestations/.test(audited);
106
+ },
107
+ oci: async (image, kind, ref) => {
108
+ if (!tokens.has(image)) {
109
+ const result = parse(await bytesFrom(`https://ghcr.io/token?service=ghcr.io&scope=repository:${image}:pull`, 64 * 1024));
110
+ if (typeof result.token !== "string" || !result.token) throw new Error("Release verification failed: anonymous OCI access.");
111
+ tokens.set(image, result.token);
112
+ }
113
+ const bytes = await bytesFrom(`https://ghcr.io/v2/${image}/${kind}/${ref}`, 16 * 1024 * 1024, {
114
+ Authorization: `Bearer ${tokens.get(image)}`,
115
+ Accept: "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json",
116
+ }, kind === "blobs");
117
+ return { digest: digest(bytes), value: parse(bytes) };
118
+ },
119
+ verifyAttestation: async (image, imageDigest) => {
120
+ const verified = JSON.parse(ghRun(releaseAttestationArguments(image, imageDigest, expected)));
121
+ return Array.isArray(verified) && verified.length > 0;
122
+ },
123
+ };
124
+ const report = await verifyPublishedRelease(expected, io);
125
+ fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`, { flag: "wx", mode: 0o600 });
126
+ console.log(JSON.stringify(report));
127
+ } finally { fs.rmSync(scratch, { recursive: true, force: true }); }
128
+ }
129
+
130
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
131
+ const previousMask = process.umask(0o077);
132
+ try { await main(process.argv.slice(2)); }
133
+ catch (error) {
134
+ console.error(error.message === usage || error.message?.startsWith("Release verification failed:")
135
+ ? error.message : "Release verification failed; no verified report was written.");
136
+ process.exitCode = 1;
137
+ } finally { process.umask(previousMask); }
138
+ }