@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.
- package/README.md +6 -0
- package/dist/claude-observation.mjs +1 -1
- package/dist/cli.mjs +53 -6
- package/dist/codex-hooks.mjs +411 -0
- package/dist/codex-permissions.mjs +67 -34
- package/dist/extensions-catalog.mjs +26 -8
- package/dist/extensions-cli.mjs +2 -2
- package/dist/guardrails/guards.mjs +64 -12
- package/dist/guardrails/patch.mjs +33 -0
- package/dist/guardrails/run.mjs +106 -31
- package/dist/guardrails-plugin.mjs +59 -1
- package/dist/launch.mjs +22 -1
- package/dist/monitor.mjs +43 -1
- package/dist/package.json +1 -1
- package/package.json +1 -1
|
@@ -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);
|
package/dist/extensions-cli.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
/**
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/guardrails/run.mjs
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 (!
|
|
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
|
-
|
|
77
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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;
|