@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 +105 -0
- package/THREAT_MODEL.md +19 -1
- package/dist/audit/append.d.ts +35 -1
- package/dist/audit/append.js +79 -11
- package/dist/cli/audit.d.ts +31 -0
- package/dist/cli/audit.js +197 -30
- package/dist/cli/cache.d.ts +33 -1
- package/dist/cli/cache.js +40 -2
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/index.js +58 -2
- package/dist/cli/tofu.d.ts +57 -0
- package/dist/cli/tofu.js +134 -0
- package/dist/config/tier-map.d.ts +1 -0
- package/dist/config/tier-map.js +210 -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/gateway/middleware/blocked-paths.js +38 -0
- package/dist/gateway/middleware/policy.js +68 -3
- package/dist/registry/tofu-gate.js +4 -1
- package/hooks/_lib/push-review-core.sh +159 -26
- package/hooks/commit-review-gate.sh +25 -1
- package/hooks/settings-protection.sh +297 -64
- package/package.json +1 -1
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.
|
|
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
|
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.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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 =
|
|
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
|
-
|
|
112
|
+
const parsed = JSON.parse(line);
|
|
113
|
+
records.push(parsed);
|
|
114
|
+
recordLineMap.push(i + 1);
|
|
73
115
|
}
|
|
74
116
|
catch (e) {
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
238
|
+
let loaded;
|
|
177
239
|
try {
|
|
178
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 (
|
|
194
|
-
|
|
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
|
+
}
|