@danielblomma/cortex-mcp 2.4.0 → 2.4.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.
- package/CHANGELOG.md +226 -0
- package/README.md +24 -3
- package/bin/cli/arguments.mjs +60 -0
- package/bin/cli/context-passthrough.mjs +129 -0
- package/bin/cli/daemon.mjs +157 -0
- package/bin/cli/enterprise.mjs +516 -0
- package/bin/cli/help.mjs +88 -0
- package/bin/cli/hooks.mjs +199 -0
- package/bin/cli/mcp-command.mjs +87 -0
- package/bin/cli/paths.mjs +14 -0
- package/bin/cli/process.mjs +49 -0
- package/bin/cli/project-commands.mjs +237 -0
- package/bin/cli/project-runtime.mjs +34 -0
- package/bin/cli/query-command.mjs +18 -0
- package/bin/cli/router.mjs +66 -0
- package/bin/cli/run-command.mjs +44 -0
- package/bin/cli/scaffold-ownership.mjs +936 -0
- package/bin/cli/scaffold.mjs +687 -0
- package/bin/cli/stage-command.mjs +9 -0
- package/bin/cli/telemetry-command.mjs +18 -0
- package/bin/cli/trusted-runtime.mjs +18 -0
- package/bin/cortex.mjs +12 -1607
- package/bin/daemon-control.mjs +162 -0
- package/mcp-registry-submission.json +1 -1
- package/package.json +8 -3
- package/scaffold/mcp/dist/.cortex-build-hash +1 -0
- package/scaffold/mcp/dist/cli/enterprise-setup.js +134 -0
- package/scaffold/mcp/dist/cli/govern.js +937 -0
- package/scaffold/mcp/dist/cli/query.js +409 -0
- package/scaffold/mcp/dist/cli/run.js +295 -0
- package/scaffold/mcp/dist/cli/stage.js +308 -0
- package/scaffold/mcp/dist/cli/telemetry-test.js +141 -0
- package/scaffold/mcp/dist/cli/ungoverned-detector.js +133 -0
- package/scaffold/mcp/dist/contextEntities.js +282 -0
- package/scaffold/mcp/dist/core/audit/query.js +72 -0
- package/scaffold/mcp/dist/core/audit/writer.js +28 -0
- package/scaffold/mcp/dist/core/config.js +235 -0
- package/scaffold/mcp/dist/core/enterprise-host-identity.js +193 -0
- package/scaffold/mcp/dist/core/enterprise-identity.js +18 -0
- package/scaffold/mcp/dist/core/enterprise-rotation.js +158 -0
- package/scaffold/mcp/dist/core/govern-paths.js +21 -0
- package/scaffold/mcp/dist/core/index.js +14 -0
- package/scaffold/mcp/dist/core/license.js +278 -0
- package/scaffold/mcp/dist/core/policy/enforce.js +66 -0
- package/scaffold/mcp/dist/core/policy/injection.js +172 -0
- package/scaffold/mcp/dist/core/policy/store.js +179 -0
- package/scaffold/mcp/dist/core/rbac/check.js +30 -0
- package/scaffold/mcp/dist/core/secure-endpoint.js +25 -0
- package/scaffold/mcp/dist/core/telemetry/collector.js +285 -0
- package/scaffold/mcp/dist/core/telemetry/state-dir.js +31 -0
- package/scaffold/mcp/dist/core/validators/builtins.js +632 -0
- package/scaffold/mcp/dist/core/validators/config.js +44 -0
- package/scaffold/mcp/dist/core/validators/engine.js +120 -0
- package/scaffold/mcp/dist/core/validators/evaluators/code_comments.js +236 -0
- package/scaffold/mcp/dist/core/validators/evaluators/regex.js +116 -0
- package/scaffold/mcp/dist/core/workflow/artifact-io.js +103 -0
- package/scaffold/mcp/dist/core/workflow/capabilities.js +88 -0
- package/scaffold/mcp/dist/core/workflow/default-workflows.js +102 -0
- package/scaffold/mcp/dist/core/workflow/enforcement.js +164 -0
- package/scaffold/mcp/dist/core/workflow/envelope.js +132 -0
- package/scaffold/mcp/dist/core/workflow/index.js +11 -0
- package/scaffold/mcp/dist/core/workflow/mcp-tools.js +175 -0
- package/scaffold/mcp/dist/core/workflow/resolution.js +69 -0
- package/scaffold/mcp/dist/core/workflow/run-lifecycle.js +123 -0
- package/scaffold/mcp/dist/core/workflow/schemas.js +141 -0
- package/scaffold/mcp/dist/core/workflow/synced-capability-registry.js +60 -0
- package/scaffold/mcp/dist/core/workflow/synced-registry.js +60 -0
- package/scaffold/mcp/dist/daemon/capability-sync-checker.js +242 -0
- package/scaffold/mcp/dist/daemon/client.js +130 -0
- package/scaffold/mcp/dist/daemon/egress-proxy.js +288 -0
- package/scaffold/mcp/dist/daemon/global-host-events.js +222 -0
- package/scaffold/mcp/dist/daemon/heartbeat-pusher.js +116 -0
- package/scaffold/mcp/dist/daemon/heartbeat-tracker.js +165 -0
- package/scaffold/mcp/dist/daemon/host-events-pusher.js +249 -0
- package/scaffold/mcp/dist/daemon/main.js +449 -0
- package/scaffold/mcp/dist/daemon/paths.js +36 -0
- package/scaffold/mcp/dist/daemon/project-service-registry.js +78 -0
- package/scaffold/mcp/dist/daemon/protocol.js +8 -0
- package/scaffold/mcp/dist/daemon/server.js +180 -0
- package/scaffold/mcp/dist/daemon/skill-sync-checker.js +557 -0
- package/scaffold/mcp/dist/daemon/sync-checker.js +165 -0
- package/scaffold/mcp/dist/daemon/ungoverned-scanner.js +136 -0
- package/scaffold/mcp/dist/daemon/workflow-sync-checker.js +249 -0
- package/scaffold/mcp/dist/defaults.js +6 -0
- package/scaffold/mcp/dist/embed.js +627 -0
- package/scaffold/mcp/dist/embedScheduler.js +479 -0
- package/scaffold/mcp/dist/embeddings.js +167 -0
- package/scaffold/mcp/dist/enterprise/audit/push.js +66 -0
- package/scaffold/mcp/dist/enterprise/index.js +327 -0
- package/scaffold/mcp/dist/enterprise/model/deploy.js +27 -0
- package/scaffold/mcp/dist/enterprise/policy/sync.js +129 -0
- package/scaffold/mcp/dist/enterprise/privacy/boundary.js +184 -0
- package/scaffold/mcp/dist/enterprise/reviews/changed-files.js +33 -0
- package/scaffold/mcp/dist/enterprise/reviews/pattern-context.js +202 -0
- package/scaffold/mcp/dist/enterprise/reviews/policy-selection.js +46 -0
- package/scaffold/mcp/dist/enterprise/reviews/push.js +102 -0
- package/scaffold/mcp/dist/enterprise/reviews/trust-state.js +186 -0
- package/scaffold/mcp/dist/enterprise/telemetry/sync.js +57 -0
- package/scaffold/mcp/dist/enterprise/tools/enterprise.js +826 -0
- package/scaffold/mcp/dist/enterprise/tools/harness.js +40 -0
- package/scaffold/mcp/dist/enterprise/tools/walk.js +73 -0
- package/scaffold/mcp/dist/enterprise/violations/push.js +77 -0
- package/scaffold/mcp/dist/enterprise/workflow/push.js +44 -0
- package/scaffold/mcp/dist/enterprise/workflow/state.js +329 -0
- package/scaffold/mcp/dist/frontmatter.js +33 -0
- package/scaffold/mcp/dist/graph.js +769 -0
- package/scaffold/mcp/dist/graphCsv.js +55 -0
- package/scaffold/mcp/dist/graphMetrics.js +8 -0
- package/scaffold/mcp/dist/hooks/permission-request.js +89 -0
- package/scaffold/mcp/dist/hooks/post-tool-use.js +105 -0
- package/scaffold/mcp/dist/hooks/pre-compact.js +29 -0
- package/scaffold/mcp/dist/hooks/pre-tool-use.js +78 -0
- package/scaffold/mcp/dist/hooks/session-end.js +43 -0
- package/scaffold/mcp/dist/hooks/session-start.js +41 -0
- package/scaffold/mcp/dist/hooks/shared.js +194 -0
- package/scaffold/mcp/dist/hooks/stop.js +28 -0
- package/scaffold/mcp/dist/hooks/user-prompt-submit.js +33 -0
- package/scaffold/mcp/dist/impactPresentation.js +137 -0
- package/scaffold/mcp/dist/impactRanking.js +191 -0
- package/scaffold/mcp/dist/impactResponse.js +30 -0
- package/scaffold/mcp/dist/impactResults.js +105 -0
- package/scaffold/mcp/dist/impactSeed.js +20 -0
- package/scaffold/mcp/dist/impactTraversal.js +64 -0
- package/scaffold/mcp/dist/jsonl.js +77 -0
- package/scaffold/mcp/dist/loadGraph.js +759 -0
- package/scaffold/mcp/dist/lruCache.js +38 -0
- package/scaffold/mcp/dist/paths.js +97 -0
- package/scaffold/mcp/dist/patternEvidence.js +272 -0
- package/scaffold/mcp/dist/plugin.js +81 -0
- package/scaffold/mcp/dist/presets.js +78 -0
- package/scaffold/mcp/dist/relatedResponse.js +18 -0
- package/scaffold/mcp/dist/relatedTraversal.js +78 -0
- package/scaffold/mcp/dist/rules.js +23 -0
- package/scaffold/mcp/dist/search.js +212 -0
- package/scaffold/mcp/dist/searchCore.js +457 -0
- package/scaffold/mcp/dist/searchResults.js +230 -0
- package/scaffold/mcp/dist/server.js +317 -0
- package/scaffold/mcp/dist/types.js +1 -0
- package/scaffold/mcp/package-lock.json +321 -200
- package/scaffold/mcp/package.json +15 -6
- package/scaffold/mcp/src/cli/enterprise-setup.ts +82 -8
- package/scaffold/mcp/src/cli/govern.ts +137 -52
- package/scaffold/mcp/src/cli/run.ts +53 -19
- package/scaffold/mcp/src/cli/stage.ts +8 -3
- package/scaffold/mcp/src/core/config.ts +9 -2
- package/scaffold/mcp/src/core/enterprise-host-identity.ts +259 -0
- package/scaffold/mcp/src/core/enterprise-identity.ts +20 -0
- package/scaffold/mcp/src/core/enterprise-rotation.ts +185 -0
- package/scaffold/mcp/src/core/govern-paths.ts +30 -0
- package/scaffold/mcp/src/core/license.ts +186 -17
- package/scaffold/mcp/src/core/secure-endpoint.ts +23 -0
- package/scaffold/mcp/src/core/workflow/enforcement.ts +8 -1
- package/scaffold/mcp/src/core/workflow/mcp-tools.ts +3 -0
- package/scaffold/mcp/src/core/workflow/resolution.ts +9 -1
- package/scaffold/mcp/src/core/workflow/synced-capability-registry.ts +15 -0
- package/scaffold/mcp/src/core/workflow/synced-registry.ts +15 -0
- package/scaffold/mcp/src/daemon/capability-sync-checker.ts +38 -2
- package/scaffold/mcp/src/daemon/egress-proxy.ts +14 -8
- package/scaffold/mcp/src/daemon/global-host-events.ts +288 -0
- package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +4 -0
- package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +2 -0
- package/scaffold/mcp/src/daemon/host-events-pusher.ts +5 -0
- package/scaffold/mcp/src/daemon/main.ts +99 -25
- package/scaffold/mcp/src/daemon/project-service-registry.ts +107 -0
- package/scaffold/mcp/src/daemon/skill-sync-checker.ts +368 -31
- package/scaffold/mcp/src/daemon/sync-checker.ts +4 -0
- package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +57 -25
- package/scaffold/mcp/src/daemon/workflow-sync-checker.ts +41 -2
- package/scaffold/mcp/src/enterprise/audit/push.ts +8 -0
- package/scaffold/mcp/src/enterprise/policy/sync.ts +12 -0
- package/scaffold/mcp/src/enterprise/reviews/push.ts +9 -0
- package/scaffold/mcp/src/enterprise/reviews/trust-state.ts +3 -1
- package/scaffold/mcp/src/enterprise/telemetry/sync.ts +9 -0
- package/scaffold/mcp/src/enterprise/violations/push.ts +9 -0
- package/scaffold/mcp/src/enterprise/workflow/push.ts +7 -0
- package/scaffold/mcp/src/plugin.ts +20 -0
- package/scaffold/mcp/tests/copilot-shim.test.mjs +19 -1
- package/scaffold/mcp/tests/egress-proxy.test.mjs +37 -0
- package/scaffold/mcp/tests/enterprise-identity-sync.test.mjs +401 -0
- package/scaffold/mcp/tests/enterprise-setup.test.mjs +189 -0
- package/scaffold/mcp/tests/global-host-events.test.mjs +81 -0
- package/scaffold/mcp/tests/govern-install.test.mjs +95 -2
- package/scaffold/mcp/tests/govern-repair.test.mjs +20 -3
- package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +26 -0
- package/scaffold/mcp/tests/license.test.mjs +367 -0
- package/scaffold/mcp/tests/project-service-registry.test.mjs +172 -0
- package/scaffold/mcp/tests/review-trust-contract.test.mjs +18 -0
- package/scaffold/mcp/tests/secure-enterprise-endpoint.test.mjs +46 -0
- package/scaffold/mcp/tests/skill-sync-checker.test.mjs +446 -2
- package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +104 -22
- package/scaffold/mcp/tests/workflow-cli.test.mjs +14 -1
- package/scaffold/mcp/tests/workflow-synced-capabilities.test.mjs +37 -0
- package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +40 -1
- package/scaffold/ownership/baseline-v2.4.1.json +22 -0
- package/scaffold/ownership/current.json +4 -0
- package/scaffold/ownership/v1.json +456 -0
- package/scaffold/scripts/ingest-parsers.mjs +1 -387
- package/scaffold/scripts/ingest-worker.mjs +4 -1
- package/scaffold/scripts/ingest.mjs +5 -3899
- package/scaffold/scripts/lib/ingest/arguments.mjs +78 -0
- package/scaffold/scripts/lib/ingest/chunks.mjs +212 -0
- package/scaffold/scripts/lib/ingest/config.mjs +120 -0
- package/scaffold/scripts/lib/ingest/constants.mjs +222 -0
- package/scaffold/scripts/lib/ingest/files.mjs +387 -0
- package/scaffold/scripts/lib/ingest/incremental-state.mjs +131 -0
- package/scaffold/scripts/lib/ingest/io.mjs +78 -0
- package/scaffold/scripts/lib/ingest/main.mjs +76 -0
- package/scaffold/scripts/lib/ingest/parser-composition.mjs +140 -0
- package/scaffold/scripts/lib/ingest/parser-registry.mjs +387 -0
- package/scaffold/scripts/lib/ingest/pipeline-stages.mjs +1625 -0
- package/scaffold/scripts/lib/ingest/projects.mjs +272 -0
- package/scaffold/scripts/lib/ingest/relations.mjs +860 -0
- package/scaffold/scripts/lib/ingest/runtime-paths.mjs +11 -0
- package/scaffold/scripts/lib/ingest/workers.mjs +264 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { hostname, platform, release } from "node:os";
|
|
4
|
+
import { loadEnterpriseConfig } from "../core/config.js";
|
|
5
|
+
import { isAllowedEnterpriseEndpoint } from "../core/secure-endpoint.js";
|
|
6
|
+
/**
|
|
7
|
+
* Govern host heartbeat — fills the host_enrollment table on cortex-web.
|
|
8
|
+
*
|
|
9
|
+
* Reads .context/govern.local.json + enterprise.yml + OS info, builds a
|
|
10
|
+
* canonical payload matching governHeartbeatSchema on the server side,
|
|
11
|
+
* and POSTs it to /api/v1/govern/heartbeat. Without this, the dashboard
|
|
12
|
+
* at /dashboard/govern shows zero hosts forever.
|
|
13
|
+
*
|
|
14
|
+
* Periodic — default 5 min, same cadence as host-events-pusher.
|
|
15
|
+
*/
|
|
16
|
+
const TIER_BY_CLI = {
|
|
17
|
+
claude: "prevent",
|
|
18
|
+
codex: "prevent",
|
|
19
|
+
copilot: "wrap",
|
|
20
|
+
};
|
|
21
|
+
function readLocalGovernState(cwd) {
|
|
22
|
+
const p = join(cwd, ".context", "govern.local.json");
|
|
23
|
+
if (!existsSync(p))
|
|
24
|
+
return {};
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function mapOs(plat) {
|
|
33
|
+
if (plat === "darwin")
|
|
34
|
+
return "darwin";
|
|
35
|
+
if (plat === "linux")
|
|
36
|
+
return "linux";
|
|
37
|
+
if (plat === "win32")
|
|
38
|
+
return "windows";
|
|
39
|
+
return "linux";
|
|
40
|
+
}
|
|
41
|
+
export function buildHeartbeatPayload(cwd, hostId) {
|
|
42
|
+
const config = loadEnterpriseConfig(join(cwd, ".context"));
|
|
43
|
+
const state = readLocalGovernState(cwd);
|
|
44
|
+
const installs = state.installs ?? {};
|
|
45
|
+
const now = new Date().toISOString();
|
|
46
|
+
const aiClisDetected = Object.entries(installs).map(([name, info]) => ({
|
|
47
|
+
name,
|
|
48
|
+
tier: TIER_BY_CLI[name] ?? "off",
|
|
49
|
+
version: info.version,
|
|
50
|
+
last_seen: now,
|
|
51
|
+
}));
|
|
52
|
+
let governMode = "off";
|
|
53
|
+
for (const inst of Object.values(installs)) {
|
|
54
|
+
if (inst.mode === "enforced") {
|
|
55
|
+
governMode = "enforced";
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
if (inst.mode === "advisory" && governMode === "off")
|
|
59
|
+
governMode = "advisory";
|
|
60
|
+
}
|
|
61
|
+
const configVersion = Object.values(installs)[0]?.version ?? null;
|
|
62
|
+
return {
|
|
63
|
+
host_id: hostId ?? hostname(),
|
|
64
|
+
os: mapOs(platform()),
|
|
65
|
+
os_version: release(),
|
|
66
|
+
govern_mode: governMode,
|
|
67
|
+
active_frameworks: config.compliance.frameworks,
|
|
68
|
+
config_version: configVersion,
|
|
69
|
+
ai_clis_detected: aiClisDetected,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export async function pushHeartbeat(cwd) {
|
|
73
|
+
const config = loadEnterpriseConfig(join(cwd, ".context"));
|
|
74
|
+
const apiKey = config.enterprise.api_key.trim();
|
|
75
|
+
const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
|
|
76
|
+
if (!apiKey || !baseUrl) {
|
|
77
|
+
return { ok: false, error: "enterprise not configured" };
|
|
78
|
+
}
|
|
79
|
+
if (!isAllowedEnterpriseEndpoint(baseUrl)) {
|
|
80
|
+
return { ok: false, error: "insecure or invalid enterprise endpoint" };
|
|
81
|
+
}
|
|
82
|
+
const payload = buildHeartbeatPayload(cwd);
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/v1/govern/heartbeat`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
Authorization: `Bearer ${apiKey}`,
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify(payload),
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
return { ok: false, error: `HTTP ${res.status} ${res.statusText}` };
|
|
94
|
+
}
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function startHeartbeatPusher(cwd, intervalMs) {
|
|
102
|
+
const tick = () => {
|
|
103
|
+
void pushHeartbeat(cwd).catch((err) => {
|
|
104
|
+
process.stderr.write(`[cortex-daemon] heartbeat push failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
void Promise.resolve().then(tick);
|
|
108
|
+
const handle = setInterval(tick, intervalMs);
|
|
109
|
+
if (typeof handle.unref === "function")
|
|
110
|
+
handle.unref();
|
|
111
|
+
return {
|
|
112
|
+
stop() {
|
|
113
|
+
clearInterval(handle);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { writeFileSync, existsSync, unlinkSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { writeHostAuditEvent } from "./ungoverned-scanner.js";
|
|
5
|
+
export const TAMPER_LOCK_FILENAME = ".cortex-tamper.lock";
|
|
6
|
+
export class HeartbeatTracker {
|
|
7
|
+
sessions = new Map();
|
|
8
|
+
hostId;
|
|
9
|
+
cleanupAfterMs;
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.hostId = options.hostId ?? hostname();
|
|
12
|
+
// Sessions with no heartbeat for this long are auto-removed (covers
|
|
13
|
+
// crashes that never sent SessionEnd). Default 12h.
|
|
14
|
+
this.cleanupAfterMs = options.cleanupAfterMs ?? 12 * 60 * 60 * 1000;
|
|
15
|
+
}
|
|
16
|
+
recordHeartbeat(payload) {
|
|
17
|
+
const existing = this.sessions.get(payload.session_id);
|
|
18
|
+
const now = payload.ts;
|
|
19
|
+
if (payload.hook === "SessionStart") {
|
|
20
|
+
this.sessions.set(payload.session_id, {
|
|
21
|
+
cli: payload.cli,
|
|
22
|
+
cwd: payload.cwd,
|
|
23
|
+
started_at: now,
|
|
24
|
+
last_heartbeat: now,
|
|
25
|
+
hook_count: 1,
|
|
26
|
+
ended: false,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
else if (payload.hook === "SessionEnd") {
|
|
30
|
+
if (existing) {
|
|
31
|
+
existing.last_heartbeat = now;
|
|
32
|
+
existing.ended = true;
|
|
33
|
+
existing.ended_at = now;
|
|
34
|
+
existing.hook_count += 1;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
// SessionEnd without prior SessionStart — register a closed session
|
|
38
|
+
// so the tamper-checker doesn't flag it later.
|
|
39
|
+
this.sessions.set(payload.session_id, {
|
|
40
|
+
cli: payload.cli,
|
|
41
|
+
cwd: payload.cwd,
|
|
42
|
+
started_at: now,
|
|
43
|
+
last_heartbeat: now,
|
|
44
|
+
hook_count: 1,
|
|
45
|
+
ended: true,
|
|
46
|
+
ended_at: now,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
if (existing) {
|
|
52
|
+
existing.last_heartbeat = now;
|
|
53
|
+
existing.hook_count += 1;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// Heartbeat from a session we never saw start (daemon was restarted
|
|
57
|
+
// mid-session). Register it as active.
|
|
58
|
+
this.sessions.set(payload.session_id, {
|
|
59
|
+
cli: payload.cli,
|
|
60
|
+
cwd: payload.cwd,
|
|
61
|
+
started_at: now,
|
|
62
|
+
last_heartbeat: now,
|
|
63
|
+
hook_count: 1,
|
|
64
|
+
ended: false,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const tamperLockActive = existsSync(join(payload.cwd, ".context", TAMPER_LOCK_FILENAME));
|
|
69
|
+
return { recorded: true, tamper_lock_active: tamperLockActive };
|
|
70
|
+
}
|
|
71
|
+
getActiveSessions() {
|
|
72
|
+
return Array.from(this.sessions.entries()).filter(([, state]) => !state.ended);
|
|
73
|
+
}
|
|
74
|
+
detectTamper(options) {
|
|
75
|
+
const now = options.now ?? new Date();
|
|
76
|
+
const thresholdMs = options.missingThresholdSeconds * 1000;
|
|
77
|
+
const flagged = [];
|
|
78
|
+
const allowedCwds = new Set(options.cwds);
|
|
79
|
+
for (const [sessionId, state] of this.sessions) {
|
|
80
|
+
// Cleanup sessions that have been silent forever — they crashed.
|
|
81
|
+
const lastMs = new Date(state.last_heartbeat).getTime();
|
|
82
|
+
if (Number.isFinite(lastMs) && now.getTime() - lastMs > this.cleanupAfterMs) {
|
|
83
|
+
this.sessions.delete(sessionId);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (state.ended)
|
|
87
|
+
continue;
|
|
88
|
+
if (!allowedCwds.has(state.cwd))
|
|
89
|
+
continue;
|
|
90
|
+
// Need at least 2 heartbeats for the "had-activity-then-silence"
|
|
91
|
+
// signal — a single SessionStart followed by silence may just be a
|
|
92
|
+
// user opening Claude and walking away.
|
|
93
|
+
if (state.hook_count < 2)
|
|
94
|
+
continue;
|
|
95
|
+
const elapsedMs = now.getTime() - lastMs;
|
|
96
|
+
if (elapsedMs <= thresholdMs)
|
|
97
|
+
continue;
|
|
98
|
+
const entry = {
|
|
99
|
+
version: 1,
|
|
100
|
+
detected_at: now.toISOString(),
|
|
101
|
+
cli: state.cli,
|
|
102
|
+
session_id: sessionId,
|
|
103
|
+
hook_name: "any",
|
|
104
|
+
last_seen: state.last_heartbeat,
|
|
105
|
+
missing_seconds: Math.round(elapsedMs / 1000),
|
|
106
|
+
host_id: this.hostId,
|
|
107
|
+
cwd: state.cwd,
|
|
108
|
+
};
|
|
109
|
+
flagged.push(entry);
|
|
110
|
+
// Mark ended so we don't re-flag the same session every tick.
|
|
111
|
+
state.ended = true;
|
|
112
|
+
state.ended_at = now.toISOString();
|
|
113
|
+
options.onTamperDetected?.(entry);
|
|
114
|
+
}
|
|
115
|
+
return flagged;
|
|
116
|
+
}
|
|
117
|
+
// For tests:
|
|
118
|
+
_forceState(sessionId, state) {
|
|
119
|
+
this.sessions.set(sessionId, state);
|
|
120
|
+
}
|
|
121
|
+
_size() {
|
|
122
|
+
return this.sessions.size;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function writeTamperLock(cwd, entry) {
|
|
126
|
+
const dir = join(cwd, ".context");
|
|
127
|
+
const path = join(dir, TAMPER_LOCK_FILENAME);
|
|
128
|
+
writeFileSync(path, JSON.stringify(entry, null, 2) + "\n", "utf8");
|
|
129
|
+
return path;
|
|
130
|
+
}
|
|
131
|
+
export function readTamperLock(cwd) {
|
|
132
|
+
const path = join(cwd, ".context", TAMPER_LOCK_FILENAME);
|
|
133
|
+
if (!existsSync(path))
|
|
134
|
+
return null;
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function removeTamperLock(cwd) {
|
|
143
|
+
const path = join(cwd, ".context", TAMPER_LOCK_FILENAME);
|
|
144
|
+
if (!existsSync(path))
|
|
145
|
+
return false;
|
|
146
|
+
try {
|
|
147
|
+
unlinkSync(path);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export async function emitTamperAudit(cwd, entry) {
|
|
155
|
+
await writeHostAuditEvent(cwd, {
|
|
156
|
+
event_type: "hook_tamper_detected",
|
|
157
|
+
timestamp: entry.detected_at,
|
|
158
|
+
host_id: entry.host_id,
|
|
159
|
+
cli: entry.cli,
|
|
160
|
+
session_id: entry.session_id,
|
|
161
|
+
hook_name: entry.hook_name,
|
|
162
|
+
last_seen: entry.last_seen,
|
|
163
|
+
missing_seconds: entry.missing_seconds,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync, statSync, } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { loadEnterpriseConfig } from "../core/config.js";
|
|
4
|
+
import { isAllowedEnterpriseEndpoint } from "../core/secure-endpoint.js";
|
|
5
|
+
/**
|
|
6
|
+
* Phase 7 sync flow — host-events pusher.
|
|
7
|
+
*
|
|
8
|
+
* The daemon writes ungoverned-session and hook-tamper events to
|
|
9
|
+
* .context/audit/host-events-YYYY-MM-DD.jsonl. This pusher batches
|
|
10
|
+
* unpushed events and POSTs them to the cortex-web govern endpoints.
|
|
11
|
+
*
|
|
12
|
+
* State (.context/.cortex-host-events-cursor.json) tracks the last
|
|
13
|
+
* pushed timestamp per event_type so we don't double-push or skip.
|
|
14
|
+
*/
|
|
15
|
+
const CURSOR_FILENAME = ".cortex-host-events-cursor.json";
|
|
16
|
+
/**
|
|
17
|
+
* Cursor format is `${ISO_TIMESTAMP}#${stable_id}` where stable_id is
|
|
18
|
+
* the pid for ungoverned events and the session_id for tamper events.
|
|
19
|
+
* The composite suffix breaks ties when two writers emit at the exact
|
|
20
|
+
* same millisecond (clock resolution / two daemon producers).
|
|
21
|
+
*
|
|
22
|
+
* For backward compatibility, a persisted value with no `#` is treated
|
|
23
|
+
* as `${ts}#~`: `~` sorts after every printable ASCII character we
|
|
24
|
+
* actually emit, so an old cursor still skips all events at-or-before
|
|
25
|
+
* its timestamp on the first read after upgrade.
|
|
26
|
+
*/
|
|
27
|
+
function readCursor(cwd) {
|
|
28
|
+
const path = join(cwd, ".context", CURSOR_FILENAME);
|
|
29
|
+
if (!existsSync(path))
|
|
30
|
+
return {};
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function normalizeCursor(value) {
|
|
39
|
+
if (!value)
|
|
40
|
+
return undefined;
|
|
41
|
+
return value.includes("#") ? value : `${value}#~`;
|
|
42
|
+
}
|
|
43
|
+
function stableIdFor(evt) {
|
|
44
|
+
if (evt.event_type === "ungoverned_ai_session_detected") {
|
|
45
|
+
const pid = evt.pid;
|
|
46
|
+
if (typeof pid === "number")
|
|
47
|
+
return String(pid);
|
|
48
|
+
if (typeof pid === "string" && pid.length > 0)
|
|
49
|
+
return pid;
|
|
50
|
+
return "0";
|
|
51
|
+
}
|
|
52
|
+
if (evt.event_type === "hook_tamper_detected") {
|
|
53
|
+
const sid = evt.session_id;
|
|
54
|
+
if (typeof sid === "string" && sid.length > 0)
|
|
55
|
+
return sid;
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
function compositeKey(evt) {
|
|
61
|
+
const ts = eventTimestamp(evt);
|
|
62
|
+
if (!ts)
|
|
63
|
+
return null;
|
|
64
|
+
return `${ts}#${stableIdFor(evt)}`;
|
|
65
|
+
}
|
|
66
|
+
function sortByTs(arr) {
|
|
67
|
+
return arr.sort((a, b) => {
|
|
68
|
+
const ka = compositeKey(a) ?? "";
|
|
69
|
+
const kb = compositeKey(b) ?? "";
|
|
70
|
+
if (ka < kb)
|
|
71
|
+
return -1;
|
|
72
|
+
if (ka > kb)
|
|
73
|
+
return 1;
|
|
74
|
+
return 0;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function writeCursor(cwd, cursor) {
|
|
78
|
+
const path = join(cwd, ".context", CURSOR_FILENAME);
|
|
79
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
80
|
+
writeFileSync(path, JSON.stringify(cursor, null, 2) + "\n", "utf8");
|
|
81
|
+
}
|
|
82
|
+
function listHostEventFiles(cwd) {
|
|
83
|
+
const dir = join(cwd, ".context", "audit");
|
|
84
|
+
if (!existsSync(dir))
|
|
85
|
+
return [];
|
|
86
|
+
return readdirSync(dir)
|
|
87
|
+
.filter((name) => name.startsWith("host-events-") && name.endsWith(".jsonl"))
|
|
88
|
+
.sort()
|
|
89
|
+
.map((name) => join(dir, name));
|
|
90
|
+
}
|
|
91
|
+
function readEventsFrom(file) {
|
|
92
|
+
if (!existsSync(file))
|
|
93
|
+
return [];
|
|
94
|
+
const stat = statSync(file);
|
|
95
|
+
if (!stat.isFile())
|
|
96
|
+
return [];
|
|
97
|
+
const raw = readFileSync(file, "utf8");
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const line of raw.split("\n")) {
|
|
100
|
+
if (!line.trim())
|
|
101
|
+
continue;
|
|
102
|
+
try {
|
|
103
|
+
out.push(JSON.parse(line));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// skip malformed lines
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
function eventTimestamp(e) {
|
|
112
|
+
const ts = e.timestamp ?? e.detected_at;
|
|
113
|
+
return typeof ts === "string" ? ts : null;
|
|
114
|
+
}
|
|
115
|
+
function isAfterComposite(evt, cursor) {
|
|
116
|
+
if (!cursor)
|
|
117
|
+
return true;
|
|
118
|
+
const key = compositeKey(evt);
|
|
119
|
+
if (!key)
|
|
120
|
+
return false;
|
|
121
|
+
return key > cursor;
|
|
122
|
+
}
|
|
123
|
+
export async function pushHostEvents(cwd) {
|
|
124
|
+
const outcome = {
|
|
125
|
+
ungoverned_pushed: 0,
|
|
126
|
+
tamper_pushed: 0,
|
|
127
|
+
errors: [],
|
|
128
|
+
};
|
|
129
|
+
const config = loadEnterpriseConfig(join(cwd, ".context"));
|
|
130
|
+
const apiKey = config.enterprise.api_key.trim();
|
|
131
|
+
const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
|
|
132
|
+
if (!apiKey || !baseUrl) {
|
|
133
|
+
outcome.errors.push("enterprise not configured");
|
|
134
|
+
return outcome;
|
|
135
|
+
}
|
|
136
|
+
if (!isAllowedEnterpriseEndpoint(baseUrl)) {
|
|
137
|
+
outcome.errors.push("insecure or invalid enterprise endpoint");
|
|
138
|
+
return outcome;
|
|
139
|
+
}
|
|
140
|
+
const cursor = readCursor(cwd);
|
|
141
|
+
const ungovernedCursor = normalizeCursor(cursor.ungoverned_last_ts);
|
|
142
|
+
const tamperCursor = normalizeCursor(cursor.tamper_last_ts);
|
|
143
|
+
const ungoverned = [];
|
|
144
|
+
const tamper = [];
|
|
145
|
+
for (const file of listHostEventFiles(cwd)) {
|
|
146
|
+
for (const evt of readEventsFrom(file)) {
|
|
147
|
+
const ts = eventTimestamp(evt);
|
|
148
|
+
if (!ts)
|
|
149
|
+
continue;
|
|
150
|
+
if (evt.event_type === "ungoverned_ai_session_detected" && isAfterComposite(evt, ungovernedCursor)) {
|
|
151
|
+
ungoverned.push(evt);
|
|
152
|
+
}
|
|
153
|
+
else if (evt.event_type === "hook_tamper_detected" && isAfterComposite(evt, tamperCursor)) {
|
|
154
|
+
tamper.push(evt);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
sortByTs(ungoverned);
|
|
159
|
+
sortByTs(tamper);
|
|
160
|
+
if (ungoverned.length > 0) {
|
|
161
|
+
const result = await pushBatch(`${baseUrl.replace(/\/$/, "")}/api/v1/govern/ungoverned`, apiKey, {
|
|
162
|
+
events: ungoverned.map(toUngovernedPayload),
|
|
163
|
+
});
|
|
164
|
+
if (result.ok) {
|
|
165
|
+
outcome.ungoverned_pushed = ungoverned.length;
|
|
166
|
+
const last = ungoverned[ungoverned.length - 1];
|
|
167
|
+
cursor.ungoverned_last_ts = compositeKey(last) ?? cursor.ungoverned_last_ts;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
outcome.errors.push(`ungoverned: ${result.error}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (tamper.length > 0) {
|
|
174
|
+
const result = await pushBatch(`${baseUrl.replace(/\/$/, "")}/api/v1/govern/tamper`, apiKey, {
|
|
175
|
+
events: tamper.map(toTamperPayload),
|
|
176
|
+
});
|
|
177
|
+
if (result.ok) {
|
|
178
|
+
outcome.tamper_pushed = tamper.length;
|
|
179
|
+
const last = tamper[tamper.length - 1];
|
|
180
|
+
cursor.tamper_last_ts = compositeKey(last) ?? cursor.tamper_last_ts;
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
outcome.errors.push(`tamper: ${result.error}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (outcome.ungoverned_pushed > 0 || outcome.tamper_pushed > 0) {
|
|
187
|
+
writeCursor(cwd, cursor);
|
|
188
|
+
}
|
|
189
|
+
return outcome;
|
|
190
|
+
}
|
|
191
|
+
function toUngovernedPayload(e) {
|
|
192
|
+
return {
|
|
193
|
+
detected_at: e.timestamp,
|
|
194
|
+
host_id: e.host_id,
|
|
195
|
+
cli: e.cli,
|
|
196
|
+
binary_path: e.binary,
|
|
197
|
+
args: e.args,
|
|
198
|
+
sys_user: e.user,
|
|
199
|
+
parent_pid: e.ppid,
|
|
200
|
+
pid: e.pid,
|
|
201
|
+
action_taken: e.action ?? "logged",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function toTamperPayload(e) {
|
|
205
|
+
return {
|
|
206
|
+
detected_at: e.timestamp,
|
|
207
|
+
host_id: e.host_id,
|
|
208
|
+
cli: e.cli,
|
|
209
|
+
hook_name: e.hook_name ?? "any",
|
|
210
|
+
session_id: e.session_id,
|
|
211
|
+
last_seen: e.last_seen ?? null,
|
|
212
|
+
missing_seconds: e.missing_seconds,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
async function pushBatch(url, apiKey, payload) {
|
|
216
|
+
try {
|
|
217
|
+
const res = await fetch(url, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: {
|
|
220
|
+
"Content-Type": "application/json",
|
|
221
|
+
Authorization: `Bearer ${apiKey}`,
|
|
222
|
+
},
|
|
223
|
+
body: JSON.stringify(payload),
|
|
224
|
+
});
|
|
225
|
+
if (!res.ok) {
|
|
226
|
+
return { ok: false, error: `HTTP ${res.status} ${res.statusText}` };
|
|
227
|
+
}
|
|
228
|
+
return { ok: true };
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
export function startHostEventsPusher(cwd, intervalMs) {
|
|
235
|
+
const tick = () => {
|
|
236
|
+
void pushHostEvents(cwd).catch((err) => {
|
|
237
|
+
process.stderr.write(`[cortex-daemon] host-events push failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
void Promise.resolve().then(tick);
|
|
241
|
+
const handle = setInterval(tick, intervalMs);
|
|
242
|
+
if (typeof handle.unref === "function")
|
|
243
|
+
handle.unref();
|
|
244
|
+
return {
|
|
245
|
+
stop() {
|
|
246
|
+
clearInterval(handle);
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|