@bookedsolid/rea 0.1.0 → 0.2.1

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.
Files changed (90) hide show
  1. package/.husky/commit-msg +130 -0
  2. package/.husky/pre-push +128 -0
  3. package/README.md +5 -5
  4. package/agents/codex-adversarial.md +23 -8
  5. package/commands/codex-review.md +2 -2
  6. package/dist/audit/append.d.ts +62 -0
  7. package/dist/audit/append.js +189 -0
  8. package/dist/audit/codex-event.d.ts +28 -0
  9. package/dist/audit/codex-event.js +15 -0
  10. package/dist/cli/doctor.d.ts +60 -1
  11. package/dist/cli/doctor.js +459 -20
  12. package/dist/cli/index.js +35 -5
  13. package/dist/cli/init.d.ts +13 -0
  14. package/dist/cli/init.js +278 -67
  15. package/dist/cli/install/canonical.d.ts +43 -0
  16. package/dist/cli/install/canonical.js +101 -0
  17. package/dist/cli/install/claude-md.d.ts +48 -0
  18. package/dist/cli/install/claude-md.js +93 -0
  19. package/dist/cli/install/commit-msg.d.ts +30 -0
  20. package/dist/cli/install/commit-msg.js +102 -0
  21. package/dist/cli/install/copy.d.ts +169 -0
  22. package/dist/cli/install/copy.js +455 -0
  23. package/dist/cli/install/fs-safe.d.ts +91 -0
  24. package/dist/cli/install/fs-safe.js +347 -0
  25. package/dist/cli/install/manifest-io.d.ts +12 -0
  26. package/dist/cli/install/manifest-io.js +44 -0
  27. package/dist/cli/install/manifest-schema.d.ts +83 -0
  28. package/dist/cli/install/manifest-schema.js +80 -0
  29. package/dist/cli/install/reagent.d.ts +59 -0
  30. package/dist/cli/install/reagent.js +160 -0
  31. package/dist/cli/install/settings-merge.d.ts +91 -0
  32. package/dist/cli/install/settings-merge.js +239 -0
  33. package/dist/cli/install/sha.d.ts +9 -0
  34. package/dist/cli/install/sha.js +21 -0
  35. package/dist/cli/serve.d.ts +11 -0
  36. package/dist/cli/serve.js +72 -6
  37. package/dist/cli/upgrade.d.ts +67 -0
  38. package/dist/cli/upgrade.js +509 -0
  39. package/dist/gateway/downstream-pool.d.ts +39 -0
  40. package/dist/gateway/downstream-pool.js +93 -0
  41. package/dist/gateway/downstream.d.ts +80 -0
  42. package/dist/gateway/downstream.js +196 -0
  43. package/dist/gateway/middleware/audit-types.d.ts +10 -0
  44. package/dist/gateway/middleware/audit.js +14 -0
  45. package/dist/gateway/middleware/injection.d.ts +59 -2
  46. package/dist/gateway/middleware/injection.js +91 -14
  47. package/dist/gateway/middleware/kill-switch.d.ts +20 -5
  48. package/dist/gateway/middleware/kill-switch.js +57 -35
  49. package/dist/gateway/middleware/redact.d.ts +83 -6
  50. package/dist/gateway/middleware/redact.js +133 -46
  51. package/dist/gateway/observability/codex-probe.d.ts +110 -0
  52. package/dist/gateway/observability/codex-probe.js +234 -0
  53. package/dist/gateway/observability/codex-telemetry.d.ts +93 -0
  54. package/dist/gateway/observability/codex-telemetry.js +221 -0
  55. package/dist/gateway/redact-safe/match-timeout.d.ts +83 -0
  56. package/dist/gateway/redact-safe/match-timeout.js +179 -0
  57. package/dist/gateway/reviewers/claude-self.d.ts +99 -0
  58. package/dist/gateway/reviewers/claude-self.js +316 -0
  59. package/dist/gateway/reviewers/codex.d.ts +64 -0
  60. package/dist/gateway/reviewers/codex.js +80 -0
  61. package/dist/gateway/reviewers/select.d.ts +64 -0
  62. package/dist/gateway/reviewers/select.js +102 -0
  63. package/dist/gateway/reviewers/types.d.ts +85 -0
  64. package/dist/gateway/reviewers/types.js +14 -0
  65. package/dist/gateway/server.d.ts +51 -0
  66. package/dist/gateway/server.js +258 -0
  67. package/dist/gateway/session.d.ts +9 -0
  68. package/dist/gateway/session.js +17 -0
  69. package/dist/policy/loader.d.ts +59 -0
  70. package/dist/policy/loader.js +65 -0
  71. package/dist/policy/profiles.d.ts +80 -0
  72. package/dist/policy/profiles.js +94 -0
  73. package/dist/policy/types.d.ts +38 -0
  74. package/dist/registry/loader.d.ts +98 -0
  75. package/dist/registry/loader.js +153 -0
  76. package/dist/registry/types.d.ts +44 -0
  77. package/dist/registry/types.js +6 -0
  78. package/dist/scripts/read-policy-field.d.ts +36 -0
  79. package/dist/scripts/read-policy-field.js +96 -0
  80. package/hooks/push-review-gate.sh +627 -17
  81. package/package.json +13 -2
  82. package/profiles/bst-internal-no-codex.yaml +40 -0
  83. package/profiles/bst-internal.yaml +23 -0
  84. package/profiles/client-engagement.yaml +23 -0
  85. package/profiles/lit-wc.yaml +17 -0
  86. package/profiles/minimal.yaml +11 -0
  87. package/profiles/open-source-no-codex.yaml +33 -0
  88. package/profiles/open-source.yaml +18 -0
  89. package/scripts/lint-safe-regex.mjs +78 -0
  90. package/scripts/postinstall.mjs +131 -0
@@ -5,8 +5,32 @@
5
5
  # security + code review before allowing the push.
6
6
  #
7
7
  # Exit codes:
8
- # 0 = allow (no meaningful diff, or review cached)
9
- # 2 = block (needs review)
8
+ # 0 = allow (no meaningful diff, or review cached, or escape hatch invoked)
9
+ # 2 = block (needs review, or escape hatch invoked but audit-append failed)
10
+ #
11
+ # ── Escape hatch: REA_SKIP_CODEX_REVIEW ──────────────────────────────────────
12
+ # Env var `REA_SKIP_CODEX_REVIEW=<reason>` bypasses the protected-path Codex
13
+ # adversarial-review requirement. Set to any non-empty value; the value IS
14
+ # the reason recorded in the audit record (no default reason is supplied —
15
+ # if the operator sets `REA_SKIP_CODEX_REVIEW=1` the reason is literally "1").
16
+ #
17
+ # The hatch ONLY applies when the diff would otherwise require Codex review
18
+ # (i.e. touches a protected path). Unprotected pushes are not affected.
19
+ #
20
+ # Every invocation appends a `tool_name: "codex.review.skipped"` record to
21
+ # `.rea/audit.jsonl` via the public audit helper. This record is intentionally
22
+ # NOT named `codex.review` so the existing jq predicate on `.tool_name ==
23
+ # "codex.review" and .metadata.verdict in {pass, concerns}` will never match
24
+ # a skip — a skipped review is not a review.
25
+ #
26
+ # Fail-closed contract:
27
+ # - `dist/audit/append.js` missing → exit 2 (build rea first)
28
+ # - Node invocation failure → exit 2
29
+ # - 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.
10
34
 
11
35
  set -uo pipefail
12
36
 
@@ -49,33 +73,618 @@ if [[ -f "$POLICY_FILE" ]]; then
49
73
  fi
50
74
  fi
51
75
 
52
- # ── 6. Determine target branch ───────────────────────────────────────────────
76
+ # ── 6. Determine source/target commits for each refspec ──────────────────────
77
+ # The authoritative source for which commits are being pushed is the pre-push
78
+ # hook stdin contract: one line per refspec, with fields
79
+ # <local_ref> <local_sha> <remote_ref> <remote_sha>
80
+ # (https://git-scm.com/docs/githooks#_pre_push). We drive the gate off those
81
+ # SHAs directly — NOT off HEAD — so that `git push origin hotfix:main` from a
82
+ # checked-out `foo` branch reviews the `hotfix` commits, not `foo`.
83
+ #
84
+ # Two execution paths:
85
+ # 1. Real `git push`: stdin is forwarded from git and contains refspec lines.
86
+ # This is what runs in production.
87
+ # 2. Hook invoked outside a real push (manual test, the Bash PreToolUse path
88
+ # where we only see the command string): stdin has no refspec lines. We
89
+ # fall back to parsing the command string and diffing against HEAD, but
90
+ # we refuse to let `src:dst` silently escape — see resolve_argv_refspecs.
91
+ #
92
+ # The REA PreToolUse wrapper currently delivers the Claude Code tool_input on
93
+ # stdin as JSON. If what we read on stdin does not look like pre-push refspec
94
+ # lines, we treat it as "no stdin" and use the argv fallback.
95
+ ZERO_SHA='0000000000000000000000000000000000000000'
53
96
  CURRENT_BRANCH=$(cd "$REA_ROOT" && git branch --show-current 2>/dev/null || echo "")
54
- TARGET_BRANCH="main"
55
97
 
56
- # Try to extract target from push command (git push origin <branch>)
57
- PUSH_TARGET=$(printf '%s' "$CMD" | grep -oE 'git[[:space:]]+push[[:space:]]+[a-zA-Z_-]+[[:space:]]+([a-zA-Z0-9/_-]+)' | awk '{print $NF}' 2>/dev/null || echo "")
58
- if [[ -n "$PUSH_TARGET" ]]; then
59
- TARGET_BRANCH="$PUSH_TARGET"
98
+ # Parse pre-push stdin into newline-separated "local_sha|remote_sha|local_ref|remote_ref"
99
+ # records on stdout. Exits non-zero without any output if stdin does not match
100
+ # the pre-push contract, so the caller can switch to the argv fallback.
101
+ #
102
+ # Pre-push stdin is plain whitespace-separated text, one line per refspec.
103
+ # Every field is either a ref name or a 40-hex SHA. We require at least one
104
+ # well-formed line to accept the input. Returning via stdout (instead of bash 4
105
+ # namerefs) keeps this portable to macOS /bin/bash 3.2.
106
+ parse_prepush_stdin() {
107
+ local raw="$1"
108
+ local accepted=0
109
+ local line local_ref local_sha remote_ref remote_sha rest
110
+ local -a records
111
+ records=()
112
+ while IFS= read -r line; do
113
+ [[ -z "$line" ]] && continue
114
+ read -r local_ref local_sha remote_ref remote_sha rest <<<"$line"
115
+ if [[ -z "$local_ref" || -z "$local_sha" || -z "$remote_ref" || -z "$remote_sha" ]]; then
116
+ continue
117
+ fi
118
+ if [[ ! "$local_sha" =~ ^[0-9a-f]{40}$ ]] || [[ ! "$remote_sha" =~ ^[0-9a-f]{40}$ ]]; then
119
+ return 1
120
+ fi
121
+ records+=("${local_sha}|${remote_sha}|${local_ref}|${remote_ref}")
122
+ accepted=1
123
+ done <<<"$raw"
124
+ if [[ "$accepted" -ne 1 ]]; then
125
+ return 1
126
+ fi
127
+ local r
128
+ for r in "${records[@]}"; do
129
+ printf '%s\n' "$r"
130
+ done
131
+ }
132
+
133
+ # Argv fallback: parse `git push [remote] [refspec...]` from the command string
134
+ # when stdin has no pre-push lines. Emits newline-separated records as
135
+ # "local_sha|remote_sha|local_ref|remote_ref" where `local_sha` is HEAD of the
136
+ # named source ref (or HEAD itself for bare refspecs) and `remote_sha` is zero
137
+ # so the merge-base logic falls back to merging against the configured default.
138
+ # Exits the script with code 2 on operator-error conditions (HEAD target,
139
+ # unresolvable source ref) — same fail-closed contract as before.
140
+ resolve_argv_refspecs() {
141
+ local cmd="$1"
142
+ local segment
143
+ segment=$(printf '%s' "$cmd" | awk '
144
+ {
145
+ idx = match($0, /git[[:space:]]+push([[:space:]]|$)/)
146
+ if (!idx) exit
147
+ tail = substr($0, idx)
148
+ n = match(tail, /[;&|]|&&|\|\|/)
149
+ if (n > 0) tail = substr(tail, 1, n - 1)
150
+ print tail
151
+ }
152
+ ')
153
+
154
+ local -a specs
155
+ specs=()
156
+ local seen_push=0 remote_seen=0 delete_mode=0 tok
157
+ # shellcheck disable=SC2086
158
+ set -- $segment
159
+ for tok in "$@"; do
160
+ case "$tok" in
161
+ git|push) seen_push=1; continue ;;
162
+ --delete|-d)
163
+ # Branch deletion. Every subsequent bare refspec is a delete target on
164
+ # the remote, not a source ref on the local side. We flip delete_mode
165
+ # so the consumer loop below emits ZERO_SHA|ZERO_SHA records matching
166
+ # the git pre-push stdin contract for deletions.
167
+ delete_mode=1
168
+ continue
169
+ ;;
170
+ --delete=*)
171
+ # `git push --delete=value` is not actually supported by git, but guard
172
+ # anyway: treat the value as a delete target.
173
+ delete_mode=1
174
+ specs+=("${tok#--delete=}")
175
+ continue
176
+ ;;
177
+ -*) continue ;;
178
+ esac
179
+ [[ "$seen_push" -eq 0 ]] && continue
180
+ if [[ "$remote_seen" -eq 0 ]]; then
181
+ remote_seen=1
182
+ continue
183
+ fi
184
+ if [[ "$delete_mode" -eq 1 ]]; then
185
+ # Tag each delete-mode token with a sentinel prefix so the consumer loop
186
+ # can distinguish it from a normal refspec without another bash array.
187
+ specs+=("__REA_DELETE__${tok}")
188
+ else
189
+ specs+=("$tok")
190
+ fi
191
+ done
192
+
193
+ if [[ "${#specs[@]}" -eq 0 ]]; then
194
+ local upstream dst_ref head_sha
195
+ upstream=$(cd "$REA_ROOT" && git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || echo "")
196
+ dst_ref="refs/heads/main"
197
+ if [[ -n "$upstream" && "$upstream" == */* ]]; then
198
+ dst_ref="refs/heads/${upstream#*/}"
199
+ fi
200
+ head_sha=$(cd "$REA_ROOT" && git rev-parse HEAD 2>/dev/null || echo "")
201
+ [[ -z "$head_sha" ]] && return 1
202
+ printf '%s|%s|HEAD|%s\n' "$head_sha" "$ZERO_SHA" "$dst_ref"
203
+ return 0
204
+ fi
205
+
206
+ local spec src dst src_sha is_delete
207
+ for spec in "${specs[@]}"; do
208
+ is_delete=0
209
+ if [[ "$spec" == __REA_DELETE__* ]]; then
210
+ is_delete=1
211
+ spec="${spec#__REA_DELETE__}"
212
+ fi
213
+ spec="${spec#+}"
214
+ if [[ "$spec" == *:* ]]; then
215
+ src="${spec%%:*}"
216
+ dst="${spec##*:}"
217
+ else
218
+ src="$spec"
219
+ dst="$spec"
220
+ fi
221
+ if [[ -z "$dst" ]]; then
222
+ dst="${spec##*:}"
223
+ src=""
224
+ fi
225
+ dst="${dst#refs/heads/}"
226
+ dst="${dst#refs/for/}"
227
+ if [[ "$is_delete" -eq 1 ]]; then
228
+ # `git push --delete origin doomed` — force the record to match the
229
+ # pre-push stdin contract for deletions: both SHAs zero, local_ref is
230
+ # the sentinel string "(delete)". The downstream HAS_DELETE branch
231
+ # fail-closes out of the agent path.
232
+ if [[ -z "$dst" || "$dst" == "HEAD" ]]; then
233
+ {
234
+ printf 'PUSH BLOCKED: --delete refspec resolves to HEAD or empty (from %q)\n' "$spec"
235
+ } >&2
236
+ exit 2
237
+ fi
238
+ printf '%s|%s|(delete)|refs/heads/%s\n' "$ZERO_SHA" "$ZERO_SHA" "$dst"
239
+ continue
240
+ fi
241
+ if [[ "$dst" == "HEAD" || -z "$dst" ]]; then
242
+ {
243
+ printf 'PUSH BLOCKED: refspec resolves to HEAD (from %q)\n' "$spec"
244
+ printf '\n'
245
+ # shellcheck disable=SC2016
246
+ printf ' `git push <remote> HEAD:<branch>` or similar is almost always\n'
247
+ printf ' operator error in this context. Name the destination branch\n'
248
+ printf ' explicitly so the review gate can diff against it.\n'
249
+ printf '\n'
250
+ } >&2
251
+ exit 2
252
+ fi
253
+ if [[ -z "$src" ]]; then
254
+ # Deletion via argv; record as all-zeros local_sha.
255
+ printf '%s|%s|(delete)|refs/heads/%s\n' "$ZERO_SHA" "$ZERO_SHA" "$dst"
256
+ continue
257
+ fi
258
+ src_sha=$(cd "$REA_ROOT" && git rev-parse --verify "${src}^{commit}" 2>/dev/null || echo "")
259
+ if [[ -z "$src_sha" ]]; then
260
+ {
261
+ printf 'PUSH BLOCKED: could not resolve source ref %q to a commit.\n' "$src"
262
+ } >&2
263
+ exit 2
264
+ fi
265
+ printf '%s|%s|refs/heads/%s|refs/heads/%s\n' "$src_sha" "$ZERO_SHA" "$src" "$dst"
266
+ done
267
+ }
268
+
269
+ # Collect refspec records. Stdin takes priority; fall back to argv parsing.
270
+ # parse_prepush_stdin exits non-zero when stdin is not a pre-push contract
271
+ # (most common case: Claude Code PreToolUse wrapper delivering JSON on stdin).
272
+ REFSPEC_RECORDS=()
273
+ if RECORDS_OUT=$(parse_prepush_stdin "$INPUT") && [[ -n "$RECORDS_OUT" ]]; then
274
+ :
275
+ else
276
+ RECORDS_OUT=$(resolve_argv_refspecs "$CMD")
60
277
  fi
278
+ while IFS= read -r _rec; do
279
+ [[ -z "$_rec" ]] && continue
280
+ REFSPEC_RECORDS+=("$_rec")
281
+ done <<<"$RECORDS_OUT"
61
282
 
62
- # ── 7. Get diff against target ───────────────────────────────────────────────
63
- MERGE_BASE=$(cd "$REA_ROOT" && git merge-base "$TARGET_BRANCH" HEAD 2>/dev/null || echo "")
283
+ if [[ "${#REFSPEC_RECORDS[@]}" -eq 0 ]]; then
284
+ {
285
+ printf 'PUSH BLOCKED: no push refspecs could be resolved.\n'
286
+ printf ' Refusing to pass without a source commit to review.\n'
287
+ } >&2
288
+ exit 2
289
+ fi
64
290
 
65
- if [[ -z "$MERGE_BASE" ]]; then
66
- # Can't determine merge base fail-open
67
- exit 0
291
+ # ── 7. Pick the source commit and merge-base to review ───────────────────────
292
+ # Across all refspecs, we pick the one whose source commit is furthest from
293
+ # its merge-base (i.e. the largest diff). That way a mixed push like
294
+ # `foo:main bar:dev` is gated on whichever refspec actually contributes new
295
+ # commits. A deletion refspec (local_sha all zeros) is still concerning — we
296
+ # check the remote side for protected-path changes against the merge-base of
297
+ # the remote sha and the default branch, but the diff body comes from the
298
+ # non-delete refspec if present. If every refspec is a delete, we fail-closed
299
+ # and require an explicit review.
300
+ SOURCE_SHA=""
301
+ MERGE_BASE=""
302
+ TARGET_BRANCH=""
303
+ SOURCE_REF=""
304
+ HAS_DELETE=0
305
+ for rec in "${REFSPEC_RECORDS[@]}"; do
306
+ IFS='|' read -r local_sha remote_sha local_ref remote_ref <<<"$rec"
307
+ target="${remote_ref#refs/heads/}"
308
+ target="${target#refs/for/}"
309
+ [[ -z "$target" ]] && target="main"
310
+
311
+ if [[ "$local_sha" == "$ZERO_SHA" ]]; then
312
+ HAS_DELETE=1
313
+ continue
314
+ fi
315
+
316
+ # Merge base: if the remote already has the ref, use remote_sha directly.
317
+ # Otherwise (new branch, remote_sha is zeros), merge-base against the target.
318
+ #
319
+ # Critical: when remote_sha is non-zero but NOT in the local object DB
320
+ # (stale checkout, no recent fetch), older code swallowed `merge-base`
321
+ # failure with `|| echo "$remote_sha"`, assigning a SHA that would make
322
+ # every downstream `rev-list`/`diff` fail. Those failures were then
323
+ # swallowed too, collapsing to an empty DIFF_FULL and fail-open exit 0.
324
+ #
325
+ # Probe object presence up front. Missing object → fail closed with a clear
326
+ # remediation message. No silent fallback.
327
+ if [[ "$remote_sha" != "$ZERO_SHA" ]]; then
328
+ if ! (cd "$REA_ROOT" && git cat-file -e "${remote_sha}^{commit}" 2>/dev/null); then
329
+ {
330
+ printf 'PUSH BLOCKED: remote object %s is not in the local object DB.\n' "$remote_sha"
331
+ printf '\n'
332
+ printf ' The gate cannot compute a review diff without it. Fetch the\n'
333
+ printf ' remote and retry:\n'
334
+ printf '\n'
335
+ printf ' git fetch origin\n'
336
+ printf ' # then retry the push\n'
337
+ printf '\n'
338
+ } >&2
339
+ exit 2
340
+ fi
341
+ mb=$(cd "$REA_ROOT" && git merge-base "$remote_sha" "$local_sha" 2>/dev/null)
342
+ mb_status=$?
343
+ if [[ "$mb_status" -ne 0 || -z "$mb" ]]; then
344
+ {
345
+ printf 'PUSH BLOCKED: no merge-base between remote %s and local %s\n' \
346
+ "${remote_sha:0:12}" "${local_sha:0:12}"
347
+ printf ' The two histories are unrelated; refusing to pass without a\n'
348
+ printf ' reviewable diff.\n'
349
+ } >&2
350
+ exit 2
351
+ fi
352
+ else
353
+ mb=$(cd "$REA_ROOT" && git merge-base "$target" "$local_sha" 2>/dev/null || echo "")
354
+ if [[ -z "$mb" ]]; then
355
+ # New branch whose target has no merge-base locally. Try the default
356
+ # branch if it exists, otherwise fail-closed (handled below).
357
+ mb=$(cd "$REA_ROOT" && git merge-base main "$local_sha" 2>/dev/null || echo "")
358
+ fi
359
+ fi
360
+ if [[ -z "$mb" ]]; then
361
+ continue
362
+ fi
363
+
364
+ # Pick the refspec whose merge-base is the oldest ancestor of its local_sha
365
+ # (i.e. the largest diff). Fail closed on rev-list errors rather than
366
+ # substituting 0 — a failed rev-list means we can't trust the comparison.
367
+ count=$(cd "$REA_ROOT" && git rev-list --count "${mb}..${local_sha}" 2>/dev/null)
368
+ count_status=$?
369
+ if [[ "$count_status" -ne 0 ]]; then
370
+ {
371
+ printf 'PUSH BLOCKED: git rev-list --count %s..%s failed (exit %s)\n' \
372
+ "${mb:0:12}" "${local_sha:0:12}" "$count_status"
373
+ printf ' Cannot size the diff; refusing to pass.\n'
374
+ } >&2
375
+ exit 2
376
+ fi
377
+ if [[ -z "$count" ]]; then
378
+ count=0
379
+ fi
380
+ if [[ -z "$SOURCE_SHA" ]] || [[ "$count" -gt "${BEST_COUNT:-0}" ]]; then
381
+ SOURCE_SHA="$local_sha"
382
+ MERGE_BASE="$mb"
383
+ TARGET_BRANCH="$target"
384
+ SOURCE_REF="$local_ref"
385
+ BEST_COUNT="$count"
386
+ fi
387
+ done
388
+
389
+ if [[ -z "$SOURCE_SHA" || -z "$MERGE_BASE" ]]; then
390
+ if [[ "$HAS_DELETE" -eq 1 ]]; then
391
+ {
392
+ printf 'PUSH BLOCKED: refspec is a branch deletion.\n'
393
+ printf '\n'
394
+ printf ' Branch deletions are sensitive operations and require explicit\n'
395
+ printf ' human action outside the agent. Perform the deletion manually.\n'
396
+ printf '\n'
397
+ } >&2
398
+ exit 2
399
+ fi
400
+ {
401
+ printf 'PUSH BLOCKED: could not resolve a merge-base for any push refspec.\n'
402
+ printf '\n'
403
+ printf ' Fetch the remote and retry, or name an explicit destination.\n'
404
+ printf '\n'
405
+ } >&2
406
+ exit 2
68
407
  fi
69
408
 
70
- DIFF_FULL=$(cd "$REA_ROOT" && git diff "$MERGE_BASE"...HEAD 2>/dev/null || echo "")
409
+ # Capture git diff exit status explicitly. The previous `|| echo ""` swallowed
410
+ # real errors (missing objects, invalid refs) and fell through to the empty-diff
411
+ # fail-open below. We now distinguish:
412
+ # exit 0 + empty output → legitimate no-op push, allow
413
+ # exit 0 + non-empty → proceed to review
414
+ # exit non-zero → fail closed, never allow
415
+ DIFF_FULL=$(cd "$REA_ROOT" && git diff "${MERGE_BASE}...${SOURCE_SHA}" 2>/dev/null)
416
+ DIFF_STATUS=$?
417
+ if [[ "$DIFF_STATUS" -ne 0 ]]; then
418
+ {
419
+ printf 'PUSH BLOCKED: git diff %s...%s failed (exit %s)\n' \
420
+ "${MERGE_BASE:0:12}" "${SOURCE_SHA:0:12}" "$DIFF_STATUS"
421
+ printf ' Cannot compute reviewable diff; refusing to pass.\n'
422
+ } >&2
423
+ exit 2
424
+ fi
71
425
 
72
426
  if [[ -z "$DIFF_FULL" ]]; then
73
- # No diffnothing to review
427
+ # git exited 0 with no output legitimate no-op push (e.g. re-push of an
428
+ # already-remote commit). Allow.
74
429
  exit 0
75
430
  fi
76
431
 
77
432
  LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || echo "0")
78
433
 
434
+ # ── 7a. Protected-path Codex adversarial review gate ────────────────────────
435
+ # If the diff touches governance-critical directories, require a codex.review
436
+ # audit entry for the current HEAD. This enforces the Plan → Build → Review
437
+ # loop for the very code that enforces it.
438
+ #
439
+ # Rationale for gating at push and NOT at commit: commit-review-gate.sh already
440
+ # performs cache-based review with triage thresholds. Doubling friction at
441
+ # every commit is pointless because nothing lands remote without passing the
442
+ # push gate. Leave commit-review-gate alone; do NOT add a mirror of this check
443
+ # there.
444
+ #
445
+ # Path match: we use `git diff --name-status` against the merge-base rather
446
+ # than scraping `+++`/`---` patch headers. Patch headers alone miss file
447
+ # deletions (the `+++` line is `/dev/null` for a deletion of a protected path),
448
+ # which is a trivial bypass. `--name-status` reports both the old and new path
449
+ # columns for every change type (A/C/D/M/R/T/U), so a protected path can be
450
+ # matched regardless of whether the change adds, removes, renames, or modifies.
451
+ #
452
+ # Proof-of-review match: we use `jq -e` with a structured predicate against
453
+ # top-level `tool_name` and `metadata.{head_sha, verdict}`. Substring greps
454
+ # against raw JSON lines are forgeable — the audit-append API accepts arbitrary
455
+ # `metadata`, so a record with `{"metadata":{"note":"tool_name:\"codex.review\""}}`
456
+ # would satisfy two independent greps. Match on the parsed structure instead.
457
+ #
458
+ # ── G11.4: honor review.codex_required ───────────────────────────────────────
459
+ # When policy.review.codex_required is explicitly false, the operator has
460
+ # opted into first-class no-Codex mode. Skip this whole branch — no audit
461
+ # entry is required, the escape-hatch is not relevant, and we fall through
462
+ # to the normal (non-Codex) push validation. The selector in
463
+ # src/gateway/reviewers/select.ts makes the same call for the reviewer pick.
464
+ #
465
+ # Fail-closed: if the helper fails to parse the policy, treat the field as
466
+ # true (safer default) and log a warning. A malformed policy file is an
467
+ # operator problem, not a reason to silently weaken the Codex gate.
468
+ READ_FIELD_JS="${REA_ROOT}/dist/scripts/read-policy-field.js"
469
+ CODEX_REQUIRED="true"
470
+ if [[ -f "$READ_FIELD_JS" ]]; then
471
+ FIELD_VALUE=$(REA_ROOT="$REA_ROOT" node "$READ_FIELD_JS" review.codex_required 2>/dev/null)
472
+ FIELD_STATUS=$?
473
+ case "$FIELD_STATUS" in
474
+ 0)
475
+ # Field is present and a scalar. Accept only literal `true` / `false`.
476
+ # Anything else is a malformed scalar; fail closed.
477
+ if [[ "$FIELD_VALUE" == "false" ]]; then
478
+ CODEX_REQUIRED="false"
479
+ elif [[ "$FIELD_VALUE" == "true" ]]; then
480
+ CODEX_REQUIRED="true"
481
+ else
482
+ printf 'REA WARN: review.codex_required resolved to non-boolean %q — treating as true\n' "$FIELD_VALUE" >&2
483
+ CODEX_REQUIRED="true"
484
+ fi
485
+ ;;
486
+ 1)
487
+ # Field absent (or policy file missing). Documented default is true.
488
+ CODEX_REQUIRED="true"
489
+ ;;
490
+ *)
491
+ # Malformed policy, unexpected helper exit. Fail closed.
492
+ printf 'REA WARN: read-policy-field exited %s — treating review.codex_required as true (fail-closed)\n' "$FIELD_STATUS" >&2
493
+ CODEX_REQUIRED="true"
494
+ ;;
495
+ esac
496
+ fi
497
+
498
+ # [.]github instead of \.github: GNU awk warns on `\.` inside an ERE (it
499
+ # treats the escape as plain `.`), which dirties stderr and makes tests that
500
+ # assert on gate output brittle. `[.]` is the unambiguous ERE form and is
501
+ # silent on every awk we target.
502
+ PROTECTED_RE='(src/gateway/middleware/|hooks/|src/policy/|[.]github/workflows/)'
503
+
504
+ PROTECTED_HITS=$(cd "$REA_ROOT" && git diff --name-status "${MERGE_BASE}...${SOURCE_SHA}" 2>/dev/null)
505
+ PROTECTED_DIFF_STATUS=$?
506
+ if [[ "$PROTECTED_DIFF_STATUS" -ne 0 ]]; then
507
+ {
508
+ printf 'PUSH BLOCKED: git diff --name-status failed (exit %s)\n' "$PROTECTED_DIFF_STATUS"
509
+ printf ' Base: %s\n' "$MERGE_BASE"
510
+ printf ' Cannot determine whether protected paths changed; refusing to pass.\n'
511
+ } >&2
512
+ exit 2
513
+ fi
514
+
515
+ if [[ "$CODEX_REQUIRED" == "true" ]] && printf '%s\n' "$PROTECTED_HITS" | awk -v re="$PROTECTED_RE" '
516
+ # Each line is: STATUS<TAB>PATH1[<TAB>PATH2]
517
+ # Status is one or two letters (single letter for A/M/D/T/U; R/C are
518
+ # followed by a similarity score like R100). We check every PATH column
519
+ # against the protected-path regex so deletions, renames, and copies are
520
+ # all caught.
521
+ {
522
+ status = $1
523
+ if (status !~ /^[ACDMRTU]/) next
524
+ for (i = 2; i <= NF; i++) {
525
+ if ($i ~ re) { found = 1; next }
526
+ }
527
+ }
528
+ END { exit found ? 0 : 1 }
529
+ '; then
530
+ # The audit entry must be keyed on the commit actually being pushed, not on
531
+ # the working-tree HEAD — `git push origin hotfix:main` from a `foo` checkout
532
+ # must match a Codex review of `hotfix`, not of `foo`.
533
+ REVIEW_SHA="$SOURCE_SHA"
534
+
535
+ # ── 7a.1 Escape hatch: REA_SKIP_CODEX_REVIEW ──────────────────────────────
536
+ # Consume the hatch ONLY when we would otherwise require Codex review (i.e.
537
+ # we are inside the protected-path branch). This preserves the gate for
538
+ # every non-protected push.
539
+ #
540
+ # Audit record is written BEFORE the stderr banner and BEFORE exit 0. If
541
+ # the audit write fails (missing dist/ build, missing git identity, Node
542
+ # failure), we fail closed — exit 2 — so an operator cannot silently slip
543
+ # a protected-path push with no receipt.
544
+ if [[ -n "${REA_SKIP_CODEX_REVIEW:-}" ]]; then
545
+ SKIP_REASON="$REA_SKIP_CODEX_REVIEW"
546
+ AUDIT_APPEND_JS="${REA_ROOT}/dist/audit/append.js"
547
+
548
+ if [[ ! -f "$AUDIT_APPEND_JS" ]]; then
549
+ {
550
+ printf 'PUSH BLOCKED: escape hatch requires rea to be built.\n'
551
+ printf '\n'
552
+ printf ' REA_SKIP_CODEX_REVIEW is set but %s is missing.\n' "$AUDIT_APPEND_JS"
553
+ printf ' Run: pnpm build\n'
554
+ printf '\n'
555
+ } >&2
556
+ exit 2
557
+ fi
558
+
559
+ # Actor: prefer git user.email, fall back to user.name. Empty → fail closed.
560
+ SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.email 2>/dev/null || echo "")
561
+ if [[ -z "$SKIP_ACTOR" ]]; then
562
+ SKIP_ACTOR=$(cd "$REA_ROOT" && git config user.name 2>/dev/null || echo "")
563
+ fi
564
+ if [[ -z "$SKIP_ACTOR" ]]; then
565
+ {
566
+ printf 'PUSH BLOCKED: escape hatch requires a git identity.\n'
567
+ printf '\n'
568
+ # shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
569
+ printf ' Neither `git config user.email` nor `git config user.name`\n'
570
+ printf ' is set. The skip audit record would have no actor; refusing\n'
571
+ printf ' to bypass without one.\n'
572
+ printf '\n'
573
+ } >&2
574
+ exit 2
575
+ fi
576
+
577
+ # files_changed is a count only (not a list). The raw name-status stream
578
+ # is already processed elsewhere in the hook; paths may be path-sensitive
579
+ # or leak info we'd rather keep out of the audit line.
580
+ SKIP_FILES_CHANGED=$(printf '%s\n' "$PROTECTED_HITS" | awk 'NF { n++ } END { print n+0 }')
581
+
582
+ # Build the metadata JSON via jq so any weird characters in reason/actor
583
+ # are properly escaped. All values are passed as --arg (strings) except
584
+ # files_changed which is --argjson (number).
585
+ SKIP_METADATA=$(jq -n \
586
+ --arg head_sha "$SOURCE_SHA" \
587
+ --arg target "$TARGET_BRANCH" \
588
+ --arg reason "$SKIP_REASON" \
589
+ --arg actor "$SKIP_ACTOR" \
590
+ --argjson files_changed "$SKIP_FILES_CHANGED" \
591
+ '{
592
+ head_sha: $head_sha,
593
+ target: $target,
594
+ reason: $reason,
595
+ actor: $actor,
596
+ verdict: "skipped",
597
+ files_changed: $files_changed
598
+ }' 2>/dev/null)
599
+
600
+ if [[ -z "$SKIP_METADATA" ]]; then
601
+ {
602
+ printf 'PUSH BLOCKED: escape hatch could not serialize audit metadata.\n' >&2
603
+ } >&2
604
+ exit 2
605
+ fi
606
+
607
+ # Write the audit record via the built helper. Pass REA_ROOT and the
608
+ # metadata JSON through env vars (avoids quoting the values into the
609
+ # one-liner; reason may contain literal double-quotes or backslashes).
610
+ REA_ROOT="$REA_ROOT" REA_SKIP_METADATA="$SKIP_METADATA" \
611
+ node --input-type=module -e "
612
+ const mod = await import(process.env.REA_ROOT + '/dist/audit/append.js');
613
+ const metadata = JSON.parse(process.env.REA_SKIP_METADATA);
614
+ await mod.appendAuditRecord(process.env.REA_ROOT, {
615
+ tool_name: 'codex.review.skipped',
616
+ server_name: 'rea.escape_hatch',
617
+ status: mod.InvocationStatus.Allowed,
618
+ tier: mod.Tier.Read,
619
+ metadata,
620
+ });
621
+ " 2>/dev/null
622
+ NODE_STATUS=$?
623
+ if [[ "$NODE_STATUS" -ne 0 ]]; then
624
+ {
625
+ printf 'PUSH BLOCKED: escape hatch audit-append failed (node exit %s).\n' "$NODE_STATUS"
626
+ printf ' Refusing to bypass the Codex-review gate without a receipt.\n'
627
+ } >&2
628
+ exit 2
629
+ fi
630
+
631
+ # Audit record is durable on disk. Emit the loud stderr banner and allow
632
+ # the push.
633
+ {
634
+ printf '\n'
635
+ printf '== CODEX REVIEW SKIPPED via REA_SKIP_CODEX_REVIEW\n'
636
+ printf ' Reason: %s\n' "$SKIP_REASON"
637
+ printf ' Actor: %s\n' "$SKIP_ACTOR"
638
+ printf ' Head SHA: %s\n' "$SOURCE_SHA"
639
+ printf ' Audited: .rea/audit.jsonl (tool_name=codex.review.skipped)\n'
640
+ printf '\n'
641
+ printf ' This is a gate weakening. Every invocation is permanently audited.\n'
642
+ printf '\n'
643
+ } >&2
644
+ exit 0
645
+ fi
646
+
647
+ AUDIT="${REA_ROOT}/.rea/audit.jsonl"
648
+ CODEX_OK=0
649
+ if [[ -f "$AUDIT" ]]; then
650
+ # jq -e exits 0 iff at least one record matches every predicate. Any other
651
+ # exit (including jq parse errors on a corrupt line) is treated as "no
652
+ # proof of review" and we fail-closed.
653
+ #
654
+ # We require verdict to be an explicit allowlisted value. Missing, null,
655
+ # or unknown verdicts fail the predicate — matching on `!=` alone admits
656
+ # forged records with `metadata` lacking a `verdict` field at all.
657
+ if jq -e --arg sha "$REVIEW_SHA" '
658
+ select(
659
+ .tool_name == "codex.review"
660
+ and .metadata.head_sha == $sha
661
+ and (.metadata.verdict == "pass" or .metadata.verdict == "concerns")
662
+ )
663
+ ' "$AUDIT" >/dev/null 2>&1; then
664
+ CODEX_OK=1
665
+ fi
666
+ fi
667
+ if [[ "$CODEX_OK" -eq 0 ]]; then
668
+ {
669
+ printf 'PUSH BLOCKED: protected paths changed — /codex-review required for %s\n' "$REVIEW_SHA"
670
+ printf '\n'
671
+ printf ' Source ref: %s\n' "${SOURCE_REF:-HEAD}"
672
+ printf ' Diff touches one of:\n'
673
+ printf ' - src/gateway/middleware/\n'
674
+ printf ' - hooks/\n'
675
+ printf ' - src/policy/\n'
676
+ printf ' - .github/workflows/\n'
677
+ printf '\n'
678
+ printf ' Run /codex-review against %s, then retry the push.\n' "$REVIEW_SHA"
679
+ printf ' The codex-adversarial agent emits the required audit entry.\n'
680
+ # shellcheck disable=SC2016 # backticks are literal markdown in user-facing message
681
+ printf ' Only `pass` or `concerns` verdicts satisfy this gate.\n'
682
+ printf '\n'
683
+ } >&2
684
+ exit 2
685
+ fi
686
+ fi
687
+
79
688
  # ── 8. Check review cache ────────────────────────────────────────────────────
80
689
  PUSH_SHA=$(printf '%s' "$DIFF_FULL" | shasum -a 256 | cut -d' ' -f1 2>/dev/null || echo "")
81
690
 
@@ -107,11 +716,12 @@ FILE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -c '^\+\+\+ ' 2>/dev/null || echo "
107
716
  {
108
717
  printf 'PUSH REVIEW GATE: Review required before pushing\n'
109
718
  printf '\n'
110
- printf ' Branch: %s %s\n' "$CURRENT_BRANCH" "$TARGET_BRANCH"
719
+ printf ' Source ref: %s (%s)\n' "${SOURCE_REF:-HEAD}" "${SOURCE_SHA:0:12}"
720
+ printf ' Target: %s\n' "$TARGET_BRANCH"
111
721
  printf ' Scope: %s files changed, %s lines\n' "$FILE_COUNT" "$LINE_COUNT"
112
722
  printf '\n'
113
723
  printf ' Action required:\n'
114
- printf ' 1. Spawn a code-reviewer agent to review: git diff %s...HEAD\n' "$MERGE_BASE"
724
+ printf ' 1. Spawn a code-reviewer agent to review: git diff %s...%s\n' "$MERGE_BASE" "$SOURCE_SHA"
115
725
  printf ' 2. Spawn a security-engineer agent for security review\n'
116
726
  printf ' 3. After both pass, cache the result:\n'
117
727
  printf ' rea cache set %s pass --branch %s --base %s\n' "$PUSH_SHA" "$CURRENT_BRANCH" "$TARGET_BRANCH"