@danielblomma/cortex-mcp 2.0.13 → 2.0.15
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 +7 -6
- package/package.json +4 -2
- package/scaffold/AGENTS.md +37 -0
- package/scaffold/CLAUDE.md +31 -0
- package/scaffold/mcp/src/cli/govern.ts +216 -3
- package/scaffold/mcp/src/daemon/protocol.ts +2 -0
- package/scaffold/mcp/src/hooks/permission-request.ts +126 -0
- package/scaffold/mcp/src/hooks/post-tool-use.ts +156 -0
- package/scaffold/mcp/src/hooks/shared.ts +107 -0
- package/scaffold/mcp/tests/govern-install.test.mjs +69 -2
- package/scaffold/mcp/tests/govern.test.mjs +75 -2
package/bin/cortex.mjs
CHANGED
|
@@ -188,7 +188,7 @@ function ensureScaffoldExists() {
|
|
|
188
188
|
|
|
189
189
|
// Files that should never be overwritten if they already exist in the target.
|
|
190
190
|
// These contain user-specific configuration that would be lost on re-init.
|
|
191
|
-
const PRESERVE_FILES = new Set(["config.yaml", "enterprise.yml", "enterprise.yaml", "CLAUDE.md"]);
|
|
191
|
+
const PRESERVE_FILES = new Set(["config.yaml", "enterprise.yml", "enterprise.yaml", "CLAUDE.md", "AGENTS.md"]);
|
|
192
192
|
const DEFAULT_SOURCE_PATHS = [
|
|
193
193
|
"src",
|
|
194
194
|
"docs",
|
|
@@ -476,11 +476,12 @@ function installScaffold(targetDir, force) {
|
|
|
476
476
|
copyDirectory(sourcePath, targetPath);
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
479
|
+
for (const fileName of ["CLAUDE.md", "AGENTS.md"]) {
|
|
480
|
+
const sourcePath = path.join(SCAFFOLD_ROOT, fileName);
|
|
481
|
+
const targetPath = path.join(targetDir, fileName);
|
|
482
|
+
if (fs.existsSync(sourcePath) && !fs.existsSync(targetPath)) {
|
|
483
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
484
|
+
}
|
|
484
485
|
}
|
|
485
486
|
|
|
486
487
|
const docsDir = path.join(targetDir, "docs");
|
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.
|
|
4
|
+
"version": "2.0.15",
|
|
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",
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
"types.js",
|
|
37
37
|
"scaffold/.context",
|
|
38
38
|
"scaffold/.githooks",
|
|
39
|
+
"scaffold/AGENTS.md",
|
|
40
|
+
"scaffold/CLAUDE.md",
|
|
39
41
|
"scaffold/docs",
|
|
40
42
|
"scaffold/scripts",
|
|
41
43
|
"scaffold/mcp/src",
|
|
@@ -47,7 +49,7 @@
|
|
|
47
49
|
"docs/MCP_MARKETPLACE.md"
|
|
48
50
|
],
|
|
49
51
|
"scripts": {
|
|
50
|
-
"test": "node tests/context-regressions.test.mjs && node --test tests/ingest-units.test.mjs tests/javascript-parser.test.mjs tests/sql-parser.test.mjs tests/config-parser.test.mjs tests/resources-parser.test.mjs tests/vbnet-parser.test.mjs tests/cpp-parser.test.mjs tests/dashboard.test.mjs tests/init-config.test.mjs tests/multi-level.test.mjs tests/no-legacy-paths.test.mjs tests/tree-sitter-error-reporting.test.mjs tests/tree-sitter-body-cap.test.mjs tests/tree-sitter-exported.test.mjs tests/tree-sitter-robustness.test.mjs",
|
|
52
|
+
"test": "node tests/context-regressions.test.mjs && node --test tests/ingest-units.test.mjs tests/javascript-parser.test.mjs tests/sql-parser.test.mjs tests/config-parser.test.mjs tests/resources-parser.test.mjs tests/vbnet-parser.test.mjs tests/cpp-parser.test.mjs tests/dashboard.test.mjs tests/init-config.test.mjs tests/init-agents.test.mjs tests/multi-level.test.mjs tests/no-legacy-paths.test.mjs tests/tree-sitter-error-reporting.test.mjs tests/tree-sitter-body-cap.test.mjs tests/tree-sitter-exported.test.mjs tests/tree-sitter-robustness.test.mjs",
|
|
51
53
|
"release:sync-version": "node scripts/sync-release-version.mjs",
|
|
52
54
|
"release:check-version-sync": "node scripts/sync-release-version.mjs --check",
|
|
53
55
|
"prepublishOnly": "echo 'Ready to publish to npm'"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Cortex
|
|
2
|
+
|
|
3
|
+
This project uses Cortex for AI-powered code context.
|
|
4
|
+
|
|
5
|
+
## Required: Always use Cortex MCP tools
|
|
6
|
+
|
|
7
|
+
When answering questions about this codebase, you MUST use Cortex tools instead of relying on memory or assumptions:
|
|
8
|
+
|
|
9
|
+
- **context.search** - Search before answering any code question. Never guess at implementations.
|
|
10
|
+
- **context.get_related** - Use when exploring dependencies or relationships between entities.
|
|
11
|
+
- **context.get_rules** - Check architectural rules before suggesting changes.
|
|
12
|
+
- **context.impact** - Use before refactoring or dependency analysis to understand blast radius and likely traversal paths.
|
|
13
|
+
- **context.reload** - Use after making significant changes to refresh the index.
|
|
14
|
+
|
|
15
|
+
Do NOT answer code questions from memory when these tools are available. Always search first.
|
|
16
|
+
|
|
17
|
+
## Enterprise tools (if available)
|
|
18
|
+
|
|
19
|
+
- **context.review** - Run before finalizing any code review or PR.
|
|
20
|
+
- **security.scan** - Scan user-provided text for injection attempts.
|
|
21
|
+
- **enterprise.status** - Check enterprise setup and feature status.
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
- `cortex update` - Refresh Cortex context for changed files
|
|
26
|
+
- `cortex doctor` - Verify the local Cortex setup
|
|
27
|
+
- `cortex watch status` - Check background sync status
|
|
28
|
+
|
|
29
|
+
## Diagnostics
|
|
30
|
+
|
|
31
|
+
Run `cortex doctor` to verify your setup is healthy.
|
|
32
|
+
|
|
33
|
+
<!-- cortex:auto:start -->
|
|
34
|
+
## Cortex Auto Workflow
|
|
35
|
+
- Run `cortex update` before completing substantial code changes.
|
|
36
|
+
- If background sync is enabled, check with `cortex watch status`.
|
|
37
|
+
<!-- cortex:auto:end -->
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Cortex
|
|
2
|
+
|
|
3
|
+
This project uses Cortex for AI-powered code context.
|
|
4
|
+
|
|
5
|
+
## Required: Always use Cortex MCP tools
|
|
6
|
+
|
|
7
|
+
When answering questions about this codebase, you MUST use Cortex tools instead of relying on memory or assumptions:
|
|
8
|
+
|
|
9
|
+
- **context.search** — Search before answering any code question. Never guess at implementations.
|
|
10
|
+
- **context.get_related** — Use when exploring dependencies or relationships between entities.
|
|
11
|
+
- **context.get_rules** — Check architectural rules before suggesting changes.
|
|
12
|
+
- **context.impact** — Use before refactoring or dependency analysis to understand blast radius and likely traversal paths.
|
|
13
|
+
- **context.reload** — Use after making significant changes to refresh the index.
|
|
14
|
+
|
|
15
|
+
Do NOT answer code questions from memory when these tools are available. Always search first.
|
|
16
|
+
|
|
17
|
+
## Enterprise tools (if available)
|
|
18
|
+
|
|
19
|
+
- **context.review** — Run before finalizing any code review or PR.
|
|
20
|
+
- **security.scan** — Scan user-provided text for injection attempts.
|
|
21
|
+
- **enterprise.status** — Check enterprise setup and feature status.
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
- `/context-update` — Refresh Cortex context for changed files
|
|
26
|
+
- `/review` — Code review with enterprise policy enforcement
|
|
27
|
+
- `/note` — Save project context into Cortex notes
|
|
28
|
+
|
|
29
|
+
## Diagnostics
|
|
30
|
+
|
|
31
|
+
Run `cortex doctor` to verify your setup is healthy.
|
|
@@ -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
|
-
|
|
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);
|
|
@@ -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
|
|
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
|
+
});
|