@brunobracaioli/aibrain 0.4.0-beta.12 → 0.4.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/claude-observation.mjs +1 -1
- package/dist/cli.mjs +53 -6
- package/dist/codex-hooks.mjs +411 -0
- package/dist/codex-permissions.mjs +67 -34
- package/dist/extensions-catalog.mjs +26 -8
- package/dist/extensions-cli.mjs +2 -2
- package/dist/guardrails/guards.mjs +64 -12
- package/dist/guardrails/patch.mjs +33 -0
- package/dist/guardrails/run.mjs +106 -31
- package/dist/guardrails-plugin.mjs +59 -1
- package/dist/launch.mjs +22 -1
- package/dist/monitor.mjs +43 -1
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,6 +41,12 @@ Licensed `aisac claude` sessions also load guardrail hooks as the session plugin
|
|
|
41
41
|
them from inside a session.
|
|
42
42
|
- **Code:** it ships in this package and runs locally. Nothing comes from the
|
|
43
43
|
server.
|
|
44
|
+
- **Codex (0.166.0 or later):** licensed `aisac codex` sessions get the same hooks
|
|
45
|
+
from session flags. Codex runs them only after you approve the AISAC hooks once
|
|
46
|
+
in `/hooks`, and you can switch each one off there too. The launch line says how
|
|
47
|
+
many are on and how many wait for that review; `aisac doctor` reports the same
|
|
48
|
+
under `extensions.codexHooks`. A new Node path (for example after an nvm or
|
|
49
|
+
Homebrew upgrade) changes the hook commands, so Codex asks for the review again.
|
|
44
50
|
|
|
45
51
|
Licensed sessions of `aisac claude` and `aisac codex` can also load reviewed MCP
|
|
46
52
|
servers: `github` (read-only, token in `GITHUB_PERSONAL_ACCESS_TOKEN`), `supabase`
|
|
@@ -16,7 +16,7 @@ export const MAX_LOG_FILES = 3;
|
|
|
16
16
|
const LOCK_WAIT_MS = 250;
|
|
17
17
|
const LOCK_STALE_MS = 30_000;
|
|
18
18
|
const ADMISSION_MS = 60 * 60 * 1000;
|
|
19
|
-
export const ADAPTER_VERSION = '0.4.0-beta.
|
|
19
|
+
export const ADAPTER_VERSION = '0.4.0-beta.13';
|
|
20
20
|
const MAX_DIAGNOSTIC_COUNT = 10_000_000;
|
|
21
21
|
const MAX_DIAGNOSTIC_BYTES = 128 * 1024 * 1024;
|
|
22
22
|
const CHECKPOINT_BACKENDS = new Set(['native_store', 'private_file']);
|
package/dist/cli.mjs
CHANGED
|
@@ -40,8 +40,10 @@ import {
|
|
|
40
40
|
import { extensionsSummary, hooksCommand, mcpCommand } from './extensions-cli.mjs';
|
|
41
41
|
import { sessionServers } from './mcp-servers.mjs';
|
|
42
42
|
import { anyFormatter } from './guardrails/format.mjs';
|
|
43
|
+
import { readCodexGuardrails } from './guardrails-plugin.mjs';
|
|
44
|
+
import { codexHooksStatus, supportsCodexHooks } from './codex-hooks.mjs';
|
|
43
45
|
|
|
44
|
-
const VERSION = '0.4.0-beta.
|
|
46
|
+
const VERSION = '0.4.0-beta.13';
|
|
45
47
|
// The command is `aisac`; `aibrain` is an alias installed from the same entry.
|
|
46
48
|
const COMMAND = 'aisac';
|
|
47
49
|
const ENDPOINT = 'https://mcp.ai-brain.aisac.cloud/mcp';
|
|
@@ -65,7 +67,7 @@ function usage() {
|
|
|
65
67
|
'AISAC Engineering: `aisac claude` and `aisac codex` (Codex 0.164+) load the licensed method; setup --lang pt-BR|en picks its language (before the first setup it runs the full setup); setup and doctor report the license; status stays offline. `aibrain` is an alias of `aisac`.',
|
|
66
68
|
);
|
|
67
69
|
console.log(
|
|
68
|
-
'Guardrails: `aisac hooks` lists them (a checklist on a terminal); `aisac hooks enable|disable <id>...` and `aisac hooks reset` change the next `aisac claude` session.',
|
|
70
|
+
'Guardrails: `aisac hooks` lists them (a checklist on a terminal); `aisac hooks enable|disable <id>...` and `aisac hooks reset` change the next `aisac claude` or `aisac codex` session.',
|
|
69
71
|
);
|
|
70
72
|
console.log(
|
|
71
73
|
'MCP catalog: `aisac mcp` lists the reviewed servers (GitHub, Supabase, Vercel, Playwright, Context7); `aisac mcp enable|disable <id>...`, `aisac mcp reset`, `aisac mcp info <id>` and `aisac mcp login <id>` (Codex) change or explain the next session.',
|
|
@@ -601,8 +603,48 @@ async function setup(args) {
|
|
|
601
603
|
if (line !== null) console.log(line);
|
|
602
604
|
}
|
|
603
605
|
|
|
604
|
-
/**
|
|
605
|
-
|
|
606
|
+
/**
|
|
607
|
+
* Whether Codex runs the AISAC hooks the user has on, from Codex's own
|
|
608
|
+
* `hooks/list` in this folder (FR6, FR8, E6b). No session starts, and Codex
|
|
609
|
+
* writes no config or trust record. Codex is not asked when the last launch had
|
|
610
|
+
* no license, because the launcher then hands Codex no hook.
|
|
611
|
+
* @param {{ executable: string | null, version: string | null | undefined, licensed: boolean }} codex
|
|
612
|
+
*/
|
|
613
|
+
async function doctorCodexHooks({ executable, version, licensed }) {
|
|
614
|
+
if (executable === null) return { status: 'codex_not_found' };
|
|
615
|
+
if (!supportsCodexHooks(version)) return { status: 'codex_version_unsupported' };
|
|
616
|
+
if (!licensed) return { status: 'not_licensed' };
|
|
617
|
+
const read = await readCodexGuardrails({
|
|
618
|
+
root: stateRoot(),
|
|
619
|
+
packageRoot: PACKAGE_ROOT,
|
|
620
|
+
executable,
|
|
621
|
+
cwd: process.cwd(),
|
|
622
|
+
profile: undefined,
|
|
623
|
+
environment: process.env,
|
|
624
|
+
}).catch(() => undefined);
|
|
625
|
+
if (read === undefined) return { status: 'list_failed' };
|
|
626
|
+
if (read === null) return { status: 'none_enabled' };
|
|
627
|
+
const { summary } = read;
|
|
628
|
+
if (summary === null) return { status: 'list_failed' };
|
|
629
|
+
const status = codexHooksStatus(summary);
|
|
630
|
+
return {
|
|
631
|
+
status,
|
|
632
|
+
handlers: summary.handlers,
|
|
633
|
+
review: summary.review,
|
|
634
|
+
off: summary.off,
|
|
635
|
+
missing: summary.missing,
|
|
636
|
+
...(summary.review.length > 0
|
|
637
|
+
? { remedy: 'Run aisac codex, open /hooks and approve the AISAC hooks.' }
|
|
638
|
+
: {}),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Extension state plus whether `format-on-edit` has a formatter in this folder,
|
|
644
|
+
* and whether Codex runs the hooks (FR8).
|
|
645
|
+
* @param {{ executable: string | null, version: string | null | undefined }} codex
|
|
646
|
+
*/
|
|
647
|
+
async function doctorExtensions(codex) {
|
|
606
648
|
const summary = await extensionsSummary(stateRoot());
|
|
607
649
|
// A token server is skipped at launch when its variable is unset (names only).
|
|
608
650
|
const mcpMissingEnv = sessionServers(summary.mcp, 'claude', process.env).missingEnv.map(
|
|
@@ -613,7 +655,9 @@ async function doctorExtensions() {
|
|
|
613
655
|
: (await anyFormatter(process.cwd()))
|
|
614
656
|
? 'ready'
|
|
615
657
|
: 'no_formatter_found';
|
|
616
|
-
|
|
658
|
+
const licensed = (await readLastLaunch(stateRoot()))?.state === 'loaded';
|
|
659
|
+
const codexHooks = await doctorCodexHooks({ ...codex, licensed });
|
|
660
|
+
return { ...summary, formatOnEdit, mcpMissingEnv, codexHooks };
|
|
617
661
|
}
|
|
618
662
|
|
|
619
663
|
/** @param {boolean} json */
|
|
@@ -710,7 +754,10 @@ async function doctor(json) {
|
|
|
710
754
|
},
|
|
711
755
|
engineering,
|
|
712
756
|
engineeringPack: await engineeringPackState(),
|
|
713
|
-
extensions: await doctorExtensions(
|
|
757
|
+
extensions: await doctorExtensions({
|
|
758
|
+
executable: native.codex,
|
|
759
|
+
version: codexCompatibility?.version,
|
|
760
|
+
}),
|
|
714
761
|
paidOrPilot: probe?.ok
|
|
715
762
|
? 'provider_free_probe_ok; paid entitlement or pilot invitation remains server-enforced'
|
|
716
763
|
: config
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* AISAC guardrails in Codex (spec aisac-engineering-extensions FR6, E6b).
|
|
4
|
+
*
|
|
5
|
+
* Codex loads session-flag hooks as `untrusted`. The user approves them once in
|
|
6
|
+
* `/hooks`, and Codex keys that approval, and the off switch, by position
|
|
7
|
+
* (`<event>:<group>:<handler>`) and command line (measured on 0.166.0). So:
|
|
8
|
+
* - the launch emits every catalog hook, in CODEX_HOOK_POSITIONS order, each with
|
|
9
|
+
* a byte-stable command: the Node executable, the dispatcher, the event and the
|
|
10
|
+
* hook id. No launch directory, version, locale or random value goes in it;
|
|
11
|
+
* - what changes per launch (the enabled hooks, the locale, the launch's state
|
|
12
|
+
* folder, the preferences file) reaches the hook process through the
|
|
13
|
+
* app-server's environment, which Codex hook processes inherit (measured on
|
|
14
|
+
* 0.166.0), as one JSON value in GUARDRAILS_SESSION_ENV.
|
|
15
|
+
* The launcher hands the monitor CODEX_HOOKS_ENV (enabled ids and locale). The
|
|
16
|
+
* monitor validates it against its own catalog and builds the flags and the
|
|
17
|
+
* session value itself, so the variable can never inject a command or a path.
|
|
18
|
+
* This module imports only the catalog and the TOML helper, so the monitor stays
|
|
19
|
+
* small.
|
|
20
|
+
*/
|
|
21
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
22
|
+
import { CODEX_HOOK_POSITIONS, HOOK_CATALOG, hookById } from './extensions-catalog.mjs';
|
|
23
|
+
import { tomlBasicString } from './codex-engineering.mjs';
|
|
24
|
+
|
|
25
|
+
/** Launcher to monitor: the enabled hook ids and the locale. */
|
|
26
|
+
export const CODEX_HOOKS_ENV = 'AISAC_EXTENSIONS_CODEX_HOOKS';
|
|
27
|
+
/** Monitor to hook processes, through the app-server's environment. */
|
|
28
|
+
export const GUARDRAILS_SESSION_ENV = 'AISAC_GUARDRAILS_SESSION';
|
|
29
|
+
const SCHEMA_VERSION = 1;
|
|
30
|
+
// Same names as the Claude session plugin (guardrails-plugin.mjs) and the
|
|
31
|
+
// preferences module (EXTENSIONS_FILE); a test keeps them equal.
|
|
32
|
+
const STATE_DIRECTORY = 'guardrails-state';
|
|
33
|
+
const PREFERENCES_FILE = 'extensions.json';
|
|
34
|
+
const MAX_VALUE_BYTES = 8 * 1024;
|
|
35
|
+
const MAX_PATH_LENGTH = 4096;
|
|
36
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
|
|
37
|
+
const LOCALES = /** @type {const} */ (['pt-BR', 'en']);
|
|
38
|
+
/** First Codex release whose hooks were measured with real turns (spike part 2). */
|
|
39
|
+
const MIN_CODEX_HOOKS_VERSION = /** @type {const} */ ([0, 166, 0]);
|
|
40
|
+
/** @type {Readonly<Record<string, number>>} */
|
|
41
|
+
const TIMEOUT_SECONDS = Object.freeze({
|
|
42
|
+
PreToolUse: 5,
|
|
43
|
+
PostToolUse: 5,
|
|
44
|
+
UserPromptSubmit: 5,
|
|
45
|
+
Stop: 5,
|
|
46
|
+
SessionStart: 10,
|
|
47
|
+
PreCompact: 10,
|
|
48
|
+
});
|
|
49
|
+
// Codex reports camelCase event names in hooks/list.
|
|
50
|
+
/** @type {Readonly<Record<string, string>>} */
|
|
51
|
+
const LISTED_EVENT = Object.freeze({
|
|
52
|
+
PreToolUse: 'preToolUse',
|
|
53
|
+
PostToolUse: 'postToolUse',
|
|
54
|
+
UserPromptSubmit: 'userPromptSubmit',
|
|
55
|
+
SessionStart: 'sessionStart',
|
|
56
|
+
PreCompact: 'preCompact',
|
|
57
|
+
Stop: 'stop',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** @typedef {import('./extensions-catalog.mjs').HookEvent} HookEvent */
|
|
61
|
+
/** @typedef {'pt-BR' | 'en'} HookLocale */
|
|
62
|
+
/** @typedef {{ node: string, dispatcher: string }} HookCommandPaths */
|
|
63
|
+
/** @typedef {{ schemaVersion: 1, hooks: string[], locale: HookLocale }} CodexHooksValue */
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {object} GuardrailsSession
|
|
66
|
+
* @property {1} schemaVersion
|
|
67
|
+
* @property {string[]} hooks The hooks the user had on when the session started.
|
|
68
|
+
* @property {HookLocale} locale
|
|
69
|
+
* @property {string} stateDir The launch's guardrail state folder, deleted on exit.
|
|
70
|
+
* @property {string} prefsPath The preferences file (FR2.2).
|
|
71
|
+
*/
|
|
72
|
+
/**
|
|
73
|
+
* @typedef {object} CodexHookHandler
|
|
74
|
+
* @property {HookEvent} event
|
|
75
|
+
* @property {string} id
|
|
76
|
+
* @property {string | undefined} matcher
|
|
77
|
+
* @property {string} command
|
|
78
|
+
* @property {number} timeout
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
82
|
+
function isRecord(value) {
|
|
83
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @param {unknown} value @returns {value is string} */
|
|
87
|
+
function isCleanAbsolutePath(value) {
|
|
88
|
+
return (
|
|
89
|
+
typeof value === 'string' &&
|
|
90
|
+
value.length > 0 &&
|
|
91
|
+
value.length <= MAX_PATH_LENGTH &&
|
|
92
|
+
!CONTROL_CHARACTERS.test(value) &&
|
|
93
|
+
isAbsolute(value) &&
|
|
94
|
+
resolve(value) === value
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {unknown} value @returns {value is HookLocale} */
|
|
99
|
+
function isLocale(value) {
|
|
100
|
+
return typeof value === 'string' && LOCALES.includes(/** @type {HookLocale} */ (value));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** POSIX single-quote quoting for the shell that runs the hook command. @param {string} value */
|
|
104
|
+
function shellQuote(value) {
|
|
105
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Ids that are Codex catalog hooks, in catalog order, without repeats. @param {unknown} ids */
|
|
109
|
+
function codexHookIds(ids) {
|
|
110
|
+
if (!Array.isArray(ids)) return null;
|
|
111
|
+
const wanted = new Set(ids);
|
|
112
|
+
if (ids.length !== wanted.size) return null;
|
|
113
|
+
for (const id of wanted)
|
|
114
|
+
if (typeof id !== 'string' || hookById(id)?.clients.includes('codex') !== true) return null;
|
|
115
|
+
return HOOK_CATALOG.map((entry) => entry.id).filter((id) => wanted.has(id));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether this Codex version gets the guardrails. A null or malformed version
|
|
120
|
+
* never does.
|
|
121
|
+
* @param {string | null | undefined} version
|
|
122
|
+
*/
|
|
123
|
+
export function supportsCodexHooks(version) {
|
|
124
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(version ?? '');
|
|
125
|
+
if (match === null) return false;
|
|
126
|
+
const parts = match.slice(1).map(Number);
|
|
127
|
+
for (let index = 0; index < MIN_CODEX_HOOKS_VERSION.length; index += 1) {
|
|
128
|
+
const part = /** @type {number} */ (parts[index]);
|
|
129
|
+
const minimum = MIN_CODEX_HOOKS_VERSION[index] ?? 0;
|
|
130
|
+
if (part !== minimum) return part > minimum;
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The catalog matcher of a hook's binding for one event. @param {string} id @param {string} event */
|
|
136
|
+
function bindingMatcher(id, event) {
|
|
137
|
+
return hookById(id)?.bindings.find((binding) => binding.event === event)?.matcher;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The byte-stable command of one Codex hook.
|
|
142
|
+
* @param {HookCommandPaths} paths @param {string} event @param {string} id
|
|
143
|
+
*/
|
|
144
|
+
export function codexHookCommand(paths, event, id) {
|
|
145
|
+
if (!isCleanAbsolutePath(paths.node) || !isCleanAbsolutePath(paths.dispatcher))
|
|
146
|
+
throw new Error('codex_hooks_path');
|
|
147
|
+
return [
|
|
148
|
+
shellQuote(paths.node),
|
|
149
|
+
shellQuote(paths.dispatcher),
|
|
150
|
+
'--client',
|
|
151
|
+
'codex',
|
|
152
|
+
'--event',
|
|
153
|
+
event,
|
|
154
|
+
'--hooks',
|
|
155
|
+
id,
|
|
156
|
+
].join(' ');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Every Codex hook handler, in position order: one matcher group per hook, so
|
|
161
|
+
* the user can switch each one off in `/hooks`.
|
|
162
|
+
* @param {HookCommandPaths} paths
|
|
163
|
+
* @returns {CodexHookHandler[]}
|
|
164
|
+
*/
|
|
165
|
+
export function codexHookHandlers(paths) {
|
|
166
|
+
return Object.entries(CODEX_HOOK_POSITIONS).flatMap(([event, ids]) =>
|
|
167
|
+
ids.map((id) => ({
|
|
168
|
+
event: /** @type {HookEvent} */ (event),
|
|
169
|
+
id,
|
|
170
|
+
matcher: bindingMatcher(id, event),
|
|
171
|
+
command: codexHookCommand(paths, event, id),
|
|
172
|
+
timeout: TIMEOUT_SECONDS[event] ?? 5,
|
|
173
|
+
})),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Session-only `-c` flags for the owned app-server, one per event.
|
|
179
|
+
* @param {HookCommandPaths} paths
|
|
180
|
+
* @returns {string[]}
|
|
181
|
+
*/
|
|
182
|
+
export function codexHookConfigArgs(paths) {
|
|
183
|
+
/** @type {Map<string, string[]>} */
|
|
184
|
+
const groups = new Map();
|
|
185
|
+
for (const handler of codexHookHandlers(paths)) {
|
|
186
|
+
const matcher =
|
|
187
|
+
handler.matcher === undefined ? '' : `matcher=${tomlBasicString(handler.matcher)},`;
|
|
188
|
+
const group = `{${matcher}hooks=[{type="command",command=${tomlBasicString(handler.command)},timeout=${handler.timeout}}]}`;
|
|
189
|
+
groups.set(handler.event, [...(groups.get(handler.event) ?? []), group]);
|
|
190
|
+
}
|
|
191
|
+
return [...groups].flatMap(([event, list]) => ['-c', `hooks.${event}=[${list.join(',')}]`]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The launcher's value for the monitor. Ids that are not Codex catalog hooks
|
|
196
|
+
* are dropped.
|
|
197
|
+
* @param {string[]} hooks @param {HookLocale} locale
|
|
198
|
+
*/
|
|
199
|
+
export function codexHooksValue(hooks, locale) {
|
|
200
|
+
const ids = codexHookIds([
|
|
201
|
+
...new Set(hooks.filter((id) => hookById(id)?.clients.includes('codex') === true)),
|
|
202
|
+
]);
|
|
203
|
+
return JSON.stringify({
|
|
204
|
+
schemaVersion: SCHEMA_VERSION,
|
|
205
|
+
hooks: ids ?? [],
|
|
206
|
+
locale: isLocale(locale) ? locale : 'en',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parse the monitor's value; null when absent or when no hook is on.
|
|
212
|
+
* @param {string | undefined} raw
|
|
213
|
+
* @returns {CodexHooksValue | null}
|
|
214
|
+
*/
|
|
215
|
+
export function parseCodexHooks(raw) {
|
|
216
|
+
if (raw === undefined || raw === '') return null;
|
|
217
|
+
if (Buffer.byteLength(raw, 'utf8') > MAX_VALUE_BYTES) throw new Error('codex_hooks_too_large');
|
|
218
|
+
/** @type {unknown} */
|
|
219
|
+
let value;
|
|
220
|
+
try {
|
|
221
|
+
value = JSON.parse(raw);
|
|
222
|
+
} catch {
|
|
223
|
+
throw new Error('codex_hooks_invalid');
|
|
224
|
+
}
|
|
225
|
+
if (
|
|
226
|
+
!isRecord(value) ||
|
|
227
|
+
Object.keys(value).sort().join(',') !== 'hooks,locale,schemaVersion' ||
|
|
228
|
+
value.schemaVersion !== SCHEMA_VERSION ||
|
|
229
|
+
!isLocale(value.locale)
|
|
230
|
+
)
|
|
231
|
+
throw new Error('codex_hooks_invalid');
|
|
232
|
+
const hooks = codexHookIds(value.hooks);
|
|
233
|
+
if (hooks === null) throw new Error('codex_hooks_invalid');
|
|
234
|
+
return hooks.length === 0 ? null : { schemaVersion: SCHEMA_VERSION, hooks, locale: value.locale };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The value the hook processes read, built by the monitor: the launcher's
|
|
239
|
+
* choices plus the launch's own state folder (deleted with the launch on exit)
|
|
240
|
+
* and the preferences file of the state root the pack was checked against.
|
|
241
|
+
* @param {CodexHooksValue} value
|
|
242
|
+
* @param {{ launchDirectory: string, stateRoot: string }} paths
|
|
243
|
+
*/
|
|
244
|
+
export function guardrailsSessionValue(value, paths) {
|
|
245
|
+
const parsed = parseGuardrailsSession(
|
|
246
|
+
JSON.stringify({
|
|
247
|
+
schemaVersion: SCHEMA_VERSION,
|
|
248
|
+
hooks: value.hooks,
|
|
249
|
+
locale: value.locale,
|
|
250
|
+
stateDir: join(paths.launchDirectory, STATE_DIRECTORY),
|
|
251
|
+
prefsPath: join(resolve(paths.stateRoot), PREFERENCES_FILE),
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
if (parsed === null) throw new Error('codex_hooks_session_invalid');
|
|
255
|
+
return JSON.stringify(parsed);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Parse the session value inside a hook process; null when absent or invalid,
|
|
260
|
+
* so the hook does nothing.
|
|
261
|
+
* @param {string | undefined} raw
|
|
262
|
+
* @returns {GuardrailsSession | null}
|
|
263
|
+
*/
|
|
264
|
+
export function parseGuardrailsSession(raw) {
|
|
265
|
+
if (raw === undefined || raw === '' || Buffer.byteLength(raw, 'utf8') > MAX_VALUE_BYTES)
|
|
266
|
+
return null;
|
|
267
|
+
/** @type {unknown} */
|
|
268
|
+
let value;
|
|
269
|
+
try {
|
|
270
|
+
value = JSON.parse(raw);
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
if (
|
|
275
|
+
!isRecord(value) ||
|
|
276
|
+
Object.keys(value).sort().join(',') !== 'hooks,locale,prefsPath,schemaVersion,stateDir' ||
|
|
277
|
+
value.schemaVersion !== SCHEMA_VERSION ||
|
|
278
|
+
!isLocale(value.locale) ||
|
|
279
|
+
!isCleanAbsolutePath(value.stateDir) ||
|
|
280
|
+
!isCleanAbsolutePath(value.prefsPath)
|
|
281
|
+
)
|
|
282
|
+
return null;
|
|
283
|
+
const hooks = codexHookIds(value.hooks);
|
|
284
|
+
if (hooks === null) return null;
|
|
285
|
+
return {
|
|
286
|
+
schemaVersion: SCHEMA_VERSION,
|
|
287
|
+
hooks,
|
|
288
|
+
locale: value.locale,
|
|
289
|
+
stateDir: value.stateDir,
|
|
290
|
+
prefsPath: value.prefsPath,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @typedef {object} CodexHooksSummary
|
|
296
|
+
* @property {{ total: number, trusted: number, untrusted: number, modified: number, disabled: number, missing: number }} handlers
|
|
297
|
+
* Every AISAC hook Codex was given, by what `hooks/list` reports.
|
|
298
|
+
* @property {string[]} on Enabled guardrails whose hooks are all trusted and on.
|
|
299
|
+
* @property {string[]} review Enabled guardrails with a hook that waits for review in `/hooks`.
|
|
300
|
+
* @property {string[]} off Enabled guardrails switched off in Codex (`/hooks` or config.toml).
|
|
301
|
+
* @property {string[]} missing Enabled guardrails Codex did not load.
|
|
302
|
+
*/
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* What `hooks/list` says about the AISAC hooks. A guardrail counts as on only
|
|
306
|
+
* when every one of its hooks is trusted and enabled (spec FR6: an untrusted
|
|
307
|
+
* guardrail is never reported as on).
|
|
308
|
+
* @param {unknown} listed The `hooks/list` result.
|
|
309
|
+
* @param {HookCommandPaths} paths
|
|
310
|
+
* @param {string[]} enabled The guardrails the user has on.
|
|
311
|
+
* @returns {CodexHooksSummary}
|
|
312
|
+
*/
|
|
313
|
+
export function summarizeCodexHooks(listed, paths, enabled) {
|
|
314
|
+
const rows = (isRecord(listed) && Array.isArray(listed.data) ? listed.data : []).flatMap(
|
|
315
|
+
(entry) => (isRecord(entry) && Array.isArray(entry.hooks) ? entry.hooks : []),
|
|
316
|
+
);
|
|
317
|
+
/** @type {Map<string, Record<string, unknown>>} */
|
|
318
|
+
const byCommand = new Map();
|
|
319
|
+
for (const row of rows)
|
|
320
|
+
if (isRecord(row) && row.source === 'sessionFlags' && typeof row.command === 'string')
|
|
321
|
+
byCommand.set(`${String(row.eventName)}\u0000${row.command}`, row);
|
|
322
|
+
const handlers = codexHookHandlers(paths);
|
|
323
|
+
const counts = {
|
|
324
|
+
total: handlers.length,
|
|
325
|
+
trusted: 0,
|
|
326
|
+
untrusted: 0,
|
|
327
|
+
modified: 0,
|
|
328
|
+
disabled: 0,
|
|
329
|
+
missing: 0,
|
|
330
|
+
};
|
|
331
|
+
/** @type {Map<string, 'on' | 'review' | 'off' | 'missing'>} */
|
|
332
|
+
const states = new Map();
|
|
333
|
+
const rank = { on: 0, off: 1, review: 2, missing: 3 };
|
|
334
|
+
for (const handler of handlers) {
|
|
335
|
+
const row = byCommand.get(`${LISTED_EVENT[handler.event]}\u0000${handler.command}`);
|
|
336
|
+
/** @type {'on' | 'review' | 'off' | 'missing'} */
|
|
337
|
+
let state;
|
|
338
|
+
if (row === undefined) {
|
|
339
|
+
counts.missing += 1;
|
|
340
|
+
state = 'missing';
|
|
341
|
+
} else if (row.trustStatus === 'untrusted' || row.trustStatus === 'modified') {
|
|
342
|
+
counts[row.trustStatus] += 1;
|
|
343
|
+
state = 'review';
|
|
344
|
+
} else if (row.trustStatus !== 'trusted' && row.trustStatus !== 'managed') {
|
|
345
|
+
counts.untrusted += 1;
|
|
346
|
+
state = 'review';
|
|
347
|
+
} else if (row.enabled === false) {
|
|
348
|
+
counts.trusted += 1;
|
|
349
|
+
counts.disabled += 1;
|
|
350
|
+
state = 'off';
|
|
351
|
+
} else {
|
|
352
|
+
counts.trusted += 1;
|
|
353
|
+
state = 'on';
|
|
354
|
+
}
|
|
355
|
+
const previous = states.get(handler.id);
|
|
356
|
+
if (previous === undefined || rank[state] > rank[previous]) states.set(handler.id, state);
|
|
357
|
+
}
|
|
358
|
+
const ids =
|
|
359
|
+
codexHookIds([
|
|
360
|
+
...new Set(enabled.filter((id) => hookById(id)?.clients.includes('codex') === true)),
|
|
361
|
+
]) ?? [];
|
|
362
|
+
return {
|
|
363
|
+
handlers: counts,
|
|
364
|
+
on: ids.filter((id) => states.get(id) === 'on'),
|
|
365
|
+
review: ids.filter((id) => states.get(id) === 'review'),
|
|
366
|
+
off: ids.filter((id) => states.get(id) === 'off'),
|
|
367
|
+
missing: ids.filter((id) => states.get(id) === 'missing'),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The doctor's one-word state for the AISAC hooks in Codex, worst first: a
|
|
373
|
+
* guardrail waiting for review outranks one Codex did not load, which outranks
|
|
374
|
+
* one switched off in Codex.
|
|
375
|
+
* @param {CodexHooksSummary} summary
|
|
376
|
+
* @returns {'needs_review' | 'not_loaded' | 'off_in_codex' | 'on'}
|
|
377
|
+
*/
|
|
378
|
+
export function codexHooksStatus(summary) {
|
|
379
|
+
if (summary.review.length > 0) return 'needs_review';
|
|
380
|
+
if (summary.missing.length > 0) return 'not_loaded';
|
|
381
|
+
if (summary.off.length > 0) return 'off_in_codex';
|
|
382
|
+
return 'on';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** @param {number} count */
|
|
386
|
+
function guardrails(count) {
|
|
387
|
+
return `${count} guardrail${count === 1 ? '' : 's'}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The launch line for Codex. With no summary (the listing failed), it says the
|
|
392
|
+
* guardrails were handed to Codex and names the review, never "on".
|
|
393
|
+
* @param {number} enabledCount
|
|
394
|
+
* @param {CodexHooksSummary | null} summary
|
|
395
|
+
*/
|
|
396
|
+
export function codexHooksLine(enabledCount, summary) {
|
|
397
|
+
const change = '(aisac hooks to change them)';
|
|
398
|
+
if (summary === null)
|
|
399
|
+
return `AISAC Engineering: ${guardrails(enabledCount)} loaded for Codex; Codex runs them only after you approve the AISAC hooks once in /hooks ${change}.`;
|
|
400
|
+
const total =
|
|
401
|
+
summary.on.length + summary.review.length + summary.off.length + summary.missing.length;
|
|
402
|
+
if (summary.on.length === total) return `AISAC Engineering: ${guardrails(total)} on ${change}.`;
|
|
403
|
+
const parts = [`AISAC Engineering: ${summary.on.length} of ${guardrails(total)} on`];
|
|
404
|
+
if (summary.review.length > 0)
|
|
405
|
+
parts.push(
|
|
406
|
+
`${summary.review.length} wait for your review: open /hooks in Codex and approve the AISAC hooks`,
|
|
407
|
+
);
|
|
408
|
+
if (summary.off.length > 0) parts.push(`${summary.off.length} switched off in Codex (/hooks)`);
|
|
409
|
+
if (summary.missing.length > 0) parts.push(`${summary.missing.length} not loaded by Codex`);
|
|
410
|
+
return `${parts.join('; ')} ${change}.`;
|
|
411
|
+
}
|
|
@@ -499,15 +499,22 @@ async function request(child, id, method, params) {
|
|
|
499
499
|
return message.result;
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
-
/**
|
|
503
|
-
|
|
504
|
-
|
|
502
|
+
/**
|
|
503
|
+
* Run one short-lived, initialized app-server for a preflight read, then stop it
|
|
504
|
+
* and its process group. `body` gets the child and whether a signal cancelled it.
|
|
505
|
+
* @template T
|
|
506
|
+
* @param {{ executable: string, cwd: string, profile: string|undefined, environment: NodeJS.ProcessEnv, configArgs?: string[] }} options
|
|
507
|
+
* @param {(child: OwnedChild, cancelled: () => boolean) => Promise<T>} body
|
|
508
|
+
* @returns {Promise<T>}
|
|
509
|
+
*/
|
|
510
|
+
async function withPreflightServer(options, body) {
|
|
511
|
+
validateProfile(options.profile);
|
|
505
512
|
const args = [];
|
|
506
|
-
if (profile !== undefined) args.push('--profile', profile);
|
|
507
|
-
args.push('app-server', '--listen', 'stdio://');
|
|
508
|
-
const child = spawn(executable, args, {
|
|
509
|
-
cwd,
|
|
510
|
-
env: childEnvironment(environment),
|
|
513
|
+
if (options.profile !== undefined) args.push('--profile', options.profile);
|
|
514
|
+
args.push(...(options.configArgs ?? []), 'app-server', '--listen', 'stdio://');
|
|
515
|
+
const child = spawn(options.executable, args, {
|
|
516
|
+
cwd: options.cwd,
|
|
517
|
+
env: childEnvironment(options.environment),
|
|
511
518
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
512
519
|
detached: process.platform !== 'win32',
|
|
513
520
|
});
|
|
@@ -531,32 +538,7 @@ export async function readEffectiveWritableRoots(executable, cwd, profile, envir
|
|
|
531
538
|
} catch {
|
|
532
539
|
fail('preflight_failed');
|
|
533
540
|
}
|
|
534
|
-
|
|
535
|
-
const rawConfig = result.config;
|
|
536
|
-
if (
|
|
537
|
-
rawConfig === undefined ||
|
|
538
|
-
rawConfig === null ||
|
|
539
|
-
typeof rawConfig !== 'object' ||
|
|
540
|
-
Array.isArray(rawConfig)
|
|
541
|
-
)
|
|
542
|
-
fail('preflight_roots_unavailable');
|
|
543
|
-
const config = /** @type {JsonObject} */ (rawConfig);
|
|
544
|
-
const rawSandbox = config.sandbox_workspace_write;
|
|
545
|
-
if (cancelled) fail('preflight_cancelled');
|
|
546
|
-
if (rawSandbox === undefined || rawSandbox === null) return [];
|
|
547
|
-
if (typeof rawSandbox !== 'object' || Array.isArray(rawSandbox))
|
|
548
|
-
fail('preflight_roots_unavailable');
|
|
549
|
-
const sandbox = /** @type {JsonObject} */ (rawSandbox);
|
|
550
|
-
const roots = sandbox.writable_roots;
|
|
551
|
-
if (cancelled) fail('preflight_cancelled');
|
|
552
|
-
if (roots === undefined || roots === null) return [];
|
|
553
|
-
if (
|
|
554
|
-
!Array.isArray(roots) ||
|
|
555
|
-
roots.some((path) => typeof path !== 'string' || !isAbsolute(path))
|
|
556
|
-
)
|
|
557
|
-
fail('preflight_roots_unavailable');
|
|
558
|
-
if (cancelled) fail('preflight_cancelled');
|
|
559
|
-
return deduplicate(roots);
|
|
541
|
+
return await body(child, () => cancelled);
|
|
560
542
|
} finally {
|
|
561
543
|
process.removeListener('SIGINT', cancel);
|
|
562
544
|
process.removeListener('SIGTERM', cancel);
|
|
@@ -565,6 +547,57 @@ export async function readEffectiveWritableRoots(executable, cwd, profile, envir
|
|
|
565
547
|
}
|
|
566
548
|
}
|
|
567
549
|
|
|
550
|
+
/** @param {string} executable @param {string} cwd @param {string|undefined} profile @param {NodeJS.ProcessEnv} environment */
|
|
551
|
+
export async function readEffectiveWritableRoots(executable, cwd, profile, environment) {
|
|
552
|
+
return withPreflightServer(
|
|
553
|
+
{ executable, cwd, profile, environment },
|
|
554
|
+
async (child, cancelled) => {
|
|
555
|
+
const result = await request(child, 1, 'config/read', { cwd, includeLayers: true });
|
|
556
|
+
const rawConfig = result.config;
|
|
557
|
+
if (
|
|
558
|
+
rawConfig === undefined ||
|
|
559
|
+
rawConfig === null ||
|
|
560
|
+
typeof rawConfig !== 'object' ||
|
|
561
|
+
Array.isArray(rawConfig)
|
|
562
|
+
)
|
|
563
|
+
fail('preflight_roots_unavailable');
|
|
564
|
+
const config = /** @type {JsonObject} */ (rawConfig);
|
|
565
|
+
const rawSandbox = config.sandbox_workspace_write;
|
|
566
|
+
if (cancelled()) fail('preflight_cancelled');
|
|
567
|
+
if (rawSandbox === undefined || rawSandbox === null) return [];
|
|
568
|
+
if (typeof rawSandbox !== 'object' || Array.isArray(rawSandbox))
|
|
569
|
+
fail('preflight_roots_unavailable');
|
|
570
|
+
const sandbox = /** @type {JsonObject} */ (rawSandbox);
|
|
571
|
+
const roots = sandbox.writable_roots;
|
|
572
|
+
if (cancelled()) fail('preflight_cancelled');
|
|
573
|
+
if (roots === undefined || roots === null) return [];
|
|
574
|
+
if (
|
|
575
|
+
!Array.isArray(roots) ||
|
|
576
|
+
roots.some((path) => typeof path !== 'string' || !isAbsolute(path))
|
|
577
|
+
)
|
|
578
|
+
fail('preflight_roots_unavailable');
|
|
579
|
+
if (cancelled()) fail('preflight_cancelled');
|
|
580
|
+
return deduplicate(roots);
|
|
581
|
+
},
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* What Codex reports for a set of session-flag hooks (`hooks/list` for one
|
|
587
|
+
* folder), read from a short-lived app-server started with those flags and the
|
|
588
|
+
* user's own CODEX_HOME, where the trust records live. It writes no config and
|
|
589
|
+
* no trust record (spec aisac-engineering-extensions FR6, part 1 Q4).
|
|
590
|
+
* @param {{ executable: string, cwd: string, profile: string|undefined, environment: NodeJS.ProcessEnv, configArgs: string[] }} options
|
|
591
|
+
* @returns {Promise<JsonObject>}
|
|
592
|
+
*/
|
|
593
|
+
export async function readCodexHookList(options) {
|
|
594
|
+
return withPreflightServer(options, async (child, cancelled) => {
|
|
595
|
+
const result = await request(child, 1, 'hooks/list', { cwds: [options.cwd] });
|
|
596
|
+
if (cancelled()) fail('preflight_cancelled');
|
|
597
|
+
return result;
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
568
601
|
/** @param {LaunchPlan} plan @param {string[]} configuredRoots */
|
|
569
602
|
export function makeCodexLaunchEnvelope(plan, configuredRoots) {
|
|
570
603
|
const roots = deduplicate([...configuredRoots, ...plan.additionalRoots]);
|