@danielblomma/cortex-mcp 2.2.5 → 2.4.1

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 (196) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +73 -3
  3. package/bin/cortex.mjs +339 -242
  4. package/bin/daemon-control.mjs +162 -0
  5. package/package.json +7 -3
  6. package/scaffold/mcp/dist/.cortex-build-hash +1 -0
  7. package/scaffold/mcp/dist/cli/enterprise-setup.js +134 -0
  8. package/scaffold/mcp/dist/cli/govern.js +937 -0
  9. package/scaffold/mcp/dist/cli/query.js +409 -0
  10. package/scaffold/mcp/dist/cli/run.js +295 -0
  11. package/scaffold/mcp/dist/cli/stage.js +308 -0
  12. package/scaffold/mcp/dist/cli/telemetry-test.js +141 -0
  13. package/scaffold/mcp/dist/cli/ungoverned-detector.js +133 -0
  14. package/scaffold/mcp/dist/contextEntities.js +282 -0
  15. package/scaffold/mcp/dist/core/audit/query.js +72 -0
  16. package/scaffold/mcp/dist/core/audit/writer.js +28 -0
  17. package/scaffold/mcp/dist/core/config.js +235 -0
  18. package/scaffold/mcp/dist/core/enterprise-host-identity.js +193 -0
  19. package/scaffold/mcp/dist/core/enterprise-identity.js +18 -0
  20. package/scaffold/mcp/dist/core/enterprise-rotation.js +158 -0
  21. package/scaffold/mcp/dist/core/govern-paths.js +21 -0
  22. package/scaffold/mcp/dist/core/index.js +14 -0
  23. package/scaffold/mcp/dist/core/license.js +278 -0
  24. package/scaffold/mcp/dist/core/policy/enforce.js +66 -0
  25. package/scaffold/mcp/dist/core/policy/injection.js +172 -0
  26. package/scaffold/mcp/dist/core/policy/store.js +179 -0
  27. package/scaffold/mcp/dist/core/rbac/check.js +30 -0
  28. package/scaffold/mcp/dist/core/secure-endpoint.js +25 -0
  29. package/scaffold/mcp/dist/core/telemetry/collector.js +285 -0
  30. package/scaffold/mcp/dist/core/telemetry/state-dir.js +31 -0
  31. package/scaffold/mcp/dist/core/validators/builtins.js +632 -0
  32. package/scaffold/mcp/dist/core/validators/config.js +44 -0
  33. package/scaffold/mcp/dist/core/validators/engine.js +120 -0
  34. package/scaffold/mcp/dist/core/validators/evaluators/code_comments.js +236 -0
  35. package/scaffold/mcp/dist/core/validators/evaluators/regex.js +116 -0
  36. package/scaffold/mcp/dist/core/workflow/artifact-io.js +103 -0
  37. package/scaffold/mcp/dist/core/workflow/capabilities.js +88 -0
  38. package/scaffold/mcp/dist/core/workflow/default-workflows.js +102 -0
  39. package/scaffold/mcp/dist/core/workflow/enforcement.js +164 -0
  40. package/scaffold/mcp/dist/core/workflow/envelope.js +132 -0
  41. package/scaffold/mcp/dist/core/workflow/index.js +11 -0
  42. package/scaffold/mcp/dist/core/workflow/mcp-tools.js +175 -0
  43. package/scaffold/mcp/dist/core/workflow/resolution.js +69 -0
  44. package/scaffold/mcp/dist/core/workflow/run-lifecycle.js +123 -0
  45. package/scaffold/mcp/dist/core/workflow/schemas.js +141 -0
  46. package/scaffold/mcp/dist/core/workflow/synced-capability-registry.js +60 -0
  47. package/scaffold/mcp/dist/core/workflow/synced-registry.js +60 -0
  48. package/scaffold/mcp/dist/daemon/capability-sync-checker.js +242 -0
  49. package/scaffold/mcp/dist/daemon/client.js +130 -0
  50. package/scaffold/mcp/dist/daemon/egress-proxy.js +288 -0
  51. package/scaffold/mcp/dist/daemon/global-host-events.js +222 -0
  52. package/scaffold/mcp/dist/daemon/heartbeat-pusher.js +116 -0
  53. package/scaffold/mcp/dist/daemon/heartbeat-tracker.js +165 -0
  54. package/scaffold/mcp/dist/daemon/host-events-pusher.js +249 -0
  55. package/scaffold/mcp/dist/daemon/main.js +449 -0
  56. package/scaffold/mcp/dist/daemon/paths.js +36 -0
  57. package/scaffold/mcp/dist/daemon/project-service-registry.js +78 -0
  58. package/scaffold/mcp/dist/daemon/protocol.js +8 -0
  59. package/scaffold/mcp/dist/daemon/server.js +180 -0
  60. package/scaffold/mcp/dist/daemon/skill-sync-checker.js +557 -0
  61. package/scaffold/mcp/dist/daemon/sync-checker.js +165 -0
  62. package/scaffold/mcp/dist/daemon/ungoverned-scanner.js +136 -0
  63. package/scaffold/mcp/dist/daemon/workflow-sync-checker.js +249 -0
  64. package/scaffold/mcp/dist/defaults.js +6 -0
  65. package/scaffold/mcp/dist/embed.js +627 -0
  66. package/scaffold/mcp/dist/embedScheduler.js +479 -0
  67. package/scaffold/mcp/dist/embeddings.js +167 -0
  68. package/scaffold/mcp/dist/enterprise/audit/push.js +66 -0
  69. package/scaffold/mcp/dist/enterprise/index.js +327 -0
  70. package/scaffold/mcp/dist/enterprise/model/deploy.js +27 -0
  71. package/scaffold/mcp/dist/enterprise/policy/sync.js +129 -0
  72. package/scaffold/mcp/dist/enterprise/privacy/boundary.js +184 -0
  73. package/scaffold/mcp/dist/enterprise/reviews/changed-files.js +33 -0
  74. package/scaffold/mcp/dist/enterprise/reviews/pattern-context.js +202 -0
  75. package/scaffold/mcp/dist/enterprise/reviews/policy-selection.js +46 -0
  76. package/scaffold/mcp/dist/enterprise/reviews/push.js +102 -0
  77. package/scaffold/mcp/dist/enterprise/reviews/trust-state.js +186 -0
  78. package/scaffold/mcp/dist/enterprise/telemetry/sync.js +57 -0
  79. package/scaffold/mcp/dist/enterprise/tools/enterprise.js +826 -0
  80. package/scaffold/mcp/dist/enterprise/tools/harness.js +40 -0
  81. package/scaffold/mcp/dist/enterprise/tools/walk.js +73 -0
  82. package/scaffold/mcp/dist/enterprise/violations/push.js +77 -0
  83. package/scaffold/mcp/dist/enterprise/workflow/push.js +44 -0
  84. package/scaffold/mcp/dist/enterprise/workflow/state.js +329 -0
  85. package/scaffold/mcp/dist/frontmatter.js +33 -0
  86. package/scaffold/mcp/dist/graph.js +769 -0
  87. package/scaffold/mcp/dist/graphCsv.js +55 -0
  88. package/scaffold/mcp/dist/graphMetrics.js +8 -0
  89. package/scaffold/mcp/dist/hooks/permission-request.js +89 -0
  90. package/scaffold/mcp/dist/hooks/post-tool-use.js +105 -0
  91. package/scaffold/mcp/dist/hooks/pre-compact.js +29 -0
  92. package/scaffold/mcp/dist/hooks/pre-tool-use.js +78 -0
  93. package/scaffold/mcp/dist/hooks/session-end.js +43 -0
  94. package/scaffold/mcp/dist/hooks/session-start.js +41 -0
  95. package/scaffold/mcp/dist/hooks/shared.js +194 -0
  96. package/scaffold/mcp/dist/hooks/stop.js +28 -0
  97. package/scaffold/mcp/dist/hooks/user-prompt-submit.js +33 -0
  98. package/scaffold/mcp/dist/impactPresentation.js +137 -0
  99. package/scaffold/mcp/dist/impactRanking.js +191 -0
  100. package/scaffold/mcp/dist/impactResponse.js +30 -0
  101. package/scaffold/mcp/dist/impactResults.js +105 -0
  102. package/scaffold/mcp/dist/impactSeed.js +20 -0
  103. package/scaffold/mcp/dist/impactTraversal.js +64 -0
  104. package/scaffold/mcp/dist/jsonl.js +77 -0
  105. package/scaffold/mcp/dist/loadGraph.js +759 -0
  106. package/scaffold/mcp/dist/lruCache.js +38 -0
  107. package/scaffold/mcp/dist/paths.js +97 -0
  108. package/scaffold/mcp/dist/patternEvidence.js +272 -0
  109. package/scaffold/mcp/dist/plugin.js +81 -0
  110. package/scaffold/mcp/dist/presets.js +78 -0
  111. package/scaffold/mcp/dist/relatedResponse.js +18 -0
  112. package/scaffold/mcp/dist/relatedTraversal.js +78 -0
  113. package/scaffold/mcp/dist/rules.js +23 -0
  114. package/scaffold/mcp/dist/search.js +212 -0
  115. package/scaffold/mcp/dist/searchCore.js +457 -0
  116. package/scaffold/mcp/dist/searchResults.js +230 -0
  117. package/scaffold/mcp/dist/server.js +317 -0
  118. package/scaffold/mcp/dist/types.js +1 -0
  119. package/scaffold/mcp/package-lock.json +336 -212
  120. package/scaffold/mcp/package.json +15 -6
  121. package/scaffold/mcp/src/cli/enterprise-setup.ts +82 -8
  122. package/scaffold/mcp/src/cli/govern.ts +137 -52
  123. package/scaffold/mcp/src/cli/query.ts +36 -2
  124. package/scaffold/mcp/src/cli/run.ts +53 -19
  125. package/scaffold/mcp/src/cli/stage.ts +8 -3
  126. package/scaffold/mcp/src/core/config.ts +9 -2
  127. package/scaffold/mcp/src/core/enterprise-host-identity.ts +259 -0
  128. package/scaffold/mcp/src/core/enterprise-identity.ts +20 -0
  129. package/scaffold/mcp/src/core/enterprise-rotation.ts +185 -0
  130. package/scaffold/mcp/src/core/govern-paths.ts +30 -0
  131. package/scaffold/mcp/src/core/license.ts +186 -17
  132. package/scaffold/mcp/src/core/secure-endpoint.ts +23 -0
  133. package/scaffold/mcp/src/core/workflow/enforcement.ts +8 -1
  134. package/scaffold/mcp/src/core/workflow/mcp-tools.ts +3 -0
  135. package/scaffold/mcp/src/core/workflow/resolution.ts +9 -1
  136. package/scaffold/mcp/src/core/workflow/synced-capability-registry.ts +15 -0
  137. package/scaffold/mcp/src/core/workflow/synced-registry.ts +15 -0
  138. package/scaffold/mcp/src/daemon/capability-sync-checker.ts +38 -2
  139. package/scaffold/mcp/src/daemon/egress-proxy.ts +14 -8
  140. package/scaffold/mcp/src/daemon/global-host-events.ts +288 -0
  141. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +4 -0
  142. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +2 -0
  143. package/scaffold/mcp/src/daemon/host-events-pusher.ts +5 -0
  144. package/scaffold/mcp/src/daemon/main.ts +99 -25
  145. package/scaffold/mcp/src/daemon/project-service-registry.ts +107 -0
  146. package/scaffold/mcp/src/daemon/skill-sync-checker.ts +368 -31
  147. package/scaffold/mcp/src/daemon/sync-checker.ts +4 -0
  148. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +57 -25
  149. package/scaffold/mcp/src/daemon/workflow-sync-checker.ts +41 -2
  150. package/scaffold/mcp/src/embed.ts +211 -10
  151. package/scaffold/mcp/src/enterprise/audit/push.ts +8 -0
  152. package/scaffold/mcp/src/enterprise/index.ts +1 -1
  153. package/scaffold/mcp/src/enterprise/policy/sync.ts +12 -0
  154. package/scaffold/mcp/src/enterprise/reviews/changed-files.ts +34 -0
  155. package/scaffold/mcp/src/enterprise/reviews/pattern-context.ts +231 -0
  156. package/scaffold/mcp/src/enterprise/reviews/push.ts +9 -0
  157. package/scaffold/mcp/src/enterprise/reviews/trust-state.ts +3 -1
  158. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +9 -0
  159. package/scaffold/mcp/src/enterprise/tools/enterprise.ts +49 -36
  160. package/scaffold/mcp/src/enterprise/violations/push.ts +9 -0
  161. package/scaffold/mcp/src/enterprise/workflow/push.ts +7 -0
  162. package/scaffold/mcp/src/paths.ts +3 -5
  163. package/scaffold/mcp/src/patternEvidence.ts +347 -0
  164. package/scaffold/mcp/src/plugin.ts +20 -0
  165. package/scaffold/mcp/src/search.ts +35 -9
  166. package/scaffold/mcp/src/searchCore.ts +254 -12
  167. package/scaffold/mcp/src/searchResults.ts +96 -9
  168. package/scaffold/mcp/src/types.ts +7 -0
  169. package/scaffold/mcp/tests/changed-files.test.mjs +41 -0
  170. package/scaffold/mcp/tests/copilot-shim.test.mjs +19 -1
  171. package/scaffold/mcp/tests/egress-proxy.test.mjs +37 -0
  172. package/scaffold/mcp/tests/embed-entities.test.mjs +109 -1
  173. package/scaffold/mcp/tests/enterprise-identity-sync.test.mjs +401 -0
  174. package/scaffold/mcp/tests/enterprise-pattern-context.test.mjs +322 -0
  175. package/scaffold/mcp/tests/enterprise-setup.test.mjs +189 -0
  176. package/scaffold/mcp/tests/global-host-events.test.mjs +81 -0
  177. package/scaffold/mcp/tests/govern-install.test.mjs +95 -2
  178. package/scaffold/mcp/tests/govern-repair.test.mjs +20 -3
  179. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +26 -0
  180. package/scaffold/mcp/tests/license.test.mjs +367 -0
  181. package/scaffold/mcp/tests/paths.test.mjs +11 -3
  182. package/scaffold/mcp/tests/pattern-evidence.test.mjs +321 -0
  183. package/scaffold/mcp/tests/project-service-registry.test.mjs +172 -0
  184. package/scaffold/mcp/tests/query-cli.test.mjs +73 -1
  185. package/scaffold/mcp/tests/review-trust-contract.test.mjs +18 -0
  186. package/scaffold/mcp/tests/search-graph-score.test.mjs +167 -0
  187. package/scaffold/mcp/tests/secure-enterprise-endpoint.test.mjs +46 -0
  188. package/scaffold/mcp/tests/skill-sync-checker.test.mjs +446 -2
  189. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +104 -22
  190. package/scaffold/mcp/tests/workflow-cli.test.mjs +14 -1
  191. package/scaffold/mcp/tests/workflow-synced-capabilities.test.mjs +37 -0
  192. package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +40 -1
  193. package/scaffold/scripts/dashboard.mjs +29 -18
  194. package/scaffold/scripts/doctor.sh +26 -8
  195. package/scaffold/scripts/ingest.mjs +37 -4
  196. package/scaffold/scripts/status.sh +13 -6
@@ -0,0 +1,288 @@
1
+ import {
2
+ chmodSync,
3
+ closeSync,
4
+ constants,
5
+ existsSync,
6
+ fchmodSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ readdirSync,
12
+ realpathSync,
13
+ renameSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ writeSync,
17
+ } from "node:fs";
18
+ import { randomUUID } from "node:crypto";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { loadEnterpriseConfig } from "../core/config.js";
22
+ import {
23
+ enterpriseCredentialId,
24
+ isAllowedLicenseEndpoint,
25
+ } from "../core/license.js";
26
+ import { matchesEnterpriseHostIdentity } from "../core/enterprise-host-identity.js";
27
+
28
+ type GlobalHostEvent = Record<string, unknown> & {
29
+ event_type: "ungoverned_ai_session_detected";
30
+ timestamp: string;
31
+ credential_id: string;
32
+ };
33
+
34
+ type GlobalCursor = {
35
+ version: 1;
36
+ credential_id: string;
37
+ ungoverned_last_key?: string;
38
+ };
39
+
40
+ const EVENT_FILE_RE = /^ungoverned-\d{4}-\d{2}-\d{2}\.jsonl$/;
41
+ const CURSOR_FILENAME = "global-host-events-cursor.json";
42
+
43
+ function configuredHome(): string {
44
+ return process.env.HOME?.trim() || homedir();
45
+ }
46
+
47
+ function globalEventDir(create: boolean): string | null {
48
+ try {
49
+ const home = realpathSync(configuredHome());
50
+ const cortexDir = join(home, ".cortex");
51
+ if (!existsSync(cortexDir)) return null;
52
+ const cortexStat = lstatSync(cortexDir);
53
+ if (!cortexStat.isDirectory() || cortexStat.isSymbolicLink()) return null;
54
+ if (realpathSync(cortexDir) !== cortexDir) return null;
55
+
56
+ const dir = join(cortexDir, "host-events");
57
+ if (existsSync(dir)) {
58
+ const stat = lstatSync(dir);
59
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
60
+ if (realpathSync(dir) !== dir) return null;
61
+ chmodSync(dir, 0o700);
62
+ return dir;
63
+ }
64
+ if (!create) return null;
65
+ mkdirSync(dir, { mode: 0o700 });
66
+ chmodSync(dir, 0o700);
67
+ return dir;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function eventKey(event: GlobalHostEvent): string {
74
+ const pid =
75
+ typeof event.pid === "number" || typeof event.pid === "string"
76
+ ? String(event.pid)
77
+ : "0";
78
+ return `${event.timestamp}#${pid}`;
79
+ }
80
+
81
+ export function writeGlobalUngovernedEvent(
82
+ credentialId: string,
83
+ event: Omit<GlobalHostEvent, "credential_id">,
84
+ ): boolean {
85
+ if (!matchesEnterpriseHostIdentity(credentialId)) return false;
86
+ const dir = globalEventDir(true);
87
+ if (!dir) return false;
88
+ const date = new Date().toISOString().slice(0, 10);
89
+ const path = join(dir, `ungoverned-${date}.jsonl`);
90
+ let fd: number | null = null;
91
+ try {
92
+ fd = openSync(
93
+ path,
94
+ constants.O_WRONLY |
95
+ constants.O_APPEND |
96
+ constants.O_CREAT |
97
+ constants.O_NOFOLLOW,
98
+ 0o600,
99
+ );
100
+ fchmodSync(fd, 0o600);
101
+ writeSync(
102
+ fd,
103
+ JSON.stringify({ ...event, credential_id: credentialId }) + "\n",
104
+ undefined,
105
+ "utf8",
106
+ );
107
+ closeSync(fd);
108
+ return true;
109
+ } catch {
110
+ if (fd !== null) {
111
+ try {
112
+ closeSync(fd);
113
+ } catch {
114
+ // Best effort.
115
+ }
116
+ }
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function readCursor(dir: string, credentialId: string): GlobalCursor {
122
+ const path = join(dir, CURSOR_FILENAME);
123
+ try {
124
+ const stat = lstatSync(path);
125
+ if (!stat.isFile() || stat.isSymbolicLink()) {
126
+ return { version: 1, credential_id: credentialId };
127
+ }
128
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<GlobalCursor>;
129
+ if (
130
+ parsed.version !== 1 ||
131
+ parsed.credential_id !== credentialId ||
132
+ (parsed.ungoverned_last_key !== undefined &&
133
+ typeof parsed.ungoverned_last_key !== "string")
134
+ ) {
135
+ return { version: 1, credential_id: credentialId };
136
+ }
137
+ return parsed as GlobalCursor;
138
+ } catch {
139
+ return { version: 1, credential_id: credentialId };
140
+ }
141
+ }
142
+
143
+ function writeCursor(dir: string, cursor: GlobalCursor): void {
144
+ const path = join(dir, CURSOR_FILENAME);
145
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
146
+ try {
147
+ writeFileSync(
148
+ temporaryPath,
149
+ JSON.stringify(cursor, null, 2) + "\n",
150
+ { encoding: "utf8", mode: 0o600, flag: "wx" },
151
+ );
152
+ chmodSync(temporaryPath, 0o600);
153
+ renameSync(temporaryPath, path);
154
+ chmodSync(path, 0o600);
155
+ } catch (err) {
156
+ try {
157
+ unlinkSync(temporaryPath);
158
+ } catch {
159
+ // Best effort.
160
+ }
161
+ throw err;
162
+ }
163
+ }
164
+
165
+ function readEvents(dir: string, credentialId: string): GlobalHostEvent[] {
166
+ const events: GlobalHostEvent[] = [];
167
+ for (const name of readdirSync(dir).filter((entry) => EVENT_FILE_RE.test(entry)).sort()) {
168
+ const path = join(dir, name);
169
+ try {
170
+ const stat = lstatSync(path);
171
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
172
+ for (const line of readFileSync(path, "utf8").split("\n")) {
173
+ if (!line.trim()) continue;
174
+ const parsed = JSON.parse(line) as Partial<GlobalHostEvent>;
175
+ if (
176
+ parsed.event_type !== "ungoverned_ai_session_detected" ||
177
+ parsed.credential_id !== credentialId ||
178
+ typeof parsed.timestamp !== "string"
179
+ ) {
180
+ continue;
181
+ }
182
+ events.push(parsed as GlobalHostEvent);
183
+ }
184
+ } catch {
185
+ // Skip malformed or concurrently changing files.
186
+ }
187
+ }
188
+ return events.sort((a, b) => eventKey(a).localeCompare(eventKey(b)));
189
+ }
190
+
191
+ export async function pushGlobalUngovernedEvents(
192
+ cwd: string,
193
+ credentialId: string,
194
+ ): Promise<{ pushed: number; error?: string }> {
195
+ if (!matchesEnterpriseHostIdentity(credentialId)) {
196
+ return { pushed: 0, error: "enterprise identity conflict" };
197
+ }
198
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
199
+ const apiKey = config.enterprise.api_key.trim();
200
+ const baseUrl = (
201
+ config.enterprise.base_url || config.enterprise.endpoint
202
+ ).trim();
203
+ if (
204
+ !apiKey ||
205
+ !baseUrl ||
206
+ !isAllowedLicenseEndpoint(baseUrl) ||
207
+ enterpriseCredentialId(baseUrl, apiKey) !== credentialId
208
+ ) {
209
+ return { pushed: 0, error: "enterprise identity conflict" };
210
+ }
211
+ const dir = globalEventDir(false);
212
+ if (!dir) return { pushed: 0 };
213
+ const cursor = readCursor(dir, credentialId);
214
+ const events = readEvents(dir, credentialId).filter(
215
+ (event) =>
216
+ !cursor.ungoverned_last_key ||
217
+ eventKey(event) > cursor.ungoverned_last_key,
218
+ );
219
+ if (events.length === 0) return { pushed: 0 };
220
+
221
+ try {
222
+ const response = await fetch(
223
+ `${baseUrl.replace(/\/$/, "")}/api/v1/govern/ungoverned`,
224
+ {
225
+ method: "POST",
226
+ headers: {
227
+ "Content-Type": "application/json",
228
+ Authorization: `Bearer ${apiKey}`,
229
+ },
230
+ body: JSON.stringify({
231
+ events: events.map((event) => ({
232
+ detected_at: event.timestamp,
233
+ host_id: event.host_id,
234
+ cli: event.cli,
235
+ binary_path: event.binary,
236
+ args: event.args,
237
+ sys_user: event.user,
238
+ parent_pid: event.ppid,
239
+ pid: event.pid,
240
+ action_taken: event.action ?? "logged",
241
+ })),
242
+ }),
243
+ },
244
+ );
245
+ if (!response.ok) {
246
+ return {
247
+ pushed: 0,
248
+ error: `HTTP ${response.status} ${response.statusText}`,
249
+ };
250
+ }
251
+ cursor.ungoverned_last_key = eventKey(events[events.length - 1]);
252
+ writeCursor(dir, cursor);
253
+ return { pushed: events.length };
254
+ } catch (err) {
255
+ return {
256
+ pushed: 0,
257
+ error: err instanceof Error ? err.message : String(err),
258
+ };
259
+ }
260
+ }
261
+
262
+ export type GlobalHostEventsPusherHandle = {
263
+ stop(): void;
264
+ };
265
+
266
+ export function startGlobalUngovernedEventsPusher(
267
+ cwd: string,
268
+ credentialId: string,
269
+ intervalMs: number,
270
+ ): GlobalHostEventsPusherHandle {
271
+ const tick = () => {
272
+ void pushGlobalUngovernedEvents(cwd, credentialId).then((outcome) => {
273
+ if (outcome.error) {
274
+ process.stderr.write(
275
+ `[cortex-daemon] global host-events push failed: ${outcome.error}\n`,
276
+ );
277
+ }
278
+ });
279
+ };
280
+ void Promise.resolve().then(tick);
281
+ const handle = setInterval(tick, intervalMs);
282
+ if (typeof handle.unref === "function") handle.unref();
283
+ return {
284
+ stop() {
285
+ clearInterval(handle);
286
+ },
287
+ };
288
+ }
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { hostname, platform, release } from "node:os";
4
4
  import { loadEnterpriseConfig } from "../core/config.js";
5
+ import { isAllowedEnterpriseEndpoint } from "../core/secure-endpoint.js";
5
6
 
6
7
  /**
7
8
  * Govern host heartbeat — fills the host_enrollment table on cortex-web.
@@ -107,6 +108,9 @@ export async function pushHeartbeat(cwd: string): Promise<HeartbeatPushOutcome>
107
108
  if (!apiKey || !baseUrl) {
108
109
  return { ok: false, error: "enterprise not configured" };
109
110
  }
111
+ if (!isAllowedEnterpriseEndpoint(baseUrl)) {
112
+ return { ok: false, error: "insecure or invalid enterprise endpoint" };
113
+ }
110
114
  const payload = buildHeartbeatPayload(cwd);
111
115
  try {
112
116
  const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/v1/govern/heartbeat`, {
@@ -130,6 +130,7 @@ export class HeartbeatTracker {
130
130
  const now = options.now ?? new Date();
131
131
  const thresholdMs = options.missingThresholdSeconds * 1000;
132
132
  const flagged: TamperLockEntry[] = [];
133
+ const allowedCwds = new Set(options.cwds);
133
134
 
134
135
  for (const [sessionId, state] of this.sessions) {
135
136
  // Cleanup sessions that have been silent forever — they crashed.
@@ -140,6 +141,7 @@ export class HeartbeatTracker {
140
141
  }
141
142
 
142
143
  if (state.ended) continue;
144
+ if (!allowedCwds.has(state.cwd)) continue;
143
145
  // Need at least 2 heartbeats for the "had-activity-then-silence"
144
146
  // signal — a single SessionStart followed by silence may just be a
145
147
  // user opening Claude and walking away.
@@ -8,6 +8,7 @@ import {
8
8
  } from "node:fs";
9
9
  import { join, dirname } from "node:path";
10
10
  import { loadEnterpriseConfig } from "../core/config.js";
11
+ import { isAllowedEnterpriseEndpoint } from "../core/secure-endpoint.js";
11
12
 
12
13
  /**
13
14
  * Phase 7 sync flow — host-events pusher.
@@ -150,6 +151,10 @@ export async function pushHostEvents(cwd: string): Promise<PushOutcome> {
150
151
  outcome.errors.push("enterprise not configured");
151
152
  return outcome;
152
153
  }
154
+ if (!isAllowedEnterpriseEndpoint(baseUrl)) {
155
+ outcome.errors.push("insecure or invalid enterprise endpoint");
156
+ return outcome;
157
+ }
153
158
 
154
159
  const cursor = readCursor(cwd);
155
160
  const ungovernedCursor = normalizeCursor(cursor.ungoverned_last_ts);
@@ -11,6 +11,9 @@ import type {
11
11
  AuditLogResult,
12
12
  } from "./protocol.js";
13
13
  import { loadEnterpriseConfig, resolveEnterpriseActivation } from "../core/config.js";
14
+ import { configuredEnterpriseCredentialId } from "../core/enterprise-identity.js";
15
+ import { matchesEnterpriseHostIdentity } from "../core/enterprise-host-identity.js";
16
+ import { startGlobalUngovernedEventsPusher } from "./global-host-events.js";
14
17
  import { pushMetrics } from "../enterprise/telemetry/sync.js";
15
18
  import { TelemetryCollector, type TelemetryMetrics } from "../core/telemetry/collector.js";
16
19
  import { resolveTelemetryStateDir, telemetryStatePath } from "../core/telemetry/state-dir.js";
@@ -34,6 +37,10 @@ import { startCapabilitySyncTimer } from "./capability-sync-checker.js";
34
37
  import { startHostEventsPusher } from "./host-events-pusher.js";
35
38
  import { startEgressProxy } from "./egress-proxy.js";
36
39
  import { startHeartbeatPusher } from "./heartbeat-pusher.js";
40
+ import {
41
+ normalizeProjectCwd,
42
+ ProjectServiceRegistry,
43
+ } from "./project-service-registry.js";
37
44
  import type { HeartbeatPayload, HeartbeatResult } from "./protocol.js";
38
45
 
39
46
  /**
@@ -302,8 +309,37 @@ async function auditLog(payload: AuditLogPayload): Promise<AuditLogResult> {
302
309
  async function main(): Promise<void> {
303
310
  // Phase 6: hook heartbeat tracker (per-session activity record + tamper detect).
304
311
  const tracker = new HeartbeatTracker();
312
+ let projectServices: ProjectServiceRegistry | null = null;
313
+ function registerEnterpriseProject(cwd: string): boolean {
314
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
315
+ if (!resolveEnterpriseActivation(config).active) return true;
316
+ if (projectServices?.has(cwd)) return true;
317
+ const credentialId = configuredEnterpriseCredentialId(cwd);
318
+ if (!credentialId || !matchesEnterpriseHostIdentity(credentialId)) {
319
+ process.stderr.write(
320
+ `[cortex-daemon] rejected Enterprise project ${cwd}: ` +
321
+ "this user profile is enrolled to another endpoint or API key\n",
322
+ );
323
+ return false;
324
+ }
325
+ if (!projectServices?.register(cwd)) {
326
+ process.stderr.write(
327
+ `[cortex-daemon] rejected Enterprise project ${cwd}: ` +
328
+ "this user daemon is already bound to another endpoint or API key\n",
329
+ );
330
+ return false;
331
+ }
332
+ return true;
333
+ }
305
334
  async function heartbeat(payload: HeartbeatPayload): Promise<HeartbeatResult> {
306
- return tracker.recordHeartbeat(payload);
335
+ const cwd = normalizeProjectCwd(payload.cwd);
336
+ if (!cwd) {
337
+ return { recorded: false, tamper_lock_active: false };
338
+ }
339
+ if (!registerEnterpriseProject(cwd)) {
340
+ return { recorded: false, tamper_lock_active: false };
341
+ }
342
+ return tracker.recordHeartbeat({ ...payload, cwd });
307
343
  }
308
344
 
309
345
  const daemon = new CortexDaemon({
@@ -312,15 +348,11 @@ async function main(): Promise<void> {
312
348
  onAuditLog: auditLog,
313
349
  onHeartbeat: heartbeat,
314
350
  });
315
- await daemon.start();
316
351
 
317
352
  // Phase 5: Tier 3 ungoverned-session detection. Periodic process scan, audit
318
353
  // emit per finding, optional SIGTERM in enforced mode (same-user only).
319
354
  const scanInterval = parseInt(process.env.CORTEX_UNGOVERNED_SCAN_MS ?? "", 10);
320
355
  const intervalMs = Number.isFinite(scanInterval) && scanInterval > 0 ? scanInterval : 60_000;
321
- if (process.env.CORTEX_DISABLE_UNGOVERNED_SCAN !== "1") {
322
- startUngovernedScanner({ cwd: process.cwd(), intervalMs });
323
- }
324
356
 
325
357
  // Phase 6: periodic tamper-checker. For each active session that had at
326
358
  // least one tool-fired hook then went silent past missing_threshold_seconds,
@@ -343,12 +375,7 @@ async function main(): Promise<void> {
343
375
  const pushIntervalRaw = parseInt(process.env.CORTEX_HOST_EVENTS_PUSH_MS ?? "", 10);
344
376
  const pushIntervalMs =
345
377
  Number.isFinite(pushIntervalRaw) && pushIntervalRaw > 0 ? pushIntervalRaw : 5 * 60 * 1000;
346
- if (process.env.CORTEX_DISABLE_SYNC_CHECK !== "1") {
347
- startSyncTimer(process.cwd(), syncIntervalMs);
348
- }
349
- if (process.env.CORTEX_DISABLE_HOST_EVENTS_PUSH !== "1") {
350
- startHostEventsPusher(process.cwd(), pushIntervalMs);
351
- }
378
+
352
379
  // Skills v3: poll cortex-web for org-authored skills, write SKILL.md
353
380
  // files into per-CLI user-scope directories. Runs at the same cadence
354
381
  // as the govern-config sync check by default but is independently
@@ -356,9 +383,6 @@ async function main(): Promise<void> {
356
383
  const skillSyncRaw = parseInt(process.env.CORTEX_SKILL_SYNC_MS ?? "", 10);
357
384
  const skillSyncMs =
358
385
  Number.isFinite(skillSyncRaw) && skillSyncRaw > 0 ? skillSyncRaw : syncIntervalMs;
359
- if (process.env.CORTEX_DISABLE_SKILL_SYNC !== "1") {
360
- startSkillSyncTimer(process.cwd(), skillSyncMs);
361
- }
362
386
 
363
387
  // Harness Phase 2: poll cortex-web for org-authored workflows, cache
364
388
  // their definitions locally so cortex.workflow.start can resolve
@@ -370,9 +394,6 @@ async function main(): Promise<void> {
370
394
  Number.isFinite(workflowSyncRaw) && workflowSyncRaw > 0
371
395
  ? workflowSyncRaw
372
396
  : skillSyncMs;
373
- if (process.env.CORTEX_DISABLE_WORKFLOW_SYNC !== "1") {
374
- startWorkflowSyncTimer(process.cwd(), workflowSyncMs);
375
- }
376
397
 
377
398
  // Harness Phase 2: poll cortex-web for org-authored capabilities and
378
399
  // cache definitions locally so evaluateToolCall can merge them over
@@ -384,18 +405,68 @@ async function main(): Promise<void> {
384
405
  Number.isFinite(capabilitySyncRaw) && capabilitySyncRaw > 0
385
406
  ? capabilitySyncRaw
386
407
  : workflowSyncMs;
387
- if (process.env.CORTEX_DISABLE_CAPABILITY_SYNC !== "1") {
388
- startCapabilitySyncTimer(process.cwd(), capabilitySyncMs);
389
- }
390
408
 
391
409
  // Govern host heartbeat — fills host_enrollment on cortex-web so the
392
410
  // dashboard at /dashboard/govern actually shows this host.
393
411
  const heartbeatRaw = parseInt(process.env.CORTEX_HEARTBEAT_PUSH_MS ?? "", 10);
394
412
  const heartbeatMs =
395
413
  Number.isFinite(heartbeatRaw) && heartbeatRaw > 0 ? heartbeatRaw : 5 * 60 * 1000;
396
- if (process.env.CORTEX_DISABLE_HEARTBEAT_PUSH !== "1") {
397
- startHeartbeatPusher(process.cwd(), heartbeatMs);
398
- }
414
+
415
+ projectServices = new ProjectServiceRegistry((cwd) => {
416
+ const handles = [];
417
+ if (process.env.CORTEX_DISABLE_SYNC_CHECK !== "1") {
418
+ handles.push(startSyncTimer(cwd, syncIntervalMs));
419
+ }
420
+ if (process.env.CORTEX_DISABLE_HOST_EVENTS_PUSH !== "1") {
421
+ handles.push(startHostEventsPusher(cwd, pushIntervalMs));
422
+ }
423
+ return handles;
424
+ }, {
425
+ credentialIdForCwd: configuredEnterpriseCredentialId,
426
+ hostFactory: (cwd, credentialId) => {
427
+ const handles = [];
428
+ // These services operate on user-global process or artifact state.
429
+ // One explicit host governance project owns them; projects carrying
430
+ // another Enterprise identity are rejected by the registry.
431
+ if (process.env.CORTEX_DISABLE_UNGOVERNED_SCAN !== "1") {
432
+ // Process snapshots do not carry a trustworthy project cwd. Keep the
433
+ // single host-wide scanner detection-only so an enforced project can
434
+ // never terminate a process belonging to an advisory project.
435
+ handles.push(startUngovernedScanner({
436
+ cwd,
437
+ credentialId,
438
+ intervalMs,
439
+ mode: "advisory",
440
+ }));
441
+ }
442
+ if (process.env.CORTEX_DISABLE_HOST_EVENTS_PUSH !== "1") {
443
+ handles.push(
444
+ startGlobalUngovernedEventsPusher(
445
+ cwd,
446
+ credentialId,
447
+ pushIntervalMs,
448
+ ),
449
+ );
450
+ }
451
+ if (process.env.CORTEX_DISABLE_SKILL_SYNC !== "1") {
452
+ handles.push(startSkillSyncTimer(cwd, skillSyncMs));
453
+ }
454
+ if (process.env.CORTEX_DISABLE_WORKFLOW_SYNC !== "1") {
455
+ handles.push(startWorkflowSyncTimer(cwd, workflowSyncMs));
456
+ }
457
+ if (process.env.CORTEX_DISABLE_CAPABILITY_SYNC !== "1") {
458
+ handles.push(startCapabilitySyncTimer(cwd, capabilitySyncMs));
459
+ }
460
+ if (process.env.CORTEX_DISABLE_HEARTBEAT_PUSH !== "1") {
461
+ handles.push(startHeartbeatPusher(cwd, heartbeatMs));
462
+ }
463
+ return handles;
464
+ },
465
+ });
466
+
467
+ await daemon.start();
468
+ const startupCwd = normalizeProjectCwd(process.cwd());
469
+ if (startupCwd) registerEnterpriseProject(startupCwd);
399
470
 
400
471
  // Phase 4 task 19: cortex egress proxy. Logs SNI + destination per
401
472
  // outbound connection (no TLS termination). cortex run sets
@@ -404,7 +475,10 @@ async function main(): Promise<void> {
404
475
  const proxyPortRaw = parseInt(process.env.CORTEX_EGRESS_PROXY_PORT ?? "", 10);
405
476
  const proxyPort = Number.isFinite(proxyPortRaw) && proxyPortRaw > 0 ? proxyPortRaw : 18888;
406
477
  if (process.env.CORTEX_DISABLE_EGRESS_PROXY !== "1") {
407
- startEgressProxy({ cwd: process.cwd(), port: proxyPort })
478
+ // A shared proxy connection has no trustworthy project identity. Keep
479
+ // transport support, but suppress project audit emission until callers
480
+ // use an attributable per-project channel.
481
+ startEgressProxy({ port: proxyPort })
408
482
  .then((handle) => {
409
483
  process.stderr.write(
410
484
  `[cortex-daemon] egress proxy listening on 127.0.0.1:${handle.port}\n`,
@@ -447,7 +521,7 @@ async function main(): Promise<void> {
447
521
  if (process.env.CORTEX_DISABLE_TAMPER_CHECK !== "1") {
448
522
  const checkTimer = setInterval(() => {
449
523
  const detected = tracker.detectTamper({
450
- cwds: [process.cwd()],
524
+ cwds: projectServices?.projectCwds() ?? [],
451
525
  missingThresholdSeconds,
452
526
  });
453
527
  for (const entry of detected) {
@@ -0,0 +1,107 @@
1
+ import { existsSync, realpathSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+
4
+ export type ProjectServiceHandle = {
5
+ stop(): void | Promise<void>;
6
+ };
7
+
8
+ export type ProjectServiceFactory = (
9
+ cwd: string,
10
+ ) => ReadonlyArray<ProjectServiceHandle>;
11
+
12
+ export type HostServiceFactory = (
13
+ cwd: string,
14
+ credentialId: string,
15
+ ) => ReadonlyArray<ProjectServiceHandle>;
16
+
17
+ export type ProjectServiceRegistryOptions = {
18
+ credentialIdForCwd?: (cwd: string) => string | null;
19
+ hostFactory?: HostServiceFactory;
20
+ };
21
+
22
+ export function normalizeProjectCwd(cwd: string): string | null {
23
+ if (!cwd.trim()) return null;
24
+ const candidate = resolve(cwd);
25
+ try {
26
+ if (!existsSync(candidate) || !statSync(candidate).isDirectory()) {
27
+ return null;
28
+ }
29
+ if (!existsSync(join(candidate, ".context"))) {
30
+ return null;
31
+ }
32
+ return realpathSync(candidate);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Owns background services for every project observed by the per-user daemon.
40
+ * Request-time handlers already carry cwd in their payloads; this registry
41
+ * gives the periodic services the same project isolation.
42
+ */
43
+ export class ProjectServiceRegistry {
44
+ private readonly projects = new Map<
45
+ string,
46
+ ReadonlyArray<ProjectServiceHandle>
47
+ >();
48
+ private hostCredentialId: string | null = null;
49
+ private hostHandles: ReadonlyArray<ProjectServiceHandle> = [];
50
+
51
+ constructor(
52
+ private readonly factory: ProjectServiceFactory,
53
+ private readonly options: ProjectServiceRegistryOptions = {},
54
+ ) {}
55
+
56
+ register(cwd: string): boolean {
57
+ const normalized = normalizeProjectCwd(cwd);
58
+ if (!normalized || this.projects.has(normalized)) return false;
59
+ const credentialId =
60
+ this.options.credentialIdForCwd?.(normalized) ?? "project-local";
61
+ if (!credentialId) return false;
62
+ if (
63
+ this.hostCredentialId &&
64
+ this.hostCredentialId !== credentialId
65
+ ) {
66
+ return false;
67
+ }
68
+ if (!this.hostCredentialId) {
69
+ this.hostCredentialId = credentialId;
70
+ this.hostHandles =
71
+ this.options.hostFactory?.(normalized, credentialId) ?? [];
72
+ }
73
+ const handles = this.factory(normalized);
74
+ this.projects.set(normalized, handles);
75
+ return true;
76
+ }
77
+
78
+ has(cwd: string): boolean {
79
+ const normalized = normalizeProjectCwd(cwd);
80
+ return normalized ? this.projects.has(normalized) : false;
81
+ }
82
+
83
+ projectCwds(): string[] {
84
+ return Array.from(this.projects.keys());
85
+ }
86
+
87
+ async stopAll(): Promise<void> {
88
+ const handles = [
89
+ ...Array.from(this.projects.values()).flat(),
90
+ ...this.hostHandles,
91
+ ];
92
+ this.projects.clear();
93
+ this.hostHandles = [];
94
+ this.hostCredentialId = null;
95
+ await Promise.allSettled(
96
+ handles.map((handle) => Promise.resolve(handle.stop())),
97
+ );
98
+ }
99
+
100
+ size(): number {
101
+ return this.projects.size;
102
+ }
103
+
104
+ activeCredentialId(): string | null {
105
+ return this.hostCredentialId;
106
+ }
107
+ }