@weave_protocol/mund 0.3.0 → 0.4.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 (116) hide show
  1. package/DETECTION_CLAIMS.md +48 -0
  2. package/README.md +91 -30
  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/index.d.ts.map +1 -1
  8. package/dist/analyzers/index.js +3 -1
  9. package/dist/analyzers/index.js.map +1 -1
  10. package/dist/analyzers/mcp-server-analyzer.d.ts.map +1 -1
  11. package/dist/analyzers/mcp-server-analyzer.js +4 -0
  12. package/dist/analyzers/mcp-server-analyzer.js.map +1 -1
  13. package/dist/canary.d.ts +48 -0
  14. package/dist/canary.d.ts.map +1 -0
  15. package/dist/canary.js +149 -0
  16. package/dist/canary.js.map +1 -0
  17. package/dist/cli/agent.d.ts +9 -0
  18. package/dist/cli/agent.d.ts.map +1 -0
  19. package/dist/cli/agent.js +91 -0
  20. package/dist/cli/agent.js.map +1 -0
  21. package/dist/cli/scan.d.ts +2 -1
  22. package/dist/cli/scan.d.ts.map +1 -1
  23. package/dist/cli/scan.js +10 -1
  24. package/dist/cli/scan.js.map +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +11 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/rugpull.d.ts +79 -0
  29. package/dist/rugpull.d.ts.map +1 -0
  30. package/dist/rugpull.js +190 -0
  31. package/dist/rugpull.js.map +1 -0
  32. package/dist/tools/canary-rugpull.d.ts +7 -0
  33. package/dist/tools/canary-rugpull.d.ts.map +1 -0
  34. package/dist/tools/canary-rugpull.js +50 -0
  35. package/dist/tools/canary-rugpull.js.map +1 -0
  36. package/dist/tools/index.d.ts +1 -0
  37. package/dist/tools/index.d.ts.map +1 -1
  38. package/dist/tools/index.js +4 -0
  39. package/dist/tools/index.js.map +1 -1
  40. package/dist/tools/monitoring.js +2 -2
  41. package/dist/tools/monitoring.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/agent-audit.ts +499 -0
  44. package/src/analyzers/index.ts +3 -1
  45. package/src/analyzers/mcp-server-analyzer.ts +3 -0
  46. package/src/canary.ts +165 -0
  47. package/src/cli/agent.ts +80 -0
  48. package/src/cli/scan.ts +10 -1
  49. package/src/index.ts +11 -0
  50. package/src/rugpull.ts +247 -0
  51. package/src/tools/canary-rugpull.ts +75 -0
  52. package/src/tools/index.ts +5 -0
  53. package/src/tools/monitoring.ts +2 -2
  54. package/tests/analyzers/engine.test.ts +1 -1
  55. package/tests/analyzers/mcp-server-analyzer.test.ts +1 -3
  56. package/tests/canary.test.ts +79 -0
  57. package/tests/cli/scan.test.ts +2 -2
  58. package/tests/fixtures/canary/base64.json +9 -0
  59. package/tests/fixtures/canary/clean-output.json +8 -0
  60. package/tests/fixtures/canary/exact-leak.json +9 -0
  61. package/tests/fixtures/canary/lowercased.json +9 -0
  62. package/tests/fixtures/canary/other-token.json +8 -0
  63. package/tests/fixtures/canary/prefix-only.json +8 -0
  64. package/tests/fixtures/canary/reversed.json +9 -0
  65. package/tests/fixtures/canary/spaced-out.json +9 -0
  66. package/tests/fixtures/canary/zero-width.json +9 -0
  67. package/tests/fixtures/preinstall/fake-mcp-server.mjs +8 -0
  68. package/tests/fixtures/preinstall/mcp-config/clean-pinned.json +13 -0
  69. package/tests/fixtures/preinstall/mcp-config/env-reference.json +15 -0
  70. package/tests/fixtures/preinstall/mcp-config/exfil-service-url.json +10 -0
  71. package/tests/fixtures/preinstall/mcp-config/inline-token.json +16 -0
  72. package/tests/fixtures/preinstall/mcp-config/name-typosquat.json +13 -0
  73. package/tests/fixtures/preinstall/mcp-config/plain-http-remote.json +10 -0
  74. package/tests/fixtures/preinstall/mcp-config/scope-typosquat.json +13 -0
  75. package/tests/fixtures/preinstall/mcp-config/shell-pipeline-launcher.json +13 -0
  76. package/tests/fixtures/preinstall/mcp-config/unpinned-package.json +14 -0
  77. package/tests/fixtures/preinstall/skills/bidi-override/SKILL.md +6 -0
  78. package/tests/fixtures/preinstall/skills/bidi-override/expected.json +4 -0
  79. package/tests/fixtures/preinstall/skills/clean/SKILL.md +6 -0
  80. package/tests/fixtures/preinstall/skills/clean/expected.json +3 -0
  81. package/tests/fixtures/preinstall/skills/curl-bash-installer/SKILL.md +6 -0
  82. package/tests/fixtures/preinstall/skills/curl-bash-installer/expected.json +4 -0
  83. package/tests/fixtures/preinstall/skills/embedded-secret/SKILL.md +6 -0
  84. package/tests/fixtures/preinstall/skills/embedded-secret/expected.json +4 -0
  85. package/tests/fixtures/preinstall/skills/exfil-url/SKILL.md +6 -0
  86. package/tests/fixtures/preinstall/skills/exfil-url/expected.json +4 -0
  87. package/tests/fixtures/preinstall/skills/hidden-tag-characters/SKILL.md +6 -0
  88. package/tests/fixtures/preinstall/skills/hidden-tag-characters/expected.json +4 -0
  89. package/tests/fixtures/rugpull/description-changed/baseline.json +36 -0
  90. package/tests/fixtures/rugpull/description-changed/current.json +36 -0
  91. package/tests/fixtures/rugpull/description-changed/expected.json +11 -0
  92. package/tests/fixtures/rugpull/gains-execution/baseline.json +36 -0
  93. package/tests/fixtures/rugpull/gains-execution/current.json +36 -0
  94. package/tests/fixtures/rugpull/gains-execution/expected.json +18 -0
  95. package/tests/fixtures/rugpull/schema-changed/baseline.json +36 -0
  96. package/tests/fixtures/rugpull/schema-changed/current.json +39 -0
  97. package/tests/fixtures/rugpull/schema-changed/expected.json +11 -0
  98. package/tests/fixtures/rugpull/schema-key-reorder/baseline.json +36 -0
  99. package/tests/fixtures/rugpull/schema-key-reorder/current.json +36 -0
  100. package/tests/fixtures/rugpull/schema-key-reorder/expected.json +5 -0
  101. package/tests/fixtures/rugpull/server-renamed/baseline.json +36 -0
  102. package/tests/fixtures/rugpull/server-renamed/current.json +36 -0
  103. package/tests/fixtures/rugpull/server-renamed/expected.json +11 -0
  104. package/tests/fixtures/rugpull/tool-added/baseline.json +36 -0
  105. package/tests/fixtures/rugpull/tool-added/current.json +44 -0
  106. package/tests/fixtures/rugpull/tool-added/expected.json +11 -0
  107. package/tests/fixtures/rugpull/tool-removed/baseline.json +36 -0
  108. package/tests/fixtures/rugpull/tool-removed/current.json +21 -0
  109. package/tests/fixtures/rugpull/tool-removed/expected.json +11 -0
  110. package/tests/fixtures/rugpull/unchanged/baseline.json +36 -0
  111. package/tests/fixtures/rugpull/unchanged/current.json +36 -0
  112. package/tests/fixtures/rugpull/unchanged/expected.json +5 -0
  113. package/tests/fixtures.test.ts +40 -0
  114. package/tests/preinstall.test.ts +153 -0
  115. package/tests/rugpull.test.ts +80 -0
  116. package/tests/tools/canary-rugpull.test.ts +57 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Pre-install checks (agent-audit). Every fixture under
3
+ * tests/fixtures/preinstall is a published detection claim: the verdict in
4
+ * its expected file is what Mund must return.
5
+ */
6
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
7
+ import { tmpdir } from 'os';
8
+ import { join } from 'path';
9
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
10
+ import {
11
+ auditCachePath, checkMcpServerConfig, discoverAntigravityMcpServers, discoverMcpServers, discoverSkillDirs, launchedPackage, runAgentAudit, scanSkillDir, verdictFor,
12
+ } from '../src/agent-audit.js';
13
+ import { BaselineStore } from '../src/rugpull.js';
14
+
15
+ const FX = join(__dirname, 'fixtures', 'preinstall');
16
+ const FAKE_SERVER = join(FX, 'fake-mcp-server.mjs');
17
+
18
+ describe('skill fixtures (detection claims)', () => {
19
+ for (const name of readdirSync(join(FX, 'skills'))) {
20
+ const expected = JSON.parse(readFileSync(join(FX, 'skills', name, 'expected.json'), 'utf8'));
21
+ it(`${name} → ${expected.verdict}`, async () => {
22
+ const audit = await scanSkillDir(join(FX, 'skills', name));
23
+ expect(audit.verdict).toBe(expected.verdict);
24
+ if (expected.rule) expect(audit.issues.map((i) => i.rule_id)).toContain(expected.rule);
25
+ });
26
+ }
27
+ });
28
+
29
+ describe('MCP config fixtures (detection claims)', () => {
30
+ for (const file of readdirSync(join(FX, 'mcp-config'))) {
31
+ const fx = JSON.parse(readFileSync(join(FX, 'mcp-config', file), 'utf8'));
32
+ it(`${file.replace('.json', '')} → ${fx.expected.verdict}`, async () => {
33
+ const issues = await checkMcpServerConfig('fixture', fx.config);
34
+ expect(verdictFor(issues)).toBe(fx.expected.verdict);
35
+ if (fx.expected.rule) expect(issues.map((i) => i.rule_id)).toContain(fx.expected.rule);
36
+ });
37
+ }
38
+ });
39
+
40
+ describe('launchedPackage', () => {
41
+ it.each([
42
+ [{ command: 'npx', args: ['-y', '@scope/pkg@1.2.3', '--flag'] }, { name: '@scope/pkg', version: '1.2.3' }],
43
+ [{ command: '/usr/local/bin/npx', args: ['pkg'] }, { name: 'pkg' }],
44
+ [{ command: 'pnpm', args: ['dlx', 'pkg@2'] }, { name: 'pkg', version: '2' }],
45
+ [{ command: 'uvx', args: ['mcp-server-git'] }, { name: 'mcp-server-git' }],
46
+ [{ command: 'node', args: ['server.js'] }, null],
47
+ ])('%j', (config, expected) => expect(launchedPackage(config)).toEqual(expected));
48
+ });
49
+
50
+ describe('discovery and live checks', () => {
51
+ let home: string;
52
+ let project: string;
53
+ let store: BaselineStore;
54
+ const writeJson = (p: string, v: unknown) => { mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(p, JSON.stringify(v)); };
55
+ const fake = (env: Record<string, string> = {}) => ({ command: process.execPath, args: [FAKE_SERVER], env });
56
+
57
+ beforeEach(() => {
58
+ home = mkdtempSync(join(tmpdir(), 'mund-home-'));
59
+ project = mkdtempSync(join(tmpdir(), 'mund-proj-'));
60
+ store = new BaselineStore(join(home, 'baselines.json'));
61
+ process.env.MUND_AUDIT_CACHE_DIR = join(home, 'cache');
62
+ });
63
+ afterEach(() => {
64
+ rmSync(home, { recursive: true, force: true });
65
+ rmSync(project, { recursive: true, force: true });
66
+ delete process.env.MUND_AUDIT_CACHE_DIR;
67
+ });
68
+
69
+ it('finds project, local, user and Claude Desktop servers plus skills', () => {
70
+ writeJson(join(project, '.mcp.json'), { mcpServers: { proj: fake() } });
71
+ writeJson(join(home, '.claude.json'), { mcpServers: { usr: fake() }, projects: { [project]: { mcpServers: { loc: fake() } } } });
72
+ mkdirSync(join(project, '.claude', 'skills', 'fmt'), { recursive: true });
73
+ writeFileSync(join(project, '.claude', 'skills', 'fmt', 'SKILL.md'), 'Formats tables.');
74
+ const servers = discoverMcpServers(project, home);
75
+ expect(servers.map((s) => [s.name, s.scope, s.approved])).toEqual([
76
+ ['proj', 'project', false], ['loc', 'local', true], ['usr', 'user', true],
77
+ ]);
78
+ });
79
+
80
+ it('never starts an unapproved project server, but checks it statically', async () => {
81
+ writeJson(join(project, '.mcp.json'), { mcpServers: { proj: fake() } });
82
+ const r = await runAgentAudit({ cwd: project, home, live: true, store });
83
+ expect(r.servers[0].live).toMatchObject({ status: 'skipped' });
84
+ expect(r.servers[0].live.reason).toMatch(/not approved/);
85
+ });
86
+
87
+ it('live-checks a project server once it is approved, and writes the verdict cache', async () => {
88
+ writeJson(join(project, '.mcp.json'), { mcpServers: { proj: fake() } });
89
+ writeJson(join(home, '.claude.json'), { projects: { [project]: { enabledMcpjsonServers: ['proj'] } } });
90
+ const r = await runAgentAudit({ cwd: project, home, live: true, store });
91
+ expect(r.servers[0]).toMatchObject({ verdict: 'allow', live: { status: 'ok', tools: 1, rugpull: 'baseline_created' } });
92
+ expect(JSON.parse(readFileSync(auditCachePath(project), 'utf8')).servers[0].name).toBe('proj');
93
+ });
94
+
95
+ it('detects a rug pull: changed description warns until accepted', async () => {
96
+ writeJson(join(home, '.claude.json'), { mcpServers: { notes: fake() } });
97
+ await runAgentAudit({ cwd: project, home, live: true, store });
98
+ writeJson(join(home, '.claude.json'), { mcpServers: { notes: fake({ TOOL_DESC: 'Read a saved note by title, newest first.' }) } });
99
+ const changed = await runAgentAudit({ cwd: project, home, live: true, store });
100
+ expect(changed.servers[0]).toMatchObject({ verdict: 'warn', live: { rugpull: 'changed' } });
101
+ await runAgentAudit({ cwd: project, home, live: true, store, accept: ['notes'] });
102
+ expect((await runAgentAudit({ cwd: project, home, live: true, store })).servers[0]).toMatchObject({ verdict: 'allow', live: { rugpull: 'unchanged' } });
103
+ });
104
+
105
+ it('blocks a rug pull that adds command execution', async () => {
106
+ writeJson(join(home, '.claude.json'), { mcpServers: { notes: fake() } });
107
+ await runAgentAudit({ cwd: project, home, live: true, store });
108
+ writeJson(join(home, '.claude.json'), { mcpServers: { notes: fake({ EXTRA_TOOL: 'run', EXTRA_DESC: 'Run a shell command on the host.' }) } });
109
+ const r = await runAgentAudit({ cwd: project, home, live: true, store });
110
+ expect(r.servers[0].verdict).toBe('block');
111
+ expect(r.servers[0].issues.some((i) => i.rule_id === 'mcp_rugpull_tool_added' && i.severity === 'critical')).toBe(true);
112
+ });
113
+
114
+ it('never starts a server whose package or URL is already distrusted', async () => {
115
+ // The launcher path doesn't exist, so even a regression couldn't run anything.
116
+ writeJson(join(home, '.claude.json'), { mcpServers: { squat: { command: join(home, 'no-such-dir', 'npx'), args: ['-y', '@modelcontextprotcol/server-filesystem@1.0.0'] } } });
117
+ const r = await runAgentAudit({ cwd: project, home, live: true, store });
118
+ expect(r.servers[0].verdict).toBe('block');
119
+ expect(r.servers[0].live).toMatchObject({ status: 'skipped' });
120
+ expect(r.servers[0].live.reason).toMatch(/not started/);
121
+ });
122
+
123
+ it('Antigravity layout: workspace servers static-only, global servers live, disabled never started', async () => {
124
+ writeJson(join(project, '.agents', 'mcp_config.json'), { mcpServers: { ws: fake() } });
125
+ writeJson(join(home, '.gemini', 'config', 'mcp_config.json'), { mcpServers: {
126
+ glob: fake(), off: { ...fake(), disabled: true }, remote: { serverUrl: 'http://mcp.example.com/mcp' },
127
+ } });
128
+ mkdirSync(join(project, '.agents', 'skills', 'fmt'), { recursive: true });
129
+ writeFileSync(join(project, '.agents', 'skills', 'fmt', 'SKILL.md'), 'Formats tables.');
130
+ mkdirSync(join(home, '.gemini', 'antigravity-cli', 'plugins', 'p1', 'skills', 'ps'), { recursive: true });
131
+ writeFileSync(join(home, '.gemini', 'antigravity-cli', 'plugins', 'p1', 'skills', 'ps', 'SKILL.md'), 'Plugin skill.');
132
+
133
+ expect(discoverAntigravityMcpServers(project, home).map((s) => [s.name, s.scope, s.approved])).toEqual([
134
+ ['ws', 'workspace', false], ['glob', 'global', true], ['off', 'global', false], ['remote', 'global', true],
135
+ ]);
136
+ expect(discoverSkillDirs(project, home, 'antigravity').map((d) => d.scope)).toEqual(['workspace', 'plugin']);
137
+
138
+ const r = await runAgentAudit({ cwd: project, home, live: true, store, harness: 'antigravity', timeoutMs: 4000 });
139
+ const by = Object.fromEntries(r.servers.map((s) => [s.name, s]));
140
+ expect(by.ws.live.status).toBe('skipped');
141
+ expect(by.glob.live).toMatchObject({ status: 'ok', tools: 1 });
142
+ expect(by.off.live.reason).toMatch(/disabled/);
143
+ expect(by.remote.issues.map((i) => i.rule_id)).toContain('mcp_insecure_transport');
144
+ expect(readFileSync(auditCachePath(project, 'antigravity'), 'utf8')).toContain('"glob"');
145
+ expect(auditCachePath(project, 'antigravity')).not.toBe(auditCachePath(project));
146
+ });
147
+
148
+ it('reports a server that fails to start without crashing the audit', async () => {
149
+ writeJson(join(home, '.claude.json'), { mcpServers: { broken: { command: join(home, 'does-not-exist') } } });
150
+ const r = await runAgentAudit({ cwd: project, home, live: true, store, timeoutMs: 3000 });
151
+ expect(r.servers[0].live.status).toBe('error');
152
+ });
153
+ });
@@ -0,0 +1,80 @@
1
+ import { mkdtempSync, rmSync } from 'fs';
2
+ import { tmpdir } from 'os';
3
+ import { join } from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { BaselineStore, changesToIssues, checkRugPull, diffManifest, fingerprintManifest } from '../src/rugpull.js';
6
+ import type { IAnalyzer } from '../src/types.js';
7
+
8
+ const manifest = (desc: string, extra: object[] = []) => ({
9
+ name: 'notes', version: '1.0.0',
10
+ tools: [{ name: 'read_note', description: desc, inputSchema: { type: 'object', properties: { title: { type: 'string' } } } }, ...extra],
11
+ });
12
+
13
+ describe('checkRugPull with a baseline store', () => {
14
+ let dir: string;
15
+ let store: BaselineStore;
16
+ beforeEach(() => {
17
+ dir = mkdtempSync(join(tmpdir(), 'mund-rugpull-'));
18
+ store = new BaselineStore(join(dir, 'baselines.json'));
19
+ });
20
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
21
+
22
+ it('records a baseline on first check, then reports unchanged', async () => {
23
+ expect((await checkRugPull('srv', manifest('Read a note.'), { store })).status).toBe('baseline_created');
24
+ expect((await checkRugPull('srv', manifest('Read a note.'), { store })).status).toBe('unchanged');
25
+ });
26
+
27
+ it('keeps reporting a change until it is accepted', async () => {
28
+ await checkRugPull('srv', manifest('Read a note.'), { store });
29
+ const changed = manifest('Read a note, newest first.');
30
+ expect((await checkRugPull('srv', changed, { store })).status).toBe('changed');
31
+ expect((await checkRugPull('srv', changed, { store })).status).toBe('changed');
32
+ await checkRugPull('srv', changed, { store, acceptChanges: true });
33
+ expect((await checkRugPull('srv', changed, { store })).status).toBe('unchanged');
34
+ });
35
+
36
+ it('keeps separate baselines per server', async () => {
37
+ await checkRugPull('a', manifest('Read a note.'), { store });
38
+ expect((await checkRugPull('b', manifest('Something else.'), { store })).status).toBe('baseline_created');
39
+ expect((await checkRugPull('a', manifest('Read a note.'), { store })).status).toBe('unchanged');
40
+ });
41
+ });
42
+
43
+ describe('escalation', () => {
44
+ it('escalates a changed tool to critical when the analyzer flags it', async () => {
45
+ // Stub analyzer: stands in for Mund's MCP analyzer so no attack text is needed here.
46
+ const analyzer: IAnalyzer = {
47
+ name: 'stub', type: 'mcp',
48
+ analyze: async (content) => content.includes('FLAGGED')
49
+ ? [{ rule_id: 'x', rule_name: 'Tool Description Injection', type: 'mcp', severity: 'critical', action: 'block', match: '' }]
50
+ : [],
51
+ };
52
+ const baseline = fingerprintManifest('srv', manifest('Read a note.'));
53
+ const [change] = await diffManifest(baseline, manifest('Read a note. FLAGGED'), analyzer);
54
+ expect(change).toMatchObject({ kind: 'description_changed', severity: 'critical' });
55
+ expect(change.escalated).toContain('Tool Description Injection');
56
+ });
57
+
58
+ it('does not escalate a benign rewording', async () => {
59
+ const baseline = fingerprintManifest('srv', manifest('Read a note.'));
60
+ const [change] = await diffManifest(baseline, manifest('Read a saved note by title.'));
61
+ expect(change).toMatchObject({ severity: 'high' });
62
+ expect(change.escalated).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe('changesToIssues', () => {
67
+ it('maps changes to blocking/alerting SecurityIssues', () => {
68
+ const issues = changesToIssues({
69
+ serverId: 'srv', status: 'changed',
70
+ changes: [
71
+ { kind: 'description_changed', tool: 'read_note', severity: 'high', detail: 'd' },
72
+ { kind: 'tool_removed', tool: 'old', severity: 'low', detail: 'r' },
73
+ ],
74
+ });
75
+ expect(issues.map(i => [i.rule_id, i.action, i.match])).toEqual([
76
+ ['mcp_rugpull_description_changed', 'block', 'srv:read_note'],
77
+ ['mcp_rugpull_tool_removed', 'alert', 'srv:old'],
78
+ ]);
79
+ });
80
+ });
@@ -0,0 +1,57 @@
1
+ import { mkdtempSync, rmSync } from 'fs';
2
+ import { tmpdir } from 'os';
3
+ import { join } from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { registerCanaryRugPullTools } from '../../src/tools/canary-rugpull.js';
6
+ import { canaries } from '../../src/canary.js';
7
+ import { FakeMcpServer } from '../helpers.js';
8
+
9
+ const parse = (r: any) => JSON.parse(r.content[0].text);
10
+
11
+ describe('canary + rug-pull MCP tools', () => {
12
+ let server: FakeMcpServer;
13
+ let dir: string;
14
+ beforeEach(() => {
15
+ server = new FakeMcpServer();
16
+ registerCanaryRugPullTools(server as any);
17
+ dir = mkdtempSync(join(tmpdir(), 'mund-tools-'));
18
+ vi.stubEnv('MUND_BASELINE_PATH', join(dir, 'baselines.json'));
19
+ });
20
+ afterEach(() => {
21
+ vi.unstubAllEnvs();
22
+ rmSync(dir, { recursive: true, force: true });
23
+ for (const c of canaries.list()) canaries.remove(c.token);
24
+ });
25
+
26
+ it('creates, lists and removes canaries', async () => {
27
+ const created = parse(await server.call('mund_create_canary', { label: 'bot' }));
28
+ expect(created.how_to_use).toContain(created.token);
29
+ expect(parse(await server.call('mund_list_canaries', {})).canaries).toHaveLength(1);
30
+ expect(parse(await server.call('mund_remove_canary', { token: created.token })).removed).toBe(true);
31
+ });
32
+
33
+ it('returns an error for a too-short custom token', async () => {
34
+ expect(parse(await server.call('mund_create_canary', { token: 'short' })).error).toMatch(/at least 12/);
35
+ });
36
+
37
+ it('walks baseline -> change -> accept for an MCP server', async () => {
38
+ const m = (d: string) => JSON.stringify({ name: 's', version: '1', tools: [{ name: 't', description: d }] });
39
+ expect(parse(await server.call('mund_check_mcp_rugpull', { server_id: 's', manifest: m('Read a note.') })).recommendation).toBe('BASELINE_RECORDED');
40
+ const changed = parse(await server.call('mund_check_mcp_rugpull', { server_id: 's', manifest: m('Read a note, newest first.') }));
41
+ expect(changed).toMatchObject({ status: 'changed', recommendation: 'REVIEW_CHANGES', risk_label: 'high' });
42
+ expect(parse(await server.call('mund_check_mcp_rugpull', { server_id: 's', manifest: m('Read a note, newest first.'), accept_changes: true })).recommendation).toBe('CHANGES_ACCEPTED');
43
+ expect(parse(await server.call('mund_check_mcp_rugpull', { server_id: 's', manifest: m('Read a note, newest first.') })).recommendation).toBe('UNCHANGED');
44
+ });
45
+
46
+ it('says DO_NOT_USE when a changed tool gains command execution', async () => {
47
+ const m = (d: string) => JSON.stringify({ name: 's', version: '1', tools: [{ name: 't', description: d }] });
48
+ await server.call('mund_check_mcp_rugpull', { server_id: 's', manifest: m('Read a note.') });
49
+ const r = parse(await server.call('mund_check_mcp_rugpull', { server_id: 's', manifest: m('Run a shell command on the host.') }));
50
+ expect(r.recommendation).toBe('DO_NOT_USE');
51
+ expect(r.risk_label).toBe('critical');
52
+ });
53
+
54
+ it('rejects invalid manifests', async () => {
55
+ expect(parse(await server.call('mund_check_mcp_rugpull', { server_id: 's', manifest: 'not json' })).error).toMatch(/Invalid manifest/);
56
+ });
57
+ });