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/src/core/engine.ts
CHANGED
|
@@ -9,13 +9,15 @@ import { randomBytes } from 'crypto'
|
|
|
9
9
|
import { resolve } from 'path'
|
|
10
10
|
import { homedir } from 'os'
|
|
11
11
|
import { DANGEROUS_COMMANDS, splitCommands } from '../rules/dangerous-commands.js'
|
|
12
|
+
import { TOOL_POISONING_RULES } from '../rules/tool-poisoning.js'
|
|
12
13
|
import { PROTECTED_PATHS } from '../rules/protected-paths.js'
|
|
13
14
|
import { INJECTION_RULES_ZH } from '../rules/injection-zh.js'
|
|
14
15
|
import { INJECTION_RULES_EN } from '../rules/injection-en.js'
|
|
15
|
-
import { redactSensitive } from '../rules/sensitive-patterns.js'
|
|
16
|
+
import { redactSensitive, compileSensitivePatterns } from '../rules/sensitive-patterns.js'
|
|
17
|
+
import type { SensitivePattern } from '../rules/sensitive-patterns.js'
|
|
16
18
|
import { AuditLog } from '../audit-log.js'
|
|
17
19
|
import { resolveLocale, DEFAULT_CONFIG } from '../types.js'
|
|
18
|
-
import type { ShellWardConfig, ResolvedLocale, InjectionRule } from '../types.js'
|
|
20
|
+
import type { ShellWardConfig, ResolvedLocale, InjectionRule, DangerousCommandRule } from '../types.js'
|
|
19
21
|
|
|
20
22
|
// ===== Result Types =====
|
|
21
23
|
|
|
@@ -45,6 +47,30 @@ export interface ResponseCheckResult {
|
|
|
45
47
|
sensitiveData: ScanResult
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** Shape of an MCP tool definition (subset of the spec we inspect). */
|
|
51
|
+
export interface McpToolDefinition {
|
|
52
|
+
name: string
|
|
53
|
+
description?: string
|
|
54
|
+
inputSchema?: Record<string, any>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ToolPoisoningFinding {
|
|
58
|
+
id: string
|
|
59
|
+
name: string
|
|
60
|
+
category: string
|
|
61
|
+
score: number
|
|
62
|
+
source: 'description' | 'parameter' | 'hidden_chars'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ToolPoisoningResult {
|
|
66
|
+
toolName: string
|
|
67
|
+
safe: boolean
|
|
68
|
+
score: number
|
|
69
|
+
threshold: number
|
|
70
|
+
findings: ToolPoisoningFinding[]
|
|
71
|
+
hiddenChars: number
|
|
72
|
+
}
|
|
73
|
+
|
|
48
74
|
// ===== Internal Types =====
|
|
49
75
|
|
|
50
76
|
interface CompiledRule extends InjectionRule {
|
|
@@ -68,6 +94,7 @@ const EXEC_TOOLS = new Set([
|
|
|
68
94
|
|
|
69
95
|
const OUTBOUND_TOOLS = new Set([
|
|
70
96
|
'send_email', 'send_message', 'post_tweet', 'message', 'sessions_send',
|
|
97
|
+
'http_post', 'curl_post',
|
|
71
98
|
])
|
|
72
99
|
|
|
73
100
|
const DUAL_USE_TOOLS = new Set([
|
|
@@ -105,6 +132,12 @@ const HIDDEN_CHAR_RANGES: [number, number, string][] = [
|
|
|
105
132
|
[0xFEFF, 0xFEFF, 'BOM/Zero-width no-break'],
|
|
106
133
|
[0x00AD, 0x00AD, 'Soft hyphen'],
|
|
107
134
|
[0xFFF9, 0xFFFB, 'Interlinear annotation'],
|
|
135
|
+
// Variation selectors — abused to smuggle hidden bytes/instructions
|
|
136
|
+
[0xFE00, 0xFE0F, 'Variation selector'],
|
|
137
|
+
[0xE0100, 0xE01EF, 'Variation selector supplement'],
|
|
138
|
+
// Unicode Tag characters — the primary "invisible prompt injection" vector
|
|
139
|
+
[0xE0001, 0xE0001, 'Language tag'],
|
|
140
|
+
[0xE0020, 0xE007F, 'Tag character'],
|
|
108
141
|
]
|
|
109
142
|
|
|
110
143
|
const TEXT_FIELDS = [
|
|
@@ -165,6 +198,15 @@ export class ShellWard {
|
|
|
165
198
|
private _canaryToken: string
|
|
166
199
|
private compiledRules: CompiledRule[]
|
|
167
200
|
|
|
201
|
+
// Tool policy sets — built-ins merged with config.customRules (allowedTools wins).
|
|
202
|
+
private readonly blockedTools: Set<string>
|
|
203
|
+
private readonly allowedTools: Set<string>
|
|
204
|
+
private readonly sensitiveTools: Set<string>
|
|
205
|
+
private readonly outboundTools: Set<string>
|
|
206
|
+
private readonly honeypots: RegExp[]
|
|
207
|
+
private readonly customSensitive: SensitivePattern[]
|
|
208
|
+
private readonly customDangerous: DangerousCommandRule[]
|
|
209
|
+
|
|
168
210
|
private sensitiveReads: Map<string, { path: string; ts: number }> = new Map()
|
|
169
211
|
private readonly TRACKING_WINDOW_MS = 5 * 60 * 1000
|
|
170
212
|
private readonly MAX_TRACKED_READS = 500
|
|
@@ -175,11 +217,26 @@ export class ShellWard {
|
|
|
175
217
|
this.log = new AuditLog(this.config)
|
|
176
218
|
this._canaryToken = 'SW-' + randomBytes(8).toString('hex')
|
|
177
219
|
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
220
|
+
const custom = this.config.customRules || {}
|
|
221
|
+
const lower = (s: string) => s.toLowerCase()
|
|
222
|
+
|
|
223
|
+
this.allowedTools = new Set((custom.allowedTools || []).map(lower))
|
|
224
|
+
this.blockedTools = new Set([...BLOCKED_TOOLS, ...(custom.blockedTools || []).map(lower)])
|
|
225
|
+
this.sensitiveTools = new Set([...SENSITIVE_TOOLS, ...(custom.sensitiveTools || []).map(lower)])
|
|
226
|
+
this.outboundTools = new Set([...OUTBOUND_TOOLS, ...(custom.outboundTools || []).map(lower)])
|
|
227
|
+
// allowedTools always wins — strip them from the block/sensitive sets.
|
|
228
|
+
for (const t of this.allowedTools) { this.blockedTools.delete(t); this.sensitiveTools.delete(t) }
|
|
229
|
+
|
|
230
|
+
this.honeypots = [...HONEYPOT_PATTERNS, ...compileRegexList(custom.honeypotPaths || [])]
|
|
231
|
+
this.customSensitive = compileSensitivePatterns(custom.sensitivePatterns || [])
|
|
232
|
+
this.customDangerous = compileDangerousRules(custom.dangerousCommands || [])
|
|
233
|
+
|
|
234
|
+
const allRules = [...INJECTION_RULES_ZH, ...INJECTION_RULES_EN, ...(custom.injectionRules || [])]
|
|
235
|
+
this.compiledRules = allRules
|
|
236
|
+
.map(rule => {
|
|
237
|
+
try { return { ...rule, compiled: new RegExp(rule.pattern, rule.flags || 'i') } } catch { return null }
|
|
238
|
+
})
|
|
239
|
+
.filter((r): r is CompiledRule => r !== null)
|
|
183
240
|
}
|
|
184
241
|
|
|
185
242
|
// ========== L1: Prompt Guard ==========
|
|
@@ -199,7 +256,8 @@ export class ShellWard {
|
|
|
199
256
|
// ========== L2: Data Scanner ==========
|
|
200
257
|
|
|
201
258
|
scanData(text: string, toolName?: string): ScanResult {
|
|
202
|
-
|
|
259
|
+
text = asString(text)
|
|
260
|
+
const [, findings] = redactSensitive(text, this.customSensitive)
|
|
203
261
|
const hasSensitiveData = findings.length > 0
|
|
204
262
|
const summary = findings.map(f => `${f.name}(${f.count})`).join(', ')
|
|
205
263
|
|
|
@@ -225,10 +283,13 @@ export class ShellWard {
|
|
|
225
283
|
// ========== L3: Tool & Command Checker ==========
|
|
226
284
|
|
|
227
285
|
checkTool(toolName: string): CheckResult {
|
|
228
|
-
const toolLower = toolName.toLowerCase()
|
|
286
|
+
const toolLower = asString(toolName).toLowerCase()
|
|
229
287
|
const enforce = this.config.mode === 'enforce'
|
|
230
288
|
|
|
231
|
-
|
|
289
|
+
// allowedTools always wins — user-trusted tools bypass policy.
|
|
290
|
+
if (this.allowedTools.has(toolLower)) return { allowed: true }
|
|
291
|
+
|
|
292
|
+
if (this.blockedTools.has(toolLower)) {
|
|
232
293
|
const reason = this.locale === 'zh'
|
|
233
294
|
? `安全策略禁止自动执行: ${toolName}`
|
|
234
295
|
: `Blocked by security policy: ${toolName}`
|
|
@@ -242,7 +303,7 @@ export class ShellWard {
|
|
|
242
303
|
return { allowed: false, level: 'CRITICAL', reason }
|
|
243
304
|
}
|
|
244
305
|
|
|
245
|
-
if (
|
|
306
|
+
if (this.sensitiveTools.has(toolLower)) {
|
|
246
307
|
this.log.write({
|
|
247
308
|
level: 'MEDIUM',
|
|
248
309
|
layer: 'L3',
|
|
@@ -257,11 +318,15 @@ export class ShellWard {
|
|
|
257
318
|
|
|
258
319
|
checkCommand(cmd: string, toolName?: string): CheckResult {
|
|
259
320
|
const enforce = this.config.mode === 'enforce'
|
|
260
|
-
const parts = splitCommands(cmd)
|
|
321
|
+
const parts = splitCommands(asString(cmd))
|
|
261
322
|
|
|
262
323
|
for (const part of parts) {
|
|
263
|
-
|
|
264
|
-
|
|
324
|
+
// Normalize shell-quote obfuscation (e.g. r''m / r""m → rm) before matching.
|
|
325
|
+
// Only empty quote pairs are stripped, so a real quoted arg like
|
|
326
|
+
// echo "rm -rf /" is untouched (no false positive).
|
|
327
|
+
const normalized = normalizeCommand(part)
|
|
328
|
+
for (const rule of [...DANGEROUS_COMMANDS, ...this.customDangerous]) {
|
|
329
|
+
if (rule.pattern.test(part) || rule.pattern.test(normalized)) {
|
|
265
330
|
const desc = this.locale === 'zh' ? rule.description_zh : rule.description_en
|
|
266
331
|
const reason = this.locale === 'zh'
|
|
267
332
|
? `检测到危险命令: ${truncate(part, 80)}\n原因: ${desc}`
|
|
@@ -282,6 +347,7 @@ export class ShellWard {
|
|
|
282
347
|
}
|
|
283
348
|
|
|
284
349
|
checkPath(path: string, operation: 'write' | 'delete', toolName?: string): CheckResult {
|
|
350
|
+
path = asString(path)
|
|
285
351
|
const enforce = this.config.mode === 'enforce'
|
|
286
352
|
const normalizedPath = normalizePath(path)
|
|
287
353
|
|
|
@@ -308,6 +374,7 @@ export class ShellWard {
|
|
|
308
374
|
// ========== L4: Injection Detection ==========
|
|
309
375
|
|
|
310
376
|
checkInjection(text: string, options?: { source?: string; threshold?: number }): InjectionResult {
|
|
377
|
+
text = asString(text)
|
|
311
378
|
const threshold = options?.threshold ?? this.config.injectionThreshold
|
|
312
379
|
const enforce = this.config.mode === 'enforce'
|
|
313
380
|
|
|
@@ -321,10 +388,14 @@ export class ShellWard {
|
|
|
321
388
|
})
|
|
322
389
|
}
|
|
323
390
|
|
|
391
|
+
// Strip invisible characters before rule matching so an attacker can't break
|
|
392
|
+
// a pattern by interleaving zero-width spaces (e.g. "ignore previous").
|
|
393
|
+
const normText = hiddenChars.length > 0 ? stripInvisible(text) : text
|
|
394
|
+
|
|
324
395
|
let score = 0
|
|
325
396
|
const matched: { id: string; name: string; score: number }[] = []
|
|
326
397
|
for (const rule of this.compiledRules) {
|
|
327
|
-
if (rule.compiled.test(text)) {
|
|
398
|
+
if (rule.compiled.test(text) || (normText !== text && rule.compiled.test(normText))) {
|
|
328
399
|
score += rule.riskScore
|
|
329
400
|
matched.push({ id: rule.id, name: rule.name, score: rule.riskScore })
|
|
330
401
|
}
|
|
@@ -346,15 +417,102 @@ export class ShellWard {
|
|
|
346
417
|
}
|
|
347
418
|
|
|
348
419
|
getInjectionThreshold(toolName?: string): number {
|
|
349
|
-
|
|
420
|
+
const lower = toolName?.toLowerCase()
|
|
421
|
+
if (lower && (LOW_RISK_TOOLS.has(lower) || this.allowedTools.has(lower))) {
|
|
350
422
|
return Math.max(this.config.injectionThreshold, 80)
|
|
351
423
|
}
|
|
352
424
|
return this.config.injectionThreshold
|
|
353
425
|
}
|
|
354
426
|
|
|
427
|
+
// ========== L4b: MCP Tool-Poisoning Scanner ==========
|
|
428
|
+
//
|
|
429
|
+
// Inspects an MCP tool *definition* (not user input) for instructions hidden
|
|
430
|
+
// in its description / parameter descriptions — the "tool poisoning" attack.
|
|
431
|
+
// Reuses the injection engine + hidden-char detection and layers on rules
|
|
432
|
+
// tuned for tool-metadata attacks. Pure & side-effect-light: callable from
|
|
433
|
+
// the SDK, the MCP server, or at plugin tool-discovery time.
|
|
434
|
+
|
|
435
|
+
scanToolDefinition(tool: McpToolDefinition, options?: { threshold?: number }): ToolPoisoningResult {
|
|
436
|
+
tool = (tool && typeof tool === 'object') ? tool : { name: 'unknown' }
|
|
437
|
+
const threshold = options?.threshold ?? 40
|
|
438
|
+
const findings: ToolPoisoningFinding[] = []
|
|
439
|
+
let score = 0
|
|
440
|
+
|
|
441
|
+
const description = typeof tool.description === 'string' ? tool.description : ''
|
|
442
|
+
const paramText = collectSchemaText(tool.inputSchema)
|
|
443
|
+
const combined = `${description}\n${paramText}`
|
|
444
|
+
|
|
445
|
+
// 1. Hidden / invisible characters anywhere in the metadata
|
|
446
|
+
const hidden = detectHiddenChars(combined)
|
|
447
|
+
if (hidden.length > 0) {
|
|
448
|
+
const s = hidden.length > 3 ? 35 : 20
|
|
449
|
+
score += s
|
|
450
|
+
findings.push({
|
|
451
|
+
id: 'tp_hidden_chars',
|
|
452
|
+
name: `Hidden characters in tool metadata (${[...new Set(hidden.map(h => h.name))].join(', ')})`,
|
|
453
|
+
category: 'concealment',
|
|
454
|
+
score: s,
|
|
455
|
+
source: 'hidden_chars',
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 2. Tool-poisoning specific rules (description + parameters)
|
|
460
|
+
for (const rule of TOOL_POISONING_RULES) {
|
|
461
|
+
const inDesc = rule.pattern.test(description)
|
|
462
|
+
const inParam = !inDesc && rule.pattern.test(paramText)
|
|
463
|
+
if (inDesc || inParam) {
|
|
464
|
+
score += rule.riskScore
|
|
465
|
+
findings.push({
|
|
466
|
+
id: rule.id,
|
|
467
|
+
name: rule.name,
|
|
468
|
+
category: rule.category,
|
|
469
|
+
score: rule.riskScore,
|
|
470
|
+
source: inDesc ? 'description' : 'parameter',
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// 3. Generic prompt-injection patterns reused on the description
|
|
476
|
+
for (const rule of this.compiledRules) {
|
|
477
|
+
if (rule.compiled.test(combined)) {
|
|
478
|
+
score += rule.riskScore
|
|
479
|
+
findings.push({
|
|
480
|
+
id: rule.id,
|
|
481
|
+
name: rule.name,
|
|
482
|
+
category: rule.category,
|
|
483
|
+
score: rule.riskScore,
|
|
484
|
+
source: 'description',
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const safe = score < threshold
|
|
490
|
+
if (!safe) {
|
|
491
|
+
this.log.write({
|
|
492
|
+
level: score >= 80 ? 'CRITICAL' : 'HIGH',
|
|
493
|
+
layer: 'L4',
|
|
494
|
+
action: this.config.mode === 'enforce' ? 'block' : 'detect',
|
|
495
|
+
detail: this.locale === 'zh'
|
|
496
|
+
? `检测到 MCP 工具投毒: ${tool.name}\n风险评分: ${score}\n命中: ${findings.map(f => f.name).join('; ')}`
|
|
497
|
+
: `MCP tool poisoning detected: ${tool.name}\nRisk score: ${score}\nMatched: ${findings.map(f => f.name).join('; ')}`,
|
|
498
|
+
tool: tool.name,
|
|
499
|
+
pattern: 'tool_poisoning',
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return { toolName: tool.name, safe, score, threshold, findings, hiddenChars: hidden.length }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Scan a list of MCP tool definitions; returns only the unsafe ones. */
|
|
507
|
+
scanToolDefinitions(tools: McpToolDefinition[], options?: { threshold?: number }): ToolPoisoningResult[] {
|
|
508
|
+
return tools.map(t => this.scanToolDefinition(t, options)).filter(r => !r.safe)
|
|
509
|
+
}
|
|
510
|
+
|
|
355
511
|
// ========== L5: Security Gate ==========
|
|
356
512
|
|
|
357
513
|
checkAction(action: string, details: string): CheckResult {
|
|
514
|
+
action = asString(action)
|
|
515
|
+
details = asString(details)
|
|
358
516
|
if (action === 'exec' || action === 'shell') {
|
|
359
517
|
return this.checkCommand(details)
|
|
360
518
|
}
|
|
@@ -377,20 +535,13 @@ export class ShellWard {
|
|
|
377
535
|
return { allowed: false, level: 'CRITICAL', reason, ruleId: 'no_payment' }
|
|
378
536
|
}
|
|
379
537
|
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
level: 'CRITICAL',
|
|
388
|
-
layer: 'L5',
|
|
389
|
-
action: 'block',
|
|
390
|
-
detail: `Gate denied (DLP): ${action}`,
|
|
391
|
-
pattern: 'gate_data_exfil',
|
|
392
|
-
})
|
|
393
|
-
return { allowed: false, level: 'CRITICAL', reason, ruleId: 'gate_data_exfil' }
|
|
538
|
+
// Outbound actions: delegate the DLP decision to the canonical data-flow
|
|
539
|
+
// guard (L7) so the Gate and the Outbound Guard can never diverge. The set
|
|
540
|
+
// of outbound tools (incl. http_post/curl_post + any customRules) lives in
|
|
541
|
+
// one place: this.outboundTools, consulted by checkOutbound.
|
|
542
|
+
if (this.outboundTools.has(action.toLowerCase())) {
|
|
543
|
+
const dlp = this.checkOutbound(action, details ? { body: details } : {})
|
|
544
|
+
if (!dlp.allowed) return dlp
|
|
394
545
|
}
|
|
395
546
|
|
|
396
547
|
this.log.write({
|
|
@@ -405,6 +556,7 @@ export class ShellWard {
|
|
|
405
556
|
// ========== L6: Response Checker ==========
|
|
406
557
|
|
|
407
558
|
checkResponse(content: string): ResponseCheckResult {
|
|
559
|
+
content = asString(content)
|
|
408
560
|
const canaryLeak = this._canaryToken ? content.includes(this._canaryToken) : false
|
|
409
561
|
|
|
410
562
|
if (canaryLeak) {
|
|
@@ -419,7 +571,7 @@ export class ShellWard {
|
|
|
419
571
|
})
|
|
420
572
|
}
|
|
421
573
|
|
|
422
|
-
const [, findings] = redactSensitive(content)
|
|
574
|
+
const [, findings] = redactSensitive(content, this.customSensitive)
|
|
423
575
|
const hasSensitiveData = findings.length > 0
|
|
424
576
|
const summary = findings.map(f => `${f.name}(${f.count})`).join(', ')
|
|
425
577
|
|
|
@@ -455,7 +607,7 @@ export class ShellWard {
|
|
|
455
607
|
}
|
|
456
608
|
|
|
457
609
|
trackFileRead(toolName: string, path: string): void {
|
|
458
|
-
for (const hp of
|
|
610
|
+
for (const hp of this.honeypots) {
|
|
459
611
|
if (hp.test(path)) {
|
|
460
612
|
this.log.write({
|
|
461
613
|
level: 'CRITICAL',
|
|
@@ -493,8 +645,9 @@ export class ShellWard {
|
|
|
493
645
|
}
|
|
494
646
|
|
|
495
647
|
checkOutbound(toolName: string, params: Record<string, any>): CheckResult {
|
|
496
|
-
|
|
497
|
-
const
|
|
648
|
+
params = (params && typeof params === 'object') ? params : {}
|
|
649
|
+
const toolLower = asString(toolName).toLowerCase()
|
|
650
|
+
const isOutbound = this.outboundTools.has(toolLower)
|
|
498
651
|
const isDualUse = DUAL_USE_TOOLS.has(toolLower)
|
|
499
652
|
const enforce = this.config.mode === 'enforce'
|
|
500
653
|
|
|
@@ -612,6 +765,7 @@ export class ShellWard {
|
|
|
612
765
|
|
|
613
766
|
extractTextFields(args: Record<string, any>): string[] {
|
|
614
767
|
const results: string[] = []
|
|
768
|
+
if (!args || typeof args !== 'object') return results
|
|
615
769
|
for (const field of TEXT_FIELDS) {
|
|
616
770
|
if (typeof args[field] === 'string' && args[field].length > 0) {
|
|
617
771
|
results.push(args[field])
|
|
@@ -656,7 +810,33 @@ function mergeConfig(userConfig?: Partial<ShellWardConfig>): ShellWardConfig {
|
|
|
656
810
|
injectionThreshold: threshold,
|
|
657
811
|
autoCheckOnStartup,
|
|
658
812
|
layers: { ...DEFAULT_CONFIG.layers, ...(userConfig.layers || {}) },
|
|
813
|
+
...(userConfig.customRules ? { customRules: userConfig.customRules } : {}),
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Compile a list of regex-source strings; invalid ones are skipped. */
|
|
818
|
+
function compileRegexList(sources: string[]): RegExp[] {
|
|
819
|
+
const out: RegExp[] = []
|
|
820
|
+
for (const src of sources) {
|
|
821
|
+
try { out.push(new RegExp(src, 'i')) } catch { /* skip invalid */ }
|
|
822
|
+
}
|
|
823
|
+
return out
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** Compile user dangerous-command rules; invalid regexes are skipped. */
|
|
827
|
+
function compileDangerousRules(rules: { id: string; pattern: string; flags?: string; description?: string }[]): DangerousCommandRule[] {
|
|
828
|
+
const out: DangerousCommandRule[] = []
|
|
829
|
+
for (const r of rules) {
|
|
830
|
+
try {
|
|
831
|
+
out.push({
|
|
832
|
+
id: r.id,
|
|
833
|
+
pattern: new RegExp(r.pattern, r.flags || 'i'),
|
|
834
|
+
description_zh: r.description || r.id,
|
|
835
|
+
description_en: r.description || r.id,
|
|
836
|
+
})
|
|
837
|
+
} catch { /* skip invalid */ }
|
|
659
838
|
}
|
|
839
|
+
return out
|
|
660
840
|
}
|
|
661
841
|
|
|
662
842
|
function normalizePath(p: string): string {
|
|
@@ -670,6 +850,65 @@ function truncate(s: string, max: number): string {
|
|
|
670
850
|
return s.length > max ? s.slice(0, max) + '...' : s
|
|
671
851
|
}
|
|
672
852
|
|
|
853
|
+
/**
|
|
854
|
+
* Defensive coercion at public API boundaries: a security check must fail safe
|
|
855
|
+
* on hostile/garbage input, never throw. null/undefined → '', everything else
|
|
856
|
+
* is stringified.
|
|
857
|
+
*/
|
|
858
|
+
function asString(v: unknown): string {
|
|
859
|
+
if (typeof v === 'string') return v
|
|
860
|
+
if (v == null) return ''
|
|
861
|
+
try { return String(v) } catch { return '' }
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Defeat shell-quote obfuscation for DETECTION (not execution): strip empty
|
|
866
|
+
* quote pairs so `r''m -rf /` and `r""m -rf /` normalize to `rm -rf /`.
|
|
867
|
+
* Deliberately conservative — non-empty quoted arguments (echo "rm -rf /")
|
|
868
|
+
* are left intact to avoid false positives. Runs a few passes for r''''m.
|
|
869
|
+
*/
|
|
870
|
+
function normalizeCommand(cmd: string): string {
|
|
871
|
+
let prev = cmd
|
|
872
|
+
for (let i = 0; i < 4; i++) {
|
|
873
|
+
const next = prev.replace(/''|""/g, '')
|
|
874
|
+
if (next === prev) break
|
|
875
|
+
prev = next
|
|
876
|
+
}
|
|
877
|
+
return prev
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Recursively collect all `description`/`title` string values out of a JSON
|
|
882
|
+
* Schema (an MCP tool's inputSchema), so poisoning hidden in a nested
|
|
883
|
+
* parameter description is scanned too. Bounded to avoid pathological schemas.
|
|
884
|
+
*/
|
|
885
|
+
function collectSchemaText(schema: unknown, depth = 0): string {
|
|
886
|
+
if (!schema || typeof schema !== 'object' || depth > 6) return ''
|
|
887
|
+
const out: string[] = []
|
|
888
|
+
for (const [key, val] of Object.entries(schema as Record<string, unknown>)) {
|
|
889
|
+
if ((key === 'description' || key === 'title') && typeof val === 'string') {
|
|
890
|
+
out.push(val)
|
|
891
|
+
} else if (val && typeof val === 'object') {
|
|
892
|
+
out.push(collectSchemaText(val, depth + 1))
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return out.join('\n')
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Remove all invisible/zero-width characters (the HIDDEN_CHAR_RANGES). */
|
|
899
|
+
function stripInvisible(text: string): string {
|
|
900
|
+
let out = ''
|
|
901
|
+
for (const char of text) {
|
|
902
|
+
const cp = char.codePointAt(0)!
|
|
903
|
+
let hidden = false
|
|
904
|
+
for (const [start, end] of HIDDEN_CHAR_RANGES) {
|
|
905
|
+
if (cp >= start && cp <= end) { hidden = true; break }
|
|
906
|
+
}
|
|
907
|
+
if (!hidden) out += char
|
|
908
|
+
}
|
|
909
|
+
return out
|
|
910
|
+
}
|
|
911
|
+
|
|
673
912
|
function detectHiddenChars(text: string): { char: string; codePoint: number; name: string }[] {
|
|
674
913
|
const found: { char: string; codePoint: number; name: string }[] = []
|
|
675
914
|
for (const char of text) {
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
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
9
|
|
|
10
|
+
import { readFileSync } from 'fs'
|
|
11
|
+
import { fileURLToPath } from 'url'
|
|
12
|
+
import { dirname, join } from 'path'
|
|
10
13
|
import { ShellWard } from './core/engine.js'
|
|
11
14
|
import { setupPromptGuard } from './layers/prompt-guard.js'
|
|
12
15
|
import { setupOutputScanner } from './layers/output-scanner.js'
|
|
@@ -20,12 +23,29 @@ import { registerAllCommands } from './commands/index.js'
|
|
|
20
23
|
import { checkForUpdate } from './update-check.js'
|
|
21
24
|
import { runAutoCheckOnStartup } from './auto-check.js'
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
// Single source of truth: read version from package.json at load time.
|
|
27
|
+
// dist/index.js → ../package.json (package.json is shipped via "files").
|
|
28
|
+
const CURRENT_VERSION: string = (() => {
|
|
29
|
+
try {
|
|
30
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
31
|
+
const pkg = JSON.parse(readFileSync(join(here, '../package.json'), 'utf8'))
|
|
32
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0'
|
|
33
|
+
} catch {
|
|
34
|
+
return '0.0.0'
|
|
35
|
+
}
|
|
36
|
+
})()
|
|
24
37
|
|
|
25
38
|
// Re-export core engine for SDK usage
|
|
26
39
|
export { ShellWard } from './core/engine.js'
|
|
27
|
-
export type {
|
|
28
|
-
|
|
40
|
+
export type {
|
|
41
|
+
CheckResult, ScanResult, InjectionResult, ResponseCheckResult,
|
|
42
|
+
McpToolDefinition, ToolPoisoningResult, ToolPoisoningFinding,
|
|
43
|
+
} from './core/engine.js'
|
|
44
|
+
export { McpBaseline } from './mcp-baseline.js'
|
|
45
|
+
export type { RugPullResult, RugPullStatus } from './mcp-baseline.js'
|
|
46
|
+
export type {
|
|
47
|
+
ShellWardConfig, CustomRules, CustomSensitivePattern, CustomCommandRule,
|
|
48
|
+
} from './types.js'
|
|
29
49
|
|
|
30
50
|
/**
|
|
31
51
|
* Wrap api.on so every hook handler gets try-catch protection.
|
|
@@ -120,8 +140,8 @@ export default {
|
|
|
120
140
|
|
|
121
141
|
// === Slash Commands ===
|
|
122
142
|
if (api.registerCommand) {
|
|
123
|
-
registerAllCommands(api, guard.config)
|
|
124
|
-
api.logger.info(
|
|
143
|
+
const commandCount = registerAllCommands(api, guard.config)
|
|
144
|
+
api.logger.info(`[ShellWard] ${commandCount} commands registered`)
|
|
125
145
|
}
|
|
126
146
|
|
|
127
147
|
const allLayers = ['promptGuard', 'outputScanner', 'toolBlocker', 'inputAuditor', 'securityGate', 'outboundGuard', 'dataFlowGuard', 'sessionGuard']
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
|
|
9
|
+
import { createHash } from 'crypto'
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
11
|
+
import { dirname, join } from 'path'
|
|
12
|
+
import { getHomeDir } from './utils.js'
|
|
13
|
+
import type { McpToolDefinition } from './core/engine.js'
|
|
14
|
+
|
|
15
|
+
export type RugPullStatus = 'new' | 'unchanged' | 'changed'
|
|
16
|
+
|
|
17
|
+
export interface RugPullResult {
|
|
18
|
+
key: string
|
|
19
|
+
status: RugPullStatus
|
|
20
|
+
currentHash: string
|
|
21
|
+
previousHash?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface BaselineEntry {
|
|
25
|
+
hash: string
|
|
26
|
+
name: string
|
|
27
|
+
ts: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_PATH = join(getHomeDir(), '.openclaw', 'shellward', 'mcp-baseline.json')
|
|
31
|
+
|
|
32
|
+
export class McpBaseline {
|
|
33
|
+
private readonly path: string
|
|
34
|
+
private store: Record<string, BaselineEntry>
|
|
35
|
+
|
|
36
|
+
/** @param filePath override the baseline file (tests pass a temp path). */
|
|
37
|
+
constructor(filePath?: string) {
|
|
38
|
+
this.path = filePath || DEFAULT_PATH
|
|
39
|
+
this.store = this.load()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Fingerprint a tool's externally-visible contract (description + schema). */
|
|
43
|
+
private fingerprint(tool: McpToolDefinition): string {
|
|
44
|
+
const canonical = JSON.stringify({
|
|
45
|
+
description: tool.description || '',
|
|
46
|
+
inputSchema: tool.inputSchema ?? null,
|
|
47
|
+
})
|
|
48
|
+
return createHash('sha256').update(canonical).digest('hex')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Stable key for a tool, namespaced by its server. */
|
|
52
|
+
static keyFor(server: string, toolName: string): string {
|
|
53
|
+
return `${server}::${toolName}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Compare against the stored baseline WITHOUT persisting. */
|
|
57
|
+
diff(key: string, tool: McpToolDefinition): RugPullResult {
|
|
58
|
+
const currentHash = this.fingerprint(tool)
|
|
59
|
+
const prev = this.store[key]
|
|
60
|
+
if (!prev) return { key, status: 'new', currentHash }
|
|
61
|
+
return {
|
|
62
|
+
key,
|
|
63
|
+
status: prev.hash === currentHash ? 'unchanged' : 'changed',
|
|
64
|
+
currentHash,
|
|
65
|
+
previousHash: prev.hash,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Compare, then update the in-memory baseline. Call save() to persist. */
|
|
70
|
+
record(key: string, tool: McpToolDefinition): RugPullResult {
|
|
71
|
+
const res = this.diff(key, tool)
|
|
72
|
+
this.store[key] = { hash: res.currentHash, name: tool.name, ts: new Date().toISOString() }
|
|
73
|
+
return res
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Number of tracked tools. */
|
|
77
|
+
get size(): number {
|
|
78
|
+
return Object.keys(this.store).length
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private load(): Record<string, BaselineEntry> {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(readFileSync(this.path, 'utf8'))
|
|
84
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
85
|
+
} catch {
|
|
86
|
+
return {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Flush the baseline to disk (owner-only perms). Never throws. */
|
|
91
|
+
save(): void {
|
|
92
|
+
try {
|
|
93
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
|
|
94
|
+
writeFileSync(this.path, JSON.stringify(this.store, null, 2), { mode: 0o600 })
|
|
95
|
+
} catch { /* best-effort; baselining must not break the host */ }
|
|
96
|
+
}
|
|
97
|
+
}
|