@bookedsolid/rea 0.10.3 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-push +22 -167
- package/agents/codex-adversarial.md +5 -3
- package/commands/codex-review.md +3 -5
- package/dist/audit/append.d.ts +7 -32
- package/dist/audit/append.js +7 -35
- package/dist/cli/audit.d.ts +0 -31
- package/dist/cli/audit.js +5 -74
- package/dist/cli/doctor.js +6 -16
- package/dist/cli/hook.d.ts +48 -0
- package/dist/cli/hook.js +127 -0
- package/dist/cli/index.js +5 -80
- package/dist/cli/init.js +1 -1
- package/dist/cli/install/gitignore.d.ts +2 -2
- package/dist/cli/install/gitignore.js +3 -3
- package/dist/cli/install/pre-push.d.ts +146 -271
- package/dist/cli/install/pre-push.js +471 -2633
- package/dist/cli/install/settings-merge.d.ts +17 -0
- package/dist/cli/install/settings-merge.js +48 -1
- package/dist/cli/upgrade.js +131 -3
- package/dist/config/tier-map.js +18 -25
- package/dist/hooks/push-gate/base.d.ts +57 -0
- package/dist/hooks/push-gate/base.js +77 -0
- package/dist/hooks/push-gate/codex-runner.d.ts +126 -0
- package/dist/hooks/push-gate/codex-runner.js +223 -0
- package/dist/hooks/push-gate/findings.d.ts +68 -0
- package/dist/hooks/push-gate/findings.js +142 -0
- package/dist/hooks/push-gate/halt.d.ts +28 -0
- package/dist/hooks/push-gate/halt.js +49 -0
- package/dist/hooks/push-gate/index.d.ts +90 -0
- package/dist/hooks/push-gate/index.js +351 -0
- package/dist/hooks/push-gate/policy.d.ts +41 -0
- package/dist/hooks/push-gate/policy.js +55 -0
- package/dist/hooks/push-gate/report.d.ts +89 -0
- package/dist/hooks/push-gate/report.js +140 -0
- package/dist/policy/loader.d.ts +10 -10
- package/dist/policy/loader.js +7 -6
- package/dist/policy/types.d.ts +31 -22
- package/package.json +1 -1
- package/dist/cache/review-cache.d.ts +0 -115
- package/dist/cache/review-cache.js +0 -200
- package/dist/cli/cache.d.ts +0 -84
- package/dist/cli/cache.js +0 -150
- package/dist/hooks/review-gate/args.d.ts +0 -126
- package/dist/hooks/review-gate/args.js +0 -315
- package/dist/hooks/review-gate/audit.d.ts +0 -131
- package/dist/hooks/review-gate/audit.js +0 -181
- package/dist/hooks/review-gate/banner.d.ts +0 -97
- package/dist/hooks/review-gate/banner.js +0 -172
- package/dist/hooks/review-gate/base-resolve.d.ts +0 -155
- package/dist/hooks/review-gate/base-resolve.js +0 -247
- package/dist/hooks/review-gate/cache-key.d.ts +0 -55
- package/dist/hooks/review-gate/cache-key.js +0 -41
- package/dist/hooks/review-gate/cache.d.ts +0 -108
- package/dist/hooks/review-gate/cache.js +0 -120
- package/dist/hooks/review-gate/constants.d.ts +0 -26
- package/dist/hooks/review-gate/constants.js +0 -34
- package/dist/hooks/review-gate/diff.d.ts +0 -181
- package/dist/hooks/review-gate/diff.js +0 -232
- package/dist/hooks/review-gate/errors.d.ts +0 -72
- package/dist/hooks/review-gate/errors.js +0 -100
- package/dist/hooks/review-gate/hash.d.ts +0 -43
- package/dist/hooks/review-gate/hash.js +0 -46
- package/dist/hooks/review-gate/index.d.ts +0 -31
- package/dist/hooks/review-gate/index.js +0 -35
- package/dist/hooks/review-gate/metadata.d.ts +0 -98
- package/dist/hooks/review-gate/metadata.js +0 -158
- package/dist/hooks/review-gate/policy.d.ts +0 -55
- package/dist/hooks/review-gate/policy.js +0 -71
- package/dist/hooks/review-gate/protected-paths.d.ts +0 -46
- package/dist/hooks/review-gate/protected-paths.js +0 -76
- package/hooks/_lib/push-review-core.sh +0 -1250
- package/hooks/commit-review-gate.sh +0 -330
- package/hooks/push-review-gate-git.sh +0 -94
- package/hooks/push-review-gate.sh +0 -92
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push-gate composition — the pure orchestrator that `rea hook push-gate`
|
|
3
|
+
* calls.
|
|
4
|
+
*
|
|
5
|
+
* Contract: `runPushGate(deps)` returns a `GateResult` with an `exitCode`
|
|
6
|
+
* the CLI wrapper hands back to `git`. Exit codes:
|
|
7
|
+
*
|
|
8
|
+
* - `0` push proceeds (pass, disabled, skipped, empty-diff)
|
|
9
|
+
* - `1` HALT kill-switch active — rea unfreeze required
|
|
10
|
+
* - `2` blocked — blocking verdict, timeout, or protocol error
|
|
11
|
+
*
|
|
12
|
+
* The happy path is a single call: resolve policy → resolve base → spawn
|
|
13
|
+
* codex exec review → parse findings → write last-review.json → emit audit
|
|
14
|
+
* record → return exit code. No cache lookups, no SHA matching, no
|
|
15
|
+
* attestation gymnastics. Every push runs codex afresh; Codex is the
|
|
16
|
+
* source of truth.
|
|
17
|
+
*
|
|
18
|
+
* The function is pure-compositional: every external dependency (git,
|
|
19
|
+
* codex, halt, policy) is injected via `PushGateDeps`, which is the
|
|
20
|
+
* affordance tests use to replace subprocess calls with deterministic
|
|
21
|
+
* fakes. `runPushGate` never reaches for `process.env` or `process.cwd`
|
|
22
|
+
* directly — `deps.env` and `deps.baseDir` are the only ambient state.
|
|
23
|
+
*/
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { appendAuditRecord } from '../../audit/append.js';
|
|
26
|
+
import { Tier, InvocationStatus } from '../../policy/types.js';
|
|
27
|
+
import { resolvePushGatePolicy, } from './policy.js';
|
|
28
|
+
import { readHalt } from './halt.js';
|
|
29
|
+
import { resolveBaseRef } from './base.js';
|
|
30
|
+
import { createRealGitExecutor, runCodexReview, CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, } from './codex-runner.js';
|
|
31
|
+
import { summarizeReview } from './findings.js';
|
|
32
|
+
import { renderBanner, writeLastReview } from './report.js';
|
|
33
|
+
/**
|
|
34
|
+
* Parse the raw pre-push stdin text into refspecs. Each line is four
|
|
35
|
+
* whitespace-separated fields. Blank lines and malformed lines are
|
|
36
|
+
* silently dropped — the empty result then falls through to the
|
|
37
|
+
* upstream-resolver path in `runPushGate`.
|
|
38
|
+
*/
|
|
39
|
+
export function parsePrePushStdin(raw) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
42
|
+
const trimmed = line.trim();
|
|
43
|
+
if (trimmed.length === 0)
|
|
44
|
+
continue;
|
|
45
|
+
const fields = trimmed.split(/\s+/);
|
|
46
|
+
if (fields.length !== 4)
|
|
47
|
+
continue;
|
|
48
|
+
const [localRef, localSha, remoteRef, remoteSha] = fields;
|
|
49
|
+
if (typeof localRef !== 'string' ||
|
|
50
|
+
typeof localSha !== 'string' ||
|
|
51
|
+
typeof remoteRef !== 'string' ||
|
|
52
|
+
typeof remoteSha !== 'string') {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
out.push({ localRef, localSha, remoteRef, remoteSha });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Well-known "null SHA" in git's wire format. Pre-push sends this as
|
|
61
|
+
* `remote_sha` for a fresh remote ref (the branch doesn't exist yet on
|
|
62
|
+
* the remote) and as `local_sha` for a branch deletion.
|
|
63
|
+
*/
|
|
64
|
+
const NULL_SHA = '0000000000000000000000000000000000000000';
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Audit event names (advisory — no gate ever reads these back)
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
const AUDIT_SERVER_NAME = 'rea';
|
|
69
|
+
const EVT_REVIEWED = 'rea.push_gate.reviewed';
|
|
70
|
+
const EVT_HALTED = 'rea.push_gate.halted';
|
|
71
|
+
const EVT_DISABLED = 'rea.push_gate.disabled';
|
|
72
|
+
const EVT_SKIPPED = 'rea.push_gate.skipped';
|
|
73
|
+
const EVT_EMPTY = 'rea.push_gate.empty_diff';
|
|
74
|
+
const EVT_ERROR = 'rea.push_gate.error';
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Composer
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
export async function runPushGate(deps) {
|
|
79
|
+
const stderr = deps.stderr;
|
|
80
|
+
const env = deps.env;
|
|
81
|
+
const readHaltFn = deps.readHalt ?? readHalt;
|
|
82
|
+
const resolvePolicyFn = deps.resolvePolicy ?? resolvePushGatePolicy;
|
|
83
|
+
const writeLastReviewFn = deps.writeLastReview ?? writeLastReview;
|
|
84
|
+
const runCodexFn = deps.runCodex ?? runCodexReview;
|
|
85
|
+
const appendAuditFn = deps.appendAudit ?? appendAuditRecord;
|
|
86
|
+
const git = deps.git ?? createRealGitExecutor(deps.baseDir);
|
|
87
|
+
// 1. HALT wins over everything, including `review.codex_required: false`.
|
|
88
|
+
// Reading it before policy also means a corrupted policy.yaml doesn't
|
|
89
|
+
// prevent the kill-switch from firing.
|
|
90
|
+
const halt = readHaltFn(deps.baseDir);
|
|
91
|
+
if (halt.halted) {
|
|
92
|
+
stderr(`REA HALT: ${halt.reason ?? 'unknown'}\nAll push operations suspended. Run: rea unfreeze\n`);
|
|
93
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_HALTED, {
|
|
94
|
+
reason: halt.reason ?? 'unknown',
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
status: 'halted',
|
|
98
|
+
exitCode: 1,
|
|
99
|
+
summary: `HALT active: ${halt.reason ?? 'unknown'}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// 2. Load policy. A malformed policy.yaml surfaces as a thrown zod error;
|
|
103
|
+
// we catch it, audit, and exit 2 rather than silently bypass.
|
|
104
|
+
let policy;
|
|
105
|
+
try {
|
|
106
|
+
policy = await resolvePolicyFn(deps.baseDir);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
110
|
+
stderr(`PUSH BLOCKED: failed to load .rea/policy.yaml — ${msg}\n`);
|
|
111
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, {
|
|
112
|
+
kind: 'policy-load',
|
|
113
|
+
error: msg,
|
|
114
|
+
});
|
|
115
|
+
return { status: 'error', exitCode: 2, summary: `policy-load error: ${msg}` };
|
|
116
|
+
}
|
|
117
|
+
if (!policy.codex_required) {
|
|
118
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_DISABLED, {
|
|
119
|
+
policy_missing: policy.policyMissing,
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
status: 'disabled',
|
|
123
|
+
exitCode: 0,
|
|
124
|
+
summary: 'review.codex_required is false — push-gate skipped',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// 3. REA_SKIP_PUSH_GATE — value-carrying waiver. HALT-wins ordering means
|
|
128
|
+
// this is checked AFTER halt (step 1) and AFTER codex_required=false
|
|
129
|
+
// short-circuit (step 2). Both of those should hold anyway; this is
|
|
130
|
+
// for the case where codex is required but the operator wants to
|
|
131
|
+
// skip for a narrow, documented reason.
|
|
132
|
+
const skipReason = (env.REA_SKIP_PUSH_GATE ?? '').trim();
|
|
133
|
+
if (skipReason.length > 0) {
|
|
134
|
+
stderr(`rea: REA_SKIP_PUSH_GATE=${skipReason} — push-gate skipped (audited).\n`);
|
|
135
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_SKIPPED, {
|
|
136
|
+
reason: skipReason,
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
status: 'skipped',
|
|
140
|
+
exitCode: 0,
|
|
141
|
+
summary: `REA_SKIP_PUSH_GATE waiver: ${skipReason}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// 4. Resolve (base_ref, head_sha) for the actual review.
|
|
145
|
+
//
|
|
146
|
+
// When pre-push stdin yielded at least one refspec (`git push` path),
|
|
147
|
+
// diff against the first NON-DELETION refspec's (remote_sha..local_sha).
|
|
148
|
+
// This matches what git itself is about to push — critical when the
|
|
149
|
+
// operator uses `git push origin HEAD:release/1.0` and the branch's
|
|
150
|
+
// tracking ref is a different branch entirely (the 0.10.x gate
|
|
151
|
+
// silently reviewed against the wrong base in that case).
|
|
152
|
+
//
|
|
153
|
+
// When stdin was empty (manual invocation, test), fall back to the
|
|
154
|
+
// upstream → origin/HEAD → main/master ladder.
|
|
155
|
+
const activeRefspec = (deps.refspecs ?? []).find((r) => r.localSha !== NULL_SHA && r.localSha.length > 0);
|
|
156
|
+
let base;
|
|
157
|
+
let headSha;
|
|
158
|
+
if (activeRefspec !== undefined && (deps.explicitBase === undefined || deps.explicitBase.length === 0)) {
|
|
159
|
+
headSha = activeRefspec.localSha;
|
|
160
|
+
if (activeRefspec.remoteSha === NULL_SHA || activeRefspec.remoteSha.length === 0) {
|
|
161
|
+
// New remote ref — no existing commits to diff against. Fall back to
|
|
162
|
+
// the resolver ladder so we still get a meaningful review (e.g. vs
|
|
163
|
+
// origin/main) rather than an empty-tree diff of everything.
|
|
164
|
+
base = resolveBaseRef(git);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
base = { ref: activeRefspec.remoteSha, source: 'explicit' };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
base = resolveBaseRef(git, {
|
|
172
|
+
...(deps.explicitBase !== undefined && deps.explicitBase.length > 0
|
|
173
|
+
? { explicit: deps.explicitBase }
|
|
174
|
+
: {}),
|
|
175
|
+
});
|
|
176
|
+
headSha = git.headSha();
|
|
177
|
+
}
|
|
178
|
+
if (headSha.length === 0) {
|
|
179
|
+
stderr('PUSH BLOCKED: could not resolve HEAD SHA. Is this a valid git repo?\n');
|
|
180
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, { kind: 'head-sha-missing' });
|
|
181
|
+
return { status: 'error', exitCode: 2, summary: 'head-sha-missing' };
|
|
182
|
+
}
|
|
183
|
+
// 5. Empty-diff short-circuit. An initial push against the empty-tree
|
|
184
|
+
// sentinel ALWAYS has a non-empty diff (HEAD vs empty tree); this
|
|
185
|
+
// short-circuit only fires when the feature branch really is a
|
|
186
|
+
// no-op relative to base.
|
|
187
|
+
const diff = git.diffNames(base.ref, headSha);
|
|
188
|
+
if (diff.length === 0) {
|
|
189
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_EMPTY, {
|
|
190
|
+
base_ref: base.ref,
|
|
191
|
+
base_source: base.source,
|
|
192
|
+
head_sha: headSha,
|
|
193
|
+
});
|
|
194
|
+
return {
|
|
195
|
+
status: 'empty-diff',
|
|
196
|
+
exitCode: 0,
|
|
197
|
+
summary: 'empty diff — nothing to review',
|
|
198
|
+
baseRef: base.ref,
|
|
199
|
+
headSha,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// 6. Run Codex. Typed errors translate to exit 2 with distinct stderr.
|
|
203
|
+
try {
|
|
204
|
+
const codexResult = await runCodexFn({
|
|
205
|
+
baseRef: base.ref,
|
|
206
|
+
cwd: deps.baseDir,
|
|
207
|
+
timeoutMs: policy.timeout_ms,
|
|
208
|
+
env,
|
|
209
|
+
});
|
|
210
|
+
const summary = summarizeReview(codexResult.reviewText);
|
|
211
|
+
const blocked = summary.verdict === 'blocking'
|
|
212
|
+
|| (summary.verdict === 'concerns'
|
|
213
|
+
&& policy.concerns_blocks
|
|
214
|
+
&& !isConcernsOverrideSet(env));
|
|
215
|
+
const lastReviewPath = path.join(deps.baseDir, '.rea', 'last-review.json');
|
|
216
|
+
const payload = writeLastReviewFn({
|
|
217
|
+
baseDir: deps.baseDir,
|
|
218
|
+
summary,
|
|
219
|
+
baseRef: base.ref,
|
|
220
|
+
headSha,
|
|
221
|
+
eventCount: codexResult.eventCount,
|
|
222
|
+
durationSeconds: codexResult.durationSeconds,
|
|
223
|
+
...(deps.now !== undefined ? { now: deps.now() } : {}),
|
|
224
|
+
});
|
|
225
|
+
stderr(renderBanner({
|
|
226
|
+
payload,
|
|
227
|
+
baseSource: base.source,
|
|
228
|
+
blocked,
|
|
229
|
+
lastReviewPath,
|
|
230
|
+
}));
|
|
231
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, {
|
|
232
|
+
verdict: summary.verdict,
|
|
233
|
+
finding_count: summary.findings.length,
|
|
234
|
+
base_ref: base.ref,
|
|
235
|
+
base_source: base.source,
|
|
236
|
+
head_sha: headSha,
|
|
237
|
+
blocked,
|
|
238
|
+
duration_seconds: codexResult.durationSeconds,
|
|
239
|
+
event_count: codexResult.eventCount,
|
|
240
|
+
concerns_override: summary.verdict === 'concerns' && isConcernsOverrideSet(env) ? true : undefined,
|
|
241
|
+
});
|
|
242
|
+
if (blocked) {
|
|
243
|
+
return {
|
|
244
|
+
status: summary.verdict === 'blocking' ? 'blocking' : 'concerns',
|
|
245
|
+
exitCode: 2,
|
|
246
|
+
summary: `${summary.verdict}: ${summary.findings.length} finding(s)`,
|
|
247
|
+
verdict: summary.verdict,
|
|
248
|
+
findingCount: summary.findings.length,
|
|
249
|
+
baseRef: base.ref,
|
|
250
|
+
headSha,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
status: summary.verdict === 'blocking'
|
|
255
|
+
? 'blocking'
|
|
256
|
+
: summary.verdict === 'concerns'
|
|
257
|
+
? 'concerns'
|
|
258
|
+
: 'pass',
|
|
259
|
+
exitCode: 0,
|
|
260
|
+
summary: `${summary.verdict}: ${summary.findings.length} finding(s)`,
|
|
261
|
+
verdict: summary.verdict,
|
|
262
|
+
findingCount: summary.findings.length,
|
|
263
|
+
baseRef: base.ref,
|
|
264
|
+
headSha,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
return handleCodexError(e, deps, base, headSha, appendAuditFn);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function isConcernsOverrideSet(env) {
|
|
272
|
+
const raw = env.REA_ALLOW_CONCERNS;
|
|
273
|
+
if (raw === undefined)
|
|
274
|
+
return false;
|
|
275
|
+
const normalized = raw.trim().toLowerCase();
|
|
276
|
+
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
|
277
|
+
}
|
|
278
|
+
async function handleCodexError(e, deps, base, headSha, appendAuditFn) {
|
|
279
|
+
const stderr = deps.stderr;
|
|
280
|
+
const runError = classifyCodexError(e);
|
|
281
|
+
const metadata = {
|
|
282
|
+
base_ref: base.ref,
|
|
283
|
+
base_source: base.source,
|
|
284
|
+
head_sha: headSha,
|
|
285
|
+
kind: runError.kind,
|
|
286
|
+
};
|
|
287
|
+
if (runError.message.length > 0)
|
|
288
|
+
metadata.error = runError.message;
|
|
289
|
+
stderr(`PUSH BLOCKED: ${runError.message}\n`);
|
|
290
|
+
await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, metadata);
|
|
291
|
+
return {
|
|
292
|
+
status: 'error',
|
|
293
|
+
exitCode: 2,
|
|
294
|
+
summary: `codex error (${runError.kind}): ${runError.message}`,
|
|
295
|
+
baseRef: base.ref,
|
|
296
|
+
headSha,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function classifyCodexError(e) {
|
|
300
|
+
if (e instanceof CodexNotInstalledError)
|
|
301
|
+
return { kind: 'not-installed', message: e.message };
|
|
302
|
+
if (e instanceof CodexTimeoutError)
|
|
303
|
+
return { kind: 'timeout', message: e.message };
|
|
304
|
+
if (e instanceof CodexProtocolError)
|
|
305
|
+
return { kind: 'protocol', message: e.message };
|
|
306
|
+
if (e instanceof CodexSubprocessError)
|
|
307
|
+
return { kind: 'subprocess', message: e.message };
|
|
308
|
+
if (e instanceof Error)
|
|
309
|
+
return { kind: 'unknown', message: e.message };
|
|
310
|
+
return { kind: 'unknown', message: String(e) };
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Audit-record helper. Never throws — audit failures are themselves audited
|
|
314
|
+
* (best-effort warn to stderr) but must not prevent the gate from returning
|
|
315
|
+
* its primary result. The hash chain remains intact if this succeeds; on
|
|
316
|
+
* failure we've already made the gate decision based on the actual review.
|
|
317
|
+
*/
|
|
318
|
+
async function safeAppend(appendFn, baseDir, toolName, metadata) {
|
|
319
|
+
try {
|
|
320
|
+
// Prune undefined values — the audit record schema's `metadata` is an
|
|
321
|
+
// arbitrary map, but `undefined` values cause JSON.stringify to emit
|
|
322
|
+
// missing keys which breaks round-trips on some readers.
|
|
323
|
+
const cleanMeta = {};
|
|
324
|
+
for (const [k, v] of Object.entries(metadata)) {
|
|
325
|
+
if (v !== undefined)
|
|
326
|
+
cleanMeta[k] = v;
|
|
327
|
+
}
|
|
328
|
+
await appendFn(baseDir, {
|
|
329
|
+
tool_name: toolName,
|
|
330
|
+
server_name: AUDIT_SERVER_NAME,
|
|
331
|
+
tier: Tier.Read,
|
|
332
|
+
status: InvocationStatus.Allowed,
|
|
333
|
+
...(Object.keys(cleanMeta).length > 0 ? { metadata: cleanMeta } : {}),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
catch (e) {
|
|
337
|
+
// Audit persistence failure should never cascade into a push block when
|
|
338
|
+
// the gate itself decided to pass — but we do want operator visibility.
|
|
339
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
340
|
+
// Use the deps.stderr is unavailable here (different stack frame); write
|
|
341
|
+
// directly to process.stderr as a fallback.
|
|
342
|
+
process.stderr.write(`rea: audit append failed (${toolName}): ${msg}\n`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Re-exports for the CLI wrapper so it can construct dependency defaults.
|
|
346
|
+
export { resolvePushGatePolicy } from './policy.js';
|
|
347
|
+
export { readHalt } from './halt.js';
|
|
348
|
+
export { resolveBaseRef } from './base.js';
|
|
349
|
+
export { runCodexReview, createRealGitExecutor } from './codex-runner.js';
|
|
350
|
+
export { summarizeReview, parseFindings, inferVerdict } from './findings.js';
|
|
351
|
+
export { writeLastReview, renderBanner } from './report.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push-gate policy resolution.
|
|
3
|
+
*
|
|
4
|
+
* Loads `.rea/policy.yaml` via the shared loader and flattens the subset the
|
|
5
|
+
* gate cares about into a single `ResolvedReviewPolicy`. Env-var overrides
|
|
6
|
+
* (`REA_SKIP_PUSH_GATE`, `REA_ALLOW_CONCERNS`) are NOT consumed here — the
|
|
7
|
+
* gate composition in `./index.ts` inspects them directly after policy load
|
|
8
|
+
* so the audit trail can distinguish "policy says skip" from "env says
|
|
9
|
+
* skip". This module is pure policy.
|
|
10
|
+
*
|
|
11
|
+
* Defaults (when a field is absent or `review:` is missing entirely):
|
|
12
|
+
* - `codex_required` → `true` (safe-by-default: run Codex)
|
|
13
|
+
* - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
|
|
14
|
+
* - `timeout_ms` → 600_000 (10 minutes)
|
|
15
|
+
*
|
|
16
|
+
* A missing `.rea/policy.yaml` is treated as "defaults apply" — the
|
|
17
|
+
* operator may not have run `rea init` yet, and the gate's behavior
|
|
18
|
+
* should match the most protective stance available. The caller is free
|
|
19
|
+
* to treat `policyMissing: true` as a doctor finding.
|
|
20
|
+
*/
|
|
21
|
+
export interface ResolvedReviewPolicy {
|
|
22
|
+
codex_required: boolean;
|
|
23
|
+
concerns_blocks: boolean;
|
|
24
|
+
timeout_ms: number;
|
|
25
|
+
/** `true` when `.rea/policy.yaml` was absent; defaults apply. */
|
|
26
|
+
policyMissing: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare const PUSH_GATE_DEFAULT_TIMEOUT_MS = 600000;
|
|
29
|
+
export declare const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
|
|
30
|
+
export declare const PUSH_GATE_DEFAULT_CONCERNS_BLOCKS = true;
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the push-gate policy for `baseDir`. Never throws — a malformed
|
|
33
|
+
* policy file surfaces as a typed error via the underlying zod validator,
|
|
34
|
+
* which we re-raise. The gate's `runPushGate()` catches that and returns
|
|
35
|
+
* `{ status: 'error', exitCode: 2 }` rather than silently bypassing.
|
|
36
|
+
*
|
|
37
|
+
* Returning a fully-populated object (no `undefined` knobs) means every
|
|
38
|
+
* downstream module can treat the policy as total — no `?? default` dance
|
|
39
|
+
* at each call site.
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolvePushGatePolicy(baseDir: string): Promise<ResolvedReviewPolicy>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push-gate policy resolution.
|
|
3
|
+
*
|
|
4
|
+
* Loads `.rea/policy.yaml` via the shared loader and flattens the subset the
|
|
5
|
+
* gate cares about into a single `ResolvedReviewPolicy`. Env-var overrides
|
|
6
|
+
* (`REA_SKIP_PUSH_GATE`, `REA_ALLOW_CONCERNS`) are NOT consumed here — the
|
|
7
|
+
* gate composition in `./index.ts` inspects them directly after policy load
|
|
8
|
+
* so the audit trail can distinguish "policy says skip" from "env says
|
|
9
|
+
* skip". This module is pure policy.
|
|
10
|
+
*
|
|
11
|
+
* Defaults (when a field is absent or `review:` is missing entirely):
|
|
12
|
+
* - `codex_required` → `true` (safe-by-default: run Codex)
|
|
13
|
+
* - `concerns_blocks` → `true` (safe-by-default: concerns halt the push)
|
|
14
|
+
* - `timeout_ms` → 600_000 (10 minutes)
|
|
15
|
+
*
|
|
16
|
+
* A missing `.rea/policy.yaml` is treated as "defaults apply" — the
|
|
17
|
+
* operator may not have run `rea init` yet, and the gate's behavior
|
|
18
|
+
* should match the most protective stance available. The caller is free
|
|
19
|
+
* to treat `policyMissing: true` as a doctor finding.
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { loadPolicyAsync } from '../../policy/loader.js';
|
|
24
|
+
export const PUSH_GATE_DEFAULT_TIMEOUT_MS = 600_000;
|
|
25
|
+
export const PUSH_GATE_DEFAULT_CODEX_REQUIRED = true;
|
|
26
|
+
export const PUSH_GATE_DEFAULT_CONCERNS_BLOCKS = true;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the push-gate policy for `baseDir`. Never throws — a malformed
|
|
29
|
+
* policy file surfaces as a typed error via the underlying zod validator,
|
|
30
|
+
* which we re-raise. The gate's `runPushGate()` catches that and returns
|
|
31
|
+
* `{ status: 'error', exitCode: 2 }` rather than silently bypassing.
|
|
32
|
+
*
|
|
33
|
+
* Returning a fully-populated object (no `undefined` knobs) means every
|
|
34
|
+
* downstream module can treat the policy as total — no `?? default` dance
|
|
35
|
+
* at each call site.
|
|
36
|
+
*/
|
|
37
|
+
export async function resolvePushGatePolicy(baseDir) {
|
|
38
|
+
const policyPath = path.join(baseDir, '.rea', 'policy.yaml');
|
|
39
|
+
if (!fs.existsSync(policyPath)) {
|
|
40
|
+
return {
|
|
41
|
+
codex_required: PUSH_GATE_DEFAULT_CODEX_REQUIRED,
|
|
42
|
+
concerns_blocks: PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
|
|
43
|
+
timeout_ms: PUSH_GATE_DEFAULT_TIMEOUT_MS,
|
|
44
|
+
policyMissing: true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const policy = await loadPolicyAsync(baseDir);
|
|
48
|
+
const review = policy.review ?? {};
|
|
49
|
+
return {
|
|
50
|
+
codex_required: review.codex_required ?? PUSH_GATE_DEFAULT_CODEX_REQUIRED,
|
|
51
|
+
concerns_blocks: review.concerns_blocks ?? PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
|
|
52
|
+
timeout_ms: review.timeout_ms ?? PUSH_GATE_DEFAULT_TIMEOUT_MS,
|
|
53
|
+
policyMissing: false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report output for the push-gate.
|
|
3
|
+
*
|
|
4
|
+
* Two channels:
|
|
5
|
+
*
|
|
6
|
+
* 1. `.rea/last-review.json` — machine-readable structured dump. Atomic
|
|
7
|
+
* write (write-to-tmp + rename), gitignored, overwritten every push.
|
|
8
|
+
* Claude reads this as the source of truth for file/line/body during
|
|
9
|
+
* the auto-fix loop.
|
|
10
|
+
*
|
|
11
|
+
* 2. stderr banner — human-legible severity-sorted summary capped to 20
|
|
12
|
+
* findings. The pre-push hook's stderr reaches Claude as the tool
|
|
13
|
+
* output of `Bash(git push)`, so this is the primary fast-path to
|
|
14
|
+
* surface verdict + first blocking finding.
|
|
15
|
+
*
|
|
16
|
+
* Redaction: before serializing anything to disk or stderr we run the
|
|
17
|
+
* shared `SECRET_PATTERNS` list over `title`, `body`, and `reviewText`. If
|
|
18
|
+
* Codex accidentally quoted a secret from the diff (common in password-
|
|
19
|
+
* reset flows, API-key migration PRs, env-file edits) it never hits disk
|
|
20
|
+
* in cleartext.
|
|
21
|
+
*/
|
|
22
|
+
import type { Finding, ReviewSummary, Verdict } from './findings.js';
|
|
23
|
+
export interface LastReviewPayload {
|
|
24
|
+
schema_version: 1;
|
|
25
|
+
/** ISO-8601 UTC timestamp of the review run (wall clock). */
|
|
26
|
+
generated_at: string;
|
|
27
|
+
verdict: Verdict;
|
|
28
|
+
base_ref: string;
|
|
29
|
+
head_sha: string;
|
|
30
|
+
finding_count: number;
|
|
31
|
+
findings: Finding[];
|
|
32
|
+
/** Full agent text (post-redact). Useful for debugging parser misses. */
|
|
33
|
+
review_text: string;
|
|
34
|
+
/** Number of raw JSONL events Codex emitted. */
|
|
35
|
+
event_count: number;
|
|
36
|
+
/** Wall clock seconds in the Codex subprocess. */
|
|
37
|
+
duration_seconds: number;
|
|
38
|
+
}
|
|
39
|
+
export interface WriteLastReviewInput {
|
|
40
|
+
baseDir: string;
|
|
41
|
+
summary: ReviewSummary;
|
|
42
|
+
baseRef: string;
|
|
43
|
+
headSha: string;
|
|
44
|
+
eventCount: number;
|
|
45
|
+
durationSeconds: number;
|
|
46
|
+
/** Test seam — defaults to `new Date()`. */
|
|
47
|
+
now?: Date;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Atomic write of `.rea/last-review.json`. Returns the redacted payload
|
|
51
|
+
* actually written so the caller can reuse it for stderr rendering
|
|
52
|
+
* without re-redacting.
|
|
53
|
+
*
|
|
54
|
+
* We write to `last-review.json.tmp.<pid>-<rand>` first, fsync the file
|
|
55
|
+
* descriptor, then rename. rename(2) is atomic within the same
|
|
56
|
+
* filesystem, so partial writes never surface to readers.
|
|
57
|
+
*/
|
|
58
|
+
export declare function writeLastReview(input: WriteLastReviewInput): LastReviewPayload;
|
|
59
|
+
export interface RenderBannerInput {
|
|
60
|
+
payload: LastReviewPayload;
|
|
61
|
+
/** Where was the base ref sourced from (audit / debugging). */
|
|
62
|
+
baseSource: string;
|
|
63
|
+
/**
|
|
64
|
+
* Whether the verdict-level action is BLOCKED or SOFT. Surfaced in the
|
|
65
|
+
* banner first line. Callers infer this from verdict + concerns_blocks.
|
|
66
|
+
*/
|
|
67
|
+
blocked: boolean;
|
|
68
|
+
/** Last-review.json on-disk path — shown as a pointer. */
|
|
69
|
+
lastReviewPath: string;
|
|
70
|
+
/** Max findings to enumerate in the banner. Default 20. */
|
|
71
|
+
maxFindings?: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Build the stderr banner as a single multi-line string. Ends with `\n`.
|
|
75
|
+
*
|
|
76
|
+
* Layout:
|
|
77
|
+
*
|
|
78
|
+
* ┌────────────────────────────────────────────┐
|
|
79
|
+
* │ push-gate VERDICT — BLOCKED / PROCEEDING │
|
|
80
|
+
* │ base: <ref> (<source>) │
|
|
81
|
+
* │ head: <sha> │
|
|
82
|
+
* │ findings: <count> │
|
|
83
|
+
* └────────────────────────────────────────────┘
|
|
84
|
+
* - [P1] Title — file:42
|
|
85
|
+
* body-excerpt
|
|
86
|
+
* ...
|
|
87
|
+
* see .rea/last-review.json for full details
|
|
88
|
+
*/
|
|
89
|
+
export declare function renderBanner(input: RenderBannerInput): string;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report output for the push-gate.
|
|
3
|
+
*
|
|
4
|
+
* Two channels:
|
|
5
|
+
*
|
|
6
|
+
* 1. `.rea/last-review.json` — machine-readable structured dump. Atomic
|
|
7
|
+
* write (write-to-tmp + rename), gitignored, overwritten every push.
|
|
8
|
+
* Claude reads this as the source of truth for file/line/body during
|
|
9
|
+
* the auto-fix loop.
|
|
10
|
+
*
|
|
11
|
+
* 2. stderr banner — human-legible severity-sorted summary capped to 20
|
|
12
|
+
* findings. The pre-push hook's stderr reaches Claude as the tool
|
|
13
|
+
* output of `Bash(git push)`, so this is the primary fast-path to
|
|
14
|
+
* surface verdict + first blocking finding.
|
|
15
|
+
*
|
|
16
|
+
* Redaction: before serializing anything to disk or stderr we run the
|
|
17
|
+
* shared `SECRET_PATTERNS` list over `title`, `body`, and `reviewText`. If
|
|
18
|
+
* Codex accidentally quoted a secret from the diff (common in password-
|
|
19
|
+
* reset flows, API-key migration PRs, env-file edits) it never hits disk
|
|
20
|
+
* in cleartext.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { randomBytes } from 'node:crypto';
|
|
25
|
+
import { compileDefaultSecretPatterns, redactSecrets, } from '../../gateway/middleware/redact.js';
|
|
26
|
+
const LAST_REVIEW_FILENAME = 'last-review.json';
|
|
27
|
+
/**
|
|
28
|
+
* Atomic write of `.rea/last-review.json`. Returns the redacted payload
|
|
29
|
+
* actually written so the caller can reuse it for stderr rendering
|
|
30
|
+
* without re-redacting.
|
|
31
|
+
*
|
|
32
|
+
* We write to `last-review.json.tmp.<pid>-<rand>` first, fsync the file
|
|
33
|
+
* descriptor, then rename. rename(2) is atomic within the same
|
|
34
|
+
* filesystem, so partial writes never surface to readers.
|
|
35
|
+
*/
|
|
36
|
+
export function writeLastReview(input) {
|
|
37
|
+
const { baseDir, summary, baseRef, headSha, eventCount, durationSeconds } = input;
|
|
38
|
+
const now = input.now ?? new Date();
|
|
39
|
+
const patterns = compileDefaultSecretPatterns({ source: 'default' });
|
|
40
|
+
const payload = {
|
|
41
|
+
schema_version: 1,
|
|
42
|
+
generated_at: now.toISOString(),
|
|
43
|
+
verdict: summary.verdict,
|
|
44
|
+
base_ref: baseRef,
|
|
45
|
+
head_sha: headSha,
|
|
46
|
+
finding_count: summary.findings.length,
|
|
47
|
+
findings: summary.findings.map((f) => redactFinding(f, patterns)),
|
|
48
|
+
review_text: redactString(summary.reviewText, patterns),
|
|
49
|
+
event_count: eventCount,
|
|
50
|
+
duration_seconds: Number.isFinite(durationSeconds) ? durationSeconds : 0,
|
|
51
|
+
};
|
|
52
|
+
const reaDir = path.join(baseDir, '.rea');
|
|
53
|
+
ensureDir(reaDir);
|
|
54
|
+
const finalPath = path.join(reaDir, LAST_REVIEW_FILENAME);
|
|
55
|
+
const tmpPath = `${finalPath}.tmp.${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
56
|
+
const fd = fs.openSync(tmpPath, 'w', 0o600);
|
|
57
|
+
try {
|
|
58
|
+
fs.writeFileSync(fd, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf8' });
|
|
59
|
+
fs.fsyncSync(fd);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
fs.closeSync(fd);
|
|
63
|
+
}
|
|
64
|
+
fs.renameSync(tmpPath, finalPath);
|
|
65
|
+
return payload;
|
|
66
|
+
}
|
|
67
|
+
function redactFinding(f, patterns) {
|
|
68
|
+
return {
|
|
69
|
+
severity: f.severity,
|
|
70
|
+
title: redactString(f.title, patterns),
|
|
71
|
+
body: redactString(f.body, patterns),
|
|
72
|
+
...(f.file !== undefined ? { file: f.file } : {}),
|
|
73
|
+
...(f.line !== undefined ? { line: f.line } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function redactString(s, patterns) {
|
|
77
|
+
const { output } = redactSecrets(s, patterns);
|
|
78
|
+
return output;
|
|
79
|
+
}
|
|
80
|
+
function ensureDir(dir) {
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
// mkdir -p is idempotent; EEXIST is fine, anything else surfaces to
|
|
86
|
+
// the caller and becomes an exit-2 error.
|
|
87
|
+
const code = e.code;
|
|
88
|
+
if (code !== 'EEXIST')
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const SEVERITY_ORDER = { P1: 0, P2: 1, P3: 2 };
|
|
93
|
+
/**
|
|
94
|
+
* Build the stderr banner as a single multi-line string. Ends with `\n`.
|
|
95
|
+
*
|
|
96
|
+
* Layout:
|
|
97
|
+
*
|
|
98
|
+
* ┌────────────────────────────────────────────┐
|
|
99
|
+
* │ push-gate VERDICT — BLOCKED / PROCEEDING │
|
|
100
|
+
* │ base: <ref> (<source>) │
|
|
101
|
+
* │ head: <sha> │
|
|
102
|
+
* │ findings: <count> │
|
|
103
|
+
* └────────────────────────────────────────────┘
|
|
104
|
+
* - [P1] Title — file:42
|
|
105
|
+
* body-excerpt
|
|
106
|
+
* ...
|
|
107
|
+
* see .rea/last-review.json for full details
|
|
108
|
+
*/
|
|
109
|
+
export function renderBanner(input) {
|
|
110
|
+
const { payload, baseSource, blocked, lastReviewPath } = input;
|
|
111
|
+
const max = input.maxFindings ?? 20;
|
|
112
|
+
const verdictLabel = blocked ? 'BLOCKED' : 'PROCEEDING';
|
|
113
|
+
const lines = [];
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
116
|
+
lines.push(`rea push-gate: ${payload.verdict.toUpperCase()} — ${verdictLabel}`);
|
|
117
|
+
lines.push(`base: ${payload.base_ref} (${baseSource})`);
|
|
118
|
+
lines.push(`head: ${payload.head_sha}`);
|
|
119
|
+
lines.push(`findings: ${payload.finding_count}`);
|
|
120
|
+
lines.push(`elapsed: ${payload.duration_seconds.toFixed(1)}s`);
|
|
121
|
+
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
122
|
+
if (payload.findings.length === 0) {
|
|
123
|
+
lines.push('(no findings)');
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const sorted = [...payload.findings].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
127
|
+
const shown = sorted.slice(0, max);
|
|
128
|
+
for (const f of shown) {
|
|
129
|
+
const loc = f.file !== undefined ? ` — ${f.file}${f.line !== undefined ? `:${f.line}` : ''}` : '';
|
|
130
|
+
lines.push(`- [${f.severity}] ${f.title}${loc}`);
|
|
131
|
+
}
|
|
132
|
+
if (sorted.length > shown.length) {
|
|
133
|
+
lines.push(`... ${sorted.length - shown.length} additional finding(s) suppressed (see JSON)`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
lines.push('');
|
|
137
|
+
lines.push(`machine-readable: ${lastReviewPath}`);
|
|
138
|
+
lines.push('');
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|