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
@@ -8,10 +8,11 @@ import { randomBytes } from 'crypto';
8
8
  import { resolve } from 'path';
9
9
  import { homedir } from 'os';
10
10
  import { DANGEROUS_COMMANDS, splitCommands } from '../rules/dangerous-commands.js';
11
+ import { TOOL_POISONING_RULES } from '../rules/tool-poisoning.js';
11
12
  import { PROTECTED_PATHS } from '../rules/protected-paths.js';
12
13
  import { INJECTION_RULES_ZH } from '../rules/injection-zh.js';
13
14
  import { INJECTION_RULES_EN } from '../rules/injection-en.js';
14
- import { redactSensitive } from '../rules/sensitive-patterns.js';
15
+ import { redactSensitive, compileSensitivePatterns } from '../rules/sensitive-patterns.js';
15
16
  import { AuditLog } from '../audit-log.js';
16
17
  import { resolveLocale, DEFAULT_CONFIG } from '../types.js';
17
18
  // ===== Constants =====
@@ -27,6 +28,7 @@ const EXEC_TOOLS = new Set([
27
28
  ]);
28
29
  const OUTBOUND_TOOLS = new Set([
29
30
  'send_email', 'send_message', 'post_tweet', 'message', 'sessions_send',
31
+ 'http_post', 'curl_post',
30
32
  ]);
31
33
  const DUAL_USE_TOOLS = new Set([
32
34
  'web_fetch', 'http_request',
@@ -57,6 +59,12 @@ const HIDDEN_CHAR_RANGES = [
57
59
  [0xFEFF, 0xFEFF, 'BOM/Zero-width no-break'],
58
60
  [0x00AD, 0x00AD, 'Soft hyphen'],
59
61
  [0xFFF9, 0xFFFB, 'Interlinear annotation'],
62
+ // Variation selectors — abused to smuggle hidden bytes/instructions
63
+ [0xFE00, 0xFE0F, 'Variation selector'],
64
+ [0xE0100, 0xE01EF, 'Variation selector supplement'],
65
+ // Unicode Tag characters — the primary "invisible prompt injection" vector
66
+ [0xE0001, 0xE0001, 'Language tag'],
67
+ [0xE0020, 0xE007F, 'Tag character'],
60
68
  ];
61
69
  const TEXT_FIELDS = [
62
70
  'content', 'body', 'text', 'message', 'query',
@@ -110,6 +118,14 @@ export class ShellWard {
110
118
  log;
111
119
  _canaryToken;
112
120
  compiledRules;
121
+ // Tool policy sets — built-ins merged with config.customRules (allowedTools wins).
122
+ blockedTools;
123
+ allowedTools;
124
+ sensitiveTools;
125
+ outboundTools;
126
+ honeypots;
127
+ customSensitive;
128
+ customDangerous;
113
129
  sensitiveReads = new Map();
114
130
  TRACKING_WINDOW_MS = 5 * 60 * 1000;
115
131
  MAX_TRACKED_READS = 500;
@@ -118,11 +134,31 @@ export class ShellWard {
118
134
  this.locale = resolveLocale(this.config);
119
135
  this.log = new AuditLog(this.config);
120
136
  this._canaryToken = 'SW-' + randomBytes(8).toString('hex');
121
- const allRules = [...INJECTION_RULES_ZH, ...INJECTION_RULES_EN];
122
- this.compiledRules = allRules.map(rule => ({
123
- ...rule,
124
- compiled: new RegExp(rule.pattern, rule.flags || 'i'),
125
- }));
137
+ const custom = this.config.customRules || {};
138
+ const lower = (s) => s.toLowerCase();
139
+ this.allowedTools = new Set((custom.allowedTools || []).map(lower));
140
+ this.blockedTools = new Set([...BLOCKED_TOOLS, ...(custom.blockedTools || []).map(lower)]);
141
+ this.sensitiveTools = new Set([...SENSITIVE_TOOLS, ...(custom.sensitiveTools || []).map(lower)]);
142
+ this.outboundTools = new Set([...OUTBOUND_TOOLS, ...(custom.outboundTools || []).map(lower)]);
143
+ // allowedTools always wins — strip them from the block/sensitive sets.
144
+ for (const t of this.allowedTools) {
145
+ this.blockedTools.delete(t);
146
+ this.sensitiveTools.delete(t);
147
+ }
148
+ this.honeypots = [...HONEYPOT_PATTERNS, ...compileRegexList(custom.honeypotPaths || [])];
149
+ this.customSensitive = compileSensitivePatterns(custom.sensitivePatterns || []);
150
+ this.customDangerous = compileDangerousRules(custom.dangerousCommands || []);
151
+ const allRules = [...INJECTION_RULES_ZH, ...INJECTION_RULES_EN, ...(custom.injectionRules || [])];
152
+ this.compiledRules = allRules
153
+ .map(rule => {
154
+ try {
155
+ return { ...rule, compiled: new RegExp(rule.pattern, rule.flags || 'i') };
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ })
161
+ .filter((r) => r !== null);
126
162
  }
127
163
  // ========== L1: Prompt Guard ==========
128
164
  getSecurityPrompt() {
@@ -137,7 +173,8 @@ export class ShellWard {
137
173
  }
138
174
  // ========== L2: Data Scanner ==========
139
175
  scanData(text, toolName) {
140
- const [, findings] = redactSensitive(text);
176
+ text = asString(text);
177
+ const [, findings] = redactSensitive(text, this.customSensitive);
141
178
  const hasSensitiveData = findings.length > 0;
142
179
  const summary = findings.map(f => `${f.name}(${f.count})`).join(', ');
143
180
  if (hasSensitiveData) {
@@ -159,9 +196,12 @@ export class ShellWard {
159
196
  }
160
197
  // ========== L3: Tool & Command Checker ==========
161
198
  checkTool(toolName) {
162
- const toolLower = toolName.toLowerCase();
199
+ const toolLower = asString(toolName).toLowerCase();
163
200
  const enforce = this.config.mode === 'enforce';
164
- if (BLOCKED_TOOLS.has(toolLower)) {
201
+ // allowedTools always wins — user-trusted tools bypass policy.
202
+ if (this.allowedTools.has(toolLower))
203
+ return { allowed: true };
204
+ if (this.blockedTools.has(toolLower)) {
165
205
  const reason = this.locale === 'zh'
166
206
  ? `安全策略禁止自动执行: ${toolName}`
167
207
  : `Blocked by security policy: ${toolName}`;
@@ -174,7 +214,7 @@ export class ShellWard {
174
214
  });
175
215
  return { allowed: false, level: 'CRITICAL', reason };
176
216
  }
177
- if (SENSITIVE_TOOLS.has(toolLower)) {
217
+ if (this.sensitiveTools.has(toolLower)) {
178
218
  this.log.write({
179
219
  level: 'MEDIUM',
180
220
  layer: 'L3',
@@ -187,10 +227,14 @@ export class ShellWard {
187
227
  }
188
228
  checkCommand(cmd, toolName) {
189
229
  const enforce = this.config.mode === 'enforce';
190
- const parts = splitCommands(cmd);
230
+ const parts = splitCommands(asString(cmd));
191
231
  for (const part of parts) {
192
- for (const rule of DANGEROUS_COMMANDS) {
193
- if (rule.pattern.test(part)) {
232
+ // Normalize shell-quote obfuscation (e.g. r''m / r""m → rm) before matching.
233
+ // Only empty quote pairs are stripped, so a real quoted arg like
234
+ // echo "rm -rf /" is untouched (no false positive).
235
+ const normalized = normalizeCommand(part);
236
+ for (const rule of [...DANGEROUS_COMMANDS, ...this.customDangerous]) {
237
+ if (rule.pattern.test(part) || rule.pattern.test(normalized)) {
194
238
  const desc = this.locale === 'zh' ? rule.description_zh : rule.description_en;
195
239
  const reason = this.locale === 'zh'
196
240
  ? `检测到危险命令: ${truncate(part, 80)}\n原因: ${desc}`
@@ -210,6 +254,7 @@ export class ShellWard {
210
254
  return { allowed: true };
211
255
  }
212
256
  checkPath(path, operation, toolName) {
257
+ path = asString(path);
213
258
  const enforce = this.config.mode === 'enforce';
214
259
  const normalizedPath = normalizePath(path);
215
260
  for (const rule of PROTECTED_PATHS) {
@@ -233,6 +278,7 @@ export class ShellWard {
233
278
  }
234
279
  // ========== L4: Injection Detection ==========
235
280
  checkInjection(text, options) {
281
+ text = asString(text);
236
282
  const threshold = options?.threshold ?? this.config.injectionThreshold;
237
283
  const enforce = this.config.mode === 'enforce';
238
284
  const hiddenChars = detectHiddenChars(text);
@@ -244,10 +290,13 @@ export class ShellWard {
244
290
  detail: `Hidden characters detected: ${[...new Set(hiddenChars.map(h => h.name))].join(', ')} (${hiddenChars.length} chars)`,
245
291
  });
246
292
  }
293
+ // Strip invisible characters before rule matching so an attacker can't break
294
+ // a pattern by interleaving zero-width spaces (e.g. "ig​nore previous").
295
+ const normText = hiddenChars.length > 0 ? stripInvisible(text) : text;
247
296
  let score = 0;
248
297
  const matched = [];
249
298
  for (const rule of this.compiledRules) {
250
- if (rule.compiled.test(text)) {
299
+ if (rule.compiled.test(text) || (normText !== text && rule.compiled.test(normText))) {
251
300
  score += rule.riskScore;
252
301
  matched.push({ id: rule.id, name: rule.name, score: rule.riskScore });
253
302
  }
@@ -267,13 +316,91 @@ export class ShellWard {
267
316
  return { safe: score < threshold, score, threshold, matched, hiddenChars: hiddenChars.length };
268
317
  }
269
318
  getInjectionThreshold(toolName) {
270
- if (toolName && LOW_RISK_TOOLS.has(toolName.toLowerCase())) {
319
+ const lower = toolName?.toLowerCase();
320
+ if (lower && (LOW_RISK_TOOLS.has(lower) || this.allowedTools.has(lower))) {
271
321
  return Math.max(this.config.injectionThreshold, 80);
272
322
  }
273
323
  return this.config.injectionThreshold;
274
324
  }
325
+ // ========== L4b: MCP Tool-Poisoning Scanner ==========
326
+ //
327
+ // Inspects an MCP tool *definition* (not user input) for instructions hidden
328
+ // in its description / parameter descriptions — the "tool poisoning" attack.
329
+ // Reuses the injection engine + hidden-char detection and layers on rules
330
+ // tuned for tool-metadata attacks. Pure & side-effect-light: callable from
331
+ // the SDK, the MCP server, or at plugin tool-discovery time.
332
+ scanToolDefinition(tool, options) {
333
+ tool = (tool && typeof tool === 'object') ? tool : { name: 'unknown' };
334
+ const threshold = options?.threshold ?? 40;
335
+ const findings = [];
336
+ let score = 0;
337
+ const description = typeof tool.description === 'string' ? tool.description : '';
338
+ const paramText = collectSchemaText(tool.inputSchema);
339
+ const combined = `${description}\n${paramText}`;
340
+ // 1. Hidden / invisible characters anywhere in the metadata
341
+ const hidden = detectHiddenChars(combined);
342
+ if (hidden.length > 0) {
343
+ const s = hidden.length > 3 ? 35 : 20;
344
+ score += s;
345
+ findings.push({
346
+ id: 'tp_hidden_chars',
347
+ name: `Hidden characters in tool metadata (${[...new Set(hidden.map(h => h.name))].join(', ')})`,
348
+ category: 'concealment',
349
+ score: s,
350
+ source: 'hidden_chars',
351
+ });
352
+ }
353
+ // 2. Tool-poisoning specific rules (description + parameters)
354
+ for (const rule of TOOL_POISONING_RULES) {
355
+ const inDesc = rule.pattern.test(description);
356
+ const inParam = !inDesc && rule.pattern.test(paramText);
357
+ if (inDesc || inParam) {
358
+ score += rule.riskScore;
359
+ findings.push({
360
+ id: rule.id,
361
+ name: rule.name,
362
+ category: rule.category,
363
+ score: rule.riskScore,
364
+ source: inDesc ? 'description' : 'parameter',
365
+ });
366
+ }
367
+ }
368
+ // 3. Generic prompt-injection patterns reused on the description
369
+ for (const rule of this.compiledRules) {
370
+ if (rule.compiled.test(combined)) {
371
+ score += rule.riskScore;
372
+ findings.push({
373
+ id: rule.id,
374
+ name: rule.name,
375
+ category: rule.category,
376
+ score: rule.riskScore,
377
+ source: 'description',
378
+ });
379
+ }
380
+ }
381
+ const safe = score < threshold;
382
+ if (!safe) {
383
+ this.log.write({
384
+ level: score >= 80 ? 'CRITICAL' : 'HIGH',
385
+ layer: 'L4',
386
+ action: this.config.mode === 'enforce' ? 'block' : 'detect',
387
+ detail: this.locale === 'zh'
388
+ ? `检测到 MCP 工具投毒: ${tool.name}\n风险评分: ${score}\n命中: ${findings.map(f => f.name).join('; ')}`
389
+ : `MCP tool poisoning detected: ${tool.name}\nRisk score: ${score}\nMatched: ${findings.map(f => f.name).join('; ')}`,
390
+ tool: tool.name,
391
+ pattern: 'tool_poisoning',
392
+ });
393
+ }
394
+ return { toolName: tool.name, safe, score, threshold, findings, hiddenChars: hidden.length };
395
+ }
396
+ /** Scan a list of MCP tool definitions; returns only the unsafe ones. */
397
+ scanToolDefinitions(tools, options) {
398
+ return tools.map(t => this.scanToolDefinition(t, options)).filter(r => !r.safe);
399
+ }
275
400
  // ========== L5: Security Gate ==========
276
401
  checkAction(action, details) {
402
+ action = asString(action);
403
+ details = asString(details);
277
404
  if (action === 'exec' || action === 'shell') {
278
405
  return this.checkCommand(details);
279
406
  }
@@ -293,20 +420,14 @@ export class ShellWard {
293
420
  });
294
421
  return { allowed: false, level: 'CRITICAL', reason, ruleId: 'no_payment' };
295
422
  }
296
- // Block outbound actions when sensitive data was recently accessed (DLP via Gate)
297
- const outboundActions = ['send_email', 'send_message', 'post_tweet', 'http_post', 'curl_post'];
298
- if (outboundActions.includes(action) && this.hasSensitiveData) {
299
- const reason = this.locale === 'zh'
300
- ? `数据外泄拦截: 近期访问了敏感数据,禁止通过 ${action} 向外部发送`
301
- : `Data exfiltration blocked: sensitive data recently accessed, ${action} denied`;
302
- this.log.write({
303
- level: 'CRITICAL',
304
- layer: 'L5',
305
- action: 'block',
306
- detail: `Gate denied (DLP): ${action}`,
307
- pattern: 'gate_data_exfil',
308
- });
309
- return { allowed: false, level: 'CRITICAL', reason, ruleId: 'gate_data_exfil' };
423
+ // Outbound actions: delegate the DLP decision to the canonical data-flow
424
+ // guard (L7) so the Gate and the Outbound Guard can never diverge. The set
425
+ // of outbound tools (incl. http_post/curl_post + any customRules) lives in
426
+ // one place: this.outboundTools, consulted by checkOutbound.
427
+ if (this.outboundTools.has(action.toLowerCase())) {
428
+ const dlp = this.checkOutbound(action, details ? { body: details } : {});
429
+ if (!dlp.allowed)
430
+ return dlp;
310
431
  }
311
432
  this.log.write({
312
433
  level: 'INFO',
@@ -318,6 +439,7 @@ export class ShellWard {
318
439
  }
319
440
  // ========== L6: Response Checker ==========
320
441
  checkResponse(content) {
442
+ content = asString(content);
321
443
  const canaryLeak = this._canaryToken ? content.includes(this._canaryToken) : false;
322
444
  if (canaryLeak) {
323
445
  this.log.write({
@@ -330,7 +452,7 @@ export class ShellWard {
330
452
  pattern: 'canary_leak',
331
453
  });
332
454
  }
333
- const [, findings] = redactSensitive(content);
455
+ const [, findings] = redactSensitive(content, this.customSensitive);
334
456
  const hasSensitiveData = findings.length > 0;
335
457
  const summary = findings.map(f => `${f.name}(${f.count})`).join(', ');
336
458
  if (hasSensitiveData) {
@@ -359,7 +481,7 @@ export class ShellWard {
359
481
  this.sensitiveReads.set(`pii-${Date.now()}-${toolName}`, { path: `[${toolName}: ${summary}]`, ts: Date.now() });
360
482
  }
361
483
  trackFileRead(toolName, path) {
362
- for (const hp of HONEYPOT_PATTERNS) {
484
+ for (const hp of this.honeypots) {
363
485
  if (hp.test(path)) {
364
486
  this.log.write({
365
487
  level: 'CRITICAL',
@@ -394,8 +516,9 @@ export class ShellWard {
394
516
  this.evictExpired();
395
517
  }
396
518
  checkOutbound(toolName, params) {
397
- const toolLower = toolName.toLowerCase();
398
- const isOutbound = OUTBOUND_TOOLS.has(toolLower);
519
+ params = (params && typeof params === 'object') ? params : {};
520
+ const toolLower = asString(toolName).toLowerCase();
521
+ const isOutbound = this.outboundTools.has(toolLower);
399
522
  const isDualUse = DUAL_USE_TOOLS.has(toolLower);
400
523
  const enforce = this.config.mode === 'enforce';
401
524
  this.evictExpired();
@@ -499,6 +622,8 @@ export class ShellWard {
499
622
  }
500
623
  extractTextFields(args) {
501
624
  const results = [];
625
+ if (!args || typeof args !== 'object')
626
+ return results;
502
627
  for (const field of TEXT_FIELDS) {
503
628
  if (typeof args[field] === 'string' && args[field].length > 0) {
504
629
  results.push(args[field]);
@@ -541,8 +666,36 @@ function mergeConfig(userConfig) {
541
666
  injectionThreshold: threshold,
542
667
  autoCheckOnStartup,
543
668
  layers: { ...DEFAULT_CONFIG.layers, ...(userConfig.layers || {}) },
669
+ ...(userConfig.customRules ? { customRules: userConfig.customRules } : {}),
544
670
  };
545
671
  }
672
+ /** Compile a list of regex-source strings; invalid ones are skipped. */
673
+ function compileRegexList(sources) {
674
+ const out = [];
675
+ for (const src of sources) {
676
+ try {
677
+ out.push(new RegExp(src, 'i'));
678
+ }
679
+ catch { /* skip invalid */ }
680
+ }
681
+ return out;
682
+ }
683
+ /** Compile user dangerous-command rules; invalid regexes are skipped. */
684
+ function compileDangerousRules(rules) {
685
+ const out = [];
686
+ for (const r of rules) {
687
+ try {
688
+ out.push({
689
+ id: r.id,
690
+ pattern: new RegExp(r.pattern, r.flags || 'i'),
691
+ description_zh: r.description || r.id,
692
+ description_en: r.description || r.id,
693
+ });
694
+ }
695
+ catch { /* skip invalid */ }
696
+ }
697
+ return out;
698
+ }
546
699
  function normalizePath(p) {
547
700
  const expanded = p.startsWith('~')
548
701
  ? p.replace(/^~/, homedir() || process.env.HOME || '/root')
@@ -557,6 +710,75 @@ function normalizePath(p) {
557
710
  function truncate(s, max) {
558
711
  return s.length > max ? s.slice(0, max) + '...' : s;
559
712
  }
713
+ /**
714
+ * Defensive coercion at public API boundaries: a security check must fail safe
715
+ * on hostile/garbage input, never throw. null/undefined → '', everything else
716
+ * is stringified.
717
+ */
718
+ function asString(v) {
719
+ if (typeof v === 'string')
720
+ return v;
721
+ if (v == null)
722
+ return '';
723
+ try {
724
+ return String(v);
725
+ }
726
+ catch {
727
+ return '';
728
+ }
729
+ }
730
+ /**
731
+ * Defeat shell-quote obfuscation for DETECTION (not execution): strip empty
732
+ * quote pairs so `r''m -rf /` and `r""m -rf /` normalize to `rm -rf /`.
733
+ * Deliberately conservative — non-empty quoted arguments (echo "rm -rf /")
734
+ * are left intact to avoid false positives. Runs a few passes for r''''m.
735
+ */
736
+ function normalizeCommand(cmd) {
737
+ let prev = cmd;
738
+ for (let i = 0; i < 4; i++) {
739
+ const next = prev.replace(/''|""/g, '');
740
+ if (next === prev)
741
+ break;
742
+ prev = next;
743
+ }
744
+ return prev;
745
+ }
746
+ /**
747
+ * Recursively collect all `description`/`title` string values out of a JSON
748
+ * Schema (an MCP tool's inputSchema), so poisoning hidden in a nested
749
+ * parameter description is scanned too. Bounded to avoid pathological schemas.
750
+ */
751
+ function collectSchemaText(schema, depth = 0) {
752
+ if (!schema || typeof schema !== 'object' || depth > 6)
753
+ return '';
754
+ const out = [];
755
+ for (const [key, val] of Object.entries(schema)) {
756
+ if ((key === 'description' || key === 'title') && typeof val === 'string') {
757
+ out.push(val);
758
+ }
759
+ else if (val && typeof val === 'object') {
760
+ out.push(collectSchemaText(val, depth + 1));
761
+ }
762
+ }
763
+ return out.join('\n');
764
+ }
765
+ /** Remove all invisible/zero-width characters (the HIDDEN_CHAR_RANGES). */
766
+ function stripInvisible(text) {
767
+ let out = '';
768
+ for (const char of text) {
769
+ const cp = char.codePointAt(0);
770
+ let hidden = false;
771
+ for (const [start, end] of HIDDEN_CHAR_RANGES) {
772
+ if (cp >= start && cp <= end) {
773
+ hidden = true;
774
+ break;
775
+ }
776
+ }
777
+ if (!hidden)
778
+ out += char;
779
+ }
780
+ return out;
781
+ }
560
782
  function detectHiddenChars(text) {
561
783
  const found = [];
562
784
  for (const char of text) {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { ShellWard } from './core/engine.js';
2
- export type { CheckResult, ScanResult, InjectionResult, ResponseCheckResult } from './core/engine.js';
3
- export type { ShellWardConfig } from './types.js';
2
+ export type { CheckResult, ScanResult, InjectionResult, ResponseCheckResult, McpToolDefinition, ToolPoisoningResult, ToolPoisoningFinding, } from './core/engine.js';
3
+ export { McpBaseline } from './mcp-baseline.js';
4
+ export type { RugPullResult, RugPullStatus } from './mcp-baseline.js';
5
+ export type { ShellWardConfig, CustomRules, CustomSensitivePattern, CustomCommandRule, } from './types.js';
4
6
  declare const _default: {
5
7
  id: string;
6
8
  register(api: any): void;
package/dist/index.js CHANGED
@@ -6,6 +6,9 @@
6
6
  //
7
7
  // See docs/定位.md — ShellWard is an AI Agent Security Layer,
8
8
  // NOT just an OpenClaw plugin. The core engine is platform-agnostic.
9
+ import { readFileSync } from 'fs';
10
+ import { fileURLToPath } from 'url';
11
+ import { dirname, join } from 'path';
9
12
  import { ShellWard } from './core/engine.js';
10
13
  import { setupPromptGuard } from './layers/prompt-guard.js';
11
14
  import { setupOutputScanner } from './layers/output-scanner.js';
@@ -18,9 +21,21 @@ import { setupSessionGuard } from './layers/session-guard.js';
18
21
  import { registerAllCommands } from './commands/index.js';
19
22
  import { checkForUpdate } from './update-check.js';
20
23
  import { runAutoCheckOnStartup } from './auto-check.js';
21
- const CURRENT_VERSION = '0.5.16';
24
+ // Single source of truth: read version from package.json at load time.
25
+ // dist/index.js → ../package.json (package.json is shipped via "files").
26
+ const CURRENT_VERSION = (() => {
27
+ try {
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ const pkg = JSON.parse(readFileSync(join(here, '../package.json'), 'utf8'));
30
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
31
+ }
32
+ catch {
33
+ return '0.0.0';
34
+ }
35
+ })();
22
36
  // Re-export core engine for SDK usage
23
37
  export { ShellWard } from './core/engine.js';
38
+ export { McpBaseline } from './mcp-baseline.js';
24
39
  /**
25
40
  * Wrap api.on so every hook handler gets try-catch protection.
26
41
  * If a security hook throws, we log the error and fail-safe:
@@ -106,8 +121,8 @@ export default {
106
121
  }
107
122
  // === Slash Commands ===
108
123
  if (api.registerCommand) {
109
- registerAllCommands(api, guard.config);
110
- api.logger.info('[ShellWard] 6 commands registered');
124
+ const commandCount = registerAllCommands(api, guard.config);
125
+ api.logger.info(`[ShellWard] ${commandCount} commands registered`);
111
126
  }
112
127
  const allLayers = ['promptGuard', 'outputScanner', 'toolBlocker', 'inputAuditor', 'securityGate', 'outboundGuard', 'dataFlowGuard', 'sessionGuard'];
113
128
  const enabledCount = allLayers.filter(k => guard.config.layers[k]).length;
@@ -0,0 +1,27 @@
1
+ import type { McpToolDefinition } from './core/engine.js';
2
+ export type RugPullStatus = 'new' | 'unchanged' | 'changed';
3
+ export interface RugPullResult {
4
+ key: string;
5
+ status: RugPullStatus;
6
+ currentHash: string;
7
+ previousHash?: string;
8
+ }
9
+ export declare class McpBaseline {
10
+ private readonly path;
11
+ private store;
12
+ /** @param filePath override the baseline file (tests pass a temp path). */
13
+ constructor(filePath?: string);
14
+ /** Fingerprint a tool's externally-visible contract (description + schema). */
15
+ private fingerprint;
16
+ /** Stable key for a tool, namespaced by its server. */
17
+ static keyFor(server: string, toolName: string): string;
18
+ /** Compare against the stored baseline WITHOUT persisting. */
19
+ diff(key: string, tool: McpToolDefinition): RugPullResult;
20
+ /** Compare, then update the in-memory baseline. Call save() to persist. */
21
+ record(key: string, tool: McpToolDefinition): RugPullResult;
22
+ /** Number of tracked tools. */
23
+ get size(): number;
24
+ private load;
25
+ /** Flush the baseline to disk (owner-only perms). Never throws. */
26
+ save(): void;
27
+ }
@@ -0,0 +1,73 @@
1
+ // src/mcp-baseline.ts — MCP "rug-pull" detection via tool-definition baselining
2
+ //
3
+ // A rug-pull attack: an MCP tool ships a benign description, gets approved/trusted,
4
+ // then later silently swaps in a malicious description. ShellWard fingerprints each
5
+ // tool's description+schema on first sight and flags later mismatches.
6
+ //
7
+ // Zero dependencies — sha256 from node:crypto, JSON store under the audit dir.
8
+ import { createHash } from 'crypto';
9
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
10
+ import { dirname, join } from 'path';
11
+ import { getHomeDir } from './utils.js';
12
+ const DEFAULT_PATH = join(getHomeDir(), '.openclaw', 'shellward', 'mcp-baseline.json');
13
+ export class McpBaseline {
14
+ path;
15
+ store;
16
+ /** @param filePath override the baseline file (tests pass a temp path). */
17
+ constructor(filePath) {
18
+ this.path = filePath || DEFAULT_PATH;
19
+ this.store = this.load();
20
+ }
21
+ /** Fingerprint a tool's externally-visible contract (description + schema). */
22
+ fingerprint(tool) {
23
+ const canonical = JSON.stringify({
24
+ description: tool.description || '',
25
+ inputSchema: tool.inputSchema ?? null,
26
+ });
27
+ return createHash('sha256').update(canonical).digest('hex');
28
+ }
29
+ /** Stable key for a tool, namespaced by its server. */
30
+ static keyFor(server, toolName) {
31
+ return `${server}::${toolName}`;
32
+ }
33
+ /** Compare against the stored baseline WITHOUT persisting. */
34
+ diff(key, tool) {
35
+ const currentHash = this.fingerprint(tool);
36
+ const prev = this.store[key];
37
+ if (!prev)
38
+ return { key, status: 'new', currentHash };
39
+ return {
40
+ key,
41
+ status: prev.hash === currentHash ? 'unchanged' : 'changed',
42
+ currentHash,
43
+ previousHash: prev.hash,
44
+ };
45
+ }
46
+ /** Compare, then update the in-memory baseline. Call save() to persist. */
47
+ record(key, tool) {
48
+ const res = this.diff(key, tool);
49
+ this.store[key] = { hash: res.currentHash, name: tool.name, ts: new Date().toISOString() };
50
+ return res;
51
+ }
52
+ /** Number of tracked tools. */
53
+ get size() {
54
+ return Object.keys(this.store).length;
55
+ }
56
+ load() {
57
+ try {
58
+ const parsed = JSON.parse(readFileSync(this.path, 'utf8'));
59
+ return parsed && typeof parsed === 'object' ? parsed : {};
60
+ }
61
+ catch {
62
+ return {};
63
+ }
64
+ }
65
+ /** Flush the baseline to disk (owner-only perms). Never throws. */
66
+ save() {
67
+ try {
68
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
69
+ writeFileSync(this.path, JSON.stringify(this.store, null, 2), { mode: 0o600 });
70
+ }
71
+ catch { /* best-effort; baselining must not break the host */ }
72
+ }
73
+ }
@@ -0,0 +1,29 @@
1
+ import type { McpToolDefinition } from './core/engine.js';
2
+ export interface McpServerSpec {
3
+ name: string;
4
+ /** 'stdio' servers are spawned; 'remote' servers are scanned over HTTP. */
5
+ transport: 'stdio' | 'remote';
6
+ command?: string;
7
+ args?: string[];
8
+ env?: Record<string, string>;
9
+ url?: string;
10
+ headers?: Record<string, string>;
11
+ source: string;
12
+ }
13
+ /**
14
+ * Discover MCP servers declared in known config files.
15
+ * Recognizes the standard `{ "mcpServers": { name: {...} } }` shape.
16
+ * @param paths override config paths (tests pass a temp file)
17
+ */
18
+ export declare function discoverMcpServers(paths?: string[]): McpServerSpec[];
19
+ /**
20
+ * Spawn a stdio MCP server, initialize, and return its tool definitions.
21
+ * Always resolves (never hangs): on error/timeout it cleans up and rejects.
22
+ */
23
+ export declare function listToolsStdio(spec: McpServerSpec, timeoutMs?: number): Promise<McpToolDefinition[]>;
24
+ /**
25
+ * Initialize a remote MCP server over Streamable HTTP and return its tool
26
+ * definitions. Best-effort: returns [] if the server speaks an unsupported
27
+ * dialect. Rejects on network error / timeout.
28
+ */
29
+ export declare function listToolsHttp(spec: McpServerSpec, timeoutMs?: number): Promise<McpToolDefinition[]>;