@bookedsolid/rea 0.1.0 → 0.2.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 (90) hide show
  1. package/.husky/commit-msg +130 -0
  2. package/.husky/pre-push +128 -0
  3. package/README.md +5 -5
  4. package/agents/codex-adversarial.md +23 -8
  5. package/commands/codex-review.md +2 -2
  6. package/dist/audit/append.d.ts +62 -0
  7. package/dist/audit/append.js +189 -0
  8. package/dist/audit/codex-event.d.ts +28 -0
  9. package/dist/audit/codex-event.js +15 -0
  10. package/dist/cli/doctor.d.ts +60 -1
  11. package/dist/cli/doctor.js +459 -20
  12. package/dist/cli/index.js +35 -5
  13. package/dist/cli/init.d.ts +13 -0
  14. package/dist/cli/init.js +278 -67
  15. package/dist/cli/install/canonical.d.ts +43 -0
  16. package/dist/cli/install/canonical.js +101 -0
  17. package/dist/cli/install/claude-md.d.ts +48 -0
  18. package/dist/cli/install/claude-md.js +93 -0
  19. package/dist/cli/install/commit-msg.d.ts +30 -0
  20. package/dist/cli/install/commit-msg.js +102 -0
  21. package/dist/cli/install/copy.d.ts +169 -0
  22. package/dist/cli/install/copy.js +455 -0
  23. package/dist/cli/install/fs-safe.d.ts +91 -0
  24. package/dist/cli/install/fs-safe.js +347 -0
  25. package/dist/cli/install/manifest-io.d.ts +12 -0
  26. package/dist/cli/install/manifest-io.js +44 -0
  27. package/dist/cli/install/manifest-schema.d.ts +83 -0
  28. package/dist/cli/install/manifest-schema.js +80 -0
  29. package/dist/cli/install/reagent.d.ts +59 -0
  30. package/dist/cli/install/reagent.js +160 -0
  31. package/dist/cli/install/settings-merge.d.ts +91 -0
  32. package/dist/cli/install/settings-merge.js +239 -0
  33. package/dist/cli/install/sha.d.ts +9 -0
  34. package/dist/cli/install/sha.js +21 -0
  35. package/dist/cli/serve.d.ts +11 -0
  36. package/dist/cli/serve.js +72 -6
  37. package/dist/cli/upgrade.d.ts +67 -0
  38. package/dist/cli/upgrade.js +509 -0
  39. package/dist/gateway/downstream-pool.d.ts +39 -0
  40. package/dist/gateway/downstream-pool.js +93 -0
  41. package/dist/gateway/downstream.d.ts +80 -0
  42. package/dist/gateway/downstream.js +196 -0
  43. package/dist/gateway/middleware/audit-types.d.ts +10 -0
  44. package/dist/gateway/middleware/audit.js +14 -0
  45. package/dist/gateway/middleware/injection.d.ts +59 -2
  46. package/dist/gateway/middleware/injection.js +91 -14
  47. package/dist/gateway/middleware/kill-switch.d.ts +20 -5
  48. package/dist/gateway/middleware/kill-switch.js +57 -35
  49. package/dist/gateway/middleware/redact.d.ts +83 -6
  50. package/dist/gateway/middleware/redact.js +133 -46
  51. package/dist/gateway/observability/codex-probe.d.ts +110 -0
  52. package/dist/gateway/observability/codex-probe.js +234 -0
  53. package/dist/gateway/observability/codex-telemetry.d.ts +93 -0
  54. package/dist/gateway/observability/codex-telemetry.js +221 -0
  55. package/dist/gateway/redact-safe/match-timeout.d.ts +83 -0
  56. package/dist/gateway/redact-safe/match-timeout.js +179 -0
  57. package/dist/gateway/reviewers/claude-self.d.ts +99 -0
  58. package/dist/gateway/reviewers/claude-self.js +316 -0
  59. package/dist/gateway/reviewers/codex.d.ts +64 -0
  60. package/dist/gateway/reviewers/codex.js +80 -0
  61. package/dist/gateway/reviewers/select.d.ts +64 -0
  62. package/dist/gateway/reviewers/select.js +102 -0
  63. package/dist/gateway/reviewers/types.d.ts +85 -0
  64. package/dist/gateway/reviewers/types.js +14 -0
  65. package/dist/gateway/server.d.ts +51 -0
  66. package/dist/gateway/server.js +258 -0
  67. package/dist/gateway/session.d.ts +9 -0
  68. package/dist/gateway/session.js +17 -0
  69. package/dist/policy/loader.d.ts +59 -0
  70. package/dist/policy/loader.js +65 -0
  71. package/dist/policy/profiles.d.ts +80 -0
  72. package/dist/policy/profiles.js +94 -0
  73. package/dist/policy/types.d.ts +38 -0
  74. package/dist/registry/loader.d.ts +98 -0
  75. package/dist/registry/loader.js +153 -0
  76. package/dist/registry/types.d.ts +44 -0
  77. package/dist/registry/types.js +6 -0
  78. package/dist/scripts/read-policy-field.d.ts +36 -0
  79. package/dist/scripts/read-policy-field.js +96 -0
  80. package/hooks/push-review-gate.sh +627 -17
  81. package/package.json +13 -2
  82. package/profiles/bst-internal-no-codex.yaml +40 -0
  83. package/profiles/bst-internal.yaml +23 -0
  84. package/profiles/client-engagement.yaml +23 -0
  85. package/profiles/lit-wc.yaml +17 -0
  86. package/profiles/minimal.yaml +11 -0
  87. package/profiles/open-source-no-codex.yaml +33 -0
  88. package/profiles/open-source.yaml +18 -0
  89. package/scripts/lint-safe-regex.mjs +78 -0
  90. package/scripts/postinstall.mjs +131 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Pool of downstream MCP connections. Owns lookup + tool-name prefixing.
3
+ *
4
+ * Tool names exposed to the upstream MCP client are `<serverName>__<toolName>`.
5
+ * The gateway splits on the FIRST `__` — downstream tools that themselves
6
+ * contain `__` in their name continue to work because the split is one-shot.
7
+ */
8
+ import { DownstreamConnection } from './downstream.js';
9
+ export class DownstreamPool {
10
+ connections = new Map();
11
+ constructor(registry) {
12
+ for (const server of registry.servers) {
13
+ if (!server.enabled)
14
+ continue;
15
+ this.connections.set(server.name, new DownstreamConnection(server));
16
+ }
17
+ }
18
+ get size() {
19
+ return this.connections.size;
20
+ }
21
+ async connectAll() {
22
+ const errors = [];
23
+ await Promise.all([...this.connections.values()].map(async (conn) => {
24
+ try {
25
+ await conn.connect();
26
+ }
27
+ catch (err) {
28
+ errors.push(err instanceof Error ? err.message : String(err));
29
+ }
30
+ }));
31
+ if (errors.length > 0 && this.connections.size > 0 && errors.length === this.connections.size) {
32
+ // Total failure — the gateway is useless. Bubble up.
33
+ throw new Error(`all downstream connections failed:\n - ${errors.join('\n - ')}`);
34
+ }
35
+ }
36
+ /**
37
+ * Aggregate tools from every healthy downstream with prefixed names.
38
+ * Unhealthy or unconnected connections are skipped — the upstream client
39
+ * will see a smaller catalog rather than a crash.
40
+ */
41
+ async listAllTools() {
42
+ const out = [];
43
+ for (const [server, conn] of this.connections) {
44
+ if (!conn.isHealthy)
45
+ continue;
46
+ try {
47
+ const tools = await conn.listTools();
48
+ for (const t of tools) {
49
+ const prefixed = {
50
+ ...t,
51
+ server,
52
+ name: `${server}__${t.name}`,
53
+ };
54
+ out.push(prefixed);
55
+ }
56
+ }
57
+ catch {
58
+ // Listing is best-effort — omit this server's tools this cycle.
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+ /**
64
+ * Split a prefixed tool name and dispatch. Returns the raw result from the
65
+ * downstream (the gateway response handler shapes it for the upstream reply).
66
+ */
67
+ async callTool(prefixedName, args) {
68
+ const { server, tool } = splitPrefixed(prefixedName);
69
+ const conn = this.connections.get(server);
70
+ if (conn === undefined) {
71
+ throw new Error(`unknown downstream server "${server}" for tool "${prefixedName}"`);
72
+ }
73
+ return conn.callTool(tool, args);
74
+ }
75
+ async close() {
76
+ await Promise.all([...this.connections.values()].map((c) => c.close()));
77
+ this.connections.clear();
78
+ }
79
+ /** Visible for tests: get a connection by server name. */
80
+ getConnection(serverName) {
81
+ return this.connections.get(serverName);
82
+ }
83
+ }
84
+ export function splitPrefixed(prefixedName) {
85
+ const idx = prefixedName.indexOf('__');
86
+ if (idx === -1) {
87
+ throw new Error(`tool name "${prefixedName}" is missing the server prefix — expected "<server>__<tool>"`);
88
+ }
89
+ return {
90
+ server: prefixedName.slice(0, idx),
91
+ tool: prefixedName.slice(idx + 2),
92
+ };
93
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Per-server downstream MCP connection wrapper.
3
+ *
4
+ * Owns the lifecycle of a single `@modelcontextprotocol/sdk` `Client` +
5
+ * `StdioClientTransport` pair. The gateway spawns one of these per entry in
6
+ * `.rea/registry.yaml`.
7
+ *
8
+ * ## Environment inheritance
9
+ *
10
+ * Children do NOT inherit the operator's full `process.env`. Every child gets:
11
+ *
12
+ * 1. A fixed allowlist of neutral OS/runtime vars (`PATH`, `HOME`, `TZ`, …).
13
+ * 2. Any names the registry opts into via `env_passthrough: [...]`. The
14
+ * schema refuses secret-looking names (TOKEN/KEY/SECRET/…) — the operator
15
+ * must type secrets explicitly via `env:` so the decision is conscious.
16
+ * 3. Values from the registry's `env:` mapping. Takes precedence over 1 and 2.
17
+ *
18
+ * Rationale: the registry is a plain YAML file — an attacker who can write to
19
+ * `.rea/` (or who lands a malicious template via `rea init`) should not be
20
+ * able to exfiltrate `OPENAI_API_KEY`, `GITHUB_TOKEN`, or customer secrets by
21
+ * spawning a child that reads `process.env`.
22
+ *
23
+ * ## Health / reconnect
24
+ *
25
+ * On a transport-layer failure we attempt exactly ONE reconnect per failure
26
+ * episode. After a successful reconnect + retry the attempt flag resets so a
27
+ * later, unrelated transport error (e.g. an idle socket closed by the OS after
28
+ * hours) also gets one reconnect. A flapping guard refuses the second
29
+ * reconnect if it lands within `RECONNECT_FLAP_WINDOW_MS` of the previous
30
+ * successful reconnect — in that case we mark the connection unhealthy and
31
+ * let the circuit breaker take over.
32
+ *
33
+ * ## Why not request-level retries
34
+ *
35
+ * MCP tool calls are not idempotent by default. Retrying `send_message` after
36
+ * a transport error could double-post. We leave the decision to the caller.
37
+ */
38
+ import type { RegistryServer } from '../registry/types.js';
39
+ export interface DownstreamToolInfo {
40
+ name: string;
41
+ description?: string;
42
+ inputSchema?: unknown;
43
+ }
44
+ /**
45
+ * Build the child env by layering:
46
+ * allowlist → registry env_passthrough → registry env.
47
+ * Later entries win. Missing host values are skipped so `process.env[name]`
48
+ * being undefined does not serialize as the literal string "undefined".
49
+ *
50
+ * Exported for testing.
51
+ */
52
+ export declare function buildChildEnv(config: RegistryServer, hostEnv?: NodeJS.ProcessEnv): Record<string, string>;
53
+ export declare class DownstreamConnection {
54
+ private readonly config;
55
+ private client;
56
+ /**
57
+ * Whether a reconnect has already been attempted in the CURRENT failure
58
+ * episode. Resets to `false` after a reconnect succeeds (so a later,
59
+ * unrelated failure also gets one shot). A flapping guard prevents this
60
+ * from turning into a reconnect loop.
61
+ */
62
+ private reconnectAttempted;
63
+ /** Epoch ms of the last successful reconnect. Used by the flapping guard. */
64
+ private lastReconnectAt;
65
+ private health;
66
+ constructor(config: RegistryServer);
67
+ get name(): string;
68
+ get isHealthy(): boolean;
69
+ connect(): Promise<void>;
70
+ listTools(): Promise<DownstreamToolInfo[]>;
71
+ /**
72
+ * Forward a tool call to the child process. On transport failure, attempt
73
+ * at most ONE reconnect per failure episode. After a successful reconnect
74
+ * the episode ends and future unrelated failures will be retried again;
75
+ * rapid back-to-back failures within the flap window are refused to avoid
76
+ * a reconnect loop (the circuit breaker takes over in that case).
77
+ */
78
+ callTool(toolName: string, args: Record<string, unknown>): Promise<unknown>;
79
+ close(): Promise<void>;
80
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Per-server downstream MCP connection wrapper.
3
+ *
4
+ * Owns the lifecycle of a single `@modelcontextprotocol/sdk` `Client` +
5
+ * `StdioClientTransport` pair. The gateway spawns one of these per entry in
6
+ * `.rea/registry.yaml`.
7
+ *
8
+ * ## Environment inheritance
9
+ *
10
+ * Children do NOT inherit the operator's full `process.env`. Every child gets:
11
+ *
12
+ * 1. A fixed allowlist of neutral OS/runtime vars (`PATH`, `HOME`, `TZ`, …).
13
+ * 2. Any names the registry opts into via `env_passthrough: [...]`. The
14
+ * schema refuses secret-looking names (TOKEN/KEY/SECRET/…) — the operator
15
+ * must type secrets explicitly via `env:` so the decision is conscious.
16
+ * 3. Values from the registry's `env:` mapping. Takes precedence over 1 and 2.
17
+ *
18
+ * Rationale: the registry is a plain YAML file — an attacker who can write to
19
+ * `.rea/` (or who lands a malicious template via `rea init`) should not be
20
+ * able to exfiltrate `OPENAI_API_KEY`, `GITHUB_TOKEN`, or customer secrets by
21
+ * spawning a child that reads `process.env`.
22
+ *
23
+ * ## Health / reconnect
24
+ *
25
+ * On a transport-layer failure we attempt exactly ONE reconnect per failure
26
+ * episode. After a successful reconnect + retry the attempt flag resets so a
27
+ * later, unrelated transport error (e.g. an idle socket closed by the OS after
28
+ * hours) also gets one reconnect. A flapping guard refuses the second
29
+ * reconnect if it lands within `RECONNECT_FLAP_WINDOW_MS` of the previous
30
+ * successful reconnect — in that case we mark the connection unhealthy and
31
+ * let the circuit breaker take over.
32
+ *
33
+ * ## Why not request-level retries
34
+ *
35
+ * MCP tool calls are not idempotent by default. Retrying `send_message` after
36
+ * a transport error could double-post. We leave the decision to the caller.
37
+ */
38
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
39
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
40
+ /**
41
+ * Neutral env vars every child inherits. These are the ones shells/toolchains
42
+ * need to function but carry no secrets in a well-configured environment.
43
+ * Covers macOS, Linux, and Windows-relevant names.
44
+ */
45
+ const DEFAULT_ENV_ALLOWLIST = [
46
+ 'PATH',
47
+ 'HOME',
48
+ 'USER',
49
+ 'LOGNAME',
50
+ 'LANG',
51
+ 'LC_ALL',
52
+ 'LC_CTYPE',
53
+ 'LC_MESSAGES',
54
+ 'TZ',
55
+ 'NODE_ENV',
56
+ 'NODE_OPTIONS',
57
+ 'NODE_EXTRA_CA_CERTS',
58
+ 'TMPDIR',
59
+ 'TEMP',
60
+ 'TMP',
61
+ ];
62
+ /**
63
+ * Flapping window. If a transport error arrives within this many ms of the
64
+ * previous successful reconnect, we refuse to reconnect again — the underlying
65
+ * child is clearly unhealthy and the circuit breaker is a better place to
66
+ * handle it.
67
+ */
68
+ 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
+ export function buildChildEnv(config, hostEnv = process.env) {
78
+ const out = {};
79
+ for (const name of DEFAULT_ENV_ALLOWLIST) {
80
+ const v = hostEnv[name];
81
+ if (typeof v === 'string')
82
+ out[name] = v;
83
+ }
84
+ if (config.env_passthrough !== undefined) {
85
+ for (const name of config.env_passthrough) {
86
+ const v = hostEnv[name];
87
+ if (typeof v === 'string')
88
+ out[name] = v;
89
+ }
90
+ }
91
+ // Explicit config.env wins — operator typed these values deliberately.
92
+ for (const [k, v] of Object.entries(config.env)) {
93
+ out[k] = v;
94
+ }
95
+ return out;
96
+ }
97
+ export class DownstreamConnection {
98
+ config;
99
+ client = null;
100
+ /**
101
+ * Whether a reconnect has already been attempted in the CURRENT failure
102
+ * episode. Resets to `false` after a reconnect succeeds (so a later,
103
+ * unrelated failure also gets one shot). A flapping guard prevents this
104
+ * from turning into a reconnect loop.
105
+ */
106
+ reconnectAttempted = false;
107
+ /** Epoch ms of the last successful reconnect. Used by the flapping guard. */
108
+ lastReconnectAt = 0;
109
+ health = 'healthy';
110
+ constructor(config) {
111
+ this.config = config;
112
+ }
113
+ get name() {
114
+ return this.config.name;
115
+ }
116
+ get isHealthy() {
117
+ return this.health !== 'unhealthy';
118
+ }
119
+ async connect() {
120
+ if (this.client !== null)
121
+ return;
122
+ const transport = new StdioClientTransport({
123
+ command: this.config.command,
124
+ args: this.config.args,
125
+ env: buildChildEnv(this.config),
126
+ });
127
+ const client = new Client({ name: `rea-gateway-client:${this.config.name}`, version: '0.2.0' }, { capabilities: {} });
128
+ try {
129
+ await client.connect(transport);
130
+ this.client = client;
131
+ this.health = 'healthy';
132
+ }
133
+ catch (err) {
134
+ this.health = 'unhealthy';
135
+ throw new Error(`failed to connect to downstream "${this.config.name}" (${this.config.command}): ${err instanceof Error ? err.message : err}`);
136
+ }
137
+ }
138
+ async listTools() {
139
+ if (this.client === null)
140
+ throw new Error(`downstream "${this.config.name}" not connected`);
141
+ const result = (await this.client.listTools());
142
+ return Array.isArray(result.tools) ? result.tools : [];
143
+ }
144
+ /**
145
+ * Forward a tool call to the child process. On transport failure, attempt
146
+ * at most ONE reconnect per failure episode. After a successful reconnect
147
+ * the episode ends and future unrelated failures will be retried again;
148
+ * rapid back-to-back failures within the flap window are refused to avoid
149
+ * a reconnect loop (the circuit breaker takes over in that case).
150
+ */
151
+ async callTool(toolName, args) {
152
+ if (this.client === null) {
153
+ await this.connect();
154
+ }
155
+ try {
156
+ return await this.client.callTool({ name: toolName, arguments: args });
157
+ }
158
+ catch (err) {
159
+ const message = err instanceof Error ? err.message : String(err);
160
+ const withinFlapWindow = this.lastReconnectAt !== 0 &&
161
+ Date.now() - this.lastReconnectAt < RECONNECT_FLAP_WINDOW_MS;
162
+ if (!this.reconnectAttempted && !withinFlapWindow) {
163
+ this.reconnectAttempted = true;
164
+ this.health = 'degraded';
165
+ try {
166
+ await this.close();
167
+ await this.connect();
168
+ const result = await this.client.callTool({ name: toolName, arguments: args });
169
+ // Success: episode closed. Reset for the NEXT unrelated failure and
170
+ // stamp the reconnect time so flap-guard can refuse rapid repeats.
171
+ this.reconnectAttempted = false;
172
+ this.lastReconnectAt = Date.now();
173
+ return result;
174
+ }
175
+ catch (reconnectErr) {
176
+ this.health = 'unhealthy';
177
+ throw new Error(`downstream "${this.config.name}" unhealthy after one reconnect: ${reconnectErr instanceof Error ? reconnectErr.message : reconnectErr}`);
178
+ }
179
+ }
180
+ this.health = 'unhealthy';
181
+ throw new Error(`downstream "${this.config.name}" call failed: ${message}`);
182
+ }
183
+ }
184
+ async close() {
185
+ const c = this.client;
186
+ this.client = null;
187
+ if (c === null)
188
+ return;
189
+ try {
190
+ await c.close();
191
+ }
192
+ catch {
193
+ // Best-effort close — child may already be gone.
194
+ }
195
+ }
196
+ }
@@ -11,6 +11,16 @@ export interface AuditRecord {
11
11
  account_name?: string;
12
12
  error?: string;
13
13
  redacted_fields?: string[];
14
+ /**
15
+ * Free-form structured metadata attached by middleware or by callers emitting
16
+ * records through the public `@bookedsolid/rea/audit` helper. Used for first-class
17
+ * event semantics such as `codex.review` (head_sha, verdict, finding_count)
18
+ * and consumer-defined events like `helix.plan` / `helix.apply`.
19
+ *
20
+ * Keys and values must be JSON-serializable. No secrets, no redactable PII —
21
+ * the redaction middleware runs on `ctx.arguments`, not on metadata.
22
+ */
23
+ metadata?: Record<string, unknown>;
14
24
  hash: string;
15
25
  prev_hash: string;
16
26
  }
@@ -62,6 +62,20 @@ export function createAuditMiddleware(baseDir, policy) {
62
62
  if (ctx.redacted_fields?.length) {
63
63
  recordBase.redacted_fields = ctx.redacted_fields;
64
64
  }
65
+ // Attach caller-supplied metadata when the middleware context carries any.
66
+ // The `autonomy_level` key is reserved for internal bookkeeping (see above)
67
+ // and is excluded from the exported metadata payload.
68
+ if (ctx.metadata !== undefined) {
69
+ const exported = {};
70
+ for (const [k, v] of Object.entries(ctx.metadata)) {
71
+ if (k === 'autonomy_level')
72
+ continue;
73
+ exported[k] = v;
74
+ }
75
+ if (Object.keys(exported).length > 0) {
76
+ recordBase.metadata = exported;
77
+ }
78
+ }
65
79
  const hash = computeHash(recordBase);
66
80
  const record = { ...recordBase, hash };
67
81
  prevHash = hash;
@@ -1,11 +1,62 @@
1
1
  import type { Middleware } from './chain.js';
2
+ import { type SafeRegex } from '../redact-safe/match-timeout.js';
3
+ /**
4
+ * Known prompt injection phrases (lowercase for case-insensitive matching).
5
+ * These patterns are commonly used to override system instructions in tool
6
+ * descriptions or resource content returned by downstream MCP servers.
7
+ */
8
+ export declare const INJECTION_PHRASES: readonly string[];
9
+ /**
10
+ * Base64-token scanner regex. The only regex the injection middleware runs
11
+ * against untrusted payloads; wrapped in `SafeRegex` at middleware creation
12
+ * time so a catastrophic input cannot hang the event loop. See G3
13
+ * (`src/gateway/redact-safe/match-timeout.ts`).
14
+ */
15
+ export declare const INJECTION_BASE64_PATTERN: RegExp;
16
+ /**
17
+ * Base64 shape-validation regex used by `tryDecodeBase64`. Shorter inputs are
18
+ * rejected before we reach this test; the pattern itself is linear, so the
19
+ * SafeRegex wrap is purely a defense-in-depth measure.
20
+ */
21
+ export declare const INJECTION_BASE64_SHAPE: RegExp;
22
+ /**
23
+ * Audit metadata key for injection-scan regex timeouts. Multiple timeouts in
24
+ * one invocation append to an array under this key.
25
+ */
26
+ export declare const INJECTION_TIMEOUT_METADATA_KEY = "injection.regex_timeout";
27
+ export interface InjectionTimeoutEvent {
28
+ event: 'injection.regex_timeout';
29
+ pattern_source: 'default';
30
+ pattern_id: string;
31
+ input_bytes: number;
32
+ timeout_ms: number;
33
+ }
34
+ interface CompiledInjectionPatterns {
35
+ base64Token: SafeRegex;
36
+ base64Shape: SafeRegex;
37
+ }
38
+ export interface ScanForInjectionOptions {
39
+ onTimeout?: (patternId: string, input: string) => void;
40
+ }
41
+ /**
42
+ * Build compiled injection patterns with the provided timeout. Precompiled at
43
+ * middleware creation so the worker spawn is the only per-call overhead.
44
+ */
45
+ export declare function compileInjectionPatterns(timeoutMs: number, onTimeout?: (patternId: string, input: string) => void): CompiledInjectionPatterns;
2
46
  /**
3
47
  * Scan a string for known prompt injection phrases.
4
48
  * Also decodes base64 tokens and checks the decoded content.
5
49
  * Returns an array of matched phrase descriptions, empty if clean.
50
+ *
51
+ * The `safe` parameter carries precompiled SafeRegex wrappers; callers build
52
+ * it once via `compileInjectionPatterns`.
6
53
  */
7
- export declare function scanForInjection(input: string): string[];
54
+ export declare function scanForInjection(input: string, safe: CompiledInjectionPatterns): string[];
8
55
  export type InjectionAction = 'block' | 'warn';
56
+ export interface InjectionMiddlewareOptions {
57
+ /** Timeout budget for each regex call. Default 100ms. */
58
+ matchTimeoutMs?: number;
59
+ }
9
60
  /**
10
61
  * PostToolUse middleware: scans tool results for prompt injection patterns.
11
62
  *
@@ -18,5 +69,11 @@ export type InjectionAction = 'block' | 'warn';
18
69
  * SECURITY: Checking PostToolUse (after downstream execution, before the result
19
70
  * reaches the LLM) is the correct place to catch injection in tool descriptions
20
71
  * and resource content coming from potentially untrusted downstream servers.
72
+ *
73
+ * SECURITY (G3): The only regexes this middleware runs are wrapped in
74
+ * `SafeRegex` with a 100ms default per-call timeout. On timeout the scanner
75
+ * records an audit event and proceeds — blocking is governed by the literal
76
+ * substring checks (which have no ReDoS surface).
21
77
  */
22
- export declare function createInjectionMiddleware(action?: InjectionAction): Middleware;
78
+ export declare function createInjectionMiddleware(action?: InjectionAction, opts?: InjectionMiddlewareOptions): Middleware;
79
+ export {};
@@ -1,10 +1,11 @@
1
1
  import { InvocationStatus } from '../../policy/types.js';
2
+ import { wrapRegex } from '../redact-safe/match-timeout.js';
2
3
  /**
3
4
  * Known prompt injection phrases (lowercase for case-insensitive matching).
4
5
  * These patterns are commonly used to override system instructions in tool
5
6
  * descriptions or resource content returned by downstream MCP servers.
6
7
  */
7
- const INJECTION_PHRASES = [
8
+ export const INJECTION_PHRASES = [
8
9
  'ignore previous instructions',
9
10
  'disregard your',
10
11
  'your new instructions are',
@@ -16,15 +17,34 @@ const INJECTION_PHRASES = [
16
17
  'you are now a ',
17
18
  'you are now an ',
18
19
  ];
20
+ /**
21
+ * Base64-token scanner regex. The only regex the injection middleware runs
22
+ * against untrusted payloads; wrapped in `SafeRegex` at middleware creation
23
+ * time so a catastrophic input cannot hang the event loop. See G3
24
+ * (`src/gateway/redact-safe/match-timeout.ts`).
25
+ */
26
+ export const INJECTION_BASE64_PATTERN = /[A-Za-z0-9+/]{20,}={0,2}/g;
27
+ /**
28
+ * Base64 shape-validation regex used by `tryDecodeBase64`. Shorter inputs are
29
+ * rejected before we reach this test; the pattern itself is linear, so the
30
+ * SafeRegex wrap is purely a defense-in-depth measure.
31
+ */
32
+ export const INJECTION_BASE64_SHAPE = /^[A-Za-z0-9+/]+=*$/;
33
+ /**
34
+ * Audit metadata key for injection-scan regex timeouts. Multiple timeouts in
35
+ * one invocation append to an array under this key.
36
+ */
37
+ export const INJECTION_TIMEOUT_METADATA_KEY = 'injection.regex_timeout';
19
38
  /**
20
39
  * Decode a base64 string, returning the decoded text or null if decoding fails.
21
40
  * Only decodes if the input looks like base64 (64-char alphabet, length divisible by 4 or padded).
22
41
  */
23
- function tryDecodeBase64(input) {
42
+ function tryDecodeBase64(input, safe) {
24
43
  // Quick heuristic: must be at least 20 chars and use only base64 chars
25
44
  if (input.length < 20)
26
45
  return null;
27
- if (!/^[A-Za-z0-9+/]+=*$/.test(input))
46
+ const shape = safe.base64Shape.test(input);
47
+ if (shape.timedOut || !shape.matched)
28
48
  return null;
29
49
  try {
30
50
  return Buffer.from(input, 'base64').toString('utf8');
@@ -33,26 +53,52 @@ function tryDecodeBase64(input) {
33
53
  return null;
34
54
  }
35
55
  }
56
+ /**
57
+ * Build compiled injection patterns with the provided timeout. Precompiled at
58
+ * middleware creation so the worker spawn is the only per-call overhead.
59
+ */
60
+ export function compileInjectionPatterns(timeoutMs, onTimeout) {
61
+ return {
62
+ base64Token: wrapRegex(INJECTION_BASE64_PATTERN, {
63
+ timeoutMs,
64
+ ...(onTimeout
65
+ ? { onTimeout: (_p, i) => onTimeout('INJECTION_BASE64_PATTERN', i) }
66
+ : {}),
67
+ }),
68
+ base64Shape: wrapRegex(INJECTION_BASE64_SHAPE, {
69
+ timeoutMs,
70
+ ...(onTimeout
71
+ ? { onTimeout: (_p, i) => onTimeout('INJECTION_BASE64_SHAPE', i) }
72
+ : {}),
73
+ }),
74
+ };
75
+ }
36
76
  /**
37
77
  * Scan a string for known prompt injection phrases.
38
78
  * Also decodes base64 tokens and checks the decoded content.
39
79
  * Returns an array of matched phrase descriptions, empty if clean.
80
+ *
81
+ * The `safe` parameter carries precompiled SafeRegex wrappers; callers build
82
+ * it once via `compileInjectionPatterns`.
40
83
  */
41
- export function scanForInjection(input) {
84
+ export function scanForInjection(input, safe) {
42
85
  if (!input || typeof input !== 'string')
43
86
  return [];
44
87
  const lower = input.toLowerCase();
45
88
  const matches = [];
46
- // Check literal phrases
89
+ // Check literal phrases (indexOf — no regex, no ReDoS surface).
47
90
  for (const phrase of INJECTION_PHRASES) {
48
91
  if (lower.includes(phrase)) {
49
92
  matches.push(`literal: "${phrase}"`);
50
93
  }
51
94
  }
52
- // Check base64-encoded variants — scan word-like tokens that look like base64
53
- const base64Tokens = input.match(/[A-Za-z0-9+/]{20,}={0,2}/g) ?? [];
95
+ // Check base64-encoded variants — scan word-like tokens that look like
96
+ // base64. The regex match is bounded via SafeRegex (timeout + hard worker
97
+ // kill).
98
+ const tokenResult = safe.base64Token.matchAll(input);
99
+ const base64Tokens = tokenResult.matches;
54
100
  for (const token of base64Tokens) {
55
- const decoded = tryDecodeBase64(token);
101
+ const decoded = tryDecodeBase64(token, safe);
56
102
  if (!decoded)
57
103
  continue;
58
104
  const decodedLower = decoded.toLowerCase();
@@ -69,23 +115,45 @@ export function scanForInjection(input) {
69
115
  * Scan an unknown value recursively, collecting all injection matches.
70
116
  * Walks strings, arrays, and plain objects.
71
117
  */
72
- function scanValue(value, matches) {
118
+ function scanValue(value, matches, safe) {
73
119
  if (typeof value === 'string') {
74
- matches.push(...scanForInjection(value));
120
+ matches.push(...scanForInjection(value, safe));
75
121
  return;
76
122
  }
77
123
  if (Array.isArray(value)) {
78
124
  for (const item of value) {
79
- scanValue(item, matches);
125
+ scanValue(item, matches, safe);
80
126
  }
81
127
  return;
82
128
  }
83
129
  if (value !== null && typeof value === 'object') {
84
130
  for (const v of Object.values(value)) {
85
- scanValue(v, matches);
131
+ scanValue(v, matches, safe);
86
132
  }
87
133
  }
88
134
  }
135
+ /**
136
+ * Record a regex-timeout event on `ctx.metadata`. Array-valued so multiple
137
+ * timeouts in one invocation are all recorded.
138
+ *
139
+ * SECURITY: The input text is NEVER written into metadata — only `input_bytes`.
140
+ */
141
+ function recordInjectionTimeout(ctx, patternId, inputBytes, timeoutMs) {
142
+ const ev = {
143
+ event: 'injection.regex_timeout',
144
+ pattern_source: 'default',
145
+ pattern_id: patternId,
146
+ input_bytes: inputBytes,
147
+ timeout_ms: timeoutMs,
148
+ };
149
+ const existing = ctx.metadata[INJECTION_TIMEOUT_METADATA_KEY];
150
+ if (Array.isArray(existing)) {
151
+ existing.push(ev);
152
+ }
153
+ else {
154
+ ctx.metadata[INJECTION_TIMEOUT_METADATA_KEY] = [ev];
155
+ }
156
+ }
89
157
  /**
90
158
  * PostToolUse middleware: scans tool results for prompt injection patterns.
91
159
  *
@@ -98,15 +166,24 @@ function scanValue(value, matches) {
98
166
  * SECURITY: Checking PostToolUse (after downstream execution, before the result
99
167
  * reaches the LLM) is the correct place to catch injection in tool descriptions
100
168
  * and resource content coming from potentially untrusted downstream servers.
169
+ *
170
+ * SECURITY (G3): The only regexes this middleware runs are wrapped in
171
+ * `SafeRegex` with a 100ms default per-call timeout. On timeout the scanner
172
+ * records an audit event and proceeds — blocking is governed by the literal
173
+ * substring checks (which have no ReDoS surface).
101
174
  */
102
- export function createInjectionMiddleware(action = 'block') {
175
+ export function createInjectionMiddleware(action = 'block', opts = {}) {
176
+ const timeoutMs = opts.matchTimeoutMs ?? 100;
103
177
  return async (ctx, next) => {
104
178
  await next();
105
179
  // Only scan if we have a result to inspect
106
180
  if (ctx.result == null)
107
181
  return;
182
+ const safe = compileInjectionPatterns(timeoutMs, (patternId, input) => {
183
+ recordInjectionTimeout(ctx, patternId, Buffer.byteLength(input, 'utf8'), timeoutMs);
184
+ });
108
185
  const matches = [];
109
- scanValue(ctx.result, matches);
186
+ scanValue(ctx.result, matches, safe);
110
187
  if (matches.length === 0)
111
188
  return;
112
189
  // Deduplicate matches