@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,223 @@
|
|
|
1
|
+
import { writeFileSync, existsSync, unlinkSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import type { HeartbeatPayload, HeartbeatResult } from "./protocol.js";
|
|
5
|
+
import { writeHostAuditEvent } from "./ungoverned-scanner.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hook heartbeat tracker for Phase 6 tamper-detection.
|
|
9
|
+
*
|
|
10
|
+
* Each hook invocation pings the daemon. The daemon tracks per-session
|
|
11
|
+
* "last seen any hook" timestamps. A session is considered active from
|
|
12
|
+
* the first SessionStart heartbeat until SessionEnd (or auto-cleanup
|
|
13
|
+
* after a long stale interval).
|
|
14
|
+
*
|
|
15
|
+
* Tamper detection (periodic): if an active session has had at least
|
|
16
|
+
* one non-SessionStart heartbeat (so we know hooks were genuinely
|
|
17
|
+
* firing) and then nothing within `missingThresholdSeconds`, we flag
|
|
18
|
+
* it. Pure idle sessions where the user just left Claude open without
|
|
19
|
+
* doing anything do not match the "had-activity-then-silence" pattern.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type SessionState = {
|
|
23
|
+
cli: HeartbeatPayload["cli"];
|
|
24
|
+
cwd: string;
|
|
25
|
+
started_at: string;
|
|
26
|
+
last_heartbeat: string;
|
|
27
|
+
hook_count: number;
|
|
28
|
+
ended: boolean;
|
|
29
|
+
ended_at?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type TamperLockEntry = {
|
|
33
|
+
version: 1;
|
|
34
|
+
detected_at: string;
|
|
35
|
+
cli: HeartbeatPayload["cli"];
|
|
36
|
+
session_id: string;
|
|
37
|
+
hook_name: string;
|
|
38
|
+
last_seen: string;
|
|
39
|
+
missing_seconds: number;
|
|
40
|
+
host_id: string;
|
|
41
|
+
cwd: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const TAMPER_LOCK_FILENAME = ".cortex-tamper.lock";
|
|
45
|
+
|
|
46
|
+
export type TamperCheckOptions = {
|
|
47
|
+
cwds: string[];
|
|
48
|
+
missingThresholdSeconds: number;
|
|
49
|
+
now?: Date;
|
|
50
|
+
onTamperDetected?: (entry: TamperLockEntry) => void;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export class HeartbeatTracker {
|
|
54
|
+
private sessions = new Map<string, SessionState>();
|
|
55
|
+
private hostId: string;
|
|
56
|
+
private cleanupAfterMs: number;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
options: { hostId?: string; cleanupAfterMs?: number } = {},
|
|
60
|
+
) {
|
|
61
|
+
this.hostId = options.hostId ?? hostname();
|
|
62
|
+
// Sessions with no heartbeat for this long are auto-removed (covers
|
|
63
|
+
// crashes that never sent SessionEnd). Default 12h.
|
|
64
|
+
this.cleanupAfterMs = options.cleanupAfterMs ?? 12 * 60 * 60 * 1000;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
recordHeartbeat(payload: HeartbeatPayload): HeartbeatResult {
|
|
68
|
+
const existing = this.sessions.get(payload.session_id);
|
|
69
|
+
const now = payload.ts;
|
|
70
|
+
|
|
71
|
+
if (payload.hook === "SessionStart") {
|
|
72
|
+
this.sessions.set(payload.session_id, {
|
|
73
|
+
cli: payload.cli,
|
|
74
|
+
cwd: payload.cwd,
|
|
75
|
+
started_at: now,
|
|
76
|
+
last_heartbeat: now,
|
|
77
|
+
hook_count: 1,
|
|
78
|
+
ended: false,
|
|
79
|
+
});
|
|
80
|
+
} else if (payload.hook === "SessionEnd") {
|
|
81
|
+
if (existing) {
|
|
82
|
+
existing.last_heartbeat = now;
|
|
83
|
+
existing.ended = true;
|
|
84
|
+
existing.ended_at = now;
|
|
85
|
+
existing.hook_count += 1;
|
|
86
|
+
} else {
|
|
87
|
+
// SessionEnd without prior SessionStart — register a closed session
|
|
88
|
+
// so the tamper-checker doesn't flag it later.
|
|
89
|
+
this.sessions.set(payload.session_id, {
|
|
90
|
+
cli: payload.cli,
|
|
91
|
+
cwd: payload.cwd,
|
|
92
|
+
started_at: now,
|
|
93
|
+
last_heartbeat: now,
|
|
94
|
+
hook_count: 1,
|
|
95
|
+
ended: true,
|
|
96
|
+
ended_at: now,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
if (existing) {
|
|
101
|
+
existing.last_heartbeat = now;
|
|
102
|
+
existing.hook_count += 1;
|
|
103
|
+
} else {
|
|
104
|
+
// Heartbeat from a session we never saw start (daemon was restarted
|
|
105
|
+
// mid-session). Register it as active.
|
|
106
|
+
this.sessions.set(payload.session_id, {
|
|
107
|
+
cli: payload.cli,
|
|
108
|
+
cwd: payload.cwd,
|
|
109
|
+
started_at: now,
|
|
110
|
+
last_heartbeat: now,
|
|
111
|
+
hook_count: 1,
|
|
112
|
+
ended: false,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const tamperLockActive = existsSync(
|
|
118
|
+
join(payload.cwd, ".context", TAMPER_LOCK_FILENAME),
|
|
119
|
+
);
|
|
120
|
+
return { recorded: true, tamper_lock_active: tamperLockActive };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getActiveSessions(): Array<[string, SessionState]> {
|
|
124
|
+
return Array.from(this.sessions.entries()).filter(
|
|
125
|
+
([, state]) => !state.ended,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
detectTamper(options: TamperCheckOptions): TamperLockEntry[] {
|
|
130
|
+
const now = options.now ?? new Date();
|
|
131
|
+
const thresholdMs = options.missingThresholdSeconds * 1000;
|
|
132
|
+
const flagged: TamperLockEntry[] = [];
|
|
133
|
+
|
|
134
|
+
for (const [sessionId, state] of this.sessions) {
|
|
135
|
+
// Cleanup sessions that have been silent forever — they crashed.
|
|
136
|
+
const lastMs = new Date(state.last_heartbeat).getTime();
|
|
137
|
+
if (Number.isFinite(lastMs) && now.getTime() - lastMs > this.cleanupAfterMs) {
|
|
138
|
+
this.sessions.delete(sessionId);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (state.ended) continue;
|
|
143
|
+
// Need at least 2 heartbeats for the "had-activity-then-silence"
|
|
144
|
+
// signal — a single SessionStart followed by silence may just be a
|
|
145
|
+
// user opening Claude and walking away.
|
|
146
|
+
if (state.hook_count < 2) continue;
|
|
147
|
+
|
|
148
|
+
const elapsedMs = now.getTime() - lastMs;
|
|
149
|
+
if (elapsedMs <= thresholdMs) continue;
|
|
150
|
+
|
|
151
|
+
const entry: TamperLockEntry = {
|
|
152
|
+
version: 1,
|
|
153
|
+
detected_at: now.toISOString(),
|
|
154
|
+
cli: state.cli,
|
|
155
|
+
session_id: sessionId,
|
|
156
|
+
hook_name: "any",
|
|
157
|
+
last_seen: state.last_heartbeat,
|
|
158
|
+
missing_seconds: Math.round(elapsedMs / 1000),
|
|
159
|
+
host_id: this.hostId,
|
|
160
|
+
cwd: state.cwd,
|
|
161
|
+
};
|
|
162
|
+
flagged.push(entry);
|
|
163
|
+
// Mark ended so we don't re-flag the same session every tick.
|
|
164
|
+
state.ended = true;
|
|
165
|
+
state.ended_at = now.toISOString();
|
|
166
|
+
options.onTamperDetected?.(entry);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return flagged;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// For tests:
|
|
173
|
+
_forceState(sessionId: string, state: SessionState): void {
|
|
174
|
+
this.sessions.set(sessionId, state);
|
|
175
|
+
}
|
|
176
|
+
_size(): number {
|
|
177
|
+
return this.sessions.size;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function writeTamperLock(cwd: string, entry: TamperLockEntry): string {
|
|
182
|
+
const dir = join(cwd, ".context");
|
|
183
|
+
const path = join(dir, TAMPER_LOCK_FILENAME);
|
|
184
|
+
writeFileSync(path, JSON.stringify(entry, null, 2) + "\n", "utf8");
|
|
185
|
+
return path;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function readTamperLock(cwd: string): TamperLockEntry | null {
|
|
189
|
+
const path = join(cwd, ".context", TAMPER_LOCK_FILENAME);
|
|
190
|
+
if (!existsSync(path)) return null;
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(readFileSync(path, "utf8")) as TamperLockEntry;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function removeTamperLock(cwd: string): boolean {
|
|
199
|
+
const path = join(cwd, ".context", TAMPER_LOCK_FILENAME);
|
|
200
|
+
if (!existsSync(path)) return false;
|
|
201
|
+
try {
|
|
202
|
+
unlinkSync(path);
|
|
203
|
+
return true;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function emitTamperAudit(
|
|
210
|
+
cwd: string,
|
|
211
|
+
entry: TamperLockEntry,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
await writeHostAuditEvent(cwd, {
|
|
214
|
+
event_type: "hook_tamper_detected",
|
|
215
|
+
timestamp: entry.detected_at,
|
|
216
|
+
host_id: entry.host_id,
|
|
217
|
+
cli: entry.cli,
|
|
218
|
+
session_id: entry.session_id,
|
|
219
|
+
hook_name: entry.hook_name,
|
|
220
|
+
last_seen: entry.last_seen,
|
|
221
|
+
missing_seconds: entry.missing_seconds,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
statSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
import { loadEnterpriseConfig } from "../core/config.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Phase 7 sync flow — host-events pusher.
|
|
14
|
+
*
|
|
15
|
+
* The daemon writes ungoverned-session and hook-tamper events to
|
|
16
|
+
* .context/audit/host-events-YYYY-MM-DD.jsonl. This pusher batches
|
|
17
|
+
* unpushed events and POSTs them to the cortex-web govern endpoints.
|
|
18
|
+
*
|
|
19
|
+
* State (.context/.cortex-host-events-cursor.json) tracks the last
|
|
20
|
+
* pushed timestamp per event_type so we don't double-push or skip.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const CURSOR_FILENAME = ".cortex-host-events-cursor.json";
|
|
24
|
+
|
|
25
|
+
type Cursor = {
|
|
26
|
+
ungoverned_last_ts?: string;
|
|
27
|
+
tamper_last_ts?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cursor format is `${ISO_TIMESTAMP}#${stable_id}` where stable_id is
|
|
32
|
+
* the pid for ungoverned events and the session_id for tamper events.
|
|
33
|
+
* The composite suffix breaks ties when two writers emit at the exact
|
|
34
|
+
* same millisecond (clock resolution / two daemon producers).
|
|
35
|
+
*
|
|
36
|
+
* For backward compatibility, a persisted value with no `#` is treated
|
|
37
|
+
* as `${ts}#~`: `~` sorts after every printable ASCII character we
|
|
38
|
+
* actually emit, so an old cursor still skips all events at-or-before
|
|
39
|
+
* its timestamp on the first read after upgrade.
|
|
40
|
+
*/
|
|
41
|
+
function readCursor(cwd: string): Cursor {
|
|
42
|
+
const path = join(cwd, ".context", CURSOR_FILENAME);
|
|
43
|
+
if (!existsSync(path)) return {};
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(path, "utf8")) as Cursor;
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeCursor(value: string | undefined): string | undefined {
|
|
52
|
+
if (!value) return undefined;
|
|
53
|
+
return value.includes("#") ? value : `${value}#~`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stableIdFor(evt: HostEvent): string {
|
|
57
|
+
if (evt.event_type === "ungoverned_ai_session_detected") {
|
|
58
|
+
const pid = evt.pid;
|
|
59
|
+
if (typeof pid === "number") return String(pid);
|
|
60
|
+
if (typeof pid === "string" && pid.length > 0) return pid;
|
|
61
|
+
return "0";
|
|
62
|
+
}
|
|
63
|
+
if (evt.event_type === "hook_tamper_detected") {
|
|
64
|
+
const sid = evt.session_id;
|
|
65
|
+
if (typeof sid === "string" && sid.length > 0) return sid;
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function compositeKey(evt: HostEvent): string | null {
|
|
72
|
+
const ts = eventTimestamp(evt);
|
|
73
|
+
if (!ts) return null;
|
|
74
|
+
return `${ts}#${stableIdFor(evt)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sortByTs<T extends HostEvent>(arr: T[]): T[] {
|
|
78
|
+
return arr.sort((a, b) => {
|
|
79
|
+
const ka = compositeKey(a) ?? "";
|
|
80
|
+
const kb = compositeKey(b) ?? "";
|
|
81
|
+
if (ka < kb) return -1;
|
|
82
|
+
if (ka > kb) return 1;
|
|
83
|
+
return 0;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writeCursor(cwd: string, cursor: Cursor): void {
|
|
88
|
+
const path = join(cwd, ".context", CURSOR_FILENAME);
|
|
89
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
90
|
+
writeFileSync(path, JSON.stringify(cursor, null, 2) + "\n", "utf8");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type HostEvent = Record<string, unknown> & { event_type?: string; timestamp?: string };
|
|
94
|
+
|
|
95
|
+
function listHostEventFiles(cwd: string): string[] {
|
|
96
|
+
const dir = join(cwd, ".context", "audit");
|
|
97
|
+
if (!existsSync(dir)) return [];
|
|
98
|
+
return readdirSync(dir)
|
|
99
|
+
.filter((name) => name.startsWith("host-events-") && name.endsWith(".jsonl"))
|
|
100
|
+
.sort()
|
|
101
|
+
.map((name) => join(dir, name));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readEventsFrom(file: string): HostEvent[] {
|
|
105
|
+
if (!existsSync(file)) return [];
|
|
106
|
+
const stat = statSync(file);
|
|
107
|
+
if (!stat.isFile()) return [];
|
|
108
|
+
const raw = readFileSync(file, "utf8");
|
|
109
|
+
const out: HostEvent[] = [];
|
|
110
|
+
for (const line of raw.split("\n")) {
|
|
111
|
+
if (!line.trim()) continue;
|
|
112
|
+
try {
|
|
113
|
+
out.push(JSON.parse(line) as HostEvent);
|
|
114
|
+
} catch {
|
|
115
|
+
// skip malformed lines
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function eventTimestamp(e: HostEvent): string | null {
|
|
122
|
+
const ts = e.timestamp ?? e.detected_at;
|
|
123
|
+
return typeof ts === "string" ? ts : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isAfterComposite(evt: HostEvent, cursor: string | undefined): boolean {
|
|
127
|
+
if (!cursor) return true;
|
|
128
|
+
const key = compositeKey(evt);
|
|
129
|
+
if (!key) return false;
|
|
130
|
+
return key > cursor;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type PushOutcome = {
|
|
134
|
+
ungoverned_pushed: number;
|
|
135
|
+
tamper_pushed: number;
|
|
136
|
+
errors: string[];
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export async function pushHostEvents(cwd: string): Promise<PushOutcome> {
|
|
140
|
+
const outcome: PushOutcome = {
|
|
141
|
+
ungoverned_pushed: 0,
|
|
142
|
+
tamper_pushed: 0,
|
|
143
|
+
errors: [],
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const config = loadEnterpriseConfig(join(cwd, ".context"));
|
|
147
|
+
const apiKey = config.enterprise.api_key.trim();
|
|
148
|
+
const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
|
|
149
|
+
if (!apiKey || !baseUrl) {
|
|
150
|
+
outcome.errors.push("enterprise not configured");
|
|
151
|
+
return outcome;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const cursor = readCursor(cwd);
|
|
155
|
+
const ungovernedCursor = normalizeCursor(cursor.ungoverned_last_ts);
|
|
156
|
+
const tamperCursor = normalizeCursor(cursor.tamper_last_ts);
|
|
157
|
+
const ungoverned: HostEvent[] = [];
|
|
158
|
+
const tamper: HostEvent[] = [];
|
|
159
|
+
|
|
160
|
+
for (const file of listHostEventFiles(cwd)) {
|
|
161
|
+
for (const evt of readEventsFrom(file)) {
|
|
162
|
+
const ts = eventTimestamp(evt);
|
|
163
|
+
if (!ts) continue;
|
|
164
|
+
if (evt.event_type === "ungoverned_ai_session_detected" && isAfterComposite(evt, ungovernedCursor)) {
|
|
165
|
+
ungoverned.push(evt);
|
|
166
|
+
} else if (evt.event_type === "hook_tamper_detected" && isAfterComposite(evt, tamperCursor)) {
|
|
167
|
+
tamper.push(evt);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
sortByTs(ungoverned);
|
|
173
|
+
sortByTs(tamper);
|
|
174
|
+
|
|
175
|
+
if (ungoverned.length > 0) {
|
|
176
|
+
const result = await pushBatch(
|
|
177
|
+
`${baseUrl.replace(/\/$/, "")}/api/v1/govern/ungoverned`,
|
|
178
|
+
apiKey,
|
|
179
|
+
{
|
|
180
|
+
events: ungoverned.map(toUngovernedPayload),
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
if (result.ok) {
|
|
184
|
+
outcome.ungoverned_pushed = ungoverned.length;
|
|
185
|
+
const last = ungoverned[ungoverned.length - 1];
|
|
186
|
+
cursor.ungoverned_last_ts = compositeKey(last) ?? cursor.ungoverned_last_ts;
|
|
187
|
+
} else {
|
|
188
|
+
outcome.errors.push(`ungoverned: ${result.error}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (tamper.length > 0) {
|
|
193
|
+
const result = await pushBatch(
|
|
194
|
+
`${baseUrl.replace(/\/$/, "")}/api/v1/govern/tamper`,
|
|
195
|
+
apiKey,
|
|
196
|
+
{
|
|
197
|
+
events: tamper.map(toTamperPayload),
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
if (result.ok) {
|
|
201
|
+
outcome.tamper_pushed = tamper.length;
|
|
202
|
+
const last = tamper[tamper.length - 1];
|
|
203
|
+
cursor.tamper_last_ts = compositeKey(last) ?? cursor.tamper_last_ts;
|
|
204
|
+
} else {
|
|
205
|
+
outcome.errors.push(`tamper: ${result.error}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (outcome.ungoverned_pushed > 0 || outcome.tamper_pushed > 0) {
|
|
210
|
+
writeCursor(cwd, cursor);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return outcome;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function toUngovernedPayload(e: HostEvent): Record<string, unknown> {
|
|
217
|
+
return {
|
|
218
|
+
detected_at: e.timestamp,
|
|
219
|
+
host_id: e.host_id,
|
|
220
|
+
cli: e.cli,
|
|
221
|
+
binary_path: e.binary,
|
|
222
|
+
args: e.args,
|
|
223
|
+
sys_user: e.user,
|
|
224
|
+
parent_pid: e.ppid,
|
|
225
|
+
pid: e.pid,
|
|
226
|
+
action_taken: e.action ?? "logged",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function toTamperPayload(e: HostEvent): Record<string, unknown> {
|
|
231
|
+
return {
|
|
232
|
+
detected_at: e.timestamp,
|
|
233
|
+
host_id: e.host_id,
|
|
234
|
+
cli: e.cli,
|
|
235
|
+
hook_name: e.hook_name ?? "any",
|
|
236
|
+
session_id: e.session_id,
|
|
237
|
+
last_seen: e.last_seen ?? null,
|
|
238
|
+
missing_seconds: e.missing_seconds,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function pushBatch(
|
|
243
|
+
url: string,
|
|
244
|
+
apiKey: string,
|
|
245
|
+
payload: unknown,
|
|
246
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
247
|
+
try {
|
|
248
|
+
const res = await fetch(url, {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: {
|
|
251
|
+
"Content-Type": "application/json",
|
|
252
|
+
Authorization: `Bearer ${apiKey}`,
|
|
253
|
+
},
|
|
254
|
+
body: JSON.stringify(payload),
|
|
255
|
+
});
|
|
256
|
+
if (!res.ok) {
|
|
257
|
+
return { ok: false, error: `HTTP ${res.status} ${res.statusText}` };
|
|
258
|
+
}
|
|
259
|
+
return { ok: true };
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export type PusherHandle = {
|
|
266
|
+
stop(): void;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export function startHostEventsPusher(cwd: string, intervalMs: number): PusherHandle {
|
|
270
|
+
const tick = () => {
|
|
271
|
+
void pushHostEvents(cwd).catch((err) => {
|
|
272
|
+
process.stderr.write(
|
|
273
|
+
`[cortex-daemon] host-events push failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
void Promise.resolve().then(tick);
|
|
278
|
+
const handle = setInterval(tick, intervalMs);
|
|
279
|
+
if (typeof handle.unref === "function") handle.unref();
|
|
280
|
+
return {
|
|
281
|
+
stop() {
|
|
282
|
+
clearInterval(handle);
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|