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.
Files changed (41) hide show
  1. package/README.md +95 -30
  2. package/dist/auto-check.d.ts +1 -0
  3. package/dist/auto-check.js +12 -1
  4. package/dist/commands/index.d.ts +2 -1
  5. package/dist/commands/index.js +7 -0
  6. package/dist/commands/scan-mcp.d.ts +2 -0
  7. package/dist/commands/scan-mcp.js +105 -0
  8. package/dist/core/engine.d.ts +35 -0
  9. package/dist/core/engine.js +225 -30
  10. package/dist/index.d.ts +4 -2
  11. package/dist/index.js +18 -3
  12. package/dist/mcp-baseline.d.ts +27 -0
  13. package/dist/mcp-baseline.js +73 -0
  14. package/dist/mcp-client.d.ts +29 -0
  15. package/dist/mcp-client.js +264 -0
  16. package/dist/mcp-server.js +64 -9
  17. package/dist/rules/dangerous-commands.js +6 -2
  18. package/dist/rules/injection-en.js +27 -2
  19. package/dist/rules/injection-zh.js +27 -4
  20. package/dist/rules/sensitive-patterns.d.ts +13 -1
  21. package/dist/rules/sensitive-patterns.js +32 -5
  22. package/dist/rules/tool-poisoning.d.ts +8 -0
  23. package/dist/rules/tool-poisoning.js +96 -0
  24. package/dist/types.d.ts +32 -0
  25. package/dist/types.js +3 -1
  26. package/package.json +4 -2
  27. package/server.json +2 -2
  28. package/src/auto-check.ts +11 -1
  29. package/src/commands/index.ts +9 -1
  30. package/src/commands/scan-mcp.ts +118 -0
  31. package/src/core/engine.ts +250 -31
  32. package/src/index.ts +25 -5
  33. package/src/mcp-baseline.ts +97 -0
  34. package/src/mcp-client.ts +268 -0
  35. package/src/mcp-server.ts +71 -9
  36. package/src/rules/dangerous-commands.ts +6 -2
  37. package/src/rules/injection-en.ts +27 -2
  38. package/src/rules/injection-zh.ts +27 -4
  39. package/src/rules/sensitive-patterns.ts +37 -5
  40. package/src/rules/tool-poisoning.ts +108 -0
  41. package/src/types.ts +38 -1
@@ -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 allRules = [...INJECTION_RULES_ZH, ...INJECTION_RULES_EN]
179
- this.compiledRules = allRules.map(rule => ({
180
- ...rule,
181
- compiled: new RegExp(rule.pattern, rule.flags || 'i'),
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
- if (BLOCKED_TOOLS.has(toolLower)) {
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 (SENSITIVE_TOOLS.has(toolLower)) {
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
- for (const rule of DANGEROUS_COMMANDS) {
264
- if (rule.pattern.test(part)) {
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. "ig​nore 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
- if (toolName && LOW_RISK_TOOLS.has(toolName.toLowerCase())) {
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
- // Block outbound actions when sensitive data was recently accessed (DLP via Gate)
381
- const outboundActions = ['send_email', 'send_message', 'post_tweet', 'http_post', 'curl_post']
382
- if (outboundActions.includes(action) && this.hasSensitiveData) {
383
- const reason = this.locale === 'zh'
384
- ? `数据外泄拦截: 近期访问了敏感数据,禁止通过 ${action} 向外部发送`
385
- : `Data exfiltration blocked: sensitive data recently accessed, ${action} denied`
386
- this.log.write({
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 HONEYPOT_PATTERNS) {
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 = OUTBOUND_TOOLS.has(toolLower)
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
- const CURRENT_VERSION = '0.5.16'
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 { CheckResult, ScanResult, InjectionResult, ResponseCheckResult } from './core/engine.js'
28
- export type { ShellWardConfig } from './types.js'
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('[ShellWard] 6 commands registered')
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
+ }