@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 +1 -1
- package/scaffold/mcp/src/cli/govern.ts +216 -3
- package/scaffold/mcp/src/daemon/protocol.ts +2 -0
- package/scaffold/mcp/src/daemon/skill-sync-checker.ts +43 -12
- 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/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.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
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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(
|
|
133
|
+
function skillFilePath(cli: SkillCli, name: string): string {
|
|
112
134
|
const root =
|
|
113
|
-
|
|
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
|
|
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(
|
|
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[
|
|
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 [
|
|
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[
|
|
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
|
|
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
|
+
});
|