@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.
- package/dist/audit/append.d.ts +35 -1
- package/dist/audit/append.js +79 -11
- package/dist/cli/audit.js +130 -34
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/index.js +18 -0
- package/dist/cli/tofu.d.ts +57 -0
- package/dist/cli/tofu.js +134 -0
- package/dist/gateway/audit/rotator.js +4 -0
- package/dist/gateway/middleware/audit-types.d.ts +35 -0
- package/dist/gateway/middleware/audit.js +6 -0
- package/dist/registry/tofu-gate.js +4 -1
- package/hooks/_lib/push-review-core.sh +121 -25
- package/package.json +1 -1
package/dist/audit/append.d.ts
CHANGED
|
@@ -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
|
-
|
|
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';
|
package/dist/audit/append.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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 =
|
|
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
|
-
|
|
112
|
+
const parsed = JSON.parse(line);
|
|
113
|
+
records.push(parsed);
|
|
114
|
+
recordLineMap.push(i + 1);
|
|
77
115
|
}
|
|
78
116
|
catch (e) {
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
238
|
+
let loaded;
|
|
181
239
|
try {
|
|
182
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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 (
|
|
198
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 } : {}),
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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 (
|
|
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>;
|
package/dist/cli/tofu.js
ADDED
|
@@ -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('
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
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.
|
|
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)",
|