@panguard-ai/panguard-mcp-proxy 1.5.6 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/proxy.d.ts +47 -1
- package/dist/proxy.js +103 -11
- package/package.json +7 -5
package/dist/proxy.d.ts
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* @module @panguard-ai/panguard-mcp-proxy/proxy
|
|
13
13
|
*/
|
|
14
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
15
|
+
import type { EvalResult } from './evaluator.js';
|
|
16
|
+
import type { McpGateVerdict } from '@panguard-ai/containment';
|
|
14
17
|
export interface ProxyConfig {
|
|
15
18
|
/** Command to start the upstream MCP server */
|
|
16
19
|
readonly upstreamCommand: string;
|
|
@@ -21,6 +24,12 @@ export interface ProxyConfig {
|
|
|
21
24
|
/** Fail mode: 'closed' blocks on error (safer), 'open' allows on error (default for availability) */
|
|
22
25
|
readonly failMode?: 'open' | 'closed';
|
|
23
26
|
}
|
|
27
|
+
/** The subset of ProxyEvaluator the proxy uses — injectable for testing. */
|
|
28
|
+
export interface ProxyEvaluatorLike {
|
|
29
|
+
loadRules(): Promise<number>;
|
|
30
|
+
evaluateToolCall(toolName: string, args: Record<string, unknown>): Promise<EvalResult>;
|
|
31
|
+
evaluateToolResponse(toolName: string, response: string): Promise<EvalResult>;
|
|
32
|
+
}
|
|
24
33
|
export declare class MCPProxy {
|
|
25
34
|
private readonly config;
|
|
26
35
|
private readonly evaluator;
|
|
@@ -28,7 +37,44 @@ export declare class MCPProxy {
|
|
|
28
37
|
private server;
|
|
29
38
|
private readonly evalTimeout;
|
|
30
39
|
private readonly failMode;
|
|
31
|
-
|
|
40
|
+
/** Layer 1 inline gate. The ProxyEvaluator stays the Layer 2 brain. */
|
|
41
|
+
private readonly guard;
|
|
42
|
+
/** Session risk: the brain (evaluator verdicts) writes, the inline gate reads. */
|
|
43
|
+
private readonly riskStore;
|
|
44
|
+
/** Confidence at/above which an evaluator deny escalates the whole session. */
|
|
45
|
+
private static readonly ESCALATE_CONFIDENCE;
|
|
46
|
+
/** One stdio session per proxy process. */
|
|
47
|
+
private readonly sessionId;
|
|
48
|
+
/** Upstream tool names = the Layer 0 capability scope (populated in start()). */
|
|
49
|
+
private upstreamToolNames;
|
|
50
|
+
constructor(config: ProxyConfig, deps?: {
|
|
51
|
+
evaluator?: ProxyEvaluatorLike;
|
|
52
|
+
});
|
|
32
53
|
start(): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Wire the proxy between an upstream (client) transport and an agent (server)
|
|
56
|
+
* transport. Extracted from start() so tests can drive the full flow over
|
|
57
|
+
* in-memory transports without spawning a process.
|
|
58
|
+
*/
|
|
59
|
+
connect(upstreamTransport: Transport, agentTransport: Transport): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Run the Layer 1 inline gate for a tool call (sync, sub-ms): build the
|
|
62
|
+
* ActionContext and apply the gate. Capabilities default to the upstream tool
|
|
63
|
+
* set (Layer 0 scope); when unknown, the requested tool is allowed so the gate
|
|
64
|
+
* only adds block-on-sight + risk gating. Exposed so the wiring is testable.
|
|
65
|
+
*/
|
|
66
|
+
gateCheck(name: string, toolArgs: Record<string, unknown>): McpGateVerdict;
|
|
67
|
+
/**
|
|
68
|
+
* Feed an async-evaluator verdict back into session risk — the dual-path
|
|
69
|
+
* loop. A high-confidence deny escalates the session so the inline gate
|
|
70
|
+
* fast-blocks subsequent calls without re-evaluating. The threshold is
|
|
71
|
+
* deliberately high (ATR precision is ~99.6%) so a single false positive
|
|
72
|
+
* cannot lock out a legitimate agent.
|
|
73
|
+
*/
|
|
74
|
+
recordEvalVerdict(verdict: {
|
|
75
|
+
outcome: string;
|
|
76
|
+
confidence: number;
|
|
77
|
+
matchedRules: readonly string[];
|
|
78
|
+
}): void;
|
|
33
79
|
private registerHandlers;
|
|
34
80
|
}
|
package/dist/proxy.js
CHANGED
|
@@ -20,6 +20,7 @@ import { appendFileSync, mkdirSync } from 'node:fs';
|
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { homedir } from 'node:os';
|
|
22
22
|
import { ProxyEvaluator } from './evaluator.js';
|
|
23
|
+
import { GuardGate, InlineGate, RiskAnalyzer, InMemoryRiskStore, NoopContainmentController, applyMcpGate, } from '@panguard-ai/containment';
|
|
23
24
|
const VERDICT_LOG = join(homedir(), '.panguard-guard', 'proxy-verdicts.jsonl');
|
|
24
25
|
function logVerdict(entry) {
|
|
25
26
|
try {
|
|
@@ -37,33 +38,100 @@ export class MCPProxy {
|
|
|
37
38
|
server = null;
|
|
38
39
|
evalTimeout;
|
|
39
40
|
failMode;
|
|
40
|
-
|
|
41
|
+
/** Layer 1 inline gate. The ProxyEvaluator stays the Layer 2 brain. */
|
|
42
|
+
guard;
|
|
43
|
+
/** Session risk: the brain (evaluator verdicts) writes, the inline gate reads. */
|
|
44
|
+
riskStore;
|
|
45
|
+
/** Confidence at/above which an evaluator deny escalates the whole session. */
|
|
46
|
+
static ESCALATE_CONFIDENCE = 95;
|
|
47
|
+
/** One stdio session per proxy process. */
|
|
48
|
+
sessionId = 'mcp-proxy-session';
|
|
49
|
+
/** Upstream tool names = the Layer 0 capability scope (populated in start()). */
|
|
50
|
+
upstreamToolNames = new Set();
|
|
51
|
+
constructor(config, deps = {}) {
|
|
41
52
|
this.config = config;
|
|
42
|
-
this.evaluator = new ProxyEvaluator();
|
|
43
|
-
|
|
53
|
+
this.evaluator = deps.evaluator ?? new ProxyEvaluator();
|
|
54
|
+
// Fail-OPEN by default: PanGuard must never become the failure point in the
|
|
55
|
+
// agent's hot path. If the async evaluator times out or errors (e.g. rules
|
|
56
|
+
// still loading on cold start), the tool call proceeds — the sync pre-check
|
|
57
|
+
// (GuardGate, below) still blocks the worst payloads instantly regardless of
|
|
58
|
+
// this mode. Opt into 'closed' only for high-assurance deployments that
|
|
59
|
+
// accept blocking the agent when the evaluator is unavailable.
|
|
60
|
+
this.failMode = config.failMode ?? 'open';
|
|
44
61
|
this.evalTimeout = config.evalTimeout ?? 5000;
|
|
62
|
+
// Sync sub-ms pre-check. Runs in front of the async evaluator so the worst
|
|
63
|
+
// payloads (and any session the brain flags) are blocked instantly — even
|
|
64
|
+
// if the async evaluator times out fail-open.
|
|
65
|
+
this.riskStore = new InMemoryRiskStore();
|
|
66
|
+
this.guard = new GuardGate({
|
|
67
|
+
gate: new InlineGate(),
|
|
68
|
+
analyzer: new RiskAnalyzer({ detect: () => [] }),
|
|
69
|
+
riskStore: this.riskStore,
|
|
70
|
+
containment: new NoopContainmentController(),
|
|
71
|
+
});
|
|
45
72
|
}
|
|
46
73
|
async start() {
|
|
47
|
-
// Load ATR rules
|
|
48
|
-
const ruleCount = await this.evaluator.loadRules();
|
|
49
|
-
process.stderr.write(`[panguard-proxy] Loaded ${ruleCount} ATR rules\n`);
|
|
50
|
-
// Connect to upstream MCP server
|
|
51
74
|
const upstreamTransport = new StdioClientTransport({
|
|
52
75
|
command: this.config.upstreamCommand,
|
|
53
76
|
args: [...this.config.upstreamArgs],
|
|
54
77
|
stderr: 'pipe',
|
|
55
78
|
});
|
|
79
|
+
const agentTransport = new StdioServerTransport();
|
|
80
|
+
await this.connect(upstreamTransport, agentTransport);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Wire the proxy between an upstream (client) transport and an agent (server)
|
|
84
|
+
* transport. Extracted from start() so tests can drive the full flow over
|
|
85
|
+
* in-memory transports without spawning a process.
|
|
86
|
+
*/
|
|
87
|
+
async connect(upstreamTransport, agentTransport) {
|
|
88
|
+
const ruleCount = await this.evaluator.loadRules();
|
|
89
|
+
process.stderr.write(`[panguard-proxy] Loaded ${ruleCount} ATR rules\n`);
|
|
56
90
|
this.client = new Client({ name: 'panguard-mcp-proxy', version: '0.1.0' }, { capabilities: {} });
|
|
57
91
|
await this.client.connect(upstreamTransport);
|
|
58
|
-
process.stderr.write(`[panguard-proxy] Connected to upstream
|
|
59
|
-
//
|
|
92
|
+
process.stderr.write(`[panguard-proxy] Connected to upstream\n`);
|
|
93
|
+
// Cache upstream tool names as the Layer 0 capability scope: an agent may
|
|
94
|
+
// only call tools the upstream actually exposes. Best-effort — if the list
|
|
95
|
+
// can't be fetched, the gate falls back to allowing the requested tool.
|
|
96
|
+
try {
|
|
97
|
+
const upstream = await this.client.listTools();
|
|
98
|
+
this.upstreamToolNames = new Set(upstream.tools.map((t) => t.name));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* leave empty; per-call fallback allows the requested tool */
|
|
102
|
+
}
|
|
60
103
|
this.server = new Server({ name: 'panguard-mcp-proxy', version: '0.1.0' }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
|
|
61
104
|
this.registerHandlers();
|
|
62
|
-
// Connect to agent via stdio
|
|
63
|
-
const agentTransport = new StdioServerTransport();
|
|
64
105
|
await this.server.connect(agentTransport);
|
|
65
106
|
process.stderr.write(`[panguard-proxy] Proxy active. ${ruleCount} rules protecting all tool calls.\n`);
|
|
66
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Run the Layer 1 inline gate for a tool call (sync, sub-ms): build the
|
|
110
|
+
* ActionContext and apply the gate. Capabilities default to the upstream tool
|
|
111
|
+
* set (Layer 0 scope); when unknown, the requested tool is allowed so the gate
|
|
112
|
+
* only adds block-on-sight + risk gating. Exposed so the wiring is testable.
|
|
113
|
+
*/
|
|
114
|
+
gateCheck(name, toolArgs) {
|
|
115
|
+
return applyMcpGate(this.guard, {
|
|
116
|
+
name,
|
|
117
|
+
args: toolArgs,
|
|
118
|
+
sessionId: this.sessionId,
|
|
119
|
+
agentId: 'mcp-agent',
|
|
120
|
+
capabilities: this.upstreamToolNames.size > 0 ? this.upstreamToolNames : new Set([name]),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Feed an async-evaluator verdict back into session risk — the dual-path
|
|
125
|
+
* loop. A high-confidence deny escalates the session so the inline gate
|
|
126
|
+
* fast-blocks subsequent calls without re-evaluating. The threshold is
|
|
127
|
+
* deliberately high (ATR precision is ~99.6%) so a single false positive
|
|
128
|
+
* cannot lock out a legitimate agent.
|
|
129
|
+
*/
|
|
130
|
+
recordEvalVerdict(verdict) {
|
|
131
|
+
if (verdict.outcome === 'deny' && verdict.confidence >= MCPProxy.ESCALATE_CONFIDENCE) {
|
|
132
|
+
this.riskStore.set(this.sessionId, { level: 'high', reasons: [...verdict.matchedRules] });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
67
135
|
registerHandlers() {
|
|
68
136
|
const client = this.client;
|
|
69
137
|
const server = this.server;
|
|
@@ -76,6 +144,27 @@ export class MCPProxy {
|
|
|
76
144
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
77
145
|
const { name, arguments: args } = request.params;
|
|
78
146
|
const toolArgs = (args ?? {});
|
|
147
|
+
// Layer 1 inline gate (sync, sub-ms) — runs BEFORE the async evaluator so
|
|
148
|
+
// the worst payloads (and any session the brain has flagged) are blocked
|
|
149
|
+
// instantly, even if the async evaluator times out fail-open.
|
|
150
|
+
const gateVerdict = this.gateCheck(name, toolArgs);
|
|
151
|
+
if (!gateVerdict.allow) {
|
|
152
|
+
logVerdict({
|
|
153
|
+
phase: 'pre-gate',
|
|
154
|
+
tool: name,
|
|
155
|
+
outcome: 'deny',
|
|
156
|
+
reason: gateVerdict.reason ?? '',
|
|
157
|
+
});
|
|
158
|
+
process.stderr.write(`[panguard-proxy] BLOCKED (inline gate): ${name} — ${gateVerdict.reason}\n`);
|
|
159
|
+
return {
|
|
160
|
+
content: [
|
|
161
|
+
{
|
|
162
|
+
type: 'text',
|
|
163
|
+
text: `[BLOCKED by PanGuard] Tool call "${name}" was blocked.\nReason: ${gateVerdict.reason}`,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
79
168
|
// PreToolUse: evaluate the call
|
|
80
169
|
let preResult;
|
|
81
170
|
try {
|
|
@@ -103,6 +192,8 @@ export class MCPProxy {
|
|
|
103
192
|
rules: preResult.matchedRules,
|
|
104
193
|
ms: preResult.durationMs,
|
|
105
194
|
});
|
|
195
|
+
// Close the dual-path loop: a high-confidence deny escalates the session.
|
|
196
|
+
this.recordEvalVerdict(preResult);
|
|
106
197
|
if (preResult.outcome === 'deny') {
|
|
107
198
|
process.stderr.write(`[panguard-proxy] BLOCKED: ${name} — ${preResult.reason}\n`);
|
|
108
199
|
return {
|
|
@@ -147,6 +238,7 @@ export class MCPProxy {
|
|
|
147
238
|
rules: postResult.matchedRules,
|
|
148
239
|
ms: postResult.durationMs,
|
|
149
240
|
});
|
|
241
|
+
this.recordEvalVerdict(postResult);
|
|
150
242
|
if (postResult.outcome === 'deny') {
|
|
151
243
|
process.stderr.write(`[panguard-proxy] BLOCKED response: ${name} — ${postResult.reason}\n`);
|
|
152
244
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panguard-ai/panguard-mcp-proxy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
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",
|
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
13
|
-
"agent-threat-rules": "^
|
|
14
|
-
"@panguard-ai/
|
|
13
|
+
"agent-threat-rules": "^3.4.0",
|
|
14
|
+
"@panguard-ai/containment": "0.1.0",
|
|
15
|
+
"@panguard-ai/atr": "1.6.1"
|
|
15
16
|
},
|
|
16
17
|
"peerDependencies": {
|
|
17
|
-
"@panguard-ai/atr": "1.
|
|
18
|
+
"@panguard-ai/atr": "1.6.1"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"dist",
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
"license": "MIT",
|
|
28
29
|
"scripts": {
|
|
29
30
|
"build": "tsc",
|
|
30
|
-
"dev": "tsx src/index.ts"
|
|
31
|
+
"dev": "tsx src/index.ts",
|
|
32
|
+
"test": "vitest run"
|
|
31
33
|
}
|
|
32
34
|
}
|