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