shellward 0.5.16 → 0.6.0
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 +225 -30
- 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 +250 -31
- 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,7 @@ export class ShellWard {
|
|
|
137
173
|
}
|
|
138
174
|
// ========== L2: Data Scanner ==========
|
|
139
175
|
scanData(text, toolName) {
|
|
140
|
-
const [, findings] = redactSensitive(text);
|
|
176
|
+
const [, findings] = redactSensitive(text, this.customSensitive);
|
|
141
177
|
const hasSensitiveData = findings.length > 0;
|
|
142
178
|
const summary = findings.map(f => `${f.name}(${f.count})`).join(', ');
|
|
143
179
|
if (hasSensitiveData) {
|
|
@@ -161,7 +197,10 @@ export class ShellWard {
|
|
|
161
197
|
checkTool(toolName) {
|
|
162
198
|
const toolLower = toolName.toLowerCase();
|
|
163
199
|
const enforce = this.config.mode === 'enforce';
|
|
164
|
-
|
|
200
|
+
// allowedTools always wins — user-trusted tools bypass policy.
|
|
201
|
+
if (this.allowedTools.has(toolLower))
|
|
202
|
+
return { allowed: true };
|
|
203
|
+
if (this.blockedTools.has(toolLower)) {
|
|
165
204
|
const reason = this.locale === 'zh'
|
|
166
205
|
? `安全策略禁止自动执行: ${toolName}`
|
|
167
206
|
: `Blocked by security policy: ${toolName}`;
|
|
@@ -174,7 +213,7 @@ export class ShellWard {
|
|
|
174
213
|
});
|
|
175
214
|
return { allowed: false, level: 'CRITICAL', reason };
|
|
176
215
|
}
|
|
177
|
-
if (
|
|
216
|
+
if (this.sensitiveTools.has(toolLower)) {
|
|
178
217
|
this.log.write({
|
|
179
218
|
level: 'MEDIUM',
|
|
180
219
|
layer: 'L3',
|
|
@@ -189,8 +228,12 @@ export class ShellWard {
|
|
|
189
228
|
const enforce = this.config.mode === 'enforce';
|
|
190
229
|
const parts = splitCommands(cmd);
|
|
191
230
|
for (const part of parts) {
|
|
192
|
-
|
|
193
|
-
|
|
231
|
+
// Normalize shell-quote obfuscation (e.g. r''m / r""m → rm) before matching.
|
|
232
|
+
// Only empty quote pairs are stripped, so a real quoted arg like
|
|
233
|
+
// echo "rm -rf /" is untouched (no false positive).
|
|
234
|
+
const normalized = normalizeCommand(part);
|
|
235
|
+
for (const rule of [...DANGEROUS_COMMANDS, ...this.customDangerous]) {
|
|
236
|
+
if (rule.pattern.test(part) || rule.pattern.test(normalized)) {
|
|
194
237
|
const desc = this.locale === 'zh' ? rule.description_zh : rule.description_en;
|
|
195
238
|
const reason = this.locale === 'zh'
|
|
196
239
|
? `检测到危险命令: ${truncate(part, 80)}\n原因: ${desc}`
|
|
@@ -244,10 +287,13 @@ export class ShellWard {
|
|
|
244
287
|
detail: `Hidden characters detected: ${[...new Set(hiddenChars.map(h => h.name))].join(', ')} (${hiddenChars.length} chars)`,
|
|
245
288
|
});
|
|
246
289
|
}
|
|
290
|
+
// Strip invisible characters before rule matching so an attacker can't break
|
|
291
|
+
// a pattern by interleaving zero-width spaces (e.g. "ignore previous").
|
|
292
|
+
const normText = hiddenChars.length > 0 ? stripInvisible(text) : text;
|
|
247
293
|
let score = 0;
|
|
248
294
|
const matched = [];
|
|
249
295
|
for (const rule of this.compiledRules) {
|
|
250
|
-
if (rule.compiled.test(text)) {
|
|
296
|
+
if (rule.compiled.test(text) || (normText !== text && rule.compiled.test(normText))) {
|
|
251
297
|
score += rule.riskScore;
|
|
252
298
|
matched.push({ id: rule.id, name: rule.name, score: rule.riskScore });
|
|
253
299
|
}
|
|
@@ -267,11 +313,86 @@ export class ShellWard {
|
|
|
267
313
|
return { safe: score < threshold, score, threshold, matched, hiddenChars: hiddenChars.length };
|
|
268
314
|
}
|
|
269
315
|
getInjectionThreshold(toolName) {
|
|
270
|
-
|
|
316
|
+
const lower = toolName?.toLowerCase();
|
|
317
|
+
if (lower && (LOW_RISK_TOOLS.has(lower) || this.allowedTools.has(lower))) {
|
|
271
318
|
return Math.max(this.config.injectionThreshold, 80);
|
|
272
319
|
}
|
|
273
320
|
return this.config.injectionThreshold;
|
|
274
321
|
}
|
|
322
|
+
// ========== L4b: MCP Tool-Poisoning Scanner ==========
|
|
323
|
+
//
|
|
324
|
+
// Inspects an MCP tool *definition* (not user input) for instructions hidden
|
|
325
|
+
// in its description / parameter descriptions — the "tool poisoning" attack.
|
|
326
|
+
// Reuses the injection engine + hidden-char detection and layers on rules
|
|
327
|
+
// tuned for tool-metadata attacks. Pure & side-effect-light: callable from
|
|
328
|
+
// the SDK, the MCP server, or at plugin tool-discovery time.
|
|
329
|
+
scanToolDefinition(tool, options) {
|
|
330
|
+
const threshold = options?.threshold ?? 40;
|
|
331
|
+
const findings = [];
|
|
332
|
+
let score = 0;
|
|
333
|
+
const description = typeof tool.description === 'string' ? tool.description : '';
|
|
334
|
+
const paramText = collectSchemaText(tool.inputSchema);
|
|
335
|
+
const combined = `${description}\n${paramText}`;
|
|
336
|
+
// 1. Hidden / invisible characters anywhere in the metadata
|
|
337
|
+
const hidden = detectHiddenChars(combined);
|
|
338
|
+
if (hidden.length > 0) {
|
|
339
|
+
const s = hidden.length > 3 ? 35 : 20;
|
|
340
|
+
score += s;
|
|
341
|
+
findings.push({
|
|
342
|
+
id: 'tp_hidden_chars',
|
|
343
|
+
name: `Hidden characters in tool metadata (${[...new Set(hidden.map(h => h.name))].join(', ')})`,
|
|
344
|
+
category: 'concealment',
|
|
345
|
+
score: s,
|
|
346
|
+
source: 'hidden_chars',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
// 2. Tool-poisoning specific rules (description + parameters)
|
|
350
|
+
for (const rule of TOOL_POISONING_RULES) {
|
|
351
|
+
const inDesc = rule.pattern.test(description);
|
|
352
|
+
const inParam = !inDesc && rule.pattern.test(paramText);
|
|
353
|
+
if (inDesc || inParam) {
|
|
354
|
+
score += rule.riskScore;
|
|
355
|
+
findings.push({
|
|
356
|
+
id: rule.id,
|
|
357
|
+
name: rule.name,
|
|
358
|
+
category: rule.category,
|
|
359
|
+
score: rule.riskScore,
|
|
360
|
+
source: inDesc ? 'description' : 'parameter',
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// 3. Generic prompt-injection patterns reused on the description
|
|
365
|
+
for (const rule of this.compiledRules) {
|
|
366
|
+
if (rule.compiled.test(combined)) {
|
|
367
|
+
score += rule.riskScore;
|
|
368
|
+
findings.push({
|
|
369
|
+
id: rule.id,
|
|
370
|
+
name: rule.name,
|
|
371
|
+
category: rule.category,
|
|
372
|
+
score: rule.riskScore,
|
|
373
|
+
source: 'description',
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const safe = score < threshold;
|
|
378
|
+
if (!safe) {
|
|
379
|
+
this.log.write({
|
|
380
|
+
level: score >= 80 ? 'CRITICAL' : 'HIGH',
|
|
381
|
+
layer: 'L4',
|
|
382
|
+
action: this.config.mode === 'enforce' ? 'block' : 'detect',
|
|
383
|
+
detail: this.locale === 'zh'
|
|
384
|
+
? `检测到 MCP 工具投毒: ${tool.name}\n风险评分: ${score}\n命中: ${findings.map(f => f.name).join('; ')}`
|
|
385
|
+
: `MCP tool poisoning detected: ${tool.name}\nRisk score: ${score}\nMatched: ${findings.map(f => f.name).join('; ')}`,
|
|
386
|
+
tool: tool.name,
|
|
387
|
+
pattern: 'tool_poisoning',
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
return { toolName: tool.name, safe, score, threshold, findings, hiddenChars: hidden.length };
|
|
391
|
+
}
|
|
392
|
+
/** Scan a list of MCP tool definitions; returns only the unsafe ones. */
|
|
393
|
+
scanToolDefinitions(tools, options) {
|
|
394
|
+
return tools.map(t => this.scanToolDefinition(t, options)).filter(r => !r.safe);
|
|
395
|
+
}
|
|
275
396
|
// ========== L5: Security Gate ==========
|
|
276
397
|
checkAction(action, details) {
|
|
277
398
|
if (action === 'exec' || action === 'shell') {
|
|
@@ -293,20 +414,14 @@ export class ShellWard {
|
|
|
293
414
|
});
|
|
294
415
|
return { allowed: false, level: 'CRITICAL', reason, ruleId: 'no_payment' };
|
|
295
416
|
}
|
|
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' };
|
|
417
|
+
// Outbound actions: delegate the DLP decision to the canonical data-flow
|
|
418
|
+
// guard (L7) so the Gate and the Outbound Guard can never diverge. The set
|
|
419
|
+
// of outbound tools (incl. http_post/curl_post + any customRules) lives in
|
|
420
|
+
// one place: this.outboundTools, consulted by checkOutbound.
|
|
421
|
+
if (this.outboundTools.has(action.toLowerCase())) {
|
|
422
|
+
const dlp = this.checkOutbound(action, details ? { body: details } : {});
|
|
423
|
+
if (!dlp.allowed)
|
|
424
|
+
return dlp;
|
|
310
425
|
}
|
|
311
426
|
this.log.write({
|
|
312
427
|
level: 'INFO',
|
|
@@ -330,7 +445,7 @@ export class ShellWard {
|
|
|
330
445
|
pattern: 'canary_leak',
|
|
331
446
|
});
|
|
332
447
|
}
|
|
333
|
-
const [, findings] = redactSensitive(content);
|
|
448
|
+
const [, findings] = redactSensitive(content, this.customSensitive);
|
|
334
449
|
const hasSensitiveData = findings.length > 0;
|
|
335
450
|
const summary = findings.map(f => `${f.name}(${f.count})`).join(', ');
|
|
336
451
|
if (hasSensitiveData) {
|
|
@@ -359,7 +474,7 @@ export class ShellWard {
|
|
|
359
474
|
this.sensitiveReads.set(`pii-${Date.now()}-${toolName}`, { path: `[${toolName}: ${summary}]`, ts: Date.now() });
|
|
360
475
|
}
|
|
361
476
|
trackFileRead(toolName, path) {
|
|
362
|
-
for (const hp of
|
|
477
|
+
for (const hp of this.honeypots) {
|
|
363
478
|
if (hp.test(path)) {
|
|
364
479
|
this.log.write({
|
|
365
480
|
level: 'CRITICAL',
|
|
@@ -395,7 +510,7 @@ export class ShellWard {
|
|
|
395
510
|
}
|
|
396
511
|
checkOutbound(toolName, params) {
|
|
397
512
|
const toolLower = toolName.toLowerCase();
|
|
398
|
-
const isOutbound =
|
|
513
|
+
const isOutbound = this.outboundTools.has(toolLower);
|
|
399
514
|
const isDualUse = DUAL_USE_TOOLS.has(toolLower);
|
|
400
515
|
const enforce = this.config.mode === 'enforce';
|
|
401
516
|
this.evictExpired();
|
|
@@ -541,8 +656,36 @@ function mergeConfig(userConfig) {
|
|
|
541
656
|
injectionThreshold: threshold,
|
|
542
657
|
autoCheckOnStartup,
|
|
543
658
|
layers: { ...DEFAULT_CONFIG.layers, ...(userConfig.layers || {}) },
|
|
659
|
+
...(userConfig.customRules ? { customRules: userConfig.customRules } : {}),
|
|
544
660
|
};
|
|
545
661
|
}
|
|
662
|
+
/** Compile a list of regex-source strings; invalid ones are skipped. */
|
|
663
|
+
function compileRegexList(sources) {
|
|
664
|
+
const out = [];
|
|
665
|
+
for (const src of sources) {
|
|
666
|
+
try {
|
|
667
|
+
out.push(new RegExp(src, 'i'));
|
|
668
|
+
}
|
|
669
|
+
catch { /* skip invalid */ }
|
|
670
|
+
}
|
|
671
|
+
return out;
|
|
672
|
+
}
|
|
673
|
+
/** Compile user dangerous-command rules; invalid regexes are skipped. */
|
|
674
|
+
function compileDangerousRules(rules) {
|
|
675
|
+
const out = [];
|
|
676
|
+
for (const r of rules) {
|
|
677
|
+
try {
|
|
678
|
+
out.push({
|
|
679
|
+
id: r.id,
|
|
680
|
+
pattern: new RegExp(r.pattern, r.flags || 'i'),
|
|
681
|
+
description_zh: r.description || r.id,
|
|
682
|
+
description_en: r.description || r.id,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
catch { /* skip invalid */ }
|
|
686
|
+
}
|
|
687
|
+
return out;
|
|
688
|
+
}
|
|
546
689
|
function normalizePath(p) {
|
|
547
690
|
const expanded = p.startsWith('~')
|
|
548
691
|
? p.replace(/^~/, homedir() || process.env.HOME || '/root')
|
|
@@ -557,6 +700,58 @@ function normalizePath(p) {
|
|
|
557
700
|
function truncate(s, max) {
|
|
558
701
|
return s.length > max ? s.slice(0, max) + '...' : s;
|
|
559
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* Defeat shell-quote obfuscation for DETECTION (not execution): strip empty
|
|
705
|
+
* quote pairs so `r''m -rf /` and `r""m -rf /` normalize to `rm -rf /`.
|
|
706
|
+
* Deliberately conservative — non-empty quoted arguments (echo "rm -rf /")
|
|
707
|
+
* are left intact to avoid false positives. Runs a few passes for r''''m.
|
|
708
|
+
*/
|
|
709
|
+
function normalizeCommand(cmd) {
|
|
710
|
+
let prev = cmd;
|
|
711
|
+
for (let i = 0; i < 4; i++) {
|
|
712
|
+
const next = prev.replace(/''|""/g, '');
|
|
713
|
+
if (next === prev)
|
|
714
|
+
break;
|
|
715
|
+
prev = next;
|
|
716
|
+
}
|
|
717
|
+
return prev;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Recursively collect all `description`/`title` string values out of a JSON
|
|
721
|
+
* Schema (an MCP tool's inputSchema), so poisoning hidden in a nested
|
|
722
|
+
* parameter description is scanned too. Bounded to avoid pathological schemas.
|
|
723
|
+
*/
|
|
724
|
+
function collectSchemaText(schema, depth = 0) {
|
|
725
|
+
if (!schema || typeof schema !== 'object' || depth > 6)
|
|
726
|
+
return '';
|
|
727
|
+
const out = [];
|
|
728
|
+
for (const [key, val] of Object.entries(schema)) {
|
|
729
|
+
if ((key === 'description' || key === 'title') && typeof val === 'string') {
|
|
730
|
+
out.push(val);
|
|
731
|
+
}
|
|
732
|
+
else if (val && typeof val === 'object') {
|
|
733
|
+
out.push(collectSchemaText(val, depth + 1));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return out.join('\n');
|
|
737
|
+
}
|
|
738
|
+
/** Remove all invisible/zero-width characters (the HIDDEN_CHAR_RANGES). */
|
|
739
|
+
function stripInvisible(text) {
|
|
740
|
+
let out = '';
|
|
741
|
+
for (const char of text) {
|
|
742
|
+
const cp = char.codePointAt(0);
|
|
743
|
+
let hidden = false;
|
|
744
|
+
for (const [start, end] of HIDDEN_CHAR_RANGES) {
|
|
745
|
+
if (cp >= start && cp <= end) {
|
|
746
|
+
hidden = true;
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (!hidden)
|
|
751
|
+
out += char;
|
|
752
|
+
}
|
|
753
|
+
return out;
|
|
754
|
+
}
|
|
560
755
|
function detectHiddenChars(text) {
|
|
561
756
|
const found = [];
|
|
562
757
|
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[]>;
|