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.
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 +255 -33
  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 +273 -34
  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,8 @@ export class ShellWard {
199
256
  // ========== L2: Data Scanner ==========
200
257
 
201
258
  scanData(text: string, toolName?: string): ScanResult {
202
- const [, findings] = redactSensitive(text)
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
- if (BLOCKED_TOOLS.has(toolLower)) {
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 (SENSITIVE_TOOLS.has(toolLower)) {
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
- for (const rule of DANGEROUS_COMMANDS) {
264
- if (rule.pattern.test(part)) {
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. "ig​nore 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
- if (toolName && LOW_RISK_TOOLS.has(toolName.toLowerCase())) {
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
- // 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' }
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 HONEYPOT_PATTERNS) {
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
- const toolLower = toolName.toLowerCase()
497
- const isOutbound = OUTBOUND_TOOLS.has(toolLower)
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
- 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
+ }