@weave_protocol/mund 0.3.1 → 0.4.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 (55) hide show
  1. package/DETECTION_CLAIMS.md +48 -0
  2. package/README.md +23 -2
  3. package/dist/agent-audit.d.ts +139 -0
  4. package/dist/agent-audit.d.ts.map +1 -0
  5. package/dist/agent-audit.js +424 -0
  6. package/dist/agent-audit.js.map +1 -0
  7. package/dist/analyzers/mcp-server-analyzer.d.ts.map +1 -1
  8. package/dist/analyzers/mcp-server-analyzer.js +4 -0
  9. package/dist/analyzers/mcp-server-analyzer.js.map +1 -1
  10. package/dist/cli/agent.d.ts +9 -0
  11. package/dist/cli/agent.d.ts.map +1 -0
  12. package/dist/cli/agent.js +91 -0
  13. package/dist/cli/agent.js.map +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +9 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/rugpull.d.ts +6 -0
  18. package/dist/rugpull.d.ts.map +1 -1
  19. package/dist/rugpull.js +18 -5
  20. package/dist/rugpull.js.map +1 -1
  21. package/dist/threat-intel-tools.d.ts +60 -7
  22. package/dist/threat-intel-tools.d.ts.map +1 -1
  23. package/dist/tools/monitoring.js +2 -2
  24. package/dist/tools/monitoring.js.map +1 -1
  25. package/package.json +3 -3
  26. package/src/agent-audit.ts +499 -0
  27. package/src/analyzers/mcp-server-analyzer.ts +3 -0
  28. package/src/cli/agent.ts +80 -0
  29. package/src/index.ts +9 -0
  30. package/src/rugpull.ts +19 -5
  31. package/src/tools/monitoring.ts +2 -2
  32. package/tests/analyzers/mcp-server-analyzer.test.ts +1 -3
  33. package/tests/fixtures/preinstall/fake-mcp-server.mjs +8 -0
  34. package/tests/fixtures/preinstall/mcp-config/clean-pinned.json +13 -0
  35. package/tests/fixtures/preinstall/mcp-config/env-reference.json +15 -0
  36. package/tests/fixtures/preinstall/mcp-config/exfil-service-url.json +10 -0
  37. package/tests/fixtures/preinstall/mcp-config/inline-token.json +16 -0
  38. package/tests/fixtures/preinstall/mcp-config/name-typosquat.json +13 -0
  39. package/tests/fixtures/preinstall/mcp-config/plain-http-remote.json +10 -0
  40. package/tests/fixtures/preinstall/mcp-config/scope-typosquat.json +13 -0
  41. package/tests/fixtures/preinstall/mcp-config/shell-pipeline-launcher.json +13 -0
  42. package/tests/fixtures/preinstall/mcp-config/unpinned-package.json +14 -0
  43. package/tests/fixtures/preinstall/skills/bidi-override/SKILL.md +6 -0
  44. package/tests/fixtures/preinstall/skills/bidi-override/expected.json +4 -0
  45. package/tests/fixtures/preinstall/skills/clean/SKILL.md +6 -0
  46. package/tests/fixtures/preinstall/skills/clean/expected.json +3 -0
  47. package/tests/fixtures/preinstall/skills/curl-bash-installer/SKILL.md +6 -0
  48. package/tests/fixtures/preinstall/skills/curl-bash-installer/expected.json +4 -0
  49. package/tests/fixtures/preinstall/skills/embedded-secret/SKILL.md +6 -0
  50. package/tests/fixtures/preinstall/skills/embedded-secret/expected.json +4 -0
  51. package/tests/fixtures/preinstall/skills/exfil-url/SKILL.md +6 -0
  52. package/tests/fixtures/preinstall/skills/exfil-url/expected.json +4 -0
  53. package/tests/fixtures/preinstall/skills/hidden-tag-characters/SKILL.md +6 -0
  54. package/tests/fixtures/preinstall/skills/hidden-tag-characters/expected.json +4 -0
  55. package/tests/preinstall.test.ts +153 -0
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Mund - The Guardian Protocol
3
+ * Agent audit: pre-install checks for skills and MCP servers
4
+ *
5
+ * Backs the "pre-install" hooks in the Weave Protocol harness adapters:
6
+ *
7
+ * - Skills: scan SKILL.md and every text file in a skill directory for
8
+ * prompt injection, exfiltration, dangerous code, secrets and hidden
9
+ * Unicode (invisible "tag" characters can smuggle instructions that a
10
+ * human reviewer never sees).
11
+ * - MCP servers: static checks on how each configured server is launched
12
+ * (package typosquats, unpinned packages, insecure or exfil URLs, inline
13
+ * secrets), plus an optional live check that connects, lists the tools,
14
+ * scans their descriptions, and compares them with the rug-pull baseline.
15
+ *
16
+ * SAFETY: the live check starts server processes. It only does that for
17
+ * servers the user configured themselves (user/local scope) or explicitly
18
+ * approved for this project. Project `.mcp.json` servers that Claude Code
19
+ * would still ask about are checked statically only, so opening a
20
+ * malicious repository never runs its servers through Mund.
21
+ */
22
+
23
+ import { createHash } from 'crypto';
24
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
25
+ import { mkdir, writeFile } from 'fs/promises';
26
+ import { homedir } from 'os';
27
+ import { dirname, join, resolve } from 'path';
28
+ import type { SecurityIssue, Severity } from './types.js';
29
+ import { DEFAULT_RULES, DANGEROUS_URL_PATTERNS } from './constants.js';
30
+ import { AnalyzerEngine } from './analyzers/index.js';
31
+ import { SecretScanner } from './analyzers/secret-scanner.js';
32
+ import { McpServerAnalyzer, type McpServerManifest } from './analyzers/mcp-server-analyzer.js';
33
+ import { computeRiskScore, type RiskScore } from './scoring.js';
34
+ import { BaselineStore, checkRugPull, changesToIssues, type RugPullResult } from './rugpull.js';
35
+ import { getVersion } from './version.js';
36
+
37
+ export type Verdict = 'allow' | 'warn' | 'block';
38
+
39
+ /** Which agent harness's config layout to audit. */
40
+ export type Harness = 'claude' | 'antigravity';
41
+
42
+ export interface SkillAudit {
43
+ name: string;
44
+ path: string;
45
+ scope: 'project' | 'user' | 'workspace' | 'global' | 'plugin';
46
+ verdict: Verdict;
47
+ risk: RiskScore;
48
+ issues: SecurityIssue[];
49
+ }
50
+
51
+ export interface McpServerConfig {
52
+ type?: string;
53
+ command?: string;
54
+ args?: string[];
55
+ env?: Record<string, string>;
56
+ url?: string;
57
+ /** Antigravity spelling of url */
58
+ serverUrl?: string;
59
+ headers?: Record<string, string>;
60
+ disabled?: boolean;
61
+ }
62
+
63
+ export interface McpServerAudit {
64
+ name: string;
65
+ scope: 'project' | 'local' | 'user' | 'desktop' | 'workspace' | 'global';
66
+ transport: 'stdio' | 'http' | 'sse' | 'unknown';
67
+ target: string;
68
+ verdict: Verdict;
69
+ risk: RiskScore;
70
+ issues: SecurityIssue[];
71
+ live: { status: 'ok' | 'error' | 'skipped'; reason?: string; tools?: number; rugpull?: RugPullResult['status'] };
72
+ }
73
+
74
+ export interface AgentAuditReport {
75
+ version: 1;
76
+ tool: { name: 'mund'; version: string };
77
+ generatedAt: string;
78
+ cwd: string;
79
+ servers: McpServerAudit[];
80
+ skills: SkillAudit[];
81
+ summary: Record<Verdict, number>;
82
+ }
83
+
84
+ // mcp_rugpull is deliberately absent: a plain change (reworded description,
85
+ // new optional parameter) warns until accepted; only escalated changes
86
+ // (now malicious-looking or newly executing commands) are critical → block.
87
+ const BLOCK_TYPES = new Set(['injection', 'exfiltration', 'hidden_unicode']);
88
+ const MAX_FILE = 512 * 1024;
89
+
90
+ /**
91
+ * critical → block; high in a blocking category (injection, exfiltration,
92
+ * hidden Unicode) → block; other high/medium → warn; else allow.
93
+ */
94
+ export function verdictFor(issues: Array<Pick<SecurityIssue, 'severity' | 'type'>>): Verdict {
95
+ let verdict: Verdict = 'allow';
96
+ for (const i of issues) {
97
+ if (i.severity === 'critical' || (i.severity === 'high' && BLOCK_TYPES.has(i.type))) return 'block';
98
+ if (i.severity === 'high' || i.severity === 'medium') verdict = 'warn';
99
+ }
100
+ return verdict;
101
+ }
102
+
103
+ // ============================================================================
104
+ // Skills
105
+ // ============================================================================
106
+
107
+ const engine = new AnalyzerEngine();
108
+
109
+ /** Invisible characters used to hide instructions from human reviewers. */
110
+ const HIDDEN_CHARS: Array<{ re: RegExp; name: string; severity: Severity }> = [
111
+ { re: /[\u{E0000}-\u{E007F}]/u, name: 'Unicode tag characters (invisible ASCII smuggling)', severity: 'critical' },
112
+ { re: /[‪-‮⁦-⁩]/, name: 'bidirectional override characters', severity: 'high' },
113
+ { re: /[​-‍⁠]{3,}/, name: 'runs of zero-width characters', severity: 'high' },
114
+ ];
115
+
116
+ function hiddenUnicode(content: string): SecurityIssue[] {
117
+ const issues: SecurityIssue[] = [];
118
+ for (const h of HIDDEN_CHARS) {
119
+ const m = h.re.exec(content);
120
+ if (!m) continue;
121
+ const before = content.slice(0, m.index).split('\n');
122
+ issues.push({
123
+ rule_id: 'hidden_unicode',
124
+ rule_name: `Hidden text: ${h.name}`,
125
+ type: 'hidden_unicode',
126
+ severity: h.severity,
127
+ action: 'block',
128
+ match: `U+${m[0].codePointAt(0)!.toString(16).toUpperCase()}`,
129
+ location: { start: m.index, end: m.index + m[0].length, line: before.length, column: before[before.length - 1].length + 1 },
130
+ suggestion: 'Text that is invisible to reviewers but readable by the model. Remove it or reject this skill.',
131
+ });
132
+ }
133
+ return issues;
134
+ }
135
+
136
+ /** Scan one piece of skill content (a SKILL.md or a file shipped with the skill). */
137
+ export async function scanSkillContent(content: string): Promise<SecurityIssue[]> {
138
+ return [...hiddenUnicode(content), ...(await engine.analyzeAll(content, DEFAULT_RULES))];
139
+ }
140
+
141
+ function textFiles(dir: string, out: string[] = [], depth = 0): string[] {
142
+ if (depth > 4) return out;
143
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
144
+ if (e.name === 'node_modules' || e.name === '.git') continue;
145
+ const p = join(dir, e.name);
146
+ if (e.isDirectory()) textFiles(p, out, depth + 1);
147
+ else if (e.isFile() && statSync(p).size <= MAX_FILE) out.push(p);
148
+ }
149
+ return out;
150
+ }
151
+
152
+ export async function scanSkillDir(dir: string, scope: SkillAudit['scope'] = 'project'): Promise<SkillAudit> {
153
+ const issues: SecurityIssue[] = [];
154
+ for (const file of textFiles(dir)) {
155
+ const buf = readFileSync(file);
156
+ if (buf.subarray(0, 4096).includes(0)) continue; // binary
157
+ for (const issue of await scanSkillContent(buf.toString('utf8'))) {
158
+ issues.push({ ...issue, rule_name: `${issue.rule_name} (${file.slice(dir.length + 1)})` });
159
+ }
160
+ }
161
+ return { name: dir.split(/[\\/]/).pop()!, path: dir, scope, verdict: verdictFor(issues), risk: computeRiskScore(issues), issues };
162
+ }
163
+
164
+ export function skillRoots(cwd: string, home = homedir(), harness: Harness = 'claude'): Array<[string, SkillAudit['scope']]> {
165
+ if (harness === 'antigravity') {
166
+ const roots: Array<[string, SkillAudit['scope']]> = [
167
+ [join(cwd, '.agents', 'skills'), 'workspace'],
168
+ [join(cwd, '.agent', 'skills'), 'workspace'],
169
+ [join(home, '.gemini', 'config', 'skills'), 'global'],
170
+ [join(home, '.gemini', 'antigravity-cli', 'skills'), 'global'],
171
+ [join(home, '.gemini', 'antigravity', 'skills'), 'global'],
172
+ ];
173
+ const plugins = join(home, '.gemini', 'antigravity-cli', 'plugins');
174
+ if (existsSync(plugins)) {
175
+ for (const p of readdirSync(plugins, { withFileTypes: true })) if (p.isDirectory()) roots.push([join(plugins, p.name, 'skills'), 'plugin']);
176
+ }
177
+ return roots;
178
+ }
179
+ return [[join(cwd, '.claude', 'skills'), 'project'], [join(home, '.claude', 'skills'), 'user']];
180
+ }
181
+
182
+ export function discoverSkillDirs(cwd: string, home = homedir(), harness: Harness = 'claude'): Array<{ dir: string; scope: SkillAudit['scope'] }> {
183
+ const roots = skillRoots(cwd, home, harness);
184
+ const out: Array<{ dir: string; scope: SkillAudit['scope'] }> = [];
185
+ for (const [root, scope] of roots) {
186
+ if (!existsSync(root)) continue;
187
+ for (const e of readdirSync(root, { withFileTypes: true })) {
188
+ if (e.isDirectory() && existsSync(join(root, e.name, 'SKILL.md'))) out.push({ dir: join(root, e.name), scope });
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+
194
+ // ============================================================================
195
+ // MCP servers
196
+ // ============================================================================
197
+
198
+ interface DiscoveredServer {
199
+ name: string;
200
+ scope: McpServerAudit['scope'];
201
+ config: McpServerConfig;
202
+ /** Started by the user's own configuration or explicitly approved; safe to start for a live check. */
203
+ approved: boolean;
204
+ }
205
+
206
+ function readJson(path: string): Record<string, any> | null {
207
+ try {
208
+ return JSON.parse(readFileSync(path, 'utf8'));
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /** Claude Desktop's config, which also supplies MCP servers to its Code tab. */
215
+ export function claudeDesktopConfigPath(home = homedir()): string {
216
+ if (process.platform === 'win32') return join(process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
217
+ if (process.platform === 'darwin') return join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
218
+ return join(home, '.config', 'Claude', 'claude_desktop_config.json');
219
+ }
220
+
221
+ /**
222
+ * Claude Code MCP config: <cwd>/.mcp.json (project), ~/.claude.json (user +
223
+ * per-project local), and Claude Desktop's claude_desktop_config.json.
224
+ */
225
+ export function discoverMcpServers(cwd: string, home = homedir()): DiscoveredServer[] {
226
+ const out: DiscoveredServer[] = [];
227
+ const claudeJson = readJson(join(home, '.claude.json')) ?? {};
228
+ const projectEntry = claudeJson.projects?.[cwd] ?? {};
229
+ const settings = [join(cwd, '.claude', 'settings.json'), join(cwd, '.claude', 'settings.local.json'), join(home, '.claude', 'settings.json')]
230
+ .map(readJson)
231
+ .filter(Boolean) as Array<Record<string, any>>;
232
+ const enableAll = settings.some((s) => s.enableAllProjectMcpServers === true) || projectEntry.enableAllProjectMcpServers === true;
233
+ const enabled = new Set<string>([
234
+ ...(projectEntry.enabledMcpjsonServers ?? []),
235
+ ...settings.flatMap((s) => s.enabledMcpjsonServers ?? []),
236
+ ]);
237
+ const disabled = new Set<string>([...(projectEntry.disabledMcpjsonServers ?? []), ...settings.flatMap((s) => s.disabledMcpjsonServers ?? [])]);
238
+
239
+ for (const [name, config] of Object.entries<McpServerConfig>(readJson(join(cwd, '.mcp.json'))?.mcpServers ?? {})) {
240
+ out.push({ name, scope: 'project', config, approved: !disabled.has(name) && (enableAll || enabled.has(name)) });
241
+ }
242
+ for (const [name, config] of Object.entries<McpServerConfig>(projectEntry.mcpServers ?? {})) {
243
+ out.push({ name, scope: 'local', config, approved: true });
244
+ }
245
+ for (const [name, config] of Object.entries<McpServerConfig>(claudeJson.mcpServers ?? {})) {
246
+ out.push({ name, scope: 'user', config, approved: true });
247
+ }
248
+ const seen = new Set(out.map((s) => s.name));
249
+ for (const [name, config] of Object.entries<McpServerConfig>(readJson(claudeDesktopConfigPath(home))?.mcpServers ?? {})) {
250
+ if (!seen.has(name)) out.push({ name, scope: 'desktop', config, approved: true });
251
+ }
252
+ return out;
253
+ }
254
+
255
+ /**
256
+ * Antigravity MCP config: <workspace>/.agents/mcp_config.json and
257
+ * ~/.gemini/config/mcp_config.json. Workspace servers are checked
258
+ * statically only (same consent rule as Claude Code project servers);
259
+ * servers marked "disabled" are never started.
260
+ */
261
+ export function discoverAntigravityMcpServers(cwd: string, home = homedir()): DiscoveredServer[] {
262
+ const out: DiscoveredServer[] = [];
263
+ const add = (path: string, scope: McpServerAudit['scope'], approved: boolean) => {
264
+ for (const [name, raw] of Object.entries<McpServerConfig>(readJson(path)?.mcpServers ?? {})) {
265
+ const config = { ...raw, url: raw.url ?? raw.serverUrl };
266
+ out.push({ name, scope, config, approved: approved && !raw.disabled });
267
+ }
268
+ };
269
+ add(join(cwd, '.agents', 'mcp_config.json'), 'workspace', false);
270
+ add(join(home, '.gemini', 'config', 'mcp_config.json'), 'global', true);
271
+ return out;
272
+ }
273
+
274
+ /** Expand ${VAR} and ${VAR:-default} the way Claude Code does in .mcp.json. */
275
+ function expand(value: string, env: NodeJS.ProcessEnv = process.env): string {
276
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, k, d) => env[k] ?? d ?? '');
277
+ }
278
+
279
+ const PACKAGE_RUNNERS = new Set(['npx', 'bunx', 'uvx', 'pipx', 'pnpx']);
280
+ const OFFICIAL_SCOPE = 'modelcontextprotocol';
281
+
282
+ function lev(a: string, b: string): number {
283
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
284
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
285
+ for (let i = 1; i <= a.length; i++)
286
+ for (let j = 1; j <= b.length; j++)
287
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
288
+ return d[a.length][b.length];
289
+ }
290
+
291
+ /** The package a launcher like `npx -y pkg@1.2.3` runs, if any. */
292
+ export function launchedPackage(config: McpServerConfig): { name: string; version?: string } | null {
293
+ const cmd = (config.command ?? '').split(/[\\/]/).pop() ?? '';
294
+ let args = config.args ?? [];
295
+ if (cmd === 'pnpm' || cmd === 'yarn') args = args[0] === 'dlx' ? args.slice(1) : [];
296
+ else if (cmd === 'pipx') args = args[0] === 'run' ? args.slice(1) : [];
297
+ else if (!PACKAGE_RUNNERS.has(cmd)) return null;
298
+ const spec = args.find((a) => !a.startsWith('-'));
299
+ if (!spec) return null;
300
+ const at = spec.lastIndexOf('@');
301
+ return at > 0 ? { name: spec.slice(0, at), version: spec.slice(at + 1) } : { name: spec };
302
+ }
303
+
304
+ const finding = (rule_id: string, rule_name: string, severity: Severity, type: string, match: string, suggestion: string): SecurityIssue => ({
305
+ rule_id, rule_name, severity, type, action: severity === 'critical' ? 'block' : 'alert', match, suggestion,
306
+ });
307
+
308
+ /** Static checks on how a server is launched. No processes are started. */
309
+ export async function checkMcpServerConfig(name: string, config: McpServerConfig): Promise<SecurityIssue[]> {
310
+ const issues: SecurityIssue[] = [];
311
+
312
+ // Inline secrets in args, env, headers or URL
313
+ const secrets = await new SecretScanner().analyze(
314
+ [...(config.args ?? []), ...Object.values(config.env ?? {}), ...Object.values(config.headers ?? {}), config.url ?? ''].filter((v) => !/^\$\{[^}]+\}$/.test(v)).join('\n'),
315
+ DEFAULT_RULES,
316
+ );
317
+ for (const s of secrets) {
318
+ issues.push(finding('mcp_config_inline_secret', `Secret written into MCP config: ${s.rule_name}`, 'high', 'secret', s.match,
319
+ 'Reference it as ${ENV_VAR} instead; config files get committed and shared.'));
320
+ }
321
+
322
+ // Package typosquats and unpinned packages
323
+ const pkg = launchedPackage(config);
324
+ if (pkg) {
325
+ const scope = pkg.name.startsWith('@') ? pkg.name.slice(1).split('/')[0] : null;
326
+ if (scope && scope !== OFFICIAL_SCOPE && lev(scope, OFFICIAL_SCOPE) <= 3) {
327
+ issues.push(finding('mcp_package_scope_typosquat', `Package scope "@${scope}" imitates the official "@${OFFICIAL_SCOPE}"`, 'critical', 'supply_chain', pkg.name,
328
+ 'Do not run this server. Check the package name character by character.'));
329
+ }
330
+ const short = pkg.name.split('/').pop()!.replace(/^(mcp-server-|server-|mcp-)|(-mcp-server|-mcp|-server)$/g, '');
331
+ for (const t of new McpServerAnalyzer().checkTyposquatting({ name: short, version: '0' } as McpServerManifest)) {
332
+ issues.push({ ...t, type: 'supply_chain', rule_name: `${t.rule_name} (package ${pkg.name})` });
333
+ }
334
+ if (!pkg.version || pkg.version === 'latest') {
335
+ issues.push(finding('mcp_unpinned_package', `Package ${pkg.name} is not pinned to a version`, 'low', 'supply_chain', pkg.name,
336
+ `Pin it (e.g. ${pkg.name}@1.2.3) so an upstream update can't change the server under you.`));
337
+ }
338
+ }
339
+
340
+ // Remote URL checks
341
+ if (config.url) {
342
+ const url = expand(config.url);
343
+ for (const p of DANGEROUS_URL_PATTERNS) {
344
+ if (p.test(url)) issues.push(finding('mcp_dangerous_url', 'MCP server URL points at a data-collection service', 'critical', 'exfiltration', url, 'Remove this server.'));
345
+ }
346
+ try {
347
+ const u = new URL(url);
348
+ const local = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname);
349
+ if (u.protocol === 'http:' && !local) {
350
+ issues.push(finding('mcp_insecure_transport', 'Remote MCP server over plain HTTP', 'medium', 'transport', url, 'Use https:// so tool traffic and tokens are encrypted.'));
351
+ }
352
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(u.hostname) && !local) {
353
+ issues.push(finding('mcp_ip_url', 'Remote MCP server addressed by raw IP', 'medium', 'transport', url, 'Prefer a named, TLS-verified host.'));
354
+ }
355
+ } catch {
356
+ issues.push(finding('mcp_bad_url', 'MCP server URL does not parse', 'medium', 'transport', url, 'Fix the URL.'));
357
+ }
358
+ }
359
+
360
+ // Dangerous launch commands (e.g. sh -c "curl … | bash")
361
+ const launch = [config.command ?? '', ...(config.args ?? [])].join(' ');
362
+ for (const i of await engine.analyzeAll(launch, DEFAULT_RULES.filter((r) => r.type === 'code_pattern'))) {
363
+ issues.push({ ...i, rule_name: `Launch command: ${i.rule_name}` });
364
+ }
365
+
366
+ void name;
367
+ return issues;
368
+ }
369
+
370
+ /** Connect to a server, list its tools, and return them as a manifest. */
371
+ export async function fetchLiveManifest(name: string, config: McpServerConfig, timeoutMs = 8000): Promise<McpServerManifest> {
372
+ const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
373
+ const client = new Client({ name: 'mund-agent-audit', version: getVersion() });
374
+ let transport: any;
375
+ if (config.url) {
376
+ if (config.type === 'sse') throw new Error('SSE transport not checked live (deprecated); static checks only');
377
+ const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
378
+ const headers = Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v)]));
379
+ transport = new StreamableHTTPClientTransport(new URL(expand(config.url)), { requestInit: { headers } });
380
+ } else if (config.command) {
381
+ const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
382
+ transport = new StdioClientTransport({
383
+ command: expand(config.command),
384
+ args: (config.args ?? []).map((a) => expand(a)),
385
+ env: { ...(process.env as Record<string, string>), ...Object.fromEntries(Object.entries(config.env ?? {}).map(([k, v]) => [k, expand(v)])) },
386
+ stderr: 'ignore',
387
+ });
388
+ } else {
389
+ throw new Error('no command or url');
390
+ }
391
+
392
+ const timeout = new Promise<never>((_, rej) => setTimeout(() => rej(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs).unref());
393
+ try {
394
+ await Promise.race([client.connect(transport), timeout]);
395
+ const tools: McpServerManifest['tools'] = [];
396
+ let cursor: string | undefined;
397
+ do {
398
+ const page: any = await Promise.race([client.listTools(cursor ? { cursor } : undefined), timeout]);
399
+ for (const t of page.tools) tools.push({ name: t.name, description: t.description, inputSchema: t.inputSchema });
400
+ cursor = page.nextCursor;
401
+ } while (cursor && tools.length < 2000);
402
+ const info = client.getServerVersion();
403
+ return { name: info?.name ?? name, version: info?.version ?? '0', tools };
404
+ } finally {
405
+ await client.close().catch(() => {});
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Live manifest findings, calibrated for a pre-install verdict:
411
+ * - typosquat rules are dropped: a server's self-reported name proves
412
+ * nothing, and the launch package is already checked statically;
413
+ * - capability findings ("can run commands", "touches files", many tools)
414
+ * describe what a server does, not that it's malicious, so they're kept
415
+ * as info. A server that newly GAINS execution is still escalated by
416
+ * rug-pull detection.
417
+ */
418
+ const DROP_LIVE = new Set(['mcp_typosquatting', 'mcp_typosquatting_pattern', 'mcp_missing_version']);
419
+ const DESCRIPTIVE_LIVE = /^mcp_capability_|^mcp_execution_warning$|^mcp_excessive_tools$/;
420
+ export function calibrateLiveFindings(issues: SecurityIssue[]): SecurityIssue[] {
421
+ return issues
422
+ .filter((i) => !DROP_LIVE.has(i.rule_id))
423
+ .map((i) => (DESCRIPTIVE_LIVE.test(i.rule_id) ? { ...i, severity: 'info' as Severity, action: 'log' as const } : i));
424
+ }
425
+
426
+ export async function auditMcpServer(
427
+ server: DiscoveredServer,
428
+ opts: { live?: boolean; store?: BaselineStore; timeoutMs?: number; accept?: string[]; harness?: Harness } = {},
429
+ ): Promise<McpServerAudit> {
430
+ const { name, scope, config } = server;
431
+ const issues = await checkMcpServerConfig(name, config);
432
+ const transport: McpServerAudit['transport'] = config.url ? (config.type === 'sse' ? 'sse' : 'http') : config.command ? 'stdio' : 'unknown';
433
+ let live: McpServerAudit['live'] = { status: 'skipped', reason: 'live checks disabled' };
434
+
435
+ // Never launch a server the static checks already distrust: a typosquat
436
+ // package or an exfil URL must not be started "to have a look".
437
+ const distrusted = verdictFor(issues) === 'block' ||
438
+ issues.some((i) => (i.severity === 'critical' || i.severity === 'high') && (i.type === 'supply_chain' || i.type === 'exfiltration'));
439
+
440
+ if (opts.live) {
441
+ if (!server.approved) {
442
+ live = { status: 'skipped', reason: server.config.disabled ? 'server is disabled in config' : 'workspace/project server not approved for automatic start; static checks only' };
443
+ } else if (distrusted) {
444
+ live = { status: 'skipped', reason: 'not started: static checks already flag its package or URL' };
445
+ } else {
446
+ try {
447
+ const manifest = await fetchLiveManifest(name, config, opts.timeoutMs);
448
+ issues.push(...calibrateLiveFindings(await new McpServerAnalyzer().analyze(JSON.stringify(manifest), [])));
449
+ const accept = !!opts.accept?.some((a) => a === 'all' || a === name);
450
+ const rug = await checkRugPull(`${opts.harness ?? 'claude'}:${scope}:${name}`, manifest, { store: opts.store, acceptChanges: accept });
451
+ issues.push(...changesToIssues(rug));
452
+ live = { status: 'ok', tools: manifest.tools?.length ?? 0, rugpull: rug.status };
453
+ } catch (err) {
454
+ live = { status: 'error', reason: (err as Error).message };
455
+ }
456
+ }
457
+ }
458
+
459
+ return {
460
+ name,
461
+ scope,
462
+ transport,
463
+ target: config.url ?? [config.command, ...(config.args ?? [])].filter(Boolean).join(' '),
464
+ verdict: verdictFor(issues),
465
+ risk: computeRiskScore(issues),
466
+ issues,
467
+ live,
468
+ };
469
+ }
470
+
471
+ // ============================================================================
472
+ // Full audit + cache
473
+ // ============================================================================
474
+
475
+ /** Cache key: sha256(resolved cwd), plus the harness for non-Claude layouts. */
476
+ export function auditCachePath(cwd: string, harness: Harness = 'claude'): string {
477
+ const base = process.env.MUND_AUDIT_CACHE_DIR || join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'mund', 'agent-audit');
478
+ const key = createHash('sha256').update(resolve(cwd)).digest('hex').slice(0, 16);
479
+ return join(base, harness === 'claude' ? `${key}.json` : `${key}.${harness}.json`);
480
+ }
481
+
482
+ export async function runAgentAudit(opts: { cwd?: string; live?: boolean; home?: string; store?: BaselineStore; timeoutMs?: number; writeCache?: boolean; accept?: string[]; harness?: Harness } = {}): Promise<AgentAuditReport> {
483
+ const cwd = resolve(opts.cwd ?? process.cwd());
484
+ const home = opts.home ?? homedir();
485
+ const [servers, skills] = await Promise.all([
486
+ Promise.all((opts.harness === 'antigravity' ? discoverAntigravityMcpServers(cwd, home) : discoverMcpServers(cwd, home)).map((s) => auditMcpServer(s, opts))),
487
+ Promise.all(discoverSkillDirs(cwd, home, opts.harness).map(({ dir, scope }) => scanSkillDir(dir, scope))),
488
+ ]);
489
+ const summary: Record<Verdict, number> = { allow: 0, warn: 0, block: 0 };
490
+ for (const x of [...servers, ...skills]) summary[x.verdict]++;
491
+ const report: AgentAuditReport = { version: 1, tool: { name: 'mund', version: getVersion() }, generatedAt: new Date().toISOString(), cwd, servers, skills, summary };
492
+
493
+ if (opts.writeCache !== false) {
494
+ const path = auditCachePath(cwd, opts.harness);
495
+ await mkdir(dirname(path), { recursive: true });
496
+ await writeFile(path, JSON.stringify(report, null, 2));
497
+ }
498
+ return report;
499
+ }
@@ -497,6 +497,9 @@ export class McpServerAnalyzer implements IAnalyzer {
497
497
  const name = (manifest.name || '').toLowerCase().replace(/[^a-z0-9]/g, '');
498
498
 
499
499
  if (!name) return issues;
500
+ // A known legitimate name can't be a typosquat of a different one
501
+ // ("github" is two edits from "gitlab").
502
+ if (LEGITIMATE_SERVERS.some((l) => l.replace(/[^a-z0-9]/g, '') === name)) return issues;
500
503
 
501
504
  for (const legitimate of LEGITIMATE_SERVERS) {
502
505
  const legitNormalized = legitimate.replace(/[^a-z0-9]/g, '');
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Mund - The Guardian Protocol
3
+ * `mund-mcp agent-audit` and `mund-mcp check-skill`: pre-install checks
4
+ * used by the Weave Protocol harness adapters.
5
+ */
6
+
7
+ import { readFileSync, statSync } from 'fs';
8
+ import { resolve } from 'path';
9
+ import { runAgentAudit, scanSkillContent, scanSkillDir, verdictFor, type Verdict } from '../agent-audit.js';
10
+ import { computeRiskScore } from '../scoring.js';
11
+
12
+ const flag = (args: string[], name: string) => {
13
+ const hit = args.find((a) => a === `--${name}` || a.startsWith(`--${name}=`));
14
+ if (!hit) return undefined;
15
+ return hit.includes('=') ? hit.slice(hit.indexOf('=') + 1) : true;
16
+ };
17
+ const RANK: Record<Verdict, number> = { allow: 0, warn: 1, block: 2 };
18
+
19
+ export const AGENT_USAGE = `Usage:
20
+ mund-mcp agent-audit [--cwd=DIR] [--live] [--json] [--fail-on=block|warn|none] [--timeout=MS]
21
+ [--accept-changes=NAME[,NAME…]|all] [--harness=claude|antigravity]
22
+ Audit installed skills and configured MCP servers (Claude Code layout).
23
+ --live connects to approved servers, scans their tool descriptions and
24
+ checks them against the rug-pull baseline. Writes a verdict cache used
25
+ by the harness hooks. --accept-changes re-baselines servers whose tool
26
+ changes you have reviewed.
27
+
28
+ mund-mcp check-skill <SKILL.md | skill-dir | -> [--json]
29
+ Scan one skill before installing it. "-" reads content from stdin.
30
+ Exit code 1 means "block".
31
+ `;
32
+
33
+ export async function runAgentAuditCli(args: string[]): Promise<number> {
34
+ if (flag(args, 'help')) { process.stdout.write(AGENT_USAGE); return 0; }
35
+ const timeout = Number(flag(args, 'timeout') ?? 8000);
36
+ const accept = typeof flag(args, 'accept-changes') === 'string' ? String(flag(args, 'accept-changes')).split(',') : undefined;
37
+ const harness = flag(args, 'harness') === 'antigravity' ? 'antigravity' : 'claude';
38
+ const report = await runAgentAudit({ cwd: (flag(args, 'cwd') as string) || process.cwd(), live: !!flag(args, 'live') || !!accept, timeoutMs: timeout, accept, harness });
39
+ if (flag(args, 'json')) {
40
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
41
+ } else {
42
+ const icon = { allow: '✓', warn: '!', block: '✗' };
43
+ for (const s of report.servers) {
44
+ const live = s.live.status === 'ok' ? `${s.live.tools} tools, baseline ${s.live.rugpull}` : `live ${s.live.status}${s.live.reason ? `: ${s.live.reason}` : ''}`;
45
+ console.log(`${icon[s.verdict]} MCP ${s.name} (${s.scope}) ${s.verdict.toUpperCase()} · ${live}`);
46
+ for (const i of s.issues.filter((x) => x.severity !== 'info')) console.log(` ${i.severity.padEnd(8)} ${i.rule_name}`);
47
+ }
48
+ for (const k of report.skills) {
49
+ console.log(`${icon[k.verdict]} SKILL ${k.name} (${k.scope}) ${k.verdict.toUpperCase()} · risk ${k.risk.score}/100`);
50
+ for (const i of k.issues.filter((x) => x.severity !== 'info' && x.severity !== 'low')) console.log(` ${i.severity.padEnd(8)} ${i.rule_name}`);
51
+ }
52
+ console.log(`\n${report.servers.length} MCP server(s), ${report.skills.length} skill(s): ${report.summary.block} block, ${report.summary.warn} warn, ${report.summary.allow} allow`);
53
+ }
54
+ const failOn = (flag(args, 'fail-on') as string) || 'block';
55
+ if (failOn === 'none') return 0;
56
+ const worst = [...report.servers, ...report.skills].reduce((w, x) => Math.max(w, RANK[x.verdict]), 0);
57
+ return worst >= RANK[failOn as Verdict] ? 1 : 0;
58
+ }
59
+
60
+ export async function runCheckSkillCli(args: string[]): Promise<number> {
61
+ const target = args.find((a) => !a.startsWith('--'));
62
+ if (!target || flag(args, 'help')) { process.stdout.write(AGENT_USAGE); return target ? 0 : 2; }
63
+ let result: { verdict: Verdict; risk: ReturnType<typeof computeRiskScore>; issues: Awaited<ReturnType<typeof scanSkillContent>> };
64
+ if (target === '-') {
65
+ const issues = await scanSkillContent(readFileSync(0, 'utf8'));
66
+ result = { verdict: verdictFor(issues), risk: computeRiskScore(issues), issues };
67
+ } else if (statSync(resolve(target)).isDirectory()) {
68
+ const r = await scanSkillDir(resolve(target));
69
+ result = { verdict: r.verdict, risk: r.risk, issues: r.issues };
70
+ } else {
71
+ const issues = await scanSkillContent(readFileSync(resolve(target), 'utf8'));
72
+ result = { verdict: verdictFor(issues), risk: computeRiskScore(issues), issues };
73
+ }
74
+ if (flag(args, 'json')) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
75
+ else {
76
+ console.log(`${result.verdict.toUpperCase()} · risk ${result.risk.score}/100 (${result.risk.label})`);
77
+ for (const i of result.issues) console.log(` ${i.severity.padEnd(8)} ${i.rule_name}`);
78
+ }
79
+ return result.verdict === 'block' ? 1 : 0;
80
+ }
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import { AnalyzerEngine } from './analyzers/index.js';
16
16
  import { NotificationHub } from './notifications/index.js';
17
17
  import { registerAllTools } from './tools/index.js';
18
18
  import { runScanCli } from './cli/scan.js';
19
+ import { runAgentAuditCli, runCheckSkillCli } from './cli/agent.js';
19
20
  import { getVersion } from './version.js';
20
21
  import { loadCanariesFromEnv } from './canary.js';
21
22
 
@@ -260,6 +261,8 @@ MCP-based security monitoring for agentic AI systems
260
261
 
261
262
  Usage: mund-mcp [options] Run the MCP server
262
263
  mund-mcp scan [paths...] Scan files (see: mund-mcp scan --help)
264
+ mund-mcp agent-audit Audit installed skills + MCP servers
265
+ mund-mcp check-skill <path> Scan a skill before installing it
263
266
 
264
267
  Options:
265
268
  --help, -h Show this help message
@@ -322,6 +325,12 @@ async function main(): Promise<void> {
322
325
  if (args[0] === 'scan') {
323
326
  process.exit(await runScanCli(args.slice(1)));
324
327
  }
328
+ if (args[0] === 'agent-audit') {
329
+ process.exit(await runAgentAuditCli(args.slice(1)));
330
+ }
331
+ if (args[0] === 'check-skill') {
332
+ process.exit(await runCheckSkillCli(args.slice(1)));
333
+ }
325
334
 
326
335
  // Parse CLI arguments
327
336
  if (args.includes('--help') || args.includes('-h')) {
package/src/rugpull.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { createHash } from 'crypto';
22
- import { readFile, writeFile, mkdir } from 'fs/promises';
22
+ import { readFile, writeFile, mkdir, rename } from 'fs/promises';
23
23
  import { homedir } from 'os';
24
24
  import { dirname, join } from 'path';
25
25
  import type { IAnalyzer, SecurityIssue, Severity } from './types.js';
@@ -179,12 +179,26 @@ export class BaselineStore {
179
179
  return (await this.load())[serverId];
180
180
  }
181
181
 
182
+ /**
183
+ * Read-modify-write, serialized per file within the process (parallel
184
+ * audits of several servers used to overwrite each other's baselines),
185
+ * and written atomically via rename so a crash can't truncate the file.
186
+ */
182
187
  async put(baseline: ManifestBaseline): Promise<void> {
183
- const all = await this.load();
184
- all[baseline.serverId] = baseline;
185
- await mkdir(dirname(this.path), { recursive: true });
186
- await writeFile(this.path, JSON.stringify({ version: 1, baselines: all }, null, 2));
188
+ const prev = BaselineStore.queues.get(this.path) ?? Promise.resolve();
189
+ const next = prev.catch(() => {}).then(async () => {
190
+ const all = await this.load();
191
+ all[baseline.serverId] = baseline;
192
+ await mkdir(dirname(this.path), { recursive: true });
193
+ const tmp = `${this.path}.${process.pid}.${Date.now()}.tmp`;
194
+ await writeFile(tmp, JSON.stringify({ version: 1, baselines: all }, null, 2));
195
+ await rename(tmp, this.path);
196
+ });
197
+ BaselineStore.queues.set(this.path, next);
198
+ return next;
187
199
  }
200
+
201
+ private static queues = new Map<string, Promise<void>>();
188
202
  }
189
203
 
190
204
  /**