@danielblomma/cortex-mcp 1.7.2 → 2.0.3

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/README.md +4 -24
  2. package/bin/cortex.mjs +679 -32
  3. package/bin/style.mjs +349 -0
  4. package/package.json +4 -3
  5. package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
  6. package/scaffold/mcp/src/cli/govern.ts +987 -0
  7. package/scaffold/mcp/src/cli/run.ts +306 -0
  8. package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
  9. package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
  10. package/scaffold/mcp/src/core/audit/query.ts +81 -0
  11. package/scaffold/mcp/src/core/audit/writer.ts +68 -0
  12. package/scaffold/mcp/src/core/config.ts +329 -0
  13. package/scaffold/mcp/src/core/index.ts +34 -0
  14. package/scaffold/mcp/src/core/license.ts +202 -0
  15. package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
  16. package/scaffold/mcp/src/core/policy/injection.ts +229 -0
  17. package/scaffold/mcp/src/core/policy/store.ts +197 -0
  18. package/scaffold/mcp/src/core/rbac/check.ts +40 -0
  19. package/scaffold/mcp/src/core/telemetry/collector.ts +408 -0
  20. package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
  21. package/scaffold/mcp/src/core/validators/config.ts +47 -0
  22. package/scaffold/mcp/src/core/validators/engine.ts +199 -0
  23. package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
  24. package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
  25. package/scaffold/mcp/src/daemon/client.ts +155 -0
  26. package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
  27. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
  28. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
  29. package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
  30. package/scaffold/mcp/src/daemon/main.ts +435 -0
  31. package/scaffold/mcp/src/daemon/paths.ts +41 -0
  32. package/scaffold/mcp/src/daemon/protocol.ts +101 -0
  33. package/scaffold/mcp/src/daemon/server.ts +227 -0
  34. package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
  35. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
  36. package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
  37. package/scaffold/mcp/src/enterprise/index.ts +386 -0
  38. package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
  39. package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
  40. package/scaffold/mcp/src/enterprise/privacy/boundary.ts +214 -0
  41. package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
  42. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +73 -0
  43. package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
  44. package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
  45. package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
  46. package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
  47. package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
  48. package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
  49. package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
  50. package/scaffold/mcp/src/hooks/session-end.ts +73 -0
  51. package/scaffold/mcp/src/hooks/session-start.ts +78 -0
  52. package/scaffold/mcp/src/hooks/shared.ts +134 -0
  53. package/scaffold/mcp/src/hooks/stop.ts +60 -0
  54. package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
  55. package/scaffold/mcp/src/loadGraph.ts +2 -0
  56. package/scaffold/mcp/src/plugin.ts +150 -0
  57. package/scaffold/mcp/src/server.ts +218 -7
  58. package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
  59. package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
  60. package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
  61. package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
  62. package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
  63. package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
  64. package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
  65. package/scaffold/mcp/tests/govern.test.mjs +74 -0
  66. package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
  67. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
  68. package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
  69. package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
  70. package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
  71. package/scaffold/mcp/tests/run.test.mjs +109 -0
  72. package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
  73. package/scaffold/mcp/tests/telemetry-collector.test.mjs +30 -0
  74. package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
  75. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
  76. package/scaffold/scripts/bootstrap.sh +0 -11
  77. package/scaffold/scripts/doctor.sh +24 -4
  78. package/types.js +5 -0
  79. package/docs/MCP_MARKETPLACE.md +0 -160
@@ -0,0 +1,155 @@
1
+ import { connect, type Socket } from "node:net";
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { randomUUID } from "node:crypto";
5
+ import { socketPath, pidFilePath, logFilePath } from "./paths.js";
6
+ import {
7
+ DEFAULT_REQUEST_TIMEOUT_MS,
8
+ type Request,
9
+ type RequestType,
10
+ type Response,
11
+ } from "./protocol.js";
12
+
13
+ /**
14
+ * Hook-side client for talking to the cortex daemon.
15
+ * Handles auto-start, connect, request/response, and timeouts.
16
+ */
17
+
18
+ export type CallOptions = {
19
+ timeoutMs?: number;
20
+ autoStart?: boolean;
21
+ };
22
+
23
+ export type CallResult<T> =
24
+ | { ok: true; result: T }
25
+ | { ok: false; error: string };
26
+
27
+ export function isProcessAlive(pid: number): boolean {
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ } catch (err) {
32
+ if (err && typeof err === "object" && "code" in err && err.code === "EPERM") {
33
+ return true;
34
+ }
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function readDaemonPid(): number | null {
40
+ if (!existsSync(pidFilePath())) return null;
41
+ try {
42
+ const raw = readFileSync(pidFilePath(), "utf8").trim();
43
+ const pid = Number.parseInt(raw, 10);
44
+ if (!Number.isFinite(pid) || pid <= 0) return null;
45
+ return pid;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function isDaemonRunning(): boolean {
52
+ const pid = readDaemonPid();
53
+ if (pid === null) return false;
54
+ return isProcessAlive(pid);
55
+ }
56
+
57
+ /**
58
+ * Spawn the daemon as a detached process. Returns immediately — caller is
59
+ * responsible for waiting until socket is ready (typically via retry loop).
60
+ */
61
+ export function spawnDaemon(daemonEntryAbsPath: string): void {
62
+ const out = (() => {
63
+ try {
64
+ // openSync returns an fd we can pass to spawn for stdio redirection.
65
+ // Append-only — the daemon log accumulates across runs.
66
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
67
+ const fs = require("node:fs") as typeof import("node:fs");
68
+ return fs.openSync(logFilePath(), "a");
69
+ } catch {
70
+ return "ignore" as const;
71
+ }
72
+ })();
73
+
74
+ const child = spawn(process.execPath, [daemonEntryAbsPath], {
75
+ detached: true,
76
+ stdio: ["ignore", out, out],
77
+ env: { ...process.env, CORTEX_DAEMON_AUTOSTART: "1" },
78
+ });
79
+ child.unref();
80
+ }
81
+
82
+ async function connectWithRetry(timeoutMs: number): Promise<Socket | null> {
83
+ const start = Date.now();
84
+ while (Date.now() - start < timeoutMs) {
85
+ const sock = await new Promise<Socket | null>((resolve) => {
86
+ const s = connect(socketPath());
87
+ s.once("connect", () => resolve(s));
88
+ s.once("error", () => {
89
+ s.destroy();
90
+ resolve(null);
91
+ });
92
+ });
93
+ if (sock) return sock;
94
+ await new Promise((r) => setTimeout(r, 100));
95
+ }
96
+ return null;
97
+ }
98
+
99
+ export async function call<T>(
100
+ type: RequestType,
101
+ payload: unknown,
102
+ options: CallOptions = {},
103
+ ): Promise<CallResult<T>> {
104
+ const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
105
+ const id = randomUUID();
106
+
107
+ const sock = await connectWithRetry(timeoutMs);
108
+ if (!sock) {
109
+ return { ok: false, error: "daemon_unreachable" };
110
+ }
111
+
112
+ return new Promise<CallResult<T>>((resolve) => {
113
+ let buffer = "";
114
+ let settled = false;
115
+
116
+ const finish = (r: CallResult<T>) => {
117
+ if (settled) return;
118
+ settled = true;
119
+ sock.end();
120
+ resolve(r);
121
+ };
122
+
123
+ const timer = setTimeout(() => {
124
+ finish({ ok: false, error: "timeout" });
125
+ }, timeoutMs);
126
+ timer.unref();
127
+
128
+ sock.on("data", (chunk) => {
129
+ buffer += chunk.toString("utf8");
130
+ const nl = buffer.indexOf("\n");
131
+ if (nl === -1) return;
132
+ const line = buffer.slice(0, nl);
133
+ try {
134
+ const resp = JSON.parse(line) as Response;
135
+ if (resp.id !== id) return; // not ours; ignore
136
+ clearTimeout(timer);
137
+ if (resp.ok) {
138
+ finish({ ok: true, result: resp.result as T });
139
+ } else {
140
+ finish({ ok: false, error: resp.error ?? "unknown_error" });
141
+ }
142
+ } catch {
143
+ finish({ ok: false, error: "invalid_response" });
144
+ }
145
+ });
146
+
147
+ sock.on("error", (err) => {
148
+ clearTimeout(timer);
149
+ finish({ ok: false, error: `socket_error: ${err.message}` });
150
+ });
151
+
152
+ const request: Request = { id, type, payload };
153
+ sock.write(`${JSON.stringify(request)}\n`);
154
+ });
155
+ }
@@ -0,0 +1,331 @@
1
+ import { createServer, connect, type Server, type Socket } from "node:net";
2
+ import { hostname } from "node:os";
3
+ import { writeHostAuditEvent } from "./ungoverned-scanner.js";
4
+
5
+ /**
6
+ * Phase 4 task 19 — cortex egress proxy.
7
+ *
8
+ * Listens on a local TCP port for HTTP CONNECT (https-proxy) requests.
9
+ * Pipes bytes through transparently; does NOT terminate TLS. Inspects
10
+ * only:
11
+ * - the CONNECT line for the destination host:port
12
+ * - the first client chunk for the TLS ClientHello SNI extension
13
+ *
14
+ * Per privacy boundary v3 we never send payload bytes to cortex-web —
15
+ * only SNI + destination + bytes-transferred counters. The audit lands
16
+ * in .context/audit/host-events-YYYY-MM-DD.jsonl as event_type =
17
+ * "egress_connection". The host-events pusher (Fas 7) then forwards
18
+ * those to cortex-web on the periodic timer.
19
+ *
20
+ * Plain HTTP (non-CONNECT) is also supported but logged with the Host
21
+ * header in place of SNI.
22
+ */
23
+
24
+ const CONNECT_RE = /^CONNECT\s+([^\s:]+):(\d+)\s+HTTP\/1\.[01]/i;
25
+ const HTTP_RE = /^([A-Z]+)\s+(http:\/\/[^\s]+)\s+HTTP\/1\.[01]/i;
26
+
27
+ export type EgressEvent = {
28
+ event_type: "egress_connection";
29
+ timestamp: string;
30
+ host_id: string;
31
+ source_port: number | null;
32
+ destination: { host: string; port: number };
33
+ protocol: "https" | "http";
34
+ sni: string | null;
35
+ bytes_client_to_server: number;
36
+ bytes_server_to_client: number;
37
+ duration_ms: number;
38
+ closed_by: "client" | "server" | "error";
39
+ error: string | null;
40
+ };
41
+
42
+ /**
43
+ * Parse SNI from a TLS ClientHello buffer. Returns null if not found
44
+ * or buffer is malformed. Does not mutate the buffer.
45
+ */
46
+ export function parseSni(buf: Buffer): string | null {
47
+ if (buf.length < 11) return null;
48
+ if (buf[0] !== 0x16) return null;
49
+ if (buf[1] !== 0x03) return null;
50
+
51
+ const recordLen = buf.readUInt16BE(3);
52
+ const recordEnd = 5 + recordLen;
53
+ if (buf.length < recordEnd) return null;
54
+
55
+ if (buf[5] !== 0x01) return null;
56
+ const handshakeLen = (buf[6] << 16) | (buf[7] << 8) | buf[8];
57
+ const handshakeEnd = 9 + handshakeLen;
58
+ if (buf.length < Math.min(handshakeEnd, recordEnd)) return null;
59
+
60
+ let p = 9 + 2 + 32;
61
+ if (p + 1 > recordEnd) return null;
62
+ const sessionIdLen = buf[p];
63
+ p += 1 + sessionIdLen;
64
+ if (p + 2 > recordEnd) return null;
65
+ const cipherSuitesLen = buf.readUInt16BE(p);
66
+ p += 2 + cipherSuitesLen;
67
+ if (p + 1 > recordEnd) return null;
68
+ const compMethodsLen = buf[p];
69
+ p += 1 + compMethodsLen;
70
+ if (p + 2 > recordEnd) return null;
71
+ const extensionsLen = buf.readUInt16BE(p);
72
+ p += 2;
73
+ const extensionsEnd = p + extensionsLen;
74
+ if (extensionsEnd > recordEnd) return null;
75
+
76
+ while (p + 4 <= extensionsEnd) {
77
+ const extType = buf.readUInt16BE(p);
78
+ const extLen = buf.readUInt16BE(p + 2);
79
+ const extEnd = p + 4 + extLen;
80
+ if (extEnd > extensionsEnd) return null;
81
+ if (extType === 0x0000) {
82
+ let q = p + 4;
83
+ if (q + 2 > extEnd) return null;
84
+ const listLen = buf.readUInt16BE(q);
85
+ q += 2;
86
+ const listEnd = q + listLen;
87
+ if (listEnd > extEnd) return null;
88
+ while (q + 3 <= listEnd) {
89
+ const nameType = buf[q];
90
+ const nameLen = buf.readUInt16BE(q + 1);
91
+ q += 3;
92
+ if (q + nameLen > listEnd) return null;
93
+ if (nameType === 0x00) {
94
+ return buf.subarray(q, q + nameLen).toString("ascii");
95
+ }
96
+ q += nameLen;
97
+ }
98
+ return null;
99
+ }
100
+ p = extEnd;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ export type ProxyOptions = {
106
+ cwd: string;
107
+ port?: number;
108
+ hostId?: string;
109
+ };
110
+
111
+ export type ProxyHandle = {
112
+ port: number;
113
+ stop(): Promise<void>;
114
+ isRunning(): boolean;
115
+ };
116
+
117
+ const HTTP_OK = "HTTP/1.1 200 Connection Established\r\nProxy-Agent: cortex-egress\r\n\r\n";
118
+ const HTTP_BAD = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n";
119
+ const HTTP_BAD_GATEWAY = "HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n";
120
+
121
+ function emit(cwd: string, evt: EgressEvent): void {
122
+ void writeHostAuditEvent(cwd, evt as unknown as Record<string, unknown>).catch((err) => {
123
+ process.stderr.write(
124
+ `[cortex-egress] audit emit failed: ${err instanceof Error ? err.message : String(err)}\n`,
125
+ );
126
+ });
127
+ }
128
+
129
+ function newEvent(host: string, port: number, protocol: "https" | "http", hostId: string, sourcePort: number | null): EgressEvent {
130
+ return {
131
+ event_type: "egress_connection",
132
+ timestamp: new Date().toISOString(),
133
+ host_id: hostId,
134
+ source_port: sourcePort,
135
+ destination: { host, port },
136
+ protocol,
137
+ sni: null,
138
+ bytes_client_to_server: 0,
139
+ bytes_server_to_client: 0,
140
+ duration_ms: 0,
141
+ closed_by: "client",
142
+ error: null,
143
+ };
144
+ }
145
+
146
+ function pipeWithCounting(
147
+ client: Socket,
148
+ upstream: Socket,
149
+ evt: EgressEvent,
150
+ cwd: string,
151
+ start: number,
152
+ ): void {
153
+ let firstClientChunk = true;
154
+ client.on("data", (chunk) => {
155
+ evt.bytes_client_to_server += chunk.length;
156
+ if (firstClientChunk && evt.protocol === "https") {
157
+ firstClientChunk = false;
158
+ const sni = parseSni(chunk);
159
+ if (sni) evt.sni = sni;
160
+ }
161
+ upstream.write(chunk);
162
+ });
163
+ upstream.on("data", (chunk) => {
164
+ evt.bytes_server_to_client += chunk.length;
165
+ client.write(chunk);
166
+ });
167
+
168
+ const finalize = (closer: "client" | "server" | "error", error: string | null = null) => {
169
+ if (evt.duration_ms > 0) return;
170
+ evt.duration_ms = Date.now() - start;
171
+ evt.closed_by = closer;
172
+ evt.error = error;
173
+ emit(cwd, evt);
174
+ try {
175
+ client.destroy();
176
+ } catch {
177
+ // ignore
178
+ }
179
+ try {
180
+ upstream.destroy();
181
+ } catch {
182
+ // ignore
183
+ }
184
+ };
185
+
186
+ client.on("end", () => finalize("client"));
187
+ upstream.on("end", () => finalize("server"));
188
+ client.on("error", (err) => finalize("error", err.message));
189
+ upstream.on("error", (err) => finalize("error", err.message));
190
+ }
191
+
192
+ function handleConnect(
193
+ client: Socket,
194
+ host: string,
195
+ port: number,
196
+ cwd: string,
197
+ hostId: string,
198
+ ): void {
199
+ const evt = newEvent(host, port, "https", hostId, client.remotePort ?? null);
200
+ const start = Date.now();
201
+ const upstream = connect({ host, port }, () => {
202
+ client.write(HTTP_OK);
203
+ pipeWithCounting(client, upstream, evt, cwd, start);
204
+ });
205
+ upstream.on("error", (err) => {
206
+ evt.duration_ms = Date.now() - start;
207
+ evt.closed_by = "error";
208
+ evt.error = `upstream connect: ${err.message}`;
209
+ emit(cwd, evt);
210
+ try {
211
+ client.write(HTTP_BAD_GATEWAY);
212
+ client.destroy();
213
+ } catch {
214
+ // ignore
215
+ }
216
+ });
217
+ }
218
+
219
+ function handleHttp(
220
+ client: Socket,
221
+ url: URL,
222
+ initialChunk: Buffer,
223
+ cwd: string,
224
+ hostId: string,
225
+ ): void {
226
+ const port = url.port ? parseInt(url.port, 10) : 80;
227
+ const evt = newEvent(url.hostname, port, "http", hostId, client.remotePort ?? null);
228
+ evt.sni = url.hostname;
229
+ const start = Date.now();
230
+ const upstream = connect({ host: url.hostname, port }, () => {
231
+ upstream.write(initialChunk);
232
+ pipeWithCounting(client, upstream, evt, cwd, start);
233
+ });
234
+ upstream.on("error", (err) => {
235
+ evt.duration_ms = Date.now() - start;
236
+ evt.closed_by = "error";
237
+ evt.error = `upstream connect: ${err.message}`;
238
+ emit(cwd, evt);
239
+ try {
240
+ client.write(HTTP_BAD_GATEWAY);
241
+ client.destroy();
242
+ } catch {
243
+ // ignore
244
+ }
245
+ });
246
+ }
247
+
248
+ function handleConnection(client: Socket, cwd: string, hostId: string): void {
249
+ let buffer = Buffer.alloc(0);
250
+
251
+ const onFirstChunk = (chunk: Buffer) => {
252
+ buffer = Buffer.concat([buffer, chunk]);
253
+ const headerEnd = buffer.indexOf("\r\n\r\n");
254
+ if (headerEnd === -1) {
255
+ if (buffer.length > 8192) {
256
+ client.write(HTTP_BAD);
257
+ client.destroy();
258
+ return;
259
+ }
260
+ client.once("data", onFirstChunk);
261
+ return;
262
+ }
263
+ const headers = buffer.subarray(0, headerEnd).toString("ascii");
264
+ const remainder = buffer.subarray(headerEnd + 4);
265
+ const firstLine = headers.split(/\r?\n/, 1)[0] ?? "";
266
+
267
+ const connectMatch = firstLine.match(CONNECT_RE);
268
+ if (connectMatch) {
269
+ const host = connectMatch[1];
270
+ const port = parseInt(connectMatch[2], 10);
271
+ handleConnect(client, host, port, cwd, hostId);
272
+ return;
273
+ }
274
+
275
+ const httpMatch = firstLine.match(HTTP_RE);
276
+ if (httpMatch) {
277
+ let url: URL;
278
+ try {
279
+ url = new URL(httpMatch[2]);
280
+ } catch {
281
+ client.write(HTTP_BAD);
282
+ client.destroy();
283
+ return;
284
+ }
285
+ const path = url.pathname + url.search;
286
+ const rebuilt = `${httpMatch[1]} ${path} HTTP/1.1\r\n${headers.split(/\r?\n/).slice(1).join("\r\n")}\r\n\r\n`;
287
+ const initial = Buffer.concat([Buffer.from(rebuilt, "ascii"), remainder]);
288
+ handleHttp(client, url, initial, cwd, hostId);
289
+ return;
290
+ }
291
+
292
+ client.write(HTTP_BAD);
293
+ client.destroy();
294
+ };
295
+
296
+ client.once("data", onFirstChunk);
297
+ client.on("error", () => {
298
+ try {
299
+ client.destroy();
300
+ } catch {
301
+ // ignore
302
+ }
303
+ });
304
+ }
305
+
306
+ export function startEgressProxy(options: ProxyOptions): Promise<ProxyHandle> {
307
+ const port = options.port ?? 18888;
308
+ const hostId = options.hostId ?? hostname();
309
+ const cwd = options.cwd;
310
+
311
+ return new Promise((resolve, reject) => {
312
+ const server: Server = createServer((client) => handleConnection(client, cwd, hostId));
313
+ server.once("error", (err) => {
314
+ reject(err);
315
+ });
316
+ server.listen(port, "127.0.0.1", () => {
317
+ const addr = server.address();
318
+ const actualPort = typeof addr === "object" && addr ? addr.port : port;
319
+ let running = true;
320
+ resolve({
321
+ port: actualPort,
322
+ async stop(): Promise<void> {
323
+ if (!running) return;
324
+ running = false;
325
+ await new Promise<void>((res) => server.close(() => res()));
326
+ },
327
+ isRunning: () => running,
328
+ });
329
+ });
330
+ });
331
+ }
@@ -0,0 +1,147 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { hostname, platform, release } from "node:os";
4
+ import { loadEnterpriseConfig } from "../core/config.js";
5
+
6
+ /**
7
+ * Govern host heartbeat — fills the host_enrollment table on cortex-web.
8
+ *
9
+ * Reads .context/govern.local.json + enterprise.yml + OS info, builds a
10
+ * canonical payload matching governHeartbeatSchema on the server side,
11
+ * and POSTs it to /api/v1/govern/heartbeat. Without this, the dashboard
12
+ * at /dashboard/govern shows zero hosts forever.
13
+ *
14
+ * Periodic — default 5 min, same cadence as host-events-pusher.
15
+ */
16
+
17
+ const TIER_BY_CLI: Record<string, "prevent" | "wrap" | "detect" | "off"> = {
18
+ claude: "prevent",
19
+ codex: "prevent",
20
+ copilot: "wrap",
21
+ };
22
+
23
+ type LocalGovernState = {
24
+ installs?: Record<
25
+ string,
26
+ {
27
+ mode?: "advisory" | "enforced";
28
+ version?: string;
29
+ frameworks?: Array<{ id: string; version: string }>;
30
+ }
31
+ >;
32
+ };
33
+
34
+ function readLocalGovernState(cwd: string): LocalGovernState {
35
+ const p = join(cwd, ".context", "govern.local.json");
36
+ if (!existsSync(p)) return {};
37
+ try {
38
+ return JSON.parse(readFileSync(p, "utf8")) as LocalGovernState;
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ function mapOs(plat: NodeJS.Platform): "darwin" | "linux" | "windows" {
45
+ if (plat === "darwin") return "darwin";
46
+ if (plat === "linux") return "linux";
47
+ if (plat === "win32") return "windows";
48
+ return "linux";
49
+ }
50
+
51
+ export type HeartbeatPayload = {
52
+ host_id: string;
53
+ os: "darwin" | "linux" | "windows";
54
+ os_version?: string;
55
+ govern_mode: "off" | "advisory" | "enforced";
56
+ active_frameworks: string[];
57
+ config_version: string | null;
58
+ ai_clis_detected: Array<{
59
+ name: string;
60
+ tier: "prevent" | "wrap" | "detect" | "off";
61
+ version?: string;
62
+ last_seen?: string;
63
+ }>;
64
+ };
65
+
66
+ export function buildHeartbeatPayload(cwd: string, hostId?: string): HeartbeatPayload {
67
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
68
+ const state = readLocalGovernState(cwd);
69
+ const installs = state.installs ?? {};
70
+ const now = new Date().toISOString();
71
+
72
+ const aiClisDetected = Object.entries(installs).map(([name, info]) => ({
73
+ name,
74
+ tier: TIER_BY_CLI[name] ?? "off",
75
+ version: info.version,
76
+ last_seen: now,
77
+ }));
78
+
79
+ let governMode: "off" | "advisory" | "enforced" = "off";
80
+ for (const inst of Object.values(installs)) {
81
+ if (inst.mode === "enforced") {
82
+ governMode = "enforced";
83
+ break;
84
+ }
85
+ if (inst.mode === "advisory" && governMode === "off") governMode = "advisory";
86
+ }
87
+
88
+ const configVersion = Object.values(installs)[0]?.version ?? null;
89
+
90
+ return {
91
+ host_id: hostId ?? hostname(),
92
+ os: mapOs(platform()),
93
+ os_version: release(),
94
+ govern_mode: governMode,
95
+ active_frameworks: config.compliance.frameworks,
96
+ config_version: configVersion,
97
+ ai_clis_detected: aiClisDetected,
98
+ };
99
+ }
100
+
101
+ export type HeartbeatPushOutcome = { ok: true } | { ok: false; error: string };
102
+
103
+ export async function pushHeartbeat(cwd: string): Promise<HeartbeatPushOutcome> {
104
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
105
+ const apiKey = config.enterprise.api_key.trim();
106
+ const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
107
+ if (!apiKey || !baseUrl) {
108
+ return { ok: false, error: "enterprise not configured" };
109
+ }
110
+ const payload = buildHeartbeatPayload(cwd);
111
+ try {
112
+ const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/v1/govern/heartbeat`, {
113
+ method: "POST",
114
+ headers: {
115
+ "Content-Type": "application/json",
116
+ Authorization: `Bearer ${apiKey}`,
117
+ },
118
+ body: JSON.stringify(payload),
119
+ });
120
+ if (!res.ok) {
121
+ return { ok: false, error: `HTTP ${res.status} ${res.statusText}` };
122
+ }
123
+ return { ok: true };
124
+ } catch (err) {
125
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
126
+ }
127
+ }
128
+
129
+ export type HeartbeatHandle = { stop(): void };
130
+
131
+ export function startHeartbeatPusher(cwd: string, intervalMs: number): HeartbeatHandle {
132
+ const tick = () => {
133
+ void pushHeartbeat(cwd).catch((err) => {
134
+ process.stderr.write(
135
+ `[cortex-daemon] heartbeat push failed: ${err instanceof Error ? err.message : String(err)}\n`,
136
+ );
137
+ });
138
+ };
139
+ void Promise.resolve().then(tick);
140
+ const handle = setInterval(tick, intervalMs);
141
+ if (typeof handle.unref === "function") handle.unref();
142
+ return {
143
+ stop() {
144
+ clearInterval(handle);
145
+ },
146
+ };
147
+ }