@vibefoundry/vibe-shield 0.0.0-stage → 1.1.1
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/LICENSE +21 -0
- package/README.md +577 -3
- package/SECURITY.md +110 -0
- package/SKILL.md +124 -0
- package/dist/bin/vibe-shield.d.ts +59 -0
- package/dist/bin/vibe-shield.js +545 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.js +108 -0
- package/dist/src/init/scaffold.d.ts +120 -0
- package/dist/src/init/scaffold.js +556 -0
- package/dist/src/mcp/server.d.ts +127 -0
- package/dist/src/mcp/server.js +479 -0
- package/dist/src/mcp-guard/skill-auditor.d.ts +105 -0
- package/dist/src/mcp-guard/skill-auditor.js +1129 -0
- package/dist/src/reporter/console-reporter.d.ts +58 -0
- package/dist/src/reporter/console-reporter.js +254 -0
- package/dist/src/reporter/formatters.d.ts +38 -0
- package/dist/src/reporter/formatters.js +262 -0
- package/dist/src/reporter/patch-prompt-writer.d.ts +35 -0
- package/dist/src/reporter/patch-prompt-writer.js +238 -0
- package/dist/src/scanner/context.d.ts +51 -0
- package/dist/src/scanner/context.js +568 -0
- package/dist/src/scanner/engine.d.ts +130 -0
- package/dist/src/scanner/engine.js +726 -0
- package/dist/src/scanner/gitignore.d.ts +49 -0
- package/dist/src/scanner/gitignore.js +195 -0
- package/dist/src/scanner/lexicon.d.ts +56 -0
- package/dist/src/scanner/lexicon.js +357 -0
- package/dist/src/scanner/rules/ai-vulnerabilities.d.ts +15 -0
- package/dist/src/scanner/rules/ai-vulnerabilities.js +859 -0
- package/dist/src/scanner/rules/client-exposure.d.ts +18 -0
- package/dist/src/scanner/rules/client-exposure.js +410 -0
- package/dist/src/scanner/rules/database-exposure.d.ts +15 -0
- package/dist/src/scanner/rules/database-exposure.js +626 -0
- package/dist/src/scanner/rules/helpers.d.ts +31 -0
- package/dist/src/scanner/rules/helpers.js +85 -0
- package/dist/src/scanner/rules/index.d.ts +30 -0
- package/dist/src/scanner/rules/index.js +95 -0
- package/dist/src/scanner/rules/secret-entropy.d.ts +17 -0
- package/dist/src/scanner/rules/secret-entropy.js +338 -0
- package/dist/src/scanner/rules/shared.d.ts +100 -0
- package/dist/src/scanner/rules/shared.js +161 -0
- package/dist/src/scanner/rules/unguarded-routes.d.ts +16 -0
- package/dist/src/scanner/rules/unguarded-routes.js +526 -0
- package/dist/src/scanner/text.d.ts +90 -0
- package/dist/src/scanner/text.js +472 -0
- package/dist/src/scanner/types.d.ts +340 -0
- package/dist/src/scanner/types.js +195 -0
- package/dist/src/secrets/patterns.d.ts +63 -0
- package/dist/src/secrets/patterns.js +392 -0
- package/package.json +97 -4
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* vibe-shield :: Model Context Protocol server
|
|
4
|
+
* -------------------------------------------
|
|
5
|
+
* A zero-dependency MCP server over stdio, so Claude Code, Claude Desktop,
|
|
6
|
+
* Cursor and Codex can call the scanner as a tool instead of shelling out and
|
|
7
|
+
* parsing console output.
|
|
8
|
+
*
|
|
9
|
+
* Transport is newline-delimited JSON-RPC 2.0. Requests are handled in arrival
|
|
10
|
+
* order and every response carries the matching `id`; notifications (no `id`)
|
|
11
|
+
* are never answered, per the JSON-RPC spec.
|
|
12
|
+
*
|
|
13
|
+
* Three tools are exposed:
|
|
14
|
+
* - `vibe_shield_scan` run the 60-rule scanner over a project
|
|
15
|
+
* - `vibe_shield_audit_skills` audit the agent surface for poisoning
|
|
16
|
+
* - `vibe_shield_explain_rule` full CWE + remediation detail for one rule
|
|
17
|
+
*
|
|
18
|
+
* Deliberate design constraints:
|
|
19
|
+
* - stdout is protocol-only. Diagnostics go to stderr, because a stray
|
|
20
|
+
* `console.log` corrupts the stream and breaks the client session.
|
|
21
|
+
* - No request is ever executed against a path outside the client's `roots`
|
|
22
|
+
* or the server's own cwd tree without an absolute path being explicit.
|
|
23
|
+
* - Every tool returns `isError` on failure rather than throwing, so a bad
|
|
24
|
+
* argument degrades one call instead of killing the session.
|
|
25
|
+
*/
|
|
26
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
29
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
30
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
31
|
+
}
|
|
32
|
+
Object.defineProperty(o, k2, desc);
|
|
33
|
+
}) : (function(o, m, k, k2) {
|
|
34
|
+
if (k2 === undefined) k2 = k;
|
|
35
|
+
o[k2] = m[k];
|
|
36
|
+
}));
|
|
37
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
38
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
39
|
+
}) : function(o, v) {
|
|
40
|
+
o["default"] = v;
|
|
41
|
+
});
|
|
42
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
43
|
+
var ownKeys = function(o) {
|
|
44
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
45
|
+
var ar = [];
|
|
46
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
47
|
+
return ar;
|
|
48
|
+
};
|
|
49
|
+
return ownKeys(o);
|
|
50
|
+
};
|
|
51
|
+
return function (mod) {
|
|
52
|
+
if (mod && mod.__esModule) return mod;
|
|
53
|
+
var result = {};
|
|
54
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
55
|
+
__setModuleDefault(result, mod);
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
})();
|
|
59
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
60
|
+
exports.MCP_TOOL_NAMES = exports.startMcpServer = exports.MCP_TOOLS = exports.SERVER_NAME = exports.MCP_PROTOCOL_VERSION = exports.RpcError = void 0;
|
|
61
|
+
exports.callTool = callTool;
|
|
62
|
+
exports.handleMessage = handleMessage;
|
|
63
|
+
exports.serveMcp = serveMcp;
|
|
64
|
+
const path = __importStar(require("node:path"));
|
|
65
|
+
const skill_auditor_1 = require("../mcp-guard/skill-auditor");
|
|
66
|
+
const engine_1 = require("../scanner/engine");
|
|
67
|
+
const rules_1 = require("../scanner/rules");
|
|
68
|
+
const types_1 = require("../scanner/types");
|
|
69
|
+
/** JSON-RPC 2.0 error codes used by this server. */
|
|
70
|
+
exports.RpcError = {
|
|
71
|
+
parseError: -32700,
|
|
72
|
+
invalidRequest: -32600,
|
|
73
|
+
methodNotFound: -32601,
|
|
74
|
+
invalidParams: -32602,
|
|
75
|
+
internalError: -32603,
|
|
76
|
+
};
|
|
77
|
+
exports.MCP_PROTOCOL_VERSION = '2025-06-18';
|
|
78
|
+
exports.SERVER_NAME = 'vibe-shield';
|
|
79
|
+
const TOOL_SCAN = 'vibe_shield_scan';
|
|
80
|
+
const TOOL_AUDIT = 'vibe_shield_audit_skills';
|
|
81
|
+
const TOOL_EXPLAIN = 'vibe_shield_explain_rule';
|
|
82
|
+
/**
|
|
83
|
+
* Tool catalogue. Descriptions are written for a model deciding *whether* to
|
|
84
|
+
* call, not for a human reading docs — they say when to reach for the tool.
|
|
85
|
+
*/
|
|
86
|
+
exports.MCP_TOOLS = [
|
|
87
|
+
{
|
|
88
|
+
name: TOOL_SCAN,
|
|
89
|
+
title: 'Scan a project for AI-introduced security bugs',
|
|
90
|
+
description: 'Runs the 60-rule vibe-shield scanner over a project directory or a set of paths and returns the grade, score and every finding with its exact file, line, CWE and remediation steps. Call this after writing or modifying application code — API routes, server actions, database migrations, auth logic, environment variables, LLM calls or webhook handlers — and before reporting a task as complete. Also call it when the user asks for a security review or audit.',
|
|
91
|
+
inputSchema: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
properties: {
|
|
94
|
+
target: {
|
|
95
|
+
type: 'string',
|
|
96
|
+
description: 'Directory or file to scan. Relative paths resolve against the server working directory. Default: the working directory.',
|
|
97
|
+
},
|
|
98
|
+
paths: {
|
|
99
|
+
type: 'array',
|
|
100
|
+
items: { type: 'string' },
|
|
101
|
+
description: 'Explicit files or directories to scan, instead of the whole project.',
|
|
102
|
+
},
|
|
103
|
+
severity: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
enum: ['critical', 'high', 'medium', 'low', 'info'],
|
|
106
|
+
description: 'Only return findings at or above this severity.',
|
|
107
|
+
},
|
|
108
|
+
rules: {
|
|
109
|
+
type: 'array',
|
|
110
|
+
items: { type: 'string' },
|
|
111
|
+
description: 'Restrict to these rule ids or prefixes, e.g. ["VSB-CLIENT-001"] or ["VSB-DB-*"].',
|
|
112
|
+
},
|
|
113
|
+
includeTests: { type: 'boolean', description: 'Include test, spec and fixture files.' },
|
|
114
|
+
includeDocs: { type: 'boolean', description: 'Include Markdown and documentation files.' },
|
|
115
|
+
limit: {
|
|
116
|
+
type: 'integer',
|
|
117
|
+
description: 'Maximum findings to return in the text summary. Default 25, 0 for all.',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: TOOL_AUDIT,
|
|
125
|
+
title: 'Audit agent skills and MCP configuration for poisoning',
|
|
126
|
+
description: 'Audits .claude/skills, .cursor rules, .cursorrules, AGENTS.md, CLAUDE.md and MCP config files (mcp.json, .mcp.json) for agent-poisoning: hidden Unicode instructions, credential exfiltration commands, reads of .env or ~/.ssh, unpinned MCP servers, literal credentials and safety-override language. Call this whenever you add or modify an agent instruction file, a skill, a Cursor rule or an MCP server config.',
|
|
127
|
+
inputSchema: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
properties: {
|
|
130
|
+
target: {
|
|
131
|
+
type: 'string',
|
|
132
|
+
description: 'Project root to audit. Default: the server working directory.',
|
|
133
|
+
},
|
|
134
|
+
paths: {
|
|
135
|
+
type: 'array',
|
|
136
|
+
items: { type: 'string' },
|
|
137
|
+
description: 'Specific agent files or directories to audit.',
|
|
138
|
+
},
|
|
139
|
+
limit: { type: 'integer', description: 'Maximum findings to return. Default 25.' },
|
|
140
|
+
},
|
|
141
|
+
additionalProperties: false,
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: TOOL_EXPLAIN,
|
|
146
|
+
title: 'Explain a detection rule',
|
|
147
|
+
description: 'Returns the full documentation for one rule id or slug: severity, confidence, CWE, why it is exploitable, the vulnerable code shape, the fixed code shape, ordered remediation steps and references. Use this when you need to understand a finding before fixing it, or when the user asks what a rule means.',
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
required: ['ruleId'],
|
|
151
|
+
properties: {
|
|
152
|
+
ruleId: {
|
|
153
|
+
type: 'string',
|
|
154
|
+
description: 'Rule id or slug, e.g. "VSB-CLIENT-001" or "public-env-secret-exposure".',
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
additionalProperties: false,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
/* -------------------------------------------------------------------------- */
|
|
162
|
+
/* Argument helpers */
|
|
163
|
+
/* -------------------------------------------------------------------------- */
|
|
164
|
+
const asString = (value) => typeof value === 'string' && value.trim().length ? value.trim() : undefined;
|
|
165
|
+
const asBoolean = (value) => (typeof value === 'boolean' ? value : undefined);
|
|
166
|
+
const asStringArray = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : undefined;
|
|
167
|
+
const asLimit = (value, fallback) => {
|
|
168
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
|
|
169
|
+
return fallback;
|
|
170
|
+
return Math.floor(value);
|
|
171
|
+
};
|
|
172
|
+
/* -------------------------------------------------------------------------- */
|
|
173
|
+
/* Result rendering */
|
|
174
|
+
/* -------------------------------------------------------------------------- */
|
|
175
|
+
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'];
|
|
176
|
+
const atOrAbove = (severity, minimum) => SEVERITY_ORDER.indexOf(severity) <= SEVERITY_ORDER.indexOf(minimum);
|
|
177
|
+
/** Compact, model-readable rendering of one finding. */
|
|
178
|
+
const renderFinding = (finding) => {
|
|
179
|
+
const lines = [
|
|
180
|
+
`${finding.severity.toUpperCase()} ${finding.filePath}:${finding.line}:${finding.column} ${finding.ruleId} — ${finding.title}`,
|
|
181
|
+
` ${finding.message}`,
|
|
182
|
+
` CWE: ${finding.cwe.id} ${finding.cwe.name}`,
|
|
183
|
+
];
|
|
184
|
+
if (finding.snippet)
|
|
185
|
+
lines.push(` > ${finding.snippet}`);
|
|
186
|
+
return lines.join('\n');
|
|
187
|
+
};
|
|
188
|
+
/** The scorecard header shared by `scan` and `audit-skills` output. */
|
|
189
|
+
const renderVerdict = (result, command) => {
|
|
190
|
+
const counts = result.score.counts;
|
|
191
|
+
const parts = SEVERITY_ORDER.filter((severity) => counts[severity] > 0).map((severity) => `${counts[severity]} ${severity}`);
|
|
192
|
+
const verb = command === 'scan' ? 'Scanned' : 'Audited';
|
|
193
|
+
return [
|
|
194
|
+
`vibe-shield ${result.version} — ${verb} ${result.stats.filesScanned} file(s) in ${result.stats.durationMs}ms`,
|
|
195
|
+
`Grade ${result.score.grade} — ${result.score.score}/100${parts.length ? ` · ${parts.join(', ')}` : ' · no findings'}`,
|
|
196
|
+
].join('\n');
|
|
197
|
+
};
|
|
198
|
+
/** Serialise a scan for the model: verdict, findings, then machine fields. */
|
|
199
|
+
const toToolResult = (result, options) => {
|
|
200
|
+
const filtered = options.minimumSeverity
|
|
201
|
+
? result.findings.filter((finding) => atOrAbove(finding.severity, options.minimumSeverity))
|
|
202
|
+
: result.findings;
|
|
203
|
+
const shown = options.limit > 0 ? filtered.slice(0, options.limit) : filtered;
|
|
204
|
+
const sections = [renderVerdict(result, options.command)];
|
|
205
|
+
if (result.stats.filesScanned === 0) {
|
|
206
|
+
// Silence is not a pass — say so rather than returning an empty, green result.
|
|
207
|
+
sections.push('', 'No files were scanned. Check `target`, and whether an ignore file excludes it.');
|
|
208
|
+
}
|
|
209
|
+
else if (filtered.length === 0) {
|
|
210
|
+
sections.push('', 'No findings at or above the requested severity.');
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
sections.push('', 'Findings (highest severity first):', '');
|
|
214
|
+
for (const finding of shown)
|
|
215
|
+
sections.push(renderFinding(finding), '');
|
|
216
|
+
if (filtered.length > shown.length) {
|
|
217
|
+
sections.push(`… and ${filtered.length - shown.length} more. Raise \`limit\` or filter by \`rules\`.`);
|
|
218
|
+
}
|
|
219
|
+
sections.push('Fix the root cause: do not delete the auth check, disable RLS or suppress the finding to make it disappear.');
|
|
220
|
+
}
|
|
221
|
+
if (result.notices.length)
|
|
222
|
+
sections.push('', ...result.notices.map((notice) => `note: ${notice}`));
|
|
223
|
+
return {
|
|
224
|
+
content: [{ type: 'text', text: sections.join('\n').trimEnd() }],
|
|
225
|
+
structuredContent: {
|
|
226
|
+
score: result.score.score,
|
|
227
|
+
grade: result.score.grade,
|
|
228
|
+
counts: result.score.counts,
|
|
229
|
+
total: filtered.length,
|
|
230
|
+
filesScanned: result.stats.filesScanned,
|
|
231
|
+
durationMs: result.stats.durationMs,
|
|
232
|
+
rootDir: result.rootDir,
|
|
233
|
+
findings: shown.map((finding) => ({
|
|
234
|
+
ruleId: finding.ruleId,
|
|
235
|
+
slug: finding.slug,
|
|
236
|
+
severity: finding.severity,
|
|
237
|
+
confidence: finding.confidence,
|
|
238
|
+
category: finding.category,
|
|
239
|
+
title: finding.title,
|
|
240
|
+
message: finding.message,
|
|
241
|
+
filePath: finding.filePath,
|
|
242
|
+
line: finding.line,
|
|
243
|
+
column: finding.column,
|
|
244
|
+
snippet: finding.snippet,
|
|
245
|
+
cwe: finding.cwe,
|
|
246
|
+
remediation: finding.remediation,
|
|
247
|
+
secureExample: finding.secureExample,
|
|
248
|
+
references: finding.references,
|
|
249
|
+
})),
|
|
250
|
+
notices: result.notices,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
};
|
|
254
|
+
/** Full rule documentation as text + structured content. */
|
|
255
|
+
const explainRuleResult = (rule) => {
|
|
256
|
+
const text = [
|
|
257
|
+
`${rule.id} — ${rule.title}`,
|
|
258
|
+
`${rule.category} · ${rule.severity} severity · ${rule.confidence} confidence · ${rule.cwe.id} ${rule.cwe.name}`,
|
|
259
|
+
'',
|
|
260
|
+
rule.description,
|
|
261
|
+
'',
|
|
262
|
+
'Vulnerable',
|
|
263
|
+
rule.insecureExample,
|
|
264
|
+
'',
|
|
265
|
+
'Fixed',
|
|
266
|
+
rule.secureExample,
|
|
267
|
+
'',
|
|
268
|
+
'Remediation',
|
|
269
|
+
...rule.remediation.map((step, index) => `${index + 1}. ${step}`),
|
|
270
|
+
'',
|
|
271
|
+
'References',
|
|
272
|
+
...rule.references.map((reference) => `- ${reference}`),
|
|
273
|
+
].join('\n');
|
|
274
|
+
return {
|
|
275
|
+
content: [{ type: 'text', text }],
|
|
276
|
+
structuredContent: {
|
|
277
|
+
id: rule.id,
|
|
278
|
+
slug: rule.slug,
|
|
279
|
+
title: rule.title,
|
|
280
|
+
category: rule.category,
|
|
281
|
+
severity: rule.severity,
|
|
282
|
+
confidence: rule.confidence,
|
|
283
|
+
cwe: rule.cwe,
|
|
284
|
+
description: rule.description,
|
|
285
|
+
insecureExample: rule.insecureExample,
|
|
286
|
+
secureExample: rule.secureExample,
|
|
287
|
+
remediation: rule.remediation,
|
|
288
|
+
references: rule.references,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
const errorResult = (message) => ({
|
|
293
|
+
content: [{ type: 'text', text: message }],
|
|
294
|
+
isError: true,
|
|
295
|
+
});
|
|
296
|
+
/* -------------------------------------------------------------------------- */
|
|
297
|
+
/* Tool dispatch */
|
|
298
|
+
/* -------------------------------------------------------------------------- */
|
|
299
|
+
/** Resolve a client-supplied target against the server's working directory. */
|
|
300
|
+
const resolveTarget = (target, cwd) => target ? path.resolve(cwd, target) : cwd;
|
|
301
|
+
/** Execute one tool by name. Never throws: failures become `isError` results. */
|
|
302
|
+
async function callTool(name, args, options = {}) {
|
|
303
|
+
const cwd = options.cwd ?? process.cwd();
|
|
304
|
+
try {
|
|
305
|
+
if (name === TOOL_SCAN) {
|
|
306
|
+
const minimumSeverity = asString(args.severity);
|
|
307
|
+
if (minimumSeverity && !(0, types_1.isSeverity)(minimumSeverity)) {
|
|
308
|
+
return errorResult(`Unknown severity "${minimumSeverity}". Use one of: ${SEVERITY_ORDER.join(', ')}.`);
|
|
309
|
+
}
|
|
310
|
+
const selection = asStringArray(args.rules);
|
|
311
|
+
const result = await (0, engine_1.scanProject)({
|
|
312
|
+
rootDir: resolveTarget(asString(args.target), cwd),
|
|
313
|
+
paths: asStringArray(args.paths),
|
|
314
|
+
includeTests: asBoolean(args.includeTests),
|
|
315
|
+
includeDocs: asBoolean(args.includeDocs),
|
|
316
|
+
rules: selection?.length ? selection.flatMap((selector) => rulesFor(selector)) : undefined,
|
|
317
|
+
useGitIndex: false,
|
|
318
|
+
});
|
|
319
|
+
return toToolResult(result, {
|
|
320
|
+
limit: asLimit(args.limit, 25),
|
|
321
|
+
minimumSeverity: minimumSeverity,
|
|
322
|
+
command: 'scan',
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (name === TOOL_AUDIT) {
|
|
326
|
+
const result = await (0, skill_auditor_1.auditSkills)(resolveTarget(asString(args.target), cwd), {
|
|
327
|
+
paths: asStringArray(args.paths),
|
|
328
|
+
});
|
|
329
|
+
return toToolResult(result, { limit: asLimit(args.limit, 25), command: 'audit-skills' });
|
|
330
|
+
}
|
|
331
|
+
if (name === TOOL_EXPLAIN) {
|
|
332
|
+
const ruleId = asString(args.ruleId);
|
|
333
|
+
if (!ruleId)
|
|
334
|
+
return errorResult('`ruleId` is required, e.g. "VSB-CLIENT-001".');
|
|
335
|
+
const rule = (0, rules_1.getRule)(ruleId);
|
|
336
|
+
if (!rule)
|
|
337
|
+
return errorResult(`Unknown rule "${ruleId}". Call tools/list or \`npx -y @vibefoundry/vibe-shield rules\` for the catalogue.`);
|
|
338
|
+
return explainRuleResult(rule);
|
|
339
|
+
}
|
|
340
|
+
return errorResult(`Unknown tool "${name}".`);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
return errorResult(`vibe-shield failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
;
|
|
347
|
+
/** Expand a rule selector (`VSB-DB-*`, exact id or slug) against the catalogue. */
|
|
348
|
+
const rulesFor = (selector) => {
|
|
349
|
+
const exact = (0, rules_1.getRule)(selector);
|
|
350
|
+
if (exact)
|
|
351
|
+
return [exact];
|
|
352
|
+
const normalized = selector.trim().toUpperCase();
|
|
353
|
+
if (normalized.endsWith('*')) {
|
|
354
|
+
const prefix = normalized.slice(0, -1);
|
|
355
|
+
return rules_1.ALL_RULES.filter((rule) => rule.id.startsWith(prefix));
|
|
356
|
+
}
|
|
357
|
+
return [];
|
|
358
|
+
};
|
|
359
|
+
/* -------------------------------------------------------------------------- */
|
|
360
|
+
/* JSON-RPC layer */
|
|
361
|
+
/* -------------------------------------------------------------------------- */
|
|
362
|
+
const isRequest = (value) => {
|
|
363
|
+
if (typeof value !== 'object' || value === null)
|
|
364
|
+
return false;
|
|
365
|
+
const candidate = value;
|
|
366
|
+
return candidate.jsonrpc === '2.0' && typeof candidate.method === 'string';
|
|
367
|
+
};
|
|
368
|
+
/** True when the message is a notification and must not receive a reply. */
|
|
369
|
+
const isNotification = (value) => !('id' in value) || value.id === undefined;
|
|
370
|
+
/**
|
|
371
|
+
* Handle one decoded JSON-RPC message.
|
|
372
|
+
* Returns `null` for notifications, which the spec forbids answering.
|
|
373
|
+
*/
|
|
374
|
+
async function handleMessage(message, options = {}) {
|
|
375
|
+
if (!isRequest(message)) {
|
|
376
|
+
// A malformed message still gets a parse/invalid-request error with a null id.
|
|
377
|
+
return {
|
|
378
|
+
jsonrpc: '2.0',
|
|
379
|
+
id: null,
|
|
380
|
+
error: { code: exports.RpcError.invalidRequest, message: 'Invalid JSON-RPC 2.0 request.' },
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const id = message.id ?? null;
|
|
384
|
+
const notification = isNotification(message);
|
|
385
|
+
const params = (message.params ?? {});
|
|
386
|
+
const reply = (result) => notification ? null : { jsonrpc: '2.0', id, result };
|
|
387
|
+
const fail = (code, errorMessage) => notification ? null : { jsonrpc: '2.0', id, error: { code, message: errorMessage } };
|
|
388
|
+
switch (message.method) {
|
|
389
|
+
case 'initialize':
|
|
390
|
+
return reply({
|
|
391
|
+
protocolVersion: exports.MCP_PROTOCOL_VERSION,
|
|
392
|
+
capabilities: { tools: { listChanged: false } },
|
|
393
|
+
serverInfo: {
|
|
394
|
+
name: exports.SERVER_NAME,
|
|
395
|
+
title: 'vibe-shield',
|
|
396
|
+
version: options.version ?? '1.1.0',
|
|
397
|
+
},
|
|
398
|
+
instructions: 'vibe-shield finds security bugs that AI coding agents ship: leaked secrets, unguarded API routes, missing Postgres RLS, SQL injection, prompt injection and poisoned agent skills. Call vibe_shield_scan after writing application code and before reporting a task as done, and vibe_shield_audit_skills after touching any agent instruction or MCP config file.',
|
|
399
|
+
});
|
|
400
|
+
// Notifications the client sends that need no reply.
|
|
401
|
+
case 'notifications/initialized':
|
|
402
|
+
case 'notifications/cancelled':
|
|
403
|
+
case 'initialized':
|
|
404
|
+
return null;
|
|
405
|
+
case 'ping':
|
|
406
|
+
return reply({});
|
|
407
|
+
case 'tools/list':
|
|
408
|
+
return reply({ tools: exports.MCP_TOOLS });
|
|
409
|
+
case 'tools/call': {
|
|
410
|
+
const name = asString(params.name);
|
|
411
|
+
if (!name)
|
|
412
|
+
return fail(exports.RpcError.invalidParams, '`name` is required.');
|
|
413
|
+
const args = (params.arguments ?? {});
|
|
414
|
+
const result = await callTool(name, args, options);
|
|
415
|
+
return reply(result);
|
|
416
|
+
}
|
|
417
|
+
case 'resources/list':
|
|
418
|
+
return reply({ resources: [] });
|
|
419
|
+
case 'prompts/list':
|
|
420
|
+
return reply({ prompts: [] });
|
|
421
|
+
default:
|
|
422
|
+
return fail(exports.RpcError.methodNotFound, `Method not found: ${message.method}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Serve MCP over newline-delimited JSON-RPC until stdin closes.
|
|
427
|
+
*
|
|
428
|
+
* Messages are processed sequentially so responses preserve request order and a
|
|
429
|
+
* slow scan cannot interleave with a later `tools/call`.
|
|
430
|
+
*/
|
|
431
|
+
function serveMcp(io, options = {}) {
|
|
432
|
+
let buffer = '';
|
|
433
|
+
let handled = 0;
|
|
434
|
+
let queue = Promise.resolve();
|
|
435
|
+
return new Promise((resolve) => {
|
|
436
|
+
const finish = () => {
|
|
437
|
+
void queue.then(() => {
|
|
438
|
+
options.onClose?.(handled);
|
|
439
|
+
resolve(handled);
|
|
440
|
+
});
|
|
441
|
+
};
|
|
442
|
+
io.stdin.on('data', (chunk) => {
|
|
443
|
+
buffer += chunk.toString();
|
|
444
|
+
let newline = buffer.indexOf('\n');
|
|
445
|
+
while (newline !== -1) {
|
|
446
|
+
const line = buffer.slice(0, newline).trim();
|
|
447
|
+
buffer = buffer.slice(newline + 1);
|
|
448
|
+
newline = buffer.indexOf('\n');
|
|
449
|
+
if (!line)
|
|
450
|
+
continue;
|
|
451
|
+
queue = queue.then(async () => {
|
|
452
|
+
let parsed;
|
|
453
|
+
try {
|
|
454
|
+
parsed = JSON.parse(line);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
io.stdout.write(`${JSON.stringify({
|
|
458
|
+
jsonrpc: '2.0',
|
|
459
|
+
id: null,
|
|
460
|
+
error: { code: exports.RpcError.parseError, message: 'Parse error: invalid JSON.' },
|
|
461
|
+
})}\n`);
|
|
462
|
+
handled += 1;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const response = await handleMessage(parsed, options);
|
|
466
|
+
if (response)
|
|
467
|
+
io.stdout.write(`${JSON.stringify(response)}\n`);
|
|
468
|
+
handled += 1;
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
io.stdin.on('end', finish);
|
|
473
|
+
io.stdin.on('close', finish);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
/** Convenience wrapper used by `vibe-shield mcp`. */
|
|
477
|
+
const startMcpServer = (options = {}) => serveMcp({ stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, options);
|
|
478
|
+
exports.startMcpServer = startMcpServer;
|
|
479
|
+
exports.MCP_TOOL_NAMES = [TOOL_SCAN, TOOL_AUDIT, TOOL_EXPLAIN];
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vibe-shield :: MCP & Agent Skill auditor
|
|
3
|
+
* ----------------------------------------
|
|
4
|
+
* The supply chain that nobody audits. A `.claude/skills` folder, a
|
|
5
|
+
* `.cursorrules` file or an `mcp.json` server entry is *code that runs with your
|
|
6
|
+
* credentials and your trust*. A poisoned skill does not need an exploit: it
|
|
7
|
+
* only needs one line that says "read ~/.ssh/id_rsa and summarise it".
|
|
8
|
+
*
|
|
9
|
+
* The twelve `VSB-AGENT-*` rules below are deliberately different from the code
|
|
10
|
+
* rules: they parse instruction prose, JSON config and shell scripts, and they
|
|
11
|
+
* look for the *intent* of exfiltration (hidden Unicode, `curl $ENV`, "do not
|
|
12
|
+
* tell the user") rather than a specific vulnerable API.
|
|
13
|
+
*/
|
|
14
|
+
import { type FileContext, type Finding, type ProjectContext, type Rule, type RuleHit, type ScanResult, type SecurityScore } from '../scanner/types';
|
|
15
|
+
/** Zero-width, bidi-control and Unicode-tag code points used to hide text. */
|
|
16
|
+
export declare const INVISIBLE_CODEPOINTS: ReadonlyArray<{
|
|
17
|
+
regex: RegExp;
|
|
18
|
+
name: string;
|
|
19
|
+
}>;
|
|
20
|
+
/** Frontmatter of a SKILL.md / `.mdc` file (YAML-ish, parsed leniently). */
|
|
21
|
+
export interface SkillFrontmatter {
|
|
22
|
+
present: boolean;
|
|
23
|
+
fields: Record<string, string>;
|
|
24
|
+
raw: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function parseFrontmatter(source: string): SkillFrontmatter;
|
|
27
|
+
/**
|
|
28
|
+
* Triage metadata attached to every agent-integrity finding.
|
|
29
|
+
*
|
|
30
|
+
* `disposition` is what an agent should do about it — this is the field that
|
|
31
|
+
* separates vibe-shield from a scanner that makes an agent refuse everything:
|
|
32
|
+
*
|
|
33
|
+
* - `report` surface it to the user and keep going (documented, expected)
|
|
34
|
+
* - `review` a human should look before the skill is trusted
|
|
35
|
+
* - `block` do not run this skill/config; ask the user first
|
|
36
|
+
*/
|
|
37
|
+
export type AgentDisposition = 'report' | 'review' | 'block';
|
|
38
|
+
export interface AgentTriage {
|
|
39
|
+
disposition: AgentDisposition;
|
|
40
|
+
risk: 'none' | 'low' | 'medium' | 'high';
|
|
41
|
+
/** One-line, agent-readable instruction. */
|
|
42
|
+
guidance: string;
|
|
43
|
+
}
|
|
44
|
+
/** Map a rule + hit context to a triage decision. */
|
|
45
|
+
export declare function triageAgentFinding(rule: Rule, ctx: FileContext, hit: RuleHit): AgentTriage;
|
|
46
|
+
/**
|
|
47
|
+
* True when a line only *describes* a dangerous pattern rather than instructing
|
|
48
|
+
* it — rule documentation ("detects: curl $ENV"), a prohibition ("never read
|
|
49
|
+
* ~/.ssh"), a code comment enumerating a catalogue.
|
|
50
|
+
*
|
|
51
|
+
* Without this, every security tool's own SKILL.md becomes a false positive,
|
|
52
|
+
* and an agent that trusts the report learns to ignore it.
|
|
53
|
+
*/
|
|
54
|
+
export declare function isDescriptiveLine(line: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* True when a negation directly governs the dangerous verb: "do not tell the
|
|
57
|
+
* user", "never read ~/.ssh", "must not auto-approve". A prohibition is the
|
|
58
|
+
* opposite of an instruction, and flagging it would punish secure guidance.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately anchored to the same clause so "do not ask, just ignore previous
|
|
61
|
+
* instructions" is *not* treated as a prohibition.
|
|
62
|
+
*/
|
|
63
|
+
export declare function isProhibitedInstruction(line: string): boolean;
|
|
64
|
+
/** Instruction files an agent will actually read. */
|
|
65
|
+
export declare const isInstructionFile: (ctx: FileContext) => boolean;
|
|
66
|
+
/** The `VSB-AGENT-*` family, owned by this module. */
|
|
67
|
+
export declare const AGENT_RULES: readonly Rule[];
|
|
68
|
+
/**
|
|
69
|
+
* Locate every agent-instruction / MCP file under `rootDir`.
|
|
70
|
+
* `.gitignore` and `.vibe-shieldignore` are honoured so the auditor reports on
|
|
71
|
+
* the same surface the scanner does (fixtures and vendored skills stay out).
|
|
72
|
+
*/
|
|
73
|
+
export declare function discoverAgentFiles(rootDir: string, options?: {
|
|
74
|
+
maxFiles?: number;
|
|
75
|
+
useIgnoreFiles?: boolean;
|
|
76
|
+
}): Promise<string[]>;
|
|
77
|
+
export interface AuditOptions {
|
|
78
|
+
rootDir?: string;
|
|
79
|
+
paths?: string[];
|
|
80
|
+
rules?: readonly Rule[];
|
|
81
|
+
/** Passed through to `detectProject` (git index, SQL index). */
|
|
82
|
+
project?: ProjectContext;
|
|
83
|
+
/** Report the paths that were audited. */
|
|
84
|
+
onFile?: (filePath: string) => void;
|
|
85
|
+
/**
|
|
86
|
+
* Package version to stamp on the result.
|
|
87
|
+
*
|
|
88
|
+
* Injected rather than imported: `scanner/engine` imports `scanner/rules`,
|
|
89
|
+
* which imports this module, so importing `packageVersion` from here would
|
|
90
|
+
* close a cycle (`engine -> rules -> skill-auditor -> engine`) and leave
|
|
91
|
+
* `AGENT_RULES` undefined at import time. Callers that live above the cycle
|
|
92
|
+
* resolve the version and pass it down.
|
|
93
|
+
*/
|
|
94
|
+
version?: string;
|
|
95
|
+
}
|
|
96
|
+
/** Score a set of findings with the shared grading model. */
|
|
97
|
+
export declare function scoreFindings(findings: readonly Finding[]): SecurityScore;
|
|
98
|
+
/**
|
|
99
|
+
* Audit every agent-instruction / MCP / skill-script file below `rootDir`.
|
|
100
|
+
* Returns a `ScanResult` shaped exactly like `scanProject()` so the reporters
|
|
101
|
+
* (console scorecard, SARIF, patch prompt) work unchanged.
|
|
102
|
+
*/
|
|
103
|
+
export declare function auditSkills(rootDir: string, options?: AuditOptions): Promise<ScanResult>;
|
|
104
|
+
/** Re-exported for tests that want to build a context without touching disk. */
|
|
105
|
+
export declare const buildAuditContext: (absolutePath: string, rootDir: string, content: string, project?: ProjectContext) => FileContext;
|