@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 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
- constructor(config: ProxyConfig);
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
- constructor(config) {
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
- this.failMode = config.failMode ?? 'closed';
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: ${this.config.upstreamCommand}\n`);
59
- // Create proxy server facing the agent
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.5.6",
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": "^2.1.1",
14
- "@panguard-ai/atr": "1.5.6"
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.5.6"
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
  }