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