mcp-proof 0.3.12 → 0.3.14
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/CHANGELOG.md +13 -0
- package/bin/mcp-proof.js +17 -57
- package/engine.js +32 -3
- package/package.json +2 -2
- package/vetokernel-client.js +0 -168
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.14
|
|
4
|
+
|
|
5
|
+
Severity-aware tool-poisoning summaries distinguish actionable patterns from low-severity URL mentions. Stdio launch errors now preserve server stderr for root-cause triage.
|
|
6
|
+
|
|
7
|
+
## 0.3.13
|
|
8
|
+
|
|
9
|
+
Scanner-only release. Runtime authorization functionality is not included.
|
|
10
|
+
|
|
11
|
+
The package contains MCP tool-poisoning, hidden Unicode, typosquat, transport,
|
|
12
|
+
runtime tools/call, Claude Skill, client-config, SARIF, Trust Card, payload-size,
|
|
13
|
+
and live stdio scanning capabilities.
|
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
|
|
13
13
|
// 1 — verdict warn
|
|
14
|
-
// 2 — verdict block
|
|
14
|
+
// 2 — verdict block
|
|
15
15
|
// 64 — usage error
|
|
16
16
|
// 65 — input parse error
|
|
17
17
|
//
|
|
@@ -24,7 +24,6 @@ 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');
|
|
28
27
|
|
|
29
28
|
const DEFAULT_MAX_INPUT_BYTES = 5 * 1024 * 1024;
|
|
30
29
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
@@ -54,9 +53,6 @@ Options:
|
|
|
54
53
|
--skill <path> scan a Claude SKILL.md (educational/warning context aware)
|
|
55
54
|
--inspect-call [<path>] inspect a tools/call payload before execution (reads stdin if path omitted)
|
|
56
55
|
--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)
|
|
60
56
|
--markdown emit a shareable Markdown Trust Card
|
|
61
57
|
--trust-card alias for --markdown
|
|
62
58
|
--sarif emit SARIF 2.1.0 for GitHub code scanning
|
|
@@ -156,23 +152,13 @@ async function main() {
|
|
|
156
152
|
let result;
|
|
157
153
|
let call;
|
|
158
154
|
try {
|
|
159
|
-
({ result
|
|
155
|
+
({ result } = runExecute(args));
|
|
160
156
|
} catch (err) {
|
|
161
157
|
process.stderr.write(`mcp-proof: ${err.message}\n`);
|
|
162
158
|
process.exit(err.exitCode || 65);
|
|
163
159
|
}
|
|
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
|
-
}
|
|
174
160
|
emitOutput(result, args);
|
|
175
|
-
process.exit(
|
|
161
|
+
process.exit(exitCodeForDecision(result.decision));
|
|
176
162
|
}
|
|
177
163
|
|
|
178
164
|
let raw;
|
|
@@ -227,7 +213,6 @@ function parseArgs(argv) {
|
|
|
227
213
|
source: '', transport: '', bind: '', json: false, selfTest: false,
|
|
228
214
|
help: false, version: false, file: '', stdio: '', config: '', skill: '',
|
|
229
215
|
inspectCall: false, deprecatedExecute: false, format: 'human', live: false, timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
230
|
-
vetokernelEndpoint: process.env.VETOKERNEL_ENDPOINT || '',
|
|
231
216
|
};
|
|
232
217
|
for (let i = 0; i < argv.length; i++) {
|
|
233
218
|
const a = argv[i];
|
|
@@ -255,7 +240,6 @@ function parseArgs(argv) {
|
|
|
255
240
|
break;
|
|
256
241
|
case '--live': out.live = true; break;
|
|
257
242
|
case '--timeout': out.timeoutMs = parseTimeout(argv[++i] || ''); break;
|
|
258
|
-
case '--vetokernel-endpoint': out.vetokernelEndpoint = argv[++i] || ''; break;
|
|
259
243
|
default:
|
|
260
244
|
if (a.startsWith('-')) {
|
|
261
245
|
process.stderr.write(`mcp-proof: unknown flag ${a}\n`);
|
|
@@ -415,10 +399,14 @@ async function fetchToolsList({ command, args, env, timeoutMs }) {
|
|
|
415
399
|
windowsHide: true,
|
|
416
400
|
});
|
|
417
401
|
|
|
418
|
-
const timer = setTimeout(() =>
|
|
402
|
+
const timer = setTimeout(() => {
|
|
403
|
+
const tail = stderrBuf.trim() ? ` — stderr: ${stderrBuf.trim().slice(-500)}` : '';
|
|
404
|
+
fail(new Error(`MCP server timed out after ${timeoutMs}ms${tail}`));
|
|
405
|
+
}, timeoutMs);
|
|
419
406
|
|
|
407
|
+
let stderrBuf = '';
|
|
420
408
|
child.on('error', fail);
|
|
421
|
-
child.stderr.on('data', () => {});
|
|
409
|
+
child.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf8'); });
|
|
422
410
|
child.stdout.on('data', (chunk) => {
|
|
423
411
|
buffer = Buffer.concat([buffer, chunk]);
|
|
424
412
|
buffer = consumeMessages(buffer, (msg) => {
|
|
@@ -442,9 +430,14 @@ async function fetchToolsList({ command, args, env, timeoutMs }) {
|
|
|
442
430
|
}
|
|
443
431
|
});
|
|
444
432
|
});
|
|
433
|
+
function childErrorContext(base, code, signal) {
|
|
434
|
+
const tail = stderrBuf.trim() ? ` — stderr: ${stderrBuf.trim().slice(-500)}` : '';
|
|
435
|
+
return new Error(`${base} (code=${code}, signal=${signal})${tail}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
445
438
|
child.on('exit', (code, signal) => {
|
|
446
439
|
if (!settled && pending.size > 0) {
|
|
447
|
-
fail(
|
|
440
|
+
fail(childErrorContext('MCP server exited before tools/list completed', code, signal));
|
|
448
441
|
}
|
|
449
442
|
});
|
|
450
443
|
|
|
@@ -671,40 +664,7 @@ function runExecute(args) {
|
|
|
671
664
|
const result = evaluateCall(parsed);
|
|
672
665
|
result.mode = 'inspect-call';
|
|
673
666
|
result.name = args.file || 'stdin';
|
|
674
|
-
return { result
|
|
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);
|
|
667
|
+
return { result };
|
|
708
668
|
}
|
|
709
669
|
|
|
710
670
|
function exitCodeForDecision(d) {
|
package/engine.js
CHANGED
|
@@ -423,6 +423,37 @@ function hasSuspiciousBase64(s) {
|
|
|
423
423
|
return false;
|
|
424
424
|
}
|
|
425
425
|
|
|
426
|
+
function summarizePatterns(perTool, severity) {
|
|
427
|
+
const counts = {};
|
|
428
|
+
for (const t of perTool) {
|
|
429
|
+
for (const p of t.patterns) {
|
|
430
|
+
if (p.severity === severity) counts[p.name] = (counts[p.name] || 0) + 1;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
|
434
|
+
return entries.map(([name, count]) => count > 1 ? `${count} ${name} mentions` : `1 ${name} pattern`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function formatToolPoisoningReason(perTool) {
|
|
438
|
+
if (perTool.length === 0) return 'no instruction-hijacking patterns detected';
|
|
439
|
+
const sevOrder = ['critical', 'high', 'medium', 'low'];
|
|
440
|
+
const bySev = {};
|
|
441
|
+
for (const t of perTool) {
|
|
442
|
+
for (const p of t.patterns) {
|
|
443
|
+
bySev[p.severity] = bySev[p.severity] || [];
|
|
444
|
+
bySev[p.severity].push({ patterns: [p] });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const parts = [];
|
|
448
|
+
for (const sev of sevOrder) {
|
|
449
|
+
const list = bySev[sev];
|
|
450
|
+
if (!list || list.length === 0) continue;
|
|
451
|
+
const names = summarizePatterns(list, sev);
|
|
452
|
+
parts.push(`${names.join(', ')} at ${sev} severity`);
|
|
453
|
+
}
|
|
454
|
+
return parts.join('; ');
|
|
455
|
+
}
|
|
456
|
+
|
|
426
457
|
function scanTools(tools, opts) {
|
|
427
458
|
const skillContext = (opts || {}).skillContext === true;
|
|
428
459
|
if (!tools || tools.length === 0) {
|
|
@@ -499,9 +530,7 @@ function scanTools(tools, opts) {
|
|
|
499
530
|
return {
|
|
500
531
|
detected: perTool.length > 0,
|
|
501
532
|
severity: maxSev,
|
|
502
|
-
reason: perTool
|
|
503
|
-
? `${perTool.length}/${tools.length} tools contain instruction-hijacking or sensitive-path references`
|
|
504
|
-
: 'no instruction-hijacking patterns detected',
|
|
533
|
+
reason: formatToolPoisoningReason(perTool),
|
|
505
534
|
perTool,
|
|
506
535
|
};
|
|
507
536
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-proof",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
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,9 +8,9 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"engine.js",
|
|
11
|
-
"vetokernel-client.js",
|
|
12
11
|
"bin/mcp-proof.js",
|
|
13
12
|
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
14
|
"LICENSE"
|
|
15
15
|
],
|
|
16
16
|
"engines": {
|
package/vetokernel-client.js
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
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 };
|