protect-mcp 0.4.1 → 0.4.3

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/README.md CHANGED
@@ -254,4 +254,4 @@ Supports OPA, Cerbos, Cedar (AWS AgentCore), and generic HTTP endpoints:
254
254
 
255
255
  MIT — free to use, modify, distribute, and build upon without restriction.
256
256
 
257
- [scopeblind.com](https://scopeblind.com) · [npm](https://www.npmjs.com/package/protect-mcp) · [GitHub](https://github.com/tomjwxf/scopeblind-gateway) · [IETF Draft](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/)
257
+ [scopeblind.com](https://scopeblind.com) · [npm](https://www.npmjs.com/package/protect-mcp) · [GitHub](https://github.com/scopeblind/ScopeBlindD2) · [IETF Draft](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/)
@@ -564,17 +564,366 @@ function buildDecisionContext(toolName, tier, opts) {
564
564
  };
565
565
  }
566
566
 
567
+ // src/cedar-evaluator.ts
568
+ import { createHash as createHash2 } from "crypto";
569
+ import { readFileSync as readFileSync4, readdirSync, existsSync as existsSync3 } from "fs";
570
+ import { join as join2, extname } from "path";
571
+ var cedarWasm = null;
572
+ var loadAttempted = false;
573
+ async function ensureCedarWasm() {
574
+ if (cedarWasm) return true;
575
+ if (loadAttempted) return false;
576
+ loadAttempted = true;
577
+ try {
578
+ const moduleName = "@cedar-policy/cedar-wasm";
579
+ cedarWasm = await import(
580
+ /* @vite-ignore */
581
+ moduleName
582
+ );
583
+ return true;
584
+ } catch {
585
+ return false;
586
+ }
587
+ }
588
+ function loadCedarPolicies(dirPath) {
589
+ if (!existsSync3(dirPath)) {
590
+ throw new Error(`Cedar policy directory not found: ${dirPath}`);
591
+ }
592
+ const entries = readdirSync(dirPath).filter((f) => extname(f) === ".cedar").sort();
593
+ if (entries.length === 0) {
594
+ throw new Error(`No .cedar files found in: ${dirPath}`);
595
+ }
596
+ const sources = [];
597
+ for (const file of entries) {
598
+ const content = readFileSync4(join2(dirPath, file), "utf-8");
599
+ sources.push(content);
600
+ }
601
+ const concatenated = sources.join("\n\n");
602
+ const digest = createHash2("sha256").update(concatenated).digest("hex").slice(0, 16);
603
+ return {
604
+ source: concatenated,
605
+ digest,
606
+ fileCount: entries.length,
607
+ files: entries
608
+ };
609
+ }
610
+ function buildEntities(req) {
611
+ const agentId = req.agentId || req.tier;
612
+ return [
613
+ {
614
+ uid: { type: "Agent", id: agentId },
615
+ attrs: {
616
+ tier: req.tier,
617
+ ...req.agentId ? { agent_id: req.agentId } : {}
618
+ },
619
+ parents: []
620
+ },
621
+ {
622
+ uid: { type: "Tool", id: req.tool },
623
+ attrs: {},
624
+ parents: []
625
+ }
626
+ ];
627
+ }
628
+ async function evaluateCedar(policySet, req) {
629
+ const available = await ensureCedarWasm();
630
+ if (!available) {
631
+ return {
632
+ allowed: true,
633
+ reason: "cedar_wasm_not_available",
634
+ metadata: { fallback: true }
635
+ };
636
+ }
637
+ try {
638
+ const agentId = req.agentId || req.tier;
639
+ const authRequest = {
640
+ principal: { type: "Agent", id: agentId },
641
+ action: { type: "Action", id: "MCP::Tool::call" },
642
+ resource: { type: "Tool", id: req.tool },
643
+ context: {
644
+ tier: req.tier,
645
+ ...req.context || {}
646
+ }
647
+ };
648
+ const entities = buildEntities(req);
649
+ let result;
650
+ if (typeof cedarWasm.isAuthorized === "function") {
651
+ result = cedarWasm.isAuthorized({
652
+ policies: policySet.source,
653
+ entities,
654
+ principal: authRequest.principal,
655
+ action: authRequest.action,
656
+ resource: authRequest.resource,
657
+ context: authRequest.context,
658
+ schema: null
659
+ // No schema enforcement — Cedar still evaluates correctly
660
+ });
661
+ } else if (typeof cedarWasm.checkAuthorization === "function") {
662
+ result = cedarWasm.checkAuthorization(
663
+ policySet.source,
664
+ JSON.stringify(entities),
665
+ JSON.stringify(authRequest)
666
+ );
667
+ } else {
668
+ const cedarEngine = cedarWasm.default || cedarWasm;
669
+ if (typeof cedarEngine.isAuthorized === "function") {
670
+ result = cedarEngine.isAuthorized({
671
+ policies: policySet.source,
672
+ entities,
673
+ principal: authRequest.principal,
674
+ action: authRequest.action,
675
+ resource: authRequest.resource,
676
+ context: authRequest.context,
677
+ schema: null
678
+ });
679
+ } else {
680
+ return {
681
+ allowed: true,
682
+ reason: "cedar_wasm_api_unsupported",
683
+ metadata: { fallback: true, exports: Object.keys(cedarWasm) }
684
+ };
685
+ }
686
+ }
687
+ const decision = parseWasmResult(result);
688
+ return {
689
+ allowed: decision.allowed,
690
+ reason: decision.allowed ? void 0 : `cedar_deny${decision.diagnostics ? ": " + decision.diagnostics : ""}`,
691
+ metadata: {
692
+ policy_digest: policySet.digest,
693
+ ...decision.matchedPolicies ? { matched_policies: decision.matchedPolicies } : {}
694
+ }
695
+ };
696
+ } catch (err) {
697
+ return {
698
+ allowed: true,
699
+ reason: `cedar_eval_error: ${err instanceof Error ? err.message : "unknown"}`,
700
+ metadata: { fallback: true, error: true }
701
+ };
702
+ }
703
+ }
704
+ function parseWasmResult(result) {
705
+ if (!result) {
706
+ return { allowed: true, diagnostics: "null result from Cedar WASM" };
707
+ }
708
+ if (result.type === "allow" || result.type === "Allow") {
709
+ return { allowed: true };
710
+ }
711
+ if (result.type === "deny" || result.type === "Deny") {
712
+ return {
713
+ allowed: false,
714
+ diagnostics: result.diagnostics ? JSON.stringify(result.diagnostics) : void 0,
715
+ matchedPolicies: result.diagnostics?.reasons
716
+ };
717
+ }
718
+ if (result.decision === "Allow") {
719
+ return { allowed: true };
720
+ }
721
+ if (result.decision === "Deny") {
722
+ return {
723
+ allowed: false,
724
+ diagnostics: result.diagnostics ? JSON.stringify(result.diagnostics) : void 0
725
+ };
726
+ }
727
+ if (typeof result === "boolean") {
728
+ return { allowed: result };
729
+ }
730
+ return { allowed: true, diagnostics: `unknown result format: ${JSON.stringify(result)}` };
731
+ }
732
+ async function isCedarAvailable() {
733
+ return ensureCedarWasm();
734
+ }
735
+
736
+ // src/notifications.ts
737
+ async function sendApprovalNotification(config, notification) {
738
+ const promises = [];
739
+ if (config.sms) {
740
+ promises.push(sendSms(config.sms, notification));
741
+ }
742
+ if (config.webhook) {
743
+ promises.push(sendWebhook(config.webhook, notification));
744
+ }
745
+ if (config.email) {
746
+ promises.push(sendEmail(config.email, notification));
747
+ }
748
+ const results = await Promise.allSettled(promises);
749
+ for (const result of results) {
750
+ if (result.status === "rejected") {
751
+ console.error(`[protect-mcp] Notification failed: ${result.reason}`);
752
+ }
753
+ }
754
+ }
755
+ async function sendSms(config, notification) {
756
+ const body = [
757
+ `\u{1F512} Approval Required`,
758
+ `Tool: ${notification.toolName}`,
759
+ notification.agentId ? `Agent: ${notification.agentId}` : null,
760
+ `Reason: ${notification.reason}`,
761
+ notification.approveUrl ? `Approve: ${notification.approveUrl}` : null,
762
+ notification.traceUrl ? `Trace: ${notification.traceUrl}` : null
763
+ ].filter(Boolean).join("\n");
764
+ const params = new URLSearchParams({
765
+ To: config.to,
766
+ From: config.from,
767
+ Body: body
768
+ });
769
+ const response = await fetch(
770
+ `https://api.twilio.com/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
771
+ {
772
+ method: "POST",
773
+ headers: {
774
+ Authorization: `Basic ${Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64")}`,
775
+ "Content-Type": "application/x-www-form-urlencoded"
776
+ },
777
+ body: params.toString()
778
+ }
779
+ );
780
+ if (!response.ok) {
781
+ throw new Error(`Twilio SMS failed: ${response.status} ${await response.text()}`);
782
+ }
783
+ }
784
+ async function sendWebhook(config, notification) {
785
+ let payload;
786
+ if (config.template === "slack") {
787
+ payload = {
788
+ blocks: [
789
+ {
790
+ type: "header",
791
+ text: { type: "plain_text", text: "\u{1F512} Agent Approval Required" }
792
+ },
793
+ {
794
+ type: "section",
795
+ fields: [
796
+ { type: "mrkdwn", text: `*Tool:*
797
+ \`${notification.toolName}\`` },
798
+ { type: "mrkdwn", text: `*Agent:*
799
+ ${notification.agentId || "unknown"}` },
800
+ { type: "mrkdwn", text: `*Policy:*
801
+ ${notification.policyName || "default"}` },
802
+ { type: "mrkdwn", text: `*Time:*
803
+ ${notification.timestamp}` }
804
+ ]
805
+ },
806
+ {
807
+ type: "section",
808
+ text: { type: "mrkdwn", text: `*Reason:* ${notification.reason}` }
809
+ },
810
+ ...notification.approveUrl || notification.traceUrl ? [
811
+ {
812
+ type: "actions",
813
+ elements: [
814
+ ...notification.approveUrl ? [{ type: "button", text: { type: "plain_text", text: "\u2705 Approve" }, url: notification.approveUrl, style: "primary" }] : [],
815
+ ...notification.traceUrl ? [{ type: "button", text: { type: "plain_text", text: "\u{1F50D} View Trace" }, url: notification.traceUrl }] : []
816
+ ]
817
+ }
818
+ ] : []
819
+ ]
820
+ };
821
+ } else if (config.template === "pagerduty") {
822
+ payload = {
823
+ routing_key: config.headers?.["X-Routing-Key"] || "",
824
+ event_action: "trigger",
825
+ payload: {
826
+ summary: `Agent approval required: ${notification.toolName}`,
827
+ source: "protect-mcp",
828
+ severity: "warning",
829
+ custom_details: {
830
+ tool: notification.toolName,
831
+ agent: notification.agentId,
832
+ policy: notification.policyName,
833
+ reason: notification.reason,
834
+ trace_url: notification.traceUrl,
835
+ approve_url: notification.approveUrl
836
+ }
837
+ }
838
+ };
839
+ } else {
840
+ payload = notification;
841
+ }
842
+ const response = await fetch(config.url, {
843
+ method: config.method || "POST",
844
+ headers: {
845
+ "Content-Type": "application/json",
846
+ ...config.headers
847
+ },
848
+ body: JSON.stringify(payload)
849
+ });
850
+ if (!response.ok) {
851
+ throw new Error(`Webhook failed: ${response.status}`);
852
+ }
853
+ }
854
+ async function sendEmail(config, notification) {
855
+ if (!config.resendApiKey) {
856
+ console.warn("[protect-mcp] Email notification skipped: no resendApiKey configured");
857
+ return;
858
+ }
859
+ const html = `
860
+ <div style="font-family: monospace; padding: 20px; background: #0d1117; color: #c9d1d9; border-radius: 8px;">
861
+ <h2 style="color: #10b981;">\u{1F512} Agent Approval Required</h2>
862
+ <table style="font-size: 14px; margin: 16px 0;">
863
+ <tr><td style="color: #8b949e; padding: 4px 16px 4px 0;">Tool:</td><td>${notification.toolName}</td></tr>
864
+ <tr><td style="color: #8b949e; padding: 4px 16px 4px 0;">Agent:</td><td>${notification.agentId || "unknown"}</td></tr>
865
+ <tr><td style="color: #8b949e; padding: 4px 16px 4px 0;">Reason:</td><td>${notification.reason}</td></tr>
866
+ <tr><td style="color: #8b949e; padding: 4px 16px 4px 0;">Time:</td><td>${notification.timestamp}</td></tr>
867
+ </table>
868
+ ${notification.approveUrl ? `<a href="${notification.approveUrl}" style="background: #10b981; color: white; padding: 8px 16px; border-radius: 6px; text-decoration: none; margin-right: 8px;">\u2705 Approve</a>` : ""}
869
+ ${notification.traceUrl ? `<a href="${notification.traceUrl}" style="background: #1f2937; color: #c9d1d9; padding: 8px 16px; border-radius: 6px; text-decoration: none; border: 1px solid #374151;">\u{1F50D} View Trace</a>` : ""}
870
+ </div>
871
+ `;
872
+ const response = await fetch("https://api.resend.com/emails", {
873
+ method: "POST",
874
+ headers: {
875
+ Authorization: `Bearer ${config.resendApiKey}`,
876
+ "Content-Type": "application/json"
877
+ },
878
+ body: JSON.stringify({
879
+ from: "ScopeBlind <noreply@scopeblind.com>",
880
+ to: config.to,
881
+ subject: `\u{1F512} Approval required: ${notification.toolName}`,
882
+ html
883
+ })
884
+ });
885
+ if (!response.ok) {
886
+ throw new Error(`Resend email failed: ${response.status}`);
887
+ }
888
+ }
889
+ function parseNotificationConfigFromEnv() {
890
+ const config = {};
891
+ let hasConfig = false;
892
+ const smsTo = process.env.SCOPEBLIND_SMS_TO;
893
+ const twilioSid = process.env.TWILIO_ACCOUNT_SID;
894
+ const twilioToken = process.env.TWILIO_AUTH_TOKEN;
895
+ const twilioFrom = process.env.TWILIO_FROM_NUMBER;
896
+ if (smsTo && twilioSid && twilioToken && twilioFrom) {
897
+ config.sms = { accountSid: twilioSid, authToken: twilioToken, from: twilioFrom, to: smsTo };
898
+ hasConfig = true;
899
+ }
900
+ const webhookUrl = process.env.SCOPEBLIND_WEBHOOK_URL;
901
+ if (webhookUrl) {
902
+ config.webhook = {
903
+ url: webhookUrl,
904
+ template: process.env.SCOPEBLIND_WEBHOOK_TEMPLATE || "custom"
905
+ };
906
+ hasConfig = true;
907
+ }
908
+ const emailTo = process.env.SCOPEBLIND_EMAIL_TO;
909
+ if (emailTo) {
910
+ config.email = { to: emailTo, resendApiKey: process.env.RESEND_API_KEY };
911
+ hasConfig = true;
912
+ }
913
+ return hasConfig ? config : null;
914
+ }
915
+
567
916
  // src/gateway.ts
568
917
  import { spawn } from "child_process";
569
918
  import { randomUUID, randomBytes } from "crypto";
570
919
  import { createInterface } from "readline";
571
920
  import { appendFileSync } from "fs";
572
- import { join as join3 } from "path";
921
+ import { join as join4 } from "path";
573
922
 
574
923
  // src/http-server.ts
575
924
  import { createServer } from "http";
576
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
577
- import { join as join2 } from "path";
925
+ import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
926
+ import { join as join3 } from "path";
578
927
  var LOG_FILE = ".protect-mcp-log.jsonl";
579
928
  var MAX_RECEIPTS = 100;
580
929
  var ReceiptBuffer = class {
@@ -667,13 +1016,13 @@ function handleHealth(res, startTime, config) {
667
1016
  }));
668
1017
  }
669
1018
  function handleStatus(res, logDir) {
670
- const logPath = join2(logDir, LOG_FILE);
671
- if (!existsSync3(logPath)) {
1019
+ const logPath = join3(logDir, LOG_FILE);
1020
+ if (!existsSync4(logPath)) {
672
1021
  res.writeHead(200);
673
1022
  res.end(JSON.stringify({ entries: 0, message: "no log file yet" }));
674
1023
  return;
675
1024
  }
676
- const raw = readFileSync4(logPath, "utf-8");
1025
+ const raw = readFileSync5(logPath, "utf-8");
677
1026
  const lines = raw.trim().split("\n").filter(Boolean);
678
1027
  const entries = [];
679
1028
  for (const line of lines) {
@@ -814,15 +1163,27 @@ var ProtectGateway = class {
814
1163
  approvalNonce = randomBytes(16).toString("hex");
815
1164
  currentTier = "unknown";
816
1165
  admissionResult = null;
1166
+ /** Notification config for approval gates (SMS, webhook, email) */
1167
+ notificationConfig = null;
817
1168
  /** HTTP transport mode: pending response resolvers keyed by JSON-RPC id */
818
1169
  pendingResponses = /* @__PURE__ */ new Map();
819
1170
  httpMode = false;
1171
+ /** Loaded Cedar policy set (when policy_engine is "cedar") */
1172
+ cedarPolicySet = null;
820
1173
  constructor(config) {
821
1174
  this.config = config;
822
- this.logFilePath = join3(process.cwd(), LOG_FILE2);
823
- this.receiptFilePath = join3(process.cwd(), RECEIPTS_FILE);
1175
+ this.logFilePath = join4(process.cwd(), LOG_FILE2);
1176
+ this.receiptFilePath = join4(process.cwd(), RECEIPTS_FILE);
824
1177
  this.evidenceStore = new EvidenceStore();
825
1178
  this.receiptBuffer = new ReceiptBuffer();
1179
+ this.notificationConfig = parseNotificationConfigFromEnv();
1180
+ }
1181
+ /**
1182
+ * Set the Cedar policy set for local evaluation.
1183
+ * Called during CLI startup when --cedar flag is used.
1184
+ */
1185
+ setCedarPolicies(policySet) {
1186
+ this.cedarPolicySet = policySet;
826
1187
  }
827
1188
  async start() {
828
1189
  const { command, args, verbose } = this.config;
@@ -991,6 +1352,27 @@ var ProtectGateway = class {
991
1352
  }
992
1353
  }
993
1354
  }
1355
+ if (this.config.policy?.policy_engine === "cedar" && this.cedarPolicySet) {
1356
+ try {
1357
+ const cedarDecision = await evaluateCedar(this.cedarPolicySet, {
1358
+ tool: toolName,
1359
+ tier: this.currentTier,
1360
+ agentId: this.admissionResult?.agent_id
1361
+ });
1362
+ if (!cedarDecision.allowed) {
1363
+ const reason = cedarDecision.reason || "cedar_deny";
1364
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: reason, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
1365
+ if (this.config.enforce) {
1366
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" denied by Cedar policy`);
1367
+ }
1368
+ return null;
1369
+ }
1370
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "cedar_allow", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
1371
+ return null;
1372
+ } catch (err) {
1373
+ if (this.config.verbose) this.log(`Cedar evaluation error: ${err instanceof Error ? err.message : err}`);
1374
+ }
1375
+ }
994
1376
  if (this.config.policy?.external && (this.config.policy.policy_engine === "external" || this.config.policy.policy_engine === "hybrid")) {
995
1377
  try {
996
1378
  const ctx = buildDecisionContext(toolName, this.currentTier, {
@@ -1038,6 +1420,20 @@ var ProtectGateway = class {
1038
1420
  return null;
1039
1421
  }
1040
1422
  this.emitDecisionLog({ tool: toolName, decision: "require_approval", reason_code: "requires_human_approval", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
1423
+ if (this.notificationConfig) {
1424
+ sendApprovalNotification(this.notificationConfig, {
1425
+ requestId,
1426
+ toolName,
1427
+ agentId: this.admissionResult?.agent_id,
1428
+ policyName: "default",
1429
+ reason: `Policy requires human approval for "${toolName}"`,
1430
+ traceUrl: `https://scopeblind.com/trace`,
1431
+ approveUrl: void 0,
1432
+ // Approve URL provided when HTTP transport is active
1433
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1434
+ }).catch(() => {
1435
+ });
1436
+ }
1041
1437
  if (this.config.enforce) {
1042
1438
  return {
1043
1439
  jsonrpc: "2.0",
@@ -1085,8 +1481,19 @@ var ProtectGateway = class {
1085
1481
  }
1086
1482
  return policy.rate_limit;
1087
1483
  }
1484
+ /**
1485
+ * Emit a decision log entry with OTel-compatible trace IDs and optional
1486
+ * signed receipt generation.
1487
+ *
1488
+ * @patent Patent-protected construction — decision receipts with configurable
1489
+ * disclosure and issuer-blind properties. Covered by Apache 2.0 patent grant
1490
+ * for users of this code. Clean-room reimplementation requires a patent license.
1491
+ * @see {@link https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/}
1492
+ */
1088
1493
  emitDecisionLog(entry) {
1089
1494
  const mode = this.config.enforce ? "enforce" : "shadow";
1495
+ const otelTraceId = entry.otel_trace_id || randomBytes(16).toString("hex");
1496
+ const otelSpanId = entry.otel_span_id || randomBytes(8).toString("hex");
1090
1497
  const log = {
1091
1498
  v: 2,
1092
1499
  tool: entry.tool || "unknown",
@@ -1099,7 +1506,9 @@ var ProtectGateway = class {
1099
1506
  mode,
1100
1507
  ...entry.rate_limit_remaining !== void 0 && { rate_limit_remaining: entry.rate_limit_remaining },
1101
1508
  ...entry.tier && { tier: entry.tier },
1102
- ...entry.credential_ref && { credential_ref: entry.credential_ref }
1509
+ ...entry.credential_ref && { credential_ref: entry.credential_ref },
1510
+ otel_trace_id: otelTraceId,
1511
+ otel_span_id: otelSpanId
1103
1512
  };
1104
1513
  process.stderr.write(`[PROTECT_MCP] ${JSON.stringify(log)}
1105
1514
  `);
@@ -1269,5 +1678,9 @@ export {
1269
1678
  isSigningEnabled,
1270
1679
  queryExternalPDP,
1271
1680
  buildDecisionContext,
1681
+ loadCedarPolicies,
1682
+ isCedarAvailable,
1683
+ sendApprovalNotification,
1684
+ parseNotificationConfigFromEnv,
1272
1685
  ProtectGateway
1273
1686
  };
@@ -3,7 +3,7 @@ import {
3
3
  getToolPolicy,
4
4
  meetsMinTier,
5
5
  parseRateLimit
6
- } from "./chunk-7HBHIKLN.mjs";
6
+ } from "./chunk-VF3OCG4D.mjs";
7
7
 
8
8
  // src/simulate.ts
9
9
  import { readFileSync } from "fs";