@cairnkeep/cli 2.22.2 → 2.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +67 -1
  2. package/containers/healthcheck.mjs +27 -33
  3. package/containers/quadlet/cairnkeep.container +1 -1
  4. package/docs/containers.md +12 -0
  5. package/docs/domain-knowledge.md +15 -0
  6. package/docs/learning/CURRICULUM-MAP.md +1 -1
  7. package/docs/learning/FEATURE-GUIDE.md +1 -1
  8. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  9. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  10. package/docs/learning/lessons/L02-installation.md +2 -2
  11. package/docs/learning/lessons/L03-first-project.md +1 -1
  12. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  13. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  14. package/docs/learning/lessons/L21-context-packs.md +1 -1
  15. package/docs/learning/lessons/L22-native-windows.md +2 -2
  16. package/docs/learning/lessons/L23-guided-setup.md +8 -1
  17. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  18. package/docs/learning/lessons/L25-playbooks.md +1 -1
  19. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  20. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  21. package/docs/learning/lessons/L28-security-assurance.md +52 -2
  22. package/docs/learning/video-scripts/V28-security-assurance.md +45 -0
  23. package/docs/native-windows.md +10 -0
  24. package/docs/operating.md +19 -1
  25. package/docs/privacy-and-data-flow.md +6 -0
  26. package/docs/releasing.md +3 -2
  27. package/docs/security-assurance.md +67 -0
  28. package/examples/eval/bundled-fake.json +1 -1
  29. package/mcp-memory-server/dist/capability-registry.js +2 -15
  30. package/mcp-memory-server/dist/context-pack-retrieval.js +6 -6
  31. package/mcp-memory-server/dist/domain-retrieval-provider.js +100 -45
  32. package/mcp-memory-server/dist/eval-plan.js +15 -22
  33. package/mcp-memory-server/dist/eval-runner.js +17 -10
  34. package/mcp-memory-server/dist/eval-workspace.js +82 -23
  35. package/mcp-memory-server/dist/failure-signature.js +29 -8
  36. package/mcp-memory-server/dist/graph-cli.js +25 -10
  37. package/mcp-memory-server/dist/okf.js +4 -9
  38. package/mcp-memory-server/dist/playbook-receipt.js +3 -17
  39. package/mcp-memory-server/dist/skill-store.js +10 -26
  40. package/mcp-memory-server/dist/stable-file.js +85 -0
  41. package/mcp-memory-server/dist/work-evidence-store.js +7 -5
  42. package/omp/extensions/cairnkeep-capture.ts +29 -17
  43. package/package.json +1 -1
  44. package/scripts/lib/opencode-v2-plugin-harness.mjs +20 -3
  45. package/scripts/lib/stable-file.mjs +75 -0
  46. package/scripts/playbook-instructions.mjs +42 -20
  47. package/scripts/setup-core.mjs +5 -36
  48. package/scripts/setup-reconcile.mjs +32 -32
  49. package/scripts/setup.mjs +25 -34
  50. package/scripts/test-ci-sharding.sh +5 -0
  51. package/scripts/test-container-health.mjs +56 -0
  52. package/scripts/test-container.sh +8 -1
  53. package/scripts/test-learning-docs.sh +8 -0
  54. package/scripts/test-omp-staged-security.mjs +110 -0
  55. package/scripts/test-runtime-file-security.mjs +311 -0
  56. package/scripts/test-setup-diagnosis.mjs +97 -0
  57. package/scripts/test-windows-native.mjs +5 -0
  58. package/scripts/verify-omp-capture.mjs +1 -0
  59. package/scripts/windows-platform.mjs +54 -22
@@ -47,6 +47,13 @@ be enabled and evaluated independently.
47
47
  | Traversal, alternate separators, drive paths, ADS, forbidden Windows characters/device aliases, invalid Unicode, or case collisions in portable data | One cross-platform path validator is used by context packs, OKF, artifacts, and evaluations; deterministic adversarial cases run in the smoke suite | Filesystems can have additional locale- or mount-specific semantics; containment checks remain required at every write |
48
48
  | Symlink or special-file substitution | Canonical roots, non-symlink regular-file checks, atomic replacement, and existing symlink/race regression tests | A hostile process running as the same account can race or replace resources outside Cairnkeep's process boundary |
49
49
  | Replaced or growing private pack state between inspection and use | One descriptor for metadata and bounded content reads; name/parent identity rechecks, private ownership and encoding validation; deterministic replacement/denial controls | Concurrent legitimate replacement can require retry; these checks are not per-user ACLs or a defense against an account with full local control |
50
+ | Replaced capability configuration, skill state or work evidence | Inspected/opened file identity agreement, bounded descriptor reads, metadata/name/parent rechecks and strict UTF-8; private ledgers retain owner/ACL checks while ordinary project files retain their modes | Concurrent legitimate writes can require retry; this does not make skill mutation transactional against another same-account writer |
51
+ | Replaced or growing evaluation, OKF, graph, receipt or derived-cache inputs | Descriptor-bound byte caps and metadata/name/parent rechecks; snapshots publish the validated bytes, private receipts retain owner/ACL checks, and rejected derived summaries are rebuilt | Concurrent legitimate changes can require retry; this does not make directory traversal, graph publication or snapshot exposure transactional against another same-account writer |
52
+ | Replaced setup policy/state/owned assets or instruction files | Bounded descriptor reads, strict UTF-8, observed-target and immediate-parent rechecks before publication, exclusive temporary files and retained instruction modes | Publication checks are point-in-time, not compare-and-swap or an all-project transaction; same-account processes remain trusted |
53
+ | Symlinked, growing or oversized OMP staged candidates | Real staging directories, portable filenames and independent descriptor reads capped at 8 KiB; unsafe entries are omitted without blocking the session | Candidate contents are untrusted proposals; these checks neither approve them nor automatically write durable memory |
54
+ | Evaluation overlay target substitution or denied Windows replacement | Exclusive descriptor-written temporary files, checked parents/targets, and bounded retries that never unlink live data | Final-check-to-publication races are not eliminated against hostile same-account writers; a successful publication followed by an ACL failure is not a rollback |
55
+ | Redirected provider requests, unbounded responses or provider-error disclosure | Explicit validated endpoints, manual redirects, streaming response caps, body cancellation and payload-free diagnostics; container probes use the server's numeric port and token parsers | External retrieval still sends the selected query to the configured provider; remote AnythingLLM HTTP remains supported for compatibility and requires operator transport review |
56
+ | Crafted failure messages exhausting regex parsing | Delimited source-path parsing without ambiguous repeated path groups; timeout-isolated adversarial controls and byte-equivalent legacy v1 fixtures | This is targeted parser regression coverage, not a general input sandbox or coverage-guided fuzzing campaign |
50
57
  | Concurrent context-pack project pointer updates | Exclusive directory locks, bounded Windows contention retries, unsafe-lock rejection, injected denial controls and native cross-process exclusion tests | A persistent lock can block progress; recovery never infers abandonment or steals the lock |
51
58
  | Unauthenticated or rebound HTTP access | HTTP refuses to start without a token, compares it in constant time, validates `Host`, denies CORS by default, and bounds headers, request time, keep-alive time, and bodies to 8 MiB | Bearer tokens are not user identities; deploy TLS at the ingress for non-loopback traffic |
52
59
  | Excessive MCP authority | Explicit MCP annotations, restrictive profiles, capability intersection, and separate HTTP consent for sensitive optional tools | The default profile remains `full` for compatibility; operators should choose `read-only` or `custom` for observation-only clients |
@@ -140,3 +147,63 @@ The native-Windows matrix runs the CLI, package, and global-install contracts on
140
147
  Node 22, 24, and 26. It deterministically partitions the complete memory-server
141
148
  smoke catalog across those lanes exactly once; the Linux matrix still runs the
142
149
  complete catalog independently on every supported Node version.
150
+
151
+ The signature smoke check exercises crafted slash-heavy messages in a child
152
+ process with a five-second deadline, plus a deterministic generated corpus.
153
+ The runtime file-read control substitutes valid files after inspection through
154
+ the public capability, skill and work-evidence readers, and separately checks
155
+ growth, special-file substitution, byte limits, encoding and descriptor cleanup.
156
+ It runs as part of `check:security-assurance`; it can also be run directly:
157
+
158
+ ```sh
159
+ node mcp-memory-server/scripts/smoke-note-signatures.mjs
160
+ node mcp-memory-server/scripts/smoke-runtime-file-reads.mjs
161
+ node mcp-memory-server/scripts/smoke-bounded-input-reads.mjs
162
+ node mcp-memory-server/scripts/smoke-workspace-file-security.mjs
163
+ node mcp-memory-server/scripts/smoke-outbound-security.mjs
164
+ node scripts/test-runtime-file-security.mjs
165
+ node scripts/test-omp-staged-security.mjs
166
+ node scripts/test-container-health.mjs
167
+ ```
168
+
169
+ Build the server first when testing a source checkout. These regressions do not
170
+ assert that every historical CodeQL/Scorecard alert is fixed. Triage findings
171
+ individually and use a fresh default-branch analysis to confirm alert status;
172
+ never dismiss an uninvestigated finding merely to improve the score.
173
+
174
+ The bounded-input control exercises real evaluation, OKF import/export, graph
175
+ status, receipt and progressive-cache readers. Valid-file substitution and
176
+ same-inode growth are injected at actual inspection/read boundaries; the test
177
+ checks that no unbounded `readFile` allocation follows growth. Evaluation inputs
178
+ retain their exact-byte binding and require UTF-8. Snapshot file/count and total
179
+ byte limits are enforced during collection, and publication uses the retained
180
+ bytes rather than reopening mutable source paths. Private receipt permissions
181
+ remain mandatory. Derived cache failures rebuild from the current visible
182
+ immutable content; read-only cache diagnosis reports issues without deleting
183
+ them. These controls do not grant pack or skill authority, and they do not
184
+ replace the native-Windows and full CI gates.
185
+
186
+ The setup/OMP/publication follow-up uses the same descriptor-first contract in
187
+ bootstrap JavaScript, without requiring a built server for setup. Setup policy,
188
+ state and owned-asset reads and `AGENTS.md` are capped at 1 MiB. Unrecorded
189
+ caller-owned assets retain the metadata-only diagnosis contract and are not
190
+ adopted or rewritten. POSIX private setup state still requires owner/mode
191
+ checks; Windows writer ACL hardening is unchanged, and bootstrap reads do not
192
+ introduce a new Windows ACL-verification claim. OMP candidates are capped at
193
+ 8 KiB, including `/cairn-staged` display; unsafe entries remain invisible.
194
+
195
+ Workspace controls inject a target symlink at the mutation boundary and check
196
+ that outside content remains untouched. Root controls inject concurrent caller
197
+ edits, denied Windows replacements and colliding temporary names. Publication
198
+ uses exclusive temporaries, descriptor writes and flushes, and rechecks the
199
+ observed target and immediate parent. These are not directory-wide transactions
200
+ or a same-account sandbox. Windows compressed and decompressed archive input
201
+ is capped at 512 MiB; a multi-database import is not one atomic transaction.
202
+
203
+ Outbound controls stub `fetch` without network access, verify destination and
204
+ redirect authority, and inject oversized bodies and private failure sentinels.
205
+ AnythingLLM successful answers retain their prior field selection and JSON
206
+ fallback; its response cap is 8 MiB. OpenViking retains its 1 MiB cap, explicit
207
+ consents and HTTPS-or-loopback rule. Both reject malformed UTF-8 and suppress
208
+ network/stream diagnostics. Health probes use only `127.0.0.1` with a validated
209
+ port and the existing private token resolver, and never follow redirects.
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.22.2","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.22.4","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
@@ -1,6 +1,6 @@
1
- import { lstat, readFile } from "node:fs/promises";
2
1
  import { join } from "node:path";
3
2
  import { isNoteDistillationEnabled } from "./note-schema.js";
3
+ import { readStableJson } from "./stable-file.js";
4
4
  import { CAPABILITY_IDS, } from "./capability-schema.js";
5
5
  const MAX_COMPATIBILITY_CONFIG_BYTES = 64 * 1024;
6
6
  function environmentName(id) {
@@ -10,20 +10,7 @@ export async function isGraphCapabilityCompatible(projectRoot) {
10
10
  const planningDirectory = join(projectRoot, ".planning");
11
11
  const configPath = join(planningDirectory, "config.json");
12
12
  try {
13
- const [directoryStat, fileStat] = await Promise.all([
14
- lstat(planningDirectory),
15
- lstat(configPath),
16
- ]);
17
- if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory())
18
- return false;
19
- if (fileStat.isSymbolicLink() || !fileStat.isFile())
20
- return false;
21
- if (fileStat.size > MAX_COMPATIBILITY_CONFIG_BYTES)
22
- return false;
23
- const bytes = await readFile(configPath);
24
- if (bytes.byteLength > MAX_COMPATIBILITY_CONFIG_BYTES)
25
- return false;
26
- const parsed = JSON.parse(bytes.toString("utf8"));
13
+ const parsed = readStableJson(configPath, { label: "Graph compatibility configuration", maxBytes: MAX_COMPATIBILITY_CONFIG_BYTES });
27
14
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
28
15
  return false;
29
16
  const graphify = parsed.graphify;
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, readdir, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
+ import { readStableJson } from "./stable-file.js";
4
5
  function isDirectory(node) {
5
6
  return "type" in node && node.type === "directory";
6
7
  }
@@ -77,7 +78,7 @@ async function writeCache(path, record) {
77
78
  catch (error) {
78
79
  // Concurrent deterministic builders may publish the same record.
79
80
  try {
80
- await readFile(path);
81
+ readStableJson(path, { label: "Progressive context cache", maxBytes: CACHE_RECORD_MAX_BYTES });
81
82
  }
82
83
  catch {
83
84
  throw error;
@@ -94,7 +95,7 @@ async function enrichPackFiles(files, cacheBaseDirectory) {
94
95
  const cachePath = join(cacheBaseDirectory, packDigest, `${setDigest}.json`);
95
96
  let cached;
96
97
  try {
97
- const parsed = JSON.parse(await readFile(cachePath, "utf8"));
98
+ const parsed = readStableJson(cachePath, { label: "Progressive context cache", maxBytes: CACHE_RECORD_MAX_BYTES });
98
99
  if (validCache(parsed, packDigest, setDigest, files))
99
100
  cached = parsed;
100
101
  }
@@ -381,20 +382,19 @@ export async function doctorProgressiveContextCache(cacheBaseDirectory, packs, o
381
382
  for (const cacheEntry of (await readdir(packPath, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
382
383
  const cachePath = join(packPath, cacheEntry.name);
383
384
  const relative = `cache/context/${packEntry.name}/${cacheEntry.name}`;
384
- const cacheInfo = await lstat(cachePath);
385
385
  if (cacheEntry.name.endsWith(".tmp")) {
386
386
  temporaryRemnants.push(relative);
387
387
  await removeDerived(cachePath, relative);
388
388
  continue;
389
389
  }
390
390
  const match = /^([a-f0-9]{64})\.json$/.exec(cacheEntry.name);
391
- if (!match || !cacheInfo.isFile() || cacheInfo.isSymbolicLink() || cacheInfo.size > CACHE_RECORD_MAX_BYTES) {
391
+ if (!match) {
392
392
  issues.push(`Invalid progressive context cache entry: ${relative}`);
393
393
  await removeDerived(cachePath, relative);
394
394
  continue;
395
395
  }
396
396
  try {
397
- const value = JSON.parse(await readFile(cachePath, "utf8"));
397
+ const value = readStableJson(cachePath, { label: "Progressive context cache", maxBytes: CACHE_RECORD_MAX_BYTES });
398
398
  const invalid = validateCacheRecord(value, packEntry.name, match[1], installedFiles);
399
399
  if (invalid)
400
400
  throw new Error(invalid);
@@ -1,5 +1,6 @@
1
1
  import { isIP } from "node:net";
2
2
  const OPENVIKING_RESPONSE_LIMIT = 1024 * 1024;
3
+ const ANYTHINGLLM_RESPONSE_LIMIT = 8 * 1024 * 1024;
3
4
  const OPENVIKING_DEFAULT_TIMEOUT_MS = 30_000;
4
5
  const OPENVIKING_MAX_TIMEOUT_MS = 120_000;
5
6
  export function resolveDomainRetrievalProvider(env = process.env) {
@@ -56,14 +57,13 @@ function openVikingTimeout(env) {
56
57
  return timeout;
57
58
  }
58
59
  function openVikingTargetUri(workspace) {
59
- if (!workspace.startsWith("viking://")) {
60
- return `viking://resources/${encodeURIComponent(workspace)}`;
61
- }
62
- if (workspace !== "viking://resources"
63
- && !/^viking:\/\/resources\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$/.test(workspace)) {
60
+ const target = workspace.startsWith("viking://")
61
+ ? workspace : `viking://resources/${encodeURIComponent(workspace)}`;
62
+ if (target !== "viking://resources"
63
+ && !/^viking:\/\/resources\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$/.test(target)) {
64
64
  throw new Error("OpenViking workspace URI must stay below viking://resources.");
65
65
  }
66
- const path = workspace.slice("viking://resources".length);
66
+ const path = target.slice("viking://resources".length);
67
67
  let decoded;
68
68
  try {
69
69
  decoded = decodeURIComponent(path);
@@ -74,28 +74,49 @@ function openVikingTargetUri(workspace) {
74
74
  if (decoded.split("/").some((segment) => segment === "." || segment === "..")) {
75
75
  throw new Error("OpenViking workspace URI must not contain traversal segments.");
76
76
  }
77
- return workspace;
77
+ return target;
78
+ }
79
+ class ResponseReadError extends Error {
80
+ }
81
+ async function discardResponse(response) {
82
+ try {
83
+ await response.body?.cancel();
84
+ }
85
+ catch { /* Cleanup must not leak provider diagnostics. */ }
78
86
  }
79
- async function boundedResponseText(response) {
87
+ async function boundedResponseText(response, maximumBytes = OPENVIKING_RESPONSE_LIMIT, label = "OpenViking") {
80
88
  const advertisedLength = Number(response.headers.get("content-length"));
81
- if (Number.isFinite(advertisedLength) && advertisedLength > OPENVIKING_RESPONSE_LIMIT) {
82
- throw new Error("OpenViking response is too large.");
89
+ if (Number.isFinite(advertisedLength) && advertisedLength > maximumBytes) {
90
+ await discardResponse(response);
91
+ throw new ResponseReadError(`${label} response is too large.`);
83
92
  }
84
93
  if (!response.body)
85
94
  return "";
86
95
  const reader = response.body.getReader();
87
96
  const chunks = [];
88
97
  let length = 0;
89
- while (true) {
90
- const { done, value } = await reader.read();
91
- if (done)
92
- break;
93
- length += value.byteLength;
94
- if (length > OPENVIKING_RESPONSE_LIMIT) {
98
+ try {
99
+ while (true) {
100
+ const { done, value } = await reader.read();
101
+ if (done)
102
+ break;
103
+ length += value.byteLength;
104
+ if (length > maximumBytes)
105
+ throw new ResponseReadError(`${label} response is too large.`);
106
+ chunks.push(value);
107
+ }
108
+ }
109
+ catch (error) {
110
+ try {
95
111
  await reader.cancel();
96
- throw new Error("OpenViking response is too large.");
97
112
  }
98
- chunks.push(value);
113
+ catch { /* Preserve the payload-free failure. */ }
114
+ if (error instanceof ResponseReadError)
115
+ throw error;
116
+ throw new ResponseReadError(`${label} response could not be read or timed out.`);
117
+ }
118
+ finally {
119
+ reader.releaseLock();
99
120
  }
100
121
  const joined = new Uint8Array(length);
101
122
  let offset = 0;
@@ -103,7 +124,12 @@ async function boundedResponseText(response) {
103
124
  joined.set(chunk, offset);
104
125
  offset += chunk.byteLength;
105
126
  }
106
- return new TextDecoder("utf-8", { fatal: true }).decode(joined);
127
+ try {
128
+ return new TextDecoder("utf-8", { fatal: true }).decode(joined);
129
+ }
130
+ catch {
131
+ throw new ResponseReadError(`${label} response is not valid UTF-8.`);
132
+ }
107
133
  }
108
134
  async function callAnythingLLM(workspace, query, env) {
109
135
  const apiKey = env.ANYTHINGLLM_API_KEY;
@@ -111,23 +137,56 @@ async function callAnythingLLM(workspace, query, env) {
111
137
  throw new Error("ANYTHINGLLM_API_KEY is not set.");
112
138
  }
113
139
  const baseUrl = env.ANYTHINGLLM_BASE_URL ?? "http://localhost:3001";
114
- const response = await fetch(`${baseUrl}/api/v1/workspace/${encodeURIComponent(workspace)}/chat`, {
115
- method: "POST",
116
- headers: {
117
- Authorization: `Bearer ${apiKey}`,
118
- "Content-Type": "application/json",
119
- },
120
- body: JSON.stringify({
121
- message: query,
122
- mode: "query",
123
- }),
124
- signal: AbortSignal.timeout(120000),
125
- });
140
+ let endpoint;
141
+ try {
142
+ endpoint = new URL(baseUrl);
143
+ }
144
+ catch {
145
+ throw new Error("AnythingLLM base URL must be a valid HTTP(S) URL.");
146
+ }
147
+ if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:")
148
+ throw new Error("AnythingLLM base URL must use HTTP or HTTPS.");
149
+ if (endpoint.username || endpoint.password)
150
+ throw new Error("AnythingLLM base URL must not contain embedded credentials.");
151
+ if (endpoint.search || endpoint.hash)
152
+ throw new Error("AnythingLLM base URL must not contain a query string or fragment.");
153
+ if (!workspace || workspace === "." || workspace === "..")
154
+ throw new Error("AnythingLLM workspace must not be empty or a dot segment.");
155
+ endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, "")}/api/v1/workspace/${encodeURIComponent(workspace)}/chat`;
156
+ let response;
157
+ try {
158
+ response = await fetch(endpoint, {
159
+ method: "POST",
160
+ headers: {
161
+ Authorization: `Bearer ${apiKey}`,
162
+ "Content-Type": "application/json",
163
+ },
164
+ body: JSON.stringify({
165
+ message: query,
166
+ mode: "query",
167
+ }),
168
+ redirect: "manual",
169
+ signal: AbortSignal.timeout(120000),
170
+ });
171
+ }
172
+ catch {
173
+ throw new Error("AnythingLLM request failed or timed out.");
174
+ }
126
175
  if (!response.ok) {
127
- const text = await response.text();
128
- throw new Error(`AnythingLLM request failed with ${response.status}: ${text}`);
176
+ await discardResponse(response);
177
+ throw new Error(`AnythingLLM request failed with ${response.status}.`);
178
+ }
179
+ const text = await boundedResponseText(response, ANYTHINGLLM_RESPONSE_LIMIT, "AnythingLLM");
180
+ let payload;
181
+ try {
182
+ const parsed = JSON.parse(text);
183
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
184
+ throw new Error();
185
+ payload = parsed;
186
+ }
187
+ catch {
188
+ throw new Error("AnythingLLM response is not a JSON object.");
129
189
  }
130
- const payload = (await response.json());
131
190
  const directText = [
132
191
  payload.textResponse,
133
192
  payload.response,
@@ -204,6 +263,7 @@ async function callOpenViking(workspace, query, remote, env) {
204
263
  }
205
264
  const endpoint = openVikingEndpoint(env);
206
265
  const timeout = openVikingTimeout(env);
266
+ const targetUri = openVikingTargetUri(workspace);
207
267
  const apiKey = (env.CAIRN_OPENVIKING_API_KEY ?? env.OPENVIKING_API_KEY)?.trim();
208
268
  let response;
209
269
  try {
@@ -215,7 +275,7 @@ async function callOpenViking(workspace, query, remote, env) {
215
275
  },
216
276
  body: JSON.stringify({
217
277
  query,
218
- target_uri: openVikingTargetUri(workspace),
278
+ target_uri: targetUri,
219
279
  context_type: ["resource"],
220
280
  limit: 10,
221
281
  }),
@@ -227,26 +287,21 @@ async function callOpenViking(workspace, query, remote, env) {
227
287
  if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
228
288
  throw new Error("OpenViking request timed out.");
229
289
  }
230
- throw new Error(`OpenViking request failed: ${error instanceof Error ? error.message : String(error)}`);
290
+ throw new Error("OpenViking request failed.");
231
291
  }
232
292
  if (response.status >= 300 && response.status < 400) {
293
+ await discardResponse(response);
233
294
  throw new Error(`OpenViking request refused redirect status ${response.status}.`);
234
295
  }
235
296
  if (!response.ok) {
297
+ await discardResponse(response);
236
298
  throw new Error(`OpenViking request failed with ${response.status}.`);
237
299
  }
238
300
  if (!(response.headers.get("content-type") ?? "").toLowerCase().includes("application/json")) {
301
+ await discardResponse(response);
239
302
  throw new Error("OpenViking response is not JSON.");
240
303
  }
241
- let text;
242
- try {
243
- text = await boundedResponseText(response);
244
- }
245
- catch (error) {
246
- if (error instanceof TypeError)
247
- throw new Error("OpenViking response is not valid UTF-8.");
248
- throw error;
249
- }
304
+ const text = await boundedResponseText(response);
250
305
  return JSON.stringify(parseOpenVikingResult(text), null, 2);
251
306
  }
252
307
  export async function queryDomainKnowledge({ workspace, query, remote = false, env = process.env, }) {
@@ -1,10 +1,11 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { constants } from "node:fs";
3
- import { accessSync, closeSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync, } from "node:fs";
3
+ import { accessSync, existsSync, lstatSync, realpathSync, } from "node:fs";
4
4
  import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { CAPABILITY_IDS } from "./capability-schema.js";
7
7
  import { EVAL_SCHEMA_VERSION, canonicalDigest, canonicalJson, evalAdapterConfigSchema, evalTaskSetSchema, } from "./eval-schema.js";
8
+ import { readStableFile } from "./stable-file.js";
8
9
  const MAX_INPUT_BYTES = 4 * 1024 * 1024;
9
10
  const MAX_GIT_OUTPUT_BYTES = 8 * 1024 * 1024;
10
11
  const MAX_REPETITIONS = 1_000;
@@ -37,30 +38,22 @@ function assertRealDirectory(path, label) {
37
38
  }
38
39
  function readBoundedJson(path, label) {
39
40
  const absolute = resolve(path);
40
- const pathInfo = lstatSync(absolute);
41
- if (pathInfo.isSymbolicLink() || !pathInfo.isFile())
42
- throw new Error(`${label} must be a regular non-symlink file.`);
43
- const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
44
- const descriptor = openSync(absolute, flags);
41
+ const { bytes } = readStableFile(absolute, { label, maxBytes: MAX_INPUT_BYTES });
42
+ let text;
45
43
  try {
46
- const info = fstatSync(descriptor);
47
- if (!info.isFile() || info.size > MAX_INPUT_BYTES)
48
- throw new Error(`${label} exceeds the ${MAX_INPUT_BYTES}-byte limit.`);
49
- const bytes = readFileSync(descriptor);
50
- if (bytes.byteLength > MAX_INPUT_BYTES)
51
- throw new Error(`${label} exceeds the ${MAX_INPUT_BYTES}-byte limit.`);
52
- let value;
53
- try {
54
- value = JSON.parse(bytes.toString("utf8"));
55
- }
56
- catch {
57
- throw new Error(`${label} is not valid JSON.`);
58
- }
59
- return { path: realpathSync(absolute), value, bytes };
44
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
45
+ }
46
+ catch {
47
+ throw new Error(`${label} contains invalid UTF-8.`);
60
48
  }
61
- finally {
62
- closeSync(descriptor);
49
+ let value;
50
+ try {
51
+ value = JSON.parse(text);
52
+ }
53
+ catch {
54
+ throw new Error(`${label} is not valid JSON.`);
63
55
  }
56
+ return { path: realpathSync(absolute), value, bytes };
64
57
  }
65
58
  function git(repository, args, label) {
66
59
  const result = spawnSync("git", ["-C", repository, ...args], {
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
- import { chmod, copyFile, lstat, mkdtemp, mkdir, readFile, readdir, realpath, rm, } from "node:fs/promises";
3
+ import { chmod, copyFile, lstat, mkdtemp, mkdir, readdir, realpath, rm, writeFile, } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -11,6 +11,7 @@ import { EvalProcessError, runBoundedCommand, runBoundedJsonAdapter, } from "./e
11
11
  import { checkpointEvalReport, createEvalReportStore, } from "./eval-report.js";
12
12
  import { applyEvalWorkspaceOverlay, cleanupEvalWorkspace, createEvalWorkspace, runTaskPreparation, runTaskVerifier, } from "./eval-workspace.js";
13
13
  import { resolveMcpToolProfile } from "./mcp-tool-profile.js";
14
+ import { readStableFile } from "./stable-file.js";
14
15
  const moduleDirectory = dirname(fileURLToPath(import.meta.url));
15
16
  const publishedReportSchemaPath = resolve(moduleDirectory, "..", "..", "schemas", "eval-report.schema.json");
16
17
  let reportSchemaDigestsCache;
@@ -240,25 +241,30 @@ async function checkpoint(reportStore, report) {
240
241
  }
241
242
  async function collectSnapshotFiles(root) {
242
243
  const files = [];
244
+ let totalBytes = 0;
243
245
  const walk = async (directory) => {
244
246
  const entries = await readdir(directory, { withFileTypes: true });
245
247
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, "en"))) {
246
248
  const absolute = join(directory, entry.name);
247
- const info = await lstat(absolute);
248
- if (info.isSymbolicLink())
249
+ if (entry.isSymbolicLink())
249
250
  throw new Error("unsafe_note_snapshot");
250
- if (info.isDirectory()) {
251
+ if (entry.isDirectory()) {
252
+ const info = await lstat(absolute);
253
+ if (!info.isDirectory() || info.isSymbolicLink())
254
+ throw new Error("unsafe_note_snapshot");
251
255
  await walk(absolute);
252
256
  continue;
253
257
  }
254
- if (!info.isFile())
258
+ if (!entry.isFile())
255
259
  throw new Error("unsafe_note_snapshot");
256
- const bytes = await readFile(absolute);
257
- files.push({ path: relative(root, absolute).split(sep).join("/"), bytes });
258
- if (files.length > MAX_SNAPSHOT_FILES
259
- || files.reduce((total, file) => total + file.bytes.byteLength, 0) > MAX_SNAPSHOT_BYTES) {
260
+ if (files.length >= MAX_SNAPSHOT_FILES) {
260
261
  throw new Error("note_snapshot_limit");
261
262
  }
263
+ const { bytes } = readStableFile(absolute, { label: "Evaluation note snapshot", maxBytes: MAX_SNAPSHOT_BYTES - totalBytes || 1 });
264
+ files.push({ path: relative(root, absolute).split(sep).join("/"), bytes });
265
+ totalBytes += bytes.byteLength;
266
+ if (totalBytes > MAX_SNAPSHOT_BYTES)
267
+ throw new Error("note_snapshot_limit");
262
268
  }
263
269
  };
264
270
  await walk(root);
@@ -300,7 +306,8 @@ export async function snapshotTaskNotes(options) {
300
306
  if (!isContained(root, destination) || destination === root)
301
307
  throw new Error("unsafe_note_snapshot");
302
308
  await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
303
- await copyFile(join(sourceRoot, ...file.path.split("/")), destination);
309
+ // Publish the validated bytes, never reopen the mutable source.
310
+ await writeFile(destination, file.bytes, { flag: "wx", mode: 0o600 });
304
311
  await chmod(destination, 0o400);
305
312
  manifest.push({ path: file.path, digest: sha256(file.bytes), bytes: file.bytes.byteLength });
306
313
  }