@bookedsolid/rea 0.19.0 → 0.21.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/commit-msg CHANGED
@@ -86,9 +86,9 @@ MATCHES=""
86
86
  # Pattern 2 below catches Co-Authored-By with named tools regardless of
87
87
  # email, so dropping users.noreply.github.com from this branch only
88
88
  # relaxes the check for human collaborators — never for AI.
89
- if grep -qiE 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com)' "$COMMIT_MSG_FILE" 2>/dev/null; then
89
+ if grep -qiE 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com|mistral\.ai|xai-org|x\.ai|inflection\.ai|perplexity\.ai|replit\.com|jetbrains\.com|bito\.ai|pieces\.app|phind\.com|you\.com)' "$COMMIT_MSG_FILE" 2>/dev/null; then
90
90
  BLOCKED=1
91
- MATCHES="${MATCHES}$(grep -niE 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com)' "$COMMIT_MSG_FILE" 2>/dev/null)
91
+ MATCHES="${MATCHES}$(grep -niE 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com|mistral\.ai|xai-org|x\.ai|inflection\.ai|perplexity\.ai|replit\.com|jetbrains\.com|bito\.ai|pieces\.app|phind\.com|you\.com)' "$COMMIT_MSG_FILE" 2>/dev/null)
92
92
  "
93
93
  fi
94
94
 
package/dist/cli/init.js CHANGED
@@ -297,6 +297,16 @@ function writePolicyYaml(targetDir, config, layered) {
297
297
  lines.push(` max_bash_output_lines: ${cp.max_bash_output_lines}`);
298
298
  }
299
299
  }
300
+ // 0.20.1+ helix-round-N P2: emit architecture_review.patterns when
301
+ // the layered profile declared them. Consumers without patterns see
302
+ // a silent no-op from architecture-review-gate.sh.
303
+ if (layered.architecture_review?.patterns !== undefined) {
304
+ lines.push(`architecture_review:`);
305
+ lines.push(` patterns:`);
306
+ for (const p of layered.architecture_review.patterns) {
307
+ lines.push(` - ${JSON.stringify(p)}`);
308
+ }
309
+ }
300
310
  // 0.18.1+ helixir #9: emit audit.rotation when the layered profile
301
311
  // declared it. Empty `rotation: {}` opts in to documented defaults
302
312
  // (50 MiB / 30 days); explicit values override.
@@ -19,6 +19,23 @@
19
19
  * and so the one git dependency surface is in one place.
20
20
  */
21
21
  import type { ChildProcessWithoutNullStreams } from 'node:child_process';
22
+ /**
23
+ * Default codex model when policy doesn't pin one. Always passed via
24
+ * `-c model="<name>"` so codex's own default (`codex-auto-review` at
25
+ * medium reasoning) is unreachable through the rea push-gate.
26
+ *
27
+ * 0.19.0 code-reviewer P3-4: exported as a single source of truth.
28
+ * `src/hooks/push-gate/index.ts` imports this for the verdict-cache
29
+ * write so the cached `model` field reflects the same constant the
30
+ * runner actually used. Bump here to bump everywhere.
31
+ */
32
+ export declare const IRON_GATE_DEFAULT_MODEL = "gpt-5.4";
33
+ /**
34
+ * Default reasoning effort when policy doesn't pin one. `high` for
35
+ * verdict stability — the helixir 2026-04-26 thrashing came from the
36
+ * lower-reasoning default.
37
+ */
38
+ export declare const IRON_GATE_DEFAULT_REASONING: 'low' | 'medium' | 'high';
22
39
  export declare class CodexNotInstalledError extends Error {
23
40
  readonly kind: "not-installed";
24
41
  constructor();
@@ -20,6 +20,26 @@
20
20
  */
21
21
  import { spawn, spawnSync } from 'node:child_process';
22
22
  // ---------------------------------------------------------------------------
23
+ // Iron-gate runtime defaults (0.18.0+)
24
+ // ---------------------------------------------------------------------------
25
+ /**
26
+ * Default codex model when policy doesn't pin one. Always passed via
27
+ * `-c model="<name>"` so codex's own default (`codex-auto-review` at
28
+ * medium reasoning) is unreachable through the rea push-gate.
29
+ *
30
+ * 0.19.0 code-reviewer P3-4: exported as a single source of truth.
31
+ * `src/hooks/push-gate/index.ts` imports this for the verdict-cache
32
+ * write so the cached `model` field reflects the same constant the
33
+ * runner actually used. Bump here to bump everywhere.
34
+ */
35
+ export const IRON_GATE_DEFAULT_MODEL = 'gpt-5.4';
36
+ /**
37
+ * Default reasoning effort when policy doesn't pin one. `high` for
38
+ * verdict stability — the helixir 2026-04-26 thrashing came from the
39
+ * lower-reasoning default.
40
+ */
41
+ export const IRON_GATE_DEFAULT_REASONING = 'high';
42
+ // ---------------------------------------------------------------------------
23
43
  // Errors
24
44
  // ---------------------------------------------------------------------------
25
45
  export class CodexNotInstalledError extends Error {
@@ -151,8 +171,10 @@ export async function runCodexReview(options) {
151
171
  // Codex's TOML parser interprets the value, so we wrap strings in TOML
152
172
  // quotes — `-c model="gpt-5.4"` not `-c model=gpt-5.4` — to ensure the
153
173
  // value lands as a string regardless of upstream parsing changes.
154
- const effectiveModel = options.model !== undefined && options.model.length > 0 ? options.model : 'gpt-5.4';
155
- const effectiveReasoning = options.reasoningEffort ?? 'high';
174
+ const effectiveModel = options.model !== undefined && options.model.length > 0
175
+ ? options.model
176
+ : IRON_GATE_DEFAULT_MODEL;
177
+ const effectiveReasoning = options.reasoningEffort ?? IRON_GATE_DEFAULT_REASONING;
156
178
  const overrideArgs = [
157
179
  '-c',
158
180
  `model="${escapeTomlString(effectiveModel)}"`,
@@ -23,11 +23,12 @@
23
23
  */
24
24
  import path from 'node:path';
25
25
  import { appendAuditRecord } from '../../audit/append.js';
26
+ import { loadPolicyAsync } from '../../policy/loader.js';
26
27
  import { Tier, InvocationStatus } from '../../policy/types.js';
27
28
  import { resolvePushGatePolicy, PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK, } from './policy.js';
28
29
  import { readHalt } from './halt.js';
29
30
  import { resolveBaseRef } from './base.js';
30
- import { createRealGitExecutor, runCodexReview, CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, } from './codex-runner.js';
31
+ import { createRealGitExecutor, runCodexReview, CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, IRON_GATE_DEFAULT_MODEL, IRON_GATE_DEFAULT_REASONING, } from './codex-runner.js';
31
32
  import { summarizeReview } from './findings.js';
32
33
  import { renderBanner, writeLastReview } from './report.js';
33
34
  import { isFlip, lookupVerdict, writeVerdict, } from './verdict-cache.js';
@@ -87,13 +88,27 @@ export async function runPushGate(deps) {
87
88
  const runCodexFn = deps.runCodex ?? runCodexReview;
88
89
  const appendAuditFn = deps.appendAudit ?? appendAuditRecord;
89
90
  const git = deps.git ?? createRealGitExecutor(deps.baseDir);
91
+ // 0.19.0 backend-engineer review P1-1: load the full Policy once and
92
+ // thread it to every safeAppend so audit rotation actually fires.
93
+ // Pre-fix the rotator short-circuited because policy was never passed
94
+ // through, silently disabling the `audit.rotation: {}` opt-in shipped
95
+ // in 0.18.1 for the bst-internal profile. A failure to load policy
96
+ // here is non-fatal — the gate continues; audit rotation just stays
97
+ // disabled for this run (back-compat).
98
+ let fullPolicy;
99
+ try {
100
+ fullPolicy = await loadPolicyAsync(deps.baseDir);
101
+ }
102
+ catch {
103
+ fullPolicy = undefined;
104
+ }
90
105
  // 1. HALT wins over everything, including `review.codex_required: false`.
91
106
  // Reading it before policy also means a corrupted policy.yaml doesn't
92
107
  // prevent the kill-switch from firing.
93
108
  const halt = readHaltFn(deps.baseDir);
94
109
  if (halt.halted) {
95
110
  stderr(`REA HALT: ${halt.reason ?? 'unknown'}\nAll push operations suspended. Run: rea unfreeze\n`);
96
- await safeAppend(appendAuditFn, deps.baseDir, EVT_HALTED, {
111
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_HALTED, fullPolicy, {
97
112
  reason: halt.reason ?? 'unknown',
98
113
  });
99
114
  return {
@@ -111,14 +126,14 @@ export async function runPushGate(deps) {
111
126
  catch (e) {
112
127
  const msg = e instanceof Error ? e.message : String(e);
113
128
  stderr(`PUSH BLOCKED: failed to load .rea/policy.yaml — ${msg}\n`);
114
- await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, {
129
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, fullPolicy, {
115
130
  kind: 'policy-load',
116
131
  error: msg,
117
132
  });
118
133
  return { status: 'error', exitCode: 2, summary: `policy-load error: ${msg}` };
119
134
  }
120
135
  if (!policy.codex_required) {
121
- await safeAppend(appendAuditFn, deps.baseDir, EVT_DISABLED, {
136
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_DISABLED, fullPolicy, {
122
137
  policy_missing: policy.policyMissing,
123
138
  });
124
139
  return {
@@ -156,7 +171,7 @@ export async function runPushGate(deps) {
156
171
  const skipVar = skipPush.length > 0 ? 'REA_SKIP_PUSH_GATE' : 'REA_SKIP_CODEX_REVIEW';
157
172
  const skipReason = skipVar === 'REA_SKIP_PUSH_GATE' ? skipPush : skipCodex;
158
173
  stderr(`rea: ${skipVar}=${skipReason} — push-gate skipped (audited).\n`);
159
- await safeAppend(appendAuditFn, deps.baseDir, EVT_SKIPPED, {
174
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_SKIPPED, fullPolicy, {
160
175
  reason: skipReason,
161
176
  skip_var: skipVar,
162
177
  });
@@ -251,7 +266,7 @@ export async function runPushGate(deps) {
251
266
  }
252
267
  if (headSha.length === 0) {
253
268
  stderr('PUSH BLOCKED: could not resolve HEAD SHA. Is this a valid git repo?\n');
254
- await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, { kind: 'head-sha-missing' });
269
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, fullPolicy, { kind: 'head-sha-missing' });
255
270
  return { status: 'error', exitCode: 2, summary: 'head-sha-missing' };
256
271
  }
257
272
  // 4b. Auto-narrow probe (J / 0.13.0). When the resolved base is far
@@ -321,7 +336,7 @@ export async function runPushGate(deps) {
321
336
  // no-op relative to base.
322
337
  const diff = git.diffNames(base.ref, headSha);
323
338
  if (diff.length === 0) {
324
- await safeAppend(appendAuditFn, deps.baseDir, EVT_EMPTY, {
339
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_EMPTY, fullPolicy, {
325
340
  base_ref: base.ref,
326
341
  base_source: base.source,
327
342
  head_sha: headSha,
@@ -348,7 +363,12 @@ export async function runPushGate(deps) {
348
363
  const cached = cacheLookup.entry;
349
364
  const cachedBlocked = cached.verdict === 'blocking'
350
365
  || (cached.verdict === 'concerns' && policy.concerns_blocks && !isConcernsOverrideSet(env));
351
- await safeAppend(appendAuditFn, deps.baseDir, EVT_CACHE_HIT, {
366
+ // 0.19.1 P3-3 (code-reviewer): emit EVT_CACHE_HIT (forensic detail
367
+ // for the cache layer specifically) AND EVT_REVIEWED (the canonical
368
+ // verdict event with `cache_hit: true` metadata). Operators
369
+ // grepping `rea.push_gate.reviewed` for verdict-stability dashboards
370
+ // see every push, including cached ones.
371
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_CACHE_HIT, fullPolicy, {
352
372
  verdict: cached.verdict,
353
373
  finding_count: cached.finding_count,
354
374
  base_ref: base.ref,
@@ -359,16 +379,23 @@ export async function runPushGate(deps) {
359
379
  cached_reasoning_effort: cached.reasoning_effort,
360
380
  blocked: cachedBlocked,
361
381
  });
382
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, fullPolicy, {
383
+ verdict: cached.verdict,
384
+ finding_count: cached.finding_count,
385
+ base_ref: base.ref,
386
+ base_source: base.source,
387
+ head_sha: headSha,
388
+ blocked: cachedBlocked,
389
+ cache_hit: true,
390
+ cached_reviewed_at: cached.reviewed_at,
391
+ cached_model: cached.model,
392
+ cached_reasoning_effort: cached.reasoning_effort,
393
+ });
394
+ // 0.19.1 P3-1 (backend): simplified return shape. Verdict maps
395
+ // 1:1 to status; cachedBlocked maps 1:1 to exitCode. The prior
396
+ // nested ternary recomputed the same mapping in both arms.
362
397
  return {
363
- status: cachedBlocked
364
- ? cached.verdict === 'blocking'
365
- ? 'blocking'
366
- : 'concerns'
367
- : cached.verdict === 'blocking'
368
- ? 'blocking'
369
- : cached.verdict === 'concerns'
370
- ? 'concerns'
371
- : 'pass',
398
+ status: cached.verdict,
372
399
  exitCode: cachedBlocked ? 2 : 0,
373
400
  summary: `${cached.verdict}: ${cached.finding_count} finding(s) (cached)`,
374
401
  verdict: cached.verdict,
@@ -423,7 +450,7 @@ export async function runPushGate(deps) {
423
450
  if (policy.cache_ttl_ms > 0) {
424
451
  const flipped = isFlip(cacheLookup.entry, summary.verdict);
425
452
  if (flipped && cacheLookup.entry !== undefined) {
426
- await safeAppend(appendAuditFn, deps.baseDir, EVT_VERDICT_FLIP, {
453
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_VERDICT_FLIP, fullPolicy, {
427
454
  head_sha: headSha,
428
455
  prior_verdict: cacheLookup.entry.verdict,
429
456
  fresh_verdict: summary.verdict,
@@ -435,20 +462,22 @@ export async function runPushGate(deps) {
435
462
  verdict: summary.verdict,
436
463
  finding_count: summary.findings.length,
437
464
  reviewed_at: deps.now !== undefined ? deps.now().toISOString() : new Date().toISOString(),
438
- model: policy.codex_model ?? 'gpt-5.4',
439
- reasoning_effort: policy.codex_reasoning_effort ?? 'high',
465
+ model: policy.codex_model ?? IRON_GATE_DEFAULT_MODEL,
466
+ reasoning_effort: policy.codex_reasoning_effort ?? IRON_GATE_DEFAULT_REASONING,
440
467
  ttl_ms: policy.cache_ttl_ms,
441
468
  };
442
469
  try {
443
- writeVerdict(deps.baseDir, headSha, entry);
470
+ await writeVerdict(deps.baseDir, headSha, entry);
444
471
  }
445
472
  catch {
446
473
  // Cache writes are best-effort. A failure here must NOT
447
474
  // affect the verdict — log to stderr (already done by the
448
- // caller via banner) and proceed.
475
+ // caller via banner) and proceed. Foreign-schema (v3+ cache
476
+ // from a future rea version) lands here and is correctly
477
+ // declined — overwriting would lose forward-compat data.
449
478
  }
450
479
  }
451
- await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, {
480
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, fullPolicy, {
452
481
  verdict: summary.verdict,
453
482
  finding_count: summary.findings.length,
454
483
  base_ref: base.ref,
@@ -492,7 +521,7 @@ export async function runPushGate(deps) {
492
521
  };
493
522
  }
494
523
  catch (e) {
495
- return handleCodexError(e, deps, base, headSha, appendAuditFn);
524
+ return handleCodexError(e, deps, base, headSha, appendAuditFn, fullPolicy);
496
525
  }
497
526
  }
498
527
  function isConcernsOverrideSet(env) {
@@ -502,7 +531,7 @@ function isConcernsOverrideSet(env) {
502
531
  const normalized = raw.trim().toLowerCase();
503
532
  return normalized === '1' || normalized === 'true' || normalized === 'yes';
504
533
  }
505
- async function handleCodexError(e, deps, base, headSha, appendAuditFn) {
534
+ async function handleCodexError(e, deps, base, headSha, appendAuditFn, policy) {
506
535
  const stderr = deps.stderr;
507
536
  const runError = classifyCodexError(e);
508
537
  const metadata = {
@@ -514,7 +543,7 @@ async function handleCodexError(e, deps, base, headSha, appendAuditFn) {
514
543
  if (runError.message.length > 0)
515
544
  metadata.error = runError.message;
516
545
  stderr(`PUSH BLOCKED: ${runError.message}\n`);
517
- await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, metadata);
546
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, policy, metadata);
518
547
  return {
519
548
  status: 'error',
520
549
  exitCode: 2,
@@ -542,7 +571,7 @@ function classifyCodexError(e) {
542
571
  * its primary result. The hash chain remains intact if this succeeds; on
543
572
  * failure we've already made the gate decision based on the actual review.
544
573
  */
545
- async function safeAppend(appendFn, baseDir, toolName, metadata) {
574
+ async function safeAppend(appendFn, baseDir, toolName, policy, metadata) {
546
575
  try {
547
576
  // Prune undefined values — the audit record schema's `metadata` is an
548
577
  // arbitrary map, but `undefined` values cause JSON.stringify to emit
@@ -552,12 +581,19 @@ async function safeAppend(appendFn, baseDir, toolName, metadata) {
552
581
  if (v !== undefined)
553
582
  cleanMeta[k] = v;
554
583
  }
584
+ // 0.19.0 P1-1 fix (backend-engineer review): pass the loaded Policy
585
+ // through so `appendAuditRecord` → `maybeRotate` actually fires.
586
+ // Pre-fix the policy was never threaded; rotation short-circuited
587
+ // to `{ rotated: false }` on the entire push-gate audit-emission
588
+ // path, silently disabling the `audit.rotation: {}` opt-in shipped
589
+ // in 0.18.1 for the bst-internal profile.
555
590
  await appendFn(baseDir, {
556
591
  tool_name: toolName,
557
592
  server_name: AUDIT_SERVER_NAME,
558
593
  tier: Tier.Read,
559
594
  status: InvocationStatus.Allowed,
560
595
  ...(Object.keys(cleanMeta).length > 0 ? { metadata: cleanMeta } : {}),
596
+ ...(policy !== undefined ? { policy } : {}),
561
597
  });
562
598
  }
563
599
  catch (e) {
@@ -37,6 +37,21 @@
37
37
  * audit log alone (helixir #8).
38
38
  * - REA_SKIP_CODEX_REVIEW short-circuits BEFORE cache lookup (unchanged).
39
39
  *
40
+ * 0.19.0 review fixes:
41
+ * - Concurrent writes are now serialized via `withAuditLock` on the
42
+ * `.rea/` directory (backend-engineer P1-2; security M3). Two
43
+ * concurrent push-gate runs no longer race read-modify-write.
44
+ * - Tmp filenames carry a high-entropy suffix (PID + millis + random)
45
+ * and are unlinked in finally so a crash mid-write doesn't leave
46
+ * stale state (backend-engineer P1-3; code-reviewer P2-1).
47
+ * - All three writers (writeVerdict, clearVerdict, pruneOlderThan,
48
+ * clearAll) route through one `_atomicWrite` helper — no asymmetry
49
+ * between paths (code-reviewer P2-2).
50
+ * - On unrecognized schema_version, reads return undefined AND
51
+ * writes refuse to overwrite — the v3 cache stays intact for a
52
+ * future rea version that knows how to read it (code-reviewer P3-5;
53
+ * backend-engineer P2-2).
54
+ *
40
55
  * The cache is OPTIONAL by design: existing callers that don't pass a
41
56
  * `cacheImpl` get the legacy stateless path. Tests inject a fake.
42
57
  */
@@ -44,6 +59,16 @@ import type { Verdict as ReviewVerdict } from './findings.js';
44
59
  export declare const VERDICT_CACHE_FILE = "last-review.cache.json";
45
60
  export declare const VERDICT_CACHE_SCHEMA_VERSION: 2;
46
61
  export declare const DEFAULT_CACHE_TTL_MS: number;
62
+ /**
63
+ * Soft cap on cached entry count before `writeVerdict` opportunistically
64
+ * prunes expired entries (0.19.1 backend-engineer P2-3). Keeps the
65
+ * cache file from growing unbounded over months of pushes against many
66
+ * SHAs — at 500 entries × ~200 bytes/entry ≈ 100 KB, we proactively
67
+ * drop expired entries on the next write. The prune is best-effort:
68
+ * if every entry is unexpired we accept the larger file rather than
69
+ * dropping fresh durable verdicts.
70
+ */
71
+ export declare const VERDICT_CACHE_PRUNE_THRESHOLD = 500;
47
72
  export interface VerdictCacheEntry {
48
73
  verdict: ReviewVerdict;
49
74
  finding_count: number;
@@ -66,33 +91,46 @@ export interface VerdictCacheLookupResult {
66
91
  * miss with `entry: undefined` — the caller proceeds to codex.
67
92
  */
68
93
  export declare function lookupVerdict(baseDir: string, headSha: string, now?: Date): VerdictCacheLookupResult;
69
- /**
70
- * Write a fresh verdict entry. Atomic via tmp-file + rename. Unrecognized
71
- * pre-existing entries are preserved (forward-compat for v3+).
72
- */
73
- export declare function writeVerdict(baseDir: string, headSha: string, entry: VerdictCacheEntry): void;
74
94
  /**
75
95
  * Detect whether a new verdict contradicts a previously-cached verdict
76
96
  * on the same SHA. Used by `runPushGate` to set the flip-flag on
77
97
  * last-review.json and emit the `verdict_flip` audit event.
78
98
  */
79
99
  export declare function isFlip(prior: VerdictCacheEntry | undefined, fresh: ReviewVerdict): boolean;
100
+ /**
101
+ * Write a fresh verdict entry. Atomic via tmp-file + rename, serialized
102
+ * via `withAuditLock` on `.rea/`. Refuses to overwrite when the existing
103
+ * cache has an unrecognized schema_version (forward-compat — a v3 cache
104
+ * from a future rea version stays intact for that version to read).
105
+ */
106
+ export declare function writeVerdict(baseDir: string, headSha: string, entry: VerdictCacheEntry): Promise<void>;
80
107
  /**
81
108
  * Remove a single SHA from the cache. Returns true if the entry existed.
82
109
  */
83
- export declare function clearVerdict(baseDir: string, headSha: string): boolean;
110
+ export declare function clearVerdict(baseDir: string, headSha: string): Promise<boolean>;
84
111
  /**
85
112
  * Remove ALL entries from the cache. Returns the count of removed entries.
86
113
  */
87
- export declare function clearAll(baseDir: string): number;
114
+ export declare function clearAll(baseDir: string): Promise<number>;
88
115
  /**
89
116
  * Remove entries whose `reviewed_at` is older than `olderThanMs` from `now`.
90
117
  * Returns the count of removed entries.
91
118
  */
92
- export declare function pruneOlderThan(baseDir: string, olderThanMs: number, now?: Date): number;
119
+ export declare function pruneOlderThan(baseDir: string, olderThanMs: number, now?: Date): Promise<number>;
93
120
  /**
94
121
  * Read all entries (used by `rea cache stats` / `rea cache show`).
95
122
  * Returns empty object on any read error (missing file, malformed JSON,
96
123
  * unsupported schema_version).
97
124
  */
98
125
  export declare function listEntries(baseDir: string): Record<string, VerdictCacheEntry>;
126
+ /**
127
+ * Thrown by writeVerdict when the existing cache file has an
128
+ * unrecognized schema_version. The caller (push-gate) catches this
129
+ * and treats the write as best-effort failure (log to stderr,
130
+ * continue) rather than overwriting forward-compat data.
131
+ */
132
+ export declare class VerdictCacheForeignSchemaError extends Error {
133
+ readonly cachePath: string;
134
+ readonly kind: "foreign-schema";
135
+ constructor(cachePath: string);
136
+ }
@@ -37,14 +37,41 @@
37
37
  * audit log alone (helixir #8).
38
38
  * - REA_SKIP_CODEX_REVIEW short-circuits BEFORE cache lookup (unchanged).
39
39
  *
40
+ * 0.19.0 review fixes:
41
+ * - Concurrent writes are now serialized via `withAuditLock` on the
42
+ * `.rea/` directory (backend-engineer P1-2; security M3). Two
43
+ * concurrent push-gate runs no longer race read-modify-write.
44
+ * - Tmp filenames carry a high-entropy suffix (PID + millis + random)
45
+ * and are unlinked in finally so a crash mid-write doesn't leave
46
+ * stale state (backend-engineer P1-3; code-reviewer P2-1).
47
+ * - All three writers (writeVerdict, clearVerdict, pruneOlderThan,
48
+ * clearAll) route through one `_atomicWrite` helper — no asymmetry
49
+ * between paths (code-reviewer P2-2).
50
+ * - On unrecognized schema_version, reads return undefined AND
51
+ * writes refuse to overwrite — the v3 cache stays intact for a
52
+ * future rea version that knows how to read it (code-reviewer P3-5;
53
+ * backend-engineer P2-2).
54
+ *
40
55
  * The cache is OPTIONAL by design: existing callers that don't pass a
41
56
  * `cacheImpl` get the legacy stateless path. Tests inject a fake.
42
57
  */
58
+ import crypto from 'node:crypto';
43
59
  import fs from 'node:fs';
44
60
  import path from 'node:path';
61
+ import { withAuditLock } from '../../audit/fs.js';
45
62
  export const VERDICT_CACHE_FILE = 'last-review.cache.json';
46
63
  export const VERDICT_CACHE_SCHEMA_VERSION = 2;
47
64
  export const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1_000; // 24h
65
+ /**
66
+ * Soft cap on cached entry count before `writeVerdict` opportunistically
67
+ * prunes expired entries (0.19.1 backend-engineer P2-3). Keeps the
68
+ * cache file from growing unbounded over months of pushes against many
69
+ * SHAs — at 500 entries × ~200 bytes/entry ≈ 100 KB, we proactively
70
+ * drop expired entries on the next write. The prune is best-effort:
71
+ * if every entry is unexpired we accept the larger file rather than
72
+ * dropping fresh durable verdicts.
73
+ */
74
+ export const VERDICT_CACHE_PRUNE_THRESHOLD = 500;
48
75
  /**
49
76
  * Read the cache file and look up `head_sha`. Missing file, malformed
50
77
  * JSON, missing entry, and unsupported schema_version all resolve to a
@@ -66,25 +93,6 @@ export function lookupVerdict(baseDir, headSha, now = new Date()) {
66
93
  }
67
94
  return { hit: true, entry };
68
95
  }
69
- /**
70
- * Write a fresh verdict entry. Atomic via tmp-file + rename. Unrecognized
71
- * pre-existing entries are preserved (forward-compat for v3+).
72
- */
73
- export function writeVerdict(baseDir, headSha, entry) {
74
- const reaDir = path.join(baseDir, '.rea');
75
- if (!fs.existsSync(reaDir)) {
76
- fs.mkdirSync(reaDir, { recursive: true });
77
- }
78
- const cachePath = path.join(reaDir, VERDICT_CACHE_FILE);
79
- const existing = readCacheFile(baseDir);
80
- const next = {
81
- schema_version: VERDICT_CACHE_SCHEMA_VERSION,
82
- entries: { ...(existing?.entries ?? {}), [headSha]: entry },
83
- };
84
- const tmp = `${cachePath}.tmp.${process.pid}`;
85
- fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
86
- fs.renameSync(tmp, cachePath);
87
- }
88
96
  /**
89
97
  * Detect whether a new verdict contradicts a previously-cached verdict
90
98
  * on the same SHA. Used by `runPushGate` to set the flip-flag on
@@ -95,68 +103,126 @@ export function isFlip(prior, fresh) {
95
103
  return false;
96
104
  return prior.verdict !== fresh;
97
105
  }
106
+ /**
107
+ * Write a fresh verdict entry. Atomic via tmp-file + rename, serialized
108
+ * via `withAuditLock` on `.rea/`. Refuses to overwrite when the existing
109
+ * cache has an unrecognized schema_version (forward-compat — a v3 cache
110
+ * from a future rea version stays intact for that version to read).
111
+ */
112
+ export async function writeVerdict(baseDir, headSha, entry) {
113
+ const reaDir = path.join(baseDir, '.rea');
114
+ if (!fs.existsSync(reaDir)) {
115
+ fs.mkdirSync(reaDir, { recursive: true });
116
+ }
117
+ const cachePath = path.join(reaDir, VERDICT_CACHE_FILE);
118
+ await withAuditLock(cachePath, async () => {
119
+ if (foreignSchemaPresent(baseDir)) {
120
+ throw new VerdictCacheForeignSchemaError(cachePath);
121
+ }
122
+ const existing = readCacheFile(baseDir);
123
+ const merged = {
124
+ ...(existing?.entries ?? {}),
125
+ [headSha]: entry,
126
+ };
127
+ // 0.19.1 P2-3 (backend-engineer): opportunistic prune.
128
+ // When the cache crosses the soft threshold, drop entries whose
129
+ // own ttl_ms has expired before writing. Cheap walk; bounded to
130
+ // O(n) at write time only when n > threshold. Prevents the cache
131
+ // file from growing unbounded over months of pushes.
132
+ const pruned = Object.keys(merged).length > VERDICT_CACHE_PRUNE_THRESHOLD
133
+ ? _pruneExpired(merged, new Date())
134
+ : merged;
135
+ const next = {
136
+ schema_version: VERDICT_CACHE_SCHEMA_VERSION,
137
+ entries: pruned,
138
+ };
139
+ _atomicWriteJson(cachePath, next);
140
+ });
141
+ }
142
+ function _pruneExpired(entries, now) {
143
+ const surviving = {};
144
+ const nowMs = now.getTime();
145
+ for (const [sha, entry] of Object.entries(entries)) {
146
+ const reviewedAtMs = Date.parse(entry.reviewed_at);
147
+ if (Number.isNaN(reviewedAtMs)) {
148
+ surviving[sha] = entry;
149
+ continue;
150
+ }
151
+ if (nowMs - reviewedAtMs < entry.ttl_ms) {
152
+ surviving[sha] = entry;
153
+ }
154
+ }
155
+ return surviving;
156
+ }
98
157
  /**
99
158
  * Remove a single SHA from the cache. Returns true if the entry existed.
100
159
  */
101
- export function clearVerdict(baseDir, headSha) {
102
- const file = readCacheFile(baseDir);
103
- if (file === undefined || file.entries[headSha] === undefined)
104
- return false;
105
- const next = {
106
- schema_version: VERDICT_CACHE_SCHEMA_VERSION,
107
- entries: { ...file.entries },
108
- };
109
- delete next.entries[headSha];
160
+ export async function clearVerdict(baseDir, headSha) {
110
161
  const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
111
- fs.writeFileSync(cachePath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
112
- return true;
162
+ return withAuditLock(cachePath, async () => {
163
+ const file = readCacheFile(baseDir);
164
+ if (file === undefined || file.entries[headSha] === undefined)
165
+ return false;
166
+ const next = {
167
+ schema_version: VERDICT_CACHE_SCHEMA_VERSION,
168
+ entries: { ...file.entries },
169
+ };
170
+ delete next.entries[headSha];
171
+ _atomicWriteJson(cachePath, next);
172
+ return true;
173
+ });
113
174
  }
114
175
  /**
115
176
  * Remove ALL entries from the cache. Returns the count of removed entries.
116
177
  */
117
- export function clearAll(baseDir) {
118
- const file = readCacheFile(baseDir);
119
- const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
120
- const count = file === undefined ? 0 : Object.keys(file.entries).length;
121
- const empty = {
122
- schema_version: VERDICT_CACHE_SCHEMA_VERSION,
123
- entries: {},
124
- };
125
- if (!fs.existsSync(path.dirname(cachePath))) {
126
- fs.mkdirSync(path.dirname(cachePath), { recursive: true });
178
+ export async function clearAll(baseDir) {
179
+ const reaDir = path.join(baseDir, '.rea');
180
+ const cachePath = path.join(reaDir, VERDICT_CACHE_FILE);
181
+ if (!fs.existsSync(reaDir)) {
182
+ fs.mkdirSync(reaDir, { recursive: true });
127
183
  }
128
- fs.writeFileSync(cachePath, `${JSON.stringify(empty, null, 2)}\n`, 'utf8');
129
- return count;
184
+ return withAuditLock(cachePath, async () => {
185
+ const file = readCacheFile(baseDir);
186
+ const count = file === undefined ? 0 : Object.keys(file.entries).length;
187
+ const empty = {
188
+ schema_version: VERDICT_CACHE_SCHEMA_VERSION,
189
+ entries: {},
190
+ };
191
+ _atomicWriteJson(cachePath, empty);
192
+ return count;
193
+ });
130
194
  }
131
195
  /**
132
196
  * Remove entries whose `reviewed_at` is older than `olderThanMs` from `now`.
133
197
  * Returns the count of removed entries.
134
198
  */
135
- export function pruneOlderThan(baseDir, olderThanMs, now = new Date()) {
136
- const file = readCacheFile(baseDir);
137
- if (file === undefined)
138
- return 0;
139
- const cutoff = now.getTime() - olderThanMs;
140
- const surviving = {};
141
- let removed = 0;
142
- for (const [sha, entry] of Object.entries(file.entries)) {
143
- const reviewedAtMs = Date.parse(entry.reviewed_at);
144
- if (Number.isNaN(reviewedAtMs) || reviewedAtMs >= cutoff) {
145
- surviving[sha] = entry;
146
- }
147
- else {
148
- removed += 1;
149
- }
150
- }
151
- if (removed === 0)
152
- return 0;
153
- const next = {
154
- schema_version: VERDICT_CACHE_SCHEMA_VERSION,
155
- entries: surviving,
156
- };
199
+ export async function pruneOlderThan(baseDir, olderThanMs, now = new Date()) {
157
200
  const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
158
- fs.writeFileSync(cachePath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
159
- return removed;
201
+ return withAuditLock(cachePath, async () => {
202
+ const file = readCacheFile(baseDir);
203
+ if (file === undefined)
204
+ return 0;
205
+ const cutoff = now.getTime() - olderThanMs;
206
+ const surviving = {};
207
+ let removed = 0;
208
+ for (const [sha, entry] of Object.entries(file.entries)) {
209
+ const reviewedAtMs = Date.parse(entry.reviewed_at);
210
+ if (Number.isNaN(reviewedAtMs) || reviewedAtMs >= cutoff) {
211
+ surviving[sha] = entry;
212
+ }
213
+ else {
214
+ removed += 1;
215
+ }
216
+ }
217
+ if (removed === 0)
218
+ return 0;
219
+ const next = {
220
+ schema_version: VERDICT_CACHE_SCHEMA_VERSION,
221
+ entries: surviving,
222
+ };
223
+ _atomicWriteJson(cachePath, next);
224
+ return removed;
225
+ });
160
226
  }
161
227
  /**
162
228
  * Read all entries (used by `rea cache stats` / `rea cache show`).
@@ -167,18 +233,43 @@ export function listEntries(baseDir) {
167
233
  const file = readCacheFile(baseDir);
168
234
  return file?.entries ?? {};
169
235
  }
236
+ /**
237
+ * Thrown by writeVerdict when the existing cache file has an
238
+ * unrecognized schema_version. The caller (push-gate) catches this
239
+ * and treats the write as best-effort failure (log to stderr,
240
+ * continue) rather than overwriting forward-compat data.
241
+ */
242
+ export class VerdictCacheForeignSchemaError extends Error {
243
+ cachePath;
244
+ kind = 'foreign-schema';
245
+ constructor(cachePath) {
246
+ super(`Refused to overwrite ${cachePath}: existing cache has unrecognized schema_version. ` +
247
+ `Either delete the file or run with a newer rea that supports it.`);
248
+ this.cachePath = cachePath;
249
+ this.name = 'VerdictCacheForeignSchemaError';
250
+ }
251
+ }
170
252
  function readCacheFile(baseDir) {
253
+ const parsed = readForeignCacheFile(baseDir);
254
+ if (parsed === undefined)
255
+ return undefined;
256
+ if (parsed.schema_version !== VERDICT_CACHE_SCHEMA_VERSION)
257
+ return undefined;
258
+ // We checked schema_version exactly; entries shape is the v2 contract.
259
+ return parsed;
260
+ }
261
+ function readForeignCacheFile(baseDir) {
171
262
  const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
172
263
  if (!fs.existsSync(cachePath))
173
264
  return undefined;
174
265
  try {
175
266
  const raw = fs.readFileSync(cachePath, 'utf8');
176
267
  const parsed = JSON.parse(raw);
177
- if (typeof parsed !== 'object' ||
178
- parsed === null ||
179
- parsed.schema_version !== VERDICT_CACHE_SCHEMA_VERSION) {
268
+ if (typeof parsed !== 'object' || parsed === null)
269
+ return undefined;
270
+ const sv = parsed.schema_version;
271
+ if (typeof sv !== 'number')
180
272
  return undefined;
181
- }
182
273
  const entries = parsed.entries;
183
274
  if (typeof entries !== 'object' || entries === null)
184
275
  return undefined;
@@ -188,3 +279,35 @@ function readCacheFile(baseDir) {
188
279
  return undefined;
189
280
  }
190
281
  }
282
+ function foreignSchemaPresent(baseDir) {
283
+ const parsed = readForeignCacheFile(baseDir);
284
+ if (parsed === undefined)
285
+ return false;
286
+ return parsed.schema_version !== VERDICT_CACHE_SCHEMA_VERSION;
287
+ }
288
+ /**
289
+ * Atomic JSON write: stringify → write tmp → fsync → rename.
290
+ *
291
+ * Tmp filename: `${target}.tmp.${pid}.${ms}.${random8}` — collision-
292
+ * resistant under concurrent writes, PID reuse, and same-process
293
+ * parallel calls. On any failure, the tmp file is unlinked so a crash
294
+ * mid-write doesn't leave stale state.
295
+ */
296
+ function _atomicWriteJson(targetPath, payload) {
297
+ const tmp = `${targetPath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
298
+ try {
299
+ fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
300
+ fs.renameSync(tmp, targetPath);
301
+ }
302
+ catch (e) {
303
+ try {
304
+ if (fs.existsSync(tmp))
305
+ fs.unlinkSync(tmp);
306
+ }
307
+ catch {
308
+ // Tmp already gone or unlink failed — caller's error is the
309
+ // important signal.
310
+ }
311
+ throw e;
312
+ }
313
+ }
@@ -178,6 +178,13 @@ declare const PolicySchema: z.ZodObject<{
178
178
  expose_diagnostics?: boolean | undefined;
179
179
  } | undefined;
180
180
  }>>;
181
+ architecture_review: z.ZodOptional<z.ZodObject<{
182
+ patterns: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
183
+ }, "strip", z.ZodTypeAny, {
184
+ patterns?: string[] | undefined;
185
+ }, {
186
+ patterns?: string[] | undefined;
187
+ }>>;
181
188
  }, "strict", z.ZodTypeAny, {
182
189
  version: string;
183
190
  profile: string;
@@ -228,6 +235,9 @@ declare const PolicySchema: z.ZodObject<{
228
235
  expose_diagnostics?: boolean | undefined;
229
236
  } | undefined;
230
237
  } | undefined;
238
+ architecture_review?: {
239
+ patterns?: string[] | undefined;
240
+ } | undefined;
231
241
  }, {
232
242
  version: string;
233
243
  profile: string;
@@ -278,6 +288,9 @@ declare const PolicySchema: z.ZodObject<{
278
288
  expose_diagnostics?: boolean | undefined;
279
289
  } | undefined;
280
290
  } | undefined;
291
+ architecture_review?: {
292
+ patterns?: string[] | undefined;
293
+ } | undefined;
281
294
  }>;
282
295
  /**
283
296
  * Async policy loader with TTL cache and mtime-based invalidation.
@@ -58,7 +58,12 @@ const ReviewPolicySchema = z
58
58
  * NOT want to lock consumers to a hardcoded enum that drifts behind
59
59
  * upstream. Codex itself validates the model name at exec time.
60
60
  */
61
- codex_model: z.string().min(1).optional(),
61
+ // 0.19.0 security review M4: restrict to a safe character class so
62
+ // a typo or malicious value can't smuggle TOML control characters
63
+ // (NUL, NL, CR, escape sequences) through the `-c model="<value>"`
64
+ // injection point. Accepts published codex model names; rejects
65
+ // re-quote / TOML-escape edge cases.
66
+ codex_model: z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/).optional(),
62
67
  /**
63
68
  * Codex reasoning effort knob (0.13.4+). Pinned via
64
69
  * `-c model_reasoning_effort="<level>"` on every invocation. Only
@@ -195,6 +200,17 @@ const PolicySchema = z
195
200
  redact: RedactPolicySchema.optional(),
196
201
  audit: AuditPolicySchema.optional(),
197
202
  gateway: GatewayPolicySchema.optional(),
203
+ // 0.20.1 helix-round-N P2: architecture-review-gate.sh patterns
204
+ // are now policy-driven. Pre-fix the hook hardcoded rea-internal
205
+ // source-tree patterns (`src/gateway/`, `hooks/_lib/`, etc.) which
206
+ // produced irrelevant advisory output in consumer projects.
207
+ // Empty (or unset) → silent no-op. bst-internal profile pins the
208
+ // rea-source patterns so dogfood behaves as before.
209
+ architecture_review: z
210
+ .object({
211
+ patterns: z.array(z.string()).optional(),
212
+ })
213
+ .optional(),
198
214
  })
199
215
  .strict();
200
216
  const DEFAULT_CACHE_TTL_MS = 30_000;
@@ -69,6 +69,13 @@ export declare const ProfileSchema: z.ZodObject<{
69
69
  max_age_days?: number | undefined;
70
70
  } | undefined;
71
71
  }>>;
72
+ architecture_review: z.ZodOptional<z.ZodObject<{
73
+ patterns: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
74
+ }, "strip", z.ZodTypeAny, {
75
+ patterns?: string[] | undefined;
76
+ }, {
77
+ patterns?: string[] | undefined;
78
+ }>>;
72
79
  }, "strict", z.ZodTypeAny, {
73
80
  autonomy_level?: AutonomyLevel | undefined;
74
81
  max_autonomy_level?: AutonomyLevel | undefined;
@@ -92,6 +99,9 @@ export declare const ProfileSchema: z.ZodObject<{
92
99
  max_age_days?: number | undefined;
93
100
  } | undefined;
94
101
  } | undefined;
102
+ architecture_review?: {
103
+ patterns?: string[] | undefined;
104
+ } | undefined;
95
105
  }, {
96
106
  autonomy_level?: AutonomyLevel | undefined;
97
107
  max_autonomy_level?: AutonomyLevel | undefined;
@@ -115,6 +125,9 @@ export declare const ProfileSchema: z.ZodObject<{
115
125
  max_age_days?: number | undefined;
116
126
  } | undefined;
117
127
  } | undefined;
128
+ architecture_review?: {
129
+ patterns?: string[] | undefined;
130
+ } | undefined;
118
131
  }>;
119
132
  export type Profile = z.infer<typeof ProfileSchema>;
120
133
  /** Hard defaults applied before any profile or wizard answer. */
@@ -69,6 +69,12 @@ export const ProfileSchema = z
69
69
  .optional(),
70
70
  })
71
71
  .optional(),
72
+ // 0.20.1+ profiles can declare architecture-sensitive paths.
73
+ architecture_review: z
74
+ .object({
75
+ patterns: z.array(z.string()).optional(),
76
+ })
77
+ .optional(),
72
78
  })
73
79
  .strict();
74
80
  /** Hard defaults applied before any profile or wizard answer. */
@@ -289,4 +289,16 @@ export interface Policy {
289
289
  redact?: RedactPolicy;
290
290
  audit?: AuditPolicy;
291
291
  gateway?: GatewayPolicy;
292
+ /**
293
+ * Architecture-review patterns (0.20.1+). When set, the
294
+ * `architecture-review-gate.sh` hook fires an advisory when a
295
+ * Write/Edit/MultiEdit/NotebookEdit lands on a path matching one
296
+ * of the patterns. When unset or empty, the hook is a silent no-op
297
+ * — consumers without architecture-sensitive paths see zero noise.
298
+ * bst-internal profile pins rea's own source-tree patterns
299
+ * (`src/gateway/`, `hooks/_lib/`, etc.).
300
+ */
301
+ architecture_review?: {
302
+ patterns?: string[];
303
+ };
292
304
  }
@@ -181,7 +181,14 @@ _rea_unwrap_nested_shells() {
181
181
  # alternation `(^|[[:space:]&|;])` therefore cannot anchor on a
182
182
  # masked separator, and the shell-name token itself can no longer
183
183
  # appear adjacent to a masked quote-introducer.
184
- WRAP = "(^|[[:space:]&|;])(bash|sh|zsh|dash|ksh)([[:space:]]+-[a-zA-Z]+)*[[:space:]]+-(c|lc|lic|ic|cl|cli|li|il)[[:space:]]+"
184
+ # 0.19.0 security review M1: extend the shell-name set to cover
185
+ # every commonly-installed POSIX-style shell. mksh / oksh / yash /
186
+ # posh ship on minimal containers, csh/tcsh on legacy macOS,
187
+ # fish on dev workstations. Each accepts -c with a quoted body.
188
+ # NOTE: pwsh (PowerShell) uses -Command / -EncodedCommand and is
189
+ # NOT covered here. Adding pwsh requires a separate code path
190
+ # because EncodedCommand base64-decodes at runtime.
191
+ WRAP = "(^|[[:space:]&|;])(bash|sh|zsh|dash|ksh|mksh|oksh|posh|yash|csh|tcsh|fish)([[:space:]]+-[a-zA-Z]+)*[[:space:]]+-(c|lc|lic|ic|cl|cli|li|il)[[:space:]]+"
185
192
  # Track the cursor in BOTH raw and masked. Because the mask is
186
193
  # byte-for-byte width-preserving, the same RSTART/RLENGTH applies
187
194
  # to both — but each iteration of the loop must SLICE both strings
@@ -70,3 +70,78 @@ resolve_parent_realpath() {
70
70
  resolved=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved=""
71
71
  printf '%s' "$resolved"
72
72
  }
73
+
74
+ # 0.20.1 helix-021 fixes: shared helper for the Bash-tier symlink
75
+ # resolution that the Write-tier `blocked-paths-enforcer.sh` has had
76
+ # since 0.10.x. Given a project-relative LOGICAL_PATH (already
77
+ # normalized via normalize_path) and the original raw token (whose
78
+ # parent dir may exist on disk), return the resolved-symlink
79
+ # project-relative form on stdout.
80
+ #
81
+ # Returns:
82
+ # - The empty string if the parent doesn't exist (caller can't
83
+ # resolve, falls back to LOGICAL_PATH only).
84
+ # - A literal `__rea_outside_root__:<resolved>` sentinel when the
85
+ # parent's realpath escapes REA_ROOT. Caller refuses with the
86
+ # same shape as the existing outside-REA_ROOT check.
87
+ # - The project-relative resolved form (lowercased to match
88
+ # case-insensitive comparisons elsewhere) when resolution
89
+ # succeeds.
90
+ #
91
+ # Reference:
92
+ # `blocked-paths-enforcer.sh` lines ~205-238 for the Write-tier
93
+ # reference implementation that this helper backports to Bash-tier.
94
+ rea_resolved_relative_form() {
95
+ local raw_token="$1"
96
+ # Skip absolute paths whose logical form is already outside REA_ROOT
97
+ # — `/tmp/log`, `/var/log/x`, etc. The caller's logical-path check
98
+ # has already decided whether to allow or refuse based on the
99
+ # logical form. Re-running symlink resolution on these would
100
+ # produce a false "symlink resolves outside project root" refusal
101
+ # (because `/tmp` resolves to `/private/tmp` on macOS, which is
102
+ # technically outside REA_ROOT). The threat model for THIS helper
103
+ # is intra-project symlink walks: a path the caller thinks is
104
+ # under REA_ROOT but resolves elsewhere via an intermediate
105
+ # symlink. Pure external paths are out of scope.
106
+ if [[ "$raw_token" == /* ]]; then
107
+ # Canonicalize REA_ROOT for the comparison.
108
+ local rea_root_canon_for_skip
109
+ rea_root_canon_for_skip=$(cd -P -- "$REA_ROOT" 2>/dev/null && pwd -P 2>/dev/null) || rea_root_canon_for_skip="$REA_ROOT"
110
+ if [[ "$raw_token" != "$rea_root_canon_for_skip"/* && "$raw_token" != "$REA_ROOT"/* ]]; then
111
+ printf ''
112
+ return 0
113
+ fi
114
+ fi
115
+ local resolved_parent
116
+ resolved_parent=$(resolve_parent_realpath "$raw_token")
117
+ if [[ -z "$resolved_parent" ]]; then
118
+ printf ''
119
+ return 0
120
+ fi
121
+ # Canonicalize REA_ROOT the same way `pwd -P` canonicalized
122
+ # `resolved_parent`. macOS resolves `/var/folders/...` to
123
+ # `/private/var/folders/...` because `/var` is a symlink to
124
+ # `/private/var`; without this normalization the prefix-equality
125
+ # below produces a false outside-REA_ROOT sentinel for every path
126
+ # under a tmpdir that started life as `/var/...`. Memo-friendly:
127
+ # `cd -P` runs once per hook invocation; the cost is bounded.
128
+ local rea_root_canon
129
+ rea_root_canon=$(cd -P -- "$REA_ROOT" 2>/dev/null && pwd -P 2>/dev/null) || rea_root_canon="$REA_ROOT"
130
+ # Outside-REA_ROOT guard. The resolve may walk a symlink that exits
131
+ # the project tree entirely; emit the sentinel so the caller
132
+ # refuses with the same wording as the logical-path traversal
133
+ # check.
134
+ if [[ "$resolved_parent" != "$rea_root_canon" && "$resolved_parent" != "$rea_root_canon"/* ]]; then
135
+ printf '__rea_outside_root__:%s/%s' "$resolved_parent" "$(basename -- "$raw_token")"
136
+ return 0
137
+ fi
138
+ # Strip canonical REA_ROOT prefix, append basename, lowercase to
139
+ # match rea_path_is_protected's case-insensitive comparison.
140
+ local rel
141
+ if [[ "$resolved_parent" == "$rea_root_canon" ]]; then
142
+ rel="$(basename -- "$raw_token")"
143
+ else
144
+ rel="${resolved_parent#"$rea_root_canon"/}/$(basename -- "$raw_token")"
145
+ fi
146
+ printf '%s' "$rel" | tr '[:upper:]' '[:lower:]'
147
+ }
@@ -45,6 +45,15 @@ REA_PROTECTED_PATTERNS_FULL=(
45
45
  '.husky/'
46
46
  '.rea/policy.yaml'
47
47
  '.rea/HALT'
48
+ # 0.19.0 security review C1: the verdict cache is a security boundary
49
+ # since 0.18.1. A forged entry would skip codex on next push of that
50
+ # SHA. Protect it like the kill-switch.
51
+ '.rea/last-review.cache.json'
52
+ # 0.20.1 round-N P1: last-review.json is the operator's only forensic
53
+ # snapshot of the most recent codex review. A forged entry presents
54
+ # a fake "PASS" verdict to operators reading the file directly, and
55
+ # to any future tooling that consults it. Protect alongside the cache.
56
+ '.rea/last-review.json'
48
57
  )
49
58
 
50
59
  # Kill-switch invariants — never relaxable. Subset of FULL.
@@ -52,6 +61,8 @@ REA_KILL_SWITCH_INVARIANTS=(
52
61
  '.claude/settings.json'
53
62
  '.rea/policy.yaml'
54
63
  '.rea/HALT'
64
+ '.rea/last-review.cache.json'
65
+ '.rea/last-review.json'
55
66
  )
56
67
 
57
68
  # Effective patterns after applying the relax list. Computed lazily on
@@ -50,15 +50,29 @@ source "$(dirname "$0")/_lib/path-normalize.sh"
50
50
  FILE_PATH=$(normalize_path "$FILE_PATH")
51
51
 
52
52
  # ── 6. Check architecture-sensitive paths ─────────────────────────────────────
53
- ARCH_PATTERNS=(
54
- 'src/types/'
55
- 'src/gateway/'
56
- 'src/config/'
57
- 'src/cli/commands/init/'
58
- 'hooks/_lib/'
59
- 'templates/'
60
- 'profiles/'
61
- )
53
+ # 0.20.1 helix-round-N P2: read patterns from policy. Pre-fix the
54
+ # rea-internal source-tree patterns (`src/gateway/`, `hooks/_lib/`,
55
+ # `profiles/`, etc.) shipped as hardcoded defaults — irrelevant noise
56
+ # in consumer projects whose architecture-sensitive paths are
57
+ # different. Consumers with their own architecture surfaces declare
58
+ # them in `.rea/policy.yaml::architecture_review.patterns`. The
59
+ # bst-internal profile pins the rea-source patterns so the dogfood
60
+ # install behaves the same as before; consumers without a pattern
61
+ # set get a silent no-op.
62
+ # shellcheck source=_lib/policy-read.sh
63
+ source "$(dirname "$0")/_lib/policy-read.sh"
64
+
65
+ ARCH_PATTERNS=()
66
+ while IFS= read -r entry; do
67
+ [[ -z "$entry" ]] && continue
68
+ ARCH_PATTERNS+=("$entry")
69
+ done < <(policy_list "architecture_review.patterns" 2>/dev/null || true)
70
+
71
+ if [[ ${#ARCH_PATTERNS[@]} -eq 0 ]]; then
72
+ # Empty/unset policy → silent no-op. Consumers who haven't declared
73
+ # architecture-sensitive paths see zero advisory output.
74
+ exit 0
75
+ fi
62
76
 
63
77
  MATCHED=""
64
78
  for pattern in "${ARCH_PATTERNS[@]}"; do
@@ -102,7 +102,7 @@ FOUND=0
102
102
  # below catches Co-Authored-By with named tools regardless of the email
103
103
  # domain, so dropping `users.noreply.github.com` from the noreply
104
104
  # pattern only relaxes the check for human collaborators — never for AI.
105
- if any_segment_matches "$CMD" 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com)'; then
105
+ if any_segment_matches "$CMD" 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com|mistral\.ai|xai-org|x\.ai|inflection\.ai|perplexity\.ai|replit\.com|jetbrains\.com|bito\.ai|pieces\.app|phind\.com|you\.com)'; then
106
106
  FOUND=1
107
107
  fi
108
108
 
@@ -161,6 +161,13 @@ _refuse() {
161
161
  }
162
162
 
163
163
  # Check a single resolved-target token. Refuses on hit.
164
+ #
165
+ # 0.20.1 helix-021 #2: in addition to the logical post-_normalize_target
166
+ # form, also check the symlink-resolved form. Pre-fix `ln -s . linkroot;
167
+ # printf x > linkroot/.env` had a logical form of `linkroot/.env`
168
+ # (no match against blocked_paths) but a resolved form of `.env`
169
+ # (which DOES match). Refuse on either match. Write-tier
170
+ # `blocked-paths-enforcer.sh` already has this resolution since 0.10.x.
164
171
  _check_token() {
165
172
  local token="$1" segment="$2"
166
173
  [[ -z "$token" ]] && return 0
@@ -172,9 +179,21 @@ _check_token() {
172
179
  # outside-root rejection on the protected list itself.
173
180
  return 0
174
181
  fi
182
+ # Symlink-resolved form via shared helper. Returns empty when the
183
+ # parent doesn't exist (legitimate "creating the parent" case);
184
+ # outside-REA_ROOT sentinel when the symlink walks out of the
185
+ # project (silently allow — same as the logical-path branch above).
186
+ local resolved_symlink
187
+ resolved_symlink=$(rea_resolved_relative_form "$token")
188
+ if [[ "$resolved_symlink" == __rea_outside_root__:* ]]; then
189
+ resolved_symlink=""
190
+ fi
175
191
  if _match_blocked "$resolved"; then
176
192
  _refuse "$MATCHED" "$resolved" "$segment"
177
193
  fi
194
+ if [[ -n "$resolved_symlink" ]] && _match_blocked "$resolved_symlink"; then
195
+ _refuse "$MATCHED" "$resolved_symlink" "$segment"
196
+ fi
178
197
  return 0
179
198
  }
180
199
 
@@ -27,6 +27,8 @@ set -uo pipefail
27
27
 
28
28
  # shellcheck source=_lib/protected-paths.sh
29
29
  source "$(dirname "$0")/_lib/protected-paths.sh"
30
+ # shellcheck source=_lib/path-normalize.sh
31
+ source "$(dirname "$0")/_lib/path-normalize.sh"
30
32
  # shellcheck source=_lib/cmd-segments.sh
31
33
  source "$(dirname "$0")/_lib/cmd-segments.sh"
32
34
 
@@ -235,7 +237,7 @@ _check_segment() {
235
237
  # walking — there may be more positional args.
236
238
  local _t
237
239
  _t=$(_normalize_target "$target_token")
238
- # 0.16.0 codex P2-3: outside-REA_ROOT sentinel handling.
240
+ # 0.16.0 codex P2-3: outside-REA_ROOT sentinel handling (logical).
239
241
  if [[ "$_t" == __rea_outside_root__:* ]]; then
240
242
  local resolved="${_t#__rea_outside_root__:}"
241
243
  {
@@ -244,15 +246,37 @@ _check_segment() {
244
246
  } >&2
245
247
  exit 2
246
248
  fi
247
- if rea_path_is_protected "$_t"; then
249
+ # 0.20.1 helix-021 #1: resolve intermediate symlinks via
250
+ # `cd -P / pwd -P` parent-canonicalization (Write-tier parity).
251
+ # `ln -s ../ .husky/pre-push.d/linkdir; printf x > .husky/pre-push.d/linkdir/pre-push`
252
+ # had a logical form of `.husky/pre-push.d/linkdir/pre-push`
253
+ # that didn't match any protected pattern; the resolved form
254
+ # is `.husky/pre-push` which DOES match. Refuse on either.
255
+ local _t_resolved
256
+ _t_resolved=$(rea_resolved_relative_form "$target_token")
257
+ if [[ "$_t_resolved" == __rea_outside_root__:* ]]; then
258
+ local resolved="${_t_resolved#__rea_outside_root__:}"
259
+ {
260
+ printf 'PROTECTED PATH (bash): symlink resolves outside project root\n'
261
+ printf ' Logical: %s\n Resolved: %s\n' "$target_token" "$resolved"
262
+ } >&2
263
+ exit 2
264
+ fi
265
+ if rea_path_is_protected "$_t" \
266
+ || ([[ -n "$_t_resolved" ]] && rea_path_is_protected "$_t_resolved"); then
248
267
  local matched=""
249
268
  local pattern_lc
269
+ local hit_form="$_t"
270
+ if [[ -n "$_t_resolved" ]] && rea_path_is_protected "$_t_resolved" \
271
+ && ! rea_path_is_protected "$_t"; then
272
+ hit_form="$_t_resolved"
273
+ fi
250
274
  for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
251
275
  pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
252
- if [[ "$_t" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
253
- if [[ "$pattern_lc" == */ && "$_t" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
276
+ if [[ "$hit_form" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
277
+ if [[ "$pattern_lc" == */ && "$hit_form" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
254
278
  done
255
- _refuse "$matched" "$_t" "$segment"
279
+ _refuse "$matched" "$hit_form" "$segment"
256
280
  fi
257
281
  # Reset target_token so the post-loop check doesn't double-check.
258
282
  target_token=""
@@ -283,17 +307,38 @@ _check_segment() {
283
307
  } >&2
284
308
  exit 2
285
309
  fi
286
- if rea_path_is_protected "$target"; then
310
+ # 0.20.1 helix-021 #1: resolve intermediate symlinks. See parallel
311
+ # block in the multi-target loop above for the rationale.
312
+ local target_resolved
313
+ target_resolved=$(rea_resolved_relative_form "$target_token")
314
+ if [[ "$target_resolved" == __rea_outside_root__:* ]]; then
315
+ local resolved="${target_resolved#__rea_outside_root__:}"
316
+ {
317
+ printf 'PROTECTED PATH (bash): symlink resolves outside project root\n'
318
+ printf '\n'
319
+ printf ' Logical: %s\n' "$target_token"
320
+ printf ' Resolved: %s\n' "$resolved"
321
+ printf ' Segment: %s\n' "$segment"
322
+ } >&2
323
+ exit 2
324
+ fi
325
+ if rea_path_is_protected "$target" \
326
+ || ([[ -n "$target_resolved" ]] && rea_path_is_protected "$target_resolved"); then
287
327
  # Find the matching pattern for the error message. Both `target`
288
328
  # and `pattern` lowercased to match `_normalize_target`'s case-
289
329
  # insensitive output (helix-015 P1 fix).
290
330
  local matched="" pattern_lc
331
+ local hit_form="$target"
332
+ if [[ -n "$target_resolved" ]] && rea_path_is_protected "$target_resolved" \
333
+ && ! rea_path_is_protected "$target"; then
334
+ hit_form="$target_resolved"
335
+ fi
291
336
  for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
292
337
  pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
293
- if [[ "$target" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
294
- if [[ "$pattern_lc" == */ && "$target" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
338
+ if [[ "$hit_form" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
339
+ if [[ "$pattern_lc" == */ && "$hit_form" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
295
340
  done
296
- _refuse "$matched" "$target" "$segment"
341
+ _refuse "$matched" "$hit_form" "$segment"
297
342
  fi
298
343
  return 0
299
344
  }
@@ -199,8 +199,15 @@ case "$LOWER_NORM" in
199
199
  if [ -d "$parent_dir" ]; then
200
200
  resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
201
201
  if [ -n "$resolved_parent" ]; then
202
+ # 0.20.1 helix-021 #3: directory-boundary on the case glob.
203
+ # Pre-fix `*"/.husky/commit-msg.d"*` matched `.husky/commit-msg.d.bak/`
204
+ # too (substring without trailing-slash anchor). A symlink
205
+ # `.husky/pre-push.d/linkdir -> ../pre-push.d.bak` then resolved
206
+ # to `.husky/pre-push.d.bak/...` and slipped through.
207
+ # The trailing `/` on each pattern (and the explicit
208
+ # exact-match arm) requires a real directory boundary.
202
209
  case "$resolved_parent" in
203
- *"/.husky/commit-msg.d"*|*"/.husky/pre-push.d"*) : ;;
210
+ */.husky/commit-msg.d|*/.husky/commit-msg.d/*|*/.husky/pre-push.d|*/.husky/pre-push.d/*) : ;;
204
211
  *)
205
212
  {
206
213
  printf 'SETTINGS PROTECTION: extension path resolves outside surface\n'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
@@ -84,6 +84,7 @@
84
84
  "@typescript-eslint/eslint-plugin": "^8.0.0",
85
85
  "@typescript-eslint/parser": "^8.0.0",
86
86
  "@vitest/coverage-v8": "^3.2.4",
87
+ "ajv": "^8.17.1",
87
88
  "eslint": "^10.2.0",
88
89
  "prettier": "^3.8.1",
89
90
  "typescript": "^5.8.0",
@@ -36,3 +36,18 @@ context_protection:
36
36
  # hash chain across the boundary.
37
37
  audit:
38
38
  rotation: {}
39
+ # 0.20.1 helix-round-N P2: rea's own architecture-sensitive paths.
40
+ # Hardcoded into the hook before this release; now policy-driven so
41
+ # consumer projects don't get rea-source patterns advisory-warning on
42
+ # their own `src/types/` directories. Empty (or unset) on other
43
+ # profiles = silent no-op. Operators add their own
44
+ # architecture-sensitive paths here on a per-project basis.
45
+ architecture_review:
46
+ patterns:
47
+ - src/types/
48
+ - src/gateway/
49
+ - src/config/
50
+ - src/cli/commands/init/
51
+ - hooks/_lib/
52
+ - templates/
53
+ - profiles/
@@ -135,10 +135,17 @@ try {
135
135
  const reaCli = path.join(consumerRoot, 'node_modules', '.bin', 'rea');
136
136
  if (fs.existsSync(reaCli)) {
137
137
  const { spawnSync } = await import('node:child_process');
138
+ // 0.19.0 backend-engineer P2-1: 5-min wall-clock cap so a hung
139
+ // upgrade falls through to print-only instead of hanging the
140
+ // consumer's `npm install`. 0.19.0 code-reviewer P3-6:
141
+ // Windows shim (.bin/rea.cmd) requires `shell: true` —
142
+ // detect via process.platform.
138
143
  const res = spawnSync(reaCli, ['upgrade', '--yes'], {
139
144
  cwd: consumerRoot,
140
145
  stdio: 'inherit',
141
146
  env: process.env,
147
+ timeout: 5 * 60 * 1000,
148
+ shell: process.platform === 'win32',
142
149
  });
143
150
  if (res.status === 0) {
144
151
  NOTE([