@dzhechkov/harness-core 0.4.5 → 0.5.1

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.
Files changed (141) hide show
  1. package/.dz-manifest.json +489 -105
  2. package/README.md +116 -5
  3. package/dist/agents-policy.d.ts +67 -0
  4. package/dist/agents-policy.d.ts.map +1 -0
  5. package/dist/agents-policy.js +258 -0
  6. package/dist/agents-policy.js.map +1 -0
  7. package/dist/codex-hooks-assets.d.ts +47 -0
  8. package/dist/codex-hooks-assets.d.ts.map +1 -0
  9. package/dist/codex-hooks-assets.js +324 -0
  10. package/dist/codex-hooks-assets.js.map +1 -0
  11. package/dist/codex-hooks-verify.d.ts +95 -0
  12. package/dist/codex-hooks-verify.d.ts.map +1 -0
  13. package/dist/codex-hooks-verify.js +169 -0
  14. package/dist/codex-hooks-verify.js.map +1 -0
  15. package/dist/codex-hooks.d.ts +341 -0
  16. package/dist/codex-hooks.d.ts.map +1 -0
  17. package/dist/codex-hooks.js +541 -0
  18. package/dist/codex-hooks.js.map +1 -0
  19. package/dist/discrimination-gate.d.ts +88 -15
  20. package/dist/discrimination-gate.d.ts.map +1 -1
  21. package/dist/discrimination-gate.js +343 -51
  22. package/dist/discrimination-gate.js.map +1 -1
  23. package/dist/feature-adr-checkpoints.d.ts +22 -0
  24. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  25. package/dist/feature-adr-checkpoints.js +42 -0
  26. package/dist/feature-adr-checkpoints.js.map +1 -1
  27. package/dist/feature-adr-routing.d.ts +218 -5
  28. package/dist/feature-adr-routing.d.ts.map +1 -1
  29. package/dist/feature-adr-routing.js +583 -54
  30. package/dist/feature-adr-routing.js.map +1 -1
  31. package/dist/guard.d.ts +13 -0
  32. package/dist/guard.d.ts.map +1 -1
  33. package/dist/guard.js +25 -1
  34. package/dist/guard.js.map +1 -1
  35. package/dist/index.d.ts +23 -7
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +37 -4
  38. package/dist/index.js.map +1 -1
  39. package/dist/loop-blobs.generated.d.ts +1 -1
  40. package/dist/loop-blobs.generated.d.ts.map +1 -1
  41. package/dist/loop-blobs.generated.js +12 -3
  42. package/dist/loop-blobs.generated.js.map +1 -1
  43. package/dist/loop-plan.d.ts +70 -0
  44. package/dist/loop-plan.d.ts.map +1 -1
  45. package/dist/loop-plan.js +103 -0
  46. package/dist/loop-plan.js.map +1 -1
  47. package/dist/loop-render.d.ts.map +1 -1
  48. package/dist/loop-render.js +37 -129
  49. package/dist/loop-render.js.map +1 -1
  50. package/dist/loop-run-semantics.d.ts +130 -0
  51. package/dist/loop-run-semantics.d.ts.map +1 -0
  52. package/dist/loop-run-semantics.js +257 -0
  53. package/dist/loop-run-semantics.js.map +1 -0
  54. package/dist/loop-trace.d.ts +31 -2
  55. package/dist/loop-trace.d.ts.map +1 -1
  56. package/dist/loop-trace.js +89 -12
  57. package/dist/loop-trace.js.map +1 -1
  58. package/dist/managed-hooks.d.ts +86 -0
  59. package/dist/managed-hooks.d.ts.map +1 -0
  60. package/dist/managed-hooks.js +101 -0
  61. package/dist/managed-hooks.js.map +1 -0
  62. package/dist/mutation-gate.d.ts +14 -0
  63. package/dist/mutation-gate.d.ts.map +1 -1
  64. package/dist/mutation-gate.js +25 -2
  65. package/dist/mutation-gate.js.map +1 -1
  66. package/dist/named-lock.d.ts +57 -0
  67. package/dist/named-lock.d.ts.map +1 -0
  68. package/dist/named-lock.js +247 -0
  69. package/dist/named-lock.js.map +1 -0
  70. package/dist/operations.d.ts +229 -0
  71. package/dist/operations.d.ts.map +1 -1
  72. package/dist/operations.js +880 -26
  73. package/dist/operations.js.map +1 -1
  74. package/dist/parity.d.ts +106 -1
  75. package/dist/parity.d.ts.map +1 -1
  76. package/dist/parity.js +209 -5
  77. package/dist/parity.js.map +1 -1
  78. package/dist/qe-bridge.d.ts +291 -0
  79. package/dist/qe-bridge.d.ts.map +1 -0
  80. package/dist/qe-bridge.js +538 -0
  81. package/dist/qe-bridge.js.map +1 -0
  82. package/dist/recall-usage.d.ts +53 -0
  83. package/dist/recall-usage.d.ts.map +1 -1
  84. package/dist/recall-usage.js +125 -2
  85. package/dist/recall-usage.js.map +1 -1
  86. package/dist/score.d.ts.map +1 -1
  87. package/dist/score.js +43 -9
  88. package/dist/score.js.map +1 -1
  89. package/dist/setup.d.ts.map +1 -1
  90. package/dist/setup.js +14 -26
  91. package/dist/setup.js.map +1 -1
  92. package/dist/shell-veto-policy.d.ts +53 -0
  93. package/dist/shell-veto-policy.d.ts.map +1 -0
  94. package/dist/shell-veto-policy.js +103 -0
  95. package/dist/shell-veto-policy.js.map +1 -0
  96. package/dist/skills.d.ts +86 -1
  97. package/dist/skills.d.ts.map +1 -1
  98. package/dist/skills.js +116 -1
  99. package/dist/skills.js.map +1 -1
  100. package/dist/targets.d.ts +75 -0
  101. package/dist/targets.d.ts.map +1 -1
  102. package/dist/targets.js +160 -0
  103. package/dist/targets.js.map +1 -1
  104. package/dist/workflow-run-dispatch.d.ts +230 -0
  105. package/dist/workflow-run-dispatch.d.ts.map +1 -0
  106. package/dist/workflow-run-dispatch.js +363 -0
  107. package/dist/workflow-run-dispatch.js.map +1 -0
  108. package/dist/workflow-run.d.ts +498 -0
  109. package/dist/workflow-run.d.ts.map +1 -0
  110. package/dist/workflow-run.js +1359 -0
  111. package/dist/workflow-run.js.map +1 -0
  112. package/package.json +6 -5
  113. package/sbom.json +1099 -139
  114. package/src/agents-policy.ts +338 -0
  115. package/src/codex-hooks-assets.ts +328 -0
  116. package/src/codex-hooks-verify.ts +237 -0
  117. package/src/codex-hooks.ts +723 -0
  118. package/src/discrimination-gate.ts +456 -58
  119. package/src/feature-adr-checkpoints.ts +38 -0
  120. package/src/feature-adr-routing.ts +697 -75
  121. package/src/guard.ts +36 -1
  122. package/src/index.ts +162 -2
  123. package/src/loop-blobs.generated.ts +12 -3
  124. package/src/loop-plan.ts +185 -0
  125. package/src/loop-render.ts +37 -127
  126. package/src/loop-run-semantics.ts +278 -0
  127. package/src/loop-trace.ts +84 -10
  128. package/src/managed-hooks.ts +150 -0
  129. package/src/mutation-gate.ts +24 -2
  130. package/src/named-lock.ts +277 -0
  131. package/src/operations.ts +1121 -29
  132. package/src/parity.ts +295 -6
  133. package/src/qe-bridge.ts +737 -0
  134. package/src/recall-usage.ts +184 -1
  135. package/src/score.ts +50 -9
  136. package/src/setup.ts +26 -27
  137. package/src/shell-veto-policy.ts +119 -0
  138. package/src/skills.ts +174 -1
  139. package/src/targets.ts +189 -0
  140. package/src/workflow-run-dispatch.ts +459 -0
  141. package/src/workflow-run.ts +1743 -0
@@ -0,0 +1,723 @@
1
+ /**
2
+ * The Codex hook registry model (`crossrt-2-codex-hooks`, ADR-001).
3
+ *
4
+ * Pure: paths, the managed entry set, sha-based attribution, the manifest, drift, and the TOML text
5
+ * of a trust block. Every filesystem and process action lives in `operations.ts`.
6
+ *
7
+ * ## Measured facts this module encodes (M0 spike, codex-cli 0.147.0 — see
8
+ * `features/crossrt-2-codex-hooks/07_code_changes/probe-results/spike-arming.md`)
9
+ *
10
+ * - `$CODEX_HOME/hooks.json` is the user registry and **`CODEX_HOME` relocates discovery**, which is
11
+ * what makes every automated probe hermetic (G-H). `$CODEX_HOME/hooks/hooks.json` is the PLUGIN
12
+ * bundle layout and does not fire.
13
+ * - Entries are trust-gated. An untrusted entry is silently not run. Trust is persisted per entry in
14
+ * `$CODEX_HOME/config.toml` as `[hooks.state."<key>"] trusted_hash = "<currentHash>"`, and both
15
+ * `key` and `currentHash` come from the runtime's own `hooks/list` RPC — they are **read, never
16
+ * computed**, because `currentHash`'s preimage is internal to codex.
17
+ * - `timeout` is in SECONDS and IS honored (600 → 5, MEASURED). `timeoutSec` in an entry is
18
+ * **silently ignored** and leaves the 600 s default in place — which is why this module emits
19
+ * `timeout` and a test pins the key set (AM-15, now probe-proven by spike S2).
20
+ * - The hook runner spawns via `$SHELL -lc`, so the emitted command is a SHELL string: the
21
+ * interpreter is an absolute `process.execPath` and both paths are single-quoted (AM-32/AM-35d).
22
+ *
23
+ * @packageDocumentation
24
+ */
25
+
26
+ import { createHash } from 'node:crypto';
27
+ import { join } from 'node:path';
28
+
29
+ import { mergeManagedHookEntries } from './managed-hooks.js';
30
+
31
+ /**
32
+ * Bump when a helper BODY changes: a changed body changes codex's `currentHash` ⇒ re-trust.
33
+ *
34
+ * 2 — the independent-QE fix round: the veto note stopped persisting the raw command line and the
35
+ * notes log/dir modes are now enforced on every write (finding 8).
36
+ * 3 — fix round 2: the note's `commandSynopsis` is a binary NAME or `(redacted)` — an
37
+ * env-assignment first token (`SECRET=xyz ssh …`) carried the credential the redaction removed
38
+ * everywhere else (R2-8).
39
+ */
40
+ export const DZ_HOOK_HELPER_VERSION = 3;
41
+
42
+ /** Seconds. Probe-proven (spike S2): `timeout` is honored, the unset default is 600 s. */
43
+ export const DZ_HOOK_TIMEOUT_SECONDS = 5;
44
+
45
+ /** The wide matcher (AM-8). Narrowing needs a recorded live probe; the guard keys on the payload. */
46
+ export const DZ_VETO_MATCHER = 'Bash|shell|local_shell';
47
+
48
+ export interface CodexHooksPaths {
49
+ readonly codexHome: string;
50
+ readonly registry: string;
51
+ readonly configToml: string;
52
+ readonly helperDir: string;
53
+ readonly vetoHelper: string;
54
+ readonly recallHelper: string;
55
+ readonly manifest: string;
56
+ readonly errorLog: string;
57
+ }
58
+
59
+ /** Every path this leg touches, all `CODEX_HOME`-relative (AM-13). */
60
+ export function codexHooksPaths(codexHome: string): CodexHooksPaths {
61
+ const helperDir = join(codexHome, 'dz-hooks');
62
+ return {
63
+ codexHome,
64
+ registry: join(codexHome, 'hooks.json'),
65
+ configToml: join(codexHome, 'config.toml'),
66
+ helperDir,
67
+ vetoHelper: join(helperDir, 'dz-codex-veto.cjs'),
68
+ recallHelper: join(helperDir, 'dz-codex-recall.cjs'),
69
+ manifest: join(helperDir, 'manifest.json'),
70
+ errorLog: join(helperDir, 'helper-errors.jsonl'),
71
+ };
72
+ }
73
+
74
+ /** The EMITTER's write-set (AM-12). The RUNTIME's is stated separately — see {@link runtimeWriteSet}. */
75
+ export function emitterWriteSet(paths: CodexHooksPaths): readonly string[] {
76
+ return [paths.registry, paths.vetoHelper, paths.recallHelper, paths.manifest];
77
+ }
78
+
79
+ /**
80
+ * The RUNTIME's write-set (AM-33). Exactly one path beyond the emitter's, and it is
81
+ * `CODEX_HOME`-local: a helper must never create a `.dz/` inside a repository the user merely ran
82
+ * `codex` in. (Recall rows are the one exception and they are opt-in-gated: they land in an
83
+ * ALREADY-EXISTING opted-in project's `.dz/`, so nothing is ever created.)
84
+ */
85
+ export function runtimeWriteSet(paths: CodexHooksPaths): readonly string[] {
86
+ return [paths.errorLog];
87
+ }
88
+
89
+ /* -------------------------------------------------------------------------- */
90
+ /* Quoting */
91
+ /* -------------------------------------------------------------------------- */
92
+
93
+ /** A single quote inside a single-quoted shell word cannot be escaped — such a path is REFUSED. */
94
+ export function isSafeForSingleQuote(path: string): boolean {
95
+ return typeof path === 'string' && path !== '' && !path.includes("'") && !path.includes('\n');
96
+ }
97
+
98
+ export function singleQuote(path: string): string {
99
+ return `'${path}'`;
100
+ }
101
+
102
+ /**
103
+ * The shell string codex runs. Absolute interpreter + single-quoted paths (AM-32/AM-35d).
104
+ * @throws when either path cannot be safely quoted — never emit a broken entry that READS installed.
105
+ */
106
+ export function buildHookCommand(nodePath: string, scriptPath: string): string {
107
+ if (!isSafeForSingleQuote(nodePath) || !isSafeForSingleQuote(scriptPath)) {
108
+ throw new Error(
109
+ `refusing to emit a hook command containing an unquotable path (single quote or newline): ${nodePath} ${scriptPath}`,
110
+ );
111
+ }
112
+ return `${singleQuote(nodePath)} ${singleQuote(scriptPath)}`;
113
+ }
114
+
115
+ /* -------------------------------------------------------------------------- */
116
+ /* The managed entries */
117
+ /* -------------------------------------------------------------------------- */
118
+
119
+ export type CodexHookId = 'codex-veto' | 'codex-recall';
120
+ export type CodexHookEvent = 'PreToolUse' | 'UserPromptSubmit';
121
+
122
+ export interface CodexHookSpec {
123
+ readonly id: CodexHookId;
124
+ readonly event: CodexHookEvent;
125
+ readonly matcher?: string;
126
+ readonly script: 'veto' | 'recall';
127
+ }
128
+
129
+ /** The two entries this leg ships, and nothing else. */
130
+ export const CODEX_MANAGED_HOOKS: readonly CodexHookSpec[] = [
131
+ { id: 'codex-veto', event: 'PreToolUse', matcher: DZ_VETO_MATCHER, script: 'veto' },
132
+ { id: 'codex-recall', event: 'UserPromptSubmit', script: 'recall' },
133
+ ];
134
+
135
+ export interface BuildManagedEntriesOptions {
136
+ readonly nodePath: string;
137
+ readonly paths: CodexHooksPaths;
138
+ }
139
+
140
+ export interface ManagedEntry {
141
+ readonly id: CodexHookId;
142
+ readonly event: CodexHookEvent;
143
+ readonly command: string;
144
+ readonly entry: Record<string, unknown>;
145
+ }
146
+
147
+ /**
148
+ * Build the managed entries.
149
+ *
150
+ * Key set is EXACTLY `{matcher?, hooks:[{type, command, timeout}]}` (AM-15). `timeout` is included
151
+ * because spike S2 recorded a probe proving the registry loads with it AND honors it; every other
152
+ * key stays out until it has its own probe.
153
+ */
154
+ export function buildManagedEntries(opts: BuildManagedEntriesOptions): readonly ManagedEntry[] {
155
+ return CODEX_MANAGED_HOOKS.map((spec) => {
156
+ const script = spec.script === 'veto' ? opts.paths.vetoHelper : opts.paths.recallHelper;
157
+ const command = buildHookCommand(opts.nodePath, script);
158
+ const entry: Record<string, unknown> = {
159
+ ...(spec.matcher !== undefined ? { matcher: spec.matcher } : {}),
160
+ hooks: [{ type: 'command', command, timeout: DZ_HOOK_TIMEOUT_SECONDS }],
161
+ };
162
+ return { id: spec.id, event: spec.event, command, entry };
163
+ });
164
+ }
165
+
166
+ /** `managed` in the shape {@link mergeManagedHookEntries} takes. */
167
+ export function managedByEvent(entries: readonly ManagedEntry[]): Record<string, unknown[]> {
168
+ const out: Record<string, unknown[]> = {};
169
+ for (const e of entries) {
170
+ (out[e.event] ??= []).push(e.entry);
171
+ }
172
+ return out;
173
+ }
174
+
175
+ /* -------------------------------------------------------------------------- */
176
+ /* Manifest + attribution */
177
+ /* -------------------------------------------------------------------------- */
178
+
179
+ export function codexHookSha256(text: string): string {
180
+ return createHash('sha256').update(text, 'utf8').digest('hex');
181
+ }
182
+
183
+ export interface CodexHookManifestEntry {
184
+ readonly id: CodexHookId;
185
+ readonly event: CodexHookEvent;
186
+ readonly matcher?: string;
187
+ readonly commandSha256: string;
188
+ /** The runtime trust key, as reported by `hooks/list`. Absent until a list succeeded. */
189
+ readonly trustKey?: string;
190
+ }
191
+
192
+ export interface CodexHookVerifyRecord {
193
+ readonly verdict: string;
194
+ readonly trust: string;
195
+ readonly bypassed: boolean;
196
+ readonly at: string;
197
+ readonly transcript?: string;
198
+ }
199
+
200
+ export interface CodexHookManifest {
201
+ readonly version: 1;
202
+ readonly writtenAt: string;
203
+ readonly codexVersion: string;
204
+ readonly registryPath: string;
205
+ readonly helperVersion: number;
206
+ readonly nodePath: string;
207
+ readonly entries: readonly CodexHookManifestEntry[];
208
+ readonly lastVerify?: CodexHookVerifyRecord;
209
+ }
210
+
211
+ export interface BuildManifestOptions {
212
+ readonly entries: readonly ManagedEntry[];
213
+ readonly paths: CodexHooksPaths;
214
+ readonly codexVersion: string;
215
+ readonly writtenAt: string;
216
+ readonly nodePath: string;
217
+ readonly trustKeys?: Readonly<Record<string, string>>;
218
+ readonly lastVerify?: CodexHookVerifyRecord;
219
+ }
220
+
221
+ export function buildCodexHookManifest(opts: BuildManifestOptions): CodexHookManifest {
222
+ return {
223
+ version: 1,
224
+ writtenAt: opts.writtenAt,
225
+ codexVersion: opts.codexVersion,
226
+ registryPath: opts.paths.registry,
227
+ helperVersion: DZ_HOOK_HELPER_VERSION,
228
+ nodePath: opts.nodePath,
229
+ entries: opts.entries.map((e) => {
230
+ const spec = CODEX_MANAGED_HOOKS.find((s) => s.id === e.id);
231
+ const trustKey = opts.trustKeys?.[e.id];
232
+ return {
233
+ id: e.id,
234
+ event: e.event,
235
+ ...(spec?.matcher !== undefined ? { matcher: spec.matcher } : {}),
236
+ commandSha256: codexHookSha256(e.command),
237
+ ...(trustKey !== undefined ? { trustKey } : {}),
238
+ };
239
+ }),
240
+ ...(opts.lastVerify !== undefined ? { lastVerify: opts.lastVerify } : {}),
241
+ };
242
+ }
243
+
244
+ export function parseCodexHookManifest(text: string): CodexHookManifest | undefined {
245
+ try {
246
+ const parsed = JSON.parse(text) as unknown;
247
+ if (parsed === null || typeof parsed !== 'object') return undefined;
248
+ const m = parsed as CodexHookManifest;
249
+ if (m.version !== 1 || !Array.isArray(m.entries)) return undefined;
250
+ return m;
251
+ } catch {
252
+ return undefined;
253
+ }
254
+ }
255
+
256
+ function entryCommands(entry: unknown): string[] {
257
+ const e = entry as { command?: unknown; hooks?: { command?: unknown }[] };
258
+ if (Array.isArray(e?.hooks)) return e.hooks.map((h) => String(h?.command ?? ''));
259
+ return [String(e?.command ?? '')];
260
+ }
261
+
262
+ /**
263
+ * Attribution is `codexHookSha256(command) ∈ manifest` — never a substring guess.
264
+ *
265
+ * ADR-001 §3: dz deletes only what it can PROVE it wrote. An entry that merely looks like ours (it
266
+ * mentions our helper filename) but whose command hash is absent from the manifest is KEPT, counted
267
+ * in `unattributable`, and warned about. A hand-edited managed command therefore survives.
268
+ */
269
+ export function isDzManagedEntry(entry: unknown, manifest: CodexHookManifest | undefined): boolean {
270
+ if (manifest === undefined) return false;
271
+ const known = new Set(manifest.entries.map((e) => e.commandSha256));
272
+ return entryCommands(entry).some((cmd) => cmd !== '' && known.has(codexHookSha256(cmd)));
273
+ }
274
+
275
+ /**
276
+ * Rebuild an OWNED entry from only the handlers that are NOT ours — per-HANDLER attribution.
277
+ *
278
+ * ADR-001 §5 promises that dz deletes only what it can prove it wrote. Attribution was applied to a
279
+ * whole MATCHER GROUP: a group holding one dz handler and one of the user's was removed (or
280
+ * replaced) wholesale, so a foreign handler was deleted by a rule written to protect it
281
+ * (independent review, finding 6). One dz handler in the group must cost exactly that handler.
282
+ *
283
+ * Returns `null` when every handler in the entry was ours (the entry itself is then dropped), and
284
+ * a rebuilt entry — same keys, same order, minus our handlers — when something foreign survives.
285
+ * A legacy FLAT entry (`{type, command}`) is one handler and has nothing to salvage.
286
+ */
287
+ export function retainForeignHandlers(entry: unknown, manifest: CodexHookManifest | undefined): unknown | null {
288
+ if (manifest === undefined) return entry;
289
+ const known = new Set(manifest.entries.map((e) => e.commandSha256));
290
+ const isOurs = (cmd: unknown): boolean => typeof cmd === 'string' && cmd !== '' && known.has(codexHookSha256(cmd));
291
+ const e = entry as { hooks?: unknown };
292
+ if (!Array.isArray(e?.hooks)) return null; // flat shape: the entry IS the handler
293
+ const kept = (e.hooks as { command?: unknown }[]).filter((h) => !isOurs(h?.command));
294
+ if (kept.length === 0) return null;
295
+ return { ...(entry as Record<string, unknown>), hooks: kept };
296
+ }
297
+
298
+ /** Cheap resemblance test — drives the `unattributable` COUNT only, never a deletion. */
299
+ export function looksLikeDzEntry(entry: unknown): boolean {
300
+ return entryCommands(entry).some(
301
+ (cmd) => cmd.includes('dz-codex-veto.cjs') || cmd.includes('dz-codex-recall.cjs'),
302
+ );
303
+ }
304
+
305
+ /* -------------------------------------------------------------------------- */
306
+ /* Registry parsing / planning */
307
+ /* -------------------------------------------------------------------------- */
308
+
309
+ export interface CodexRegistry {
310
+ /** Every top-level key other than `hooks`, preserved verbatim. */
311
+ readonly rest: Record<string, unknown>;
312
+ readonly hooks: Record<string, unknown[]>;
313
+ }
314
+
315
+ export type ParseRegistryResult =
316
+ | { readonly ok: true; readonly registry: CodexRegistry }
317
+ | { readonly ok: false; readonly error: string };
318
+
319
+ /**
320
+ * Parse an existing registry. An unparseable file is an ERROR, never an empty registry: silently
321
+ * treating it as `{}` would overwrite entries we promised to preserve (I1).
322
+ */
323
+ export function parseCodexRegistry(text: string | undefined): ParseRegistryResult {
324
+ if (text === undefined || text.trim() === '') {
325
+ return { ok: true, registry: { rest: {}, hooks: {} } };
326
+ }
327
+ let parsed: unknown;
328
+ try {
329
+ parsed = JSON.parse(text);
330
+ } catch (err) {
331
+ return { ok: false, error: `hooks.json is not valid JSON: ${(err as Error).message}` };
332
+ }
333
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
334
+ return { ok: false, error: 'hooks.json must be a JSON object' };
335
+ }
336
+ const obj = parsed as Record<string, unknown>;
337
+ const rawHooks = obj['hooks'];
338
+ if (rawHooks !== undefined && (rawHooks === null || typeof rawHooks !== 'object' || Array.isArray(rawHooks))) {
339
+ return { ok: false, error: 'hooks.json "hooks" must be an object keyed by event name' };
340
+ }
341
+ const hooks: Record<string, unknown[]> = {};
342
+ for (const [event, value] of Object.entries((rawHooks ?? {}) as Record<string, unknown>)) {
343
+ // REFUSE, never coerce. `Array.isArray(value) ? [...value] : []` silently turned a foreign
344
+ // event whose value dz does not understand into an EMPTY list, and the next write persisted
345
+ // that emptiness — configuration loss dressed as a merge (independent review, finding 5). An
346
+ // unparseable shape is the same class of fact as unparseable JSON: refuse before planning.
347
+ if (!Array.isArray(value)) {
348
+ return {
349
+ ok: false,
350
+ error: `hooks.json event ${JSON.stringify(event)} must be an array of entries (found ${value === null ? 'null' : typeof value}) — dz refuses to rewrite a registry it cannot read without losing it`,
351
+ };
352
+ }
353
+ hooks[event] = [...value];
354
+ }
355
+ const rest: Record<string, unknown> = {};
356
+ for (const [k, v] of Object.entries(obj)) if (k !== 'hooks') rest[k] = v;
357
+ return { ok: true, registry: { rest, hooks } };
358
+ }
359
+
360
+ export function serializeCodexRegistry(registry: CodexRegistry): string {
361
+ return `${JSON.stringify({ ...registry.rest, hooks: registry.hooks }, null, 2)}\n`;
362
+ }
363
+
364
+ export interface CodexHooksPlan {
365
+ readonly registry: CodexRegistry;
366
+ readonly text: string;
367
+ readonly changed: boolean;
368
+ readonly foreignPreserved: number;
369
+ readonly unattributable: number;
370
+ }
371
+
372
+ export interface PlanCodexHooksInput {
373
+ readonly currentText: string | undefined;
374
+ readonly entries: readonly ManagedEntry[];
375
+ readonly manifest: CodexHookManifest | undefined;
376
+ }
377
+
378
+ /**
379
+ * Compute the merged registry.
380
+ *
381
+ * The merge itself is `mergeManagedHookEntries` — the SAME implementation the Claude path uses
382
+ * (AM-3 / G-E). Only the attribution predicate differs.
383
+ */
384
+ export function planCodexHooks(
385
+ input: PlanCodexHooksInput,
386
+ ): { ok: true; plan: CodexHooksPlan } | { ok: false; error: string } {
387
+ const parsed = parseCodexRegistry(input.currentText);
388
+ if (!parsed.ok) return { ok: false, error: parsed.error };
389
+ // The union of the manifest's hashes and the ones we are about to write: on a re-run the manifest
390
+ // is current, on a helper-version bump it is not, and BOTH vintages of our own entry must be
391
+ // replaced rather than duplicated.
392
+ const selfManifest: CodexHookManifest = {
393
+ version: 1,
394
+ writtenAt: '',
395
+ codexVersion: '',
396
+ registryPath: '',
397
+ helperVersion: DZ_HOOK_HELPER_VERSION,
398
+ nodePath: '',
399
+ entries: [
400
+ ...(input.manifest?.entries ?? []),
401
+ ...input.entries.map((e) => ({ id: e.id, event: e.event, commandSha256: codexHookSha256(e.command) })),
402
+ ],
403
+ };
404
+ const plan = mergeManagedHookEntries(parsed.registry.hooks, managedByEvent(input.entries), {
405
+ isManaged: (entry) => isDzManagedEntry(entry, selfManifest),
406
+ looksLikeOurs: (entry) => looksLikeDzEntry(entry) && !isDzManagedEntry(entry, selfManifest),
407
+ // Per-HANDLER salvage (finding 6): an owned entry that also carries the user's own handler is
408
+ // rebuilt without ours, never dropped whole. The Claude path passes no salvage and is
409
+ // byte-identical to before (AM-3).
410
+ retainForeign: (entry) => retainForeignHandlers(entry, selfManifest),
411
+ reportLabel: 'codex',
412
+ });
413
+ const registry: CodexRegistry = { rest: parsed.registry.rest, hooks: plan.hooks };
414
+ return {
415
+ ok: true,
416
+ plan: {
417
+ registry,
418
+ text: serializeCodexRegistry(registry),
419
+ changed: plan.changed,
420
+ foreignPreserved: plan.foreignPreserved,
421
+ unattributable: plan.unattributable,
422
+ },
423
+ };
424
+ }
425
+
426
+ export interface RemoveCodexHooksResult {
427
+ readonly registry: CodexRegistry;
428
+ readonly text: string;
429
+ readonly removed: number;
430
+ readonly changed: boolean;
431
+ readonly unattributable: number;
432
+ }
433
+
434
+ /**
435
+ * `--remove`: delete ONLY manifest-attributed entries.
436
+ *
437
+ * With the manifest deleted this removes **zero** entries and reports them all as `unattributable`.
438
+ * That is the intended, conservative behaviour: dz never deletes what it cannot prove it wrote.
439
+ */
440
+ export function removeCodexHooks(
441
+ currentText: string | undefined,
442
+ manifest: CodexHookManifest | undefined,
443
+ ): { ok: true; result: RemoveCodexHooksResult } | { ok: false; error: string } {
444
+ const parsed = parseCodexRegistry(currentText);
445
+ if (!parsed.ok) return { ok: false, error: parsed.error };
446
+ const hooks: Record<string, unknown[]> = {};
447
+ let removed = 0;
448
+ let unattributable = 0;
449
+ for (const [event, entries] of Object.entries(parsed.registry.hooks)) {
450
+ const kept: unknown[] = [];
451
+ for (const entry of entries) {
452
+ if (isDzManagedEntry(entry, manifest)) {
453
+ removed += 1;
454
+ // Per-HANDLER removal (finding 6): a mixed matcher group keeps every handler that is not
455
+ // ours. Removing dz's guard must never remove the user's alongside it.
456
+ const salvaged = retainForeignHandlers(entry, manifest);
457
+ if (salvaged !== null) kept.push(salvaged);
458
+ continue;
459
+ }
460
+ if (looksLikeDzEntry(entry)) unattributable += 1;
461
+ kept.push(entry);
462
+ }
463
+ hooks[event] = kept;
464
+ }
465
+ const registry: CodexRegistry = { rest: parsed.registry.rest, hooks };
466
+ return {
467
+ ok: true,
468
+ result: { registry, text: serializeCodexRegistry(registry), removed, changed: removed > 0, unattributable },
469
+ };
470
+ }
471
+
472
+ export interface DriftReport {
473
+ /** Both managed entries present in the registry and attributable. */
474
+ readonly installed: boolean;
475
+ readonly presentIds: readonly CodexHookId[];
476
+ readonly missingIds: readonly CodexHookId[];
477
+ readonly foreignPreserved: number;
478
+ readonly unattributable: number;
479
+ /** A managed command whose hash is not the one the manifest records for that id. */
480
+ readonly drifted: readonly CodexHookId[];
481
+ }
482
+
483
+ /** `--check` recomputes from the FILE; it never trusts the manifest's claim on its own. */
484
+ export function diffCodexHooks(
485
+ currentText: string | undefined,
486
+ entries: readonly ManagedEntry[],
487
+ manifest: CodexHookManifest | undefined,
488
+ ): DriftReport {
489
+ const parsed = parseCodexRegistry(currentText);
490
+ const hooks = parsed.ok ? parsed.registry.hooks : {};
491
+ const present: CodexHookId[] = [];
492
+ const drifted: CodexHookId[] = [];
493
+ let foreignPreserved = 0;
494
+ let unattributable = 0;
495
+ const wantByEvent = new Map(entries.map((e) => [e.event, e] as const));
496
+
497
+ for (const [event, list] of Object.entries(hooks)) {
498
+ for (const entry of list) {
499
+ const want = wantByEvent.get(event as CodexHookEvent);
500
+ const cmds = entryCommands(entry);
501
+ if (want !== undefined && cmds.includes(want.command)) {
502
+ present.push(want.id);
503
+ continue;
504
+ }
505
+ if (isDzManagedEntry(entry, manifest)) {
506
+ // Ours by manifest, but not the command we would write now: an old helper version.
507
+ const id = manifest?.entries.find((m) => cmds.some((c) => codexHookSha256(c) === m.commandSha256))?.id;
508
+ if (id !== undefined) drifted.push(id);
509
+ continue;
510
+ }
511
+ foreignPreserved += 1;
512
+ if (looksLikeDzEntry(entry)) unattributable += 1;
513
+ }
514
+ }
515
+ const missing = entries.map((e) => e.id).filter((id) => !present.includes(id));
516
+ return {
517
+ installed: missing.length === 0,
518
+ presentIds: present,
519
+ missingIds: missing,
520
+ foreignPreserved,
521
+ unattributable,
522
+ drifted,
523
+ };
524
+ }
525
+
526
+ /* -------------------------------------------------------------------------- */
527
+ /* Trust (the M0 arming route) */
528
+ /* -------------------------------------------------------------------------- */
529
+
530
+ /** codex's snake_case event spelling inside a trust key (MEASURED from `hooks/list`). */
531
+ export function trustEventName(event: CodexHookEvent): string {
532
+ return event === 'PreToolUse' ? 'pre_tool_use' : 'user_prompt_submit';
533
+ }
534
+
535
+ /**
536
+ * Compare two event spellings the way the runtime actually spells them.
537
+ *
538
+ * MEASURED, both on this machine, on the SAME response shape:
539
+ * - the trust KEY embeds `pre_tool_use` / `user_prompt_submit` (snake_case);
540
+ * - `hooks/list`'s `eventName` FIELD is `preToolUse` / `userPromptSubmit` on **codex-cli 0.148.0**
541
+ * (reproducer: `listCodexHooks(<temp home>)` after an install — the rows are printed verbatim in
542
+ * `07_code_changes/probe-results/fixround/trust-diagnosis.txt`).
543
+ *
544
+ * Pinning either spelling alone silently drops every row: the fix round's first cut compared the
545
+ * field against the KEY spelling, `hooks/list` matched 0 of 2 entries, trust was never written, and
546
+ * the live probe's ARMED leg stopped blocking. Case and separators are therefore normalised away —
547
+ * the spelling is the runtime's cosmetic choice, the EVENT is the fact.
548
+ */
549
+ export function sameHookEvent(a: string, b: string): boolean {
550
+ const norm = (v: string): string => v.toLowerCase().replace(/[-_\s]/g, '');
551
+ return norm(a) === norm(b);
552
+ }
553
+
554
+ /**
555
+ * The trust key codex uses: `<sourcePath>:<snake_event>:<groupIndex>:<hookIndex>`.
556
+ *
557
+ * This is the EXPECTED spelling, used only to cross-check what `hooks/list` reports. The install
558
+ * path uses the reported key verbatim — a computed key that drifts from the runtime's would arm
559
+ * nothing while reading like success.
560
+ */
561
+ export function expectedTrustKey(
562
+ registryPath: string,
563
+ event: CodexHookEvent,
564
+ groupIndex: number,
565
+ hookIndex: number,
566
+ ): string {
567
+ return `${registryPath}:${trustEventName(event)}:${groupIndex}:${hookIndex}`;
568
+ }
569
+
570
+ /**
571
+ * Split a `hooks/list` trust key into its parts, or `null` when it is not one.
572
+ *
573
+ * MEASURED shape: `<sourcePath>:<snake_event>:<groupIndex>:<hookIndex>`. The path may itself carry
574
+ * a colon, so the split is anchored at the END — the last two fields are the indices and the third
575
+ * from the end is the event.
576
+ *
577
+ * The INDICES are deliberately not predicted: they are positions inside the user's registry, and a
578
+ * foreign entry shifts them. What IS ours to require is the rest — this key names OUR registry and
579
+ * OUR event (fix round 2, R2-7: a row carrying an arbitrary key was selected on path+event+command
580
+ * alone, and the key is what dz then writes trust against).
581
+ */
582
+ export function parseTrustKey(
583
+ key: string,
584
+ ): { readonly sourcePath: string; readonly event: string; readonly groupIndex: number; readonly hookIndex: number } | null {
585
+ const m = /^(.*):([A-Za-z_][A-Za-z_]*):(\d+):(\d+)$/.exec(key);
586
+ if (m === null) return null;
587
+ return { sourcePath: m[1] ?? '', event: m[2] ?? '', groupIndex: Number(m[3]), hookIndex: Number(m[4]) };
588
+ }
589
+
590
+ export interface HookTrustRow {
591
+ readonly key: string;
592
+ readonly trustedHash: string;
593
+ }
594
+
595
+ /** TOML-escape a bare-string key (the key is an absolute path plus `:` separators). */
596
+ function tomlQuote(value: string): string {
597
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
598
+ }
599
+
600
+ export const DZ_TRUST_BEGIN = '# --- dz codex hooks trust (managed block, dz-rewritten) ---';
601
+ export const DZ_TRUST_END = '# --- end dz codex hooks trust ---';
602
+
603
+ /**
604
+ * Render dz's trust rows as a MANAGED BLOCK in `config.toml`.
605
+ *
606
+ * A managed block, not a whole-file rewrite: `~/.codex/config.toml` already carries a
607
+ * `ruvnet-brain` managed block and seven `[projects."…"]` trust rows on this machine (MEASURED),
608
+ * and dz is not the only writer. The block is delimited so a re-run replaces exactly dz's rows and
609
+ * nothing else.
610
+ */
611
+ export function renderTrustBlock(rows: readonly HookTrustRow[]): string {
612
+ if (rows.length === 0) return '';
613
+ const body = rows
614
+ .map((r) => `[hooks.state.${tomlQuote(r.key)}]\ntrusted_hash = ${tomlQuote(r.trustedHash)}`)
615
+ .join('\n');
616
+ return `${DZ_TRUST_BEGIN}\n${body}\n${DZ_TRUST_END}\n`;
617
+ }
618
+
619
+ export type TrustBlockResult = { readonly ok: true; readonly text: string } | { readonly ok: false; readonly error: string };
620
+
621
+ function countOccurrences(haystack: string, needle: string): number {
622
+ let n = 0;
623
+ let i = haystack.indexOf(needle);
624
+ while (i >= 0) {
625
+ n += 1;
626
+ i = haystack.indexOf(needle, i + needle.length);
627
+ }
628
+ return n;
629
+ }
630
+
631
+ /**
632
+ * Replace (or append) dz's managed trust block, leaving every other byte of config.toml alone.
633
+ *
634
+ * The fence must be **exactly one well-ordered pair**, or dz refuses (independent review, finding
635
+ * 9). The old `indexOf`-pair logic was satisfiable by a damaged file in three ways, and each one
636
+ * eats the user's TOML on the NEXT sync: a lone BEGIN made the rewrite append a second block, so
637
+ * the following run's `begin…end` window spanned everything between the orphan marker and the new
638
+ * block's END; a reversed pair did the same; duplicate pairs left an orphan block behind. Refusing
639
+ * costs one manual edit — the alternative silently deletes `[projects."…"]` trust rows.
640
+ */
641
+ export function upsertTrustBlock(configToml: string, rows: readonly HookTrustRow[]): TrustBlockResult {
642
+ const block = renderTrustBlock(rows);
643
+ const begins = countOccurrences(configToml, DZ_TRUST_BEGIN);
644
+ const ends = countOccurrences(configToml, DZ_TRUST_END);
645
+ const heal = `heal by hand: leave exactly one ${JSON.stringify(DZ_TRUST_BEGIN)} … ${JSON.stringify(DZ_TRUST_END)} pair (or delete both markers) and re-run`;
646
+ if (begins > 1 || ends > 1) {
647
+ return { ok: false, error: `config.toml carries ${begins} dz trust BEGIN and ${ends} END markers — dz refuses to guess which block is its own. ${heal}` };
648
+ }
649
+ if (begins !== ends) {
650
+ return { ok: false, error: `config.toml carries an UNPAIRED dz trust marker (${begins} BEGIN, ${ends} END) — rewriting it would consume unrelated TOML. ${heal}` };
651
+ }
652
+ if (begins === 1) {
653
+ const begin = configToml.indexOf(DZ_TRUST_BEGIN);
654
+ const end = configToml.indexOf(DZ_TRUST_END);
655
+ if (end < begin) {
656
+ return { ok: false, error: `config.toml carries the dz trust END marker BEFORE its BEGIN — the fence is inverted and the span between them is not dz's. ${heal}` };
657
+ }
658
+ const before = configToml.slice(0, begin);
659
+ const after = configToml.slice(end + DZ_TRUST_END.length).replace(/^\n/, '');
660
+ return { ok: true, text: `${before}${block}${after}` };
661
+ }
662
+ const base = configToml === '' || configToml.endsWith('\n') ? configToml : `${configToml}\n`;
663
+ return { ok: true, text: `${base}${base === '' ? '' : '\n'}${block}` };
664
+ }
665
+
666
+ export interface HooksListHookMetadata {
667
+ readonly key: string;
668
+ readonly eventName: string;
669
+ readonly command?: string | null;
670
+ readonly sourcePath: string;
671
+ readonly source: string;
672
+ readonly currentHash: string;
673
+ readonly trustStatus: 'managed' | 'untrusted' | 'trusted' | 'modified';
674
+ readonly enabled: boolean;
675
+ readonly timeoutSec: number;
676
+ }
677
+
678
+ export interface SelectOwnHookOptions {
679
+ /** The registry file dz wrote. A row from any other source path is not dz's row. */
680
+ readonly registryPath: string;
681
+ }
682
+
683
+ /**
684
+ * Pick, from a `hooks/list` response, the metadata of the entries WE wrote.
685
+ *
686
+ * The match is on THREE facts, not one (independent review, finding 7): the row's `sourcePath` is
687
+ * the registry dz wrote, its `eventName` is the event dz registered the entry under, and the
688
+ * command string is byte-equal. Command alone was not enough — a project-scoped
689
+ * `<repo>/.codex/hooks.json` DOES load on codex 0.148 (MEASURED, see ADR-004's addendum), so a
690
+ * shadow copy of dz's own command line could supply the `trusted` row that armed the user-global
691
+ * entry nobody had approved.
692
+ *
693
+ * AMBIGUITY IS REFUSED, not resolved: two rows claiming the same entry drop BOTH, because the one
694
+ * dz would arm is then a coin flip and the trust write is keyed by the row it picked.
695
+ */
696
+ export function selectOwnHookMetadata(
697
+ hooks: readonly HooksListHookMetadata[],
698
+ entries: readonly ManagedEntry[],
699
+ options: SelectOwnHookOptions,
700
+ ): { readonly id: CodexHookId; readonly meta: HooksListHookMetadata }[] {
701
+ const byCommand = new Map(entries.map((e) => [e.command, e] as const));
702
+ const claims = new Map<CodexHookId, { id: CodexHookId; meta: HooksListHookMetadata }[]>();
703
+ for (const meta of hooks) {
704
+ if (typeof meta.command !== 'string') continue;
705
+ const entry = byCommand.get(meta.command);
706
+ if (entry === undefined) continue;
707
+ if (meta.sourcePath !== options.registryPath) continue;
708
+ if (!sameHookEvent(meta.eventName, trustEventName(entry.event))) continue;
709
+ // The KEY is the thing dz writes trust against, so it — not just the row's other fields — has
710
+ // to be the key our entry would own (R2-7). An arbitrary or foreign key is ambiguous by
711
+ // construction: arming it would persist trust for something we cannot identify.
712
+ const parsedKey = typeof meta.key === 'string' ? parseTrustKey(meta.key) : null;
713
+ if (parsedKey === null) continue;
714
+ if (parsedKey.sourcePath !== options.registryPath) continue;
715
+ if (!sameHookEvent(parsedKey.event, trustEventName(entry.event))) continue;
716
+ (claims.get(entry.id) ?? claims.set(entry.id, []).get(entry.id)!).push({ id: entry.id, meta });
717
+ }
718
+ const out: { id: CodexHookId; meta: HooksListHookMetadata }[] = [];
719
+ for (const rows of claims.values()) {
720
+ if (rows.length === 1) out.push(rows[0]!);
721
+ }
722
+ return out;
723
+ }