@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 +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/push-review-core.sh +38 -1
- package/hooks/commit-review-gate.sh +25 -1
- package/hooks/settings-protection.sh +297 -64
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -480,6 +480,111 @@ and `ClaudeSelfReviewer` is the in-process fallback (tagged
|
|
|
480
480
|
`degraded: true` in the audit record so self-review is visible and
|
|
481
481
|
countable).
|
|
482
482
|
|
|
483
|
+
## Agent push workflow — satisfying the push-review gate
|
|
484
|
+
|
|
485
|
+
When `git push` is blocked by `push-review-gate.sh` the gate prints
|
|
486
|
+
remediation steps. This section is the canonical one-command flow the
|
|
487
|
+
steps reduce to. Agents should copy-paste this verbatim; humans should
|
|
488
|
+
expect agents to.
|
|
489
|
+
|
|
490
|
+
### 1. Run the adversarial review
|
|
491
|
+
|
|
492
|
+
```bash
|
|
493
|
+
# From an interactive Claude Code session:
|
|
494
|
+
/codex-review
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
This invokes the `codex-adversarial` agent, which records a
|
|
498
|
+
`codex.review` audit entry with `verdict: pass | concerns | blocking |
|
|
499
|
+
error` and a `finding_count`. The push gate looks up that entry by
|
|
500
|
+
`head_sha + verdict ∈ {pass, concerns}`.
|
|
501
|
+
|
|
502
|
+
### 2. Record-and-cache in one CLI call
|
|
503
|
+
|
|
504
|
+
If you already have a review verdict (from `/codex-review`, or from a
|
|
505
|
+
manual Codex run, or from an offline review) emit the audit record AND
|
|
506
|
+
update the push-review cache with a single command:
|
|
507
|
+
|
|
508
|
+
```bash
|
|
509
|
+
rea audit record codex-review \
|
|
510
|
+
--head-sha "$(git rev-parse HEAD)" \
|
|
511
|
+
--branch "$(git rev-parse --abbrev-ref HEAD)" \
|
|
512
|
+
--target main \
|
|
513
|
+
--verdict pass \
|
|
514
|
+
--finding-count 0 \
|
|
515
|
+
--summary "no findings" \
|
|
516
|
+
--also-set-cache
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
`--also-set-cache` writes both `.rea/audit.jsonl` and
|
|
520
|
+
`.rea/review-cache.jsonl` in the same invocation (two sequential
|
|
521
|
+
appends, not a two-phase commit — but close enough in practice that the
|
|
522
|
+
push-gate lookup cannot see the audit record without the cache entry
|
|
523
|
+
unless a crash lands between them). Without it, the audit record lands
|
|
524
|
+
but the cache stays cold — and the next `git push` pays for a re-review
|
|
525
|
+
even though the audit trail already shows the review happened.
|
|
526
|
+
`--also-set-cache` is what the gate's remediation text should be reduced
|
|
527
|
+
to.
|
|
528
|
+
|
|
529
|
+
Verdict mapping for the cache leg:
|
|
530
|
+
|
|
531
|
+
| `--verdict` | Cache `result` | Cache `reason` |
|
|
532
|
+
| ------------ | -------------- | -------------- |
|
|
533
|
+
| `pass` | `pass` | — (omitted) |
|
|
534
|
+
| `concerns` | `pass` | `codex:concerns` |
|
|
535
|
+
| `blocking` | `fail` | `codex:blocking` |
|
|
536
|
+
| `error` | `fail` | `codex:error` |
|
|
537
|
+
|
|
538
|
+
### 3. Push
|
|
539
|
+
|
|
540
|
+
```bash
|
|
541
|
+
git push
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
The gate hits the cache, sees `{"hit":true,"result":"pass"}`, and exits
|
|
545
|
+
0 on the first attempt. No `!`-bash escapes, no manual audit writing,
|
|
546
|
+
no separate `rea cache set` invocation.
|
|
547
|
+
|
|
548
|
+
### SDK alternative
|
|
549
|
+
|
|
550
|
+
When embedding the flow in a TypeScript tool instead of shelling out,
|
|
551
|
+
import the public audit helper:
|
|
552
|
+
|
|
553
|
+
```ts
|
|
554
|
+
import {
|
|
555
|
+
appendAuditRecord,
|
|
556
|
+
CODEX_REVIEW_SERVER_NAME,
|
|
557
|
+
CODEX_REVIEW_TOOL_NAME,
|
|
558
|
+
InvocationStatus,
|
|
559
|
+
Tier,
|
|
560
|
+
} from '@bookedsolid/rea/audit';
|
|
561
|
+
|
|
562
|
+
await appendAuditRecord(process.cwd(), {
|
|
563
|
+
tool_name: CODEX_REVIEW_TOOL_NAME,
|
|
564
|
+
server_name: CODEX_REVIEW_SERVER_NAME,
|
|
565
|
+
tier: Tier.Read,
|
|
566
|
+
status: InvocationStatus.Allowed,
|
|
567
|
+
metadata: {
|
|
568
|
+
head_sha: headSha,
|
|
569
|
+
target: 'main',
|
|
570
|
+
finding_count: 0,
|
|
571
|
+
verdict: 'pass',
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
The CLI wraps exactly this — use the CLI unless the host is already a
|
|
577
|
+
TypeScript process that wants to avoid the subprocess roundtrip.
|
|
578
|
+
|
|
579
|
+
### Agent autonomy self-consistency
|
|
580
|
+
|
|
581
|
+
At autonomy `L1`, `rea cache check`, `rea audit record codex-review`,
|
|
582
|
+
`rea doctor`, and `rea status` are classified **Read tier** — they
|
|
583
|
+
cannot be denied by REA's own middleware. `rea cache set` is Write
|
|
584
|
+
tier and is still allowed at L1. `rea freeze` is Destructive tier and
|
|
585
|
+
is denied at L1 (deny-reason includes the subcommand, e.g.
|
|
586
|
+
`Bash (rea freeze)`, not just `Bash`).
|
|
587
|
+
|
|
483
588
|
## Hooks
|
|
484
589
|
|
|
485
590
|
Fourteen hooks. Each does one thing.
|
package/THREAT_MODEL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Threat Model — REA Gateway and Hook Layer
|
|
2
2
|
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.x | Last updated: 2026-04-21
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -475,6 +475,24 @@ Ref: `src/registry/fingerprint.ts` (`canonicalize()`, `fingerprintServer()`), `s
|
|
|
475
475
|
|
|
476
476
|
Ref: `src/gateway/middleware/injection.ts`, `src/gateway/middleware/injection.test.ts`.
|
|
477
477
|
|
|
478
|
+
### 5.22 Hook-Patch Session Env Var (0.10.0, Defect I)
|
|
479
|
+
|
|
480
|
+
**Threat:** `settings-protection.sh` blanket-blocks edits under `.claude/hooks/`. That is the correct default (agents must not silently rewrite safety infrastructure) but it leaves no documented path for applying upstream-sourced CodeRabbit/Codex findings on hook scripts during a live session. Before 0.10.0, operators reached for `!`-bash to sidestep the hook entirely, which dodged every audit surface — a worse outcome than the block it was working around. The source-of-truth `hooks/` directory is intentionally editable by default; `rea init` is the supply-chain step that promotes those edits into `.claude/hooks/`, so gating at the runtime directory is where the runtime-trust decision belongs.
|
|
481
|
+
|
|
482
|
+
**Mitigations:**
|
|
483
|
+
|
|
484
|
+
- `REA_HOOK_PATCH_SESSION=<reason>` is a **session-scoped**, **self-revoking** bypass. When set to a non-empty value, `settings-protection.sh` (`hooks/settings-protection.sh:219-336`) allows edits ONLY to paths under `.claude/hooks/` (the runtime directory). Every other protected path (`.rea/policy.yaml`, `.rea/HALT`, `.claude/settings.json`, `.claude/settings.local.json`) remains blocked — this is a hook-maintenance escape hatch, not a policy-editing one.
|
|
485
|
+
- Order of enforcement: (§5a) reject any path containing a `..` segment in either raw or normalized form; (§6) deny hard-protected paths; (§6b) only then consult the patch-session allowlist. This ordering closes a pre-merge Codex-surfaced bypass where `.claude/hooks/../settings.json` slipped through the old patch-session case-glob and reached `.claude/settings.json` on disk with the env var set.
|
|
486
|
+
- The session boundary IS the expiry. A new shell requires a fresh opt-in. There is no auto-expiry countdown to race, no policy-file flag that can silently linger across sessions, no CI refusal — the env var lives and dies with the process that set it.
|
|
487
|
+
- Every allowed edit emits a `hooks.patch.session` audit record routed through the TypeScript `appendAuditRecord()` chain (`src/audit/append.ts`) so the entry extends the hash chain and cannot be silently back-filled. The payload carries: `metadata.reason` (env-var value verbatim, control-character-stripped before display), `metadata.file` (normalized path), `metadata.sha_before` (pre-edit SHA-256 of the file, captured via the portable `sha256sum`/`shasum`/`openssl` fallback chain), `metadata.actor.{name,email}` (from `git config`), `metadata.pid`, `metadata.ppid`. The hook resolves the audit module via two paths, in order: (1) `import("@bookedsolid/rea/audit")` — succeeds for `rea init`-installed consumers via `node_modules/`, and via package self-reference when running inside the rea source repo; (2) `import(REA_ROOT + "/dist/audit/append.js")` — direct file import for development checkouts. If BOTH resolve-paths fail, the edit is **refused outright** (exit 2). There is no bash-native jq fallback because that path would skip `prev_hash`/`hash` computation and silently degrade the chain integrity `rea audit verify` relies on — audit-drop is indistinguishable from chain tampering, and a hook that can drop audit on demand is a governance failure, not a usability win.
|
|
488
|
+
- Note on `codex.review` entries: audit records emitted by `rea audit record codex-review` (and the related hook-append paths) are **local assertions** recorded by the operator's agent runtime. They extend the local hash chain — so retroactive tampering is detectable — but they are not signed by Codex or by any external party. Operators auditing a consumer repo must treat the presence of a `codex.review` record as evidence that SOMEONE claimed a Codex pass at that SHA, not cryptographic proof that Codex itself issued that verdict.
|
|
489
|
+
|
|
490
|
+
**Trust boundary:** `REA_HOOK_PATCH_SESSION` is advisory for the tool-gate bypass only. It does not raise the autonomy level, does not disable any other hook, and does not relax blocked-paths, attribution, or secret-scanning enforcement. The gateway-layer middleware chain does not read this env var — it only affects the shell hook that guards `.claude/hooks/`. A malicious agent that set the env var for itself without human intent would gain the ability to mutate runtime hooks, but every such mutation is auditable and the session ends with the shell; there is no persistent escalation path.
|
|
491
|
+
|
|
492
|
+
**Residual risk:** An operator who declares a reason and then forgets to unset the variable carries the bypass across every subsequent hook edit in that shell. Mitigation: treat the env var as a one-use stand and unset it immediately after the intended patch; the audit trail will show repeated `hooks.patch.session` records if the lifetime leaks. A follow-up hardening could scope the var to a single edit by tying it to a nonce committed to the audit record and invalidating on next append — not shipped in 0.10.0 because the session-boundary model matches how operators actually reason about the feature.
|
|
493
|
+
|
|
494
|
+
Ref: `hooks/settings-protection.sh:86-336`, `.claude/hooks/settings-protection.sh` (dogfood mirror), `__tests__/hooks/settings-protection-patch-session.test.ts`, `src/audit/append.ts`.
|
|
495
|
+
|
|
478
496
|
---
|
|
479
497
|
|
|
480
498
|
## 6. Residual Risks and Open Issues
|
package/dist/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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
66
|
-
#
|
|
67
|
-
p=$(printf '%s' "$p" |
|
|
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
|
-
#
|
|
70
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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.
|
|
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)",
|