@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.
- package/CHANGELOG.md +79 -1
- package/containers/healthcheck.mjs +27 -33
- package/containers/quadlet/cairnkeep.container +1 -1
- package/docs/containers.md +12 -0
- package/docs/domain-knowledge.md +15 -0
- package/docs/learning/CURRICULUM-MAP.md +1 -1
- package/docs/learning/FEATURE-GUIDE.md +1 -1
- 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 +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- 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 +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +72 -2
- package/docs/learning/tracks/operator.md +6 -1
- package/docs/learning/video-scripts/V28-security-assurance.md +65 -0
- package/docs/native-windows.md +10 -0
- package/docs/operating.md +10 -0
- package/docs/privacy-and-data-flow.md +6 -0
- package/docs/releasing.md +61 -2
- package/docs/security-assurance.md +82 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/capability-registry.js +2 -15
- package/mcp-memory-server/dist/context-pack-retrieval.js +6 -6
- package/mcp-memory-server/dist/domain-retrieval-provider.js +100 -45
- package/mcp-memory-server/dist/eval-plan.js +15 -22
- package/mcp-memory-server/dist/eval-runner.js +17 -10
- package/mcp-memory-server/dist/eval-workspace.js +82 -23
- package/mcp-memory-server/dist/failure-signature.js +29 -8
- package/mcp-memory-server/dist/graph-cli.js +25 -10
- package/mcp-memory-server/dist/note-store.js +24 -4
- package/mcp-memory-server/dist/okf.js +4 -9
- package/mcp-memory-server/dist/playbook-receipt.js +3 -17
- package/mcp-memory-server/dist/skill-store.js +10 -26
- package/mcp-memory-server/dist/stable-file.js +85 -0
- package/mcp-memory-server/dist/work-evidence-store.js +7 -5
- package/omp/extensions/cairnkeep-capture.ts +29 -17
- package/package.json +1 -1
- package/scripts/lib/opencode-v2-plugin-harness.mjs +20 -3
- package/scripts/lib/release-verification.mjs +131 -0
- package/scripts/lib/stable-file.mjs +75 -0
- package/scripts/playbook-instructions.mjs +42 -20
- package/scripts/setup-core.mjs +5 -36
- package/scripts/setup-reconcile.mjs +32 -32
- package/scripts/setup.mjs +7 -31
- package/scripts/test-ci-sharding.sh +5 -0
- package/scripts/test-container-health.mjs +56 -0
- package/scripts/test-container.sh +8 -1
- package/scripts/test-learning-docs.sh +8 -0
- package/scripts/test-omp-staged-security.mjs +110 -0
- package/scripts/test-release-verification.mjs +109 -0
- package/scripts/test-release-verification.sh +4 -0
- package/scripts/test-runtime-file-security.mjs +311 -0
- package/scripts/test-setup-reconcile.mjs +4 -2
- package/scripts/test-windows-native.mjs +4 -0
- package/scripts/verify-omp-capture.mjs +1 -0
- package/scripts/verify-phase19-runtime-evidence.mjs +43 -3
- package/scripts/verify-published-release.mjs +138 -0
- 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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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; }
|