@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
|
@@ -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
|
+
}
|