@bookedsolid/rea 0.27.0 → 0.28.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/data/claims/helix-022.json +51 -0
- package/data/claims/helix-023.json +44 -0
- package/data/claims/helix-024.json +72 -0
- package/data/claims/helix-028.json +23 -0
- package/data/claims/helix-031.json +27 -0
- package/dist/cli/index.js +6 -0
- package/dist/cli/preflight.d.ts +12 -0
- package/dist/cli/preflight.js +65 -4
- package/dist/cli/review.d.ts +55 -1
- package/dist/cli/review.js +167 -5
- package/dist/cli/status.d.ts +6 -0
- package/dist/cli/status.js +7 -0
- package/dist/cli/verify-claim.d.ts +149 -0
- package/dist/cli/verify-claim.js +386 -0
- package/dist/gateway/downstream-pool.d.ts +17 -0
- package/dist/gateway/downstream-pool.js +1 -0
- package/dist/gateway/downstream.d.ts +25 -0
- package/dist/gateway/downstream.js +40 -0
- package/dist/gateway/live-state.d.ts +12 -0
- package/dist/gateway/live-state.js +1 -0
- package/dist/hooks/bash-scanner/walker.js +196 -0
- package/dist/hooks/push-gate/findings.d.ts +27 -0
- package/dist/hooks/push-gate/findings.js +87 -0
- package/dist/hooks/push-gate/index.js +58 -4
- package/dist/hooks/push-gate/policy.d.ts +15 -0
- package/dist/hooks/push-gate/policy.js +82 -0
- package/dist/policy/loader.d.ts +20 -0
- package/dist/policy/loader.js +12 -0
- package/dist/policy/types.d.ts +31 -0
- package/hooks/blocked-paths-bash-gate.sh +12 -0
- package/hooks/protected-paths-bash-gate.sh +21 -0
- package/package.json +2 -1
|
@@ -256,8 +256,204 @@ export function walkForWrites(file) {
|
|
|
256
256
|
// emit (the path-shape decision is the scanner's, not ours — we
|
|
257
257
|
// emit conservatively).
|
|
258
258
|
detectCwdChangeIntoProtected(file, out);
|
|
259
|
+
// 0.28.0 round-18 P2 closure (FuncDecl-then-call). Static AST does
|
|
260
|
+
// not model invocation, so a function body's writes were emitted
|
|
261
|
+
// ONLY if the body itself contained a top-level write — but the
|
|
262
|
+
// PoC `f() { echo x > .rea/HALT; }; f` has the write in the body
|
|
263
|
+
// and the call as a separate Stmt. Pre-fix the call was an opaque
|
|
264
|
+
// CallExpr whose head matched no built-in detector, so the body
|
|
265
|
+
// writes never propagated to the outer detection set.
|
|
266
|
+
//
|
|
267
|
+
// Closure: pre-pass the AST to map every FuncDecl name → list of
|
|
268
|
+
// detected writes within its body (computed by the same walker on
|
|
269
|
+
// a synthetic file rooted at the body), then on every CallExpr
|
|
270
|
+
// whose head matches a known function name, re-emit the captured
|
|
271
|
+
// writes with `originSrc: 'funcdecl_invocation:<name>'` so the
|
|
272
|
+
// scanner refuses on the same kill-switch terms it would for a
|
|
273
|
+
// direct redirect.
|
|
274
|
+
detectFuncDeclThenCall(file, out);
|
|
259
275
|
return out;
|
|
260
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* 0.28.0 round-18 P2 — FuncDecl-then-call closure. Two-phase:
|
|
279
|
+
*
|
|
280
|
+
* Phase 1 (collectFuncDeclWrites): walk every FuncDecl in the file,
|
|
281
|
+
* compute the writes its body produces (by recursively running
|
|
282
|
+
* walkForWrites against the body), and record them keyed by the
|
|
283
|
+
* declared function name. Functions defined inside other constructs
|
|
284
|
+
* (e.g., a FuncDecl nested inside an IfClause body) are still
|
|
285
|
+
* collected — `syntax.Walk` visits them.
|
|
286
|
+
*
|
|
287
|
+
* Phase 2 (emitFuncDeclInvocationWrites): walk every CallExpr in
|
|
288
|
+
* the file. When the CallExpr's head normalizes to a known function
|
|
289
|
+
* name AND we have not already attributed this CallExpr's writes
|
|
290
|
+
* (avoid double-counting when the same CallExpr was already inside
|
|
291
|
+
* a body whose detections we collected), append the captured writes
|
|
292
|
+
* to `out` with the call site's position.
|
|
293
|
+
*
|
|
294
|
+
* Conservative trade-offs:
|
|
295
|
+
*
|
|
296
|
+
* - We only RE-EMIT writes; we do not re-run the walker against
|
|
297
|
+
* a synthesized "argv-substituted" body. Bash positional
|
|
298
|
+
* parameters (`$1`, `$@`) inside a function body that influence
|
|
299
|
+
* write paths are ignored — too dynamic to resolve statically
|
|
300
|
+
* and the existing dynamic-write detectors already refuse on
|
|
301
|
+
* unresolved expansions.
|
|
302
|
+
*
|
|
303
|
+
* - We do not chase call chains (`f() { g; }; g() { echo > .rea/HALT; }; f`).
|
|
304
|
+
* Each declared function's body is collected independently; an
|
|
305
|
+
* invocation matches at most ONE name. Two-hop chains require a
|
|
306
|
+
* follow-up closure (deferred — the PoC is one-hop, which is what
|
|
307
|
+
* the round-18 deferral cited).
|
|
308
|
+
*
|
|
309
|
+
* - Recursive function calls would loop the body-collection phase
|
|
310
|
+
* unless guarded. We pass a `visited` set keyed by FuncDecl
|
|
311
|
+
* identity so a self-referential body is walked at most once.
|
|
312
|
+
*/
|
|
313
|
+
function detectFuncDeclThenCall(file, out) {
|
|
314
|
+
const declWrites = collectFuncDeclWrites(file);
|
|
315
|
+
if (declWrites.size === 0)
|
|
316
|
+
return;
|
|
317
|
+
emitFuncDeclInvocationWrites(file, declWrites, out);
|
|
318
|
+
}
|
|
319
|
+
function collectFuncDeclWrites(file) {
|
|
320
|
+
const result = new Map();
|
|
321
|
+
const visited = new Set();
|
|
322
|
+
const visitor = (node) => {
|
|
323
|
+
if (node === null || node === undefined)
|
|
324
|
+
return true;
|
|
325
|
+
const t = nodeType(node);
|
|
326
|
+
if (t !== 'FuncDecl')
|
|
327
|
+
return true;
|
|
328
|
+
if (visited.has(node))
|
|
329
|
+
return false;
|
|
330
|
+
visited.add(node);
|
|
331
|
+
const name = funcDeclName(node);
|
|
332
|
+
if (name.length === 0)
|
|
333
|
+
return true;
|
|
334
|
+
const body = node['Body'];
|
|
335
|
+
if (!body || typeof body !== 'object')
|
|
336
|
+
return true;
|
|
337
|
+
// Recursively walk the body. We synthesize a BashFile-shaped
|
|
338
|
+
// wrapper so `syntax.Walk` sees a proper top-level entry point.
|
|
339
|
+
// The wrapper carries one Stmt whose Cmd is the body — when the
|
|
340
|
+
// body is itself a Stmt (Block / Subshell), we forward it
|
|
341
|
+
// directly. mvdan-sh's body shapes vary across versions; both
|
|
342
|
+
// shapes are accepted defensively.
|
|
343
|
+
const writes = [];
|
|
344
|
+
try {
|
|
345
|
+
walkSubtreeNoFuncDecl(body, writes);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
// Pathological body — fail closed at the higher tier. We do
|
|
349
|
+
// not propagate errors from this post-pass; the caller already
|
|
350
|
+
// has the primary detections.
|
|
351
|
+
}
|
|
352
|
+
if (writes.length > 0)
|
|
353
|
+
result.set(name, writes);
|
|
354
|
+
return true;
|
|
355
|
+
};
|
|
356
|
+
try {
|
|
357
|
+
syntax.Walk(file, visitor);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// Best-effort.
|
|
361
|
+
}
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Walk a subtree using the same dispatch as the main `walkForWrites`
|
|
366
|
+
* loop, but WITHOUT re-entering `detectFuncDeclThenCall`. Keeps the
|
|
367
|
+
* body-collection phase bounded — a self-referential function body
|
|
368
|
+
* (`f() { f; }`) would otherwise loop the post-pass forever.
|
|
369
|
+
*
|
|
370
|
+
* Mutates `out` in place.
|
|
371
|
+
*/
|
|
372
|
+
function walkSubtreeNoFuncDecl(root, out) {
|
|
373
|
+
try {
|
|
374
|
+
const visit = (node) => {
|
|
375
|
+
if (node === null || node === undefined)
|
|
376
|
+
return true;
|
|
377
|
+
const t = nodeType(node);
|
|
378
|
+
switch (t) {
|
|
379
|
+
case 'Stmt':
|
|
380
|
+
extractStmtRedirects(node, out);
|
|
381
|
+
extractHeredocShellPayloads(node, out);
|
|
382
|
+
break;
|
|
383
|
+
case 'CallExpr':
|
|
384
|
+
walkCallExpr(node, out);
|
|
385
|
+
break;
|
|
386
|
+
case 'BinaryCmd':
|
|
387
|
+
detectPipeIntoBareShell(node, out);
|
|
388
|
+
break;
|
|
389
|
+
case 'ParamExp':
|
|
390
|
+
recurseParamExpSlice(node, visit);
|
|
391
|
+
break;
|
|
392
|
+
default:
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
};
|
|
397
|
+
syntax.Walk(root, visit);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
/* swallow */
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function emitFuncDeclInvocationWrites(file, declWrites, out) {
|
|
404
|
+
const visitor = (node) => {
|
|
405
|
+
if (node === null || node === undefined)
|
|
406
|
+
return true;
|
|
407
|
+
const t = nodeType(node);
|
|
408
|
+
if (t !== 'CallExpr')
|
|
409
|
+
return true;
|
|
410
|
+
const args = asArray(node['Args']);
|
|
411
|
+
if (args.length === 0)
|
|
412
|
+
return true;
|
|
413
|
+
const head = args[0];
|
|
414
|
+
if (!head || typeof head !== 'object')
|
|
415
|
+
return true;
|
|
416
|
+
const headWord = wordToString(head);
|
|
417
|
+
if (headWord === null || headWord.value.length === 0)
|
|
418
|
+
return true;
|
|
419
|
+
const name = normalizeCmdHead(headWord.value);
|
|
420
|
+
const captured = declWrites.get(name);
|
|
421
|
+
if (captured === undefined || captured.length === 0)
|
|
422
|
+
return true;
|
|
423
|
+
// Re-emit each captured write with the call-site's position so
|
|
424
|
+
// the scanner's reporter shows the operator the SITE of the
|
|
425
|
+
// invocation, not the (possibly far-away) function definition.
|
|
426
|
+
const callPos = headWord.position;
|
|
427
|
+
for (const w of captured) {
|
|
428
|
+
out.push({
|
|
429
|
+
...w,
|
|
430
|
+
position: callPos,
|
|
431
|
+
originSrc: (w.originSrc !== undefined && w.originSrc.length > 0
|
|
432
|
+
? `${w.originSrc} via funcdecl_invocation:${name}`
|
|
433
|
+
: `funcdecl_invocation:${name}`),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return true;
|
|
437
|
+
};
|
|
438
|
+
try {
|
|
439
|
+
syntax.Walk(file, visitor);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
/* swallow */
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** Extract a FuncDecl's declared name. mvdan-sh stores it on `Name.Value`. */
|
|
446
|
+
function funcDeclName(funcDecl) {
|
|
447
|
+
const nameNode = funcDecl['Name'];
|
|
448
|
+
if (!nameNode || typeof nameNode !== 'object')
|
|
449
|
+
return '';
|
|
450
|
+
// mvdan-sh's Lit shape: { Value: string }. The reference probe:
|
|
451
|
+
// syntax.Walk visits Name as a Lit; .Value is the string.
|
|
452
|
+
const v = nameNode['Value'];
|
|
453
|
+
if (typeof v !== 'string')
|
|
454
|
+
return '';
|
|
455
|
+
return v.trim();
|
|
456
|
+
}
|
|
261
457
|
/**
|
|
262
458
|
* helix-024 F1 closure — detect `cd <DIR>` / `pushd <DIR>` (and `cd
|
|
263
459
|
* $VAR` / `pushd $VAR` dynamic forms) and emit synthetic detections
|
|
@@ -66,3 +66,30 @@ export declare function inferVerdict(findings: Finding[]): Verdict;
|
|
|
66
66
|
* Convenience: parse + infer in one call.
|
|
67
67
|
*/
|
|
68
68
|
export declare function summarizeReview(reviewText: string): ReviewSummary;
|
|
69
|
+
/**
|
|
70
|
+
* 0.28.0 helix-029 — partition findings against a list of gitignore-style
|
|
71
|
+
* globs. Findings whose `file` path matches any glob land in
|
|
72
|
+
* `excluded`; everything else (including findings without a `file`
|
|
73
|
+
* field — codex prose without a path can't be path-filtered) lands in
|
|
74
|
+
* `kept`. The verdict is recomputed from `kept` only.
|
|
75
|
+
*
|
|
76
|
+
* Glob semantics (intentionally minimal — full gitignore parity is out
|
|
77
|
+
* of scope for the gate):
|
|
78
|
+
*
|
|
79
|
+
* - `**` matches any number of path segments (including zero)
|
|
80
|
+
* - `*` matches any chars within a single path segment
|
|
81
|
+
* - `?` matches a single character within a segment
|
|
82
|
+
* - leading `/` anchors the glob at the root (the default — paths are
|
|
83
|
+
* repo-relative anyway)
|
|
84
|
+
* - trailing `/` is treated as `/**` (directory match)
|
|
85
|
+
*
|
|
86
|
+
* Path normalization: backslashes → slashes (Windows checkout
|
|
87
|
+
* tolerance), leading `./` stripped. Globs are case-sensitive on every
|
|
88
|
+
* platform so a Windows checkout doesn't silently widen the filter.
|
|
89
|
+
*/
|
|
90
|
+
export interface FilterResult {
|
|
91
|
+
kept: Finding[];
|
|
92
|
+
excluded: Finding[];
|
|
93
|
+
verdict: Verdict;
|
|
94
|
+
}
|
|
95
|
+
export declare function filterFindingsByPath(findings: Finding[], globs: readonly string[]): FilterResult;
|
|
@@ -140,3 +140,90 @@ export function summarizeReview(reviewText) {
|
|
|
140
140
|
reviewText,
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
|
+
export function filterFindingsByPath(findings, globs) {
|
|
144
|
+
if (globs.length === 0) {
|
|
145
|
+
return { kept: findings, excluded: [], verdict: inferVerdict(findings) };
|
|
146
|
+
}
|
|
147
|
+
// Compile once. Anchored at start, end, slash-tolerant.
|
|
148
|
+
const compiled = globs.map(compileGlob);
|
|
149
|
+
const kept = [];
|
|
150
|
+
const excluded = [];
|
|
151
|
+
for (const f of findings) {
|
|
152
|
+
if (f.file === undefined) {
|
|
153
|
+
kept.push(f);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const norm = normalizePath(f.file);
|
|
157
|
+
let matched = false;
|
|
158
|
+
for (const re of compiled) {
|
|
159
|
+
if (re.test(norm)) {
|
|
160
|
+
matched = true;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (matched)
|
|
165
|
+
excluded.push(f);
|
|
166
|
+
else
|
|
167
|
+
kept.push(f);
|
|
168
|
+
}
|
|
169
|
+
return { kept, excluded, verdict: inferVerdict(kept) };
|
|
170
|
+
}
|
|
171
|
+
function normalizePath(p) {
|
|
172
|
+
let out = p.replace(/\\/g, '/');
|
|
173
|
+
if (out.startsWith('./'))
|
|
174
|
+
out = out.slice(2);
|
|
175
|
+
if (out.startsWith('/'))
|
|
176
|
+
out = out.slice(1);
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Compile a gitignore-style glob into a RegExp. Conservative — handles
|
|
181
|
+
* the four wildcard forms documented above and treats every other
|
|
182
|
+
* character as a literal. The cost of a too-narrow compiler is a
|
|
183
|
+
* miss-and-no-filter (the finding stays in `kept` and blocks the push,
|
|
184
|
+
* which is the safer failure mode). The cost of a too-wide compiler is
|
|
185
|
+
* a false suppression — strictly worse, since findings disappear
|
|
186
|
+
* silently. We err narrow.
|
|
187
|
+
*/
|
|
188
|
+
function compileGlob(rawGlob) {
|
|
189
|
+
let glob = rawGlob;
|
|
190
|
+
if (glob.startsWith('/'))
|
|
191
|
+
glob = glob.slice(1);
|
|
192
|
+
// Trailing `/` → `/**` (directory match).
|
|
193
|
+
if (glob.endsWith('/'))
|
|
194
|
+
glob = `${glob}**`;
|
|
195
|
+
let re = '';
|
|
196
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
197
|
+
const c = glob[i];
|
|
198
|
+
if (c === '*') {
|
|
199
|
+
// `**` matches any number of path segments (including zero); a
|
|
200
|
+
// single `*` matches any chars within a segment.
|
|
201
|
+
if (i + 1 < glob.length && glob[i + 1] === '*') {
|
|
202
|
+
// Consume the second `*`. If followed by `/`, also consume it
|
|
203
|
+
// so `a/**/b` matches both `a/b` (zero segments) and `a/x/b`.
|
|
204
|
+
i += 1;
|
|
205
|
+
if (i + 1 < glob.length && glob[i + 1] === '/') {
|
|
206
|
+
i += 1;
|
|
207
|
+
re += '(?:.*/)?';
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
re += '.*';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
re += '[^/]*';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else if (c === '?') {
|
|
218
|
+
re += '[^/]';
|
|
219
|
+
}
|
|
220
|
+
else if (c !== undefined && /[.+^$|(){}[\]\\]/.test(c)) {
|
|
221
|
+
// Escape regex meta-characters.
|
|
222
|
+
re += `\\${c}`;
|
|
223
|
+
}
|
|
224
|
+
else if (c !== undefined) {
|
|
225
|
+
re += c;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return new RegExp(`^${re}$`);
|
|
229
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* directly — `deps.env` and `deps.baseDir` are the only ambient state.
|
|
23
23
|
*/
|
|
24
24
|
import path from 'node:path';
|
|
25
|
+
import crypto from 'node:crypto';
|
|
25
26
|
import { appendAuditRecord } from '../../audit/append.js';
|
|
26
27
|
import { loadPolicyAsync } from '../../policy/loader.js';
|
|
27
28
|
import { Tier, InvocationStatus } from '../../policy/types.js';
|
|
@@ -29,7 +30,7 @@ import { resolvePushGatePolicy, PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK, } fro
|
|
|
29
30
|
import { readHalt } from './halt.js';
|
|
30
31
|
import { resolveBaseRef } from './base.js';
|
|
31
32
|
import { createRealGitExecutor, runCodexReview, CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, IRON_GATE_DEFAULT_MODEL, IRON_GATE_DEFAULT_REASONING, } from './codex-runner.js';
|
|
32
|
-
import { summarizeReview } from './findings.js';
|
|
33
|
+
import { filterFindingsByPath, summarizeReview } from './findings.js';
|
|
33
34
|
import { renderBanner, writeLastReview } from './report.js';
|
|
34
35
|
import { isFlip, lookupVerdict, writeVerdict } from './verdict-cache.js';
|
|
35
36
|
/**
|
|
@@ -359,7 +360,21 @@ export async function runPushGate(deps) {
|
|
|
359
360
|
// reuse the cached verdict — durable PASS. Cache is bypassed when
|
|
360
361
|
// policy.review.cache_ttl_ms is 0. Cache miss / expired falls
|
|
361
362
|
// through to the codex call below.
|
|
362
|
-
|
|
363
|
+
//
|
|
364
|
+
// 0.28.0 codex round-1 P2: include a stable digest of
|
|
365
|
+
// policy.exclude_paths in the cache key so updates to the path
|
|
366
|
+
// filter immediately invalidate prior cached verdicts. Without
|
|
367
|
+
// this, enabling exclude_paths after a cached blocking verdict
|
|
368
|
+
// would keep refusing the push until TTL expires; disabling it
|
|
369
|
+
// would keep approving a previously-filtered pass. Digest is
|
|
370
|
+
// SHA-256 of a sorted JSON array — same input → same digest.
|
|
371
|
+
const excludeDigest = crypto
|
|
372
|
+
.createHash('sha256')
|
|
373
|
+
.update(JSON.stringify([...(policy.exclude_paths ?? [])].sort()))
|
|
374
|
+
.digest('hex')
|
|
375
|
+
.slice(0, 16);
|
|
376
|
+
const cacheKey = `${headSha}#${excludeDigest}`;
|
|
377
|
+
const cacheLookup = policy.cache_ttl_ms > 0 ? lookupVerdict(deps.baseDir, cacheKey) : { hit: false };
|
|
363
378
|
if (cacheLookup.hit && cacheLookup.entry !== undefined) {
|
|
364
379
|
const cached = cacheLookup.entry;
|
|
365
380
|
const cachedBlocked = cached.verdict === 'blocking' ||
|
|
@@ -421,7 +436,38 @@ export async function runPushGate(deps) {
|
|
|
421
436
|
? { reasoningEffort: policy.codex_reasoning_effort }
|
|
422
437
|
: {}),
|
|
423
438
|
});
|
|
424
|
-
const
|
|
439
|
+
const rawSummary = summarizeReview(codexResult.reviewText);
|
|
440
|
+
// 0.28.0 helix-029 — apply path-scoped filter BEFORE verdict
|
|
441
|
+
// computation. When `policy.exclude_paths` is empty (the default),
|
|
442
|
+
// the filter is a no-op: kept === rawSummary.findings, excluded
|
|
443
|
+
// is empty. When set, findings whose `file` matches any glob are
|
|
444
|
+
// moved into `filtered.excluded` and the verdict recomputes from
|
|
445
|
+
// `filtered.kept` only. The audit shape stays unchanged — we add
|
|
446
|
+
// a `filtered_findings_count` counter into metadata for forensic
|
|
447
|
+
// grep but do NOT introduce a new top-level array on the audit
|
|
448
|
+
// record.
|
|
449
|
+
// Defensive `?? []` — older test fixtures and embedders that
|
|
450
|
+
// construct `ResolvedReviewPolicy` by hand may omit the field;
|
|
451
|
+
// an undefined slot would crash `filterFindingsByPath` at the
|
|
452
|
+
// `globs.length === 0` check.
|
|
453
|
+
const filtered = filterFindingsByPath(rawSummary.findings, policy.exclude_paths ?? []);
|
|
454
|
+
const summary = {
|
|
455
|
+
...rawSummary,
|
|
456
|
+
findings: filtered.kept,
|
|
457
|
+
verdict: filtered.verdict,
|
|
458
|
+
};
|
|
459
|
+
if (filtered.excluded.length > 0) {
|
|
460
|
+
// Emit a single stderr line per excluded finding so the operator
|
|
461
|
+
// can still see them (and act — file upstream, audit, etc.) even
|
|
462
|
+
// though they no longer block the push. This is the audit-trail
|
|
463
|
+
// surface the helix bug report asked for; the `filtered_findings`
|
|
464
|
+
// array on `last-review.json` is the OPTIONAL extra layer (see
|
|
465
|
+
// below).
|
|
466
|
+
stderr(`rea: ${filtered.excluded.length} finding(s) filtered by review.exclude_paths (path-scoped):\n`);
|
|
467
|
+
for (const f of filtered.excluded) {
|
|
468
|
+
stderr(` - [${f.severity}] ${f.title}${f.file !== undefined ? ` — ${f.file}${f.line !== undefined ? `:${String(f.line)}` : ''}` : ''}\n`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
425
471
|
const blocked = summary.verdict === 'blocking' ||
|
|
426
472
|
(summary.verdict === 'concerns' && policy.concerns_blocks && !isConcernsOverrideSet(env));
|
|
427
473
|
const lastReviewPath = path.join(deps.baseDir, '.rea', 'last-review.json');
|
|
@@ -466,7 +512,11 @@ export async function runPushGate(deps) {
|
|
|
466
512
|
ttl_ms: policy.cache_ttl_ms,
|
|
467
513
|
};
|
|
468
514
|
try {
|
|
469
|
-
|
|
515
|
+
// 0.28.0 codex round-1 P2: write under the same compound key
|
|
516
|
+
// used for lookup so subsequent same-SHA-same-policy lookups
|
|
517
|
+
// hit cache, while same-SHA-different-policy lookups miss
|
|
518
|
+
// and produce a fresh verdict tied to the new policy state.
|
|
519
|
+
await writeVerdict(deps.baseDir, cacheKey, entry);
|
|
470
520
|
}
|
|
471
521
|
catch {
|
|
472
522
|
// Cache writes are best-effort. A failure here must NOT
|
|
@@ -479,6 +529,10 @@ export async function runPushGate(deps) {
|
|
|
479
529
|
await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, fullPolicy, {
|
|
480
530
|
verdict: summary.verdict,
|
|
481
531
|
finding_count: summary.findings.length,
|
|
532
|
+
// 0.28.0 helix-029: counter only — keeps the audit-record shape
|
|
533
|
+
// unchanged so no consumer parser breaks. Operators grep
|
|
534
|
+
// `filtered_findings_count` to see how many were suppressed.
|
|
535
|
+
filtered_findings_count: filtered.excluded.length > 0 ? filtered.excluded.length : undefined,
|
|
482
536
|
base_ref: base.ref,
|
|
483
537
|
base_source: base.source,
|
|
484
538
|
head_sha: headSha,
|
|
@@ -62,6 +62,21 @@ export interface ResolvedReviewPolicy {
|
|
|
62
62
|
* (24 hours) when policy.review.cache_ttl_ms is unset.
|
|
63
63
|
*/
|
|
64
64
|
cache_ttl_ms: number;
|
|
65
|
+
/**
|
|
66
|
+
* 0.28.0 helix-029 — path-scoped finding filter. Gitignore-style
|
|
67
|
+
* globs (anchored to repo root). Empty when no filter is active.
|
|
68
|
+
* Includes both `policy.review.exclude_paths` AND, when
|
|
69
|
+
* `auto_exclude_managed` is on, paths from `.rea/install-manifest.json`.
|
|
70
|
+
* Resolved BEFORE the gate runs so the gate has a single list to
|
|
71
|
+
* filter against.
|
|
72
|
+
*/
|
|
73
|
+
exclude_paths: string[];
|
|
74
|
+
/**
|
|
75
|
+
* 0.28.0 helix-029 — true when the resolver pulled paths from
|
|
76
|
+
* `.rea/install-manifest.json` into `exclude_paths`. Surfaced for
|
|
77
|
+
* audit shape only; the gate filter consumes `exclude_paths`.
|
|
78
|
+
*/
|
|
79
|
+
auto_exclude_managed: boolean;
|
|
65
80
|
/** `true` when `.rea/policy.yaml` was absent; defaults apply. */
|
|
66
81
|
policyMissing: boolean;
|
|
67
82
|
}
|
|
@@ -99,11 +99,34 @@ export async function resolvePushGatePolicy(baseDir) {
|
|
|
99
99
|
codex_model: PUSH_GATE_DEFAULT_CODEX_MODEL,
|
|
100
100
|
codex_reasoning_effort: PUSH_GATE_DEFAULT_CODEX_REASONING_EFFORT,
|
|
101
101
|
cache_ttl_ms: PUSH_GATE_DEFAULT_CACHE_TTL_MS,
|
|
102
|
+
exclude_paths: [],
|
|
103
|
+
auto_exclude_managed: false,
|
|
102
104
|
policyMissing: true,
|
|
103
105
|
};
|
|
104
106
|
}
|
|
105
107
|
const policy = await loadPolicyAsync(baseDir);
|
|
106
108
|
const review = policy.review ?? {};
|
|
109
|
+
// 0.28.0 helix-029 — derived default for auto_exclude_managed:
|
|
110
|
+
// - true when `exclude_paths` is set AND the operator did not
|
|
111
|
+
// explicitly disable it
|
|
112
|
+
// - false when `exclude_paths` is unset (no filter to compose)
|
|
113
|
+
// - false when explicitly set to `false`
|
|
114
|
+
const explicitGlobs = review.exclude_paths ?? [];
|
|
115
|
+
const hasExplicitGlobs = explicitGlobs.length > 0;
|
|
116
|
+
// Principal-engineer redesign: auto_exclude_managed is meaningful
|
|
117
|
+
// only when the operator signaled intent via exclude_paths. A bare
|
|
118
|
+
// `auto_exclude_managed: true` without globs is a no-op — the
|
|
119
|
+
// operator is otherwise opting OUT of any filter. Default true when
|
|
120
|
+
// exclude_paths is set; false when unset; explicit false always wins.
|
|
121
|
+
const autoExcludeDefault = hasExplicitGlobs;
|
|
122
|
+
const autoExcludeFlag = review.auto_exclude_managed ?? autoExcludeDefault;
|
|
123
|
+
const autoExcludeActive = autoExcludeFlag && hasExplicitGlobs;
|
|
124
|
+
let mergedExcludes = [...explicitGlobs];
|
|
125
|
+
if (autoExcludeActive) {
|
|
126
|
+
mergedExcludes = mergedExcludes.concat(readManifestPaths(baseDir));
|
|
127
|
+
}
|
|
128
|
+
// De-dupe — globs and manifest entries can overlap.
|
|
129
|
+
mergedExcludes = Array.from(new Set(mergedExcludes));
|
|
107
130
|
return {
|
|
108
131
|
codex_required: review.codex_required ?? PUSH_GATE_DEFAULT_CODEX_REQUIRED,
|
|
109
132
|
concerns_blocks: review.concerns_blocks ?? PUSH_GATE_DEFAULT_CONCERNS_BLOCKS,
|
|
@@ -113,6 +136,65 @@ export async function resolvePushGatePolicy(baseDir) {
|
|
|
113
136
|
codex_model: review.codex_model ?? PUSH_GATE_DEFAULT_CODEX_MODEL,
|
|
114
137
|
codex_reasoning_effort: review.codex_reasoning_effort ?? PUSH_GATE_DEFAULT_CODEX_REASONING_EFFORT,
|
|
115
138
|
cache_ttl_ms: review.cache_ttl_ms ?? PUSH_GATE_DEFAULT_CACHE_TTL_MS,
|
|
139
|
+
exclude_paths: mergedExcludes,
|
|
140
|
+
auto_exclude_managed: autoExcludeActive,
|
|
116
141
|
policyMissing: false,
|
|
117
142
|
};
|
|
118
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* 0.28.0 helix-029 — read repo-relative paths from
|
|
146
|
+
* `.rea/install-manifest.json` for the auto-exclude path. The manifest
|
|
147
|
+
* shape is `{ files: { "<rel-path>": { sha256: ..., source: ... } } }`
|
|
148
|
+
* (or similar). We pull only the keys (relative paths) and degrade to
|
|
149
|
+
* an empty array on parse / shape errors — auto-exclude is best-effort,
|
|
150
|
+
* never the gate's failure mode.
|
|
151
|
+
*/
|
|
152
|
+
function readManifestPaths(baseDir) {
|
|
153
|
+
const manifestPath = path.join(baseDir, '.rea', 'install-manifest.json');
|
|
154
|
+
if (!fs.existsSync(manifestPath))
|
|
155
|
+
return [];
|
|
156
|
+
try {
|
|
157
|
+
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
158
|
+
const parsed = JSON.parse(raw);
|
|
159
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
160
|
+
return [];
|
|
161
|
+
const obj = parsed;
|
|
162
|
+
const candidates = [];
|
|
163
|
+
// Common shapes: `{ files: {...} }` (object keyed by path), or
|
|
164
|
+
// `{ files: [...] }` (array of strings or `{path: ...}`). Be
|
|
165
|
+
// tolerant — the manifest format has shifted across rea minors.
|
|
166
|
+
if (obj.files !== undefined) {
|
|
167
|
+
if (Array.isArray(obj.files))
|
|
168
|
+
candidates.push(...obj.files);
|
|
169
|
+
else if (typeof obj.files === 'object' && obj.files !== null) {
|
|
170
|
+
candidates.push(...Object.keys(obj.files));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else if (Array.isArray(obj)) {
|
|
174
|
+
candidates.push(...obj);
|
|
175
|
+
}
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const c of candidates) {
|
|
178
|
+
const candidate = typeof c === 'string'
|
|
179
|
+
? c
|
|
180
|
+
: typeof c === 'object' && c !== null && typeof c.path === 'string'
|
|
181
|
+
? c.path
|
|
182
|
+
: '';
|
|
183
|
+
if (candidate.length === 0)
|
|
184
|
+
continue;
|
|
185
|
+
// 0.28.0 codex round-1 P1: manifest entries are LITERAL paths to
|
|
186
|
+
// managed files written by `rea init`. They are NEVER glob
|
|
187
|
+
// patterns. Reject any entry containing glob metachars to prevent
|
|
188
|
+
// a manifest like `{"files":["**"]}` from suppressing every
|
|
189
|
+
// finding via the auto_exclude_managed code path. Fail closed —
|
|
190
|
+
// a tampered manifest entry is dropped, never trusted as a glob.
|
|
191
|
+
if (/[*?\[\]!]/.test(candidate))
|
|
192
|
+
continue;
|
|
193
|
+
out.push(candidate);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -90,6 +90,18 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
90
90
|
* verdict. Set to 0 to disable caching (every push re-invokes codex).
|
|
91
91
|
*/
|
|
92
92
|
cache_ttl_ms: z.ZodOptional<z.ZodNumber>;
|
|
93
|
+
/**
|
|
94
|
+
* 0.28.0 helix-029 — path-scoped finding filter. Gitignore-style
|
|
95
|
+
* globs; findings whose `file` matches any are filtered out before
|
|
96
|
+
* verdict computation. Enables `auto_exclude_managed: true` by
|
|
97
|
+
* default; pass `auto_exclude_managed: false` explicitly to opt out.
|
|
98
|
+
*/
|
|
99
|
+
exclude_paths: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
100
|
+
/**
|
|
101
|
+
* 0.28.0 helix-029 — derived default. Defaults to true when
|
|
102
|
+
* `exclude_paths` is set, false when `exclude_paths` is unset.
|
|
103
|
+
*/
|
|
104
|
+
auto_exclude_managed: z.ZodOptional<z.ZodBoolean>;
|
|
93
105
|
/**
|
|
94
106
|
* 0.26.0 local-first enforcement. Strict so a typo in the off-switch
|
|
95
107
|
* surface (`mode: of`, `refuse_at: pushh`) fails policy load instead
|
|
@@ -122,6 +134,8 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
122
134
|
codex_model?: string | undefined;
|
|
123
135
|
codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
|
|
124
136
|
cache_ttl_ms?: number | undefined;
|
|
137
|
+
exclude_paths?: string[] | undefined;
|
|
138
|
+
auto_exclude_managed?: boolean | undefined;
|
|
125
139
|
local_review?: {
|
|
126
140
|
mode?: "enforced" | "off" | undefined;
|
|
127
141
|
max_age_seconds?: number | undefined;
|
|
@@ -137,6 +151,8 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
137
151
|
codex_model?: string | undefined;
|
|
138
152
|
codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
|
|
139
153
|
cache_ttl_ms?: number | undefined;
|
|
154
|
+
exclude_paths?: string[] | undefined;
|
|
155
|
+
auto_exclude_managed?: boolean | undefined;
|
|
140
156
|
local_review?: {
|
|
141
157
|
mode?: "enforced" | "off" | undefined;
|
|
142
158
|
max_age_seconds?: number | undefined;
|
|
@@ -260,6 +276,8 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
260
276
|
codex_model?: string | undefined;
|
|
261
277
|
codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
|
|
262
278
|
cache_ttl_ms?: number | undefined;
|
|
279
|
+
exclude_paths?: string[] | undefined;
|
|
280
|
+
auto_exclude_managed?: boolean | undefined;
|
|
263
281
|
local_review?: {
|
|
264
282
|
mode?: "enforced" | "off" | undefined;
|
|
265
283
|
max_age_seconds?: number | undefined;
|
|
@@ -323,6 +341,8 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
323
341
|
codex_model?: string | undefined;
|
|
324
342
|
codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
|
|
325
343
|
cache_ttl_ms?: number | undefined;
|
|
344
|
+
exclude_paths?: string[] | undefined;
|
|
345
|
+
auto_exclude_managed?: boolean | undefined;
|
|
326
346
|
local_review?: {
|
|
327
347
|
mode?: "enforced" | "off" | undefined;
|
|
328
348
|
max_age_seconds?: number | undefined;
|
package/dist/policy/loader.js
CHANGED
|
@@ -89,6 +89,18 @@ const ReviewPolicySchema = z
|
|
|
89
89
|
* verdict. Set to 0 to disable caching (every push re-invokes codex).
|
|
90
90
|
*/
|
|
91
91
|
cache_ttl_ms: z.number().int().nonnegative().optional(),
|
|
92
|
+
/**
|
|
93
|
+
* 0.28.0 helix-029 — path-scoped finding filter. Gitignore-style
|
|
94
|
+
* globs; findings whose `file` matches any are filtered out before
|
|
95
|
+
* verdict computation. Enables `auto_exclude_managed: true` by
|
|
96
|
+
* default; pass `auto_exclude_managed: false` explicitly to opt out.
|
|
97
|
+
*/
|
|
98
|
+
exclude_paths: z.array(z.string().min(1)).optional(),
|
|
99
|
+
/**
|
|
100
|
+
* 0.28.0 helix-029 — derived default. Defaults to true when
|
|
101
|
+
* `exclude_paths` is set, false when `exclude_paths` is unset.
|
|
102
|
+
*/
|
|
103
|
+
auto_exclude_managed: z.boolean().optional(),
|
|
92
104
|
/**
|
|
93
105
|
* 0.26.0 local-first enforcement. Strict so a typo in the off-switch
|
|
94
106
|
* surface (`mode: of`, `refuse_at: pushh`) fails policy load instead
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -169,6 +169,37 @@ export interface ReviewPolicy {
|
|
|
169
169
|
* a `rea.push_gate.verdict_flip` audit event and overwrite the cache.
|
|
170
170
|
*/
|
|
171
171
|
cache_ttl_ms?: number;
|
|
172
|
+
/**
|
|
173
|
+
* 0.28.0 helix-029 — path-scoped finding filter. Gitignore-style
|
|
174
|
+
* globs against repo-relative paths. Findings whose `file` matches
|
|
175
|
+
* any glob in this list are filtered OUT before the verdict is
|
|
176
|
+
* computed, but are still emitted on stderr (so the operator can
|
|
177
|
+
* file them upstream). Useful for downstream consumers of rea who
|
|
178
|
+
* cannot patch rea-managed paths but should not be blocked from
|
|
179
|
+
* pushing while waiting on an upstream fix.
|
|
180
|
+
*
|
|
181
|
+
* Setting this list also enables `auto_exclude_managed` by default —
|
|
182
|
+
* paths from `.rea/install-manifest.json` are excluded in addition
|
|
183
|
+
* to whatever globs are listed here. Pass `auto_exclude_managed:
|
|
184
|
+
* false` to opt out and rely on `exclude_paths` alone.
|
|
185
|
+
*
|
|
186
|
+
* Empty (or unset) → no filtering, pre-0.28.0 behavior.
|
|
187
|
+
*
|
|
188
|
+
* The audit shape is unchanged; the gate emits a
|
|
189
|
+
* `filtered_findings_count` counter into the audit metadata so
|
|
190
|
+
* operators can grep `rea.push_gate.reviewed` to see how many
|
|
191
|
+
* findings were suppressed without re-parsing prose.
|
|
192
|
+
*/
|
|
193
|
+
exclude_paths?: string[];
|
|
194
|
+
/**
|
|
195
|
+
* 0.28.0 helix-029 — derived default. When `exclude_paths` is set,
|
|
196
|
+
* defaults to `true` — paths from `.rea/install-manifest.json` are
|
|
197
|
+
* excluded in addition to the explicit globs. Set explicitly to
|
|
198
|
+
* `false` to rely only on `exclude_paths`. When `exclude_paths` is
|
|
199
|
+
* unset, this field is a no-op (no filter is active in the first
|
|
200
|
+
* place).
|
|
201
|
+
*/
|
|
202
|
+
auto_exclude_managed?: boolean;
|
|
172
203
|
/**
|
|
173
204
|
* Local-first review enforcement (0.26.0+ — CTO directive 2026-05-05).
|
|
174
205
|
*
|