@cairnkeep/cli 2.25.0 → 2.27.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 (66) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/docs/README.md +6 -0
  3. package/docs/harness-compatibility.md +5 -2
  4. package/docs/learning/CURRICULUM-MAP.md +3 -3
  5. package/docs/learning/FEATURE-GUIDE.md +13 -1
  6. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  7. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  8. package/docs/learning/lessons/L02-installation.md +2 -2
  9. package/docs/learning/lessons/L03-first-project.md +1 -1
  10. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  11. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  12. package/docs/learning/lessons/L21-context-packs.md +1 -1
  13. package/docs/learning/lessons/L22-native-windows.md +2 -2
  14. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  15. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  16. package/docs/learning/lessons/L25-playbooks.md +1 -1
  17. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  18. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  19. package/docs/learning/lessons/L28-security-assurance.md +5 -1
  20. package/docs/learning/lessons/L29-team-continuity.md +42 -1
  21. package/docs/learning/tracks/operator.md +5 -0
  22. package/docs/learning/video-scripts/V28-security-assurance.md +4 -0
  23. package/docs/learning/video-scripts/V29-team-continuity.md +29 -1
  24. package/docs/operating.md +10 -0
  25. package/docs/privacy-and-data-flow.md +30 -1
  26. package/docs/provider-data-flow-review.md +48 -0
  27. package/docs/releasing.md +6 -0
  28. package/docs/security-assurance.md +11 -0
  29. package/docs/storage.md +23 -0
  30. package/docs/team-onboarding.md +230 -0
  31. package/docs/team-operations.md +127 -0
  32. package/docs/team-pilot.md +2 -0
  33. package/docs/team.md +21 -4
  34. package/examples/eval/bundled-fake.json +1 -1
  35. package/mcp-memory-server/dist/capability-config.js +12 -1
  36. package/mcp-memory-server/dist/team-audit.js +41 -0
  37. package/mcp-memory-server/dist/team-cli.js +81 -30
  38. package/mcp-memory-server/dist/team-client.js +71 -0
  39. package/mcp-memory-server/dist/team-errors.js +7 -0
  40. package/mcp-memory-server/dist/team-files.js +28 -0
  41. package/mcp-memory-server/dist/team-mcp.js +12 -4
  42. package/mcp-memory-server/dist/team-pages.js +40 -0
  43. package/mcp-memory-server/dist/team-store.js +105 -40
  44. package/mcp-memory-server/package.json +2 -2
  45. package/package.json +1 -1
  46. package/scripts/cairn-cli.mjs +6 -1
  47. package/scripts/completion.sh +16 -3
  48. package/scripts/lib/team-retention.mjs +1 -1
  49. package/scripts/team-connect.mjs +366 -0
  50. package/scripts/test-ci-sharding.sh +6 -1
  51. package/scripts/test-codeql-cohort.mjs +35 -0
  52. package/scripts/test-completion.sh +7 -1
  53. package/scripts/test-package-install.sh +1 -0
  54. package/scripts/test-phase18-capability-lifecycle.sh +2 -2
  55. package/scripts/test-release-cache.mjs +16 -0
  56. package/scripts/test-security-baseline.sh +4 -0
  57. package/scripts/test-smoke-timings.mjs +19 -0
  58. package/scripts/test-team-docs.sh +26 -2
  59. package/scripts/test-team-installed.mjs +62 -0
  60. package/scripts/test-team-lab-env.mjs +9 -0
  61. package/scripts/test-team-operations.mjs +31 -0
  62. package/scripts/test-team-operations.sh +5 -0
  63. package/scripts/test-team-pilot.mjs +43 -1
  64. package/scripts/verify-published-release.mjs +11 -2
  65. package/scripts/verify-security-baseline.mjs +20 -0
  66. package/scripts/windows-platform.mjs +1 -1
@@ -1,12 +1,15 @@
1
- import { closeSync, existsSync, openSync, writeFileSync, fsyncSync } from 'node:fs';
2
- import { dirname, join, resolve } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
5
  import { readStableJson } from './stable-file.js';
6
- import { hardenPrivatePath } from './platform-security.js';
7
- import { openTeamStore, restoreTeamSnapshot, verifyTeamSnapshot, prepareTeamPrivateDirectory, TEAM_SNAPSHOT_MAX_BYTES, TeamError } from './team-store.js';
6
+ import { openTeamStore, restoreTeamSnapshot, verifyTeamSnapshot, TEAM_SNAPSHOT_MAX_BYTES, TeamError } from './team-store.js';
7
+ import { writePrivateExclusive } from './team-files.js';
8
+ export { writePrivateExclusive } from './team-files.js';
8
9
  import { createTeamHttpServer, teamRequest } from './team-http.js';
9
10
  import { createTeamMcpServer, teamClientToken } from './team-mcp.js';
11
+ import { readTeamConnection, connectionToken } from './team-client.js';
12
+ import { verifyTeamAuditArchive } from './team-audit.js';
10
13
  const HELP = `cairn team — experimental token-first team continuity (default off)
11
14
 
12
15
  CAIRN_TEAM=1 is required. Personal memory is never uploaded.
@@ -18,8 +21,11 @@ CAIRN_TEAM=1 is required. Personal memory is never uploaded.
18
21
  cairn team credential issue SUBJECT --class human|workload --expires-at ISO --output FILE [--data DIR]
19
22
  cairn team credential revoke ID [--data DIR]
20
23
  cairn team serve [--data DIR] [--port PORT] # requires CAIRN_TEAM_HTTP=1; loopback only
21
- cairn team mcp [--project-root PATH] # local stdio MCP; no review/admin tools
22
- cairn team list --project ID
24
+ cairn team connect [--project-root PATH] [--url URL --organization ID --project ID --token-file FILE --harness LIST] [--access read-only|full] [--apply --yes]
25
+ cairn team disconnect [--project-root PATH] [--apply --yes]
26
+ cairn team doctor --client [--project-root PATH]
27
+ cairn team mcp [--project-root PATH | --connection FILE] # local stdio MCP; no review/admin tools
28
+ cairn team list --project ID [--page-size N --cursor CURSOR]
23
29
  cairn team proposals --project ID
24
30
  cairn team audit --project ID # scoped, value-free metadata
25
31
  cairn team read --project ID --key KEY [--revision DIGEST]
@@ -28,8 +34,11 @@ CAIRN_TEAM=1 is required. Personal memory is never uploaded.
28
34
  cairn team proposal-show --project ID --proposal ID
29
35
  cairn team propose --project ID --input FILE --confirm VALUE_DIGEST
30
36
  cairn team review --project ID --proposal ID --decision approve|reject --confirm PROPOSAL_DIGEST
31
- cairn team doctor [--data DIR]
32
- cairn team backup --output FILE [--data DIR]
37
+ cairn team doctor [--data DIR] # capacity and audit checkpoint
38
+ cairn team backup --output FILE [--read-only] [--data DIR]
39
+ cairn team audit-export --backup FILE --output FILE [--data DIR]
40
+ cairn team audit-verify --input FILE [--previous FILE]
41
+ cairn team audit-archive --input FILE --backup FILE --confirm DIGEST [--apply] [--data DIR]
33
42
  cairn team restore --input FILE --confirm SNAPSHOT_DIGEST --data NEW_DIR
34
43
  cairn team memory-delete --project ID --key KEY --confirm REVISION_DIGEST [--data DIR]
35
44
 
@@ -37,15 +46,25 @@ Clients require CAIRN_TEAM_URL, CAIRN_TEAM_ORGANIZATION and CAIRN_TEAM_TOKEN_FIL
37
46
  Never put tokens in command arguments, source trees or harness config.
38
47
  Team API is not OAuth MCP HTTP. Independent production security review and a
39
48
  deployment-specific approved real-data pilot remain admission gates. See docs/team.md.
49
+ Paging is optional for list/search/history/proposals/audit. Repeat filters and
50
+ page size with the returned next_cursor. Search pages are at most ten items.
51
+ Audit export is metadata-only and does not mutate the store. Archival defaults
52
+ to a no-write plan, retains files, and explicitly upgrades the team store to v2.
40
53
  `;
41
54
  function parse(args) {
42
55
  const words = [];
43
56
  const flags = {};
44
- const known = new Set(['data', 'organization', 'project', 'roles', 'class', 'expires-at', 'output', 'port', 'project-root', 'key', 'revision', 'query', 'proposal', 'input', 'confirm', 'decision']);
57
+ const known = new Set(['data', 'organization', 'project', 'roles', 'class', 'expires-at', 'output', 'port', 'project-root', 'key', 'revision', 'query', 'proposal', 'input', 'confirm', 'decision', 'connection', 'backup', 'apply', 'read-only', 'page-size', 'cursor', 'previous']);
45
58
  for (let i = 0; i < args.length; i++) {
46
59
  const arg = args[i];
47
60
  if (arg.startsWith('--')) {
48
61
  const name = arg.slice(2);
62
+ if (['apply', 'read-only'].includes(name)) {
63
+ if (flags[name] !== undefined)
64
+ throw new TeamError('invalid');
65
+ flags[name] = 'true';
66
+ continue;
67
+ }
49
68
  if (!known.has(name) || flags[name] !== undefined || !args[i + 1] || args[i + 1].startsWith('--'))
50
69
  throw new TeamError('invalid');
51
70
  flags[name] = args[++i];
@@ -55,20 +74,6 @@ function parse(args) {
55
74
  }
56
75
  return { words, flags };
57
76
  }
58
- export function writePrivateExclusive(path, bytes) {
59
- path = resolve(path);
60
- const directory = dirname(path);
61
- prepareTeamPrivateDirectory(directory, true);
62
- const fd = openSync(path, 'wx', 0o600);
63
- try {
64
- hardenPrivatePath(path);
65
- writeFileSync(fd, bytes, 'utf8');
66
- fsyncSync(fd);
67
- }
68
- finally {
69
- closeSync(fd);
70
- }
71
- }
72
77
  async function main(args) {
73
78
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
74
79
  console.log(HELP);
@@ -81,10 +86,12 @@ async function main(args) {
81
86
  const allowed = {
82
87
  init: ['data', 'organization'], project: ['data'], member: subcommand === 'remove' ? ['data', 'project'] : ['data', 'project', 'roles'],
83
88
  credential: subcommand === 'issue' ? ['data', 'class', 'expires-at', 'output'] : ['data'],
84
- serve: ['data', 'port'], mcp: ['project-root'], doctor: ['data'], backup: ['data', 'output'],
89
+ serve: ['data', 'port'], mcp: ['project-root', 'connection'], doctor: ['data'], backup: ['data', 'output', 'read-only'],
90
+ 'audit-export': ['data', 'backup', 'output'], 'audit-archive': ['data', 'backup', 'input', 'confirm', 'apply'],
91
+ 'audit-verify': ['input', 'previous'],
85
92
  restore: ['data', 'input', 'confirm'], 'memory-delete': ['data', 'project', 'key', 'confirm'],
86
- list: ['project'], proposals: ['project'], audit: ['project'], read: ['project', 'key', 'revision'],
87
- history: ['project', 'key'], search: ['project', 'query'], 'proposal-show': ['project', 'proposal'],
93
+ list: ['project', 'page-size', 'cursor'], proposals: ['project', 'page-size', 'cursor'], audit: ['project', 'page-size', 'cursor'], read: ['project', 'key', 'revision'],
94
+ history: ['project', 'key', 'page-size', 'cursor'], search: ['project', 'query', 'page-size', 'cursor'], 'proposal-show': ['project', 'proposal'],
88
95
  propose: ['project', 'input', 'confirm'], review: ['project', 'proposal', 'decision', 'confirm'],
89
96
  };
90
97
  if (!allowed[command] || Object.keys(flags).some(flag => !allowed[command].includes(flag)))
@@ -96,11 +103,30 @@ async function main(args) {
96
103
  const print = (result) => console.log(JSON.stringify(result, null, 2));
97
104
  let store;
98
105
  try {
106
+ if (command === 'audit-verify') {
107
+ if (words.length !== 1 || !flags.input)
108
+ throw new TeamError('invalid');
109
+ const load = (path) => verifyTeamAuditArchive(readStableJson(resolve(path), { label: 'Team audit archive', maxBytes: TEAM_SNAPSHOT_MAX_BYTES, private: true }));
110
+ const archive = load(flags.input);
111
+ if (flags.previous) {
112
+ const previous = load(flags.previous), last = previous.entries[previous.entries.length - 1], checkpoint = archive.previous_checkpoint;
113
+ if (previous.organization !== archive.organization || !checkpoint || checkpoint.archive_digest !== previous.digest || checkpoint.backup_digest !== previous.backup_digest || checkpoint.sequence !== last.sequence || checkpoint.digest !== last.digest || checkpoint.previous_archive_digest !== (previous.previous_checkpoint?.archive_digest ?? null))
114
+ throw new TeamError('integrity');
115
+ }
116
+ print({ verified: true, archive_digest: archive.digest, events: archive.entries.length, first_sequence: archive.entries[0].sequence, last_sequence: archive.entries[archive.entries.length - 1].sequence, previous_archive_digest: archive.previous_checkpoint?.archive_digest ?? null, previous_link_verified: !!flags.previous, history_scope: 'single-segment-and-optional-previous-link' });
117
+ return;
118
+ }
99
119
  if (command === 'mcp') {
100
120
  if (words.length !== 1)
101
121
  throw new TeamError('invalid');
102
- teamClientToken(); // validate private credential before startup
103
- const server = await createTeamMcpServer({ url: process.env.CAIRN_TEAM_URL ?? '', organization: process.env.CAIRN_TEAM_ORGANIZATION ?? '', token: () => teamClientToken(), projectRoot: flags['project-root'] });
122
+ if (flags.connection && flags['project-root'])
123
+ throw new TeamError('invalid');
124
+ const connection = flags.connection ? readTeamConnection(resolve(flags.connection)) : undefined;
125
+ const token = connection ? () => connectionToken(connection) : () => teamClientToken();
126
+ token(); // validate private credential before startup
127
+ const server = await createTeamMcpServer(connection
128
+ ? { url: connection.url, organization: connection.organization, project: connection.project, readOnly: connection.access === 'read-only', token, projectRoot: connection.project_root }
129
+ : { url: process.env.CAIRN_TEAM_URL ?? '', organization: process.env.CAIRN_TEAM_ORGANIZATION ?? '', token, projectRoot: flags['project-root'] });
104
130
  await server.connect(new StdioServerTransport());
105
131
  return;
106
132
  }
@@ -112,7 +138,7 @@ async function main(args) {
112
138
  print({ restored: true, organization: store.organization, credentials_revoked: true });
113
139
  return;
114
140
  }
115
- if (['init', 'project', 'member', 'credential', 'serve', 'doctor', 'backup', 'memory-delete'].includes(command)) {
141
+ if (['init', 'project', 'member', 'credential', 'serve', 'doctor', 'backup', 'memory-delete', 'audit-export', 'audit-archive'].includes(command)) {
116
142
  if (command === 'init' && (words.length !== 1 || !flags.organization))
117
143
  throw new TeamError('invalid');
118
144
  store = await openTeamStore(root, command === 'init' ? { create: true, organization: flags.organization } : {});
@@ -123,11 +149,30 @@ async function main(args) {
123
149
  if (command === 'backup' && words.length === 1) {
124
150
  if (!flags.output)
125
151
  throw new TeamError('invalid');
126
- const snapshot = await store.snapshot();
152
+ const snapshot = await store.snapshot({ audit: flags['read-only'] !== 'true' });
127
153
  writePrivateExclusive(flags.output, `${JSON.stringify(snapshot)}\n`);
128
154
  print({ snapshot_digest: snapshot.digest, written: true });
129
155
  return;
130
156
  }
157
+ if (['audit-export', 'audit-archive'].includes(command) && words.length === 1) {
158
+ if (!flags.backup)
159
+ throw new TeamError('invalid');
160
+ const backup = verifyTeamSnapshot(readStableJson(resolve(flags.backup), { label: 'Team archival backup', maxBytes: TEAM_SNAPSHOT_MAX_BYTES, private: true }));
161
+ if (command === 'audit-export') {
162
+ if (!flags.output)
163
+ throw new TeamError('invalid');
164
+ const archive = await store.exportAudit(backup);
165
+ writePrivateExclusive(flags.output, `${JSON.stringify(archive)}\n`);
166
+ print({ written: true, archive_digest: archive.digest, backup_digest: archive.backup_digest, events: archive.entries.length });
167
+ }
168
+ else {
169
+ if (!flags.input || !flags.confirm)
170
+ throw new TeamError('invalid');
171
+ const archive = verifyTeamAuditArchive(readStableJson(resolve(flags.input), { label: 'Team audit archive', maxBytes: TEAM_SNAPSHOT_MAX_BYTES, private: true }));
172
+ print(await store.archiveAudit(archive, backup, flags.confirm, flags.apply === 'true'));
173
+ }
174
+ return;
175
+ }
131
176
  if (command === 'memory-delete' && words.length === 1) {
132
177
  await store.admin({ operation: 'memory-delete', project: flags.project, key: flags.key, confirm: flags.confirm });
133
178
  print({ deleted: true, backup_copies_retained: true });
@@ -206,6 +251,12 @@ async function main(args) {
206
251
  }
207
252
  else
208
253
  throw new TeamError('invalid');
254
+ if (flags['page-size'] || flags.cursor) {
255
+ const raw = flags['page-size'] ?? '100';
256
+ if (!/^[1-9][0-9]{0,2}$/.test(raw) || Number(raw) > 100 || (command === 'search' && Number(raw) > 10))
257
+ throw new TeamError('invalid');
258
+ input.page = { size: Number(raw), ...(flags.cursor ? { cursor: flags.cursor } : {}) };
259
+ }
209
260
  print(await teamRequest(process.env.CAIRN_TEAM_URL ?? '', teamClientToken(), input));
210
261
  }
211
262
  finally {
@@ -0,0 +1,71 @@
1
+ import { dirname, isAbsolute, join } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { z } from 'zod';
4
+ import { readStableJson, readStableFile } from './stable-file.js';
5
+ import { teamClientToken } from './team-mcp.js';
6
+ import { teamRequest, validateTeamUrl } from './team-http.js';
7
+ import { TeamError } from './team-store.js';
8
+ const id = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/);
9
+ const path = z.string().min(1).max(4096).refine(value => isAbsolute(value) && !/[\x00-\x1f\x7f]/.test(value));
10
+ const schema = z.object({
11
+ schema_version: z.literal(1), project_root: path,
12
+ organization: id, project: id, url: z.string().max(4096), token_file: path,
13
+ access: z.enum(['read-only', 'full']),
14
+ }).strict();
15
+ export class TeamClientError extends Error {
16
+ }
17
+ export function validateTeamConnection(value) {
18
+ const result = schema.safeParse(value);
19
+ if (!result.success)
20
+ throw new TeamClientError('Team connection fields are invalid; check organization/project IDs, absolute paths and access profile.');
21
+ try {
22
+ validateTeamUrl(result.data.url);
23
+ }
24
+ catch {
25
+ throw new TeamClientError('Team connection URL must be loopback HTTP or HTTPS without credentials, query, fragment or path prefix.');
26
+ }
27
+ return result.data;
28
+ }
29
+ export function readTeamConnection(path) {
30
+ const { bytes } = readStableFile(path, { label: 'Team connection', maxBytes: 32768, private: true });
31
+ let value;
32
+ try {
33
+ value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
34
+ }
35
+ catch {
36
+ throw new TeamClientError('Team connection contains invalid UTF-8 or JSON.');
37
+ }
38
+ const connection = validateTeamConnection(value);
39
+ const receipt = readStableJson(join(dirname(path), 'receipt.json'), { label: 'Team connection activation', maxBytes: 16 * 1024 * 1024, private: true });
40
+ if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)
41
+ || receipt.schema_version !== 1
42
+ || receipt.status !== 'connected'
43
+ || receipt.project_root !== connection.project_root)
44
+ throw new TeamClientError('Team connection is incomplete or disconnected; inspect recovery state.');
45
+ if (receipt.connection_digest !== createHash('sha256').update(bytes).digest('hex'))
46
+ throw new TeamClientError('Team connection activation changed; inspect recovery state.');
47
+ return connection;
48
+ }
49
+ export function connectionToken(connection) {
50
+ try {
51
+ return teamClientToken({ CAIRN_TEAM_TOKEN_FILE: connection.token_file });
52
+ }
53
+ catch {
54
+ throw new TeamClientError('Team credential file is unsafe or unreadable; use an owner-only file issued by the operator.');
55
+ }
56
+ }
57
+ /** Authenticate and check reader membership without retrieving memory or proposals.
58
+ * Expiry and roles come from the service, never from guesses about opaque tokens. */
59
+ export async function connectionDoctor(connection) {
60
+ connection = validateTeamConnection(connection);
61
+ const token = connectionToken(connection);
62
+ const result = await teamRequest(connection.url, token, { organization: connection.organization, project: connection.project, operation: 'connection-check' });
63
+ const metadata = z.object({ schema_version: z.literal(1), organization: id, project: id,
64
+ subject: id, credential_id: z.string().uuid(), credential_class: z.enum(['human', 'workload']),
65
+ expires_at: z.iso.datetime(), roles: z.array(z.enum(['reader', 'contributor', 'reviewer', 'maintainer', 'auditor'])).max(5),
66
+ }).strict().safeParse(result);
67
+ if (!metadata.success || metadata.data.organization !== connection.organization || metadata.data.project !== connection.project
68
+ || !metadata.data.roles.includes('reader') || Date.parse(metadata.data.expires_at) <= Date.now())
69
+ throw new TeamError('integrity');
70
+ return { ok: true, ...metadata.data, access: connection.access, experimental: true };
71
+ }
@@ -0,0 +1,7 @@
1
+ export class TeamError extends Error {
2
+ code;
3
+ constructor(code) {
4
+ super(`Team ${code}.`);
5
+ this.code = code;
6
+ }
7
+ }
@@ -0,0 +1,28 @@
1
+ import { closeSync, openSync, writeFileSync, fsyncSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { hardenPrivatePath } from './platform-security.js';
4
+ import { prepareTeamPrivateDirectory } from './team-store.js';
5
+ export function writePrivateExclusive(path, bytes) {
6
+ path = resolve(path);
7
+ prepareTeamPrivateDirectory(dirname(path), true);
8
+ const fd = openSync(path, 'wx', 0o600);
9
+ try {
10
+ hardenPrivatePath(path);
11
+ writeFileSync(fd, bytes, 'utf8');
12
+ fsyncSync(fd);
13
+ }
14
+ finally {
15
+ closeSync(fd);
16
+ }
17
+ // Persist the new directory entry as well as its contents on POSIX.
18
+ // Node cannot open/fsync a directory handle on Windows.
19
+ if (process.platform !== 'win32') {
20
+ const parent = openSync(dirname(path), 'r');
21
+ try {
22
+ fsyncSync(parent);
23
+ }
24
+ finally {
25
+ closeSync(parent);
26
+ }
27
+ }
28
+ }
@@ -5,6 +5,7 @@ import { isCapabilityContractEnabled, resolveCapabilityStatus } from './capabili
5
5
  import { teamRequest, validateTeamUrl } from './team-http.js';
6
6
  import { TeamError, teamDigest } from './team-store.js';
7
7
  import { resolveHttpToken } from './http-security.js';
8
+ import { teamPageSchema } from './team-pages.js';
8
9
  const observation = (title) => ({ title, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } });
9
10
  // The bridge crosses the local process boundary into a remote team service;
10
11
  // openWorldHint is true even though team retrieval is deterministic and closed
@@ -32,6 +33,8 @@ export async function createTeamMcpServer(options) {
32
33
  validateTeamUrl(options.url);
33
34
  if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(options.organization))
34
35
  throw new TeamError('invalid');
36
+ if (options.project !== undefined && !/^[a-z0-9][a-z0-9-]{0,63}$/.test(options.project))
37
+ throw new TeamError('invalid');
35
38
  const profile = resolveMcpToolProfile({ projectRoot: options.projectRoot });
36
39
  const capability = isCapabilityContractEnabled() ? await resolveCapabilityStatus({ projectRoot: options.projectRoot }) : undefined;
37
40
  if (capability?.issues.length)
@@ -47,16 +50,18 @@ export async function createTeamMcpServer(options) {
47
50
  const key = z.string().regex(/^(decisions|patterns|constraints|pitfalls|conventions|bugs)\/[a-z0-9][a-z0-9-]{0,95}$/);
48
51
  const hash = z.string().regex(/^[a-f0-9]{64}$/);
49
52
  const schemas = {
50
- team_memory_list: { project },
53
+ team_memory_list: { project, page: teamPageSchema.optional() },
51
54
  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 },
55
+ team_memory_search: { project, query: z.string().min(1).max(256), page: teamPageSchema.extend({ size: z.number().int().min(1).max(10) }).optional() },
56
+ team_memory_history: { project, key, page: teamPageSchema.optional() },
54
57
  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
58
  };
56
59
  const operations = { team_memory_list: 'list', team_memory_read: 'read', team_memory_search: 'search', team_memory_history: 'history', team_memory_propose: 'propose' };
57
60
  const enabled = Object.keys(TEAM_TOOL_CATALOG).filter(name => {
58
61
  if (!profileAllowsTool(profile, coreNames[name]))
59
62
  return false;
63
+ if (options.readOnly && !TEAM_TOOL_CATALOG[name].annotations.readOnlyHint)
64
+ return false;
60
65
  if (mode === 'read-only' && !TEAM_TOOL_CATALOG[name].annotations.readOnlyHint)
61
66
  return false;
62
67
  if (mode === 'custom' && !requested.includes(name))
@@ -64,10 +69,13 @@ export async function createTeamMcpServer(options) {
64
69
  const capId = name === 'team_memory_propose' ? 'memory.write' : name === 'team_memory_search' ? 'memory.search' : undefined;
65
70
  return !capId || !capability || capability.capabilities.find(row => row.id === capId)?.enabled === true;
66
71
  });
67
- const profileDigest = teamDigest(JSON.stringify({ schema_version: 1, core_profile_digest: profile.profile_digest, team_profile: mode, tools: enabled }));
72
+ const profileDigest = teamDigest(JSON.stringify({ schema_version: 1, core_profile_digest: profile.profile_digest, team_profile: mode, tools: enabled,
73
+ ...(options.project !== undefined ? { connection_project: options.project, connection_read_only: options.readOnly === true } : {}) }));
68
74
  for (const name of enabled) {
69
75
  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
76
  try {
77
+ if (options.project !== undefined && input.project !== options.project)
78
+ throw new TeamError('denied');
71
79
  // Re-read the private credential each call so local key rotation
72
80
  // does not need a harness restart. The service still reauthorizes.
73
81
  const result = await teamRequest(options.url, options.token(), { organization: options.organization, ...input, operation: operations[name] });
@@ -0,0 +1,40 @@
1
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { TeamError } from './team-errors.js';
4
+ export const teamPageSchema = z.object({ size: z.number().int().min(1).max(100), cursor: z.string().min(1).max(2048).optional() }).strict();
5
+ const cursorSchema = z.object({ v: z.literal(1), scope: z.string().regex(/^[a-f0-9]{64}$/), view: z.string().regex(/^[a-f0-9]{64}$/), offset: z.number().int().positive().max(100000), upper: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) }).strict();
6
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
7
+ const mac = (key, value) => createHmac('sha256', Buffer.from(key, 'hex')).update(value).digest();
8
+ export function pageTeamItems(items, secret, scope, page, audit = false) {
9
+ let offset = 0, upper = audit ? Number(items[0]?.sequence ?? 0) : 0;
10
+ let decoded;
11
+ if (page.cursor) {
12
+ try {
13
+ const match = /^([A-Za-z0-9_-]+)\.([a-f0-9]{64})$/.exec(page.cursor);
14
+ if (!match || !timingSafeEqual(mac(secret, match[1]), Buffer.from(match[2], 'hex')))
15
+ throw new Error();
16
+ decoded = cursorSchema.parse(JSON.parse(Buffer.from(match[1], 'base64url').toString('utf8')));
17
+ if (decoded.scope !== hash(scope))
18
+ throw new Error();
19
+ offset = decoded.offset;
20
+ upper = decoded.upper;
21
+ }
22
+ catch {
23
+ throw new TeamError('invalid');
24
+ }
25
+ }
26
+ if (audit)
27
+ items = items.filter(row => Number(row.sequence) <= upper);
28
+ const view = hash(JSON.stringify(items));
29
+ if (decoded && decoded.view !== view)
30
+ throw new TeamError('conflict');
31
+ if (offset > items.length)
32
+ throw new TeamError('invalid');
33
+ const selected = items.slice(offset, offset + page.size), next = offset + selected.length;
34
+ let cursor = null;
35
+ if (next < items.length) {
36
+ const bytes = Buffer.from(JSON.stringify({ v: 1, scope: hash(scope), view, offset: next, upper })).toString('base64url');
37
+ cursor = bytes + '.' + mac(secret, bytes).toString('hex');
38
+ }
39
+ return { items: selected, next_cursor: cursor };
40
+ }