@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.
Files changed (214) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/README.md +24 -3
  3. package/bin/cli/arguments.mjs +60 -0
  4. package/bin/cli/context-passthrough.mjs +129 -0
  5. package/bin/cli/daemon.mjs +157 -0
  6. package/bin/cli/enterprise.mjs +516 -0
  7. package/bin/cli/help.mjs +88 -0
  8. package/bin/cli/hooks.mjs +199 -0
  9. package/bin/cli/mcp-command.mjs +87 -0
  10. package/bin/cli/paths.mjs +14 -0
  11. package/bin/cli/process.mjs +49 -0
  12. package/bin/cli/project-commands.mjs +237 -0
  13. package/bin/cli/project-runtime.mjs +34 -0
  14. package/bin/cli/query-command.mjs +18 -0
  15. package/bin/cli/router.mjs +66 -0
  16. package/bin/cli/run-command.mjs +44 -0
  17. package/bin/cli/scaffold-ownership.mjs +936 -0
  18. package/bin/cli/scaffold.mjs +687 -0
  19. package/bin/cli/stage-command.mjs +9 -0
  20. package/bin/cli/telemetry-command.mjs +18 -0
  21. package/bin/cli/trusted-runtime.mjs +18 -0
  22. package/bin/cortex.mjs +12 -1607
  23. package/bin/daemon-control.mjs +162 -0
  24. package/mcp-registry-submission.json +1 -1
  25. package/package.json +8 -3
  26. package/scaffold/mcp/dist/.cortex-build-hash +1 -0
  27. package/scaffold/mcp/dist/cli/enterprise-setup.js +134 -0
  28. package/scaffold/mcp/dist/cli/govern.js +937 -0
  29. package/scaffold/mcp/dist/cli/query.js +409 -0
  30. package/scaffold/mcp/dist/cli/run.js +295 -0
  31. package/scaffold/mcp/dist/cli/stage.js +308 -0
  32. package/scaffold/mcp/dist/cli/telemetry-test.js +141 -0
  33. package/scaffold/mcp/dist/cli/ungoverned-detector.js +133 -0
  34. package/scaffold/mcp/dist/contextEntities.js +282 -0
  35. package/scaffold/mcp/dist/core/audit/query.js +72 -0
  36. package/scaffold/mcp/dist/core/audit/writer.js +28 -0
  37. package/scaffold/mcp/dist/core/config.js +235 -0
  38. package/scaffold/mcp/dist/core/enterprise-host-identity.js +193 -0
  39. package/scaffold/mcp/dist/core/enterprise-identity.js +18 -0
  40. package/scaffold/mcp/dist/core/enterprise-rotation.js +158 -0
  41. package/scaffold/mcp/dist/core/govern-paths.js +21 -0
  42. package/scaffold/mcp/dist/core/index.js +14 -0
  43. package/scaffold/mcp/dist/core/license.js +278 -0
  44. package/scaffold/mcp/dist/core/policy/enforce.js +66 -0
  45. package/scaffold/mcp/dist/core/policy/injection.js +172 -0
  46. package/scaffold/mcp/dist/core/policy/store.js +179 -0
  47. package/scaffold/mcp/dist/core/rbac/check.js +30 -0
  48. package/scaffold/mcp/dist/core/secure-endpoint.js +25 -0
  49. package/scaffold/mcp/dist/core/telemetry/collector.js +285 -0
  50. package/scaffold/mcp/dist/core/telemetry/state-dir.js +31 -0
  51. package/scaffold/mcp/dist/core/validators/builtins.js +632 -0
  52. package/scaffold/mcp/dist/core/validators/config.js +44 -0
  53. package/scaffold/mcp/dist/core/validators/engine.js +120 -0
  54. package/scaffold/mcp/dist/core/validators/evaluators/code_comments.js +236 -0
  55. package/scaffold/mcp/dist/core/validators/evaluators/regex.js +116 -0
  56. package/scaffold/mcp/dist/core/workflow/artifact-io.js +103 -0
  57. package/scaffold/mcp/dist/core/workflow/capabilities.js +88 -0
  58. package/scaffold/mcp/dist/core/workflow/default-workflows.js +102 -0
  59. package/scaffold/mcp/dist/core/workflow/enforcement.js +164 -0
  60. package/scaffold/mcp/dist/core/workflow/envelope.js +132 -0
  61. package/scaffold/mcp/dist/core/workflow/index.js +11 -0
  62. package/scaffold/mcp/dist/core/workflow/mcp-tools.js +175 -0
  63. package/scaffold/mcp/dist/core/workflow/resolution.js +69 -0
  64. package/scaffold/mcp/dist/core/workflow/run-lifecycle.js +123 -0
  65. package/scaffold/mcp/dist/core/workflow/schemas.js +141 -0
  66. package/scaffold/mcp/dist/core/workflow/synced-capability-registry.js +60 -0
  67. package/scaffold/mcp/dist/core/workflow/synced-registry.js +60 -0
  68. package/scaffold/mcp/dist/daemon/capability-sync-checker.js +242 -0
  69. package/scaffold/mcp/dist/daemon/client.js +130 -0
  70. package/scaffold/mcp/dist/daemon/egress-proxy.js +288 -0
  71. package/scaffold/mcp/dist/daemon/global-host-events.js +222 -0
  72. package/scaffold/mcp/dist/daemon/heartbeat-pusher.js +116 -0
  73. package/scaffold/mcp/dist/daemon/heartbeat-tracker.js +165 -0
  74. package/scaffold/mcp/dist/daemon/host-events-pusher.js +249 -0
  75. package/scaffold/mcp/dist/daemon/main.js +449 -0
  76. package/scaffold/mcp/dist/daemon/paths.js +36 -0
  77. package/scaffold/mcp/dist/daemon/project-service-registry.js +78 -0
  78. package/scaffold/mcp/dist/daemon/protocol.js +8 -0
  79. package/scaffold/mcp/dist/daemon/server.js +180 -0
  80. package/scaffold/mcp/dist/daemon/skill-sync-checker.js +557 -0
  81. package/scaffold/mcp/dist/daemon/sync-checker.js +165 -0
  82. package/scaffold/mcp/dist/daemon/ungoverned-scanner.js +136 -0
  83. package/scaffold/mcp/dist/daemon/workflow-sync-checker.js +249 -0
  84. package/scaffold/mcp/dist/defaults.js +6 -0
  85. package/scaffold/mcp/dist/embed.js +627 -0
  86. package/scaffold/mcp/dist/embedScheduler.js +479 -0
  87. package/scaffold/mcp/dist/embeddings.js +167 -0
  88. package/scaffold/mcp/dist/enterprise/audit/push.js +66 -0
  89. package/scaffold/mcp/dist/enterprise/index.js +327 -0
  90. package/scaffold/mcp/dist/enterprise/model/deploy.js +27 -0
  91. package/scaffold/mcp/dist/enterprise/policy/sync.js +129 -0
  92. package/scaffold/mcp/dist/enterprise/privacy/boundary.js +184 -0
  93. package/scaffold/mcp/dist/enterprise/reviews/changed-files.js +33 -0
  94. package/scaffold/mcp/dist/enterprise/reviews/pattern-context.js +202 -0
  95. package/scaffold/mcp/dist/enterprise/reviews/policy-selection.js +46 -0
  96. package/scaffold/mcp/dist/enterprise/reviews/push.js +102 -0
  97. package/scaffold/mcp/dist/enterprise/reviews/trust-state.js +186 -0
  98. package/scaffold/mcp/dist/enterprise/telemetry/sync.js +57 -0
  99. package/scaffold/mcp/dist/enterprise/tools/enterprise.js +826 -0
  100. package/scaffold/mcp/dist/enterprise/tools/harness.js +40 -0
  101. package/scaffold/mcp/dist/enterprise/tools/walk.js +73 -0
  102. package/scaffold/mcp/dist/enterprise/violations/push.js +77 -0
  103. package/scaffold/mcp/dist/enterprise/workflow/push.js +44 -0
  104. package/scaffold/mcp/dist/enterprise/workflow/state.js +329 -0
  105. package/scaffold/mcp/dist/frontmatter.js +33 -0
  106. package/scaffold/mcp/dist/graph.js +769 -0
  107. package/scaffold/mcp/dist/graphCsv.js +55 -0
  108. package/scaffold/mcp/dist/graphMetrics.js +8 -0
  109. package/scaffold/mcp/dist/hooks/permission-request.js +89 -0
  110. package/scaffold/mcp/dist/hooks/post-tool-use.js +105 -0
  111. package/scaffold/mcp/dist/hooks/pre-compact.js +29 -0
  112. package/scaffold/mcp/dist/hooks/pre-tool-use.js +78 -0
  113. package/scaffold/mcp/dist/hooks/session-end.js +43 -0
  114. package/scaffold/mcp/dist/hooks/session-start.js +41 -0
  115. package/scaffold/mcp/dist/hooks/shared.js +194 -0
  116. package/scaffold/mcp/dist/hooks/stop.js +28 -0
  117. package/scaffold/mcp/dist/hooks/user-prompt-submit.js +33 -0
  118. package/scaffold/mcp/dist/impactPresentation.js +137 -0
  119. package/scaffold/mcp/dist/impactRanking.js +191 -0
  120. package/scaffold/mcp/dist/impactResponse.js +30 -0
  121. package/scaffold/mcp/dist/impactResults.js +105 -0
  122. package/scaffold/mcp/dist/impactSeed.js +20 -0
  123. package/scaffold/mcp/dist/impactTraversal.js +64 -0
  124. package/scaffold/mcp/dist/jsonl.js +77 -0
  125. package/scaffold/mcp/dist/loadGraph.js +759 -0
  126. package/scaffold/mcp/dist/lruCache.js +38 -0
  127. package/scaffold/mcp/dist/paths.js +97 -0
  128. package/scaffold/mcp/dist/patternEvidence.js +272 -0
  129. package/scaffold/mcp/dist/plugin.js +81 -0
  130. package/scaffold/mcp/dist/presets.js +78 -0
  131. package/scaffold/mcp/dist/relatedResponse.js +18 -0
  132. package/scaffold/mcp/dist/relatedTraversal.js +78 -0
  133. package/scaffold/mcp/dist/rules.js +23 -0
  134. package/scaffold/mcp/dist/search.js +212 -0
  135. package/scaffold/mcp/dist/searchCore.js +457 -0
  136. package/scaffold/mcp/dist/searchResults.js +230 -0
  137. package/scaffold/mcp/dist/server.js +317 -0
  138. package/scaffold/mcp/dist/types.js +1 -0
  139. package/scaffold/mcp/package-lock.json +321 -200
  140. package/scaffold/mcp/package.json +15 -6
  141. package/scaffold/mcp/src/cli/enterprise-setup.ts +82 -8
  142. package/scaffold/mcp/src/cli/govern.ts +137 -52
  143. package/scaffold/mcp/src/cli/run.ts +53 -19
  144. package/scaffold/mcp/src/cli/stage.ts +8 -3
  145. package/scaffold/mcp/src/core/config.ts +9 -2
  146. package/scaffold/mcp/src/core/enterprise-host-identity.ts +259 -0
  147. package/scaffold/mcp/src/core/enterprise-identity.ts +20 -0
  148. package/scaffold/mcp/src/core/enterprise-rotation.ts +185 -0
  149. package/scaffold/mcp/src/core/govern-paths.ts +30 -0
  150. package/scaffold/mcp/src/core/license.ts +186 -17
  151. package/scaffold/mcp/src/core/secure-endpoint.ts +23 -0
  152. package/scaffold/mcp/src/core/workflow/enforcement.ts +8 -1
  153. package/scaffold/mcp/src/core/workflow/mcp-tools.ts +3 -0
  154. package/scaffold/mcp/src/core/workflow/resolution.ts +9 -1
  155. package/scaffold/mcp/src/core/workflow/synced-capability-registry.ts +15 -0
  156. package/scaffold/mcp/src/core/workflow/synced-registry.ts +15 -0
  157. package/scaffold/mcp/src/daemon/capability-sync-checker.ts +38 -2
  158. package/scaffold/mcp/src/daemon/egress-proxy.ts +14 -8
  159. package/scaffold/mcp/src/daemon/global-host-events.ts +288 -0
  160. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +4 -0
  161. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +2 -0
  162. package/scaffold/mcp/src/daemon/host-events-pusher.ts +5 -0
  163. package/scaffold/mcp/src/daemon/main.ts +99 -25
  164. package/scaffold/mcp/src/daemon/project-service-registry.ts +107 -0
  165. package/scaffold/mcp/src/daemon/skill-sync-checker.ts +368 -31
  166. package/scaffold/mcp/src/daemon/sync-checker.ts +4 -0
  167. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +57 -25
  168. package/scaffold/mcp/src/daemon/workflow-sync-checker.ts +41 -2
  169. package/scaffold/mcp/src/enterprise/audit/push.ts +8 -0
  170. package/scaffold/mcp/src/enterprise/policy/sync.ts +12 -0
  171. package/scaffold/mcp/src/enterprise/reviews/push.ts +9 -0
  172. package/scaffold/mcp/src/enterprise/reviews/trust-state.ts +3 -1
  173. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +9 -0
  174. package/scaffold/mcp/src/enterprise/violations/push.ts +9 -0
  175. package/scaffold/mcp/src/enterprise/workflow/push.ts +7 -0
  176. package/scaffold/mcp/src/plugin.ts +20 -0
  177. package/scaffold/mcp/tests/copilot-shim.test.mjs +19 -1
  178. package/scaffold/mcp/tests/egress-proxy.test.mjs +37 -0
  179. package/scaffold/mcp/tests/enterprise-identity-sync.test.mjs +401 -0
  180. package/scaffold/mcp/tests/enterprise-setup.test.mjs +189 -0
  181. package/scaffold/mcp/tests/global-host-events.test.mjs +81 -0
  182. package/scaffold/mcp/tests/govern-install.test.mjs +95 -2
  183. package/scaffold/mcp/tests/govern-repair.test.mjs +20 -3
  184. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +26 -0
  185. package/scaffold/mcp/tests/license.test.mjs +367 -0
  186. package/scaffold/mcp/tests/project-service-registry.test.mjs +172 -0
  187. package/scaffold/mcp/tests/review-trust-contract.test.mjs +18 -0
  188. package/scaffold/mcp/tests/secure-enterprise-endpoint.test.mjs +46 -0
  189. package/scaffold/mcp/tests/skill-sync-checker.test.mjs +446 -2
  190. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +104 -22
  191. package/scaffold/mcp/tests/workflow-cli.test.mjs +14 -1
  192. package/scaffold/mcp/tests/workflow-synced-capabilities.test.mjs +37 -0
  193. package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +40 -1
  194. package/scaffold/ownership/baseline-v2.4.1.json +22 -0
  195. package/scaffold/ownership/current.json +4 -0
  196. package/scaffold/ownership/v1.json +456 -0
  197. package/scaffold/scripts/ingest-parsers.mjs +1 -387
  198. package/scaffold/scripts/ingest-worker.mjs +4 -1
  199. package/scaffold/scripts/ingest.mjs +5 -3899
  200. package/scaffold/scripts/lib/ingest/arguments.mjs +78 -0
  201. package/scaffold/scripts/lib/ingest/chunks.mjs +212 -0
  202. package/scaffold/scripts/lib/ingest/config.mjs +120 -0
  203. package/scaffold/scripts/lib/ingest/constants.mjs +222 -0
  204. package/scaffold/scripts/lib/ingest/files.mjs +387 -0
  205. package/scaffold/scripts/lib/ingest/incremental-state.mjs +131 -0
  206. package/scaffold/scripts/lib/ingest/io.mjs +78 -0
  207. package/scaffold/scripts/lib/ingest/main.mjs +76 -0
  208. package/scaffold/scripts/lib/ingest/parser-composition.mjs +140 -0
  209. package/scaffold/scripts/lib/ingest/parser-registry.mjs +387 -0
  210. package/scaffold/scripts/lib/ingest/pipeline-stages.mjs +1625 -0
  211. package/scaffold/scripts/lib/ingest/projects.mjs +272 -0
  212. package/scaffold/scripts/lib/ingest/relations.mjs +860 -0
  213. package/scaffold/scripts/lib/ingest/runtime-paths.mjs +11 -0
  214. package/scaffold/scripts/lib/ingest/workers.mjs +264 -0
@@ -0,0 +1,937 @@
1
+ import { writeFileSync, readFileSync, readdirSync, statSync, mkdirSync, existsSync, renameSync, unlinkSync, chmodSync, } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { platform, hostname } from "node:os";
4
+ import { randomUUID } from "node:crypto";
5
+ import { loadEnterpriseConfig } from "../core/config.js";
6
+ import { isAllowedEnterpriseEndpoint } from "../core/secure-endpoint.js";
7
+ import { getGovernManagedPath } from "../core/govern-paths.js";
8
+ import { matchesEnterpriseHostIdentity } from "../core/enterprise-host-identity.js";
9
+ import { enterpriseCredentialId } from "../core/license.js";
10
+ import { getDefaultCopilotShimPath, installCopilotShim, uninstallCopilotShim, } from "./run.js";
11
+ import { readTamperLock, removeTamperLock, emitTamperAudit, } from "../daemon/heartbeat-tracker.js";
12
+ const ALL_CLIS = ["claude", "codex", "copilot"];
13
+ const TIER1_CLIS = ["claude", "codex"];
14
+ const SUPPORTED_CODEX_HOOK_EVENTS = new Set([
15
+ "SessionStart",
16
+ "SessionEnd",
17
+ "PreToolUse",
18
+ "PermissionRequest",
19
+ "PostToolUse",
20
+ "UserPromptSubmit",
21
+ "Stop",
22
+ ]);
23
+ export function getManagedSettingsPath(cli, os) {
24
+ if (cli === "copilot") {
25
+ throw new Error(`govern install for ${cli} not yet supported on ${os}`);
26
+ }
27
+ return getGovernManagedPath(cli, os);
28
+ }
29
+ export function requireRoot() {
30
+ const getuid = process.getuid;
31
+ if (typeof getuid !== "function") {
32
+ throw new Error("govern install on this OS requires admin privileges; not yet supported (only macOS + Linux).");
33
+ }
34
+ if (getuid() !== 0) {
35
+ throw new Error("This command writes to a system path. Re-run with: sudo cortex govern <command>");
36
+ }
37
+ }
38
+ function tomlString(value) {
39
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
40
+ }
41
+ function tomlArray(values) {
42
+ const items = values.map((v) => {
43
+ if (typeof v === "string")
44
+ return tomlString(v);
45
+ if (typeof v === "number" || typeof v === "boolean")
46
+ return String(v);
47
+ return tomlString(JSON.stringify(v));
48
+ });
49
+ return `[${items.join(", ")}]`;
50
+ }
51
+ function isRecord(value) {
52
+ return value !== null && typeof value === "object" && !Array.isArray(value);
53
+ }
54
+ function normalizeCodexHookCommand(raw) {
55
+ if (typeof raw === "string" && raw.trim()) {
56
+ return { type: "command", command: raw.trim() };
57
+ }
58
+ if (!isRecord(raw)) {
59
+ return null;
60
+ }
61
+ const command = typeof raw.command === "string" ? raw.command.trim() : "";
62
+ if (!command) {
63
+ return null;
64
+ }
65
+ const timeout = typeof raw.timeout === "number" && Number.isFinite(raw.timeout)
66
+ ? Math.trunc(raw.timeout)
67
+ : undefined;
68
+ const statusMessage = typeof raw.statusMessage === "string" && raw.statusMessage.trim()
69
+ ? raw.statusMessage
70
+ : undefined;
71
+ return {
72
+ type: "command",
73
+ command,
74
+ ...(timeout !== undefined ? { timeout } : {}),
75
+ ...(statusMessage ? { statusMessage } : {}),
76
+ };
77
+ }
78
+ function normalizeCodexHookCommands(raw) {
79
+ if (Array.isArray(raw)) {
80
+ return raw
81
+ .map((entry) => normalizeCodexHookCommand(entry))
82
+ .filter((entry) => entry !== null);
83
+ }
84
+ const single = normalizeCodexHookCommand(raw);
85
+ return single ? [single] : [];
86
+ }
87
+ function normalizeCodexHookHandlers(raw) {
88
+ const entries = Array.isArray(raw) ? raw : [raw];
89
+ const normalized = [];
90
+ for (const entry of entries) {
91
+ if (typeof entry === "string") {
92
+ normalized.push({ hooks: [{ type: "command", command: entry }] });
93
+ continue;
94
+ }
95
+ if (!isRecord(entry)) {
96
+ continue;
97
+ }
98
+ const matcher = typeof entry.matcher === "string" && entry.matcher.trim()
99
+ ? entry.matcher
100
+ : undefined;
101
+ if (Array.isArray(entry.hooks) || typeof entry.hooks === "string" || isRecord(entry.hooks)) {
102
+ const hooks = normalizeCodexHookCommands(entry.hooks);
103
+ if (hooks.length > 0) {
104
+ normalized.push({ ...(matcher ? { matcher } : {}), hooks });
105
+ }
106
+ continue;
107
+ }
108
+ const shorthand = normalizeCodexHookCommand(entry);
109
+ if (shorthand) {
110
+ normalized.push({
111
+ ...(matcher ? { matcher } : {}),
112
+ hooks: [shorthand],
113
+ });
114
+ }
115
+ }
116
+ return normalized;
117
+ }
118
+ function normalizeCodexHooks(managedSettings) {
119
+ const hooksRoot = managedSettings.hooks;
120
+ if (!isRecord(hooksRoot)) {
121
+ return {};
122
+ }
123
+ const normalized = {};
124
+ for (const [eventName, rawHandlers] of Object.entries(hooksRoot)) {
125
+ if (!SUPPORTED_CODEX_HOOK_EVENTS.has(eventName)) {
126
+ continue;
127
+ }
128
+ const handlers = normalizeCodexHookHandlers(rawHandlers);
129
+ if (handlers.length > 0) {
130
+ normalized[eventName] = handlers;
131
+ }
132
+ }
133
+ return normalized;
134
+ }
135
+ function codexManagedHooksDir(requirementsPath) {
136
+ return join(dirname(requirementsPath), "hooks");
137
+ }
138
+ function shellQuotedCommandPath(filePath) {
139
+ return `"${filePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
140
+ }
141
+ function managedCodexHookWrapperContent(hookName) {
142
+ return [
143
+ "#!/bin/sh",
144
+ "set -eu",
145
+ 'CORTEX="${CORTEX_BIN:-cortex}"',
146
+ `exec "$CORTEX" hook ${hookName} "$@"`,
147
+ "",
148
+ ].join("\n");
149
+ }
150
+ function materializeCodexManagedHooks(requirementsPath, managedSettings) {
151
+ const hooksByEvent = normalizeCodexHooks(managedSettings);
152
+ const eventNames = Object.keys(hooksByEvent);
153
+ if (eventNames.length === 0) {
154
+ return { managedHookDir: null, hooksByEvent };
155
+ }
156
+ const managedHookDir = codexManagedHooksDir(requirementsPath);
157
+ mkdirSync(managedHookDir, { recursive: true });
158
+ for (const handlers of Object.values(hooksByEvent)) {
159
+ for (const handler of handlers) {
160
+ for (const hook of handler.hooks) {
161
+ const match = hook.command.match(/^cortex hook ([a-z0-9-]+)$/);
162
+ if (!match) {
163
+ continue;
164
+ }
165
+ const hookName = match[1];
166
+ const wrapperPath = join(managedHookDir, `${hookName}.sh`);
167
+ writeAtomic(wrapperPath, managedCodexHookWrapperContent(hookName), 0o755);
168
+ hook.command = shellQuotedCommandPath(wrapperPath);
169
+ }
170
+ }
171
+ }
172
+ return { managedHookDir, hooksByEvent };
173
+ }
174
+ export function buildCodexRequirementsToml(config, options = {}) {
175
+ const denyRead = config.deny_rules
176
+ .map((r) => r.pattern)
177
+ .filter((p) => /^(Edit|Read|Write)\(/.test(p))
178
+ .map((p) => p.replace(/^[A-Za-z]+\(/, "").replace(/\)$/, ""));
179
+ const hooksByEvent = options.hooksByEvent ?? normalizeCodexHooks(config.managed_settings);
180
+ const lines = [
181
+ "# Cortex govern — codex requirements (Phase 3 of PLAN.govern-mode.md).",
182
+ "# Admin-enforced upper bounds. Users cannot weaken these via ~/.codex/config.toml.",
183
+ "",
184
+ `allowed_sandbox_modes = ${tomlArray(["read-only", "workspace-write"])}`,
185
+ `allowed_approval_policies = ${tomlArray(["untrusted", "on-request"])}`,
186
+ "",
187
+ "[permissions.filesystem]",
188
+ `deny_read = ${tomlArray(denyRead)}`,
189
+ "",
190
+ "[features]",
191
+ "codex_hooks = true",
192
+ "",
193
+ ];
194
+ const eventNames = Object.keys(hooksByEvent);
195
+ if (eventNames.length > 0) {
196
+ lines.push("[hooks]");
197
+ if (options.managedHookDir) {
198
+ lines.push(`managed_dir = ${tomlString(options.managedHookDir)}`);
199
+ }
200
+ lines.push("");
201
+ for (const eventName of eventNames) {
202
+ for (const handler of hooksByEvent[eventName]) {
203
+ lines.push(`[[hooks.${eventName}]]`);
204
+ if (handler.matcher) {
205
+ lines.push(`matcher = ${tomlString(handler.matcher)}`);
206
+ }
207
+ for (const hook of handler.hooks) {
208
+ lines.push("");
209
+ lines.push(`[[hooks.${eventName}.hooks]]`);
210
+ lines.push(`type = ${tomlString(hook.type)}`);
211
+ lines.push(`command = ${tomlString(hook.command)}`);
212
+ if (hook.timeout !== undefined) {
213
+ lines.push(`timeout = ${hook.timeout}`);
214
+ }
215
+ if (hook.statusMessage) {
216
+ lines.push(`statusMessage = ${tomlString(hook.statusMessage)}`);
217
+ }
218
+ }
219
+ lines.push("");
220
+ }
221
+ }
222
+ }
223
+ return lines.join("\n");
224
+ }
225
+ function writeAtomic(filePath, content, mode) {
226
+ mkdirSync(dirname(filePath), { recursive: true });
227
+ const tmp = `${filePath}.tmp.${randomUUID()}`;
228
+ writeFileSync(tmp, content, "utf8");
229
+ if (mode !== undefined) {
230
+ chmodSync(tmp, mode);
231
+ }
232
+ renameSync(tmp, filePath);
233
+ }
234
+ function getStatePath(cwd) {
235
+ return join(cwd, ".context", "govern.local.json");
236
+ }
237
+ function loadState(cwd) {
238
+ try {
239
+ const raw = readFileSync(getStatePath(cwd), "utf8");
240
+ const parsed = JSON.parse(raw);
241
+ return { installs: parsed.installs ?? {} };
242
+ }
243
+ catch {
244
+ return { installs: {} };
245
+ }
246
+ }
247
+ function saveState(cwd, state) {
248
+ const path = getStatePath(cwd);
249
+ writeAtomic(path, JSON.stringify(state, null, 2) + "\n", 0o600);
250
+ }
251
+ async function runProjectOperation(runner, operation) {
252
+ return runner ? await runner(operation) : await operation();
253
+ }
254
+ function managedPathForCli(cli, options) {
255
+ const override = options.pathOverride?.[cli];
256
+ if (override) {
257
+ if (!options.skipRoot) {
258
+ throw new Error("Managed path overrides are allowed only in non-root tests.");
259
+ }
260
+ return override;
261
+ }
262
+ return cli === "copilot"
263
+ ? getDefaultCopilotShimPath(platform())
264
+ : getManagedSettingsPath(cli, platform());
265
+ }
266
+ async function fetchGovernConfig(baseUrl, apiKey, cli, frameworks) {
267
+ if (!isAllowedEnterpriseEndpoint(baseUrl)) {
268
+ throw new Error("insecure or invalid enterprise endpoint");
269
+ }
270
+ const url = new URL(`${baseUrl.replace(/\/$/, "")}/api/v1/govern/config`);
271
+ url.searchParams.set("cli", cli);
272
+ url.searchParams.set("frameworks", frameworks.join(","));
273
+ const res = await fetch(url, {
274
+ headers: { Authorization: `Bearer ${apiKey}` },
275
+ });
276
+ if (!res.ok) {
277
+ throw new Error(`govern config fetch failed: HTTP ${res.status} ${res.statusText}`);
278
+ }
279
+ const etag = res.headers.get("etag");
280
+ const config = (await res.json());
281
+ return { config, etag };
282
+ }
283
+ async function postApplied(baseUrl, apiKey, payload) {
284
+ if (!isAllowedEnterpriseEndpoint(baseUrl)) {
285
+ throw new Error("insecure or invalid enterprise endpoint");
286
+ }
287
+ const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/v1/govern/applied`, {
288
+ method: "POST",
289
+ headers: {
290
+ "Content-Type": "application/json",
291
+ Authorization: `Bearer ${apiKey}`,
292
+ },
293
+ body: JSON.stringify(payload),
294
+ });
295
+ if (!res.ok) {
296
+ const body = await res.text().catch(() => "");
297
+ throw new Error(`govern applied notify failed: HTTP ${res.status} ${body}`);
298
+ }
299
+ }
300
+ export async function runGovernInstall(options) {
301
+ const cwd = options.cwd ?? process.cwd();
302
+ const contextDir = join(cwd, ".context");
303
+ if (!existsSync(contextDir)) {
304
+ return {
305
+ ok: false,
306
+ message: `No .context/ at ${cwd}. Run 'cortex init --bootstrap' first.`,
307
+ installed: [],
308
+ };
309
+ }
310
+ let apiKey = options.apiKey?.trim() ?? "";
311
+ let baseUrl = options.baseUrl?.trim() ?? "";
312
+ let frameworks = options.frameworks ?? [];
313
+ if (!apiKey || !baseUrl || frameworks.length === 0) {
314
+ const config = await runProjectOperation(options.projectOperation, () => loadEnterpriseConfig(contextDir));
315
+ if (!apiKey)
316
+ apiKey = config.enterprise.api_key.trim();
317
+ if (!baseUrl)
318
+ baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
319
+ if (frameworks.length === 0) {
320
+ frameworks = config.compliance.frameworks;
321
+ }
322
+ }
323
+ if (!apiKey) {
324
+ return {
325
+ ok: false,
326
+ message: "No enterprise.api_key available (pass via options or set in enterprise.yml).",
327
+ installed: [],
328
+ };
329
+ }
330
+ if (!baseUrl) {
331
+ return {
332
+ ok: false,
333
+ message: "No enterprise.base_url configured (pass via options or enterprise.yml).",
334
+ installed: [],
335
+ };
336
+ }
337
+ const targets = options.cli === "all" ? [...ALL_CLIS] : [options.cli];
338
+ if (frameworks.length === 0) {
339
+ return {
340
+ ok: false,
341
+ message: "No frameworks configured. Set compliance.frameworks in enterprise.yml.",
342
+ installed: [],
343
+ };
344
+ }
345
+ const mode = options.mode ?? "advisory";
346
+ const state = await runProjectOperation(options.projectOperation, () => loadState(cwd));
347
+ const installed = [];
348
+ for (const cli of targets) {
349
+ if (cli === "copilot") {
350
+ if (!options.skipRoot)
351
+ requireRoot();
352
+ const shimPath = managedPathForCli(cli, options);
353
+ const shimResult = installCopilotShim({ shimPath });
354
+ if (!shimResult.ok) {
355
+ console.log(`! ${cli}: ${shimResult.message}`);
356
+ continue;
357
+ }
358
+ state.installs[cli] = {
359
+ path: shimResult.shimPath ?? "",
360
+ version: "shim-v1",
361
+ frameworks: [{ id: "tier2", version: "wrap" }],
362
+ installed_at: new Date().toISOString(),
363
+ mode,
364
+ };
365
+ installed.push(cli);
366
+ console.log(`✓ ${cli}: ${shimResult.message} (mode=${mode})`);
367
+ continue;
368
+ }
369
+ if (!options.skipRoot)
370
+ requireRoot();
371
+ const path = managedPathForCli(cli, options);
372
+ let merged;
373
+ let version;
374
+ try {
375
+ const result = await fetchGovernConfig(baseUrl, apiKey, cli, frameworks);
376
+ merged = result.config;
377
+ version = result.etag?.replace(/"/g, "") ?? "unknown";
378
+ }
379
+ catch (err) {
380
+ return {
381
+ ok: false,
382
+ message: `Failed to fetch govern config for ${cli}: ${err instanceof Error ? err.message : String(err)}`,
383
+ installed,
384
+ };
385
+ }
386
+ const codexManagedHooks = cli === "codex"
387
+ ? materializeCodexManagedHooks(path, merged.managed_settings)
388
+ : { managedHookDir: null, hooksByEvent: {} };
389
+ const content = cli === "claude"
390
+ ? JSON.stringify(merged.managed_settings, null, 2) + "\n"
391
+ : buildCodexRequirementsToml(merged, codexManagedHooks);
392
+ try {
393
+ writeAtomic(path, content);
394
+ }
395
+ catch (err) {
396
+ await postApplied(baseUrl, apiKey, {
397
+ host_id: hostname(),
398
+ cli,
399
+ version,
400
+ source: "manual",
401
+ success: false,
402
+ error_message: err instanceof Error ? err.message : String(err),
403
+ }).catch(() => undefined);
404
+ return {
405
+ ok: false,
406
+ message: `Failed to write ${path}: ${err instanceof Error ? err.message : String(err)}`,
407
+ installed,
408
+ };
409
+ }
410
+ state.installs[cli] = {
411
+ path,
412
+ version,
413
+ frameworks: merged.frameworks,
414
+ installed_at: new Date().toISOString(),
415
+ mode,
416
+ };
417
+ await postApplied(baseUrl, apiKey, {
418
+ host_id: hostname(),
419
+ cli,
420
+ version,
421
+ source: "manual",
422
+ success: true,
423
+ }).catch((err) => {
424
+ console.log(`! Could not notify cortex-web of applied state for ${cli}: ${err instanceof Error ? err.message : String(err)}`);
425
+ });
426
+ installed.push(cli);
427
+ const shortVersion = version.length > 12 ? `${version.slice(0, 12)}...` : version;
428
+ console.log(`✓ ${cli}: managed-settings written to ${path} (version ${shortVersion}, mode=${mode})`);
429
+ }
430
+ await runProjectOperation(options.projectOperation, () => saveState(cwd, state));
431
+ return {
432
+ ok: true,
433
+ message: `Installed govern for ${installed.join(", ") || "(none)"}.`,
434
+ installed,
435
+ };
436
+ }
437
+ export async function runGovernUninstall(options) {
438
+ const cwd = options.cwd ?? process.cwd();
439
+ const state = await runProjectOperation(options.projectOperation, () => loadState(cwd));
440
+ // Filter out unknown CLI keys defensively — govern.local.json is a
441
+ // user-writable file and a corrupted/forward-compatible entry must not
442
+ // crash the path that walks it.
443
+ const allInstalledClis = Object.keys(state.installs).filter((k) => ALL_CLIS.includes(k));
444
+ const targets = options.cli === "all" ? allInstalledClis : [options.cli];
445
+ for (const cli of targets) {
446
+ const inst = state.installs[cli];
447
+ if (inst?.mode === "enforced") {
448
+ if (!options.breakGlass) {
449
+ return {
450
+ ok: false,
451
+ message: `${cli} is installed in enforced mode. Pass --break-glass --reason "<text>" to override.`,
452
+ uninstalled: [],
453
+ };
454
+ }
455
+ if (!options.reason || options.reason.trim().length < 4) {
456
+ return {
457
+ ok: false,
458
+ message: '--break-glass requires --reason "<text>" (at least 4 chars)',
459
+ uninstalled: [],
460
+ };
461
+ }
462
+ }
463
+ }
464
+ const uninstalled = [];
465
+ for (const cli of targets) {
466
+ const inst = state.installs[cli];
467
+ if (!inst)
468
+ continue;
469
+ if (!options.skipRoot)
470
+ requireRoot();
471
+ const managedPath = managedPathForCli(cli, options);
472
+ if (cli === "copilot") {
473
+ const shimResult = uninstallCopilotShim(managedPath);
474
+ if (!shimResult.ok) {
475
+ console.log(`! ${cli}: ${shimResult.message}`);
476
+ continue;
477
+ }
478
+ delete state.installs[cli];
479
+ uninstalled.push(cli);
480
+ console.log(`✓ ${cli}: ${shimResult.message}` +
481
+ (options.breakGlass ? ` (break-glass: ${options.reason})` : ""));
482
+ continue;
483
+ }
484
+ try {
485
+ unlinkSync(managedPath);
486
+ }
487
+ catch {
488
+ // file already gone — proceed
489
+ }
490
+ delete state.installs[cli];
491
+ uninstalled.push(cli);
492
+ console.log(`✓ ${cli}: managed-settings removed from ${managedPath}` +
493
+ (options.breakGlass ? ` (break-glass: ${options.reason})` : ""));
494
+ }
495
+ await runProjectOperation(options.projectOperation, () => saveState(cwd, state));
496
+ return {
497
+ ok: true,
498
+ message: `Uninstalled govern for ${uninstalled.join(", ") || "(none)"}.`,
499
+ uninstalled,
500
+ };
501
+ }
502
+ const TIER_BY_CLI = {
503
+ claude: "Tier 1 (Prevent)",
504
+ codex: "Tier 1 (Prevent)",
505
+ copilot: "Tier 2 (Wrap)",
506
+ };
507
+ function classifyManagedPath(cli, filePath) {
508
+ if (cli === "copilot")
509
+ return "shim";
510
+ if (filePath.endsWith(".json"))
511
+ return "managed-settings.json";
512
+ if (filePath.endsWith(".toml"))
513
+ return "requirements.toml";
514
+ return "unknown";
515
+ }
516
+ function countDenyRules(cli, filePath) {
517
+ if (!existsSync(filePath))
518
+ return { count: null, shimReal: null };
519
+ let raw;
520
+ try {
521
+ raw = readFileSync(filePath, "utf8");
522
+ }
523
+ catch {
524
+ return { count: null, shimReal: null };
525
+ }
526
+ if (cli === "copilot") {
527
+ // Shim is a shell script — pull the captured "Real binary" comment.
528
+ const m = raw.match(/Real binary captured at install time:\s*(.+?)\s*$/m);
529
+ return { count: null, shimReal: m ? m[1] : null };
530
+ }
531
+ if (cli === "claude") {
532
+ try {
533
+ const parsed = JSON.parse(raw);
534
+ const deny = parsed.permissions?.deny;
535
+ return { count: Array.isArray(deny) ? deny.length : 0, shimReal: null };
536
+ }
537
+ catch {
538
+ return { count: null, shimReal: null };
539
+ }
540
+ }
541
+ if (cli === "codex") {
542
+ // requirements.toml: count items inside `deny_read = [ ... ]`.
543
+ const m = raw.match(/deny_read\s*=\s*\[([^\]]*)\]/);
544
+ if (!m)
545
+ return { count: 0, shimReal: null };
546
+ const inner = m[1].trim();
547
+ if (!inner)
548
+ return { count: 0, shimReal: null };
549
+ return { count: inner.split(",").filter((s) => s.trim()).length, shimReal: null };
550
+ }
551
+ return { count: null, shimReal: null };
552
+ }
553
+ function readUpdateNotification(cwd) {
554
+ const path = join(cwd, ".context", ".govern-update-available.json");
555
+ if (!existsSync(path))
556
+ return null;
557
+ try {
558
+ const raw = JSON.parse(readFileSync(path, "utf8"));
559
+ if (!raw.cli || !raw.latest_version || !raw.detected_at)
560
+ return null;
561
+ return {
562
+ cli: raw.cli,
563
+ latest_version: raw.latest_version,
564
+ current_version: raw.current_version ?? null,
565
+ detected_at: raw.detected_at,
566
+ };
567
+ }
568
+ catch {
569
+ return null;
570
+ }
571
+ }
572
+ function readActiveTamperLock(cwd) {
573
+ const path = join(cwd, ".context", ".cortex-tamper.lock");
574
+ if (!existsSync(path))
575
+ return null;
576
+ try {
577
+ const raw = JSON.parse(readFileSync(path, "utf8"));
578
+ if (!raw.cli || !raw.session_id || !raw.detected_at || !raw.last_seen)
579
+ return null;
580
+ return {
581
+ cli: raw.cli,
582
+ session_id: raw.session_id,
583
+ detected_at: raw.detected_at,
584
+ last_seen: raw.last_seen,
585
+ missing_seconds: raw.missing_seconds ?? 0,
586
+ };
587
+ }
588
+ catch {
589
+ return null;
590
+ }
591
+ }
592
+ function readRecentEvents(cwd, windowMs, now) {
593
+ const counts = {
594
+ ungoverned_ai_session_detected: 0,
595
+ hook_tamper_detected: 0,
596
+ tamper_repaired: 0,
597
+ govern_config_unchanged: 0,
598
+ govern_config_available: 0,
599
+ govern_config_sync_failed: 0,
600
+ };
601
+ const sample = [];
602
+ const cutoff = now.getTime() - windowMs;
603
+ const auditDir = join(cwd, ".context", "audit");
604
+ if (!existsSync(auditDir))
605
+ return { counts, sample };
606
+ let files;
607
+ try {
608
+ // Walk every host-events-*.jsonl file, newest first. The window can
609
+ // span more than two daily files (a 24h window read at 00:30 still
610
+ // needs yesterday's file + a sliver of the day before), and we must
611
+ // not silently drop events because the slice(-2) heuristic happens
612
+ // to land on a quiet day. Per-line cutoff still bounds work below.
613
+ files = readdirSync(auditDir)
614
+ .filter((n) => n.startsWith("host-events-") && n.endsWith(".jsonl"))
615
+ .sort()
616
+ .reverse()
617
+ .map((n) => join(auditDir, n));
618
+ }
619
+ catch {
620
+ return { counts, sample };
621
+ }
622
+ outer: for (const file of files) {
623
+ let raw;
624
+ try {
625
+ raw = readFileSync(file, "utf8");
626
+ }
627
+ catch {
628
+ continue;
629
+ }
630
+ // Within a single file, lines are appended in chronological order, so
631
+ // once we hit a line older than the cutoff every subsequent line is
632
+ // also too old. The filename ordering is already newest-first across
633
+ // files, so once an entire file is too old it's the boundary and we
634
+ // stop walking older files altogether.
635
+ let fileHadAnyInWindow = false;
636
+ let fileHadAnyOutOfWindow = false;
637
+ for (const line of raw.split("\n")) {
638
+ if (!line.trim())
639
+ continue;
640
+ let evt;
641
+ try {
642
+ evt = JSON.parse(line);
643
+ }
644
+ catch {
645
+ continue;
646
+ }
647
+ const ts = (evt.timestamp ?? evt.detected_at);
648
+ if (!ts)
649
+ continue;
650
+ const t = new Date(ts).getTime();
651
+ if (!Number.isFinite(t))
652
+ continue;
653
+ if (t < cutoff) {
654
+ fileHadAnyOutOfWindow = true;
655
+ continue;
656
+ }
657
+ fileHadAnyInWindow = true;
658
+ const type = evt.event_type;
659
+ if (type && type in counts) {
660
+ counts[type] += 1;
661
+ }
662
+ if (sample.length < 10)
663
+ sample.push(evt);
664
+ }
665
+ // If this file had only out-of-window events, the cutoff is behind us
666
+ // and any older file can only contain even older events — stop early.
667
+ if (fileHadAnyOutOfWindow && !fileHadAnyInWindow)
668
+ break outer;
669
+ }
670
+ return { counts, sample };
671
+ }
672
+ export function buildGovernStatus(options = {}) {
673
+ const cwd = options.cwd ?? process.cwd();
674
+ const now = options.now ?? new Date();
675
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
676
+ const state = loadState(cwd);
677
+ const installs = [];
678
+ let mostRestrictiveMode = "off";
679
+ for (const [rawCliName, record] of Object.entries(state.installs)) {
680
+ // Skip any unknown CLI keys (e.g. a forward-compatible 'gemini' entry
681
+ // written by a newer enterprise endpoint, or a hand-edited corrupt
682
+ // file). Casting blindly would let unknown keys flow into TIER_BY_CLI
683
+ // / countDenyRules and produce undefined-shaped data.
684
+ if (!ALL_CLIS.includes(rawCliName))
685
+ continue;
686
+ if (!record)
687
+ continue;
688
+ const cliName = rawCliName;
689
+ const present = existsSync(record.path);
690
+ let size = null;
691
+ if (present) {
692
+ try {
693
+ size = statSync(record.path).size;
694
+ }
695
+ catch {
696
+ size = null;
697
+ }
698
+ }
699
+ const { count, shimReal } = countDenyRules(cliName, record.path);
700
+ installs.push({
701
+ cli: cliName,
702
+ tier: TIER_BY_CLI[cliName],
703
+ path: record.path,
704
+ version: record.version,
705
+ mode: record.mode,
706
+ frameworks: record.frameworks,
707
+ installed_at: record.installed_at,
708
+ managed_path_present: present,
709
+ managed_path_size_bytes: size,
710
+ managed_path_kind: classifyManagedPath(cliName, record.path),
711
+ deny_rules_count: count,
712
+ shim_real_binary: shimReal,
713
+ });
714
+ if (record.mode === "enforced")
715
+ mostRestrictiveMode = "enforced";
716
+ else if (record.mode === "advisory" && mostRestrictiveMode === "off") {
717
+ mostRestrictiveMode = "advisory";
718
+ }
719
+ }
720
+ const { counts, sample } = readRecentEvents(cwd, 24 * 60 * 60 * 1000, now);
721
+ const enterpriseBaseUrl = config.enterprise.base_url || config.enterprise.endpoint;
722
+ const enterpriseApiKey = config.enterprise.api_key.trim();
723
+ return {
724
+ cwd,
725
+ host_id: hostname(),
726
+ generated_at: now.toISOString(),
727
+ enterprise: {
728
+ api_key_set: enterpriseApiKey !== "",
729
+ host_identity_bound: enterpriseApiKey !== "" &&
730
+ enterpriseBaseUrl !== "" &&
731
+ matchesEnterpriseHostIdentity(enterpriseCredentialId(enterpriseBaseUrl, enterpriseApiKey)),
732
+ base_url: enterpriseBaseUrl,
733
+ frameworks_configured: config.compliance.frameworks,
734
+ govern_mode_config: config.govern.mode,
735
+ },
736
+ mode_effective: mostRestrictiveMode,
737
+ installs,
738
+ update_notification: readUpdateNotification(cwd),
739
+ tamper_lock: readActiveTamperLock(cwd),
740
+ recent_events_24h: counts,
741
+ recent_events_sample: sample,
742
+ };
743
+ }
744
+ function formatCompact(report) {
745
+ const lines = [];
746
+ lines.push("Cortex Enterprise — Govern Overview");
747
+ lines.push("===================================");
748
+ lines.push(`Host: ${report.host_id}`);
749
+ lines.push(`Mode: ${report.mode_effective}`);
750
+ lines.push(`Frameworks: ${report.enterprise.frameworks_configured.join(", ") || "(none)"}`);
751
+ lines.push(`Endpoint: ${report.enterprise.base_url || "(not set)"}`);
752
+ lines.push(`API key: ${report.enterprise.api_key_set ? "configured" : "NOT SET (run 'sudo cortex enterprise install --api-key-stdin')"}`);
753
+ lines.push(`Host identity: ${report.enterprise.host_identity_bound ? "verified" : "NOT ENROLLED (re-run stdin install)"}`);
754
+ lines.push("");
755
+ if (report.tamper_lock) {
756
+ lines.push("⚠ TAMPER LOCK ACTIVE");
757
+ lines.push(` cli=${report.tamper_lock.cli} session=${report.tamper_lock.session_id} detected=${report.tamper_lock.detected_at}`);
758
+ lines.push(" Run: sudo cortex enterprise repair");
759
+ lines.push("");
760
+ }
761
+ if (report.update_notification) {
762
+ lines.push(`↺ UPDATE AVAILABLE: ${report.update_notification.cli} ` +
763
+ `(current=${report.update_notification.current_version ?? "unknown"} → ` +
764
+ `latest=${report.update_notification.latest_version})`);
765
+ lines.push(" Run: sudo cortex enterprise sync");
766
+ lines.push("");
767
+ }
768
+ if (report.installs.length === 0) {
769
+ lines.push("No CLIs governed on this host.");
770
+ lines.push("Run: sudo cortex enterprise install --api-key-stdin");
771
+ return lines.join("\n");
772
+ }
773
+ lines.push("AI CLIs on this host:");
774
+ for (const i of report.installs) {
775
+ const presence = i.managed_path_present ? "✓" : "✗";
776
+ const denyText = i.deny_rules_count !== null ? `${i.deny_rules_count} deny rules` : "shim";
777
+ lines.push(` ${presence} ${i.cli.padEnd(8)} ${i.tier.padEnd(20)} ${denyText}, mode=${i.mode}`);
778
+ }
779
+ lines.push("");
780
+ lines.push("Recent activity (last 24h):");
781
+ lines.push(` ungoverned sessions: ${report.recent_events_24h.ungoverned_ai_session_detected}`);
782
+ lines.push(` tamper detected: ${report.recent_events_24h.hook_tamper_detected}`);
783
+ lines.push(` tamper repaired: ${report.recent_events_24h.tamper_repaired}`);
784
+ lines.push(` config unchanged: ${report.recent_events_24h.govern_config_unchanged}`);
785
+ lines.push(` config available: ${report.recent_events_24h.govern_config_available}`);
786
+ lines.push(` sync failed: ${report.recent_events_24h.govern_config_sync_failed}`);
787
+ lines.push("");
788
+ lines.push("Run 'cortex enterprise status --verbose' for the full deny-rule list and event details.");
789
+ return lines.join("\n");
790
+ }
791
+ function formatVerbose(report) {
792
+ const sections = [formatCompact(report), ""];
793
+ sections.push("Per-CLI managed-config detail:");
794
+ for (const i of report.installs) {
795
+ sections.push(` [${i.cli}]`);
796
+ sections.push(` path: ${i.path}`);
797
+ sections.push(` kind: ${i.managed_path_kind}`);
798
+ sections.push(` file: ${i.managed_path_present ? `present (${i.managed_path_size_bytes ?? "?"} bytes)` : "MISSING"}`);
799
+ sections.push(` version: ${i.version}`);
800
+ sections.push(` mode: ${i.mode}`);
801
+ sections.push(` installed_at: ${i.installed_at}`);
802
+ sections.push(` frameworks: ${i.frameworks.map((f) => `${f.id}@${f.version}`).join(", ") || "(none)"}`);
803
+ if (i.deny_rules_count !== null) {
804
+ sections.push(` deny_rules: ${i.deny_rules_count}`);
805
+ }
806
+ if (i.shim_real_binary) {
807
+ sections.push(` shim → real: ${i.shim_real_binary}`);
808
+ }
809
+ sections.push("");
810
+ }
811
+ sections.push("Recent host events (sample, up to 10):");
812
+ if (report.recent_events_sample.length === 0) {
813
+ sections.push(" (none in last 24h)");
814
+ }
815
+ else {
816
+ for (const evt of report.recent_events_sample) {
817
+ sections.push(` ${JSON.stringify(evt)}`);
818
+ }
819
+ }
820
+ return sections.join("\n");
821
+ }
822
+ export function runGovernStatus(options = {}) {
823
+ const report = buildGovernStatus({ cwd: options.cwd });
824
+ if (options.json) {
825
+ console.log(JSON.stringify(report, null, 2));
826
+ return;
827
+ }
828
+ console.log(options.verbose ? formatVerbose(report) : formatCompact(report));
829
+ }
830
+ /**
831
+ * Verify that managed-settings files for each governed CLI still exist
832
+ * (and copilot's shim path is still our shim). If everything checks out,
833
+ * remove .cortex-tamper.lock and emit a tamper_repaired audit event.
834
+ *
835
+ * Re-fetching from cortex-web (full re-install) is intentionally NOT done
836
+ * here — that path is `cortex enterprise sync`. Repair is the post-incident
837
+ * "I've reviewed the situation, lock cleared" verb.
838
+ */
839
+ export async function runGovernRepair(options = {}) {
840
+ const cwd = options.cwd ?? process.cwd();
841
+ const state = await runProjectOperation(options.projectOperation, () => loadState(cwd));
842
+ // Filter to known CLIs so a corrupt or forward-compatible install
843
+ // record doesn't crash the repair walk.
844
+ const installed = Object.entries(state.installs).filter((entry) => ALL_CLIS.includes(entry[0]) && entry[1] !== undefined);
845
+ if (installed.length === 0) {
846
+ return {
847
+ ok: false,
848
+ message: "No CLIs governed on this host — nothing to repair. Run 'cortex enterprise install --api-key-stdin' first.",
849
+ reverified: [],
850
+ };
851
+ }
852
+ if (!options.skipRoot)
853
+ requireRoot();
854
+ const verified = [];
855
+ const missing = [];
856
+ for (const [cli] of installed) {
857
+ const managedPath = managedPathForCli(cli, options);
858
+ if (!existsSync(managedPath)) {
859
+ missing.push(`${cli}: ${managedPath} is missing`);
860
+ continue;
861
+ }
862
+ if (cli === "copilot") {
863
+ // Verify the file is still our shim (not replaced by a real binary).
864
+ try {
865
+ const raw = readFileSync(managedPath, "utf8");
866
+ if (!raw.includes("# cortex-shim-v1")) {
867
+ missing.push(`${cli}: ${managedPath} is no longer a cortex shim`);
868
+ continue;
869
+ }
870
+ }
871
+ catch {
872
+ missing.push(`${cli}: ${managedPath} could not be read`);
873
+ continue;
874
+ }
875
+ }
876
+ verified.push(cli);
877
+ }
878
+ if (missing.length > 0) {
879
+ return {
880
+ ok: false,
881
+ message: "Cannot repair — the following managed paths are missing or replaced:\n " +
882
+ missing.join("\n ") +
883
+ "\nRun 'sudo cortex enterprise sync' to re-install, then 'cortex enterprise repair' again.",
884
+ reverified: verified,
885
+ };
886
+ }
887
+ const lock = await runProjectOperation(options.projectOperation, () => readTamperLock(cwd));
888
+ if (!lock) {
889
+ return {
890
+ ok: true,
891
+ message: "No tamper lock present — managed paths verified, nothing to clear.",
892
+ removed_lock: false,
893
+ reverified: verified,
894
+ };
895
+ }
896
+ const removed = await runProjectOperation(options.projectOperation, () => removeTamperLock(cwd));
897
+ if (removed) {
898
+ await runProjectOperation(options.projectOperation, () => emitTamperAudit(cwd, {
899
+ ...lock,
900
+ detected_at: new Date().toISOString(),
901
+ hook_name: "tamper_repaired",
902
+ missing_seconds: 0,
903
+ }).catch(() => undefined));
904
+ }
905
+ return {
906
+ ok: true,
907
+ message: `Repaired: managed paths verified for ${verified.join(", ")}; ` +
908
+ `tamper lock removed${options.reason ? ` (reason: ${options.reason})` : ""}.`,
909
+ removed_lock: removed,
910
+ reverified: verified,
911
+ };
912
+ }
913
+ export async function runGovernSync(options = {}) {
914
+ const cwd = options.cwd ?? process.cwd();
915
+ const state = await runProjectOperation(options.projectOperation, () => loadState(cwd));
916
+ // Drop unknown CLI keys: runGovernInstall would throw on an unsupported
917
+ // cli, but silently skipping is the right behaviour when sync runs in
918
+ // the daemon — it's not a user typo to surface, just stale/forward
919
+ // state we don't recognise.
920
+ const targets = Object.keys(state.installs).filter((k) => ALL_CLIS.includes(k));
921
+ if (targets.length === 0) {
922
+ console.log("Nothing to sync — no CLIs governed on this host.");
923
+ return;
924
+ }
925
+ for (const cli of targets) {
926
+ const previous = state.installs[cli];
927
+ const result = await runGovernInstall({
928
+ cli,
929
+ cwd,
930
+ mode: previous?.mode,
931
+ projectOperation: options.projectOperation,
932
+ });
933
+ if (!result.ok) {
934
+ console.log(`! sync ${cli} failed: ${result.message}`);
935
+ }
936
+ }
937
+ }