@intutic/mcp-governance-proxy 1.10.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.
Files changed (123) hide show
  1. package/README.md +28 -0
  2. package/dist/anomaly/detectors.d.ts +129 -0
  3. package/dist/anomaly/detectors.d.ts.map +1 -0
  4. package/dist/anomaly/detectors.js +368 -0
  5. package/dist/anomaly/detectors.js.map +1 -0
  6. package/dist/anomaly/index.d.ts +52 -0
  7. package/dist/anomaly/index.d.ts.map +1 -0
  8. package/dist/anomaly/index.js +71 -0
  9. package/dist/anomaly/index.js.map +1 -0
  10. package/dist/config.d.ts +107 -1
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +129 -6
  13. package/dist/config.js.map +1 -1
  14. package/dist/daemon/healthMonitor.d.ts.map +1 -1
  15. package/dist/daemon/healthMonitor.js +45 -46
  16. package/dist/daemon/healthMonitor.js.map +1 -1
  17. package/dist/daemon/index.d.ts +15 -1
  18. package/dist/daemon/index.d.ts.map +1 -1
  19. package/dist/daemon/index.js +78 -9
  20. package/dist/daemon/index.js.map +1 -1
  21. package/dist/daemon/instrumentation.d.ts +20 -0
  22. package/dist/daemon/instrumentation.d.ts.map +1 -0
  23. package/dist/daemon/instrumentation.js +61 -0
  24. package/dist/daemon/instrumentation.js.map +1 -0
  25. package/dist/daemon/policyCache.d.ts +83 -0
  26. package/dist/daemon/policyCache.d.ts.map +1 -1
  27. package/dist/daemon/policyCache.js +280 -11
  28. package/dist/daemon/policyCache.js.map +1 -1
  29. package/dist/daemon/statusReporter.d.ts +12 -0
  30. package/dist/daemon/statusReporter.d.ts.map +1 -0
  31. package/dist/daemon/statusReporter.js +130 -0
  32. package/dist/daemon/statusReporter.js.map +1 -0
  33. package/dist/daemon/telemetryBatcher.d.ts +3 -1
  34. package/dist/daemon/telemetryBatcher.d.ts.map +1 -1
  35. package/dist/daemon/telemetryBatcher.js +56 -13
  36. package/dist/daemon/telemetryBatcher.js.map +1 -1
  37. package/dist/daemonClient.d.ts +1 -1
  38. package/dist/daemonClient.d.ts.map +1 -1
  39. package/dist/daemonClient.js +0 -2
  40. package/dist/daemonClient.js.map +1 -1
  41. package/dist/dlp.d.ts +19 -0
  42. package/dist/dlp.d.ts.map +1 -1
  43. package/dist/dlp.js +95 -18
  44. package/dist/dlp.js.map +1 -1
  45. package/dist/emitter.d.ts +40 -2
  46. package/dist/emitter.d.ts.map +1 -1
  47. package/dist/emitter.js +27 -5
  48. package/dist/emitter.js.map +1 -1
  49. package/dist/index.js +13 -2
  50. package/dist/index.js.map +1 -1
  51. package/dist/injection.d.ts +46 -0
  52. package/dist/injection.d.ts.map +1 -0
  53. package/dist/injection.js +147 -0
  54. package/dist/injection.js.map +1 -0
  55. package/dist/interceptor.d.ts +131 -1
  56. package/dist/interceptor.d.ts.map +1 -1
  57. package/dist/interceptor.js +301 -5
  58. package/dist/interceptor.js.map +1 -1
  59. package/dist/policy.d.ts +110 -2
  60. package/dist/policy.d.ts.map +1 -1
  61. package/dist/policy.js +206 -5
  62. package/dist/policy.js.map +1 -1
  63. package/dist/proxy.d.ts +205 -0
  64. package/dist/proxy.d.ts.map +1 -1
  65. package/dist/proxy.js +572 -65
  66. package/dist/proxy.js.map +1 -1
  67. package/dist/remoteBridge.d.ts +51 -0
  68. package/dist/remoteBridge.d.ts.map +1 -0
  69. package/dist/remoteBridge.js +182 -0
  70. package/dist/remoteBridge.js.map +1 -0
  71. package/dist/session.d.ts +158 -0
  72. package/dist/session.d.ts.map +1 -0
  73. package/dist/session.js +206 -0
  74. package/dist/session.js.map +1 -0
  75. package/dist/sessionScope.d.ts +51 -0
  76. package/dist/sessionScope.d.ts.map +1 -0
  77. package/dist/sessionScope.js +94 -0
  78. package/dist/sessionScope.js.map +1 -0
  79. package/dist/sessionStore.d.ts +69 -0
  80. package/dist/sessionStore.d.ts.map +1 -0
  81. package/dist/sessionStore.js +143 -0
  82. package/dist/sessionStore.js.map +1 -0
  83. package/dist/tofu.d.ts +112 -0
  84. package/dist/tofu.d.ts.map +1 -0
  85. package/dist/tofu.js +180 -0
  86. package/dist/tofu.js.map +1 -0
  87. package/dist/toolPoison.d.ts +32 -0
  88. package/dist/toolPoison.d.ts.map +1 -0
  89. package/dist/toolPoison.js +81 -0
  90. package/dist/toolPoison.js.map +1 -0
  91. package/dist/valkeyErrors.d.ts +17 -0
  92. package/dist/valkeyErrors.d.ts.map +1 -0
  93. package/dist/valkeyErrors.js +24 -0
  94. package/dist/valkeyErrors.js.map +1 -0
  95. package/dist/wasm/context.d.ts +57 -0
  96. package/dist/wasm/context.d.ts.map +1 -0
  97. package/dist/wasm/context.js +59 -0
  98. package/dist/wasm/context.js.map +1 -0
  99. package/dist/wasm/hostImports.d.ts +43 -0
  100. package/dist/wasm/hostImports.d.ts.map +1 -0
  101. package/dist/wasm/hostImports.js +158 -0
  102. package/dist/wasm/hostImports.js.map +1 -0
  103. package/dist/wasm/loader.d.ts +95 -0
  104. package/dist/wasm/loader.d.ts.map +1 -0
  105. package/dist/wasm/loader.js +198 -0
  106. package/dist/wasm/loader.js.map +1 -0
  107. package/dist/wasm/referencedFiles.d.ts +115 -0
  108. package/dist/wasm/referencedFiles.d.ts.map +1 -0
  109. package/dist/wasm/referencedFiles.js +366 -0
  110. package/dist/wasm/referencedFiles.js.map +1 -0
  111. package/dist/wasm/runner.d.ts +91 -0
  112. package/dist/wasm/runner.d.ts.map +1 -0
  113. package/dist/wasm/runner.js +308 -0
  114. package/dist/wasm/runner.js.map +1 -0
  115. package/dist/wasm/watch.d.ts +5 -0
  116. package/dist/wasm/watch.d.ts.map +1 -0
  117. package/dist/wasm/watch.js +54 -0
  118. package/dist/wasm/watch.js.map +1 -0
  119. package/dist/wasm/worker.d.ts +39 -0
  120. package/dist/wasm/worker.d.ts.map +1 -0
  121. package/dist/wasm/worker.js +245 -0
  122. package/dist/wasm/worker.js.map +1 -0
  123. 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
- this.interceptor = new ToolCallInterceptor(this.policy, this.emitter, config.failOpen);
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 null;
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 null;
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
- : { connected: false, controlPlane: cpUrl, workspaceId, error: 'Control plane unreachable' };
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 data = await callControlPlane(`/api/v1/sops?workspaceId=${workspaceId}&limit=${limit}`);
118
- const text = data
119
- ? JSON.stringify(data, null, 2)
120
- : 'Could not reach control plane to list SOPs.';
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 data = await callControlPlane(`/api/v1/incidents?workspaceId=${workspaceId}&limit=${limit}`);
128
- const text = data
129
- ? JSON.stringify(data, null, 2)
130
- : 'Could not reach control plane to list incidents.';
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
- this.policy.start();
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.policy.stop();
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
- // Real server stdout → harness stdout (pass through)
173
- realServer.stdout.on('data', (chunk) => {
174
- process.stdout.write(chunk);
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
- // Harness stdin → governance interceptor → real server stdin
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
- const trimmed = line.trim();
180
- if (!trimmed)
181
- return;
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.policy.stop();
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.policy.stop();
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.policy.stop();
739
+ this.stopPolicy();
233
740
  reject(err);
234
741
  });
235
742
  });