@danielblomma/cortex-mcp 2.0.13 → 2.0.14

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@danielblomma/cortex-mcp",
3
3
  "mcpName": "io.github.DanielBlomma/cortex",
4
- "version": "2.0.13",
4
+ "version": "2.0.14",
5
5
  "description": "Local, repo-scoped context platform for coding assistants. Semantic search, graph relationships, and architectural rule context.",
6
6
  "type": "module",
7
7
  "author": "Daniel Blomma",
@@ -8,6 +8,7 @@ import {
8
8
  renameSync,
9
9
  unlinkSync,
10
10
  rmSync,
11
+ chmodSync,
11
12
  } from "node:fs";
12
13
  import { join, dirname } from "node:path";
13
14
  import { platform, hostname } from "node:os";
@@ -58,6 +59,30 @@ export type FetchedConfig = {
58
59
  frameworks: Array<{ id: string; version: string }>;
59
60
  };
60
61
 
62
+ type CodexHookCommand = {
63
+ type: "command";
64
+ command: string;
65
+ timeout?: number;
66
+ statusMessage?: string;
67
+ };
68
+
69
+ type CodexHookHandler = {
70
+ matcher?: string;
71
+ hooks: CodexHookCommand[];
72
+ };
73
+
74
+ type CodexHookHandlersByEvent = Record<string, CodexHookHandler[]>;
75
+
76
+ const SUPPORTED_CODEX_HOOK_EVENTS = new Set([
77
+ "SessionStart",
78
+ "SessionEnd",
79
+ "PreToolUse",
80
+ "PermissionRequest",
81
+ "PostToolUse",
82
+ "UserPromptSubmit",
83
+ "Stop",
84
+ ]);
85
+
61
86
  export function getManagedSettingsPath(cli: GovernCli, os: NodeJS.Platform): string {
62
87
  const path = DEFAULT_PATHS[cli]?.[os];
63
88
  if (!path) {
@@ -93,11 +118,159 @@ function tomlArray(values: unknown[]): string {
93
118
  return `[${items.join(", ")}]`;
94
119
  }
95
120
 
96
- export function buildCodexRequirementsToml(config: FetchedConfig): string {
121
+ function isRecord(value: unknown): value is Record<string, unknown> {
122
+ return value !== null && typeof value === "object" && !Array.isArray(value);
123
+ }
124
+
125
+ function normalizeCodexHookCommand(raw: unknown): CodexHookCommand | null {
126
+ if (typeof raw === "string" && raw.trim()) {
127
+ return { type: "command", command: raw.trim() };
128
+ }
129
+ if (!isRecord(raw)) {
130
+ return null;
131
+ }
132
+ const command = typeof raw.command === "string" ? raw.command.trim() : "";
133
+ if (!command) {
134
+ return null;
135
+ }
136
+ const timeout = typeof raw.timeout === "number" && Number.isFinite(raw.timeout)
137
+ ? Math.trunc(raw.timeout)
138
+ : undefined;
139
+ const statusMessage = typeof raw.statusMessage === "string" && raw.statusMessage.trim()
140
+ ? raw.statusMessage
141
+ : undefined;
142
+ return {
143
+ type: "command",
144
+ command,
145
+ ...(timeout !== undefined ? { timeout } : {}),
146
+ ...(statusMessage ? { statusMessage } : {}),
147
+ };
148
+ }
149
+
150
+ function normalizeCodexHookCommands(raw: unknown): CodexHookCommand[] {
151
+ if (Array.isArray(raw)) {
152
+ return raw
153
+ .map((entry) => normalizeCodexHookCommand(entry))
154
+ .filter((entry): entry is CodexHookCommand => entry !== null);
155
+ }
156
+ const single = normalizeCodexHookCommand(raw);
157
+ return single ? [single] : [];
158
+ }
159
+
160
+ function normalizeCodexHookHandlers(raw: unknown): CodexHookHandler[] {
161
+ const entries = Array.isArray(raw) ? raw : [raw];
162
+ const normalized: CodexHookHandler[] = [];
163
+
164
+ for (const entry of entries) {
165
+ if (typeof entry === "string") {
166
+ normalized.push({ hooks: [{ type: "command", command: entry }] });
167
+ continue;
168
+ }
169
+ if (!isRecord(entry)) {
170
+ continue;
171
+ }
172
+
173
+ const matcher = typeof entry.matcher === "string" && entry.matcher.trim()
174
+ ? entry.matcher
175
+ : undefined;
176
+
177
+ if (Array.isArray(entry.hooks) || typeof entry.hooks === "string" || isRecord(entry.hooks)) {
178
+ const hooks = normalizeCodexHookCommands(entry.hooks);
179
+ if (hooks.length > 0) {
180
+ normalized.push({ ...(matcher ? { matcher } : {}), hooks });
181
+ }
182
+ continue;
183
+ }
184
+
185
+ const shorthand = normalizeCodexHookCommand(entry);
186
+ if (shorthand) {
187
+ normalized.push({
188
+ ...(matcher ? { matcher } : {}),
189
+ hooks: [shorthand],
190
+ });
191
+ }
192
+ }
193
+
194
+ return normalized;
195
+ }
196
+
197
+ function normalizeCodexHooks(managedSettings: Record<string, unknown>): CodexHookHandlersByEvent {
198
+ const hooksRoot = managedSettings.hooks;
199
+ if (!isRecord(hooksRoot)) {
200
+ return {};
201
+ }
202
+
203
+ const normalized: CodexHookHandlersByEvent = {};
204
+ for (const [eventName, rawHandlers] of Object.entries(hooksRoot)) {
205
+ if (!SUPPORTED_CODEX_HOOK_EVENTS.has(eventName)) {
206
+ continue;
207
+ }
208
+ const handlers = normalizeCodexHookHandlers(rawHandlers);
209
+ if (handlers.length > 0) {
210
+ normalized[eventName] = handlers;
211
+ }
212
+ }
213
+ return normalized;
214
+ }
215
+
216
+ function codexManagedHooksDir(requirementsPath: string): string {
217
+ return join(dirname(requirementsPath), "hooks");
218
+ }
219
+
220
+ function shellQuotedCommandPath(filePath: string): string {
221
+ return `"${filePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
222
+ }
223
+
224
+ function managedCodexHookWrapperContent(hookName: string): string {
225
+ return [
226
+ "#!/bin/sh",
227
+ "set -eu",
228
+ 'CORTEX="${CORTEX_BIN:-cortex}"',
229
+ `exec "$CORTEX" hook ${hookName} "$@"`,
230
+ "",
231
+ ].join("\n");
232
+ }
233
+
234
+ function materializeCodexManagedHooks(
235
+ requirementsPath: string,
236
+ managedSettings: Record<string, unknown>,
237
+ ): { managedHookDir: string | null; hooksByEvent: CodexHookHandlersByEvent } {
238
+ const hooksByEvent = normalizeCodexHooks(managedSettings);
239
+ const eventNames = Object.keys(hooksByEvent);
240
+ if (eventNames.length === 0) {
241
+ return { managedHookDir: null, hooksByEvent };
242
+ }
243
+
244
+ const managedHookDir = codexManagedHooksDir(requirementsPath);
245
+ mkdirSync(managedHookDir, { recursive: true });
246
+
247
+ for (const handlers of Object.values(hooksByEvent)) {
248
+ for (const handler of handlers) {
249
+ for (const hook of handler.hooks) {
250
+ const match = hook.command.match(/^cortex hook ([a-z0-9-]+)$/);
251
+ if (!match) {
252
+ continue;
253
+ }
254
+ const hookName = match[1];
255
+ const wrapperPath = join(managedHookDir, `${hookName}.sh`);
256
+ writeAtomic(wrapperPath, managedCodexHookWrapperContent(hookName), 0o755);
257
+ hook.command = shellQuotedCommandPath(wrapperPath);
258
+ }
259
+ }
260
+ }
261
+
262
+ return { managedHookDir, hooksByEvent };
263
+ }
264
+
265
+ export function buildCodexRequirementsToml(
266
+ config: FetchedConfig,
267
+ options: { managedHookDir?: string | null; hooksByEvent?: CodexHookHandlersByEvent } = {},
268
+ ): string {
97
269
  const denyRead = config.deny_rules
98
270
  .map((r) => r.pattern)
99
271
  .filter((p) => /^(Edit|Read|Write)\(/.test(p))
100
272
  .map((p) => p.replace(/^[A-Za-z]+\(/, "").replace(/\)$/, ""));
273
+ const hooksByEvent = options.hooksByEvent ?? normalizeCodexHooks(config.managed_settings);
101
274
  const lines: string[] = [
102
275
  "# Cortex govern — codex requirements (Phase 3 of PLAN.govern-mode.md).",
103
276
  "# Admin-enforced upper bounds. Users cannot weaken these via ~/.codex/config.toml.",
@@ -112,13 +285,48 @@ export function buildCodexRequirementsToml(config: FetchedConfig): string {
112
285
  "codex_hooks = true",
113
286
  "",
114
287
  ];
288
+
289
+ const eventNames = Object.keys(hooksByEvent);
290
+ if (eventNames.length > 0) {
291
+ lines.push("[hooks]");
292
+ if (options.managedHookDir) {
293
+ lines.push(`managed_dir = ${tomlString(options.managedHookDir)}`);
294
+ }
295
+ lines.push("");
296
+
297
+ for (const eventName of eventNames) {
298
+ for (const handler of hooksByEvent[eventName]) {
299
+ lines.push(`[[hooks.${eventName}]]`);
300
+ if (handler.matcher) {
301
+ lines.push(`matcher = ${tomlString(handler.matcher)}`);
302
+ }
303
+ for (const hook of handler.hooks) {
304
+ lines.push("");
305
+ lines.push(`[[hooks.${eventName}.hooks]]`);
306
+ lines.push(`type = ${tomlString(hook.type)}`);
307
+ lines.push(`command = ${tomlString(hook.command)}`);
308
+ if (hook.timeout !== undefined) {
309
+ lines.push(`timeout = ${hook.timeout}`);
310
+ }
311
+ if (hook.statusMessage) {
312
+ lines.push(`statusMessage = ${tomlString(hook.statusMessage)}`);
313
+ }
314
+ }
315
+ lines.push("");
316
+ }
317
+ }
318
+ }
319
+
115
320
  return lines.join("\n");
116
321
  }
117
322
 
118
- function writeAtomic(filePath: string, content: string): void {
323
+ function writeAtomic(filePath: string, content: string, mode?: number): void {
119
324
  mkdirSync(dirname(filePath), { recursive: true });
120
325
  const tmp = `${filePath}.tmp.${randomUUID()}`;
121
326
  writeFileSync(tmp, content, "utf8");
327
+ if (mode !== undefined) {
328
+ chmodSync(tmp, mode);
329
+ }
122
330
  renameSync(tmp, filePath);
123
331
  }
124
332
 
@@ -303,10 +511,15 @@ export async function runGovernInstall(
303
511
  };
304
512
  }
305
513
 
514
+ const codexManagedHooks =
515
+ cli === "codex"
516
+ ? materializeCodexManagedHooks(path, merged.managed_settings)
517
+ : { managedHookDir: null, hooksByEvent: {} };
518
+
306
519
  const content =
307
520
  cli === "claude"
308
521
  ? JSON.stringify(merged.managed_settings, null, 2) + "\n"
309
- : buildCodexRequirementsToml(merged);
522
+ : buildCodexRequirementsToml(merged, codexManagedHooks);
310
523
 
311
524
  try {
312
525
  writeAtomic(path, content);
@@ -82,6 +82,8 @@ export type HeartbeatPayload = {
82
82
  cli: "claude" | "codex" | "copilot";
83
83
  hook:
84
84
  | "PreToolUse"
85
+ | "PostToolUse"
86
+ | "PermissionRequest"
85
87
  | "UserPromptSubmit"
86
88
  | "SessionStart"
87
89
  | "SessionEnd"
@@ -0,0 +1,126 @@
1
+ import { call } from "../daemon/client.js";
2
+ import type {
3
+ AuditLogPayload,
4
+ AuditLogResult,
5
+ PolicyCheckPayload,
6
+ PolicyCheckResult,
7
+ } from "../daemon/protocol.js";
8
+ import { evaluateToolCall } from "../core/workflow/enforcement.js";
9
+ import {
10
+ ensureDaemon,
11
+ isEnterpriseProject,
12
+ normalizeToolCall,
13
+ parseInput,
14
+ readStdin,
15
+ resolveDaemonEntry,
16
+ sendHeartbeat,
17
+ serializeForAudit,
18
+ getStringField,
19
+ } from "./shared.js";
20
+
21
+ async function main(): Promise<void> {
22
+ const raw = await readStdin();
23
+ const input = parseInput(raw);
24
+ const normalized = normalizeToolCall(input);
25
+ const enterprise = isEnterpriseProject(normalized.cwd);
26
+
27
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
28
+
29
+ if (normalized.sessionId) {
30
+ void sendHeartbeat({
31
+ cli: "codex",
32
+ hook: "PermissionRequest",
33
+ session_id: normalized.sessionId,
34
+ cwd: normalized.cwd,
35
+ });
36
+ }
37
+
38
+ const activeTaskId = process.env.CORTEX_ACTIVE_TASK_ID?.trim();
39
+ if (activeTaskId) {
40
+ try {
41
+ const verdict = evaluateToolCall({
42
+ cwd: normalized.cwd,
43
+ taskId: activeTaskId,
44
+ call: { toolName: normalized.toolName, toolInput: normalized.toolInput },
45
+ });
46
+ if (!verdict.allowed) {
47
+ process.stderr.write(`[cortex] Permission denied by workflow: ${verdict.reason}\n`);
48
+ process.exit(2);
49
+ }
50
+ } catch (err) {
51
+ process.stderr.write(
52
+ `[cortex] permission capability evaluation failed (${
53
+ err instanceof Error ? err.message : String(err)
54
+ }); deferring to policy.check\n`,
55
+ );
56
+ }
57
+ }
58
+
59
+ const policyPayload: PolicyCheckPayload = {
60
+ tool: normalized.toolName,
61
+ cwd: normalized.cwd,
62
+ input: normalized.toolInput,
63
+ };
64
+ const policyRes = await call<PolicyCheckResult>("policy.check", policyPayload, {
65
+ timeoutMs: 5000,
66
+ });
67
+
68
+ const approvalReason = getStringField(input, ["reason", "permission_reason", "permissionReason"]);
69
+ const auditPayload: AuditLogPayload = {
70
+ cwd: normalized.cwd,
71
+ entry: {
72
+ timestamp: new Date().toISOString(),
73
+ tool: "permission.request",
74
+ input: {
75
+ tool_name: normalized.toolName,
76
+ command: normalized.toolInput.command ?? null,
77
+ prefix_rule: normalized.toolInput.prefix_rule ?? null,
78
+ sandbox_permissions: normalized.toolInput.sandbox_permissions ?? null,
79
+ },
80
+ event_type: "session",
81
+ evidence_level: "diagnostic",
82
+ resource_type: "approval_request",
83
+ session_id: normalized.sessionId,
84
+ metadata: {
85
+ tool_name: normalized.toolName,
86
+ reason: approvalReason ?? null,
87
+ command_preview: serializeForAudit(normalized.toolInput.command),
88
+ },
89
+ ...(policyRes.ok && !policyRes.result.allow
90
+ ? {
91
+ status: "error" as const,
92
+ }
93
+ : {}),
94
+ },
95
+ };
96
+ void call<AuditLogResult>("audit.log", auditPayload, { timeoutMs: 3000 });
97
+
98
+ if (policyRes.ok) {
99
+ if (policyRes.result.allow) {
100
+ process.exit(0);
101
+ }
102
+ process.stderr.write(
103
+ `[cortex] Permission denied by policy: ${policyRes.result.reason ?? "unspecified"}\n`,
104
+ );
105
+ process.exit(2);
106
+ }
107
+
108
+ if (enterprise) {
109
+ process.stderr.write(
110
+ `[cortex] Enterprise daemon unreachable (${policyRes.error}). Denying permission per fail-closed policy.\n`,
111
+ );
112
+ process.exit(2);
113
+ }
114
+
115
+ process.stderr.write(
116
+ `[cortex] Daemon unreachable (${policyRes.error}). Allowing permission request (community mode).\n`,
117
+ );
118
+ process.exit(0);
119
+ }
120
+
121
+ main().catch((err) => {
122
+ process.stderr.write(
123
+ `[cortex permission-request] error: ${err instanceof Error ? err.message : String(err)}\n`,
124
+ );
125
+ process.exit(0);
126
+ });
@@ -0,0 +1,156 @@
1
+ import { call } from "../daemon/client.js";
2
+ import type {
3
+ AuditLogPayload,
4
+ AuditLogResult,
5
+ PolicyCheckPayload,
6
+ PolicyCheckResult,
7
+ } from "../daemon/protocol.js";
8
+ import { evaluateToolCall } from "../core/workflow/enforcement.js";
9
+ import {
10
+ ensureDaemon,
11
+ getBooleanField,
12
+ getNumberField,
13
+ getRecordField,
14
+ getStringField,
15
+ isEnterpriseProject,
16
+ normalizeToolCall,
17
+ parseInput,
18
+ readStdin,
19
+ resolveDaemonEntry,
20
+ sendHeartbeat,
21
+ serializeForAudit,
22
+ } from "./shared.js";
23
+
24
+ function extractToolOutput(
25
+ input: Record<string, unknown>,
26
+ ): Record<string, unknown> {
27
+ const record =
28
+ getRecordField(input, ["tool_output", "toolOutput", "tool_result", "toolResult", "result"]) ??
29
+ {};
30
+
31
+ const outputText = getStringField(input, ["output", "stdout", "stderr"]);
32
+ if (outputText && record.output === undefined) {
33
+ record.output = outputText;
34
+ }
35
+
36
+ const success = getBooleanField(input, ["success"]);
37
+ if (success !== undefined && record.success === undefined) {
38
+ record.success = success;
39
+ }
40
+
41
+ const exitCode = getNumberField(input, ["exit_code", "exitCode"]);
42
+ if (exitCode !== undefined && record.exit_code === undefined) {
43
+ record.exit_code = exitCode;
44
+ }
45
+
46
+ return record;
47
+ }
48
+
49
+ async function main(): Promise<void> {
50
+ const raw = await readStdin();
51
+ const input = parseInput(raw);
52
+ const normalized = normalizeToolCall(input);
53
+ const enterprise = isEnterpriseProject(normalized.cwd);
54
+ const toolOutput = extractToolOutput(input);
55
+
56
+ ensureDaemon(resolveDaemonEntry(import.meta.url));
57
+
58
+ if (normalized.sessionId) {
59
+ void sendHeartbeat({
60
+ cli: "codex",
61
+ hook: "PostToolUse",
62
+ session_id: normalized.sessionId,
63
+ cwd: normalized.cwd,
64
+ });
65
+ }
66
+
67
+ const success =
68
+ getBooleanField(input, ["success"]) ??
69
+ (typeof toolOutput.exit_code === "number" ? toolOutput.exit_code === 0 : undefined);
70
+ const durationMs = getNumberField(input, ["duration_ms", "durationMs"]);
71
+
72
+ const auditPayload: AuditLogPayload = {
73
+ cwd: normalized.cwd,
74
+ entry: {
75
+ timestamp: new Date().toISOString(),
76
+ tool: normalized.toolName,
77
+ input: normalized.toolInput,
78
+ event_type: "tool",
79
+ evidence_level: "diagnostic",
80
+ resource_type: "tool_result",
81
+ session_id: normalized.sessionId,
82
+ ...(durationMs !== undefined ? { duration_ms: durationMs } : {}),
83
+ ...(success !== undefined
84
+ ? { status: success ? ("success" as const) : ("error" as const) }
85
+ : {}),
86
+ metadata: {
87
+ hook: "PostToolUse",
88
+ tool_output_preview: serializeForAudit(toolOutput),
89
+ },
90
+ },
91
+ };
92
+ void call<AuditLogResult>("audit.log", auditPayload, { timeoutMs: 3000 });
93
+
94
+ const activeTaskId = process.env.CORTEX_ACTIVE_TASK_ID?.trim();
95
+ if (activeTaskId) {
96
+ try {
97
+ const verdict = evaluateToolCall({
98
+ cwd: normalized.cwd,
99
+ taskId: activeTaskId,
100
+ call: { toolName: normalized.toolName, toolInput: normalized.toolInput },
101
+ });
102
+ if (!verdict.allowed) {
103
+ process.stderr.write(`[cortex] Blocked after tool execution: ${verdict.reason}\n`);
104
+ process.exit(2);
105
+ }
106
+ } catch (err) {
107
+ process.stderr.write(
108
+ `[cortex] post-tool capability evaluation failed (${
109
+ err instanceof Error ? err.message : String(err)
110
+ }); deferring to policy.check\n`,
111
+ );
112
+ }
113
+ }
114
+
115
+ if (Object.keys(toolOutput).length === 0) {
116
+ process.exit(0);
117
+ }
118
+
119
+ const policyPayload: PolicyCheckPayload = {
120
+ tool: `${normalized.toolName}.result`,
121
+ cwd: normalized.cwd,
122
+ input: toolOutput,
123
+ };
124
+ const policyRes = await call<PolicyCheckResult>("policy.check", policyPayload, {
125
+ timeoutMs: 5000,
126
+ });
127
+
128
+ if (policyRes.ok) {
129
+ if (policyRes.result.allow) {
130
+ process.exit(0);
131
+ }
132
+ process.stderr.write(
133
+ `[cortex] Blocked after tool execution by policy: ${policyRes.result.reason ?? "unspecified"}\n`,
134
+ );
135
+ process.exit(2);
136
+ }
137
+
138
+ if (enterprise) {
139
+ process.stderr.write(
140
+ `[cortex] Enterprise daemon unreachable (${policyRes.error}). Blocking continuation per fail-closed policy.\n`,
141
+ );
142
+ process.exit(2);
143
+ }
144
+
145
+ process.stderr.write(
146
+ `[cortex] Daemon unreachable (${policyRes.error}). Allowing continuation (community mode).\n`,
147
+ );
148
+ process.exit(0);
149
+ }
150
+
151
+ main().catch((err) => {
152
+ process.stderr.write(
153
+ `[cortex post-tool-use] error: ${err instanceof Error ? err.message : String(err)}\n`,
154
+ );
155
+ process.exit(0);
156
+ });
@@ -20,6 +20,13 @@ export type HookInput = {
20
20
  [key: string]: unknown;
21
21
  };
22
22
 
23
+ export type NormalizedToolCall = {
24
+ cwd: string;
25
+ sessionId?: string;
26
+ toolName: string;
27
+ toolInput: Record<string, unknown>;
28
+ };
29
+
23
30
  export async function readStdin(): Promise<string> {
24
31
  return new Promise((resolve) => {
25
32
  let data = "";
@@ -42,6 +49,106 @@ export function parseInput(raw: string): HookInput {
42
49
  }
43
50
  }
44
51
 
52
+ function isRecord(value: unknown): value is Record<string, unknown> {
53
+ return value !== null && typeof value === "object" && !Array.isArray(value);
54
+ }
55
+
56
+ export function getStringField(
57
+ input: Record<string, unknown>,
58
+ keys: string[],
59
+ ): string | undefined {
60
+ for (const key of keys) {
61
+ const value = input[key];
62
+ if (typeof value === "string" && value.trim()) return value;
63
+ }
64
+ return undefined;
65
+ }
66
+
67
+ export function getNumberField(
68
+ input: Record<string, unknown>,
69
+ keys: string[],
70
+ ): number | undefined {
71
+ for (const key of keys) {
72
+ const value = input[key];
73
+ if (typeof value === "number" && Number.isFinite(value)) return value;
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ export function getBooleanField(
79
+ input: Record<string, unknown>,
80
+ keys: string[],
81
+ ): boolean | undefined {
82
+ for (const key of keys) {
83
+ const value = input[key];
84
+ if (typeof value === "boolean") return value;
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ export function getRecordField(
90
+ input: Record<string, unknown>,
91
+ keys: string[],
92
+ ): Record<string, unknown> | undefined {
93
+ for (const key of keys) {
94
+ const value = input[key];
95
+ if (isRecord(value)) return value;
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ export function normalizeToolInput(value: unknown): Record<string, unknown> {
101
+ if (isRecord(value)) return value;
102
+ return {};
103
+ }
104
+
105
+ export function normalizeToolCall(input: HookInput): NormalizedToolCall {
106
+ const cwd = getStringField(input, ["cwd", "working_directory", "workingDirectory"]) ?? process.cwd();
107
+ const sessionId = getStringField(input, ["session_id", "sessionId"]);
108
+ const toolName =
109
+ getStringField(input, ["tool_name", "toolName", "tool"]) ??
110
+ (typeof input.command === "string" ? "Bash" : "unknown");
111
+ const toolInput =
112
+ getRecordField(input, ["tool_input", "toolInput", "tool_args", "toolArgs", "input", "args"]) ??
113
+ {};
114
+
115
+ if (typeof input.command === "string" && toolInput.command === undefined) {
116
+ toolInput.command = input.command;
117
+ }
118
+ if (Array.isArray(input.prefix_rule) && toolInput.prefix_rule === undefined) {
119
+ toolInput.prefix_rule = input.prefix_rule;
120
+ }
121
+ if (
122
+ typeof input.sandbox_permissions === "string" &&
123
+ toolInput.sandbox_permissions === undefined
124
+ ) {
125
+ toolInput.sandbox_permissions = input.sandbox_permissions;
126
+ }
127
+
128
+ return {
129
+ cwd,
130
+ ...(sessionId ? { sessionId } : {}),
131
+ toolName,
132
+ toolInput,
133
+ };
134
+ }
135
+
136
+ export function serializeForAudit(value: unknown, maxLen = 1200): string | undefined {
137
+ if (value === undefined) return undefined;
138
+ const raw =
139
+ typeof value === "string"
140
+ ? value
141
+ : (() => {
142
+ try {
143
+ return JSON.stringify(value);
144
+ } catch {
145
+ return String(value);
146
+ }
147
+ })();
148
+ if (!raw) return undefined;
149
+ return raw.length <= maxLen ? raw : `${raw.slice(0, maxLen)}...`;
150
+ }
151
+
45
152
  /**
46
153
  * Detect whether the current project is running enterprise mode.
47
154
  * Lightweight YAML peek — we don't want to load the full config parser
@@ -109,7 +109,39 @@ test("install --cli codex writes requirements.toml with sandbox bounds", async (
109
109
  "GET /api/v1/govern/config": (req, res) => {
110
110
  const config = {
111
111
  cli: "codex",
112
- managed_settings: {},
112
+ managed_settings: {
113
+ hooks: {
114
+ PreToolUse: [
115
+ {
116
+ matcher: "Edit|Write|Bash|MultiEdit",
117
+ command: "cortex hook pre-tool-use",
118
+ statusMessage: "Checking Cortex policy",
119
+ timeout: 30,
120
+ },
121
+ ],
122
+ PostToolUse: [
123
+ {
124
+ command: "cortex hook post-tool-use",
125
+ },
126
+ ],
127
+ PermissionRequest: [
128
+ {
129
+ command: "cortex hook permission-request",
130
+ },
131
+ ],
132
+ SessionStart: [
133
+ {
134
+ matcher: "startup|resume|clear",
135
+ command: "cortex hook session-start",
136
+ },
137
+ ],
138
+ SessionEnd: [
139
+ {
140
+ command: "cortex hook session-end",
141
+ },
142
+ ],
143
+ },
144
+ },
113
145
  deny_rules: [
114
146
  { pattern: "Edit(~/.codex/config.toml)", source_frameworks: ["iso27001"] },
115
147
  ],
@@ -124,7 +156,8 @@ test("install --cli codex writes requirements.toml with sandbox bounds", async (
124
156
  });
125
157
 
126
158
  const { root } = makeProject({ apiKey: "ent_test_key_12345678", baseUrl });
127
- const codexPath = path.join(root, "fake-codex-requirements.toml");
159
+ const codexDir = path.join(root, "fake managed codex");
160
+ const codexPath = path.join(codexDir, "requirements.toml");
128
161
  try {
129
162
  const result = await runGovernInstall({
130
163
  cli: "codex",
@@ -138,6 +171,40 @@ test("install --cli codex writes requirements.toml with sandbox bounds", async (
138
171
  assert.match(toml, /allowed_sandbox_modes = \["read-only", "workspace-write"\]/);
139
172
  assert.match(toml, /\[permissions\.filesystem\]/);
140
173
  assert.match(toml, /deny_read = \["~\/.codex\/config\.toml"\]/);
174
+ assert.match(toml, /\[hooks\]/);
175
+ assert.match(toml, /managed_dir = ".+fake managed codex\/hooks"/);
176
+ assert.match(toml, /\[\[hooks\.PreToolUse\]\]/);
177
+ assert.match(toml, /matcher = "Edit\|Write\|Bash\|MultiEdit"/);
178
+ assert.match(toml, /command = "\\".+fake managed codex\/hooks\/pre-tool-use\.sh\\""/);
179
+ assert.match(toml, /\[\[hooks\.PostToolUse\]\]/);
180
+ assert.match(toml, /command = "\\".+fake managed codex\/hooks\/post-tool-use\.sh\\""/);
181
+ assert.match(toml, /\[\[hooks\.PermissionRequest\]\]/);
182
+ assert.match(toml, /command = "\\".+fake managed codex\/hooks\/permission-request\.sh\\""/);
183
+ assert.match(toml, /\[\[hooks\.SessionStart\]\]/);
184
+ assert.match(toml, /command = "\\".+fake managed codex\/hooks\/session-start\.sh\\""/);
185
+ assert.match(toml, /\[\[hooks\.SessionEnd\]\]/);
186
+ assert.match(toml, /command = "\\".+fake managed codex\/hooks\/session-end\.sh\\""/);
187
+
188
+ const preToolUseWrapper = path.join(codexDir, "hooks", "pre-tool-use.sh");
189
+ const postToolUseWrapper = path.join(codexDir, "hooks", "post-tool-use.sh");
190
+ const permissionRequestWrapper = path.join(codexDir, "hooks", "permission-request.sh");
191
+ const sessionStartWrapper = path.join(codexDir, "hooks", "session-start.sh");
192
+ const sessionEndWrapper = path.join(codexDir, "hooks", "session-end.sh");
193
+ assert.equal(fs.existsSync(preToolUseWrapper), true);
194
+ assert.equal(fs.existsSync(postToolUseWrapper), true);
195
+ assert.equal(fs.existsSync(permissionRequestWrapper), true);
196
+ assert.equal(fs.existsSync(sessionStartWrapper), true);
197
+ assert.equal(fs.existsSync(sessionEndWrapper), true);
198
+ const preToolUseContents = fs.readFileSync(preToolUseWrapper, "utf8");
199
+ assert.match(preToolUseContents, /exec "\$CORTEX" hook pre-tool-use "\$@"/);
200
+ const postToolUseContents = fs.readFileSync(postToolUseWrapper, "utf8");
201
+ assert.match(postToolUseContents, /exec "\$CORTEX" hook post-tool-use "\$@"/);
202
+ const permissionRequestContents = fs.readFileSync(permissionRequestWrapper, "utf8");
203
+ assert.match(permissionRequestContents, /exec "\$CORTEX" hook permission-request "\$@"/);
204
+ const sessionEndContents = fs.readFileSync(sessionEndWrapper, "utf8");
205
+ assert.match(sessionEndContents, /exec "\$CORTEX" hook session-end "\$@"/);
206
+ const mode = fs.statSync(preToolUseWrapper).mode & 0o777;
207
+ assert.equal(mode, 0o755);
141
208
  } finally {
142
209
  server.close();
143
210
  fs.rmSync(root, { recursive: true, force: true });
@@ -1,5 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import path from "node:path";
3
4
 
4
5
  import { getManagedSettingsPath, buildCodexRequirementsToml } from "../dist/cli/govern.js";
5
6
 
@@ -34,7 +35,33 @@ test("getManagedSettingsPath: throws on unsupported cli (copilot has no managed
34
35
  test("buildCodexRequirementsToml: emits sandbox + approval upper bounds", () => {
35
36
  const config = {
36
37
  cli: "codex",
37
- managed_settings: {},
38
+ managed_settings: {
39
+ hooks: {
40
+ PreToolUse: [
41
+ {
42
+ matcher: "Edit|Write|Bash|MultiEdit",
43
+ command: '"/Library/Application Support/Codex/hooks/pre-tool-use.sh"',
44
+ statusMessage: "Checking Cortex policy",
45
+ timeout: 30,
46
+ },
47
+ ],
48
+ PostToolUse: [
49
+ {
50
+ command: '"/Library/Application Support/Codex/hooks/post-tool-use.sh"',
51
+ },
52
+ ],
53
+ PermissionRequest: [
54
+ {
55
+ command: '"/Library/Application Support/Codex/hooks/permission-request.sh"',
56
+ },
57
+ ],
58
+ SessionEnd: [
59
+ {
60
+ command: "cortex hook session-end",
61
+ },
62
+ ],
63
+ },
64
+ },
38
65
  deny_rules: [
39
66
  { pattern: "Edit(~/.codex/config.toml)", source_frameworks: ["iso27001"] },
40
67
  { pattern: "Bash(curl *)", source_frameworks: ["iso27001"] },
@@ -42,11 +69,26 @@ test("buildCodexRequirementsToml: emits sandbox + approval upper bounds", () =>
42
69
  tamper_config: { heartbeat_interval_seconds: 60, missing_threshold_seconds: 300 },
43
70
  frameworks: [{ id: "iso27001", version: "0.1.0" }],
44
71
  };
45
- const toml = buildCodexRequirementsToml(config);
72
+ const toml = buildCodexRequirementsToml(config, {
73
+ managedHookDir: "/Library/Application Support/Codex/hooks",
74
+ });
46
75
  assert.match(toml, /allowed_sandbox_modes = \["read-only", "workspace-write"\]/);
47
76
  assert.match(toml, /allowed_approval_policies = \["untrusted", "on-request"\]/);
48
77
  assert.match(toml, /\[permissions\.filesystem\]/);
49
78
  assert.match(toml, /deny_read = \["~\/.codex\/config\.toml"\]/);
79
+ assert.match(toml, /\[hooks\]/);
80
+ assert.match(toml, /managed_dir = "\/Library\/Application Support\/Codex\/hooks"/);
81
+ assert.match(toml, /\[\[hooks\.PreToolUse\]\]/);
82
+ assert.match(toml, /matcher = "Edit\|Write\|Bash\|MultiEdit"/);
83
+ assert.match(toml, /command = "\\"\/Library\/Application Support\/Codex\/hooks\/pre-tool-use\.sh\\""/);
84
+ assert.match(toml, /\[\[hooks\.PostToolUse\]\]/);
85
+ assert.match(toml, /command = "\\"\/Library\/Application Support\/Codex\/hooks\/post-tool-use\.sh\\""/);
86
+ assert.match(toml, /\[\[hooks\.PermissionRequest\]\]/);
87
+ assert.match(toml, /command = "\\"\/Library\/Application Support\/Codex\/hooks\/permission-request\.sh\\""/);
88
+ assert.match(toml, /statusMessage = "Checking Cortex policy"/);
89
+ assert.match(toml, /timeout = 30/);
90
+ assert.match(toml, /\[\[hooks\.SessionEnd\]\]/);
91
+ assert.match(toml, /command = "cortex hook session-end"/);
50
92
  // Bash(...) patterns should not appear in deny_read (filesystem only)
51
93
  assert.doesNotMatch(toml, /curl/);
52
94
  });
@@ -72,3 +114,34 @@ test("buildCodexRequirementsToml: escapes quotes in patterns", () => {
72
114
  });
73
115
  assert.match(toml, /\\"quote\\"/);
74
116
  });
117
+
118
+ test("buildCodexRequirementsToml: emits managed hook paths under the provided directory", () => {
119
+ const managedHookDir = path.join("/tmp", "Codex Hooks");
120
+ const toml = buildCodexRequirementsToml({
121
+ cli: "codex",
122
+ managed_settings: {
123
+ hooks: {
124
+ SessionStart: [
125
+ {
126
+ matcher: "startup|resume|clear",
127
+ hooks: [
128
+ {
129
+ type: "command",
130
+ command: `"${path.join(managedHookDir, "session-start.sh")}"`,
131
+ },
132
+ ],
133
+ },
134
+ ],
135
+ },
136
+ },
137
+ deny_rules: [],
138
+ tamper_config: { heartbeat_interval_seconds: 60, missing_threshold_seconds: 300 },
139
+ frameworks: [],
140
+ }, {
141
+ managedHookDir,
142
+ });
143
+ assert.match(toml, /managed_dir = "\/tmp\/Codex Hooks"/);
144
+ assert.match(toml, /\[\[hooks\.SessionStart\]\]/);
145
+ assert.match(toml, /matcher = "startup\|resume\|clear"/);
146
+ assert.match(toml, /command = "\\"\/tmp\/Codex Hooks\/session-start\.sh\\""/);
147
+ });