@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
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { constants, existsSync } from "node:fs";
|
|
3
|
-
import { chmod, lstat, mkdir, mkdtemp,
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, existsSync, fchmodSync, fsyncSync, linkSync, lstatSync, openSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmod, lstat, mkdir, mkdtemp, realpath, writeFile, } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { canonicalDigest } from "./eval-schema.js";
|
|
7
7
|
import { EvalProcessError, runBoundedCommand, } from "./eval-process.js";
|
|
8
|
+
import { readStableFile, readStableJson } from "./stable-file.js";
|
|
8
9
|
const GIT_TIMEOUT_MS = 120_000;
|
|
9
10
|
const GIT_OUTPUT_LIMIT = 64 * 1024;
|
|
10
11
|
const CLEANUP_TIMEOUT_MS = 120_000;
|
|
@@ -53,24 +54,13 @@ async function validateBundledBinding(plan) {
|
|
|
53
54
|
}
|
|
54
55
|
if (canonicalDigest(plan.task_set) !== plan.task_set_digest)
|
|
55
56
|
throw new Error("task_set_digest_mismatch");
|
|
56
|
-
let handle;
|
|
57
57
|
let binding;
|
|
58
58
|
try {
|
|
59
|
-
|
|
60
|
-
const info = await handle.stat();
|
|
61
|
-
if (!info.isFile() || info.size > BINDING_LIMIT)
|
|
62
|
-
throw new Error("unsafe_bundled_binding");
|
|
63
|
-
const bytes = await handle.readFile();
|
|
64
|
-
if (bytes.byteLength > BINDING_LIMIT)
|
|
65
|
-
throw new Error("unsafe_bundled_binding");
|
|
66
|
-
binding = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
59
|
+
binding = readStableJson(plan.source.binding_path, { label: "Bundled evaluation binding", maxBytes: BINDING_LIMIT });
|
|
67
60
|
}
|
|
68
61
|
catch {
|
|
69
62
|
throw new Error("invalid_bundled_binding");
|
|
70
63
|
}
|
|
71
|
-
finally {
|
|
72
|
-
await handle?.close().catch(() => undefined);
|
|
73
|
-
}
|
|
74
64
|
if (!binding || typeof binding !== "object" || Array.isArray(binding))
|
|
75
65
|
throw new Error("invalid_bundled_binding");
|
|
76
66
|
const record = binding;
|
|
@@ -109,8 +99,10 @@ async function materializeBundledSource(plan, sourcePath) {
|
|
|
109
99
|
const info = await lstat(destination);
|
|
110
100
|
if (info.isSymbolicLink() || !info.isFile())
|
|
111
101
|
throw new Error("bundled_path_unsafe");
|
|
112
|
-
|
|
102
|
+
const expected = Buffer.from(file.content, "utf8");
|
|
103
|
+
if (!expected.equals(readStableFile(destination, { label: "Bundled evaluation source", maxBytes: Math.max(1, expected.byteLength) }).bytes)) {
|
|
113
104
|
throw new Error("bundled_source_mismatch");
|
|
105
|
+
}
|
|
114
106
|
}
|
|
115
107
|
if (canonicalDigest(plan.task_set) !== plan.task_set_digest)
|
|
116
108
|
throw new Error("task_set_digest_mismatch");
|
|
@@ -194,17 +186,18 @@ export async function runTaskPreparation(workspace, options = {}) {
|
|
|
194
186
|
});
|
|
195
187
|
}
|
|
196
188
|
export async function applyEvalWorkspaceOverlay(workspace, overlay) {
|
|
189
|
+
const source = await assertPrivateDirectory(workspace.source_path);
|
|
197
190
|
const segments = overlay.relative_path.split("/");
|
|
198
191
|
if (overlay.relative_path.startsWith("/") || overlay.relative_path.endsWith("/")
|
|
199
192
|
|| overlay.relative_path.includes("\\") || segments.some((segment) => segment === "" || segment === "." || segment === "..")
|
|
200
193
|
|| /[\u0000-\u001f\u007f]/.test(overlay.relative_path)) {
|
|
201
194
|
throw new Error("workspace_overlay_path_invalid");
|
|
202
195
|
}
|
|
203
|
-
const target = resolve(
|
|
204
|
-
if (!isContained(
|
|
196
|
+
const target = resolve(source, overlay.relative_path);
|
|
197
|
+
if (!isContained(source, target) || target === source) {
|
|
205
198
|
throw new Error("workspace_overlay_path_escape");
|
|
206
199
|
}
|
|
207
|
-
let cursor =
|
|
200
|
+
let cursor = source;
|
|
208
201
|
for (const segment of segments.slice(0, -1)) {
|
|
209
202
|
cursor = join(cursor, segment);
|
|
210
203
|
try {
|
|
@@ -228,19 +221,85 @@ export async function applyEvalWorkspaceOverlay(workspace, overlay) {
|
|
|
228
221
|
throw error;
|
|
229
222
|
}
|
|
230
223
|
const parent = await realpath(dirname(target));
|
|
231
|
-
const source = await realpath(workspace.source_path);
|
|
232
224
|
if (!isContained(source, parent))
|
|
233
225
|
throw new Error("workspace_overlay_parent_escape");
|
|
234
226
|
const bytes = Buffer.from(overlay.content, "utf8");
|
|
235
227
|
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
236
228
|
if (digest !== overlay.digest)
|
|
237
229
|
throw new Error("workspace_overlay_digest_mismatch");
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const storedDigest = createHash("sha256").update(await readFile(target)).digest("hex");
|
|
230
|
+
publishOverlay(source, target, bytes);
|
|
231
|
+
const storedDigest = createHash("sha256").update(readStableFile(target, { label: "Evaluation overlay", maxBytes: Math.max(1, bytes.byteLength) }).bytes).digest("hex");
|
|
241
232
|
if (storedDigest !== overlay.digest)
|
|
242
233
|
throw new Error("workspace_overlay_write_mismatch");
|
|
243
234
|
}
|
|
235
|
+
function sameTarget(left, right) {
|
|
236
|
+
return right.isFile() && !right.isSymbolicLink()
|
|
237
|
+
&& left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
238
|
+
&& left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs && left.mode === right.mode;
|
|
239
|
+
}
|
|
240
|
+
function overlayTarget(path) {
|
|
241
|
+
try {
|
|
242
|
+
const info = lstatSync(path);
|
|
243
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
244
|
+
throw new Error("workspace_overlay_target_unsafe");
|
|
245
|
+
return info;
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (error.code === "ENOENT")
|
|
249
|
+
return null;
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function publishOverlay(source, target, bytes) {
|
|
254
|
+
const root = lstatSync(source);
|
|
255
|
+
const parent = lstatSync(dirname(target));
|
|
256
|
+
const before = overlayTarget(target);
|
|
257
|
+
const temporary = join(dirname(target), `.cairn-overlay-${process.pid}-${randomBytes(12).toString("hex")}.tmp`);
|
|
258
|
+
function unchangedBoundary() {
|
|
259
|
+
const rootAfter = lstatSync(source);
|
|
260
|
+
const parentAfter = lstatSync(dirname(target));
|
|
261
|
+
if (!rootAfter.isDirectory() || rootAfter.isSymbolicLink()
|
|
262
|
+
|| !parentAfter.isDirectory() || parentAfter.isSymbolicLink()
|
|
263
|
+
|| root.dev !== rootAfter.dev || root.ino !== rootAfter.ino
|
|
264
|
+
|| parent.dev !== parentAfter.dev || parent.ino !== parentAfter.ino) {
|
|
265
|
+
throw new Error("workspace_overlay_parent_unsafe");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
let descriptor;
|
|
269
|
+
let ownsTemporary = false;
|
|
270
|
+
try {
|
|
271
|
+
unchangedBoundary();
|
|
272
|
+
descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL
|
|
273
|
+
| (constants.O_NOFOLLOW ?? 0) | (process.platform === "win32" ? 0 : constants.O_NONBLOCK ?? 0), 0o600);
|
|
274
|
+
ownsTemporary = true;
|
|
275
|
+
unchangedBoundary();
|
|
276
|
+
writeFileSync(descriptor, bytes);
|
|
277
|
+
fchmodSync(descriptor, 0o600);
|
|
278
|
+
fsyncSync(descriptor);
|
|
279
|
+
closeSync(descriptor);
|
|
280
|
+
descriptor = undefined;
|
|
281
|
+
if (!readStableFile(temporary, { label: "Evaluation overlay temporary", maxBytes: Math.max(1, bytes.byteLength) }).bytes.equals(bytes)) {
|
|
282
|
+
throw new Error("workspace_overlay_write_mismatch");
|
|
283
|
+
}
|
|
284
|
+
unchangedBoundary();
|
|
285
|
+
const current = overlayTarget(target);
|
|
286
|
+
if (before ? !current || !sameTarget(before, current) : current !== null) {
|
|
287
|
+
throw new Error("workspace_overlay_target_changed");
|
|
288
|
+
}
|
|
289
|
+
// Atomic rename never follows the destination file. Exclusive linking
|
|
290
|
+
// also refuses a new destination that appeared after absence was checked.
|
|
291
|
+
if (before)
|
|
292
|
+
renameSync(temporary, target);
|
|
293
|
+
else
|
|
294
|
+
linkSync(temporary, target);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
if (descriptor !== undefined)
|
|
298
|
+
closeSync(descriptor);
|
|
299
|
+
if (ownsTemporary)
|
|
300
|
+
rmSync(temporary, { force: true });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
244
303
|
function verifierReason(code) {
|
|
245
304
|
return `verifier_${code}`;
|
|
246
305
|
}
|
|
@@ -46,7 +46,30 @@ function normalizeVolatile(value, root) {
|
|
|
46
46
|
.replace(/[\t\u00a0 ]+/g, " ")
|
|
47
47
|
.trim();
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
// Match each delimited path once. A repeated path-segment group is ambiguous
|
|
50
|
+
// because a segment itself may contain slashes, causing exponential retries.
|
|
51
|
+
function sourcePath(text) {
|
|
52
|
+
for (const match of text.matchAll(/(?:^|[\s,])(\/[^\s,:]*):\d+(?::\d+)?/gm)) {
|
|
53
|
+
if (/\/(?:src|pkg|lib|test|tests)\/[^\s,:]+$/i.test(match[1]))
|
|
54
|
+
return match[1];
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
function withoutSourceSuffix(message) {
|
|
59
|
+
const comma = message.lastIndexOf(",");
|
|
60
|
+
if (comma < 0)
|
|
61
|
+
return message;
|
|
62
|
+
const suffix = message.slice(comma + 1).trimStart();
|
|
63
|
+
if (!suffix.endsWith(":<line>"))
|
|
64
|
+
return message;
|
|
65
|
+
const path = suffix.slice(0, -":<line>".length);
|
|
66
|
+
if (path.toLowerCase() === "<root>" || (!/[\s,]/.test(path)
|
|
67
|
+
&& /(?:^|\/)(?:src|pkg|lib|test|tests)\/[^\s,:]+$/i.test(path))) {
|
|
68
|
+
return message.slice(0, comma);
|
|
69
|
+
}
|
|
70
|
+
return message;
|
|
71
|
+
}
|
|
72
|
+
function parseFrames(text, root, source) {
|
|
50
73
|
const frames = [];
|
|
51
74
|
const add = (fn, file) => {
|
|
52
75
|
const normalizedFunction = fn.replace(/^async\s+/, "").trim();
|
|
@@ -81,9 +104,8 @@ function parseFrames(text, root) {
|
|
|
81
104
|
}
|
|
82
105
|
match = line.match(/^\d+:\s+(?:0x[0-9a-f]+\s+-\s+)?(.+)$/i);
|
|
83
106
|
if (match) {
|
|
84
|
-
const source = text.match(/(?:^|[\s,])((?:\/[^\s,:]+)*\/(?:src|pkg|lib|test|tests)\/[^\s,:]+):\d+(?::\d+)?/im);
|
|
85
107
|
if (source)
|
|
86
|
-
add(match[1], source
|
|
108
|
+
add(match[1], source);
|
|
87
109
|
}
|
|
88
110
|
}
|
|
89
111
|
return frames.slice(0, 16);
|
|
@@ -96,8 +118,7 @@ function selectMessage(text, root) {
|
|
|
96
118
|
const first = lines.find((line) => !/^(?:at\s+|File\s+|stack backtrace:|\d+:\s+(?:<address>|0x))/i.test(line));
|
|
97
119
|
if (!first)
|
|
98
120
|
return "unknown failure";
|
|
99
|
-
return first
|
|
100
|
-
.replace(/,\s*(?:<root>|\/?(?:[^\s,]+\/)*(?:src|pkg|lib|test|tests)\/[^\s,:]+):<line>$/i, "")
|
|
121
|
+
return withoutSourceSuffix(first)
|
|
101
122
|
.replace(/\s+/g, " ")
|
|
102
123
|
.trim()
|
|
103
124
|
.slice(0, 4096);
|
|
@@ -114,10 +135,10 @@ function familyFor(message) {
|
|
|
114
135
|
}
|
|
115
136
|
export function buildFailureSignature(text, options = {}) {
|
|
116
137
|
const normalizedMessage = selectMessage(text, options.root);
|
|
117
|
-
const
|
|
138
|
+
const source = sourcePath(text);
|
|
139
|
+
const frames = parseFrames(text, options.root, source);
|
|
118
140
|
const explicitComponent = options.component ? safePath(options.component, options.root) : "";
|
|
119
|
-
const
|
|
120
|
-
const component = explicitComponent || frames[0]?.file || (sourceInMessage ? safePath(sourceInMessage[1], options.root) : "");
|
|
141
|
+
const component = explicitComponent || frames[0]?.file || (source ? safePath(source, options.root) : "");
|
|
121
142
|
const family = familyFor(normalizedMessage);
|
|
122
143
|
const normalizedForHash = normalizedMessage.toLocaleLowerCase("en-US");
|
|
123
144
|
const stackDigest = frames.length > 0 ? hash("stack", frames) : "";
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
|
-
import { lstat, mkdir,
|
|
4
|
+
import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
6
6
|
import { resolveCapabilityStatus } from "./capability-config.js";
|
|
7
7
|
import { isGraphCapabilityCompatible } from "./capability-registry.js";
|
|
8
|
+
import { readStableFile } from "./stable-file.js";
|
|
8
9
|
const INSPECTION_TIMEOUT_MS = 30_000;
|
|
9
10
|
const BUILD_TIMEOUT_MS = 300_000;
|
|
10
11
|
const MAX_GRAPH_BYTES = 256 * 1024 * 1024;
|
|
@@ -33,14 +34,22 @@ function plural(count, singular) {
|
|
|
33
34
|
return `${count} ${singular}${count === 1 ? "" : "s"}`;
|
|
34
35
|
}
|
|
35
36
|
async function readBoundedFile(path, maximumBytes) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
try {
|
|
38
|
+
return readStableFile(path, { label: "graph artifact", maxBytes: maximumBytes }).bytes;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
// Optional artifacts need a missing-file result, not an unsafe-file
|
|
42
|
+
// result. This post-failure diagnosis never authorizes another open.
|
|
43
|
+
try {
|
|
44
|
+
await lstat(path);
|
|
45
|
+
}
|
|
46
|
+
catch (inspectionError) {
|
|
47
|
+
if (inspectionError.code === "ENOENT") {
|
|
48
|
+
throw Object.assign(new Error("graph artifact not found."), { code: "ENOENT" });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
39
52
|
}
|
|
40
|
-
const bytes = await readFile(path);
|
|
41
|
-
if (bytes.byteLength > maximumBytes)
|
|
42
|
-
throw new Error("graph artifact is too large.");
|
|
43
|
-
return bytes;
|
|
44
53
|
}
|
|
45
54
|
function parseGraph(bytes) {
|
|
46
55
|
const value = JSON.parse(bytes.toString("utf8"));
|
|
@@ -59,8 +68,14 @@ async function readGraph(path) {
|
|
|
59
68
|
return parseGraph(await readBoundedFile(path, MAX_GRAPH_BYTES));
|
|
60
69
|
}
|
|
61
70
|
catch (error) {
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
// This check explains a failed read; it never authorizes a later open.
|
|
72
|
+
try {
|
|
73
|
+
await lstat(path);
|
|
74
|
+
}
|
|
75
|
+
catch (inspectionError) {
|
|
76
|
+
if (inspectionError.code === "ENOENT") {
|
|
77
|
+
throw new Error("no published graph found; run /graphify build first.");
|
|
78
|
+
}
|
|
64
79
|
}
|
|
65
80
|
throw error;
|
|
66
81
|
}
|
|
@@ -6,6 +6,7 @@ import { buildFailureSignature } from "./failure-signature.js";
|
|
|
6
6
|
import { NOTE_SCHEMA_VERSION, noteNodeSchema, } from "./note-schema.js";
|
|
7
7
|
import { memoryImportEnvelopeSchema, nodePathSchema, } from "./node-schema.js";
|
|
8
8
|
import { atomicReplace } from "./platform-security.js";
|
|
9
|
+
import { readStableFile } from "./stable-file.js";
|
|
9
10
|
const MANAGED_START = "<!-- cairnkeep:managed:v1:start -->";
|
|
10
11
|
const MANAGED_END = "<!-- cairnkeep:managed:v1:end -->";
|
|
11
12
|
const MAX_PROCESSED_SESSIONS_PER_PROJECT = 4096;
|
|
@@ -569,7 +570,22 @@ export function doctorNoteStore(repair = false, options = {}) {
|
|
|
569
570
|
}
|
|
570
571
|
}
|
|
571
572
|
function fileHash(path) {
|
|
572
|
-
|
|
573
|
+
try {
|
|
574
|
+
const { bytes } = readStableFile(path, { label: "Note transaction pre-image", maxBytes: 64 * 1024 * 1024 });
|
|
575
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
// Missing targets are valid pre-images. Diagnose absence only after
|
|
579
|
+
// the descriptor-first read failed; never authorize a read by a check.
|
|
580
|
+
try {
|
|
581
|
+
lstatSync(path);
|
|
582
|
+
}
|
|
583
|
+
catch (missing) {
|
|
584
|
+
if (missing.code === "ENOENT")
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
573
589
|
}
|
|
574
590
|
function bytesHash(bytes) {
|
|
575
591
|
return bytes === null ? null : createHash("sha256").update(bytes).digest("hex");
|
|
@@ -1024,9 +1040,13 @@ export async function commitNoteImport(plan) {
|
|
|
1024
1040
|
export function createNoteMutationFixture(options) {
|
|
1025
1041
|
const notesRoot = join(options.storeRoot, ".cairnkeep-note-fixture");
|
|
1026
1042
|
const target = join(notesRoot, "live.txt");
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
writeFileSync(target, "before\n");
|
|
1043
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1044
|
+
try {
|
|
1045
|
+
writeFileSync(target, "before\n", { flag: "wx", mode: 0o600 });
|
|
1046
|
+
}
|
|
1047
|
+
catch (error) {
|
|
1048
|
+
if (error.code !== "EEXIST")
|
|
1049
|
+
throw error;
|
|
1030
1050
|
}
|
|
1031
1051
|
const bytes = options.operation === "delete" ? null : `${options.operation}-after\n`;
|
|
1032
1052
|
return { schema_version: 1, operation: options.operation, notes_root: notesRoot, changes: [{ path: target, bytes, before_hash: fileHash(target), final_hash: bytesHash(bytes) }], result: { ok: true, fixture: true } };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { existsSync, lstatSync } from "node:fs";
|
|
3
|
-
import { mkdir, mkdtemp,
|
|
3
|
+
import { mkdir, mkdtemp, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { parseDocument, stringify } from "yaml";
|
|
6
6
|
import { z } from "zod";
|
|
@@ -8,6 +8,7 @@ import { portablePathCollisionKey, portableRelativePathIssue } from "./path-secu
|
|
|
8
8
|
import { hardenPrivatePath } from "./platform-security.js";
|
|
9
9
|
import { readSharedNoteForExport } from "./note-store.js";
|
|
10
10
|
import { redactLocalValue } from "./trajectory-redaction.js";
|
|
11
|
+
import { readStableFile } from "./stable-file.js";
|
|
11
12
|
const MAX_FILE_BYTES = 1024 * 1024;
|
|
12
13
|
const MAX_ENTRIES = 1024;
|
|
13
14
|
const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
@@ -256,10 +257,7 @@ export async function indexOkfBundle(directory, selectedPaths, declaredVersion)
|
|
|
256
257
|
const absolute = resolve(root, ...entry.raw_path.split("/"));
|
|
257
258
|
if (!contained(root, absolute))
|
|
258
259
|
throw new Error(`Unsafe OKF path: ${path}`);
|
|
259
|
-
const
|
|
260
|
-
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_FILE_BYTES)
|
|
261
|
-
throw new Error(`OKF file is unsafe or too large: ${path}`);
|
|
262
|
-
const bytes = await readFile(absolute);
|
|
260
|
+
const { bytes } = readStableFile(absolute, { label: "OKF file", maxBytes: MAX_FILE_BYTES });
|
|
263
261
|
content.set(path, bytes);
|
|
264
262
|
const text = utf8(bytes, path);
|
|
265
263
|
const name = basename(path);
|
|
@@ -377,10 +375,7 @@ async function exportMaterial(options) {
|
|
|
377
375
|
const absolute = resolve(projectRoot, ...source.split("/"));
|
|
378
376
|
if (!contained(projectRoot, absolute))
|
|
379
377
|
throw new Error(`Unsafe OKF export source: ${source}`);
|
|
380
|
-
const
|
|
381
|
-
if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink() || sourceInfo.size > MAX_FILE_BYTES)
|
|
382
|
-
throw new Error(`OKF export source is unsafe or too large: ${source}`);
|
|
383
|
-
const bytes = await readFile(absolute);
|
|
378
|
+
const { bytes } = readStableFile(absolute, { label: "OKF export source", maxBytes: MAX_FILE_BYTES });
|
|
384
379
|
const text = utf8(bytes, source);
|
|
385
380
|
const redacted = redactLocalValue(text, projectRoot);
|
|
386
381
|
replacements += redacted.replacement_count;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
3
|
import { link, lstat, mkdir, open, realpath, rm } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
|
|
7
7
|
import { playbookProjectIdentity, resolvePlaybookStatus } from "./playbook.js";
|
|
8
|
+
import { readStableJson } from "./stable-file.js";
|
|
8
9
|
import { PLAYBOOK_SCHEMA_VERSION, playbookActionIdSchema, playbookActorSchema, playbookEventSchema, playbookOutcomeSchema, playbookReasonSchema, } from "./playbook-schema.js";
|
|
9
10
|
const MAX_RECEIPT_BYTES = 8 * 1024;
|
|
10
11
|
const MAX_RECEIPTS = 10_000;
|
|
@@ -64,22 +65,7 @@ async function ensureStore(projectRoot) {
|
|
|
64
65
|
return target.receipts;
|
|
65
66
|
}
|
|
66
67
|
async function safeRead(path) {
|
|
67
|
-
|
|
68
|
-
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_RECEIPT_BYTES || !privatePathIsSafe(path)) {
|
|
69
|
-
throw new Error("Unsafe playbook receipt.");
|
|
70
|
-
}
|
|
71
|
-
let handle;
|
|
72
|
-
try {
|
|
73
|
-
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
74
|
-
const opened = await handle.stat();
|
|
75
|
-
if (!opened.isFile() || opened.size > MAX_RECEIPT_BYTES || opened.dev !== info.dev || opened.ino !== info.ino) {
|
|
76
|
-
throw new Error("Playbook receipt changed during validation.");
|
|
77
|
-
}
|
|
78
|
-
return receiptSchema.parse(JSON.parse((await handle.readFile()).toString("utf8")));
|
|
79
|
-
}
|
|
80
|
-
finally {
|
|
81
|
-
await handle?.close().catch(() => undefined);
|
|
82
|
-
}
|
|
68
|
+
return receiptSchema.parse(readStableJson(path, { label: "Playbook receipt", maxBytes: MAX_RECEIPT_BYTES, private: true }));
|
|
83
69
|
}
|
|
84
70
|
async function atomicReceipt(path, value) {
|
|
85
71
|
const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
@@ -6,6 +6,7 @@ import { EvalProcessError, runBoundedJsonProcess } from "./eval-process.js";
|
|
|
6
6
|
import { noteNodeSchema } from "./node-schema.js";
|
|
7
7
|
import { getNoteLayout, listAddressedNotes } from "./note-store.js";
|
|
8
8
|
import { hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
|
|
9
|
+
import { readStableJson, readStableText } from "./stable-file.js";
|
|
9
10
|
import { MAX_SKILL_BYTES, SKILL_SCHEMA_VERSION, skillAdapterConfigSchema, skillApplicationSchema, skillCandidateSchema, skillEvaluationSchema, skillProposalRequestSchema, skillProposalResponseSchema, skillProposalSchema, skillRelativePathSchema, } from "./skill-schema.js";
|
|
10
11
|
const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
|
|
11
12
|
const PROTECTED_ADAPTER_ENVIRONMENT = new Set([
|
|
@@ -114,17 +115,7 @@ function atomicWrite(path, value) {
|
|
|
114
115
|
hardenPrivatePath(path);
|
|
115
116
|
}
|
|
116
117
|
function readJson(path) {
|
|
117
|
-
|
|
118
|
-
if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_ARTIFACT_BYTES || !privatePathIsSafe(path)) {
|
|
119
|
-
throw new Error(`Unsafe skill artifact: ${path}`);
|
|
120
|
-
}
|
|
121
|
-
const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
122
|
-
try {
|
|
123
|
-
return JSON.parse(readFileSync(descriptor, "utf8"));
|
|
124
|
-
}
|
|
125
|
-
finally {
|
|
126
|
-
closeSync(descriptor);
|
|
127
|
-
}
|
|
118
|
+
return readStableJson(path, { label: "Skill artifact", maxBytes: MAX_ARTIFACT_BYTES, private: true });
|
|
128
119
|
}
|
|
129
120
|
function listJson(directory, parse) {
|
|
130
121
|
if (!existsSync(directory))
|
|
@@ -261,18 +252,13 @@ function assertSafeTarget(projectRoot, relativePath) {
|
|
|
261
252
|
if (!isContained(root, target) || target === root)
|
|
262
253
|
throw new Error("Skill target escapes the project.");
|
|
263
254
|
let cursor = root;
|
|
264
|
-
for (const segment of canonicalPath.split("/")) {
|
|
255
|
+
for (const segment of canonicalPath.split("/").slice(0, -1)) {
|
|
265
256
|
cursor = join(cursor, segment);
|
|
266
257
|
const info = lstatSync(cursor);
|
|
267
258
|
if (info.isSymbolicLink())
|
|
268
259
|
throw new Error("Skill target crosses a symlink.");
|
|
269
260
|
}
|
|
270
|
-
const info =
|
|
271
|
-
if (!info.isFile() || info.size > MAX_SKILL_BYTES)
|
|
272
|
-
throw new Error("Skill target must be a regular file no larger than 256 KiB.");
|
|
273
|
-
const content = readFileSync(target, "utf8");
|
|
274
|
-
if (Buffer.byteLength(content, "utf8") > MAX_SKILL_BYTES)
|
|
275
|
-
throw new Error("Skill target exceeds 256 KiB.");
|
|
261
|
+
const { text: content, stat: info } = readStableText(target, { label: "Skill target", maxBytes: MAX_SKILL_BYTES });
|
|
276
262
|
return { path: target, content, digest: sha256(content), mode: info.mode & 0o777 };
|
|
277
263
|
}
|
|
278
264
|
export function readSkillTarget(projectRoot, relativePath) {
|
|
@@ -520,7 +506,7 @@ export function applySkillProposal(options) {
|
|
|
520
506
|
hardenPrivatePath(backupPath);
|
|
521
507
|
try {
|
|
522
508
|
atomicWriteTarget(target.path, proposal.candidate_content, proposal.target_mode);
|
|
523
|
-
if (
|
|
509
|
+
if (assertSafeTarget(store.project_root, proposal.target_path).digest !== proposal.candidate_content_digest) {
|
|
524
510
|
throw new Error("Applied skill digest verification failed.");
|
|
525
511
|
}
|
|
526
512
|
atomicWrite(artifactPath(store.applications, application.id), application);
|
|
@@ -529,7 +515,7 @@ export function applySkillProposal(options) {
|
|
|
529
515
|
const reason = error instanceof Error ? error.message : String(error);
|
|
530
516
|
let currentDigest = null;
|
|
531
517
|
try {
|
|
532
|
-
currentDigest =
|
|
518
|
+
currentDigest = assertSafeTarget(store.project_root, proposal.target_path).digest;
|
|
533
519
|
}
|
|
534
520
|
catch {
|
|
535
521
|
// The private backup remains available for manual recovery.
|
|
@@ -538,7 +524,7 @@ export function applySkillProposal(options) {
|
|
|
538
524
|
let restored = false;
|
|
539
525
|
try {
|
|
540
526
|
atomicWriteTarget(target.path, target.content, target.mode);
|
|
541
|
-
if (
|
|
527
|
+
if (assertSafeTarget(store.project_root, proposal.target_path).digest !== target.digest)
|
|
542
528
|
throw new Error("restore_digest_mismatch");
|
|
543
529
|
restored = true;
|
|
544
530
|
}
|
|
@@ -576,16 +562,14 @@ export function rollbackSkillApplication(options) {
|
|
|
576
562
|
if (target.digest !== application.applied_digest)
|
|
577
563
|
throw new Error("Skill target changed after application; rollback requires manual resolution.");
|
|
578
564
|
const backup = resolve(store.project_root, application.backup_path);
|
|
579
|
-
|
|
580
|
-
if (!isContained(store.backups, backup) || backupInfo.isSymbolicLink() || !backupInfo.isFile()
|
|
581
|
-
|| backupInfo.size > MAX_SKILL_BYTES || !privatePathIsSafe(backup)) {
|
|
565
|
+
if (!isContained(store.backups, backup)) {
|
|
582
566
|
throw new Error("Skill backup is missing or unsafe.");
|
|
583
567
|
}
|
|
584
|
-
const original =
|
|
568
|
+
const { text: original } = readStableText(backup, { label: "Skill backup", maxBytes: MAX_SKILL_BYTES, private: true });
|
|
585
569
|
if (sha256(original) !== application.before_digest)
|
|
586
570
|
throw new Error("Skill backup digest does not match the application ledger.");
|
|
587
571
|
atomicWriteTarget(target.path, original, application.target_mode);
|
|
588
|
-
if (
|
|
572
|
+
if (assertSafeTarget(store.project_root, application.target_path).digest !== application.before_digest) {
|
|
589
573
|
throw new Error("Skill rollback digest verification failed; the application remains marked as applied.");
|
|
590
574
|
}
|
|
591
575
|
const rolledBack = skillApplicationSchema.parse({
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { posixPrivateMetadataIsSafe, privatePathIsSafe } from "./platform-security.js";
|
|
4
|
+
function sameFile(left, right) {
|
|
5
|
+
return right.isFile() && !right.isSymbolicLink()
|
|
6
|
+
&& left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
7
|
+
&& left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs
|
|
8
|
+
&& left.uid === right.uid && left.gid === right.gid && left.mode === right.mode;
|
|
9
|
+
}
|
|
10
|
+
/** Bind bounded runtime reads to the inspected file, not a later pathname. */
|
|
11
|
+
export function readStableFile(path, options) {
|
|
12
|
+
const { label, maxBytes } = options;
|
|
13
|
+
let descriptor;
|
|
14
|
+
try {
|
|
15
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
16
|
+
throw new Error();
|
|
17
|
+
// Opening never relies on a prior pathname check. Nonblocking/no-ctty
|
|
18
|
+
// flags make special-file admission bounded before descriptor inspection.
|
|
19
|
+
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
|
|
20
|
+
| (constants.O_NOCTTY ?? 0) | (process.platform === "win32" ? 0 : constants.O_NONBLOCK ?? 0));
|
|
21
|
+
const opened = fstatSync(descriptor);
|
|
22
|
+
if (!opened.isFile() || opened.size > maxBytes)
|
|
23
|
+
throw new Error();
|
|
24
|
+
const parent = lstatSync(dirname(path));
|
|
25
|
+
const named = lstatSync(path);
|
|
26
|
+
if (named.isSymbolicLink() || parent.isSymbolicLink())
|
|
27
|
+
throw new Error(`${label} crosses a symlink.`);
|
|
28
|
+
if (!parent.isDirectory() || !sameFile(opened, named))
|
|
29
|
+
throw new Error();
|
|
30
|
+
if (options.private) {
|
|
31
|
+
if (process.platform === "win32" ? !privatePathIsSafe(path) : !posixPrivateMetadataIsSafe(opened))
|
|
32
|
+
throw new Error();
|
|
33
|
+
if (!sameFile(opened, lstatSync(path)))
|
|
34
|
+
throw new Error();
|
|
35
|
+
}
|
|
36
|
+
// One extra byte detects growth without following it or growing allocation.
|
|
37
|
+
const buffer = Buffer.allocUnsafe(opened.size + 1);
|
|
38
|
+
let offset = 0;
|
|
39
|
+
while (offset < buffer.length) {
|
|
40
|
+
const count = readSync(descriptor, buffer, offset, buffer.length - offset, null);
|
|
41
|
+
if (count === 0)
|
|
42
|
+
break;
|
|
43
|
+
offset += count;
|
|
44
|
+
}
|
|
45
|
+
const after = fstatSync(descriptor);
|
|
46
|
+
const parentAfter = lstatSync(dirname(path));
|
|
47
|
+
if (!sameFile(opened, after) || offset !== opened.size
|
|
48
|
+
|| !parentAfter.isDirectory() || parentAfter.isSymbolicLink()
|
|
49
|
+
|| parent.dev !== parentAfter.dev || parent.ino !== parentAfter.ino
|
|
50
|
+
|| (options.private && process.platform === "win32" && !privatePathIsSafe(path))
|
|
51
|
+
|| !sameFile(opened, lstatSync(path)))
|
|
52
|
+
throw new Error();
|
|
53
|
+
return { bytes: buffer.subarray(0, offset), stat: opened };
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// Do not quote paths, private payloads or operating-system diagnostics.
|
|
57
|
+
if (error instanceof Error && error.message === `${label} crosses a symlink.`)
|
|
58
|
+
throw error;
|
|
59
|
+
if (error.code === "ELOOP")
|
|
60
|
+
throw new Error(`${label} crosses a symlink.`);
|
|
61
|
+
throw new Error(`${label} is unsafe, changed or unreadable.`);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
if (descriptor !== undefined)
|
|
65
|
+
closeSync(descriptor);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function readStableText(path, options) {
|
|
69
|
+
const { bytes, stat } = readStableFile(path, options);
|
|
70
|
+
try {
|
|
71
|
+
return { text: new TextDecoder("utf-8", { fatal: true }).decode(bytes), stat };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new Error(`${options.label} contains invalid UTF-8.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function readStableJson(path, options) {
|
|
78
|
+
const { text } = readStableText(path, options);
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(text);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new Error(`${options.label} contains invalid JSON.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync,
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { getArtifactLimits, isArtifactStoreEnabled } from "./artifact-schema.js";
|
|
6
6
|
import { putArtifact } from "./artifact-store.js";
|
|
7
|
-
import { hardenPrivatePath
|
|
7
|
+
import { hardenPrivatePath } from "./platform-security.js";
|
|
8
|
+
import { readStableJson } from "./stable-file.js";
|
|
8
9
|
import { WORK_EVIDENCE_SCHEMA_VERSION, completeWorkEvidenceSchema, contextUsageReceiptInputSchema, getWorkEvidenceLimits, isWorkEvidenceEnabled, isWorkEvidencePatchEnabled, pendingWorkEvidenceSchema, storedWorkEvidenceSchema, workEvidenceIdSchema, workEvidenceLinkSchema, } from "./work-evidence-schema.js";
|
|
9
10
|
const MAX_RECORD_BYTES = 1024 * 1024;
|
|
10
11
|
const STALE_PENDING_MS = 24 * 60 * 60 * 1000;
|
|
@@ -97,11 +98,12 @@ function atomicJson(path, value) {
|
|
|
97
98
|
hardenPrivatePath(path);
|
|
98
99
|
}
|
|
99
100
|
function readJson(path) {
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
try {
|
|
102
|
+
return readStableJson(path, { label: "Work-evidence record", maxBytes: MAX_RECORD_BYTES, private: true });
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
102
105
|
throw new Error("Unsafe work-evidence record.");
|
|
103
106
|
}
|
|
104
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
105
107
|
}
|
|
106
108
|
function gitBuffer(root, args, maxBuffer = 16 * 1024 * 1024) {
|
|
107
109
|
return execFileSync("git", ["-C", root, ...args], {
|