@panguard-ai/panguard-mcp-proxy 1.8.3 → 1.8.5
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 +42 -0
- package/dist/evaluator.js +97 -10
- package/dist/proxy.js +2 -0
- package/package.json +6 -6
package/dist/evaluator.d.ts
CHANGED
|
@@ -13,7 +13,26 @@ export interface EvalResult {
|
|
|
13
13
|
readonly matchedRules: readonly string[];
|
|
14
14
|
readonly confidence: number;
|
|
15
15
|
readonly durationMs: number;
|
|
16
|
+
/**
|
|
17
|
+
* Gap A slice 2: true when a non-'allow' outcome is driven ONLY by advise-only
|
|
18
|
+
* (fresh, untrusted auto-pulled) rules — i.e. no bundled/trusted rule justifies
|
|
19
|
+
* it. A caller MUST NOT escalate such an outcome to a hard block (even under an
|
|
20
|
+
* enforce posture), so a fresh rule can never wall off a tool call until the
|
|
21
|
+
* user trusts it. Always false for 'deny' (deny only ever comes from a trusted
|
|
22
|
+
* rule) and for 'allow'.
|
|
23
|
+
*/
|
|
24
|
+
readonly adviseOnly: boolean;
|
|
16
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* The synthetic matchedRules id the evaluator emits when its OWN evaluation
|
|
28
|
+
* throws (a fail-closed 'deny' produced by the catch below, NOT a real rule
|
|
29
|
+
* match). Exported as the single source of truth so the tool-call hook can
|
|
30
|
+
* detect an engine crash and fail OPEN (never brick the agent) instead of
|
|
31
|
+
* treating it as a real block — see panguard/cli/commands/hook.ts. Keep the two
|
|
32
|
+
* sides coupled through THIS constant, never a bare string, so a rename can't
|
|
33
|
+
* silently break the fail-open contract.
|
|
34
|
+
*/
|
|
35
|
+
export declare const EVALUATION_ERROR_SENTINEL = "evaluation-error";
|
|
17
36
|
/** Minimal rule shape the deny policy needs. */
|
|
18
37
|
interface RuleLike {
|
|
19
38
|
readonly severity: string;
|
|
@@ -42,6 +61,15 @@ export declare class ProxyEvaluator {
|
|
|
42
61
|
private readonly engine;
|
|
43
62
|
private rulesLoaded;
|
|
44
63
|
private ruleCount;
|
|
64
|
+
/**
|
|
65
|
+
* Gap A slice 2: IDs of auto-pulled rules that are ADVISE-ONLY — loaded fresh
|
|
66
|
+
* from a not-yet-trusted bundle. They may surface an 'ask' advisory but are
|
|
67
|
+
* EXCLUDED from the hard-deny decision, so a fresh rule can never silently
|
|
68
|
+
* block the user's tool. Once the user trusts the bundle version, the hook
|
|
69
|
+
* loads them with adviseOnly=false and they are absent from this set (i.e.
|
|
70
|
+
* they arm). Bundled/shipped rules are never in this set.
|
|
71
|
+
*/
|
|
72
|
+
private readonly adviseOnlyIds;
|
|
45
73
|
private blockedTools;
|
|
46
74
|
private readonly blocklistPath;
|
|
47
75
|
private blocklistMtime;
|
|
@@ -50,7 +78,21 @@ export declare class ProxyEvaluator {
|
|
|
50
78
|
/** Reload blocklist from disk if modified (called before each evaluation) */
|
|
51
79
|
private refreshBlocklist;
|
|
52
80
|
loadRules(): Promise<number>;
|
|
81
|
+
/**
|
|
82
|
+
* Gap A slice 2: merge auto-pulled rules (staged + integrity-verified by the
|
|
83
|
+
* daemon) into the engine. ONLY rules whose id is not already bundled are
|
|
84
|
+
* added — a newer bundle re-ships existing ids, and those stay as their trusted
|
|
85
|
+
* bundled copy. `adviseOnly` marks the fresh ids so evaluate() lets them advise
|
|
86
|
+
* but never hard-deny (see adviseOnlyIds). Best-effort: a failure here must
|
|
87
|
+
* never break the enforce path, so it is caught and the proxy keeps running on
|
|
88
|
+
* the bundled ruleset alone. Returns the number of fresh rules added.
|
|
89
|
+
*/
|
|
90
|
+
loadAutoRules(rulesDir: string, opts: {
|
|
91
|
+
adviseOnly: boolean;
|
|
92
|
+
}): Promise<number>;
|
|
53
93
|
getRuleCount(): number;
|
|
94
|
+
/** Number of auto-pulled rules currently held advise-only (not yet trusted). */
|
|
95
|
+
getAdviseOnlyCount(): number;
|
|
54
96
|
/** Flatten args into a readable string for ATR regex matching */
|
|
55
97
|
private flattenArgs;
|
|
56
98
|
/**
|
package/dist/evaluator.js
CHANGED
|
@@ -28,6 +28,16 @@ function findRulesDir() {
|
|
|
28
28
|
}
|
|
29
29
|
throw new Error('Cannot find ATR rules directory. Install agent-threat-rules.');
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The synthetic matchedRules id the evaluator emits when its OWN evaluation
|
|
33
|
+
* throws (a fail-closed 'deny' produced by the catch below, NOT a real rule
|
|
34
|
+
* match). Exported as the single source of truth so the tool-call hook can
|
|
35
|
+
* detect an engine crash and fail OPEN (never brick the agent) instead of
|
|
36
|
+
* treating it as a real block — see panguard/cli/commands/hook.ts. Keep the two
|
|
37
|
+
* sides coupled through THIS constant, never a bare string, so a rename can't
|
|
38
|
+
* silently break the fail-open contract.
|
|
39
|
+
*/
|
|
40
|
+
export const EVALUATION_ERROR_SENTINEL = 'evaluation-error';
|
|
31
41
|
/**
|
|
32
42
|
* Whether a single rule match is strong enough to HARD-DENY a live tool call
|
|
33
43
|
* (vs. degrade to 'ask'). This is the proxy's false-positive control point: the
|
|
@@ -56,6 +66,15 @@ export class ProxyEvaluator {
|
|
|
56
66
|
engine;
|
|
57
67
|
rulesLoaded = false;
|
|
58
68
|
ruleCount = 0;
|
|
69
|
+
/**
|
|
70
|
+
* Gap A slice 2: IDs of auto-pulled rules that are ADVISE-ONLY — loaded fresh
|
|
71
|
+
* from a not-yet-trusted bundle. They may surface an 'ask' advisory but are
|
|
72
|
+
* EXCLUDED from the hard-deny decision, so a fresh rule can never silently
|
|
73
|
+
* block the user's tool. Once the user trusts the bundle version, the hook
|
|
74
|
+
* loads them with adviseOnly=false and they are absent from this set (i.e.
|
|
75
|
+
* they arm). Bundled/shipped rules are never in this set.
|
|
76
|
+
*/
|
|
77
|
+
adviseOnlyIds = new Set();
|
|
59
78
|
blockedTools = new Set();
|
|
60
79
|
blocklistPath;
|
|
61
80
|
blocklistMtime = 0;
|
|
@@ -110,9 +129,46 @@ export class ProxyEvaluator {
|
|
|
110
129
|
}
|
|
111
130
|
return this.ruleCount;
|
|
112
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Gap A slice 2: merge auto-pulled rules (staged + integrity-verified by the
|
|
134
|
+
* daemon) into the engine. ONLY rules whose id is not already bundled are
|
|
135
|
+
* added — a newer bundle re-ships existing ids, and those stay as their trusted
|
|
136
|
+
* bundled copy. `adviseOnly` marks the fresh ids so evaluate() lets them advise
|
|
137
|
+
* but never hard-deny (see adviseOnlyIds). Best-effort: a failure here must
|
|
138
|
+
* never break the enforce path, so it is caught and the proxy keeps running on
|
|
139
|
+
* the bundled ruleset alone. Returns the number of fresh rules added.
|
|
140
|
+
*/
|
|
141
|
+
async loadAutoRules(rulesDir, opts) {
|
|
142
|
+
try {
|
|
143
|
+
if (!existsSync(rulesDir))
|
|
144
|
+
return 0;
|
|
145
|
+
const existing = new Set(this.engine.getRules().map((r) => r.id));
|
|
146
|
+
const sub = new ATREngine({ rulesDir });
|
|
147
|
+
await sub.loadRules();
|
|
148
|
+
let added = 0;
|
|
149
|
+
for (const rule of sub.getRules()) {
|
|
150
|
+
if (existing.has(rule.id))
|
|
151
|
+
continue; // already have this id from the bundled set
|
|
152
|
+
this.engine.addRule(rule);
|
|
153
|
+
if (opts.adviseOnly)
|
|
154
|
+
this.adviseOnlyIds.add(rule.id);
|
|
155
|
+
added++;
|
|
156
|
+
}
|
|
157
|
+
this.ruleCount += added;
|
|
158
|
+
return added;
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
process.stderr.write(`[panguard-proxy] auto-rules load skipped (ignored, bundled rules unaffected): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
113
165
|
getRuleCount() {
|
|
114
166
|
return this.ruleCount;
|
|
115
167
|
}
|
|
168
|
+
/** Number of auto-pulled rules currently held advise-only (not yet trusted). */
|
|
169
|
+
getAdviseOnlyCount() {
|
|
170
|
+
return this.adviseOnlyIds.size;
|
|
171
|
+
}
|
|
116
172
|
/** Flatten args into a readable string for ATR regex matching */
|
|
117
173
|
flattenArgs(args) {
|
|
118
174
|
const parts = [];
|
|
@@ -147,6 +203,7 @@ export class ProxyEvaluator {
|
|
|
147
203
|
matchedRules: ['guard-blocklist'],
|
|
148
204
|
confidence: 100,
|
|
149
205
|
durationMs: Date.now() - start,
|
|
206
|
+
adviseOnly: false,
|
|
150
207
|
};
|
|
151
208
|
}
|
|
152
209
|
// Flatten args into natural text so ATR regexes can match content like paths and commands
|
|
@@ -205,21 +262,50 @@ export class ProxyEvaluator {
|
|
|
205
262
|
matchedRules: [],
|
|
206
263
|
confidence: 0,
|
|
207
264
|
durationMs,
|
|
265
|
+
adviseOnly: false,
|
|
208
266
|
};
|
|
209
267
|
}
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
268
|
+
// FALSE-POSITIVE CONTROL POINT (this is a SYNCHRONOUS per-tool-call gate).
|
|
269
|
+
// 1. Hard-DENY on a trusted match (shouldHardDeny: critical, or high+stable).
|
|
270
|
+
// 2. Otherwise surface an 'ask' advisory ONLY for a high-severity match —
|
|
271
|
+
// something serious enough to warrant a per-call heads-up even if
|
|
272
|
+
// unproven.
|
|
273
|
+
// 3. Medium/low matches are demoted to a SILENT allow here. The engine
|
|
274
|
+
// runs full 'hunt' detection, and many broad rules (e.g. keyword-
|
|
275
|
+
// presence rules that fire on any `curl`/`rm`/`env`, severity=medium,
|
|
276
|
+
// condition:any) would otherwise spam an advisory on routine tool calls
|
|
277
|
+
// — the dominant false-positive source. Their matches are still
|
|
278
|
+
// returned in matchedRules and the async daemon path keeps them for
|
|
279
|
+
// offline review; we just do not raise a per-call advisory/block on an
|
|
280
|
+
// unactionable low-severity broad match. Real attacks are
|
|
281
|
+
// critical/high-stable and hard-deny at step 1, so are unaffected.
|
|
282
|
+
// Gap A slice 2: an advise-only auto-pulled rule (fresh, not yet trusted)
|
|
283
|
+
// is EXCLUDED from the hard-deny decision — it can still surface as an
|
|
284
|
+
// 'ask' advisory below, but it can never silently block the user's tool.
|
|
285
|
+
// Bundled/trusted rules are unaffected. This is the arm gate for the
|
|
286
|
+
// enforce path.
|
|
287
|
+
const blockMatch = matches.find((m) => shouldHardDeny(m.rule) && !this.adviseOnlyIds.has(m.rule.id));
|
|
288
|
+
const askMatch = blockMatch ??
|
|
289
|
+
matches.find((m) => m.rule.severity === 'high' || m.rule.severity === 'critical');
|
|
290
|
+
const outcome = blockMatch ? 'deny' : askMatch ? 'ask' : 'allow';
|
|
291
|
+
const topMatch = blockMatch ?? askMatch ?? matches[0];
|
|
292
|
+
// An 'ask' is advise-only-driven when NO trusted (non-advise-only)
|
|
293
|
+
// high/critical rule justifies it — i.e. the only rules asking for
|
|
294
|
+
// attention are fresh, untrusted auto-pulled ones. The caller must not
|
|
295
|
+
// escalate such an 'ask' to a block. A 'deny' is never advise-only (the
|
|
296
|
+
// block gate above already excluded advise-only rules).
|
|
297
|
+
const adviseOnly = outcome === 'ask' &&
|
|
298
|
+
!matches.some((m) => (m.rule.severity === 'high' || m.rule.severity === 'critical') &&
|
|
299
|
+
!this.adviseOnlyIds.has(m.rule.id));
|
|
217
300
|
return {
|
|
218
301
|
outcome,
|
|
219
|
-
reason:
|
|
302
|
+
reason: outcome === 'allow'
|
|
303
|
+
? `No actionable threat (low-severity match suppressed: ${topMatch.rule.title}, ${topMatch.rule.severity})`
|
|
304
|
+
: `${topMatch.rule.title} (${topMatch.rule.severity})`,
|
|
220
305
|
matchedRules: matches.map((m) => m.rule.id),
|
|
221
|
-
confidence: topMatch.confidence,
|
|
306
|
+
confidence: outcome === 'allow' ? 0 : topMatch.confidence,
|
|
222
307
|
durationMs,
|
|
308
|
+
adviseOnly,
|
|
223
309
|
};
|
|
224
310
|
}
|
|
225
311
|
catch (err) {
|
|
@@ -228,9 +314,10 @@ export class ProxyEvaluator {
|
|
|
228
314
|
return {
|
|
229
315
|
outcome: 'deny',
|
|
230
316
|
reason: 'Evaluation error (fail-closed for safety)',
|
|
231
|
-
matchedRules: [
|
|
317
|
+
matchedRules: [EVALUATION_ERROR_SENTINEL],
|
|
232
318
|
confidence: 100,
|
|
233
319
|
durationMs: Date.now() - start,
|
|
320
|
+
adviseOnly: false,
|
|
234
321
|
};
|
|
235
322
|
}
|
|
236
323
|
}
|
package/dist/proxy.js
CHANGED
|
@@ -398,6 +398,7 @@ export class MCPProxy {
|
|
|
398
398
|
matchedRules: [],
|
|
399
399
|
confidence: 0,
|
|
400
400
|
durationMs: this.evalTimeout,
|
|
401
|
+
adviseOnly: false,
|
|
401
402
|
};
|
|
402
403
|
}
|
|
403
404
|
this.logVerdict({
|
|
@@ -465,6 +466,7 @@ export class MCPProxy {
|
|
|
465
466
|
matchedRules: [],
|
|
466
467
|
confidence: 0,
|
|
467
468
|
durationMs: this.evalTimeout,
|
|
469
|
+
adviseOnly: false,
|
|
468
470
|
};
|
|
469
471
|
}
|
|
470
472
|
this.logVerdict({
|
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.5",
|
|
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.8.
|
|
25
|
-
"@panguard-ai/
|
|
26
|
-
"@panguard-ai/
|
|
23
|
+
"agent-threat-rules": "^3.5.8",
|
|
24
|
+
"@panguard-ai/atr": "1.8.5",
|
|
25
|
+
"@panguard-ai/containment": "0.1.0",
|
|
26
|
+
"@panguard-ai/panguard-guard": "1.8.5"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"@panguard-ai/atr": "1.8.
|
|
29
|
+
"@panguard-ai/atr": "1.8.5"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|