@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
|
@@ -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
|
|
176
|
-
const
|
|
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
|
|
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
|
+
}
|