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