@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
@@ -0,0 +1,300 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { CortexDaemon } from "./server.js";
4
+ import type {
5
+ PolicyCheckPayload,
6
+ PolicyCheckResult,
7
+ TelemetryFlushPayload,
8
+ TelemetryFlushResult,
9
+ AuditLogPayload,
10
+ AuditLogResult,
11
+ } from "./protocol.js";
12
+ import { loadEnterpriseConfig, resolveEnterpriseActivation } from "../core/config.js";
13
+ import { pushMetrics } from "../enterprise/telemetry/sync.js";
14
+ import type { TelemetryMetrics } from "../core/telemetry/collector.js";
15
+ import { AuditWriter, type AuditEntry } from "../core/audit/writer.js";
16
+ import { PolicyStore } from "../core/policy/store.js";
17
+ import {
18
+ enforceInjectionPolicy,
19
+ isInjectionDefenseActive,
20
+ } from "../core/policy/enforce.js";
21
+ import { syncFromCloud } from "../enterprise/policy/sync.js";
22
+ import { startUngovernedScanner } from "./ungoverned-scanner.js";
23
+ import {
24
+ HeartbeatTracker,
25
+ writeTamperLock,
26
+ emitTamperAudit,
27
+ } from "./heartbeat-tracker.js";
28
+ import { startSyncTimer } from "./sync-checker.js";
29
+ import { startHostEventsPusher } from "./host-events-pusher.js";
30
+ import { startEgressProxy } from "./egress-proxy.js";
31
+ import { startHeartbeatPusher } from "./heartbeat-pusher.js";
32
+ import type { HeartbeatPayload, HeartbeatResult } from "./protocol.js";
33
+
34
+ /**
35
+ * Daemon entry point. Run by `cortex daemon start` (or auto-spawned by
36
+ * the first hook that needs it).
37
+ *
38
+ * v2.0.0: policy.check is currently a stub allowing all calls (real policy
39
+ * evaluation in subsequent commit). telemetry.flush is fully wired — the
40
+ * Stop hook now reliably pushes metrics.json even if MCP died abruptly.
41
+ */
42
+
43
+ function extractStringFields(value: unknown, out: string[] = []): string[] {
44
+ if (typeof value === "string") {
45
+ out.push(value);
46
+ } else if (Array.isArray(value)) {
47
+ for (const v of value) extractStringFields(v, out);
48
+ } else if (value && typeof value === "object") {
49
+ for (const v of Object.values(value as Record<string, unknown>)) {
50
+ extractStringFields(v, out);
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ async function policyCheck(
57
+ payload: PolicyCheckPayload,
58
+ ): Promise<PolicyCheckResult> {
59
+ if (!payload.cwd) return { allow: true };
60
+ const contextDir = join(payload.cwd, ".context");
61
+ if (!existsSync(contextDir)) return { allow: true };
62
+
63
+ const store = new PolicyStore(contextDir);
64
+ const policies = store.getMergedPolicies();
65
+ if (!isInjectionDefenseActive(policies)) {
66
+ return { allow: true };
67
+ }
68
+
69
+ const haystack = extractStringFields(payload.input).join("\n");
70
+ if (!haystack) return { allow: true };
71
+
72
+ const result = enforceInjectionPolicy(haystack, policies);
73
+ if (result.allowed) return { allow: true };
74
+
75
+ const topMatch = result.scan.matches[0];
76
+ const reason = topMatch
77
+ ? `prompt-injection-defense: ${topMatch.category} (${topMatch.matched.slice(0, 80)})`
78
+ : "prompt-injection-defense: flagged";
79
+ return { allow: false, reason };
80
+ }
81
+
82
+ function readMetrics(contextDir: string): TelemetryMetrics | null {
83
+ const path = join(contextDir, "telemetry", "metrics.json");
84
+ if (!existsSync(path)) return null;
85
+ try {
86
+ return JSON.parse(readFileSync(path, "utf8")) as TelemetryMetrics;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ async function telemetryFlush(
93
+ payload: TelemetryFlushPayload,
94
+ ): Promise<TelemetryFlushResult> {
95
+ const cwd = payload.cwd ?? process.cwd();
96
+ const contextDir = join(cwd, ".context");
97
+
98
+ if (!existsSync(contextDir)) {
99
+ return { flushed: false, events_pushed: 0 };
100
+ }
101
+
102
+ const config = loadEnterpriseConfig(contextDir);
103
+ const activation = resolveEnterpriseActivation(config);
104
+
105
+ if (!activation.active || !config.telemetry.enabled) {
106
+ // Community mode or telemetry disabled → nothing to push.
107
+ return { flushed: false, events_pushed: 0 };
108
+ }
109
+
110
+ if (!config.telemetry.endpoint || !config.telemetry.api_key) {
111
+ return { flushed: false, events_pushed: 0 };
112
+ }
113
+
114
+ const metrics = readMetrics(contextDir);
115
+ if (!metrics) {
116
+ // No metrics on disk yet — MCP probably hasn't flushed once. Nothing
117
+ // to push from disk. (MCP's interval flush + session-end push handle
118
+ // the in-memory case.)
119
+ return { flushed: false, events_pushed: 0 };
120
+ }
121
+
122
+ const result = await pushMetrics(
123
+ metrics,
124
+ config.telemetry.endpoint,
125
+ config.telemetry.api_key,
126
+ {
127
+ repo: basename(cwd),
128
+ session_id: payload.session_id,
129
+ },
130
+ );
131
+
132
+ if (!result.success) {
133
+ process.stderr.write(
134
+ `[cortex-daemon] telemetry push failed: ${result.error ?? "unknown"}\n`,
135
+ );
136
+ return { flushed: false, events_pushed: 0 };
137
+ }
138
+
139
+ return {
140
+ flushed: true,
141
+ events_pushed: metrics.total_tool_calls,
142
+ };
143
+ }
144
+
145
+ // Per-cwd AuditWriter cache. Daemon serves multiple projects so we don't
146
+ // want to instantiate (and lose buffered state) on every audit.log call.
147
+ const auditWriters = new Map<string, AuditWriter>();
148
+
149
+ function getAuditWriter(cwd: string): AuditWriter {
150
+ const contextDir = join(cwd, ".context");
151
+ let writer = auditWriters.get(contextDir);
152
+ if (!writer) {
153
+ writer = new AuditWriter(contextDir);
154
+ auditWriters.set(contextDir, writer);
155
+ }
156
+ return writer;
157
+ }
158
+
159
+ async function auditLog(payload: AuditLogPayload): Promise<AuditLogResult> {
160
+ if (!payload.cwd || !payload.entry) {
161
+ return { written: false };
162
+ }
163
+
164
+ const contextDir = join(payload.cwd, ".context");
165
+ if (!existsSync(contextDir)) {
166
+ return { written: false };
167
+ }
168
+
169
+ const writer = getAuditWriter(payload.cwd);
170
+ const entry: AuditEntry = {
171
+ timestamp: payload.entry.timestamp,
172
+ tool: payload.entry.tool,
173
+ input: payload.entry.input,
174
+ result_count: payload.entry.result_count ?? 0,
175
+ entities_returned: [],
176
+ rules_applied: [],
177
+ duration_ms: payload.entry.duration_ms ?? 0,
178
+ status: payload.entry.status,
179
+ event_type: payload.entry.event_type as AuditEntry["event_type"],
180
+ evidence_level: payload.entry.evidence_level,
181
+ resource_type: payload.entry.resource_type,
182
+ session_id: payload.entry.session_id,
183
+ metadata: payload.entry.metadata,
184
+ };
185
+
186
+ writer.log(entry);
187
+ return { written: true };
188
+ }
189
+
190
+ async function main(): Promise<void> {
191
+ // Phase 6: hook heartbeat tracker (per-session activity record + tamper detect).
192
+ const tracker = new HeartbeatTracker();
193
+ async function heartbeat(payload: HeartbeatPayload): Promise<HeartbeatResult> {
194
+ return tracker.recordHeartbeat(payload);
195
+ }
196
+
197
+ const daemon = new CortexDaemon({
198
+ onPolicyCheck: policyCheck,
199
+ onTelemetryFlush: telemetryFlush,
200
+ onAuditLog: auditLog,
201
+ onHeartbeat: heartbeat,
202
+ });
203
+ await daemon.start();
204
+
205
+ // Phase 5: Tier 3 ungoverned-session detection. Periodic process scan, audit
206
+ // emit per finding, optional SIGTERM in enforced mode (same-user only).
207
+ const scanInterval = parseInt(process.env.CORTEX_UNGOVERNED_SCAN_MS ?? "", 10);
208
+ const intervalMs = Number.isFinite(scanInterval) && scanInterval > 0 ? scanInterval : 60_000;
209
+ if (process.env.CORTEX_DISABLE_UNGOVERNED_SCAN !== "1") {
210
+ startUngovernedScanner({ cwd: process.cwd(), intervalMs });
211
+ }
212
+
213
+ // Phase 6: periodic tamper-checker. For each active session that had at
214
+ // least one tool-fired hook then went silent past missing_threshold_seconds,
215
+ // write .cortex-tamper.lock + audit event. The next SessionStart in
216
+ // enforced mode will refuse to register tools until 'cortex enterprise
217
+ // repair' clears the lock.
218
+ const tamperThreshold = parseInt(process.env.CORTEX_TAMPER_MISSING_THRESHOLD_S ?? "", 10);
219
+ const missingThresholdSeconds =
220
+ Number.isFinite(tamperThreshold) && tamperThreshold > 0 ? tamperThreshold : 300;
221
+ const tamperCheckInterval = parseInt(process.env.CORTEX_TAMPER_CHECK_MS ?? "", 10);
222
+ const tamperCheckMs =
223
+ Number.isFinite(tamperCheckInterval) && tamperCheckInterval > 0 ? tamperCheckInterval : 60_000;
224
+ // Phase 7: periodic sync-version-check + host-events push to cortex-web.
225
+ // Daemon runs as the user post-Fas-3 privilege drop, so sync only checks
226
+ // version availability (writes a notification + audit). Re-applying
227
+ // managed-settings still requires 'sudo cortex enterprise sync'.
228
+ const syncIntervalRaw = parseInt(process.env.CORTEX_SYNC_CHECK_MS ?? "", 10);
229
+ const syncIntervalMs =
230
+ Number.isFinite(syncIntervalRaw) && syncIntervalRaw > 0 ? syncIntervalRaw : 60 * 60 * 1000;
231
+ const pushIntervalRaw = parseInt(process.env.CORTEX_HOST_EVENTS_PUSH_MS ?? "", 10);
232
+ const pushIntervalMs =
233
+ Number.isFinite(pushIntervalRaw) && pushIntervalRaw > 0 ? pushIntervalRaw : 5 * 60 * 1000;
234
+ if (process.env.CORTEX_DISABLE_SYNC_CHECK !== "1") {
235
+ startSyncTimer(process.cwd(), syncIntervalMs);
236
+ }
237
+ if (process.env.CORTEX_DISABLE_HOST_EVENTS_PUSH !== "1") {
238
+ startHostEventsPusher(process.cwd(), pushIntervalMs);
239
+ }
240
+
241
+ // Govern host heartbeat — fills host_enrollment on cortex-web so the
242
+ // dashboard at /dashboard/govern actually shows this host.
243
+ const heartbeatRaw = parseInt(process.env.CORTEX_HEARTBEAT_PUSH_MS ?? "", 10);
244
+ const heartbeatMs =
245
+ Number.isFinite(heartbeatRaw) && heartbeatRaw > 0 ? heartbeatRaw : 5 * 60 * 1000;
246
+ if (process.env.CORTEX_DISABLE_HEARTBEAT_PUSH !== "1") {
247
+ startHeartbeatPusher(process.cwd(), heartbeatMs);
248
+ }
249
+
250
+ // Phase 4 task 19: cortex egress proxy. Logs SNI + destination per
251
+ // outbound connection (no TLS termination). cortex run sets
252
+ // HTTPS_PROXY/HTTP_PROXY for the Copilot wrap; other AI CLIs respect
253
+ // these env vars too if a developer wires them in.
254
+ const proxyPortRaw = parseInt(process.env.CORTEX_EGRESS_PROXY_PORT ?? "", 10);
255
+ const proxyPort = Number.isFinite(proxyPortRaw) && proxyPortRaw > 0 ? proxyPortRaw : 18888;
256
+ if (process.env.CORTEX_DISABLE_EGRESS_PROXY !== "1") {
257
+ startEgressProxy({ cwd: process.cwd(), port: proxyPort })
258
+ .then((handle) => {
259
+ process.stderr.write(
260
+ `[cortex-daemon] egress proxy listening on 127.0.0.1:${handle.port}\n`,
261
+ );
262
+ })
263
+ .catch((err) => {
264
+ process.stderr.write(
265
+ `[cortex-daemon] egress proxy failed to start: ${err instanceof Error ? err.message : String(err)}\n`,
266
+ );
267
+ });
268
+ }
269
+
270
+ if (process.env.CORTEX_DISABLE_TAMPER_CHECK !== "1") {
271
+ const checkTimer = setInterval(() => {
272
+ const detected = tracker.detectTamper({
273
+ cwds: [process.cwd()],
274
+ missingThresholdSeconds,
275
+ });
276
+ for (const entry of detected) {
277
+ try {
278
+ writeTamperLock(entry.cwd, entry);
279
+ } catch (err) {
280
+ process.stderr.write(
281
+ `[cortex-daemon] failed to write tamper lock: ${err instanceof Error ? err.message : String(err)}\n`,
282
+ );
283
+ }
284
+ void emitTamperAudit(entry.cwd, entry).catch((err) => {
285
+ process.stderr.write(
286
+ `[cortex-daemon] failed to emit tamper audit: ${err instanceof Error ? err.message : String(err)}\n`,
287
+ );
288
+ });
289
+ }
290
+ }, tamperCheckMs);
291
+ if (typeof checkTimer.unref === "function") checkTimer.unref();
292
+ }
293
+ }
294
+
295
+ main().catch((err) => {
296
+ process.stderr.write(
297
+ `[cortex-daemon] fatal: ${err instanceof Error ? err.message : String(err)}\n`,
298
+ );
299
+ process.exit(1);
300
+ });
@@ -0,0 +1,41 @@
1
+ import { join } from "node:path";
2
+ import { homedir, tmpdir, userInfo } from "node:os";
3
+ import { mkdirSync } from "node:fs";
4
+
5
+ /**
6
+ * Resolves filesystem locations the daemon and hooks share.
7
+ * Per-user, not per-project — one daemon serves all projects so warm graph
8
+ * + embeddings stay loaded across switches.
9
+ */
10
+
11
+ function safeUid(): string {
12
+ try {
13
+ const info = userInfo();
14
+ if (typeof info.uid === "number" && info.uid >= 0) {
15
+ return String(info.uid);
16
+ }
17
+ return info.username || "anon";
18
+ } catch {
19
+ return "anon";
20
+ }
21
+ }
22
+
23
+ export function daemonDir(): string {
24
+ const dir = join(homedir(), ".cortex");
25
+ mkdirSync(dir, { recursive: true });
26
+ return dir;
27
+ }
28
+
29
+ export function pidFilePath(): string {
30
+ return join(daemonDir(), "daemon.pid");
31
+ }
32
+
33
+ export function logFilePath(): string {
34
+ return join(daemonDir(), "daemon.log");
35
+ }
36
+
37
+ export function socketPath(): string {
38
+ // Keep socket in tmpdir per-user — Linux has 108-char path limit on
39
+ // sockaddr_un.sun_path so we avoid putting it under $HOME.
40
+ return join(tmpdir(), `cortex-${safeUid()}.sock`);
41
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Wire protocol between cortex hooks and the cortex daemon.
3
+ * Newline-delimited JSON over a Unix socket.
4
+ *
5
+ * Each request: { id, type, payload }
6
+ * Each response: { id, ok, result?, error? }
7
+ */
8
+
9
+ export type RequestType =
10
+ | "ping"
11
+ | "policy.check"
12
+ | "telemetry.flush"
13
+ | "audit.log"
14
+ | "heartbeat"
15
+ | "shutdown";
16
+
17
+ export type Request<T extends RequestType = RequestType> = {
18
+ id: string;
19
+ type: T;
20
+ payload: unknown;
21
+ };
22
+
23
+ export type Response = {
24
+ id: string;
25
+ ok: boolean;
26
+ result?: unknown;
27
+ error?: string;
28
+ };
29
+
30
+ export type PolicyCheckPayload = {
31
+ tool: string;
32
+ cwd: string;
33
+ // Tool-specific input — Claude Code sends this verbatim from PreToolUse
34
+ input: Record<string, unknown>;
35
+ };
36
+
37
+ export type PolicyCheckResult = {
38
+ allow: boolean;
39
+ reason?: string;
40
+ // Optional context to inject when allowing (rules, ADRs)
41
+ inject?: string[];
42
+ };
43
+
44
+ export type TelemetryFlushPayload = {
45
+ reason: "stop" | "session_end" | "interval";
46
+ session_id?: string;
47
+ // Working directory of the project whose telemetry should flush.
48
+ // Hook scripts pass Claude Code's cwd through here.
49
+ cwd?: string;
50
+ };
51
+
52
+ export type TelemetryFlushResult = {
53
+ flushed: boolean;
54
+ events_pushed: number;
55
+ };
56
+
57
+ export type AuditLogPayload = {
58
+ cwd: string;
59
+ // Subset of AuditEntry — daemon fills in date-based file routing.
60
+ // Caller passes only the event-shaped fields; daemon writes them
61
+ // as-is to the per-day audit log.
62
+ entry: {
63
+ timestamp: string;
64
+ tool: string;
65
+ input: Record<string, unknown>;
66
+ result_count?: number;
67
+ duration_ms?: number;
68
+ status?: "success" | "error";
69
+ event_type?: string;
70
+ evidence_level?: "required" | "diagnostic";
71
+ resource_type?: string;
72
+ session_id?: string;
73
+ metadata?: Record<string, unknown>;
74
+ };
75
+ };
76
+
77
+ export type AuditLogResult = {
78
+ written: boolean;
79
+ };
80
+
81
+ export type HeartbeatPayload = {
82
+ cli: "claude" | "codex" | "copilot";
83
+ hook:
84
+ | "PreToolUse"
85
+ | "UserPromptSubmit"
86
+ | "SessionStart"
87
+ | "SessionEnd"
88
+ | "Stop"
89
+ | "PreCompact";
90
+ session_id: string;
91
+ instance_id?: string;
92
+ cwd: string;
93
+ ts: string;
94
+ };
95
+
96
+ export type HeartbeatResult = {
97
+ recorded: boolean;
98
+ tamper_lock_active?: boolean;
99
+ };
100
+
101
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
@@ -0,0 +1,227 @@
1
+ import { createServer, type Server, type Socket } from "node:net";
2
+ import { writeFileSync, unlinkSync, existsSync } from "node:fs";
3
+ import { socketPath, pidFilePath } from "./paths.js";
4
+ import type {
5
+ Request,
6
+ Response,
7
+ PolicyCheckPayload,
8
+ PolicyCheckResult,
9
+ TelemetryFlushPayload,
10
+ TelemetryFlushResult,
11
+ AuditLogPayload,
12
+ AuditLogResult,
13
+ HeartbeatPayload,
14
+ HeartbeatResult,
15
+ } from "./protocol.js";
16
+
17
+ /**
18
+ * The cortex daemon serves hooks (PreToolUse, Stop, etc.) over a Unix socket.
19
+ * Long-lived per-user process. Hooks are thin shims; the daemon holds warm
20
+ * state (graph, embeddings, license cache).
21
+ *
22
+ * v2.0.0 MVP: ping + policy.check + telemetry.flush + shutdown.
23
+ * Future: full MCP-tool routing through the daemon (today MCP still runs
24
+ * its own per-session stdio process — see plan Fas 3.6).
25
+ */
26
+
27
+ const IDLE_SHUTDOWN_MS = 30 * 60 * 1000; // 30 min idle → shutdown
28
+
29
+ type DaemonOptions = {
30
+ onPolicyCheck?: (payload: PolicyCheckPayload) => Promise<PolicyCheckResult>;
31
+ onTelemetryFlush?: (
32
+ payload: TelemetryFlushPayload,
33
+ ) => Promise<TelemetryFlushResult>;
34
+ onAuditLog?: (payload: AuditLogPayload) => Promise<AuditLogResult>;
35
+ onHeartbeat?: (payload: HeartbeatPayload) => Promise<HeartbeatResult>;
36
+ };
37
+
38
+ export class CortexDaemon {
39
+ private server: Server | null = null;
40
+ private idleTimer: NodeJS.Timeout | null = null;
41
+ private readonly opts: DaemonOptions;
42
+
43
+ constructor(opts: DaemonOptions = {}) {
44
+ this.opts = opts;
45
+ }
46
+
47
+ async start(): Promise<void> {
48
+ const sockPath = socketPath();
49
+
50
+ // Clean up stale socket from a prior crash.
51
+ if (existsSync(sockPath)) {
52
+ try {
53
+ unlinkSync(sockPath);
54
+ } catch {
55
+ // ignore — listen() will surface the real error
56
+ }
57
+ }
58
+
59
+ const server = createServer((socket) => this.handleConnection(socket));
60
+ this.server = server;
61
+
62
+ await new Promise<void>((resolve, reject) => {
63
+ server.once("error", reject);
64
+ server.listen(sockPath, () => {
65
+ server.off("error", reject);
66
+ resolve();
67
+ });
68
+ });
69
+
70
+ // Persist PID so cortex CLI can detect a running daemon.
71
+ try {
72
+ writeFileSync(pidFilePath(), String(process.pid), "utf8");
73
+ } catch {
74
+ // Non-fatal — clients can still connect via socket existence.
75
+ }
76
+
77
+ this.armIdleTimer();
78
+ process.stderr.write(
79
+ `[cortex-daemon] listening on ${sockPath} pid=${process.pid}\n`,
80
+ );
81
+
82
+ // Best-effort cleanup on shutdown signals.
83
+ const cleanup = () => {
84
+ this.stop().finally(() => process.exit(0));
85
+ };
86
+ process.on("SIGINT", cleanup);
87
+ process.on("SIGTERM", cleanup);
88
+ }
89
+
90
+ async stop(): Promise<void> {
91
+ if (this.idleTimer) {
92
+ clearTimeout(this.idleTimer);
93
+ this.idleTimer = null;
94
+ }
95
+ if (this.server) {
96
+ const srv = this.server;
97
+ this.server = null;
98
+ await new Promise<void>((resolve) => srv.close(() => resolve()));
99
+ }
100
+ try {
101
+ unlinkSync(pidFilePath());
102
+ } catch {
103
+ // ignore
104
+ }
105
+ try {
106
+ unlinkSync(socketPath());
107
+ } catch {
108
+ // ignore
109
+ }
110
+ }
111
+
112
+ private armIdleTimer(): void {
113
+ if (this.idleTimer) clearTimeout(this.idleTimer);
114
+ this.idleTimer = setTimeout(() => {
115
+ process.stderr.write("[cortex-daemon] idle shutdown\n");
116
+ this.stop().finally(() => process.exit(0));
117
+ }, IDLE_SHUTDOWN_MS);
118
+ this.idleTimer.unref();
119
+ }
120
+
121
+ private handleConnection(socket: Socket): void {
122
+ this.armIdleTimer();
123
+ let buffer = "";
124
+
125
+ socket.on("data", (chunk) => {
126
+ buffer += chunk.toString("utf8");
127
+ let nlIndex: number;
128
+ while ((nlIndex = buffer.indexOf("\n")) !== -1) {
129
+ const line = buffer.slice(0, nlIndex);
130
+ buffer = buffer.slice(nlIndex + 1);
131
+ if (!line.trim()) continue;
132
+ void this.handleLine(socket, line);
133
+ }
134
+ });
135
+
136
+ socket.on("error", () => {
137
+ // Suppress — clients dropping mid-write must not crash the daemon.
138
+ });
139
+ }
140
+
141
+ private async handleLine(socket: Socket, line: string): Promise<void> {
142
+ let req: Request;
143
+ try {
144
+ req = JSON.parse(line) as Request;
145
+ } catch {
146
+ this.sendError(socket, "<unknown>", "invalid_json");
147
+ return;
148
+ }
149
+
150
+ try {
151
+ switch (req.type) {
152
+ case "ping":
153
+ this.sendOk(socket, req.id, { pong: true, pid: process.pid });
154
+ return;
155
+ case "policy.check": {
156
+ if (!this.opts.onPolicyCheck) {
157
+ this.sendOk(socket, req.id, { allow: true } as PolicyCheckResult);
158
+ return;
159
+ }
160
+ const result = await this.opts.onPolicyCheck(
161
+ req.payload as PolicyCheckPayload,
162
+ );
163
+ this.sendOk(socket, req.id, result);
164
+ return;
165
+ }
166
+ case "telemetry.flush": {
167
+ if (!this.opts.onTelemetryFlush) {
168
+ this.sendOk(socket, req.id, {
169
+ flushed: false,
170
+ events_pushed: 0,
171
+ } as TelemetryFlushResult);
172
+ return;
173
+ }
174
+ const result = await this.opts.onTelemetryFlush(
175
+ req.payload as TelemetryFlushPayload,
176
+ );
177
+ this.sendOk(socket, req.id, result);
178
+ return;
179
+ }
180
+ case "audit.log": {
181
+ if (!this.opts.onAuditLog) {
182
+ this.sendOk(socket, req.id, { written: false } as AuditLogResult);
183
+ return;
184
+ }
185
+ const result = await this.opts.onAuditLog(
186
+ req.payload as AuditLogPayload,
187
+ );
188
+ this.sendOk(socket, req.id, result);
189
+ return;
190
+ }
191
+ case "heartbeat": {
192
+ if (!this.opts.onHeartbeat) {
193
+ this.sendOk(socket, req.id, { recorded: false } as HeartbeatResult);
194
+ return;
195
+ }
196
+ const result = await this.opts.onHeartbeat(
197
+ req.payload as HeartbeatPayload,
198
+ );
199
+ this.sendOk(socket, req.id, result);
200
+ return;
201
+ }
202
+ case "shutdown":
203
+ this.sendOk(socket, req.id, { ok: true });
204
+ setTimeout(() => this.stop().finally(() => process.exit(0)), 50);
205
+ return;
206
+ default:
207
+ this.sendError(socket, req.id, `unknown_type: ${req.type}`);
208
+ }
209
+ } catch (err) {
210
+ this.sendError(
211
+ socket,
212
+ req.id,
213
+ err instanceof Error ? err.message : "unknown_error",
214
+ );
215
+ }
216
+ }
217
+
218
+ private sendOk(socket: Socket, id: string, result: unknown): void {
219
+ const payload: Response = { id, ok: true, result };
220
+ socket.write(`${JSON.stringify(payload)}\n`);
221
+ }
222
+
223
+ private sendError(socket: Socket, id: string, error: string): void {
224
+ const payload: Response = { id, ok: false, error };
225
+ socket.write(`${JSON.stringify(payload)}\n`);
226
+ }
227
+ }