@brunobracaioli/aibrain 0.4.0-beta.3 → 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 +12 -0
- package/dist/claude-observation.mjs +1 -1
- package/dist/cli.mjs +19 -1
- 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 +13 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* A small POSIX-style shell word splitter for guardrail decisions. It never
|
|
4
|
+
* runs anything. It keeps quotes, escapes and command substitutions inside
|
|
5
|
+
* their word, splits commands on control operators, and marks redirection
|
|
6
|
+
* targets. When the text cannot be split (an unterminated quote), `ok` is
|
|
7
|
+
* false and callers fall back to a word-boundary scan of the raw text.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} ShellCommand
|
|
12
|
+
* @property {string[]} words Words of one simple command, without redirections.
|
|
13
|
+
* @property {string[]} targets File targets of its redirections (`>`, `>>`, `<`, `&>`, ...).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const CONTROL_OPERATORS = ['&&', '||', ';;', '|&', ';', '|', '&', '(', ')'];
|
|
17
|
+
const REDIRECTIONS = ['&>>', '&>', '>>', '>|', '<>', '<<<', '<<', '>', '<'];
|
|
18
|
+
const HEREDOC_OPERATORS = new Set(['<<', '<<<']);
|
|
19
|
+
const MAX_COMMAND_LENGTH = 64 * 1024;
|
|
20
|
+
|
|
21
|
+
/** @param {string} text @param {number} index @param {string[]} candidates */
|
|
22
|
+
function operatorAt(text, index, candidates) {
|
|
23
|
+
return candidates.find((candidate) => text.startsWith(candidate, index));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read a `$(...)` or backtick substitution verbatim, so its text stays in the word.
|
|
28
|
+
* @param {string} text @param {number} start index of `$(` or the backtick
|
|
29
|
+
* @returns {number} index after the substitution, or -1 when it never closes
|
|
30
|
+
*/
|
|
31
|
+
function substitutionEnd(text, start) {
|
|
32
|
+
if (text[start] === '`') {
|
|
33
|
+
const close = text.indexOf('`', start + 1);
|
|
34
|
+
return close === -1 ? -1 : close + 1;
|
|
35
|
+
}
|
|
36
|
+
let depth = 0;
|
|
37
|
+
for (let index = start; index < text.length; index += 1) {
|
|
38
|
+
if (text[index] === '(') depth += 1;
|
|
39
|
+
else if (text[index] === ')') {
|
|
40
|
+
depth -= 1;
|
|
41
|
+
if (depth === 0) return index + 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return -1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Split a shell command line into simple commands.
|
|
49
|
+
* @param {string} text
|
|
50
|
+
* @returns {{ ok: boolean, commands: ShellCommand[] }}
|
|
51
|
+
*/
|
|
52
|
+
export function splitShell(text) {
|
|
53
|
+
if (typeof text !== 'string' || text.length > MAX_COMMAND_LENGTH)
|
|
54
|
+
return { ok: false, commands: [] };
|
|
55
|
+
/** @type {ShellCommand[]} */
|
|
56
|
+
const commands = [];
|
|
57
|
+
/** @type {ShellCommand} */
|
|
58
|
+
let current = { words: [], targets: [] };
|
|
59
|
+
let word = '';
|
|
60
|
+
let inWord = false;
|
|
61
|
+
/** @type {string | null} */
|
|
62
|
+
let pendingRedirection = null;
|
|
63
|
+
|
|
64
|
+
const endWord = () => {
|
|
65
|
+
if (!inWord) return;
|
|
66
|
+
if (pendingRedirection !== null) {
|
|
67
|
+
if (!HEREDOC_OPERATORS.has(pendingRedirection)) current.targets.push(word);
|
|
68
|
+
pendingRedirection = null;
|
|
69
|
+
} else current.words.push(word);
|
|
70
|
+
word = '';
|
|
71
|
+
inWord = false;
|
|
72
|
+
};
|
|
73
|
+
const endCommand = () => {
|
|
74
|
+
endWord();
|
|
75
|
+
if (current.words.length > 0 || current.targets.length > 0) commands.push(current);
|
|
76
|
+
current = { words: [], targets: [] };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
let index = 0;
|
|
80
|
+
while (index < text.length) {
|
|
81
|
+
const char = text[index];
|
|
82
|
+
if (char === ' ' || char === '\t') {
|
|
83
|
+
endWord();
|
|
84
|
+
index += 1;
|
|
85
|
+
} else if (char === '\n') {
|
|
86
|
+
endCommand();
|
|
87
|
+
index += 1;
|
|
88
|
+
} else if (char === '#' && !inWord) {
|
|
89
|
+
const newline = text.indexOf('\n', index);
|
|
90
|
+
index = newline === -1 ? text.length : newline;
|
|
91
|
+
} else if (char === "'") {
|
|
92
|
+
const close = text.indexOf("'", index + 1);
|
|
93
|
+
if (close === -1) return { ok: false, commands: [] };
|
|
94
|
+
word += text.slice(index + 1, close);
|
|
95
|
+
inWord = true;
|
|
96
|
+
index = close + 1;
|
|
97
|
+
} else if (char === '"') {
|
|
98
|
+
let cursor = index + 1;
|
|
99
|
+
let closed = false;
|
|
100
|
+
while (cursor < text.length) {
|
|
101
|
+
const inner = text[cursor];
|
|
102
|
+
if (
|
|
103
|
+
inner === '\\' &&
|
|
104
|
+
cursor + 1 < text.length &&
|
|
105
|
+
'"\\$`\n'.includes(text[cursor + 1] ?? '')
|
|
106
|
+
) {
|
|
107
|
+
if (text[cursor + 1] !== '\n') word += text[cursor + 1];
|
|
108
|
+
cursor += 2;
|
|
109
|
+
} else if (inner === '"') {
|
|
110
|
+
closed = true;
|
|
111
|
+
cursor += 1;
|
|
112
|
+
break;
|
|
113
|
+
} else if (inner === '$' && text[cursor + 1] === '(') {
|
|
114
|
+
const end = substitutionEnd(text, cursor + 1);
|
|
115
|
+
if (end === -1) return { ok: false, commands: [] };
|
|
116
|
+
word += text.slice(cursor, end);
|
|
117
|
+
cursor = end;
|
|
118
|
+
} else {
|
|
119
|
+
word += inner;
|
|
120
|
+
cursor += 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!closed) return { ok: false, commands: [] };
|
|
124
|
+
inWord = true;
|
|
125
|
+
index = cursor;
|
|
126
|
+
} else if (char === '\\') {
|
|
127
|
+
if (text[index + 1] === '\n') index += 2;
|
|
128
|
+
else {
|
|
129
|
+
word += text[index + 1] ?? '';
|
|
130
|
+
inWord = true;
|
|
131
|
+
index += 2;
|
|
132
|
+
}
|
|
133
|
+
} else if ((char === '$' && text[index + 1] === '(') || char === '`') {
|
|
134
|
+
const end = substitutionEnd(text, char === '`' ? index : index + 1);
|
|
135
|
+
if (end === -1) return { ok: false, commands: [] };
|
|
136
|
+
word += text.slice(index, end);
|
|
137
|
+
inWord = true;
|
|
138
|
+
index = end;
|
|
139
|
+
} else {
|
|
140
|
+
const redirection = operatorAt(text, index, REDIRECTIONS);
|
|
141
|
+
const descriptorWord = inWord && /^\d+$/u.test(word);
|
|
142
|
+
if (redirection !== undefined && (!inWord || descriptorWord)) {
|
|
143
|
+
if (descriptorWord) {
|
|
144
|
+
word = '';
|
|
145
|
+
inWord = false;
|
|
146
|
+
} else endWord();
|
|
147
|
+
index += redirection.length;
|
|
148
|
+
// `2>&1` and `>&2` duplicate a descriptor; they name no file.
|
|
149
|
+
if (text[index] === '&' && /\d|-/u.test(text[index + 1] ?? '')) {
|
|
150
|
+
index += 2;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
pendingRedirection = redirection;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const operator = operatorAt(text, index, CONTROL_OPERATORS);
|
|
157
|
+
if (operator !== undefined) {
|
|
158
|
+
endCommand();
|
|
159
|
+
index += operator.length;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
word += char;
|
|
163
|
+
inWord = true;
|
|
164
|
+
index += 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
endCommand();
|
|
168
|
+
return { ok: true, commands };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Basename of a program word (`/usr/bin/git` -> `git`). @param {string} word */
|
|
172
|
+
export function programName(word) {
|
|
173
|
+
const slash = word.lastIndexOf('/');
|
|
174
|
+
return slash === -1 ? word : word.slice(slash + 1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const WRAPPERS = new Set([
|
|
178
|
+
'sudo',
|
|
179
|
+
'doas',
|
|
180
|
+
'env',
|
|
181
|
+
'command',
|
|
182
|
+
'nice',
|
|
183
|
+
'nohup',
|
|
184
|
+
'time',
|
|
185
|
+
'exec',
|
|
186
|
+
'npx',
|
|
187
|
+
'bunx',
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The words of a command with leading `VAR=value` assignments and wrappers
|
|
192
|
+
* such as `sudo` or `env` removed, so `sudo rm -rf /` is seen as `rm -rf /`.
|
|
193
|
+
* @param {string[]} words
|
|
194
|
+
*/
|
|
195
|
+
export function effectiveWords(words) {
|
|
196
|
+
let index = 0;
|
|
197
|
+
while (index < words.length) {
|
|
198
|
+
const word = words[index] ?? '';
|
|
199
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(word)) index += 1;
|
|
200
|
+
else if (WRAPPERS.has(programName(word))) {
|
|
201
|
+
index += 1;
|
|
202
|
+
while ((words[index] ?? '').startsWith('-')) index += 1;
|
|
203
|
+
} else break;
|
|
204
|
+
}
|
|
205
|
+
return words.slice(index);
|
|
206
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The session plugin `aisac-guardrails` (spec aisac-engineering-extensions
|
|
4
|
+
* FR5): written into the launch's private folder with only the enabled
|
|
5
|
+
* hooks, and deleted with that folder on exit. Every command is the absolute
|
|
6
|
+
* Node executable plus the dispatcher inside the installed package; nothing
|
|
7
|
+
* comes from the server (ADR-0048).
|
|
8
|
+
*/
|
|
9
|
+
import { access, mkdir, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { isAbsolute, join } from 'node:path';
|
|
11
|
+
import { hookById } from './extensions-catalog.mjs';
|
|
12
|
+
import { enabledHookIds, preferencesPath, readPreferences } from './extensions-preferences.mjs';
|
|
13
|
+
import { readStoredLocale, resolvePackLocale } from './engineering-pack.mjs';
|
|
14
|
+
|
|
15
|
+
export const GUARDRAILS_PLUGIN_NAME = 'aisac-guardrails';
|
|
16
|
+
const PLUGIN_VERSION = '1.0.0';
|
|
17
|
+
const PLUGIN_DIRECTORY = 'guardrails';
|
|
18
|
+
const STATE_DIRECTORY = 'guardrails-state';
|
|
19
|
+
const DISPATCHER = join('guardrails', 'run.mjs');
|
|
20
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
|
|
21
|
+
/** @type {Readonly<Record<string, number>>} */
|
|
22
|
+
const TIMEOUT_SECONDS = Object.freeze({
|
|
23
|
+
PreToolUse: 5,
|
|
24
|
+
PostToolUse: 5,
|
|
25
|
+
UserPromptSubmit: 5,
|
|
26
|
+
Stop: 5,
|
|
27
|
+
SessionStart: 10,
|
|
28
|
+
PreCompact: 10,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** POSIX single-quote quoting for the host's shell. @param {string} value */
|
|
32
|
+
export function shellQuote(value) {
|
|
33
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {string} path */
|
|
37
|
+
function safeAbsolute(path) {
|
|
38
|
+
return isAbsolute(path) && !CONTROL_CHARACTERS.test(path);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The plugin's hooks.json for the enabled ids: one entry per event, whose
|
|
43
|
+
* matcher is the union of the enabled hooks' matchers for that event.
|
|
44
|
+
* @param {string[]} enabled
|
|
45
|
+
* @param {{ node: string, dispatcher: string, stateDir: string, prefsPath: string, locale: 'pt-BR' | 'en' }} paths
|
|
46
|
+
*/
|
|
47
|
+
export function guardrailsHooks(enabled, paths) {
|
|
48
|
+
/** @type {Map<string, { ids: string[], matchers: Set<string>, unmatched: boolean }>} */
|
|
49
|
+
const events = new Map();
|
|
50
|
+
for (const id of enabled) {
|
|
51
|
+
for (const binding of hookById(id)?.bindings ?? []) {
|
|
52
|
+
const current = events.get(binding.event) ?? {
|
|
53
|
+
ids: [],
|
|
54
|
+
matchers: new Set(),
|
|
55
|
+
unmatched: false,
|
|
56
|
+
};
|
|
57
|
+
if (!current.ids.includes(id)) current.ids.push(id);
|
|
58
|
+
if (binding.matcher === undefined) current.unmatched = true;
|
|
59
|
+
else for (const name of binding.matcher.split('|')) current.matchers.add(name);
|
|
60
|
+
events.set(binding.event, current);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** @type {Record<string, unknown[]>} */
|
|
64
|
+
const hooks = {};
|
|
65
|
+
for (const [event, group] of events) {
|
|
66
|
+
const command = [
|
|
67
|
+
shellQuote(paths.node),
|
|
68
|
+
shellQuote(paths.dispatcher),
|
|
69
|
+
'--event',
|
|
70
|
+
event,
|
|
71
|
+
'--hooks',
|
|
72
|
+
group.ids.join(','),
|
|
73
|
+
'--state',
|
|
74
|
+
shellQuote(paths.stateDir),
|
|
75
|
+
'--prefs',
|
|
76
|
+
shellQuote(paths.prefsPath),
|
|
77
|
+
'--locale',
|
|
78
|
+
paths.locale,
|
|
79
|
+
].join(' ');
|
|
80
|
+
hooks[event] = [
|
|
81
|
+
{
|
|
82
|
+
...(group.unmatched || group.matchers.size === 0
|
|
83
|
+
? {}
|
|
84
|
+
: { matcher: [...group.matchers].join('|') }),
|
|
85
|
+
hooks: [{ type: 'command', command, timeout: TIMEOUT_SECONDS[event] ?? 5 }],
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
description: 'AISAC Engineering guardrails for this session (aisac hooks to change them).',
|
|
91
|
+
hooks,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Write the plugin for a licensed launch. Null when no hook is enabled or the
|
|
97
|
+
* package lacks the dispatcher; the launch then goes on without it.
|
|
98
|
+
* @param {{ directory: string, root: string, packageRoot: string }} input
|
|
99
|
+
* @returns {Promise<{ pluginRoot: string, count: number, line: string } | null>}
|
|
100
|
+
*/
|
|
101
|
+
export async function prepareGuardrails(input) {
|
|
102
|
+
const enabled = enabledHookIds(await readPreferences(input.root));
|
|
103
|
+
if (enabled.length === 0) return null;
|
|
104
|
+
const dispatcher = join(input.packageRoot, DISPATCHER);
|
|
105
|
+
const prefsPath = preferencesPath(input.root);
|
|
106
|
+
const pluginRoot = join(input.directory, PLUGIN_DIRECTORY);
|
|
107
|
+
const stateDir = join(input.directory, STATE_DIRECTORY);
|
|
108
|
+
if (![process.execPath, dispatcher, prefsPath, pluginRoot, stateDir].every(safeAbsolute))
|
|
109
|
+
return null;
|
|
110
|
+
if (
|
|
111
|
+
!(await access(dispatcher).then(
|
|
112
|
+
() => true,
|
|
113
|
+
() => false,
|
|
114
|
+
))
|
|
115
|
+
)
|
|
116
|
+
return null;
|
|
117
|
+
const locale = resolvePackLocale(await readStoredLocale(input.root));
|
|
118
|
+
await mkdir(join(pluginRoot, '.claude-plugin'), { recursive: true, mode: 0o700 });
|
|
119
|
+
await mkdir(join(pluginRoot, 'hooks'), { mode: 0o700 });
|
|
120
|
+
await mkdir(stateDir, { mode: 0o700 });
|
|
121
|
+
const manifest = {
|
|
122
|
+
name: GUARDRAILS_PLUGIN_NAME,
|
|
123
|
+
version: PLUGIN_VERSION,
|
|
124
|
+
description: 'AISAC Engineering guardrail hooks, loaded for this session only.',
|
|
125
|
+
author: { name: 'AISAC' },
|
|
126
|
+
hooks: './hooks/hooks.json',
|
|
127
|
+
};
|
|
128
|
+
const hooks = guardrailsHooks(enabled, {
|
|
129
|
+
node: process.execPath,
|
|
130
|
+
dispatcher,
|
|
131
|
+
stateDir,
|
|
132
|
+
prefsPath,
|
|
133
|
+
locale,
|
|
134
|
+
});
|
|
135
|
+
await writeFile(
|
|
136
|
+
join(pluginRoot, '.claude-plugin', 'plugin.json'),
|
|
137
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
138
|
+
{ mode: 0o600, flag: 'wx' },
|
|
139
|
+
);
|
|
140
|
+
await writeFile(join(pluginRoot, 'hooks', 'hooks.json'), `${JSON.stringify(hooks, null, 2)}\n`, {
|
|
141
|
+
mode: 0o600,
|
|
142
|
+
flag: 'wx',
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
pluginRoot,
|
|
146
|
+
count: enabled.length,
|
|
147
|
+
line: `AISAC Engineering: ${enabled.length} guardrail${enabled.length === 1 ? '' : 's'} on (aisac hooks to change them).`,
|
|
148
|
+
};
|
|
149
|
+
}
|
package/dist/launch.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
sweepStaleLaunches,
|
|
19
19
|
writeLaunchMarker,
|
|
20
20
|
} from './engineering-pack.mjs';
|
|
21
|
+
import { prepareGuardrails } from './guardrails-plugin.mjs';
|
|
21
22
|
import {
|
|
22
23
|
CODEX_ENGINEERING_ENV,
|
|
23
24
|
CODEX_ENGINEERING_SKILLS_ROOT_ENV,
|
|
@@ -428,6 +429,7 @@ function waitForChild(child, directory) {
|
|
|
428
429
|
/**
|
|
429
430
|
* @typedef {object} LaunchDependencies
|
|
430
431
|
* @property {typeof prepareEngineeringPack} [preparePack] AISAC Engineering pack loader (E4b)
|
|
432
|
+
* @property {typeof prepareGuardrails} [prepareGuardrails] Guardrail hooks plugin writer (E6a)
|
|
431
433
|
*/
|
|
432
434
|
|
|
433
435
|
/**
|
|
@@ -438,6 +440,7 @@ function waitForChild(child, directory) {
|
|
|
438
440
|
*/
|
|
439
441
|
export async function launchClaude(config, root, args, dependencies = {}) {
|
|
440
442
|
const preparePack = dependencies.preparePack ?? prepareEngineeringPack;
|
|
443
|
+
const writeGuardrails = dependencies.prepareGuardrails ?? prepareGuardrails;
|
|
441
444
|
const validated = assertConfig(config);
|
|
442
445
|
const launchArgs = await parseClaudeLaunchArgs(args, process.cwd());
|
|
443
446
|
assertClaudeArgs(launchArgs.args);
|
|
@@ -578,6 +581,15 @@ export async function launchClaude(config, root, args, dependencies = {}) {
|
|
|
578
581
|
packageRoot: validated.packageRoot,
|
|
579
582
|
}).catch(() => ({ pluginRoot: null, line: null }));
|
|
580
583
|
if (engineering.line !== null) process.stderr.write(`${engineering.line}\n`);
|
|
584
|
+
// Guardrails need the license this launch confirmed (the pack loaded), and
|
|
585
|
+
// like the pack they never block the launch (spec aisac-engineering-extensions FR5).
|
|
586
|
+
const guardrails =
|
|
587
|
+
engineering.pluginRoot === null
|
|
588
|
+
? null
|
|
589
|
+
: await writeGuardrails({ directory, root, packageRoot: validated.packageRoot }).catch(
|
|
590
|
+
() => null,
|
|
591
|
+
);
|
|
592
|
+
if (guardrails !== null) process.stderr.write(`${guardrails.line}\n`);
|
|
581
593
|
const environment = childEnvironment(root);
|
|
582
594
|
environment.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS = '1';
|
|
583
595
|
/** @type {import('node:child_process').ChildProcess} */
|
|
@@ -595,6 +607,7 @@ export async function launchClaude(config, root, args, dependencies = {}) {
|
|
|
595
607
|
'--plugin-dir',
|
|
596
608
|
pluginRoot,
|
|
597
609
|
...(engineering.pluginRoot === null ? [] : ['--plugin-dir', engineering.pluginRoot]),
|
|
610
|
+
...(guardrails === null ? [] : ['--plugin-dir', guardrails.pluginRoot]),
|
|
598
611
|
...launchArgs.args,
|
|
599
612
|
],
|
|
600
613
|
{ cwd: process.cwd(), env: environment, stdio: 'inherit' },
|
package/dist/package.json
CHANGED
package/package.json
CHANGED