@bookedsolid/rea 0.9.4 → 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 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
@@ -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
+ }
@@ -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
- err(`result must be 'pass' or 'fail'; got ${JSON.stringify(raw)}`);
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> must be "pass" or "fail". Idempotent line-per-invocation; last write wins on (sha, branch, base).')
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;
@@ -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
+ }
@@ -376,6 +376,44 @@ function matchesBlockedPattern(value, pattern) {
376
376
  }
377
377
  return false;
378
378
  }
379
+ // Defect H (rea#79): dot-anchored patterns. A pattern whose base starts with
380
+ // `.` (e.g. `.rea/`, `.env`, `.husky/`) is meant to block ONLY leading-dot
381
+ // filesystem entries — never any path segment that happens to be spelled
382
+ // similarly without the dot. The previous suffix-based match let pattern
383
+ // `.rea/` trip on `Projects/rea/Bug Reports` (any project folder named
384
+ // `rea`) because `suffix.startsWith(base)` was false but the final
385
+ // `segs.includes(base)` fallback conflated `.rea` with `rea` through
386
+ // normalization downstream in some code paths. By explicitly requiring
387
+ // leading-dot segment equality, dot-prefixed patterns cannot bleed across
388
+ // the dot/no-dot boundary regardless of normalization rule drift.
389
+ const dotAnchored = base.startsWith('.');
390
+ if (dotAnchored) {
391
+ // Dot-anchored: segment must equal base exactly. Dir patterns also match
392
+ // "<base>/..." via the trailing slash marker. Never scans non-dot
393
+ // segments, so `Projects/rea/...` can never match `.rea/`.
394
+ for (let i = 0; i < segs.length; i++) {
395
+ const seg = segs[i];
396
+ if (seg === base) {
397
+ // Exact segment match — for a non-dir pattern this matches a FILE
398
+ // named exactly `.env`; for a dir pattern it matches the directory
399
+ // entry itself (the trailing-slash below covers its contents).
400
+ if (!dirPattern && i !== segs.length - 1)
401
+ continue;
402
+ return true;
403
+ }
404
+ if (dirPattern && seg === base)
405
+ return true;
406
+ }
407
+ if (dirPattern) {
408
+ // Dir pattern: any suffix that starts with `<base>/` matches.
409
+ for (let i = 0; i < segs.length; i++) {
410
+ const suffix = segs.slice(i).join('/');
411
+ if (suffix === base || suffix.startsWith(`${base}/`))
412
+ return true;
413
+ }
414
+ }
415
+ return false;
416
+ }
379
417
  for (let i = 0; i < segs.length; i++) {
380
418
  const suffix = segs.slice(i).join('/');
381
419
  if (suffix === base)
@@ -1,6 +1,48 @@
1
1
  import { AutonomyLevel, InvocationStatus, Tier } from '../../policy/types.js';
2
- import { classifyTool, isToolBlocked } from '../../config/tier-map.js';
2
+ import { classifyTool, isToolBlocked, reaCommandTier } from '../../config/tier-map.js';
3
3
  import { loadPolicyAsync } from '../../policy/loader.js';
4
+ const BASH_DISPLAY_MAX_LEN = 80;
5
+ /** Extract the `rea <subcommand>` head from a Bash command string for display
6
+ * in deny messages. Returns `null` when the command is not a rea invocation. */
7
+ function extractReaSubcommand(command) {
8
+ const tokens = command.trim().split(/\s+/);
9
+ if (tokens.length === 0)
10
+ return null;
11
+ const first = tokens[0];
12
+ if (first === undefined)
13
+ return null;
14
+ let idx = 0;
15
+ if (first === 'npx' && tokens.length >= 2 && (tokens[1] === 'rea' || tokens[1] === '@bookedsolid/rea')) {
16
+ idx = 2;
17
+ }
18
+ else if (first === 'rea' || first.endsWith('/rea')) {
19
+ idx = 1;
20
+ }
21
+ else {
22
+ return null;
23
+ }
24
+ const sub = tokens[idx];
25
+ if (sub === undefined)
26
+ return 'rea';
27
+ const sub2 = tokens[idx + 1];
28
+ if (sub2 !== undefined && /^[a-z][a-z-]*$/.test(sub2)) {
29
+ return `rea ${sub} ${sub2}`;
30
+ }
31
+ return `rea ${sub}`;
32
+ }
33
+ /** Build a readable `Bash: <head>` display string for deny messages. Caller
34
+ * is responsible for only invoking this for tool_name === 'Bash'. Uses
35
+ * JSON.stringify to escape hostile characters (newlines, control chars). */
36
+ function formatBashDisplay(command, reaDisplay) {
37
+ if (reaDisplay !== null) {
38
+ return `Bash (${reaDisplay})`;
39
+ }
40
+ const trimmed = command.trim();
41
+ const truncated = trimmed.length > BASH_DISPLAY_MAX_LEN
42
+ ? `${trimmed.slice(0, BASH_DISPLAY_MAX_LEN - 1)}…`
43
+ : trimmed;
44
+ return `Bash (${JSON.stringify(truncated)})`;
45
+ }
4
46
  /**
5
47
  * Autonomy level tier permissions:
6
48
  * - L0: Read only
@@ -48,7 +90,23 @@ export function createPolicyMiddleware(initialPolicy, gatewayConfig, baseDir) {
48
90
  }
49
91
  // SECURITY: Re-derive tier from tool_name — do NOT trust ctx.tier from prior middleware.
50
92
  // This prevents a rogue middleware from downgrading a destructive tool to read-tier.
51
- const tier = classifyTool(ctx.tool_name, ctx.server_name, gatewayConfig);
93
+ let tier = classifyTool(ctx.tool_name, ctx.server_name, gatewayConfig);
94
+ // Defect E (rea#78): when the invocation is a `Bash` call whose command
95
+ // parses as `rea <subcommand>`, classify by subcommand instead of the
96
+ // generic `Write` Bash default. REA's own CLI must not be denied by REA's
97
+ // own middleware at the autonomy level the gate's remediation text
98
+ // targets. Returns null on non-rea commands so the generic tier stands.
99
+ let reaSubcommandDisplay = null;
100
+ if (ctx.tool_name === 'Bash') {
101
+ const command = ctx.arguments['command'];
102
+ if (typeof command === 'string') {
103
+ const subTier = reaCommandTier(command);
104
+ if (subTier !== null) {
105
+ tier = subTier;
106
+ reaSubcommandDisplay = extractReaSubcommand(command);
107
+ }
108
+ }
109
+ }
52
110
  ctx.tier = tier; // Overwrite with authoritative classification
53
111
  // Validate autonomy level is known
54
112
  const allowed = TIER_ALLOWED[policy.autonomy_level];
@@ -60,7 +118,14 @@ export function createPolicyMiddleware(initialPolicy, gatewayConfig, baseDir) {
60
118
  // Check autonomy level vs tier (fail-closed: deny if tier unknown)
61
119
  if (!allowed.has(tier)) {
62
120
  ctx.status = InvocationStatus.Denied;
63
- ctx.error = `Autonomy level ${policy.autonomy_level} does not allow ${tier}-tier tools. Tool: ${ctx.tool_name}`;
121
+ // Defect E composition: when the denial is a Bash invocation, include
122
+ // the command head so the deny-reason is actionable. `Bash` alone tells
123
+ // the operator nothing about WHICH shell command tripped the gate.
124
+ const toolDisplay = ctx.tool_name === 'Bash' && typeof ctx.arguments['command'] === 'string'
125
+ ? formatBashDisplay(ctx.arguments['command'], reaSubcommandDisplay)
126
+ : ctx.tool_name;
127
+ ctx.error = `Autonomy level ${policy.autonomy_level} does not allow ${tier}-tier tools. Tool: ${toolDisplay}`;
128
+ ctx.metadata['reason_code'] = 'tier_exceeds_autonomy';
64
129
  return;
65
130
  }
66
131
  // Store current autonomy level in metadata for audit middleware
@@ -1059,8 +1059,45 @@ pr_core_run() {
1059
1059
  fi
1060
1060
 
1061
1061
  if [[ -n "$PUSH_SHA" ]] && [[ ${#REA_CLI_ARGS[@]} -gt 0 ]]; then
1062
+ # Defect F (rea#75): distinguish cache-miss from cache-error. Prior version
1063
+ # swallowed all non-zero exits and stderr into a silent `{hit:false}`, which
1064
+ # masked Defect A (0.9.2 `node <shim>` SyntaxError) for weeks. Now we
1065
+ # capture stderr + exit code separately and emit a visible WARN with an
1066
+ # actionable filename when the CLI failed.
1062
1067
  local CACHE_RESULT
1063
- CACHE_RESULT=$("${REA_CLI_ARGS[@]}" cache check "$PUSH_SHA" --branch "$SOURCE_BRANCH" --base "$TARGET_BRANCH" 2>/dev/null || echo '{"hit":false}')
1068
+ local CACHE_STDOUT=""
1069
+ local CACHE_STDERR_FILE
1070
+ # SECURITY (Codex LOW 4): require mktemp. A predictable /tmp path like
1071
+ # /tmp/rea-cache-err.$PID is a TOCTOU attack surface on shared hosts —
1072
+ # another user can pre-create a symlink from that name to a file they
1073
+ # want us to clobber. If mktemp is unavailable, fail loudly rather than
1074
+ # silently falling back to a predictable path.
1075
+ if ! CACHE_STDERR_FILE=$(mktemp -t rea-cache-err.XXXXXX 2>/dev/null); then
1076
+ printf 'rea push-review: mktemp unavailable; cannot capture cache-check stderr. Aborting.\n' >&2
1077
+ return 2
1078
+ fi
1079
+ local CACHE_EXIT=0
1080
+ CACHE_STDOUT=$("${REA_CLI_ARGS[@]}" cache check "$PUSH_SHA" --branch "$SOURCE_BRANCH" --base "$TARGET_BRANCH" 2>"$CACHE_STDERR_FILE") || CACHE_EXIT=$?
1081
+ local CACHE_STDERR=""
1082
+ CACHE_STDERR=$(cat "$CACHE_STDERR_FILE" 2>/dev/null || true)
1083
+ rm -f "$CACHE_STDERR_FILE"
1084
+ if [[ "$CACHE_EXIT" -ne 0 ]]; then
1085
+ # SECURITY (Codex LOW 5): strip C0/C1 control characters from CLI
1086
+ # stderr before echoing to the terminal. A tampered dist/ or hostile
1087
+ # CLI could otherwise emit OSC/CSI sequences that rewrite lines above
1088
+ # the deny message and mislead the operator. We strip both C0 + DEL
1089
+ # AND C1 (0x80-0x9F) — some terminal emulators still honor bare C1
1090
+ # bytes as CSI introducers (0x9B) or OSC (0x9D).
1091
+ local CACHE_STDERR_SAFE
1092
+ CACHE_STDERR_SAFE=$(printf '%s' "$CACHE_STDERR" | LC_ALL=C tr -d '\000-\037\177\200-\237')
1093
+ printf 'rea push-review: CACHE CHECK FAILED (exit=%d): %s\n' "$CACHE_EXIT" "$CACHE_STDERR_SAFE" >&2
1094
+ printf 'rea push-review: treating as miss; file bookedsolidtech/rea issue if unexpected.\n' >&2
1095
+ CACHE_RESULT='{"hit":false,"reason":"query_error"}'
1096
+ elif [[ -z "$CACHE_STDOUT" ]]; then
1097
+ CACHE_RESULT='{"hit":false,"reason":"cold"}'
1098
+ else
1099
+ CACHE_RESULT="$CACHE_STDOUT"
1100
+ fi
1064
1101
  # Require BOTH hit == true AND result == "pass". A cached `fail` verdict
1065
1102
  # (Codex 0.8.0 pass-2 finding #1) must NOT satisfy the gate — cache.ts
1066
1103
  # serializes `result` verbatim, so a negative verdict would otherwise
@@ -242,7 +242,31 @@ if [[ -n "$STAGED_SHA" ]]; then
242
242
  # predicate at push-review-core.sh §8; the §218-226 direct-cache fallback
243
243
  # already enforces `result == "pass"`, so the two paths must agree.
244
244
  if [[ ${#REA_CLI_ARGS[@]} -gt 0 ]]; then
245
- CACHE_RESULT=$("${REA_CLI_ARGS[@]}" cache check "$STAGED_SHA" --branch "$BRANCH" --base "$BASE_BRANCH" 2>/dev/null || echo '{"hit":false}')
245
+ # Defect F (rea#75): surface cache-query errors instead of treating them as
246
+ # legitimate misses. See hooks/_lib/push-review-core.sh for the rationale.
247
+ # SECURITY (Codex LOW 4): require mktemp. Predictable /tmp paths are a
248
+ # TOCTOU surface on shared hosts; fall-loud instead of fall-back.
249
+ if ! CACHE_STDERR_FILE=$(mktemp -t rea-commit-cache-err.XXXXXX 2>/dev/null); then
250
+ printf 'rea commit-review: mktemp unavailable; cannot capture cache-check stderr. Aborting.\n' >&2
251
+ exit 2
252
+ fi
253
+ CACHE_EXIT=0
254
+ CACHE_STDOUT=$("${REA_CLI_ARGS[@]}" cache check "$STAGED_SHA" --branch "$BRANCH" --base "$BASE_BRANCH" 2>"$CACHE_STDERR_FILE") || CACHE_EXIT=$?
255
+ CACHE_STDERR=$(cat "$CACHE_STDERR_FILE" 2>/dev/null || true)
256
+ rm -f "$CACHE_STDERR_FILE"
257
+ if [[ "$CACHE_EXIT" -ne 0 ]]; then
258
+ # SECURITY (Codex LOW 5): strip C0/C1 control chars before echoing CLI
259
+ # stderr. Includes 0x80-0x9F because some terminals interpret bare C1
260
+ # bytes (CSI 0x9B, OSC 0x9D) as escape introducers.
261
+ CACHE_STDERR_SAFE=$(printf '%s' "$CACHE_STDERR" | LC_ALL=C tr -d '\000-\037\177\200-\237')
262
+ printf 'rea commit-review: CACHE CHECK FAILED (exit=%d): %s\n' "$CACHE_EXIT" "$CACHE_STDERR_SAFE" >&2
263
+ printf 'rea commit-review: treating as miss; file bookedsolidtech/rea issue if unexpected.\n' >&2
264
+ CACHE_RESULT='{"hit":false,"reason":"query_error"}'
265
+ elif [[ -z "$CACHE_STDOUT" ]]; then
266
+ CACHE_RESULT='{"hit":false,"reason":"cold"}'
267
+ else
268
+ CACHE_RESULT="$CACHE_STDOUT"
269
+ fi
246
270
  if printf '%s' "$CACHE_RESULT" | jq -e '.hit == true and .result == "pass"' >/dev/null 2>&1; then
247
271
  CACHE_HIT=true
248
272
  fi
@@ -59,89 +59,322 @@ normalize_path() {
59
59
  p="${p#$root/}"
60
60
  fi
61
61
 
62
- # URL decode common sequences
63
- p=$(printf '%s' "$p" | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g')
62
+ # URL decode common sequences. Include %5C (`\`) so Windows-style or
63
+ # percent-encoded back-slash traversal (`..%5C`, `\..\`) normalizes to the
64
+ # forward-slash form the §5a detector sees.
65
+ p=$(printf '%s' "$p" \
66
+ | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g; s/%5[Cc]/\\/g')
64
67
 
65
- # Collapse path traversals
66
- # Remove ./ components
67
- p=$(printf '%s' "$p" | sed 's|\./||g')
68
+ # Translate any backslash separators to forward slashes. Keeps the traversal
69
+ # check in §5a working for `.claude\hooks\..\settings.json`-style inputs.
70
+ p=$(printf '%s' "$p" | tr '\\\\' '/')
68
71
 
69
- # Remove leading ./
70
- p="${p#./}"
72
+ # Strip leading ./ components only. We intentionally do NOT strip interior
73
+ # ./ sequences — that transformation corrupts `..` traversals (e.g. `.../`
74
+ # collapsed to `../`, or `../` collapsed to `./`) and hides traversal from
75
+ # the §5a detector.
76
+ while [[ "$p" == ./* ]]; do
77
+ p="${p#./}"
78
+ done
71
79
 
72
80
  printf '%s' "$p"
73
81
  }
74
82
 
83
+ # Strip C0/C1 control characters from a string to prevent terminal escape
84
+ # injection when we echo protected paths back to the operator. Escape sequences
85
+ # in file names could otherwise rewrite lines above the deny message.
86
+ #
87
+ # Byte ranges stripped:
88
+ # \000-\037 — C0 controls (BEL, BS, HT, LF, CR, ESC, …)
89
+ # \177 — DEL
90
+ # \200-\237 — C1 controls (CSI 0x9B, OSC 0x9D, …). Many terminals still
91
+ # interpret these as single-byte CSI introducers; without
92
+ # stripping, a UTF-8 file name whose bytes fall in this range
93
+ # could still drive the cursor on older emulators.
94
+ sanitize_for_stderr() {
95
+ printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177\200-\237'
96
+ }
97
+
75
98
  NORMALIZED=$(normalize_path "$FILE_PATH")
99
+ SAFE_FILE_PATH=$(sanitize_for_stderr "$FILE_PATH")
100
+ SAFE_NORMALIZED=$(sanitize_for_stderr "$NORMALIZED")
101
+
102
+ # ── 5a. Reject path traversal segments (Codex HIGH: Defect I bypass) ─────────
103
+ # A path containing `..` segments can be used to bypass the protected-path
104
+ # globs in §6 — e.g. `.claude/hooks/../settings.json` would pass the
105
+ # `.claude/hooks/*` case-glob in the patch-session allowlist but actually
106
+ # refers to `.claude/settings.json`. We refuse any path that contains a `..`
107
+ # segment in either the raw input OR the normalized form. The request must
108
+ # be reissued with a canonical path.
109
+ #
110
+ # For the raw-input check, translate backslashes first so a Windows-style
111
+ # `.claude\hooks\..\settings.json` is rejected at the raw stage too (the
112
+ # normalized form also catches it — this is defense in depth).
113
+ RAW_PATH_SLASHED=$(printf '%s' "$FILE_PATH" | tr '\\\\' '/')
114
+ raw_has_traversal=0
115
+ case "/$RAW_PATH_SLASHED/" in
116
+ */../*) raw_has_traversal=1 ;;
117
+ esac
118
+ norm_has_traversal=0
119
+ case "/$NORMALIZED/" in
120
+ */../*) norm_has_traversal=1 ;;
121
+ esac
122
+ if [[ "$raw_has_traversal" -eq 1 ]] || [[ "$norm_has_traversal" -eq 1 ]]; then
123
+ {
124
+ printf 'SETTINGS PROTECTION: path traversal rejected\n'
125
+ printf '\n'
126
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
127
+ printf " Rule: path contains a '..' segment; rewrite to a canonical\n"
128
+ printf ' project-relative path without traversal.\n'
129
+ } >&2
130
+ exit 2
131
+ fi
76
132
 
77
133
  # ── 6. Protected path patterns ────────────────────────────────────────────────
134
+ # §6 runs BEFORE the patch-session allowlist so hook-patch sessions cannot
135
+ # reach .rea/policy.yaml, .rea/HALT, or .claude/settings.json via any glob
136
+ # creativity.
78
137
  PROTECTED_PATTERNS=(
79
138
  '.claude/settings.json'
80
139
  '.claude/settings.local.json'
81
- '.claude/hooks/'
82
140
  '.husky/'
83
141
  '.rea/policy.yaml'
84
142
  '.rea/HALT'
85
143
  )
86
144
 
87
- for pattern in "${PROTECTED_PATTERNS[@]}"; do
88
- # Exact match
89
- if [[ "$NORMALIZED" == "$pattern" ]]; then
90
- {
91
- printf 'SETTINGS PROTECTION: Modification blocked\n'
92
- printf '\n'
93
- printf ' File: %s\n' "$FILE_PATH"
94
- printf ' Rule: This file is protected from agent modification.\n'
95
- printf '\n'
96
- printf ' Protected files include hook scripts, settings, policy,\n'
97
- printf ' and kill switch files. These must be modified by humans\n'
98
- printf ' via rea CLI or direct editing.\n'
99
- printf '\n'
100
- printf ' Use: rea init (to update hooks/settings)\n'
101
- printf ' rea freeze/unfreeze (for HALT file)\n'
102
- printf ' Edit .rea/policy.yaml manually\n'
103
- } >&2
104
- exit 2
105
- fi
106
-
107
- # Directory prefix match (patterns ending in /)
108
- if [[ "$pattern" == */ ]] && [[ "$NORMALIZED" == "$pattern"* ]]; then
109
- {
110
- printf 'SETTINGS PROTECTION: Modification blocked\n'
111
- printf '\n'
112
- printf ' File: %s\n' "$FILE_PATH"
113
- printf ' Rule: Files under %s are protected from agent modification.\n' "$pattern"
114
- printf '\n'
115
- printf ' These files control the hook safety layer and must be\n'
116
- printf ' modified by humans via rea CLI or direct editing.\n'
117
- } >&2
118
- exit 2
119
- fi
120
- done
145
+ # Patterns that are protected from general agent edits but can be unlocked by
146
+ # REA_HOOK_PATCH_SESSION. Kept separate from the hard-protected list above so
147
+ # the patch-session gate in §6b only applies to these directories.
148
+ PATCH_SESSION_PATTERNS=(
149
+ '.claude/hooks/'
150
+ )
121
151
 
122
- # ── 7. Case-insensitive fallback check ────────────────────────────────────────
123
- # Catch case-manipulation bypass attempts (e.g., .Claude/Settings.json)
124
152
  LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
125
- for pattern in "${PROTECTED_PATTERNS[@]}"; do
126
- LOWER_PATTERN=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
127
- if [[ "$LOWER_NORM" == "$LOWER_PATTERN" ]]; then
128
- {
129
- printf 'SETTINGS PROTECTION: Modification blocked (case-insensitive match)\n'
130
- printf '\n'
131
- printf ' File: %s\n' "$FILE_PATH"
132
- printf ' Matched: %s\n' "$pattern"
133
- } >&2
134
- exit 2
135
- fi
136
- if [[ "$LOWER_PATTERN" == */ ]] && [[ "$LOWER_NORM" == "$LOWER_PATTERN"* ]]; then
137
- {
138
- printf 'SETTINGS PROTECTION: Modification blocked (case-insensitive match)\n'
139
- printf '\n'
140
- printf ' File: %s\n' "$FILE_PATH"
141
- printf ' Matched: %s*\n' "$pattern"
142
- } >&2
143
- exit 2
153
+
154
+ # Match $NORMALIZED against PROTECTED_PATTERNS (exact or prefix for patterns
155
+ # ending in '/'). Sets $PROTECTED_MATCH to the matched pattern; exit 0 on hit.
156
+ match_protected() {
157
+ local pattern
158
+ PROTECTED_MATCH=""
159
+ for pattern in "${PROTECTED_PATTERNS[@]}"; do
160
+ if [[ "$NORMALIZED" == "$pattern" ]]; then
161
+ PROTECTED_MATCH="$pattern"
162
+ return 0
163
+ fi
164
+ if [[ "$pattern" == */ ]] && [[ "$NORMALIZED" == "$pattern"* ]]; then
165
+ PROTECTED_MATCH="$pattern"
166
+ return 0
167
+ fi
168
+ done
169
+ return 1
170
+ }
171
+
172
+ match_protected_ci() {
173
+ local pattern lp
174
+ PROTECTED_MATCH=""
175
+ for pattern in "${PROTECTED_PATTERNS[@]}"; do
176
+ lp=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
177
+ if [[ "$LOWER_NORM" == "$lp" ]]; then
178
+ PROTECTED_MATCH="$pattern"
179
+ return 0
180
+ fi
181
+ if [[ "$lp" == */ ]] && [[ "$LOWER_NORM" == "$lp"* ]]; then
182
+ PROTECTED_MATCH="$pattern"
183
+ return 0
184
+ fi
185
+ done
186
+ return 1
187
+ }
188
+
189
+ match_patch_session() {
190
+ local pattern
191
+ PROTECTED_MATCH=""
192
+ for pattern in "${PATCH_SESSION_PATTERNS[@]}"; do
193
+ if [[ "$NORMALIZED" == "$pattern" ]]; then
194
+ PROTECTED_MATCH="$pattern"
195
+ return 0
196
+ fi
197
+ if [[ "$pattern" == */ ]] && [[ "$NORMALIZED" == "$pattern"* ]]; then
198
+ PROTECTED_MATCH="$pattern"
199
+ return 0
200
+ fi
201
+ done
202
+ return 1
203
+ }
204
+
205
+ match_patch_session_ci() {
206
+ local pattern lp
207
+ PROTECTED_MATCH=""
208
+ for pattern in "${PATCH_SESSION_PATTERNS[@]}"; do
209
+ lp=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
210
+ if [[ "$LOWER_NORM" == "$lp" ]]; then
211
+ PROTECTED_MATCH="$pattern"
212
+ return 0
213
+ fi
214
+ if [[ "$lp" == */ ]] && [[ "$LOWER_NORM" == "$lp"* ]]; then
215
+ PROTECTED_MATCH="$pattern"
216
+ return 0
217
+ fi
218
+ done
219
+ return 1
220
+ }
221
+
222
+ if match_protected; then
223
+ {
224
+ printf 'SETTINGS PROTECTION: Modification blocked\n'
225
+ printf '\n'
226
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
227
+ printf ' Matched: %s\n' "$PROTECTED_MATCH"
228
+ printf ' Rule: This file is protected from agent modification, including\n'
229
+ printf ' sessions with REA_HOOK_PATCH_SESSION set.\n'
230
+ } >&2
231
+ exit 2
232
+ fi
233
+
234
+ if match_protected_ci; then
235
+ {
236
+ printf 'SETTINGS PROTECTION: Modification blocked (case-insensitive match)\n'
237
+ printf '\n'
238
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
239
+ printf ' Matched: %s\n' "$PROTECTED_MATCH"
240
+ } >&2
241
+ exit 2
242
+ fi
243
+
244
+ # ── 6b. Hook-patch session (Defect I / rea#76) ───────────────────────────────
245
+ # When REA_HOOK_PATCH_SESSION is set to a non-empty reason, allow edits under
246
+ # .claude/hooks/ and hooks/ for this session. The session boundary IS the
247
+ # expiry — a new shell requires a fresh opt-in. Every allowed edit is audited
248
+ # as hooks.patch.session so the bypass is never silent.
249
+ #
250
+ # SECURITY: runs AFTER §5a (traversal reject) and §6 (hard-protected denies),
251
+ # so no glob creativity can reach policy/HALT/settings files from here.
252
+ if [[ -n "${REA_HOOK_PATCH_SESSION:-}" ]]; then
253
+ if match_patch_session; then
254
+ SAFE_REASON=$(sanitize_for_stderr "${REA_HOOK_PATCH_SESSION}")
255
+ # Audit record via the TypeScript chain so the hash chain stays intact.
256
+ # If the append fails, block the edit — silent failure would let an
257
+ # attacker disable audit logging and then patch hooks unobserved.
258
+ SHA_BEFORE=""
259
+ if [[ -f "$FILE_PATH" ]]; then
260
+ if command -v sha256sum >/dev/null 2>&1; then
261
+ SHA_BEFORE=$(sha256sum "$FILE_PATH" 2>/dev/null | awk '{print $1}')
262
+ elif command -v shasum >/dev/null 2>&1; then
263
+ SHA_BEFORE=$(shasum -a 256 "$FILE_PATH" 2>/dev/null | awk '{print $1}')
264
+ elif command -v openssl >/dev/null 2>&1; then
265
+ SHA_BEFORE=$(openssl dgst -sha256 "$FILE_PATH" 2>/dev/null | awk '{print $NF}')
266
+ fi
267
+ fi
268
+ ACTOR_NAME=$(git -C "$REA_ROOT" config user.name 2>/dev/null || printf 'unknown')
269
+ ACTOR_EMAIL=$(git -C "$REA_ROOT" config user.email 2>/dev/null || printf 'unknown')
270
+
271
+ AUDIT_PAYLOAD=$(
272
+ cd "$REA_ROOT" 2>/dev/null || true
273
+ REA_AUDIT_REASON="${REA_HOOK_PATCH_SESSION}" \
274
+ REA_AUDIT_FILE="$NORMALIZED" \
275
+ REA_AUDIT_SHA="$SHA_BEFORE" \
276
+ REA_AUDIT_ACTOR_NAME="$ACTOR_NAME" \
277
+ REA_AUDIT_ACTOR_EMAIL="$ACTOR_EMAIL" \
278
+ REA_AUDIT_PID="$$" \
279
+ REA_AUDIT_PPID="$PPID" \
280
+ REA_AUDIT_SESSION="${CLAUDE_SESSION_ID:-external}" \
281
+ REA_AUDIT_ROOT="$REA_ROOT" \
282
+ node --input-type=module -e '
283
+ const root = process.env.REA_AUDIT_ROOT;
284
+ async function loadMod() {
285
+ // Consumer path: `@bookedsolid/rea` resolvable via node_modules
286
+ // (how `rea init`-installed consumers reach the published package)
287
+ // or via package self-reference when the hook runs inside the rea
288
+ // source repo itself.
289
+ try {
290
+ return await import("@bookedsolid/rea/audit");
291
+ } catch (e1) {
292
+ // Dev path: direct file import from the source repos dist/.
293
+ try {
294
+ return await import(root + "/dist/audit/append.js");
295
+ } catch (e2) {
296
+ process.stderr.write(
297
+ "audit import failed: package=" + (e1 && e1.message ? e1.message : e1) +
298
+ "; dist=" + (e2 && e2.message ? e2.message : e2) + "\n");
299
+ process.exit(1);
300
+ }
301
+ }
302
+ }
303
+ (async () => {
304
+ const mod = await loadMod();
305
+ try {
306
+ await mod.appendAuditRecord(root, {
307
+ session_id: process.env.REA_AUDIT_SESSION,
308
+ tool_name: "hooks.patch.session",
309
+ server_name: "rea",
310
+ tier: "write",
311
+ status: "allowed",
312
+ autonomy_level: "unknown",
313
+ duration_ms: 0,
314
+ metadata: {
315
+ reason: process.env.REA_AUDIT_REASON,
316
+ file: process.env.REA_AUDIT_FILE,
317
+ sha_before: process.env.REA_AUDIT_SHA,
318
+ actor: {
319
+ name: process.env.REA_AUDIT_ACTOR_NAME,
320
+ email: process.env.REA_AUDIT_ACTOR_EMAIL,
321
+ },
322
+ pid: Number(process.env.REA_AUDIT_PID),
323
+ ppid: Number(process.env.REA_AUDIT_PPID),
324
+ },
325
+ });
326
+ process.exit(0);
327
+ } catch (e) {
328
+ process.stderr.write("audit append failed: " + (e && e.message ? e.message : e) + "\n");
329
+ process.exit(1);
330
+ }
331
+ })();
332
+ ' 2>&1
333
+ )
334
+ AUDIT_EXIT=$?
335
+ if [[ "$AUDIT_EXIT" -ne 0 ]]; then
336
+ # Fail closed. We deliberately do NOT fall back to a raw `jq … >> audit`
337
+ # write: that path skips prev_hash/hash computation and would silently
338
+ # degrade the hash-chain integrity the rest of REA (and `rea audit verify`)
339
+ # relies on. If the TypeScript chain is unavailable (no `dist/`, missing
340
+ # Node, broken import), refuse the hook-patch edit and surface why. The
341
+ # operator resolves by building the package (`pnpm build`) or running
342
+ # against a published install that ships `dist/`.
343
+ {
344
+ printf 'SETTINGS PROTECTION: audit-append failed; refusing hook-patch edit\n'
345
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
346
+ printf ' Rule: hash-chained audit is required; no raw-jq fallback.\n'
347
+ printf ' Detail: %s\n' "$(sanitize_for_stderr "$AUDIT_PAYLOAD")"
348
+ } >&2
349
+ exit 2
350
+ fi
351
+ printf 'REA_HOOK_PATCH_SESSION: allowing edit to %s (reason: %s)\n' \
352
+ "$SAFE_NORMALIZED" "$SAFE_REASON" >&2
353
+ exit 0
144
354
  fi
145
- done
355
+ fi
356
+
357
+ # ── 6c. Patch-session patterns are still blocked when env var is NOT set ─────
358
+ if match_patch_session; then
359
+ {
360
+ printf 'SETTINGS PROTECTION: Modification blocked\n'
361
+ printf '\n'
362
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
363
+ printf ' Matched: %s\n' "$PROTECTED_MATCH"
364
+ printf ' Rule: Files under this path are protected. To apply an upstream\n'
365
+ printf ' hook finding, set REA_HOOK_PATCH_SESSION=<reason> and retry.\n'
366
+ } >&2
367
+ exit 2
368
+ fi
369
+
370
+ if match_patch_session_ci; then
371
+ {
372
+ printf 'SETTINGS PROTECTION: Modification blocked (case-insensitive match)\n'
373
+ printf '\n'
374
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
375
+ printf ' Matched: %s\n' "$PROTECTED_MATCH"
376
+ } >&2
377
+ exit 2
378
+ fi
146
379
 
147
380
  exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.9.4",
3
+ "version": "0.10.0",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",