@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.
- package/CHANGELOG.md +201 -0
- package/README.md +73 -3
- package/bin/cortex.mjs +339 -242
- package/bin/daemon-control.mjs +162 -0
- package/package.json +7 -3
- package/scaffold/mcp/dist/.cortex-build-hash +1 -0
- package/scaffold/mcp/dist/cli/enterprise-setup.js +134 -0
- package/scaffold/mcp/dist/cli/govern.js +937 -0
- package/scaffold/mcp/dist/cli/query.js +409 -0
- package/scaffold/mcp/dist/cli/run.js +295 -0
- package/scaffold/mcp/dist/cli/stage.js +308 -0
- package/scaffold/mcp/dist/cli/telemetry-test.js +141 -0
- package/scaffold/mcp/dist/cli/ungoverned-detector.js +133 -0
- package/scaffold/mcp/dist/contextEntities.js +282 -0
- package/scaffold/mcp/dist/core/audit/query.js +72 -0
- package/scaffold/mcp/dist/core/audit/writer.js +28 -0
- package/scaffold/mcp/dist/core/config.js +235 -0
- package/scaffold/mcp/dist/core/enterprise-host-identity.js +193 -0
- package/scaffold/mcp/dist/core/enterprise-identity.js +18 -0
- package/scaffold/mcp/dist/core/enterprise-rotation.js +158 -0
- package/scaffold/mcp/dist/core/govern-paths.js +21 -0
- package/scaffold/mcp/dist/core/index.js +14 -0
- package/scaffold/mcp/dist/core/license.js +278 -0
- package/scaffold/mcp/dist/core/policy/enforce.js +66 -0
- package/scaffold/mcp/dist/core/policy/injection.js +172 -0
- package/scaffold/mcp/dist/core/policy/store.js +179 -0
- package/scaffold/mcp/dist/core/rbac/check.js +30 -0
- package/scaffold/mcp/dist/core/secure-endpoint.js +25 -0
- package/scaffold/mcp/dist/core/telemetry/collector.js +285 -0
- package/scaffold/mcp/dist/core/telemetry/state-dir.js +31 -0
- package/scaffold/mcp/dist/core/validators/builtins.js +632 -0
- package/scaffold/mcp/dist/core/validators/config.js +44 -0
- package/scaffold/mcp/dist/core/validators/engine.js +120 -0
- package/scaffold/mcp/dist/core/validators/evaluators/code_comments.js +236 -0
- package/scaffold/mcp/dist/core/validators/evaluators/regex.js +116 -0
- package/scaffold/mcp/dist/core/workflow/artifact-io.js +103 -0
- package/scaffold/mcp/dist/core/workflow/capabilities.js +88 -0
- package/scaffold/mcp/dist/core/workflow/default-workflows.js +102 -0
- package/scaffold/mcp/dist/core/workflow/enforcement.js +164 -0
- package/scaffold/mcp/dist/core/workflow/envelope.js +132 -0
- package/scaffold/mcp/dist/core/workflow/index.js +11 -0
- package/scaffold/mcp/dist/core/workflow/mcp-tools.js +175 -0
- package/scaffold/mcp/dist/core/workflow/resolution.js +69 -0
- package/scaffold/mcp/dist/core/workflow/run-lifecycle.js +123 -0
- package/scaffold/mcp/dist/core/workflow/schemas.js +141 -0
- package/scaffold/mcp/dist/core/workflow/synced-capability-registry.js +60 -0
- package/scaffold/mcp/dist/core/workflow/synced-registry.js +60 -0
- package/scaffold/mcp/dist/daemon/capability-sync-checker.js +242 -0
- package/scaffold/mcp/dist/daemon/client.js +130 -0
- package/scaffold/mcp/dist/daemon/egress-proxy.js +288 -0
- package/scaffold/mcp/dist/daemon/global-host-events.js +222 -0
- package/scaffold/mcp/dist/daemon/heartbeat-pusher.js +116 -0
- package/scaffold/mcp/dist/daemon/heartbeat-tracker.js +165 -0
- package/scaffold/mcp/dist/daemon/host-events-pusher.js +249 -0
- package/scaffold/mcp/dist/daemon/main.js +449 -0
- package/scaffold/mcp/dist/daemon/paths.js +36 -0
- package/scaffold/mcp/dist/daemon/project-service-registry.js +78 -0
- package/scaffold/mcp/dist/daemon/protocol.js +8 -0
- package/scaffold/mcp/dist/daemon/server.js +180 -0
- package/scaffold/mcp/dist/daemon/skill-sync-checker.js +557 -0
- package/scaffold/mcp/dist/daemon/sync-checker.js +165 -0
- package/scaffold/mcp/dist/daemon/ungoverned-scanner.js +136 -0
- package/scaffold/mcp/dist/daemon/workflow-sync-checker.js +249 -0
- package/scaffold/mcp/dist/defaults.js +6 -0
- package/scaffold/mcp/dist/embed.js +627 -0
- package/scaffold/mcp/dist/embedScheduler.js +479 -0
- package/scaffold/mcp/dist/embeddings.js +167 -0
- package/scaffold/mcp/dist/enterprise/audit/push.js +66 -0
- package/scaffold/mcp/dist/enterprise/index.js +327 -0
- package/scaffold/mcp/dist/enterprise/model/deploy.js +27 -0
- package/scaffold/mcp/dist/enterprise/policy/sync.js +129 -0
- package/scaffold/mcp/dist/enterprise/privacy/boundary.js +184 -0
- package/scaffold/mcp/dist/enterprise/reviews/changed-files.js +33 -0
- package/scaffold/mcp/dist/enterprise/reviews/pattern-context.js +202 -0
- package/scaffold/mcp/dist/enterprise/reviews/policy-selection.js +46 -0
- package/scaffold/mcp/dist/enterprise/reviews/push.js +102 -0
- package/scaffold/mcp/dist/enterprise/reviews/trust-state.js +186 -0
- package/scaffold/mcp/dist/enterprise/telemetry/sync.js +57 -0
- package/scaffold/mcp/dist/enterprise/tools/enterprise.js +826 -0
- package/scaffold/mcp/dist/enterprise/tools/harness.js +40 -0
- package/scaffold/mcp/dist/enterprise/tools/walk.js +73 -0
- package/scaffold/mcp/dist/enterprise/violations/push.js +77 -0
- package/scaffold/mcp/dist/enterprise/workflow/push.js +44 -0
- package/scaffold/mcp/dist/enterprise/workflow/state.js +329 -0
- package/scaffold/mcp/dist/frontmatter.js +33 -0
- package/scaffold/mcp/dist/graph.js +769 -0
- package/scaffold/mcp/dist/graphCsv.js +55 -0
- package/scaffold/mcp/dist/graphMetrics.js +8 -0
- package/scaffold/mcp/dist/hooks/permission-request.js +89 -0
- package/scaffold/mcp/dist/hooks/post-tool-use.js +105 -0
- package/scaffold/mcp/dist/hooks/pre-compact.js +29 -0
- package/scaffold/mcp/dist/hooks/pre-tool-use.js +78 -0
- package/scaffold/mcp/dist/hooks/session-end.js +43 -0
- package/scaffold/mcp/dist/hooks/session-start.js +41 -0
- package/scaffold/mcp/dist/hooks/shared.js +194 -0
- package/scaffold/mcp/dist/hooks/stop.js +28 -0
- package/scaffold/mcp/dist/hooks/user-prompt-submit.js +33 -0
- package/scaffold/mcp/dist/impactPresentation.js +137 -0
- package/scaffold/mcp/dist/impactRanking.js +191 -0
- package/scaffold/mcp/dist/impactResponse.js +30 -0
- package/scaffold/mcp/dist/impactResults.js +105 -0
- package/scaffold/mcp/dist/impactSeed.js +20 -0
- package/scaffold/mcp/dist/impactTraversal.js +64 -0
- package/scaffold/mcp/dist/jsonl.js +77 -0
- package/scaffold/mcp/dist/loadGraph.js +759 -0
- package/scaffold/mcp/dist/lruCache.js +38 -0
- package/scaffold/mcp/dist/paths.js +97 -0
- package/scaffold/mcp/dist/patternEvidence.js +272 -0
- package/scaffold/mcp/dist/plugin.js +81 -0
- package/scaffold/mcp/dist/presets.js +78 -0
- package/scaffold/mcp/dist/relatedResponse.js +18 -0
- package/scaffold/mcp/dist/relatedTraversal.js +78 -0
- package/scaffold/mcp/dist/rules.js +23 -0
- package/scaffold/mcp/dist/search.js +212 -0
- package/scaffold/mcp/dist/searchCore.js +457 -0
- package/scaffold/mcp/dist/searchResults.js +230 -0
- package/scaffold/mcp/dist/server.js +317 -0
- package/scaffold/mcp/dist/types.js +1 -0
- package/scaffold/mcp/package-lock.json +336 -212
- package/scaffold/mcp/package.json +15 -6
- package/scaffold/mcp/src/cli/enterprise-setup.ts +82 -8
- package/scaffold/mcp/src/cli/govern.ts +137 -52
- package/scaffold/mcp/src/cli/query.ts +36 -2
- package/scaffold/mcp/src/cli/run.ts +53 -19
- package/scaffold/mcp/src/cli/stage.ts +8 -3
- package/scaffold/mcp/src/core/config.ts +9 -2
- package/scaffold/mcp/src/core/enterprise-host-identity.ts +259 -0
- package/scaffold/mcp/src/core/enterprise-identity.ts +20 -0
- package/scaffold/mcp/src/core/enterprise-rotation.ts +185 -0
- package/scaffold/mcp/src/core/govern-paths.ts +30 -0
- package/scaffold/mcp/src/core/license.ts +186 -17
- package/scaffold/mcp/src/core/secure-endpoint.ts +23 -0
- package/scaffold/mcp/src/core/workflow/enforcement.ts +8 -1
- package/scaffold/mcp/src/core/workflow/mcp-tools.ts +3 -0
- package/scaffold/mcp/src/core/workflow/resolution.ts +9 -1
- package/scaffold/mcp/src/core/workflow/synced-capability-registry.ts +15 -0
- package/scaffold/mcp/src/core/workflow/synced-registry.ts +15 -0
- package/scaffold/mcp/src/daemon/capability-sync-checker.ts +38 -2
- package/scaffold/mcp/src/daemon/egress-proxy.ts +14 -8
- package/scaffold/mcp/src/daemon/global-host-events.ts +288 -0
- package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +4 -0
- package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +2 -0
- package/scaffold/mcp/src/daemon/host-events-pusher.ts +5 -0
- package/scaffold/mcp/src/daemon/main.ts +99 -25
- package/scaffold/mcp/src/daemon/project-service-registry.ts +107 -0
- package/scaffold/mcp/src/daemon/skill-sync-checker.ts +368 -31
- package/scaffold/mcp/src/daemon/sync-checker.ts +4 -0
- package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +57 -25
- package/scaffold/mcp/src/daemon/workflow-sync-checker.ts +41 -2
- package/scaffold/mcp/src/embed.ts +211 -10
- package/scaffold/mcp/src/enterprise/audit/push.ts +8 -0
- package/scaffold/mcp/src/enterprise/index.ts +1 -1
- package/scaffold/mcp/src/enterprise/policy/sync.ts +12 -0
- package/scaffold/mcp/src/enterprise/reviews/changed-files.ts +34 -0
- package/scaffold/mcp/src/enterprise/reviews/pattern-context.ts +231 -0
- package/scaffold/mcp/src/enterprise/reviews/push.ts +9 -0
- package/scaffold/mcp/src/enterprise/reviews/trust-state.ts +3 -1
- package/scaffold/mcp/src/enterprise/telemetry/sync.ts +9 -0
- package/scaffold/mcp/src/enterprise/tools/enterprise.ts +49 -36
- package/scaffold/mcp/src/enterprise/violations/push.ts +9 -0
- package/scaffold/mcp/src/enterprise/workflow/push.ts +7 -0
- package/scaffold/mcp/src/paths.ts +3 -5
- package/scaffold/mcp/src/patternEvidence.ts +347 -0
- package/scaffold/mcp/src/plugin.ts +20 -0
- package/scaffold/mcp/src/search.ts +35 -9
- package/scaffold/mcp/src/searchCore.ts +254 -12
- package/scaffold/mcp/src/searchResults.ts +96 -9
- package/scaffold/mcp/src/types.ts +7 -0
- package/scaffold/mcp/tests/changed-files.test.mjs +41 -0
- package/scaffold/mcp/tests/copilot-shim.test.mjs +19 -1
- package/scaffold/mcp/tests/egress-proxy.test.mjs +37 -0
- package/scaffold/mcp/tests/embed-entities.test.mjs +109 -1
- package/scaffold/mcp/tests/enterprise-identity-sync.test.mjs +401 -0
- package/scaffold/mcp/tests/enterprise-pattern-context.test.mjs +322 -0
- package/scaffold/mcp/tests/enterprise-setup.test.mjs +189 -0
- package/scaffold/mcp/tests/global-host-events.test.mjs +81 -0
- package/scaffold/mcp/tests/govern-install.test.mjs +95 -2
- package/scaffold/mcp/tests/govern-repair.test.mjs +20 -3
- package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +26 -0
- package/scaffold/mcp/tests/license.test.mjs +367 -0
- package/scaffold/mcp/tests/paths.test.mjs +11 -3
- package/scaffold/mcp/tests/pattern-evidence.test.mjs +321 -0
- package/scaffold/mcp/tests/project-service-registry.test.mjs +172 -0
- package/scaffold/mcp/tests/query-cli.test.mjs +73 -1
- package/scaffold/mcp/tests/review-trust-contract.test.mjs +18 -0
- package/scaffold/mcp/tests/search-graph-score.test.mjs +167 -0
- package/scaffold/mcp/tests/secure-enterprise-endpoint.test.mjs +46 -0
- package/scaffold/mcp/tests/skill-sync-checker.test.mjs +446 -2
- package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +104 -22
- package/scaffold/mcp/tests/workflow-cli.test.mjs +14 -1
- package/scaffold/mcp/tests/workflow-synced-capabilities.test.mjs +37 -0
- package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +40 -1
- package/scaffold/scripts/dashboard.mjs +29 -18
- package/scaffold/scripts/doctor.sh +26 -8
- package/scaffold/scripts/ingest.mjs +37 -4
- package/scaffold/scripts/status.sh +13 -6
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { loadEnterpriseConfig } from "../core/config.js";
|
|
6
|
+
import { matchesEnterpriseHostIdentity } from "../core/enterprise-host-identity.js";
|
|
7
|
+
import { enterpriseCredentialId, isAllowedLicenseEndpoint, } from "../core/license.js";
|
|
8
|
+
import { writeHostAuditEvent } from "./ungoverned-scanner.js";
|
|
9
|
+
import { daemonDir } from "./paths.js";
|
|
10
|
+
/**
|
|
11
|
+
* Skills v3 sync flow — daemon side.
|
|
12
|
+
*
|
|
13
|
+
* The daemon polls cortex-web /api/v1/govern/skills/manifest each tick to
|
|
14
|
+
* learn what skills the org has authored. It diffs against a local state
|
|
15
|
+
* file, then for each new/changed skill it fetches the assembled SKILL.md
|
|
16
|
+
* and writes it to the appropriate per-CLI skills directory. Removed
|
|
17
|
+
* skills are unlinked. Unlike govern-config sync, this does NOT need
|
|
18
|
+
* root: SKILL.md files live in user-owned directories the daemon can
|
|
19
|
+
* write to directly.
|
|
20
|
+
*
|
|
21
|
+
* Three audit outcomes per tick:
|
|
22
|
+
* - skills_unchanged — manifest matches local state
|
|
23
|
+
* - skills_synced — at least one skill was written or removed
|
|
24
|
+
* (metadata: added/changed/removed counts)
|
|
25
|
+
* - skills_sync_failed — network / auth / disk error
|
|
26
|
+
*
|
|
27
|
+
* When something changes, a notification file is written so
|
|
28
|
+
* 'cortex enterprise status' can prompt the user to restart Claude
|
|
29
|
+
* Code / Codex CLI to pick up the new skills.
|
|
30
|
+
*/
|
|
31
|
+
const STATE_FILENAME = "skills.local.json";
|
|
32
|
+
const NOTIFICATION_FILENAME = ".skills-update-applied.json";
|
|
33
|
+
const OWNERSHIP_FILENAME = ".cortex-managed.json";
|
|
34
|
+
const OWNERSHIP_VERSION = 1;
|
|
35
|
+
const SUPPORTED_CLIS = ["claude", "codex"];
|
|
36
|
+
const SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/;
|
|
37
|
+
function stateFilePath() {
|
|
38
|
+
return join(daemonDir(), STATE_FILENAME);
|
|
39
|
+
}
|
|
40
|
+
function notificationFilePath() {
|
|
41
|
+
return join(daemonDir(), NOTIFICATION_FILENAME);
|
|
42
|
+
}
|
|
43
|
+
function readState() {
|
|
44
|
+
const path = stateFilePath();
|
|
45
|
+
if (!existsSync(path))
|
|
46
|
+
return { skills: {} };
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
49
|
+
const normalizedSkills = {};
|
|
50
|
+
for (const [key, record] of Object.entries(parsed.skills ?? {})) {
|
|
51
|
+
if (!record || typeof record !== "object")
|
|
52
|
+
continue;
|
|
53
|
+
const inferredCli = record.path?.includes("/.codex/skills/")
|
|
54
|
+
? "codex"
|
|
55
|
+
: "claude";
|
|
56
|
+
const cli = record.cli === "codex" || record.cli === "claude"
|
|
57
|
+
? record.cli
|
|
58
|
+
: inferredCli;
|
|
59
|
+
const normalizedKey = key.includes(":") ? key : `${cli}:${key}`;
|
|
60
|
+
normalizedSkills[normalizedKey] = {
|
|
61
|
+
credential_id: typeof record.credential_id === "string"
|
|
62
|
+
? record.credential_id
|
|
63
|
+
: undefined,
|
|
64
|
+
cli,
|
|
65
|
+
scope: String(record.scope ?? "global"),
|
|
66
|
+
updated_at: String(record.updated_at ?? ""),
|
|
67
|
+
path: String(record.path ?? ""),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
credential_id: typeof parsed.credential_id === "string"
|
|
72
|
+
? parsed.credential_id
|
|
73
|
+
: undefined,
|
|
74
|
+
skills: normalizedSkills,
|
|
75
|
+
last_synced_at: parsed.last_synced_at,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { skills: {} };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function writeState(state) {
|
|
83
|
+
writeFileSync(stateFilePath(), JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the on-disk SKILL.md path for a skill install target. Global
|
|
87
|
+
* skills are installed once per CLI, so the destination root depends on the
|
|
88
|
+
* active sync target rather than just the stored scope.
|
|
89
|
+
*/
|
|
90
|
+
function isSafeSkillName(name) {
|
|
91
|
+
return typeof name === "string" && SKILL_NAME_RE.test(name);
|
|
92
|
+
}
|
|
93
|
+
function assertPathContained(root, target) {
|
|
94
|
+
const rel = relative(root, target);
|
|
95
|
+
if (rel === ".." ||
|
|
96
|
+
rel.startsWith(`..${sep}`) ||
|
|
97
|
+
isAbsolute(rel)) {
|
|
98
|
+
throw new Error("skill target escapes managed root");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function configuredSkillRoot(cli) {
|
|
102
|
+
return join(realpathSync(homedir()), cli === "codex" ? ".codex" : ".claude", "skills");
|
|
103
|
+
}
|
|
104
|
+
function assertNotSymlink(path, description) {
|
|
105
|
+
if (existsSync(path) && lstatSync(path).isSymbolicLink()) {
|
|
106
|
+
throw new Error(`${description} must not be a symbolic link`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function resolveSkillRoot(cli, create) {
|
|
110
|
+
const root = configuredSkillRoot(cli);
|
|
111
|
+
const cliRoot = resolve(root, "..");
|
|
112
|
+
assertNotSymlink(cliRoot, `${cli} configuration directory`);
|
|
113
|
+
if (!existsSync(cliRoot)) {
|
|
114
|
+
if (!create)
|
|
115
|
+
return null;
|
|
116
|
+
mkdirSync(cliRoot, { mode: 0o700 });
|
|
117
|
+
}
|
|
118
|
+
if (!lstatSync(cliRoot).isDirectory()) {
|
|
119
|
+
throw new Error(`${cli} configuration path is not a directory`);
|
|
120
|
+
}
|
|
121
|
+
assertNotSymlink(root, `${cli} skills root`);
|
|
122
|
+
if (!existsSync(root)) {
|
|
123
|
+
if (!create)
|
|
124
|
+
return null;
|
|
125
|
+
mkdirSync(root, { mode: 0o700 });
|
|
126
|
+
}
|
|
127
|
+
if (!lstatSync(root).isDirectory()) {
|
|
128
|
+
throw new Error(`${cli} skills root is not a directory`);
|
|
129
|
+
}
|
|
130
|
+
return realpathSync(root);
|
|
131
|
+
}
|
|
132
|
+
function skillFilePath(root, name) {
|
|
133
|
+
if (!isSafeSkillName(name)) {
|
|
134
|
+
throw new Error("invalid skill name");
|
|
135
|
+
}
|
|
136
|
+
const target = resolve(root, name, "SKILL.md");
|
|
137
|
+
assertPathContained(resolve(root), target);
|
|
138
|
+
return target;
|
|
139
|
+
}
|
|
140
|
+
function stateSkillKey(cli, name) {
|
|
141
|
+
return `${cli}:${name}`;
|
|
142
|
+
}
|
|
143
|
+
function shouldSyncForCli(scope, cli) {
|
|
144
|
+
if (scope === "global")
|
|
145
|
+
return true;
|
|
146
|
+
return scope === `cli:${cli}`;
|
|
147
|
+
}
|
|
148
|
+
async function fetchManifest(baseUrl, apiKey, cli) {
|
|
149
|
+
const url = new URL(baseUrl.replace(/\/$/, "") + "/api/v1/govern/skills/manifest");
|
|
150
|
+
url.searchParams.set("cli", cli);
|
|
151
|
+
const res = await fetch(url, {
|
|
152
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
153
|
+
});
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
156
|
+
}
|
|
157
|
+
const body = (await res.json());
|
|
158
|
+
if (body.skills === undefined)
|
|
159
|
+
return [];
|
|
160
|
+
if (!Array.isArray(body.skills)) {
|
|
161
|
+
throw new Error("invalid skill manifest: skills must be an array");
|
|
162
|
+
}
|
|
163
|
+
return body.skills;
|
|
164
|
+
}
|
|
165
|
+
async function fetchSkillBody(baseUrl, apiKey, name) {
|
|
166
|
+
const url = new URL(baseUrl.replace(/\/$/, "") +
|
|
167
|
+
"/api/v1/govern/skills/" +
|
|
168
|
+
encodeURIComponent(name));
|
|
169
|
+
const res = await fetch(url, {
|
|
170
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
174
|
+
}
|
|
175
|
+
return res.text();
|
|
176
|
+
}
|
|
177
|
+
function validateManifest(manifest, cli) {
|
|
178
|
+
const validated = [];
|
|
179
|
+
const names = new Set();
|
|
180
|
+
for (let index = 0; index < manifest.length; index += 1) {
|
|
181
|
+
const raw = manifest[index];
|
|
182
|
+
if (!raw || typeof raw !== "object") {
|
|
183
|
+
throw new Error(`invalid skill manifest entry at index ${index}`);
|
|
184
|
+
}
|
|
185
|
+
const entry = raw;
|
|
186
|
+
if (!isSafeSkillName(entry.name)) {
|
|
187
|
+
throw new Error(`invalid skill name at manifest index ${index}`);
|
|
188
|
+
}
|
|
189
|
+
if (entry.scope !== "global" &&
|
|
190
|
+
entry.scope !== "cli:claude" &&
|
|
191
|
+
entry.scope !== "cli:codex") {
|
|
192
|
+
throw new Error(`invalid skill scope at manifest index ${index}`);
|
|
193
|
+
}
|
|
194
|
+
if (typeof entry.updated_at !== "string" || !entry.updated_at.trim()) {
|
|
195
|
+
throw new Error(`invalid skill timestamp at manifest index ${index}`);
|
|
196
|
+
}
|
|
197
|
+
if (shouldSyncForCli(entry.scope, cli)) {
|
|
198
|
+
if (names.has(entry.name)) {
|
|
199
|
+
throw new Error(`duplicate skill name at manifest index ${index}`);
|
|
200
|
+
}
|
|
201
|
+
names.add(entry.name);
|
|
202
|
+
}
|
|
203
|
+
validated.push({
|
|
204
|
+
name: entry.name,
|
|
205
|
+
scope: entry.scope,
|
|
206
|
+
updated_at: entry.updated_at,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return validated;
|
|
210
|
+
}
|
|
211
|
+
function ownershipMarker(cli, name) {
|
|
212
|
+
return {
|
|
213
|
+
version: OWNERSHIP_VERSION,
|
|
214
|
+
manager: "cortex",
|
|
215
|
+
cli,
|
|
216
|
+
name,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function assertCortexOwnedSkillDir(skillDir, cli, name) {
|
|
220
|
+
const markerPath = join(skillDir, OWNERSHIP_FILENAME);
|
|
221
|
+
if (!existsSync(markerPath) || lstatSync(markerPath).isSymbolicLink()) {
|
|
222
|
+
throw new Error("skill directory is not owned by Cortex");
|
|
223
|
+
}
|
|
224
|
+
let parsed;
|
|
225
|
+
try {
|
|
226
|
+
parsed = JSON.parse(readFileSync(markerPath, "utf8"));
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
throw new Error("skill directory has an invalid Cortex ownership marker");
|
|
230
|
+
}
|
|
231
|
+
if (parsed.version !== OWNERSHIP_VERSION ||
|
|
232
|
+
parsed.manager !== "cortex" ||
|
|
233
|
+
parsed.cli !== cli ||
|
|
234
|
+
parsed.name !== name) {
|
|
235
|
+
throw new Error("skill directory has an invalid Cortex ownership marker");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function preflightSkillWrite(cli, name) {
|
|
239
|
+
const root = resolveSkillRoot(cli, true);
|
|
240
|
+
if (!root)
|
|
241
|
+
throw new Error("skill root could not be created");
|
|
242
|
+
const target = skillFilePath(root, name);
|
|
243
|
+
const skillDir = resolve(root, name);
|
|
244
|
+
assertPathContained(root, skillDir);
|
|
245
|
+
if (!existsSync(skillDir))
|
|
246
|
+
return { root, skillDir, target };
|
|
247
|
+
const stat = lstatSync(skillDir);
|
|
248
|
+
if (stat.isSymbolicLink()) {
|
|
249
|
+
throw new Error("skill directory must not be a symbolic link");
|
|
250
|
+
}
|
|
251
|
+
if (!stat.isDirectory()) {
|
|
252
|
+
throw new Error("managed skill target is not a directory");
|
|
253
|
+
}
|
|
254
|
+
assertCortexOwnedSkillDir(skillDir, cli, name);
|
|
255
|
+
const existingSkillFile = join(skillDir, "SKILL.md");
|
|
256
|
+
if (existsSync(existingSkillFile) &&
|
|
257
|
+
lstatSync(existingSkillFile).isSymbolicLink()) {
|
|
258
|
+
throw new Error("skill file must not be a symbolic link");
|
|
259
|
+
}
|
|
260
|
+
return { root, skillDir, target };
|
|
261
|
+
}
|
|
262
|
+
function writeSkillFile(cli, name, content) {
|
|
263
|
+
const { root, skillDir, target: lexicalTarget, } = preflightSkillWrite(cli, name);
|
|
264
|
+
let created = false;
|
|
265
|
+
if (existsSync(skillDir)) {
|
|
266
|
+
const stat = lstatSync(skillDir);
|
|
267
|
+
if (stat.isSymbolicLink()) {
|
|
268
|
+
throw new Error("skill directory must not be a symbolic link");
|
|
269
|
+
}
|
|
270
|
+
if (!stat.isDirectory()) {
|
|
271
|
+
throw new Error("managed skill target is not a directory");
|
|
272
|
+
}
|
|
273
|
+
assertCortexOwnedSkillDir(skillDir, cli, name);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
mkdirSync(skillDir, { mode: 0o700 });
|
|
277
|
+
created = true;
|
|
278
|
+
writeFileSync(join(skillDir, OWNERSHIP_FILENAME), JSON.stringify(ownershipMarker(cli, name), null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
279
|
+
}
|
|
280
|
+
const realSkillDir = realpathSync(skillDir);
|
|
281
|
+
assertPathContained(root, realSkillDir);
|
|
282
|
+
const target = join(realSkillDir, "SKILL.md");
|
|
283
|
+
assertPathContained(root, target);
|
|
284
|
+
if (existsSync(target) && lstatSync(target).isSymbolicLink()) {
|
|
285
|
+
throw new Error("skill file must not be a symbolic link");
|
|
286
|
+
}
|
|
287
|
+
const temporaryTarget = join(realSkillDir, `.SKILL.md.${process.pid}.${randomUUID()}.tmp`);
|
|
288
|
+
try {
|
|
289
|
+
writeFileSync(temporaryTarget, content, {
|
|
290
|
+
encoding: "utf8",
|
|
291
|
+
mode: 0o600,
|
|
292
|
+
flag: "wx",
|
|
293
|
+
});
|
|
294
|
+
renameSync(temporaryTarget, target);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
if (existsSync(temporaryTarget))
|
|
298
|
+
unlinkSync(temporaryTarget);
|
|
299
|
+
if (created) {
|
|
300
|
+
const entries = readdirSync(skillDir);
|
|
301
|
+
if (entries.length === 1 &&
|
|
302
|
+
entries[0] === OWNERSHIP_FILENAME) {
|
|
303
|
+
unlinkSync(join(skillDir, OWNERSHIP_FILENAME));
|
|
304
|
+
rmdirSync(skillDir);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
return lexicalTarget;
|
|
310
|
+
}
|
|
311
|
+
function removeSkillFile(cli, name) {
|
|
312
|
+
if (!isSafeSkillName(name)) {
|
|
313
|
+
throw new Error("invalid skill name");
|
|
314
|
+
}
|
|
315
|
+
const root = resolveSkillRoot(cli, false);
|
|
316
|
+
if (!root)
|
|
317
|
+
return;
|
|
318
|
+
const skillDir = resolve(root, name);
|
|
319
|
+
assertPathContained(root, skillDir);
|
|
320
|
+
if (!existsSync(skillDir))
|
|
321
|
+
return;
|
|
322
|
+
const stat = lstatSync(skillDir);
|
|
323
|
+
if (stat.isSymbolicLink()) {
|
|
324
|
+
throw new Error("skill directory must not be a symbolic link");
|
|
325
|
+
}
|
|
326
|
+
if (!stat.isDirectory()) {
|
|
327
|
+
throw new Error("managed skill target is not a directory");
|
|
328
|
+
}
|
|
329
|
+
assertPathContained(root, realpathSync(skillDir));
|
|
330
|
+
assertCortexOwnedSkillDir(skillDir, cli, name);
|
|
331
|
+
const entries = readdirSync(skillDir);
|
|
332
|
+
const unexpected = entries.filter((entry) => entry !== OWNERSHIP_FILENAME && entry !== "SKILL.md");
|
|
333
|
+
if (unexpected.length > 0) {
|
|
334
|
+
throw new Error("managed skill directory contains unowned files");
|
|
335
|
+
}
|
|
336
|
+
const skillPath = join(skillDir, "SKILL.md");
|
|
337
|
+
if (existsSync(skillPath)) {
|
|
338
|
+
if (lstatSync(skillPath).isSymbolicLink()) {
|
|
339
|
+
throw new Error("skill file must not be a symbolic link");
|
|
340
|
+
}
|
|
341
|
+
unlinkSync(skillPath);
|
|
342
|
+
}
|
|
343
|
+
unlinkSync(join(skillDir, OWNERSHIP_FILENAME));
|
|
344
|
+
rmdirSync(skillDir);
|
|
345
|
+
}
|
|
346
|
+
function writeNotification(data) {
|
|
347
|
+
writeFileSync(notificationFilePath(), JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
348
|
+
}
|
|
349
|
+
export async function runSkillSyncForCli(cwd, cli) {
|
|
350
|
+
const config = loadEnterpriseConfig(join(cwd, ".context"));
|
|
351
|
+
const apiKey = config.enterprise.api_key.trim();
|
|
352
|
+
const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
|
|
353
|
+
if (!apiKey || !baseUrl) {
|
|
354
|
+
return { kind: "failed", cli, error: "enterprise not configured" };
|
|
355
|
+
}
|
|
356
|
+
if (!isAllowedLicenseEndpoint(baseUrl)) {
|
|
357
|
+
return { kind: "failed", cli, error: "insecure or invalid enterprise endpoint" };
|
|
358
|
+
}
|
|
359
|
+
const credentialId = enterpriseCredentialId(baseUrl, apiKey);
|
|
360
|
+
if (!matchesEnterpriseHostIdentity(credentialId)) {
|
|
361
|
+
return {
|
|
362
|
+
kind: "failed",
|
|
363
|
+
cli,
|
|
364
|
+
error: "enterprise identity conflict: this user profile is already enrolled to another endpoint or API key",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const state = readState();
|
|
368
|
+
const identityChanged = state.credential_id !== credentialId;
|
|
369
|
+
let stateSanitized = identityChanged;
|
|
370
|
+
state.credential_id = credentialId;
|
|
371
|
+
let manifest;
|
|
372
|
+
try {
|
|
373
|
+
manifest = validateManifest(await fetchManifest(baseUrl, apiKey, cli), cli);
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
return {
|
|
377
|
+
kind: "failed",
|
|
378
|
+
cli,
|
|
379
|
+
error: err instanceof Error ? err.message : String(err),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const relevantManifest = manifest.filter((entry) => shouldSyncForCli(entry.scope, cli));
|
|
383
|
+
const remoteByName = new Map(relevantManifest.map((e) => [e.name, e]));
|
|
384
|
+
const added = [];
|
|
385
|
+
const changed = [];
|
|
386
|
+
const removed = [];
|
|
387
|
+
// Detect adds + changes
|
|
388
|
+
for (const entry of relevantManifest) {
|
|
389
|
+
const skillKey = stateSkillKey(cli, entry.name);
|
|
390
|
+
const local = state.skills[skillKey];
|
|
391
|
+
const isNew = !local;
|
|
392
|
+
const isChanged = Boolean(local) &&
|
|
393
|
+
(identityChanged ||
|
|
394
|
+
local.credential_id !== credentialId ||
|
|
395
|
+
local.updated_at !== entry.updated_at ||
|
|
396
|
+
local.scope !== entry.scope);
|
|
397
|
+
if (!isNew && !isChanged)
|
|
398
|
+
continue;
|
|
399
|
+
try {
|
|
400
|
+
preflightSkillWrite(cli, entry.name);
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
return {
|
|
404
|
+
kind: "failed",
|
|
405
|
+
cli,
|
|
406
|
+
error: err instanceof Error
|
|
407
|
+
? `preflight ${entry.name}: ${err.message}`
|
|
408
|
+
: `preflight ${entry.name}: ${String(err)}`,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
let body;
|
|
412
|
+
try {
|
|
413
|
+
body = await fetchSkillBody(baseUrl, apiKey, entry.name);
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
return {
|
|
417
|
+
kind: "failed",
|
|
418
|
+
cli,
|
|
419
|
+
error: err instanceof Error
|
|
420
|
+
? `fetch ${entry.name}: ${err.message}`
|
|
421
|
+
: `fetch ${entry.name}: ${String(err)}`,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
let path;
|
|
425
|
+
try {
|
|
426
|
+
path = writeSkillFile(cli, entry.name, body);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
return {
|
|
430
|
+
kind: "failed",
|
|
431
|
+
cli,
|
|
432
|
+
error: err instanceof Error
|
|
433
|
+
? `write ${entry.name}: ${err.message}`
|
|
434
|
+
: `write ${entry.name}: ${String(err)}`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
state.skills[skillKey] = {
|
|
438
|
+
credential_id: credentialId,
|
|
439
|
+
cli,
|
|
440
|
+
scope: entry.scope,
|
|
441
|
+
updated_at: entry.updated_at,
|
|
442
|
+
path,
|
|
443
|
+
};
|
|
444
|
+
(isNew ? added : changed).push(entry.name);
|
|
445
|
+
}
|
|
446
|
+
// Detect removes — entries we have locally for this cli but the manifest
|
|
447
|
+
// dropped (or disabled). We only consider state entries whose scope
|
|
448
|
+
// matches this cli, so we don't accidentally remove the other CLI's
|
|
449
|
+
// skills when running a per-cli tick.
|
|
450
|
+
for (const [skillKey, record] of Object.entries(state.skills)) {
|
|
451
|
+
if (record.cli !== cli)
|
|
452
|
+
continue;
|
|
453
|
+
const [, name] = skillKey.split(":", 2);
|
|
454
|
+
if (!name)
|
|
455
|
+
continue;
|
|
456
|
+
if (!isSafeSkillName(name)) {
|
|
457
|
+
// State from an older vulnerable version is not deletion authority.
|
|
458
|
+
// Drop the record without touching its persisted absolute path.
|
|
459
|
+
delete state.skills[skillKey];
|
|
460
|
+
stateSanitized = true;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (!shouldSyncForCli(record.scope, cli))
|
|
464
|
+
continue;
|
|
465
|
+
if (remoteByName.has(name))
|
|
466
|
+
continue;
|
|
467
|
+
try {
|
|
468
|
+
removeSkillFile(cli, name);
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
return {
|
|
472
|
+
kind: "failed",
|
|
473
|
+
cli,
|
|
474
|
+
error: err instanceof Error
|
|
475
|
+
? `remove ${name}: ${err.message}`
|
|
476
|
+
: `remove ${name}: ${String(err)}`,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
delete state.skills[skillKey];
|
|
480
|
+
removed.push(name);
|
|
481
|
+
}
|
|
482
|
+
const totalChanged = added.length + changed.length + removed.length;
|
|
483
|
+
if (totalChanged === 0) {
|
|
484
|
+
if (stateSanitized) {
|
|
485
|
+
state.last_synced_at = new Date().toISOString();
|
|
486
|
+
writeState(state);
|
|
487
|
+
}
|
|
488
|
+
return { kind: "unchanged", cli, count: relevantManifest.length };
|
|
489
|
+
}
|
|
490
|
+
state.last_synced_at = new Date().toISOString();
|
|
491
|
+
writeState(state);
|
|
492
|
+
return { kind: "synced", cli, added, changed, removed };
|
|
493
|
+
}
|
|
494
|
+
export async function runSkillSyncOnce(cwd, clis = SUPPORTED_CLIS) {
|
|
495
|
+
const outcomes = [];
|
|
496
|
+
const now = new Date().toISOString();
|
|
497
|
+
for (const cli of clis) {
|
|
498
|
+
const outcome = await runSkillSyncForCli(cwd, cli);
|
|
499
|
+
outcomes.push(outcome);
|
|
500
|
+
const eventBase = {
|
|
501
|
+
timestamp: now,
|
|
502
|
+
host_id: hostname(),
|
|
503
|
+
cli,
|
|
504
|
+
};
|
|
505
|
+
if (outcome.kind === "unchanged") {
|
|
506
|
+
await writeHostAuditEvent(cwd, {
|
|
507
|
+
...eventBase,
|
|
508
|
+
event_type: "skills_unchanged",
|
|
509
|
+
count: outcome.count,
|
|
510
|
+
}).catch(() => undefined);
|
|
511
|
+
}
|
|
512
|
+
else if (outcome.kind === "synced") {
|
|
513
|
+
await writeHostAuditEvent(cwd, {
|
|
514
|
+
...eventBase,
|
|
515
|
+
event_type: "skills_synced",
|
|
516
|
+
added: outcome.added,
|
|
517
|
+
changed: outcome.changed,
|
|
518
|
+
removed: outcome.removed,
|
|
519
|
+
}).catch(() => undefined);
|
|
520
|
+
writeNotification({
|
|
521
|
+
added: outcome.added.length,
|
|
522
|
+
changed: outcome.changed.length,
|
|
523
|
+
removed: outcome.removed.length,
|
|
524
|
+
cli,
|
|
525
|
+
detected_at: now,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
await writeHostAuditEvent(cwd, {
|
|
530
|
+
...eventBase,
|
|
531
|
+
event_type: "skills_sync_failed",
|
|
532
|
+
error: outcome.error,
|
|
533
|
+
}).catch(() => undefined);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// We deliberately leave the notification file in place when this tick
|
|
537
|
+
// had no changes — it represents "restart pending" from a prior sync,
|
|
538
|
+
// not current drift. `cortex enterprise status --acknowledge-skills`
|
|
539
|
+
// (future CLI) will be the explicit clear path.
|
|
540
|
+
return outcomes;
|
|
541
|
+
}
|
|
542
|
+
export function startSkillSyncTimer(cwd, intervalMs) {
|
|
543
|
+
const tick = () => {
|
|
544
|
+
void runSkillSyncOnce(cwd).catch((err) => {
|
|
545
|
+
process.stderr.write(`[cortex-daemon] skill sync failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
546
|
+
});
|
|
547
|
+
};
|
|
548
|
+
void Promise.resolve().then(tick);
|
|
549
|
+
const handle = setInterval(tick, intervalMs);
|
|
550
|
+
if (typeof handle.unref === "function")
|
|
551
|
+
handle.unref();
|
|
552
|
+
return {
|
|
553
|
+
stop() {
|
|
554
|
+
clearInterval(handle);
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
}
|