@bookedsolid/rea 0.10.3 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-push +48 -162
- package/README.md +834 -552
- package/agents/codex-adversarial.md +5 -3
- package/commands/codex-review.md +3 -5
- package/dist/audit/append.d.ts +7 -32
- package/dist/audit/append.js +7 -35
- package/dist/cli/audit.d.ts +0 -31
- package/dist/cli/audit.js +5 -74
- package/dist/cli/doctor.d.ts +12 -0
- package/dist/cli/doctor.js +96 -17
- package/dist/cli/hook.d.ts +55 -0
- package/dist/cli/hook.js +138 -0
- package/dist/cli/index.js +5 -80
- package/dist/cli/init.js +1 -1
- package/dist/cli/install/gitignore.d.ts +2 -2
- package/dist/cli/install/gitignore.js +3 -3
- package/dist/cli/install/pre-push.d.ts +158 -272
- package/dist/cli/install/pre-push.js +491 -2633
- package/dist/cli/install/settings-merge.d.ts +17 -0
- package/dist/cli/install/settings-merge.js +48 -1
- package/dist/cli/upgrade.js +131 -3
- package/dist/config/tier-map.js +18 -25
- package/dist/hooks/push-gate/base.d.ts +104 -0
- package/dist/hooks/push-gate/base.js +198 -0
- package/dist/hooks/push-gate/codex-runner.d.ts +126 -0
- package/dist/hooks/push-gate/codex-runner.js +223 -0
- package/dist/hooks/push-gate/findings.d.ts +68 -0
- package/dist/hooks/push-gate/findings.js +142 -0
- package/dist/hooks/push-gate/halt.d.ts +28 -0
- package/dist/hooks/push-gate/halt.js +49 -0
- package/dist/hooks/push-gate/index.d.ts +98 -0
- package/dist/hooks/push-gate/index.js +416 -0
- package/dist/hooks/push-gate/policy.d.ts +55 -0
- package/dist/hooks/push-gate/policy.js +64 -0
- package/dist/hooks/push-gate/report.d.ts +89 -0
- package/dist/hooks/push-gate/report.js +140 -0
- package/dist/policy/loader.d.ts +15 -10
- package/dist/policy/loader.js +8 -6
- package/dist/policy/types.d.ts +73 -22
- package/package.json +1 -1
- package/scripts/tarball-smoke.sh +7 -2
- package/dist/cache/review-cache.d.ts +0 -115
- package/dist/cache/review-cache.js +0 -200
- package/dist/cli/cache.d.ts +0 -84
- package/dist/cli/cache.js +0 -150
- package/dist/hooks/review-gate/args.d.ts +0 -126
- package/dist/hooks/review-gate/args.js +0 -315
- package/dist/hooks/review-gate/audit.d.ts +0 -131
- package/dist/hooks/review-gate/audit.js +0 -181
- package/dist/hooks/review-gate/banner.d.ts +0 -97
- package/dist/hooks/review-gate/banner.js +0 -172
- package/dist/hooks/review-gate/base-resolve.d.ts +0 -155
- package/dist/hooks/review-gate/base-resolve.js +0 -247
- package/dist/hooks/review-gate/cache-key.d.ts +0 -55
- package/dist/hooks/review-gate/cache-key.js +0 -41
- package/dist/hooks/review-gate/cache.d.ts +0 -108
- package/dist/hooks/review-gate/cache.js +0 -120
- package/dist/hooks/review-gate/constants.d.ts +0 -26
- package/dist/hooks/review-gate/constants.js +0 -34
- package/dist/hooks/review-gate/diff.d.ts +0 -181
- package/dist/hooks/review-gate/diff.js +0 -232
- package/dist/hooks/review-gate/errors.d.ts +0 -72
- package/dist/hooks/review-gate/errors.js +0 -100
- package/dist/hooks/review-gate/hash.d.ts +0 -43
- package/dist/hooks/review-gate/hash.js +0 -46
- package/dist/hooks/review-gate/index.d.ts +0 -31
- package/dist/hooks/review-gate/index.js +0 -35
- package/dist/hooks/review-gate/metadata.d.ts +0 -98
- package/dist/hooks/review-gate/metadata.js +0 -158
- package/dist/hooks/review-gate/policy.d.ts +0 -55
- package/dist/hooks/review-gate/policy.js +0 -71
- package/dist/hooks/review-gate/protected-paths.d.ts +0 -46
- package/dist/hooks/review-gate/protected-paths.js +0 -76
- package/hooks/_lib/push-review-core.sh +0 -1250
- package/hooks/commit-review-gate.sh +0 -330
- package/hooks/push-review-gate-git.sh +0 -94
- package/hooks/push-review-gate.sh +0 -92
|
@@ -1,71 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Pre-push hook installer (0.11.0 stateless push-gate).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
20
|
-
* fallback
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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,247 @@ 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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
*/
|
|
98
|
-
export const
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
* v3 — 0.12.0 BODY_TEMPLATE rewrite: positional-args dispatch via `case`
|
|
69
|
+
* arms with `set --`, removing the `exec $REA_BIN` word-splitting bug
|
|
70
|
+
* that broke pushes from repo paths containing spaces.
|
|
71
|
+
* v2 — 0.11.0 stateless push-gate body (no bash core, no audit grep).
|
|
72
|
+
* v1 — 0.10.x and prior, delegated to `.claude/hooks/push-review-gate.sh`.
|
|
73
|
+
*/
|
|
74
|
+
export const FALLBACK_MARKER = '# rea:pre-push-fallback v3';
|
|
75
|
+
/** Legacy v2 marker (0.11.x bodies). Refresh-on-upgrade. */
|
|
76
|
+
export const LEGACY_FALLBACK_MARKER_V2 = '# rea:pre-push-fallback v2';
|
|
77
|
+
/** Legacy v1 marker — used by upgrade migration to detect old installs. */
|
|
78
|
+
export const LEGACY_FALLBACK_MARKER_V1 = '# rea:pre-push-fallback v1';
|
|
79
|
+
/**
|
|
80
|
+
* Marker present in the shipped `.husky/pre-push` governance gate. The
|
|
81
|
+
* second line of the shipped husky hook is this marker — rea upgrade
|
|
82
|
+
* detects it to refresh in-place. Bump the suffix whenever the body
|
|
83
|
+
* changes; pre-0.12 markers live in `LEGACY_HUSKY_GATE_MARKER_V{1,2}`.
|
|
84
|
+
*/
|
|
85
|
+
export const HUSKY_GATE_MARKER = '# rea:husky-pre-push-gate v3';
|
|
86
|
+
/** Legacy v2 husky marker (0.11.x bodies). Refresh-on-upgrade. */
|
|
87
|
+
export const LEGACY_HUSKY_GATE_MARKER_V2 = '# rea:husky-pre-push-gate v2';
|
|
88
|
+
/** Legacy v1 husky marker for migration. */
|
|
89
|
+
export const LEGACY_HUSKY_GATE_MARKER_V1 = '# rea:husky-pre-push-gate v1';
|
|
90
|
+
/**
|
|
91
|
+
* Body-level marker so a hook that carries the header marker but has an
|
|
92
|
+
* empty body (stubbed out by a consumer) is NOT classified as rea-managed.
|
|
93
|
+
* A real rea hook always carries both markers.
|
|
94
|
+
*/
|
|
95
|
+
export const HUSKY_GATE_BODY_MARKER = '# rea:gate-body-v3';
|
|
96
|
+
/** Legacy v2 body marker (0.11.x bodies). Refresh-on-upgrade. */
|
|
97
|
+
export const LEGACY_HUSKY_GATE_BODY_MARKER_V2 = '# rea:gate-body-v2';
|
|
98
|
+
/** Legacy body marker — used by upgrade migration detection. */
|
|
99
|
+
export const LEGACY_HUSKY_GATE_BODY_MARKER_V1 = '# rea:gate-body-v1';
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Body templates
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
/**
|
|
104
|
+
* The canonical 0.11.0 stub body, used for BOTH `.git/hooks/pre-push`
|
|
105
|
+
* fallback AND `.husky/pre-push` (same code, same markers — the only
|
|
106
|
+
* difference is the header marker on line 2 which identifies the install
|
|
107
|
+
* shape for upgrade classification).
|
|
108
|
+
*
|
|
109
|
+
* Hand-maintained POSIX sh. Keep under 20 lines. Every statement must be
|
|
110
|
+
* meaningful — ballast breaks the "shell body does only HALT + exec" story
|
|
111
|
+
* that makes the gate trivially auditable.
|
|
112
|
+
*/
|
|
113
|
+
const BODY_TEMPLATE = `set -eu
|
|
114
|
+
|
|
115
|
+
# REA push-gate (0.11.0+). The heavy lifting — git diff resolution, Codex
|
|
116
|
+
# invocation, verdict inference, audit write — lives in
|
|
117
|
+
# \`src/hooks/push-gate/\` and is invoked via \`rea hook push-gate\`.
|
|
118
|
+
# This stub only short-circuits on the kill-switch and resolves the rea
|
|
119
|
+
# binary (in priority: project node_modules/.bin/rea → dogfood dist →
|
|
120
|
+
# PATH → npx).
|
|
121
|
+
#
|
|
122
|
+
# The 0.10.x hooks assumed rea was on PATH. Consumers who bootstrap via
|
|
123
|
+
# \`npx @bookedsolid/rea init\` have no persistent global rea install, so
|
|
124
|
+
# the bare \`exec rea\` pattern fails with "rea: not found" on push. We
|
|
125
|
+
# resolve against the project-local node_modules/.bin first, then PATH,
|
|
126
|
+
# then fall back to npx so the gate runs in every documented setup.
|
|
127
|
+
|
|
128
|
+
REA_ROOT=\$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
129
|
+
if [ -f "\${REA_ROOT}/.rea/HALT" ]; then
|
|
130
|
+
reason=\$(awk 'NR==1 { print; exit }' "\${REA_ROOT}/.rea/HALT" 2>/dev/null || printf 'unknown')
|
|
131
|
+
[ -z "\${reason:-}" ] && reason='unknown'
|
|
132
|
+
printf 'REA HALT: %s\\nAll push operations suspended. Run: rea unfreeze\\n' "\$reason" >&2
|
|
133
|
+
exit 1
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
# Dispatch the rea CLI as a positional-args list rather than a string
|
|
137
|
+
# (the 0.11.x \`exec \$REA_BIN ...\` pattern broke when REA_ROOT contained
|
|
138
|
+
# whitespace because the unquoted \$REA_BIN expansion underwent word
|
|
139
|
+
# splitting; \`/Users/jane/My Projects/repo\` produced four argv tokens
|
|
140
|
+
# instead of two). The \`set --\` form below preserves spaces verbatim.
|
|
141
|
+
#
|
|
142
|
+
# The pre-push stdin carries one line per refspec; \`exec\` inherits stdin
|
|
143
|
+
# unchanged. \$@ on entry carries git's <remote-name> <remote-url>; we
|
|
144
|
+
# preserve those by appending "\$@" inside each \`set --\` arm.
|
|
145
|
+
|
|
146
|
+
if [ -x "\${REA_ROOT}/node_modules/.bin/rea" ]; then
|
|
147
|
+
set -- "\${REA_ROOT}/node_modules/.bin/rea" hook push-gate "\$@"
|
|
148
|
+
elif [ -f "\${REA_ROOT}/dist/cli/index.js" ] && [ -f "\${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "\${REA_ROOT}/package.json" 2>/dev/null; then
|
|
149
|
+
# rea's own repo (dogfood) — the package is not installed under
|
|
150
|
+
# node_modules here because we ARE the package. The built CLI
|
|
151
|
+
# entry point lives at dist/cli/index.js; node runs it directly.
|
|
152
|
+
# Gate this branch on \`package.json\` declaring \`@bookedsolid/rea\` so a
|
|
153
|
+
# consumer repo that happens to ship its own \`dist/cli/index.js\` does
|
|
154
|
+
# not get this hook executing the consumer's unrelated build.
|
|
155
|
+
set -- node "\${REA_ROOT}/dist/cli/index.js" hook push-gate "\$@"
|
|
156
|
+
elif command -v rea >/dev/null 2>&1; then
|
|
157
|
+
set -- rea hook push-gate "\$@"
|
|
158
|
+
elif command -v npx >/dev/null 2>&1; then
|
|
159
|
+
# Last resort: npx will resolve the package from npm or the cache.
|
|
160
|
+
# Pass \`--no-install\` so a rare cache-cold machine surfaces a clear
|
|
161
|
+
# error instead of silently downloading at push time.
|
|
162
|
+
set -- npx --no-install @bookedsolid/rea hook push-gate "\$@"
|
|
163
|
+
else
|
|
164
|
+
printf 'rea: cannot locate the rea CLI. Install locally (\`pnpm add -D @bookedsolid/rea\`) or globally (\`npm i -g @bookedsolid/rea\`).\\n' >&2
|
|
165
|
+
exit 2
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
exec "\$@"
|
|
169
|
+
`;
|
|
170
|
+
/** Fallback hook body — `.git/hooks/pre-push` in vanilla-git installs. */
|
|
171
|
+
export function fallbackHookContent() {
|
|
172
|
+
return `#!/bin/sh
|
|
173
|
+
${FALLBACK_MARKER}
|
|
174
|
+
${HUSKY_GATE_BODY_MARKER}
|
|
175
|
+
#
|
|
176
|
+
# Fallback pre-push hook installed by \`rea init\` (vanilla git, no Husky).
|
|
177
|
+
# Do NOT edit by hand: re-run \`rea init\` or \`rea upgrade\` to refresh.
|
|
178
|
+
#
|
|
179
|
+
# Governance contract: HALT kill-switch check, then delegate to
|
|
180
|
+
# \`rea hook push-gate\`. The push-gate runs \`codex exec review --json\`
|
|
181
|
+
# against the diff and exits 0 (pass / empty-diff / disabled / skip),
|
|
182
|
+
# 1 (HALT — how we got here only when the file appeared mid-push),
|
|
183
|
+
# or 2 (blocked verdict, timeout, Codex error).
|
|
184
|
+
|
|
185
|
+
${BODY_TEMPLATE}`;
|
|
1571
186
|
}
|
|
1572
|
-
/**
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
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;
|
|
187
|
+
/** Husky hook body — `.husky/pre-push` when hooksPath=.husky. */
|
|
188
|
+
export function huskyHookContent() {
|
|
189
|
+
return `#!/bin/sh
|
|
190
|
+
${HUSKY_GATE_MARKER}
|
|
191
|
+
${HUSKY_GATE_BODY_MARKER}
|
|
192
|
+
#
|
|
193
|
+
# Husky pre-push hook installed by \`rea init\` / \`rea upgrade\`. Do NOT
|
|
194
|
+
# edit by hand — the file is refreshed on every rea upgrade.
|
|
195
|
+
#
|
|
196
|
+
# Governance contract: HALT kill-switch check, then delegate to
|
|
197
|
+
# \`rea hook push-gate\`. See src/hooks/push-gate/index.ts.
|
|
198
|
+
|
|
199
|
+
${BODY_TEMPLATE}`;
|
|
1681
200
|
}
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Classification
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
1682
204
|
/**
|
|
1683
|
-
* True when `content`
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
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.
|
|
205
|
+
* True when `content` starts with the exact rea fallback prelude (shebang
|
|
206
|
+
* + v2 marker). Strict: the marker must be on line 2, nothing interposed,
|
|
207
|
+
* no leading whitespace. Substring matches are deliberately rejected.
|
|
1699
208
|
*/
|
|
1700
|
-
function
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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;
|
|
209
|
+
export function isReaManagedFallback(content) {
|
|
210
|
+
if (!content.startsWith('#!/bin/sh\n'))
|
|
211
|
+
return false;
|
|
212
|
+
const secondLineEnd = content.indexOf('\n', 10);
|
|
213
|
+
if (secondLineEnd < 0)
|
|
214
|
+
return false;
|
|
215
|
+
const secondLine = content.slice(10, secondLineEnd);
|
|
216
|
+
return secondLine === FALLBACK_MARKER;
|
|
1777
217
|
}
|
|
1778
218
|
/**
|
|
1779
|
-
*
|
|
1780
|
-
*
|
|
1781
|
-
*
|
|
1782
|
-
*
|
|
1783
|
-
*
|
|
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.
|
|
219
|
+
* True when `content` is a legacy fallback hook authored by an earlier rea
|
|
220
|
+
* release: v2 (0.11.x — broken `exec $REA_BIN` body) or v1 (0.10.x —
|
|
221
|
+
* delegated to `.claude/hooks/push-review-gate.sh`). Used by `rea upgrade`
|
|
222
|
+
* to migrate — we overwrite these unconditionally because we control the
|
|
223
|
+
* entire body shape.
|
|
1833
224
|
*/
|
|
1834
|
-
function
|
|
1835
|
-
|
|
1836
|
-
if (trimmed.length === 0)
|
|
225
|
+
export function isLegacyReaManagedFallback(content) {
|
|
226
|
+
if (!content.startsWith('#!/bin/sh\n'))
|
|
1837
227
|
return false;
|
|
1838
|
-
|
|
1839
|
-
|
|
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)
|
|
228
|
+
const secondLineEnd = content.indexOf('\n', 10);
|
|
229
|
+
if (secondLineEnd < 0)
|
|
1849
230
|
return false;
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
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);
|
|
231
|
+
const secondLine = content.slice(10, secondLineEnd);
|
|
232
|
+
return (secondLine === LEGACY_FALLBACK_MARKER_V2 ||
|
|
233
|
+
secondLine === LEGACY_FALLBACK_MARKER_V1);
|
|
1868
234
|
}
|
|
1869
235
|
/**
|
|
1870
|
-
*
|
|
236
|
+
* True when `content` carries the rea Husky gate markers in the canonical
|
|
237
|
+
* positions — shebang on line 1, `HUSKY_GATE_MARKER` on line 2,
|
|
238
|
+
* `HUSKY_GATE_BODY_MARKER` on line 3.
|
|
1871
239
|
*
|
|
1872
|
-
*
|
|
1873
|
-
*
|
|
1874
|
-
*
|
|
1875
|
-
*
|
|
1876
|
-
*
|
|
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.
|
|
240
|
+
* Why three anchored lines instead of a substring search: the 0.10.x
|
|
241
|
+
* implementation lived in ~2000 lines of structural parser because the old
|
|
242
|
+
* body varied. The 0.11.0 body is hand-templated and stable — anchored
|
|
243
|
+
* matching on three fixed lines closes the classification question with
|
|
244
|
+
* six comparisons.
|
|
1897
245
|
*/
|
|
1898
|
-
function
|
|
1899
|
-
|
|
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;
|
|
246
|
+
export function isReaManagedHuskyGate(content) {
|
|
247
|
+
return hasHeaderMarkers(content, HUSKY_GATE_MARKER, HUSKY_GATE_BODY_MARKER);
|
|
1951
248
|
}
|
|
1952
249
|
/**
|
|
1953
|
-
*
|
|
1954
|
-
*
|
|
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.
|
|
250
|
+
* True when `content` is a legacy Husky gate from an earlier rea release:
|
|
251
|
+
* v2 (0.11.x — broken `exec $REA_BIN` body) or v1 (0.10.x — bash core
|
|
252
|
+
* delegating). Used to trigger the upgrade migration.
|
|
1978
253
|
*/
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
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' };
|
|
254
|
+
export function isLegacyReaManagedHuskyGate(content) {
|
|
255
|
+
return (hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V2, LEGACY_HUSKY_GATE_BODY_MARKER_V2) ||
|
|
256
|
+
hasHeaderMarkers(content, LEGACY_HUSKY_GATE_MARKER_V1, LEGACY_HUSKY_GATE_BODY_MARKER_V1));
|
|
2022
257
|
}
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
*
|
|
2034
|
-
*
|
|
258
|
+
function hasHeaderMarkers(content, header, body) {
|
|
259
|
+
if (!content.startsWith('#!/bin/sh\n'))
|
|
260
|
+
return false;
|
|
261
|
+
const lines = content.split('\n');
|
|
262
|
+
// lines[0] = "#!/bin/sh"
|
|
263
|
+
// lines[1] = header marker
|
|
264
|
+
// lines[2] = body marker
|
|
265
|
+
return lines[1] === header && lines[2] === body;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* True when `content` looks like a user-authored pre-push that still
|
|
269
|
+
* invokes `rea hook push-gate` (a legitimate governance-carrying custom
|
|
270
|
+
* hook). We don't attempt to parse control flow — the 0.10.x attempt at
|
|
271
|
+
* that produced 800 lines of heuristics that still had gaps. Instead, we
|
|
272
|
+
* match only on the substring `rea hook push-gate` preceded by one of
|
|
273
|
+
* `exec`, `$(`, \``, `;`, or line-start whitespace. A comment containing
|
|
274
|
+
* the phrase does NOT qualify (leading `#`).
|
|
275
|
+
*
|
|
276
|
+
* Governance-carrying is a soft signal: `rea doctor` uses it to print
|
|
277
|
+
* "external (delegates to rea hook push-gate)" rather than "foreign".
|
|
278
|
+
* `classifyPrePushInstall` maps it to "skip / active-pre-push-present"
|
|
279
|
+
* — we don't overwrite consumer-authored hooks that respect the gate.
|
|
2035
280
|
*/
|
|
2036
|
-
function
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
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
|
-
`;
|
|
281
|
+
export function referencesReviewGate(content) {
|
|
282
|
+
// Strip full-line comments before searching — the grep regex matches
|
|
283
|
+
// whitespace-anchored phrases but not commented-out ones.
|
|
284
|
+
const lines = content.split(/\r?\n/);
|
|
285
|
+
for (const rawLine of lines) {
|
|
286
|
+
// Line starts with `#` (possibly leading whitespace)? comment — skip.
|
|
287
|
+
if (/^\s*#/.test(rawLine))
|
|
288
|
+
continue;
|
|
289
|
+
if (/(?:^|\s|;|`|\$\()\s*(?:exec\s+)?rea\s+hook\s+push-gate\b/.test(rawLine)) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
2062
294
|
}
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Hook resolution
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
2063
298
|
/**
|
|
2064
|
-
* Read `core.hooksPath` via `git config --get`.
|
|
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.
|
|
299
|
+
* Read `core.hooksPath` via `git config --get`. Returns `null` when unset.
|
|
2068
300
|
*/
|
|
2069
301
|
async function readHooksPathFromGit(targetDir) {
|
|
2070
302
|
try {
|
|
@@ -2076,10 +308,6 @@ async function readHooksPathFromGit(targetDir) {
|
|
|
2076
308
|
return null;
|
|
2077
309
|
}
|
|
2078
310
|
}
|
|
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
311
|
export async function resolveHooksDir(targetDir) {
|
|
2084
312
|
const configured = await readHooksPathFromGit(targetDir);
|
|
2085
313
|
if (configured === null) {
|
|
@@ -2091,25 +319,8 @@ export async function resolveHooksDir(targetDir) {
|
|
|
2091
319
|
return { dir: absolute, configured: true };
|
|
2092
320
|
}
|
|
2093
321
|
/**
|
|
2094
|
-
* Resolve the
|
|
2095
|
-
*
|
|
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.
|
|
322
|
+
* Resolve the absolute path git itself would fire for `hooks/<name>`. Works
|
|
323
|
+
* across vanilla repos, linked worktrees (shared hooks dir), and submodules.
|
|
2113
324
|
*/
|
|
2114
325
|
async function resolveGitHookPath(targetDir, hookName) {
|
|
2115
326
|
try {
|
|
@@ -2123,18 +334,6 @@ async function resolveGitHookPath(targetDir, hookName) {
|
|
|
2123
334
|
return null;
|
|
2124
335
|
}
|
|
2125
336
|
}
|
|
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
337
|
async function resolveTargetHookPath(targetDir) {
|
|
2139
338
|
const hooksInfo = await resolveHooksDir(targetDir);
|
|
2140
339
|
if (hooksInfo.configured && hooksInfo.dir !== null) {
|
|
@@ -2147,51 +346,65 @@ async function resolveTargetHookPath(targetDir) {
|
|
|
2147
346
|
if (gitHookPath !== null) {
|
|
2148
347
|
return { hookPath: gitHookPath, hooksPathConfigured: false };
|
|
2149
348
|
}
|
|
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
349
|
return {
|
|
2154
350
|
hookPath: path.join(targetDir, '.git', 'hooks', 'pre-push'),
|
|
2155
351
|
hooksPathConfigured: false,
|
|
2156
352
|
};
|
|
2157
353
|
}
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
354
|
+
export async function classifyExistingHook(hookPath) {
|
|
355
|
+
let stat;
|
|
356
|
+
try {
|
|
357
|
+
stat = await fsPromises.lstat(hookPath);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return { kind: 'absent' };
|
|
361
|
+
}
|
|
362
|
+
if (stat.isDirectory())
|
|
363
|
+
return { kind: 'foreign', reason: 'is-directory' };
|
|
364
|
+
if (stat.isSymbolicLink())
|
|
365
|
+
return { kind: 'foreign', reason: 'is-symlink' };
|
|
366
|
+
if (!stat.isFile())
|
|
367
|
+
return { kind: 'foreign', reason: 'not-regular-file' };
|
|
368
|
+
let content;
|
|
369
|
+
try {
|
|
370
|
+
content = await fsPromises.readFile(hookPath, 'utf8');
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
return {
|
|
374
|
+
kind: 'foreign',
|
|
375
|
+
reason: `read-error: ${e instanceof Error ? e.message : String(e)}`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
if (isReaManagedHuskyGate(content))
|
|
379
|
+
return { kind: 'rea-managed-husky' };
|
|
380
|
+
if (isLegacyReaManagedHuskyGate(content))
|
|
381
|
+
return { kind: 'rea-managed-husky-legacy-v1' };
|
|
382
|
+
if (isReaManagedFallback(content))
|
|
383
|
+
return { kind: 'rea-managed' };
|
|
384
|
+
if (isLegacyReaManagedFallback(content))
|
|
385
|
+
return { kind: 'rea-managed-legacy-v1' };
|
|
386
|
+
if (referencesReviewGate(content))
|
|
387
|
+
return { kind: 'gate-delegating' };
|
|
388
|
+
return { kind: 'foreign', reason: 'no-marker' };
|
|
389
|
+
}
|
|
2167
390
|
export async function classifyPrePushInstall(targetDir) {
|
|
2168
391
|
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
392
|
const classification = await classifyExistingHook(hookPath);
|
|
2175
393
|
if (classification.kind === 'absent') {
|
|
2176
394
|
return { action: 'install', hookPath };
|
|
2177
395
|
}
|
|
2178
|
-
if (classification.kind === 'rea-managed'
|
|
396
|
+
if (classification.kind === 'rea-managed' ||
|
|
397
|
+
classification.kind === 'rea-managed-legacy-v1') {
|
|
2179
398
|
return { action: 'refresh', hookPath };
|
|
2180
399
|
}
|
|
2181
|
-
if (classification.kind === 'rea-managed-husky'
|
|
2182
|
-
|
|
2183
|
-
//
|
|
2184
|
-
//
|
|
2185
|
-
// so doctor reports `ok` — but map to skip/active-pre-push-present so
|
|
2186
|
-
// `installPrePushFallback` never touches it.
|
|
400
|
+
if (classification.kind === 'rea-managed-husky' ||
|
|
401
|
+
classification.kind === 'rea-managed-husky-legacy-v1') {
|
|
402
|
+
// Canonical husky gate — never touched by the fallback installer. The
|
|
403
|
+
// canonical copy module refreshes it.
|
|
2187
404
|
return { action: 'skip', reason: 'active-pre-push-present', hookPath };
|
|
2188
405
|
}
|
|
2189
|
-
// Non-rea file
|
|
2190
|
-
//
|
|
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.
|
|
406
|
+
// Non-rea file exists. Executable + references the gate → governance-
|
|
407
|
+
// carrying; skip with 'active-pre-push-present'. Else foreign.
|
|
2195
408
|
let executable = false;
|
|
2196
409
|
try {
|
|
2197
410
|
const stat = await fsPromises.stat(hookPath);
|
|
@@ -2201,38 +414,101 @@ export async function classifyPrePushInstall(targetDir) {
|
|
|
2201
414
|
executable = false;
|
|
2202
415
|
}
|
|
2203
416
|
if (executable && classification.kind === 'gate-delegating') {
|
|
2204
|
-
|
|
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
|
-
};
|
|
417
|
+
return { action: 'skip', reason: 'active-pre-push-present', hookPath };
|
|
2218
418
|
}
|
|
2219
|
-
|
|
2220
|
-
return {
|
|
2221
|
-
action: 'skip',
|
|
2222
|
-
reason: 'foreign-pre-push',
|
|
2223
|
-
hookPath,
|
|
2224
|
-
};
|
|
419
|
+
return { action: 'skip', reason: 'foreign-pre-push', hookPath };
|
|
2225
420
|
}
|
|
2226
421
|
/**
|
|
2227
|
-
*
|
|
2228
|
-
*
|
|
2229
|
-
* the same directory as `dst` so we only touch files we would have created.
|
|
422
|
+
* Install (or refresh) the `.git/hooks/pre-push` stub when there is no
|
|
423
|
+
* active governance-carrying hook. Never overwrites foreign hooks.
|
|
2230
424
|
*
|
|
2231
|
-
*
|
|
2232
|
-
*
|
|
2233
|
-
*
|
|
2234
|
-
*
|
|
425
|
+
* Concurrency: a proper-lockfile-based advisory lock on the git common-dir
|
|
426
|
+
* serializes concurrent installs (two `rea init` runs in the same repo).
|
|
427
|
+
* Atomicity: fresh installs use `link(2)` (atomic create-or-fail); refresh
|
|
428
|
+
* uses `rename(2)` with a dev+ino+mtime guard against mid-write
|
|
429
|
+
* replacement.
|
|
2235
430
|
*/
|
|
431
|
+
export async function installPrePushFallback(options) {
|
|
432
|
+
const warnings = [];
|
|
433
|
+
const lockDir = await resolveLockDir(options.targetDir);
|
|
434
|
+
await fsPromises.mkdir(lockDir, { recursive: true });
|
|
435
|
+
const release = await properLockfile.lock(lockDir, {
|
|
436
|
+
retries: { retries: 6, minTimeout: 100, maxTimeout: 500 },
|
|
437
|
+
realpath: false,
|
|
438
|
+
});
|
|
439
|
+
try {
|
|
440
|
+
// Re-classify AFTER acquiring the lock — another installer or husky
|
|
441
|
+
// postinstall might have written a hook while we waited.
|
|
442
|
+
const decision = await classifyPrePushInstall(options.targetDir);
|
|
443
|
+
if (decision.action === 'skip') {
|
|
444
|
+
if (decision.reason === 'foreign-pre-push') {
|
|
445
|
+
warnings.push(`foreign pre-push at ${decision.hookPath} — leaving alone. Run \`rea doctor\` to see the governance gap.`);
|
|
446
|
+
}
|
|
447
|
+
return { decision, warnings };
|
|
448
|
+
}
|
|
449
|
+
// Capture identity BEFORE writing, so the refresh path can detect a
|
|
450
|
+
// concurrent modification between the lock-scoped re-classify and the
|
|
451
|
+
// final rename.
|
|
452
|
+
let guard = { kind: 'absent' };
|
|
453
|
+
if (decision.action === 'refresh') {
|
|
454
|
+
try {
|
|
455
|
+
const st = await fsPromises.stat(decision.hookPath);
|
|
456
|
+
guard = {
|
|
457
|
+
kind: 'present',
|
|
458
|
+
dev: st.dev,
|
|
459
|
+
ino: st.ino,
|
|
460
|
+
mtimeMs: st.mtimeMs,
|
|
461
|
+
size: st.size,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
// Vanished between classify and stat — downgrade to install.
|
|
466
|
+
guard = { kind: 'absent' };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
await cleanupStaleTempFiles(decision.hookPath);
|
|
470
|
+
await writeExecutable({
|
|
471
|
+
dst: decision.hookPath,
|
|
472
|
+
content: fallbackHookContent(),
|
|
473
|
+
exclusive: decision.action === 'install',
|
|
474
|
+
guard,
|
|
475
|
+
});
|
|
476
|
+
if (decision.action === 'refresh') {
|
|
477
|
+
warn(`refreshed rea-managed pre-push at ${decision.hookPath}`);
|
|
478
|
+
}
|
|
479
|
+
return { decision, written: decision.hookPath, warnings };
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
await release();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async function resolveLockDir(targetDir) {
|
|
486
|
+
// Lock at `${git-common-dir}/rea-prepush.lockdir` so concurrent installs
|
|
487
|
+
// in the same repo (or across linked worktrees sharing the common dir)
|
|
488
|
+
// serialize. Fall back to the target dir if git is unreachable.
|
|
489
|
+
try {
|
|
490
|
+
const { stdout } = await execFileAsync('git', ['-C', targetDir, 'rev-parse', '--git-common-dir'], { encoding: 'utf8' });
|
|
491
|
+
const commonDir = stdout.trim();
|
|
492
|
+
if (commonDir.length > 0) {
|
|
493
|
+
const absolute = path.isAbsolute(commonDir)
|
|
494
|
+
? commonDir
|
|
495
|
+
: path.join(targetDir, commonDir);
|
|
496
|
+
return path.join(absolute, 'rea-prepush.lockdir');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
// fall through
|
|
501
|
+
}
|
|
502
|
+
return path.join(targetDir, '.rea-prepush.lockdir');
|
|
503
|
+
}
|
|
504
|
+
class RefreshRaceError extends Error {
|
|
505
|
+
code = 'REA_REFRESH_RACE';
|
|
506
|
+
constructor(dst) {
|
|
507
|
+
super(`refresh aborted: ${dst} was modified by another writer between ` +
|
|
508
|
+
`classify and rename. Re-run \`rea init\` to re-evaluate.`);
|
|
509
|
+
this.name = 'RefreshRaceError';
|
|
510
|
+
}
|
|
511
|
+
}
|
|
2236
512
|
async function cleanupStaleTempFiles(dst) {
|
|
2237
513
|
const dir = path.dirname(dst);
|
|
2238
514
|
const prefix = `${path.basename(dst)}.rea-tmp-`;
|
|
@@ -2241,27 +517,8 @@ async function cleanupStaleTempFiles(dst) {
|
|
|
2241
517
|
entries = await fsPromises.readdir(dir);
|
|
2242
518
|
}
|
|
2243
519
|
catch {
|
|
2244
|
-
return;
|
|
520
|
+
return;
|
|
2245
521
|
}
|
|
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
522
|
const candidates = entries.filter((e) => e.startsWith(prefix));
|
|
2266
523
|
await Promise.all(candidates.map(async (name) => {
|
|
2267
524
|
const abs = path.join(dir, name);
|
|
@@ -2282,61 +539,17 @@ async function cleanupStaleTempFiles(dst) {
|
|
|
2282
539
|
}
|
|
2283
540
|
if (!body.startsWith('#!/bin/sh'))
|
|
2284
541
|
return;
|
|
2285
|
-
if (!body.includes(
|
|
2286
|
-
!body.includes(
|
|
542
|
+
if (!body.includes(FALLBACK_MARKER) &&
|
|
543
|
+
!body.includes(HUSKY_GATE_MARKER) &&
|
|
544
|
+
!body.includes(LEGACY_FALLBACK_MARKER_V2) &&
|
|
545
|
+
!body.includes(LEGACY_HUSKY_GATE_MARKER_V2) &&
|
|
546
|
+
!body.includes(LEGACY_FALLBACK_MARKER_V1) &&
|
|
547
|
+
!body.includes(LEGACY_HUSKY_GATE_MARKER_V1)) {
|
|
2287
548
|
return;
|
|
2288
549
|
}
|
|
2289
550
|
await fsPromises.unlink(abs).catch(() => undefined);
|
|
2290
551
|
}));
|
|
2291
552
|
}
|
|
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
553
|
async function verifyRefreshGuard(dst, guard) {
|
|
2341
554
|
if (guard.kind === 'absent')
|
|
2342
555
|
return;
|
|
@@ -2345,9 +558,6 @@ async function verifyRefreshGuard(dst, guard) {
|
|
|
2345
558
|
current = await fsPromises.stat(dst);
|
|
2346
559
|
}
|
|
2347
560
|
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
561
|
throw new RefreshRaceError(dst);
|
|
2352
562
|
}
|
|
2353
563
|
if (current.dev !== guard.dev ||
|
|
@@ -2357,465 +567,113 @@ async function verifyRefreshGuard(dst, guard) {
|
|
|
2357
567
|
throw new RefreshRaceError(dst);
|
|
2358
568
|
}
|
|
2359
569
|
}
|
|
2360
|
-
async function writeExecutable(
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
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);
|
|
570
|
+
async function writeExecutable(opts) {
|
|
571
|
+
const dir = path.dirname(opts.dst);
|
|
572
|
+
await fsPromises.mkdir(dir, { recursive: true });
|
|
573
|
+
const rand = crypto.randomBytes(8).toString('hex');
|
|
574
|
+
const tmp = path.join(dir, `${path.basename(opts.dst)}.rea-tmp-${rand}`);
|
|
575
|
+
await fsPromises.writeFile(tmp, opts.content, { encoding: 'utf8', mode: 0o755 });
|
|
2371
576
|
try {
|
|
2372
|
-
await
|
|
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);
|
|
577
|
+
await fsPromises.chmod(tmp, 0o755);
|
|
2376
578
|
}
|
|
2377
|
-
|
|
2378
|
-
|
|
579
|
+
catch {
|
|
580
|
+
// chmod may fail on filesystems that don't honor mode (Windows, some
|
|
581
|
+
// network shares) — writeFile already set mode, move on.
|
|
2379
582
|
}
|
|
2380
583
|
try {
|
|
2381
|
-
if (exclusive) {
|
|
2382
|
-
//
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
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
|
-
}
|
|
584
|
+
if (opts.exclusive) {
|
|
585
|
+
// Atomic create-or-fail. EEXIST → a racing writer won; give up.
|
|
586
|
+
await fsPromises.link(tmp, opts.dst);
|
|
587
|
+
await fsPromises.unlink(tmp).catch(() => undefined);
|
|
588
|
+
return;
|
|
2419
589
|
}
|
|
2420
|
-
// Refresh: verify
|
|
2421
|
-
//
|
|
2422
|
-
|
|
2423
|
-
await
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
590
|
+
// Refresh path: verify guard, then rename. Guard detection closes the
|
|
591
|
+
// classify→rename window.
|
|
592
|
+
await verifyRefreshGuard(opts.dst, opts.guard);
|
|
593
|
+
await fsPromises.rename(tmp, opts.dst);
|
|
594
|
+
}
|
|
595
|
+
catch (e) {
|
|
596
|
+
const code = e.code;
|
|
597
|
+
if (opts.exclusive && (code === 'EXDEV' || code === 'EPERM' || code === 'ENOSYS')) {
|
|
598
|
+
// Cross-device or unsupported link(2). Fall back to copyFile with
|
|
599
|
+
// EXCL flag — strictly worse (observable empty/partial window) but
|
|
600
|
+
// better than refusing the install on network mounts.
|
|
601
|
+
await fsPromises.copyFile(tmp, opts.dst, fs.constants.COPYFILE_EXCL);
|
|
602
|
+
await fsPromises.unlink(tmp).catch(() => undefined);
|
|
603
|
+
return;
|
|
2427
604
|
}
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
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);
|
|
605
|
+
if (!opts.exclusive && code === 'EXDEV') {
|
|
606
|
+
await verifyRefreshGuard(opts.dst, opts.guard);
|
|
607
|
+
await fsPromises.copyFile(tmp, opts.dst);
|
|
2437
608
|
await fsPromises.unlink(tmp).catch(() => undefined);
|
|
2438
|
-
return
|
|
609
|
+
return;
|
|
2439
610
|
}
|
|
2440
|
-
}
|
|
2441
|
-
catch (err) {
|
|
2442
611
|
await fsPromises.unlink(tmp).catch(() => undefined);
|
|
2443
|
-
throw
|
|
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
|
-
}
|
|
612
|
+
throw e;
|
|
2526
613
|
}
|
|
2527
614
|
}
|
|
2528
615
|
/**
|
|
2529
|
-
*
|
|
2530
|
-
*
|
|
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`.
|
|
616
|
+
* Read-only probe used by `rea doctor`. Inspects every plausible hook
|
|
617
|
+
* location and reports whether governance is active.
|
|
2535
618
|
*/
|
|
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
619
|
export async function inspectPrePushState(targetDir) {
|
|
2744
|
-
const
|
|
2745
|
-
const
|
|
2746
|
-
//
|
|
2747
|
-
//
|
|
2748
|
-
//
|
|
2749
|
-
//
|
|
2750
|
-
const
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
//
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
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
|
-
});
|
|
620
|
+
const configured = await readHooksPathFromGit(targetDir);
|
|
621
|
+
const activePathResult = await resolveTargetHookPath(targetDir);
|
|
622
|
+
// Candidate list is the active path plus the "other" canonical locations
|
|
623
|
+
// (`.husky/pre-push` and `.git/hooks/pre-push`). Deduplicate by absolute
|
|
624
|
+
// path — worktree shared hooks means two candidates can resolve to the
|
|
625
|
+
// same file.
|
|
626
|
+
const candidatePaths = new Set();
|
|
627
|
+
candidatePaths.add(activePathResult.hookPath);
|
|
628
|
+
candidatePaths.add(path.join(targetDir, '.husky', 'pre-push'));
|
|
629
|
+
// `.git/hooks/pre-push` via git's actual resolution (worktree-safe).
|
|
630
|
+
const gitHookPath = await resolveGitHookPath(targetDir, 'pre-push');
|
|
631
|
+
if (gitHookPath !== null)
|
|
632
|
+
candidatePaths.add(gitHookPath);
|
|
2771
633
|
const candidates = [];
|
|
2772
|
-
for (const p of
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
634
|
+
for (const p of candidatePaths) {
|
|
635
|
+
const cand = {
|
|
636
|
+
path: p,
|
|
637
|
+
exists: false,
|
|
638
|
+
executable: false,
|
|
639
|
+
};
|
|
2777
640
|
try {
|
|
2778
|
-
const
|
|
2779
|
-
exists =
|
|
2780
|
-
|
|
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
|
-
}
|
|
641
|
+
const st = await fsPromises.stat(p);
|
|
642
|
+
cand.exists = st.isFile();
|
|
643
|
+
cand.executable = cand.exists && (st.mode & 0o111) !== 0;
|
|
2794
644
|
}
|
|
2795
645
|
catch {
|
|
2796
|
-
//
|
|
2797
|
-
}
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
const
|
|
2811
|
-
const
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
646
|
+
// absent
|
|
647
|
+
}
|
|
648
|
+
if (cand.exists) {
|
|
649
|
+
const cls = await classifyExistingHook(p);
|
|
650
|
+
cand.kind = cls.kind;
|
|
651
|
+
cand.reaManaged =
|
|
652
|
+
cls.kind === 'rea-managed' ||
|
|
653
|
+
cls.kind === 'rea-managed-husky' ||
|
|
654
|
+
cls.kind === 'rea-managed-legacy-v1' ||
|
|
655
|
+
cls.kind === 'rea-managed-husky-legacy-v1';
|
|
656
|
+
cand.delegatesToGate = cls.kind === 'gate-delegating';
|
|
657
|
+
}
|
|
658
|
+
candidates.push(cand);
|
|
659
|
+
}
|
|
660
|
+
const active = candidates.find((c) => c.path === activePathResult.hookPath);
|
|
661
|
+
const activeIsGovernance = active !== undefined &&
|
|
662
|
+
active.exists &&
|
|
663
|
+
active.executable &&
|
|
664
|
+
(active.reaManaged === true || active.delegatesToGate === true);
|
|
665
|
+
const activeForeign = active !== undefined &&
|
|
666
|
+
active.exists &&
|
|
667
|
+
active.executable &&
|
|
668
|
+
active.reaManaged !== true &&
|
|
669
|
+
active.delegatesToGate !== true;
|
|
670
|
+
// Silence `configured` unused warning — it's semantically relevant even if
|
|
671
|
+
// we don't surface it in state today (doctor may consume later).
|
|
672
|
+
void configured;
|
|
673
|
+
return {
|
|
674
|
+
ok: activeIsGovernance,
|
|
675
|
+
activePath: activePathResult.hookPath,
|
|
676
|
+
activeForeign,
|
|
677
|
+
candidates,
|
|
678
|
+
};
|
|
2821
679
|
}
|