@brunobracaioli/aibrain 0.4.0-beta.12 → 0.4.0-beta.14
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/claude-observation.mjs +1 -1
- package/dist/cli.mjs +69 -8
- package/dist/codex-hooks.mjs +411 -0
- package/dist/codex-permissions.mjs +67 -34
- package/dist/engineering-init.mjs +613 -0
- package/dist/engineering-pack.mjs +8 -1
- 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
|
@@ -30,6 +30,19 @@ roles. Nothing is written to `~/.codex`, `~/.agents` or the project, and the
|
|
|
30
30
|
session's folder is deleted on exit. An older Codex starts without the pack and
|
|
31
31
|
says `codex_outdated`.
|
|
32
32
|
|
|
33
|
+
`aisac init [dir]` (licensed accounts, client `0.4.0-beta.14` or later) writes the
|
|
34
|
+
AISAC skeleton into a new or existing project: `CLAUDE.md`, `AGENTS.md`,
|
|
35
|
+
`NOTES.example.md`, the `docs/` tree with its templates, a Claude Code rules file,
|
|
36
|
+
a CI workflow with pinned actions, Dependabot, `.gitignore` and the workloop
|
|
37
|
+
template. The templates travel inside the licensed pack, fetched and verified like
|
|
38
|
+
a launch; the command writes only files that are missing, never follows a symbolic
|
|
39
|
+
link, never touches `.git/` and prints every file it wrote or skipped. Options:
|
|
40
|
+
`--stack node|python|none` (otherwise detected from `package.json`, `pyproject.toml`
|
|
41
|
+
or `requirements.txt`), `--lang pt-BR|en`, `--dry-run`, `--json`. Exit codes: `0`
|
|
42
|
+
when every file exists or was written, `1` for a license, pack or write failure
|
|
43
|
+
(nothing is written before the pack is verified), `2` for a usage error. Until the
|
|
44
|
+
pack carries the templates, the command says so and writes nothing.
|
|
45
|
+
|
|
33
46
|
Licensed `aisac claude` sessions also load guardrail hooks as the session plugin
|
|
34
47
|
`aisac-guardrails`.
|
|
35
48
|
- **Default on:** `secrets-guard` refuses secret files by name. Four more hooks
|
|
@@ -41,6 +54,12 @@ Licensed `aisac claude` sessions also load guardrail hooks as the session plugin
|
|
|
41
54
|
them from inside a session.
|
|
42
55
|
- **Code:** it ships in this package and runs locally. Nothing comes from the
|
|
43
56
|
server.
|
|
57
|
+
- **Codex (0.166.0 or later):** licensed `aisac codex` sessions get the same hooks
|
|
58
|
+
from session flags. Codex runs them only after you approve the AISAC hooks once
|
|
59
|
+
in `/hooks`, and you can switch each one off there too. The launch line says how
|
|
60
|
+
many are on and how many wait for that review; `aisac doctor` reports the same
|
|
61
|
+
under `extensions.codexHooks`. A new Node path (for example after an nvm or
|
|
62
|
+
Homebrew upgrade) changes the hook commands, so Codex asks for the review again.
|
|
44
63
|
|
|
45
64
|
Licensed sessions of `aisac claude` and `aisac codex` can also load reviewed MCP
|
|
46
65
|
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.14';
|
|
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
|
@@ -37,11 +37,14 @@ import {
|
|
|
37
37
|
resolvePackLocale,
|
|
38
38
|
writeStoredLocale,
|
|
39
39
|
} from './engineering-pack.mjs';
|
|
40
|
+
import { initCommand } from './engineering-init.mjs';
|
|
40
41
|
import { extensionsSummary, hooksCommand, mcpCommand } from './extensions-cli.mjs';
|
|
41
42
|
import { sessionServers } from './mcp-servers.mjs';
|
|
42
43
|
import { anyFormatter } from './guardrails/format.mjs';
|
|
44
|
+
import { readCodexGuardrails } from './guardrails-plugin.mjs';
|
|
45
|
+
import { codexHooksStatus, supportsCodexHooks } from './codex-hooks.mjs';
|
|
43
46
|
|
|
44
|
-
const VERSION = '0.4.0-beta.
|
|
47
|
+
const VERSION = '0.4.0-beta.14';
|
|
45
48
|
// The command is `aisac`; `aibrain` is an alias installed from the same entry.
|
|
46
49
|
const COMMAND = 'aisac';
|
|
47
50
|
const ENDPOINT = 'https://mcp.ai-brain.aisac.cloud/mcp';
|
|
@@ -50,7 +53,7 @@ const LEGACY_DISCLOSURE_VERSION = 'aibrain-paid-context-v1-2026-10-09';
|
|
|
50
53
|
|
|
51
54
|
function usage() {
|
|
52
55
|
console.log(
|
|
53
|
-
`${COMMAND} ${VERSION}\n\nUsage:\n ${COMMAND} setup [--client claude|codex|both] [--context-profile upstream|conservative]\n [--compact-at 1..95] [--keep-threshold 0..1] [--preserve-recent 1..100]\n [--min-reduction 0..1] [--truncate-head-chars 0..4096]\n [--max-state-tokens 1000..25000] [--max-request-tokens 1000..30000]\n [--tool-policy compatible|read_only] [--goal TEXT]\n ${COMMAND} doctor [--json]\n ${COMMAND} status [--json]\n ${COMMAND} disable\n ${COMMAND} setup --undo\n ${COMMAND} claude [-- native-args...]\n ${COMMAND} codex [-- native-args...]\n\nPolicy notes:\n Automatic monitoring asks for compaction at compact-at percent; state/request\n limits are estimated upper bounds and do not override server quotas.\n min-reduction, truncate-head-chars and tool-policy apply only to Claude;\n Codex keeps native compaction and same-session recovery.`,
|
|
56
|
+
`${COMMAND} ${VERSION}\n\nUsage:\n ${COMMAND} setup [--client claude|codex|both] [--context-profile upstream|conservative]\n [--compact-at 1..95] [--keep-threshold 0..1] [--preserve-recent 1..100]\n [--min-reduction 0..1] [--truncate-head-chars 0..4096]\n [--max-state-tokens 1000..25000] [--max-request-tokens 1000..30000]\n [--tool-policy compatible|read_only] [--goal TEXT]\n ${COMMAND} doctor [--json]\n ${COMMAND} status [--json]\n ${COMMAND} disable\n ${COMMAND} setup --undo\n ${COMMAND} claude [-- native-args...]\n ${COMMAND} codex [-- native-args...]\n ${COMMAND} init [dir] [--lang pt-BR|en] [--stack node|python|none] [--dry-run] [--json]\n\nPolicy notes:\n Automatic monitoring asks for compaction at compact-at percent; state/request\n limits are estimated upper bounds and do not override server quotas.\n min-reduction, truncate-head-chars and tool-policy apply only to Claude;\n Codex keeps native compaction and same-session recovery.`,
|
|
54
57
|
);
|
|
55
58
|
console.log(
|
|
56
59
|
'Clients: setup without --client configures the clients installed on PATH (Claude Code, Codex, or both).',
|
|
@@ -65,7 +68,10 @@ function usage() {
|
|
|
65
68
|
'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
69
|
);
|
|
67
70
|
console.log(
|
|
68
|
-
'
|
|
71
|
+
'Project skeleton: `aisac init [dir]` (licensed accounts) writes the AISAC skeleton from the pack into a new or existing project, only the files that are missing; `--dry-run` lists them, `--stack` picks node|python|none when detection is not enough.',
|
|
72
|
+
);
|
|
73
|
+
console.log(
|
|
74
|
+
'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
75
|
);
|
|
70
76
|
console.log(
|
|
71
77
|
'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 +607,48 @@ async function setup(args) {
|
|
|
601
607
|
if (line !== null) console.log(line);
|
|
602
608
|
}
|
|
603
609
|
|
|
604
|
-
/**
|
|
605
|
-
|
|
610
|
+
/**
|
|
611
|
+
* Whether Codex runs the AISAC hooks the user has on, from Codex's own
|
|
612
|
+
* `hooks/list` in this folder (FR6, FR8, E6b). No session starts, and Codex
|
|
613
|
+
* writes no config or trust record. Codex is not asked when the last launch had
|
|
614
|
+
* no license, because the launcher then hands Codex no hook.
|
|
615
|
+
* @param {{ executable: string | null, version: string | null | undefined, licensed: boolean }} codex
|
|
616
|
+
*/
|
|
617
|
+
async function doctorCodexHooks({ executable, version, licensed }) {
|
|
618
|
+
if (executable === null) return { status: 'codex_not_found' };
|
|
619
|
+
if (!supportsCodexHooks(version)) return { status: 'codex_version_unsupported' };
|
|
620
|
+
if (!licensed) return { status: 'not_licensed' };
|
|
621
|
+
const read = await readCodexGuardrails({
|
|
622
|
+
root: stateRoot(),
|
|
623
|
+
packageRoot: PACKAGE_ROOT,
|
|
624
|
+
executable,
|
|
625
|
+
cwd: process.cwd(),
|
|
626
|
+
profile: undefined,
|
|
627
|
+
environment: process.env,
|
|
628
|
+
}).catch(() => undefined);
|
|
629
|
+
if (read === undefined) return { status: 'list_failed' };
|
|
630
|
+
if (read === null) return { status: 'none_enabled' };
|
|
631
|
+
const { summary } = read;
|
|
632
|
+
if (summary === null) return { status: 'list_failed' };
|
|
633
|
+
const status = codexHooksStatus(summary);
|
|
634
|
+
return {
|
|
635
|
+
status,
|
|
636
|
+
handlers: summary.handlers,
|
|
637
|
+
review: summary.review,
|
|
638
|
+
off: summary.off,
|
|
639
|
+
missing: summary.missing,
|
|
640
|
+
...(summary.review.length > 0
|
|
641
|
+
? { remedy: 'Run aisac codex, open /hooks and approve the AISAC hooks.' }
|
|
642
|
+
: {}),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Extension state plus whether `format-on-edit` has a formatter in this folder,
|
|
648
|
+
* and whether Codex runs the hooks (FR8).
|
|
649
|
+
* @param {{ executable: string | null, version: string | null | undefined }} codex
|
|
650
|
+
*/
|
|
651
|
+
async function doctorExtensions(codex) {
|
|
606
652
|
const summary = await extensionsSummary(stateRoot());
|
|
607
653
|
// A token server is skipped at launch when its variable is unset (names only).
|
|
608
654
|
const mcpMissingEnv = sessionServers(summary.mcp, 'claude', process.env).missingEnv.map(
|
|
@@ -613,7 +659,9 @@ async function doctorExtensions() {
|
|
|
613
659
|
: (await anyFormatter(process.cwd()))
|
|
614
660
|
? 'ready'
|
|
615
661
|
: 'no_formatter_found';
|
|
616
|
-
|
|
662
|
+
const licensed = (await readLastLaunch(stateRoot()))?.state === 'loaded';
|
|
663
|
+
const codexHooks = await doctorCodexHooks({ ...codex, licensed });
|
|
664
|
+
return { ...summary, formatOnEdit, mcpMissingEnv, codexHooks };
|
|
617
665
|
}
|
|
618
666
|
|
|
619
667
|
/** @param {boolean} json */
|
|
@@ -710,7 +758,10 @@ async function doctor(json) {
|
|
|
710
758
|
},
|
|
711
759
|
engineering,
|
|
712
760
|
engineeringPack: await engineeringPackState(),
|
|
713
|
-
extensions: await doctorExtensions(
|
|
761
|
+
extensions: await doctorExtensions({
|
|
762
|
+
executable: native.codex,
|
|
763
|
+
version: codexCompatibility?.version,
|
|
764
|
+
}),
|
|
714
765
|
paidOrPilot: probe?.ok
|
|
715
766
|
? 'provider_free_probe_ok; paid entitlement or pilot invitation remains server-enforced'
|
|
716
767
|
: config
|
|
@@ -798,6 +849,14 @@ async function main(argv) {
|
|
|
798
849
|
}
|
|
799
850
|
if (command === 'hooks') return hooksCommand(rest);
|
|
800
851
|
if (command === 'mcp') return mcpCommand(rest);
|
|
852
|
+
if (command === 'init')
|
|
853
|
+
return initCommand(rest, {
|
|
854
|
+
packageRoot: PACKAGE_ROOT,
|
|
855
|
+
readConfig,
|
|
856
|
+
stateRoot,
|
|
857
|
+
cwd: process.cwd(),
|
|
858
|
+
env: process.env,
|
|
859
|
+
});
|
|
801
860
|
if (command === 'disable') {
|
|
802
861
|
if (rest.length > 0) throw new Error('disable_does_not_accept_options');
|
|
803
862
|
return removeOwnedSetup();
|
|
@@ -850,5 +909,7 @@ main(cliArgs).catch((error) => {
|
|
|
850
909
|
const guide = /** @type {{ guide?: unknown }} */ (error ?? {}).guide;
|
|
851
910
|
if (typeof guide === 'string') console.error(`\n${guide}`);
|
|
852
911
|
}
|
|
853
|
-
|
|
912
|
+
// Usage errors of `aisac init` exit 2 (spec FR3); everything else exits 1.
|
|
913
|
+
const exitCode = /** @type {{ exitCode?: unknown }} */ (error ?? {}).exitCode;
|
|
914
|
+
process.exitCode = exitCode === 2 ? 2 : 1;
|
|
854
915
|
});
|
|
@@ -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
|
+
}
|