@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.
Files changed (56) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/dist/cli/doctor.d.ts +19 -4
  4. package/dist/cli/doctor.js +172 -5
  5. package/dist/cli/index.js +9 -1
  6. package/dist/cli/init.js +93 -7
  7. package/dist/cli/install/pre-push.d.ts +335 -0
  8. package/dist/cli/install/pre-push.js +2818 -0
  9. package/dist/cli/serve.d.ts +64 -0
  10. package/dist/cli/serve.js +270 -2
  11. package/dist/cli/status.d.ts +90 -0
  12. package/dist/cli/status.js +399 -0
  13. package/dist/cli/utils.d.ts +4 -0
  14. package/dist/cli/utils.js +4 -0
  15. package/dist/gateway/circuit-breaker.d.ts +17 -0
  16. package/dist/gateway/circuit-breaker.js +32 -3
  17. package/dist/gateway/downstream-pool.d.ts +2 -1
  18. package/dist/gateway/downstream-pool.js +2 -2
  19. package/dist/gateway/downstream.d.ts +39 -3
  20. package/dist/gateway/downstream.js +73 -14
  21. package/dist/gateway/log.d.ts +122 -0
  22. package/dist/gateway/log.js +334 -0
  23. package/dist/gateway/middleware/audit.d.ts +10 -1
  24. package/dist/gateway/middleware/audit.js +26 -1
  25. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  26. package/dist/gateway/middleware/blocked-paths.js +439 -67
  27. package/dist/gateway/middleware/injection.d.ts +218 -13
  28. package/dist/gateway/middleware/injection.js +433 -51
  29. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  30. package/dist/gateway/middleware/kill-switch.js +20 -1
  31. package/dist/gateway/observability/metrics.d.ts +125 -0
  32. package/dist/gateway/observability/metrics.js +321 -0
  33. package/dist/gateway/server.d.ts +19 -0
  34. package/dist/gateway/server.js +99 -15
  35. package/dist/policy/loader.d.ts +13 -0
  36. package/dist/policy/loader.js +28 -0
  37. package/dist/policy/profiles.d.ts +13 -0
  38. package/dist/policy/profiles.js +12 -0
  39. package/dist/policy/types.d.ts +28 -0
  40. package/dist/registry/fingerprint.d.ts +73 -0
  41. package/dist/registry/fingerprint.js +81 -0
  42. package/dist/registry/fingerprints-store.d.ts +62 -0
  43. package/dist/registry/fingerprints-store.js +111 -0
  44. package/dist/registry/interpolate.d.ts +58 -0
  45. package/dist/registry/interpolate.js +121 -0
  46. package/dist/registry/loader.d.ts +2 -2
  47. package/dist/registry/loader.js +22 -1
  48. package/dist/registry/tofu-gate.d.ts +41 -0
  49. package/dist/registry/tofu-gate.js +189 -0
  50. package/dist/registry/tofu.d.ts +111 -0
  51. package/dist/registry/tofu.js +173 -0
  52. package/dist/registry/types.d.ts +9 -1
  53. package/package.json +1 -1
  54. package/profiles/bst-internal-no-codex.yaml +5 -0
  55. package/profiles/bst-internal.yaml +7 -0
  56. 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(config.env)) {
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: buildChildEnv(this.config),
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): Middleware;
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) {