@brunobracaioli/aibrain 0.4.0-beta.12 → 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.
@@ -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
  : {
@@ -6,11 +6,18 @@
6
6
  * Node executable plus the dispatcher inside the installed package; nothing
7
7
  * comes from the server (ADR-0048).
8
8
  */
9
- import { access, mkdir, writeFile } from 'node:fs/promises';
9
+ import { access, mkdir, realpath, writeFile } from 'node:fs/promises';
10
10
  import { isAbsolute, join } from 'node:path';
11
11
  import { hookById } from './extensions-catalog.mjs';
12
12
  import { enabledHookIds, preferencesPath, readPreferences } from './extensions-preferences.mjs';
13
13
  import { readStoredLocale, resolvePackLocale } from './engineering-pack.mjs';
14
+ import {
15
+ codexHookConfigArgs,
16
+ codexHooksLine,
17
+ codexHooksValue,
18
+ summarizeCodexHooks,
19
+ } from './codex-hooks.mjs';
20
+ import { readCodexHookList } from './codex-permissions.mjs';
14
21
 
15
22
  export const GUARDRAILS_PLUGIN_NAME = 'aisac-guardrails';
16
23
  const PLUGIN_VERSION = '1.0.0';
@@ -147,3 +154,54 @@ export async function prepareGuardrails(input) {
147
154
  line: `AISAC Engineering: ${enabled.length} guardrail${enabled.length === 1 ? '' : 's'} on (aisac hooks to change them).`,
148
155
  };
149
156
  }
157
+
158
+ /**
159
+ * What Codex reports for the AISAC hooks the user has on, read with Codex's own
160
+ * `hooks/list` for the exact flags the monitor passes (spec FR6, E6b). Null when
161
+ * no Codex hook is enabled or the package lacks the dispatcher; `summary` is null
162
+ * when the listing failed.
163
+ * @param {{ root: string, packageRoot: string, executable: string, cwd: string, profile: string | undefined, environment: NodeJS.ProcessEnv }} input
164
+ * @param {{ listHooks?: typeof readCodexHookList }} [dependencies]
165
+ * @returns {Promise<{ enabled: string[], summary: import('./codex-hooks.mjs').CodexHooksSummary | null } | null>}
166
+ */
167
+ export async function readCodexGuardrails(input, dependencies = {}) {
168
+ const listHooks = dependencies.listHooks ?? readCodexHookList;
169
+ const enabled = enabledHookIds(await readPreferences(input.root)).filter(
170
+ (id) => hookById(id)?.clients.includes('codex') === true,
171
+ );
172
+ if (enabled.length === 0) return null;
173
+ // The monitor resolves the same file from its own folder; both use the real path.
174
+ const dispatcher = await realpath(join(input.packageRoot, DISPATCHER)).catch(() => null);
175
+ if (dispatcher === null || !safeAbsolute(dispatcher) || !safeAbsolute(process.execPath))
176
+ return null;
177
+ const paths = { node: process.execPath, dispatcher };
178
+ const summary = await listHooks({
179
+ executable: input.executable,
180
+ cwd: input.cwd,
181
+ profile: input.profile,
182
+ environment: input.environment,
183
+ configArgs: codexHookConfigArgs(paths),
184
+ }).then(
185
+ (listed) => summarizeCodexHooks(listed, paths, enabled),
186
+ () => null,
187
+ );
188
+ return { enabled, summary };
189
+ }
190
+
191
+ /**
192
+ * The guardrails for a licensed Codex launch: the value the launcher hands the
193
+ * monitor, and the launch line. The line comes from `hooks/list`, so a guardrail
194
+ * that waits for the user's review in `/hooks` is never called on.
195
+ * @param {{ root: string, packageRoot: string, executable: string, cwd: string, profile: string | undefined, environment: NodeJS.ProcessEnv }} input
196
+ * @param {{ listHooks?: typeof readCodexHookList }} [dependencies]
197
+ * @returns {Promise<{ value: string, line: string } | null>}
198
+ */
199
+ export async function prepareCodexGuardrails(input, dependencies = {}) {
200
+ const read = await readCodexGuardrails(input, dependencies);
201
+ if (read === null) return null;
202
+ const locale = resolvePackLocale(await readStoredLocale(input.root));
203
+ return {
204
+ value: codexHooksValue(read.enabled, locale),
205
+ line: codexHooksLine(read.enabled.length, read.summary),
206
+ };
207
+ }
package/dist/launch.mjs CHANGED
@@ -18,7 +18,8 @@ import {
18
18
  sweepStaleLaunches,
19
19
  writeLaunchMarker,
20
20
  } from './engineering-pack.mjs';
21
- import { prepareGuardrails } from './guardrails-plugin.mjs';
21
+ import { prepareCodexGuardrails, prepareGuardrails } from './guardrails-plugin.mjs';
22
+ import { CODEX_HOOKS_ENV, GUARDRAILS_SESSION_ENV, supportsCodexHooks } from './codex-hooks.mjs';
22
23
  import {
23
24
  CODEX_MCP_ENV,
24
25
  claudeDenyRules,
@@ -149,6 +150,8 @@ function childEnvironment(root) {
149
150
  delete environment[CODEX_ENGINEERING_ENV];
150
151
  delete environment[CODEX_ENGINEERING_SKILLS_ROOT_ENV];
151
152
  delete environment[CODEX_MCP_ENV];
153
+ delete environment[CODEX_HOOKS_ENV];
154
+ delete environment[GUARDRAILS_SESSION_ENV];
152
155
  environment.AIBRAIN_STATE_DIR = resolve(root);
153
156
  return environment;
154
157
  }
@@ -451,6 +454,7 @@ export async function readSessionServers(root, client, environment) {
451
454
  * @property {typeof prepareEngineeringPack} [preparePack] AISAC Engineering pack loader (E4b)
452
455
  * @property {typeof prepareGuardrails} [prepareGuardrails] Guardrail hooks plugin writer (E6a)
453
456
  * @property {typeof readSessionServers} [readSessionServers] MCP catalog servers to load (E6c)
457
+ * @property {typeof prepareCodexGuardrails} [prepareCodexGuardrails] Codex guardrail hooks (E6b)
454
458
  */
455
459
 
456
460
  /**
@@ -675,6 +679,7 @@ export async function launchClaude(config, root, args, dependencies = {}) {
675
679
  export async function launchCodex(config, root, args, dependencies = {}) {
676
680
  const preparePack = dependencies.preparePack ?? prepareEngineeringPack;
677
681
  const readServers = dependencies.readSessionServers ?? readSessionServers;
682
+ const writeCodexGuardrails = dependencies.prepareCodexGuardrails ?? prepareCodexGuardrails;
678
683
  const validated = assertConfig(config);
679
684
  assertCodexArgs(args);
680
685
  const captureLockPlatform = captureLockPlatformCheck();
@@ -744,6 +749,22 @@ export async function launchCodex(config, root, args, dependencies = {}) {
744
749
  process.stderr.write(`${line}\n`);
745
750
  if (servers !== null && servers.ready.length > 0)
746
751
  environment[CODEX_MCP_ENV] = JSON.stringify(servers.ready.map((entry) => entry.id));
752
+ // Guardrail hooks need the same license and a Codex measured with them
753
+ // (0.166.0+); like the pack they never block the launch (spec extensions FR6).
754
+ const guardrails = supportsCodexHooks(compatibility.version)
755
+ ? await writeCodexGuardrails({
756
+ root,
757
+ packageRoot,
758
+ executable,
759
+ cwd: launchPlan.effectiveCwd,
760
+ profile: launchPlan.profile,
761
+ environment,
762
+ }).catch(() => null)
763
+ : null;
764
+ if (guardrails !== null) {
765
+ environment[CODEX_HOOKS_ENV] = guardrails.value;
766
+ process.stderr.write(`${guardrails.line}\n`);
767
+ }
747
768
  }
748
769
  environment.AI_BRAIN_MCP_SERVER = MCP_ALIAS;
749
770
  environment.CODEX_BIN = executable;