javi-forge 1.35.1 → 1.36.0

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,531 @@
1
+ /**
2
+ * Codex PreToolUse ownership manager (agent-agnostic slice 2). Installs the
3
+ * SAME shipped SkillGuard `.mjs` asset as a Codex `PreToolUse` hook by writing
4
+ * `~/.codex/hooks.json` + setting `[features] hooks = true` in
5
+ * `~/.codex/config.toml`, through the identical secure-fs transaction the Claude
6
+ * installer uses (no weaker path). It NEVER modifies the guard asset, the pure
7
+ * `evaluate*` engine, or Claude's observable behavior.
8
+ *
9
+ * TRUST (highest-risk surface — engram id 15743, codex-cli 0.147.0, verified
10
+ * live 2026-08-18): codex hooks are stable + default-ON, but each hook needs a
11
+ * `trusted_hash` recorded in `config.toml` under
12
+ * `[hooks.state."<abs-hook-path>:pre_tool_use:0:0"]`; an UNTRUSTED hook is
13
+ * SILENTLY SKIPPED unless `--dangerously-bypass-hook-trust`. There is NO
14
+ * `codex hooks trust` subcommand (confirmed: `codex --help` has no `hooks`
15
+ * command). So we DO NOT compute-and-write a trusted_hash we cannot prove
16
+ * reproducible (a wrong-but-present hash would leave the hook skipped while
17
+ * making doctor believe it is trusted — the exact fail-open theater this arc
18
+ * exists to kill). Instead: install writes the files + REPORTS the trust step,
19
+ * and the doctor DETECTS the missing trust entry and reports `blocked`
20
+ * (untrusted = NOT running).
21
+ *
22
+ * STALE-HASH INVALIDATION: the trust key path is STABLE across upgrades, so a
23
+ * rewrite of hooks.json (asset/command/timeout change) leaves the recorded
24
+ * `trusted_hash` stale — Codex silently skips the hook while the header
25
+ * persists (doctor would wrongly stay `trusted`). So whenever install/repair
26
+ * REWRITES the managed hooks.json, it REMOVES our `[hooks.state."<hooksFile>:*"]`
27
+ * table(s) in the same transactional config write (foreign rows untouched),
28
+ * reverting the doctor to `untrusted → blocked` until the user re-approves. An
29
+ * idempotent no-op install (unchanged hook content) never touches the table.
30
+ */
31
+ import os from "node:os";
32
+ import path from "node:path";
33
+ import { CLAUDE_HOOK_ASSETS_DIR } from "../constants.js";
34
+ import { ASSET_NAME } from "./__fixtures__/claude-hook-ownership.js";
35
+ import { classifyAssetState, detectNode, probeNodeOnPath, } from "./claude-hook-manager.js";
36
+ import { isPlainObject, validateSettingsShape, } from "./claude-hook-settings.js";
37
+ import { safeReadFile } from "./safe-read.js";
38
+ import { selectSecureFs } from "./secure-fs-posix.js";
39
+ import { runTransaction, } from "./secure-fs-transaction.js";
40
+ const NODE_MINIMUM_MAJOR = 22;
41
+ const CODEX_TIMEOUT = 30;
42
+ /**
43
+ * Matcher covering the two tools the guard must gate under Codex: `Bash`
44
+ * (sensitive-command protection, drop-in) and `apply_patch` (managed-config
45
+ * file-write protection, the S1 shim). PreToolUse fires on all tools; the
46
+ * matcher narrows delivery to what we evaluate. (Confirmed against a real
47
+ * codex-cli 0.147.0 run during S2.8.)
48
+ */
49
+ const CODEX_MATCHER = "Bash|apply_patch";
50
+ const READ_OPTS = {
51
+ maxBytes: 1024 * 1024,
52
+ hardRejectOverBytes: 1024 * 1024,
53
+ maxLineLength: Number.POSITIVE_INFINITY,
54
+ };
55
+ /** The shipped, in-package guard asset the Codex hook references by ABSOLUTE path. */
56
+ export const SHIPPED_CODEX_ASSET = path.join(CLAUDE_HOOK_ASSETS_DIR, ASSET_NAME);
57
+ /** Resolve `~/.codex/{hooks.json,config.toml}` for a given home directory. */
58
+ export function codexConfigPaths(homeDir) {
59
+ const codexDir = path.join(homeDir, ".codex");
60
+ return {
61
+ codexDir,
62
+ hooksFile: path.join(codexDir, "hooks.json"),
63
+ configFile: path.join(codexDir, "config.toml"),
64
+ };
65
+ }
66
+ /** The exact `command` string the managed Codex hook runs (single-string form). */
67
+ export function expectedCodexCommand(assetPath) {
68
+ return `node ${assetPath} --agent=codex`;
69
+ }
70
+ /** The interactive step that establishes hook trust (there is no non-interactive subcommand). */
71
+ export function codexTrustGrantCommand(hooksFile) {
72
+ return `run codex once and APPROVE the hook when prompted (records trust for ${hooksFile} in ~/.codex/config.toml), or pass --dangerously-bypass-hook-trust for vetted automation`;
73
+ }
74
+ // =============================================================================
75
+ // Pure config.toml helpers (minimal, targeted, fail-closed) — no TOML dep
76
+ // =============================================================================
77
+ const TABLE_HEADER = /^\s*\[([^[\]]+)\]\s*(?:#.*)?$/;
78
+ const HOOKS_LINE = /^\s*hooks\s*=\s*(true|false)\b/;
79
+ /** Read the `[features] hooks` flag: "true" | "false" | "absent". */
80
+ export function parseFeaturesHooks(text) {
81
+ let inFeatures = false;
82
+ for (const line of text.split(/\r?\n/)) {
83
+ const header = TABLE_HEADER.exec(line);
84
+ if (header) {
85
+ inFeatures = header[1].trim() === "features";
86
+ continue;
87
+ }
88
+ if (inFeatures) {
89
+ const m = HOOKS_LINE.exec(line);
90
+ if (m)
91
+ return m[1] === "true" ? "true" : "false";
92
+ }
93
+ }
94
+ return "absent";
95
+ }
96
+ /**
97
+ * True when `config.toml` records a trust table for THIS hook path, i.e. a
98
+ * `[hooks.state."<hooksFile>:pre_tool_use:0:0"]` header. Fail-closed: a trust
99
+ * entry for a different path does not count.
100
+ *
101
+ * NOTE (fail-open the arc kills): presence of the header is NOT proof the hook
102
+ * is still trusted — Codex records a `trusted_hash` under it, and a hook whose
103
+ * content was rewritten (e.g. an asset/command/timeout upgrade) has a STALE hash
104
+ * → Codex silently skips it and re-prompts. We cannot recompute Codex's hash to
105
+ * compare here, so instead the installer INVALIDATES this table whenever it
106
+ * rewrites the managed hooks.json (see `removeCodexTrustEntries`), reverting the
107
+ * doctor to `untrusted → blocked` until the user re-approves in codex.
108
+ */
109
+ export function hasCodexTrustEntry(text, hooksFile) {
110
+ const needle = `${hooksFile}:pre_tool_use:0:0`;
111
+ for (const line of text.split(/\r?\n/)) {
112
+ const header = TABLE_HEADER.exec(line);
113
+ if (!header)
114
+ continue;
115
+ const inner = header[1].trim();
116
+ if (inner.startsWith("hooks.state.") && inner.includes(needle))
117
+ return true;
118
+ }
119
+ return false;
120
+ }
121
+ /**
122
+ * Remove every `[hooks.state."<hooksFile>:*"]` table (header + body lines) keyed
123
+ * on OUR managed hooks.json path, preserving all other content — including
124
+ * FOREIGN `hooks.state` rows for other hooks files. Used to invalidate a now-
125
+ * stale `trusted_hash` when the managed hooks.json content is rewritten: the
126
+ * trust-key path is stable across upgrades, so a rewritten hook keeps its old
127
+ * (now wrong) recorded hash and would be silently skipped by Codex while the
128
+ * header persisted. Dropping the table forces the doctor back to `untrusted`
129
+ * until the user re-approves the hook in codex.
130
+ */
131
+ export function removeCodexTrustEntries(text, hooksFile) {
132
+ // Match the quoted path prefix so a path that merely has ours as a string
133
+ // prefix (a different file) is never removed.
134
+ const needle = `"${hooksFile}:`;
135
+ const lines = text.split(/\r?\n/);
136
+ const kept = [];
137
+ let dropping = false;
138
+ for (const line of lines) {
139
+ const header = TABLE_HEADER.exec(line);
140
+ if (header) {
141
+ const inner = header[1].trim();
142
+ dropping = inner.startsWith("hooks.state.") && inner.includes(needle);
143
+ if (dropping)
144
+ continue;
145
+ kept.push(line);
146
+ continue;
147
+ }
148
+ if (dropping)
149
+ continue;
150
+ kept.push(line);
151
+ }
152
+ return kept.join("\n");
153
+ }
154
+ /**
155
+ * Ensure `[features] hooks = true`, preserving all other content and idempotent
156
+ * when already true. Only ever INSERTS a line or flips a `hooks = false` inside
157
+ * `[features]`, so it can never corrupt unrelated TOML.
158
+ */
159
+ export function mergeFeaturesHooksTrue(text) {
160
+ const current = parseFeaturesHooks(text);
161
+ if (current === "true")
162
+ return text;
163
+ const lines = text.split(/\r?\n/);
164
+ // Flip an existing `hooks = false` inside [features].
165
+ if (current === "false") {
166
+ let inFeatures = false;
167
+ for (let i = 0; i < lines.length; i++) {
168
+ const header = TABLE_HEADER.exec(lines[i]);
169
+ if (header) {
170
+ inFeatures = header[1].trim() === "features";
171
+ continue;
172
+ }
173
+ if (inFeatures && HOOKS_LINE.exec(lines[i])) {
174
+ lines[i] = "hooks = true";
175
+ return lines.join("\n");
176
+ }
177
+ }
178
+ }
179
+ // [features] exists but has no hooks line → insert right after the header.
180
+ for (let i = 0; i < lines.length; i++) {
181
+ const header = TABLE_HEADER.exec(lines[i]);
182
+ if (header && header[1].trim() === "features") {
183
+ lines.splice(i + 1, 0, "hooks = true");
184
+ return lines.join("\n");
185
+ }
186
+ }
187
+ // No [features] table at all → append one.
188
+ const base = text.length === 0 ? "" : text.endsWith("\n") ? text : `${text}\n`;
189
+ return `${base}[features]\nhooks = true\n`;
190
+ }
191
+ // =============================================================================
192
+ // hooks.json classification (reuses the settings-schema validators)
193
+ // =============================================================================
194
+ const CODEX_CMD_RE = /(?:^|\s)node\s+\S*javi-forge-skillguard-pre-tool-use\.mjs\s+--agent=codex(?:\s|$)/;
195
+ /** Every `PreToolUse` handler across all groups, in order. */
196
+ function preToolUseHandlers(value) {
197
+ const hooks = isPlainObject(value) ? value.hooks : undefined;
198
+ const groups = isPlainObject(hooks) && Array.isArray(hooks.PreToolUse)
199
+ ? hooks.PreToolUse
200
+ : [];
201
+ const handlers = [];
202
+ for (const group of groups) {
203
+ const list = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
204
+ for (const h of list)
205
+ if (isPlainObject(h))
206
+ handlers.push(h);
207
+ }
208
+ return handlers;
209
+ }
210
+ /**
211
+ * Classify `hooks.json`. Reuses `validateSettingsShape` (the SAME settings-schema
212
+ * validator the Claude classifier uses — the Codex hooks.json schema is
213
+ * identical) and recognizes our managed handler by its exact command string.
214
+ * - malformed → not a valid hooks container
215
+ * - managed-current → our exact command present
216
+ * - released-outdated→ our guard present but at a stale asset path
217
+ * - foreign → other PreToolUse handlers, none of them ours
218
+ * - absent → no PreToolUse handlers at all (installable)
219
+ */
220
+ export function classifyCodexHooksJson(value, expectedCommand) {
221
+ if (!validateSettingsShape(value))
222
+ return { state: "malformed" };
223
+ const handlers = preToolUseHandlers(value);
224
+ const ours = handlers.filter((h) => h.type === "command" &&
225
+ typeof h.command === "string" &&
226
+ CODEX_CMD_RE.test(h.command));
227
+ if (ours.some((h) => h.command === expectedCommand)) {
228
+ return { state: "managed-current" };
229
+ }
230
+ if (ours.length > 0)
231
+ return { state: "released-outdated", detail: "stale-path" };
232
+ if (handlers.length > 0)
233
+ return { state: "foreign", detail: "no-managed-hook" };
234
+ return { state: "absent" };
235
+ }
236
+ /** Build the fresh managed hooks.json container for a given asset path. */
237
+ function buildCodexHooksContainer(assetPath) {
238
+ return {
239
+ hooks: {
240
+ PreToolUse: [
241
+ {
242
+ matcher: CODEX_MATCHER,
243
+ hooks: [
244
+ {
245
+ type: "command",
246
+ command: expectedCodexCommand(assetPath),
247
+ timeout: CODEX_TIMEOUT,
248
+ },
249
+ ],
250
+ },
251
+ ],
252
+ },
253
+ };
254
+ }
255
+ /**
256
+ * Merge our managed group into an existing container: drop any prior managed
257
+ * groups (ours, by command regex) and append a fresh one, preserving every
258
+ * foreign group. A fresh install (no container) yields the clean container.
259
+ */
260
+ function mergeCodexHooks(existing, assetPath) {
261
+ if (!isPlainObject(existing))
262
+ return buildCodexHooksContainer(assetPath);
263
+ const container = structuredClone(existing);
264
+ if (!isPlainObject(container.hooks))
265
+ container.hooks = {};
266
+ const hooks = container.hooks;
267
+ const groups = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
268
+ const kept = groups.filter((group) => {
269
+ const list = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
270
+ const isOurs = list.some((h) => isPlainObject(h) &&
271
+ h.type === "command" &&
272
+ typeof h.command === "string" &&
273
+ CODEX_CMD_RE.test(h.command));
274
+ return !isOurs;
275
+ });
276
+ const fresh = buildCodexHooksContainer(assetPath).hooks;
277
+ hooks.PreToolUse = [...kept, ...fresh.PreToolUse];
278
+ return container;
279
+ }
280
+ const EXECUTION_RESIDUAL = [
281
+ 'the installed hook is command-form (command: "node …"): node is resolved from Codex\'s PATH, which this process cannot observe — the node-on-PATH row is a heuristic proxy, never proof the guard will spawn',
282
+ "an untrusted hook is silently skipped by Codex unless run with --dangerously-bypass-hook-trust; trust is recorded in ~/.codex/config.toml [hooks.state] and is not settable non-interactively",
283
+ "a fresh install OR any upgrade that rewrites hooks.json invalidates the recorded trust hash (it would otherwise go stale and be silently skipped) — you MUST re-approve the hook in codex before it runs again",
284
+ ];
285
+ async function readText(target) {
286
+ const read = await safeReadFile(target, READ_OPTS);
287
+ if (read.ok)
288
+ return { ok: true, text: read.content };
289
+ return { ok: false, reason: read.reason };
290
+ }
291
+ async function readManifest() {
292
+ const read = await safeReadFile(path.join(CLAUDE_HOOK_ASSETS_DIR, "manifest.json"), READ_OPTS);
293
+ if (!read.ok)
294
+ throw new Error(`unreadable claude-hooks manifest: ${read.reason}`);
295
+ return JSON.parse(read.content);
296
+ }
297
+ export async function doctorCodexPreToolUse(homeDir = os.homedir(), options = {}) {
298
+ const manifest = options.manifest ?? (await readManifest());
299
+ const assetPath = options.assetPath ?? SHIPPED_CODEX_ASSET;
300
+ const { hooksFile, configFile } = codexConfigPaths(homeDir);
301
+ const expectedCommand = expectedCodexCommand(assetPath);
302
+ // hooks.json registration.
303
+ const hooksRead = await readText(hooksFile);
304
+ let hooksJson;
305
+ if (!hooksRead.ok) {
306
+ hooksJson =
307
+ hooksRead.reason === "not-found"
308
+ ? { state: "absent" }
309
+ : { state: "non-regular", detail: hooksRead.reason };
310
+ }
311
+ else {
312
+ try {
313
+ hooksJson = classifyCodexHooksJson(JSON.parse(hooksRead.text), expectedCommand);
314
+ }
315
+ catch {
316
+ hooksJson = { state: "malformed", detail: "invalid-json" };
317
+ }
318
+ }
319
+ // config.toml features + trust.
320
+ const configRead = await readText(configFile);
321
+ const configReadable = configRead.ok || configRead.reason === "not-found";
322
+ const configText = configRead.ok ? configRead.text : "";
323
+ const featuresHooks = configRead.ok
324
+ ? parseFeaturesHooks(configText)
325
+ : "absent";
326
+ const trusted = configRead.ok && hasCodexTrustEntry(configText, hooksFile);
327
+ // asset currency (SAME shipped asset, hashed against the manifest).
328
+ const claudeManifest = {
329
+ asset: manifest.asset,
330
+ settingsEntries: { current: null, historical: [] },
331
+ };
332
+ const asset = await classifyAssetState(assetPath, claudeManifest);
333
+ const node = detectNode(options.nodeVersion ?? process.versions.node);
334
+ const nodeOnPath = await (options.nodeProbe ?? probeNodeOnPath)();
335
+ const blockers = [];
336
+ const unknownSources = [];
337
+ if (!configReadable)
338
+ blockers.push("config:unreadable");
339
+ if (featuresHooks === "false")
340
+ blockers.push("policy:features.hooks=false");
341
+ // THE fail-open guard: an untrusted hook is silently skipped → NOT running.
342
+ if (!trusted)
343
+ blockers.push("trust:untrusted (hook is silently skipped)");
344
+ if (asset.state !== "managed-current")
345
+ blockers.push(`guard:asset=${asset.state}`);
346
+ if (hooksJson.state !== "managed-current") {
347
+ blockers.push(`registration:hooks.json=${hooksJson.state}`);
348
+ }
349
+ if (nodeOnPath.status === "absent") {
350
+ blockers.push("runtime:node-not-on-PATH (heuristic: this process' PATH)");
351
+ }
352
+ else if (nodeOnPath.status === "resolved" &&
353
+ nodeOnPath.major < NODE_MINIMUM_MAJOR) {
354
+ blockers.push(`runtime:node-on-PATH v${nodeOnPath.major} (<${NODE_MINIMUM_MAJOR}, heuristic)`);
355
+ }
356
+ else if (nodeOnPath.status === "unknown") {
357
+ unknownSources.push(`runtime:node-on-PATH (heuristic: ${nodeOnPath.detail})`);
358
+ }
359
+ const status = blockers.length > 0
360
+ ? "blocked"
361
+ : unknownSources.length > 0
362
+ ? "inconclusive"
363
+ : "runnable";
364
+ const remediation = [];
365
+ if (hooksJson.state === "absent" || asset.state !== "managed-current") {
366
+ remediation.push("install the codex guard with: javi-forge hooks install codex");
367
+ }
368
+ if (!trusted)
369
+ remediation.push(codexTrustGrantCommand(hooksFile));
370
+ if (featuresHooks === "false") {
371
+ remediation.push("remove `[features] hooks = false` from ~/.codex/config.toml");
372
+ }
373
+ if (!node.satisfiesMinimum)
374
+ remediation.push("install Node 22 or newer");
375
+ return {
376
+ healthy: status === "runnable",
377
+ hooksJson,
378
+ config: { featuresHooks, readable: configReadable },
379
+ asset: { state: asset.state, sha256: asset.sha256 },
380
+ node,
381
+ nodeOnPath,
382
+ execution: {
383
+ status,
384
+ blockers,
385
+ unknownSources,
386
+ residual: [...EXECUTION_RESIDUAL],
387
+ },
388
+ trust: {
389
+ state: trusted ? "trusted" : "untrusted",
390
+ grantCommand: codexTrustGrantCommand(hooksFile),
391
+ },
392
+ remediation: [...new Set(remediation)],
393
+ };
394
+ }
395
+ function serialize(container) {
396
+ return Buffer.from(`${JSON.stringify(container, null, 2)}\n`, "utf8");
397
+ }
398
+ export async function _runCodex(homeDir, _mode, _options, deps) {
399
+ const manifest = deps.manifest ?? (await readManifest());
400
+ const platform = deps.platform ?? process.platform;
401
+ const secureFs = deps.secureFs !== undefined ? deps.secureFs : selectSecureFs(platform);
402
+ const clock = deps.clock ?? (() => new Date());
403
+ const nonce = deps.nonce ??
404
+ (() => Math.random().toString(16).slice(2, 10).padEnd(8, "0"));
405
+ const assetPath = deps.assetPath ?? SHIPPED_CODEX_ASSET;
406
+ const { codexDir, hooksFile, configFile } = codexConfigPaths(homeDir);
407
+ const expectedCommand = expectedCodexCommand(assetPath);
408
+ const nodeOnPath = await (deps.nodeProbe ?? probeNodeOnPath)();
409
+ const doctor = () => doctorCodexPreToolUse(homeDir, {
410
+ manifest,
411
+ assetPath,
412
+ nodeProbe: async () => nodeOnPath,
413
+ });
414
+ if (!secureFs) {
415
+ return {
416
+ ok: false,
417
+ changed: [],
418
+ backups: [],
419
+ errors: ["windows-secure-object-unavailable"],
420
+ warnings: [],
421
+ report: await doctor(),
422
+ };
423
+ }
424
+ // Classify current state.
425
+ const hooksRead = await readText(hooksFile);
426
+ const hooksExisted = hooksRead.ok;
427
+ let hooksState;
428
+ if (!hooksRead.ok) {
429
+ hooksState =
430
+ hooksRead.reason === "not-found"
431
+ ? { state: "absent" }
432
+ : { state: "non-regular", detail: hooksRead.reason };
433
+ }
434
+ else {
435
+ try {
436
+ hooksState = classifyCodexHooksJson(JSON.parse(hooksRead.text), expectedCommand);
437
+ }
438
+ catch {
439
+ hooksState = { state: "malformed", detail: "invalid-json" };
440
+ }
441
+ }
442
+ if (hooksState.state === "malformed" || hooksState.state === "non-regular") {
443
+ return {
444
+ ok: false,
445
+ changed: [],
446
+ backups: [],
447
+ errors: [
448
+ `refuse hooks.json in state ${hooksState.state} — manual review`,
449
+ ],
450
+ warnings: [],
451
+ report: await doctor(),
452
+ };
453
+ }
454
+ const configRead = await readText(configFile);
455
+ const configExisted = configRead.ok;
456
+ const configText = configRead.ok ? configRead.text : "";
457
+ // Build desired bytes (null = no change for that component).
458
+ const hooksDesired = hooksState.state === "managed-current"
459
+ ? null
460
+ : serialize(mergeCodexHooks(hooksRead.ok ? JSON.parse(hooksRead.text) : undefined, assetPath));
461
+ // When the managed hooks.json content changes, any recorded trust hash for
462
+ // OUR hooks path is now stale — Codex would silently skip the rewritten hook
463
+ // while the header persisted. Invalidate that trust table in the SAME write
464
+ // so the doctor honestly reverts to `untrusted → blocked` until re-approval.
465
+ // An idempotent no-op install (hook content unchanged) leaves trust intact.
466
+ const hookContentChanged = hooksDesired !== null;
467
+ let nextConfig = configText;
468
+ if (hookContentChanged) {
469
+ nextConfig = removeCodexTrustEntries(nextConfig, hooksFile);
470
+ }
471
+ nextConfig = mergeFeaturesHooksTrue(nextConfig);
472
+ const configDesired = configExisted && nextConfig === configText
473
+ ? null
474
+ : Buffer.from(nextConfig, "utf8");
475
+ // Untrusted-after-install warning (report-the-trust-step).
476
+ const warnings = [
477
+ `the codex hook is installed but NOT yet trusted — ${codexTrustGrantCommand(hooksFile)}`,
478
+ ];
479
+ if (hooksDesired === null && configDesired === null) {
480
+ return {
481
+ ok: true,
482
+ changed: [],
483
+ backups: [],
484
+ errors: [],
485
+ warnings,
486
+ report: await doctor(),
487
+ };
488
+ }
489
+ // `repair --force` mirrors Claude's force semantics: replace the managed file
490
+ // after capturing a persistent backup of its prior content. It only has teeth
491
+ // on a component that both PRE-EXISTED and is being rewritten this run.
492
+ const forced = _mode === "repair" && _options.force === true;
493
+ const components = [
494
+ {
495
+ path: hooksFile,
496
+ desired: hooksDesired,
497
+ capturePrior: hooksExisted && hooksDesired !== null,
498
+ forceBackup: forced && hooksExisted && hooksDesired !== null,
499
+ wasAbsent: !hooksExisted,
500
+ },
501
+ {
502
+ path: configFile,
503
+ desired: configDesired,
504
+ capturePrior: configExisted && configDesired !== null,
505
+ forceBackup: forced && configExisted && configDesired !== null,
506
+ wasAbsent: !configExisted,
507
+ },
508
+ ];
509
+ const tx = await runTransaction({
510
+ secureFs,
511
+ clock,
512
+ nonce,
513
+ projectDir: homeDir,
514
+ layout: { containers: [codexDir], components },
515
+ });
516
+ return {
517
+ ok: tx.ok,
518
+ changed: tx.committed,
519
+ backups: tx.backups,
520
+ errors: tx.errors,
521
+ warnings,
522
+ report: await doctor(),
523
+ };
524
+ }
525
+ export function installCodexPreToolUse(homeDir = os.homedir()) {
526
+ return _runCodex(homeDir, "install", {}, {});
527
+ }
528
+ export function repairCodexPreToolUse(homeDir = os.homedir(), options) {
529
+ return _runCodex(homeDir, "repair", options ?? {}, {});
530
+ }
531
+ //# sourceMappingURL=codex-hook-manager.js.map
@@ -139,13 +139,33 @@ export interface TransactionComponent {
139
139
  /** True when the target did not exist before this op (rollback = unlink). */
140
140
  wasAbsent: boolean;
141
141
  }
142
+ /**
143
+ * Optional non-Claude container topology (Codex adapter, agnostic slice 2). When
144
+ * present it FULLY REPLACES the default `.claude`/`.claude/hooks` + [asset,settings]
145
+ * wiring; every proof primitive (ancestor gate, managed-container proof,
146
+ * capture/stage/commit/rollback) runs unchanged over the supplied dirs/components.
147
+ * When absent, the transaction behaves byte-identically to before this seam.
148
+ */
149
+ export interface TransactionLayout {
150
+ /** Managed containers to ensure/prove/create, ordered PARENT-FIRST (absolute). */
151
+ containers: string[];
152
+ /** Ordered write components (captured/staged/committed in array order). */
153
+ components: TransactionComponent[];
154
+ }
142
155
  export interface RunTransactionInput extends TransactionDeps {
143
- /** Existing project directory; `.claude` / `.claude/hooks` are created under it. */
156
+ /**
157
+ * Existing base directory; the ancestor chain root..projectDir is gated. The
158
+ * default topology creates `.claude` / `.claude/hooks` under it; a `layout`
159
+ * (Codex) uses it only as the gated ancestor-chain leaf (its containers are
160
+ * absolute and supplied directly).
161
+ */
144
162
  projectDir: string;
145
- /** Committed first. */
146
- asset: TransactionComponent;
147
- /** Committed second. */
148
- settings: TransactionComponent;
163
+ /** Committed first (default Claude topology; ignored when `layout` is present). */
164
+ asset?: TransactionComponent;
165
+ /** Committed second (default Claude topology; ignored when `layout` is present). */
166
+ settings?: TransactionComponent;
167
+ /** Non-Claude container topology; overrides `asset`/`settings` when present. */
168
+ layout?: TransactionLayout;
149
169
  }
150
170
  export interface TransactionOutcome {
151
171
  ok: boolean;
@@ -65,14 +65,34 @@ function ancestorChain(leaf) {
65
65
  * manual-recovery guidance (never clobbers a concurrent change).
66
66
  */
67
67
  export async function runTransaction(input) {
68
+ // Seam validation (fail-closed): the topology is EITHER a `layout` OR the
69
+ // default Claude pair [asset, settings]. A caller with neither would deref
70
+ // `undefined.desired` deep in the engine — refuse up front with a clear error.
71
+ if (!input.layout && !(input.asset && input.settings)) {
72
+ throw new TxAbort("validate", "runTransaction requires `layout` or both `asset` and `settings`");
73
+ }
68
74
  const { secureFs, clock, nonce, projectDir } = input;
69
- const claudeDir = path.join(projectDir, ".claude");
70
- const hooksDir = path.join(claudeDir, "hooks");
75
+ // Container topology + write components. The default (no `layout`) is the exact
76
+ // Claude `.claude` → `.claude/hooks` nesting with [asset, settings]; a `layout`
77
+ // (Codex) supplies absolute containers parent-first and an ordered component
78
+ // list. Either way the proof primitives below are identical.
79
+ const containers = input.layout
80
+ ? input.layout.containers
81
+ : [
82
+ path.join(projectDir, ".claude"),
83
+ path.join(projectDir, ".claude", "hooks"),
84
+ ];
85
+ const components = input.layout
86
+ ? input.layout.components
87
+ : [
88
+ input.asset,
89
+ input.settings,
90
+ ];
71
91
  // The dirs the tool OWNS: their children include the executed asset and the
72
92
  // settings it is referenced from. Fixed and known to the core regardless of
73
93
  // the per-run write plan; each existing member is proved on EVERY anyWrite run
74
94
  // (Round-4/5 / JDA-401 + JDB5-001).
75
- const managedContainers = new Set([claudeDir, hooksDir]);
95
+ const managedContainers = new Set(containers);
76
96
  const heldByPath = new Map();
77
97
  const heldOrder = [];
78
98
  const createdDirs = [];
@@ -80,7 +100,12 @@ export async function runTransaction(input) {
80
100
  const committed = [];
81
101
  const backups = [];
82
102
  const needsWrite = (c) => c.desired !== null;
83
- const anyWrite = needsWrite(input.asset) || needsWrite(input.settings);
103
+ const anyWrite = components.some(needsWrite);
104
+ // A component writes into `container` when its parent dir IS the container or a
105
+ // descendant of it — used to decide which absent containers to create.
106
+ const writesInto = (container) => components.some((c) => needsWrite(c) &&
107
+ (path.dirname(c.path) === container ||
108
+ path.dirname(c.path).startsWith(`${container}${path.sep}`)));
84
109
  // The uniform per-held-dir gate. It runs the LENIENT ancestor ACL predicate on
85
110
  // EVERY held dir — including managed containers, which are ALSO proved strict by
86
111
  // `proveManagedContainer` right after they are gated (in ensureManagedContainer)
@@ -168,23 +193,23 @@ export async function runTransaction(input) {
168
193
  // pre-commit); one that is absent is CREATED only when a child is written
169
194
  // into it this run, else left alone (nothing to secure).
170
195
  if (anyWrite) {
171
- const projectHandle = heldByPath.get(projectDir);
172
- // .claude always ensured on anyWrite (holds settings; grandparent of
173
- // the asset). createIfAbsent=true never returns null (opens, creates, or
174
- // throws) → narrow non-null before passing as parent (JDA6-003).
175
- const claudeHandle = await ensureManagedContainer(projectHandle, claudeDir,
176
- /* createIfAbsent */ true);
177
- if (!claudeHandle) {
178
- throw new TxAbort(`container ${claudeDir}`, "unexpected null handle");
196
+ // Ensure every managed container PARENT-FIRST. Each container's parent is
197
+ // already held (its dirname was gated in preflight or ensured by an earlier
198
+ // iteration). A container is CREATED only when a component writes into it
199
+ // or a descendant container this run; otherwise it is proved IF present,
200
+ // else left alone (JDB5-001). This generalizes the former hardcoded
201
+ // `.claude` → `.claude/hooks` pair without changing any proof primitive.
202
+ for (const container of containers) {
203
+ const parentHandle = heldByPath.get(path.dirname(container));
204
+ if (!parentHandle) {
205
+ throw new TxAbort(`container ${container}`, "parent chain not held");
206
+ }
207
+ await ensureManagedContainer(parentHandle, container,
208
+ /* createIfAbsent */ writesInto(container));
179
209
  }
180
- // .claude/hooks: create when the asset writes into it; otherwise prove
181
- // IF it exists (settings-only repair must still secure the hook's
182
- // container — JDB5-001).
183
- await ensureManagedContainer(claudeHandle, hooksDir,
184
- /* createIfAbsent */ needsWrite(input.asset));
185
210
  }
186
- // --- CAPTURE + (FORCED) BACKUP + STAGE, asset then settings ---
187
- for (const component of [input.asset, input.settings]) {
211
+ // --- CAPTURE + (FORCED) BACKUP + STAGE, in component order ---
212
+ for (const component of components) {
188
213
  if (!needsWrite(component))
189
214
  continue;
190
215
  const parentPath = path.dirname(component.path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.35.1",
3
+ "version": "1.36.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {