@bookedsolid/rea 0.3.0 → 0.4.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 (56) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/dist/cli/doctor.d.ts +19 -4
  4. package/dist/cli/doctor.js +172 -5
  5. package/dist/cli/index.js +9 -1
  6. package/dist/cli/init.js +93 -7
  7. package/dist/cli/install/pre-push.d.ts +335 -0
  8. package/dist/cli/install/pre-push.js +2818 -0
  9. package/dist/cli/serve.d.ts +64 -0
  10. package/dist/cli/serve.js +270 -2
  11. package/dist/cli/status.d.ts +90 -0
  12. package/dist/cli/status.js +399 -0
  13. package/dist/cli/utils.d.ts +4 -0
  14. package/dist/cli/utils.js +4 -0
  15. package/dist/gateway/circuit-breaker.d.ts +17 -0
  16. package/dist/gateway/circuit-breaker.js +32 -3
  17. package/dist/gateway/downstream-pool.d.ts +2 -1
  18. package/dist/gateway/downstream-pool.js +2 -2
  19. package/dist/gateway/downstream.d.ts +39 -3
  20. package/dist/gateway/downstream.js +73 -14
  21. package/dist/gateway/log.d.ts +122 -0
  22. package/dist/gateway/log.js +334 -0
  23. package/dist/gateway/middleware/audit.d.ts +10 -1
  24. package/dist/gateway/middleware/audit.js +26 -1
  25. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  26. package/dist/gateway/middleware/blocked-paths.js +439 -67
  27. package/dist/gateway/middleware/injection.d.ts +218 -13
  28. package/dist/gateway/middleware/injection.js +433 -51
  29. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  30. package/dist/gateway/middleware/kill-switch.js +20 -1
  31. package/dist/gateway/observability/metrics.d.ts +125 -0
  32. package/dist/gateway/observability/metrics.js +321 -0
  33. package/dist/gateway/server.d.ts +19 -0
  34. package/dist/gateway/server.js +99 -15
  35. package/dist/policy/loader.d.ts +13 -0
  36. package/dist/policy/loader.js +28 -0
  37. package/dist/policy/profiles.d.ts +13 -0
  38. package/dist/policy/profiles.js +12 -0
  39. package/dist/policy/types.d.ts +28 -0
  40. package/dist/registry/fingerprint.d.ts +73 -0
  41. package/dist/registry/fingerprint.js +81 -0
  42. package/dist/registry/fingerprints-store.d.ts +62 -0
  43. package/dist/registry/fingerprints-store.js +111 -0
  44. package/dist/registry/interpolate.d.ts +58 -0
  45. package/dist/registry/interpolate.js +121 -0
  46. package/dist/registry/loader.d.ts +2 -2
  47. package/dist/registry/loader.js +22 -1
  48. package/dist/registry/tofu-gate.d.ts +41 -0
  49. package/dist/registry/tofu-gate.js +189 -0
  50. package/dist/registry/tofu.d.ts +111 -0
  51. package/dist/registry/tofu.js +173 -0
  52. package/dist/registry/types.d.ts +9 -1
  53. package/package.json +1 -1
  54. package/profiles/bst-internal-no-codex.yaml +5 -0
  55. package/profiles/bst-internal.yaml +7 -0
  56. package/scripts/tarball-smoke.sh +197 -0
@@ -0,0 +1,2818 @@
1
+ /**
2
+ * G6 — Pre-push hook fallback installer.
3
+ *
4
+ * Ships alongside `commit-msg.ts` as a second-line defender for the
5
+ * protected-path Codex audit gate. The primary path is `.husky/pre-push`,
6
+ * which rea copies into the consumer's `.husky/` via the canonical copy
7
+ * module. That file only runs when the consumer has husky active
8
+ * (`core.hooksPath` points at `.husky/`). Consumers who have never run
9
+ * `husky install`, or who have disabled husky entirely, would otherwise get
10
+ * ZERO pre-push enforcement — and the protected-path gate is exactly the
11
+ * thing we cannot let silently lapse.
12
+ *
13
+ * The fallback writes a small shell script that `exec`s the same
14
+ * `push-review-gate.sh` logic the Claude Code hook already runs. The gate
15
+ * itself is shared — we do NOT duplicate its 700 lines.
16
+ *
17
+ * ## Install policy (decision tree, documented)
18
+ *
19
+ * Given a consumer repo, we must decide where (if anywhere) to install a
20
+ * fallback `pre-push`:
21
+ *
22
+ * 1. `core.hooksPath` unset (vanilla git):
23
+ * → Install `.git/hooks/pre-push`. This is the only path git will fire.
24
+ * `.husky/pre-push` sits on disk as a source-of-truth copy but is not
25
+ * consulted by git directly.
26
+ *
27
+ * 2. `core.hooksPath` set to a directory containing an EXECUTABLE,
28
+ * governance-carrying `pre-push`:
29
+ * → Do NOT install. A hook is "governance-carrying" when it either
30
+ * carries our `FALLBACK_MARKER` (rea-managed) or execs / invokes
31
+ * `.claude/hooks/push-review-gate.sh` (consumer-wired delegation).
32
+ * This is the happy path for any project running husky 9+ that has
33
+ * wired the gate.
34
+ *
35
+ * 3. `core.hooksPath` set to a directory with a pre-push that is NOT
36
+ * governance-carrying (wrong bits, unrelated script, lint-only husky
37
+ * hook, directory, etc.):
38
+ * → Classify as foreign. Leave it alone, warn the user, and let
39
+ * `rea doctor` downgrade the check to `warn` so the gap is visible.
40
+ *
41
+ * 4. `core.hooksPath` set to a directory WITHOUT a pre-push:
42
+ * → Install into the configured hooksPath (as `pre-push`). This is the
43
+ * "hooksPath is set but nothing lives there yet" case. The active
44
+ * hook directory has changed; we install where git will actually look.
45
+ *
46
+ * Idempotency: every install writes a stable managed header
47
+ * (`# rea:pre-push-fallback v1`). Re-running `rea init` detects the header
48
+ * by ANCHORED match (exact second line after the shebang) and refreshes in
49
+ * place; it NEVER overwrites a hook without our marker — if the consumer
50
+ * has their own pre-push already, we warn and skip. Substring matches are
51
+ * deliberately rejected: a consumer comment, a grep log, or copy-pasted
52
+ * snippet containing the sentinel must not reclassify a foreign file as
53
+ * rea-managed.
54
+ *
55
+ * ## Why not just rely on `.husky/pre-push`?
56
+ *
57
+ * Three concrete failure modes we saw during 0.2.x dogfooding:
58
+ * - Consumer hasn't run `husky install` (fresh clone, pnpm hasn't run
59
+ * postinstall yet, etc.). `.husky/pre-push` exists but git's hooksPath
60
+ * still points at `.git/hooks/`. No enforcement.
61
+ * - Consumer deliberately uses `core.hooksPath=./custom-hooks` with a
62
+ * different tool. `.husky/pre-push` is dead weight.
63
+ * - CI or release automation disables husky via `HUSKY=0`. Again, no
64
+ * enforcement at push time.
65
+ *
66
+ * The protected-path Codex audit requirement is too important to let any
67
+ * of those slip through silently. See THREAT_MODEL.md §Governance for the
68
+ * full rationale.
69
+ */
70
+ import { execFile } from 'node:child_process';
71
+ import crypto from 'node:crypto';
72
+ import fs from 'node:fs';
73
+ import fsPromises from 'node:fs/promises';
74
+ import path from 'node:path';
75
+ import { promisify } from 'node:util';
76
+ import properLockfile from 'proper-lockfile';
77
+ import { warn } from '../utils.js';
78
+ const execFileAsync = promisify(execFile);
79
+ /**
80
+ * Marker baked into every rea-installed fallback pre-push hook. Used for
81
+ * idempotency: on re-run we refresh files carrying the marker and refuse
82
+ * to touch anything that doesn't.
83
+ *
84
+ * Bump the version suffix whenever the embedded script semantics change so
85
+ * upgrades can migrate old installs. Comparison is NOT a substring match —
86
+ * see `isReaManagedFallback` for the anchored form required to classify
87
+ * a file as rea-managed.
88
+ */
89
+ export const FALLBACK_MARKER = '# rea:pre-push-fallback v1';
90
+ /**
91
+ * Marker present in the shipped `.husky/pre-push` governance gate. Detection
92
+ * requires the marker to appear on the SECOND LINE of the file (immediately
93
+ * after the shebang) to prevent a consumer comment or copy-pasted snippet
94
+ * that mentions the string from causing a foreign hook to be misclassified
95
+ * as rea-managed and then silently overwritten. See `isReaManagedHuskyGate`
96
+ * for the anchored check.
97
+ */
98
+ export const HUSKY_GATE_MARKER = '# rea:husky-pre-push-gate v1';
99
+ /**
100
+ * Second versioned marker embedded in the body of the shipped `.husky/pre-push`.
101
+ * Required alongside `HUSKY_GATE_MARKER` so that a hook containing only the
102
+ * header marker + `exit 0` (or any stub body) is not classified as rea-managed.
103
+ * A genuine rea Husky gate always carries both. The marker is versioned so it
104
+ * can be bumped if the gate implementation changes significantly.
105
+ */
106
+ export const HUSKY_GATE_BODY_MARKER = '# rea:gate-body-v1';
107
+ /**
108
+ * Fixed two-line prelude every rea-managed fallback hook opens with. Used
109
+ * to distinguish a real rea install from a file that merely happens to
110
+ * contain the marker substring (consumer comment, grep log, copy-pasted
111
+ * snippet, etc.). The equality check is exact-bytes, anchored at offset 0.
112
+ */
113
+ const FALLBACK_PRELUDE = `#!/bin/sh\n${FALLBACK_MARKER}\n`;
114
+ /**
115
+ * Any reference to this token in a pre-push hook's body counts as a
116
+ * consumer-wired delegation to the shared review gate. Used to distinguish
117
+ * a legitimate custom pre-push that still honors rea governance from a
118
+ * lint-only husky hook that would silently bypass it.
119
+ */
120
+ const GATE_DELEGATION_TOKEN = '.claude/hooks/push-review-gate.sh';
121
+ /**
122
+ * True when `content` starts with the exact rea fallback prelude. The
123
+ * marker must appear as the second line, immediately after the shebang,
124
+ * with no leading whitespace, no alternate shebang (`#!/usr/bin/env sh`),
125
+ * and no interposed blank lines. Anything else is foreign.
126
+ *
127
+ * Rejecting a substring match is what stops a consumer comment like
128
+ * `# Hint: the old rea:pre-push-fallback v1 marker moved into .husky/` from
129
+ * accidentally classifying a user's own hook as rea-managed and then
130
+ * getting overwritten on the next `rea init`.
131
+ */
132
+ export function isReaManagedFallback(content) {
133
+ return content.startsWith(FALLBACK_PRELUDE);
134
+ }
135
+ /**
136
+ * True when `content` has the shipped Husky gate marker on the SECOND LINE
137
+ * (immediately after the shebang). This is the canonical structure of the
138
+ * rea-authored `.husky/pre-push` — the shebang occupies line 1 and the marker
139
+ * occupies line 2 with no intervening blank lines.
140
+ *
141
+ * Requiring line-2 placement prevents a consumer comment, copy-pasted snippet,
142
+ * or any other text that merely *mentions* the marker string from reclassifying
143
+ * a consumer-owned hook as rea-managed and triggering an overwrite on the next
144
+ * `rea init`. A marker buried anywhere else in the file is not the canonical
145
+ * structure and must not be trusted.
146
+ *
147
+ * This classification is checked BEFORE `isReaManagedFallback` in
148
+ * `classifyExistingHook` so that the shipped `.husky/pre-push` is recognized
149
+ * as a governance-carrying hook rather than `foreign/no-marker`.
150
+ */
151
+ export function isReaManagedHuskyGate(content) {
152
+ // Positional anchor: header marker must be on line 2 (immediately after
153
+ // shebang). Prevents classifying any file that merely mentions the sentinel.
154
+ const lines = content.split(/\r?\n/);
155
+ if (lines.length < 2 || lines[1] !== HUSKY_GATE_MARKER)
156
+ return false;
157
+ // R12 F1 (strengthened from R11): heuristic "mentions the string"
158
+ // signatures are still spoofable. A file can include `[ -f .rea/HALT ]`
159
+ // followed by `:` (no-op), or `echo codex.review .rea/audit.jsonl`, and
160
+ // trivially satisfy a presence check without ever enforcing anything.
161
+ //
162
+ // Recognition now requires PROOF OF ENFORCEMENT:
163
+ //
164
+ // 1. HALT enforcement — the HALT test must be paired with a non-zero
165
+ // `exit` in the matching path. Either short-circuit form
166
+ // (`[ -f .rea/HALT ] && exit N`) or block form
167
+ // (`if [ -f .rea/HALT ]; then ... exit N ... fi`).
168
+ //
169
+ // 2. Audit check — the audit token must appear on a line with a command
170
+ // that can FAIL on a missing match. `grep`, `rg`, `awk`, `test`, `[`,
171
+ // or `[[` paired with `.rea/audit.jsonl` or `codex.review` satisfies.
172
+ // `echo codex.review .rea/audit.jsonl` does NOT — echo always succeeds.
173
+ //
174
+ // Both together demand the file actually implement the enforcement
175
+ // behavior. Governance is a behavior, not a sticker; proving behavior
176
+ // requires matching the structure of a real check, not just the text of
177
+ // one.
178
+ if (!hasHaltEnforcement(content))
179
+ return false;
180
+ if (!hasAuditCheck(content))
181
+ return false;
182
+ return true;
183
+ }
184
+ /**
185
+ * Pre-0.4 rea-authored `.husky/pre-push` shape — same governance behavior
186
+ * as the current gate but lacks the line-2/3 versioned markers
187
+ * (`# rea:husky-pre-push-gate v1` / `# rea:gate-body-v1`) introduced in
188
+ * 0.4.
189
+ *
190
+ * Codex R21 F1: without this detector, any consumer upgrading from a rea
191
+ * release that shipped the pre-marker hook fell into `foreign/no-marker`.
192
+ * `classifyPrePushInstall` mapped that to `skip/foreign-pre-push` and
193
+ * `rea init` refused to touch the file. `rea doctor` reported
194
+ * `activeForeign=true`. Users had no self-heal path short of manually
195
+ * deleting the hook — which is a bad migration story for a governance
196
+ * primitive that they are supposed to trust.
197
+ *
198
+ * Shape-level detection:
199
+ * 1. Line 2 is the canonical pre-0.4 filename header
200
+ * `# .husky/pre-push — rea governance gate for terminal-initiated pushes.`
201
+ * This header shipped verbatim across the 0.2.x/0.3.x rea releases.
202
+ * 2. Real governance still present — `hasHaltEnforcement(content)` AND
203
+ * `hasAuditCheck(content)` both pass. A stub that only matches the
204
+ * header comment (no enforcement) fails the shape check and stays
205
+ * classified as foreign.
206
+ *
207
+ * Classification consequence: `classifyExistingHook` returns
208
+ * `rea-managed-husky` for legacy matches. `classifyPrePushInstall` maps
209
+ * that to `skip/active-pre-push-present` — `rea init` does not touch the
210
+ * hook (correctness: the file IS still functional governance), but
211
+ * `inspectPrePushState` reports `ok=true, activeForeign=false` so doctor
212
+ * stops flagging it. The canonical-manifest-driven upgrade path
213
+ * (`rea upgrade`) detects the hash mismatch against the packaged
214
+ * `.husky/pre-push` and surfaces the legacy shape as drift, letting the
215
+ * operator opt into the refresh explicitly.
216
+ */
217
+ export function isLegacyReaManagedHuskyGate(content) {
218
+ // R24 F2 — byte-identical SHA256 allowlist.
219
+ //
220
+ // The R22 token fingerprint (`block_push=0` + `block_push=1` + `"$block_push"
221
+ // -ne 0` + `codex.review` grep) did not prove control flow — a drifted or
222
+ // consumer-owned hook could retain those tokens while no longer enforcing
223
+ // the audit gate, and still be classified as rea-managed. The only safe
224
+ // recognition we can make without re-deriving POSIX control-flow semantics
225
+ // is exact-byte equality against a hook body we know we shipped.
226
+ //
227
+ // Each entry below is the SHA256 of a `.husky/pre-push` body that rea has
228
+ // historically published, collected via `git log --follow .husky/pre-push`.
229
+ // On match, the consumer is trusted to be on a known-good rea-managed body,
230
+ // and the canonical-manifest reconciler (`rea upgrade`) handles the refresh
231
+ // to the current canonical SHA. Any byte drift flips the file back to
232
+ // `foreign`, which forces an explicit opt-in upgrade instead of silent
233
+ // trust — the exact escape hatch R24 F2 demanded.
234
+ //
235
+ // R25 F3 — line-ending normalization.
236
+ //
237
+ // Git with `core.autocrlf=true` on Windows checks out text files with CRLF,
238
+ // so a consumer on Windows would present us with `\r\n`-delimited bytes of
239
+ // the exact hook body we shipped. Rejecting those as foreign strands them
240
+ // on the legacy form with no clean upgrade path. We collapse `\r\n` → `\n`
241
+ // (and bare `\r` → `\n` — classic-Mac is dead but cheap to handle) BEFORE
242
+ // hashing so LF-normalized and CRLF-checkout bytes hash to the same value.
243
+ //
244
+ // Trailing-whitespace drift and in-body edits still flip to foreign; only
245
+ // the line-ending platform conversion is forgiven. That preserves the R24
246
+ // F2 invariant — exact-byte equality modulo platform EOL — without blessing
247
+ // semantic drift.
248
+ const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
249
+ const hash = crypto.createHash('sha256').update(normalized).digest('hex');
250
+ return KNOWN_LEGACY_HUSKY_SHA256.has(hash);
251
+ }
252
+ /**
253
+ * R24 F2 — exact-byte allowlist of shipped `.husky/pre-push` bodies
254
+ * recognized as legacy rea-managed hooks. Append a new entry here the
255
+ * moment any change lands in `hooks/` or `.husky/` that alters the
256
+ * published hook body, so consumers upgrading from that version see their
257
+ * install classified correctly and not stranded as foreign.
258
+ *
259
+ * Generated via: `git log --all --format='%H' --follow -- .husky/pre-push`
260
+ * then `git show <sha>:.husky/pre-push | shasum -a 256` for each commit.
261
+ */
262
+ const KNOWN_LEGACY_HUSKY_SHA256 = new Set([
263
+ // v0.3.x shipped body (commit 320c090 → 0.3.0 release).
264
+ '5014c585c4af5aa0425fde36441711fa55833e03b81967c45045c5bd716b821e',
265
+ // Intermediate iteration (commit a356eb0, pre-release).
266
+ '9a668414c557d280a56f48795583acffefbd11b81e2799fd54eb023e48ccb14b',
267
+ // Intermediate iteration (commit 68c2cf2, pre-release).
268
+ '9d4885b64f50dd91887c2c6b4d17e3aa91b0be5da8e842ca8915bec1bf369de5',
269
+ // Initial publication (commit b513760, G6 MVP).
270
+ '1ee21164ccce628a1ef85c313d09afdcdb8560efd761ec64b046cca6cc319cba',
271
+ ]);
272
+ /**
273
+ * True when `content` contains a POSIX shell construct that detects
274
+ * `.rea/HALT` AND causes the script to exit non-zero on match. Comment
275
+ * lines are stripped before scanning so `# if [ -f .rea/HALT ]; then exit`
276
+ * does not satisfy.
277
+ *
278
+ * Patterns accepted:
279
+ * - Short-circuit: `[ -f .rea/HALT ] && exit N`
280
+ * `test -f .rea/HALT && exit N`
281
+ * `[ -f .rea/HALT ] && { ...; exit N; }`
282
+ * - Block form: `if [ -f .rea/HALT ]; then ... exit N ... fi`
283
+ * (exit must appear in the THEN branch of the HALT if,
284
+ * bounded by the matching `fi`)
285
+ *
286
+ * Patterns rejected (previously accepted by R11's signature heuristic):
287
+ * - `[ -f .rea/HALT ] && :` — no-op stub
288
+ * - `if [ -f .rea/HALT ]; then :; fi` — no-op stub
289
+ * - `# check .rea/HALT` — comment only
290
+ * - `echo .rea/HALT` — print, not enforce
291
+ * - `[ -f .rea/HALT ] && exit` — bare `exit` == `exit 0` on most shells (R13)
292
+ * - `[ -f .rea/HALT ] && exit 0` — exit 0 allows the push (R13)
293
+ * - `if [ -f .rea/HALT ]; then exit; fi` — same: no explicit non-zero code (R13)
294
+ *
295
+ * R12 F1: the previous `hasHaltTest` regex allowed a no-op body. We now
296
+ * require proof that the HALT match actually exits the script.
297
+ *
298
+ * R13 F1: the R12 regex accepted a bare `\bexit\b` on the HALT path, which
299
+ * matched both `exit 0` (allow push) and bare `exit` (POSIX: last command's
300
+ * status — commonly 0). A hook can satisfy R12 and still let pushes through
301
+ * when HALT is present. Proof of blocking enforcement now REQUIRES an explicit
302
+ * non-zero positive integer exit code on the HALT path.
303
+ *
304
+ * R16 F1: the R15 block-form regex used `[\s\S]*?` spans between `then`,
305
+ * `exit`, and `fi`, which could span across UNRELATED if/fi blocks. A spoof
306
+ * `if [ -f .rea/HALT ]; then :; fi; if X; then exit 1; fi` satisfied the regex
307
+ * even though the `exit 1` belonged to a separate if. We now walk statements
308
+ * via a frame stack: each `if` pushes a frame (tagged `haltCond=true` when the
309
+ * condition is the HALT test), statements in the `then` branch toggle
310
+ * `nonZeroExitInBody`, and the matching `fi` pops. Enforcement is proven only
311
+ * when BOTH flags are set on the same popped frame.
312
+ */
313
+ function hasHaltEnforcement(content) {
314
+ // R13 F1: require `exit N` where N >= 1. Bare `exit` and `exit 0` MUST NOT
315
+ // qualify — both allow the push. Leading zeros on a positive value (e.g.,
316
+ // `exit 01`) are still rejected; shell treats the argument as a string and
317
+ // the spec says "implementation-defined" for values outside 0–255, so we
318
+ // keep the strict form `[1-9]\d*`.
319
+ //
320
+ // R20 F3: the body-check used `NONZERO_EXIT.test(full)`, which only looked
321
+ // for the substring `exit N`. That accepted `echo exit 1` / `printf
322
+ // 'exit 1\n'` inside the HALT branch, even though the hook only printed
323
+ // text. Replaced with statement-level head-token parsing via
324
+ // `isHeadExitStmt` so only a command-head `exit N` / `return N` counts.
325
+ //
326
+ // R20 (defensive): also strip function bodies so a HALT-check + exit 1
327
+ // that lives inside an uncalled helper function doesn't register as
328
+ // enforcement. Consistent with `hasAuditCheck` and `referencesReviewGate`.
329
+ content = stripFunctionBodies(content);
330
+ const HALT_TEST = /(?:\[[ \t]+-f[^\n]*\.rea\/HALT[^\n]*\]|\btest[ \t]+-f[^\n]*\.rea\/HALT)/;
331
+ // Scan a free-form body chunk (after `then`, or the whole branch body) for
332
+ // any head-token exit/return terminator. Splits on `;`/newline via
333
+ // `splitStatements` and rejects shapes like `echo exit 1`.
334
+ const bodyHasHeadExit = (body) => {
335
+ if (body.length === 0)
336
+ return false;
337
+ for (const stmt of splitStatements(body)) {
338
+ if (isHeadExitStmt(stmt))
339
+ return true;
340
+ }
341
+ return false;
342
+ };
343
+ // R26 F1 — reachability-aware walk.
344
+ //
345
+ // A HALT enforcement match is only proof of governance when it lives at
346
+ // the TOP LEVEL of the script. An enforcement block nested under `if false;
347
+ // then ... fi`, under a loop that never runs, or under any outer control
348
+ // structure can never be reached, so it does not actually block pushes.
349
+ //
350
+ // Both the short-circuit form (`[ -f HALT ] && exit N`) and the block
351
+ // form (`if [ -f HALT ]; then exit N fi`) are now recognized only when
352
+ // they appear at `frames.length === 0 && loopDepth === 0`. A nested
353
+ // enforcement block still closes its frame, but we refuse to return
354
+ // true for it.
355
+ const shortCircuitRe = /(?:\[[ \t]+-f[^\n]*\.rea\/HALT[^\n]*\]|\btest[ \t]+-f[^\n]*\.rea\/HALT)[ \t]*&&[ \t]*(.*)$/m;
356
+ const exitStmtRe = /^exit[ \t]+[1-9]\d*\b/;
357
+ const stmtHasShortCircuitHalt = (stmtFull) => {
358
+ const m = stmtFull.match(shortCircuitRe);
359
+ if (m === null)
360
+ return false;
361
+ const tail = m[1] ?? '';
362
+ if (exitStmtRe.test(tail.trimStart()))
363
+ return true;
364
+ const blockMatch = tail.match(/^\s*\{([^}]*)\}/);
365
+ if (blockMatch !== null) {
366
+ const body = blockMatch[1] ?? '';
367
+ const stmts = body.split(/[;\n]/).map((s) => s.trim());
368
+ if (stmts.some((s) => exitStmtRe.test(s)))
369
+ return true;
370
+ }
371
+ return false;
372
+ };
373
+ const frames = [];
374
+ let loopDepth = 0;
375
+ // R28 B1/B2 — open `case ... esac` depth. R27 tracked per-branch
376
+ // liveness (scrutinee literal + pattern match) but that created two
377
+ // attack vectors: a dynamic scrutinee short-circuited to "all branches
378
+ // live" (exploitable by writing `case "$NEVER_SET" in "foo") proof ;;
379
+ // esac`), and a parenthesized POSIX pattern `(foo)` fell outside the
380
+ // branch-head regex, leaving `curBranchLive` at its init value. The
381
+ // canonical shipped hook never wraps HALT/audit proof inside a case —
382
+ // case is used only for the `0000...) continue` deletion guard BEFORE
383
+ // the audit-if. So we collapse to the strictly simpler rule: reject
384
+ // any proof inside any open case frame via `caseDepth === 0` at every
385
+ // acceptance site. Nested `if`/loop frames inside case still need to
386
+ // be tracked for stack balance, but their proofs can never satisfy the
387
+ // classifier.
388
+ let caseDepth = 0;
389
+ for (const stmt of statementsOf(content)) {
390
+ const head = stmt.head;
391
+ const full = stmt.full;
392
+ if (/^case\b/.test(head)) {
393
+ caseDepth++;
394
+ continue;
395
+ }
396
+ if (/^esac\b/.test(head)) {
397
+ caseDepth = Math.max(0, caseDepth - 1);
398
+ continue;
399
+ }
400
+ if (/^(for|while|until)\b/.test(head)) {
401
+ loopDepth++;
402
+ continue;
403
+ }
404
+ if (/^done\b/.test(head)) {
405
+ loopDepth = Math.max(0, loopDepth - 1);
406
+ continue;
407
+ }
408
+ if (/^if\b/.test(head)) {
409
+ const frame = {
410
+ haltCond: HALT_TEST.test(full),
411
+ nonZeroExitInBody: false,
412
+ branch: 'cond',
413
+ };
414
+ if (/(^|[;\s])then\b/.test(full)) {
415
+ frame.branch = 'body';
416
+ if (bodyHasHeadExit(afterThen(full)))
417
+ frame.nonZeroExitInBody = true;
418
+ }
419
+ frames.push(frame);
420
+ continue;
421
+ }
422
+ if (/^then\b/.test(head)) {
423
+ const frame = frames[frames.length - 1];
424
+ if (frame !== undefined) {
425
+ frame.branch = 'body';
426
+ if (bodyHasHeadExit(afterThen(full)))
427
+ frame.nonZeroExitInBody = true;
428
+ }
429
+ continue;
430
+ }
431
+ if (/^(elif|else)\b/.test(head)) {
432
+ const frame = frames[frames.length - 1];
433
+ if (frame !== undefined)
434
+ frame.branch = 'cond';
435
+ continue;
436
+ }
437
+ if (/^fi\b/.test(head)) {
438
+ const frame = frames.pop();
439
+ // R26 F1: the closing `fi` only proves enforcement when the if it
440
+ // closes was itself TOP-LEVEL. `frames.length === 0` post-pop means
441
+ // there is no outer enclosing `if`; `loopDepth === 0` means we are
442
+ // not inside a `for/while/until`. A nested HALT if under any parent
443
+ // guard is rejected. R27 F2: additionally require no open dead
444
+ // `case` branch.
445
+ if (frame !== undefined &&
446
+ frame.haltCond &&
447
+ frame.nonZeroExitInBody &&
448
+ frames.length === 0 &&
449
+ loopDepth === 0 &&
450
+ caseDepth === 0) {
451
+ return true;
452
+ }
453
+ continue;
454
+ }
455
+ // R26 F1 — top-level short-circuit form. The `[ -f HALT ] && exit N`
456
+ // shape only proves enforcement when it lives at top level. An
457
+ // equivalent shape inside `if false; then ... fi` is never executed
458
+ // and must not count. `frames.length === 0 && loopDepth === 0` gates
459
+ // both conditions. R28 adds `caseDepth === 0` so a short-circuit HALT
460
+ // inside any open case branch (dead OR live under ambiguous
461
+ // scrutinee) is rejected.
462
+ if (frames.length === 0 &&
463
+ loopDepth === 0 &&
464
+ caseDepth === 0 &&
465
+ stmtHasShortCircuitHalt(full)) {
466
+ return true;
467
+ }
468
+ if (frames.length > 0) {
469
+ const frame = frames[frames.length - 1];
470
+ if (frame !== undefined && frame.branch === 'body') {
471
+ if (bodyHasHeadExit(full))
472
+ frame.nonZeroExitInBody = true;
473
+ }
474
+ }
475
+ }
476
+ return false;
477
+ }
478
+ /**
479
+ * Return everything after the first `then` keyword on a statement that
480
+ * combines the `if`/condition with the `then` body on the same logical line.
481
+ * Used by `hasHaltEnforcement` to scope the exit-body check so an `exit 1`
482
+ * that precedes `then` (impossible in valid shell, but defensive) is not
483
+ * mistaken for enforcement.
484
+ */
485
+ function afterThen(line) {
486
+ const m = line.match(/(^|[;\s])then\b(.*)$/s);
487
+ if (m === null || m[2] === undefined)
488
+ return '';
489
+ return m[2];
490
+ }
491
+ /**
492
+ * Walk `content` and produce a stream of shell statements, normalized for
493
+ * parser consumption. Handles:
494
+ *
495
+ * - Line continuations (`\<newline>` → single space) so multi-physical-line
496
+ * constructs like the shipped husky `grep -E ... | \` + `grep -qF ...` are
497
+ * evaluated as one statement.
498
+ * - Full-line comments (`# ...` at line start) are dropped entirely.
499
+ * - Trailing comments (` # ...`) are stripped via `stripTrailingComment`.
500
+ * - `;`-separated statements are split via quote/paren/brace-aware
501
+ * `splitStatements` so `fi; if X; then ...` yields THREE statements.
502
+ *
503
+ * Each statement's `head` is the first whitespace-delimited token so callers
504
+ * can cheaply classify as `if`/`then`/`fi`/`done`/etc. The `full` retains the
505
+ * complete statement text for regex content checks (HALT test, audit grep,
506
+ * exit N, variable assignments).
507
+ */
508
+ function statementsOf(content) {
509
+ const out = [];
510
+ const joined = joinLineContinuations(content);
511
+ for (const raw of joined.split(/\r?\n/)) {
512
+ const t = raw.trimStart();
513
+ if (t.startsWith('#'))
514
+ continue;
515
+ const line = stripTrailingComment(t);
516
+ for (const stmt of splitStatements(line)) {
517
+ const trimmed = stmt.trim();
518
+ if (trimmed.length === 0)
519
+ continue;
520
+ const head = trimmed.split(/\s+/, 1)[0] ?? '';
521
+ out.push({ head, full: trimmed });
522
+ }
523
+ }
524
+ return out;
525
+ }
526
+ /**
527
+ * Split a logical line into `;`-separated statements, respecting shell
528
+ * quoting and grouping so `;` inside strings / `(...)` / `{...}` is not a
529
+ * separator. Examples:
530
+ *
531
+ * splitStatements("fi; if X; then exit 1; fi")
532
+ * → ["fi", "if X", "then exit 1", "fi"]
533
+ *
534
+ * splitStatements("echo 'a;b'; echo c")
535
+ * → ["echo 'a;b'", "echo c"]
536
+ *
537
+ * Not a full POSIX parser (no heredoc, no command substitution nesting
538
+ * beyond depth counting) but sufficient for the one-liner hook shapes we
539
+ * actually need to analyze. Any ambiguous input falls back to treating the
540
+ * unclosed quote/paren as swallowing subsequent `;`, which is the safe
541
+ * behavior — the statement appears as a single larger block and the
542
+ * enclosing parser sees it as a non-control statement.
543
+ */
544
+ function splitStatements(line) {
545
+ const stmts = [];
546
+ let buf = '';
547
+ let inSingle = false;
548
+ let inDouble = false;
549
+ let parenDepth = 0;
550
+ let braceDepth = 0;
551
+ for (let i = 0; i < line.length; i++) {
552
+ const c = line[i];
553
+ if (c === "'" && !inDouble) {
554
+ inSingle = !inSingle;
555
+ buf += c;
556
+ continue;
557
+ }
558
+ if (c === '"' && !inSingle) {
559
+ inDouble = !inDouble;
560
+ buf += c;
561
+ continue;
562
+ }
563
+ if (!inSingle && !inDouble) {
564
+ if (c === '(')
565
+ parenDepth++;
566
+ else if (c === ')')
567
+ parenDepth = Math.max(0, parenDepth - 1);
568
+ else if (c === '{')
569
+ braceDepth++;
570
+ else if (c === '}')
571
+ braceDepth = Math.max(0, braceDepth - 1);
572
+ else if (c === ';' && parenDepth === 0 && braceDepth === 0) {
573
+ stmts.push(buf);
574
+ buf = '';
575
+ continue;
576
+ }
577
+ }
578
+ buf += c ?? '';
579
+ }
580
+ if (buf.length > 0)
581
+ stmts.push(buf);
582
+ return stmts;
583
+ }
584
+ /**
585
+ * True when `content` contains a shell command that performs a CONTENT match
586
+ * against the `codex.review` audit record, BOUND TO the audit log path, and
587
+ * whose failure is allowed to propagate (no `|| true`, no backgrounding, no
588
+ * pipelines that mask the match via a non-grep tail).
589
+ *
590
+ * Commands accepted (on a non-comment logical line that references
591
+ * `codex.review`, literally or with the backslash-escaped form `codex\.review`
592
+ * that appears inside a grep -E pattern):
593
+ * - `grep` / `egrep` / `fgrep` — classic POSIX content match
594
+ * - `rg` — ripgrep
595
+ *
596
+ * Binding to the audit log (required — R15 F1):
597
+ * - Literal `.rea/audit.jsonl` appears on the same logical line, OR
598
+ * - A variable reference like `$AUDIT` / `${AUDIT}` whose ASSIGNMENT has a
599
+ * RHS containing the literal `.rea/audit.jsonl`. The shipped husky gate
600
+ * uses `AUDIT_LOG="${REA_ROOT}/.rea/audit.jsonl"` and `grep ... "$AUDIT_LOG"`.
601
+ *
602
+ * Line continuations: a trailing backslash followed by a newline is joined
603
+ * before scanning. The shipped husky gate splits the audit check across two
604
+ * physical lines via `grep -E ... "$AUDIT_LOG" 2>/dev/null | \` + `grep -qF ...`
605
+ * — we must see the full logical line so the pipe-tail check runs against
606
+ * the complete construct.
607
+ *
608
+ * Commands REJECTED (R13 F2, preserved):
609
+ * - `test -s .rea/audit.jsonl` / `[ -f .rea/audit.jsonl ]` — file existence
610
+ * or non-empty test does NOT prove a `codex.review` record is present.
611
+ * - `awk`, `sed` — pattern-no-match is silent success; the opposite of
612
+ * what we need.
613
+ *
614
+ * Enforcement-swallowing forms REJECTED (R15 F1):
615
+ * - `|| true` / `|| :` / `|| /bin/true` / `|| /usr/bin/true` / `|| exit 0`
616
+ * / `|| exit` with no arg — any of these mask a grep miss.
617
+ * - Backgrounded: bare `&` that is not part of `&&` or an fd redirect —
618
+ * the backgrounded pipeline returns 0 to the shell regardless of grep.
619
+ * - `;` followed by a non-control command word — the LAST command becomes
620
+ * the status, swallowing the grep miss. Control keywords
621
+ * (`then`/`do`/`fi`/`done`/`else`/`elif`/`esac`) and trailing comments
622
+ * are allowed.
623
+ * - Pipelines whose LAST segment is NOT a grep-family command — under
624
+ * POSIX sh the pipeline's exit is the last command's, so `grep ... | cat`
625
+ * returns 0 even when grep missed. Pipelines of grep-only segments are
626
+ * allowed (the shipped husky gate uses `grep -E ... | grep -qF ...`).
627
+ *
628
+ * Why `codex\.review` (literal backslash) is accepted: the shipped
629
+ * `.husky/pre-push` embeds the token inside a grep -E regex where `.` is
630
+ * escaped to match the literal character, so the token appears on disk as
631
+ * `codex\.review`. The classifier must recognize both forms.
632
+ *
633
+ * R12 F1: rejected `echo`-based spoofs by requiring a paired check command.
634
+ * R13 F2: rejected file-existence-only proofs by requiring a content-match
635
+ * command AND the `codex.review` token.
636
+ * R15 F1: bind the match to the audit log, reject failure-swallowing tails,
637
+ * and join shell line continuations so the shipped gate's multi-line
638
+ * `grep | grep` is evaluated as a single logical line.
639
+ */
640
+ function hasAuditCheck(content) {
641
+ // R19 F2 + R20 F2: pre-strip function bodies so assignments and grep lines
642
+ // inside dead (uncalled) helper functions don't pollute classifier state.
643
+ const topLevel = stripFunctionBodies(content);
644
+ // R20 F2 (Codex): `collectAuditLogVars` was monotonic — it added every
645
+ // variable whose RHS had ever mentioned the audit path, and never removed
646
+ // the binding when that variable was later reassigned to an unrelated
647
+ // file. A spoof `AUDIT_LOG=.rea/audit.jsonl; AUDIT_LOG=/tmp/spoof; grep
648
+ // codex.review "$AUDIT_LOG"` therefore passed the classifier.
649
+ //
650
+ // Replaced with live in-order state: we walk statements ourselves and
651
+ // update `auditVars` on every assignment — RHS with audit path adds the
652
+ // binding, RHS without removes it. The set is mutated in place so the
653
+ // audit-grep check at each `if` sees the current bindings.
654
+ const auditVars = new Set();
655
+ const ASSIGN_RE = /^(?:export[ \t]+|readonly[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
656
+ const AUDIT_LITERAL = /\.rea\/audit\.jsonl/;
657
+ const updateAuditVarState = (full) => {
658
+ const t = stripTrailingComment(full.trimStart());
659
+ if (t.startsWith('#'))
660
+ return;
661
+ const m = t.match(ASSIGN_RE);
662
+ if (m === null)
663
+ return;
664
+ const name = m[1];
665
+ const rhs = m[2];
666
+ if (name === undefined || rhs === undefined)
667
+ return;
668
+ if (AUDIT_LITERAL.test(rhs)) {
669
+ auditVars.add(name);
670
+ }
671
+ else {
672
+ auditVars.delete(name);
673
+ }
674
+ };
675
+ const frames = [];
676
+ // Parallel stack mirroring `for`/`while`/`until` nesting: `true` for loops
677
+ // whose header is NOT an obviously-dead literal (`while false; do`,
678
+ // `until true; do`, `for X in; do` with empty list), `false` otherwise.
679
+ // Length is the usual `loopDepth`; we keep a boolean for each loop so the
680
+ // audit-if fi-pop can require every enclosing loop to be live.
681
+ const loopLiveStack = [];
682
+ // R28 B1/B2 — open `case ... esac` depth. See `hasHaltEnforcement` for
683
+ // the rationale: per-branch liveness tracking (R27) had two bypass
684
+ // paths (dynamic-scrutinee short-circuit and parenthesized-pattern
685
+ // regex miss). The strictly simpler rule: reject any proof inside any
686
+ // open case frame via `caseDepth === 0`. The canonical shipped hook
687
+ // does not wrap the audit-if in a case — the `case "$local_sha" in
688
+ // 0000...) continue ;; esac` deletion guard closes before the audit-if
689
+ // so `caseDepth === 0` when the audit proof is walked.
690
+ let caseDepth = 0;
691
+ let pendingFallThroughMissBlock = false;
692
+ const recordBlockingIntoFrame = (text) => {
693
+ if (frames.length === 0)
694
+ return;
695
+ const frame = frames[frames.length - 1];
696
+ if (frame === undefined)
697
+ return;
698
+ if (isAllowOnMatchStmtLine(text) && frame.branch === 'then') {
699
+ frame.hasAllowOnMatchInThen = true;
700
+ }
701
+ if (!isBlockingStmtLine(text, loopLiveStack.length))
702
+ return;
703
+ if (frame.branch === 'then')
704
+ frame.hasBlockingInThen = true;
705
+ if (frame.branch === 'else')
706
+ frame.hasBlockingInElse = true;
707
+ };
708
+ for (const stmt of statementsOf(topLevel)) {
709
+ const head = stmt.head;
710
+ const full = stmt.full;
711
+ // R20 F2: update audit-var bindings BEFORE any classifier sees this
712
+ // statement. An assignment at the top of the hook records the binding;
713
+ // a later reassignment to a non-audit path removes it. Order matters —
714
+ // the audit-grep check below uses the CURRENT state, not a precomputed
715
+ // snapshot. Assignment-statement updates are idempotent for non-
716
+ // assignments (the regex fails cleanly and the set is untouched).
717
+ updateAuditVarState(full);
718
+ const loopDepth = loopLiveStack.length;
719
+ if (/^case\b/.test(head)) {
720
+ caseDepth++;
721
+ continue;
722
+ }
723
+ if (/^esac\b/.test(head)) {
724
+ caseDepth = Math.max(0, caseDepth - 1);
725
+ continue;
726
+ }
727
+ if (/^(for|while|until)\b/.test(head)) {
728
+ loopLiveStack.push(!isDeadLoopHead(full));
729
+ }
730
+ if (/^done\b/.test(head)) {
731
+ loopLiveStack.pop();
732
+ }
733
+ if (/^if\b/.test(head)) {
734
+ const condStmt = /(^|[;\s])then\b/.test(full)
735
+ ? full.replace(/(^|[;\s])then\b.*$/s, '')
736
+ : full;
737
+ const condIsAuditGrep = isAuditGrepLine(condStmt, auditVars);
738
+ const condIsNegated = /^if\s+!\s/.test(condStmt);
739
+ const frame = {
740
+ isNegatedAuditIf: condIsAuditGrep && condIsNegated,
741
+ isPositiveAuditIf: condIsAuditGrep && !condIsNegated,
742
+ hasBlockingInThen: false,
743
+ hasBlockingInElse: false,
744
+ hasAllowOnMatchInThen: false,
745
+ branch: 'cond',
746
+ condIsLive: !isDeadIfCondition(condStmt),
747
+ };
748
+ if (/(^|[;\s])then\b/.test(full)) {
749
+ frame.branch = 'then';
750
+ const bodyText = afterThen(full);
751
+ if (isBlockingStmtLine(bodyText, loopDepth)) {
752
+ frame.hasBlockingInThen = true;
753
+ }
754
+ if (isAllowOnMatchStmtLine(bodyText)) {
755
+ frame.hasAllowOnMatchInThen = true;
756
+ }
757
+ }
758
+ frames.push(frame);
759
+ continue;
760
+ }
761
+ if (/^then\b/.test(head)) {
762
+ const frame = frames[frames.length - 1];
763
+ if (frame !== undefined) {
764
+ frame.branch = 'then';
765
+ const bodyText = afterThen(full);
766
+ if (isBlockingStmtLine(bodyText, loopDepth)) {
767
+ frame.hasBlockingInThen = true;
768
+ }
769
+ if (isAllowOnMatchStmtLine(bodyText)) {
770
+ frame.hasAllowOnMatchInThen = true;
771
+ }
772
+ }
773
+ continue;
774
+ }
775
+ if (/^elif\b/.test(head)) {
776
+ const frame = frames[frames.length - 1];
777
+ if (frame !== undefined)
778
+ frame.branch = 'cond';
779
+ continue;
780
+ }
781
+ if (/^else\b/.test(head)) {
782
+ const frame = frames[frames.length - 1];
783
+ if (frame !== undefined) {
784
+ frame.branch = 'else';
785
+ // Body after `else` on the same statement (e.g. `else exit 1`
786
+ // produced by `splitStatements` on `; else exit 1;`). Must be
787
+ // routed to hasBlockingInElse — otherwise the blocking text is
788
+ // visible only as statement head text and the frame never sees it.
789
+ const bodyText = full.replace(/^else\b/, '');
790
+ if (isBlockingStmtLine(bodyText, loopDepth)) {
791
+ frame.hasBlockingInElse = true;
792
+ }
793
+ }
794
+ continue;
795
+ }
796
+ if (/^fi\b/.test(head)) {
797
+ const frame = frames.pop();
798
+ if (frame === undefined)
799
+ continue;
800
+ // R26 F1 v2 — reject an audit-if whose execution path is statically
801
+ // dead. Two cases to catch:
802
+ // (1) The audit-if itself has a dead condition (`if false; then
803
+ // audit-grep; ...`): the audit machinery is inside a branch
804
+ // that never runs.
805
+ // (2) The audit-if is nested inside a dead enclosing construct —
806
+ // either an `if` frame whose cond is a dead literal (`if false;
807
+ // then if ! grep ...; then exit 1; fi; fi`), or a `for/while/
808
+ // until` loop whose header is dead (`while false; do audit-if;
809
+ // done`).
810
+ //
811
+ // v1 of R26 over-tightened by requiring `frames.length === 0`,
812
+ // which broke the canonical shipped hook: it wraps the audit-if in
813
+ // a legitimate protected-paths `if git diff ... | grep -qE PROTECTED;
814
+ // then ... fi` guard, AND inside a `while read refspec; do ... done`
815
+ // loop. Those enclosing constructs are live under normal execution.
816
+ //
817
+ // v2 checks every enclosing frame and loop for an obviously-dead
818
+ // literal header. Live-but-text-visible conditions (variable tests,
819
+ // command substitutions, user-supplied globs) pass through — the
820
+ // threat we harden against here is the "look painfully obvious"
821
+ // dead-code spoof, not arbitrary reachability analysis.
822
+ //
823
+ // The fall-through miss-path form (c) additionally requires
824
+ // `loopDepth === 0`: the post-fi blocker is inherently top-level,
825
+ // and wrapping it in a loop would make the blocker execute once
826
+ // per iteration, a pathological shape outside the canonical
827
+ // allow-on-match template.
828
+ // R28: replace per-branch liveness with "no open case at all".
829
+ // The canonical hook never wraps audit in a case; `caseDepth === 0`
830
+ // suffices and removes the whole dynamic-scrutinee and
831
+ // parenthesized-pattern bypass surface.
832
+ const enclosingAllLive = frame.condIsLive &&
833
+ frames.every((f) => f.condIsLive) &&
834
+ loopLiveStack.every((v) => v) &&
835
+ caseDepth === 0;
836
+ if (enclosingAllLive && frame.isNegatedAuditIf && frame.hasBlockingInThen) {
837
+ return true;
838
+ }
839
+ if (enclosingAllLive && frame.isPositiveAuditIf && frame.hasBlockingInElse) {
840
+ return true;
841
+ }
842
+ if (enclosingAllLive &&
843
+ frames.length === 0 &&
844
+ loopDepth === 0 &&
845
+ caseDepth === 0 &&
846
+ frame.isPositiveAuditIf &&
847
+ frame.hasAllowOnMatchInThen &&
848
+ !frame.hasBlockingInElse) {
849
+ pendingFallThroughMissBlock = true;
850
+ }
851
+ continue;
852
+ }
853
+ // Top-level blocking statement after a qualifying positive audit-if:
854
+ // satisfies the fall-through miss-path requirement (form c). R27 F2:
855
+ // the blocker itself must not live inside a dead `case` branch either,
856
+ // so the `caseStack.length === 0` guard mirrors the `frames.length`/
857
+ // `loopDepth` guards — a post-fi `exit 1` wrapped in `case "a" in
858
+ // "b") ... ;; esac` never runs.
859
+ if (pendingFallThroughMissBlock &&
860
+ frames.length === 0 &&
861
+ loopDepth === 0 &&
862
+ caseDepth === 0 &&
863
+ isBlockingStmtLine(full, loopDepth)) {
864
+ return true;
865
+ }
866
+ // R22 F1 — clear the pending fall-through requirement if we see an
867
+ // intervening top-level `exit`/`return` terminator that is NOT blocking
868
+ // (i.e. `exit 0`, bare `exit`, `return 0`, bare `return`). Such a
869
+ // terminator makes the later `exit N` unreachable, so the miss path
870
+ // still exits successfully — the hook is NOT audit-enforcing. Without
871
+ // this reset, `if grep ...; then exit 0; fi; exit 0; exit 1` would be
872
+ // accepted as fall-through-blocking because the later `exit 1` would
873
+ // satisfy `pendingFallThroughMissBlock` even though execution can never
874
+ // reach it on a miss.
875
+ if (pendingFallThroughMissBlock &&
876
+ frames.length === 0 &&
877
+ loopDepth === 0 &&
878
+ caseDepth === 0 &&
879
+ isHeadTerminatorStmtLine(full) &&
880
+ !isBlockingStmtLine(full, loopDepth)) {
881
+ pendingFallThroughMissBlock = false;
882
+ }
883
+ // Statement inside an open `if` frame — route blocking statements to
884
+ // the correct branch counter. The shipped husky gate uses exactly this
885
+ // shape: `if ! grep ...; then` followed by `printf ...` +
886
+ // `block_push=1` + `continue` + `fi`.
887
+ recordBlockingIntoFrame(full);
888
+ }
889
+ // R19 F1: No bare-grep fallback. The prior line-level fallback accepted a
890
+ // top-level audit grep under the assumption that POSIX `set -e` would
891
+ // abort the shell on a miss. But `isReaManagedHuskyGate` never verifies
892
+ // that `set -e` is actually enabled for the containing hook, so a gate
893
+ // with `grep ... .rea/audit.jsonl` followed by `exit 0` would pass the
894
+ // classifier while leaving the miss path non-blocking. Rather than chase
895
+ // that proof (`set -e` can be disabled by a nested `set +e`, aliased,
896
+ // overridden by `trap`, etc.), we require an explicit if-form miss-path
897
+ // blocker — that's what forms (a), (b), and (c) above enforce.
898
+ //
899
+ // Consequence: the only accepted audit-check shapes are now
900
+ // (a) `if ! <audit-grep>; then exit N; fi`
901
+ // (b) `if <audit-grep>; then :; else exit N; fi`
902
+ // (c) `if <audit-grep>; then exit 0; fi` followed by top-level `exit N`
903
+ //
904
+ // Any hook shape outside this set is treated as not-audit-enforced and
905
+ // therefore not rea-managed; `rea doctor` will flag it and the installer
906
+ // will refresh it to the canonical husky-gate template.
907
+ return false;
908
+ }
909
+ /**
910
+ * True when `line` is a single logical line that performs an enforcing
911
+ * `grep`-family match against the audit log for the `codex.review` token.
912
+ * All R15 F1 constraints apply: audit-log binding (literal path or tracked
913
+ * variable), rejection of swallowing tails (`|| true`, `|| :`, trailing `&`,
914
+ * `;` followed by a non-control command), and pipelines must terminate in a
915
+ * grep-family command.
916
+ *
917
+ * Extracted into a helper so `hasAuditCheck` can call it from both the
918
+ * top-level-with-`set -e` path and the `if <audit-grep>; then ... fi` frame.
919
+ */
920
+ function isAuditGrepLine(line, auditVars) {
921
+ // R27 F1 — grep-family MUST be the head command of a pipeline segment,
922
+ // not a substring of another command's argument. The prior shape-level
923
+ // check scanned for any `\bgrep\b` word-boundary match anywhere in the
924
+ // line, which accepted a spoof like
925
+ //
926
+ // if echo "grep codex.review .rea/audit.jsonl"; then :; else exit 1; fi
927
+ //
928
+ // The `echo` always succeeds (exit 0), so the `else` never runs and the
929
+ // hook reports success without any audit proof. The `grep` token lived
930
+ // inside echo's quoted arg — our grepRe still matched it and the
931
+ // surrounding segment (walked via `findCommandEnd`) still contained
932
+ // `codex.review` and `$AUDIT_LOG`, so both scope-bound conditions
933
+ // passed.
934
+ //
935
+ // Fix: split the line into quote-aware pipeline segments and only
936
+ // consider segments whose HEAD TOKEN (after optional conditional
937
+ // openers like `if`/`elif`/`while`/`until` and a leading `!`) is a
938
+ // grep-family command. `echo ...`, `cat ...`, `printf ...` with
939
+ // grep-in-args no longer qualify.
940
+ //
941
+ // R26 F2 prior concerns remain: the pattern and audit-log reference
942
+ // must both live inside the grep segment, not in a later command
943
+ // joined by `&&` (`findCommandEnd`-style scoping is preserved via
944
+ // segment boundaries).
945
+ // R28 C1 — reject any audit-grep line that contains an unquoted `$(`
946
+ // or backtick. The canonical shipped hook does not compose its audit
947
+ // grep via command substitution, and the pipeline segmenter does not
948
+ // model `$(...)` or backtick subshells. Accepting such lines would let
949
+ // an attacker hide a pipeline boundary inside `$(...)` — or more
950
+ // simply, embed a swallower like `$(echo; exit 0)` into the grep's
951
+ // own arg scope. Any hook using those constructs is drift; `rea init`
952
+ // will refresh it to the canonical form.
953
+ if (containsUnquotedSubshell(line))
954
+ return false;
955
+ const segments = pipelineSegmentsForAudit(line);
956
+ let bound = false;
957
+ for (const seg of segments) {
958
+ let head = seg.trimStart();
959
+ head = head.replace(/^(if|elif|while|until)[ \t]+/, '');
960
+ head = head.replace(/^![ \t]+/, '');
961
+ const firstTokMatch = head.match(/^(\S+)/);
962
+ if (firstTokMatch === null)
963
+ continue;
964
+ const firstTok = firstTokMatch[1] ?? '';
965
+ const isGrepFamily = firstTok === 'grep' ||
966
+ firstTok === 'egrep' ||
967
+ firstTok === 'fgrep' ||
968
+ firstTok === 'rg' ||
969
+ /^\/[A-Za-z0-9/_.\-]*\/(grep|egrep|fgrep|rg)$/.test(firstTok);
970
+ if (!isGrepFamily)
971
+ continue;
972
+ // The pattern literal MUST live in this grep's own args. Accept both
973
+ // the raw `codex.review` form and the shell-escaped `codex\.review`
974
+ // form (the shipped canonical gate uses the escaped form).
975
+ if (!/codex\\?\.review/.test(head))
976
+ continue;
977
+ const refsAuditLog = /\.rea\/audit\.jsonl/.test(head) ||
978
+ Array.from(auditVars).some((v) => new RegExp(`\\$\\{?${v}\\}?`).test(head));
979
+ if (!refsAuditLog)
980
+ continue;
981
+ bound = true;
982
+ break;
983
+ }
984
+ if (!bound)
985
+ return false;
986
+ if (isSwallowingAuditCheck(line))
987
+ return false;
988
+ if (!isGrepOnlyPipeline(line))
989
+ return false;
990
+ return true;
991
+ }
992
+ /**
993
+ * Quote-aware pipeline segmenter used by `isAuditGrepLine`. Splits `s` at
994
+ * unquoted single `|` (pipe) characters, preserving `||` as an intra-segment
995
+ * construct so `isSwallowingAuditCheck` can reject it. Recognizes single/
996
+ * double-quote pairs and backslash escapes outside single quotes.
997
+ *
998
+ * Deliberately simpler than `splitStatements`: it only needs to partition
999
+ * pipelines, not full statement boundaries. ANSI-C `$'...'`, command
1000
+ * substitution `$()`, and backticks are not modeled — any hook using those
1001
+ * inside an audit check is drift and will be refreshed to the canonical
1002
+ * template by `rea init`.
1003
+ */
1004
+ function pipelineSegmentsForAudit(s) {
1005
+ const out = [];
1006
+ let buf = '';
1007
+ let inSingle = false;
1008
+ let inDouble = false;
1009
+ let escape = false;
1010
+ for (let i = 0; i < s.length; i++) {
1011
+ const c = s[i] ?? '';
1012
+ const next = s[i + 1] ?? '';
1013
+ if (escape) {
1014
+ escape = false;
1015
+ buf += c;
1016
+ continue;
1017
+ }
1018
+ if (c === '\\' && !inSingle) {
1019
+ escape = true;
1020
+ buf += c;
1021
+ continue;
1022
+ }
1023
+ if (c === "'" && !inDouble) {
1024
+ inSingle = !inSingle;
1025
+ buf += c;
1026
+ continue;
1027
+ }
1028
+ if (c === '"' && !inSingle) {
1029
+ inDouble = !inDouble;
1030
+ buf += c;
1031
+ continue;
1032
+ }
1033
+ if (!inSingle && !inDouble && c === '|') {
1034
+ if (next === '|') {
1035
+ buf += c + next;
1036
+ i++;
1037
+ continue;
1038
+ }
1039
+ out.push(buf);
1040
+ buf = '';
1041
+ continue;
1042
+ }
1043
+ buf += c;
1044
+ }
1045
+ if (buf.length > 0)
1046
+ out.push(buf);
1047
+ return out;
1048
+ }
1049
+ /**
1050
+ * R28 C1 — true when `s` contains an unquoted `$(` (POSIX command
1051
+ * substitution), `` ` `` (backtick subshell), or `$((` (arithmetic
1052
+ * expansion) outside single quotes. Any of these let an attacker hide
1053
+ * pipeline/statement boundaries that `pipelineSegmentsForAudit` and
1054
+ * `splitStatements` don't see — composing an audit grep via `$(...)`,
1055
+ * embedding a swallower like `$(exit 0)`, or splitting the pattern
1056
+ * across a subshell boundary. The canonical shipped hook never uses
1057
+ * these in its audit check; rejecting them early is strictly stricter
1058
+ * than what the shipped hook shape needs, so no regression.
1059
+ *
1060
+ * Single-quoted contents are inert in POSIX shell (no expansion), so we
1061
+ * allow `$(` and backticks inside `'...'`. Double-quoted contents DO
1062
+ * expand `$(...)`, so those are flagged. Backslash-escaped `\$(` and
1063
+ * `` \` `` outside single quotes are allowed — the backslash disables
1064
+ * expansion. Arithmetic `$((...))` shares the `$(` prefix and is
1065
+ * flagged by the same check.
1066
+ */
1067
+ function containsUnquotedSubshell(s) {
1068
+ let inSingle = false;
1069
+ let inDouble = false;
1070
+ let escape = false;
1071
+ for (let i = 0; i < s.length; i++) {
1072
+ const c = s[i] ?? '';
1073
+ if (escape) {
1074
+ escape = false;
1075
+ continue;
1076
+ }
1077
+ if (c === '\\' && !inSingle) {
1078
+ escape = true;
1079
+ continue;
1080
+ }
1081
+ if (c === "'" && !inDouble) {
1082
+ inSingle = !inSingle;
1083
+ continue;
1084
+ }
1085
+ if (c === '"' && !inSingle) {
1086
+ inDouble = !inDouble;
1087
+ continue;
1088
+ }
1089
+ if (inSingle)
1090
+ continue;
1091
+ if (c === '$' && s[i + 1] === '(')
1092
+ return true;
1093
+ if (c === '`')
1094
+ return true;
1095
+ }
1096
+ return false;
1097
+ }
1098
+ /**
1099
+ * True when an `if <cond>; then ... fi` condition is an obviously-dead
1100
+ * literal — one whose exit status is statically known without executing any
1101
+ * external command. The classifier uses this to refuse to accept an audit-if
1102
+ * whose then-body (or whose enclosing then-body) is unreachable in every
1103
+ * execution, which would otherwise let `if false; then <audit-if>; fi`
1104
+ * score as governance proof.
1105
+ *
1106
+ * Detected shapes (after stripping leading `if ` and optional `! ` negation):
1107
+ * - Bare `false`, `/bin/false`, `/usr/bin/false` — exit status 1 always.
1108
+ * With odd-count negations those become live; even-count stays dead.
1109
+ * - `[ "LITA" OP "LITB" ]` / `test "LITA" OP "LITB"` where OP is `=`/`==`/
1110
+ * `!=` and both sides are plain double-quoted literals with no variable
1111
+ * expansion or command substitution. Dead when `=`/`==` with unequal
1112
+ * literals, or `!=` with equal literals.
1113
+ *
1114
+ * Everything else is treated as LIVE — variable tests, command
1115
+ * substitutions (`$(...)`), pipelines, and non-test commands cannot be
1116
+ * statically evaluated by this parser. The safety posture is: err toward
1117
+ * accepting legitimate conditional guards. Dead-code spoofs beyond these
1118
+ * patterns are outside the threat model this heuristic targets; a
1119
+ * sufficiently creative adversary (`if [ "$SECRET" = "nomatch" ]; then
1120
+ * audit-if; fi`) is caught by human review and by the broader reachability
1121
+ * checks elsewhere in this module, not by a more elaborate literal-folding
1122
+ * engine here.
1123
+ */
1124
+ function isDeadIfCondition(condStmt) {
1125
+ let s = condStmt.trim();
1126
+ if (!s.startsWith('if ') && !s.startsWith('if\t'))
1127
+ return false;
1128
+ s = s.replace(/^if[ \t]+/, '');
1129
+ let negations = 0;
1130
+ while (/^![ \t]+/.test(s)) {
1131
+ negations++;
1132
+ s = s.replace(/^![ \t]+/, '');
1133
+ }
1134
+ // Head-token dead commands: `false`, `/bin/false`, `/usr/bin/false`. A
1135
+ // trailing space, semicolon, or end-of-string separates the head.
1136
+ const headMatch = s.match(/^(\S+)(?=\s|;|$)/);
1137
+ const head = headMatch ? headMatch[1] : '';
1138
+ const headIsDeadFalse = head === 'false' || head === '/bin/false' || head === '/usr/bin/false';
1139
+ const headIsDeadTrue = head === 'true' || head === ':' || head === '/bin/true' || head === '/usr/bin/true';
1140
+ if (headIsDeadFalse) {
1141
+ return negations % 2 === 0;
1142
+ }
1143
+ if (headIsDeadTrue) {
1144
+ // `if true; then ...` is ALWAYS-TAKEN, not dead. But under a single
1145
+ // negation (`if ! true;`) it becomes always-skipped — dead.
1146
+ return negations % 2 === 1;
1147
+ }
1148
+ // Literal-constant equality tests: `[ "x" = "y" ]` / `test "x" = "y"`.
1149
+ const LITEQ = /^(?:\[|test)\s+"([^"$`\\]*)"\s+(=|==|!=)\s+"([^"$`\\]*)"\s+\]?\s*$/;
1150
+ const m = s.match(LITEQ);
1151
+ if (m !== null) {
1152
+ const lhs = m[1] ?? '';
1153
+ const op = m[2] ?? '';
1154
+ const rhs = m[3] ?? '';
1155
+ let dead;
1156
+ if (op === '=' || op === '==') {
1157
+ dead = lhs !== rhs;
1158
+ }
1159
+ else {
1160
+ dead = lhs === rhs;
1161
+ }
1162
+ return negations % 2 === 0 ? dead : !dead;
1163
+ }
1164
+ return false;
1165
+ }
1166
+ /**
1167
+ * True when a loop header (`for`/`while`/`until`) is statically dead — no
1168
+ * iteration will ever execute. Catches the `while false; do <audit-if>;
1169
+ * done` spoof symmetric to `isDeadIfCondition`.
1170
+ *
1171
+ * Detected shapes:
1172
+ * - `while false`, `while /bin/false`, `while /usr/bin/false`
1173
+ * - `until true`, `until :`, `until /bin/true`, `until /usr/bin/true`
1174
+ * - `for VAR in ; do` — empty in-list, no iterations. Note: `for VAR;`
1175
+ * (no `in`) iterates positional params and is treated as LIVE because
1176
+ * `"$@"` is usually populated; a hook invoked with no args would
1177
+ * not iterate but that is an operational state, not a syntactic
1178
+ * deadness, so we accept it as live.
1179
+ *
1180
+ * Everything else (variable-driven conditions, command substitutions) is
1181
+ * treated as live.
1182
+ */
1183
+ function isDeadLoopHead(loopHead) {
1184
+ const s = loopHead.trim();
1185
+ const whileM = s.match(/^while[ \t]+(.+?)(?:[;\s]+do\b|;?\s*$)/);
1186
+ if (whileM !== null) {
1187
+ const cond = (whileM[1] ?? '').trim();
1188
+ const head = cond.split(/\s+/, 1)[0] ?? '';
1189
+ if (head === 'false' || head === '/bin/false' || head === '/usr/bin/false') {
1190
+ return true;
1191
+ }
1192
+ return false;
1193
+ }
1194
+ const untilM = s.match(/^until[ \t]+(.+?)(?:[;\s]+do\b|;?\s*$)/);
1195
+ if (untilM !== null) {
1196
+ const cond = (untilM[1] ?? '').trim();
1197
+ const head = cond.split(/\s+/, 1)[0] ?? '';
1198
+ if (head === 'true' ||
1199
+ head === ':' ||
1200
+ head === '/bin/true' ||
1201
+ head === '/usr/bin/true') {
1202
+ return true;
1203
+ }
1204
+ return false;
1205
+ }
1206
+ const forEmptyIn = /^for[ \t]+[A-Za-z_][A-Za-z0-9_]*[ \t]+in[ \t]*(?:;|\s*do\b|\s*$)/;
1207
+ if (forEmptyIn.test(s))
1208
+ return true;
1209
+ return false;
1210
+ }
1211
+ /**
1212
+ * True when `line` contains at least one statement that, if executed, causes
1213
+ * the push to block. Recognized forms:
1214
+ *
1215
+ * - `exit N` / `return N` where N >= 1 — explicit non-zero termination.
1216
+ *
1217
+ * Explicitly NOT blocking:
1218
+ * - `:` (null command) — no-op.
1219
+ * - `printf` / `echo` — informational output only.
1220
+ * - Bare assignments (e.g. `block_push=1`) without a subsequent
1221
+ * flow-control statement — the flag means nothing by itself.
1222
+ * - `continue` / `break` — loop control only. The shell still falls
1223
+ * through to whatever follows the enclosing `done`, which may be
1224
+ * `exit 0`. The accumulator pattern that would make them blocking
1225
+ * (`block_push=1; continue` paired with a post-loop
1226
+ * `if [ "$block_push" -ne 0 ]; then exit 1; fi`) is outside the
1227
+ * scope of this text-level parser (R18 F2). The shipped husky gate
1228
+ * was restructured to use `exit 1` directly inside the miss-path
1229
+ * body so that this detector can verify it cleanly.
1230
+ */
1231
+ /**
1232
+ * True when the trimmed statement text's FIRST command token is `exit N`
1233
+ * or `return N` with N >= 1. Refuses to match when `exit N` appears as an
1234
+ * argument to another command (`echo exit 1`, `printf 'exit 1'`, etc.),
1235
+ * which the shell never executes as a real exit.
1236
+ *
1237
+ * R20 F1/F3 (Codex): the prior substring regex `\bexit[ \t]+[1-9]\d*\b`
1238
+ * accepted `echo exit 1` as blocking because the regex only cared that
1239
+ * the characters `exit 1` appeared somewhere in the statement. Head-token
1240
+ * parsing refuses that shape.
1241
+ *
1242
+ * Also recognizes a grouped block `{ … }` whose inner statements contain a
1243
+ * head-matched exit — `{ echo halt; exit 1; }` still blocks, but
1244
+ * `{ echo exit 1; }` does not.
1245
+ */
1246
+ function isHeadExitStmt(stmt) {
1247
+ const t = stmt.trim();
1248
+ if (t.length === 0)
1249
+ return false;
1250
+ if (/^exit[ \t]+[1-9]\d*\b/.test(t))
1251
+ return true;
1252
+ // R23 F1 — top-level `return N` in a POSIX hook script is NOT a script
1253
+ // terminator. `/bin/sh -c 'return 1; exit 0'` emits a diagnostic and
1254
+ // continues to `exit 0`, so a hook that writes `return 1` where it means
1255
+ // `exit 1` is still fully bypassable. `stripFunctionBodies` already zeroes
1256
+ // out real function bodies, so by the time the parser sees a statement
1257
+ // every `return` here is top-level — treat it as non-blocking.
1258
+ // Grouped block `{ a; b; exit N; }` — split the body on `;`/newline and
1259
+ // require that AT LEAST ONE inner statement is itself head-matched.
1260
+ const blockMatch = t.match(/^\{([^}]*)\}/);
1261
+ if (blockMatch !== null) {
1262
+ const body = blockMatch[1] ?? '';
1263
+ for (const inner of body.split(/[;\n]/)) {
1264
+ const sub = inner.trim();
1265
+ if (sub.length === 0)
1266
+ continue;
1267
+ if (/^exit[ \t]+[1-9]\d*\b/.test(sub))
1268
+ return true;
1269
+ }
1270
+ }
1271
+ return false;
1272
+ }
1273
+ /**
1274
+ * True when `line` contains at least one statement that unconditionally
1275
+ * allows the push to proceed from the current branch (`exit 0` / `return 0`).
1276
+ * Used to detect the legacy "allow-on-match + fall-through-block" shape
1277
+ * (R18 form c):
1278
+ *
1279
+ * if grep -q codex.review .rea/audit.jsonl; then exit 0; fi
1280
+ * exit 1
1281
+ *
1282
+ * On match, `exit 0` in the then-branch terminates the shell successfully.
1283
+ * On miss, the then-branch is skipped and control falls through the `fi`,
1284
+ * where the post-fi `exit 1` blocks the push. The classifier needs to see
1285
+ * both halves (allow in then, block after fi) to accept this shape.
1286
+ *
1287
+ * Bare `exit` / `return` (no arg) is not treated as allow-on-match because
1288
+ * POSIX uses the last command's exit status, which is brittle inside a
1289
+ * freshly-entered `then` branch (grep's status was already consumed by the
1290
+ * `if`). Requiring an explicit `0` keeps the detector fail-closed.
1291
+ */
1292
+ function isAllowOnMatchStmtLine(line) {
1293
+ const text = line.trim();
1294
+ if (text.length === 0)
1295
+ return false;
1296
+ for (const stmt of splitStatements(text)) {
1297
+ const t = stmt.trim();
1298
+ if (t.length === 0)
1299
+ continue;
1300
+ // R23 F2 — the prior substring regex accepted `echo exit 0` and
1301
+ // `printf 'return 0'` as allow-on-match because `\bexit 0\b` matched
1302
+ // the string argument. Use head-position parsing so only a real command
1303
+ // `exit 0` / `return 0` qualifies, and drop `return` entirely at top
1304
+ // level (R23 F1: top-level `return` is not a terminator in a POSIX
1305
+ // script).
1306
+ if (/^exit[ \t]+0(?=[\s;|&]|$)/.test(t))
1307
+ return true;
1308
+ const blockMatch = t.match(/^\{([^}]*)\}/);
1309
+ if (blockMatch !== null) {
1310
+ const body = blockMatch[1] ?? '';
1311
+ for (const inner of body.split(/[;\n]/)) {
1312
+ const sub = inner.trim();
1313
+ if (sub.length === 0)
1314
+ continue;
1315
+ if (/^exit[ \t]+0(?=[\s;|&]|$)/.test(sub))
1316
+ return true;
1317
+ }
1318
+ }
1319
+ }
1320
+ return false;
1321
+ }
1322
+ /**
1323
+ * True when `line` contains a head-position `exit` or `return` statement
1324
+ * with ANY exit status (or none). Used to invalidate a pending
1325
+ * fall-through-miss-block expectation in `hasAuditCheck` form (c) — if a
1326
+ * top-level terminator runs before a later blocking `exit N`, the blocker
1327
+ * becomes unreachable on the audit-miss path and the hook is no longer
1328
+ * proved to be audit-enforcing.
1329
+ *
1330
+ * Grouped-block inner terminators are also recognized so
1331
+ * `{ cleanup; exit 0; }` invalidates a pending expectation.
1332
+ */
1333
+ function isHeadTerminatorStmtLine(line) {
1334
+ const text = line.trim();
1335
+ if (text.length === 0)
1336
+ return false;
1337
+ for (const stmt of splitStatements(text)) {
1338
+ const t = stmt.trim();
1339
+ if (t.length === 0)
1340
+ continue;
1341
+ if (/^exit(?:[ \t]+\d+)?(?=[\s;|&]|$)/.test(t))
1342
+ return true;
1343
+ // R23 F1 — top-level `return` is NOT a script terminator in a POSIX
1344
+ // hook. Excluded here so it does not falsely clear a pending
1345
+ // fall-through expectation.
1346
+ const blockMatch = t.match(/^\{([^}]*)\}/);
1347
+ if (blockMatch !== null) {
1348
+ const body = blockMatch[1] ?? '';
1349
+ for (const inner of body.split(/[;\n]/)) {
1350
+ const sub = inner.trim();
1351
+ if (sub.length === 0)
1352
+ continue;
1353
+ if (/^exit(?:[ \t]+\d+)?(?=[\s;|&]|$)/.test(sub))
1354
+ return true;
1355
+ }
1356
+ }
1357
+ }
1358
+ return false;
1359
+ }
1360
+ function isBlockingStmtLine(line, _loopDepth) {
1361
+ // R18 F2: `continue`/`break` are no longer treated as blocking on their
1362
+ // own. Both only affect loop control — the shell still falls through to
1363
+ // whatever follows the enclosing `done`, which may well be `exit 0`.
1364
+ // The shipped husky gate used `continue` paired with `block_push=1` +
1365
+ // a post-loop `if [ "$block_push" -ne 0 ]; then exit 1; fi`; that
1366
+ // accumulator pattern is outside the scope of this text-level parser,
1367
+ // so we now require an explicit `exit N` or `return N` inside the
1368
+ // miss-path body and have restructured the shipped hook accordingly.
1369
+ //
1370
+ // R20 F1: the substring regex `\bexit[ \t]+[1-9]\d*\b` accepted any
1371
+ // occurrence of `exit 1` in the statement, so `echo exit 1` and
1372
+ // `printf 'exit 1'` were mistakenly treated as blocking. Switch to
1373
+ // head-token parsing via `isHeadExitStmt`, which only accepts the
1374
+ // terminator at command-head position (and inside grouped blocks).
1375
+ const text = line.trim();
1376
+ if (text.length === 0)
1377
+ return false;
1378
+ for (const stmt of splitStatements(text)) {
1379
+ if (isHeadExitStmt(stmt))
1380
+ return true;
1381
+ }
1382
+ return false;
1383
+ }
1384
+ /**
1385
+ * Join POSIX shell line continuations so the rest of the parser sees one
1386
+ * logical line per command. A trailing backslash immediately before `\n`
1387
+ * (no intervening whitespace — POSIX rule) is the continuation token.
1388
+ *
1389
+ * Using a single space as the join character preserves token boundaries so
1390
+ * downstream regexes (which rely on `\b`) still match correctly across the
1391
+ * former line break.
1392
+ */
1393
+ function joinLineContinuations(content) {
1394
+ return content.replace(/\\\r?\n/g, ' ');
1395
+ }
1396
+ /**
1397
+ * Replace every function-body line with an empty line so downstream
1398
+ * classifiers only see top-level (reachable) shell. A function definition
1399
+ * with no call site is dead code — any `exec <gate>` / `GATE=...; exec
1400
+ * "$GATE"` / etc. inside it MUST be ignored, otherwise the classifier
1401
+ * accepts uncalled helper functions as proof of gate delegation (R18 F1
1402
+ * + R19 F2).
1403
+ *
1404
+ * Recognizes both POSIX and bash-style function definitions:
1405
+ * name() { body; }
1406
+ * name() {
1407
+ * body
1408
+ * }
1409
+ * function name { body; }
1410
+ * function name() { body; }
1411
+ *
1412
+ * Brace counting strips `${...}` parameter expansions first so they don't
1413
+ * skew the depth counter. Approximate but fail-closed: a pathological
1414
+ * unbalanced `{` inside a heredoc would leak into a "still-in-function"
1415
+ * state, erasing more content than necessary — that errs toward
1416
+ * under-accepting, never over-accepting.
1417
+ */
1418
+ function stripFunctionBodies(content) {
1419
+ const lines = content.split(/\r?\n/);
1420
+ const out = [];
1421
+ let funcBraceDepth = 0;
1422
+ const funcDefRe = /^(?:function[ \t]+[A-Za-z_][A-Za-z0-9_]*(?:[ \t]*\([ \t]*\))?|[A-Za-z_][A-Za-z0-9_]*[ \t]*\([ \t]*\))[ \t]*\{?[ \t]*$/;
1423
+ for (const raw of lines) {
1424
+ const line = raw.trimStart();
1425
+ if (funcBraceDepth === 0) {
1426
+ const stripped = line.replace(/\$\{[^}]*\}/g, '');
1427
+ if (funcDefRe.test(stripped)) {
1428
+ funcBraceDepth++;
1429
+ out.push('');
1430
+ continue;
1431
+ }
1432
+ }
1433
+ if (funcBraceDepth > 0) {
1434
+ const stripped = line.replace(/\$\{[^}]*\}/g, '');
1435
+ const opens = (stripped.match(/\{/g) ?? []).length;
1436
+ const closes = (stripped.match(/\}/g) ?? []).length;
1437
+ funcBraceDepth = Math.max(0, funcBraceDepth + opens - closes);
1438
+ out.push('');
1439
+ continue;
1440
+ }
1441
+ out.push(raw);
1442
+ }
1443
+ return out.join('\n');
1444
+ }
1445
+ /**
1446
+ * Strip a trailing `# ...` comment (hash preceded by whitespace and outside
1447
+ * of quoted strings) from a line. Preserves `#` inside single- or double-
1448
+ * quoted segments — a pragmatic but non-complete quoting parser that is
1449
+ * sufficient for hook-script shapes (no `$(...)` command substitution
1450
+ * nesting is handled). Without this, an attacker could hide a swallower
1451
+ * behind a comment boundary during manual code review, though not from the
1452
+ * shell itself; stripping here keeps the classifier aligned with the
1453
+ * shell's view of the line.
1454
+ */
1455
+ function stripTrailingComment(line) {
1456
+ let inSingle = false;
1457
+ let inDouble = false;
1458
+ for (let i = 0; i < line.length; i++) {
1459
+ const c = line[i];
1460
+ const prev = i > 0 ? line[i - 1] : '';
1461
+ if (c === "'" && !inDouble)
1462
+ inSingle = !inSingle;
1463
+ else if (c === '"' && !inSingle)
1464
+ inDouble = !inDouble;
1465
+ else if (c === '#' &&
1466
+ !inSingle &&
1467
+ !inDouble &&
1468
+ (prev === ' ' || prev === '\t')) {
1469
+ return line.slice(0, i).trimEnd();
1470
+ }
1471
+ }
1472
+ return line;
1473
+ }
1474
+ /**
1475
+ * True when `line` uses any construct that silently discards the grep's
1476
+ * non-zero exit when no `codex.review` record matches. Applies to the
1477
+ * logical line containing the audit-check grep (continuations already joined).
1478
+ *
1479
+ * NOTE: `grep ...; then` (the shipped husky gate's shape, as the condition
1480
+ * of `if ! grep ...; then`) is accepted — `then` is a control keyword.
1481
+ */
1482
+ function isSwallowingAuditCheck(line) {
1483
+ // `\b` only matches at a word/non-word boundary, which excludes `:` when
1484
+ // followed by end-of-line or whitespace (both non-word). Use `(?!\w)`
1485
+ // instead so `|| :`, `|| true`, `|| /bin/true` all match regardless of
1486
+ // what trails.
1487
+ if (/\|\|\s*(true|:|\/bin\/true|\/usr\/bin\/true)(?!\w)/.test(line))
1488
+ return true;
1489
+ // `|| exit` with no numeric argument falls through to `$?` which can be
1490
+ // 0; `|| exit 0` (and `exit 00` etc.) explicitly allows the push.
1491
+ if (/\|\|\s*exit(\s+0+\b|\s*$|\s*;)/.test(line))
1492
+ return true;
1493
+ // Bare `&` that is not part of `&&` and not an fd-redirect piece. Strip
1494
+ // POSIX fd redirects first so `2>&1`, `1>&2`, `<&0`, and bash `&>` forms
1495
+ // do not look like backgrounding.
1496
+ const stripped = line.replace(/\d*[<>]&\d*-?/g, ' ').replace(/&>>?/g, ' ');
1497
+ if (/(?<!&)&(?!&)/.test(stripped))
1498
+ return true;
1499
+ // R28 C2 — check EVERY `;` position in the line, not just the first.
1500
+ // The pre-R28 `line.match(/;\s*(\S+)/)` (non-global) only returned the
1501
+ // first `;` match. A line like
1502
+ // `if ! grep -qE codex.review "$AUDIT_LOG"; then exit 1; fi; exit 0`
1503
+ // matched the first `;` (followed by `then`, a control keyword, safe),
1504
+ // declared the line non-swallowing, and missed the trailing `; exit 0`
1505
+ // that actually neutralizes the blocker. Splitting on all `;`s and
1506
+ // checking each tail's head token closes the gap. Each logical
1507
+ // statement the splitter emits is still passed through this check
1508
+ // separately, but defense-in-depth: any joined-statement input is
1509
+ // checked comprehensively.
1510
+ const controlKeywords = new Set([
1511
+ 'then',
1512
+ 'do',
1513
+ 'fi',
1514
+ 'done',
1515
+ 'else',
1516
+ 'elif',
1517
+ 'esac',
1518
+ ]);
1519
+ const semiMatches = Array.from(line.matchAll(/;\s*(\S+)/g));
1520
+ for (const m of semiMatches) {
1521
+ const first = m[1];
1522
+ if (first === undefined)
1523
+ continue;
1524
+ if (first.startsWith('#'))
1525
+ continue;
1526
+ if (controlKeywords.has(first))
1527
+ continue;
1528
+ return true;
1529
+ }
1530
+ return false;
1531
+ }
1532
+ /**
1533
+ * True when every pipeline segment's last command is a grep-family match.
1534
+ * For single-command lines (no `|`) returns true trivially.
1535
+ *
1536
+ * Under POSIX `/bin/sh`, a pipeline's exit is the LAST command's, so
1537
+ * `grep ... .rea/audit.jsonl | cat` returns 0 on miss. Requiring the tail
1538
+ * to be a grep (or rg) keeps enforcement meaningful while permitting the
1539
+ * shipped husky gate's `grep -E ... | grep -qF ...` form.
1540
+ */
1541
+ function isGrepOnlyPipeline(line) {
1542
+ const segments = [];
1543
+ let buf = '';
1544
+ for (let i = 0; i < line.length; i++) {
1545
+ const c = line[i];
1546
+ const next = line[i + 1] ?? '';
1547
+ if (c === '|' && next !== '|') {
1548
+ segments.push(buf);
1549
+ buf = '';
1550
+ }
1551
+ else if (c === '|' && next === '|') {
1552
+ buf += c + next;
1553
+ i++;
1554
+ }
1555
+ else {
1556
+ buf += c;
1557
+ }
1558
+ }
1559
+ if (buf.length > 0)
1560
+ segments.push(buf);
1561
+ if (segments.length <= 1)
1562
+ return true;
1563
+ const grepCmd = /(^|\s)(grep|egrep|fgrep|rg)\b/;
1564
+ const last = segments[segments.length - 1];
1565
+ if (last === undefined)
1566
+ return true;
1567
+ return grepCmd.test(last);
1568
+ }
1569
+ /**
1570
+ * True when `content` contains a REAL shell invocation of
1571
+ * `push-review-gate.sh`. Used as a softer signal that a consumer-owned
1572
+ * pre-push still wires the shared gate (e.g. a husky 9 file that runs
1573
+ * lint AND execs the gate). Combined with "exists AND executable", a
1574
+ * gate-referencing foreign hook is a legitimate integration point —
1575
+ * doctor reports `pass`, install skips.
1576
+ *
1577
+ * Accepts (positive-match allowlist):
1578
+ * - Bare invocation: `.claude/hooks/push-review-gate.sh "$@"`
1579
+ * - POSIX exec keyword: `exec`, `.`, `sh`, `bash`, `zsh` followed by the
1580
+ * gate path. The bash-only `source` keyword is NOT accepted — the POSIX
1581
+ * equivalent `.` (dot) is.
1582
+ * - Quoted/expanded path prefix: `exec "$REA_ROOT"/.claude/hooks/push-review-gate.sh "$@"`
1583
+ * — double- or single-quoted variable expansions before the literal path
1584
+ * are treated as part of the path, not as a mention context.
1585
+ * - Trailing `;` after `exec <gate>`: `exec gate.sh "$@";` — exec replaces
1586
+ * the shell, so the `;` and anything after it never runs; gate exit IS
1587
+ * the hook's exit status.
1588
+ * - Variable indirection: `GATE=<path-containing-gate>` on one line plus
1589
+ * `exec "$GATE"` / `. "$GATE"` / etc. on a later line.
1590
+ *
1591
+ * Rejects:
1592
+ * - Comment lines starting with `#`
1593
+ * - Shell tests: `[ -x .claude/hooks/push-review-gate.sh ]`
1594
+ * - File tests: `test -f .claude/hooks/push-review-gate.sh`
1595
+ * - Chmod / cp / mv / cat / printf / echo mentioning the path
1596
+ * - String literals inside quoted arguments to non-invocation commands
1597
+ * - Invocations inside `if`/`for`/`while`/`case` blocks (conditional —
1598
+ * not guaranteed to run)
1599
+ * - Invocations after an unconditional top-level `exit`
1600
+ * - Non-`exec` invocations followed by `||`, `&&`, `;`, or trailing `&`
1601
+ * (status-swallowing operators)
1602
+ *
1603
+ * This is a pragmatic heuristic, not a full shell parser. R12 F2 broadened
1604
+ * the allowlist to match the forms Codex flagged as valid but previously
1605
+ * rejected; narrower patterns silently hard-failed `rea doctor` on
1606
+ * correctly-governed consumer repos.
1607
+ */
1608
+ export function referencesReviewGate(content) {
1609
+ // R19 F2: Pre-strip function bodies before ANY detection pass. Both the
1610
+ // literal-path line walker and the variable-indirection helper must agree
1611
+ // that content inside an uncalled function is dead code. Previously the
1612
+ // variable-indirection path ran first (early return) and bypassed the
1613
+ // function-scope guard that only the literal-path walker applied.
1614
+ const topLevel = stripFunctionBodies(content);
1615
+ // First pass: variable indirection. If the caller wrote the path into a
1616
+ // shell variable and execs the variable, same-line matching won't catch it.
1617
+ // Scan for assignment + later invocation of the same variable.
1618
+ if (hasVariableGateInvocation(topLevel))
1619
+ return true;
1620
+ const lines = topLevel.split(/\r?\n/);
1621
+ let exitedBeforeGate = false;
1622
+ let depth = 0;
1623
+ // R18 F1: function bodies are a separate scope that was not depth-tracked.
1624
+ // A hook like `run_gate() { exec .claude/hooks/push-review-gate.sh; }` with
1625
+ // no call site for `run_gate` previously passed because the exec sat at
1626
+ // depth 0 by line-level accounting. We now treat any content inside a
1627
+ // function body as "not top-level" and reject invocations there.
1628
+ let funcBraceDepth = 0;
1629
+ const funcDefRe = /^(?:function[ \t]+[A-Za-z_][A-Za-z0-9_]*(?:[ \t]*\([ \t]*\))?|[A-Za-z_][A-Za-z0-9_]*[ \t]*\([ \t]*\))[ \t]*\{?[ \t]*$/;
1630
+ for (const raw of lines) {
1631
+ let line = raw.trim();
1632
+ if (line.length === 0)
1633
+ continue;
1634
+ if (line.startsWith('#'))
1635
+ continue;
1636
+ const hashIdx = line.indexOf('#');
1637
+ if (hashIdx > 0) {
1638
+ const before = line[hashIdx - 1];
1639
+ if (before === ' ' || before === '\t') {
1640
+ line = line.slice(0, hashIdx).trimEnd();
1641
+ }
1642
+ }
1643
+ // Function-body tracking. Enter when a function definition line is seen;
1644
+ // exit when the matching `}` arrives on its own line. Nested braces
1645
+ // (e.g. brace-expansion `${VAR:-default}`) are stripped first so they
1646
+ // don't throw off the counter.
1647
+ const stripped = line.replace(/\$\{[^}]*\}/g, '');
1648
+ if (funcBraceDepth === 0 && funcDefRe.test(stripped)) {
1649
+ funcBraceDepth++;
1650
+ if (!line.includes('{')) {
1651
+ // Body opens on a subsequent line — leave counter at 1, the next
1652
+ // line's `{` will be absorbed by brace-balance below.
1653
+ }
1654
+ continue;
1655
+ }
1656
+ if (funcBraceDepth > 0) {
1657
+ const opens = (stripped.match(/\{/g) ?? []).length;
1658
+ const closes = (stripped.match(/\}/g) ?? []).length;
1659
+ funcBraceDepth = Math.max(0, funcBraceDepth + opens - closes);
1660
+ continue;
1661
+ }
1662
+ if (/^(if|for|while|until|case)\b/.test(line))
1663
+ depth++;
1664
+ if (/^(fi|done|esac)\b/.test(line))
1665
+ depth = Math.max(0, depth - 1);
1666
+ if (depth === 0 && isTopLevelExit(line)) {
1667
+ exitedBeforeGate = true;
1668
+ }
1669
+ if (!line.includes(GATE_DELEGATION_TOKEN))
1670
+ continue;
1671
+ if (looksLikeGateInvocation(line) &&
1672
+ depth === 0 &&
1673
+ !hasContinuationOperator(line) &&
1674
+ !exitedBeforeGate)
1675
+ return true;
1676
+ }
1677
+ return false;
1678
+ }
1679
+ /**
1680
+ * True when `content` contains a variable assignment whose value contains
1681
+ * the gate token, followed (later in the file) by an `exec`/`.`/`sh`/`bash`/
1682
+ * `zsh` invocation of that same variable. Handles the idiomatic defensive
1683
+ * form Codex flagged:
1684
+ *
1685
+ * GATE=.claude/hooks/push-review-gate.sh
1686
+ * exec "$GATE" "$@"
1687
+ *
1688
+ * Same guards apply to the invocation line (unconditional, top-level, no
1689
+ * status-swallowing operators) — we do NOT accept a variable invocation
1690
+ * that sits inside an `if` block or is followed by `&&` / `||` / `;`.
1691
+ *
1692
+ * R12 F2: previous `referencesReviewGate` only checked same-line literal
1693
+ * path forms. A valid delegating hook that routed through a variable was
1694
+ * classified as foreign, causing `rea doctor` to hard-fail on governed
1695
+ * repos.
1696
+ */
1697
+ function hasVariableGateInvocation(content) {
1698
+ // R16 F3: track each variable's CURRENT value at exec time, not just
1699
+ // whether the name has EVER appeared on the LHS of a gate-carrying
1700
+ // assignment. A spoof like `GATE=gate.sh\nGATE=/bin/true\nexec "$GATE"`
1701
+ // previously passed because the classifier only checked the first
1702
+ // assignment and considered the variable "gate-carrying" for the rest of
1703
+ // the file. We now process statements in order and update each variable's
1704
+ // gate-carrying state on every assignment (including inside blocks).
1705
+ //
1706
+ // Conservative model: an assignment inside a conditional branch reassigns
1707
+ // the variable unconditionally in the tracker. This is imprecise (the
1708
+ // branch may not fire at runtime) but fail-closed — it causes us to
1709
+ // under-accept, never to over-accept. An operator whose valid gate
1710
+ // delegation accidentally trips this pattern can hoist the assignment to
1711
+ // top level to recover recognition.
1712
+ const varIsGateCarrying = new Map();
1713
+ const assignRe = /^(?:export[ \t]+|readonly[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
1714
+ let depth = 0;
1715
+ let exitedBeforeGate = false;
1716
+ for (const stmt of statementsOf(content)) {
1717
+ const { head, full } = stmt;
1718
+ if (/^(if|for|while|case|until)\b/.test(head))
1719
+ depth++;
1720
+ if (/^(fi|done|esac)\b/.test(head))
1721
+ depth = Math.max(0, depth - 1);
1722
+ // Some branch keywords (then/else/elif/do) may prefix a statement — strip
1723
+ // them so the assignment match can see the underlying VAR=value shape.
1724
+ const body = full
1725
+ .replace(/^(then|else|elif|do)[ \t]+/, '')
1726
+ .trimStart();
1727
+ const m = body.match(assignRe);
1728
+ if (m) {
1729
+ const name = m[1];
1730
+ const rhs = m[2];
1731
+ if (name !== undefined && rhs !== undefined) {
1732
+ varIsGateCarrying.set(name, rhs.includes(GATE_DELEGATION_TOKEN));
1733
+ }
1734
+ continue;
1735
+ }
1736
+ if (depth === 0 && isTopLevelExit(full)) {
1737
+ exitedBeforeGate = true;
1738
+ continue;
1739
+ }
1740
+ for (const [v, isGate] of varIsGateCarrying) {
1741
+ if (!isGate)
1742
+ continue;
1743
+ const pattern = new RegExp(`^(exec|sh|bash|zsh|\\.)[ \\t]+["']?\\$\\{?${v}\\}?["']?(?=[ \\t;|&"'()]|$)`);
1744
+ if (!pattern.test(body))
1745
+ continue;
1746
+ if (depth !== 0)
1747
+ continue;
1748
+ if (exitedBeforeGate)
1749
+ continue;
1750
+ const execLed = /^exec\b/.test(body);
1751
+ const varIdx = body.search(new RegExp(`\\$\\{?${v}\\}?`));
1752
+ if (varIdx === -1)
1753
+ continue;
1754
+ const afterVar = body
1755
+ .slice(varIdx)
1756
+ .replace(new RegExp(`^\\$\\{?${v}\\}?["']?`), '');
1757
+ const tail = afterVar
1758
+ .replace(/\d*[<>]&\d*-?/g, ' ')
1759
+ .replace(/&>>?/g, ' ');
1760
+ if (/\|/.test(tail))
1761
+ continue;
1762
+ if (execLed) {
1763
+ if (/&&/.test(tail) || /&\s*$/.test(tail))
1764
+ continue;
1765
+ }
1766
+ else {
1767
+ if (/&&|;/.test(tail) || /&\s*$/.test(tail))
1768
+ continue;
1769
+ }
1770
+ return true;
1771
+ }
1772
+ }
1773
+ return false;
1774
+ }
1775
+ /**
1776
+ * Returns true when `line` contains a shell status-swallowing operator after
1777
+ * the gate filename. Swallowing operators:
1778
+ * - `||` / `&&` — the gate's exit is masked by the follow-up command
1779
+ * - `;` — the sequential-command separator; the LAST command's
1780
+ * exit becomes the line's (only problematic for non-exec
1781
+ * invocations — exec REPLACES the shell, so anything
1782
+ * after `exec gate "$@" ;` is dead code and the `;` is
1783
+ * harmless).
1784
+ * - `|` / `|&` — pipeline. Under POSIX `/bin/sh`, a pipeline's exit
1785
+ * status is that of the LAST command in the pipe, so
1786
+ * `gate "$@" | cat` returns `cat`'s status (always 0),
1787
+ * silently dropping gate failures. Bash's
1788
+ * `pipefail` option fixes this, but we cannot assume
1789
+ * `set -o pipefail` is in effect (it is not POSIX and
1790
+ * the shipped gate does not set it for consumers).
1791
+ * Applies to both exec-led and non-exec-led lines —
1792
+ * `exec gate | cat` is still a pipe whose exit comes
1793
+ * from the right-hand side.
1794
+ * - trailing `&` — background job (line exits 0 regardless of gate)
1795
+ *
1796
+ * Not swallowing:
1797
+ * - POSIX fd redirects: `2>&1`, `>&2`, `&>/dev/null` — contain `&` but
1798
+ * do not change exit propagation. Stripped before the check.
1799
+ * - `;` after `exec gate` — exec REPLACES the current shell with the
1800
+ * command, so no code after the exec statement runs. The gate's exit
1801
+ * IS the hook's exit status.
1802
+ *
1803
+ * R10 F2: stripped fd duplications before checking `&`.
1804
+ * R12 F2: treat a trailing `;` as harmless when the line begins with `exec`.
1805
+ * R14 F1: reject single `|` (pipe) — the pipeline's last-command exit is
1806
+ * never the gate's, so the gate's failure is silently dropped.
1807
+ */
1808
+ /**
1809
+ * True when the trimmed `line` is a top-level `exit` or `return` statement
1810
+ * that terminates (or short-circuits) the script. Accepts the forms Codex
1811
+ * R15 F2 called out as gaps in the earlier `^(exit|return)(\s+\d+)?$`
1812
+ * regex, all of which must mark the script as having already exited so
1813
+ * later gate-invocation lines are treated as dead code:
1814
+ *
1815
+ * - `exit` / `return`
1816
+ * - `exit 0` / `return 1`
1817
+ * - `exit 0;` / `return 1;` — trailing semicolon
1818
+ * - `exit 0 # x` / `return 1 # x` — trailing comment
1819
+ * - `exit 0; # x` / `exit 0 ; # x` — semicolon then comment
1820
+ * - `exit 0; foo` — multi-statement; first is exit,
1821
+ * so the shell never reaches `foo`
1822
+ *
1823
+ * We split on the first `;` and test the leading statement. This mirrors
1824
+ * how a POSIX shell executes the line: it evaluates statements left-to-
1825
+ * right, and `exit`/`return` unwinds before any right-hand statements run.
1826
+ *
1827
+ * R15 F2: the earlier anchored regex missed every non-bare form above, so
1828
+ * a spoof like `exit 0;` followed by `exec .claude/hooks/push-review-gate.sh`
1829
+ * was classified as valid delegation — dead code reported as governance.
1830
+ */
1831
+ function isTopLevelExit(line) {
1832
+ const trimmed = stripTrailingComment(line).trimEnd();
1833
+ if (trimmed.length === 0)
1834
+ return false;
1835
+ // R17 F3: split on `;`, `&&`, and `||`. Once `exit`/`return` runs, the
1836
+ // shell unwinds — all three list operators leave their right-hand side
1837
+ // unreachable. The previous `split(';')[0]` missed `exit 0 && cmd` and
1838
+ // `return 1 || :`, allowing a later gate invocation to be classified
1839
+ // reachable when it was actually dead code.
1840
+ const firstStmt = trimmed.split(/;|&&|\|\|/)[0]?.trim() ?? '';
1841
+ return /^(exit|return)(\s+\d+)?$/.test(firstStmt);
1842
+ }
1843
+ function hasContinuationOperator(line) {
1844
+ const gateIdx = line.indexOf(GATE_DELEGATION_TOKEN);
1845
+ if (gateIdx === -1)
1846
+ return false;
1847
+ let tail = line.slice(gateIdx + GATE_DELEGATION_TOKEN.length);
1848
+ tail = tail.replace(/\d*[<>]&\d*-?/g, ' ');
1849
+ tail = tail.replace(/&>>?/g, ' ');
1850
+ // `\|` matches any pipe character, which covers both `||` (logical OR)
1851
+ // and a single `|` (pipeline). Both swallow the gate's exit status:
1852
+ // `||` masks via fallback-chaining, `|` masks via POSIX pipeline
1853
+ // last-command semantics. Listing them together means we never have to
1854
+ // disambiguate `|` from `||` — either is a rejection.
1855
+ const PIPE_OR_LOGICAL_OR = /\|/;
1856
+ if (PIPE_OR_LOGICAL_OR.test(tail))
1857
+ return true;
1858
+ const execLed = /^\s*exec\b/.test(line);
1859
+ if (execLed) {
1860
+ // Under exec, `;` and anything after it is unreachable. `&&` still
1861
+ // applies to exec-failure (command-not-found) and DOES swallow.
1862
+ return /&&/.test(tail) || /&\s*$/.test(tail);
1863
+ }
1864
+ return /&&|;/.test(tail) || /&\s*$/.test(tail);
1865
+ }
1866
+ /**
1867
+ * Positive-match only: does `line` actually invoke the gate?
1868
+ *
1869
+ * Returns `true` ONLY in two forms:
1870
+ * 1. Bare line-start invocation — the gate path (possibly quoted, possibly
1871
+ * with a path prefix) is the first token on the line. Examples:
1872
+ * `.claude/hooks/push-review-gate.sh "$@"`
1873
+ * `"/abs/path/.claude/hooks/push-review-gate.sh"`
1874
+ * 2. Explicit POSIX delegation keyword immediately before the path —
1875
+ * exactly one of `exec`, `.`, `sh`, `bash`, or `zsh` followed only by
1876
+ * whitespace and then the gate path (again, optionally quoted/prefixed).
1877
+ * `source` is NOT accepted: it is bash-only and not in POSIX sh, so
1878
+ * hooks shebanged `#!/bin/sh` would fail silently on dash/busybox.
1879
+ * Use `.` (dot) — the POSIX equivalent — instead.
1880
+ * Examples:
1881
+ * `exec .claude/hooks/push-review-gate.sh "$@"`
1882
+ * `. .claude/hooks/push-review-gate.sh`
1883
+ * `sh .claude/hooks/push-review-gate.sh`
1884
+ *
1885
+ * Everything else returns `false`. Specifically, command words like `test`,
1886
+ * `[`, `[[`, `chmod`, `cp`, `mv`, `cat`, `echo`, `printf`, `if`, `while`,
1887
+ * `#` (comment — already filtered by caller) etc. before the gate path are
1888
+ * NOT invocation forms and return `false`.
1889
+ *
1890
+ * This is a deliberate positive-match (allowlist) approach; a blocklist is
1891
+ * insufficient because any new "mention" form would be a false positive until
1892
+ * explicitly blocked. The allowlist is stable: the set of ways to actually
1893
+ * exec a shell script does not grow.
1894
+ */
1895
+ function looksLikeGateInvocation(line) {
1896
+ // The character class for path prefixes was previously
1897
+ // [A-Za-z0-9_./${}~-]*
1898
+ // which rejected quoted variable expansions in the middle of a path —
1899
+ // the idiomatic defensive form `exec "$REA_ROOT"/.claude/hooks/...`
1900
+ // contains a `"` after `$REA_ROOT` that was not in the class, so the
1901
+ // match stopped before reaching the literal path.
1902
+ //
1903
+ // R12 F2: extend the char class to include `"` and `'` so that quoted
1904
+ // mid-path expansions are consumed as part of the path. This accepts:
1905
+ // - `exec "$REA_ROOT"/.claude/hooks/push-review-gate.sh`
1906
+ // - `exec "$HOME"/project/.claude/hooks/push-review-gate.sh`
1907
+ // - `"$A"'/'.claude/hooks/push-review-gate.sh` (pathological; still works)
1908
+ // The false-positive surface is limited: ANY line that begins with a
1909
+ // quoted string and then contains the literal gate path will be accepted,
1910
+ // but that is exactly the invocation shape we want to recognize — a
1911
+ // line like `echo "$X/.claude/hooks/push-review-gate.sh"` would not
1912
+ // match because it begins with `echo`, not a path/quoted-prefix.
1913
+ const pathChars = '["\'$A-Za-z0-9_./${}~-]';
1914
+ // Form 1: gate path (optionally prefixed by quoted/expanded chars) is the
1915
+ // first thing on the (already-trimmed) line.
1916
+ const bareInvocationRe = new RegExp(`^${pathChars}*\\.claude\\/hooks\\/push-review-gate\\.sh(?=\\s|$|[;|&"'()])`);
1917
+ if (bareInvocationRe.test(line))
1918
+ return true;
1919
+ // Form 2: POSIX delegation keyword + gate path. `source` is bash-only and
1920
+ // excluded; the POSIX equivalent `.` is accepted.
1921
+ const delegationRe = new RegExp(`^(exec|sh|bash|zsh|\\.)\\s+${pathChars}*\\.claude\\/hooks\\/push-review-gate\\.sh(?=\\s|$|[;|&"'()])`);
1922
+ if (delegationRe.test(line))
1923
+ return true;
1924
+ // Form 3 (R22 F3) — absolute interpreter path or `env`-based interpreter.
1925
+ // Real hooks commonly invoke the gate as `/bin/sh gate`, `/usr/bin/env sh
1926
+ // gate`, or `exec /bin/bash gate`. All of these are unconditional and
1927
+ // safe delegations, but forms 1 and 2 rejected them because the
1928
+ // command-head token was a path (`/bin/sh`) instead of a bare word.
1929
+ // `/bin/sh .claude/hooks/push-review-gate.sh`
1930
+ // `/usr/bin/sh .claude/hooks/push-review-gate.sh`
1931
+ // `/usr/bin/env sh .claude/hooks/push-review-gate.sh`
1932
+ // `exec /bin/bash .claude/hooks/push-review-gate.sh`
1933
+ //
1934
+ // The `\/[^\s;|&()]+\/` prefix requires an absolute path with at least one
1935
+ // path component, which rejects `/sh` (unlikely on any real system but
1936
+ // not a valid interpreter invocation shape). The `env` form accepts either
1937
+ // bare `env` (relies on PATH) or an absolute `/usr/bin/env` path.
1938
+ const interpreterRe = new RegExp(`^(?:exec\\s+)?` +
1939
+ `(?:` +
1940
+ `(?:\\/[^\\s;|&()]+\\/)?env\\s+(?:sh|bash|zsh)` +
1941
+ `|` +
1942
+ `\\/[^\\s;|&()]+\\/(?:sh|bash|zsh)` +
1943
+ `)` +
1944
+ `\\s+${pathChars}*\\.claude\\/hooks\\/push-review-gate\\.sh(?=\\s|$|[;|&"'()])`);
1945
+ if (interpreterRe.test(line))
1946
+ return true;
1947
+ return false;
1948
+ }
1949
+ /**
1950
+ * Read `hookPath` and classify. Does not consult file mode — callers are
1951
+ * expected to combine this with an executable-bit check where relevant.
1952
+ *
1953
+ * A directory, symlink (regardless of target type), or unreadable file is
1954
+ * "foreign" so that we never silently clobber anything we cannot inspect
1955
+ * or own. A missing path returns the distinct `absent` kind so the install
1956
+ * re-check can distinguish "safe to write here" from "something non-file
1957
+ * raced into place".
1958
+ *
1959
+ * R25 F1 — symlink handling.
1960
+ *
1961
+ * The previous implementation used `stat()`, which silently follows
1962
+ * symlinks. If a repository intentionally pointed `.git/hooks/pre-push` at
1963
+ * a centrally-managed hook (a common shared-infra pattern, or the Husky
1964
+ * `core.hooksPath` setup where a symlink proxies to a hook under `.husky/`),
1965
+ * `stat` would report the target file and classify based on its body. The
1966
+ * refresh path then writes via `rename(tmp, dst)` — which replaces the
1967
+ * *symlink itself* with a regular file, breaking the central-managed link
1968
+ * forever with no operator warning.
1969
+ *
1970
+ * We now `lstat()` first and treat any symlink as `foreign/symlink`. The
1971
+ * caller (`classifyPrePushInstall`) maps `foreign` to `skip` on the install
1972
+ * path, so a consumer with a central-hook symlink keeps their setup
1973
+ * untouched. Operators who want rea to manage the hook must remove the
1974
+ * symlink first — an explicit opt-in that preserves central infra intent.
1975
+ */
1976
+ async function classifyExistingHook(hookPath) {
1977
+ let stat;
1978
+ try {
1979
+ stat = await fsPromises.lstat(hookPath);
1980
+ }
1981
+ catch (err) {
1982
+ // ENOENT is the expected state during an `install` flow. Any other
1983
+ // lstat error (permission denied, I/O) is treated as a "not-a-file"
1984
+ // foreign signal so we never proceed with a write.
1985
+ const code = err.code;
1986
+ if (code === 'ENOENT')
1987
+ return { kind: 'absent' };
1988
+ return { kind: 'foreign', reason: 'not-a-file' };
1989
+ }
1990
+ if (stat.isSymbolicLink()) {
1991
+ // R25 F1 — symlinks are intentionally never refreshed. Treat as
1992
+ // foreign so the install path skips and the consumer decides whether
1993
+ // to unlink manually and re-run `rea init`.
1994
+ return { kind: 'foreign', reason: 'symlink' };
1995
+ }
1996
+ if (!stat.isFile()) {
1997
+ return { kind: 'foreign', reason: 'not-a-file' };
1998
+ }
1999
+ let content;
2000
+ try {
2001
+ content = await fsPromises.readFile(hookPath, 'utf8');
2002
+ }
2003
+ catch {
2004
+ return { kind: 'foreign', reason: 'unreadable' };
2005
+ }
2006
+ if (isReaManagedHuskyGate(content))
2007
+ return { kind: 'rea-managed-husky' };
2008
+ // R21 F1: pre-0.4 rea `.husky/pre-push` shape. Same governance, no
2009
+ // line-2 marker. Fold into `rea-managed-husky` so upgrade paths treat
2010
+ // the legacy hook as a known rea artifact (skip/active rather than
2011
+ // foreign) and the canonical manifest reconciler handles the refresh.
2012
+ if (isLegacyReaManagedHuskyGate(content))
2013
+ return { kind: 'rea-managed-husky' };
2014
+ if (isReaManagedFallback(content))
2015
+ return { kind: 'rea-managed' };
2016
+ if (referencesReviewGate(content))
2017
+ return { kind: 'gate-delegating' };
2018
+ return { kind: 'foreign', reason: 'no-marker' };
2019
+ }
2020
+ /**
2021
+ * Content of the fallback hook. Intentionally minimal: delegates all real
2022
+ * work to `.claude/hooks/push-review-gate.sh`, which is the shared gate
2023
+ * already covered by tests. The only logic here is the "which gate to
2024
+ * call" resolution.
2025
+ *
2026
+ * The stdin contract of git's native pre-push (one line per refspec) is
2027
+ * passed through to the gate verbatim. The gate already knows how to parse
2028
+ * that shape — see `parse_prepush_stdin` in `push-review-gate.sh`.
2029
+ *
2030
+ * IMPORTANT: The first two lines are fixed and must remain byte-identical
2031
+ * to `FALLBACK_PRELUDE`. `isReaManagedFallback` anchors on them.
2032
+ */
2033
+ function fallbackHookContent() {
2034
+ return `#!/bin/sh
2035
+ ${FALLBACK_MARKER}
2036
+ #
2037
+ # Fallback pre-push hook installed by \`rea init\` when Husky is not the
2038
+ # active git hook path. Do NOT edit by hand: re-run \`rea init\` to refresh.
2039
+ #
2040
+ # This file delegates to .claude/hooks/push-review-gate.sh so there is
2041
+ # exactly one implementation of the push-review logic across rea, Husky,
2042
+ # and vanilla git installs. Removing this file disables the protected-path
2043
+ # Codex gate for terminal-initiated pushes; prefer switching to Husky
2044
+ # instead.
2045
+
2046
+ set -eu
2047
+
2048
+ REA_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
2049
+ GATE="\${REA_ROOT}/${GATE_DELEGATION_TOKEN}"
2050
+
2051
+ if [ ! -x "\$GATE" ]; then
2052
+ printf 'rea: push-review-gate missing or not executable at %s\\n' "\$GATE" >&2
2053
+ printf ' Run \`rea init\` to reinstall, or \`pnpm build\` if rea was built from source.\\n' >&2
2054
+ exit 1
2055
+ fi
2056
+
2057
+ exec "\$GATE" "\$@"
2058
+ `;
2059
+ }
2060
+ /**
2061
+ * Read `core.hooksPath` via `git config --get`. Mirrors the helper in
2062
+ * `commit-msg.ts` — we consult git, never regex-match `.git/config` —
2063
+ * so section-scoped keys (`[worktree]`, `[alias]`, includes) resolve the
2064
+ * same way git itself resolves them.
2065
+ */
2066
+ async function readHooksPathFromGit(targetDir) {
2067
+ try {
2068
+ const { stdout } = await execFileAsync('git', ['-C', targetDir, 'config', '--get', 'core.hooksPath'], { encoding: 'utf8' });
2069
+ const trimmed = stdout.trim();
2070
+ return trimmed.length > 0 ? trimmed : null;
2071
+ }
2072
+ catch {
2073
+ return null;
2074
+ }
2075
+ }
2076
+ /**
2077
+ * Resolve a configured `core.hooksPath` (possibly relative) to an absolute
2078
+ * path relative to `targetDir`, or `null` if the key is unset.
2079
+ */
2080
+ export async function resolveHooksDir(targetDir) {
2081
+ const configured = await readHooksPathFromGit(targetDir);
2082
+ if (configured === null) {
2083
+ return { dir: null, configured: false };
2084
+ }
2085
+ const absolute = path.isAbsolute(configured)
2086
+ ? configured
2087
+ : path.join(targetDir, configured);
2088
+ return { dir: absolute, configured: true };
2089
+ }
2090
+ /**
2091
+ * Resolve the git-managed hook path for a named hook (e.g. `pre-push`) via
2092
+ * `git rev-parse --git-path hooks/<name>`. Returns the absolute path git
2093
+ * itself would look at when `core.hooksPath` is unset.
2094
+ *
2095
+ * This is the correct way to locate `.git/hooks/<name>` in ALL repo shapes:
2096
+ * - Vanilla repo: `<repo>/.git/hooks/<name>`
2097
+ * - Linked worktree: `.git` is a FILE pointing at the worktree's gitdir,
2098
+ * and `git rev-parse --git-path hooks/<name>` returns the per-worktree
2099
+ * hooks directory (shared across worktrees in modern git — see
2100
+ * `extensions.worktreeConfig`). Hard-coding `<repo>/.git/hooks/<name>`
2101
+ * in that shape points at a path that does not exist.
2102
+ * - Submodule: `.git` is a file pointing into the superproject's modules/
2103
+ * dir. `git rev-parse --git-path` resolves to the correct location
2104
+ * inside that gitdir.
2105
+ *
2106
+ * Returns `null` when `targetDir` is not a git repo or the git binary is
2107
+ * unreachable; the caller already short-circuits the non-repo case via
2108
+ * `fs.existsSync(.git)`, but we fall back defensively so this seam never
2109
+ * throws.
2110
+ */
2111
+ async function resolveGitHookPath(targetDir, hookName) {
2112
+ try {
2113
+ const { stdout } = await execFileAsync('git', ['-C', targetDir, 'rev-parse', '--git-path', `hooks/${hookName}`], { encoding: 'utf8' });
2114
+ const trimmed = stdout.trim();
2115
+ if (trimmed.length === 0)
2116
+ return null;
2117
+ return path.isAbsolute(trimmed) ? trimmed : path.join(targetDir, trimmed);
2118
+ }
2119
+ catch {
2120
+ return null;
2121
+ }
2122
+ }
2123
+ /**
2124
+ * Resolve the hook path we would target given the current git config and
2125
+ * on-disk state. Split out so `installPrePushFallback` can re-resolve it
2126
+ * immediately before the write to close the classify → write TOCTOU window.
2127
+ *
2128
+ * When `core.hooksPath` is unset we ask git for the real hook path rather
2129
+ * than hard-coding `<targetDir>/.git/hooks/pre-push`. In a linked worktree
2130
+ * `<targetDir>/.git` is a FILE (pointing at the worktree's gitdir), so the
2131
+ * hard-coded form resolves to a non-existent path and every classify call
2132
+ * would report `absent` against the wrong location — silently installing
2133
+ * (or refusing to install) in the wrong place.
2134
+ */
2135
+ async function resolveTargetHookPath(targetDir) {
2136
+ const hooksInfo = await resolveHooksDir(targetDir);
2137
+ if (hooksInfo.configured && hooksInfo.dir !== null) {
2138
+ return {
2139
+ hookPath: path.join(hooksInfo.dir, 'pre-push'),
2140
+ hooksPathConfigured: true,
2141
+ };
2142
+ }
2143
+ const gitHookPath = await resolveGitHookPath(targetDir, 'pre-push');
2144
+ if (gitHookPath !== null) {
2145
+ return { hookPath: gitHookPath, hooksPathConfigured: false };
2146
+ }
2147
+ // Last-resort fallback for non-git-repo edge cases (caller's existsSync
2148
+ // already guards production paths, but keep behavior stable if git is
2149
+ // unreachable).
2150
+ return {
2151
+ hookPath: path.join(targetDir, '.git', 'hooks', 'pre-push'),
2152
+ hooksPathConfigured: false,
2153
+ };
2154
+ }
2155
+ /**
2156
+ * Classify what we should do at `targetDir` based on current state. Pure —
2157
+ * reads the filesystem and git config but performs no writes. Split out so
2158
+ * tests can drive every branch without going through the write path.
2159
+ *
2160
+ * NOTE: The result is a snapshot. `installPrePushFallback` re-resolves and
2161
+ * re-classifies immediately before writing to defend against a husky
2162
+ * install or concurrent `rea init` running between classify and write.
2163
+ */
2164
+ export async function classifyPrePushInstall(targetDir) {
2165
+ const { hookPath } = await resolveTargetHookPath(targetDir);
2166
+ // A file exists at the target. Classify before deciding. We skip the
2167
+ // older `fs.existsSync` fast path because `classifyExistingHook` already
2168
+ // distinguishes `absent` from foreign-non-file — collapsing both checks
2169
+ // into one also removes a tiny TOCTOU window where a file could be
2170
+ // unlinked between existsSync and stat.
2171
+ const classification = await classifyExistingHook(hookPath);
2172
+ if (classification.kind === 'absent') {
2173
+ return { action: 'install', hookPath };
2174
+ }
2175
+ if (classification.kind === 'rea-managed') {
2176
+ return { action: 'refresh', hookPath };
2177
+ }
2178
+ if (classification.kind === 'rea-managed-husky') {
2179
+ // The canonical `.husky/pre-push` governance gate. It is the authoritative
2180
+ // rea-authored hook for Husky installs and must NEVER be overwritten by the
2181
+ // fallback installer. Treat it as governance-carrying (like gate-delegating)
2182
+ // so doctor reports `ok` — but map to skip/active-pre-push-present so
2183
+ // `installPrePushFallback` never touches it.
2184
+ return { action: 'skip', reason: 'active-pre-push-present', hookPath };
2185
+ }
2186
+ // Non-rea file present. Whether we call it "active governance hook" or
2187
+ // "foreign" depends on whether it (a) looks like a real executable git
2188
+ // hook AND (b) actually invokes the shared review gate. Mere existence
2189
+ // of SOMETHING at the path does NOT satisfy governance — that was the
2190
+ // 0.2.x hole where a lint-only husky hook silently bypassed the Codex
2191
+ // audit gate.
2192
+ let executable = false;
2193
+ try {
2194
+ const stat = await fsPromises.stat(hookPath);
2195
+ executable = stat.isFile() && (stat.mode & 0o111) !== 0;
2196
+ }
2197
+ catch {
2198
+ executable = false;
2199
+ }
2200
+ if (executable && classification.kind === 'gate-delegating') {
2201
+ // Consumer-owned executable hook that wires the gate. Applies equally
2202
+ // to a `.husky/pre-push` under a hooksPath-configured repo AND a
2203
+ // user-authored `.git/hooks/pre-push` in a vanilla repo — git will
2204
+ // fire whichever one is active, and either one is allowed to own the
2205
+ // governance contract as long as it actually execs the gate. We
2206
+ // intentionally do NOT gate this on `hooksPathConfigured`: doing so
2207
+ // regressed the vanilla-repo case where a user already wired the
2208
+ // gate into `.git/hooks/pre-push` themselves (and `rea doctor`
2209
+ // correctly reports `ok` on that shape — the two must agree).
2210
+ return {
2211
+ action: 'skip',
2212
+ reason: 'active-pre-push-present',
2213
+ hookPath,
2214
+ };
2215
+ }
2216
+ // Everything else is foreign — warn, leave alone, let doctor surface it.
2217
+ return {
2218
+ action: 'skip',
2219
+ reason: 'foreign-pre-push',
2220
+ hookPath,
2221
+ };
2222
+ }
2223
+ /**
2224
+ * Remove any stale `.rea-tmp-*` siblings left over from a crashed previous
2225
+ * install. Best-effort; non-fatal if scanning fails. Siblings are scoped to
2226
+ * the same directory as `dst` so we only touch files we would have created.
2227
+ *
2228
+ * A previous implementation used a deterministic PID-based suffix which
2229
+ * could (a) collide across concurrent installs in the same process, and
2230
+ * (b) leave a predictable 0o755 sibling on crash. Random suffixes plus
2231
+ * proactive cleanup closes both windows.
2232
+ */
2233
+ async function cleanupStaleTempFiles(dst) {
2234
+ const dir = path.dirname(dst);
2235
+ const prefix = `${path.basename(dst)}.rea-tmp-`;
2236
+ let entries;
2237
+ try {
2238
+ entries = await fsPromises.readdir(dir);
2239
+ }
2240
+ catch {
2241
+ return; // dir doesn't exist yet — nothing to clean.
2242
+ }
2243
+ // R24 F1 — verify provenance BEFORE unlinking. The naming convention
2244
+ // alone is not enough: a concurrent tool or an adversarial user could
2245
+ // drop `pre-push.rea-tmp-XXX` files that we would otherwise delete.
2246
+ //
2247
+ // Ownership proof is three-fold:
2248
+ // 1. `lstat` rejects anything that is not a regular file (symlink,
2249
+ // directory, fifo, socket). We never created a non-file tmp, so
2250
+ // anything else is not ours.
2251
+ // 2. The file must be readable and begin with `#!/bin/sh` — our
2252
+ // `writeExecutable` always writes this header as the first line.
2253
+ // 3. The body must contain one of our canonical markers
2254
+ // (`HUSKY_GATE_MARKER` or `FALLBACK_MARKER`). Any temp file we
2255
+ // created while crashing mid-write contains exactly these bytes.
2256
+ //
2257
+ // A 0-byte or partial-write tmp that predates either marker is left
2258
+ // alone — if the installer crashes before writing any content, the
2259
+ // caller's next run re-opens a fresh random UUID-suffixed file so the
2260
+ // leftover is a harmless orphan. We would rather leak an orphan than
2261
+ // delete someone else's file.
2262
+ const candidates = entries.filter((e) => e.startsWith(prefix));
2263
+ await Promise.all(candidates.map(async (name) => {
2264
+ const abs = path.join(dir, name);
2265
+ try {
2266
+ const st = await fsPromises.lstat(abs);
2267
+ if (!st.isFile())
2268
+ return;
2269
+ }
2270
+ catch {
2271
+ return;
2272
+ }
2273
+ let body;
2274
+ try {
2275
+ body = await fsPromises.readFile(abs, 'utf8');
2276
+ }
2277
+ catch {
2278
+ return;
2279
+ }
2280
+ if (!body.startsWith('#!/bin/sh'))
2281
+ return;
2282
+ if (!body.includes(HUSKY_GATE_MARKER) &&
2283
+ !body.includes(FALLBACK_MARKER)) {
2284
+ return;
2285
+ }
2286
+ await fsPromises.unlink(abs).catch(() => undefined);
2287
+ }));
2288
+ }
2289
+ /**
2290
+ * Atomically write `content` to `dst` with executable bits set.
2291
+ *
2292
+ * When `exclusive` is true (new installs): primary path is `link(tmp, dst)`
2293
+ * — POSIX guarantees the hardlink creation is atomic: `dst` does not exist
2294
+ * until the operation succeeds, and then it points to the FULLY-WRITTEN
2295
+ * temp file. A concurrent reader (e.g. git firing the hook) either sees
2296
+ * the file absent or the complete content, never a partial write. Fails
2297
+ * with EEXIST if a file appeared at `dst` after the caller's re-check.
2298
+ *
2299
+ * Codex R21 F2: the previous implementation used
2300
+ * `copyFile(tmp, dst, COPYFILE_EXCL)`, which opens dst with
2301
+ * `O_CREAT|O_EXCL|O_WRONLY` and then writes content through it. The
2302
+ * create IS atomic but the subsequent write is not — `dst` is observable
2303
+ * empty/partial by concurrent readers during the copy, and a crash mid-
2304
+ * copy leaves a broken live hook. For a governance primitive that's a
2305
+ * real bypass window. `link()` closes it.
2306
+ *
2307
+ * On `EXDEV`, `EPERM`, or `ENOSYS` (cross-device mounts, some network
2308
+ * filesystems, sandboxes that disable `link(2)`), fall back to
2309
+ * `copyFile(COPYFILE_EXCL)`. The fallback is strictly worse but
2310
+ * unavoidable when the kernel refuses the primary path; the warning
2311
+ * accompanying this code is documentation, not configuration.
2312
+ *
2313
+ * When `exclusive` is false (refreshes): uses `rename()` — the destination
2314
+ * is expected to exist and be rea-managed. Immediately before the rename,
2315
+ * re-stats `dst` and verifies (dev, ino, mtimeMs, size) match `guard`.
2316
+ * Mismatch throws an error with `code = 'REA_REFRESH_RACE'` so the caller
2317
+ * can downgrade to a foreign-pre-push skip instead of stomping an
2318
+ * unexpected replacement. Falls back to `copyFile()` on EXDEV with the
2319
+ * same guard applied against a stat taken just before the copy.
2320
+ *
2321
+ * R14 F2: the previous implementation used `rename(tmp, dst)` without any
2322
+ * final destination check. Even with the lock-scoped re-check upstream, a
2323
+ * concurrent writer outside the lock (Husky, a user editor, another tool)
2324
+ * could rewrite `dst` between the re-check and the rename; the blind
2325
+ * rename would silently replace their hook with ours. The guard closes
2326
+ * every detectable race window short of the kernel-level atomic swap
2327
+ * (renameat2 RENAME_EXCHANGE) which Node does not expose.
2328
+ */
2329
+ class RefreshRaceError extends Error {
2330
+ code = 'REA_REFRESH_RACE';
2331
+ constructor(dst) {
2332
+ super(`refresh aborted: ${dst} was modified by another writer between ` +
2333
+ `the safety re-check and the rename. Re-run \`rea init\` to re-evaluate.`);
2334
+ this.name = 'RefreshRaceError';
2335
+ }
2336
+ }
2337
+ async function verifyRefreshGuard(dst, guard) {
2338
+ if (guard.kind === 'absent')
2339
+ return;
2340
+ let current;
2341
+ try {
2342
+ current = await fsPromises.stat(dst);
2343
+ }
2344
+ catch {
2345
+ // File vanished — a consumer removed it. Aborting the refresh is
2346
+ // safer than re-creating a file where one no longer exists; the user
2347
+ // can re-run `rea init` to land a fresh install.
2348
+ throw new RefreshRaceError(dst);
2349
+ }
2350
+ if (current.dev !== guard.dev ||
2351
+ current.ino !== guard.ino ||
2352
+ current.mtimeMs !== guard.mtimeMs ||
2353
+ current.size !== guard.size) {
2354
+ throw new RefreshRaceError(dst);
2355
+ }
2356
+ }
2357
+ async function writeExecutable(dst, content, exclusive, guard = { kind: 'absent' }) {
2358
+ await fsPromises.mkdir(path.dirname(dst), { recursive: true });
2359
+ // Random suffix + `wx` open flag: PID was observed to collide during
2360
+ // concurrent installs in the same process (e.g. two worktrees running
2361
+ // `rea init` back-to-back, or a test harness driving the function in
2362
+ // parallel). UUIDs are collision-free for our purposes and `wx` fails
2363
+ // loudly if anything we didn't expect is in the way.
2364
+ const tmp = `${dst}.rea-tmp-${crypto.randomUUID()}`;
2365
+ // `open` with `'wx'` == O_WRONLY|O_CREAT|O_EXCL. Mode bits on the open
2366
+ // call itself so the file is executable the moment it appears on disk.
2367
+ const handle = await fsPromises.open(tmp, 'wx', 0o755);
2368
+ try {
2369
+ await handle.writeFile(content, 'utf8');
2370
+ // Some platforms ignore the open-time mode argument; force the bits
2371
+ // again before finalize for belt-and-suspenders.
2372
+ await handle.chmod(0o755);
2373
+ }
2374
+ finally {
2375
+ await handle.close().catch(() => undefined);
2376
+ }
2377
+ try {
2378
+ if (exclusive) {
2379
+ // R21 F2: link-first for ATOMIC publication. `link(2)` atomically
2380
+ // creates `dst` pointing at the fully-written `tmp`; a concurrent
2381
+ // reader either sees the file absent or the complete content, never
2382
+ // a partial write. EEXIST still propagates if dst appeared after
2383
+ // our safety re-check (exclusive semantics preserved).
2384
+ try {
2385
+ await fsPromises.link(tmp, dst);
2386
+ await fsPromises.unlink(tmp).catch(() => undefined);
2387
+ return { degradedFromAtomic: false };
2388
+ }
2389
+ catch (linkErr) {
2390
+ const e = linkErr;
2391
+ // EEXIST is the one exclusive-violation case we MUST propagate —
2392
+ // another writer won the race, we must abort the install.
2393
+ if (e.code === 'EEXIST')
2394
+ throw linkErr;
2395
+ // EXDEV: cross-device mount (link doesn't span filesystems).
2396
+ // EPERM / ENOSYS: some network filesystems and sandboxes refuse
2397
+ // or don't implement link(2).
2398
+ if (e.code !== 'EXDEV' && e.code !== 'EPERM' && e.code !== 'ENOSYS') {
2399
+ throw linkErr;
2400
+ }
2401
+ // R25 F2 — non-atomic fallback. `copyFile(..., COPYFILE_EXCL)`
2402
+ // still refuses to clobber an existing `dst`, so the
2403
+ // exclusive-semantic half of the atomic contract is preserved,
2404
+ // but the copy itself is not instantaneous: a crash or concurrent
2405
+ // reader CAN observe a partially-written live hook. We return
2406
+ // `degradedFromAtomic: true` so the caller can surface a warning
2407
+ // to the operator. This is the narrow, explicitly-signaled
2408
+ // escape hatch for filesystems where link(2) is unavailable;
2409
+ // we deliberately do not fail closed (which would make rea
2410
+ // unusable on e.g. cross-mount `.git` dirs) but we also refuse
2411
+ // to silently lose the atomicity guarantee.
2412
+ await fsPromises.copyFile(tmp, dst, fs.constants.COPYFILE_EXCL);
2413
+ await fsPromises.unlink(tmp).catch(() => undefined);
2414
+ return { degradedFromAtomic: true };
2415
+ }
2416
+ }
2417
+ // Refresh: verify dst still matches the identity captured at the
2418
+ // re-check before the atomic replace. Rename is atomic on the same
2419
+ // filesystem.
2420
+ await verifyRefreshGuard(dst, guard);
2421
+ try {
2422
+ await fsPromises.rename(tmp, dst);
2423
+ return { degradedFromAtomic: false };
2424
+ }
2425
+ catch (renameErr) {
2426
+ const e = renameErr;
2427
+ if (e.code !== 'EXDEV')
2428
+ throw renameErr;
2429
+ // Cross-device mount: verify again, then fall back to copy+unlink.
2430
+ // The extra verify is cheap and narrows the window further.
2431
+ // Non-atomic — same R25 F2 rationale applies on refresh.
2432
+ await verifyRefreshGuard(dst, guard);
2433
+ await fsPromises.copyFile(tmp, dst);
2434
+ await fsPromises.unlink(tmp).catch(() => undefined);
2435
+ return { degradedFromAtomic: true };
2436
+ }
2437
+ }
2438
+ catch (err) {
2439
+ await fsPromises.unlink(tmp).catch(() => undefined);
2440
+ throw err;
2441
+ }
2442
+ }
2443
+ /**
2444
+ * Resolve the actual git common directory for `targetDir`. In a linked
2445
+ * worktree or submodule, `<targetDir>/.git` is a FILE that points at the
2446
+ * real git dir (`gitdir: /path/to/..../git/worktrees/<name>`), and the
2447
+ * "common" directory — where locks and shared state belong — lives one
2448
+ * level up via `git rev-parse --git-common-dir`. In a vanilla repo the
2449
+ * common dir is just `<targetDir>/.git`.
2450
+ *
2451
+ * Returns `null` if `targetDir` is not a git repo (the caller already
2452
+ * short-circuits this via `fs.existsSync(.git)`, but we handle the
2453
+ * fallback anyway so we never throw from the lock seam).
2454
+ */
2455
+ async function resolveGitCommonDir(targetDir) {
2456
+ try {
2457
+ const { stdout } = await execFileAsync('git', ['-C', targetDir, 'rev-parse', '--git-common-dir'], { encoding: 'utf8' });
2458
+ const trimmed = stdout.trim();
2459
+ if (trimmed.length === 0)
2460
+ return null;
2461
+ return path.isAbsolute(trimmed) ? trimmed : path.join(targetDir, trimmed);
2462
+ }
2463
+ catch {
2464
+ return null;
2465
+ }
2466
+ }
2467
+ /**
2468
+ * Acquire a short-lived advisory lock under the git common directory so
2469
+ * two concurrent `rea init` runs do not race on the same pre-push hook.
2470
+ *
2471
+ * The lock lives under `<git-common-dir>/rea-pre-push-install.lock`. In
2472
+ * a vanilla repo that's `<repo>/.git/rea-pre-push-install.lock`; in a
2473
+ * linked worktree `.git` is a FILE, not a directory, so we resolve via
2474
+ * `git rev-parse --git-common-dir` to find the real writable location.
2475
+ * Writing inside the .git FILE would throw ENOTDIR and regress the very
2476
+ * worktree/submodule cases husky is most common in.
2477
+ *
2478
+ * proper-lockfile is already a runtime dep (audit chain uses it). Stale
2479
+ * timeout is deliberately short — install is a few hundred milliseconds
2480
+ * in the worst case, and a crashed run should not block the next one for
2481
+ * long.
2482
+ *
2483
+ * If the git common dir cannot be resolved (unreachable git binary, not a
2484
+ * repo, etc.), run without a lock rather than throwing. The outer caller
2485
+ * already verified `.git` exists, so this is a belt-and-suspenders path.
2486
+ */
2487
+ async function withInstallLock(targetDir, fn) {
2488
+ const commonDir = await resolveGitCommonDir(targetDir);
2489
+ if (commonDir === null) {
2490
+ // No lock available, but the work is still safe — we still do the
2491
+ // TOCTOU re-check plus `wx` open. Concurrency hardening degrades to
2492
+ // best-effort in this edge case.
2493
+ return fn();
2494
+ }
2495
+ // Ensure the common dir exists before proper-lockfile tries to create
2496
+ // a lockfile inside it. `git rev-parse --git-common-dir` returning a
2497
+ // path is not a promise the path currently exists (submodules mid-init,
2498
+ // etc.), so mkdir defensively.
2499
+ await fsPromises.mkdir(commonDir, { recursive: true });
2500
+ const release = await properLockfile.lock(commonDir, {
2501
+ stale: 10_000,
2502
+ retries: {
2503
+ retries: 20,
2504
+ factor: 1.3,
2505
+ minTimeout: 15,
2506
+ maxTimeout: 200,
2507
+ randomize: true,
2508
+ },
2509
+ realpath: false,
2510
+ lockfilePath: path.join(commonDir, 'rea-pre-push-install.lock'),
2511
+ });
2512
+ try {
2513
+ return await fn();
2514
+ }
2515
+ finally {
2516
+ try {
2517
+ await release();
2518
+ }
2519
+ catch {
2520
+ // Release can legitimately fail if stale-detection already freed
2521
+ // the lockfile. Work completed; swallow.
2522
+ }
2523
+ }
2524
+ }
2525
+ /**
2526
+ * Install (or refresh, or skip) the fallback pre-push hook at `targetDir`.
2527
+ * Idempotent: safe to call on every `rea init`, including re-runs over an
2528
+ * existing install. Never overwrites a foreign hook.
2529
+ *
2530
+ * Requires `targetDir/.git` to exist. Non-git directories are skipped with
2531
+ * a warning — same shape as `installCommitMsgHook`.
2532
+ */
2533
+ export async function installPrePushFallback(targetDir, options = {}) {
2534
+ const result = {
2535
+ decision: { action: 'install', hookPath: '' },
2536
+ warnings: [],
2537
+ };
2538
+ const gitDir = path.join(targetDir, '.git');
2539
+ if (!fs.existsSync(gitDir)) {
2540
+ result.warnings.push('.git/ not found — skipping pre-push fallback (not a git repo?)');
2541
+ // Return a synthetic skip decision so callers can log uniformly.
2542
+ result.decision = {
2543
+ action: 'skip',
2544
+ reason: 'foreign-pre-push',
2545
+ hookPath: path.join(targetDir, '.git', 'hooks', 'pre-push'),
2546
+ };
2547
+ return result;
2548
+ }
2549
+ const useLock = options.useLock !== false;
2550
+ const run = async () => {
2551
+ // Classify once for the caller-visible decision. We re-resolve
2552
+ // immediately before the write to close the TOCTOU window.
2553
+ const decision = await classifyPrePushInstall(targetDir);
2554
+ result.decision = decision;
2555
+ switch (decision.action) {
2556
+ case 'install':
2557
+ case 'refresh': {
2558
+ // R24 F1 — stale-temp cleanup runs ONLY on write paths. Running
2559
+ // it unconditionally (pre-R24 behavior) meant a `skip/foreign` or
2560
+ // `skip/active-pre-push-present` decision still scanned and
2561
+ // unlinked any sibling matching `pre-push.rea-tmp-*` — which
2562
+ // under an adversarial or concurrent-tool scenario could delete
2563
+ // unrelated files. The cleanup is only a hygiene step for
2564
+ // recovery from a crashed write, so scope it to the branches
2565
+ // that actually write.
2566
+ await cleanupStaleTempFiles(decision.hookPath);
2567
+ if (options.onBeforeReresolve !== undefined) {
2568
+ await options.onBeforeReresolve(decision.hookPath);
2569
+ }
2570
+ // Re-resolve hooksPath and re-inspect the destination just before
2571
+ // the rename. Between the classification above and this point,
2572
+ // `husky install` could have flipped `core.hooksPath`, or a second
2573
+ // `rea init` could have landed a file. Bail out safely if anything
2574
+ // unexpected appeared; the user can re-run to converge.
2575
+ const resolved = await resolveTargetHookPath(targetDir);
2576
+ if (resolved.hookPath !== decision.hookPath) {
2577
+ result.warnings.push(`core.hooksPath changed during install — expected ${decision.hookPath}, ` +
2578
+ `now ${resolved.hookPath}. Re-run \`rea init\` to install into the current location.`);
2579
+ result.decision = {
2580
+ action: 'skip',
2581
+ reason: 'foreign-pre-push',
2582
+ hookPath: resolved.hookPath,
2583
+ };
2584
+ return result;
2585
+ }
2586
+ // Re-classify the destination. For `install` the fast rule is
2587
+ // "must still be absent" — a regular file, directory, symlink, or
2588
+ // unreadable entry that raced into place is all equally unsafe to
2589
+ // stomp, and refusing early avoids a rename() that would either
2590
+ // succeed silently against a foreign file or fail noisily against
2591
+ // a non-file. For `refresh` the rule is "still rea-managed"; a
2592
+ // file that got replaced by a consumer between classify and write
2593
+ // must not be clobbered.
2594
+ const reCheck = await classifyExistingHook(decision.hookPath);
2595
+ if (decision.action === 'install') {
2596
+ if (reCheck.kind !== 'absent') {
2597
+ // Something appeared at the path (regular file, directory,
2598
+ // symlink, or unreadable entry). Do NOT stomp.
2599
+ result.warnings.push(`pre-push hook at ${decision.hookPath} appeared during install — ` +
2600
+ `leaving it untouched. Re-run \`rea init\` to re-evaluate.`);
2601
+ result.decision = {
2602
+ action: 'skip',
2603
+ reason: 'foreign-pre-push',
2604
+ hookPath: decision.hookPath,
2605
+ };
2606
+ return result;
2607
+ }
2608
+ }
2609
+ else {
2610
+ // refresh
2611
+ if (reCheck.kind === 'rea-managed-husky') {
2612
+ // R11 F2: a canonical Husky gate replaced the fallback between
2613
+ // classify and write. Do NOT proceed to writeExecutable — the
2614
+ // Husky gate is the authoritative rea-authored hook and must
2615
+ // never be clobbered by the fallback. Terminal skip.
2616
+ result.decision = {
2617
+ action: 'skip',
2618
+ reason: 'active-pre-push-present',
2619
+ hookPath: decision.hookPath,
2620
+ };
2621
+ return result;
2622
+ }
2623
+ if (reCheck.kind !== 'rea-managed') {
2624
+ result.warnings.push(`pre-push hook at ${decision.hookPath} is no longer rea-managed — ` +
2625
+ `leaving it untouched.`);
2626
+ result.decision = {
2627
+ action: 'skip',
2628
+ reason: 'foreign-pre-push',
2629
+ hookPath: decision.hookPath,
2630
+ };
2631
+ return result;
2632
+ }
2633
+ }
2634
+ // R14 F2: capture the identity of the destination as it stood at
2635
+ // re-check time. `writeExecutable` will re-verify right before the
2636
+ // rename so a replacement landed by a concurrent writer (outside
2637
+ // our install lock) cannot be silently stomped. Only refreshes
2638
+ // need a guard; installs use `COPYFILE_EXCL` which is inherently
2639
+ // race-safe against new files appearing at `dst`.
2640
+ let refreshGuard = { kind: 'absent' };
2641
+ if (decision.action === 'refresh') {
2642
+ try {
2643
+ const s = await fsPromises.stat(decision.hookPath);
2644
+ refreshGuard = {
2645
+ kind: 'present',
2646
+ dev: s.dev,
2647
+ ino: s.ino,
2648
+ mtimeMs: s.mtimeMs,
2649
+ size: s.size,
2650
+ };
2651
+ }
2652
+ catch {
2653
+ // The file vanished between reCheck and this stat. Abort the
2654
+ // refresh — a missing file at refresh time means something
2655
+ // outside our control removed it; re-running `rea init` will
2656
+ // re-install fresh.
2657
+ result.warnings.push(`pre-push hook at ${decision.hookPath} disappeared before refresh — ` +
2658
+ `re-run \`rea init\` to re-install.`);
2659
+ result.decision = {
2660
+ action: 'skip',
2661
+ reason: 'foreign-pre-push',
2662
+ hookPath: decision.hookPath,
2663
+ };
2664
+ return result;
2665
+ }
2666
+ }
2667
+ if (options.onBeforeWrite !== undefined) {
2668
+ await options.onBeforeWrite(decision.hookPath);
2669
+ }
2670
+ try {
2671
+ const writeResult = await writeExecutable(decision.hookPath, fallbackHookContent(), decision.action === 'install', refreshGuard);
2672
+ if (writeResult.degradedFromAtomic) {
2673
+ // R25 F2 — link(2) unavailable on this filesystem; publication
2674
+ // used copyFile(EXCL) which is exclusive-safe but not atomic.
2675
+ // A concurrent reader or a crash mid-copy could observe the
2676
+ // hook in a partially-written state. Operators should be
2677
+ // aware so they can weigh whether to run `rea init` again or
2678
+ // relocate `.git` to a filesystem that supports hardlinks.
2679
+ result.warnings.push(`pre-push hook at ${decision.hookPath} was published non-atomically ` +
2680
+ `(link(2) unavailable on this filesystem). The file is in place and ` +
2681
+ `correct, but a crash mid-install could leave a partial hook. ` +
2682
+ `Consider moving the repo to a filesystem that supports hardlinks ` +
2683
+ `for atomic publication.`);
2684
+ }
2685
+ }
2686
+ catch (writeErr) {
2687
+ const e = writeErr;
2688
+ if (e.code === 'EEXIST') {
2689
+ // A file appeared between the re-check and the copyFile(EXCL).
2690
+ // Bail safely.
2691
+ result.warnings.push(`pre-push hook appeared at ${decision.hookPath} after the safety check — ` +
2692
+ `leaving it untouched. Re-run \`rea init\` to re-evaluate.`);
2693
+ result.decision = {
2694
+ action: 'skip',
2695
+ reason: 'foreign-pre-push',
2696
+ hookPath: decision.hookPath,
2697
+ };
2698
+ return result;
2699
+ }
2700
+ if (e.code === 'REA_REFRESH_RACE') {
2701
+ // R14 F2: the destination changed between the safety re-check
2702
+ // and the rename — a consumer or another installer landed a
2703
+ // file at the hook path outside our advisory lock. Fail
2704
+ // closed: do not stomp the replacement. The user can re-run
2705
+ // `rea init` to re-evaluate.
2706
+ result.warnings.push(`pre-push hook at ${decision.hookPath} was modified during refresh — ` +
2707
+ `leaving the current file in place. Re-run \`rea init\` to re-evaluate.`);
2708
+ result.decision = {
2709
+ action: 'skip',
2710
+ reason: 'foreign-pre-push',
2711
+ hookPath: decision.hookPath,
2712
+ };
2713
+ return result;
2714
+ }
2715
+ throw writeErr;
2716
+ }
2717
+ result.written = decision.hookPath;
2718
+ if (decision.action === 'refresh') {
2719
+ // Informational — refreshing our own marker is the idempotent
2720
+ // path, not a warning condition.
2721
+ warn(`refreshed rea-managed pre-push at ${decision.hookPath}`);
2722
+ }
2723
+ return result;
2724
+ }
2725
+ case 'skip': {
2726
+ if (decision.reason === 'foreign-pre-push') {
2727
+ result.warnings.push(`pre-push hook at ${decision.hookPath} is not rea-managed — ` +
2728
+ `leaving it untouched. Add \`exec ${GATE_DELEGATION_TOKEN} "$@"\` ` +
2729
+ `to it manually to wire the Codex audit gate.`);
2730
+ }
2731
+ // 'active-pre-push-present' is the happy husky path — no warning.
2732
+ return result;
2733
+ }
2734
+ }
2735
+ };
2736
+ if (!useLock)
2737
+ return run();
2738
+ return withInstallLock(targetDir, run);
2739
+ }
2740
+ export async function inspectPrePushState(targetDir) {
2741
+ const candidatePaths = [];
2742
+ const hooksInfo = await resolveHooksDir(targetDir);
2743
+ // Resolved via `git rev-parse --git-path hooks/pre-push` so linked
2744
+ // worktrees (where `.git` is a FILE, not a directory) are handled
2745
+ // correctly. Fall back to the hard-coded form only when git cannot
2746
+ // answer — the inspect path must never throw.
2747
+ const gitHookPath = (await resolveGitHookPath(targetDir, 'pre-push')) ??
2748
+ path.join(targetDir, '.git', 'hooks', 'pre-push');
2749
+ // Priority order matches install policy:
2750
+ // 1. Configured hooksPath (husky or custom)
2751
+ // 2. `.git/hooks/pre-push` (fallback target, via git-path resolution)
2752
+ // 3. `.husky/pre-push` (source-of-truth copy, may be inert if husky
2753
+ // isn't wired up yet)
2754
+ if (hooksInfo.configured && hooksInfo.dir !== null) {
2755
+ candidatePaths.push(path.join(hooksInfo.dir, 'pre-push'));
2756
+ }
2757
+ candidatePaths.push(gitHookPath);
2758
+ candidatePaths.push(path.join(targetDir, '.husky', 'pre-push'));
2759
+ // De-duplicate while preserving order (hooksPath may already point at
2760
+ // `.husky/` on husky projects).
2761
+ const seen = new Set();
2762
+ const uniq = candidatePaths.filter((p) => {
2763
+ if (seen.has(p))
2764
+ return false;
2765
+ seen.add(p);
2766
+ return true;
2767
+ });
2768
+ const candidates = [];
2769
+ for (const p of uniq) {
2770
+ let exists = false;
2771
+ let executable = false;
2772
+ let reaManaged = false;
2773
+ let delegatesToGate = false;
2774
+ try {
2775
+ const stat = await fsPromises.stat(p);
2776
+ exists = stat.isFile();
2777
+ if (exists) {
2778
+ executable = (stat.mode & 0o111) !== 0;
2779
+ try {
2780
+ const content = await fsPromises.readFile(p, 'utf8');
2781
+ reaManaged =
2782
+ isReaManagedFallback(content) ||
2783
+ isReaManagedHuskyGate(content) ||
2784
+ isLegacyReaManagedHuskyGate(content);
2785
+ delegatesToGate = referencesReviewGate(content);
2786
+ }
2787
+ catch {
2788
+ // unreadable — leave both false
2789
+ }
2790
+ }
2791
+ }
2792
+ catch {
2793
+ // ENOENT or other stat failure — leave defaults.
2794
+ }
2795
+ candidates.push({ path: p, exists, executable, reaManaged, delegatesToGate });
2796
+ }
2797
+ // A candidate only counts as "active" when git would actually fire it.
2798
+ // If core.hooksPath is set, only the candidate inside that directory is
2799
+ // active. Otherwise only `.git/hooks/pre-push` is active. `.husky/pre-push`
2800
+ // on its own, without hooksPath pointing at `.husky/`, never fires —
2801
+ // report it for context but do not let it satisfy `ok`.
2802
+ const activePath = hooksInfo.configured && hooksInfo.dir !== null
2803
+ ? path.join(hooksInfo.dir, 'pre-push')
2804
+ : gitHookPath;
2805
+ const active = candidates.find((c) => c.path === activePath);
2806
+ const activeExistsExec = active !== undefined && active.exists && active.executable;
2807
+ const activeGoverns = active !== undefined && (active.reaManaged || active.delegatesToGate);
2808
+ const ok = activeExistsExec && activeGoverns;
2809
+ const activeForeign = activeExistsExec && !activeGoverns;
2810
+ // R13 F3: the earlier `activeSuspect` field downgraded active-foreign
2811
+ // doctor results to WARN when the file substring-mentioned the gate path.
2812
+ // That was unsafe: any comment, echo, or dead string mentioning the path
2813
+ // triggered the downgrade, so `rea doctor` could exit 0 on an ungoverned
2814
+ // hook. The classifier must fail closed — either the parser confirms a
2815
+ // real invocation (and `delegatesToGate` is already true) or doctor
2816
+ // reports `fail`.
2817
+ return { candidates, activePath, ok, activeForeign };
2818
+ }