@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 +18 -0
- package/dist/evaluator.d.ts +35 -3
- package/dist/evaluator.js +36 -5
- package/dist/proxy.d.ts +41 -3
- package/dist/proxy.js +179 -22
- package/package.json +13 -5
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
|
package/dist/evaluator.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
/**
|
|
126
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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: '
|
|
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
|
|
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
|
|
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 = {}) {
|
|
@@ -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.
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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.
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
133
|
-
*
|
|
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:
|
|
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
|
-
|
|
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) =>
|
|
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,
|
|
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.
|
|
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.
|
|
24
|
-
"@panguard-ai/atr": "1.
|
|
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.
|
|
26
|
+
"@panguard-ai/panguard-guard": "1.8.2"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"@panguard-ai/atr": "1.
|
|
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
|
},
|