@bookedsolid/rea 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/audit/append.d.ts +35 -1
  2. package/dist/audit/append.js +79 -11
  3. package/dist/cli/audit.js +130 -34
  4. package/dist/cli/doctor.js +1 -1
  5. package/dist/cli/index.js +18 -0
  6. package/dist/cli/tofu.d.ts +57 -0
  7. package/dist/cli/tofu.js +134 -0
  8. package/dist/gateway/audit/rotator.js +4 -0
  9. package/dist/gateway/middleware/audit-types.d.ts +35 -0
  10. package/dist/gateway/middleware/audit.js +6 -0
  11. package/dist/hooks/review-gate/args.d.ts +126 -0
  12. package/dist/hooks/review-gate/args.js +315 -0
  13. package/dist/hooks/review-gate/banner.d.ts +97 -0
  14. package/dist/hooks/review-gate/banner.js +172 -0
  15. package/dist/hooks/review-gate/cache-key.d.ts +55 -0
  16. package/dist/hooks/review-gate/cache-key.js +41 -0
  17. package/dist/hooks/review-gate/constants.d.ts +26 -0
  18. package/dist/hooks/review-gate/constants.js +34 -0
  19. package/dist/hooks/review-gate/errors.d.ts +72 -0
  20. package/dist/hooks/review-gate/errors.js +100 -0
  21. package/dist/hooks/review-gate/hash.d.ts +43 -0
  22. package/dist/hooks/review-gate/hash.js +46 -0
  23. package/dist/hooks/review-gate/index.d.ts +21 -0
  24. package/dist/hooks/review-gate/index.js +21 -0
  25. package/dist/hooks/review-gate/metadata.d.ts +98 -0
  26. package/dist/hooks/review-gate/metadata.js +158 -0
  27. package/dist/hooks/review-gate/policy.d.ts +55 -0
  28. package/dist/hooks/review-gate/policy.js +71 -0
  29. package/dist/hooks/review-gate/protected-paths.d.ts +46 -0
  30. package/dist/hooks/review-gate/protected-paths.js +76 -0
  31. package/dist/registry/tofu-gate.js +4 -1
  32. package/hooks/_lib/push-review-core.sh +121 -25
  33. package/package.json +1 -1
@@ -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(),