@bookedsolid/rea 0.2.1 → 0.3.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/THREAT_MODEL.md +100 -29
- package/dist/audit/append.d.ts +21 -8
- package/dist/audit/append.js +48 -83
- package/dist/audit/fs.d.ts +68 -0
- package/dist/audit/fs.js +171 -0
- package/dist/cli/audit.d.ts +40 -0
- package/dist/cli/audit.js +205 -0
- package/dist/cli/index.js +17 -0
- package/dist/gateway/audit/rotator.d.ts +116 -0
- package/dist/gateway/audit/rotator.js +289 -0
- package/dist/gateway/middleware/audit.d.ts +14 -0
- package/dist/gateway/middleware/audit.js +77 -57
- package/dist/policy/loader.d.ts +34 -0
- package/dist/policy/loader.js +19 -0
- package/dist/policy/types.d.ts +24 -0
- package/package.json +3 -1
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.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
|
|
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
|
|
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
|
|
169
|
-
-
|
|
170
|
-
-
|
|
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:**
|
|
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`.
|
|
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 |
|
|
227
|
-
| Semantic injection via Codex adversarial-review responses | High | No issue filed
|
|
228
|
-
| Double-URL-encoding bypass for blocked paths | Medium | Planned fix
|
|
229
|
-
| No real-time alert on audit hash chain break | Medium |
|
|
230
|
-
|
|
|
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
|
-
|
|
|
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):
|
|
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,
|
|
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).
|
package/dist/audit/append.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
package/dist/audit/append.js
CHANGED
|
@@ -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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared audit filesystem primitives (G1). Both the gateway audit middleware
|
|
3
|
+
* (`src/gateway/middleware/audit.ts`) and the public append helper
|
|
4
|
+
* (`src/audit/append.ts`) funnel through this module so locking, partial-write
|
|
5
|
+
* recovery, and rotation semantics stay in lockstep.
|
|
6
|
+
*
|
|
7
|
+
* ## Locking
|
|
8
|
+
*
|
|
9
|
+
* Every append acquires a `proper-lockfile` lock on the audit file's parent
|
|
10
|
+
* directory (`.rea/`) — NOT on `audit.jsonl` directly, because `proper-lockfile`
|
|
11
|
+
* refuses to lock a file that does not yet exist. The lock is taken BEFORE the
|
|
12
|
+
* read-last-record → compute-hash → append → fsync sequence, so two processes
|
|
13
|
+
* on the same filesystem can append concurrently without interleaving.
|
|
14
|
+
*
|
|
15
|
+
* Stale-lock detection: `proper-lockfile` handles `EEXIST` with `stale: 10000`
|
|
16
|
+
* (10s). A crashed writer that leaves a stale lockfile frees itself on the
|
|
17
|
+
* next append attempt.
|
|
18
|
+
*
|
|
19
|
+
* ## Partial-write recovery
|
|
20
|
+
*
|
|
21
|
+
* An append that crashes mid-write leaves a trailing line WITHOUT a newline.
|
|
22
|
+
* `readLastRecord()` detects this signal (file doesn't end with `\n`) and
|
|
23
|
+
* truncates the partial line before returning the previous record's hash.
|
|
24
|
+
* This recovery is idempotent and runs on every read.
|
|
25
|
+
*
|
|
26
|
+
* ## Locking contract
|
|
27
|
+
*
|
|
28
|
+
* Callers invoke `withAuditLock(auditFile, async () => { ... })`. The callback
|
|
29
|
+
* MUST perform its read → compute → append → fsync inside the lock scope. On
|
|
30
|
+
* lock acquisition failure the callback does NOT run — the caller receives
|
|
31
|
+
* the error and decides whether to fall back (middleware logs and continues;
|
|
32
|
+
* the public helper propagates).
|
|
33
|
+
*/
|
|
34
|
+
import type { AuditRecord } from '../gateway/middleware/audit-types.js';
|
|
35
|
+
export declare const GENESIS_HASH: string;
|
|
36
|
+
/**
|
|
37
|
+
* Acquire an exclusive lock on the audit file's parent directory and run
|
|
38
|
+
* `fn` inside it. The parent directory must exist before calling this.
|
|
39
|
+
*
|
|
40
|
+
* The lock is released even if `fn` throws. Lock-acquisition failures
|
|
41
|
+
* surface as the caller's rejection — middleware catches and logs, the
|
|
42
|
+
* public helper propagates.
|
|
43
|
+
*/
|
|
44
|
+
export declare function withAuditLock<T>(auditFile: string, fn: () => Promise<T>): Promise<T>;
|
|
45
|
+
export declare function computeHash(record: Omit<AuditRecord, 'hash'>): string;
|
|
46
|
+
/**
|
|
47
|
+
* Read the last complete JSON record from the audit file. Returns the parsed
|
|
48
|
+
* record plus its hash (the value a new append should use for `prev_hash`).
|
|
49
|
+
*
|
|
50
|
+
* Recovers from three tail states:
|
|
51
|
+
* - File does not exist → genesis.
|
|
52
|
+
* - File exists but is empty (or only whitespace) → genesis.
|
|
53
|
+
* - File tail does not end in `\n` → treat the trailing partial line as a
|
|
54
|
+
* crash signal, truncate it, and return the record before it.
|
|
55
|
+
*
|
|
56
|
+
* Never throws on read-side issues except raw I/O errors (permission, ENOSPC,
|
|
57
|
+
* etc.) that the caller should surface.
|
|
58
|
+
*/
|
|
59
|
+
export declare function readLastRecord(auditFile: string): Promise<{
|
|
60
|
+
record: AuditRecord | null;
|
|
61
|
+
hash: string;
|
|
62
|
+
}>;
|
|
63
|
+
/**
|
|
64
|
+
* Open-and-fsync the audit file. Called after an append to flush the write
|
|
65
|
+
* to durable storage. fsync failure is not fatal — the append itself
|
|
66
|
+
* already succeeded; durability is best-effort.
|
|
67
|
+
*/
|
|
68
|
+
export declare function fsyncFile(filePath: string): Promise<void>;
|