@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,73 @@
|
|
|
1
|
+
import { call } from "../daemon/client.js";
|
|
2
|
+
import type {
|
|
3
|
+
AuditLogPayload,
|
|
4
|
+
AuditLogResult,
|
|
5
|
+
TelemetryFlushPayload,
|
|
6
|
+
TelemetryFlushResult,
|
|
7
|
+
} from "../daemon/protocol.js";
|
|
8
|
+
import {
|
|
9
|
+
ensureDaemon,
|
|
10
|
+
parseInput,
|
|
11
|
+
readStdin,
|
|
12
|
+
resolveDaemonEntry,
|
|
13
|
+
sendHeartbeat,
|
|
14
|
+
} from "./shared.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SessionEnd hook. Final telemetry push + audit log when Claude Code's
|
|
18
|
+
* session terminates entirely (more authoritative than Stop, which fires
|
|
19
|
+
* each turn).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
type ClaudeSessionEndInput = {
|
|
23
|
+
session_id?: string;
|
|
24
|
+
cwd?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
async function main(): Promise<void> {
|
|
28
|
+
const raw = await readStdin();
|
|
29
|
+
const input = parseInput(raw) as ClaudeSessionEndInput;
|
|
30
|
+
const cwd = input.cwd ?? process.cwd();
|
|
31
|
+
|
|
32
|
+
ensureDaemon(resolveDaemonEntry(import.meta.url));
|
|
33
|
+
|
|
34
|
+
// Audit first — even if telemetry push fails, we want the session-end
|
|
35
|
+
// record locally so cortex-web can later derive missing data.
|
|
36
|
+
const auditPayload: AuditLogPayload = {
|
|
37
|
+
cwd,
|
|
38
|
+
entry: {
|
|
39
|
+
timestamp: new Date().toISOString(),
|
|
40
|
+
tool: "session.end",
|
|
41
|
+
input: { session_id: input.session_id ?? null },
|
|
42
|
+
event_type: "session",
|
|
43
|
+
evidence_level: "diagnostic",
|
|
44
|
+
resource_type: "session",
|
|
45
|
+
session_id: input.session_id,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
await call<AuditLogResult>("audit.log", auditPayload, { timeoutMs: 3000 });
|
|
49
|
+
|
|
50
|
+
// Final flush — best-effort, may have nothing on disk if MCP already
|
|
51
|
+
// flushed during shutdown.
|
|
52
|
+
const flushPayload: TelemetryFlushPayload = {
|
|
53
|
+
reason: "session_end",
|
|
54
|
+
session_id: input.session_id,
|
|
55
|
+
cwd,
|
|
56
|
+
};
|
|
57
|
+
await call<TelemetryFlushResult>("telemetry.flush", flushPayload, {
|
|
58
|
+
timeoutMs: 5000,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (input.session_id) {
|
|
62
|
+
await sendHeartbeat({
|
|
63
|
+
cli: "claude",
|
|
64
|
+
hook: "SessionEnd",
|
|
65
|
+
session_id: input.session_id,
|
|
66
|
+
cwd,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { call } from "../daemon/client.js";
|
|
2
|
+
import type { AuditLogPayload, AuditLogResult } from "../daemon/protocol.js";
|
|
3
|
+
import {
|
|
4
|
+
ensureDaemon,
|
|
5
|
+
isEnforcedMode,
|
|
6
|
+
parseInput,
|
|
7
|
+
readStdin,
|
|
8
|
+
readTamperLockJson,
|
|
9
|
+
resolveDaemonEntry,
|
|
10
|
+
sendHeartbeat,
|
|
11
|
+
} from "./shared.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* SessionStart hook for Claude Code.
|
|
15
|
+
*
|
|
16
|
+
* Phase 6 additions:
|
|
17
|
+
* - In enforced mode, refuses session start if a tamper-lock exists.
|
|
18
|
+
* The user must run `cortex enterprise repair` (sudo) to clear it.
|
|
19
|
+
* - Always sends a heartbeat so the daemon's tamper-tracker can spot
|
|
20
|
+
* silent post-startup hook removal.
|
|
21
|
+
*
|
|
22
|
+
* Audit/telemetry/heartbeat failures must never block legitimate session
|
|
23
|
+
* startup — only the explicit tamper-lock check does.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
type ClaudeSessionStartInput = {
|
|
27
|
+
session_id?: string;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
async function main(): Promise<void> {
|
|
32
|
+
const raw = await readStdin();
|
|
33
|
+
const input = parseInput(raw) as ClaudeSessionStartInput;
|
|
34
|
+
const cwd = input.cwd ?? process.cwd();
|
|
35
|
+
const sessionId = input.session_id ?? "";
|
|
36
|
+
|
|
37
|
+
// Tamper-lock check happens BEFORE we spawn the daemon. We don't want
|
|
38
|
+
// a tampered project to keep getting fresh daemons started.
|
|
39
|
+
const lock = readTamperLockJson(cwd);
|
|
40
|
+
if (lock && isEnforcedMode(cwd)) {
|
|
41
|
+
process.stderr.write(
|
|
42
|
+
"[cortex] Govern enforced: session blocked because hook tampering was detected.\n" +
|
|
43
|
+
" Run 'sudo cortex enterprise repair' to verify managed-settings\n" +
|
|
44
|
+
" integrity and clear .context/.cortex-tamper.lock, then retry.\n",
|
|
45
|
+
);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ensureDaemon(resolveDaemonEntry(import.meta.url));
|
|
50
|
+
|
|
51
|
+
const payload: AuditLogPayload = {
|
|
52
|
+
cwd,
|
|
53
|
+
entry: {
|
|
54
|
+
timestamp: new Date().toISOString(),
|
|
55
|
+
tool: "session.start",
|
|
56
|
+
input: { session_id: sessionId || null },
|
|
57
|
+
event_type: "session",
|
|
58
|
+
evidence_level: "diagnostic",
|
|
59
|
+
resource_type: "session",
|
|
60
|
+
session_id: sessionId,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
await call<AuditLogResult>("audit.log", payload, { timeoutMs: 3000 });
|
|
65
|
+
|
|
66
|
+
if (sessionId) {
|
|
67
|
+
await sendHeartbeat({
|
|
68
|
+
cli: "claude",
|
|
69
|
+
hook: "SessionStart",
|
|
70
|
+
session_id: sessionId,
|
|
71
|
+
cwd,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { call, isDaemonRunning, spawnDaemon } from "../daemon/client.js";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import type { HeartbeatPayload, HeartbeatResult } from "../daemon/protocol.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shared utilities for hook scripts.
|
|
9
|
+
*
|
|
10
|
+
* Hook flow (Claude Code spec):
|
|
11
|
+
* 1. Claude Code spawns the hook script with input JSON on stdin
|
|
12
|
+
* 2. Hook reads stdin, makes a decision
|
|
13
|
+
* 3. Hook prints JSON to stdout (or exits non-zero to block)
|
|
14
|
+
* 4. exit 0 = allow, exit 2 = block, other non-zero = error
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type HookInput = {
|
|
18
|
+
// Claude Code's documented hook input shape — varies by hook type.
|
|
19
|
+
// We read it as a generic record and let the hook narrow.
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function readStdin(): Promise<string> {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
let data = "";
|
|
26
|
+
process.stdin.setEncoding("utf8");
|
|
27
|
+
process.stdin.on("data", (chunk) => {
|
|
28
|
+
data += chunk;
|
|
29
|
+
});
|
|
30
|
+
process.stdin.on("end", () => resolve(data));
|
|
31
|
+
// If stdin is a TTY (running interactively for testing), don't hang.
|
|
32
|
+
if (process.stdin.isTTY) resolve("");
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseInput(raw: string): HookInput {
|
|
37
|
+
if (!raw.trim()) return {};
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(raw) as HookInput;
|
|
40
|
+
} catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Detect whether the current project is running enterprise mode.
|
|
47
|
+
* Lightweight YAML peek — we don't want to load the full config parser
|
|
48
|
+
* on every hook invocation.
|
|
49
|
+
*/
|
|
50
|
+
export function isEnterpriseProject(cwd: string): boolean {
|
|
51
|
+
const candidates = [
|
|
52
|
+
join(cwd, ".context", "enterprise.yml"),
|
|
53
|
+
join(cwd, ".context", "enterprise.yaml"),
|
|
54
|
+
];
|
|
55
|
+
for (const path of candidates) {
|
|
56
|
+
if (!existsSync(path)) continue;
|
|
57
|
+
try {
|
|
58
|
+
const raw = readFileSync(path, "utf8");
|
|
59
|
+
// Look for an api_key under enterprise: section.
|
|
60
|
+
// Conservative: any non-empty api_key value implies "intent to be enterprise".
|
|
61
|
+
const match = raw.match(/^\s*api_key:\s*(\S.*?)\s*$/m);
|
|
62
|
+
if (match && match[1] && match[1] !== "") return true;
|
|
63
|
+
} catch {
|
|
64
|
+
// ignore
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function ensureDaemon(daemonEntry: string): void {
|
|
71
|
+
if (isDaemonRunning()) return;
|
|
72
|
+
spawnDaemon(daemonEntry);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve daemon entry script path relative to this hook script's location.
|
|
77
|
+
* Hooks live at dist/hooks/<name>.js, daemon at dist/daemon/main.js.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveDaemonEntry(hookFileUrl: string): string {
|
|
80
|
+
const hookPath = fileURLToPath(hookFileUrl);
|
|
81
|
+
// dist/hooks/<x>.js → dist/daemon/main.js
|
|
82
|
+
return join(hookPath, "..", "..", "daemon", "main.js");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Send a heartbeat to the daemon for tamper-detection bookkeeping.
|
|
87
|
+
* Failure to reach the daemon is non-fatal — daemon-down is treated as
|
|
88
|
+
* "we don't know" rather than tamper. Returns whether the daemon
|
|
89
|
+
* reported an active tamper-lock for this cwd.
|
|
90
|
+
*/
|
|
91
|
+
export async function sendHeartbeat(
|
|
92
|
+
payload: Omit<HeartbeatPayload, "ts">,
|
|
93
|
+
): Promise<{ tamperLockActive: boolean }> {
|
|
94
|
+
const result = await call<HeartbeatResult>(
|
|
95
|
+
"heartbeat",
|
|
96
|
+
{ ...payload, ts: new Date().toISOString() } satisfies HeartbeatPayload,
|
|
97
|
+
{ timeoutMs: 1500 },
|
|
98
|
+
);
|
|
99
|
+
if (!result.ok) return { tamperLockActive: false };
|
|
100
|
+
return { tamperLockActive: result.result.tamper_lock_active === true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const TAMPER_LOCK_FILENAME = ".cortex-tamper.lock";
|
|
104
|
+
|
|
105
|
+
export function readTamperLockJson(cwd: string): unknown | null {
|
|
106
|
+
const path = join(cwd, ".context", TAMPER_LOCK_FILENAME);
|
|
107
|
+
if (!existsSync(path)) return null;
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* True iff this cwd is in enforced govern mode. Reads govern.local.json,
|
|
117
|
+
* which is written by the install/install-flow. We don't fall back to
|
|
118
|
+
* enterprise.yml because the user's intent is captured at install time.
|
|
119
|
+
*/
|
|
120
|
+
export function isEnforcedMode(cwd: string): boolean {
|
|
121
|
+
const path = join(cwd, ".context", "govern.local.json");
|
|
122
|
+
if (!existsSync(path)) return false;
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as {
|
|
125
|
+
installs?: Record<string, { mode?: string }>;
|
|
126
|
+
};
|
|
127
|
+
for (const inst of Object.values(parsed.installs ?? {})) {
|
|
128
|
+
if (inst.mode === "enforced") return true;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { call } from "../daemon/client.js";
|
|
2
|
+
import type { TelemetryFlushPayload, TelemetryFlushResult } from "../daemon/protocol.js";
|
|
3
|
+
import {
|
|
4
|
+
ensureDaemon,
|
|
5
|
+
parseInput,
|
|
6
|
+
readStdin,
|
|
7
|
+
resolveDaemonEntry,
|
|
8
|
+
} from "./shared.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Stop hook for Claude Code.
|
|
12
|
+
*
|
|
13
|
+
* Fires when Claude finishes responding. We use this to guarantee a
|
|
14
|
+
* telemetry flush — historically metrics.json was lost when MCP exited
|
|
15
|
+
* abruptly. The Stop hook runs in Claude Code's process tree, not the
|
|
16
|
+
* MCP server's, so it survives MCP shutdown.
|
|
17
|
+
*
|
|
18
|
+
* Always exits 0 — telemetry failures must never block the user.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
type ClaudeStopInput = {
|
|
22
|
+
session_id?: string;
|
|
23
|
+
cwd?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
async function main(): Promise<void> {
|
|
27
|
+
const raw = await readStdin();
|
|
28
|
+
const input = parseInput(raw) as ClaudeStopInput;
|
|
29
|
+
|
|
30
|
+
ensureDaemon(resolveDaemonEntry(import.meta.url));
|
|
31
|
+
|
|
32
|
+
const payload: TelemetryFlushPayload = {
|
|
33
|
+
reason: "stop",
|
|
34
|
+
session_id: input.session_id,
|
|
35
|
+
cwd: input.cwd ?? process.cwd(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const res = await call<TelemetryFlushResult>("telemetry.flush", payload, {
|
|
39
|
+
timeoutMs: 5000,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (res.ok && res.result.flushed) {
|
|
43
|
+
process.stderr.write(
|
|
44
|
+
`[cortex] Telemetry flushed: ${res.result.events_pushed} events\n`,
|
|
45
|
+
);
|
|
46
|
+
} else if (!res.ok) {
|
|
47
|
+
// Best-effort: silent in normal output. Daemon-reachability is logged
|
|
48
|
+
// through other channels (PreToolUse warnings, daemon log).
|
|
49
|
+
process.stderr.write(
|
|
50
|
+
`[cortex] Telemetry flush skipped: ${res.error}\n`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
main().catch(() => {
|
|
58
|
+
// Telemetry must never block. Swallow all errors.
|
|
59
|
+
process.exit(0);
|
|
60
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { call } from "../daemon/client.js";
|
|
2
|
+
import type { AuditLogPayload, AuditLogResult } from "../daemon/protocol.js";
|
|
3
|
+
import {
|
|
4
|
+
ensureDaemon,
|
|
5
|
+
parseInput,
|
|
6
|
+
readStdin,
|
|
7
|
+
resolveDaemonEntry,
|
|
8
|
+
sendHeartbeat,
|
|
9
|
+
} from "./shared.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* UserPromptSubmit hook. Fires when the user submits a prompt to Claude.
|
|
13
|
+
*
|
|
14
|
+
* v2.0.0 MVP: logs the event for audit. A future commit will use this
|
|
15
|
+
* hook to inject mandatory rules / ADRs as system context so Claude
|
|
16
|
+
* cannot proceed without seeing them.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type ClaudeUserPromptInput = {
|
|
20
|
+
session_id?: string;
|
|
21
|
+
cwd?: string;
|
|
22
|
+
// Claude passes the prompt — we only log length, not contents, to
|
|
23
|
+
// avoid logging sensitive user input by default.
|
|
24
|
+
prompt?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
async function main(): Promise<void> {
|
|
28
|
+
const raw = await readStdin();
|
|
29
|
+
const input = parseInput(raw) as ClaudeUserPromptInput;
|
|
30
|
+
const cwd = input.cwd ?? process.cwd();
|
|
31
|
+
|
|
32
|
+
ensureDaemon(resolveDaemonEntry(import.meta.url));
|
|
33
|
+
|
|
34
|
+
const promptLen = typeof input.prompt === "string" ? input.prompt.length : 0;
|
|
35
|
+
|
|
36
|
+
const payload: AuditLogPayload = {
|
|
37
|
+
cwd,
|
|
38
|
+
entry: {
|
|
39
|
+
timestamp: new Date().toISOString(),
|
|
40
|
+
tool: "user.prompt",
|
|
41
|
+
input: { prompt_length: promptLen },
|
|
42
|
+
event_type: "session",
|
|
43
|
+
evidence_level: "diagnostic",
|
|
44
|
+
resource_type: "user_input",
|
|
45
|
+
session_id: input.session_id,
|
|
46
|
+
metadata: { prompt_length: promptLen },
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
await call<AuditLogResult>("audit.log", payload, { timeoutMs: 3000 });
|
|
51
|
+
|
|
52
|
+
if (input.session_id) {
|
|
53
|
+
await sendHeartbeat({
|
|
54
|
+
cli: "claude",
|
|
55
|
+
hook: "UserPromptSubmit",
|
|
56
|
+
session_id: input.session_id,
|
|
57
|
+
cwd,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
main().catch(() => process.exit(0));
|
|
@@ -443,6 +443,8 @@ async function main(): Promise<void> {
|
|
|
443
443
|
|
|
444
444
|
if (reset) {
|
|
445
445
|
fs.rmSync(DB_PATH, { recursive: true, force: true });
|
|
446
|
+
fs.rmSync(`${DB_PATH}.wal`, { force: true });
|
|
447
|
+
fs.rmSync(`${DB_PATH}.shm`, { force: true });
|
|
446
448
|
}
|
|
447
449
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
448
450
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
|
|
3
|
+
export type ToolCallHook = (toolName: string, resultCount: number, tokensSaved: number) => void;
|
|
4
|
+
|
|
5
|
+
export type ToolExecutionPhase = "start" | "success" | "error";
|
|
6
|
+
|
|
7
|
+
export type ToolExecutionEvent = {
|
|
8
|
+
phase: ToolExecutionPhase;
|
|
9
|
+
tool: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
input: Record<string, unknown>;
|
|
12
|
+
query?: string;
|
|
13
|
+
query_length?: number;
|
|
14
|
+
result_count?: number;
|
|
15
|
+
estimated_tokens_saved?: number;
|
|
16
|
+
entities_returned?: string[];
|
|
17
|
+
rules_applied?: string[];
|
|
18
|
+
duration_ms?: number;
|
|
19
|
+
error?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type SessionCallRecord = {
|
|
23
|
+
tool: string;
|
|
24
|
+
query?: string;
|
|
25
|
+
resultCount: number;
|
|
26
|
+
time: string;
|
|
27
|
+
outcome?: "success" | "error";
|
|
28
|
+
duration_ms?: number;
|
|
29
|
+
error?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type SessionEndHook = (calls: SessionCallRecord[]) => Promise<void>;
|
|
33
|
+
|
|
34
|
+
export type SessionPhase = "start" | "end";
|
|
35
|
+
|
|
36
|
+
export type SessionEvent = {
|
|
37
|
+
phase: SessionPhase;
|
|
38
|
+
timestamp: string;
|
|
39
|
+
duration_ms?: number;
|
|
40
|
+
tool_calls?: number;
|
|
41
|
+
successful_tool_calls?: number;
|
|
42
|
+
failed_tool_calls?: number;
|
|
43
|
+
calls?: SessionCallRecord[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type ToolEventHook = (event: ToolExecutionEvent) => void | Promise<void>;
|
|
47
|
+
export type SessionEventHook = (event: SessionEvent) => void | Promise<void>;
|
|
48
|
+
|
|
49
|
+
export type CortexPlugin = {
|
|
50
|
+
name: string;
|
|
51
|
+
version: string;
|
|
52
|
+
register: (server: McpServer) => void | Promise<void>;
|
|
53
|
+
onToolCall?: ToolCallHook;
|
|
54
|
+
onSessionEnd?: SessionEndHook;
|
|
55
|
+
onToolEvent?: ToolEventHook;
|
|
56
|
+
onSessionEvent?: SessionEventHook;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type EditionInfo = {
|
|
60
|
+
edition: "community" | "enterprise";
|
|
61
|
+
name?: string;
|
|
62
|
+
version?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
let loadedEdition: EditionInfo = { edition: "community" };
|
|
66
|
+
let toolCallHook: ToolCallHook | null = null;
|
|
67
|
+
let sessionEndHook: SessionEndHook | null = null;
|
|
68
|
+
let toolEventHook: ToolEventHook | null = null;
|
|
69
|
+
let sessionEventHook: SessionEventHook | null = null;
|
|
70
|
+
|
|
71
|
+
export function getEdition(): EditionInfo {
|
|
72
|
+
return loadedEdition;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getToolCallHook(): ToolCallHook | null {
|
|
76
|
+
return toolCallHook;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getSessionEndHook(): SessionEndHook | null {
|
|
80
|
+
return sessionEndHook;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getToolEventHook(): ToolEventHook | null {
|
|
84
|
+
return toolEventHook;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getSessionEventHook(): SessionEventHook | null {
|
|
88
|
+
return sessionEventHook;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function loadPlugins(server: McpServer): Promise<void> {
|
|
92
|
+
// v2.0.0: enterprise is now in-process. We still gate registration on
|
|
93
|
+
// whether enterprise activation succeeds (license + config), so community
|
|
94
|
+
// users get a no-op when no api_key is present.
|
|
95
|
+
try {
|
|
96
|
+
const enterprise = await import("./enterprise/index.js");
|
|
97
|
+
const { resolveEnterpriseActivation, loadEnterpriseConfig } = await import("./core/config.js");
|
|
98
|
+
const path = await import("node:path");
|
|
99
|
+
const projectRoot = process.env.CORTEX_PROJECT_ROOT?.trim() || process.cwd();
|
|
100
|
+
const contextDir = path.join(projectRoot, ".context");
|
|
101
|
+
const config = loadEnterpriseConfig(contextDir);
|
|
102
|
+
const activation = resolveEnterpriseActivation(config);
|
|
103
|
+
|
|
104
|
+
if (!activation.active) {
|
|
105
|
+
// Community mode: no api_key or invalid config. Skip registration.
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// v2.0.0: validate license against cortex-web before activating.
|
|
110
|
+
// The verifier uses 24h cache + 7d grace period so transient endpoint
|
|
111
|
+
// outages don't degrade enterprise users immediately.
|
|
112
|
+
const { verifyLicense } = await import("./core/license.js");
|
|
113
|
+
const license = await verifyLicense(contextDir, config.enterprise.endpoint, config.enterprise.api_key, {
|
|
114
|
+
client_version: process.env.CORTEX_VERSION,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!license.valid) {
|
|
118
|
+
process.stderr.write(
|
|
119
|
+
`[cortex] Enterprise inactive: license check failed (${license.reason}, source=${license.source})\n`
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (typeof enterprise.register === "function") {
|
|
125
|
+
await enterprise.register(server);
|
|
126
|
+
loadedEdition = {
|
|
127
|
+
edition: "enterprise",
|
|
128
|
+
name: enterprise.name ?? "enterprise",
|
|
129
|
+
version: enterprise.version ?? "unknown",
|
|
130
|
+
};
|
|
131
|
+
if (typeof enterprise.onToolCall === "function") {
|
|
132
|
+
toolCallHook = enterprise.onToolCall;
|
|
133
|
+
}
|
|
134
|
+
if (typeof enterprise.onSessionEnd === "function") {
|
|
135
|
+
sessionEndHook = enterprise.onSessionEnd;
|
|
136
|
+
}
|
|
137
|
+
if (typeof enterprise.onToolEvent === "function") {
|
|
138
|
+
toolEventHook = enterprise.onToolEvent;
|
|
139
|
+
}
|
|
140
|
+
if (typeof enterprise.onSessionEvent === "function") {
|
|
141
|
+
sessionEventHook = enterprise.onSessionEvent;
|
|
142
|
+
}
|
|
143
|
+
process.stderr.write(`[cortex] Enterprise loaded: ${loadedEdition.version}\n`);
|
|
144
|
+
}
|
|
145
|
+
} catch (error: unknown) {
|
|
146
|
+
process.stderr.write(
|
|
147
|
+
`[cortex] Enterprise activation failed: ${error instanceof Error ? error.message : "unknown error"}\n`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|