@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
package/scripts/setup.mjs CHANGED
@@ -1,13 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import {
3
- closeSync,
4
- constants,
5
3
  existsSync,
6
- fstatSync,
7
4
  lstatSync,
8
5
  mkdirSync,
9
- openSync,
10
- readFileSync,
11
6
  } from "node:fs";
12
7
  import { createHash } from "node:crypto";
13
8
  import { homedir } from "node:os";
@@ -25,6 +20,7 @@ import { reconcileSetupPlan } from "./setup-reconcile.mjs";
25
20
  import { HARNESS_IDS, HARNESS_REGISTRY, machineSyncCommand, machineSyncCommands, requiredHarnessAssetPaths } from "./harness-registry.mjs";
26
21
  import { reconcilePlaybookInstructions } from "./playbook-instructions.mjs";
27
22
  import { selectManyPrompt, selectOnePrompt, supportsTerminalPrompts } from "./terminal-prompts.mjs";
23
+ import { readStableFile, readStableJson, readStableText } from "./lib/stable-file.mjs";
28
24
 
29
25
  const HARNESSES = HARNESS_IDS;
30
26
  const GIT_MODES = Object.freeze(["init", "existing", "none"]);
@@ -360,33 +356,14 @@ function safeRequiredAsset(project, path, launcher) {
360
356
  }
361
357
 
362
358
  function readPrivateState(path) {
363
- const info = lstatSync(path);
364
- if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("unsafe");
365
- if (process.platform !== "win32" && (info.mode & 0o777) !== 0o600) throw new Error("unsafe");
366
- let descriptor;
367
- try {
368
- descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
369
- const opened = fstatSync(descriptor);
370
- if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > 1024 * 1024) throw new Error("unsafe");
371
- return JSON.parse(readFileSync(descriptor, "utf8"));
372
- } finally {
373
- if (descriptor !== undefined) closeSync(descriptor);
374
- }
359
+ return readStableJson(path, { label: "Setup state", maxBytes: 1024 * 1024, privateMode: 0o600 });
375
360
  }
376
361
 
377
362
  function readTomlConfig(path) {
378
363
  if (!existsSync(path)) return null;
379
- const info = lstatSync(path);
380
- if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) return null;
381
- let descriptor;
382
364
  try {
383
- descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
384
- const opened = fstatSync(descriptor);
385
- if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > 1024 * 1024) return null;
386
- return readFileSync(descriptor, "utf8");
387
- } finally {
388
- if (descriptor !== undefined) closeSync(descriptor);
389
- }
365
+ return readStableText(path, { label: "Harness configuration", maxBytes: 1024 * 1024 }).text;
366
+ } catch { return null; }
390
367
  }
391
368
 
392
369
  function codexMemorySection(text, id) {
@@ -457,10 +434,9 @@ export function diagnoseSetup(target = ".") {
457
434
  if (!safeStateAsset(path, record)) return incompleteDiagnosis(recovery);
458
435
  const destination = join(project, ...path.split("/"));
459
436
  if (!existsSync(destination)) return incompleteDiagnosis(recovery);
460
- const info = lstatSync(destination);
461
- if (!info.isFile() || info.isSymbolicLink()) return incompleteDiagnosis(recovery);
462
- const digest = createHash("sha256").update(readFileSync(destination)).digest("hex");
463
- if (digest !== record.digest || (process.platform !== "win32" && (info.mode & 0o777) !== record.mode)) return incompleteDiagnosis(recovery);
437
+ const current = readStableFile(destination, { label: "Setup asset", maxBytes: 1024 * 1024 });
438
+ const digest = createHash("sha256").update(current.bytes).digest("hex");
439
+ if (digest !== record.digest || (process.platform !== "win32" && (current.stat.mode & 0o777) !== record.mode)) return incompleteDiagnosis(recovery);
464
440
  }
465
441
  const requiredAssets = [...COMMON_SETUP_ASSETS, ...requiredHarnessAssetPaths(state.harnesses, state.memory)];
466
442
  const launchers = new Set(state.harnesses.map((id) => HARNESS_REGISTRY.find((harness) => harness.id === id).launcher.path));
@@ -29,5 +29,10 @@ for (const shard of ["1/3", "2/3", "3/3"]) assert.ok(workflow.includes(`shard: "
29
29
  assert.match(workflow, /run-smoke-suite\.mjs --shard \$\{\{ matrix\.shard \}\}/);
30
30
  assert.doesNotMatch(workflow.match(/ native-windows:[\s\S]*?\n repository:/)?.[0] ?? "",
31
31
  /npm --prefix mcp-memory-server test/);
32
+ const native = workflow.match(/ native-windows:[\s\S]*?\n repository:/)?.[0] ?? "";
33
+ const focused = native.indexOf("run: node mcp-memory-server/scripts/smoke-bounded-input-reads.mjs");
34
+ assert.ok(focused > native.indexOf("npm --prefix mcp-memory-server run build"));
35
+ assert.ok(focused < native.indexOf("- name: Native Windows CLI contracts"));
36
+ assert.ok(focused < native.indexOf("- name: Sharded memory-server contracts"));
32
37
  console.log(`PASS: ${smokeChecks.length} memory-server contracts split once across balanced Windows shards (${weights.join(", ")})`);
33
38
  NODE
@@ -0,0 +1,56 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, writeFileSync, chmodSync, rmSync, symlinkSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { hardenPrivatePath } from "../mcp-memory-server/dist/platform-security.js";
6
+
7
+ const source = readFileSync(new URL("../containers/healthcheck.mjs", import.meta.url), "utf8");
8
+ const containerfile = readFileSync(new URL("../Containerfile", import.meta.url), "utf8");
9
+ const quadlet = readFileSync(new URL("../containers/quadlet/cairnkeep.container", import.meta.url), "utf8");
10
+ assert.match(quadlet, /^Secret=cairnkeep-http-token,type=mount,target=http-token,uid=10001,gid=10001,mode=0400$/m,
11
+ "the managed container secret must satisfy the probe's private-file contract");
12
+ assert.match(containerfile, /COPY --chmod=644 containers\/healthcheck\.mjs \/opt\/cairnkeep\/containers\/healthcheck\.mjs/, "probe imports must resolve within the packaged server");
13
+ assert.match(containerfile, /ln -s \/opt\/cairnkeep\/containers\/healthcheck\.mjs \/usr\/local\/lib\/cairnkeep\/container-healthcheck\.mjs/, "keep the established container probe path");
14
+ assert.match(source, /parseHttpPort/, "health destination must use the server's numeric port parser");
15
+ assert.match(source, /resolveHttpToken/, "health tokens must use the server's bounded resolver");
16
+ const { containerHealthcheck } = await import("../containers/healthcheck.mjs");
17
+ const root = mkdtempSync(join(tmpdir(), "cairn-health-security-"));
18
+ const originalFetch = globalThis.fetch;
19
+ const token = "synthetic-health-token-0123456789";
20
+ const requests = [];
21
+ try {
22
+ globalThis.fetch = async (url, options) => {
23
+ requests.push({ url: String(url), options });
24
+ return Response.json({ result: {} });
25
+ };
26
+ assert.equal(await containerHealthcheck({}), 0);
27
+ for (const port of ["7801@external.example.test", "1/path", "-1", "65536", " 7801 "]) {
28
+ assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: port, CAIRN_MEMORY_HTTP_TOKEN: token }), 1);
29
+ }
30
+ assert.equal(requests.length, 0, "disabled or invalid configuration must not fetch");
31
+ assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: "7801", CAIRN_MEMORY_HTTP_TOKEN: token }), 0);
32
+ assert.equal(requests[0].url, "http://127.0.0.1:7801/mcp");
33
+ assert.equal(requests[0].options.redirect, "manual");
34
+ assert.equal(requests[0].options.headers.Authorization, `Bearer ${token}`);
35
+ assert.equal(JSON.parse(requests[0].options.body).method, "initialize");
36
+ const tokenFile = join(root, "token");
37
+ writeFileSync(tokenFile, token + "\n", { mode: 0o600 });
38
+ hardenPrivatePath(tokenFile);
39
+ assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: "7801", CAIRN_MEMORY_HTTP_TOKEN_FILE: tokenFile }), 0);
40
+ if (process.platform !== "win32") {
41
+ chmodSync(tokenFile, 0o644);
42
+ assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: "7801", CAIRN_MEMORY_HTTP_TOKEN_FILE: tokenFile }), 1);
43
+ chmodSync(tokenFile, 0o600);
44
+ const link = join(root, "linked-token");
45
+ symlinkSync(tokenFile, link);
46
+ assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: "7801", CAIRN_MEMORY_HTTP_TOKEN_FILE: link }), 1);
47
+ }
48
+ writeFileSync(tokenFile, "x".repeat(64 * 1024 + 1));
49
+ assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: "7801", CAIRN_MEMORY_HTTP_TOKEN_FILE: tokenFile }), 1);
50
+ globalThis.fetch = async () => new Response(null, { status: 302, headers: { Location: "https://external.example.test" } });
51
+ assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: "7801", CAIRN_MEMORY_HTTP_TOKEN: token }), 1);
52
+ } finally {
53
+ globalThis.fetch = originalFetch;
54
+ rmSync(root, { recursive: true, force: true });
55
+ }
56
+ console.log("PASS: container health checks remain loopback-only with bounded, safe credentials and no redirects");
@@ -110,7 +110,14 @@ done
110
110
  exit 1
111
111
  }
112
112
  cat "$tmp/write.out"
113
- "$ENGINE" exec "$name" node /usr/local/lib/cairnkeep/container-healthcheck.mjs
113
+ # Exec probes see the original container environment, not the entrypoint's
114
+ # loaded secret. The deliberately public bind fixture must be rejected.
115
+ if "$ENGINE" exec "$name" node /usr/local/lib/cairnkeep/container-healthcheck.mjs; then
116
+ echo "FAIL: healthcheck accepted a non-private token file" >&2
117
+ exit 1
118
+ fi
119
+ # Exercise the same legacy entrypoint against a private, container-owned file.
120
+ "$ENGINE" exec "$name" sh -c 'umask 077; cat /run/secrets/http-token > /tmp/health-token; CAIRN_MEMORY_HTTP_TOKEN_FILE=/tmp/health-token node /usr/local/lib/cairnkeep/container-healthcheck.mjs; status=$?; rm -f /tmp/health-token; exit "$status"'
114
121
  "$ENGINE" rm -f "$name" >/dev/null
115
122
 
116
123
  # Replacement boot with the same volume must recover the canary.
@@ -192,5 +192,13 @@ for phrase in \
192
192
  }
193
193
  done
194
194
  grep -qF '`cairn security` | L28' "$coverage"
195
+ for file in "$l28" "$video28" docs/security-assurance.md; do
196
+ for phrase in 'smoke-note-signatures.mjs' 'smoke-runtime-file-reads.mjs' 'smoke-bounded-input-reads.mjs' 'smoke-workspace-file-security.mjs' 'smoke-outbound-security.mjs' 'test-runtime-file-security.mjs' 'test-omp-staged-security.mjs' 'test-container-health.mjs' 'coverage-guided'; do
197
+ grep -qF -- "$phrase" "$file" || {
198
+ echo "Security learning document missing runtime regression boundary: $file ($phrase)" >&2
199
+ exit 1
200
+ }
201
+ done
202
+ done
195
203
 
196
204
  echo "PASS: public learning path structure, readiness, links, and version alignment"
@@ -0,0 +1,110 @@
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 { dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+
8
+ const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
+ const root = fs.mkdtempSync(join(tmpdir(), "cairn-omp-staged-security-"));
10
+ const originalRead = fs.readFileSync;
11
+ const originalDescriptorRead = fs.readSync;
12
+ const originalOpen = fs.openSync;
13
+ const failures = [];
14
+ function restore() {
15
+ fs.readFileSync = originalRead;
16
+ fs.readSync = originalDescriptorRead;
17
+ fs.openSync = originalOpen;
18
+ syncBuiltinESMExports();
19
+ }
20
+ function mock() {
21
+ const handlers = new Map();
22
+ const commands = new Map();
23
+ const messages = [];
24
+ return {
25
+ handlers, commands, messages,
26
+ pi: {
27
+ on(event, handler) { handlers.set(event, handler); },
28
+ registerCommand(name, command) { commands.set(name, command); },
29
+ getAllTools() { return []; },
30
+ sendMessage(payload) { messages.push(payload); },
31
+ },
32
+ };
33
+ }
34
+ function context(project, ui) {
35
+ return { cwd: project, agent: { kind: "main" }, hasUI: true, ui: { notify(text) { ui.push(text); } } };
36
+ }
37
+ async function control(name, operation) {
38
+ try { await operation(); }
39
+ catch (error) { failures.push(`${name}: ${error.message}`); }
40
+ }
41
+ try {
42
+ fs.writeFileSync(join(root, "package.json"), '{"type":"module"}');
43
+ const extension = join(root, "capture.ts");
44
+ fs.writeFileSync(extension, fs.readFileSync(join(repository, "omp", "extensions", "cairnkeep-capture.ts"), "utf8").replaceAll("@@INFRA_ROOT@@", repository.replaceAll("\\", "/")));
45
+ const { default: factory } = await import(pathToFileURL(extension).href);
46
+ const project = join(root, "project");
47
+ const staging = join(project, ".planning", "memory-staging");
48
+ fs.mkdirSync(staging, { recursive: true });
49
+ const staged = join(staging, "fixture.json");
50
+ const payload = '{"candidates":[{"value":"reviewed fixture"}]}';
51
+ fs.writeFileSync(staged, payload);
52
+ let instance = mock();
53
+ factory(instance.pi);
54
+ let ui = [];
55
+ await instance.handlers.get("session_start")({}, context(project, ui));
56
+ assert.equal(instance.messages.length, 1);
57
+ assert.match(ui[0], /1 memory candidate file/);
58
+ assert.match(instance.messages[0].content, /memory_write/);
59
+
60
+ const outside = join(root, "outside.json");
61
+ fs.writeFileSync(outside, '{"candidates":[{"value":"private-external-sentinel"}]}');
62
+ if (process.platform !== "win32") {
63
+ fs.symlinkSync(outside, join(staging, "linked.json"));
64
+ await control("OMP staged symlink exclusion", async () => {
65
+ instance = mock(); factory(instance.pi); ui = [];
66
+ await instance.handlers.get("session_start")({}, context(project, ui));
67
+ assert.match(ui[0], /1 memory candidate file/);
68
+ ui = [];
69
+ await instance.commands.get("cairn-staged").handler("", context(project, ui));
70
+ assert.equal(ui.join("\n").includes("private-external-sentinel"), false);
71
+ });
72
+ fs.unlinkSync(join(staging, "linked.json"));
73
+ }
74
+ await control("OMP staged growth", async () => {
75
+ const descriptors = new Set();
76
+ let grew = false;
77
+ let unbounded = false;
78
+ const change = () => {
79
+ if (!grew) { grew = true; fs.appendFileSync(staged, " ".repeat(64 * 1024)); }
80
+ };
81
+ fs.openSync = (path, ...args) => {
82
+ const fd = originalOpen(path, ...args);
83
+ if (path === staged) descriptors.add(fd);
84
+ return fd;
85
+ };
86
+ fs.readSync = (fd, ...args) => { if (descriptors.has(fd)) change(); return originalDescriptorRead(fd, ...args); };
87
+ fs.readFileSync = (path, ...args) => {
88
+ if (path === staged || descriptors.has(path)) { unbounded = true; change(); }
89
+ return originalRead(path, ...args);
90
+ };
91
+ syncBuiltinESMExports();
92
+ instance = mock(); factory(instance.pi); ui = [];
93
+ try {
94
+ await instance.handlers.get("session_start")({}, context(project, ui));
95
+ assert.equal(grew, true);
96
+ assert.equal(unbounded, false);
97
+ assert.equal(instance.messages.length, 0, "changed staged content must not become wakeup evidence");
98
+ } finally { restore(); }
99
+ });
100
+ fs.writeFileSync(staged, payload);
101
+ fs.writeFileSync(join(staging, "oversized.json"), '{"candidates":[{"value":"oversized-sentinel"}]}' + " ".repeat(8192));
102
+ ui = []; instance = mock(); factory(instance.pi);
103
+ await instance.commands.get("cairn-staged").handler("", context(project, ui));
104
+ await control("OMP listing bound", () => { assert.equal(ui.join("\n").includes("oversized-sentinel"), false); });
105
+ assert.deepEqual(failures, [], "staged inspection must be bounded, contained and fail open without trusting unsafe content");
106
+ } finally {
107
+ restore();
108
+ fs.rmSync(root, { recursive: true, force: true });
109
+ }
110
+ console.log("PASS: OMP staged inspection rejects symlinks, growth and oversized content without blocking the session");
@@ -0,0 +1,109 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { spawnSync } from "node:child_process";
4
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { releaseAttestationArguments, validateReleaseOptions, verifyPublishedRelease } from "./lib/release-verification.mjs";
9
+
10
+ const options = { repo: "example/project", version: "1.2.3", commit: "a".repeat(40), tree: "b".repeat(40), ciRun: "12", publishRun: "13", securityRun: "14" };
11
+ const hash = bytes => createHash("sha256").update(bytes).digest("hex");
12
+ function fixture() {
13
+ const tar = Buffer.from("synthetic-package");
14
+ const sbom = Buffer.from(JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.6", metadata: { component: { version: options.version } } }));
15
+ const sums = Buffer.from(`${hash(tar)} ./cairnkeep-cli-1.2.3.tgz\n${hash(sbom)} ./cairnkeep-cli-1.2.3.cdx.json\n`);
16
+ const candidate = new Map([["cairnkeep-cli-1.2.3.tgz", tar], ["cairnkeep-cli-1.2.3.cdx.json", sbom], ["SHA256SUMS", sums],
17
+ ["RELEASE-CANDIDATE.json", Buffer.from(JSON.stringify({ schema_version: 1, tree: options.tree, version: options.version }))]]);
18
+ const released = new Map([...candidate].filter(([name]) => name !== "RELEASE-CANDIDATE.json"));
19
+ const api = new Map([
20
+ ["actions/runs/12", { conclusion: "success", path: ".github/workflows/ci.yml", head_sha: options.commit }],
21
+ ["actions/runs/13", { conclusion: "success", path: ".github/workflows/publish.yml", head_sha: options.commit, event: "release" }],
22
+ ["actions/runs/14", { conclusion: "success", path: ".github/workflows/security.yml", head_sha: options.commit }],
23
+ [`git/commits/${options.commit}`, { tree: { sha: options.tree } }],
24
+ ["git/ref/tags/v1.2.3", { object: { type: "commit", sha: options.commit } }],
25
+ ["actions/runs/12/artifacts?per_page=100", { artifacts: [{ id: 5, name: `release-candidate-${options.tree}`, expired: false }] }],
26
+ ["releases/tags/v1.2.3", { draft: false, prerelease: false, tag_name: "v1.2.3" }],
27
+ ]);
28
+ const objects = new Map();
29
+ const add = value => { const digest = `sha256:${hash(Buffer.from(JSON.stringify(value)))}`; objects.set(digest, value); return digest; };
30
+ const manifests = [];
31
+ for (const architecture of ["amd64", "arm64"]) {
32
+ const config = add({ os: "linux", architecture, config: { Labels: { "org.opencontainers.image.revision": options.commit } } });
33
+ const image = add({ config: { digest: config } });
34
+ manifests.push({ digest: image, platform: { os: "linux", architecture } });
35
+ const statement = add({ predicateType: "https://spdx.dev/Document", subject: [{ digest: { sha256: image.slice(7) } }], predicate: { spdxVersion: "SPDX-2.3" } });
36
+ const attestation = add({ layers: [{ digest: statement }] });
37
+ manifests.push({ digest: attestation, annotations: { "vnd.docker.reference.digest": image, "vnd.docker.reference.type": "attestation-manifest" } });
38
+ }
39
+ const index = add({ manifests });
40
+ let signed = 0, signatures = 0;
41
+ const io = {
42
+ api: async path => { assert(api.has(path), `unexpected API request ${path}`); return api.get(path); },
43
+ candidate: async () => candidate, release: async () => released,
44
+ registry: async () => ({ version: options.version, dist: { tarball: "https://registry.npmjs.org/package.tgz", integrity: `sha512-${createHash("sha512").update(tar).digest("base64")}` } }),
45
+ tarball: async () => tar,
46
+ oci: async (_image, _kind, ref) => { const digest = ref.startsWith("sha256:") ? ref : index; return { digest, value: objects.get(digest) }; },
47
+ verifyAttestation: async (_image, digest, expected) => { assert.equal(digest, index); assert.deepEqual(expected, options); signed++; return true; },
48
+ verifyNpm: async bytes => { assert(bytes.equals(tar)); signatures++; return true; },
49
+ };
50
+ return { io, api, candidate, released, objects, manifests, index, counts: () => ({ signed, signatures }) };
51
+ }
52
+ const originalFetch = globalThis.fetch;
53
+ globalThis.fetch = () => { throw new Error("offline release controls must not use the network"); };
54
+ try {
55
+ for (const invalid of [{ repo: "../.." }, { repo: "owner/../secret" }, { version: "01.2.3" }, { version: "1.2.3-beta" },
56
+ { commit: "main" }, { ciRun: "0" }, { publishRun: "not-a-run" }]) {
57
+ assert.throws(() => validateReleaseOptions({ ...options, ...invalid }));
58
+ }
59
+ const attestationArgs = releaseAttestationArguments("example/project", `sha256:${"d".repeat(64)}`, options);
60
+ for (const [flag, value] of [["--source-digest", options.commit], ["--source-ref", "refs/tags/v1.2.3"],
61
+ ["--cert-identity", "https://github.com/example/project/.github/workflows/publish.yml@refs/tags/v1.2.3"],
62
+ ["--cert-oidc-issuer", "https://token.actions.githubusercontent.com"], ["--predicate-type", "https://slsa.dev/provenance/v1"]]) {
63
+ assert.equal(attestationArgs[attestationArgs.indexOf(flag) + 1], value);
64
+ }
65
+ assert(attestationArgs.includes("--deny-self-hosted-runners"));
66
+ assert(!attestationArgs.includes("--signer-workflow"), "mutually exclusive identity flags cannot be combined");
67
+ assert.throws(() => releaseAttestationArguments("other/project", `sha256:${"d".repeat(64)}`, options));
68
+ const valid = fixture();
69
+ const report = await verifyPublishedRelease(options, valid.io);
70
+ assert.equal(report.status, "verified"); assert.equal(report.images.length, 2);
71
+ assert.equal(report.deployment_verified, false); assert.equal(report.full_image_layer_pull_verified, false);
72
+ assert.deepEqual(valid.counts(), { signed: 2, signatures: 1 });
73
+ for (const [name, mutate] of [
74
+ ["failed CI", f => { f.api.get("actions/runs/12").conclusion = "failure"; }],
75
+ ["wrong CI workflow", f => { f.api.get("actions/runs/12").path = ".github/workflows/other.yml"; }],
76
+ ["failed security", f => { f.api.get("actions/runs/14").conclusion = "failure"; }],
77
+ ["wrong security workflow", f => { f.api.get("actions/runs/14").path = ".github/workflows/other.yml"; }],
78
+ ["different security tree", f => { const sha = "c".repeat(40); f.api.get("actions/runs/14").head_sha = sha; f.api.set(`git/commits/${sha}`, { tree: { sha: "d".repeat(40) } }); }],
79
+ ["different tree", f => { f.api.get(`git/commits/${options.commit}`).tree.sha = "c".repeat(40); }],
80
+ ["different tag", f => { f.api.get("git/ref/tags/v1.2.3").object.sha = "c".repeat(40); }],
81
+ ["expired candidate", f => { f.api.get("actions/runs/12/artifacts?per_page=100").artifacts[0].expired = true; }],
82
+ ["failed publication", f => { f.api.get("actions/runs/13").conclusion = "failure"; }],
83
+ ["wrong publish source", f => { f.api.get("actions/runs/13").head_sha = "c".repeat(40); }],
84
+ ["draft release", f => { f.api.get("releases/tags/v1.2.3").draft = true; }],
85
+ ["candidate tampering", f => { f.candidate.set("cairnkeep-cli-1.2.3.tgz", Buffer.from("tampered")); }],
86
+ ["asset tampering", f => { f.released.set("cairnkeep-cli-1.2.3.cdx.json", Buffer.from("tampered")); }],
87
+ ["undeclared candidate", f => { f.candidate.set("extra", Buffer.from("extra")); }],
88
+ ["checksum traversal", f => { f.candidate.set("SHA256SUMS", Buffer.from(`${"a".repeat(64)} ../escape\n`)); }],
89
+ ["registry mismatch", f => { f.io.tarball = async () => Buffer.from("different"); }],
90
+ ["registry credentials", f => { f.io.registry = async () => ({ version: "1.2.3", dist: { tarball: "https://secret@registry.npmjs.org/package.tgz" } }); }],
91
+ ["signature failure", f => { f.io.verifyNpm = async () => false; }],
92
+ ["missing architecture", f => { f.objects.get(f.index).manifests = f.manifests.filter(m => m.platform?.architecture !== "arm64"); }],
93
+ ["wrong image revision", f => { for (const value of f.objects.values()) if (value.config?.Labels) value.config.Labels["org.opencontainers.image.revision"] = "c".repeat(40); }],
94
+ ["unbound SBOM", f => { for (const value of f.objects.values()) if (value.subject) value.subject[0].digest.sha256 = "c".repeat(64); }],
95
+ ["provenance failure", f => { f.io.verifyAttestation = async () => false; }],
96
+ ]) { const f = fixture(); mutate(f); await assert.rejects(() => verifyPublishedRelease(options, f.io), undefined, name); }
97
+ const scratch = mkdtempSync(join(tmpdir(), "cairn-release-report-test-"));
98
+ try {
99
+ const output = join(scratch, "existing.json"); writeFileSync(output, "retained-report\n");
100
+ const command = fileURLToPath(new URL("verify-published-release.mjs", import.meta.url));
101
+ const result = spawnSync(process.execPath, [command, "--version", options.version, "--commit", options.commit,
102
+ "--tree", options.tree, "--ci-run", options.ciRun, "--security-run", options.securityRun, "--publish-run", options.publishRun,
103
+ "--out", output, "--gh", join(scratch, "missing-command")], { encoding: "utf8", timeout: 3000 });
104
+ assert.notEqual(result.status, 0); assert.match(result.stderr, /output already exists/);
105
+ assert.equal(readFileSync(output, "utf8"), "retained-report\n");
106
+ assert.equal(result.stdout, "");
107
+ } finally { rmSync(scratch, { recursive: true, force: true }); }
108
+ console.log("PASS: offline exact-tree release, assets, registry, signatures, architectures, SBOM and provenance controls");
109
+ } finally { globalThis.fetch = originalFetch; }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
4
+ node "$ROOT/scripts/test-release-verification.mjs"