@cairnkeep/cli 2.20.0 → 2.21.0

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 (61) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +6 -3
  3. package/SECURITY.md +13 -0
  4. package/containers/entrypoint.sh +2 -1
  5. package/docs/README.md +2 -0
  6. package/docs/learning/CURRICULUM-MAP.md +4 -2
  7. package/docs/learning/FEATURE-GUIDE.md +6 -1
  8. package/docs/learning/README.md +1 -0
  9. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  10. package/docs/learning/lessons/L01-safe-trial.md +1 -1
  11. package/docs/learning/lessons/L02-installation.md +1 -1
  12. package/docs/learning/lessons/L03-first-project.md +1 -1
  13. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  14. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  15. package/docs/learning/lessons/L21-context-packs.md +1 -1
  16. package/docs/learning/lessons/L22-native-windows.md +1 -1
  17. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  18. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  19. package/docs/learning/lessons/L25-playbooks.md +1 -1
  20. package/docs/learning/lessons/L26-context-intelligence.md +3 -0
  21. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  22. package/docs/learning/lessons/L28-security-assurance.md +104 -0
  23. package/docs/learning/video-scripts/V28-security-assurance.md +72 -0
  24. package/docs/operating.md +40 -5
  25. package/docs/privacy-and-data-flow.md +14 -0
  26. package/docs/releasing.md +9 -1
  27. package/docs/security-assurance.md +122 -0
  28. package/examples/eval/bundled-fake.json +1 -1
  29. package/mcp-memory-server/dist/artifact-schema.js +2 -7
  30. package/mcp-memory-server/dist/context-pack.js +152 -31
  31. package/mcp-memory-server/dist/eval-schema.js +13 -12
  32. package/mcp-memory-server/dist/http-security.js +104 -0
  33. package/mcp-memory-server/dist/index.js +50 -12
  34. package/mcp-memory-server/dist/okf.js +35 -9
  35. package/mcp-memory-server/dist/path-security.js +49 -0
  36. package/mcp-memory-server/dist/platform-security.js +154 -31
  37. package/mcp-memory-server/dist/project-environment.js +132 -0
  38. package/mcp-memory-server/dist/security-cli.js +46 -0
  39. package/mcp-memory-server/dist/security-doctor.js +211 -0
  40. package/mcp-memory-server/package.json +5 -4
  41. package/package.json +5 -4
  42. package/scripts/bootstrap.sh +1 -0
  43. package/scripts/cairn-cli.mjs +2 -0
  44. package/scripts/completion.sh +9 -3
  45. package/scripts/setup-reconcile.mjs +3 -0
  46. package/scripts/test-bootstrap-untracked.sh +2 -0
  47. package/scripts/test-cli-dispatch.sh +16 -1
  48. package/scripts/test-completion.sh +3 -0
  49. package/scripts/test-container-runner.sh +7 -0
  50. package/scripts/test-launcher-env-precedence.sh +30 -0
  51. package/scripts/test-learning-docs.sh +19 -0
  52. package/scripts/test-security-baseline.sh +4 -0
  53. package/scripts/verify-security-baseline.mjs +79 -0
  54. package/scripts/windows-platform.mjs +4 -2
  55. package/templates/start-claude.sh.template +3 -0
  56. package/templates/start-codex.sh.template +3 -0
  57. package/templates/start-kimi.sh.template +3 -0
  58. package/templates/start-omp.sh.template +3 -0
  59. package/templates/start-opencode.sh.template +3 -0
  60. package/templates/start-pi.sh.template +3 -0
  61. package/templates/start-qwen.sh.template +3 -0
@@ -0,0 +1,122 @@
1
+ # Security assurance and threat model
2
+
3
+ This document describes Cairnkeep's security boundaries, the controls that
4
+ enforce them, and the risks that remain. It is an engineering contract, not a
5
+ certification. Report suspected vulnerabilities through the private process in
6
+ [SECURITY.md](../SECURITY.md).
7
+
8
+ ## Security objectives
9
+
10
+ Cairnkeep aims to:
11
+
12
+ - keep project memory, local evidence, credentials, and approvals scoped to the
13
+ intended project and operator;
14
+ - make network exposure explicit, authenticated, bounded, and closed by
15
+ default;
16
+ - prevent persisted portable paths from changing meaning across POSIX and
17
+ Windows;
18
+ - separate read-only observation from mutation and require explicit enablement
19
+ for optional authority;
20
+ - make releases traceable to one reviewed Git tree with pinned build actions,
21
+ dependency checks, an SBOM, and provenance.
22
+
23
+ Availability of the host, secrecy after an operating-system account compromise,
24
+ and correctness of an external model or harness are outside those objectives.
25
+
26
+ ## Assets and trust boundaries
27
+
28
+ | Asset | Boundary | Principal controls |
29
+ |---|---|---|
30
+ | Project memory and local evidence | Project root and `.agentfs/` | Canonical project identity, contained paths, private files, scoped stores |
31
+ | Credentials and endpoint configuration | Process environment and `.ai/.env` | Local-only defaults, private-file checks, redacted diagnostics |
32
+ | MCP mutation authority | Effective tool catalog | Complete tool annotations, capability gates, read-only/custom profiles |
33
+ | Remote MCP transport | Authenticated HTTP listener | Fail-closed bearer auth, Host allowlist, opt-in CORS, body/header/time limits |
34
+ | Context-pack and skill content | Immutable digest-pinned objects | Portable path validation, content digests, explicit enablement and skill approval |
35
+ | Release artifacts | Reviewed Git tree and GitHub Actions | SHA-pinned actions, dependency review, CodeQL, SBOM, checksums and provenance |
36
+
37
+ The local stdio server trusts the account that launches it and the selected
38
+ project directory. An authenticated HTTP deployment is one trusted storage
39
+ domain: project-routing headers select a store but are not a user ACL. Harness
40
+ plugins and external retrieval/model services are separate principals and must
41
+ be enabled and evaluated independently.
42
+
43
+ ## Threats and mitigations
44
+
45
+ | Threat | Mitigation and verification | Residual risk |
46
+ |---|---|---|
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
+ | 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
+ | 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 |
50
+ | 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 |
51
+ | Malicious repository or context content influencing an agent | Memory results are locators, skills need digest-bound approval, pack content stays read-only, and maintained source must be verified | Prompt injection is not solved; the harness/model must continue treating retrieved text as untrusted data |
52
+ | Credential leakage through diagnostics | Security diagnostics report only presence, strength, file safety, and transport posture; tests assert token values never appear | A compromised process or account can read its own environment and files |
53
+ | Dependency or CI compromise | Production audits, dependency review, CodeQL, advisory Scorecard, selected Actions, mandatory full-SHA pins, release-candidate checks, SBOMs and attestations | These controls reduce risk but do not prove dependency or publisher authenticity |
54
+
55
+ ## Local posture check
56
+
57
+ Run the read-only check from a configured project:
58
+
59
+ ```sh
60
+ cairn security doctor --project .
61
+ cairn security doctor --project . --json
62
+ ```
63
+
64
+ The command inspects project-root canonicalization and its immediate replacement
65
+ boundary, private managed files, the
66
+ effective MCP profile, HTTP token strength and token-file permissions, Host
67
+ allowlisting, least authority, and remote-transport encryption. It reads token
68
+ length, repetition, and header-safe syntax only; it never emits token content. `FAIL` makes the
69
+ command exit non-zero. `WARN` is an explicit risk decision and does not fail;
70
+ `SKIP` means the corresponding optional surface is disabled or absent.
71
+
72
+ HTTP Bearer values must use the RFC-compatible ASCII token alphabet
73
+ (`A-Z`, `a-z`, digits, `-._~+/`, with optional trailing `=`). Controls,
74
+ whitespace, Unicode, and multiline values fail closed before the listener starts.
75
+
76
+ The selected project's `.ai/.env` is parsed as literal assignments without
77
+ execution through a descriptor-bound 64 KiB read; ambient variables override
78
+ file values, and the generated launchers preserve that precedence. Relative
79
+ token-file paths are resolved from the selected project.
80
+ Symlinked `.ai` directories, replaceable project roots, unsafe private files,
81
+ and ambiguous shell expressions fail closed. POSIX checks require trusted
82
+ ownership as well as restrictive mode bits; an unquoted value is limited to a
83
+ small non-shell metacharacter alphabet, so quote values containing spaces or
84
+ punctuation.
85
+
86
+ `cairn doctor` remains the operational dependency/store check. Run both before
87
+ exposing HTTP, after changing tool authority, and before a security-sensitive
88
+ release.
89
+
90
+ ## Repository and release controls
91
+
92
+ The public repository enables dependency vulnerability alerts and automated
93
+ security updates, secret scanning with push protection, selected GitHub
94
+ Actions, and required full-length action SHA pins. Pull requests run CodeQL and
95
+ dependency review. OpenSSF Scorecard runs on the default branch as advisory
96
+ evidence; it is not an authorization oracle.
97
+
98
+ The release workflow consumes the already-tested candidate for the exact Git
99
+ tree. It verifies the candidate manifest and checksums, publishes npm with
100
+ provenance, produces CycloneDX 1.6 and container SBOMs, and attests container
101
+ provenance. A digest proves content integrity, not publisher identity; verify
102
+ the repository, release tag, registry namespace, and provenance together.
103
+
104
+ ## Deliberate non-goals and residual risks
105
+
106
+ - Cairnkeep is not a sandbox, endpoint-security product, secret manager, TLS
107
+ terminator, or multi-user authorization service.
108
+ - It cannot make a malicious harness, model, plugin, repository, or retrieved
109
+ document trustworthy.
110
+ - Local databases are protected by operating-system permissions, not
111
+ application-level encryption.
112
+ - Same-account processes and host administrators remain trusted.
113
+ - Security checks are point-in-time evidence. Re-run them after configuration,
114
+ dependency, network, or filesystem changes.
115
+
116
+ ## Verification evidence
117
+
118
+ The maintained gates are `npm run security:baseline`, both production
119
+ `npm audit` checks, `scripts/test-security-baseline.sh`, the memory-server
120
+ security-assurance and HTTP-guard smoke tests, native-Windows tests, and the
121
+ complete public test matrix. The baseline gate also rejects
122
+ `pull_request_target` and every unpinned non-local GitHub Action.
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.20.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.21.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { isAbsolute } from "node:path";
3
2
  import { z } from "zod";
4
3
  import { nodePathSchema } from "./node-schema.js";
4
+ import { portableRelativePathIssue } from "./path-security.js";
5
5
  export const ARTIFACT_SCHEMA_VERSION = 1;
6
6
  export const ARTIFACT_KINDS = [
7
7
  "compaction_summary",
@@ -63,13 +63,8 @@ export const testOutputContentSchema = z.object({
63
63
  status: z.enum(["passed", "failed", "unknown"]).optional(),
64
64
  }).strict();
65
65
  export const generatedFilePathLabelSchema = z.string().min(1).max(1024).superRefine((value, context) => {
66
- if (isAbsolute(value) || value.startsWith("/") || value.endsWith("/") || value.includes("\\") || /[\u0000-\u001f\u007f]/.test(value)) {
66
+ if (portableRelativePathIssue(value)) {
67
67
  context.addIssue({ code: "custom", message: "Generated-file path label must be project-relative." });
68
- return;
69
- }
70
- const segments = value.split("/");
71
- if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
72
- context.addIssue({ code: "custom", message: "Generated-file path label contains an invalid segment." });
73
68
  }
74
69
  });
75
70
  export const generatedFileInputContentSchema = z.object({
@@ -1,15 +1,16 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
4
- import { chmod, cp, mkdir, mkdtemp, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
3
+ import { constants, existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
4
+ import { chmod, mkdir, mkdtemp, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { z } from "zod";
9
9
  import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText } from "./embeddings.js";
10
10
  import { doctorProgressiveContextCache, loadProgressiveContext, publicTree, selectHierarchicalFiles, } from "./context-pack-retrieval.js";
11
- import { indexOkfBundle, validateOkfBundle } from "./okf.js";
11
+ import { indexOkfBundle, validateOkfBundle, validatedOkfFileContent } from "./okf.js";
12
12
  import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
13
+ import { portablePathCollisionKey, portableRelativePathIssue } from "./path-security.js";
13
14
  const execFileAsync = promisify(execFile);
14
15
  export const CONTEXT_PACK_MANIFEST = "context-pack.json";
15
16
  export const CONTEXT_PACK_MAX_FILE_BYTES = 1024 * 1024;
@@ -41,6 +42,7 @@ export const contextPackManifestSchema = z.object({
41
42
  }).strict().optional(),
42
43
  files: z.array(contextPackFileSchema).max(CONTEXT_PACK_MAX_ENTRIES),
43
44
  }).strict();
45
+ const validatedPackContent = new WeakMap();
44
46
  function sha256(...parts) {
45
47
  const hash = createHash("sha256");
46
48
  for (const part of parts)
@@ -57,7 +59,7 @@ function objectRoot(digest) {
57
59
  return join(packBaseDir(), "objects", digest);
58
60
  }
59
61
  function normalizePackPath(value) {
60
- if (!value || value.includes("\\") || isAbsolute(value) || value.split("/").some((part) => part === "" || part === "." || part === "..")) {
62
+ if (portableRelativePathIssue(value)) {
61
63
  throw new Error(`Unsafe context pack path: ${value}`);
62
64
  }
63
65
  return value;
@@ -116,18 +118,43 @@ async function walkFiles(root, directory = root) {
116
118
  }
117
119
  for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
118
120
  const absolute = join(directory, entry.name);
119
- const relativePath = relative(root, absolute).split(sep).join("/");
121
+ const rawPath = relative(root, absolute).split(sep).join("/");
122
+ const canonicalPath = rawPath.normalize("NFC");
120
123
  if (entry.isSymbolicLink())
121
- throw new Error(`Context packs may not contain symlinks: ${relativePath}`);
124
+ throw new Error(`Context packs may not contain symlinks: ${canonicalPath}`);
122
125
  if (entry.isDirectory())
123
126
  result.push(...await walkFiles(root, absolute));
124
127
  else if (entry.isFile())
125
- result.push(relativePath);
128
+ result.push({ raw_path: rawPath, canonical_path: canonicalPath });
126
129
  else
127
- throw new Error(`Context packs may contain regular files only: ${relativePath}`);
130
+ throw new Error(`Context packs may contain regular files only: ${canonicalPath}`);
128
131
  }
129
132
  return result;
130
133
  }
134
+ async function physicalPackInventory(root) {
135
+ const files = await walkFiles(root);
136
+ if (files.length > CONTEXT_PACK_MAX_ENTRIES + 1)
137
+ throw new Error("Context pack exceeds the entry limit.");
138
+ const byCanonical = new Map();
139
+ const portable = new Map();
140
+ for (const physical of files) {
141
+ if (portableRelativePathIssue(physical.canonical_path))
142
+ throw new Error(`Unsafe context pack path: ${physical.canonical_path}`);
143
+ const portableKey = portablePathCollisionKey(physical.canonical_path);
144
+ const previous = portable.get(portableKey);
145
+ if (previous)
146
+ throw new Error(`Duplicate portable context pack path: ${previous}, ${physical.raw_path}`);
147
+ portable.set(portableKey, physical.raw_path);
148
+ byCanonical.set(physical.canonical_path, physical);
149
+ }
150
+ return { files, by_canonical: byCanonical };
151
+ }
152
+ function physicalPath(root, file) {
153
+ const absolute = resolve(root, ...file.raw_path.split("/"));
154
+ if (!contained(root, absolute))
155
+ throw new Error(`Unsafe context pack path: ${file.canonical_path}`);
156
+ return absolute;
157
+ }
131
158
  export async function validateContextPack(directory) {
132
159
  const requestedRoot = resolve(directory);
133
160
  const requestedInfo = lstatSync(requestedRoot);
@@ -140,28 +167,32 @@ export async function validateContextPack(directory) {
140
167
  if (!rootInfo.isDirectory() || lstatSync(root).isSymbolicLink())
141
168
  throw new Error("Context pack source must be a real directory.");
142
169
  const manifestPath = join(root, CONTEXT_PACK_MANIFEST);
143
- const manifestInfo = lstatSync(manifestPath);
144
- if (!manifestInfo.isFile() || manifestInfo.isSymbolicLink() || manifestInfo.size > CONTEXT_PACK_MAX_FILE_BYTES)
145
- throw new Error("Context pack manifest is unsafe.");
146
- const manifestBytes = await readFile(manifestPath);
170
+ const manifestBytes = await readBoundedPackFile(manifestPath, CONTEXT_PACK_MANIFEST);
147
171
  assertUtf8(manifestBytes, CONTEXT_PACK_MANIFEST);
148
172
  const manifest = contextPackManifestSchema.parse(JSON.parse(manifestBytes.toString("utf8")));
173
+ const inventory = await physicalPackInventory(root);
174
+ const physicalByCanonical = inventory.by_canonical;
149
175
  const declared = new Set();
176
+ const portableDeclared = new Set();
150
177
  let totalBytes = manifestBytes.byteLength;
151
178
  const hash = createHash("sha256").update(manifestBytes).update("\0");
152
179
  const content = new Map();
153
180
  for (const file of manifest.files) {
154
181
  const normalized = normalizePackPath(file.path);
155
- if (normalized === CONTEXT_PACK_MANIFEST || declared.has(normalized))
182
+ const portableKey = portablePathCollisionKey(normalized);
183
+ if (portableKey === portablePathCollisionKey(CONTEXT_PACK_MANIFEST)
184
+ || declared.has(normalized) || portableDeclared.has(portableKey)) {
156
185
  throw new Error(`Duplicate or reserved context pack path: ${normalized}`);
186
+ }
157
187
  declared.add(normalized);
158
- const absolute = resolve(root, normalized);
188
+ portableDeclared.add(portableKey);
189
+ const physical = physicalByCanonical.get(normalized);
190
+ if (!physical)
191
+ throw new Error(`Context pack file declaration mismatch; missing: ${normalized}`);
192
+ const absolute = physicalPath(root, physical);
159
193
  if (!contained(root, absolute))
160
194
  throw new Error(`Unsafe context pack path: ${normalized}`);
161
- const info = lstatSync(absolute);
162
- if (!info.isFile() || info.isSymbolicLink() || info.size > CONTEXT_PACK_MAX_FILE_BYTES)
163
- throw new Error(`Context pack file is unsafe or too large: ${normalized}`);
164
- const bytes = await readFile(absolute);
195
+ const bytes = await readBoundedPackFile(absolute, normalized);
165
196
  assertUtf8(bytes, normalized);
166
197
  if (sha256(bytes) !== file.sha256)
167
198
  throw new Error(`Context pack digest mismatch: ${normalized}`);
@@ -170,16 +201,18 @@ export async function validateContextPack(directory) {
170
201
  throw new Error("Context pack exceeds the total size limit.");
171
202
  content.set(normalized, bytes);
172
203
  }
173
- const actual = (await walkFiles(root)).filter((path) => path !== CONTEXT_PACK_MANIFEST);
204
+ const actual = inventory.files.map(({ canonical_path }) => canonical_path).filter((path) => path !== CONTEXT_PACK_MANIFEST);
174
205
  const undeclared = actual.filter((path) => !declared.has(path));
175
- const missing = [...declared].filter((path) => !actual.includes(path));
206
+ const missing = [...declared].filter((path) => !physicalByCanonical.has(path));
176
207
  if (undeclared.length || missing.length)
177
208
  throw new Error(`Context pack file declaration mismatch${undeclared.length ? `; undeclared: ${undeclared.join(", ")}` : ""}${missing.length ? `; missing: ${missing.join(", ")}` : ""}`);
178
209
  for (const path of [...declared].sort((a, b) => a.localeCompare(b, "en"))) {
179
210
  const bytes = content.get(path);
180
211
  hash.update(path, "utf8").update("\0").update(bytes).update("\0");
181
212
  }
182
- return { root, manifest, manifest_bytes: manifestBytes, digest: hash.digest("hex"), total_bytes: totalBytes };
213
+ const validated = { root, manifest, manifest_bytes: manifestBytes, digest: hash.digest("hex"), total_bytes: totalBytes };
214
+ validatedPackContent.set(validated, content);
215
+ return validated;
183
216
  }
184
217
  async function atomicJson(path, value) {
185
218
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
@@ -210,6 +243,41 @@ async function atomicJson(path, value) {
210
243
  await rm(temporary, { force: true });
211
244
  }
212
245
  }
246
+ async function readBoundedPackFile(path, label) {
247
+ const unsafeMessage = label === CONTEXT_PACK_MANIFEST
248
+ ? "Context pack manifest is unsafe or too large."
249
+ : `Context pack file is unsafe or too large: ${label}`;
250
+ let handle;
251
+ try {
252
+ const nonBlocking = process.platform === "win32" ? 0 : (constants.O_NONBLOCK ?? 0);
253
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | nonBlocking);
254
+ const opened = await handle.stat();
255
+ if (!opened.isFile() || opened.size > CONTEXT_PACK_MAX_FILE_BYTES)
256
+ throw new Error(unsafeMessage);
257
+ const buffer = Buffer.allocUnsafe(opened.size + 1);
258
+ let offset = 0;
259
+ while (offset < buffer.byteLength) {
260
+ const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, null);
261
+ if (bytesRead === 0)
262
+ break;
263
+ offset += bytesRead;
264
+ }
265
+ const bytes = buffer.subarray(0, offset);
266
+ const afterRead = await handle.stat();
267
+ const named = lstatSync(path);
268
+ if (named.isSymbolicLink() || !named.isFile()
269
+ || opened.dev !== afterRead.dev || opened.ino !== afterRead.ino || opened.size !== afterRead.size
270
+ || opened.mtimeMs !== afterRead.mtimeMs || opened.ctimeMs !== afterRead.ctimeMs
271
+ || opened.dev !== named.dev || opened.ino !== named.ino
272
+ || bytes.byteLength !== afterRead.size || bytes.byteLength > CONTEXT_PACK_MAX_FILE_BYTES) {
273
+ throw new Error(`Context pack file changed while it was being read: ${label}`);
274
+ }
275
+ return bytes;
276
+ }
277
+ finally {
278
+ await handle?.close();
279
+ }
280
+ }
213
281
  async function makeImmutable(root) {
214
282
  const directories = [];
215
283
  const walk = async (directory) => {
@@ -344,7 +412,18 @@ async function publishContextPack(pack, source) {
344
412
  const temporary = join(objects, `.${pack.digest}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
345
413
  let published = true;
346
414
  try {
347
- await cp(pack.root, temporary, { recursive: true, errorOnExist: true, force: false });
415
+ await mkdir(temporary, { mode: 0o700 });
416
+ await writeFile(join(temporary, CONTEXT_PACK_MANIFEST), pack.manifest_bytes, { mode: 0o600, flag: "wx" });
417
+ const content = validatedPackContent.get(pack);
418
+ if (!content)
419
+ throw new Error("Context pack validation bytes are unavailable.");
420
+ for (const [path, bytes] of content) {
421
+ const target = resolve(temporary, ...path.split("/"));
422
+ if (!contained(temporary, target))
423
+ throw new Error(`Unsafe context pack publication path: ${path}`);
424
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
425
+ await writeFile(target, bytes, { mode: 0o600, flag: "wx" });
426
+ }
348
427
  const copied = await validateContextPack(temporary);
349
428
  if (copied.digest !== pack.digest)
350
429
  throw new Error("Context pack changed during installation.");
@@ -388,12 +467,11 @@ async function prepareOkfContextPack(sourceRoot, options) {
388
467
  const okf = await validateOkfBundle(sourceRoot);
389
468
  await mkdir(staging, { recursive: true, mode: 0o700 });
390
469
  for (const file of okf.files) {
391
- const source = resolve(okf.root, ...file.path.split("/"));
392
470
  const target = resolve(staging, ...file.path.split("/"));
393
- if (!contained(okf.root, source) || !contained(staging, target))
471
+ if (!contained(staging, target))
394
472
  throw new Error(`Unsafe OKF path: ${file.path}`);
395
473
  await mkdir(dirname(target), { recursive: true, mode: 0o700 });
396
- await cp(source, target, { errorOnExist: true, force: false });
474
+ await writeFile(target, validatedOkfFileContent(okf, file.path), { mode: 0o600, flag: "wx" });
397
475
  }
398
476
  const title = options.title?.trim() || okf.files.find(({ path }) => path === "index.md")?.title || options.id;
399
477
  const description = options.description?.trim() || `Imported Open Knowledge Format ${okf.version} bundle.`;
@@ -1216,30 +1294,73 @@ export async function initializeContextPack(directory, options) {
1216
1294
  if (existsSync(manifestPath))
1217
1295
  throw new Error("Context pack manifest already exists.");
1218
1296
  const id = options.id ?? basename(root).toLocaleLowerCase("en").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1219
- const files = (await walkFiles(root)).filter((path) => path !== CONTEXT_PACK_MANIFEST);
1297
+ const inventory = await physicalPackInventory(root);
1298
+ const files = inventory.files.filter(({ canonical_path }) => canonical_path !== CONTEXT_PACK_MANIFEST);
1299
+ if (files.length > CONTEXT_PACK_MAX_ENTRIES)
1300
+ throw new Error("Context pack exceeds the entry limit.");
1220
1301
  const entries = [];
1221
- for (const path of files) {
1222
- const bytes = await readFile(join(root, path));
1302
+ let totalBytes = 0;
1303
+ for (const file of files) {
1304
+ const path = file.canonical_path;
1305
+ if (portablePathCollisionKey(path) === portablePathCollisionKey(CONTEXT_PACK_MANIFEST)) {
1306
+ throw new Error(`Duplicate or reserved context pack path: ${path}`);
1307
+ }
1308
+ const source = physicalPath(root, file);
1309
+ const bytes = await readBoundedPackFile(source, path);
1223
1310
  assertUtf8(bytes, path);
1311
+ totalBytes += bytes.byteLength;
1312
+ if (totalBytes > CONTEXT_PACK_MAX_TOTAL_BYTES)
1313
+ throw new Error("Context pack exceeds the total size limit.");
1224
1314
  entries.push({ path, kind: "document", title: basename(path).replace(/\.[^.]+$/, ""), description: "", keywords: [], sha256: sha256(bytes) });
1225
1315
  }
1226
1316
  const manifest = contextPackManifestSchema.parse({ schema_version: 1, id, version: options.version ?? "0.1.0", title: options.title ?? id, description: options.description ?? `Context pack ${id}`, license: options.license ?? "UNLICENSED", files: entries });
1317
+ const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8");
1318
+ if (manifestBytes.byteLength > CONTEXT_PACK_MAX_FILE_BYTES || totalBytes + manifestBytes.byteLength > CONTEXT_PACK_MAX_TOTAL_BYTES) {
1319
+ throw new Error("Context pack manifest or total pack exceeds the size limit.");
1320
+ }
1227
1321
  await atomicJson(manifestPath, manifest);
1228
1322
  return manifest;
1229
1323
  }
1230
1324
  export async function lockContextPack(directory) {
1231
- const path = join(resolve(directory), CONTEXT_PACK_MANIFEST);
1232
- const value = contextPackManifestSchema.parse(JSON.parse(await readFile(path, "utf8")));
1325
+ const root = resolve(directory);
1326
+ const path = join(root, CONTEXT_PACK_MANIFEST);
1327
+ const manifestBytes = await readBoundedPackFile(path, CONTEXT_PACK_MANIFEST);
1328
+ const value = contextPackManifestSchema.parse(JSON.parse(manifestBytes.toString("utf8")));
1329
+ const inventory = await physicalPackInventory(root);
1330
+ const actual = new Set(inventory.files.map(({ canonical_path }) => canonical_path).filter((candidate) => candidate !== CONTEXT_PACK_MANIFEST));
1331
+ const declared = new Set();
1332
+ const portableDeclared = new Set();
1233
1333
  const files = [];
1334
+ let contentBytes = 0;
1234
1335
  for (const entry of value.files) {
1235
1336
  const normalized = normalizePackPath(entry.path);
1236
- const bytes = await readFile(join(resolve(directory), normalized));
1337
+ const portableKey = portablePathCollisionKey(normalized);
1338
+ if (portableKey === portablePathCollisionKey(CONTEXT_PACK_MANIFEST) || declared.has(normalized) || portableDeclared.has(portableKey)) {
1339
+ throw new Error(`Duplicate or reserved context pack path: ${normalized}`);
1340
+ }
1341
+ declared.add(normalized);
1342
+ portableDeclared.add(portableKey);
1343
+ const physical = inventory.by_canonical.get(normalized);
1344
+ if (!physical)
1345
+ throw new Error(`Context pack file declaration mismatch; missing: ${normalized}`);
1346
+ const source = physicalPath(root, physical);
1347
+ const bytes = await readBoundedPackFile(source, normalized);
1237
1348
  assertUtf8(bytes, normalized);
1238
1349
  if (bytes.byteLength > CONTEXT_PACK_MAX_FILE_BYTES)
1239
1350
  throw new Error(`Context pack file is too large: ${normalized}`);
1351
+ contentBytes += bytes.byteLength;
1352
+ if (contentBytes > CONTEXT_PACK_MAX_TOTAL_BYTES)
1353
+ throw new Error("Context pack exceeds the total size limit.");
1240
1354
  files.push({ ...entry, sha256: sha256(bytes) });
1241
1355
  }
1356
+ const undeclared = [...actual].filter((candidate) => !declared.has(candidate));
1357
+ if (undeclared.length)
1358
+ throw new Error(`Context pack file declaration mismatch; undeclared: ${undeclared.join(", ")}`);
1242
1359
  const locked = { ...value, files };
1360
+ const lockedBytes = Buffer.from(`${JSON.stringify(locked, null, 2)}\n`, "utf8");
1361
+ if (lockedBytes.byteLength > CONTEXT_PACK_MAX_FILE_BYTES || contentBytes + lockedBytes.byteLength > CONTEXT_PACK_MAX_TOTAL_BYTES) {
1362
+ throw new Error("Context pack manifest or total pack exceeds the size limit.");
1363
+ }
1243
1364
  await atomicJson(path, locked);
1244
1365
  await validateContextPack(directory);
1245
1366
  return locked;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { isAbsolute } from "node:path";
3
2
  import { z } from "zod";
4
3
  import { CAPABILITY_IDS, capabilityIdSchema } from "./capability-schema.js";
4
+ import { portablePathCollisionKey, portableRelativePathIssue } from "./path-security.js";
5
5
  export const EVAL_SCHEMA_VERSION = 1;
6
6
  export const EVAL_ADAPTER_RESULT_STATUSES = ["completed", "adapter_error"];
7
7
  export const EVAL_OBSERVATION_TERMINAL_STATES = [
@@ -30,13 +30,13 @@ const boundedTextSchema = z.string().max(MAX_TEXT_BYTES);
30
30
  const nonnegativeIntegerSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
31
31
  const positiveIntegerSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER);
32
32
  export const evalRelativePathSchema = z.string().min(1).max(1024).superRefine((value, context) => {
33
- if (isAbsolute(value) || value.startsWith("/") || value.endsWith("/") || value.includes("\\") || /[\u0000-\u001f\u007f]/.test(value)) {
33
+ if (portableRelativePathIssue(value, { allowCurrentDirectory: true })) {
34
34
  context.addIssue({ code: "custom", message: "Evaluation paths must be relative canonical paths." });
35
- return;
36
35
  }
37
- const segments = value.split("/");
38
- if (segments.some((segment) => segment === "" || segment === "..")) {
39
- context.addIssue({ code: "custom", message: "Evaluation path contains an invalid segment." });
36
+ });
37
+ export const evalFileRelativePathSchema = z.string().min(1).max(1024).superRefine((value, context) => {
38
+ if (portableRelativePathIssue(value)) {
39
+ context.addIssue({ code: "custom", message: "Evaluation file paths must be relative canonical file paths." });
40
40
  }
41
41
  });
42
42
  export const evalCommandSchema = z.strictObject({
@@ -53,7 +53,7 @@ const gitSourceSchema = z.strictObject({
53
53
  revision: commitSchema,
54
54
  });
55
55
  const bundledFileSchema = z.strictObject({
56
- path: evalRelativePathSchema,
56
+ path: evalFileRelativePathSchema,
57
57
  content: z.string().max(256 * 1024),
58
58
  });
59
59
  const bundledFakeSourceSchema = z.strictObject({
@@ -62,10 +62,11 @@ const bundledFakeSourceSchema = z.strictObject({
62
62
  files: z.array(bundledFileSchema).min(1).max(1024).superRefine((files, context) => {
63
63
  const seen = new Set();
64
64
  for (const [index, file] of files.entries()) {
65
- if (seen.has(file.path)) {
66
- context.addIssue({ code: "custom", path: [index, "path"], message: "Bundled file paths must be unique." });
65
+ const key = portablePathCollisionKey(file.path);
66
+ if (seen.has(key)) {
67
+ context.addIssue({ code: "custom", path: [index, "path"], message: "Bundled file paths must be portably unique." });
67
68
  }
68
- seen.add(file.path);
69
+ seen.add(key);
69
70
  }
70
71
  }),
71
72
  });
@@ -116,7 +117,7 @@ export const evalAdapterRequestSchema = z.strictObject({
116
117
  limits: evalLimitsSchema,
117
118
  seed: z.string().min(1).max(256),
118
119
  expected_capability_digest: digestSchema,
119
- output_path: evalRelativePathSchema,
120
+ output_path: evalFileRelativePathSchema,
120
121
  });
121
122
  const usageSchema = z.strictObject({
122
123
  input_tokens: nonnegativeIntegerSchema.optional(),
@@ -168,7 +169,7 @@ const verifierObservationSchema = z.strictObject({
168
169
  reason: valueFreeCodeSchema.nullable(),
169
170
  });
170
171
  const noteSnapshotEntrySchema = z.strictObject({
171
- path: evalRelativePathSchema,
172
+ path: evalFileRelativePathSchema,
172
173
  digest: digestSchema,
173
174
  bytes: nonnegativeIntegerSchema,
174
175
  });
@@ -0,0 +1,104 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { privatePathIsSafe } from "./platform-security.js";
4
+ export function bearerTokenIsSafe(token) {
5
+ return /^[A-Za-z0-9._~+/-]+=*$/.test(token);
6
+ }
7
+ export function normalizeHttpHost(raw) {
8
+ return raw?.trim() || "127.0.0.1";
9
+ }
10
+ export function parseHttpPort(raw) {
11
+ if (raw === undefined || raw === "")
12
+ return { enabled: false };
13
+ if (!/^[1-9][0-9]{0,4}$/.test(raw))
14
+ return { enabled: false, error: "invalid" };
15
+ const port = Number(raw);
16
+ return port <= 65535 ? { enabled: true, port } : { enabled: false, error: "invalid" };
17
+ }
18
+ export function formatHostAuthority(host, port) {
19
+ const normalized = host.trim();
20
+ return `${normalized.includes(":") && !normalized.startsWith("[") ? `[${normalized}]` : normalized}:${port}`;
21
+ }
22
+ export function normalizeHostAuthority(raw, defaultPort) {
23
+ if (!raw || raw.trim() !== raw || /[/\\@]/.test(raw))
24
+ return undefined;
25
+ const explicit = /^\[([^\]]+)\]:([0-9]+)$/.exec(raw) ?? /^([^:]+):([0-9]+)$/.exec(raw);
26
+ const implicit = defaultPort === undefined ? undefined : /^\[([^\]]+)\]$/.exec(raw) ?? /^([^:]+)$/.exec(raw);
27
+ const match = explicit ?? implicit;
28
+ if (!match)
29
+ return undefined;
30
+ const port = parseHttpPort(explicit ? match[2] : String(defaultPort));
31
+ if (!port.enabled || "error" in port)
32
+ return undefined;
33
+ try {
34
+ const bracketed = raw.startsWith("[");
35
+ const parsed = new URL(`http://${bracketed ? `[${match[1]}]` : match[1]}/`);
36
+ if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash)
37
+ return undefined;
38
+ return `${parsed.hostname}:${port.port}`;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ function readBoundedDescriptor(descriptor, approvedSize) {
45
+ const buffer = Buffer.allocUnsafe(approvedSize + 1);
46
+ let offset = 0;
47
+ while (offset < buffer.byteLength) {
48
+ const bytesRead = readSync(descriptor, buffer, offset, buffer.byteLength - offset, null);
49
+ if (bytesRead === 0)
50
+ break;
51
+ offset += bytesRead;
52
+ }
53
+ return buffer.subarray(0, offset);
54
+ }
55
+ export function resolveHttpToken(env, options = {}) {
56
+ const direct = env.CAIRN_MEMORY_HTTP_TOKEN?.trim();
57
+ const rawFile = env.CAIRN_MEMORY_HTTP_TOKEN_FILE?.trim();
58
+ if (direct && rawFile)
59
+ return { ok: false, reason: "multiple", file: rawFile };
60
+ if (direct)
61
+ return bearerTokenIsSafe(direct)
62
+ ? { ok: true, source: "environment", token: direct }
63
+ : { ok: false, reason: "token-unsafe" };
64
+ if (!rawFile)
65
+ return { ok: false, reason: "missing" };
66
+ const file = resolve(options.baseDirectory ?? process.cwd(), rawFile);
67
+ let descriptor;
68
+ try {
69
+ const nonBlocking = process.platform === "win32" ? 0 : (constants.O_NONBLOCK ?? 0);
70
+ descriptor = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | nonBlocking);
71
+ const opened = fstatSync(descriptor);
72
+ if (!opened.isFile() || opened.size > 64 * 1024)
73
+ return { ok: false, reason: "file-unsafe", file };
74
+ const tokenBytes = readBoundedDescriptor(descriptor, opened.size);
75
+ const afterRead = fstatSync(descriptor);
76
+ const named = lstatSync(file);
77
+ if (named.isSymbolicLink() || !named.isFile()
78
+ || opened.dev !== afterRead.dev || opened.ino !== afterRead.ino || opened.size !== afterRead.size
79
+ || opened.mtimeMs !== afterRead.mtimeMs || opened.ctimeMs !== afterRead.ctimeMs
80
+ || opened.dev !== named.dev || opened.ino !== named.ino
81
+ || tokenBytes.byteLength !== afterRead.size || tokenBytes.byteLength > 64 * 1024) {
82
+ return { ok: false, reason: "file-changed", file };
83
+ }
84
+ if (!privatePathIsSafe(file))
85
+ return { ok: false, reason: "file-unsafe", file };
86
+ const namedAfterAcl = lstatSync(file);
87
+ if (namedAfterAcl.dev !== opened.dev || namedAfterAcl.ino !== opened.ino || namedAfterAcl.isSymbolicLink()) {
88
+ return { ok: false, reason: "file-changed", file };
89
+ }
90
+ const token = tokenBytes.toString("utf8").trim();
91
+ return bearerTokenIsSafe(token)
92
+ ? { ok: true, source: "file", token, file }
93
+ : { ok: false, reason: "token-unsafe", file };
94
+ }
95
+ catch (error) {
96
+ if (error.code === "ENOENT")
97
+ return { ok: false, reason: "file-missing", file };
98
+ return { ok: false, reason: "file-unreadable", file };
99
+ }
100
+ finally {
101
+ if (descriptor !== undefined)
102
+ closeSync(descriptor);
103
+ }
104
+ }