@dzhechkov/harness-core 0.4.4 → 0.5.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.
Files changed (97) hide show
  1. package/.dz-manifest.json +283 -103
  2. package/LICENSE +21 -0
  3. package/README.md +73 -5
  4. package/dist/agents-policy.d.ts +67 -0
  5. package/dist/agents-policy.d.ts.map +1 -0
  6. package/dist/agents-policy.js +258 -0
  7. package/dist/agents-policy.js.map +1 -0
  8. package/dist/codex-hooks-assets.d.ts +47 -0
  9. package/dist/codex-hooks-assets.d.ts.map +1 -0
  10. package/dist/codex-hooks-assets.js +287 -0
  11. package/dist/codex-hooks-assets.js.map +1 -0
  12. package/dist/codex-hooks-verify.d.ts +74 -0
  13. package/dist/codex-hooks-verify.d.ts.map +1 -0
  14. package/dist/codex-hooks-verify.js +140 -0
  15. package/dist/codex-hooks-verify.js.map +1 -0
  16. package/dist/codex-hooks.d.ts +258 -0
  17. package/dist/codex-hooks.d.ts.map +1 -0
  18. package/dist/codex-hooks.js +391 -0
  19. package/dist/codex-hooks.js.map +1 -0
  20. package/dist/discrimination-gate.d.ts +88 -15
  21. package/dist/discrimination-gate.d.ts.map +1 -1
  22. package/dist/discrimination-gate.js +343 -51
  23. package/dist/discrimination-gate.js.map +1 -1
  24. package/dist/feature-adr-checkpoints.d.ts +22 -0
  25. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  26. package/dist/feature-adr-checkpoints.js +42 -0
  27. package/dist/feature-adr-checkpoints.js.map +1 -1
  28. package/dist/feature-adr-routing.d.ts +196 -5
  29. package/dist/feature-adr-routing.d.ts.map +1 -1
  30. package/dist/feature-adr-routing.js +538 -54
  31. package/dist/feature-adr-routing.js.map +1 -1
  32. package/dist/guard.d.ts +13 -0
  33. package/dist/guard.d.ts.map +1 -1
  34. package/dist/guard.js +25 -1
  35. package/dist/guard.js.map +1 -1
  36. package/dist/index.d.ts +17 -7
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +23 -4
  39. package/dist/index.js.map +1 -1
  40. package/dist/loop-blobs.generated.js +2 -2
  41. package/dist/loop-blobs.generated.js.map +1 -1
  42. package/dist/managed-hooks.d.ts +76 -0
  43. package/dist/managed-hooks.d.ts.map +1 -0
  44. package/dist/managed-hooks.js +89 -0
  45. package/dist/managed-hooks.js.map +1 -0
  46. package/dist/mutation-gate.d.ts +14 -0
  47. package/dist/mutation-gate.d.ts.map +1 -1
  48. package/dist/mutation-gate.js +25 -2
  49. package/dist/mutation-gate.js.map +1 -1
  50. package/dist/operations.d.ts +153 -0
  51. package/dist/operations.d.ts.map +1 -1
  52. package/dist/operations.js +560 -24
  53. package/dist/operations.js.map +1 -1
  54. package/dist/parity.d.ts +38 -1
  55. package/dist/parity.d.ts.map +1 -1
  56. package/dist/parity.js +78 -5
  57. package/dist/parity.js.map +1 -1
  58. package/dist/recall-usage.d.ts +53 -0
  59. package/dist/recall-usage.d.ts.map +1 -1
  60. package/dist/recall-usage.js +125 -2
  61. package/dist/recall-usage.js.map +1 -1
  62. package/dist/setup.d.ts.map +1 -1
  63. package/dist/setup.js +14 -26
  64. package/dist/setup.js.map +1 -1
  65. package/dist/shell-veto-policy.d.ts +53 -0
  66. package/dist/shell-veto-policy.d.ts.map +1 -0
  67. package/dist/shell-veto-policy.js +103 -0
  68. package/dist/shell-veto-policy.js.map +1 -0
  69. package/dist/skills.d.ts +86 -1
  70. package/dist/skills.d.ts.map +1 -1
  71. package/dist/skills.js +116 -1
  72. package/dist/skills.js.map +1 -1
  73. package/dist/targets.d.ts +75 -0
  74. package/dist/targets.d.ts.map +1 -1
  75. package/dist/targets.js +160 -0
  76. package/dist/targets.js.map +1 -1
  77. package/package.json +20 -19
  78. package/sbom.json +552 -102
  79. package/src/agents-policy.ts +338 -0
  80. package/src/codex-hooks-assets.ts +291 -0
  81. package/src/codex-hooks-verify.ts +184 -0
  82. package/src/codex-hooks.ts +571 -0
  83. package/src/discrimination-gate.ts +456 -58
  84. package/src/feature-adr-checkpoints.ts +38 -0
  85. package/src/feature-adr-routing.ts +642 -75
  86. package/src/guard.ts +36 -1
  87. package/src/index.ts +118 -2
  88. package/src/loop-blobs.generated.ts +2 -2
  89. package/src/managed-hooks.ts +129 -0
  90. package/src/mutation-gate.ts +24 -2
  91. package/src/operations.ts +719 -28
  92. package/src/parity.ts +120 -6
  93. package/src/recall-usage.ts +184 -1
  94. package/src/setup.ts +26 -27
  95. package/src/shell-veto-policy.ts +119 -0
  96. package/src/skills.ts +174 -1
  97. package/src/targets.ts +189 -0
@@ -0,0 +1,258 @@
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
+ /** Bump when a helper BODY changes: a changed body changes codex's `currentHash` ⇒ re-trust. */
26
+ export declare const DZ_HOOK_HELPER_VERSION = 1;
27
+ /** Seconds. Probe-proven (spike S2): `timeout` is honored, the unset default is 600 s. */
28
+ export declare const DZ_HOOK_TIMEOUT_SECONDS = 5;
29
+ /** The wide matcher (AM-8). Narrowing needs a recorded live probe; the guard keys on the payload. */
30
+ export declare const DZ_VETO_MATCHER = "Bash|shell|local_shell";
31
+ export interface CodexHooksPaths {
32
+ readonly codexHome: string;
33
+ readonly registry: string;
34
+ readonly configToml: string;
35
+ readonly helperDir: string;
36
+ readonly vetoHelper: string;
37
+ readonly recallHelper: string;
38
+ readonly manifest: string;
39
+ readonly errorLog: string;
40
+ }
41
+ /** Every path this leg touches, all `CODEX_HOME`-relative (AM-13). */
42
+ export declare function codexHooksPaths(codexHome: string): CodexHooksPaths;
43
+ /** The EMITTER's write-set (AM-12). The RUNTIME's is stated separately — see {@link runtimeWriteSet}. */
44
+ export declare function emitterWriteSet(paths: CodexHooksPaths): readonly string[];
45
+ /**
46
+ * The RUNTIME's write-set (AM-33). Exactly one path beyond the emitter's, and it is
47
+ * `CODEX_HOME`-local: a helper must never create a `.dz/` inside a repository the user merely ran
48
+ * `codex` in. (Recall rows are the one exception and they are opt-in-gated: they land in an
49
+ * ALREADY-EXISTING opted-in project's `.dz/`, so nothing is ever created.)
50
+ */
51
+ export declare function runtimeWriteSet(paths: CodexHooksPaths): readonly string[];
52
+ /** A single quote inside a single-quoted shell word cannot be escaped — such a path is REFUSED. */
53
+ export declare function isSafeForSingleQuote(path: string): boolean;
54
+ export declare function singleQuote(path: string): string;
55
+ /**
56
+ * The shell string codex runs. Absolute interpreter + single-quoted paths (AM-32/AM-35d).
57
+ * @throws when either path cannot be safely quoted — never emit a broken entry that READS installed.
58
+ */
59
+ export declare function buildHookCommand(nodePath: string, scriptPath: string): string;
60
+ export type CodexHookId = 'codex-veto' | 'codex-recall';
61
+ export type CodexHookEvent = 'PreToolUse' | 'UserPromptSubmit';
62
+ export interface CodexHookSpec {
63
+ readonly id: CodexHookId;
64
+ readonly event: CodexHookEvent;
65
+ readonly matcher?: string;
66
+ readonly script: 'veto' | 'recall';
67
+ }
68
+ /** The two entries this leg ships, and nothing else. */
69
+ export declare const CODEX_MANAGED_HOOKS: readonly CodexHookSpec[];
70
+ export interface BuildManagedEntriesOptions {
71
+ readonly nodePath: string;
72
+ readonly paths: CodexHooksPaths;
73
+ }
74
+ export interface ManagedEntry {
75
+ readonly id: CodexHookId;
76
+ readonly event: CodexHookEvent;
77
+ readonly command: string;
78
+ readonly entry: Record<string, unknown>;
79
+ }
80
+ /**
81
+ * Build the managed entries.
82
+ *
83
+ * Key set is EXACTLY `{matcher?, hooks:[{type, command, timeout}]}` (AM-15). `timeout` is included
84
+ * because spike S2 recorded a probe proving the registry loads with it AND honors it; every other
85
+ * key stays out until it has its own probe.
86
+ */
87
+ export declare function buildManagedEntries(opts: BuildManagedEntriesOptions): readonly ManagedEntry[];
88
+ /** `managed` in the shape {@link mergeManagedHookEntries} takes. */
89
+ export declare function managedByEvent(entries: readonly ManagedEntry[]): Record<string, unknown[]>;
90
+ export declare function codexHookSha256(text: string): string;
91
+ export interface CodexHookManifestEntry {
92
+ readonly id: CodexHookId;
93
+ readonly event: CodexHookEvent;
94
+ readonly matcher?: string;
95
+ readonly commandSha256: string;
96
+ /** The runtime trust key, as reported by `hooks/list`. Absent until a list succeeded. */
97
+ readonly trustKey?: string;
98
+ }
99
+ export interface CodexHookVerifyRecord {
100
+ readonly verdict: string;
101
+ readonly trust: string;
102
+ readonly bypassed: boolean;
103
+ readonly at: string;
104
+ readonly transcript?: string;
105
+ }
106
+ export interface CodexHookManifest {
107
+ readonly version: 1;
108
+ readonly writtenAt: string;
109
+ readonly codexVersion: string;
110
+ readonly registryPath: string;
111
+ readonly helperVersion: number;
112
+ readonly nodePath: string;
113
+ readonly entries: readonly CodexHookManifestEntry[];
114
+ readonly lastVerify?: CodexHookVerifyRecord;
115
+ }
116
+ export interface BuildManifestOptions {
117
+ readonly entries: readonly ManagedEntry[];
118
+ readonly paths: CodexHooksPaths;
119
+ readonly codexVersion: string;
120
+ readonly writtenAt: string;
121
+ readonly nodePath: string;
122
+ readonly trustKeys?: Readonly<Record<string, string>>;
123
+ readonly lastVerify?: CodexHookVerifyRecord;
124
+ }
125
+ export declare function buildCodexHookManifest(opts: BuildManifestOptions): CodexHookManifest;
126
+ export declare function parseCodexHookManifest(text: string): CodexHookManifest | undefined;
127
+ /**
128
+ * Attribution is `codexHookSha256(command) ∈ manifest` — never a substring guess.
129
+ *
130
+ * ADR-001 §3: dz deletes only what it can PROVE it wrote. An entry that merely looks like ours (it
131
+ * mentions our helper filename) but whose command hash is absent from the manifest is KEPT, counted
132
+ * in `unattributable`, and warned about. A hand-edited managed command therefore survives.
133
+ */
134
+ export declare function isDzManagedEntry(entry: unknown, manifest: CodexHookManifest | undefined): boolean;
135
+ /** Cheap resemblance test — drives the `unattributable` COUNT only, never a deletion. */
136
+ export declare function looksLikeDzEntry(entry: unknown): boolean;
137
+ export interface CodexRegistry {
138
+ /** Every top-level key other than `hooks`, preserved verbatim. */
139
+ readonly rest: Record<string, unknown>;
140
+ readonly hooks: Record<string, unknown[]>;
141
+ }
142
+ export type ParseRegistryResult = {
143
+ readonly ok: true;
144
+ readonly registry: CodexRegistry;
145
+ } | {
146
+ readonly ok: false;
147
+ readonly error: string;
148
+ };
149
+ /**
150
+ * Parse an existing registry. An unparseable file is an ERROR, never an empty registry: silently
151
+ * treating it as `{}` would overwrite entries we promised to preserve (I1).
152
+ */
153
+ export declare function parseCodexRegistry(text: string | undefined): ParseRegistryResult;
154
+ export declare function serializeCodexRegistry(registry: CodexRegistry): string;
155
+ export interface CodexHooksPlan {
156
+ readonly registry: CodexRegistry;
157
+ readonly text: string;
158
+ readonly changed: boolean;
159
+ readonly foreignPreserved: number;
160
+ readonly unattributable: number;
161
+ }
162
+ export interface PlanCodexHooksInput {
163
+ readonly currentText: string | undefined;
164
+ readonly entries: readonly ManagedEntry[];
165
+ readonly manifest: CodexHookManifest | undefined;
166
+ }
167
+ /**
168
+ * Compute the merged registry.
169
+ *
170
+ * The merge itself is `mergeManagedHookEntries` — the SAME implementation the Claude path uses
171
+ * (AM-3 / G-E). Only the attribution predicate differs.
172
+ */
173
+ export declare function planCodexHooks(input: PlanCodexHooksInput): {
174
+ ok: true;
175
+ plan: CodexHooksPlan;
176
+ } | {
177
+ ok: false;
178
+ error: string;
179
+ };
180
+ export interface RemoveCodexHooksResult {
181
+ readonly registry: CodexRegistry;
182
+ readonly text: string;
183
+ readonly removed: number;
184
+ readonly changed: boolean;
185
+ readonly unattributable: number;
186
+ }
187
+ /**
188
+ * `--remove`: delete ONLY manifest-attributed entries.
189
+ *
190
+ * With the manifest deleted this removes **zero** entries and reports them all as `unattributable`.
191
+ * That is the intended, conservative behaviour: dz never deletes what it cannot prove it wrote.
192
+ */
193
+ export declare function removeCodexHooks(currentText: string | undefined, manifest: CodexHookManifest | undefined): {
194
+ ok: true;
195
+ result: RemoveCodexHooksResult;
196
+ } | {
197
+ ok: false;
198
+ error: string;
199
+ };
200
+ export interface DriftReport {
201
+ /** Both managed entries present in the registry and attributable. */
202
+ readonly installed: boolean;
203
+ readonly presentIds: readonly CodexHookId[];
204
+ readonly missingIds: readonly CodexHookId[];
205
+ readonly foreignPreserved: number;
206
+ readonly unattributable: number;
207
+ /** A managed command whose hash is not the one the manifest records for that id. */
208
+ readonly drifted: readonly CodexHookId[];
209
+ }
210
+ /** `--check` recomputes from the FILE; it never trusts the manifest's claim on its own. */
211
+ export declare function diffCodexHooks(currentText: string | undefined, entries: readonly ManagedEntry[], manifest: CodexHookManifest | undefined): DriftReport;
212
+ /** codex's snake_case event spelling inside a trust key (MEASURED from `hooks/list`). */
213
+ export declare function trustEventName(event: CodexHookEvent): string;
214
+ /**
215
+ * The trust key codex uses: `<sourcePath>:<snake_event>:<groupIndex>:<hookIndex>`.
216
+ *
217
+ * This is the EXPECTED spelling, used only to cross-check what `hooks/list` reports. The install
218
+ * path uses the reported key verbatim — a computed key that drifts from the runtime's would arm
219
+ * nothing while reading like success.
220
+ */
221
+ export declare function expectedTrustKey(registryPath: string, event: CodexHookEvent, groupIndex: number, hookIndex: number): string;
222
+ export interface HookTrustRow {
223
+ readonly key: string;
224
+ readonly trustedHash: string;
225
+ }
226
+ export declare const DZ_TRUST_BEGIN = "# --- dz codex hooks trust (managed block, dz-rewritten) ---";
227
+ export declare const DZ_TRUST_END = "# --- end dz codex hooks trust ---";
228
+ /**
229
+ * Render dz's trust rows as a MANAGED BLOCK in `config.toml`.
230
+ *
231
+ * A managed block, not a whole-file rewrite: `~/.codex/config.toml` already carries a
232
+ * `ruvnet-brain` managed block and seven `[projects."…"]` trust rows on this machine (MEASURED),
233
+ * and dz is not the only writer. The block is delimited so a re-run replaces exactly dz's rows and
234
+ * nothing else.
235
+ */
236
+ export declare function renderTrustBlock(rows: readonly HookTrustRow[]): string;
237
+ /** Replace (or append) dz's managed trust block, leaving every other byte of config.toml alone. */
238
+ export declare function upsertTrustBlock(configToml: string, rows: readonly HookTrustRow[]): string;
239
+ export interface HooksListHookMetadata {
240
+ readonly key: string;
241
+ readonly eventName: string;
242
+ readonly command?: string | null;
243
+ readonly sourcePath: string;
244
+ readonly source: string;
245
+ readonly currentHash: string;
246
+ readonly trustStatus: 'managed' | 'untrusted' | 'trusted' | 'modified';
247
+ readonly enabled: boolean;
248
+ readonly timeoutSec: number;
249
+ }
250
+ /**
251
+ * Pick, from a `hooks/list` response, the metadata of the entries WE wrote — matched on the exact
252
+ * command string, so a third party's entry can never inherit dz's trust write.
253
+ */
254
+ export declare function selectOwnHookMetadata(hooks: readonly HooksListHookMetadata[], entries: readonly ManagedEntry[]): {
255
+ readonly id: CodexHookId;
256
+ readonly meta: HooksListHookMetadata;
257
+ }[];
258
+ //# sourceMappingURL=codex-hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codex-hooks.d.ts","sourceRoot":"","sources":["../src/codex-hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAOH,gGAAgG;AAChG,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC,0FAA0F;AAC1F,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,qGAAqG;AACrG,eAAO,MAAM,eAAe,2BAA2B,CAAC;AAExD,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,sEAAsE;AACtE,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,eAAe,CAYlE;AAED,yGAAyG;AACzG,wBAAgB,eAAe,CAAC,KAAK,EAAE,eAAe,GAAG,SAAS,MAAM,EAAE,CAEzE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,eAAe,GAAG,SAAS,MAAM,EAAE,CAEzE;AAMD,mGAAmG;AACnG,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE1D;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAO7E;AAMD,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,cAAc,CAAC;AACxD,MAAM,MAAM,cAAc,GAAG,YAAY,GAAG,kBAAkB,CAAC;AAE/D,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,EAAE,WAAW,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,CAAC;CACpC;AAED,wDAAwD;AACxD,eAAO,MAAM,mBAAmB,EAAE,SAAS,aAAa,EAGvD,CAAC;AAEF,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;CACjC;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,EAAE,WAAW,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACzC;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,0BAA0B,GAAG,SAAS,YAAY,EAAE,CAU7F;AAED,oEAAoE;AACpE,wBAAgB,cAAc,CAAC,OAAO,EAAE,SAAS,YAAY,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAM1F;AAMD,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE,EAAE,WAAW,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,yFAAyF;IACzF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;IACpB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,sBAAsB,EAAE,CAAC;IACpD,QAAQ,CAAC,UAAU,CAAC,EAAE,qBAAqB,CAAC;CAC7C;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,OAAO,EAAE,SAAS,YAAY,EAAE,CAAC;IAC1C,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,QAAQ,CAAC,UAAU,CAAC,EAAE,qBAAqB,CAAC;CAC7C;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,oBAAoB,GAAG,iBAAiB,CAqBpF;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS,CAUlF;AAQD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,iBAAiB,GAAG,SAAS,GAAG,OAAO,CAIjG;AAED,yFAAyF;AACzF,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIxD;AAMD,MAAM,WAAW,aAAa;IAC5B,kEAAkE;IAClE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;CAC3C;AAED,MAAM,MAAM,mBAAmB,GAC3B;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,mBAAmB,CAyBhF;AAED,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,CAEtE;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,OAAO,EAAE,SAAS,YAAY,EAAE,CAAC;IAC1C,QAAQ,CAAC,QAAQ,EAAE,iBAAiB,GAAG,SAAS,CAAC;CAClD;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,mBAAmB,GACzB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,cAAc,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAkCnE;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,QAAQ,EAAE,iBAAiB,GAAG,SAAS,GACtC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,sBAAsB,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAsB7E;AAED,MAAM,WAAW,WAAW;IAC1B,qEAAqE;IACrE,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,SAAS,WAAW,EAAE,CAAC;IAC5C,QAAQ,CAAC,UAAU,EAAE,SAAS,WAAW,EAAE,CAAC;IAC5C,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,oFAAoF;IACpF,QAAQ,CAAC,OAAO,EAAE,SAAS,WAAW,EAAE,CAAC;CAC1C;AAED,2FAA2F;AAC3F,wBAAgB,cAAc,CAC5B,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,OAAO,EAAE,SAAS,YAAY,EAAE,EAChC,QAAQ,EAAE,iBAAiB,GAAG,SAAS,GACtC,WAAW,CAoCb;AAMD,yFAAyF;AACzF,wBAAgB,cAAc,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAE5D;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,cAAc,EACrB,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GAChB,MAAM,CAER;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAOD,eAAO,MAAM,cAAc,iEAAiE,CAAC;AAC7F,eAAO,MAAM,YAAY,uCAAuC,CAAC;AAEjE;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,YAAY,EAAE,GAAG,MAAM,CAMtE;AAED,mGAAmG;AACnG,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,YAAY,EAAE,GAAG,MAAM,CAW1F;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,WAAW,EAAE,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,UAAU,CAAC;IACvE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,SAAS,qBAAqB,EAAE,EACvC,OAAO,EAAE,SAAS,YAAY,EAAE,GAC/B;IAAE,QAAQ,CAAC,EAAE,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,qBAAqB,CAAA;CAAE,EAAE,CAQtE"}
@@ -0,0 +1,391 @@
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
+ import { createHash } from 'node:crypto';
26
+ import { join } from 'node:path';
27
+ import { mergeManagedHookEntries } from './managed-hooks.js';
28
+ /** Bump when a helper BODY changes: a changed body changes codex's `currentHash` ⇒ re-trust. */
29
+ export const DZ_HOOK_HELPER_VERSION = 1;
30
+ /** Seconds. Probe-proven (spike S2): `timeout` is honored, the unset default is 600 s. */
31
+ export const DZ_HOOK_TIMEOUT_SECONDS = 5;
32
+ /** The wide matcher (AM-8). Narrowing needs a recorded live probe; the guard keys on the payload. */
33
+ export const DZ_VETO_MATCHER = 'Bash|shell|local_shell';
34
+ /** Every path this leg touches, all `CODEX_HOME`-relative (AM-13). */
35
+ export function codexHooksPaths(codexHome) {
36
+ const helperDir = join(codexHome, 'dz-hooks');
37
+ return {
38
+ codexHome,
39
+ registry: join(codexHome, 'hooks.json'),
40
+ configToml: join(codexHome, 'config.toml'),
41
+ helperDir,
42
+ vetoHelper: join(helperDir, 'dz-codex-veto.cjs'),
43
+ recallHelper: join(helperDir, 'dz-codex-recall.cjs'),
44
+ manifest: join(helperDir, 'manifest.json'),
45
+ errorLog: join(helperDir, 'helper-errors.jsonl'),
46
+ };
47
+ }
48
+ /** The EMITTER's write-set (AM-12). The RUNTIME's is stated separately — see {@link runtimeWriteSet}. */
49
+ export function emitterWriteSet(paths) {
50
+ return [paths.registry, paths.vetoHelper, paths.recallHelper, paths.manifest];
51
+ }
52
+ /**
53
+ * The RUNTIME's write-set (AM-33). Exactly one path beyond the emitter's, and it is
54
+ * `CODEX_HOME`-local: a helper must never create a `.dz/` inside a repository the user merely ran
55
+ * `codex` in. (Recall rows are the one exception and they are opt-in-gated: they land in an
56
+ * ALREADY-EXISTING opted-in project's `.dz/`, so nothing is ever created.)
57
+ */
58
+ export function runtimeWriteSet(paths) {
59
+ return [paths.errorLog];
60
+ }
61
+ /* -------------------------------------------------------------------------- */
62
+ /* Quoting */
63
+ /* -------------------------------------------------------------------------- */
64
+ /** A single quote inside a single-quoted shell word cannot be escaped — such a path is REFUSED. */
65
+ export function isSafeForSingleQuote(path) {
66
+ return typeof path === 'string' && path !== '' && !path.includes("'") && !path.includes('\n');
67
+ }
68
+ export function singleQuote(path) {
69
+ return `'${path}'`;
70
+ }
71
+ /**
72
+ * The shell string codex runs. Absolute interpreter + single-quoted paths (AM-32/AM-35d).
73
+ * @throws when either path cannot be safely quoted — never emit a broken entry that READS installed.
74
+ */
75
+ export function buildHookCommand(nodePath, scriptPath) {
76
+ if (!isSafeForSingleQuote(nodePath) || !isSafeForSingleQuote(scriptPath)) {
77
+ throw new Error(`refusing to emit a hook command containing an unquotable path (single quote or newline): ${nodePath} ${scriptPath}`);
78
+ }
79
+ return `${singleQuote(nodePath)} ${singleQuote(scriptPath)}`;
80
+ }
81
+ /** The two entries this leg ships, and nothing else. */
82
+ export const CODEX_MANAGED_HOOKS = [
83
+ { id: 'codex-veto', event: 'PreToolUse', matcher: DZ_VETO_MATCHER, script: 'veto' },
84
+ { id: 'codex-recall', event: 'UserPromptSubmit', script: 'recall' },
85
+ ];
86
+ /**
87
+ * Build the managed entries.
88
+ *
89
+ * Key set is EXACTLY `{matcher?, hooks:[{type, command, timeout}]}` (AM-15). `timeout` is included
90
+ * because spike S2 recorded a probe proving the registry loads with it AND honors it; every other
91
+ * key stays out until it has its own probe.
92
+ */
93
+ export function buildManagedEntries(opts) {
94
+ return CODEX_MANAGED_HOOKS.map((spec) => {
95
+ const script = spec.script === 'veto' ? opts.paths.vetoHelper : opts.paths.recallHelper;
96
+ const command = buildHookCommand(opts.nodePath, script);
97
+ const entry = {
98
+ ...(spec.matcher !== undefined ? { matcher: spec.matcher } : {}),
99
+ hooks: [{ type: 'command', command, timeout: DZ_HOOK_TIMEOUT_SECONDS }],
100
+ };
101
+ return { id: spec.id, event: spec.event, command, entry };
102
+ });
103
+ }
104
+ /** `managed` in the shape {@link mergeManagedHookEntries} takes. */
105
+ export function managedByEvent(entries) {
106
+ const out = {};
107
+ for (const e of entries) {
108
+ (out[e.event] ??= []).push(e.entry);
109
+ }
110
+ return out;
111
+ }
112
+ /* -------------------------------------------------------------------------- */
113
+ /* Manifest + attribution */
114
+ /* -------------------------------------------------------------------------- */
115
+ export function codexHookSha256(text) {
116
+ return createHash('sha256').update(text, 'utf8').digest('hex');
117
+ }
118
+ export function buildCodexHookManifest(opts) {
119
+ return {
120
+ version: 1,
121
+ writtenAt: opts.writtenAt,
122
+ codexVersion: opts.codexVersion,
123
+ registryPath: opts.paths.registry,
124
+ helperVersion: DZ_HOOK_HELPER_VERSION,
125
+ nodePath: opts.nodePath,
126
+ entries: opts.entries.map((e) => {
127
+ const spec = CODEX_MANAGED_HOOKS.find((s) => s.id === e.id);
128
+ const trustKey = opts.trustKeys?.[e.id];
129
+ return {
130
+ id: e.id,
131
+ event: e.event,
132
+ ...(spec?.matcher !== undefined ? { matcher: spec.matcher } : {}),
133
+ commandSha256: codexHookSha256(e.command),
134
+ ...(trustKey !== undefined ? { trustKey } : {}),
135
+ };
136
+ }),
137
+ ...(opts.lastVerify !== undefined ? { lastVerify: opts.lastVerify } : {}),
138
+ };
139
+ }
140
+ export function parseCodexHookManifest(text) {
141
+ try {
142
+ const parsed = JSON.parse(text);
143
+ if (parsed === null || typeof parsed !== 'object')
144
+ return undefined;
145
+ const m = parsed;
146
+ if (m.version !== 1 || !Array.isArray(m.entries))
147
+ return undefined;
148
+ return m;
149
+ }
150
+ catch {
151
+ return undefined;
152
+ }
153
+ }
154
+ function entryCommands(entry) {
155
+ const e = entry;
156
+ if (Array.isArray(e?.hooks))
157
+ return e.hooks.map((h) => String(h?.command ?? ''));
158
+ return [String(e?.command ?? '')];
159
+ }
160
+ /**
161
+ * Attribution is `codexHookSha256(command) ∈ manifest` — never a substring guess.
162
+ *
163
+ * ADR-001 §3: dz deletes only what it can PROVE it wrote. An entry that merely looks like ours (it
164
+ * mentions our helper filename) but whose command hash is absent from the manifest is KEPT, counted
165
+ * in `unattributable`, and warned about. A hand-edited managed command therefore survives.
166
+ */
167
+ export function isDzManagedEntry(entry, manifest) {
168
+ if (manifest === undefined)
169
+ return false;
170
+ const known = new Set(manifest.entries.map((e) => e.commandSha256));
171
+ return entryCommands(entry).some((cmd) => cmd !== '' && known.has(codexHookSha256(cmd)));
172
+ }
173
+ /** Cheap resemblance test — drives the `unattributable` COUNT only, never a deletion. */
174
+ export function looksLikeDzEntry(entry) {
175
+ return entryCommands(entry).some((cmd) => cmd.includes('dz-codex-veto.cjs') || cmd.includes('dz-codex-recall.cjs'));
176
+ }
177
+ /**
178
+ * Parse an existing registry. An unparseable file is an ERROR, never an empty registry: silently
179
+ * treating it as `{}` would overwrite entries we promised to preserve (I1).
180
+ */
181
+ export function parseCodexRegistry(text) {
182
+ if (text === undefined || text.trim() === '') {
183
+ return { ok: true, registry: { rest: {}, hooks: {} } };
184
+ }
185
+ let parsed;
186
+ try {
187
+ parsed = JSON.parse(text);
188
+ }
189
+ catch (err) {
190
+ return { ok: false, error: `hooks.json is not valid JSON: ${err.message}` };
191
+ }
192
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
193
+ return { ok: false, error: 'hooks.json must be a JSON object' };
194
+ }
195
+ const obj = parsed;
196
+ const rawHooks = obj['hooks'];
197
+ if (rawHooks !== undefined && (rawHooks === null || typeof rawHooks !== 'object' || Array.isArray(rawHooks))) {
198
+ return { ok: false, error: 'hooks.json "hooks" must be an object keyed by event name' };
199
+ }
200
+ const hooks = {};
201
+ for (const [event, value] of Object.entries((rawHooks ?? {}))) {
202
+ hooks[event] = Array.isArray(value) ? [...value] : [];
203
+ }
204
+ const rest = {};
205
+ for (const [k, v] of Object.entries(obj))
206
+ if (k !== 'hooks')
207
+ rest[k] = v;
208
+ return { ok: true, registry: { rest, hooks } };
209
+ }
210
+ export function serializeCodexRegistry(registry) {
211
+ return `${JSON.stringify({ ...registry.rest, hooks: registry.hooks }, null, 2)}\n`;
212
+ }
213
+ /**
214
+ * Compute the merged registry.
215
+ *
216
+ * The merge itself is `mergeManagedHookEntries` — the SAME implementation the Claude path uses
217
+ * (AM-3 / G-E). Only the attribution predicate differs.
218
+ */
219
+ export function planCodexHooks(input) {
220
+ const parsed = parseCodexRegistry(input.currentText);
221
+ if (!parsed.ok)
222
+ return { ok: false, error: parsed.error };
223
+ // The union of the manifest's hashes and the ones we are about to write: on a re-run the manifest
224
+ // is current, on a helper-version bump it is not, and BOTH vintages of our own entry must be
225
+ // replaced rather than duplicated.
226
+ const selfManifest = {
227
+ version: 1,
228
+ writtenAt: '',
229
+ codexVersion: '',
230
+ registryPath: '',
231
+ helperVersion: DZ_HOOK_HELPER_VERSION,
232
+ nodePath: '',
233
+ entries: [
234
+ ...(input.manifest?.entries ?? []),
235
+ ...input.entries.map((e) => ({ id: e.id, event: e.event, commandSha256: codexHookSha256(e.command) })),
236
+ ],
237
+ };
238
+ const plan = mergeManagedHookEntries(parsed.registry.hooks, managedByEvent(input.entries), {
239
+ isManaged: (entry) => isDzManagedEntry(entry, selfManifest),
240
+ looksLikeOurs: (entry) => looksLikeDzEntry(entry) && !isDzManagedEntry(entry, selfManifest),
241
+ reportLabel: 'codex',
242
+ });
243
+ const registry = { rest: parsed.registry.rest, hooks: plan.hooks };
244
+ return {
245
+ ok: true,
246
+ plan: {
247
+ registry,
248
+ text: serializeCodexRegistry(registry),
249
+ changed: plan.changed,
250
+ foreignPreserved: plan.foreignPreserved,
251
+ unattributable: plan.unattributable,
252
+ },
253
+ };
254
+ }
255
+ /**
256
+ * `--remove`: delete ONLY manifest-attributed entries.
257
+ *
258
+ * With the manifest deleted this removes **zero** entries and reports them all as `unattributable`.
259
+ * That is the intended, conservative behaviour: dz never deletes what it cannot prove it wrote.
260
+ */
261
+ export function removeCodexHooks(currentText, manifest) {
262
+ const parsed = parseCodexRegistry(currentText);
263
+ if (!parsed.ok)
264
+ return { ok: false, error: parsed.error };
265
+ const hooks = {};
266
+ let removed = 0;
267
+ let unattributable = 0;
268
+ for (const [event, entries] of Object.entries(parsed.registry.hooks)) {
269
+ const kept = entries.filter((entry) => {
270
+ if (isDzManagedEntry(entry, manifest)) {
271
+ removed += 1;
272
+ return false;
273
+ }
274
+ if (looksLikeDzEntry(entry))
275
+ unattributable += 1;
276
+ return true;
277
+ });
278
+ hooks[event] = kept;
279
+ }
280
+ const registry = { rest: parsed.registry.rest, hooks };
281
+ return {
282
+ ok: true,
283
+ result: { registry, text: serializeCodexRegistry(registry), removed, changed: removed > 0, unattributable },
284
+ };
285
+ }
286
+ /** `--check` recomputes from the FILE; it never trusts the manifest's claim on its own. */
287
+ export function diffCodexHooks(currentText, entries, manifest) {
288
+ const parsed = parseCodexRegistry(currentText);
289
+ const hooks = parsed.ok ? parsed.registry.hooks : {};
290
+ const present = [];
291
+ const drifted = [];
292
+ let foreignPreserved = 0;
293
+ let unattributable = 0;
294
+ const wantByEvent = new Map(entries.map((e) => [e.event, e]));
295
+ for (const [event, list] of Object.entries(hooks)) {
296
+ for (const entry of list) {
297
+ const want = wantByEvent.get(event);
298
+ const cmds = entryCommands(entry);
299
+ if (want !== undefined && cmds.includes(want.command)) {
300
+ present.push(want.id);
301
+ continue;
302
+ }
303
+ if (isDzManagedEntry(entry, manifest)) {
304
+ // Ours by manifest, but not the command we would write now: an old helper version.
305
+ const id = manifest?.entries.find((m) => cmds.some((c) => codexHookSha256(c) === m.commandSha256))?.id;
306
+ if (id !== undefined)
307
+ drifted.push(id);
308
+ continue;
309
+ }
310
+ foreignPreserved += 1;
311
+ if (looksLikeDzEntry(entry))
312
+ unattributable += 1;
313
+ }
314
+ }
315
+ const missing = entries.map((e) => e.id).filter((id) => !present.includes(id));
316
+ return {
317
+ installed: missing.length === 0,
318
+ presentIds: present,
319
+ missingIds: missing,
320
+ foreignPreserved,
321
+ unattributable,
322
+ drifted,
323
+ };
324
+ }
325
+ /* -------------------------------------------------------------------------- */
326
+ /* Trust (the M0 arming route) */
327
+ /* -------------------------------------------------------------------------- */
328
+ /** codex's snake_case event spelling inside a trust key (MEASURED from `hooks/list`). */
329
+ export function trustEventName(event) {
330
+ return event === 'PreToolUse' ? 'pre_tool_use' : 'user_prompt_submit';
331
+ }
332
+ /**
333
+ * The trust key codex uses: `<sourcePath>:<snake_event>:<groupIndex>:<hookIndex>`.
334
+ *
335
+ * This is the EXPECTED spelling, used only to cross-check what `hooks/list` reports. The install
336
+ * path uses the reported key verbatim — a computed key that drifts from the runtime's would arm
337
+ * nothing while reading like success.
338
+ */
339
+ export function expectedTrustKey(registryPath, event, groupIndex, hookIndex) {
340
+ return `${registryPath}:${trustEventName(event)}:${groupIndex}:${hookIndex}`;
341
+ }
342
+ /** TOML-escape a bare-string key (the key is an absolute path plus `:` separators). */
343
+ function tomlQuote(value) {
344
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
345
+ }
346
+ export const DZ_TRUST_BEGIN = '# --- dz codex hooks trust (managed block, dz-rewritten) ---';
347
+ export const DZ_TRUST_END = '# --- end dz codex hooks trust ---';
348
+ /**
349
+ * Render dz's trust rows as a MANAGED BLOCK in `config.toml`.
350
+ *
351
+ * A managed block, not a whole-file rewrite: `~/.codex/config.toml` already carries a
352
+ * `ruvnet-brain` managed block and seven `[projects."…"]` trust rows on this machine (MEASURED),
353
+ * and dz is not the only writer. The block is delimited so a re-run replaces exactly dz's rows and
354
+ * nothing else.
355
+ */
356
+ export function renderTrustBlock(rows) {
357
+ if (rows.length === 0)
358
+ return '';
359
+ const body = rows
360
+ .map((r) => `[hooks.state.${tomlQuote(r.key)}]\ntrusted_hash = ${tomlQuote(r.trustedHash)}`)
361
+ .join('\n');
362
+ return `${DZ_TRUST_BEGIN}\n${body}\n${DZ_TRUST_END}\n`;
363
+ }
364
+ /** Replace (or append) dz's managed trust block, leaving every other byte of config.toml alone. */
365
+ export function upsertTrustBlock(configToml, rows) {
366
+ const block = renderTrustBlock(rows);
367
+ const begin = configToml.indexOf(DZ_TRUST_BEGIN);
368
+ const end = configToml.indexOf(DZ_TRUST_END);
369
+ if (begin >= 0 && end > begin) {
370
+ const before = configToml.slice(0, begin);
371
+ const after = configToml.slice(end + DZ_TRUST_END.length).replace(/^\n/, '');
372
+ return `${before}${block}${after}`;
373
+ }
374
+ const base = configToml === '' || configToml.endsWith('\n') ? configToml : `${configToml}\n`;
375
+ return `${base}${base === '' ? '' : '\n'}${block}`;
376
+ }
377
+ /**
378
+ * Pick, from a `hooks/list` response, the metadata of the entries WE wrote — matched on the exact
379
+ * command string, so a third party's entry can never inherit dz's trust write.
380
+ */
381
+ export function selectOwnHookMetadata(hooks, entries) {
382
+ const byCommand = new Map(entries.map((e) => [e.command, e.id]));
383
+ const out = [];
384
+ for (const meta of hooks) {
385
+ const id = typeof meta.command === 'string' ? byCommand.get(meta.command) : undefined;
386
+ if (id !== undefined)
387
+ out.push({ id, meta });
388
+ }
389
+ return out;
390
+ }
391
+ //# sourceMappingURL=codex-hooks.js.map