@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.
- package/README.md +19 -0
- package/dist/assets/codex-context-compaction.mjs +98 -1
- package/dist/claude-observation.mjs +1 -1
- package/dist/cli.mjs +20 -2
- package/dist/codex-engineering.mjs +165 -0
- package/dist/engineering-pack.mjs +139 -16
- package/dist/extensions-catalog.mjs +140 -0
- package/dist/extensions-cli.mjs +214 -0
- package/dist/extensions-preferences.mjs +122 -0
- package/dist/guardrails/continuity.mjs +239 -0
- package/dist/guardrails/format.mjs +111 -0
- package/dist/guardrails/guards.mjs +397 -0
- package/dist/guardrails/run.mjs +211 -0
- package/dist/guardrails/shell-words.mjs +206 -0
- package/dist/guardrails-plugin.mjs +149 -0
- package/dist/launch.mjs +41 -2
- package/dist/monitor.mjs +21 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
});
|