@panguard-ai/panguard-mcp-proxy 1.8.0 → 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/dist/evaluator.d.ts +19 -1
- package/dist/evaluator.js +20 -2
- package/dist/proxy.d.ts +40 -2
- package/dist/proxy.js +135 -11
- package/package.json +4 -4
package/dist/evaluator.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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: '
|
|
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
|
|
77
|
-
*
|
|
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.
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
142
|
-
*
|
|
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:
|
|
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.
|
|
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",
|
|
@@ -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/
|
|
24
|
+
"@panguard-ai/atr": "1.8.2",
|
|
25
25
|
"@panguard-ai/containment": "0.1.0",
|
|
26
|
-
"@panguard-ai/
|
|
26
|
+
"@panguard-ai/panguard-guard": "1.8.2"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"@panguard-ai/atr": "1.8.
|
|
29
|
+
"@panguard-ai/atr": "1.8.2"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|