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/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,7 @@ export class ShellWard {
|
|
|
199
256
|
// ========== L2: Data Scanner ==========
|
|
200
257
|
|
|
201
258
|
scanData(text: string, toolName?: string): ScanResult {
|
|
202
|
-
const [, findings] = redactSensitive(text)
|
|
259
|
+
const [, findings] = redactSensitive(text, this.customSensitive)
|
|
203
260
|
const hasSensitiveData = findings.length > 0
|
|
204
261
|
const summary = findings.map(f => `${f.name}(${f.count})`).join(', ')
|
|
205
262
|
|
|
@@ -228,7 +285,10 @@ export class ShellWard {
|
|
|
228
285
|
const toolLower = toolName.toLowerCase()
|
|
229
286
|
const enforce = this.config.mode === 'enforce'
|
|
230
287
|
|
|
231
|
-
|
|
288
|
+
// allowedTools always wins — user-trusted tools bypass policy.
|
|
289
|
+
if (this.allowedTools.has(toolLower)) return { allowed: true }
|
|
290
|
+
|
|
291
|
+
if (this.blockedTools.has(toolLower)) {
|
|
232
292
|
const reason = this.locale === 'zh'
|
|
233
293
|
? `安全策略禁止自动执行: ${toolName}`
|
|
234
294
|
: `Blocked by security policy: ${toolName}`
|
|
@@ -242,7 +302,7 @@ export class ShellWard {
|
|
|
242
302
|
return { allowed: false, level: 'CRITICAL', reason }
|
|
243
303
|
}
|
|
244
304
|
|
|
245
|
-
if (
|
|
305
|
+
if (this.sensitiveTools.has(toolLower)) {
|
|
246
306
|
this.log.write({
|
|
247
307
|
level: 'MEDIUM',
|
|
248
308
|
layer: 'L3',
|
|
@@ -260,8 +320,12 @@ export class ShellWard {
|
|
|
260
320
|
const parts = splitCommands(cmd)
|
|
261
321
|
|
|
262
322
|
for (const part of parts) {
|
|
263
|
-
|
|
264
|
-
|
|
323
|
+
// Normalize shell-quote obfuscation (e.g. r''m / r""m → rm) before matching.
|
|
324
|
+
// Only empty quote pairs are stripped, so a real quoted arg like
|
|
325
|
+
// echo "rm -rf /" is untouched (no false positive).
|
|
326
|
+
const normalized = normalizeCommand(part)
|
|
327
|
+
for (const rule of [...DANGEROUS_COMMANDS, ...this.customDangerous]) {
|
|
328
|
+
if (rule.pattern.test(part) || rule.pattern.test(normalized)) {
|
|
265
329
|
const desc = this.locale === 'zh' ? rule.description_zh : rule.description_en
|
|
266
330
|
const reason = this.locale === 'zh'
|
|
267
331
|
? `检测到危险命令: ${truncate(part, 80)}\n原因: ${desc}`
|
|
@@ -321,10 +385,14 @@ export class ShellWard {
|
|
|
321
385
|
})
|
|
322
386
|
}
|
|
323
387
|
|
|
388
|
+
// Strip invisible characters before rule matching so an attacker can't break
|
|
389
|
+
// a pattern by interleaving zero-width spaces (e.g. "ignore previous").
|
|
390
|
+
const normText = hiddenChars.length > 0 ? stripInvisible(text) : text
|
|
391
|
+
|
|
324
392
|
let score = 0
|
|
325
393
|
const matched: { id: string; name: string; score: number }[] = []
|
|
326
394
|
for (const rule of this.compiledRules) {
|
|
327
|
-
if (rule.compiled.test(text)) {
|
|
395
|
+
if (rule.compiled.test(text) || (normText !== text && rule.compiled.test(normText))) {
|
|
328
396
|
score += rule.riskScore
|
|
329
397
|
matched.push({ id: rule.id, name: rule.name, score: rule.riskScore })
|
|
330
398
|
}
|
|
@@ -346,12 +414,96 @@ export class ShellWard {
|
|
|
346
414
|
}
|
|
347
415
|
|
|
348
416
|
getInjectionThreshold(toolName?: string): number {
|
|
349
|
-
|
|
417
|
+
const lower = toolName?.toLowerCase()
|
|
418
|
+
if (lower && (LOW_RISK_TOOLS.has(lower) || this.allowedTools.has(lower))) {
|
|
350
419
|
return Math.max(this.config.injectionThreshold, 80)
|
|
351
420
|
}
|
|
352
421
|
return this.config.injectionThreshold
|
|
353
422
|
}
|
|
354
423
|
|
|
424
|
+
// ========== L4b: MCP Tool-Poisoning Scanner ==========
|
|
425
|
+
//
|
|
426
|
+
// Inspects an MCP tool *definition* (not user input) for instructions hidden
|
|
427
|
+
// in its description / parameter descriptions — the "tool poisoning" attack.
|
|
428
|
+
// Reuses the injection engine + hidden-char detection and layers on rules
|
|
429
|
+
// tuned for tool-metadata attacks. Pure & side-effect-light: callable from
|
|
430
|
+
// the SDK, the MCP server, or at plugin tool-discovery time.
|
|
431
|
+
|
|
432
|
+
scanToolDefinition(tool: McpToolDefinition, options?: { threshold?: number }): ToolPoisoningResult {
|
|
433
|
+
const threshold = options?.threshold ?? 40
|
|
434
|
+
const findings: ToolPoisoningFinding[] = []
|
|
435
|
+
let score = 0
|
|
436
|
+
|
|
437
|
+
const description = typeof tool.description === 'string' ? tool.description : ''
|
|
438
|
+
const paramText = collectSchemaText(tool.inputSchema)
|
|
439
|
+
const combined = `${description}\n${paramText}`
|
|
440
|
+
|
|
441
|
+
// 1. Hidden / invisible characters anywhere in the metadata
|
|
442
|
+
const hidden = detectHiddenChars(combined)
|
|
443
|
+
if (hidden.length > 0) {
|
|
444
|
+
const s = hidden.length > 3 ? 35 : 20
|
|
445
|
+
score += s
|
|
446
|
+
findings.push({
|
|
447
|
+
id: 'tp_hidden_chars',
|
|
448
|
+
name: `Hidden characters in tool metadata (${[...new Set(hidden.map(h => h.name))].join(', ')})`,
|
|
449
|
+
category: 'concealment',
|
|
450
|
+
score: s,
|
|
451
|
+
source: 'hidden_chars',
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 2. Tool-poisoning specific rules (description + parameters)
|
|
456
|
+
for (const rule of TOOL_POISONING_RULES) {
|
|
457
|
+
const inDesc = rule.pattern.test(description)
|
|
458
|
+
const inParam = !inDesc && rule.pattern.test(paramText)
|
|
459
|
+
if (inDesc || inParam) {
|
|
460
|
+
score += rule.riskScore
|
|
461
|
+
findings.push({
|
|
462
|
+
id: rule.id,
|
|
463
|
+
name: rule.name,
|
|
464
|
+
category: rule.category,
|
|
465
|
+
score: rule.riskScore,
|
|
466
|
+
source: inDesc ? 'description' : 'parameter',
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 3. Generic prompt-injection patterns reused on the description
|
|
472
|
+
for (const rule of this.compiledRules) {
|
|
473
|
+
if (rule.compiled.test(combined)) {
|
|
474
|
+
score += rule.riskScore
|
|
475
|
+
findings.push({
|
|
476
|
+
id: rule.id,
|
|
477
|
+
name: rule.name,
|
|
478
|
+
category: rule.category,
|
|
479
|
+
score: rule.riskScore,
|
|
480
|
+
source: 'description',
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const safe = score < threshold
|
|
486
|
+
if (!safe) {
|
|
487
|
+
this.log.write({
|
|
488
|
+
level: score >= 80 ? 'CRITICAL' : 'HIGH',
|
|
489
|
+
layer: 'L4',
|
|
490
|
+
action: this.config.mode === 'enforce' ? 'block' : 'detect',
|
|
491
|
+
detail: this.locale === 'zh'
|
|
492
|
+
? `检测到 MCP 工具投毒: ${tool.name}\n风险评分: ${score}\n命中: ${findings.map(f => f.name).join('; ')}`
|
|
493
|
+
: `MCP tool poisoning detected: ${tool.name}\nRisk score: ${score}\nMatched: ${findings.map(f => f.name).join('; ')}`,
|
|
494
|
+
tool: tool.name,
|
|
495
|
+
pattern: 'tool_poisoning',
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return { toolName: tool.name, safe, score, threshold, findings, hiddenChars: hidden.length }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Scan a list of MCP tool definitions; returns only the unsafe ones. */
|
|
503
|
+
scanToolDefinitions(tools: McpToolDefinition[], options?: { threshold?: number }): ToolPoisoningResult[] {
|
|
504
|
+
return tools.map(t => this.scanToolDefinition(t, options)).filter(r => !r.safe)
|
|
505
|
+
}
|
|
506
|
+
|
|
355
507
|
// ========== L5: Security Gate ==========
|
|
356
508
|
|
|
357
509
|
checkAction(action: string, details: string): CheckResult {
|
|
@@ -377,20 +529,13 @@ export class ShellWard {
|
|
|
377
529
|
return { allowed: false, level: 'CRITICAL', reason, ruleId: 'no_payment' }
|
|
378
530
|
}
|
|
379
531
|
|
|
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' }
|
|
532
|
+
// Outbound actions: delegate the DLP decision to the canonical data-flow
|
|
533
|
+
// guard (L7) so the Gate and the Outbound Guard can never diverge. The set
|
|
534
|
+
// of outbound tools (incl. http_post/curl_post + any customRules) lives in
|
|
535
|
+
// one place: this.outboundTools, consulted by checkOutbound.
|
|
536
|
+
if (this.outboundTools.has(action.toLowerCase())) {
|
|
537
|
+
const dlp = this.checkOutbound(action, details ? { body: details } : {})
|
|
538
|
+
if (!dlp.allowed) return dlp
|
|
394
539
|
}
|
|
395
540
|
|
|
396
541
|
this.log.write({
|
|
@@ -419,7 +564,7 @@ export class ShellWard {
|
|
|
419
564
|
})
|
|
420
565
|
}
|
|
421
566
|
|
|
422
|
-
const [, findings] = redactSensitive(content)
|
|
567
|
+
const [, findings] = redactSensitive(content, this.customSensitive)
|
|
423
568
|
const hasSensitiveData = findings.length > 0
|
|
424
569
|
const summary = findings.map(f => `${f.name}(${f.count})`).join(', ')
|
|
425
570
|
|
|
@@ -455,7 +600,7 @@ export class ShellWard {
|
|
|
455
600
|
}
|
|
456
601
|
|
|
457
602
|
trackFileRead(toolName: string, path: string): void {
|
|
458
|
-
for (const hp of
|
|
603
|
+
for (const hp of this.honeypots) {
|
|
459
604
|
if (hp.test(path)) {
|
|
460
605
|
this.log.write({
|
|
461
606
|
level: 'CRITICAL',
|
|
@@ -494,7 +639,7 @@ export class ShellWard {
|
|
|
494
639
|
|
|
495
640
|
checkOutbound(toolName: string, params: Record<string, any>): CheckResult {
|
|
496
641
|
const toolLower = toolName.toLowerCase()
|
|
497
|
-
const isOutbound =
|
|
642
|
+
const isOutbound = this.outboundTools.has(toolLower)
|
|
498
643
|
const isDualUse = DUAL_USE_TOOLS.has(toolLower)
|
|
499
644
|
const enforce = this.config.mode === 'enforce'
|
|
500
645
|
|
|
@@ -656,7 +801,33 @@ function mergeConfig(userConfig?: Partial<ShellWardConfig>): ShellWardConfig {
|
|
|
656
801
|
injectionThreshold: threshold,
|
|
657
802
|
autoCheckOnStartup,
|
|
658
803
|
layers: { ...DEFAULT_CONFIG.layers, ...(userConfig.layers || {}) },
|
|
804
|
+
...(userConfig.customRules ? { customRules: userConfig.customRules } : {}),
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Compile a list of regex-source strings; invalid ones are skipped. */
|
|
809
|
+
function compileRegexList(sources: string[]): RegExp[] {
|
|
810
|
+
const out: RegExp[] = []
|
|
811
|
+
for (const src of sources) {
|
|
812
|
+
try { out.push(new RegExp(src, 'i')) } catch { /* skip invalid */ }
|
|
659
813
|
}
|
|
814
|
+
return out
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Compile user dangerous-command rules; invalid regexes are skipped. */
|
|
818
|
+
function compileDangerousRules(rules: { id: string; pattern: string; flags?: string; description?: string }[]): DangerousCommandRule[] {
|
|
819
|
+
const out: DangerousCommandRule[] = []
|
|
820
|
+
for (const r of rules) {
|
|
821
|
+
try {
|
|
822
|
+
out.push({
|
|
823
|
+
id: r.id,
|
|
824
|
+
pattern: new RegExp(r.pattern, r.flags || 'i'),
|
|
825
|
+
description_zh: r.description || r.id,
|
|
826
|
+
description_en: r.description || r.id,
|
|
827
|
+
})
|
|
828
|
+
} catch { /* skip invalid */ }
|
|
829
|
+
}
|
|
830
|
+
return out
|
|
660
831
|
}
|
|
661
832
|
|
|
662
833
|
function normalizePath(p: string): string {
|
|
@@ -670,6 +841,54 @@ function truncate(s: string, max: number): string {
|
|
|
670
841
|
return s.length > max ? s.slice(0, max) + '...' : s
|
|
671
842
|
}
|
|
672
843
|
|
|
844
|
+
/**
|
|
845
|
+
* Defeat shell-quote obfuscation for DETECTION (not execution): strip empty
|
|
846
|
+
* quote pairs so `r''m -rf /` and `r""m -rf /` normalize to `rm -rf /`.
|
|
847
|
+
* Deliberately conservative — non-empty quoted arguments (echo "rm -rf /")
|
|
848
|
+
* are left intact to avoid false positives. Runs a few passes for r''''m.
|
|
849
|
+
*/
|
|
850
|
+
function normalizeCommand(cmd: string): string {
|
|
851
|
+
let prev = cmd
|
|
852
|
+
for (let i = 0; i < 4; i++) {
|
|
853
|
+
const next = prev.replace(/''|""/g, '')
|
|
854
|
+
if (next === prev) break
|
|
855
|
+
prev = next
|
|
856
|
+
}
|
|
857
|
+
return prev
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Recursively collect all `description`/`title` string values out of a JSON
|
|
862
|
+
* Schema (an MCP tool's inputSchema), so poisoning hidden in a nested
|
|
863
|
+
* parameter description is scanned too. Bounded to avoid pathological schemas.
|
|
864
|
+
*/
|
|
865
|
+
function collectSchemaText(schema: unknown, depth = 0): string {
|
|
866
|
+
if (!schema || typeof schema !== 'object' || depth > 6) return ''
|
|
867
|
+
const out: string[] = []
|
|
868
|
+
for (const [key, val] of Object.entries(schema as Record<string, unknown>)) {
|
|
869
|
+
if ((key === 'description' || key === 'title') && typeof val === 'string') {
|
|
870
|
+
out.push(val)
|
|
871
|
+
} else if (val && typeof val === 'object') {
|
|
872
|
+
out.push(collectSchemaText(val, depth + 1))
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return out.join('\n')
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Remove all invisible/zero-width characters (the HIDDEN_CHAR_RANGES). */
|
|
879
|
+
function stripInvisible(text: string): string {
|
|
880
|
+
let out = ''
|
|
881
|
+
for (const char of text) {
|
|
882
|
+
const cp = char.codePointAt(0)!
|
|
883
|
+
let hidden = false
|
|
884
|
+
for (const [start, end] of HIDDEN_CHAR_RANGES) {
|
|
885
|
+
if (cp >= start && cp <= end) { hidden = true; break }
|
|
886
|
+
}
|
|
887
|
+
if (!hidden) out += char
|
|
888
|
+
}
|
|
889
|
+
return out
|
|
890
|
+
}
|
|
891
|
+
|
|
673
892
|
function detectHiddenChars(text: string): { char: string; codePoint: number; name: string }[] {
|
|
674
893
|
const found: { char: string; codePoint: number; name: string }[] = []
|
|
675
894
|
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
|
+
}
|