@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.
- package/README.md +10 -3
- package/dist/claude-observation.mjs +1 -1
- package/dist/cli.mjs +93 -14
- 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
|
@@ -499,15 +499,22 @@ async function request(child, id, method, params) {
|
|
|
499
499
|
return message.result;
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
-
/**
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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);
|
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
|
: {
|