protect-mcp 0.4.2 → 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 +1 -1
- package/dist/{chunk-7HBHIKLN.mjs → chunk-VF3OCG4D.mjs} +422 -9
- package/dist/{chunk-VWUN6AI6.mjs → chunk-VIA2B65K.mjs} +1 -1
- package/dist/cli.js +690 -93
- package/dist/cli.mjs +183 -7
- package/dist/{http-transport-RIVV2RVQ.mjs → http-transport-VLIPOPIC.mjs} +1 -1
- package/dist/index.d.mts +1429 -2
- package/dist/index.d.ts +1429 -2
- package/dist/index.js +1728 -25
- package/dist/index.mjs +1273 -3
- package/package.json +4 -3
- package/policies/cedar/clinejection.cedar +50 -0
- package/policies/cedar/terraform-destroy.cedar +44 -0
- package/policies/clinejection.json +6 -0
- package/policies/data-exfiltration.json +6 -0
- package/policies/financial-safe.json +8 -0
- package/policies/github-mcp-hijack.json +6 -0
- package/policies/terraform-destroy.json +6 -0
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/
|
|
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
|
|
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
|
|
577
|
-
import { join as
|
|
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 =
|
|
671
|
-
if (!
|
|
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 =
|
|
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 =
|
|
823
|
-
this.receiptFilePath =
|
|
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
|
};
|