@bookedsolid/rea 0.6.2 → 0.7.0

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