@bookedsolid/rea 0.6.2 → 0.7.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.
@@ -0,0 +1,1013 @@
1
+ #!/bin/bash
2
+ # hooks/_lib/push-review-core.sh — shared core for push-review adapters.
3
+ #
4
+ # Source, do not execute. Callers (adapters) must:
5
+ # 1. capture stdin into INPUT
6
+ # 2. source this file
7
+ # 3. call `pr_core_run "$0" "$INPUT" "$@"` (passing the adapter's own
8
+ # script path as $1, the raw stdin as $2, and the adapter's argv after)
9
+ #
10
+ # BUG-008 cleanup (0.7.0): the same core serves two physical adapters —
11
+ #
12
+ # hooks/push-review-gate.sh — Claude Code PreToolUse adapter. Stdin
13
+ # is JSON `.tool_input.command`; argv
14
+ # is empty. BUG-008 sniff handles the
15
+ # case where this hook is wired into
16
+ # `.husky/pre-push` directly and git's
17
+ # native refspec lines arrive on stdin.
18
+ #
19
+ # hooks/push-review-gate-git.sh — Native `.husky/pre-push` adapter. Stdin
20
+ # is always git's refspec contract; argv
21
+ # $1 is the remote name, $2 is the URL.
22
+ #
23
+ # Both adapters delegate here unchanged. The sniff inside `pr_core_run`
24
+ # recognizes the two stdin shapes and routes accordingly.
25
+ #
26
+ # The functions are prefixed `pr_` (push-review) so sourcing this file into
27
+ # another hook (which may already define its own helpers) is safe.
28
+
29
+ # The caller sets `set -uo pipefail`; core inherits.
30
+
31
+ # Unused-in-isolation globals that `pr_core_run` writes as locals:
32
+ # REA_ROOT, CMD, CODEX_REQUIRED, ZERO_SHA. We do NOT declare them at file
33
+ # scope — dynamic scoping means `pr_core_run`'s `local` declarations are
34
+ # visible inside the helpers it calls.
35
+
36
+ # ── pr_parse_prepush_stdin ───────────────────────────────────────────────────
37
+ # Parse git's pre-push stdin contract.
38
+ #
39
+ # Stdin shape: one line per refspec, with fields
40
+ # `<local_ref> <local_sha> <remote_ref> <remote_sha>`
41
+ # Emits one record per accepted line on stdout:
42
+ # `local_sha|remote_sha|local_ref|remote_ref`
43
+ # Returns non-zero with no output if stdin does not match the contract, so
44
+ # the caller can switch to argv fallback. Portable to macOS /bin/bash 3.2.
45
+ pr_parse_prepush_stdin() {
46
+ local raw="$1"
47
+ local accepted=0
48
+ local line local_ref local_sha remote_ref remote_sha rest
49
+ local -a records
50
+ records=()
51
+ while IFS= read -r line; do
52
+ [[ -z "$line" ]] && continue
53
+ read -r local_ref local_sha remote_ref remote_sha rest <<<"$line"
54
+ if [[ -z "$local_ref" || -z "$local_sha" || -z "$remote_ref" || -z "$remote_sha" ]]; then
55
+ continue
56
+ fi
57
+ if [[ ! "$local_sha" =~ ^[0-9a-f]{40}$ ]] || [[ ! "$remote_sha" =~ ^[0-9a-f]{40}$ ]]; then
58
+ return 1
59
+ fi
60
+ records+=("${local_sha}|${remote_sha}|${local_ref}|${remote_ref}")
61
+ accepted=1
62
+ done <<<"$raw"
63
+ if [[ "$accepted" -ne 1 ]]; then
64
+ return 1
65
+ fi
66
+ local r
67
+ for r in "${records[@]}"; do
68
+ printf '%s\n' "$r"
69
+ done
70
+ }
71
+
72
+ # ── pr_resolve_argv_refspecs ─────────────────────────────────────────────────
73
+ # Fallback refspec resolver: parse `git push [remote] [refspec...]` from the
74
+ # command string when stdin has no pre-push lines. Emits newline-separated
75
+ # records as "local_sha|remote_sha|local_ref|remote_ref" where `local_sha` is
76
+ # HEAD of the named source ref (or HEAD itself for bare refspecs) and
77
+ # `remote_sha` is zero so merge-base logic falls back to the configured
78
+ # default. Exits the script with code 2 on operator-error conditions
79
+ # (HEAD target, unresolvable source ref).
80
+ #
81
+ # Reads REA_ROOT and ZERO_SHA from the caller's function scope.
82
+ pr_resolve_argv_refspecs() {
83
+ local cmd="$1"
84
+ local segment
85
+ segment=$(printf '%s' "$cmd" | awk '
86
+ {
87
+ idx = match($0, /git[[:space:]]+push([[:space:]]|$)/)
88
+ if (!idx) exit
89
+ tail = substr($0, idx)
90
+ n = match(tail, /[;&|]|&&|\|\|/)
91
+ if (n > 0) tail = substr(tail, 1, n - 1)
92
+ print tail
93
+ }
94
+ ')
95
+
96
+ local -a specs
97
+ specs=()
98
+ local seen_push=0 remote_seen=0 delete_mode=0 tok
99
+ # shellcheck disable=SC2086
100
+ set -- $segment
101
+ for tok in "$@"; do
102
+ case "$tok" in
103
+ git|push) seen_push=1; continue ;;
104
+ --delete|-d)
105
+ delete_mode=1
106
+ continue
107
+ ;;
108
+ --delete=*)
109
+ delete_mode=1
110
+ specs+=("${tok#--delete=}")
111
+ continue
112
+ ;;
113
+ -*) continue ;;
114
+ esac
115
+ [[ "$seen_push" -eq 0 ]] && continue
116
+ if [[ "$remote_seen" -eq 0 ]]; then
117
+ remote_seen=1
118
+ continue
119
+ fi
120
+ if [[ "$delete_mode" -eq 1 ]]; then
121
+ specs+=("__REA_DELETE__${tok}")
122
+ else
123
+ specs+=("$tok")
124
+ fi
125
+ done
126
+
127
+ if [[ "${#specs[@]}" -eq 0 ]]; then
128
+ local upstream dst_ref head_sha
129
+ upstream=$(cd "$REA_ROOT" && git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || echo "")
130
+ dst_ref="refs/heads/main"
131
+ if [[ -n "$upstream" && "$upstream" == */* ]]; then
132
+ dst_ref="refs/heads/${upstream#*/}"
133
+ fi
134
+ head_sha=$(cd "$REA_ROOT" && git rev-parse HEAD 2>/dev/null || echo "")
135
+ [[ -z "$head_sha" ]] && return 1
136
+ printf '%s|%s|HEAD|%s\n' "$head_sha" "$ZERO_SHA" "$dst_ref"
137
+ return 0
138
+ fi
139
+
140
+ local spec src dst src_sha is_delete
141
+ for spec in "${specs[@]}"; do
142
+ is_delete=0
143
+ if [[ "$spec" == __REA_DELETE__* ]]; then
144
+ is_delete=1
145
+ spec="${spec#__REA_DELETE__}"
146
+ fi
147
+ spec="${spec#+}"
148
+ if [[ "$spec" == *:* ]]; then
149
+ src="${spec%%:*}"
150
+ dst="${spec##*:}"
151
+ else
152
+ src="$spec"
153
+ dst="$spec"
154
+ fi
155
+ if [[ -z "$dst" ]]; then
156
+ dst="${spec##*:}"
157
+ src=""
158
+ fi
159
+ dst="${dst#refs/heads/}"
160
+ dst="${dst#refs/for/}"
161
+ if [[ "$is_delete" -eq 1 ]]; then
162
+ if [[ -z "$dst" || "$dst" == "HEAD" ]]; then
163
+ {
164
+ printf 'PUSH BLOCKED: --delete refspec resolves to HEAD or empty (from %q)\n' "$spec"
165
+ } >&2
166
+ exit 2
167
+ fi
168
+ printf '%s|%s|(delete)|refs/heads/%s\n' "$ZERO_SHA" "$ZERO_SHA" "$dst"
169
+ continue
170
+ fi
171
+ if [[ "$dst" == "HEAD" || -z "$dst" ]]; then
172
+ {
173
+ printf 'PUSH BLOCKED: refspec resolves to HEAD (from %q)\n' "$spec"
174
+ printf '\n'
175
+ # shellcheck disable=SC2016
176
+ printf ' `git push <remote> HEAD:<branch>` or similar is almost always\n'
177
+ printf ' operator error in this context. Name the destination branch\n'
178
+ printf ' explicitly so the review gate can diff against it.\n'
179
+ printf '\n'
180
+ } >&2
181
+ exit 2
182
+ fi
183
+ if [[ -z "$src" ]]; then
184
+ printf '%s|%s|(delete)|refs/heads/%s\n' "$ZERO_SHA" "$ZERO_SHA" "$dst"
185
+ continue
186
+ fi
187
+ src_sha=$(cd "$REA_ROOT" && git rev-parse --verify "${src}^{commit}" 2>/dev/null || echo "")
188
+ if [[ -z "$src_sha" ]]; then
189
+ {
190
+ printf 'PUSH BLOCKED: could not resolve source ref %q to a commit.\n' "$src"
191
+ } >&2
192
+ exit 2
193
+ fi
194
+ printf '%s|%s|refs/heads/%s|refs/heads/%s\n' "$src_sha" "$ZERO_SHA" "$src" "$dst"
195
+ done
196
+ }
197
+
198
+ # ── pr_core_run ──────────────────────────────────────────────────────────────
199
+ # Main orchestrator. Arguments:
200
+ # $1 = adapter script path (BASH_SOURCE[0] from the adapter)
201
+ # $2 = raw stdin (INPUT) captured by the adapter
202
+ # $3..$N = adapter's original argv ($@). For git-native adapters, $3 is the
203
+ # remote name and $4 is the URL. For Claude Code, typically absent.
204
+ #
205
+ # The function may `exit 0` (allow), `exit 2` (block), or fall through to
206
+ # section 9 which prints the review prompt and exits 2.
207
+ pr_core_run() {
208
+ local adapter_script="$1"
209
+ local INPUT="$2"
210
+ shift 2
211
+ # Remaining positional args are the adapter's original argv. For a git
212
+ # native pre-push the first is the remote name; for Claude Code it is
213
+ # typically unset. Default to `origin` for BUG-008 sniff consistency.
214
+ local argv_remote="${1:-origin}"
215
+
216
+ # ── 1a. Cross-repo guard (must come FIRST — before any rea-scoped check) ──
217
+ # BUG-012 (0.6.2) — anchor the install to the SCRIPT'S OWN LOCATION on disk.
218
+ # The hook knows where it lives: installed at `<root>/.claude/hooks/<name>.sh`,
219
+ # so `<root>` is two levels up from the adapter's BASH_SOURCE. No
220
+ # caller-controlled env var participates in the trust decision.
221
+ #
222
+ # See THREAT_MODEL.md § CLAUDE_PROJECT_DIR for the full rationale.
223
+ local SCRIPT_DIR
224
+ SCRIPT_DIR="$(cd -- "$(dirname -- "$adapter_script")" && pwd -P 2>/dev/null)"
225
+ # Walk up from SCRIPT_DIR looking for `.rea/policy.yaml`. This resolves
226
+ # correctly for every reasonable topology — installed copy at
227
+ # `<root>/.claude/hooks/<name>.sh` (2 up), source-of-truth copy at
228
+ # `<root>/hooks/<name>.sh` (1 up, used when rea dogfoods itself or a
229
+ # developer runs `bash hooks/push-review-gate.sh` to smoke-test), and any
230
+ # future `hooks/_lib/` nesting. Cap at 4 levels so a stray hook dropped in
231
+ # the wrong spot fails fast instead of walking to the filesystem root.
232
+ local REA_ROOT=""
233
+ local _anchor_candidate="$SCRIPT_DIR"
234
+ local _i
235
+ for _i in 1 2 3 4; do
236
+ _anchor_candidate="$(cd -- "$_anchor_candidate/.." && pwd -P 2>/dev/null || true)"
237
+ if [[ -n "$_anchor_candidate" && -f "$_anchor_candidate/.rea/policy.yaml" ]]; then
238
+ REA_ROOT="$_anchor_candidate"
239
+ break
240
+ fi
241
+ done
242
+ if [[ -z "$REA_ROOT" ]]; then
243
+ printf 'rea-hook: no .rea/policy.yaml found within 4 parents of %s\n' \
244
+ "$SCRIPT_DIR" >&2
245
+ printf 'rea-hook: is this an installed rea hook, or is `.rea/policy.yaml`\n' >&2
246
+ printf 'rea-hook: nested more than 4 directories above the hook script?\n' >&2
247
+ exit 2
248
+ fi
249
+
250
+ # Advisory-only: warn if the caller set CLAUDE_PROJECT_DIR to a path that
251
+ # does not match the script anchor. Never let the env var override the
252
+ # decision.
253
+ if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
254
+ local CPD_REAL
255
+ CPD_REAL=$(cd -- "${CLAUDE_PROJECT_DIR}" 2>/dev/null && pwd -P 2>/dev/null || true)
256
+ if [[ -n "$CPD_REAL" && "$CPD_REAL" != "$REA_ROOT" ]]; then
257
+ printf 'rea-hook: ignoring CLAUDE_PROJECT_DIR=%s — anchoring to script location %s\n' \
258
+ "$CLAUDE_PROJECT_DIR" "$REA_ROOT" >&2
259
+ fi
260
+ fi
261
+
262
+ local CWD_REAL CWD_COMMON REA_COMMON CWD_COMMON_REAL REA_COMMON_REAL
263
+ CWD_REAL=$(pwd -P 2>/dev/null || pwd)
264
+ CWD_COMMON=$(git -C "$CWD_REAL" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
265
+ REA_COMMON=$(git -C "$REA_ROOT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
266
+ if [[ -n "$CWD_COMMON" && -n "$REA_COMMON" ]]; then
267
+ # Both sides are git checkouts. Realpath'd common-dirs match IFF they
268
+ # point at the same underlying repository (main or linked worktree).
269
+ CWD_COMMON_REAL=$(cd "$CWD_COMMON" 2>/dev/null && pwd -P 2>/dev/null || echo "$CWD_COMMON")
270
+ REA_COMMON_REAL=$(cd "$REA_COMMON" 2>/dev/null && pwd -P 2>/dev/null || echo "$REA_COMMON")
271
+ if [[ "$CWD_COMMON_REAL" != "$REA_COMMON_REAL" ]]; then
272
+ exit 0
273
+ fi
274
+ elif [[ -z "$CWD_COMMON" && -z "$REA_COMMON" ]]; then
275
+ # Both sides non-git: legitimate 0.5.1 non-git escape-hatch. Fall back to
276
+ # a literal path-prefix match. Quoted expansions prevent glob expansion.
277
+ case "$CWD_REAL/" in
278
+ "$REA_ROOT"/*|"$REA_ROOT"/) : ;; # inside rea — run the gate
279
+ *) exit 0 ;; # outside rea — not our gate
280
+ esac
281
+ fi
282
+ # Mixed state (one side git, other not) or either probe failed → fail
283
+ # CLOSED: run the gate.
284
+
285
+ # ── 2. Dependency check ───────────────────────────────────────────────────
286
+ if ! command -v jq >/dev/null 2>&1; then
287
+ printf 'REA ERROR: jq is required but not installed.\n' >&2
288
+ printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
289
+ exit 2
290
+ fi
291
+
292
+ # ── 3. HALT check ─────────────────────────────────────────────────────────
293
+ local HALT_FILE="${REA_ROOT}/.rea/HALT"
294
+ if [ -f "$HALT_FILE" ]; then
295
+ printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
296
+ "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
297
+ exit 2
298
+ fi
299
+
300
+ # ── 4. Parse command ──────────────────────────────────────────────────────
301
+ local CMD
302
+ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
303
+
304
+ # ── 4a. BUG-008: self-detect git's native pre-push contract ───────────────
305
+ # When the hook is wired into `.husky/pre-push`, git invokes it with
306
+ # `$1 = remote name`, `$2 = remote url`
307
+ # and delivers one line per refspec on stdin:
308
+ # `<local_ref> <local_sha> <remote_ref> <remote_sha>`
309
+ # The Claude Code PreToolUse wrapper instead delivers JSON on stdin, which
310
+ # is what the jq parse above targets. When jq returns empty, the stdin may
311
+ # in fact be git's pre-push ref-list — sniff the first non-blank line, and
312
+ # if it matches the `<ref> <40-hex> <ref> <40-hex>` shape, synthesize CMD
313
+ # as `git push <remote>` (from the adapter's argv_remote) so the remainder
314
+ # of the gate runs through the pre-push parser in step 6 rather than the
315
+ # argv fallback.
316
+ #
317
+ # Any other stdin shape (empty, random JSON, a non-push tool call) still
318
+ # exits 0 here — the gate is a no-op for non-push Bash calls by design.
319
+ local FIRST_STDIN_LINE
320
+ FIRST_STDIN_LINE=$(printf '%s' "$INPUT" | awk 'NF { print; exit }')
321
+ if [[ -z "$CMD" ]]; then
322
+ if [[ -n "$FIRST_STDIN_LINE" ]] \
323
+ && printf '%s' "$FIRST_STDIN_LINE" \
324
+ | grep -qE '^[^[:space:]]+[[:space:]]+[0-9a-f]{40}[[:space:]]+[^[:space:]]+[[:space:]]+[0-9a-f]{40}[[:space:]]*$'; then
325
+ CMD="git push ${argv_remote}"
326
+ else
327
+ exit 0
328
+ fi
329
+ fi
330
+
331
+ # Only trigger on git push commands
332
+ if ! printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+push'; then
333
+ exit 0
334
+ fi
335
+
336
+ # ── 5. Check if quality gates are enabled ─────────────────────────────────
337
+ local POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
338
+ if [[ -f "$POLICY_FILE" ]]; then
339
+ if grep -qE 'push_review:[[:space:]]*false' "$POLICY_FILE" 2>/dev/null; then
340
+ exit 0
341
+ fi
342
+ fi
343
+
344
+ # ── 5a. REA_SKIP_PUSH_REVIEW — whole-gate escape hatch ────────────────────
345
+ # An opt-in bypass for the ENTIRE push-review gate (not just the Codex
346
+ # branch). Exists to unblock consumers when rea itself is broken or a
347
+ # corrupt policy/audit file would otherwise deadlock a push. Requires an
348
+ # explicit non-empty reason; the value of REA_SKIP_PUSH_REVIEW is recorded
349
+ # verbatim in the audit record as the reason.
350
+ #
351
+ # Audit tool_name is `push.review.skipped`. This is intentionally NOT
352
+ # `codex.review` or `codex.review.skipped` — a skip of the whole gate is a
353
+ # separately-audited event and does not satisfy the Codex-review jq
354
+ # predicate.
355
+ if [[ -n "${REA_SKIP_PUSH_REVIEW:-}" ]]; then
356
+ local SKIP_REASON="$REA_SKIP_PUSH_REVIEW"
357
+ local AUDIT_APPEND_JS="${REA_ROOT}/dist/audit/append.js"
358
+
359
+ if [[ ! -f "$AUDIT_APPEND_JS" ]]; then
360
+ {
361
+ printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW requires rea to be built.\n'
362
+ printf '\n'
363
+ printf ' REA_SKIP_PUSH_REVIEW is set but %s is missing.\n' "$AUDIT_APPEND_JS"
364
+ printf ' Run: pnpm build\n'
365
+ printf '\n'
366
+ } >&2
367
+ exit 2
368
+ fi
369
+
370
+ # Codex F2: CI-aware refusal.
371
+ if [[ -n "${CI:-}" ]]; then
372
+ local ALLOW_CI_SKIP=""
373
+ local READ_FIELD_JS="${REA_ROOT}/dist/scripts/read-policy-field.js"
374
+ if [[ -f "$READ_FIELD_JS" ]]; then
375
+ ALLOW_CI_SKIP=$(REA_ROOT="$REA_ROOT" node "$READ_FIELD_JS" review.allow_skip_in_ci 2>/dev/null || echo "")
376
+ fi
377
+ if [[ "$ALLOW_CI_SKIP" != "true" ]]; then
378
+ {
379
+ printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW refused in CI context.\n'
380
+ printf '\n'
381
+ printf ' CI env var is set. An unauthenticated env-var bypass in a shared\n'
382
+ printf ' build agent is not trusted. To enable, set\n'
383
+ printf ' review:\n'
384
+ printf ' allow_skip_in_ci: true\n'
385
+ printf ' in .rea/policy.yaml — explicitly authorizing env-var skips in CI.\n'
386
+ printf '\n'
387
+ } >&2
388
+ exit 2
389
+ fi
390
+ fi
391
+
392
+ local SKIP_ACTOR
393
+ SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.email 2>/dev/null || echo "")
394
+ if [[ -z "$SKIP_ACTOR" ]]; then
395
+ SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.name 2>/dev/null || echo "")
396
+ fi
397
+ if [[ -z "$SKIP_ACTOR" ]]; then
398
+ {
399
+ printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW requires a git identity.\n'
400
+ printf '\n'
401
+ # shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
402
+ printf ' Neither `git config user.email` nor `git config user.name`\n'
403
+ printf ' is set. The skip audit record would have no actor; refusing\n'
404
+ printf ' to bypass without one.\n'
405
+ printf '\n'
406
+ } >&2
407
+ exit 2
408
+ fi
409
+
410
+ local SKIP_BRANCH SKIP_HEAD
411
+ SKIP_BRANCH=$(cd "$REA_ROOT" && git branch --show-current 2>/dev/null || echo "")
412
+ SKIP_HEAD=$(cd "$REA_ROOT" && git rev-parse HEAD 2>/dev/null || echo "")
413
+
414
+ # Codex F2: record OS identity alongside the (mutable, git-sourced) actor
415
+ # so downstream auditors can reconstruct who REALLY invoked the bypass on
416
+ # a shared host. None of these are forgeable from inside the push process
417
+ # alone.
418
+ local SKIP_OS_UID SKIP_OS_WHOAMI SKIP_OS_HOST SKIP_OS_PID SKIP_OS_PPID
419
+ local SKIP_OS_PPID_CMD SKIP_OS_TTY SKIP_OS_CI
420
+ SKIP_OS_UID=$(id -u 2>/dev/null || echo "")
421
+ SKIP_OS_WHOAMI=$(whoami 2>/dev/null || echo "")
422
+ SKIP_OS_HOST=$(hostname 2>/dev/null || echo "")
423
+ SKIP_OS_PID=$$
424
+ SKIP_OS_PPID=$PPID
425
+ SKIP_OS_PPID_CMD=$(ps -o command= -p "$PPID" 2>/dev/null | head -c 512 || echo "")
426
+ SKIP_OS_TTY=$(tty 2>/dev/null || echo "not-a-tty")
427
+ SKIP_OS_CI="${CI:-}"
428
+
429
+ local SKIP_METADATA
430
+ SKIP_METADATA=$(jq -n \
431
+ --arg head_sha "$SKIP_HEAD" \
432
+ --arg branch "$SKIP_BRANCH" \
433
+ --arg reason "$SKIP_REASON" \
434
+ --arg actor "$SKIP_ACTOR" \
435
+ --arg os_uid "$SKIP_OS_UID" \
436
+ --arg os_whoami "$SKIP_OS_WHOAMI" \
437
+ --arg os_hostname "$SKIP_OS_HOST" \
438
+ --arg os_pid "$SKIP_OS_PID" \
439
+ --arg os_ppid "$SKIP_OS_PPID" \
440
+ --arg os_ppid_cmd "$SKIP_OS_PPID_CMD" \
441
+ --arg os_tty "$SKIP_OS_TTY" \
442
+ --arg os_ci "$SKIP_OS_CI" \
443
+ '{
444
+ head_sha: $head_sha,
445
+ branch: $branch,
446
+ reason: $reason,
447
+ actor: $actor,
448
+ verdict: "skipped",
449
+ os_identity: {
450
+ uid: $os_uid,
451
+ whoami: $os_whoami,
452
+ hostname: $os_hostname,
453
+ pid: $os_pid,
454
+ ppid: $os_ppid,
455
+ ppid_cmd: $os_ppid_cmd,
456
+ tty: $os_tty,
457
+ ci: $os_ci
458
+ }
459
+ }' 2>/dev/null)
460
+
461
+ if [[ -z "$SKIP_METADATA" ]]; then
462
+ {
463
+ printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW could not serialize audit metadata.\n' >&2
464
+ } >&2
465
+ exit 2
466
+ fi
467
+
468
+ REA_ROOT="$REA_ROOT" REA_SKIP_METADATA="$SKIP_METADATA" \
469
+ node --input-type=module -e "
470
+ const mod = await import(process.env.REA_ROOT + '/dist/audit/append.js');
471
+ const metadata = JSON.parse(process.env.REA_SKIP_METADATA);
472
+ await mod.appendAuditRecord(process.env.REA_ROOT, {
473
+ tool_name: 'push.review.skipped',
474
+ server_name: 'rea.escape_hatch',
475
+ status: mod.InvocationStatus.Allowed,
476
+ tier: mod.Tier.Read,
477
+ metadata,
478
+ });
479
+ " 2>/dev/null
480
+ local NODE_STATUS=$?
481
+ if [[ "$NODE_STATUS" -ne 0 ]]; then
482
+ {
483
+ printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW audit-append failed (node exit %s).\n' "$NODE_STATUS"
484
+ printf ' Refusing to bypass the push gate without a receipt.\n'
485
+ } >&2
486
+ exit 2
487
+ fi
488
+
489
+ {
490
+ printf '\n'
491
+ printf '== PUSH REVIEW GATE SKIPPED via REA_SKIP_PUSH_REVIEW\n'
492
+ printf ' Reason: %s\n' "$SKIP_REASON"
493
+ printf ' Actor: %s\n' "$SKIP_ACTOR"
494
+ printf ' Branch: %s\n' "${SKIP_BRANCH:-<detached>}"
495
+ printf ' Head: %s\n' "${SKIP_HEAD:-<unknown>}"
496
+ printf ' Audited: .rea/audit.jsonl (tool_name=push.review.skipped)\n'
497
+ printf '\n'
498
+ printf ' This is a gate weakening. Every invocation is permanently audited.\n'
499
+ printf '\n'
500
+ } >&2
501
+ exit 0
502
+ fi
503
+
504
+ # ── 5b. Resolve review.codex_required (hoisted from section 7a) ───────────
505
+ # We need this BEFORE the REA_SKIP_CODEX_REVIEW check so G11.4 first-class
506
+ # no-Codex mode stays a clean no-op: when the policy says Codex is not
507
+ # required at all, there is nothing to skip, and setting
508
+ # REA_SKIP_CODEX_REVIEW must not write a skip audit record.
509
+ #
510
+ # Fail-closed: a malformed/unparseable policy is treated as
511
+ # codex_required=true so we never silently drop the Codex gate on a broken
512
+ # policy file.
513
+ local READ_FIELD_JS="${REA_ROOT}/dist/scripts/read-policy-field.js"
514
+ local CODEX_REQUIRED="true"
515
+ if [[ -f "$READ_FIELD_JS" ]]; then
516
+ local FIELD_VALUE FIELD_STATUS
517
+ FIELD_VALUE=$(REA_ROOT="$REA_ROOT" node "$READ_FIELD_JS" review.codex_required 2>/dev/null)
518
+ FIELD_STATUS=$?
519
+ case "$FIELD_STATUS" in
520
+ 0)
521
+ if [[ "$FIELD_VALUE" == "false" ]]; then
522
+ CODEX_REQUIRED="false"
523
+ elif [[ "$FIELD_VALUE" == "true" ]]; then
524
+ CODEX_REQUIRED="true"
525
+ else
526
+ printf 'REA WARN: review.codex_required resolved to non-boolean %q — treating as true\n' "$FIELD_VALUE" >&2
527
+ CODEX_REQUIRED="true"
528
+ fi
529
+ ;;
530
+ 1)
531
+ CODEX_REQUIRED="true"
532
+ ;;
533
+ *)
534
+ printf 'REA WARN: read-policy-field exited %s — treating review.codex_required as true (fail-closed)\n' "$FIELD_STATUS" >&2
535
+ CODEX_REQUIRED="true"
536
+ ;;
537
+ esac
538
+ fi
539
+
540
+ # ── 5c. REA_SKIP_CODEX_REVIEW — Codex-review bypass ───────────────────────
541
+ # Runs here (before ref-resolution) so ref-resolution failures in section 6
542
+ # do not strand an operator who has committed to the skip. See the
543
+ # adapter's file-top docstring for the ordering rationale (0.7.0).
544
+ #
545
+ # Gated on CODEX_REQUIRED=true (from section 5b): if policy explicitly opts
546
+ # into no-Codex mode, the skip is a no-op — nothing to skip, no audit noise.
547
+ if [[ -n "${REA_SKIP_CODEX_REVIEW:-}" && "$CODEX_REQUIRED" == "true" ]]; then
548
+ local SKIP_REASON="$REA_SKIP_CODEX_REVIEW"
549
+ local AUDIT_APPEND_JS="${REA_ROOT}/dist/audit/append.js"
550
+
551
+ if [[ ! -f "$AUDIT_APPEND_JS" ]]; then
552
+ {
553
+ printf 'PUSH BLOCKED: escape hatch requires rea to be built.\n'
554
+ printf '\n'
555
+ printf ' REA_SKIP_CODEX_REVIEW is set but %s is missing.\n' "$AUDIT_APPEND_JS"
556
+ printf ' Run: pnpm build\n'
557
+ printf '\n'
558
+ } >&2
559
+ exit 2
560
+ fi
561
+
562
+ local SKIP_ACTOR
563
+ SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.email 2>/dev/null || echo "")
564
+ if [[ -z "$SKIP_ACTOR" ]]; then
565
+ SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.name 2>/dev/null || echo "")
566
+ fi
567
+ if [[ -z "$SKIP_ACTOR" ]]; then
568
+ {
569
+ printf 'PUSH BLOCKED: escape hatch requires a git identity.\n'
570
+ printf '\n'
571
+ # shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
572
+ printf ' Neither `git config user.email` nor `git config user.name`\n'
573
+ printf ' is set. The skip audit record would have no actor; refusing\n'
574
+ printf ' to bypass without one.\n'
575
+ printf '\n'
576
+ } >&2
577
+ exit 2
578
+ fi
579
+
580
+ # Metadata source of truth: the pre-push stdin contract. Parse the FIRST
581
+ # well-formed refspec line from the captured INPUT so the skip audit
582
+ # record describes the actual push, not the checkout that happened to be
583
+ # active.
584
+ local SKIP_HEAD="" SKIP_TARGET="" SKIP_SOURCE=""
585
+ local SKIP_UPSTREAM
586
+
587
+ local __line __lref __lsha __rref __rsha __rest
588
+ while IFS= read -r __line; do
589
+ # shellcheck disable=SC2034 # field-splitting into named vars is the intent
590
+ read -r __lref __lsha __rref __rsha __rest <<< "$__line"
591
+ if [[ -z "$__rest" && "$__lsha" =~ ^[0-9a-f]{40}$ && -n "$__rref" ]]; then
592
+ SKIP_HEAD="$__lsha"
593
+ SKIP_TARGET="${__rref#refs/heads/}"
594
+ SKIP_SOURCE="prepush-stdin"
595
+ break
596
+ fi
597
+ done <<< "$INPUT"
598
+
599
+ if [[ -z "$SKIP_HEAD" ]]; then
600
+ SKIP_HEAD=$(cd "$REA_ROOT" && git rev-parse HEAD 2>/dev/null || echo "")
601
+ SKIP_UPSTREAM=$(cd "$REA_ROOT" && git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || echo "")
602
+ SKIP_TARGET="main"
603
+ if [[ -n "$SKIP_UPSTREAM" && "$SKIP_UPSTREAM" == */* ]]; then
604
+ SKIP_TARGET="${SKIP_UPSTREAM#*/}"
605
+ fi
606
+ SKIP_SOURCE="local-fallback"
607
+ fi
608
+
609
+ local SKIP_METADATA
610
+ SKIP_METADATA=$(jq -n \
611
+ --arg head_sha "$SKIP_HEAD" \
612
+ --arg target "$SKIP_TARGET" \
613
+ --arg reason "$SKIP_REASON" \
614
+ --arg actor "$SKIP_ACTOR" \
615
+ --arg source "$SKIP_SOURCE" \
616
+ '{
617
+ head_sha: $head_sha,
618
+ target: $target,
619
+ reason: $reason,
620
+ actor: $actor,
621
+ verdict: "skipped",
622
+ files_changed: null,
623
+ metadata_source: $source
624
+ }' 2>/dev/null)
625
+
626
+ if [[ -z "$SKIP_METADATA" ]]; then
627
+ {
628
+ printf 'PUSH BLOCKED: escape hatch could not serialize audit metadata.\n' >&2
629
+ } >&2
630
+ exit 2
631
+ fi
632
+
633
+ REA_ROOT="$REA_ROOT" REA_SKIP_METADATA="$SKIP_METADATA" \
634
+ node --input-type=module -e "
635
+ const mod = await import(process.env.REA_ROOT + '/dist/audit/append.js');
636
+ const metadata = JSON.parse(process.env.REA_SKIP_METADATA);
637
+ await mod.appendAuditRecord(process.env.REA_ROOT, {
638
+ tool_name: 'codex.review.skipped',
639
+ server_name: 'rea.escape_hatch',
640
+ status: mod.InvocationStatus.Allowed,
641
+ tier: mod.Tier.Read,
642
+ metadata,
643
+ });
644
+ " 2>/dev/null
645
+ local NODE_STATUS=$?
646
+ if [[ "$NODE_STATUS" -ne 0 ]]; then
647
+ {
648
+ printf 'PUSH BLOCKED: escape hatch audit-append failed (node exit %s).\n' "$NODE_STATUS"
649
+ printf ' Refusing to bypass the Codex-review gate without a receipt.\n'
650
+ } >&2
651
+ exit 2
652
+ fi
653
+
654
+ {
655
+ printf '\n'
656
+ printf '== CODEX REVIEW SKIPPED via REA_SKIP_CODEX_REVIEW\n'
657
+ printf ' Reason: %s\n' "$SKIP_REASON"
658
+ printf ' Actor: %s\n' "$SKIP_ACTOR"
659
+ printf ' Head SHA: %s\n' "${SKIP_HEAD:-<unknown>}"
660
+ printf ' Audited: .rea/audit.jsonl (tool_name=codex.review.skipped)\n'
661
+ printf '\n'
662
+ printf ' This is a gate weakening. Every invocation is permanently audited.\n'
663
+ printf '\n'
664
+ } >&2
665
+ exit 0
666
+ fi
667
+
668
+ # ── 6. Determine source/target commits for each refspec ───────────────────
669
+ # The authoritative source for which commits are being pushed is the pre-
670
+ # push hook stdin contract: one line per refspec, with fields
671
+ # <local_ref> <local_sha> <remote_ref> <remote_sha>
672
+ # (https://git-scm.com/docs/githooks#_pre_push). We drive the gate off
673
+ # those SHAs directly — NOT off HEAD — so that `git push origin hotfix:main`
674
+ # from a checked-out `foo` branch reviews the `hotfix` commits, not `foo`.
675
+ #
676
+ # If what we read on stdin does not look like pre-push refspec lines, we
677
+ # treat it as "no stdin" and use the argv fallback.
678
+ local ZERO_SHA='0000000000000000000000000000000000000000'
679
+ local CURRENT_BRANCH
680
+ CURRENT_BRANCH=$(cd "$REA_ROOT" && git branch --show-current 2>/dev/null || echo "")
681
+
682
+ # Collect refspec records. Stdin takes priority; fall back to argv parsing.
683
+ local -a REFSPEC_RECORDS
684
+ REFSPEC_RECORDS=()
685
+ local RECORDS_OUT _rec
686
+ if RECORDS_OUT=$(pr_parse_prepush_stdin "$INPUT") && [[ -n "$RECORDS_OUT" ]]; then
687
+ :
688
+ else
689
+ RECORDS_OUT=$(pr_resolve_argv_refspecs "$CMD")
690
+ fi
691
+ while IFS= read -r _rec; do
692
+ [[ -z "$_rec" ]] && continue
693
+ REFSPEC_RECORDS+=("$_rec")
694
+ done <<<"$RECORDS_OUT"
695
+
696
+ if [[ "${#REFSPEC_RECORDS[@]}" -eq 0 ]]; then
697
+ {
698
+ printf 'PUSH BLOCKED: no push refspecs could be resolved.\n'
699
+ printf ' Refusing to pass without a source commit to review.\n'
700
+ } >&2
701
+ exit 2
702
+ fi
703
+
704
+ # ── 7. Pick the source commit and merge-base to review ────────────────────
705
+ # Across all refspecs, we pick the one whose source commit is furthest from
706
+ # its merge-base (i.e. the largest diff). That way a mixed push like
707
+ # `foo:main bar:dev` is gated on whichever refspec actually contributes new
708
+ # commits. A deletion refspec (local_sha all zeros) is still concerning —
709
+ # we check the remote side for protected-path changes against the merge-
710
+ # base of the remote sha and the default branch, but the diff body comes
711
+ # from the non-delete refspec if present. If every refspec is a delete, we
712
+ # fail-closed and require an explicit review.
713
+ local SOURCE_SHA="" MERGE_BASE="" TARGET_BRANCH="" SOURCE_REF=""
714
+ local HAS_DELETE=0 BEST_COUNT=0
715
+ local rec local_sha remote_sha local_ref remote_ref target mb mb_status count count_status
716
+ for rec in "${REFSPEC_RECORDS[@]}"; do
717
+ IFS='|' read -r local_sha remote_sha local_ref remote_ref <<<"$rec"
718
+ target="${remote_ref#refs/heads/}"
719
+ target="${target#refs/for/}"
720
+ [[ -z "$target" ]] && target="main"
721
+
722
+ if [[ "$local_sha" == "$ZERO_SHA" ]]; then
723
+ HAS_DELETE=1
724
+ continue
725
+ fi
726
+
727
+ if [[ "$remote_sha" != "$ZERO_SHA" ]]; then
728
+ if ! (cd "$REA_ROOT" && git cat-file -e "${remote_sha}^{commit}" 2>/dev/null); then
729
+ {
730
+ printf 'PUSH BLOCKED: remote object %s is not in the local object DB.\n' "$remote_sha"
731
+ printf '\n'
732
+ printf ' The gate cannot compute a review diff without it. Fetch the\n'
733
+ printf ' remote and retry:\n'
734
+ printf '\n'
735
+ printf ' git fetch origin\n'
736
+ printf ' # then retry the push\n'
737
+ printf '\n'
738
+ } >&2
739
+ exit 2
740
+ fi
741
+ mb=$(cd "$REA_ROOT" && git merge-base "$remote_sha" "$local_sha" 2>/dev/null)
742
+ mb_status=$?
743
+ if [[ "$mb_status" -ne 0 || -z "$mb" ]]; then
744
+ {
745
+ printf 'PUSH BLOCKED: no merge-base between remote %s and local %s\n' \
746
+ "${remote_sha:0:12}" "${local_sha:0:12}"
747
+ printf ' The two histories are unrelated; refusing to pass without a\n'
748
+ printf ' reviewable diff.\n'
749
+ } >&2
750
+ exit 2
751
+ fi
752
+ else
753
+ # New branch (remote_sha == ZERO). `target` is the REMOTE ref name (the
754
+ # branch being created on origin), not a sensible merge-base anchor:
755
+ # if the local repo already has a branch by that name pointing at
756
+ # `local_sha`, `git merge-base <target> <local_sha>` returns `local_sha`
757
+ # — collapsing the reviewable diff to empty and silently bypassing the
758
+ # gate.
759
+ #
760
+ # We MUST anchor on a REMOTE-TRACKING ref (e.g. refs/remotes/origin/main),
761
+ # not the bare branch name. Bare `main` resolves to the local short ref
762
+ # `refs/heads/main`, which the pusher controls — a local `main` that has
763
+ # been fast-forwarded to contain the feature tip (or a rebased topic
764
+ # branch) would give `merge-base main <local_sha> == local_sha`, silently
765
+ # passing the gate. Remote-tracking refs are server-authoritative from
766
+ # the most recent fetch, so they cannot be tampered with locally.
767
+ #
768
+ # argv_remote is set from the adapter's argv (git passes the remote name
769
+ # as $1 on pre-push); defaults to "origin" when absent (BUG-008 sniff).
770
+ local default_ref default_ref_status
771
+ default_ref=$(cd "$REA_ROOT" && git symbolic-ref "refs/remotes/${argv_remote}/HEAD" 2>/dev/null)
772
+ default_ref_status=$?
773
+ if [[ "$default_ref_status" -ne 0 || -z "$default_ref" ]]; then
774
+ # symbolic-ref failed (common on shallow or mirror clones where
775
+ # origin/HEAD was never set). Probe the common default-branch names in
776
+ # order: main, then master. Both are remote-tracking refs and still
777
+ # server-authoritative; the order matters only for projects that still
778
+ # default to `master` (older internal forks), where without this
779
+ # fallback the first push of a new branch would fail closed.
780
+ if cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/main" >/dev/null 2>&1; then
781
+ default_ref="refs/remotes/${argv_remote}/main"
782
+ elif cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/master" >/dev/null 2>&1; then
783
+ default_ref="refs/remotes/${argv_remote}/master"
784
+ else
785
+ default_ref=""
786
+ fi
787
+ fi
788
+ if [[ -n "$default_ref" ]]; then
789
+ mb=$(cd "$REA_ROOT" && git merge-base "$default_ref" "$local_sha" 2>/dev/null || echo "")
790
+ if [[ -z "$mb" ]]; then
791
+ # default_ref resolved but merge-base came back empty (unrelated
792
+ # histories, grafted branch, or transient git failure). Mirror the
793
+ # `.husky/pre-push` fix in 701b631 by falling through to the
794
+ # empty-tree baseline rather than silently `continue`-ing (the
795
+ # pre-pass-4 behavior). `continue` here combined with the
796
+ # longest-diff selection below let a protected-path refspec with
797
+ # an empty merge-base silently bypass the gate whenever another
798
+ # refspec in the same push was selected as BEST. Flagged HIGH by
799
+ # Codex pass-4 finding #1.
800
+ mb='4b825dc642cb6eb9a060e54bf8d69288fbee4904'
801
+ fi
802
+ else
803
+ # Bootstrap: no remote-tracking ref resolved. Use the well-known
804
+ # empty-tree SHA as the merge-base baseline so the per-refspec diff
805
+ # covers the full push content and the per-refspec protected-path
806
+ # check below still runs. Prior behavior silently `continue`d here,
807
+ # which — combined with the longest-diff selection accumulator at
808
+ # :822-828 — let a bootstrap protected-path refspec bypass the gate
809
+ # whenever a second, well-anchored refspec in the same push was
810
+ # selected as BEST instead. Flagged HIGH by Codex pass-3 and fixed
811
+ # for the `.husky/pre-push` side in 701b631. `git diff` accepts a
812
+ # tree SHA as LHS, so :861 `git diff "${MERGE_BASE}..${SOURCE_SHA}"`
813
+ # (two-dot is required — three-dot would compute an implicit
814
+ # merge-base with the tree on LHS and fail) works transparently
815
+ # with this baseline.
816
+ mb='4b825dc642cb6eb9a060e54bf8d69288fbee4904'
817
+ fi
818
+ fi
819
+ if [[ -z "$mb" ]]; then
820
+ continue
821
+ fi
822
+
823
+ # Per-refspec protected-path check (Codex pass-4 finding #2). The
824
+ # BEST_COUNT accumulator below selects a single winning refspec for
825
+ # the general push-review gate, but the protected-path Codex audit
826
+ # requirement must run on EVERY refspec — otherwise a multi-refspec
827
+ # push like `git push origin big-feature:big-feature hotfix:main` can
828
+ # hide a small protected-path refspec behind a larger, non-protected
829
+ # one (husky's per-refspec loop at .husky/pre-push:89-175 blocks this
830
+ # case; pre-pass-4 shared core did not).
831
+ if [[ "$CODEX_REQUIRED" == "true" ]]; then
832
+ local _refspec_hits _refspec_diff_status
833
+ _refspec_hits=$(cd "$REA_ROOT" && git diff --name-status "${mb}..${local_sha}" 2>/dev/null)
834
+ _refspec_diff_status=$?
835
+ if [[ "$_refspec_diff_status" -ne 0 ]]; then
836
+ {
837
+ printf 'PUSH BLOCKED: git diff --name-status %s..%s failed (exit %s)\n' \
838
+ "${mb:0:12}" "${local_sha:0:12}" "$_refspec_diff_status"
839
+ printf ' Refspec: %s\n' "${local_ref:-<unknown>}"
840
+ printf ' Cannot determine whether protected paths changed; refusing to pass.\n'
841
+ } >&2
842
+ exit 2
843
+ fi
844
+ if printf '%s\n' "$_refspec_hits" | awk -v re='^(src/gateway/middleware/|hooks/|[.]claude/hooks/|src/policy/|[.]github/workflows/)' '
845
+ {
846
+ status = $1
847
+ if (status !~ /^[ACDMRTU]/) next
848
+ for (i = 2; i <= NF; i++) {
849
+ if ($i ~ re) { found = 1; next }
850
+ }
851
+ }
852
+ END { exit found ? 0 : 1 }
853
+ '; then
854
+ local _audit="${REA_ROOT}/.rea/audit.jsonl"
855
+ local _codex_ok=0
856
+ if [[ -f "$_audit" ]]; then
857
+ if jq -e --arg sha "$local_sha" '
858
+ select(
859
+ .tool_name == "codex.review"
860
+ and .metadata.head_sha == $sha
861
+ and (.metadata.verdict == "pass" or .metadata.verdict == "concerns")
862
+ )
863
+ ' "$_audit" >/dev/null 2>&1; then
864
+ _codex_ok=1
865
+ fi
866
+ fi
867
+ if [[ "$_codex_ok" -eq 0 ]]; then
868
+ {
869
+ printf 'PUSH BLOCKED: protected paths changed — /codex-review required for %s\n' "$local_sha"
870
+ printf '\n'
871
+ printf ' Source ref: %s\n' "${local_ref:-<unknown>}"
872
+ printf ' Diff touches one of:\n'
873
+ printf ' - src/gateway/middleware/\n'
874
+ printf ' - hooks/\n'
875
+ printf ' - .claude/hooks/\n'
876
+ printf ' - src/policy/\n'
877
+ printf ' - .github/workflows/\n'
878
+ printf '\n'
879
+ printf ' Run /codex-review against %s, then retry the push.\n' "$local_sha"
880
+ printf ' The codex-adversarial agent emits the required audit entry.\n'
881
+ # shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
882
+ printf ' Only `pass` or `concerns` verdicts satisfy this gate.\n'
883
+ printf '\n'
884
+ } >&2
885
+ exit 2
886
+ fi
887
+ fi
888
+ fi
889
+
890
+ count=$(cd "$REA_ROOT" && git rev-list --count "${mb}..${local_sha}" 2>/dev/null)
891
+ count_status=$?
892
+ if [[ "$count_status" -ne 0 ]]; then
893
+ {
894
+ printf 'PUSH BLOCKED: git rev-list --count %s..%s failed (exit %s)\n' \
895
+ "${mb:0:12}" "${local_sha:0:12}" "$count_status"
896
+ printf ' Cannot size the diff; refusing to pass.\n'
897
+ } >&2
898
+ exit 2
899
+ fi
900
+ if [[ -z "$count" ]]; then
901
+ count=0
902
+ fi
903
+ if [[ -z "$SOURCE_SHA" ]] || [[ "$count" -gt "$BEST_COUNT" ]]; then
904
+ SOURCE_SHA="$local_sha"
905
+ MERGE_BASE="$mb"
906
+ TARGET_BRANCH="$target"
907
+ SOURCE_REF="$local_ref"
908
+ BEST_COUNT="$count"
909
+ fi
910
+ done
911
+
912
+ if [[ -z "$SOURCE_SHA" || -z "$MERGE_BASE" ]]; then
913
+ if [[ "$HAS_DELETE" -eq 1 ]]; then
914
+ {
915
+ printf 'PUSH BLOCKED: refspec is a branch deletion.\n'
916
+ printf '\n'
917
+ printf ' Branch deletions are sensitive operations and require explicit\n'
918
+ printf ' human action outside the agent. Perform the deletion manually.\n'
919
+ printf '\n'
920
+ } >&2
921
+ exit 2
922
+ fi
923
+ {
924
+ printf 'PUSH BLOCKED: could not resolve a merge-base for any push refspec.\n'
925
+ printf '\n'
926
+ printf ' Fetch the remote and retry, or name an explicit destination.\n'
927
+ printf '\n'
928
+ } >&2
929
+ exit 2
930
+ fi
931
+
932
+ # Capture git diff exit status explicitly.
933
+ #
934
+ # Use two-dot (`A..B`) rather than three-dot (`A...B`). Three-dot form
935
+ # computes an implicit merge-base between A and B, which FAILS when A
936
+ # is a tree (e.g. the empty-tree baseline used on bootstrap refspecs
937
+ # — see pr_parse_prepush_stdin's new-branch block). Two-dot accepts
938
+ # any revision on the left and is equivalent to `A...B` here because
939
+ # MERGE_BASE is ALREADY the merge-base of the two commit cases, so the
940
+ # implicit merge-base in three-dot would be redundant.
941
+ local DIFF_FULL DIFF_STATUS
942
+ DIFF_FULL=$(cd "$REA_ROOT" && git diff "${MERGE_BASE}..${SOURCE_SHA}" 2>/dev/null)
943
+ DIFF_STATUS=$?
944
+ if [[ "$DIFF_STATUS" -ne 0 ]]; then
945
+ {
946
+ printf 'PUSH BLOCKED: git diff %s..%s failed (exit %s)\n' \
947
+ "${MERGE_BASE:0:12}" "${SOURCE_SHA:0:12}" "$DIFF_STATUS"
948
+ printf ' Cannot compute reviewable diff; refusing to pass.\n'
949
+ } >&2
950
+ exit 2
951
+ fi
952
+
953
+ if [[ -z "$DIFF_FULL" ]]; then
954
+ exit 0
955
+ fi
956
+
957
+ local LINE_COUNT
958
+ LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || echo "0")
959
+
960
+ # ── 7a. Protected-path Codex adversarial review gate ──────────────────────
961
+ # The per-refspec check runs inside the main loop (section 7, above) so
962
+ # that a multi-refspec push cannot hide a protected-path refspec behind
963
+ # a larger, non-protected one. See Codex pass-4 finding #2. If any
964
+ # protected-path refspec lacked a valid Codex audit, the loop already
965
+ # exited with code 2; reaching this point means every protected-path
966
+ # refspec was either clean or had an acceptable audit.
967
+
968
+ # ── 8. Check review cache ─────────────────────────────────────────────────
969
+ local PUSH_SHA
970
+ PUSH_SHA=$(printf '%s' "$DIFF_FULL" | shasum -a 256 | cut -d' ' -f1 2>/dev/null || echo "")
971
+
972
+ local -a REA_CLI_ARGS
973
+ REA_CLI_ARGS=()
974
+ if [[ -f "${REA_ROOT}/node_modules/.bin/rea" ]]; then
975
+ REA_CLI_ARGS=(node "${REA_ROOT}/node_modules/.bin/rea")
976
+ elif [[ -f "${REA_ROOT}/dist/cli/index.js" ]]; then
977
+ REA_CLI_ARGS=(node "${REA_ROOT}/dist/cli/index.js")
978
+ fi
979
+
980
+ if [[ -n "$PUSH_SHA" ]] && [[ ${#REA_CLI_ARGS[@]} -gt 0 ]]; then
981
+ local CACHE_RESULT
982
+ CACHE_RESULT=$("${REA_CLI_ARGS[@]}" cache check "$PUSH_SHA" --branch "$CURRENT_BRANCH" --base "$TARGET_BRANCH" 2>/dev/null || echo '{"hit":false}')
983
+ if printf '%s' "$CACHE_RESULT" | jq -e '.hit == true' >/dev/null 2>&1; then
984
+ local DISCORD_LIB="${REA_ROOT}/hooks/_lib/discord.sh"
985
+ if [ -f "$DISCORD_LIB" ]; then
986
+ # shellcheck source=/dev/null
987
+ source "$DISCORD_LIB"
988
+ discord_notify "dev" "Push passed quality gates on \`${CURRENT_BRANCH}\` -- $(cd "$REA_ROOT" && git log -1 --oneline 2>/dev/null)" "green"
989
+ fi
990
+ exit 0
991
+ fi
992
+ fi
993
+
994
+ # ── 9. Block and request review ───────────────────────────────────────────
995
+ local FILE_COUNT
996
+ FILE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -c '^\+\+\+ ' 2>/dev/null || echo "0")
997
+
998
+ {
999
+ printf 'PUSH REVIEW GATE: Review required before pushing\n'
1000
+ printf '\n'
1001
+ printf ' Source ref: %s (%s)\n' "${SOURCE_REF:-HEAD}" "${SOURCE_SHA:0:12}"
1002
+ printf ' Target: %s\n' "$TARGET_BRANCH"
1003
+ printf ' Scope: %s files changed, %s lines\n' "$FILE_COUNT" "$LINE_COUNT"
1004
+ printf '\n'
1005
+ printf ' Action required:\n'
1006
+ printf ' 1. Spawn a code-reviewer agent to review: git diff %s..%s\n' "$MERGE_BASE" "$SOURCE_SHA"
1007
+ printf ' 2. Spawn a security-engineer agent for security review\n'
1008
+ printf ' 3. After both pass, cache the result:\n'
1009
+ printf ' rea cache set %s pass --branch %s --base %s\n' "$PUSH_SHA" "$CURRENT_BRANCH" "$TARGET_BRANCH"
1010
+ printf '\n'
1011
+ } >&2
1012
+ exit 2
1013
+ }