@panguard-ai/panguard-mcp-proxy 1.8.4 → 1.8.6
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 +75 -2
- package/dist/proxy.js +2 -0
- package/package.json +5 -5
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,6 +262,7 @@ export class ProxyEvaluator {
|
|
|
205
262
|
matchedRules: [],
|
|
206
263
|
confidence: 0,
|
|
207
264
|
durationMs,
|
|
265
|
+
adviseOnly: false,
|
|
208
266
|
};
|
|
209
267
|
}
|
|
210
268
|
// FALSE-POSITIVE CONTROL POINT (this is a SYNCHRONOUS per-tool-call gate).
|
|
@@ -221,11 +279,24 @@ export class ProxyEvaluator {
|
|
|
221
279
|
// offline review; we just do not raise a per-call advisory/block on an
|
|
222
280
|
// unactionable low-severity broad match. Real attacks are
|
|
223
281
|
// critical/high-stable and hard-deny at step 1, so are unaffected.
|
|
224
|
-
|
|
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));
|
|
225
288
|
const askMatch = blockMatch ??
|
|
226
289
|
matches.find((m) => m.rule.severity === 'high' || m.rule.severity === 'critical');
|
|
227
290
|
const outcome = blockMatch ? 'deny' : askMatch ? 'ask' : 'allow';
|
|
228
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));
|
|
229
300
|
return {
|
|
230
301
|
outcome,
|
|
231
302
|
reason: outcome === 'allow'
|
|
@@ -234,6 +305,7 @@ export class ProxyEvaluator {
|
|
|
234
305
|
matchedRules: matches.map((m) => m.rule.id),
|
|
235
306
|
confidence: outcome === 'allow' ? 0 : topMatch.confidence,
|
|
236
307
|
durationMs,
|
|
308
|
+
adviseOnly,
|
|
237
309
|
};
|
|
238
310
|
}
|
|
239
311
|
catch (err) {
|
|
@@ -242,9 +314,10 @@ export class ProxyEvaluator {
|
|
|
242
314
|
return {
|
|
243
315
|
outcome: 'deny',
|
|
244
316
|
reason: 'Evaluation error (fail-closed for safety)',
|
|
245
|
-
matchedRules: [
|
|
317
|
+
matchedRules: [EVALUATION_ERROR_SENTINEL],
|
|
246
318
|
confidence: 100,
|
|
247
319
|
durationMs: Date.now() - start,
|
|
320
|
+
adviseOnly: false,
|
|
248
321
|
};
|
|
249
322
|
}
|
|
250
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.6",
|
|
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.
|
|
23
|
+
"agent-threat-rules": "^3.5.8",
|
|
24
24
|
"@panguard-ai/containment": "0.1.0",
|
|
25
|
-
"@panguard-ai/atr": "1.8.
|
|
26
|
-
"@panguard-ai/panguard-guard": "1.8.
|
|
25
|
+
"@panguard-ai/atr": "1.8.6",
|
|
26
|
+
"@panguard-ai/panguard-guard": "1.8.6"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"@panguard-ai/atr": "1.8.
|
|
29
|
+
"@panguard-ai/atr": "1.8.6"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|