@brunobracaioli/aibrain 0.4.0-beta.2 → 0.4.0-beta.4

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.
@@ -0,0 +1,397 @@
1
+ // @ts-check
2
+ /**
3
+ * Block guardrails for PreToolUse (spec aisac-engineering-extensions FR2,
4
+ * FR2.1, FR2.2). Each guard is a pure decision from the tool name and input;
5
+ * it returns a refusal reason or null. Guards decide from names only and
6
+ * never open the files they protect. They lower risk; they are not a sandbox.
7
+ */
8
+ import { execFileSync } from 'node:child_process';
9
+ import { isAbsolute, resolve } from 'node:path';
10
+ import { effectiveWords, programName, splitShell } from './shell-words.mjs';
11
+
12
+ /**
13
+ * @typedef {object} GuardContext
14
+ * @property {string} projectDir
15
+ * @property {string} home
16
+ * @property {string} prefsPath The extensions preferences file (FR2.2).
17
+ * @property {'pt-BR' | 'en'} locale
18
+ */
19
+ /** @typedef {{ tool_name?: unknown, tool_input?: unknown, cwd?: unknown }} ToolEvent */
20
+
21
+ const TEMPLATE_SUFFIXES = new Set(['example', 'sample', 'template']);
22
+ const SECRET_DIRECTORIES = new Set(['.ssh', '.aws', '.gnupg']);
23
+ const SECRET_NAMES = new Set(['.envrc', '.npmrc', '.netrc', '.pgpass', 'credentials.json']);
24
+ const PRIVATE_KEY_NAME = /^id_(?:rsa|dsa|ecdsa|ed25519)(?:[._-].*)?$/u;
25
+ const CERTIFICATE_EXTENSION = /\.(?:pem|p12|pfx)$/iu;
26
+ const KEY_EXTENSION = /\.key$/iu;
27
+ const SERVICE_ACCOUNT = /^service-account.*\.json$/iu;
28
+ // Splits a word into the names it mentions (`open('.env')` -> `open`, `.env`),
29
+ // so `process.env.HOME` stays one name and passes.
30
+ const NAME_DELIMITERS = /[\s'"=<>:(),;|&`$]+/u;
31
+ // A quoted name inside a word that contains spaces (`python -c "open('.env')"`).
32
+ const QUOTED_NAME = /['"]([^'"\s]+)['"]/gu;
33
+ const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
34
+ const SEARCH_PROGRAMS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack']);
35
+ const MAX_NESTING = 2;
36
+ const AISAC_PROGRAMS = new Set(['aisac', 'aibrain']);
37
+ const AISAC_PACKAGE = /^@(?:aisac\/cli|brunobracaioli\/aibrain)(?:@\S*)?$/u;
38
+ const EXTENSION_COMMANDS = new Set(['hooks', 'mcp']);
39
+ const EXTENSION_CHANGES = new Set(['enable', 'disable', 'reset']);
40
+ const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace']);
41
+ const PROTECTED_BRANCHES = new Set(['main', 'master']);
42
+ const GIT_TIMEOUT_MS = 2_000;
43
+ const DANGEROUS_RM_TARGETS = new Set([
44
+ '/',
45
+ '/*',
46
+ '~',
47
+ '~/',
48
+ '~/*',
49
+ '.',
50
+ './',
51
+ './*',
52
+ '..',
53
+ '../',
54
+ '*',
55
+ '$HOME',
56
+ '$HOME/',
57
+ '$HOME/*',
58
+ '${HOME}',
59
+ '${HOME}/',
60
+ ]);
61
+ const DATABASE_CLIENTS = new Set([
62
+ 'psql',
63
+ 'mysql',
64
+ 'mariadb',
65
+ 'sqlite3',
66
+ 'sqlcmd',
67
+ 'clickhouse-client',
68
+ 'cockroach',
69
+ 'duckdb',
70
+ ]);
71
+ const DESTRUCTIVE_SQL =
72
+ /\b(?:DROP\s+(?:DATABASE|TABLE|SCHEMA)|TRUNCATE(?:\s+TABLE)?\s+[A-Za-z_"`])/iu;
73
+
74
+ const MESSAGES = {
75
+ en: {
76
+ secret: 'refused: this touches a secret file (rule: %s). Ask the user to do it themselves.',
77
+ extensions:
78
+ 'refused: AISAC extensions are changed only by the user, from their own terminal (aisac hooks / aisac mcp).',
79
+ pushProtected: 'refused: push to %s. Open a pull request instead.',
80
+ pushForce:
81
+ 'refused: force push. Use --force-with-lease on your own branch if it is really needed.',
82
+ rm: 'refused: rm -rf on %s.',
83
+ sql: 'refused: destructive SQL (DROP or TRUNCATE) sent to a database client.',
84
+ },
85
+ 'pt-BR': {
86
+ secret:
87
+ 'recusado: isto mexe num arquivo de segredo (regra: %s). Peça ao usuário para fazer isso.',
88
+ extensions:
89
+ 'recusado: as extensões do AISAC só são mudadas pelo usuário, no terminal dele (aisac hooks / aisac mcp).',
90
+ pushProtected: 'recusado: push na %s. Abra um pull request.',
91
+ pushForce:
92
+ 'recusado: push forçado. Use --force-with-lease na sua branch, se for mesmo preciso.',
93
+ rm: 'recusado: rm -rf em %s.',
94
+ sql: 'recusado: SQL destrutivo (DROP ou TRUNCATE) enviado a um cliente de banco.',
95
+ },
96
+ };
97
+
98
+ /** @param {GuardContext} context @param {keyof typeof MESSAGES.en} key @param {string} [value] */
99
+ function message(context, key, value = '') {
100
+ return (MESSAGES[context.locale] ?? MESSAGES.en)[key].replace('%s', value);
101
+ }
102
+
103
+ /** @param {unknown} value @returns {Record<string, unknown>} */
104
+ function record(value) {
105
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
106
+ ? /** @type {Record<string, unknown>} */ (value)
107
+ : {};
108
+ }
109
+
110
+ /**
111
+ * The secret rule a path matches, or null. Names only. With `extensions`
112
+ * false, only dot names, key names and private directories count: used for
113
+ * names found inside code, where `obj.key` is not a file.
114
+ * @param {string} path
115
+ * @param {{ extensions?: boolean }} [options]
116
+ * @returns {string | null}
117
+ */
118
+ export function secretRule(path, options = {}) {
119
+ const segments = path.split(/[/\\]/u).filter((segment) => segment.length > 0);
120
+ if (segments.some((segment) => SECRET_DIRECTORIES.has(segment))) return 'private directory';
121
+ const name = segments.at(-1) ?? '';
122
+ if (name === '.env') return '.env';
123
+ if (name.startsWith('.env.')) {
124
+ const suffix = name.slice('.env.'.length);
125
+ return TEMPLATE_SUFFIXES.has(suffix) ? null : '.env.*';
126
+ }
127
+ if (/^\.env[*?[]/u.test(name)) return '.env*';
128
+ if (SECRET_NAMES.has(name)) return name;
129
+ if (PRIVATE_KEY_NAME.test(name) && !name.endsWith('.pub')) return 'private key';
130
+ if (options.extensions === false) return null;
131
+ if (CERTIFICATE_EXTENSION.test(name)) return 'key or certificate file';
132
+ if (KEY_EXTENSION.test(name) && !name.startsWith('.')) return 'key file';
133
+ if (SERVICE_ACCOUNT.test(name)) return 'service account key';
134
+ return null;
135
+ }
136
+
137
+ /** Resolve `~`, `$HOME` and relative paths for comparison. @param {string} path @param {GuardContext} context */
138
+ function resolvePath(path, context) {
139
+ const expanded = path
140
+ .replace(/^~(?=\/|$)/u, context.home)
141
+ .replace(/^\$\{?HOME\}?(?=\/|$)/u, context.home);
142
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(context.projectDir, expanded);
143
+ }
144
+
145
+ /** @param {string} path @param {GuardContext} context */
146
+ function isPreferencesPath(path, context) {
147
+ return path.length > 0 && resolvePath(path, context) === resolve(context.prefsPath);
148
+ }
149
+
150
+ /** Path-like parts of a word: the word itself and a `--flag=value` / `VAR=value` value. @param {string} word */
151
+ function pathCandidates(word) {
152
+ const equals = word.lastIndexOf('=');
153
+ return equals === -1 ? [word] : [word, word.slice(equals + 1)];
154
+ }
155
+
156
+ /** @param {string[]} words */
157
+ function changesExtensions(words) {
158
+ const effective = effectiveWords(words);
159
+ const program = programName(effective[0] ?? '');
160
+ const aisacEntry =
161
+ AISAC_PROGRAMS.has(program) ||
162
+ AISAC_PACKAGE.test(effective[0] ?? '') ||
163
+ (program === 'node' && /(?:@aisac\/cli|aibrain)[\\/].*cli\.mjs$/u.test(effective[1] ?? ''));
164
+ if (!aisacEntry) return false;
165
+ const rest = effective.slice(program === 'node' ? 2 : 1);
166
+ const commandIndex = rest.findIndex((word) => EXTENSION_COMMANDS.has(word));
167
+ return (
168
+ commandIndex !== -1 && rest.slice(commandIndex + 1).some((word) => EXTENSION_CHANGES.has(word))
169
+ );
170
+ }
171
+
172
+ /**
173
+ * `secrets-guard` (FR2.1) with the FR2.2 self-protection.
174
+ * @param {ToolEvent} event @param {GuardContext} context
175
+ * @returns {string | null}
176
+ */
177
+ export function secretsGuard(event, context) {
178
+ const tool = typeof event.tool_name === 'string' ? event.tool_name : '';
179
+ const input = record(event.tool_input);
180
+ if (tool === 'Bash')
181
+ return secretsInCommand(typeof input.command === 'string' ? input.command : '', context);
182
+ const pathValue = tool === 'Grep' ? input.path : (input.file_path ?? input.notebook_path);
183
+ 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);
187
+ return rule === null ? null : message(context, 'secret', rule);
188
+ }
189
+
190
+ /** @param {string} text @param {GuardContext} context */
191
+ function mentionsPreferences(text, context) {
192
+ return text.includes(resolve(context.prefsPath)) || text.includes('.aibrain/extensions.json');
193
+ }
194
+
195
+ /** Contents of `$(...)` and backtick substitutions in a word. @param {string} word */
196
+ function substitutions(word) {
197
+ /** @type {string[]} */
198
+ const found = [];
199
+ for (const match of word.matchAll(/\$\(([^()]*(?:\([^()]*\)[^()]*)*)\)|`([^`]*)`/gu))
200
+ found.push(match[1] ?? match[2] ?? '');
201
+ return found;
202
+ }
203
+
204
+ /**
205
+ * A word's refusal. A word without spaces is a path candidate and a list of
206
+ * names; a word with spaces (a quoted script or message) counts only for
207
+ * quoted names inside it, so `git commit -m "ignore .env files"` passes.
208
+ * @param {string} word @param {GuardContext} context @param {number} depth
209
+ * @returns {string | null}
210
+ */
211
+ function wordRefusal(word, context, depth) {
212
+ if (mentionsPreferences(word, context)) return message(context, 'extensions');
213
+ if (!/\s/u.test(word)) {
214
+ for (const candidate of pathCandidates(word)) {
215
+ if (isPreferencesPath(candidate, context)) return message(context, 'extensions');
216
+ const rule = secretRule(candidate);
217
+ if (rule !== null) return message(context, 'secret', rule);
218
+ }
219
+ for (const name of word.split(NAME_DELIMITERS)) {
220
+ const rule = name.length > 0 ? secretRule(name, { extensions: false }) : null;
221
+ if (rule !== null) return message(context, 'secret', rule);
222
+ }
223
+ } else {
224
+ for (const match of word.matchAll(QUOTED_NAME)) {
225
+ const rule = secretRule(match[1] ?? '', { extensions: false });
226
+ if (rule !== null) return message(context, 'secret', rule);
227
+ }
228
+ }
229
+ if (depth < MAX_NESTING)
230
+ for (const inner of substitutions(word)) {
231
+ const refusal = secretsInCommand(inner, context, depth + 1);
232
+ if (refusal !== null) return refusal;
233
+ }
234
+ return null;
235
+ }
236
+
237
+ /** Words that are search patterns, not files (`grep PATTERN`, `rg -e PATTERN`). @param {string[]} words */
238
+ function searchPatternIndexes(words) {
239
+ const indexes = new Set();
240
+ if (!SEARCH_PROGRAMS.has(programName(words[0] ?? ''))) return indexes;
241
+ let explicit = false;
242
+ for (let index = 1; index < words.length; index += 1)
243
+ if (words[index] === '-e' || words[index] === '--regexp') {
244
+ indexes.add(index + 1);
245
+ explicit = true;
246
+ }
247
+ if (!explicit) {
248
+ const first = words.findIndex((word, index) => index > 0 && !word.startsWith('-'));
249
+ if (first !== -1) indexes.add(first);
250
+ }
251
+ return indexes;
252
+ }
253
+
254
+ /** @param {string} command @param {GuardContext} context @param {number} [depth] @returns {string | null} */
255
+ function secretsInCommand(command, context, depth = 0) {
256
+ if (command.length === 0) return null;
257
+ const parsed = splitShell(command);
258
+ if (!parsed.ok) {
259
+ if (mentionsPreferences(command, context)) return message(context, 'extensions');
260
+ for (const name of command.split(NAME_DELIMITERS)) {
261
+ const rule = name.length > 0 ? secretRule(name, { extensions: false }) : null;
262
+ if (rule !== null) return message(context, 'secret', rule);
263
+ }
264
+ return null;
265
+ }
266
+ for (const item of parsed.commands) {
267
+ if (changesExtensions(item.words)) return message(context, 'extensions');
268
+ const words = effectiveWords(item.words);
269
+ const program = programName(words[0] ?? '');
270
+ const patterns = searchPatternIndexes(words);
271
+ const prefix = item.words.slice(0, item.words.length - words.length);
272
+ for (const [index, word] of [...prefix, ...words].entries()) {
273
+ if (patterns.has(index - prefix.length)) continue;
274
+ const refusal = wordRefusal(word, context, depth);
275
+ if (refusal !== null) return refusal;
276
+ }
277
+ for (const target of item.targets) {
278
+ const refusal = wordRefusal(target, context, depth);
279
+ if (refusal !== null) return refusal;
280
+ }
281
+ // `bash -c "cat .env"` and `eval "..."` carry a whole command in one word.
282
+ const script =
283
+ SHELLS.has(program) && words.includes('-c')
284
+ ? words[words.indexOf('-c') + 1]
285
+ : program === 'eval'
286
+ ? words.slice(1).join(' ')
287
+ : undefined;
288
+ if (script !== undefined && depth < MAX_NESTING) {
289
+ const refusal = secretsInCommand(script, context, depth + 1);
290
+ if (refusal !== null) return refusal;
291
+ }
292
+ }
293
+ return null;
294
+ }
295
+
296
+ /** Destination branch of a push refspec (`HEAD:main`, `+main`, `refs/heads/main`). @param {string} refspec */
297
+ function refspecDestination(refspec) {
298
+ const unforced = refspec.startsWith('+') ? refspec.slice(1) : refspec;
299
+ const colon = unforced.lastIndexOf(':');
300
+ const destination = colon === -1 ? unforced : unforced.slice(colon + 1);
301
+ return destination.replace(/^refs\/heads\//u, '');
302
+ }
303
+
304
+ /** @param {string} directory */
305
+ function currentBranch(directory) {
306
+ try {
307
+ return execFileSync('git', ['-C', directory, 'symbolic-ref', '--short', 'HEAD'], {
308
+ encoding: 'utf8',
309
+ timeout: GIT_TIMEOUT_MS,
310
+ stdio: ['ignore', 'pipe', 'ignore'],
311
+ }).trim();
312
+ } catch {
313
+ return '';
314
+ }
315
+ }
316
+
317
+ /**
318
+ * `git-guard`: pushes to main or master, and force pushes.
319
+ * @param {ToolEvent} event @param {GuardContext} context
320
+ * @returns {string | null}
321
+ */
322
+ export function gitGuard(event, context) {
323
+ const command = record(event.tool_input).command;
324
+ if (event.tool_name !== 'Bash' || typeof command !== 'string') return null;
325
+ const parsed = splitShell(command);
326
+ if (!parsed.ok) return null;
327
+ for (const item of parsed.commands) {
328
+ const words = effectiveWords(item.words);
329
+ if (programName(words[0] ?? '') !== 'git') continue;
330
+ let index = 1;
331
+ let directory = context.projectDir;
332
+ while (index < words.length && (words[index] ?? '').startsWith('-')) {
333
+ const option = words[index] ?? '';
334
+ if (GIT_OPTIONS_WITH_VALUE.has(option)) {
335
+ if (option === '-C') directory = resolve(directory, words[index + 1] ?? '.');
336
+ index += 2;
337
+ } else index += 1;
338
+ }
339
+ if (words[index] !== 'push') continue;
340
+ const args = words.slice(index + 1);
341
+ const flags = args.filter((word) => word.startsWith('-'));
342
+ const positional = args.filter((word) => !word.startsWith('-'));
343
+ const forced =
344
+ flags.some(
345
+ (flag) =>
346
+ flag === '--force' ||
347
+ flag === '--mirror' ||
348
+ (/^-[A-Za-z]+$/u.test(flag) && flag.includes('f')),
349
+ ) || positional.slice(1).some((refspec) => refspec.startsWith('+'));
350
+ if (forced) return message(context, 'pushForce');
351
+ if (flags.includes('--all')) return message(context, 'pushProtected', 'main/master (--all)');
352
+ const refspecs = positional.slice(1);
353
+ const destinations =
354
+ refspecs.length > 0 ? refspecs.map(refspecDestination) : [currentBranch(directory)];
355
+ const protectedBranch = destinations.find((branch) => PROTECTED_BRANCHES.has(branch));
356
+ if (protectedBranch !== undefined) return message(context, 'pushProtected', protectedBranch);
357
+ }
358
+ return null;
359
+ }
360
+
361
+ /**
362
+ * `destructive-guard`: rm -rf on a root-like target, and destructive SQL sent to a client.
363
+ * @param {ToolEvent} event @param {GuardContext} context
364
+ * @returns {string | null}
365
+ */
366
+ export function destructiveGuard(event, context) {
367
+ const command = record(event.tool_input).command;
368
+ if (event.tool_name !== 'Bash' || typeof command !== 'string') return null;
369
+ const parsed = splitShell(command);
370
+ if (!parsed.ok) return null;
371
+ let databaseClient = false;
372
+ for (const item of parsed.commands) {
373
+ const words = effectiveWords(item.words);
374
+ const program = programName(words[0] ?? '');
375
+ if (DATABASE_CLIENTS.has(program)) databaseClient = true;
376
+ if (program !== 'rm') continue;
377
+ const flags = words.slice(1).filter((word) => word.startsWith('-'));
378
+ const recursive = flags.some(
379
+ (flag) => flag === '--recursive' || (/^-[A-Za-z]+$/u.test(flag) && /[rR]/u.test(flag)),
380
+ );
381
+ const force = flags.some(
382
+ (flag) => flag === '--force' || (/^-[A-Za-z]+$/u.test(flag) && flag.includes('f')),
383
+ );
384
+ const target = words
385
+ .slice(1)
386
+ .find((word) => !word.startsWith('-') && DANGEROUS_RM_TARGETS.has(word));
387
+ if (recursive && force && target !== undefined) return message(context, 'rm', target);
388
+ }
389
+ return databaseClient && DESTRUCTIVE_SQL.test(command) ? message(context, 'sql') : null;
390
+ }
391
+
392
+ /** Guards by catalog id, in the order they run. */
393
+ export const GUARDS = Object.freeze({
394
+ 'secrets-guard': secretsGuard,
395
+ 'git-guard': gitGuard,
396
+ 'destructive-guard': destructiveGuard,
397
+ });
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
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.
9
+ *
10
+ * Exit 0 always, except exit 2 for a PreToolUse refusal (reason on stderr).
11
+ * Any error lets the tool call run: a broken guard must not stop all work.
12
+ */
13
+ import { homedir } from 'node:os';
14
+ import { isAbsolute } from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { hookById } from '../extensions-catalog.mjs';
17
+ import { GUARDS } from './guards.mjs';
18
+ import {
19
+ continuityReminder,
20
+ docsReminder,
21
+ recordEdit,
22
+ resetTurn,
23
+ restoreCompactSnapshot,
24
+ saveCompactSnapshot,
25
+ sessionContext,
26
+ } from './continuity.mjs';
27
+ import { formatEdited } from './format.mjs';
28
+
29
+ const MAX_INPUT_BYTES = 1024 * 1024;
30
+ const EVENTS = new Set([
31
+ 'PreToolUse',
32
+ 'PostToolUse',
33
+ 'UserPromptSubmit',
34
+ 'SessionStart',
35
+ 'PreCompact',
36
+ 'Stop',
37
+ ]);
38
+ const REFUSAL_EXIT_CODE = 2;
39
+
40
+ /**
41
+ * @typedef {object} RunOptions
42
+ * @property {import('../extensions-catalog.mjs').HookEvent} event
43
+ * @property {string[]} hooks
44
+ * @property {string} stateDir
45
+ * @property {string} prefsPath
46
+ * @property {'pt-BR' | 'en'} locale
47
+ */
48
+
49
+ /**
50
+ * Strict launcher arguments; null when anything is off.
51
+ * @param {string[]} argv
52
+ * @returns {RunOptions | null}
53
+ */
54
+ export function parseArguments(argv) {
55
+ /** @type {Record<string, string>} */
56
+ const values = {};
57
+ for (let index = 0; index < argv.length; index += 2) {
58
+ const name = argv[index] ?? '';
59
+ const value = argv[index + 1];
60
+ if (
61
+ !['--event', '--hooks', '--state', '--prefs', '--locale'].includes(name) ||
62
+ value === undefined
63
+ )
64
+ return null;
65
+ values[name] = value;
66
+ }
67
+ const event = values['--event'] ?? '';
68
+ const stateDir = values['--state'] ?? '';
69
+ 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
+ });
75
+ return {
76
+ event: /** @type {import('../extensions-catalog.mjs').HookEvent} */ (event),
77
+ hooks,
78
+ stateDir,
79
+ prefsPath,
80
+ locale: values['--locale'] === 'pt-BR' ? 'pt-BR' : 'en',
81
+ };
82
+ }
83
+
84
+ /** @returns {Promise<string | null>} */
85
+ async function readInput() {
86
+ /** @type {Buffer[]} */
87
+ const chunks = [];
88
+ let size = 0;
89
+ for await (const chunk of process.stdin) {
90
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
91
+ size += buffer.length;
92
+ if (size > MAX_INPUT_BYTES) return null;
93
+ chunks.push(buffer);
94
+ }
95
+ return Buffer.concat(chunks).toString('utf8');
96
+ }
97
+
98
+ /** Does a hook's binding for this event accept the tool or source? @param {string} id @param {string} event @param {string} subject */
99
+ function bound(id, event, subject) {
100
+ return (hookById(id)?.bindings ?? []).some(
101
+ (binding) =>
102
+ binding.event === event &&
103
+ (binding.matcher === undefined || binding.matcher.split('|').includes(subject)),
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Run the enabled hooks for one event.
109
+ * @param {RunOptions} options
110
+ * @param {Record<string, unknown>} input
111
+ * @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
112
+ */
113
+ export async function dispatch(options, input) {
114
+ const envProject = process.env.CLAUDE_PROJECT_DIR;
115
+ const projectDir =
116
+ typeof envProject === 'string' && isAbsolute(envProject)
117
+ ? envProject
118
+ : typeof input.cwd === 'string' && isAbsolute(input.cwd)
119
+ ? input.cwd
120
+ : process.cwd();
121
+ const context = {
122
+ projectDir,
123
+ stateDir: options.stateDir,
124
+ prefsPath: options.prefsPath,
125
+ home: homedir(),
126
+ locale: options.locale,
127
+ };
128
+ const enabled = new Set(options.hooks);
129
+ const tool = typeof input.tool_name === 'string' ? input.tool_name : '';
130
+ const toolInput =
131
+ typeof input.tool_input === 'object' && input.tool_input !== null
132
+ ? /** @type {Record<string, unknown>} */ (input.tool_input)
133
+ : {};
134
+ const quiet = { exitCode: 0, stdout: '', stderr: '' };
135
+
136
+ if (options.event === 'PreToolUse') {
137
+ for (const [id, guard] of Object.entries(GUARDS)) {
138
+ if (!enabled.has(id) || !bound(id, 'PreToolUse', tool)) continue;
139
+ const reason = guard(input, context);
140
+ if (reason !== null)
141
+ return { exitCode: REFUSAL_EXIT_CODE, stdout: '', stderr: `[aisac ${id}] ${reason}\n` };
142
+ }
143
+ return quiet;
144
+ }
145
+ 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
+ : '';
155
+ return reminder.length === 0
156
+ ? quiet
157
+ : {
158
+ exitCode: 0,
159
+ stdout: JSON.stringify({
160
+ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: reminder },
161
+ }),
162
+ stderr: '',
163
+ };
164
+ }
165
+ if (options.event === 'UserPromptSubmit') {
166
+ if (enabled.has('docs-reminder') || enabled.has('continuity-reminder'))
167
+ await resetTurn(context, input.session_id);
168
+ return quiet;
169
+ }
170
+ if (options.event === 'Stop') {
171
+ const reason = enabled.has('continuity-reminder')
172
+ ? await continuityReminder(context, input.session_id, input.stop_hook_active)
173
+ : '';
174
+ return reason.length === 0
175
+ ? quiet
176
+ : { exitCode: 0, stdout: JSON.stringify({ decision: 'block', reason }), stderr: '' };
177
+ }
178
+ if (options.event === 'PreCompact') {
179
+ if (enabled.has('compact-continuity')) await saveCompactSnapshot(context, input.session_id);
180
+ return quiet;
181
+ }
182
+ const source = typeof input.source === 'string' ? input.source : 'startup';
183
+ const text =
184
+ source === 'compact'
185
+ ? enabled.has('compact-continuity')
186
+ ? await restoreCompactSnapshot(context, input.session_id)
187
+ : ''
188
+ : enabled.has('session-context') && bound('session-context', 'SessionStart', source)
189
+ ? await sessionContext(context)
190
+ : '';
191
+ return { exitCode: 0, stdout: text, stderr: '' };
192
+ }
193
+
194
+ async function main() {
195
+ const options = parseArguments(process.argv.slice(2));
196
+ if (options === null || options.hooks.length === 0) return;
197
+ const raw = await readInput();
198
+ if (raw === null) return;
199
+ /** @type {unknown} */
200
+ const parsed = JSON.parse(raw);
201
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return;
202
+ const result = await dispatch(options, /** @type {Record<string, unknown>} */ (parsed));
203
+ if (result.stdout.length > 0) process.stdout.write(result.stdout);
204
+ if (result.stderr.length > 0) process.stderr.write(result.stderr);
205
+ process.exitCode = result.exitCode;
206
+ }
207
+
208
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href)
209
+ main().catch(() => {
210
+ process.exitCode = 0;
211
+ });