@bookedsolid/rea 0.9.4 → 0.10.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.
@@ -1,4 +1,31 @@
1
1
  import type { Tier, InvocationStatus } from '../../policy/types.js';
2
+ /**
3
+ * Emission-path discriminator for the audit record (defect P).
4
+ *
5
+ * The push-review gate trusts `tool_name: "codex.review"` records to certify
6
+ * a real Codex adversarial review ran on the given commit SHA. Before this
7
+ * field existed, any script with filesystem access to `node_modules` could
8
+ * call `appendAuditRecord(...)` with a `codex.review` tool name and forge
9
+ * the certification — the governance promise was a convention, not enforced.
10
+ *
11
+ * `emission_source` tags the code path that wrote the record:
12
+ *
13
+ * - `"rea-cli"` — emitted by the `rea` CLI itself (e.g. `rea audit
14
+ * record codex-review`). The rea CLI is classified by
15
+ * `reaCommandTier()` (defect E) and is an audited,
16
+ * policy-governed entry point.
17
+ * - `"codex-cli"` — emitted by the Codex adversarial review path itself,
18
+ * the authoritative source.
19
+ * - `"other"` — every other caller of the public
20
+ * `appendAuditRecord()` helper (consumer plugins,
21
+ * ad-hoc scripts, tests). Legitimate for event types
22
+ * OTHER than `codex.review`; REJECTED by the
23
+ * push-review cache gate for `codex.review` lookups.
24
+ *
25
+ * The field is part of the hashed record body — it cannot be altered after
26
+ * the fact without breaking the chain.
27
+ */
28
+ export type EmissionSource = 'rea-cli' | 'codex-cli' | 'other';
2
29
  export interface AuditRecord {
3
30
  timestamp: string;
4
31
  session_id: string;
@@ -21,6 +48,14 @@ export interface AuditRecord {
21
48
  * the redaction middleware runs on `ctx.arguments`, not on metadata.
22
49
  */
23
50
  metadata?: Record<string, unknown>;
51
+ /**
52
+ * Defect P (0.10.1). Discriminates the emission path: `"rea-cli"` for
53
+ * rea's own CLI, `"codex-cli"` for the Codex adversarial reviewer,
54
+ * `"other"` for every other caller of the public audit helper. Required
55
+ * field; the push-review gate refuses to accept `codex.review` records
56
+ * whose source is `"other"` (or missing, for pre-0.10.1 legacy records).
57
+ */
58
+ emission_source: EmissionSource;
24
59
  hash: string;
25
60
  prev_hash: string;
26
61
  }
@@ -95,6 +95,12 @@ metrics) {
95
95
  autonomy_level: autonomyLevel,
96
96
  duration_ms,
97
97
  prev_hash: prevHash,
98
+ // Defect P: gateway middleware records every proxied tool call.
99
+ // rea itself is the writer — tag as rea-cli so the schema is
100
+ // consistent. "rea-cli" here is a misnomer (the gateway isn't a
101
+ // CLI) but is part of the stable 0.10.1 discriminator set;
102
+ // semantically it means "written by @bookedsolid/rea itself".
103
+ emission_source: 'rea-cli',
98
104
  };
99
105
  if (ctx.error) {
100
106
  recordBase.error = ctx.error;
@@ -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
@@ -141,7 +141,10 @@ async function emitSideEffects(baseDir, c, log) {
141
141
  boxLine(` current: ${c.current.slice(0, 16)}…`),
142
142
  boxLine(''),
143
143
  boxLine(' The server will NOT connect. Other servers remain up.'),
144
- boxLine(' To accept (once): REA_ACCEPT_DRIFT=<name> rea serve'),
144
+ boxLine(' After a legitimate registry edit:'),
145
+ boxLine(` rea tofu accept ${c.server} --reason "<why>"`),
146
+ boxLine(' One-shot bypass (not recommended):'),
147
+ boxLine(` REA_ACCEPT_DRIFT=${c.server} rea serve`),
145
148
  ` ╚${'═'.repeat(BOX_INNER_WIDTH)}╝`,
146
149
  '',
147
150
  ].join('\n'));
@@ -719,12 +719,20 @@ pr_core_run() {
719
719
  # fail-closed and require an explicit review.
720
720
  local SOURCE_SHA="" MERGE_BASE="" TARGET_BRANCH="" SOURCE_REF=""
721
721
  local HAS_DELETE=0 BEST_COUNT=0
722
- local rec local_sha remote_sha local_ref remote_ref target mb mb_status count count_status
722
+ local rec local_sha remote_sha local_ref remote_ref target resolved_base mb mb_status count count_status
723
723
  for rec in "${REFSPEC_RECORDS[@]}"; do
724
724
  IFS='|' read -r local_sha remote_sha local_ref remote_ref <<<"$rec"
725
725
  target="${remote_ref#refs/heads/}"
726
726
  target="${target#refs/for/}"
727
727
  [[ -z "$target" ]] && target="main"
728
+ # Defect N: track the SEMANTIC base (the ref the diff was anchored on)
729
+ # distinctly from `target` (the pushed remote ref). For a tracked branch
730
+ # they coincide; for a new branch, `target` is the branch name being
731
+ # created — which is NOT what we reviewed against, so `Target:` must
732
+ # echo `resolved_base` instead. Default to `target` for the tracked
733
+ # case; the new-branch path overrides with the resolved default_ref
734
+ # short name below.
735
+ resolved_base="$target"
728
736
 
729
737
  if [[ "$local_sha" == "$ZERO_SHA" ]]; then
730
738
  HAS_DELETE=1
@@ -774,25 +782,81 @@ pr_core_run() {
774
782
  #
775
783
  # argv_remote is set from the adapter's argv (git passes the remote name
776
784
  # as $1 on pre-push); defaults to "origin" when absent (BUG-008 sniff).
777
- local default_ref default_ref_status
778
- default_ref=$(cd "$REA_ROOT" && git symbolic-ref "refs/remotes/${argv_remote}/HEAD" 2>/dev/null)
779
- default_ref_status=$?
780
- if [[ "$default_ref_status" -ne 0 || -z "$default_ref" ]]; then
781
- # symbolic-ref failed (common on shallow or mirror clones where
782
- # origin/HEAD was never set). Probe the common default-branch names in
783
- # order: main, then master. Both are remote-tracking refs and still
784
- # server-authoritative; the order matters only for projects that still
785
- # default to `master` (older internal forks), where without this
786
- # fallback the first push of a new branch would fail closed.
787
- if cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/main" >/dev/null 2>&1; then
788
- default_ref="refs/remotes/${argv_remote}/main"
789
- elif cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/master" >/dev/null 2>&1; then
790
- default_ref="refs/remotes/${argv_remote}/master"
791
- else
792
- default_ref=""
785
+ #
786
+ # Defect N (0.10.1): BEFORE falling back to the remote's default branch,
787
+ # consult per-branch config `branch.<source>.base`. A feature branch
788
+ # targeting `dev` in a main-as-production repo would otherwise resolve
789
+ # against `origin/main` silently, producing a diff that spans the entire
790
+ # dev→main history reviewers see "Scope: 28690 lines" for a 4-file
791
+ # change. The git-config route uses local branch knowledge that is
792
+ # authoritative for this working copy (set via `git branch --set-upstream`,
793
+ # or by CI tooling that tracks the intended target). This is consulted
794
+ # BEFORE origin/HEAD because the latter is a server-default that may
795
+ # mis-represent the reviewer's actual intent for this specific branch.
796
+ local default_ref default_ref_status configured_base source_branch
797
+ source_branch="${local_ref#refs/heads/}"
798
+ default_ref=""
799
+ # Codex 0.10.1 finding #1: `local` is function-scoped, not loop-
800
+ # iteration-scoped — without an explicit reset, iteration N inherits
801
+ # iteration N-1's configured_base and falsely promotes resolved_base
802
+ # when the current refspec's local_ref does NOT begin with refs/heads/
803
+ # (tag push, gerrit-style refs/for/, etc.). Reset before every
804
+ # potential assignment so each iteration sees a clean slate.
805
+ configured_base=""
806
+
807
+ if [[ -n "$source_branch" && "$source_branch" != "HEAD" ]]; then
808
+ configured_base=$(cd "$REA_ROOT" && git config --get "branch.${source_branch}.base" 2>/dev/null || echo "")
809
+ if [[ -n "$configured_base" ]]; then
810
+ # Prefer the REMOTE-TRACKING form so the gate still anchors on a
811
+ # server-authoritative ref (see the local-ref hijack explanation
812
+ # above). Fall back to the local short ref only if the remote
813
+ # counterpart doesn't exist, with a visible WARN on stderr — the
814
+ # local ref is less trustworthy and the reviewer should know.
815
+ if cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/${configured_base}" >/dev/null 2>&1; then
816
+ default_ref="refs/remotes/${argv_remote}/${configured_base}"
817
+ elif cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/heads/${configured_base}" >/dev/null 2>&1; then
818
+ default_ref="refs/heads/${configured_base}"
819
+ printf 'WARN: branch.%s.base=%s resolved to local ref; remote counterpart %s/%s missing — reviewer-side diff may be stale\n' \
820
+ "$source_branch" "$configured_base" "$argv_remote" "$configured_base" >&2
821
+ fi
822
+ fi
823
+ fi
824
+
825
+ if [[ -z "$default_ref" ]]; then
826
+ default_ref=$(cd "$REA_ROOT" && git symbolic-ref "refs/remotes/${argv_remote}/HEAD" 2>/dev/null)
827
+ default_ref_status=$?
828
+ if [[ "$default_ref_status" -ne 0 || -z "$default_ref" ]]; then
829
+ # symbolic-ref failed (common on shallow or mirror clones where
830
+ # origin/HEAD was never set). Probe the common default-branch names in
831
+ # order: main, then master. Both are remote-tracking refs and still
832
+ # server-authoritative; the order matters only for projects that still
833
+ # default to `master` (older internal forks), where without this
834
+ # fallback the first push of a new branch would fail closed.
835
+ if cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/main" >/dev/null 2>&1; then
836
+ default_ref="refs/remotes/${argv_remote}/main"
837
+ elif cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/master" >/dev/null 2>&1; then
838
+ default_ref="refs/remotes/${argv_remote}/master"
839
+ else
840
+ default_ref=""
841
+ fi
793
842
  fi
794
843
  fi
795
844
  if [[ -n "$default_ref" ]]; then
845
+ # Defect N: if operator-configured `branch.<source>.base` resolved the
846
+ # ref we're about to diff against, overwrite `resolved_base` with the
847
+ # short name so TARGET_BRANCH (and the Target: label) reflect the
848
+ # actual review anchor. Without an explicit config override, leave
849
+ # `resolved_base` at the refspec target — this preserves the cache
850
+ # contract for new-branch pushes where remote_ref is the same as the
851
+ # source branch (the common case) and for bare pushes that
852
+ # argv-resolve via `@{upstream}`. Only operators who opted into a
853
+ # per-branch base get the label promoted, keeping the change
854
+ # backward-compatible for every other path.
855
+ if [[ -n "$configured_base" ]]; then
856
+ resolved_base="${default_ref#refs/remotes/${argv_remote}/}"
857
+ resolved_base="${resolved_base#refs/heads/}"
858
+ [[ -z "$resolved_base" ]] && resolved_base="$default_ref"
859
+ fi
796
860
  mb=$(cd "$REA_ROOT" && git merge-base "$default_ref" "$local_sha" 2>/dev/null || echo "")
797
861
  if [[ -z "$mb" ]]; then
798
862
  # default_ref resolved but merge-base came back empty (unrelated
@@ -867,13 +931,40 @@ pr_core_run() {
867
931
  if [[ "$CODEX_WAIVER_ACTIVE" == "1" ]]; then
868
932
  _codex_ok=1
869
933
  elif [[ -f "$_audit" ]]; then
870
- if jq -e --arg sha "$local_sha" '
871
- select(
872
- .tool_name == "codex.review"
873
- and .metadata.head_sha == $sha
874
- and (.metadata.verdict == "pass" or .metadata.verdict == "concerns")
875
- )
876
- ' "$_audit" >/dev/null 2>&1; then
934
+ # Defect P (0.10.1): require .emission_source == "rea-cli" or
935
+ # "codex-cli" so agents cannot forge a codex.review record by
936
+ # directly calling appendAuditRecord() from an ad-hoc .mjs script
937
+ # (the generic helper stamps "other"). Legacy records (pre-0.10.1)
938
+ # have no emission_source field and are rejected the first push
939
+ # on an upgraded consumer requires a fresh `rea audit record
940
+ # codex-review` (or Codex CLI emission) which stamps "rea-cli".
941
+ #
942
+ # Defect T/U (0.10.2): read the audit file as raw lines and parse
943
+ # each with `fromjson?`. Before 0.10.2 this scan used
944
+ # `jq -e '<filter>' "$_audit"` which feeds the file as a single
945
+ # JSON stream — a single malformed line (literal backslash-u
946
+ # followed by non-hex characters inside a string, for example)
947
+ # makes jq bail on the stream with exit 2 and the `select` never
948
+ # runs against ANY record, including legitimate codex.review
949
+ # entries further down the file. The failure is total: every
950
+ # cached codex.review receipt becomes unreachable until the
951
+ # corrupt line is hand-edited out. `-R` flips jq into raw-input
952
+ # mode (one string per line), and `fromjson?` is the error-
953
+ # suppressing parser — malformed lines silently yield empty
954
+ # output. The `select` filter then inspects each successfully
955
+ # parsed record exactly as before, and `grep -q .` detects
956
+ # whether ANY record survived the filter. Lines 1107 and the
957
+ # earlier cache_result scans at :432/:612 operate on a single
958
+ # printf'd JSON string, not audit.jsonl, so they remain `jq -e`.
959
+ if jq -R --arg sha "$local_sha" '
960
+ fromjson?
961
+ | select(
962
+ .tool_name == "codex.review"
963
+ and .metadata.head_sha == $sha
964
+ and (.metadata.verdict == "pass" or .metadata.verdict == "concerns")
965
+ and (.emission_source == "rea-cli" or .emission_source == "codex-cli")
966
+ )
967
+ ' "$_audit" 2>/dev/null | grep -q .; then
877
968
  _codex_ok=1
878
969
  fi
879
970
  fi
@@ -918,7 +1009,12 @@ pr_core_run() {
918
1009
  if [[ -z "$SOURCE_SHA" ]] || [[ "$count" -gt "$BEST_COUNT" ]]; then
919
1010
  SOURCE_SHA="$local_sha"
920
1011
  MERGE_BASE="$mb"
921
- TARGET_BRANCH="$target"
1012
+ # Defect N: use `resolved_base` (the actual merge-base anchor we
1013
+ # diffed against), not `target` (the pushed-ref name). For tracked
1014
+ # branches these are the same; for new branches without an upstream
1015
+ # the distinction is the difference between "Target: <source-branch>"
1016
+ # (misleading) and "Target: main" (or whichever base was resolved).
1017
+ TARGET_BRANCH="$resolved_base"
922
1018
  SOURCE_REF="$local_ref"
923
1019
  BEST_COUNT="$count"
924
1020
  fi
@@ -1059,8 +1155,45 @@ pr_core_run() {
1059
1155
  fi
1060
1156
 
1061
1157
  if [[ -n "$PUSH_SHA" ]] && [[ ${#REA_CLI_ARGS[@]} -gt 0 ]]; then
1158
+ # Defect F (rea#75): distinguish cache-miss from cache-error. Prior version
1159
+ # swallowed all non-zero exits and stderr into a silent `{hit:false}`, which
1160
+ # masked Defect A (0.9.2 `node <shim>` SyntaxError) for weeks. Now we
1161
+ # capture stderr + exit code separately and emit a visible WARN with an
1162
+ # actionable filename when the CLI failed.
1062
1163
  local CACHE_RESULT
1063
- CACHE_RESULT=$("${REA_CLI_ARGS[@]}" cache check "$PUSH_SHA" --branch "$SOURCE_BRANCH" --base "$TARGET_BRANCH" 2>/dev/null || echo '{"hit":false}')
1164
+ local CACHE_STDOUT=""
1165
+ local CACHE_STDERR_FILE
1166
+ # SECURITY (Codex LOW 4): require mktemp. A predictable /tmp path like
1167
+ # /tmp/rea-cache-err.$PID is a TOCTOU attack surface on shared hosts —
1168
+ # another user can pre-create a symlink from that name to a file they
1169
+ # want us to clobber. If mktemp is unavailable, fail loudly rather than
1170
+ # silently falling back to a predictable path.
1171
+ if ! CACHE_STDERR_FILE=$(mktemp -t rea-cache-err.XXXXXX 2>/dev/null); then
1172
+ printf 'rea push-review: mktemp unavailable; cannot capture cache-check stderr. Aborting.\n' >&2
1173
+ return 2
1174
+ fi
1175
+ local CACHE_EXIT=0
1176
+ CACHE_STDOUT=$("${REA_CLI_ARGS[@]}" cache check "$PUSH_SHA" --branch "$SOURCE_BRANCH" --base "$TARGET_BRANCH" 2>"$CACHE_STDERR_FILE") || CACHE_EXIT=$?
1177
+ local CACHE_STDERR=""
1178
+ CACHE_STDERR=$(cat "$CACHE_STDERR_FILE" 2>/dev/null || true)
1179
+ rm -f "$CACHE_STDERR_FILE"
1180
+ if [[ "$CACHE_EXIT" -ne 0 ]]; then
1181
+ # SECURITY (Codex LOW 5): strip C0/C1 control characters from CLI
1182
+ # stderr before echoing to the terminal. A tampered dist/ or hostile
1183
+ # CLI could otherwise emit OSC/CSI sequences that rewrite lines above
1184
+ # the deny message and mislead the operator. We strip both C0 + DEL
1185
+ # AND C1 (0x80-0x9F) — some terminal emulators still honor bare C1
1186
+ # bytes as CSI introducers (0x9B) or OSC (0x9D).
1187
+ local CACHE_STDERR_SAFE
1188
+ CACHE_STDERR_SAFE=$(printf '%s' "$CACHE_STDERR" | LC_ALL=C tr -d '\000-\037\177\200-\237')
1189
+ printf 'rea push-review: CACHE CHECK FAILED (exit=%d): %s\n' "$CACHE_EXIT" "$CACHE_STDERR_SAFE" >&2
1190
+ printf 'rea push-review: treating as miss; file bookedsolidtech/rea issue if unexpected.\n' >&2
1191
+ CACHE_RESULT='{"hit":false,"reason":"query_error"}'
1192
+ elif [[ -z "$CACHE_STDOUT" ]]; then
1193
+ CACHE_RESULT='{"hit":false,"reason":"cold"}'
1194
+ else
1195
+ CACHE_RESULT="$CACHE_STDOUT"
1196
+ fi
1064
1197
  # Require BOTH hit == true AND result == "pass". A cached `fail` verdict
1065
1198
  # (Codex 0.8.0 pass-2 finding #1) must NOT satisfy the gate — cache.ts
1066
1199
  # serializes `result` verbatim, so a negative verdict would otherwise
@@ -242,7 +242,31 @@ if [[ -n "$STAGED_SHA" ]]; then
242
242
  # predicate at push-review-core.sh §8; the §218-226 direct-cache fallback
243
243
  # already enforces `result == "pass"`, so the two paths must agree.
244
244
  if [[ ${#REA_CLI_ARGS[@]} -gt 0 ]]; then
245
- CACHE_RESULT=$("${REA_CLI_ARGS[@]}" cache check "$STAGED_SHA" --branch "$BRANCH" --base "$BASE_BRANCH" 2>/dev/null || echo '{"hit":false}')
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
246
270
  if printf '%s' "$CACHE_RESULT" | jq -e '.hit == true and .result == "pass"' >/dev/null 2>&1; then
247
271
  CACHE_HIT=true
248
272
  fi