@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
@@ -5,6 +5,8 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
 
7
7
  import { runScanOnce, writeHostAuditEvent, startUngovernedScanner } from "../dist/daemon/ungoverned-scanner.js";
8
+ import { claimEnterpriseHostIdentity } from "../dist/core/enterprise-host-identity.js";
9
+ import { enterpriseCredentialId } from "../dist/core/license.js";
8
10
 
9
11
  function makeWorkspace(governMode, installs = null) {
10
12
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-ungoverned-"));
@@ -41,7 +43,7 @@ test("writeHostAuditEvent appends one JSONL line per call", async () => {
41
43
  }
42
44
  });
43
45
 
44
- test("runScanOnce: writes audit event with action=logged in advisory mode", async () => {
46
+ test("runScanOnce: does not write unattributed host process metadata into project audit", async () => {
45
47
  const { root } = makeWorkspace("advisory");
46
48
  try {
47
49
  const fakeProcs = [
@@ -54,20 +56,64 @@ test("runScanOnce: writes audit event with action=logged in advisory mode", asyn
54
56
  });
55
57
  assert.equal(findings.length, 1);
56
58
 
57
- const date = new Date().toISOString().slice(0, 10);
58
- const file = path.join(root, ".context", "audit", `host-events-${date}.jsonl`);
59
- const events = fs.readFileSync(file, "utf8").trim().split("\n").map(JSON.parse);
60
- assert.equal(events.length, 1);
61
- assert.equal(events[0].event_type, "ungoverned_ai_session_detected");
62
- assert.equal(events[0].cli, "claude");
63
- assert.equal(events[0].mode, "advisory");
64
- assert.equal(events[0].action, "logged");
65
- assert.equal(events[0].host_id, "test-host");
59
+ assert.equal(
60
+ fs.existsSync(path.join(root, ".context", "audit")),
61
+ false,
62
+ );
66
63
  } finally {
67
64
  fs.rmSync(root, { recursive: true, force: true });
68
65
  }
69
66
  });
70
67
 
68
+ test("runScanOnce: writes detections only to the credential-bound user-global queue", async () => {
69
+ const { root } = makeWorkspace("advisory");
70
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-global-events-"));
71
+ const endpoint = "https://events.example.com";
72
+ const apiKey = "ent_global_events_12345678";
73
+ const credentialId = enterpriseCredentialId(endpoint, apiKey);
74
+ process.env.HOME = homeDir;
75
+ try {
76
+ assert.equal(
77
+ claimEnterpriseHostIdentity(credentialId, endpoint),
78
+ true,
79
+ );
80
+ await runScanOnce({
81
+ cwd: root,
82
+ credentialId,
83
+ detectorOptions: {
84
+ hostId: "host-global",
85
+ processes: [
86
+ {
87
+ pid: 4321,
88
+ ppid: 1,
89
+ user: os.userInfo().username,
90
+ comm: "claude",
91
+ args: "claude --prompt private",
92
+ },
93
+ ],
94
+ },
95
+ });
96
+
97
+ assert.equal(fs.existsSync(path.join(root, ".context", "audit")), false);
98
+ const queueDir = path.join(homeDir, ".cortex", "host-events");
99
+ const queueFile = fs.readdirSync(queueDir).find((name) =>
100
+ name.startsWith("ungoverned-")
101
+ );
102
+ assert.ok(queueFile);
103
+ assert.equal(fs.statSync(path.join(queueDir, queueFile)).mode & 0o777, 0o600);
104
+ const event = JSON.parse(
105
+ fs.readFileSync(path.join(queueDir, queueFile), "utf8").trim(),
106
+ );
107
+ assert.equal(event.credential_id, credentialId);
108
+ assert.equal(event.pid, 4321);
109
+ assert.match(event.args, /private/);
110
+ } finally {
111
+ delete process.env.HOME;
112
+ fs.rmSync(root, { recursive: true, force: true });
113
+ fs.rmSync(homeDir, { recursive: true, force: true });
114
+ }
115
+ });
116
+
71
117
  test("runScanOnce: enforced mode marks action=sigterm but our mock doesn't actually signal real procs", async () => {
72
118
  const { root } = makeWorkspace("enforced");
73
119
  try {
@@ -91,12 +137,11 @@ test("runScanOnce: enforced mode marks action=sigterm but our mock doesn't actua
91
137
  process.kill = origKill;
92
138
  }
93
139
 
94
- const date = new Date().toISOString().slice(0, 10);
95
- const file = path.join(root, ".context", "audit", `host-events-${date}.jsonl`);
96
- const events = fs.readFileSync(file, "utf8").trim().split("\n").map(JSON.parse);
97
- assert.equal(events[0].mode, "enforced");
98
- assert.equal(events[0].action, "sigterm");
99
140
  assert.deepEqual(killed, [99999, "SIGTERM"]);
141
+ assert.equal(
142
+ fs.existsSync(path.join(root, ".context", "audit")),
143
+ false,
144
+ );
100
145
  } finally {
101
146
  fs.rmSync(root, { recursive: true, force: true });
102
147
  }
@@ -127,9 +172,15 @@ test("runScanOnce: emits onFinding callback per detection", async () => {
127
172
  test("runScanOnce: skips Tier 1 CLIs that already have managed installs", async () => {
128
173
  const { root } = makeWorkspace("enforced");
129
174
  const managedClaudePath = path.join(root, "managed-settings.json");
130
- fs.writeFileSync(managedClaudePath, "{}\n");
175
+ fs.writeFileSync(
176
+ managedClaudePath,
177
+ JSON.stringify({ allowManagedHooksOnly: true }) + "\n",
178
+ );
131
179
  const managedCodexPath = path.join(root, "requirements.toml");
132
- fs.writeFileSync(managedCodexPath, "# managed\n");
180
+ fs.writeFileSync(
181
+ managedCodexPath,
182
+ "# Cortex govern — codex requirements (test).\n",
183
+ );
133
184
  fs.writeFileSync(
134
185
  path.join(root, ".context", "govern.local.json"),
135
186
  JSON.stringify({
@@ -160,16 +211,47 @@ test("runScanOnce: skips Tier 1 CLIs that already have managed installs", async
160
211
  const findings = await runScanOnce({
161
212
  cwd: root,
162
213
  mode: "enforced",
214
+ managedPathOverrides: {
215
+ claude: managedClaudePath,
216
+ codex: managedCodexPath,
217
+ },
163
218
  detectorOptions: { processes: fakeProcs, hostId: "test-host" },
164
219
  });
165
220
  assert.equal(findings.length, 1);
166
221
  assert.equal(findings[0].cli, "copilot");
167
222
 
168
- const date = new Date().toISOString().slice(0, 10);
169
- const file = path.join(root, ".context", "audit", `host-events-${date}.jsonl`);
170
- const events = fs.readFileSync(file, "utf8").trim().split("\n").map(JSON.parse);
171
- assert.equal(events.length, 1);
172
- assert.equal(events[0].cli, "copilot");
223
+ assert.equal(fs.existsSync(path.join(root, ".context", "audit")), false);
224
+ } finally {
225
+ fs.rmSync(root, { recursive: true, force: true });
226
+ }
227
+ });
228
+
229
+ test("runScanOnce: forged persisted paths cannot suppress Tier 1 findings", async () => {
230
+ const { root } = makeWorkspace("advisory", {
231
+ claude: {
232
+ mode: "advisory",
233
+ path: "/bin/sh",
234
+ version: "forged",
235
+ frameworks: [],
236
+ installed_at: "now",
237
+ },
238
+ });
239
+ try {
240
+ const findings = await runScanOnce({
241
+ cwd: root,
242
+ mode: "advisory",
243
+ detectorOptions: {
244
+ processes: [{
245
+ pid: 7654,
246
+ ppid: 1,
247
+ user: os.userInfo().username,
248
+ comm: "claude",
249
+ args: "claude --prompt hi",
250
+ }],
251
+ },
252
+ });
253
+ assert.equal(findings.length, 1);
254
+ assert.equal(findings[0].cli, "claude");
173
255
  } finally {
174
256
  fs.rmSync(root, { recursive: true, force: true });
175
257
  }
@@ -5,6 +5,10 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
 
7
7
  import { runStageCommand } from "../dist/cli/stage.js";
8
+ import { enterpriseCredentialId } from "../dist/core/license.js";
9
+
10
+ const ENTERPRISE_ENDPOINT = "https://licenses.example.com";
11
+ const ENTERPRISE_KEY = "ent_workflow_cli_12345678";
8
12
 
9
13
  const TINY_WORKFLOW = {
10
14
  id: "tiny",
@@ -41,7 +45,12 @@ function makeWorkspace() {
41
45
  fs.mkdirSync(path.join(dir, ".context"), { recursive: true });
42
46
  fs.writeFileSync(
43
47
  path.join(dir, ".context", "enterprise.yml"),
44
- "enterprise:\n api_key: test-key-for-cli-tests\n",
48
+ [
49
+ "enterprise:",
50
+ ` api_key: ${ENTERPRISE_KEY}`,
51
+ ` endpoint: ${ENTERPRISE_ENDPOINT}`,
52
+ "",
53
+ ].join("\n"),
45
54
  "utf8",
46
55
  );
47
56
  return dir;
@@ -53,6 +62,10 @@ function writeSyncedWorkflow(homeDir, workflowId, definition) {
53
62
  fs.writeFileSync(
54
63
  path.join(cortexDir, "workflows.local.json"),
55
64
  JSON.stringify({
65
+ credential_id: enterpriseCredentialId(
66
+ ENTERPRISE_ENDPOINT,
67
+ ENTERPRISE_KEY,
68
+ ),
56
69
  workflows: {
57
70
  [workflowId]: {
58
71
  workflow_id: workflowId,
@@ -10,6 +10,7 @@ import {
10
10
  } from "../dist/core/workflow/synced-capability-registry.js";
11
11
  import { evaluateToolCall } from "../dist/core/workflow/enforcement.js";
12
12
  import { createRun } from "../dist/core/workflow/run-lifecycle.js";
13
+ import { enterpriseCredentialId } from "../dist/core/license.js";
13
14
 
14
15
  function makeWorkspace() {
15
16
  return fs.mkdtempSync(path.join(os.tmpdir(), "cortex-synced-caps-"));
@@ -49,6 +50,23 @@ function writeCache(dir, payload) {
49
50
  );
50
51
  }
51
52
 
53
+ function configureEnterprise(cwd) {
54
+ const endpoint = "https://licenses.example.com";
55
+ const apiKey = "ent_capability_12345678";
56
+ fs.mkdirSync(path.join(cwd, ".context"), { recursive: true });
57
+ fs.writeFileSync(
58
+ path.join(cwd, ".context", "enterprise.yml"),
59
+ [
60
+ "enterprise:",
61
+ ` api_key: ${apiKey}`,
62
+ ` endpoint: ${endpoint}`,
63
+ "",
64
+ ].join("\n"),
65
+ "utf8",
66
+ );
67
+ return enterpriseCredentialId(endpoint, apiKey);
68
+ }
69
+
52
70
  test("syncedCapabilitiesCachePath: defaults to ~/.cortex/capabilities.local.json", () => {
53
71
  const expected = path.join(os.homedir(), ".cortex", "capabilities.local.json");
54
72
  assert.equal(syncedCapabilitiesCachePath(), expected);
@@ -107,16 +125,33 @@ test("loadSyncedCapabilities: returns valid capability definitions", () => {
107
125
  assert.deepEqual(loaded["frontend-builder"].write_globs, ["src/components/**"]);
108
126
  });
109
127
 
128
+ test("loadSyncedCapabilities: rejects a cache bound to another Enterprise identity", () => {
129
+ const dir = makeWorkspace();
130
+ writeCache(dir, {
131
+ credential_id: "identity-a",
132
+ capabilities: {
133
+ "frontend-builder": {
134
+ capability_name: "frontend-builder",
135
+ updated_at: "2026-05-07T12:00:00.000Z",
136
+ definition: FRONTEND_BUILDER,
137
+ },
138
+ },
139
+ });
140
+ assert.deepEqual(loadSyncedCapabilities(dir, "identity-b"), {});
141
+ });
142
+
110
143
  test("evaluateToolCall integration: synced capability is consulted via merged registry", () => {
111
144
  const cwd = makeWorkspace();
112
145
  // Sandbox the home-dir-based loader.
113
146
  const fakeHome = makeWorkspace();
114
147
  process.env.HOME = fakeHome;
115
148
  try {
149
+ const credentialId = configureEnterprise(cwd);
116
150
  fs.mkdirSync(path.join(fakeHome, ".cortex"), { recursive: true });
117
151
  fs.writeFileSync(
118
152
  path.join(fakeHome, ".cortex", "capabilities.local.json"),
119
153
  JSON.stringify({
154
+ credential_id: credentialId,
120
155
  capabilities: {
121
156
  "frontend-builder": {
122
157
  capability_name: "frontend-builder",
@@ -163,6 +198,7 @@ test("evaluateToolCall integration: synced capability with same name as bundled
163
198
  const fakeHome = makeWorkspace();
164
199
  process.env.HOME = fakeHome;
165
200
  try {
201
+ const credentialId = configureEnterprise(cwd);
166
202
  // Override the bundled "builder" capability with a much stricter version
167
203
  // — only test files writable.
168
204
  const stricterBuilder = {
@@ -176,6 +212,7 @@ test("evaluateToolCall integration: synced capability with same name as bundled
176
212
  fs.writeFileSync(
177
213
  path.join(fakeHome, ".cortex", "capabilities.local.json"),
178
214
  JSON.stringify({
215
+ credential_id: credentialId,
179
216
  capabilities: {
180
217
  builder: {
181
218
  capability_name: "builder",
@@ -11,6 +11,7 @@ import {
11
11
  import { resolveWorkflowDefinition } from "../dist/core/workflow/resolution.js";
12
12
  import { runWorkflowStart } from "../dist/core/workflow/mcp-tools.js";
13
13
  import { SECURE_BUILD_WORKFLOW } from "../dist/core/workflow/default-workflows.js";
14
+ import { enterpriseCredentialId } from "../dist/core/license.js";
14
15
 
15
16
  function makeWorkspace() {
16
17
  return fs.mkdtempSync(path.join(os.tmpdir(), "cortex-synced-registry-"));
@@ -49,6 +50,23 @@ function writeCache(dir, payload) {
49
50
  );
50
51
  }
51
52
 
53
+ function configureEnterprise(cwd) {
54
+ const endpoint = "https://licenses.example.com";
55
+ const apiKey = "ent_workflow_12345678";
56
+ fs.mkdirSync(path.join(cwd, ".context"), { recursive: true });
57
+ fs.writeFileSync(
58
+ path.join(cwd, ".context", "enterprise.yml"),
59
+ [
60
+ "enterprise:",
61
+ ` api_key: ${apiKey}`,
62
+ ` endpoint: ${endpoint}`,
63
+ "",
64
+ ].join("\n"),
65
+ "utf8",
66
+ );
67
+ return enterpriseCredentialId(endpoint, apiKey);
68
+ }
69
+
52
70
  test("syncedWorkflowsCachePath: defaults to ~/.cortex/workflows.local.json", () => {
53
71
  const expected = path.join(os.homedir(), ".cortex", "workflows.local.json");
54
72
  assert.equal(syncedWorkflowsCachePath(), expected);
@@ -110,6 +128,22 @@ test("loadSyncedWorkflows: returns valid workflow definitions keyed by workflow_
110
128
  assert.equal(loaded.tiny.stages.length, 2);
111
129
  });
112
130
 
131
+ test("loadSyncedWorkflows: rejects a cache bound to another Enterprise identity", () => {
132
+ const dir = makeWorkspace();
133
+ writeCache(dir, {
134
+ credential_id: "identity-a",
135
+ workflows: {
136
+ tiny: {
137
+ workflow_id: "tiny",
138
+ version: 1,
139
+ updated_at: "2026-05-06T12:00:00.000Z",
140
+ definition: TINY_WORKFLOW,
141
+ },
142
+ },
143
+ });
144
+ assert.deepEqual(loadSyncedWorkflows(dir, "identity-b"), {});
145
+ });
146
+
113
147
  test("resolveWorkflow integration: synced workflow takes precedence over bundled default", () => {
114
148
  // We can't easily intercept loadSyncedWorkflows() from inside
115
149
  // mcp-tools.ts (it reads from a fixed home-dir path). Instead, exercise
@@ -148,10 +182,12 @@ test("resolveWorkflow integration: synced cache adds new workflow_ids beyond def
148
182
  const fakeHome = makeWorkspace();
149
183
  process.env.HOME = fakeHome;
150
184
  try {
185
+ const credentialId = configureEnterprise(cwd);
151
186
  fs.mkdirSync(path.join(fakeHome, ".cortex"), { recursive: true });
152
187
  fs.writeFileSync(
153
188
  path.join(fakeHome, ".cortex", "workflows.local.json"),
154
189
  JSON.stringify({
190
+ credential_id: credentialId,
155
191
  workflows: {
156
192
  tiny: {
157
193
  workflow_id: "tiny",
@@ -215,7 +251,10 @@ test("resolveWorkflowDefinition: unknown workflow errors list bundled and synced
215
251
  const fakeHome = makeWorkspace();
216
252
  process.env.HOME = fakeHome;
217
253
  try {
254
+ const cwd = makeWorkspace();
255
+ const credentialId = configureEnterprise(cwd);
218
256
  writeCache(path.join(fakeHome, ".cortex"), {
257
+ credential_id: credentialId,
219
258
  workflows: {
220
259
  tiny: {
221
260
  workflow_id: "tiny",
@@ -226,7 +265,7 @@ test("resolveWorkflowDefinition: unknown workflow errors list bundled and synced
226
265
  },
227
266
  });
228
267
  assert.throws(
229
- () => resolveWorkflowDefinition("missing-workflow"),
268
+ () => resolveWorkflowDefinition("missing-workflow", { cwd }),
230
269
  /Available bundled: secure-build\. Available synced: tiny\./,
231
270
  );
232
271
  } finally {
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { execSync } from "node:child_process";
5
+ import { execFileSync, execSync } from "node:child_process";
6
6
 
7
7
  const __filename = fileURLToPath(import.meta.url);
8
8
  const __dirname = path.dirname(__filename);
@@ -286,12 +286,11 @@ function getLocalCliVersion() {
286
286
  }
287
287
 
288
288
  try {
289
- const output = execSync("cortex --version", {
289
+ const output = execFileSync("cortex", ["--version"], {
290
290
  cwd: REPO_ROOT,
291
291
  stdio: ["ignore", "pipe", "ignore"],
292
292
  encoding: "utf8",
293
293
  timeout: 1500,
294
- shell: true,
295
294
  }).trim();
296
295
  if (parseVersion(output)) {
297
296
  return output;
@@ -331,13 +330,12 @@ function getVersionStatus() {
331
330
  } else {
332
331
  try {
333
332
  const npmCache = path.join(CACHE_DIR, "npm-cache");
334
- const latestRaw = execSync("npm view github:DanielBlomma/cortex version --json", {
333
+ const latestRaw = execFileSync("npm", ["view", "github:DanielBlomma/cortex", "version", "--json"], {
335
334
  cwd: REPO_ROOT,
336
335
  stdio: ["ignore", "pipe", "pipe"],
337
336
  encoding: "utf8",
338
337
  timeout: VERSION_LOOKUP_TIMEOUT_MS,
339
338
  env: { ...process.env, NPM_CONFIG_CACHE: npmCache },
340
- shell: true,
341
339
  }).trim();
342
340
  const parsedLatest = JSON.parse(latestRaw);
343
341
  const latest = Array.isArray(parsedLatest)
@@ -352,20 +350,30 @@ function getVersionStatus() {
352
350
  latest,
353
351
  message: "unsupported latest version format",
354
352
  };
355
- } else if (compareVersions(latestParsed, localParsed) > 0) {
356
- value = {
357
- state: "update-available",
358
- local,
359
- latest,
360
- message: null,
361
- };
362
353
  } else {
363
- value = {
364
- state: "current",
365
- local,
366
- latest,
367
- message: null,
368
- };
354
+ const comparison = compareVersions(latestParsed, localParsed);
355
+ if (comparison > 0) {
356
+ value = {
357
+ state: "update-available",
358
+ local,
359
+ latest,
360
+ message: null,
361
+ };
362
+ } else if (comparison < 0) {
363
+ value = {
364
+ state: "local-newer",
365
+ local,
366
+ latest,
367
+ message: "local build is newer than published version",
368
+ };
369
+ } else {
370
+ value = {
371
+ state: "current",
372
+ local,
373
+ latest,
374
+ message: null,
375
+ };
376
+ }
369
377
  }
370
378
  } catch (error) {
371
379
  value = {
@@ -690,6 +698,9 @@ function render(data, isTTY) {
690
698
  if (data.version.state === "update-available") {
691
699
  lines.push(sideBorder(
692
700
  `Version: ${bold(col("UPDATE AVAILABLE", C.red))} ${col(`${data.version.local} -> ${data.version.latest}`, C.red)}`, w));
701
+ } else if (data.version.state === "local-newer") {
702
+ lines.push(sideBorder(
703
+ `Version: ${col(data.version.local, C.yellow)} ${dim(`Published: ${data.version.latest} local newer`)}`, w));
693
704
  } else if (data.version.state === "current") {
694
705
  lines.push(sideBorder(
695
706
  `Version: ${col(data.version.local, C.green)} ${dim(`Latest: ${data.version.latest}`)}`, w));
@@ -180,15 +180,33 @@ fi
180
180
 
181
181
  # Quick runtime import check
182
182
  if [[ -f "$MCP_DIR/dist/server.js" ]] && [[ -d "$MCP_DIR/node_modules" ]]; then
183
- MCP_CHECK=$(cd "$REPO_ROOT" && timeout 10 node -e '
184
- const start = Date.now();
185
- try {
186
- require("./.context/mcp/dist/graph.js");
187
- console.log("ok " + (Date.now() - start));
188
- } catch(e) {
189
- console.log("fail " + e.message);
183
+ MCP_CHECK=$(cd "$REPO_ROOT" && node -e '
184
+ const { spawnSync } = require("node:child_process");
185
+ const timeoutMs = Number(process.env.CORTEX_DOCTOR_GRAPH_TIMEOUT_MS || 30000);
186
+ const probe = [
187
+ "const start = Date.now();",
188
+ "try {",
189
+ " require(\"./.context/mcp/dist/graph.js\");",
190
+ " console.log(\"ok \" + (Date.now() - start));",
191
+ "} catch (e) {",
192
+ " console.log(\"fail \" + (e && e.message ? e.message : String(e)));",
193
+ " process.exitCode = 1;",
194
+ "}"
195
+ ].join("\n");
196
+ const result = spawnSync(process.execPath, ["-e", probe], {
197
+ encoding: "utf8",
198
+ stdio: ["ignore", "pipe", "pipe"],
199
+ timeout: timeoutMs
200
+ });
201
+ if (result.error && result.error.code === "ETIMEDOUT") {
202
+ console.log("fail timeout after " + timeoutMs + "ms");
203
+ } else if (result.error) {
204
+ console.log("fail " + result.error.message);
205
+ } else {
206
+ const lines = String(result.stdout || "").trim().split(/\r?\n/).filter(Boolean);
207
+ console.log(lines[lines.length - 1] || ("fail exit " + (result.status ?? "unknown")));
190
208
  }
191
- ' 2>/dev/null || echo "fail timeout")
209
+ ' 2>/dev/null || echo "fail graph check crashed")
192
210
  if [[ "$MCP_CHECK" == ok* ]]; then
193
211
  MS="${MCP_CHECK#ok }"
194
212
  pass "Graph module loads (${MS}ms)"
@@ -457,11 +457,11 @@ function parseRules(rulesText) {
457
457
  function walkDirectory(directoryPath, files) {
458
458
  const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
459
459
  for (const entry of entries) {
460
- if (entry.isDirectory() && SKIP_DIRECTORIES.has(entry.name)) {
460
+ const absolutePath = path.join(directoryPath, entry.name);
461
+ if (entry.isDirectory() && shouldSkipDirectory(absolutePath, entry.name)) {
461
462
  continue;
462
463
  }
463
464
 
464
- const absolutePath = path.join(directoryPath, entry.name);
465
465
  if (entry.isDirectory()) {
466
466
  walkDirectory(absolutePath, files);
467
467
  continue;
@@ -473,10 +473,40 @@ function walkDirectory(directoryPath, files) {
473
473
  }
474
474
  }
475
475
 
476
+ function shouldSkipDirectory(absolutePath, entryName) {
477
+ if (entryName === "bin" && path.resolve(path.dirname(absolutePath)) === REPO_ROOT) {
478
+ return false;
479
+ }
480
+ return SKIP_DIRECTORIES.has(entryName);
481
+ }
482
+
483
+ function normalizeSourcePrefix(sourcePath) {
484
+ const source = toPosixPath(sourcePath).replace(/^\.\/+/, "").replace(/\/+$/, "");
485
+ return source === "." ? "" : source;
486
+ }
487
+
488
+ function normalizeRelativePath(relPath) {
489
+ return toPosixPath(relPath).replace(/^\.\/+/, "").replace(/\/+$/, "");
490
+ }
491
+
492
+ function hasSkippedDirectorySegment(relPath) {
493
+ const parts = normalizeRelativePath(relPath).split("/").filter(Boolean);
494
+ return parts.some((part, index) => {
495
+ if (part === "bin" && index === 0) {
496
+ return false;
497
+ }
498
+ return SKIP_DIRECTORIES.has(part);
499
+ });
500
+ }
501
+
476
502
  function hasSourcePrefix(relPath, sourcePaths) {
503
+ const normalizedRelPath = normalizeRelativePath(relPath);
504
+ if (!normalizedRelPath || hasSkippedDirectorySegment(normalizedRelPath)) {
505
+ return false;
506
+ }
477
507
  return sourcePaths.some((sourcePath) => {
478
- const source = toPosixPath(sourcePath).replace(/\/+$/, "");
479
- return relPath === source || relPath.startsWith(`${source}/`);
508
+ const source = normalizeSourcePrefix(sourcePath);
509
+ return source === "" || normalizedRelPath === source || normalizedRelPath.startsWith(`${source}/`);
480
510
  });
481
511
  }
482
512
 
@@ -627,6 +657,9 @@ function collectCandidateFiles(sourcePaths, mode) {
627
657
  }
628
658
 
629
659
  for (const sourcePath of sourcePaths) {
660
+ if (hasSkippedDirectorySegment(sourcePath)) {
661
+ continue;
662
+ }
630
663
  const absoluteSourcePath = path.resolve(REPO_ROOT, sourcePath);
631
664
  if (!fs.existsSync(absoluteSourcePath)) {
632
665
  continue;
@@ -198,11 +198,12 @@ try {
198
198
 
199
199
  node -e '
200
200
  const path = require("node:path");
201
- const { execSync } = require("node:child_process");
201
+ const { execFileSync, execSync } = require("node:child_process");
202
202
 
203
203
  const repoRoot = process.argv[1];
204
204
  const cacheDir = process.argv[2];
205
205
  const localVersionEnv = process.argv[3] || "";
206
+ const VERSION_LOOKUP_TIMEOUT_MS = Number(process.env.CORTEX_VERSION_LOOKUP_TIMEOUT_MS || 8000);
206
207
 
207
208
  function parseVersion(value) {
208
209
  const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/);
@@ -225,7 +226,7 @@ function getLocalVersion() {
225
226
  }
226
227
 
227
228
  try {
228
- const output = execSync("cortex --version", {
229
+ const output = execFileSync("cortex", ["--version"], {
229
230
  cwd: repoRoot,
230
231
  stdio: ["ignore", "pipe", "ignore"],
231
232
  encoding: "utf8",
@@ -282,11 +283,11 @@ try {
282
283
  const npmCache = path.join(cacheDir, "npm-cache");
283
284
  let latestRaw = "";
284
285
  try {
285
- latestRaw = execSync("npm view github:DanielBlomma/cortex version --json", {
286
+ latestRaw = execFileSync("npm", ["view", "github:DanielBlomma/cortex", "version", "--json"], {
286
287
  cwd: repoRoot,
287
288
  stdio: ["ignore", "pipe", "pipe"],
288
289
  encoding: "utf8",
289
- timeout: 2500,
290
+ timeout: VERSION_LOOKUP_TIMEOUT_MS,
290
291
  env: { ...process.env, NPM_CONFIG_CACHE: npmCache }
291
292
  }).trim();
292
293
  } catch (error) {
@@ -317,13 +318,19 @@ try {
317
318
  process.exit(0);
318
319
  }
319
320
 
320
- const hasUpdate = compareVersions(latestParsed, localParsed) > 0;
321
+ const comparison = compareVersions(latestParsed, localParsed);
321
322
  console.log(`[status] cortex_latest_version=${latestVersion}`);
322
323
 
323
- if (hasUpdate) {
324
+ if (comparison > 0) {
325
+ console.log("[status] cortex_version_state=update-available");
324
326
  console.log("[status] cortex_update_available=yes");
325
327
  console.log("[status] run: npm i -g github:DanielBlomma/cortex");
328
+ } else if (comparison < 0) {
329
+ console.log("[status] cortex_version_state=local-newer");
330
+ console.log("[status] cortex_update_available=no");
331
+ console.log("[status] note: local Cortex is newer than the published version");
326
332
  } else {
333
+ console.log("[status] cortex_version_state=current");
327
334
  console.log("[status] cortex_update_available=no");
328
335
  }
329
336
  } catch (error) {