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/dist/cli.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  initSigning,
5
5
  loadPolicy,
6
6
  validateCredentials
7
- } from "./chunk-3WCA7O4D.mjs";
7
+ } from "./chunk-U7TMVD3E.mjs";
8
8
 
9
9
  // src/cli.ts
10
10
  function printHelp() {
@@ -16,6 +16,9 @@ Usage:
16
16
  protect-mcp init [--dir <path>]
17
17
  protect-mcp demo
18
18
  protect-mcp status [--dir <path>]
19
+ protect-mcp digest [--today] [--dir <path>]
20
+ protect-mcp receipts [--last <n>] [--dir <path>]
21
+ protect-mcp bundle [--output <path>] [--dir <path>]
19
22
 
20
23
  Options:
21
24
  --policy <path> Policy/config JSON file (default: allow-all)
@@ -28,6 +31,9 @@ Commands:
28
31
  init Generate config template, Ed25519 keypair, and sample policy
29
32
  demo Start a demo server wrapped with protect-mcp (see receipts instantly)
30
33
  status Show tool call statistics from the local decision log
34
+ digest Generate a human-readable summary of agent activity
35
+ receipts Show recent persisted signed receipts
36
+ bundle Export an offline-verifiable audit bundle
31
37
 
32
38
  Examples:
33
39
  protect-mcp -- node my-server.js
@@ -36,6 +42,9 @@ Examples:
36
42
  protect-mcp init
37
43
  protect-mcp demo
38
44
  protect-mcp status
45
+ protect-mcp digest --today
46
+ protect-mcp receipts --last 10
47
+ protect-mcp bundle --output audit.json
39
48
 
40
49
  `);
41
50
  }
@@ -196,11 +205,10 @@ Add --enforce when ready to block policy violations.
196
205
  }
197
206
  async function handleDemo() {
198
207
  const { existsSync } = await import("fs");
199
- const { join, dirname } = await import("path");
200
- const { fileURLToPath } = await import("url");
201
- const __filename = fileURLToPath(import.meta.url);
202
- const __dirname = dirname(__filename);
203
- const demoServerPath = join(__dirname, "demo-server.js");
208
+ const { join, dirname, resolve } = await import("path");
209
+ const cliPath = resolve(process.argv[1] || "dist/cli.js");
210
+ const cliDir = dirname(cliPath);
211
+ const demoServerPath = join(cliDir, "demo-server.js");
204
212
  const configPath = join(process.cwd(), "protect-mcp.json");
205
213
  const hasConfig = existsSync(configPath);
206
214
  if (!hasConfig) {
@@ -385,6 +393,27 @@ ${bold("protect-mcp status")}
385
393
  } catch {
386
394
  }
387
395
  }
396
+ const keyPath = join(dir, "keys", "gateway.json");
397
+ if (existsSync(keyPath)) {
398
+ try {
399
+ const keyData = JSON.parse(readFileSync(keyPath, "utf-8"));
400
+ if (keyData.publicKey) {
401
+ const fingerprint = keyData.publicKey.slice(0, 16) + "...";
402
+ process.stdout.write(`
403
+ ${bold("\u{1F6E1}\uFE0F Passport identity:")}
404
+ `);
405
+ process.stdout.write(` Public key: ${fingerprint}
406
+ `);
407
+ if (keyData.kid) process.stdout.write(` Key ID: ${keyData.kid}
408
+ `);
409
+ process.stdout.write(` Issuer: ${keyData.issuer || "protect-mcp"}
410
+ `);
411
+ process.stdout.write(` Verify: ${dim("npx @veritasacta/verify <receipt.json>")}
412
+ `);
413
+ }
414
+ } catch {
415
+ }
416
+ }
388
417
  process.stdout.write(`
389
418
  Log file: ${dim(logPath)}
390
419
 
@@ -405,6 +434,186 @@ function red(s) {
405
434
  function yellow(s) {
406
435
  return process.env.NO_COLOR ? s : `\x1B[33m${s}\x1B[0m`;
407
436
  }
437
+ async function handleDigest(argv) {
438
+ const { readFileSync, existsSync } = await import("fs");
439
+ const { join } = await import("path");
440
+ let dir = process.cwd();
441
+ const dirIdx = argv.indexOf("--dir");
442
+ if (dirIdx !== -1 && argv[dirIdx + 1]) dir = argv[dirIdx + 1];
443
+ const today = argv.includes("--today");
444
+ const logPath = join(dir, ".protect-mcp-log.jsonl");
445
+ if (!existsSync(logPath)) {
446
+ process.stderr.write(`${bold("protect-mcp digest")}
447
+
448
+ No log file found. Run protect-mcp first.
449
+ `);
450
+ process.exit(0);
451
+ }
452
+ const raw = readFileSync(logPath, "utf-8");
453
+ const lines = raw.trim().split("\n").filter(Boolean);
454
+ let entries = [];
455
+ for (const line of lines) {
456
+ try {
457
+ entries.push(JSON.parse(line));
458
+ } catch {
459
+ }
460
+ }
461
+ if (today) {
462
+ const todayStart = /* @__PURE__ */ new Date();
463
+ todayStart.setHours(0, 0, 0, 0);
464
+ entries = entries.filter((e) => e.timestamp >= todayStart.getTime());
465
+ }
466
+ if (entries.length === 0) {
467
+ process.stdout.write(`
468
+ ${bold("\u{1F6E1}\uFE0F Agent Digest")}
469
+
470
+ No activity${today ? " today" : ""}.
471
+
472
+ `);
473
+ process.exit(0);
474
+ }
475
+ const allowed = entries.filter((e) => e.decision === "allow").length;
476
+ const denied = entries.filter((e) => e.decision === "deny").length;
477
+ const approvalRequired = entries.filter((e) => e.decision === "require_approval").length;
478
+ const toolUsage = /* @__PURE__ */ new Map();
479
+ for (const e of entries) {
480
+ toolUsage.set(e.tool, (toolUsage.get(e.tool) || 0) + 1);
481
+ }
482
+ const sortedTools = [...toolUsage.entries()].sort((a, b) => b[1] - a[1]);
483
+ const currentTier = entries[entries.length - 1]?.tier || "unknown";
484
+ const firstTime = new Date(Math.min(...entries.map((e) => e.timestamp)));
485
+ const lastTime = new Date(Math.max(...entries.map((e) => e.timestamp)));
486
+ const durationMs = lastTime.getTime() - firstTime.getTime();
487
+ const durationStr = durationMs < 6e4 ? `${Math.round(durationMs / 1e3)}s` : durationMs < 36e5 ? `${Math.round(durationMs / 6e4)}m` : `${(durationMs / 36e5).toFixed(1)}h`;
488
+ process.stdout.write(`
489
+ ${bold("\u{1F6E1}\uFE0F Agent Daily Digest")}
490
+
491
+ `);
492
+ process.stdout.write(` \u{1F4CA} ${bold(String(entries.length))} actions | `);
493
+ process.stdout.write(`${green("\u2713 " + allowed)} allowed | `);
494
+ process.stdout.write(`${red("\u2717 " + denied)} blocked`);
495
+ if (approvalRequired > 0) process.stdout.write(` | ${yellow("\u23F3 " + approvalRequired)} awaiting approval`);
496
+ process.stdout.write(`
497
+ `);
498
+ process.stdout.write(` \u{1F3C5} Trust tier: ${bold(currentTier)} | \u23F1 Active: ${durationStr}
499
+
500
+ `);
501
+ process.stdout.write(` ${bold("Tools used:")}
502
+ `);
503
+ for (const [tool, count] of sortedTools.slice(0, 8)) {
504
+ process.stdout.write(` ${tool.padEnd(22)} ${count}x
505
+ `);
506
+ }
507
+ if (denied > 0) {
508
+ const deniedTools = entries.filter((e) => e.decision === "deny");
509
+ const deniedToolNames = [...new Set(deniedTools.map((e) => e.tool))];
510
+ process.stdout.write(`
511
+ ${bold(red("Blocked tools:"))}
512
+ `);
513
+ for (const tool of deniedToolNames) {
514
+ const reason = deniedTools.find((e) => e.tool === tool)?.reason_code || "policy";
515
+ process.stdout.write(` ${red("\u2717")} ${tool} (${reason})
516
+ `);
517
+ }
518
+ }
519
+ process.stdout.write(`
520
+ ${dim("Latest receipt: curl -s http://127.0.0.1:9876/receipts/latest | jq -r .receipt > receipt.json")}
521
+ `);
522
+ process.stdout.write(` ${dim("Verify: npx @veritasacta/verify receipt.json --key <public-key-hex>")}
523
+ `);
524
+ process.stdout.write(` ${dim("Export: npx protect-mcp bundle --output audit.json")}
525
+
526
+ `);
527
+ }
528
+ async function handleReceipts(argv) {
529
+ const { readFileSync, existsSync } = await import("fs");
530
+ const { join } = await import("path");
531
+ let dir = process.cwd();
532
+ const dirIdx = argv.indexOf("--dir");
533
+ if (dirIdx !== -1 && argv[dirIdx + 1]) dir = argv[dirIdx + 1];
534
+ const lastIdx = argv.indexOf("--last");
535
+ const count = lastIdx !== -1 && argv[lastIdx + 1] ? parseInt(argv[lastIdx + 1], 10) : 20;
536
+ const receiptsPath = join(dir, ".protect-mcp-receipts.jsonl");
537
+ if (!existsSync(receiptsPath)) {
538
+ process.stderr.write(`${bold("protect-mcp receipts")}
539
+
540
+ No signed receipt file found. Run protect-mcp with signing enabled first.
541
+ `);
542
+ process.exit(0);
543
+ }
544
+ const raw = readFileSync(receiptsPath, "utf-8");
545
+ const lines = raw.trim().split("\n").filter(Boolean);
546
+ const recent = lines.slice(-count);
547
+ process.stdout.write(`
548
+ ${bold("\u{1F6E1}\uFE0F Recent Receipts")} (last ${recent.length})
549
+
550
+ `);
551
+ for (const line of recent) {
552
+ try {
553
+ const entry = JSON.parse(line);
554
+ const payload = entry.payload || {};
555
+ const time = typeof entry.issued_at === "string" ? new Date(entry.issued_at).toLocaleTimeString() : "unknown";
556
+ const decision = payload.decision || "unknown";
557
+ const icon = decision === "allow" ? green("\u2713") : decision === "require_approval" ? yellow("\u23F3") : red("\u2717");
558
+ process.stdout.write(` ${dim(time)} ${icon} ${String(payload.tool || "unknown").padEnd(22)} ${String(entry.type || "receipt").padEnd(18)} ${dim(String(payload.reason_code || "signed"))}
559
+ `);
560
+ } catch {
561
+ }
562
+ }
563
+ process.stdout.write(`
564
+ `);
565
+ }
566
+ async function handleBundle(argv) {
567
+ const { readFileSync, writeFileSync, existsSync } = await import("fs");
568
+ const { join } = await import("path");
569
+ const { createAuditBundle } = await import("./bundle-TXOTFJIJ.mjs");
570
+ let dir = process.cwd();
571
+ const dirIdx = argv.indexOf("--dir");
572
+ if (dirIdx !== -1 && argv[dirIdx + 1]) dir = argv[dirIdx + 1];
573
+ const outputIdx = argv.indexOf("--output");
574
+ const outputPath = outputIdx !== -1 && argv[outputIdx + 1] ? argv[outputIdx + 1] : join(dir, "audit-bundle.json");
575
+ const receiptsPath = join(dir, ".protect-mcp-receipts.jsonl");
576
+ const keyPath = join(dir, "keys", "gateway.json");
577
+ if (!existsSync(receiptsPath)) {
578
+ process.stderr.write(`${bold("protect-mcp bundle")}
579
+
580
+ No signed receipt file found. Run protect-mcp with signing enabled first.
581
+ `);
582
+ process.exit(0);
583
+ }
584
+ if (!existsSync(keyPath)) {
585
+ process.stderr.write(`${bold("protect-mcp bundle")}
586
+
587
+ No key file found at ${keyPath}
588
+ `);
589
+ process.exit(1);
590
+ }
591
+ const receipts = readFileSync(receiptsPath, "utf-8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
592
+ const keyData = JSON.parse(readFileSync(keyPath, "utf-8"));
593
+ const bundle = createAuditBundle({
594
+ tenant: keyData.issuer || "protect-mcp",
595
+ receipts,
596
+ signingKeys: [{
597
+ kty: "OKP",
598
+ crv: "Ed25519",
599
+ kid: keyData.kid || "unknown",
600
+ x: Buffer.from(keyData.publicKey, "hex").toString("base64url"),
601
+ use: "sig"
602
+ }]
603
+ });
604
+ writeFileSync(outputPath, JSON.stringify(bundle, null, 2) + "\n");
605
+ process.stdout.write(`
606
+ ${bold("protect-mcp bundle")}
607
+
608
+ `);
609
+ process.stdout.write(` Receipts: ${receipts.length}
610
+ `);
611
+ process.stdout.write(` Output: ${outputPath}
612
+ `);
613
+ process.stdout.write(` Verify: npx @veritasacta/verify ${outputPath} --bundle
614
+
615
+ `);
616
+ }
408
617
  async function main() {
409
618
  const args = process.argv.slice(2);
410
619
  if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
@@ -423,6 +632,18 @@ async function main() {
423
632
  await handleStatus(args.slice(1));
424
633
  process.exit(0);
425
634
  }
635
+ if (args[0] === "digest") {
636
+ await handleDigest(args.slice(1));
637
+ process.exit(0);
638
+ }
639
+ if (args[0] === "receipts") {
640
+ await handleReceipts(args.slice(1));
641
+ process.exit(0);
642
+ }
643
+ if (args[0] === "bundle") {
644
+ await handleBundle(args.slice(1));
645
+ process.exit(0);
646
+ }
426
647
  const { policyPath, slug, enforce, verbose, childCommand } = parseArgs(args);
427
648
  let policy = null;
428
649
  let policyDigest = "none";
package/dist/index.d.mts CHANGED
@@ -21,6 +21,8 @@ interface ToolPolicy {
21
21
  rate_limit?: string;
22
22
  /** Explicitly block this tool */
23
23
  block?: boolean;
24
+ /** Require human approval before executing (non-blocking: returns MCP error for LLM to suspend) */
25
+ require_approval?: boolean;
24
26
  /** Minimum trust tier required for this tool (v2) */
25
27
  min_tier?: TrustTier;
26
28
  /** Tier-specific rate limits (v2) */
@@ -113,7 +115,7 @@ interface DecisionLog {
113
115
  /** Tool name that was called */
114
116
  tool: string;
115
117
  /** Decision: allow or deny */
116
- decision: 'allow' | 'deny';
118
+ decision: 'allow' | 'deny' | 'require_approval';
117
119
  /** Why this decision was made */
118
120
  reason_code: string;
119
121
  /** SHA-256 digest of the canonicalized policy file */
@@ -284,8 +286,13 @@ declare class ProtectGateway {
284
286
  private rateLimitStore;
285
287
  private clientReader;
286
288
  private logFilePath;
289
+ private receiptFilePath;
287
290
  private evidenceStore;
288
291
  private receiptBuffer;
292
+ /** Approval grants keyed by request_id (scoped to the specific action that was requested) */
293
+ private approvalStore;
294
+ /** Random nonce generated at startup — required for approval endpoint authentication */
295
+ private readonly approvalNonce;
289
296
  private currentTier;
290
297
  private admissionResult;
291
298
  constructor(config: ProtectConfig);
package/dist/index.d.ts CHANGED
@@ -21,6 +21,8 @@ interface ToolPolicy {
21
21
  rate_limit?: string;
22
22
  /** Explicitly block this tool */
23
23
  block?: boolean;
24
+ /** Require human approval before executing (non-blocking: returns MCP error for LLM to suspend) */
25
+ require_approval?: boolean;
24
26
  /** Minimum trust tier required for this tool (v2) */
25
27
  min_tier?: TrustTier;
26
28
  /** Tier-specific rate limits (v2) */
@@ -113,7 +115,7 @@ interface DecisionLog {
113
115
  /** Tool name that was called */
114
116
  tool: string;
115
117
  /** Decision: allow or deny */
116
- decision: 'allow' | 'deny';
118
+ decision: 'allow' | 'deny' | 'require_approval';
117
119
  /** Why this decision was made */
118
120
  reason_code: string;
119
121
  /** SHA-256 digest of the canonicalized policy file */
@@ -284,8 +286,13 @@ declare class ProtectGateway {
284
286
  private rateLimitStore;
285
287
  private clientReader;
286
288
  private logFilePath;
289
+ private receiptFilePath;
287
290
  private evidenceStore;
288
291
  private receiptBuffer;
292
+ /** Approval grants keyed by request_id (scoped to the specific action that was requested) */
293
+ private approvalStore;
294
+ /** Random nonce generated at startup — required for approval endpoint authentication */
295
+ private readonly approvalNonce;
289
296
  private currentTier;
290
297
  private admissionResult;
291
298
  constructor(config: ProtectConfig);
package/dist/index.js CHANGED
@@ -402,8 +402,8 @@ async function initSigning(config) {
402
402
  signerState = {
403
403
  privateKey: keyData.privateKey,
404
404
  publicKey: keyData.publicKey,
405
- kid: artifactsModule.computeKid(keyData.publicKey),
406
- issuer: config.issuer || "protect-mcp"
405
+ kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
406
+ issuer: config.issuer || keyData.issuer || "protect-mcp"
407
407
  };
408
408
  } catch (err) {
409
409
  warnings.push(`signing: failed to load key file: ${err instanceof Error ? err.message : err}`);
@@ -615,13 +615,16 @@ var ReceiptBuffer = class {
615
615
  count() {
616
616
  return this.receipts.length;
617
617
  }
618
+ getLatest() {
619
+ return this.receipts.length > 0 ? this.receipts[this.receipts.length - 1] : void 0;
620
+ }
618
621
  };
619
- function startStatusServer(config, receiptBuffer) {
622
+ function startStatusServer(config, receiptBuffer, approvalStore, approvalNonce) {
620
623
  const startTime = Date.now();
621
624
  const logDir = process.cwd();
622
625
  const server = (0, import_node_http.createServer)((req, res) => {
623
626
  res.setHeader("Access-Control-Allow-Origin", "*");
624
- res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
627
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
625
628
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
626
629
  res.setHeader("Content-Type", "application/json");
627
630
  if (req.method === "OPTIONS") {
@@ -638,18 +641,30 @@ function startStatusServer(config, receiptBuffer) {
638
641
  handleStatus(res, logDir);
639
642
  } else if (path === "/receipts") {
640
643
  handleReceipts(res, receiptBuffer, url);
644
+ } else if (path === "/receipts/latest") {
645
+ handleReceiptLatest(res, receiptBuffer);
641
646
  } else if (path.startsWith("/receipts/")) {
642
647
  const id = path.slice("/receipts/".length);
643
648
  handleReceiptById(res, receiptBuffer, id);
649
+ } else if (path === "/approve" && req.method === "POST") {
650
+ handleApprove(req, res, approvalStore, approvalNonce);
651
+ } else if (path === "/approvals" && req.method === "GET") {
652
+ handleListApprovals(res, approvalStore);
644
653
  } else {
645
654
  res.writeHead(404);
646
- res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/:id"] }));
655
+ res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/latest", "/receipts/:id", "/approve", "/approvals"] }));
647
656
  }
648
657
  } catch (err) {
649
658
  res.writeHead(500);
650
659
  res.end(JSON.stringify({ error: "internal_error" }));
651
660
  }
652
661
  });
662
+ server.on("error", (err) => {
663
+ if (config.verbose) {
664
+ process.stderr.write(`[PROTECT_MCP] HTTP status server error: ${err.message}
665
+ `);
666
+ }
667
+ });
653
668
  server.listen(config.port, "127.0.0.1", () => {
654
669
  if (config.verbose) {
655
670
  process.stderr.write(`[PROTECT_MCP] HTTP status server listening on http://127.0.0.1:${config.port}
@@ -665,7 +680,7 @@ function handleHealth(res, startTime, config) {
665
680
  status: "ok",
666
681
  uptime_ms: Date.now() - startTime,
667
682
  mode: config.mode,
668
- version: "0.3.0"
683
+ version: "0.3.1"
669
684
  }));
670
685
  }
671
686
  function handleStatus(res, logDir) {
@@ -714,6 +729,16 @@ function handleReceipts(res, buffer, url) {
714
729
  receipts
715
730
  }));
716
731
  }
732
+ function handleReceiptLatest(res, buffer) {
733
+ const latest = buffer.getLatest();
734
+ if (!latest) {
735
+ res.writeHead(404);
736
+ res.end(JSON.stringify({ error: "no_receipts", message: "No receipts yet. Make a tool call through protect-mcp first." }));
737
+ return;
738
+ }
739
+ res.writeHead(200);
740
+ res.end(JSON.stringify(latest));
741
+ }
717
742
  function handleReceiptById(res, buffer, id) {
718
743
  const receipt = buffer.getById(id);
719
744
  if (!receipt) {
@@ -724,22 +749,92 @@ function handleReceiptById(res, buffer, id) {
724
749
  res.writeHead(200);
725
750
  res.end(JSON.stringify(receipt));
726
751
  }
752
+ function handleApprove(req, res, approvalStore, expectedNonce) {
753
+ if (!approvalStore) {
754
+ res.writeHead(503);
755
+ res.end(JSON.stringify({ error: "approval_store_not_available" }));
756
+ return;
757
+ }
758
+ let body = "";
759
+ req.on("data", (chunk) => {
760
+ body += chunk.toString();
761
+ });
762
+ req.on("end", () => {
763
+ try {
764
+ const { request_id, tool, mode, nonce } = JSON.parse(body);
765
+ if (expectedNonce && nonce !== expectedNonce) {
766
+ res.writeHead(403);
767
+ res.end(JSON.stringify({ error: "invalid_nonce", message: "Approval nonce does not match. Check stderr output for the correct nonce." }));
768
+ return;
769
+ }
770
+ if (!tool || typeof tool !== "string") {
771
+ res.writeHead(400);
772
+ res.end(JSON.stringify({ error: "missing_tool", usage: '{"request_id":"abc123","tool":"send_email","mode":"once|always","nonce":"..."}' }));
773
+ return;
774
+ }
775
+ const grantMode = mode === "always" ? "always" : "once";
776
+ const ttlMs = grantMode === "once" ? 5 * 60 * 1e3 : 24 * 60 * 60 * 1e3;
777
+ const grantEntry = { tool, mode: grantMode, expires_at: Date.now() + ttlMs };
778
+ if (grantMode === "always") {
779
+ approvalStore.set(`always:${tool}`, grantEntry);
780
+ } else if (request_id) {
781
+ approvalStore.set(request_id, grantEntry);
782
+ } else {
783
+ approvalStore.set(tool, grantEntry);
784
+ }
785
+ res.writeHead(200);
786
+ res.end(JSON.stringify({
787
+ approved: true,
788
+ request_id: request_id || null,
789
+ tool,
790
+ mode: grantMode,
791
+ expires_in_seconds: ttlMs / 1e3
792
+ }));
793
+ } catch {
794
+ res.writeHead(400);
795
+ res.end(JSON.stringify({ error: "invalid_json", usage: '{"request_id":"abc123","tool":"send_email","mode":"once","nonce":"..."}' }));
796
+ }
797
+ });
798
+ }
799
+ function handleListApprovals(res, approvalStore) {
800
+ if (!approvalStore) {
801
+ res.writeHead(200);
802
+ res.end(JSON.stringify({ grants: [] }));
803
+ return;
804
+ }
805
+ const now = Date.now();
806
+ const grants = [];
807
+ for (const [key, grant] of approvalStore) {
808
+ if (now < grant.expires_at) {
809
+ grants.push({ key, tool: grant.tool, mode: grant.mode, expires_in_seconds: Math.round((grant.expires_at - now) / 1e3) });
810
+ }
811
+ }
812
+ res.writeHead(200);
813
+ res.end(JSON.stringify({ grants }));
814
+ }
727
815
 
728
816
  // src/gateway.ts
729
817
  var LOG_FILE2 = ".protect-mcp-log.jsonl";
818
+ var RECEIPTS_FILE = ".protect-mcp-receipts.jsonl";
730
819
  var ProtectGateway = class {
731
820
  child = null;
732
821
  config;
733
822
  rateLimitStore = /* @__PURE__ */ new Map();
734
823
  clientReader = null;
735
824
  logFilePath;
825
+ receiptFilePath;
736
826
  evidenceStore;
737
827
  receiptBuffer;
828
+ /** Approval grants keyed by request_id (scoped to the specific action that was requested) */
829
+ approvalStore = /* @__PURE__ */ new Map();
830
+ /** Random nonce generated at startup — required for approval endpoint authentication */
831
+ approvalNonce = (0, import_node_crypto2.randomBytes)(16).toString("hex");
738
832
  currentTier = "unknown";
739
833
  admissionResult = null;
740
834
  constructor(config) {
741
835
  this.config = config;
742
836
  this.logFilePath = (0, import_node_path3.join)(process.cwd(), LOG_FILE2);
837
+ this.receiptFilePath = (0, import_node_path3.join)(process.cwd(), RECEIPTS_FILE);
743
838
  this.evidenceStore = new EvidenceStore();
744
839
  this.receiptBuffer = new ReceiptBuffer();
745
840
  }
@@ -763,12 +858,15 @@ var ProtectGateway = class {
763
858
  this.log(`External PDP: ${this.config.policy.external?.endpoint || "not configured"}`);
764
859
  }
765
860
  }
861
+ this.log(`Approval nonce: ${this.approvalNonce}`);
766
862
  const httpPort = parseInt(process.env.PROTECT_MCP_HTTP_PORT || "9876", 10);
767
863
  if (httpPort > 0) {
768
864
  try {
769
865
  startStatusServer(
770
866
  { port: httpPort, mode, verbose },
771
- this.receiptBuffer
867
+ this.receiptBuffer,
868
+ this.approvalStore,
869
+ this.approvalNonce
772
870
  );
773
871
  } catch {
774
872
  if (verbose) this.log(`HTTP status server could not start on port ${httpPort}`);
@@ -922,6 +1020,32 @@ var ProtectGateway = class {
922
1020
  }
923
1021
  return null;
924
1022
  }
1023
+ if (toolPolicy.require_approval) {
1024
+ const grant = this.approvalStore.get(requestId);
1025
+ const alwaysGrant = this.approvalStore.get(`always:${toolName}`);
1026
+ if (grant && Date.now() < grant.expires_at || alwaysGrant && Date.now() < alwaysGrant.expires_at) {
1027
+ if (grant && grant.mode === "once") this.approvalStore.delete(requestId);
1028
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "approval_granted", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
1029
+ return null;
1030
+ }
1031
+ this.emitDecisionLog({ tool: toolName, decision: "require_approval", reason_code: "requires_human_approval", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
1032
+ if (this.config.enforce) {
1033
+ return {
1034
+ jsonrpc: "2.0",
1035
+ id: request.id,
1036
+ result: {
1037
+ content: [
1038
+ {
1039
+ type: "text",
1040
+ 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.`
1041
+ }
1042
+ ],
1043
+ isError: true
1044
+ }
1045
+ };
1046
+ }
1047
+ return null;
1048
+ }
925
1049
  const rateSpec = this.getTierRateLimit(toolPolicy, this.currentTier);
926
1050
  if (rateSpec) {
927
1051
  try {
@@ -979,6 +1103,10 @@ var ProtectGateway = class {
979
1103
  if (signed.signed) {
980
1104
  process.stderr.write(`[PROTECT_MCP_RECEIPT] ${signed.signed}
981
1105
  `);
1106
+ try {
1107
+ (0, import_node_fs5.appendFileSync)(this.receiptFilePath, signed.signed + "\n");
1108
+ } catch {
1109
+ }
982
1110
  this.receiptBuffer.add(log.request_id, signed.signed);
983
1111
  if (this.admissionResult?.agent_id) {
984
1112
  this.evidenceStore.record(this.admissionResult.agent_id, this.config.signing?.issuer || "protect-mcp");
package/dist/index.mjs CHANGED
@@ -15,56 +15,11 @@ import {
15
15
  resolveCredential,
16
16
  signDecision,
17
17
  validateCredentials
18
- } from "./chunk-3WCA7O4D.mjs";
19
-
20
- // src/bundle.ts
21
- function createAuditBundle(opts) {
22
- const receipts = opts.receipts.filter(
23
- (r) => r && typeof r === "object" && typeof r.signature === "string"
24
- );
25
- if (receipts.length === 0) {
26
- throw new Error("Audit bundle requires at least one signed receipt");
27
- }
28
- const keyMap = /* @__PURE__ */ new Map();
29
- for (const key of opts.signingKeys) {
30
- if (!keyMap.has(key.kid)) {
31
- keyMap.set(key.kid, key);
32
- }
33
- }
34
- let timeRange = opts.timeRange || null;
35
- if (!timeRange) {
36
- const timestamps = receipts.map((r) => r.issued_at || r.timestamp).filter(Boolean).sort();
37
- if (timestamps.length > 0) {
38
- timeRange = {
39
- from: timestamps[0],
40
- to: timestamps[timestamps.length - 1]
41
- };
42
- }
43
- }
44
- return {
45
- format: "scopeblind:audit-bundle",
46
- version: 1,
47
- exported_at: (/* @__PURE__ */ new Date()).toISOString(),
48
- tenant: opts.tenant,
49
- time_range: timeRange,
50
- receipts,
51
- anchors: opts.anchors || [],
52
- verification: {
53
- algorithm: "ed25519",
54
- signing_keys: Array.from(keyMap.values()),
55
- 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`
56
- }
57
- };
58
- }
59
- function collectSignedReceipts(logs) {
60
- return logs.filter((log) => log.v === 2).map((log) => {
61
- const logRecord = log;
62
- if (logRecord.receipt) {
63
- return logRecord.receipt;
64
- }
65
- return logRecord;
66
- }).filter((r) => typeof r.signature === "string");
67
- }
18
+ } from "./chunk-U7TMVD3E.mjs";
19
+ import {
20
+ collectSignedReceipts,
21
+ createAuditBundle
22
+ } from "./chunk-5JXFV37Y.mjs";
68
23
 
69
24
  // src/manifest.ts
70
25
  function isAgentId(s) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "protect-mcp",
3
- "version": "0.3.0",
4
- "description": "Security gateway for MCP servers. Shadow-mode logs by default, per-tool policies, trust-tier gating, credential isolation, BYOPE (OPA/Cerbos), signed receipts, offline verification.",
3
+ "version": "0.3.1",
4
+ "description": "Security gateway for MCP servers. Shadow-mode logs, per-tool policies, optional local Ed25519-signed receipts. Programmatic hooks for trust tiers, credential config, and external policy engines.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "module": "dist/index.mjs",