@brunobracaioli/aibrain 0.4.0-beta.11 → 0.4.0-beta.13

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.
@@ -499,15 +499,22 @@ async function request(child, id, method, params) {
499
499
  return message.result;
500
500
  }
501
501
 
502
- /** @param {string} executable @param {string} cwd @param {string|undefined} profile @param {NodeJS.ProcessEnv} environment */
503
- export async function readEffectiveWritableRoots(executable, cwd, profile, environment) {
504
- validateProfile(profile);
502
+ /**
503
+ * Run one short-lived, initialized app-server for a preflight read, then stop it
504
+ * and its process group. `body` gets the child and whether a signal cancelled it.
505
+ * @template T
506
+ * @param {{ executable: string, cwd: string, profile: string|undefined, environment: NodeJS.ProcessEnv, configArgs?: string[] }} options
507
+ * @param {(child: OwnedChild, cancelled: () => boolean) => Promise<T>} body
508
+ * @returns {Promise<T>}
509
+ */
510
+ async function withPreflightServer(options, body) {
511
+ validateProfile(options.profile);
505
512
  const args = [];
506
- if (profile !== undefined) args.push('--profile', profile);
507
- args.push('app-server', '--listen', 'stdio://');
508
- const child = spawn(executable, args, {
509
- cwd,
510
- env: childEnvironment(environment),
513
+ if (options.profile !== undefined) args.push('--profile', options.profile);
514
+ args.push(...(options.configArgs ?? []), 'app-server', '--listen', 'stdio://');
515
+ const child = spawn(options.executable, args, {
516
+ cwd: options.cwd,
517
+ env: childEnvironment(options.environment),
511
518
  stdio: ['pipe', 'pipe', 'ignore'],
512
519
  detached: process.platform !== 'win32',
513
520
  });
@@ -531,32 +538,7 @@ export async function readEffectiveWritableRoots(executable, cwd, profile, envir
531
538
  } catch {
532
539
  fail('preflight_failed');
533
540
  }
534
- const result = await request(child, 1, 'config/read', { cwd, includeLayers: true });
535
- const rawConfig = result.config;
536
- if (
537
- rawConfig === undefined ||
538
- rawConfig === null ||
539
- typeof rawConfig !== 'object' ||
540
- Array.isArray(rawConfig)
541
- )
542
- fail('preflight_roots_unavailable');
543
- const config = /** @type {JsonObject} */ (rawConfig);
544
- const rawSandbox = config.sandbox_workspace_write;
545
- if (cancelled) fail('preflight_cancelled');
546
- if (rawSandbox === undefined || rawSandbox === null) return [];
547
- if (typeof rawSandbox !== 'object' || Array.isArray(rawSandbox))
548
- fail('preflight_roots_unavailable');
549
- const sandbox = /** @type {JsonObject} */ (rawSandbox);
550
- const roots = sandbox.writable_roots;
551
- if (cancelled) fail('preflight_cancelled');
552
- if (roots === undefined || roots === null) return [];
553
- if (
554
- !Array.isArray(roots) ||
555
- roots.some((path) => typeof path !== 'string' || !isAbsolute(path))
556
- )
557
- fail('preflight_roots_unavailable');
558
- if (cancelled) fail('preflight_cancelled');
559
- return deduplicate(roots);
541
+ return await body(child, () => cancelled);
560
542
  } finally {
561
543
  process.removeListener('SIGINT', cancel);
562
544
  process.removeListener('SIGTERM', cancel);
@@ -565,6 +547,57 @@ export async function readEffectiveWritableRoots(executable, cwd, profile, envir
565
547
  }
566
548
  }
567
549
 
550
+ /** @param {string} executable @param {string} cwd @param {string|undefined} profile @param {NodeJS.ProcessEnv} environment */
551
+ export async function readEffectiveWritableRoots(executable, cwd, profile, environment) {
552
+ return withPreflightServer(
553
+ { executable, cwd, profile, environment },
554
+ async (child, cancelled) => {
555
+ const result = await request(child, 1, 'config/read', { cwd, includeLayers: true });
556
+ const rawConfig = result.config;
557
+ if (
558
+ rawConfig === undefined ||
559
+ rawConfig === null ||
560
+ typeof rawConfig !== 'object' ||
561
+ Array.isArray(rawConfig)
562
+ )
563
+ fail('preflight_roots_unavailable');
564
+ const config = /** @type {JsonObject} */ (rawConfig);
565
+ const rawSandbox = config.sandbox_workspace_write;
566
+ if (cancelled()) fail('preflight_cancelled');
567
+ if (rawSandbox === undefined || rawSandbox === null) return [];
568
+ if (typeof rawSandbox !== 'object' || Array.isArray(rawSandbox))
569
+ fail('preflight_roots_unavailable');
570
+ const sandbox = /** @type {JsonObject} */ (rawSandbox);
571
+ const roots = sandbox.writable_roots;
572
+ if (cancelled()) fail('preflight_cancelled');
573
+ if (roots === undefined || roots === null) return [];
574
+ if (
575
+ !Array.isArray(roots) ||
576
+ roots.some((path) => typeof path !== 'string' || !isAbsolute(path))
577
+ )
578
+ fail('preflight_roots_unavailable');
579
+ if (cancelled()) fail('preflight_cancelled');
580
+ return deduplicate(roots);
581
+ },
582
+ );
583
+ }
584
+
585
+ /**
586
+ * What Codex reports for a set of session-flag hooks (`hooks/list` for one
587
+ * folder), read from a short-lived app-server started with those flags and the
588
+ * user's own CODEX_HOME, where the trust records live. It writes no config and
589
+ * no trust record (spec aisac-engineering-extensions FR6, part 1 Q4).
590
+ * @param {{ executable: string, cwd: string, profile: string|undefined, environment: NodeJS.ProcessEnv, configArgs: string[] }} options
591
+ * @returns {Promise<JsonObject>}
592
+ */
593
+ export async function readCodexHookList(options) {
594
+ return withPreflightServer(options, async (child, cancelled) => {
595
+ const result = await request(child, 1, 'hooks/list', { cwds: [options.cwd] });
596
+ if (cancelled()) fail('preflight_cancelled');
597
+ return result;
598
+ });
599
+ }
600
+
568
601
  /** @param {LaunchPlan} plan @param {string[]} configuredRoots */
569
602
  export function makeCodexLaunchEnvelope(plan, configuredRoots) {
570
603
  const roots = deduplicate([...configuredRoots, ...plan.additionalRoots]);
@@ -35,7 +35,7 @@ export const HOOK_CATALOG = Object.freeze([
35
35
  id: 'secrets-guard',
36
36
  mode: 'block',
37
37
  defaultOn: true,
38
- clients: ['claude'],
38
+ clients: ['claude', 'codex'],
39
39
  bindings: [
40
40
  { event: 'PreToolUse', matcher: 'Read|Edit|MultiEdit|Write|NotebookEdit|Grep|Bash' },
41
41
  ],
@@ -49,7 +49,7 @@ export const HOOK_CATALOG = Object.freeze([
49
49
  id: 'git-guard',
50
50
  mode: 'block',
51
51
  defaultOn: false,
52
- clients: ['claude'],
52
+ clients: ['claude', 'codex'],
53
53
  bindings: [{ event: 'PreToolUse', matcher: 'Bash' }],
54
54
  description: {
55
55
  en: 'Refuses git push to main or master and force pushes (--force-with-lease passes).',
@@ -60,7 +60,7 @@ export const HOOK_CATALOG = Object.freeze([
60
60
  id: 'destructive-guard',
61
61
  mode: 'block',
62
62
  defaultOn: false,
63
- clients: ['claude'],
63
+ clients: ['claude', 'codex'],
64
64
  bindings: [{ event: 'PreToolUse', matcher: 'Bash' }],
65
65
  description: {
66
66
  en: 'Refuses rm -rf on /, ~, . or .. and DROP DATABASE, DROP TABLE or TRUNCATE.',
@@ -71,7 +71,7 @@ export const HOOK_CATALOG = Object.freeze([
71
71
  id: 'format-on-edit',
72
72
  mode: 'advise',
73
73
  defaultOn: false,
74
- clients: ['claude'],
74
+ clients: ['claude', 'codex'],
75
75
  bindings: [{ event: 'PostToolUse', matcher: EDIT_TOOLS }],
76
76
  description: {
77
77
  en: "Formats the edited file with the project's own formatter, when one is installed.",
@@ -82,7 +82,7 @@ export const HOOK_CATALOG = Object.freeze([
82
82
  id: 'docs-reminder',
83
83
  mode: 'advise',
84
84
  defaultOn: true,
85
- clients: ['claude'],
85
+ clients: ['claude', 'codex'],
86
86
  bindings: [{ event: 'PostToolUse', matcher: EDIT_TOOLS }, { event: 'UserPromptSubmit' }],
87
87
  description: {
88
88
  en: 'After a source file changes in a project with docs/, reminds once per turn to update the docs.',
@@ -94,7 +94,7 @@ export const HOOK_CATALOG = Object.freeze([
94
94
  id: 'session-context',
95
95
  mode: 'context',
96
96
  defaultOn: true,
97
- clients: ['claude'],
97
+ clients: ['claude', 'codex'],
98
98
  bindings: [{ event: 'SessionStart', matcher: 'startup|resume|clear' }],
99
99
  description: {
100
100
  en: 'Starts the session with a short repository snapshot (branch, status, recent commits).',
@@ -105,7 +105,7 @@ export const HOOK_CATALOG = Object.freeze([
105
105
  id: 'compact-continuity',
106
106
  mode: 'context',
107
107
  defaultOn: true,
108
- clients: ['claude'],
108
+ clients: ['claude', 'codex'],
109
109
  bindings: [{ event: 'PreCompact' }, { event: 'SessionStart', matcher: 'compact' }],
110
110
  description: {
111
111
  en: 'Saves a repository snapshot before a compaction and restores it right after.',
@@ -116,7 +116,7 @@ export const HOOK_CATALOG = Object.freeze([
116
116
  id: 'continuity-reminder',
117
117
  mode: 'block',
118
118
  defaultOn: true,
119
- clients: ['claude'],
119
+ clients: ['claude', 'codex'],
120
120
  bindings: [
121
121
  { event: 'PostToolUse', matcher: EDIT_TOOLS },
122
122
  { event: 'UserPromptSubmit' },
@@ -130,6 +130,24 @@ export const HOOK_CATALOG = Object.freeze([
130
130
  },
131
131
  ]);
132
132
 
133
+ /**
134
+ * Codex hook positions (spec FR6, E6b). Codex keys a user's trust and off switch
135
+ * by position (`<event>:<group>:<handler>`), so each event lists its hooks in a
136
+ * fixed order and the launcher always emits all of them. This list is
137
+ * append-only: a new hook goes at the end of its event, and a retired id stays
138
+ * in place (the dispatcher ignores an id the catalog no longer has), so a review
139
+ * the user made in `/hooks` never moves to another hook.
140
+ * @type {Readonly<Record<HookEvent, readonly string[]>>}
141
+ */
142
+ export const CODEX_HOOK_POSITIONS = Object.freeze({
143
+ PreToolUse: Object.freeze(['secrets-guard', 'git-guard', 'destructive-guard']),
144
+ PostToolUse: Object.freeze(['format-on-edit', 'docs-reminder', 'continuity-reminder']),
145
+ UserPromptSubmit: Object.freeze(['docs-reminder', 'continuity-reminder']),
146
+ SessionStart: Object.freeze(['session-context', 'compact-continuity']),
147
+ PreCompact: Object.freeze(['compact-continuity']),
148
+ Stop: Object.freeze(['continuity-reminder']),
149
+ });
150
+
133
151
  /** @param {string} id @returns {HookEntry | undefined} */
134
152
  export function hookById(id) {
135
153
  return HOOK_CATALOG.find((entry) => entry.id === id);
@@ -59,13 +59,13 @@ const LOGIN_LABEL = Object.freeze({
59
59
  /** @type {ExtensionKind} */
60
60
  const HOOKS = {
61
61
  key: 'hooks',
62
- title: 'AISAC Engineering hooks (Claude Code)',
62
+ title: 'AISAC Engineering hooks (Claude Code, and Codex 0.166+ after one review in /hooks)',
63
63
  ids: hookIds,
64
64
  resolve: resolveHooks,
65
65
  unknown: unknownHookIds,
66
66
  column: (id) => hookById(id)?.mode ?? '',
67
67
  nextSession:
68
- 'Applies to the next `aisac claude` session; running sessions keep their current set.',
68
+ 'Applies to the next `aisac claude` or `aisac codex` session; running sessions keep their current set. In Codex the AISAC hooks run only after you approve them once in /hooks, and you can switch each one off there too.',
69
69
  };
70
70
 
71
71
  /** @type {ExtensionKind} */
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { isAbsolute, resolve } from 'node:path';
10
+ import { PATCH_TOOL, patchPaths } from './patch.mjs';
10
11
  import { effectiveWords, programName, splitShell } from './shell-words.mjs';
11
12
 
12
13
  /**
@@ -14,6 +15,8 @@ import { effectiveWords, programName, splitShell } from './shell-words.mjs';
14
15
  * @property {string} projectDir
15
16
  * @property {string} home
16
17
  * @property {string} prefsPath The extensions preferences file (FR2.2).
18
+ * @property {readonly string[]} [codexConfigs] Codex's user config files, which hold
19
+ * the switches for the AISAC hooks in Codex (FR2.2, E6b).
17
20
  * @property {'pt-BR' | 'en'} locale
18
21
  */
19
22
  /** @typedef {{ tool_name?: unknown, tool_input?: unknown, cwd?: unknown }} ToolEvent */
@@ -68,6 +71,10 @@ const DATABASE_CLIENTS = new Set([
68
71
  'cockroach',
69
72
  'duckdb',
70
73
  ]);
74
+ // Codex's user config named through a variable or `~` (`~/.codex/config.toml`,
75
+ // `$CODEX_HOME/config.toml`); resolved paths are compared as well.
76
+ const CODEX_CONFIG_MENTION =
77
+ /(?:~|\$\{?HOME\}?)\/\.codex\/config\.toml|\$\{?CODEX_HOME\}?\/config\.toml/u;
71
78
  const DESTRUCTIVE_SQL =
72
79
  /\b(?:DROP\s+(?:DATABASE|TABLE|SCHEMA)|TRUNCATE(?:\s+TABLE)?\s+[A-Za-z_"`])/iu;
73
80
 
@@ -76,6 +83,8 @@ const MESSAGES = {
76
83
  secret: 'refused: this touches a secret file (rule: %s). Ask the user to do it themselves.',
77
84
  extensions:
78
85
  'refused: AISAC extensions are changed only by the user, from their own terminal (aisac hooks / aisac mcp).',
86
+ codexConfig:
87
+ "refused: Codex's config.toml holds the switches for the AISAC guardrails; only the user changes it, from their own terminal.",
79
88
  pushProtected: 'refused: push to %s. Open a pull request instead.',
80
89
  pushForce:
81
90
  'refused: force push. Use --force-with-lease on your own branch if it is really needed.',
@@ -87,6 +96,8 @@ const MESSAGES = {
87
96
  'recusado: isto mexe num arquivo de segredo (regra: %s). Peça ao usuário para fazer isso.',
88
97
  extensions:
89
98
  'recusado: as extensões do AISAC só são mudadas pelo usuário, no terminal dele (aisac hooks / aisac mcp).',
99
+ codexConfig:
100
+ 'recusado: o config.toml do Codex guarda os interruptores dos guardrails do AISAC; só o usuário muda esse arquivo, no terminal dele.',
90
101
  pushProtected: 'recusado: push na %s. Abra um pull request.',
91
102
  pushForce:
92
103
  'recusado: push forçado. Use --force-with-lease na sua branch, se for mesmo preciso.',
@@ -142,9 +153,19 @@ function resolvePath(path, context) {
142
153
  return isAbsolute(expanded) ? resolve(expanded) : resolve(context.projectDir, expanded);
143
154
  }
144
155
 
145
- /** @param {string} path @param {GuardContext} context */
146
- function isPreferencesPath(path, context) {
147
- return path.length > 0 && resolvePath(path, context) === resolve(context.prefsPath);
156
+ /**
157
+ * The FR2.2 refusal for a path that names a file only the user may change: the
158
+ * preferences file, or Codex's user config. Null for any other path.
159
+ * @param {string} path @param {GuardContext} context
160
+ * @returns {string | null}
161
+ */
162
+ function protectedRefusal(path, context) {
163
+ if (path.length === 0) return null;
164
+ const resolved = resolvePath(path, context);
165
+ if (resolved === resolve(context.prefsPath)) return message(context, 'extensions');
166
+ if ((context.codexConfigs ?? []).some((config) => resolved === resolve(config)))
167
+ return message(context, 'codexConfig');
168
+ return null;
148
169
  }
149
170
 
150
171
  /** Path-like parts of a word: the word itself and a `--flag=value` / `VAR=value` value. @param {string} word */
@@ -179,17 +200,45 @@ export function secretsGuard(event, context) {
179
200
  const input = record(event.tool_input);
180
201
  if (tool === 'Bash')
181
202
  return secretsInCommand(typeof input.command === 'string' ? input.command : '', context);
203
+ // A Codex patch writes every file its headers name (E6b).
204
+ if (tool === PATCH_TOOL) {
205
+ for (const path of patchPaths(input.command)) {
206
+ const refusal = writeRefusal(path, context);
207
+ if (refusal !== null) return refusal;
208
+ }
209
+ return null;
210
+ }
182
211
  const pathValue = tool === 'Grep' ? input.path : (input.file_path ?? input.notebook_path);
183
212
  if (typeof pathValue !== 'string' || pathValue.length === 0) return null;
184
- if (tool !== 'Read' && tool !== 'Grep' && isPreferencesPath(pathValue, context))
185
- return message(context, 'extensions');
186
- const rule = secretRule(pathValue);
213
+ if (tool === 'Read' || tool === 'Grep') {
214
+ const rule = secretRule(pathValue);
215
+ return rule === null ? null : message(context, 'secret', rule);
216
+ }
217
+ return writeRefusal(pathValue, context);
218
+ }
219
+
220
+ /** A write to a protected file or a secret file. @param {string} path @param {GuardContext} context */
221
+ function writeRefusal(path, context) {
222
+ const refusal = protectedRefusal(path, context);
223
+ if (refusal !== null) return refusal;
224
+ const rule = secretRule(path);
187
225
  return rule === null ? null : message(context, 'secret', rule);
188
226
  }
189
227
 
190
- /** @param {string} text @param {GuardContext} context */
191
- function mentionsPreferences(text, context) {
192
- return text.includes(resolve(context.prefsPath)) || text.includes('.aibrain/extensions.json');
228
+ /**
229
+ * The FR2.2 refusal for text that names a protected file anywhere in it.
230
+ * @param {string} text @param {GuardContext} context
231
+ * @returns {string | null}
232
+ */
233
+ function mentionsProtected(text, context) {
234
+ if (text.includes(resolve(context.prefsPath)) || text.includes('.aibrain/extensions.json'))
235
+ return message(context, 'extensions');
236
+ if (
237
+ CODEX_CONFIG_MENTION.test(text) ||
238
+ (context.codexConfigs ?? []).some((config) => text.includes(resolve(config)))
239
+ )
240
+ return message(context, 'codexConfig');
241
+ return null;
193
242
  }
194
243
 
195
244
  /** Contents of `$(...)` and backtick substitutions in a word. @param {string} word */
@@ -209,10 +258,12 @@ function substitutions(word) {
209
258
  * @returns {string | null}
210
259
  */
211
260
  function wordRefusal(word, context, depth) {
212
- if (mentionsPreferences(word, context)) return message(context, 'extensions');
261
+ const mention = mentionsProtected(word, context);
262
+ if (mention !== null) return mention;
213
263
  if (!/\s/u.test(word)) {
214
264
  for (const candidate of pathCandidates(word)) {
215
- if (isPreferencesPath(candidate, context)) return message(context, 'extensions');
265
+ const refusal = protectedRefusal(candidate, context);
266
+ if (refusal !== null) return refusal;
216
267
  const rule = secretRule(candidate);
217
268
  if (rule !== null) return message(context, 'secret', rule);
218
269
  }
@@ -256,7 +307,8 @@ function secretsInCommand(command, context, depth = 0) {
256
307
  if (command.length === 0) return null;
257
308
  const parsed = splitShell(command);
258
309
  if (!parsed.ok) {
259
- if (mentionsPreferences(command, context)) return message(context, 'extensions');
310
+ const mention = mentionsProtected(command, context);
311
+ if (mention !== null) return mention;
260
312
  for (const name of command.split(NAME_DELIMITERS)) {
261
313
  const rule = name.length > 0 ? secretRule(name, { extensions: false }) : null;
262
314
  if (rule !== null) return message(context, 'secret', rule);
@@ -0,0 +1,33 @@
1
+ // @ts-check
2
+ /**
3
+ * Paths a Codex patch touches (spec aisac-engineering-extensions FR6, E6b).
4
+ * Codex sends an edit as `tool_name: "apply_patch"` with the whole patch text in
5
+ * `tool_input.command` and no `file_path` (measured on Codex 0.166.0), so the
6
+ * guardrails read the file names from the patch headers. Names only: the patch
7
+ * body is never interpreted.
8
+ */
9
+
10
+ export const PATCH_TOOL = 'apply_patch';
11
+
12
+ // `*** Add File: <path>`, `*** Update File: <path>`, `*** Delete File: <path>`
13
+ // and `*** Move to: <path>` (the new name of an updated file).
14
+ const HEADER = /^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/u;
15
+
16
+ /**
17
+ * The paths named in a patch, in order and without repeats. Anything that is
18
+ * not a string, or has no header, names no path. There is no cap: the hook
19
+ * input is already bounded (1 MiB), and a cap would let a patch hide a secret
20
+ * file after the limit.
21
+ * @param {unknown} patch
22
+ * @returns {string[]}
23
+ */
24
+ export function patchPaths(patch) {
25
+ if (typeof patch !== 'string' || patch.length === 0) return [];
26
+ /** @type {Set<string>} */
27
+ const paths = new Set();
28
+ for (const line of patch.split(/\r?\n/u)) {
29
+ const path = HEADER.exec(line)?.[1]?.trim();
30
+ if (path !== undefined && path.length > 0) paths.add(path);
31
+ }
32
+ return [...paths];
33
+ }
@@ -1,20 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
3
  /**
4
- * Guardrail dispatcher (spec aisac-engineering-extensions FR2, FR5): the
5
- * command the session plugin `aisac-guardrails` runs for each host event. One
6
- * process per event runs every enabled hook bound to it, so a tool call pays
7
- * for one Node start. Arguments come from the launcher; the event payload
8
- * comes from the host on stdin and is untrusted.
4
+ * Guardrail dispatcher (spec aisac-engineering-extensions FR2, FR5, FR6): the
5
+ * command the hosts run for each hook event.
6
+ * - Claude Code: the session plugin `aisac-guardrails` registers one command per
7
+ * event, and that one process runs every enabled hook bound to it, so a tool
8
+ * call pays for one Node start. The launcher passes the state folder, the
9
+ * preferences file and the locale as arguments.
10
+ * - Codex (`--client codex`, E6b): one command per hook and event, byte-stable so
11
+ * the user's review in `/hooks` holds. The enabled set, the locale, the state
12
+ * folder and the preferences file come from GUARDRAILS_SESSION_ENV, which the
13
+ * monitor put in the app-server's environment; without it the hook does
14
+ * nothing. The project is the payload's `cwd`, never an inherited
15
+ * CLAUDE_PROJECT_DIR, and a patch (`apply_patch`) is an edit of every file its
16
+ * headers name.
17
+ * The event payload comes from the host on stdin and is untrusted.
9
18
  *
10
19
  * Exit 0 always, except exit 2 for a PreToolUse refusal (reason on stderr).
11
20
  * Any error lets the tool call run: a broken guard must not stop all work.
12
21
  */
13
22
  import { homedir } from 'node:os';
14
- import { isAbsolute } from 'node:path';
23
+ import { isAbsolute, join, resolve } from 'node:path';
15
24
  import { pathToFileURL } from 'node:url';
25
+ import { GUARDRAILS_SESSION_ENV, parseGuardrailsSession } from '../codex-hooks.mjs';
16
26
  import { hookById } from '../extensions-catalog.mjs';
17
27
  import { GUARDS } from './guards.mjs';
28
+ import { PATCH_TOOL, patchPaths } from './patch.mjs';
18
29
  import {
19
30
  continuityReminder,
20
31
  docsReminder,
@@ -36,9 +47,18 @@ const EVENTS = new Set([
36
47
  'Stop',
37
48
  ]);
38
49
  const REFUSAL_EXIT_CODE = 2;
50
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
51
+ const ARGUMENT_NAMES = ['--client', '--event', '--hooks', '--state', '--prefs', '--locale'];
52
+ // Codex matches a patch with the edit tool names too (`Edit|Write` fires for
53
+ // `apply_patch`, measured on 0.166.0), so the catalog's Claude matchers bind it.
54
+ /** @type {Readonly<Record<string, readonly string[]>>} */
55
+ const TOOL_ALIASES = Object.freeze({
56
+ [PATCH_TOOL]: Object.freeze([PATCH_TOOL, 'Edit', 'Write']),
57
+ });
39
58
 
40
59
  /**
41
60
  * @typedef {object} RunOptions
61
+ * @property {'claude' | 'codex'} client
42
62
  * @property {import('../extensions-catalog.mjs').HookEvent} event
43
63
  * @property {string[]} hooks
44
64
  * @property {string} stateDir
@@ -46,41 +66,77 @@ const REFUSAL_EXIT_CODE = 2;
46
66
  * @property {'pt-BR' | 'en'} locale
47
67
  */
48
68
 
69
+ /** Catalog ids bound to the event. @param {string[]} ids @param {string} event */
70
+ function boundIds(ids, event) {
71
+ return ids.filter((id) => {
72
+ const entry = hookById(id);
73
+ return entry !== undefined && entry.bindings.some((binding) => binding.event === event);
74
+ });
75
+ }
76
+
49
77
  /**
50
- * Strict launcher arguments; null when anything is off.
78
+ * Strict launcher arguments; null when anything is off. In Codex the enabled
79
+ * set and the paths come from the session value in the environment, and a hook
80
+ * the session did not enable is dropped.
51
81
  * @param {string[]} argv
82
+ * @param {NodeJS.ProcessEnv} [env]
52
83
  * @returns {RunOptions | null}
53
84
  */
54
- export function parseArguments(argv) {
85
+ export function parseArguments(argv, env = process.env) {
55
86
  /** @type {Record<string, string>} */
56
87
  const values = {};
57
88
  for (let index = 0; index < argv.length; index += 2) {
58
89
  const name = argv[index] ?? '';
59
90
  const value = argv[index + 1];
60
- if (
61
- !['--event', '--hooks', '--state', '--prefs', '--locale'].includes(name) ||
62
- value === undefined
63
- )
91
+ if (!ARGUMENT_NAMES.includes(name) || value === undefined || Object.hasOwn(values, name))
64
92
  return null;
65
93
  values[name] = value;
66
94
  }
67
95
  const event = values['--event'] ?? '';
96
+ if (!EVENTS.has(event)) return null;
97
+ const typedEvent = /** @type {import('../extensions-catalog.mjs').HookEvent} */ (event);
98
+ const requested = (values['--hooks'] ?? '').split(',');
99
+ if (values['--client'] === 'codex') {
100
+ if (['--state', '--prefs', '--locale'].some((name) => Object.hasOwn(values, name))) return null;
101
+ const session = parseGuardrailsSession(env[GUARDRAILS_SESSION_ENV]);
102
+ if (session === null) return null;
103
+ return {
104
+ client: 'codex',
105
+ event: typedEvent,
106
+ hooks: boundIds(requested, event).filter((id) => session.hooks.includes(id)),
107
+ stateDir: session.stateDir,
108
+ prefsPath: session.prefsPath,
109
+ locale: session.locale,
110
+ };
111
+ }
112
+ if (values['--client'] !== undefined && values['--client'] !== 'claude') return null;
68
113
  const stateDir = values['--state'] ?? '';
69
114
  const prefsPath = values['--prefs'] ?? '';
70
- if (!EVENTS.has(event) || !isAbsolute(stateDir) || !isAbsolute(prefsPath)) return null;
71
- const hooks = (values['--hooks'] ?? '').split(',').filter((id) => {
72
- const entry = hookById(id);
73
- return entry !== undefined && entry.bindings.some((binding) => binding.event === event);
74
- });
115
+ if (!isAbsolute(stateDir) || !isAbsolute(prefsPath)) return null;
75
116
  return {
76
- event: /** @type {import('../extensions-catalog.mjs').HookEvent} */ (event),
77
- hooks,
117
+ client: 'claude',
118
+ event: typedEvent,
119
+ hooks: boundIds(requested, event),
78
120
  stateDir,
79
121
  prefsPath,
80
122
  locale: values['--locale'] === 'pt-BR' ? 'pt-BR' : 'en',
81
123
  };
82
124
  }
83
125
 
126
+ /**
127
+ * Codex's user config files: `~/.codex/config.toml`, and `$CODEX_HOME/config.toml`
128
+ * when CODEX_HOME is set. They hold the off switch of every Codex hook, so
129
+ * `secrets-guard` keeps the agent out of them (FR2.2, E6b).
130
+ * @param {NodeJS.ProcessEnv} env @param {string} home
131
+ */
132
+ export function codexConfigPaths(env, home) {
133
+ const paths = [join(home, '.codex', 'config.toml')];
134
+ const codexHome = env.CODEX_HOME;
135
+ if (typeof codexHome === 'string' && isAbsolute(codexHome) && !CONTROL_CHARACTERS.test(codexHome))
136
+ paths.push(join(resolve(codexHome), 'config.toml'));
137
+ return [...new Set(paths)];
138
+ }
139
+
84
140
  /** @returns {Promise<string | null>} */
85
141
  async function readInput() {
86
142
  /** @type {Buffer[]} */
@@ -97,13 +153,24 @@ async function readInput() {
97
153
 
98
154
  /** Does a hook's binding for this event accept the tool or source? @param {string} id @param {string} event @param {string} subject */
99
155
  function bound(id, event, subject) {
156
+ const names = TOOL_ALIASES[subject] ?? [subject];
100
157
  return (hookById(id)?.bindings ?? []).some(
101
158
  (binding) =>
102
159
  binding.event === event &&
103
- (binding.matcher === undefined || binding.matcher.split('|').includes(subject)),
160
+ (binding.matcher === undefined ||
161
+ binding.matcher.split('|').some((name) => names.includes(name))),
104
162
  );
105
163
  }
106
164
 
165
+ /**
166
+ * The files a tool call edits: `file_path`, or every path a Codex patch names.
167
+ * @param {string} tool @param {Record<string, unknown>} toolInput
168
+ * @returns {unknown[]}
169
+ */
170
+ function editedFiles(tool, toolInput) {
171
+ return tool === PATCH_TOOL ? patchPaths(toolInput.command) : [toolInput.file_path];
172
+ }
173
+
107
174
  /**
108
175
  * Run the enabled hooks for one event.
109
176
  * @param {RunOptions} options
@@ -111,18 +178,22 @@ function bound(id, event, subject) {
111
178
  * @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
112
179
  */
113
180
  export async function dispatch(options, input) {
114
- const envProject = process.env.CLAUDE_PROJECT_DIR;
181
+ // Codex adds no project variable: one in its environment was inherited from the
182
+ // user's shell (for example a Claude Code terminal), so Codex uses the payload cwd.
183
+ const envProject = options.client === 'codex' ? undefined : process.env.CLAUDE_PROJECT_DIR;
115
184
  const projectDir =
116
185
  typeof envProject === 'string' && isAbsolute(envProject)
117
186
  ? envProject
118
187
  : typeof input.cwd === 'string' && isAbsolute(input.cwd)
119
188
  ? input.cwd
120
189
  : process.cwd();
190
+ const home = homedir();
121
191
  const context = {
122
192
  projectDir,
123
193
  stateDir: options.stateDir,
124
194
  prefsPath: options.prefsPath,
125
- home: homedir(),
195
+ codexConfigs: codexConfigPaths(process.env, home),
196
+ home,
126
197
  locale: options.locale,
127
198
  };
128
199
  const enabled = new Set(options.hooks);
@@ -143,15 +214,19 @@ export async function dispatch(options, input) {
143
214
  return quiet;
144
215
  }
145
216
  if (options.event === 'PostToolUse') {
146
- const filePath = toolInput.file_path;
147
- if (enabled.has('format-on-edit') && bound('format-on-edit', 'PostToolUse', tool))
148
- await formatEdited(projectDir, filePath);
149
- if (enabled.has('continuity-reminder') && bound('continuity-reminder', 'PostToolUse', tool))
150
- await recordEdit(context, input.session_id, filePath);
151
- const reminder =
152
- enabled.has('docs-reminder') && bound('docs-reminder', 'PostToolUse', tool)
153
- ? await docsReminder(context, input.session_id, filePath)
154
- : '';
217
+ let reminder = '';
218
+ for (const filePath of editedFiles(tool, toolInput)) {
219
+ if (enabled.has('format-on-edit') && bound('format-on-edit', 'PostToolUse', tool))
220
+ await formatEdited(projectDir, filePath);
221
+ if (enabled.has('continuity-reminder') && bound('continuity-reminder', 'PostToolUse', tool))
222
+ await recordEdit(context, input.session_id, filePath);
223
+ if (
224
+ reminder.length === 0 &&
225
+ enabled.has('docs-reminder') &&
226
+ bound('docs-reminder', 'PostToolUse', tool)
227
+ )
228
+ reminder = await docsReminder(context, input.session_id, filePath);
229
+ }
155
230
  return reminder.length === 0
156
231
  ? quiet
157
232
  : {