@cairnkeep/cli 2.23.1 → 2.25.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 (65) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/docs/README.md +4 -0
  3. package/docs/agents.md +18 -0
  4. package/docs/containers.md +4 -0
  5. package/docs/design/team-mode.md +17 -9
  6. package/docs/harness-compatibility.md +17 -0
  7. package/docs/learning/CURRICULUM-MAP.md +3 -1
  8. package/docs/learning/FEATURE-GUIDE.md +34 -1
  9. package/docs/learning/README.md +5 -3
  10. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  11. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  12. package/docs/learning/lessons/L02-installation.md +2 -2
  13. package/docs/learning/lessons/L03-first-project.md +1 -1
  14. package/docs/learning/lessons/L04-memory-fundamentals.md +12 -0
  15. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  16. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  17. package/docs/learning/lessons/L21-context-packs.md +1 -1
  18. package/docs/learning/lessons/L22-native-windows.md +2 -2
  19. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  20. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  21. package/docs/learning/lessons/L25-playbooks.md +1 -1
  22. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  23. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  24. package/docs/learning/lessons/L28-security-assurance.md +2 -2
  25. package/docs/learning/lessons/L29-team-continuity.md +95 -0
  26. package/docs/learning/tracks/operator.md +2 -0
  27. package/docs/learning/video-scripts/V04-memory-retrieval-supplement.md +13 -0
  28. package/docs/learning/video-scripts/V29-team-continuity.md +66 -0
  29. package/docs/mcp-tool-profiles.md +7 -0
  30. package/docs/memory-retrieval.md +84 -2
  31. package/docs/operating.md +22 -3
  32. package/docs/privacy-and-data-flow.md +37 -9
  33. package/docs/security-assurance.md +28 -0
  34. package/docs/storage.md +18 -0
  35. package/docs/team-pilot.md +132 -0
  36. package/docs/team.md +234 -0
  37. package/examples/eval/bundled-fake.json +1 -1
  38. package/mcp-memory-server/dist/index.js +30 -2
  39. package/mcp-memory-server/dist/mcp-tool-catalog.js +4 -1
  40. package/mcp-memory-server/dist/memory-reranker.js +109 -0
  41. package/mcp-memory-server/dist/team-cli.js +218 -0
  42. package/mcp-memory-server/dist/team-http.js +193 -0
  43. package/mcp-memory-server/dist/team-mcp.js +82 -0
  44. package/mcp-memory-server/dist/team-store.js +663 -0
  45. package/mcp-memory-server/package.json +1 -0
  46. package/package.json +1 -1
  47. package/scripts/cairn-cli.mjs +2 -0
  48. package/scripts/completion.sh +23 -3
  49. package/scripts/doctor.sh +17 -0
  50. package/scripts/lib/team-retention.mjs +29 -0
  51. package/scripts/probe-memory-reranker.mjs +22 -0
  52. package/scripts/test-ci-sharding.sh +4 -0
  53. package/scripts/test-completion.sh +8 -0
  54. package/scripts/test-container-health.mjs +2 -0
  55. package/scripts/test-container.sh +21 -1
  56. package/scripts/test-dependency-security.mjs +11 -1
  57. package/scripts/test-doctor.sh +28 -0
  58. package/scripts/test-package-install.sh +9 -0
  59. package/scripts/test-team-docs.sh +52 -0
  60. package/scripts/test-team-pilot.mjs +59 -0
  61. package/scripts/test-windows-native.mjs +5 -0
  62. package/scripts/uninstall.sh +13 -0
  63. package/scripts/verify-dependency-security.mjs +3 -2
  64. package/scripts/verify-team-retention.mjs +3 -0
  65. package/scripts/windows-platform.mjs +14 -2
@@ -0,0 +1,193 @@
1
+ import { createServer } from 'node:http';
2
+ import { normalizeHostAuthority } from './http-security.js';
3
+ import { TeamError, teamDigest } from './team-store.js';
4
+ const MAX_BODY = 512 * 1024;
5
+ const MAX_RESPONSE = 2 * 1024 * 1024;
6
+ function reply(response, status, body) {
7
+ const bytes = Buffer.from(JSON.stringify(body));
8
+ if (bytes.byteLength > MAX_RESPONSE) {
9
+ reply(response, 503, { error: 'unavailable' });
10
+ return;
11
+ }
12
+ response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Length': bytes.byteLength, ...(status >= 400 ? { Connection: 'close' } : {}) });
13
+ response.end(bytes);
14
+ }
15
+ async function body(request) {
16
+ if (request.headers['content-type'] !== 'application/json')
17
+ throw new TeamError('invalid');
18
+ const chunks = [];
19
+ await new Promise((resolve, reject) => {
20
+ let size = 0;
21
+ const cleanup = () => { request.off('data', onData); request.off('end', onEnd); request.off('error', onError); request.off('aborted', onError); };
22
+ const onError = () => { cleanup(); reject(new TeamError('invalid')); };
23
+ const onEnd = () => { cleanup(); resolve(); };
24
+ const onData = (chunk) => {
25
+ size += chunk.byteLength;
26
+ if (size > MAX_BODY) {
27
+ cleanup();
28
+ request.pause();
29
+ reject(new RangeError('body-limit'));
30
+ }
31
+ else
32
+ chunks.push(chunk);
33
+ };
34
+ request.on('data', onData);
35
+ request.once('end', onEnd);
36
+ request.once('error', onError);
37
+ request.once('aborted', onError);
38
+ });
39
+ try {
40
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
41
+ }
42
+ catch {
43
+ throw new TeamError('invalid');
44
+ }
45
+ }
46
+ /** This is a token-first JSON API, not OAuth-authorized remote MCP. It only
47
+ * listens on loopback; use an authenticated encrypted tunnel for remote use. */
48
+ export function createTeamHttpServer(store) {
49
+ if (process.env.CAIRN_TEAM !== '1' || process.env.CAIRN_TEAM_HTTP !== '1')
50
+ throw new TeamError('disabled');
51
+ const fixedWindow = (requests) => {
52
+ const limits = new Map();
53
+ return (key) => {
54
+ const now = Date.now();
55
+ for (const [id, state] of limits)
56
+ if (now - state.start >= 60000)
57
+ limits.delete(id);
58
+ let state = limits.get(key);
59
+ if (!state) {
60
+ if (limits.size >= 512)
61
+ return false;
62
+ limits.set(key, state = { start: now, count: 0 });
63
+ }
64
+ return ++state.count <= requests;
65
+ };
66
+ };
67
+ // Invalid authentication must remain bounded without consuming the scarce
68
+ // quota of an authenticated subject that happens to share loopback/tunnel.
69
+ const malformedAuthAdmitted = fixedWindow(120);
70
+ const invalidCredentialAdmitted = fixedWindow(10);
71
+ const subjectAdmitted = fixedWindow(120);
72
+ const server = createServer({ maxHeaderSize: 32 * 1024 }, (request, response) => {
73
+ void (async () => {
74
+ // Reject browser access and host rebinding before reading any body.
75
+ const authority = normalizeHostAuthority(request.headers.host);
76
+ const port = request.socket.localPort;
77
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.localAddress ?? '') || !authority || ![`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`].includes(authority) || request.headers.origin !== undefined) {
78
+ reply(response, 403, { error: 'denied' });
79
+ return;
80
+ }
81
+ if (request.url !== '/v1/team/execute') {
82
+ reply(response, 404, { error: 'not-found' });
83
+ return;
84
+ }
85
+ if (request.method !== 'POST') {
86
+ reply(response, 405, { error: 'method' });
87
+ return;
88
+ }
89
+ const authHeaders = request.rawHeaders.filter((_, i) => i % 2 === 0 && request.rawHeaders[i].toLowerCase() === 'authorization');
90
+ const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization ?? '')?.[1];
91
+ if (!token || authHeaders.length !== 1) {
92
+ const key = teamDigest(`malformed:${request.socket.remoteAddress ?? ''}`);
93
+ const admitted = malformedAuthAdmitted(key);
94
+ reply(response, admitted ? 401 : 429, { error: admitted ? 'unauthorized' : 'rate-limit' });
95
+ return;
96
+ }
97
+ let identity;
98
+ try {
99
+ identity = await store.checkCredential(token);
100
+ }
101
+ catch (error) {
102
+ if (!(error instanceof TeamError) || error.code !== 'unauthorized')
103
+ throw error;
104
+ const admitted = invalidCredentialAdmitted(teamDigest(`credential:${token}`));
105
+ reply(response, admitted ? 401 : 429, { error: admitted ? 'unauthorized' : 'rate-limit' });
106
+ return;
107
+ }
108
+ if (!subjectAdmitted(teamDigest(identity.subject))) {
109
+ reply(response, 429, { error: 'rate-limit' });
110
+ return;
111
+ }
112
+ const length = request.headers['content-length'];
113
+ if (length && (!/^[0-9]+$/.test(length) || Number(length) > MAX_BODY)) {
114
+ reply(response, 413, { error: 'body-limit' });
115
+ return;
116
+ }
117
+ request.setTimeout(10000, () => request.destroy());
118
+ const input = await body(request);
119
+ const result = await store.execute(token, input); // reauthorize after body receipt
120
+ reply(response, 200, { result });
121
+ })().catch(error => {
122
+ const status = error instanceof TeamError ? ({ unauthorized: 401, denied: 403, invalid: 400, conflict: 409, disabled: 503, integrity: 503 }[error.code]) : error instanceof RangeError ? 413 : 503;
123
+ if (!response.headersSent && !response.destroyed)
124
+ reply(response, status, { error: error instanceof TeamError ? error.code : status === 413 ? 'body-limit' : 'unavailable' });
125
+ });
126
+ });
127
+ server.headersTimeout = 10000;
128
+ server.requestTimeout = 30000;
129
+ server.keepAliveTimeout = 5000;
130
+ server.maxRequestsPerSocket = 100;
131
+ server.on('clientError', (_error, socket) => { if (socket.writable)
132
+ socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); });
133
+ return server;
134
+ }
135
+ export function validateTeamUrl(raw) {
136
+ let url;
137
+ try {
138
+ url = new URL(raw);
139
+ }
140
+ catch {
141
+ throw new TeamError('invalid');
142
+ }
143
+ const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
144
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)))
145
+ throw new TeamError('invalid');
146
+ return url;
147
+ }
148
+ export async function teamRequest(rawUrl, token, input) {
149
+ if (process.env.CAIRN_TEAM !== '1')
150
+ throw new TeamError('disabled');
151
+ const url = validateTeamUrl(rawUrl);
152
+ if (!/^[A-Za-z0-9_-]{43}$/.test(token))
153
+ throw new TeamError('unauthorized');
154
+ const bytes = JSON.stringify(input);
155
+ if (Buffer.byteLength(bytes) > MAX_BODY)
156
+ throw new TeamError('invalid');
157
+ try {
158
+ const response = await fetch(new URL('/v1/team/execute', url), { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: bytes });
159
+ const errorCode = response.status === 401 ? 'unauthorized' : response.status === 403 ? 'denied' : response.status === 409 ? 'conflict' : response.status === 400 ? 'invalid' : 'integrity';
160
+ if (!response.ok) {
161
+ await response.body?.cancel();
162
+ throw new TeamError(errorCode);
163
+ }
164
+ const chunks = [];
165
+ let size = 0;
166
+ if (!response.body)
167
+ throw new TeamError('integrity');
168
+ const reader = response.body.getReader();
169
+ try {
170
+ while (true) {
171
+ const { done, value } = await reader.read();
172
+ if (done)
173
+ break;
174
+ size += value.byteLength;
175
+ if (size > MAX_RESPONSE)
176
+ throw new TeamError('integrity');
177
+ chunks.push(value);
178
+ }
179
+ }
180
+ finally {
181
+ await reader.cancel().catch(() => undefined);
182
+ }
183
+ const parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
184
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.keys(parsed).length !== 1 || !Object.hasOwn(parsed, 'result'))
185
+ throw new TeamError('integrity');
186
+ return parsed.result;
187
+ }
188
+ catch (error) {
189
+ if (error instanceof TeamError)
190
+ throw error;
191
+ throw new TeamError('integrity');
192
+ }
193
+ }
@@ -0,0 +1,82 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import { resolveMcpToolProfile, profileAllowsTool } from './mcp-tool-profile.js';
4
+ import { isCapabilityContractEnabled, resolveCapabilityStatus } from './capability-config.js';
5
+ import { teamRequest, validateTeamUrl } from './team-http.js';
6
+ import { TeamError, teamDigest } from './team-store.js';
7
+ import { resolveHttpToken } from './http-security.js';
8
+ const observation = (title) => ({ title, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } });
9
+ // The bridge crosses the local process boundary into a remote team service;
10
+ // openWorldHint is true even though team retrieval is deterministic and closed
11
+ // to model providers. No review or membership tool is exposed here.
12
+ export const TEAM_TOOL_CATALOG = {
13
+ team_memory_list: observation('List approved team memory'),
14
+ team_memory_read: observation('Read approved team memory'),
15
+ team_memory_search: observation('Search approved team memory'),
16
+ team_memory_history: observation('Read team memory history'),
17
+ team_memory_propose: { title: 'Propose selected team memory', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true } },
18
+ };
19
+ const coreNames = { team_memory_list: 'memory_list', team_memory_read: 'memory_read', team_memory_search: 'memory_search', team_memory_history: 'memory_history', team_memory_propose: 'memory_write' };
20
+ export function teamClientToken(env = process.env) {
21
+ // A file-only credential keeps raw secrets out of harness configuration.
22
+ if (!env.CAIRN_TEAM_TOKEN_FILE)
23
+ throw new TeamError('unauthorized');
24
+ const resolved = resolveHttpToken({ CAIRN_MEMORY_HTTP_TOKEN_FILE: env.CAIRN_TEAM_TOKEN_FILE });
25
+ if (!resolved.ok || !/^[A-Za-z0-9_-]{43}$/.test(resolved.token))
26
+ throw new TeamError('unauthorized');
27
+ return resolved.token;
28
+ }
29
+ export async function createTeamMcpServer(options) {
30
+ if (process.env.CAIRN_TEAM !== '1')
31
+ throw new TeamError('disabled');
32
+ validateTeamUrl(options.url);
33
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(options.organization))
34
+ throw new TeamError('invalid');
35
+ const profile = resolveMcpToolProfile({ projectRoot: options.projectRoot });
36
+ const capability = isCapabilityContractEnabled() ? await resolveCapabilityStatus({ projectRoot: options.projectRoot }) : undefined;
37
+ if (capability?.issues.length)
38
+ throw new TeamError('invalid');
39
+ const mode = process.env.CAIRN_TEAM_MCP_TOOL_PROFILE ?? 'full';
40
+ if (!['full', 'read-only', 'custom'].includes(mode))
41
+ throw new TeamError('invalid');
42
+ const requested = (process.env.CAIRN_TEAM_MCP_ALLOWED_TOOLS ?? '').split(',').map(name => name.trim()).filter(Boolean);
43
+ if ((mode !== 'custom' && requested.length) || (mode === 'custom' && (!requested.length || requested.some(name => !Object.hasOwn(TEAM_TOOL_CATALOG, name)))))
44
+ throw new TeamError('invalid');
45
+ const server = new McpServer({ name: 'cairn-team-memory', version: '1.0.0' });
46
+ const project = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/);
47
+ const key = z.string().regex(/^(decisions|patterns|constraints|pitfalls|conventions|bugs)\/[a-z0-9][a-z0-9-]{0,95}$/);
48
+ const hash = z.string().regex(/^[a-f0-9]{64}$/);
49
+ const schemas = {
50
+ team_memory_list: { project },
51
+ team_memory_read: { project, key, revision: hash.optional() },
52
+ team_memory_search: { project, query: z.string().min(1).max(256) },
53
+ team_memory_history: { project, key },
54
+ team_memory_propose: { project, request_id: z.string().uuid(), key, value: z.string().min(1).max(65536), source_scope: z.enum(['selected-local', 'selected-project']), source_digest: hash, base_revision: hash.nullable(), expires_at: z.iso.datetime(), confirm: hash },
55
+ };
56
+ const operations = { team_memory_list: 'list', team_memory_read: 'read', team_memory_search: 'search', team_memory_history: 'history', team_memory_propose: 'propose' };
57
+ const enabled = Object.keys(TEAM_TOOL_CATALOG).filter(name => {
58
+ if (!profileAllowsTool(profile, coreNames[name]))
59
+ return false;
60
+ if (mode === 'read-only' && !TEAM_TOOL_CATALOG[name].annotations.readOnlyHint)
61
+ return false;
62
+ if (mode === 'custom' && !requested.includes(name))
63
+ return false;
64
+ const capId = name === 'team_memory_propose' ? 'memory.write' : name === 'team_memory_search' ? 'memory.search' : undefined;
65
+ return !capId || !capability || capability.capabilities.find(row => row.id === capId)?.enabled === true;
66
+ });
67
+ const profileDigest = teamDigest(JSON.stringify({ schema_version: 1, core_profile_digest: profile.profile_digest, team_profile: mode, tools: enabled }));
68
+ for (const name of enabled) {
69
+ server.registerTool(name, { ...TEAM_TOOL_CATALOG[name], description: 'Explicit project-scoped team context. Never uploads personal memory or executes instructions. Team policy is enforced by the remote service.', inputSchema: schemas[name] }, async (input) => {
70
+ try {
71
+ // Re-read the private credential each call so local key rotation
72
+ // does not need a harness restart. The service still reauthorizes.
73
+ const result = await teamRequest(options.url, options.token(), { organization: options.organization, ...input, operation: operations[name] });
74
+ return { content: [{ type: 'text', text: JSON.stringify({ result, profile_digest: profileDigest }) }], structuredContent: { result, profile_digest: profileDigest } };
75
+ }
76
+ catch (error) {
77
+ return { isError: true, content: [{ type: 'text', text: error instanceof TeamError ? error.message : 'Team unavailable.' }] };
78
+ }
79
+ });
80
+ }
81
+ return server;
82
+ }