@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.
@@ -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
- // Hard-DENY only on a trusted match (see shouldHardDeny); every other
211
- // match is still surfaced as 'ask' (user-in-the-loop), never silently
212
- // allowed. This is the proxy's false-positive control point the engine
213
- // runs full 'hunt' detection so nothing is missed.
214
- const blockMatch = matches.find((m) => shouldHardDeny(m.rule));
215
- const outcome = blockMatch ? 'deny' : 'ask';
216
- const topMatch = blockMatch ?? matches[0];
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: `${topMatch.rule.title} (${topMatch.rule.severity})`,
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: ['evaluation-error'],
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",
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.6",
24
- "@panguard-ai/atr": "1.8.3",
25
- "@panguard-ai/panguard-guard": "1.8.3",
26
- "@panguard-ai/containment": "0.1.0"
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.3"
29
+ "@panguard-ai/atr": "1.8.5"
30
30
  },
31
31
  "files": [
32
32
  "dist",