@bookedsolid/rea 0.19.0 → 0.20.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
 
@@ -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,7 @@ 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
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_CACHE_HIT, fullPolicy, {
352
367
  verdict: cached.verdict,
353
368
  finding_count: cached.finding_count,
354
369
  base_ref: base.ref,
@@ -423,7 +438,7 @@ export async function runPushGate(deps) {
423
438
  if (policy.cache_ttl_ms > 0) {
424
439
  const flipped = isFlip(cacheLookup.entry, summary.verdict);
425
440
  if (flipped && cacheLookup.entry !== undefined) {
426
- await safeAppend(appendAuditFn, deps.baseDir, EVT_VERDICT_FLIP, {
441
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_VERDICT_FLIP, fullPolicy, {
427
442
  head_sha: headSha,
428
443
  prior_verdict: cacheLookup.entry.verdict,
429
444
  fresh_verdict: summary.verdict,
@@ -435,20 +450,22 @@ export async function runPushGate(deps) {
435
450
  verdict: summary.verdict,
436
451
  finding_count: summary.findings.length,
437
452
  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',
453
+ model: policy.codex_model ?? IRON_GATE_DEFAULT_MODEL,
454
+ reasoning_effort: policy.codex_reasoning_effort ?? IRON_GATE_DEFAULT_REASONING,
440
455
  ttl_ms: policy.cache_ttl_ms,
441
456
  };
442
457
  try {
443
- writeVerdict(deps.baseDir, headSha, entry);
458
+ await writeVerdict(deps.baseDir, headSha, entry);
444
459
  }
445
460
  catch {
446
461
  // Cache writes are best-effort. A failure here must NOT
447
462
  // affect the verdict — log to stderr (already done by the
448
- // caller via banner) and proceed.
463
+ // caller via banner) and proceed. Foreign-schema (v3+ cache
464
+ // from a future rea version) lands here and is correctly
465
+ // declined — overwriting would lose forward-compat data.
449
466
  }
450
467
  }
451
- await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, {
468
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_REVIEWED, fullPolicy, {
452
469
  verdict: summary.verdict,
453
470
  finding_count: summary.findings.length,
454
471
  base_ref: base.ref,
@@ -492,7 +509,7 @@ export async function runPushGate(deps) {
492
509
  };
493
510
  }
494
511
  catch (e) {
495
- return handleCodexError(e, deps, base, headSha, appendAuditFn);
512
+ return handleCodexError(e, deps, base, headSha, appendAuditFn, fullPolicy);
496
513
  }
497
514
  }
498
515
  function isConcernsOverrideSet(env) {
@@ -502,7 +519,7 @@ function isConcernsOverrideSet(env) {
502
519
  const normalized = raw.trim().toLowerCase();
503
520
  return normalized === '1' || normalized === 'true' || normalized === 'yes';
504
521
  }
505
- async function handleCodexError(e, deps, base, headSha, appendAuditFn) {
522
+ async function handleCodexError(e, deps, base, headSha, appendAuditFn, policy) {
506
523
  const stderr = deps.stderr;
507
524
  const runError = classifyCodexError(e);
508
525
  const metadata = {
@@ -514,7 +531,7 @@ async function handleCodexError(e, deps, base, headSha, appendAuditFn) {
514
531
  if (runError.message.length > 0)
515
532
  metadata.error = runError.message;
516
533
  stderr(`PUSH BLOCKED: ${runError.message}\n`);
517
- await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, metadata);
534
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, policy, metadata);
518
535
  return {
519
536
  status: 'error',
520
537
  exitCode: 2,
@@ -542,7 +559,7 @@ function classifyCodexError(e) {
542
559
  * its primary result. The hash chain remains intact if this succeeds; on
543
560
  * failure we've already made the gate decision based on the actual review.
544
561
  */
545
- async function safeAppend(appendFn, baseDir, toolName, metadata) {
562
+ async function safeAppend(appendFn, baseDir, toolName, policy, metadata) {
546
563
  try {
547
564
  // Prune undefined values — the audit record schema's `metadata` is an
548
565
  // arbitrary map, but `undefined` values cause JSON.stringify to emit
@@ -552,12 +569,19 @@ async function safeAppend(appendFn, baseDir, toolName, metadata) {
552
569
  if (v !== undefined)
553
570
  cleanMeta[k] = v;
554
571
  }
572
+ // 0.19.0 P1-1 fix (backend-engineer review): pass the loaded Policy
573
+ // through so `appendAuditRecord` → `maybeRotate` actually fires.
574
+ // Pre-fix the policy was never threaded; rotation short-circuited
575
+ // to `{ rotated: false }` on the entire push-gate audit-emission
576
+ // path, silently disabling the `audit.rotation: {}` opt-in shipped
577
+ // in 0.18.1 for the bst-internal profile.
555
578
  await appendFn(baseDir, {
556
579
  tool_name: toolName,
557
580
  server_name: AUDIT_SERVER_NAME,
558
581
  tier: Tier.Read,
559
582
  status: InvocationStatus.Allowed,
560
583
  ...(Object.keys(cleanMeta).length > 0 ? { metadata: cleanMeta } : {}),
584
+ ...(policy !== undefined ? { policy } : {}),
561
585
  });
562
586
  }
563
587
  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
  */
@@ -66,33 +81,46 @@ export interface VerdictCacheLookupResult {
66
81
  * miss with `entry: undefined` — the caller proceeds to codex.
67
82
  */
68
83
  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
84
  /**
75
85
  * Detect whether a new verdict contradicts a previously-cached verdict
76
86
  * on the same SHA. Used by `runPushGate` to set the flip-flag on
77
87
  * last-review.json and emit the `verdict_flip` audit event.
78
88
  */
79
89
  export declare function isFlip(prior: VerdictCacheEntry | undefined, fresh: ReviewVerdict): boolean;
90
+ /**
91
+ * Write a fresh verdict entry. Atomic via tmp-file + rename, serialized
92
+ * via `withAuditLock` on `.rea/`. Refuses to overwrite when the existing
93
+ * cache has an unrecognized schema_version (forward-compat — a v3 cache
94
+ * from a future rea version stays intact for that version to read).
95
+ */
96
+ export declare function writeVerdict(baseDir: string, headSha: string, entry: VerdictCacheEntry): Promise<void>;
80
97
  /**
81
98
  * Remove a single SHA from the cache. Returns true if the entry existed.
82
99
  */
83
- export declare function clearVerdict(baseDir: string, headSha: string): boolean;
100
+ export declare function clearVerdict(baseDir: string, headSha: string): Promise<boolean>;
84
101
  /**
85
102
  * Remove ALL entries from the cache. Returns the count of removed entries.
86
103
  */
87
- export declare function clearAll(baseDir: string): number;
104
+ export declare function clearAll(baseDir: string): Promise<number>;
88
105
  /**
89
106
  * Remove entries whose `reviewed_at` is older than `olderThanMs` from `now`.
90
107
  * Returns the count of removed entries.
91
108
  */
92
- export declare function pruneOlderThan(baseDir: string, olderThanMs: number, now?: Date): number;
109
+ export declare function pruneOlderThan(baseDir: string, olderThanMs: number, now?: Date): Promise<number>;
93
110
  /**
94
111
  * Read all entries (used by `rea cache stats` / `rea cache show`).
95
112
  * Returns empty object on any read error (missing file, malformed JSON,
96
113
  * unsupported schema_version).
97
114
  */
98
115
  export declare function listEntries(baseDir: string): Record<string, VerdictCacheEntry>;
116
+ /**
117
+ * Thrown by writeVerdict when the existing cache file has an
118
+ * unrecognized schema_version. The caller (push-gate) catches this
119
+ * and treats the write as best-effort failure (log to stderr,
120
+ * continue) rather than overwriting forward-compat data.
121
+ */
122
+ export declare class VerdictCacheForeignSchemaError extends Error {
123
+ readonly cachePath: string;
124
+ readonly kind: "foreign-schema";
125
+ constructor(cachePath: string);
126
+ }
@@ -37,11 +37,28 @@
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
@@ -66,25 +83,6 @@ export function lookupVerdict(baseDir, headSha, now = new Date()) {
66
83
  }
67
84
  return { hit: true, entry };
68
85
  }
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
86
  /**
89
87
  * Detect whether a new verdict contradicts a previously-cached verdict
90
88
  * on the same SHA. Used by `runPushGate` to set the flip-flag on
@@ -95,68 +93,99 @@ export function isFlip(prior, fresh) {
95
93
  return false;
96
94
  return prior.verdict !== fresh;
97
95
  }
96
+ /**
97
+ * Write a fresh verdict entry. Atomic via tmp-file + rename, serialized
98
+ * via `withAuditLock` on `.rea/`. Refuses to overwrite when the existing
99
+ * cache has an unrecognized schema_version (forward-compat — a v3 cache
100
+ * from a future rea version stays intact for that version to read).
101
+ */
102
+ export async function writeVerdict(baseDir, headSha, entry) {
103
+ const reaDir = path.join(baseDir, '.rea');
104
+ if (!fs.existsSync(reaDir)) {
105
+ fs.mkdirSync(reaDir, { recursive: true });
106
+ }
107
+ const cachePath = path.join(reaDir, VERDICT_CACHE_FILE);
108
+ await withAuditLock(cachePath, async () => {
109
+ if (foreignSchemaPresent(baseDir)) {
110
+ throw new VerdictCacheForeignSchemaError(cachePath);
111
+ }
112
+ const existing = readCacheFile(baseDir);
113
+ const next = {
114
+ schema_version: VERDICT_CACHE_SCHEMA_VERSION,
115
+ entries: { ...(existing?.entries ?? {}), [headSha]: entry },
116
+ };
117
+ _atomicWriteJson(cachePath, next);
118
+ });
119
+ }
98
120
  /**
99
121
  * Remove a single SHA from the cache. Returns true if the entry existed.
100
122
  */
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];
123
+ export async function clearVerdict(baseDir, headSha) {
110
124
  const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
111
- fs.writeFileSync(cachePath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
112
- return true;
125
+ return withAuditLock(cachePath, async () => {
126
+ const file = readCacheFile(baseDir);
127
+ if (file === undefined || file.entries[headSha] === undefined)
128
+ return false;
129
+ const next = {
130
+ schema_version: VERDICT_CACHE_SCHEMA_VERSION,
131
+ entries: { ...file.entries },
132
+ };
133
+ delete next.entries[headSha];
134
+ _atomicWriteJson(cachePath, next);
135
+ return true;
136
+ });
113
137
  }
114
138
  /**
115
139
  * Remove ALL entries from the cache. Returns the count of removed entries.
116
140
  */
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 });
141
+ export async function clearAll(baseDir) {
142
+ const reaDir = path.join(baseDir, '.rea');
143
+ const cachePath = path.join(reaDir, VERDICT_CACHE_FILE);
144
+ if (!fs.existsSync(reaDir)) {
145
+ fs.mkdirSync(reaDir, { recursive: true });
127
146
  }
128
- fs.writeFileSync(cachePath, `${JSON.stringify(empty, null, 2)}\n`, 'utf8');
129
- return count;
147
+ return withAuditLock(cachePath, async () => {
148
+ const file = readCacheFile(baseDir);
149
+ const count = file === undefined ? 0 : Object.keys(file.entries).length;
150
+ const empty = {
151
+ schema_version: VERDICT_CACHE_SCHEMA_VERSION,
152
+ entries: {},
153
+ };
154
+ _atomicWriteJson(cachePath, empty);
155
+ return count;
156
+ });
130
157
  }
131
158
  /**
132
159
  * Remove entries whose `reviewed_at` is older than `olderThanMs` from `now`.
133
160
  * Returns the count of removed entries.
134
161
  */
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
- };
162
+ export async function pruneOlderThan(baseDir, olderThanMs, now = new Date()) {
157
163
  const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
158
- fs.writeFileSync(cachePath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
159
- return removed;
164
+ return withAuditLock(cachePath, async () => {
165
+ const file = readCacheFile(baseDir);
166
+ if (file === undefined)
167
+ return 0;
168
+ const cutoff = now.getTime() - olderThanMs;
169
+ const surviving = {};
170
+ let removed = 0;
171
+ for (const [sha, entry] of Object.entries(file.entries)) {
172
+ const reviewedAtMs = Date.parse(entry.reviewed_at);
173
+ if (Number.isNaN(reviewedAtMs) || reviewedAtMs >= cutoff) {
174
+ surviving[sha] = entry;
175
+ }
176
+ else {
177
+ removed += 1;
178
+ }
179
+ }
180
+ if (removed === 0)
181
+ return 0;
182
+ const next = {
183
+ schema_version: VERDICT_CACHE_SCHEMA_VERSION,
184
+ entries: surviving,
185
+ };
186
+ _atomicWriteJson(cachePath, next);
187
+ return removed;
188
+ });
160
189
  }
161
190
  /**
162
191
  * Read all entries (used by `rea cache stats` / `rea cache show`).
@@ -167,18 +196,43 @@ export function listEntries(baseDir) {
167
196
  const file = readCacheFile(baseDir);
168
197
  return file?.entries ?? {};
169
198
  }
199
+ /**
200
+ * Thrown by writeVerdict when the existing cache file has an
201
+ * unrecognized schema_version. The caller (push-gate) catches this
202
+ * and treats the write as best-effort failure (log to stderr,
203
+ * continue) rather than overwriting forward-compat data.
204
+ */
205
+ export class VerdictCacheForeignSchemaError extends Error {
206
+ cachePath;
207
+ kind = 'foreign-schema';
208
+ constructor(cachePath) {
209
+ super(`Refused to overwrite ${cachePath}: existing cache has unrecognized schema_version. ` +
210
+ `Either delete the file or run with a newer rea that supports it.`);
211
+ this.cachePath = cachePath;
212
+ this.name = 'VerdictCacheForeignSchemaError';
213
+ }
214
+ }
170
215
  function readCacheFile(baseDir) {
216
+ const parsed = readForeignCacheFile(baseDir);
217
+ if (parsed === undefined)
218
+ return undefined;
219
+ if (parsed.schema_version !== VERDICT_CACHE_SCHEMA_VERSION)
220
+ return undefined;
221
+ // We checked schema_version exactly; entries shape is the v2 contract.
222
+ return parsed;
223
+ }
224
+ function readForeignCacheFile(baseDir) {
171
225
  const cachePath = path.join(baseDir, '.rea', VERDICT_CACHE_FILE);
172
226
  if (!fs.existsSync(cachePath))
173
227
  return undefined;
174
228
  try {
175
229
  const raw = fs.readFileSync(cachePath, 'utf8');
176
230
  const parsed = JSON.parse(raw);
177
- if (typeof parsed !== 'object' ||
178
- parsed === null ||
179
- parsed.schema_version !== VERDICT_CACHE_SCHEMA_VERSION) {
231
+ if (typeof parsed !== 'object' || parsed === null)
232
+ return undefined;
233
+ const sv = parsed.schema_version;
234
+ if (typeof sv !== 'number')
180
235
  return undefined;
181
- }
182
236
  const entries = parsed.entries;
183
237
  if (typeof entries !== 'object' || entries === null)
184
238
  return undefined;
@@ -188,3 +242,35 @@ function readCacheFile(baseDir) {
188
242
  return undefined;
189
243
  }
190
244
  }
245
+ function foreignSchemaPresent(baseDir) {
246
+ const parsed = readForeignCacheFile(baseDir);
247
+ if (parsed === undefined)
248
+ return false;
249
+ return parsed.schema_version !== VERDICT_CACHE_SCHEMA_VERSION;
250
+ }
251
+ /**
252
+ * Atomic JSON write: stringify → write tmp → fsync → rename.
253
+ *
254
+ * Tmp filename: `${target}.tmp.${pid}.${ms}.${random8}` — collision-
255
+ * resistant under concurrent writes, PID reuse, and same-process
256
+ * parallel calls. On any failure, the tmp file is unlinked so a crash
257
+ * mid-write doesn't leave stale state.
258
+ */
259
+ function _atomicWriteJson(targetPath, payload) {
260
+ const tmp = `${targetPath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
261
+ try {
262
+ fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
263
+ fs.renameSync(tmp, targetPath);
264
+ }
265
+ catch (e) {
266
+ try {
267
+ if (fs.existsSync(tmp))
268
+ fs.unlinkSync(tmp);
269
+ }
270
+ catch {
271
+ // Tmp already gone or unlink failed — caller's error is the
272
+ // important signal.
273
+ }
274
+ throw e;
275
+ }
276
+ }
@@ -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
@@ -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
@@ -45,6 +45,10 @@ 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'
48
52
  )
49
53
 
50
54
  # Kill-switch invariants — never relaxable. Subset of FULL.
@@ -52,6 +56,7 @@ REA_KILL_SWITCH_INVARIANTS=(
52
56
  '.claude/settings.json'
53
57
  '.rea/policy.yaml'
54
58
  '.rea/HALT'
59
+ '.rea/last-review.cache.json'
55
60
  )
56
61
 
57
62
  # Effective patterns after applying the relax list. Computed lazily on
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.19.0",
3
+ "version": "0.20.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",
@@ -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([