@intutic/mcp-governance-proxy 1.9.0 → 1.10.1
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/README.md +28 -0
- package/dist/anomaly/detectors.d.ts +129 -0
- package/dist/anomaly/detectors.d.ts.map +1 -0
- package/dist/anomaly/detectors.js +368 -0
- package/dist/anomaly/detectors.js.map +1 -0
- package/dist/anomaly/index.d.ts +52 -0
- package/dist/anomaly/index.d.ts.map +1 -0
- package/dist/anomaly/index.js +71 -0
- package/dist/anomaly/index.js.map +1 -0
- package/dist/config.d.ts +107 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +129 -6
- package/dist/config.js.map +1 -1
- package/dist/daemon/healthMonitor.d.ts.map +1 -1
- package/dist/daemon/healthMonitor.js +45 -46
- package/dist/daemon/healthMonitor.js.map +1 -1
- package/dist/daemon/index.d.ts +15 -1
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +78 -9
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/instrumentation.d.ts +20 -0
- package/dist/daemon/instrumentation.d.ts.map +1 -0
- package/dist/daemon/instrumentation.js +61 -0
- package/dist/daemon/instrumentation.js.map +1 -0
- package/dist/daemon/policyCache.d.ts +83 -0
- package/dist/daemon/policyCache.d.ts.map +1 -1
- package/dist/daemon/policyCache.js +280 -11
- package/dist/daemon/policyCache.js.map +1 -1
- package/dist/daemon/statusReporter.d.ts +12 -0
- package/dist/daemon/statusReporter.d.ts.map +1 -0
- package/dist/daemon/statusReporter.js +130 -0
- package/dist/daemon/statusReporter.js.map +1 -0
- package/dist/daemon/telemetryBatcher.d.ts +3 -1
- package/dist/daemon/telemetryBatcher.d.ts.map +1 -1
- package/dist/daemon/telemetryBatcher.js +56 -13
- package/dist/daemon/telemetryBatcher.js.map +1 -1
- package/dist/daemonClient.d.ts +1 -1
- package/dist/daemonClient.d.ts.map +1 -1
- package/dist/daemonClient.js +0 -2
- package/dist/daemonClient.js.map +1 -1
- package/dist/dlp.d.ts +19 -0
- package/dist/dlp.d.ts.map +1 -1
- package/dist/dlp.js +95 -18
- package/dist/dlp.js.map +1 -1
- package/dist/emitter.d.ts +40 -2
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +27 -5
- package/dist/emitter.js.map +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/dist/injection.d.ts +46 -0
- package/dist/injection.d.ts.map +1 -0
- package/dist/injection.js +147 -0
- package/dist/injection.js.map +1 -0
- package/dist/interceptor.d.ts +131 -1
- package/dist/interceptor.d.ts.map +1 -1
- package/dist/interceptor.js +301 -5
- package/dist/interceptor.js.map +1 -1
- package/dist/policy.d.ts +110 -2
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +206 -5
- package/dist/policy.js.map +1 -1
- package/dist/proxy.d.ts +205 -0
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +572 -65
- package/dist/proxy.js.map +1 -1
- package/dist/remoteBridge.d.ts +51 -0
- package/dist/remoteBridge.d.ts.map +1 -0
- package/dist/remoteBridge.js +182 -0
- package/dist/remoteBridge.js.map +1 -0
- package/dist/session.d.ts +158 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +206 -0
- package/dist/session.js.map +1 -0
- package/dist/sessionScope.d.ts +51 -0
- package/dist/sessionScope.d.ts.map +1 -0
- package/dist/sessionScope.js +94 -0
- package/dist/sessionScope.js.map +1 -0
- package/dist/sessionStore.d.ts +69 -0
- package/dist/sessionStore.d.ts.map +1 -0
- package/dist/sessionStore.js +143 -0
- package/dist/sessionStore.js.map +1 -0
- package/dist/tofu.d.ts +112 -0
- package/dist/tofu.d.ts.map +1 -0
- package/dist/tofu.js +180 -0
- package/dist/tofu.js.map +1 -0
- package/dist/toolPoison.d.ts +32 -0
- package/dist/toolPoison.d.ts.map +1 -0
- package/dist/toolPoison.js +81 -0
- package/dist/toolPoison.js.map +1 -0
- package/dist/valkeyErrors.d.ts +17 -0
- package/dist/valkeyErrors.d.ts.map +1 -0
- package/dist/valkeyErrors.js +24 -0
- package/dist/valkeyErrors.js.map +1 -0
- package/dist/wasm/context.d.ts +57 -0
- package/dist/wasm/context.d.ts.map +1 -0
- package/dist/wasm/context.js +59 -0
- package/dist/wasm/context.js.map +1 -0
- package/dist/wasm/hostImports.d.ts +43 -0
- package/dist/wasm/hostImports.d.ts.map +1 -0
- package/dist/wasm/hostImports.js +158 -0
- package/dist/wasm/hostImports.js.map +1 -0
- package/dist/wasm/loader.d.ts +95 -0
- package/dist/wasm/loader.d.ts.map +1 -0
- package/dist/wasm/loader.js +198 -0
- package/dist/wasm/loader.js.map +1 -0
- package/dist/wasm/referencedFiles.d.ts +115 -0
- package/dist/wasm/referencedFiles.d.ts.map +1 -0
- package/dist/wasm/referencedFiles.js +366 -0
- package/dist/wasm/referencedFiles.js.map +1 -0
- package/dist/wasm/runner.d.ts +91 -0
- package/dist/wasm/runner.d.ts.map +1 -0
- package/dist/wasm/runner.js +308 -0
- package/dist/wasm/runner.js.map +1 -0
- package/dist/wasm/watch.d.ts +5 -0
- package/dist/wasm/watch.d.ts.map +1 -0
- package/dist/wasm/watch.js +54 -0
- package/dist/wasm/watch.js.map +1 -0
- package/dist/wasm/worker.d.ts +39 -0
- package/dist/wasm/worker.d.ts.map +1 -0
- package/dist/wasm/worker.js +245 -0
- package/dist/wasm/worker.js.map +1 -0
- package/package.json +14 -4
package/dist/proxy.js
CHANGED
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
* Harness ↔ [McpGovernanceProxy as MCP Server] ↔ Control Plane REST API
|
|
10
10
|
* Exposes governance tools: intutic_governance_status, intutic_list_sops, intutic_list_incidents.
|
|
11
11
|
*
|
|
12
|
+
* Architecture (remote bridge mode — `--remote-url`, see remoteBridge.ts):
|
|
13
|
+
* Harness stdin → [McpGovernanceProxy] → remote MCP server (HTTP/SSE)
|
|
14
|
+
* Remote MCP server → [McpGovernanceProxy] → Harness stdout
|
|
15
|
+
* The harness still spawns this proxy as an ordinary stdio child process —
|
|
16
|
+
* only the UPSTREAM side changes, from a spawned child's stdin/stdout to an
|
|
17
|
+
* MCP SDK client transport talking HTTP/SSE. `handleHarnessLine` (request
|
|
18
|
+
* direction) and `handleServerLine` (response direction) are the exact same
|
|
19
|
+
* functions proxy mode uses; remoteBridge.ts only supplies a different
|
|
20
|
+
* upstream and a different `forward`.
|
|
21
|
+
*
|
|
12
22
|
* CRITICAL: Never write to process.stdout except for valid JSON-RPC frames.
|
|
13
23
|
* All logging MUST go to process.stderr via @intutic/logger.
|
|
14
24
|
*
|
|
@@ -22,9 +32,17 @@ import { createStderrLogger as createLogger } from './stderrLog.js';
|
|
|
22
32
|
import { PolicyClient } from './policy.js';
|
|
23
33
|
import { GovernanceEmitter } from './emitter.js';
|
|
24
34
|
import { ToolCallInterceptor } from './interceptor.js';
|
|
35
|
+
import { redactText as redactMcpText } from './dlp.js';
|
|
36
|
+
import { scanText, injectionSeverity, setDynamicInjectionPatterns } from './injection.js';
|
|
37
|
+
import { toolPoisoning, dlpEscalation } from './anomaly/index.js';
|
|
38
|
+
import { SessionState } from './session.js';
|
|
39
|
+
import { ValkeySessionStore } from './sessionStore.js';
|
|
40
|
+
import { WasmRunner } from './wasm/runner.js';
|
|
41
|
+
import { checkTofu, decideTofuAction } from './tofu.js';
|
|
25
42
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
26
43
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
27
44
|
import { z } from 'zod';
|
|
45
|
+
import { watchWasmDir } from './wasm/watch.js';
|
|
28
46
|
const log = createLogger('mcp-governance-proxy');
|
|
29
47
|
/**
|
|
30
48
|
* Build a JSON-RPC 2.0 error response for a blocked tool call.
|
|
@@ -45,17 +63,380 @@ function buildBlockResponse(id, reason) {
|
|
|
45
63
|
function writeFrame(frame) {
|
|
46
64
|
process.stdout.write(JSON.stringify(frame) + '\n');
|
|
47
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Inspect one line of real-server output before it reaches the harness.
|
|
68
|
+
*
|
|
69
|
+
* This is the seam the byte-for-byte pipe never had: the request direction has
|
|
70
|
+
* been scanned since the interceptor existed, while the real server's stdout —
|
|
71
|
+
* tool results, resource reads, every tools/list — streamed into the agent's
|
|
72
|
+
* context untouched. A result carrying a credential put it straight into
|
|
73
|
+
* model-visible context; a `redact` Decision variant and a `tool_redacted`
|
|
74
|
+
* event existed as declared types with no producer. This function is the
|
|
75
|
+
* producer.
|
|
76
|
+
*
|
|
77
|
+
* Redaction, not blocking, on results: the call already executed, so refusing
|
|
78
|
+
* the response protects nothing — keeping the secret out of the agent's
|
|
79
|
+
* context is the only thing still at stake. If the redacted result no longer
|
|
80
|
+
* parses (a secret that spanned JSON syntax), the whole result is withheld
|
|
81
|
+
* and replaced with an error naming why — forwarding either the original or
|
|
82
|
+
* a broken body would be worse, the same fail-closed reparse rule the Rust
|
|
83
|
+
* proxy applies to non-streaming bodies.
|
|
84
|
+
*
|
|
85
|
+
* tools/list curation (the Uber-gateway mechanism): when the workspace
|
|
86
|
+
* declares an additive allowlist, tools outside it are removed from the
|
|
87
|
+
* listing — an agent that never sees a tool does not hallucinate calls to
|
|
88
|
+
* it, and the call-time block in the interceptor stays as the enforcement
|
|
89
|
+
* backstop. Operator description overrides apply to what remains, which is
|
|
90
|
+
* also the counter to a poisoned upstream description — the pin detects the
|
|
91
|
+
* change; the override controls what the agent actually reads.
|
|
92
|
+
*/
|
|
93
|
+
export function processServerLine(raw, pending, allowedTools, overrides, injectionAction = 'warn', injectionPatterns = []) {
|
|
94
|
+
// Workspace-supplied injection patterns (TD-436), on top of the floor.
|
|
95
|
+
// Idempotent when the list has not changed, so this is cheap per line.
|
|
96
|
+
setDynamicInjectionPatterns(injectionPatterns);
|
|
97
|
+
const trimmed = raw.trim();
|
|
98
|
+
if (!trimmed)
|
|
99
|
+
return { line: raw };
|
|
100
|
+
let msg;
|
|
101
|
+
try {
|
|
102
|
+
msg = JSON.parse(trimmed);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return { line: raw }; // not JSON-RPC (or partial) — forward untouched
|
|
106
|
+
}
|
|
107
|
+
if (msg.id === undefined || msg.id === null)
|
|
108
|
+
return { line: raw }; // notification
|
|
109
|
+
const req = pending.get(msg.id);
|
|
110
|
+
if (!req)
|
|
111
|
+
return { line: raw };
|
|
112
|
+
pending.delete(msg.id);
|
|
113
|
+
if (!msg.result || typeof msg.result !== 'object')
|
|
114
|
+
return { line: raw };
|
|
115
|
+
if (req.method === 'tools/call' || req.method === 'resources/read') {
|
|
116
|
+
const serialized = JSON.stringify(msg.result);
|
|
117
|
+
const { redacted, findings } = redactMcpText(serialized);
|
|
118
|
+
const label = req.toolName ?? req.method;
|
|
119
|
+
// DLP redaction runs FIRST — a secret must never reach the injection
|
|
120
|
+
// scanner (or anything downstream of it) unredacted. If a match spans
|
|
121
|
+
// JSON syntax and the redacted text no longer parses, this withholds the
|
|
122
|
+
// whole result exactly as before Phase 1 existed; the injection scanner
|
|
123
|
+
// never sees a body that DLP already decided to withhold.
|
|
124
|
+
let resultText = serialized;
|
|
125
|
+
let resultObj = msg.result;
|
|
126
|
+
let redactedTool;
|
|
127
|
+
let redactions;
|
|
128
|
+
if (findings.length > 0) {
|
|
129
|
+
redactedTool = label;
|
|
130
|
+
redactions = findings.map((f) => f.description);
|
|
131
|
+
try {
|
|
132
|
+
resultObj = JSON.parse(redacted);
|
|
133
|
+
resultText = redacted;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
const withheld = {
|
|
137
|
+
jsonrpc: '2.0',
|
|
138
|
+
id: msg.id,
|
|
139
|
+
error: {
|
|
140
|
+
code: -32603,
|
|
141
|
+
message: `[Intutic Governance] Result withheld: it contained sensitive data ` +
|
|
142
|
+
`whose redaction did not survive re-parsing. The tool ran; its output ` +
|
|
143
|
+
`was not delivered. (${findings.map((f) => f.description).join('; ')})`,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
return { line: JSON.stringify(withheld), redactedTool, redactions };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Injection scan — response direction. Runs on the (already
|
|
150
|
+
// DLP-redacted, if applicable) result text: a tool result or
|
|
151
|
+
// resources/read body is exactly the "one node's output becomes the
|
|
152
|
+
// next node's input" untrusted-content case injection.rs's module doc
|
|
153
|
+
// describes.
|
|
154
|
+
const injectionPatterns = scanText(resultText);
|
|
155
|
+
let injectionFindings;
|
|
156
|
+
if (injectionPatterns.length > 0) {
|
|
157
|
+
injectionFindings = [{ source: 'tool_result', toolName: label, patterns: injectionPatterns }];
|
|
158
|
+
if (injectionAction === 'block') {
|
|
159
|
+
// Response-side block never claims the CALL was refused — it
|
|
160
|
+
// already ran. Only the delivered output is withheld, worded the
|
|
161
|
+
// same way the DLP reparse-withheld frame above is.
|
|
162
|
+
const withheldInjection = {
|
|
163
|
+
jsonrpc: '2.0',
|
|
164
|
+
id: msg.id,
|
|
165
|
+
error: {
|
|
166
|
+
code: -32603,
|
|
167
|
+
message: `[Intutic Governance] Result withheld: it triggered prompt-injection ` +
|
|
168
|
+
`pattern(s) (${injectionPatterns.join(', ')}). The tool call already ran; ` +
|
|
169
|
+
`its output was not delivered.`,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
line: JSON.stringify(withheldInjection),
|
|
174
|
+
redactedTool,
|
|
175
|
+
redactions,
|
|
176
|
+
injectionFindings,
|
|
177
|
+
injectionBlocked: true,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// 'warn' (default): report only, fall through and deliver the result.
|
|
181
|
+
}
|
|
182
|
+
// Phase 2's dlp_escalation: ≥3 distinct DLP-redacted pattern types in
|
|
183
|
+
// this one response is a credential sweep, not a single mistake — see
|
|
184
|
+
// `dlpEscalation`'s own doc comment for why this only escalates the
|
|
185
|
+
// event severity below and never gates a new block.
|
|
186
|
+
const dlpEscalationFinding = redactions ? dlpEscalation(redactions) : null;
|
|
187
|
+
if (findings.length === 0 && injectionFindings === undefined && !dlpEscalationFinding)
|
|
188
|
+
return { line: raw };
|
|
189
|
+
msg.result = resultObj;
|
|
190
|
+
return {
|
|
191
|
+
line: JSON.stringify(msg),
|
|
192
|
+
redactedTool,
|
|
193
|
+
redactions,
|
|
194
|
+
injectionFindings,
|
|
195
|
+
dlpEscalationReason: dlpEscalationFinding?.reason,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (req.method === 'tools/list') {
|
|
199
|
+
const tools = msg.result['tools'];
|
|
200
|
+
if (!Array.isArray(tools))
|
|
201
|
+
return { line: raw };
|
|
202
|
+
let hidden = 0;
|
|
203
|
+
let overridden = 0;
|
|
204
|
+
let kept = tools;
|
|
205
|
+
if (allowedTools.length > 0) {
|
|
206
|
+
kept = kept.filter((t) => {
|
|
207
|
+
const keep = typeof t['name'] === 'string' && allowedTools.includes(t['name']);
|
|
208
|
+
if (!keep)
|
|
209
|
+
hidden += 1;
|
|
210
|
+
return keep;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
for (const t of kept) {
|
|
214
|
+
const name = t['name'];
|
|
215
|
+
if (typeof name === 'string' && overrides[name] !== undefined) {
|
|
216
|
+
t['description'] = overrides[name];
|
|
217
|
+
overridden += 1;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Injection scan — tool descriptions, POST-curation (after allowlist
|
|
221
|
+
// filtering and operator overrides above, so a description an operator
|
|
222
|
+
// already replaced is scanned as what the agent will actually read, not
|
|
223
|
+
// as the upstream server's original text). Report-only in v1: curation
|
|
224
|
+
// and TOFU already govern what the agent sees in a tools/list response,
|
|
225
|
+
// so a description match here never removes or blocks a listing — it
|
|
226
|
+
// only produces an `injection_detected` event for the caller to emit.
|
|
227
|
+
let injectionFindings;
|
|
228
|
+
for (const t of kept) {
|
|
229
|
+
const name = t['name'];
|
|
230
|
+
const description = t['description'];
|
|
231
|
+
if (typeof name !== 'string' || typeof description !== 'string')
|
|
232
|
+
continue;
|
|
233
|
+
const patterns = scanText(description);
|
|
234
|
+
if (patterns.length === 0)
|
|
235
|
+
continue;
|
|
236
|
+
injectionFindings ??= [];
|
|
237
|
+
injectionFindings.push({ source: 'tool_description', toolName: name, patterns });
|
|
238
|
+
}
|
|
239
|
+
// Phase 2's tool_poisoning — same post-curation tool set the injection
|
|
240
|
+
// scan just ran over, a different pattern set (toolPoison.ts's 7
|
|
241
|
+
// description-poisoning patterns, tuned for documentation-shaped
|
|
242
|
+
// payloads rather than conversational jailbreak phrasing).
|
|
243
|
+
const toolPoisoningFinding = toolPoisoning(kept
|
|
244
|
+
.filter((t) => typeof t['name'] === 'string')
|
|
245
|
+
.map((t) => ({ name: t['name'], description: typeof t['description'] === 'string' ? t['description'] : undefined })));
|
|
246
|
+
// toolsListTools/toolsListMsgId are set on EVERY tools/list response,
|
|
247
|
+
// curated or not — TOFU pinning (tofu.ts) needs the full post-curation
|
|
248
|
+
// tool set regardless of whether allowlist filtering or description
|
|
249
|
+
// overrides touched it this time.
|
|
250
|
+
if (hidden === 0 && overridden === 0) {
|
|
251
|
+
return {
|
|
252
|
+
line: raw,
|
|
253
|
+
toolsListTools: kept,
|
|
254
|
+
toolsListMsgId: msg.id,
|
|
255
|
+
injectionFindings,
|
|
256
|
+
toolPoisoningReason: toolPoisoningFinding?.reason,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
msg.result['tools'] = kept;
|
|
260
|
+
return {
|
|
261
|
+
line: JSON.stringify(msg),
|
|
262
|
+
curated: { hidden, overridden },
|
|
263
|
+
toolsListTools: kept,
|
|
264
|
+
toolsListMsgId: msg.id,
|
|
265
|
+
injectionFindings,
|
|
266
|
+
toolPoisoningReason: toolPoisoningFinding?.reason,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return { line: raw };
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Handle one harness→upstream line — shared, byte-for-byte, between stdio
|
|
273
|
+
* proxy mode (`McpGovernanceProxy.runProxy`, below) and the remote HTTP/SSE
|
|
274
|
+
* bridge (`remoteBridge.ts`'s `runRemoteProxy`): parse JSON-RPC, register any
|
|
275
|
+
* `tools/list`/`resources/read` request so its response gets inspected on the
|
|
276
|
+
* way back (see `processServerLine`), and run every `tools/call` through the
|
|
277
|
+
* SAME `interceptor.decide()` call this proxy has always used. `forward` is
|
|
278
|
+
* the ONLY thing that differs by upstream transport — stdio mode writes to
|
|
279
|
+
* the spawned child process's stdin, remote bridge mode calls the MCP SDK
|
|
280
|
+
* transport's `send()` — so pulling it out as a parameter, rather than
|
|
281
|
+
* forking this whole function, is what keeps governance identical across
|
|
282
|
+
* both upstream transports instead of two copies that could quietly drift.
|
|
283
|
+
*
|
|
284
|
+
* A free function (like `processServerLine`), not a class method, precisely
|
|
285
|
+
* so `remoteBridge.ts` can call it without needing a `McpGovernanceProxy`
|
|
286
|
+
* instance shaped for the stdio child-process lifecycle it doesn't have.
|
|
287
|
+
*/
|
|
288
|
+
export function handleHarnessLine(line, pending, interceptor, forward, session) {
|
|
289
|
+
const trimmed = line.trim();
|
|
290
|
+
if (!trimmed)
|
|
291
|
+
return;
|
|
292
|
+
let msg;
|
|
293
|
+
try {
|
|
294
|
+
msg = JSON.parse(trimmed);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
log.warn({ action: 'parse_error', line: trimmed.slice(0, 100) }, 'Failed to parse JSON-RPC line');
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// Register responses that need inspecting on the way back.
|
|
301
|
+
if (msg.id !== null && msg.id !== undefined) {
|
|
302
|
+
if (msg.method === 'tools/list') {
|
|
303
|
+
pending.set(msg.id, { method: 'tools/list' });
|
|
304
|
+
}
|
|
305
|
+
else if (msg.method === 'resources/read') {
|
|
306
|
+
pending.set(msg.id, { method: 'resources/read' });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Intercept tools/call
|
|
310
|
+
if (msg.method === 'tools/call') {
|
|
311
|
+
const params = msg.params;
|
|
312
|
+
const toolName = params?.name ?? '<unknown>';
|
|
313
|
+
const toolInput = params?.arguments ?? {};
|
|
314
|
+
// Run governance check asynchronously
|
|
315
|
+
interceptor.decide(toolName, toolInput).then((decision) => {
|
|
316
|
+
if (decision.action === 'block') {
|
|
317
|
+
log.warn({ action: 'tool_blocked', toolName, reason: decision.reason }, 'Tool call blocked by governance proxy');
|
|
318
|
+
writeFrame(buildBlockResponse(msg.id, decision.reason));
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
// Allow: the response now needs inspecting on the way back —
|
|
322
|
+
// registered BEFORE forwarding, or a fast server could answer
|
|
323
|
+
// into the gap.
|
|
324
|
+
if (msg.id !== null && msg.id !== undefined) {
|
|
325
|
+
pending.set(msg.id, { method: 'tools/call', toolName });
|
|
326
|
+
}
|
|
327
|
+
// Phase 2: record into the session's rolling tool-call sequence only
|
|
328
|
+
// NOW that the call is actually allowed — a blocked call must not
|
|
329
|
+
// count toward consecutive_repeat/ping_pong_cycle/landmark_cycle/
|
|
330
|
+
// tool_diversity_collapse, or an agent could be penalized for calls
|
|
331
|
+
// that never ran.
|
|
332
|
+
session?.recordCall(toolName);
|
|
333
|
+
forward(line);
|
|
334
|
+
}
|
|
335
|
+
}).catch((err) => {
|
|
336
|
+
// Governance check failed — fail-open: forward to real server
|
|
337
|
+
log.error({ action: 'interceptor_error', err: err.message }, 'Interceptor error — failing open');
|
|
338
|
+
forward(line);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
// Non-tools/call messages pass through immediately
|
|
343
|
+
forward(line);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
48
346
|
export class McpGovernanceProxy {
|
|
49
347
|
config;
|
|
50
348
|
policy;
|
|
51
349
|
emitter;
|
|
52
350
|
interceptor;
|
|
351
|
+
/**
|
|
352
|
+
* Phase 2's session state — the tool-call sequence and per-detector reask
|
|
353
|
+
* counters. ONE instance per proxy process, shared with the interceptor
|
|
354
|
+
* (for detection + reask counting), `handleHarnessLine` (for post-decision
|
|
355
|
+
* recording), and this class's own `handleServerLine` (for caching the
|
|
356
|
+
* post-curation tools/list the tool_poisoning detector reads). Since Wave
|
|
357
|
+
* 5.3 (TD-437) it is also shared ACROSS the session's sibling proxy
|
|
358
|
+
* processes through `sessionStore` when a Valkey URL is configured — see
|
|
359
|
+
* session.ts's module doc.
|
|
360
|
+
*/
|
|
361
|
+
session;
|
|
362
|
+
/** The Valkey-backed shared window, when `config.valkeyUrl` is set and this is not the standalone entry. */
|
|
363
|
+
sessionStore;
|
|
364
|
+
/**
|
|
365
|
+
* Phase 3's WASM custom-rule runner — owns the one dedicated
|
|
366
|
+
* `worker_threads` Worker and the `~/.intutic/wasm/` directory loader.
|
|
367
|
+
* Rescanned on the SAME 60s policy-tick timer `PolicyClient.start` already
|
|
368
|
+
* runs, not a second one (see `policy.ts`'s `start(onTick)`).
|
|
369
|
+
*/
|
|
370
|
+
wasmRunner;
|
|
53
371
|
realServer = null;
|
|
54
372
|
constructor(config) {
|
|
55
373
|
this.config = config;
|
|
56
374
|
this.policy = new PolicyClient(config.controlPlaneUrl, config.apiKey, config.workspaceId, config.policyTtlMs, config.mcpProxyMode);
|
|
57
375
|
this.emitter = new GovernanceEmitter(config.controlPlaneUrl, config.apiKey, config.eventsFilePath, config.workspaceId, config.mcpProxyMode);
|
|
58
|
-
|
|
376
|
+
// The standalone `intutic` entry fronts no real server and records no
|
|
377
|
+
// calls worth sharing; every wrapped server gets the shared window.
|
|
378
|
+
this.sessionStore =
|
|
379
|
+
config.valkeyUrl && !config.standalone ? new ValkeySessionStore(config.valkeyUrl) : undefined;
|
|
380
|
+
this.session = new SessionState({ scope: config.sessionScope, store: this.sessionStore });
|
|
381
|
+
log.info({ action: 'session_scope', scope: config.sessionScope ?? null, shared: Boolean(this.sessionStore && config.sessionScope) }, this.sessionStore && config.sessionScope
|
|
382
|
+
? 'Anomaly session window shared with sibling proxies through Valkey'
|
|
383
|
+
: 'Anomaly session window is per-process');
|
|
384
|
+
this.wasmRunner = new WasmRunner(config.mcpWasmDir);
|
|
385
|
+
this.interceptor = new ToolCallInterceptor(this.policy, this.emitter, config.failOpen, config.serverName, config.mcpInjectionAction, this.session, config.mcpAnomalyMode, config.mcpAnomalyOverrides, this.wasmRunner, config.workspaceId);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* The tools/call decision engine — exposed so `remoteBridge.ts` can drive
|
|
389
|
+
* `handleHarnessLine` (the exported free function above) against the SAME
|
|
390
|
+
* interceptor instance this class builds from `config` in its constructor,
|
|
391
|
+
* rather than constructing a second one that could drift.
|
|
392
|
+
*/
|
|
393
|
+
getInterceptor() {
|
|
394
|
+
return this.interceptor;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* The shared session state — exposed for the same reason `getInterceptor`
|
|
398
|
+
* is: `remoteBridge.ts` drives `handleHarnessLine` with this exact
|
|
399
|
+
* instance so the sequence detectors read and the sequence
|
|
400
|
+
* `handleHarnessLine` records into are never two different objects.
|
|
401
|
+
*/
|
|
402
|
+
getSessionState() {
|
|
403
|
+
return this.session;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Start the policy client's background refresh timer. Exposed alongside
|
|
407
|
+
* `stopPolicy` so `remoteBridge.ts` can drive the same policy lifecycle
|
|
408
|
+
* `runProxy` below drives for stdio mode, without reaching into `policy`
|
|
409
|
+
* (private) directly. Also rides this same tick to rescan Phase 3's
|
|
410
|
+
* `~/.intutic/wasm/` directory — see `policy.ts`'s `start(onTick)` doc.
|
|
411
|
+
*/
|
|
412
|
+
startPolicy() {
|
|
413
|
+
this.policy.start(() => {
|
|
414
|
+
this.ensureWasmWatch();
|
|
415
|
+
return this.wasmRunner.rescan();
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
wasmWatch = null;
|
|
419
|
+
/**
|
|
420
|
+
* Hot reload for `~/.intutic/wasm/` (TD-442): a directory watcher, not a
|
|
421
|
+
* second timer, so a dropped-in rule takes effect within a second instead
|
|
422
|
+
* of at the next 60-second tick. Re-armed on every tick because the
|
|
423
|
+
* directory may not exist when the proxy starts.
|
|
424
|
+
*/
|
|
425
|
+
ensureWasmWatch() {
|
|
426
|
+
if (this.wasmWatch)
|
|
427
|
+
return;
|
|
428
|
+
this.wasmWatch = watchWasmDir(this.wasmRunner.getDir(), () => this.wasmRunner.rescan());
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Stop the policy client's background refresh timer, and shut down the
|
|
432
|
+
* WASM worker thread. See `startPolicy`.
|
|
433
|
+
*/
|
|
434
|
+
stopPolicy() {
|
|
435
|
+
this.policy.stop();
|
|
436
|
+
this.wasmWatch?.close();
|
|
437
|
+
this.wasmWatch = null;
|
|
438
|
+
void this.wasmRunner.shutdown();
|
|
439
|
+
void this.sessionStore?.close();
|
|
59
440
|
}
|
|
60
441
|
/**
|
|
61
442
|
* Start the proxy. Dispatches to standalone or proxy mode based on config.
|
|
@@ -81,10 +462,6 @@ export class McpGovernanceProxy {
|
|
|
81
462
|
const cpUrl = this.config.controlPlaneUrl;
|
|
82
463
|
const apiKey = this.config.apiKey;
|
|
83
464
|
const workspaceId = this.config.workspaceId;
|
|
84
|
-
/**
|
|
85
|
-
* Helper: call control plane REST API.
|
|
86
|
-
* Returns null on any error (graceful degradation — never crashes the MCP server).
|
|
87
|
-
*/
|
|
88
465
|
async function callControlPlane(path) {
|
|
89
466
|
try {
|
|
90
467
|
const res = await fetch(`${cpUrl}${path}`, {
|
|
@@ -94,40 +471,55 @@ export class McpGovernanceProxy {
|
|
|
94
471
|
},
|
|
95
472
|
signal: AbortSignal.timeout(5000),
|
|
96
473
|
});
|
|
474
|
+
if (res.status === 403)
|
|
475
|
+
return { ok: false, reason: 'forbidden' };
|
|
97
476
|
if (!res.ok)
|
|
98
|
-
return
|
|
99
|
-
return res.json();
|
|
477
|
+
return { ok: false, reason: 'unreachable' };
|
|
478
|
+
return { ok: true, data: await res.json() };
|
|
100
479
|
}
|
|
101
480
|
catch {
|
|
102
|
-
return
|
|
481
|
+
return { ok: false, reason: 'unreachable' };
|
|
103
482
|
}
|
|
104
483
|
}
|
|
484
|
+
/** Renders a failed call as text for the agent, naming the actual cause. */
|
|
485
|
+
function describeFailure(result, what) {
|
|
486
|
+
return result.reason === 'forbidden'
|
|
487
|
+
? `Your Intutic role is not permitted to ${what}. This needs the OWNER, ADMIN or EM role.`
|
|
488
|
+
: `Could not reach control plane to ${what}.`;
|
|
489
|
+
}
|
|
105
490
|
// Tool: intutic_governance_status
|
|
106
491
|
server.tool('intutic_governance_status', 'Returns the current governance status and health of the Intutic control plane for this workspace.', {}, async () => {
|
|
107
492
|
const health = await callControlPlane('/healthz');
|
|
108
|
-
const status = health
|
|
109
|
-
? { connected: true, controlPlane: cpUrl, workspaceId, ...health }
|
|
110
|
-
: {
|
|
493
|
+
const status = health.ok
|
|
494
|
+
? { connected: true, controlPlane: cpUrl, workspaceId, ...health.data }
|
|
495
|
+
: {
|
|
496
|
+
connected: false,
|
|
497
|
+
controlPlane: cpUrl,
|
|
498
|
+
workspaceId,
|
|
499
|
+
error: health.reason === 'forbidden'
|
|
500
|
+
? 'Control plane rejected this API key'
|
|
501
|
+
: 'Control plane unreachable',
|
|
502
|
+
};
|
|
111
503
|
return {
|
|
112
504
|
content: [{ type: 'text', text: JSON.stringify(status, null, 2) }],
|
|
113
505
|
};
|
|
114
506
|
});
|
|
115
507
|
// Tool: intutic_list_sops
|
|
116
508
|
server.tool('intutic_list_sops', 'Lists Standard Operating Procedures (SOPs) active in this workspace.', { limit: z.number().int().min(1).max(50).default(10).describe('Number of SOPs to return (1–50)') }, async ({ limit }) => {
|
|
117
|
-
const
|
|
118
|
-
const text =
|
|
119
|
-
? JSON.stringify(data, null, 2)
|
|
120
|
-
: '
|
|
509
|
+
const result = await callControlPlane(`/api/v1/sops?workspaceId=${workspaceId}&limit=${limit}`);
|
|
510
|
+
const text = result.ok
|
|
511
|
+
? JSON.stringify(result.data, null, 2)
|
|
512
|
+
: describeFailure(result, 'list SOPs');
|
|
121
513
|
return {
|
|
122
514
|
content: [{ type: 'text', text }],
|
|
123
515
|
};
|
|
124
516
|
});
|
|
125
517
|
// Tool: intutic_list_incidents
|
|
126
518
|
server.tool('intutic_list_incidents', 'Lists recent governance incidents (policy violations, blocked tool calls) in this workspace.', { limit: z.number().int().min(1).max(50).default(10).describe('Number of incidents to return (1–50)') }, async ({ limit }) => {
|
|
127
|
-
const
|
|
128
|
-
const text =
|
|
129
|
-
? JSON.stringify(data, null, 2)
|
|
130
|
-
: '
|
|
519
|
+
const result = await callControlPlane(`/api/v1/incidents?workspaceId=${workspaceId}&limit=${limit}`);
|
|
520
|
+
const text = result.ok
|
|
521
|
+
? JSON.stringify(result.data, null, 2)
|
|
522
|
+
: describeFailure(result, 'list incidents');
|
|
131
523
|
return {
|
|
132
524
|
content: [{ type: 'text', text }],
|
|
133
525
|
};
|
|
@@ -144,6 +536,137 @@ export class McpGovernanceProxy {
|
|
|
144
536
|
await server.close();
|
|
145
537
|
}
|
|
146
538
|
// ─── Proxy Mode ───────────────────────────────────────────────────────────────
|
|
539
|
+
/**
|
|
540
|
+
* Inspects one real-server → harness line (see `processServerLine`) and
|
|
541
|
+
* writes the outcome to stdout. Async, unlike the byte pipe this replaced,
|
|
542
|
+
* because a `tools/list` response also runs server-level TOFU pinning
|
|
543
|
+
* (tofu.ts), which does file I/O — the ONLY case this awaits before
|
|
544
|
+
* writing; every other line is forwarded exactly as fast as before.
|
|
545
|
+
*
|
|
546
|
+
* On a TOFU mismatch, honors `mcpProxyFailBehavior` via `config.failOpen` —
|
|
547
|
+
* the SAME field `interceptor.ts` already reads for its own fail-open/
|
|
548
|
+
* fail-closed branches, not a new mechanism: fail-open logs and forwards
|
|
549
|
+
* the (possibly curated) tools/list response as normal; fail-closed
|
|
550
|
+
* replaces it with a JSON-RPC error naming the server and the setting,
|
|
551
|
+
* mirroring `buildBlockResponse`'s existing wording style.
|
|
552
|
+
*
|
|
553
|
+
* Public (not `private`) so `remoteBridge.ts` can hand it every message
|
|
554
|
+
* `transport.onmessage` receives from a remote upstream, VERBATIM — DLP
|
|
555
|
+
* redaction, TOFU pinning, and event emission on the response direction
|
|
556
|
+
* must apply identically regardless of which upstream transport produced
|
|
557
|
+
* the line, and this is the one place that logic lives.
|
|
558
|
+
*/
|
|
559
|
+
async handleServerLine(rawLine, pending) {
|
|
560
|
+
const injectionAction = this.policy.getInjectionAction() ?? this.config.mcpInjectionAction;
|
|
561
|
+
const outcome = processServerLine(rawLine, pending, this.policy.getAllowedTools(), this.policy.getToolDescriptionOverrides(), injectionAction, this.policy.getInjectionPatterns());
|
|
562
|
+
if (outcome.injectionFindings) {
|
|
563
|
+
for (const finding of outcome.injectionFindings) {
|
|
564
|
+
const severity = injectionSeverity(finding.patterns, finding.source);
|
|
565
|
+
const reason = `Prompt-injection pattern(s) detected in ${finding.source} ("${finding.toolName}"): ${finding.patterns.join(', ')}`;
|
|
566
|
+
log.warn({
|
|
567
|
+
action: 'injection_detected',
|
|
568
|
+
toolName: finding.toolName,
|
|
569
|
+
source: finding.source,
|
|
570
|
+
patterns: finding.patterns,
|
|
571
|
+
severity,
|
|
572
|
+
withheld: outcome.injectionBlocked === true && finding.source === 'tool_result',
|
|
573
|
+
}, 'Prompt-injection pattern matched in MCP response traffic');
|
|
574
|
+
this.emitter.emit('injection_detected', finding.toolName, undefined, reason, severity);
|
|
575
|
+
// tools/list description findings are report-only in v1 (never
|
|
576
|
+
// blocked by injection alone — curation + TOFU already govern the
|
|
577
|
+
// listing); only a `tool_result` finding can carry
|
|
578
|
+
// `injectionBlocked`.
|
|
579
|
+
if (outcome.injectionBlocked === true && finding.source === 'tool_result') {
|
|
580
|
+
this.emitter.emit('tool_blocked', finding.toolName, undefined, reason);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (outcome.redactedTool) {
|
|
585
|
+
log.warn({
|
|
586
|
+
action: 'result_redacted',
|
|
587
|
+
toolName: outcome.redactedTool,
|
|
588
|
+
redactions: outcome.redactions,
|
|
589
|
+
}, 'Sensitive data redacted from a tool result before it reached the agent');
|
|
590
|
+
this.emitter.emit('tool_redacted', outcome.redactedTool, undefined, `Result redacted: ${(outcome.redactions ?? []).join('; ')}`, outcome.dlpEscalationReason ? 'high' : undefined);
|
|
591
|
+
}
|
|
592
|
+
if (outcome.dlpEscalationReason) {
|
|
593
|
+
// Phase 2's dlp_escalation: severity escalation only, never a new
|
|
594
|
+
// block — see `ServerLineOutcome.dlpEscalationReason`'s doc comment.
|
|
595
|
+
log.warn({ action: 'anomaly_detected', detectorId: 'dlp_escalation', toolName: outcome.redactedTool }, outcome.dlpEscalationReason);
|
|
596
|
+
this.emitter.emit('anomaly_detected', outcome.redactedTool ?? 'unknown', undefined, outcome.dlpEscalationReason, 'high');
|
|
597
|
+
}
|
|
598
|
+
if (outcome.curated) {
|
|
599
|
+
log.info({ action: 'tools_list_curated', ...outcome.curated }, 'tools/list curated: allowlist filtering and/or description overrides applied');
|
|
600
|
+
}
|
|
601
|
+
if (outcome.toolPoisoningReason) {
|
|
602
|
+
// Phase 2's tool_poisoning: report-only (Steer), same as Phase 1's
|
|
603
|
+
// tools/list description injection scan — never blocks the listing.
|
|
604
|
+
log.warn({ action: 'anomaly_detected', detectorId: 'tool_poisoning' }, outcome.toolPoisoningReason);
|
|
605
|
+
this.emitter.emit('anomaly_detected', this.config.serverName, undefined, outcome.toolPoisoningReason, 'low');
|
|
606
|
+
}
|
|
607
|
+
if (outcome.toolsListTools) {
|
|
608
|
+
// Cache the post-curation tools/list for Phase 2's tool_poisoning
|
|
609
|
+
// detector (already applied above, from this same outcome) and for
|
|
610
|
+
// Phase 3's WASM rule context (`tools` field) once that lands.
|
|
611
|
+
this.session.setToolsList(outcome.toolsListTools
|
|
612
|
+
.filter((t) => typeof t['name'] === 'string')
|
|
613
|
+
.map((t) => ({
|
|
614
|
+
name: t['name'],
|
|
615
|
+
description: typeof t['description'] === 'string' ? t['description'] : undefined,
|
|
616
|
+
})));
|
|
617
|
+
const serverName = this.config.serverName;
|
|
618
|
+
let tofu;
|
|
619
|
+
try {
|
|
620
|
+
tofu = await checkTofu(this.config.workspaceId, serverName, outcome.toolsListTools);
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
// Pin storage I/O failed (disk full, permissions, ~/.intutic
|
|
624
|
+
// unwritable). This is a governance-check failure like any other in
|
|
625
|
+
// this package — fail-open forwards, fail-closed refuses — never a
|
|
626
|
+
// silent skip either way.
|
|
627
|
+
log.error({ action: 'tofu_check_error', err: err.message }, 'TOFU check failed');
|
|
628
|
+
if (!this.config.failOpen) {
|
|
629
|
+
writeFrame(buildBlockResponse(outcome.toolsListMsgId ?? null, `TOFU pin check for MCP server "${serverName}" failed (could not read/write ` +
|
|
630
|
+
`~/.intutic/mcp-pins/). Blocked by workspace policy (fail-closed mode). ` +
|
|
631
|
+
`Contact your administrator or update mcpProxyFailBehavior to open.`));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
process.stdout.write(outcome.line + '\n');
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
// Phase 3's `tool_contract_changed` WASM context field mirrors this
|
|
638
|
+
// check's OUTCOME, not its blocking decision — a rule may want to see
|
|
639
|
+
// the mismatch even in fail-open mode, where `action.block` is false.
|
|
640
|
+
// 'skipped' (no tools declared) deliberately leaves the session's
|
|
641
|
+
// cached flag untouched; see SessionState.setToolContractChanged's doc.
|
|
642
|
+
if (tofu.status === 'first_contact' || tofu.status === 'match') {
|
|
643
|
+
this.session.setToolContractChanged(false);
|
|
644
|
+
}
|
|
645
|
+
else if (tofu.status === 'mismatch') {
|
|
646
|
+
this.session.setToolContractChanged(true);
|
|
647
|
+
}
|
|
648
|
+
if (tofu.status === 'first_contact') {
|
|
649
|
+
log.info({ action: 'tofu_first_contact', serverName, fingerprint: tofu.fingerprint }, 'MCP server tool definitions pinned on first contact');
|
|
650
|
+
}
|
|
651
|
+
else if (tofu.status === 'mismatch') {
|
|
652
|
+
const action = decideTofuAction(tofu, serverName, this.config.failOpen);
|
|
653
|
+
const reason = action.reason ?? 'MCP server tool definitions changed since first pinned.';
|
|
654
|
+
log.warn({
|
|
655
|
+
action: 'mcp_server_definition_changed',
|
|
656
|
+
serverName,
|
|
657
|
+
previousFingerprint: tofu.previousFingerprint,
|
|
658
|
+
fingerprint: tofu.fingerprint,
|
|
659
|
+
blocked: action.block,
|
|
660
|
+
}, reason);
|
|
661
|
+
this.emitter.emit('mcp_server_definition_changed', serverName, undefined, reason);
|
|
662
|
+
if (action.block) {
|
|
663
|
+
writeFrame(buildBlockResponse(outcome.toolsListMsgId ?? null, reason));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
process.stdout.write(outcome.line + '\n');
|
|
669
|
+
}
|
|
147
670
|
/**
|
|
148
671
|
* Start the proxy. Spawns the real MCP server and begins proxying stdin/stdout.
|
|
149
672
|
* Returns a promise that resolves when the real server exits.
|
|
@@ -151,8 +674,9 @@ export class McpGovernanceProxy {
|
|
|
151
674
|
async runProxy() {
|
|
152
675
|
const [cmd, ...args] = this.config.realServerCommand;
|
|
153
676
|
log.info({ action: 'proxy_start', cmd, args, workspaceId: this.config.workspaceId }, 'Starting MCP governance proxy');
|
|
154
|
-
// Start background policy refresh
|
|
155
|
-
|
|
677
|
+
// Start background policy refresh (and, on the same tick, Phase 3's
|
|
678
|
+
// WASM rescan — see startPolicy).
|
|
679
|
+
this.startPolicy();
|
|
156
680
|
// Spawn the real MCP server
|
|
157
681
|
const realServer = node_child.spawn(cmd, args, {
|
|
158
682
|
stdio: ['pipe', 'pipe', 'inherit'], // inherit stderr so real server logs appear normally
|
|
@@ -161,7 +685,7 @@ export class McpGovernanceProxy {
|
|
|
161
685
|
// Handle graceful shutdown
|
|
162
686
|
const shutdown = (signal) => {
|
|
163
687
|
log.info({ action: 'proxy_shutdown', signal }, 'Shutting down MCP governance proxy');
|
|
164
|
-
this.
|
|
688
|
+
this.stopPolicy();
|
|
165
689
|
if (!realServer.killed) {
|
|
166
690
|
realServer.kill();
|
|
167
691
|
}
|
|
@@ -169,53 +693,36 @@ export class McpGovernanceProxy {
|
|
|
169
693
|
};
|
|
170
694
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
171
695
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
696
|
+
// Requests whose responses this proxy must inspect on the way back.
|
|
697
|
+
// Bounded by the number of in-flight calls; entries are removed when the
|
|
698
|
+
// response arrives (or lost with the process, which is fine — the map
|
|
699
|
+
// exists to route inspection, not to account).
|
|
700
|
+
const pending = new Map();
|
|
701
|
+
// Real server stdout → response inspection → harness stdout. The pipe
|
|
702
|
+
// this replaces forwarded bytes verbatim, which left the whole response
|
|
703
|
+
// direction unscanned; see processServerLine. Line-framed like the input
|
|
704
|
+
// direction (MCP stdio is newline-delimited JSON-RPC), so readline does
|
|
705
|
+
// the chunk reassembly a byte pipe never had to think about.
|
|
706
|
+
const serverRl = node_readline.createInterface({
|
|
707
|
+
input: realServer.stdout,
|
|
708
|
+
terminal: false,
|
|
175
709
|
});
|
|
176
|
-
|
|
710
|
+
serverRl.on('line', (rawLine) => {
|
|
711
|
+
void this.handleServerLine(rawLine, pending);
|
|
712
|
+
});
|
|
713
|
+
// Harness stdin → governance interceptor → real server stdin. Delegates
|
|
714
|
+
// to the module-level `handleHarnessLine`, shared verbatim with
|
|
715
|
+
// remoteBridge.ts's remote bridge mode — only `forward` differs: here it
|
|
716
|
+
// writes to the spawned child process's stdin.
|
|
177
717
|
const rl = node_readline.createInterface({ input: process.stdin, terminal: false });
|
|
178
718
|
rl.on('line', (line) => {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
let msg;
|
|
183
|
-
try {
|
|
184
|
-
msg = JSON.parse(trimmed);
|
|
185
|
-
}
|
|
186
|
-
catch {
|
|
187
|
-
log.warn({ action: 'parse_error', line: trimmed.slice(0, 100) }, 'Failed to parse JSON-RPC line');
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
// Intercept tools/call
|
|
191
|
-
if (msg.method === 'tools/call') {
|
|
192
|
-
const params = msg.params;
|
|
193
|
-
const toolName = params?.name ?? '<unknown>';
|
|
194
|
-
const toolInput = params?.arguments ?? {};
|
|
195
|
-
// Run governance check asynchronously
|
|
196
|
-
this.interceptor.decide(toolName, toolInput).then((decision) => {
|
|
197
|
-
if (decision.action === 'block') {
|
|
198
|
-
log.warn({ action: 'tool_blocked', toolName, reason: decision.reason }, 'Tool call blocked by governance proxy');
|
|
199
|
-
writeFrame(buildBlockResponse(msg.id, decision.reason));
|
|
200
|
-
}
|
|
201
|
-
else {
|
|
202
|
-
// Allow: forward to real server
|
|
203
|
-
realServer.stdin.write(line + '\n');
|
|
204
|
-
}
|
|
205
|
-
}).catch((err) => {
|
|
206
|
-
// Governance check failed — fail-open: forward to real server
|
|
207
|
-
log.error({ action: 'interceptor_error', err: err.message }, 'Interceptor error — failing open');
|
|
208
|
-
realServer.stdin.write(line + '\n');
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
else {
|
|
212
|
-
// Non-tools/call messages pass through immediately
|
|
213
|
-
realServer.stdin.write(line + '\n');
|
|
214
|
-
}
|
|
719
|
+
handleHarnessLine(line, pending, this.interceptor, (l) => {
|
|
720
|
+
realServer.stdin.write(l + '\n');
|
|
721
|
+
}, this.session);
|
|
215
722
|
});
|
|
216
723
|
rl.on('close', () => {
|
|
217
724
|
log.info({ action: 'stdin_closed' }, 'Harness stdin closed — shutting down');
|
|
218
|
-
this.
|
|
725
|
+
this.stopPolicy();
|
|
219
726
|
if (!realServer.killed) {
|
|
220
727
|
realServer.kill();
|
|
221
728
|
}
|
|
@@ -224,12 +731,12 @@ export class McpGovernanceProxy {
|
|
|
224
731
|
return new Promise((resolve, reject) => {
|
|
225
732
|
realServer.on('exit', (code, signal) => {
|
|
226
733
|
log.info({ action: 'real_server_exit', code, signal }, 'Real MCP server exited');
|
|
227
|
-
this.
|
|
734
|
+
this.stopPolicy();
|
|
228
735
|
resolve();
|
|
229
736
|
});
|
|
230
737
|
realServer.on('error', (err) => {
|
|
231
738
|
log.error({ action: 'real_server_error', err: err.message }, 'Real MCP server process error');
|
|
232
|
-
this.
|
|
739
|
+
this.stopPolicy();
|
|
233
740
|
reject(err);
|
|
234
741
|
});
|
|
235
742
|
});
|