@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,13 +1,24 @@
1
1
  import {
2
2
  existsSync,
3
+ lstatSync,
3
4
  mkdirSync,
4
5
  readFileSync,
5
- rmSync,
6
+ readdirSync,
7
+ realpathSync,
8
+ renameSync,
9
+ rmdirSync,
10
+ unlinkSync,
6
11
  writeFileSync,
7
12
  } from "node:fs";
13
+ import { randomUUID } from "node:crypto";
8
14
  import { homedir, hostname } from "node:os";
9
- import { join } from "node:path";
15
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
10
16
  import { loadEnterpriseConfig } from "../core/config.js";
17
+ import { matchesEnterpriseHostIdentity } from "../core/enterprise-host-identity.js";
18
+ import {
19
+ enterpriseCredentialId,
20
+ isAllowedLicenseEndpoint,
21
+ } from "../core/license.js";
11
22
  import { writeHostAuditEvent } from "./ungoverned-scanner.js";
12
23
  import { daemonDir } from "./paths.js";
13
24
 
@@ -35,10 +46,14 @@ import { daemonDir } from "./paths.js";
35
46
 
36
47
  const STATE_FILENAME = "skills.local.json";
37
48
  const NOTIFICATION_FILENAME = ".skills-update-applied.json";
49
+ const OWNERSHIP_FILENAME = ".cortex-managed.json";
50
+ const OWNERSHIP_VERSION = 1;
38
51
 
39
52
  const SUPPORTED_CLIS = ["claude", "codex"] as const;
40
53
  type SkillCli = (typeof SUPPORTED_CLIS)[number];
41
54
 
55
+ const SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/;
56
+
42
57
  type ManifestEntry = {
43
58
  name: string;
44
59
  scope: string;
@@ -46,6 +61,7 @@ type ManifestEntry = {
46
61
  };
47
62
 
48
63
  type LocalSkillRecord = {
64
+ credential_id?: string;
49
65
  cli: SkillCli;
50
66
  scope: string;
51
67
  updated_at: string;
@@ -53,6 +69,7 @@ type LocalSkillRecord = {
53
69
  };
54
70
 
55
71
  type LocalSkillsState = {
72
+ credential_id?: string;
56
73
  skills: Record<string, LocalSkillRecord>;
57
74
  last_synced_at?: string;
58
75
  };
@@ -102,6 +119,10 @@ function readState(): LocalSkillsState {
102
119
  : inferredCli;
103
120
  const normalizedKey = key.includes(":") ? key : `${cli}:${key}`;
104
121
  normalizedSkills[normalizedKey] = {
122
+ credential_id:
123
+ typeof record.credential_id === "string"
124
+ ? record.credential_id
125
+ : undefined,
105
126
  cli,
106
127
  scope: String(record.scope ?? "global"),
107
128
  updated_at: String(record.updated_at ?? ""),
@@ -109,6 +130,10 @@ function readState(): LocalSkillsState {
109
130
  };
110
131
  }
111
132
  return {
133
+ credential_id:
134
+ typeof parsed.credential_id === "string"
135
+ ? parsed.credential_id
136
+ : undefined,
112
137
  skills: normalizedSkills,
113
138
  last_synced_at: parsed.last_synced_at,
114
139
  };
@@ -130,12 +155,65 @@ function writeState(state: LocalSkillsState): void {
130
155
  * skills are installed once per CLI, so the destination root depends on the
131
156
  * active sync target rather than just the stored scope.
132
157
  */
133
- function skillFilePath(cli: SkillCli, name: string): string {
134
- const root =
135
- cli === "codex"
136
- ? join(homedir(), ".codex", "skills")
137
- : join(homedir(), ".claude", "skills");
138
- return join(root, name, "SKILL.md");
158
+ function isSafeSkillName(name: unknown): name is string {
159
+ return typeof name === "string" && SKILL_NAME_RE.test(name);
160
+ }
161
+
162
+ function assertPathContained(root: string, target: string): void {
163
+ const rel = relative(root, target);
164
+ if (
165
+ rel === ".." ||
166
+ rel.startsWith(`..${sep}`) ||
167
+ isAbsolute(rel)
168
+ ) {
169
+ throw new Error("skill target escapes managed root");
170
+ }
171
+ }
172
+
173
+ function configuredSkillRoot(cli: SkillCli): string {
174
+ return join(
175
+ realpathSync(homedir()),
176
+ cli === "codex" ? ".codex" : ".claude",
177
+ "skills",
178
+ );
179
+ }
180
+
181
+ function assertNotSymlink(path: string, description: string): void {
182
+ if (existsSync(path) && lstatSync(path).isSymbolicLink()) {
183
+ throw new Error(`${description} must not be a symbolic link`);
184
+ }
185
+ }
186
+
187
+ function resolveSkillRoot(cli: SkillCli, create: boolean): string | null {
188
+ const root = configuredSkillRoot(cli);
189
+ const cliRoot = resolve(root, "..");
190
+ assertNotSymlink(cliRoot, `${cli} configuration directory`);
191
+ if (!existsSync(cliRoot)) {
192
+ if (!create) return null;
193
+ mkdirSync(cliRoot, { mode: 0o700 });
194
+ }
195
+ if (!lstatSync(cliRoot).isDirectory()) {
196
+ throw new Error(`${cli} configuration path is not a directory`);
197
+ }
198
+
199
+ assertNotSymlink(root, `${cli} skills root`);
200
+ if (!existsSync(root)) {
201
+ if (!create) return null;
202
+ mkdirSync(root, { mode: 0o700 });
203
+ }
204
+ if (!lstatSync(root).isDirectory()) {
205
+ throw new Error(`${cli} skills root is not a directory`);
206
+ }
207
+ return realpathSync(root);
208
+ }
209
+
210
+ function skillFilePath(root: string, name: string): string {
211
+ if (!isSafeSkillName(name)) {
212
+ throw new Error("invalid skill name");
213
+ }
214
+ const target = resolve(root, name, "SKILL.md");
215
+ assertPathContained(resolve(root), target);
216
+ return target;
139
217
  }
140
218
 
141
219
  function stateSkillKey(cli: SkillCli, name: string): string {
@@ -151,7 +229,7 @@ async function fetchManifest(
151
229
  baseUrl: string,
152
230
  apiKey: string,
153
231
  cli: SkillCli,
154
- ): Promise<ManifestEntry[]> {
232
+ ): Promise<unknown[]> {
155
233
  const url = new URL(
156
234
  baseUrl.replace(/\/$/, "") + "/api/v1/govern/skills/manifest",
157
235
  );
@@ -162,8 +240,12 @@ async function fetchManifest(
162
240
  if (!res.ok) {
163
241
  throw new Error(`HTTP ${res.status} ${res.statusText}`);
164
242
  }
165
- const body = (await res.json()) as { skills?: ManifestEntry[] };
166
- return body.skills ?? [];
243
+ const body = (await res.json()) as { skills?: unknown };
244
+ if (body.skills === undefined) return [];
245
+ if (!Array.isArray(body.skills)) {
246
+ throw new Error("invalid skill manifest: skills must be an array");
247
+ }
248
+ return body.skills;
167
249
  }
168
250
 
169
251
  async function fetchSkillBody(
@@ -185,19 +267,219 @@ async function fetchSkillBody(
185
267
  return res.text();
186
268
  }
187
269
 
188
- function writeSkillFile(path: string, content: string): void {
189
- const dir = path.replace(/\/SKILL\.md$/, "");
190
- mkdirSync(dir, { recursive: true });
191
- writeFileSync(path, content, "utf8");
270
+ function validateManifest(
271
+ manifest: unknown[],
272
+ cli: SkillCli,
273
+ ): ManifestEntry[] {
274
+ const validated: ManifestEntry[] = [];
275
+ const names = new Set<string>();
276
+ for (let index = 0; index < manifest.length; index += 1) {
277
+ const raw = manifest[index];
278
+ if (!raw || typeof raw !== "object") {
279
+ throw new Error(`invalid skill manifest entry at index ${index}`);
280
+ }
281
+ const entry = raw as Record<string, unknown>;
282
+ if (!isSafeSkillName(entry.name)) {
283
+ throw new Error(`invalid skill name at manifest index ${index}`);
284
+ }
285
+ if (
286
+ entry.scope !== "global" &&
287
+ entry.scope !== "cli:claude" &&
288
+ entry.scope !== "cli:codex"
289
+ ) {
290
+ throw new Error(`invalid skill scope at manifest index ${index}`);
291
+ }
292
+ if (typeof entry.updated_at !== "string" || !entry.updated_at.trim()) {
293
+ throw new Error(`invalid skill timestamp at manifest index ${index}`);
294
+ }
295
+ if (shouldSyncForCli(entry.scope, cli)) {
296
+ if (names.has(entry.name)) {
297
+ throw new Error(`duplicate skill name at manifest index ${index}`);
298
+ }
299
+ names.add(entry.name);
300
+ }
301
+ validated.push({
302
+ name: entry.name,
303
+ scope: entry.scope,
304
+ updated_at: entry.updated_at,
305
+ });
306
+ }
307
+ return validated;
308
+ }
309
+
310
+ type OwnershipMarker = {
311
+ version: number;
312
+ manager: string;
313
+ cli: SkillCli;
314
+ name: string;
315
+ };
316
+
317
+ function ownershipMarker(cli: SkillCli, name: string): OwnershipMarker {
318
+ return {
319
+ version: OWNERSHIP_VERSION,
320
+ manager: "cortex",
321
+ cli,
322
+ name,
323
+ };
324
+ }
325
+
326
+ function assertCortexOwnedSkillDir(
327
+ skillDir: string,
328
+ cli: SkillCli,
329
+ name: string,
330
+ ): void {
331
+ const markerPath = join(skillDir, OWNERSHIP_FILENAME);
332
+ if (!existsSync(markerPath) || lstatSync(markerPath).isSymbolicLink()) {
333
+ throw new Error("skill directory is not owned by Cortex");
334
+ }
335
+ let parsed: Partial<OwnershipMarker>;
336
+ try {
337
+ parsed = JSON.parse(readFileSync(markerPath, "utf8")) as Partial<OwnershipMarker>;
338
+ } catch {
339
+ throw new Error("skill directory has an invalid Cortex ownership marker");
340
+ }
341
+ if (
342
+ parsed.version !== OWNERSHIP_VERSION ||
343
+ parsed.manager !== "cortex" ||
344
+ parsed.cli !== cli ||
345
+ parsed.name !== name
346
+ ) {
347
+ throw new Error("skill directory has an invalid Cortex ownership marker");
348
+ }
192
349
  }
193
350
 
194
- function removeSkillFile(path: string): void {
195
- if (!existsSync(path)) return;
196
- // Remove the per-skill directory (parent of SKILL.md). The skills root
197
- // is shared with non-Cortex skills so we never recurse beyond the
198
- // skill's own directory.
199
- const dir = path.replace(/\/SKILL\.md$/, "");
200
- rmSync(dir, { recursive: true, force: true });
351
+ function preflightSkillWrite(
352
+ cli: SkillCli,
353
+ name: string,
354
+ ): { root: string; skillDir: string; target: string } {
355
+ const root = resolveSkillRoot(cli, true);
356
+ if (!root) throw new Error("skill root could not be created");
357
+ const target = skillFilePath(root, name);
358
+ const skillDir = resolve(root, name);
359
+ assertPathContained(root, skillDir);
360
+ if (!existsSync(skillDir)) return { root, skillDir, target };
361
+
362
+ const stat = lstatSync(skillDir);
363
+ if (stat.isSymbolicLink()) {
364
+ throw new Error("skill directory must not be a symbolic link");
365
+ }
366
+ if (!stat.isDirectory()) {
367
+ throw new Error("managed skill target is not a directory");
368
+ }
369
+ assertCortexOwnedSkillDir(skillDir, cli, name);
370
+ const existingSkillFile = join(skillDir, "SKILL.md");
371
+ if (
372
+ existsSync(existingSkillFile) &&
373
+ lstatSync(existingSkillFile).isSymbolicLink()
374
+ ) {
375
+ throw new Error("skill file must not be a symbolic link");
376
+ }
377
+ return { root, skillDir, target };
378
+ }
379
+
380
+ function writeSkillFile(
381
+ cli: SkillCli,
382
+ name: string,
383
+ content: string,
384
+ ): string {
385
+ const {
386
+ root,
387
+ skillDir,
388
+ target: lexicalTarget,
389
+ } = preflightSkillWrite(cli, name);
390
+
391
+ let created = false;
392
+ if (existsSync(skillDir)) {
393
+ const stat = lstatSync(skillDir);
394
+ if (stat.isSymbolicLink()) {
395
+ throw new Error("skill directory must not be a symbolic link");
396
+ }
397
+ if (!stat.isDirectory()) {
398
+ throw new Error("managed skill target is not a directory");
399
+ }
400
+ assertCortexOwnedSkillDir(skillDir, cli, name);
401
+ } else {
402
+ mkdirSync(skillDir, { mode: 0o700 });
403
+ created = true;
404
+ writeFileSync(
405
+ join(skillDir, OWNERSHIP_FILENAME),
406
+ JSON.stringify(ownershipMarker(cli, name), null, 2) + "\n",
407
+ { encoding: "utf8", mode: 0o600, flag: "wx" },
408
+ );
409
+ }
410
+
411
+ const realSkillDir = realpathSync(skillDir);
412
+ assertPathContained(root, realSkillDir);
413
+ const target = join(realSkillDir, "SKILL.md");
414
+ assertPathContained(root, target);
415
+ if (existsSync(target) && lstatSync(target).isSymbolicLink()) {
416
+ throw new Error("skill file must not be a symbolic link");
417
+ }
418
+
419
+ const temporaryTarget = join(
420
+ realSkillDir,
421
+ `.SKILL.md.${process.pid}.${randomUUID()}.tmp`,
422
+ );
423
+ try {
424
+ writeFileSync(temporaryTarget, content, {
425
+ encoding: "utf8",
426
+ mode: 0o600,
427
+ flag: "wx",
428
+ });
429
+ renameSync(temporaryTarget, target);
430
+ } catch (err) {
431
+ if (existsSync(temporaryTarget)) unlinkSync(temporaryTarget);
432
+ if (created) {
433
+ const entries = readdirSync(skillDir);
434
+ if (
435
+ entries.length === 1 &&
436
+ entries[0] === OWNERSHIP_FILENAME
437
+ ) {
438
+ unlinkSync(join(skillDir, OWNERSHIP_FILENAME));
439
+ rmdirSync(skillDir);
440
+ }
441
+ }
442
+ throw err;
443
+ }
444
+ return lexicalTarget;
445
+ }
446
+
447
+ function removeSkillFile(cli: SkillCli, name: string): void {
448
+ if (!isSafeSkillName(name)) {
449
+ throw new Error("invalid skill name");
450
+ }
451
+ const root = resolveSkillRoot(cli, false);
452
+ if (!root) return;
453
+ const skillDir = resolve(root, name);
454
+ assertPathContained(root, skillDir);
455
+ if (!existsSync(skillDir)) return;
456
+
457
+ const stat = lstatSync(skillDir);
458
+ if (stat.isSymbolicLink()) {
459
+ throw new Error("skill directory must not be a symbolic link");
460
+ }
461
+ if (!stat.isDirectory()) {
462
+ throw new Error("managed skill target is not a directory");
463
+ }
464
+ assertPathContained(root, realpathSync(skillDir));
465
+ assertCortexOwnedSkillDir(skillDir, cli, name);
466
+
467
+ const entries = readdirSync(skillDir);
468
+ const unexpected = entries.filter(
469
+ (entry) => entry !== OWNERSHIP_FILENAME && entry !== "SKILL.md",
470
+ );
471
+ if (unexpected.length > 0) {
472
+ throw new Error("managed skill directory contains unowned files");
473
+ }
474
+ const skillPath = join(skillDir, "SKILL.md");
475
+ if (existsSync(skillPath)) {
476
+ if (lstatSync(skillPath).isSymbolicLink()) {
477
+ throw new Error("skill file must not be a symbolic link");
478
+ }
479
+ unlinkSync(skillPath);
480
+ }
481
+ unlinkSync(join(skillDir, OWNERSHIP_FILENAME));
482
+ rmdirSync(skillDir);
201
483
  }
202
484
 
203
485
  function writeNotification(data: {
@@ -224,10 +506,30 @@ export async function runSkillSyncForCli(
224
506
  if (!apiKey || !baseUrl) {
225
507
  return { kind: "failed", cli, error: "enterprise not configured" };
226
508
  }
509
+ if (!isAllowedLicenseEndpoint(baseUrl)) {
510
+ return { kind: "failed", cli, error: "insecure or invalid enterprise endpoint" };
511
+ }
512
+
513
+ const credentialId = enterpriseCredentialId(baseUrl, apiKey);
514
+ if (!matchesEnterpriseHostIdentity(credentialId)) {
515
+ return {
516
+ kind: "failed",
517
+ cli,
518
+ error:
519
+ "enterprise identity conflict: this user profile is already enrolled to another endpoint or API key",
520
+ };
521
+ }
522
+ const state = readState();
523
+ const identityChanged = state.credential_id !== credentialId;
524
+ let stateSanitized = identityChanged;
525
+ state.credential_id = credentialId;
227
526
 
228
527
  let manifest: ManifestEntry[];
229
528
  try {
230
- manifest = await fetchManifest(baseUrl, apiKey, cli);
529
+ manifest = validateManifest(
530
+ await fetchManifest(baseUrl, apiKey, cli),
531
+ cli,
532
+ );
231
533
  } catch (err) {
232
534
  return {
233
535
  kind: "failed",
@@ -236,7 +538,6 @@ export async function runSkillSyncForCli(
236
538
  };
237
539
  }
238
540
 
239
- const state = readState();
240
541
  const relevantManifest = manifest.filter((entry) =>
241
542
  shouldSyncForCli(entry.scope, cli),
242
543
  );
@@ -245,7 +546,6 @@ export async function runSkillSyncForCli(
245
546
  const added: string[] = [];
246
547
  const changed: string[] = [];
247
548
  const removed: string[] = [];
248
-
249
549
  // Detect adds + changes
250
550
  for (const entry of relevantManifest) {
251
551
  const skillKey = stateSkillKey(cli, entry.name);
@@ -253,9 +553,27 @@ export async function runSkillSyncForCli(
253
553
  const isNew = !local;
254
554
  const isChanged =
255
555
  Boolean(local) &&
256
- (local.updated_at !== entry.updated_at || local.scope !== entry.scope);
556
+ (
557
+ identityChanged ||
558
+ local.credential_id !== credentialId ||
559
+ local.updated_at !== entry.updated_at ||
560
+ local.scope !== entry.scope
561
+ );
257
562
  if (!isNew && !isChanged) continue;
258
563
 
564
+ try {
565
+ preflightSkillWrite(cli, entry.name);
566
+ } catch (err) {
567
+ return {
568
+ kind: "failed",
569
+ cli,
570
+ error:
571
+ err instanceof Error
572
+ ? `preflight ${entry.name}: ${err.message}`
573
+ : `preflight ${entry.name}: ${String(err)}`,
574
+ };
575
+ }
576
+
259
577
  let body: string;
260
578
  try {
261
579
  body = await fetchSkillBody(baseUrl, apiKey, entry.name);
@@ -270,9 +588,9 @@ export async function runSkillSyncForCli(
270
588
  };
271
589
  }
272
590
 
273
- const path = skillFilePath(cli, entry.name);
591
+ let path: string;
274
592
  try {
275
- writeSkillFile(path, body);
593
+ path = writeSkillFile(cli, entry.name, body);
276
594
  } catch (err) {
277
595
  return {
278
596
  kind: "failed",
@@ -285,6 +603,7 @@ export async function runSkillSyncForCli(
285
603
  }
286
604
 
287
605
  state.skills[skillKey] = {
606
+ credential_id: credentialId,
288
607
  cli,
289
608
  scope: entry.scope,
290
609
  updated_at: entry.updated_at,
@@ -301,12 +620,26 @@ export async function runSkillSyncForCli(
301
620
  if (record.cli !== cli) continue;
302
621
  const [, name] = skillKey.split(":", 2);
303
622
  if (!name) continue;
623
+ if (!isSafeSkillName(name)) {
624
+ // State from an older vulnerable version is not deletion authority.
625
+ // Drop the record without touching its persisted absolute path.
626
+ delete state.skills[skillKey];
627
+ stateSanitized = true;
628
+ continue;
629
+ }
304
630
  if (!shouldSyncForCli(record.scope, cli)) continue;
305
631
  if (remoteByName.has(name)) continue;
306
632
  try {
307
- removeSkillFile(record.path);
308
- } catch {
309
- // best-effort; if unlink fails the next tick will retry
633
+ removeSkillFile(cli, name);
634
+ } catch (err) {
635
+ return {
636
+ kind: "failed",
637
+ cli,
638
+ error:
639
+ err instanceof Error
640
+ ? `remove ${name}: ${err.message}`
641
+ : `remove ${name}: ${String(err)}`,
642
+ };
310
643
  }
311
644
  delete state.skills[skillKey];
312
645
  removed.push(name);
@@ -314,6 +647,10 @@ export async function runSkillSyncForCli(
314
647
 
315
648
  const totalChanged = added.length + changed.length + removed.length;
316
649
  if (totalChanged === 0) {
650
+ if (stateSanitized) {
651
+ state.last_synced_at = new Date().toISOString();
652
+ writeState(state);
653
+ }
317
654
  return { kind: "unchanged", cli, count: relevantManifest.length };
318
655
  }
319
656
 
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
2
  import { hostname } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { loadEnterpriseConfig } from "../core/config.js";
5
+ import { isAllowedEnterpriseEndpoint } from "../core/secure-endpoint.js";
5
6
  import { writeHostAuditEvent } from "./ungoverned-scanner.js";
6
7
 
7
8
  /**
@@ -84,6 +85,9 @@ export async function checkSyncForCli(
84
85
  if (!apiKey || !baseUrl) {
85
86
  return { kind: "failed", error: "enterprise not configured" };
86
87
  }
88
+ if (!isAllowedEnterpriseEndpoint(baseUrl)) {
89
+ return { kind: "failed", error: "insecure or invalid enterprise endpoint" };
90
+ }
87
91
  const frameworks = activeFrameworks(cwd);
88
92
  if (frameworks.length === 0) {
89
93
  return { kind: "failed", error: "no active frameworks" };
@@ -1,7 +1,7 @@
1
1
  import { appendFile, mkdir } from "node:fs/promises";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { userInfo } from "node:os";
4
+ import { platform, userInfo } from "node:os";
5
5
  import {
6
6
  detectUngoverned,
7
7
  enforceFinding,
@@ -9,12 +9,16 @@ import {
9
9
  type EnforcementMode,
10
10
  type UngovernedFinding,
11
11
  } from "../cli/ungoverned-detector.js";
12
+ import { writeGlobalUngovernedEvent } from "./global-host-events.js";
13
+ import { getGovernManagedPath } from "../core/govern-paths.js";
12
14
 
13
15
  export type ScannerOptions = {
14
16
  cwd: string;
15
17
  intervalMs?: number;
16
18
  mode?: EnforcementMode;
17
19
  detectorOptions?: DetectorOptions;
20
+ credentialId?: string;
21
+ managedPathOverrides?: Partial<Record<"claude" | "codex", string>>;
18
22
  onFinding?: (finding: UngovernedFinding & { action: string }) => void;
19
23
  };
20
24
 
@@ -38,7 +42,28 @@ function readMode(cwd: string): EnforcementMode {
38
42
  }
39
43
  }
40
44
 
41
- function readManagedTier1Clis(cwd: string): Set<string> {
45
+ function isExpectedManagedArtifact(
46
+ cli: "claude" | "codex",
47
+ path: string,
48
+ ): boolean {
49
+ try {
50
+ const stat = lstatSync(path);
51
+ if (!stat.isFile() || stat.isSymbolicLink()) return false;
52
+ const raw = readFileSync(path, "utf8");
53
+ if (cli === "codex") {
54
+ return raw.startsWith("# Cortex govern — codex requirements");
55
+ }
56
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
57
+ return parsed.allowManagedHooksOnly === true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ function readManagedTier1Clis(
64
+ cwd: string,
65
+ overrides?: Partial<Record<"claude" | "codex", string>>,
66
+ ): Set<string> {
42
67
  const stateFile = join(cwd, ".context", "govern.local.json");
43
68
  const managed = new Set<string>();
44
69
  if (!existsSync(stateFile)) return managed;
@@ -49,7 +74,13 @@ function readManagedTier1Clis(cwd: string): Set<string> {
49
74
  };
50
75
  for (const [cli, inst] of Object.entries(parsed.installs ?? {})) {
51
76
  if (!TIER1_CLIS.has(cli)) continue;
52
- if (!inst?.path || !existsSync(inst.path)) continue;
77
+ const tier1Cli = cli as "claude" | "codex";
78
+ const expectedPath =
79
+ overrides?.[tier1Cli] ??
80
+ getGovernManagedPath(tier1Cli, platform());
81
+ if (!isExpectedManagedArtifact(tier1Cli, expectedPath)) {
82
+ continue;
83
+ }
53
84
  managed.add(cli);
54
85
  }
55
86
  } catch {
@@ -79,7 +110,10 @@ export async function writeHostAuditEvent(
79
110
 
80
111
  export async function runScanOnce(options: ScannerOptions): Promise<UngovernedFinding[]> {
81
112
  const mode = options.mode ?? readMode(options.cwd);
82
- const managedTier1Clis = readManagedTier1Clis(options.cwd);
113
+ const managedTier1Clis = readManagedTier1Clis(
114
+ options.cwd,
115
+ options.managedPathOverrides,
116
+ );
83
117
  const findings = filterManagedTier1Findings(
84
118
  detectUngoverned(options.detectorOptions),
85
119
  managedTier1Clis,
@@ -87,26 +121,24 @@ export async function runScanOnce(options: ScannerOptions): Promise<UngovernedFi
87
121
  const me = userInfo().username;
88
122
  for (const finding of findings) {
89
123
  const action = enforceFinding(finding, { mode, currentUser: me });
90
- const event = {
91
- event_type: "ungoverned_ai_session_detected",
92
- timestamp: finding.detected_at,
93
- host_id: finding.host_id,
94
- cli: finding.cli,
95
- binary: finding.binary,
96
- pid: finding.pid,
97
- ppid: finding.ppid,
98
- user: finding.user,
99
- args: finding.args,
100
- parent_chain: finding.parent_chain,
101
- mode,
102
- action,
103
- };
104
- try {
105
- await writeHostAuditEvent(options.cwd, event);
106
- } catch (err) {
107
- process.stderr.write(
108
- `[cortex-daemon] failed to write ungoverned audit: ${err instanceof Error ? err.message : String(err)}\n`,
109
- );
124
+ // A host process snapshot has no trustworthy project attribution. Store
125
+ // it only in the credential-bound user-global queue; never in whichever
126
+ // project happened to register first.
127
+ if (options.credentialId) {
128
+ writeGlobalUngovernedEvent(options.credentialId, {
129
+ event_type: "ungoverned_ai_session_detected",
130
+ timestamp: finding.detected_at,
131
+ host_id: finding.host_id,
132
+ cli: finding.cli,
133
+ binary: finding.binary,
134
+ pid: finding.pid,
135
+ ppid: finding.ppid,
136
+ user: finding.user,
137
+ args: finding.args,
138
+ parent_chain: finding.parent_chain,
139
+ mode,
140
+ action,
141
+ });
110
142
  }
111
143
  options.onFinding?.({ ...finding, action });
112
144
  }