javi-forge 1.35.0 → 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
@@ -19,10 +19,28 @@ export interface SpawnOutcome {
19
19
  stdout: string;
20
20
  }
21
21
  export type SpawnFn = (cmd: string, args: string[]) => Promise<SpawnOutcome>;
22
+ /** Minimal `lstat` seam: yields the on-disk owner uid of the target. Injectable. */
23
+ export type StatFn = (target: string) => Promise<{
24
+ uid: number;
25
+ }>;
22
26
  /** The bounded ACL prover behind each platform adapter. */
23
27
  export interface PosixAclAdapter {
24
- /** Run the bounded, LC_ALL=C ACL tool and decide clean|extended|inconclusive. */
28
+ /**
29
+ * STRICT any-extended-entry proof: refuse ANY named/mask/default/inherited ACL
30
+ * entry. Used on managed containers (`.claude`/`.claude/hooks`) and leaf source
31
+ * files, where the tool owns the node and tolerates no foreign ACL surface.
32
+ */
25
33
  proveClean(target: string): Promise<SecureResult<void>>;
34
+ /**
35
+ * LENIENT path-endangering proof for ANCESTOR (non-managed) controlling dirs:
36
+ * refuse only when a foreign principal can swap/delete/rename the on-path node
37
+ * — a named-user for a uid outside {owner, root, euid} with effective (raw ∩
38
+ * mask) `w`, OR any named-group with effective `w`. Everything else (base
39
+ * entries, a lone `mask::`, effective-non-write named entries, x-only, trusted
40
+ * named users, `default:*`) proceeds. Same fail-closed spawn edges as
41
+ * `proveClean`. On darwin this is the no-op alias of `proveClean` (deferred).
42
+ */
43
+ proveNoEndangeringAcl(target: string): Promise<SecureResult<void>>;
26
44
  }
27
45
  /**
28
46
  * The EXACT detail strings the POSIX adapters emit, exported so consumers (the
@@ -38,7 +56,7 @@ export declare const ACL_DETAIL: {
38
56
  readonly macosAclFlag: "ACL present (+ flag)";
39
57
  readonly macosAceListed: "ACE listed";
40
58
  };
41
- export declare function createLinuxAclAdapter(spawn?: SpawnFn): PosixAclAdapter;
59
+ export declare function createLinuxAclAdapter(spawn?: SpawnFn, stat?: StatFn): PosixAclAdapter;
42
60
  export declare function createMacosAclAdapter(spawn?: SpawnFn): PosixAclAdapter;
43
61
  /**
44
62
  * Whether the host's ACL adapter is RESOLVABLE — an install-time capability