@danielblomma/cortex-mcp 1.7.1 → 2.0.2
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/bin/cortex.mjs +679 -32
- package/bin/style.mjs +349 -0
- package/package.json +4 -3
- package/scaffold/mcp/package-lock.json +834 -671
- package/scaffold/mcp/package.json +1 -1
- package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
- package/scaffold/mcp/src/cli/govern.ts +987 -0
- package/scaffold/mcp/src/cli/run.ts +306 -0
- package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
- package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
- package/scaffold/mcp/src/core/audit/query.ts +81 -0
- package/scaffold/mcp/src/core/audit/writer.ts +68 -0
- package/scaffold/mcp/src/core/config.ts +329 -0
- package/scaffold/mcp/src/core/index.ts +34 -0
- package/scaffold/mcp/src/core/license.ts +202 -0
- package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
- package/scaffold/mcp/src/core/policy/injection.ts +229 -0
- package/scaffold/mcp/src/core/policy/store.ts +197 -0
- package/scaffold/mcp/src/core/rbac/check.ts +40 -0
- package/scaffold/mcp/src/core/telemetry/collector.ts +234 -0
- package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
- package/scaffold/mcp/src/core/validators/config.ts +47 -0
- package/scaffold/mcp/src/core/validators/engine.ts +199 -0
- package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
- package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
- package/scaffold/mcp/src/daemon/client.ts +155 -0
- package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
- package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
- package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
- package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
- package/scaffold/mcp/src/daemon/main.ts +300 -0
- package/scaffold/mcp/src/daemon/paths.ts +41 -0
- package/scaffold/mcp/src/daemon/protocol.ts +101 -0
- package/scaffold/mcp/src/daemon/server.ts +227 -0
- package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
- package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
- package/scaffold/mcp/src/embed.ts +1 -1
- package/scaffold/mcp/src/embeddings.ts +1 -1
- package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
- package/scaffold/mcp/src/enterprise/index.ts +415 -0
- package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
- package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
- package/scaffold/mcp/src/enterprise/privacy/boundary.ts +212 -0
- package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
- package/scaffold/mcp/src/enterprise/telemetry/sync.ts +72 -0
- package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
- package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
- package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
- package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
- package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
- package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
- package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
- package/scaffold/mcp/src/hooks/session-end.ts +73 -0
- package/scaffold/mcp/src/hooks/session-start.ts +78 -0
- package/scaffold/mcp/src/hooks/shared.ts +134 -0
- package/scaffold/mcp/src/hooks/stop.ts +60 -0
- package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
- package/scaffold/mcp/src/plugin.ts +150 -0
- package/scaffold/mcp/src/server.ts +218 -7
- package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
- package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
- package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
- package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
- package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
- package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
- package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
- package/scaffold/mcp/tests/govern.test.mjs +74 -0
- package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
- package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
- package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
- package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
- package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
- package/scaffold/mcp/tests/run.test.mjs +109 -0
- package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
- package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
- package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
- package/scaffold/scripts/bootstrap.sh +0 -11
- package/scaffold/scripts/doctor.sh +24 -4
- package/types.js +5 -0
|
@@ -4,9 +4,35 @@ import { z } from "zod";
|
|
|
4
4
|
import { reloadContextGraph } from "./graph.js";
|
|
5
5
|
import { runContextRules } from "./rules.js";
|
|
6
6
|
import { runContextImpact, runContextRelated, runContextSearch } from "./search.js";
|
|
7
|
+
import {
|
|
8
|
+
getToolCallHook,
|
|
9
|
+
getToolEventHook,
|
|
10
|
+
getSessionEndHook,
|
|
11
|
+
getSessionEventHook,
|
|
12
|
+
loadPlugins,
|
|
13
|
+
} from "./plugin.js";
|
|
7
14
|
|
|
8
15
|
type ToolPayload = Record<string, unknown>;
|
|
9
16
|
|
|
17
|
+
const ESTIMATED_TOKENS_SAVED_PER_RESULT = 400;
|
|
18
|
+
const MAX_SESSION_CALLS = 1000;
|
|
19
|
+
const SHUTDOWN_TIMEOUT_MS = 3000;
|
|
20
|
+
|
|
21
|
+
type SessionCall = {
|
|
22
|
+
tool: string;
|
|
23
|
+
query?: string;
|
|
24
|
+
resultCount: number;
|
|
25
|
+
time: string;
|
|
26
|
+
outcome?: "success" | "error";
|
|
27
|
+
duration_ms?: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const sessionCalls: SessionCall[] = [];
|
|
32
|
+
const sessionStartedAt = Date.now();
|
|
33
|
+
let successfulToolCalls = 0;
|
|
34
|
+
let failedToolCalls = 0;
|
|
35
|
+
|
|
10
36
|
const SearchInput = z.object({
|
|
11
37
|
query: z.string().min(1),
|
|
12
38
|
top_k: z.number().int().positive().max(20).default(5),
|
|
@@ -123,6 +149,115 @@ function buildToolResult(data: ToolPayload) {
|
|
|
123
149
|
};
|
|
124
150
|
}
|
|
125
151
|
|
|
152
|
+
function extractQuery(input: unknown): string | undefined {
|
|
153
|
+
if (input && typeof input === "object" && "query" in input) {
|
|
154
|
+
const q = (input as { query?: unknown }).query;
|
|
155
|
+
if (typeof q === "string") return q;
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function notifyToolStart(toolName: string, input: unknown): string {
|
|
161
|
+
const timestamp = new Date().toISOString();
|
|
162
|
+
const eventHook = getToolEventHook();
|
|
163
|
+
if (eventHook) {
|
|
164
|
+
const query = extractQuery(input);
|
|
165
|
+
void eventHook({
|
|
166
|
+
phase: "start",
|
|
167
|
+
tool: toolName,
|
|
168
|
+
timestamp,
|
|
169
|
+
input: (input ?? {}) as Record<string, unknown>,
|
|
170
|
+
query,
|
|
171
|
+
query_length: query?.length,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return timestamp;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function notifyToolCall(toolName: string, input: unknown, result: ToolPayload, durationMs: number, startedAtIso: string): void {
|
|
178
|
+
const resultCount = Array.isArray((result as { results?: unknown }).results)
|
|
179
|
+
? ((result as { results: unknown[] }).results).length
|
|
180
|
+
: 0;
|
|
181
|
+
const query = extractQuery(input);
|
|
182
|
+
if (sessionCalls.length < MAX_SESSION_CALLS) {
|
|
183
|
+
sessionCalls.push({
|
|
184
|
+
tool: toolName,
|
|
185
|
+
query,
|
|
186
|
+
resultCount,
|
|
187
|
+
time: startedAtIso,
|
|
188
|
+
outcome: "success",
|
|
189
|
+
duration_ms: durationMs,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
successfulToolCalls++;
|
|
193
|
+
|
|
194
|
+
const eventHook = getToolEventHook();
|
|
195
|
+
const hook = getToolCallHook();
|
|
196
|
+
if (!eventHook && hook) {
|
|
197
|
+
hook(toolName, resultCount, resultCount * ESTIMATED_TOKENS_SAVED_PER_RESULT);
|
|
198
|
+
}
|
|
199
|
+
if (eventHook) {
|
|
200
|
+
void eventHook({
|
|
201
|
+
phase: "success",
|
|
202
|
+
tool: toolName,
|
|
203
|
+
timestamp: new Date().toISOString(),
|
|
204
|
+
input: (input ?? {}) as Record<string, unknown>,
|
|
205
|
+
query,
|
|
206
|
+
query_length: query?.length,
|
|
207
|
+
result_count: resultCount,
|
|
208
|
+
estimated_tokens_saved: resultCount * ESTIMATED_TOKENS_SAVED_PER_RESULT,
|
|
209
|
+
duration_ms: durationMs,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function notifyToolError(toolName: string, input: unknown, error: unknown, durationMs: number, startedAtIso: string): void {
|
|
215
|
+
const query = extractQuery(input);
|
|
216
|
+
if (sessionCalls.length < MAX_SESSION_CALLS) {
|
|
217
|
+
sessionCalls.push({
|
|
218
|
+
tool: toolName,
|
|
219
|
+
query,
|
|
220
|
+
resultCount: 0,
|
|
221
|
+
time: startedAtIso,
|
|
222
|
+
outcome: "error",
|
|
223
|
+
duration_ms: durationMs,
|
|
224
|
+
error: error instanceof Error ? error.message : String(error),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
failedToolCalls++;
|
|
228
|
+
|
|
229
|
+
const eventHook = getToolEventHook();
|
|
230
|
+
if (eventHook) {
|
|
231
|
+
void eventHook({
|
|
232
|
+
phase: "error",
|
|
233
|
+
tool: toolName,
|
|
234
|
+
timestamp: new Date().toISOString(),
|
|
235
|
+
input: (input ?? {}) as Record<string, unknown>,
|
|
236
|
+
query,
|
|
237
|
+
query_length: query?.length,
|
|
238
|
+
duration_ms: durationMs,
|
|
239
|
+
error: error instanceof Error ? error.message : String(error),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function executeInstrumentedTool(
|
|
245
|
+
toolName: string,
|
|
246
|
+
input: unknown,
|
|
247
|
+
run: () => Promise<ToolPayload>
|
|
248
|
+
) {
|
|
249
|
+
const startedAt = Date.now();
|
|
250
|
+
const startedAtIso = notifyToolStart(toolName, input);
|
|
251
|
+
try {
|
|
252
|
+
const result = await run();
|
|
253
|
+
notifyToolCall(toolName, input, result, Date.now() - startedAt, startedAtIso);
|
|
254
|
+
return buildToolResult(result);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
notifyToolError(toolName, input, error, Date.now() - startedAt, startedAtIso);
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
126
261
|
function registerTools(server: McpServer): void {
|
|
127
262
|
server.registerTool(
|
|
128
263
|
"context.search",
|
|
@@ -130,7 +265,11 @@ function registerTools(server: McpServer): void {
|
|
|
130
265
|
description: "Search ranked context documents and code using semantic, graph and trust weighting.",
|
|
131
266
|
inputSchema: SearchInput
|
|
132
267
|
},
|
|
133
|
-
async (input) =>
|
|
268
|
+
async (input) => executeInstrumentedTool(
|
|
269
|
+
"context.search",
|
|
270
|
+
input,
|
|
271
|
+
async () => runContextSearch(SearchInput.parse(input ?? {}))
|
|
272
|
+
)
|
|
134
273
|
);
|
|
135
274
|
|
|
136
275
|
server.registerTool(
|
|
@@ -139,7 +278,11 @@ function registerTools(server: McpServer): void {
|
|
|
139
278
|
description: "Return related entities and graph edges for a context entity id.",
|
|
140
279
|
inputSchema: RelatedInput
|
|
141
280
|
},
|
|
142
|
-
async (input) =>
|
|
281
|
+
async (input) => executeInstrumentedTool(
|
|
282
|
+
"context.get_related",
|
|
283
|
+
input,
|
|
284
|
+
async () => runContextRelated(RelatedInput.parse(input ?? {}))
|
|
285
|
+
)
|
|
143
286
|
);
|
|
144
287
|
|
|
145
288
|
server.registerTool(
|
|
@@ -148,7 +291,11 @@ function registerTools(server: McpServer): void {
|
|
|
148
291
|
description: "Traverse likely impact paths across config, code and SQL starting from an entity id or query.",
|
|
149
292
|
inputSchema: ImpactInput
|
|
150
293
|
},
|
|
151
|
-
async (input) =>
|
|
294
|
+
async (input) => executeInstrumentedTool(
|
|
295
|
+
"context.impact",
|
|
296
|
+
input,
|
|
297
|
+
async () => runContextImpact(ImpactInput.parse(input ?? {}))
|
|
298
|
+
)
|
|
152
299
|
);
|
|
153
300
|
|
|
154
301
|
server.registerTool(
|
|
@@ -157,7 +304,11 @@ function registerTools(server: McpServer): void {
|
|
|
157
304
|
description: "List indexed rules filtered by scope and active status.",
|
|
158
305
|
inputSchema: RulesInput.optional()
|
|
159
306
|
},
|
|
160
|
-
async (input) =>
|
|
307
|
+
async (input) => executeInstrumentedTool(
|
|
308
|
+
"context.get_rules",
|
|
309
|
+
input,
|
|
310
|
+
async () => runContextRules(RulesInput.parse(input ?? {}))
|
|
311
|
+
)
|
|
161
312
|
);
|
|
162
313
|
|
|
163
314
|
server.registerTool(
|
|
@@ -166,13 +317,50 @@ function registerTools(server: McpServer): void {
|
|
|
166
317
|
description: "Reload RyuGraph connection after graph updates or maintenance.",
|
|
167
318
|
inputSchema: ReloadInput.optional()
|
|
168
319
|
},
|
|
169
|
-
async (input) => {
|
|
320
|
+
async (input) => executeInstrumentedTool("context.reload", input, async () => {
|
|
170
321
|
const parsed = ReloadInput.parse(input ?? {});
|
|
171
|
-
return
|
|
172
|
-
}
|
|
322
|
+
return reloadContextGraph(parsed.force);
|
|
323
|
+
})
|
|
173
324
|
);
|
|
174
325
|
}
|
|
175
326
|
|
|
327
|
+
let shutdownCalled = false;
|
|
328
|
+
|
|
329
|
+
async function onShutdown(): Promise<void> {
|
|
330
|
+
if (shutdownCalled) return;
|
|
331
|
+
shutdownCalled = true;
|
|
332
|
+
const sessionEventHook = getSessionEventHook();
|
|
333
|
+
if (sessionEventHook) {
|
|
334
|
+
try {
|
|
335
|
+
await Promise.race([
|
|
336
|
+
Promise.resolve(sessionEventHook({
|
|
337
|
+
phase: "end",
|
|
338
|
+
timestamp: new Date().toISOString(),
|
|
339
|
+
duration_ms: Date.now() - sessionStartedAt,
|
|
340
|
+
tool_calls: sessionCalls.length,
|
|
341
|
+
successful_tool_calls: successfulToolCalls,
|
|
342
|
+
failed_tool_calls: failedToolCalls,
|
|
343
|
+
calls: [...sessionCalls],
|
|
344
|
+
})),
|
|
345
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("session event hook timeout")), SHUTDOWN_TIMEOUT_MS))
|
|
346
|
+
]);
|
|
347
|
+
} catch {
|
|
348
|
+
// Best effort — don't block shutdown
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const hook = getSessionEndHook();
|
|
352
|
+
if (hook) {
|
|
353
|
+
try {
|
|
354
|
+
await Promise.race([
|
|
355
|
+
hook([...sessionCalls]),
|
|
356
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("shutdown hook timeout")), SHUTDOWN_TIMEOUT_MS))
|
|
357
|
+
]);
|
|
358
|
+
} catch {
|
|
359
|
+
// Best effort — don't block shutdown
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
176
364
|
async function main(): Promise<void> {
|
|
177
365
|
const server = new McpServer({
|
|
178
366
|
name: "cortex-context",
|
|
@@ -180,8 +368,31 @@ async function main(): Promise<void> {
|
|
|
180
368
|
});
|
|
181
369
|
|
|
182
370
|
registerTools(server);
|
|
371
|
+
|
|
372
|
+
// v2.0.0: load enterprise plugin in-process if .context/enterprise.yml
|
|
373
|
+
// is present and license validates. Community-mode is a no-op.
|
|
374
|
+
await loadPlugins(server);
|
|
375
|
+
|
|
376
|
+
// Notify session start to enterprise (if active).
|
|
377
|
+
const sessionEventHook = getSessionEventHook();
|
|
378
|
+
if (sessionEventHook) {
|
|
379
|
+
void sessionEventHook({
|
|
380
|
+
phase: "start",
|
|
381
|
+
timestamp: new Date(sessionStartedAt).toISOString(),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
183
385
|
const transport = new StdioServerTransport();
|
|
184
386
|
await server.connect(transport);
|
|
387
|
+
|
|
388
|
+
const cleanup = () => {
|
|
389
|
+
void onShutdown().finally(() => process.exit(0));
|
|
390
|
+
};
|
|
391
|
+
process.on("SIGINT", cleanup);
|
|
392
|
+
process.on("SIGTERM", cleanup);
|
|
393
|
+
process.on("beforeExit", () => {
|
|
394
|
+
void onShutdown();
|
|
395
|
+
});
|
|
185
396
|
}
|
|
186
397
|
|
|
187
398
|
main().catch((error) => {
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
installCopilotShim,
|
|
9
|
+
uninstallCopilotShim,
|
|
10
|
+
buildCopilotShim,
|
|
11
|
+
isCortexShim,
|
|
12
|
+
SHIM_MARKER,
|
|
13
|
+
} from "../dist/cli/run.js";
|
|
14
|
+
|
|
15
|
+
function makeWorkspace() {
|
|
16
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-shim-"));
|
|
17
|
+
const realDir = path.join(dir, "real-bin");
|
|
18
|
+
const shimDir = path.join(dir, "shim-bin");
|
|
19
|
+
fs.mkdirSync(realDir, { recursive: true });
|
|
20
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
21
|
+
const realCopilot = path.join(realDir, "copilot");
|
|
22
|
+
fs.writeFileSync(realCopilot, "#!/bin/sh\necho real copilot\n", { mode: 0o755 });
|
|
23
|
+
return {
|
|
24
|
+
dir,
|
|
25
|
+
realDir,
|
|
26
|
+
shimDir,
|
|
27
|
+
realCopilot,
|
|
28
|
+
shimPath: path.join(shimDir, "copilot"),
|
|
29
|
+
searchPath: `${realDir}:${shimDir}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("buildCopilotShim: contains SHIM_MARKER and exec line", () => {
|
|
34
|
+
const shim = buildCopilotShim("/usr/bin/copilot");
|
|
35
|
+
assert.ok(shim.startsWith("#!/bin/sh"));
|
|
36
|
+
assert.match(shim, new RegExp(SHIM_MARKER));
|
|
37
|
+
assert.match(shim, /Real binary captured at install time: \/usr\/bin\/copilot/);
|
|
38
|
+
assert.match(shim, /exec "\$CORTEX" run copilot "\$@"/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("installCopilotShim: writes shim, finds real binary, makes shim executable", () => {
|
|
42
|
+
const ws = makeWorkspace();
|
|
43
|
+
try {
|
|
44
|
+
const result = installCopilotShim({
|
|
45
|
+
shimPath: ws.shimPath,
|
|
46
|
+
searchPath: ws.searchPath,
|
|
47
|
+
});
|
|
48
|
+
assert.equal(result.ok, true, result.message);
|
|
49
|
+
assert.equal(result.realBinary, ws.realCopilot);
|
|
50
|
+
assert.equal(result.shimPath, ws.shimPath);
|
|
51
|
+
assert.equal(isCortexShim(ws.shimPath), true);
|
|
52
|
+
const stat = fs.statSync(ws.shimPath);
|
|
53
|
+
assert.equal(stat.mode & 0o111, 0o111, "shim should be executable");
|
|
54
|
+
} finally {
|
|
55
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("installCopilotShim: errors when real binary missing", () => {
|
|
60
|
+
const ws = makeWorkspace();
|
|
61
|
+
try {
|
|
62
|
+
fs.unlinkSync(ws.realCopilot);
|
|
63
|
+
const result = installCopilotShim({
|
|
64
|
+
shimPath: ws.shimPath,
|
|
65
|
+
searchPath: ws.searchPath,
|
|
66
|
+
});
|
|
67
|
+
assert.equal(result.ok, false);
|
|
68
|
+
assert.match(result.message, /not found in PATH/);
|
|
69
|
+
} finally {
|
|
70
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("installCopilotShim: refuses to overwrite a non-shim file at shim path", () => {
|
|
75
|
+
const ws = makeWorkspace();
|
|
76
|
+
try {
|
|
77
|
+
fs.writeFileSync(ws.shimPath, "#!/bin/sh\necho not a cortex shim\n", { mode: 0o755 });
|
|
78
|
+
const result = installCopilotShim({
|
|
79
|
+
shimPath: ws.shimPath,
|
|
80
|
+
searchPath: ws.searchPath,
|
|
81
|
+
});
|
|
82
|
+
assert.equal(result.ok, false);
|
|
83
|
+
assert.match(result.message, /not a cortex shim/);
|
|
84
|
+
} finally {
|
|
85
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("installCopilotShim: skips a previously-installed shim during PATH search and replaces it cleanly", () => {
|
|
90
|
+
const ws = makeWorkspace();
|
|
91
|
+
try {
|
|
92
|
+
// First install — shim goes to shimDir.
|
|
93
|
+
const first = installCopilotShim({
|
|
94
|
+
shimPath: ws.shimPath,
|
|
95
|
+
searchPath: ws.searchPath,
|
|
96
|
+
});
|
|
97
|
+
assert.equal(first.ok, true);
|
|
98
|
+
// Second install — searchPath now lists shimDir before realDir; install must
|
|
99
|
+
// still find the real binary by skipping its own shim.
|
|
100
|
+
const second = installCopilotShim({
|
|
101
|
+
shimPath: ws.shimPath,
|
|
102
|
+
searchPath: `${ws.shimDir}:${ws.realDir}`,
|
|
103
|
+
});
|
|
104
|
+
assert.equal(second.ok, true, second.message);
|
|
105
|
+
assert.equal(second.realBinary, ws.realCopilot);
|
|
106
|
+
} finally {
|
|
107
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("uninstallCopilotShim: removes a cortex shim", () => {
|
|
112
|
+
const ws = makeWorkspace();
|
|
113
|
+
try {
|
|
114
|
+
installCopilotShim({ shimPath: ws.shimPath, searchPath: ws.searchPath });
|
|
115
|
+
assert.equal(fs.existsSync(ws.shimPath), true);
|
|
116
|
+
const result = uninstallCopilotShim(ws.shimPath);
|
|
117
|
+
assert.equal(result.ok, true, result.message);
|
|
118
|
+
assert.equal(fs.existsSync(ws.shimPath), false);
|
|
119
|
+
} finally {
|
|
120
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("uninstallCopilotShim: refuses to delete a file that is no longer a cortex shim", () => {
|
|
125
|
+
const ws = makeWorkspace();
|
|
126
|
+
try {
|
|
127
|
+
fs.writeFileSync(ws.shimPath, "#!/bin/sh\necho not a shim anymore\n", { mode: 0o755 });
|
|
128
|
+
const result = uninstallCopilotShim(ws.shimPath);
|
|
129
|
+
assert.equal(result.ok, false);
|
|
130
|
+
assert.match(result.message, /no longer a cortex shim/);
|
|
131
|
+
assert.equal(fs.existsSync(ws.shimPath), true, "should not delete user file");
|
|
132
|
+
} finally {
|
|
133
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("uninstallCopilotShim: missing file is a no-op success", () => {
|
|
138
|
+
const ws = makeWorkspace();
|
|
139
|
+
try {
|
|
140
|
+
const result = uninstallCopilotShim(ws.shimPath);
|
|
141
|
+
assert.equal(result.ok, true);
|
|
142
|
+
assert.match(result.message, /already absent/);
|
|
143
|
+
} finally {
|
|
144
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { isProcessAlive } from "../dist/daemon/client.js";
|
|
5
|
+
|
|
6
|
+
test("isProcessAlive: treats EPERM from signal 0 as alive", () => {
|
|
7
|
+
const originalKill = process.kill;
|
|
8
|
+
process.kill = () => {
|
|
9
|
+
const err = new Error("operation not permitted");
|
|
10
|
+
err.code = "EPERM";
|
|
11
|
+
throw err;
|
|
12
|
+
};
|
|
13
|
+
try {
|
|
14
|
+
assert.equal(isProcessAlive(12345), true);
|
|
15
|
+
} finally {
|
|
16
|
+
process.kill = originalKill;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("isProcessAlive: returns false for ESRCH", () => {
|
|
21
|
+
const originalKill = process.kill;
|
|
22
|
+
process.kill = () => {
|
|
23
|
+
const err = new Error("no such process");
|
|
24
|
+
err.code = "ESRCH";
|
|
25
|
+
throw err;
|
|
26
|
+
};
|
|
27
|
+
try {
|
|
28
|
+
assert.equal(isProcessAlive(12345), false);
|
|
29
|
+
} finally {
|
|
30
|
+
process.kill = originalKill;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
|
|
8
|
+
import { parseSni, startEgressProxy } from "../dist/daemon/egress-proxy.js";
|
|
9
|
+
|
|
10
|
+
function makeProject() {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-egress-"));
|
|
12
|
+
fs.mkdirSync(path.join(root, ".context"), { recursive: true });
|
|
13
|
+
return root;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Hand-craft a minimal TLS 1.2 ClientHello with a server_name extension
|
|
18
|
+
* for the given hostname. Enough for the SNI parser to find it.
|
|
19
|
+
*/
|
|
20
|
+
function buildClientHello(serverName) {
|
|
21
|
+
const nameBuf = Buffer.from(serverName, "ascii");
|
|
22
|
+
const sniListInner = Buffer.concat([
|
|
23
|
+
Buffer.from([0x00]),
|
|
24
|
+
Buffer.from([(nameBuf.length >> 8) & 0xff, nameBuf.length & 0xff]),
|
|
25
|
+
nameBuf,
|
|
26
|
+
]);
|
|
27
|
+
const sniList = Buffer.concat([
|
|
28
|
+
Buffer.from([(sniListInner.length >> 8) & 0xff, sniListInner.length & 0xff]),
|
|
29
|
+
sniListInner,
|
|
30
|
+
]);
|
|
31
|
+
const sniExt = Buffer.concat([
|
|
32
|
+
Buffer.from([0x00, 0x00]),
|
|
33
|
+
Buffer.from([(sniList.length >> 8) & 0xff, sniList.length & 0xff]),
|
|
34
|
+
sniList,
|
|
35
|
+
]);
|
|
36
|
+
const extensions = Buffer.concat([
|
|
37
|
+
Buffer.from([(sniExt.length >> 8) & 0xff, sniExt.length & 0xff]),
|
|
38
|
+
sniExt,
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const random = Buffer.alloc(32);
|
|
42
|
+
const sessionId = Buffer.from([0x00]);
|
|
43
|
+
const cipherSuites = Buffer.from([0x00, 0x02, 0xc0, 0x2f]);
|
|
44
|
+
const compMethods = Buffer.from([0x01, 0x00]);
|
|
45
|
+
|
|
46
|
+
const clientHelloBody = Buffer.concat([
|
|
47
|
+
Buffer.from([0x03, 0x03]),
|
|
48
|
+
random,
|
|
49
|
+
sessionId,
|
|
50
|
+
cipherSuites,
|
|
51
|
+
compMethods,
|
|
52
|
+
extensions,
|
|
53
|
+
]);
|
|
54
|
+
const handshake = Buffer.concat([
|
|
55
|
+
Buffer.from([0x01, (clientHelloBody.length >> 16) & 0xff, (clientHelloBody.length >> 8) & 0xff, clientHelloBody.length & 0xff]),
|
|
56
|
+
clientHelloBody,
|
|
57
|
+
]);
|
|
58
|
+
const record = Buffer.concat([
|
|
59
|
+
Buffer.from([0x16, 0x03, 0x01, (handshake.length >> 8) & 0xff, handshake.length & 0xff]),
|
|
60
|
+
handshake,
|
|
61
|
+
]);
|
|
62
|
+
return record;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("parseSni: extracts hostname from a well-formed ClientHello", () => {
|
|
66
|
+
const buf = buildClientHello("api.githubcopilot.com");
|
|
67
|
+
assert.equal(parseSni(buf), "api.githubcopilot.com");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("parseSni: returns null for too-short buffer", () => {
|
|
71
|
+
assert.equal(parseSni(Buffer.from([0x16, 0x03])), null);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("parseSni: returns null for non-TLS bytes", () => {
|
|
75
|
+
assert.equal(parseSni(Buffer.from("GET / HTTP/1.1\r\n\r\n", "ascii")), null);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("parseSni: returns null when no SNI extension is present", () => {
|
|
79
|
+
// ClientHello without extensions at all (extensions_length = 0).
|
|
80
|
+
const random = Buffer.alloc(32);
|
|
81
|
+
const sessionId = Buffer.from([0x00]);
|
|
82
|
+
const cipherSuites = Buffer.from([0x00, 0x02, 0xc0, 0x2f]);
|
|
83
|
+
const compMethods = Buffer.from([0x01, 0x00]);
|
|
84
|
+
const extensions = Buffer.from([0x00, 0x00]);
|
|
85
|
+
const body = Buffer.concat([
|
|
86
|
+
Buffer.from([0x03, 0x03]),
|
|
87
|
+
random,
|
|
88
|
+
sessionId,
|
|
89
|
+
cipherSuites,
|
|
90
|
+
compMethods,
|
|
91
|
+
extensions,
|
|
92
|
+
]);
|
|
93
|
+
const handshake = Buffer.concat([
|
|
94
|
+
Buffer.from([0x01, 0x00, (body.length >> 8) & 0xff, body.length & 0xff]),
|
|
95
|
+
body,
|
|
96
|
+
]);
|
|
97
|
+
const record = Buffer.concat([
|
|
98
|
+
Buffer.from([0x16, 0x03, 0x01, (handshake.length >> 8) & 0xff, handshake.length & 0xff]),
|
|
99
|
+
handshake,
|
|
100
|
+
]);
|
|
101
|
+
assert.equal(parseSni(record), null);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function startEchoServer() {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
const server = net.createServer((sock) => {
|
|
107
|
+
sock.on("data", (chunk) => sock.write(chunk));
|
|
108
|
+
});
|
|
109
|
+
server.listen(0, "127.0.0.1", () => {
|
|
110
|
+
const addr = server.address();
|
|
111
|
+
resolve({ server, port: addr.port });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
test("egress proxy: CONNECT establishes tunnel and emits event with destination + SNI", async () => {
|
|
117
|
+
const echo = await startEchoServer();
|
|
118
|
+
const cwd = makeProject();
|
|
119
|
+
const proxy = await startEgressProxy({ cwd, port: 0, hostId: "test-host" });
|
|
120
|
+
try {
|
|
121
|
+
await new Promise((resolve, reject) => {
|
|
122
|
+
const client = net.connect(proxy.port, "127.0.0.1", () => {
|
|
123
|
+
client.write(`CONNECT 127.0.0.1:${echo.port} HTTP/1.1\r\nHost: 127.0.0.1:${echo.port}\r\n\r\n`);
|
|
124
|
+
});
|
|
125
|
+
let phase = "wait-200";
|
|
126
|
+
let pending = "";
|
|
127
|
+
client.on("data", (chunk) => {
|
|
128
|
+
if (phase === "wait-200") {
|
|
129
|
+
pending += chunk.toString();
|
|
130
|
+
if (pending.includes("\r\n\r\n")) {
|
|
131
|
+
assert.match(pending, /HTTP\/1\.1 200/);
|
|
132
|
+
phase = "tls";
|
|
133
|
+
client.write(buildClientHello("api.githubcopilot.com"));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (phase === "tls") {
|
|
138
|
+
client.end();
|
|
139
|
+
resolve();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
client.on("error", reject);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Give the proxy a moment to flush the audit event.
|
|
146
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
147
|
+
|
|
148
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
149
|
+
const auditFile = path.join(cwd, ".context", "audit", `host-events-${date}.jsonl`);
|
|
150
|
+
assert.equal(fs.existsSync(auditFile), true, "audit file should exist");
|
|
151
|
+
const events = fs
|
|
152
|
+
.readFileSync(auditFile, "utf8")
|
|
153
|
+
.trim()
|
|
154
|
+
.split("\n")
|
|
155
|
+
.map((l) => JSON.parse(l));
|
|
156
|
+
const egress = events.find((e) => e.event_type === "egress_connection");
|
|
157
|
+
assert.ok(egress, "egress_connection event should be emitted");
|
|
158
|
+
assert.equal(egress.protocol, "https");
|
|
159
|
+
assert.equal(egress.destination.host, "127.0.0.1");
|
|
160
|
+
assert.equal(egress.destination.port, echo.port);
|
|
161
|
+
assert.equal(egress.sni, "api.githubcopilot.com");
|
|
162
|
+
assert.ok(egress.bytes_client_to_server > 0);
|
|
163
|
+
assert.ok(egress.bytes_server_to_client > 0);
|
|
164
|
+
} finally {
|
|
165
|
+
await proxy.stop();
|
|
166
|
+
await new Promise((r) => echo.server.close(r));
|
|
167
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("egress proxy: HTTP request also logs destination", async () => {
|
|
172
|
+
const echo = await startEchoServer();
|
|
173
|
+
const cwd = makeProject();
|
|
174
|
+
const proxy = await startEgressProxy({ cwd, port: 0, hostId: "test-host" });
|
|
175
|
+
try {
|
|
176
|
+
await new Promise((resolve, reject) => {
|
|
177
|
+
const client = net.connect(proxy.port, "127.0.0.1", () => {
|
|
178
|
+
client.write(
|
|
179
|
+
`GET http://127.0.0.1:${echo.port}/health HTTP/1.1\r\nHost: 127.0.0.1:${echo.port}\r\n\r\n`,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
client.on("data", () => {
|
|
183
|
+
client.end();
|
|
184
|
+
resolve();
|
|
185
|
+
});
|
|
186
|
+
client.on("error", reject);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
190
|
+
|
|
191
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
192
|
+
const auditFile = path.join(cwd, ".context", "audit", `host-events-${date}.jsonl`);
|
|
193
|
+
const events = fs
|
|
194
|
+
.readFileSync(auditFile, "utf8")
|
|
195
|
+
.trim()
|
|
196
|
+
.split("\n")
|
|
197
|
+
.map((l) => JSON.parse(l));
|
|
198
|
+
const egress = events.find((e) => e.event_type === "egress_connection");
|
|
199
|
+
assert.ok(egress);
|
|
200
|
+
assert.equal(egress.protocol, "http");
|
|
201
|
+
assert.equal(egress.destination.host, "127.0.0.1");
|
|
202
|
+
assert.equal(egress.destination.port, echo.port);
|
|
203
|
+
assert.equal(egress.sni, "127.0.0.1");
|
|
204
|
+
} finally {
|
|
205
|
+
await proxy.stop();
|
|
206
|
+
await new Promise((r) => echo.server.close(r));
|
|
207
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("egress proxy: malformed first line returns 400 and closes", async () => {
|
|
212
|
+
const cwd = makeProject();
|
|
213
|
+
const proxy = await startEgressProxy({ cwd, port: 0, hostId: "test-host" });
|
|
214
|
+
try {
|
|
215
|
+
const got = await new Promise((resolve) => {
|
|
216
|
+
const client = net.connect(proxy.port, "127.0.0.1", () => {
|
|
217
|
+
client.write("garbage line\r\n\r\n");
|
|
218
|
+
});
|
|
219
|
+
let buf = "";
|
|
220
|
+
client.on("data", (chunk) => {
|
|
221
|
+
buf += chunk.toString();
|
|
222
|
+
});
|
|
223
|
+
client.on("close", () => resolve(buf));
|
|
224
|
+
});
|
|
225
|
+
assert.match(got, /400/);
|
|
226
|
+
} finally {
|
|
227
|
+
await proxy.stop();
|
|
228
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("egress proxy: stop() closes the server", async () => {
|
|
233
|
+
const cwd = makeProject();
|
|
234
|
+
const proxy = await startEgressProxy({ cwd, port: 0, hostId: "test-host" });
|
|
235
|
+
assert.equal(proxy.isRunning(), true);
|
|
236
|
+
await proxy.stop();
|
|
237
|
+
assert.equal(proxy.isRunning(), false);
|
|
238
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
239
|
+
});
|