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/dist/cli.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
initSigning,
|
|
5
5
|
loadPolicy,
|
|
6
6
|
validateCredentials
|
|
7
|
-
} from "./chunk-
|
|
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
|
|
201
|
-
const
|
|
202
|
-
const
|
|
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.
|
|
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-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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.
|
|
4
|
-
"description": "Security gateway for MCP servers. Shadow-mode logs
|
|
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",
|