@danielblomma/cortex-mcp 2.4.0 → 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 (173) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +19 -3
  3. package/bin/cortex.mjs +308 -75
  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 +321 -200
  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/run.ts +53 -19
  124. package/scaffold/mcp/src/cli/stage.ts +8 -3
  125. package/scaffold/mcp/src/core/config.ts +9 -2
  126. package/scaffold/mcp/src/core/enterprise-host-identity.ts +259 -0
  127. package/scaffold/mcp/src/core/enterprise-identity.ts +20 -0
  128. package/scaffold/mcp/src/core/enterprise-rotation.ts +185 -0
  129. package/scaffold/mcp/src/core/govern-paths.ts +30 -0
  130. package/scaffold/mcp/src/core/license.ts +186 -17
  131. package/scaffold/mcp/src/core/secure-endpoint.ts +23 -0
  132. package/scaffold/mcp/src/core/workflow/enforcement.ts +8 -1
  133. package/scaffold/mcp/src/core/workflow/mcp-tools.ts +3 -0
  134. package/scaffold/mcp/src/core/workflow/resolution.ts +9 -1
  135. package/scaffold/mcp/src/core/workflow/synced-capability-registry.ts +15 -0
  136. package/scaffold/mcp/src/core/workflow/synced-registry.ts +15 -0
  137. package/scaffold/mcp/src/daemon/capability-sync-checker.ts +38 -2
  138. package/scaffold/mcp/src/daemon/egress-proxy.ts +14 -8
  139. package/scaffold/mcp/src/daemon/global-host-events.ts +288 -0
  140. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +4 -0
  141. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +2 -0
  142. package/scaffold/mcp/src/daemon/host-events-pusher.ts +5 -0
  143. package/scaffold/mcp/src/daemon/main.ts +99 -25
  144. package/scaffold/mcp/src/daemon/project-service-registry.ts +107 -0
  145. package/scaffold/mcp/src/daemon/skill-sync-checker.ts +368 -31
  146. package/scaffold/mcp/src/daemon/sync-checker.ts +4 -0
  147. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +57 -25
  148. package/scaffold/mcp/src/daemon/workflow-sync-checker.ts +41 -2
  149. package/scaffold/mcp/src/enterprise/audit/push.ts +8 -0
  150. package/scaffold/mcp/src/enterprise/policy/sync.ts +12 -0
  151. package/scaffold/mcp/src/enterprise/reviews/push.ts +9 -0
  152. package/scaffold/mcp/src/enterprise/reviews/trust-state.ts +3 -1
  153. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +9 -0
  154. package/scaffold/mcp/src/enterprise/violations/push.ts +9 -0
  155. package/scaffold/mcp/src/enterprise/workflow/push.ts +7 -0
  156. package/scaffold/mcp/src/plugin.ts +20 -0
  157. package/scaffold/mcp/tests/copilot-shim.test.mjs +19 -1
  158. package/scaffold/mcp/tests/egress-proxy.test.mjs +37 -0
  159. package/scaffold/mcp/tests/enterprise-identity-sync.test.mjs +401 -0
  160. package/scaffold/mcp/tests/enterprise-setup.test.mjs +189 -0
  161. package/scaffold/mcp/tests/global-host-events.test.mjs +81 -0
  162. package/scaffold/mcp/tests/govern-install.test.mjs +95 -2
  163. package/scaffold/mcp/tests/govern-repair.test.mjs +20 -3
  164. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +26 -0
  165. package/scaffold/mcp/tests/license.test.mjs +367 -0
  166. package/scaffold/mcp/tests/project-service-registry.test.mjs +172 -0
  167. package/scaffold/mcp/tests/review-trust-contract.test.mjs +18 -0
  168. package/scaffold/mcp/tests/secure-enterprise-endpoint.test.mjs +46 -0
  169. package/scaffold/mcp/tests/skill-sync-checker.test.mjs +446 -2
  170. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +104 -22
  171. package/scaffold/mcp/tests/workflow-cli.test.mjs +14 -1
  172. package/scaffold/mcp/tests/workflow-synced-capabilities.test.mjs +37 -0
  173. package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +40 -1
@@ -35,7 +35,7 @@ import { isEnforcedMode, isEnterpriseProject } from "../hooks/shared.js";
35
35
  const ENTERPRISE_REQUIRED_MESSAGE =
36
36
  "cortex stage is part of the Cortex Harness — an enterprise-only feature. " +
37
37
  "This project does not have an enterprise license configured (no enterprise.api_key " +
38
- "in .context/enterprise.yml). Run 'cortex enterprise <api-key>' to enable it, or " +
38
+ "in .context/enterprise.yml). Run 'cortex enterprise install --api-key-stdin' to enable it, or " +
39
39
  "contact your org admin.";
40
40
 
41
41
  export async function runStageCommand(args: string[]): Promise<void> {
@@ -142,6 +142,7 @@ async function runStart(args: string[]): Promise<void> {
142
142
  typeof flags.workflow === "string" ? flags.workflow : "secure-build";
143
143
 
144
144
  const resolved = resolveWorkflowDefinition(workflowId, {
145
+ cwd: projectRoot(),
145
146
  emitBundledFallbackWarning: true,
146
147
  bundledFallbackPolicy: isEnforcedMode(projectRoot()) ? "block" : "warn",
147
148
  });
@@ -183,7 +184,9 @@ async function runEnvelope(args: string[]): Promise<void> {
183
184
  `No run state for task ${taskId}. Start one with 'cortex stage start'.`,
184
185
  );
185
186
  }
186
- const workflow = resolveWorkflowDefinition(state.workflow_id).workflow;
187
+ const workflow = resolveWorkflowDefinition(state.workflow_id, {
188
+ cwd: projectRoot(),
189
+ }).workflow;
187
190
  const envelope = composeStageEnvelope({
188
191
  cwd: projectRoot(),
189
192
  taskId,
@@ -243,7 +246,9 @@ async function runAdvance(args: string[]): Promise<void> {
243
246
  `No run state for task ${taskId}. Start one with 'cortex stage start'.`,
244
247
  );
245
248
  }
246
- const workflow = resolveWorkflowDefinition(state.workflow_id).workflow;
249
+ const workflow = resolveWorkflowDefinition(state.workflow_id, {
250
+ cwd: projectRoot(),
251
+ }).workflow;
247
252
  const stage = workflow.stages.find((s) => s.name === stageName);
248
253
  if (!stage) {
249
254
  throw new Error(
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { Role, RBACConfig } from "./rbac/check.js";
4
4
  import { parseValidatorsConfig, type ValidatorsConfig } from "./validators/config.js";
5
+ import { isAllowedEnterpriseEndpoint } from "./secure-endpoint.js";
5
6
 
6
7
  export type TelemetryConfig = {
7
8
  enabled: boolean;
@@ -201,7 +202,10 @@ function isLikelyHttpUrl(value: string): boolean {
201
202
 
202
203
  export function resolveEnterpriseActivation(config: EnterpriseConfig): EnterpriseActivation {
203
204
  const apiKey = config.enterprise.api_key.trim();
204
- const endpoint = config.enterprise.endpoint.trim();
205
+ const endpoint = (
206
+ config.enterprise.base_url ||
207
+ config.enterprise.endpoint
208
+ ).trim();
205
209
 
206
210
  if (!apiKey) {
207
211
  return { active: false, reason: "missing_api_key", api_key: null, endpoint: endpoint || null };
@@ -215,7 +219,10 @@ export function resolveEnterpriseActivation(config: EnterpriseConfig): Enterpris
215
219
  return { active: false, reason: "invalid_api_key_format", api_key: apiKey, endpoint };
216
220
  }
217
221
 
218
- if (!isLikelyHttpUrl(endpoint)) {
222
+ if (
223
+ !isLikelyHttpUrl(endpoint) ||
224
+ !isAllowedEnterpriseEndpoint(endpoint)
225
+ ) {
219
226
  return { active: false, reason: "invalid_endpoint_format", api_key: apiKey, endpoint };
220
227
  }
221
228
 
@@ -0,0 +1,259 @@
1
+ import {
2
+ chownSync,
3
+ chmodSync,
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ renameSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { createHash, randomUUID } from "node:crypto";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ const HOST_IDENTITY_FILENAME = "enterprise-host-identity.json";
18
+ const HOST_IDENTITY_VERSION = 2;
19
+
20
+ type EnterpriseHostIdentity = {
21
+ version: 2;
22
+ credential_id: string;
23
+ endpoint_sha256: string;
24
+ };
25
+
26
+ type HostIdentityOwner = {
27
+ uid: number;
28
+ gid: number;
29
+ };
30
+
31
+ type HostIdentityOptions = {
32
+ homeDir?: string;
33
+ owner?: HostIdentityOwner;
34
+ };
35
+
36
+ export type EnterpriseHostIdentityStatus =
37
+ | "unbound"
38
+ | "match"
39
+ | "rotation"
40
+ | "conflict"
41
+ | "invalid";
42
+
43
+ function configuredHomeDir(homeDir?: string): string {
44
+ return homeDir ?? process.env.HOME?.trim() ?? homedir();
45
+ }
46
+
47
+ function hostIdentityDir(
48
+ options: HostIdentityOptions = {},
49
+ create = false,
50
+ ): string | null {
51
+ try {
52
+ const home = realpathSync(configuredHomeDir(options.homeDir));
53
+ const dir = join(home, ".cortex");
54
+ if (existsSync(dir)) {
55
+ const stat = lstatSync(dir);
56
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
57
+ if (realpathSync(dir) !== dir) return null;
58
+ if (options.owner) {
59
+ if (stat.uid !== options.owner.uid && stat.uid !== 0) return null;
60
+ chownSync(dir, options.owner.uid, options.owner.gid);
61
+ chmodSync(dir, 0o700);
62
+ }
63
+ return dir;
64
+ }
65
+ if (!create) return null;
66
+ mkdirSync(dir, { mode: 0o700 });
67
+ if (options.owner) {
68
+ chownSync(dir, options.owner.uid, options.owner.gid);
69
+ }
70
+ chmodSync(dir, 0o700);
71
+ return dir;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function hostIdentityPath(
78
+ options: HostIdentityOptions = {},
79
+ createDir = false,
80
+ ): string | null {
81
+ const dir = hostIdentityDir(options, createDir);
82
+ return dir ? join(dir, HOST_IDENTITY_FILENAME) : null;
83
+ }
84
+
85
+ function readHostIdentity(
86
+ options: HostIdentityOptions = {},
87
+ ): EnterpriseHostIdentity | null {
88
+ const path = hostIdentityPath(options);
89
+ if (!path) return null;
90
+ if (!existsSync(path)) return null;
91
+ try {
92
+ const stat = lstatSync(path);
93
+ if (!stat.isFile() || stat.isSymbolicLink()) return null;
94
+ if (
95
+ options.owner &&
96
+ stat.uid !== options.owner.uid &&
97
+ stat.uid !== 0
98
+ ) {
99
+ return null;
100
+ }
101
+ const parsed = JSON.parse(
102
+ readFileSync(path, "utf8"),
103
+ ) as Partial<EnterpriseHostIdentity>;
104
+ if (
105
+ parsed.version !== HOST_IDENTITY_VERSION ||
106
+ typeof parsed.credential_id !== "string" ||
107
+ !/^[a-f0-9]{64}$/.test(parsed.credential_id) ||
108
+ typeof parsed.endpoint_sha256 !== "string" ||
109
+ !/^[a-f0-9]{64}$/.test(parsed.endpoint_sha256)
110
+ ) {
111
+ return null;
112
+ }
113
+ return parsed as EnterpriseHostIdentity;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Atomically bind user-global Enterprise services and artifacts to one
121
+ * endpoint/API-key identity. Malformed existing enrollment fails closed.
122
+ */
123
+ export function claimEnterpriseHostIdentity(
124
+ credentialId: string,
125
+ endpoint: string,
126
+ options: HostIdentityOptions = {},
127
+ ): boolean {
128
+ if (!/^[a-f0-9]{64}$/.test(credentialId)) return false;
129
+ const endpointSha256 = createHash("sha256")
130
+ .update(endpoint.trim().replace(/\/+$/, ""), "utf8")
131
+ .digest("hex");
132
+ const path = hostIdentityPath(options, true);
133
+ if (!path) return false;
134
+ if (existsSync(path)) {
135
+ const existing = readHostIdentity(options);
136
+ if (!existing || existing.endpoint_sha256 !== endpointSha256) {
137
+ return false;
138
+ }
139
+ if (existing.credential_id === credentialId) {
140
+ try {
141
+ if (options.owner) {
142
+ chownSync(path, options.owner.uid, options.owner.gid);
143
+ }
144
+ chmodSync(path, 0o600);
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+ return replaceHostIdentity(path, {
151
+ version: HOST_IDENTITY_VERSION,
152
+ credential_id: credentialId,
153
+ endpoint_sha256: endpointSha256,
154
+ }, options.owner);
155
+ }
156
+
157
+ try {
158
+ writeFileSync(
159
+ path,
160
+ JSON.stringify(
161
+ {
162
+ version: HOST_IDENTITY_VERSION,
163
+ credential_id: credentialId,
164
+ endpoint_sha256: endpointSha256,
165
+ },
166
+ null,
167
+ 2,
168
+ ) + "\n",
169
+ { encoding: "utf8", mode: 0o600, flag: "wx" },
170
+ );
171
+ if (options.owner) {
172
+ chownSync(path, options.owner.uid, options.owner.gid);
173
+ }
174
+ chmodSync(path, 0o600);
175
+ return true;
176
+ } catch {
177
+ // Another process may have won the create race. Only the same identity
178
+ // is accepted after re-reading the completed enrollment record.
179
+ const existing = readHostIdentity(options);
180
+ return (
181
+ existing?.credential_id === credentialId &&
182
+ existing.endpoint_sha256 === endpointSha256
183
+ );
184
+ }
185
+ }
186
+
187
+ function replaceHostIdentity(
188
+ path: string,
189
+ identity: EnterpriseHostIdentity,
190
+ owner?: HostIdentityOwner,
191
+ ): boolean {
192
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
193
+ try {
194
+ writeFileSync(
195
+ temporaryPath,
196
+ JSON.stringify(identity, null, 2) + "\n",
197
+ { encoding: "utf8", mode: 0o600, flag: "wx" },
198
+ );
199
+ if (owner) {
200
+ chownSync(temporaryPath, owner.uid, owner.gid);
201
+ }
202
+ chmodSync(temporaryPath, 0o600);
203
+ renameSync(temporaryPath, path);
204
+ chmodSync(path, 0o600);
205
+ return true;
206
+ } catch {
207
+ try {
208
+ unlinkSync(temporaryPath);
209
+ } catch {
210
+ // Best effort.
211
+ }
212
+ return false;
213
+ }
214
+ }
215
+
216
+ export function matchesEnterpriseHostIdentity(
217
+ credentialId: string,
218
+ options: HostIdentityOptions = {},
219
+ ): boolean {
220
+ if (!/^[a-f0-9]{64}$/.test(credentialId)) return false;
221
+ return readHostIdentity(options)?.credential_id === credentialId;
222
+ }
223
+
224
+ export function inspectEnterpriseHostIdentity(
225
+ credentialId: string,
226
+ endpoint: string,
227
+ options: HostIdentityOptions = {},
228
+ ): EnterpriseHostIdentityStatus {
229
+ if (!/^[a-f0-9]{64}$/.test(credentialId)) return "invalid";
230
+ let home: string;
231
+ try {
232
+ home = realpathSync(configuredHomeDir(options.homeDir));
233
+ } catch {
234
+ return "invalid";
235
+ }
236
+ const dir = join(home, ".cortex");
237
+ if (!existsSync(dir)) return "unbound";
238
+ try {
239
+ const dirStat = lstatSync(dir);
240
+ if (
241
+ !dirStat.isDirectory() ||
242
+ dirStat.isSymbolicLink() ||
243
+ realpathSync(dir) !== dir
244
+ ) {
245
+ return "invalid";
246
+ }
247
+ } catch {
248
+ return "invalid";
249
+ }
250
+ const path = join(dir, HOST_IDENTITY_FILENAME);
251
+ if (!existsSync(path)) return "unbound";
252
+ const existing = readHostIdentity(options);
253
+ if (!existing) return "invalid";
254
+ const endpointSha256 = createHash("sha256")
255
+ .update(endpoint.trim().replace(/\/+$/, ""), "utf8")
256
+ .digest("hex");
257
+ if (existing.endpoint_sha256 !== endpointSha256) return "conflict";
258
+ return existing.credential_id === credentialId ? "match" : "rotation";
259
+ }
@@ -0,0 +1,20 @@
1
+ import { join } from "node:path";
2
+ import { loadEnterpriseConfig } from "./config.js";
3
+ import { enterpriseCredentialId } from "./license.js";
4
+
5
+ /**
6
+ * Resolve the opaque per-user Enterprise identity for a project.
7
+ *
8
+ * The identifier binds the normalized endpoint and API key without
9
+ * persisting either credential in shared daemon metadata.
10
+ */
11
+ export function configuredEnterpriseCredentialId(cwd: string): string | null {
12
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
13
+ const apiKey = config.enterprise.api_key.trim();
14
+ const endpoint = (
15
+ config.enterprise.base_url ||
16
+ config.enterprise.endpoint
17
+ ).trim();
18
+ if (!apiKey || !endpoint) return null;
19
+ return enterpriseCredentialId(endpoint, apiKey);
20
+ }
@@ -0,0 +1,185 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ realpathSync,
7
+ rmdirSync,
8
+ unlinkSync,
9
+ } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { homedir } from "node:os";
12
+
13
+ const SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/;
14
+ const OWNERSHIP_FILENAME = ".cortex-managed.json";
15
+ const STATE_FILES = [
16
+ "skills.local.json",
17
+ "workflows.local.json",
18
+ "capabilities.local.json",
19
+ ".skills-update-applied.json",
20
+ ] as const;
21
+
22
+ type SkillCli = "claude" | "codex";
23
+
24
+ type PurgeTarget = {
25
+ dir: string;
26
+ marker: string;
27
+ skill: string | null;
28
+ };
29
+
30
+ function configuredHome(homeDir?: string): string {
31
+ return homeDir ?? process.env.HOME?.trim() ?? homedir();
32
+ }
33
+
34
+ function safeDirectory(path: string): string | null {
35
+ if (!existsSync(path)) return null;
36
+ const stat = lstatSync(path);
37
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
38
+ throw new Error(`${path} is not a safe directory`);
39
+ }
40
+ const real = realpathSync(path);
41
+ if (real !== path) throw new Error(`${path} is not a canonical directory`);
42
+ return real;
43
+ }
44
+
45
+ function inspectManagedSkill(
46
+ root: string,
47
+ cli: SkillCli,
48
+ name: string,
49
+ ): PurgeTarget | null {
50
+ if (!SKILL_NAME_RE.test(name)) {
51
+ throw new Error("invalid managed skill name");
52
+ }
53
+ const dir = resolve(root, name);
54
+ if (!dir.startsWith(`${root}/`)) {
55
+ throw new Error("managed skill escapes its root");
56
+ }
57
+ const stat = lstatSync(dir);
58
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
59
+ throw new Error("managed skill target is not a safe directory");
60
+ }
61
+ if (realpathSync(dir) !== dir) {
62
+ throw new Error("managed skill target is not canonical");
63
+ }
64
+ const marker = join(dir, OWNERSHIP_FILENAME);
65
+ if (!existsSync(marker)) return null;
66
+ const markerStat = lstatSync(marker);
67
+ if (!markerStat.isFile() || markerStat.isSymbolicLink()) {
68
+ throw new Error("managed skill ownership marker is unsafe");
69
+ }
70
+ const parsed = JSON.parse(readFileSync(marker, "utf8")) as {
71
+ version?: unknown;
72
+ manager?: unknown;
73
+ cli?: unknown;
74
+ name?: unknown;
75
+ };
76
+ if (
77
+ parsed.version !== 1 ||
78
+ parsed.manager !== "cortex" ||
79
+ parsed.cli !== cli ||
80
+ parsed.name !== name
81
+ ) {
82
+ throw new Error("managed skill ownership marker is invalid");
83
+ }
84
+ const entries = readdirSync(dir);
85
+ const unexpected = entries.filter(
86
+ (entry) => entry !== OWNERSHIP_FILENAME && entry !== "SKILL.md",
87
+ );
88
+ if (unexpected.length > 0) {
89
+ throw new Error("managed skill directory contains unowned files");
90
+ }
91
+ const skill = join(dir, "SKILL.md");
92
+ if (existsSync(skill)) {
93
+ const skillStat = lstatSync(skill);
94
+ if (!skillStat.isFile() || skillStat.isSymbolicLink()) {
95
+ throw new Error("managed skill file is unsafe");
96
+ }
97
+ }
98
+ return { dir, marker, skill: existsSync(skill) ? skill : null };
99
+ }
100
+
101
+ function recordedSkills(home: string): Array<{ cli: SkillCli; name: string }> {
102
+ const statePath = join(home, ".cortex", "skills.local.json");
103
+ if (!existsSync(statePath)) return [];
104
+ const stat = lstatSync(statePath);
105
+ if (!stat.isFile() || stat.isSymbolicLink()) {
106
+ throw new Error("skills state is unsafe");
107
+ }
108
+ const parsed = JSON.parse(readFileSync(statePath, "utf8")) as {
109
+ skills?: Record<string, { cli?: unknown }>;
110
+ };
111
+ if (!parsed.skills || typeof parsed.skills !== "object") return [];
112
+ const records: Array<{ cli: SkillCli; name: string }> = [];
113
+ for (const [key, value] of Object.entries(parsed.skills)) {
114
+ const [keyCli, name] = key.includes(":")
115
+ ? key.split(":", 2)
116
+ : [String(value?.cli ?? ""), key];
117
+ if (
118
+ (keyCli !== "claude" && keyCli !== "codex") ||
119
+ !SKILL_NAME_RE.test(name)
120
+ ) {
121
+ throw new Error("skills state contains an unsafe record");
122
+ }
123
+ records.push({ cli: keyCli, name });
124
+ }
125
+ return records;
126
+ }
127
+
128
+ /**
129
+ * Purge credential-specific artifacts before an explicit API-key rotation.
130
+ * Every path is derived from the target user's home and every skill deletion
131
+ * requires the Cortex ownership marker. Any ambiguity aborts rotation.
132
+ */
133
+ export function prepareEnterpriseCredentialRotation(homeDir?: string): boolean {
134
+ try {
135
+ const home = realpathSync(configuredHome(homeDir));
136
+ const cortexDir = safeDirectory(join(home, ".cortex"));
137
+ if (!cortexDir) return true;
138
+
139
+ const targets = new Map<string, PurgeTarget>();
140
+ const recorded = recordedSkills(home);
141
+ for (const cli of ["claude", "codex"] as const) {
142
+ const cliDir = safeDirectory(join(home, cli === "codex" ? ".codex" : ".claude"));
143
+ if (!cliDir) {
144
+ if (recorded.some((record) => record.cli === cli)) return false;
145
+ continue;
146
+ }
147
+ const root = safeDirectory(join(cliDir, "skills"));
148
+ if (!root) {
149
+ if (recorded.some((record) => record.cli === cli)) return false;
150
+ continue;
151
+ }
152
+ for (const name of readdirSync(root)) {
153
+ const candidate = join(root, name);
154
+ const stat = lstatSync(candidate);
155
+ if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
156
+ const target = inspectManagedSkill(root, cli, name);
157
+ if (target) targets.set(target.dir, target);
158
+ }
159
+ for (const record of recorded.filter((entry) => entry.cli === cli)) {
160
+ const candidate = join(root, record.name);
161
+ if (!existsSync(candidate)) continue;
162
+ const target = inspectManagedSkill(root, cli, record.name);
163
+ if (!target) {
164
+ // Legacy state without an ownership marker is not deletion
165
+ // authority. Abort and require the operator to resolve it.
166
+ return false;
167
+ }
168
+ targets.set(target.dir, target);
169
+ }
170
+ }
171
+
172
+ for (const target of targets.values()) {
173
+ if (target.skill) unlinkSync(target.skill);
174
+ unlinkSync(target.marker);
175
+ rmdirSync(target.dir);
176
+ }
177
+ for (const filename of STATE_FILES) {
178
+ const path = join(cortexDir, filename);
179
+ if (existsSync(path)) unlinkSync(path);
180
+ }
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
@@ -0,0 +1,30 @@
1
+ export type GovernManagedCli = "claude" | "codex" | "copilot";
2
+
3
+ const MANAGED_PATHS: Record<
4
+ GovernManagedCli,
5
+ Partial<Record<NodeJS.Platform, string>>
6
+ > = {
7
+ claude: {
8
+ darwin: "/Library/Application Support/ClaudeCode/managed-settings.json",
9
+ linux: "/etc/claude-code/managed-settings.json",
10
+ },
11
+ codex: {
12
+ darwin: "/Library/Application Support/Codex/requirements.toml",
13
+ linux: "/etc/codex/requirements.toml",
14
+ },
15
+ copilot: {
16
+ darwin: "/usr/local/bin/copilot",
17
+ linux: "/usr/local/bin/copilot",
18
+ },
19
+ };
20
+
21
+ export function getGovernManagedPath(
22
+ cli: GovernManagedCli,
23
+ os: NodeJS.Platform,
24
+ ): string {
25
+ const path = MANAGED_PATHS[cli][os];
26
+ if (!path) {
27
+ throw new Error(`govern install for ${cli} not yet supported on ${os}`);
28
+ }
29
+ return path;
30
+ }