protect-mcp 0.3.0 → 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/README.md +11 -1
- package/dist/bundle-TXOTFJIJ.mjs +8 -0
- package/dist/chunk-5JXFV37Y.mjs +53 -0
- package/dist/{chunk-3WCA7O4D.mjs → chunk-U7TMVD3E.mjs} +136 -8
- package/dist/cli.js +427 -20
- package/dist/cli.mjs +227 -6
- package/dist/index.d.mts +8 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +135 -7
- package/dist/index.mjs +5 -50
- package/package.json +2 -2
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
|
+
};
|
|
@@ -336,8 +336,8 @@ async function initSigning(config) {
|
|
|
336
336
|
signerState = {
|
|
337
337
|
privateKey: keyData.privateKey,
|
|
338
338
|
publicKey: keyData.publicKey,
|
|
339
|
-
kid: artifactsModule.computeKid(keyData.publicKey),
|
|
340
|
-
issuer: config.issuer || "protect-mcp"
|
|
339
|
+
kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
|
|
340
|
+
issuer: config.issuer || keyData.issuer || "protect-mcp"
|
|
341
341
|
};
|
|
342
342
|
} catch (err) {
|
|
343
343
|
warnings.push(`signing: failed to load key file: ${err instanceof Error ? err.message : err}`);
|
|
@@ -524,7 +524,7 @@ function buildDecisionContext(toolName, tier, opts) {
|
|
|
524
524
|
|
|
525
525
|
// src/gateway.ts
|
|
526
526
|
import { spawn } from "child_process";
|
|
527
|
-
import { randomUUID } from "crypto";
|
|
527
|
+
import { randomUUID, randomBytes } from "crypto";
|
|
528
528
|
import { createInterface } from "readline";
|
|
529
529
|
import { appendFileSync } from "fs";
|
|
530
530
|
import { join as join3 } from "path";
|
|
@@ -556,13 +556,16 @@ var ReceiptBuffer = class {
|
|
|
556
556
|
count() {
|
|
557
557
|
return this.receipts.length;
|
|
558
558
|
}
|
|
559
|
+
getLatest() {
|
|
560
|
+
return this.receipts.length > 0 ? this.receipts[this.receipts.length - 1] : void 0;
|
|
561
|
+
}
|
|
559
562
|
};
|
|
560
|
-
function startStatusServer(config, receiptBuffer) {
|
|
563
|
+
function startStatusServer(config, receiptBuffer, approvalStore, approvalNonce) {
|
|
561
564
|
const startTime = Date.now();
|
|
562
565
|
const logDir = process.cwd();
|
|
563
566
|
const server = createServer((req, res) => {
|
|
564
567
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
565
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
568
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
566
569
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
567
570
|
res.setHeader("Content-Type", "application/json");
|
|
568
571
|
if (req.method === "OPTIONS") {
|
|
@@ -579,18 +582,30 @@ function startStatusServer(config, receiptBuffer) {
|
|
|
579
582
|
handleStatus(res, logDir);
|
|
580
583
|
} else if (path === "/receipts") {
|
|
581
584
|
handleReceipts(res, receiptBuffer, url);
|
|
585
|
+
} else if (path === "/receipts/latest") {
|
|
586
|
+
handleReceiptLatest(res, receiptBuffer);
|
|
582
587
|
} else if (path.startsWith("/receipts/")) {
|
|
583
588
|
const id = path.slice("/receipts/".length);
|
|
584
589
|
handleReceiptById(res, receiptBuffer, id);
|
|
590
|
+
} else if (path === "/approve" && req.method === "POST") {
|
|
591
|
+
handleApprove(req, res, approvalStore, approvalNonce);
|
|
592
|
+
} else if (path === "/approvals" && req.method === "GET") {
|
|
593
|
+
handleListApprovals(res, approvalStore);
|
|
585
594
|
} else {
|
|
586
595
|
res.writeHead(404);
|
|
587
|
-
res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/:id"] }));
|
|
596
|
+
res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/latest", "/receipts/:id", "/approve", "/approvals"] }));
|
|
588
597
|
}
|
|
589
598
|
} catch (err) {
|
|
590
599
|
res.writeHead(500);
|
|
591
600
|
res.end(JSON.stringify({ error: "internal_error" }));
|
|
592
601
|
}
|
|
593
602
|
});
|
|
603
|
+
server.on("error", (err) => {
|
|
604
|
+
if (config.verbose) {
|
|
605
|
+
process.stderr.write(`[PROTECT_MCP] HTTP status server error: ${err.message}
|
|
606
|
+
`);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
594
609
|
server.listen(config.port, "127.0.0.1", () => {
|
|
595
610
|
if (config.verbose) {
|
|
596
611
|
process.stderr.write(`[PROTECT_MCP] HTTP status server listening on http://127.0.0.1:${config.port}
|
|
@@ -606,7 +621,7 @@ function handleHealth(res, startTime, config) {
|
|
|
606
621
|
status: "ok",
|
|
607
622
|
uptime_ms: Date.now() - startTime,
|
|
608
623
|
mode: config.mode,
|
|
609
|
-
version: "0.3.
|
|
624
|
+
version: "0.3.1"
|
|
610
625
|
}));
|
|
611
626
|
}
|
|
612
627
|
function handleStatus(res, logDir) {
|
|
@@ -655,6 +670,16 @@ function handleReceipts(res, buffer, url) {
|
|
|
655
670
|
receipts
|
|
656
671
|
}));
|
|
657
672
|
}
|
|
673
|
+
function handleReceiptLatest(res, buffer) {
|
|
674
|
+
const latest = buffer.getLatest();
|
|
675
|
+
if (!latest) {
|
|
676
|
+
res.writeHead(404);
|
|
677
|
+
res.end(JSON.stringify({ error: "no_receipts", message: "No receipts yet. Make a tool call through protect-mcp first." }));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
res.writeHead(200);
|
|
681
|
+
res.end(JSON.stringify(latest));
|
|
682
|
+
}
|
|
658
683
|
function handleReceiptById(res, buffer, id) {
|
|
659
684
|
const receipt = buffer.getById(id);
|
|
660
685
|
if (!receipt) {
|
|
@@ -665,22 +690,92 @@ function handleReceiptById(res, buffer, id) {
|
|
|
665
690
|
res.writeHead(200);
|
|
666
691
|
res.end(JSON.stringify(receipt));
|
|
667
692
|
}
|
|
693
|
+
function handleApprove(req, res, approvalStore, expectedNonce) {
|
|
694
|
+
if (!approvalStore) {
|
|
695
|
+
res.writeHead(503);
|
|
696
|
+
res.end(JSON.stringify({ error: "approval_store_not_available" }));
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
let body = "";
|
|
700
|
+
req.on("data", (chunk) => {
|
|
701
|
+
body += chunk.toString();
|
|
702
|
+
});
|
|
703
|
+
req.on("end", () => {
|
|
704
|
+
try {
|
|
705
|
+
const { request_id, tool, mode, nonce } = JSON.parse(body);
|
|
706
|
+
if (expectedNonce && nonce !== expectedNonce) {
|
|
707
|
+
res.writeHead(403);
|
|
708
|
+
res.end(JSON.stringify({ error: "invalid_nonce", message: "Approval nonce does not match. Check stderr output for the correct nonce." }));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (!tool || typeof tool !== "string") {
|
|
712
|
+
res.writeHead(400);
|
|
713
|
+
res.end(JSON.stringify({ error: "missing_tool", usage: '{"request_id":"abc123","tool":"send_email","mode":"once|always","nonce":"..."}' }));
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const grantMode = mode === "always" ? "always" : "once";
|
|
717
|
+
const ttlMs = grantMode === "once" ? 5 * 60 * 1e3 : 24 * 60 * 60 * 1e3;
|
|
718
|
+
const grantEntry = { tool, mode: grantMode, expires_at: Date.now() + ttlMs };
|
|
719
|
+
if (grantMode === "always") {
|
|
720
|
+
approvalStore.set(`always:${tool}`, grantEntry);
|
|
721
|
+
} else if (request_id) {
|
|
722
|
+
approvalStore.set(request_id, grantEntry);
|
|
723
|
+
} else {
|
|
724
|
+
approvalStore.set(tool, grantEntry);
|
|
725
|
+
}
|
|
726
|
+
res.writeHead(200);
|
|
727
|
+
res.end(JSON.stringify({
|
|
728
|
+
approved: true,
|
|
729
|
+
request_id: request_id || null,
|
|
730
|
+
tool,
|
|
731
|
+
mode: grantMode,
|
|
732
|
+
expires_in_seconds: ttlMs / 1e3
|
|
733
|
+
}));
|
|
734
|
+
} catch {
|
|
735
|
+
res.writeHead(400);
|
|
736
|
+
res.end(JSON.stringify({ error: "invalid_json", usage: '{"request_id":"abc123","tool":"send_email","mode":"once","nonce":"..."}' }));
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
function handleListApprovals(res, approvalStore) {
|
|
741
|
+
if (!approvalStore) {
|
|
742
|
+
res.writeHead(200);
|
|
743
|
+
res.end(JSON.stringify({ grants: [] }));
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const now = Date.now();
|
|
747
|
+
const grants = [];
|
|
748
|
+
for (const [key, grant] of approvalStore) {
|
|
749
|
+
if (now < grant.expires_at) {
|
|
750
|
+
grants.push({ key, tool: grant.tool, mode: grant.mode, expires_in_seconds: Math.round((grant.expires_at - now) / 1e3) });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
res.writeHead(200);
|
|
754
|
+
res.end(JSON.stringify({ grants }));
|
|
755
|
+
}
|
|
668
756
|
|
|
669
757
|
// src/gateway.ts
|
|
670
758
|
var LOG_FILE2 = ".protect-mcp-log.jsonl";
|
|
759
|
+
var RECEIPTS_FILE = ".protect-mcp-receipts.jsonl";
|
|
671
760
|
var ProtectGateway = class {
|
|
672
761
|
child = null;
|
|
673
762
|
config;
|
|
674
763
|
rateLimitStore = /* @__PURE__ */ new Map();
|
|
675
764
|
clientReader = null;
|
|
676
765
|
logFilePath;
|
|
766
|
+
receiptFilePath;
|
|
677
767
|
evidenceStore;
|
|
678
768
|
receiptBuffer;
|
|
769
|
+
/** Approval grants keyed by request_id (scoped to the specific action that was requested) */
|
|
770
|
+
approvalStore = /* @__PURE__ */ new Map();
|
|
771
|
+
/** Random nonce generated at startup — required for approval endpoint authentication */
|
|
772
|
+
approvalNonce = randomBytes(16).toString("hex");
|
|
679
773
|
currentTier = "unknown";
|
|
680
774
|
admissionResult = null;
|
|
681
775
|
constructor(config) {
|
|
682
776
|
this.config = config;
|
|
683
777
|
this.logFilePath = join3(process.cwd(), LOG_FILE2);
|
|
778
|
+
this.receiptFilePath = join3(process.cwd(), RECEIPTS_FILE);
|
|
684
779
|
this.evidenceStore = new EvidenceStore();
|
|
685
780
|
this.receiptBuffer = new ReceiptBuffer();
|
|
686
781
|
}
|
|
@@ -704,12 +799,15 @@ var ProtectGateway = class {
|
|
|
704
799
|
this.log(`External PDP: ${this.config.policy.external?.endpoint || "not configured"}`);
|
|
705
800
|
}
|
|
706
801
|
}
|
|
802
|
+
this.log(`Approval nonce: ${this.approvalNonce}`);
|
|
707
803
|
const httpPort = parseInt(process.env.PROTECT_MCP_HTTP_PORT || "9876", 10);
|
|
708
804
|
if (httpPort > 0) {
|
|
709
805
|
try {
|
|
710
806
|
startStatusServer(
|
|
711
807
|
{ port: httpPort, mode, verbose },
|
|
712
|
-
this.receiptBuffer
|
|
808
|
+
this.receiptBuffer,
|
|
809
|
+
this.approvalStore,
|
|
810
|
+
this.approvalNonce
|
|
713
811
|
);
|
|
714
812
|
} catch {
|
|
715
813
|
if (verbose) this.log(`HTTP status server could not start on port ${httpPort}`);
|
|
@@ -863,6 +961,32 @@ var ProtectGateway = class {
|
|
|
863
961
|
}
|
|
864
962
|
return null;
|
|
865
963
|
}
|
|
964
|
+
if (toolPolicy.require_approval) {
|
|
965
|
+
const grant = this.approvalStore.get(requestId);
|
|
966
|
+
const alwaysGrant = this.approvalStore.get(`always:${toolName}`);
|
|
967
|
+
if (grant && Date.now() < grant.expires_at || alwaysGrant && Date.now() < alwaysGrant.expires_at) {
|
|
968
|
+
if (grant && grant.mode === "once") this.approvalStore.delete(requestId);
|
|
969
|
+
this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "approval_granted", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
this.emitDecisionLog({ tool: toolName, decision: "require_approval", reason_code: "requires_human_approval", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
|
|
973
|
+
if (this.config.enforce) {
|
|
974
|
+
return {
|
|
975
|
+
jsonrpc: "2.0",
|
|
976
|
+
id: request.id,
|
|
977
|
+
result: {
|
|
978
|
+
content: [
|
|
979
|
+
{
|
|
980
|
+
type: "text",
|
|
981
|
+
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.`
|
|
982
|
+
}
|
|
983
|
+
],
|
|
984
|
+
isError: true
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
866
990
|
const rateSpec = this.getTierRateLimit(toolPolicy, this.currentTier);
|
|
867
991
|
if (rateSpec) {
|
|
868
992
|
try {
|
|
@@ -920,6 +1044,10 @@ var ProtectGateway = class {
|
|
|
920
1044
|
if (signed.signed) {
|
|
921
1045
|
process.stderr.write(`[PROTECT_MCP_RECEIPT] ${signed.signed}
|
|
922
1046
|
`);
|
|
1047
|
+
try {
|
|
1048
|
+
appendFileSync(this.receiptFilePath, signed.signed + "\n");
|
|
1049
|
+
} catch {
|
|
1050
|
+
}
|
|
923
1051
|
this.receiptBuffer.add(log.request_id, signed.signed);
|
|
924
1052
|
if (this.admissionResult?.agent_id) {
|
|
925
1053
|
this.evidenceStore.record(this.admissionResult.agent_id, this.config.signing?.issuer || "protect-mcp");
|