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