@danielblomma/cortex-mcp 2.4.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +19 -3
  3. package/bin/cortex.mjs +308 -75
  4. package/bin/daemon-control.mjs +162 -0
  5. package/package.json +7 -3
  6. package/scaffold/mcp/dist/.cortex-build-hash +1 -0
  7. package/scaffold/mcp/dist/cli/enterprise-setup.js +134 -0
  8. package/scaffold/mcp/dist/cli/govern.js +937 -0
  9. package/scaffold/mcp/dist/cli/query.js +409 -0
  10. package/scaffold/mcp/dist/cli/run.js +295 -0
  11. package/scaffold/mcp/dist/cli/stage.js +308 -0
  12. package/scaffold/mcp/dist/cli/telemetry-test.js +141 -0
  13. package/scaffold/mcp/dist/cli/ungoverned-detector.js +133 -0
  14. package/scaffold/mcp/dist/contextEntities.js +282 -0
  15. package/scaffold/mcp/dist/core/audit/query.js +72 -0
  16. package/scaffold/mcp/dist/core/audit/writer.js +28 -0
  17. package/scaffold/mcp/dist/core/config.js +235 -0
  18. package/scaffold/mcp/dist/core/enterprise-host-identity.js +193 -0
  19. package/scaffold/mcp/dist/core/enterprise-identity.js +18 -0
  20. package/scaffold/mcp/dist/core/enterprise-rotation.js +158 -0
  21. package/scaffold/mcp/dist/core/govern-paths.js +21 -0
  22. package/scaffold/mcp/dist/core/index.js +14 -0
  23. package/scaffold/mcp/dist/core/license.js +278 -0
  24. package/scaffold/mcp/dist/core/policy/enforce.js +66 -0
  25. package/scaffold/mcp/dist/core/policy/injection.js +172 -0
  26. package/scaffold/mcp/dist/core/policy/store.js +179 -0
  27. package/scaffold/mcp/dist/core/rbac/check.js +30 -0
  28. package/scaffold/mcp/dist/core/secure-endpoint.js +25 -0
  29. package/scaffold/mcp/dist/core/telemetry/collector.js +285 -0
  30. package/scaffold/mcp/dist/core/telemetry/state-dir.js +31 -0
  31. package/scaffold/mcp/dist/core/validators/builtins.js +632 -0
  32. package/scaffold/mcp/dist/core/validators/config.js +44 -0
  33. package/scaffold/mcp/dist/core/validators/engine.js +120 -0
  34. package/scaffold/mcp/dist/core/validators/evaluators/code_comments.js +236 -0
  35. package/scaffold/mcp/dist/core/validators/evaluators/regex.js +116 -0
  36. package/scaffold/mcp/dist/core/workflow/artifact-io.js +103 -0
  37. package/scaffold/mcp/dist/core/workflow/capabilities.js +88 -0
  38. package/scaffold/mcp/dist/core/workflow/default-workflows.js +102 -0
  39. package/scaffold/mcp/dist/core/workflow/enforcement.js +164 -0
  40. package/scaffold/mcp/dist/core/workflow/envelope.js +132 -0
  41. package/scaffold/mcp/dist/core/workflow/index.js +11 -0
  42. package/scaffold/mcp/dist/core/workflow/mcp-tools.js +175 -0
  43. package/scaffold/mcp/dist/core/workflow/resolution.js +69 -0
  44. package/scaffold/mcp/dist/core/workflow/run-lifecycle.js +123 -0
  45. package/scaffold/mcp/dist/core/workflow/schemas.js +141 -0
  46. package/scaffold/mcp/dist/core/workflow/synced-capability-registry.js +60 -0
  47. package/scaffold/mcp/dist/core/workflow/synced-registry.js +60 -0
  48. package/scaffold/mcp/dist/daemon/capability-sync-checker.js +242 -0
  49. package/scaffold/mcp/dist/daemon/client.js +130 -0
  50. package/scaffold/mcp/dist/daemon/egress-proxy.js +288 -0
  51. package/scaffold/mcp/dist/daemon/global-host-events.js +222 -0
  52. package/scaffold/mcp/dist/daemon/heartbeat-pusher.js +116 -0
  53. package/scaffold/mcp/dist/daemon/heartbeat-tracker.js +165 -0
  54. package/scaffold/mcp/dist/daemon/host-events-pusher.js +249 -0
  55. package/scaffold/mcp/dist/daemon/main.js +449 -0
  56. package/scaffold/mcp/dist/daemon/paths.js +36 -0
  57. package/scaffold/mcp/dist/daemon/project-service-registry.js +78 -0
  58. package/scaffold/mcp/dist/daemon/protocol.js +8 -0
  59. package/scaffold/mcp/dist/daemon/server.js +180 -0
  60. package/scaffold/mcp/dist/daemon/skill-sync-checker.js +557 -0
  61. package/scaffold/mcp/dist/daemon/sync-checker.js +165 -0
  62. package/scaffold/mcp/dist/daemon/ungoverned-scanner.js +136 -0
  63. package/scaffold/mcp/dist/daemon/workflow-sync-checker.js +249 -0
  64. package/scaffold/mcp/dist/defaults.js +6 -0
  65. package/scaffold/mcp/dist/embed.js +627 -0
  66. package/scaffold/mcp/dist/embedScheduler.js +479 -0
  67. package/scaffold/mcp/dist/embeddings.js +167 -0
  68. package/scaffold/mcp/dist/enterprise/audit/push.js +66 -0
  69. package/scaffold/mcp/dist/enterprise/index.js +327 -0
  70. package/scaffold/mcp/dist/enterprise/model/deploy.js +27 -0
  71. package/scaffold/mcp/dist/enterprise/policy/sync.js +129 -0
  72. package/scaffold/mcp/dist/enterprise/privacy/boundary.js +184 -0
  73. package/scaffold/mcp/dist/enterprise/reviews/changed-files.js +33 -0
  74. package/scaffold/mcp/dist/enterprise/reviews/pattern-context.js +202 -0
  75. package/scaffold/mcp/dist/enterprise/reviews/policy-selection.js +46 -0
  76. package/scaffold/mcp/dist/enterprise/reviews/push.js +102 -0
  77. package/scaffold/mcp/dist/enterprise/reviews/trust-state.js +186 -0
  78. package/scaffold/mcp/dist/enterprise/telemetry/sync.js +57 -0
  79. package/scaffold/mcp/dist/enterprise/tools/enterprise.js +826 -0
  80. package/scaffold/mcp/dist/enterprise/tools/harness.js +40 -0
  81. package/scaffold/mcp/dist/enterprise/tools/walk.js +73 -0
  82. package/scaffold/mcp/dist/enterprise/violations/push.js +77 -0
  83. package/scaffold/mcp/dist/enterprise/workflow/push.js +44 -0
  84. package/scaffold/mcp/dist/enterprise/workflow/state.js +329 -0
  85. package/scaffold/mcp/dist/frontmatter.js +33 -0
  86. package/scaffold/mcp/dist/graph.js +769 -0
  87. package/scaffold/mcp/dist/graphCsv.js +55 -0
  88. package/scaffold/mcp/dist/graphMetrics.js +8 -0
  89. package/scaffold/mcp/dist/hooks/permission-request.js +89 -0
  90. package/scaffold/mcp/dist/hooks/post-tool-use.js +105 -0
  91. package/scaffold/mcp/dist/hooks/pre-compact.js +29 -0
  92. package/scaffold/mcp/dist/hooks/pre-tool-use.js +78 -0
  93. package/scaffold/mcp/dist/hooks/session-end.js +43 -0
  94. package/scaffold/mcp/dist/hooks/session-start.js +41 -0
  95. package/scaffold/mcp/dist/hooks/shared.js +194 -0
  96. package/scaffold/mcp/dist/hooks/stop.js +28 -0
  97. package/scaffold/mcp/dist/hooks/user-prompt-submit.js +33 -0
  98. package/scaffold/mcp/dist/impactPresentation.js +137 -0
  99. package/scaffold/mcp/dist/impactRanking.js +191 -0
  100. package/scaffold/mcp/dist/impactResponse.js +30 -0
  101. package/scaffold/mcp/dist/impactResults.js +105 -0
  102. package/scaffold/mcp/dist/impactSeed.js +20 -0
  103. package/scaffold/mcp/dist/impactTraversal.js +64 -0
  104. package/scaffold/mcp/dist/jsonl.js +77 -0
  105. package/scaffold/mcp/dist/loadGraph.js +759 -0
  106. package/scaffold/mcp/dist/lruCache.js +38 -0
  107. package/scaffold/mcp/dist/paths.js +97 -0
  108. package/scaffold/mcp/dist/patternEvidence.js +272 -0
  109. package/scaffold/mcp/dist/plugin.js +81 -0
  110. package/scaffold/mcp/dist/presets.js +78 -0
  111. package/scaffold/mcp/dist/relatedResponse.js +18 -0
  112. package/scaffold/mcp/dist/relatedTraversal.js +78 -0
  113. package/scaffold/mcp/dist/rules.js +23 -0
  114. package/scaffold/mcp/dist/search.js +212 -0
  115. package/scaffold/mcp/dist/searchCore.js +457 -0
  116. package/scaffold/mcp/dist/searchResults.js +230 -0
  117. package/scaffold/mcp/dist/server.js +317 -0
  118. package/scaffold/mcp/dist/types.js +1 -0
  119. package/scaffold/mcp/package-lock.json +321 -200
  120. package/scaffold/mcp/package.json +15 -6
  121. package/scaffold/mcp/src/cli/enterprise-setup.ts +82 -8
  122. package/scaffold/mcp/src/cli/govern.ts +137 -52
  123. package/scaffold/mcp/src/cli/run.ts +53 -19
  124. package/scaffold/mcp/src/cli/stage.ts +8 -3
  125. package/scaffold/mcp/src/core/config.ts +9 -2
  126. package/scaffold/mcp/src/core/enterprise-host-identity.ts +259 -0
  127. package/scaffold/mcp/src/core/enterprise-identity.ts +20 -0
  128. package/scaffold/mcp/src/core/enterprise-rotation.ts +185 -0
  129. package/scaffold/mcp/src/core/govern-paths.ts +30 -0
  130. package/scaffold/mcp/src/core/license.ts +186 -17
  131. package/scaffold/mcp/src/core/secure-endpoint.ts +23 -0
  132. package/scaffold/mcp/src/core/workflow/enforcement.ts +8 -1
  133. package/scaffold/mcp/src/core/workflow/mcp-tools.ts +3 -0
  134. package/scaffold/mcp/src/core/workflow/resolution.ts +9 -1
  135. package/scaffold/mcp/src/core/workflow/synced-capability-registry.ts +15 -0
  136. package/scaffold/mcp/src/core/workflow/synced-registry.ts +15 -0
  137. package/scaffold/mcp/src/daemon/capability-sync-checker.ts +38 -2
  138. package/scaffold/mcp/src/daemon/egress-proxy.ts +14 -8
  139. package/scaffold/mcp/src/daemon/global-host-events.ts +288 -0
  140. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +4 -0
  141. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +2 -0
  142. package/scaffold/mcp/src/daemon/host-events-pusher.ts +5 -0
  143. package/scaffold/mcp/src/daemon/main.ts +99 -25
  144. package/scaffold/mcp/src/daemon/project-service-registry.ts +107 -0
  145. package/scaffold/mcp/src/daemon/skill-sync-checker.ts +368 -31
  146. package/scaffold/mcp/src/daemon/sync-checker.ts +4 -0
  147. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +57 -25
  148. package/scaffold/mcp/src/daemon/workflow-sync-checker.ts +41 -2
  149. package/scaffold/mcp/src/enterprise/audit/push.ts +8 -0
  150. package/scaffold/mcp/src/enterprise/policy/sync.ts +12 -0
  151. package/scaffold/mcp/src/enterprise/reviews/push.ts +9 -0
  152. package/scaffold/mcp/src/enterprise/reviews/trust-state.ts +3 -1
  153. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +9 -0
  154. package/scaffold/mcp/src/enterprise/violations/push.ts +9 -0
  155. package/scaffold/mcp/src/enterprise/workflow/push.ts +7 -0
  156. package/scaffold/mcp/src/plugin.ts +20 -0
  157. package/scaffold/mcp/tests/copilot-shim.test.mjs +19 -1
  158. package/scaffold/mcp/tests/egress-proxy.test.mjs +37 -0
  159. package/scaffold/mcp/tests/enterprise-identity-sync.test.mjs +401 -0
  160. package/scaffold/mcp/tests/enterprise-setup.test.mjs +189 -0
  161. package/scaffold/mcp/tests/global-host-events.test.mjs +81 -0
  162. package/scaffold/mcp/tests/govern-install.test.mjs +95 -2
  163. package/scaffold/mcp/tests/govern-repair.test.mjs +20 -3
  164. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +26 -0
  165. package/scaffold/mcp/tests/license.test.mjs +367 -0
  166. package/scaffold/mcp/tests/project-service-registry.test.mjs +172 -0
  167. package/scaffold/mcp/tests/review-trust-contract.test.mjs +18 -0
  168. package/scaffold/mcp/tests/secure-enterprise-endpoint.test.mjs +46 -0
  169. package/scaffold/mcp/tests/skill-sync-checker.test.mjs +446 -2
  170. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +104 -22
  171. package/scaffold/mcp/tests/workflow-cli.test.mjs +14 -1
  172. package/scaffold/mcp/tests/workflow-synced-capabilities.test.mjs +37 -0
  173. package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +40 -1
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs";
2
+ // ryugraph's CSV reader treats an empty field as NULL by default
3
+ // (DEFAULT_CSV_NULL_STRINGS = {""}), so a quoted empty string round-trips to
4
+ // null rather than "". Every column the graph loader emits is a real
5
+ // string/number/bool produced by asString/asNumber/asBoolean — never null —
6
+ // so we override NULL_STRINGS with a sentinel that cannot occur in any cell.
7
+ // A NUL byte qualifies: ingest's binary-file filter rejects any source file
8
+ // containing one, and filesystem paths and computed ids cannot contain NUL
9
+ // either. This keeps empty strings as "" and matches the prepared-statement
10
+ // loader byte-for-byte.
11
+ export const CSV_NULL_SENTINEL = String.fromCharCode(0);
12
+ // PARALLEL=false is required: ryugraph's parallel CSV reader rejects quoted
13
+ // newlines, and chunk/rule/ADR bodies routinely contain them. ESCAPE='"'
14
+ // plus QUOTE='"' is RFC 4180 style (a literal quote is written as "").
15
+ export const CSV_COPY_OPTIONS = `(HEADER=true, DELIM=",", QUOTE='"', ESCAPE='"', PARALLEL=false, NULL_STRINGS=['${CSV_NULL_SENTINEL}'])`;
16
+ // Every cell is quoted unconditionally and internal quotes are doubled. The
17
+ // reader coerces quoted "true"/"123" back to BOOL/INT64 columns, so a single
18
+ // uniform escaping path covers strings, numbers, and booleans.
19
+ export function toCsvCell(value) {
20
+ const text = value === null || value === undefined
21
+ ? ""
22
+ : typeof value === "boolean"
23
+ ? value
24
+ ? "true"
25
+ : "false"
26
+ : String(value);
27
+ return `"${text.replace(/"/g, '""')}"`;
28
+ }
29
+ export function toCsvRow(values) {
30
+ return values.map(toCsvCell).join(",");
31
+ }
32
+ export function writeCsv(filePath, header, rows) {
33
+ const fd = fs.openSync(filePath, "w");
34
+ let rowCount = 0;
35
+ try {
36
+ fs.writeSync(fd, `${toCsvRow(header)}\n`, undefined, "utf8");
37
+ for (const row of rows) {
38
+ fs.writeSync(fd, `${toCsvRow(row)}\n`, undefined, "utf8");
39
+ rowCount += 1;
40
+ }
41
+ }
42
+ finally {
43
+ fs.closeSync(fd);
44
+ }
45
+ return rowCount;
46
+ }
47
+ // Escape a filesystem path for use inside a double-quoted ryugraph COPY path
48
+ // literal. Separators are normalized to "/" (accepted on every platform,
49
+ // Windows included) and any embedded double quote is backslash-escaped, so a
50
+ // repo or cache path containing a quote does not break the COPY statement and
51
+ // silently lose the bulk-load path. Order matters: normalize separators first,
52
+ // then escape quotes, so the escaping backslash is not itself rewritten.
53
+ export function toCopyPathLiteral(filePath) {
54
+ return filePath.replace(/\\/g, "/").replace(/"/g, '\\"');
55
+ }
@@ -0,0 +1,8 @@
1
+ export function relationDegree(relations) {
2
+ const degrees = new Map();
3
+ for (const relation of relations) {
4
+ degrees.set(relation.from, (degrees.get(relation.from) ?? 0) + 1);
5
+ degrees.set(relation.to, (degrees.get(relation.to) ?? 0) + 1);
6
+ }
7
+ return degrees;
8
+ }
@@ -0,0 +1,89 @@
1
+ import { call } from "../daemon/client.js";
2
+ import { evaluateToolCall } from "../core/workflow/enforcement.js";
3
+ import { ensureDaemon, isEnterpriseProject, normalizeToolCall, parseInput, readStdin, resolveDaemonEntry, sendHeartbeat, serializeForAudit, getStringField, } from "./shared.js";
4
+ async function main() {
5
+ const raw = await readStdin();
6
+ const input = parseInput(raw);
7
+ const normalized = normalizeToolCall(input);
8
+ const enterprise = isEnterpriseProject(normalized.cwd);
9
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
10
+ if (normalized.sessionId) {
11
+ void sendHeartbeat({
12
+ cli: "codex",
13
+ hook: "PermissionRequest",
14
+ session_id: normalized.sessionId,
15
+ cwd: normalized.cwd,
16
+ });
17
+ }
18
+ const activeTaskId = process.env.CORTEX_ACTIVE_TASK_ID?.trim();
19
+ if (activeTaskId) {
20
+ try {
21
+ const verdict = evaluateToolCall({
22
+ cwd: normalized.cwd,
23
+ taskId: activeTaskId,
24
+ call: { toolName: normalized.toolName, toolInput: normalized.toolInput },
25
+ });
26
+ if (!verdict.allowed) {
27
+ process.stderr.write(`[cortex] Permission denied by workflow: ${verdict.reason}\n`);
28
+ process.exit(2);
29
+ }
30
+ }
31
+ catch (err) {
32
+ process.stderr.write(`[cortex] permission capability evaluation failed (${err instanceof Error ? err.message : String(err)}); deferring to policy.check\n`);
33
+ }
34
+ }
35
+ const policyPayload = {
36
+ tool: normalized.toolName,
37
+ cwd: normalized.cwd,
38
+ input: normalized.toolInput,
39
+ };
40
+ const policyRes = await call("policy.check", policyPayload, {
41
+ timeoutMs: 5000,
42
+ });
43
+ const approvalReason = getStringField(input, ["reason", "permission_reason", "permissionReason"]);
44
+ const auditPayload = {
45
+ cwd: normalized.cwd,
46
+ entry: {
47
+ timestamp: new Date().toISOString(),
48
+ tool: "permission.request",
49
+ input: {
50
+ tool_name: normalized.toolName,
51
+ command: normalized.toolInput.command ?? null,
52
+ prefix_rule: normalized.toolInput.prefix_rule ?? null,
53
+ sandbox_permissions: normalized.toolInput.sandbox_permissions ?? null,
54
+ },
55
+ event_type: "session",
56
+ evidence_level: "diagnostic",
57
+ resource_type: "approval_request",
58
+ session_id: normalized.sessionId,
59
+ metadata: {
60
+ tool_name: normalized.toolName,
61
+ reason: approvalReason ?? null,
62
+ command_preview: serializeForAudit(normalized.toolInput.command),
63
+ },
64
+ ...(policyRes.ok && !policyRes.result.allow
65
+ ? {
66
+ status: "error",
67
+ }
68
+ : {}),
69
+ },
70
+ };
71
+ void call("audit.log", auditPayload, { timeoutMs: 3000 });
72
+ if (policyRes.ok) {
73
+ if (policyRes.result.allow) {
74
+ process.exit(0);
75
+ }
76
+ process.stderr.write(`[cortex] Permission denied by policy: ${policyRes.result.reason ?? "unspecified"}\n`);
77
+ process.exit(2);
78
+ }
79
+ if (enterprise) {
80
+ process.stderr.write(`[cortex] Enterprise daemon unreachable (${policyRes.error}). Denying permission per fail-closed policy.\n`);
81
+ process.exit(2);
82
+ }
83
+ process.stderr.write(`[cortex] Daemon unreachable (${policyRes.error}). Allowing permission request (community mode).\n`);
84
+ process.exit(0);
85
+ }
86
+ main().catch((err) => {
87
+ process.stderr.write(`[cortex permission-request] error: ${err instanceof Error ? err.message : String(err)}\n`);
88
+ process.exit(0);
89
+ });
@@ -0,0 +1,105 @@
1
+ import { call } from "../daemon/client.js";
2
+ import { evaluateToolCall } from "../core/workflow/enforcement.js";
3
+ import { ensureDaemon, getBooleanField, getNumberField, getRecordField, getStringField, isEnterpriseProject, normalizeToolCall, parseInput, readStdin, resolveDaemonEntry, sendHeartbeat, serializeForAudit, } from "./shared.js";
4
+ function extractToolOutput(input) {
5
+ const record = getRecordField(input, ["tool_output", "toolOutput", "tool_result", "toolResult", "result"]) ??
6
+ {};
7
+ const outputText = getStringField(input, ["output", "stdout", "stderr"]);
8
+ if (outputText && record.output === undefined) {
9
+ record.output = outputText;
10
+ }
11
+ const success = getBooleanField(input, ["success"]);
12
+ if (success !== undefined && record.success === undefined) {
13
+ record.success = success;
14
+ }
15
+ const exitCode = getNumberField(input, ["exit_code", "exitCode"]);
16
+ if (exitCode !== undefined && record.exit_code === undefined) {
17
+ record.exit_code = exitCode;
18
+ }
19
+ return record;
20
+ }
21
+ async function main() {
22
+ const raw = await readStdin();
23
+ const input = parseInput(raw);
24
+ const normalized = normalizeToolCall(input);
25
+ const enterprise = isEnterpriseProject(normalized.cwd);
26
+ const toolOutput = extractToolOutput(input);
27
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
28
+ if (normalized.sessionId) {
29
+ void sendHeartbeat({
30
+ cli: "codex",
31
+ hook: "PostToolUse",
32
+ session_id: normalized.sessionId,
33
+ cwd: normalized.cwd,
34
+ });
35
+ }
36
+ const success = getBooleanField(input, ["success"]) ??
37
+ (typeof toolOutput.exit_code === "number" ? toolOutput.exit_code === 0 : undefined);
38
+ const durationMs = getNumberField(input, ["duration_ms", "durationMs"]);
39
+ const auditPayload = {
40
+ cwd: normalized.cwd,
41
+ entry: {
42
+ timestamp: new Date().toISOString(),
43
+ tool: normalized.toolName,
44
+ input: normalized.toolInput,
45
+ event_type: "tool",
46
+ evidence_level: "diagnostic",
47
+ resource_type: "tool_result",
48
+ session_id: normalized.sessionId,
49
+ ...(durationMs !== undefined ? { duration_ms: durationMs } : {}),
50
+ ...(success !== undefined
51
+ ? { status: success ? "success" : "error" }
52
+ : {}),
53
+ metadata: {
54
+ hook: "PostToolUse",
55
+ tool_output_preview: serializeForAudit(toolOutput),
56
+ },
57
+ },
58
+ };
59
+ void call("audit.log", auditPayload, { timeoutMs: 3000 });
60
+ const activeTaskId = process.env.CORTEX_ACTIVE_TASK_ID?.trim();
61
+ if (activeTaskId) {
62
+ try {
63
+ const verdict = evaluateToolCall({
64
+ cwd: normalized.cwd,
65
+ taskId: activeTaskId,
66
+ call: { toolName: normalized.toolName, toolInput: normalized.toolInput },
67
+ });
68
+ if (!verdict.allowed) {
69
+ process.stderr.write(`[cortex] Blocked after tool execution: ${verdict.reason}\n`);
70
+ process.exit(2);
71
+ }
72
+ }
73
+ catch (err) {
74
+ process.stderr.write(`[cortex] post-tool capability evaluation failed (${err instanceof Error ? err.message : String(err)}); deferring to policy.check\n`);
75
+ }
76
+ }
77
+ if (Object.keys(toolOutput).length === 0) {
78
+ process.exit(0);
79
+ }
80
+ const policyPayload = {
81
+ tool: `${normalized.toolName}.result`,
82
+ cwd: normalized.cwd,
83
+ input: toolOutput,
84
+ };
85
+ const policyRes = await call("policy.check", policyPayload, {
86
+ timeoutMs: 5000,
87
+ });
88
+ if (policyRes.ok) {
89
+ if (policyRes.result.allow) {
90
+ process.exit(0);
91
+ }
92
+ process.stderr.write(`[cortex] Blocked after tool execution by policy: ${policyRes.result.reason ?? "unspecified"}\n`);
93
+ process.exit(2);
94
+ }
95
+ if (enterprise) {
96
+ process.stderr.write(`[cortex] Enterprise daemon unreachable (${policyRes.error}). Blocking continuation per fail-closed policy.\n`);
97
+ process.exit(2);
98
+ }
99
+ process.stderr.write(`[cortex] Daemon unreachable (${policyRes.error}). Allowing continuation (community mode).\n`);
100
+ process.exit(0);
101
+ }
102
+ main().catch((err) => {
103
+ process.stderr.write(`[cortex post-tool-use] error: ${err instanceof Error ? err.message : String(err)}\n`);
104
+ process.exit(0);
105
+ });
@@ -0,0 +1,29 @@
1
+ import { call } from "../daemon/client.js";
2
+ import { ensureDaemon, parseInput, readStdin, resolveDaemonEntry, } from "./shared.js";
3
+ async function main() {
4
+ const raw = await readStdin();
5
+ const input = parseInput(raw);
6
+ const cwd = input.cwd ?? process.cwd();
7
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
8
+ const payload = {
9
+ cwd,
10
+ entry: {
11
+ timestamp: new Date().toISOString(),
12
+ tool: "session.pre_compact",
13
+ input: {
14
+ session_id: input.session_id ?? null,
15
+ context_size_tokens: input.context_size_tokens ?? null,
16
+ },
17
+ event_type: "session",
18
+ evidence_level: "diagnostic",
19
+ resource_type: "session",
20
+ session_id: input.session_id,
21
+ metadata: {
22
+ context_size_tokens: input.context_size_tokens ?? null,
23
+ },
24
+ },
25
+ };
26
+ await call("audit.log", payload, { timeoutMs: 3000 });
27
+ process.exit(0);
28
+ }
29
+ main().catch(() => process.exit(0));
@@ -0,0 +1,78 @@
1
+ import { call } from "../daemon/client.js";
2
+ import { ensureDaemon, isEnterpriseProject, parseInput, readStdin, resolveDaemonEntry, sendHeartbeat, } from "./shared.js";
3
+ import { evaluateToolCall } from "../core/workflow/enforcement.js";
4
+ async function main() {
5
+ const raw = await readStdin();
6
+ const input = parseInput(raw);
7
+ const cwd = input.cwd || process.cwd();
8
+ const tool = input.tool_name || "unknown";
9
+ const enterprise = isEnterpriseProject(cwd);
10
+ // Try to bring the daemon up if it's not already.
11
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
12
+ if (input.session_id) {
13
+ void sendHeartbeat({
14
+ cli: "claude",
15
+ hook: "PreToolUse",
16
+ session_id: input.session_id,
17
+ cwd,
18
+ });
19
+ }
20
+ // Workflow capability gate. Runs before policy.check so harness-level
21
+ // restrictions block before the daemon's general policy machinery sees
22
+ // the call. No-op when CORTEX_ACTIVE_TASK_ID is unset.
23
+ const activeTaskId = process.env.CORTEX_ACTIVE_TASK_ID?.trim();
24
+ if (activeTaskId) {
25
+ try {
26
+ const verdict = evaluateToolCall({
27
+ cwd,
28
+ taskId: activeTaskId,
29
+ call: { toolName: tool, toolInput: input.tool_input ?? {} },
30
+ });
31
+ if (!verdict.allowed) {
32
+ process.stderr.write(`[cortex] Blocked by harness capability: ${verdict.reason}\n`);
33
+ process.exit(2);
34
+ }
35
+ }
36
+ catch (err) {
37
+ // Capability evaluation should never crash the hook — if it does,
38
+ // log and fall through to the existing policy.check rather than
39
+ // accidentally blocking a legitimate tool.
40
+ process.stderr.write(`[cortex] capability evaluation failed (${err instanceof Error ? err.message : String(err)}); deferring to policy.check\n`);
41
+ }
42
+ }
43
+ const payload = {
44
+ tool,
45
+ cwd,
46
+ input: input.tool_input ?? {},
47
+ };
48
+ const res = await call("policy.check", payload, {
49
+ timeoutMs: 5000,
50
+ });
51
+ if (res.ok) {
52
+ if (res.result.allow) {
53
+ process.exit(0);
54
+ }
55
+ else {
56
+ // Hook spec: exit 2 + stderr message → Claude Code blocks the tool.
57
+ process.stderr.write(`[cortex] Blocked by policy: ${res.result.reason ?? "unspecified"}\n`);
58
+ process.exit(2);
59
+ }
60
+ }
61
+ // Daemon unreachable — apply split fail-mode.
62
+ if (enterprise) {
63
+ process.stderr.write(`[cortex] Enterprise daemon unreachable (${res.error}). Blocking tool per fail-closed policy.\n`);
64
+ process.stderr.write("[cortex] Start the daemon with: cortex daemon start\n");
65
+ process.exit(2);
66
+ }
67
+ else {
68
+ process.stderr.write(`[cortex] Daemon unreachable (${res.error}). Allowing tool (community mode).\n`);
69
+ process.exit(0);
70
+ }
71
+ }
72
+ main().catch((err) => {
73
+ process.stderr.write(`[cortex pre-tool-use] error: ${err instanceof Error ? err.message : String(err)}\n`);
74
+ // Hook errors should not block the user — fail-open on internal exceptions
75
+ // regardless of mode. The split fail-mode applies only to the explicit
76
+ // "daemon unreachable" path above.
77
+ process.exit(0);
78
+ });
@@ -0,0 +1,43 @@
1
+ import { call } from "../daemon/client.js";
2
+ import { ensureDaemon, parseInput, readStdin, resolveDaemonEntry, sendHeartbeat, } from "./shared.js";
3
+ async function main() {
4
+ const raw = await readStdin();
5
+ const input = parseInput(raw);
6
+ const cwd = input.cwd ?? process.cwd();
7
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
8
+ // Audit first — even if telemetry push fails, we want the session-end
9
+ // record locally so cortex-web can later derive missing data.
10
+ const auditPayload = {
11
+ cwd,
12
+ entry: {
13
+ timestamp: new Date().toISOString(),
14
+ tool: "session.end",
15
+ input: { session_id: input.session_id ?? null },
16
+ event_type: "session",
17
+ evidence_level: "diagnostic",
18
+ resource_type: "session",
19
+ session_id: input.session_id,
20
+ },
21
+ };
22
+ await call("audit.log", auditPayload, { timeoutMs: 3000 });
23
+ // Final flush — best-effort, may have nothing on disk if MCP already
24
+ // flushed during shutdown.
25
+ const flushPayload = {
26
+ reason: "session_end",
27
+ session_id: input.session_id,
28
+ cwd,
29
+ };
30
+ await call("telemetry.flush", flushPayload, {
31
+ timeoutMs: 5000,
32
+ });
33
+ if (input.session_id) {
34
+ await sendHeartbeat({
35
+ cli: "claude",
36
+ hook: "SessionEnd",
37
+ session_id: input.session_id,
38
+ cwd,
39
+ });
40
+ }
41
+ process.exit(0);
42
+ }
43
+ main().catch(() => process.exit(0));
@@ -0,0 +1,41 @@
1
+ import { call } from "../daemon/client.js";
2
+ import { ensureDaemon, isEnforcedMode, parseInput, readStdin, readTamperLockJson, resolveDaemonEntry, sendHeartbeat, } from "./shared.js";
3
+ async function main() {
4
+ const raw = await readStdin();
5
+ const input = parseInput(raw);
6
+ const cwd = input.cwd ?? process.cwd();
7
+ const sessionId = input.session_id ?? "";
8
+ // Tamper-lock check happens BEFORE we spawn the daemon. We don't want
9
+ // a tampered project to keep getting fresh daemons started.
10
+ const lock = readTamperLockJson(cwd);
11
+ if (lock && isEnforcedMode(cwd)) {
12
+ process.stderr.write("[cortex] Govern enforced: session blocked because hook tampering was detected.\n" +
13
+ " Run 'sudo cortex enterprise repair' to verify managed-settings\n" +
14
+ " integrity and clear .context/.cortex-tamper.lock, then retry.\n");
15
+ process.exit(2);
16
+ }
17
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
18
+ const payload = {
19
+ cwd,
20
+ entry: {
21
+ timestamp: new Date().toISOString(),
22
+ tool: "session.start",
23
+ input: { session_id: sessionId || null },
24
+ event_type: "session",
25
+ evidence_level: "diagnostic",
26
+ resource_type: "session",
27
+ session_id: sessionId,
28
+ },
29
+ };
30
+ await call("audit.log", payload, { timeoutMs: 3000 });
31
+ if (sessionId) {
32
+ await sendHeartbeat({
33
+ cli: "claude",
34
+ hook: "SessionStart",
35
+ session_id: sessionId,
36
+ cwd,
37
+ });
38
+ }
39
+ process.exit(0);
40
+ }
41
+ main().catch(() => process.exit(0));
@@ -0,0 +1,194 @@
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
+ export async function readStdin() {
6
+ return new Promise((resolve) => {
7
+ let data = "";
8
+ process.stdin.setEncoding("utf8");
9
+ process.stdin.on("data", (chunk) => {
10
+ data += chunk;
11
+ });
12
+ process.stdin.on("end", () => resolve(data));
13
+ // If stdin is a TTY (running interactively for testing), don't hang.
14
+ if (process.stdin.isTTY)
15
+ resolve("");
16
+ });
17
+ }
18
+ export function parseInput(raw) {
19
+ if (!raw.trim())
20
+ return {};
21
+ try {
22
+ return JSON.parse(raw);
23
+ }
24
+ catch {
25
+ return {};
26
+ }
27
+ }
28
+ function isRecord(value) {
29
+ return value !== null && typeof value === "object" && !Array.isArray(value);
30
+ }
31
+ export function getStringField(input, keys) {
32
+ for (const key of keys) {
33
+ const value = input[key];
34
+ if (typeof value === "string" && value.trim())
35
+ return value;
36
+ }
37
+ return undefined;
38
+ }
39
+ export function getNumberField(input, keys) {
40
+ for (const key of keys) {
41
+ const value = input[key];
42
+ if (typeof value === "number" && Number.isFinite(value))
43
+ return value;
44
+ }
45
+ return undefined;
46
+ }
47
+ export function getBooleanField(input, keys) {
48
+ for (const key of keys) {
49
+ const value = input[key];
50
+ if (typeof value === "boolean")
51
+ return value;
52
+ }
53
+ return undefined;
54
+ }
55
+ export function getRecordField(input, keys) {
56
+ for (const key of keys) {
57
+ const value = input[key];
58
+ if (isRecord(value))
59
+ return value;
60
+ }
61
+ return undefined;
62
+ }
63
+ export function normalizeToolInput(value) {
64
+ if (isRecord(value))
65
+ return value;
66
+ return {};
67
+ }
68
+ export function normalizeToolCall(input) {
69
+ const cwd = getStringField(input, ["cwd", "working_directory", "workingDirectory"]) ?? process.cwd();
70
+ const sessionId = getStringField(input, ["session_id", "sessionId"]);
71
+ const toolName = getStringField(input, ["tool_name", "toolName", "tool"]) ??
72
+ (typeof input.command === "string" ? "Bash" : "unknown");
73
+ const toolInput = getRecordField(input, ["tool_input", "toolInput", "tool_args", "toolArgs", "input", "args"]) ??
74
+ {};
75
+ if (typeof input.command === "string" && toolInput.command === undefined) {
76
+ toolInput.command = input.command;
77
+ }
78
+ if (Array.isArray(input.prefix_rule) && toolInput.prefix_rule === undefined) {
79
+ toolInput.prefix_rule = input.prefix_rule;
80
+ }
81
+ if (typeof input.sandbox_permissions === "string" &&
82
+ toolInput.sandbox_permissions === undefined) {
83
+ toolInput.sandbox_permissions = input.sandbox_permissions;
84
+ }
85
+ return {
86
+ cwd,
87
+ ...(sessionId ? { sessionId } : {}),
88
+ toolName,
89
+ toolInput,
90
+ };
91
+ }
92
+ export function serializeForAudit(value, maxLen = 1200) {
93
+ if (value === undefined)
94
+ return undefined;
95
+ const raw = typeof value === "string"
96
+ ? value
97
+ : (() => {
98
+ try {
99
+ return JSON.stringify(value);
100
+ }
101
+ catch {
102
+ return String(value);
103
+ }
104
+ })();
105
+ if (!raw)
106
+ return undefined;
107
+ return raw.length <= maxLen ? raw : `${raw.slice(0, maxLen)}...`;
108
+ }
109
+ /**
110
+ * Detect whether the current project is running enterprise mode.
111
+ * Lightweight YAML peek — we don't want to load the full config parser
112
+ * on every hook invocation.
113
+ */
114
+ export function isEnterpriseProject(cwd) {
115
+ const candidates = [
116
+ join(cwd, ".context", "enterprise.yml"),
117
+ join(cwd, ".context", "enterprise.yaml"),
118
+ ];
119
+ for (const path of candidates) {
120
+ if (!existsSync(path))
121
+ continue;
122
+ try {
123
+ const raw = readFileSync(path, "utf8");
124
+ // Look for an api_key under enterprise: section.
125
+ // Conservative: any non-empty api_key value implies "intent to be enterprise".
126
+ const match = raw.match(/^\s*api_key:\s*(\S.*?)\s*$/m);
127
+ if (match && match[1] && match[1] !== "")
128
+ return true;
129
+ }
130
+ catch {
131
+ // ignore
132
+ }
133
+ }
134
+ return false;
135
+ }
136
+ export function ensureDaemon(daemonEntry) {
137
+ if (isDaemonRunning())
138
+ return;
139
+ spawnDaemon(daemonEntry);
140
+ }
141
+ /**
142
+ * Resolve daemon entry script path relative to this hook script's location.
143
+ * Hooks live at dist/hooks/<name>.js, daemon at dist/daemon/main.js.
144
+ */
145
+ export function resolveDaemonEntry(hookFileUrl) {
146
+ const hookPath = fileURLToPath(hookFileUrl);
147
+ // dist/hooks/<x>.js → dist/daemon/main.js
148
+ return join(hookPath, "..", "..", "daemon", "main.js");
149
+ }
150
+ /**
151
+ * Send a heartbeat to the daemon for tamper-detection bookkeeping.
152
+ * Failure to reach the daemon is non-fatal — daemon-down is treated as
153
+ * "we don't know" rather than tamper. Returns whether the daemon
154
+ * reported an active tamper-lock for this cwd.
155
+ */
156
+ export async function sendHeartbeat(payload) {
157
+ const result = await call("heartbeat", { ...payload, ts: new Date().toISOString() }, { timeoutMs: 1500 });
158
+ if (!result.ok)
159
+ return { tamperLockActive: false };
160
+ return { tamperLockActive: result.result.tamper_lock_active === true };
161
+ }
162
+ const TAMPER_LOCK_FILENAME = ".cortex-tamper.lock";
163
+ export function readTamperLockJson(cwd) {
164
+ const path = join(cwd, ".context", TAMPER_LOCK_FILENAME);
165
+ if (!existsSync(path))
166
+ return null;
167
+ try {
168
+ return JSON.parse(readFileSync(path, "utf8"));
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ /**
175
+ * True iff this cwd is in enforced govern mode. Reads govern.local.json,
176
+ * which is written by the install/install-flow. We don't fall back to
177
+ * enterprise.yml because the user's intent is captured at install time.
178
+ */
179
+ export function isEnforcedMode(cwd) {
180
+ const path = join(cwd, ".context", "govern.local.json");
181
+ if (!existsSync(path))
182
+ return false;
183
+ try {
184
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
185
+ for (const inst of Object.values(parsed.installs ?? {})) {
186
+ if (inst.mode === "enforced")
187
+ return true;
188
+ }
189
+ return false;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ }
@@ -0,0 +1,28 @@
1
+ import { call } from "../daemon/client.js";
2
+ import { ensureDaemon, parseInput, readStdin, resolveDaemonEntry, } from "./shared.js";
3
+ async function main() {
4
+ const raw = await readStdin();
5
+ const input = parseInput(raw);
6
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
7
+ const payload = {
8
+ reason: "stop",
9
+ session_id: input.session_id,
10
+ cwd: input.cwd ?? process.cwd(),
11
+ };
12
+ const res = await call("telemetry.flush", payload, {
13
+ timeoutMs: 5000,
14
+ });
15
+ if (res.ok && res.result.flushed) {
16
+ process.stderr.write(`[cortex] Telemetry flushed: ${res.result.events_pushed} events\n`);
17
+ }
18
+ else if (!res.ok) {
19
+ // Best-effort: silent in normal output. Daemon-reachability is logged
20
+ // through other channels (PreToolUse warnings, daemon log).
21
+ process.stderr.write(`[cortex] Telemetry flush skipped: ${res.error}\n`);
22
+ }
23
+ process.exit(0);
24
+ }
25
+ main().catch(() => {
26
+ // Telemetry must never block. Swallow all errors.
27
+ process.exit(0);
28
+ });