@panguard-ai/panguard-mcp-proxy 1.8.0 → 1.8.3

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.
@@ -68,7 +68,25 @@ export declare class ProxyEvaluator {
68
68
  * and false-fired multi_agent_comm rules on ordinary commands.
69
69
  */
70
70
  evaluateToolCall(toolName: string, args: Record<string, unknown>, eventType?: AgentEvent['type']): Promise<EvalResult>;
71
- /** Evaluate a tool response (PostToolUse) */
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
+ */
72
90
  evaluateToolResponse(toolName: string, response: string): Promise<EvalResult>;
73
91
  private evaluate;
74
92
  }
package/dist/evaluator.js CHANGED
@@ -162,11 +162,29 @@ export class ProxyEvaluator {
162
162
  };
163
163
  return this.evaluate(event, start);
164
164
  }
165
- /** 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
+ */
166
184
  async evaluateToolResponse(toolName, response) {
167
185
  const start = Date.now();
168
186
  const event = {
169
- type: 'mcp_exchange',
187
+ type: 'tool_response',
170
188
  timestamp: new Date().toISOString(),
171
189
  content: response,
172
190
  fields: {
package/dist/proxy.d.ts CHANGED
@@ -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 = {}) {
@@ -109,14 +128,23 @@ export class MCPProxy {
109
128
  await this.client.connect(upstreamTransport);
110
129
  process.stderr.write(`[panguard-proxy] Connected to upstream\n`);
111
130
  // Cache upstream tool names as the Layer 0 capability scope: an agent may
112
- // only call tools the upstream actually exposes. Best-effort if the list
113
- // can't be fetched, the gate falls back to allowing the requested tool.
114
- try {
115
- const upstream = await this.client.listTools();
116
- this.upstreamToolNames = new Set(upstream.tools.map((t) => t.name));
117
- }
118
- catch {
119
- /* 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();
120
148
  }
121
149
  this.server = new Server({ name: 'panguard-mcp-proxy', version: '0.1.0' }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
122
150
  this.registerHandlers();
@@ -135,19 +163,115 @@ export class MCPProxy {
135
163
  // "FLAGGED (ask)". Document it here so operators know flagged calls still run.
136
164
  process.stderr.write("[panguard-proxy] Note: 'ask' verdicts are logged-and-forwarded (no MCP user prompt); watch stderr for FLAGGED (ask) lines.\n");
137
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
+ }
138
223
  /**
139
224
  * Run the Layer 1 inline gate for a tool call (sync, sub-ms): build the
140
225
  * ActionContext and apply the gate. Capabilities default to the upstream tool
141
- * set (Layer 0 scope); when unknown, the requested tool is allowed so the gate
142
- * 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.
143
229
  */
144
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.
145
269
  return applyMcpGate(this.guard, {
146
270
  name,
147
271
  args: toolArgs,
148
272
  sessionId: this.sessionId,
149
273
  agentId: this.agentId,
150
- capabilities: this.upstreamToolNames.size > 0 ? this.upstreamToolNames : new Set([name]),
274
+ capabilities: new Set([name]),
151
275
  });
152
276
  }
153
277
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panguard-ai/panguard-mcp-proxy",
3
- "version": "1.8.0",
3
+ "version": "1.8.3",
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",
@@ -21,12 +21,12 @@
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.12.0",
23
23
  "agent-threat-rules": "^3.5.6",
24
- "@panguard-ai/panguard-guard": "1.8.0",
25
- "@panguard-ai/containment": "0.1.0",
26
- "@panguard-ai/atr": "1.8.0"
24
+ "@panguard-ai/atr": "1.8.3",
25
+ "@panguard-ai/panguard-guard": "1.8.3",
26
+ "@panguard-ai/containment": "0.1.0"
27
27
  },
28
28
  "peerDependencies": {
29
- "@panguard-ai/atr": "1.8.0"
29
+ "@panguard-ai/atr": "1.8.3"
30
30
  },
31
31
  "files": [
32
32
  "dist",