@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.
- package/.husky/commit-msg +130 -0
- package/.husky/pre-push +128 -0
- package/README.md +5 -5
- package/agents/codex-adversarial.md +23 -8
- package/commands/codex-review.md +2 -2
- package/dist/audit/append.d.ts +62 -0
- package/dist/audit/append.js +189 -0
- package/dist/audit/codex-event.d.ts +28 -0
- package/dist/audit/codex-event.js +15 -0
- package/dist/cli/doctor.d.ts +60 -1
- package/dist/cli/doctor.js +459 -20
- package/dist/cli/index.js +35 -5
- package/dist/cli/init.d.ts +13 -0
- package/dist/cli/init.js +278 -67
- package/dist/cli/install/canonical.d.ts +43 -0
- package/dist/cli/install/canonical.js +101 -0
- package/dist/cli/install/claude-md.d.ts +48 -0
- package/dist/cli/install/claude-md.js +93 -0
- package/dist/cli/install/commit-msg.d.ts +30 -0
- package/dist/cli/install/commit-msg.js +102 -0
- package/dist/cli/install/copy.d.ts +169 -0
- package/dist/cli/install/copy.js +455 -0
- package/dist/cli/install/fs-safe.d.ts +91 -0
- package/dist/cli/install/fs-safe.js +347 -0
- package/dist/cli/install/manifest-io.d.ts +12 -0
- package/dist/cli/install/manifest-io.js +44 -0
- package/dist/cli/install/manifest-schema.d.ts +83 -0
- package/dist/cli/install/manifest-schema.js +80 -0
- package/dist/cli/install/reagent.d.ts +59 -0
- package/dist/cli/install/reagent.js +160 -0
- package/dist/cli/install/settings-merge.d.ts +91 -0
- package/dist/cli/install/settings-merge.js +239 -0
- package/dist/cli/install/sha.d.ts +9 -0
- package/dist/cli/install/sha.js +21 -0
- package/dist/cli/serve.d.ts +11 -0
- package/dist/cli/serve.js +72 -6
- package/dist/cli/upgrade.d.ts +67 -0
- package/dist/cli/upgrade.js +509 -0
- package/dist/gateway/downstream-pool.d.ts +39 -0
- package/dist/gateway/downstream-pool.js +93 -0
- package/dist/gateway/downstream.d.ts +80 -0
- package/dist/gateway/downstream.js +196 -0
- package/dist/gateway/middleware/audit-types.d.ts +10 -0
- package/dist/gateway/middleware/audit.js +14 -0
- package/dist/gateway/middleware/injection.d.ts +59 -2
- package/dist/gateway/middleware/injection.js +91 -14
- package/dist/gateway/middleware/kill-switch.d.ts +20 -5
- package/dist/gateway/middleware/kill-switch.js +57 -35
- package/dist/gateway/middleware/redact.d.ts +83 -6
- package/dist/gateway/middleware/redact.js +133 -46
- package/dist/gateway/observability/codex-probe.d.ts +110 -0
- package/dist/gateway/observability/codex-probe.js +234 -0
- package/dist/gateway/observability/codex-telemetry.d.ts +93 -0
- package/dist/gateway/observability/codex-telemetry.js +221 -0
- package/dist/gateway/redact-safe/match-timeout.d.ts +83 -0
- package/dist/gateway/redact-safe/match-timeout.js +179 -0
- package/dist/gateway/reviewers/claude-self.d.ts +99 -0
- package/dist/gateway/reviewers/claude-self.js +316 -0
- package/dist/gateway/reviewers/codex.d.ts +64 -0
- package/dist/gateway/reviewers/codex.js +80 -0
- package/dist/gateway/reviewers/select.d.ts +64 -0
- package/dist/gateway/reviewers/select.js +102 -0
- package/dist/gateway/reviewers/types.d.ts +85 -0
- package/dist/gateway/reviewers/types.js +14 -0
- package/dist/gateway/server.d.ts +51 -0
- package/dist/gateway/server.js +258 -0
- package/dist/gateway/session.d.ts +9 -0
- package/dist/gateway/session.js +17 -0
- package/dist/policy/loader.d.ts +59 -0
- package/dist/policy/loader.js +65 -0
- package/dist/policy/profiles.d.ts +80 -0
- package/dist/policy/profiles.js +94 -0
- package/dist/policy/types.d.ts +38 -0
- package/dist/registry/loader.d.ts +98 -0
- package/dist/registry/loader.js +153 -0
- package/dist/registry/types.d.ts +44 -0
- package/dist/registry/types.js +6 -0
- package/dist/scripts/read-policy-field.d.ts +36 -0
- package/dist/scripts/read-policy-field.js +96 -0
- package/hooks/push-review-gate.sh +627 -17
- package/package.json +13 -2
- package/profiles/bst-internal-no-codex.yaml +40 -0
- package/profiles/bst-internal.yaml +23 -0
- package/profiles/client-engagement.yaml +23 -0
- package/profiles/lit-wc.yaml +17 -0
- package/profiles/minimal.yaml +11 -0
- package/profiles/open-source-no-codex.yaml +33 -0
- package/profiles/open-source.yaml +18 -0
- package/scripts/lint-safe-regex.mjs +78 -0
- 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
|
-
|
|
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
|
|
53
|
-
|
|
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
|