@bookedsolid/rea 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.husky/pre-push +22 -167
  2. package/agents/codex-adversarial.md +5 -3
  3. package/commands/codex-review.md +3 -5
  4. package/dist/audit/append.d.ts +7 -32
  5. package/dist/audit/append.js +7 -35
  6. package/dist/cli/audit.d.ts +0 -31
  7. package/dist/cli/audit.js +5 -74
  8. package/dist/cli/doctor.js +6 -16
  9. package/dist/cli/hook.d.ts +48 -0
  10. package/dist/cli/hook.js +127 -0
  11. package/dist/cli/index.js +5 -80
  12. package/dist/cli/init.js +1 -1
  13. package/dist/cli/install/gitignore.d.ts +2 -2
  14. package/dist/cli/install/gitignore.js +3 -3
  15. package/dist/cli/install/pre-push.d.ts +146 -271
  16. package/dist/cli/install/pre-push.js +471 -2633
  17. package/dist/cli/install/settings-merge.d.ts +17 -0
  18. package/dist/cli/install/settings-merge.js +48 -1
  19. package/dist/cli/upgrade.js +131 -3
  20. package/dist/config/tier-map.js +18 -25
  21. package/dist/hooks/push-gate/base.d.ts +57 -0
  22. package/dist/hooks/push-gate/base.js +77 -0
  23. package/dist/hooks/push-gate/codex-runner.d.ts +126 -0
  24. package/dist/hooks/push-gate/codex-runner.js +223 -0
  25. package/dist/hooks/push-gate/findings.d.ts +68 -0
  26. package/dist/hooks/push-gate/findings.js +142 -0
  27. package/dist/hooks/push-gate/halt.d.ts +28 -0
  28. package/dist/hooks/push-gate/halt.js +49 -0
  29. package/dist/hooks/push-gate/index.d.ts +90 -0
  30. package/dist/hooks/push-gate/index.js +351 -0
  31. package/dist/hooks/push-gate/policy.d.ts +41 -0
  32. package/dist/hooks/push-gate/policy.js +55 -0
  33. package/dist/hooks/push-gate/report.d.ts +89 -0
  34. package/dist/hooks/push-gate/report.js +140 -0
  35. package/dist/policy/loader.d.ts +10 -10
  36. package/dist/policy/loader.js +7 -6
  37. package/dist/policy/types.d.ts +31 -22
  38. package/package.json +1 -1
  39. package/dist/cache/review-cache.d.ts +0 -115
  40. package/dist/cache/review-cache.js +0 -200
  41. package/dist/cli/cache.d.ts +0 -84
  42. package/dist/cli/cache.js +0 -150
  43. package/dist/hooks/review-gate/args.d.ts +0 -126
  44. package/dist/hooks/review-gate/args.js +0 -315
  45. package/dist/hooks/review-gate/banner.d.ts +0 -97
  46. package/dist/hooks/review-gate/banner.js +0 -172
  47. package/dist/hooks/review-gate/cache-key.d.ts +0 -55
  48. package/dist/hooks/review-gate/cache-key.js +0 -41
  49. package/dist/hooks/review-gate/constants.d.ts +0 -26
  50. package/dist/hooks/review-gate/constants.js +0 -34
  51. package/dist/hooks/review-gate/errors.d.ts +0 -72
  52. package/dist/hooks/review-gate/errors.js +0 -100
  53. package/dist/hooks/review-gate/hash.d.ts +0 -43
  54. package/dist/hooks/review-gate/hash.js +0 -46
  55. package/dist/hooks/review-gate/index.d.ts +0 -21
  56. package/dist/hooks/review-gate/index.js +0 -21
  57. package/dist/hooks/review-gate/metadata.d.ts +0 -98
  58. package/dist/hooks/review-gate/metadata.js +0 -158
  59. package/dist/hooks/review-gate/policy.d.ts +0 -55
  60. package/dist/hooks/review-gate/policy.js +0 -71
  61. package/dist/hooks/review-gate/protected-paths.d.ts +0 -46
  62. package/dist/hooks/review-gate/protected-paths.js +0 -76
  63. package/hooks/_lib/push-review-core.sh +0 -1250
  64. package/hooks/commit-review-gate.sh +0 -330
  65. package/hooks/push-review-gate-git.sh +0 -94
  66. package/hooks/push-review-gate.sh +0 -92
@@ -1,1250 +0,0 @@
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. 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
- --argjson os_pid "$SKIP_OS_PID" \
439
- --argjson 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 WAIVER active (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 ' Scope: waives the protected-path Codex-audit requirement only.\n'
663
- printf ' Still active: HALT, cross-repo guard, ref-resolution,\n'
664
- printf ' push-review cache. For a full-gate bypass\n'
665
- # shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
666
- printf ' use `REA_SKIP_PUSH_REVIEW=<reason>`.\n'
667
- printf '\n'
668
- printf ' This is a gate weakening. The waiver receipt is written BEFORE\n'
669
- printf ' this banner — seeing this banner means the audit is durable.\n'
670
- printf '\n'
671
- } >&2
672
- CODEX_WAIVER_ACTIVE=1
673
- fi
674
-
675
- # ── 6. Determine source/target commits for each refspec ───────────────────
676
- # The authoritative source for which commits are being pushed is the pre-
677
- # push hook stdin contract: one line per refspec, with fields
678
- # <local_ref> <local_sha> <remote_ref> <remote_sha>
679
- # (https://git-scm.com/docs/githooks#_pre_push). We drive the gate off
680
- # those SHAs directly — NOT off HEAD — so that `git push origin hotfix:main`
681
- # from a checked-out `foo` branch reviews the `hotfix` commits, not `foo`.
682
- #
683
- # If what we read on stdin does not look like pre-push refspec lines, we
684
- # treat it as "no stdin" and use the argv fallback.
685
- local ZERO_SHA='0000000000000000000000000000000000000000'
686
- local CURRENT_BRANCH
687
- CURRENT_BRANCH=$(cd "$REA_ROOT" && git branch --show-current 2>/dev/null || echo "")
688
-
689
- # Collect refspec records. Stdin takes priority; fall back to argv parsing.
690
- local -a REFSPEC_RECORDS
691
- REFSPEC_RECORDS=()
692
- local RECORDS_OUT _rec
693
- if RECORDS_OUT=$(pr_parse_prepush_stdin "$INPUT") && [[ -n "$RECORDS_OUT" ]]; then
694
- :
695
- else
696
- RECORDS_OUT=$(pr_resolve_argv_refspecs "$CMD")
697
- fi
698
- while IFS= read -r _rec; do
699
- [[ -z "$_rec" ]] && continue
700
- REFSPEC_RECORDS+=("$_rec")
701
- done <<<"$RECORDS_OUT"
702
-
703
- if [[ "${#REFSPEC_RECORDS[@]}" -eq 0 ]]; then
704
- {
705
- printf 'PUSH BLOCKED: no push refspecs could be resolved.\n'
706
- printf ' Refusing to pass without a source commit to review.\n'
707
- } >&2
708
- exit 2
709
- fi
710
-
711
- # ── 7. Pick the source commit and merge-base to review ────────────────────
712
- # Across all refspecs, we pick the one whose source commit is furthest from
713
- # its merge-base (i.e. the largest diff). That way a mixed push like
714
- # `foo:main bar:dev` is gated on whichever refspec actually contributes new
715
- # commits. A deletion refspec (local_sha all zeros) is still concerning —
716
- # we check the remote side for protected-path changes against the merge-
717
- # base of the remote sha and the default branch, but the diff body comes
718
- # from the non-delete refspec if present. If every refspec is a delete, we
719
- # fail-closed and require an explicit review.
720
- local SOURCE_SHA="" MERGE_BASE="" TARGET_BRANCH="" SOURCE_REF=""
721
- local HAS_DELETE=0 BEST_COUNT=0
722
- local rec local_sha remote_sha local_ref remote_ref target resolved_base mb mb_status count count_status
723
- for rec in "${REFSPEC_RECORDS[@]}"; do
724
- IFS='|' read -r local_sha remote_sha local_ref remote_ref <<<"$rec"
725
- target="${remote_ref#refs/heads/}"
726
- target="${target#refs/for/}"
727
- [[ -z "$target" ]] && target="main"
728
- # Defect N: track the SEMANTIC base (the ref the diff was anchored on)
729
- # distinctly from `target` (the pushed remote ref). For a tracked branch
730
- # they coincide; for a new branch, `target` is the branch name being
731
- # created — which is NOT what we reviewed against, so `Target:` must
732
- # echo `resolved_base` instead. Default to `target` for the tracked
733
- # case; the new-branch path overrides with the resolved default_ref
734
- # short name below.
735
- resolved_base="$target"
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
- #
786
- # Defect N (0.10.1): BEFORE falling back to the remote's default branch,
787
- # consult per-branch config `branch.<source>.base`. A feature branch
788
- # targeting `dev` in a main-as-production repo would otherwise resolve
789
- # against `origin/main` silently, producing a diff that spans the entire
790
- # dev→main history — reviewers see "Scope: 28690 lines" for a 4-file
791
- # change. The git-config route uses local branch knowledge that is
792
- # authoritative for this working copy (set via `git branch --set-upstream`,
793
- # or by CI tooling that tracks the intended target). This is consulted
794
- # BEFORE origin/HEAD because the latter is a server-default that may
795
- # mis-represent the reviewer's actual intent for this specific branch.
796
- local default_ref default_ref_status configured_base source_branch
797
- source_branch="${local_ref#refs/heads/}"
798
- default_ref=""
799
- # Codex 0.10.1 finding #1: `local` is function-scoped, not loop-
800
- # iteration-scoped — without an explicit reset, iteration N inherits
801
- # iteration N-1's configured_base and falsely promotes resolved_base
802
- # when the current refspec's local_ref does NOT begin with refs/heads/
803
- # (tag push, gerrit-style refs/for/, etc.). Reset before every
804
- # potential assignment so each iteration sees a clean slate.
805
- configured_base=""
806
-
807
- if [[ -n "$source_branch" && "$source_branch" != "HEAD" ]]; then
808
- configured_base=$(cd "$REA_ROOT" && git config --get "branch.${source_branch}.base" 2>/dev/null || echo "")
809
- if [[ -n "$configured_base" ]]; then
810
- # Prefer the REMOTE-TRACKING form so the gate still anchors on a
811
- # server-authoritative ref (see the local-ref hijack explanation
812
- # above). Fall back to the local short ref only if the remote
813
- # counterpart doesn't exist, with a visible WARN on stderr — the
814
- # local ref is less trustworthy and the reviewer should know.
815
- if cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/${configured_base}" >/dev/null 2>&1; then
816
- default_ref="refs/remotes/${argv_remote}/${configured_base}"
817
- elif cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/heads/${configured_base}" >/dev/null 2>&1; then
818
- default_ref="refs/heads/${configured_base}"
819
- printf 'WARN: branch.%s.base=%s resolved to local ref; remote counterpart %s/%s missing — reviewer-side diff may be stale\n' \
820
- "$source_branch" "$configured_base" "$argv_remote" "$configured_base" >&2
821
- fi
822
- fi
823
- fi
824
-
825
- if [[ -z "$default_ref" ]]; then
826
- default_ref=$(cd "$REA_ROOT" && git symbolic-ref "refs/remotes/${argv_remote}/HEAD" 2>/dev/null)
827
- default_ref_status=$?
828
- if [[ "$default_ref_status" -ne 0 || -z "$default_ref" ]]; then
829
- # symbolic-ref failed (common on shallow or mirror clones where
830
- # origin/HEAD was never set). Probe the common default-branch names in
831
- # order: main, then master. Both are remote-tracking refs and still
832
- # server-authoritative; the order matters only for projects that still
833
- # default to `master` (older internal forks), where without this
834
- # fallback the first push of a new branch would fail closed.
835
- if cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/main" >/dev/null 2>&1; then
836
- default_ref="refs/remotes/${argv_remote}/main"
837
- elif cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/master" >/dev/null 2>&1; then
838
- default_ref="refs/remotes/${argv_remote}/master"
839
- else
840
- default_ref=""
841
- fi
842
- fi
843
- fi
844
- if [[ -n "$default_ref" ]]; then
845
- # Defect N: if operator-configured `branch.<source>.base` resolved the
846
- # ref we're about to diff against, overwrite `resolved_base` with the
847
- # short name so TARGET_BRANCH (and the Target: label) reflect the
848
- # actual review anchor. Without an explicit config override, leave
849
- # `resolved_base` at the refspec target — this preserves the cache
850
- # contract for new-branch pushes where remote_ref is the same as the
851
- # source branch (the common case) and for bare pushes that
852
- # argv-resolve via `@{upstream}`. Only operators who opted into a
853
- # per-branch base get the label promoted, keeping the change
854
- # backward-compatible for every other path.
855
- if [[ -n "$configured_base" ]]; then
856
- resolved_base="${default_ref#refs/remotes/${argv_remote}/}"
857
- resolved_base="${resolved_base#refs/heads/}"
858
- [[ -z "$resolved_base" ]] && resolved_base="$default_ref"
859
- fi
860
- mb=$(cd "$REA_ROOT" && git merge-base "$default_ref" "$local_sha" 2>/dev/null || echo "")
861
- if [[ -z "$mb" ]]; then
862
- # default_ref resolved but merge-base came back empty (unrelated
863
- # histories, grafted branch, or transient git failure). Mirror the
864
- # `.husky/pre-push` fix in 701b631 by falling through to the
865
- # empty-tree baseline rather than silently `continue`-ing (the
866
- # pre-pass-4 behavior). `continue` here combined with the
867
- # longest-diff selection below let a protected-path refspec with
868
- # an empty merge-base silently bypass the gate whenever another
869
- # refspec in the same push was selected as BEST. Flagged HIGH by
870
- # Codex pass-4 finding #1.
871
- mb='4b825dc642cb6eb9a060e54bf8d69288fbee4904'
872
- fi
873
- else
874
- # Bootstrap: no remote-tracking ref resolved. Use the well-known
875
- # empty-tree SHA as the merge-base baseline so the per-refspec diff
876
- # covers the full push content and the per-refspec protected-path
877
- # check below still runs. Prior behavior silently `continue`d here,
878
- # which — combined with the longest-diff selection accumulator at
879
- # :822-828 — let a bootstrap protected-path refspec bypass the gate
880
- # whenever a second, well-anchored refspec in the same push was
881
- # selected as BEST instead. Flagged HIGH by Codex pass-3 and fixed
882
- # for the `.husky/pre-push` side in 701b631. `git diff` accepts a
883
- # tree SHA as LHS, so :861 `git diff "${MERGE_BASE}..${SOURCE_SHA}"`
884
- # (two-dot is required — three-dot would compute an implicit
885
- # merge-base with the tree on LHS and fail) works transparently
886
- # with this baseline.
887
- mb='4b825dc642cb6eb9a060e54bf8d69288fbee4904'
888
- fi
889
- fi
890
- if [[ -z "$mb" ]]; then
891
- continue
892
- fi
893
-
894
- # Per-refspec protected-path check (Codex pass-4 finding #2). The
895
- # BEST_COUNT accumulator below selects a single winning refspec for
896
- # the general push-review gate, but the protected-path Codex audit
897
- # requirement must run on EVERY refspec — otherwise a multi-refspec
898
- # push like `git push origin big-feature:big-feature hotfix:main` can
899
- # hide a small protected-path refspec behind a larger, non-protected
900
- # one (husky's per-refspec loop at .husky/pre-push:89-175 blocks this
901
- # case; pre-pass-4 shared core did not).
902
- if [[ "$CODEX_REQUIRED" == "true" ]]; then
903
- local _refspec_hits _refspec_diff_status
904
- _refspec_hits=$(cd "$REA_ROOT" && git diff --name-status "${mb}..${local_sha}" 2>/dev/null)
905
- _refspec_diff_status=$?
906
- if [[ "$_refspec_diff_status" -ne 0 ]]; then
907
- {
908
- printf 'PUSH BLOCKED: git diff --name-status %s..%s failed (exit %s)\n' \
909
- "${mb:0:12}" "${local_sha:0:12}" "$_refspec_diff_status"
910
- printf ' Refspec: %s\n' "${local_ref:-<unknown>}"
911
- printf ' Cannot determine whether protected paths changed; refusing to pass.\n'
912
- } >&2
913
- exit 2
914
- fi
915
- if printf '%s\n' "$_refspec_hits" | awk -v re='^(src/gateway/middleware/|hooks/|[.]claude/hooks/|src/policy/|[.]github/workflows/|[.]rea/|[.]husky/)' '
916
- {
917
- status = $1
918
- if (status !~ /^[ACDMRTU]/) next
919
- for (i = 2; i <= NF; i++) {
920
- if ($i ~ re) { found = 1; next }
921
- }
922
- }
923
- END { exit found ? 0 : 1 }
924
- '; then
925
- local _audit="${REA_ROOT}/.rea/audit.jsonl"
926
- local _codex_ok=0
927
- # 0.8.0 (#85): Codex-only waiver satisfies this check without a real
928
- # audit entry. Every other gate still ran — HALT, cross-repo guard,
929
- # ref-resolution, push-review cache — and the waiver itself is
930
- # already recorded in .rea/audit.jsonl as tool_name=codex.review.skipped.
931
- if [[ "$CODEX_WAIVER_ACTIVE" == "1" ]]; then
932
- _codex_ok=1
933
- elif [[ -f "$_audit" ]]; then
934
- # Defect P (0.10.1): require .emission_source == "rea-cli" or
935
- # "codex-cli" so agents cannot forge a codex.review record by
936
- # directly calling appendAuditRecord() from an ad-hoc .mjs script
937
- # (the generic helper stamps "other"). Legacy records (pre-0.10.1)
938
- # have no emission_source field and are rejected — the first push
939
- # on an upgraded consumer requires a fresh `rea audit record
940
- # codex-review` (or Codex CLI emission) which stamps "rea-cli".
941
- #
942
- # Defect T/U (0.10.2): read the audit file as raw lines and parse
943
- # each with `fromjson?`. Before 0.10.2 this scan used
944
- # `jq -e '<filter>' "$_audit"` which feeds the file as a single
945
- # JSON stream — a single malformed line (literal backslash-u
946
- # followed by non-hex characters inside a string, for example)
947
- # makes jq bail on the stream with exit 2 and the `select` never
948
- # runs against ANY record, including legitimate codex.review
949
- # entries further down the file. The failure is total: every
950
- # cached codex.review receipt becomes unreachable until the
951
- # corrupt line is hand-edited out. `-R` flips jq into raw-input
952
- # mode (one string per line), and `fromjson?` is the error-
953
- # suppressing parser — malformed lines silently yield empty
954
- # output. The `select` filter then inspects each successfully
955
- # parsed record exactly as before, and `grep -q .` detects
956
- # whether ANY record survived the filter. Lines 1107 and the
957
- # earlier cache_result scans at :432/:612 operate on a single
958
- # printf'd JSON string, not audit.jsonl, so they remain `jq -e`.
959
- if jq -R --arg sha "$local_sha" '
960
- fromjson?
961
- | select(
962
- .tool_name == "codex.review"
963
- and .metadata.head_sha == $sha
964
- and (.metadata.verdict == "pass" or .metadata.verdict == "concerns")
965
- and (.emission_source == "rea-cli" or .emission_source == "codex-cli")
966
- )
967
- ' "$_audit" 2>/dev/null | grep -q .; then
968
- _codex_ok=1
969
- fi
970
- fi
971
- if [[ "$_codex_ok" -eq 0 ]]; then
972
- {
973
- printf 'PUSH BLOCKED: protected paths changed — /codex-review required for %s\n' "$local_sha"
974
- printf '\n'
975
- printf ' Source ref: %s\n' "${local_ref:-<unknown>}"
976
- printf ' Diff touches one of:\n'
977
- printf ' - src/gateway/middleware/\n'
978
- printf ' - hooks/\n'
979
- printf ' - .claude/hooks/\n'
980
- printf ' - src/policy/\n'
981
- printf ' - .github/workflows/\n'
982
- printf ' - .rea/\n'
983
- printf ' - .husky/\n'
984
- printf '\n'
985
- printf ' Run /codex-review against %s, then retry the push.\n' "$local_sha"
986
- printf ' The codex-adversarial agent emits the required audit entry.\n'
987
- # shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
988
- printf ' Only `pass` or `concerns` verdicts satisfy this gate.\n'
989
- printf '\n'
990
- } >&2
991
- exit 2
992
- fi
993
- fi
994
- fi
995
-
996
- count=$(cd "$REA_ROOT" && git rev-list --count "${mb}..${local_sha}" 2>/dev/null)
997
- count_status=$?
998
- if [[ "$count_status" -ne 0 ]]; then
999
- {
1000
- printf 'PUSH BLOCKED: git rev-list --count %s..%s failed (exit %s)\n' \
1001
- "${mb:0:12}" "${local_sha:0:12}" "$count_status"
1002
- printf ' Cannot size the diff; refusing to pass.\n'
1003
- } >&2
1004
- exit 2
1005
- fi
1006
- if [[ -z "$count" ]]; then
1007
- count=0
1008
- fi
1009
- if [[ -z "$SOURCE_SHA" ]] || [[ "$count" -gt "$BEST_COUNT" ]]; then
1010
- SOURCE_SHA="$local_sha"
1011
- MERGE_BASE="$mb"
1012
- # Defect N: use `resolved_base` (the actual merge-base anchor we
1013
- # diffed against), not `target` (the pushed-ref name). For tracked
1014
- # branches these are the same; for new branches without an upstream
1015
- # the distinction is the difference between "Target: <source-branch>"
1016
- # (misleading) and "Target: main" (or whichever base was resolved).
1017
- TARGET_BRANCH="$resolved_base"
1018
- SOURCE_REF="$local_ref"
1019
- BEST_COUNT="$count"
1020
- fi
1021
- done
1022
-
1023
- # Defect J (rea#61): branch-deletion guard MUST fail closed regardless of
1024
- # whether another refspec in the same push resolved a SOURCE_SHA. A mixed
1025
- # push like `git push origin safe:safe :main` iterates both refspecs; the
1026
- # safe refspec sets SOURCE_SHA from its local_sha, and the deletion refspec
1027
- # sets only HAS_DELETE=1 via its `continue` branch. If we check HAS_DELETE
1028
- # INSIDE the `-z SOURCE_SHA` fallback, the delete slips through unchecked.
1029
- # Hoist the check above the fallback so any deletion anywhere in the push
1030
- # blocks the entire push.
1031
- if [[ "$HAS_DELETE" -eq 1 ]]; then
1032
- {
1033
- printf 'PUSH BLOCKED: refspec is a branch deletion.\n'
1034
- printf '\n'
1035
- printf ' Branch deletions are sensitive operations and require explicit\n'
1036
- printf ' human action outside the agent. Perform the deletion manually.\n'
1037
- printf '\n'
1038
- } >&2
1039
- exit 2
1040
- fi
1041
-
1042
- if [[ -z "$SOURCE_SHA" || -z "$MERGE_BASE" ]]; then
1043
- {
1044
- printf 'PUSH BLOCKED: could not resolve a merge-base for any push refspec.\n'
1045
- printf '\n'
1046
- printf ' Fetch the remote and retry, or name an explicit destination.\n'
1047
- printf '\n'
1048
- } >&2
1049
- exit 2
1050
- fi
1051
-
1052
- # Capture git diff exit status explicitly.
1053
- #
1054
- # Use two-dot (`A..B`) rather than three-dot (`A...B`). Three-dot form
1055
- # computes an implicit merge-base between A and B, which FAILS when A
1056
- # is a tree (e.g. the empty-tree baseline used on bootstrap refspecs
1057
- # — see pr_parse_prepush_stdin's new-branch block). Two-dot accepts
1058
- # any revision on the left and is equivalent to `A...B` here because
1059
- # MERGE_BASE is ALREADY the merge-base of the two commit cases, so the
1060
- # implicit merge-base in three-dot would be redundant.
1061
- local DIFF_FULL DIFF_STATUS
1062
- DIFF_FULL=$(cd "$REA_ROOT" && git diff "${MERGE_BASE}..${SOURCE_SHA}" 2>/dev/null)
1063
- DIFF_STATUS=$?
1064
- if [[ "$DIFF_STATUS" -ne 0 ]]; then
1065
- {
1066
- printf 'PUSH BLOCKED: git diff %s..%s failed (exit %s)\n' \
1067
- "${MERGE_BASE:0:12}" "${SOURCE_SHA:0:12}" "$DIFF_STATUS"
1068
- printf ' Cannot compute reviewable diff; refusing to pass.\n'
1069
- } >&2
1070
- exit 2
1071
- fi
1072
-
1073
- if [[ -z "$DIFF_FULL" ]]; then
1074
- exit 0
1075
- fi
1076
-
1077
- # Defect K (rea#62): `grep -c ... || echo "0"` captures `0\n0` when grep
1078
- # exits non-zero on no-match — grep still prints its own `0` to stdout before
1079
- # exiting, and the `|| echo "0"` branch appends another. `|| true` swallows
1080
- # the non-zero exit, and `${LINE_COUNT:-0}` defaults an empty result to 0.
1081
- local LINE_COUNT
1082
- LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || true)
1083
- LINE_COUNT="${LINE_COUNT:-0}"
1084
-
1085
- # ── 7a. Protected-path Codex adversarial review gate ──────────────────────
1086
- # The per-refspec check runs inside the main loop (section 7, above) so
1087
- # that a multi-refspec push cannot hide a protected-path refspec behind
1088
- # a larger, non-protected one. See Codex pass-4 finding #2. If any
1089
- # protected-path refspec lacked a valid Codex audit, the loop already
1090
- # exited with code 2; reaching this point means every protected-path
1091
- # refspec was either clean or had an acceptable audit.
1092
-
1093
- # ── 8. Check review cache ─────────────────────────────────────────────────
1094
- # Defect L (rea#63): `shasum` is not installed on Alpine, distroless, or
1095
- # most minimal Linux CI images — only `sha256sum` is. The prior `shasum -a
1096
- # 256 ... || echo ""` chain silently produced an empty PUSH_SHA, which the
1097
- # rest of the gate treats as "no cache entry" rather than "hasher missing".
1098
- # Combined with the silent-cache-miss fallback (Defect F), every push from
1099
- # such a runner burned a full fresh codex review invisibly.
1100
- #
1101
- # Portable chain: sha256sum → shasum → openssl. The openssl branch uses
1102
- # `awk '{print $NF}'` WITHOUT `-r` — `-r` was added in OpenSSL 3.0 /
1103
- # LibreSSL 3.3+; on OpenSSL 1.1.1 (Debian 11, Ubuntu 20.04, RHEL 8,
1104
- # Amazon Linux 2, Alpine 3.13–3.14) `-r` is rejected and stdout is empty.
1105
- # `$NF` handles BOTH default output shapes: `(stdin)= <hex>` (1.1.x) and
1106
- # `<hex> *stdin` (3.x/LibreSSL coreutils-style).
1107
- #
1108
- # Hex-64 validation catches broken pipes, partial reads, or unexpected
1109
- # hasher output that would otherwise be silently cached as garbage.
1110
- local PUSH_SHA=""
1111
- if command -v sha256sum >/dev/null 2>&1; then
1112
- PUSH_SHA=$(printf '%s' "$DIFF_FULL" | sha256sum 2>/dev/null | awk '{print $1}')
1113
- elif command -v shasum >/dev/null 2>&1; then
1114
- PUSH_SHA=$(printf '%s' "$DIFF_FULL" | shasum -a 256 2>/dev/null | awk '{print $1}')
1115
- elif command -v openssl >/dev/null 2>&1; then
1116
- PUSH_SHA=$(printf '%s' "$DIFF_FULL" | openssl dgst -sha256 2>/dev/null | awk '{print $NF}')
1117
- else
1118
- printf 'rea push-review: WARN no sha256 hasher found (sha256sum/shasum/openssl); cache disabled\n' >&2
1119
- fi
1120
- if [[ -n "$PUSH_SHA" && ! "$PUSH_SHA" =~ ^[0-9a-f]{64}$ ]]; then
1121
- printf 'rea push-review: WARN hasher returned invalid output; cache disabled\n' >&2
1122
- PUSH_SHA=""
1123
- fi
1124
-
1125
- local -a REA_CLI_ARGS
1126
- REA_CLI_ARGS=()
1127
- # node_modules/.bin/rea is a launcher (pnpm writes a POSIX shell shim, npm
1128
- # writes a symlink to dist/cli/index.js with its own `#!/usr/bin/env node`
1129
- # shebang). Either way it is NOT a plain JS file, so running `node` on it
1130
- # would parse shell syntax as JavaScript and SyntaxError. Execute the shim
1131
- # directly — it handles `exec node` itself — and only prepend `node` on the
1132
- # dist fallback, which is a real JS module. The `-x` guard picks up both
1133
- # pnpm shims (executable regular file) and npm symlinks (executable target).
1134
- if [[ -x "${REA_ROOT}/node_modules/.bin/rea" ]]; then
1135
- REA_CLI_ARGS=("${REA_ROOT}/node_modules/.bin/rea")
1136
- elif [[ -f "${REA_ROOT}/dist/cli/index.js" ]]; then
1137
- REA_CLI_ARGS=(node "${REA_ROOT}/dist/cli/index.js")
1138
- fi
1139
-
1140
- # Cache-branch derivation (Codex 0.8.0 pass-2 finding #2, pass-3 finding #1):
1141
- # Use the PUSHED source ref (from pre-push stdin / bootstrap walk), not the
1142
- # checkout branch. `git push origin hotfix:main` from a `feature` checkout
1143
- # must look up a cache entry keyed on `hotfix`, not `feature`. Strip the
1144
- # `refs/heads/` prefix.
1145
- #
1146
- # Fall back to the checkout branch when SOURCE_REF is:
1147
- # • unset (defence-in-depth, not reached on any observed path), or
1148
- # • the literal string "HEAD" — emitted by pr_resolve_argv_refspecs for a
1149
- # bare `git push` with no explicit refspec. Keying a cache lookup on
1150
- # "HEAD" would force a miss on every bare push; the checkout branch
1151
- # name is the right lookup key for that workflow.
1152
- local SOURCE_BRANCH="${SOURCE_REF#refs/heads/}"
1153
- if [[ -z "$SOURCE_BRANCH" || "$SOURCE_BRANCH" == "HEAD" ]]; then
1154
- SOURCE_BRANCH="$CURRENT_BRANCH"
1155
- fi
1156
-
1157
- if [[ -n "$PUSH_SHA" ]] && [[ ${#REA_CLI_ARGS[@]} -gt 0 ]]; then
1158
- # Defect F (rea#75): distinguish cache-miss from cache-error. Prior version
1159
- # swallowed all non-zero exits and stderr into a silent `{hit:false}`, which
1160
- # masked Defect A (0.9.2 `node <shim>` SyntaxError) for weeks. Now we
1161
- # capture stderr + exit code separately and emit a visible WARN with an
1162
- # actionable filename when the CLI failed.
1163
- local CACHE_RESULT
1164
- local CACHE_STDOUT=""
1165
- local CACHE_STDERR_FILE
1166
- # SECURITY (Codex LOW 4): require mktemp. A predictable /tmp path like
1167
- # /tmp/rea-cache-err.$PID is a TOCTOU attack surface on shared hosts —
1168
- # another user can pre-create a symlink from that name to a file they
1169
- # want us to clobber. If mktemp is unavailable, fail loudly rather than
1170
- # silently falling back to a predictable path.
1171
- if ! CACHE_STDERR_FILE=$(mktemp -t rea-cache-err.XXXXXX 2>/dev/null); then
1172
- printf 'rea push-review: mktemp unavailable; cannot capture cache-check stderr. Aborting.\n' >&2
1173
- return 2
1174
- fi
1175
- local CACHE_EXIT=0
1176
- CACHE_STDOUT=$("${REA_CLI_ARGS[@]}" cache check "$PUSH_SHA" --branch "$SOURCE_BRANCH" --base "$TARGET_BRANCH" 2>"$CACHE_STDERR_FILE") || CACHE_EXIT=$?
1177
- local CACHE_STDERR=""
1178
- CACHE_STDERR=$(cat "$CACHE_STDERR_FILE" 2>/dev/null || true)
1179
- rm -f "$CACHE_STDERR_FILE"
1180
- if [[ "$CACHE_EXIT" -ne 0 ]]; then
1181
- # SECURITY (Codex LOW 5): strip C0/C1 control characters from CLI
1182
- # stderr before echoing to the terminal. A tampered dist/ or hostile
1183
- # CLI could otherwise emit OSC/CSI sequences that rewrite lines above
1184
- # the deny message and mislead the operator. We strip both C0 + DEL
1185
- # AND C1 (0x80-0x9F) — some terminal emulators still honor bare C1
1186
- # bytes as CSI introducers (0x9B) or OSC (0x9D).
1187
- local CACHE_STDERR_SAFE
1188
- CACHE_STDERR_SAFE=$(printf '%s' "$CACHE_STDERR" | LC_ALL=C tr -d '\000-\037\177\200-\237')
1189
- printf 'rea push-review: CACHE CHECK FAILED (exit=%d): %s\n' "$CACHE_EXIT" "$CACHE_STDERR_SAFE" >&2
1190
- printf 'rea push-review: treating as miss; file bookedsolidtech/rea issue if unexpected.\n' >&2
1191
- CACHE_RESULT='{"hit":false,"reason":"query_error"}'
1192
- elif [[ -z "$CACHE_STDOUT" ]]; then
1193
- CACHE_RESULT='{"hit":false,"reason":"cold"}'
1194
- else
1195
- CACHE_RESULT="$CACHE_STDOUT"
1196
- fi
1197
- # Require BOTH hit == true AND result == "pass". A cached `fail` verdict
1198
- # (Codex 0.8.0 pass-2 finding #1) must NOT satisfy the gate — cache.ts
1199
- # serializes `result` verbatim, so a negative verdict would otherwise
1200
- # slip through. Under the #85 narrowed semantic the cache is the ONLY
1201
- # way a waiver-using operator reaches exit 0, so a permissive predicate
1202
- # here would be a real security regression.
1203
- if printf '%s' "$CACHE_RESULT" | jq -e '.hit == true and .result == "pass"' >/dev/null 2>&1; then
1204
- local DISCORD_LIB="${REA_ROOT}/hooks/_lib/discord.sh"
1205
- if [ -f "$DISCORD_LIB" ]; then
1206
- # shellcheck source=/dev/null
1207
- source "$DISCORD_LIB"
1208
- discord_notify "dev" "Push passed quality gates on \`${SOURCE_BRANCH}\` -- $(cd "$REA_ROOT" && git log -1 --oneline 2>/dev/null)" "green"
1209
- fi
1210
- exit 0
1211
- fi
1212
- fi
1213
-
1214
- # ── 9. Block and request review ───────────────────────────────────────────
1215
- # Defect K (rea#62): same `0\n0` bug as LINE_COUNT above.
1216
- local FILE_COUNT
1217
- FILE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -c '^\+\+\+ ' 2>/dev/null || true)
1218
- FILE_COUNT="${FILE_COUNT:-0}"
1219
-
1220
- {
1221
- printf 'PUSH REVIEW GATE: Review required before pushing\n'
1222
- printf '\n'
1223
- printf ' Source ref: %s (%s)\n' "${SOURCE_REF:-HEAD}" "${SOURCE_SHA:0:12}"
1224
- printf ' Target: %s\n' "$TARGET_BRANCH"
1225
- printf ' Scope: %s files changed, %s lines\n' "$FILE_COUNT" "$LINE_COUNT"
1226
- printf '\n'
1227
- printf ' Action required:\n'
1228
- printf ' 1. Spawn a code-reviewer agent to review: git diff %s..%s\n' "$MERGE_BASE" "$SOURCE_SHA"
1229
- printf ' 2. Spawn a security-engineer agent for security review\n'
1230
- # Defect L (rea#63) follow-up: when no sha256 hasher is available the
1231
- # cache is disabled and PUSH_SHA is empty. Emitting `rea cache set <blank>
1232
- # pass ...` would be a dead-end — the CLI rejects the empty positional.
1233
- # Print an alternate completion path in that case. The Codex-adversarial
1234
- # review concerns list flagged this UX cliff in the 0.9.4 pass.
1235
- if [[ -n "$PUSH_SHA" ]]; then
1236
- printf ' 3. After both pass, cache the result:\n'
1237
- printf ' rea cache set %s pass --branch %s --base %s\n' "$PUSH_SHA" "$SOURCE_BRANCH" "$TARGET_BRANCH"
1238
- else
1239
- printf ' 3. Cache is DISABLED on this host (no sha256 hasher found).\n'
1240
- printf ' After both reviews pass, bypass the push-review gate with:\n'
1241
- printf ' REA_SKIP_PUSH_REVIEW="<reason>" git push ...\n'
1242
- printf ' The bypass is audited as push.review.skipped — this is the\n'
1243
- printf ' documented escape hatch when cache is unavailable.\n'
1244
- printf ' To restore the cache path, install one of: sha256sum,\n'
1245
- printf ' shasum (Perl Digest::SHA), or openssl.\n'
1246
- fi
1247
- printf '\n'
1248
- } >&2
1249
- exit 2
1250
- }