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