@cairnkeep/cli 2.22.3 → 2.22.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +79 -1
  2. package/containers/healthcheck.mjs +27 -33
  3. package/containers/quadlet/cairnkeep.container +1 -1
  4. package/docs/containers.md +12 -0
  5. package/docs/domain-knowledge.md +15 -0
  6. package/docs/learning/CURRICULUM-MAP.md +1 -1
  7. package/docs/learning/FEATURE-GUIDE.md +1 -1
  8. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  9. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  10. package/docs/learning/lessons/L02-installation.md +2 -2
  11. package/docs/learning/lessons/L03-first-project.md +1 -1
  12. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  13. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  14. package/docs/learning/lessons/L21-context-packs.md +1 -1
  15. package/docs/learning/lessons/L22-native-windows.md +2 -2
  16. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  17. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  18. package/docs/learning/lessons/L25-playbooks.md +1 -1
  19. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  20. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  21. package/docs/learning/lessons/L28-security-assurance.md +72 -2
  22. package/docs/learning/tracks/operator.md +6 -1
  23. package/docs/learning/video-scripts/V28-security-assurance.md +65 -0
  24. package/docs/native-windows.md +10 -0
  25. package/docs/operating.md +10 -0
  26. package/docs/privacy-and-data-flow.md +6 -0
  27. package/docs/releasing.md +61 -2
  28. package/docs/security-assurance.md +82 -0
  29. package/examples/eval/bundled-fake.json +1 -1
  30. package/mcp-memory-server/dist/capability-registry.js +2 -15
  31. package/mcp-memory-server/dist/context-pack-retrieval.js +6 -6
  32. package/mcp-memory-server/dist/domain-retrieval-provider.js +100 -45
  33. package/mcp-memory-server/dist/eval-plan.js +15 -22
  34. package/mcp-memory-server/dist/eval-runner.js +17 -10
  35. package/mcp-memory-server/dist/eval-workspace.js +82 -23
  36. package/mcp-memory-server/dist/failure-signature.js +29 -8
  37. package/mcp-memory-server/dist/graph-cli.js +25 -10
  38. package/mcp-memory-server/dist/note-store.js +24 -4
  39. package/mcp-memory-server/dist/okf.js +4 -9
  40. package/mcp-memory-server/dist/playbook-receipt.js +3 -17
  41. package/mcp-memory-server/dist/skill-store.js +10 -26
  42. package/mcp-memory-server/dist/stable-file.js +85 -0
  43. package/mcp-memory-server/dist/work-evidence-store.js +7 -5
  44. package/omp/extensions/cairnkeep-capture.ts +29 -17
  45. package/package.json +1 -1
  46. package/scripts/lib/opencode-v2-plugin-harness.mjs +20 -3
  47. package/scripts/lib/release-verification.mjs +131 -0
  48. package/scripts/lib/stable-file.mjs +75 -0
  49. package/scripts/playbook-instructions.mjs +42 -20
  50. package/scripts/setup-core.mjs +5 -36
  51. package/scripts/setup-reconcile.mjs +32 -32
  52. package/scripts/setup.mjs +7 -31
  53. package/scripts/test-ci-sharding.sh +5 -0
  54. package/scripts/test-container-health.mjs +56 -0
  55. package/scripts/test-container.sh +8 -1
  56. package/scripts/test-learning-docs.sh +8 -0
  57. package/scripts/test-omp-staged-security.mjs +110 -0
  58. package/scripts/test-release-verification.mjs +109 -0
  59. package/scripts/test-release-verification.sh +4 -0
  60. package/scripts/test-runtime-file-security.mjs +311 -0
  61. package/scripts/test-setup-reconcile.mjs +4 -2
  62. package/scripts/test-windows-native.mjs +4 -0
  63. package/scripts/verify-omp-capture.mjs +1 -0
  64. package/scripts/verify-phase19-runtime-evidence.mjs +43 -3
  65. package/scripts/verify-published-release.mjs +138 -0
  66. package/scripts/windows-platform.mjs +54 -22
package/docs/operating.md CHANGED
@@ -100,6 +100,16 @@ selected asset is a regular file under non-symlinked scaffold directories and
100
100
  that POSIX launchers are executable, without adopting or rewriting custom files.
101
101
  An empty ownership map is valid when all required assets were already present.
102
102
 
103
+ In the security follow-up, policy/state/owned-asset and `AGENTS.md` reads are
104
+ descriptor-bound and capped at 1 MiB. A changed, linked, oversized or invalid
105
+ UTF-8 input fails closed instead of being accepted through a later path read.
106
+ Publication rechecks observed targets and preserves concurrent caller edits
107
+ detected before replacement. Instruction reconciliation retains the original
108
+ file mode; unsafe instruction files are not rewritten. These checks are not an
109
+ all-project transaction or a sandbox against another process using your account.
110
+ Correct the reported input and rerun the printed recovery command; do not
111
+ delete caller-owned files or the ownership record to bypass a failure.
112
+
103
113
  Setup never installs or refreshes machine-level harness assets. Its
104
114
  `machine_sync.automatic` field is always false, and the human output labels each
105
115
  reported command as not run automatically. The JSON result's
@@ -683,6 +683,12 @@ skill readable; Cairnkeep never copies it into a harness or executes it.
683
683
  - The OpenViking adapter is query-only. It sends the requested workspace target
684
684
  and query to the configured endpoint, but performs no synchronization, watch,
685
685
  commit, update, or deletion. Non-loopback endpoints must use HTTPS.
686
+ - In the query security follow-up, AnythingLLM and OpenViking do not follow
687
+ redirects, bound response bytes and suppress provider error bodies and raw
688
+ network/stream diagnostics. Successful requests still send the selected
689
+ query to the configured provider. AnythingLLM retains remote HTTP support
690
+ for compatibility; choose HTTPS outside loopback. Synchronization scripts
691
+ have their own authority and do not inherit query-only guarantees.
686
692
 
687
693
  Pack digests and proposal digests protect integrity and stale-state detection;
688
694
  they do not authenticate a publisher or make extracted statements true.
package/docs/releasing.md CHANGED
@@ -47,8 +47,9 @@ does not require republishing the npm package.
47
47
  anonymous pull before announcing the release.
48
48
 
49
49
  The native-Windows lanes retain stable check names for branch protection. Each
50
- lane exercises the Windows CLI and packed global install on its Node version,
51
- while the complete memory-server smoke catalog is deterministically balanced
50
+ lane exercises the Windows CLI and packed global install on its Node version.
51
+ Focused bounded-input controls run first so platform-specific failures surface
52
+ before the longer smoke shards. The complete memory-server smoke catalog is deterministically balanced
52
53
  across the three lanes. The Linux Node 22/24/26 matrix independently runs the
53
54
  complete catalog on every supported runtime. This preserves coverage while
54
55
  removing three serial copies of the slow Windows suite from the critical path.
@@ -67,6 +68,64 @@ different version or tree.
67
68
 
68
69
  ## Repository configuration
69
70
 
71
+ ### Repeatable post-publication verification
72
+
73
+ From a source checkout, use the maintained artifact verifier after the normal
74
+ publication workflow succeeds:
75
+
76
+ ```sh
77
+ node scripts/verify-published-release.mjs \
78
+ --version X.Y.Z --commit RELEASE_COMMIT --tree RELEASE_TREE \
79
+ --ci-run SUCCESSFUL_CI_RUN --security-run SUCCESSFUL_SECURITY_RUN \
80
+ --publish-run SUCCESSFUL_PUBLISH_RUN \
81
+ --out /private/evidence/release-verification.json
82
+ ```
83
+
84
+ Provide the exact 40-character commit and tree and numeric run IDs. The output
85
+ parent must already exist. The tool refuses to replace an existing report and
86
+ writes one only after all checks succeed, with mode `0600` on POSIX. It supports
87
+ stable releases whose npm version and both `latest` image tags are the expected
88
+ release; it does not reinterpret an old release as the current channel.
89
+
90
+ It requires a GitHub CLI supporting `attestation verify` and its exact-source,
91
+ workflow, certificate-identity and hosted-runner policy flags, plus a current
92
+ npm supporting registry-signature and provenance verification. Use `--gh PATH`
93
+ or `--npm PATH` for explicit tool selection; stale trust data or unsupported
94
+ flags fail closed rather than skipping a check. No runtime dependency is added.
95
+
96
+ The tool checks successful maintained CI, security and publication workflows, immutable
97
+ tag/commit/tree binding, retained CI candidate checksums and exact release-asset
98
+ bytes, CycloneDX 1.6, registry bytes/integrity, npm signatures/attestations, both
99
+ anonymous OCI architectures, per-architecture bound SPDX documents and signed
100
+ exact-source publication provenance. It uses bounded network responses and
101
+ subprocess deadlines. Registry blob redirects are limited to the designated
102
+ GitHub container storage host, without forwarding registry authorization.
103
+
104
+ Verification downloads assets and installs the exact registry package into
105
+ private disposable staging with lifecycle scripts disabled; staging is removed
106
+ on success or failure. It does not publish, move tags, change configuration,
107
+ restart a server, enable capture or upgrade a machine. The report explicitly
108
+ states `deployment_verified: false`: separately run the maintained backup,
109
+ upgrade/rollback, routed/legacy-session and project-doctor procedures. Artifact
110
+ verification is not deployment evidence or a security certification.
111
+ Anonymous OCI checks read manifests, configuration and bound SBOM blobs, not
112
+ every image layer. The report records `full_image_layer_pull_verified: false`;
113
+ the separate anonymous full-pull release gate above still applies.
114
+
115
+ Run the offline positive and mutation controls without network access:
116
+
117
+ ```sh
118
+ node scripts/test-release-verification.mjs
119
+ ```
120
+
121
+ These controls inject failed workflows, different trees/tags, expired
122
+ candidates, tampered assets, unsafe checksum paths, registry mismatches,
123
+ signature/provenance failures, missing architectures and unbound SBOMs. They
124
+ test policy decisions using synthetic inputs, not real cryptographic signatures;
125
+ the live verifier delegates signature validation to npm and the GitHub CLI.
126
+
127
+ ### Publication permissions
128
+
70
129
  The repository must provide an `NPM_TOKEN` Actions secret authorized to publish
71
130
  `@cairnkeep/cli`. Keep that credential out of local files and rotate it according
72
131
  to the npm account's security policy. Workflow defaults grant only
@@ -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,78 @@ 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
+ node mcp-memory-server/scripts/smoke-note-mcp.mjs --fixture-security-only
168
+ node scripts/verify-phase19-runtime-evidence.mjs --self-test
169
+ ```
170
+
171
+ Build the server first when testing a source checkout. These regressions do not
172
+ assert that every historical CodeQL/Scorecard alert is fixed. Triage findings
173
+ individually and use a fresh default-branch analysis to confirm alert status;
174
+ never dismiss an uninvestigated finding merely to improve the score.
175
+
176
+ The developer-evidence follow-up also hardens the shared runtime note pre-image
177
+ hasher: it reads from an inspected descriptor under a 64 MiB cap and rejects
178
+ replacement or growth before accepting a hash. Missing targets remain valid
179
+ pre-images; linked or unsafe targets do not. Fixture creation uses exclusive
180
+ creation, retaining a file created by a competing writer. Regression controls
181
+ exercise both the actual transaction entry point and fixture creation.
182
+ Developer snapshots retain exact hashes and modes from the same bounded read;
183
+ the runtime-evidence verifier retains its 16 MiB cap and rejects both growth
184
+ and replacement. Corrupt SQLite fixtures must still remain failed and unchanged
185
+ after doctor. Remote-context test labels never interpolate provider values.
186
+ These are read-integrity controls, not atomic multi-file note publication or a
187
+ same-account sandbox. Re-run the full native-platform gates before shipping.
188
+
189
+ The bounded-input control exercises real evaluation, OKF import/export, graph
190
+ status, receipt and progressive-cache readers. Valid-file substitution and
191
+ same-inode growth are injected at actual inspection/read boundaries; the test
192
+ checks that no unbounded `readFile` allocation follows growth. Evaluation inputs
193
+ retain their exact-byte binding and require UTF-8. Snapshot file/count and total
194
+ byte limits are enforced during collection, and publication uses the retained
195
+ bytes rather than reopening mutable source paths. Private receipt permissions
196
+ remain mandatory. Derived cache failures rebuild from the current visible
197
+ immutable content; read-only cache diagnosis reports issues without deleting
198
+ them. These controls do not grant pack or skill authority, and they do not
199
+ replace the native-Windows and full CI gates.
200
+
201
+ The setup/OMP/publication follow-up uses the same descriptor-first contract in
202
+ bootstrap JavaScript, without requiring a built server for setup. Setup policy,
203
+ state and owned-asset reads and `AGENTS.md` are capped at 1 MiB. Unrecorded
204
+ caller-owned assets retain the metadata-only diagnosis contract and are not
205
+ adopted or rewritten. POSIX private setup state still requires owner/mode
206
+ checks; Windows writer ACL hardening is unchanged, and bootstrap reads do not
207
+ introduce a new Windows ACL-verification claim. OMP candidates are capped at
208
+ 8 KiB, including `/cairn-staged` display; unsafe entries remain invisible.
209
+
210
+ Workspace controls inject a target symlink at the mutation boundary and check
211
+ that outside content remains untouched. Root controls inject concurrent caller
212
+ edits, denied Windows replacements and colliding temporary names. Publication
213
+ uses exclusive temporaries, descriptor writes and flushes, and rechecks the
214
+ observed target and immediate parent. These are not directory-wide transactions
215
+ or a same-account sandbox. Windows compressed and decompressed archive input
216
+ is capped at 512 MiB; a multi-database import is not one atomic transaction.
217
+
218
+ Outbound controls stub `fetch` without network access, verify destination and
219
+ redirect authority, and inject oversized bodies and private failure sentinels.
220
+ AnythingLLM successful answers retain their prior field selection and JSON
221
+ fallback; its response cap is 8 MiB. OpenViking retains its 1 MiB cap, explicit
222
+ consents and HTTPS-or-loopback rule. Both reject malformed UTF-8 and suppress
223
+ network/stream diagnostics. Health probes use only `127.0.0.1` with a validated
224
+ port and the existing private token resolver, and never follow redirects.
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.22.3","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.22.5","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
  }