@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.
- package/CHANGELOG.md +64 -0
- package/docs/README.md +6 -0
- package/docs/harness-compatibility.md +5 -2
- package/docs/learning/CURRICULUM-MAP.md +3 -3
- package/docs/learning/FEATURE-GUIDE.md +13 -1
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +5 -1
- package/docs/learning/lessons/L29-team-continuity.md +42 -1
- package/docs/learning/tracks/operator.md +5 -0
- package/docs/learning/video-scripts/V28-security-assurance.md +4 -0
- package/docs/learning/video-scripts/V29-team-continuity.md +29 -1
- package/docs/operating.md +10 -0
- package/docs/privacy-and-data-flow.md +30 -1
- package/docs/provider-data-flow-review.md +48 -0
- package/docs/releasing.md +6 -0
- package/docs/security-assurance.md +11 -0
- package/docs/storage.md +23 -0
- package/docs/team-onboarding.md +230 -0
- package/docs/team-operations.md +127 -0
- package/docs/team-pilot.md +2 -0
- package/docs/team.md +21 -4
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/capability-config.js +12 -1
- package/mcp-memory-server/dist/team-audit.js +41 -0
- package/mcp-memory-server/dist/team-cli.js +81 -30
- package/mcp-memory-server/dist/team-client.js +71 -0
- package/mcp-memory-server/dist/team-errors.js +7 -0
- package/mcp-memory-server/dist/team-files.js +28 -0
- package/mcp-memory-server/dist/team-mcp.js +12 -4
- package/mcp-memory-server/dist/team-pages.js +40 -0
- package/mcp-memory-server/dist/team-store.js +105 -40
- package/mcp-memory-server/package.json +2 -2
- package/package.json +1 -1
- package/scripts/cairn-cli.mjs +6 -1
- package/scripts/completion.sh +16 -3
- package/scripts/lib/team-retention.mjs +1 -1
- package/scripts/team-connect.mjs +366 -0
- package/scripts/test-ci-sharding.sh +6 -1
- package/scripts/test-codeql-cohort.mjs +35 -0
- package/scripts/test-completion.sh +7 -1
- package/scripts/test-package-install.sh +1 -0
- package/scripts/test-phase18-capability-lifecycle.sh +2 -2
- package/scripts/test-release-cache.mjs +16 -0
- package/scripts/test-security-baseline.sh +4 -0
- package/scripts/test-smoke-timings.mjs +19 -0
- package/scripts/test-team-docs.sh +26 -2
- package/scripts/test-team-installed.mjs +62 -0
- package/scripts/test-team-lab-env.mjs +9 -0
- package/scripts/test-team-operations.mjs +31 -0
- package/scripts/test-team-operations.sh +5 -0
- package/scripts/test-team-pilot.mjs +43 -1
- package/scripts/verify-published-release.mjs +11 -2
- package/scripts/verify-security-baseline.mjs +20 -0
- package/scripts/windows-platform.mjs +1 -1
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 {
|
|
7
|
-
import {
|
|
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
|
|
22
|
-
cairn team
|
|
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
|
-
|
|
103
|
-
|
|
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,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
|
+
}
|