@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.
@@ -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
- /** Last error observed, or null if the connection has never failed (or fully recovered). */
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
- /** Last error observed, or null if the connection has never failed (or fully recovered). */
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
- return this.lastErrorMessage;
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
@@ -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
- return { halt: true, reason: trimmed.length > 0 ? trimmed : null };
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
- const snapshot = buildHealthSnapshot({
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: snapshot.gateway.halt,
245
- downstreams_registered: snapshot.summary.registered,
246
- downstreams_healthy: snapshot.summary.healthy,
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
- logger.warn({
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(snapshot, null, 2),
302
+ text: JSON.stringify(wireSnapshot, null, 2),
262
303
  },
263
304
  ],
264
305
  };
@@ -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.
@@ -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;
@@ -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.0",
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)",
@@ -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"