@danielblomma/cortex-mcp 2.0.12 → 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.12",
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"
@@ -46,6 +46,7 @@ type ManifestEntry = {
46
46
  };
47
47
 
48
48
  type LocalSkillRecord = {
49
+ cli: SkillCli;
49
50
  scope: string;
50
51
  updated_at: string;
51
52
  path: string;
@@ -88,7 +89,29 @@ function readState(): LocalSkillsState {
88
89
  if (!existsSync(path)) return { skills: {} };
89
90
  try {
90
91
  const parsed = JSON.parse(readFileSync(path, "utf8")) as LocalSkillsState;
91
- return { skills: parsed.skills ?? {}, last_synced_at: parsed.last_synced_at };
92
+ const normalizedSkills: Record<string, LocalSkillRecord> = {};
93
+ for (const [key, record] of Object.entries(parsed.skills ?? {})) {
94
+ if (!record || typeof record !== "object") continue;
95
+ const inferredCli =
96
+ record.path?.includes("/.codex/skills/")
97
+ ? "codex"
98
+ : "claude";
99
+ const cli =
100
+ record.cli === "codex" || record.cli === "claude"
101
+ ? record.cli
102
+ : inferredCli;
103
+ const normalizedKey = key.includes(":") ? key : `${cli}:${key}`;
104
+ normalizedSkills[normalizedKey] = {
105
+ cli,
106
+ scope: String(record.scope ?? "global"),
107
+ updated_at: String(record.updated_at ?? ""),
108
+ path: String(record.path ?? ""),
109
+ };
110
+ }
111
+ return {
112
+ skills: normalizedSkills,
113
+ last_synced_at: parsed.last_synced_at,
114
+ };
92
115
  } catch {
93
116
  return { skills: {} };
94
117
  }
@@ -103,19 +126,22 @@ function writeState(state: LocalSkillsState): void {
103
126
  }
104
127
 
105
128
  /**
106
- * Resolve the on-disk SKILL.md path for a skill. Global skills live under
107
- * ~/.claude/skills (Claude Code's user-scope skills directory); cli:codex
108
- * skills live under ~/.codex/skills. cli:claude scope is treated as
109
- * Claude-only and lands in ~/.claude/skills.
129
+ * Resolve the on-disk SKILL.md path for a skill install target. Global
130
+ * skills are installed once per CLI, so the destination root depends on the
131
+ * active sync target rather than just the stored scope.
110
132
  */
111
- function skillFilePath(scope: string, name: string): string {
133
+ function skillFilePath(cli: SkillCli, name: string): string {
112
134
  const root =
113
- scope === "cli:codex"
135
+ cli === "codex"
114
136
  ? join(homedir(), ".codex", "skills")
115
137
  : join(homedir(), ".claude", "skills");
116
138
  return join(root, name, "SKILL.md");
117
139
  }
118
140
 
141
+ function stateSkillKey(cli: SkillCli, name: string): string {
142
+ return `${cli}:${name}`;
143
+ }
144
+
119
145
  function shouldSyncForCli(scope: string, cli: SkillCli): boolean {
120
146
  if (scope === "global") return true;
121
147
  return scope === `cli:${cli}`;
@@ -222,7 +248,8 @@ export async function runSkillSyncForCli(
222
248
 
223
249
  // Detect adds + changes
224
250
  for (const entry of relevantManifest) {
225
- const local = state.skills[entry.name];
251
+ const skillKey = stateSkillKey(cli, entry.name);
252
+ const local = state.skills[skillKey];
226
253
  const isNew = !local;
227
254
  const isChanged =
228
255
  Boolean(local) &&
@@ -243,7 +270,7 @@ export async function runSkillSyncForCli(
243
270
  };
244
271
  }
245
272
 
246
- const path = skillFilePath(entry.scope, entry.name);
273
+ const path = skillFilePath(cli, entry.name);
247
274
  try {
248
275
  writeSkillFile(path, body);
249
276
  } catch (err) {
@@ -257,7 +284,8 @@ export async function runSkillSyncForCli(
257
284
  };
258
285
  }
259
286
 
260
- state.skills[entry.name] = {
287
+ state.skills[skillKey] = {
288
+ cli,
261
289
  scope: entry.scope,
262
290
  updated_at: entry.updated_at,
263
291
  path,
@@ -269,7 +297,10 @@ export async function runSkillSyncForCli(
269
297
  // dropped (or disabled). We only consider state entries whose scope
270
298
  // matches this cli, so we don't accidentally remove the other CLI's
271
299
  // skills when running a per-cli tick.
272
- for (const [name, record] of Object.entries(state.skills)) {
300
+ for (const [skillKey, record] of Object.entries(state.skills)) {
301
+ if (record.cli !== cli) continue;
302
+ const [, name] = skillKey.split(":", 2);
303
+ if (!name) continue;
273
304
  if (!shouldSyncForCli(record.scope, cli)) continue;
274
305
  if (remoteByName.has(name)) continue;
275
306
  try {
@@ -277,7 +308,7 @@ export async function runSkillSyncForCli(
277
308
  } catch {
278
309
  // best-effort; if unlink fails the next tick will retry
279
310
  }
280
- delete state.skills[name];
311
+ delete state.skills[skillKey];
281
312
  removed.push(name);
282
313
  }
283
314
 
@@ -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
+ });