@panguard-ai/panguard-mcp-proxy 1.7.3 → 1.8.2

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 ADDED
@@ -0,0 +1,18 @@
1
+ # @panguard-ai/panguard-mcp-proxy
2
+
3
+ MCP Proxy — runtime interception for AI agent tool calls using ATR (Agent Threat Rules).
4
+
5
+ Transparent tool-call evaluation:
6
+
7
+ - Intercepts MCP tool calls at runtime
8
+ - Evaluates threats against 675+ ATR rules
9
+ - Allows or blocks execution based on policy
10
+ - Integration with Claude, Cursor, and other AI agents
11
+
12
+ Part of the **PanGuard Community** suite — 100% free and open source (MIT).
13
+
14
+ ```bash
15
+ npm install @panguard-ai/panguard-mcp-proxy
16
+ ```
17
+
18
+ **License**: MIT
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * @module @panguard-ai/panguard-mcp-proxy/evaluator
8
8
  */
9
+ import type { AgentEvent } from '@panguard-ai/atr';
9
10
  export interface EvalResult {
10
11
  readonly outcome: 'allow' | 'deny' | 'ask';
11
12
  readonly reason: string;
@@ -52,9 +53,40 @@ export declare class ProxyEvaluator {
52
53
  getRuleCount(): number;
53
54
  /** Flatten args into a readable string for ATR regex matching */
54
55
  private flattenArgs;
55
- /** Evaluate a tool call (PreToolUse) */
56
- evaluateToolCall(toolName: string, args: Record<string, unknown>): Promise<EvalResult>;
57
- /** Evaluate a tool response (PostToolUse) */
56
+ /**
57
+ * Evaluate a tool call (PreToolUse).
58
+ *
59
+ * `eventType` selects which ATR rules apply. The MCP proxy sees genuine
60
+ * MCP exchanges, so it keeps the 'mcp_exchange' default. The Claude-Code /
61
+ * Gemini / Codex PreToolUse hook wraps built-in tools (Bash, Edit, Write,
62
+ * WebFetch) — those are 'tool_call' events. Passing 'tool_call' makes the
63
+ * engine run the tool_call rule set (shell injection, credential theft,
64
+ * SSRF, RCE, privilege escalation) AND, via the engine's mcp-over-tool
65
+ * exception, the mcp_exchange rules — while correctly skipping
66
+ * multi_agent_comm and llm_io rules that never apply to a shell command.
67
+ * The old hardcoded 'mcp_exchange' both missed the tool_call attack rules
68
+ * and false-fired multi_agent_comm rules on ordinary commands.
69
+ */
70
+ evaluateToolCall(toolName: string, args: Record<string, unknown>, eventType?: AgentEvent['type']): Promise<EvalResult>;
71
+ /**
72
+ * Evaluate a tool response (PostToolUse) — the indirect-prompt-injection path.
73
+ *
74
+ * The event MUST be typed 'tool_response' (NOT 'mcp_exchange'). A poisoned MCP /
75
+ * tool / RAG output is where indirect prompt injection rides in, and the ATR
76
+ * engine handles that case only for a 'tool_response'-typed event:
77
+ * 1. EVENT_TYPE_TO_SOURCE['tool_response'] === 'mcp_exchange', which fires the
78
+ * engine's llmIoOverToolResponse exception so the whole llm_io family
79
+ * (system-prompt-override, SSRF-via-URL, SQLi-in-natural-language,
80
+ * shell-injection) is allowed to run against the response.
81
+ * 2. getFieldValue() routes event.content into the user_input / agent_output
82
+ * fields ONLY when event.type === 'tool_response'. The ~200 llm_io rules
83
+ * that target user_input/agent_output therefore see the response text and
84
+ * can match.
85
+ * Passing 'mcp_exchange' (the old value) is NOT a key in EVENT_TYPE_TO_SOURCE, so
86
+ * eventSourceType is undefined: the source-type filter is bypassed (llm_io rules
87
+ * run) but getFieldValue leaves user_input/agent_output undefined, so those rules
88
+ * silently match nothing — defeating the entire indirect-injection scan.
89
+ */
58
90
  evaluateToolResponse(toolName: string, response: string): Promise<EvalResult>;
59
91
  private evaluate;
60
92
  }
package/dist/evaluator.js CHANGED
@@ -122,8 +122,21 @@ export class ProxyEvaluator {
122
122
  }
123
123
  return parts.join('\n');
124
124
  }
125
- /** Evaluate a tool call (PreToolUse) */
126
- async evaluateToolCall(toolName, args) {
125
+ /**
126
+ * Evaluate a tool call (PreToolUse).
127
+ *
128
+ * `eventType` selects which ATR rules apply. The MCP proxy sees genuine
129
+ * MCP exchanges, so it keeps the 'mcp_exchange' default. The Claude-Code /
130
+ * Gemini / Codex PreToolUse hook wraps built-in tools (Bash, Edit, Write,
131
+ * WebFetch) — those are 'tool_call' events. Passing 'tool_call' makes the
132
+ * engine run the tool_call rule set (shell injection, credential theft,
133
+ * SSRF, RCE, privilege escalation) AND, via the engine's mcp-over-tool
134
+ * exception, the mcp_exchange rules — while correctly skipping
135
+ * multi_agent_comm and llm_io rules that never apply to a shell command.
136
+ * The old hardcoded 'mcp_exchange' both missed the tool_call attack rules
137
+ * and false-fired multi_agent_comm rules on ordinary commands.
138
+ */
139
+ async evaluateToolCall(toolName, args, eventType = 'mcp_exchange') {
127
140
  const start = Date.now();
128
141
  // Check Guard blocklist first (instant deny, no regex needed)
129
142
  this.refreshBlocklist();
@@ -139,7 +152,7 @@ export class ProxyEvaluator {
139
152
  // Flatten args into natural text so ATR regexes can match content like paths and commands
140
153
  const flatContent = `${toolName} ${this.flattenArgs(args)}`;
141
154
  const event = {
142
- type: 'mcp_exchange',
155
+ type: eventType,
143
156
  timestamp: new Date().toISOString(),
144
157
  content: flatContent,
145
158
  fields: {
@@ -149,11 +162,29 @@ export class ProxyEvaluator {
149
162
  };
150
163
  return this.evaluate(event, start);
151
164
  }
152
- /** Evaluate a tool response (PostToolUse) */
165
+ /**
166
+ * Evaluate a tool response (PostToolUse) — the indirect-prompt-injection path.
167
+ *
168
+ * The event MUST be typed 'tool_response' (NOT 'mcp_exchange'). A poisoned MCP /
169
+ * tool / RAG output is where indirect prompt injection rides in, and the ATR
170
+ * engine handles that case only for a 'tool_response'-typed event:
171
+ * 1. EVENT_TYPE_TO_SOURCE['tool_response'] === 'mcp_exchange', which fires the
172
+ * engine's llmIoOverToolResponse exception so the whole llm_io family
173
+ * (system-prompt-override, SSRF-via-URL, SQLi-in-natural-language,
174
+ * shell-injection) is allowed to run against the response.
175
+ * 2. getFieldValue() routes event.content into the user_input / agent_output
176
+ * fields ONLY when event.type === 'tool_response'. The ~200 llm_io rules
177
+ * that target user_input/agent_output therefore see the response text and
178
+ * can match.
179
+ * Passing 'mcp_exchange' (the old value) is NOT a key in EVENT_TYPE_TO_SOURCE, so
180
+ * eventSourceType is undefined: the source-type filter is bypassed (llm_io rules
181
+ * run) but getFieldValue leaves user_input/agent_output undefined, so those rules
182
+ * silently match nothing — defeating the entire indirect-injection scan.
183
+ */
153
184
  async evaluateToolResponse(toolName, response) {
154
185
  const start = Date.now();
155
186
  const event = {
156
- type: 'mcp_exchange',
187
+ type: 'tool_response',
157
188
  timestamp: new Date().toISOString(),
158
189
  content: response,
159
190
  fields: {
package/dist/proxy.d.ts CHANGED
@@ -32,7 +32,7 @@ export interface ProxyConfig {
32
32
  /** The subset of ProxyEvaluator the proxy uses — injectable for testing. */
33
33
  export interface ProxyEvaluatorLike {
34
34
  loadRules(): Promise<number>;
35
- evaluateToolCall(toolName: string, args: Record<string, unknown>): Promise<EvalResult>;
35
+ evaluateToolCall(toolName: string, args: Record<string, unknown>, eventType?: string): Promise<EvalResult>;
36
36
  evaluateToolResponse(toolName: string, response: string): Promise<EvalResult>;
37
37
  }
38
38
  export declare class MCPProxy {
@@ -48,6 +48,12 @@ export declare class MCPProxy {
48
48
  private readonly riskStore;
49
49
  /** Confidence at/above which an evaluator deny escalates the whole session. */
50
50
  private static readonly ESCALATE_CONFIDENCE;
51
+ /** Max background attempts to re-fetch the tool list after a listTools() failure. */
52
+ private static readonly SCOPE_RETRY_MAX;
53
+ /** Base backoff (ms) for the exponential scope re-fetch retry (capped). */
54
+ private static readonly SCOPE_RETRY_BASE_MS;
55
+ /** Ceiling (ms) for the exponential scope re-fetch backoff. */
56
+ private static readonly SCOPE_RETRY_MAX_MS;
51
57
  /**
52
58
  * One stdio session per proxy process. Defaults to a per-process unique id
53
59
  * (not the old hardcoded constant) so verdict lines from distinct runs are
@@ -58,6 +64,19 @@ export declare class MCPProxy {
58
64
  private readonly agentId;
59
65
  /** Upstream tool names = the Layer 0 capability scope (populated in start()). */
60
66
  private upstreamToolNames;
67
+ /**
68
+ * True once listTools() succeeds (any count) → the scope is KNOWN. False means
69
+ * the list has not been fetched or the fetch FAILED → scope unknown (degraded,
70
+ * fail-open by tool name). A known-but-empty scope is resolved=true with an
71
+ * empty set, which DENIES every call — not the same as unresolved.
72
+ */
73
+ private capabilityScopeResolved;
74
+ /** One-shot throttle so the per-call degraded warning does not spam stderr. */
75
+ private warnedScopeDegraded;
76
+ /** Count of background scope re-fetch attempts made after a listTools() failure. */
77
+ private scopeRetryAttempts;
78
+ /** True while a background scope re-fetch is in flight (prevents pile-up). */
79
+ private scopeRetryInFlight;
61
80
  /** Tamper-evident chain over proxy-verdicts.jsonl (lazily keyed in connect()). */
62
81
  private chain;
63
82
  constructor(config: ProxyConfig, deps?: {
@@ -70,11 +89,30 @@ export declare class MCPProxy {
70
89
  * in-memory transports without spawning a process.
71
90
  */
72
91
  connect(upstreamTransport: Transport, agentTransport: Transport): Promise<void>;
92
+ /**
93
+ * Fetch the upstream tool list and cache it as the Layer 0 capability scope.
94
+ * Returns true on success (scope RESOLVED, regardless of count — a successful
95
+ * empty list is a known-empty scope that DENIES every call), false if
96
+ * listTools() throws (scope left UNRESOLVED). Never throws. Shared by connect()
97
+ * and the background retry so both paths update the scope identically.
98
+ */
99
+ private resolveCapabilityScope;
100
+ /**
101
+ * Schedule a bounded, exponential-backoff background re-fetch of the tool list
102
+ * so a transient listTools() failure self-heals instead of latching a degraded
103
+ * capability scope for the whole proxy lifetime. Fire-and-forget (never blocks a
104
+ * tool call); at most one attempt is in flight at a time and total attempts are
105
+ * capped by SCOPE_RETRY_MAX.
106
+ */
107
+ private scheduleScopeRetry;
108
+ /** Execute one background scope re-fetch attempt, then reschedule if needed. */
109
+ private runScopeRetry;
73
110
  /**
74
111
  * Run the Layer 1 inline gate for a tool call (sync, sub-ms): build the
75
112
  * ActionContext and apply the gate. Capabilities default to the upstream tool
76
- * set (Layer 0 scope); when unknown, the requested tool is allowed so the gate
77
- * only adds block-on-sight + risk gating. Exposed so the wiring is testable.
113
+ * set (Layer 0 scope); when the scope is unresolved the fallback honors
114
+ * this.failMode fail-CLOSED (default) DENIES, fail-open allows by name.
115
+ * Exposed so the wiring is testable.
78
116
  */
79
117
  gateCheck(name: string, toolArgs: Record<string, unknown>): McpGateVerdict;
80
118
  /**
package/dist/proxy.js CHANGED
@@ -36,6 +36,12 @@ export class MCPProxy {
36
36
  riskStore;
37
37
  /** Confidence at/above which an evaluator deny escalates the whole session. */
38
38
  static ESCALATE_CONFIDENCE = 95;
39
+ /** Max background attempts to re-fetch the tool list after a listTools() failure. */
40
+ static SCOPE_RETRY_MAX = 5;
41
+ /** Base backoff (ms) for the exponential scope re-fetch retry (capped). */
42
+ static SCOPE_RETRY_BASE_MS = 500;
43
+ /** Ceiling (ms) for the exponential scope re-fetch backoff. */
44
+ static SCOPE_RETRY_MAX_MS = 30_000;
39
45
  /**
40
46
  * One stdio session per proxy process. Defaults to a per-process unique id
41
47
  * (not the old hardcoded constant) so verdict lines from distinct runs are
@@ -46,6 +52,19 @@ export class MCPProxy {
46
52
  agentId;
47
53
  /** Upstream tool names = the Layer 0 capability scope (populated in start()). */
48
54
  upstreamToolNames = new Set();
55
+ /**
56
+ * True once listTools() succeeds (any count) → the scope is KNOWN. False means
57
+ * the list has not been fetched or the fetch FAILED → scope unknown (degraded,
58
+ * fail-open by tool name). A known-but-empty scope is resolved=true with an
59
+ * empty set, which DENIES every call — not the same as unresolved.
60
+ */
61
+ capabilityScopeResolved = false;
62
+ /** One-shot throttle so the per-call degraded warning does not spam stderr. */
63
+ warnedScopeDegraded = false;
64
+ /** Count of background scope re-fetch attempts made after a listTools() failure. */
65
+ scopeRetryAttempts = 0;
66
+ /** True while a background scope re-fetch is in flight (prevents pile-up). */
67
+ scopeRetryInFlight = false;
49
68
  /** Tamper-evident chain over proxy-verdicts.jsonl (lazily keyed in connect()). */
50
69
  chain = null;
51
70
  constructor(config, deps = {}) {
@@ -64,12 +83,21 @@ export class MCPProxy {
64
83
  config.failMode ??
65
84
  (envFailMode === 'open' || envFailMode === 'closed' ? envFailMode : 'closed');
66
85
  this.evalTimeout = config.evalTimeout ?? 5000;
67
- this.sessionId =
68
- config.sessionId ?? `mcp-proxy-${process.pid}-${Date.now().toString(36)}`;
86
+ this.sessionId = config.sessionId ?? `mcp-proxy-${process.pid}-${Date.now().toString(36)}`;
69
87
  this.agentId = config.agentId ?? process.env['PANGUARD_AGENT_ID'] ?? 'mcp-agent';
70
- // Sync sub-ms pre-check. Runs in front of the async evaluator so the worst
71
- // payloads (and any session the brain flags) are blocked instantly — and,
72
- // with fail-closed as the default, an unavailable async evaluator denies.
88
+ // Sync sub-ms pre-check (InlineGate.onAction) that reads riskStore: once a
89
+ // session is escalated to 'high' it fast-blocks subsequent calls before the
90
+ // async evaluator even runs. That escalation is FED by recordEvalVerdict()
91
+ // (a real ATR deny -> riskStore.set high), so the session-risk loop is live.
92
+ //
93
+ // HONEST SCOPE (Community): the async behavioral brain — RiskAnalyzer.analyze
94
+ // via guard.onSessionActivity — is NOT driven here; it needs a real
95
+ // ContentDetector + per-session event feed and, more importantly, a real
96
+ // ContainmentController to act on its verdicts (Community ships Noop). So the
97
+ // detector is a deliberate no-op, not a forgotten wire: content detection is
98
+ // the ATR engine (evaluateToolCall/evaluateToolResponse) and session
99
+ // escalation is the sync riskStore path above. Wiring the behavioral brain +
100
+ // active containment is a Pro-tier layer, tracked separately.
73
101
  this.riskStore = new InMemoryRiskStore();
74
102
  this.guard = new GuardGate({
75
103
  gate: new InlineGate(),
@@ -100,14 +128,23 @@ export class MCPProxy {
100
128
  await this.client.connect(upstreamTransport);
101
129
  process.stderr.write(`[panguard-proxy] Connected to upstream\n`);
102
130
  // Cache upstream tool names as the Layer 0 capability scope: an agent may
103
- // only call tools the upstream actually exposes. Best-effort if the list
104
- // can't be fetched, the gate falls back to allowing the requested tool.
105
- try {
106
- const upstream = await this.client.listTools();
107
- this.upstreamToolNames = new Set(upstream.tools.map((t) => t.name));
108
- }
109
- catch {
110
- /* leave empty; per-call fallback allows the requested tool */
131
+ // only call tools the upstream actually exposes. If the list can't be fetched
132
+ // we do NOT latch a permanent fail-open: a bounded background retry re-tries
133
+ // the fetch (so a transient failure self-heals) and, until it resolves, the
134
+ // gate honors this.failMode — fail-CLOSED (the default) DENIES rather than
135
+ // waving unknown-scope calls through. See resolveCapabilityScope + gateCheck.
136
+ const resolved = await this.resolveCapabilityScope();
137
+ if (!resolved) {
138
+ // listTools failed → the Layer-0 capability scope is UNKNOWN (unresolved).
139
+ // Loudly flag it (a silent pass was indistinguishable from a real allow in
140
+ // logs) and start the background re-fetch so the scope can self-heal.
141
+ process.stderr.write(`[panguard-proxy] WARNING: upstream listTools() failed — Layer-0 capability-scope enforcement is DEGRADED. ` +
142
+ `Fail-mode='${this.failMode}': ` +
143
+ (this.failMode === 'closed'
144
+ ? 'tool calls are DENIED until the scope resolves.'
145
+ : 'tools are allowed by name (fail-open opt-in) until the scope resolves.') +
146
+ ' A bounded background retry is re-fetching the tool list.\n');
147
+ this.scheduleScopeRetry();
111
148
  }
112
149
  this.server = new Server({ name: 'panguard-mcp-proxy', version: '0.1.0' }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
113
150
  this.registerHandlers();
@@ -126,19 +163,115 @@ export class MCPProxy {
126
163
  // "FLAGGED (ask)". Document it here so operators know flagged calls still run.
127
164
  process.stderr.write("[panguard-proxy] Note: 'ask' verdicts are logged-and-forwarded (no MCP user prompt); watch stderr for FLAGGED (ask) lines.\n");
128
165
  }
166
+ /**
167
+ * Fetch the upstream tool list and cache it as the Layer 0 capability scope.
168
+ * Returns true on success (scope RESOLVED, regardless of count — a successful
169
+ * empty list is a known-empty scope that DENIES every call), false if
170
+ * listTools() throws (scope left UNRESOLVED). Never throws. Shared by connect()
171
+ * and the background retry so both paths update the scope identically.
172
+ */
173
+ async resolveCapabilityScope() {
174
+ if (!this.client)
175
+ return false;
176
+ try {
177
+ const upstream = await this.client.listTools();
178
+ this.upstreamToolNames = new Set(upstream.tools.map((t) => t.name));
179
+ this.capabilityScopeResolved = true;
180
+ // Reset the degraded-warning throttle so a later re-failure warns again.
181
+ this.warnedScopeDegraded = false;
182
+ return true;
183
+ }
184
+ catch {
185
+ this.capabilityScopeResolved = false;
186
+ return false;
187
+ }
188
+ }
189
+ /**
190
+ * Schedule a bounded, exponential-backoff background re-fetch of the tool list
191
+ * so a transient listTools() failure self-heals instead of latching a degraded
192
+ * capability scope for the whole proxy lifetime. Fire-and-forget (never blocks a
193
+ * tool call); at most one attempt is in flight at a time and total attempts are
194
+ * capped by SCOPE_RETRY_MAX.
195
+ */
196
+ scheduleScopeRetry() {
197
+ if (this.capabilityScopeResolved || this.scopeRetryInFlight)
198
+ return;
199
+ if (this.scopeRetryAttempts >= MCPProxy.SCOPE_RETRY_MAX)
200
+ return;
201
+ this.scopeRetryInFlight = true;
202
+ const attempt = this.scopeRetryAttempts;
203
+ const delay = Math.min(MCPProxy.SCOPE_RETRY_BASE_MS * 2 ** attempt, MCPProxy.SCOPE_RETRY_MAX_MS);
204
+ const timer = setTimeout(() => {
205
+ void this.runScopeRetry();
206
+ }, delay);
207
+ // Do not keep the event loop alive solely for this retry timer.
208
+ if (typeof timer.unref === 'function')
209
+ timer.unref();
210
+ }
211
+ /** Execute one background scope re-fetch attempt, then reschedule if needed. */
212
+ async runScopeRetry() {
213
+ this.scopeRetryAttempts += 1;
214
+ const ok = await this.resolveCapabilityScope();
215
+ this.scopeRetryInFlight = false;
216
+ if (ok) {
217
+ process.stderr.write(`[panguard-proxy] Layer-0 capability scope RESOLVED after retry (${this.upstreamToolNames.size} tools); tool-scope enforcement restored.\n`);
218
+ return;
219
+ }
220
+ // Still failing — back off and try again until the attempt cap is reached.
221
+ this.scheduleScopeRetry();
222
+ }
129
223
  /**
130
224
  * Run the Layer 1 inline gate for a tool call (sync, sub-ms): build the
131
225
  * ActionContext and apply the gate. Capabilities default to the upstream tool
132
- * set (Layer 0 scope); when unknown, the requested tool is allowed so the gate
133
- * only adds block-on-sight + risk gating. Exposed so the wiring is testable.
226
+ * set (Layer 0 scope); when the scope is unresolved the fallback honors
227
+ * this.failMode fail-CLOSED (default) DENIES, fail-open allows by name.
228
+ * Exposed so the wiring is testable.
134
229
  */
135
230
  gateCheck(name, toolArgs) {
231
+ // Decide the capability set from whether the scope is RESOLVED, never from
232
+ // its size — that was the bug: a successful-but-empty list looked identical
233
+ // to "no list yet" and fell through to a silent allow.
234
+ if (this.capabilityScopeResolved) {
235
+ // Known scope. If it is empty, the upstream exposes no tools, so every call
236
+ // is out-of-scope and applyMcpGate DENIES it. No fallback, no silent allow.
237
+ return applyMcpGate(this.guard, {
238
+ name,
239
+ args: toolArgs,
240
+ sessionId: this.sessionId,
241
+ agentId: this.agentId,
242
+ capabilities: this.upstreamToolNames,
243
+ });
244
+ }
245
+ // Unknown scope (listTools failed / not yet run). Nudge a bounded background
246
+ // re-fetch so a transient failure does not permanently disable tool-scope,
247
+ // then decide THIS call by failMode — never an unconditional fail-open.
248
+ this.scheduleScopeRetry();
249
+ if (!this.warnedScopeDegraded) {
250
+ this.warnedScopeDegraded = true;
251
+ process.stderr.write(`[panguard-proxy] scope-degraded: capability scope unresolved (fail-${this.failMode}) — ` +
252
+ (this.failMode === 'closed'
253
+ ? `tools (starting with '${name}') are DENIED until the upstream tool list resolves.`
254
+ : `tools (starting with '${name}') pass Layer-0 by name fallback (fail-open opt-in). Content rules still evaluate them; tool-scope is NOT enforced until the upstream tool list is available.`) +
255
+ '\n');
256
+ }
257
+ if (this.failMode === 'closed') {
258
+ // Fail-CLOSED default (security-first, mirrors the evaluator): an unknown
259
+ // capability scope must not wave calls through. Deny until it resolves.
260
+ return {
261
+ allow: false,
262
+ reason: 'Capability scope is unresolved (upstream tool list unavailable); denying under fail-closed policy. ' +
263
+ 'Set PANGUARD_PROXY_FAIL_MODE=open to allow tools by name while degraded.',
264
+ escalated: false,
265
+ };
266
+ }
267
+ // Fail-open opt-in: allow by name to preserve availability. This gap is
268
+ // capability-scope only; the inline content gate below still runs.
136
269
  return applyMcpGate(this.guard, {
137
270
  name,
138
271
  args: toolArgs,
139
272
  sessionId: this.sessionId,
140
273
  agentId: this.agentId,
141
- capabilities: this.upstreamToolNames.size > 0 ? this.upstreamToolNames : new Set([name]),
274
+ capabilities: new Set([name]),
142
275
  });
143
276
  }
144
277
  /**
@@ -149,7 +282,11 @@ export class MCPProxy {
149
282
  * cannot lock out a legitimate agent.
150
283
  */
151
284
  recordEvalVerdict(verdict) {
152
- if (verdict.outcome === 'deny' && verdict.confidence >= MCPProxy.ESCALATE_CONFIDENCE) {
285
+ // Normalize confidence to a 0-100 scale before the threshold check: the ATR
286
+ // engine reports match confidence on a 0-1 scale, so comparing it raw to a
287
+ // 0-100 threshold (95) made this escalation NEVER fire for a real deny.
288
+ const conf = verdict.confidence <= 1 ? verdict.confidence * 100 : verdict.confidence;
289
+ if (verdict.outcome === 'deny' && conf >= MCPProxy.ESCALATE_CONFIDENCE) {
153
290
  this.riskStore.set(this.sessionId, { level: 'high', reasons: [...verdict.matchedRules] });
154
291
  }
155
292
  }
@@ -238,11 +375,17 @@ export class MCPProxy {
238
375
  ],
239
376
  };
240
377
  }
241
- // PreToolUse: evaluate the call
378
+ // PreToolUse: evaluate the call as a 'tool_call' event so the full
379
+ // tool_call rule family (shell injection, SSRF, SQLi, credential theft,
380
+ // privilege escalation, tool poisoning) runs — an MCP tool call carries
381
+ // exactly those payloads in its args. Via the engine's mcp-over-tool
382
+ // exception this is a superset of the mcp_exchange rules, so nothing that
383
+ // 'mcp_exchange' caught is lost. (The old default silently skipped ~44
384
+ // tool_call rules on every MCP call.)
242
385
  let preResult;
243
386
  try {
244
387
  preResult = await Promise.race([
245
- this.evaluator.evaluateToolCall(name, toolArgs),
388
+ this.evaluator.evaluateToolCall(name, toolArgs, 'tool_call'),
246
389
  new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), this.evalTimeout)),
247
390
  ]);
248
391
  }
@@ -287,11 +430,25 @@ export class MCPProxy {
287
430
  }
288
431
  // Forward to upstream
289
432
  const result = await client.callTool({ name, arguments: toolArgs });
290
- // PostToolUse: evaluate the response
433
+ // PostToolUse: evaluate the response. Serialize EVERY content block —
434
+ // text AND non-text (resource URIs, embedded resource.text/blob, image
435
+ // data) — so a malicious server cannot smuggle an exfil/injection payload
436
+ // through a resource/image block that a text-only extractor would silently
437
+ // drop. Cap is 256KB (not 10KB) so padding the payload past a tiny cap no
438
+ // longer evades the scan.
291
439
  const responseText = result.content
292
- ?.map((c) => c.text ?? '')
440
+ ?.map((c) => {
441
+ if (typeof c['text'] === 'string')
442
+ return c['text'];
443
+ try {
444
+ return JSON.stringify(c);
445
+ }
446
+ catch {
447
+ return '';
448
+ }
449
+ })
293
450
  .join('\n')
294
- .slice(0, 10000); // Cap at 10KB for evaluation
451
+ .slice(0, 262144);
295
452
  if (responseText) {
296
453
  let postResult;
297
454
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panguard-ai/panguard-mcp-proxy",
3
- "version": "1.7.3",
3
+ "version": "1.8.2",
4
4
  "description": "MCP Proxy — runtime interception for AI agent tool calls using ATR rules",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,13 +20,13 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.12.0",
23
- "agent-threat-rules": "^3.5.0",
24
- "@panguard-ai/atr": "1.7.3",
23
+ "agent-threat-rules": "^3.5.6",
24
+ "@panguard-ai/atr": "1.8.2",
25
25
  "@panguard-ai/containment": "0.1.0",
26
- "@panguard-ai/panguard-guard": "1.7.3"
26
+ "@panguard-ai/panguard-guard": "1.8.2"
27
27
  },
28
28
  "peerDependencies": {
29
- "@panguard-ai/atr": "1.7.3"
29
+ "@panguard-ai/atr": "1.8.2"
30
30
  },
31
31
  "files": [
32
32
  "dist",
@@ -34,6 +34,14 @@
34
34
  "README.md",
35
35
  "LICENSE"
36
36
  ],
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/panguard-ai/panguard-ai.git",
40
+ "directory": "packages/panguard-mcp-proxy"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
37
45
  "publishConfig": {
38
46
  "access": "public"
39
47
  },