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 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,8 @@
1
+ import {
2
+ collectSignedReceipts,
3
+ createAuditBundle
4
+ } from "./chunk-5JXFV37Y.mjs";
5
+ export {
6
+ collectSignedReceipts,
7
+ createAuditBundle
8
+ };
@@ -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.0"
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");