@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.
- package/README.md +105 -0
- package/THREAT_MODEL.md +19 -1
- package/dist/audit/append.d.ts +35 -1
- package/dist/audit/append.js +79 -11
- package/dist/cli/audit.d.ts +31 -0
- package/dist/cli/audit.js +197 -30
- package/dist/cli/cache.d.ts +33 -1
- package/dist/cli/cache.js +40 -2
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/index.js +58 -2
- package/dist/cli/tofu.d.ts +57 -0
- package/dist/cli/tofu.js +134 -0
- package/dist/config/tier-map.d.ts +1 -0
- package/dist/config/tier-map.js +210 -0
- package/dist/gateway/audit/rotator.js +4 -0
- package/dist/gateway/middleware/audit-types.d.ts +35 -0
- package/dist/gateway/middleware/audit.js +6 -0
- package/dist/gateway/middleware/blocked-paths.js +38 -0
- package/dist/gateway/middleware/policy.js +68 -3
- package/dist/registry/tofu-gate.js +4 -1
- package/hooks/_lib/push-review-core.sh +159 -26
- package/hooks/commit-review-gate.sh +25 -1
- package/hooks/settings-protection.sh +297 -64
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|