protect-mcp 0.2.2 → 0.3.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/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;
@@ -271,8 +402,8 @@ async function initSigning(config) {
271
402
  signerState = {
272
403
  privateKey: keyData.privateKey,
273
404
  publicKey: keyData.publicKey,
274
- kid: artifactsModule.computeKid(keyData.publicKey),
275
- issuer: config.issuer || "protect-mcp"
405
+ kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
406
+ issuer: config.issuer || keyData.issuer || "protect-mcp"
276
407
  };
277
408
  } catch (err) {
278
409
  warnings.push(`signing: failed to load key file: ${err instanceof Error ? err.message : err}`);
@@ -335,21 +466,378 @@ 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
+ getLatest() {
619
+ return this.receipts.length > 0 ? this.receipts[this.receipts.length - 1] : void 0;
620
+ }
621
+ };
622
+ function startStatusServer(config, receiptBuffer, approvalStore, approvalNonce) {
623
+ const startTime = Date.now();
624
+ const logDir = process.cwd();
625
+ const server = (0, import_node_http.createServer)((req, res) => {
626
+ res.setHeader("Access-Control-Allow-Origin", "*");
627
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
628
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
629
+ res.setHeader("Content-Type", "application/json");
630
+ if (req.method === "OPTIONS") {
631
+ res.writeHead(204);
632
+ res.end();
633
+ return;
634
+ }
635
+ const url = new URL(req.url || "/", `http://localhost:${config.port}`);
636
+ const path = url.pathname;
637
+ try {
638
+ if (path === "/health") {
639
+ handleHealth(res, startTime, config);
640
+ } else if (path === "/status") {
641
+ handleStatus(res, logDir);
642
+ } else if (path === "/receipts") {
643
+ handleReceipts(res, receiptBuffer, url);
644
+ } else if (path === "/receipts/latest") {
645
+ handleReceiptLatest(res, receiptBuffer);
646
+ } else if (path.startsWith("/receipts/")) {
647
+ const id = path.slice("/receipts/".length);
648
+ handleReceiptById(res, receiptBuffer, id);
649
+ } else if (path === "/approve" && req.method === "POST") {
650
+ handleApprove(req, res, approvalStore, approvalNonce);
651
+ } else if (path === "/approvals" && req.method === "GET") {
652
+ handleListApprovals(res, approvalStore);
653
+ } else {
654
+ res.writeHead(404);
655
+ res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/latest", "/receipts/:id", "/approve", "/approvals"] }));
656
+ }
657
+ } catch (err) {
658
+ res.writeHead(500);
659
+ res.end(JSON.stringify({ error: "internal_error" }));
660
+ }
661
+ });
662
+ server.on("error", (err) => {
663
+ if (config.verbose) {
664
+ process.stderr.write(`[PROTECT_MCP] HTTP status server error: ${err.message}
665
+ `);
666
+ }
667
+ });
668
+ server.listen(config.port, "127.0.0.1", () => {
669
+ if (config.verbose) {
670
+ process.stderr.write(`[PROTECT_MCP] HTTP status server listening on http://127.0.0.1:${config.port}
671
+ `);
672
+ }
673
+ });
674
+ server.unref();
675
+ return server;
676
+ }
677
+ function handleHealth(res, startTime, config) {
678
+ res.writeHead(200);
679
+ res.end(JSON.stringify({
680
+ status: "ok",
681
+ uptime_ms: Date.now() - startTime,
682
+ mode: config.mode,
683
+ version: "0.3.1"
684
+ }));
685
+ }
686
+ function handleStatus(res, logDir) {
687
+ const logPath = (0, import_node_path2.join)(logDir, LOG_FILE);
688
+ if (!(0, import_node_fs4.existsSync)(logPath)) {
689
+ res.writeHead(200);
690
+ res.end(JSON.stringify({ entries: 0, message: "no log file yet" }));
691
+ return;
692
+ }
693
+ const raw = (0, import_node_fs4.readFileSync)(logPath, "utf-8");
694
+ const lines = raw.trim().split("\n").filter(Boolean);
695
+ const entries = [];
696
+ for (const line of lines) {
697
+ try {
698
+ entries.push(JSON.parse(line));
699
+ } catch {
700
+ }
701
+ }
702
+ const toolCounts = {};
703
+ let allowCount = 0, denyCount = 0;
704
+ const tierCounts = {};
705
+ for (const e of entries) {
706
+ toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1;
707
+ if (e.decision === "allow") allowCount++;
708
+ else denyCount++;
709
+ if (e.tier) tierCounts[e.tier] = (tierCounts[e.tier] || 0) + 1;
710
+ }
711
+ res.writeHead(200);
712
+ res.end(JSON.stringify({
713
+ entries: entries.length,
714
+ allow: allowCount,
715
+ deny: denyCount,
716
+ tools: toolCounts,
717
+ tiers: tierCounts,
718
+ first_timestamp: entries.length > 0 ? entries[0].timestamp : null,
719
+ last_timestamp: entries.length > 0 ? entries[entries.length - 1].timestamp : null
720
+ }));
721
+ }
722
+ function handleReceipts(res, buffer, url) {
723
+ const limit = parseInt(url.searchParams.get("limit") || "20", 10);
724
+ const receipts = buffer.getAll().slice(0, Math.min(limit, MAX_RECEIPTS));
725
+ res.writeHead(200);
726
+ res.end(JSON.stringify({
727
+ count: receipts.length,
728
+ total: buffer.count(),
729
+ receipts
730
+ }));
731
+ }
732
+ function handleReceiptLatest(res, buffer) {
733
+ const latest = buffer.getLatest();
734
+ if (!latest) {
735
+ res.writeHead(404);
736
+ res.end(JSON.stringify({ error: "no_receipts", message: "No receipts yet. Make a tool call through protect-mcp first." }));
737
+ return;
738
+ }
739
+ res.writeHead(200);
740
+ res.end(JSON.stringify(latest));
741
+ }
742
+ function handleReceiptById(res, buffer, id) {
743
+ const receipt = buffer.getById(id);
744
+ if (!receipt) {
745
+ res.writeHead(404);
746
+ res.end(JSON.stringify({ error: "receipt_not_found", request_id: id }));
747
+ return;
748
+ }
749
+ res.writeHead(200);
750
+ res.end(JSON.stringify(receipt));
751
+ }
752
+ function handleApprove(req, res, approvalStore, expectedNonce) {
753
+ if (!approvalStore) {
754
+ res.writeHead(503);
755
+ res.end(JSON.stringify({ error: "approval_store_not_available" }));
756
+ return;
757
+ }
758
+ let body = "";
759
+ req.on("data", (chunk) => {
760
+ body += chunk.toString();
761
+ });
762
+ req.on("end", () => {
763
+ try {
764
+ const { request_id, tool, mode, nonce } = JSON.parse(body);
765
+ if (expectedNonce && nonce !== expectedNonce) {
766
+ res.writeHead(403);
767
+ res.end(JSON.stringify({ error: "invalid_nonce", message: "Approval nonce does not match. Check stderr output for the correct nonce." }));
768
+ return;
769
+ }
770
+ if (!tool || typeof tool !== "string") {
771
+ res.writeHead(400);
772
+ res.end(JSON.stringify({ error: "missing_tool", usage: '{"request_id":"abc123","tool":"send_email","mode":"once|always","nonce":"..."}' }));
773
+ return;
774
+ }
775
+ const grantMode = mode === "always" ? "always" : "once";
776
+ const ttlMs = grantMode === "once" ? 5 * 60 * 1e3 : 24 * 60 * 60 * 1e3;
777
+ const grantEntry = { tool, mode: grantMode, expires_at: Date.now() + ttlMs };
778
+ if (grantMode === "always") {
779
+ approvalStore.set(`always:${tool}`, grantEntry);
780
+ } else if (request_id) {
781
+ approvalStore.set(request_id, grantEntry);
782
+ } else {
783
+ approvalStore.set(tool, grantEntry);
784
+ }
785
+ res.writeHead(200);
786
+ res.end(JSON.stringify({
787
+ approved: true,
788
+ request_id: request_id || null,
789
+ tool,
790
+ mode: grantMode,
791
+ expires_in_seconds: ttlMs / 1e3
792
+ }));
793
+ } catch {
794
+ res.writeHead(400);
795
+ res.end(JSON.stringify({ error: "invalid_json", usage: '{"request_id":"abc123","tool":"send_email","mode":"once","nonce":"..."}' }));
796
+ }
797
+ });
798
+ }
799
+ function handleListApprovals(res, approvalStore) {
800
+ if (!approvalStore) {
801
+ res.writeHead(200);
802
+ res.end(JSON.stringify({ grants: [] }));
803
+ return;
804
+ }
805
+ const now = Date.now();
806
+ const grants = [];
807
+ for (const [key, grant] of approvalStore) {
808
+ if (now < grant.expires_at) {
809
+ grants.push({ key, tool: grant.tool, mode: grant.mode, expires_in_seconds: Math.round((grant.expires_at - now) / 1e3) });
810
+ }
811
+ }
812
+ res.writeHead(200);
813
+ res.end(JSON.stringify({ grants }));
814
+ }
815
+
338
816
  // src/gateway.ts
817
+ var LOG_FILE2 = ".protect-mcp-log.jsonl";
818
+ var RECEIPTS_FILE = ".protect-mcp-receipts.jsonl";
339
819
  var ProtectGateway = class {
340
820
  child = null;
341
821
  config;
342
822
  rateLimitStore = /* @__PURE__ */ new Map();
343
823
  clientReader = null;
344
- // Trust-tier state for the current session
824
+ logFilePath;
825
+ receiptFilePath;
826
+ evidenceStore;
827
+ receiptBuffer;
828
+ /** Approval grants keyed by request_id (scoped to the specific action that was requested) */
829
+ approvalStore = /* @__PURE__ */ new Map();
830
+ /** Random nonce generated at startup — required for approval endpoint authentication */
831
+ approvalNonce = (0, import_node_crypto2.randomBytes)(16).toString("hex");
345
832
  currentTier = "unknown";
346
833
  admissionResult = null;
347
834
  constructor(config) {
348
835
  this.config = config;
836
+ this.logFilePath = (0, import_node_path3.join)(process.cwd(), LOG_FILE2);
837
+ this.receiptFilePath = (0, import_node_path3.join)(process.cwd(), RECEIPTS_FILE);
838
+ this.evidenceStore = new EvidenceStore();
839
+ this.receiptBuffer = new ReceiptBuffer();
349
840
  }
350
- /**
351
- * Start the gateway: spawn child process and wire up message relay.
352
- */
353
841
  async start() {
354
842
  const { command, args, verbose } = this.config;
355
843
  const mode = this.config.enforce ? "enforce" : "shadow";
@@ -366,11 +854,37 @@ var ProtectGateway = class {
366
854
  const labels = Object.keys(this.config.credentials);
367
855
  this.log(`Credential vault: ${labels.length} credential(s) configured [${labels.join(", ")}]`);
368
856
  }
857
+ if (this.config.policy?.policy_engine === "external" || this.config.policy?.policy_engine === "hybrid") {
858
+ this.log(`External PDP: ${this.config.policy.external?.endpoint || "not configured"}`);
859
+ }
369
860
  }
370
- this.child = (0, import_node_child_process.spawn)(command, args, {
371
- stdio: ["pipe", "pipe", "pipe"],
372
- env: { ...process.env }
373
- });
861
+ this.log(`Approval nonce: ${this.approvalNonce}`);
862
+ const httpPort = parseInt(process.env.PROTECT_MCP_HTTP_PORT || "9876", 10);
863
+ if (httpPort > 0) {
864
+ try {
865
+ startStatusServer(
866
+ { port: httpPort, mode, verbose },
867
+ this.receiptBuffer,
868
+ this.approvalStore,
869
+ this.approvalNonce
870
+ );
871
+ } catch {
872
+ if (verbose) this.log(`HTTP status server could not start on port ${httpPort}`);
873
+ }
874
+ }
875
+ const childEnv = { ...process.env };
876
+ if (this.config.credentials) {
877
+ for (const [label, credConfig] of Object.entries(this.config.credentials)) {
878
+ if (credConfig.inject === "env" && credConfig.name && credConfig.value_env) {
879
+ const envValue = process.env[credConfig.value_env];
880
+ if (envValue) {
881
+ childEnv[credConfig.name] = envValue;
882
+ if (verbose) this.log(`Credential "${label}": injected as env var "${credConfig.name}"`);
883
+ }
884
+ }
885
+ }
886
+ }
887
+ this.child = (0, import_node_child_process.spawn)(command, args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv });
374
888
  if (!this.child.stdin || !this.child.stdout || !this.child.stderr) {
375
889
  throw new Error("Failed to create pipes to child process");
376
890
  }
@@ -386,9 +900,8 @@ var ProtectGateway = class {
386
900
  this.handleClientMessage(line);
387
901
  });
388
902
  this.child.on("exit", (code, signal) => {
389
- if (this.config.verbose) {
390
- this.log(`Child process exited (code=${code}, signal=${signal})`);
391
- }
903
+ if (verbose) this.log(`Child process exited (code=${code}, signal=${signal})`);
904
+ this.evidenceStore.save();
392
905
  process.exit(code ?? 1);
393
906
  });
394
907
  this.child.on("error", (err) => {
@@ -398,30 +911,18 @@ var ProtectGateway = class {
398
911
  process.on("SIGINT", () => this.stop());
399
912
  process.on("SIGTERM", () => this.stop());
400
913
  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
- }
914
+ if (verbose) this.log("Client stdin closed, closing child stdin");
915
+ if (this.child?.stdin?.writable) this.child.stdin.end();
407
916
  });
408
917
  }
409
- /**
410
- * Set the trust tier for this session.
411
- * Called at admission (first interaction) or by explicit manifest presentation.
412
- */
413
918
  setManifest(manifest) {
414
- this.admissionResult = evaluateTier(manifest);
919
+ this.admissionResult = evaluateTier(manifest, { evidenceStore: this.evidenceStore });
415
920
  this.currentTier = this.admissionResult.tier;
416
921
  if (this.config.verbose) {
417
- this.log(`Admission: tier=${this.currentTier} agent=${this.admissionResult.agent_id || "none"} reason=${this.admissionResult.reason}`);
922
+ this.log(`Admission: tier=${this.currentTier} agent=${this.admissionResult.agent_id || "none"}`);
418
923
  }
419
924
  return this.admissionResult;
420
925
  }
421
- /**
422
- * Handle a message from the MCP client (stdin).
423
- * Intercept tools/call requests; pass through everything else.
424
- */
425
926
  handleClientMessage(raw) {
426
927
  const trimmed = raw.trim();
427
928
  if (!trimmed) return;
@@ -433,25 +934,38 @@ var ProtectGateway = class {
433
934
  return;
434
935
  }
435
936
  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
- }
937
+ this.interceptToolCallAsync(message, trimmed);
938
+ return;
441
939
  }
442
940
  this.sendToChild(trimmed);
443
941
  }
444
- /**
445
- * Handle a message from the wrapped MCP server (child stdout).
446
- * Forward to client (stdout) transparently.
447
- */
942
+ async interceptToolCallAsync(request, raw) {
943
+ const result = await this.interceptToolCall(request);
944
+ if (result) {
945
+ this.sendToClient(JSON.stringify(result));
946
+ } else {
947
+ const modified = this.injectParamsCredentials(request);
948
+ this.sendToChild(JSON.stringify(modified));
949
+ }
950
+ }
448
951
  handleServerMessage(raw) {
449
952
  this.sendToClient(raw);
450
953
  }
451
- /**
452
- * Intercept a tools/call request. Returns a JSON-RPC error response if denied, null if allowed.
453
- */
454
- interceptToolCall(request) {
954
+ injectParamsCredentials(request) {
955
+ if (!this.config.credentials) return request;
956
+ const injections = {};
957
+ for (const [label, credConfig] of Object.entries(this.config.credentials)) {
958
+ if (credConfig.inject === "header" || credConfig.inject === "query") {
959
+ const cred = resolveCredential(label, this.config.credentials);
960
+ if (cred.resolved && cred.value && cred.name) {
961
+ injections[cred.name] = cred.value;
962
+ }
963
+ }
964
+ }
965
+ if (Object.keys(injections).length === 0) return request;
966
+ return { ...request, params: { ...request.params, _credentials: injections } };
967
+ }
968
+ async interceptToolCall(request) {
455
969
  const toolName = request.params?.name || "unknown";
456
970
  const requestId = (0, import_node_crypto2.randomUUID)().slice(0, 12);
457
971
  const toolPolicy = getToolPolicy(toolName, this.config.policy);
@@ -462,54 +976,76 @@ var ProtectGateway = class {
462
976
  if (cred.resolved) {
463
977
  credentialRef = cred.label;
464
978
  } 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
- });
979
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "credential_error", request_id: requestId, tier: this.currentTier, credential_ref: toolName });
473
980
  if (this.config.enforce) {
474
- this.log(`Credential error for "${toolName}": ${cred.error}`);
475
981
  return this.makeErrorResponse(request.id, -32600, `Credential error for tool "${toolName}"`);
476
982
  }
477
983
  }
478
984
  }
985
+ if (this.config.policy?.external && (this.config.policy.policy_engine === "external" || this.config.policy.policy_engine === "hybrid")) {
986
+ try {
987
+ const ctx = buildDecisionContext(toolName, this.currentTier, {
988
+ agentId: this.admissionResult?.agent_id,
989
+ manifestHash: this.admissionResult?.manifest_hash,
990
+ credentialRef,
991
+ mode,
992
+ slug: this.config.slug
993
+ });
994
+ const externalDecision = await queryExternalPDP(ctx, this.config.policy.external);
995
+ if (!externalDecision.allowed) {
996
+ const reason = `external_pdp_deny${externalDecision.reason ? ": " + externalDecision.reason : ""}`;
997
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: reason, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
998
+ if (this.config.enforce) {
999
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" denied by external policy engine`);
1000
+ }
1001
+ if (this.config.policy.policy_engine === "external") return null;
1002
+ }
1003
+ } catch (err) {
1004
+ if (this.config.verbose) this.log(`External PDP error: ${err instanceof Error ? err.message : err}`);
1005
+ }
1006
+ }
479
1007
  if (toolPolicy.min_tier) {
480
1008
  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
- });
1009
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "tier_insufficient", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
489
1010
  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
- );
1011
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" requires tier "${toolPolicy.min_tier}"`);
495
1012
  }
496
1013
  return null;
497
1014
  }
498
1015
  }
499
1016
  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
- });
1017
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "policy_block", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
508
1018
  if (this.config.enforce) {
509
1019
  return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" is blocked by policy`);
510
1020
  }
511
1021
  return null;
512
1022
  }
1023
+ if (toolPolicy.require_approval) {
1024
+ const grant = this.approvalStore.get(requestId);
1025
+ const alwaysGrant = this.approvalStore.get(`always:${toolName}`);
1026
+ if (grant && Date.now() < grant.expires_at || alwaysGrant && Date.now() < alwaysGrant.expires_at) {
1027
+ if (grant && grant.mode === "once") this.approvalStore.delete(requestId);
1028
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "approval_granted", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
1029
+ return null;
1030
+ }
1031
+ this.emitDecisionLog({ tool: toolName, decision: "require_approval", reason_code: "requires_human_approval", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
1032
+ if (this.config.enforce) {
1033
+ return {
1034
+ jsonrpc: "2.0",
1035
+ id: request.id,
1036
+ result: {
1037
+ content: [
1038
+ {
1039
+ type: "text",
1040
+ text: `REQUIRES_APPROVAL: The tool "${toolName}" requires human approval before execution. Request ID: ${requestId}. Approval nonce: ${this.approvalNonce}. Tell the user you need their approval to use "${toolName}" and will retry when granted. Do NOT retry this tool call until the user explicitly approves it.`
1041
+ }
1042
+ ],
1043
+ isError: true
1044
+ }
1045
+ };
1046
+ }
1047
+ return null;
1048
+ }
513
1049
  const rateSpec = this.getTierRateLimit(toolPolicy, this.currentTier);
514
1050
  if (rateSpec) {
515
1051
  try {
@@ -517,59 +1053,22 @@ var ProtectGateway = class {
517
1053
  const key = `tool:${toolName}:${this.currentTier}`;
518
1054
  const { allowed, remaining } = checkRateLimit(key, limit, this.rateLimitStore);
519
1055
  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
- });
1056
+ 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
1057
  if (this.config.enforce) {
530
- return this.makeErrorResponse(
531
- request.id,
532
- -32600,
533
- `Tool "${toolName}" rate limit exceeded (${rateSpec})`
534
- );
1058
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" rate limit exceeded (${rateSpec})`);
535
1059
  }
536
1060
  return null;
537
1061
  }
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
- });
1062
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "policy_allow", request_id: requestId, rate_limit_remaining: remaining, tier: this.currentTier, credential_ref: credentialRef });
547
1063
  } 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
- });
1064
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "default_allow", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
556
1065
  }
557
1066
  } else {
558
1067
  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
- });
1068
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: reasonCode, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
567
1069
  }
568
1070
  return null;
569
1071
  }
570
- /**
571
- * Get the applicable rate limit spec based on the agent's tier.
572
- */
573
1072
  getTierRateLimit(policy, tier) {
574
1073
  if (policy.rate_limits && policy.rate_limits[tier]) {
575
1074
  const tierLimit = policy.rate_limits[tier];
@@ -577,10 +1076,6 @@ var ProtectGateway = class {
577
1076
  }
578
1077
  return policy.rate_limit;
579
1078
  }
580
- /**
581
- * Emit a structured decision log to stderr.
582
- * If signing is enabled, also emits a signed artifact.
583
- */
584
1079
  emitDecisionLog(entry) {
585
1080
  const mode = this.config.enforce ? "enforce" : "shadow";
586
1081
  const log = {
@@ -599,55 +1094,48 @@ var ProtectGateway = class {
599
1094
  };
600
1095
  process.stderr.write(`[PROTECT_MCP] ${JSON.stringify(log)}
601
1096
  `);
1097
+ try {
1098
+ (0, import_node_fs5.appendFileSync)(this.logFilePath, JSON.stringify(log) + "\n");
1099
+ } catch {
1100
+ }
602
1101
  if (isSigningEnabled()) {
603
1102
  const signed = signDecision(log);
604
1103
  if (signed.signed) {
605
1104
  process.stderr.write(`[PROTECT_MCP_RECEIPT] ${signed.signed}
606
1105
  `);
1106
+ try {
1107
+ (0, import_node_fs5.appendFileSync)(this.receiptFilePath, signed.signed + "\n");
1108
+ } catch {
1109
+ }
1110
+ this.receiptBuffer.add(log.request_id, signed.signed);
1111
+ if (this.admissionResult?.agent_id) {
1112
+ this.evidenceStore.record(this.admissionResult.agent_id, this.config.signing?.issuer || "protect-mcp");
1113
+ if (this.evidenceStore.getSummary(this.admissionResult.agent_id).receipt_count % 10 === 0) {
1114
+ this.evidenceStore.save();
1115
+ }
1116
+ }
607
1117
  } else if (signed.warning) {
608
1118
  process.stderr.write(`[PROTECT_MCP] Warning: ${signed.warning}
609
1119
  `);
610
1120
  }
611
1121
  }
612
1122
  }
613
- /**
614
- * Create a JSON-RPC error response.
615
- */
616
1123
  makeErrorResponse(id, code, message) {
617
- return {
618
- jsonrpc: "2.0",
619
- id,
620
- error: { code, message }
621
- };
1124
+ return { jsonrpc: "2.0", id, error: { code, message } };
622
1125
  }
623
- /**
624
- * Send a message to the child process (wrapped MCP server).
625
- */
626
1126
  sendToChild(message) {
627
- if (this.child?.stdin?.writable) {
628
- this.child.stdin.write(message + "\n");
629
- }
1127
+ if (this.child?.stdin?.writable) this.child.stdin.write(message + "\n");
630
1128
  }
631
- /**
632
- * Send a message to the MCP client (stdout).
633
- */
634
1129
  sendToClient(message) {
635
1130
  process.stdout.write(message + "\n");
636
1131
  }
637
- /**
638
- * Log a message to stderr (debug output).
639
- */
640
1132
  log(message) {
641
1133
  process.stderr.write(`[PROTECT_MCP] ${message}
642
1134
  `);
643
1135
  }
644
- /**
645
- * Stop the gateway: kill child process and exit.
646
- */
647
1136
  stop() {
648
- if (this.clientReader) {
649
- this.clientReader.close();
650
- }
1137
+ this.evidenceStore.save();
1138
+ if (this.clientReader) this.clientReader.close();
651
1139
  if (this.child) {
652
1140
  this.child.kill("SIGTERM");
653
1141
  this.child = null;
@@ -656,128 +1144,6 @@ var ProtectGateway = class {
656
1144
  }
657
1145
  };
658
1146
 
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
1147
  // src/bundle.ts
782
1148
  function createAuditBundle(opts) {
783
1149
  const receipts = opts.receipts.filter(