@bookedsolid/rea 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-push +22 -167
- package/agents/codex-adversarial.md +5 -3
- package/commands/codex-review.md +3 -5
- package/dist/audit/append.d.ts +7 -32
- package/dist/audit/append.js +7 -35
- package/dist/cli/audit.d.ts +0 -31
- package/dist/cli/audit.js +5 -74
- package/dist/cli/doctor.js +6 -16
- package/dist/cli/hook.d.ts +48 -0
- package/dist/cli/hook.js +127 -0
- package/dist/cli/index.js +5 -80
- package/dist/cli/init.js +1 -1
- package/dist/cli/install/gitignore.d.ts +2 -2
- package/dist/cli/install/gitignore.js +3 -3
- package/dist/cli/install/pre-push.d.ts +146 -271
- package/dist/cli/install/pre-push.js +471 -2633
- package/dist/cli/install/settings-merge.d.ts +17 -0
- package/dist/cli/install/settings-merge.js +48 -1
- package/dist/cli/upgrade.js +131 -3
- package/dist/config/tier-map.js +18 -25
- package/dist/hooks/push-gate/base.d.ts +57 -0
- package/dist/hooks/push-gate/base.js +77 -0
- package/dist/hooks/push-gate/codex-runner.d.ts +126 -0
- package/dist/hooks/push-gate/codex-runner.js +223 -0
- package/dist/hooks/push-gate/findings.d.ts +68 -0
- package/dist/hooks/push-gate/findings.js +142 -0
- package/dist/hooks/push-gate/halt.d.ts +28 -0
- package/dist/hooks/push-gate/halt.js +49 -0
- package/dist/hooks/push-gate/index.d.ts +90 -0
- package/dist/hooks/push-gate/index.js +351 -0
- package/dist/hooks/push-gate/policy.d.ts +41 -0
- package/dist/hooks/push-gate/policy.js +55 -0
- package/dist/hooks/push-gate/report.d.ts +89 -0
- package/dist/hooks/push-gate/report.js +140 -0
- package/dist/policy/loader.d.ts +10 -10
- package/dist/policy/loader.js +7 -6
- package/dist/policy/types.d.ts +31 -22
- package/package.json +1 -1
- package/dist/cache/review-cache.d.ts +0 -115
- package/dist/cache/review-cache.js +0 -200
- package/dist/cli/cache.d.ts +0 -84
- package/dist/cli/cache.js +0 -150
- package/dist/hooks/review-gate/args.d.ts +0 -126
- package/dist/hooks/review-gate/args.js +0 -315
- package/dist/hooks/review-gate/banner.d.ts +0 -97
- package/dist/hooks/review-gate/banner.js +0 -172
- package/dist/hooks/review-gate/cache-key.d.ts +0 -55
- package/dist/hooks/review-gate/cache-key.js +0 -41
- package/dist/hooks/review-gate/constants.d.ts +0 -26
- package/dist/hooks/review-gate/constants.js +0 -34
- package/dist/hooks/review-gate/errors.d.ts +0 -72
- package/dist/hooks/review-gate/errors.js +0 -100
- package/dist/hooks/review-gate/hash.d.ts +0 -43
- package/dist/hooks/review-gate/hash.js +0 -46
- package/dist/hooks/review-gate/index.d.ts +0 -21
- package/dist/hooks/review-gate/index.js +0 -21
- package/dist/hooks/review-gate/metadata.d.ts +0 -98
- package/dist/hooks/review-gate/metadata.js +0 -158
- package/dist/hooks/review-gate/policy.d.ts +0 -55
- package/dist/hooks/review-gate/policy.js +0 -71
- package/dist/hooks/review-gate/protected-paths.d.ts +0 -46
- package/dist/hooks/review-gate/protected-paths.js +0 -76
- package/hooks/_lib/push-review-core.sh +0 -1250
- package/hooks/commit-review-gate.sh +0 -330
- package/hooks/push-review-gate-git.sh +0 -94
- package/hooks/push-review-gate.sh +0 -92
|
@@ -1,1250 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# hooks/_lib/push-review-core.sh — shared core for push-review adapters.
|
|
3
|
-
#
|
|
4
|
-
# Source, do not execute. Callers (adapters) must:
|
|
5
|
-
# 1. capture stdin into INPUT
|
|
6
|
-
# 2. source this file
|
|
7
|
-
# 3. call `pr_core_run "$0" "$INPUT" "$@"` (passing the adapter's own
|
|
8
|
-
# script path as $1, the raw stdin as $2, and the adapter's argv after)
|
|
9
|
-
#
|
|
10
|
-
# BUG-008 cleanup (0.7.0): the same core serves two physical adapters —
|
|
11
|
-
#
|
|
12
|
-
# hooks/push-review-gate.sh — Claude Code PreToolUse adapter. Stdin
|
|
13
|
-
# is JSON `.tool_input.command`; argv
|
|
14
|
-
# is empty. BUG-008 sniff handles the
|
|
15
|
-
# case where this hook is wired into
|
|
16
|
-
# `.husky/pre-push` directly and git's
|
|
17
|
-
# native refspec lines arrive on stdin.
|
|
18
|
-
#
|
|
19
|
-
# hooks/push-review-gate-git.sh — Native `.husky/pre-push` adapter. Stdin
|
|
20
|
-
# is always git's refspec contract; argv
|
|
21
|
-
# $1 is the remote name, $2 is the URL.
|
|
22
|
-
#
|
|
23
|
-
# Both adapters delegate here unchanged. The sniff inside `pr_core_run`
|
|
24
|
-
# recognizes the two stdin shapes and routes accordingly.
|
|
25
|
-
#
|
|
26
|
-
# The functions are prefixed `pr_` (push-review) so sourcing this file into
|
|
27
|
-
# another hook (which may already define its own helpers) is safe.
|
|
28
|
-
|
|
29
|
-
# The caller sets `set -uo pipefail`; core inherits.
|
|
30
|
-
|
|
31
|
-
# Unused-in-isolation globals that `pr_core_run` writes as locals:
|
|
32
|
-
# REA_ROOT, CMD, CODEX_REQUIRED, ZERO_SHA. We do NOT declare them at file
|
|
33
|
-
# scope — dynamic scoping means `pr_core_run`'s `local` declarations are
|
|
34
|
-
# visible inside the helpers it calls.
|
|
35
|
-
|
|
36
|
-
# ── pr_parse_prepush_stdin ───────────────────────────────────────────────────
|
|
37
|
-
# Parse git's pre-push stdin contract.
|
|
38
|
-
#
|
|
39
|
-
# Stdin shape: one line per refspec, with fields
|
|
40
|
-
# `<local_ref> <local_sha> <remote_ref> <remote_sha>`
|
|
41
|
-
# Emits one record per accepted line on stdout:
|
|
42
|
-
# `local_sha|remote_sha|local_ref|remote_ref`
|
|
43
|
-
# Returns non-zero with no output if stdin does not match the contract, so
|
|
44
|
-
# the caller can switch to argv fallback. Portable to macOS /bin/bash 3.2.
|
|
45
|
-
pr_parse_prepush_stdin() {
|
|
46
|
-
local raw="$1"
|
|
47
|
-
local accepted=0
|
|
48
|
-
local line local_ref local_sha remote_ref remote_sha rest
|
|
49
|
-
local -a records
|
|
50
|
-
records=()
|
|
51
|
-
while IFS= read -r line; do
|
|
52
|
-
[[ -z "$line" ]] && continue
|
|
53
|
-
read -r local_ref local_sha remote_ref remote_sha rest <<<"$line"
|
|
54
|
-
if [[ -z "$local_ref" || -z "$local_sha" || -z "$remote_ref" || -z "$remote_sha" ]]; then
|
|
55
|
-
continue
|
|
56
|
-
fi
|
|
57
|
-
if [[ ! "$local_sha" =~ ^[0-9a-f]{40}$ ]] || [[ ! "$remote_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
|
58
|
-
return 1
|
|
59
|
-
fi
|
|
60
|
-
records+=("${local_sha}|${remote_sha}|${local_ref}|${remote_ref}")
|
|
61
|
-
accepted=1
|
|
62
|
-
done <<<"$raw"
|
|
63
|
-
if [[ "$accepted" -ne 1 ]]; then
|
|
64
|
-
return 1
|
|
65
|
-
fi
|
|
66
|
-
local r
|
|
67
|
-
for r in "${records[@]}"; do
|
|
68
|
-
printf '%s\n' "$r"
|
|
69
|
-
done
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
# ── pr_resolve_argv_refspecs ─────────────────────────────────────────────────
|
|
73
|
-
# Fallback refspec resolver: parse `git push [remote] [refspec...]` from the
|
|
74
|
-
# command string when stdin has no pre-push lines. Emits newline-separated
|
|
75
|
-
# records as "local_sha|remote_sha|local_ref|remote_ref" where `local_sha` is
|
|
76
|
-
# HEAD of the named source ref (or HEAD itself for bare refspecs) and
|
|
77
|
-
# `remote_sha` is zero so merge-base logic falls back to the configured
|
|
78
|
-
# default. Exits the script with code 2 on operator-error conditions
|
|
79
|
-
# (HEAD target, unresolvable source ref).
|
|
80
|
-
#
|
|
81
|
-
# Reads REA_ROOT and ZERO_SHA from the caller's function scope.
|
|
82
|
-
pr_resolve_argv_refspecs() {
|
|
83
|
-
local cmd="$1"
|
|
84
|
-
local segment
|
|
85
|
-
segment=$(printf '%s' "$cmd" | awk '
|
|
86
|
-
{
|
|
87
|
-
idx = match($0, /git[[:space:]]+push([[:space:]]|$)/)
|
|
88
|
-
if (!idx) exit
|
|
89
|
-
tail = substr($0, idx)
|
|
90
|
-
n = match(tail, /[;&|]|&&|\|\|/)
|
|
91
|
-
if (n > 0) tail = substr(tail, 1, n - 1)
|
|
92
|
-
print tail
|
|
93
|
-
}
|
|
94
|
-
')
|
|
95
|
-
|
|
96
|
-
local -a specs
|
|
97
|
-
specs=()
|
|
98
|
-
local seen_push=0 remote_seen=0 delete_mode=0 tok
|
|
99
|
-
# shellcheck disable=SC2086
|
|
100
|
-
set -- $segment
|
|
101
|
-
for tok in "$@"; do
|
|
102
|
-
case "$tok" in
|
|
103
|
-
git|push) seen_push=1; continue ;;
|
|
104
|
-
--delete|-d)
|
|
105
|
-
delete_mode=1
|
|
106
|
-
continue
|
|
107
|
-
;;
|
|
108
|
-
--delete=*)
|
|
109
|
-
delete_mode=1
|
|
110
|
-
specs+=("${tok#--delete=}")
|
|
111
|
-
continue
|
|
112
|
-
;;
|
|
113
|
-
-*) continue ;;
|
|
114
|
-
esac
|
|
115
|
-
[[ "$seen_push" -eq 0 ]] && continue
|
|
116
|
-
if [[ "$remote_seen" -eq 0 ]]; then
|
|
117
|
-
remote_seen=1
|
|
118
|
-
continue
|
|
119
|
-
fi
|
|
120
|
-
if [[ "$delete_mode" -eq 1 ]]; then
|
|
121
|
-
specs+=("__REA_DELETE__${tok}")
|
|
122
|
-
else
|
|
123
|
-
specs+=("$tok")
|
|
124
|
-
fi
|
|
125
|
-
done
|
|
126
|
-
|
|
127
|
-
if [[ "${#specs[@]}" -eq 0 ]]; then
|
|
128
|
-
local upstream dst_ref head_sha
|
|
129
|
-
upstream=$(cd "$REA_ROOT" && git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || echo "")
|
|
130
|
-
dst_ref="refs/heads/main"
|
|
131
|
-
if [[ -n "$upstream" && "$upstream" == */* ]]; then
|
|
132
|
-
dst_ref="refs/heads/${upstream#*/}"
|
|
133
|
-
fi
|
|
134
|
-
head_sha=$(cd "$REA_ROOT" && git rev-parse HEAD 2>/dev/null || echo "")
|
|
135
|
-
[[ -z "$head_sha" ]] && return 1
|
|
136
|
-
printf '%s|%s|HEAD|%s\n' "$head_sha" "$ZERO_SHA" "$dst_ref"
|
|
137
|
-
return 0
|
|
138
|
-
fi
|
|
139
|
-
|
|
140
|
-
local spec src dst src_sha is_delete
|
|
141
|
-
for spec in "${specs[@]}"; do
|
|
142
|
-
is_delete=0
|
|
143
|
-
if [[ "$spec" == __REA_DELETE__* ]]; then
|
|
144
|
-
is_delete=1
|
|
145
|
-
spec="${spec#__REA_DELETE__}"
|
|
146
|
-
fi
|
|
147
|
-
spec="${spec#+}"
|
|
148
|
-
if [[ "$spec" == *:* ]]; then
|
|
149
|
-
src="${spec%%:*}"
|
|
150
|
-
dst="${spec##*:}"
|
|
151
|
-
else
|
|
152
|
-
src="$spec"
|
|
153
|
-
dst="$spec"
|
|
154
|
-
fi
|
|
155
|
-
if [[ -z "$dst" ]]; then
|
|
156
|
-
dst="${spec##*:}"
|
|
157
|
-
src=""
|
|
158
|
-
fi
|
|
159
|
-
dst="${dst#refs/heads/}"
|
|
160
|
-
dst="${dst#refs/for/}"
|
|
161
|
-
if [[ "$is_delete" -eq 1 ]]; then
|
|
162
|
-
if [[ -z "$dst" || "$dst" == "HEAD" ]]; then
|
|
163
|
-
{
|
|
164
|
-
printf 'PUSH BLOCKED: --delete refspec resolves to HEAD or empty (from %q)\n' "$spec"
|
|
165
|
-
} >&2
|
|
166
|
-
exit 2
|
|
167
|
-
fi
|
|
168
|
-
printf '%s|%s|(delete)|refs/heads/%s\n' "$ZERO_SHA" "$ZERO_SHA" "$dst"
|
|
169
|
-
continue
|
|
170
|
-
fi
|
|
171
|
-
if [[ "$dst" == "HEAD" || -z "$dst" ]]; then
|
|
172
|
-
{
|
|
173
|
-
printf 'PUSH BLOCKED: refspec resolves to HEAD (from %q)\n' "$spec"
|
|
174
|
-
printf '\n'
|
|
175
|
-
# shellcheck disable=SC2016
|
|
176
|
-
printf ' `git push <remote> HEAD:<branch>` or similar is almost always\n'
|
|
177
|
-
printf ' operator error in this context. Name the destination branch\n'
|
|
178
|
-
printf ' explicitly so the review gate can diff against it.\n'
|
|
179
|
-
printf '\n'
|
|
180
|
-
} >&2
|
|
181
|
-
exit 2
|
|
182
|
-
fi
|
|
183
|
-
if [[ -z "$src" ]]; then
|
|
184
|
-
printf '%s|%s|(delete)|refs/heads/%s\n' "$ZERO_SHA" "$ZERO_SHA" "$dst"
|
|
185
|
-
continue
|
|
186
|
-
fi
|
|
187
|
-
src_sha=$(cd "$REA_ROOT" && git rev-parse --verify "${src}^{commit}" 2>/dev/null || echo "")
|
|
188
|
-
if [[ -z "$src_sha" ]]; then
|
|
189
|
-
{
|
|
190
|
-
printf 'PUSH BLOCKED: could not resolve source ref %q to a commit.\n' "$src"
|
|
191
|
-
} >&2
|
|
192
|
-
exit 2
|
|
193
|
-
fi
|
|
194
|
-
printf '%s|%s|refs/heads/%s|refs/heads/%s\n' "$src_sha" "$ZERO_SHA" "$src" "$dst"
|
|
195
|
-
done
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
# ── pr_core_run ──────────────────────────────────────────────────────────────
|
|
199
|
-
# Main orchestrator. Arguments:
|
|
200
|
-
# $1 = adapter script path (BASH_SOURCE[0] from the adapter)
|
|
201
|
-
# $2 = raw stdin (INPUT) captured by the adapter
|
|
202
|
-
# $3..$N = adapter's original argv ($@). For git-native adapters, $3 is the
|
|
203
|
-
# remote name and $4 is the URL. For Claude Code, typically absent.
|
|
204
|
-
#
|
|
205
|
-
# The function may `exit 0` (allow), `exit 2` (block), or fall through to
|
|
206
|
-
# section 9 which prints the review prompt and exits 2.
|
|
207
|
-
pr_core_run() {
|
|
208
|
-
local adapter_script="$1"
|
|
209
|
-
local INPUT="$2"
|
|
210
|
-
shift 2
|
|
211
|
-
# Remaining positional args are the adapter's original argv. For a git
|
|
212
|
-
# native pre-push the first is the remote name; for Claude Code it is
|
|
213
|
-
# typically unset. Default to `origin` for BUG-008 sniff consistency.
|
|
214
|
-
local argv_remote="${1:-origin}"
|
|
215
|
-
|
|
216
|
-
# 0.8.0 (#85): when REA_SKIP_CODEX_REVIEW is set, this flag flips to 1
|
|
217
|
-
# in section 5c. The protected-path Codex-audit check (section 7) then
|
|
218
|
-
# treats the requirement as satisfied — but every other gate (HALT,
|
|
219
|
-
# cross-repo guard, ref-resolution, push-review cache, blocked-paths)
|
|
220
|
-
# still runs. Full-gate bypass moved to REA_SKIP_PUSH_REVIEW a release
|
|
221
|
-
# cycle ago; this narrows REA_SKIP_CODEX_REVIEW to what its name implies.
|
|
222
|
-
local CODEX_WAIVER_ACTIVE=0
|
|
223
|
-
|
|
224
|
-
# ── 1a. Cross-repo guard (must come FIRST — before any rea-scoped check) ──
|
|
225
|
-
# BUG-012 (0.6.2) — anchor the install to the SCRIPT'S OWN LOCATION on disk.
|
|
226
|
-
# The hook knows where it lives: installed at `<root>/.claude/hooks/<name>.sh`,
|
|
227
|
-
# so `<root>` is two levels up from the adapter's BASH_SOURCE. No
|
|
228
|
-
# caller-controlled env var participates in the trust decision.
|
|
229
|
-
#
|
|
230
|
-
# See THREAT_MODEL.md § CLAUDE_PROJECT_DIR for the full rationale.
|
|
231
|
-
local SCRIPT_DIR
|
|
232
|
-
SCRIPT_DIR="$(cd -- "$(dirname -- "$adapter_script")" && pwd -P 2>/dev/null)"
|
|
233
|
-
# Walk up from SCRIPT_DIR looking for `.rea/policy.yaml`. This resolves
|
|
234
|
-
# correctly for every reasonable topology — installed copy at
|
|
235
|
-
# `<root>/.claude/hooks/<name>.sh` (2 up), source-of-truth copy at
|
|
236
|
-
# `<root>/hooks/<name>.sh` (1 up, used when rea dogfoods itself or a
|
|
237
|
-
# developer runs `bash hooks/push-review-gate.sh` to smoke-test), and any
|
|
238
|
-
# future `hooks/_lib/` nesting. Cap at 4 levels so a stray hook dropped in
|
|
239
|
-
# the wrong spot fails fast instead of walking to the filesystem root.
|
|
240
|
-
local REA_ROOT=""
|
|
241
|
-
local _anchor_candidate="$SCRIPT_DIR"
|
|
242
|
-
local _i
|
|
243
|
-
for _i in 1 2 3 4; do
|
|
244
|
-
_anchor_candidate="$(cd -- "$_anchor_candidate/.." && pwd -P 2>/dev/null || true)"
|
|
245
|
-
if [[ -n "$_anchor_candidate" && -f "$_anchor_candidate/.rea/policy.yaml" ]]; then
|
|
246
|
-
REA_ROOT="$_anchor_candidate"
|
|
247
|
-
break
|
|
248
|
-
fi
|
|
249
|
-
done
|
|
250
|
-
if [[ -z "$REA_ROOT" ]]; then
|
|
251
|
-
printf 'rea-hook: no .rea/policy.yaml found within 4 parents of %s\n' \
|
|
252
|
-
"$SCRIPT_DIR" >&2
|
|
253
|
-
printf 'rea-hook: is this an installed rea hook, or is `.rea/policy.yaml`\n' >&2
|
|
254
|
-
printf 'rea-hook: nested more than 4 directories above the hook script?\n' >&2
|
|
255
|
-
exit 2
|
|
256
|
-
fi
|
|
257
|
-
|
|
258
|
-
# Advisory-only: warn if the caller set CLAUDE_PROJECT_DIR to a path that
|
|
259
|
-
# does not match the script anchor. Never let the env var override the
|
|
260
|
-
# decision.
|
|
261
|
-
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
|
|
262
|
-
local CPD_REAL
|
|
263
|
-
CPD_REAL=$(cd -- "${CLAUDE_PROJECT_DIR}" 2>/dev/null && pwd -P 2>/dev/null || true)
|
|
264
|
-
if [[ -n "$CPD_REAL" && "$CPD_REAL" != "$REA_ROOT" ]]; then
|
|
265
|
-
printf 'rea-hook: ignoring CLAUDE_PROJECT_DIR=%s — anchoring to script location %s\n' \
|
|
266
|
-
"$CLAUDE_PROJECT_DIR" "$REA_ROOT" >&2
|
|
267
|
-
fi
|
|
268
|
-
fi
|
|
269
|
-
|
|
270
|
-
local CWD_REAL CWD_COMMON REA_COMMON CWD_COMMON_REAL REA_COMMON_REAL
|
|
271
|
-
CWD_REAL=$(pwd -P 2>/dev/null || pwd)
|
|
272
|
-
CWD_COMMON=$(git -C "$CWD_REAL" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
|
|
273
|
-
REA_COMMON=$(git -C "$REA_ROOT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
|
|
274
|
-
if [[ -n "$CWD_COMMON" && -n "$REA_COMMON" ]]; then
|
|
275
|
-
# Both sides are git checkouts. Realpath'd common-dirs match IFF they
|
|
276
|
-
# point at the same underlying repository (main or linked worktree).
|
|
277
|
-
CWD_COMMON_REAL=$(cd "$CWD_COMMON" 2>/dev/null && pwd -P 2>/dev/null || echo "$CWD_COMMON")
|
|
278
|
-
REA_COMMON_REAL=$(cd "$REA_COMMON" 2>/dev/null && pwd -P 2>/dev/null || echo "$REA_COMMON")
|
|
279
|
-
if [[ "$CWD_COMMON_REAL" != "$REA_COMMON_REAL" ]]; then
|
|
280
|
-
exit 0
|
|
281
|
-
fi
|
|
282
|
-
elif [[ -z "$CWD_COMMON" && -z "$REA_COMMON" ]]; then
|
|
283
|
-
# Both sides non-git: legitimate 0.5.1 non-git escape-hatch. Fall back to
|
|
284
|
-
# a literal path-prefix match. Quoted expansions prevent glob expansion.
|
|
285
|
-
case "$CWD_REAL/" in
|
|
286
|
-
"$REA_ROOT"/*|"$REA_ROOT"/) : ;; # inside rea — run the gate
|
|
287
|
-
*) exit 0 ;; # outside rea — not our gate
|
|
288
|
-
esac
|
|
289
|
-
fi
|
|
290
|
-
# Mixed state (one side git, other not) or either probe failed → fail
|
|
291
|
-
# CLOSED: run the gate.
|
|
292
|
-
|
|
293
|
-
# ── 2. Dependency check ───────────────────────────────────────────────────
|
|
294
|
-
if ! command -v jq >/dev/null 2>&1; then
|
|
295
|
-
printf 'REA ERROR: jq is required but not installed.\n' >&2
|
|
296
|
-
printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
|
|
297
|
-
exit 2
|
|
298
|
-
fi
|
|
299
|
-
|
|
300
|
-
# ── 3. HALT check ─────────────────────────────────────────────────────────
|
|
301
|
-
local HALT_FILE="${REA_ROOT}/.rea/HALT"
|
|
302
|
-
if [ -f "$HALT_FILE" ]; then
|
|
303
|
-
printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
|
|
304
|
-
"$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
|
|
305
|
-
exit 2
|
|
306
|
-
fi
|
|
307
|
-
|
|
308
|
-
# ── 4. Parse command ──────────────────────────────────────────────────────
|
|
309
|
-
local CMD
|
|
310
|
-
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
311
|
-
|
|
312
|
-
# ── 4a. BUG-008: self-detect git's native pre-push contract ───────────────
|
|
313
|
-
# When the hook is wired into `.husky/pre-push`, git invokes it with
|
|
314
|
-
# `$1 = remote name`, `$2 = remote url`
|
|
315
|
-
# and delivers one line per refspec on stdin:
|
|
316
|
-
# `<local_ref> <local_sha> <remote_ref> <remote_sha>`
|
|
317
|
-
# The Claude Code PreToolUse wrapper instead delivers JSON on stdin, which
|
|
318
|
-
# is what the jq parse above targets. When jq returns empty, the stdin may
|
|
319
|
-
# in fact be git's pre-push ref-list — sniff the first non-blank line, and
|
|
320
|
-
# if it matches the `<ref> <40-hex> <ref> <40-hex>` shape, synthesize CMD
|
|
321
|
-
# as `git push <remote>` (from the adapter's argv_remote) so the remainder
|
|
322
|
-
# of the gate runs through the pre-push parser in step 6 rather than the
|
|
323
|
-
# argv fallback.
|
|
324
|
-
#
|
|
325
|
-
# Any other stdin shape (empty, random JSON, a non-push tool call) still
|
|
326
|
-
# exits 0 here — the gate is a no-op for non-push Bash calls by design.
|
|
327
|
-
local FIRST_STDIN_LINE
|
|
328
|
-
FIRST_STDIN_LINE=$(printf '%s' "$INPUT" | awk 'NF { print; exit }')
|
|
329
|
-
if [[ -z "$CMD" ]]; then
|
|
330
|
-
if [[ -n "$FIRST_STDIN_LINE" ]] \
|
|
331
|
-
&& printf '%s' "$FIRST_STDIN_LINE" \
|
|
332
|
-
| grep -qE '^[^[:space:]]+[[:space:]]+[0-9a-f]{40}[[:space:]]+[^[:space:]]+[[:space:]]+[0-9a-f]{40}[[:space:]]*$'; then
|
|
333
|
-
CMD="git push ${argv_remote}"
|
|
334
|
-
else
|
|
335
|
-
exit 0
|
|
336
|
-
fi
|
|
337
|
-
fi
|
|
338
|
-
|
|
339
|
-
# Only trigger on git push commands
|
|
340
|
-
if ! printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+push'; then
|
|
341
|
-
exit 0
|
|
342
|
-
fi
|
|
343
|
-
|
|
344
|
-
# ── 5. REA_SKIP_PUSH_REVIEW — whole-gate escape hatch ─────────────────────
|
|
345
|
-
# An opt-in bypass for the ENTIRE push-review gate (not just the Codex
|
|
346
|
-
# branch). Exists to unblock consumers when rea itself is broken or a
|
|
347
|
-
# corrupt policy/audit file would otherwise deadlock a push. Requires an
|
|
348
|
-
# explicit non-empty reason; the value of REA_SKIP_PUSH_REVIEW is recorded
|
|
349
|
-
# verbatim in the audit record as the reason.
|
|
350
|
-
#
|
|
351
|
-
# Audit tool_name is `push.review.skipped`. This is intentionally NOT
|
|
352
|
-
# `codex.review` or `codex.review.skipped` — a skip of the whole gate is a
|
|
353
|
-
# separately-audited event and does not satisfy the Codex-review jq
|
|
354
|
-
# predicate.
|
|
355
|
-
if [[ -n "${REA_SKIP_PUSH_REVIEW:-}" ]]; then
|
|
356
|
-
local SKIP_REASON="$REA_SKIP_PUSH_REVIEW"
|
|
357
|
-
local AUDIT_APPEND_JS="${REA_ROOT}/dist/audit/append.js"
|
|
358
|
-
|
|
359
|
-
if [[ ! -f "$AUDIT_APPEND_JS" ]]; then
|
|
360
|
-
{
|
|
361
|
-
printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW requires rea to be built.\n'
|
|
362
|
-
printf '\n'
|
|
363
|
-
printf ' REA_SKIP_PUSH_REVIEW is set but %s is missing.\n' "$AUDIT_APPEND_JS"
|
|
364
|
-
printf ' Run: pnpm build\n'
|
|
365
|
-
printf '\n'
|
|
366
|
-
} >&2
|
|
367
|
-
exit 2
|
|
368
|
-
fi
|
|
369
|
-
|
|
370
|
-
# Codex F2: CI-aware refusal.
|
|
371
|
-
if [[ -n "${CI:-}" ]]; then
|
|
372
|
-
local ALLOW_CI_SKIP=""
|
|
373
|
-
local READ_FIELD_JS="${REA_ROOT}/dist/scripts/read-policy-field.js"
|
|
374
|
-
if [[ -f "$READ_FIELD_JS" ]]; then
|
|
375
|
-
ALLOW_CI_SKIP=$(REA_ROOT="$REA_ROOT" node "$READ_FIELD_JS" review.allow_skip_in_ci 2>/dev/null || echo "")
|
|
376
|
-
fi
|
|
377
|
-
if [[ "$ALLOW_CI_SKIP" != "true" ]]; then
|
|
378
|
-
{
|
|
379
|
-
printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW refused in CI context.\n'
|
|
380
|
-
printf '\n'
|
|
381
|
-
printf ' CI env var is set. An unauthenticated env-var bypass in a shared\n'
|
|
382
|
-
printf ' build agent is not trusted. To enable, set\n'
|
|
383
|
-
printf ' review:\n'
|
|
384
|
-
printf ' allow_skip_in_ci: true\n'
|
|
385
|
-
printf ' in .rea/policy.yaml — explicitly authorizing env-var skips in CI.\n'
|
|
386
|
-
printf '\n'
|
|
387
|
-
} >&2
|
|
388
|
-
exit 2
|
|
389
|
-
fi
|
|
390
|
-
fi
|
|
391
|
-
|
|
392
|
-
local SKIP_ACTOR
|
|
393
|
-
SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.email 2>/dev/null || echo "")
|
|
394
|
-
if [[ -z "$SKIP_ACTOR" ]]; then
|
|
395
|
-
SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.name 2>/dev/null || echo "")
|
|
396
|
-
fi
|
|
397
|
-
if [[ -z "$SKIP_ACTOR" ]]; then
|
|
398
|
-
{
|
|
399
|
-
printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW requires a git identity.\n'
|
|
400
|
-
printf '\n'
|
|
401
|
-
# shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
|
|
402
|
-
printf ' Neither `git config user.email` nor `git config user.name`\n'
|
|
403
|
-
printf ' is set. The skip audit record would have no actor; refusing\n'
|
|
404
|
-
printf ' to bypass without one.\n'
|
|
405
|
-
printf '\n'
|
|
406
|
-
} >&2
|
|
407
|
-
exit 2
|
|
408
|
-
fi
|
|
409
|
-
|
|
410
|
-
local SKIP_BRANCH SKIP_HEAD
|
|
411
|
-
SKIP_BRANCH=$(cd "$REA_ROOT" && git branch --show-current 2>/dev/null || echo "")
|
|
412
|
-
SKIP_HEAD=$(cd "$REA_ROOT" && git rev-parse HEAD 2>/dev/null || echo "")
|
|
413
|
-
|
|
414
|
-
# Codex F2: record OS identity alongside the (mutable, git-sourced) actor
|
|
415
|
-
# so downstream auditors can reconstruct who REALLY invoked the bypass on
|
|
416
|
-
# a shared host. None of these are forgeable from inside the push process
|
|
417
|
-
# alone.
|
|
418
|
-
local SKIP_OS_UID SKIP_OS_WHOAMI SKIP_OS_HOST SKIP_OS_PID SKIP_OS_PPID
|
|
419
|
-
local SKIP_OS_PPID_CMD SKIP_OS_TTY SKIP_OS_CI
|
|
420
|
-
SKIP_OS_UID=$(id -u 2>/dev/null || echo "")
|
|
421
|
-
SKIP_OS_WHOAMI=$(whoami 2>/dev/null || echo "")
|
|
422
|
-
SKIP_OS_HOST=$(hostname 2>/dev/null || echo "")
|
|
423
|
-
SKIP_OS_PID=$$
|
|
424
|
-
SKIP_OS_PPID=$PPID
|
|
425
|
-
SKIP_OS_PPID_CMD=$(ps -o command= -p "$PPID" 2>/dev/null | head -c 512 || echo "")
|
|
426
|
-
SKIP_OS_TTY=$(tty 2>/dev/null || echo "not-a-tty")
|
|
427
|
-
SKIP_OS_CI="${CI:-}"
|
|
428
|
-
|
|
429
|
-
local SKIP_METADATA
|
|
430
|
-
SKIP_METADATA=$(jq -n \
|
|
431
|
-
--arg head_sha "$SKIP_HEAD" \
|
|
432
|
-
--arg branch "$SKIP_BRANCH" \
|
|
433
|
-
--arg reason "$SKIP_REASON" \
|
|
434
|
-
--arg actor "$SKIP_ACTOR" \
|
|
435
|
-
--arg os_uid "$SKIP_OS_UID" \
|
|
436
|
-
--arg os_whoami "$SKIP_OS_WHOAMI" \
|
|
437
|
-
--arg os_hostname "$SKIP_OS_HOST" \
|
|
438
|
-
--argjson os_pid "$SKIP_OS_PID" \
|
|
439
|
-
--argjson os_ppid "$SKIP_OS_PPID" \
|
|
440
|
-
--arg os_ppid_cmd "$SKIP_OS_PPID_CMD" \
|
|
441
|
-
--arg os_tty "$SKIP_OS_TTY" \
|
|
442
|
-
--arg os_ci "$SKIP_OS_CI" \
|
|
443
|
-
'{
|
|
444
|
-
head_sha: $head_sha,
|
|
445
|
-
branch: $branch,
|
|
446
|
-
reason: $reason,
|
|
447
|
-
actor: $actor,
|
|
448
|
-
verdict: "skipped",
|
|
449
|
-
os_identity: {
|
|
450
|
-
uid: $os_uid,
|
|
451
|
-
whoami: $os_whoami,
|
|
452
|
-
hostname: $os_hostname,
|
|
453
|
-
pid: $os_pid,
|
|
454
|
-
ppid: $os_ppid,
|
|
455
|
-
ppid_cmd: $os_ppid_cmd,
|
|
456
|
-
tty: $os_tty,
|
|
457
|
-
ci: $os_ci
|
|
458
|
-
}
|
|
459
|
-
}' 2>/dev/null)
|
|
460
|
-
|
|
461
|
-
if [[ -z "$SKIP_METADATA" ]]; then
|
|
462
|
-
{
|
|
463
|
-
printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW could not serialize audit metadata.\n' >&2
|
|
464
|
-
} >&2
|
|
465
|
-
exit 2
|
|
466
|
-
fi
|
|
467
|
-
|
|
468
|
-
REA_ROOT="$REA_ROOT" REA_SKIP_METADATA="$SKIP_METADATA" \
|
|
469
|
-
node --input-type=module -e "
|
|
470
|
-
const mod = await import(process.env.REA_ROOT + '/dist/audit/append.js');
|
|
471
|
-
const metadata = JSON.parse(process.env.REA_SKIP_METADATA);
|
|
472
|
-
await mod.appendAuditRecord(process.env.REA_ROOT, {
|
|
473
|
-
tool_name: 'push.review.skipped',
|
|
474
|
-
server_name: 'rea.escape_hatch',
|
|
475
|
-
status: mod.InvocationStatus.Allowed,
|
|
476
|
-
tier: mod.Tier.Read,
|
|
477
|
-
metadata,
|
|
478
|
-
});
|
|
479
|
-
" 2>/dev/null
|
|
480
|
-
local NODE_STATUS=$?
|
|
481
|
-
if [[ "$NODE_STATUS" -ne 0 ]]; then
|
|
482
|
-
{
|
|
483
|
-
printf 'PUSH BLOCKED: REA_SKIP_PUSH_REVIEW audit-append failed (node exit %s).\n' "$NODE_STATUS"
|
|
484
|
-
printf ' Refusing to bypass the push gate without a receipt.\n'
|
|
485
|
-
} >&2
|
|
486
|
-
exit 2
|
|
487
|
-
fi
|
|
488
|
-
|
|
489
|
-
{
|
|
490
|
-
printf '\n'
|
|
491
|
-
printf '== PUSH REVIEW GATE SKIPPED via REA_SKIP_PUSH_REVIEW\n'
|
|
492
|
-
printf ' Reason: %s\n' "$SKIP_REASON"
|
|
493
|
-
printf ' Actor: %s\n' "$SKIP_ACTOR"
|
|
494
|
-
printf ' Branch: %s\n' "${SKIP_BRANCH:-<detached>}"
|
|
495
|
-
printf ' Head: %s\n' "${SKIP_HEAD:-<unknown>}"
|
|
496
|
-
printf ' Audited: .rea/audit.jsonl (tool_name=push.review.skipped)\n'
|
|
497
|
-
printf '\n'
|
|
498
|
-
printf ' This is a gate weakening. Every invocation is permanently audited.\n'
|
|
499
|
-
printf '\n'
|
|
500
|
-
} >&2
|
|
501
|
-
exit 0
|
|
502
|
-
fi
|
|
503
|
-
|
|
504
|
-
# ── 5b. Resolve review.codex_required (hoisted from section 7a) ───────────
|
|
505
|
-
# We need this BEFORE the REA_SKIP_CODEX_REVIEW check so G11.4 first-class
|
|
506
|
-
# no-Codex mode stays a clean no-op: when the policy says Codex is not
|
|
507
|
-
# required at all, there is nothing to skip, and setting
|
|
508
|
-
# REA_SKIP_CODEX_REVIEW must not write a skip audit record.
|
|
509
|
-
#
|
|
510
|
-
# Fail-closed: a malformed/unparseable policy is treated as
|
|
511
|
-
# codex_required=true so we never silently drop the Codex gate on a broken
|
|
512
|
-
# policy file.
|
|
513
|
-
local READ_FIELD_JS="${REA_ROOT}/dist/scripts/read-policy-field.js"
|
|
514
|
-
local CODEX_REQUIRED="true"
|
|
515
|
-
if [[ -f "$READ_FIELD_JS" ]]; then
|
|
516
|
-
local FIELD_VALUE FIELD_STATUS
|
|
517
|
-
FIELD_VALUE=$(REA_ROOT="$REA_ROOT" node "$READ_FIELD_JS" review.codex_required 2>/dev/null)
|
|
518
|
-
FIELD_STATUS=$?
|
|
519
|
-
case "$FIELD_STATUS" in
|
|
520
|
-
0)
|
|
521
|
-
if [[ "$FIELD_VALUE" == "false" ]]; then
|
|
522
|
-
CODEX_REQUIRED="false"
|
|
523
|
-
elif [[ "$FIELD_VALUE" == "true" ]]; then
|
|
524
|
-
CODEX_REQUIRED="true"
|
|
525
|
-
else
|
|
526
|
-
printf 'REA WARN: review.codex_required resolved to non-boolean %q — treating as true\n' "$FIELD_VALUE" >&2
|
|
527
|
-
CODEX_REQUIRED="true"
|
|
528
|
-
fi
|
|
529
|
-
;;
|
|
530
|
-
1)
|
|
531
|
-
CODEX_REQUIRED="true"
|
|
532
|
-
;;
|
|
533
|
-
*)
|
|
534
|
-
printf 'REA WARN: read-policy-field exited %s — treating review.codex_required as true (fail-closed)\n' "$FIELD_STATUS" >&2
|
|
535
|
-
CODEX_REQUIRED="true"
|
|
536
|
-
;;
|
|
537
|
-
esac
|
|
538
|
-
fi
|
|
539
|
-
|
|
540
|
-
# ── 5c. REA_SKIP_CODEX_REVIEW — Codex-review bypass ───────────────────────
|
|
541
|
-
# Runs here (before ref-resolution) so ref-resolution failures in section 6
|
|
542
|
-
# do not strand an operator who has committed to the skip. See the
|
|
543
|
-
# adapter's file-top docstring for the ordering rationale (0.7.0).
|
|
544
|
-
#
|
|
545
|
-
# Gated on CODEX_REQUIRED=true (from section 5b): if policy explicitly opts
|
|
546
|
-
# into no-Codex mode, the skip is a no-op — nothing to skip, no audit noise.
|
|
547
|
-
if [[ -n "${REA_SKIP_CODEX_REVIEW:-}" && "$CODEX_REQUIRED" == "true" ]]; then
|
|
548
|
-
local SKIP_REASON="$REA_SKIP_CODEX_REVIEW"
|
|
549
|
-
local AUDIT_APPEND_JS="${REA_ROOT}/dist/audit/append.js"
|
|
550
|
-
|
|
551
|
-
if [[ ! -f "$AUDIT_APPEND_JS" ]]; then
|
|
552
|
-
{
|
|
553
|
-
printf 'PUSH BLOCKED: escape hatch requires rea to be built.\n'
|
|
554
|
-
printf '\n'
|
|
555
|
-
printf ' REA_SKIP_CODEX_REVIEW is set but %s is missing.\n' "$AUDIT_APPEND_JS"
|
|
556
|
-
printf ' Run: pnpm build\n'
|
|
557
|
-
printf '\n'
|
|
558
|
-
} >&2
|
|
559
|
-
exit 2
|
|
560
|
-
fi
|
|
561
|
-
|
|
562
|
-
local SKIP_ACTOR
|
|
563
|
-
SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.email 2>/dev/null || echo "")
|
|
564
|
-
if [[ -z "$SKIP_ACTOR" ]]; then
|
|
565
|
-
SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.name 2>/dev/null || echo "")
|
|
566
|
-
fi
|
|
567
|
-
if [[ -z "$SKIP_ACTOR" ]]; then
|
|
568
|
-
{
|
|
569
|
-
printf 'PUSH BLOCKED: escape hatch requires a git identity.\n'
|
|
570
|
-
printf '\n'
|
|
571
|
-
# shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
|
|
572
|
-
printf ' Neither `git config user.email` nor `git config user.name`\n'
|
|
573
|
-
printf ' is set. The skip audit record would have no actor; refusing\n'
|
|
574
|
-
printf ' to bypass without one.\n'
|
|
575
|
-
printf '\n'
|
|
576
|
-
} >&2
|
|
577
|
-
exit 2
|
|
578
|
-
fi
|
|
579
|
-
|
|
580
|
-
# Metadata source of truth: the pre-push stdin contract. Parse the FIRST
|
|
581
|
-
# well-formed refspec line from the captured INPUT so the skip audit
|
|
582
|
-
# record describes the actual push, not the checkout that happened to be
|
|
583
|
-
# active.
|
|
584
|
-
local SKIP_HEAD="" SKIP_TARGET="" SKIP_SOURCE=""
|
|
585
|
-
local SKIP_UPSTREAM
|
|
586
|
-
|
|
587
|
-
local __line __lref __lsha __rref __rsha __rest
|
|
588
|
-
while IFS= read -r __line; do
|
|
589
|
-
# shellcheck disable=SC2034 # field-splitting into named vars is the intent
|
|
590
|
-
read -r __lref __lsha __rref __rsha __rest <<< "$__line"
|
|
591
|
-
if [[ -z "$__rest" && "$__lsha" =~ ^[0-9a-f]{40}$ && -n "$__rref" ]]; then
|
|
592
|
-
SKIP_HEAD="$__lsha"
|
|
593
|
-
SKIP_TARGET="${__rref#refs/heads/}"
|
|
594
|
-
SKIP_SOURCE="prepush-stdin"
|
|
595
|
-
break
|
|
596
|
-
fi
|
|
597
|
-
done <<< "$INPUT"
|
|
598
|
-
|
|
599
|
-
if [[ -z "$SKIP_HEAD" ]]; then
|
|
600
|
-
SKIP_HEAD=$(cd "$REA_ROOT" && git rev-parse HEAD 2>/dev/null || echo "")
|
|
601
|
-
SKIP_UPSTREAM=$(cd "$REA_ROOT" && git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || echo "")
|
|
602
|
-
SKIP_TARGET="main"
|
|
603
|
-
if [[ -n "$SKIP_UPSTREAM" && "$SKIP_UPSTREAM" == */* ]]; then
|
|
604
|
-
SKIP_TARGET="${SKIP_UPSTREAM#*/}"
|
|
605
|
-
fi
|
|
606
|
-
SKIP_SOURCE="local-fallback"
|
|
607
|
-
fi
|
|
608
|
-
|
|
609
|
-
local SKIP_METADATA
|
|
610
|
-
SKIP_METADATA=$(jq -n \
|
|
611
|
-
--arg head_sha "$SKIP_HEAD" \
|
|
612
|
-
--arg target "$SKIP_TARGET" \
|
|
613
|
-
--arg reason "$SKIP_REASON" \
|
|
614
|
-
--arg actor "$SKIP_ACTOR" \
|
|
615
|
-
--arg source "$SKIP_SOURCE" \
|
|
616
|
-
'{
|
|
617
|
-
head_sha: $head_sha,
|
|
618
|
-
target: $target,
|
|
619
|
-
reason: $reason,
|
|
620
|
-
actor: $actor,
|
|
621
|
-
verdict: "skipped",
|
|
622
|
-
files_changed: null,
|
|
623
|
-
metadata_source: $source
|
|
624
|
-
}' 2>/dev/null)
|
|
625
|
-
|
|
626
|
-
if [[ -z "$SKIP_METADATA" ]]; then
|
|
627
|
-
{
|
|
628
|
-
printf 'PUSH BLOCKED: escape hatch could not serialize audit metadata.\n' >&2
|
|
629
|
-
} >&2
|
|
630
|
-
exit 2
|
|
631
|
-
fi
|
|
632
|
-
|
|
633
|
-
REA_ROOT="$REA_ROOT" REA_SKIP_METADATA="$SKIP_METADATA" \
|
|
634
|
-
node --input-type=module -e "
|
|
635
|
-
const mod = await import(process.env.REA_ROOT + '/dist/audit/append.js');
|
|
636
|
-
const metadata = JSON.parse(process.env.REA_SKIP_METADATA);
|
|
637
|
-
await mod.appendAuditRecord(process.env.REA_ROOT, {
|
|
638
|
-
tool_name: 'codex.review.skipped',
|
|
639
|
-
server_name: 'rea.escape_hatch',
|
|
640
|
-
status: mod.InvocationStatus.Allowed,
|
|
641
|
-
tier: mod.Tier.Read,
|
|
642
|
-
metadata,
|
|
643
|
-
});
|
|
644
|
-
" 2>/dev/null
|
|
645
|
-
local NODE_STATUS=$?
|
|
646
|
-
if [[ "$NODE_STATUS" -ne 0 ]]; then
|
|
647
|
-
{
|
|
648
|
-
printf 'PUSH BLOCKED: escape hatch audit-append failed (node exit %s).\n' "$NODE_STATUS"
|
|
649
|
-
printf ' Refusing to bypass the Codex-review gate without a receipt.\n'
|
|
650
|
-
} >&2
|
|
651
|
-
exit 2
|
|
652
|
-
fi
|
|
653
|
-
|
|
654
|
-
{
|
|
655
|
-
printf '\n'
|
|
656
|
-
printf '== CODEX REVIEW WAIVER active (REA_SKIP_CODEX_REVIEW)\n'
|
|
657
|
-
printf ' Reason: %s\n' "$SKIP_REASON"
|
|
658
|
-
printf ' Actor: %s\n' "$SKIP_ACTOR"
|
|
659
|
-
printf ' Head SHA: %s\n' "${SKIP_HEAD:-<unknown>}"
|
|
660
|
-
printf ' Audited: .rea/audit.jsonl (tool_name=codex.review.skipped)\n'
|
|
661
|
-
printf '\n'
|
|
662
|
-
printf ' Scope: waives the protected-path Codex-audit requirement only.\n'
|
|
663
|
-
printf ' Still active: HALT, cross-repo guard, ref-resolution,\n'
|
|
664
|
-
printf ' push-review cache. For a full-gate bypass\n'
|
|
665
|
-
# shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
|
|
666
|
-
printf ' use `REA_SKIP_PUSH_REVIEW=<reason>`.\n'
|
|
667
|
-
printf '\n'
|
|
668
|
-
printf ' This is a gate weakening. The waiver receipt is written BEFORE\n'
|
|
669
|
-
printf ' this banner — seeing this banner means the audit is durable.\n'
|
|
670
|
-
printf '\n'
|
|
671
|
-
} >&2
|
|
672
|
-
CODEX_WAIVER_ACTIVE=1
|
|
673
|
-
fi
|
|
674
|
-
|
|
675
|
-
# ── 6. Determine source/target commits for each refspec ───────────────────
|
|
676
|
-
# The authoritative source for which commits are being pushed is the pre-
|
|
677
|
-
# push hook stdin contract: one line per refspec, with fields
|
|
678
|
-
# <local_ref> <local_sha> <remote_ref> <remote_sha>
|
|
679
|
-
# (https://git-scm.com/docs/githooks#_pre_push). We drive the gate off
|
|
680
|
-
# those SHAs directly — NOT off HEAD — so that `git push origin hotfix:main`
|
|
681
|
-
# from a checked-out `foo` branch reviews the `hotfix` commits, not `foo`.
|
|
682
|
-
#
|
|
683
|
-
# If what we read on stdin does not look like pre-push refspec lines, we
|
|
684
|
-
# treat it as "no stdin" and use the argv fallback.
|
|
685
|
-
local ZERO_SHA='0000000000000000000000000000000000000000'
|
|
686
|
-
local CURRENT_BRANCH
|
|
687
|
-
CURRENT_BRANCH=$(cd "$REA_ROOT" && git branch --show-current 2>/dev/null || echo "")
|
|
688
|
-
|
|
689
|
-
# Collect refspec records. Stdin takes priority; fall back to argv parsing.
|
|
690
|
-
local -a REFSPEC_RECORDS
|
|
691
|
-
REFSPEC_RECORDS=()
|
|
692
|
-
local RECORDS_OUT _rec
|
|
693
|
-
if RECORDS_OUT=$(pr_parse_prepush_stdin "$INPUT") && [[ -n "$RECORDS_OUT" ]]; then
|
|
694
|
-
:
|
|
695
|
-
else
|
|
696
|
-
RECORDS_OUT=$(pr_resolve_argv_refspecs "$CMD")
|
|
697
|
-
fi
|
|
698
|
-
while IFS= read -r _rec; do
|
|
699
|
-
[[ -z "$_rec" ]] && continue
|
|
700
|
-
REFSPEC_RECORDS+=("$_rec")
|
|
701
|
-
done <<<"$RECORDS_OUT"
|
|
702
|
-
|
|
703
|
-
if [[ "${#REFSPEC_RECORDS[@]}" -eq 0 ]]; then
|
|
704
|
-
{
|
|
705
|
-
printf 'PUSH BLOCKED: no push refspecs could be resolved.\n'
|
|
706
|
-
printf ' Refusing to pass without a source commit to review.\n'
|
|
707
|
-
} >&2
|
|
708
|
-
exit 2
|
|
709
|
-
fi
|
|
710
|
-
|
|
711
|
-
# ── 7. Pick the source commit and merge-base to review ────────────────────
|
|
712
|
-
# Across all refspecs, we pick the one whose source commit is furthest from
|
|
713
|
-
# its merge-base (i.e. the largest diff). That way a mixed push like
|
|
714
|
-
# `foo:main bar:dev` is gated on whichever refspec actually contributes new
|
|
715
|
-
# commits. A deletion refspec (local_sha all zeros) is still concerning —
|
|
716
|
-
# we check the remote side for protected-path changes against the merge-
|
|
717
|
-
# base of the remote sha and the default branch, but the diff body comes
|
|
718
|
-
# from the non-delete refspec if present. If every refspec is a delete, we
|
|
719
|
-
# fail-closed and require an explicit review.
|
|
720
|
-
local SOURCE_SHA="" MERGE_BASE="" TARGET_BRANCH="" SOURCE_REF=""
|
|
721
|
-
local HAS_DELETE=0 BEST_COUNT=0
|
|
722
|
-
local rec local_sha remote_sha local_ref remote_ref target resolved_base mb mb_status count count_status
|
|
723
|
-
for rec in "${REFSPEC_RECORDS[@]}"; do
|
|
724
|
-
IFS='|' read -r local_sha remote_sha local_ref remote_ref <<<"$rec"
|
|
725
|
-
target="${remote_ref#refs/heads/}"
|
|
726
|
-
target="${target#refs/for/}"
|
|
727
|
-
[[ -z "$target" ]] && target="main"
|
|
728
|
-
# Defect N: track the SEMANTIC base (the ref the diff was anchored on)
|
|
729
|
-
# distinctly from `target` (the pushed remote ref). For a tracked branch
|
|
730
|
-
# they coincide; for a new branch, `target` is the branch name being
|
|
731
|
-
# created — which is NOT what we reviewed against, so `Target:` must
|
|
732
|
-
# echo `resolved_base` instead. Default to `target` for the tracked
|
|
733
|
-
# case; the new-branch path overrides with the resolved default_ref
|
|
734
|
-
# short name below.
|
|
735
|
-
resolved_base="$target"
|
|
736
|
-
|
|
737
|
-
if [[ "$local_sha" == "$ZERO_SHA" ]]; then
|
|
738
|
-
HAS_DELETE=1
|
|
739
|
-
continue
|
|
740
|
-
fi
|
|
741
|
-
|
|
742
|
-
if [[ "$remote_sha" != "$ZERO_SHA" ]]; then
|
|
743
|
-
if ! (cd "$REA_ROOT" && git cat-file -e "${remote_sha}^{commit}" 2>/dev/null); then
|
|
744
|
-
{
|
|
745
|
-
printf 'PUSH BLOCKED: remote object %s is not in the local object DB.\n' "$remote_sha"
|
|
746
|
-
printf '\n'
|
|
747
|
-
printf ' The gate cannot compute a review diff without it. Fetch the\n'
|
|
748
|
-
printf ' remote and retry:\n'
|
|
749
|
-
printf '\n'
|
|
750
|
-
printf ' git fetch origin\n'
|
|
751
|
-
printf ' # then retry the push\n'
|
|
752
|
-
printf '\n'
|
|
753
|
-
} >&2
|
|
754
|
-
exit 2
|
|
755
|
-
fi
|
|
756
|
-
mb=$(cd "$REA_ROOT" && git merge-base "$remote_sha" "$local_sha" 2>/dev/null)
|
|
757
|
-
mb_status=$?
|
|
758
|
-
if [[ "$mb_status" -ne 0 || -z "$mb" ]]; then
|
|
759
|
-
{
|
|
760
|
-
printf 'PUSH BLOCKED: no merge-base between remote %s and local %s\n' \
|
|
761
|
-
"${remote_sha:0:12}" "${local_sha:0:12}"
|
|
762
|
-
printf ' The two histories are unrelated; refusing to pass without a\n'
|
|
763
|
-
printf ' reviewable diff.\n'
|
|
764
|
-
} >&2
|
|
765
|
-
exit 2
|
|
766
|
-
fi
|
|
767
|
-
else
|
|
768
|
-
# New branch (remote_sha == ZERO). `target` is the REMOTE ref name (the
|
|
769
|
-
# branch being created on origin), not a sensible merge-base anchor:
|
|
770
|
-
# if the local repo already has a branch by that name pointing at
|
|
771
|
-
# `local_sha`, `git merge-base <target> <local_sha>` returns `local_sha`
|
|
772
|
-
# — collapsing the reviewable diff to empty and silently bypassing the
|
|
773
|
-
# gate.
|
|
774
|
-
#
|
|
775
|
-
# We MUST anchor on a REMOTE-TRACKING ref (e.g. refs/remotes/origin/main),
|
|
776
|
-
# not the bare branch name. Bare `main` resolves to the local short ref
|
|
777
|
-
# `refs/heads/main`, which the pusher controls — a local `main` that has
|
|
778
|
-
# been fast-forwarded to contain the feature tip (or a rebased topic
|
|
779
|
-
# branch) would give `merge-base main <local_sha> == local_sha`, silently
|
|
780
|
-
# passing the gate. Remote-tracking refs are server-authoritative from
|
|
781
|
-
# the most recent fetch, so they cannot be tampered with locally.
|
|
782
|
-
#
|
|
783
|
-
# argv_remote is set from the adapter's argv (git passes the remote name
|
|
784
|
-
# as $1 on pre-push); defaults to "origin" when absent (BUG-008 sniff).
|
|
785
|
-
#
|
|
786
|
-
# Defect N (0.10.1): BEFORE falling back to the remote's default branch,
|
|
787
|
-
# consult per-branch config `branch.<source>.base`. A feature branch
|
|
788
|
-
# targeting `dev` in a main-as-production repo would otherwise resolve
|
|
789
|
-
# against `origin/main` silently, producing a diff that spans the entire
|
|
790
|
-
# dev→main history — reviewers see "Scope: 28690 lines" for a 4-file
|
|
791
|
-
# change. The git-config route uses local branch knowledge that is
|
|
792
|
-
# authoritative for this working copy (set via `git branch --set-upstream`,
|
|
793
|
-
# or by CI tooling that tracks the intended target). This is consulted
|
|
794
|
-
# BEFORE origin/HEAD because the latter is a server-default that may
|
|
795
|
-
# mis-represent the reviewer's actual intent for this specific branch.
|
|
796
|
-
local default_ref default_ref_status configured_base source_branch
|
|
797
|
-
source_branch="${local_ref#refs/heads/}"
|
|
798
|
-
default_ref=""
|
|
799
|
-
# Codex 0.10.1 finding #1: `local` is function-scoped, not loop-
|
|
800
|
-
# iteration-scoped — without an explicit reset, iteration N inherits
|
|
801
|
-
# iteration N-1's configured_base and falsely promotes resolved_base
|
|
802
|
-
# when the current refspec's local_ref does NOT begin with refs/heads/
|
|
803
|
-
# (tag push, gerrit-style refs/for/, etc.). Reset before every
|
|
804
|
-
# potential assignment so each iteration sees a clean slate.
|
|
805
|
-
configured_base=""
|
|
806
|
-
|
|
807
|
-
if [[ -n "$source_branch" && "$source_branch" != "HEAD" ]]; then
|
|
808
|
-
configured_base=$(cd "$REA_ROOT" && git config --get "branch.${source_branch}.base" 2>/dev/null || echo "")
|
|
809
|
-
if [[ -n "$configured_base" ]]; then
|
|
810
|
-
# Prefer the REMOTE-TRACKING form so the gate still anchors on a
|
|
811
|
-
# server-authoritative ref (see the local-ref hijack explanation
|
|
812
|
-
# above). Fall back to the local short ref only if the remote
|
|
813
|
-
# counterpart doesn't exist, with a visible WARN on stderr — the
|
|
814
|
-
# local ref is less trustworthy and the reviewer should know.
|
|
815
|
-
if cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/${configured_base}" >/dev/null 2>&1; then
|
|
816
|
-
default_ref="refs/remotes/${argv_remote}/${configured_base}"
|
|
817
|
-
elif cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/heads/${configured_base}" >/dev/null 2>&1; then
|
|
818
|
-
default_ref="refs/heads/${configured_base}"
|
|
819
|
-
printf 'WARN: branch.%s.base=%s resolved to local ref; remote counterpart %s/%s missing — reviewer-side diff may be stale\n' \
|
|
820
|
-
"$source_branch" "$configured_base" "$argv_remote" "$configured_base" >&2
|
|
821
|
-
fi
|
|
822
|
-
fi
|
|
823
|
-
fi
|
|
824
|
-
|
|
825
|
-
if [[ -z "$default_ref" ]]; then
|
|
826
|
-
default_ref=$(cd "$REA_ROOT" && git symbolic-ref "refs/remotes/${argv_remote}/HEAD" 2>/dev/null)
|
|
827
|
-
default_ref_status=$?
|
|
828
|
-
if [[ "$default_ref_status" -ne 0 || -z "$default_ref" ]]; then
|
|
829
|
-
# symbolic-ref failed (common on shallow or mirror clones where
|
|
830
|
-
# origin/HEAD was never set). Probe the common default-branch names in
|
|
831
|
-
# order: main, then master. Both are remote-tracking refs and still
|
|
832
|
-
# server-authoritative; the order matters only for projects that still
|
|
833
|
-
# default to `master` (older internal forks), where without this
|
|
834
|
-
# fallback the first push of a new branch would fail closed.
|
|
835
|
-
if cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/main" >/dev/null 2>&1; then
|
|
836
|
-
default_ref="refs/remotes/${argv_remote}/main"
|
|
837
|
-
elif cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/master" >/dev/null 2>&1; then
|
|
838
|
-
default_ref="refs/remotes/${argv_remote}/master"
|
|
839
|
-
else
|
|
840
|
-
default_ref=""
|
|
841
|
-
fi
|
|
842
|
-
fi
|
|
843
|
-
fi
|
|
844
|
-
if [[ -n "$default_ref" ]]; then
|
|
845
|
-
# Defect N: if operator-configured `branch.<source>.base` resolved the
|
|
846
|
-
# ref we're about to diff against, overwrite `resolved_base` with the
|
|
847
|
-
# short name so TARGET_BRANCH (and the Target: label) reflect the
|
|
848
|
-
# actual review anchor. Without an explicit config override, leave
|
|
849
|
-
# `resolved_base` at the refspec target — this preserves the cache
|
|
850
|
-
# contract for new-branch pushes where remote_ref is the same as the
|
|
851
|
-
# source branch (the common case) and for bare pushes that
|
|
852
|
-
# argv-resolve via `@{upstream}`. Only operators who opted into a
|
|
853
|
-
# per-branch base get the label promoted, keeping the change
|
|
854
|
-
# backward-compatible for every other path.
|
|
855
|
-
if [[ -n "$configured_base" ]]; then
|
|
856
|
-
resolved_base="${default_ref#refs/remotes/${argv_remote}/}"
|
|
857
|
-
resolved_base="${resolved_base#refs/heads/}"
|
|
858
|
-
[[ -z "$resolved_base" ]] && resolved_base="$default_ref"
|
|
859
|
-
fi
|
|
860
|
-
mb=$(cd "$REA_ROOT" && git merge-base "$default_ref" "$local_sha" 2>/dev/null || echo "")
|
|
861
|
-
if [[ -z "$mb" ]]; then
|
|
862
|
-
# default_ref resolved but merge-base came back empty (unrelated
|
|
863
|
-
# histories, grafted branch, or transient git failure). Mirror the
|
|
864
|
-
# `.husky/pre-push` fix in 701b631 by falling through to the
|
|
865
|
-
# empty-tree baseline rather than silently `continue`-ing (the
|
|
866
|
-
# pre-pass-4 behavior). `continue` here combined with the
|
|
867
|
-
# longest-diff selection below let a protected-path refspec with
|
|
868
|
-
# an empty merge-base silently bypass the gate whenever another
|
|
869
|
-
# refspec in the same push was selected as BEST. Flagged HIGH by
|
|
870
|
-
# Codex pass-4 finding #1.
|
|
871
|
-
mb='4b825dc642cb6eb9a060e54bf8d69288fbee4904'
|
|
872
|
-
fi
|
|
873
|
-
else
|
|
874
|
-
# Bootstrap: no remote-tracking ref resolved. Use the well-known
|
|
875
|
-
# empty-tree SHA as the merge-base baseline so the per-refspec diff
|
|
876
|
-
# covers the full push content and the per-refspec protected-path
|
|
877
|
-
# check below still runs. Prior behavior silently `continue`d here,
|
|
878
|
-
# which — combined with the longest-diff selection accumulator at
|
|
879
|
-
# :822-828 — let a bootstrap protected-path refspec bypass the gate
|
|
880
|
-
# whenever a second, well-anchored refspec in the same push was
|
|
881
|
-
# selected as BEST instead. Flagged HIGH by Codex pass-3 and fixed
|
|
882
|
-
# for the `.husky/pre-push` side in 701b631. `git diff` accepts a
|
|
883
|
-
# tree SHA as LHS, so :861 `git diff "${MERGE_BASE}..${SOURCE_SHA}"`
|
|
884
|
-
# (two-dot is required — three-dot would compute an implicit
|
|
885
|
-
# merge-base with the tree on LHS and fail) works transparently
|
|
886
|
-
# with this baseline.
|
|
887
|
-
mb='4b825dc642cb6eb9a060e54bf8d69288fbee4904'
|
|
888
|
-
fi
|
|
889
|
-
fi
|
|
890
|
-
if [[ -z "$mb" ]]; then
|
|
891
|
-
continue
|
|
892
|
-
fi
|
|
893
|
-
|
|
894
|
-
# Per-refspec protected-path check (Codex pass-4 finding #2). The
|
|
895
|
-
# BEST_COUNT accumulator below selects a single winning refspec for
|
|
896
|
-
# the general push-review gate, but the protected-path Codex audit
|
|
897
|
-
# requirement must run on EVERY refspec — otherwise a multi-refspec
|
|
898
|
-
# push like `git push origin big-feature:big-feature hotfix:main` can
|
|
899
|
-
# hide a small protected-path refspec behind a larger, non-protected
|
|
900
|
-
# one (husky's per-refspec loop at .husky/pre-push:89-175 blocks this
|
|
901
|
-
# case; pre-pass-4 shared core did not).
|
|
902
|
-
if [[ "$CODEX_REQUIRED" == "true" ]]; then
|
|
903
|
-
local _refspec_hits _refspec_diff_status
|
|
904
|
-
_refspec_hits=$(cd "$REA_ROOT" && git diff --name-status "${mb}..${local_sha}" 2>/dev/null)
|
|
905
|
-
_refspec_diff_status=$?
|
|
906
|
-
if [[ "$_refspec_diff_status" -ne 0 ]]; then
|
|
907
|
-
{
|
|
908
|
-
printf 'PUSH BLOCKED: git diff --name-status %s..%s failed (exit %s)\n' \
|
|
909
|
-
"${mb:0:12}" "${local_sha:0:12}" "$_refspec_diff_status"
|
|
910
|
-
printf ' Refspec: %s\n' "${local_ref:-<unknown>}"
|
|
911
|
-
printf ' Cannot determine whether protected paths changed; refusing to pass.\n'
|
|
912
|
-
} >&2
|
|
913
|
-
exit 2
|
|
914
|
-
fi
|
|
915
|
-
if printf '%s\n' "$_refspec_hits" | awk -v re='^(src/gateway/middleware/|hooks/|[.]claude/hooks/|src/policy/|[.]github/workflows/|[.]rea/|[.]husky/)' '
|
|
916
|
-
{
|
|
917
|
-
status = $1
|
|
918
|
-
if (status !~ /^[ACDMRTU]/) next
|
|
919
|
-
for (i = 2; i <= NF; i++) {
|
|
920
|
-
if ($i ~ re) { found = 1; next }
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
END { exit found ? 0 : 1 }
|
|
924
|
-
'; then
|
|
925
|
-
local _audit="${REA_ROOT}/.rea/audit.jsonl"
|
|
926
|
-
local _codex_ok=0
|
|
927
|
-
# 0.8.0 (#85): Codex-only waiver satisfies this check without a real
|
|
928
|
-
# audit entry. Every other gate still ran — HALT, cross-repo guard,
|
|
929
|
-
# ref-resolution, push-review cache — and the waiver itself is
|
|
930
|
-
# already recorded in .rea/audit.jsonl as tool_name=codex.review.skipped.
|
|
931
|
-
if [[ "$CODEX_WAIVER_ACTIVE" == "1" ]]; then
|
|
932
|
-
_codex_ok=1
|
|
933
|
-
elif [[ -f "$_audit" ]]; then
|
|
934
|
-
# Defect P (0.10.1): require .emission_source == "rea-cli" or
|
|
935
|
-
# "codex-cli" so agents cannot forge a codex.review record by
|
|
936
|
-
# directly calling appendAuditRecord() from an ad-hoc .mjs script
|
|
937
|
-
# (the generic helper stamps "other"). Legacy records (pre-0.10.1)
|
|
938
|
-
# have no emission_source field and are rejected — the first push
|
|
939
|
-
# on an upgraded consumer requires a fresh `rea audit record
|
|
940
|
-
# codex-review` (or Codex CLI emission) which stamps "rea-cli".
|
|
941
|
-
#
|
|
942
|
-
# Defect T/U (0.10.2): read the audit file as raw lines and parse
|
|
943
|
-
# each with `fromjson?`. Before 0.10.2 this scan used
|
|
944
|
-
# `jq -e '<filter>' "$_audit"` which feeds the file as a single
|
|
945
|
-
# JSON stream — a single malformed line (literal backslash-u
|
|
946
|
-
# followed by non-hex characters inside a string, for example)
|
|
947
|
-
# makes jq bail on the stream with exit 2 and the `select` never
|
|
948
|
-
# runs against ANY record, including legitimate codex.review
|
|
949
|
-
# entries further down the file. The failure is total: every
|
|
950
|
-
# cached codex.review receipt becomes unreachable until the
|
|
951
|
-
# corrupt line is hand-edited out. `-R` flips jq into raw-input
|
|
952
|
-
# mode (one string per line), and `fromjson?` is the error-
|
|
953
|
-
# suppressing parser — malformed lines silently yield empty
|
|
954
|
-
# output. The `select` filter then inspects each successfully
|
|
955
|
-
# parsed record exactly as before, and `grep -q .` detects
|
|
956
|
-
# whether ANY record survived the filter. Lines 1107 and the
|
|
957
|
-
# earlier cache_result scans at :432/:612 operate on a single
|
|
958
|
-
# printf'd JSON string, not audit.jsonl, so they remain `jq -e`.
|
|
959
|
-
if jq -R --arg sha "$local_sha" '
|
|
960
|
-
fromjson?
|
|
961
|
-
| select(
|
|
962
|
-
.tool_name == "codex.review"
|
|
963
|
-
and .metadata.head_sha == $sha
|
|
964
|
-
and (.metadata.verdict == "pass" or .metadata.verdict == "concerns")
|
|
965
|
-
and (.emission_source == "rea-cli" or .emission_source == "codex-cli")
|
|
966
|
-
)
|
|
967
|
-
' "$_audit" 2>/dev/null | grep -q .; then
|
|
968
|
-
_codex_ok=1
|
|
969
|
-
fi
|
|
970
|
-
fi
|
|
971
|
-
if [[ "$_codex_ok" -eq 0 ]]; then
|
|
972
|
-
{
|
|
973
|
-
printf 'PUSH BLOCKED: protected paths changed — /codex-review required for %s\n' "$local_sha"
|
|
974
|
-
printf '\n'
|
|
975
|
-
printf ' Source ref: %s\n' "${local_ref:-<unknown>}"
|
|
976
|
-
printf ' Diff touches one of:\n'
|
|
977
|
-
printf ' - src/gateway/middleware/\n'
|
|
978
|
-
printf ' - hooks/\n'
|
|
979
|
-
printf ' - .claude/hooks/\n'
|
|
980
|
-
printf ' - src/policy/\n'
|
|
981
|
-
printf ' - .github/workflows/\n'
|
|
982
|
-
printf ' - .rea/\n'
|
|
983
|
-
printf ' - .husky/\n'
|
|
984
|
-
printf '\n'
|
|
985
|
-
printf ' Run /codex-review against %s, then retry the push.\n' "$local_sha"
|
|
986
|
-
printf ' The codex-adversarial agent emits the required audit entry.\n'
|
|
987
|
-
# shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
|
|
988
|
-
printf ' Only `pass` or `concerns` verdicts satisfy this gate.\n'
|
|
989
|
-
printf '\n'
|
|
990
|
-
} >&2
|
|
991
|
-
exit 2
|
|
992
|
-
fi
|
|
993
|
-
fi
|
|
994
|
-
fi
|
|
995
|
-
|
|
996
|
-
count=$(cd "$REA_ROOT" && git rev-list --count "${mb}..${local_sha}" 2>/dev/null)
|
|
997
|
-
count_status=$?
|
|
998
|
-
if [[ "$count_status" -ne 0 ]]; then
|
|
999
|
-
{
|
|
1000
|
-
printf 'PUSH BLOCKED: git rev-list --count %s..%s failed (exit %s)\n' \
|
|
1001
|
-
"${mb:0:12}" "${local_sha:0:12}" "$count_status"
|
|
1002
|
-
printf ' Cannot size the diff; refusing to pass.\n'
|
|
1003
|
-
} >&2
|
|
1004
|
-
exit 2
|
|
1005
|
-
fi
|
|
1006
|
-
if [[ -z "$count" ]]; then
|
|
1007
|
-
count=0
|
|
1008
|
-
fi
|
|
1009
|
-
if [[ -z "$SOURCE_SHA" ]] || [[ "$count" -gt "$BEST_COUNT" ]]; then
|
|
1010
|
-
SOURCE_SHA="$local_sha"
|
|
1011
|
-
MERGE_BASE="$mb"
|
|
1012
|
-
# Defect N: use `resolved_base` (the actual merge-base anchor we
|
|
1013
|
-
# diffed against), not `target` (the pushed-ref name). For tracked
|
|
1014
|
-
# branches these are the same; for new branches without an upstream
|
|
1015
|
-
# the distinction is the difference between "Target: <source-branch>"
|
|
1016
|
-
# (misleading) and "Target: main" (or whichever base was resolved).
|
|
1017
|
-
TARGET_BRANCH="$resolved_base"
|
|
1018
|
-
SOURCE_REF="$local_ref"
|
|
1019
|
-
BEST_COUNT="$count"
|
|
1020
|
-
fi
|
|
1021
|
-
done
|
|
1022
|
-
|
|
1023
|
-
# Defect J (rea#61): branch-deletion guard MUST fail closed regardless of
|
|
1024
|
-
# whether another refspec in the same push resolved a SOURCE_SHA. A mixed
|
|
1025
|
-
# push like `git push origin safe:safe :main` iterates both refspecs; the
|
|
1026
|
-
# safe refspec sets SOURCE_SHA from its local_sha, and the deletion refspec
|
|
1027
|
-
# sets only HAS_DELETE=1 via its `continue` branch. If we check HAS_DELETE
|
|
1028
|
-
# INSIDE the `-z SOURCE_SHA` fallback, the delete slips through unchecked.
|
|
1029
|
-
# Hoist the check above the fallback so any deletion anywhere in the push
|
|
1030
|
-
# blocks the entire push.
|
|
1031
|
-
if [[ "$HAS_DELETE" -eq 1 ]]; then
|
|
1032
|
-
{
|
|
1033
|
-
printf 'PUSH BLOCKED: refspec is a branch deletion.\n'
|
|
1034
|
-
printf '\n'
|
|
1035
|
-
printf ' Branch deletions are sensitive operations and require explicit\n'
|
|
1036
|
-
printf ' human action outside the agent. Perform the deletion manually.\n'
|
|
1037
|
-
printf '\n'
|
|
1038
|
-
} >&2
|
|
1039
|
-
exit 2
|
|
1040
|
-
fi
|
|
1041
|
-
|
|
1042
|
-
if [[ -z "$SOURCE_SHA" || -z "$MERGE_BASE" ]]; then
|
|
1043
|
-
{
|
|
1044
|
-
printf 'PUSH BLOCKED: could not resolve a merge-base for any push refspec.\n'
|
|
1045
|
-
printf '\n'
|
|
1046
|
-
printf ' Fetch the remote and retry, or name an explicit destination.\n'
|
|
1047
|
-
printf '\n'
|
|
1048
|
-
} >&2
|
|
1049
|
-
exit 2
|
|
1050
|
-
fi
|
|
1051
|
-
|
|
1052
|
-
# Capture git diff exit status explicitly.
|
|
1053
|
-
#
|
|
1054
|
-
# Use two-dot (`A..B`) rather than three-dot (`A...B`). Three-dot form
|
|
1055
|
-
# computes an implicit merge-base between A and B, which FAILS when A
|
|
1056
|
-
# is a tree (e.g. the empty-tree baseline used on bootstrap refspecs
|
|
1057
|
-
# — see pr_parse_prepush_stdin's new-branch block). Two-dot accepts
|
|
1058
|
-
# any revision on the left and is equivalent to `A...B` here because
|
|
1059
|
-
# MERGE_BASE is ALREADY the merge-base of the two commit cases, so the
|
|
1060
|
-
# implicit merge-base in three-dot would be redundant.
|
|
1061
|
-
local DIFF_FULL DIFF_STATUS
|
|
1062
|
-
DIFF_FULL=$(cd "$REA_ROOT" && git diff "${MERGE_BASE}..${SOURCE_SHA}" 2>/dev/null)
|
|
1063
|
-
DIFF_STATUS=$?
|
|
1064
|
-
if [[ "$DIFF_STATUS" -ne 0 ]]; then
|
|
1065
|
-
{
|
|
1066
|
-
printf 'PUSH BLOCKED: git diff %s..%s failed (exit %s)\n' \
|
|
1067
|
-
"${MERGE_BASE:0:12}" "${SOURCE_SHA:0:12}" "$DIFF_STATUS"
|
|
1068
|
-
printf ' Cannot compute reviewable diff; refusing to pass.\n'
|
|
1069
|
-
} >&2
|
|
1070
|
-
exit 2
|
|
1071
|
-
fi
|
|
1072
|
-
|
|
1073
|
-
if [[ -z "$DIFF_FULL" ]]; then
|
|
1074
|
-
exit 0
|
|
1075
|
-
fi
|
|
1076
|
-
|
|
1077
|
-
# Defect K (rea#62): `grep -c ... || echo "0"` captures `0\n0` when grep
|
|
1078
|
-
# exits non-zero on no-match — grep still prints its own `0` to stdout before
|
|
1079
|
-
# exiting, and the `|| echo "0"` branch appends another. `|| true` swallows
|
|
1080
|
-
# the non-zero exit, and `${LINE_COUNT:-0}` defaults an empty result to 0.
|
|
1081
|
-
local LINE_COUNT
|
|
1082
|
-
LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || true)
|
|
1083
|
-
LINE_COUNT="${LINE_COUNT:-0}"
|
|
1084
|
-
|
|
1085
|
-
# ── 7a. Protected-path Codex adversarial review gate ──────────────────────
|
|
1086
|
-
# The per-refspec check runs inside the main loop (section 7, above) so
|
|
1087
|
-
# that a multi-refspec push cannot hide a protected-path refspec behind
|
|
1088
|
-
# a larger, non-protected one. See Codex pass-4 finding #2. If any
|
|
1089
|
-
# protected-path refspec lacked a valid Codex audit, the loop already
|
|
1090
|
-
# exited with code 2; reaching this point means every protected-path
|
|
1091
|
-
# refspec was either clean or had an acceptable audit.
|
|
1092
|
-
|
|
1093
|
-
# ── 8. Check review cache ─────────────────────────────────────────────────
|
|
1094
|
-
# Defect L (rea#63): `shasum` is not installed on Alpine, distroless, or
|
|
1095
|
-
# most minimal Linux CI images — only `sha256sum` is. The prior `shasum -a
|
|
1096
|
-
# 256 ... || echo ""` chain silently produced an empty PUSH_SHA, which the
|
|
1097
|
-
# rest of the gate treats as "no cache entry" rather than "hasher missing".
|
|
1098
|
-
# Combined with the silent-cache-miss fallback (Defect F), every push from
|
|
1099
|
-
# such a runner burned a full fresh codex review invisibly.
|
|
1100
|
-
#
|
|
1101
|
-
# Portable chain: sha256sum → shasum → openssl. The openssl branch uses
|
|
1102
|
-
# `awk '{print $NF}'` WITHOUT `-r` — `-r` was added in OpenSSL 3.0 /
|
|
1103
|
-
# LibreSSL 3.3+; on OpenSSL 1.1.1 (Debian 11, Ubuntu 20.04, RHEL 8,
|
|
1104
|
-
# Amazon Linux 2, Alpine 3.13–3.14) `-r` is rejected and stdout is empty.
|
|
1105
|
-
# `$NF` handles BOTH default output shapes: `(stdin)= <hex>` (1.1.x) and
|
|
1106
|
-
# `<hex> *stdin` (3.x/LibreSSL coreutils-style).
|
|
1107
|
-
#
|
|
1108
|
-
# Hex-64 validation catches broken pipes, partial reads, or unexpected
|
|
1109
|
-
# hasher output that would otherwise be silently cached as garbage.
|
|
1110
|
-
local PUSH_SHA=""
|
|
1111
|
-
if command -v sha256sum >/dev/null 2>&1; then
|
|
1112
|
-
PUSH_SHA=$(printf '%s' "$DIFF_FULL" | sha256sum 2>/dev/null | awk '{print $1}')
|
|
1113
|
-
elif command -v shasum >/dev/null 2>&1; then
|
|
1114
|
-
PUSH_SHA=$(printf '%s' "$DIFF_FULL" | shasum -a 256 2>/dev/null | awk '{print $1}')
|
|
1115
|
-
elif command -v openssl >/dev/null 2>&1; then
|
|
1116
|
-
PUSH_SHA=$(printf '%s' "$DIFF_FULL" | openssl dgst -sha256 2>/dev/null | awk '{print $NF}')
|
|
1117
|
-
else
|
|
1118
|
-
printf 'rea push-review: WARN no sha256 hasher found (sha256sum/shasum/openssl); cache disabled\n' >&2
|
|
1119
|
-
fi
|
|
1120
|
-
if [[ -n "$PUSH_SHA" && ! "$PUSH_SHA" =~ ^[0-9a-f]{64}$ ]]; then
|
|
1121
|
-
printf 'rea push-review: WARN hasher returned invalid output; cache disabled\n' >&2
|
|
1122
|
-
PUSH_SHA=""
|
|
1123
|
-
fi
|
|
1124
|
-
|
|
1125
|
-
local -a REA_CLI_ARGS
|
|
1126
|
-
REA_CLI_ARGS=()
|
|
1127
|
-
# node_modules/.bin/rea is a launcher (pnpm writes a POSIX shell shim, npm
|
|
1128
|
-
# writes a symlink to dist/cli/index.js with its own `#!/usr/bin/env node`
|
|
1129
|
-
# shebang). Either way it is NOT a plain JS file, so running `node` on it
|
|
1130
|
-
# would parse shell syntax as JavaScript and SyntaxError. Execute the shim
|
|
1131
|
-
# directly — it handles `exec node` itself — and only prepend `node` on the
|
|
1132
|
-
# dist fallback, which is a real JS module. The `-x` guard picks up both
|
|
1133
|
-
# pnpm shims (executable regular file) and npm symlinks (executable target).
|
|
1134
|
-
if [[ -x "${REA_ROOT}/node_modules/.bin/rea" ]]; then
|
|
1135
|
-
REA_CLI_ARGS=("${REA_ROOT}/node_modules/.bin/rea")
|
|
1136
|
-
elif [[ -f "${REA_ROOT}/dist/cli/index.js" ]]; then
|
|
1137
|
-
REA_CLI_ARGS=(node "${REA_ROOT}/dist/cli/index.js")
|
|
1138
|
-
fi
|
|
1139
|
-
|
|
1140
|
-
# Cache-branch derivation (Codex 0.8.0 pass-2 finding #2, pass-3 finding #1):
|
|
1141
|
-
# Use the PUSHED source ref (from pre-push stdin / bootstrap walk), not the
|
|
1142
|
-
# checkout branch. `git push origin hotfix:main` from a `feature` checkout
|
|
1143
|
-
# must look up a cache entry keyed on `hotfix`, not `feature`. Strip the
|
|
1144
|
-
# `refs/heads/` prefix.
|
|
1145
|
-
#
|
|
1146
|
-
# Fall back to the checkout branch when SOURCE_REF is:
|
|
1147
|
-
# • unset (defence-in-depth, not reached on any observed path), or
|
|
1148
|
-
# • the literal string "HEAD" — emitted by pr_resolve_argv_refspecs for a
|
|
1149
|
-
# bare `git push` with no explicit refspec. Keying a cache lookup on
|
|
1150
|
-
# "HEAD" would force a miss on every bare push; the checkout branch
|
|
1151
|
-
# name is the right lookup key for that workflow.
|
|
1152
|
-
local SOURCE_BRANCH="${SOURCE_REF#refs/heads/}"
|
|
1153
|
-
if [[ -z "$SOURCE_BRANCH" || "$SOURCE_BRANCH" == "HEAD" ]]; then
|
|
1154
|
-
SOURCE_BRANCH="$CURRENT_BRANCH"
|
|
1155
|
-
fi
|
|
1156
|
-
|
|
1157
|
-
if [[ -n "$PUSH_SHA" ]] && [[ ${#REA_CLI_ARGS[@]} -gt 0 ]]; then
|
|
1158
|
-
# Defect F (rea#75): distinguish cache-miss from cache-error. Prior version
|
|
1159
|
-
# swallowed all non-zero exits and stderr into a silent `{hit:false}`, which
|
|
1160
|
-
# masked Defect A (0.9.2 `node <shim>` SyntaxError) for weeks. Now we
|
|
1161
|
-
# capture stderr + exit code separately and emit a visible WARN with an
|
|
1162
|
-
# actionable filename when the CLI failed.
|
|
1163
|
-
local CACHE_RESULT
|
|
1164
|
-
local CACHE_STDOUT=""
|
|
1165
|
-
local CACHE_STDERR_FILE
|
|
1166
|
-
# SECURITY (Codex LOW 4): require mktemp. A predictable /tmp path like
|
|
1167
|
-
# /tmp/rea-cache-err.$PID is a TOCTOU attack surface on shared hosts —
|
|
1168
|
-
# another user can pre-create a symlink from that name to a file they
|
|
1169
|
-
# want us to clobber. If mktemp is unavailable, fail loudly rather than
|
|
1170
|
-
# silently falling back to a predictable path.
|
|
1171
|
-
if ! CACHE_STDERR_FILE=$(mktemp -t rea-cache-err.XXXXXX 2>/dev/null); then
|
|
1172
|
-
printf 'rea push-review: mktemp unavailable; cannot capture cache-check stderr. Aborting.\n' >&2
|
|
1173
|
-
return 2
|
|
1174
|
-
fi
|
|
1175
|
-
local CACHE_EXIT=0
|
|
1176
|
-
CACHE_STDOUT=$("${REA_CLI_ARGS[@]}" cache check "$PUSH_SHA" --branch "$SOURCE_BRANCH" --base "$TARGET_BRANCH" 2>"$CACHE_STDERR_FILE") || CACHE_EXIT=$?
|
|
1177
|
-
local CACHE_STDERR=""
|
|
1178
|
-
CACHE_STDERR=$(cat "$CACHE_STDERR_FILE" 2>/dev/null || true)
|
|
1179
|
-
rm -f "$CACHE_STDERR_FILE"
|
|
1180
|
-
if [[ "$CACHE_EXIT" -ne 0 ]]; then
|
|
1181
|
-
# SECURITY (Codex LOW 5): strip C0/C1 control characters from CLI
|
|
1182
|
-
# stderr before echoing to the terminal. A tampered dist/ or hostile
|
|
1183
|
-
# CLI could otherwise emit OSC/CSI sequences that rewrite lines above
|
|
1184
|
-
# the deny message and mislead the operator. We strip both C0 + DEL
|
|
1185
|
-
# AND C1 (0x80-0x9F) — some terminal emulators still honor bare C1
|
|
1186
|
-
# bytes as CSI introducers (0x9B) or OSC (0x9D).
|
|
1187
|
-
local CACHE_STDERR_SAFE
|
|
1188
|
-
CACHE_STDERR_SAFE=$(printf '%s' "$CACHE_STDERR" | LC_ALL=C tr -d '\000-\037\177\200-\237')
|
|
1189
|
-
printf 'rea push-review: CACHE CHECK FAILED (exit=%d): %s\n' "$CACHE_EXIT" "$CACHE_STDERR_SAFE" >&2
|
|
1190
|
-
printf 'rea push-review: treating as miss; file bookedsolidtech/rea issue if unexpected.\n' >&2
|
|
1191
|
-
CACHE_RESULT='{"hit":false,"reason":"query_error"}'
|
|
1192
|
-
elif [[ -z "$CACHE_STDOUT" ]]; then
|
|
1193
|
-
CACHE_RESULT='{"hit":false,"reason":"cold"}'
|
|
1194
|
-
else
|
|
1195
|
-
CACHE_RESULT="$CACHE_STDOUT"
|
|
1196
|
-
fi
|
|
1197
|
-
# Require BOTH hit == true AND result == "pass". A cached `fail` verdict
|
|
1198
|
-
# (Codex 0.8.0 pass-2 finding #1) must NOT satisfy the gate — cache.ts
|
|
1199
|
-
# serializes `result` verbatim, so a negative verdict would otherwise
|
|
1200
|
-
# slip through. Under the #85 narrowed semantic the cache is the ONLY
|
|
1201
|
-
# way a waiver-using operator reaches exit 0, so a permissive predicate
|
|
1202
|
-
# here would be a real security regression.
|
|
1203
|
-
if printf '%s' "$CACHE_RESULT" | jq -e '.hit == true and .result == "pass"' >/dev/null 2>&1; then
|
|
1204
|
-
local DISCORD_LIB="${REA_ROOT}/hooks/_lib/discord.sh"
|
|
1205
|
-
if [ -f "$DISCORD_LIB" ]; then
|
|
1206
|
-
# shellcheck source=/dev/null
|
|
1207
|
-
source "$DISCORD_LIB"
|
|
1208
|
-
discord_notify "dev" "Push passed quality gates on \`${SOURCE_BRANCH}\` -- $(cd "$REA_ROOT" && git log -1 --oneline 2>/dev/null)" "green"
|
|
1209
|
-
fi
|
|
1210
|
-
exit 0
|
|
1211
|
-
fi
|
|
1212
|
-
fi
|
|
1213
|
-
|
|
1214
|
-
# ── 9. Block and request review ───────────────────────────────────────────
|
|
1215
|
-
# Defect K (rea#62): same `0\n0` bug as LINE_COUNT above.
|
|
1216
|
-
local FILE_COUNT
|
|
1217
|
-
FILE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -c '^\+\+\+ ' 2>/dev/null || true)
|
|
1218
|
-
FILE_COUNT="${FILE_COUNT:-0}"
|
|
1219
|
-
|
|
1220
|
-
{
|
|
1221
|
-
printf 'PUSH REVIEW GATE: Review required before pushing\n'
|
|
1222
|
-
printf '\n'
|
|
1223
|
-
printf ' Source ref: %s (%s)\n' "${SOURCE_REF:-HEAD}" "${SOURCE_SHA:0:12}"
|
|
1224
|
-
printf ' Target: %s\n' "$TARGET_BRANCH"
|
|
1225
|
-
printf ' Scope: %s files changed, %s lines\n' "$FILE_COUNT" "$LINE_COUNT"
|
|
1226
|
-
printf '\n'
|
|
1227
|
-
printf ' Action required:\n'
|
|
1228
|
-
printf ' 1. Spawn a code-reviewer agent to review: git diff %s..%s\n' "$MERGE_BASE" "$SOURCE_SHA"
|
|
1229
|
-
printf ' 2. Spawn a security-engineer agent for security review\n'
|
|
1230
|
-
# Defect L (rea#63) follow-up: when no sha256 hasher is available the
|
|
1231
|
-
# cache is disabled and PUSH_SHA is empty. Emitting `rea cache set <blank>
|
|
1232
|
-
# pass ...` would be a dead-end — the CLI rejects the empty positional.
|
|
1233
|
-
# Print an alternate completion path in that case. The Codex-adversarial
|
|
1234
|
-
# review concerns list flagged this UX cliff in the 0.9.4 pass.
|
|
1235
|
-
if [[ -n "$PUSH_SHA" ]]; then
|
|
1236
|
-
printf ' 3. After both pass, cache the result:\n'
|
|
1237
|
-
printf ' rea cache set %s pass --branch %s --base %s\n' "$PUSH_SHA" "$SOURCE_BRANCH" "$TARGET_BRANCH"
|
|
1238
|
-
else
|
|
1239
|
-
printf ' 3. Cache is DISABLED on this host (no sha256 hasher found).\n'
|
|
1240
|
-
printf ' After both reviews pass, bypass the push-review gate with:\n'
|
|
1241
|
-
printf ' REA_SKIP_PUSH_REVIEW="<reason>" git push ...\n'
|
|
1242
|
-
printf ' The bypass is audited as push.review.skipped — this is the\n'
|
|
1243
|
-
printf ' documented escape hatch when cache is unavailable.\n'
|
|
1244
|
-
printf ' To restore the cache path, install one of: sha256sum,\n'
|
|
1245
|
-
printf ' shasum (Perl Digest::SHA), or openssl.\n'
|
|
1246
|
-
fi
|
|
1247
|
-
printf '\n'
|
|
1248
|
-
} >&2
|
|
1249
|
-
exit 2
|
|
1250
|
-
}
|