@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,306 @@
1
+ import { spawn } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ unlinkSync,
7
+ mkdirSync,
8
+ chmodSync,
9
+ } from "node:fs";
10
+ import { join, dirname } from "node:path";
11
+ import { platform, tmpdir } from "node:os";
12
+ import { randomUUID } from "node:crypto";
13
+
14
+ export type RunCli = "claude" | "codex" | "copilot";
15
+ export const RUN_CLIS: RunCli[] = ["claude", "codex", "copilot"];
16
+
17
+ // Inline ANSI helper. Kept here (rather than imported from bin/style.mjs) so
18
+ // the scaffold MCP stays self-contained — projects bundle scaffold/ on init
19
+ // but do not pull bin/ into their tree. Style stays brand-aligned: cyan accent
20
+ // (xterm-256 code 81), graceful degrade when the stream is not a TTY or when
21
+ // NO_COLOR / CORTEX_NO_COLOR are set.
22
+ function runHeaderSupportsColor(): boolean {
23
+ if (process.env.NO_COLOR) return false;
24
+ if (process.env.CORTEX_NO_COLOR) return false;
25
+ if (process.env.TERM === "dumb") return false;
26
+ return Boolean(process.stderr.isTTY);
27
+ }
28
+
29
+ function runHeaderUnicodeOk(): boolean {
30
+ if (process.env.CORTEX_NO_UNICODE) return false;
31
+ const locale = process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || "";
32
+ if (!locale) return process.platform !== "win32";
33
+ return /UTF-?8/i.test(locale);
34
+ }
35
+
36
+ export function buildRunHeader(label: string): string {
37
+ const glyph = runHeaderUnicodeOk() ? "▸" : ">";
38
+ if (!runHeaderSupportsColor()) {
39
+ return `${glyph} ${label}`;
40
+ }
41
+ return `\x1b[38;5;81m${glyph}\x1b[0m ${label}`;
42
+ }
43
+
44
+ export const SHIM_MARKER = "# cortex-shim-v1";
45
+
46
+ export function isCortexShim(filePath: string): boolean {
47
+ try {
48
+ return readFileSync(filePath, "utf8").includes(SHIM_MARKER);
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ export function findRealBinary(name: string, excludePaths: string[] = []): string | null {
55
+ const exclusions = new Set(excludePaths);
56
+ const pathDirs = (process.env.PATH ?? "").split(":");
57
+ for (const dir of pathDirs) {
58
+ if (!dir) continue;
59
+ const candidate = join(dir, name);
60
+ if (exclusions.has(candidate)) continue;
61
+ if (!existsSync(candidate)) continue;
62
+ if (isCortexShim(candidate)) continue;
63
+ return candidate;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ export function buildDarwinSandboxProfile(homeDir: string): string {
69
+ return [
70
+ "(version 1)",
71
+ "(allow default)",
72
+ "; Cortex Tier 2 (wrap) profile for Copilot CLI.",
73
+ "; Deny writes to Copilot's mutable config locations so AI cannot",
74
+ "; reconfigure itself out of governance.",
75
+ `(deny file-write* (subpath "${homeDir}/.copilot"))`,
76
+ `(deny file-write* (subpath "${homeDir}/.copilot.local"))`,
77
+ '(deny file-write* (regex #"^/etc/copilot"))',
78
+ "",
79
+ ].join("\n");
80
+ }
81
+
82
+ export function buildLinuxBwrapArgs(
83
+ homeDir: string,
84
+ realBinary: string,
85
+ args: string[],
86
+ ): string[] {
87
+ return [
88
+ "--die-with-parent",
89
+ "--unshare-user",
90
+ "--ro-bind",
91
+ "/",
92
+ "/",
93
+ "--proc",
94
+ "/proc",
95
+ "--dev",
96
+ "/dev",
97
+ "--bind",
98
+ homeDir,
99
+ homeDir,
100
+ "--tmpfs",
101
+ `${homeDir}/.copilot`,
102
+ "--tmpfs",
103
+ `${homeDir}/.copilot.local`,
104
+ "--ro-bind",
105
+ "/etc",
106
+ "/etc",
107
+ "--",
108
+ realBinary,
109
+ ...args,
110
+ ];
111
+ }
112
+
113
+ export type RunOptions = {
114
+ cli: RunCli;
115
+ args: string[];
116
+ realBinary?: string;
117
+ excludePaths?: string[];
118
+ };
119
+
120
+ function spawnAndWait(cmd: string, args: string[], extraEnv?: Record<string, string>): Promise<number> {
121
+ return new Promise((resolve) => {
122
+ const env = extraEnv ? { ...process.env, ...extraEnv } : process.env;
123
+ const child = spawn(cmd, args, { stdio: "inherit", env });
124
+ child.on("exit", (code, signal) => {
125
+ if (signal) resolve(128);
126
+ else resolve(code ?? 1);
127
+ });
128
+ child.on("error", (err) => {
129
+ console.error(`✗ Failed to spawn ${cmd}: ${err.message}`);
130
+ resolve(127);
131
+ });
132
+ });
133
+ }
134
+
135
+ export async function runAiCli(options: RunOptions): Promise<number> {
136
+ const { cli, args } = options;
137
+ const real =
138
+ options.realBinary ?? findRealBinary(cli, options.excludePaths ?? []);
139
+ if (!real) {
140
+ const exclusions = options.excludePaths?.length
141
+ ? ` (excluding ${options.excludePaths.join(", ")})`
142
+ : "";
143
+ console.error(`✗ Could not find '${cli}' binary in PATH${exclusions}.`);
144
+ return 127;
145
+ }
146
+
147
+ if (cli !== "copilot") {
148
+ return spawnAndWait(real, args);
149
+ }
150
+
151
+ const home = process.env.HOME ?? "";
152
+ if (!home) {
153
+ console.error("✗ HOME not set — required for Copilot wrap profile.");
154
+ return 1;
155
+ }
156
+
157
+ const proxyPort = process.env.CORTEX_EGRESS_PROXY_PORT ?? "18888";
158
+ const proxyEnv: Record<string, string> = {
159
+ HTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,
160
+ HTTP_PROXY: `http://127.0.0.1:${proxyPort}`,
161
+ https_proxy: `http://127.0.0.1:${proxyPort}`,
162
+ http_proxy: `http://127.0.0.1:${proxyPort}`,
163
+ };
164
+
165
+ const os = platform();
166
+ if (os === "darwin") {
167
+ const profile = buildDarwinSandboxProfile(home);
168
+ const tmpProfile = join(tmpdir(), `cortex-copilot-${randomUUID()}.sb`);
169
+ writeFileSync(tmpProfile, profile);
170
+ try {
171
+ process.stderr.write(buildRunHeader("cortex run copilot — Tier 2 wrap (sandbox-exec)") + "\n");
172
+ return await spawnAndWait("sandbox-exec", ["-f", tmpProfile, real, ...args], proxyEnv);
173
+ } finally {
174
+ try {
175
+ unlinkSync(tmpProfile);
176
+ } catch {
177
+ // best-effort cleanup
178
+ }
179
+ }
180
+ }
181
+ if (os === "linux") {
182
+ process.stderr.write(buildRunHeader("cortex run copilot — Tier 2 wrap (bwrap)") + "\n");
183
+ return spawnAndWait("bwrap", buildLinuxBwrapArgs(home, real, args), proxyEnv);
184
+ }
185
+ console.error(`✗ Tier 2 (wrap) for copilot not yet supported on ${os}.`);
186
+ return 1;
187
+ }
188
+
189
+ const DEFAULT_COPILOT_SHIM_PATHS: Partial<Record<NodeJS.Platform, string>> = {
190
+ darwin: "/usr/local/bin/copilot",
191
+ linux: "/usr/local/bin/copilot",
192
+ };
193
+
194
+ export function getDefaultCopilotShimPath(os: NodeJS.Platform): string {
195
+ const path = DEFAULT_COPILOT_SHIM_PATHS[os];
196
+ if (!path) {
197
+ throw new Error(`copilot shim install not yet supported on ${os}`);
198
+ }
199
+ return path;
200
+ }
201
+
202
+ export function buildCopilotShim(realBinary: string): string {
203
+ return [
204
+ "#!/bin/sh",
205
+ SHIM_MARKER,
206
+ "# Cortex Tier 2 wrap shim — re-execs through 'cortex run copilot'.",
207
+ "# Direct invocations of copilot are routed through cortex's OS sandbox.",
208
+ `# Real binary captured at install time: ${realBinary}`,
209
+ "",
210
+ 'CORTEX="${CORTEX_BIN:-cortex}"',
211
+ 'exec "$CORTEX" run copilot "$@"',
212
+ "",
213
+ ].join("\n");
214
+ }
215
+
216
+ export type InstallShimOptions = {
217
+ shimPath?: string;
218
+ realBinary?: string;
219
+ searchPath?: string;
220
+ };
221
+
222
+ export type InstallShimResult = {
223
+ ok: boolean;
224
+ message: string;
225
+ shimPath?: string;
226
+ realBinary?: string;
227
+ };
228
+
229
+ export function installCopilotShim(options: InstallShimOptions = {}): InstallShimResult {
230
+ const shimPath = options.shimPath ?? getDefaultCopilotShimPath(platform());
231
+ const search = options.searchPath ?? process.env.PATH ?? "";
232
+ const real =
233
+ options.realBinary ??
234
+ findRealBinaryIn(search, "copilot", [shimPath]);
235
+ if (!real) {
236
+ return {
237
+ ok: false,
238
+ message:
239
+ `Copilot CLI not found in PATH (excluding ${shimPath}). ` +
240
+ "Install GitHub Copilot CLI first, then re-run cortex enterprise sync.",
241
+ };
242
+ }
243
+ if (existsSync(shimPath) && !isCortexShim(shimPath)) {
244
+ return {
245
+ ok: false,
246
+ message:
247
+ `${shimPath} exists and is not a cortex shim — refusing to overwrite. ` +
248
+ "Move/rename the existing file, then re-run.",
249
+ };
250
+ }
251
+ try {
252
+ mkdirSync(dirname(shimPath), { recursive: true });
253
+ writeFileSync(shimPath, buildCopilotShim(real));
254
+ chmodSync(shimPath, 0o755);
255
+ } catch (err) {
256
+ return {
257
+ ok: false,
258
+ message: `Failed to write shim at ${shimPath}: ${err instanceof Error ? err.message : String(err)}`,
259
+ };
260
+ }
261
+ return {
262
+ ok: true,
263
+ message: `Installed copilot shim at ${shimPath} (real binary: ${real})`,
264
+ shimPath,
265
+ realBinary: real,
266
+ };
267
+ }
268
+
269
+ function findRealBinaryIn(
270
+ searchPath: string,
271
+ name: string,
272
+ excludePaths: string[],
273
+ ): string | null {
274
+ const exclusions = new Set(excludePaths);
275
+ for (const dir of searchPath.split(":")) {
276
+ if (!dir) continue;
277
+ const candidate = join(dir, name);
278
+ if (exclusions.has(candidate)) continue;
279
+ if (!existsSync(candidate)) continue;
280
+ if (isCortexShim(candidate)) continue;
281
+ return candidate;
282
+ }
283
+ return null;
284
+ }
285
+
286
+ export function uninstallCopilotShim(shimPath?: string): { ok: boolean; message: string } {
287
+ const target = shimPath ?? getDefaultCopilotShimPath(platform());
288
+ if (!existsSync(target)) {
289
+ return { ok: true, message: `${target} already absent.` };
290
+ }
291
+ if (!isCortexShim(target)) {
292
+ return {
293
+ ok: false,
294
+ message: `${target} is no longer a cortex shim — refusing to delete (would clobber a real binary).`,
295
+ };
296
+ }
297
+ try {
298
+ unlinkSync(target);
299
+ } catch (err) {
300
+ return {
301
+ ok: false,
302
+ message: `Failed to remove ${target}: ${err instanceof Error ? err.message : String(err)}`,
303
+ };
304
+ }
305
+ return { ok: true, message: `Removed copilot shim at ${target}` };
306
+ }
@@ -0,0 +1,158 @@
1
+ import { basename, join } from "node:path";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { hostname, platform, arch } from "node:os";
5
+ import {
6
+ loadEnterpriseConfig,
7
+ resolveEnterpriseActivation,
8
+ } from "../core/config.js";
9
+ import { pushMetrics } from "../enterprise/telemetry/sync.js";
10
+ import type { TelemetryMetrics } from "../core/telemetry/collector.js";
11
+
12
+ /**
13
+ * Smoke-test the telemetry pipeline end-to-end.
14
+ *
15
+ * $ cortex telemetry test
16
+ *
17
+ * Reads .context/enterprise.yml, builds a synthetic-but-valid metrics
18
+ * payload, sends it to the configured endpoint, and reports the result
19
+ * with actionable diagnostics on failure.
20
+ *
21
+ * This exists because the silent-failure path (push fails, dashboard
22
+ * stays empty, no error surfaces anywhere) was the original v2.0.0
23
+ * motivator. The smoke-test gives users a single command to verify
24
+ * their entire pipeline.
25
+ */
26
+
27
+ function readMachineId(contextDir: string): string {
28
+ const path = join(contextDir, "telemetry", "machine_id");
29
+ if (existsSync(path)) {
30
+ try {
31
+ const id = readFileSync(path, "utf8").trim();
32
+ if (id) return id;
33
+ } catch {
34
+ // fall through to compute one
35
+ }
36
+ }
37
+ return createHash("sha256")
38
+ .update(`${hostname()}|${platform()}|${arch()}|test`)
39
+ .digest("hex")
40
+ .slice(0, 16);
41
+ }
42
+
43
+ function buildSyntheticMetrics(instanceId: string, version: string): TelemetryMetrics {
44
+ const now = new Date();
45
+ const periodStart = new Date(now.getTime() - 60_000).toISOString();
46
+ const periodEnd = now.toISOString();
47
+ return {
48
+ period_start: periodStart,
49
+ period_end: periodEnd,
50
+ total_tool_calls: 1,
51
+ successful_tool_calls: 1,
52
+ failed_tool_calls: 0,
53
+ total_duration_ms: 100,
54
+ session_starts: 1,
55
+ session_ends: 1,
56
+ session_duration_ms_total: 100,
57
+ searches: 1,
58
+ related_lookups: 0,
59
+ caller_lookups: 0,
60
+ trace_lookups: 0,
61
+ impact_analyses: 0,
62
+ rule_lookups: 0,
63
+ reloads: 0,
64
+ total_results_returned: 1,
65
+ estimated_tokens_saved: 100,
66
+ estimated_tokens_total: 500,
67
+ client_version: version,
68
+ instance_id: instanceId,
69
+ tool_metrics: {
70
+ "telemetry.test": {
71
+ calls: 1,
72
+ failures: 0,
73
+ total_duration_ms: 100,
74
+ total_results_returned: 1,
75
+ estimated_tokens_saved: 100,
76
+ },
77
+ },
78
+ };
79
+ }
80
+
81
+ export async function runTelemetryTest(): Promise<number> {
82
+ const projectRoot = process.env.CORTEX_PROJECT_ROOT?.trim() || process.cwd();
83
+ const contextDir = join(projectRoot, ".context");
84
+
85
+ if (!existsSync(contextDir)) {
86
+ console.error(`No .context directory at ${projectRoot}`);
87
+ console.error(`Run 'cortex init --bootstrap' first.`);
88
+ return 1;
89
+ }
90
+
91
+ const config = loadEnterpriseConfig(contextDir);
92
+ const activation = resolveEnterpriseActivation(config);
93
+
94
+ console.log(`Project: ${projectRoot}`);
95
+ console.log(`Enterprise active: ${activation.active} (${activation.reason})`);
96
+
97
+ if (!activation.active) {
98
+ console.error("");
99
+ console.error("Cannot run telemetry test in community mode.");
100
+ console.error("Configure .context/enterprise.yml with valid api_key + endpoint.");
101
+ return 1;
102
+ }
103
+
104
+ if (!config.telemetry.enabled) {
105
+ console.error("");
106
+ console.error("telemetry.enabled is false in enterprise.yml");
107
+ return 1;
108
+ }
109
+
110
+ console.log(`Endpoint: ${config.telemetry.endpoint}`);
111
+ console.log(`API key prefix: ${config.telemetry.api_key.slice(0, 8)}...`);
112
+
113
+ const instanceId = readMachineId(contextDir);
114
+ const version = process.env.CORTEX_VERSION ?? "telemetry-test";
115
+ const metrics = buildSyntheticMetrics(instanceId, version);
116
+
117
+ console.log("");
118
+ console.log("Sending synthetic metrics...");
119
+
120
+ const start = Date.now();
121
+ const result = await pushMetrics(
122
+ metrics,
123
+ config.telemetry.endpoint,
124
+ config.telemetry.api_key,
125
+ { repo: basename(projectRoot) },
126
+ );
127
+ const elapsed = Date.now() - start;
128
+
129
+ if (result.success) {
130
+ console.log("");
131
+ console.log(`✓ Push succeeded in ${elapsed}ms (HTTP ${result.status})`);
132
+ console.log("");
133
+ console.log("Next: open the dashboard. The synthetic event should appear within 60s.");
134
+ console.log("If it does not, the issue is on the server (ingest or read path),");
135
+ console.log("not the client.");
136
+ return 0;
137
+ }
138
+
139
+ console.error("");
140
+ console.error(`✗ Push failed after ${elapsed}ms: ${result.error ?? "unknown"}`);
141
+ console.error("");
142
+ if (result.status === 401) {
143
+ console.error(" → API key was rejected. Check that the key in enterprise.yml");
144
+ console.error(" matches a row in cortex-web's api_keys table and is not");
145
+ console.error(" revoked or expired.");
146
+ } else if (result.status === 403) {
147
+ console.error(" → API key lacks 'telemetry' scope. Add the scope in cortex-web.");
148
+ } else if (result.status === 400) {
149
+ console.error(" → Payload schema rejected. Likely a client/server version skew.");
150
+ console.error(" Check telemetryPushSchema vs TelemetryMetrics.");
151
+ } else if (result.status && result.status >= 500) {
152
+ console.error(" → Server error. Check cortex-web logs and DB connectivity.");
153
+ } else {
154
+ console.error(" → Network error. Verify endpoint is reachable from this machine.");
155
+ console.error(` curl -I ${config.telemetry.endpoint}`);
156
+ }
157
+ return 1;
158
+ }
@@ -0,0 +1,168 @@
1
+ import { execSync } from "node:child_process";
2
+ import { basename } from "node:path";
3
+ import { hostname, userInfo } from "node:os";
4
+
5
+ export type ProcessSnapshot = {
6
+ pid: number;
7
+ ppid: number;
8
+ user: string;
9
+ comm: string;
10
+ args: string;
11
+ };
12
+
13
+ export const DEFAULT_AI_BINARIES = [
14
+ "claude",
15
+ "codex",
16
+ "copilot",
17
+ "gemini-cli",
18
+ "gemini",
19
+ "aider",
20
+ "cursor",
21
+ ];
22
+
23
+ export type UngovernedFinding = {
24
+ pid: number;
25
+ ppid: number;
26
+ user: string;
27
+ cli: string;
28
+ binary: string;
29
+ args: string;
30
+ parent_chain: number[];
31
+ detected_at: string;
32
+ host_id: string;
33
+ };
34
+
35
+ export type DetectorOptions = {
36
+ knownBinaries?: string[];
37
+ processes?: ProcessSnapshot[];
38
+ hostId?: string;
39
+ };
40
+
41
+ const PS_LINE_RE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s*(.*)$/;
42
+
43
+ export function listProcesses(): ProcessSnapshot[] {
44
+ try {
45
+ const out = execSync("ps -axo pid,ppid,user,comm,args", {
46
+ encoding: "utf8",
47
+ timeout: 5000,
48
+ maxBuffer: 8 * 1024 * 1024,
49
+ });
50
+ const lines = out.trim().split("\n").slice(1);
51
+ return lines
52
+ .map(parseProcessLine)
53
+ .filter((p): p is ProcessSnapshot => p !== null);
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ export function parseProcessLine(line: string): ProcessSnapshot | null {
60
+ const m = line.match(PS_LINE_RE);
61
+ if (!m) return null;
62
+ const pid = parseInt(m[1], 10);
63
+ const ppid = parseInt(m[2], 10);
64
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid)) return null;
65
+ return {
66
+ pid,
67
+ ppid,
68
+ user: m[3],
69
+ comm: m[4],
70
+ args: m[5] ?? "",
71
+ };
72
+ }
73
+
74
+ export function isCortexAncestor(args: string): boolean {
75
+ if (!args) return false;
76
+ // Accept invocations whose command line shows cortex orchestration:
77
+ // - `cortex run ...` → wrapper spawned the AI CLI (Tier 2 governed)
78
+ // - `cortex enterprise` → install-time orchestration
79
+ // - `cortex daemon` → daemon-supervised
80
+ // - any path ending in `cortex.mjs` or named `cortex` followed by space/EOL
81
+ if (/\bcortex\s+run\b/.test(args)) return true;
82
+ if (/\bcortex\s+enterprise\b/.test(args)) return true;
83
+ if (/\bcortex\s+daemon\b/.test(args)) return true;
84
+ if (/\bcortex\s+hook\b/.test(args)) return true;
85
+ if (/\bcortex(\s|$)/.test(args)) return true;
86
+ if (/cortex\.mjs/.test(args)) return true;
87
+ return false;
88
+ }
89
+
90
+ function findCli(comm: string, knownBinaries: string[]): string | null {
91
+ const base = basename(comm);
92
+ return knownBinaries.includes(base) ? base : null;
93
+ }
94
+
95
+ export function detectUngoverned(options: DetectorOptions = {}): UngovernedFinding[] {
96
+ const known = options.knownBinaries ?? DEFAULT_AI_BINARIES;
97
+ const procs = options.processes ?? listProcesses();
98
+ const byPid = new Map<number, ProcessSnapshot>();
99
+ for (const p of procs) byPid.set(p.pid, p);
100
+
101
+ const hostId = options.hostId ?? hostname();
102
+ const findings: UngovernedFinding[] = [];
103
+
104
+ for (const proc of procs) {
105
+ const cli = findCli(proc.comm, known);
106
+ if (!cli) continue;
107
+ if (isCortexAncestor(proc.args)) continue;
108
+
109
+ const chain: number[] = [proc.pid];
110
+ let current: ProcessSnapshot | undefined = byPid.get(proc.ppid);
111
+ let governed = false;
112
+ let depth = 0;
113
+ while (current && current.pid > 1 && depth < 32) {
114
+ chain.push(current.pid);
115
+ if (isCortexAncestor(current.args)) {
116
+ governed = true;
117
+ break;
118
+ }
119
+ const next: ProcessSnapshot | undefined = byPid.get(current.ppid);
120
+ if (!next || next.pid === current.pid) break;
121
+ current = next;
122
+ depth += 1;
123
+ }
124
+
125
+ if (!governed) {
126
+ findings.push({
127
+ pid: proc.pid,
128
+ ppid: proc.ppid,
129
+ user: proc.user,
130
+ cli,
131
+ binary: proc.comm,
132
+ args: proc.args,
133
+ parent_chain: chain,
134
+ detected_at: new Date().toISOString(),
135
+ host_id: hostId,
136
+ });
137
+ }
138
+ }
139
+
140
+ return findings;
141
+ }
142
+
143
+ export type EnforcementMode = "advisory" | "enforced";
144
+ export type EnforcementAction = "logged" | "sigterm" | "skipped_cross_user";
145
+
146
+ export type EnforceOptions = {
147
+ mode: EnforcementMode;
148
+ sendSignal?: (pid: number, signal: NodeJS.Signals) => void;
149
+ currentUser?: string;
150
+ };
151
+
152
+ export function enforceFinding(
153
+ finding: UngovernedFinding,
154
+ options: EnforceOptions,
155
+ ): EnforcementAction {
156
+ if (options.mode !== "enforced") return "logged";
157
+ const me = options.currentUser ?? userInfo().username;
158
+ if (finding.user !== me) {
159
+ return "skipped_cross_user";
160
+ }
161
+ const send = options.sendSignal ?? ((pid, sig) => process.kill(pid, sig));
162
+ try {
163
+ send(finding.pid, "SIGTERM");
164
+ } catch {
165
+ // process exited between detection and signal — best-effort
166
+ }
167
+ return "sigterm";
168
+ }
@@ -0,0 +1,81 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { AuditEntry } from "./writer.js";
4
+
5
+ export type AuditQuery = {
6
+ from?: string;
7
+ to?: string;
8
+ tool?: string;
9
+ event_type?: string;
10
+ evidence_level?: string;
11
+ status?: "success" | "error";
12
+ session_id?: string;
13
+ limit?: number;
14
+ };
15
+
16
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
17
+
18
+ export function queryAuditLog(contextDir: string, query: AuditQuery): AuditEntry[] {
19
+ const auditDir = join(contextDir, "audit");
20
+ const limit = query.limit ?? 50;
21
+
22
+ if (query.from && !DATE_RE.test(query.from)) return [];
23
+ if (query.to && !DATE_RE.test(query.to)) return [];
24
+
25
+ let files: string[];
26
+ try {
27
+ files = readdirSync(auditDir)
28
+ .filter(f => f.endsWith(".jsonl"))
29
+ .sort();
30
+ } catch {
31
+ return [];
32
+ }
33
+
34
+ // Filter files by date range
35
+ if (query.from) {
36
+ const fromFile = `${query.from}.jsonl`;
37
+ files = files.filter(f => f >= fromFile);
38
+ }
39
+ if (query.to) {
40
+ const toFile = `${query.to}.jsonl`;
41
+ files = files.filter(f => f <= toFile);
42
+ }
43
+
44
+ const results: AuditEntry[] = [];
45
+
46
+ // Read newest files first
47
+ for (const file of files.reverse()) {
48
+ if (results.length >= limit) break;
49
+
50
+ try {
51
+ const raw = readFileSync(join(auditDir, file), "utf8").trim();
52
+ if (!raw) continue;
53
+
54
+ const entries: AuditEntry[] = raw
55
+ .split("\n")
56
+ .map(line => {
57
+ try { return JSON.parse(line); }
58
+ catch { return null; }
59
+ })
60
+ .filter(Boolean)
61
+ .reverse(); // newest first within file
62
+
63
+ for (const entry of entries) {
64
+ if (results.length >= limit) break;
65
+
66
+ // Apply tool filter
67
+ if (query.tool && entry.tool !== query.tool) continue;
68
+ if (query.event_type && entry.event_type !== query.event_type) continue;
69
+ if (query.evidence_level && entry.evidence_level !== query.evidence_level) continue;
70
+ if (query.status && entry.status !== query.status) continue;
71
+ if (query.session_id && entry.session_id !== query.session_id) continue;
72
+
73
+ results.push(entry);
74
+ }
75
+ } catch {
76
+ // skip unreadable files
77
+ }
78
+ }
79
+
80
+ return results;
81
+ }