mcp-proof 0.3.10 → 0.3.12
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 +4 -3
- package/bin/mcp-proof.js +73 -14
- package/package.json +2 -1
- package/vetokernel-client.js +168 -0
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ npx mcp-proof --config ~/.config/Claude/claude_desktop_config.json
|
|
|
29
29
|
npx mcp-proof --skill ~/.claude/skills/example/SKILL.md
|
|
30
30
|
|
|
31
31
|
# Inspect an exact tools/call payload before execution
|
|
32
|
-
cat call.json | npx mcp-proof --
|
|
32
|
+
cat call.json | npx mcp-proof --inspect-call --json
|
|
33
33
|
|
|
34
34
|
# Create a shareable Trust Card for a PR or README
|
|
35
35
|
npx mcp-proof tools.json --markdown > MCP_TRUST.md
|
|
@@ -134,7 +134,8 @@ Options:
|
|
|
134
134
|
--stdio <cmd> launch a real stdio MCP server, call tools/list, scan returned tools
|
|
135
135
|
--config <path> scan every stdio server in an MCP client config with mcpServers
|
|
136
136
|
--skill <path> scan a Claude SKILL.md (educational/warning context aware)
|
|
137
|
-
--
|
|
137
|
+
--inspect-call [<path>] inspect a tools/call payload before execution
|
|
138
|
+
--execute [<path>] deprecated alias for --inspect-call
|
|
138
139
|
--markdown emit a shareable Markdown Trust Card
|
|
139
140
|
--trust-card alias for --markdown
|
|
140
141
|
--sarif emit SARIF 2.1.0 for GitHub code scanning
|
|
@@ -226,7 +227,7 @@ The npm CLI is local-first: MCP manifests, tool descriptions, tool arguments, cl
|
|
|
226
227
|
- **Publisher verification is lexical only by default.** npm scope match + Levenshtein typosquat. Pass `--live` to confirm npm packages against `registry.npmjs.org` with one network call. *No GitHub-API lookup, no signature verification.*
|
|
227
228
|
- **No source-tree scan.** The full VyriAI gateway also runs a static scanner over the package source for `child_process`, postinstall scripts, obfuscation. That step needs the source unpacked locally and isn't shipped in this CLI.
|
|
228
229
|
- **No dependency CVE check.** The full gateway wraps `npm audit`; this CLI doesn't shell out.
|
|
229
|
-
- **Limited runtime-payload inspection.** `--
|
|
230
|
+
- **Limited runtime-payload inspection.** `--inspect-call` scans a supplied `tools/call` name and arguments before execution (`--execute` remains a deprecated alias); it does not execute the call, enforce an authorization policy, verify identity, or observe server runtime behavior.
|
|
230
231
|
- **Stdio discovery only.** `--stdio` / `--config` launches local stdio MCP servers and calls `tools/list`. It does not yet connect to HTTP/SSE MCP servers.
|
|
231
232
|
- **Skill context is keyword-based.** The skill detector looks for warning/example/demonstration framing around sensitive signals. A well-disguised attack without that framing may not downgrade.
|
|
232
233
|
- **Local payload cap.** The CLI refuses inputs over 5 MB by default to avoid accidental memory blowups in CI. Override with `MCP_SCAN_MAX_BYTES=20mb` if you have an unusually large tools manifest.
|
package/bin/mcp-proof.js
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// mcp-proof --self-test
|
|
10
10
|
//
|
|
11
11
|
// Exit codes:
|
|
12
|
-
// 0 — verdict allow
|
|
12
|
+
// 0 — verdict allow (and, with --vetokernel-endpoint, VetoKernel authorized it)
|
|
13
13
|
// 1 — verdict warn
|
|
14
|
-
// 2 — verdict block
|
|
14
|
+
// 2 — verdict block, or VetoKernel denied the action
|
|
15
15
|
// 64 — usage error
|
|
16
16
|
// 65 — input parse error
|
|
17
17
|
//
|
|
@@ -24,6 +24,7 @@ const os = require('os');
|
|
|
24
24
|
const path = require('path');
|
|
25
25
|
const { spawn } = require('child_process');
|
|
26
26
|
const { evaluate, evaluateCall, scanSkillManifest, verifyPublisherAsync } = require('../engine.js');
|
|
27
|
+
const { authorizeCall } = require('../vetokernel-client.js');
|
|
27
28
|
|
|
28
29
|
const DEFAULT_MAX_INPUT_BYTES = 5 * 1024 * 1024;
|
|
29
30
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
@@ -43,15 +44,19 @@ Usage:
|
|
|
43
44
|
mcp-proof --stdio "<command to launch MCP server>"
|
|
44
45
|
mcp-proof --config <claude-or-cursor-config.json>
|
|
45
46
|
mcp-proof --skill <SKILL.md>
|
|
46
|
-
mcp-proof --
|
|
47
|
+
mcp-proof --inspect-call [<call.json>] inspect a tools/call payload before execution
|
|
47
48
|
|
|
48
|
-
If <file.json> is omitted and --stdio/--config/--skill/--
|
|
49
|
+
If <file.json> is omitted and --stdio/--config/--skill/--inspect-call are not used, JSON is read from stdin.
|
|
49
50
|
|
|
50
51
|
Options:
|
|
51
52
|
--stdio <cmd> launch a real stdio MCP server, call tools/list, scan returned tools
|
|
52
53
|
--config <path> scan every stdio server in an MCP client config with mcpServers
|
|
53
54
|
--skill <path> scan a Claude SKILL.md (educational/warning context aware)
|
|
54
|
-
--
|
|
55
|
+
--inspect-call [<path>] inspect a tools/call payload before execution (reads stdin if path omitted)
|
|
56
|
+
--execute [<path>] deprecated alias for --inspect-call
|
|
57
|
+
--vetokernel-endpoint <url> with --inspect-call, ask VetoKernel to authorize the
|
|
58
|
+
proposed action before execution and verify the returned receipt
|
|
59
|
+
(env: VETOKERNEL_ENDPOINT, VETOKERNEL_API_KEY, VETOKERNEL_TENANT_ID)
|
|
55
60
|
--markdown emit a shareable Markdown Trust Card
|
|
56
61
|
--trust-card alias for --markdown
|
|
57
62
|
--sarif emit SARIF 2.1.0 for GitHub code scanning
|
|
@@ -94,9 +99,12 @@ async function main() {
|
|
|
94
99
|
if (args.selfTest) {
|
|
95
100
|
return runSelfTest(args);
|
|
96
101
|
}
|
|
97
|
-
|
|
102
|
+
if (args.deprecatedExecute) {
|
|
103
|
+
process.stderr.write('mcp-proof: --execute is deprecated; use --inspect-call instead.\n');
|
|
104
|
+
}
|
|
105
|
+
const exclusiveModes = [args.stdio, args.config, args.skill, args.inspectCall].filter(Boolean).length;
|
|
98
106
|
if (exclusiveModes > 1) {
|
|
99
|
-
process.stderr.write('mcp-proof: use one of --stdio, --config, --skill, --
|
|
107
|
+
process.stderr.write('mcp-proof: use one of --stdio, --config, --skill, --inspect-call at a time.\n');
|
|
100
108
|
process.exit(64);
|
|
101
109
|
}
|
|
102
110
|
|
|
@@ -144,16 +152,27 @@ async function main() {
|
|
|
144
152
|
process.exit(exitCodeForDecision(result.decision));
|
|
145
153
|
}
|
|
146
154
|
|
|
147
|
-
if (args.
|
|
155
|
+
if (args.inspectCall) {
|
|
148
156
|
let result;
|
|
157
|
+
let call;
|
|
149
158
|
try {
|
|
150
|
-
result = runExecute(args);
|
|
159
|
+
({ result, call } = runExecute(args));
|
|
151
160
|
} catch (err) {
|
|
152
161
|
process.stderr.write(`mcp-proof: ${err.message}\n`);
|
|
153
162
|
process.exit(err.exitCode || 65);
|
|
154
163
|
}
|
|
164
|
+
if (args.vetokernelEndpoint) {
|
|
165
|
+
try {
|
|
166
|
+
result.authorization = await authorizeInspectedCall(result, call, args);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// Fail closed: if the authorization service is unreachable, the action
|
|
169
|
+
// is not authorized.
|
|
170
|
+
process.stderr.write(`mcp-proof: vetokernel authorization failed: ${err.message}\n`);
|
|
171
|
+
process.exit(2);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
155
174
|
emitOutput(result, args);
|
|
156
|
-
process.exit(
|
|
175
|
+
process.exit(exitCodeForInspectedCall(result));
|
|
157
176
|
}
|
|
158
177
|
|
|
159
178
|
let raw;
|
|
@@ -207,7 +226,8 @@ function parseArgs(argv) {
|
|
|
207
226
|
const out = {
|
|
208
227
|
source: '', transport: '', bind: '', json: false, selfTest: false,
|
|
209
228
|
help: false, version: false, file: '', stdio: '', config: '', skill: '',
|
|
210
|
-
|
|
229
|
+
inspectCall: false, deprecatedExecute: false, format: 'human', live: false, timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
230
|
+
vetokernelEndpoint: process.env.VETOKERNEL_ENDPOINT || '',
|
|
211
231
|
};
|
|
212
232
|
for (let i = 0; i < argv.length; i++) {
|
|
213
233
|
const a = argv[i];
|
|
@@ -224,12 +244,18 @@ function parseArgs(argv) {
|
|
|
224
244
|
case '--stdio': out.stdio = argv[++i] || ''; break;
|
|
225
245
|
case '--config': out.config = expandHome(argv[++i] || ''); break;
|
|
226
246
|
case '--skill': out.skill = expandHome(argv[++i] || ''); break;
|
|
247
|
+
case '--inspect-call':
|
|
248
|
+
out.inspectCall = true;
|
|
249
|
+
if (argv[i + 1] && !argv[i + 1].startsWith('-')) out.file = argv[++i];
|
|
250
|
+
break;
|
|
227
251
|
case '--execute':
|
|
228
|
-
out.
|
|
252
|
+
out.inspectCall = true;
|
|
253
|
+
out.deprecatedExecute = true;
|
|
229
254
|
if (argv[i + 1] && !argv[i + 1].startsWith('-')) out.file = argv[++i];
|
|
230
255
|
break;
|
|
231
256
|
case '--live': out.live = true; break;
|
|
232
257
|
case '--timeout': out.timeoutMs = parseTimeout(argv[++i] || ''); break;
|
|
258
|
+
case '--vetokernel-endpoint': out.vetokernelEndpoint = argv[++i] || ''; break;
|
|
233
259
|
default:
|
|
234
260
|
if (a.startsWith('-')) {
|
|
235
261
|
process.stderr.write(`mcp-proof: unknown flag ${a}\n`);
|
|
@@ -643,9 +669,42 @@ function runExecute(args) {
|
|
|
643
669
|
throw e;
|
|
644
670
|
}
|
|
645
671
|
const result = evaluateCall(parsed);
|
|
646
|
-
result.mode = '
|
|
672
|
+
result.mode = 'inspect-call';
|
|
647
673
|
result.name = args.file || 'stdin';
|
|
648
|
-
return result;
|
|
674
|
+
return { result, call: parsed };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// authorizeInspectedCall asks VetoKernel to authorize the exact action this
|
|
678
|
+
// call proposes. action_type and target_id come from the call file itself,
|
|
679
|
+
// falling back to the tool name and arguments.target_id, so a call fixture is
|
|
680
|
+
// self-describing and needs no extra flags.
|
|
681
|
+
function authorizeInspectedCall(result, call, args) {
|
|
682
|
+
const callArgs = call.arguments || (call.params && call.params.arguments) || {};
|
|
683
|
+
const parametersJSON = JSON.stringify(callArgs);
|
|
684
|
+
return authorizeCall({
|
|
685
|
+
endpoint: args.vetokernelEndpoint,
|
|
686
|
+
apiKey: process.env.VETOKERNEL_API_KEY || '',
|
|
687
|
+
tenantId: process.env.VETOKERNEL_TENANT_ID || 'default',
|
|
688
|
+
agentId: call.agent_id || 'mcp-proof',
|
|
689
|
+
actionId: call.action_id || `mcp-proof-${Date.now()}`,
|
|
690
|
+
toolName: result.tool,
|
|
691
|
+
actionType: call.action_type || result.tool,
|
|
692
|
+
targetId: call.target_id || callArgs.target_id || '',
|
|
693
|
+
parametersJSON,
|
|
694
|
+
context: call.context,
|
|
695
|
+
scanner: {
|
|
696
|
+
name: 'mcp-proof',
|
|
697
|
+
decision: result.decision,
|
|
698
|
+
severity: result.call_scan.severity,
|
|
699
|
+
reason: result.call_scan.reason,
|
|
700
|
+
},
|
|
701
|
+
timeoutMs: args.timeoutMs,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function exitCodeForInspectedCall(result) {
|
|
706
|
+
if (result.authorization && result.authorization.decision !== 'allow') return 2;
|
|
707
|
+
return exitCodeForDecision(result.decision);
|
|
649
708
|
}
|
|
650
709
|
|
|
651
710
|
function exitCodeForDecision(d) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-proof",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"description": "Auditable, privacy-preserving MCP (Model Context Protocol) server verifier with local Trust Cards and SARIF output — test servers before connecting them to an AI system.",
|
|
5
5
|
"main": "engine.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"engine.js",
|
|
11
|
+
"vetokernel-client.js",
|
|
11
12
|
"bin/mcp-proof.js",
|
|
12
13
|
"README.md",
|
|
13
14
|
"LICENSE"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// vetokernel-client.js — post an inspected tools/call to VetoKernel's
|
|
2
|
+
// execution-authorization endpoint and independently verify the receipt.
|
|
3
|
+
//
|
|
4
|
+
// Node stdlib only (http, https, crypto, url) — mcp-proof adds no dependencies.
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
const https = require('https');
|
|
11
|
+
const { URL } = require('url');
|
|
12
|
+
|
|
13
|
+
const SCHEMA_VERSION = '1.0';
|
|
14
|
+
|
|
15
|
+
// goJSONString matches Go's encoding/json string encoding, which HTML-escapes
|
|
16
|
+
// <, > and & by default. Everything else Node's JSON.stringify already agrees on.
|
|
17
|
+
function goJSONString(value) {
|
|
18
|
+
return JSON.stringify(String(value))
|
|
19
|
+
.replace(/</g, '\\u003c')
|
|
20
|
+
.replace(/>/g, '\\u003e')
|
|
21
|
+
.replace(/&/g, '\\u0026');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// canonicalReceiptJSON reproduces, in JavaScript, the exact byte sequence
|
|
25
|
+
// VetoKernel hashes into receipt_hash (Go: backend/auth/execution_authorization.go
|
|
26
|
+
// CanonicalReceiptBytes). It is deliberately an INDEPENDENT re-implementation:
|
|
27
|
+
// agreement between the two is what makes the receipt independently verified
|
|
28
|
+
// rather than merely re-read. Field order and the omitempty set are load-bearing
|
|
29
|
+
// and are pinned by a golden vector in both test suites.
|
|
30
|
+
function canonicalReceiptJSON(receipt) {
|
|
31
|
+
const fields = [
|
|
32
|
+
['schema_version', receipt.schema_version, false],
|
|
33
|
+
['decision', receipt.decision, false],
|
|
34
|
+
['action_id', receipt.action_id, false],
|
|
35
|
+
['action_type', receipt.action_type, false],
|
|
36
|
+
['target_id', receipt.target_id, false],
|
|
37
|
+
['tool_name', receipt.tool_name, true],
|
|
38
|
+
['parameters_hash', receipt.parameters_hash, true],
|
|
39
|
+
['tenant_id', receipt.tenant_id, false],
|
|
40
|
+
['payload_hash', receipt.payload_hash, false],
|
|
41
|
+
['context_hash', receipt.context_hash, false],
|
|
42
|
+
['authorized_by_agent_id', receipt.authorized_by_agent_id, true],
|
|
43
|
+
['issued_at', receipt.issued_at, false],
|
|
44
|
+
];
|
|
45
|
+
const parts = [];
|
|
46
|
+
for (const [key, raw, omitempty] of fields) {
|
|
47
|
+
const value = raw === undefined || raw === null ? '' : raw;
|
|
48
|
+
if (omitempty && value === '') continue;
|
|
49
|
+
parts.push(goJSONString(key) + ':' + goJSONString(value));
|
|
50
|
+
}
|
|
51
|
+
return '{' + parts.join(',') + '}';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// verifyReceipt re-derives both bindings the receipt claims: that payload_hash
|
|
55
|
+
// is sha256 of the exact parameter bytes we sent, and that receipt_hash is
|
|
56
|
+
// sha256 of the canonical receipt fields.
|
|
57
|
+
function verifyReceipt(receipt, parametersJSON) {
|
|
58
|
+
if (!receipt || typeof receipt !== 'object') {
|
|
59
|
+
return { verified: false, problems: ['no receipt returned'], payload_hash: '', receipt_hash: '' };
|
|
60
|
+
}
|
|
61
|
+
const problems = [];
|
|
62
|
+
if (receipt.schema_version !== SCHEMA_VERSION) {
|
|
63
|
+
problems.push(`unexpected schema_version ${receipt.schema_version}`);
|
|
64
|
+
}
|
|
65
|
+
if (receipt.decision !== 'allow') {
|
|
66
|
+
problems.push(`unexpected receipt decision ${receipt.decision}`);
|
|
67
|
+
}
|
|
68
|
+
const payloadHash = crypto.createHash('sha256')
|
|
69
|
+
.update(Buffer.from(String(parametersJSON), 'utf8')).digest('hex');
|
|
70
|
+
if (receipt.payload_hash !== payloadHash) {
|
|
71
|
+
problems.push(`payload_hash does not bind the parameters we sent (recomputed ${payloadHash})`);
|
|
72
|
+
}
|
|
73
|
+
const receiptHash = crypto.createHash('sha256')
|
|
74
|
+
.update(canonicalReceiptJSON(receipt), 'utf8').digest('hex');
|
|
75
|
+
if (receipt.receipt_hash !== receiptHash) {
|
|
76
|
+
problems.push(`receipt_hash mismatch (recomputed ${receiptHash})`);
|
|
77
|
+
}
|
|
78
|
+
return { verified: problems.length === 0, problems, payload_hash: payloadHash, receipt_hash: receiptHash };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// authorizeCall proposes a consequential action to VetoKernel and returns its
|
|
82
|
+
// decision. It fails closed: an allow whose receipt does not independently
|
|
83
|
+
// verify is downgraded to a deny.
|
|
84
|
+
function authorizeCall(options) {
|
|
85
|
+
const {
|
|
86
|
+
endpoint,
|
|
87
|
+
apiKey = '',
|
|
88
|
+
tenantId = 'default',
|
|
89
|
+
agentId = 'mcp-proof',
|
|
90
|
+
actionId = '',
|
|
91
|
+
toolName = '',
|
|
92
|
+
actionType = '',
|
|
93
|
+
targetId = '',
|
|
94
|
+
parametersJSON = '{}',
|
|
95
|
+
context,
|
|
96
|
+
scanner = { name: 'mcp-proof', decision: 'allow', severity: 'low', reason: 'clean' },
|
|
97
|
+
timeoutMs = 5000,
|
|
98
|
+
} = options;
|
|
99
|
+
|
|
100
|
+
const body = JSON.stringify({
|
|
101
|
+
schema_version: SCHEMA_VERSION,
|
|
102
|
+
action_id: actionId,
|
|
103
|
+
agent_id: agentId,
|
|
104
|
+
tool_name: toolName,
|
|
105
|
+
action_type: actionType,
|
|
106
|
+
target_id: targetId,
|
|
107
|
+
parameters_json: parametersJSON,
|
|
108
|
+
context: context || undefined,
|
|
109
|
+
scanner,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const url = new URL(endpoint);
|
|
113
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
114
|
+
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const req = transport.request({
|
|
117
|
+
protocol: url.protocol,
|
|
118
|
+
hostname: url.hostname,
|
|
119
|
+
port: url.port,
|
|
120
|
+
path: url.pathname + url.search,
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
'Content-Type': 'application/json',
|
|
124
|
+
'Content-Length': Buffer.byteLength(body),
|
|
125
|
+
'X-API-Key': apiKey,
|
|
126
|
+
'X-Tenant-ID': tenantId,
|
|
127
|
+
},
|
|
128
|
+
}, (res) => {
|
|
129
|
+
const chunks = [];
|
|
130
|
+
res.on('data', (c) => chunks.push(c));
|
|
131
|
+
res.on('end', () => {
|
|
132
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(raw);
|
|
136
|
+
} catch (_) {
|
|
137
|
+
reject(new Error(`vetokernel returned non-JSON (status ${res.statusCode}): ${raw.slice(0, 200)}`));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const out = {
|
|
141
|
+
endpoint,
|
|
142
|
+
status: res.statusCode,
|
|
143
|
+
decision: parsed.decision === 'allow' ? 'allow' : 'deny',
|
|
144
|
+
reason: parsed.reason || '',
|
|
145
|
+
receipt: parsed.receipt || null,
|
|
146
|
+
verification: null,
|
|
147
|
+
};
|
|
148
|
+
out.verification = out.decision === 'allow'
|
|
149
|
+
? verifyReceipt(parsed.receipt, parametersJSON)
|
|
150
|
+
: { verified: false, problems: ['denied — no receipt issued'], payload_hash: '', receipt_hash: '' };
|
|
151
|
+
if (out.decision === 'allow' && !out.verification.verified) {
|
|
152
|
+
out.decision = 'deny';
|
|
153
|
+
out.reason = `receipt failed independent verification: ${out.verification.problems.join('; ')}`;
|
|
154
|
+
}
|
|
155
|
+
resolve(out);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
req.setTimeout(timeoutMs, () => {
|
|
160
|
+
req.destroy(new Error(`vetokernel request timed out after ${timeoutMs}ms`));
|
|
161
|
+
});
|
|
162
|
+
req.on('error', reject);
|
|
163
|
+
req.write(body);
|
|
164
|
+
req.end();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { authorizeCall, verifyReceipt, canonicalReceiptJSON, SCHEMA_VERSION };
|