@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.
Files changed (79) hide show
  1. package/bin/cortex.mjs +679 -32
  2. package/bin/style.mjs +349 -0
  3. package/package.json +4 -3
  4. package/scaffold/mcp/package-lock.json +834 -671
  5. package/scaffold/mcp/package.json +1 -1
  6. package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
  7. package/scaffold/mcp/src/cli/govern.ts +987 -0
  8. package/scaffold/mcp/src/cli/run.ts +306 -0
  9. package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
  10. package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
  11. package/scaffold/mcp/src/core/audit/query.ts +81 -0
  12. package/scaffold/mcp/src/core/audit/writer.ts +68 -0
  13. package/scaffold/mcp/src/core/config.ts +329 -0
  14. package/scaffold/mcp/src/core/index.ts +34 -0
  15. package/scaffold/mcp/src/core/license.ts +202 -0
  16. package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
  17. package/scaffold/mcp/src/core/policy/injection.ts +229 -0
  18. package/scaffold/mcp/src/core/policy/store.ts +197 -0
  19. package/scaffold/mcp/src/core/rbac/check.ts +40 -0
  20. package/scaffold/mcp/src/core/telemetry/collector.ts +234 -0
  21. package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
  22. package/scaffold/mcp/src/core/validators/config.ts +47 -0
  23. package/scaffold/mcp/src/core/validators/engine.ts +199 -0
  24. package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
  25. package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
  26. package/scaffold/mcp/src/daemon/client.ts +155 -0
  27. package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
  28. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
  29. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
  30. package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
  31. package/scaffold/mcp/src/daemon/main.ts +300 -0
  32. package/scaffold/mcp/src/daemon/paths.ts +41 -0
  33. package/scaffold/mcp/src/daemon/protocol.ts +101 -0
  34. package/scaffold/mcp/src/daemon/server.ts +227 -0
  35. package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
  36. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
  37. package/scaffold/mcp/src/embed.ts +1 -1
  38. package/scaffold/mcp/src/embeddings.ts +1 -1
  39. package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
  40. package/scaffold/mcp/src/enterprise/index.ts +415 -0
  41. package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
  42. package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
  43. package/scaffold/mcp/src/enterprise/privacy/boundary.ts +212 -0
  44. package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
  45. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +72 -0
  46. package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
  47. package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
  48. package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
  49. package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
  50. package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
  51. package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
  52. package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
  53. package/scaffold/mcp/src/hooks/session-end.ts +73 -0
  54. package/scaffold/mcp/src/hooks/session-start.ts +78 -0
  55. package/scaffold/mcp/src/hooks/shared.ts +134 -0
  56. package/scaffold/mcp/src/hooks/stop.ts +60 -0
  57. package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
  58. package/scaffold/mcp/src/plugin.ts +150 -0
  59. package/scaffold/mcp/src/server.ts +218 -7
  60. package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
  61. package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
  62. package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
  63. package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
  64. package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
  65. package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
  66. package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
  67. package/scaffold/mcp/tests/govern.test.mjs +74 -0
  68. package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
  69. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
  70. package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
  71. package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
  72. package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
  73. package/scaffold/mcp/tests/run.test.mjs +109 -0
  74. package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
  75. package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
  76. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
  77. package/scaffold/scripts/bootstrap.sh +0 -11
  78. package/scaffold/scripts/doctor.sh +24 -4
  79. 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) => buildToolResult(await runContextSearch(SearchInput.parse(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) => buildToolResult(await runContextRelated(RelatedInput.parse(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) => buildToolResult(await runContextImpact(ImpactInput.parse(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) => buildToolResult(await runContextRules(RulesInput.parse(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 buildToolResult(await reloadContextGraph(parsed.force));
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
+ });