@bookedsolid/rea 0.9.3 → 0.10.0
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/cli/audit.d.ts +31 -0
- package/dist/cli/audit.js +71 -0
- package/dist/cli/cache.d.ts +33 -1
- package/dist/cli/cache.js +40 -2
- package/dist/cli/index.js +40 -2
- package/dist/config/tier-map.d.ts +1 -0
- package/dist/config/tier-map.js +210 -0
- package/dist/gateway/middleware/blocked-paths.js +38 -0
- package/dist/gateway/middleware/policy.js +68 -3
- package/hooks/_lib/common.sh +6 -1
- package/hooks/_lib/push-review-core.sh +115 -19
- package/hooks/commit-review-gate.sh +119 -7
- 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/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 { appendAuditRecord, CODEX_REVIEW_SERVER_NAME, CODEX_REVIEW_TOOL_NAME, } 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
|
|
@@ -203,3 +207,70 @@ export async function runAuditVerify(options) {
|
|
|
203
207
|
}
|
|
204
208
|
log(`Audit chain verified: ${totalRecords} records across ${filesToVerify.length} file(s) — clean.`);
|
|
205
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* `rea audit record codex-review` (Defect D / rea#77). Emits the single audit
|
|
212
|
+
* event the push-review cache gate looks up by `tool_name == "codex.review"` +
|
|
213
|
+
* `metadata.head_sha == <sha>` + `metadata.verdict in {pass, concerns}`. Prior
|
|
214
|
+
* to this command, agents had to reverse-engineer the canonical `tool_name`
|
|
215
|
+
* string, the hash-chain append path, and the `CodexReviewMetadata` shape —
|
|
216
|
+
* the most common failure mode was emitting `tool_name: "codex-adversarial-review"`
|
|
217
|
+
* (the agent's name) instead of `codex.review` (the event type), which the
|
|
218
|
+
* gate's jq predicate silently missed.
|
|
219
|
+
*
|
|
220
|
+
* `--also-set-cache` performs the audit record AND the review-cache write
|
|
221
|
+
* in one invocation — two sequential appends in a single process, not a
|
|
222
|
+
* two-phase commit. A crash between them leaves the audit entry without
|
|
223
|
+
* a cache row; the cache is recomputable from audit, the audit chain is
|
|
224
|
+
* the source of truth. What this DOES eliminate is the two-step race where
|
|
225
|
+
* `rea cache set` is denied by permission middleware (Defect E) after the
|
|
226
|
+
* audit has already been emitted, leaving the gate stuck on "audit present
|
|
227
|
+
* but cache cold" with no way forward.
|
|
228
|
+
*/
|
|
229
|
+
export async function runAuditRecordCodexReview(options) {
|
|
230
|
+
if (options.headSha.length === 0) {
|
|
231
|
+
err('--head-sha must not be empty');
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
if (options.branch.length === 0) {
|
|
235
|
+
err('--branch must not be empty');
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
if (options.target.length === 0) {
|
|
239
|
+
err('--target must not be empty');
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
if (!Number.isFinite(options.findingCount) || options.findingCount < 0) {
|
|
243
|
+
err(`--finding-count must be a non-negative integer; got ${options.findingCount}`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
const baseDir = process.cwd();
|
|
247
|
+
const metadata = {
|
|
248
|
+
head_sha: options.headSha,
|
|
249
|
+
target: options.target,
|
|
250
|
+
finding_count: options.findingCount,
|
|
251
|
+
verdict: options.verdict,
|
|
252
|
+
};
|
|
253
|
+
if (options.summary !== undefined && options.summary.length > 0) {
|
|
254
|
+
metadata.summary = options.summary;
|
|
255
|
+
}
|
|
256
|
+
await appendAuditRecord(baseDir, {
|
|
257
|
+
tool_name: CODEX_REVIEW_TOOL_NAME,
|
|
258
|
+
server_name: CODEX_REVIEW_SERVER_NAME,
|
|
259
|
+
tier: Tier.Read,
|
|
260
|
+
status: InvocationStatus.Allowed,
|
|
261
|
+
...(options.sessionId !== undefined ? { session_id: options.sessionId } : {}),
|
|
262
|
+
metadata,
|
|
263
|
+
});
|
|
264
|
+
log(`Recorded codex.review (${options.verdict}, ${options.findingCount} finding${options.findingCount === 1 ? '' : 's'}) for ${options.headSha.slice(0, 12)}.`);
|
|
265
|
+
if (options.alsoSetCache === true) {
|
|
266
|
+
const effect = codexVerdictToCacheResult(options.verdict);
|
|
267
|
+
const cacheEntry = await appendCacheEntry(baseDir, {
|
|
268
|
+
sha: options.headSha,
|
|
269
|
+
branch: options.branch,
|
|
270
|
+
base: options.target,
|
|
271
|
+
result: effect.result,
|
|
272
|
+
...(effect.reason !== undefined ? { reason: effect.reason } : {}),
|
|
273
|
+
});
|
|
274
|
+
log(`Cached ${cacheEntry.result} for ${cacheEntry.sha.slice(0, 12)} (${cacheEntry.branch} → ${cacheEntry.base}).`);
|
|
275
|
+
}
|
|
276
|
+
}
|
package/dist/cli/cache.d.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* entirely.
|
|
21
21
|
*/
|
|
22
22
|
import { type CacheResult } from '../cache/review-cache.js';
|
|
23
|
+
import type { CodexVerdict } from '../audit/codex-event.js';
|
|
23
24
|
export interface CacheCheckOptions {
|
|
24
25
|
sha: string;
|
|
25
26
|
branch: string;
|
|
@@ -48,5 +49,36 @@ export declare function runCacheCheck(options: CacheCheckOptions): Promise<void>
|
|
|
48
49
|
export declare function runCacheSet(options: CacheSetOptions): Promise<void>;
|
|
49
50
|
export declare function runCacheClear(options: CacheClearOptions): Promise<void>;
|
|
50
51
|
export declare function runCacheList(options: CacheListOptions): Promise<void>;
|
|
51
|
-
/** Parse-and-validate helper for `set` — surfaces a clean error on bad input.
|
|
52
|
+
/** Parse-and-validate helper for `set` — surfaces a clean error on bad input.
|
|
53
|
+
*
|
|
54
|
+
* Accepts the two historical cache values (`pass`, `fail`) AND the four
|
|
55
|
+
* canonical Codex verdicts (`pass`, `concerns`, `blocking`, `error`) per
|
|
56
|
+
* Defect D (rea#77). Codex verdicts are mapped to cache semantics at the CLI
|
|
57
|
+
* boundary: `pass|concerns` → gate-satisfying `pass`; `blocking|error` →
|
|
58
|
+
* gate-failing `fail`. The cache internal vocabulary stays binary
|
|
59
|
+
* (`pass`/`fail` = "gate-satisfying?") while the CLI accepts the full Codex
|
|
60
|
+
* vocabulary so agents can copy the `/codex-review` verdict verbatim.
|
|
61
|
+
*/
|
|
52
62
|
export declare function parseCacheResult(raw: string): CacheResult;
|
|
63
|
+
/** Shape returned by {@link codexVerdictToCacheResult}: the binary cache result
|
|
64
|
+
* plus an optional machine-readable `reason` string that records the source
|
|
65
|
+
* Codex verdict. `reason` is populated for non-`pass` verdicts so downstream
|
|
66
|
+
* listings expose WHY a cache fail was recorded. */
|
|
67
|
+
export interface CodexVerdictCacheEffect {
|
|
68
|
+
result: CacheResult;
|
|
69
|
+
reason?: string | undefined;
|
|
70
|
+
}
|
|
71
|
+
/** Map a Codex verdict to the binary cache result the gate compares against.
|
|
72
|
+
*
|
|
73
|
+
* Mapping rationale:
|
|
74
|
+
* - `pass` → cache `pass` (clean review, gate should pass)
|
|
75
|
+
* - `concerns` → cache `pass` (non-blocking findings, gate should pass;
|
|
76
|
+
* reviewer captured concerns in the audit record `metadata.summary`)
|
|
77
|
+
* - `blocking` → cache `fail` (must address findings before merge)
|
|
78
|
+
* - `error` → cache `fail` (Codex itself errored; no clean-bill-of-health)
|
|
79
|
+
*
|
|
80
|
+
* Kept separate from `parseCacheResult` so callers that already have a typed
|
|
81
|
+
* `CodexVerdict` (e.g. `rea audit record codex-review --also-set-cache`) don't
|
|
82
|
+
* round-trip through string parsing.
|
|
83
|
+
*/
|
|
84
|
+
export declare function codexVerdictToCacheResult(verdict: CodexVerdict): CodexVerdictCacheEffect;
|
package/dist/cli/cache.js
CHANGED
|
@@ -103,10 +103,48 @@ export async function runCacheList(options) {
|
|
|
103
103
|
console.log(`${e.recorded_at} ${e.result.padEnd(4)} ${shortSha} ${e.branch} → ${e.base}${reason}`);
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
-
/** Parse-and-validate helper for `set` — surfaces a clean error on bad input.
|
|
106
|
+
/** Parse-and-validate helper for `set` — surfaces a clean error on bad input.
|
|
107
|
+
*
|
|
108
|
+
* Accepts the two historical cache values (`pass`, `fail`) AND the four
|
|
109
|
+
* canonical Codex verdicts (`pass`, `concerns`, `blocking`, `error`) per
|
|
110
|
+
* Defect D (rea#77). Codex verdicts are mapped to cache semantics at the CLI
|
|
111
|
+
* boundary: `pass|concerns` → gate-satisfying `pass`; `blocking|error` →
|
|
112
|
+
* gate-failing `fail`. The cache internal vocabulary stays binary
|
|
113
|
+
* (`pass`/`fail` = "gate-satisfying?") while the CLI accepts the full Codex
|
|
114
|
+
* vocabulary so agents can copy the `/codex-review` verdict verbatim.
|
|
115
|
+
*/
|
|
107
116
|
export function parseCacheResult(raw) {
|
|
108
117
|
if (raw === 'pass' || raw === 'fail')
|
|
109
118
|
return raw;
|
|
110
|
-
|
|
119
|
+
if (raw === 'concerns')
|
|
120
|
+
return 'pass';
|
|
121
|
+
if (raw === 'blocking' || raw === 'error')
|
|
122
|
+
return 'fail';
|
|
123
|
+
err(`result must be 'pass', 'fail', 'concerns', 'blocking', or 'error'; got ${JSON.stringify(raw)}`);
|
|
111
124
|
process.exit(1);
|
|
112
125
|
}
|
|
126
|
+
/** Map a Codex verdict to the binary cache result the gate compares against.
|
|
127
|
+
*
|
|
128
|
+
* Mapping rationale:
|
|
129
|
+
* - `pass` → cache `pass` (clean review, gate should pass)
|
|
130
|
+
* - `concerns` → cache `pass` (non-blocking findings, gate should pass;
|
|
131
|
+
* reviewer captured concerns in the audit record `metadata.summary`)
|
|
132
|
+
* - `blocking` → cache `fail` (must address findings before merge)
|
|
133
|
+
* - `error` → cache `fail` (Codex itself errored; no clean-bill-of-health)
|
|
134
|
+
*
|
|
135
|
+
* Kept separate from `parseCacheResult` so callers that already have a typed
|
|
136
|
+
* `CodexVerdict` (e.g. `rea audit record codex-review --also-set-cache`) don't
|
|
137
|
+
* round-trip through string parsing.
|
|
138
|
+
*/
|
|
139
|
+
export function codexVerdictToCacheResult(verdict) {
|
|
140
|
+
switch (verdict) {
|
|
141
|
+
case 'pass':
|
|
142
|
+
return { result: 'pass' };
|
|
143
|
+
case 'concerns':
|
|
144
|
+
return { result: 'pass', reason: 'codex:concerns' };
|
|
145
|
+
case 'blocking':
|
|
146
|
+
return { result: 'fail', reason: 'codex:blocking' };
|
|
147
|
+
case 'error':
|
|
148
|
+
return { result: 'fail', reason: 'codex:error' };
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import { runAuditRotate, runAuditVerify } from './audit.js';
|
|
3
|
+
import { runAuditRecordCodexReview, runAuditRotate, runAuditVerify } from './audit.js';
|
|
4
4
|
import { parseCacheResult, runCacheCheck, runCacheClear, runCacheList, runCacheSet, } from './cache.js';
|
|
5
5
|
import { runCheck } from './check.js';
|
|
6
6
|
import { runDoctor } from './doctor.js';
|
|
@@ -102,6 +102,44 @@ async function main() {
|
|
|
102
102
|
.action(async (opts) => {
|
|
103
103
|
await runAuditVerify({ ...(opts.since !== undefined ? { since: opts.since } : {}) });
|
|
104
104
|
});
|
|
105
|
+
const auditRecord = audit
|
|
106
|
+
.command('record')
|
|
107
|
+
.description('Emit a structured audit record (D).');
|
|
108
|
+
auditRecord
|
|
109
|
+
.command('codex-review')
|
|
110
|
+
.description('Append a codex.review audit entry the push-review cache gate recognizes. With --also-set-cache, writes the review cache in the same invocation (two sequential appends in one process — not a two-phase commit).')
|
|
111
|
+
.requiredOption('--head-sha <sha>', 'git HEAD SHA the review covers')
|
|
112
|
+
.requiredOption('--branch <branch>', 'feature branch under review')
|
|
113
|
+
.requiredOption('--target <target>', 'base ref or SHA diffed against (e.g. main)')
|
|
114
|
+
.requiredOption('--verdict <verdict>', 'one of: pass | concerns | blocking | error')
|
|
115
|
+
.requiredOption('--finding-count <N>', 'non-negative integer finding count', (raw) => {
|
|
116
|
+
const n = Number.parseInt(raw, 10);
|
|
117
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
118
|
+
throw new Error(`--finding-count must be a non-negative integer; got ${JSON.stringify(raw)}`);
|
|
119
|
+
}
|
|
120
|
+
return n;
|
|
121
|
+
})
|
|
122
|
+
.option('--summary <text>', 'one-sentence review summary (optional)')
|
|
123
|
+
.option('--session-id <id>', 'session id to attribute (defaults to "external")')
|
|
124
|
+
.option('--also-set-cache', 'also update .rea/review-cache.jsonl to reflect this verdict, in the same invocation (recommended for post-review push flow)')
|
|
125
|
+
.action(async (opts) => {
|
|
126
|
+
if (opts.verdict !== 'pass' &&
|
|
127
|
+
opts.verdict !== 'concerns' &&
|
|
128
|
+
opts.verdict !== 'blocking' &&
|
|
129
|
+
opts.verdict !== 'error') {
|
|
130
|
+
throw new Error(`--verdict must be one of pass|concerns|blocking|error; got ${JSON.stringify(opts.verdict)}`);
|
|
131
|
+
}
|
|
132
|
+
await runAuditRecordCodexReview({
|
|
133
|
+
headSha: opts.headSha,
|
|
134
|
+
branch: opts.branch,
|
|
135
|
+
target: opts.target,
|
|
136
|
+
verdict: opts.verdict,
|
|
137
|
+
findingCount: opts.findingCount,
|
|
138
|
+
...(opts.summary !== undefined ? { summary: opts.summary } : {}),
|
|
139
|
+
...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
|
|
140
|
+
...(opts.alsoSetCache === true ? { alsoSetCache: true } : {}),
|
|
141
|
+
});
|
|
142
|
+
});
|
|
105
143
|
const cache = program
|
|
106
144
|
.command('cache')
|
|
107
145
|
.description('Review-cache operations — check/set/clear/list .rea/review-cache.jsonl (BUG-009). Used by hooks/push-review-gate.sh to skip re-review on a previously-approved diff.');
|
|
@@ -115,7 +153,7 @@ async function main() {
|
|
|
115
153
|
});
|
|
116
154
|
cache
|
|
117
155
|
.command('set <sha> <result>')
|
|
118
|
-
.description('Record a review outcome. <result>
|
|
156
|
+
.description('Record a review outcome. <result> accepts pass|fail (historical) or pass|concerns|blocking|error (Codex verdicts). concerns→pass, blocking|error→fail. Idempotent line-per-invocation; last write wins on (sha, branch, base).')
|
|
119
157
|
.requiredOption('--branch <branch>', 'feature branch being pushed')
|
|
120
158
|
.requiredOption('--base <base>', 'base branch the feature targets')
|
|
121
159
|
.option('--reason <text>', 'free-text context for this entry (recommended on fail)')
|
|
@@ -9,3 +9,4 @@ export declare function classifyTool(toolName: string, serverName: string, gatew
|
|
|
9
9
|
* Check if a tool is explicitly blocked in gateway config.
|
|
10
10
|
*/
|
|
11
11
|
export declare function isToolBlocked(toolName: string, serverName: string, gatewayConfig?: GatewayConfig): boolean;
|
|
12
|
+
export declare function reaCommandTier(command: string): Tier | null;
|
package/dist/config/tier-map.js
CHANGED
|
@@ -106,3 +106,213 @@ export function isToolBlocked(toolName, serverName, gatewayConfig) {
|
|
|
106
106
|
const override = serverConfig?.tool_overrides?.[toolName];
|
|
107
107
|
return override?.blocked === true;
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Classify a `rea <subcommand>` Bash invocation by its own semantics rather
|
|
111
|
+
* than the generic Bash default.
|
|
112
|
+
*
|
|
113
|
+
* Defect E (rea#78): REA's own governance CLI must not be denied by REA's own
|
|
114
|
+
* middleware. The gate's error messages literally say "Run `rea cache set
|
|
115
|
+
* <sha> pass --branch <x> --base <y>`" — then the agent is denied at autonomy
|
|
116
|
+
* L1 because `Bash` is classified Write and the downstream middleware can't
|
|
117
|
+
* see that the Write is just appending a line to `.rea/review-cache.jsonl`.
|
|
118
|
+
*
|
|
119
|
+
* This helper returns the tier appropriate to the rea subcommand when the
|
|
120
|
+
* command parses as `rea <sub>` or `npx rea <sub>`. Returns `null` if the
|
|
121
|
+
* command is not a rea invocation — callers then fall back to the generic
|
|
122
|
+
* Bash tier.
|
|
123
|
+
*
|
|
124
|
+
* Tier mapping:
|
|
125
|
+
* - Read: `cache check|list|get`, `audit verify`,
|
|
126
|
+
* `audit record codex-review`, `check`, `doctor`, `status`
|
|
127
|
+
* - Write: `cache set|clear`, `audit rotate`, `init`,
|
|
128
|
+
* `serve`, `upgrade`, `unfreeze`
|
|
129
|
+
* - Destructive: `freeze` (writes `.rea/HALT`, suspends the session)
|
|
130
|
+
*
|
|
131
|
+
* `audit record codex-review` is Read-tier because it is REA's own append-only
|
|
132
|
+
* audit surface — the whole point of the command is to let an L1 agent satisfy
|
|
133
|
+
* the push-review gate without a human in the loop. Write-tier here would
|
|
134
|
+
* reintroduce exactly the deadlock Defect D/E close.
|
|
135
|
+
*
|
|
136
|
+
* SECURITY: returns `null` for any command containing shell metacharacters
|
|
137
|
+
* that would let an attacker piggyback arbitrary commands onto an allowed
|
|
138
|
+
* prefix (e.g. `rea check && rm -rf ~`). Bash tokenizes on whitespace, but
|
|
139
|
+
* the shell itself dispatches the full command string — token[0] matching
|
|
140
|
+
* is not a sufficient trust decision. Falling back to `null` forces the
|
|
141
|
+
* generic Write-tier Bash default, which is what the operator expects for
|
|
142
|
+
* any command they did not explicitly model here.
|
|
143
|
+
*/
|
|
144
|
+
// Reject redirection and chaining operators. Bare `rea check > /etc/passwd`
|
|
145
|
+
// still executes a write the classifier cannot reason about; same for
|
|
146
|
+
// heredocs (`<<`), pipe-process-substitution (`>(`, `<(`), and the
|
|
147
|
+
// chain/substitute operators the prior pass already covered.
|
|
148
|
+
const REA_SHELL_METACHAR_RE = /[;&|`\n\r<>]|\$\(|>\(|<\(/;
|
|
149
|
+
/**
|
|
150
|
+
* Returns true iff `first` is an invocation shape we trust for Read-tier
|
|
151
|
+
* downgrade. Implemented as a function because the trust rules are not pure
|
|
152
|
+
* suffix matching — pass-3 Codex review surfaced two P1 bypasses in the old
|
|
153
|
+
* suffix-only model:
|
|
154
|
+
*
|
|
155
|
+
* 1. A repo-authored `./bin/rea` script satisfied `endsWith('/bin/rea')`
|
|
156
|
+
* and classified as Read at L0 → RCE via repo content.
|
|
157
|
+
* 2. A repo-authored `./dist/cli/index.js` satisfied
|
|
158
|
+
* `endsWith('/dist/cli/index.js')` → same.
|
|
159
|
+
*
|
|
160
|
+
* The rules now require:
|
|
161
|
+
* - The first token is **absolute** (starts with `/`). Relative paths are
|
|
162
|
+
* attacker-influenced via CWD and repo content, so they never get the
|
|
163
|
+
* Read-tier downgrade. Callers MAY still run relative-path rea — they
|
|
164
|
+
* just fall through to weak-trust (bare `rea`) semantics: Destructive
|
|
165
|
+
* subcommands still upgrade; Read/Write fall back to the generic Bash
|
|
166
|
+
* Write tier.
|
|
167
|
+
* - The path matches one of the two *strong* install shapes:
|
|
168
|
+
* (a) contains `/node_modules/.bin/rea` anywhere (unambiguous marker
|
|
169
|
+
* of an npm install directory tree);
|
|
170
|
+
* (b) starts with `/usr/` or `/opt/` AND ends with `/bin/rea`
|
|
171
|
+
* (classic root-write system install location). `/home/…/bin/rea`
|
|
172
|
+
* is intentionally NOT honored — `/home/<user>/` is writable
|
|
173
|
+
* without root, so an attacker with local shell access could
|
|
174
|
+
* pre-seed a trusted-looking path there.
|
|
175
|
+
*
|
|
176
|
+
* The old `/dist/cli/index.js` suffix is gone entirely. The legitimate
|
|
177
|
+
* developer invocation `node ./dist/cli/index.js` has `first === 'node'`
|
|
178
|
+
* which never matches; only a filesystem-marked-executable
|
|
179
|
+
* `./dist/cli/index.js` would have hit the old suffix, and that shape was
|
|
180
|
+
* always attacker-authorable inside a repo. Similarly, `/.bin/rea` (exactly
|
|
181
|
+
* `/.bin/rea`, at filesystem root) was an accident of suffix matching, not
|
|
182
|
+
* a real install location; it is gone.
|
|
183
|
+
*/
|
|
184
|
+
function isTrustedReaPath(first) {
|
|
185
|
+
if (!first.startsWith('/'))
|
|
186
|
+
return false;
|
|
187
|
+
// npm install marker — absolute path whose tail is `/node_modules/.bin/rea`.
|
|
188
|
+
// This is unambiguous: an attacker can only seed this path by having already
|
|
189
|
+
// run a real npm install, at which point they already had execution.
|
|
190
|
+
if (first.endsWith('/node_modules/.bin/rea'))
|
|
191
|
+
return true;
|
|
192
|
+
// Classic global install — absolute path rooted at a system prefix that
|
|
193
|
+
// requires root write (so attacker-seeded files are out-of-scope for the
|
|
194
|
+
// repo-content threat model).
|
|
195
|
+
if (first.endsWith('/bin/rea')) {
|
|
196
|
+
if (first.startsWith('/usr/'))
|
|
197
|
+
return true;
|
|
198
|
+
if (first.startsWith('/opt/'))
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
export function reaCommandTier(command) {
|
|
204
|
+
if (typeof command !== 'string' || command.length === 0)
|
|
205
|
+
return null;
|
|
206
|
+
// Refuse to classify commands that chain/substitute/redirect — the trailing
|
|
207
|
+
// shell payload is arbitrary, so the prefix's read-tier status tells us
|
|
208
|
+
// nothing about what the shell will actually execute.
|
|
209
|
+
if (REA_SHELL_METACHAR_RE.test(command))
|
|
210
|
+
return null;
|
|
211
|
+
const trimmed = command.trim();
|
|
212
|
+
if (trimmed.length === 0)
|
|
213
|
+
return null;
|
|
214
|
+
const tokens = trimmed.split(/\s+/);
|
|
215
|
+
if (tokens.length === 0)
|
|
216
|
+
return null;
|
|
217
|
+
const first = tokens[0];
|
|
218
|
+
if (first === undefined)
|
|
219
|
+
return null;
|
|
220
|
+
// Classify the invocation's trust posture. The ONLY fully-trusted shape is
|
|
221
|
+
// an absolute-path invocation that `isTrustedReaPath()` recognizes as a
|
|
222
|
+
// strong install marker (npm `/node_modules/.bin/rea` or a root-write
|
|
223
|
+
// system global under `/usr/` or `/opt/`). Everything else — bare `rea`,
|
|
224
|
+
// `npx rea …`, relative paths — is treated as *weak trust*: we still
|
|
225
|
+
// recognize the subcommand for the sake of destructive-tier UPGRADES
|
|
226
|
+
// (e.g. `rea freeze` at L1 should be blocked whether or not we can prove
|
|
227
|
+
// the binary is ours), but we refuse to DOWNGRADE anything that could be
|
|
228
|
+
// piggybacking on a PATH-spoofable name or an `npx` network/install
|
|
229
|
+
// side-effect.
|
|
230
|
+
//
|
|
231
|
+
// npx note (pass-3 Codex Finding 2): `npx rea …` on a machine without the
|
|
232
|
+
// package locally cached downloads the tarball, writes to the npm cache,
|
|
233
|
+
// and executes — explicitly not Read-tier semantics. Treating npx as weak
|
|
234
|
+
// trust forces agents to commit to a deterministic install path (absolute
|
|
235
|
+
// `/usr/local/bin/rea` from `npm i -g`, or the fully-resolved
|
|
236
|
+
// `/…/node_modules/.bin/rea` from a project install) if they want the
|
|
237
|
+
// Read-tier downgrade.
|
|
238
|
+
let idx = 0;
|
|
239
|
+
let trust = 'trusted';
|
|
240
|
+
if (first === 'npx') {
|
|
241
|
+
if (tokens.length < 2)
|
|
242
|
+
return null;
|
|
243
|
+
const second = tokens[1];
|
|
244
|
+
if (second !== 'rea' && second !== '@bookedsolid/rea')
|
|
245
|
+
return null;
|
|
246
|
+
idx = 2;
|
|
247
|
+
trust = 'weak';
|
|
248
|
+
}
|
|
249
|
+
else if (isTrustedReaPath(first)) {
|
|
250
|
+
idx = 1;
|
|
251
|
+
}
|
|
252
|
+
else if (first === 'rea' || first.split('/').pop() === 'rea') {
|
|
253
|
+
// Bare `rea` OR any path (relative/absolute) whose tail is literally
|
|
254
|
+
// `rea`. This captures `./bin/rea`, `./node_modules/.bin/rea`,
|
|
255
|
+
// `/home/user/.npm-global/bin/rea`, `/tmp/fake/rea`, etc. — none of
|
|
256
|
+
// these are full-trust under `isTrustedReaPath()`, but we still want
|
|
257
|
+
// Destructive subcommands (`freeze`) to UPGRADE from Bash Write even
|
|
258
|
+
// here, because destructive intent is invocation-shape-independent.
|
|
259
|
+
idx = 1;
|
|
260
|
+
trust = 'weak';
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const sub = tokens[idx];
|
|
266
|
+
if (sub === undefined) {
|
|
267
|
+
// `rea` with no subcommand is help/version under `commander` — a read.
|
|
268
|
+
// Under weak trust, we refuse to downgrade; fall back to generic Write.
|
|
269
|
+
return trust === 'trusted' ? Tier.Read : null;
|
|
270
|
+
}
|
|
271
|
+
const sub2 = tokens[idx + 1];
|
|
272
|
+
const subcommandTier = (() => {
|
|
273
|
+
switch (sub) {
|
|
274
|
+
case 'check':
|
|
275
|
+
case 'doctor':
|
|
276
|
+
case 'status':
|
|
277
|
+
return Tier.Read;
|
|
278
|
+
case 'cache': {
|
|
279
|
+
if (sub2 === 'check' || sub2 === 'list' || sub2 === 'get')
|
|
280
|
+
return Tier.Read;
|
|
281
|
+
if (sub2 === 'set' || sub2 === 'clear')
|
|
282
|
+
return Tier.Write;
|
|
283
|
+
return Tier.Write;
|
|
284
|
+
}
|
|
285
|
+
case 'audit': {
|
|
286
|
+
if (sub2 === 'verify')
|
|
287
|
+
return Tier.Read;
|
|
288
|
+
if (sub2 === 'record')
|
|
289
|
+
return Tier.Read;
|
|
290
|
+
if (sub2 === 'rotate')
|
|
291
|
+
return Tier.Write;
|
|
292
|
+
return Tier.Write;
|
|
293
|
+
}
|
|
294
|
+
case 'init':
|
|
295
|
+
case 'serve':
|
|
296
|
+
case 'upgrade':
|
|
297
|
+
case 'unfreeze':
|
|
298
|
+
return Tier.Write;
|
|
299
|
+
case 'freeze':
|
|
300
|
+
return Tier.Destructive;
|
|
301
|
+
default:
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
})();
|
|
305
|
+
// Trusted path — return whatever the subcommand semantics say.
|
|
306
|
+
// Unknown subcommand: default Write (safer than Read).
|
|
307
|
+
if (trust === 'trusted') {
|
|
308
|
+
return subcommandTier ?? Tier.Write;
|
|
309
|
+
}
|
|
310
|
+
// Weak trust (bare `rea`) — only honor upgrades above Write.
|
|
311
|
+
// Read/Write subcommands: return null so the middleware applies the generic
|
|
312
|
+
// Bash Write default (same as the pre-helper behavior, no downgrade).
|
|
313
|
+
// Destructive subcommands: KEEP the upgrade — `rea freeze` at L1 must block
|
|
314
|
+
// even if we cannot prove the binary on PATH is ours.
|
|
315
|
+
if (subcommandTier === Tier.Destructive)
|
|
316
|
+
return Tier.Destructive;
|
|
317
|
+
return null;
|
|
318
|
+
}
|