@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +51 -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 +44 -5
  25. package/docs/privacy-and-data-flow.md +14 -0
  26. package/docs/releasing.md +16 -1
  27. package/docs/security-assurance.md +130 -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 +2 -1
  41. package/package.json +3 -2
  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/doctor.sh +20 -4
  46. package/scripts/setup-reconcile.mjs +3 -0
  47. package/scripts/test-bootstrap-untracked.sh +2 -0
  48. package/scripts/test-ci-sharding.sh +33 -0
  49. package/scripts/test-cli-dispatch.sh +16 -1
  50. package/scripts/test-completion.sh +3 -0
  51. package/scripts/test-container-runner.sh +7 -0
  52. package/scripts/test-doctor.sh +29 -11
  53. package/scripts/test-launcher-env-precedence.sh +30 -0
  54. package/scripts/test-learning-docs.sh +19 -0
  55. package/scripts/test-security-baseline.sh +4 -0
  56. package/scripts/verify-security-baseline.mjs +79 -0
  57. package/scripts/windows-platform.mjs +4 -2
  58. package/templates/start-claude.sh.template +3 -0
  59. package/templates/start-codex.sh.template +3 -0
  60. package/templates/start-kimi.sh.template +3 -0
  61. package/templates/start-omp.sh.template +3 -0
  62. package/templates/start-opencode.sh.template +3 -0
  63. package/templates/start-pi.sh.template +3 -0
  64. 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 && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:memory-proposals && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli && npm run check:mcp-trust && npm run check:pi-mcp-bridge && npm run check:pi-bridge-env && npm run check:pi-bridge-child && npm run check:context-pack && npm run check:context-pack-retrieval && npm run check:context-pack-v216-compat && npm run check:context-usage && npm run check:domain-retrieval && npm run check:retrieval-benchmark && npm run check:work-evidence && npm run check:okf && npm run check:playbook",
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.20.1",
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
- "check:public": "scripts/verify-no-private-references.sh && scripts/verify-docs-parity.sh && npm run audit:runtime && npm run build:server && npm test",
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": {
@@ -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
@@ -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 ...]
@@ -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 -fsS -m 10 -o /dev/null \
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 2>&1
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 request failed for model ${CAIRN_MEMORY_EMBEDDING_MODEL} (${CAIRN_MEMORY_EMBEDDING_URL})"
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"
@@ -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 valid = request.url === "/v1/embeddings"
96
- && request.headers.authorization === "Bearer test-key"
97
- && body.model === "test-embedding";
98
- response.writeHead(valid ? 200 : 404, { "Content-Type": "application/json" });
99
- response.end(JSON.stringify(valid
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
- : { error: "invalid request" }));
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-wrong-model"; mkdir -p "$proj4/.ai"
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=missing-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 a missing embedding model:\n$(cat "$tmp/out4")"
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 request failed for model missing-model" "$tmp/out4" ||
135
- fail "expected a functional embedding FAIL line"
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