@bookedsolid/rea 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,11 +65,45 @@ export interface AppendAuditInput {
65
65
  * Append a structured audit record to `${baseDir}/.rea/audit.jsonl` with a
66
66
  * hash chained against the tail of the existing log.
67
67
  *
68
+ * ## emission_source (defect P)
69
+ *
70
+ * Records written through this public helper are ALWAYS stamped with
71
+ * `emission_source: "other"`. External consumers (Helix, ad-hoc scripts,
72
+ * plugins) have no way to self-assert `"rea-cli"` or `"codex-cli"` through
73
+ * this entry point — the parameter is not part of the public
74
+ * {@link AppendAuditInput} shape. Records emitted by the rea CLI itself use
75
+ * the dedicated {@link appendCodexReviewAuditRecord} helper, which is the
76
+ * ONLY path that stamps `"rea-cli"`.
77
+ *
78
+ * The push-review cache gate rejects `codex.review` records whose
79
+ * `emission_source` is `"other"` (or missing, for legacy records), so
80
+ * forging a `codex.review` record through this helper produces a line that
81
+ * is on the hash chain but does NOT satisfy the gate.
82
+ *
68
83
  * @param baseDir - Repo/project root (the directory that contains `.rea/`).
69
84
  * @param input - Event data. `tool_name` and `server_name` are required.
70
85
  * @returns The full written record, including the computed `hash`.
71
86
  */
72
87
  export declare function appendAuditRecord(baseDir: string, input: AppendAuditInput): Promise<AuditRecord>;
73
- export type { AuditRecord } from '../gateway/middleware/audit-types.js';
88
+ /**
89
+ * Append a `tool_name: "codex.review"` audit record certifying that a Codex
90
+ * adversarial review ran on a specific commit SHA (defect P).
91
+ *
92
+ * This is the ONLY write path in `@bookedsolid/rea` that produces
93
+ * `emission_source: "rea-cli"` for `codex.review` records. Consumers MUST
94
+ * reach this helper through the `rea audit record codex-review` CLI (which
95
+ * is classified as a Write-tier Bash invocation by `reaCommandTier`, defect
96
+ * E). Any other code path calling the generic {@link appendAuditRecord}
97
+ * with `tool_name: "codex.review"` lands with `emission_source: "other"`
98
+ * and does NOT satisfy the push-review cache gate — closing the forgery
99
+ * surface that `.reports/hook-patches/emit-audit-*.mjs` scripts exploited
100
+ * before this patch.
101
+ *
102
+ * `tool_name` and `server_name` are fixed to the canonical values
103
+ * (`"codex.review"` / `"codex"`) and are NOT accepted as caller inputs —
104
+ * the type excludes them so the contract is self-documenting.
105
+ */
106
+ export declare function appendCodexReviewAuditRecord(baseDir: string, input: Omit<AppendAuditInput, 'tool_name' | 'server_name'>): Promise<AuditRecord>;
107
+ export type { AuditRecord, EmissionSource } from '../gateway/middleware/audit-types.js';
74
108
  export { Tier, InvocationStatus } from '../policy/types.js';
75
109
  export { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, type CodexVerdict, type CodexReviewMetadata, } from './codex-event.js';
@@ -37,6 +37,7 @@ import path from 'node:path';
37
37
  import { Tier, InvocationStatus } from '../policy/types.js';
38
38
  import { GENESIS_HASH, computeHash, fsyncFile, readLastRecord, withAuditLock, } from './fs.js';
39
39
  import { maybeRotate } from '../gateway/audit/rotator.js';
40
+ import { CODEX_REVIEW_SERVER_NAME, CODEX_REVIEW_TOOL_NAME } from './codex-event.js';
40
41
  const REA_DIR = '.rea';
41
42
  const AUDIT_FILE = 'audit.jsonl';
42
43
  /** Per-file write queue to preserve linear hash-chain order within a process. */
@@ -78,7 +79,7 @@ async function resolveBaseDir(baseDir) {
78
79
  return absolute;
79
80
  }
80
81
  }
81
- async function doAppend(resolvedBase, input) {
82
+ async function doAppend(resolvedBase, input, emissionSource) {
82
83
  const reaDir = path.join(resolvedBase, REA_DIR);
83
84
  const auditFile = path.join(reaDir, AUDIT_FILE);
84
85
  await fs.mkdir(reaDir, { recursive: true });
@@ -100,6 +101,7 @@ async function doAppend(resolvedBase, input) {
100
101
  autonomy_level: input.autonomy_level ?? 'unknown',
101
102
  duration_ms: input.duration_ms ?? 0,
102
103
  prev_hash: effectivePrev,
104
+ emission_source: emissionSource,
103
105
  };
104
106
  if (input.error)
105
107
  recordBase.error = input.error;
@@ -111,20 +113,39 @@ async function doAppend(resolvedBase, input) {
111
113
  const hash = computeHash(recordBase);
112
114
  const record = { ...recordBase, hash };
113
115
  const line = JSON.stringify(record) + '\n';
116
+ // Defect T (0.10.2): serialization self-check. A valid AuditRecord + the
117
+ // trailing newline should always round-trip through JSON.parse, but we
118
+ // verify that invariant BEFORE the line touches the hash-chain file. A
119
+ // throw here aborts the append WITHOUT writing anything — the caller sees
120
+ // the failure and the on-disk chain tail is unchanged. This is
121
+ // defense-in-depth against the class of regression that would otherwise
122
+ // write an unparseable line to `.rea/audit.jsonl` and only surface at
123
+ // `rea audit verify` time (or, worse, when push-review-core.sh's jq scan
124
+ // silently fails to find a legitimate `codex.review` record past the
125
+ // corruption). The concrete failure modes guarded against:
126
+ //
127
+ // - A future refactor introducing a non-JSON-safe field into
128
+ // AuditRecord (BigInt, circular ref, undefined-in-array, etc.) that
129
+ // slips past TypeScript.
130
+ // - A hostile `metadata` value whose serialized form produces output
131
+ // JSON.parse rejects (currently impossible given our input types,
132
+ // but the check is cheap and the recovery cost is high).
133
+ try {
134
+ JSON.parse(line);
135
+ }
136
+ catch (e) {
137
+ throw new Error(`Audit append aborted: JSON.stringify produced an unparseable line ` +
138
+ `for tool_name=${JSON.stringify(record.tool_name)} ` +
139
+ `server_name=${JSON.stringify(record.server_name)}. ` +
140
+ `Underlying parser error: ${e.message}. ` +
141
+ `No data was written to ${auditFile}.`);
142
+ }
114
143
  await fs.appendFile(auditFile, line);
115
144
  await fsyncFile(auditFile);
116
145
  return record;
117
146
  });
118
147
  }
119
- /**
120
- * Append a structured audit record to `${baseDir}/.rea/audit.jsonl` with a
121
- * hash chained against the tail of the existing log.
122
- *
123
- * @param baseDir - Repo/project root (the directory that contains `.rea/`).
124
- * @param input - Event data. `tool_name` and `server_name` are required.
125
- * @returns The full written record, including the computed `hash`.
126
- */
127
- export async function appendAuditRecord(baseDir, input) {
148
+ async function enqueueAppend(baseDir, input, emissionSource) {
128
149
  // Canonicalize the baseDir so every caller targeting the same on-disk
129
150
  // directory lands on the same queue key, regardless of whether they passed
130
151
  // `'.'`, `process.cwd()`, or a symlinked path. Without this, two callers in
@@ -139,7 +160,7 @@ export async function appendAuditRecord(baseDir, input) {
139
160
  /* previous write's error is owned by that caller */
140
161
  })
141
162
  .then(async () => {
142
- record = await doAppend(resolvedBase, input);
163
+ record = await doAppend(resolvedBase, input, emissionSource);
143
164
  });
144
165
  writeQueues.set(key, next
145
166
  .finally(() => {
@@ -161,5 +182,52 @@ export async function appendAuditRecord(baseDir, input) {
161
182
  await next;
162
183
  return record;
163
184
  }
185
+ /**
186
+ * Append a structured audit record to `${baseDir}/.rea/audit.jsonl` with a
187
+ * hash chained against the tail of the existing log.
188
+ *
189
+ * ## emission_source (defect P)
190
+ *
191
+ * Records written through this public helper are ALWAYS stamped with
192
+ * `emission_source: "other"`. External consumers (Helix, ad-hoc scripts,
193
+ * plugins) have no way to self-assert `"rea-cli"` or `"codex-cli"` through
194
+ * this entry point — the parameter is not part of the public
195
+ * {@link AppendAuditInput} shape. Records emitted by the rea CLI itself use
196
+ * the dedicated {@link appendCodexReviewAuditRecord} helper, which is the
197
+ * ONLY path that stamps `"rea-cli"`.
198
+ *
199
+ * The push-review cache gate rejects `codex.review` records whose
200
+ * `emission_source` is `"other"` (or missing, for legacy records), so
201
+ * forging a `codex.review` record through this helper produces a line that
202
+ * is on the hash chain but does NOT satisfy the gate.
203
+ *
204
+ * @param baseDir - Repo/project root (the directory that contains `.rea/`).
205
+ * @param input - Event data. `tool_name` and `server_name` are required.
206
+ * @returns The full written record, including the computed `hash`.
207
+ */
208
+ export async function appendAuditRecord(baseDir, input) {
209
+ return enqueueAppend(baseDir, input, 'other');
210
+ }
211
+ /**
212
+ * Append a `tool_name: "codex.review"` audit record certifying that a Codex
213
+ * adversarial review ran on a specific commit SHA (defect P).
214
+ *
215
+ * This is the ONLY write path in `@bookedsolid/rea` that produces
216
+ * `emission_source: "rea-cli"` for `codex.review` records. Consumers MUST
217
+ * reach this helper through the `rea audit record codex-review` CLI (which
218
+ * is classified as a Write-tier Bash invocation by `reaCommandTier`, defect
219
+ * E). Any other code path calling the generic {@link appendAuditRecord}
220
+ * with `tool_name: "codex.review"` lands with `emission_source: "other"`
221
+ * and does NOT satisfy the push-review cache gate — closing the forgery
222
+ * surface that `.reports/hook-patches/emit-audit-*.mjs` scripts exploited
223
+ * before this patch.
224
+ *
225
+ * `tool_name` and `server_name` are fixed to the canonical values
226
+ * (`"codex.review"` / `"codex"`) and are NOT accepted as caller inputs —
227
+ * the type excludes them so the contract is self-documenting.
228
+ */
229
+ export async function appendCodexReviewAuditRecord(baseDir, input) {
230
+ return enqueueAppend(baseDir, { ...input, tool_name: CODEX_REVIEW_TOOL_NAME, server_name: CODEX_REVIEW_SERVER_NAME }, 'rea-cli');
231
+ }
164
232
  export { Tier, InvocationStatus } from '../policy/types.js';
165
233
  export { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, } from './codex-event.js';
package/dist/cli/audit.js CHANGED
@@ -13,7 +13,7 @@
13
13
  import fs from 'node:fs/promises';
14
14
  import path from 'node:path';
15
15
  import { forceRotate } from '../gateway/audit/rotator.js';
16
- import { appendAuditRecord, CODEX_REVIEW_SERVER_NAME, CODEX_REVIEW_TOOL_NAME, } from '../audit/append.js';
16
+ import { appendCodexReviewAuditRecord, } from '../audit/append.js';
17
17
  import { computeHash, GENESIS_HASH } from '../audit/fs.js';
18
18
  import { appendEntry as appendCacheEntry } from '../cache/review-cache.js';
19
19
  import { AUDIT_FILE, REA_DIR, err, log, reaPath } from './utils.js';
@@ -59,36 +59,83 @@ export async function runAuditRotate(_options) {
59
59
  console.log(` A rotation marker anchors the new chain on the old tail's hash.`);
60
60
  }
61
61
  /**
62
- * Load a JSONL audit file as a record array + per-line raw text, so we can
63
- * re-hash against the exact serialization that was written. Throws on read
64
- * errors; returns an empty array for an empty file.
62
+ * Best-effort column extractor. Node's JSON.parse error messages include a
63
+ * `position N` that is a 0-based character offset into the parsed string.
64
+ * When we parse a single JSONL line, that offset maps directly to a column.
65
+ * Returns undefined when the position token is absent — the line number
66
+ * alone is still useful.
67
+ */
68
+ function extractColumnFromParserError(message) {
69
+ const m = /position (\d+)/.exec(message);
70
+ if (m === null)
71
+ return undefined;
72
+ const n = Number.parseInt(m[1] ?? '', 10);
73
+ if (!Number.isFinite(n) || n < 0)
74
+ return undefined;
75
+ return n + 1;
76
+ }
77
+ /**
78
+ * Load a JSONL audit file as a record array + per-line raw text + a list of
79
+ * per-line parse failures, so we can re-hash against the exact serialization
80
+ * that was written AND report every malformed line in one pass (defect T).
81
+ *
82
+ * Unparseable lines are a DISTINCT failure class from hash-chain tampers:
83
+ *
84
+ * - Malformed lines are collected into `parseFailures` and dropped from
85
+ * `records`. `rawLines` still contains the full original line array, so
86
+ * callers can cross-reference. `recordLineMap[i]` holds the 1-based file
87
+ * line number of `records[i]`.
88
+ * - The chain-verify pass runs only over the parseable subset. A caller
89
+ * that wants to report the verification result as partial checks
90
+ * `parseFailures.length > 0`.
91
+ *
92
+ * Throws only on read errors; returns an empty shape for an empty file.
65
93
  */
66
94
  async function loadRecords(filePath) {
67
95
  const raw = await fs.readFile(filePath, 'utf8');
68
96
  // Drop a single trailing newline but preserve blank lines inside the file
69
97
  // so index numbers line up with real record positions.
70
98
  const trimmedTail = raw.replace(/\n$/, '');
71
- if (trimmedTail.length === 0)
72
- return { records: [], rawLines: [] };
99
+ if (trimmedTail.length === 0) {
100
+ return { records: [], recordLineMap: [], rawLines: [], parseFailures: [] };
101
+ }
73
102
  const rawLines = trimmedTail.split('\n');
74
- const records = rawLines.map((line, i) => {
103
+ const records = [];
104
+ const recordLineMap = [];
105
+ const parseFailures = [];
106
+ const basename = path.basename(filePath);
107
+ for (let i = 0; i < rawLines.length; i++) {
108
+ const line = rawLines[i];
109
+ // Empty lines mid-file are not records but also not parseable — JSON.parse('')
110
+ // throws. Treat as a parse failure so verify surfaces them explicitly.
75
111
  try {
76
- return JSON.parse(line);
112
+ const parsed = JSON.parse(line);
113
+ records.push(parsed);
114
+ recordLineMap.push(i + 1);
77
115
  }
78
116
  catch (e) {
79
- throw new Error(`Cannot parse JSON at ${path.basename(filePath)} line ${i + 1}: ${e.message}`);
117
+ const msg = e.message;
118
+ const col = extractColumnFromParserError(msg);
119
+ parseFailures.push({
120
+ file: basename,
121
+ lineNumber: i + 1,
122
+ ...(col !== undefined ? { column: col } : {}),
123
+ message: msg,
124
+ });
80
125
  }
81
- });
82
- return { records, rawLines };
126
+ }
127
+ return { records, recordLineMap, rawLines, parseFailures };
83
128
  }
84
- function verifyChain(fileBasename, records, expectedStartPrev) {
129
+ function verifyChain(fileBasename, records, recordLineMap, expectedStartPrev) {
85
130
  let prev = expectedStartPrev;
86
131
  for (let i = 0; i < records.length; i++) {
87
132
  const r = records[i];
133
+ const fileLineNumber = recordLineMap[i] ?? i + 1;
88
134
  if (r.prev_hash !== prev) {
89
135
  return {
90
136
  file: fileBasename,
91
- lineIndex: i,
137
+ recordIndex: i,
138
+ fileLineNumber,
92
139
  reason: 'prev_hash does not match previous record',
93
140
  expected: prev,
94
141
  actual: r.prev_hash,
@@ -101,7 +148,8 @@ function verifyChain(fileBasename, records, expectedStartPrev) {
101
148
  if (recomputed !== hash) {
102
149
  return {
103
150
  file: fileBasename,
104
- lineIndex: i,
151
+ recordIndex: i,
152
+ fileLineNumber,
105
153
  reason: 'stored hash does not match recomputed hash over record body',
106
154
  expected: recomputed,
107
155
  actual: hash,
@@ -174,37 +222,82 @@ export async function runAuditVerify(options) {
174
222
  console.error(` Expected: ${path.relative(baseDir, currentAudit)}`);
175
223
  process.exit(1);
176
224
  }
225
+ // Defect T (0.10.2): collect-all-errors mode. We no longer abort at the
226
+ // first unparseable line — `rea audit verify` now walks every file, lists
227
+ // EVERY malformed line with its number + parser message, and attempts
228
+ // chain verification over the parseable subset. Unparseable lines are a
229
+ // distinct failure class from hash-chain tampers; both contribute to a
230
+ // non-zero exit, but they are reported separately so an operator can tell
231
+ // "JSONL corruption" from "someone edited a hash".
177
232
  let expectedPrev = GENESIS_HASH;
178
233
  let totalRecords = 0;
234
+ const allParseFailures = [];
235
+ let chainFailure = null;
236
+ let chainFailureFile = null;
179
237
  for (const filePath of filesToVerify) {
180
- let records;
238
+ let loaded;
181
239
  try {
182
- ({ records } = await loadRecords(filePath));
240
+ loaded = await loadRecords(filePath);
183
241
  }
184
242
  catch (e) {
185
243
  err(`${e.message}`);
186
244
  process.exit(1);
187
245
  }
188
- const basename = path.basename(filePath);
189
- const failure = verifyChain(basename, records, expectedPrev);
190
- if (failure !== null) {
191
- err(`Audit chain TAMPER DETECTED in ${failure.file}`);
192
- console.error(` Record index: ${failure.lineIndex} (0-based within file)`);
193
- console.error(` Reason: ${failure.reason}`);
194
- if (failure.expected !== undefined) {
195
- console.error(` Expected: ${failure.expected}`);
246
+ const { records, recordLineMap, parseFailures } = loaded;
247
+ allParseFailures.push(...parseFailures);
248
+ // Chain verify over the parseable subset only. If an earlier file had a
249
+ // chain failure we stop verifying further files — advancing `expectedPrev`
250
+ // past an unknown tail would produce misleading secondary failures.
251
+ // recordLineMap threads the 1-based original-file line number through so
252
+ // the failure diagnostic names the editor/jq position directly, not the
253
+ // parseable-subset index which diverges from the file whenever a
254
+ // malformed line precedes the tamper.
255
+ if (chainFailure === null) {
256
+ const failure = verifyChain(path.basename(filePath), records, recordLineMap, expectedPrev);
257
+ if (failure !== null) {
258
+ chainFailure = failure;
259
+ chainFailureFile = filePath;
196
260
  }
197
- if (failure.actual !== undefined) {
198
- console.error(` Actual: ${failure.actual}`);
261
+ else if (records.length > 0) {
262
+ expectedPrev = records[records.length - 1].hash;
199
263
  }
200
- process.exit(1);
201
- }
202
- // Advance the cross-file anchor for the next file.
203
- if (records.length > 0) {
204
- expectedPrev = records[records.length - 1].hash;
205
264
  }
206
265
  totalRecords += records.length;
207
266
  }
267
+ // Report parse failures first — they're independent of the chain result.
268
+ if (allParseFailures.length > 0) {
269
+ err(`Audit verify: ${allParseFailures.length} unparseable line(s) detected. ` +
270
+ `Chain verification was performed over the parseable subset only.`);
271
+ for (const f of allParseFailures) {
272
+ const loc = f.column !== undefined
273
+ ? `${f.file}:${f.lineNumber}:${f.column}`
274
+ : `${f.file}:${f.lineNumber}`;
275
+ console.error(` ${loc} ${f.message}`);
276
+ }
277
+ }
278
+ // Then report any chain failure found on the parseable subset.
279
+ if (chainFailure !== null) {
280
+ err(`Audit chain TAMPER DETECTED in ${chainFailure.file}`);
281
+ // File-line-number is the operator-facing anchor — jump straight to the
282
+ // offending line with `sed -n "${n}p" audit.jsonl` or editor:LINE. The
283
+ // parseable-subset index is kept for audit-tooling consumers that walk
284
+ // the records[] array.
285
+ console.error(` File line: ${chainFailure.fileLineNumber} (1-based in ${chainFailure.file})`);
286
+ console.error(` Record index: ${chainFailure.recordIndex} (0-based within parseable subset)`);
287
+ console.error(` Reason: ${chainFailure.reason}`);
288
+ if (chainFailure.expected !== undefined) {
289
+ console.error(` Expected: ${chainFailure.expected}`);
290
+ }
291
+ if (chainFailure.actual !== undefined) {
292
+ console.error(` Actual: ${chainFailure.actual}`);
293
+ }
294
+ if (chainFailureFile !== null) {
295
+ console.error(` File path: ${path.relative(baseDir, chainFailureFile)}`);
296
+ }
297
+ }
298
+ if (allParseFailures.length > 0 || chainFailure !== null) {
299
+ process.exit(1);
300
+ }
208
301
  log(`Audit chain verified: ${totalRecords} records across ${filesToVerify.length} file(s) — clean.`);
209
302
  }
210
303
  /**
@@ -253,9 +346,12 @@ export async function runAuditRecordCodexReview(options) {
253
346
  if (options.summary !== undefined && options.summary.length > 0) {
254
347
  metadata.summary = options.summary;
255
348
  }
256
- await appendAuditRecord(baseDir, {
257
- tool_name: CODEX_REVIEW_TOOL_NAME,
258
- server_name: CODEX_REVIEW_SERVER_NAME,
349
+ // Defect P: stamps emission_source: "rea-cli" so the record satisfies the
350
+ // push-review gate's new integrity predicate. Legacy records (without
351
+ // emission_source) and records written through the generic
352
+ // appendAuditRecord() helper (emission_source: "other") are rejected.
353
+ // tool_name/server_name are fixed inside the helper.
354
+ await appendCodexReviewAuditRecord(baseDir, {
259
355
  tier: Tier.Read,
260
356
  status: InvocationStatus.Allowed,
261
357
  ...(options.sessionId !== undefined ? { session_id: options.sessionId } : {}),
@@ -103,7 +103,7 @@ export async function checkFingerprintStore(baseDir) {
103
103
  return {
104
104
  label,
105
105
  status: 'warn',
106
- detail: `${parts.join(', ')} — next \`rea serve\` will block drift (set REA_ACCEPT_DRIFT=<name> to accept)`,
106
+ detail: `${parts.join(', ')} — next \`rea serve\` will block drift (run \`rea tofu list\` for detail, \`rea tofu accept <name>\` to rebase after a legitimate registry edit)`,
107
107
  };
108
108
  }
109
109
  function checkRegistryParses(baseDir, registryPath) {
package/dist/cli/index.js CHANGED
@@ -8,6 +8,7 @@ import { runFreeze, runUnfreeze } from './freeze.js';
8
8
  import { runInit } from './init.js';
9
9
  import { runServe } from './serve.js';
10
10
  import { runStatus } from './status.js';
11
+ import { runTofuAccept, runTofuList } from './tofu.js';
11
12
  import { runUpgrade } from './upgrade.js';
12
13
  import { err, getPkgVersion } from './utils.js';
13
14
  async function main() {
@@ -180,6 +181,23 @@ async function main() {
180
181
  .action(async (opts) => {
181
182
  await runCacheList({ ...(opts.branch !== undefined ? { branch: opts.branch } : {}) });
182
183
  });
184
+ const tofu = program
185
+ .command('tofu')
186
+ .description('TOFU fingerprint operations (G7) — inspect and rebase `.rea/fingerprints.json` when a legitimate registry edit has triggered drift fail-close. Emits audit records.');
187
+ tofu
188
+ .command('list')
189
+ .description('Print every server declared in `.rea/registry.yaml` with its current-vs-stored fingerprint verdict (first-seen | unchanged | drifted).')
190
+ .option('--json', 'emit JSON instead of the human-readable table')
191
+ .action(async (opts) => {
192
+ await runTofuList({ ...(opts.json === true ? { json: true } : {}) });
193
+ });
194
+ tofu
195
+ .command('accept <name>')
196
+ .description('Rebase the stored fingerprint for <name> to match the current canonical shape in `.rea/registry.yaml`. Use after a deliberate registry edit (vault added, command path renamed, env-key set changed). Emits a `tofu.drift_accepted_by_cli` audit record; next `rea serve` will classify as unchanged.')
197
+ .option('--reason <text>', 'free-text note captured in the audit record (recommended when accepting drift — explains WHY the canonical shape changed)')
198
+ .action(async (name, opts) => {
199
+ await runTofuAccept({ name, ...(opts.reason !== undefined ? { reason: opts.reason } : {}) });
200
+ });
183
201
  program
184
202
  .command('doctor')
185
203
  .description('Validate the install: policy parses, .rea/ layout, hooks, Codex plugin.')
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `rea tofu` — operator-facing recovery surface for TOFU fingerprint drift
3
+ * (defect S).
4
+ *
5
+ * The TOFU gate in `src/registry/tofu-gate.ts` fail-closes on drift: an
6
+ * enabled downstream whose canonical fingerprint no longer matches the stored
7
+ * baseline is silently dropped from the spawn set. The only documented
8
+ * recovery path used to be `REA_ACCEPT_DRIFT=<name>` as a startup env var,
9
+ * which is useless when the gateway is spawned indirectly (e.g. by Claude
10
+ * Code via `.mcp.json`) — there is no operator-reachable env in that path.
11
+ *
12
+ * This module provides two verbs:
13
+ *
14
+ * - `list` — print every declared server's current-vs-stored
15
+ * fingerprint verdict so the operator can see drift
16
+ * before reaching for `accept`.
17
+ * - `accept <name>` — recompute the current fingerprint for `<name>` and
18
+ * write it to `.rea/fingerprints.json`. Emits a
19
+ * `tofu.drift_accepted_by_cli` audit record so the
20
+ * action is on the hash chain.
21
+ *
22
+ * Both verbs are pure CLI surface — they do NOT speak to a running `rea
23
+ * serve`. The next gateway boot re-runs `applyTofuGate` against the updated
24
+ * store and classifies the server as `unchanged` with no banner.
25
+ *
26
+ * ## Trust model
27
+ *
28
+ * `accept` updates the stored baseline to match whatever the YAML currently
29
+ * says. It is a **deliberate operator action**: anyone who can run `rea`
30
+ * could already edit `.rea/fingerprints.json` by hand. The CLI is an
31
+ * audit-recording wrapper over that capability, not a privilege expansion.
32
+ *
33
+ * The audit record captures BOTH fingerprints (stored + current) and the
34
+ * registry canonical shape at accept-time, so a forensic re-hash of the
35
+ * registry after the fact can confirm the operator accepted the shape they
36
+ * intended to accept.
37
+ */
38
+ import type { RegistryServer } from '../registry/types.js';
39
+ export type TofuVerdictLabel = 'first-seen' | 'unchanged' | 'drifted';
40
+ export interface TofuRow {
41
+ name: string;
42
+ enabled: boolean;
43
+ current: string;
44
+ stored: string | undefined;
45
+ verdict: TofuVerdictLabel;
46
+ }
47
+ /** Pure classifier used by both `list` and `accept` — keep free of I/O. */
48
+ export declare function classifyRows(servers: RegistryServer[], stored: Record<string, string>): TofuRow[];
49
+ export interface RunTofuListOptions {
50
+ json?: boolean;
51
+ }
52
+ export declare function runTofuList(options?: RunTofuListOptions): Promise<void>;
53
+ export interface RunTofuAcceptOptions {
54
+ name: string;
55
+ reason?: string;
56
+ }
57
+ export declare function runTofuAccept(options: RunTofuAcceptOptions): Promise<void>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `rea tofu` — operator-facing recovery surface for TOFU fingerprint drift
3
+ * (defect S).
4
+ *
5
+ * The TOFU gate in `src/registry/tofu-gate.ts` fail-closes on drift: an
6
+ * enabled downstream whose canonical fingerprint no longer matches the stored
7
+ * baseline is silently dropped from the spawn set. The only documented
8
+ * recovery path used to be `REA_ACCEPT_DRIFT=<name>` as a startup env var,
9
+ * which is useless when the gateway is spawned indirectly (e.g. by Claude
10
+ * Code via `.mcp.json`) — there is no operator-reachable env in that path.
11
+ *
12
+ * This module provides two verbs:
13
+ *
14
+ * - `list` — print every declared server's current-vs-stored
15
+ * fingerprint verdict so the operator can see drift
16
+ * before reaching for `accept`.
17
+ * - `accept <name>` — recompute the current fingerprint for `<name>` and
18
+ * write it to `.rea/fingerprints.json`. Emits a
19
+ * `tofu.drift_accepted_by_cli` audit record so the
20
+ * action is on the hash chain.
21
+ *
22
+ * Both verbs are pure CLI surface — they do NOT speak to a running `rea
23
+ * serve`. The next gateway boot re-runs `applyTofuGate` against the updated
24
+ * store and classifies the server as `unchanged` with no banner.
25
+ *
26
+ * ## Trust model
27
+ *
28
+ * `accept` updates the stored baseline to match whatever the YAML currently
29
+ * says. It is a **deliberate operator action**: anyone who can run `rea`
30
+ * could already edit `.rea/fingerprints.json` by hand. The CLI is an
31
+ * audit-recording wrapper over that capability, not a privilege expansion.
32
+ *
33
+ * The audit record captures BOTH fingerprints (stored + current) and the
34
+ * registry canonical shape at accept-time, so a forensic re-hash of the
35
+ * registry after the fact can confirm the operator accepted the shape they
36
+ * intended to accept.
37
+ */
38
+ import { appendAuditRecord } from '../audit/append.js';
39
+ import { InvocationStatus, Tier } from '../policy/types.js';
40
+ import { fingerprintServer } from '../registry/fingerprint.js';
41
+ import { FINGERPRINT_STORE_VERSION, loadFingerprintStore, saveFingerprintStore, } from '../registry/fingerprints-store.js';
42
+ import { loadRegistry } from '../registry/loader.js';
43
+ import { err, log } from './utils.js';
44
+ /** Pure classifier used by both `list` and `accept` — keep free of I/O. */
45
+ export function classifyRows(servers, stored) {
46
+ return servers.map((s) => {
47
+ const current = fingerprintServer(s);
48
+ const prior = stored[s.name];
49
+ let verdict;
50
+ if (prior === undefined)
51
+ verdict = 'first-seen';
52
+ else if (prior === current)
53
+ verdict = 'unchanged';
54
+ else
55
+ verdict = 'drifted';
56
+ return {
57
+ name: s.name,
58
+ enabled: s.enabled !== false,
59
+ current,
60
+ stored: prior,
61
+ verdict,
62
+ };
63
+ });
64
+ }
65
+ export async function runTofuList(options = {}) {
66
+ const baseDir = process.cwd();
67
+ const registry = loadRegistry(baseDir);
68
+ const store = await loadFingerprintStore(baseDir);
69
+ const rows = classifyRows(registry.servers, store.servers);
70
+ if (options.json === true) {
71
+ process.stdout.write(JSON.stringify({ servers: rows }, null, 2) + '\n');
72
+ return;
73
+ }
74
+ if (rows.length === 0) {
75
+ log('No servers declared in .rea/registry.yaml.');
76
+ return;
77
+ }
78
+ log('TOFU fingerprint status:');
79
+ log('');
80
+ for (const row of rows) {
81
+ const shortCur = row.current.slice(0, 12);
82
+ const shortPrior = row.stored !== undefined ? row.stored.slice(0, 12) : '—';
83
+ const flag = row.enabled ? '' : ' (disabled)';
84
+ log(` ${row.verdict.padEnd(10)} ${row.name.padEnd(20)} stored=${shortPrior} current=${shortCur}${flag}`);
85
+ }
86
+ log('');
87
+ const drifted = rows.filter((r) => r.verdict === 'drifted');
88
+ if (drifted.length > 0) {
89
+ log(` ${drifted.length} drifted — run \`rea tofu accept <name>\` to rebase the stored fingerprint (emits an audit record).`);
90
+ }
91
+ }
92
+ export async function runTofuAccept(options) {
93
+ const baseDir = process.cwd();
94
+ const registry = loadRegistry(baseDir);
95
+ const server = registry.servers.find((s) => s.name === options.name);
96
+ if (server === undefined) {
97
+ err(`Server "${options.name}" is not declared in .rea/registry.yaml. Run \`rea tofu list\` to see declared servers.`);
98
+ process.exit(1);
99
+ }
100
+ const current = fingerprintServer(server);
101
+ const store = await loadFingerprintStore(baseDir);
102
+ const stored = store.servers[server.name];
103
+ if (stored === current) {
104
+ log(`tofu: "${server.name}" already matches stored fingerprint (${current.slice(0, 12)}…) — no change written.`);
105
+ return;
106
+ }
107
+ const nextStore = {
108
+ version: FINGERPRINT_STORE_VERSION,
109
+ servers: { ...store.servers, [server.name]: current },
110
+ };
111
+ await saveFingerprintStore(baseDir, nextStore);
112
+ const event = stored === undefined ? 'tofu.first_seen_accepted_by_cli' : 'tofu.drift_accepted_by_cli';
113
+ try {
114
+ await appendAuditRecord(baseDir, {
115
+ tool_name: 'rea.tofu',
116
+ server_name: 'rea',
117
+ tier: Tier.Write,
118
+ status: InvocationStatus.Allowed,
119
+ metadata: {
120
+ event,
121
+ server: server.name,
122
+ stored_fingerprint: stored ?? null,
123
+ current_fingerprint: current,
124
+ ...(options.reason !== undefined ? { reason: options.reason } : {}),
125
+ },
126
+ });
127
+ }
128
+ catch (auditErr) {
129
+ err(`tofu: fingerprint updated, but audit append failed — operator MUST investigate: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
130
+ process.exit(2);
131
+ }
132
+ const shortPrior = stored !== undefined ? stored.slice(0, 12) : '(first-seen)';
133
+ log(`tofu: accepted "${server.name}" — stored=${shortPrior} → current=${current.slice(0, 12)}. Next \`rea serve\` will classify as unchanged.`);
134
+ }
@@ -237,6 +237,10 @@ export async function performRotation(auditFile, now = new Date()) {
237
237
  autonomy_level: 'system',
238
238
  duration_ms: 0,
239
239
  prev_hash: tailHash,
240
+ // Defect P: rotation markers are written by rea itself, not by an
241
+ // external caller of appendAuditRecord() — tag as rea-cli so the
242
+ // hash chain remains consistent under the post-P schema.
243
+ emission_source: 'rea-cli',
240
244
  metadata: {
241
245
  rotated_from: path.basename(rotatedPath),
242
246
  rotated_at: now.toISOString(),
@@ -1,4 +1,31 @@
1
1
  import type { Tier, InvocationStatus } from '../../policy/types.js';
2
+ /**
3
+ * Emission-path discriminator for the audit record (defect P).
4
+ *
5
+ * The push-review gate trusts `tool_name: "codex.review"` records to certify
6
+ * a real Codex adversarial review ran on the given commit SHA. Before this
7
+ * field existed, any script with filesystem access to `node_modules` could
8
+ * call `appendAuditRecord(...)` with a `codex.review` tool name and forge
9
+ * the certification — the governance promise was a convention, not enforced.
10
+ *
11
+ * `emission_source` tags the code path that wrote the record:
12
+ *
13
+ * - `"rea-cli"` — emitted by the `rea` CLI itself (e.g. `rea audit
14
+ * record codex-review`). The rea CLI is classified by
15
+ * `reaCommandTier()` (defect E) and is an audited,
16
+ * policy-governed entry point.
17
+ * - `"codex-cli"` — emitted by the Codex adversarial review path itself,
18
+ * the authoritative source.
19
+ * - `"other"` — every other caller of the public
20
+ * `appendAuditRecord()` helper (consumer plugins,
21
+ * ad-hoc scripts, tests). Legitimate for event types
22
+ * OTHER than `codex.review`; REJECTED by the
23
+ * push-review cache gate for `codex.review` lookups.
24
+ *
25
+ * The field is part of the hashed record body — it cannot be altered after
26
+ * the fact without breaking the chain.
27
+ */
28
+ export type EmissionSource = 'rea-cli' | 'codex-cli' | 'other';
2
29
  export interface AuditRecord {
3
30
  timestamp: string;
4
31
  session_id: string;
@@ -21,6 +48,14 @@ export interface AuditRecord {
21
48
  * the redaction middleware runs on `ctx.arguments`, not on metadata.
22
49
  */
23
50
  metadata?: Record<string, unknown>;
51
+ /**
52
+ * Defect P (0.10.1). Discriminates the emission path: `"rea-cli"` for
53
+ * rea's own CLI, `"codex-cli"` for the Codex adversarial reviewer,
54
+ * `"other"` for every other caller of the public audit helper. Required
55
+ * field; the push-review gate refuses to accept `codex.review` records
56
+ * whose source is `"other"` (or missing, for pre-0.10.1 legacy records).
57
+ */
58
+ emission_source: EmissionSource;
24
59
  hash: string;
25
60
  prev_hash: string;
26
61
  }
@@ -95,6 +95,12 @@ metrics) {
95
95
  autonomy_level: autonomyLevel,
96
96
  duration_ms,
97
97
  prev_hash: prevHash,
98
+ // Defect P: gateway middleware records every proxied tool call.
99
+ // rea itself is the writer — tag as rea-cli so the schema is
100
+ // consistent. "rea-cli" here is a misnomer (the gateway isn't a
101
+ // CLI) but is part of the stable 0.10.1 discriminator set;
102
+ // semantically it means "written by @bookedsolid/rea itself".
103
+ emission_source: 'rea-cli',
98
104
  };
99
105
  if (ctx.error) {
100
106
  recordBase.error = ctx.error;
@@ -141,7 +141,10 @@ async function emitSideEffects(baseDir, c, log) {
141
141
  boxLine(` current: ${c.current.slice(0, 16)}…`),
142
142
  boxLine(''),
143
143
  boxLine(' The server will NOT connect. Other servers remain up.'),
144
- boxLine(' To accept (once): REA_ACCEPT_DRIFT=<name> rea serve'),
144
+ boxLine(' After a legitimate registry edit:'),
145
+ boxLine(` rea tofu accept ${c.server} --reason "<why>"`),
146
+ boxLine(' One-shot bypass (not recommended):'),
147
+ boxLine(` REA_ACCEPT_DRIFT=${c.server} rea serve`),
145
148
  ` ╚${'═'.repeat(BOX_INNER_WIDTH)}╝`,
146
149
  '',
147
150
  ].join('\n'));
@@ -719,12 +719,20 @@ pr_core_run() {
719
719
  # fail-closed and require an explicit review.
720
720
  local SOURCE_SHA="" MERGE_BASE="" TARGET_BRANCH="" SOURCE_REF=""
721
721
  local HAS_DELETE=0 BEST_COUNT=0
722
- local rec local_sha remote_sha local_ref remote_ref target mb mb_status count count_status
722
+ local rec local_sha remote_sha local_ref remote_ref target resolved_base mb mb_status count count_status
723
723
  for rec in "${REFSPEC_RECORDS[@]}"; do
724
724
  IFS='|' read -r local_sha remote_sha local_ref remote_ref <<<"$rec"
725
725
  target="${remote_ref#refs/heads/}"
726
726
  target="${target#refs/for/}"
727
727
  [[ -z "$target" ]] && target="main"
728
+ # Defect N: track the SEMANTIC base (the ref the diff was anchored on)
729
+ # distinctly from `target` (the pushed remote ref). For a tracked branch
730
+ # they coincide; for a new branch, `target` is the branch name being
731
+ # created — which is NOT what we reviewed against, so `Target:` must
732
+ # echo `resolved_base` instead. Default to `target` for the tracked
733
+ # case; the new-branch path overrides with the resolved default_ref
734
+ # short name below.
735
+ resolved_base="$target"
728
736
 
729
737
  if [[ "$local_sha" == "$ZERO_SHA" ]]; then
730
738
  HAS_DELETE=1
@@ -774,25 +782,81 @@ pr_core_run() {
774
782
  #
775
783
  # argv_remote is set from the adapter's argv (git passes the remote name
776
784
  # as $1 on pre-push); defaults to "origin" when absent (BUG-008 sniff).
777
- local default_ref default_ref_status
778
- default_ref=$(cd "$REA_ROOT" && git symbolic-ref "refs/remotes/${argv_remote}/HEAD" 2>/dev/null)
779
- default_ref_status=$?
780
- if [[ "$default_ref_status" -ne 0 || -z "$default_ref" ]]; then
781
- # symbolic-ref failed (common on shallow or mirror clones where
782
- # origin/HEAD was never set). Probe the common default-branch names in
783
- # order: main, then master. Both are remote-tracking refs and still
784
- # server-authoritative; the order matters only for projects that still
785
- # default to `master` (older internal forks), where without this
786
- # fallback the first push of a new branch would fail closed.
787
- if cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/main" >/dev/null 2>&1; then
788
- default_ref="refs/remotes/${argv_remote}/main"
789
- elif cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/master" >/dev/null 2>&1; then
790
- default_ref="refs/remotes/${argv_remote}/master"
791
- else
792
- default_ref=""
785
+ #
786
+ # Defect N (0.10.1): BEFORE falling back to the remote's default branch,
787
+ # consult per-branch config `branch.<source>.base`. A feature branch
788
+ # targeting `dev` in a main-as-production repo would otherwise resolve
789
+ # against `origin/main` silently, producing a diff that spans the entire
790
+ # dev→main history reviewers see "Scope: 28690 lines" for a 4-file
791
+ # change. The git-config route uses local branch knowledge that is
792
+ # authoritative for this working copy (set via `git branch --set-upstream`,
793
+ # or by CI tooling that tracks the intended target). This is consulted
794
+ # BEFORE origin/HEAD because the latter is a server-default that may
795
+ # mis-represent the reviewer's actual intent for this specific branch.
796
+ local default_ref default_ref_status configured_base source_branch
797
+ source_branch="${local_ref#refs/heads/}"
798
+ default_ref=""
799
+ # Codex 0.10.1 finding #1: `local` is function-scoped, not loop-
800
+ # iteration-scoped — without an explicit reset, iteration N inherits
801
+ # iteration N-1's configured_base and falsely promotes resolved_base
802
+ # when the current refspec's local_ref does NOT begin with refs/heads/
803
+ # (tag push, gerrit-style refs/for/, etc.). Reset before every
804
+ # potential assignment so each iteration sees a clean slate.
805
+ configured_base=""
806
+
807
+ if [[ -n "$source_branch" && "$source_branch" != "HEAD" ]]; then
808
+ configured_base=$(cd "$REA_ROOT" && git config --get "branch.${source_branch}.base" 2>/dev/null || echo "")
809
+ if [[ -n "$configured_base" ]]; then
810
+ # Prefer the REMOTE-TRACKING form so the gate still anchors on a
811
+ # server-authoritative ref (see the local-ref hijack explanation
812
+ # above). Fall back to the local short ref only if the remote
813
+ # counterpart doesn't exist, with a visible WARN on stderr — the
814
+ # local ref is less trustworthy and the reviewer should know.
815
+ if cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/${configured_base}" >/dev/null 2>&1; then
816
+ default_ref="refs/remotes/${argv_remote}/${configured_base}"
817
+ elif cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/heads/${configured_base}" >/dev/null 2>&1; then
818
+ default_ref="refs/heads/${configured_base}"
819
+ printf 'WARN: branch.%s.base=%s resolved to local ref; remote counterpart %s/%s missing — reviewer-side diff may be stale\n' \
820
+ "$source_branch" "$configured_base" "$argv_remote" "$configured_base" >&2
821
+ fi
822
+ fi
823
+ fi
824
+
825
+ if [[ -z "$default_ref" ]]; then
826
+ default_ref=$(cd "$REA_ROOT" && git symbolic-ref "refs/remotes/${argv_remote}/HEAD" 2>/dev/null)
827
+ default_ref_status=$?
828
+ if [[ "$default_ref_status" -ne 0 || -z "$default_ref" ]]; then
829
+ # symbolic-ref failed (common on shallow or mirror clones where
830
+ # origin/HEAD was never set). Probe the common default-branch names in
831
+ # order: main, then master. Both are remote-tracking refs and still
832
+ # server-authoritative; the order matters only for projects that still
833
+ # default to `master` (older internal forks), where without this
834
+ # fallback the first push of a new branch would fail closed.
835
+ if cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/main" >/dev/null 2>&1; then
836
+ default_ref="refs/remotes/${argv_remote}/main"
837
+ elif cd "$REA_ROOT" && git rev-parse --verify --quiet "refs/remotes/${argv_remote}/master" >/dev/null 2>&1; then
838
+ default_ref="refs/remotes/${argv_remote}/master"
839
+ else
840
+ default_ref=""
841
+ fi
793
842
  fi
794
843
  fi
795
844
  if [[ -n "$default_ref" ]]; then
845
+ # Defect N: if operator-configured `branch.<source>.base` resolved the
846
+ # ref we're about to diff against, overwrite `resolved_base` with the
847
+ # short name so TARGET_BRANCH (and the Target: label) reflect the
848
+ # actual review anchor. Without an explicit config override, leave
849
+ # `resolved_base` at the refspec target — this preserves the cache
850
+ # contract for new-branch pushes where remote_ref is the same as the
851
+ # source branch (the common case) and for bare pushes that
852
+ # argv-resolve via `@{upstream}`. Only operators who opted into a
853
+ # per-branch base get the label promoted, keeping the change
854
+ # backward-compatible for every other path.
855
+ if [[ -n "$configured_base" ]]; then
856
+ resolved_base="${default_ref#refs/remotes/${argv_remote}/}"
857
+ resolved_base="${resolved_base#refs/heads/}"
858
+ [[ -z "$resolved_base" ]] && resolved_base="$default_ref"
859
+ fi
796
860
  mb=$(cd "$REA_ROOT" && git merge-base "$default_ref" "$local_sha" 2>/dev/null || echo "")
797
861
  if [[ -z "$mb" ]]; then
798
862
  # default_ref resolved but merge-base came back empty (unrelated
@@ -867,13 +931,40 @@ pr_core_run() {
867
931
  if [[ "$CODEX_WAIVER_ACTIVE" == "1" ]]; then
868
932
  _codex_ok=1
869
933
  elif [[ -f "$_audit" ]]; then
870
- if jq -e --arg sha "$local_sha" '
871
- select(
872
- .tool_name == "codex.review"
873
- and .metadata.head_sha == $sha
874
- and (.metadata.verdict == "pass" or .metadata.verdict == "concerns")
875
- )
876
- ' "$_audit" >/dev/null 2>&1; then
934
+ # Defect P (0.10.1): require .emission_source == "rea-cli" or
935
+ # "codex-cli" so agents cannot forge a codex.review record by
936
+ # directly calling appendAuditRecord() from an ad-hoc .mjs script
937
+ # (the generic helper stamps "other"). Legacy records (pre-0.10.1)
938
+ # have no emission_source field and are rejected the first push
939
+ # on an upgraded consumer requires a fresh `rea audit record
940
+ # codex-review` (or Codex CLI emission) which stamps "rea-cli".
941
+ #
942
+ # Defect T/U (0.10.2): read the audit file as raw lines and parse
943
+ # each with `fromjson?`. Before 0.10.2 this scan used
944
+ # `jq -e '<filter>' "$_audit"` which feeds the file as a single
945
+ # JSON stream — a single malformed line (literal backslash-u
946
+ # followed by non-hex characters inside a string, for example)
947
+ # makes jq bail on the stream with exit 2 and the `select` never
948
+ # runs against ANY record, including legitimate codex.review
949
+ # entries further down the file. The failure is total: every
950
+ # cached codex.review receipt becomes unreachable until the
951
+ # corrupt line is hand-edited out. `-R` flips jq into raw-input
952
+ # mode (one string per line), and `fromjson?` is the error-
953
+ # suppressing parser — malformed lines silently yield empty
954
+ # output. The `select` filter then inspects each successfully
955
+ # parsed record exactly as before, and `grep -q .` detects
956
+ # whether ANY record survived the filter. Lines 1107 and the
957
+ # earlier cache_result scans at :432/:612 operate on a single
958
+ # printf'd JSON string, not audit.jsonl, so they remain `jq -e`.
959
+ if jq -R --arg sha "$local_sha" '
960
+ fromjson?
961
+ | select(
962
+ .tool_name == "codex.review"
963
+ and .metadata.head_sha == $sha
964
+ and (.metadata.verdict == "pass" or .metadata.verdict == "concerns")
965
+ and (.emission_source == "rea-cli" or .emission_source == "codex-cli")
966
+ )
967
+ ' "$_audit" 2>/dev/null | grep -q .; then
877
968
  _codex_ok=1
878
969
  fi
879
970
  fi
@@ -918,7 +1009,12 @@ pr_core_run() {
918
1009
  if [[ -z "$SOURCE_SHA" ]] || [[ "$count" -gt "$BEST_COUNT" ]]; then
919
1010
  SOURCE_SHA="$local_sha"
920
1011
  MERGE_BASE="$mb"
921
- TARGET_BRANCH="$target"
1012
+ # Defect N: use `resolved_base` (the actual merge-base anchor we
1013
+ # diffed against), not `target` (the pushed-ref name). For tracked
1014
+ # branches these are the same; for new branches without an upstream
1015
+ # the distinction is the difference between "Target: <source-branch>"
1016
+ # (misleading) and "Target: main" (or whichever base was resolved).
1017
+ TARGET_BRANCH="$resolved_base"
922
1018
  SOURCE_REF="$local_ref"
923
1019
  BEST_COUNT="$count"
924
1020
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
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)",