@danielblomma/cortex-mcp 1.7.2 → 2.0.3
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/README.md +4 -24
- package/bin/cortex.mjs +679 -32
- package/bin/style.mjs +349 -0
- package/package.json +4 -3
- package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
- package/scaffold/mcp/src/cli/govern.ts +987 -0
- package/scaffold/mcp/src/cli/run.ts +306 -0
- package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
- package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
- package/scaffold/mcp/src/core/audit/query.ts +81 -0
- package/scaffold/mcp/src/core/audit/writer.ts +68 -0
- package/scaffold/mcp/src/core/config.ts +329 -0
- package/scaffold/mcp/src/core/index.ts +34 -0
- package/scaffold/mcp/src/core/license.ts +202 -0
- package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
- package/scaffold/mcp/src/core/policy/injection.ts +229 -0
- package/scaffold/mcp/src/core/policy/store.ts +197 -0
- package/scaffold/mcp/src/core/rbac/check.ts +40 -0
- package/scaffold/mcp/src/core/telemetry/collector.ts +408 -0
- package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
- package/scaffold/mcp/src/core/validators/config.ts +47 -0
- package/scaffold/mcp/src/core/validators/engine.ts +199 -0
- package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
- package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
- package/scaffold/mcp/src/daemon/client.ts +155 -0
- package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
- package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
- package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
- package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
- package/scaffold/mcp/src/daemon/main.ts +435 -0
- package/scaffold/mcp/src/daemon/paths.ts +41 -0
- package/scaffold/mcp/src/daemon/protocol.ts +101 -0
- package/scaffold/mcp/src/daemon/server.ts +227 -0
- package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
- package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
- package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
- package/scaffold/mcp/src/enterprise/index.ts +386 -0
- package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
- package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
- package/scaffold/mcp/src/enterprise/privacy/boundary.ts +214 -0
- package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
- package/scaffold/mcp/src/enterprise/telemetry/sync.ts +73 -0
- package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
- package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
- package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
- package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
- package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
- package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
- package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
- package/scaffold/mcp/src/hooks/session-end.ts +73 -0
- package/scaffold/mcp/src/hooks/session-start.ts +78 -0
- package/scaffold/mcp/src/hooks/shared.ts +134 -0
- package/scaffold/mcp/src/hooks/stop.ts +60 -0
- package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
- package/scaffold/mcp/src/loadGraph.ts +2 -0
- package/scaffold/mcp/src/plugin.ts +150 -0
- package/scaffold/mcp/src/server.ts +218 -7
- package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
- package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
- package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
- package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
- package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
- package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
- package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
- package/scaffold/mcp/tests/govern.test.mjs +74 -0
- package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
- package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
- package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
- package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
- package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
- package/scaffold/mcp/tests/run.test.mjs +109 -0
- package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
- package/scaffold/mcp/tests/telemetry-collector.test.mjs +30 -0
- package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
- package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
- package/scaffold/scripts/bootstrap.sh +0 -11
- package/scaffold/scripts/doctor.sh +24 -4
- package/types.js +5 -0
- package/docs/MCP_MARKETPLACE.md +0 -160
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createServer, type Server, type Socket } from "node:net";
|
|
2
|
+
import { writeFileSync, unlinkSync, existsSync } from "node:fs";
|
|
3
|
+
import { socketPath, pidFilePath } from "./paths.js";
|
|
4
|
+
import type {
|
|
5
|
+
Request,
|
|
6
|
+
Response,
|
|
7
|
+
PolicyCheckPayload,
|
|
8
|
+
PolicyCheckResult,
|
|
9
|
+
TelemetryFlushPayload,
|
|
10
|
+
TelemetryFlushResult,
|
|
11
|
+
AuditLogPayload,
|
|
12
|
+
AuditLogResult,
|
|
13
|
+
HeartbeatPayload,
|
|
14
|
+
HeartbeatResult,
|
|
15
|
+
} from "./protocol.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The cortex daemon serves hooks (PreToolUse, Stop, etc.) over a Unix socket.
|
|
19
|
+
* Long-lived per-user process. Hooks are thin shims; the daemon holds warm
|
|
20
|
+
* state (graph, embeddings, license cache).
|
|
21
|
+
*
|
|
22
|
+
* v2.0.0 MVP: ping + policy.check + telemetry.flush + shutdown.
|
|
23
|
+
* Future: full MCP-tool routing through the daemon (today MCP still runs
|
|
24
|
+
* its own per-session stdio process — see plan Fas 3.6).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const IDLE_SHUTDOWN_MS = 30 * 60 * 1000; // 30 min idle → shutdown
|
|
28
|
+
|
|
29
|
+
type DaemonOptions = {
|
|
30
|
+
onPolicyCheck?: (payload: PolicyCheckPayload) => Promise<PolicyCheckResult>;
|
|
31
|
+
onTelemetryFlush?: (
|
|
32
|
+
payload: TelemetryFlushPayload,
|
|
33
|
+
) => Promise<TelemetryFlushResult>;
|
|
34
|
+
onAuditLog?: (payload: AuditLogPayload) => Promise<AuditLogResult>;
|
|
35
|
+
onHeartbeat?: (payload: HeartbeatPayload) => Promise<HeartbeatResult>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export class CortexDaemon {
|
|
39
|
+
private server: Server | null = null;
|
|
40
|
+
private idleTimer: NodeJS.Timeout | null = null;
|
|
41
|
+
private readonly opts: DaemonOptions;
|
|
42
|
+
|
|
43
|
+
constructor(opts: DaemonOptions = {}) {
|
|
44
|
+
this.opts = opts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async start(): Promise<void> {
|
|
48
|
+
const sockPath = socketPath();
|
|
49
|
+
|
|
50
|
+
// Clean up stale socket from a prior crash.
|
|
51
|
+
if (existsSync(sockPath)) {
|
|
52
|
+
try {
|
|
53
|
+
unlinkSync(sockPath);
|
|
54
|
+
} catch {
|
|
55
|
+
// ignore — listen() will surface the real error
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const server = createServer((socket) => this.handleConnection(socket));
|
|
60
|
+
this.server = server;
|
|
61
|
+
|
|
62
|
+
await new Promise<void>((resolve, reject) => {
|
|
63
|
+
server.once("error", reject);
|
|
64
|
+
server.listen(sockPath, () => {
|
|
65
|
+
server.off("error", reject);
|
|
66
|
+
resolve();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Persist PID so cortex CLI can detect a running daemon.
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(pidFilePath(), String(process.pid), "utf8");
|
|
73
|
+
} catch {
|
|
74
|
+
// Non-fatal — clients can still connect via socket existence.
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.armIdleTimer();
|
|
78
|
+
process.stderr.write(
|
|
79
|
+
`[cortex-daemon] listening on ${sockPath} pid=${process.pid}\n`,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Best-effort cleanup on shutdown signals.
|
|
83
|
+
const cleanup = () => {
|
|
84
|
+
this.stop().finally(() => process.exit(0));
|
|
85
|
+
};
|
|
86
|
+
process.on("SIGINT", cleanup);
|
|
87
|
+
process.on("SIGTERM", cleanup);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async stop(): Promise<void> {
|
|
91
|
+
if (this.idleTimer) {
|
|
92
|
+
clearTimeout(this.idleTimer);
|
|
93
|
+
this.idleTimer = null;
|
|
94
|
+
}
|
|
95
|
+
if (this.server) {
|
|
96
|
+
const srv = this.server;
|
|
97
|
+
this.server = null;
|
|
98
|
+
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(pidFilePath());
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
unlinkSync(socketPath());
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private armIdleTimer(): void {
|
|
113
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
114
|
+
this.idleTimer = setTimeout(() => {
|
|
115
|
+
process.stderr.write("[cortex-daemon] idle shutdown\n");
|
|
116
|
+
this.stop().finally(() => process.exit(0));
|
|
117
|
+
}, IDLE_SHUTDOWN_MS);
|
|
118
|
+
this.idleTimer.unref();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private handleConnection(socket: Socket): void {
|
|
122
|
+
this.armIdleTimer();
|
|
123
|
+
let buffer = "";
|
|
124
|
+
|
|
125
|
+
socket.on("data", (chunk) => {
|
|
126
|
+
buffer += chunk.toString("utf8");
|
|
127
|
+
let nlIndex: number;
|
|
128
|
+
while ((nlIndex = buffer.indexOf("\n")) !== -1) {
|
|
129
|
+
const line = buffer.slice(0, nlIndex);
|
|
130
|
+
buffer = buffer.slice(nlIndex + 1);
|
|
131
|
+
if (!line.trim()) continue;
|
|
132
|
+
void this.handleLine(socket, line);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
socket.on("error", () => {
|
|
137
|
+
// Suppress — clients dropping mid-write must not crash the daemon.
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async handleLine(socket: Socket, line: string): Promise<void> {
|
|
142
|
+
let req: Request;
|
|
143
|
+
try {
|
|
144
|
+
req = JSON.parse(line) as Request;
|
|
145
|
+
} catch {
|
|
146
|
+
this.sendError(socket, "<unknown>", "invalid_json");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
switch (req.type) {
|
|
152
|
+
case "ping":
|
|
153
|
+
this.sendOk(socket, req.id, { pong: true, pid: process.pid });
|
|
154
|
+
return;
|
|
155
|
+
case "policy.check": {
|
|
156
|
+
if (!this.opts.onPolicyCheck) {
|
|
157
|
+
this.sendOk(socket, req.id, { allow: true } as PolicyCheckResult);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const result = await this.opts.onPolicyCheck(
|
|
161
|
+
req.payload as PolicyCheckPayload,
|
|
162
|
+
);
|
|
163
|
+
this.sendOk(socket, req.id, result);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
case "telemetry.flush": {
|
|
167
|
+
if (!this.opts.onTelemetryFlush) {
|
|
168
|
+
this.sendOk(socket, req.id, {
|
|
169
|
+
flushed: false,
|
|
170
|
+
events_pushed: 0,
|
|
171
|
+
} as TelemetryFlushResult);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const result = await this.opts.onTelemetryFlush(
|
|
175
|
+
req.payload as TelemetryFlushPayload,
|
|
176
|
+
);
|
|
177
|
+
this.sendOk(socket, req.id, result);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
case "audit.log": {
|
|
181
|
+
if (!this.opts.onAuditLog) {
|
|
182
|
+
this.sendOk(socket, req.id, { written: false } as AuditLogResult);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const result = await this.opts.onAuditLog(
|
|
186
|
+
req.payload as AuditLogPayload,
|
|
187
|
+
);
|
|
188
|
+
this.sendOk(socket, req.id, result);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
case "heartbeat": {
|
|
192
|
+
if (!this.opts.onHeartbeat) {
|
|
193
|
+
this.sendOk(socket, req.id, { recorded: false } as HeartbeatResult);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const result = await this.opts.onHeartbeat(
|
|
197
|
+
req.payload as HeartbeatPayload,
|
|
198
|
+
);
|
|
199
|
+
this.sendOk(socket, req.id, result);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
case "shutdown":
|
|
203
|
+
this.sendOk(socket, req.id, { ok: true });
|
|
204
|
+
setTimeout(() => this.stop().finally(() => process.exit(0)), 50);
|
|
205
|
+
return;
|
|
206
|
+
default:
|
|
207
|
+
this.sendError(socket, req.id, `unknown_type: ${req.type}`);
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
this.sendError(
|
|
211
|
+
socket,
|
|
212
|
+
req.id,
|
|
213
|
+
err instanceof Error ? err.message : "unknown_error",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private sendOk(socket: Socket, id: string, result: unknown): void {
|
|
219
|
+
const payload: Response = { id, ok: true, result };
|
|
220
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private sendError(socket: Socket, id: string, error: string): void {
|
|
224
|
+
const payload: Response = { id, ok: false, error };
|
|
225
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|