@bookedsolid/rea 0.6.0 → 0.6.2
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/dist/gateway/downstream.d.ts +12 -1
- package/dist/gateway/downstream.js +16 -2
- package/dist/gateway/meta/health.d.ts +77 -0
- package/dist/gateway/meta/health.js +160 -0
- package/dist/gateway/server.js +49 -8
- package/dist/policy/loader.d.ts +27 -0
- package/dist/policy/loader.js +15 -0
- package/dist/policy/types.d.ts +28 -0
- package/hooks/commit-review-gate.sh +56 -1
- package/hooks/push-review-gate.sh +104 -1
- package/package.json +1 -1
- package/scripts/tarball-smoke.sh +115 -0
|
@@ -111,7 +111,18 @@ export declare class DownstreamConnection {
|
|
|
111
111
|
get isHealthy(): boolean;
|
|
112
112
|
/** True iff the underlying MCP client is currently connected. */
|
|
113
113
|
get isConnected(): boolean;
|
|
114
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* Last error observed, or null if the connection has never failed (or fully
|
|
116
|
+
* recovered).
|
|
117
|
+
*
|
|
118
|
+
* BUG-011 (0.6.2): cap exposure via `boundedDiagnosticString`. An
|
|
119
|
+
* adversarial downstream MCP can throw `new Error(huge_string)`, and that
|
|
120
|
+
* raw message flows from `err.message` into `lastErrorMessage` at the
|
|
121
|
+
* assignment sites below. Bounding here means every consumer of the
|
|
122
|
+
* getter — the `__rea__health` snapshot, diagnostic logs, future status
|
|
123
|
+
* dashboards — sees a bounded, UTF-16-safe string. `sanitizeHealthSnapshot`
|
|
124
|
+
* applies the same cap for defense-in-depth.
|
|
125
|
+
*/
|
|
115
126
|
get lastError(): string | null;
|
|
116
127
|
connect(): Promise<void>;
|
|
117
128
|
listTools(): Promise<DownstreamToolInfo[]>;
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
39
39
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
40
40
|
import { interpolateEnv } from '../registry/interpolate.js';
|
|
41
|
+
import { boundedDiagnosticString } from './meta/health.js';
|
|
41
42
|
/**
|
|
42
43
|
* Neutral env vars every child inherits. These are the ones shells/toolchains
|
|
43
44
|
* need to function but carry no secrets in a well-configured environment.
|
|
@@ -134,9 +135,22 @@ export class DownstreamConnection {
|
|
|
134
135
|
get isConnected() {
|
|
135
136
|
return this.client !== null;
|
|
136
137
|
}
|
|
137
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* Last error observed, or null if the connection has never failed (or fully
|
|
140
|
+
* recovered).
|
|
141
|
+
*
|
|
142
|
+
* BUG-011 (0.6.2): cap exposure via `boundedDiagnosticString`. An
|
|
143
|
+
* adversarial downstream MCP can throw `new Error(huge_string)`, and that
|
|
144
|
+
* raw message flows from `err.message` into `lastErrorMessage` at the
|
|
145
|
+
* assignment sites below. Bounding here means every consumer of the
|
|
146
|
+
* getter — the `__rea__health` snapshot, diagnostic logs, future status
|
|
147
|
+
* dashboards — sees a bounded, UTF-16-safe string. `sanitizeHealthSnapshot`
|
|
148
|
+
* applies the same cap for defense-in-depth.
|
|
149
|
+
*/
|
|
138
150
|
get lastError() {
|
|
139
|
-
|
|
151
|
+
if (this.lastErrorMessage === null)
|
|
152
|
+
return null;
|
|
153
|
+
return boundedDiagnosticString(this.lastErrorMessage);
|
|
140
154
|
}
|
|
141
155
|
async connect() {
|
|
142
156
|
if (this.client !== null)
|
|
@@ -77,6 +77,14 @@ export interface MetaHealthSnapshot {
|
|
|
77
77
|
connected: number;
|
|
78
78
|
healthy: number;
|
|
79
79
|
total_tools: number;
|
|
80
|
+
/**
|
|
81
|
+
* BUG-011 (0.6.2) — process-lifetime count of `meta.health` audit-append
|
|
82
|
+
* failures. An operator who sees this incrementing is looking at a silent
|
|
83
|
+
* observability gap: the short-circuit response is still being served,
|
|
84
|
+
* but the audit log is losing entries. Surfaced here so the condition is
|
|
85
|
+
* detectable without parsing stderr.
|
|
86
|
+
*/
|
|
87
|
+
audit_fail_count: number;
|
|
80
88
|
};
|
|
81
89
|
}
|
|
82
90
|
export interface BuildHealthSnapshotDeps {
|
|
@@ -98,6 +106,12 @@ export interface BuildHealthSnapshotDeps {
|
|
|
98
106
|
haltReason: string | null;
|
|
99
107
|
/** Current epoch ms. Injected for determinism in tests. */
|
|
100
108
|
nowMs?: number;
|
|
109
|
+
/**
|
|
110
|
+
* BUG-011 (0.6.2) — process-lifetime audit-append failure counter.
|
|
111
|
+
* Injected from `server.ts` so the snapshot reports a live value.
|
|
112
|
+
* Absent → surfaces as 0 in the snapshot.
|
|
113
|
+
*/
|
|
114
|
+
auditFailCount?: number;
|
|
101
115
|
}
|
|
102
116
|
/**
|
|
103
117
|
* Pure function that builds the snapshot from injected state. All I/O happens
|
|
@@ -105,6 +119,69 @@ export interface BuildHealthSnapshotDeps {
|
|
|
105
119
|
* throws" a local invariant rather than a chain-wide claim.
|
|
106
120
|
*/
|
|
107
121
|
export declare function buildHealthSnapshot(deps: BuildHealthSnapshotDeps): MetaHealthSnapshot;
|
|
122
|
+
/**
|
|
123
|
+
* BUG-011 (0.6.2) — placeholder the sanitizer writes into any string whose
|
|
124
|
+
* injection classification comes back non-clean under `expose_diagnostics`.
|
|
125
|
+
* Exported so tests can assert the exact token.
|
|
126
|
+
*/
|
|
127
|
+
export declare const INJECTION_REDACTED_PLACEHOLDER = "<redacted: suspected injection>";
|
|
128
|
+
/**
|
|
129
|
+
* BUG-011 (0.6.2) — max code-units of diagnostic text surfaced through the
|
|
130
|
+
* meta-tool wire under `expose_diagnostics: true`. Upstream MCP error
|
|
131
|
+
* messages and HALT-file contents are ADVERSARY-CONTROLLABLE (a downstream
|
|
132
|
+
* can throw `new Error(huge_string)`); without a cap, an attacker can force
|
|
133
|
+
* `__rea__health` responses into the hundreds of MB, DoS-ing the one tool
|
|
134
|
+
* designed to remain callable when everything else is broken. 4096 UTF-16
|
|
135
|
+
* code units is plenty to diagnose a real failure and cheap to keep on the
|
|
136
|
+
* wire — even in the worst-case all-surrogate-pair scenario the UTF-8 byte
|
|
137
|
+
* length stays under ~16 KiB. Named `_CHARS` because JavaScript string
|
|
138
|
+
* `.length` and `.slice` are code-unit operations, not byte operations;
|
|
139
|
+
* Codex review C-11.1 flagged the previous `_BYTES` naming as misleading.
|
|
140
|
+
* Truncation happens BEFORE redact/inject scanning so those routines
|
|
141
|
+
* always see bounded input.
|
|
142
|
+
*/
|
|
143
|
+
export declare const DIAGNOSTIC_STRING_MAX_CHARS = 4096;
|
|
144
|
+
/**
|
|
145
|
+
* Bound a diagnostic string at `DIAGNOSTIC_STRING_MAX_CHARS` without
|
|
146
|
+
* emitting a lone high-surrogate. Exported so every site that ingests an
|
|
147
|
+
* adversary-controllable diagnostic string (`downstream.ts#lastError`,
|
|
148
|
+
* `server.ts` HALT-file read, the sanitizer itself) shares one definition
|
|
149
|
+
* of "bounded diagnostic string". Codex review N-1 (2026-04-20).
|
|
150
|
+
*
|
|
151
|
+
* Callers that want the `… [truncated]` sentinel appended should use
|
|
152
|
+
* `truncateForDiagnostics`; callers that just need a hard upper bound
|
|
153
|
+
* (audit-tap sites where a sentinel would be noise) use this directly.
|
|
154
|
+
*/
|
|
155
|
+
export declare function boundedDiagnosticString(s: string): string;
|
|
156
|
+
/**
|
|
157
|
+
* BUG-011 (0.6.2) — sanitize a snapshot before it crosses the MCP wire.
|
|
158
|
+
*
|
|
159
|
+
* The `__rea__health` short-circuit in `server.ts` responds BEFORE the
|
|
160
|
+
* middleware chain so the tool stays callable under HALT. That bypasses the
|
|
161
|
+
* normal `redact` and `injection` middleware by design — but `last_error`
|
|
162
|
+
* and `halt_reason` are populated verbatim from upstream error messages
|
|
163
|
+
* (`err.message` / `String(err)`) and from the HALT file contents. Both can
|
|
164
|
+
* contain secrets (a downstream MCP that echoes an API key in its error
|
|
165
|
+
* path) or prompt-injection payloads (any adversarial downstream).
|
|
166
|
+
*
|
|
167
|
+
* Sanitization strategy, gated by `policy.gateway.health.expose_diagnostics`:
|
|
168
|
+
*
|
|
169
|
+
* - `undefined` or `false` (default): STRIP. `halt_reason` → `null`;
|
|
170
|
+
* every `downstreams[].last_error` → `null`. Consumers who want the raw
|
|
171
|
+
* text read the audit log (`event: meta.health`) or `rea doctor`.
|
|
172
|
+
*
|
|
173
|
+
* - `true` (explicit opt-in): REDACT. Apply `redactSecrets` (default
|
|
174
|
+
* secret-pattern list, 100ms match budget per pattern) to the string;
|
|
175
|
+
* then run `classifyInjection` at `Tier.Read` (the short-circuit tier
|
|
176
|
+
* for meta-tool reads). If the classification is anything other than
|
|
177
|
+
* `clean`, replace the entire string with
|
|
178
|
+
* `INJECTION_REDACTED_PLACEHOLDER` — the post-redact output cannot be
|
|
179
|
+
* trusted as human-readable text when injection markers are present.
|
|
180
|
+
*
|
|
181
|
+
* Pure — no I/O, no logging, no mutation of the input snapshot. The caller
|
|
182
|
+
* passes the pre-built snapshot; this returns a fresh object.
|
|
183
|
+
*/
|
|
184
|
+
export declare function sanitizeHealthSnapshot(snapshot: MetaHealthSnapshot, policy: Policy): MetaHealthSnapshot;
|
|
108
185
|
/**
|
|
109
186
|
* The descriptor the gateway advertises via `tools/list`. No arguments —
|
|
110
187
|
* callers request a snapshot by calling with `{}`. Keeping the surface
|
|
@@ -43,6 +43,9 @@
|
|
|
43
43
|
* broken. Every field is best-effort; a missing value is surfaced as
|
|
44
44
|
* `null`, not as an exception.
|
|
45
45
|
*/
|
|
46
|
+
import { Tier } from '../../policy/types.js';
|
|
47
|
+
import { compileDefaultSecretPatterns, redactSecrets, REDACT_TIMEOUT_SENTINEL, } from '../middleware/redact.js';
|
|
48
|
+
import { classifyInjection, compileInjectionPatterns, scanStringForInjection, } from '../middleware/injection.js';
|
|
46
49
|
/** Canonical MCP tool name exposed by the gateway. */
|
|
47
50
|
export const META_HEALTH_TOOL_NAME = '__rea__health';
|
|
48
51
|
/** `server_name` recorded in audit entries for this meta-tool. */
|
|
@@ -88,9 +91,166 @@ export function buildHealthSnapshot(deps) {
|
|
|
88
91
|
connected,
|
|
89
92
|
healthy,
|
|
90
93
|
total_tools,
|
|
94
|
+
audit_fail_count: deps.auditFailCount ?? 0,
|
|
91
95
|
},
|
|
92
96
|
};
|
|
93
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* BUG-011 (0.6.2) — placeholder the sanitizer writes into any string whose
|
|
100
|
+
* injection classification comes back non-clean under `expose_diagnostics`.
|
|
101
|
+
* Exported so tests can assert the exact token.
|
|
102
|
+
*/
|
|
103
|
+
export const INJECTION_REDACTED_PLACEHOLDER = '<redacted: suspected injection>';
|
|
104
|
+
/**
|
|
105
|
+
* BUG-011 (0.6.2) — max code-units of diagnostic text surfaced through the
|
|
106
|
+
* meta-tool wire under `expose_diagnostics: true`. Upstream MCP error
|
|
107
|
+
* messages and HALT-file contents are ADVERSARY-CONTROLLABLE (a downstream
|
|
108
|
+
* can throw `new Error(huge_string)`); without a cap, an attacker can force
|
|
109
|
+
* `__rea__health` responses into the hundreds of MB, DoS-ing the one tool
|
|
110
|
+
* designed to remain callable when everything else is broken. 4096 UTF-16
|
|
111
|
+
* code units is plenty to diagnose a real failure and cheap to keep on the
|
|
112
|
+
* wire — even in the worst-case all-surrogate-pair scenario the UTF-8 byte
|
|
113
|
+
* length stays under ~16 KiB. Named `_CHARS` because JavaScript string
|
|
114
|
+
* `.length` and `.slice` are code-unit operations, not byte operations;
|
|
115
|
+
* Codex review C-11.1 flagged the previous `_BYTES` naming as misleading.
|
|
116
|
+
* Truncation happens BEFORE redact/inject scanning so those routines
|
|
117
|
+
* always see bounded input.
|
|
118
|
+
*/
|
|
119
|
+
export const DIAGNOSTIC_STRING_MAX_CHARS = 4096;
|
|
120
|
+
const TRUNCATION_SUFFIX = '… [truncated]';
|
|
121
|
+
/**
|
|
122
|
+
* Drop a trailing lone high-surrogate so the result is valid UTF-16 that
|
|
123
|
+
* round-trips cleanly through UTF-8 encoders. `String.prototype.slice` cuts
|
|
124
|
+
* at an arbitrary code-unit index — when that index falls between a
|
|
125
|
+
* surrogate pair, the naive result ends with U+D800–U+DBFF on its own and
|
|
126
|
+
* `Buffer.from(s, 'utf8')` silently replaces it with U+FFFD, corrupting
|
|
127
|
+
* the diagnostic. Codex review C-11.2 / N-1.
|
|
128
|
+
*/
|
|
129
|
+
function dropTrailingHighSurrogate(s) {
|
|
130
|
+
if (s.length === 0)
|
|
131
|
+
return s;
|
|
132
|
+
const last = s.charCodeAt(s.length - 1);
|
|
133
|
+
return last >= 0xd800 && last <= 0xdbff ? s.slice(0, -1) : s;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Bound a diagnostic string at `DIAGNOSTIC_STRING_MAX_CHARS` without
|
|
137
|
+
* emitting a lone high-surrogate. Exported so every site that ingests an
|
|
138
|
+
* adversary-controllable diagnostic string (`downstream.ts#lastError`,
|
|
139
|
+
* `server.ts` HALT-file read, the sanitizer itself) shares one definition
|
|
140
|
+
* of "bounded diagnostic string". Codex review N-1 (2026-04-20).
|
|
141
|
+
*
|
|
142
|
+
* Callers that want the `… [truncated]` sentinel appended should use
|
|
143
|
+
* `truncateForDiagnostics`; callers that just need a hard upper bound
|
|
144
|
+
* (audit-tap sites where a sentinel would be noise) use this directly.
|
|
145
|
+
*/
|
|
146
|
+
export function boundedDiagnosticString(s) {
|
|
147
|
+
if (s.length <= DIAGNOSTIC_STRING_MAX_CHARS)
|
|
148
|
+
return s;
|
|
149
|
+
return dropTrailingHighSurrogate(s.slice(0, DIAGNOSTIC_STRING_MAX_CHARS));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Truncate `raw` to at most `DIAGNOSTIC_STRING_MAX_CHARS` code units
|
|
153
|
+
* (including the suffix). After slicing at an arbitrary code-unit index
|
|
154
|
+
* we may be left with a lone high-surrogate (U+D800–U+DBFF) — drop it
|
|
155
|
+
* so downstream UTF-8 encoders don't silently replace it with U+FFFD.
|
|
156
|
+
*/
|
|
157
|
+
function truncateForDiagnostics(raw) {
|
|
158
|
+
if (raw.length <= DIAGNOSTIC_STRING_MAX_CHARS)
|
|
159
|
+
return raw;
|
|
160
|
+
const sliced = dropTrailingHighSurrogate(raw.slice(0, DIAGNOSTIC_STRING_MAX_CHARS - TRUNCATION_SUFFIX.length));
|
|
161
|
+
return sliced + TRUNCATION_SUFFIX;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* BUG-011 (0.6.2) — sanitize a snapshot before it crosses the MCP wire.
|
|
165
|
+
*
|
|
166
|
+
* The `__rea__health` short-circuit in `server.ts` responds BEFORE the
|
|
167
|
+
* middleware chain so the tool stays callable under HALT. That bypasses the
|
|
168
|
+
* normal `redact` and `injection` middleware by design — but `last_error`
|
|
169
|
+
* and `halt_reason` are populated verbatim from upstream error messages
|
|
170
|
+
* (`err.message` / `String(err)`) and from the HALT file contents. Both can
|
|
171
|
+
* contain secrets (a downstream MCP that echoes an API key in its error
|
|
172
|
+
* path) or prompt-injection payloads (any adversarial downstream).
|
|
173
|
+
*
|
|
174
|
+
* Sanitization strategy, gated by `policy.gateway.health.expose_diagnostics`:
|
|
175
|
+
*
|
|
176
|
+
* - `undefined` or `false` (default): STRIP. `halt_reason` → `null`;
|
|
177
|
+
* every `downstreams[].last_error` → `null`. Consumers who want the raw
|
|
178
|
+
* text read the audit log (`event: meta.health`) or `rea doctor`.
|
|
179
|
+
*
|
|
180
|
+
* - `true` (explicit opt-in): REDACT. Apply `redactSecrets` (default
|
|
181
|
+
* secret-pattern list, 100ms match budget per pattern) to the string;
|
|
182
|
+
* then run `classifyInjection` at `Tier.Read` (the short-circuit tier
|
|
183
|
+
* for meta-tool reads). If the classification is anything other than
|
|
184
|
+
* `clean`, replace the entire string with
|
|
185
|
+
* `INJECTION_REDACTED_PLACEHOLDER` — the post-redact output cannot be
|
|
186
|
+
* trusted as human-readable text when injection markers are present.
|
|
187
|
+
*
|
|
188
|
+
* Pure — no I/O, no logging, no mutation of the input snapshot. The caller
|
|
189
|
+
* passes the pre-built snapshot; this returns a fresh object.
|
|
190
|
+
*/
|
|
191
|
+
export function sanitizeHealthSnapshot(snapshot, policy) {
|
|
192
|
+
const expose = policy.gateway?.health?.expose_diagnostics === true;
|
|
193
|
+
if (!expose) {
|
|
194
|
+
return {
|
|
195
|
+
...snapshot,
|
|
196
|
+
gateway: { ...snapshot.gateway, halt_reason: null },
|
|
197
|
+
downstreams: snapshot.downstreams.map((d) => ({ ...d, last_error: null })),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
// expose_diagnostics === true: redact + injection-scan every diagnostic
|
|
201
|
+
// string. Compile patterns per-call — this path fires only when the LLM
|
|
202
|
+
// (or an operator) invokes `__rea__health`, which is rare enough that the
|
|
203
|
+
// allocation cost is irrelevant and the bounded freshness is a net win.
|
|
204
|
+
const secretPatterns = compileDefaultSecretPatterns({
|
|
205
|
+
timeoutMs: 100,
|
|
206
|
+
});
|
|
207
|
+
const injectionPatterns = compileInjectionPatterns(100);
|
|
208
|
+
const clean = (raw) => {
|
|
209
|
+
if (raw === null)
|
|
210
|
+
return null;
|
|
211
|
+
// Truncate BEFORE scanning: an adversarial downstream can produce
|
|
212
|
+
// arbitrarily long error strings, and the sanitizer must not spend
|
|
213
|
+
// O(n) per-pattern time on attacker-chosen n.
|
|
214
|
+
const bounded = truncateForDiagnostics(raw);
|
|
215
|
+
// Codex review C-11.3: `redactSecrets` returns `timedOut: true` and
|
|
216
|
+
// replaces the full input with REDACT_TIMEOUT_SENTINEL when a pattern's
|
|
217
|
+
// match budget is exceeded. Treat that exactly like a non-clean
|
|
218
|
+
// injection verdict — the output cannot be trusted as human-readable
|
|
219
|
+
// text and must not distinguish timeout-hit from pattern-hit on the
|
|
220
|
+
// wire.
|
|
221
|
+
//
|
|
222
|
+
// N-2 defense-in-depth: also collapse when the post-redact output
|
|
223
|
+
// HAPPENS to equal the sentinel (e.g., a downstream echoes the string
|
|
224
|
+
// in its error text). The sentinel is a gateway-internal token; its
|
|
225
|
+
// presence on the meta-tool wire is always a failure signal, not a
|
|
226
|
+
// diagnostic. Collapsing to the injection placeholder keeps the
|
|
227
|
+
// on-wire output indistinguishable from a real timeout.
|
|
228
|
+
const { output, timedOut } = redactSecrets(bounded, secretPatterns);
|
|
229
|
+
if (timedOut || output === REDACT_TIMEOUT_SENTINEL) {
|
|
230
|
+
return INJECTION_REDACTED_PLACEHOLDER;
|
|
231
|
+
}
|
|
232
|
+
const scan = {
|
|
233
|
+
literalMatches: new Set(),
|
|
234
|
+
base64DecodedMatches: new Set(),
|
|
235
|
+
};
|
|
236
|
+
scanStringForInjection(output, scan, injectionPatterns);
|
|
237
|
+
// Tier.Read: any literal match AT ALL classifies to `likely_injection`
|
|
238
|
+
// under the decision table (rule 4). That's the right bar here — a
|
|
239
|
+
// meta-tool response is a read-tier surface by construction.
|
|
240
|
+
const verdict = classifyInjection(scan, Tier.Read);
|
|
241
|
+
if (verdict.verdict !== 'clean')
|
|
242
|
+
return INJECTION_REDACTED_PLACEHOLDER;
|
|
243
|
+
return output;
|
|
244
|
+
};
|
|
245
|
+
return {
|
|
246
|
+
...snapshot,
|
|
247
|
+
gateway: { ...snapshot.gateway, halt_reason: clean(snapshot.gateway.halt_reason) },
|
|
248
|
+
downstreams: snapshot.downstreams.map((d) => ({
|
|
249
|
+
...d,
|
|
250
|
+
last_error: clean(d.last_error),
|
|
251
|
+
})),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
94
254
|
/**
|
|
95
255
|
* The descriptor the gateway advertises via `tools/list`. No arguments —
|
|
96
256
|
* callers request a snapshot by calling with `{}`. Keeping the surface
|
package/dist/gateway/server.js
CHANGED
|
@@ -35,7 +35,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
|
|
|
35
35
|
import fs from 'node:fs/promises';
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
import { DownstreamPool, splitPrefixed } from './downstream-pool.js';
|
|
38
|
-
import { META_HEALTH_TOOL_NAME, META_SERVER_NAME, META_TOOL_NAME, buildHealthSnapshot, metaHealthToolDescriptor, } from './meta/health.js';
|
|
38
|
+
import { boundedDiagnosticString, META_HEALTH_TOOL_NAME, META_SERVER_NAME, META_TOOL_NAME, buildHealthSnapshot, metaHealthToolDescriptor, sanitizeHealthSnapshot, } from './meta/health.js';
|
|
39
39
|
import { appendAuditRecord } from '../audit/append.js';
|
|
40
40
|
import { getPkgVersion } from '../cli/utils.js';
|
|
41
41
|
import { createAuditMiddleware } from './middleware/audit.js';
|
|
@@ -127,6 +127,11 @@ export function createGateway(opts) {
|
|
|
127
127
|
const pool = new DownstreamPool(registry, logger);
|
|
128
128
|
const gatewayVersion = getPkgVersion();
|
|
129
129
|
const startedAtMs = Date.now();
|
|
130
|
+
// BUG-011 (0.6.2) — process-lifetime counter of failed audit appends from
|
|
131
|
+
// the `__rea__health` short-circuit. Exposed on the health snapshot as
|
|
132
|
+
// `summary.audit_fail_count` so operators can detect the silent-audit-gap
|
|
133
|
+
// condition without parsing stderr.
|
|
134
|
+
let healthAuditFailCount = 0;
|
|
130
135
|
const server = new Server({ name: 'rea', version: gatewayVersion }, { capabilities: { tools: {} } });
|
|
131
136
|
// Build the circuit breaker with observability hooks wired in — state
|
|
132
137
|
// transitions log a structured record AND update the Prometheus gauge.
|
|
@@ -161,7 +166,13 @@ export function createGateway(opts) {
|
|
|
161
166
|
try {
|
|
162
167
|
const contents = await fs.readFile(path.join(baseDir, '.rea', 'HALT'), 'utf8');
|
|
163
168
|
const trimmed = contents.trim();
|
|
164
|
-
|
|
169
|
+
// Hard-cap the raw read at the diagnostic string budget before it
|
|
170
|
+
// enters the snapshot. An oversize HALT file (operator accident or
|
|
171
|
+
// local attacker) must not cause an O(size) allocation on every
|
|
172
|
+
// `__rea__health` call. `sanitizeHealthSnapshot` also truncates,
|
|
173
|
+
// but capping at ingestion keeps the snapshot itself bounded.
|
|
174
|
+
const bounded = boundedDiagnosticString(trimmed);
|
|
175
|
+
return { halt: true, reason: bounded.length > 0 ? bounded : null };
|
|
165
176
|
}
|
|
166
177
|
catch {
|
|
167
178
|
return { halt: false, reason: null };
|
|
@@ -220,14 +231,23 @@ export function createGateway(opts) {
|
|
|
220
231
|
if (prefixed === META_HEALTH_TOOL_NAME) {
|
|
221
232
|
const startMs = Date.now();
|
|
222
233
|
const haltState = await readHalt();
|
|
223
|
-
|
|
234
|
+
// Internal snapshot carries the raw diagnostic strings — used by the
|
|
235
|
+
// audit record below so operators have the full text in the log even
|
|
236
|
+
// when the MCP response has them stripped/redacted.
|
|
237
|
+
const internalSnapshot = buildHealthSnapshot({
|
|
224
238
|
gatewayVersion,
|
|
225
239
|
startedAtMs,
|
|
226
240
|
policy,
|
|
227
241
|
downstreams: pool.healthSnapshot(),
|
|
228
242
|
halt: haltState.halt,
|
|
229
243
|
haltReason: haltState.reason,
|
|
244
|
+
auditFailCount: healthAuditFailCount,
|
|
230
245
|
});
|
|
246
|
+
// BUG-011 (0.6.2) — sanitize BEFORE serializing to the wire. Strips
|
|
247
|
+
// `halt_reason` + per-downstream `last_error` by default; when
|
|
248
|
+
// `gateway.health.expose_diagnostics: true` applies redactSecrets +
|
|
249
|
+
// injection-scan and replaces any non-clean string with the sentinel.
|
|
250
|
+
const wireSnapshot = sanitizeHealthSnapshot(internalSnapshot, policy);
|
|
231
251
|
// Best-effort audit append. Failures here must never prevent the
|
|
232
252
|
// caller from getting the health response — that would defeat the
|
|
233
253
|
// whole point of a "works when everything else is broken" tool.
|
|
@@ -241,24 +261,45 @@ export function createGateway(opts) {
|
|
|
241
261
|
session_id: currentSessionId(),
|
|
242
262
|
duration_ms: Date.now() - startMs,
|
|
243
263
|
metadata: {
|
|
244
|
-
halt:
|
|
245
|
-
|
|
246
|
-
|
|
264
|
+
halt: internalSnapshot.gateway.halt,
|
|
265
|
+
// BUG-011 (0.6.2) — N-3: the audit log is the authoritative
|
|
266
|
+
// trusted-operator sink for full diagnostic text. Strings are
|
|
267
|
+
// already bounded at ingestion (halt-file read + downstream
|
|
268
|
+
// lastError getter) via `boundedDiagnosticString`, and the
|
|
269
|
+
// audit file is on local disk with hash-chained append-only
|
|
270
|
+
// semantics — not LLM-reachable. Log the pre-sanitize strings
|
|
271
|
+
// here so the `rea doctor` / audit-tail path preserves the
|
|
272
|
+
// text the MCP wire strips under the default policy.
|
|
273
|
+
halt_reason: internalSnapshot.gateway.halt_reason,
|
|
274
|
+
downstreams_registered: internalSnapshot.summary.registered,
|
|
275
|
+
downstreams_healthy: internalSnapshot.summary.healthy,
|
|
276
|
+
downstream_errors: internalSnapshot.downstreams
|
|
277
|
+
.filter((d) => d.last_error !== null)
|
|
278
|
+
.map((d) => ({ name: d.name, last_error: d.last_error })),
|
|
247
279
|
},
|
|
248
280
|
});
|
|
249
281
|
}
|
|
250
282
|
catch (err) {
|
|
251
|
-
|
|
283
|
+
// BUG-011 (0.6.2) — elevated from `warn` to `error`. A dropped
|
|
284
|
+
// meta.health audit entry is an observability gap: the response
|
|
285
|
+
// still goes out but the record of it is missing, which defeats
|
|
286
|
+
// the forensic value of the hash chain for that call. Also bump a
|
|
287
|
+
// process-lifetime counter surfaced on the next snapshot's
|
|
288
|
+
// `summary.audit_fail_count` so operators can detect the condition
|
|
289
|
+
// without parsing stderr.
|
|
290
|
+
healthAuditFailCount += 1;
|
|
291
|
+
logger.error({
|
|
252
292
|
event: 'meta.health.audit_failed',
|
|
253
293
|
message: 'failed to append audit record for __rea__health; serving response anyway',
|
|
254
294
|
error: err instanceof Error ? err.message : String(err),
|
|
295
|
+
audit_fail_count: healthAuditFailCount,
|
|
255
296
|
});
|
|
256
297
|
}
|
|
257
298
|
return {
|
|
258
299
|
content: [
|
|
259
300
|
{
|
|
260
301
|
type: 'text',
|
|
261
|
-
text: JSON.stringify(
|
|
302
|
+
text: JSON.stringify(wireSnapshot, null, 2),
|
|
262
303
|
},
|
|
263
304
|
],
|
|
264
305
|
};
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -95,6 +95,23 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
95
95
|
max_age_days?: number | undefined;
|
|
96
96
|
} | undefined;
|
|
97
97
|
}>>;
|
|
98
|
+
gateway: z.ZodOptional<z.ZodObject<{
|
|
99
|
+
health: z.ZodOptional<z.ZodObject<{
|
|
100
|
+
expose_diagnostics: z.ZodOptional<z.ZodBoolean>;
|
|
101
|
+
}, "strict", z.ZodTypeAny, {
|
|
102
|
+
expose_diagnostics?: boolean | undefined;
|
|
103
|
+
}, {
|
|
104
|
+
expose_diagnostics?: boolean | undefined;
|
|
105
|
+
}>>;
|
|
106
|
+
}, "strict", z.ZodTypeAny, {
|
|
107
|
+
health?: {
|
|
108
|
+
expose_diagnostics?: boolean | undefined;
|
|
109
|
+
} | undefined;
|
|
110
|
+
}, {
|
|
111
|
+
health?: {
|
|
112
|
+
expose_diagnostics?: boolean | undefined;
|
|
113
|
+
} | undefined;
|
|
114
|
+
}>>;
|
|
98
115
|
}, "strict", z.ZodTypeAny, {
|
|
99
116
|
version: string;
|
|
100
117
|
profile: string;
|
|
@@ -133,6 +150,11 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
133
150
|
max_age_days?: number | undefined;
|
|
134
151
|
} | undefined;
|
|
135
152
|
} | undefined;
|
|
153
|
+
gateway?: {
|
|
154
|
+
health?: {
|
|
155
|
+
expose_diagnostics?: boolean | undefined;
|
|
156
|
+
} | undefined;
|
|
157
|
+
} | undefined;
|
|
136
158
|
}, {
|
|
137
159
|
version: string;
|
|
138
160
|
profile: string;
|
|
@@ -171,6 +193,11 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
171
193
|
max_age_days?: number | undefined;
|
|
172
194
|
} | undefined;
|
|
173
195
|
} | undefined;
|
|
196
|
+
gateway?: {
|
|
197
|
+
health?: {
|
|
198
|
+
expose_diagnostics?: boolean | undefined;
|
|
199
|
+
} | undefined;
|
|
200
|
+
} | undefined;
|
|
174
201
|
}>;
|
|
175
202
|
/**
|
|
176
203
|
* Async policy loader with TTL cache and mtime-based invalidation.
|
package/dist/policy/loader.js
CHANGED
|
@@ -93,6 +93,20 @@ const InjectionPolicySchema = z
|
|
|
93
93
|
suspicious_blocks_writes: z.boolean().optional(),
|
|
94
94
|
})
|
|
95
95
|
.strict();
|
|
96
|
+
/**
|
|
97
|
+
* BUG-011 (0.6.2) — gateway-level policy. Currently only the `health`
|
|
98
|
+
* sub-block is defined; kept strict so typos (`gateway.heath`) fail loudly.
|
|
99
|
+
*/
|
|
100
|
+
const GatewayHealthPolicySchema = z
|
|
101
|
+
.object({
|
|
102
|
+
expose_diagnostics: z.boolean().optional(),
|
|
103
|
+
})
|
|
104
|
+
.strict();
|
|
105
|
+
const GatewayPolicySchema = z
|
|
106
|
+
.object({
|
|
107
|
+
health: GatewayHealthPolicySchema.optional(),
|
|
108
|
+
})
|
|
109
|
+
.strict();
|
|
96
110
|
const PolicySchema = z
|
|
97
111
|
.object({
|
|
98
112
|
version: z.string(),
|
|
@@ -111,6 +125,7 @@ const PolicySchema = z
|
|
|
111
125
|
review: ReviewPolicySchema.optional(),
|
|
112
126
|
redact: RedactPolicySchema.optional(),
|
|
113
127
|
audit: AuditPolicySchema.optional(),
|
|
128
|
+
gateway: GatewayPolicySchema.optional(),
|
|
114
129
|
})
|
|
115
130
|
.strict();
|
|
116
131
|
const DEFAULT_CACHE_TTL_MS = 30_000;
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -124,6 +124,33 @@ export interface AuditPolicy {
|
|
|
124
124
|
export interface InjectionPolicy {
|
|
125
125
|
suspicious_blocks_writes?: boolean;
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* BUG-011 (0.6.2) — gateway-level policy knobs.
|
|
129
|
+
*
|
|
130
|
+
* `health.expose_diagnostics` governs whether `__rea__health` emits
|
|
131
|
+
* `halt_reason` and per-downstream `last_error` strings in its MCP response
|
|
132
|
+
* (vs. dropping them to `null`). The short-circuit responds BEFORE the
|
|
133
|
+
* middleware chain — so it bypasses `redact` and `injection` middleware by
|
|
134
|
+
* design (the tool must stay callable under HALT). That means downstream
|
|
135
|
+
* error strings, which are populated verbatim from `err.message`, can carry
|
|
136
|
+
* secrets or injection payloads all the way to the caller unless we
|
|
137
|
+
* sanitize in the short-circuit path itself.
|
|
138
|
+
*
|
|
139
|
+
* Default `false` (fields emitted as `null`). The Helix team's explicit
|
|
140
|
+
* preference was "strip, don't redact" — a smaller trust ask than trusting
|
|
141
|
+
* our secret/injection pattern coverage. Operators who accept that trade-off
|
|
142
|
+
* (e.g. single-tenant dev boxes) can flip `expose_diagnostics: true`, at
|
|
143
|
+
* which point the short-circuit applies the same `redactSecrets` +
|
|
144
|
+
* `classifyInjection` pass the middleware chain would. The full untouched
|
|
145
|
+
* values always flow into the audit log regardless — diagnostics remain
|
|
146
|
+
* available via `rea doctor`, just not over the MCP wire.
|
|
147
|
+
*/
|
|
148
|
+
export interface GatewayHealthPolicy {
|
|
149
|
+
expose_diagnostics?: boolean;
|
|
150
|
+
}
|
|
151
|
+
export interface GatewayPolicy {
|
|
152
|
+
health?: GatewayHealthPolicy;
|
|
153
|
+
}
|
|
127
154
|
export interface Policy {
|
|
128
155
|
version: string;
|
|
129
156
|
profile: string;
|
|
@@ -141,4 +168,5 @@ export interface Policy {
|
|
|
141
168
|
review?: ReviewPolicy;
|
|
142
169
|
redact?: RedactPolicy;
|
|
143
170
|
audit?: AuditPolicy;
|
|
171
|
+
gateway?: GatewayPolicy;
|
|
144
172
|
}
|
|
@@ -15,6 +15,62 @@ set -uo pipefail
|
|
|
15
15
|
# ── 1. Read ALL stdin immediately ─────────────────────────────────────────────
|
|
16
16
|
INPUT=$(cat)
|
|
17
17
|
|
|
18
|
+
# ── 1a. Cross-repo guard (must come FIRST — before any rea-scoped check) ──────
|
|
19
|
+
# BUG-012 (0.6.2) — mirror of push-review-gate.sh §1a. Script-location
|
|
20
|
+
# anchor (not CLAUDE_PROJECT_DIR) owns the trust decision. See the
|
|
21
|
+
# push-gate comment and THREAT_MODEL.md § CLAUDE_PROJECT_DIR for the full
|
|
22
|
+
# rationale. In short: CLAUDE_PROJECT_DIR is caller-controlled, cannot be
|
|
23
|
+
# trusted for authorization, and the hook's own filesystem location is the
|
|
24
|
+
# only forge-resistant anchor available to a bash script.
|
|
25
|
+
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" && pwd -P 2>/dev/null)"
|
|
26
|
+
# Walk up from SCRIPT_DIR looking for `.rea/policy.yaml`. Matches every
|
|
27
|
+
# reasonable install topology (see push-review-gate.sh §1a for the full
|
|
28
|
+
# rationale). A hard-coded `../..` breaks the source-path invocation
|
|
29
|
+
# (`bash hooks/commit-review-gate.sh`) and silently reads .rea state from
|
|
30
|
+
# the WRONG directory.
|
|
31
|
+
REA_ROOT=""
|
|
32
|
+
_anchor_candidate="$SCRIPT_DIR"
|
|
33
|
+
for _ in 1 2 3 4; do
|
|
34
|
+
_anchor_candidate="$(cd -- "$_anchor_candidate/.." && pwd -P 2>/dev/null || true)"
|
|
35
|
+
if [[ -n "$_anchor_candidate" && -f "$_anchor_candidate/.rea/policy.yaml" ]]; then
|
|
36
|
+
REA_ROOT="$_anchor_candidate"
|
|
37
|
+
break
|
|
38
|
+
fi
|
|
39
|
+
done
|
|
40
|
+
if [[ -z "$REA_ROOT" ]]; then
|
|
41
|
+
printf 'rea-hook: no .rea/policy.yaml found within 4 parents of %s\n' \
|
|
42
|
+
"$SCRIPT_DIR" >&2
|
|
43
|
+
printf 'rea-hook: is this an installed rea hook, or is `.rea/policy.yaml`\n' >&2
|
|
44
|
+
printf 'rea-hook: nested more than 4 directories above the hook script?\n' >&2
|
|
45
|
+
exit 2
|
|
46
|
+
fi
|
|
47
|
+
unset _anchor_candidate
|
|
48
|
+
|
|
49
|
+
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
|
|
50
|
+
CPD_REAL=$(cd -- "${CLAUDE_PROJECT_DIR}" 2>/dev/null && pwd -P 2>/dev/null || true)
|
|
51
|
+
if [[ -n "$CPD_REAL" && "$CPD_REAL" != "$REA_ROOT" ]]; then
|
|
52
|
+
printf 'rea-hook: ignoring CLAUDE_PROJECT_DIR=%s — anchoring to script location %s\n' \
|
|
53
|
+
"$CLAUDE_PROJECT_DIR" "$REA_ROOT" >&2
|
|
54
|
+
fi
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
CWD_REAL=$(pwd -P 2>/dev/null || pwd)
|
|
58
|
+
CWD_COMMON=$(git -C "$CWD_REAL" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
|
|
59
|
+
REA_COMMON=$(git -C "$REA_ROOT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
|
|
60
|
+
if [[ -n "$CWD_COMMON" && -n "$REA_COMMON" ]]; then
|
|
61
|
+
CWD_COMMON_REAL=$(cd "$CWD_COMMON" 2>/dev/null && pwd -P 2>/dev/null || echo "$CWD_COMMON")
|
|
62
|
+
REA_COMMON_REAL=$(cd "$REA_COMMON" 2>/dev/null && pwd -P 2>/dev/null || echo "$REA_COMMON")
|
|
63
|
+
if [[ "$CWD_COMMON_REAL" != "$REA_COMMON_REAL" ]]; then
|
|
64
|
+
exit 0
|
|
65
|
+
fi
|
|
66
|
+
elif [[ -z "$CWD_COMMON" && -z "$REA_COMMON" ]]; then
|
|
67
|
+
case "$CWD_REAL/" in
|
|
68
|
+
"$REA_ROOT"/*|"$REA_ROOT"/) : ;; # inside rea — run the gate
|
|
69
|
+
*) exit 0 ;; # outside rea — not our gate
|
|
70
|
+
esac
|
|
71
|
+
fi
|
|
72
|
+
# Mixed state or probe error → fail CLOSED: run the gate.
|
|
73
|
+
|
|
18
74
|
# ── 2. Dependency check ──────────────────────────────────────────────────────
|
|
19
75
|
if ! command -v jq >/dev/null 2>&1; then
|
|
20
76
|
printf 'REA ERROR: jq is required but not installed.\n' >&2
|
|
@@ -23,7 +79,6 @@ if ! command -v jq >/dev/null 2>&1; then
|
|
|
23
79
|
fi
|
|
24
80
|
|
|
25
81
|
# ── 3. HALT check ────────────────────────────────────────────────────────────
|
|
26
|
-
REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
27
82
|
HALT_FILE="${REA_ROOT}/.rea/HALT"
|
|
28
83
|
if [ -f "$HALT_FILE" ]; then
|
|
29
84
|
printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
|
|
@@ -37,6 +37,110 @@ set -uo pipefail
|
|
|
37
37
|
# ── 1. Read ALL stdin immediately ─────────────────────────────────────────────
|
|
38
38
|
INPUT=$(cat)
|
|
39
39
|
|
|
40
|
+
# ── 1a. Cross-repo guard (must come FIRST — before any rea-scoped check) ──────
|
|
41
|
+
# BUG-012 (0.6.2) — anchor the install to the SCRIPT'S OWN LOCATION on disk.
|
|
42
|
+
# The hook knows where it lives: installed at `<root>/.claude/hooks/<name>.sh`,
|
|
43
|
+
# so `<root>` is two levels up from `BASH_SOURCE[0]`. No caller-controlled
|
|
44
|
+
# env var participates in the trust decision.
|
|
45
|
+
#
|
|
46
|
+
# WHY THIS CHANGED in 0.6.2
|
|
47
|
+
# The 0.6.1 guard read `REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"` before the
|
|
48
|
+
# jq/HALT checks. That made `CLAUDE_PROJECT_DIR` a trust boundary: any process
|
|
49
|
+
# that could set it to a foreign path bypassed HALT and every other rea
|
|
50
|
+
# gate. CLAUDE_PROJECT_DIR is documentation/UX — it tells the wrapper which
|
|
51
|
+
# project directory the user opened. It is NOT authentication. Authorization
|
|
52
|
+
# must come from something the caller cannot forge, hence the script-path
|
|
53
|
+
# anchor. See THREAT_MODEL.md § CLAUDE_PROJECT_DIR.
|
|
54
|
+
#
|
|
55
|
+
# BEHAVIOR UNDER EACH INSTALL TOPOLOGY
|
|
56
|
+
# Consumer install: <consumer>/.claude/hooks/push-review-gate.sh
|
|
57
|
+
# → REA_ROOT = <consumer>
|
|
58
|
+
# → Guard runs against <consumer>/.rea/policy.yaml.
|
|
59
|
+
# rea dogfood: /…/rea/.claude/hooks/push-review-gate.sh
|
|
60
|
+
# → REA_ROOT = /…/rea (this repo itself)
|
|
61
|
+
# → Guard runs against rea's own policy.yaml.
|
|
62
|
+
#
|
|
63
|
+
# CLAUDE_PROJECT_DIR, if set, is still TREATED AS ADVISORY: if it names a
|
|
64
|
+
# different path, we emit a one-line stderr note and continue with the
|
|
65
|
+
# script-derived REA_ROOT. We never short-circuit based on comparing the
|
|
66
|
+
# env var against the script location — that would re-open the bypass.
|
|
67
|
+
#
|
|
68
|
+
# Repo-identity comparison via shared `--git-common-dir`, NOT path-prefix or
|
|
69
|
+
# `--show-toplevel`. A linked worktree created by `git worktree add` has a
|
|
70
|
+
# different toplevel but the SAME repository (shared object DB / refs /
|
|
71
|
+
# history). Any worktree of rea IS rea and must run the gate.
|
|
72
|
+
# `--path-format=absolute` (Git ≥ 2.31, March 2021) normalizes the common
|
|
73
|
+
# dir so the same repo's common-dir is equal regardless of which worktree
|
|
74
|
+
# asked. Engines pin Node ≥20 which ships with a recent-enough Git for dev.
|
|
75
|
+
#
|
|
76
|
+
# BUG-012 fail-closed: when ONE side is a git checkout and the other is not
|
|
77
|
+
# (or the `--git-common-dir` probe errored), we run the gate (treat as
|
|
78
|
+
# same-repo). Fail open on probe failure is what 0.6.1 did and it meant a
|
|
79
|
+
# transient git quirk inside a legitimate rea worktree could bypass HALT.
|
|
80
|
+
# The path-prefix fallback is ONLY used when BOTH sides are non-git — the
|
|
81
|
+
# documented 0.5.1 non-git escape-hatch scenario (`data/`, `figgy`).
|
|
82
|
+
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" && pwd -P 2>/dev/null)"
|
|
83
|
+
# Walk up from SCRIPT_DIR looking for `.rea/policy.yaml`. This resolves
|
|
84
|
+
# correctly for every reasonable topology — installed copy at
|
|
85
|
+
# `<root>/.claude/hooks/<name>.sh` (2 up), source-of-truth copy at
|
|
86
|
+
# `<root>/hooks/<name>.sh` (1 up, used when rea dogfoods itself or a
|
|
87
|
+
# developer runs `bash hooks/push-review-gate.sh` to smoke-test), and any
|
|
88
|
+
# future `hooks/_lib/` nesting. A hard-coded `../..` breaks the source-path
|
|
89
|
+
# invocation and silently reads .rea state from the WRONG directory.
|
|
90
|
+
# Cap at 4 levels so a stray hook dropped in the wrong spot fails fast
|
|
91
|
+
# instead of walking to the filesystem root.
|
|
92
|
+
REA_ROOT=""
|
|
93
|
+
_anchor_candidate="$SCRIPT_DIR"
|
|
94
|
+
for _ in 1 2 3 4; do
|
|
95
|
+
_anchor_candidate="$(cd -- "$_anchor_candidate/.." && pwd -P 2>/dev/null || true)"
|
|
96
|
+
if [[ -n "$_anchor_candidate" && -f "$_anchor_candidate/.rea/policy.yaml" ]]; then
|
|
97
|
+
REA_ROOT="$_anchor_candidate"
|
|
98
|
+
break
|
|
99
|
+
fi
|
|
100
|
+
done
|
|
101
|
+
if [[ -z "$REA_ROOT" ]]; then
|
|
102
|
+
printf 'rea-hook: no .rea/policy.yaml found within 4 parents of %s\n' \
|
|
103
|
+
"$SCRIPT_DIR" >&2
|
|
104
|
+
printf 'rea-hook: is this an installed rea hook, or is `.rea/policy.yaml`\n' >&2
|
|
105
|
+
printf 'rea-hook: nested more than 4 directories above the hook script?\n' >&2
|
|
106
|
+
exit 2
|
|
107
|
+
fi
|
|
108
|
+
unset _anchor_candidate
|
|
109
|
+
|
|
110
|
+
# Advisory-only: warn if the caller set CLAUDE_PROJECT_DIR to a path that
|
|
111
|
+
# does not match the script anchor. Never let the env var override the
|
|
112
|
+
# decision.
|
|
113
|
+
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
|
|
114
|
+
CPD_REAL=$(cd -- "${CLAUDE_PROJECT_DIR}" 2>/dev/null && pwd -P 2>/dev/null || true)
|
|
115
|
+
if [[ -n "$CPD_REAL" && "$CPD_REAL" != "$REA_ROOT" ]]; then
|
|
116
|
+
printf 'rea-hook: ignoring CLAUDE_PROJECT_DIR=%s — anchoring to script location %s\n' \
|
|
117
|
+
"$CLAUDE_PROJECT_DIR" "$REA_ROOT" >&2
|
|
118
|
+
fi
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
CWD_REAL=$(pwd -P 2>/dev/null || pwd)
|
|
122
|
+
CWD_COMMON=$(git -C "$CWD_REAL" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
|
|
123
|
+
REA_COMMON=$(git -C "$REA_ROOT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
|
|
124
|
+
if [[ -n "$CWD_COMMON" && -n "$REA_COMMON" ]]; then
|
|
125
|
+
# Both sides are git checkouts. Realpath'd common-dirs match IFF they
|
|
126
|
+
# point at the same underlying repository (main or linked worktree).
|
|
127
|
+
CWD_COMMON_REAL=$(cd "$CWD_COMMON" 2>/dev/null && pwd -P 2>/dev/null || echo "$CWD_COMMON")
|
|
128
|
+
REA_COMMON_REAL=$(cd "$REA_COMMON" 2>/dev/null && pwd -P 2>/dev/null || echo "$REA_COMMON")
|
|
129
|
+
if [[ "$CWD_COMMON_REAL" != "$REA_COMMON_REAL" ]]; then
|
|
130
|
+
exit 0
|
|
131
|
+
fi
|
|
132
|
+
elif [[ -z "$CWD_COMMON" && -z "$REA_COMMON" ]]; then
|
|
133
|
+
# Both sides non-git: legitimate 0.5.1 non-git escape-hatch. Fall back to
|
|
134
|
+
# a literal path-prefix match. Quoted expansions prevent glob expansion.
|
|
135
|
+
case "$CWD_REAL/" in
|
|
136
|
+
"$REA_ROOT"/*|"$REA_ROOT"/) : ;; # inside rea — run the gate
|
|
137
|
+
*) exit 0 ;; # outside rea — not our gate
|
|
138
|
+
esac
|
|
139
|
+
fi
|
|
140
|
+
# Mixed state (one side git, other not) or either probe failed → fail
|
|
141
|
+
# CLOSED: run the gate. A transient `--git-common-dir` probe failure in a
|
|
142
|
+
# legitimate rea worktree must not silently bypass HALT.
|
|
143
|
+
|
|
40
144
|
# ── 2. Dependency check ──────────────────────────────────────────────────────
|
|
41
145
|
if ! command -v jq >/dev/null 2>&1; then
|
|
42
146
|
printf 'REA ERROR: jq is required but not installed.\n' >&2
|
|
@@ -45,7 +149,6 @@ if ! command -v jq >/dev/null 2>&1; then
|
|
|
45
149
|
fi
|
|
46
150
|
|
|
47
151
|
# ── 3. HALT check ────────────────────────────────────────────────────────────
|
|
48
|
-
REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
49
152
|
HALT_FILE="${REA_ROOT}/.rea/HALT"
|
|
50
153
|
if [ -f "$HALT_FILE" ]; then
|
|
51
154
|
printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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)",
|
package/scripts/tarball-smoke.sh
CHANGED
|
@@ -181,6 +181,121 @@ echo "[smoke] → $AGENT_COUNT agents, $HOOK_COUNT hooks, $COMMAND_COUNT comma
|
|
|
181
181
|
echo "[smoke] rea doctor"
|
|
182
182
|
./node_modules/.bin/rea doctor
|
|
183
183
|
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# BUG-013 — security-claim content gate.
|
|
186
|
+
#
|
|
187
|
+
# If any changeset carries the `[security]` marker, the tarball MUST ship
|
|
188
|
+
# compiled evidence of the claimed fix. The rule:
|
|
189
|
+
#
|
|
190
|
+
# 1. Find every `.changeset/*.md` in the source tree that contains `[security]`
|
|
191
|
+
# 2. Assert AT LEAST ONE `*sanitize*.test.ts` or `*security*.test.ts` exists
|
|
192
|
+
# under `src/` (a "security-claim" changeset without a matching regression
|
|
193
|
+
# test is a marketing bullet, not a shipped fix)
|
|
194
|
+
# 3. For every such test file, extract the symbols it imports from the
|
|
195
|
+
# module under test (named imports from relative paths) and assert each
|
|
196
|
+
# symbol appears somewhere under `dist/`. Tests are excluded from the
|
|
197
|
+
# npm build (tsconfig.build.json), so a stale dist/ from a prior release
|
|
198
|
+
# would not contain the new symbol that the test exercises — this catches
|
|
199
|
+
# the 0.6.0→0.6.1 byte-identical dist/ regression that motivated BUG-013.
|
|
200
|
+
#
|
|
201
|
+
# Bypass-resistant: the gate keys on the changeset marker, not a flag the
|
|
202
|
+
# release author chooses. Narrow: no-op when no `[security]` changesets exist.
|
|
203
|
+
#
|
|
204
|
+
# Known limits (called out honestly rather than papered over):
|
|
205
|
+
# - The gate asserts the imported SYMBOLS are present in dist/. It does
|
|
206
|
+
# NOT assert those symbols are NEW vs. the previous published release.
|
|
207
|
+
# A test that imports only pre-existing symbols would satisfy the gate
|
|
208
|
+
# against a stale dist/. The two defense-in-depth layers that close
|
|
209
|
+
# this gap — `Rebuild dist/ from HEAD before publish` and
|
|
210
|
+
# `Verify published tarball dist/ matches CI-built dist/` — live in
|
|
211
|
+
# `.github/workflows/release.yml` (see `.rea/drafts-0.6.2/` for the
|
|
212
|
+
# pending hand-apply patch). The content gate here catches the
|
|
213
|
+
# 0.6.0→0.6.1 class of regression in the common case; the workflow
|
|
214
|
+
# hash check catches the adversarial case.
|
|
215
|
+
# - The gate does not tie a specific changeset to a specific test file.
|
|
216
|
+
# If a security changeset names BUG-X but the shipping security test
|
|
217
|
+
# covers BUG-Y, the gate passes. Mitigation is the same: the workflow
|
|
218
|
+
# hash verification plus human review of the changeset at PR time.
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
SEC_CHANGESETS="$(grep -l '\[security\]' "$REPO_ROOT"/.changeset/*.md 2>/dev/null || true)"
|
|
221
|
+
if [ -n "$SEC_CHANGESETS" ]; then
|
|
222
|
+
echo "[smoke] security-claim gate: $(printf '%s\n' "$SEC_CHANGESETS" | wc -l | awk '{print $1}') changeset(s) tagged [security]"
|
|
223
|
+
|
|
224
|
+
SEC_SRC_TESTS="$(cd "$REPO_ROOT" && find src -type f \( -name '*sanitize*.test.ts' -o -name '*security*.test.ts' \) 2>/dev/null | sort)"
|
|
225
|
+
if [ -z "$SEC_SRC_TESTS" ]; then
|
|
226
|
+
echo "[smoke] FAIL — [security] changeset present but no *sanitize*.test.ts or *security*.test.ts under src/" >&2
|
|
227
|
+
echo "[smoke] a security-claim changeset with no matching regression test is a trust violation" >&2
|
|
228
|
+
exit 2
|
|
229
|
+
fi
|
|
230
|
+
|
|
231
|
+
# For each security test, collect the named imports pulled from relative
|
|
232
|
+
# paths — those are the symbols under test and must be compiled into dist/.
|
|
233
|
+
# Example line we want to match:
|
|
234
|
+
# import { sanitizeHealthSnapshot, INJECTION_REDACTED_PLACEHOLDER } from './health';
|
|
235
|
+
# We ignore imports from bare package names ('vitest', 'node:fs', etc.).
|
|
236
|
+
MISSING_SYMBOLS=""
|
|
237
|
+
SYMBOL_COUNT=0
|
|
238
|
+
while IFS= read -r src_test; do
|
|
239
|
+
[ -z "$src_test" ] && continue
|
|
240
|
+
# Collect named imports from relative-path sources using perl for a
|
|
241
|
+
# multi-line regex. Output: one symbol per line.
|
|
242
|
+
# We intentionally skip:
|
|
243
|
+
# - `import type { ... }` — entire clause is type-only
|
|
244
|
+
# - `{ ..., type Foo, ... }` — inline type-only marker on a member
|
|
245
|
+
# TypeScript erases both at compile time, so asserting them against dist/
|
|
246
|
+
# would false-positive. Also skip `as` aliases (the aliased symbol is a
|
|
247
|
+
# local rebind, not the exported one we want to grep).
|
|
248
|
+
SYMBOLS="$(perl -0777 -ne '
|
|
249
|
+
while (/import(\s+type)?\s*\{([^}]+)\}\s*from\s*[\x27"](\.[^\x27"]+)[\x27"]/sg) {
|
|
250
|
+
next if $1; # whole clause is `import type { ... }` — skip
|
|
251
|
+
my $group = $2;
|
|
252
|
+
$group =~ s/\s+/ /g;
|
|
253
|
+
for my $sym (split /,/, $group) {
|
|
254
|
+
$sym =~ s/^\s+|\s+$//g;
|
|
255
|
+
next if $sym =~ /^type\s+/; # inline `type Foo` — skip
|
|
256
|
+
$sym =~ s/\s+as\s+\w+$//;
|
|
257
|
+
next unless $sym =~ /^\w+$/;
|
|
258
|
+
print "$sym\n";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
' "$REPO_ROOT/$src_test" | sort -u)"
|
|
262
|
+
|
|
263
|
+
while IFS= read -r sym; do
|
|
264
|
+
[ -z "$sym" ] && continue
|
|
265
|
+
SYMBOL_COUNT=$((SYMBOL_COUNT + 1))
|
|
266
|
+
# grep -r across dist/ — if the symbol does not appear anywhere, the
|
|
267
|
+
# build did not include the fix the test covers.
|
|
268
|
+
if ! grep -r --include='*.js' -l -F -w "$sym" "$REPO_ROOT/dist" >/dev/null 2>&1; then
|
|
269
|
+
MISSING_SYMBOLS="$MISSING_SYMBOLS
|
|
270
|
+
$sym (imported by $src_test)"
|
|
271
|
+
fi
|
|
272
|
+
done <<< "$SYMBOLS"
|
|
273
|
+
done <<< "$SEC_SRC_TESTS"
|
|
274
|
+
|
|
275
|
+
if [ -n "$MISSING_SYMBOLS" ]; then
|
|
276
|
+
echo "[smoke] FAIL — [security] changeset present but symbols under test are MISSING from dist/:" >&2
|
|
277
|
+
echo "[smoke] (dist/ may be stale — rebuild before publishing)" >&2
|
|
278
|
+
printf '%s\n' "$MISSING_SYMBOLS" >&2
|
|
279
|
+
exit 2
|
|
280
|
+
fi
|
|
281
|
+
|
|
282
|
+
# Codex review blocker #1 (2026-04-20) — a test file written with
|
|
283
|
+
# namespace/default/dynamic imports, or one that only imports from bare
|
|
284
|
+
# packages, produces zero symbols to check. Before this guard, the gate
|
|
285
|
+
# would pass with "0 symbols all present in dist/", re-opening the
|
|
286
|
+
# byte-identical-dist/ regression that BUG-013 was written to catch.
|
|
287
|
+
if [ "$SYMBOL_COUNT" -eq 0 ]; then
|
|
288
|
+
echo "[smoke] FAIL — [security] changeset present but no checkable symbols extracted" >&2
|
|
289
|
+
echo "[smoke] one or more src/**/(*sanitize*|*security*).test.ts files must use" >&2
|
|
290
|
+
echo "[smoke] the \`import { Named } from './relative'\` shape so the gate can" >&2
|
|
291
|
+
echo "[smoke] verify the symbol under test appears in compiled dist/." >&2
|
|
292
|
+
echo "[smoke] (namespace/default/dynamic-only imports can't be verified)" >&2
|
|
293
|
+
exit 2
|
|
294
|
+
fi
|
|
295
|
+
|
|
296
|
+
echo "[smoke] → $(printf '%s\n' "$SEC_SRC_TESTS" | wc -l | awk '{print $1}') security regression test(s), $SYMBOL_COUNT imported symbol(s) all present in dist/"
|
|
297
|
+
fi
|
|
298
|
+
|
|
184
299
|
# Verify every declared public export resolves. If the exports map points at a
|
|
185
300
|
# file that didn't ship in `files:`, this is where we catch it.
|
|
186
301
|
echo "[smoke] resolve exports"
|