@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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { securityDoctor } from "./security-doctor.js";
|
|
2
|
+
import { projectEnvironment } from "./project-environment.js";
|
|
3
|
+
function usage(exitCode = 2) {
|
|
4
|
+
process.stderr.write("Usage: cairn security doctor [--project PATH] [--json]\n");
|
|
5
|
+
process.exit(exitCode);
|
|
6
|
+
}
|
|
7
|
+
function option(args, name) {
|
|
8
|
+
const index = args.indexOf(name);
|
|
9
|
+
if (index < 0)
|
|
10
|
+
return undefined;
|
|
11
|
+
const value = args[index + 1];
|
|
12
|
+
if (!value || value.startsWith("--"))
|
|
13
|
+
usage();
|
|
14
|
+
args.splice(index, 2);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
const command = args.shift();
|
|
19
|
+
if (["help", "--help", "-h"].includes(command ?? ""))
|
|
20
|
+
usage(0);
|
|
21
|
+
if (command !== "doctor")
|
|
22
|
+
usage();
|
|
23
|
+
const jsonIndex = args.indexOf("--json");
|
|
24
|
+
const json = jsonIndex >= 0;
|
|
25
|
+
if (json)
|
|
26
|
+
args.splice(jsonIndex, 1);
|
|
27
|
+
const projectRoot = option(args, "--project") ?? process.cwd();
|
|
28
|
+
if (args.length)
|
|
29
|
+
usage();
|
|
30
|
+
const loaded = projectEnvironment(projectRoot, process.env);
|
|
31
|
+
const report = securityDoctor({ projectRoot, env: loaded.env, environmentIssue: loaded.issue });
|
|
32
|
+
if (json) {
|
|
33
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
process.stdout.write("cairn security doctor\n");
|
|
37
|
+
for (const check of report.checks) {
|
|
38
|
+
process.stdout.write(` [${check.state}] ${check.summary}\n`);
|
|
39
|
+
if (check.remediation && check.state !== "PASS" && check.state !== "SKIP") {
|
|
40
|
+
process.stdout.write(` ${check.remediation}\n`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
process.stdout.write(`Summary: ${report.summary.pass} pass, ${report.summary.warn} warn, ${report.summary.fail} fail, ${report.summary.skip} skip\n`);
|
|
44
|
+
}
|
|
45
|
+
if (!report.ok)
|
|
46
|
+
process.exitCode = 1;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { normalizeHostAuthority, normalizeHttpHost, parseHttpPort, resolveHttpToken } from "./http-security.js";
|
|
4
|
+
import { resolveMcpToolProfile } from "./mcp-tool-profile.js";
|
|
5
|
+
import { parentReplacementBoundaryIsSafe, posixPrivateMetadataIsSafe, privatePathIsSafe, replacementBoundaryIsSafe } from "./platform-security.js";
|
|
6
|
+
const PRIVATE_PROJECT_FILES = [
|
|
7
|
+
".ai/.env",
|
|
8
|
+
".ai/capabilities.json",
|
|
9
|
+
".ai/mcp-tools.json",
|
|
10
|
+
".ai/playbooks.json",
|
|
11
|
+
];
|
|
12
|
+
function loopbackHost(host) {
|
|
13
|
+
const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
14
|
+
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
|
15
|
+
}
|
|
16
|
+
function tokenStrength(token) {
|
|
17
|
+
const bytes = Buffer.byteLength(token.trim(), "utf8");
|
|
18
|
+
if (bytes === 0)
|
|
19
|
+
return "missing";
|
|
20
|
+
if (bytes < 32 || /^(.)(?:\1)+$/.test(token.trim()))
|
|
21
|
+
return "weak";
|
|
22
|
+
return "ok";
|
|
23
|
+
}
|
|
24
|
+
function lstatIfPresent(path) {
|
|
25
|
+
try {
|
|
26
|
+
return lstatSync(path);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code === "ENOENT")
|
|
30
|
+
return undefined;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function checkPrivateDirectory(projectRoot) {
|
|
35
|
+
const path = join(projectRoot, ".ai");
|
|
36
|
+
const info = lstatIfPresent(path);
|
|
37
|
+
if (!info)
|
|
38
|
+
return { id: "private-directory:.ai", state: "SKIP", summary: ".ai is not present" };
|
|
39
|
+
const safePermissions = process.platform === "win32"
|
|
40
|
+
? privatePathIsSafe(path)
|
|
41
|
+
: posixPrivateMetadataIsSafe(info) && (Number(info.mode) & 0o700) === 0o700;
|
|
42
|
+
if (!info.isDirectory() || info.isSymbolicLink() || !safePermissions) {
|
|
43
|
+
return { id: "private-directory:.ai", state: "FAIL", summary: ".ai is symlinked, non-directory, inaccessible, or not private to a trusted owner", remediation: process.platform === "win32" ? "Restrict .ai to the current account and remove inherited access." : "Replace symlinks, restore trusted ownership, and run: chmod 700 .ai" };
|
|
44
|
+
}
|
|
45
|
+
return { id: "private-directory:.ai", state: "PASS", summary: ".ai is a real directory not writable by other accounts" };
|
|
46
|
+
}
|
|
47
|
+
function checkPrivateFile(projectRoot, relativePath, safeParent) {
|
|
48
|
+
const path = join(projectRoot, ...relativePath.split("/"));
|
|
49
|
+
if (!safeParent)
|
|
50
|
+
return { id: `private-file:${relativePath}`, state: "SKIP", summary: `${relativePath} was not followed through an unsafe .ai directory` };
|
|
51
|
+
let info;
|
|
52
|
+
try {
|
|
53
|
+
info = lstatIfPresent(path);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { id: `private-file:${relativePath}`, state: "FAIL", summary: `${relativePath} metadata is inaccessible`, remediation: "Restore trusted ownership and private directory traversal permissions." };
|
|
57
|
+
}
|
|
58
|
+
if (!info)
|
|
59
|
+
return { id: `private-file:${relativePath}`, state: "SKIP", summary: `${relativePath} is not present` };
|
|
60
|
+
if (!info.isFile() || info.isSymbolicLink() || !privatePathIsSafe(path)) {
|
|
61
|
+
return {
|
|
62
|
+
id: `private-file:${relativePath}`,
|
|
63
|
+
state: "FAIL",
|
|
64
|
+
summary: `${relativePath} is not a private regular file`,
|
|
65
|
+
remediation: process.platform === "win32"
|
|
66
|
+
? `Restrict ${relativePath} to the current account and remove inherited access.`
|
|
67
|
+
: `Run: chmod 600 ${relativePath}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { id: `private-file:${relativePath}`, state: "PASS", summary: `${relativePath} is private and non-symlinked` };
|
|
71
|
+
}
|
|
72
|
+
function sameFileIdentity(left, right) {
|
|
73
|
+
return left.dev === right.dev && left.ino === right.ino && left.isDirectory() === right.isDirectory();
|
|
74
|
+
}
|
|
75
|
+
export function securityDoctor(options = {}) {
|
|
76
|
+
const env = options.env ?? process.env;
|
|
77
|
+
const requestedRoot = resolve(options.projectRoot ?? process.cwd());
|
|
78
|
+
const checks = [];
|
|
79
|
+
let projectRoot = requestedRoot;
|
|
80
|
+
let projectRootTrusted = false;
|
|
81
|
+
let requestedInfo;
|
|
82
|
+
try {
|
|
83
|
+
requestedInfo = lstatIfPresent(requestedRoot);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
checks.push({ id: "project-root", state: "FAIL", summary: "Project root metadata is inaccessible", remediation: "Restore trusted ownership and directory traversal permissions." });
|
|
87
|
+
}
|
|
88
|
+
if (!requestedInfo) {
|
|
89
|
+
if (!checks.some(({ id }) => id === "project-root"))
|
|
90
|
+
checks.push({ id: "project-root", state: "FAIL", summary: "Project root does not exist", remediation: "Run the command from a real project directory." });
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const info = requestedInfo;
|
|
94
|
+
if (!info.isDirectory() || info.isSymbolicLink() || !replacementBoundaryIsSafe(requestedRoot)) {
|
|
95
|
+
checks.push({ id: "project-root", state: "FAIL", summary: "Project root is symlinked, non-directory, or writable by another account", remediation: process.platform === "win32" ? "Use a project directory whose ACL denies replacement by untrusted accounts." : "Use a canonical project directory and run: chmod go-w ." });
|
|
96
|
+
}
|
|
97
|
+
else if (!parentReplacementBoundaryIsSafe(requestedRoot)) {
|
|
98
|
+
checks.push({ id: "project-root-parent", state: "FAIL", summary: "The requested project parent directory can replace the selected project root", remediation: "Move the project below an owner-controlled directory or restrict the parent directory permissions." });
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
try {
|
|
102
|
+
options.testBeforeCanonicalize?.();
|
|
103
|
+
const canonical = realpathSync(requestedRoot);
|
|
104
|
+
const requestedAfter = lstatSync(requestedRoot);
|
|
105
|
+
const canonicalInfo = lstatSync(canonical);
|
|
106
|
+
if (requestedAfter.isSymbolicLink() || !sameFileIdentity(info, requestedAfter) || !sameFileIdentity(info, canonicalInfo)
|
|
107
|
+
|| !parentReplacementBoundaryIsSafe(canonical))
|
|
108
|
+
throw new Error("project identity changed");
|
|
109
|
+
projectRoot = canonical;
|
|
110
|
+
projectRootTrusted = true;
|
|
111
|
+
checks.push({ id: "project-root", state: "PASS", summary: "Project root and its immediate replacement boundary are protected" });
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
checks.push({ id: "project-root", state: "FAIL", summary: "Project root identity or canonical location changed during inspection", remediation: "Retry after placing the project below an owner-controlled, non-symlink directory." });
|
|
115
|
+
}
|
|
116
|
+
if (projectRootTrusted) {
|
|
117
|
+
const directoryCheck = checkPrivateDirectory(projectRoot);
|
|
118
|
+
checks.push(directoryCheck);
|
|
119
|
+
for (const file of PRIVATE_PROJECT_FILES)
|
|
120
|
+
checks.push(checkPrivateFile(projectRoot, file, directoryCheck.state !== "FAIL"));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (options.environmentIssue)
|
|
125
|
+
checks.push({ id: "project-environment", state: "FAIL", summary: "Project .ai/.env could not be interpreted safely", remediation: options.environmentIssue });
|
|
126
|
+
else
|
|
127
|
+
checks.push({ id: "project-environment", state: "PASS", summary: "Project environment uses supported literal assignments or is absent" });
|
|
128
|
+
let profileMode;
|
|
129
|
+
if (!projectRootTrusted) {
|
|
130
|
+
checks.push({ id: "mcp-profile", state: "SKIP", summary: "MCP tool profile was not read through an unsafe project root" });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
try {
|
|
134
|
+
const profile = resolveMcpToolProfile({ projectRoot, env });
|
|
135
|
+
profileMode = profile.mode;
|
|
136
|
+
checks.push({ id: "mcp-profile", state: "PASS", summary: `MCP tool profile is valid (${profile.mode})` });
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
checks.push({ id: "mcp-profile", state: "FAIL", summary: "MCP tool profile is invalid or unsafe", remediation: "Run: cairn mcp-tools status --project ." });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const portResolution = parseHttpPort(env.MCP_HTTP_PORT);
|
|
143
|
+
if ("error" in portResolution) {
|
|
144
|
+
checks.push({ id: "http-exposure", state: "FAIL", summary: "MCP_HTTP_PORT is invalid", remediation: "Unset MCP_HTTP_PORT or choose a canonical decimal port from 1 through 65535." });
|
|
145
|
+
}
|
|
146
|
+
else if (!portResolution.enabled) {
|
|
147
|
+
checks.push({ id: "http-exposure", state: "SKIP", summary: "MCP HTTP transport is disabled" });
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
const port = portResolution.port;
|
|
151
|
+
const host = normalizeHttpHost(env.MCP_HTTP_HOST);
|
|
152
|
+
const resolution = !projectRootTrusted && env.CAIRN_MEMORY_HTTP_TOKEN_FILE && !isAbsolute(env.CAIRN_MEMORY_HTTP_TOKEN_FILE)
|
|
153
|
+
? { ok: false, reason: "unsafe-base" }
|
|
154
|
+
: resolveHttpToken(env, { baseDirectory: projectRoot });
|
|
155
|
+
let token;
|
|
156
|
+
if (resolution.ok) {
|
|
157
|
+
token = resolution.token;
|
|
158
|
+
if (resolution.source === "file")
|
|
159
|
+
checks.push({ id: "http-token-file", state: "PASS", summary: "HTTP token file is private and non-symlinked" });
|
|
160
|
+
}
|
|
161
|
+
else if (resolution.reason !== "missing") {
|
|
162
|
+
const id = resolution.reason === "multiple" ? "http-token-source"
|
|
163
|
+
: resolution.reason === "token-unsafe" ? "http-token"
|
|
164
|
+
: "http-token-file";
|
|
165
|
+
checks.push({ id, state: "FAIL", summary: `HTTP token resolution failed (${resolution.reason})`, remediation: "Set exactly one token source and use a readable, private, non-symlink token file with an ASCII Bearer token." });
|
|
166
|
+
}
|
|
167
|
+
const strength = tokenStrength(token ?? "");
|
|
168
|
+
checks.push(strength === "ok"
|
|
169
|
+
? { id: "http-token", state: "PASS", summary: "HTTP bearer token meets the minimum length" }
|
|
170
|
+
: { id: "http-token", state: "FAIL", summary: strength === "missing" ? "HTTP bearer token is missing" : "HTTP bearer token is too weak", remediation: "Generate at least 32 random bytes; for example: openssl rand -hex 32" });
|
|
171
|
+
const nonLoopback = !loopbackHost(host);
|
|
172
|
+
const hosts = (env.CAIRN_MEMORY_HTTP_ALLOWED_HOSTS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
|
|
173
|
+
const invalidHosts = hosts.filter((value) => !normalizeHostAuthority(value));
|
|
174
|
+
checks.push(invalidHosts.length > 0
|
|
175
|
+
? { id: "http-host-allowlist", state: "FAIL", summary: "HTTP Host allowlist contains an invalid authority", remediation: `Use host:port authorities matching port ${port}; bracket IPv6 literals.` }
|
|
176
|
+
: nonLoopback && hosts.length === 0
|
|
177
|
+
? { id: "http-host-allowlist", state: "WARN", summary: "Non-loopback HTTP binding relies on the derived Host allowlist", remediation: "Set CAIRN_MEMORY_HTTP_ALLOWED_HOSTS explicitly to the public host names and ports." }
|
|
178
|
+
: { id: "http-host-allowlist", state: "PASS", summary: nonLoopback ? "Non-loopback HTTP binding has an explicit Host allowlist" : "HTTP binding is loopback-only" });
|
|
179
|
+
if (nonLoopback && profileMode === "full") {
|
|
180
|
+
checks.push({ id: "http-least-authority", state: "WARN", summary: "Network HTTP exposure uses the full MCP tool profile", remediation: "Use CAIRN_MCP_TOOL_PROFILE=read-only or an explicit custom allowlist when mutations are unnecessary." });
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
checks.push({ id: "http-least-authority", state: "PASS", summary: "HTTP exposure and MCP tool authority are bounded" });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const remote = env.CAIRN_MEMORY_REMOTE_URL?.trim();
|
|
187
|
+
if (!remote) {
|
|
188
|
+
checks.push({ id: "remote-transport", state: "SKIP", summary: "Remote memory endpoint is not configured" });
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
try {
|
|
192
|
+
const url = new URL(remote);
|
|
193
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
|
|
194
|
+
checks.push({ id: "remote-transport", state: "FAIL", summary: "Remote memory URL has an unsafe scheme or embedded credentials", remediation: "Use an HTTP(S) URL and provide credentials through the supported secret environment variable." });
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const local = loopbackHost(url.hostname);
|
|
198
|
+
checks.push(url.protocol === "https:" || local
|
|
199
|
+
? { id: "remote-transport", state: "PASS", summary: "Remote memory transport is encrypted or loopback-only" }
|
|
200
|
+
: { id: "remote-transport", state: "WARN", summary: "Remote memory transport uses plaintext HTTP", remediation: "Use HTTPS or a separately authenticated private network." });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
checks.push({ id: "remote-transport", state: "FAIL", summary: "Remote memory URL is invalid", remediation: "Set CAIRN_MEMORY_REMOTE_URL to an absolute HTTP or HTTPS URL." });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const summary = { pass: 0, warn: 0, fail: 0, skip: 0 };
|
|
208
|
+
for (const check of checks)
|
|
209
|
+
summary[check.state.toLowerCase()] += 1;
|
|
210
|
+
return { schema_version: 1, ok: summary.fail === 0, project_root: projectRoot, checks, summary };
|
|
211
|
+
}
|
|
@@ -59,9 +59,10 @@
|
|
|
59
59
|
"check:work-evidence": "node scripts/smoke-work-evidence.mjs",
|
|
60
60
|
"check:okf": "node scripts/smoke-okf.mjs",
|
|
61
61
|
"check:playbook": "node scripts/smoke-playbook.mjs",
|
|
62
|
+
"check:security-assurance": "node scripts/smoke-security-assurance.mjs",
|
|
62
63
|
"check:notes": "npm run build && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment",
|
|
63
64
|
"test": "npm run test:smoke",
|
|
64
|
-
"test:smoke": "npm run build &&
|
|
65
|
+
"test:smoke": "npm run build && node scripts/run-smoke-suite.mjs",
|
|
65
66
|
"test:search": "npm run build && npm run check:search",
|
|
66
67
|
"test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
|
|
67
68
|
"test:all": "npm run test:memory",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cairnkeep/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.21.1",
|
|
4
4
|
"description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"test": "node scripts/run-repository-tests.mjs",
|
|
51
51
|
"test:windows": "node scripts/test-windows-native.mjs",
|
|
52
52
|
"audit:runtime": "node scripts/verify-dependency-security.mjs && npm audit --omit=dev --audit-level=high && npm --prefix mcp-memory-server audit --omit=dev --audit-level=high",
|
|
53
|
-
"
|
|
53
|
+
"security:baseline": "node scripts/verify-security-baseline.mjs",
|
|
54
|
+
"check:public": "scripts/verify-no-private-references.sh && scripts/verify-docs-parity.sh && npm run audit:runtime && npm run security:baseline && npm run build:server && npm test",
|
|
54
55
|
"prepublishOnly": "npm run check:public && npm --prefix mcp-memory-server test --global=false --dry-run=false"
|
|
55
56
|
},
|
|
56
57
|
"repository": {
|
package/scripts/bootstrap.sh
CHANGED
|
@@ -72,6 +72,7 @@ install_file "$TPL/env.example.template" "$target/.ai/env.example" 0
|
|
|
72
72
|
install_file "$TPL/trajectory-redaction.json.template" "$target/.ai/trajectory-redaction.json" 0644
|
|
73
73
|
install_file "$TPL/capabilities.json.template" "$target/.ai/capabilities.json" 0600
|
|
74
74
|
install_file "$TPL/playbooks.json.template" "$target/.ai/playbooks.json" 0600
|
|
75
|
+
chmod 700 "$target/.ai"
|
|
75
76
|
|
|
76
77
|
if [[ $untracked -eq 0 ]]; then
|
|
77
78
|
node "$CAIRN_ROOT/scripts/playbook-instructions.mjs" "$target" >/dev/null
|
package/scripts/cairn-cli.mjs
CHANGED
|
@@ -20,6 +20,7 @@ const NODE_COMMANDS = new Map([
|
|
|
20
20
|
["graph", "graph-cli.js"],
|
|
21
21
|
["evidence", "work-evidence-cli.js"],
|
|
22
22
|
["playbook", "playbook-cli.js"],
|
|
23
|
+
["security", "security-cli.js"],
|
|
23
24
|
]);
|
|
24
25
|
|
|
25
26
|
const POSIX_COMMANDS = new Map([
|
|
@@ -59,6 +60,7 @@ Usage:
|
|
|
59
60
|
cairn graph <build|query|status|diff|explain|path>
|
|
60
61
|
cairn evidence <list|show|delete|prune|doctor>
|
|
61
62
|
cairn playbook <list|status|init|set|enable|disable|reset|check|record|receipts|instructions|doctor>
|
|
63
|
+
cairn security doctor [--project PATH] [--json]
|
|
62
64
|
cairn memory <path|export|import>
|
|
63
65
|
cairn audit-timer [--on-calendar SPEC] [--para-root PATH] [--render-only DIR]
|
|
64
66
|
cairn uninstall [--dry-run] [--yes] [--purge-memory] [--purge-packs] [PROJECT ...]
|
package/scripts/completion.sh
CHANGED
|
@@ -13,7 +13,7 @@ _cairn_complete() {
|
|
|
13
13
|
COMPREPLY=()
|
|
14
14
|
current=${COMP_WORDS[COMP_CWORD]}
|
|
15
15
|
previous=${COMP_WORDS[COMP_CWORD-1]:-}
|
|
16
|
-
commands="bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help"
|
|
16
|
+
commands="bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor security trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help"
|
|
17
17
|
if (( COMP_CWORD == 1 )); then
|
|
18
18
|
COMPREPLY=( $(compgen -W "$commands" -- "$current") )
|
|
19
19
|
return
|
|
@@ -26,6 +26,7 @@ _cairn_complete() {
|
|
|
26
26
|
sync-kimi) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
|
|
27
27
|
sync-omp) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
|
|
28
28
|
doctor) COMPREPLY=( $(compgen -W "--repair" -- "$current") ) ;;
|
|
29
|
+
security) COMPREPLY=( $(compgen -W "doctor --project --json" -- "$current") ) ;;
|
|
29
30
|
trajectory) COMPREPLY=( $(compgen -W "list show prune --json --dry-run" -- "$current") ) ;;
|
|
30
31
|
artifact) COMPREPLY=( $(compgen -W "list show delete prune --kind --session --json --dry-run --include-protected" -- "$current") ) ;;
|
|
31
32
|
evidence) COMPREPLY=( $(compgen -W "list show delete prune doctor --status --json --dry-run --repair" -- "$current") ) ;;
|
|
@@ -97,6 +98,7 @@ _cairn() {
|
|
|
97
98
|
'sync-kimi:install the Kimi graph Skill'
|
|
98
99
|
'sync-omp:install the oh-my-pi memory extension'
|
|
99
100
|
'doctor:check runtime dependencies and endpoints'
|
|
101
|
+
'security:audit local security posture'
|
|
100
102
|
'trajectory:inspect and prune local session trajectories'
|
|
101
103
|
'artifact:inspect, delete, and prune local artifacts'
|
|
102
104
|
'evidence:inspect and manage local Git-linked work evidence'
|
|
@@ -128,6 +130,7 @@ _cairn() {
|
|
|
128
130
|
sync-kimi) _arguments '--apply[apply changes]' '--live-root[Kimi Code root]:directory:_files -/' ;;
|
|
129
131
|
sync-omp) _arguments '--apply[apply changes]' '--live-root[omp agent root]:directory:_files -/' ;;
|
|
130
132
|
doctor) _arguments '--repair[repair trajectory metadata and indexes]' ;;
|
|
133
|
+
security) _values 'security command' doctor '--project[project root]:directory:_files -/' '--json[emit JSON]' ;;
|
|
131
134
|
trajectory) _values 'trajectory command' list show prune ;;
|
|
132
135
|
artifact)
|
|
133
136
|
case $words[3] in
|
|
@@ -191,8 +194,8 @@ EOF
|
|
|
191
194
|
fish)
|
|
192
195
|
sed "s/__CAIRN_HARNESSES__/$harnesses/g" <<'EOF'
|
|
193
196
|
complete -c cairn -f
|
|
194
|
-
for command in bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help
|
|
195
|
-
complete -c cairn -n "not __fish_seen_subcommand_from bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help" -a $command
|
|
197
|
+
for command in bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor security trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help
|
|
198
|
+
complete -c cairn -n "not __fish_seen_subcommand_from bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor security trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help" -a $command
|
|
196
199
|
end
|
|
197
200
|
complete -c cairn -n "__fish_seen_subcommand_from sync" -l apply
|
|
198
201
|
complete -c cairn -n "__fish_seen_subcommand_from sync" -l live-root -r
|
|
@@ -211,6 +214,9 @@ complete -c cairn -n "__fish_seen_subcommand_from setup" -l yes
|
|
|
211
214
|
complete -c cairn -n "__fish_seen_subcommand_from setup" -l json
|
|
212
215
|
complete -c cairn -n "__fish_seen_subcommand_from memory" -a "path export import"
|
|
213
216
|
complete -c cairn -n "__fish_seen_subcommand_from doctor" -l repair
|
|
217
|
+
complete -c cairn -n "__fish_seen_subcommand_from security" -a "doctor"
|
|
218
|
+
complete -c cairn -n "__fish_seen_subcommand_from security" -l project -r
|
|
219
|
+
complete -c cairn -n "__fish_seen_subcommand_from security" -l json
|
|
214
220
|
complete -c cairn -n "__fish_seen_subcommand_from trajectory" -a "list show prune"
|
|
215
221
|
complete -c cairn -n "__fish_seen_subcommand_from artifact" -a "list show delete prune"
|
|
216
222
|
complete -c cairn -n "__fish_seen_subcommand_from artifact; and __fish_seen_subcommand_from list" -l kind -r
|
package/scripts/doctor.sh
CHANGED
|
@@ -36,15 +36,31 @@ fail() { printf ' [FAIL] %s\n' "$1"; fails=$((fails + 1)); }
|
|
|
36
36
|
# reachable); a connect/timeout failure returns non-zero.
|
|
37
37
|
reachable() { curl -sS -m 5 -o /dev/null "$1" >/dev/null 2>&1; }
|
|
38
38
|
|
|
39
|
+
embedding_failure="request failed"
|
|
39
40
|
embedding_works() {
|
|
40
|
-
local payload
|
|
41
|
+
local payload http_status curl_status
|
|
41
42
|
payload=$(node -e 'process.stdout.write(JSON.stringify({ model: process.argv[1], input: ["cairnkeep health check"] }))' \
|
|
42
43
|
"$CAIRN_MEMORY_EMBEDDING_MODEL") || return 1
|
|
43
|
-
curl -
|
|
44
|
+
http_status=$(curl -sS -m 10 -o /dev/null -w '%{http_code}' \
|
|
44
45
|
-H "Authorization: Bearer $CAIRN_LLM_API_KEY" \
|
|
45
46
|
-H "Content-Type: application/json" \
|
|
46
47
|
--data "$payload" \
|
|
47
|
-
"${CAIRN_MEMORY_EMBEDDING_URL%/}/embeddings" >/dev/null
|
|
48
|
+
"${CAIRN_MEMORY_EMBEDDING_URL%/}/embeddings" 2>/dev/null)
|
|
49
|
+
curl_status=$?
|
|
50
|
+
if [[ $curl_status -ne 0 ]]; then
|
|
51
|
+
embedding_failure="transport failed"
|
|
52
|
+
return 1
|
|
53
|
+
fi
|
|
54
|
+
case "$http_status" in
|
|
55
|
+
2??) return 0 ;;
|
|
56
|
+
401|403) embedding_failure="authorization rejected (HTTP $http_status)" ;;
|
|
57
|
+
404) embedding_failure="endpoint or model not found (HTTP 404)" ;;
|
|
58
|
+
408|429) embedding_failure="temporarily unavailable (HTTP $http_status)" ;;
|
|
59
|
+
5??) embedding_failure="upstream service failed (HTTP $http_status)" ;;
|
|
60
|
+
[0-9][0-9][0-9]) embedding_failure="request failed (HTTP $http_status)" ;;
|
|
61
|
+
*) embedding_failure="request failed" ;;
|
|
62
|
+
esac
|
|
63
|
+
return 1
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
echo "cairn doctor"
|
|
@@ -90,7 +106,7 @@ if [[ -n "${CAIRN_MEMORY_EMBEDDING_URL:-}" ]]; then
|
|
|
90
106
|
elif embedding_works; then
|
|
91
107
|
pass "embedding endpoint accepted model ${CAIRN_MEMORY_EMBEDDING_MODEL} (${CAIRN_MEMORY_EMBEDDING_URL})"
|
|
92
108
|
else
|
|
93
|
-
fail "embedding
|
|
109
|
+
fail "embedding ${embedding_failure} for model ${CAIRN_MEMORY_EMBEDDING_MODEL} (${CAIRN_MEMORY_EMBEDDING_URL})"
|
|
94
110
|
fi
|
|
95
111
|
else
|
|
96
112
|
skip "embedding endpoint (CAIRN_MEMORY_EMBEDDING_URL unset)"
|
|
@@ -263,6 +263,7 @@ export async function writeSetupStateAtomic(path, state, options = {}) {
|
|
|
263
263
|
if (!existsSync(target)) throw new Error("Setup state target does not exist.");
|
|
264
264
|
assertExistingAncestors(target, path);
|
|
265
265
|
safeMkdirChain(target, directory);
|
|
266
|
+
hardenPrivatePath(directory);
|
|
266
267
|
const bytes = Buffer.from(`${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
|
267
268
|
if (existsSync(path) && readFileSync(path).equals(bytes) && (statSync(path).mode & 0o777) === 0o600) return normalized;
|
|
268
269
|
await atomicWrite(path, bytes, 0o600, options.atomicReplace ?? defaultAtomicReplace, true);
|
|
@@ -285,6 +286,8 @@ export async function reconcileSetupPlan(plan, options = {}) {
|
|
|
285
286
|
for (const asset of selected) assertExistingAncestors(target, asset.destination);
|
|
286
287
|
const statePath = join(target, ".ai", "cairnkeep.json");
|
|
287
288
|
assertExistingAncestors(target, statePath);
|
|
289
|
+
safeMkdirChain(target, dirname(statePath));
|
|
290
|
+
hardenPrivatePath(dirname(statePath));
|
|
288
291
|
const previousState = options.previousState === undefined ? readPriorState(statePath) : options.previousState === null ? null : normalizeState(options.previousState);
|
|
289
292
|
|
|
290
293
|
const decisions = selected.map((asset) => {
|
|
@@ -84,6 +84,8 @@ grep -qxF "/.ai/" "$tmp/repo/.git/info/exclude" || fail "missing /.ai/ exclude e
|
|
|
84
84
|
grep -qxF "/.planning/" "$tmp/repo/.git/info/exclude" || fail "missing /.planning/ exclude entry"
|
|
85
85
|
grep -qxF "/.agentfs/" "$tmp/repo/.git/info/exclude" || fail "missing /.agentfs/ exclude entry"
|
|
86
86
|
[[ -f "$tmp/repo/.ai/capabilities.json" ]] || fail "capability configuration missing"
|
|
87
|
+
node -e 'const fs=require("fs"); const mode=fs.statSync(process.argv[1]).mode & 0o777; if (mode !== 0o700) process.exit(1)' "$tmp/repo/.ai" ||
|
|
88
|
+
fail ".ai directory must be private mode 0700"
|
|
87
89
|
[[ ! -e "$tmp/repo/.agentfs/trajectory.db" ]] || fail "fresh bootstrap created a callback database"
|
|
88
90
|
|
|
89
91
|
# Existing scaffold and operator configuration bytes are preserved on rerun.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
5
|
+
node --input-type=module - "$ROOT" <<'NODE'
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
|
|
11
|
+
const root = process.argv[2];
|
|
12
|
+
const { buildShardPlan, smokeChecks } = await import(
|
|
13
|
+
pathToFileURL(join(root, "mcp-memory-server", "scripts", "run-smoke-suite.mjs")));
|
|
14
|
+
const plan = buildShardPlan(3);
|
|
15
|
+
const flattened = plan.flatMap(({ checks }) => checks.map(({ name }) => name));
|
|
16
|
+
assert.equal(flattened.length, smokeChecks.length);
|
|
17
|
+
assert.equal(new Set(flattened).size, smokeChecks.length);
|
|
18
|
+
assert.deepEqual([...flattened].sort(), smokeChecks.map(({ name }) => name).sort());
|
|
19
|
+
assert.ok(plan.every(({ checks }) => checks.length > 0));
|
|
20
|
+
const weights = plan.map(({ weight }) => weight);
|
|
21
|
+
assert.ok(Math.max(...weights) - Math.min(...weights) <= 5,
|
|
22
|
+
`native Windows shards are imbalanced: ${weights.join(", ")}`);
|
|
23
|
+
|
|
24
|
+
const pkg = JSON.parse(readFileSync(join(root, "mcp-memory-server", "package.json"), "utf8"));
|
|
25
|
+
assert.match(pkg.scripts["test:smoke"], /run-smoke-suite\.mjs/);
|
|
26
|
+
const workflow = readFileSync(join(root, ".github", "workflows", "ci.yml"), "utf8");
|
|
27
|
+
assert.match(workflow, /name: native-windows \(\$\{\{ matrix\.node \}\}\)/);
|
|
28
|
+
for (const shard of ["1/3", "2/3", "3/3"]) assert.ok(workflow.includes(`shard: "${shard}"`));
|
|
29
|
+
assert.match(workflow, /run-smoke-suite\.mjs --shard \$\{\{ matrix\.shard \}\}/);
|
|
30
|
+
assert.doesNotMatch(workflow.match(/ native-windows:[\s\S]*?\n repository:/)?.[0] ?? "",
|
|
31
|
+
/npm --prefix mcp-memory-server test/);
|
|
32
|
+
console.log(`PASS: ${smokeChecks.length} memory-server contracts split once across balanced Windows shards (${weights.join(", ")})`);
|
|
33
|
+
NODE
|
|
@@ -22,6 +22,7 @@ tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
|
22
22
|
"$cairn" help | grep -q "cairn mcp-tools <list|status|set|reset>" || fail "help missing mcp-tools"
|
|
23
23
|
"$cairn" help | grep -q "cairn pack <init|lock|validate|install|list|show|remove|enable|disable|update|skills|approve-skill|revoke-skill>" || fail "help missing pack"
|
|
24
24
|
"$cairn" help | grep -q "cairn graph <build|query|status|diff|explain|path>" || fail "help missing graph"
|
|
25
|
+
"$cairn" help | grep -q "cairn security doctor" || fail "help missing security doctor"
|
|
25
26
|
"$cairn" sync --help >/dev/null 2>&1 || fail "cairn sync dispatch"
|
|
26
27
|
"$cairn" sync-pi --help >/dev/null 2>&1 || fail "cairn sync-pi dispatch"
|
|
27
28
|
"$cairn" sync-kimi --help >/dev/null 2>&1 || fail "cairn sync-kimi dispatch"
|
|
@@ -48,6 +49,20 @@ CAIRN_PACK_BASE_DIR="$tmp/pack-store" "$cairn" pack list --json >"$tmp/packs.jso
|
|
|
48
49
|
node -e 'const v=require(process.argv[1]);if(v.schema_version!==1||!Array.isArray(v.tools))process.exit(1)' "$tmp/mcp-tools.json" || fail "mcp-tools JSON contract"
|
|
49
50
|
node -e 'const v=require(process.argv[1]);if(v.schema_version!==1||!Array.isArray(v.packs))process.exit(1)' "$tmp/packs.json" || fail "pack JSON contract"
|
|
50
51
|
"$cairn" graph --help | grep -q "cairn graph explain <symbol>" || fail "cairn graph dispatch"
|
|
52
|
+
( cd "$tmp" && "$cairn" security doctor --json ) >"$tmp/security.json" || fail "cairn security doctor dispatch"
|
|
53
|
+
node -e 'const v=require(process.argv[1]);if(v.schema_version!==1||v.ok!==true||!Array.isArray(v.checks))process.exit(1)' "$tmp/security.json" || fail "security doctor JSON contract"
|
|
54
|
+
mkdir -m 700 "$tmp/security-project" "$tmp/security-project/.ai"
|
|
55
|
+
printf '%s\n' 'MCP_HTTP_PORT=7801' 'MCP_HTTP_HOST=0.0.0.0' 'CAIRN_MEMORY_HTTP_TOKEN=short' >"$tmp/security-project/.ai/.env"
|
|
56
|
+
chmod 600 "$tmp/security-project/.ai/.env"
|
|
57
|
+
set +e
|
|
58
|
+
env -u MCP_HTTP_PORT -u MCP_HTTP_HOST -u CAIRN_MEMORY_HTTP_TOKEN -u CAIRN_MEMORY_HTTP_TOKEN_FILE \
|
|
59
|
+
"$cairn" security doctor --project "$tmp/security-project" --json >"$tmp/security-project.json"
|
|
60
|
+
security_status=$?
|
|
61
|
+
set -e
|
|
62
|
+
[[ "$security_status" -eq 1 ]] || fail "project .ai/.env weak token should fail security doctor"
|
|
63
|
+
node -e 'const v=require(process.argv[1]);if(v.checks.find((x)=>x.id==="http-token")?.state!=="FAIL")process.exit(1)' "$tmp/security-project.json" || fail "security doctor ignored project .ai/.env"
|
|
64
|
+
CAIRN_MEMORY_HTTP_TOKEN=ambient-synthetic-token-0123456789abcdef \
|
|
65
|
+
"$cairn" security doctor --project "$tmp/security-project" --json >"$tmp/security-override.json" || fail "ambient environment should override project .ai/.env"
|
|
51
66
|
grep -qxF 'MCP capability changes require a memory-server restart. Operating capability' "$tmp/capabilities-help" || fail "capability help missing exact restart wording"
|
|
52
67
|
grep -qxF 'changes apply on the next invocation.' "$tmp/capabilities-help" || fail "capability help missing exact invocation wording"
|
|
53
68
|
if grep -Eq '^ cairn capabilities (guard|start|finish|doctor)' "$tmp/capabilities-help"; then
|
|
@@ -88,4 +103,4 @@ CAIRN_AGENTFS_BASE_DIR="$tmp/store" "$cairn" memory path | grep -qx "$tmp/store"
|
|
|
88
103
|
"$cairn" audit-timer --render-only "$tmp/u" >/dev/null || fail "cairn audit-timer dispatch"
|
|
89
104
|
[[ -f "$tmp/u/memory-wiki-audit.timer" ]] || fail "audit-timer render via cairn produced no units"
|
|
90
105
|
|
|
91
|
-
echo "PASS: cairn dispatch (doctor, memory, artifact, evidence, audit-timer)"
|
|
106
|
+
echo "PASS: cairn dispatch (doctor, security, memory, artifact, evidence, audit-timer)"
|
|
@@ -46,6 +46,8 @@ for shell in bash zsh fish; do
|
|
|
46
46
|
grep -q 'mcp-tools' "$tmp/$shell"
|
|
47
47
|
grep -q 'full.*read-only.*custom\|full read-only custom' "$tmp/$shell"
|
|
48
48
|
grep -q 'pack' "$tmp/$shell"
|
|
49
|
+
grep -q 'security' "$tmp/$shell"
|
|
50
|
+
grep -q -- '--project\|-l project' "$tmp/$shell"
|
|
49
51
|
grep -q 'init.*lock.*validate.*install.*list.*show.*remove.*enable.*disable.*update.*skills.*approve-skill.*revoke-skill.*doctor\|init lock validate install list show remove enable disable update skills approve-skill revoke-skill doctor' "$tmp/$shell"
|
|
50
52
|
grep -q -- '--repair\|-l repair' "$tmp/$shell"
|
|
51
53
|
grep -q 'proposals' "$tmp/$shell"
|
|
@@ -84,6 +86,7 @@ grep -qF 'cairn skill <harvest|list|show|review|propose|evaluate|apply|rollback>
|
|
|
84
86
|
grep -qF 'cairn evidence <list|show|delete|prune|doctor>' "$tmp/root-help"
|
|
85
87
|
grep -qF 'cairn playbook <list|status|init|set|enable|disable|reset|check|record|receipts|instructions|doctor>' "$tmp/root-help"
|
|
86
88
|
grep -qF 'cairn proposals <create|list|show|apply|doctor>' "$tmp/root-help"
|
|
89
|
+
grep -qF 'cairn security doctor [--project PATH] [--json]' "$tmp/root-help"
|
|
87
90
|
node "$ROOT/mcp-memory-server/dist/skill-cli.js" --help >"$tmp/skill-help"
|
|
88
91
|
for command in $skill_commands; do
|
|
89
92
|
grep -q "cairn skill $command" "$tmp/skill-help"
|
|
@@ -65,6 +65,13 @@ fi
|
|
|
65
65
|
grep -Fq "mode 400 or 600" "$tmp/err"
|
|
66
66
|
chmod 600 "$token"
|
|
67
67
|
|
|
68
|
+
env -u CAIRN_MEMORY_HTTP_TOKEN CAIRN_MEMORY_HTTP_TOKEN_FILE="$token" \
|
|
69
|
+
"$ROOT/containers/entrypoint.sh" sh -c \
|
|
70
|
+
'test "$CAIRN_MEMORY_HTTP_TOKEN" = token && test -z "${CAIRN_MEMORY_HTTP_TOKEN_FILE:-}"' || {
|
|
71
|
+
echo "FAIL: container entrypoint did not canonicalize the token source" >&2
|
|
72
|
+
exit 1
|
|
73
|
+
}
|
|
74
|
+
|
|
68
75
|
run_launcher workspace --repo "$repo" --image example/workspace:1 \
|
|
69
76
|
--workspace-volume example-workspace -- echo sandbox
|
|
70
77
|
has_arg "$repo:/source:ro,Z"
|
package/scripts/test-doctor.sh
CHANGED
|
@@ -92,13 +92,15 @@ const server = createServer((request, response) => {
|
|
|
92
92
|
request.on("end", () => {
|
|
93
93
|
let body = {};
|
|
94
94
|
try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch {}
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
const routeValid = request.url === "/v1/embeddings";
|
|
96
|
+
const keyValid = request.headers.authorization === "Bearer test-key";
|
|
97
|
+
const modelValid = body.model === "test-embedding";
|
|
98
|
+
const unauthorized = body.model === "unauthorized-model";
|
|
99
|
+
const status = routeValid && keyValid && modelValid ? 200 : unauthorized ? 403 : 404;
|
|
100
|
+
response.writeHead(status, { "Content-Type": "application/json" });
|
|
101
|
+
response.end(JSON.stringify(status === 200
|
|
100
102
|
? { data: [{ index: 0, embedding: [1, 0] }] }
|
|
101
|
-
: {
|
|
103
|
+
: { message: "private upstream detail must not leak", request_id: "private-request-id" }));
|
|
102
104
|
});
|
|
103
105
|
});
|
|
104
106
|
server.listen(0, "127.0.0.1", () => {
|
|
@@ -122,17 +124,33 @@ EOF
|
|
|
122
124
|
grep -q "\[PASS\] embedding endpoint accepted model test-embedding" "$tmp/out3" ||
|
|
123
125
|
fail "expected a functional embedding PASS line"
|
|
124
126
|
|
|
125
|
-
proj4="$tmp/embedding-
|
|
127
|
+
proj4="$tmp/embedding-unauthorized"; mkdir -p "$proj4/.ai"
|
|
126
128
|
cat > "$proj4/.ai/.env" <<EOF
|
|
127
129
|
CAIRN_MEMORY_EMBEDDING_URL=http://127.0.0.1:$port/v1
|
|
128
|
-
CAIRN_MEMORY_EMBEDDING_MODEL=
|
|
130
|
+
CAIRN_MEMORY_EMBEDDING_MODEL=unauthorized-model
|
|
129
131
|
CAIRN_LLM_API_KEY=test-key
|
|
130
132
|
EOF
|
|
131
133
|
if ( cd "$proj4" && "$doctor" ) >"$tmp/out4" 2>&1; then
|
|
132
|
-
fail "doctor should reject
|
|
134
|
+
fail "doctor should reject an unauthorized embedding request:\n$(cat "$tmp/out4")"
|
|
135
|
+
fi
|
|
136
|
+
grep -q "\[FAIL\] embedding authorization rejected (HTTP 403) for model unauthorized-model" "$tmp/out4" ||
|
|
137
|
+
fail "expected a sanitized embedding authorization diagnostic"
|
|
138
|
+
! grep -q "private upstream detail\|private-request-id" "$tmp/out4" ||
|
|
139
|
+
fail "embedding diagnostic leaked an upstream body or request id"
|
|
140
|
+
|
|
141
|
+
proj5="$tmp/embedding-missing-model"; mkdir -p "$proj5/.ai"
|
|
142
|
+
cat > "$proj5/.ai/.env" <<EOF
|
|
143
|
+
CAIRN_MEMORY_EMBEDDING_URL=http://127.0.0.1:$port/v1
|
|
144
|
+
CAIRN_MEMORY_EMBEDDING_MODEL=missing-model
|
|
145
|
+
CAIRN_LLM_API_KEY=test-key
|
|
146
|
+
EOF
|
|
147
|
+
if ( cd "$proj5" && "$doctor" ) >"$tmp/out5" 2>&1; then
|
|
148
|
+
fail "doctor should reject a missing embedding model:\n$(cat "$tmp/out5")"
|
|
133
149
|
fi
|
|
134
|
-
grep -q "\[FAIL\] embedding
|
|
135
|
-
fail "expected a
|
|
150
|
+
grep -q "\[FAIL\] embedding endpoint or model not found (HTTP 404) for model missing-model" "$tmp/out5" ||
|
|
151
|
+
fail "expected a sanitized missing embedding model diagnostic"
|
|
152
|
+
! grep -q "private upstream detail\|private-request-id" "$tmp/out5" ||
|
|
153
|
+
fail "missing-model diagnostic leaked an upstream body or request id"
|
|
136
154
|
fi
|
|
137
155
|
|
|
138
156
|
# 5. Artifact doctor passes a valid store, repairs only derived state, and
|