@cairnkeep/cli 2.20.0 → 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +6 -3
  3. package/SECURITY.md +13 -0
  4. package/containers/entrypoint.sh +2 -1
  5. package/docs/README.md +2 -0
  6. package/docs/learning/CURRICULUM-MAP.md +4 -2
  7. package/docs/learning/FEATURE-GUIDE.md +6 -1
  8. package/docs/learning/README.md +1 -0
  9. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  10. package/docs/learning/lessons/L01-safe-trial.md +1 -1
  11. package/docs/learning/lessons/L02-installation.md +1 -1
  12. package/docs/learning/lessons/L03-first-project.md +1 -1
  13. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  14. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  15. package/docs/learning/lessons/L21-context-packs.md +1 -1
  16. package/docs/learning/lessons/L22-native-windows.md +1 -1
  17. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  18. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  19. package/docs/learning/lessons/L25-playbooks.md +1 -1
  20. package/docs/learning/lessons/L26-context-intelligence.md +3 -0
  21. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  22. package/docs/learning/lessons/L28-security-assurance.md +104 -0
  23. package/docs/learning/video-scripts/V28-security-assurance.md +72 -0
  24. package/docs/operating.md +40 -5
  25. package/docs/privacy-and-data-flow.md +14 -0
  26. package/docs/releasing.md +9 -1
  27. package/docs/security-assurance.md +122 -0
  28. package/examples/eval/bundled-fake.json +1 -1
  29. package/mcp-memory-server/dist/artifact-schema.js +2 -7
  30. package/mcp-memory-server/dist/context-pack.js +152 -31
  31. package/mcp-memory-server/dist/eval-schema.js +13 -12
  32. package/mcp-memory-server/dist/http-security.js +104 -0
  33. package/mcp-memory-server/dist/index.js +50 -12
  34. package/mcp-memory-server/dist/okf.js +35 -9
  35. package/mcp-memory-server/dist/path-security.js +49 -0
  36. package/mcp-memory-server/dist/platform-security.js +154 -31
  37. package/mcp-memory-server/dist/project-environment.js +132 -0
  38. package/mcp-memory-server/dist/security-cli.js +46 -0
  39. package/mcp-memory-server/dist/security-doctor.js +211 -0
  40. package/mcp-memory-server/package.json +5 -4
  41. package/package.json +5 -4
  42. package/scripts/bootstrap.sh +1 -0
  43. package/scripts/cairn-cli.mjs +2 -0
  44. package/scripts/completion.sh +9 -3
  45. package/scripts/setup-reconcile.mjs +3 -0
  46. package/scripts/test-bootstrap-untracked.sh +2 -0
  47. package/scripts/test-cli-dispatch.sh +16 -1
  48. package/scripts/test-completion.sh +3 -0
  49. package/scripts/test-container-runner.sh +7 -0
  50. package/scripts/test-launcher-env-precedence.sh +30 -0
  51. package/scripts/test-learning-docs.sh +19 -0
  52. package/scripts/test-security-baseline.sh +4 -0
  53. package/scripts/verify-security-baseline.mjs +79 -0
  54. package/scripts/windows-platform.mjs +4 -2
  55. package/templates/start-claude.sh.template +3 -0
  56. package/templates/start-codex.sh.template +3 -0
  57. package/templates/start-kimi.sh.template +3 -0
  58. package/templates/start-omp.sh.template +3 -0
  59. package/templates/start-opencode.sh.template +3 -0
  60. package/templates/start-pi.sh.template +3 -0
  61. package/templates/start-qwen.sh.template +3 -0
@@ -0,0 +1,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 && 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 && npm run check:security-assurance",
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",
@@ -71,11 +72,11 @@
71
72
  "dependencies": {
72
73
  "@modelcontextprotocol/sdk": "^1.32.1",
73
74
  "agentfs-sdk": "^0.6.4",
74
- "yaml": "2.9.0",
75
- "zod": "^4.4.3"
75
+ "yaml": "2.9.1",
76
+ "zod": "^4.6.5"
76
77
  },
77
78
  "devDependencies": {
78
- "@types/node": "^22.20.1",
79
+ "@types/node": "^22.20.5",
79
80
  "typescript": "^7.0.2"
80
81
  }
81
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.20.0",
3
+ "version": "2.21.0",
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",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@modelcontextprotocol/sdk": "^1.32.1",
42
42
  "agentfs-sdk": "^0.6.4",
43
- "yaml": "2.9.0",
44
- "zod": "^4.4.3"
43
+ "yaml": "2.9.1",
44
+ "zod": "^4.6.5"
45
45
  },
46
46
  "scripts": {
47
47
  "build:server": "npm --prefix mcp-memory-server ci --include=dev --global=false --dry-run=false && npm --prefix mcp-memory-server run build --global=false --dry-run=false",
@@ -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
@@ -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.
@@ -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"
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
5
+ tmp=$(mktemp -d)
6
+ trap 'rm -rf "$tmp"' EXIT
7
+
8
+ project="$tmp/project"
9
+ fake_bin="$tmp/bin"
10
+ mkdir -p "$project/.ai" "$fake_bin"
11
+ cp "$ROOT/templates/start-codex.sh.template" "$project/.ai/start-codex.sh"
12
+ chmod +x "$project/.ai/start-codex.sh"
13
+ cat > "$project/.ai/.env" <<'EOF'
14
+ CAIRN_PRECEDENCE_TEST=file
15
+ CAIRN_FILE_ONLY=file-only
16
+ EOF
17
+ chmod 600 "$project/.ai/.env"
18
+ cat > "$fake_bin/codex" <<'EOF'
19
+ #!/usr/bin/env bash
20
+ printf '%s|%s\n' "$CAIRN_PRECEDENCE_TEST" "$CAIRN_FILE_ONLY"
21
+ EOF
22
+ chmod +x "$fake_bin/codex"
23
+
24
+ output=$(PATH="$fake_bin:$PATH" CAIRN_PRECEDENCE_TEST=ambient "$project/.ai/start-codex.sh")
25
+ [[ "$output" == "ambient|file-only" ]] || {
26
+ echo "FAIL: launcher environment precedence drifted: $output" >&2
27
+ exit 1
28
+ }
29
+
30
+ echo "PASS: POSIX launcher keeps ambient overrides and adds project-only values"
@@ -166,4 +166,23 @@ grep -qF 'course-13-playbooks' "$coverage"
166
166
  grep -qF 'L25-playbooks.md' docs/learning/tracks/practitioner.md
167
167
  grep -qF 'L25-playbooks.md' docs/learning/tracks/operator.md
168
168
 
169
+ l28=docs/learning/lessons/L28-security-assurance.md
170
+ video28=docs/learning/video-scripts/V28-security-assurance.md
171
+ for file in "$l28" "$video28" docs/security-assurance.md; do
172
+ [[ -f "$file" ]]
173
+ done
174
+ grep -qF "Tested with:** Cairnkeep $version" "$l28"
175
+ for phrase in \
176
+ 'cairn security doctor --project' \
177
+ 'npm run security:baseline' \
178
+ 'check:security-assurance' \
179
+ 'check:http-guard' \
180
+ 'not a certification'; do
181
+ grep -qF -- "$phrase" "$l28" || {
182
+ echo "L28 missing security-assurance boundary: $phrase" >&2
183
+ exit 1
184
+ }
185
+ done
186
+ grep -qF '`cairn security` | L28' "$coverage"
187
+
169
188
  echo "PASS: public learning path structure, readiness, links, and version alignment"
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
4
+ node "$ROOT/scripts/verify-security-baseline.mjs"