@weave_protocol/mund 0.3.0 → 0.3.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 (74) hide show
  1. package/README.md +70 -30
  2. package/dist/analyzers/index.d.ts.map +1 -1
  3. package/dist/analyzers/index.js +3 -1
  4. package/dist/analyzers/index.js.map +1 -1
  5. package/dist/canary.d.ts +48 -0
  6. package/dist/canary.d.ts.map +1 -0
  7. package/dist/canary.js +149 -0
  8. package/dist/canary.js.map +1 -0
  9. package/dist/cli/scan.d.ts +2 -1
  10. package/dist/cli/scan.d.ts.map +1 -1
  11. package/dist/cli/scan.js +10 -1
  12. package/dist/cli/scan.js.map +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/rugpull.d.ts +73 -0
  17. package/dist/rugpull.d.ts.map +1 -0
  18. package/dist/rugpull.js +177 -0
  19. package/dist/rugpull.js.map +1 -0
  20. package/dist/tools/canary-rugpull.d.ts +7 -0
  21. package/dist/tools/canary-rugpull.d.ts.map +1 -0
  22. package/dist/tools/canary-rugpull.js +50 -0
  23. package/dist/tools/canary-rugpull.js.map +1 -0
  24. package/dist/tools/index.d.ts +1 -0
  25. package/dist/tools/index.d.ts.map +1 -1
  26. package/dist/tools/index.js +4 -0
  27. package/dist/tools/index.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/analyzers/index.ts +3 -1
  30. package/src/canary.ts +165 -0
  31. package/src/cli/scan.ts +10 -1
  32. package/src/index.ts +2 -0
  33. package/src/rugpull.ts +233 -0
  34. package/src/tools/canary-rugpull.ts +75 -0
  35. package/src/tools/index.ts +5 -0
  36. package/tests/analyzers/engine.test.ts +1 -1
  37. package/tests/canary.test.ts +79 -0
  38. package/tests/cli/scan.test.ts +2 -2
  39. package/tests/fixtures/canary/base64.json +9 -0
  40. package/tests/fixtures/canary/clean-output.json +8 -0
  41. package/tests/fixtures/canary/exact-leak.json +9 -0
  42. package/tests/fixtures/canary/lowercased.json +9 -0
  43. package/tests/fixtures/canary/other-token.json +8 -0
  44. package/tests/fixtures/canary/prefix-only.json +8 -0
  45. package/tests/fixtures/canary/reversed.json +9 -0
  46. package/tests/fixtures/canary/spaced-out.json +9 -0
  47. package/tests/fixtures/canary/zero-width.json +9 -0
  48. package/tests/fixtures/rugpull/description-changed/baseline.json +36 -0
  49. package/tests/fixtures/rugpull/description-changed/current.json +36 -0
  50. package/tests/fixtures/rugpull/description-changed/expected.json +11 -0
  51. package/tests/fixtures/rugpull/gains-execution/baseline.json +36 -0
  52. package/tests/fixtures/rugpull/gains-execution/current.json +36 -0
  53. package/tests/fixtures/rugpull/gains-execution/expected.json +18 -0
  54. package/tests/fixtures/rugpull/schema-changed/baseline.json +36 -0
  55. package/tests/fixtures/rugpull/schema-changed/current.json +39 -0
  56. package/tests/fixtures/rugpull/schema-changed/expected.json +11 -0
  57. package/tests/fixtures/rugpull/schema-key-reorder/baseline.json +36 -0
  58. package/tests/fixtures/rugpull/schema-key-reorder/current.json +36 -0
  59. package/tests/fixtures/rugpull/schema-key-reorder/expected.json +5 -0
  60. package/tests/fixtures/rugpull/server-renamed/baseline.json +36 -0
  61. package/tests/fixtures/rugpull/server-renamed/current.json +36 -0
  62. package/tests/fixtures/rugpull/server-renamed/expected.json +11 -0
  63. package/tests/fixtures/rugpull/tool-added/baseline.json +36 -0
  64. package/tests/fixtures/rugpull/tool-added/current.json +44 -0
  65. package/tests/fixtures/rugpull/tool-added/expected.json +11 -0
  66. package/tests/fixtures/rugpull/tool-removed/baseline.json +36 -0
  67. package/tests/fixtures/rugpull/tool-removed/current.json +21 -0
  68. package/tests/fixtures/rugpull/tool-removed/expected.json +11 -0
  69. package/tests/fixtures/rugpull/unchanged/baseline.json +36 -0
  70. package/tests/fixtures/rugpull/unchanged/current.json +36 -0
  71. package/tests/fixtures/rugpull/unchanged/expected.json +5 -0
  72. package/tests/fixtures.test.ts +40 -0
  73. package/tests/rugpull.test.ts +80 -0
  74. package/tests/tools/canary-rugpull.test.ts +57 -0
package/src/rugpull.ts ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Mund - The Guardian Protocol
3
+ * MCP Rug-Pull Detection (OWASP ASI04)
4
+ *
5
+ * A rug pull is an MCP server that passes review at install time and
6
+ * then changes what its tools say or do. Static scanning can't see that,
7
+ * so Mund records a fingerprint of every tool when a server is first
8
+ * approved (the baseline) and diffs later manifests against it.
9
+ *
10
+ * tool description changed -> high (the classic rug pull)
11
+ * tool input schema changed -> medium
12
+ * tool added -> medium
13
+ * tool removed -> low
14
+ * server name changed -> high
15
+ *
16
+ * Any changed or added tool is re-scanned with the MCP analyzer; if it
17
+ * now trips a high/critical finding, or newly gains execution
18
+ * capability, the change is escalated to critical.
19
+ */
20
+
21
+ import { createHash } from 'crypto';
22
+ import { readFile, writeFile, mkdir } from 'fs/promises';
23
+ import { homedir } from 'os';
24
+ import { dirname, join } from 'path';
25
+ import type { IAnalyzer, SecurityIssue, Severity } from './types.js';
26
+ import { McpServerAnalyzer, type McpServerManifest, type McpToolDefinition } from './analyzers/mcp-server-analyzer.js';
27
+
28
+ export type ToolChangeKind = 'description_changed' | 'schema_changed' | 'tool_added' | 'tool_removed' | 'server_renamed';
29
+
30
+ export interface ToolFingerprint {
31
+ descriptionHash: string;
32
+ schemaHash: string;
33
+ /** Kept so a diff can show what the description used to say. */
34
+ description: string;
35
+ }
36
+
37
+ export interface ManifestBaseline {
38
+ serverId: string;
39
+ serverName: string;
40
+ recordedAt: string;
41
+ tools: Record<string, ToolFingerprint>;
42
+ }
43
+
44
+ export interface ToolChange {
45
+ kind: ToolChangeKind;
46
+ tool: string;
47
+ severity: Severity;
48
+ detail: string;
49
+ /** Set when the changed tool now trips the MCP analyzer or gains execution capability. */
50
+ escalated?: string;
51
+ }
52
+
53
+ export interface RugPullResult {
54
+ serverId: string;
55
+ status: 'baseline_created' | 'unchanged' | 'changed';
56
+ changes: ToolChange[];
57
+ baselineRecordedAt?: string;
58
+ }
59
+
60
+ const SEVERITY_RANK: Record<Severity, number> = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
61
+
62
+ export function defaultBaselinePath(): string {
63
+ if (process.env.MUND_BASELINE_PATH) return process.env.MUND_BASELINE_PATH;
64
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
65
+ return join(base, 'mund', 'mcp-baselines.json');
66
+ }
67
+
68
+ /** Stable JSON: object keys sorted so equivalent schemas hash the same. */
69
+ function canonical(value: unknown): string {
70
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
71
+ if (value && typeof value === 'object') {
72
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b));
73
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`;
74
+ }
75
+ return JSON.stringify(value ?? null);
76
+ }
77
+
78
+ const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
79
+
80
+ export function fingerprintManifest(serverId: string, manifest: McpServerManifest): ManifestBaseline {
81
+ const tools: Record<string, ToolFingerprint> = {};
82
+ for (const tool of manifest.tools ?? []) {
83
+ tools[tool.name] = {
84
+ descriptionHash: sha256(tool.description ?? ''),
85
+ schemaHash: sha256(canonical(tool.inputSchema ?? {})),
86
+ description: (tool.description ?? '').slice(0, 2000),
87
+ };
88
+ }
89
+ return { serverId, serverName: manifest.name ?? serverId, recordedAt: new Date().toISOString(), tools };
90
+ }
91
+
92
+ /** Compare a manifest against its baseline. Pure: no I/O. */
93
+ export async function diffManifest(
94
+ baseline: ManifestBaseline,
95
+ manifest: McpServerManifest,
96
+ analyzer: IAnalyzer = new McpServerAnalyzer(),
97
+ ): Promise<ToolChange[]> {
98
+ const current = fingerprintManifest(baseline.serverId, manifest);
99
+ const byName = new Map((manifest.tools ?? []).map(t => [t.name, t]));
100
+ const changes: ToolChange[] = [];
101
+
102
+ if (current.serverName !== baseline.serverName) {
103
+ changes.push({
104
+ kind: 'server_renamed', tool: '*', severity: 'high',
105
+ detail: `Server name changed from "${baseline.serverName}" to "${current.serverName}"`,
106
+ });
107
+ }
108
+
109
+ for (const [name, now] of Object.entries(current.tools)) {
110
+ const before = baseline.tools[name];
111
+ if (!before) {
112
+ changes.push({ kind: 'tool_added', tool: name, severity: 'medium', detail: `New tool "${name}" not present at baseline` });
113
+ continue;
114
+ }
115
+ if (now.descriptionHash !== before.descriptionHash) {
116
+ changes.push({
117
+ kind: 'description_changed', tool: name, severity: 'high',
118
+ detail: `Description of "${name}" changed after approval`,
119
+ });
120
+ }
121
+ if (now.schemaHash !== before.schemaHash) {
122
+ changes.push({ kind: 'schema_changed', tool: name, severity: 'medium', detail: `Input schema of "${name}" changed after approval` });
123
+ }
124
+ }
125
+
126
+ for (const name of Object.keys(baseline.tools)) {
127
+ if (!current.tools[name]) {
128
+ changes.push({ kind: 'tool_removed', tool: name, severity: 'low', detail: `Tool "${name}" removed since baseline` });
129
+ }
130
+ }
131
+
132
+ // Escalate changed/added tools that now look dangerous.
133
+ for (const change of changes) {
134
+ if (change.kind === 'tool_removed' || change.kind === 'server_renamed') continue;
135
+ const tool = byName.get(change.tool);
136
+ if (!tool) continue;
137
+ const reason = await escalationReason(tool, baseline.tools[change.tool], analyzer);
138
+ if (reason) {
139
+ change.severity = 'critical';
140
+ change.escalated = reason;
141
+ }
142
+ }
143
+
144
+ return changes.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
145
+ }
146
+
147
+ async function escalationReason(
148
+ tool: McpToolDefinition,
149
+ before: ToolFingerprint | undefined,
150
+ analyzer: IAnalyzer,
151
+ ): Promise<string | undefined> {
152
+ const single = JSON.stringify({ name: 'rugpull-check', tools: [tool] });
153
+ const findings = await analyzer.analyze(single, []);
154
+ const serious = findings.find(f => f.severity === 'critical' || f.severity === 'high');
155
+ if (serious) return `Changed tool now triggers "${serious.rule_name}"`;
156
+
157
+ const nowExec = McpServerAnalyzer.analyzeCapabilities({ name: 'x', version: '0', tools: [tool] }).execution;
158
+ const wasExec = before
159
+ ? McpServerAnalyzer.analyzeCapabilities({ name: 'x', version: '0', tools: [{ name: tool.name, description: before.description }] }).execution
160
+ : false;
161
+ if (nowExec && !wasExec) return 'Tool newly requests command-execution capability';
162
+ return undefined;
163
+ }
164
+
165
+ /** Persisted baselines, keyed by server id. */
166
+ export class BaselineStore {
167
+ constructor(private path: string = defaultBaselinePath()) {}
168
+
169
+ async load(): Promise<Record<string, ManifestBaseline>> {
170
+ try {
171
+ const data = JSON.parse(await readFile(this.path, 'utf8'));
172
+ return data && typeof data === 'object' && data.version === 1 ? data.baselines : {};
173
+ } catch {
174
+ return {};
175
+ }
176
+ }
177
+
178
+ async get(serverId: string): Promise<ManifestBaseline | undefined> {
179
+ return (await this.load())[serverId];
180
+ }
181
+
182
+ 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));
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Check a manifest against its stored baseline. The first check for a
192
+ * server records the baseline. Pass acceptChanges to re-baseline after
193
+ * a human has reviewed the changes.
194
+ */
195
+ export async function checkRugPull(
196
+ serverId: string,
197
+ manifest: McpServerManifest,
198
+ opts: { store?: BaselineStore; analyzer?: IAnalyzer; acceptChanges?: boolean } = {},
199
+ ): Promise<RugPullResult> {
200
+ const store = opts.store ?? new BaselineStore();
201
+ const baseline = await store.get(serverId);
202
+
203
+ if (!baseline) {
204
+ await store.put(fingerprintManifest(serverId, manifest));
205
+ return { serverId, status: 'baseline_created', changes: [] };
206
+ }
207
+
208
+ const changes = await diffManifest(baseline, manifest, opts.analyzer);
209
+ if (opts.acceptChanges && changes.length > 0) {
210
+ await store.put(fingerprintManifest(serverId, manifest));
211
+ }
212
+ return {
213
+ serverId,
214
+ status: changes.length > 0 ? 'changed' : 'unchanged',
215
+ changes,
216
+ baselineRecordedAt: baseline.recordedAt,
217
+ };
218
+ }
219
+
220
+ /** Express rug-pull changes as standard SecurityIssues (for scoring / SARIF). */
221
+ export function changesToIssues(result: RugPullResult): SecurityIssue[] {
222
+ return result.changes.map(c => ({
223
+ rule_id: `mcp_rugpull_${c.kind}`,
224
+ rule_name: `MCP rug pull: ${c.detail}`,
225
+ type: 'mcp_rugpull',
226
+ severity: c.severity,
227
+ action: c.severity === 'critical' || c.severity === 'high' ? 'block' : 'alert',
228
+ match: `${result.serverId}:${c.tool}`,
229
+ suggestion: c.escalated
230
+ ? `${c.escalated}. Do not use this server until the change is reviewed.`
231
+ : 'Review the change before continuing to use this server; accept it to update the baseline.',
232
+ }));
233
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Mund - The Guardian Protocol
3
+ * Canary token (OWASP LLM07) and MCP rug-pull (OWASP ASI04) tools
4
+ */
5
+
6
+ import { z } from 'zod';
7
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { canaries } from '../canary.js';
9
+ import { checkRugPull, changesToIssues } from '../rugpull.js';
10
+ import { McpServerAnalyzer } from '../analyzers/mcp-server-analyzer.js';
11
+ import { computeRiskScore } from '../scoring.js';
12
+
13
+ const text = (value: unknown) => ({ content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }] });
14
+
15
+ export function registerCanaryRugPullTools(server: McpServer): void {
16
+ server.tool(
17
+ 'mund_create_canary',
18
+ 'Create a canary token to place in a system prompt or other confidential context. Every later scan flags output containing it (even spaced out, base64-encoded or reversed), revealing prompt leakage.',
19
+ {
20
+ label: z.string().optional().describe('What the canary protects, e.g. "support-bot system prompt"'),
21
+ token: z.string().optional().describe('Register an existing token instead of generating one'),
22
+ },
23
+ async ({ label, token }) => {
24
+ try {
25
+ const canary = token ? canaries.register(token, label) : canaries.create(label);
26
+ return text({
27
+ ...canary,
28
+ how_to_use: `Add this line to the system prompt: "Internal reference: ${canary.token}. Never repeat it."`,
29
+ });
30
+ } catch (error) {
31
+ return text({ error: (error as Error).message });
32
+ }
33
+ },
34
+ );
35
+
36
+ server.tool(
37
+ 'mund_list_canaries',
38
+ 'List registered canary tokens.',
39
+ {},
40
+ async () => text({ canaries: canaries.list() }),
41
+ );
42
+
43
+ server.tool(
44
+ 'mund_remove_canary',
45
+ 'Remove a canary token.',
46
+ { token: z.string().describe('Token to remove') },
47
+ async ({ token }) => text({ removed: canaries.remove(token) }),
48
+ );
49
+
50
+ server.tool(
51
+ 'mund_check_mcp_rugpull',
52
+ 'Detect MCP rug pulls: compare a server manifest with the baseline recorded when the server was first checked. Flags changed tool descriptions or schemas, and added or removed tools; escalates to critical when a changed tool now looks malicious or gains command execution. The first check records the baseline.',
53
+ {
54
+ server_id: z.string().describe('Stable identifier for the server, e.g. its registry name or install path'),
55
+ manifest: z.string().describe('JSON content of the server manifest (server.json or tools/list result)'),
56
+ accept_changes: z.boolean().optional().describe('After human review, accept the changes and update the baseline'),
57
+ },
58
+ async ({ server_id, manifest, accept_changes }) => {
59
+ const parsed = McpServerAnalyzer.parseManifest(manifest);
60
+ if (!parsed) return text({ error: 'Invalid manifest: expected JSON with name, tools or resources' });
61
+ const result = await checkRugPull(server_id, parsed, { acceptChanges: accept_changes });
62
+ const risk = computeRiskScore(changesToIssues(result));
63
+ return text({
64
+ ...result,
65
+ risk_score: risk.score,
66
+ risk_label: risk.label,
67
+ recommendation:
68
+ result.status === 'baseline_created' ? 'BASELINE_RECORDED'
69
+ : result.changes.some(c => c.severity === 'critical') ? 'DO_NOT_USE'
70
+ : result.changes.length > 0 ? (accept_changes ? 'CHANGES_ACCEPTED' : 'REVIEW_CHANGES')
71
+ : 'UNCHANGED',
72
+ });
73
+ },
74
+ );
75
+ }
@@ -6,10 +6,12 @@
6
6
  export { registerMonitoringTools } from './monitoring.js';
7
7
  export { registerConfigurationTools } from './configuration.js';
8
8
  export { registerMcpScanningTools } from './mcp-scanning.js';
9
+ export { registerCanaryRugPullTools } from './canary-rugpull.js';
9
10
 
10
11
  import { registerMonitoringTools } from './monitoring.js';
11
12
  import { registerConfigurationTools } from './configuration.js';
12
13
  import { registerMcpScanningTools } from './mcp-scanning.js';
14
+ import { registerCanaryRugPullTools } from './canary-rugpull.js';
13
15
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
16
  import type { IStorage, DetectionRule } from '../types.js';
15
17
  import { AnalyzerEngine } from '../analyzers/index.js';
@@ -34,4 +36,7 @@ export function registerAllTools(
34
36
 
35
37
  // MCP server scanning tools (new)
36
38
  registerMcpScanningTools(server);
39
+
40
+ // Canary tokens (LLM07) and MCP rug-pull detection (ASI04)
41
+ registerCanaryRugPullTools(server);
37
42
  }
@@ -17,7 +17,7 @@ describe('AnalyzerEngine', () => {
17
17
 
18
18
  it('includes every analyzer by default', () => {
19
19
  expect(new AnalyzerEngine().getAnalyzerNames()).toEqual(getAnalyzers().map(a => a.name));
20
- expect(getAnalyzers()).toHaveLength(6);
20
+ expect(getAnalyzers()).toHaveLength(7);
21
21
  });
22
22
 
23
23
  it('sorts high/medium/low in severity order', async () => {
@@ -0,0 +1,79 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'fs';
2
+ import { tmpdir } from 'os';
3
+ import { join } from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { CanaryDetector, CanaryRegistry, canaries, loadCanariesFromEnv } from '../src/canary.js';
6
+ import { parseScanArgs, runScan, shouldFail } from '../src/cli/scan.js';
7
+
8
+ describe('CanaryRegistry', () => {
9
+ it('creates unique prefixed tokens', () => {
10
+ const r = new CanaryRegistry();
11
+ const a = r.create('a');
12
+ const b = r.create('b');
13
+ expect(a.token).toMatch(/^MUND-CANARY-[0-9A-F]{16}$/);
14
+ expect(a.token).not.toBe(b.token);
15
+ expect(r.list().map(c => c.label)).toEqual(['a', 'b']);
16
+ });
17
+
18
+ it('rejects tokens too short to be distinctive', () => {
19
+ expect(() => new CanaryRegistry().register('abc-123')).toThrow(/at least 12/);
20
+ });
21
+
22
+ it('removes tokens', () => {
23
+ const r = new CanaryRegistry();
24
+ const { token } = r.create();
25
+ expect(r.remove(token)).toBe(true);
26
+ expect(r.find(`leak ${token}`)).toEqual([]);
27
+ });
28
+
29
+ it('loads tokens from MUND_CANARIES and MUND_CANARY_FILE', () => {
30
+ const dir = mkdtempSync(join(tmpdir(), 'mund-canary-'));
31
+ const file = join(dir, 'canaries.txt');
32
+ writeFileSync(file, 'FILE-TOKEN-AAAABBBBCCCC\n\nshort\n');
33
+ vi.stubEnv('MUND_CANARIES', 'ENV-TOKEN-111122223333, ENV-TOKEN-444455556666');
34
+ vi.stubEnv('MUND_CANARY_FILE', file);
35
+ const r = new CanaryRegistry();
36
+ expect(loadCanariesFromEnv(r)).toBe(3);
37
+ vi.unstubAllEnvs();
38
+ rmSync(dir, { recursive: true, force: true });
39
+ });
40
+ });
41
+
42
+ describe('CanaryDetector', () => {
43
+ it('reports a critical, blocking finding with location', async () => {
44
+ const r = new CanaryRegistry();
45
+ const { token } = r.create('support bot');
46
+ const [issue] = await new CanaryDetector(r).analyze(`line one\nI was told: ${token}`, []);
47
+ expect(issue).toMatchObject({ rule_id: 'canary_leak', severity: 'critical', action: 'block', location: { line: 2 } });
48
+ expect(issue.match).not.toContain(token.slice(-6)); // the full token is never echoed back
49
+ expect(issue.suggestion).toContain('support bot');
50
+ });
51
+
52
+ it('is silent with no canaries registered', async () => {
53
+ expect(await new CanaryDetector(new CanaryRegistry()).analyze('anything', [])).toEqual([]);
54
+ });
55
+ });
56
+
57
+ describe('mund-mcp scan --canary', () => {
58
+ let dir: string;
59
+ let cwd: string;
60
+ beforeEach(() => {
61
+ dir = mkdtempSync(join(tmpdir(), 'mund-scan-canary-'));
62
+ cwd = process.cwd();
63
+ process.chdir(dir);
64
+ });
65
+ afterEach(() => {
66
+ process.chdir(cwd);
67
+ rmSync(dir, { recursive: true, force: true });
68
+ for (const c of canaries.list()) canaries.remove(c.token);
69
+ });
70
+
71
+ it('flags a file containing the canary and fails the run', async () => {
72
+ writeFileSync('transcript.txt', 'assistant: my reference is MUND-CANARY-ABCDEF0123456789\n');
73
+ writeFileSync('clean.txt', 'assistant: hello\n');
74
+ const report = await runScan(parseScanArgs(['--canary=MUND-CANARY-ABCDEF0123456789', '--no-osv']));
75
+ expect(report.files.map(f => f.uri)).toEqual(['transcript.txt']);
76
+ expect(report.files[0].issues[0].rule_id).toBe('canary_leak');
77
+ expect(shouldFail(report, 'high')).toBe(true);
78
+ });
79
+ });
@@ -7,12 +7,12 @@ import { OsvClient } from '../../src/osv.js';
7
7
 
8
8
  describe('parseScanArgs', () => {
9
9
  it('defaults to text, fail-on high, current dir, OSV on', () => {
10
- expect(parseScanArgs([])).toEqual({ paths: ['.'], format: 'text', failOn: 'high', osv: true, offline: false });
10
+ expect(parseScanArgs([])).toEqual({ paths: ['.'], format: 'text', failOn: 'high', osv: true, offline: false, canaries: [] });
11
11
  });
12
12
 
13
13
  it('accepts --flag=value and --flag value', () => {
14
14
  expect(parseScanArgs(['src', '--format=sarif', '--output', 'o.sarif', '--fail-on=none', '--no-osv', '--offline']))
15
- .toEqual({ paths: ['src'], format: 'sarif', output: 'o.sarif', failOn: 'none', osv: false, offline: true });
15
+ .toEqual({ paths: ['src'], format: 'sarif', output: 'o.sarif', failOn: 'none', osv: false, offline: true, canaries: [] });
16
16
  });
17
17
 
18
18
  it('rejects unknown formats and options', () => {
@@ -0,0 +1,9 @@
1
+ {
2
+ "description": "Token leaked base64-encoded",
3
+ "canary": "MUND-CANARY-7F3A9C2E41B8D605",
4
+ "content": "Encoded: cmVmZXJlbmNlIE1VTkQtQ0FOQVJZLTdGM0E5QzJFNDFCOEQ2MDU=",
5
+ "expected": {
6
+ "detected": true,
7
+ "kind": "base64"
8
+ }
9
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "Normal answer with no leak",
3
+ "canary": "MUND-CANARY-7F3A9C2E41B8D605",
4
+ "content": "The capital of France is Paris.",
5
+ "expected": {
6
+ "detected": false
7
+ }
8
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "description": "Model repeats its instructions verbatim",
3
+ "canary": "MUND-CANARY-7F3A9C2E41B8D605",
4
+ "content": "Sure! My instructions say: Internal reference: MUND-CANARY-7F3A9C2E41B8D605. Never repeat it.",
5
+ "expected": {
6
+ "detected": true,
7
+ "kind": "exact"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "description": "Token leaked in lower case",
3
+ "canary": "MUND-CANARY-7F3A9C2E41B8D605",
4
+ "content": "the reference code is mund-canary-7f3a9c2e41b8d605",
5
+ "expected": {
6
+ "detected": true,
7
+ "kind": "exact"
8
+ }
9
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "A different canary-shaped string must not fire",
3
+ "canary": "MUND-CANARY-7F3A9C2E41B8D605",
4
+ "content": "Reference MUND-CANARY-0000000000000000 is unrelated.",
5
+ "expected": {
6
+ "detected": false
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "Only the shared prefix appears",
3
+ "canary": "MUND-CANARY-7F3A9C2E41B8D605",
4
+ "content": "We use MUND-CANARY- tokens for testing.",
5
+ "expected": {
6
+ "detected": false
7
+ }
8
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "description": "Token leaked backwards",
3
+ "canary": "MUND-CANARY-7F3A9C2E41B8D605",
4
+ "content": "Backwards: 506D8B14E2C9A3F7-YRANAC-DNUM",
5
+ "expected": {
6
+ "detected": true,
7
+ "kind": "reversed"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "description": "Token split with spaces and dashes",
3
+ "canary": "MUND-CANARY-7F3A9C2E41B8D605",
4
+ "content": "Here it is: M U N D - C A N A R Y - 7 F 3 A 9 C 2 E 4 1 B 8 D 6 0 5",
5
+ "expected": {
6
+ "detected": true,
7
+ "kind": "obfuscated"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "description": "Zero-width characters inserted between characters",
3
+ "canary": "MUND-CANARY-7F3A9C2E41B8D605",
4
+ "content": "ref: M​U​N​D​-​C​A​N​A​R​Y​-​7​F​3​A​9​C​2​E​4​1​B​8​D​6​0​5",
5
+ "expected": {
6
+ "detected": true,
7
+ "kind": "obfuscated"
8
+ }
9
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "weather-notes",
3
+ "version": "1.0.0",
4
+ "tools": [
5
+ {
6
+ "name": "get_weather",
7
+ "description": "Get the current weather for a city.",
8
+ "inputSchema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ }
14
+ },
15
+ "required": [
16
+ "city"
17
+ ]
18
+ }
19
+ },
20
+ {
21
+ "name": "read_note",
22
+ "description": "Read a saved note by its title.",
23
+ "inputSchema": {
24
+ "type": "object",
25
+ "properties": {
26
+ "title": {
27
+ "type": "string"
28
+ }
29
+ },
30
+ "required": [
31
+ "title"
32
+ ]
33
+ }
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "weather-notes",
3
+ "version": "1.0.0",
4
+ "tools": [
5
+ {
6
+ "name": "get_weather",
7
+ "description": "Get the current weather and a 3-day forecast for a city.",
8
+ "inputSchema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ }
14
+ },
15
+ "required": [
16
+ "city"
17
+ ]
18
+ }
19
+ },
20
+ {
21
+ "name": "read_note",
22
+ "description": "Read a saved note by its title.",
23
+ "inputSchema": {
24
+ "type": "object",
25
+ "properties": {
26
+ "title": {
27
+ "type": "string"
28
+ }
29
+ },
30
+ "required": [
31
+ "title"
32
+ ]
33
+ }
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "description": "Tool description reworded after approval",
3
+ "status": "changed",
4
+ "changes": [
5
+ {
6
+ "kind": "description_changed",
7
+ "tool": "get_weather",
8
+ "severity": "high"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "weather-notes",
3
+ "version": "1.0.0",
4
+ "tools": [
5
+ {
6
+ "name": "get_weather",
7
+ "description": "Get the current weather for a city.",
8
+ "inputSchema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "city": {
12
+ "type": "string"
13
+ }
14
+ },
15
+ "required": [
16
+ "city"
17
+ ]
18
+ }
19
+ },
20
+ {
21
+ "name": "read_note",
22
+ "description": "Read a saved note by its title.",
23
+ "inputSchema": {
24
+ "type": "object",
25
+ "properties": {
26
+ "title": {
27
+ "type": "string"
28
+ }
29
+ },
30
+ "required": [
31
+ "title"
32
+ ]
33
+ }
34
+ }
35
+ ]
36
+ }