@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 +59 -4
- package/THREAT_MODEL.md +14 -0
- package/dist/cli/install/pre-push.js +3 -0
- package/dist/gateway/downstream.d.ts +16 -8
- package/dist/gateway/downstream.js +57 -11
- 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/_lib/push-review-core.sh +1013 -0
- package/hooks/commit-review-gate.sh +51 -28
- package/hooks/push-review-gate-git.sh +92 -0
- package/hooks/push-review-gate.sh +47 -940
- package/package.json +1 -1
- package/scripts/dist-regression-gate.sh +220 -0
- package/scripts/tarball-smoke.sh +115 -0
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
|
-
|
|
86
|
-
[ -z "${
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|