@bookedsolid/rea 0.9.3 → 0.10.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.
@@ -376,6 +376,44 @@ function matchesBlockedPattern(value, pattern) {
376
376
  }
377
377
  return false;
378
378
  }
379
+ // Defect H (rea#79): dot-anchored patterns. A pattern whose base starts with
380
+ // `.` (e.g. `.rea/`, `.env`, `.husky/`) is meant to block ONLY leading-dot
381
+ // filesystem entries — never any path segment that happens to be spelled
382
+ // similarly without the dot. The previous suffix-based match let pattern
383
+ // `.rea/` trip on `Projects/rea/Bug Reports` (any project folder named
384
+ // `rea`) because `suffix.startsWith(base)` was false but the final
385
+ // `segs.includes(base)` fallback conflated `.rea` with `rea` through
386
+ // normalization downstream in some code paths. By explicitly requiring
387
+ // leading-dot segment equality, dot-prefixed patterns cannot bleed across
388
+ // the dot/no-dot boundary regardless of normalization rule drift.
389
+ const dotAnchored = base.startsWith('.');
390
+ if (dotAnchored) {
391
+ // Dot-anchored: segment must equal base exactly. Dir patterns also match
392
+ // "<base>/..." via the trailing slash marker. Never scans non-dot
393
+ // segments, so `Projects/rea/...` can never match `.rea/`.
394
+ for (let i = 0; i < segs.length; i++) {
395
+ const seg = segs[i];
396
+ if (seg === base) {
397
+ // Exact segment match — for a non-dir pattern this matches a FILE
398
+ // named exactly `.env`; for a dir pattern it matches the directory
399
+ // entry itself (the trailing-slash below covers its contents).
400
+ if (!dirPattern && i !== segs.length - 1)
401
+ continue;
402
+ return true;
403
+ }
404
+ if (dirPattern && seg === base)
405
+ return true;
406
+ }
407
+ if (dirPattern) {
408
+ // Dir pattern: any suffix that starts with `<base>/` matches.
409
+ for (let i = 0; i < segs.length; i++) {
410
+ const suffix = segs.slice(i).join('/');
411
+ if (suffix === base || suffix.startsWith(`${base}/`))
412
+ return true;
413
+ }
414
+ }
415
+ return false;
416
+ }
379
417
  for (let i = 0; i < segs.length; i++) {
380
418
  const suffix = segs.slice(i).join('/');
381
419
  if (suffix === base)
@@ -1,6 +1,48 @@
1
1
  import { AutonomyLevel, InvocationStatus, Tier } from '../../policy/types.js';
2
- import { classifyTool, isToolBlocked } from '../../config/tier-map.js';
2
+ import { classifyTool, isToolBlocked, reaCommandTier } from '../../config/tier-map.js';
3
3
  import { loadPolicyAsync } from '../../policy/loader.js';
4
+ const BASH_DISPLAY_MAX_LEN = 80;
5
+ /** Extract the `rea <subcommand>` head from a Bash command string for display
6
+ * in deny messages. Returns `null` when the command is not a rea invocation. */
7
+ function extractReaSubcommand(command) {
8
+ const tokens = command.trim().split(/\s+/);
9
+ if (tokens.length === 0)
10
+ return null;
11
+ const first = tokens[0];
12
+ if (first === undefined)
13
+ return null;
14
+ let idx = 0;
15
+ if (first === 'npx' && tokens.length >= 2 && (tokens[1] === 'rea' || tokens[1] === '@bookedsolid/rea')) {
16
+ idx = 2;
17
+ }
18
+ else if (first === 'rea' || first.endsWith('/rea')) {
19
+ idx = 1;
20
+ }
21
+ else {
22
+ return null;
23
+ }
24
+ const sub = tokens[idx];
25
+ if (sub === undefined)
26
+ return 'rea';
27
+ const sub2 = tokens[idx + 1];
28
+ if (sub2 !== undefined && /^[a-z][a-z-]*$/.test(sub2)) {
29
+ return `rea ${sub} ${sub2}`;
30
+ }
31
+ return `rea ${sub}`;
32
+ }
33
+ /** Build a readable `Bash: <head>` display string for deny messages. Caller
34
+ * is responsible for only invoking this for tool_name === 'Bash'. Uses
35
+ * JSON.stringify to escape hostile characters (newlines, control chars). */
36
+ function formatBashDisplay(command, reaDisplay) {
37
+ if (reaDisplay !== null) {
38
+ return `Bash (${reaDisplay})`;
39
+ }
40
+ const trimmed = command.trim();
41
+ const truncated = trimmed.length > BASH_DISPLAY_MAX_LEN
42
+ ? `${trimmed.slice(0, BASH_DISPLAY_MAX_LEN - 1)}…`
43
+ : trimmed;
44
+ return `Bash (${JSON.stringify(truncated)})`;
45
+ }
4
46
  /**
5
47
  * Autonomy level tier permissions:
6
48
  * - L0: Read only
@@ -48,7 +90,23 @@ export function createPolicyMiddleware(initialPolicy, gatewayConfig, baseDir) {
48
90
  }
49
91
  // SECURITY: Re-derive tier from tool_name — do NOT trust ctx.tier from prior middleware.
50
92
  // This prevents a rogue middleware from downgrading a destructive tool to read-tier.
51
- const tier = classifyTool(ctx.tool_name, ctx.server_name, gatewayConfig);
93
+ let tier = classifyTool(ctx.tool_name, ctx.server_name, gatewayConfig);
94
+ // Defect E (rea#78): when the invocation is a `Bash` call whose command
95
+ // parses as `rea <subcommand>`, classify by subcommand instead of the
96
+ // generic `Write` Bash default. REA's own CLI must not be denied by REA's
97
+ // own middleware at the autonomy level the gate's remediation text
98
+ // targets. Returns null on non-rea commands so the generic tier stands.
99
+ let reaSubcommandDisplay = null;
100
+ if (ctx.tool_name === 'Bash') {
101
+ const command = ctx.arguments['command'];
102
+ if (typeof command === 'string') {
103
+ const subTier = reaCommandTier(command);
104
+ if (subTier !== null) {
105
+ tier = subTier;
106
+ reaSubcommandDisplay = extractReaSubcommand(command);
107
+ }
108
+ }
109
+ }
52
110
  ctx.tier = tier; // Overwrite with authoritative classification
53
111
  // Validate autonomy level is known
54
112
  const allowed = TIER_ALLOWED[policy.autonomy_level];
@@ -60,7 +118,14 @@ export function createPolicyMiddleware(initialPolicy, gatewayConfig, baseDir) {
60
118
  // Check autonomy level vs tier (fail-closed: deny if tier unknown)
61
119
  if (!allowed.has(tier)) {
62
120
  ctx.status = InvocationStatus.Denied;
63
- ctx.error = `Autonomy level ${policy.autonomy_level} does not allow ${tier}-tier tools. Tool: ${ctx.tool_name}`;
121
+ // Defect E composition: when the denial is a Bash invocation, include
122
+ // the command head so the deny-reason is actionable. `Bash` alone tells
123
+ // the operator nothing about WHICH shell command tripped the gate.
124
+ const toolDisplay = ctx.tool_name === 'Bash' && typeof ctx.arguments['command'] === 'string'
125
+ ? formatBashDisplay(ctx.arguments['command'], reaSubcommandDisplay)
126
+ : ctx.tool_name;
127
+ ctx.error = `Autonomy level ${policy.autonomy_level} does not allow ${tier}-tier tools. Tool: ${toolDisplay}`;
128
+ ctx.metadata['reason_code'] = 'tier_exceeds_autonomy';
64
129
  return;
65
130
  }
66
131
  // Store current autonomy level in metadata for audit middleware
@@ -87,7 +87,12 @@ triage_score() {
87
87
  local diff_input
88
88
  diff_input=$(cat)
89
89
  local line_count
90
- line_count=$(printf '%s' "$diff_input" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || echo "0")
90
+ # Defect K (rea#62) sibling: see `hooks/commit-review-gate.sh` for the
91
+ # full bug rationale. `|| echo "0"` captures "0\n0" on no-match, which
92
+ # breaks arithmetic comparisons downstream. `|| true` + bash default keeps
93
+ # the branch arithmetic-safe.
94
+ line_count=$(printf '%s' "$diff_input" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || true)
95
+ line_count="${line_count:-0}"
91
96
 
92
97
  # Check for sensitive paths
93
98
  local sensitive=0
@@ -435,8 +435,8 @@ pr_core_run() {
435
435
  --arg os_uid "$SKIP_OS_UID" \
436
436
  --arg os_whoami "$SKIP_OS_WHOAMI" \
437
437
  --arg os_hostname "$SKIP_OS_HOST" \
438
- --arg os_pid "$SKIP_OS_PID" \
439
- --arg os_ppid "$SKIP_OS_PPID" \
438
+ --argjson os_pid "$SKIP_OS_PID" \
439
+ --argjson os_ppid "$SKIP_OS_PPID" \
440
440
  --arg os_ppid_cmd "$SKIP_OS_PPID_CMD" \
441
441
  --arg os_tty "$SKIP_OS_TTY" \
442
442
  --arg os_ci "$SKIP_OS_CI" \
@@ -924,17 +924,26 @@ pr_core_run() {
924
924
  fi
925
925
  done
926
926
 
927
+ # Defect J (rea#61): branch-deletion guard MUST fail closed regardless of
928
+ # whether another refspec in the same push resolved a SOURCE_SHA. A mixed
929
+ # push like `git push origin safe:safe :main` iterates both refspecs; the
930
+ # safe refspec sets SOURCE_SHA from its local_sha, and the deletion refspec
931
+ # sets only HAS_DELETE=1 via its `continue` branch. If we check HAS_DELETE
932
+ # INSIDE the `-z SOURCE_SHA` fallback, the delete slips through unchecked.
933
+ # Hoist the check above the fallback so any deletion anywhere in the push
934
+ # blocks the entire push.
935
+ if [[ "$HAS_DELETE" -eq 1 ]]; then
936
+ {
937
+ printf 'PUSH BLOCKED: refspec is a branch deletion.\n'
938
+ printf '\n'
939
+ printf ' Branch deletions are sensitive operations and require explicit\n'
940
+ printf ' human action outside the agent. Perform the deletion manually.\n'
941
+ printf '\n'
942
+ } >&2
943
+ exit 2
944
+ fi
945
+
927
946
  if [[ -z "$SOURCE_SHA" || -z "$MERGE_BASE" ]]; then
928
- if [[ "$HAS_DELETE" -eq 1 ]]; then
929
- {
930
- printf 'PUSH BLOCKED: refspec is a branch deletion.\n'
931
- printf '\n'
932
- printf ' Branch deletions are sensitive operations and require explicit\n'
933
- printf ' human action outside the agent. Perform the deletion manually.\n'
934
- printf '\n'
935
- } >&2
936
- exit 2
937
- fi
938
947
  {
939
948
  printf 'PUSH BLOCKED: could not resolve a merge-base for any push refspec.\n'
940
949
  printf '\n'
@@ -969,8 +978,13 @@ pr_core_run() {
969
978
  exit 0
970
979
  fi
971
980
 
981
+ # Defect K (rea#62): `grep -c ... || echo "0"` captures `0\n0` when grep
982
+ # exits non-zero on no-match — grep still prints its own `0` to stdout before
983
+ # exiting, and the `|| echo "0"` branch appends another. `|| true` swallows
984
+ # the non-zero exit, and `${LINE_COUNT:-0}` defaults an empty result to 0.
972
985
  local LINE_COUNT
973
- LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || echo "0")
986
+ LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || true)
987
+ LINE_COUNT="${LINE_COUNT:-0}"
974
988
 
975
989
  # ── 7a. Protected-path Codex adversarial review gate ──────────────────────
976
990
  # The per-refspec check runs inside the main loop (section 7, above) so
@@ -981,8 +995,36 @@ pr_core_run() {
981
995
  # refspec was either clean or had an acceptable audit.
982
996
 
983
997
  # ── 8. Check review cache ─────────────────────────────────────────────────
984
- local PUSH_SHA
985
- PUSH_SHA=$(printf '%s' "$DIFF_FULL" | shasum -a 256 | cut -d' ' -f1 2>/dev/null || echo "")
998
+ # Defect L (rea#63): `shasum` is not installed on Alpine, distroless, or
999
+ # most minimal Linux CI images only `sha256sum` is. The prior `shasum -a
1000
+ # 256 ... || echo ""` chain silently produced an empty PUSH_SHA, which the
1001
+ # rest of the gate treats as "no cache entry" rather than "hasher missing".
1002
+ # Combined with the silent-cache-miss fallback (Defect F), every push from
1003
+ # such a runner burned a full fresh codex review invisibly.
1004
+ #
1005
+ # Portable chain: sha256sum → shasum → openssl. The openssl branch uses
1006
+ # `awk '{print $NF}'` WITHOUT `-r` — `-r` was added in OpenSSL 3.0 /
1007
+ # LibreSSL 3.3+; on OpenSSL 1.1.1 (Debian 11, Ubuntu 20.04, RHEL 8,
1008
+ # Amazon Linux 2, Alpine 3.13–3.14) `-r` is rejected and stdout is empty.
1009
+ # `$NF` handles BOTH default output shapes: `(stdin)= <hex>` (1.1.x) and
1010
+ # `<hex> *stdin` (3.x/LibreSSL coreutils-style).
1011
+ #
1012
+ # Hex-64 validation catches broken pipes, partial reads, or unexpected
1013
+ # hasher output that would otherwise be silently cached as garbage.
1014
+ local PUSH_SHA=""
1015
+ if command -v sha256sum >/dev/null 2>&1; then
1016
+ PUSH_SHA=$(printf '%s' "$DIFF_FULL" | sha256sum 2>/dev/null | awk '{print $1}')
1017
+ elif command -v shasum >/dev/null 2>&1; then
1018
+ PUSH_SHA=$(printf '%s' "$DIFF_FULL" | shasum -a 256 2>/dev/null | awk '{print $1}')
1019
+ elif command -v openssl >/dev/null 2>&1; then
1020
+ PUSH_SHA=$(printf '%s' "$DIFF_FULL" | openssl dgst -sha256 2>/dev/null | awk '{print $NF}')
1021
+ else
1022
+ printf 'rea push-review: WARN no sha256 hasher found (sha256sum/shasum/openssl); cache disabled\n' >&2
1023
+ fi
1024
+ if [[ -n "$PUSH_SHA" && ! "$PUSH_SHA" =~ ^[0-9a-f]{64}$ ]]; then
1025
+ printf 'rea push-review: WARN hasher returned invalid output; cache disabled\n' >&2
1026
+ PUSH_SHA=""
1027
+ fi
986
1028
 
987
1029
  local -a REA_CLI_ARGS
988
1030
  REA_CLI_ARGS=()
@@ -1017,8 +1059,45 @@ pr_core_run() {
1017
1059
  fi
1018
1060
 
1019
1061
  if [[ -n "$PUSH_SHA" ]] && [[ ${#REA_CLI_ARGS[@]} -gt 0 ]]; then
1062
+ # Defect F (rea#75): distinguish cache-miss from cache-error. Prior version
1063
+ # swallowed all non-zero exits and stderr into a silent `{hit:false}`, which
1064
+ # masked Defect A (0.9.2 `node <shim>` SyntaxError) for weeks. Now we
1065
+ # capture stderr + exit code separately and emit a visible WARN with an
1066
+ # actionable filename when the CLI failed.
1020
1067
  local CACHE_RESULT
1021
- CACHE_RESULT=$("${REA_CLI_ARGS[@]}" cache check "$PUSH_SHA" --branch "$SOURCE_BRANCH" --base "$TARGET_BRANCH" 2>/dev/null || echo '{"hit":false}')
1068
+ local CACHE_STDOUT=""
1069
+ local CACHE_STDERR_FILE
1070
+ # SECURITY (Codex LOW 4): require mktemp. A predictable /tmp path like
1071
+ # /tmp/rea-cache-err.$PID is a TOCTOU attack surface on shared hosts —
1072
+ # another user can pre-create a symlink from that name to a file they
1073
+ # want us to clobber. If mktemp is unavailable, fail loudly rather than
1074
+ # silently falling back to a predictable path.
1075
+ if ! CACHE_STDERR_FILE=$(mktemp -t rea-cache-err.XXXXXX 2>/dev/null); then
1076
+ printf 'rea push-review: mktemp unavailable; cannot capture cache-check stderr. Aborting.\n' >&2
1077
+ return 2
1078
+ fi
1079
+ local CACHE_EXIT=0
1080
+ CACHE_STDOUT=$("${REA_CLI_ARGS[@]}" cache check "$PUSH_SHA" --branch "$SOURCE_BRANCH" --base "$TARGET_BRANCH" 2>"$CACHE_STDERR_FILE") || CACHE_EXIT=$?
1081
+ local CACHE_STDERR=""
1082
+ CACHE_STDERR=$(cat "$CACHE_STDERR_FILE" 2>/dev/null || true)
1083
+ rm -f "$CACHE_STDERR_FILE"
1084
+ if [[ "$CACHE_EXIT" -ne 0 ]]; then
1085
+ # SECURITY (Codex LOW 5): strip C0/C1 control characters from CLI
1086
+ # stderr before echoing to the terminal. A tampered dist/ or hostile
1087
+ # CLI could otherwise emit OSC/CSI sequences that rewrite lines above
1088
+ # the deny message and mislead the operator. We strip both C0 + DEL
1089
+ # AND C1 (0x80-0x9F) — some terminal emulators still honor bare C1
1090
+ # bytes as CSI introducers (0x9B) or OSC (0x9D).
1091
+ local CACHE_STDERR_SAFE
1092
+ CACHE_STDERR_SAFE=$(printf '%s' "$CACHE_STDERR" | LC_ALL=C tr -d '\000-\037\177\200-\237')
1093
+ printf 'rea push-review: CACHE CHECK FAILED (exit=%d): %s\n' "$CACHE_EXIT" "$CACHE_STDERR_SAFE" >&2
1094
+ printf 'rea push-review: treating as miss; file bookedsolidtech/rea issue if unexpected.\n' >&2
1095
+ CACHE_RESULT='{"hit":false,"reason":"query_error"}'
1096
+ elif [[ -z "$CACHE_STDOUT" ]]; then
1097
+ CACHE_RESULT='{"hit":false,"reason":"cold"}'
1098
+ else
1099
+ CACHE_RESULT="$CACHE_STDOUT"
1100
+ fi
1022
1101
  # Require BOTH hit == true AND result == "pass". A cached `fail` verdict
1023
1102
  # (Codex 0.8.0 pass-2 finding #1) must NOT satisfy the gate — cache.ts
1024
1103
  # serializes `result` verbatim, so a negative verdict would otherwise
@@ -1037,8 +1116,10 @@ pr_core_run() {
1037
1116
  fi
1038
1117
 
1039
1118
  # ── 9. Block and request review ───────────────────────────────────────────
1119
+ # Defect K (rea#62): same `0\n0` bug as LINE_COUNT above.
1040
1120
  local FILE_COUNT
1041
- FILE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -c '^\+\+\+ ' 2>/dev/null || echo "0")
1121
+ FILE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -c '^\+\+\+ ' 2>/dev/null || true)
1122
+ FILE_COUNT="${FILE_COUNT:-0}"
1042
1123
 
1043
1124
  {
1044
1125
  printf 'PUSH REVIEW GATE: Review required before pushing\n'
@@ -1050,8 +1131,23 @@ pr_core_run() {
1050
1131
  printf ' Action required:\n'
1051
1132
  printf ' 1. Spawn a code-reviewer agent to review: git diff %s..%s\n' "$MERGE_BASE" "$SOURCE_SHA"
1052
1133
  printf ' 2. Spawn a security-engineer agent for security review\n'
1053
- printf ' 3. After both pass, cache the result:\n'
1054
- printf ' rea cache set %s pass --branch %s --base %s\n' "$PUSH_SHA" "$SOURCE_BRANCH" "$TARGET_BRANCH"
1134
+ # Defect L (rea#63) follow-up: when no sha256 hasher is available the
1135
+ # cache is disabled and PUSH_SHA is empty. Emitting `rea cache set <blank>
1136
+ # pass ...` would be a dead-end — the CLI rejects the empty positional.
1137
+ # Print an alternate completion path in that case. The Codex-adversarial
1138
+ # review concerns list flagged this UX cliff in the 0.9.4 pass.
1139
+ if [[ -n "$PUSH_SHA" ]]; then
1140
+ printf ' 3. After both pass, cache the result:\n'
1141
+ printf ' rea cache set %s pass --branch %s --base %s\n' "$PUSH_SHA" "$SOURCE_BRANCH" "$TARGET_BRANCH"
1142
+ else
1143
+ printf ' 3. Cache is DISABLED on this host (no sha256 hasher found).\n'
1144
+ printf ' After both reviews pass, bypass the push-review gate with:\n'
1145
+ printf ' REA_SKIP_PUSH_REVIEW="<reason>" git push ...\n'
1146
+ printf ' The bypass is audited as push.review.skipped — this is the\n'
1147
+ printf ' documented escape hatch when cache is unavailable.\n'
1148
+ printf ' To restore the cache path, install one of: sha256sum,\n'
1149
+ printf ' shasum (Perl Digest::SHA), or openssl.\n'
1150
+ fi
1055
1151
  printf '\n'
1056
1152
  } >&2
1057
1153
  exit 2
@@ -114,7 +114,16 @@ if [[ -z "$DIFF_OUTPUT" ]]; then
114
114
  fi
115
115
 
116
116
  # Count changed lines (additions + deletions)
117
- LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || echo "0")
117
+ # Defect K (rea#62) sibling: `|| echo "0"` captures "0\n0" into LINE_COUNT
118
+ # when grep exits non-zero on a no-match — grep still prints its own `0` and
119
+ # `echo "0"` appends another. At this site the concatenated `"0\n0"` is then
120
+ # evaluated as arithmetic (`-gt $SIGNIFICANT_THRESHOLD`, `-ge $TRIVIAL_THRESHOLD`
121
+ # below) and bash emits a "syntax error in expression" at runtime on any
122
+ # rename-only / mode-only / empty-file-add diff. `|| true` + bash-default
123
+ # expansion fixes both the banner cosmetic and the arithmetic-unsafe control
124
+ # flow in one shot.
125
+ LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || true)
126
+ LINE_COUNT="${LINE_COUNT:-0}"
118
127
 
119
128
  # Check for sensitive paths
120
129
  SENSITIVE=0
@@ -162,17 +171,103 @@ fi
162
171
 
163
172
  # ── 10. Check review cache for all non-trivial commits ────────────────────────
164
173
  # Compute SHA and branch here so both standard and significant tiers share them.
165
- STAGED_SHA=$(cd "$REA_ROOT" && git diff --cached | shasum -a 256 | cut -d' ' -f1 2>/dev/null || echo "")
174
+ #
175
+ # Defect L (rea#63) sibling: `shasum` is not installed on Alpine, distroless,
176
+ # or most minimal Linux CI images — only `sha256sum` is. The prior chain
177
+ # silently produced an empty STAGED_SHA, which the cache block then skipped
178
+ # AND the banner at §11 rendered as `rea cache set pass` — a dead-end the
179
+ # agent cannot execute. Portable chain mirrors push-review-core.sh §8:
180
+ # sha256sum → shasum → openssl. The openssl branch uses `awk '{print $NF}'`
181
+ # WITHOUT `-r` to stay compatible with OpenSSL 1.1.x (Debian 11, Ubuntu
182
+ # 20.04, RHEL 8, Amazon Linux 2, Alpine 3.13–3.14).
183
+ STAGED_SHA=""
184
+ if command -v sha256sum >/dev/null 2>&1; then
185
+ STAGED_SHA=$(printf '%s' "$DIFF_FULL" | sha256sum 2>/dev/null | awk '{print $1}')
186
+ elif command -v shasum >/dev/null 2>&1; then
187
+ STAGED_SHA=$(printf '%s' "$DIFF_FULL" | shasum -a 256 2>/dev/null | awk '{print $1}')
188
+ elif command -v openssl >/dev/null 2>&1; then
189
+ STAGED_SHA=$(printf '%s' "$DIFF_FULL" | openssl dgst -sha256 2>/dev/null | awk '{print $NF}')
190
+ else
191
+ printf 'rea commit-review: WARN no sha256 hasher found (sha256sum/shasum/openssl); cache disabled\n' >&2
192
+ fi
193
+ if [[ -n "$STAGED_SHA" && ! "$STAGED_SHA" =~ ^[0-9a-f]{64}$ ]]; then
194
+ printf 'rea commit-review: WARN hasher returned invalid output; cache disabled\n' >&2
195
+ STAGED_SHA=""
196
+ fi
166
197
  BRANCH=$(cd "$REA_ROOT" && git branch --show-current 2>/dev/null || echo "")
167
198
  CACHE_FILE="${REA_ROOT}/.rea/review-cache.json"
168
199
 
200
+ # Codex pass-3 finding #1: `rea cache check` and `rea cache set` both declare
201
+ # `--base` as a `requiredOption` in src/cli/index.ts. Prior versions of this
202
+ # gate omitted `--base`, so (a) the CLI path exited non-zero and the
203
+ # `|| echo '{"hit":false}'` fallback quietly masked the contract error, and
204
+ # (b) the section-11 banner instructed the agent to run `rea cache set <sha>
205
+ # pass` — also missing `--base`, rejected by the CLI on every retry. A
206
+ # successful cache flow was unreachable.
207
+ #
208
+ # Resolve BASE_BRANCH by the same preference order the push-gate uses in
209
+ # push-review-core.sh §7 (lines 778-794): origin/HEAD → origin/main →
210
+ # origin/master → empty. If nothing resolves, disable the cache (the
211
+ # alternative is emitting a cache command the CLI rejects on every call).
212
+ BASE_BRANCH=""
213
+ _origin_head=$(cd "$REA_ROOT" && git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || true)
214
+ if [[ -n "$_origin_head" ]]; then
215
+ BASE_BRANCH="${_origin_head#refs/remotes/origin/}"
216
+ fi
217
+ if [[ -z "$BASE_BRANCH" ]]; then
218
+ # Use `git -C` so the current-shell cwd is never mutated — matches the
219
+ # cross-repo guard at §1a and keeps the file's dominant idiom. Raw
220
+ # `cd "$REA_ROOT" && git …` would leave the hook process sitting in
221
+ # $REA_ROOT, which is safe today but breaks silently if a future edit
222
+ # adds a relative-path command downstream.
223
+ if git -C "$REA_ROOT" rev-parse --verify --quiet refs/remotes/origin/main >/dev/null 2>&1; then
224
+ BASE_BRANCH="main"
225
+ elif git -C "$REA_ROOT" rev-parse --verify --quiet refs/remotes/origin/master >/dev/null 2>&1; then
226
+ BASE_BRANCH="master"
227
+ fi
228
+ fi
229
+ if [[ -z "$BASE_BRANCH" && -n "$STAGED_SHA" ]]; then
230
+ printf 'rea commit-review: WARN could not resolve base branch (no origin/HEAD, no origin/main, no origin/master); cache disabled\n' >&2
231
+ STAGED_SHA=""
232
+ fi
233
+ unset _origin_head
234
+
169
235
  if [[ -n "$STAGED_SHA" ]]; then
170
236
  CACHE_HIT=false
171
237
 
172
- # Primary: use CLI when available — handles TTL, expiry, and branch-scoped keys
238
+ # Primary: use CLI when available — handles TTL, expiry, and branch-scoped keys.
239
+ # Cache predicate must require BOTH `.hit == true` AND `.result == "pass"` —
240
+ # a cached `fail` verdict would otherwise satisfy `.hit == true` and let the
241
+ # commit proceed despite a recorded negative review. Mirrors the push-gate
242
+ # predicate at push-review-core.sh §8; the §218-226 direct-cache fallback
243
+ # already enforces `result == "pass"`, so the two paths must agree.
173
244
  if [[ ${#REA_CLI_ARGS[@]} -gt 0 ]]; then
174
- CACHE_RESULT=$("${REA_CLI_ARGS[@]}" cache check "$STAGED_SHA" --branch "$BRANCH" 2>/dev/null || echo '{"hit":false}')
175
- if printf '%s' "$CACHE_RESULT" | jq -e '.hit == true' >/dev/null 2>&1; then
245
+ # Defect F (rea#75): surface cache-query errors instead of treating them as
246
+ # legitimate misses. See hooks/_lib/push-review-core.sh for the rationale.
247
+ # SECURITY (Codex LOW 4): require mktemp. Predictable /tmp paths are a
248
+ # TOCTOU surface on shared hosts; fall-loud instead of fall-back.
249
+ if ! CACHE_STDERR_FILE=$(mktemp -t rea-commit-cache-err.XXXXXX 2>/dev/null); then
250
+ printf 'rea commit-review: mktemp unavailable; cannot capture cache-check stderr. Aborting.\n' >&2
251
+ exit 2
252
+ fi
253
+ CACHE_EXIT=0
254
+ CACHE_STDOUT=$("${REA_CLI_ARGS[@]}" cache check "$STAGED_SHA" --branch "$BRANCH" --base "$BASE_BRANCH" 2>"$CACHE_STDERR_FILE") || CACHE_EXIT=$?
255
+ CACHE_STDERR=$(cat "$CACHE_STDERR_FILE" 2>/dev/null || true)
256
+ rm -f "$CACHE_STDERR_FILE"
257
+ if [[ "$CACHE_EXIT" -ne 0 ]]; then
258
+ # SECURITY (Codex LOW 5): strip C0/C1 control chars before echoing CLI
259
+ # stderr. Includes 0x80-0x9F because some terminals interpret bare C1
260
+ # bytes (CSI 0x9B, OSC 0x9D) as escape introducers.
261
+ CACHE_STDERR_SAFE=$(printf '%s' "$CACHE_STDERR" | LC_ALL=C tr -d '\000-\037\177\200-\237')
262
+ printf 'rea commit-review: CACHE CHECK FAILED (exit=%d): %s\n' "$CACHE_EXIT" "$CACHE_STDERR_SAFE" >&2
263
+ printf 'rea commit-review: treating as miss; file bookedsolidtech/rea issue if unexpected.\n' >&2
264
+ CACHE_RESULT='{"hit":false,"reason":"query_error"}'
265
+ elif [[ -z "$CACHE_STDOUT" ]]; then
266
+ CACHE_RESULT='{"hit":false,"reason":"cold"}'
267
+ else
268
+ CACHE_RESULT="$CACHE_STDOUT"
269
+ fi
270
+ if printf '%s' "$CACHE_RESULT" | jq -e '.hit == true and .result == "pass"' >/dev/null 2>&1; then
176
271
  CACHE_HIT=true
177
272
  fi
178
273
  fi
@@ -210,8 +305,25 @@ fi
210
305
  printf ' 1. Inspect: git diff --cached\n'
211
306
  printf ' 2. Decide: Is this safe to commit? (initial commits, refactors, and\n'
212
307
  printf ' feature work are normal — use judgement, not ceremony)\n'
213
- printf ' 3. Approve: rea cache set %s pass\n' "$STAGED_SHA"
214
- printf ' 4. Retry the git commit command\n'
308
+ # Defect L follow-up: when no sha256 hasher is available STAGED_SHA is empty
309
+ # and `rea cache set pass` is a dead-end the CLI rejects. Branch the banner
310
+ # to surface an actionable path instead. Unlike push-review-core.sh there is
311
+ # no `REA_SKIP_COMMIT_REVIEW` env escape hatch (the commit gate only fires
312
+ # under Claude Code's Bash `PreToolUse` matcher, so a human direct-shell
313
+ # commit bypasses it entirely). The only remediation is to install a sha256
314
+ # hasher or ask the user to commit directly.
315
+ if [[ -n "$STAGED_SHA" ]]; then
316
+ printf ' 3. Approve: rea cache set %s pass --branch %s --base %s\n' \
317
+ "$STAGED_SHA" "$BRANCH" "$BASE_BRANCH"
318
+ printf ' 4. Retry the git commit command\n'
319
+ else
320
+ printf ' 3. Cache is DISABLED on this host (no sha256 hasher or no base\n'
321
+ printf ' branch resolvable). Install one of: sha256sum (Linux coreutils),\n'
322
+ printf ' shasum (perl-core), or openssl; or ensure origin/HEAD is set so\n'
323
+ printf ' the gate can identify the merge target. Without these the cache\n'
324
+ printf ' path cannot complete — escalate to the user if neither can be\n'
325
+ printf ' provided.\n'
326
+ fi
215
327
  printf '\n'
216
328
  printf ' Only escalate to the user if you find a genuine problem in the diff.\n'
217
329
  } >&2