@bookedsolid/rea 0.2.1 → 0.4.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.
Files changed (65) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/THREAT_MODEL.md +100 -29
  4. package/dist/audit/append.d.ts +21 -8
  5. package/dist/audit/append.js +48 -83
  6. package/dist/audit/fs.d.ts +68 -0
  7. package/dist/audit/fs.js +171 -0
  8. package/dist/cli/audit.d.ts +40 -0
  9. package/dist/cli/audit.js +205 -0
  10. package/dist/cli/doctor.d.ts +19 -4
  11. package/dist/cli/doctor.js +172 -5
  12. package/dist/cli/index.js +26 -1
  13. package/dist/cli/init.js +93 -7
  14. package/dist/cli/install/pre-push.d.ts +335 -0
  15. package/dist/cli/install/pre-push.js +2818 -0
  16. package/dist/cli/serve.d.ts +64 -0
  17. package/dist/cli/serve.js +270 -2
  18. package/dist/cli/status.d.ts +90 -0
  19. package/dist/cli/status.js +399 -0
  20. package/dist/cli/utils.d.ts +4 -0
  21. package/dist/cli/utils.js +4 -0
  22. package/dist/gateway/audit/rotator.d.ts +116 -0
  23. package/dist/gateway/audit/rotator.js +289 -0
  24. package/dist/gateway/circuit-breaker.d.ts +17 -0
  25. package/dist/gateway/circuit-breaker.js +32 -3
  26. package/dist/gateway/downstream-pool.d.ts +2 -1
  27. package/dist/gateway/downstream-pool.js +2 -2
  28. package/dist/gateway/downstream.d.ts +39 -3
  29. package/dist/gateway/downstream.js +73 -14
  30. package/dist/gateway/log.d.ts +122 -0
  31. package/dist/gateway/log.js +334 -0
  32. package/dist/gateway/middleware/audit.d.ts +24 -1
  33. package/dist/gateway/middleware/audit.js +103 -58
  34. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  35. package/dist/gateway/middleware/blocked-paths.js +439 -67
  36. package/dist/gateway/middleware/injection.d.ts +218 -13
  37. package/dist/gateway/middleware/injection.js +433 -51
  38. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  39. package/dist/gateway/middleware/kill-switch.js +20 -1
  40. package/dist/gateway/observability/metrics.d.ts +125 -0
  41. package/dist/gateway/observability/metrics.js +321 -0
  42. package/dist/gateway/server.d.ts +19 -0
  43. package/dist/gateway/server.js +99 -15
  44. package/dist/policy/loader.d.ts +47 -0
  45. package/dist/policy/loader.js +47 -0
  46. package/dist/policy/profiles.d.ts +13 -0
  47. package/dist/policy/profiles.js +12 -0
  48. package/dist/policy/types.d.ts +52 -0
  49. package/dist/registry/fingerprint.d.ts +73 -0
  50. package/dist/registry/fingerprint.js +81 -0
  51. package/dist/registry/fingerprints-store.d.ts +62 -0
  52. package/dist/registry/fingerprints-store.js +111 -0
  53. package/dist/registry/interpolate.d.ts +58 -0
  54. package/dist/registry/interpolate.js +121 -0
  55. package/dist/registry/loader.d.ts +2 -2
  56. package/dist/registry/loader.js +22 -1
  57. package/dist/registry/tofu-gate.d.ts +41 -0
  58. package/dist/registry/tofu-gate.js +189 -0
  59. package/dist/registry/tofu.d.ts +111 -0
  60. package/dist/registry/tofu.js +173 -0
  61. package/dist/registry/types.d.ts +9 -1
  62. package/package.json +3 -1
  63. package/profiles/bst-internal-no-codex.yaml +5 -0
  64. package/profiles/bst-internal.yaml +7 -0
  65. package/scripts/tarball-smoke.sh +197 -0
package/.husky/pre-push CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/bin/sh
2
+ # rea:husky-pre-push-gate v1
3
+ # rea:gate-body-v1
2
4
  # .husky/pre-push — rea governance gate for terminal-initiated pushes.
3
5
  #
4
6
  # Mirrors the logic of `.claude/hooks/push-review-gate.sh` but consumes the
@@ -20,8 +22,10 @@
20
22
  # which ran the loop in a subshell — `exit 1` inside the loop aborted the
21
23
  # subshell only, and the script then ran `exit 0` and allowed the push. We
22
24
  # now feed the loop with a here-doc so it runs in the main shell, and we
23
- # track `block_push` in the enclosing scope. Final `exit 1` is reached only
24
- # if no refspec is blocked; a single blocking refspec propagates correctly.
25
+ # abort immediately (`exit 1`) on the first blocking refspec. The accumulator
26
+ # pattern (`block_push=1; continue`) was dropped so the text-level detector
27
+ # in `src/cli/install/pre-push.ts` can verify the miss-path is truly blocking
28
+ # without modeling loop-carried flags and post-loop exit blocks.
25
29
 
26
30
  set -eu
27
31
 
@@ -63,13 +67,11 @@ if [ -f "$READ_FIELD_JS" ]; then
63
67
  fi
64
68
  fi
65
69
 
66
- block_push=0
67
-
68
- # Here-doc feeds the loop without creating a subshell, so `block_push=1`
69
- # assignments below persist in the enclosing scope and the final `exit`
70
- # reflects them. A pipeline would run the loop in a subshell and `exit 1`
71
- # inside it would only abort that subshell — NOT the push — which was a
72
- # real governance defect in the pre-review version of this file.
70
+ # Here-doc feeds the loop without creating a subshell, so an `exit 1`
71
+ # inside the loop terminates the hook and blocks the push. A pipeline
72
+ # would run the loop in a subshell and `exit 1` inside it would only
73
+ # abort that subshell NOT the push which was a real governance
74
+ # defect in the pre-review version of this file.
73
75
  while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
74
76
  [ -z "${local_sha:-}" ] && continue
75
77
  # Branch deletion: local_sha is 40 zeros. Skip protected-path check.
@@ -103,26 +105,21 @@ while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
103
105
  if [ ! -f "$AUDIT_LOG" ]; then
104
106
  printf 'PUSH BLOCKED: protected paths changed but no audit log found at %s\n' "$AUDIT_LOG" >&2
105
107
  printf ' Run /codex-review on HEAD %s before pushing.\n' "$local_sha" >&2
106
- block_push=1
107
- continue
108
+ exit 1
108
109
  fi
109
110
  # Require both (a) a `codex.review` tool_name and (b) the exact head_sha
110
111
  # on the same JSONL line. The `codex.review` pattern ends with a closing
111
- # quote, so `codex.review.skipped` never satisfies the gate.
112
+ # quote, so `codex.review.skipped` never satisfies the gate. The first
113
+ # refspec that fails this check aborts the hook — no accumulator needed.
112
114
  if ! grep -E '"tool_name":"codex\.review"' "$AUDIT_LOG" 2>/dev/null | \
113
115
  grep -qF "\"head_sha\":\"$local_sha\""; then
114
116
  printf 'PUSH BLOCKED: protected paths changed — /codex-review required for HEAD %s\n' "$local_sha" >&2
115
117
  printf ' Run /codex-review, or set REA_SKIP_CODEX_REVIEW=<reason> to bypass.\n' >&2
116
- block_push=1
117
- continue
118
+ exit 1
118
119
  fi
119
120
  fi
120
121
  done <<HOOK_INPUT_EOF
121
122
  $INPUT
122
123
  HOOK_INPUT_EOF
123
124
 
124
- if [ "$block_push" -ne 0 ]; then
125
- exit 1
126
- fi
127
-
128
125
  exit 0
package/README.md CHANGED
@@ -66,7 +66,10 @@ to build a separate package that composes with REA.
66
66
  `policy.yaml` is the maximum surface area — one outbound POST, opt-in.
67
67
  - **Not a daemon supervisor.** `rea serve` is started by Claude Code via
68
68
  `.mcp.json`. Claude Code owns the lifecycle. There is no `rea start`,
69
- no `rea stop`, no pid file, no systemd unit.
69
+ no `rea stop`, no systemd unit. A short-lived `.rea/serve.pid`
70
+ breadcrumb is written at startup so `rea status` can detect a live
71
+ gateway — it is removed on graceful shutdown and never used for
72
+ locking or lifecycle management.
70
73
  - **Not a hosted service.** There is no REA Cloud, no SaaS tier, no
71
74
  multi-token workstreams, no workload isolation platform.
72
75
  - **Not a 70-agent roster.** 10 curated agents ship in the package. Four
@@ -132,6 +135,43 @@ install, `.mcp.json` gateway wiring, Codex plugin availability, and the
132
135
  integrity of the audit hash chain. It returns a pass/fail summary with
133
136
  specific remediation hints.
134
137
 
138
+ ### 4. Watch the running gateway
139
+
140
+ ```bash
141
+ rea status # human-readable summary
142
+ rea status --json # JSON — pipe to jq
143
+ ```
144
+
145
+ `rea status` is the live-process view. It reads the pidfile written by
146
+ `rea serve`, verifies the pid is alive, and surfaces the session id,
147
+ policy summary (profile, autonomy, HALT state), and audit stats (lines,
148
+ last timestamp, whether the tail record's hash looks well-formed). Use
149
+ `rea check` when you want the pure on-disk view without probing for a
150
+ live process.
151
+
152
+ ### 5. Optional Prometheus `/metrics` endpoint
153
+
154
+ `rea serve` can expose a loopback-only Prometheus endpoint when the
155
+ `REA_METRICS_PORT` environment variable is set:
156
+
157
+ ```bash
158
+ REA_METRICS_PORT=9464 rea serve
159
+ # in another shell
160
+ curl http://127.0.0.1:9464/metrics
161
+ ```
162
+
163
+ Metrics exposed: per-downstream call and error counters, in-flight
164
+ gauge, audit-lines-appended counter, circuit-breaker state gauge, and a
165
+ seconds-since-last-HALT-check gauge. The listener binds to `127.0.0.1`
166
+ only, serves only `GET /metrics` (everything else is a fixed-body 404),
167
+ and never binds by default — "no silent listeners" is a design rule.
168
+ There is no TLS; scrape through SSH/a reverse proxy if you need
169
+ cross-host access.
170
+
171
+ Set `REA_LOG_LEVEL=debug` for verbose gateway logs; the default is
172
+ `info`. Records are JSON lines on a non-TTY stderr and pretty-printed
173
+ on an interactive terminal.
174
+
135
175
  ## Architecture
136
176
 
137
177
  ### Middleware chain
package/THREAT_MODEL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Threat Model — REA Gateway and Hook Layer
2
2
 
3
- Version: 0.1.x | Last updated: 2026-04-18
3
+ Version: 0.2.x | Last updated: 2026-04-18
4
4
 
5
5
  ---
6
6
 
@@ -19,11 +19,14 @@ This document describes the security threat model for REA (`@bookedsolid/rea`),
19
19
  | `.rea/policy.yaml` | Autonomy level, max autonomy ceiling, blocked paths, attribution policy | Critical — controls all tool access |
20
20
  | `.rea/audit.jsonl` | Hash-chained audit log of every tool invocation | High — integrity evidence |
21
21
  | `.rea/HALT` | Kill-switch file; presence blocks all tool calls | High — single point of emergency stop |
22
+ | `.rea/install-manifest.json` | SHA-256 baseline of shipped artifacts; drives `rea upgrade` drift reports | Medium — upgrade trust signal |
22
23
  | Hook scripts (`hooks/*.sh`) | Bash scripts that enforce security at tool invocation time | High — bypass = loss of control plane |
23
24
  | Agent definitions (`agents/*`) | Role definitions and behavioral constraints for specialist agents | Medium |
24
25
  | Secrets in scope | Credentials, API keys, tokens visible in tool arguments or results | Critical |
25
26
  | Gateway process memory | In-flight tool arguments, results, session state | Medium |
26
- | Codex invocation audit entries | Record of `/codex review` and `/codex adversarial-review` outcomes | Medium — pre-merge gate evidence |
27
+ | Codex invocation audit entries | Record of `/codex-review` / `/codex:adversarial-review` outcomes | Medium — pre-merge gate evidence |
28
+ | Escape-hatch audit entries | `codex.review.skipped` records naming the bypass reason and operator | Medium — governance-weakening signal |
29
+ | `.rea/metrics.jsonl` | Reviewer telemetry (counts, latency, rate-limit signals; NO payloads) | Low — operational observability |
27
30
 
28
31
  ---
29
32
 
@@ -32,7 +35,7 @@ This document describes the security threat model for REA (`@bookedsolid/rea`),
32
35
  ```
33
36
  ┌─────────────────────────────────────────────────────────────────┐
34
37
  │ TRUSTED │
35
- │ Human operator (operates via Claude Code UI)
38
+ │ Human operator (operates via Claude Code UI or terminal)
36
39
  │ Claude Code / agent process │
37
40
  │ Codex plugin (running under the same Claude Code process) │
38
41
  │ │ │
@@ -46,13 +49,14 @@ This document describes the security threat model for REA (`@bookedsolid/rea`),
46
49
  │ │ │
47
50
  │ ▼ │
48
51
  │ UNTRUSTED │
49
- │ Downstream MCP servers (tool descriptions, results)
52
+ │ Downstream MCP servers (tool descriptions, results, binaries)
50
53
  │ External network (responses, fetched content) │
51
54
  │ Codex plugin RESPONSES (treated as untrusted input) │
55
+ │ Downstream subprocess environment (env vars we export to them) │
52
56
  └─────────────────────────────────────────────────────────────────┘
53
57
  ```
54
58
 
55
- Downstream MCP servers are treated as untrusted by default. Codex plugin *invocations* are trusted (same process), but Codex *responses* are treated as untrusted input and flow through the injection and redaction middleware just like any other tool result. The `.rea/` directory is always protected — no agent or MCP server can write to it through the gateway.
59
+ Downstream MCP servers are treated as untrusted by default. Codex plugin *invocations* are trusted (same process), but Codex *responses* are treated as untrusted input and flow through the injection and redaction middleware just like any other tool result. The `.rea/` directory is always protected — no agent or MCP server can write to it through the gateway. Environment variables exported to downstream MCP subprocesses are a deliberate data flow outward from the trusted process to an untrusted child — see §5.11.
56
60
 
57
61
  ---
58
62
 
@@ -66,6 +70,7 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
66
70
  | Poisoned Codex response | A compromised or adversarial Codex review | Induce Claude to take unsafe action under "review" cover |
67
71
  | Local user escalation | Direct filesystem access on the same machine | Modify policy.yaml, tamper with audit log, remove hooks |
68
72
  | Supply chain attacker | npm package substitution or dependency confusion | Install malicious code that executes during build/run |
73
+ | Catalog-drift attacker | Compromised downstream starts advertising new tools | Extend attack surface silently after install review |
69
74
 
70
75
  ---
71
76
 
@@ -78,10 +83,11 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
78
83
  **Mitigations:**
79
84
 
80
85
  - `injection` middleware scans tool arguments and results for instruction-like patterns.
86
+ - All injection regexes run under a per-call worker-thread timeout (`wrapRegex`, default 100ms) with a hard kill on timeout — catastrophic backtracking cannot hang the gateway. See §5.12.
81
87
  - Gateway middleware chain re-derives tier from tool_name independently on every invocation — a poisoned description cannot change the tier classification.
82
88
  - `.rea/policy.yaml` is re-read on every invocation; a "set autonomy to L3" instruction cannot be persisted by any middleware path.
83
89
 
84
- **Residual risk:** Semantic injection in natural-language descriptions is not fully mitigated. No regex can reliably detect all instruction-injection patterns.
90
+ **Residual risk:** Semantic injection in natural-language descriptions is not fully mitigated. No regex can reliably detect all instruction-injection patterns. A three-valued escalation (`clean` / `suspicious` / `likely-injection`) is planned for 0.3.0 so write/destructive tiers require human-loop approval on suspicion rather than accepting a binary pass/fail.
85
91
 
86
92
  ---
87
93
 
@@ -91,10 +97,11 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
91
97
 
92
98
  **Mitigations:**
93
99
 
94
- - `settings-protection.sh` blocks agent writes to `.claude/settings.json`.
100
+ - `settings-protection.sh` blocks agent writes to `.claude/settings.json`, `.claude/hooks/*`, `.husky/*`, `.rea/policy.yaml`, and `.rea/HALT`.
95
101
  - The attribution-advisory hook and commit-msg hook both reject AI-attributed commits, so bypassing one does not disable the other.
96
102
  - `CLAUDE.md` template bundled with REA enforces a non-negotiable rule: `--no-verify` is never permitted.
97
103
  - The hook layer and gateway layer are independent — bypassing hooks does not disable the gateway middleware chain.
104
+ - `dangerous-bash-interceptor.sh` explicitly pattern-matches `--no-verify`, `--no-gpg-sign`, `git reset --hard`, `rm -rf /`, and other destructive forms.
98
105
 
99
106
  **Residual risk:** A sufficiently privileged local process (same user) can modify git config directly without going through the hook. There is no runtime detection for this outside of audit log review.
100
107
 
@@ -110,6 +117,8 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
110
117
  - `policy` middleware enforces the `max_autonomy_level` ceiling at runtime: autonomy_level is clamped even if policy.yaml is edited to exceed the maximum.
111
118
  - Policy file is re-read on every tool invocation, so a stricter policy takes effect immediately. A more permissive edit never bypasses the ceiling.
112
119
  - `promotion_requires_human_approval: true` is the default; CLI flag `--i-understand-the-risks` is required to disable it.
120
+ - Policy schema is zod `.strict()` — unknown fields fail loudly at load, not silently ignored. Typos cannot silently defang a policy.
121
+ - User-supplied redaction patterns are validated against `safe-regex` at policy load time; a flagged pattern rejects the entire policy load.
113
122
 
114
123
  **Residual risk:** If the local user (not an agent) directly edits policy.yaml and also raises `max_autonomy_level`, there is no enforcement beyond the honor system. This is by design — the human operator is a trusted actor.
115
124
 
@@ -125,8 +134,9 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
125
134
  - Audit write failures are logged to stderr but do not crash the gateway — the tool call proceeds. This avoids DoS via audit failure while preserving the event.
126
135
  - `.rea/` is always blocked from agent writes, so agents cannot delete the audit file through the gateway.
127
136
  - `rea check audit` verifies the hash chain on demand; intended for pre-merge gates and compliance reviews.
137
+ - Public `@bookedsolid/rea/audit` helper (`src/audit/append.ts`) is the only supported write path for consumers — it computes the hash and appends atomically with fsync so helix.* events and rea.* events share the chain.
128
138
 
129
- **Residual risk:** A local user with filesystem access can delete or truncate audit files outside the gateway. Hash-chain verification is a detection control, not a prevention control.
139
+ **Residual risk:** A local user with filesystem access can delete or truncate audit files outside the gateway. Hash-chain verification is a detection control, not a prevention control. Concurrent-writer safety (proper-lockfile) and rotation semantics are planned for 0.3.0 (G1). Until then, concurrent `append()` calls from distinct processes can race at the fsync step.
130
140
 
131
141
  ---
132
142
 
@@ -137,9 +147,10 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
137
147
  **Mitigations:**
138
148
 
139
149
  - `redact` middleware scans both tool arguments (pre-execution) and tool results (post-execution) using secret patterns covering AWS keys, GitHub tokens, generic API keys, bearer tokens, PEM private keys, Discord tokens, Anthropic/OpenAI keys, and base64-encoded variants.
140
- - Redaction patterns are validated at load time for catastrophic-backtracking safety.
150
+ - Redaction patterns are validated at load time for catastrophic-backtracking safety (`safe-regex`), AND bounded at runtime via a per-call worker-thread timeout with hard-kill on budget exhaustion. See §5.12.
151
+ - On regex timeout, the offending value is replaced with the sentinel `[REDACTED: pattern timeout]` — a scanner that cannot complete never lets the untouched value through. The byte length of the offending input is recorded in audit metadata; the input text is NEVER written to the log.
141
152
  - `secret-scanner.sh` hook scans file writes for credential patterns.
142
- - `env-file-protection.sh` blocks reads of `.env` files.
153
+ - `env-file-protection.sh` blocks reads of `.env*` files.
143
154
 
144
155
  **Residual risk:** Secret patterns not in the catalog (custom token formats, hex-encoded credentials) will not be redacted. Encoding-based bypasses (double-URL-encoding, Unicode normalization) are partially mitigated but not comprehensively tested.
145
156
 
@@ -165,18 +176,20 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
165
176
 
166
177
  **Mitigations:**
167
178
 
168
- - `kill-switch` middleware validates that HALT is a regular file (`isFile()`), not a directory.
169
- - Symlink detection via `lstat`; if HALT is a symlink, its resolved target must remain within `.rea/`.
170
- - Read size capped at 1024 bytes.
179
+ - `kill-switch` middleware issues exactly **one syscall per invocation** against HALT: `fs.open(path, O_RDONLY)`. The previous `stat lstat → open` sequence had a TOCTOU window between the check and the read; the new implementation has none (shipped in 0.2.0, G4).
180
+ - **Semantic guarantee:** HALT is evaluated once per invocation at chain entry. A call that passes the check runs to completion; a call that fails it is denied. Creating `.rea/HALT` mid-flight does **not** cancel in-flight invocations — it blocks *subsequent* invocations only. This matches standard kill-switch semantics (SIGTERM after acceptance: the process continues).
181
+ - **Fail-closed on unknown state:** `ENOENT` → proceed; any other errno (`EACCES`, `EPERM`, `EISDIR`, `EIO`, …) → deny.
182
+ - Read size is bounded at `MAX_HALT_READ_BYTES = 1024`; a large HALT file cannot DoS a scan.
183
+ - **Observability:** `ctx.metadata.halt_decision` (`absent` / `present` / `unknown`) is recorded on every audit row, plus `ctx.metadata.halt_at_invocation` (ISO-8601 timestamp when HALT was present, else `null`).
171
184
  - The middleware **never clears HALT**. Unfreezing is an explicit `rea unfreeze --reason "..."` CLI action.
172
185
 
173
- **Residual risk:** TOCTOU between the `stat` call and the `open` call is a theoretical race on shared filesystems, but `.rea/` is a project-local directory controlled by the operator.
186
+ **Residual risk:** On shared filesystems with eventual consistency, readers on different hosts could observe HALT at different times. `.rea/` is a project-local directory by design, so this is theoretical for the intended deployment.
174
187
 
175
188
  ---
176
189
 
177
- ### 5.8 Codex Plugin Abuse
190
+ ### 5.8 Codex Plugin Abuse and Codex-Review Gate Weakening
178
191
 
179
- **Threat:** A poisoned Codex adversarial-review response contains prompt-injection content designed to make Claude take an unsafe action "per the reviewer's recommendation." A malicious actor uses `/codex-review` to launder an attack past the policy layer.
192
+ **Threat:** A poisoned Codex adversarial-review response contains prompt-injection content designed to make Claude take an unsafe action "per the reviewer's recommendation." A malicious actor uses `/codex-review` to launder an attack past the policy layer. Separately: a rogue agent or operator disables the Codex-review requirement to push protected-path changes without adversarial review.
180
193
 
181
194
  **Mitigations:**
182
195
 
@@ -184,8 +197,13 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
184
197
  - Every Codex invocation produces an audit entry with request summary, response summary, and pass/fail signal — tamper-evident via the hash chain.
185
198
  - Codex never receives `.rea/policy.yaml` content in its prompt; Codex reviews diffs, not policy.
186
199
  - The `codex-adversarial` agent cannot by itself modify policy, trigger writes, or bypass blocked paths — it is a review tool, not an actor.
200
+ - **Pluggable reviewer** (0.2.0, G11.2): when Codex is unreachable, `ClaudeSelfReviewer` is the fallback. Claude-on-Claude review is explicitly tagged `degraded: true` in the audit record so self-review is visible and countable.
201
+ - **Audited escape hatch** (0.2.0, G11.1): `REA_SKIP_CODEX_REVIEW=<reason>` bypasses the protected-path Codex requirement but writes a `codex.review.skipped` audit record carrying the verbatim reason, the operator's git identity, the head_sha, and the files-changed count. Fail-closed on missing `dist/audit/append.js` or missing git identity — the gate never silently disables. Skip records use `tool_name: "codex.review.skipped"` so a skip cannot satisfy a future Codex-review requirement on the same HEAD.
202
+ - **First-class no-Codex mode** (0.2.0, G11.4): `policy.review.codex_required: false` skips the protected-path Codex requirement entirely. In that mode `REA_SKIP_CODEX_REVIEW` becomes a no-op (skipping a review that isn't required has no meaning), and no skip record is emitted. Both `.claude/hooks/push-review-gate.sh` (Claude Code path) and `.husky/pre-push` (terminal path) honor this knob.
203
+ - **Availability probe** (0.2.0, G11.3): `rea serve` runs an initial `codex --version` probe on startup when `codex_required` ≠ false. A failed probe emits a single stderr warn — startup never fail-closes on a Codex miss.
204
+ - **Reviewer telemetry** (0.2.0, G11.5): `ClaudeSelfReviewer.review()` writes a row to `.rea/metrics.jsonl` with invocation counts, estimated tokens (chars/4), latency, and a `rate_limited` signal parsed from stderr. Payloads are NEVER stored; a unit test asserts that marker strings in inputs never appear in the metrics file.
187
205
 
188
- **Residual risk:** Semantic injection in Codex responses (e.g., reviewer recommends a specific code change that is itself malicious) cannot be fully detected. Mitigation is defense-in-depth: the middleware still runs on any subsequent write that Claude attempts based on the review.
206
+ **Residual risk:** Semantic injection in Codex responses (e.g., reviewer recommends a specific code change that is itself malicious) cannot be fully detected. Mitigation is defense-in-depth: the middleware still runs on any subsequent write that Claude attempts based on the review. A `rea doctor` abuse signal on escape-hatch frequency (≥3 invocations per rolling 7 days) is proposed for 0.3.0.
189
207
 
190
208
  ---
191
209
 
@@ -198,10 +216,10 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
198
216
  - `dependency-audit-gate.sh` runs `npm audit` before commits and blocks on high/critical vulnerabilities.
199
217
  - Dependabot weekly scans for npm and github-actions.
200
218
  - CI publish pipeline includes gitleaks secret scanning and npm publish payload validation.
201
- - **npm publish uses OIDC provenance** — package identity is cryptographically bound to the GitHub Actions workflow that built it.
202
- - REA's runtime dependencies are minimal: `@modelcontextprotocol/sdk`, `yaml`, `zod`. No transitive dep >10 levels deep.
219
+ - **npm publish uses OIDC provenance** — package identity is cryptographically bound to the GitHub Actions workflow that built it. Migration to OIDC trusted-publisher (retiring `NODE_AUTH_TOKEN`) is planned for 0.3.0 (G8).
220
+ - REA's runtime dependencies are minimal: `@anthropic-ai/sdk`, `@clack/prompts`, `@modelcontextprotocol/sdk`, `commander`, `safe-regex`, `yaml`, `zod`.
203
221
 
204
- **Residual risk:** Zero-day vulnerabilities in direct or transitive dependencies. SBOM generation is planned but not yet automated.
222
+ **Residual risk:** Zero-day vulnerabilities in direct or transitive dependencies. SBOM generation is planned but not yet automated. The `pnpm test` suite does not exercise "package works when a consumer installs it" — a dev-only dep that's mis-imported at runtime is not caught by CI (this was the 0.2.0 → 0.2.1 issue). A post-publish tarball smoke (install tarball into scratch dir, run CLI) is proposed for 0.3.0.
205
223
 
206
224
  ---
207
225
 
@@ -214,22 +232,75 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
214
232
  - **REA source code never uses `eval`, `Function()`, or dynamic `require`/`import()` on policy-driven input.** ESLint rules enforce this.
215
233
  - Policy parsing is strict zod schema — unknown fields rejected, not ignored.
216
234
  - Profile composition is a static key-merge, not a code evaluation.
235
+ - User-supplied redaction regex patterns are compiled via `new RegExp(...)` with `safe-regex` vetting at load and per-call worker-thread timeout enforcement at runtime — regex compilation is the only policy-driven code path, and it's bounded.
217
236
 
218
237
  **Residual risk:** A malicious third-party middleware plugin (not currently supported) could reintroduce this risk. Plugins are out of scope for v1 by design.
219
238
 
220
239
  ---
221
240
 
241
+ ### 5.11 Downstream Subprocess Environment Inheritance
242
+
243
+ **Threat:** `rea serve` spawns downstream MCP servers as child processes over stdio. Environment variables from the gateway process leak into the child by default (Node's `child_process.spawn` inherits `process.env` unless overridden). A malicious or compromised downstream can read anything the gateway can — `AWS_*`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GITHUB_TOKEN`, database URLs, session cookies.
244
+
245
+ **Mitigations:**
246
+
247
+ - Downstream subprocesses are launched with an explicit env object built from `registry.yaml#servers[].env` — the gateway does not pass `process.env` through wholesale.
248
+ - Registry schema is zod `.strict()` — typos in the env list fail at load.
249
+ - Operator intent to forward a specific variable (e.g., `HOME`, `PATH`) is expressed explicitly in `registry.yaml`; no "allow list by default."
250
+ - The `redact` middleware also scrubs values that match secret patterns — if a downstream inadvertently emits a credential in its response, it is redacted before reaching the agent.
251
+
252
+ **Residual risk:** If an operator explicitly forwards a credential-bearing variable into a downstream, that is a conscious trust decision — REA does not override it. A `rea doctor` lint that flags likely-credential variable names (`*_TOKEN`, `*_KEY`, `*_SECRET`) in registry.yaml is a candidate for 0.3.0 (G7 adjacent).
253
+
254
+ ---
255
+
256
+ ### 5.12 Regex Denial-of-Service (ReDoS)
257
+
258
+ **Threat:** A malicious MCP payload carefully crafted to trigger catastrophic backtracking in a redaction or injection regex — hanging the gateway's event loop and denying service to all downstream tools.
259
+
260
+ **Mitigations (shipped 0.2.0, G3):**
261
+
262
+ - Every built-in pattern in `redact.ts` and `injection.ts` is statically linted via `safe-regex` at build time (`pnpm lint:regex` — chained into `pnpm lint` BEFORE eslint so a bad regex short-circuits the pipeline).
263
+ - Every user-supplied `redact.patterns[]` is re-vetted via `safe-regex` at policy load time; a flagged pattern rejects the entire policy load, naming the offender.
264
+ - Every regex call at runtime flows through `wrapRegex(pattern, {timeoutMs})` — a worker-thread timeout wrapper that blocks the parent on `Atomics.wait` over a SharedArrayBuffer and hard-`terminate()`s the worker on budget exhaustion. Default budget is 100ms; configurable via `policy.redact.match_timeout_ms`.
265
+ - On timeout the redaction middleware substitutes the sentinel `[REDACTED: pattern timeout]` and records `{event: "redact.regex_timeout", pattern_source, pattern_id, input_bytes, timeout_ms}` on `ctx.metadata`. The input text is NEVER written — only its byte length.
266
+ - The "Private Key" PEM armor pattern, flagged by `safe-regex` in the original form, was tightened to a bounded alternation that `safe-regex` accepts.
267
+
268
+ **Residual risk:** A pattern that `safe-regex` approves but that is nevertheless slow on pathological inputs could still time out frequently, effectively denying redaction for that class of input. The sentinel-replacement behavior is a fail-secure outcome (the value is redacted), but a downstream that can trigger mass timeouts can effectively delete content from reaching the agent. Detection via `.rea/metrics.jsonl` rate-limit signals is the current observability story.
269
+
270
+ ---
271
+
272
+ ### 5.13 Installer Path Trust
273
+
274
+ **Threat:** `rea init` and `rea upgrade` copy shipped artifacts from the npm package into a consumer project (`.claude/hooks/*`, `.claude/agents/*`, `.claude/commands/*`, `.husky/*`, a managed CLAUDE.md fragment). A compromised npm tarball could carry a subverted hook that runs in the consumer's context with the consumer's privileges.
275
+
276
+ **Mitigations (shipped 0.2.0, G12):**
277
+
278
+ - Shipped artifacts are listed explicitly in `package.json#files[]`; nothing outside `dist/`, `hooks/`, `agents/`, `commands/`, `.husky/`, `scripts/`, `profiles/` is in the tarball.
279
+ - npm publish uses `--provenance` — each published version's tarball is cryptographically bound to the exact GitHub Actions workflow run that built it (commit SHA, workflow file, runner image). A consumer can verify provenance via `npm audit signatures`.
280
+ - `rea init` writes `.rea/install-manifest.json` recording the SHA-256 of every shipped file on first install. Subsequent `rea upgrade` runs compare canonical (what this rea version ships) against on-disk content via that manifest, and against the consumer's previous baseline — drifted files are flagged, not silently replaced.
281
+ - `rea upgrade` conflict policy: `unmodified` files auto-update silently; `drifted` files prompt (`keep | overwrite | diff`); `--yes` defaults to `keep` (safe). `--force` required for overwrite.
282
+ - Hook scripts are chmodded `0o755` during copy; the manifest records the hash of the content, not the mode, so a tampered mode is caught by `rea doctor` (which separately checks `hook executable`).
283
+ - The `postinstall` hook prints a one-line stderr nudge when the installed rea version disagrees with the manifest version — silent inside CI (`CI=true`), silent when no manifest exists, silent inside the rea repo itself. It never fails the install.
284
+
285
+ **Residual risk:** A consumer that blindly accepts `rea upgrade` prompts without reviewing diff output is trusting the current rea version's maintainers transitively through npm. Mitigation depends on the provenance ecosystem maturing — the `npm audit signatures` verification is a manual step today, not a default gate.
286
+
287
+ ---
288
+
222
289
  ## 6. Residual Risks and Open Issues
223
290
 
224
291
  | Risk | Severity | Tracking |
225
292
  | ------------------------------------------------------------- | -------- | ------------------------------ |
226
- | Semantic prompt injection via tool descriptions | High | No issue filed |
227
- | Semantic injection via Codex adversarial-review responses | High | No issue filed |
228
- | Double-URL-encoding bypass for blocked paths | Medium | Planned fix in 0.2.x |
229
- | No real-time alert on audit hash chain break | Medium | Planned for 0.3.x |
230
- | SBOM not automated in publish pipeline | Medium | Planned for 0.2.x |
293
+ | Semantic prompt injection via tool descriptions | High | 0.3.0 G9 (tier escalation) |
294
+ | Semantic injection via Codex adversarial-review responses | High | No issue filed (defense in depth via middleware) |
295
+ | Double-URL-encoding bypass for blocked paths | Medium | Planned fix |
296
+ | No real-time alert on audit hash chain break | Medium | 0.3.0 G1 + G5 |
297
+ | Concurrent audit writers can race at fsync | Medium | 0.3.0 G1 (proper-lockfile) |
298
+ | SBOM not automated in publish pipeline | Medium | Planned |
231
299
  | Secret pattern gaps (custom token formats, encoding variants) | Medium | No issue filed |
232
- | TOCTOU on HALT file in shared filesystem scenarios | Low | Theoretical |
300
+ | Post-publish tarball smoke not in CI | Medium | 0.3.0 CI hardening |
301
+ | Escape-hatch abuse signal not surfaced in `rea doctor` | Low | 0.3.0 (threshold: ≥3 / 7d) |
302
+ | Catalog drift by downstream not detected on reconnect | Medium | 0.3.0 G7 (fingerprint + drift) |
303
+ | OIDC trusted publisher not yet migrated (`NODE_AUTH_TOKEN` still in use) | Medium | 0.3.0 G8 |
233
304
  | Local user can escalate policy.yaml outside gateway | Low | By design (trusted actor) |
234
305
 
235
306
  ---
@@ -238,8 +309,8 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
238
309
 
239
310
  REA operates two independent layers. Bypassing one does not disable the other.
240
311
 
241
- **Hook layer** (development-time): 11 Claude Code hooks intercept tool calls before execution at the Claude Code level. Hooks enforce: secret scanning, dangerous command interception, blocked path enforcement, settings protection, attribution advisory, dependency audit, commit/push review gates, and PR issue linking.
312
+ **Hook layer** (development-time): 13 Claude Code hooks intercept tool calls before execution at the Claude Code level. Hooks enforce: secret scanning, dangerous command interception, blocked path enforcement, settings protection, attribution advisory, dependency audit, commit/push review gates, PR issue linking, architecture review, env file protection, changeset security gates, and security-disclosure gates.
242
313
 
243
- **Gateway layer** (runtime, `rea serve`): A middleware chain processes every proxied MCP tool call. Middleware enforces: kill switch, policy/autonomy level, blocked paths, tier classification, rate limit, circuit breaker, secret redaction (pre and post), prompt injection detection, result size cap, and hash-chained audit logging.
314
+ **Gateway layer** (runtime, `rea serve`): A middleware chain processes every proxied MCP tool call. Middleware enforces: audit, kill switch, policy/autonomy level, tier classification, blocked paths, rate limit, circuit breaker, prompt injection detection, secret redaction (pre and post), and result size cap.
244
315
 
245
- Both layers fail closed: on read failure, parse error, or unexpected condition, the default action is deny.
316
+ Both layers fail closed: on read failure, parse error, unknown errno on HALT, regex timeout, or any unexpected condition, the default action is deny (or for redaction specifically: replace with a sentinel — the content never escapes unscanned).
@@ -15,19 +15,25 @@
15
15
  * - Never throws on stat/missing-file conditions; only throws on write failure
16
16
  * (the caller decides how to react).
17
17
  *
18
- * ## Concurrency
18
+ * ## Concurrency (G1)
19
19
  *
20
- * The helper serializes writes per-process via a module-scoped queue keyed by
21
- * the resolved audit-file path. Cross-process concurrency on the same file is
22
- * NOT handled here writers in separate processes can interleave and break
23
- * the chain. The current deployment targets (rea's own governance hooks, the
24
- * Codex agent, Helix) all funnel through a single process at a time. If that
25
- * changes, add an exclusive-lock file (`audit.jsonl.lock`) before lifting this
26
- * restriction. Documented risk; do not silently expand the guarantee.
20
+ * Writes are serialized two ways:
21
+ *
22
+ * 1. Per-process: a module-scoped queue keyed by the canonical path
23
+ * preserves linear ordering within a single Node process.
24
+ * 2. Cross-process: each `doAppend` call is wrapped in a `proper-lockfile`
25
+ * lock on `.rea/`. Stale locks are reclaimed after 10s. Two processes
26
+ * appending concurrently serialize cleanly; the hash chain stays linear.
27
+ *
28
+ * Rotation (`maybeRotate`) runs BEFORE the append lock is taken, so a full
29
+ * audit file is rotated out of the way transparently. The rotation marker
30
+ * record preserves hash-chain continuity across the boundary.
27
31
  *
28
32
  * @see {@link file://./codex-event.ts} for the canonical `codex.review` shape.
33
+ * @see {@link file://../gateway/audit/rotator.ts} for rotation semantics.
29
34
  */
30
35
  import { Tier, InvocationStatus } from '../policy/types.js';
36
+ import type { Policy } from '../policy/types.js';
31
37
  import type { AuditRecord } from '../gateway/middleware/audit-types.js';
32
38
  /**
33
39
  * Input shape for {@link appendAuditRecord}. All fields except `tool_name`
@@ -47,6 +53,13 @@ export interface AppendAuditInput {
47
53
  metadata?: Record<string, unknown>;
48
54
  /** ISO-8601 timestamp; defaults to `new Date().toISOString()` */
49
55
  timestamp?: string;
56
+ /**
57
+ * Optional policy for rotation decisions. When absent, rotation is
58
+ * disabled (back-compat). Callers that want rotation pass the already-
59
+ * loaded policy; the helper does not re-read `.rea/policy.yaml` on every
60
+ * append — that would be a surprise cost for consumers.
61
+ */
62
+ policy?: Policy;
50
63
  }
51
64
  /**
52
65
  * Append a structured audit record to `${baseDir}/.rea/audit.jsonl` with a
@@ -15,23 +15,28 @@
15
15
  * - Never throws on stat/missing-file conditions; only throws on write failure
16
16
  * (the caller decides how to react).
17
17
  *
18
- * ## Concurrency
18
+ * ## Concurrency (G1)
19
19
  *
20
- * The helper serializes writes per-process via a module-scoped queue keyed by
21
- * the resolved audit-file path. Cross-process concurrency on the same file is
22
- * NOT handled here writers in separate processes can interleave and break
23
- * the chain. The current deployment targets (rea's own governance hooks, the
24
- * Codex agent, Helix) all funnel through a single process at a time. If that
25
- * changes, add an exclusive-lock file (`audit.jsonl.lock`) before lifting this
26
- * restriction. Documented risk; do not silently expand the guarantee.
20
+ * Writes are serialized two ways:
21
+ *
22
+ * 1. Per-process: a module-scoped queue keyed by the canonical path
23
+ * preserves linear ordering within a single Node process.
24
+ * 2. Cross-process: each `doAppend` call is wrapped in a `proper-lockfile`
25
+ * lock on `.rea/`. Stale locks are reclaimed after 10s. Two processes
26
+ * appending concurrently serialize cleanly; the hash chain stays linear.
27
+ *
28
+ * Rotation (`maybeRotate`) runs BEFORE the append lock is taken, so a full
29
+ * audit file is rotated out of the way transparently. The rotation marker
30
+ * record preserves hash-chain continuity across the boundary.
27
31
  *
28
32
  * @see {@link file://./codex-event.ts} for the canonical `codex.review` shape.
33
+ * @see {@link file://../gateway/audit/rotator.ts} for rotation semantics.
29
34
  */
30
35
  import fs from 'node:fs/promises';
31
36
  import path from 'node:path';
32
- import crypto from 'node:crypto';
33
37
  import { Tier, InvocationStatus } from '../policy/types.js';
34
- const GENESIS_HASH = '0'.repeat(64);
38
+ import { GENESIS_HASH, computeHash, fsyncFile, readLastRecord, withAuditLock, } from './fs.js';
39
+ import { maybeRotate } from '../gateway/audit/rotator.js';
35
40
  const REA_DIR = '.rea';
36
41
  const AUDIT_FILE = 'audit.jsonl';
37
42
  /** Per-file write queue to preserve linear hash-chain order within a process. */
@@ -73,83 +78,43 @@ async function resolveBaseDir(baseDir) {
73
78
  return absolute;
74
79
  }
75
80
  }
76
- function computeHash(record) {
77
- return crypto.createHash('sha256').update(JSON.stringify(record)).digest('hex');
78
- }
79
- async function readLastHash(auditFile) {
80
- let data;
81
- try {
82
- data = await fs.readFile(auditFile, 'utf8');
83
- }
84
- catch (err) {
85
- if (err.code === 'ENOENT')
86
- return GENESIS_HASH;
87
- throw err;
88
- }
89
- // Walk the file backwards by newline — the last non-empty line is the tail.
90
- const trimmed = data.replace(/\n+$/, '');
91
- if (trimmed.length === 0)
92
- return GENESIS_HASH;
93
- const lastNewline = trimmed.lastIndexOf('\n');
94
- const lastLine = lastNewline === -1 ? trimmed : trimmed.slice(lastNewline + 1);
95
- try {
96
- const parsed = JSON.parse(lastLine);
97
- if (typeof parsed.hash === 'string' && parsed.hash.length === 64) {
98
- return parsed.hash;
99
- }
100
- }
101
- catch {
102
- // Corrupt tail line — fall through to genesis. The operator will see this
103
- // because the chain verify tool (future) will flag the break point. We do
104
- // not throw: refusing to append would mask every subsequent event.
105
- }
106
- return GENESIS_HASH;
107
- }
108
- async function fsyncFile(filePath) {
109
- let fh;
110
- try {
111
- fh = await fs.open(filePath, 'r');
112
- await fh.sync();
113
- }
114
- catch {
115
- // fsync failure is not fatal — durability is best-effort here; the write
116
- // itself already succeeded.
117
- }
118
- finally {
119
- if (fh)
120
- await fh.close();
121
- }
122
- }
123
81
  async function doAppend(resolvedBase, input) {
124
82
  const reaDir = path.join(resolvedBase, REA_DIR);
125
83
  const auditFile = path.join(reaDir, AUDIT_FILE);
126
84
  await fs.mkdir(reaDir, { recursive: true });
127
- const prevHash = await readLastHash(auditFile);
128
- const now = input.timestamp ?? new Date().toISOString();
129
- const recordBase = {
130
- timestamp: now,
131
- session_id: input.session_id ?? 'external',
132
- tool_name: input.tool_name,
133
- server_name: input.server_name,
134
- tier: input.tier ?? Tier.Read,
135
- status: input.status ?? InvocationStatus.Allowed,
136
- autonomy_level: input.autonomy_level ?? 'unknown',
137
- duration_ms: input.duration_ms ?? 0,
138
- prev_hash: prevHash,
139
- };
140
- if (input.error)
141
- recordBase.error = input.error;
142
- if (input.redacted_fields?.length)
143
- recordBase.redacted_fields = input.redacted_fields;
144
- if (input.metadata && Object.keys(input.metadata).length > 0) {
145
- recordBase.metadata = input.metadata;
146
- }
147
- const hash = computeHash(recordBase);
148
- const record = { ...recordBase, hash };
149
- const line = JSON.stringify(record) + '\n';
150
- await fs.appendFile(auditFile, line);
151
- await fsyncFile(auditFile);
152
- return record;
85
+ // Rotate BEFORE acquiring our append lock. maybeRotate takes its own lock
86
+ // internally and is idempotent; callers that race simply observe a fresh
87
+ // file with the rotation marker as their chain anchor.
88
+ await maybeRotate(auditFile, input.policy);
89
+ return withAuditLock(auditFile, async () => {
90
+ const { hash: prevHash } = await readLastRecord(auditFile);
91
+ const effectivePrev = prevHash || GENESIS_HASH;
92
+ const now = input.timestamp ?? new Date().toISOString();
93
+ const recordBase = {
94
+ timestamp: now,
95
+ session_id: input.session_id ?? 'external',
96
+ tool_name: input.tool_name,
97
+ server_name: input.server_name,
98
+ tier: input.tier ?? Tier.Read,
99
+ status: input.status ?? InvocationStatus.Allowed,
100
+ autonomy_level: input.autonomy_level ?? 'unknown',
101
+ duration_ms: input.duration_ms ?? 0,
102
+ prev_hash: effectivePrev,
103
+ };
104
+ if (input.error)
105
+ recordBase.error = input.error;
106
+ if (input.redacted_fields?.length)
107
+ recordBase.redacted_fields = input.redacted_fields;
108
+ if (input.metadata && Object.keys(input.metadata).length > 0) {
109
+ recordBase.metadata = input.metadata;
110
+ }
111
+ const hash = computeHash(recordBase);
112
+ const record = { ...recordBase, hash };
113
+ const line = JSON.stringify(record) + '\n';
114
+ await fs.appendFile(auditFile, line);
115
+ await fsyncFile(auditFile);
116
+ return record;
117
+ });
153
118
  }
154
119
  /**
155
120
  * Append a structured audit record to `${baseDir}/.rea/audit.jsonl` with a