javi-forge 1.35.1 → 1.37.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,546 @@
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 { resolvePlatformSupport, } from "./platform-support.js";
38
+ import { safeReadFile } from "./safe-read.js";
39
+ import { selectSecureFs } from "./secure-fs-posix.js";
40
+ import { runTransaction, } from "./secure-fs-transaction.js";
41
+ const NODE_MINIMUM_MAJOR = 22;
42
+ const CODEX_TIMEOUT = 30;
43
+ /**
44
+ * Matcher covering the two tools the guard must gate under Codex: `Bash`
45
+ * (sensitive-command protection, drop-in) and `apply_patch` (managed-config
46
+ * file-write protection, the S1 shim). PreToolUse fires on all tools; the
47
+ * matcher narrows delivery to what we evaluate. (Confirmed against a real
48
+ * codex-cli 0.147.0 run during S2.8.)
49
+ */
50
+ const CODEX_MATCHER = "Bash|apply_patch";
51
+ const READ_OPTS = {
52
+ maxBytes: 1024 * 1024,
53
+ hardRejectOverBytes: 1024 * 1024,
54
+ maxLineLength: Number.POSITIVE_INFINITY,
55
+ };
56
+ /** The shipped, in-package guard asset the Codex hook references by ABSOLUTE path. */
57
+ export const SHIPPED_CODEX_ASSET = path.join(CLAUDE_HOOK_ASSETS_DIR, ASSET_NAME);
58
+ /** Resolve `~/.codex/{hooks.json,config.toml}` for a given home directory. */
59
+ export function codexConfigPaths(homeDir) {
60
+ const codexDir = path.join(homeDir, ".codex");
61
+ return {
62
+ codexDir,
63
+ hooksFile: path.join(codexDir, "hooks.json"),
64
+ configFile: path.join(codexDir, "config.toml"),
65
+ };
66
+ }
67
+ /** The exact `command` string the managed Codex hook runs (single-string form). */
68
+ export function expectedCodexCommand(assetPath) {
69
+ return `node ${assetPath} --agent=codex`;
70
+ }
71
+ /** The interactive step that establishes hook trust (there is no non-interactive subcommand). */
72
+ export function codexTrustGrantCommand(hooksFile) {
73
+ 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`;
74
+ }
75
+ // =============================================================================
76
+ // Pure config.toml helpers (minimal, targeted, fail-closed) — no TOML dep
77
+ // =============================================================================
78
+ const TABLE_HEADER = /^\s*\[([^[\]]+)\]\s*(?:#.*)?$/;
79
+ const HOOKS_LINE = /^\s*hooks\s*=\s*(true|false)\b/;
80
+ /** Read the `[features] hooks` flag: "true" | "false" | "absent". */
81
+ export function parseFeaturesHooks(text) {
82
+ let inFeatures = false;
83
+ for (const line of text.split(/\r?\n/)) {
84
+ const header = TABLE_HEADER.exec(line);
85
+ if (header) {
86
+ inFeatures = header[1].trim() === "features";
87
+ continue;
88
+ }
89
+ if (inFeatures) {
90
+ const m = HOOKS_LINE.exec(line);
91
+ if (m)
92
+ return m[1] === "true" ? "true" : "false";
93
+ }
94
+ }
95
+ return "absent";
96
+ }
97
+ /**
98
+ * True when `config.toml` records a trust table for THIS hook path, i.e. a
99
+ * `[hooks.state."<hooksFile>:pre_tool_use:0:0"]` header. Fail-closed: a trust
100
+ * entry for a different path does not count.
101
+ *
102
+ * NOTE (fail-open the arc kills): presence of the header is NOT proof the hook
103
+ * is still trusted — Codex records a `trusted_hash` under it, and a hook whose
104
+ * content was rewritten (e.g. an asset/command/timeout upgrade) has a STALE hash
105
+ * → Codex silently skips it and re-prompts. We cannot recompute Codex's hash to
106
+ * compare here, so instead the installer INVALIDATES this table whenever it
107
+ * rewrites the managed hooks.json (see `removeCodexTrustEntries`), reverting the
108
+ * doctor to `untrusted → blocked` until the user re-approves in codex.
109
+ */
110
+ export function hasCodexTrustEntry(text, hooksFile) {
111
+ const needle = `${hooksFile}:pre_tool_use:0:0`;
112
+ for (const line of text.split(/\r?\n/)) {
113
+ const header = TABLE_HEADER.exec(line);
114
+ if (!header)
115
+ continue;
116
+ const inner = header[1].trim();
117
+ if (inner.startsWith("hooks.state.") && inner.includes(needle))
118
+ return true;
119
+ }
120
+ return false;
121
+ }
122
+ /**
123
+ * Remove every `[hooks.state."<hooksFile>:*"]` table (header + body lines) keyed
124
+ * on OUR managed hooks.json path, preserving all other content — including
125
+ * FOREIGN `hooks.state` rows for other hooks files. Used to invalidate a now-
126
+ * stale `trusted_hash` when the managed hooks.json content is rewritten: the
127
+ * trust-key path is stable across upgrades, so a rewritten hook keeps its old
128
+ * (now wrong) recorded hash and would be silently skipped by Codex while the
129
+ * header persisted. Dropping the table forces the doctor back to `untrusted`
130
+ * until the user re-approves the hook in codex.
131
+ */
132
+ export function removeCodexTrustEntries(text, hooksFile) {
133
+ // Match the quoted path prefix so a path that merely has ours as a string
134
+ // prefix (a different file) is never removed.
135
+ const needle = `"${hooksFile}:`;
136
+ const lines = text.split(/\r?\n/);
137
+ const kept = [];
138
+ let dropping = false;
139
+ for (const line of lines) {
140
+ const header = TABLE_HEADER.exec(line);
141
+ if (header) {
142
+ const inner = header[1].trim();
143
+ dropping = inner.startsWith("hooks.state.") && inner.includes(needle);
144
+ if (dropping)
145
+ continue;
146
+ kept.push(line);
147
+ continue;
148
+ }
149
+ if (dropping)
150
+ continue;
151
+ kept.push(line);
152
+ }
153
+ return kept.join("\n");
154
+ }
155
+ /**
156
+ * Ensure `[features] hooks = true`, preserving all other content and idempotent
157
+ * when already true. Only ever INSERTS a line or flips a `hooks = false` inside
158
+ * `[features]`, so it can never corrupt unrelated TOML.
159
+ */
160
+ export function mergeFeaturesHooksTrue(text) {
161
+ const current = parseFeaturesHooks(text);
162
+ if (current === "true")
163
+ return text;
164
+ const lines = text.split(/\r?\n/);
165
+ // Flip an existing `hooks = false` inside [features].
166
+ if (current === "false") {
167
+ let inFeatures = false;
168
+ for (let i = 0; i < lines.length; i++) {
169
+ const header = TABLE_HEADER.exec(lines[i]);
170
+ if (header) {
171
+ inFeatures = header[1].trim() === "features";
172
+ continue;
173
+ }
174
+ if (inFeatures && HOOKS_LINE.exec(lines[i])) {
175
+ lines[i] = "hooks = true";
176
+ return lines.join("\n");
177
+ }
178
+ }
179
+ }
180
+ // [features] exists but has no hooks line → insert right after the header.
181
+ for (let i = 0; i < lines.length; i++) {
182
+ const header = TABLE_HEADER.exec(lines[i]);
183
+ if (header && header[1].trim() === "features") {
184
+ lines.splice(i + 1, 0, "hooks = true");
185
+ return lines.join("\n");
186
+ }
187
+ }
188
+ // No [features] table at all → append one.
189
+ const base = text.length === 0 ? "" : text.endsWith("\n") ? text : `${text}\n`;
190
+ return `${base}[features]\nhooks = true\n`;
191
+ }
192
+ // =============================================================================
193
+ // hooks.json classification (reuses the settings-schema validators)
194
+ // =============================================================================
195
+ const CODEX_CMD_RE = /(?:^|\s)node\s+\S*javi-forge-skillguard-pre-tool-use\.mjs\s+--agent=codex(?:\s|$)/;
196
+ /** Every `PreToolUse` handler across all groups, in order. */
197
+ function preToolUseHandlers(value) {
198
+ const hooks = isPlainObject(value) ? value.hooks : undefined;
199
+ const groups = isPlainObject(hooks) && Array.isArray(hooks.PreToolUse)
200
+ ? hooks.PreToolUse
201
+ : [];
202
+ const handlers = [];
203
+ for (const group of groups) {
204
+ const list = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
205
+ for (const h of list)
206
+ if (isPlainObject(h))
207
+ handlers.push(h);
208
+ }
209
+ return handlers;
210
+ }
211
+ /**
212
+ * Classify `hooks.json`. Reuses `validateSettingsShape` (the SAME settings-schema
213
+ * validator the Claude classifier uses — the Codex hooks.json schema is
214
+ * identical) and recognizes our managed handler by its exact command string.
215
+ * - malformed → not a valid hooks container
216
+ * - managed-current → our exact command present
217
+ * - released-outdated→ our guard present but at a stale asset path
218
+ * - foreign → other PreToolUse handlers, none of them ours
219
+ * - absent → no PreToolUse handlers at all (installable)
220
+ */
221
+ export function classifyCodexHooksJson(value, expectedCommand) {
222
+ if (!validateSettingsShape(value))
223
+ return { state: "malformed" };
224
+ const handlers = preToolUseHandlers(value);
225
+ const ours = handlers.filter((h) => h.type === "command" &&
226
+ typeof h.command === "string" &&
227
+ CODEX_CMD_RE.test(h.command));
228
+ if (ours.some((h) => h.command === expectedCommand)) {
229
+ return { state: "managed-current" };
230
+ }
231
+ if (ours.length > 0)
232
+ return { state: "released-outdated", detail: "stale-path" };
233
+ if (handlers.length > 0)
234
+ return { state: "foreign", detail: "no-managed-hook" };
235
+ return { state: "absent" };
236
+ }
237
+ /** Build the fresh managed hooks.json container for a given asset path. */
238
+ function buildCodexHooksContainer(assetPath) {
239
+ return {
240
+ hooks: {
241
+ PreToolUse: [
242
+ {
243
+ matcher: CODEX_MATCHER,
244
+ hooks: [
245
+ {
246
+ type: "command",
247
+ command: expectedCodexCommand(assetPath),
248
+ timeout: CODEX_TIMEOUT,
249
+ },
250
+ ],
251
+ },
252
+ ],
253
+ },
254
+ };
255
+ }
256
+ /**
257
+ * Merge our managed group into an existing container: drop any prior managed
258
+ * groups (ours, by command regex) and append a fresh one, preserving every
259
+ * foreign group. A fresh install (no container) yields the clean container.
260
+ */
261
+ function mergeCodexHooks(existing, assetPath) {
262
+ if (!isPlainObject(existing))
263
+ return buildCodexHooksContainer(assetPath);
264
+ const container = structuredClone(existing);
265
+ if (!isPlainObject(container.hooks))
266
+ container.hooks = {};
267
+ const hooks = container.hooks;
268
+ const groups = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
269
+ const kept = groups.filter((group) => {
270
+ const list = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
271
+ const isOurs = list.some((h) => isPlainObject(h) &&
272
+ h.type === "command" &&
273
+ typeof h.command === "string" &&
274
+ CODEX_CMD_RE.test(h.command));
275
+ return !isOurs;
276
+ });
277
+ const fresh = buildCodexHooksContainer(assetPath).hooks;
278
+ hooks.PreToolUse = [...kept, ...fresh.PreToolUse];
279
+ return container;
280
+ }
281
+ const EXECUTION_RESIDUAL = [
282
+ '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',
283
+ "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",
284
+ "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",
285
+ ];
286
+ async function readText(target) {
287
+ const read = await safeReadFile(target, READ_OPTS);
288
+ if (read.ok)
289
+ return { ok: true, text: read.content };
290
+ return { ok: false, reason: read.reason };
291
+ }
292
+ async function readManifest() {
293
+ const read = await safeReadFile(path.join(CLAUDE_HOOK_ASSETS_DIR, "manifest.json"), READ_OPTS);
294
+ if (!read.ok)
295
+ throw new Error(`unreadable claude-hooks manifest: ${read.reason}`);
296
+ return JSON.parse(read.content);
297
+ }
298
+ export async function doctorCodexPreToolUse(homeDir = os.homedir(), options = {}) {
299
+ const manifest = options.manifest ?? (await readManifest());
300
+ const assetPath = options.assetPath ?? SHIPPED_CODEX_ASSET;
301
+ const { hooksFile, configFile } = codexConfigPaths(homeDir);
302
+ const expectedCommand = expectedCodexCommand(assetPath);
303
+ // hooks.json registration.
304
+ const hooksRead = await readText(hooksFile);
305
+ let hooksJson;
306
+ if (!hooksRead.ok) {
307
+ hooksJson =
308
+ hooksRead.reason === "not-found"
309
+ ? { state: "absent" }
310
+ : { state: "non-regular", detail: hooksRead.reason };
311
+ }
312
+ else {
313
+ try {
314
+ hooksJson = classifyCodexHooksJson(JSON.parse(hooksRead.text), expectedCommand);
315
+ }
316
+ catch {
317
+ hooksJson = { state: "malformed", detail: "invalid-json" };
318
+ }
319
+ }
320
+ // config.toml features + trust.
321
+ const configRead = await readText(configFile);
322
+ const configReadable = configRead.ok || configRead.reason === "not-found";
323
+ const configText = configRead.ok ? configRead.text : "";
324
+ const featuresHooks = configRead.ok
325
+ ? parseFeaturesHooks(configText)
326
+ : "absent";
327
+ const trusted = configRead.ok && hasCodexTrustEntry(configText, hooksFile);
328
+ // asset currency (SAME shipped asset, hashed against the manifest).
329
+ const claudeManifest = {
330
+ asset: manifest.asset,
331
+ settingsEntries: { current: null, historical: [] },
332
+ };
333
+ const asset = await classifyAssetState(assetPath, claudeManifest);
334
+ const node = detectNode(options.nodeVersion ?? process.versions.node);
335
+ const nodeOnPath = await (options.nodeProbe ?? probeNodeOnPath)();
336
+ const blockers = [];
337
+ const unknownSources = [];
338
+ if (!configReadable)
339
+ blockers.push("config:unreadable");
340
+ if (featuresHooks === "false")
341
+ blockers.push("policy:features.hooks=false");
342
+ // THE fail-open guard: an untrusted hook is silently skipped → NOT running.
343
+ if (!trusted)
344
+ blockers.push("trust:untrusted (hook is silently skipped)");
345
+ if (asset.state !== "managed-current")
346
+ blockers.push(`guard:asset=${asset.state}`);
347
+ if (hooksJson.state !== "managed-current") {
348
+ blockers.push(`registration:hooks.json=${hooksJson.state}`);
349
+ }
350
+ if (nodeOnPath.status === "absent") {
351
+ blockers.push("runtime:node-not-on-PATH (heuristic: this process' PATH)");
352
+ }
353
+ else if (nodeOnPath.status === "resolved" &&
354
+ nodeOnPath.major < NODE_MINIMUM_MAJOR) {
355
+ blockers.push(`runtime:node-on-PATH v${nodeOnPath.major} (<${NODE_MINIMUM_MAJOR}, heuristic)`);
356
+ }
357
+ else if (nodeOnPath.status === "unknown") {
358
+ unknownSources.push(`runtime:node-on-PATH (heuristic: ${nodeOnPath.detail})`);
359
+ }
360
+ const status = blockers.length > 0
361
+ ? "blocked"
362
+ : unknownSources.length > 0
363
+ ? "inconclusive"
364
+ : "runnable";
365
+ const remediation = [];
366
+ if (hooksJson.state === "absent" || asset.state !== "managed-current") {
367
+ remediation.push("install the codex guard with: javi-forge hooks install codex");
368
+ }
369
+ if (!trusted)
370
+ remediation.push(codexTrustGrantCommand(hooksFile));
371
+ if (featuresHooks === "false") {
372
+ remediation.push("remove `[features] hooks = false` from ~/.codex/config.toml");
373
+ }
374
+ if (!node.satisfiesMinimum)
375
+ remediation.push("install Node 22 or newer");
376
+ const platformSupport = resolvePlatformSupport(options.platform ?? process.platform);
377
+ return {
378
+ ...(platformSupport ? { platformSupport } : {}),
379
+ healthy: status === "runnable",
380
+ hooksJson,
381
+ config: { featuresHooks, readable: configReadable },
382
+ asset: { state: asset.state, sha256: asset.sha256 },
383
+ node,
384
+ nodeOnPath,
385
+ execution: {
386
+ status,
387
+ blockers,
388
+ unknownSources,
389
+ residual: [...EXECUTION_RESIDUAL],
390
+ },
391
+ trust: {
392
+ state: trusted ? "trusted" : "untrusted",
393
+ grantCommand: codexTrustGrantCommand(hooksFile),
394
+ },
395
+ remediation: [...new Set(remediation)],
396
+ };
397
+ }
398
+ function serialize(container) {
399
+ return Buffer.from(`${JSON.stringify(container, null, 2)}\n`, "utf8");
400
+ }
401
+ export async function _runCodex(homeDir, _mode, _options, deps) {
402
+ const platform = deps.platform ?? process.platform;
403
+ const platformSupport = resolvePlatformSupport(platform);
404
+ if (platformSupport) {
405
+ return {
406
+ ok: false,
407
+ changed: [],
408
+ backups: [],
409
+ errors: [platformSupport.refusalCode],
410
+ warnings: [platformSupport.guidance],
411
+ lifecycleRefusal: platformSupport,
412
+ };
413
+ }
414
+ const doctorFn = deps.doctor ?? doctorCodexPreToolUse;
415
+ const manifest = deps.manifest ?? (await readManifest());
416
+ const secureFs = deps.secureFs !== undefined ? deps.secureFs : selectSecureFs(platform);
417
+ const clock = deps.clock ?? (() => new Date());
418
+ const nonce = deps.nonce ??
419
+ (() => Math.random().toString(16).slice(2, 10).padEnd(8, "0"));
420
+ const assetPath = deps.assetPath ?? SHIPPED_CODEX_ASSET;
421
+ const { codexDir, hooksFile, configFile } = codexConfigPaths(homeDir);
422
+ const expectedCommand = expectedCodexCommand(assetPath);
423
+ const nodeOnPath = await (deps.nodeProbe ?? probeNodeOnPath)();
424
+ const doctor = () => doctorFn(homeDir, {
425
+ manifest,
426
+ assetPath,
427
+ nodeProbe: async () => nodeOnPath,
428
+ });
429
+ if (!secureFs) {
430
+ return {
431
+ ok: false,
432
+ changed: [],
433
+ backups: [],
434
+ errors: ["windows-secure-object-unavailable"],
435
+ warnings: [],
436
+ report: await doctor(),
437
+ };
438
+ }
439
+ // Classify current state.
440
+ const hooksRead = await readText(hooksFile);
441
+ const hooksExisted = hooksRead.ok;
442
+ let hooksState;
443
+ if (!hooksRead.ok) {
444
+ hooksState =
445
+ hooksRead.reason === "not-found"
446
+ ? { state: "absent" }
447
+ : { state: "non-regular", detail: hooksRead.reason };
448
+ }
449
+ else {
450
+ try {
451
+ hooksState = classifyCodexHooksJson(JSON.parse(hooksRead.text), expectedCommand);
452
+ }
453
+ catch {
454
+ hooksState = { state: "malformed", detail: "invalid-json" };
455
+ }
456
+ }
457
+ if (hooksState.state === "malformed" || hooksState.state === "non-regular") {
458
+ return {
459
+ ok: false,
460
+ changed: [],
461
+ backups: [],
462
+ errors: [
463
+ `refuse hooks.json in state ${hooksState.state} — manual review`,
464
+ ],
465
+ warnings: [],
466
+ report: await doctor(),
467
+ };
468
+ }
469
+ const configRead = await readText(configFile);
470
+ const configExisted = configRead.ok;
471
+ const configText = configRead.ok ? configRead.text : "";
472
+ // Build desired bytes (null = no change for that component).
473
+ const hooksDesired = hooksState.state === "managed-current"
474
+ ? null
475
+ : serialize(mergeCodexHooks(hooksRead.ok ? JSON.parse(hooksRead.text) : undefined, assetPath));
476
+ // When the managed hooks.json content changes, any recorded trust hash for
477
+ // OUR hooks path is now stale — Codex would silently skip the rewritten hook
478
+ // while the header persisted. Invalidate that trust table in the SAME write
479
+ // so the doctor honestly reverts to `untrusted → blocked` until re-approval.
480
+ // An idempotent no-op install (hook content unchanged) leaves trust intact.
481
+ const hookContentChanged = hooksDesired !== null;
482
+ let nextConfig = configText;
483
+ if (hookContentChanged) {
484
+ nextConfig = removeCodexTrustEntries(nextConfig, hooksFile);
485
+ }
486
+ nextConfig = mergeFeaturesHooksTrue(nextConfig);
487
+ const configDesired = configExisted && nextConfig === configText
488
+ ? null
489
+ : Buffer.from(nextConfig, "utf8");
490
+ // Untrusted-after-install warning (report-the-trust-step).
491
+ const warnings = [
492
+ `the codex hook is installed but NOT yet trusted — ${codexTrustGrantCommand(hooksFile)}`,
493
+ ];
494
+ if (hooksDesired === null && configDesired === null) {
495
+ return {
496
+ ok: true,
497
+ changed: [],
498
+ backups: [],
499
+ errors: [],
500
+ warnings,
501
+ report: await doctor(),
502
+ };
503
+ }
504
+ // `repair --force` mirrors Claude's force semantics: replace the managed file
505
+ // after capturing a persistent backup of its prior content. It only has teeth
506
+ // on a component that both PRE-EXISTED and is being rewritten this run.
507
+ const forced = _mode === "repair" && _options.force === true;
508
+ const components = [
509
+ {
510
+ path: hooksFile,
511
+ desired: hooksDesired,
512
+ capturePrior: hooksExisted && hooksDesired !== null,
513
+ forceBackup: forced && hooksExisted && hooksDesired !== null,
514
+ wasAbsent: !hooksExisted,
515
+ },
516
+ {
517
+ path: configFile,
518
+ desired: configDesired,
519
+ capturePrior: configExisted && configDesired !== null,
520
+ forceBackup: forced && configExisted && configDesired !== null,
521
+ wasAbsent: !configExisted,
522
+ },
523
+ ];
524
+ const tx = await runTransaction({
525
+ secureFs,
526
+ clock,
527
+ nonce,
528
+ projectDir: homeDir,
529
+ layout: { containers: [codexDir], components },
530
+ });
531
+ return {
532
+ ok: tx.ok,
533
+ changed: tx.committed,
534
+ backups: tx.backups,
535
+ errors: tx.errors,
536
+ warnings,
537
+ report: await doctor(),
538
+ };
539
+ }
540
+ export function installCodexPreToolUse(homeDir = os.homedir()) {
541
+ return _runCodex(homeDir, "install", {}, {});
542
+ }
543
+ export function repairCodexPreToolUse(homeDir = os.homedir(), options) {
544
+ return _runCodex(homeDir, "repair", options ?? {}, {});
545
+ }
546
+ //# sourceMappingURL=codex-hook-manager.js.map
@@ -0,0 +1,19 @@
1
+ export declare const PLATFORM_SUPPORT_STATE: {
2
+ readonly MACOS_DEPRECATED: "macos-deprecated";
3
+ };
4
+ export declare const LIFECYCLE_SUPPORT: {
5
+ readonly UNSUPPORTED: "unsupported";
6
+ };
7
+ export declare const PLATFORM_REFUSAL: {
8
+ readonly MACOS_LIFECYCLE_UNSUPPORTED: "macos-lifecycle-unsupported";
9
+ };
10
+ export interface PlatformSupport {
11
+ platform: "darwin";
12
+ state: typeof PLATFORM_SUPPORT_STATE.MACOS_DEPRECATED;
13
+ lifecycle: typeof LIFECYCLE_SUPPORT.UNSUPPORTED;
14
+ refusalCode: typeof PLATFORM_REFUSAL.MACOS_LIFECYCLE_UNSUPPORTED;
15
+ guidance: string;
16
+ }
17
+ export declare const MACOS_DEPRECATION_GUIDANCE = "macOS is deprecated and unsupported for install, repair, and init; pin a supported release or migrate. Existing installed guards are not removed; Darwin removal is planned for 2.0.";
18
+ export declare function resolvePlatformSupport(platform: string): PlatformSupport | undefined;
19
+ //# sourceMappingURL=platform-support.d.ts.map
@@ -0,0 +1,22 @@
1
+ export const PLATFORM_SUPPORT_STATE = {
2
+ MACOS_DEPRECATED: "macos-deprecated",
3
+ };
4
+ export const LIFECYCLE_SUPPORT = {
5
+ UNSUPPORTED: "unsupported",
6
+ };
7
+ export const PLATFORM_REFUSAL = {
8
+ MACOS_LIFECYCLE_UNSUPPORTED: "macos-lifecycle-unsupported",
9
+ };
10
+ export const MACOS_DEPRECATION_GUIDANCE = "macOS is deprecated and unsupported for install, repair, and init; pin a supported release or migrate. Existing installed guards are not removed; Darwin removal is planned for 2.0.";
11
+ export function resolvePlatformSupport(platform) {
12
+ if (platform !== "darwin")
13
+ return undefined;
14
+ return {
15
+ platform: "darwin",
16
+ state: PLATFORM_SUPPORT_STATE.MACOS_DEPRECATED,
17
+ lifecycle: LIFECYCLE_SUPPORT.UNSUPPORTED,
18
+ refusalCode: PLATFORM_REFUSAL.MACOS_LIFECYCLE_UNSUPPORTED,
19
+ guidance: MACOS_DEPRECATION_GUIDANCE,
20
+ };
21
+ }
22
+ //# sourceMappingURL=platform-support.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;