protect-mcp 0.2.2 → 0.3.0

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/index.js CHANGED
@@ -61,6 +61,8 @@ module.exports = __toCommonJS(index_exports);
61
61
  var import_node_child_process = require("child_process");
62
62
  var import_node_crypto2 = require("crypto");
63
63
  var import_node_readline = require("readline");
64
+ var import_node_fs5 = require("fs");
65
+ var import_node_path3 = require("path");
64
66
 
65
67
  // src/policy.ts
66
68
  var import_node_crypto = require("crypto");
@@ -138,8 +140,126 @@ function checkRateLimit(key, limit, store) {
138
140
  return { allowed: true, remaining: limit.count - timestamps.length };
139
141
  }
140
142
 
143
+ // src/evidence-store.ts
144
+ var import_node_fs2 = require("fs");
145
+ var import_node_path = require("path");
146
+ var DEFAULT_THRESHOLDS = {
147
+ min_receipts: 10,
148
+ min_epoch_span: 3,
149
+ min_issuers: 2
150
+ };
151
+ var EvidenceStore = class {
152
+ agents = /* @__PURE__ */ new Map();
153
+ filePath;
154
+ dirty = false;
155
+ constructor(dir) {
156
+ this.filePath = (0, import_node_path.join)(dir || process.cwd(), ".protect-mcp-evidence.json");
157
+ this.load();
158
+ }
159
+ /**
160
+ * Record a receipt observation for an agent.
161
+ */
162
+ record(agentId, issuer, timestamp) {
163
+ const ts = timestamp || (/* @__PURE__ */ new Date()).toISOString();
164
+ const epochHour = Math.floor(new Date(ts).getTime() / (3600 * 1e3));
165
+ const existing = this.agents.get(agentId);
166
+ const observation = {
167
+ issuer,
168
+ timestamp: ts,
169
+ epoch_hour: epochHour
170
+ };
171
+ if (existing) {
172
+ existing.receipts.push(observation);
173
+ existing.last_seen = ts;
174
+ if (existing.receipts.length > 200) {
175
+ existing.receipts = existing.receipts.slice(-200);
176
+ }
177
+ } else {
178
+ this.agents.set(agentId, {
179
+ agent_id: agentId,
180
+ receipts: [observation],
181
+ first_seen: ts,
182
+ last_seen: ts
183
+ });
184
+ }
185
+ this.dirty = true;
186
+ }
187
+ /**
188
+ * Get the evidence summary for an agent.
189
+ */
190
+ getSummary(agentId) {
191
+ const record = this.agents.get(agentId);
192
+ if (!record || record.receipts.length === 0) {
193
+ return { receipt_count: 0, epoch_span: 0, issuer_count: 0 };
194
+ }
195
+ const uniqueIssuers = new Set(record.receipts.map((r) => r.issuer));
196
+ const uniqueEpochs = new Set(record.receipts.map((r) => r.epoch_hour));
197
+ return {
198
+ receipt_count: record.receipts.length,
199
+ epoch_span: uniqueEpochs.size,
200
+ issuer_count: uniqueIssuers.size
201
+ };
202
+ }
203
+ /**
204
+ * Check if an agent meets the evidenced tier thresholds.
205
+ */
206
+ meetsEvidencedThreshold(agentId, thresholds = DEFAULT_THRESHOLDS) {
207
+ const summary = this.getSummary(agentId);
208
+ return summary.receipt_count >= thresholds.min_receipts && summary.epoch_span >= thresholds.min_epoch_span && summary.issuer_count >= thresholds.min_issuers;
209
+ }
210
+ /**
211
+ * Persist to disk (call periodically or on shutdown).
212
+ */
213
+ save() {
214
+ if (!this.dirty) return;
215
+ const data = {};
216
+ for (const [id, record] of this.agents) {
217
+ data[id] = record;
218
+ }
219
+ try {
220
+ (0, import_node_fs2.writeFileSync)(this.filePath, JSON.stringify({ v: 1, agents: data }, null, 2) + "\n");
221
+ this.dirty = false;
222
+ } catch {
223
+ }
224
+ }
225
+ /**
226
+ * Load from disk.
227
+ */
228
+ load() {
229
+ if (!(0, import_node_fs2.existsSync)(this.filePath)) return;
230
+ try {
231
+ const raw = (0, import_node_fs2.readFileSync)(this.filePath, "utf-8");
232
+ const parsed = JSON.parse(raw);
233
+ if (parsed.agents && typeof parsed.agents === "object") {
234
+ for (const [id, record] of Object.entries(parsed.agents)) {
235
+ this.agents.set(id, record);
236
+ }
237
+ }
238
+ } catch {
239
+ }
240
+ }
241
+ /**
242
+ * Get total agent count (for status display).
243
+ */
244
+ agentCount() {
245
+ return this.agents.size;
246
+ }
247
+ /**
248
+ * Get all agent summaries (for status display).
249
+ */
250
+ allSummaries() {
251
+ const result = [];
252
+ for (const [id] of this.agents) {
253
+ result.push({ agent_id: id, summary: this.getSummary(id) });
254
+ }
255
+ return result;
256
+ }
257
+ };
258
+
141
259
  // src/admission.ts
142
- function evaluateTier(manifest, overrides) {
260
+ function evaluateTier(manifest, opts) {
261
+ const options = opts && ("evidenceStore" in opts || "overrides" in opts || "thresholds" in opts) ? opts : { overrides: opts };
262
+ const { overrides, evidenceStore, thresholds } = options;
143
263
  if (!manifest) {
144
264
  return {
145
265
  tier: "unknown",
@@ -165,7 +285,8 @@ function evaluateTier(manifest, overrides) {
165
285
  if (manifest.signature_valid === true) {
166
286
  if (manifest.evidence_summary) {
167
287
  const es = manifest.evidence_summary;
168
- if (es.receipt_count >= 10 && es.epoch_span >= 3 && es.issuer_count >= 2) {
288
+ const t = thresholds || DEFAULT_THRESHOLDS;
289
+ if (es.receipt_count >= t.min_receipts && es.epoch_span >= t.min_epoch_span && es.issuer_count >= t.min_issuers) {
169
290
  return {
170
291
  tier: "evidenced",
171
292
  agent_id: manifest.agent_id,
@@ -174,6 +295,16 @@ function evaluateTier(manifest, overrides) {
174
295
  };
175
296
  }
176
297
  }
298
+ if (evidenceStore && manifest.agent_id) {
299
+ if (evidenceStore.meetsEvidencedThreshold(manifest.agent_id, thresholds)) {
300
+ return {
301
+ tier: "evidenced",
302
+ agent_id: manifest.agent_id,
303
+ manifest_hash: manifest.manifest_hash,
304
+ reason: "evidence_store_threshold_met"
305
+ };
306
+ }
307
+ }
177
308
  return {
178
309
  tier: "signed-known",
179
310
  agent_id: manifest.agent_id,
@@ -243,7 +374,7 @@ function validateCredentials(credentials) {
243
374
  }
244
375
 
245
376
  // src/signing.ts
246
- var import_node_fs2 = require("fs");
377
+ var import_node_fs3 = require("fs");
247
378
  var signerState = null;
248
379
  var artifactsModule = null;
249
380
  async function initSigning(config) {
@@ -258,12 +389,12 @@ async function initSigning(config) {
258
389
  return warnings;
259
390
  }
260
391
  if (config.key_path) {
261
- if (!(0, import_node_fs2.existsSync)(config.key_path)) {
392
+ if (!(0, import_node_fs3.existsSync)(config.key_path)) {
262
393
  warnings.push(`signing: key file not found at ${config.key_path} \u2014 run "protect-mcp init" to generate`);
263
394
  return warnings;
264
395
  }
265
396
  try {
266
- const keyData = JSON.parse((0, import_node_fs2.readFileSync)(config.key_path, "utf-8"));
397
+ const keyData = JSON.parse((0, import_node_fs3.readFileSync)(config.key_path, "utf-8"));
267
398
  if (!keyData.privateKey || !keyData.publicKey) {
268
399
  warnings.push("signing: key file missing privateKey or publicKey fields");
269
400
  return warnings;
@@ -335,21 +466,283 @@ function isSigningEnabled() {
335
466
  return signerState !== null && artifactsModule !== null;
336
467
  }
337
468
 
469
+ // src/external-pdp.ts
470
+ async function queryExternalPDP(context, config) {
471
+ const timeout = config.timeout_ms || 500;
472
+ const controller = new AbortController();
473
+ const timer = setTimeout(() => controller.abort(), timeout);
474
+ try {
475
+ const body = formatRequest(context, config.format || "generic");
476
+ const response = await fetch(config.endpoint, {
477
+ method: "POST",
478
+ headers: { "Content-Type": "application/json" },
479
+ body: JSON.stringify(body),
480
+ signal: controller.signal
481
+ });
482
+ clearTimeout(timer);
483
+ if (!response.ok) {
484
+ return fallbackDecision(config, `PDP returned HTTP ${response.status}`);
485
+ }
486
+ const result = await response.json();
487
+ return parseResponse(result, config.format || "generic");
488
+ } catch (err) {
489
+ clearTimeout(timer);
490
+ if (err instanceof Error && err.name === "AbortError") {
491
+ return fallbackDecision(config, `PDP timeout after ${timeout}ms`);
492
+ }
493
+ return fallbackDecision(config, `PDP error: ${err instanceof Error ? err.message : "unknown"}`);
494
+ }
495
+ }
496
+ function formatRequest(context, format) {
497
+ switch (format) {
498
+ case "opa":
499
+ return {
500
+ input: {
501
+ actor: context.actor,
502
+ action: context.action,
503
+ target: context.target,
504
+ credential_ref: context.credential_ref,
505
+ mode: context.mode,
506
+ metadata: context.request_metadata
507
+ }
508
+ };
509
+ case "cerbos":
510
+ return {
511
+ principal: {
512
+ id: context.actor.id || "unknown",
513
+ roles: [context.actor.tier],
514
+ attr: {
515
+ manifest_hash: context.actor.manifest_hash
516
+ }
517
+ },
518
+ resource: {
519
+ kind: "tool",
520
+ id: context.action.tool,
521
+ attr: context.target
522
+ },
523
+ actions: [context.action.operation || "call"]
524
+ };
525
+ case "generic":
526
+ default:
527
+ return context;
528
+ }
529
+ }
530
+ function parseResponse(result, format) {
531
+ switch (format) {
532
+ case "opa":
533
+ if (typeof result.result === "boolean") {
534
+ return { allowed: result.result };
535
+ }
536
+ if (result.result && typeof result.result === "object") {
537
+ const r = result.result;
538
+ return {
539
+ allowed: Boolean(r.allow),
540
+ reason: r.reason,
541
+ metadata: r
542
+ };
543
+ }
544
+ return { allowed: false, reason: "unrecognized OPA response" };
545
+ case "cerbos":
546
+ if (Array.isArray(result.results) && result.results.length > 0) {
547
+ const actions = result.results[0].actions;
548
+ if (actions) {
549
+ const effect = Object.values(actions)[0];
550
+ return { allowed: effect === "EFFECT_ALLOW" };
551
+ }
552
+ }
553
+ return { allowed: false, reason: "unrecognized Cerbos response" };
554
+ case "generic":
555
+ default:
556
+ return {
557
+ allowed: Boolean(result.allowed),
558
+ reason: result.reason,
559
+ metadata: result.metadata
560
+ };
561
+ }
562
+ }
563
+ function fallbackDecision(config, reason) {
564
+ const fallback = config.fallback || "deny";
565
+ return {
566
+ allowed: fallback === "allow",
567
+ reason: `fallback_${fallback}: ${reason}`
568
+ };
569
+ }
570
+ function buildDecisionContext(toolName, tier, opts) {
571
+ return {
572
+ v: 1,
573
+ actor: {
574
+ id: opts.agentId,
575
+ tier,
576
+ manifest_hash: opts.manifestHash
577
+ },
578
+ action: {
579
+ tool: toolName,
580
+ operation: "call"
581
+ },
582
+ target: {
583
+ service: opts.slug || "default"
584
+ },
585
+ credential_ref: opts.credentialRef,
586
+ mode: opts.mode,
587
+ request_metadata: opts.requestMetadata || {}
588
+ };
589
+ }
590
+
591
+ // src/http-server.ts
592
+ var import_node_http = require("http");
593
+ var import_node_fs4 = require("fs");
594
+ var import_node_path2 = require("path");
595
+ var LOG_FILE = ".protect-mcp-log.jsonl";
596
+ var MAX_RECEIPTS = 100;
597
+ var ReceiptBuffer = class {
598
+ receipts = [];
599
+ add(requestId, receipt) {
600
+ this.receipts.push({
601
+ request_id: requestId,
602
+ receipt,
603
+ timestamp: Date.now()
604
+ });
605
+ if (this.receipts.length > MAX_RECEIPTS) {
606
+ this.receipts = this.receipts.slice(-MAX_RECEIPTS);
607
+ }
608
+ }
609
+ getAll() {
610
+ return [...this.receipts].reverse();
611
+ }
612
+ getById(requestId) {
613
+ return this.receipts.find((r) => r.request_id === requestId);
614
+ }
615
+ count() {
616
+ return this.receipts.length;
617
+ }
618
+ };
619
+ function startStatusServer(config, receiptBuffer) {
620
+ const startTime = Date.now();
621
+ const logDir = process.cwd();
622
+ const server = (0, import_node_http.createServer)((req, res) => {
623
+ res.setHeader("Access-Control-Allow-Origin", "*");
624
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
625
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
626
+ res.setHeader("Content-Type", "application/json");
627
+ if (req.method === "OPTIONS") {
628
+ res.writeHead(204);
629
+ res.end();
630
+ return;
631
+ }
632
+ const url = new URL(req.url || "/", `http://localhost:${config.port}`);
633
+ const path = url.pathname;
634
+ try {
635
+ if (path === "/health") {
636
+ handleHealth(res, startTime, config);
637
+ } else if (path === "/status") {
638
+ handleStatus(res, logDir);
639
+ } else if (path === "/receipts") {
640
+ handleReceipts(res, receiptBuffer, url);
641
+ } else if (path.startsWith("/receipts/")) {
642
+ const id = path.slice("/receipts/".length);
643
+ handleReceiptById(res, receiptBuffer, id);
644
+ } else {
645
+ res.writeHead(404);
646
+ res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/:id"] }));
647
+ }
648
+ } catch (err) {
649
+ res.writeHead(500);
650
+ res.end(JSON.stringify({ error: "internal_error" }));
651
+ }
652
+ });
653
+ server.listen(config.port, "127.0.0.1", () => {
654
+ if (config.verbose) {
655
+ process.stderr.write(`[PROTECT_MCP] HTTP status server listening on http://127.0.0.1:${config.port}
656
+ `);
657
+ }
658
+ });
659
+ server.unref();
660
+ return server;
661
+ }
662
+ function handleHealth(res, startTime, config) {
663
+ res.writeHead(200);
664
+ res.end(JSON.stringify({
665
+ status: "ok",
666
+ uptime_ms: Date.now() - startTime,
667
+ mode: config.mode,
668
+ version: "0.3.0"
669
+ }));
670
+ }
671
+ function handleStatus(res, logDir) {
672
+ const logPath = (0, import_node_path2.join)(logDir, LOG_FILE);
673
+ if (!(0, import_node_fs4.existsSync)(logPath)) {
674
+ res.writeHead(200);
675
+ res.end(JSON.stringify({ entries: 0, message: "no log file yet" }));
676
+ return;
677
+ }
678
+ const raw = (0, import_node_fs4.readFileSync)(logPath, "utf-8");
679
+ const lines = raw.trim().split("\n").filter(Boolean);
680
+ const entries = [];
681
+ for (const line of lines) {
682
+ try {
683
+ entries.push(JSON.parse(line));
684
+ } catch {
685
+ }
686
+ }
687
+ const toolCounts = {};
688
+ let allowCount = 0, denyCount = 0;
689
+ const tierCounts = {};
690
+ for (const e of entries) {
691
+ toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1;
692
+ if (e.decision === "allow") allowCount++;
693
+ else denyCount++;
694
+ if (e.tier) tierCounts[e.tier] = (tierCounts[e.tier] || 0) + 1;
695
+ }
696
+ res.writeHead(200);
697
+ res.end(JSON.stringify({
698
+ entries: entries.length,
699
+ allow: allowCount,
700
+ deny: denyCount,
701
+ tools: toolCounts,
702
+ tiers: tierCounts,
703
+ first_timestamp: entries.length > 0 ? entries[0].timestamp : null,
704
+ last_timestamp: entries.length > 0 ? entries[entries.length - 1].timestamp : null
705
+ }));
706
+ }
707
+ function handleReceipts(res, buffer, url) {
708
+ const limit = parseInt(url.searchParams.get("limit") || "20", 10);
709
+ const receipts = buffer.getAll().slice(0, Math.min(limit, MAX_RECEIPTS));
710
+ res.writeHead(200);
711
+ res.end(JSON.stringify({
712
+ count: receipts.length,
713
+ total: buffer.count(),
714
+ receipts
715
+ }));
716
+ }
717
+ function handleReceiptById(res, buffer, id) {
718
+ const receipt = buffer.getById(id);
719
+ if (!receipt) {
720
+ res.writeHead(404);
721
+ res.end(JSON.stringify({ error: "receipt_not_found", request_id: id }));
722
+ return;
723
+ }
724
+ res.writeHead(200);
725
+ res.end(JSON.stringify(receipt));
726
+ }
727
+
338
728
  // src/gateway.ts
729
+ var LOG_FILE2 = ".protect-mcp-log.jsonl";
339
730
  var ProtectGateway = class {
340
731
  child = null;
341
732
  config;
342
733
  rateLimitStore = /* @__PURE__ */ new Map();
343
734
  clientReader = null;
344
- // Trust-tier state for the current session
735
+ logFilePath;
736
+ evidenceStore;
737
+ receiptBuffer;
345
738
  currentTier = "unknown";
346
739
  admissionResult = null;
347
740
  constructor(config) {
348
741
  this.config = config;
742
+ this.logFilePath = (0, import_node_path3.join)(process.cwd(), LOG_FILE2);
743
+ this.evidenceStore = new EvidenceStore();
744
+ this.receiptBuffer = new ReceiptBuffer();
349
745
  }
350
- /**
351
- * Start the gateway: spawn child process and wire up message relay.
352
- */
353
746
  async start() {
354
747
  const { command, args, verbose } = this.config;
355
748
  const mode = this.config.enforce ? "enforce" : "shadow";
@@ -366,11 +759,34 @@ var ProtectGateway = class {
366
759
  const labels = Object.keys(this.config.credentials);
367
760
  this.log(`Credential vault: ${labels.length} credential(s) configured [${labels.join(", ")}]`);
368
761
  }
762
+ if (this.config.policy?.policy_engine === "external" || this.config.policy?.policy_engine === "hybrid") {
763
+ this.log(`External PDP: ${this.config.policy.external?.endpoint || "not configured"}`);
764
+ }
369
765
  }
370
- this.child = (0, import_node_child_process.spawn)(command, args, {
371
- stdio: ["pipe", "pipe", "pipe"],
372
- env: { ...process.env }
373
- });
766
+ const httpPort = parseInt(process.env.PROTECT_MCP_HTTP_PORT || "9876", 10);
767
+ if (httpPort > 0) {
768
+ try {
769
+ startStatusServer(
770
+ { port: httpPort, mode, verbose },
771
+ this.receiptBuffer
772
+ );
773
+ } catch {
774
+ if (verbose) this.log(`HTTP status server could not start on port ${httpPort}`);
775
+ }
776
+ }
777
+ const childEnv = { ...process.env };
778
+ if (this.config.credentials) {
779
+ for (const [label, credConfig] of Object.entries(this.config.credentials)) {
780
+ if (credConfig.inject === "env" && credConfig.name && credConfig.value_env) {
781
+ const envValue = process.env[credConfig.value_env];
782
+ if (envValue) {
783
+ childEnv[credConfig.name] = envValue;
784
+ if (verbose) this.log(`Credential "${label}": injected as env var "${credConfig.name}"`);
785
+ }
786
+ }
787
+ }
788
+ }
789
+ this.child = (0, import_node_child_process.spawn)(command, args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv });
374
790
  if (!this.child.stdin || !this.child.stdout || !this.child.stderr) {
375
791
  throw new Error("Failed to create pipes to child process");
376
792
  }
@@ -386,9 +802,8 @@ var ProtectGateway = class {
386
802
  this.handleClientMessage(line);
387
803
  });
388
804
  this.child.on("exit", (code, signal) => {
389
- if (this.config.verbose) {
390
- this.log(`Child process exited (code=${code}, signal=${signal})`);
391
- }
805
+ if (verbose) this.log(`Child process exited (code=${code}, signal=${signal})`);
806
+ this.evidenceStore.save();
392
807
  process.exit(code ?? 1);
393
808
  });
394
809
  this.child.on("error", (err) => {
@@ -398,30 +813,18 @@ var ProtectGateway = class {
398
813
  process.on("SIGINT", () => this.stop());
399
814
  process.on("SIGTERM", () => this.stop());
400
815
  process.stdin.on("end", () => {
401
- if (this.config.verbose) {
402
- this.log("Client stdin closed, closing child stdin");
403
- }
404
- if (this.child?.stdin?.writable) {
405
- this.child.stdin.end();
406
- }
816
+ if (verbose) this.log("Client stdin closed, closing child stdin");
817
+ if (this.child?.stdin?.writable) this.child.stdin.end();
407
818
  });
408
819
  }
409
- /**
410
- * Set the trust tier for this session.
411
- * Called at admission (first interaction) or by explicit manifest presentation.
412
- */
413
820
  setManifest(manifest) {
414
- this.admissionResult = evaluateTier(manifest);
821
+ this.admissionResult = evaluateTier(manifest, { evidenceStore: this.evidenceStore });
415
822
  this.currentTier = this.admissionResult.tier;
416
823
  if (this.config.verbose) {
417
- this.log(`Admission: tier=${this.currentTier} agent=${this.admissionResult.agent_id || "none"} reason=${this.admissionResult.reason}`);
824
+ this.log(`Admission: tier=${this.currentTier} agent=${this.admissionResult.agent_id || "none"}`);
418
825
  }
419
826
  return this.admissionResult;
420
827
  }
421
- /**
422
- * Handle a message from the MCP client (stdin).
423
- * Intercept tools/call requests; pass through everything else.
424
- */
425
828
  handleClientMessage(raw) {
426
829
  const trimmed = raw.trim();
427
830
  if (!trimmed) return;
@@ -433,25 +836,38 @@ var ProtectGateway = class {
433
836
  return;
434
837
  }
435
838
  if (message.method === "tools/call" && message.id !== void 0) {
436
- const result = this.interceptToolCall(message);
437
- if (result) {
438
- this.sendToClient(JSON.stringify(result));
439
- return;
440
- }
839
+ this.interceptToolCallAsync(message, trimmed);
840
+ return;
441
841
  }
442
842
  this.sendToChild(trimmed);
443
843
  }
444
- /**
445
- * Handle a message from the wrapped MCP server (child stdout).
446
- * Forward to client (stdout) transparently.
447
- */
844
+ async interceptToolCallAsync(request, raw) {
845
+ const result = await this.interceptToolCall(request);
846
+ if (result) {
847
+ this.sendToClient(JSON.stringify(result));
848
+ } else {
849
+ const modified = this.injectParamsCredentials(request);
850
+ this.sendToChild(JSON.stringify(modified));
851
+ }
852
+ }
448
853
  handleServerMessage(raw) {
449
854
  this.sendToClient(raw);
450
855
  }
451
- /**
452
- * Intercept a tools/call request. Returns a JSON-RPC error response if denied, null if allowed.
453
- */
454
- interceptToolCall(request) {
856
+ injectParamsCredentials(request) {
857
+ if (!this.config.credentials) return request;
858
+ const injections = {};
859
+ for (const [label, credConfig] of Object.entries(this.config.credentials)) {
860
+ if (credConfig.inject === "header" || credConfig.inject === "query") {
861
+ const cred = resolveCredential(label, this.config.credentials);
862
+ if (cred.resolved && cred.value && cred.name) {
863
+ injections[cred.name] = cred.value;
864
+ }
865
+ }
866
+ }
867
+ if (Object.keys(injections).length === 0) return request;
868
+ return { ...request, params: { ...request.params, _credentials: injections } };
869
+ }
870
+ async interceptToolCall(request) {
455
871
  const toolName = request.params?.name || "unknown";
456
872
  const requestId = (0, import_node_crypto2.randomUUID)().slice(0, 12);
457
873
  const toolPolicy = getToolPolicy(toolName, this.config.policy);
@@ -462,49 +878,45 @@ var ProtectGateway = class {
462
878
  if (cred.resolved) {
463
879
  credentialRef = cred.label;
464
880
  } else if (cred.error && !cred.error.includes("not configured")) {
465
- this.emitDecisionLog({
466
- tool: toolName,
467
- decision: "deny",
468
- reason_code: "policy_block",
469
- request_id: requestId,
470
- credential_ref: toolName,
471
- tier: this.currentTier
472
- });
881
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "credential_error", request_id: requestId, tier: this.currentTier, credential_ref: toolName });
473
882
  if (this.config.enforce) {
474
- this.log(`Credential error for "${toolName}": ${cred.error}`);
475
883
  return this.makeErrorResponse(request.id, -32600, `Credential error for tool "${toolName}"`);
476
884
  }
477
885
  }
478
886
  }
887
+ if (this.config.policy?.external && (this.config.policy.policy_engine === "external" || this.config.policy.policy_engine === "hybrid")) {
888
+ try {
889
+ const ctx = buildDecisionContext(toolName, this.currentTier, {
890
+ agentId: this.admissionResult?.agent_id,
891
+ manifestHash: this.admissionResult?.manifest_hash,
892
+ credentialRef,
893
+ mode,
894
+ slug: this.config.slug
895
+ });
896
+ const externalDecision = await queryExternalPDP(ctx, this.config.policy.external);
897
+ if (!externalDecision.allowed) {
898
+ const reason = `external_pdp_deny${externalDecision.reason ? ": " + externalDecision.reason : ""}`;
899
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: reason, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
900
+ if (this.config.enforce) {
901
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" denied by external policy engine`);
902
+ }
903
+ if (this.config.policy.policy_engine === "external") return null;
904
+ }
905
+ } catch (err) {
906
+ if (this.config.verbose) this.log(`External PDP error: ${err instanceof Error ? err.message : err}`);
907
+ }
908
+ }
479
909
  if (toolPolicy.min_tier) {
480
910
  if (!meetsMinTier(this.currentTier, toolPolicy.min_tier)) {
481
- this.emitDecisionLog({
482
- tool: toolName,
483
- decision: "deny",
484
- reason_code: "tier_insufficient",
485
- request_id: requestId,
486
- tier: this.currentTier,
487
- credential_ref: credentialRef
488
- });
911
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "tier_insufficient", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
489
912
  if (this.config.enforce) {
490
- return this.makeErrorResponse(
491
- request.id,
492
- -32600,
493
- `Tool "${toolName}" requires tier "${toolPolicy.min_tier}", agent has "${this.currentTier}"`
494
- );
913
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" requires tier "${toolPolicy.min_tier}"`);
495
914
  }
496
915
  return null;
497
916
  }
498
917
  }
499
918
  if (toolPolicy.block) {
500
- this.emitDecisionLog({
501
- tool: toolName,
502
- decision: "deny",
503
- reason_code: "policy_block",
504
- request_id: requestId,
505
- tier: this.currentTier,
506
- credential_ref: credentialRef
507
- });
919
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "policy_block", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
508
920
  if (this.config.enforce) {
509
921
  return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" is blocked by policy`);
510
922
  }
@@ -517,59 +929,22 @@ var ProtectGateway = class {
517
929
  const key = `tool:${toolName}:${this.currentTier}`;
518
930
  const { allowed, remaining } = checkRateLimit(key, limit, this.rateLimitStore);
519
931
  if (!allowed) {
520
- this.emitDecisionLog({
521
- tool: toolName,
522
- decision: "deny",
523
- reason_code: "rate_limit_exceeded",
524
- request_id: requestId,
525
- rate_limit_remaining: 0,
526
- tier: this.currentTier,
527
- credential_ref: credentialRef
528
- });
932
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "rate_limit_exceeded", request_id: requestId, rate_limit_remaining: 0, tier: this.currentTier, credential_ref: credentialRef });
529
933
  if (this.config.enforce) {
530
- return this.makeErrorResponse(
531
- request.id,
532
- -32600,
533
- `Tool "${toolName}" rate limit exceeded (${rateSpec})`
534
- );
934
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" rate limit exceeded (${rateSpec})`);
535
935
  }
536
936
  return null;
537
937
  }
538
- this.emitDecisionLog({
539
- tool: toolName,
540
- decision: "allow",
541
- reason_code: "policy_allow",
542
- request_id: requestId,
543
- rate_limit_remaining: remaining,
544
- tier: this.currentTier,
545
- credential_ref: credentialRef
546
- });
938
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "policy_allow", request_id: requestId, rate_limit_remaining: remaining, tier: this.currentTier, credential_ref: credentialRef });
547
939
  } catch {
548
- this.emitDecisionLog({
549
- tool: toolName,
550
- decision: "allow",
551
- reason_code: "default_allow",
552
- request_id: requestId,
553
- tier: this.currentTier,
554
- credential_ref: credentialRef
555
- });
940
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "default_allow", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
556
941
  }
557
942
  } else {
558
943
  const reasonCode = this.config.enforce ? "policy_allow" : "observe_mode";
559
- this.emitDecisionLog({
560
- tool: toolName,
561
- decision: "allow",
562
- reason_code: reasonCode,
563
- request_id: requestId,
564
- tier: this.currentTier,
565
- credential_ref: credentialRef
566
- });
944
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: reasonCode, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
567
945
  }
568
946
  return null;
569
947
  }
570
- /**
571
- * Get the applicable rate limit spec based on the agent's tier.
572
- */
573
948
  getTierRateLimit(policy, tier) {
574
949
  if (policy.rate_limits && policy.rate_limits[tier]) {
575
950
  const tierLimit = policy.rate_limits[tier];
@@ -577,10 +952,6 @@ var ProtectGateway = class {
577
952
  }
578
953
  return policy.rate_limit;
579
954
  }
580
- /**
581
- * Emit a structured decision log to stderr.
582
- * If signing is enabled, also emits a signed artifact.
583
- */
584
955
  emitDecisionLog(entry) {
585
956
  const mode = this.config.enforce ? "enforce" : "shadow";
586
957
  const log = {
@@ -599,55 +970,44 @@ var ProtectGateway = class {
599
970
  };
600
971
  process.stderr.write(`[PROTECT_MCP] ${JSON.stringify(log)}
601
972
  `);
973
+ try {
974
+ (0, import_node_fs5.appendFileSync)(this.logFilePath, JSON.stringify(log) + "\n");
975
+ } catch {
976
+ }
602
977
  if (isSigningEnabled()) {
603
978
  const signed = signDecision(log);
604
979
  if (signed.signed) {
605
980
  process.stderr.write(`[PROTECT_MCP_RECEIPT] ${signed.signed}
606
981
  `);
982
+ this.receiptBuffer.add(log.request_id, signed.signed);
983
+ if (this.admissionResult?.agent_id) {
984
+ this.evidenceStore.record(this.admissionResult.agent_id, this.config.signing?.issuer || "protect-mcp");
985
+ if (this.evidenceStore.getSummary(this.admissionResult.agent_id).receipt_count % 10 === 0) {
986
+ this.evidenceStore.save();
987
+ }
988
+ }
607
989
  } else if (signed.warning) {
608
990
  process.stderr.write(`[PROTECT_MCP] Warning: ${signed.warning}
609
991
  `);
610
992
  }
611
993
  }
612
994
  }
613
- /**
614
- * Create a JSON-RPC error response.
615
- */
616
995
  makeErrorResponse(id, code, message) {
617
- return {
618
- jsonrpc: "2.0",
619
- id,
620
- error: { code, message }
621
- };
996
+ return { jsonrpc: "2.0", id, error: { code, message } };
622
997
  }
623
- /**
624
- * Send a message to the child process (wrapped MCP server).
625
- */
626
998
  sendToChild(message) {
627
- if (this.child?.stdin?.writable) {
628
- this.child.stdin.write(message + "\n");
629
- }
999
+ if (this.child?.stdin?.writable) this.child.stdin.write(message + "\n");
630
1000
  }
631
- /**
632
- * Send a message to the MCP client (stdout).
633
- */
634
1001
  sendToClient(message) {
635
1002
  process.stdout.write(message + "\n");
636
1003
  }
637
- /**
638
- * Log a message to stderr (debug output).
639
- */
640
1004
  log(message) {
641
1005
  process.stderr.write(`[PROTECT_MCP] ${message}
642
1006
  `);
643
1007
  }
644
- /**
645
- * Stop the gateway: kill child process and exit.
646
- */
647
1008
  stop() {
648
- if (this.clientReader) {
649
- this.clientReader.close();
650
- }
1009
+ this.evidenceStore.save();
1010
+ if (this.clientReader) this.clientReader.close();
651
1011
  if (this.child) {
652
1012
  this.child.kill("SIGTERM");
653
1013
  this.child = null;
@@ -656,128 +1016,6 @@ var ProtectGateway = class {
656
1016
  }
657
1017
  };
658
1018
 
659
- // src/external-pdp.ts
660
- async function queryExternalPDP(context, config) {
661
- const timeout = config.timeout_ms || 500;
662
- const controller = new AbortController();
663
- const timer = setTimeout(() => controller.abort(), timeout);
664
- try {
665
- const body = formatRequest(context, config.format || "generic");
666
- const response = await fetch(config.endpoint, {
667
- method: "POST",
668
- headers: { "Content-Type": "application/json" },
669
- body: JSON.stringify(body),
670
- signal: controller.signal
671
- });
672
- clearTimeout(timer);
673
- if (!response.ok) {
674
- return fallbackDecision(config, `PDP returned HTTP ${response.status}`);
675
- }
676
- const result = await response.json();
677
- return parseResponse(result, config.format || "generic");
678
- } catch (err) {
679
- clearTimeout(timer);
680
- if (err instanceof Error && err.name === "AbortError") {
681
- return fallbackDecision(config, `PDP timeout after ${timeout}ms`);
682
- }
683
- return fallbackDecision(config, `PDP error: ${err instanceof Error ? err.message : "unknown"}`);
684
- }
685
- }
686
- function formatRequest(context, format) {
687
- switch (format) {
688
- case "opa":
689
- return {
690
- input: {
691
- actor: context.actor,
692
- action: context.action,
693
- target: context.target,
694
- credential_ref: context.credential_ref,
695
- mode: context.mode,
696
- metadata: context.request_metadata
697
- }
698
- };
699
- case "cerbos":
700
- return {
701
- principal: {
702
- id: context.actor.id || "unknown",
703
- roles: [context.actor.tier],
704
- attr: {
705
- manifest_hash: context.actor.manifest_hash
706
- }
707
- },
708
- resource: {
709
- kind: "tool",
710
- id: context.action.tool,
711
- attr: context.target
712
- },
713
- actions: [context.action.operation || "call"]
714
- };
715
- case "generic":
716
- default:
717
- return context;
718
- }
719
- }
720
- function parseResponse(result, format) {
721
- switch (format) {
722
- case "opa":
723
- if (typeof result.result === "boolean") {
724
- return { allowed: result.result };
725
- }
726
- if (result.result && typeof result.result === "object") {
727
- const r = result.result;
728
- return {
729
- allowed: Boolean(r.allow),
730
- reason: r.reason,
731
- metadata: r
732
- };
733
- }
734
- return { allowed: false, reason: "unrecognized OPA response" };
735
- case "cerbos":
736
- if (Array.isArray(result.results) && result.results.length > 0) {
737
- const actions = result.results[0].actions;
738
- if (actions) {
739
- const effect = Object.values(actions)[0];
740
- return { allowed: effect === "EFFECT_ALLOW" };
741
- }
742
- }
743
- return { allowed: false, reason: "unrecognized Cerbos response" };
744
- case "generic":
745
- default:
746
- return {
747
- allowed: Boolean(result.allowed),
748
- reason: result.reason,
749
- metadata: result.metadata
750
- };
751
- }
752
- }
753
- function fallbackDecision(config, reason) {
754
- const fallback = config.fallback || "deny";
755
- return {
756
- allowed: fallback === "allow",
757
- reason: `fallback_${fallback}: ${reason}`
758
- };
759
- }
760
- function buildDecisionContext(toolName, tier, opts) {
761
- return {
762
- v: 1,
763
- actor: {
764
- id: opts.agentId,
765
- tier,
766
- manifest_hash: opts.manifestHash
767
- },
768
- action: {
769
- tool: toolName,
770
- operation: "call"
771
- },
772
- target: {
773
- service: opts.slug || "default"
774
- },
775
- credential_ref: opts.credentialRef,
776
- mode: opts.mode,
777
- request_metadata: opts.requestMetadata || {}
778
- };
779
- }
780
-
781
1019
  // src/bundle.ts
782
1020
  function createAuditBundle(opts) {
783
1021
  const receipts = opts.receipts.filter(