@bookedsolid/rea 0.26.1 → 0.28.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.
Files changed (45) hide show
  1. package/README.md +16 -3
  2. package/agents/adversarial-test-specialist.md +113 -0
  3. package/agents/ast-parser-specialist.md +92 -0
  4. package/agents/codex-adversarial.md +50 -97
  5. package/agents/figma-dx-specialist.md +112 -0
  6. package/agents/mcp-protocol-specialist.md +94 -0
  7. package/agents/observability-specialist.md +103 -0
  8. package/agents/rea-orchestrator.md +25 -5
  9. package/agents/shell-scripting-specialist.md +101 -0
  10. package/commands/codex-review.md +62 -59
  11. package/data/claims/helix-022.json +51 -0
  12. package/data/claims/helix-023.json +44 -0
  13. package/data/claims/helix-024.json +72 -0
  14. package/data/claims/helix-028.json +23 -0
  15. package/data/claims/helix-031.json +27 -0
  16. package/dist/cli/hook.d.ts +78 -4
  17. package/dist/cli/hook.js +291 -4
  18. package/dist/cli/index.js +6 -0
  19. package/dist/cli/preflight.d.ts +12 -0
  20. package/dist/cli/preflight.js +65 -4
  21. package/dist/cli/status.d.ts +6 -0
  22. package/dist/cli/status.js +7 -0
  23. package/dist/cli/verify-claim.d.ts +149 -0
  24. package/dist/cli/verify-claim.js +386 -0
  25. package/dist/gateway/downstream-pool.d.ts +17 -0
  26. package/dist/gateway/downstream-pool.js +1 -0
  27. package/dist/gateway/downstream.d.ts +25 -0
  28. package/dist/gateway/downstream.js +40 -0
  29. package/dist/gateway/live-state.d.ts +12 -0
  30. package/dist/gateway/live-state.js +1 -0
  31. package/dist/hooks/bash-scanner/walker.js +196 -0
  32. package/dist/hooks/push-gate/codex-runner.d.ts +9 -0
  33. package/dist/hooks/push-gate/codex-runner.js +14 -1
  34. package/dist/hooks/push-gate/findings.d.ts +27 -0
  35. package/dist/hooks/push-gate/findings.js +87 -0
  36. package/dist/hooks/push-gate/index.js +58 -4
  37. package/dist/hooks/push-gate/policy.d.ts +15 -0
  38. package/dist/hooks/push-gate/policy.js +82 -0
  39. package/dist/policy/loader.d.ts +20 -0
  40. package/dist/policy/loader.js +12 -0
  41. package/dist/policy/types.d.ts +31 -0
  42. package/hooks/_lib/cmd-segments.sh +10 -0
  43. package/hooks/blocked-paths-bash-gate.sh +12 -0
  44. package/hooks/protected-paths-bash-gate.sh +21 -0
  45. 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
@@ -119,6 +119,15 @@ export interface CodexRunOptions {
119
119
  cwd: string;
120
120
  env: NodeJS.ProcessEnv;
121
121
  }) => ChildProcessWithoutNullStreams;
122
+ /**
123
+ * 0.27.0 — optional callback fired for every raw stdout chunk from
124
+ * `codex exec review`. Used by `rea hook codex-review` (the thin Bash-
125
+ * direct CLI) to tee the JSONL stream into a tempfile so the caller can
126
+ * read the un-summarized review JSON directly. Errors thrown from the
127
+ * sink are caught and ignored — sink failure must NEVER change the
128
+ * verdict. Receives chunks in arrival order.
129
+ */
130
+ rawStdoutSink?: (chunk: Buffer) => void;
122
131
  }
123
132
  export interface CodexRunResult {
124
133
  /** The concatenated text of every `item.completed` agent_message item. */
@@ -262,7 +262,20 @@ export async function runCodexReview(options) {
262
262
  reject(new CodexTimeoutError(options.timeoutMs));
263
263
  }, options.timeoutMs);
264
264
  timer.unref?.();
265
- child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
265
+ child.stdout.on('data', (chunk) => {
266
+ stdoutChunks.push(chunk);
267
+ // 0.27.0 raw-stdout tee for `rea hook codex-review`. Sink errors
268
+ // are swallowed — a bad sink must not make a passing review fail.
269
+ const sink = options.rawStdoutSink;
270
+ if (sink !== undefined) {
271
+ try {
272
+ sink(chunk);
273
+ }
274
+ catch {
275
+ /* sink failure is non-fatal */
276
+ }
277
+ }
278
+ });
266
279
  child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
267
280
  child.on('error', (e) => {
268
281
  clearTimeout(timer);
@@ -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
- const cacheLookup = policy.cache_ttl_ms > 0 ? lookupVerdict(deps.baseDir, headSha) : { hit: false };
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 summary = summarizeReview(codexResult.reviewText);
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
- await writeVerdict(deps.baseDir, headSha, entry);
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
+ }
@@ -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;
@@ -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