@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,73 @@
1
+ import { call } from "../daemon/client.js";
2
+ import type {
3
+ AuditLogPayload,
4
+ AuditLogResult,
5
+ TelemetryFlushPayload,
6
+ TelemetryFlushResult,
7
+ } from "../daemon/protocol.js";
8
+ import {
9
+ ensureDaemon,
10
+ parseInput,
11
+ readStdin,
12
+ resolveDaemonEntry,
13
+ sendHeartbeat,
14
+ } from "./shared.js";
15
+
16
+ /**
17
+ * SessionEnd hook. Final telemetry push + audit log when Claude Code's
18
+ * session terminates entirely (more authoritative than Stop, which fires
19
+ * each turn).
20
+ */
21
+
22
+ type ClaudeSessionEndInput = {
23
+ session_id?: string;
24
+ cwd?: string;
25
+ };
26
+
27
+ async function main(): Promise<void> {
28
+ const raw = await readStdin();
29
+ const input = parseInput(raw) as ClaudeSessionEndInput;
30
+ const cwd = input.cwd ?? process.cwd();
31
+
32
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
33
+
34
+ // Audit first — even if telemetry push fails, we want the session-end
35
+ // record locally so cortex-web can later derive missing data.
36
+ const auditPayload: AuditLogPayload = {
37
+ cwd,
38
+ entry: {
39
+ timestamp: new Date().toISOString(),
40
+ tool: "session.end",
41
+ input: { session_id: input.session_id ?? null },
42
+ event_type: "session",
43
+ evidence_level: "diagnostic",
44
+ resource_type: "session",
45
+ session_id: input.session_id,
46
+ },
47
+ };
48
+ await call<AuditLogResult>("audit.log", auditPayload, { timeoutMs: 3000 });
49
+
50
+ // Final flush — best-effort, may have nothing on disk if MCP already
51
+ // flushed during shutdown.
52
+ const flushPayload: TelemetryFlushPayload = {
53
+ reason: "session_end",
54
+ session_id: input.session_id,
55
+ cwd,
56
+ };
57
+ await call<TelemetryFlushResult>("telemetry.flush", flushPayload, {
58
+ timeoutMs: 5000,
59
+ });
60
+
61
+ if (input.session_id) {
62
+ await sendHeartbeat({
63
+ cli: "claude",
64
+ hook: "SessionEnd",
65
+ session_id: input.session_id,
66
+ cwd,
67
+ });
68
+ }
69
+
70
+ process.exit(0);
71
+ }
72
+
73
+ main().catch(() => process.exit(0));
@@ -0,0 +1,78 @@
1
+ import { call } from "../daemon/client.js";
2
+ import type { AuditLogPayload, AuditLogResult } from "../daemon/protocol.js";
3
+ import {
4
+ ensureDaemon,
5
+ isEnforcedMode,
6
+ parseInput,
7
+ readStdin,
8
+ readTamperLockJson,
9
+ resolveDaemonEntry,
10
+ sendHeartbeat,
11
+ } from "./shared.js";
12
+
13
+ /**
14
+ * SessionStart hook for Claude Code.
15
+ *
16
+ * Phase 6 additions:
17
+ * - In enforced mode, refuses session start if a tamper-lock exists.
18
+ * The user must run `cortex enterprise repair` (sudo) to clear it.
19
+ * - Always sends a heartbeat so the daemon's tamper-tracker can spot
20
+ * silent post-startup hook removal.
21
+ *
22
+ * Audit/telemetry/heartbeat failures must never block legitimate session
23
+ * startup — only the explicit tamper-lock check does.
24
+ */
25
+
26
+ type ClaudeSessionStartInput = {
27
+ session_id?: string;
28
+ cwd?: string;
29
+ };
30
+
31
+ async function main(): Promise<void> {
32
+ const raw = await readStdin();
33
+ const input = parseInput(raw) as ClaudeSessionStartInput;
34
+ const cwd = input.cwd ?? process.cwd();
35
+ const sessionId = input.session_id ?? "";
36
+
37
+ // Tamper-lock check happens BEFORE we spawn the daemon. We don't want
38
+ // a tampered project to keep getting fresh daemons started.
39
+ const lock = readTamperLockJson(cwd);
40
+ if (lock && isEnforcedMode(cwd)) {
41
+ process.stderr.write(
42
+ "[cortex] Govern enforced: session blocked because hook tampering was detected.\n" +
43
+ " Run 'sudo cortex enterprise repair' to verify managed-settings\n" +
44
+ " integrity and clear .context/.cortex-tamper.lock, then retry.\n",
45
+ );
46
+ process.exit(2);
47
+ }
48
+
49
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
50
+
51
+ const payload: AuditLogPayload = {
52
+ cwd,
53
+ entry: {
54
+ timestamp: new Date().toISOString(),
55
+ tool: "session.start",
56
+ input: { session_id: sessionId || null },
57
+ event_type: "session",
58
+ evidence_level: "diagnostic",
59
+ resource_type: "session",
60
+ session_id: sessionId,
61
+ },
62
+ };
63
+
64
+ await call<AuditLogResult>("audit.log", payload, { timeoutMs: 3000 });
65
+
66
+ if (sessionId) {
67
+ await sendHeartbeat({
68
+ cli: "claude",
69
+ hook: "SessionStart",
70
+ session_id: sessionId,
71
+ cwd,
72
+ });
73
+ }
74
+
75
+ process.exit(0);
76
+ }
77
+
78
+ main().catch(() => process.exit(0));
@@ -0,0 +1,134 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { call, isDaemonRunning, spawnDaemon } from "../daemon/client.js";
4
+ import { fileURLToPath } from "node:url";
5
+ import type { HeartbeatPayload, HeartbeatResult } from "../daemon/protocol.js";
6
+
7
+ /**
8
+ * Shared utilities for hook scripts.
9
+ *
10
+ * Hook flow (Claude Code spec):
11
+ * 1. Claude Code spawns the hook script with input JSON on stdin
12
+ * 2. Hook reads stdin, makes a decision
13
+ * 3. Hook prints JSON to stdout (or exits non-zero to block)
14
+ * 4. exit 0 = allow, exit 2 = block, other non-zero = error
15
+ */
16
+
17
+ export type HookInput = {
18
+ // Claude Code's documented hook input shape — varies by hook type.
19
+ // We read it as a generic record and let the hook narrow.
20
+ [key: string]: unknown;
21
+ };
22
+
23
+ export async function readStdin(): Promise<string> {
24
+ return new Promise((resolve) => {
25
+ let data = "";
26
+ process.stdin.setEncoding("utf8");
27
+ process.stdin.on("data", (chunk) => {
28
+ data += chunk;
29
+ });
30
+ process.stdin.on("end", () => resolve(data));
31
+ // If stdin is a TTY (running interactively for testing), don't hang.
32
+ if (process.stdin.isTTY) resolve("");
33
+ });
34
+ }
35
+
36
+ export function parseInput(raw: string): HookInput {
37
+ if (!raw.trim()) return {};
38
+ try {
39
+ return JSON.parse(raw) as HookInput;
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Detect whether the current project is running enterprise mode.
47
+ * Lightweight YAML peek — we don't want to load the full config parser
48
+ * on every hook invocation.
49
+ */
50
+ export function isEnterpriseProject(cwd: string): boolean {
51
+ const candidates = [
52
+ join(cwd, ".context", "enterprise.yml"),
53
+ join(cwd, ".context", "enterprise.yaml"),
54
+ ];
55
+ for (const path of candidates) {
56
+ if (!existsSync(path)) continue;
57
+ try {
58
+ const raw = readFileSync(path, "utf8");
59
+ // Look for an api_key under enterprise: section.
60
+ // Conservative: any non-empty api_key value implies "intent to be enterprise".
61
+ const match = raw.match(/^\s*api_key:\s*(\S.*?)\s*$/m);
62
+ if (match && match[1] && match[1] !== "") return true;
63
+ } catch {
64
+ // ignore
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+
70
+ export function ensureDaemon(daemonEntry: string): void {
71
+ if (isDaemonRunning()) return;
72
+ spawnDaemon(daemonEntry);
73
+ }
74
+
75
+ /**
76
+ * Resolve daemon entry script path relative to this hook script's location.
77
+ * Hooks live at dist/hooks/<name>.js, daemon at dist/daemon/main.js.
78
+ */
79
+ export function resolveDaemonEntry(hookFileUrl: string): string {
80
+ const hookPath = fileURLToPath(hookFileUrl);
81
+ // dist/hooks/<x>.js → dist/daemon/main.js
82
+ return join(hookPath, "..", "..", "daemon", "main.js");
83
+ }
84
+
85
+ /**
86
+ * Send a heartbeat to the daemon for tamper-detection bookkeeping.
87
+ * Failure to reach the daemon is non-fatal — daemon-down is treated as
88
+ * "we don't know" rather than tamper. Returns whether the daemon
89
+ * reported an active tamper-lock for this cwd.
90
+ */
91
+ export async function sendHeartbeat(
92
+ payload: Omit<HeartbeatPayload, "ts">,
93
+ ): Promise<{ tamperLockActive: boolean }> {
94
+ const result = await call<HeartbeatResult>(
95
+ "heartbeat",
96
+ { ...payload, ts: new Date().toISOString() } satisfies HeartbeatPayload,
97
+ { timeoutMs: 1500 },
98
+ );
99
+ if (!result.ok) return { tamperLockActive: false };
100
+ return { tamperLockActive: result.result.tamper_lock_active === true };
101
+ }
102
+
103
+ const TAMPER_LOCK_FILENAME = ".cortex-tamper.lock";
104
+
105
+ export function readTamperLockJson(cwd: string): unknown | null {
106
+ const path = join(cwd, ".context", TAMPER_LOCK_FILENAME);
107
+ if (!existsSync(path)) return null;
108
+ try {
109
+ return JSON.parse(readFileSync(path, "utf8"));
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * True iff this cwd is in enforced govern mode. Reads govern.local.json,
117
+ * which is written by the install/install-flow. We don't fall back to
118
+ * enterprise.yml because the user's intent is captured at install time.
119
+ */
120
+ export function isEnforcedMode(cwd: string): boolean {
121
+ const path = join(cwd, ".context", "govern.local.json");
122
+ if (!existsSync(path)) return false;
123
+ try {
124
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as {
125
+ installs?: Record<string, { mode?: string }>;
126
+ };
127
+ for (const inst of Object.values(parsed.installs ?? {})) {
128
+ if (inst.mode === "enforced") return true;
129
+ }
130
+ return false;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
@@ -0,0 +1,60 @@
1
+ import { call } from "../daemon/client.js";
2
+ import type { TelemetryFlushPayload, TelemetryFlushResult } from "../daemon/protocol.js";
3
+ import {
4
+ ensureDaemon,
5
+ parseInput,
6
+ readStdin,
7
+ resolveDaemonEntry,
8
+ } from "./shared.js";
9
+
10
+ /**
11
+ * Stop hook for Claude Code.
12
+ *
13
+ * Fires when Claude finishes responding. We use this to guarantee a
14
+ * telemetry flush — historically metrics.json was lost when MCP exited
15
+ * abruptly. The Stop hook runs in Claude Code's process tree, not the
16
+ * MCP server's, so it survives MCP shutdown.
17
+ *
18
+ * Always exits 0 — telemetry failures must never block the user.
19
+ */
20
+
21
+ type ClaudeStopInput = {
22
+ session_id?: string;
23
+ cwd?: string;
24
+ };
25
+
26
+ async function main(): Promise<void> {
27
+ const raw = await readStdin();
28
+ const input = parseInput(raw) as ClaudeStopInput;
29
+
30
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
31
+
32
+ const payload: TelemetryFlushPayload = {
33
+ reason: "stop",
34
+ session_id: input.session_id,
35
+ cwd: input.cwd ?? process.cwd(),
36
+ };
37
+
38
+ const res = await call<TelemetryFlushResult>("telemetry.flush", payload, {
39
+ timeoutMs: 5000,
40
+ });
41
+
42
+ if (res.ok && res.result.flushed) {
43
+ process.stderr.write(
44
+ `[cortex] Telemetry flushed: ${res.result.events_pushed} events\n`,
45
+ );
46
+ } else if (!res.ok) {
47
+ // Best-effort: silent in normal output. Daemon-reachability is logged
48
+ // through other channels (PreToolUse warnings, daemon log).
49
+ process.stderr.write(
50
+ `[cortex] Telemetry flush skipped: ${res.error}\n`,
51
+ );
52
+ }
53
+
54
+ process.exit(0);
55
+ }
56
+
57
+ main().catch(() => {
58
+ // Telemetry must never block. Swallow all errors.
59
+ process.exit(0);
60
+ });
@@ -0,0 +1,64 @@
1
+ import { call } from "../daemon/client.js";
2
+ import type { AuditLogPayload, AuditLogResult } from "../daemon/protocol.js";
3
+ import {
4
+ ensureDaemon,
5
+ parseInput,
6
+ readStdin,
7
+ resolveDaemonEntry,
8
+ sendHeartbeat,
9
+ } from "./shared.js";
10
+
11
+ /**
12
+ * UserPromptSubmit hook. Fires when the user submits a prompt to Claude.
13
+ *
14
+ * v2.0.0 MVP: logs the event for audit. A future commit will use this
15
+ * hook to inject mandatory rules / ADRs as system context so Claude
16
+ * cannot proceed without seeing them.
17
+ */
18
+
19
+ type ClaudeUserPromptInput = {
20
+ session_id?: string;
21
+ cwd?: string;
22
+ // Claude passes the prompt — we only log length, not contents, to
23
+ // avoid logging sensitive user input by default.
24
+ prompt?: string;
25
+ };
26
+
27
+ async function main(): Promise<void> {
28
+ const raw = await readStdin();
29
+ const input = parseInput(raw) as ClaudeUserPromptInput;
30
+ const cwd = input.cwd ?? process.cwd();
31
+
32
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
33
+
34
+ const promptLen = typeof input.prompt === "string" ? input.prompt.length : 0;
35
+
36
+ const payload: AuditLogPayload = {
37
+ cwd,
38
+ entry: {
39
+ timestamp: new Date().toISOString(),
40
+ tool: "user.prompt",
41
+ input: { prompt_length: promptLen },
42
+ event_type: "session",
43
+ evidence_level: "diagnostic",
44
+ resource_type: "user_input",
45
+ session_id: input.session_id,
46
+ metadata: { prompt_length: promptLen },
47
+ },
48
+ };
49
+
50
+ await call<AuditLogResult>("audit.log", payload, { timeoutMs: 3000 });
51
+
52
+ if (input.session_id) {
53
+ await sendHeartbeat({
54
+ cli: "claude",
55
+ hook: "UserPromptSubmit",
56
+ session_id: input.session_id,
57
+ cwd,
58
+ });
59
+ }
60
+
61
+ process.exit(0);
62
+ }
63
+
64
+ main().catch(() => process.exit(0));
@@ -0,0 +1,150 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+
3
+ export type ToolCallHook = (toolName: string, resultCount: number, tokensSaved: number) => void;
4
+
5
+ export type ToolExecutionPhase = "start" | "success" | "error";
6
+
7
+ export type ToolExecutionEvent = {
8
+ phase: ToolExecutionPhase;
9
+ tool: string;
10
+ timestamp: string;
11
+ input: Record<string, unknown>;
12
+ query?: string;
13
+ query_length?: number;
14
+ result_count?: number;
15
+ estimated_tokens_saved?: number;
16
+ entities_returned?: string[];
17
+ rules_applied?: string[];
18
+ duration_ms?: number;
19
+ error?: string;
20
+ };
21
+
22
+ export type SessionCallRecord = {
23
+ tool: string;
24
+ query?: string;
25
+ resultCount: number;
26
+ time: string;
27
+ outcome?: "success" | "error";
28
+ duration_ms?: number;
29
+ error?: string;
30
+ };
31
+
32
+ export type SessionEndHook = (calls: SessionCallRecord[]) => Promise<void>;
33
+
34
+ export type SessionPhase = "start" | "end";
35
+
36
+ export type SessionEvent = {
37
+ phase: SessionPhase;
38
+ timestamp: string;
39
+ duration_ms?: number;
40
+ tool_calls?: number;
41
+ successful_tool_calls?: number;
42
+ failed_tool_calls?: number;
43
+ calls?: SessionCallRecord[];
44
+ };
45
+
46
+ export type ToolEventHook = (event: ToolExecutionEvent) => void | Promise<void>;
47
+ export type SessionEventHook = (event: SessionEvent) => void | Promise<void>;
48
+
49
+ export type CortexPlugin = {
50
+ name: string;
51
+ version: string;
52
+ register: (server: McpServer) => void | Promise<void>;
53
+ onToolCall?: ToolCallHook;
54
+ onSessionEnd?: SessionEndHook;
55
+ onToolEvent?: ToolEventHook;
56
+ onSessionEvent?: SessionEventHook;
57
+ };
58
+
59
+ export type EditionInfo = {
60
+ edition: "community" | "enterprise";
61
+ name?: string;
62
+ version?: string;
63
+ };
64
+
65
+ let loadedEdition: EditionInfo = { edition: "community" };
66
+ let toolCallHook: ToolCallHook | null = null;
67
+ let sessionEndHook: SessionEndHook | null = null;
68
+ let toolEventHook: ToolEventHook | null = null;
69
+ let sessionEventHook: SessionEventHook | null = null;
70
+
71
+ export function getEdition(): EditionInfo {
72
+ return loadedEdition;
73
+ }
74
+
75
+ export function getToolCallHook(): ToolCallHook | null {
76
+ return toolCallHook;
77
+ }
78
+
79
+ export function getSessionEndHook(): SessionEndHook | null {
80
+ return sessionEndHook;
81
+ }
82
+
83
+ export function getToolEventHook(): ToolEventHook | null {
84
+ return toolEventHook;
85
+ }
86
+
87
+ export function getSessionEventHook(): SessionEventHook | null {
88
+ return sessionEventHook;
89
+ }
90
+
91
+ export async function loadPlugins(server: McpServer): Promise<void> {
92
+ // v2.0.0: enterprise is now in-process. We still gate registration on
93
+ // whether enterprise activation succeeds (license + config), so community
94
+ // users get a no-op when no api_key is present.
95
+ try {
96
+ const enterprise = await import("./enterprise/index.js");
97
+ const { resolveEnterpriseActivation, loadEnterpriseConfig } = await import("./core/config.js");
98
+ const path = await import("node:path");
99
+ const projectRoot = process.env.CORTEX_PROJECT_ROOT?.trim() || process.cwd();
100
+ const contextDir = path.join(projectRoot, ".context");
101
+ const config = loadEnterpriseConfig(contextDir);
102
+ const activation = resolveEnterpriseActivation(config);
103
+
104
+ if (!activation.active) {
105
+ // Community mode: no api_key or invalid config. Skip registration.
106
+ return;
107
+ }
108
+
109
+ // v2.0.0: validate license against cortex-web before activating.
110
+ // The verifier uses 24h cache + 7d grace period so transient endpoint
111
+ // outages don't degrade enterprise users immediately.
112
+ const { verifyLicense } = await import("./core/license.js");
113
+ const license = await verifyLicense(contextDir, config.enterprise.endpoint, config.enterprise.api_key, {
114
+ client_version: process.env.CORTEX_VERSION,
115
+ });
116
+
117
+ if (!license.valid) {
118
+ process.stderr.write(
119
+ `[cortex] Enterprise inactive: license check failed (${license.reason}, source=${license.source})\n`
120
+ );
121
+ return;
122
+ }
123
+
124
+ if (typeof enterprise.register === "function") {
125
+ await enterprise.register(server);
126
+ loadedEdition = {
127
+ edition: "enterprise",
128
+ name: enterprise.name ?? "enterprise",
129
+ version: enterprise.version ?? "unknown",
130
+ };
131
+ if (typeof enterprise.onToolCall === "function") {
132
+ toolCallHook = enterprise.onToolCall;
133
+ }
134
+ if (typeof enterprise.onSessionEnd === "function") {
135
+ sessionEndHook = enterprise.onSessionEnd;
136
+ }
137
+ if (typeof enterprise.onToolEvent === "function") {
138
+ toolEventHook = enterprise.onToolEvent;
139
+ }
140
+ if (typeof enterprise.onSessionEvent === "function") {
141
+ sessionEventHook = enterprise.onSessionEvent;
142
+ }
143
+ process.stderr.write(`[cortex] Enterprise loaded: ${loadedEdition.version}\n`);
144
+ }
145
+ } catch (error: unknown) {
146
+ process.stderr.write(
147
+ `[cortex] Enterprise activation failed: ${error instanceof Error ? error.message : "unknown error"}\n`
148
+ );
149
+ }
150
+ }