@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
@@ -1,6 +1,18 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ mkdirSync,
7
+ existsSync,
8
+ renameSync,
9
+ unlinkSync,
10
+ } from "node:fs";
2
11
  import { join } from "node:path";
3
12
  import { resolveTelemetryStateDir, telemetryStatePath } from "./telemetry/state-dir.js";
13
+ import { isAllowedEnterpriseEndpoint } from "./secure-endpoint.js";
14
+
15
+ export { isAllowedEnterpriseEndpoint as isAllowedLicenseEndpoint };
4
16
 
5
17
  export type LicenseVerification =
6
18
  | {
@@ -20,6 +32,9 @@ export type LicenseVerification =
20
32
  };
21
33
 
22
34
  type CacheEntry = {
35
+ version: 2;
36
+ endpoint: string;
37
+ api_key_sha256: string;
23
38
  result: LicenseVerification;
24
39
  // ISO timestamp for cache freshness window
25
40
  cached_at: string;
@@ -34,23 +49,83 @@ function cachePath(contextDir: string): string {
34
49
  return telemetryStatePath(contextDir, CACHE_FILE);
35
50
  }
36
51
 
52
+ function isIsoTimestamp(value: unknown): value is string {
53
+ if (typeof value !== "string") return false;
54
+ const timestamp = new Date(value).getTime();
55
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value;
56
+ }
57
+
37
58
  function readCache(contextDir: string): CacheEntry | null {
38
59
  const path = cachePath(contextDir);
39
60
  if (!existsSync(path)) return null;
40
61
  try {
41
62
  const raw = readFileSync(path, "utf8");
42
- return JSON.parse(raw) as CacheEntry;
63
+ const parsed = JSON.parse(raw) as Partial<CacheEntry>;
64
+ if (
65
+ !parsed ||
66
+ typeof parsed !== "object" ||
67
+ parsed.version !== 2 ||
68
+ typeof parsed.endpoint !== "string" ||
69
+ !parsed.endpoint ||
70
+ typeof parsed.api_key_sha256 !== "string" ||
71
+ !/^[a-f0-9]{64}$/.test(parsed.api_key_sha256) ||
72
+ !parsed.result ||
73
+ typeof parsed.result !== "object" ||
74
+ typeof parsed.result.valid !== "boolean" ||
75
+ !isIsoTimestamp(parsed.cached_at)
76
+ ) {
77
+ deleteCache(contextDir);
78
+ return null;
79
+ }
80
+ if (parsed.result.valid) {
81
+ if (
82
+ typeof parsed.result.edition !== "string" ||
83
+ !Array.isArray(parsed.result.features) ||
84
+ !parsed.result.features.every((feature) => typeof feature === "string") ||
85
+ !isIsoTimestamp(parsed.result.expires_at) ||
86
+ typeof parsed.result.max_repos !== "number" ||
87
+ !Number.isFinite(parsed.result.max_repos) ||
88
+ parsed.result.max_repos < 0 ||
89
+ !isIsoTimestamp(parsed.result.verified_at) ||
90
+ !["remote", "cache"].includes(parsed.result.source)
91
+ ) {
92
+ deleteCache(contextDir);
93
+ return null;
94
+ }
95
+ } else if (
96
+ typeof parsed.result.reason !== "string" ||
97
+ !isIsoTimestamp(parsed.result.verified_at) ||
98
+ !["remote", "cache", "grace_expired"].includes(parsed.result.source)
99
+ ) {
100
+ deleteCache(contextDir);
101
+ return null;
102
+ }
103
+ return parsed as CacheEntry;
43
104
  } catch {
105
+ deleteCache(contextDir);
44
106
  return null;
45
107
  }
46
108
  }
47
109
 
48
110
  function writeCache(contextDir: string, entry: CacheEntry): void {
49
111
  const path = cachePath(contextDir);
112
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
50
113
  try {
51
114
  mkdirSync(resolveTelemetryStateDir(contextDir), { recursive: true });
52
- writeFileSync(path, JSON.stringify(entry, null, 2), "utf8");
115
+ writeFileSync(
116
+ temporaryPath,
117
+ JSON.stringify(entry, null, 2) + "\n",
118
+ { encoding: "utf8", mode: 0o600, flag: "wx" },
119
+ );
120
+ chmodSync(temporaryPath, 0o600);
121
+ renameSync(temporaryPath, path);
122
+ chmodSync(path, 0o600);
53
123
  } catch {
124
+ try {
125
+ unlinkSync(temporaryPath);
126
+ } catch {
127
+ // Best effort.
128
+ }
54
129
  // Cache failures are non-fatal — license check just won't be cached.
55
130
  }
56
131
  }
@@ -69,6 +144,48 @@ function ageMs(isoTimestamp: string): number {
69
144
  return Date.now() - new Date(isoTimestamp).getTime();
70
145
  }
71
146
 
147
+ export function normalizeLicenseEndpoint(endpoint: string): string {
148
+ return endpoint.trim().replace(/\/+$/, "");
149
+ }
150
+
151
+ function apiKeyFingerprint(apiKey: string): string {
152
+ return createHash("sha256").update(apiKey, "utf8").digest("hex");
153
+ }
154
+
155
+ function cacheMatchesCredential(
156
+ cached: CacheEntry,
157
+ endpoint: string,
158
+ apiKey: string,
159
+ ): boolean {
160
+ return (
161
+ cached.version === 2 &&
162
+ cached.endpoint === normalizeLicenseEndpoint(endpoint) &&
163
+ cached.api_key_sha256 === apiKeyFingerprint(apiKey)
164
+ );
165
+ }
166
+
167
+ export function enterpriseCredentialId(
168
+ endpoint: string,
169
+ apiKey: string,
170
+ ): string {
171
+ return createHash("sha256")
172
+ .update(normalizeLicenseEndpoint(endpoint), "utf8")
173
+ .update("\0", "utf8")
174
+ .update(apiKey, "utf8")
175
+ .digest("hex");
176
+ }
177
+
178
+ function cacheResultIsUnexpired(cached: CacheEntry): boolean {
179
+ if (!cached.result.valid) return false;
180
+ const expiresAt = new Date(cached.result.expires_at).getTime();
181
+ return Number.isFinite(expiresAt) && expiresAt > Date.now();
182
+ }
183
+
184
+ function cacheAgeIsWithin(cached: CacheEntry, limitMs: number): boolean {
185
+ const age = ageMs(cached.cached_at);
186
+ return Number.isFinite(age) && age >= 0 && age < limitMs;
187
+ }
188
+
72
189
  async function fetchLicense(
73
190
  endpoint: string,
74
191
  apiKey: string,
@@ -93,21 +210,41 @@ async function fetchLicense(
93
210
  signal: controller.signal,
94
211
  });
95
212
 
213
+ if (res.status === 401 || res.status === 403) {
214
+ return {
215
+ valid: false,
216
+ reason: "authentication_rejected",
217
+ verified_at: new Date().toISOString(),
218
+ source: "remote",
219
+ };
220
+ }
221
+
96
222
  if (!res.ok) {
97
- // 401/429/5xx treat as transient, surface null so caller can fall back.
223
+ // Rate limiting, server failures, and other transport-level responses
224
+ // may use an identity-matched positive cache during the grace period.
98
225
  return null;
99
226
  }
100
227
 
101
228
  const json = (await res.json()) as Record<string, unknown>;
102
229
  const verifiedAt = new Date().toISOString();
103
230
 
104
- if (json.valid === true) {
231
+ if (
232
+ json.valid === true &&
233
+ typeof json.edition === "string" &&
234
+ Array.isArray(json.features) &&
235
+ json.features.every((feature) => typeof feature === "string") &&
236
+ isIsoTimestamp(json.expires_at) &&
237
+ new Date(json.expires_at).getTime() > Date.now() &&
238
+ typeof json.max_repos === "number" &&
239
+ Number.isFinite(json.max_repos) &&
240
+ json.max_repos >= 0
241
+ ) {
105
242
  return {
106
243
  valid: true,
107
- edition: String(json.edition ?? "unknown"),
108
- features: Array.isArray(json.features) ? json.features.map(String) : [],
109
- expires_at: String(json.expires_at ?? ""),
110
- max_repos: typeof json.max_repos === "number" ? json.max_repos : 0,
244
+ edition: json.edition,
245
+ features: json.features,
246
+ expires_at: json.expires_at,
247
+ max_repos: json.max_repos,
111
248
  verified_at: verifiedAt,
112
249
  source: "remote",
113
250
  };
@@ -115,7 +252,9 @@ async function fetchLicense(
115
252
 
116
253
  return {
117
254
  valid: false,
118
- reason: String(json.reason ?? "unknown"),
255
+ reason: json.valid === true
256
+ ? "malformed_license_response"
257
+ : String(json.reason ?? "unknown"),
119
258
  verified_at: verifiedAt,
120
259
  source: "remote",
121
260
  };
@@ -129,7 +268,8 @@ async function fetchLicense(
129
268
 
130
269
  /**
131
270
  * Verify the license for the given api_key. Layered fallback:
132
- * 1. If a positive cache (valid:true) is fresh (<24h) use cache
271
+ * 1. If a positive cache (valid:true) is fresh (<24h), unexpired, and
272
+ * bound to this endpoint + API-key fingerprint → use cache
133
273
  * Negative cache entries are never trusted; if one is encountered
134
274
  * it's deleted on the spot so a since-fixed remote can heal.
135
275
  * 2. Otherwise try remote endpoint
@@ -138,8 +278,9 @@ async function fetchLicense(
138
278
  * cache (so a revoked/expired key doesn't keep masquerading as
139
279
  * valid past its remote-side fail), return result, do NOT
140
280
  * cache the negative.
141
- * - On transient failure → fall back to positive cache if within
142
- * grace period (7d). If only a negative cache exists, ignore it.
281
+ * - On transient failure → fall back to the same identity-bound positive
282
+ * cache if within the grace period (7d). Authentication rejection is
283
+ * authoritative and never uses grace.
143
284
  * 3. If no usable cache and endpoint unreachable → return invalid
144
285
  * (grace_expired).
145
286
  *
@@ -153,6 +294,16 @@ export async function verifyLicense(
153
294
  apiKey: string,
154
295
  options: { instance_id?: string; client_version?: string } = {},
155
296
  ): Promise<LicenseVerification> {
297
+ if (!isAllowedEnterpriseEndpoint(endpoint)) {
298
+ deleteCache(contextDir);
299
+ return {
300
+ valid: false,
301
+ reason: "insecure_or_invalid_endpoint",
302
+ verified_at: new Date().toISOString(),
303
+ source: "remote",
304
+ };
305
+ }
306
+
156
307
  let cached = readCache(contextDir);
157
308
 
158
309
  // Defensive: a previous version of this code wrote negative results
@@ -163,9 +314,19 @@ export async function verifyLicense(
163
314
  cached = null;
164
315
  }
165
316
 
317
+ const matchingCache =
318
+ cached && cacheMatchesCredential(cached, endpoint, apiKey)
319
+ ? cached
320
+ : null;
321
+
166
322
  // Fresh positive cache: skip remote.
167
- if (cached && cached.result.valid === true && ageMs(cached.cached_at) < CACHE_TTL_MS) {
168
- return { ...cached.result, source: "cache" };
323
+ if (
324
+ matchingCache &&
325
+ matchingCache.result.valid === true &&
326
+ cacheResultIsUnexpired(matchingCache) &&
327
+ cacheAgeIsWithin(matchingCache, CACHE_TTL_MS)
328
+ ) {
329
+ return { ...matchingCache.result, source: "cache" };
169
330
  }
170
331
 
171
332
  const remote = await fetchLicense(
@@ -178,6 +339,9 @@ export async function verifyLicense(
178
339
  if (remote) {
179
340
  if (remote.valid) {
180
341
  writeCache(contextDir, {
342
+ version: 2,
343
+ endpoint: normalizeLicenseEndpoint(endpoint),
344
+ api_key_sha256: apiKeyFingerprint(apiKey),
181
345
  result: remote,
182
346
  cached_at: new Date().toISOString(),
183
347
  });
@@ -190,8 +354,13 @@ export async function verifyLicense(
190
354
  }
191
355
 
192
356
  // Remote unreachable. Fall back to positive cache if within grace.
193
- if (cached && cached.result.valid === true && ageMs(cached.cached_at) < GRACE_PERIOD_MS) {
194
- return { ...cached.result, source: "cache" };
357
+ if (
358
+ matchingCache &&
359
+ matchingCache.result.valid === true &&
360
+ cacheResultIsUnexpired(matchingCache) &&
361
+ cacheAgeIsWithin(matchingCache, GRACE_PERIOD_MS)
362
+ ) {
363
+ return { ...matchingCache.result, source: "cache" };
195
364
  }
196
365
 
197
366
  return {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Enterprise bearer credentials may travel only over TLS. Loopback HTTP is
3
+ * retained for local development because the connection cannot leave the
4
+ * host. Embedded URL credentials are never accepted.
5
+ */
6
+ export function isAllowedEnterpriseEndpoint(endpoint: string): boolean {
7
+ let parsed: URL;
8
+ try {
9
+ parsed = new URL(endpoint);
10
+ } catch {
11
+ return false;
12
+ }
13
+ if (parsed.username || parsed.password) return false;
14
+ if (parsed.protocol === "https:") return true;
15
+ if (parsed.protocol !== "http:") return false;
16
+ const host = parsed.hostname.toLowerCase();
17
+ return (
18
+ host === "localhost" ||
19
+ host === "127.0.0.1" ||
20
+ host === "::1" ||
21
+ host === "[::1]"
22
+ );
23
+ }
@@ -5,6 +5,7 @@ import { DEFAULT_CAPABILITIES, type CapabilityDefinition } from "./capabilities.
5
5
  import { workflowDefinitionSchema, type WorkflowDefinition } from "./schemas.js";
6
6
  import { DEFAULT_WORKFLOWS } from "./default-workflows.js";
7
7
  import { loadSyncedCapabilities } from "./synced-capability-registry.js";
8
+ import { configuredEnterpriseCredentialId } from "../enterprise-identity.js";
8
9
 
9
10
  /**
10
11
  * Pre-tool-use enforcement for the harness. Pure function: takes the tool
@@ -89,7 +90,13 @@ export function evaluateToolCall(options: EvaluateOptions): EnforcementResult {
89
90
  // capabilities, with synced ones taking precedence on name collisions
90
91
  // so org overrides actually override.
91
92
  const capabilities =
92
- options.capabilities ?? { ...DEFAULT_CAPABILITIES, ...loadSyncedCapabilities() };
93
+ options.capabilities ?? {
94
+ ...DEFAULT_CAPABILITIES,
95
+ ...loadSyncedCapabilities(
96
+ undefined,
97
+ configuredEnterpriseCredentialId(options.cwd) ?? undefined,
98
+ ),
99
+ };
93
100
  const capability = capabilities[stage.capability];
94
101
  if (!capability) {
95
102
  return {
@@ -96,6 +96,7 @@ export function runWorkflowStart(
96
96
  ) {
97
97
  const resolved = resolveWorkflowDefinition(input.workflow_id, {
98
98
  registry: ctx.workflows,
99
+ cwd: ctx.cwd,
99
100
  bundledFallbackPolicy: ctx.bundledFallbackPolicy,
100
101
  });
101
102
  const workflow = resolved.workflow;
@@ -130,6 +131,7 @@ export function runWorkflowAdvance(
130
131
  }
131
132
  const workflow = resolveWorkflowDefinition(state.workflow_id, {
132
133
  registry: ctx.workflows,
134
+ cwd: ctx.cwd,
133
135
  }).workflow;
134
136
  const stage = workflow.stages.find((s) => s.name === input.stage);
135
137
  if (!stage) {
@@ -210,6 +212,7 @@ export function runWorkflowEnvelope(
210
212
  }
211
213
  const workflow = resolveWorkflowDefinition(state.workflow_id, {
212
214
  registry: ctx.workflows,
215
+ cwd: ctx.cwd,
213
216
  }).workflow;
214
217
  const envelope = composeStageEnvelope({
215
218
  cwd: ctx.cwd,
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_WORKFLOWS } from "./default-workflows.js";
2
2
  import { loadSyncedWorkflows } from "./synced-registry.js";
3
+ import { configuredEnterpriseCredentialId } from "../enterprise-identity.js";
3
4
  import type { WorkflowDefinition } from "./schemas.js";
4
5
 
5
6
  export type WorkflowSource = "bundled" | "synced" | "injected";
@@ -24,6 +25,7 @@ export type WorkflowResolution = {
24
25
  type ResolveWorkflowOptions = {
25
26
  registry?: Record<string, WorkflowDefinition>;
26
27
  syncedDir?: string;
28
+ cwd?: string;
27
29
  emitBundledFallbackWarning?: boolean;
28
30
  bundledFallbackPolicy?: "allow" | "warn" | "block";
29
31
  };
@@ -59,7 +61,13 @@ export function resolveWorkflowDefinition(
59
61
  }
60
62
 
61
63
  const bundled = DEFAULT_WORKFLOWS;
62
- const synced = loadSyncedWorkflows(options.syncedDir);
64
+ const expectedCredentialId = options.syncedDir
65
+ ? undefined
66
+ : configuredEnterpriseCredentialId(options.cwd ?? process.cwd()) ?? undefined;
67
+ const synced = loadSyncedWorkflows(
68
+ options.syncedDir,
69
+ expectedCredentialId,
70
+ );
63
71
  const bundledIds = sortIds(Object.keys(bundled));
64
72
  const syncedIds = sortIds(Object.keys(synced));
65
73
  const merged = { ...bundled, ...synced };
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { configuredEnterpriseCredentialId } from "../enterprise-identity.js";
4
5
  import {
5
6
  capabilityDefinitionSchema,
6
7
  type CapabilityDefinition,
@@ -27,6 +28,7 @@ type LocalCapabilityRecord = {
27
28
  };
28
29
 
29
30
  type LocalCapabilitiesState = {
31
+ credential_id?: string;
30
32
  capabilities?: Record<string, LocalCapabilityRecord>;
31
33
  };
32
34
 
@@ -42,6 +44,7 @@ export function syncedCapabilitiesCachePath(dir?: string): string {
42
44
  */
43
45
  export function loadSyncedCapabilities(
44
46
  dir?: string,
47
+ expectedCredentialId?: string,
45
48
  ): Record<string, CapabilityDefinition> {
46
49
  const path = syncedCapabilitiesCachePath(dir);
47
50
  if (!existsSync(path)) return {};
@@ -52,6 +55,18 @@ export function loadSyncedCapabilities(
52
55
  } catch {
53
56
  return {};
54
57
  }
58
+ const requiredCredentialId =
59
+ expectedCredentialId ??
60
+ (dir ? undefined : configuredEnterpriseCredentialId(process.cwd()));
61
+ if (!dir && !requiredCredentialId) {
62
+ return {};
63
+ }
64
+ if (
65
+ requiredCredentialId &&
66
+ parsed.credential_id !== requiredCredentialId
67
+ ) {
68
+ return {};
69
+ }
55
70
  const records = parsed.capabilities;
56
71
  if (!records || typeof records !== "object") return {};
57
72
 
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { configuredEnterpriseCredentialId } from "../enterprise-identity.js";
4
5
  import { workflowDefinitionSchema, type WorkflowDefinition } from "./schemas.js";
5
6
 
6
7
  /**
@@ -25,6 +26,7 @@ type LocalWorkflowRecord = {
25
26
  };
26
27
 
27
28
  type LocalWorkflowsState = {
29
+ credential_id?: string;
28
30
  workflows?: Record<string, LocalWorkflowRecord>;
29
31
  };
30
32
 
@@ -40,6 +42,7 @@ export function syncedWorkflowsCachePath(dir?: string): string {
40
42
  */
41
43
  export function loadSyncedWorkflows(
42
44
  dir?: string,
45
+ expectedCredentialId?: string,
43
46
  ): Record<string, WorkflowDefinition> {
44
47
  const path = syncedWorkflowsCachePath(dir);
45
48
  if (!existsSync(path)) return {};
@@ -50,6 +53,18 @@ export function loadSyncedWorkflows(
50
53
  } catch {
51
54
  return {};
52
55
  }
56
+ const requiredCredentialId =
57
+ expectedCredentialId ??
58
+ (dir ? undefined : configuredEnterpriseCredentialId(process.cwd()));
59
+ if (!dir && !requiredCredentialId) {
60
+ return {};
61
+ }
62
+ if (
63
+ requiredCredentialId &&
64
+ parsed.credential_id !== requiredCredentialId
65
+ ) {
66
+ return {};
67
+ }
53
68
  const records = parsed.workflows;
54
69
  if (!records || typeof records !== "object") return {};
55
70
 
@@ -6,6 +6,11 @@ import {
6
6
  import { hostname } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { loadEnterpriseConfig } from "../core/config.js";
9
+ import { matchesEnterpriseHostIdentity } from "../core/enterprise-host-identity.js";
10
+ import {
11
+ enterpriseCredentialId,
12
+ isAllowedLicenseEndpoint,
13
+ } from "../core/license.js";
9
14
  import {
10
15
  capabilityDefinitionSchema,
11
16
  type CapabilityDefinition,
@@ -51,6 +56,7 @@ type LocalCapabilityRecord = {
51
56
  };
52
57
 
53
58
  type LocalCapabilitiesState = {
59
+ credential_id?: string;
54
60
  capabilities: Record<string, LocalCapabilityRecord>;
55
61
  last_synced_at?: string;
56
62
  };
@@ -75,6 +81,10 @@ function readSyncedCapabilitiesState(): LocalCapabilitiesState {
75
81
  try {
76
82
  const parsed = JSON.parse(readFileSync(path, "utf8")) as LocalCapabilitiesState;
77
83
  return {
84
+ credential_id:
85
+ typeof parsed.credential_id === "string"
86
+ ? parsed.credential_id
87
+ : undefined,
78
88
  capabilities: parsed.capabilities ?? {},
79
89
  last_synced_at: parsed.last_synced_at,
80
90
  };
@@ -145,6 +155,28 @@ export async function runCapabilitySyncOnce(
145
155
  await writeAudit(cwd, outcome);
146
156
  return outcome;
147
157
  }
158
+ if (!isAllowedLicenseEndpoint(baseUrl)) {
159
+ const outcome: CapabilitySyncOutcome = {
160
+ kind: "failed",
161
+ error: "insecure or invalid enterprise endpoint",
162
+ };
163
+ await writeAudit(cwd, outcome);
164
+ return outcome;
165
+ }
166
+
167
+ const credentialId = enterpriseCredentialId(baseUrl, apiKey);
168
+ if (!matchesEnterpriseHostIdentity(credentialId)) {
169
+ const outcome: CapabilitySyncOutcome = {
170
+ kind: "failed",
171
+ error:
172
+ "enterprise identity conflict: this user profile is already enrolled to another endpoint or API key",
173
+ };
174
+ await writeAudit(cwd, outcome);
175
+ return outcome;
176
+ }
177
+ const state = readSyncedCapabilitiesState();
178
+ const identityChanged = state.credential_id !== credentialId;
179
+ state.credential_id = credentialId;
148
180
 
149
181
  let manifest: ManifestEntry[];
150
182
  try {
@@ -158,7 +190,6 @@ export async function runCapabilitySyncOnce(
158
190
  return outcome;
159
191
  }
160
192
 
161
- const state = readSyncedCapabilitiesState();
162
193
  const remoteByName = new Map(manifest.map((e) => [e.capability_name, e]));
163
194
 
164
195
  const added: string[] = [];
@@ -169,7 +200,8 @@ export async function runCapabilitySyncOnce(
169
200
  const local = state.capabilities[entry.capability_name];
170
201
  const isNew = !local;
171
202
  const isChanged =
172
- Boolean(local) && local.updated_at !== entry.updated_at;
203
+ Boolean(local) &&
204
+ (identityChanged || local.updated_at !== entry.updated_at);
173
205
  if (!isNew && !isChanged) continue;
174
206
 
175
207
  let fetched: FetchedCapability;
@@ -218,6 +250,10 @@ export async function runCapabilitySyncOnce(
218
250
 
219
251
  const totalChanged = added.length + changed.length + removed.length;
220
252
  if (totalChanged === 0) {
253
+ if (identityChanged) {
254
+ state.last_synced_at = new Date().toISOString();
255
+ writeSyncedCapabilitiesState(state);
256
+ }
221
257
  const outcome: CapabilitySyncOutcome = {
222
258
  kind: "unchanged",
223
259
  count: manifest.length,
@@ -14,8 +14,9 @@ import { writeHostAuditEvent } from "./ungoverned-scanner.js";
14
14
  * Per privacy boundary v3 we never send payload bytes to cortex-web —
15
15
  * only SNI + destination + bytes-transferred counters. The audit lands
16
16
  * in .context/audit/host-events-YYYY-MM-DD.jsonl as event_type =
17
- * "egress_connection". The host-events pusher (Fas 7) then forwards
18
- * those to cortex-web on the periodic timer.
17
+ * "egress_connection" only when a caller supplies an attributable project
18
+ * cwd. The shared daemon proxy intentionally omits cwd and therefore emits
19
+ * no project audit record rather than misattributing cross-project traffic.
19
20
  *
20
21
  * Plain HTTP (non-CONNECT) is also supported but logged with the Host
21
22
  * header in place of SNI.
@@ -103,7 +104,7 @@ export function parseSni(buf: Buffer): string | null {
103
104
  }
104
105
 
105
106
  export type ProxyOptions = {
106
- cwd: string;
107
+ cwd?: string;
107
108
  port?: number;
108
109
  hostId?: string;
109
110
  };
@@ -118,7 +119,8 @@ const HTTP_OK = "HTTP/1.1 200 Connection Established\r\nProxy-Agent: cortex-egre
118
119
  const HTTP_BAD = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n";
119
120
  const HTTP_BAD_GATEWAY = "HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n";
120
121
 
121
- function emit(cwd: string, evt: EgressEvent): void {
122
+ function emit(cwd: string | undefined, evt: EgressEvent): void {
123
+ if (!cwd) return;
122
124
  void writeHostAuditEvent(cwd, evt as unknown as Record<string, unknown>).catch((err) => {
123
125
  process.stderr.write(
124
126
  `[cortex-egress] audit emit failed: ${err instanceof Error ? err.message : String(err)}\n`,
@@ -147,7 +149,7 @@ function pipeWithCounting(
147
149
  client: Socket,
148
150
  upstream: Socket,
149
151
  evt: EgressEvent,
150
- cwd: string,
152
+ cwd: string | undefined,
151
153
  start: number,
152
154
  ): void {
153
155
  let firstClientChunk = true;
@@ -193,7 +195,7 @@ function handleConnect(
193
195
  client: Socket,
194
196
  host: string,
195
197
  port: number,
196
- cwd: string,
198
+ cwd: string | undefined,
197
199
  hostId: string,
198
200
  ): void {
199
201
  const evt = newEvent(host, port, "https", hostId, client.remotePort ?? null);
@@ -220,7 +222,7 @@ function handleHttp(
220
222
  client: Socket,
221
223
  url: URL,
222
224
  initialChunk: Buffer,
223
- cwd: string,
225
+ cwd: string | undefined,
224
226
  hostId: string,
225
227
  ): void {
226
228
  const port = url.port ? parseInt(url.port, 10) : 80;
@@ -245,7 +247,11 @@ function handleHttp(
245
247
  });
246
248
  }
247
249
 
248
- function handleConnection(client: Socket, cwd: string, hostId: string): void {
250
+ function handleConnection(
251
+ client: Socket,
252
+ cwd: string | undefined,
253
+ hostId: string,
254
+ ): void {
249
255
  let buffer = Buffer.alloc(0);
250
256
 
251
257
  const onFirstChunk = (chunk: Buffer) => {