@bookedsolid/rea 0.9.4 → 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/README.md CHANGED
@@ -480,6 +480,111 @@ and `ClaudeSelfReviewer` is the in-process fallback (tagged
480
480
  `degraded: true` in the audit record so self-review is visible and
481
481
  countable).
482
482
 
483
+ ## Agent push workflow — satisfying the push-review gate
484
+
485
+ When `git push` is blocked by `push-review-gate.sh` the gate prints
486
+ remediation steps. This section is the canonical one-command flow the
487
+ steps reduce to. Agents should copy-paste this verbatim; humans should
488
+ expect agents to.
489
+
490
+ ### 1. Run the adversarial review
491
+
492
+ ```bash
493
+ # From an interactive Claude Code session:
494
+ /codex-review
495
+ ```
496
+
497
+ This invokes the `codex-adversarial` agent, which records a
498
+ `codex.review` audit entry with `verdict: pass | concerns | blocking |
499
+ error` and a `finding_count`. The push gate looks up that entry by
500
+ `head_sha + verdict ∈ {pass, concerns}`.
501
+
502
+ ### 2. Record-and-cache in one CLI call
503
+
504
+ If you already have a review verdict (from `/codex-review`, or from a
505
+ manual Codex run, or from an offline review) emit the audit record AND
506
+ update the push-review cache with a single command:
507
+
508
+ ```bash
509
+ rea audit record codex-review \
510
+ --head-sha "$(git rev-parse HEAD)" \
511
+ --branch "$(git rev-parse --abbrev-ref HEAD)" \
512
+ --target main \
513
+ --verdict pass \
514
+ --finding-count 0 \
515
+ --summary "no findings" \
516
+ --also-set-cache
517
+ ```
518
+
519
+ `--also-set-cache` writes both `.rea/audit.jsonl` and
520
+ `.rea/review-cache.jsonl` in the same invocation (two sequential
521
+ appends, not a two-phase commit — but close enough in practice that the
522
+ push-gate lookup cannot see the audit record without the cache entry
523
+ unless a crash lands between them). Without it, the audit record lands
524
+ but the cache stays cold — and the next `git push` pays for a re-review
525
+ even though the audit trail already shows the review happened.
526
+ `--also-set-cache` is what the gate's remediation text should be reduced
527
+ to.
528
+
529
+ Verdict mapping for the cache leg:
530
+
531
+ | `--verdict` | Cache `result` | Cache `reason` |
532
+ | ------------ | -------------- | -------------- |
533
+ | `pass` | `pass` | — (omitted) |
534
+ | `concerns` | `pass` | `codex:concerns` |
535
+ | `blocking` | `fail` | `codex:blocking` |
536
+ | `error` | `fail` | `codex:error` |
537
+
538
+ ### 3. Push
539
+
540
+ ```bash
541
+ git push
542
+ ```
543
+
544
+ The gate hits the cache, sees `{"hit":true,"result":"pass"}`, and exits
545
+ 0 on the first attempt. No `!`-bash escapes, no manual audit writing,
546
+ no separate `rea cache set` invocation.
547
+
548
+ ### SDK alternative
549
+
550
+ When embedding the flow in a TypeScript tool instead of shelling out,
551
+ import the public audit helper:
552
+
553
+ ```ts
554
+ import {
555
+ appendAuditRecord,
556
+ CODEX_REVIEW_SERVER_NAME,
557
+ CODEX_REVIEW_TOOL_NAME,
558
+ InvocationStatus,
559
+ Tier,
560
+ } from '@bookedsolid/rea/audit';
561
+
562
+ await appendAuditRecord(process.cwd(), {
563
+ tool_name: CODEX_REVIEW_TOOL_NAME,
564
+ server_name: CODEX_REVIEW_SERVER_NAME,
565
+ tier: Tier.Read,
566
+ status: InvocationStatus.Allowed,
567
+ metadata: {
568
+ head_sha: headSha,
569
+ target: 'main',
570
+ finding_count: 0,
571
+ verdict: 'pass',
572
+ },
573
+ });
574
+ ```
575
+
576
+ The CLI wraps exactly this — use the CLI unless the host is already a
577
+ TypeScript process that wants to avoid the subprocess roundtrip.
578
+
579
+ ### Agent autonomy self-consistency
580
+
581
+ At autonomy `L1`, `rea cache check`, `rea audit record codex-review`,
582
+ `rea doctor`, and `rea status` are classified **Read tier** — they
583
+ cannot be denied by REA's own middleware. `rea cache set` is Write
584
+ tier and is still allowed at L1. `rea freeze` is Destructive tier and
585
+ is denied at L1 (deny-reason includes the subcommand, e.g.
586
+ `Bash (rea freeze)`, not just `Bash`).
587
+
483
588
  ## Hooks
484
589
 
485
590
  Fourteen hooks. Each does one thing.
package/THREAT_MODEL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Threat Model — REA Gateway and Hook Layer
2
2
 
3
- Version: 0.9.x | Last updated: 2026-04-21
3
+ Version: 0.10.x | Last updated: 2026-04-21
4
4
 
5
5
  ---
6
6
 
@@ -475,6 +475,24 @@ Ref: `src/registry/fingerprint.ts` (`canonicalize()`, `fingerprintServer()`), `s
475
475
 
476
476
  Ref: `src/gateway/middleware/injection.ts`, `src/gateway/middleware/injection.test.ts`.
477
477
 
478
+ ### 5.22 Hook-Patch Session Env Var (0.10.0, Defect I)
479
+
480
+ **Threat:** `settings-protection.sh` blanket-blocks edits under `.claude/hooks/`. That is the correct default (agents must not silently rewrite safety infrastructure) but it leaves no documented path for applying upstream-sourced CodeRabbit/Codex findings on hook scripts during a live session. Before 0.10.0, operators reached for `!`-bash to sidestep the hook entirely, which dodged every audit surface — a worse outcome than the block it was working around. The source-of-truth `hooks/` directory is intentionally editable by default; `rea init` is the supply-chain step that promotes those edits into `.claude/hooks/`, so gating at the runtime directory is where the runtime-trust decision belongs.
481
+
482
+ **Mitigations:**
483
+
484
+ - `REA_HOOK_PATCH_SESSION=<reason>` is a **session-scoped**, **self-revoking** bypass. When set to a non-empty value, `settings-protection.sh` (`hooks/settings-protection.sh:219-336`) allows edits ONLY to paths under `.claude/hooks/` (the runtime directory). Every other protected path (`.rea/policy.yaml`, `.rea/HALT`, `.claude/settings.json`, `.claude/settings.local.json`) remains blocked — this is a hook-maintenance escape hatch, not a policy-editing one.
485
+ - Order of enforcement: (§5a) reject any path containing a `..` segment in either raw or normalized form; (§6) deny hard-protected paths; (§6b) only then consult the patch-session allowlist. This ordering closes a pre-merge Codex-surfaced bypass where `.claude/hooks/../settings.json` slipped through the old patch-session case-glob and reached `.claude/settings.json` on disk with the env var set.
486
+ - The session boundary IS the expiry. A new shell requires a fresh opt-in. There is no auto-expiry countdown to race, no policy-file flag that can silently linger across sessions, no CI refusal — the env var lives and dies with the process that set it.
487
+ - Every allowed edit emits a `hooks.patch.session` audit record routed through the TypeScript `appendAuditRecord()` chain (`src/audit/append.ts`) so the entry extends the hash chain and cannot be silently back-filled. The payload carries: `metadata.reason` (env-var value verbatim, control-character-stripped before display), `metadata.file` (normalized path), `metadata.sha_before` (pre-edit SHA-256 of the file, captured via the portable `sha256sum`/`shasum`/`openssl` fallback chain), `metadata.actor.{name,email}` (from `git config`), `metadata.pid`, `metadata.ppid`. The hook resolves the audit module via two paths, in order: (1) `import("@bookedsolid/rea/audit")` — succeeds for `rea init`-installed consumers via `node_modules/`, and via package self-reference when running inside the rea source repo; (2) `import(REA_ROOT + "/dist/audit/append.js")` — direct file import for development checkouts. If BOTH resolve-paths fail, the edit is **refused outright** (exit 2). There is no bash-native jq fallback because that path would skip `prev_hash`/`hash` computation and silently degrade the chain integrity `rea audit verify` relies on — audit-drop is indistinguishable from chain tampering, and a hook that can drop audit on demand is a governance failure, not a usability win.
488
+ - Note on `codex.review` entries: audit records emitted by `rea audit record codex-review` (and the related hook-append paths) are **local assertions** recorded by the operator's agent runtime. They extend the local hash chain — so retroactive tampering is detectable — but they are not signed by Codex or by any external party. Operators auditing a consumer repo must treat the presence of a `codex.review` record as evidence that SOMEONE claimed a Codex pass at that SHA, not cryptographic proof that Codex itself issued that verdict.
489
+
490
+ **Trust boundary:** `REA_HOOK_PATCH_SESSION` is advisory for the tool-gate bypass only. It does not raise the autonomy level, does not disable any other hook, and does not relax blocked-paths, attribution, or secret-scanning enforcement. The gateway-layer middleware chain does not read this env var — it only affects the shell hook that guards `.claude/hooks/`. A malicious agent that set the env var for itself without human intent would gain the ability to mutate runtime hooks, but every such mutation is auditable and the session ends with the shell; there is no persistent escalation path.
491
+
492
+ **Residual risk:** An operator who declares a reason and then forgets to unset the variable carries the bypass across every subsequent hook edit in that shell. Mitigation: treat the env var as a one-use stand and unset it immediately after the intended patch; the audit trail will show repeated `hooks.patch.session` records if the lifetime leaks. A follow-up hardening could scope the var to a single edit by tying it to a nonce committed to the audit record and invalidating on next append — not shipped in 0.10.0 because the session-boundary model matches how operators actually reason about the feature.
493
+
494
+ Ref: `hooks/settings-protection.sh:86-336`, `.claude/hooks/settings-protection.sh` (dogfood mirror), `__tests__/hooks/settings-protection-patch-session.test.ts`, `src/audit/append.ts`.
495
+
478
496
  ---
479
497
 
480
498
  ## 6. Residual Risks and Open Issues
@@ -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';
@@ -10,6 +10,7 @@
10
10
  * explicit by definition, and verify operates on existing files regardless
11
11
  * of policy.
12
12
  */
13
+ import { type CodexVerdict } from '../audit/append.js';
13
14
  /**
14
15
  * Reserved for future rotate knobs (e.g. `--retain N` to prune old rotated
15
16
  * files). Empty today — kept as a typed record so the call site's option
@@ -38,3 +39,33 @@ export declare function runAuditRotate(_options: AuditRotateOptions): Promise<vo
38
39
  * exit code is the primary signal.
39
40
  */
40
41
  export declare function runAuditVerify(options: AuditVerifyOptions): Promise<void>;
42
+ export interface AuditRecordCodexReviewOptions {
43
+ headSha: string;
44
+ branch: string;
45
+ target: string;
46
+ verdict: CodexVerdict;
47
+ findingCount: number;
48
+ summary?: string | undefined;
49
+ sessionId?: string | undefined;
50
+ alsoSetCache?: boolean | undefined;
51
+ }
52
+ /**
53
+ * `rea audit record codex-review` (Defect D / rea#77). Emits the single audit
54
+ * event the push-review cache gate looks up by `tool_name == "codex.review"` +
55
+ * `metadata.head_sha == <sha>` + `metadata.verdict in {pass, concerns}`. Prior
56
+ * to this command, agents had to reverse-engineer the canonical `tool_name`
57
+ * string, the hash-chain append path, and the `CodexReviewMetadata` shape —
58
+ * the most common failure mode was emitting `tool_name: "codex-adversarial-review"`
59
+ * (the agent's name) instead of `codex.review` (the event type), which the
60
+ * gate's jq predicate silently missed.
61
+ *
62
+ * `--also-set-cache` performs the audit record AND the review-cache write
63
+ * in one invocation — two sequential appends in a single process, not a
64
+ * two-phase commit. A crash between them leaves the audit entry without
65
+ * a cache row; the cache is recomputable from audit, the audit chain is
66
+ * the source of truth. What this DOES eliminate is the two-step race where
67
+ * `rea cache set` is denied by permission middleware (Defect E) after the
68
+ * audit has already been emitted, leaving the gate stuck on "audit present
69
+ * but cache cold" with no way forward.
70
+ */
71
+ export declare function runAuditRecordCodexReview(options: AuditRecordCodexReviewOptions): Promise<void>;
package/dist/cli/audit.js CHANGED
@@ -13,8 +13,12 @@
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 { appendCodexReviewAuditRecord, } from '../audit/append.js';
16
17
  import { computeHash, GENESIS_HASH } from '../audit/fs.js';
18
+ import { appendEntry as appendCacheEntry } from '../cache/review-cache.js';
17
19
  import { AUDIT_FILE, REA_DIR, err, log, reaPath } from './utils.js';
20
+ import { Tier, InvocationStatus } from '../policy/types.js';
21
+ import { codexVerdictToCacheResult } from './cache.js';
18
22
  /**
19
23
  * `rea audit rotate`. Forces a rotation now regardless of thresholds.
20
24
  * Empty audit files are a no-op — rotating an empty chain would produce a
@@ -55,36 +59,83 @@ export async function runAuditRotate(_options) {
55
59
  console.log(` A rotation marker anchors the new chain on the old tail's hash.`);
56
60
  }
57
61
  /**
58
- * Load a JSONL audit file as a record array + per-line raw text, so we can
59
- * re-hash against the exact serialization that was written. Throws on read
60
- * 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.
61
93
  */
62
94
  async function loadRecords(filePath) {
63
95
  const raw = await fs.readFile(filePath, 'utf8');
64
96
  // Drop a single trailing newline but preserve blank lines inside the file
65
97
  // so index numbers line up with real record positions.
66
98
  const trimmedTail = raw.replace(/\n$/, '');
67
- if (trimmedTail.length === 0)
68
- return { records: [], rawLines: [] };
99
+ if (trimmedTail.length === 0) {
100
+ return { records: [], recordLineMap: [], rawLines: [], parseFailures: [] };
101
+ }
69
102
  const rawLines = trimmedTail.split('\n');
70
- 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.
71
111
  try {
72
- return JSON.parse(line);
112
+ const parsed = JSON.parse(line);
113
+ records.push(parsed);
114
+ recordLineMap.push(i + 1);
73
115
  }
74
116
  catch (e) {
75
- 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
+ });
76
125
  }
77
- });
78
- return { records, rawLines };
126
+ }
127
+ return { records, recordLineMap, rawLines, parseFailures };
79
128
  }
80
- function verifyChain(fileBasename, records, expectedStartPrev) {
129
+ function verifyChain(fileBasename, records, recordLineMap, expectedStartPrev) {
81
130
  let prev = expectedStartPrev;
82
131
  for (let i = 0; i < records.length; i++) {
83
132
  const r = records[i];
133
+ const fileLineNumber = recordLineMap[i] ?? i + 1;
84
134
  if (r.prev_hash !== prev) {
85
135
  return {
86
136
  file: fileBasename,
87
- lineIndex: i,
137
+ recordIndex: i,
138
+ fileLineNumber,
88
139
  reason: 'prev_hash does not match previous record',
89
140
  expected: prev,
90
141
  actual: r.prev_hash,
@@ -97,7 +148,8 @@ function verifyChain(fileBasename, records, expectedStartPrev) {
97
148
  if (recomputed !== hash) {
98
149
  return {
99
150
  file: fileBasename,
100
- lineIndex: i,
151
+ recordIndex: i,
152
+ fileLineNumber,
101
153
  reason: 'stored hash does not match recomputed hash over record body',
102
154
  expected: recomputed,
103
155
  actual: hash,
@@ -170,36 +222,151 @@ export async function runAuditVerify(options) {
170
222
  console.error(` Expected: ${path.relative(baseDir, currentAudit)}`);
171
223
  process.exit(1);
172
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".
173
232
  let expectedPrev = GENESIS_HASH;
174
233
  let totalRecords = 0;
234
+ const allParseFailures = [];
235
+ let chainFailure = null;
236
+ let chainFailureFile = null;
175
237
  for (const filePath of filesToVerify) {
176
- let records;
238
+ let loaded;
177
239
  try {
178
- ({ records } = await loadRecords(filePath));
240
+ loaded = await loadRecords(filePath);
179
241
  }
180
242
  catch (e) {
181
243
  err(`${e.message}`);
182
244
  process.exit(1);
183
245
  }
184
- const basename = path.basename(filePath);
185
- const failure = verifyChain(basename, records, expectedPrev);
186
- if (failure !== null) {
187
- err(`Audit chain TAMPER DETECTED in ${failure.file}`);
188
- console.error(` Record index: ${failure.lineIndex} (0-based within file)`);
189
- console.error(` Reason: ${failure.reason}`);
190
- if (failure.expected !== undefined) {
191
- 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;
192
260
  }
193
- if (failure.actual !== undefined) {
194
- console.error(` Actual: ${failure.actual}`);
261
+ else if (records.length > 0) {
262
+ expectedPrev = records[records.length - 1].hash;
195
263
  }
196
- process.exit(1);
197
- }
198
- // Advance the cross-file anchor for the next file.
199
- if (records.length > 0) {
200
- expectedPrev = records[records.length - 1].hash;
201
264
  }
202
265
  totalRecords += records.length;
203
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
+ }
204
301
  log(`Audit chain verified: ${totalRecords} records across ${filesToVerify.length} file(s) — clean.`);
205
302
  }
303
+ /**
304
+ * `rea audit record codex-review` (Defect D / rea#77). Emits the single audit
305
+ * event the push-review cache gate looks up by `tool_name == "codex.review"` +
306
+ * `metadata.head_sha == <sha>` + `metadata.verdict in {pass, concerns}`. Prior
307
+ * to this command, agents had to reverse-engineer the canonical `tool_name`
308
+ * string, the hash-chain append path, and the `CodexReviewMetadata` shape —
309
+ * the most common failure mode was emitting `tool_name: "codex-adversarial-review"`
310
+ * (the agent's name) instead of `codex.review` (the event type), which the
311
+ * gate's jq predicate silently missed.
312
+ *
313
+ * `--also-set-cache` performs the audit record AND the review-cache write
314
+ * in one invocation — two sequential appends in a single process, not a
315
+ * two-phase commit. A crash between them leaves the audit entry without
316
+ * a cache row; the cache is recomputable from audit, the audit chain is
317
+ * the source of truth. What this DOES eliminate is the two-step race where
318
+ * `rea cache set` is denied by permission middleware (Defect E) after the
319
+ * audit has already been emitted, leaving the gate stuck on "audit present
320
+ * but cache cold" with no way forward.
321
+ */
322
+ export async function runAuditRecordCodexReview(options) {
323
+ if (options.headSha.length === 0) {
324
+ err('--head-sha must not be empty');
325
+ process.exit(1);
326
+ }
327
+ if (options.branch.length === 0) {
328
+ err('--branch must not be empty');
329
+ process.exit(1);
330
+ }
331
+ if (options.target.length === 0) {
332
+ err('--target must not be empty');
333
+ process.exit(1);
334
+ }
335
+ if (!Number.isFinite(options.findingCount) || options.findingCount < 0) {
336
+ err(`--finding-count must be a non-negative integer; got ${options.findingCount}`);
337
+ process.exit(1);
338
+ }
339
+ const baseDir = process.cwd();
340
+ const metadata = {
341
+ head_sha: options.headSha,
342
+ target: options.target,
343
+ finding_count: options.findingCount,
344
+ verdict: options.verdict,
345
+ };
346
+ if (options.summary !== undefined && options.summary.length > 0) {
347
+ metadata.summary = options.summary;
348
+ }
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, {
355
+ tier: Tier.Read,
356
+ status: InvocationStatus.Allowed,
357
+ ...(options.sessionId !== undefined ? { session_id: options.sessionId } : {}),
358
+ metadata,
359
+ });
360
+ log(`Recorded codex.review (${options.verdict}, ${options.findingCount} finding${options.findingCount === 1 ? '' : 's'}) for ${options.headSha.slice(0, 12)}.`);
361
+ if (options.alsoSetCache === true) {
362
+ const effect = codexVerdictToCacheResult(options.verdict);
363
+ const cacheEntry = await appendCacheEntry(baseDir, {
364
+ sha: options.headSha,
365
+ branch: options.branch,
366
+ base: options.target,
367
+ result: effect.result,
368
+ ...(effect.reason !== undefined ? { reason: effect.reason } : {}),
369
+ });
370
+ log(`Cached ${cacheEntry.result} for ${cacheEntry.sha.slice(0, 12)} (${cacheEntry.branch} → ${cacheEntry.base}).`);
371
+ }
372
+ }