@bookedsolid/rea 0.3.0 → 0.4.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 +15 -18
- package/README.md +41 -1
- package/dist/cli/doctor.d.ts +19 -4
- package/dist/cli/doctor.js +172 -5
- package/dist/cli/index.js +9 -1
- package/dist/cli/init.js +93 -7
- package/dist/cli/install/pre-push.d.ts +335 -0
- package/dist/cli/install/pre-push.js +2818 -0
- package/dist/cli/serve.d.ts +64 -0
- package/dist/cli/serve.js +270 -2
- package/dist/cli/status.d.ts +90 -0
- package/dist/cli/status.js +399 -0
- package/dist/cli/utils.d.ts +4 -0
- package/dist/cli/utils.js +4 -0
- package/dist/gateway/circuit-breaker.d.ts +17 -0
- package/dist/gateway/circuit-breaker.js +32 -3
- package/dist/gateway/downstream-pool.d.ts +2 -1
- package/dist/gateway/downstream-pool.js +2 -2
- package/dist/gateway/downstream.d.ts +39 -3
- package/dist/gateway/downstream.js +73 -14
- package/dist/gateway/log.d.ts +122 -0
- package/dist/gateway/log.js +334 -0
- package/dist/gateway/middleware/audit.d.ts +10 -1
- package/dist/gateway/middleware/audit.js +26 -1
- package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
- package/dist/gateway/middleware/blocked-paths.js +439 -67
- package/dist/gateway/middleware/injection.d.ts +218 -13
- package/dist/gateway/middleware/injection.js +433 -51
- package/dist/gateway/middleware/kill-switch.d.ts +10 -1
- package/dist/gateway/middleware/kill-switch.js +20 -1
- package/dist/gateway/observability/metrics.d.ts +125 -0
- package/dist/gateway/observability/metrics.js +321 -0
- package/dist/gateway/server.d.ts +19 -0
- package/dist/gateway/server.js +99 -15
- package/dist/policy/loader.d.ts +13 -0
- package/dist/policy/loader.js +28 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +12 -0
- package/dist/policy/types.d.ts +28 -0
- package/dist/registry/fingerprint.d.ts +73 -0
- package/dist/registry/fingerprint.js +81 -0
- package/dist/registry/fingerprints-store.d.ts +62 -0
- package/dist/registry/fingerprints-store.js +111 -0
- package/dist/registry/interpolate.d.ts +58 -0
- package/dist/registry/interpolate.js +121 -0
- package/dist/registry/loader.d.ts +2 -2
- package/dist/registry/loader.js +22 -1
- package/dist/registry/tofu-gate.d.ts +41 -0
- package/dist/registry/tofu-gate.js +189 -0
- package/dist/registry/tofu.d.ts +111 -0
- package/dist/registry/tofu.js +173 -0
- package/dist/registry/types.d.ts +9 -1
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +5 -0
- package/profiles/bst-internal.yaml +7 -0
- package/scripts/tarball-smoke.sh +197 -0
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
*/
|
|
38
38
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
39
39
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
40
|
+
import { interpolateEnv } from '../registry/interpolate.js';
|
|
40
41
|
/**
|
|
41
42
|
* Neutral env vars every child inherits. These are the ones shells/toolchains
|
|
42
43
|
* need to function but carry no secrets in a well-configured environment.
|
|
@@ -66,14 +67,6 @@ const DEFAULT_ENV_ALLOWLIST = [
|
|
|
66
67
|
* handle it.
|
|
67
68
|
*/
|
|
68
69
|
const RECONNECT_FLAP_WINDOW_MS = 30_000;
|
|
69
|
-
/**
|
|
70
|
-
* Build the child env by layering:
|
|
71
|
-
* allowlist → registry env_passthrough → registry env.
|
|
72
|
-
* Later entries win. Missing host values are skipped so `process.env[name]`
|
|
73
|
-
* being undefined does not serialize as the literal string "undefined".
|
|
74
|
-
*
|
|
75
|
-
* Exported for testing.
|
|
76
|
-
*/
|
|
77
70
|
export function buildChildEnv(config, hostEnv = process.env) {
|
|
78
71
|
const out = {};
|
|
79
72
|
for (const name of DEFAULT_ENV_ALLOWLIST) {
|
|
@@ -88,14 +81,21 @@ export function buildChildEnv(config, hostEnv = process.env) {
|
|
|
88
81
|
out[name] = v;
|
|
89
82
|
}
|
|
90
83
|
}
|
|
84
|
+
// Interpolate placeholders in config.env BEFORE layering it on top.
|
|
85
|
+
// `interpolateEnv` is pure — no I/O, throws only on malformed syntax
|
|
86
|
+
// (unterminated brace, empty `${}`, illegal var name). Missing host
|
|
87
|
+
// vars are reported via `result.missing`; the caller decides whether
|
|
88
|
+
// to refuse the spawn.
|
|
89
|
+
const interp = interpolateEnv(config.env, hostEnv);
|
|
91
90
|
// Explicit config.env wins — operator typed these values deliberately.
|
|
92
|
-
for (const [k, v] of Object.entries(
|
|
91
|
+
for (const [k, v] of Object.entries(interp.resolved)) {
|
|
93
92
|
out[k] = v;
|
|
94
93
|
}
|
|
95
|
-
return out;
|
|
94
|
+
return { env: out, missing: interp.missing, secretKeys: interp.secretKeys };
|
|
96
95
|
}
|
|
97
96
|
export class DownstreamConnection {
|
|
98
97
|
config;
|
|
98
|
+
logger;
|
|
99
99
|
client = null;
|
|
100
100
|
/**
|
|
101
101
|
* Whether a reconnect has already been attempted in the CURRENT failure
|
|
@@ -107,8 +107,15 @@ export class DownstreamConnection {
|
|
|
107
107
|
/** Epoch ms of the last successful reconnect. Used by the flapping guard. */
|
|
108
108
|
lastReconnectAt = 0;
|
|
109
109
|
health = 'healthy';
|
|
110
|
-
constructor(config
|
|
110
|
+
constructor(config,
|
|
111
|
+
/**
|
|
112
|
+
* Optional structured logger (G5). When omitted, connection lifecycle
|
|
113
|
+
* events are simply not logged — keeping the class usable in unit tests
|
|
114
|
+
* that don't care about observability.
|
|
115
|
+
*/
|
|
116
|
+
logger) {
|
|
111
117
|
this.config = config;
|
|
118
|
+
this.logger = logger;
|
|
112
119
|
}
|
|
113
120
|
get name() {
|
|
114
121
|
return this.config.name;
|
|
@@ -119,10 +126,40 @@ export class DownstreamConnection {
|
|
|
119
126
|
async connect() {
|
|
120
127
|
if (this.client !== null)
|
|
121
128
|
return;
|
|
129
|
+
// Resolve env BEFORE spawning. If any `${VAR}` reference in the registry's
|
|
130
|
+
// explicit env: map is unset at startup, refuse to spawn this server:
|
|
131
|
+
// - log a clear, secret-safe error (only the var name appears; the
|
|
132
|
+
// resolved value would not exist anyway since it's missing)
|
|
133
|
+
// - mark this connection unhealthy so the pool skips it
|
|
134
|
+
// - leave every other server's spawn path untouched (the gateway as a
|
|
135
|
+
// whole keeps coming up)
|
|
136
|
+
//
|
|
137
|
+
// Malformed syntax (unterminated brace, `${}`, illegal identifier) throws
|
|
138
|
+
// from interpolateEnv — that's a load-time error and we propagate it so
|
|
139
|
+
// the operator sees it at startup with server context attached.
|
|
140
|
+
let built;
|
|
141
|
+
try {
|
|
142
|
+
built = buildChildEnv(this.config);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
this.health = 'unhealthy';
|
|
146
|
+
throw new Error(`failed to resolve env for downstream "${this.config.name}": ${err instanceof Error ? err.message : err}`);
|
|
147
|
+
}
|
|
148
|
+
if (built.missing.length > 0) {
|
|
149
|
+
this.health = 'unhealthy';
|
|
150
|
+
// One line per missing var so grep/jq users can find the exact gap.
|
|
151
|
+
// We intentionally do NOT log the env key name's VALUE (there is none —
|
|
152
|
+
// it's unresolved) nor any other env values.
|
|
153
|
+
for (const missingVar of built.missing) {
|
|
154
|
+
console.error(`[rea-gateway] refusing to start downstream "${this.config.name}": ` +
|
|
155
|
+
`env references ${'${'}${missingVar}${'}'} but process.env.${missingVar} is not set`);
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`downstream "${this.config.name}" refused to start — missing env: ${built.missing.join(', ')}`);
|
|
158
|
+
}
|
|
122
159
|
const transport = new StdioClientTransport({
|
|
123
160
|
command: this.config.command,
|
|
124
161
|
args: this.config.args,
|
|
125
|
-
env:
|
|
162
|
+
env: built.env,
|
|
126
163
|
});
|
|
127
164
|
const client = new Client({ name: `rea-gateway-client:${this.config.name}`, version: '0.2.0' }, { capabilities: {} });
|
|
128
165
|
try {
|
|
@@ -157,11 +194,16 @@ export class DownstreamConnection {
|
|
|
157
194
|
}
|
|
158
195
|
catch (err) {
|
|
159
196
|
const message = err instanceof Error ? err.message : String(err);
|
|
160
|
-
const withinFlapWindow = this.lastReconnectAt !== 0 &&
|
|
161
|
-
Date.now() - this.lastReconnectAt < RECONNECT_FLAP_WINDOW_MS;
|
|
197
|
+
const withinFlapWindow = this.lastReconnectAt !== 0 && Date.now() - this.lastReconnectAt < RECONNECT_FLAP_WINDOW_MS;
|
|
162
198
|
if (!this.reconnectAttempted && !withinFlapWindow) {
|
|
163
199
|
this.reconnectAttempted = true;
|
|
164
200
|
this.health = 'degraded';
|
|
201
|
+
this.logger?.warn({
|
|
202
|
+
event: 'downstream.reconnect_attempt',
|
|
203
|
+
server_name: this.config.name,
|
|
204
|
+
message: `downstream "${this.config.name}" will reconnect once after error`,
|
|
205
|
+
reason: message,
|
|
206
|
+
});
|
|
165
207
|
try {
|
|
166
208
|
await this.close();
|
|
167
209
|
await this.connect();
|
|
@@ -170,14 +212,31 @@ export class DownstreamConnection {
|
|
|
170
212
|
// stamp the reconnect time so flap-guard can refuse rapid repeats.
|
|
171
213
|
this.reconnectAttempted = false;
|
|
172
214
|
this.lastReconnectAt = Date.now();
|
|
215
|
+
this.logger?.info({
|
|
216
|
+
event: 'downstream.reconnected',
|
|
217
|
+
server_name: this.config.name,
|
|
218
|
+
message: `downstream "${this.config.name}" reconnected successfully`,
|
|
219
|
+
});
|
|
173
220
|
return result;
|
|
174
221
|
}
|
|
175
222
|
catch (reconnectErr) {
|
|
176
223
|
this.health = 'unhealthy';
|
|
224
|
+
this.logger?.error({
|
|
225
|
+
event: 'downstream.reconnect_failed',
|
|
226
|
+
server_name: this.config.name,
|
|
227
|
+
message: `downstream "${this.config.name}" unhealthy after one reconnect`,
|
|
228
|
+
error: reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr),
|
|
229
|
+
});
|
|
177
230
|
throw new Error(`downstream "${this.config.name}" unhealthy after one reconnect: ${reconnectErr instanceof Error ? reconnectErr.message : reconnectErr}`);
|
|
178
231
|
}
|
|
179
232
|
}
|
|
180
233
|
this.health = 'unhealthy';
|
|
234
|
+
this.logger?.error({
|
|
235
|
+
event: 'downstream.call_failed',
|
|
236
|
+
server_name: this.config.name,
|
|
237
|
+
message: `downstream "${this.config.name}" call failed`,
|
|
238
|
+
error: message,
|
|
239
|
+
});
|
|
181
240
|
throw new Error(`downstream "${this.config.name}" call failed: ${message}`);
|
|
182
241
|
}
|
|
183
242
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured gateway logger (G5).
|
|
3
|
+
*
|
|
4
|
+
* Minimal JSON-lines logger for `rea serve` and its collaborators. The existing
|
|
5
|
+
* codebase had `console.error`/`console.warn` scattered across the gateway;
|
|
6
|
+
* that worked for humans tailing stderr but made logs impossible to parse for
|
|
7
|
+
* future tooling (shipping to syslog, aggregating in a dashboard, greping
|
|
8
|
+
* per-session).
|
|
9
|
+
*
|
|
10
|
+
* Design constraints (from the task spec):
|
|
11
|
+
*
|
|
12
|
+
* 1. NO dependency — pino would be overkill, and dep weight matters for an
|
|
13
|
+
* install-anywhere CLI. ~30 lines of hand-rolled code is enough.
|
|
14
|
+
* 2. Respect `REA_LOG_LEVEL` (info default; debug for future verbose hooks).
|
|
15
|
+
* 3. Pretty-print on a TTY stderr; emit JSON lines on non-TTY (CI, redirected
|
|
16
|
+
* stderr, process-supervised runs).
|
|
17
|
+
* 4. Preserve the `[rea-serve]` / `[rea]` prefix convention — the helix smoke
|
|
18
|
+
* test greps for these. Pretty-mode prints them explicitly; JSON mode
|
|
19
|
+
* carries them as structured fields.
|
|
20
|
+
* 5. Never throw. A logger that can crash the gateway is a bad trade.
|
|
21
|
+
*
|
|
22
|
+
* ## What this is NOT
|
|
23
|
+
*
|
|
24
|
+
* - Not an OpenTelemetry integration — see {@link ./observability/metrics.ts}
|
|
25
|
+
* for counters/gauges. Logs and metrics are separate concerns.
|
|
26
|
+
* - Not the audit log — `.rea/audit.jsonl` is a hash-chained, tamper-evident
|
|
27
|
+
* record of tool calls. This module is free-form operator observability.
|
|
28
|
+
* - Not a file logger. Records go to stderr only. If you need durable logs,
|
|
29
|
+
* redirect the process's stderr.
|
|
30
|
+
*/
|
|
31
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
32
|
+
/** Structured fields every record carries. Additional fields are allowed. */
|
|
33
|
+
export interface LogFields {
|
|
34
|
+
/** Short verb-ish name — `downstream.connect`, `circuit.open`, etc. */
|
|
35
|
+
event: string;
|
|
36
|
+
/** Running gateway session id, when known. Omitted from startup records. */
|
|
37
|
+
session_id?: string;
|
|
38
|
+
/** Name of a downstream MCP server, when the record is server-scoped. */
|
|
39
|
+
server_name?: string;
|
|
40
|
+
/** Human-readable message. JSON mode carries it verbatim. */
|
|
41
|
+
message: string;
|
|
42
|
+
/** Any additional context. Values must be JSON-serializable. */
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
export interface Logger {
|
|
46
|
+
debug(fields: LogFields): void;
|
|
47
|
+
info(fields: LogFields): void;
|
|
48
|
+
warn(fields: LogFields): void;
|
|
49
|
+
error(fields: LogFields): void;
|
|
50
|
+
/** Spawn a logger that merges `base` into every record. */
|
|
51
|
+
child(base: Partial<LogFields>): Logger;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Pre-emit field redactor. Called for every string-valued field in a
|
|
55
|
+
* record just before serialization. Returns the redacted string.
|
|
56
|
+
*
|
|
57
|
+
* SECURITY: downstream error messages may carry credential material
|
|
58
|
+
* (env var names, argv fragments, file paths). Without a hook the raw
|
|
59
|
+
* text reaches stderr unchanged. `createLogger` in `rea serve` installs
|
|
60
|
+
* a redactor compiled from the same SECRET_PATTERNS used by the redact
|
|
61
|
+
* middleware so log records carry the same protection as audit records.
|
|
62
|
+
*
|
|
63
|
+
* The hook must be synchronous, total (never throw), and idempotent on
|
|
64
|
+
* already-redacted strings. It MUST NOT perform I/O.
|
|
65
|
+
*/
|
|
66
|
+
export type FieldRedactor = (value: string) => string;
|
|
67
|
+
export interface LoggerOptions {
|
|
68
|
+
/** Minimum level to emit. Default from REA_LOG_LEVEL env, else 'info'. */
|
|
69
|
+
level?: LogLevel;
|
|
70
|
+
/** Sink for serialized records. Defaults to `process.stderr`. */
|
|
71
|
+
stream?: NodeJS.WritableStream;
|
|
72
|
+
/**
|
|
73
|
+
* Force output mode. Default: auto — JSON when `stream.isTTY !== true`,
|
|
74
|
+
* pretty when the stream is a TTY.
|
|
75
|
+
*/
|
|
76
|
+
mode?: 'json' | 'pretty';
|
|
77
|
+
/** Clock injection for tests. Default: `Date.now`. */
|
|
78
|
+
now?: () => number;
|
|
79
|
+
/** Base fields merged into every record (used by `child()`). */
|
|
80
|
+
base?: Partial<LogFields>;
|
|
81
|
+
/**
|
|
82
|
+
* Optional pre-emit redactor applied to every string-valued field.
|
|
83
|
+
* See {@link FieldRedactor}. When omitted, no log-field redaction runs
|
|
84
|
+
* (0.3.x behavior). `rea serve` wires this up to the same patterns the
|
|
85
|
+
* redact middleware uses so downstream error messages can't leak creds
|
|
86
|
+
* to stderr.
|
|
87
|
+
*/
|
|
88
|
+
redactField?: FieldRedactor;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse `REA_LOG_LEVEL`. Unknown values fall back to 'info' — we never want
|
|
92
|
+
* startup to fail because an operator typo'd an env var.
|
|
93
|
+
*/
|
|
94
|
+
export declare function resolveLogLevel(raw: string | undefined): LogLevel;
|
|
95
|
+
/**
|
|
96
|
+
* Build a {@link FieldRedactor} from a list of plain regexes. Each regex
|
|
97
|
+
* is applied in order with `String.prototype.replace`, producing
|
|
98
|
+
* `[REDACTED]` where a match is found.
|
|
99
|
+
*
|
|
100
|
+
* Why not reuse the middleware's `SafeRegex` wrappers?
|
|
101
|
+
* - Log fields are short operator-supplied strings (typically downstream
|
|
102
|
+
* error messages + event names), not arbitrary MCP payloads. The
|
|
103
|
+
* ReDoS surface that motivated `SafeRegex` for the redact middleware
|
|
104
|
+
* does not apply here — the patterns we ship are anchored and
|
|
105
|
+
* bounded.
|
|
106
|
+
* - Running regex in a worker thread on every log line would add
|
|
107
|
+
* measurable latency to every event and pull in the worker pool on
|
|
108
|
+
* startup. For a sync logger that needs to be near-free, a
|
|
109
|
+
* sync-regex path is the right tradeoff.
|
|
110
|
+
*
|
|
111
|
+
* Exported so callers can pass their own compiled pattern list without
|
|
112
|
+
* touching the internal shape.
|
|
113
|
+
*/
|
|
114
|
+
export declare function buildRegexRedactor(patterns: ReadonlyArray<{
|
|
115
|
+
name: string;
|
|
116
|
+
pattern: RegExp;
|
|
117
|
+
}>): FieldRedactor;
|
|
118
|
+
/**
|
|
119
|
+
* Create a logger. Defaults are appropriate for `rea serve` at runtime;
|
|
120
|
+
* tests inject `stream`, `mode`, and `now` directly.
|
|
121
|
+
*/
|
|
122
|
+
export declare function createLogger(opts?: LoggerOptions): Logger;
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured gateway logger (G5).
|
|
3
|
+
*
|
|
4
|
+
* Minimal JSON-lines logger for `rea serve` and its collaborators. The existing
|
|
5
|
+
* codebase had `console.error`/`console.warn` scattered across the gateway;
|
|
6
|
+
* that worked for humans tailing stderr but made logs impossible to parse for
|
|
7
|
+
* future tooling (shipping to syslog, aggregating in a dashboard, greping
|
|
8
|
+
* per-session).
|
|
9
|
+
*
|
|
10
|
+
* Design constraints (from the task spec):
|
|
11
|
+
*
|
|
12
|
+
* 1. NO dependency — pino would be overkill, and dep weight matters for an
|
|
13
|
+
* install-anywhere CLI. ~30 lines of hand-rolled code is enough.
|
|
14
|
+
* 2. Respect `REA_LOG_LEVEL` (info default; debug for future verbose hooks).
|
|
15
|
+
* 3. Pretty-print on a TTY stderr; emit JSON lines on non-TTY (CI, redirected
|
|
16
|
+
* stderr, process-supervised runs).
|
|
17
|
+
* 4. Preserve the `[rea-serve]` / `[rea]` prefix convention — the helix smoke
|
|
18
|
+
* test greps for these. Pretty-mode prints them explicitly; JSON mode
|
|
19
|
+
* carries them as structured fields.
|
|
20
|
+
* 5. Never throw. A logger that can crash the gateway is a bad trade.
|
|
21
|
+
*
|
|
22
|
+
* ## What this is NOT
|
|
23
|
+
*
|
|
24
|
+
* - Not an OpenTelemetry integration — see {@link ./observability/metrics.ts}
|
|
25
|
+
* for counters/gauges. Logs and metrics are separate concerns.
|
|
26
|
+
* - Not the audit log — `.rea/audit.jsonl` is a hash-chained, tamper-evident
|
|
27
|
+
* record of tool calls. This module is free-form operator observability.
|
|
28
|
+
* - Not a file logger. Records go to stderr only. If you need durable logs,
|
|
29
|
+
* redirect the process's stderr.
|
|
30
|
+
*/
|
|
31
|
+
/** Ordered lowest → highest. A record is emitted iff its level ≥ current. */
|
|
32
|
+
const LEVEL_ORDER = {
|
|
33
|
+
debug: 10,
|
|
34
|
+
info: 20,
|
|
35
|
+
warn: 30,
|
|
36
|
+
error: 40,
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Hard byte-cap for string-valued log fields. Applied before any regex runs
|
|
40
|
+
* so that an attacker-influenced error message (e.g. a downstream MCP error
|
|
41
|
+
* that echoes request content) cannot cause catastrophic backtracking against
|
|
42
|
+
* the redaction pattern set, even if a badly-written pattern is loaded.
|
|
43
|
+
*
|
|
44
|
+
* 4 KiB is generous for any legitimate structured-log value and tiny compared
|
|
45
|
+
* to the multi-MB payloads that would be needed to trigger backtracking on
|
|
46
|
+
* a pathological pattern.
|
|
47
|
+
*/
|
|
48
|
+
const MAX_LOG_FIELD_BYTES = 4096;
|
|
49
|
+
/**
|
|
50
|
+
* Parse `REA_LOG_LEVEL`. Unknown values fall back to 'info' — we never want
|
|
51
|
+
* startup to fail because an operator typo'd an env var.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveLogLevel(raw) {
|
|
54
|
+
if (raw === undefined)
|
|
55
|
+
return 'info';
|
|
56
|
+
const normalized = raw.trim().toLowerCase();
|
|
57
|
+
if (normalized === 'debug' ||
|
|
58
|
+
normalized === 'info' ||
|
|
59
|
+
normalized === 'warn' ||
|
|
60
|
+
normalized === 'error') {
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
return 'info';
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* ANSI color helpers. Kept tiny — we don't pull chalk to color four words.
|
|
67
|
+
* A non-TTY writer never hits these because pretty mode only runs on a TTY.
|
|
68
|
+
*/
|
|
69
|
+
const COLOR = {
|
|
70
|
+
reset: '\x1b[0m',
|
|
71
|
+
dim: '\x1b[2m',
|
|
72
|
+
red: '\x1b[31m',
|
|
73
|
+
yellow: '\x1b[33m',
|
|
74
|
+
green: '\x1b[32m',
|
|
75
|
+
cyan: '\x1b[36m',
|
|
76
|
+
};
|
|
77
|
+
function levelColor(level) {
|
|
78
|
+
switch (level) {
|
|
79
|
+
case 'error':
|
|
80
|
+
return COLOR.red;
|
|
81
|
+
case 'warn':
|
|
82
|
+
return COLOR.yellow;
|
|
83
|
+
case 'info':
|
|
84
|
+
return COLOR.green;
|
|
85
|
+
case 'debug':
|
|
86
|
+
return COLOR.cyan;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Resolve `mode` from options and stream. A non-TTY stream always gets JSON —
|
|
91
|
+
* that is the well-defined contract for supervisors like systemd or Claude
|
|
92
|
+
* Code's MCP runner which redirect our stderr.
|
|
93
|
+
*/
|
|
94
|
+
function resolveMode(stream, explicit) {
|
|
95
|
+
if (explicit !== undefined)
|
|
96
|
+
return explicit;
|
|
97
|
+
const isTTY = stream.isTTY === true;
|
|
98
|
+
return isTTY ? 'pretty' : 'json';
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Stringify a record body. We do NOT use JSON.stringify's replacer argument
|
|
102
|
+
* for ordering — JSON.stringify preserves insertion order for string keys,
|
|
103
|
+
* so composing the record in a stable order is enough.
|
|
104
|
+
*/
|
|
105
|
+
function serialize(record) {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.stringify(record);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Last-ditch: drop unserializable values. A logger that throws on a
|
|
111
|
+
// circular ref in a user-supplied field would be a denial-of-service
|
|
112
|
+
// surface on its own daemon.
|
|
113
|
+
return JSON.stringify({
|
|
114
|
+
timestamp: record['timestamp'],
|
|
115
|
+
level: record['level'],
|
|
116
|
+
event: record['event'],
|
|
117
|
+
message: '[unserializable log record]',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Safe stringify for pretty-mode extras. JSON mode has its own serialize()
|
|
123
|
+
* fallback; pretty mode historically called `JSON.stringify(v)` raw, which
|
|
124
|
+
* throws on cyclic references. The outer emit() wrapped that in an empty
|
|
125
|
+
* try/catch so the entire record was dropped silently — a logger that
|
|
126
|
+
* drops records on operator-supplied extras is a DoS surface.
|
|
127
|
+
*
|
|
128
|
+
* Here we catch the throw, substitute a stable placeholder, and keep
|
|
129
|
+
* emitting. The record reaches the operator even if one field is
|
|
130
|
+
* unserializable.
|
|
131
|
+
*/
|
|
132
|
+
function safeStringifyExtra(v) {
|
|
133
|
+
try {
|
|
134
|
+
return JSON.stringify(v);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return '[unserializable]';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Pretty-format for a human reader. Keeps the `[rea-serve]` prefix convention
|
|
142
|
+
* so the helix smoke test's grep still matches.
|
|
143
|
+
*/
|
|
144
|
+
function formatPretty(level, timestamp, fields) {
|
|
145
|
+
const color = levelColor(level);
|
|
146
|
+
const levelTag = level.toUpperCase().padEnd(5);
|
|
147
|
+
// Strip C0 controls and DEL from disk-sourced strings before terminal output
|
|
148
|
+
// to prevent ANSI/OSC escape injection from compromised MCP error messages.
|
|
149
|
+
const strip = (s) => s.replace(/[\x00-\x1f\x7f\u200b-\u200f\u202a-\u202e\u2028\u2029\u2066-\u2069]/g, '?');
|
|
150
|
+
const event = strip(fields.event);
|
|
151
|
+
const message = strip(fields.message);
|
|
152
|
+
// Extract the well-known fields we already rendered.
|
|
153
|
+
const { event: _e, message: _m, session_id, server_name, ...rest } = fields;
|
|
154
|
+
void _e;
|
|
155
|
+
void _m;
|
|
156
|
+
const extras = [];
|
|
157
|
+
if (server_name !== undefined)
|
|
158
|
+
extras.push(`server=${strip(String(server_name))}`);
|
|
159
|
+
if (session_id !== undefined)
|
|
160
|
+
extras.push(`session=${strip(String(session_id)).slice(0, 8)}`);
|
|
161
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
162
|
+
if (k === 'timestamp' || k === 'level')
|
|
163
|
+
continue; // trusted-internal: toISOString + enum-derived
|
|
164
|
+
extras.push(`${k}=${typeof v === 'string' ? strip(v) : safeStringifyExtra(v)}`);
|
|
165
|
+
}
|
|
166
|
+
const extrasStr = extras.length > 0 ? ` ${COLOR.dim}${extras.join(' ')}${COLOR.reset}` : '';
|
|
167
|
+
return `${COLOR.dim}${timestamp}${COLOR.reset} ${color}${levelTag}${COLOR.reset} [rea-serve] ${event}: ${message}${extrasStr}\n`;
|
|
168
|
+
}
|
|
169
|
+
class BasicLogger {
|
|
170
|
+
minLevel;
|
|
171
|
+
stream;
|
|
172
|
+
mode;
|
|
173
|
+
now;
|
|
174
|
+
base;
|
|
175
|
+
redactField;
|
|
176
|
+
constructor(opts) {
|
|
177
|
+
const level = opts.level ?? resolveLogLevel(process.env['REA_LOG_LEVEL']);
|
|
178
|
+
this.minLevel = LEVEL_ORDER[level];
|
|
179
|
+
this.stream = opts.stream ?? process.stderr;
|
|
180
|
+
this.mode = resolveMode(this.stream, opts.mode);
|
|
181
|
+
this.now = opts.now ?? Date.now;
|
|
182
|
+
this.base = opts.base ?? {};
|
|
183
|
+
this.redactField = opts.redactField;
|
|
184
|
+
}
|
|
185
|
+
debug(fields) {
|
|
186
|
+
this.emit('debug', fields);
|
|
187
|
+
}
|
|
188
|
+
info(fields) {
|
|
189
|
+
this.emit('info', fields);
|
|
190
|
+
}
|
|
191
|
+
warn(fields) {
|
|
192
|
+
this.emit('warn', fields);
|
|
193
|
+
}
|
|
194
|
+
error(fields) {
|
|
195
|
+
this.emit('error', fields);
|
|
196
|
+
}
|
|
197
|
+
child(base) {
|
|
198
|
+
return new BasicLogger({
|
|
199
|
+
level: inverseLevel(this.minLevel),
|
|
200
|
+
stream: this.stream,
|
|
201
|
+
mode: this.mode,
|
|
202
|
+
now: this.now,
|
|
203
|
+
base: { ...this.base, ...base },
|
|
204
|
+
...(this.redactField !== undefined ? { redactField: this.redactField } : {}),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Apply the configured field redactor (if any) to every string-valued
|
|
209
|
+
* entry in the merged record. Non-string values pass through
|
|
210
|
+
* unchanged — a JSON-serializable object with credentials inside would
|
|
211
|
+
* need its own redactor upstream; the typical leak path for downstream
|
|
212
|
+
* MCP errors is a plain-string `error` or `message` field.
|
|
213
|
+
*
|
|
214
|
+
* SECURITY: downstream error messages are attacker-influenced and can
|
|
215
|
+
* be arbitrarily long. A badly-backtracking pattern applied to a large
|
|
216
|
+
* string would stall the event loop. Hard-cap each string field at
|
|
217
|
+
* MAX_LOG_FIELD_BYTES BEFORE applying any regex. The cap is applied
|
|
218
|
+
* regardless of whether a redactor is configured so the logger never
|
|
219
|
+
* writes runaway-length values to stderr even without patterns loaded.
|
|
220
|
+
*/
|
|
221
|
+
applyRedactor(merged) {
|
|
222
|
+
const redactor = this.redactField;
|
|
223
|
+
const out = {};
|
|
224
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
225
|
+
if (typeof v === 'string') {
|
|
226
|
+
// Truncate before regex — prevents catastrophic backtracking on
|
|
227
|
+
// attacker-controlled strings regardless of which patterns are loaded.
|
|
228
|
+
const capped = v.length > MAX_LOG_FIELD_BYTES
|
|
229
|
+
? v.slice(0, MAX_LOG_FIELD_BYTES) + '\u2026[truncated]'
|
|
230
|
+
: v;
|
|
231
|
+
if (redactor === undefined) {
|
|
232
|
+
out[k] = capped;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
try {
|
|
236
|
+
out[k] = redactor(capped);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// A crashing redactor must not drop the record. Fall back to
|
|
240
|
+
// a stable sentinel so the record still reaches the operator
|
|
241
|
+
// without the raw (possibly sensitive) value.
|
|
242
|
+
out[k] = '[redactor-error]';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
out[k] = v;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
emit(level, raw) {
|
|
253
|
+
if (LEVEL_ORDER[level] < this.minLevel)
|
|
254
|
+
return;
|
|
255
|
+
// Merge in base fields; explicit fields win.
|
|
256
|
+
const merged0 = { ...this.base, ...raw };
|
|
257
|
+
// SECURITY: apply the field redactor BEFORE serialization so the line
|
|
258
|
+
// written to stderr never contains the raw string. No-op when no
|
|
259
|
+
// redactor was configured.
|
|
260
|
+
const merged = this.applyRedactor(merged0);
|
|
261
|
+
const timestamp = new Date(this.now()).toISOString();
|
|
262
|
+
try {
|
|
263
|
+
let line;
|
|
264
|
+
if (this.mode === 'json') {
|
|
265
|
+
const record = { timestamp, level, ...merged };
|
|
266
|
+
line = serialize(record) + '\n';
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
line = formatPretty(level, timestamp, merged);
|
|
270
|
+
}
|
|
271
|
+
this.stream.write(line);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// Last resort — the stream rejected our write. Swallow; nothing we do
|
|
275
|
+
// here can improve the situation.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Recover the log level name from its numeric order. Only used when creating
|
|
281
|
+
* a child logger so the child inherits the parent's level without a second env
|
|
282
|
+
* lookup.
|
|
283
|
+
*/
|
|
284
|
+
function inverseLevel(order) {
|
|
285
|
+
for (const [name, n] of Object.entries(LEVEL_ORDER)) {
|
|
286
|
+
if (n === order)
|
|
287
|
+
return name;
|
|
288
|
+
}
|
|
289
|
+
return 'info';
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Build a {@link FieldRedactor} from a list of plain regexes. Each regex
|
|
293
|
+
* is applied in order with `String.prototype.replace`, producing
|
|
294
|
+
* `[REDACTED]` where a match is found.
|
|
295
|
+
*
|
|
296
|
+
* Why not reuse the middleware's `SafeRegex` wrappers?
|
|
297
|
+
* - Log fields are short operator-supplied strings (typically downstream
|
|
298
|
+
* error messages + event names), not arbitrary MCP payloads. The
|
|
299
|
+
* ReDoS surface that motivated `SafeRegex` for the redact middleware
|
|
300
|
+
* does not apply here — the patterns we ship are anchored and
|
|
301
|
+
* bounded.
|
|
302
|
+
* - Running regex in a worker thread on every log line would add
|
|
303
|
+
* measurable latency to every event and pull in the worker pool on
|
|
304
|
+
* startup. For a sync logger that needs to be near-free, a
|
|
305
|
+
* sync-regex path is the right tradeoff.
|
|
306
|
+
*
|
|
307
|
+
* Exported so callers can pass their own compiled pattern list without
|
|
308
|
+
* touching the internal shape.
|
|
309
|
+
*/
|
|
310
|
+
export function buildRegexRedactor(patterns) {
|
|
311
|
+
// Capture patterns by reference; they are long-lived across logger use.
|
|
312
|
+
return (value) => {
|
|
313
|
+
let out = value;
|
|
314
|
+
for (const { pattern } of patterns) {
|
|
315
|
+
// Always create a fresh RegExp for global or sticky patterns — shared
|
|
316
|
+
// stateful RegExp objects carry `lastIndex` that concurrent callers (e.g.
|
|
317
|
+
// the SafeRegex worker in the redact middleware) can leave non-zero,
|
|
318
|
+
// causing String.replace to start mid-string and silently skip leading
|
|
319
|
+
// secrets. The `y` (sticky) flag carries the same hazard as `g`.
|
|
320
|
+
const re = (pattern.global || pattern.sticky)
|
|
321
|
+
? new RegExp(pattern.source, pattern.flags)
|
|
322
|
+
: pattern;
|
|
323
|
+
out = out.replace(re, '[REDACTED]');
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Create a logger. Defaults are appropriate for `rea serve` at runtime;
|
|
330
|
+
* tests inject `stream`, `mode`, and `now` directly.
|
|
331
|
+
*/
|
|
332
|
+
export function createLogger(opts = {}) {
|
|
333
|
+
return new BasicLogger(opts);
|
|
334
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Policy } from '../../policy/types.js';
|
|
2
2
|
import type { Middleware } from './chain.js';
|
|
3
|
+
import type { MetricsRegistry } from '../observability/metrics.js';
|
|
3
4
|
/**
|
|
4
5
|
* Post-execution middleware: appends a hash-chained JSONL audit record.
|
|
5
6
|
*
|
|
@@ -23,4 +24,12 @@ import type { Middleware } from './chain.js';
|
|
|
23
24
|
* the rotation boundary. When no `audit.rotation` block is set in policy,
|
|
24
25
|
* rotation is a no-op — 0.2.x behavior is preserved.
|
|
25
26
|
*/
|
|
26
|
-
export declare function createAuditMiddleware(baseDir: string, policy?: Policy
|
|
27
|
+
export declare function createAuditMiddleware(baseDir: string, policy?: Policy,
|
|
28
|
+
/**
|
|
29
|
+
* Optional metrics registry. When supplied, the
|
|
30
|
+
* `rea_audit_lines_appended_total` counter is incremented on every
|
|
31
|
+
* successful append (post-fsync). When omitted, no metrics are emitted —
|
|
32
|
+
* keeps the middleware usable in unit tests that don't exercise the
|
|
33
|
+
* observability surface.
|
|
34
|
+
*/
|
|
35
|
+
metrics?: MetricsRegistry): Middleware;
|
|
@@ -26,7 +26,15 @@ import { maybeRotate } from '../audit/rotator.js';
|
|
|
26
26
|
* the rotation boundary. When no `audit.rotation` block is set in policy,
|
|
27
27
|
* rotation is a no-op — 0.2.x behavior is preserved.
|
|
28
28
|
*/
|
|
29
|
-
export function createAuditMiddleware(baseDir, policy
|
|
29
|
+
export function createAuditMiddleware(baseDir, policy,
|
|
30
|
+
/**
|
|
31
|
+
* Optional metrics registry. When supplied, the
|
|
32
|
+
* `rea_audit_lines_appended_total` counter is incremented on every
|
|
33
|
+
* successful append (post-fsync). When omitted, no metrics are emitted —
|
|
34
|
+
* keeps the middleware usable in unit tests that don't exercise the
|
|
35
|
+
* observability surface.
|
|
36
|
+
*/
|
|
37
|
+
metrics) {
|
|
30
38
|
// REA writes to a single .rea/audit.jsonl file (not dated per-day files).
|
|
31
39
|
const reaDir = path.join(baseDir, '.rea');
|
|
32
40
|
const auditFile = path.join(reaDir, 'audit.jsonl');
|
|
@@ -56,6 +64,15 @@ export function createAuditMiddleware(baseDir, policy) {
|
|
|
56
64
|
// kill-switch denied before policy middleware ran).
|
|
57
65
|
const duration_ms = Date.now() - ctx.start_time;
|
|
58
66
|
const autonomyLevel = ctx.metadata.autonomy_level ?? policy?.autonomy_level ?? 'unknown';
|
|
67
|
+
// Cap ctx.error before writing the audit record. A downstream MCP server
|
|
68
|
+
// can produce arbitrarily long error strings; if the audit record grows
|
|
69
|
+
// beyond ~64 KiB, `rea status` misreports it as corrupt because the tail
|
|
70
|
+
// window in summarizeAudit cannot contain the full record. 4096 bytes is
|
|
71
|
+
// generous for any legitimate error description.
|
|
72
|
+
const MAX_AUDIT_ERROR_BYTES = 4096;
|
|
73
|
+
if (ctx.error && ctx.error.length > MAX_AUDIT_ERROR_BYTES) {
|
|
74
|
+
ctx.error = ctx.error.slice(0, MAX_AUDIT_ERROR_BYTES) + '\u2026[truncated]';
|
|
75
|
+
}
|
|
59
76
|
// Serialize audit writes via a queue to maintain hash chain linearity under concurrency.
|
|
60
77
|
// Each write awaits the previous one before running its lock-scoped append.
|
|
61
78
|
const writePromise = writeQueue.then(async () => {
|
|
@@ -112,6 +129,14 @@ export function createAuditMiddleware(baseDir, policy) {
|
|
|
112
129
|
await fs.appendFile(auditFile, line);
|
|
113
130
|
}
|
|
114
131
|
await fsyncFile(auditFile);
|
|
132
|
+
// Only increment after fsync — a counter advance for a line that
|
|
133
|
+
// was never durable on disk would be a lie.
|
|
134
|
+
try {
|
|
135
|
+
metrics?.incAuditLines(1);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Metrics failures must never crash the gateway.
|
|
139
|
+
}
|
|
115
140
|
});
|
|
116
141
|
}
|
|
117
142
|
catch (auditErr) {
|