shellward 0.5.16 → 0.6.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/README.md +95 -30
- package/dist/auto-check.d.ts +1 -0
- package/dist/auto-check.js +12 -1
- package/dist/commands/index.d.ts +2 -1
- package/dist/commands/index.js +7 -0
- package/dist/commands/scan-mcp.d.ts +2 -0
- package/dist/commands/scan-mcp.js +105 -0
- package/dist/core/engine.d.ts +35 -0
- package/dist/core/engine.js +255 -33
- package/dist/index.d.ts +4 -2
- package/dist/index.js +18 -3
- package/dist/mcp-baseline.d.ts +27 -0
- package/dist/mcp-baseline.js +73 -0
- package/dist/mcp-client.d.ts +29 -0
- package/dist/mcp-client.js +264 -0
- package/dist/mcp-server.js +64 -9
- package/dist/rules/dangerous-commands.js +6 -2
- package/dist/rules/injection-en.js +27 -2
- package/dist/rules/injection-zh.js +27 -4
- package/dist/rules/sensitive-patterns.d.ts +13 -1
- package/dist/rules/sensitive-patterns.js +32 -5
- package/dist/rules/tool-poisoning.d.ts +8 -0
- package/dist/rules/tool-poisoning.js +96 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.js +3 -1
- package/package.json +4 -2
- package/server.json +2 -2
- package/src/auto-check.ts +11 -1
- package/src/commands/index.ts +9 -1
- package/src/commands/scan-mcp.ts +118 -0
- package/src/core/engine.ts +273 -34
- package/src/index.ts +25 -5
- package/src/mcp-baseline.ts +97 -0
- package/src/mcp-client.ts +268 -0
- package/src/mcp-server.ts +71 -9
- package/src/rules/dangerous-commands.ts +6 -2
- package/src/rules/injection-en.ts +27 -2
- package/src/rules/injection-zh.ts +27 -4
- package/src/rules/sensitive-patterns.ts +37 -5
- package/src/rules/tool-poisoning.ts +108 -0
- package/src/types.ts +38 -1
package/dist/core/engine.js
CHANGED
|
@@ -8,10 +8,11 @@ import { randomBytes } from 'crypto';
|
|
|
8
8
|
import { resolve } from 'path';
|
|
9
9
|
import { homedir } from 'os';
|
|
10
10
|
import { DANGEROUS_COMMANDS, splitCommands } from '../rules/dangerous-commands.js';
|
|
11
|
+
import { TOOL_POISONING_RULES } from '../rules/tool-poisoning.js';
|
|
11
12
|
import { PROTECTED_PATHS } from '../rules/protected-paths.js';
|
|
12
13
|
import { INJECTION_RULES_ZH } from '../rules/injection-zh.js';
|
|
13
14
|
import { INJECTION_RULES_EN } from '../rules/injection-en.js';
|
|
14
|
-
import { redactSensitive } from '../rules/sensitive-patterns.js';
|
|
15
|
+
import { redactSensitive, compileSensitivePatterns } from '../rules/sensitive-patterns.js';
|
|
15
16
|
import { AuditLog } from '../audit-log.js';
|
|
16
17
|
import { resolveLocale, DEFAULT_CONFIG } from '../types.js';
|
|
17
18
|
// ===== Constants =====
|
|
@@ -27,6 +28,7 @@ const EXEC_TOOLS = new Set([
|
|
|
27
28
|
]);
|
|
28
29
|
const OUTBOUND_TOOLS = new Set([
|
|
29
30
|
'send_email', 'send_message', 'post_tweet', 'message', 'sessions_send',
|
|
31
|
+
'http_post', 'curl_post',
|
|
30
32
|
]);
|
|
31
33
|
const DUAL_USE_TOOLS = new Set([
|
|
32
34
|
'web_fetch', 'http_request',
|
|
@@ -57,6 +59,12 @@ const HIDDEN_CHAR_RANGES = [
|
|
|
57
59
|
[0xFEFF, 0xFEFF, 'BOM/Zero-width no-break'],
|
|
58
60
|
[0x00AD, 0x00AD, 'Soft hyphen'],
|
|
59
61
|
[0xFFF9, 0xFFFB, 'Interlinear annotation'],
|
|
62
|
+
// Variation selectors — abused to smuggle hidden bytes/instructions
|
|
63
|
+
[0xFE00, 0xFE0F, 'Variation selector'],
|
|
64
|
+
[0xE0100, 0xE01EF, 'Variation selector supplement'],
|
|
65
|
+
// Unicode Tag characters — the primary "invisible prompt injection" vector
|
|
66
|
+
[0xE0001, 0xE0001, 'Language tag'],
|
|
67
|
+
[0xE0020, 0xE007F, 'Tag character'],
|
|
60
68
|
];
|
|
61
69
|
const TEXT_FIELDS = [
|
|
62
70
|
'content', 'body', 'text', 'message', 'query',
|
|
@@ -110,6 +118,14 @@ export class ShellWard {
|
|
|
110
118
|
log;
|
|
111
119
|
_canaryToken;
|
|
112
120
|
compiledRules;
|
|
121
|
+
// Tool policy sets — built-ins merged with config.customRules (allowedTools wins).
|
|
122
|
+
blockedTools;
|
|
123
|
+
allowedTools;
|
|
124
|
+
sensitiveTools;
|
|
125
|
+
outboundTools;
|
|
126
|
+
honeypots;
|
|
127
|
+
customSensitive;
|
|
128
|
+
customDangerous;
|
|
113
129
|
sensitiveReads = new Map();
|
|
114
130
|
TRACKING_WINDOW_MS = 5 * 60 * 1000;
|
|
115
131
|
MAX_TRACKED_READS = 500;
|
|
@@ -118,11 +134,31 @@ export class ShellWard {
|
|
|
118
134
|
this.locale = resolveLocale(this.config);
|
|
119
135
|
this.log = new AuditLog(this.config);
|
|
120
136
|
this._canaryToken = 'SW-' + randomBytes(8).toString('hex');
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
137
|
+
const custom = this.config.customRules || {};
|
|
138
|
+
const lower = (s) => s.toLowerCase();
|
|
139
|
+
this.allowedTools = new Set((custom.allowedTools || []).map(lower));
|
|
140
|
+
this.blockedTools = new Set([...BLOCKED_TOOLS, ...(custom.blockedTools || []).map(lower)]);
|
|
141
|
+
this.sensitiveTools = new Set([...SENSITIVE_TOOLS, ...(custom.sensitiveTools || []).map(lower)]);
|
|
142
|
+
this.outboundTools = new Set([...OUTBOUND_TOOLS, ...(custom.outboundTools || []).map(lower)]);
|
|
143
|
+
// allowedTools always wins — strip them from the block/sensitive sets.
|
|
144
|
+
for (const t of this.allowedTools) {
|
|
145
|
+
this.blockedTools.delete(t);
|
|
146
|
+
this.sensitiveTools.delete(t);
|
|
147
|
+
}
|
|
148
|
+
this.honeypots = [...HONEYPOT_PATTERNS, ...compileRegexList(custom.honeypotPaths || [])];
|
|
149
|
+
this.customSensitive = compileSensitivePatterns(custom.sensitivePatterns || []);
|
|
150
|
+
this.customDangerous = compileDangerousRules(custom.dangerousCommands || []);
|
|
151
|
+
const allRules = [...INJECTION_RULES_ZH, ...INJECTION_RULES_EN, ...(custom.injectionRules || [])];
|
|
152
|
+
this.compiledRules = allRules
|
|
153
|
+
.map(rule => {
|
|
154
|
+
try {
|
|
155
|
+
return { ...rule, compiled: new RegExp(rule.pattern, rule.flags || 'i') };
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
.filter((r) => r !== null);
|
|
126
162
|
}
|
|
127
163
|
// ========== L1: Prompt Guard ==========
|
|
128
164
|
getSecurityPrompt() {
|
|
@@ -137,7 +173,8 @@ export class ShellWard {
|
|
|
137
173
|
}
|
|
138
174
|
// ========== L2: Data Scanner ==========
|
|
139
175
|
scanData(text, toolName) {
|
|
140
|
-
|
|
176
|
+
text = asString(text);
|
|
177
|
+
const [, findings] = redactSensitive(text, this.customSensitive);
|
|
141
178
|
const hasSensitiveData = findings.length > 0;
|
|
142
179
|
const summary = findings.map(f => `${f.name}(${f.count})`).join(', ');
|
|
143
180
|
if (hasSensitiveData) {
|
|
@@ -159,9 +196,12 @@ export class ShellWard {
|
|
|
159
196
|
}
|
|
160
197
|
// ========== L3: Tool & Command Checker ==========
|
|
161
198
|
checkTool(toolName) {
|
|
162
|
-
const toolLower = toolName.toLowerCase();
|
|
199
|
+
const toolLower = asString(toolName).toLowerCase();
|
|
163
200
|
const enforce = this.config.mode === 'enforce';
|
|
164
|
-
|
|
201
|
+
// allowedTools always wins — user-trusted tools bypass policy.
|
|
202
|
+
if (this.allowedTools.has(toolLower))
|
|
203
|
+
return { allowed: true };
|
|
204
|
+
if (this.blockedTools.has(toolLower)) {
|
|
165
205
|
const reason = this.locale === 'zh'
|
|
166
206
|
? `安全策略禁止自动执行: ${toolName}`
|
|
167
207
|
: `Blocked by security policy: ${toolName}`;
|
|
@@ -174,7 +214,7 @@ export class ShellWard {
|
|
|
174
214
|
});
|
|
175
215
|
return { allowed: false, level: 'CRITICAL', reason };
|
|
176
216
|
}
|
|
177
|
-
if (
|
|
217
|
+
if (this.sensitiveTools.has(toolLower)) {
|
|
178
218
|
this.log.write({
|
|
179
219
|
level: 'MEDIUM',
|
|
180
220
|
layer: 'L3',
|
|
@@ -187,10 +227,14 @@ export class ShellWard {
|
|
|
187
227
|
}
|
|
188
228
|
checkCommand(cmd, toolName) {
|
|
189
229
|
const enforce = this.config.mode === 'enforce';
|
|
190
|
-
const parts = splitCommands(cmd);
|
|
230
|
+
const parts = splitCommands(asString(cmd));
|
|
191
231
|
for (const part of parts) {
|
|
192
|
-
|
|
193
|
-
|
|
232
|
+
// Normalize shell-quote obfuscation (e.g. r''m / r""m → rm) before matching.
|
|
233
|
+
// Only empty quote pairs are stripped, so a real quoted arg like
|
|
234
|
+
// echo "rm -rf /" is untouched (no false positive).
|
|
235
|
+
const normalized = normalizeCommand(part);
|
|
236
|
+
for (const rule of [...DANGEROUS_COMMANDS, ...this.customDangerous]) {
|
|
237
|
+
if (rule.pattern.test(part) || rule.pattern.test(normalized)) {
|
|
194
238
|
const desc = this.locale === 'zh' ? rule.description_zh : rule.description_en;
|
|
195
239
|
const reason = this.locale === 'zh'
|
|
196
240
|
? `检测到危险命令: ${truncate(part, 80)}\n原因: ${desc}`
|
|
@@ -210,6 +254,7 @@ export class ShellWard {
|
|
|
210
254
|
return { allowed: true };
|
|
211
255
|
}
|
|
212
256
|
checkPath(path, operation, toolName) {
|
|
257
|
+
path = asString(path);
|
|
213
258
|
const enforce = this.config.mode === 'enforce';
|
|
214
259
|
const normalizedPath = normalizePath(path);
|
|
215
260
|
for (const rule of PROTECTED_PATHS) {
|
|
@@ -233,6 +278,7 @@ export class ShellWard {
|
|
|
233
278
|
}
|
|
234
279
|
// ========== L4: Injection Detection ==========
|
|
235
280
|
checkInjection(text, options) {
|
|
281
|
+
text = asString(text);
|
|
236
282
|
const threshold = options?.threshold ?? this.config.injectionThreshold;
|
|
237
283
|
const enforce = this.config.mode === 'enforce';
|
|
238
284
|
const hiddenChars = detectHiddenChars(text);
|
|
@@ -244,10 +290,13 @@ export class ShellWard {
|
|
|
244
290
|
detail: `Hidden characters detected: ${[...new Set(hiddenChars.map(h => h.name))].join(', ')} (${hiddenChars.length} chars)`,
|
|
245
291
|
});
|
|
246
292
|
}
|
|
293
|
+
// Strip invisible characters before rule matching so an attacker can't break
|
|
294
|
+
// a pattern by interleaving zero-width spaces (e.g. "ignore previous").
|
|
295
|
+
const normText = hiddenChars.length > 0 ? stripInvisible(text) : text;
|
|
247
296
|
let score = 0;
|
|
248
297
|
const matched = [];
|
|
249
298
|
for (const rule of this.compiledRules) {
|
|
250
|
-
if (rule.compiled.test(text)) {
|
|
299
|
+
if (rule.compiled.test(text) || (normText !== text && rule.compiled.test(normText))) {
|
|
251
300
|
score += rule.riskScore;
|
|
252
301
|
matched.push({ id: rule.id, name: rule.name, score: rule.riskScore });
|
|
253
302
|
}
|
|
@@ -267,13 +316,91 @@ export class ShellWard {
|
|
|
267
316
|
return { safe: score < threshold, score, threshold, matched, hiddenChars: hiddenChars.length };
|
|
268
317
|
}
|
|
269
318
|
getInjectionThreshold(toolName) {
|
|
270
|
-
|
|
319
|
+
const lower = toolName?.toLowerCase();
|
|
320
|
+
if (lower && (LOW_RISK_TOOLS.has(lower) || this.allowedTools.has(lower))) {
|
|
271
321
|
return Math.max(this.config.injectionThreshold, 80);
|
|
272
322
|
}
|
|
273
323
|
return this.config.injectionThreshold;
|
|
274
324
|
}
|
|
325
|
+
// ========== L4b: MCP Tool-Poisoning Scanner ==========
|
|
326
|
+
//
|
|
327
|
+
// Inspects an MCP tool *definition* (not user input) for instructions hidden
|
|
328
|
+
// in its description / parameter descriptions — the "tool poisoning" attack.
|
|
329
|
+
// Reuses the injection engine + hidden-char detection and layers on rules
|
|
330
|
+
// tuned for tool-metadata attacks. Pure & side-effect-light: callable from
|
|
331
|
+
// the SDK, the MCP server, or at plugin tool-discovery time.
|
|
332
|
+
scanToolDefinition(tool, options) {
|
|
333
|
+
tool = (tool && typeof tool === 'object') ? tool : { name: 'unknown' };
|
|
334
|
+
const threshold = options?.threshold ?? 40;
|
|
335
|
+
const findings = [];
|
|
336
|
+
let score = 0;
|
|
337
|
+
const description = typeof tool.description === 'string' ? tool.description : '';
|
|
338
|
+
const paramText = collectSchemaText(tool.inputSchema);
|
|
339
|
+
const combined = `${description}\n${paramText}`;
|
|
340
|
+
// 1. Hidden / invisible characters anywhere in the metadata
|
|
341
|
+
const hidden = detectHiddenChars(combined);
|
|
342
|
+
if (hidden.length > 0) {
|
|
343
|
+
const s = hidden.length > 3 ? 35 : 20;
|
|
344
|
+
score += s;
|
|
345
|
+
findings.push({
|
|
346
|
+
id: 'tp_hidden_chars',
|
|
347
|
+
name: `Hidden characters in tool metadata (${[...new Set(hidden.map(h => h.name))].join(', ')})`,
|
|
348
|
+
category: 'concealment',
|
|
349
|
+
score: s,
|
|
350
|
+
source: 'hidden_chars',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
// 2. Tool-poisoning specific rules (description + parameters)
|
|
354
|
+
for (const rule of TOOL_POISONING_RULES) {
|
|
355
|
+
const inDesc = rule.pattern.test(description);
|
|
356
|
+
const inParam = !inDesc && rule.pattern.test(paramText);
|
|
357
|
+
if (inDesc || inParam) {
|
|
358
|
+
score += rule.riskScore;
|
|
359
|
+
findings.push({
|
|
360
|
+
id: rule.id,
|
|
361
|
+
name: rule.name,
|
|
362
|
+
category: rule.category,
|
|
363
|
+
score: rule.riskScore,
|
|
364
|
+
source: inDesc ? 'description' : 'parameter',
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// 3. Generic prompt-injection patterns reused on the description
|
|
369
|
+
for (const rule of this.compiledRules) {
|
|
370
|
+
if (rule.compiled.test(combined)) {
|
|
371
|
+
score += rule.riskScore;
|
|
372
|
+
findings.push({
|
|
373
|
+
id: rule.id,
|
|
374
|
+
name: rule.name,
|
|
375
|
+
category: rule.category,
|
|
376
|
+
score: rule.riskScore,
|
|
377
|
+
source: 'description',
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const safe = score < threshold;
|
|
382
|
+
if (!safe) {
|
|
383
|
+
this.log.write({
|
|
384
|
+
level: score >= 80 ? 'CRITICAL' : 'HIGH',
|
|
385
|
+
layer: 'L4',
|
|
386
|
+
action: this.config.mode === 'enforce' ? 'block' : 'detect',
|
|
387
|
+
detail: this.locale === 'zh'
|
|
388
|
+
? `检测到 MCP 工具投毒: ${tool.name}\n风险评分: ${score}\n命中: ${findings.map(f => f.name).join('; ')}`
|
|
389
|
+
: `MCP tool poisoning detected: ${tool.name}\nRisk score: ${score}\nMatched: ${findings.map(f => f.name).join('; ')}`,
|
|
390
|
+
tool: tool.name,
|
|
391
|
+
pattern: 'tool_poisoning',
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return { toolName: tool.name, safe, score, threshold, findings, hiddenChars: hidden.length };
|
|
395
|
+
}
|
|
396
|
+
/** Scan a list of MCP tool definitions; returns only the unsafe ones. */
|
|
397
|
+
scanToolDefinitions(tools, options) {
|
|
398
|
+
return tools.map(t => this.scanToolDefinition(t, options)).filter(r => !r.safe);
|
|
399
|
+
}
|
|
275
400
|
// ========== L5: Security Gate ==========
|
|
276
401
|
checkAction(action, details) {
|
|
402
|
+
action = asString(action);
|
|
403
|
+
details = asString(details);
|
|
277
404
|
if (action === 'exec' || action === 'shell') {
|
|
278
405
|
return this.checkCommand(details);
|
|
279
406
|
}
|
|
@@ -293,20 +420,14 @@ export class ShellWard {
|
|
|
293
420
|
});
|
|
294
421
|
return { allowed: false, level: 'CRITICAL', reason, ruleId: 'no_payment' };
|
|
295
422
|
}
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
layer: 'L5',
|
|
305
|
-
action: 'block',
|
|
306
|
-
detail: `Gate denied (DLP): ${action}`,
|
|
307
|
-
pattern: 'gate_data_exfil',
|
|
308
|
-
});
|
|
309
|
-
return { allowed: false, level: 'CRITICAL', reason, ruleId: 'gate_data_exfil' };
|
|
423
|
+
// Outbound actions: delegate the DLP decision to the canonical data-flow
|
|
424
|
+
// guard (L7) so the Gate and the Outbound Guard can never diverge. The set
|
|
425
|
+
// of outbound tools (incl. http_post/curl_post + any customRules) lives in
|
|
426
|
+
// one place: this.outboundTools, consulted by checkOutbound.
|
|
427
|
+
if (this.outboundTools.has(action.toLowerCase())) {
|
|
428
|
+
const dlp = this.checkOutbound(action, details ? { body: details } : {});
|
|
429
|
+
if (!dlp.allowed)
|
|
430
|
+
return dlp;
|
|
310
431
|
}
|
|
311
432
|
this.log.write({
|
|
312
433
|
level: 'INFO',
|
|
@@ -318,6 +439,7 @@ export class ShellWard {
|
|
|
318
439
|
}
|
|
319
440
|
// ========== L6: Response Checker ==========
|
|
320
441
|
checkResponse(content) {
|
|
442
|
+
content = asString(content);
|
|
321
443
|
const canaryLeak = this._canaryToken ? content.includes(this._canaryToken) : false;
|
|
322
444
|
if (canaryLeak) {
|
|
323
445
|
this.log.write({
|
|
@@ -330,7 +452,7 @@ export class ShellWard {
|
|
|
330
452
|
pattern: 'canary_leak',
|
|
331
453
|
});
|
|
332
454
|
}
|
|
333
|
-
const [, findings] = redactSensitive(content);
|
|
455
|
+
const [, findings] = redactSensitive(content, this.customSensitive);
|
|
334
456
|
const hasSensitiveData = findings.length > 0;
|
|
335
457
|
const summary = findings.map(f => `${f.name}(${f.count})`).join(', ');
|
|
336
458
|
if (hasSensitiveData) {
|
|
@@ -359,7 +481,7 @@ export class ShellWard {
|
|
|
359
481
|
this.sensitiveReads.set(`pii-${Date.now()}-${toolName}`, { path: `[${toolName}: ${summary}]`, ts: Date.now() });
|
|
360
482
|
}
|
|
361
483
|
trackFileRead(toolName, path) {
|
|
362
|
-
for (const hp of
|
|
484
|
+
for (const hp of this.honeypots) {
|
|
363
485
|
if (hp.test(path)) {
|
|
364
486
|
this.log.write({
|
|
365
487
|
level: 'CRITICAL',
|
|
@@ -394,8 +516,9 @@ export class ShellWard {
|
|
|
394
516
|
this.evictExpired();
|
|
395
517
|
}
|
|
396
518
|
checkOutbound(toolName, params) {
|
|
397
|
-
|
|
398
|
-
const
|
|
519
|
+
params = (params && typeof params === 'object') ? params : {};
|
|
520
|
+
const toolLower = asString(toolName).toLowerCase();
|
|
521
|
+
const isOutbound = this.outboundTools.has(toolLower);
|
|
399
522
|
const isDualUse = DUAL_USE_TOOLS.has(toolLower);
|
|
400
523
|
const enforce = this.config.mode === 'enforce';
|
|
401
524
|
this.evictExpired();
|
|
@@ -499,6 +622,8 @@ export class ShellWard {
|
|
|
499
622
|
}
|
|
500
623
|
extractTextFields(args) {
|
|
501
624
|
const results = [];
|
|
625
|
+
if (!args || typeof args !== 'object')
|
|
626
|
+
return results;
|
|
502
627
|
for (const field of TEXT_FIELDS) {
|
|
503
628
|
if (typeof args[field] === 'string' && args[field].length > 0) {
|
|
504
629
|
results.push(args[field]);
|
|
@@ -541,8 +666,36 @@ function mergeConfig(userConfig) {
|
|
|
541
666
|
injectionThreshold: threshold,
|
|
542
667
|
autoCheckOnStartup,
|
|
543
668
|
layers: { ...DEFAULT_CONFIG.layers, ...(userConfig.layers || {}) },
|
|
669
|
+
...(userConfig.customRules ? { customRules: userConfig.customRules } : {}),
|
|
544
670
|
};
|
|
545
671
|
}
|
|
672
|
+
/** Compile a list of regex-source strings; invalid ones are skipped. */
|
|
673
|
+
function compileRegexList(sources) {
|
|
674
|
+
const out = [];
|
|
675
|
+
for (const src of sources) {
|
|
676
|
+
try {
|
|
677
|
+
out.push(new RegExp(src, 'i'));
|
|
678
|
+
}
|
|
679
|
+
catch { /* skip invalid */ }
|
|
680
|
+
}
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
683
|
+
/** Compile user dangerous-command rules; invalid regexes are skipped. */
|
|
684
|
+
function compileDangerousRules(rules) {
|
|
685
|
+
const out = [];
|
|
686
|
+
for (const r of rules) {
|
|
687
|
+
try {
|
|
688
|
+
out.push({
|
|
689
|
+
id: r.id,
|
|
690
|
+
pattern: new RegExp(r.pattern, r.flags || 'i'),
|
|
691
|
+
description_zh: r.description || r.id,
|
|
692
|
+
description_en: r.description || r.id,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
catch { /* skip invalid */ }
|
|
696
|
+
}
|
|
697
|
+
return out;
|
|
698
|
+
}
|
|
546
699
|
function normalizePath(p) {
|
|
547
700
|
const expanded = p.startsWith('~')
|
|
548
701
|
? p.replace(/^~/, homedir() || process.env.HOME || '/root')
|
|
@@ -557,6 +710,75 @@ function normalizePath(p) {
|
|
|
557
710
|
function truncate(s, max) {
|
|
558
711
|
return s.length > max ? s.slice(0, max) + '...' : s;
|
|
559
712
|
}
|
|
713
|
+
/**
|
|
714
|
+
* Defensive coercion at public API boundaries: a security check must fail safe
|
|
715
|
+
* on hostile/garbage input, never throw. null/undefined → '', everything else
|
|
716
|
+
* is stringified.
|
|
717
|
+
*/
|
|
718
|
+
function asString(v) {
|
|
719
|
+
if (typeof v === 'string')
|
|
720
|
+
return v;
|
|
721
|
+
if (v == null)
|
|
722
|
+
return '';
|
|
723
|
+
try {
|
|
724
|
+
return String(v);
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
return '';
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Defeat shell-quote obfuscation for DETECTION (not execution): strip empty
|
|
732
|
+
* quote pairs so `r''m -rf /` and `r""m -rf /` normalize to `rm -rf /`.
|
|
733
|
+
* Deliberately conservative — non-empty quoted arguments (echo "rm -rf /")
|
|
734
|
+
* are left intact to avoid false positives. Runs a few passes for r''''m.
|
|
735
|
+
*/
|
|
736
|
+
function normalizeCommand(cmd) {
|
|
737
|
+
let prev = cmd;
|
|
738
|
+
for (let i = 0; i < 4; i++) {
|
|
739
|
+
const next = prev.replace(/''|""/g, '');
|
|
740
|
+
if (next === prev)
|
|
741
|
+
break;
|
|
742
|
+
prev = next;
|
|
743
|
+
}
|
|
744
|
+
return prev;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Recursively collect all `description`/`title` string values out of a JSON
|
|
748
|
+
* Schema (an MCP tool's inputSchema), so poisoning hidden in a nested
|
|
749
|
+
* parameter description is scanned too. Bounded to avoid pathological schemas.
|
|
750
|
+
*/
|
|
751
|
+
function collectSchemaText(schema, depth = 0) {
|
|
752
|
+
if (!schema || typeof schema !== 'object' || depth > 6)
|
|
753
|
+
return '';
|
|
754
|
+
const out = [];
|
|
755
|
+
for (const [key, val] of Object.entries(schema)) {
|
|
756
|
+
if ((key === 'description' || key === 'title') && typeof val === 'string') {
|
|
757
|
+
out.push(val);
|
|
758
|
+
}
|
|
759
|
+
else if (val && typeof val === 'object') {
|
|
760
|
+
out.push(collectSchemaText(val, depth + 1));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return out.join('\n');
|
|
764
|
+
}
|
|
765
|
+
/** Remove all invisible/zero-width characters (the HIDDEN_CHAR_RANGES). */
|
|
766
|
+
function stripInvisible(text) {
|
|
767
|
+
let out = '';
|
|
768
|
+
for (const char of text) {
|
|
769
|
+
const cp = char.codePointAt(0);
|
|
770
|
+
let hidden = false;
|
|
771
|
+
for (const [start, end] of HIDDEN_CHAR_RANGES) {
|
|
772
|
+
if (cp >= start && cp <= end) {
|
|
773
|
+
hidden = true;
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (!hidden)
|
|
778
|
+
out += char;
|
|
779
|
+
}
|
|
780
|
+
return out;
|
|
781
|
+
}
|
|
560
782
|
function detectHiddenChars(text) {
|
|
561
783
|
const found = [];
|
|
562
784
|
for (const char of text) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { ShellWard } from './core/engine.js';
|
|
2
|
-
export type { CheckResult, ScanResult, InjectionResult, ResponseCheckResult } from './core/engine.js';
|
|
3
|
-
export
|
|
2
|
+
export type { CheckResult, ScanResult, InjectionResult, ResponseCheckResult, McpToolDefinition, ToolPoisoningResult, ToolPoisoningFinding, } from './core/engine.js';
|
|
3
|
+
export { McpBaseline } from './mcp-baseline.js';
|
|
4
|
+
export type { RugPullResult, RugPullStatus } from './mcp-baseline.js';
|
|
5
|
+
export type { ShellWardConfig, CustomRules, CustomSensitivePattern, CustomCommandRule, } from './types.js';
|
|
4
6
|
declare const _default: {
|
|
5
7
|
id: string;
|
|
6
8
|
register(api: any): void;
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
//
|
|
7
7
|
// See docs/定位.md — ShellWard is an AI Agent Security Layer,
|
|
8
8
|
// NOT just an OpenClaw plugin. The core engine is platform-agnostic.
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { dirname, join } from 'path';
|
|
9
12
|
import { ShellWard } from './core/engine.js';
|
|
10
13
|
import { setupPromptGuard } from './layers/prompt-guard.js';
|
|
11
14
|
import { setupOutputScanner } from './layers/output-scanner.js';
|
|
@@ -18,9 +21,21 @@ import { setupSessionGuard } from './layers/session-guard.js';
|
|
|
18
21
|
import { registerAllCommands } from './commands/index.js';
|
|
19
22
|
import { checkForUpdate } from './update-check.js';
|
|
20
23
|
import { runAutoCheckOnStartup } from './auto-check.js';
|
|
21
|
-
|
|
24
|
+
// Single source of truth: read version from package.json at load time.
|
|
25
|
+
// dist/index.js → ../package.json (package.json is shipped via "files").
|
|
26
|
+
const CURRENT_VERSION = (() => {
|
|
27
|
+
try {
|
|
28
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const pkg = JSON.parse(readFileSync(join(here, '../package.json'), 'utf8'));
|
|
30
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return '0.0.0';
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
22
36
|
// Re-export core engine for SDK usage
|
|
23
37
|
export { ShellWard } from './core/engine.js';
|
|
38
|
+
export { McpBaseline } from './mcp-baseline.js';
|
|
24
39
|
/**
|
|
25
40
|
* Wrap api.on so every hook handler gets try-catch protection.
|
|
26
41
|
* If a security hook throws, we log the error and fail-safe:
|
|
@@ -106,8 +121,8 @@ export default {
|
|
|
106
121
|
}
|
|
107
122
|
// === Slash Commands ===
|
|
108
123
|
if (api.registerCommand) {
|
|
109
|
-
registerAllCommands(api, guard.config);
|
|
110
|
-
api.logger.info(
|
|
124
|
+
const commandCount = registerAllCommands(api, guard.config);
|
|
125
|
+
api.logger.info(`[ShellWard] ${commandCount} commands registered`);
|
|
111
126
|
}
|
|
112
127
|
const allLayers = ['promptGuard', 'outputScanner', 'toolBlocker', 'inputAuditor', 'securityGate', 'outboundGuard', 'dataFlowGuard', 'sessionGuard'];
|
|
113
128
|
const enabledCount = allLayers.filter(k => guard.config.layers[k]).length;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { McpToolDefinition } from './core/engine.js';
|
|
2
|
+
export type RugPullStatus = 'new' | 'unchanged' | 'changed';
|
|
3
|
+
export interface RugPullResult {
|
|
4
|
+
key: string;
|
|
5
|
+
status: RugPullStatus;
|
|
6
|
+
currentHash: string;
|
|
7
|
+
previousHash?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class McpBaseline {
|
|
10
|
+
private readonly path;
|
|
11
|
+
private store;
|
|
12
|
+
/** @param filePath override the baseline file (tests pass a temp path). */
|
|
13
|
+
constructor(filePath?: string);
|
|
14
|
+
/** Fingerprint a tool's externally-visible contract (description + schema). */
|
|
15
|
+
private fingerprint;
|
|
16
|
+
/** Stable key for a tool, namespaced by its server. */
|
|
17
|
+
static keyFor(server: string, toolName: string): string;
|
|
18
|
+
/** Compare against the stored baseline WITHOUT persisting. */
|
|
19
|
+
diff(key: string, tool: McpToolDefinition): RugPullResult;
|
|
20
|
+
/** Compare, then update the in-memory baseline. Call save() to persist. */
|
|
21
|
+
record(key: string, tool: McpToolDefinition): RugPullResult;
|
|
22
|
+
/** Number of tracked tools. */
|
|
23
|
+
get size(): number;
|
|
24
|
+
private load;
|
|
25
|
+
/** Flush the baseline to disk (owner-only perms). Never throws. */
|
|
26
|
+
save(): void;
|
|
27
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/mcp-baseline.ts — MCP "rug-pull" detection via tool-definition baselining
|
|
2
|
+
//
|
|
3
|
+
// A rug-pull attack: an MCP tool ships a benign description, gets approved/trusted,
|
|
4
|
+
// then later silently swaps in a malicious description. ShellWard fingerprints each
|
|
5
|
+
// tool's description+schema on first sight and flags later mismatches.
|
|
6
|
+
//
|
|
7
|
+
// Zero dependencies — sha256 from node:crypto, JSON store under the audit dir.
|
|
8
|
+
import { createHash } from 'crypto';
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
10
|
+
import { dirname, join } from 'path';
|
|
11
|
+
import { getHomeDir } from './utils.js';
|
|
12
|
+
const DEFAULT_PATH = join(getHomeDir(), '.openclaw', 'shellward', 'mcp-baseline.json');
|
|
13
|
+
export class McpBaseline {
|
|
14
|
+
path;
|
|
15
|
+
store;
|
|
16
|
+
/** @param filePath override the baseline file (tests pass a temp path). */
|
|
17
|
+
constructor(filePath) {
|
|
18
|
+
this.path = filePath || DEFAULT_PATH;
|
|
19
|
+
this.store = this.load();
|
|
20
|
+
}
|
|
21
|
+
/** Fingerprint a tool's externally-visible contract (description + schema). */
|
|
22
|
+
fingerprint(tool) {
|
|
23
|
+
const canonical = JSON.stringify({
|
|
24
|
+
description: tool.description || '',
|
|
25
|
+
inputSchema: tool.inputSchema ?? null,
|
|
26
|
+
});
|
|
27
|
+
return createHash('sha256').update(canonical).digest('hex');
|
|
28
|
+
}
|
|
29
|
+
/** Stable key for a tool, namespaced by its server. */
|
|
30
|
+
static keyFor(server, toolName) {
|
|
31
|
+
return `${server}::${toolName}`;
|
|
32
|
+
}
|
|
33
|
+
/** Compare against the stored baseline WITHOUT persisting. */
|
|
34
|
+
diff(key, tool) {
|
|
35
|
+
const currentHash = this.fingerprint(tool);
|
|
36
|
+
const prev = this.store[key];
|
|
37
|
+
if (!prev)
|
|
38
|
+
return { key, status: 'new', currentHash };
|
|
39
|
+
return {
|
|
40
|
+
key,
|
|
41
|
+
status: prev.hash === currentHash ? 'unchanged' : 'changed',
|
|
42
|
+
currentHash,
|
|
43
|
+
previousHash: prev.hash,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Compare, then update the in-memory baseline. Call save() to persist. */
|
|
47
|
+
record(key, tool) {
|
|
48
|
+
const res = this.diff(key, tool);
|
|
49
|
+
this.store[key] = { hash: res.currentHash, name: tool.name, ts: new Date().toISOString() };
|
|
50
|
+
return res;
|
|
51
|
+
}
|
|
52
|
+
/** Number of tracked tools. */
|
|
53
|
+
get size() {
|
|
54
|
+
return Object.keys(this.store).length;
|
|
55
|
+
}
|
|
56
|
+
load() {
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
59
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Flush the baseline to disk (owner-only perms). Never throws. */
|
|
66
|
+
save() {
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
69
|
+
writeFileSync(this.path, JSON.stringify(this.store, null, 2), { mode: 0o600 });
|
|
70
|
+
}
|
|
71
|
+
catch { /* best-effort; baselining must not break the host */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { McpToolDefinition } from './core/engine.js';
|
|
2
|
+
export interface McpServerSpec {
|
|
3
|
+
name: string;
|
|
4
|
+
/** 'stdio' servers are spawned; 'remote' servers are scanned over HTTP. */
|
|
5
|
+
transport: 'stdio' | 'remote';
|
|
6
|
+
command?: string;
|
|
7
|
+
args?: string[];
|
|
8
|
+
env?: Record<string, string>;
|
|
9
|
+
url?: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
source: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Discover MCP servers declared in known config files.
|
|
15
|
+
* Recognizes the standard `{ "mcpServers": { name: {...} } }` shape.
|
|
16
|
+
* @param paths override config paths (tests pass a temp file)
|
|
17
|
+
*/
|
|
18
|
+
export declare function discoverMcpServers(paths?: string[]): McpServerSpec[];
|
|
19
|
+
/**
|
|
20
|
+
* Spawn a stdio MCP server, initialize, and return its tool definitions.
|
|
21
|
+
* Always resolves (never hangs): on error/timeout it cleans up and rejects.
|
|
22
|
+
*/
|
|
23
|
+
export declare function listToolsStdio(spec: McpServerSpec, timeoutMs?: number): Promise<McpToolDefinition[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Initialize a remote MCP server over Streamable HTTP and return its tool
|
|
26
|
+
* definitions. Best-effort: returns [] if the server speaks an unsupported
|
|
27
|
+
* dialect. Rejects on network error / timeout.
|
|
28
|
+
*/
|
|
29
|
+
export declare function listToolsHttp(spec: McpServerSpec, timeoutMs?: number): Promise<McpToolDefinition[]>;
|