mcp-proof 0.3.5 → 0.3.7
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 +46 -5
- package/engine.js +181 -6
- package/package.json +1 -1
package/bin/mcp-proof.js
CHANGED
|
@@ -23,7 +23,7 @@ const fs = require('fs');
|
|
|
23
23
|
const os = require('os');
|
|
24
24
|
const path = require('path');
|
|
25
25
|
const { spawn } = require('child_process');
|
|
26
|
-
const { evaluate, scanSkillManifest, verifyPublisherAsync } = require('../engine.js');
|
|
26
|
+
const { evaluate, evaluateCall, scanSkillManifest, verifyPublisherAsync } = require('../engine.js');
|
|
27
27
|
|
|
28
28
|
const DEFAULT_MAX_INPUT_BYTES = 5 * 1024 * 1024;
|
|
29
29
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
@@ -43,13 +43,15 @@ Usage:
|
|
|
43
43
|
mcp-proof --stdio "<command to launch MCP server>"
|
|
44
44
|
mcp-proof --config <claude-or-cursor-config.json>
|
|
45
45
|
mcp-proof --skill <SKILL.md>
|
|
46
|
+
mcp-proof --execute [<call.json>] runtime authorization of a tools/call payload
|
|
46
47
|
|
|
47
|
-
If <file.json> is omitted and --stdio/--config/--skill are not used, JSON is read from stdin.
|
|
48
|
+
If <file.json> is omitted and --stdio/--config/--skill/--execute are not used, JSON is read from stdin.
|
|
48
49
|
|
|
49
50
|
Options:
|
|
50
51
|
--stdio <cmd> launch a real stdio MCP server, call tools/list, scan returned tools
|
|
51
52
|
--config <path> scan every stdio server in an MCP client config with mcpServers
|
|
52
53
|
--skill <path> scan a Claude SKILL.md (educational/warning context aware)
|
|
54
|
+
--execute [<path>] runtime authorization of a tools/call payload (reads stdin if path omitted)
|
|
53
55
|
--live confirm npm publisher against registry.npmjs.org (one network call)
|
|
54
56
|
--timeout <ms> MCP launch/protocol timeout per server (default: 15000)
|
|
55
57
|
--source <s> npm:@scope/pkg, git+https://..., or file://... (drives publisher verification)
|
|
@@ -89,9 +91,9 @@ async function main() {
|
|
|
89
91
|
if (args.selfTest) {
|
|
90
92
|
return runSelfTest(args);
|
|
91
93
|
}
|
|
92
|
-
const exclusiveModes = [args.stdio, args.config, args.skill].filter(Boolean).length;
|
|
94
|
+
const exclusiveModes = [args.stdio, args.config, args.skill, args.execute].filter(Boolean).length;
|
|
93
95
|
if (exclusiveModes > 1) {
|
|
94
|
-
process.stderr.write('mcp-proof: use one of --stdio, --config, --skill at a time.\n');
|
|
96
|
+
process.stderr.write('mcp-proof: use one of --stdio, --config, --skill, --execute at a time.\n');
|
|
95
97
|
process.exit(64);
|
|
96
98
|
}
|
|
97
99
|
|
|
@@ -151,6 +153,22 @@ async function main() {
|
|
|
151
153
|
process.exit(exitCodeForDecision(result.decision));
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
if (args.execute) {
|
|
157
|
+
let result;
|
|
158
|
+
try {
|
|
159
|
+
result = runExecute(args);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
process.stderr.write(`mcp-proof: ${err.message}\n`);
|
|
162
|
+
process.exit(err.exitCode || 65);
|
|
163
|
+
}
|
|
164
|
+
if (args.json) {
|
|
165
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
166
|
+
} else {
|
|
167
|
+
renderHumanReadable(result, 0, result.tool || 'execute');
|
|
168
|
+
}
|
|
169
|
+
process.exit(exitCodeForDecision(result.decision));
|
|
170
|
+
}
|
|
171
|
+
|
|
154
172
|
let raw;
|
|
155
173
|
try {
|
|
156
174
|
raw = args.file ? readFileLimited(args.file) : readStdinLimited();
|
|
@@ -206,7 +224,7 @@ function parseArgs(argv) {
|
|
|
206
224
|
const out = {
|
|
207
225
|
source: '', transport: '', bind: '', json: false, selfTest: false,
|
|
208
226
|
help: false, version: false, file: '', stdio: '', config: '', skill: '',
|
|
209
|
-
live: false, timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
227
|
+
execute: false, live: false, timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
210
228
|
};
|
|
211
229
|
for (let i = 0; i < argv.length; i++) {
|
|
212
230
|
const a = argv[i];
|
|
@@ -221,6 +239,10 @@ function parseArgs(argv) {
|
|
|
221
239
|
case '--stdio': out.stdio = argv[++i] || ''; break;
|
|
222
240
|
case '--config': out.config = expandHome(argv[++i] || ''); break;
|
|
223
241
|
case '--skill': out.skill = expandHome(argv[++i] || ''); break;
|
|
242
|
+
case '--execute':
|
|
243
|
+
out.execute = true;
|
|
244
|
+
if (argv[i + 1] && !argv[i + 1].startsWith('-')) out.file = argv[++i];
|
|
245
|
+
break;
|
|
224
246
|
case '--live': out.live = true; break;
|
|
225
247
|
case '--timeout': out.timeoutMs = parseTimeout(argv[++i] || ''); break;
|
|
226
248
|
default:
|
|
@@ -577,6 +599,25 @@ function worstDecision(decisions) {
|
|
|
577
599
|
return 'allow';
|
|
578
600
|
}
|
|
579
601
|
|
|
602
|
+
function runExecute(args) {
|
|
603
|
+
const raw = args.file ? readFileLimited(args.file) : readStdinLimited();
|
|
604
|
+
if (!raw.trim()) {
|
|
605
|
+
throw new Error('empty input. Pipe a tools/call JSON or pass a path.');
|
|
606
|
+
}
|
|
607
|
+
let parsed;
|
|
608
|
+
try {
|
|
609
|
+
parsed = JSON.parse(raw);
|
|
610
|
+
} catch (err) {
|
|
611
|
+
const e = new Error(`invalid JSON: ${err.message}`);
|
|
612
|
+
e.exitCode = 65;
|
|
613
|
+
throw e;
|
|
614
|
+
}
|
|
615
|
+
const result = evaluateCall(parsed);
|
|
616
|
+
result.mode = 'execute';
|
|
617
|
+
result.name = args.file || 'stdin';
|
|
618
|
+
return result;
|
|
619
|
+
}
|
|
620
|
+
|
|
580
621
|
function exitCodeForDecision(d) {
|
|
581
622
|
return d === 'allow' ? 0 : d === 'warn' ? 1 : 2;
|
|
582
623
|
}
|
package/engine.js
CHANGED
|
@@ -123,8 +123,119 @@ const SUSPICIOUS_DECODED =
|
|
|
123
123
|
const SKILL_BENIGN_MARKERS =
|
|
124
124
|
/\b(?:warning|⚠️|example|demonstration|sample|illustration|not\s+safe|suspicious|malicious|attack)\b/i;
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
// Negation markers in the same clause as a sensitive-pattern match indicate a
|
|
127
|
+
// warning/prohibition ("Never ask the model to read ~/.ssh/id_rsa") rather than
|
|
128
|
+
// an attack directive.
|
|
129
|
+
const SKILL_NEGATION_RE =
|
|
130
|
+
/\b(?:never|no|not|n't|nothing|none|do not|don't|doesn't|does not|won't|will not|wouldn't|would not|shouldn't|should not|mustn't|must not|without|avoid|prohibited|forbidden)\b/i;
|
|
131
|
+
|
|
132
|
+
// Imperative/directive action verbs, including third-person/present/past
|
|
133
|
+
// conjugations ("forwards", "reads", "forwarded") — real tool descriptions
|
|
134
|
+
// are often written in descriptive third person ("this tool forwards data
|
|
135
|
+
// to..."), not just the bare imperative. For sensitive patterns, the
|
|
136
|
+
// pattern alone is downgraded unless an un-negated directive action
|
|
137
|
+
// appears nearby. "leak" is deliberately kept bare-only (no "leaked"/
|
|
138
|
+
// "leaking"): "leaked" is an extremely common adjective in security prose
|
|
139
|
+
// describing already-exposed data ("leaked .env file", "leaked API key"),
|
|
140
|
+
// not a live directive, and generalizing it produces exactly that false
|
|
141
|
+
// match.
|
|
142
|
+
const SKILL_DIRECTIVE_ACTION_RE =
|
|
143
|
+
/\b(?:read|write|send|upload|exfiltrate|include|append|attach|post|fetch|extract|copy|steal|forward|use|call|invoke|execute|run|ignore|disregard)(?:s|ed|ing)?\b|\bleak\b/gi;
|
|
144
|
+
|
|
145
|
+
// A lone '\n' is markdown/prose line-wrapping, not a sentence end — SKILL.md
|
|
146
|
+
// files and tool descriptions wrap constantly mid-sentence. Only a blank
|
|
147
|
+
// line (a real paragraph break) should reset clause context; otherwise a
|
|
148
|
+
// directive verb like "read" on one line and its target ("~/.ssh/id_rsa")
|
|
149
|
+
// on the next would be silently treated as two different clauses.
|
|
150
|
+
function isNewlineBoundary(text, i) {
|
|
151
|
+
let before = i - 1;
|
|
152
|
+
while (before >= 0 && (text[before] === ' ' || text[before] === '\t')) before--;
|
|
153
|
+
if (before >= 0 && text[before] === '\n') return true;
|
|
154
|
+
let after = i + 1;
|
|
155
|
+
while (after < text.length && (text[after] === ' ' || text[after] === '\t')) after++;
|
|
156
|
+
return after < text.length && text[after] === '\n';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isClauseBoundary(text, i) {
|
|
160
|
+
const c = text[i];
|
|
161
|
+
return c === '.' || c === '!' || c === '?' || (c === '\n' && isNewlineBoundary(text, i));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function findClauseStart(text, idx) {
|
|
165
|
+
let start = 0;
|
|
166
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
167
|
+
if (isClauseBoundary(text, i)) {
|
|
168
|
+
start = i + 1;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return start;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function findClauseEnd(text, idx) {
|
|
176
|
+
for (let i = idx; i < text.length; i++) {
|
|
177
|
+
if (isClauseBoundary(text, i)) {
|
|
178
|
+
return i;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return text.length;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const URL_RE = /https?:\/\/[^\s)]+/gi;
|
|
185
|
+
|
|
186
|
+
// The local clause window around the match, plus the immediately adjacent
|
|
187
|
+
// clause on either side — mirrors the "same sentence or adjacent clause"
|
|
188
|
+
// co-occurrence rule used for the directive/negation check below. URLs are
|
|
189
|
+
// stripped before the caller tests for benign markers: a URL's host/path is
|
|
190
|
+
// an opaque data token, not authorial framing, and "example" (the RFC 2606
|
|
191
|
+
// placeholder TLD, extremely common in exactly this kind of exfil URL) would
|
|
192
|
+
// otherwise launder a real attack — whether "example" lands inside the
|
|
193
|
+
// match itself ("https://attacker.example/collect") or elsewhere in the
|
|
194
|
+
// same sentence, neither should count as the author signaling "this is
|
|
195
|
+
// educational".
|
|
196
|
+
function localClauseWindow(text, matchIndex, matchLen) {
|
|
197
|
+
const clauseStart = findClauseStart(text, matchIndex);
|
|
198
|
+
const prevClauseStart = clauseStart > 0 ? findClauseStart(text, clauseStart - 1) : 0;
|
|
199
|
+
const clauseEnd = findClauseEnd(text, matchIndex + matchLen);
|
|
200
|
+
const nextClauseEnd = clauseEnd < text.length ? findClauseEnd(text, clauseEnd + 1) : text.length;
|
|
201
|
+
return text.slice(prevClauseStart, nextClauseEnd);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hasUnnegatedModelDirective(text, matchIndex, matchText) {
|
|
205
|
+
const sentStart = findClauseStart(text, matchIndex);
|
|
206
|
+
const before = text.slice(sentStart, matchIndex);
|
|
207
|
+
// If the clause is negated before the match, the action is a warning/prohibition.
|
|
208
|
+
if (SKILL_NEGATION_RE.test(before)) return false;
|
|
209
|
+
// Find the last directive action in the clause before the match.
|
|
210
|
+
let lastActionIndex = -1;
|
|
211
|
+
let m;
|
|
212
|
+
SKILL_DIRECTIVE_ACTION_RE.lastIndex = 0;
|
|
213
|
+
while ((m = SKILL_DIRECTIVE_ACTION_RE.exec(before)) !== null) {
|
|
214
|
+
lastActionIndex = m.index;
|
|
215
|
+
}
|
|
216
|
+
if (lastActionIndex === -1) return false;
|
|
217
|
+
// Action must be within 80 chars of the match to be co-located in the same clause.
|
|
218
|
+
return (matchIndex - (sentStart + lastActionIndex)) <= 100;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// sigName is the SIGNATURES entry name for the specific match being judged —
|
|
222
|
+
// pass it (and the match position) whenever available so instruction-hijack
|
|
223
|
+
// signatures are never downgraded and the benign-marker check stays scoped
|
|
224
|
+
// to the local clause rather than the whole corpus.
|
|
225
|
+
function isBenignSkillContext(text, matchIndex, matchText, sigName) {
|
|
226
|
+
const isHijackSig = sigName && (sigName.startsWith('instruction_hijack') || sigName === 'system_role_overwrite');
|
|
227
|
+
if (matchIndex === undefined || !matchText) {
|
|
228
|
+
return !isHijackSig && SKILL_BENIGN_MARKERS.test(text.replace(URL_RE, ' '));
|
|
229
|
+
}
|
|
230
|
+
const window = localClauseWindow(text, matchIndex, matchText.length);
|
|
231
|
+
if (SKILL_BENIGN_MARKERS.test(window.replace(URL_RE, ' '))) return true;
|
|
232
|
+
// Instruction-hijack and role-overwrite signatures ARE the directive; past
|
|
233
|
+
// this point do not downgrade them purely because they contain negation
|
|
234
|
+
// words ("Do not mention this to the user" is itself the attack phrase,
|
|
235
|
+
// negated or not) — only an explicit local benign marker, checked above,
|
|
236
|
+
// is a strong enough signal to treat one of these as quoted/educational.
|
|
237
|
+
if (isHijackSig) return false;
|
|
238
|
+
return !hasUnnegatedModelDirective(text, matchIndex, matchText);
|
|
128
239
|
}
|
|
129
240
|
|
|
130
241
|
function findAllMatches(corpus, re) {
|
|
@@ -334,9 +445,10 @@ function scanTools(tools, opts) {
|
|
|
334
445
|
let sev = sig.sev;
|
|
335
446
|
let downgraded = false;
|
|
336
447
|
// For skill manifests, use the whole prompt for context framing. If the
|
|
337
|
-
// author has explicitly labeled the content as a warning/example,
|
|
338
|
-
//
|
|
339
|
-
|
|
448
|
+
// author has explicitly labeled the content as a warning/example, or if
|
|
449
|
+
// a sensitive-pattern match appears in a negated/descriptive clause rather
|
|
450
|
+
// than an un-negated model directive, the match is treated as educational.
|
|
451
|
+
if (skillContext && isBenignSkillContext(corpus, m.index, m.text, sig.name)) {
|
|
340
452
|
sev = 'low';
|
|
341
453
|
downgraded = true;
|
|
342
454
|
}
|
|
@@ -522,4 +634,67 @@ function evaluate(req) {
|
|
|
522
634
|
|
|
523
635
|
function round1(v) { return Math.round(v * 10) / 10; }
|
|
524
636
|
|
|
525
|
-
|
|
637
|
+
// ── Runtime tool-call authorization ──────────────────────────────────────────
|
|
638
|
+
//
|
|
639
|
+
// evaluateCall answers the execution-time question: given the exact tool and
|
|
640
|
+
// the exact arguments, is this call still authorized? It applies the same
|
|
641
|
+
// signatures used for static tool manifest scanning to the live payload.
|
|
642
|
+
|
|
643
|
+
function flattenArgs(args) {
|
|
644
|
+
const values = [];
|
|
645
|
+
function walk(v) {
|
|
646
|
+
if (v == null) return;
|
|
647
|
+
if (typeof v === 'string') values.push(v);
|
|
648
|
+
else if (Array.isArray(v)) v.forEach(walk);
|
|
649
|
+
else if (typeof v === 'object') Object.values(v).forEach(walk);
|
|
650
|
+
else values.push(String(v));
|
|
651
|
+
}
|
|
652
|
+
walk(args);
|
|
653
|
+
return values.join('\n');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function scanCall(toolName, args) {
|
|
657
|
+
const hay = [toolName, flattenArgs(args)].join('\n');
|
|
658
|
+
for (const sig of SIGNATURES) {
|
|
659
|
+
if (sig.re.test(hay)) {
|
|
660
|
+
return { detected: true, severity: sig.sev, reason: sig.reason };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (HIDDEN_CHARS.test(hay)) {
|
|
664
|
+
return { detected: true, severity: 'high', reason: 'arguments contain hidden unicode characters' };
|
|
665
|
+
}
|
|
666
|
+
const b64 = hay.match(BASE64_RE);
|
|
667
|
+
if (b64) {
|
|
668
|
+
for (const m of b64) {
|
|
669
|
+
try {
|
|
670
|
+
const decoded = Buffer.from(m, 'base64').toString('utf8');
|
|
671
|
+
if (SUSPICIOUS_DECODED.test(decoded)) {
|
|
672
|
+
return { detected: true, severity: 'high', reason: 'arguments contain base64 with suspicious decoded content' };
|
|
673
|
+
}
|
|
674
|
+
} catch (_) { /* ignore */ }
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return { detected: false, severity: 'low', reason: 'clean' };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function evaluateCall(req) {
|
|
681
|
+
const start = Date.now();
|
|
682
|
+
const toolName = req.tool || (req.params && req.params.name) || '';
|
|
683
|
+
const args = req.arguments || (req.params && req.params.arguments) || {};
|
|
684
|
+
const scan = scanCall(toolName, args);
|
|
685
|
+
const score = {
|
|
686
|
+
tool: toolName,
|
|
687
|
+
call_scan: scan,
|
|
688
|
+
reasons: [],
|
|
689
|
+
decision: 'allow',
|
|
690
|
+
};
|
|
691
|
+
if (scan.detected) {
|
|
692
|
+
score.reasons.push(`${scan.severity}: ${scan.reason}`);
|
|
693
|
+
if (scan.severity === 'critical') score.decision = 'block';
|
|
694
|
+
else score.decision = 'warn';
|
|
695
|
+
}
|
|
696
|
+
score.durationMs = Date.now() - start;
|
|
697
|
+
return score;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
module.exports = { evaluate, evaluateCall, scanTools, scanCall, scanSkillManifest, verifyPublisher, verifyPublisherAsync, validateTransport, levenshtein };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-proof",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "Unbranded, free MCP (Model Context Protocol) server verifier — detects tool poisoning, typosquats, hidden unicode, base64 payloads, exposed transports, and skill-manifest attacks. Same engine as the VyriAI control plane, no account required.",
|
|
5
5
|
"main": "engine.js",
|
|
6
6
|
"bin": {
|