@bookedsolid/rea 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,14 +8,38 @@
8
8
  # 0 = allow (no meaningful diff, or review cached, or escape hatch invoked)
9
9
  # 2 = block (needs review, or escape hatch invoked but audit-append failed)
10
10
  #
11
- # ── Escape hatch: REA_SKIP_CODEX_REVIEW ──────────────────────────────────────
12
- # Env var `REA_SKIP_CODEX_REVIEW=<reason>` bypasses the protected-path Codex
13
- # adversarial-review requirement. Set to any non-empty value; the value IS
14
- # the reason recorded in the audit record (no default reason is supplied
15
- # if the operator sets `REA_SKIP_CODEX_REVIEW=1` the reason is literally "1").
11
+ # ── Architecture (0.7.0 BUG-008 cleanup) ─────────────────────────────────────
12
+ # This file is now a thin ADAPTER. All logic lives in
13
+ # `hooks/_lib/push-review-core.sh` (see `pr_core_run`). The adapter's only
14
+ # job is to (a) capture stdin, and (b) hand its own script path + stdin +
15
+ # argv to the core so the cross-repo anchor walks up from the RIGHT script
16
+ # location.
17
+ #
18
+ # Two adapters share the core:
19
+ # - push-review-gate.sh ← this file, Claude Code PreToolUse stdin (JSON)
20
+ # - push-review-gate-git.sh ← native `.husky/pre-push` stdin (git refspec)
21
+ # The core's BUG-008 sniff makes either stdin shape work from either adapter,
22
+ # so in practice a consumer can wire THIS file into `.husky/pre-push` and it
23
+ # just works. The `-git` adapter exists for clarity of install intent.
16
24
  #
17
- # The hatch ONLY applies when the diff would otherwise require Codex review
18
- # (i.e. touches a protected path). Unprotected pushes are not affected.
25
+ # ── Escape hatch: REA_SKIP_CODEX_REVIEW ──────────────────────────────────────
26
+ # Env var `REA_SKIP_CODEX_REVIEW=<reason>` bypasses the Codex adversarial-
27
+ # review requirement. Set to any non-empty value; the value IS the reason
28
+ # recorded in the audit record (no default reason is supplied — if the
29
+ # operator sets `REA_SKIP_CODEX_REVIEW=1` the reason is literally "1").
30
+ #
31
+ # ORDERING (0.7.0): the hatch fires AFTER the HALT check but BEFORE ref-
32
+ # resolution and protected-path detection. Prior to 0.7.0 the check ran
33
+ # inside the protected-path branch and only fired when the diff touched a
34
+ # protected path — which meant an operator who wanted to skip Codex review
35
+ # got blocked by a transient ref-resolution failure (missing remote object,
36
+ # unresolvable source ref, etc.) before the skip ever fired. The new
37
+ # ordering mirrors REA_SKIP_PUSH_REVIEW: if the operator has committed to
38
+ # the bypass (accepting the audit record), ref-resolution failures should
39
+ # not strand the skip. Tradeoff: the skip now fires on every push when set,
40
+ # not just protected-path pushes. The audit receipt makes the operator
41
+ # accountable either way, and REA_SKIP_CODEX_REVIEW keeps its distinct
42
+ # tool_name so it never satisfies the `codex.review` jq predicate.
19
43
  #
20
44
  # Every invocation appends a `tool_name: "codex.review.skipped"` record to
21
45
  # `.rea/audit.jsonl` via the public audit helper. This record is intentionally
@@ -27,944 +51,27 @@
27
51
  # - `dist/audit/append.js` missing → exit 2 (build rea first)
28
52
  # - Node invocation failure → exit 2
29
53
  # - Unable to resolve actor from git config → exit 2
30
- #
31
- # Tracked under G11.1 on the 0.3.0 plan (solidifying features). G11.2–G11.5
32
- # (pluggable reviewer, availability probe, no-Codex first-class config,
33
- # rate-limit telemetry) are future work and are NOT implemented here.
34
54
 
35
55
  set -uo pipefail
36
56
 
37
- # ── 1. Read ALL stdin immediately ─────────────────────────────────────────────
57
+ # Read ALL stdin immediately. The core's BUG-008 sniff decides whether this
58
+ # is Claude Code JSON or git's native pre-push refspec list.
38
59
  INPUT=$(cat)
39
60
 
40
- # ── 1a. Cross-repo guard (must come FIRST before any rea-scoped check) ──────
41
- # When CLAUDE_PROJECT_DIR points to the rea repo (the Claude Code session's
42
- # project directory) but the current working directory is a DIFFERENT
43
- # repository, this hook is firing for someone else's push. rea's gate only
44
- # owns pushes from within rea itself — exit 0 so the foreign repo's
45
- # `git push` proceeds unblocked.
46
- #
47
- # MUST run before the jq check and HALT check. Those are rea-scoped concerns:
48
- # a missing-jq or HALT-frozen state in rea must not block pushes in OTHER
49
- # repos that merely share a Claude Code session with rea. Fixing that
50
- # governance-scope leak is half the point of this guard.
51
- #
52
- # Also: without this guard, ref-resolution inside `resolve_argv_refspecs`
53
- # runs `git rev-parse` inside REA_ROOT for refs that only exist in the
54
- # foreign repo, which hard-fails with "could not resolve source ref". That
55
- # failure lands BEFORE REA_SKIP_PUSH_REVIEW / REA_SKIP_CODEX_REVIEW can be
56
- # checked, so consumers are left with no documented way out. Discovered
57
- # during the 0.6.0 cross-repo consumer upgrade; fixed in 0.6.1.
58
- #
59
- # Repo-identity comparison via shared `--git-common-dir`, NOT path-prefix or
60
- # `--show-toplevel`. Why common-dir: a linked worktree created by
61
- # `git worktree add` has a different toplevel (different checkout path) but
62
- # the SAME repository — shared object DB, shared refs, shared HEAD history.
63
- # Any `.claude/worktrees/*` checkout of rea IS rea and must run the gate.
64
- # `--show-toplevel` would falsely flag those worktrees as "foreign" and
65
- # bypass HALT plus every other gate (Codex R3 finding, 0.6.1).
66
- #
67
- # `--path-format=absolute` (Git ≥ 2.31, March 2021) normalizes the common
68
- # dir so the same repo's common-dir is equal regardless of which worktree
69
- # asked. Engines pin Node ≥20 which ships with a recent-enough Git for dev.
70
- #
71
- # Falls back to path-prefix when either cwd or REA_ROOT is not a git
72
- # checkout (the rea 0.5.1 non-git escape-hatch scenario).
73
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
74
- if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
75
- CWD_REAL=$(pwd -P 2>/dev/null || pwd)
76
- if REA_REAL=$(cd "$REA_ROOT" 2>/dev/null && pwd -P 2>/dev/null); then
77
- CWD_COMMON=$(git -C "$CWD_REAL" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
78
- REA_COMMON=$(git -C "$REA_REAL" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
79
- if [[ -n "$CWD_COMMON" && -n "$REA_COMMON" ]]; then
80
- # Both sides are git checkouts. Realpath'd common-dirs match IFF they
81
- # point at the same underlying repository (main or linked worktree).
82
- CWD_COMMON_REAL=$(cd "$CWD_COMMON" 2>/dev/null && pwd -P 2>/dev/null || echo "$CWD_COMMON")
83
- REA_COMMON_REAL=$(cd "$REA_COMMON" 2>/dev/null && pwd -P 2>/dev/null || echo "$REA_COMMON")
84
- if [[ "$CWD_COMMON_REAL" != "$REA_COMMON_REAL" ]]; then
85
- exit 0
86
- fi
87
- else
88
- # Non-git-repo path: literal quoted expansions — no glob expansion.
89
- case "$CWD_REAL/" in
90
- "$REA_REAL"/*|"$REA_REAL"/) : ;; # inside rea — run the gate
91
- *) exit 0 ;; # outside rea — not our gate
92
- esac
93
- fi
94
- fi
95
- fi
96
-
97
- # ── 2. Dependency check ──────────────────────────────────────────────────────
98
- if ! command -v jq >/dev/null 2>&1; then
99
- printf 'REA ERROR: jq is required but not installed.\n' >&2
100
- printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
61
+ # Resolve the core library from this adapter's own on-disk location. Using
62
+ # BASH_SOURCE (not argv $0) so `bash hooks/push-review-gate.sh` and
63
+ # `.../.claude/hooks/push-review-gate.sh` both find `_lib/` next to the
64
+ # adapter. Consistent with the BUG-012 script-anchor rationale in core.
65
+ _adapter_script="${BASH_SOURCE[0]:-$0}"
66
+ _adapter_dir="$(cd -- "$(dirname -- "$_adapter_script")" && pwd -P 2>/dev/null)"
67
+ _core_lib="${_adapter_dir}/_lib/push-review-core.sh"
68
+ if [[ ! -f "$_core_lib" ]]; then
69
+ printf 'rea-hook: push-review-core.sh not found next to %s\n' \
70
+ "$_adapter_script" >&2
71
+ printf 'rea-hook: expected at %s\n' "$_core_lib" >&2
101
72
  exit 2
102
73
  fi
74
+ # shellcheck source=_lib/push-review-core.sh
75
+ source "$_core_lib"
103
76
 
104
- # ── 3. HALT check ────────────────────────────────────────────────────────────
105
- HALT_FILE="${REA_ROOT}/.rea/HALT"
106
- if [ -f "$HALT_FILE" ]; then
107
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
108
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
109
- exit 2
110
- fi
111
-
112
- # ── 4. Parse command ──────────────────────────────────────────────────────────
113
- CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
114
-
115
- # ── 4a. BUG-008: self-detect git's native pre-push contract ───────────────────
116
- # When the hook is wired into `.husky/pre-push`, git invokes it with
117
- # `$1 = remote name`, `$2 = remote url`
118
- # and delivers one line per refspec on stdin:
119
- # `<local_ref> <local_sha> <remote_ref> <remote_sha>`
120
- # The Claude Code PreToolUse wrapper instead delivers JSON on stdin, which is
121
- # what the jq parse above targets. When jq returns empty, the stdin may in
122
- # fact be git's pre-push ref-list — sniff the first non-blank line, and if it
123
- # matches the `<ref> <40-hex> <ref> <40-hex>` shape, synthesize CMD as
124
- # `git push <remote>` (from argv $1) so the remainder of the gate runs
125
- # through the pre-push parser in step 6 rather than the argv fallback.
126
- #
127
- # Any other stdin shape (empty, random JSON, a non-push tool call) still
128
- # exits 0 here — the gate is a no-op for non-push Bash calls by design.
129
- FIRST_STDIN_LINE=$(printf '%s' "$INPUT" | awk 'NF { print; exit }')
130
- if [[ -z "$CMD" ]]; then
131
- if [[ -n "$FIRST_STDIN_LINE" ]] \
132
- && printf '%s' "$FIRST_STDIN_LINE" \
133
- | grep -qE '^[^[:space:]]+[[:space:]]+[0-9a-f]{40}[[:space:]]+[^[:space:]]+[[:space:]]+[0-9a-f]{40}[[:space:]]*$'; then
134
- # Git native pre-push path. Remote comes from argv $1 — falls back to
135
- # `origin` for safety if the hook was invoked without arguments.
136
- CMD="git push ${1:-origin}"
137
- else
138
- exit 0
139
- fi
140
- fi
141
-
142
- # Only trigger on git push commands
143
- if ! printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+push'; then
144
- exit 0
145
- fi
146
-
147
- # ── 5. Check if quality gates are enabled ─────────────────────────────────────
148
- POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
149
- if [[ -f "$POLICY_FILE" ]]; then
150
- if grep -qE 'push_review:[[:space:]]*false' "$POLICY_FILE" 2>/dev/null; then
151
- exit 0
152
- fi
153
- fi
154
-
155
- # ── 5a. REA_SKIP_PUSH_REVIEW — whole-gate escape hatch ───────────────────────
156
- # An opt-in bypass for the ENTIRE push-review gate (not just the Codex branch).
157
- # Exists to unblock consumers when rea itself is broken (as in BUG-009 pre-0.5.0)
158
- # or a corrupt policy/audit file would otherwise deadlock a push. Requires an
159
- # explicit non-empty reason; the value of REA_SKIP_PUSH_REVIEW is recorded
160
- # verbatim in the audit record as the reason.
161
- #
162
- # Fail-closed contract matches REA_SKIP_CODEX_REVIEW:
163
- # - missing dist/audit/append.js → exit 2
164
- # - missing git identity → exit 2
165
- # - Node failure → exit 2
166
- #
167
- # Audit tool_name is `push.review.skipped`. This is intentionally NOT
168
- # `codex.review` or `codex.review.skipped` — a skip of the whole gate is a
169
- # separately-audited event and does not satisfy the Codex-review jq predicate.
170
- if [[ -n "${REA_SKIP_PUSH_REVIEW:-}" ]]; then
171
- SKIP_REASON="$REA_SKIP_PUSH_REVIEW"
172
- AUDIT_APPEND_JS="${REA_ROOT}/dist/audit/append.js"
173
-
174
- if [[ ! -f "$AUDIT_APPEND_JS" ]]; then
175
- {
176
- printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW requires rea to be built.\n'
177
- printf '\n'
178
- printf ' REA_SKIP_PUSH_REVIEW is set but %s is missing.\n' "$AUDIT_APPEND_JS"
179
- printf ' Run: pnpm build\n'
180
- printf '\n'
181
- } >&2
182
- exit 2
183
- fi
184
-
185
- # Codex F2: CI-aware refusal. The skip hatch is ambient — any process that
186
- # can set env vars can flip the gate off with a forged git identity (git
187
- # config is mutable repo config). In a CI context, refuse by default; only
188
- # allow if the policy explicitly opted in via review.allow_skip_in_ci=true.
189
- if [[ -n "${CI:-}" ]]; then
190
- ALLOW_CI_SKIP=""
191
- READ_FIELD_JS="${REA_ROOT}/dist/scripts/read-policy-field.js"
192
- if [[ -f "$READ_FIELD_JS" ]]; then
193
- ALLOW_CI_SKIP=$(REA_ROOT="$REA_ROOT" node "$READ_FIELD_JS" review.allow_skip_in_ci 2>/dev/null || echo "")
194
- fi
195
- if [[ "$ALLOW_CI_SKIP" != "true" ]]; then
196
- {
197
- printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW refused in CI context.\n'
198
- printf '\n'
199
- printf ' CI env var is set. An unauthenticated env-var bypass in a shared\n'
200
- printf ' build agent is not trusted. To enable, set\n'
201
- printf ' review:\n'
202
- printf ' allow_skip_in_ci: true\n'
203
- printf ' in .rea/policy.yaml — explicitly authorizing env-var skips in CI.\n'
204
- printf '\n'
205
- } >&2
206
- exit 2
207
- fi
208
- fi
209
-
210
- SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.email 2>/dev/null || echo "")
211
- if [[ -z "$SKIP_ACTOR" ]]; then
212
- SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.name 2>/dev/null || echo "")
213
- fi
214
- if [[ -z "$SKIP_ACTOR" ]]; then
215
- {
216
- printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW requires a git identity.\n'
217
- printf '\n'
218
- # shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
219
- printf ' Neither `git config user.email` nor `git config user.name`\n'
220
- printf ' is set. The skip audit record would have no actor; refusing\n'
221
- printf ' to bypass without one.\n'
222
- printf '\n'
223
- } >&2
224
- exit 2
225
- fi
226
-
227
- SKIP_BRANCH=$(cd "$REA_ROOT" && git branch --show-current 2>/dev/null || echo "")
228
- SKIP_HEAD=$(cd "$REA_ROOT" && git rev-parse HEAD 2>/dev/null || echo "")
229
-
230
- # Codex F2: record OS identity alongside the (mutable, git-sourced) actor so
231
- # downstream auditors can reconstruct who REALLY invoked the bypass on a
232
- # shared host. None of these are forgeable from inside the push process alone.
233
- SKIP_OS_UID=$(id -u 2>/dev/null || echo "")
234
- SKIP_OS_WHOAMI=$(whoami 2>/dev/null || echo "")
235
- SKIP_OS_HOST=$(hostname 2>/dev/null || echo "")
236
- SKIP_OS_PID=$$
237
- SKIP_OS_PPID=$PPID
238
- SKIP_OS_PPID_CMD=$(ps -o command= -p "$PPID" 2>/dev/null | head -c 512 || echo "")
239
- SKIP_OS_TTY=$(tty 2>/dev/null || echo "not-a-tty")
240
- SKIP_OS_CI="${CI:-}"
241
-
242
- SKIP_METADATA=$(jq -n \
243
- --arg head_sha "$SKIP_HEAD" \
244
- --arg branch "$SKIP_BRANCH" \
245
- --arg reason "$SKIP_REASON" \
246
- --arg actor "$SKIP_ACTOR" \
247
- --arg os_uid "$SKIP_OS_UID" \
248
- --arg os_whoami "$SKIP_OS_WHOAMI" \
249
- --arg os_hostname "$SKIP_OS_HOST" \
250
- --arg os_pid "$SKIP_OS_PID" \
251
- --arg os_ppid "$SKIP_OS_PPID" \
252
- --arg os_ppid_cmd "$SKIP_OS_PPID_CMD" \
253
- --arg os_tty "$SKIP_OS_TTY" \
254
- --arg os_ci "$SKIP_OS_CI" \
255
- '{
256
- head_sha: $head_sha,
257
- branch: $branch,
258
- reason: $reason,
259
- actor: $actor,
260
- verdict: "skipped",
261
- os_identity: {
262
- uid: $os_uid,
263
- whoami: $os_whoami,
264
- hostname: $os_hostname,
265
- pid: $os_pid,
266
- ppid: $os_ppid,
267
- ppid_cmd: $os_ppid_cmd,
268
- tty: $os_tty,
269
- ci: $os_ci
270
- }
271
- }' 2>/dev/null)
272
-
273
- if [[ -z "$SKIP_METADATA" ]]; then
274
- {
275
- printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW could not serialize audit metadata.\n' >&2
276
- } >&2
277
- exit 2
278
- fi
279
-
280
- REA_ROOT="$REA_ROOT" REA_SKIP_METADATA="$SKIP_METADATA" \
281
- node --input-type=module -e "
282
- const mod = await import(process.env.REA_ROOT + '/dist/audit/append.js');
283
- const metadata = JSON.parse(process.env.REA_SKIP_METADATA);
284
- await mod.appendAuditRecord(process.env.REA_ROOT, {
285
- tool_name: 'push.review.skipped',
286
- server_name: 'rea.escape_hatch',
287
- status: mod.InvocationStatus.Allowed,
288
- tier: mod.Tier.Read,
289
- metadata,
290
- });
291
- " 2>/dev/null
292
- NODE_STATUS=$?
293
- if [[ "$NODE_STATUS" -ne 0 ]]; then
294
- {
295
- printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW audit-append failed (node exit %s).\n' "$NODE_STATUS"
296
- printf ' Refusing to bypass the push gate without a receipt.\n'
297
- } >&2
298
- exit 2
299
- fi
300
-
301
- {
302
- printf '\n'
303
- printf '== PUSH REVIEW GATE SKIPPED via REA_SKIP_PUSH_REVIEW\n'
304
- printf ' Reason: %s\n' "$SKIP_REASON"
305
- printf ' Actor: %s\n' "$SKIP_ACTOR"
306
- printf ' Branch: %s\n' "${SKIP_BRANCH:-<detached>}"
307
- printf ' Head: %s\n' "${SKIP_HEAD:-<unknown>}"
308
- printf ' Audited: .rea/audit.jsonl (tool_name=push.review.skipped)\n'
309
- printf '\n'
310
- printf ' This is a gate weakening. Every invocation is permanently audited.\n'
311
- printf '\n'
312
- } >&2
313
- exit 0
314
- fi
315
-
316
- # ── 6. Determine source/target commits for each refspec ──────────────────────
317
- # The authoritative source for which commits are being pushed is the pre-push
318
- # hook stdin contract: one line per refspec, with fields
319
- # <local_ref> <local_sha> <remote_ref> <remote_sha>
320
- # (https://git-scm.com/docs/githooks#_pre_push). We drive the gate off those
321
- # SHAs directly — NOT off HEAD — so that `git push origin hotfix:main` from a
322
- # checked-out `foo` branch reviews the `hotfix` commits, not `foo`.
323
- #
324
- # Two execution paths:
325
- # 1. Real `git push`: stdin is forwarded from git and contains refspec lines.
326
- # This is what runs in production.
327
- # 2. Hook invoked outside a real push (manual test, the Bash PreToolUse path
328
- # where we only see the command string): stdin has no refspec lines. We
329
- # fall back to parsing the command string and diffing against HEAD, but
330
- # we refuse to let `src:dst` silently escape — see resolve_argv_refspecs.
331
- #
332
- # The REA PreToolUse wrapper currently delivers the Claude Code tool_input on
333
- # stdin as JSON. If what we read on stdin does not look like pre-push refspec
334
- # lines, we treat it as "no stdin" and use the argv fallback.
335
- ZERO_SHA='0000000000000000000000000000000000000000'
336
- CURRENT_BRANCH=$(cd "$REA_ROOT" && git branch --show-current 2>/dev/null || echo "")
337
-
338
- # Parse pre-push stdin into newline-separated "local_sha|remote_sha|local_ref|remote_ref"
339
- # records on stdout. Exits non-zero without any output if stdin does not match
340
- # the pre-push contract, so the caller can switch to the argv fallback.
341
- #
342
- # Pre-push stdin is plain whitespace-separated text, one line per refspec.
343
- # Every field is either a ref name or a 40-hex SHA. We require at least one
344
- # well-formed line to accept the input. Returning via stdout (instead of bash 4
345
- # namerefs) keeps this portable to macOS /bin/bash 3.2.
346
- parse_prepush_stdin() {
347
- local raw="$1"
348
- local accepted=0
349
- local line local_ref local_sha remote_ref remote_sha rest
350
- local -a records
351
- records=()
352
- while IFS= read -r line; do
353
- [[ -z "$line" ]] && continue
354
- read -r local_ref local_sha remote_ref remote_sha rest <<<"$line"
355
- if [[ -z "$local_ref" || -z "$local_sha" || -z "$remote_ref" || -z "$remote_sha" ]]; then
356
- continue
357
- fi
358
- if [[ ! "$local_sha" =~ ^[0-9a-f]{40}$ ]] || [[ ! "$remote_sha" =~ ^[0-9a-f]{40}$ ]]; then
359
- return 1
360
- fi
361
- records+=("${local_sha}|${remote_sha}|${local_ref}|${remote_ref}")
362
- accepted=1
363
- done <<<"$raw"
364
- if [[ "$accepted" -ne 1 ]]; then
365
- return 1
366
- fi
367
- local r
368
- for r in "${records[@]}"; do
369
- printf '%s\n' "$r"
370
- done
371
- }
372
-
373
- # Argv fallback: parse `git push [remote] [refspec...]` from the command string
374
- # when stdin has no pre-push lines. Emits newline-separated records as
375
- # "local_sha|remote_sha|local_ref|remote_ref" where `local_sha` is HEAD of the
376
- # named source ref (or HEAD itself for bare refspecs) and `remote_sha` is zero
377
- # so the merge-base logic falls back to merging against the configured default.
378
- # Exits the script with code 2 on operator-error conditions (HEAD target,
379
- # unresolvable source ref) — same fail-closed contract as before.
380
- resolve_argv_refspecs() {
381
- local cmd="$1"
382
- local segment
383
- segment=$(printf '%s' "$cmd" | awk '
384
- {
385
- idx = match($0, /git[[:space:]]+push([[:space:]]|$)/)
386
- if (!idx) exit
387
- tail = substr($0, idx)
388
- n = match(tail, /[;&|]|&&|\|\|/)
389
- if (n > 0) tail = substr(tail, 1, n - 1)
390
- print tail
391
- }
392
- ')
393
-
394
- local -a specs
395
- specs=()
396
- local seen_push=0 remote_seen=0 delete_mode=0 tok
397
- # shellcheck disable=SC2086
398
- set -- $segment
399
- for tok in "$@"; do
400
- case "$tok" in
401
- git|push) seen_push=1; continue ;;
402
- --delete|-d)
403
- # Branch deletion. Every subsequent bare refspec is a delete target on
404
- # the remote, not a source ref on the local side. We flip delete_mode
405
- # so the consumer loop below emits ZERO_SHA|ZERO_SHA records matching
406
- # the git pre-push stdin contract for deletions.
407
- delete_mode=1
408
- continue
409
- ;;
410
- --delete=*)
411
- # `git push --delete=value` is not actually supported by git, but guard
412
- # anyway: treat the value as a delete target.
413
- delete_mode=1
414
- specs+=("${tok#--delete=}")
415
- continue
416
- ;;
417
- -*) continue ;;
418
- esac
419
- [[ "$seen_push" -eq 0 ]] && continue
420
- if [[ "$remote_seen" -eq 0 ]]; then
421
- remote_seen=1
422
- continue
423
- fi
424
- if [[ "$delete_mode" -eq 1 ]]; then
425
- # Tag each delete-mode token with a sentinel prefix so the consumer loop
426
- # can distinguish it from a normal refspec without another bash array.
427
- specs+=("__REA_DELETE__${tok}")
428
- else
429
- specs+=("$tok")
430
- fi
431
- done
432
-
433
- if [[ "${#specs[@]}" -eq 0 ]]; then
434
- local upstream dst_ref head_sha
435
- upstream=$(cd "$REA_ROOT" && git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || echo "")
436
- dst_ref="refs/heads/main"
437
- if [[ -n "$upstream" && "$upstream" == */* ]]; then
438
- dst_ref="refs/heads/${upstream#*/}"
439
- fi
440
- head_sha=$(cd "$REA_ROOT" && git rev-parse HEAD 2>/dev/null || echo "")
441
- [[ -z "$head_sha" ]] && return 1
442
- printf '%s|%s|HEAD|%s\n' "$head_sha" "$ZERO_SHA" "$dst_ref"
443
- return 0
444
- fi
445
-
446
- local spec src dst src_sha is_delete
447
- for spec in "${specs[@]}"; do
448
- is_delete=0
449
- if [[ "$spec" == __REA_DELETE__* ]]; then
450
- is_delete=1
451
- spec="${spec#__REA_DELETE__}"
452
- fi
453
- spec="${spec#+}"
454
- if [[ "$spec" == *:* ]]; then
455
- src="${spec%%:*}"
456
- dst="${spec##*:}"
457
- else
458
- src="$spec"
459
- dst="$spec"
460
- fi
461
- if [[ -z "$dst" ]]; then
462
- dst="${spec##*:}"
463
- src=""
464
- fi
465
- dst="${dst#refs/heads/}"
466
- dst="${dst#refs/for/}"
467
- if [[ "$is_delete" -eq 1 ]]; then
468
- # `git push --delete origin doomed` — force the record to match the
469
- # pre-push stdin contract for deletions: both SHAs zero, local_ref is
470
- # the sentinel string "(delete)". The downstream HAS_DELETE branch
471
- # fail-closes out of the agent path.
472
- if [[ -z "$dst" || "$dst" == "HEAD" ]]; then
473
- {
474
- printf 'PUSH BLOCKED: --delete refspec resolves to HEAD or empty (from %q)\n' "$spec"
475
- } >&2
476
- exit 2
477
- fi
478
- printf '%s|%s|(delete)|refs/heads/%s\n' "$ZERO_SHA" "$ZERO_SHA" "$dst"
479
- continue
480
- fi
481
- if [[ "$dst" == "HEAD" || -z "$dst" ]]; then
482
- {
483
- printf 'PUSH BLOCKED: refspec resolves to HEAD (from %q)\n' "$spec"
484
- printf '\n'
485
- # shellcheck disable=SC2016
486
- printf ' `git push <remote> HEAD:<branch>` or similar is almost always\n'
487
- printf ' operator error in this context. Name the destination branch\n'
488
- printf ' explicitly so the review gate can diff against it.\n'
489
- printf '\n'
490
- } >&2
491
- exit 2
492
- fi
493
- if [[ -z "$src" ]]; then
494
- # Deletion via argv; record as all-zeros local_sha.
495
- printf '%s|%s|(delete)|refs/heads/%s\n' "$ZERO_SHA" "$ZERO_SHA" "$dst"
496
- continue
497
- fi
498
- src_sha=$(cd "$REA_ROOT" && git rev-parse --verify "${src}^{commit}" 2>/dev/null || echo "")
499
- if [[ -z "$src_sha" ]]; then
500
- {
501
- printf 'PUSH BLOCKED: could not resolve source ref %q to a commit.\n' "$src"
502
- } >&2
503
- exit 2
504
- fi
505
- printf '%s|%s|refs/heads/%s|refs/heads/%s\n' "$src_sha" "$ZERO_SHA" "$src" "$dst"
506
- done
507
- }
508
-
509
- # Collect refspec records. Stdin takes priority; fall back to argv parsing.
510
- # parse_prepush_stdin exits non-zero when stdin is not a pre-push contract
511
- # (most common case: Claude Code PreToolUse wrapper delivering JSON on stdin).
512
- REFSPEC_RECORDS=()
513
- if RECORDS_OUT=$(parse_prepush_stdin "$INPUT") && [[ -n "$RECORDS_OUT" ]]; then
514
- :
515
- else
516
- RECORDS_OUT=$(resolve_argv_refspecs "$CMD")
517
- fi
518
- while IFS= read -r _rec; do
519
- [[ -z "$_rec" ]] && continue
520
- REFSPEC_RECORDS+=("$_rec")
521
- done <<<"$RECORDS_OUT"
522
-
523
- if [[ "${#REFSPEC_RECORDS[@]}" -eq 0 ]]; then
524
- {
525
- printf 'PUSH BLOCKED: no push refspecs could be resolved.\n'
526
- printf ' Refusing to pass without a source commit to review.\n'
527
- } >&2
528
- exit 2
529
- fi
530
-
531
- # ── 7. Pick the source commit and merge-base to review ───────────────────────
532
- # Across all refspecs, we pick the one whose source commit is furthest from
533
- # its merge-base (i.e. the largest diff). That way a mixed push like
534
- # `foo:main bar:dev` is gated on whichever refspec actually contributes new
535
- # commits. A deletion refspec (local_sha all zeros) is still concerning — we
536
- # check the remote side for protected-path changes against the merge-base of
537
- # the remote sha and the default branch, but the diff body comes from the
538
- # non-delete refspec if present. If every refspec is a delete, we fail-closed
539
- # and require an explicit review.
540
- SOURCE_SHA=""
541
- MERGE_BASE=""
542
- TARGET_BRANCH=""
543
- SOURCE_REF=""
544
- HAS_DELETE=0
545
- for rec in "${REFSPEC_RECORDS[@]}"; do
546
- IFS='|' read -r local_sha remote_sha local_ref remote_ref <<<"$rec"
547
- target="${remote_ref#refs/heads/}"
548
- target="${target#refs/for/}"
549
- [[ -z "$target" ]] && target="main"
550
-
551
- if [[ "$local_sha" == "$ZERO_SHA" ]]; then
552
- HAS_DELETE=1
553
- continue
554
- fi
555
-
556
- # Merge base: if the remote already has the ref, use remote_sha directly.
557
- # Otherwise (new branch, remote_sha is zeros), merge-base against the target.
558
- #
559
- # Critical: when remote_sha is non-zero but NOT in the local object DB
560
- # (stale checkout, no recent fetch), older code swallowed `merge-base`
561
- # failure with `|| echo "$remote_sha"`, assigning a SHA that would make
562
- # every downstream `rev-list`/`diff` fail. Those failures were then
563
- # swallowed too, collapsing to an empty DIFF_FULL and fail-open exit 0.
564
- #
565
- # Probe object presence up front. Missing object → fail closed with a clear
566
- # remediation message. No silent fallback.
567
- if [[ "$remote_sha" != "$ZERO_SHA" ]]; then
568
- if ! (cd "$REA_ROOT" && git cat-file -e "${remote_sha}^{commit}" 2>/dev/null); then
569
- {
570
- printf 'PUSH BLOCKED: remote object %s is not in the local object DB.\n' "$remote_sha"
571
- printf '\n'
572
- printf ' The gate cannot compute a review diff without it. Fetch the\n'
573
- printf ' remote and retry:\n'
574
- printf '\n'
575
- printf ' git fetch origin\n'
576
- printf ' # then retry the push\n'
577
- printf '\n'
578
- } >&2
579
- exit 2
580
- fi
581
- mb=$(cd "$REA_ROOT" && git merge-base "$remote_sha" "$local_sha" 2>/dev/null)
582
- mb_status=$?
583
- if [[ "$mb_status" -ne 0 || -z "$mb" ]]; then
584
- {
585
- printf 'PUSH BLOCKED: no merge-base between remote %s and local %s\n' \
586
- "${remote_sha:0:12}" "${local_sha:0:12}"
587
- printf ' The two histories are unrelated; refusing to pass without a\n'
588
- printf ' reviewable diff.\n'
589
- } >&2
590
- exit 2
591
- fi
592
- else
593
- mb=$(cd "$REA_ROOT" && git merge-base "$target" "$local_sha" 2>/dev/null || echo "")
594
- if [[ -z "$mb" ]]; then
595
- # New branch whose target has no merge-base locally. Try the default
596
- # branch if it exists, otherwise fail-closed (handled below).
597
- mb=$(cd "$REA_ROOT" && git merge-base main "$local_sha" 2>/dev/null || echo "")
598
- fi
599
- fi
600
- if [[ -z "$mb" ]]; then
601
- continue
602
- fi
603
-
604
- # Pick the refspec whose merge-base is the oldest ancestor of its local_sha
605
- # (i.e. the largest diff). Fail closed on rev-list errors rather than
606
- # substituting 0 — a failed rev-list means we can't trust the comparison.
607
- count=$(cd "$REA_ROOT" && git rev-list --count "${mb}..${local_sha}" 2>/dev/null)
608
- count_status=$?
609
- if [[ "$count_status" -ne 0 ]]; then
610
- {
611
- printf 'PUSH BLOCKED: git rev-list --count %s..%s failed (exit %s)\n' \
612
- "${mb:0:12}" "${local_sha:0:12}" "$count_status"
613
- printf ' Cannot size the diff; refusing to pass.\n'
614
- } >&2
615
- exit 2
616
- fi
617
- if [[ -z "$count" ]]; then
618
- count=0
619
- fi
620
- if [[ -z "$SOURCE_SHA" ]] || [[ "$count" -gt "${BEST_COUNT:-0}" ]]; then
621
- SOURCE_SHA="$local_sha"
622
- MERGE_BASE="$mb"
623
- TARGET_BRANCH="$target"
624
- SOURCE_REF="$local_ref"
625
- BEST_COUNT="$count"
626
- fi
627
- done
628
-
629
- if [[ -z "$SOURCE_SHA" || -z "$MERGE_BASE" ]]; then
630
- if [[ "$HAS_DELETE" -eq 1 ]]; then
631
- {
632
- printf 'PUSH BLOCKED: refspec is a branch deletion.\n'
633
- printf '\n'
634
- printf ' Branch deletions are sensitive operations and require explicit\n'
635
- printf ' human action outside the agent. Perform the deletion manually.\n'
636
- printf '\n'
637
- } >&2
638
- exit 2
639
- fi
640
- {
641
- printf 'PUSH BLOCKED: could not resolve a merge-base for any push refspec.\n'
642
- printf '\n'
643
- printf ' Fetch the remote and retry, or name an explicit destination.\n'
644
- printf '\n'
645
- } >&2
646
- exit 2
647
- fi
648
-
649
- # Capture git diff exit status explicitly. The previous `|| echo ""` swallowed
650
- # real errors (missing objects, invalid refs) and fell through to the empty-diff
651
- # fail-open below. We now distinguish:
652
- # exit 0 + empty output → legitimate no-op push, allow
653
- # exit 0 + non-empty → proceed to review
654
- # exit non-zero → fail closed, never allow
655
- DIFF_FULL=$(cd "$REA_ROOT" && git diff "${MERGE_BASE}...${SOURCE_SHA}" 2>/dev/null)
656
- DIFF_STATUS=$?
657
- if [[ "$DIFF_STATUS" -ne 0 ]]; then
658
- {
659
- printf 'PUSH BLOCKED: git diff %s...%s failed (exit %s)\n' \
660
- "${MERGE_BASE:0:12}" "${SOURCE_SHA:0:12}" "$DIFF_STATUS"
661
- printf ' Cannot compute reviewable diff; refusing to pass.\n'
662
- } >&2
663
- exit 2
664
- fi
665
-
666
- if [[ -z "$DIFF_FULL" ]]; then
667
- # git exited 0 with no output — legitimate no-op push (e.g. re-push of an
668
- # already-remote commit). Allow.
669
- exit 0
670
- fi
671
-
672
- LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || echo "0")
673
-
674
- # ── 7a. Protected-path Codex adversarial review gate ────────────────────────
675
- # If the diff touches governance-critical directories, require a codex.review
676
- # audit entry for the current HEAD. This enforces the Plan → Build → Review
677
- # loop for the very code that enforces it.
678
- #
679
- # Rationale for gating at push and NOT at commit: commit-review-gate.sh already
680
- # performs cache-based review with triage thresholds. Doubling friction at
681
- # every commit is pointless because nothing lands remote without passing the
682
- # push gate. Leave commit-review-gate alone; do NOT add a mirror of this check
683
- # there.
684
- #
685
- # Path match: we use `git diff --name-status` against the merge-base rather
686
- # than scraping `+++`/`---` patch headers. Patch headers alone miss file
687
- # deletions (the `+++` line is `/dev/null` for a deletion of a protected path),
688
- # which is a trivial bypass. `--name-status` reports both the old and new path
689
- # columns for every change type (A/C/D/M/R/T/U), so a protected path can be
690
- # matched regardless of whether the change adds, removes, renames, or modifies.
691
- #
692
- # Proof-of-review match: we use `jq -e` with a structured predicate against
693
- # top-level `tool_name` and `metadata.{head_sha, verdict}`. Substring greps
694
- # against raw JSON lines are forgeable — the audit-append API accepts arbitrary
695
- # `metadata`, so a record with `{"metadata":{"note":"tool_name:\"codex.review\""}}`
696
- # would satisfy two independent greps. Match on the parsed structure instead.
697
- #
698
- # ── G11.4: honor review.codex_required ───────────────────────────────────────
699
- # When policy.review.codex_required is explicitly false, the operator has
700
- # opted into first-class no-Codex mode. Skip this whole branch — no audit
701
- # entry is required, the escape-hatch is not relevant, and we fall through
702
- # to the normal (non-Codex) push validation. The selector in
703
- # src/gateway/reviewers/select.ts makes the same call for the reviewer pick.
704
- #
705
- # Fail-closed: if the helper fails to parse the policy, treat the field as
706
- # true (safer default) and log a warning. A malformed policy file is an
707
- # operator problem, not a reason to silently weaken the Codex gate.
708
- READ_FIELD_JS="${REA_ROOT}/dist/scripts/read-policy-field.js"
709
- CODEX_REQUIRED="true"
710
- if [[ -f "$READ_FIELD_JS" ]]; then
711
- FIELD_VALUE=$(REA_ROOT="$REA_ROOT" node "$READ_FIELD_JS" review.codex_required 2>/dev/null)
712
- FIELD_STATUS=$?
713
- case "$FIELD_STATUS" in
714
- 0)
715
- # Field is present and a scalar. Accept only literal `true` / `false`.
716
- # Anything else is a malformed scalar; fail closed.
717
- if [[ "$FIELD_VALUE" == "false" ]]; then
718
- CODEX_REQUIRED="false"
719
- elif [[ "$FIELD_VALUE" == "true" ]]; then
720
- CODEX_REQUIRED="true"
721
- else
722
- printf 'REA WARN: review.codex_required resolved to non-boolean %q — treating as true\n' "$FIELD_VALUE" >&2
723
- CODEX_REQUIRED="true"
724
- fi
725
- ;;
726
- 1)
727
- # Field absent (or policy file missing). Documented default is true.
728
- CODEX_REQUIRED="true"
729
- ;;
730
- *)
731
- # Malformed policy, unexpected helper exit. Fail closed.
732
- printf 'REA WARN: read-policy-field exited %s — treating review.codex_required as true (fail-closed)\n' "$FIELD_STATUS" >&2
733
- CODEX_REQUIRED="true"
734
- ;;
735
- esac
736
- fi
737
-
738
- # [.]github instead of \.github: GNU awk warns on `\.` inside an ERE (it
739
- # treats the escape as plain `.`), which dirties stderr and makes tests that
740
- # assert on gate output brittle. `[.]` is the unambiguous ERE form and is
741
- # silent on every awk we target.
742
- PROTECTED_RE='(src/gateway/middleware/|hooks/|src/policy/|[.]github/workflows/)'
743
-
744
- PROTECTED_HITS=$(cd "$REA_ROOT" && git diff --name-status "${MERGE_BASE}...${SOURCE_SHA}" 2>/dev/null)
745
- PROTECTED_DIFF_STATUS=$?
746
- if [[ "$PROTECTED_DIFF_STATUS" -ne 0 ]]; then
747
- {
748
- printf 'PUSH BLOCKED: git diff --name-status failed (exit %s)\n' "$PROTECTED_DIFF_STATUS"
749
- printf ' Base: %s\n' "$MERGE_BASE"
750
- printf ' Cannot determine whether protected paths changed; refusing to pass.\n'
751
- } >&2
752
- exit 2
753
- fi
754
-
755
- if [[ "$CODEX_REQUIRED" == "true" ]] && printf '%s\n' "$PROTECTED_HITS" | awk -v re="$PROTECTED_RE" '
756
- # Each line is: STATUS<TAB>PATH1[<TAB>PATH2]
757
- # Status is one or two letters (single letter for A/M/D/T/U; R/C are
758
- # followed by a similarity score like R100). We check every PATH column
759
- # against the protected-path regex so deletions, renames, and copies are
760
- # all caught.
761
- {
762
- status = $1
763
- if (status !~ /^[ACDMRTU]/) next
764
- for (i = 2; i <= NF; i++) {
765
- if ($i ~ re) { found = 1; next }
766
- }
767
- }
768
- END { exit found ? 0 : 1 }
769
- '; then
770
- # The audit entry must be keyed on the commit actually being pushed, not on
771
- # the working-tree HEAD — `git push origin hotfix:main` from a `foo` checkout
772
- # must match a Codex review of `hotfix`, not of `foo`.
773
- REVIEW_SHA="$SOURCE_SHA"
774
-
775
- # ── 7a.1 Escape hatch: REA_SKIP_CODEX_REVIEW ──────────────────────────────
776
- # Consume the hatch ONLY when we would otherwise require Codex review (i.e.
777
- # we are inside the protected-path branch). This preserves the gate for
778
- # every non-protected push.
779
- #
780
- # Audit record is written BEFORE the stderr banner and BEFORE exit 0. If
781
- # the audit write fails (missing dist/ build, missing git identity, Node
782
- # failure), we fail closed — exit 2 — so an operator cannot silently slip
783
- # a protected-path push with no receipt.
784
- if [[ -n "${REA_SKIP_CODEX_REVIEW:-}" ]]; then
785
- SKIP_REASON="$REA_SKIP_CODEX_REVIEW"
786
- AUDIT_APPEND_JS="${REA_ROOT}/dist/audit/append.js"
787
-
788
- if [[ ! -f "$AUDIT_APPEND_JS" ]]; then
789
- {
790
- printf 'PUSH BLOCKED: escape hatch requires rea to be built.\n'
791
- printf '\n'
792
- printf ' REA_SKIP_CODEX_REVIEW is set but %s is missing.\n' "$AUDIT_APPEND_JS"
793
- printf ' Run: pnpm build\n'
794
- printf '\n'
795
- } >&2
796
- exit 2
797
- fi
798
-
799
- # Actor: prefer git user.email, fall back to user.name. Empty → fail closed.
800
- SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.email 2>/dev/null || echo "")
801
- if [[ -z "$SKIP_ACTOR" ]]; then
802
- SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.name 2>/dev/null || echo "")
803
- fi
804
- if [[ -z "$SKIP_ACTOR" ]]; then
805
- {
806
- printf 'PUSH BLOCKED: escape hatch requires a git identity.\n'
807
- printf '\n'
808
- # shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
809
- printf ' Neither `git config user.email` nor `git config user.name`\n'
810
- printf ' is set. The skip audit record would have no actor; refusing\n'
811
- printf ' to bypass without one.\n'
812
- printf '\n'
813
- } >&2
814
- exit 2
815
- fi
816
-
817
- # files_changed is a count only (not a list). The raw name-status stream
818
- # is already processed elsewhere in the hook; paths may be path-sensitive
819
- # or leak info we'd rather keep out of the audit line.
820
- SKIP_FILES_CHANGED=$(printf '%s\n' "$PROTECTED_HITS" | awk 'NF { n++ } END { print n+0 }')
821
-
822
- # Build the metadata JSON via jq so any weird characters in reason/actor
823
- # are properly escaped. All values are passed as --arg (strings) except
824
- # files_changed which is --argjson (number).
825
- SKIP_METADATA=$(jq -n \
826
- --arg head_sha "$SOURCE_SHA" \
827
- --arg target "$TARGET_BRANCH" \
828
- --arg reason "$SKIP_REASON" \
829
- --arg actor "$SKIP_ACTOR" \
830
- --argjson files_changed "$SKIP_FILES_CHANGED" \
831
- '{
832
- head_sha: $head_sha,
833
- target: $target,
834
- reason: $reason,
835
- actor: $actor,
836
- verdict: "skipped",
837
- files_changed: $files_changed
838
- }' 2>/dev/null)
839
-
840
- if [[ -z "$SKIP_METADATA" ]]; then
841
- {
842
- printf 'PUSH BLOCKED: escape hatch could not serialize audit metadata.\n' >&2
843
- } >&2
844
- exit 2
845
- fi
846
-
847
- # Write the audit record via the built helper. Pass REA_ROOT and the
848
- # metadata JSON through env vars (avoids quoting the values into the
849
- # one-liner; reason may contain literal double-quotes or backslashes).
850
- REA_ROOT="$REA_ROOT" REA_SKIP_METADATA="$SKIP_METADATA" \
851
- node --input-type=module -e "
852
- const mod = await import(process.env.REA_ROOT + '/dist/audit/append.js');
853
- const metadata = JSON.parse(process.env.REA_SKIP_METADATA);
854
- await mod.appendAuditRecord(process.env.REA_ROOT, {
855
- tool_name: 'codex.review.skipped',
856
- server_name: 'rea.escape_hatch',
857
- status: mod.InvocationStatus.Allowed,
858
- tier: mod.Tier.Read,
859
- metadata,
860
- });
861
- " 2>/dev/null
862
- NODE_STATUS=$?
863
- if [[ "$NODE_STATUS" -ne 0 ]]; then
864
- {
865
- printf 'PUSH BLOCKED: escape hatch audit-append failed (node exit %s).\n' "$NODE_STATUS"
866
- printf ' Refusing to bypass the Codex-review gate without a receipt.\n'
867
- } >&2
868
- exit 2
869
- fi
870
-
871
- # Audit record is durable on disk. Emit the loud stderr banner and allow
872
- # the push.
873
- {
874
- printf '\n'
875
- printf '== CODEX REVIEW SKIPPED via REA_SKIP_CODEX_REVIEW\n'
876
- printf ' Reason: %s\n' "$SKIP_REASON"
877
- printf ' Actor: %s\n' "$SKIP_ACTOR"
878
- printf ' Head SHA: %s\n' "$SOURCE_SHA"
879
- printf ' Audited: .rea/audit.jsonl (tool_name=codex.review.skipped)\n'
880
- printf '\n'
881
- printf ' This is a gate weakening. Every invocation is permanently audited.\n'
882
- printf '\n'
883
- } >&2
884
- exit 0
885
- fi
886
-
887
- AUDIT="${REA_ROOT}/.rea/audit.jsonl"
888
- CODEX_OK=0
889
- if [[ -f "$AUDIT" ]]; then
890
- # jq -e exits 0 iff at least one record matches every predicate. Any other
891
- # exit (including jq parse errors on a corrupt line) is treated as "no
892
- # proof of review" and we fail-closed.
893
- #
894
- # We require verdict to be an explicit allowlisted value. Missing, null,
895
- # or unknown verdicts fail the predicate — matching on `!=` alone admits
896
- # forged records with `metadata` lacking a `verdict` field at all.
897
- if jq -e --arg sha "$REVIEW_SHA" '
898
- select(
899
- .tool_name == "codex.review"
900
- and .metadata.head_sha == $sha
901
- and (.metadata.verdict == "pass" or .metadata.verdict == "concerns")
902
- )
903
- ' "$AUDIT" >/dev/null 2>&1; then
904
- CODEX_OK=1
905
- fi
906
- fi
907
- if [[ "$CODEX_OK" -eq 0 ]]; then
908
- {
909
- printf 'PUSH BLOCKED: protected paths changed — /codex-review required for %s\n' "$REVIEW_SHA"
910
- printf '\n'
911
- printf ' Source ref: %s\n' "${SOURCE_REF:-HEAD}"
912
- printf ' Diff touches one of:\n'
913
- printf ' - src/gateway/middleware/\n'
914
- printf ' - hooks/\n'
915
- printf ' - src/policy/\n'
916
- printf ' - .github/workflows/\n'
917
- printf '\n'
918
- printf ' Run /codex-review against %s, then retry the push.\n' "$REVIEW_SHA"
919
- printf ' The codex-adversarial agent emits the required audit entry.\n'
920
- # shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
921
- printf ' Only `pass` or `concerns` verdicts satisfy this gate.\n'
922
- printf '\n'
923
- } >&2
924
- exit 2
925
- fi
926
- fi
927
-
928
- # ── 8. Check review cache ────────────────────────────────────────────────────
929
- PUSH_SHA=$(printf '%s' "$DIFF_FULL" | shasum -a 256 | cut -d' ' -f1 2>/dev/null || echo "")
930
-
931
- # Resolve rea CLI (node_modules/.bin first, dist fallback)
932
- REA_CLI_ARGS=()
933
- if [[ -f "${REA_ROOT}/node_modules/.bin/rea" ]]; then
934
- REA_CLI_ARGS=(node "${REA_ROOT}/node_modules/.bin/rea")
935
- elif [[ -f "${REA_ROOT}/dist/cli/index.js" ]]; then
936
- REA_CLI_ARGS=(node "${REA_ROOT}/dist/cli/index.js")
937
- fi
938
-
939
- if [[ -n "$PUSH_SHA" ]] && [[ ${#REA_CLI_ARGS[@]} -gt 0 ]]; then
940
- CACHE_RESULT=$("${REA_CLI_ARGS[@]}" cache check "$PUSH_SHA" --branch "$CURRENT_BRANCH" --base "$TARGET_BRANCH" 2>/dev/null || echo '{"hit":false}')
941
- if printf '%s' "$CACHE_RESULT" | jq -e '.hit == true' >/dev/null 2>&1; then
942
- # Review was already approved — notify and allow the push through
943
- DISCORD_LIB="${REA_ROOT}/hooks/_lib/discord.sh"
944
- if [ -f "$DISCORD_LIB" ]; then
945
- # shellcheck source=/dev/null
946
- source "$DISCORD_LIB"
947
- discord_notify "dev" "Push passed quality gates on \`${CURRENT_BRANCH}\` -- $(cd "$REA_ROOT" && git log -1 --oneline 2>/dev/null)" "green"
948
- fi
949
- exit 0
950
- fi
951
- fi
952
-
953
- # ── 9. Block and request review ──────────────────────────────────────────────
954
- FILE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -c '^\+\+\+ ' 2>/dev/null || echo "0")
955
-
956
- {
957
- printf 'PUSH REVIEW GATE: Review required before pushing\n'
958
- printf '\n'
959
- printf ' Source ref: %s (%s)\n' "${SOURCE_REF:-HEAD}" "${SOURCE_SHA:0:12}"
960
- printf ' Target: %s\n' "$TARGET_BRANCH"
961
- printf ' Scope: %s files changed, %s lines\n' "$FILE_COUNT" "$LINE_COUNT"
962
- printf '\n'
963
- printf ' Action required:\n'
964
- printf ' 1. Spawn a code-reviewer agent to review: git diff %s...%s\n' "$MERGE_BASE" "$SOURCE_SHA"
965
- printf ' 2. Spawn a security-engineer agent for security review\n'
966
- printf ' 3. After both pass, cache the result:\n'
967
- printf ' rea cache set %s pass --branch %s --base %s\n' "$PUSH_SHA" "$CURRENT_BRANCH" "$TARGET_BRANCH"
968
- printf '\n'
969
- } >&2
970
- exit 2
77
+ pr_core_run "$_adapter_script" "$INPUT" "$@"