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
@@ -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,7 @@ export class ShellWard {
137
173
  }
138
174
  // ========== L2: Data Scanner ==========
139
175
  scanData(text, toolName) {
140
- const [, findings] = redactSensitive(text);
176
+ const [, findings] = redactSensitive(text, this.customSensitive);
141
177
  const hasSensitiveData = findings.length > 0;
142
178
  const summary = findings.map(f => `${f.name}(${f.count})`).join(', ');
143
179
  if (hasSensitiveData) {
@@ -161,7 +197,10 @@ export class ShellWard {
161
197
  checkTool(toolName) {
162
198
  const toolLower = toolName.toLowerCase();
163
199
  const enforce = this.config.mode === 'enforce';
164
- if (BLOCKED_TOOLS.has(toolLower)) {
200
+ // allowedTools always wins — user-trusted tools bypass policy.
201
+ if (this.allowedTools.has(toolLower))
202
+ return { allowed: true };
203
+ if (this.blockedTools.has(toolLower)) {
165
204
  const reason = this.locale === 'zh'
166
205
  ? `安全策略禁止自动执行: ${toolName}`
167
206
  : `Blocked by security policy: ${toolName}`;
@@ -174,7 +213,7 @@ export class ShellWard {
174
213
  });
175
214
  return { allowed: false, level: 'CRITICAL', reason };
176
215
  }
177
- if (SENSITIVE_TOOLS.has(toolLower)) {
216
+ if (this.sensitiveTools.has(toolLower)) {
178
217
  this.log.write({
179
218
  level: 'MEDIUM',
180
219
  layer: 'L3',
@@ -189,8 +228,12 @@ export class ShellWard {
189
228
  const enforce = this.config.mode === 'enforce';
190
229
  const parts = splitCommands(cmd);
191
230
  for (const part of parts) {
192
- for (const rule of DANGEROUS_COMMANDS) {
193
- if (rule.pattern.test(part)) {
231
+ // Normalize shell-quote obfuscation (e.g. r''m / r""m → rm) before matching.
232
+ // Only empty quote pairs are stripped, so a real quoted arg like
233
+ // echo "rm -rf /" is untouched (no false positive).
234
+ const normalized = normalizeCommand(part);
235
+ for (const rule of [...DANGEROUS_COMMANDS, ...this.customDangerous]) {
236
+ if (rule.pattern.test(part) || rule.pattern.test(normalized)) {
194
237
  const desc = this.locale === 'zh' ? rule.description_zh : rule.description_en;
195
238
  const reason = this.locale === 'zh'
196
239
  ? `检测到危险命令: ${truncate(part, 80)}\n原因: ${desc}`
@@ -244,10 +287,13 @@ export class ShellWard {
244
287
  detail: `Hidden characters detected: ${[...new Set(hiddenChars.map(h => h.name))].join(', ')} (${hiddenChars.length} chars)`,
245
288
  });
246
289
  }
290
+ // Strip invisible characters before rule matching so an attacker can't break
291
+ // a pattern by interleaving zero-width spaces (e.g. "ig​nore previous").
292
+ const normText = hiddenChars.length > 0 ? stripInvisible(text) : text;
247
293
  let score = 0;
248
294
  const matched = [];
249
295
  for (const rule of this.compiledRules) {
250
- if (rule.compiled.test(text)) {
296
+ if (rule.compiled.test(text) || (normText !== text && rule.compiled.test(normText))) {
251
297
  score += rule.riskScore;
252
298
  matched.push({ id: rule.id, name: rule.name, score: rule.riskScore });
253
299
  }
@@ -267,11 +313,86 @@ export class ShellWard {
267
313
  return { safe: score < threshold, score, threshold, matched, hiddenChars: hiddenChars.length };
268
314
  }
269
315
  getInjectionThreshold(toolName) {
270
- if (toolName && LOW_RISK_TOOLS.has(toolName.toLowerCase())) {
316
+ const lower = toolName?.toLowerCase();
317
+ if (lower && (LOW_RISK_TOOLS.has(lower) || this.allowedTools.has(lower))) {
271
318
  return Math.max(this.config.injectionThreshold, 80);
272
319
  }
273
320
  return this.config.injectionThreshold;
274
321
  }
322
+ // ========== L4b: MCP Tool-Poisoning Scanner ==========
323
+ //
324
+ // Inspects an MCP tool *definition* (not user input) for instructions hidden
325
+ // in its description / parameter descriptions — the "tool poisoning" attack.
326
+ // Reuses the injection engine + hidden-char detection and layers on rules
327
+ // tuned for tool-metadata attacks. Pure & side-effect-light: callable from
328
+ // the SDK, the MCP server, or at plugin tool-discovery time.
329
+ scanToolDefinition(tool, options) {
330
+ const threshold = options?.threshold ?? 40;
331
+ const findings = [];
332
+ let score = 0;
333
+ const description = typeof tool.description === 'string' ? tool.description : '';
334
+ const paramText = collectSchemaText(tool.inputSchema);
335
+ const combined = `${description}\n${paramText}`;
336
+ // 1. Hidden / invisible characters anywhere in the metadata
337
+ const hidden = detectHiddenChars(combined);
338
+ if (hidden.length > 0) {
339
+ const s = hidden.length > 3 ? 35 : 20;
340
+ score += s;
341
+ findings.push({
342
+ id: 'tp_hidden_chars',
343
+ name: `Hidden characters in tool metadata (${[...new Set(hidden.map(h => h.name))].join(', ')})`,
344
+ category: 'concealment',
345
+ score: s,
346
+ source: 'hidden_chars',
347
+ });
348
+ }
349
+ // 2. Tool-poisoning specific rules (description + parameters)
350
+ for (const rule of TOOL_POISONING_RULES) {
351
+ const inDesc = rule.pattern.test(description);
352
+ const inParam = !inDesc && rule.pattern.test(paramText);
353
+ if (inDesc || inParam) {
354
+ score += rule.riskScore;
355
+ findings.push({
356
+ id: rule.id,
357
+ name: rule.name,
358
+ category: rule.category,
359
+ score: rule.riskScore,
360
+ source: inDesc ? 'description' : 'parameter',
361
+ });
362
+ }
363
+ }
364
+ // 3. Generic prompt-injection patterns reused on the description
365
+ for (const rule of this.compiledRules) {
366
+ if (rule.compiled.test(combined)) {
367
+ score += rule.riskScore;
368
+ findings.push({
369
+ id: rule.id,
370
+ name: rule.name,
371
+ category: rule.category,
372
+ score: rule.riskScore,
373
+ source: 'description',
374
+ });
375
+ }
376
+ }
377
+ const safe = score < threshold;
378
+ if (!safe) {
379
+ this.log.write({
380
+ level: score >= 80 ? 'CRITICAL' : 'HIGH',
381
+ layer: 'L4',
382
+ action: this.config.mode === 'enforce' ? 'block' : 'detect',
383
+ detail: this.locale === 'zh'
384
+ ? `检测到 MCP 工具投毒: ${tool.name}\n风险评分: ${score}\n命中: ${findings.map(f => f.name).join('; ')}`
385
+ : `MCP tool poisoning detected: ${tool.name}\nRisk score: ${score}\nMatched: ${findings.map(f => f.name).join('; ')}`,
386
+ tool: tool.name,
387
+ pattern: 'tool_poisoning',
388
+ });
389
+ }
390
+ return { toolName: tool.name, safe, score, threshold, findings, hiddenChars: hidden.length };
391
+ }
392
+ /** Scan a list of MCP tool definitions; returns only the unsafe ones. */
393
+ scanToolDefinitions(tools, options) {
394
+ return tools.map(t => this.scanToolDefinition(t, options)).filter(r => !r.safe);
395
+ }
275
396
  // ========== L5: Security Gate ==========
276
397
  checkAction(action, details) {
277
398
  if (action === 'exec' || action === 'shell') {
@@ -293,20 +414,14 @@ export class ShellWard {
293
414
  });
294
415
  return { allowed: false, level: 'CRITICAL', reason, ruleId: 'no_payment' };
295
416
  }
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' };
417
+ // Outbound actions: delegate the DLP decision to the canonical data-flow
418
+ // guard (L7) so the Gate and the Outbound Guard can never diverge. The set
419
+ // of outbound tools (incl. http_post/curl_post + any customRules) lives in
420
+ // one place: this.outboundTools, consulted by checkOutbound.
421
+ if (this.outboundTools.has(action.toLowerCase())) {
422
+ const dlp = this.checkOutbound(action, details ? { body: details } : {});
423
+ if (!dlp.allowed)
424
+ return dlp;
310
425
  }
311
426
  this.log.write({
312
427
  level: 'INFO',
@@ -330,7 +445,7 @@ export class ShellWard {
330
445
  pattern: 'canary_leak',
331
446
  });
332
447
  }
333
- const [, findings] = redactSensitive(content);
448
+ const [, findings] = redactSensitive(content, this.customSensitive);
334
449
  const hasSensitiveData = findings.length > 0;
335
450
  const summary = findings.map(f => `${f.name}(${f.count})`).join(', ');
336
451
  if (hasSensitiveData) {
@@ -359,7 +474,7 @@ export class ShellWard {
359
474
  this.sensitiveReads.set(`pii-${Date.now()}-${toolName}`, { path: `[${toolName}: ${summary}]`, ts: Date.now() });
360
475
  }
361
476
  trackFileRead(toolName, path) {
362
- for (const hp of HONEYPOT_PATTERNS) {
477
+ for (const hp of this.honeypots) {
363
478
  if (hp.test(path)) {
364
479
  this.log.write({
365
480
  level: 'CRITICAL',
@@ -395,7 +510,7 @@ export class ShellWard {
395
510
  }
396
511
  checkOutbound(toolName, params) {
397
512
  const toolLower = toolName.toLowerCase();
398
- const isOutbound = OUTBOUND_TOOLS.has(toolLower);
513
+ const isOutbound = this.outboundTools.has(toolLower);
399
514
  const isDualUse = DUAL_USE_TOOLS.has(toolLower);
400
515
  const enforce = this.config.mode === 'enforce';
401
516
  this.evictExpired();
@@ -541,8 +656,36 @@ function mergeConfig(userConfig) {
541
656
  injectionThreshold: threshold,
542
657
  autoCheckOnStartup,
543
658
  layers: { ...DEFAULT_CONFIG.layers, ...(userConfig.layers || {}) },
659
+ ...(userConfig.customRules ? { customRules: userConfig.customRules } : {}),
544
660
  };
545
661
  }
662
+ /** Compile a list of regex-source strings; invalid ones are skipped. */
663
+ function compileRegexList(sources) {
664
+ const out = [];
665
+ for (const src of sources) {
666
+ try {
667
+ out.push(new RegExp(src, 'i'));
668
+ }
669
+ catch { /* skip invalid */ }
670
+ }
671
+ return out;
672
+ }
673
+ /** Compile user dangerous-command rules; invalid regexes are skipped. */
674
+ function compileDangerousRules(rules) {
675
+ const out = [];
676
+ for (const r of rules) {
677
+ try {
678
+ out.push({
679
+ id: r.id,
680
+ pattern: new RegExp(r.pattern, r.flags || 'i'),
681
+ description_zh: r.description || r.id,
682
+ description_en: r.description || r.id,
683
+ });
684
+ }
685
+ catch { /* skip invalid */ }
686
+ }
687
+ return out;
688
+ }
546
689
  function normalizePath(p) {
547
690
  const expanded = p.startsWith('~')
548
691
  ? p.replace(/^~/, homedir() || process.env.HOME || '/root')
@@ -557,6 +700,58 @@ function normalizePath(p) {
557
700
  function truncate(s, max) {
558
701
  return s.length > max ? s.slice(0, max) + '...' : s;
559
702
  }
703
+ /**
704
+ * Defeat shell-quote obfuscation for DETECTION (not execution): strip empty
705
+ * quote pairs so `r''m -rf /` and `r""m -rf /` normalize to `rm -rf /`.
706
+ * Deliberately conservative — non-empty quoted arguments (echo "rm -rf /")
707
+ * are left intact to avoid false positives. Runs a few passes for r''''m.
708
+ */
709
+ function normalizeCommand(cmd) {
710
+ let prev = cmd;
711
+ for (let i = 0; i < 4; i++) {
712
+ const next = prev.replace(/''|""/g, '');
713
+ if (next === prev)
714
+ break;
715
+ prev = next;
716
+ }
717
+ return prev;
718
+ }
719
+ /**
720
+ * Recursively collect all `description`/`title` string values out of a JSON
721
+ * Schema (an MCP tool's inputSchema), so poisoning hidden in a nested
722
+ * parameter description is scanned too. Bounded to avoid pathological schemas.
723
+ */
724
+ function collectSchemaText(schema, depth = 0) {
725
+ if (!schema || typeof schema !== 'object' || depth > 6)
726
+ return '';
727
+ const out = [];
728
+ for (const [key, val] of Object.entries(schema)) {
729
+ if ((key === 'description' || key === 'title') && typeof val === 'string') {
730
+ out.push(val);
731
+ }
732
+ else if (val && typeof val === 'object') {
733
+ out.push(collectSchemaText(val, depth + 1));
734
+ }
735
+ }
736
+ return out.join('\n');
737
+ }
738
+ /** Remove all invisible/zero-width characters (the HIDDEN_CHAR_RANGES). */
739
+ function stripInvisible(text) {
740
+ let out = '';
741
+ for (const char of text) {
742
+ const cp = char.codePointAt(0);
743
+ let hidden = false;
744
+ for (const [start, end] of HIDDEN_CHAR_RANGES) {
745
+ if (cp >= start && cp <= end) {
746
+ hidden = true;
747
+ break;
748
+ }
749
+ }
750
+ if (!hidden)
751
+ out += char;
752
+ }
753
+ return out;
754
+ }
560
755
  function detectHiddenChars(text) {
561
756
  const found = [];
562
757
  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[]>;