mcp-proof 0.3.11 → 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/bin/mcp-proof.js +55 -5
- package/package.json +2 -1
- package/vetokernel-client.js +168 -0
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;
|
|
@@ -53,6 +54,9 @@ Options:
|
|
|
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)
|
|
55
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)
|
|
56
60
|
--markdown emit a shareable Markdown Trust Card
|
|
57
61
|
--trust-card alias for --markdown
|
|
58
62
|
--sarif emit SARIF 2.1.0 for GitHub code scanning
|
|
@@ -150,14 +154,25 @@ async function main() {
|
|
|
150
154
|
|
|
151
155
|
if (args.inspectCall) {
|
|
152
156
|
let result;
|
|
157
|
+
let call;
|
|
153
158
|
try {
|
|
154
|
-
result = runExecute(args);
|
|
159
|
+
({ result, call } = runExecute(args));
|
|
155
160
|
} catch (err) {
|
|
156
161
|
process.stderr.write(`mcp-proof: ${err.message}\n`);
|
|
157
162
|
process.exit(err.exitCode || 65);
|
|
158
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
|
+
}
|
|
159
174
|
emitOutput(result, args);
|
|
160
|
-
process.exit(
|
|
175
|
+
process.exit(exitCodeForInspectedCall(result));
|
|
161
176
|
}
|
|
162
177
|
|
|
163
178
|
let raw;
|
|
@@ -212,6 +227,7 @@ function parseArgs(argv) {
|
|
|
212
227
|
source: '', transport: '', bind: '', json: false, selfTest: false,
|
|
213
228
|
help: false, version: false, file: '', stdio: '', config: '', skill: '',
|
|
214
229
|
inspectCall: false, deprecatedExecute: false, format: 'human', live: false, timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
230
|
+
vetokernelEndpoint: process.env.VETOKERNEL_ENDPOINT || '',
|
|
215
231
|
};
|
|
216
232
|
for (let i = 0; i < argv.length; i++) {
|
|
217
233
|
const a = argv[i];
|
|
@@ -239,6 +255,7 @@ function parseArgs(argv) {
|
|
|
239
255
|
break;
|
|
240
256
|
case '--live': out.live = true; break;
|
|
241
257
|
case '--timeout': out.timeoutMs = parseTimeout(argv[++i] || ''); break;
|
|
258
|
+
case '--vetokernel-endpoint': out.vetokernelEndpoint = argv[++i] || ''; break;
|
|
242
259
|
default:
|
|
243
260
|
if (a.startsWith('-')) {
|
|
244
261
|
process.stderr.write(`mcp-proof: unknown flag ${a}\n`);
|
|
@@ -654,7 +671,40 @@ function runExecute(args) {
|
|
|
654
671
|
const result = evaluateCall(parsed);
|
|
655
672
|
result.mode = 'inspect-call';
|
|
656
673
|
result.name = args.file || 'stdin';
|
|
657
|
-
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);
|
|
658
708
|
}
|
|
659
709
|
|
|
660
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 };
|