@danielblomma/cortex-mcp 1.7.1 → 2.0.2

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 (79) hide show
  1. package/bin/cortex.mjs +679 -32
  2. package/bin/style.mjs +349 -0
  3. package/package.json +4 -3
  4. package/scaffold/mcp/package-lock.json +834 -671
  5. package/scaffold/mcp/package.json +1 -1
  6. package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
  7. package/scaffold/mcp/src/cli/govern.ts +987 -0
  8. package/scaffold/mcp/src/cli/run.ts +306 -0
  9. package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
  10. package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
  11. package/scaffold/mcp/src/core/audit/query.ts +81 -0
  12. package/scaffold/mcp/src/core/audit/writer.ts +68 -0
  13. package/scaffold/mcp/src/core/config.ts +329 -0
  14. package/scaffold/mcp/src/core/index.ts +34 -0
  15. package/scaffold/mcp/src/core/license.ts +202 -0
  16. package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
  17. package/scaffold/mcp/src/core/policy/injection.ts +229 -0
  18. package/scaffold/mcp/src/core/policy/store.ts +197 -0
  19. package/scaffold/mcp/src/core/rbac/check.ts +40 -0
  20. package/scaffold/mcp/src/core/telemetry/collector.ts +234 -0
  21. package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
  22. package/scaffold/mcp/src/core/validators/config.ts +47 -0
  23. package/scaffold/mcp/src/core/validators/engine.ts +199 -0
  24. package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
  25. package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
  26. package/scaffold/mcp/src/daemon/client.ts +155 -0
  27. package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
  28. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
  29. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
  30. package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
  31. package/scaffold/mcp/src/daemon/main.ts +300 -0
  32. package/scaffold/mcp/src/daemon/paths.ts +41 -0
  33. package/scaffold/mcp/src/daemon/protocol.ts +101 -0
  34. package/scaffold/mcp/src/daemon/server.ts +227 -0
  35. package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
  36. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
  37. package/scaffold/mcp/src/embed.ts +1 -1
  38. package/scaffold/mcp/src/embeddings.ts +1 -1
  39. package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
  40. package/scaffold/mcp/src/enterprise/index.ts +415 -0
  41. package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
  42. package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
  43. package/scaffold/mcp/src/enterprise/privacy/boundary.ts +212 -0
  44. package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
  45. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +72 -0
  46. package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
  47. package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
  48. package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
  49. package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
  50. package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
  51. package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
  52. package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
  53. package/scaffold/mcp/src/hooks/session-end.ts +73 -0
  54. package/scaffold/mcp/src/hooks/session-start.ts +78 -0
  55. package/scaffold/mcp/src/hooks/shared.ts +134 -0
  56. package/scaffold/mcp/src/hooks/stop.ts +60 -0
  57. package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
  58. package/scaffold/mcp/src/plugin.ts +150 -0
  59. package/scaffold/mcp/src/server.ts +218 -7
  60. package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
  61. package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
  62. package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
  63. package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
  64. package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
  65. package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
  66. package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
  67. package/scaffold/mcp/tests/govern.test.mjs +74 -0
  68. package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
  69. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
  70. package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
  71. package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
  72. package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
  73. package/scaffold/mcp/tests/run.test.mjs +109 -0
  74. package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
  75. package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
  76. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
  77. package/scaffold/scripts/bootstrap.sh +0 -11
  78. package/scaffold/scripts/doctor.sh +24 -4
  79. package/types.js +5 -0
@@ -0,0 +1,213 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { join } from "node:path";
4
+ import { loadEnterpriseConfig } from "../core/config.js";
5
+ import { writeHostAuditEvent } from "./ungoverned-scanner.js";
6
+
7
+ /**
8
+ * Phase 7 sync flow — daemon side.
9
+ *
10
+ * The daemon periodically pings cortex-web /api/v1/govern/config to learn
11
+ * whether a new config version is available. It does NOT re-apply (that
12
+ * would require root, which the daemon explicitly doesn't have post-Fas-3
13
+ * privilege drop). Instead it emits an audit event and writes a
14
+ * notification file that 'cortex enterprise status' surfaces. The
15
+ * operator must then run 'sudo cortex enterprise sync' to actually
16
+ * re-fetch + write managed-settings.
17
+ *
18
+ * Three audit outcomes per tick:
19
+ * - govern_config_unchanged (304 / same version) — heartbeat that
20
+ * cortex-web is reachable and we're current.
21
+ * - govern_config_available (200 with new version) — operator action needed.
22
+ * - govern_config_sync_failed (network / auth error) — also written so
23
+ * admin sees blackouts in audit timeline.
24
+ */
25
+
26
+ const NOTIFICATION_FILENAME = ".govern-update-available.json";
27
+
28
+ export type SyncCheckOptions = {
29
+ cwd: string;
30
+ cli: "claude" | "codex" | "copilot";
31
+ now?: () => Date;
32
+ };
33
+
34
+ export type SyncCheckOutcome =
35
+ | { kind: "unchanged"; version: string }
36
+ | { kind: "available"; latest_version: string; current_version: string | null }
37
+ | { kind: "failed"; error: string };
38
+
39
+ type LocalGovernState = {
40
+ installs?: Record<
41
+ string,
42
+ {
43
+ version?: string;
44
+ mode?: string;
45
+ frameworks?: Array<{ id: string; version: string }>;
46
+ }
47
+ >;
48
+ };
49
+
50
+ function readLocalGovernState(cwd: string): LocalGovernState {
51
+ const path = join(cwd, ".context", "govern.local.json");
52
+ if (!existsSync(path)) return {};
53
+ try {
54
+ return JSON.parse(readFileSync(path, "utf8")) as LocalGovernState;
55
+ } catch {
56
+ return {};
57
+ }
58
+ }
59
+
60
+ function activeFrameworks(cwd: string): string[] {
61
+ const state = readLocalGovernState(cwd);
62
+ for (const inst of Object.values(state.installs ?? {})) {
63
+ if (inst.frameworks?.length) {
64
+ return inst.frameworks.map((f) => f.id);
65
+ }
66
+ }
67
+ // Fall back to enterprise.yml's compliance.frameworks
68
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
69
+ return config.compliance.frameworks;
70
+ }
71
+
72
+ function currentVersion(cwd: string, cli: string): string | null {
73
+ const state = readLocalGovernState(cwd);
74
+ return state.installs?.[cli]?.version ?? null;
75
+ }
76
+
77
+ export async function checkSyncForCli(
78
+ options: SyncCheckOptions,
79
+ ): Promise<SyncCheckOutcome> {
80
+ const cwd = options.cwd;
81
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
82
+ const apiKey = config.enterprise.api_key.trim();
83
+ const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
84
+ if (!apiKey || !baseUrl) {
85
+ return { kind: "failed", error: "enterprise not configured" };
86
+ }
87
+ const frameworks = activeFrameworks(cwd);
88
+ if (frameworks.length === 0) {
89
+ return { kind: "failed", error: "no active frameworks" };
90
+ }
91
+
92
+ const url = new URL(baseUrl.replace(/\/$/, "") + "/api/v1/govern/config");
93
+ url.searchParams.set("cli", options.cli);
94
+ url.searchParams.set("frameworks", frameworks.join(","));
95
+
96
+ const installedVersion = currentVersion(cwd, options.cli);
97
+ const headers: Record<string, string> = {
98
+ Authorization: `Bearer ${apiKey}`,
99
+ };
100
+ if (installedVersion) headers["If-None-Match"] = `"${installedVersion}"`;
101
+
102
+ let res: Response;
103
+ try {
104
+ res = await fetch(url, { headers });
105
+ } catch (err) {
106
+ return { kind: "failed", error: err instanceof Error ? err.message : String(err) };
107
+ }
108
+
109
+ if (res.status === 304) {
110
+ return { kind: "unchanged", version: installedVersion ?? "unknown" };
111
+ }
112
+ if (!res.ok) {
113
+ return { kind: "failed", error: `HTTP ${res.status} ${res.statusText}` };
114
+ }
115
+
116
+ const etag = (res.headers.get("etag") ?? "").replace(/"/g, "");
117
+ if (etag && etag === installedVersion) {
118
+ return { kind: "unchanged", version: etag };
119
+ }
120
+ return {
121
+ kind: "available",
122
+ latest_version: etag || "unknown",
123
+ current_version: installedVersion,
124
+ };
125
+ }
126
+
127
+ function writeUpdateNotification(
128
+ cwd: string,
129
+ data: { latest_version: string; current_version: string | null; cli: string; detected_at: string },
130
+ ): void {
131
+ const dir = join(cwd, ".context");
132
+ mkdirSync(dir, { recursive: true });
133
+ writeFileSync(
134
+ join(dir, NOTIFICATION_FILENAME),
135
+ JSON.stringify(data, null, 2) + "\n",
136
+ "utf8",
137
+ );
138
+ }
139
+
140
+ export async function runSyncCheckOnce(
141
+ cwd: string,
142
+ clis: Array<"claude" | "codex" | "copilot">,
143
+ ): Promise<SyncCheckOutcome[]> {
144
+ const outcomes: SyncCheckOutcome[] = [];
145
+ const now = new Date().toISOString();
146
+ for (const cli of clis) {
147
+ const outcome = await checkSyncForCli({ cwd, cli });
148
+ outcomes.push(outcome);
149
+ const eventBase = {
150
+ timestamp: now,
151
+ host_id: hostname(),
152
+ cli,
153
+ };
154
+ if (outcome.kind === "unchanged") {
155
+ await writeHostAuditEvent(cwd, {
156
+ ...eventBase,
157
+ event_type: "govern_config_unchanged",
158
+ version: outcome.version,
159
+ }).catch(() => undefined);
160
+ } else if (outcome.kind === "available") {
161
+ await writeHostAuditEvent(cwd, {
162
+ ...eventBase,
163
+ event_type: "govern_config_available",
164
+ latest_version: outcome.latest_version,
165
+ current_version: outcome.current_version,
166
+ }).catch(() => undefined);
167
+ writeUpdateNotification(cwd, {
168
+ latest_version: outcome.latest_version,
169
+ current_version: outcome.current_version,
170
+ cli,
171
+ detected_at: now,
172
+ });
173
+ } else {
174
+ await writeHostAuditEvent(cwd, {
175
+ ...eventBase,
176
+ event_type: "govern_config_sync_failed",
177
+ error: outcome.error,
178
+ }).catch(() => undefined);
179
+ }
180
+ }
181
+ return outcomes;
182
+ }
183
+
184
+ export type SyncTimerHandle = {
185
+ stop(): void;
186
+ };
187
+
188
+ export function startSyncTimer(
189
+ cwd: string,
190
+ intervalMs: number,
191
+ ): SyncTimerHandle {
192
+ const tick = () => {
193
+ const state = readLocalGovernState(cwd);
194
+ const clis = Object.keys(state.installs ?? {}) as Array<
195
+ "claude" | "codex" | "copilot"
196
+ >;
197
+ if (clis.length === 0) return;
198
+ void runSyncCheckOnce(cwd, clis).catch((err) => {
199
+ process.stderr.write(
200
+ `[cortex-daemon] sync check failed: ${err instanceof Error ? err.message : String(err)}\n`,
201
+ );
202
+ });
203
+ };
204
+
205
+ void Promise.resolve().then(tick);
206
+ const handle = setInterval(tick, intervalMs);
207
+ if (typeof handle.unref === "function") handle.unref();
208
+ return {
209
+ stop() {
210
+ clearInterval(handle);
211
+ },
212
+ };
213
+ }
@@ -0,0 +1,149 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { userInfo } from "node:os";
5
+ import {
6
+ detectUngoverned,
7
+ enforceFinding,
8
+ type DetectorOptions,
9
+ type EnforcementMode,
10
+ type UngovernedFinding,
11
+ } from "../cli/ungoverned-detector.js";
12
+
13
+ export type ScannerOptions = {
14
+ cwd: string;
15
+ intervalMs?: number;
16
+ mode?: EnforcementMode;
17
+ detectorOptions?: DetectorOptions;
18
+ onFinding?: (finding: UngovernedFinding & { action: string }) => void;
19
+ };
20
+
21
+ const DEFAULT_INTERVAL_MS = 60_000;
22
+ const TIER1_CLIS = new Set(["claude", "codex"]);
23
+
24
+ function readMode(cwd: string): EnforcementMode {
25
+ const stateFile = join(cwd, ".context", "govern.local.json");
26
+ if (!existsSync(stateFile)) return "advisory";
27
+ try {
28
+ const raw = readFileSync(stateFile, "utf8");
29
+ const parsed = JSON.parse(raw) as {
30
+ installs?: Record<string, { mode?: EnforcementMode }>;
31
+ };
32
+ for (const inst of Object.values(parsed.installs ?? {})) {
33
+ if (inst.mode === "enforced") return "enforced";
34
+ }
35
+ return "advisory";
36
+ } catch {
37
+ return "advisory";
38
+ }
39
+ }
40
+
41
+ function readManagedTier1Clis(cwd: string): Set<string> {
42
+ const stateFile = join(cwd, ".context", "govern.local.json");
43
+ const managed = new Set<string>();
44
+ if (!existsSync(stateFile)) return managed;
45
+ try {
46
+ const raw = readFileSync(stateFile, "utf8");
47
+ const parsed = JSON.parse(raw) as {
48
+ installs?: Record<string, { path?: string }>;
49
+ };
50
+ for (const [cli, inst] of Object.entries(parsed.installs ?? {})) {
51
+ if (!TIER1_CLIS.has(cli)) continue;
52
+ if (!inst?.path || !existsSync(inst.path)) continue;
53
+ managed.add(cli);
54
+ }
55
+ } catch {
56
+ return managed;
57
+ }
58
+ return managed;
59
+ }
60
+
61
+ function filterManagedTier1Findings(
62
+ findings: UngovernedFinding[],
63
+ managedTier1Clis: Set<string>,
64
+ ): UngovernedFinding[] {
65
+ if (managedTier1Clis.size === 0) return findings;
66
+ return findings.filter((finding) => !managedTier1Clis.has(finding.cli));
67
+ }
68
+
69
+ export async function writeHostAuditEvent(
70
+ cwd: string,
71
+ event: Record<string, unknown>,
72
+ ): Promise<void> {
73
+ const auditDir = join(cwd, ".context", "audit");
74
+ await mkdir(auditDir, { recursive: true });
75
+ const date = new Date().toISOString().slice(0, 10);
76
+ const file = join(auditDir, `host-events-${date}.jsonl`);
77
+ await appendFile(file, JSON.stringify(event) + "\n");
78
+ }
79
+
80
+ export async function runScanOnce(options: ScannerOptions): Promise<UngovernedFinding[]> {
81
+ const mode = options.mode ?? readMode(options.cwd);
82
+ const managedTier1Clis = readManagedTier1Clis(options.cwd);
83
+ const findings = filterManagedTier1Findings(
84
+ detectUngoverned(options.detectorOptions),
85
+ managedTier1Clis,
86
+ );
87
+ const me = userInfo().username;
88
+ for (const finding of findings) {
89
+ 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
+ );
110
+ }
111
+ options.onFinding?.({ ...finding, action });
112
+ }
113
+ return findings;
114
+ }
115
+
116
+ export type ScannerHandle = {
117
+ stop(): void;
118
+ isRunning(): boolean;
119
+ };
120
+
121
+ export function startUngovernedScanner(options: ScannerOptions): ScannerHandle {
122
+ const interval = options.intervalMs ?? DEFAULT_INTERVAL_MS;
123
+ let running = true;
124
+
125
+ const tick = async () => {
126
+ if (!running) return;
127
+ try {
128
+ await runScanOnce(options);
129
+ } catch (err) {
130
+ process.stderr.write(
131
+ `[cortex-daemon] ungoverned scan failed: ${err instanceof Error ? err.message : String(err)}\n`,
132
+ );
133
+ }
134
+ };
135
+
136
+ void tick();
137
+ const handle = setInterval(() => void tick(), interval);
138
+ if (typeof handle.unref === "function") handle.unref();
139
+
140
+ return {
141
+ stop() {
142
+ running = false;
143
+ clearInterval(handle);
144
+ },
145
+ isRunning() {
146
+ return running;
147
+ },
148
+ };
149
+ }
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { env, pipeline } from "@xenova/transformers";
5
+ import { env, pipeline } from "@huggingface/transformers";
6
6
  import { readJsonl, asString, asNumber, asBoolean } from "./jsonl.js";
7
7
  import type { JsonObject, JsonValue } from "./types.js";
8
8
 
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { env, pipeline } from "@xenova/transformers";
2
+ import { env, pipeline } from "@huggingface/transformers";
3
3
  import { PATHS } from "./paths.js";
4
4
  import type { EmbeddingIndex, JsonObject } from "./types.js";
5
5
 
@@ -0,0 +1,84 @@
1
+ import type { AuditEntry } from "../../core/audit/writer.js";
2
+ import { sanitizeAuditEntryForPush } from "../privacy/boundary.js";
3
+
4
+ export type AuditPushContext = {
5
+ repo?: string;
6
+ instance_id?: string;
7
+ session_id?: string;
8
+ };
9
+
10
+ export type AuditPushResult = {
11
+ success: boolean;
12
+ count: number;
13
+ error?: string;
14
+ };
15
+
16
+ const pending: AuditEntry[] = [];
17
+ let activeContext: AuditPushContext = {};
18
+
19
+ export function setAuditPushContext(context: AuditPushContext): void {
20
+ activeContext = { ...context };
21
+ }
22
+
23
+ export function queueAuditEvent(entry: AuditEntry): void {
24
+ pending.push(sanitizeAuditEntryForPush({
25
+ ...entry,
26
+ repo: entry.repo ?? activeContext.repo,
27
+ instance_id: entry.instance_id ?? activeContext.instance_id,
28
+ session_id: entry.session_id ?? activeContext.session_id,
29
+ }));
30
+ }
31
+
32
+ export function pendingCount(): number {
33
+ return pending.length;
34
+ }
35
+
36
+ export async function pushAuditEvents(
37
+ baseUrl: string,
38
+ apiKey: string,
39
+ ): Promise<AuditPushResult> {
40
+ if (pending.length === 0) {
41
+ return { success: true, count: 0 };
42
+ }
43
+
44
+ const auditUrl = `${baseUrl.replace(/\/$/, "")}/api/v1/audit/push`;
45
+ let pushedCount = 0;
46
+
47
+ while (pending.length > 0) {
48
+ const batch = pending.splice(0, 100);
49
+
50
+ try {
51
+ const response = await fetch(auditUrl, {
52
+ method: "POST",
53
+ headers: {
54
+ Authorization: `Bearer ${apiKey}`,
55
+ "Content-Type": "application/json",
56
+ Accept: "application/json",
57
+ },
58
+ body: JSON.stringify({
59
+ repo: activeContext.repo,
60
+ instance_id: activeContext.instance_id,
61
+ session_id: activeContext.session_id,
62
+ events: batch,
63
+ }),
64
+ signal: AbortSignal.timeout(10_000),
65
+ });
66
+
67
+ if (!response.ok) {
68
+ pending.unshift(...batch);
69
+ return { success: false, count: pushedCount, error: `HTTP ${response.status}` };
70
+ }
71
+
72
+ pushedCount += batch.length;
73
+ } catch (err) {
74
+ pending.unshift(...batch);
75
+ return {
76
+ success: false,
77
+ count: pushedCount,
78
+ error: err instanceof Error ? err.message : "unknown error",
79
+ };
80
+ }
81
+ }
82
+
83
+ return { success: true, count: pushedCount };
84
+ }