@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,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,12 @@ import {
18
18
  sweepStaleLaunches,
19
19
  writeLaunchMarker,
20
20
  } from './engineering-pack.mjs';
21
+ import { prepareGuardrails } from './guardrails-plugin.mjs';
22
+ import {
23
+ CODEX_ENGINEERING_ENV,
24
+ CODEX_ENGINEERING_SKILLS_ROOT_ENV,
25
+ supportsCodexEngineering,
26
+ } from './codex-engineering.mjs';
21
27
  import {
22
28
  CODEX_LAUNCH_ENVELOPE,
23
29
  CODEX_MONITOR_CWD,
@@ -132,6 +138,8 @@ function childEnvironment(root) {
132
138
  delete environment.CODEX_CONTEXT_COMPACT_AT;
133
139
  delete environment[CODEX_LAUNCH_ENVELOPE];
134
140
  delete environment[CODEX_MONITOR_CWD];
141
+ delete environment[CODEX_ENGINEERING_ENV];
142
+ delete environment[CODEX_ENGINEERING_SKILLS_ROOT_ENV];
135
143
  environment.AIBRAIN_STATE_DIR = resolve(root);
136
144
  return environment;
137
145
  }
@@ -421,6 +429,7 @@ function waitForChild(child, directory) {
421
429
  /**
422
430
  * @typedef {object} LaunchDependencies
423
431
  * @property {typeof prepareEngineeringPack} [preparePack] AISAC Engineering pack loader (E4b)
432
+ * @property {typeof prepareGuardrails} [prepareGuardrails] Guardrail hooks plugin writer (E6a)
424
433
  */
425
434
 
426
435
  /**
@@ -431,6 +440,7 @@ function waitForChild(child, directory) {
431
440
  */
432
441
  export async function launchClaude(config, root, args, dependencies = {}) {
433
442
  const preparePack = dependencies.preparePack ?? prepareEngineeringPack;
443
+ const writeGuardrails = dependencies.prepareGuardrails ?? prepareGuardrails;
434
444
  const validated = assertConfig(config);
435
445
  const launchArgs = await parseClaudeLaunchArgs(args, process.cwd());
436
446
  assertClaudeArgs(launchArgs.args);
@@ -571,6 +581,15 @@ export async function launchClaude(config, root, args, dependencies = {}) {
571
581
  packageRoot: validated.packageRoot,
572
582
  }).catch(() => ({ pluginRoot: null, line: null }));
573
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`);
574
593
  const environment = childEnvironment(root);
575
594
  environment.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS = '1';
576
595
  /** @type {import('node:child_process').ChildProcess} */
@@ -588,6 +607,7 @@ export async function launchClaude(config, root, args, dependencies = {}) {
588
607
  '--plugin-dir',
589
608
  pluginRoot,
590
609
  ...(engineering.pluginRoot === null ? [] : ['--plugin-dir', engineering.pluginRoot]),
610
+ ...(guardrails === null ? [] : ['--plugin-dir', guardrails.pluginRoot]),
591
611
  ...launchArgs.args,
592
612
  ],
593
613
  { cwd: process.cwd(), env: environment, stdio: 'inherit' },
@@ -602,8 +622,14 @@ export async function launchClaude(config, root, args, dependencies = {}) {
602
622
  return finished;
603
623
  }
604
624
 
605
- /** @param {LaunchConfig} config @param {string} root @param {string[]} args */
606
- export async function launchCodex(config, root, args) {
625
+ /**
626
+ * @param {LaunchConfig} config
627
+ * @param {string} root
628
+ * @param {string[]} args
629
+ * @param {LaunchDependencies} [dependencies]
630
+ */
631
+ export async function launchCodex(config, root, args, dependencies = {}) {
632
+ const preparePack = dependencies.preparePack ?? prepareEngineeringPack;
607
633
  const validated = assertConfig(config);
608
634
  assertCodexArgs(args);
609
635
  const captureLockPlatform = captureLockPlatformCheck();
@@ -654,6 +680,19 @@ export async function launchCodex(config, root, args) {
654
680
  await rm(directory, { recursive: true, force: true });
655
681
  throw error;
656
682
  }
683
+ // The pack never blocks the launch: every failure starts Codex without it.
684
+ const engineering = await preparePack({
685
+ endpoint: validated.endpoint,
686
+ token: validated.token,
687
+ root,
688
+ directory,
689
+ packageRoot: validated.packageRoot,
690
+ client: 'codex',
691
+ hostSupported: supportsCodexEngineering(compatibility.version),
692
+ }).catch(() => ({ pluginRoot: null, codex: null, line: null }));
693
+ if (engineering.line !== null) process.stderr.write(`${engineering.line}\n`);
694
+ if (engineering.codex != null)
695
+ environment[CODEX_ENGINEERING_ENV] = JSON.stringify(engineering.codex);
657
696
  environment.AI_BRAIN_MCP_SERVER = MCP_ALIAS;
658
697
  environment.CODEX_BIN = executable;
659
698
  environment.CODEX_MONITOR_COMMAND = monitor;
package/dist/monitor.mjs CHANGED
@@ -12,6 +12,12 @@ import {
12
12
  ownedAppServerConfigArgs,
13
13
  parseCodexLaunchEnvelope,
14
14
  } from './codex-permissions.mjs';
15
+ import {
16
+ CODEX_ENGINEERING_ENV,
17
+ CODEX_ENGINEERING_SKILLS_ROOT_ENV,
18
+ codexEngineeringConfigArgs,
19
+ parseCodexEngineering,
20
+ } from './codex-engineering.mjs';
15
21
 
16
22
  const root = dirname(fileURLToPath(import.meta.url));
17
23
  const endpoint = process.env.AIBRAIN_MCP_URL?.trim();
@@ -32,6 +38,17 @@ if (parsed.search) throw new Error('aibrain_monitor_endpoint_query');
32
38
  const canonical = join(root, 'assets', 'codex-context-compaction.mjs');
33
39
  await access(canonical, constants.R_OK);
34
40
  const launchEnvelope = parseCodexLaunchEnvelope(process.env[CODEX_LAUNCH_ENVELOPE]);
41
+ // AISAC Engineering (E4c): a malformed value starts the session without the pack.
42
+ let engineering = null;
43
+ try {
44
+ const stateDir = process.env.AIBRAIN_STATE_DIR;
45
+ engineering = parseCodexEngineering(
46
+ process.env[CODEX_ENGINEERING_ENV],
47
+ stateDir === undefined ? {} : { stateDir },
48
+ );
49
+ } catch {
50
+ engineering = null;
51
+ }
35
52
  const policyText = process.env.CODEX_CONTEXT_POLICY_JSON;
36
53
  let policy;
37
54
  if (policyText === undefined) {
@@ -89,6 +106,9 @@ const env = {
89
106
  };
90
107
  delete env[CODEX_LAUNCH_ENVELOPE];
91
108
  delete env[CODEX_APP_SERVER_PROFILE];
109
+ delete env[CODEX_ENGINEERING_ENV];
110
+ delete env[CODEX_ENGINEERING_SKILLS_ROOT_ENV];
111
+ if (engineering !== null) env[CODEX_ENGINEERING_SKILLS_ROOT_ENV] = engineering.skillsRoot;
92
112
  if (launchEnvelope?.profile !== undefined) env[CODEX_APP_SERVER_PROFILE] = launchEnvelope.profile;
93
113
  if (policy !== null) env.CODEX_CONTEXT_POLICY_JSON = JSON.stringify(policy);
94
114
  else delete env.CODEX_CONTEXT_POLICY_JSON;
@@ -109,6 +129,7 @@ const child = spawn(
109
129
  '-c',
110
130
  'mcp_servers.aibrain_context.bearer_token_env_var="CODEX_RECOVERY_MCP_TOKEN"',
111
131
  ...(launchEnvelope === null ? [] : ownedAppServerConfigArgs(launchEnvelope)),
132
+ ...(engineering === null ? [] : codexEngineeringConfigArgs(engineering)),
112
133
  ],
113
134
  { cwd: process.cwd(), env, stdio: 'inherit' },
114
135
  );
package/dist/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.4.0-beta.2",
3
+ "version": "0.4.0-beta.4",
4
4
  "type": "module"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.4.0-beta.2",
3
+ "version": "0.4.0-beta.4",
4
4
  "private": false,
5
5
  "description": "AISAC client for Claude Code and Codex: AI Brain memory, context management and AISAC Engineering",
6
6
  "license": "UNLICENSED",