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.
- package/assets/claude-hooks/javi-forge-skillguard-pre-tool-use.mjs +97 -29
- package/assets/claude-hooks/manifest.json +1 -1
- package/dist/cli/dispatch/hooks.d.ts +9 -2
- package/dist/cli/dispatch/hooks.js +22 -8
- package/dist/commands/codex-hooks.d.ts +26 -0
- package/dist/commands/codex-hooks.js +104 -0
- package/dist/lib/__fixtures__/claude-hook-ownership.d.ts +6 -0
- package/dist/lib/__fixtures__/claude-hook-ownership.js +7 -1
- package/dist/lib/__fixtures__/fake-secure-fs.d.ts +9 -1
- package/dist/lib/__fixtures__/fake-secure-fs.js +15 -0
- package/dist/lib/agent-adapter.d.ts +56 -0
- package/dist/lib/agent-adapter.js +88 -0
- package/dist/lib/claude-hook-manager.js +3 -2
- package/dist/lib/claude-hook-settings.d.ts +3 -3
- package/dist/lib/claude-hook-settings.js +3 -3
- package/dist/lib/codex-hook-manager.d.ts +161 -0
- package/dist/lib/codex-hook-manager.js +531 -0
- package/dist/lib/secure-fs-posix.d.ts +20 -2
- package/dist/lib/secure-fs-posix.js +162 -24
- package/dist/lib/secure-fs-transaction.d.ts +40 -6
- package/dist/lib/secure-fs-transaction.js +60 -22
- package/dist/lib/secure-fs-windows.js +7 -0
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|