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