@exfil/canary 1.0.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.
- package/LICENSE +21 -0
- package/README.md +387 -0
- package/SECURITY.md +50 -0
- package/dist/entities.d.ts +43 -0
- package/dist/entities.d.ts.map +1 -0
- package/dist/entities.js +218 -0
- package/dist/entities.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +183 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +29 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +50 -0
- package/dist/logger.js.map +1 -0
- package/dist/persistence.d.ts +48 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +296 -0
- package/dist/persistence.js.map +1 -0
- package/dist/proxy/DownstreamManager.d.ts +55 -0
- package/dist/proxy/DownstreamManager.d.ts.map +1 -0
- package/dist/proxy/DownstreamManager.js +110 -0
- package/dist/proxy/DownstreamManager.js.map +1 -0
- package/dist/proxy/ProxyServer.d.ts +60 -0
- package/dist/proxy/ProxyServer.d.ts.map +1 -0
- package/dist/proxy/ProxyServer.js +480 -0
- package/dist/proxy/ProxyServer.js.map +1 -0
- package/dist/proxy/auditor/DualAuditor.d.ts +27 -0
- package/dist/proxy/auditor/DualAuditor.d.ts.map +1 -0
- package/dist/proxy/auditor/DualAuditor.js +44 -0
- package/dist/proxy/auditor/DualAuditor.js.map +1 -0
- package/dist/proxy/auditor/LLMAuditor.d.ts +16 -0
- package/dist/proxy/auditor/LLMAuditor.d.ts.map +1 -0
- package/dist/proxy/auditor/LLMAuditor.js +221 -0
- package/dist/proxy/auditor/LLMAuditor.js.map +1 -0
- package/dist/proxy/auditor/types.d.ts +54 -0
- package/dist/proxy/auditor/types.d.ts.map +1 -0
- package/dist/proxy/auditor/types.js +11 -0
- package/dist/proxy/auditor/types.js.map +1 -0
- package/dist/proxy/types.d.ts +71 -0
- package/dist/proxy/types.d.ts.map +1 -0
- package/dist/proxy/types.js +8 -0
- package/dist/proxy/types.js.map +1 -0
- package/dist/scanner.d.ts +37 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +57 -0
- package/dist/scanner.js.map +1 -0
- package/dist/server.d.ts +59 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +711 -0
- package/dist/server.js.map +1 -0
- package/dist/simhash.d.ts +65 -0
- package/dist/simhash.d.ts.map +1 -0
- package/dist/simhash.js +151 -0
- package/dist/simhash.js.map +1 -0
- package/dist/state.d.ts +86 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +136 -0
- package/dist/state.js.map +1 -0
- package/dist/token.d.ts +70 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +146 -0
- package/dist/token.js.map +1 -0
- package/dist/types.d.ts +190 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
- package/proxy.example.json +53 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CanaryMcpServer — MCP server implementation.
|
|
3
|
+
*
|
|
4
|
+
* Registers four tools:
|
|
5
|
+
* - wrap_content Embeds a canary token; returns wrapped content + token_id.
|
|
6
|
+
* - check_leakage Checks whether a specific token_id appears in provided output.
|
|
7
|
+
* - scan_outbound Scans data for ANY active tokens (returns aggregate only — RC-3).
|
|
8
|
+
* - get_report Operator-only summary (RC-4; see README).
|
|
9
|
+
*
|
|
10
|
+
* SECURITY NOTES:
|
|
11
|
+
* - Tool descriptions deliberately avoid "canary" / "monitoring" language visible to agents.
|
|
12
|
+
* - scan_outbound never returns token_id values in its response to the agent (RC-3).
|
|
13
|
+
* - sequence is never included in any tool output (RC-5).
|
|
14
|
+
* - Webhook delivery is HTTPS-only with SSRF protection and optional HMAC signing (RC-6).
|
|
15
|
+
*/
|
|
16
|
+
import { createHmac, randomBytes } from 'crypto';
|
|
17
|
+
import { request as httpsRequest } from 'https';
|
|
18
|
+
import { URL } from 'url';
|
|
19
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
20
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
21
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
22
|
+
import { generateTokenSequence, generateTokenId, embedToken, containsSequence, } from './token.js';
|
|
23
|
+
import { registerToken, lookupToken, getActiveTokens, recordLeakage, pruneExpiredTokens, isTokenExpired, } from './state.js';
|
|
24
|
+
import { scanForAllTokens, isValidTokenId } from './scanner.js';
|
|
25
|
+
import { extractEntities, scanForEntityValues } from './entities.js';
|
|
26
|
+
import { computeSimHash, isSimilar } from './simhash.js';
|
|
27
|
+
import { persistState } from './persistence.js';
|
|
28
|
+
import { log } from './logger.js';
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Hard limits (RC-2)
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
/** Maximum number of active tokens per session. */
|
|
33
|
+
const SESSION_TOKEN_CAP = 10_000;
|
|
34
|
+
/** Maximum byte length of the `content` argument to wrap_content. */
|
|
35
|
+
const MAX_CONTENT_BYTES = 10 * 1024 * 1024; // 10 MiB
|
|
36
|
+
/** Maximum byte length of the `output` argument to check_leakage. */
|
|
37
|
+
const MAX_OUTPUT_BYTES = 10 * 1024 * 1024; // 10 MiB
|
|
38
|
+
/** Maximum byte length of the `data` argument to scan_outbound. */
|
|
39
|
+
const MAX_SCAN_BYTES = 50 * 1024 * 1024; // 50 MiB
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Argument extraction helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
const optStr = (args, k) => {
|
|
44
|
+
const v = args[k];
|
|
45
|
+
return typeof v === 'string' ? v : undefined;
|
|
46
|
+
};
|
|
47
|
+
const optNum = (args, k) => {
|
|
48
|
+
const v = args[k];
|
|
49
|
+
return typeof v === 'number' ? v : undefined;
|
|
50
|
+
};
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// RFC-1918 / loopback / link-local SSRF block list (RC-6)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
/**
|
|
55
|
+
* Returns true if `hostname` resolves to a private/loopback/link-local address
|
|
56
|
+
* that should be blocked to prevent SSRF.
|
|
57
|
+
*
|
|
58
|
+
* We check the literal hostname only (no DNS resolution) since we only accept
|
|
59
|
+
* HTTPS URLs; callers with public hostnames that resolve to private IPs are an
|
|
60
|
+
* accepted residual risk documented in SECURITY.md.
|
|
61
|
+
*/
|
|
62
|
+
function isSsrfBlockedHostname(hostname) {
|
|
63
|
+
// IPv4 dotted-decimal check.
|
|
64
|
+
const parts = hostname.split('.');
|
|
65
|
+
if (parts.length === 4) {
|
|
66
|
+
const octets = parts.map(Number);
|
|
67
|
+
if (octets.every((o) => Number.isInteger(o) && o >= 0 && o <= 255)) {
|
|
68
|
+
const [a, b] = octets;
|
|
69
|
+
if (a === 10)
|
|
70
|
+
return true; // 10.0.0.0/8
|
|
71
|
+
if (a === 127)
|
|
72
|
+
return true; // 127.0.0.0/8
|
|
73
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
74
|
+
return true; // 172.16.0.0/12
|
|
75
|
+
if (a === 192 && b === 168)
|
|
76
|
+
return true; // 192.168.0.0/16
|
|
77
|
+
if (a === 169 && b === 254)
|
|
78
|
+
return true; // 169.254.0.0/16 (link-local)
|
|
79
|
+
if (a === 0)
|
|
80
|
+
return true; // 0.0.0.0/8
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Loopback hostnames.
|
|
85
|
+
const lower = hostname.toLowerCase();
|
|
86
|
+
if (lower === 'localhost' || lower.endsWith('.localhost'))
|
|
87
|
+
return true;
|
|
88
|
+
// IPv6 loopback.
|
|
89
|
+
if (lower === '::1' || lower === '[::1]')
|
|
90
|
+
return true;
|
|
91
|
+
// IPv6 unspecified address.
|
|
92
|
+
if (lower === '::' || lower === '[::]')
|
|
93
|
+
return true;
|
|
94
|
+
// IPv6 link-local (fe80::/10).
|
|
95
|
+
if (/^fe[89ab][0-9a-f]:/i.test(lower) || /^\[fe[89ab][0-9a-f]:/i.test(lower))
|
|
96
|
+
return true;
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Validates a webhook URL: must be https://, must not target SSRF-blocked hosts.
|
|
101
|
+
*
|
|
102
|
+
* @param raw Raw URL string from environment.
|
|
103
|
+
* @returns Validated URL string, or throws on failure.
|
|
104
|
+
*/
|
|
105
|
+
export function validateWebhookUrl(raw) {
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = new URL(raw);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new Error(`CANARY_MCP_ALERT_WEBHOOK is not a valid URL: "${raw}"`);
|
|
112
|
+
}
|
|
113
|
+
if (parsed.protocol !== 'https:') {
|
|
114
|
+
throw new Error(`CANARY_MCP_ALERT_WEBHOOK must use https:// — got "${parsed.protocol}"`);
|
|
115
|
+
}
|
|
116
|
+
if (isSsrfBlockedHostname(parsed.hostname)) {
|
|
117
|
+
throw new Error(`CANARY_MCP_ALERT_WEBHOOK hostname "${parsed.hostname}" is in a blocked private/loopback range.`);
|
|
118
|
+
}
|
|
119
|
+
return raw;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Delivers a leakage alert to the configured webhook.
|
|
123
|
+
*
|
|
124
|
+
* - Uses Node.js built-in `https` (no external dependencies).
|
|
125
|
+
* - Signs the body with HMAC-SHA256 if `config.webhook_secret` is set (RC-6).
|
|
126
|
+
* - Failures are logged at warn level; the server continues regardless.
|
|
127
|
+
*
|
|
128
|
+
* @param payload Alert payload.
|
|
129
|
+
* @param config Server configuration.
|
|
130
|
+
* @returns Whether the webhook was successfully delivered (2xx response).
|
|
131
|
+
*/
|
|
132
|
+
async function deliverWebhook(payload, config) {
|
|
133
|
+
if (!config.alert_webhook)
|
|
134
|
+
return false;
|
|
135
|
+
const body = JSON.stringify(payload);
|
|
136
|
+
const headers = {
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
'Content-Length': String(Buffer.byteLength(body)),
|
|
139
|
+
'User-Agent': 'exfil/canary/1.0.0',
|
|
140
|
+
};
|
|
141
|
+
if (config.webhook_secret) {
|
|
142
|
+
const sig = createHmac('sha256', config.webhook_secret)
|
|
143
|
+
.update(body)
|
|
144
|
+
.digest('hex');
|
|
145
|
+
headers['X-Canary-Signature-256'] = `sha256=${sig}`;
|
|
146
|
+
}
|
|
147
|
+
const url = new URL(config.alert_webhook);
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const req = httpsRequest({
|
|
150
|
+
hostname: url.hostname,
|
|
151
|
+
port: url.port || 443,
|
|
152
|
+
path: url.pathname + url.search,
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers,
|
|
155
|
+
timeout: 5000,
|
|
156
|
+
}, (res) => {
|
|
157
|
+
// Consume the response body to free the socket.
|
|
158
|
+
res.resume();
|
|
159
|
+
const ok = res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300;
|
|
160
|
+
if (!ok) {
|
|
161
|
+
log('warn', `Webhook returned non-2xx status: ${res.statusCode}`);
|
|
162
|
+
}
|
|
163
|
+
resolve(ok);
|
|
164
|
+
});
|
|
165
|
+
req.on('timeout', () => {
|
|
166
|
+
req.destroy();
|
|
167
|
+
log('warn', 'Webhook request timed out.');
|
|
168
|
+
resolve(false);
|
|
169
|
+
});
|
|
170
|
+
req.on('error', (err) => {
|
|
171
|
+
log('warn', `Webhook delivery error: ${err.message}`);
|
|
172
|
+
resolve(false);
|
|
173
|
+
});
|
|
174
|
+
req.write(body);
|
|
175
|
+
req.end();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// CanaryMcpServer
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
/** The main MCP server class. */
|
|
182
|
+
export class CanaryMcpServer {
|
|
183
|
+
server;
|
|
184
|
+
config;
|
|
185
|
+
state;
|
|
186
|
+
/**
|
|
187
|
+
* Constructs a new `CanaryMcpServer`.
|
|
188
|
+
*
|
|
189
|
+
* @param state Initial session state (either freshly created or recovered from disk).
|
|
190
|
+
* @param config Validated server configuration derived from environment variables.
|
|
191
|
+
*/
|
|
192
|
+
constructor(state, config) {
|
|
193
|
+
this.state = state;
|
|
194
|
+
this.config = config;
|
|
195
|
+
this.server = new Server({ name: 'exfil/canary', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
196
|
+
this.registerHandlers();
|
|
197
|
+
}
|
|
198
|
+
// -------------------------------------------------------------------------
|
|
199
|
+
// Tool registration
|
|
200
|
+
// -------------------------------------------------------------------------
|
|
201
|
+
registerHandlers() {
|
|
202
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
203
|
+
tools: [
|
|
204
|
+
{
|
|
205
|
+
name: 'wrap_content',
|
|
206
|
+
// Deliberately neutral description — no "canary" / "monitoring" language.
|
|
207
|
+
description: 'Prepares content for agent processing with integrity markers. ' +
|
|
208
|
+
'Returns the content with embedded markers and a tracking identifier.',
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: {
|
|
212
|
+
content: {
|
|
213
|
+
type: 'string',
|
|
214
|
+
description: 'Content to prepare.',
|
|
215
|
+
},
|
|
216
|
+
source_type: {
|
|
217
|
+
type: 'string',
|
|
218
|
+
enum: [
|
|
219
|
+
'tool_result', 'file_read', 'api_response',
|
|
220
|
+
'database_row', 'user_message', 'other',
|
|
221
|
+
],
|
|
222
|
+
description: 'Content origin classification.',
|
|
223
|
+
},
|
|
224
|
+
source_server: {
|
|
225
|
+
type: 'string',
|
|
226
|
+
description: 'Identifier of the originating MCP server.',
|
|
227
|
+
},
|
|
228
|
+
source_tool: {
|
|
229
|
+
type: 'string',
|
|
230
|
+
description: 'Name of the originating tool.',
|
|
231
|
+
},
|
|
232
|
+
source_call_id: {
|
|
233
|
+
type: 'string',
|
|
234
|
+
description: 'Call ID from the originating tool invocation.',
|
|
235
|
+
},
|
|
236
|
+
embed_position: {
|
|
237
|
+
type: 'string',
|
|
238
|
+
enum: ['prefix', 'suffix', 'both', 'random_word_boundary'],
|
|
239
|
+
description: 'Where to embed the integrity marker.',
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
required: ['content', 'source_type'],
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: 'check_leakage',
|
|
247
|
+
description: 'Checks whether a tracked integrity marker is present in the provided output. ' +
|
|
248
|
+
'Use this to audit specific tool call outputs.',
|
|
249
|
+
inputSchema: {
|
|
250
|
+
type: 'object',
|
|
251
|
+
properties: {
|
|
252
|
+
token_id: {
|
|
253
|
+
type: 'string',
|
|
254
|
+
description: 'Tracking identifier returned by wrap_content.',
|
|
255
|
+
},
|
|
256
|
+
output: {
|
|
257
|
+
type: 'string',
|
|
258
|
+
description: 'Output text to check.',
|
|
259
|
+
},
|
|
260
|
+
target_server: {
|
|
261
|
+
type: 'string',
|
|
262
|
+
description: 'MCP server that produced the output.',
|
|
263
|
+
},
|
|
264
|
+
target_tool: {
|
|
265
|
+
type: 'string',
|
|
266
|
+
description: 'Tool that produced the output.',
|
|
267
|
+
},
|
|
268
|
+
target_call_id: {
|
|
269
|
+
type: 'string',
|
|
270
|
+
description: 'Call ID of the tool invocation being checked.',
|
|
271
|
+
},
|
|
272
|
+
turn_number: {
|
|
273
|
+
type: 'number',
|
|
274
|
+
description: 'Current conversation turn.',
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
required: ['token_id', 'output'],
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: 'scan_outbound',
|
|
282
|
+
description: 'Scans data for any active integrity markers before it leaves the agent context. ' +
|
|
283
|
+
'Returns a clean/not-clean signal only.',
|
|
284
|
+
inputSchema: {
|
|
285
|
+
type: 'object',
|
|
286
|
+
properties: {
|
|
287
|
+
data: {
|
|
288
|
+
type: 'string',
|
|
289
|
+
description: 'Data to scan.',
|
|
290
|
+
},
|
|
291
|
+
target_server: {
|
|
292
|
+
type: 'string',
|
|
293
|
+
description: 'Destination MCP server.',
|
|
294
|
+
},
|
|
295
|
+
target_tool: {
|
|
296
|
+
type: 'string',
|
|
297
|
+
description: 'Destination tool.',
|
|
298
|
+
},
|
|
299
|
+
target_call_id: {
|
|
300
|
+
type: 'string',
|
|
301
|
+
description: 'Call ID of the outbound tool invocation.',
|
|
302
|
+
},
|
|
303
|
+
turn_number: {
|
|
304
|
+
type: 'number',
|
|
305
|
+
description: 'Current conversation turn.',
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
required: ['data'],
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
// RC-4: get_report is registered but MUST NOT appear in agent tool lists.
|
|
312
|
+
// Operators should remove it from the ListTools response or restrict
|
|
313
|
+
// access via CANARY_MCP_MGMT_KEY. See README.md.
|
|
314
|
+
{
|
|
315
|
+
name: 'get_report',
|
|
316
|
+
description: '[OPERATOR-ONLY] Returns session integrity summary. ' +
|
|
317
|
+
'This tool must not be accessible to the agent — see exfil/canary README.',
|
|
318
|
+
inputSchema: {
|
|
319
|
+
type: 'object',
|
|
320
|
+
properties: {
|
|
321
|
+
mgmt_key: {
|
|
322
|
+
type: 'string',
|
|
323
|
+
description: 'Management key (must match CANARY_MCP_MGMT_KEY env var if set).',
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
required: [],
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
}));
|
|
331
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
332
|
+
const { name, arguments: args } = request.params;
|
|
333
|
+
switch (name) {
|
|
334
|
+
case 'wrap_content':
|
|
335
|
+
return this.handleWrapContent(args ?? {});
|
|
336
|
+
case 'check_leakage':
|
|
337
|
+
return this.handleCheckLeakage(args ?? {});
|
|
338
|
+
case 'scan_outbound':
|
|
339
|
+
return this.handleScanOutbound(args ?? {});
|
|
340
|
+
case 'get_report':
|
|
341
|
+
return this.handleGetReport(args ?? {});
|
|
342
|
+
default:
|
|
343
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
// -------------------------------------------------------------------------
|
|
348
|
+
// Tool: wrap_content
|
|
349
|
+
// -------------------------------------------------------------------------
|
|
350
|
+
async handleWrapContent(args) {
|
|
351
|
+
// RC-2: size limits checked absolutely first.
|
|
352
|
+
const contentRaw = args['content'];
|
|
353
|
+
if (typeof contentRaw !== 'string') {
|
|
354
|
+
throw new McpError(ErrorCode.InvalidParams, '"content" must be a string.');
|
|
355
|
+
}
|
|
356
|
+
if (Buffer.byteLength(contentRaw) > MAX_CONTENT_BYTES) {
|
|
357
|
+
throw new McpError(ErrorCode.InvalidParams, `"content" exceeds maximum size of ${MAX_CONTENT_BYTES} bytes.`);
|
|
358
|
+
}
|
|
359
|
+
if (this.state.tokens.size >= SESSION_TOKEN_CAP) {
|
|
360
|
+
throw new McpError(ErrorCode.InvalidParams, `Session token cap of ${SESSION_TOKEN_CAP} reached. Expire old tokens first.`);
|
|
361
|
+
}
|
|
362
|
+
// Validate source_type.
|
|
363
|
+
const sourceTypeRaw = args['source_type'];
|
|
364
|
+
const validSourceTypes = new Set([
|
|
365
|
+
'tool_result', 'file_read', 'api_response', 'database_row', 'user_message', 'other',
|
|
366
|
+
]);
|
|
367
|
+
if (typeof sourceTypeRaw !== 'string' || !validSourceTypes.has(sourceTypeRaw)) {
|
|
368
|
+
throw new McpError(ErrorCode.InvalidParams, '"source_type" is invalid.');
|
|
369
|
+
}
|
|
370
|
+
const source_type = sourceTypeRaw;
|
|
371
|
+
// Validate embed_position (optional, default 'suffix').
|
|
372
|
+
const positionRaw = args['embed_position'] ?? 'suffix';
|
|
373
|
+
const validPositions = new Set(['prefix', 'suffix', 'both', 'random_word_boundary']);
|
|
374
|
+
if (typeof positionRaw !== 'string' || !validPositions.has(positionRaw)) {
|
|
375
|
+
throw new McpError(ErrorCode.InvalidParams, '"embed_position" is invalid.');
|
|
376
|
+
}
|
|
377
|
+
const embed_position = positionRaw;
|
|
378
|
+
// Generate token.
|
|
379
|
+
const sequence = generateTokenSequence();
|
|
380
|
+
const token_id = generateTokenId();
|
|
381
|
+
// v1.1: Extract named entities for structural canary detection.
|
|
382
|
+
const entity_canaries = extractEntities(contentRaw);
|
|
383
|
+
// v1.6: Compute SimHash fingerprint for semantic leakage detection.
|
|
384
|
+
const simhash = computeSimHash(contentRaw);
|
|
385
|
+
registerToken(this.state, {
|
|
386
|
+
token_id,
|
|
387
|
+
sequence,
|
|
388
|
+
source_type,
|
|
389
|
+
source_server: optStr(args, 'source_server'),
|
|
390
|
+
source_tool: optStr(args, 'source_tool'),
|
|
391
|
+
source_call_id: optStr(args, 'source_call_id'),
|
|
392
|
+
embed_position,
|
|
393
|
+
ttl_ms: this.config.token_ttl_seconds * 1000,
|
|
394
|
+
entity_canaries,
|
|
395
|
+
simhash,
|
|
396
|
+
});
|
|
397
|
+
const wrapped_content = embedToken(contentRaw, sequence, embed_position);
|
|
398
|
+
log('debug', 'Token registered.', { token_id, source_type, embed_position });
|
|
399
|
+
// Persist after mutation.
|
|
400
|
+
await persistState(this.state, this.config).catch(() => { });
|
|
401
|
+
// RC-5: sequence NEVER in output.
|
|
402
|
+
return {
|
|
403
|
+
content: [
|
|
404
|
+
{
|
|
405
|
+
type: 'text',
|
|
406
|
+
text: JSON.stringify({ token_id, wrapped_content }),
|
|
407
|
+
},
|
|
408
|
+
],
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
// -------------------------------------------------------------------------
|
|
412
|
+
// Tool: check_leakage
|
|
413
|
+
// -------------------------------------------------------------------------
|
|
414
|
+
async handleCheckLeakage(args) {
|
|
415
|
+
// RC-2: size limits first.
|
|
416
|
+
const outputRaw = args['output'];
|
|
417
|
+
if (typeof outputRaw !== 'string') {
|
|
418
|
+
throw new McpError(ErrorCode.InvalidParams, '"output" must be a string.');
|
|
419
|
+
}
|
|
420
|
+
if (Buffer.byteLength(outputRaw) > MAX_OUTPUT_BYTES) {
|
|
421
|
+
throw new McpError(ErrorCode.InvalidParams, `"output" exceeds maximum size of ${MAX_OUTPUT_BYTES} bytes.`);
|
|
422
|
+
}
|
|
423
|
+
const token_id_raw = args['token_id'];
|
|
424
|
+
if (typeof token_id_raw !== 'string' || !isValidTokenId(token_id_raw)) {
|
|
425
|
+
throw new McpError(ErrorCode.InvalidParams, '"token_id" must be a 32-char hex string.');
|
|
426
|
+
}
|
|
427
|
+
const token = lookupToken(this.state, token_id_raw);
|
|
428
|
+
if (!token) {
|
|
429
|
+
return {
|
|
430
|
+
content: [
|
|
431
|
+
{
|
|
432
|
+
type: 'text',
|
|
433
|
+
text: JSON.stringify({
|
|
434
|
+
token_id: token_id_raw,
|
|
435
|
+
status: 'unknown',
|
|
436
|
+
leaked: false,
|
|
437
|
+
message: 'Token not found in this session.',
|
|
438
|
+
}),
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (!token.sequence) {
|
|
444
|
+
// Recovered from disk — cannot re-detect.
|
|
445
|
+
return {
|
|
446
|
+
content: [
|
|
447
|
+
{
|
|
448
|
+
type: 'text',
|
|
449
|
+
text: JSON.stringify({
|
|
450
|
+
token_id: token_id_raw,
|
|
451
|
+
status: 'unknown',
|
|
452
|
+
leaked: token.leaked,
|
|
453
|
+
message: 'Token was recovered from disk; re-detection unavailable.',
|
|
454
|
+
}),
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const found = containsSequence(outputRaw, token.sequence);
|
|
460
|
+
if (found) {
|
|
461
|
+
const action_taken = await this.resolveAction(token_id_raw, 'check_leakage', optStr(args, 'target_server'), optStr(args, 'target_tool'), optStr(args, 'target_call_id'), optNum(args, 'turn_number'));
|
|
462
|
+
log('info', 'Leakage detected via check_leakage.', {
|
|
463
|
+
token_id: token_id_raw,
|
|
464
|
+
action_taken,
|
|
465
|
+
});
|
|
466
|
+
return {
|
|
467
|
+
content: [
|
|
468
|
+
{
|
|
469
|
+
type: 'text',
|
|
470
|
+
text: JSON.stringify({
|
|
471
|
+
token_id: token_id_raw,
|
|
472
|
+
status: 'active',
|
|
473
|
+
leaked: true,
|
|
474
|
+
action_taken,
|
|
475
|
+
}),
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
content: [
|
|
482
|
+
{
|
|
483
|
+
type: 'text',
|
|
484
|
+
text: JSON.stringify({
|
|
485
|
+
token_id: token_id_raw,
|
|
486
|
+
status: !isTokenExpired(token) ? 'active' : 'expired',
|
|
487
|
+
leaked: token.leaked,
|
|
488
|
+
}),
|
|
489
|
+
},
|
|
490
|
+
],
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
// -------------------------------------------------------------------------
|
|
494
|
+
// Tool: scan_outbound
|
|
495
|
+
// -------------------------------------------------------------------------
|
|
496
|
+
async handleScanOutbound(args) {
|
|
497
|
+
// RC-2: size limits first.
|
|
498
|
+
const dataRaw = args['data'];
|
|
499
|
+
if (typeof dataRaw !== 'string') {
|
|
500
|
+
throw new McpError(ErrorCode.InvalidParams, '"data" must be a string.');
|
|
501
|
+
}
|
|
502
|
+
if (Buffer.byteLength(dataRaw) > MAX_SCAN_BYTES) {
|
|
503
|
+
throw new McpError(ErrorCode.InvalidParams, `"data" exceeds maximum scan size of ${MAX_SCAN_BYTES} bytes.`);
|
|
504
|
+
}
|
|
505
|
+
const t0 = Date.now();
|
|
506
|
+
const activeTokens = getActiveTokens(this.state).filter((t) => t.sequence !== '');
|
|
507
|
+
const results = scanForAllTokens(dataRaw, activeTokens);
|
|
508
|
+
const scan_duration_ms = Date.now() - t0;
|
|
509
|
+
// resolvedTokenIds tracks which tokens have already had resolveAction called,
|
|
510
|
+
// so that unicode, entity, and semantic scans don't fire duplicate events
|
|
511
|
+
// for the same token in a single scan_outbound call.
|
|
512
|
+
const resolvedTokenIds = new Set();
|
|
513
|
+
// ── Unicode marker scan ──────────────────────────────────────────────────
|
|
514
|
+
let leakage_count = 0;
|
|
515
|
+
for (const result of results) {
|
|
516
|
+
if (result.found) {
|
|
517
|
+
leakage_count += 1;
|
|
518
|
+
// RC-3: match details go to internal state only, not agent output.
|
|
519
|
+
await this.resolveAction(result.token_id, 'scan_outbound', optStr(args, 'target_server'), optStr(args, 'target_tool'), optStr(args, 'target_call_id'), optNum(args, 'turn_number'));
|
|
520
|
+
resolvedTokenIds.add(result.token_id);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// ── v1.1: Named entity scan ──────────────────────────────────────────────
|
|
524
|
+
let entity_leakage_count = 0;
|
|
525
|
+
for (const token of activeTokens) {
|
|
526
|
+
if (token.entity_canaries.length === 0)
|
|
527
|
+
continue;
|
|
528
|
+
const matched = scanForEntityValues(dataRaw, token.entity_canaries);
|
|
529
|
+
if (matched.length > 0) {
|
|
530
|
+
entity_leakage_count += 1;
|
|
531
|
+
log('warn', 'scan_outbound detected entity leakage.', {
|
|
532
|
+
token_id: token.token_id,
|
|
533
|
+
entity_types: matched.map((e) => e.entity_type),
|
|
534
|
+
});
|
|
535
|
+
if (!resolvedTokenIds.has(token.token_id)) {
|
|
536
|
+
await this.resolveAction(token.token_id, 'scan_outbound', optStr(args, 'target_server'), optStr(args, 'target_tool'), optStr(args, 'target_call_id'), optNum(args, 'turn_number'));
|
|
537
|
+
resolvedTokenIds.add(token.token_id);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// ── v1.6: SimHash semantic scan ──────────────────────────────────────────
|
|
542
|
+
let semantic_leakage_count = 0;
|
|
543
|
+
const dataSimhash = computeSimHash(dataRaw);
|
|
544
|
+
if (dataSimhash !== null) {
|
|
545
|
+
for (const token of activeTokens) {
|
|
546
|
+
if (token.simhash === null)
|
|
547
|
+
continue;
|
|
548
|
+
if (!isSimilar(token.simhash, dataSimhash))
|
|
549
|
+
continue;
|
|
550
|
+
semantic_leakage_count += 1;
|
|
551
|
+
log('warn', 'scan_outbound detected semantic leakage via SimHash.', {
|
|
552
|
+
token_id: token.token_id,
|
|
553
|
+
});
|
|
554
|
+
if (!resolvedTokenIds.has(token.token_id)) {
|
|
555
|
+
await this.resolveAction(token.token_id, 'scan_outbound', optStr(args, 'target_server'), optStr(args, 'target_tool'), optStr(args, 'target_call_id'), optNum(args, 'turn_number'));
|
|
556
|
+
resolvedTokenIds.add(token.token_id);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const total_leakage = leakage_count + entity_leakage_count + semantic_leakage_count;
|
|
561
|
+
if (total_leakage > 0) {
|
|
562
|
+
log('warn', 'scan_outbound detected leakage.', {
|
|
563
|
+
leakage_count,
|
|
564
|
+
entity_leakage_count,
|
|
565
|
+
semantic_leakage_count,
|
|
566
|
+
tokens_scanned: activeTokens.length,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
log('debug', 'scan_outbound: clean.', { tokens_scanned: activeTokens.length });
|
|
571
|
+
}
|
|
572
|
+
// RC-3: return only aggregate, no token_ids or sequences.
|
|
573
|
+
const agentResult = {
|
|
574
|
+
clean: total_leakage === 0,
|
|
575
|
+
tokens_scanned: activeTokens.length,
|
|
576
|
+
scan_duration_ms,
|
|
577
|
+
leakage_count,
|
|
578
|
+
entity_leakage_count,
|
|
579
|
+
semantic_leakage_count,
|
|
580
|
+
};
|
|
581
|
+
return {
|
|
582
|
+
content: [{ type: 'text', text: JSON.stringify(agentResult) }],
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
// -------------------------------------------------------------------------
|
|
586
|
+
// Tool: get_report (RC-4: operator-only)
|
|
587
|
+
// -------------------------------------------------------------------------
|
|
588
|
+
async handleGetReport(args) {
|
|
589
|
+
// RC-4: If CANARY_MCP_MGMT_KEY is set, require it to match.
|
|
590
|
+
const requiredKey = process.env['CANARY_MCP_MGMT_KEY'];
|
|
591
|
+
if (requiredKey) {
|
|
592
|
+
const provided = args['mgmt_key'];
|
|
593
|
+
if (typeof provided !== 'string' || provided !== requiredKey) {
|
|
594
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid or missing mgmt_key.');
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const now = Date.now();
|
|
598
|
+
const allTokens = [...this.state.tokens.values()];
|
|
599
|
+
const activeCount = allTokens.filter((t) => t.expires_at > now).length;
|
|
600
|
+
const expiredCount = allTokens.length - activeCount;
|
|
601
|
+
const leakedCount = allTokens.filter((t) => t.leaked).length;
|
|
602
|
+
const allEvents = [...this.state.leakage_events.values()];
|
|
603
|
+
// RC-5: Never include sequences. Only safe fields exposed.
|
|
604
|
+
const tokenSummaries = allTokens.map((t) => ({
|
|
605
|
+
token_id: t.token_id,
|
|
606
|
+
source_type: t.source_type,
|
|
607
|
+
source_server: t.source_server,
|
|
608
|
+
source_tool: t.source_tool,
|
|
609
|
+
embed_position: t.embed_position,
|
|
610
|
+
created_at: t.created_at,
|
|
611
|
+
expires_at: t.expires_at,
|
|
612
|
+
status: t.expires_at > now ? 'active' : 'expired',
|
|
613
|
+
leaked: t.leaked,
|
|
614
|
+
leakage_event_ids: t.leakage_event_ids,
|
|
615
|
+
// sequence deliberately omitted.
|
|
616
|
+
}));
|
|
617
|
+
return {
|
|
618
|
+
content: [
|
|
619
|
+
{
|
|
620
|
+
type: 'text',
|
|
621
|
+
text: JSON.stringify({
|
|
622
|
+
session_id: this.state.session_id,
|
|
623
|
+
created_at: this.state.created_at,
|
|
624
|
+
token_counter: this.state.token_counter,
|
|
625
|
+
summary: {
|
|
626
|
+
total_tokens: allTokens.length,
|
|
627
|
+
active_tokens: activeCount,
|
|
628
|
+
expired_tokens: expiredCount,
|
|
629
|
+
leaked_tokens: leakedCount,
|
|
630
|
+
total_leakage_events: allEvents.length,
|
|
631
|
+
},
|
|
632
|
+
tokens: tokenSummaries,
|
|
633
|
+
leakage_events: allEvents,
|
|
634
|
+
}),
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
// -------------------------------------------------------------------------
|
|
640
|
+
// Action dispatch helper
|
|
641
|
+
// -------------------------------------------------------------------------
|
|
642
|
+
/**
|
|
643
|
+
* Records leakage, fires webhook if configured, and returns the action taken.
|
|
644
|
+
* Internal helper — not exposed to agents.
|
|
645
|
+
*/
|
|
646
|
+
async resolveAction(token_id, detection_method, target_server, target_tool, target_call_id, turn_number) {
|
|
647
|
+
const mode = this.config.response_mode;
|
|
648
|
+
let webhook_attempted = false;
|
|
649
|
+
let webhook_delivered = null;
|
|
650
|
+
if (mode === 'alert' && this.config.alert_webhook) {
|
|
651
|
+
webhook_attempted = true;
|
|
652
|
+
const payload = {
|
|
653
|
+
event: 'leakage_detected',
|
|
654
|
+
event_id: randomBytes(16).toString('hex'),
|
|
655
|
+
token_id,
|
|
656
|
+
detected_at: Date.now(),
|
|
657
|
+
session_id: this.state.session_id,
|
|
658
|
+
};
|
|
659
|
+
webhook_delivered = await deliverWebhook(payload, this.config);
|
|
660
|
+
}
|
|
661
|
+
const action_taken = mode === 'halt' ? 'halted'
|
|
662
|
+
: mode === 'alert' ? 'alerted'
|
|
663
|
+
: 'logged';
|
|
664
|
+
recordLeakage(this.state, {
|
|
665
|
+
token_id,
|
|
666
|
+
detection_method,
|
|
667
|
+
target_server,
|
|
668
|
+
target_tool,
|
|
669
|
+
target_call_id,
|
|
670
|
+
turn_number,
|
|
671
|
+
action_taken,
|
|
672
|
+
webhook_attempted,
|
|
673
|
+
webhook_delivered,
|
|
674
|
+
});
|
|
675
|
+
await persistState(this.state, this.config).catch(() => { });
|
|
676
|
+
if (mode === 'halt') {
|
|
677
|
+
throw new McpError(ErrorCode.InternalError, 'Integrity marker detected in outbound data. Operation halted.');
|
|
678
|
+
}
|
|
679
|
+
return action_taken;
|
|
680
|
+
}
|
|
681
|
+
// -------------------------------------------------------------------------
|
|
682
|
+
// Lifecycle
|
|
683
|
+
// -------------------------------------------------------------------------
|
|
684
|
+
/**
|
|
685
|
+
* Starts the server on the MCP stdio transport.
|
|
686
|
+
*/
|
|
687
|
+
async start() {
|
|
688
|
+
const transport = new StdioServerTransport();
|
|
689
|
+
await this.server.connect(transport);
|
|
690
|
+
log('info', 'exfil/canary server started.', { session_id: this.state.session_id });
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Gracefully shuts down the server and persists final state.
|
|
694
|
+
*/
|
|
695
|
+
async shutdown() {
|
|
696
|
+
log('info', 'Shutting down exfil/canary server.');
|
|
697
|
+
await persistState(this.state, this.config).catch(() => { });
|
|
698
|
+
await this.server.close();
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Prunes expired tokens. Called by the background sweep interval.
|
|
702
|
+
*/
|
|
703
|
+
sweepExpiredTokens() {
|
|
704
|
+
const removed = pruneExpiredTokens(this.state);
|
|
705
|
+
if (removed > 0) {
|
|
706
|
+
log('debug', `Expiry sweep removed ${removed} token(s).`);
|
|
707
|
+
persistState(this.state, this.config).catch(() => { });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
//# sourceMappingURL=server.js.map
|