@bookedsolid/rea 0.2.1 → 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/THREAT_MODEL.md +100 -29
- package/dist/audit/append.d.ts +21 -8
- package/dist/audit/append.js +48 -83
- package/dist/audit/fs.d.ts +68 -0
- package/dist/audit/fs.js +171 -0
- package/dist/cli/audit.d.ts +40 -0
- package/dist/cli/audit.js +205 -0
- package/dist/cli/doctor.d.ts +19 -4
- package/dist/cli/doctor.js +172 -5
- package/dist/cli/index.js +26 -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/audit/rotator.d.ts +116 -0
- package/dist/gateway/audit/rotator.js +289 -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 +24 -1
- package/dist/gateway/middleware/audit.js +103 -58
- 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 +47 -0
- package/dist/policy/loader.js +47 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +12 -0
- package/dist/policy/types.d.ts +52 -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 +3 -1
- package/profiles/bst-internal-no-codex.yaml +5 -0
- package/profiles/bst-internal.yaml +7 -0
- package/scripts/tarball-smoke.sh +197 -0
|
@@ -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
|
*
|
|
@@ -8,5 +9,27 @@ import type { Middleware } from './chain.js';
|
|
|
8
9
|
* SECURITY: Wraps next() in try/finally to ensure audit runs even on middleware exceptions.
|
|
9
10
|
* SECURITY: Placed as outermost middleware so audit records ALL invocations, including denials.
|
|
10
11
|
* PERFORMANCE: All fs operations are async to avoid blocking the event loop.
|
|
12
|
+
*
|
|
13
|
+
* CONCURRENCY (G1):
|
|
14
|
+
* - Per-process: the writeQueue below serializes writes within the Node process.
|
|
15
|
+
* - Cross-process: each write acquires a `proper-lockfile` lock on `.rea/`.
|
|
16
|
+
* Stale locks are reclaimed after 10s. Lock-acquisition failure falls back
|
|
17
|
+
* to the current best-effort behavior — the tool call proceeds and the
|
|
18
|
+
* failure is logged. Breaking the invocation because the auditor failed
|
|
19
|
+
* would let an audit outage take down the gateway.
|
|
20
|
+
*
|
|
21
|
+
* ROTATION (G1):
|
|
22
|
+
* - `maybeRotate` runs before each write's lock acquisition. Rotation writes
|
|
23
|
+
* a marker record whose `prev_hash` preserves hash-chain continuity across
|
|
24
|
+
* the rotation boundary. When no `audit.rotation` block is set in policy,
|
|
25
|
+
* rotation is a no-op — 0.2.x behavior is preserved.
|
|
26
|
+
*/
|
|
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.
|
|
11
34
|
*/
|
|
12
|
-
|
|
35
|
+
metrics?: MetricsRegistry): Middleware;
|