protect-mcp 0.3.0 → 0.3.2
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 +11 -1
- package/dist/bundle-TXOTFJIJ.mjs +8 -0
- package/dist/chunk-5JXFV37Y.mjs +53 -0
- package/dist/{chunk-3WCA7O4D.mjs → chunk-GV7N53QE.mjs} +300 -11
- package/dist/chunk-JQDVKZBN.mjs +165 -0
- package/dist/cli.js +1180 -38
- package/dist/cli.mjs +640 -11
- package/dist/index.d.mts +156 -2
- package/dist/index.d.ts +156 -2
- package/dist/index.js +466 -19
- package/dist/index.mjs +17 -50
- package/dist/report-ENQ3KUI2.mjs +8 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -7,6 +7,9 @@ Security gateway for MCP servers. Shadow-mode logs by default, per-tool policies
|
|
|
7
7
|
## Quick Start
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
+
# Wrap an existing OpenClaw / MCP config into a usable pack
|
|
11
|
+
npx @scopeblind/passport wrap --runtime openclaw --config ./openclaw.json --policy email-safe
|
|
12
|
+
|
|
10
13
|
# Shadow mode — log every tool call, enforce nothing
|
|
11
14
|
npx protect-mcp -- node my-server.js
|
|
12
15
|
|
|
@@ -18,6 +21,9 @@ npx protect-mcp --policy protect-mcp.json -- node my-server.js
|
|
|
18
21
|
|
|
19
22
|
# Enforce mode
|
|
20
23
|
npx protect-mcp --policy protect-mcp.json --enforce -- node my-server.js
|
|
24
|
+
|
|
25
|
+
# Export an offline-verifiable audit bundle
|
|
26
|
+
npx protect-mcp bundle --output audit.json
|
|
21
27
|
```
|
|
22
28
|
|
|
23
29
|
## What It Does
|
|
@@ -39,7 +45,7 @@ All other MCP messages (`initialize`, `tools/list`, notifications) pass through
|
|
|
39
45
|
|
|
40
46
|
- **Per-tool policies** — block destructive tools, rate-limit expensive ones, and attach minimum-tier requirements
|
|
41
47
|
- **Structured decision logs** — every decision is emitted to `stderr` with `[PROTECT_MCP]`
|
|
42
|
-
- **Optional local signed receipts** — generated when you run with a policy containing `signing.key_path`
|
|
48
|
+
- **Optional local signed receipts** — generated when you run with a policy containing `signing.key_path`, persisted to `.protect-mcp-receipts.jsonl`, and exposed at `http://127.0.0.1:9876/receipts`
|
|
43
49
|
- **Offline verification** — verify receipts or bundles with `npx @veritasacta/verify`
|
|
44
50
|
- **No account required** — local keys, local policy, local process
|
|
45
51
|
|
|
@@ -123,6 +129,10 @@ protect-mcp init
|
|
|
123
129
|
|
|
124
130
|
Commands:
|
|
125
131
|
init Generate Ed25519 keypair + config template
|
|
132
|
+
status Show decision stats and local passport identity
|
|
133
|
+
digest Generate a local human-readable summary
|
|
134
|
+
receipts Show recent persisted signed receipts
|
|
135
|
+
bundle Export an offline-verifiable audit bundle
|
|
126
136
|
|
|
127
137
|
Options:
|
|
128
138
|
--policy <path> Policy/config JSON file
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/bundle.ts
|
|
2
|
+
function createAuditBundle(opts) {
|
|
3
|
+
const receipts = opts.receipts.filter(
|
|
4
|
+
(r) => r && typeof r === "object" && typeof r.signature === "string"
|
|
5
|
+
);
|
|
6
|
+
if (receipts.length === 0) {
|
|
7
|
+
throw new Error("Audit bundle requires at least one signed receipt");
|
|
8
|
+
}
|
|
9
|
+
const keyMap = /* @__PURE__ */ new Map();
|
|
10
|
+
for (const key of opts.signingKeys) {
|
|
11
|
+
if (!keyMap.has(key.kid)) {
|
|
12
|
+
keyMap.set(key.kid, key);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
let timeRange = opts.timeRange || null;
|
|
16
|
+
if (!timeRange) {
|
|
17
|
+
const timestamps = receipts.map((r) => r.issued_at || r.timestamp).filter(Boolean).sort();
|
|
18
|
+
if (timestamps.length > 0) {
|
|
19
|
+
timeRange = {
|
|
20
|
+
from: timestamps[0],
|
|
21
|
+
to: timestamps[timestamps.length - 1]
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
format: "scopeblind:audit-bundle",
|
|
27
|
+
version: 1,
|
|
28
|
+
exported_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
29
|
+
tenant: opts.tenant,
|
|
30
|
+
time_range: timeRange,
|
|
31
|
+
receipts,
|
|
32
|
+
anchors: opts.anchors || [],
|
|
33
|
+
verification: {
|
|
34
|
+
algorithm: "ed25519",
|
|
35
|
+
signing_keys: Array.from(keyMap.values()),
|
|
36
|
+
instructions: `Verify each receipt by: (1) remove the "signature" field, (2) canonicalize the remaining object with JCS (sorted keys at every level), (3) encode as UTF-8 bytes, (4) verify the Ed25519 signature using the signing key matching the receipt's "kid" field. CLI: npx @veritasacta/verify bundle.json --bundle`
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function collectSignedReceipts(logs) {
|
|
41
|
+
return logs.filter((log) => log.v === 2).map((log) => {
|
|
42
|
+
const logRecord = log;
|
|
43
|
+
if (logRecord.receipt) {
|
|
44
|
+
return logRecord.receipt;
|
|
45
|
+
}
|
|
46
|
+
return logRecord;
|
|
47
|
+
}).filter((r) => typeof r.signature === "string");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
createAuditBundle,
|
|
52
|
+
collectSignedReceipts
|
|
53
|
+
};
|
|
@@ -317,7 +317,11 @@ async function initSigning(config) {
|
|
|
317
317
|
return warnings;
|
|
318
318
|
}
|
|
319
319
|
try {
|
|
320
|
-
|
|
320
|
+
const moduleName = "@veritasacta/artifacts";
|
|
321
|
+
artifactsModule = await import(
|
|
322
|
+
/* @vite-ignore */
|
|
323
|
+
moduleName
|
|
324
|
+
);
|
|
321
325
|
} catch {
|
|
322
326
|
warnings.push("signing: @veritasacta/artifacts not available \u2014 receipts will be unsigned");
|
|
323
327
|
return warnings;
|
|
@@ -336,8 +340,8 @@ async function initSigning(config) {
|
|
|
336
340
|
signerState = {
|
|
337
341
|
privateKey: keyData.privateKey,
|
|
338
342
|
publicKey: keyData.publicKey,
|
|
339
|
-
kid: artifactsModule.computeKid(keyData.publicKey),
|
|
340
|
-
issuer: config.issuer || "protect-mcp"
|
|
343
|
+
kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
|
|
344
|
+
issuer: config.issuer || keyData.issuer || "protect-mcp"
|
|
341
345
|
};
|
|
342
346
|
} catch (err) {
|
|
343
347
|
warnings.push(`signing: failed to load key file: ${err instanceof Error ? err.message : err}`);
|
|
@@ -524,7 +528,7 @@ function buildDecisionContext(toolName, tier, opts) {
|
|
|
524
528
|
|
|
525
529
|
// src/gateway.ts
|
|
526
530
|
import { spawn } from "child_process";
|
|
527
|
-
import { randomUUID } from "crypto";
|
|
531
|
+
import { randomUUID, randomBytes } from "crypto";
|
|
528
532
|
import { createInterface } from "readline";
|
|
529
533
|
import { appendFileSync } from "fs";
|
|
530
534
|
import { join as join3 } from "path";
|
|
@@ -556,13 +560,16 @@ var ReceiptBuffer = class {
|
|
|
556
560
|
count() {
|
|
557
561
|
return this.receipts.length;
|
|
558
562
|
}
|
|
563
|
+
getLatest() {
|
|
564
|
+
return this.receipts.length > 0 ? this.receipts[this.receipts.length - 1] : void 0;
|
|
565
|
+
}
|
|
559
566
|
};
|
|
560
|
-
function startStatusServer(config, receiptBuffer) {
|
|
567
|
+
function startStatusServer(config, receiptBuffer, approvalStore, approvalNonce) {
|
|
561
568
|
const startTime = Date.now();
|
|
562
569
|
const logDir = process.cwd();
|
|
563
570
|
const server = createServer((req, res) => {
|
|
564
571
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
565
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
572
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
566
573
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
567
574
|
res.setHeader("Content-Type", "application/json");
|
|
568
575
|
if (req.method === "OPTIONS") {
|
|
@@ -579,18 +586,30 @@ function startStatusServer(config, receiptBuffer) {
|
|
|
579
586
|
handleStatus(res, logDir);
|
|
580
587
|
} else if (path === "/receipts") {
|
|
581
588
|
handleReceipts(res, receiptBuffer, url);
|
|
589
|
+
} else if (path === "/receipts/latest") {
|
|
590
|
+
handleReceiptLatest(res, receiptBuffer);
|
|
582
591
|
} else if (path.startsWith("/receipts/")) {
|
|
583
592
|
const id = path.slice("/receipts/".length);
|
|
584
593
|
handleReceiptById(res, receiptBuffer, id);
|
|
594
|
+
} else if (path === "/approve" && req.method === "POST") {
|
|
595
|
+
handleApprove(req, res, approvalStore, approvalNonce);
|
|
596
|
+
} else if (path === "/approvals" && req.method === "GET") {
|
|
597
|
+
handleListApprovals(res, approvalStore);
|
|
585
598
|
} else {
|
|
586
599
|
res.writeHead(404);
|
|
587
|
-
res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/:id"] }));
|
|
600
|
+
res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/latest", "/receipts/:id", "/approve", "/approvals"] }));
|
|
588
601
|
}
|
|
589
602
|
} catch (err) {
|
|
590
603
|
res.writeHead(500);
|
|
591
604
|
res.end(JSON.stringify({ error: "internal_error" }));
|
|
592
605
|
}
|
|
593
606
|
});
|
|
607
|
+
server.on("error", (err) => {
|
|
608
|
+
if (config.verbose) {
|
|
609
|
+
process.stderr.write(`[PROTECT_MCP] HTTP status server error: ${err.message}
|
|
610
|
+
`);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
594
613
|
server.listen(config.port, "127.0.0.1", () => {
|
|
595
614
|
if (config.verbose) {
|
|
596
615
|
process.stderr.write(`[PROTECT_MCP] HTTP status server listening on http://127.0.0.1:${config.port}
|
|
@@ -606,7 +625,7 @@ function handleHealth(res, startTime, config) {
|
|
|
606
625
|
status: "ok",
|
|
607
626
|
uptime_ms: Date.now() - startTime,
|
|
608
627
|
mode: config.mode,
|
|
609
|
-
version: "0.3.
|
|
628
|
+
version: "0.3.1"
|
|
610
629
|
}));
|
|
611
630
|
}
|
|
612
631
|
function handleStatus(res, logDir) {
|
|
@@ -655,6 +674,16 @@ function handleReceipts(res, buffer, url) {
|
|
|
655
674
|
receipts
|
|
656
675
|
}));
|
|
657
676
|
}
|
|
677
|
+
function handleReceiptLatest(res, buffer) {
|
|
678
|
+
const latest = buffer.getLatest();
|
|
679
|
+
if (!latest) {
|
|
680
|
+
res.writeHead(404);
|
|
681
|
+
res.end(JSON.stringify({ error: "no_receipts", message: "No receipts yet. Make a tool call through protect-mcp first." }));
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
res.writeHead(200);
|
|
685
|
+
res.end(JSON.stringify(latest));
|
|
686
|
+
}
|
|
658
687
|
function handleReceiptById(res, buffer, id) {
|
|
659
688
|
const receipt = buffer.getById(id);
|
|
660
689
|
if (!receipt) {
|
|
@@ -665,22 +694,92 @@ function handleReceiptById(res, buffer, id) {
|
|
|
665
694
|
res.writeHead(200);
|
|
666
695
|
res.end(JSON.stringify(receipt));
|
|
667
696
|
}
|
|
697
|
+
function handleApprove(req, res, approvalStore, expectedNonce) {
|
|
698
|
+
if (!approvalStore) {
|
|
699
|
+
res.writeHead(503);
|
|
700
|
+
res.end(JSON.stringify({ error: "approval_store_not_available" }));
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
let body = "";
|
|
704
|
+
req.on("data", (chunk) => {
|
|
705
|
+
body += chunk.toString();
|
|
706
|
+
});
|
|
707
|
+
req.on("end", () => {
|
|
708
|
+
try {
|
|
709
|
+
const { request_id, tool, mode, nonce } = JSON.parse(body);
|
|
710
|
+
if (expectedNonce && nonce !== expectedNonce) {
|
|
711
|
+
res.writeHead(403);
|
|
712
|
+
res.end(JSON.stringify({ error: "invalid_nonce", message: "Approval nonce does not match. Check stderr output for the correct nonce." }));
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (!tool || typeof tool !== "string") {
|
|
716
|
+
res.writeHead(400);
|
|
717
|
+
res.end(JSON.stringify({ error: "missing_tool", usage: '{"request_id":"abc123","tool":"send_email","mode":"once|always","nonce":"..."}' }));
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const grantMode = mode === "always" ? "always" : "once";
|
|
721
|
+
const ttlMs = grantMode === "once" ? 5 * 60 * 1e3 : 24 * 60 * 60 * 1e3;
|
|
722
|
+
const grantEntry = { tool, mode: grantMode, expires_at: Date.now() + ttlMs };
|
|
723
|
+
if (grantMode === "always") {
|
|
724
|
+
approvalStore.set(`always:${tool}`, grantEntry);
|
|
725
|
+
} else if (request_id) {
|
|
726
|
+
approvalStore.set(request_id, grantEntry);
|
|
727
|
+
} else {
|
|
728
|
+
approvalStore.set(tool, grantEntry);
|
|
729
|
+
}
|
|
730
|
+
res.writeHead(200);
|
|
731
|
+
res.end(JSON.stringify({
|
|
732
|
+
approved: true,
|
|
733
|
+
request_id: request_id || null,
|
|
734
|
+
tool,
|
|
735
|
+
mode: grantMode,
|
|
736
|
+
expires_in_seconds: ttlMs / 1e3
|
|
737
|
+
}));
|
|
738
|
+
} catch {
|
|
739
|
+
res.writeHead(400);
|
|
740
|
+
res.end(JSON.stringify({ error: "invalid_json", usage: '{"request_id":"abc123","tool":"send_email","mode":"once","nonce":"..."}' }));
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
function handleListApprovals(res, approvalStore) {
|
|
745
|
+
if (!approvalStore) {
|
|
746
|
+
res.writeHead(200);
|
|
747
|
+
res.end(JSON.stringify({ grants: [] }));
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const now = Date.now();
|
|
751
|
+
const grants = [];
|
|
752
|
+
for (const [key, grant] of approvalStore) {
|
|
753
|
+
if (now < grant.expires_at) {
|
|
754
|
+
grants.push({ key, tool: grant.tool, mode: grant.mode, expires_in_seconds: Math.round((grant.expires_at - now) / 1e3) });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
res.writeHead(200);
|
|
758
|
+
res.end(JSON.stringify({ grants }));
|
|
759
|
+
}
|
|
668
760
|
|
|
669
761
|
// src/gateway.ts
|
|
670
762
|
var LOG_FILE2 = ".protect-mcp-log.jsonl";
|
|
763
|
+
var RECEIPTS_FILE = ".protect-mcp-receipts.jsonl";
|
|
671
764
|
var ProtectGateway = class {
|
|
672
765
|
child = null;
|
|
673
766
|
config;
|
|
674
767
|
rateLimitStore = /* @__PURE__ */ new Map();
|
|
675
768
|
clientReader = null;
|
|
676
769
|
logFilePath;
|
|
770
|
+
receiptFilePath;
|
|
677
771
|
evidenceStore;
|
|
678
772
|
receiptBuffer;
|
|
773
|
+
/** Approval grants keyed by request_id (scoped to the specific action that was requested) */
|
|
774
|
+
approvalStore = /* @__PURE__ */ new Map();
|
|
775
|
+
/** Random nonce generated at startup — required for approval endpoint authentication */
|
|
776
|
+
approvalNonce = randomBytes(16).toString("hex");
|
|
679
777
|
currentTier = "unknown";
|
|
680
778
|
admissionResult = null;
|
|
681
779
|
constructor(config) {
|
|
682
780
|
this.config = config;
|
|
683
781
|
this.logFilePath = join3(process.cwd(), LOG_FILE2);
|
|
782
|
+
this.receiptFilePath = join3(process.cwd(), RECEIPTS_FILE);
|
|
684
783
|
this.evidenceStore = new EvidenceStore();
|
|
685
784
|
this.receiptBuffer = new ReceiptBuffer();
|
|
686
785
|
}
|
|
@@ -704,12 +803,15 @@ var ProtectGateway = class {
|
|
|
704
803
|
this.log(`External PDP: ${this.config.policy.external?.endpoint || "not configured"}`);
|
|
705
804
|
}
|
|
706
805
|
}
|
|
806
|
+
this.log(`Approval nonce: ${this.approvalNonce}`);
|
|
707
807
|
const httpPort = parseInt(process.env.PROTECT_MCP_HTTP_PORT || "9876", 10);
|
|
708
808
|
if (httpPort > 0) {
|
|
709
809
|
try {
|
|
710
810
|
startStatusServer(
|
|
711
811
|
{ port: httpPort, mode, verbose },
|
|
712
|
-
this.receiptBuffer
|
|
812
|
+
this.receiptBuffer,
|
|
813
|
+
this.approvalStore,
|
|
814
|
+
this.approvalNonce
|
|
713
815
|
);
|
|
714
816
|
} catch {
|
|
715
817
|
if (verbose) this.log(`HTTP status server could not start on port ${httpPort}`);
|
|
@@ -811,8 +913,31 @@ var ProtectGateway = class {
|
|
|
811
913
|
async interceptToolCall(request) {
|
|
812
914
|
const toolName = request.params?.name || "unknown";
|
|
813
915
|
const requestId = randomUUID().slice(0, 12);
|
|
814
|
-
const toolPolicy = getToolPolicy(toolName, this.config.policy);
|
|
815
916
|
const mode = this.config.enforce ? "enforce" : "shadow";
|
|
917
|
+
let resolvedAgentKid = this.admissionResult?.agent_id;
|
|
918
|
+
let effectiveToolPolicy;
|
|
919
|
+
if (this.config.multiAgent?.enabled) {
|
|
920
|
+
const paramKid = request.params?._passport_kid;
|
|
921
|
+
if (paramKid) resolvedAgentKid = paramKid;
|
|
922
|
+
const agentOverrides = resolvedAgentKid ? this.config.multiAgent.agentPolicies?.[resolvedAgentKid] : void 0;
|
|
923
|
+
if (agentOverrides && agentOverrides[toolName]) {
|
|
924
|
+
effectiveToolPolicy = { ...getToolPolicy(toolName, this.config.policy), ...agentOverrides[toolName] };
|
|
925
|
+
} else if (!resolvedAgentKid && this.config.multiAgent.unknownAgentPolicy === "deny") {
|
|
926
|
+
this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "unknown_agent_denied", request_id: requestId, tier: this.currentTier });
|
|
927
|
+
if (this.config.enforce) {
|
|
928
|
+
return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" denied: unidentified agent`);
|
|
929
|
+
}
|
|
930
|
+
return null;
|
|
931
|
+
} else {
|
|
932
|
+
effectiveToolPolicy = getToolPolicy(toolName, this.config.policy);
|
|
933
|
+
}
|
|
934
|
+
if (this.config.verbose && resolvedAgentKid) {
|
|
935
|
+
this.log(`Multi-agent: resolved kid=${resolvedAgentKid} for tool=${toolName}`);
|
|
936
|
+
}
|
|
937
|
+
} else {
|
|
938
|
+
effectiveToolPolicy = getToolPolicy(toolName, this.config.policy);
|
|
939
|
+
}
|
|
940
|
+
const toolPolicy = effectiveToolPolicy;
|
|
816
941
|
let credentialRef;
|
|
817
942
|
if (this.config.credentials) {
|
|
818
943
|
const cred = resolveCredential(toolName, this.config.credentials);
|
|
@@ -863,6 +988,32 @@ var ProtectGateway = class {
|
|
|
863
988
|
}
|
|
864
989
|
return null;
|
|
865
990
|
}
|
|
991
|
+
if (toolPolicy.require_approval) {
|
|
992
|
+
const grant = this.approvalStore.get(requestId);
|
|
993
|
+
const alwaysGrant = this.approvalStore.get(`always:${toolName}`);
|
|
994
|
+
if (grant && Date.now() < grant.expires_at || alwaysGrant && Date.now() < alwaysGrant.expires_at) {
|
|
995
|
+
if (grant && grant.mode === "once") this.approvalStore.delete(requestId);
|
|
996
|
+
this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "approval_granted", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
this.emitDecisionLog({ tool: toolName, decision: "require_approval", reason_code: "requires_human_approval", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
|
|
1000
|
+
if (this.config.enforce) {
|
|
1001
|
+
return {
|
|
1002
|
+
jsonrpc: "2.0",
|
|
1003
|
+
id: request.id,
|
|
1004
|
+
result: {
|
|
1005
|
+
content: [
|
|
1006
|
+
{
|
|
1007
|
+
type: "text",
|
|
1008
|
+
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.`
|
|
1009
|
+
}
|
|
1010
|
+
],
|
|
1011
|
+
isError: true
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
866
1017
|
const rateSpec = this.getTierRateLimit(toolPolicy, this.currentTier);
|
|
867
1018
|
if (rateSpec) {
|
|
868
1019
|
try {
|
|
@@ -920,6 +1071,10 @@ var ProtectGateway = class {
|
|
|
920
1071
|
if (signed.signed) {
|
|
921
1072
|
process.stderr.write(`[PROTECT_MCP_RECEIPT] ${signed.signed}
|
|
922
1073
|
`);
|
|
1074
|
+
try {
|
|
1075
|
+
appendFileSync(this.receiptFilePath, signed.signed + "\n");
|
|
1076
|
+
} catch {
|
|
1077
|
+
}
|
|
923
1078
|
this.receiptBuffer.add(log.request_id, signed.signed);
|
|
924
1079
|
if (this.admissionResult?.agent_id) {
|
|
925
1080
|
this.evidenceStore.record(this.admissionResult.agent_id, this.config.signing?.issuer || "protect-mcp");
|
|
@@ -957,6 +1112,137 @@ var ProtectGateway = class {
|
|
|
957
1112
|
}
|
|
958
1113
|
};
|
|
959
1114
|
|
|
1115
|
+
// src/simulate.ts
|
|
1116
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
1117
|
+
function parseLogFile(path) {
|
|
1118
|
+
const raw = readFileSync5(path, "utf-8");
|
|
1119
|
+
const entries = [];
|
|
1120
|
+
for (const line of raw.split("\n")) {
|
|
1121
|
+
const trimmed = line.trim();
|
|
1122
|
+
if (!trimmed) continue;
|
|
1123
|
+
const jsonStr = trimmed.replace(/^\[PROTECT_MCP\]\s*/, "");
|
|
1124
|
+
try {
|
|
1125
|
+
const parsed = JSON.parse(jsonStr);
|
|
1126
|
+
if (parsed.tool && parsed.decision) {
|
|
1127
|
+
entries.push(parsed);
|
|
1128
|
+
}
|
|
1129
|
+
} catch {
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return entries;
|
|
1133
|
+
}
|
|
1134
|
+
function simulate(entries, policy, tier = "unknown") {
|
|
1135
|
+
const rateLimitStore = /* @__PURE__ */ new Map();
|
|
1136
|
+
const toolResults = /* @__PURE__ */ new Map();
|
|
1137
|
+
const totals = {
|
|
1138
|
+
allow: 0,
|
|
1139
|
+
block: 0,
|
|
1140
|
+
rate_limited: 0,
|
|
1141
|
+
require_approval: 0,
|
|
1142
|
+
tier_insufficient: 0
|
|
1143
|
+
};
|
|
1144
|
+
const originalTotals = { allow: 0, deny: 0 };
|
|
1145
|
+
const changes = [];
|
|
1146
|
+
for (const entry of entries) {
|
|
1147
|
+
const toolName = entry.tool;
|
|
1148
|
+
const toolPolicy = getToolPolicy(toolName, policy);
|
|
1149
|
+
if (entry.decision === "allow") {
|
|
1150
|
+
originalTotals.allow++;
|
|
1151
|
+
} else {
|
|
1152
|
+
originalTotals.deny++;
|
|
1153
|
+
}
|
|
1154
|
+
let newDecision;
|
|
1155
|
+
if (toolPolicy.block) {
|
|
1156
|
+
newDecision = "block";
|
|
1157
|
+
} else if (toolPolicy.min_tier && !meetsMinTier(tier, toolPolicy.min_tier)) {
|
|
1158
|
+
newDecision = "tier_insufficient";
|
|
1159
|
+
} else if (toolPolicy.require_approval) {
|
|
1160
|
+
newDecision = "require_approval";
|
|
1161
|
+
} else if (toolPolicy.rate_limit) {
|
|
1162
|
+
const limit = parseRateLimit(toolPolicy.rate_limit);
|
|
1163
|
+
const result = checkRateLimit(toolName, limit, rateLimitStore);
|
|
1164
|
+
newDecision = result.allowed ? "allow" : "rate_limited";
|
|
1165
|
+
} else {
|
|
1166
|
+
newDecision = "allow";
|
|
1167
|
+
}
|
|
1168
|
+
totals[newDecision]++;
|
|
1169
|
+
if (!toolResults.has(toolName)) {
|
|
1170
|
+
toolResults.set(toolName, {
|
|
1171
|
+
tool: toolName,
|
|
1172
|
+
calls: 0,
|
|
1173
|
+
results: { allow: 0, block: 0, rate_limited: 0, require_approval: 0, tier_insufficient: 0 },
|
|
1174
|
+
original: { allow: 0, deny: 0 }
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
const tr = toolResults.get(toolName);
|
|
1178
|
+
tr.calls++;
|
|
1179
|
+
tr.results[newDecision]++;
|
|
1180
|
+
if (entry.decision === "allow") {
|
|
1181
|
+
tr.original.allow++;
|
|
1182
|
+
} else {
|
|
1183
|
+
tr.original.deny++;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
for (const [tool, result] of toolResults) {
|
|
1187
|
+
const wasAllBlocked = result.original.allow === 0;
|
|
1188
|
+
const nowAllBlocked = result.results.allow === 0;
|
|
1189
|
+
const wasAllAllowed = result.original.deny === 0;
|
|
1190
|
+
if (wasAllAllowed && result.results.block > 0) {
|
|
1191
|
+
changes.push(`${tool}: ${result.results.block} calls would be blocked (was: all allowed)`);
|
|
1192
|
+
}
|
|
1193
|
+
if (wasAllAllowed && result.results.rate_limited > 0) {
|
|
1194
|
+
changes.push(`${tool}: ${result.results.rate_limited} calls would be rate-limited (was: all allowed)`);
|
|
1195
|
+
}
|
|
1196
|
+
if (wasAllAllowed && result.results.require_approval > 0) {
|
|
1197
|
+
changes.push(`${tool}: ${result.results.require_approval} calls would require approval (was: all allowed)`);
|
|
1198
|
+
}
|
|
1199
|
+
if (wasAllAllowed && result.results.tier_insufficient > 0) {
|
|
1200
|
+
changes.push(`${tool}: ${result.results.tier_insufficient} calls would fail tier check (was: all allowed)`);
|
|
1201
|
+
}
|
|
1202
|
+
if (wasAllBlocked && result.results.allow > 0 && !nowAllBlocked) {
|
|
1203
|
+
changes.push(`${tool}: ${result.results.allow} calls would now be allowed (was: all blocked)`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return {
|
|
1207
|
+
policy_file: "",
|
|
1208
|
+
log_file: "",
|
|
1209
|
+
total_calls: entries.length,
|
|
1210
|
+
results: totals,
|
|
1211
|
+
original: originalTotals,
|
|
1212
|
+
tool_breakdown: Array.from(toolResults.values()).sort((a, b) => b.calls - a.calls),
|
|
1213
|
+
changes
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
function formatSimulation(summary) {
|
|
1217
|
+
const lines = [];
|
|
1218
|
+
lines.push(`Simulating ${summary.policy_file} against ${summary.total_calls} recorded tool calls:
|
|
1219
|
+
`);
|
|
1220
|
+
const maxToolLen = Math.max(...summary.tool_breakdown.map((t) => t.tool.length), 4);
|
|
1221
|
+
for (const tr of summary.tool_breakdown) {
|
|
1222
|
+
const parts = [];
|
|
1223
|
+
if (tr.results.allow > 0) parts.push(`${tr.results.allow} allow`);
|
|
1224
|
+
if (tr.results.block > 0) parts.push(`\x1B[31m${tr.results.block} blocked\x1B[0m`);
|
|
1225
|
+
if (tr.results.rate_limited > 0) parts.push(`\x1B[33m${tr.results.rate_limited} rate_limited\x1B[0m`);
|
|
1226
|
+
if (tr.results.require_approval > 0) parts.push(`\x1B[36m${tr.results.require_approval} require_approval\x1B[0m`);
|
|
1227
|
+
if (tr.results.tier_insufficient > 0) parts.push(`\x1B[35m${tr.results.tier_insufficient} tier_insufficient\x1B[0m`);
|
|
1228
|
+
const originalParts = [];
|
|
1229
|
+
if (tr.original.allow > 0) originalParts.push(`${tr.original.allow} allow`);
|
|
1230
|
+
if (tr.original.deny > 0) originalParts.push(`${tr.original.deny} deny`);
|
|
1231
|
+
lines.push(` ${tr.tool.padEnd(maxToolLen)} \xD7 ${String(tr.calls).padStart(3)} \u2192 ${parts.join(", ")} (was: ${originalParts.join(", ")})`);
|
|
1232
|
+
}
|
|
1233
|
+
lines.push("");
|
|
1234
|
+
lines.push(`Summary: ${summary.results.allow} allow, ${summary.results.block} blocked, ${summary.results.rate_limited} rate_limited, ${summary.results.require_approval} require_approval, ${summary.results.tier_insufficient} tier_insufficient`);
|
|
1235
|
+
lines.push(` vs original: ${summary.original.allow} allow, ${summary.original.deny} deny`);
|
|
1236
|
+
if (summary.changes.length > 0) {
|
|
1237
|
+
lines.push("");
|
|
1238
|
+
lines.push("Changes:");
|
|
1239
|
+
for (const change of summary.changes) {
|
|
1240
|
+
lines.push(` \u2022 ${change}`);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return lines.join("\n");
|
|
1244
|
+
}
|
|
1245
|
+
|
|
960
1246
|
export {
|
|
961
1247
|
loadPolicy,
|
|
962
1248
|
getToolPolicy,
|
|
@@ -973,5 +1259,8 @@ export {
|
|
|
973
1259
|
isSigningEnabled,
|
|
974
1260
|
queryExternalPDP,
|
|
975
1261
|
buildDecisionContext,
|
|
976
|
-
ProtectGateway
|
|
1262
|
+
ProtectGateway,
|
|
1263
|
+
parseLogFile,
|
|
1264
|
+
simulate,
|
|
1265
|
+
formatSimulation
|
|
977
1266
|
};
|