@bookedsolid/rea 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.husky/pre-push CHANGED
@@ -29,8 +29,22 @@
29
29
 
30
30
  set -eu
31
31
 
32
+ # git passes the remote name as $1 to pre-push. Fall back to `origin` for
33
+ # direct invocation (tests, manual runs). The shared core uses the same
34
+ # argv_remote convention — parity required so a push to `upstream` probes
35
+ # `upstream/main` rather than stale `origin/main`.
36
+ REMOTE="${1:-origin}"
37
+
32
38
  REA_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
33
39
 
40
+ # Well-known empty-tree SHA: `git hash-object -t tree /dev/null`. Every git
41
+ # installation carries this object implicitly — using it as a merge-base
42
+ # baseline for initial pushes lets `git diff $EMPTY_TREE $local_sha` emit
43
+ # the complete change set against a truly-empty tree. The protected-path
44
+ # check then sees every file in the initial push, so a first push of
45
+ # protected-path changes to a fresh remote is still gated.
46
+ EMPTY_TREE='4b825dc642cb6eb9a060e54bf8d69288fbee4904'
47
+
34
48
  if [ -f "${REA_ROOT}/.rea/HALT" ]; then
35
49
  # POSIX `head` does not specify `-c`; use awk for the first line. HALT is
36
50
  # a short reason string, so the first line is enough for display.
@@ -81,14 +95,55 @@ while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
81
95
 
82
96
  # Determine merge base. If remote is new (remote_sha is zeros), diff against
83
97
  # the default branch; else against remote_sha.
98
+ #
99
+ # Anchor on a REMOTE-TRACKING ref (refs/remotes/<remote>/<name>), NOT a bare
100
+ # branch name. A bare `main` resolves to refs/heads/main, which the pusher
101
+ # controls locally — a local main fast-forwarded to the feature tip would
102
+ # give merge-base main <local_sha> == local_sha and silently collapse the
103
+ # diff to empty. Remote-tracking refs are server-authoritative from the
104
+ # last fetch and cannot be tampered with locally.
105
+ #
106
+ # Fallback order when $REMOTE/HEAD is not set (common on shallow or mirror
107
+ # clones): probe $REMOTE/main then $REMOTE/master via rev-parse. If neither
108
+ # exists — initial push to a fresh remote with no tracking refs yet — use
109
+ # the well-known EMPTY_TREE as the baseline so the diff covers the FULL
110
+ # change set. This keeps the protected-path check honest on first push
111
+ # (prior versions of this patch `continue`d here, which was a fail-open
112
+ # flagged as HIGH by adversarial review).
84
113
  if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
85
- default_branch=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
86
- [ -z "${default_branch:-}" ] && default_branch="main"
87
- base=$(git merge-base "$default_branch" "$local_sha" 2>/dev/null || printf '')
114
+ default_ref=$(git symbolic-ref "refs/remotes/${REMOTE}/HEAD" 2>/dev/null || printf '')
115
+ if [ -z "${default_ref:-}" ]; then
116
+ if git rev-parse --verify --quiet "refs/remotes/${REMOTE}/main" >/dev/null 2>&1; then
117
+ default_ref="refs/remotes/${REMOTE}/main"
118
+ elif git rev-parse --verify --quiet "refs/remotes/${REMOTE}/master" >/dev/null 2>&1; then
119
+ default_ref="refs/remotes/${REMOTE}/master"
120
+ else
121
+ default_ref=""
122
+ fi
123
+ fi
124
+ if [ -n "${default_ref:-}" ]; then
125
+ base=$(git merge-base "$default_ref" "$local_sha" 2>/dev/null || printf '')
126
+ else
127
+ # Bootstrap: no remote-tracking ref exists at all. Use the empty-tree
128
+ # baseline so the diff covers every file in the push. git diff accepts
129
+ # a tree SHA as the left-hand side.
130
+ base="$EMPTY_TREE"
131
+ fi
88
132
  else
89
133
  base=$(git merge-base "$remote_sha" "$local_sha" 2>/dev/null || printf '')
90
134
  fi
91
- [ -z "${base:-}" ] && continue
135
+ # Fail CLOSED on empty merge-base when a remote ref DID resolve. The
136
+ # 0.4.0..0.6.2 behavior here was to `continue` — a silent bypass. A push
137
+ # whose history is unrelated to origin (or any transient git failure at
138
+ # merge-base resolution) would pass through without the protected-path
139
+ # check ever running. Refuse instead and force the operator to resolve it.
140
+ if [ -z "${base:-}" ]; then
141
+ printf 'PUSH BLOCKED: could not resolve merge-base between %s and %s (local_ref=%s remote_ref=%s).\n' \
142
+ "${remote_sha:-<new>}" "${local_sha:-<missing>}" "${local_ref:-<unknown>}" "${remote_ref:-<unknown>}" >&2
143
+ printf ' Run `git fetch %s` and retry. If the history is genuinely unrelated\n' "$REMOTE" >&2
144
+ printf ' to %s (e.g. grafted branch), resolve manually before pushing.\n' "$REMOTE" >&2
145
+ exit 1
146
+ fi
92
147
 
93
148
  # Check if the diff touches protected paths.
94
149
  if git diff --name-only "$base" "$local_sha" 2>/dev/null | grep -qE "$PROTECTED_RE"; then
package/THREAT_MODEL.md CHANGED
@@ -107,6 +107,20 @@ Downstream MCP servers are treated as untrusted by default. Codex plugin *invoca
107
107
 
108
108
  ---
109
109
 
110
+ ### 5.2a `CLAUDE_PROJECT_DIR` as advisory-only signal (BUG-012, 0.6.2)
111
+
112
+ **Threat:** The `push-review-gate.sh` and `commit-review-gate.sh` hooks need to know the rea repository root so that (a) cross-repo invocations from consumer repositories short-circuit cleanly, and (b) HALT / policy enforcement always evaluates the correct policy file. Prior to 0.6.2, the guard read the root from the `CLAUDE_PROJECT_DIR` environment variable. That variable is caller-controlled — any process invoking the hook (or any shell that has it exported in the environment) can set it to a foreign path, which the guard would then treat as rea. The result: HALT is silently bypassed, the cross-repo short-circuit fires on the wrong comparison, and policy is read from a directory the caller chose.
113
+
114
+ **Mitigations:**
115
+
116
+ - The hooks derive `REA_ROOT` from their own on-disk location using `BASH_SOURCE[0]` + `pwd -P`, then walk up to 4 parent directories looking for `.rea/policy.yaml` as the authoritative install marker. Install topology is fixed: hooks live at `<root>/.claude/hooks/<name>.sh`, so the anchor is forge-resistant — a caller cannot relocate the hook file without filesystem write access to the rea install, which is already protected by `settings-protection.sh` and `blocked-paths` enforcement.
117
+ - `CLAUDE_PROJECT_DIR` is retained only as an advisory signal. When set and the realpath differs from the script-derived `REA_ROOT`, the hook emits a stderr advisory and continues using the script-derived value. It is never compared for short-circuit, never used to select the policy file, and never used to locate HALT.
118
+ - The cross-repo guard compares `git rev-parse --git-common-dir` on both sides (not path prefixes). Mixed state (one side git, one non-git) fails **closed** — the gate runs — rather than falling through to path-prefix. Only the both-non-git case still uses path-prefix, matching the documented 0.5.1 non-git escape hatch.
119
+
120
+ **Residual risk:** If a local attacker has write access to the rea install directory they can move or replace the hook file, which would change `SCRIPT_DIR` and therefore `REA_ROOT`. This is equivalent to tampering with any other hook contents (`settings-protection.sh` already addresses it) and lies outside the `CLAUDE_PROJECT_DIR` threat class. Ref: `__tests__/hooks/push-review-gate-cross-repo.test.ts` "BUG-012: foreign CLAUDE_PROJECT_DIR does NOT bypass HALT".
121
+
122
+ ---
123
+
110
124
  ### 5.3 Policy Tampering
111
125
 
112
126
  **Threat:** An attacker or rogue agent modifies `policy.yaml` to elevate `autonomy_level` above `max_autonomy_level`, removes blocked paths, or disables `block_ai_attribution`.
@@ -268,6 +268,9 @@ const KNOWN_LEGACY_HUSKY_SHA256 = new Set([
268
268
  '9d4885b64f50dd91887c2c6b4d17e3aa91b0be5da8e842ca8915bec1bf369de5',
269
269
  // Initial publication (commit b513760, G6 MVP).
270
270
  '1ee21164ccce628a1ef85c313d09afdcdb8560efd761ec64b046cca6cc319cba',
271
+ // 0.7.0 — Codex pass-2 empty-tree baseline + $1 remote honoring +
272
+ // fail-closed on empty merge-base when a remote ref did resolve.
273
+ '84449e17a04986f3a6580eeb6fb9192cc6d8fabb099cd41cab0574a800c82056',
271
274
  ]);
272
275
  /**
273
276
  * True when `content` contains a POSIX shell construct that detects
@@ -75,6 +75,7 @@ export interface BuiltChildEnv {
75
75
  }
76
76
  export declare function buildChildEnv(config: RegistryServer, hostEnv?: NodeJS.ProcessEnv): BuiltChildEnv;
77
77
  export declare class DownstreamConnection {
78
+ #private;
78
79
  private readonly config;
79
80
  /**
80
81
  * Optional structured logger (G5). When omitted, connection lifecycle
@@ -93,13 +94,6 @@ export declare class DownstreamConnection {
93
94
  /** Epoch ms of the last successful reconnect. Used by the flapping guard. */
94
95
  private lastReconnectAt;
95
96
  private health;
96
- /**
97
- * The most recent error observed on this connection (connect or call
98
- * failure). Surfaced via `__rea__health` so callers can diagnose an empty
99
- * tool catalog without digging through stderr logs. Set to `null` after a
100
- * successful connect/reconnect.
101
- */
102
- private lastErrorMessage;
103
97
  constructor(config: RegistryServer,
104
98
  /**
105
99
  * Optional structured logger (G5). When omitted, connection lifecycle
@@ -111,7 +105,21 @@ export declare class DownstreamConnection {
111
105
  get isHealthy(): boolean;
112
106
  /** True iff the underlying MCP client is currently connected. */
113
107
  get isConnected(): boolean;
114
- /** Last error observed, or null if the connection has never failed (or fully recovered). */
108
+ /**
109
+ * Last error observed, or null if the connection has never failed (or fully
110
+ * recovered).
111
+ *
112
+ * BUG-011 (0.6.2) → BUG-014 (0.7.0): cap exposure via
113
+ * `boundedDiagnosticString`. 0.6.2 applied the bound at *read*, which
114
+ * meant every assignment site was trusted to eventually flow through
115
+ * this getter. 0.7.0 moves the bound to the private *setter* above, so
116
+ * the invariant is structural — every `this.#lastErrorMessage = x` write
117
+ * is bounded at assignment time regardless of how many assignment sites
118
+ * exist or where they live. We keep the read-side bound as cheap
119
+ * defense-in-depth (it's a no-op for already-bounded strings and costs
120
+ * O(length) only if a future intra-class edit writes directly to the
121
+ * backing field instead of going through the setter).
122
+ */
115
123
  get lastError(): string | null;
116
124
  connect(): Promise<void>;
117
125
  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.
@@ -112,8 +113,36 @@ export class DownstreamConnection {
112
113
  * failure). Surfaced via `__rea__health` so callers can diagnose an empty
113
114
  * tool catalog without digging through stderr logs. Set to `null` after a
114
115
  * successful connect/reconnect.
116
+ *
117
+ * BUG-014 (0.7.0): true ECMAScript private field + private accessor pair.
118
+ * Every internal write `this.#lastErrorMessage = x` goes through the
119
+ * setter, which applies `boundedDiagnosticString` at assignment time.
120
+ * This converts the prior "bound-at-read" invariant (see `get lastError`
121
+ * below, which was the single chokepoint before 0.7.0) into a structural
122
+ * property: no matter how many assignment sites exist, every one produces
123
+ * a bounded string. A future refactor can add new sites without needing
124
+ * to know the bound exists — the setter enforces it.
125
+ *
126
+ * The backing field `#lastErrorBacking` is the raw storage; only the
127
+ * setter writes to it. External code cannot reach either name because
128
+ * both are ES-private (`#`), not TS-private.
115
129
  */
116
- lastErrorMessage = null;
130
+ #lastErrorBacking = null;
131
+ get #lastErrorMessage() {
132
+ return this.#lastErrorBacking;
133
+ }
134
+ set #lastErrorMessage(msg) {
135
+ if (msg !== null && typeof msg !== 'string') {
136
+ // BUG-014 defense-in-depth: the TS type gate is strict, but a future
137
+ // refactor (or an `as unknown as string` cast) could slip a non-string
138
+ // through. `boundedDiagnosticString` calls `.length` / `.slice` on the
139
+ // input — a non-string would throw or silently corrupt the field. Fail
140
+ // loud instead.
141
+ throw new TypeError(`DownstreamConnection#lastErrorMessage: expected string | null, got ${typeof msg}`);
142
+ }
143
+ this.#lastErrorBacking =
144
+ msg === null ? null : boundedDiagnosticString(msg);
145
+ }
117
146
  constructor(config,
118
147
  /**
119
148
  * Optional structured logger (G5). When omitted, connection lifecycle
@@ -134,9 +163,26 @@ export class DownstreamConnection {
134
163
  get isConnected() {
135
164
  return this.client !== null;
136
165
  }
137
- /** Last error observed, or null if the connection has never failed (or fully recovered). */
166
+ /**
167
+ * Last error observed, or null if the connection has never failed (or fully
168
+ * recovered).
169
+ *
170
+ * BUG-011 (0.6.2) → BUG-014 (0.7.0): cap exposure via
171
+ * `boundedDiagnosticString`. 0.6.2 applied the bound at *read*, which
172
+ * meant every assignment site was trusted to eventually flow through
173
+ * this getter. 0.7.0 moves the bound to the private *setter* above, so
174
+ * the invariant is structural — every `this.#lastErrorMessage = x` write
175
+ * is bounded at assignment time regardless of how many assignment sites
176
+ * exist or where they live. We keep the read-side bound as cheap
177
+ * defense-in-depth (it's a no-op for already-bounded strings and costs
178
+ * O(length) only if a future intra-class edit writes directly to the
179
+ * backing field instead of going through the setter).
180
+ */
138
181
  get lastError() {
139
- return this.lastErrorMessage;
182
+ const raw = this.#lastErrorMessage;
183
+ if (raw === null)
184
+ return null;
185
+ return boundedDiagnosticString(raw);
140
186
  }
141
187
  async connect() {
142
188
  if (this.client !== null)
@@ -159,12 +205,12 @@ export class DownstreamConnection {
159
205
  catch (err) {
160
206
  this.health = 'unhealthy';
161
207
  const msg = `failed to resolve env for downstream "${this.config.name}": ${err instanceof Error ? err.message : err}`;
162
- this.lastErrorMessage = msg;
208
+ this.#lastErrorMessage = msg;
163
209
  throw new Error(msg);
164
210
  }
165
211
  if (built.missing.length > 0) {
166
212
  this.health = 'unhealthy';
167
- this.lastErrorMessage = `missing env: ${built.missing.join(', ')}`;
213
+ this.#lastErrorMessage = `missing env: ${built.missing.join(', ')}`;
168
214
  // One line per missing var so grep/jq users can find the exact gap.
169
215
  // We intentionally do NOT log the env key name's VALUE (there is none —
170
216
  // it's unresolved) nor any other env values.
@@ -184,12 +230,12 @@ export class DownstreamConnection {
184
230
  await client.connect(transport);
185
231
  this.client = client;
186
232
  this.health = 'healthy';
187
- this.lastErrorMessage = null;
233
+ this.#lastErrorMessage = null;
188
234
  }
189
235
  catch (err) {
190
236
  this.health = 'unhealthy';
191
237
  const msg = `failed to connect to downstream "${this.config.name}" (${this.config.command}): ${err instanceof Error ? err.message : err}`;
192
- this.lastErrorMessage = msg;
238
+ this.#lastErrorMessage = msg;
193
239
  throw new Error(msg);
194
240
  }
195
241
  }
@@ -216,7 +262,7 @@ export class DownstreamConnection {
216
262
  // this, a connection that failed once and then recovered on the very
217
263
  // next call (same client, no reconnect) would forever report the old
218
264
  // error via `__rea__health`, misleading operators about live state.
219
- this.lastErrorMessage = null;
265
+ this.#lastErrorMessage = null;
220
266
  return result;
221
267
  }
222
268
  catch (err) {
@@ -239,7 +285,7 @@ export class DownstreamConnection {
239
285
  // stamp the reconnect time so flap-guard can refuse rapid repeats.
240
286
  this.reconnectAttempted = false;
241
287
  this.lastReconnectAt = Date.now();
242
- this.lastErrorMessage = null;
288
+ this.#lastErrorMessage = null;
243
289
  this.logger?.info({
244
290
  event: 'downstream.reconnected',
245
291
  server_name: this.config.name,
@@ -250,7 +296,7 @@ export class DownstreamConnection {
250
296
  catch (reconnectErr) {
251
297
  this.health = 'unhealthy';
252
298
  const errMsg = reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr);
253
- this.lastErrorMessage = errMsg;
299
+ this.#lastErrorMessage = errMsg;
254
300
  this.logger?.error({
255
301
  event: 'downstream.reconnect_failed',
256
302
  server_name: this.config.name,
@@ -261,7 +307,7 @@ export class DownstreamConnection {
261
307
  }
262
308
  }
263
309
  this.health = 'unhealthy';
264
- this.lastErrorMessage = message;
310
+ this.#lastErrorMessage = message;
265
311
  this.logger?.error({
266
312
  event: 'downstream.call_failed',
267
313
  server_name: this.config.name,
@@ -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