@cairnkeep/cli 2.20.1 → 2.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +6 -3
- package/SECURITY.md +13 -0
- package/containers/entrypoint.sh +2 -1
- package/docs/README.md +2 -0
- package/docs/learning/CURRICULUM-MAP.md +4 -2
- package/docs/learning/FEATURE-GUIDE.md +6 -1
- package/docs/learning/README.md +1 -0
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +1 -1
- package/docs/learning/lessons/L02-installation.md +1 -1
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +1 -1
- package/docs/learning/lessons/L23-guided-setup.md +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +3 -0
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +104 -0
- package/docs/learning/video-scripts/V28-security-assurance.md +72 -0
- package/docs/operating.md +44 -5
- package/docs/privacy-and-data-flow.md +14 -0
- package/docs/releasing.md +16 -1
- package/docs/security-assurance.md +130 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-schema.js +2 -7
- package/mcp-memory-server/dist/context-pack.js +152 -31
- package/mcp-memory-server/dist/eval-schema.js +13 -12
- package/mcp-memory-server/dist/http-security.js +104 -0
- package/mcp-memory-server/dist/index.js +50 -12
- package/mcp-memory-server/dist/okf.js +35 -9
- package/mcp-memory-server/dist/path-security.js +49 -0
- package/mcp-memory-server/dist/platform-security.js +154 -31
- package/mcp-memory-server/dist/project-environment.js +132 -0
- package/mcp-memory-server/dist/security-cli.js +46 -0
- package/mcp-memory-server/dist/security-doctor.js +211 -0
- package/mcp-memory-server/package.json +2 -1
- package/package.json +3 -2
- package/scripts/bootstrap.sh +1 -0
- package/scripts/cairn-cli.mjs +2 -0
- package/scripts/completion.sh +9 -3
- package/scripts/doctor.sh +20 -4
- package/scripts/setup-reconcile.mjs +3 -0
- package/scripts/test-bootstrap-untracked.sh +2 -0
- package/scripts/test-ci-sharding.sh +33 -0
- package/scripts/test-cli-dispatch.sh +16 -1
- package/scripts/test-completion.sh +3 -0
- package/scripts/test-container-runner.sh +7 -0
- package/scripts/test-doctor.sh +29 -11
- package/scripts/test-launcher-env-precedence.sh +30 -0
- package/scripts/test-learning-docs.sh +19 -0
- package/scripts/test-security-baseline.sh +4 -0
- package/scripts/verify-security-baseline.mjs +79 -0
- package/scripts/windows-platform.mjs +4 -2
- package/templates/start-claude.sh.template +3 -0
- package/templates/start-codex.sh.template +3 -0
- package/templates/start-kimi.sh.template +3 -0
- package/templates/start-omp.sh.template +3 -0
- package/templates/start-opencode.sh.template +3 -0
- package/templates/start-pi.sh.template +3 -0
- 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
|
|
1757
|
-
|
|
1758
|
-
|
|
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
|
|
1763
|
-
if (!
|
|
1764
|
-
process.stderr.write(
|
|
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
|
|
1779
|
+
const hostCandidates = configuredHosts.length > 0
|
|
1772
1780
|
? configuredHosts
|
|
1773
|
-
: [
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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://${
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
241
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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"], {
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
144
|
+
return posixPrivateMetadataIsSafe(info);
|
|
42
145
|
try {
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
+
}
|