@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
@@ -15,6 +15,7 @@ import { artifactIdSchema, artifactKindSchema, artifactMediaTypeSchema, artifact
15
15
  import { isCapabilityContractEnabled, resolveCapabilityStatus, } from "./capability-config.js";
16
16
  import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText, } from "./embeddings.js";
17
17
  import { queryDomainKnowledge, resolveDomainRetrievalProvider } from "./domain-retrieval-provider.js";
18
+ import { formatHostAuthority, normalizeHostAuthority, normalizeHttpHost, parseHttpPort, resolveHttpToken } from "./http-security.js";
18
19
  import { computeRepoState, exploreCacheKey, normalizeExploreQuery, readExploreCache, writeExploreCache, } from "./explore-cache.js";
19
20
  import { canonicalTagsSchema, isTypedMemoryNodesEnabled, memoryImportEnvelopeSchema, noteNodeSchema, nodeTypeSchema, } from "./node-schema.js";
20
21
  import { attachNodeMetadata, commitMemoryImport, createTypedNode, deleteTypedNode, getTypedNode, listTypedHistory, planMemoryImport, supersedeTypedNode, } from "./node-store.js";
@@ -1753,24 +1754,37 @@ if (cliCommand === "explore") {
1753
1754
  process.exit(1);
1754
1755
  }
1755
1756
  }
1756
- const httpPort = parseInt(process.env.MCP_HTTP_PORT ?? "", 10);
1757
- if (httpPort > 0) {
1758
- const httpHost = process.env.MCP_HTTP_HOST ?? "127.0.0.1";
1757
+ const httpPortResolution = parseHttpPort(process.env.MCP_HTTP_PORT);
1758
+ const MAX_HTTP_REQUEST_BYTES = 8 * 1024 * 1024;
1759
+ if ("error" in httpPortResolution) {
1760
+ process.stderr.write("cairn-memory: MCP_HTTP_PORT must be a canonical decimal integer from 1 through 65535.\n");
1761
+ process.exit(1);
1762
+ }
1763
+ if (httpPortResolution.enabled) {
1764
+ const httpPort = httpPortResolution.port;
1765
+ const httpHost = normalizeHttpHost(process.env.MCP_HTTP_HOST);
1759
1766
  // HTTP mode exposes every memory tool over the network, so it is guarded:
1760
1767
  // a bearer token is mandatory (fail closed), CORS is opt-in per origin, and
1761
1768
  // the Host header is validated to block DNS-rebinding. See docs/operating.md.
1762
- const httpToken = process.env.CAIRN_MEMORY_HTTP_TOKEN?.trim();
1763
- if (!httpToken) {
1764
- process.stderr.write("cairn-memory: HTTP mode requires CAIRN_MEMORY_HTTP_TOKEN — refusing to start an unauthenticated network server.\n");
1769
+ const tokenResolution = resolveHttpToken(process.env, { baseDirectory: process.cwd() });
1770
+ if (!tokenResolution.ok || !tokenResolution.token) {
1771
+ process.stderr.write(`cairn-memory: HTTP token resolution failed (${tokenResolution.ok ? "empty" : tokenResolution.reason}) — refusing to start an unauthenticated network server.\n`);
1765
1772
  process.exit(1);
1766
1773
  }
1774
+ const httpToken = tokenResolution.token;
1767
1775
  const allowedOrigins = (process.env.CAIRN_MEMORY_HTTP_ALLOWED_ORIGINS ?? "")
1768
1776
  .split(",").map((value) => value.trim()).filter(Boolean);
1769
1777
  const configuredHosts = (process.env.CAIRN_MEMORY_HTTP_ALLOWED_HOSTS ?? "")
1770
1778
  .split(",").map((value) => value.trim()).filter(Boolean);
1771
- const allowedHosts = new Set(configuredHosts.length > 0
1779
+ const hostCandidates = configuredHosts.length > 0
1772
1780
  ? configuredHosts
1773
- : [`${httpHost}:${httpPort}`, `localhost:${httpPort}`, `127.0.0.1:${httpPort}`]);
1781
+ : [formatHostAuthority(httpHost, httpPort), formatHostAuthority("localhost", httpPort), formatHostAuthority("127.0.0.1", httpPort)];
1782
+ const normalizedHosts = hostCandidates.map(normalizeHostAuthority);
1783
+ if (normalizedHosts.some((value) => !value)) {
1784
+ process.stderr.write("cairn-memory: CAIRN_MEMORY_HTTP_ALLOWED_HOSTS contains an invalid host:port authority.\n");
1785
+ process.exit(1);
1786
+ }
1787
+ const allowedHosts = new Set(normalizedHosts);
1774
1788
  const tokenMatches = (header) => {
1775
1789
  const prefix = "Bearer ";
1776
1790
  if (!header || !header.startsWith(prefix)) {
@@ -1805,7 +1819,12 @@ if (httpPort > 0) {
1805
1819
  await session.connect(transport);
1806
1820
  return transport.handleRequest(request);
1807
1821
  };
1808
- const httpServer = createServer(async (req, res) => {
1822
+ const httpServer = createServer({
1823
+ headersTimeout: 10_000,
1824
+ requestTimeout: 30_000,
1825
+ keepAliveTimeout: 5_000,
1826
+ maxHeaderSize: 32 * 1024,
1827
+ }, async (req, res) => {
1809
1828
  const allowOrigin = originAllowed(req.headers.origin);
1810
1829
  if (allowOrigin) {
1811
1830
  res.setHeader("Access-Control-Allow-Origin", allowOrigin);
@@ -1813,12 +1832,23 @@ if (httpPort > 0) {
1813
1832
  res.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS");
1814
1833
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id, Accept, Authorization, X-Cairn-Project, X-Cairn-Scopes, X-Cairn-AnythingLLM-Workspaces");
1815
1834
  }
1835
+ const declaredLength = Number(req.headers["content-length"] ?? "0");
1836
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_HTTP_REQUEST_BYTES) {
1837
+ res.writeHead(413, { "X-Cairn-Rejection": "request-body-limit" }).end("request body too large");
1838
+ return;
1839
+ }
1840
+ const bodyForbidden = req.method === "GET" || req.method === "DELETE" || req.method === "OPTIONS";
1841
+ if (bodyForbidden && (declaredLength > 0 || req.headers["transfer-encoding"])) {
1842
+ res.writeHead(400).end("request body not allowed for this method");
1843
+ return;
1844
+ }
1816
1845
  if (req.method === "OPTIONS") {
1817
1846
  res.writeHead(allowOrigin ? 204 : 403).end();
1818
1847
  return;
1819
1848
  }
1820
1849
  // DNS-rebinding protection: only serve requests whose Host we expect.
1821
- if (!req.headers.host || !allowedHosts.has(req.headers.host)) {
1850
+ const requestAuthority = normalizeHostAuthority(req.headers.host, httpPort);
1851
+ if (!requestAuthority || !allowedHosts.has(requestAuthority)) {
1822
1852
  res.writeHead(403).end("host not allowed");
1823
1853
  return;
1824
1854
  }
@@ -1832,11 +1862,19 @@ if (httpPort > 0) {
1832
1862
  let body = null;
1833
1863
  if (req.method !== "GET" && req.method !== "DELETE") {
1834
1864
  const chunks = [];
1835
- for await (const chunk of req)
1865
+ let receivedBytes = 0;
1866
+ for await (const rawChunk of req) {
1867
+ const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk);
1868
+ receivedBytes += chunk.byteLength;
1869
+ if (receivedBytes > MAX_HTTP_REQUEST_BYTES) {
1870
+ res.writeHead(413, { "X-Cairn-Rejection": "request-body-limit" }).end("request body too large");
1871
+ return;
1872
+ }
1836
1873
  chunks.push(chunk);
1874
+ }
1837
1875
  body = Buffer.concat(chunks);
1838
1876
  }
1839
- const request = new Request(`http://${req.headers.host}${req.url}`, {
1877
+ const request = new Request(`http://${requestAuthority}${req.url}`, {
1840
1878
  method: req.method,
1841
1879
  headers,
1842
1880
  body,
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:f
4
4
  import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
5
5
  import { parseDocument, stringify } from "yaml";
6
6
  import { z } from "zod";
7
+ import { portablePathCollisionKey, portableRelativePathIssue } from "./path-security.js";
7
8
  import { hardenPrivatePath } from "./platform-security.js";
8
9
  import { readSharedNoteForExport } from "./note-store.js";
9
10
  import { redactLocalValue } from "./trajectory-redaction.js";
@@ -41,6 +42,8 @@ const frontmatterSchema = z.object({
41
42
  stale_after: z.string().regex(DATE).optional(),
42
43
  timestamp: z.string().min(1).max(128).optional(),
43
44
  }).passthrough();
45
+ const okfIndexContent = new WeakMap();
46
+ const validatedOkfContent = new WeakMap();
44
47
  function sha256(value) {
45
48
  return createHash("sha256").update(value).digest("hex");
46
49
  }
@@ -55,8 +58,7 @@ function canonical(value) {
55
58
  return JSON.stringify(value);
56
59
  }
57
60
  function normalizedPath(value) {
58
- if (!value || value.includes("\\") || isAbsolute(value)
59
- || value.split("/").some((part) => part === "" || part === "." || part === "..")) {
61
+ if (portableRelativePathIssue(value)) {
60
62
  throw new Error(`Unsafe OKF path: ${value}`);
61
63
  }
62
64
  return value;
@@ -90,13 +92,14 @@ async function walk(root, directory = root) {
90
92
  continue;
91
93
  }
92
94
  const absolute = join(directory, entry.name);
93
- const path = relative(root, absolute).split(sep).join("/");
95
+ const rawPath = relative(root, absolute).split(sep).join("/");
96
+ const path = rawPath.normalize("NFC");
94
97
  if (entry.isSymbolicLink())
95
98
  throw new Error(`OKF bundles may not contain symlinks: ${path}`);
96
99
  if (entry.isDirectory())
97
100
  files.push(...await walk(root, absolute));
98
101
  else if (entry.isFile())
99
- files.push(normalizedPath(path));
102
+ files.push({ path: normalizedPath(path), raw_path: rawPath });
100
103
  else
101
104
  throw new Error(`OKF bundles may contain regular files only: ${path}`);
102
105
  if (files.length > MAX_ENTRIES)
@@ -231,20 +234,33 @@ export async function indexOkfBundle(directory, selectedPaths, declaredVersion)
231
234
  if (!requestedInfo.isDirectory() || requestedInfo.isSymbolicLink())
232
235
  throw new Error("OKF source must be a real directory.");
233
236
  const root = requested;
234
- const paths = selectedPaths ? selectedPaths.map(normalizedPath).sort((a, b) => a.localeCompare(b, "en")) : await walk(root);
237
+ const entries = selectedPaths
238
+ ? selectedPaths.map(normalizedPath).sort((a, b) => a.localeCompare(b, "en")).map((path) => ({ path, raw_path: path }))
239
+ : await walk(root);
240
+ const portablePaths = new Set();
241
+ for (const entry of entries) {
242
+ const key = portablePathCollisionKey(entry.path);
243
+ if (portablePaths.has(key))
244
+ throw new Error(`Duplicate portable OKF path: ${entry.path}`);
245
+ portablePaths.add(key);
246
+ }
247
+ const paths = entries.map(({ path }) => path);
235
248
  const existing = new Set(paths);
236
249
  const files = [];
250
+ const content = new Map();
237
251
  const diagnostics = [];
238
252
  let version = declaredVersion ?? "";
239
253
  let sawV02 = false;
240
- for (const path of paths) {
241
- const absolute = resolve(root, ...path.split("/"));
254
+ for (const entry of entries) {
255
+ const path = entry.path;
256
+ const absolute = resolve(root, ...entry.raw_path.split("/"));
242
257
  if (!contained(root, absolute))
243
258
  throw new Error(`Unsafe OKF path: ${path}`);
244
259
  const info = lstatSync(absolute);
245
260
  if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_FILE_BYTES)
246
261
  throw new Error(`OKF file is unsafe or too large: ${path}`);
247
262
  const bytes = await readFile(absolute);
263
+ content.set(path, bytes);
248
264
  const text = utf8(bytes, path);
249
265
  const name = basename(path);
250
266
  const markdown = extname(path).toLowerCase() === ".md";
@@ -294,7 +310,9 @@ export async function indexOkfBundle(directory, selectedPaths, declaredVersion)
294
310
  version = sawV02 ? "0.2" : "0.1";
295
311
  if (!/^0\.(?:1|2)$/.test(version))
296
312
  diagnostics.unshift({ severity: "warning", code: "unsupported-version", path: "index.md", message: `OKF version ${version} is not explicitly supported; consumed on a best-effort basis.` });
297
- return { schema_version: 1, version, files, diagnostics };
313
+ const index = { schema_version: 1, version, files, diagnostics };
314
+ okfIndexContent.set(index, content);
315
+ return index;
298
316
  }
299
317
  export async function validateOkfBundle(directory) {
300
318
  const root = resolve(directory);
@@ -302,7 +320,15 @@ export async function validateOkfBundle(directory) {
302
320
  const totalBytes = index.files.reduce((sum, file) => sum + file.bytes, 0);
303
321
  if (totalBytes > MAX_TOTAL_BYTES)
304
322
  throw new Error("OKF bundle exceeds the total size limit.");
305
- return { ...index, root, concepts: index.files.flatMap((file) => file.concept ? [file.concept] : []), total_bytes: totalBytes };
323
+ const bundle = { ...index, root, concepts: index.files.flatMap((file) => file.concept ? [file.concept] : []), total_bytes: totalBytes };
324
+ validatedOkfContent.set(bundle, okfIndexContent.get(index) ?? new Map());
325
+ return bundle;
326
+ }
327
+ export function validatedOkfFileContent(bundle, path) {
328
+ const bytes = validatedOkfContent.get(bundle)?.get(path);
329
+ if (!bytes)
330
+ throw new Error(`Validated OKF bytes are unavailable: ${path}`);
331
+ return bytes;
306
332
  }
307
333
  function normalizeExportSource(value) {
308
334
  const path = normalizedPath(value.replaceAll("\\", "/"));
@@ -0,0 +1,49 @@
1
+ import { posix, win32 } from "node:path";
2
+ const WINDOWS_DEVICE = /^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/i;
3
+ function hasUnpairedSurrogate(value) {
4
+ for (let index = 0; index < value.length; index += 1) {
5
+ const code = value.charCodeAt(index);
6
+ if (code >= 0xd800 && code <= 0xdbff) {
7
+ const next = value.charCodeAt(index + 1);
8
+ if (!(next >= 0xdc00 && next <= 0xdfff))
9
+ return true;
10
+ index += 1;
11
+ }
12
+ else if (code >= 0xdc00 && code <= 0xdfff)
13
+ return true;
14
+ }
15
+ return false;
16
+ }
17
+ /**
18
+ * Returns a stable reason when a path cannot be interpreted identically on
19
+ * POSIX and Windows. These paths are persisted in portable Cairnkeep formats,
20
+ * so validation must not depend on the host performing the validation.
21
+ */
22
+ export function portableRelativePathIssue(value, options = {}) {
23
+ if (!value)
24
+ return "empty";
25
+ if (value !== value.normalize("NFC"))
26
+ return "non-canonical-unicode";
27
+ if (hasUnpairedSurrogate(value))
28
+ return "unpaired-surrogate";
29
+ if (/[<>:"\\|?*\u0000-\u001f\u007f]/.test(value))
30
+ return "separator-or-control";
31
+ if (posix.isAbsolute(value) || win32.isAbsolute(value))
32
+ return "absolute";
33
+ if (value === "." && options.allowCurrentDirectory)
34
+ return undefined;
35
+ const segments = value.split("/");
36
+ if (segments.some((segment) => segment === "" || segment === "." || segment === ".."))
37
+ return "invalid-segment";
38
+ if (segments.some((segment) => /[ .]$/.test(segment) || WINDOWS_DEVICE.test(segment))) {
39
+ return "non-portable-segment";
40
+ }
41
+ return undefined;
42
+ }
43
+ export function portablePathCollisionKey(value) {
44
+ // Windows' case-insensitive comparison is closer to invariant uppercase
45
+ // mapping than to lowercasing. In particular, final sigma (ς/σ), long-s
46
+ // (ſ/s), and dotless-i (ı/i) converge when uppercased but not when
47
+ // lowercased. NFC first also makes the key stable across macOS and Linux.
48
+ return value.normalize("NFC").toLocaleUpperCase("en-US");
49
+ }
@@ -2,13 +2,101 @@ import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
2
2
  import { rename } from "node:fs/promises";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { dirname } from "node:path";
5
6
  let cachedWindowsIdentity;
7
+ const WINDOWS_COMMAND_TIMEOUT_MS = 10_000;
8
+ const WINDOWS_COMMAND_MAX_BUFFER = 1024 * 1024;
9
+ const WINDOWS_SYSTEM_SIDS = new Set(["S-1-5-18", "S-1-5-32-544"]);
10
+ const WINDOWS_REPLACEMENT_RIGHTS = 2 | 4 | 16 | 64 | 256 | 65_536 | 262_144 | 524_288;
11
+ function trustedWindowsSid(sid, currentSid) {
12
+ return sid.toUpperCase() === currentSid.toUpperCase() || WINDOWS_SYSTEM_SIDS.has(sid.toUpperCase());
13
+ }
14
+ export function posixOwnerIsTrusted(uid, currentUid = process.getuid?.()) {
15
+ return currentUid !== undefined && (uid === currentUid || uid === 0);
16
+ }
17
+ export function posixPrivateMetadataIsSafe(info) {
18
+ return posixOwnerIsTrusted(info.uid) && (info.mode & 0o077) === 0;
19
+ }
20
+ export function windowsPrivateAclIsSafe(snapshot, currentSid) {
21
+ if (!snapshot.protected || !trustedWindowsSid(snapshot.owner_sid, currentSid))
22
+ return false;
23
+ const allows = snapshot.access.filter(({ type }) => type.toLowerCase() === "allow");
24
+ return allows.some(({ sid }) => sid.toUpperCase() === currentSid.toUpperCase())
25
+ && allows.every(({ sid }) => trustedWindowsSid(sid, currentSid));
26
+ }
27
+ export function windowsReplacementAclIsSafe(snapshot, currentSid) {
28
+ if (!trustedWindowsSid(snapshot.owner_sid, currentSid))
29
+ return false;
30
+ return snapshot.access.every(({ sid, type, rights }) => type.toLowerCase() !== "allow"
31
+ || trustedWindowsSid(sid, currentSid)
32
+ || (rights & WINDOWS_REPLACEMENT_RIGHTS) === 0);
33
+ }
34
+ export function windowsPrivateAclHardeningPlan(snapshot, identity, directory) {
35
+ const grant = directory ? `*${identity.sid}:(OI)(CI)(F)` : `*${identity.sid}:(F)`;
36
+ const untrustedAllows = [...new Set(snapshot.access
37
+ .filter(({ sid, type }) => type.toLowerCase() === "allow" && !trustedWindowsSid(sid, identity.sid))
38
+ .map(({ sid }) => sid.toUpperCase()))];
39
+ const denies = [...new Set(snapshot.access
40
+ .filter(({ type }) => type.toLowerCase() === "deny")
41
+ .map(({ sid }) => sid.toUpperCase()))];
42
+ return [
43
+ ["/grant:r", grant],
44
+ ["/inheritance:r"],
45
+ ...untrustedAllows.map((sid) => ["/remove:g", `*${sid}`]),
46
+ ...denies.map((sid) => ["/remove:d", `*${sid}`]),
47
+ ["/setowner", identity.account],
48
+ ];
49
+ }
50
+ function windowsAclSnapshot(path) {
51
+ const script = [
52
+ "$ErrorActionPreference='Stop'",
53
+ "$sections=[System.Security.AccessControl.AccessControlSections]::Owner -bor [System.Security.AccessControl.AccessControlSections]::Access",
54
+ "$acl=if([System.IO.Directory]::Exists($env:CK_INTERNAL_ACL_PATH)){[System.Security.AccessControl.DirectorySecurity]::new($env:CK_INTERNAL_ACL_PATH,$sections)}else{[System.Security.AccessControl.FileSecurity]::new($env:CK_INTERNAL_ACL_PATH,$sections)}",
55
+ "$owner=$acl.Owner",
56
+ "try{$owner=([System.Security.Principal.NTAccount]$owner).Translate([System.Security.Principal.SecurityIdentifier]).Value}catch{}",
57
+ "$access=@($acl.Access|ForEach-Object{$sid=$_.IdentityReference.Value;try{$sid=$_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value}catch{};[pscustomobject]@{sid=$sid;type=$_.AccessControlType.ToString();rights=[int64]$_.FileSystemRights}})",
58
+ "[pscustomobject]@{owner_sid=$owner;protected=$acl.AreAccessRulesProtected;access=$access}|ConvertTo-Json -Compress -Depth 4",
59
+ ].join(";");
60
+ const result = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
61
+ encoding: "utf8",
62
+ windowsHide: true,
63
+ timeout: WINDOWS_COMMAND_TIMEOUT_MS,
64
+ maxBuffer: WINDOWS_COMMAND_MAX_BUFFER,
65
+ env: { ...process.env, CK_INTERNAL_ACL_PATH: path },
66
+ });
67
+ if (result.status !== 0 || !result.stdout.trim()) {
68
+ const detail = result.error?.message || result.stderr.trim();
69
+ throw new Error(`Unable to inspect Windows ACL${detail ? `: ${detail}` : "."}`);
70
+ }
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(result.stdout);
74
+ }
75
+ catch {
76
+ throw new Error("Unable to parse the Windows ACL inspection result.");
77
+ }
78
+ if (typeof parsed.owner_sid !== "string" || typeof parsed.protected !== "boolean" || !Array.isArray(parsed.access)) {
79
+ throw new Error("Windows ACL inspection returned an invalid structure.");
80
+ }
81
+ const access = parsed.access.filter((ace) => Boolean(ace)
82
+ && typeof ace.sid === "string" && typeof ace.type === "string" && Number.isSafeInteger(ace.rights));
83
+ if (access.length !== parsed.access.length)
84
+ throw new Error("Windows ACL inspection returned an invalid access rule.");
85
+ return { owner_sid: parsed.owner_sid, protected: parsed.protected, access };
86
+ }
6
87
  function currentWindowsIdentity() {
7
88
  if (cachedWindowsIdentity)
8
89
  return cachedWindowsIdentity;
9
- const result = spawnSync("whoami.exe", ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", windowsHide: true });
10
- if (result.status !== 0)
11
- throw new Error("Unable to resolve the current Windows security identity.");
90
+ const result = spawnSync("whoami.exe", ["/user", "/fo", "csv", "/nh"], {
91
+ encoding: "utf8",
92
+ windowsHide: true,
93
+ timeout: WINDOWS_COMMAND_TIMEOUT_MS,
94
+ maxBuffer: WINDOWS_COMMAND_MAX_BUFFER,
95
+ });
96
+ if (result.status !== 0) {
97
+ const detail = result.error?.message || result.stderr.trim();
98
+ throw new Error(`Unable to resolve the current Windows security identity${detail ? `: ${detail}` : "."}`);
99
+ }
12
100
  const match = result.stdout.match(/^"([^"]+)","(S-1-[0-9-]+)"/im);
13
101
  if (!match)
14
102
  throw new Error("Unable to resolve the current Windows security identity.");
@@ -21,15 +109,30 @@ export function hardenPrivatePath(path) {
21
109
  return;
22
110
  }
23
111
  const identity = currentWindowsIdentity();
24
- const args = [
25
- path,
26
- "/inheritance:r",
27
- "/remove:g", "*S-1-5-18", "*S-1-5-32-544",
28
- "/grant:r", `*${identity.sid}:(F)`,
29
- ];
30
- const result = spawnSync("icacls.exe", args, { encoding: "utf8", windowsHide: true });
31
- if (result.status !== 0)
32
- throw new Error("Unable to restrict Windows ACLs for private Cairnkeep state.");
112
+ const directory = lstatSync(path).isDirectory();
113
+ let snapshot;
114
+ try {
115
+ snapshot = windowsAclSnapshot(path);
116
+ }
117
+ catch (error) {
118
+ const detail = error instanceof Error ? error.message : String(error);
119
+ throw new Error(`Unable to inspect Windows ACLs before hardening private Cairnkeep state: ${detail}`);
120
+ }
121
+ for (const operation of windowsPrivateAclHardeningPlan(snapshot, identity, directory)) {
122
+ const args = [path, ...operation];
123
+ const result = spawnSync("icacls.exe", args, {
124
+ encoding: "utf8",
125
+ windowsHide: true,
126
+ timeout: WINDOWS_COMMAND_TIMEOUT_MS,
127
+ maxBuffer: WINDOWS_COMMAND_MAX_BUFFER,
128
+ });
129
+ if (result.status !== 0) {
130
+ const detail = result.error?.message || result.stderr.trim() || result.stdout.trim();
131
+ throw new Error(`Unable to restrict Windows ACLs for private Cairnkeep state${detail ? `: ${detail}` : "."}`);
132
+ }
133
+ }
134
+ if (!privatePathIsSafe(path))
135
+ throw new Error("Unable to verify private Windows ACLs after hardening.");
33
136
  }
34
137
  export function privatePathIsSafe(path) {
35
138
  if (!existsSync(path))
@@ -38,30 +141,46 @@ export function privatePathIsSafe(path) {
38
141
  if (info.isSymbolicLink())
39
142
  return false;
40
143
  if (process.platform !== "win32")
41
- return (info.mode & 0o077) === 0;
144
+ return posixPrivateMetadataIsSafe(info);
42
145
  try {
43
- const account = currentWindowsIdentity().account.toLowerCase();
44
- const result = spawnSync("icacls.exe", [path], { encoding: "utf8", windowsHide: true });
45
- if (result.status !== 0 || !result.stdout)
46
- return false;
47
- const grants = result.stdout.split(/\r?\n/).filter((line) => {
48
- const body = line.trim();
49
- const marker = body.indexOf(":(");
50
- if (marker < 0)
51
- return false;
52
- const permissions = body.slice(marker + 1).toUpperCase();
53
- return !permissions.includes("(DENY)") && !permissions.includes("(NW)");
54
- });
55
- const normalized = grants.map((grant) => grant.toLowerCase());
56
- const currentMarker = `${account}:(`;
57
- return normalized.some((grant) => grant.includes(currentMarker))
58
- && normalized.every((grant) => grant.includes(currentMarker)
59
- || /\\logonsessionid_[0-9]+_[0-9]+:\(rx\)$/.test(grant.trim()));
146
+ const identity = currentWindowsIdentity();
147
+ const snapshot = windowsAclSnapshot(path);
148
+ return Boolean(snapshot && windowsPrivateAclIsSafe(snapshot, identity.sid));
149
+ }
150
+ catch {
151
+ return false;
152
+ }
153
+ }
154
+ export function replacementBoundaryIsSafe(path) {
155
+ if (!existsSync(path))
156
+ return false;
157
+ const info = lstatSync(path);
158
+ if (!info.isDirectory() || info.isSymbolicLink())
159
+ return false;
160
+ if (process.platform !== "win32")
161
+ return posixOwnerIsTrusted(info.uid) && (info.mode & 0o022) === 0;
162
+ try {
163
+ const identity = currentWindowsIdentity();
164
+ const snapshot = windowsAclSnapshot(path);
165
+ return Boolean(snapshot && windowsReplacementAclIsSafe(snapshot, identity.sid));
60
166
  }
61
167
  catch {
62
168
  return false;
63
169
  }
64
170
  }
171
+ export function parentReplacementBoundaryIsSafe(path) {
172
+ const parentPath = dirname(path);
173
+ if (!existsSync(parentPath))
174
+ return false;
175
+ const info = lstatSync(parentPath);
176
+ if (!info.isDirectory() || info.isSymbolicLink())
177
+ return false;
178
+ if (process.platform === "win32")
179
+ return replacementBoundaryIsSafe(parentPath);
180
+ const writableByOthers = (info.mode & 0o022) !== 0;
181
+ const sticky = (info.mode & 0o1000) !== 0;
182
+ return posixOwnerIsTrusted(info.uid) && (!writableByOthers || sticky);
183
+ }
65
184
  function delay(milliseconds) {
66
185
  return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
67
186
  }
@@ -86,6 +205,8 @@ export async function atomicReplace(source, destination) {
86
205
  ], {
87
206
  encoding: "utf8",
88
207
  windowsHide: true,
208
+ timeout: WINDOWS_COMMAND_TIMEOUT_MS,
209
+ maxBuffer: WINDOWS_COMMAND_MAX_BUFFER,
89
210
  env: {
90
211
  ...process.env,
91
212
  CK_INTERNAL_ATOMIC_SOURCE: source,
@@ -98,7 +219,9 @@ export async function atomicReplace(source, destination) {
98
219
  return;
99
220
  }
100
221
  rmSync(backup, { force: true });
101
- lastError = result.stderr.trim() || result.stdout.trim() || lastError;
222
+ if (result.error)
223
+ throw new Error(`Windows atomic replacement failed: ${result.error.message}`);
224
+ lastError = result.stderr?.trim() || result.stdout?.trim() || lastError;
102
225
  }
103
226
  await delay(25 * (attempt + 1));
104
227
  }
@@ -0,0 +1,132 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { parentReplacementBoundaryIsSafe, posixPrivateMetadataIsSafe, privatePathIsSafe, replacementBoundaryIsSafe } from "./platform-security.js";
4
+ const ASSIGNMENT = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
5
+ const MAX_PROJECT_ENV_BYTES = 64 * 1024;
6
+ function readBoundedDescriptor(descriptor, approvedSize) {
7
+ const buffer = Buffer.allocUnsafe(approvedSize + 1);
8
+ let offset = 0;
9
+ while (offset < buffer.byteLength) {
10
+ const bytesRead = readSync(descriptor, buffer, offset, buffer.byteLength - offset, null);
11
+ if (bytesRead === 0)
12
+ break;
13
+ offset += bytesRead;
14
+ }
15
+ return buffer.subarray(0, offset);
16
+ }
17
+ function literalValue(raw, line) {
18
+ if (/[\x00-\x1f\x7f]/.test(raw))
19
+ throw new Error(`.ai/.env line ${line} contains a control character`);
20
+ if (/^\s/.test(raw))
21
+ throw new Error(`.ai/.env line ${line} has whitespace after the assignment operator`);
22
+ const value = raw.trim();
23
+ if (!value)
24
+ return "";
25
+ if (/^'[^']*'$/.test(value))
26
+ return value.slice(1, -1);
27
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
28
+ const inner = value.slice(1, -1);
29
+ if (!/^(?:[^"\\$`]|\\[\\"])*$/.test(inner)) {
30
+ throw new Error(`.ai/.env line ${line} is not a literal double-quoted value`);
31
+ }
32
+ return inner.replace(/\\([\\"])/g, "$1");
33
+ }
34
+ const withoutComment = value.replace(/\s+#.*$/, "").trim();
35
+ if (!/^[A-Za-z0-9_./:@%+,=-]*$/.test(withoutComment))
36
+ throw new Error(`.ai/.env line ${line} is not a literal assignment`);
37
+ return withoutComment;
38
+ }
39
+ export function projectEnvironment(projectRoot, ambient) {
40
+ const requestedRoot = resolve(projectRoot);
41
+ let approvedRootInfo;
42
+ try {
43
+ approvedRootInfo = lstatSync(requestedRoot);
44
+ if (!approvedRootInfo.isDirectory() || approvedRootInfo.isSymbolicLink()
45
+ || !replacementBoundaryIsSafe(requestedRoot) || !parentReplacementBoundaryIsSafe(requestedRoot)) {
46
+ return { env: { ...ambient }, issue: "project root and its parent must be owner-controlled real directories" };
47
+ }
48
+ }
49
+ catch {
50
+ return { env: { ...ambient }, issue: "project root metadata could not be inspected" };
51
+ }
52
+ let canonicalRoot;
53
+ try {
54
+ canonicalRoot = realpathSync(requestedRoot);
55
+ const requestedAfter = lstatSync(requestedRoot);
56
+ const canonicalInfo = lstatSync(canonicalRoot);
57
+ if (requestedAfter.dev !== approvedRootInfo.dev || requestedAfter.ino !== approvedRootInfo.ino
58
+ || canonicalInfo.dev !== approvedRootInfo.dev || canonicalInfo.ino !== approvedRootInfo.ino
59
+ || requestedAfter.isSymbolicLink() || !parentReplacementBoundaryIsSafe(canonicalRoot)) {
60
+ return { env: { ...ambient }, issue: "project root changed while it was being inspected" };
61
+ }
62
+ }
63
+ catch {
64
+ return { env: { ...ambient }, issue: "project root could not be canonicalized safely" };
65
+ }
66
+ const directory = join(canonicalRoot, ".ai");
67
+ const path = join(directory, ".env");
68
+ try {
69
+ const directoryInfo = lstatSync(directory);
70
+ if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink() || !replacementBoundaryIsSafe(directory))
71
+ return { env: { ...ambient }, issue: ".ai must be an owner-controlled real directory" };
72
+ }
73
+ catch (error) {
74
+ if (error.code === "ENOENT")
75
+ return { env: { ...ambient } };
76
+ return { env: { ...ambient }, issue: ".ai/.env metadata could not be inspected" };
77
+ }
78
+ let text;
79
+ let descriptor;
80
+ try {
81
+ const nonBlocking = process.platform === "win32" ? 0 : (constants.O_NONBLOCK ?? 0);
82
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | nonBlocking);
83
+ const opened = fstatSync(descriptor);
84
+ if (!opened.isFile() || opened.size > MAX_PROJECT_ENV_BYTES)
85
+ return { env: { ...ambient }, issue: ".ai/.env must be a private regular file no larger than 64 KiB" };
86
+ const bytes = readBoundedDescriptor(descriptor, opened.size);
87
+ const afterRead = fstatSync(descriptor);
88
+ const named = lstatSync(path);
89
+ const descriptorPrivate = process.platform === "win32" || posixPrivateMetadataIsSafe(opened);
90
+ if (!descriptorPrivate || named.isSymbolicLink() || !named.isFile()
91
+ || opened.dev !== afterRead.dev || opened.ino !== afterRead.ino || opened.size !== afterRead.size
92
+ || opened.mtimeMs !== afterRead.mtimeMs || opened.ctimeMs !== afterRead.ctimeMs
93
+ || named.dev !== opened.dev || named.ino !== opened.ino
94
+ || bytes.byteLength !== afterRead.size || bytes.byteLength > MAX_PROJECT_ENV_BYTES) {
95
+ return { env: { ...ambient }, issue: ".ai/.env changed while it was being inspected" };
96
+ }
97
+ if (process.platform === "win32") {
98
+ if (!privatePathIsSafe(path))
99
+ return { env: { ...ambient }, issue: ".ai/.env must be a private regular file no larger than 64 KiB" };
100
+ const namedAfterAcl = lstatSync(path);
101
+ if (namedAfterAcl.dev !== opened.dev || namedAfterAcl.ino !== opened.ino || namedAfterAcl.isSymbolicLink()) {
102
+ return { env: { ...ambient }, issue: ".ai/.env changed while it was being inspected" };
103
+ }
104
+ }
105
+ text = bytes.toString("utf8");
106
+ }
107
+ catch (error) {
108
+ if (error.code === "ENOENT")
109
+ return { env: { ...ambient } };
110
+ return { env: { ...ambient }, issue: ".ai/.env is unreadable" };
111
+ }
112
+ finally {
113
+ if (descriptor !== undefined)
114
+ closeSync(descriptor);
115
+ }
116
+ const loaded = {};
117
+ try {
118
+ for (const [index, source] of text.split(/\r?\n/).entries()) {
119
+ const line = source.trim();
120
+ if (!line || line.startsWith("#"))
121
+ continue;
122
+ const match = ASSIGNMENT.exec(line);
123
+ if (!match)
124
+ throw new Error(`.ai/.env line ${index + 1} is not an assignment`);
125
+ loaded[match[1]] = literalValue(match[2], index + 1);
126
+ }
127
+ }
128
+ catch (error) {
129
+ return { env: { ...loaded, ...ambient }, issue: error instanceof Error ? error.message : String(error) };
130
+ }
131
+ return { env: { ...loaded, ...ambient } };
132
+ }