mcp-proof 0.3.4 → 0.3.6

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.
Files changed (2) hide show
  1. package/engine.js +117 -5
  2. package/package.json +1 -1
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
- function isBenignSkillContext(text) {
127
- return SKILL_BENIGN_MARKERS.test(text);
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, the
338
- // match is treated as educational rather than an attack directive.
339
- if (skillContext && isBenignSkillContext(corpus)) {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-proof",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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": {