@jacobmolz/mcpguard 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,3771 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/constants.ts
13
+ import { homedir } from "os";
14
+ import { join } from "path";
15
+ var DEFAULT_HOME, DEFAULT_SOCKET_PATH, DEFAULT_PID_FILE, DEFAULT_DAEMON_KEY_PATH, DEFAULT_DB_PATH, DEFAULT_DASHBOARD_PORT, DAEMON_KEY_BYTES, AUTH_TIMEOUT, MAX_MESSAGE_SIZE, DEFAULT_EXTENDS_CACHE_DIR, EXTENDS_FETCH_TIMEOUT, CONFIG_RELOAD_DEBOUNCE, OAUTH_TOKEN_DIR, OAUTH_TOKEN_FILE_MODE, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_TIMEOUT;
16
+ var init_constants = __esm({
17
+ "src/constants.ts"() {
18
+ "use strict";
19
+ DEFAULT_HOME = join(homedir(), ".config", "mcp-guard");
20
+ DEFAULT_SOCKET_PATH = join(DEFAULT_HOME, "daemon.sock");
21
+ DEFAULT_PID_FILE = join(DEFAULT_HOME, "daemon.pid");
22
+ DEFAULT_DAEMON_KEY_PATH = join(DEFAULT_HOME, "daemon.key");
23
+ DEFAULT_DB_PATH = join(DEFAULT_HOME, "mcp-guard.db");
24
+ DEFAULT_DASHBOARD_PORT = 9777;
25
+ DAEMON_KEY_BYTES = 32;
26
+ AUTH_TIMEOUT = 5e3;
27
+ MAX_MESSAGE_SIZE = 4 * 1024 * 1024;
28
+ DEFAULT_EXTENDS_CACHE_DIR = "extends-cache";
29
+ EXTENDS_FETCH_TIMEOUT = 1e4;
30
+ CONFIG_RELOAD_DEBOUNCE = 250;
31
+ OAUTH_TOKEN_DIR = "oauth-tokens";
32
+ OAUTH_TOKEN_FILE_MODE = 384;
33
+ OAUTH_CALLBACK_PORT = 8399;
34
+ OAUTH_CALLBACK_TIMEOUT = 12e4;
35
+ }
36
+ });
37
+
38
+ // src/config/schema.ts
39
+ import { z } from "zod";
40
+ var permissionsSchema, rateLimitSchema, policySchema, serverSchema, daemonSchema, claimsToRolesSchema, oauthSchema, authSchema, interceptorConfigSchema, piiActionSchema, piiTypeActionsSchema, customPiiTypeSchema, piiSchema, auditSchema, extendsSchema, configSchema;
41
+ var init_schema = __esm({
42
+ "src/config/schema.ts"() {
43
+ "use strict";
44
+ init_constants();
45
+ permissionsSchema = z.object({
46
+ allowed_tools: z.array(z.string()).optional(),
47
+ denied_tools: z.array(z.string()).default([]),
48
+ allowed_resources: z.array(z.string()).optional(),
49
+ denied_resources: z.array(z.string()).default([])
50
+ }).default({});
51
+ rateLimitSchema = z.object({
52
+ requests_per_minute: z.number().min(1).optional(),
53
+ requests_per_hour: z.number().min(1).optional(),
54
+ tool_limits: z.record(z.string(), z.object({
55
+ requests_per_minute: z.number().min(1).optional()
56
+ })).default({})
57
+ }).default({});
58
+ policySchema = z.object({
59
+ permissions: permissionsSchema,
60
+ rate_limit: rateLimitSchema,
61
+ sampling: z.object({
62
+ enabled: z.boolean().default(false),
63
+ max_tokens: z.number().min(1).optional(),
64
+ rate_limit: z.number().min(1).optional(),
65
+ audit: z.enum(["basic", "verbose"]).default("basic")
66
+ }).default({}),
67
+ locked: z.boolean().default(false)
68
+ }).default({});
69
+ serverSchema = z.object({
70
+ command: z.string().optional(),
71
+ args: z.array(z.string()).default([]),
72
+ env: z.record(z.string()).default({}),
73
+ url: z.string().url().optional(),
74
+ transport: z.enum(["stdio", "sse", "streamable-http"]).default("stdio"),
75
+ /** Bearer token for authenticating MCP-Guard to the upstream HTTP server.
76
+ * Use env var interpolation for security: upstream_auth_token: "${MCP_AUTH_TOKEN}" */
77
+ upstream_auth_token: z.string().optional(),
78
+ policy: policySchema
79
+ });
80
+ daemonSchema = z.object({
81
+ socket_path: z.string().default(DEFAULT_SOCKET_PATH),
82
+ home: z.string().default(DEFAULT_HOME),
83
+ shutdown_timeout: z.number().min(1).default(30),
84
+ log_level: z.enum(["debug", "info", "warn", "error"]).default("info"),
85
+ dashboard_port: z.number().min(0).max(65535).default(DEFAULT_DASHBOARD_PORT),
86
+ encryption: z.object({
87
+ enabled: z.boolean().default(false)
88
+ }).default({})
89
+ });
90
+ claimsToRolesSchema = z.object({
91
+ claim_name: z.string().default("roles"),
92
+ mapping: z.record(z.string(), z.array(z.string())).default({})
93
+ }).default({});
94
+ oauthSchema = z.object({
95
+ issuer: z.string().url(),
96
+ jwks_uri: z.string().url().optional(),
97
+ audience: z.string().optional(),
98
+ client_id: z.string(),
99
+ client_secret: z.string().optional(),
100
+ scopes: z.array(z.string()).default(["openid", "profile"]),
101
+ claims_to_roles: claimsToRolesSchema,
102
+ clock_tolerance_seconds: z.number().min(0).max(300).default(30)
103
+ });
104
+ authSchema = z.object({
105
+ mode: z.enum(["os", "api_key", "oauth"]).default("os"),
106
+ api_keys: z.record(z.string(), z.object({
107
+ roles: z.array(z.string()).default(["default"])
108
+ })).default({}),
109
+ oauth: oauthSchema.optional(),
110
+ roles: z.record(z.string(), z.object({
111
+ permissions: permissionsSchema,
112
+ rate_limit: rateLimitSchema
113
+ })).default({})
114
+ }).default({}).refine((auth) => {
115
+ if (auth.mode === "oauth" && !auth.oauth) {
116
+ return false;
117
+ }
118
+ return true;
119
+ }, { message: 'auth.oauth config required when mode is "oauth"' });
120
+ interceptorConfigSchema = z.object({
121
+ timeout: z.number().min(1).default(10),
122
+ timeout_action: z.enum(["block"]).default("block")
123
+ }).default({});
124
+ piiActionSchema = z.enum(["block", "redact", "warn"]);
125
+ piiTypeActionsSchema = z.object({
126
+ request: piiActionSchema.default("redact"),
127
+ response: piiActionSchema.default("warn")
128
+ });
129
+ customPiiTypeSchema = z.object({
130
+ label: z.string(),
131
+ patterns: z.array(z.object({ regex: z.string() })).min(1),
132
+ actions: piiTypeActionsSchema
133
+ });
134
+ piiSchema = z.object({
135
+ enabled: z.boolean().default(true),
136
+ confidence_threshold: z.number().min(0).max(1).default(0.8),
137
+ actions: z.record(z.string(), piiTypeActionsSchema).default({
138
+ email: { request: "redact", response: "warn" },
139
+ phone: { request: "redact", response: "warn" },
140
+ ssn: { request: "block", response: "redact" },
141
+ credit_card: { request: "block", response: "redact" },
142
+ aws_key: { request: "redact", response: "redact" },
143
+ github_token: { request: "redact", response: "redact" }
144
+ }),
145
+ custom_types: z.record(z.string(), customPiiTypeSchema).default({})
146
+ }).default({});
147
+ auditSchema = z.object({
148
+ enabled: z.boolean().default(true),
149
+ stdout: z.boolean().default(true),
150
+ retention_days: z.number().min(1).default(90)
151
+ }).default({});
152
+ extendsSchema = z.object({
153
+ url: z.string().url().refine((url) => {
154
+ const parsed = new URL(url);
155
+ if (parsed.protocol === "https:") return true;
156
+ if (parsed.protocol === "http:") {
157
+ const host = parsed.hostname;
158
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
159
+ }
160
+ return false;
161
+ }, "extends URL must use HTTPS (HTTP allowed only for loopback addresses)"),
162
+ sha256: z.string().regex(/^[a-f0-9]{64}$/i, "SHA-256 hash must be 64 hex characters")
163
+ });
164
+ configSchema = z.object({
165
+ extends: extendsSchema.optional(),
166
+ servers: z.record(z.string(), serverSchema),
167
+ daemon: daemonSchema.default({}),
168
+ auth: authSchema,
169
+ interceptors: interceptorConfigSchema,
170
+ pii: piiSchema,
171
+ audit: auditSchema
172
+ });
173
+ }
174
+ });
175
+
176
+ // src/errors.ts
177
+ var McpGuardError, ConfigError, AuthError, StorageError, BridgeError, DashboardError, OAuthError;
178
+ var init_errors = __esm({
179
+ "src/errors.ts"() {
180
+ "use strict";
181
+ McpGuardError = class extends Error {
182
+ constructor(message, code) {
183
+ super(message);
184
+ this.code = code;
185
+ this.name = this.constructor.name;
186
+ }
187
+ code;
188
+ };
189
+ ConfigError = class extends McpGuardError {
190
+ constructor(message) {
191
+ super(message, "CONFIG_ERROR");
192
+ }
193
+ };
194
+ AuthError = class extends McpGuardError {
195
+ constructor(message) {
196
+ super(message, "AUTH_ERROR");
197
+ }
198
+ };
199
+ StorageError = class extends McpGuardError {
200
+ constructor(message) {
201
+ super(message, "STORAGE_ERROR");
202
+ }
203
+ };
204
+ BridgeError = class extends McpGuardError {
205
+ constructor(message) {
206
+ super(message, "BRIDGE_ERROR");
207
+ }
208
+ };
209
+ DashboardError = class extends McpGuardError {
210
+ constructor(message) {
211
+ super(message, "DASHBOARD_ERROR");
212
+ }
213
+ };
214
+ OAuthError = class extends McpGuardError {
215
+ constructor(message) {
216
+ super(message, "OAUTH_ERROR");
217
+ }
218
+ };
219
+ }
220
+ });
221
+
222
+ // src/config/fetcher.ts
223
+ import { readFile, writeFile, mkdir } from "fs/promises";
224
+ import { join as join2 } from "path";
225
+ import { createHash } from "crypto";
226
+ function computeSha256(content) {
227
+ return createHash("sha256").update(content, "utf-8").digest("hex");
228
+ }
229
+ async function fetchBaseConfig(url, expectedSha256, cacheDir) {
230
+ const cacheFile = join2(cacheDir, `${expectedSha256}.yaml`);
231
+ let fetchError;
232
+ try {
233
+ const response = await fetch(url, {
234
+ signal: AbortSignal.timeout(EXTENDS_FETCH_TIMEOUT)
235
+ });
236
+ if (!response.ok) {
237
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
238
+ }
239
+ const yaml3 = await response.text();
240
+ const actualHash = computeSha256(yaml3);
241
+ if (actualHash.toLowerCase() !== expectedSha256.toLowerCase()) {
242
+ throw new ConfigError(
243
+ `Base config SHA-256 mismatch \u2014 expected ${expectedSha256}, got ${actualHash}. The remote config at ${url} may have been tampered with or updated without a hash change.`
244
+ );
245
+ }
246
+ await mkdir(cacheDir, { recursive: true, mode: 448 });
247
+ await writeFile(cacheFile, yaml3, { mode: 384 });
248
+ return { yaml: yaml3, fromCache: false };
249
+ } catch (err) {
250
+ if (err instanceof ConfigError) {
251
+ throw err;
252
+ }
253
+ fetchError = err;
254
+ }
255
+ let cachedYaml;
256
+ try {
257
+ cachedYaml = await readFile(cacheFile, "utf-8");
258
+ } catch {
259
+ throw new ConfigError(
260
+ `Failed to fetch base config from ${url} (${fetchError?.message}) and no cached copy exists. Cannot start without a verified base config.`
261
+ );
262
+ }
263
+ const cachedHash = computeSha256(cachedYaml);
264
+ if (cachedHash.toLowerCase() !== expectedSha256.toLowerCase()) {
265
+ throw new ConfigError(
266
+ `Cached base config has invalid SHA-256 \u2014 expected ${expectedSha256}, got ${cachedHash}. Cache may be corrupted.`
267
+ );
268
+ }
269
+ return { yaml: cachedYaml, fromCache: true };
270
+ }
271
+ var init_fetcher = __esm({
272
+ "src/config/fetcher.ts"() {
273
+ "use strict";
274
+ init_errors();
275
+ init_constants();
276
+ }
277
+ });
278
+
279
+ // src/config/merger.ts
280
+ function mergeConfigs(base, personal) {
281
+ return {
282
+ // extends is consumed during loading — not carried forward
283
+ servers: mergeServers(base, personal),
284
+ // Base wins for daemon, auth — personal cannot change these
285
+ daemon: base.daemon,
286
+ auth: base.auth,
287
+ // Stricter interceptor timeout
288
+ interceptors: {
289
+ timeout: Math.min(base.interceptors.timeout, personal.interceptors.timeout),
290
+ timeout_action: base.interceptors.timeout_action
291
+ },
292
+ pii: mergePii(base.pii, personal.pii),
293
+ audit: base.audit
294
+ };
295
+ }
296
+ function mergeServers(base, personal) {
297
+ const merged = {};
298
+ for (const [name, baseServer] of Object.entries(base.servers)) {
299
+ const personalServer = personal.servers[name];
300
+ if (!personalServer) {
301
+ merged[name] = baseServer;
302
+ continue;
303
+ }
304
+ if (baseServer.policy.locked) {
305
+ merged[name] = baseServer;
306
+ continue;
307
+ }
308
+ merged[name] = {
309
+ // Server connection config from base (personal cannot change transport/command)
310
+ command: baseServer.command,
311
+ args: baseServer.args,
312
+ env: baseServer.env,
313
+ url: baseServer.url,
314
+ transport: baseServer.transport,
315
+ upstream_auth_token: baseServer.upstream_auth_token,
316
+ policy: mergePolicy(baseServer.policy, personalServer.policy)
317
+ };
318
+ }
319
+ for (const [name, personalServer] of Object.entries(personal.servers)) {
320
+ if (!(name in base.servers)) {
321
+ merged[name] = personalServer;
322
+ }
323
+ }
324
+ return merged;
325
+ }
326
+ function mergePolicy(base, personal) {
327
+ return {
328
+ permissions: {
329
+ // Intersection — only tools in BOTH lists survive
330
+ allowed_tools: intersectLists(
331
+ base.permissions.allowed_tools,
332
+ personal.permissions.allowed_tools
333
+ ),
334
+ // Union — all denials from both apply
335
+ denied_tools: unionLists(base.permissions.denied_tools, personal.permissions.denied_tools),
336
+ // Same for resources
337
+ allowed_resources: intersectLists(
338
+ base.permissions.allowed_resources,
339
+ personal.permissions.allowed_resources
340
+ ),
341
+ denied_resources: unionLists(base.permissions.denied_resources, personal.permissions.denied_resources)
342
+ },
343
+ rate_limit: {
344
+ // Stricter (lower) value wins
345
+ requests_per_minute: minOptional(
346
+ base.rate_limit.requests_per_minute,
347
+ personal.rate_limit.requests_per_minute
348
+ ),
349
+ requests_per_hour: minOptional(
350
+ base.rate_limit.requests_per_hour,
351
+ personal.rate_limit.requests_per_hour
352
+ ),
353
+ tool_limits: mergeToolLimits(base.rate_limit.tool_limits, personal.rate_limit.tool_limits)
354
+ },
355
+ sampling: {
356
+ // AND — both must enable for sampling to work
357
+ enabled: base.sampling.enabled && personal.sampling.enabled,
358
+ max_tokens: minOptional(base.sampling.max_tokens, personal.sampling.max_tokens),
359
+ rate_limit: minOptional(base.sampling.rate_limit, personal.sampling.rate_limit),
360
+ // Stricter audit level wins (verbose > basic)
361
+ audit: base.sampling.audit === "verbose" || personal.sampling.audit === "verbose" ? "verbose" : "basic"
362
+ },
363
+ locked: base.locked
364
+ };
365
+ }
366
+ function mergePii(base, personal) {
367
+ return {
368
+ // Base wins — personal cannot toggle PII scanning on or off
369
+ enabled: base.enabled,
370
+ // Lower threshold = more sensitive = stricter. Personal cannot raise it.
371
+ confidence_threshold: Math.min(base.confidence_threshold, personal.confidence_threshold),
372
+ // PII actions: personal can escalate (warn→block), cannot relax (block→warn)
373
+ actions: mergePiiActions(base.actions, personal.actions),
374
+ // Custom types: additive — personal can add, cannot remove base types
375
+ custom_types: mergePiiCustomTypes(base.custom_types, personal.custom_types)
376
+ };
377
+ }
378
+ function mergePiiActions(base, personal) {
379
+ const merged = { ...base };
380
+ for (const [type, personalAction] of Object.entries(personal)) {
381
+ const baseAction = base[type];
382
+ if (!baseAction) {
383
+ merged[type] = personalAction;
384
+ continue;
385
+ }
386
+ merged[type] = {
387
+ request: stricterAction(baseAction.request, personalAction.request),
388
+ response: stricterAction(baseAction.response, personalAction.response)
389
+ };
390
+ }
391
+ return merged;
392
+ }
393
+ function mergePiiCustomTypes(base, personal) {
394
+ const merged = { ...base };
395
+ for (const [name, personalType] of Object.entries(personal)) {
396
+ const baseType = base[name];
397
+ if (!baseType) {
398
+ merged[name] = personalType;
399
+ continue;
400
+ }
401
+ merged[name] = {
402
+ label: baseType.label,
403
+ // Union patterns: all base patterns preserved, personal can add more
404
+ patterns: [
405
+ ...baseType.patterns,
406
+ ...personalType.patterns.filter(
407
+ (pp) => !baseType.patterns.some((bp) => bp.regex === pp.regex)
408
+ )
409
+ ],
410
+ // Actions: take the stricter of base vs personal per direction
411
+ actions: {
412
+ request: stricterAction(baseType.actions.request, personalType.actions.request),
413
+ response: stricterAction(baseType.actions.response, personalType.actions.response)
414
+ }
415
+ };
416
+ }
417
+ return merged;
418
+ }
419
+ function stricterAction(base, personal) {
420
+ const baseSeverity = PII_ACTION_SEVERITY[base] ?? 0;
421
+ const personalSeverity = PII_ACTION_SEVERITY[personal] ?? 0;
422
+ return personalSeverity >= baseSeverity ? personal : base;
423
+ }
424
+ function intersectLists(base, personal) {
425
+ if (base === void 0) return personal;
426
+ if (personal === void 0) return base;
427
+ const baseSet = new Set(base);
428
+ return personal.filter((item) => baseSet.has(item));
429
+ }
430
+ function unionLists(base, personal) {
431
+ return [.../* @__PURE__ */ new Set([...base, ...personal])];
432
+ }
433
+ function minOptional(a, b) {
434
+ if (a === void 0) return b;
435
+ if (b === void 0) return a;
436
+ return Math.min(a, b);
437
+ }
438
+ function mergeToolLimits(base, personal) {
439
+ const merged = { ...base };
440
+ for (const [tool, personalLimit] of Object.entries(personal)) {
441
+ const baseLimit = base[tool];
442
+ if (!baseLimit) {
443
+ merged[tool] = personalLimit;
444
+ continue;
445
+ }
446
+ merged[tool] = {
447
+ requests_per_minute: minOptional(
448
+ baseLimit.requests_per_minute,
449
+ personalLimit.requests_per_minute
450
+ )
451
+ };
452
+ }
453
+ return merged;
454
+ }
455
+ var PII_ACTION_SEVERITY;
456
+ var init_merger = __esm({
457
+ "src/config/merger.ts"() {
458
+ "use strict";
459
+ PII_ACTION_SEVERITY = {
460
+ warn: 0,
461
+ redact: 1,
462
+ block: 2
463
+ };
464
+ }
465
+ });
466
+
467
+ // src/config/loader.ts
468
+ var loader_exports = {};
469
+ __export(loader_exports, {
470
+ loadConfig: () => loadConfig,
471
+ reloadConfig: () => reloadConfig
472
+ });
473
+ import { readFile as readFile2 } from "fs/promises";
474
+ import { join as join3 } from "path";
475
+ import yaml from "js-yaml";
476
+ function interpolateEnvVars(content) {
477
+ return content.replace(/\$\{([^}]+)\}/g, (_match, varName) => {
478
+ const value = process.env[varName];
479
+ if (value === void 0) {
480
+ throw new ConfigError(`Unresolved environment variable: \${${varName}}`);
481
+ }
482
+ return value;
483
+ });
484
+ }
485
+ function parseAndValidate(content, source) {
486
+ const interpolated = interpolateEnvVars(content);
487
+ const raw = yaml.load(interpolated);
488
+ const result = configSchema.safeParse(raw);
489
+ if (!result.success) {
490
+ const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
491
+ throw new ConfigError(`Invalid config (${source}):
492
+ ${issues}`);
493
+ }
494
+ return result.data;
495
+ }
496
+ async function loadConfig(configPath) {
497
+ const path = configPath ?? process.env["MCP_GUARD_CONFIG"] ?? "mcp-guard.yaml";
498
+ let content;
499
+ try {
500
+ content = await readFile2(path, "utf-8");
501
+ } catch (err) {
502
+ const code = err.code;
503
+ if (code === "ENOENT") {
504
+ throw new ConfigError(`Config file not found: ${path}`);
505
+ }
506
+ throw new ConfigError(`Failed to read config file: ${path} \u2014 ${String(err)}`);
507
+ }
508
+ const personalConfig = parseAndValidate(content, path);
509
+ if (personalConfig.extends) {
510
+ const cacheDir = join3(personalConfig.daemon.home, DEFAULT_EXTENDS_CACHE_DIR);
511
+ const { yaml: baseYaml } = await fetchBaseConfig(
512
+ personalConfig.extends.url,
513
+ personalConfig.extends.sha256,
514
+ cacheDir
515
+ );
516
+ const baseConfig = parseAndValidate(baseYaml, personalConfig.extends.url);
517
+ const merged = mergeConfigs(baseConfig, personalConfig);
518
+ return Object.freeze(merged);
519
+ }
520
+ return Object.freeze(personalConfig);
521
+ }
522
+ async function reloadConfig(configPath) {
523
+ const path = configPath;
524
+ let content;
525
+ try {
526
+ content = await readFile2(path, "utf-8");
527
+ } catch (err) {
528
+ const code = err.code;
529
+ if (code === "ENOENT") {
530
+ throw new ConfigError(`Config file not found: ${path}`);
531
+ }
532
+ throw new ConfigError(`Failed to read config file: ${path} \u2014 ${String(err)}`);
533
+ }
534
+ const personalConfig = parseAndValidate(content, path);
535
+ if (personalConfig.extends) {
536
+ const sha256 = personalConfig.extends.sha256;
537
+ if (cachedBase && cachedBase.sha256 === sha256) {
538
+ const merged2 = mergeConfigs(cachedBase.config, personalConfig);
539
+ return Object.freeze(merged2);
540
+ }
541
+ const cacheDir = join3(personalConfig.daemon.home, DEFAULT_EXTENDS_CACHE_DIR);
542
+ const { yaml: baseYaml } = await fetchBaseConfig(
543
+ personalConfig.extends.url,
544
+ sha256,
545
+ cacheDir
546
+ );
547
+ const baseConfig = parseAndValidate(baseYaml, personalConfig.extends.url);
548
+ cachedBase = { sha256, config: baseConfig };
549
+ const merged = mergeConfigs(baseConfig, personalConfig);
550
+ return Object.freeze(merged);
551
+ }
552
+ cachedBase = void 0;
553
+ return Object.freeze(personalConfig);
554
+ }
555
+ var cachedBase;
556
+ var init_loader = __esm({
557
+ "src/config/loader.ts"() {
558
+ "use strict";
559
+ init_schema();
560
+ init_errors();
561
+ init_fetcher();
562
+ init_merger();
563
+ init_constants();
564
+ }
565
+ });
566
+
567
+ // src/cli.ts
568
+ init_loader();
569
+ import { Command } from "commander";
570
+ import { readFile as readFile6, unlink as unlink3 } from "fs/promises";
571
+ import { join as join10 } from "path";
572
+
573
+ // src/daemon/index.ts
574
+ import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
575
+ import { join as join6 } from "path";
576
+
577
+ // src/identity/daemon-key.ts
578
+ init_errors();
579
+ init_constants();
580
+ import { randomBytes, timingSafeEqual } from "crypto";
581
+ import { readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, stat, chmod } from "fs/promises";
582
+ import { dirname } from "path";
583
+ async function ensureDaemonKey(keyPath) {
584
+ const path = keyPath ?? DEFAULT_DAEMON_KEY_PATH;
585
+ try {
586
+ const stats = await stat(path);
587
+ const mode = stats.mode & 511;
588
+ if (mode !== 384) {
589
+ throw new AuthError(
590
+ `Daemon key has insecure permissions: ${mode.toString(8)} (expected 600): ${path}`
591
+ );
592
+ }
593
+ return await readFile3(path);
594
+ } catch (err) {
595
+ if (err instanceof AuthError) throw err;
596
+ const errCode = err.code;
597
+ if (errCode !== "ENOENT") {
598
+ throw new AuthError(`Failed to read daemon key: ${err}`);
599
+ }
600
+ }
601
+ const dir = dirname(path);
602
+ await mkdir2(dir, { recursive: true, mode: 448 });
603
+ const key = randomBytes(DAEMON_KEY_BYTES);
604
+ await writeFile2(path, key, { mode: 384 });
605
+ return key;
606
+ }
607
+ async function readDaemonKey(keyPath) {
608
+ const path = keyPath ?? DEFAULT_DAEMON_KEY_PATH;
609
+ try {
610
+ const stats = await stat(path);
611
+ const mode = stats.mode & 511;
612
+ if (mode !== 384) {
613
+ throw new AuthError(
614
+ `Daemon key has insecure permissions: ${mode.toString(8)} (expected 600): ${path}`
615
+ );
616
+ }
617
+ return await readFile3(path);
618
+ } catch (err) {
619
+ if (err instanceof AuthError) throw err;
620
+ throw new AuthError(`Daemon key not found: ${path}`);
621
+ }
622
+ }
623
+ function verifyDaemonKey(presented, expected) {
624
+ if (presented.length !== expected.length) {
625
+ return false;
626
+ }
627
+ return timingSafeEqual(presented, expected);
628
+ }
629
+
630
+ // src/storage/sqlite.ts
631
+ init_errors();
632
+ import Database from "better-sqlite3-multiple-ciphers";
633
+ import { chmodSync } from "fs";
634
+ import { hkdfSync } from "crypto";
635
+ function deriveDbEncryptionKey(daemonKey) {
636
+ const derived = hkdfSync("sha256", daemonKey, "mcp-guard", "mcp-guard-db-encryption", 32);
637
+ return Buffer.from(derived).toString("hex");
638
+ }
639
+ function openDatabase(options) {
640
+ try {
641
+ const db = new Database(options.path);
642
+ if (options.encryptionKey) {
643
+ if (!/^[a-f0-9]+$/i.test(options.encryptionKey)) {
644
+ throw new StorageError("Encryption key must be hex-encoded");
645
+ }
646
+ db.pragma(`key="x'${options.encryptionKey}'"`);
647
+ }
648
+ db.pragma("journal_mode = WAL");
649
+ db.pragma("busy_timeout = 5000");
650
+ db.pragma("foreign_keys = ON");
651
+ if (options.path !== ":memory:") {
652
+ chmodSync(options.path, 384);
653
+ }
654
+ return db;
655
+ } catch (err) {
656
+ throw new StorageError(`Failed to open database: ${options.path} \u2014 ${String(err)}`);
657
+ }
658
+ }
659
+ function checkpointWal(db) {
660
+ try {
661
+ db.pragma("wal_checkpoint(TRUNCATE)");
662
+ } catch (err) {
663
+ throw new StorageError(`WAL checkpoint failed: ${String(err)}`);
664
+ }
665
+ }
666
+
667
+ // src/storage/migrations.ts
668
+ init_errors();
669
+ var migrations = [
670
+ {
671
+ name: "001-schema-migrations",
672
+ up(db) {
673
+ db.exec(`
674
+ CREATE TABLE IF NOT EXISTS schema_migrations (
675
+ name TEXT PRIMARY KEY,
676
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
677
+ )
678
+ `);
679
+ }
680
+ },
681
+ {
682
+ name: "002-daemon-state",
683
+ up(db) {
684
+ db.exec(`
685
+ CREATE TABLE IF NOT EXISTS daemon_state (
686
+ key TEXT PRIMARY KEY,
687
+ value TEXT NOT NULL,
688
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
689
+ )
690
+ `);
691
+ }
692
+ },
693
+ {
694
+ name: "003-audit-logs",
695
+ up(db) {
696
+ db.exec(`
697
+ CREATE TABLE audit_logs (
698
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
699
+ timestamp TEXT NOT NULL DEFAULT (datetime('now')),
700
+ bridge_id TEXT NOT NULL,
701
+ server TEXT NOT NULL,
702
+ method TEXT NOT NULL,
703
+ direction TEXT NOT NULL CHECK(direction IN ('request', 'response')),
704
+ identity_uid INTEGER NOT NULL,
705
+ identity_username TEXT NOT NULL,
706
+ identity_roles TEXT NOT NULL,
707
+ tool_or_resource TEXT,
708
+ params_summary TEXT,
709
+ interceptor_decisions TEXT NOT NULL,
710
+ allowed INTEGER NOT NULL,
711
+ blocked_by TEXT,
712
+ block_reason TEXT,
713
+ latency_ms REAL,
714
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
715
+ );
716
+
717
+ CREATE INDEX idx_audit_server ON audit_logs(server);
718
+ CREATE INDEX idx_audit_timestamp ON audit_logs(timestamp);
719
+ CREATE INDEX idx_audit_method ON audit_logs(method);
720
+ CREATE INDEX idx_audit_identity ON audit_logs(identity_username);
721
+ `);
722
+ }
723
+ },
724
+ {
725
+ name: "004-rate-limits",
726
+ up(db) {
727
+ db.exec(`
728
+ CREATE TABLE rate_limits (
729
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
730
+ key TEXT NOT NULL UNIQUE,
731
+ tokens REAL NOT NULL,
732
+ max_tokens REAL NOT NULL,
733
+ refill_rate REAL NOT NULL,
734
+ last_refill TEXT NOT NULL DEFAULT (datetime('now')),
735
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
736
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
737
+ )
738
+ `);
739
+ }
740
+ }
741
+ ];
742
+ function runMigrations(db) {
743
+ db.exec(`
744
+ CREATE TABLE IF NOT EXISTS schema_migrations (
745
+ name TEXT PRIMARY KEY,
746
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
747
+ )
748
+ `);
749
+ const applied = new Set(
750
+ db.prepare("SELECT name FROM schema_migrations").all().map((row) => row.name)
751
+ );
752
+ for (const migration of migrations) {
753
+ if (applied.has(migration.name)) {
754
+ continue;
755
+ }
756
+ const transaction = db.transaction(() => {
757
+ migration.up(db);
758
+ db.prepare("INSERT INTO schema_migrations (name) VALUES (?)").run(migration.name);
759
+ });
760
+ try {
761
+ transaction();
762
+ } catch (err) {
763
+ throw new StorageError(`Migration '${migration.name}' failed: ${String(err)}`);
764
+ }
765
+ }
766
+ }
767
+
768
+ // src/daemon/socket-server.ts
769
+ import { createServer } from "net";
770
+ import { randomUUID } from "crypto";
771
+ import { unlinkSync } from "fs";
772
+ import { chmodSync as chmodSync2 } from "fs";
773
+
774
+ // src/identity/os-identity.ts
775
+ init_errors();
776
+ import koffi from "koffi";
777
+ var getPeerCredentialsFn;
778
+ function initMacOS() {
779
+ const lib = koffi.load("libc.dylib");
780
+ const getpeereid = lib.func("int getpeereid(int, _Out_ uint32_t *, _Out_ uint32_t *)");
781
+ const getsockopt = lib.func(
782
+ "int getsockopt(int, int, int, _Out_ uint8_t *, _Inout_ uint32_t *)"
783
+ );
784
+ return (socketFd) => {
785
+ const uidBuf = [0];
786
+ const gidBuf = [0];
787
+ const rc = getpeereid(socketFd, uidBuf, gidBuf);
788
+ if (rc !== 0) {
789
+ throw new AuthError(`getpeereid failed with rc=${rc}`);
790
+ }
791
+ const pidBuf = Buffer.alloc(4);
792
+ const lenBuf = [4];
793
+ const pidRc = getsockopt(socketFd, 0, 2, pidBuf, lenBuf);
794
+ const pid = pidRc === 0 ? pidBuf.readUInt32LE(0) : void 0;
795
+ return { uid: uidBuf[0], gid: gidBuf[0], pid };
796
+ };
797
+ }
798
+ function initLinux() {
799
+ const lib = koffi.load("libc.so.6");
800
+ koffi.struct("ucred", {
801
+ pid: "int",
802
+ uid: "uint32_t",
803
+ gid: "uint32_t"
804
+ });
805
+ const getsockopt = lib.func("int getsockopt(int, int, int, _Out_ ucred *, _Inout_ uint32_t *)");
806
+ return (socketFd) => {
807
+ const cred = { pid: 0, uid: 0, gid: 0 };
808
+ const lenBuf = [12];
809
+ const rc = getsockopt(socketFd, 1, 17, cred, lenBuf);
810
+ if (rc !== 0) {
811
+ throw new AuthError(`getsockopt SO_PEERCRED failed with rc=${rc}`);
812
+ }
813
+ return { uid: cred.uid, gid: cred.gid, pid: cred.pid };
814
+ };
815
+ }
816
+ function getPeerCredentials(socketFd) {
817
+ if (!getPeerCredentialsFn) {
818
+ if (process.platform === "darwin") {
819
+ getPeerCredentialsFn = initMacOS();
820
+ } else if (process.platform === "linux") {
821
+ getPeerCredentialsFn = initLinux();
822
+ } else {
823
+ throw new AuthError(`Unsupported platform for peer credentials: ${process.platform}`);
824
+ }
825
+ }
826
+ return getPeerCredentialsFn(socketFd);
827
+ }
828
+ function verifyPeerIsCurrentUser(creds) {
829
+ if (!process.getuid) {
830
+ throw new AuthError("process.getuid not available on this platform");
831
+ }
832
+ return creds.uid === process.getuid();
833
+ }
834
+
835
+ // src/daemon/socket-server.ts
836
+ init_constants();
837
+ function writeFramed(socket, data) {
838
+ const json = JSON.stringify(data);
839
+ const payload = Buffer.from(json, "utf-8");
840
+ const header = Buffer.alloc(4);
841
+ header.writeUInt32BE(payload.length, 0);
842
+ socket.write(Buffer.concat([header, payload]));
843
+ }
844
+ function createFrameParser(onMessage) {
845
+ let buffer = Buffer.alloc(0);
846
+ return (chunk) => {
847
+ buffer = Buffer.concat([buffer, chunk]);
848
+ while (buffer.length >= 4) {
849
+ const length = buffer.readUInt32BE(0);
850
+ if (length > MAX_MESSAGE_SIZE) {
851
+ buffer = Buffer.alloc(0);
852
+ return;
853
+ }
854
+ if (buffer.length < 4 + length) {
855
+ break;
856
+ }
857
+ const json = buffer.subarray(4, 4 + length).toString("utf-8");
858
+ buffer = buffer.subarray(4 + length);
859
+ try {
860
+ onMessage(JSON.parse(json));
861
+ } catch {
862
+ }
863
+ }
864
+ };
865
+ }
866
+ function createSocketServer(options) {
867
+ const { socketPath, daemonKey, onConnection, logger: logger2 } = options;
868
+ const connections = /* @__PURE__ */ new Map();
869
+ let server;
870
+ function handleSocket(socket) {
871
+ let authenticated = false;
872
+ let connId;
873
+ const messageHandlers = [];
874
+ const authTimer = setTimeout(() => {
875
+ if (!authenticated) {
876
+ logger2.warn("Connection auth timeout \u2014 closing");
877
+ socket.destroy();
878
+ }
879
+ }, AUTH_TIMEOUT);
880
+ const parser = createFrameParser((data) => {
881
+ const msg = data;
882
+ if (!authenticated) {
883
+ if (msg.type !== "auth") {
884
+ socket.destroy();
885
+ return;
886
+ }
887
+ const presented = Buffer.from(msg.key, "hex");
888
+ if (!verifyDaemonKey(presented, daemonKey)) {
889
+ writeFramed(socket, { type: "auth_fail", reason: "Invalid daemon key" });
890
+ socket.destroy();
891
+ return;
892
+ }
893
+ try {
894
+ const fd = socket._handle?.fd;
895
+ if (fd !== void 0) {
896
+ const creds = getPeerCredentials(fd);
897
+ if (!verifyPeerIsCurrentUser(creds)) {
898
+ writeFramed(socket, { type: "auth_fail", reason: "UID mismatch" });
899
+ socket.destroy();
900
+ return;
901
+ }
902
+ connId = randomUUID();
903
+ authenticated = true;
904
+ clearTimeout(authTimer);
905
+ const conn = {
906
+ id: connId,
907
+ uid: creds.uid,
908
+ pid: creds.pid,
909
+ send: (message) => {
910
+ if (!socket.destroyed) writeFramed(socket, message);
911
+ },
912
+ onMessage: (handler) => messageHandlers.push(handler),
913
+ close: () => socket.destroy()
914
+ };
915
+ connections.set(connId, conn);
916
+ writeFramed(socket, { type: "auth_ok" });
917
+ logger2.info("Bridge authenticated", { bridge: connId, uid: creds.uid, pid: creds.pid });
918
+ onConnection(conn);
919
+ } else {
920
+ writeFramed(socket, { type: "auth_fail", reason: "Cannot verify peer credentials" });
921
+ socket.destroy();
922
+ }
923
+ } catch (err) {
924
+ logger2.error("Peer credential check failed", { error: String(err) });
925
+ writeFramed(socket, { type: "auth_fail", reason: "Credential check failed" });
926
+ socket.destroy();
927
+ }
928
+ return;
929
+ }
930
+ for (const handler of messageHandlers) {
931
+ handler(msg);
932
+ }
933
+ });
934
+ socket.on("data", parser);
935
+ socket.on("close", () => {
936
+ clearTimeout(authTimer);
937
+ if (connId) {
938
+ connections.delete(connId);
939
+ logger2.info("Bridge disconnected", { bridge: connId });
940
+ }
941
+ });
942
+ socket.on("error", (err) => {
943
+ logger2.error("Socket error", { bridge: connId, error: String(err) });
944
+ });
945
+ }
946
+ return {
947
+ async listen() {
948
+ try {
949
+ unlinkSync(socketPath);
950
+ } catch {
951
+ }
952
+ server = createServer(handleSocket);
953
+ return new Promise((resolve, reject) => {
954
+ server.on("error", reject);
955
+ server.listen(socketPath, () => {
956
+ chmodSync2(socketPath, 384);
957
+ logger2.info("Socket server listening", { path: socketPath });
958
+ resolve();
959
+ });
960
+ });
961
+ },
962
+ async close() {
963
+ for (const conn of connections.values()) {
964
+ conn.send({ type: "shutdown", reason: "daemon stopping" });
965
+ conn.close();
966
+ }
967
+ connections.clear();
968
+ return new Promise((resolve) => {
969
+ if (!server) {
970
+ resolve();
971
+ return;
972
+ }
973
+ server.close(() => {
974
+ try {
975
+ unlinkSync(socketPath);
976
+ } catch {
977
+ }
978
+ resolve();
979
+ });
980
+ });
981
+ },
982
+ getConnections() {
983
+ return Array.from(connections.values());
984
+ }
985
+ };
986
+ }
987
+
988
+ // src/proxy/mcp-client.ts
989
+ import { Client } from "@modelcontextprotocol/sdk/client";
990
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
991
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
992
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
993
+ async function createUpstreamClient(name, config, logger2, options) {
994
+ let status = "disconnected";
995
+ const client = new Client({
996
+ name: `mcp-guard-upstream-${name}`,
997
+ version: "0.1.0"
998
+ });
999
+ const upstream = {
1000
+ name,
1001
+ client,
1002
+ get status() {
1003
+ return status;
1004
+ },
1005
+ async connect() {
1006
+ status = "connecting";
1007
+ try {
1008
+ let transport;
1009
+ if (config.transport === "sse" || config.transport === "streamable-http") {
1010
+ if (!config.url) {
1011
+ throw new Error(`Server '${name}' has transport '${config.transport}' but no url specified`);
1012
+ }
1013
+ const url = new URL(config.url);
1014
+ const headers = {};
1015
+ if (options?.authToken) {
1016
+ headers["Authorization"] = `Bearer ${options.authToken}`;
1017
+ }
1018
+ logger2.info("Connecting to upstream server", { server: name, transport: config.transport, url: config.url });
1019
+ if (config.transport === "sse") {
1020
+ transport = new SSEClientTransport(url, {
1021
+ requestInit: { headers }
1022
+ });
1023
+ } else {
1024
+ transport = new StreamableHTTPClientTransport(url, {
1025
+ requestInit: { headers }
1026
+ });
1027
+ }
1028
+ transport.onclose = () => {
1029
+ status = "disconnected";
1030
+ logger2.info("Upstream server disconnected", { server: name });
1031
+ };
1032
+ transport.onerror = (err) => {
1033
+ status = "error";
1034
+ logger2.error("Upstream server error", { server: name, error: String(err) });
1035
+ };
1036
+ } else {
1037
+ const command = config.command;
1038
+ if (!command) {
1039
+ throw new Error(`Server '${name}' has transport 'stdio' but no command specified`);
1040
+ }
1041
+ logger2.info("Connecting to upstream server", { server: name, command });
1042
+ transport = new StdioClientTransport({
1043
+ command,
1044
+ args: config.args,
1045
+ env: { ...process.env, ...config.env }
1046
+ });
1047
+ transport.onclose = () => {
1048
+ status = "disconnected";
1049
+ logger2.info("Upstream server disconnected", { server: name });
1050
+ };
1051
+ transport.onerror = (err) => {
1052
+ status = "error";
1053
+ logger2.error("Upstream server error", { server: name, error: String(err) });
1054
+ };
1055
+ }
1056
+ await client.connect(transport);
1057
+ status = "connected";
1058
+ logger2.info("Connected to upstream server", { server: name });
1059
+ } catch (err) {
1060
+ status = "error";
1061
+ logger2.error("Failed to connect to upstream server", {
1062
+ server: name,
1063
+ error: String(err)
1064
+ });
1065
+ throw err;
1066
+ }
1067
+ },
1068
+ async disconnect() {
1069
+ try {
1070
+ await client.close();
1071
+ } catch {
1072
+ }
1073
+ status = "disconnected";
1074
+ logger2.info("Disconnected from upstream server", { server: name });
1075
+ }
1076
+ };
1077
+ return upstream;
1078
+ }
1079
+
1080
+ // src/daemon/server-manager.ts
1081
+ function createServerManager(config, logger2) {
1082
+ const clients = /* @__PURE__ */ new Map();
1083
+ return {
1084
+ async startAll() {
1085
+ const entries = Object.entries(config.servers);
1086
+ logger2.info("Starting upstream servers", { count: entries.length });
1087
+ const results = await Promise.allSettled(
1088
+ entries.map(async ([name, serverConfig]) => {
1089
+ const client = await createUpstreamClient(name, serverConfig, logger2, {
1090
+ authToken: serverConfig.upstream_auth_token
1091
+ });
1092
+ clients.set(name, client);
1093
+ await client.connect();
1094
+ })
1095
+ );
1096
+ for (let i = 0; i < results.length; i++) {
1097
+ const result = results[i];
1098
+ if (result.status === "rejected") {
1099
+ logger2.error("Failed to start server", {
1100
+ server: entries[i][0],
1101
+ error: String(result.reason)
1102
+ });
1103
+ }
1104
+ }
1105
+ },
1106
+ async stopAll() {
1107
+ logger2.info("Stopping all upstream servers", { count: clients.size });
1108
+ const promises = Array.from(clients.values()).map((c) => c.disconnect());
1109
+ await Promise.allSettled(promises);
1110
+ clients.clear();
1111
+ },
1112
+ getClient(serverName) {
1113
+ return clients.get(serverName);
1114
+ },
1115
+ getStatus() {
1116
+ const status = /* @__PURE__ */ new Map();
1117
+ for (const [name, client] of clients) {
1118
+ status.set(name, client.status);
1119
+ }
1120
+ return status;
1121
+ }
1122
+ };
1123
+ }
1124
+
1125
+ // src/proxy/mcp-server.ts
1126
+ function createProxyServer(upstreamClients, logger2) {
1127
+ async function handleMessage(message, serverName) {
1128
+ const upstream = upstreamClients.get(serverName);
1129
+ if (!upstream) {
1130
+ return {
1131
+ jsonrpc: "2.0",
1132
+ id: message.id,
1133
+ error: { code: -32600, message: `Unknown server: ${serverName}` }
1134
+ };
1135
+ }
1136
+ if (upstream.status !== "connected") {
1137
+ return {
1138
+ jsonrpc: "2.0",
1139
+ id: message.id,
1140
+ error: { code: -32603, message: `Server '${serverName}' is not connected` }
1141
+ };
1142
+ }
1143
+ const { method, params, id } = message;
1144
+ if (!method) {
1145
+ return {
1146
+ jsonrpc: "2.0",
1147
+ id,
1148
+ error: { code: -32600, message: "Missing method in request" }
1149
+ };
1150
+ }
1151
+ try {
1152
+ const result = await routeMethod(upstream, method, params);
1153
+ return { jsonrpc: "2.0", id, result };
1154
+ } catch (err) {
1155
+ logger2.error("Upstream request failed", {
1156
+ server: serverName,
1157
+ method,
1158
+ error: String(err)
1159
+ });
1160
+ return {
1161
+ jsonrpc: "2.0",
1162
+ id,
1163
+ error: { code: -32603, message: `Upstream error: ${String(err)}` }
1164
+ };
1165
+ }
1166
+ }
1167
+ return { handleMessage };
1168
+ }
1169
+ async function routeMethod(upstream, method, params) {
1170
+ const client = upstream.client;
1171
+ switch (method) {
1172
+ case "tools/list":
1173
+ return await client.listTools(params);
1174
+ case "tools/call":
1175
+ return await client.callTool(params);
1176
+ case "resources/list":
1177
+ return await client.listResources(params);
1178
+ case "resources/read":
1179
+ return await client.readResource(params);
1180
+ case "resources/list_templates":
1181
+ return await client.listResourceTemplates(
1182
+ params
1183
+ );
1184
+ case "prompts/list":
1185
+ return await client.listPrompts(params);
1186
+ case "prompts/get":
1187
+ return await client.getPrompt(params);
1188
+ default:
1189
+ if (method.startsWith("notifications/")) {
1190
+ await client.notification({ method, params });
1191
+ return void 0;
1192
+ }
1193
+ throw new Error(`Method not found: ${method}`);
1194
+ }
1195
+ }
1196
+
1197
+ // src/daemon/shutdown.ts
1198
+ import { unlink } from "fs/promises";
1199
+ function registerShutdownHandlers(context) {
1200
+ let shutdownPromise;
1201
+ async function shutdown(signal) {
1202
+ const { socketServer, serverManager, db, pidFile, logger: logger2, onBeforeShutdown } = context;
1203
+ logger2.info("Shutdown initiated", { signal });
1204
+ try {
1205
+ if (onBeforeShutdown) {
1206
+ try {
1207
+ await onBeforeShutdown();
1208
+ } catch (err) {
1209
+ logger2.warn("Pre-shutdown hook failed", { error: String(err) });
1210
+ }
1211
+ }
1212
+ await socketServer.close();
1213
+ logger2.info("Socket server closed");
1214
+ await serverManager.stopAll();
1215
+ logger2.info("Upstream servers disconnected");
1216
+ try {
1217
+ checkpointWal(db);
1218
+ db.close();
1219
+ logger2.info("Database closed");
1220
+ } catch {
1221
+ }
1222
+ try {
1223
+ await unlink(pidFile);
1224
+ } catch {
1225
+ }
1226
+ logger2.info("Shutdown complete");
1227
+ } catch (err) {
1228
+ logger2.error("Shutdown error", { error: String(err) });
1229
+ throw err;
1230
+ }
1231
+ }
1232
+ function triggerShutdown(signal) {
1233
+ if (!shutdownPromise) {
1234
+ shutdownPromise = shutdown(signal);
1235
+ }
1236
+ return shutdownPromise;
1237
+ }
1238
+ function signalHandler(signal) {
1239
+ const timer = setTimeout(() => {
1240
+ context.logger.warn("Shutdown timeout exceeded \u2014 forcing exit", { signal });
1241
+ process.exit(1);
1242
+ }, context.timeout);
1243
+ triggerShutdown(signal).then(() => {
1244
+ clearTimeout(timer);
1245
+ process.exit(0);
1246
+ }).catch(() => {
1247
+ clearTimeout(timer);
1248
+ process.exit(1);
1249
+ });
1250
+ }
1251
+ process.once("SIGTERM", () => signalHandler("SIGTERM"));
1252
+ process.once("SIGINT", () => signalHandler("SIGINT"));
1253
+ return {
1254
+ shutdown(signal) {
1255
+ return triggerShutdown(signal ?? "programmatic");
1256
+ }
1257
+ };
1258
+ }
1259
+
1260
+ // src/logger.ts
1261
+ var LEVEL_ORDER = {
1262
+ debug: 0,
1263
+ info: 1,
1264
+ warn: 2,
1265
+ error: 3
1266
+ };
1267
+ function getLogLevel() {
1268
+ const env = process.env["MCP_GUARD_LOG_LEVEL"]?.toLowerCase();
1269
+ if (env && env in LEVEL_ORDER) {
1270
+ return env;
1271
+ }
1272
+ return "info";
1273
+ }
1274
+ function createLogger(defaultContext) {
1275
+ const minLevel = getLogLevel();
1276
+ function log(level, message, extra) {
1277
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[minLevel]) {
1278
+ return;
1279
+ }
1280
+ const entry = {
1281
+ level,
1282
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1283
+ ...defaultContext,
1284
+ message,
1285
+ ...extra
1286
+ };
1287
+ process.stderr.write(JSON.stringify(entry) + "\n");
1288
+ }
1289
+ return {
1290
+ debug: (message, extra) => log("debug", message, extra),
1291
+ info: (message, extra) => log("info", message, extra),
1292
+ warn: (message, extra) => log("warn", message, extra),
1293
+ error: (message, extra) => log("error", message, extra)
1294
+ };
1295
+ }
1296
+
1297
+ // src/interceptors/pipeline.ts
1298
+ function createPipeline(options) {
1299
+ const { interceptors, timeout, logger: logger2 } = options;
1300
+ async function execute(ctx) {
1301
+ const decisions = [];
1302
+ let currentParams = ctx.message.params;
1303
+ for (const interceptor of interceptors) {
1304
+ const start = Date.now();
1305
+ let decision;
1306
+ const timer = createTimeout(timeout, interceptor.name);
1307
+ try {
1308
+ decision = await Promise.race([
1309
+ interceptor.execute(ctx),
1310
+ timer.promise
1311
+ ]);
1312
+ } catch (err) {
1313
+ timer.clear();
1314
+ const durationMs2 = Date.now() - start;
1315
+ const reason = String(err);
1316
+ logger2.error("Interceptor failed", {
1317
+ interceptor: interceptor.name,
1318
+ error: reason,
1319
+ durationMs: durationMs2
1320
+ });
1321
+ const blockDecision = {
1322
+ action: "BLOCK",
1323
+ reason: `Interceptor '${interceptor.name}' failed: ${reason}`
1324
+ };
1325
+ decisions.push({
1326
+ interceptor: interceptor.name,
1327
+ decision: blockDecision,
1328
+ durationMs: durationMs2
1329
+ });
1330
+ return {
1331
+ allowed: false,
1332
+ decisions,
1333
+ finalParams: currentParams
1334
+ };
1335
+ }
1336
+ timer.clear();
1337
+ const durationMs = Date.now() - start;
1338
+ if (decision.action === "MODIFY") {
1339
+ const mutatedProtected = "method" in decision.params && decision.params["method"] !== ctx.message.method || "name" in decision.params && decision.params["name"] !== currentParams?.["name"] || "uri" in decision.params && decision.params["uri"] !== currentParams?.["uri"];
1340
+ if (mutatedProtected) {
1341
+ logger2.error("Interceptor attempted to modify protected fields", {
1342
+ interceptor: interceptor.name
1343
+ });
1344
+ const blockDecision = {
1345
+ action: "BLOCK",
1346
+ reason: `Interceptor '${interceptor.name}' attempted to modify protected fields`
1347
+ };
1348
+ decisions.push({
1349
+ interceptor: interceptor.name,
1350
+ decision: blockDecision,
1351
+ durationMs
1352
+ });
1353
+ return {
1354
+ allowed: false,
1355
+ decisions,
1356
+ finalParams: currentParams
1357
+ };
1358
+ }
1359
+ currentParams = { ...currentParams, ...decision.params };
1360
+ ctx = { ...ctx, message: { ...ctx.message, params: currentParams } };
1361
+ const authMode = decision.metadata?.["authMode"];
1362
+ if (authMode && decision.metadata?.["roles"]) {
1363
+ const roles = decision.metadata["roles"];
1364
+ if (authMode === "oauth") {
1365
+ ctx = {
1366
+ ...ctx,
1367
+ identity: {
1368
+ ...ctx.identity,
1369
+ roles,
1370
+ username: decision.metadata["oauthSubject"] ?? ctx.identity.username,
1371
+ authMode: "oauth",
1372
+ oauthSubject: decision.metadata["oauthSubject"]
1373
+ }
1374
+ };
1375
+ } else if (authMode === "api_key") {
1376
+ ctx = {
1377
+ ...ctx,
1378
+ identity: {
1379
+ ...ctx.identity,
1380
+ roles,
1381
+ authMode: "api_key"
1382
+ }
1383
+ };
1384
+ }
1385
+ }
1386
+ }
1387
+ decisions.push({
1388
+ interceptor: interceptor.name,
1389
+ decision,
1390
+ durationMs
1391
+ });
1392
+ if (decision.action === "BLOCK") {
1393
+ return {
1394
+ allowed: false,
1395
+ decisions,
1396
+ finalParams: currentParams
1397
+ };
1398
+ }
1399
+ }
1400
+ return {
1401
+ allowed: true,
1402
+ decisions,
1403
+ finalParams: currentParams,
1404
+ resolvedIdentity: ctx.identity
1405
+ };
1406
+ }
1407
+ return { execute };
1408
+ }
1409
+ function createTimeout(ms, name) {
1410
+ let timerId;
1411
+ const promise = new Promise((_, reject) => {
1412
+ timerId = setTimeout(() => reject(new Error(`Interceptor '${name}' timed out after ${ms}ms`)), ms);
1413
+ });
1414
+ promise.catch(() => {
1415
+ });
1416
+ return {
1417
+ promise,
1418
+ clear: () => clearTimeout(timerId)
1419
+ };
1420
+ }
1421
+
1422
+ // src/interceptors/auth.ts
1423
+ import { timingSafeEqual as timingSafeEqual2, createHash as createHash2 } from "crypto";
1424
+
1425
+ // src/identity/token-validator.ts
1426
+ init_errors();
1427
+ import { createRemoteJWKSet, jwtVerify } from "jose";
1428
+ async function discoverJwksUri(issuer) {
1429
+ const discoveryUrl = `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
1430
+ try {
1431
+ const response = await fetch(discoveryUrl);
1432
+ if (response.ok) {
1433
+ const metadata = await response.json();
1434
+ if (metadata.jwks_uri && typeof metadata.jwks_uri === "string") {
1435
+ return metadata.jwks_uri;
1436
+ }
1437
+ }
1438
+ } catch {
1439
+ }
1440
+ return `${issuer.replace(/\/$/, "")}/.well-known/jwks.json`;
1441
+ }
1442
+ async function createTokenValidator(config) {
1443
+ const jwksUri = config.jwks_uri ?? await discoverJwksUri(config.issuer);
1444
+ const jwks = createRemoteJWKSet(new URL(jwksUri));
1445
+ return {
1446
+ async validate(token) {
1447
+ try {
1448
+ const audience = config.audience ?? config.client_id;
1449
+ const { payload } = await jwtVerify(token, jwks, {
1450
+ issuer: config.issuer,
1451
+ audience,
1452
+ clockTolerance: config.clock_tolerance_seconds
1453
+ });
1454
+ if (!payload.sub) {
1455
+ throw new OAuthError('JWT missing required "sub" claim');
1456
+ }
1457
+ return {
1458
+ valid: true,
1459
+ claims: payload,
1460
+ subject: payload.sub
1461
+ };
1462
+ } catch (err) {
1463
+ if (err instanceof OAuthError) {
1464
+ throw err;
1465
+ }
1466
+ throw new OAuthError(`JWT validation failed: ${err instanceof Error ? err.message : String(err)}`);
1467
+ }
1468
+ }
1469
+ };
1470
+ }
1471
+
1472
+ // src/identity/roles.ts
1473
+ import { userInfo } from "os";
1474
+ function resolveOAuthIdentity(claims, config) {
1475
+ const sub = typeof claims["sub"] === "string" ? claims["sub"] : "oauth-user";
1476
+ const oauthConfig = config.auth.oauth;
1477
+ if (!oauthConfig) {
1478
+ return { uid: -1, username: sub, roles: [], authMode: "oauth", oauthSubject: sub };
1479
+ }
1480
+ const claimName = oauthConfig.claims_to_roles.claim_name;
1481
+ const claimValue = claims[claimName];
1482
+ const mapping = oauthConfig.claims_to_roles.mapping;
1483
+ const claimValues = Array.isArray(claimValue) ? claimValue.filter((v) => typeof v === "string") : typeof claimValue === "string" ? [claimValue] : [];
1484
+ const roles = [];
1485
+ for (const value of claimValues) {
1486
+ const mapped = mapping[value];
1487
+ if (mapped) {
1488
+ roles.push(...mapped);
1489
+ }
1490
+ }
1491
+ const uniqueRoles = [...new Set(roles)];
1492
+ return {
1493
+ uid: -1,
1494
+ username: sub,
1495
+ roles: uniqueRoles,
1496
+ authMode: "oauth",
1497
+ oauthSubject: sub
1498
+ };
1499
+ }
1500
+ function resolveIdentity(uid, pid, config) {
1501
+ const username = resolveUsername(uid);
1502
+ const roles = resolveRoles(username, config);
1503
+ return { uid, pid, username, roles };
1504
+ }
1505
+ function resolveUsername(uid) {
1506
+ try {
1507
+ const info = userInfo();
1508
+ if (info.uid === uid) {
1509
+ return info.username;
1510
+ }
1511
+ } catch {
1512
+ }
1513
+ return `uid:${uid}`;
1514
+ }
1515
+ function resolveRoles(username, config) {
1516
+ const matchedRoles = [];
1517
+ for (const roleName of Object.keys(config.auth.roles)) {
1518
+ if (roleName === username) {
1519
+ matchedRoles.push(roleName);
1520
+ }
1521
+ }
1522
+ return matchedRoles.length > 0 ? matchedRoles : ["default"];
1523
+ }
1524
+
1525
+ // src/interceptors/auth.ts
1526
+ init_errors();
1527
+ function createAuthInterceptor(config) {
1528
+ const hashedKeys = /* @__PURE__ */ new Map();
1529
+ for (const [key, keyConfig] of Object.entries(config.auth.api_keys)) {
1530
+ const hash = createHash2("sha256").update(key).digest("hex");
1531
+ hashedKeys.set(hash, keyConfig);
1532
+ }
1533
+ let tokenValidator;
1534
+ let tokenValidatorPromise;
1535
+ if (config.auth.mode === "oauth" && config.auth.oauth) {
1536
+ tokenValidatorPromise = createTokenValidator(config.auth.oauth);
1537
+ }
1538
+ return {
1539
+ name: "auth",
1540
+ async execute(ctx) {
1541
+ if (config.auth.mode === "os") {
1542
+ return validateOsIdentity(ctx);
1543
+ }
1544
+ if (config.auth.mode === "oauth") {
1545
+ if (!tokenValidatorPromise) {
1546
+ return { action: "BLOCK", reason: "OAuth configured but token validator not initialized", code: "OAUTH_INTERNAL" };
1547
+ }
1548
+ if (!tokenValidator) {
1549
+ tokenValidator = await tokenValidatorPromise;
1550
+ }
1551
+ return validateOAuthToken(ctx, tokenValidator, config);
1552
+ }
1553
+ return validateApiKey(ctx, hashedKeys);
1554
+ }
1555
+ };
1556
+ }
1557
+ function validateOsIdentity(ctx) {
1558
+ if (!ctx.identity) {
1559
+ return { action: "BLOCK", reason: "No identity resolved", code: "AUTH_MISSING" };
1560
+ }
1561
+ if (!ctx.identity.username) {
1562
+ return { action: "BLOCK", reason: "Identity has no username", code: "AUTH_INVALID" };
1563
+ }
1564
+ if (!ctx.identity.roles || ctx.identity.roles.length === 0) {
1565
+ return { action: "BLOCK", reason: "Identity has no roles", code: "AUTH_NO_ROLES" };
1566
+ }
1567
+ return { action: "PASS" };
1568
+ }
1569
+ function validateApiKey(ctx, hashedKeys) {
1570
+ const apiKey = ctx.message.params?.["_api_key"];
1571
+ if (!apiKey) {
1572
+ return { action: "BLOCK", reason: "API key required but not provided", code: "AUTH_MISSING" };
1573
+ }
1574
+ const presentedHash = createHash2("sha256").update(apiKey).digest();
1575
+ let matchedRoles;
1576
+ for (const [storedHash, keyConfig] of hashedKeys) {
1577
+ const storedBuf = Buffer.from(storedHash, "hex");
1578
+ if (presentedHash.length === storedBuf.length && timingSafeEqual2(presentedHash, storedBuf)) {
1579
+ matchedRoles = keyConfig.roles;
1580
+ break;
1581
+ }
1582
+ }
1583
+ if (!matchedRoles) {
1584
+ return { action: "BLOCK", reason: "Invalid API key", code: "AUTH_INVALID" };
1585
+ }
1586
+ const { _api_key: _, ...cleanParams } = ctx.message.params ?? {};
1587
+ return {
1588
+ action: "MODIFY",
1589
+ params: cleanParams,
1590
+ metadata: {
1591
+ authMode: "api_key",
1592
+ roles: matchedRoles
1593
+ }
1594
+ };
1595
+ }
1596
+ async function validateOAuthToken(ctx, tokenValidator, config) {
1597
+ const bearerToken = ctx.message.params?.["_bearer_token"];
1598
+ if (!bearerToken) {
1599
+ return { action: "BLOCK", reason: "OAuth token required but not provided", code: "OAUTH_TOKEN_MISSING" };
1600
+ }
1601
+ try {
1602
+ const result = await tokenValidator.validate(bearerToken);
1603
+ const identity = resolveOAuthIdentity(result.claims, config);
1604
+ if (!identity.roles || identity.roles.length === 0) {
1605
+ return { action: "BLOCK", reason: "OAuth token has no mapped roles", code: "OAUTH_NO_ROLES" };
1606
+ }
1607
+ const { _bearer_token: _, ...cleanParams } = ctx.message.params ?? {};
1608
+ return {
1609
+ action: "MODIFY",
1610
+ params: cleanParams,
1611
+ metadata: {
1612
+ oauthSubject: result.subject,
1613
+ roles: identity.roles,
1614
+ authMode: "oauth"
1615
+ }
1616
+ };
1617
+ } catch (err) {
1618
+ const safeReason = err instanceof OAuthError ? sanitizeOAuthError(err.message) : "OAuth token validation failed";
1619
+ return { action: "BLOCK", reason: safeReason, code: "OAUTH_INVALID_TOKEN" };
1620
+ }
1621
+ }
1622
+ function sanitizeOAuthError(message) {
1623
+ return message.replace(/eyJ[A-Za-z0-9_-]{20,}/g, "[redacted]").replace(/[A-Za-z0-9_-]{40,}/g, "[redacted]");
1624
+ }
1625
+
1626
+ // src/interceptors/effective-policy.ts
1627
+ function resolveEffectivePermissions(serverPermissions, identity, config) {
1628
+ const allowedToolsLists = [];
1629
+ const allowedResourcesLists = [];
1630
+ if (serverPermissions.allowed_tools) {
1631
+ allowedToolsLists.push(serverPermissions.allowed_tools);
1632
+ }
1633
+ if (serverPermissions.allowed_resources) {
1634
+ allowedResourcesLists.push(serverPermissions.allowed_resources);
1635
+ }
1636
+ let deniedTools = [...serverPermissions.denied_tools];
1637
+ let deniedResources = [...serverPermissions.denied_resources];
1638
+ for (const role of identity.roles) {
1639
+ const roleConfig = config.auth.roles[role];
1640
+ if (!roleConfig) continue;
1641
+ const rolePerms = roleConfig.permissions;
1642
+ deniedTools = [.../* @__PURE__ */ new Set([...deniedTools, ...rolePerms.denied_tools])];
1643
+ deniedResources = [.../* @__PURE__ */ new Set([...deniedResources, ...rolePerms.denied_resources])];
1644
+ if (rolePerms.allowed_tools) {
1645
+ allowedToolsLists.push(rolePerms.allowed_tools);
1646
+ }
1647
+ if (rolePerms.allowed_resources) {
1648
+ allowedResourcesLists.push(rolePerms.allowed_resources);
1649
+ }
1650
+ }
1651
+ return {
1652
+ allowed_tools_lists: allowedToolsLists,
1653
+ denied_tools: deniedTools,
1654
+ allowed_resources_lists: allowedResourcesLists,
1655
+ denied_resources: deniedResources
1656
+ };
1657
+ }
1658
+ function resolveEffectiveRateLimit(serverRateLimit, identity, config) {
1659
+ const effective = {
1660
+ requests_per_minute: serverRateLimit.requests_per_minute,
1661
+ requests_per_hour: serverRateLimit.requests_per_hour,
1662
+ tool_limits: { ...serverRateLimit.tool_limits }
1663
+ };
1664
+ for (const role of identity.roles) {
1665
+ const roleConfig = config.auth.roles[role];
1666
+ if (!roleConfig) continue;
1667
+ const roleLimit = roleConfig.rate_limit;
1668
+ if (roleLimit.requests_per_minute) {
1669
+ effective.requests_per_minute = effective.requests_per_minute ? Math.min(effective.requests_per_minute, roleLimit.requests_per_minute) : roleLimit.requests_per_minute;
1670
+ }
1671
+ if (roleLimit.requests_per_hour) {
1672
+ effective.requests_per_hour = effective.requests_per_hour ? Math.min(effective.requests_per_hour, roleLimit.requests_per_hour) : roleLimit.requests_per_hour;
1673
+ }
1674
+ for (const [toolName, toolLimit] of Object.entries(roleLimit.tool_limits)) {
1675
+ const existing = effective.tool_limits[toolName];
1676
+ if (!existing) {
1677
+ effective.tool_limits[toolName] = { ...toolLimit };
1678
+ } else if (toolLimit.requests_per_minute) {
1679
+ existing.requests_per_minute = existing.requests_per_minute ? Math.min(existing.requests_per_minute, toolLimit.requests_per_minute) : toolLimit.requests_per_minute;
1680
+ }
1681
+ }
1682
+ }
1683
+ return effective;
1684
+ }
1685
+
1686
+ // src/interceptors/rate-limit.ts
1687
+ function createRateLimitInterceptor(store, config) {
1688
+ return {
1689
+ name: "rate-limit",
1690
+ async execute(ctx) {
1691
+ const serverConfig = config.servers[ctx.server];
1692
+ if (!serverConfig) {
1693
+ return { action: "PASS" };
1694
+ }
1695
+ const rateConfig = resolveEffectiveRateLimit(
1696
+ serverConfig.policy.rate_limit,
1697
+ ctx.identity,
1698
+ config
1699
+ );
1700
+ if (rateConfig.requests_per_minute) {
1701
+ const key = `server:${ctx.server}:${ctx.identity.username}:rpm`;
1702
+ const allowed = store.tryConsume(key, {
1703
+ maxTokens: rateConfig.requests_per_minute,
1704
+ refillRate: rateConfig.requests_per_minute / 60
1705
+ // tokens per second
1706
+ });
1707
+ if (!allowed) {
1708
+ return {
1709
+ action: "BLOCK",
1710
+ reason: "Rate limit exceeded (requests per minute)",
1711
+ code: "RATE_LIMITED"
1712
+ };
1713
+ }
1714
+ }
1715
+ if (rateConfig.requests_per_hour) {
1716
+ const key = `server:${ctx.server}:${ctx.identity.username}:rph`;
1717
+ const allowed = store.tryConsume(key, {
1718
+ maxTokens: rateConfig.requests_per_hour,
1719
+ refillRate: rateConfig.requests_per_hour / 3600
1720
+ // tokens per second
1721
+ });
1722
+ if (!allowed) {
1723
+ return {
1724
+ action: "BLOCK",
1725
+ reason: "Rate limit exceeded (requests per hour)",
1726
+ code: "RATE_LIMITED"
1727
+ };
1728
+ }
1729
+ }
1730
+ if (ctx.message.method === "tools/call") {
1731
+ const toolName = ctx.message.params?.["name"];
1732
+ if (toolName && rateConfig.tool_limits[toolName]) {
1733
+ const toolLimit = rateConfig.tool_limits[toolName];
1734
+ if (toolLimit.requests_per_minute) {
1735
+ const key = `tool:${ctx.server}:${ctx.identity.username}:${toolName}:rpm`;
1736
+ const allowed = store.tryConsume(key, {
1737
+ maxTokens: toolLimit.requests_per_minute,
1738
+ refillRate: toolLimit.requests_per_minute / 60
1739
+ });
1740
+ if (!allowed) {
1741
+ return {
1742
+ action: "BLOCK",
1743
+ reason: `Rate limit exceeded for tool '${toolName}'`,
1744
+ code: "RATE_LIMITED"
1745
+ };
1746
+ }
1747
+ }
1748
+ }
1749
+ }
1750
+ return { action: "PASS" };
1751
+ }
1752
+ };
1753
+ }
1754
+
1755
+ // src/interceptors/permissions.ts
1756
+ var MAX_MATCH_INPUT_LENGTH = 1024;
1757
+ var patternCache = /* @__PURE__ */ new Map();
1758
+ function createPermissionInterceptor(config) {
1759
+ for (const serverConfig of Object.values(config.servers)) {
1760
+ const allPatterns = [
1761
+ ...serverConfig.policy.permissions.denied_tools,
1762
+ ...serverConfig.policy.permissions.allowed_tools ?? [],
1763
+ ...serverConfig.policy.permissions.denied_resources,
1764
+ ...serverConfig.policy.permissions.allowed_resources ?? []
1765
+ ];
1766
+ for (const pattern of allPatterns) {
1767
+ if (!patternCache.has(pattern)) {
1768
+ const compiled = compilePattern(pattern);
1769
+ if (compiled) patternCache.set(pattern, compiled);
1770
+ }
1771
+ }
1772
+ }
1773
+ return {
1774
+ name: "permissions",
1775
+ async execute(ctx) {
1776
+ const serverConfig = config.servers[ctx.server];
1777
+ if (!serverConfig) {
1778
+ return { action: "PASS" };
1779
+ }
1780
+ const permissions = resolveEffectivePermissions(
1781
+ serverConfig.policy.permissions,
1782
+ ctx.identity,
1783
+ config
1784
+ );
1785
+ if (ctx.message.method === "tools/call") {
1786
+ const toolName = ctx.message.params?.["name"];
1787
+ if (!toolName) {
1788
+ return { action: "BLOCK", reason: "tools/call missing required tool name", code: "MALFORMED_REQUEST" };
1789
+ }
1790
+ if (matchesAny(toolName, permissions.denied_tools)) {
1791
+ return {
1792
+ action: "BLOCK",
1793
+ reason: `Tool '${toolName}' is denied`,
1794
+ code: "PERMISSION_DENIED"
1795
+ };
1796
+ }
1797
+ for (const allowList of permissions.allowed_tools_lists) {
1798
+ if (!matchesAny(toolName, allowList)) {
1799
+ return {
1800
+ action: "BLOCK",
1801
+ reason: `Tool '${toolName}' is not in allowed list`,
1802
+ code: "PERMISSION_DENIED"
1803
+ };
1804
+ }
1805
+ }
1806
+ return { action: "PASS" };
1807
+ }
1808
+ if (ctx.message.method === "resources/read") {
1809
+ const uri = ctx.message.params?.["uri"];
1810
+ if (!uri) {
1811
+ return { action: "BLOCK", reason: "resources/read missing required URI", code: "MALFORMED_REQUEST" };
1812
+ }
1813
+ if (matchesAny(uri, permissions.denied_resources)) {
1814
+ return {
1815
+ action: "BLOCK",
1816
+ reason: `Resource '${uri}' is denied`,
1817
+ code: "PERMISSION_DENIED"
1818
+ };
1819
+ }
1820
+ for (const allowList of permissions.allowed_resources_lists) {
1821
+ if (!matchesAny(uri, allowList)) {
1822
+ return {
1823
+ action: "BLOCK",
1824
+ reason: `Resource '${uri}' is not in allowed list`,
1825
+ code: "PERMISSION_DENIED"
1826
+ };
1827
+ }
1828
+ }
1829
+ return { action: "PASS" };
1830
+ }
1831
+ return { action: "PASS" };
1832
+ }
1833
+ };
1834
+ }
1835
+ function matchesAny(value, patterns) {
1836
+ return patterns.some((pattern) => matchesPattern(value, pattern));
1837
+ }
1838
+ function matchesPattern(value, pattern) {
1839
+ if (pattern.startsWith("^") || pattern.includes("*")) {
1840
+ if (value.length > MAX_MATCH_INPUT_LENGTH) {
1841
+ return false;
1842
+ }
1843
+ const compiled = patternCache.get(pattern) ?? compileAndCache(pattern);
1844
+ return compiled ? compiled.test(value) : false;
1845
+ }
1846
+ return value === pattern;
1847
+ }
1848
+ function compilePattern(pattern) {
1849
+ try {
1850
+ if (pattern.startsWith("^")) {
1851
+ return new RegExp(pattern);
1852
+ }
1853
+ if (pattern.includes("*")) {
1854
+ const regexStr = "^" + pattern.replace(/[.+?{}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$";
1855
+ return new RegExp(regexStr);
1856
+ }
1857
+ } catch {
1858
+ }
1859
+ return null;
1860
+ }
1861
+ function compileAndCache(pattern) {
1862
+ const compiled = compilePattern(pattern);
1863
+ if (compiled) patternCache.set(pattern, compiled);
1864
+ return compiled;
1865
+ }
1866
+
1867
+ // src/interceptors/sampling-guard.ts
1868
+ function createSamplingGuardInterceptor(config) {
1869
+ return {
1870
+ name: "sampling-guard",
1871
+ async execute(ctx) {
1872
+ if (ctx.message.method !== "sampling/createMessage") {
1873
+ return { action: "PASS" };
1874
+ }
1875
+ const serverConfig = config.servers[ctx.server];
1876
+ if (!serverConfig) {
1877
+ return {
1878
+ action: "BLOCK",
1879
+ reason: `Sampling is disabled for unknown server "${ctx.server}"`,
1880
+ code: "SAMPLING_DISABLED"
1881
+ };
1882
+ }
1883
+ if (!serverConfig.policy.sampling.enabled) {
1884
+ return {
1885
+ action: "BLOCK",
1886
+ reason: `Sampling is disabled for server "${ctx.server}"`,
1887
+ code: "SAMPLING_DISABLED"
1888
+ };
1889
+ }
1890
+ return { action: "PASS" };
1891
+ }
1892
+ };
1893
+ }
1894
+
1895
+ // src/pii/redactor.ts
1896
+ function redactString(content, matches) {
1897
+ if (matches.length === 0) return content;
1898
+ const sorted = [...matches].sort((a, b) => a.start - b.start);
1899
+ const nonOverlapping = [];
1900
+ let lastEnd = -1;
1901
+ for (const m of sorted) {
1902
+ if (m.start >= lastEnd) {
1903
+ nonOverlapping.push(m);
1904
+ lastEnd = m.end;
1905
+ }
1906
+ }
1907
+ let result = content;
1908
+ for (let i = nonOverlapping.length - 1; i >= 0; i--) {
1909
+ const m = nonOverlapping[i];
1910
+ const marker = `[REDACTED:${m.type}]`;
1911
+ result = result.slice(0, m.start) + marker + result.slice(m.end);
1912
+ }
1913
+ return result;
1914
+ }
1915
+ function scanAndRedact(value, detect, shouldRedact) {
1916
+ const allMatches = [];
1917
+ function walk(val, path) {
1918
+ if (typeof val === "string") {
1919
+ const matches = detect(val);
1920
+ for (const m of matches) {
1921
+ allMatches.push({ type: m.type, confidence: m.confidence, start: m.start, end: m.end, path });
1922
+ }
1923
+ if (shouldRedact && matches.length > 0) {
1924
+ return redactString(val, matches);
1925
+ }
1926
+ return val;
1927
+ }
1928
+ if (Array.isArray(val)) {
1929
+ return val.map((item, i) => walk(item, path ? `${path}[${i}]` : `[${i}]`));
1930
+ }
1931
+ if (val !== null && typeof val === "object") {
1932
+ const result = {};
1933
+ for (const [key, child] of Object.entries(val)) {
1934
+ result[key] = walk(child, path ? `${path}.${key}` : key);
1935
+ }
1936
+ return result;
1937
+ }
1938
+ return val;
1939
+ }
1940
+ const redacted = walk(value, "");
1941
+ return { matches: allMatches, redacted };
1942
+ }
1943
+
1944
+ // src/interceptors/pii-detect.ts
1945
+ var MAX_SCAN_BYTES = 1048576;
1946
+ var ACTION_SEVERITY = {
1947
+ warn: 0,
1948
+ redact: 1,
1949
+ block: 2
1950
+ };
1951
+ function createPiiInterceptor(registry, config) {
1952
+ const actionMap = { ...config.pii.actions };
1953
+ for (const [typeName, customType] of Object.entries(config.pii.custom_types)) {
1954
+ actionMap[typeName] = customType.actions;
1955
+ }
1956
+ return {
1957
+ name: "pii-detect",
1958
+ async execute(ctx) {
1959
+ if (!config.pii.enabled) {
1960
+ return { action: "PASS" };
1961
+ }
1962
+ const params = ctx.message.params;
1963
+ if (!params) {
1964
+ return { action: "PASS" };
1965
+ }
1966
+ const serialized = JSON.stringify(params);
1967
+ if (serialized.length > MAX_SCAN_BYTES) {
1968
+ return {
1969
+ action: "BLOCK",
1970
+ reason: "Content exceeds 1MB PII scan limit \u2014 blocked uninspected",
1971
+ code: "PII_CONTENT_TOO_LARGE"
1972
+ };
1973
+ }
1974
+ const direction = ctx.direction;
1975
+ const detectFn = (content) => registry.scan(content, { direction, server: ctx.server });
1976
+ let scanResult;
1977
+ try {
1978
+ scanResult = scanAndRedact(params, detectFn, false);
1979
+ } catch (err) {
1980
+ return {
1981
+ action: "BLOCK",
1982
+ reason: `PII detector error: ${String(err)}`,
1983
+ code: "PII_DETECTOR_ERROR"
1984
+ };
1985
+ }
1986
+ if (scanResult.matches.length === 0) {
1987
+ return { action: "PASS" };
1988
+ }
1989
+ let strictestAction = "warn";
1990
+ const detections = [];
1991
+ for (const match of scanResult.matches) {
1992
+ const typeActions = actionMap[match.type];
1993
+ const action = typeActions ? typeActions[direction] : direction === "request" ? "redact" : "warn";
1994
+ detections.push({ type: match.type, action });
1995
+ if (ACTION_SEVERITY[action] > ACTION_SEVERITY[strictestAction]) {
1996
+ strictestAction = action;
1997
+ }
1998
+ }
1999
+ const metadata = {
2000
+ piiDetections: detections
2001
+ };
2002
+ if (strictestAction === "block") {
2003
+ const detectedTypes = [...new Set(detections.map((d) => d.type))].join(", ");
2004
+ return {
2005
+ action: "BLOCK",
2006
+ reason: `PII detected (${detectedTypes}) \u2014 blocked by ${direction} policy`,
2007
+ code: "PII_BLOCKED",
2008
+ metadata
2009
+ };
2010
+ }
2011
+ if (strictestAction === "redact") {
2012
+ let redactResult;
2013
+ try {
2014
+ redactResult = scanAndRedact(params, detectFn, true);
2015
+ } catch (err) {
2016
+ return {
2017
+ action: "BLOCK",
2018
+ reason: `PII redaction error: ${String(err)}`,
2019
+ code: "PII_DETECTOR_ERROR"
2020
+ };
2021
+ }
2022
+ const redactedParams = { ...redactResult.redacted };
2023
+ delete redactedParams["name"];
2024
+ delete redactedParams["method"];
2025
+ delete redactedParams["uri"];
2026
+ return {
2027
+ action: "MODIFY",
2028
+ params: redactedParams,
2029
+ metadata
2030
+ };
2031
+ }
2032
+ return { action: "PASS", metadata };
2033
+ }
2034
+ };
2035
+ }
2036
+
2037
+ // src/pii/regex-detector.ts
2038
+ var MAX_CONTENT_LENGTH = 65536;
2039
+ function luhnCheck(digits) {
2040
+ const cleaned = digits.replace(/\D/g, "");
2041
+ if (cleaned.length === 0) return false;
2042
+ let sum = 0;
2043
+ let alternate = false;
2044
+ for (let i = cleaned.length - 1; i >= 0; i--) {
2045
+ let n = parseInt(cleaned[i], 10);
2046
+ if (alternate) {
2047
+ n *= 2;
2048
+ if (n > 9) n -= 9;
2049
+ }
2050
+ sum += n;
2051
+ alternate = !alternate;
2052
+ }
2053
+ return sum % 10 === 0;
2054
+ }
2055
+ var PATTERNS = [
2056
+ {
2057
+ type: "email",
2058
+ regex: /\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/g,
2059
+ confidence: 0.9
2060
+ },
2061
+ {
2062
+ type: "phone",
2063
+ regex: /(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
2064
+ confidence: 0.8,
2065
+ validate: (match) => {
2066
+ const digits = match.replace(/\D/g, "");
2067
+ return digits.length >= 10 && digits.length <= 15;
2068
+ }
2069
+ },
2070
+ {
2071
+ type: "ssn",
2072
+ regex: /\b(?!000|666|9\d{2})\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b/g,
2073
+ confidence: 0.95
2074
+ },
2075
+ {
2076
+ type: "credit_card",
2077
+ // Matches common card prefixes: Visa (4), Mastercard (5[1-5], 2[2-7]),
2078
+ // Amex (3[47]), Discover (6011, 65, 644-649)
2079
+ regex: /\b(?:4\d{3}|5[1-5]\d{2}|2[2-7]\d{2}|3[47]\d{2}|6(?:011|5\d{2}|4[4-9]\d))[- ]?\d{4}[- ]?\d{4}[- ]?\d{1,7}\b/g,
2080
+ confidence: 0.95,
2081
+ validate: (match) => {
2082
+ const digits = match.replace(/\D/g, "");
2083
+ return digits.length >= 13 && digits.length <= 19 && luhnCheck(digits);
2084
+ }
2085
+ },
2086
+ {
2087
+ type: "aws_key",
2088
+ regex: /\bAKIA[0-9A-Z]{16}\b/g,
2089
+ confidence: 0.95
2090
+ },
2091
+ {
2092
+ type: "github_token",
2093
+ regex: /\b(?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36}\b/g,
2094
+ confidence: 0.95
2095
+ }
2096
+ ];
2097
+ function createRegexDetector() {
2098
+ return {
2099
+ name: "regex",
2100
+ detect(content, _ctx) {
2101
+ if (!content || content.length === 0) return [];
2102
+ const scanContent = content.length > MAX_CONTENT_LENGTH ? content.slice(0, MAX_CONTENT_LENGTH) : content;
2103
+ const matches = [];
2104
+ for (const pattern of PATTERNS) {
2105
+ pattern.regex.lastIndex = 0;
2106
+ let match;
2107
+ while ((match = pattern.regex.exec(scanContent)) !== null) {
2108
+ const value = match[0];
2109
+ if (pattern.validate && !pattern.validate(value)) {
2110
+ continue;
2111
+ }
2112
+ matches.push({
2113
+ type: pattern.type,
2114
+ value,
2115
+ confidence: pattern.confidence,
2116
+ start: match.index,
2117
+ end: match.index + value.length
2118
+ });
2119
+ }
2120
+ }
2121
+ matches.sort((a, b) => a.start - b.start);
2122
+ return matches;
2123
+ }
2124
+ };
2125
+ }
2126
+
2127
+ // src/pii/registry.ts
2128
+ var logger = createLogger({ component: "pii-registry" });
2129
+ function createPIIRegistry(config) {
2130
+ const detectors = [createRegexDetector()];
2131
+ for (const [typeName, customType] of Object.entries(config.custom_types)) {
2132
+ const compiledPatterns = [];
2133
+ for (const pattern of customType.patterns) {
2134
+ try {
2135
+ compiledPatterns.push(new RegExp(pattern.regex, "g"));
2136
+ } catch {
2137
+ logger.warn("Invalid custom PII regex, skipping", {
2138
+ type: typeName,
2139
+ regex: pattern.regex
2140
+ });
2141
+ }
2142
+ }
2143
+ if (compiledPatterns.length > 0) {
2144
+ detectors.push({
2145
+ name: `custom:${typeName}`,
2146
+ detect(content, _ctx) {
2147
+ const matches = [];
2148
+ for (const regex of compiledPatterns) {
2149
+ regex.lastIndex = 0;
2150
+ let match;
2151
+ while ((match = regex.exec(content)) !== null) {
2152
+ matches.push({
2153
+ type: typeName,
2154
+ value: match[0],
2155
+ confidence: 0.85,
2156
+ start: match.index,
2157
+ end: match.index + match[0].length
2158
+ });
2159
+ }
2160
+ }
2161
+ return matches;
2162
+ }
2163
+ });
2164
+ }
2165
+ }
2166
+ const confidenceThreshold = config.confidence_threshold;
2167
+ return {
2168
+ scan(content, ctx) {
2169
+ if (!content || content.length === 0) return [];
2170
+ const scanContent = content.length > MAX_CONTENT_LENGTH ? content.slice(0, MAX_CONTENT_LENGTH) : content;
2171
+ const allMatches = [];
2172
+ for (const detector of detectors) {
2173
+ const matches = detector.detect(scanContent, ctx);
2174
+ allMatches.push(...matches);
2175
+ }
2176
+ const filtered = allMatches.filter((m) => m.confidence >= confidenceThreshold);
2177
+ filtered.sort((a, b) => a.start - b.start);
2178
+ const deduped = [];
2179
+ for (const m of filtered) {
2180
+ const overlapping = deduped.findIndex(
2181
+ (existing) => m.start < existing.end && m.end > existing.start
2182
+ );
2183
+ if (overlapping === -1) {
2184
+ deduped.push(m);
2185
+ } else if (m.confidence >= deduped[overlapping].confidence) {
2186
+ deduped[overlapping] = m;
2187
+ }
2188
+ }
2189
+ return deduped;
2190
+ }
2191
+ };
2192
+ }
2193
+
2194
+ // src/storage/rate-limit-store.ts
2195
+ function createRateLimitStore(db) {
2196
+ const upsertStmt = db.prepare(`
2197
+ INSERT INTO rate_limits (key, tokens, max_tokens, refill_rate, last_refill, updated_at)
2198
+ VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
2199
+ ON CONFLICT(key) DO UPDATE SET
2200
+ tokens = excluded.tokens,
2201
+ max_tokens = excluded.max_tokens,
2202
+ refill_rate = excluded.refill_rate,
2203
+ last_refill = excluded.last_refill,
2204
+ updated_at = excluded.updated_at
2205
+ `);
2206
+ const updateTokensStmt = db.prepare(`
2207
+ UPDATE rate_limits SET tokens = ?, updated_at = datetime('now') WHERE key = ?
2208
+ `);
2209
+ const selectStmt = db.prepare(`
2210
+ SELECT tokens, max_tokens, refill_rate, last_refill
2211
+ FROM rate_limits WHERE key = ?
2212
+ `);
2213
+ const deleteStmt = db.prepare(`
2214
+ DELETE FROM rate_limits WHERE updated_at < ?
2215
+ `);
2216
+ const tryConsumeTransaction = db.transaction(
2217
+ (key, config) => {
2218
+ const row = selectStmt.get(key);
2219
+ if (!row) {
2220
+ upsertStmt.run(key, config.maxTokens - 1, config.maxTokens, config.refillRate);
2221
+ return true;
2222
+ }
2223
+ const lastRefill = (/* @__PURE__ */ new Date(row.last_refill + "Z")).getTime();
2224
+ const now = Date.now();
2225
+ const elapsedSeconds = (now - lastRefill) / 1e3;
2226
+ const effectiveMax = Math.min(row.max_tokens, config.maxTokens);
2227
+ const refilled = Math.min(
2228
+ effectiveMax,
2229
+ row.tokens + elapsedSeconds * config.refillRate
2230
+ );
2231
+ if (refilled < 1) {
2232
+ updateTokensStmt.run(refilled, key);
2233
+ return false;
2234
+ }
2235
+ upsertStmt.run(key, refilled - 1, config.maxTokens, config.refillRate);
2236
+ return true;
2237
+ }
2238
+ );
2239
+ return {
2240
+ tryConsume(key, config) {
2241
+ return tryConsumeTransaction(key, config);
2242
+ },
2243
+ getRemaining(key) {
2244
+ const row = selectStmt.get(key);
2245
+ if (!row) return null;
2246
+ const lastRefill = (/* @__PURE__ */ new Date(row.last_refill + "Z")).getTime();
2247
+ const now = Date.now();
2248
+ const elapsedSeconds = (now - lastRefill) / 1e3;
2249
+ return Math.min(row.max_tokens, row.tokens + elapsedSeconds * row.refill_rate);
2250
+ },
2251
+ cleanup(olderThan) {
2252
+ deleteStmt.run(olderThan.toISOString());
2253
+ }
2254
+ };
2255
+ }
2256
+
2257
+ // src/audit/store.ts
2258
+ function createAuditStore(db) {
2259
+ const insertStmt = db.prepare(`
2260
+ INSERT INTO audit_logs (
2261
+ bridge_id, server, method, direction,
2262
+ identity_uid, identity_username, identity_roles,
2263
+ tool_or_resource, params_summary,
2264
+ interceptor_decisions, allowed, blocked_by, block_reason, latency_ms
2265
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2266
+ `);
2267
+ const cleanupStmt = db.prepare(`
2268
+ DELETE FROM audit_logs WHERE timestamp < datetime('now', ?)
2269
+ `);
2270
+ return {
2271
+ write(entry) {
2272
+ const blockedDecision = entry.pipelineResult.decisions.find(
2273
+ (d) => d.decision.action === "BLOCK"
2274
+ );
2275
+ insertStmt.run(
2276
+ entry.bridgeId,
2277
+ entry.server,
2278
+ entry.method,
2279
+ entry.direction,
2280
+ entry.identity.uid,
2281
+ entry.identity.username,
2282
+ JSON.stringify(entry.identity.roles),
2283
+ entry.toolOrResource ?? null,
2284
+ entry.paramsSummary ?? null,
2285
+ JSON.stringify(
2286
+ entry.pipelineResult.decisions.map((d) => ({
2287
+ name: d.interceptor,
2288
+ action: d.decision.action,
2289
+ reason: d.decision.action === "BLOCK" ? d.decision.reason : void 0,
2290
+ metadata: d.decision.metadata,
2291
+ durationMs: d.durationMs
2292
+ }))
2293
+ ),
2294
+ entry.pipelineResult.allowed ? 1 : 0,
2295
+ blockedDecision?.interceptor ?? null,
2296
+ blockedDecision?.decision.action === "BLOCK" ? blockedDecision.decision.reason : null,
2297
+ entry.latencyMs ?? null
2298
+ );
2299
+ },
2300
+ query(filters) {
2301
+ const conditions = [];
2302
+ const params = [];
2303
+ if (filters.server) {
2304
+ conditions.push("server = ?");
2305
+ params.push(filters.server);
2306
+ }
2307
+ if (filters.user) {
2308
+ conditions.push("identity_username = ?");
2309
+ params.push(filters.user);
2310
+ }
2311
+ if (filters.method) {
2312
+ conditions.push("method = ?");
2313
+ params.push(filters.method);
2314
+ }
2315
+ if (filters.type === "allow") {
2316
+ conditions.push("allowed = 1");
2317
+ } else if (filters.type === "block") {
2318
+ conditions.push("allowed = 0");
2319
+ }
2320
+ if (filters.last) {
2321
+ const modifier = parseTimeModifier(filters.last);
2322
+ if (modifier) {
2323
+ conditions.push("timestamp >= datetime('now', ?)");
2324
+ params.push(modifier);
2325
+ }
2326
+ }
2327
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
2328
+ const limit = filters.limit ?? 100;
2329
+ const sql = `SELECT * FROM audit_logs ${where} ORDER BY timestamp DESC LIMIT ?`;
2330
+ params.push(limit);
2331
+ return db.prepare(sql).all(...params);
2332
+ },
2333
+ cleanup(olderThanDays) {
2334
+ const days = Math.max(1, Math.abs(olderThanDays));
2335
+ const result = cleanupStmt.run(`-${days} days`);
2336
+ return result.changes;
2337
+ }
2338
+ };
2339
+ }
2340
+ function parseTimeModifier(duration) {
2341
+ const match = duration.match(/^(\d+)(m|h|d)$/);
2342
+ if (!match) return null;
2343
+ const value = parseInt(match[1], 10);
2344
+ const unit = match[2];
2345
+ switch (unit) {
2346
+ case "m":
2347
+ return `-${value} minutes`;
2348
+ case "h":
2349
+ return `-${value} hours`;
2350
+ case "d":
2351
+ return `-${value} days`;
2352
+ default:
2353
+ return null;
2354
+ }
2355
+ }
2356
+
2357
+ // src/audit/stdout-logger.ts
2358
+ function formatAuditEntry(entry) {
2359
+ const blockedDecision = entry.pipelineResult.decisions.find(
2360
+ (d) => d.decision.action === "BLOCK"
2361
+ );
2362
+ return JSON.stringify({
2363
+ type: "audit",
2364
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2365
+ server: entry.server,
2366
+ method: entry.method,
2367
+ direction: entry.direction,
2368
+ identity: entry.identity.username,
2369
+ roles: entry.identity.roles,
2370
+ allowed: entry.pipelineResult.allowed,
2371
+ blocked_by: blockedDecision?.interceptor ?? null,
2372
+ block_reason: blockedDecision?.decision.action === "BLOCK" ? blockedDecision.decision.reason : null,
2373
+ tool_or_resource: entry.toolOrResource ?? null,
2374
+ latency_ms: entry.latencyMs ?? null
2375
+ });
2376
+ }
2377
+
2378
+ // src/audit/tap.ts
2379
+ function createAuditTap(store, logger2, config) {
2380
+ let lastWriteTime = null;
2381
+ return {
2382
+ record(entry) {
2383
+ if (!config.audit.enabled) {
2384
+ return;
2385
+ }
2386
+ try {
2387
+ store.write(entry);
2388
+ lastWriteTime = (/* @__PURE__ */ new Date()).toISOString();
2389
+ } catch (err) {
2390
+ logger2.error("Audit store write failed", { error: String(err) });
2391
+ }
2392
+ if (config.audit.stdout) {
2393
+ try {
2394
+ process.stdout.write(formatAuditEntry(entry) + "\n");
2395
+ } catch (err) {
2396
+ logger2.error("Audit stdout write failed", { error: String(err) });
2397
+ }
2398
+ }
2399
+ },
2400
+ getLastWriteTime() {
2401
+ return lastWriteTime;
2402
+ }
2403
+ };
2404
+ }
2405
+
2406
+ // src/proxy/capability-filter.ts
2407
+ function filterCapabilities(capabilities, serverConfig) {
2408
+ if (!serverConfig.policy.sampling.enabled && "sampling" in capabilities) {
2409
+ const { sampling: _, ...rest } = capabilities;
2410
+ return rest;
2411
+ }
2412
+ return capabilities;
2413
+ }
2414
+ function filterToolsList(tools, serverConfig, identity, config) {
2415
+ const permissions = resolveEffectivePermissions(serverConfig.policy.permissions, identity, config);
2416
+ return tools.filter((tool) => {
2417
+ if (matchesAny(tool.name, permissions.denied_tools)) {
2418
+ return false;
2419
+ }
2420
+ for (const allowList of permissions.allowed_tools_lists) {
2421
+ if (!matchesAny(tool.name, allowList)) {
2422
+ return false;
2423
+ }
2424
+ }
2425
+ return true;
2426
+ });
2427
+ }
2428
+ function filterResourcesList(resources, serverConfig, identity, config) {
2429
+ const permissions = resolveEffectivePermissions(serverConfig.policy.permissions, identity, config);
2430
+ return resources.filter((resource) => {
2431
+ if (matchesAny(resource.uri, permissions.denied_resources)) {
2432
+ return false;
2433
+ }
2434
+ for (const allowList of permissions.allowed_resources_lists) {
2435
+ if (!matchesAny(resource.uri, allowList)) {
2436
+ return false;
2437
+ }
2438
+ }
2439
+ return true;
2440
+ });
2441
+ }
2442
+
2443
+ // src/config/watcher.ts
2444
+ init_loader();
2445
+ init_constants();
2446
+ import { watch } from "fs";
2447
+ function createConfigWatcher(configPath, onChange, logger2, currentConfig) {
2448
+ let oldConfig = currentConfig;
2449
+ let debounceTimer;
2450
+ let watcher;
2451
+ try {
2452
+ watcher = watch(configPath, { persistent: false }, (_eventType) => {
2453
+ if (debounceTimer) {
2454
+ clearTimeout(debounceTimer);
2455
+ }
2456
+ debounceTimer = setTimeout(async () => {
2457
+ try {
2458
+ const newConfig = await reloadConfig(configPath);
2459
+ const previousConfig = oldConfig;
2460
+ oldConfig = newConfig;
2461
+ onChange(newConfig, previousConfig);
2462
+ logger2.info("Config reloaded successfully");
2463
+ } catch (err) {
2464
+ logger2.warn("Config reload failed \u2014 keeping previous config", {
2465
+ error: String(err)
2466
+ });
2467
+ }
2468
+ }, CONFIG_RELOAD_DEBOUNCE);
2469
+ });
2470
+ } catch (err) {
2471
+ logger2.error("Failed to watch config file \u2014 hot reload disabled", { error: String(err), path: configPath });
2472
+ return { stop() {
2473
+ } };
2474
+ }
2475
+ return {
2476
+ stop() {
2477
+ if (debounceTimer) {
2478
+ clearTimeout(debounceTimer);
2479
+ }
2480
+ watcher.close();
2481
+ }
2482
+ };
2483
+ }
2484
+
2485
+ // src/dashboard/server.ts
2486
+ import { createServer as createServer2 } from "http";
2487
+ import { randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
2488
+ import { writeFile as writeFile3 } from "fs/promises";
2489
+ import { join as join5 } from "path";
2490
+
2491
+ // src/dashboard/health.ts
2492
+ import { readFileSync } from "fs";
2493
+ import { join as join4, dirname as dirname2 } from "path";
2494
+ import { fileURLToPath } from "url";
2495
+ function loadVersion() {
2496
+ let dir = dirname2(fileURLToPath(import.meta.url));
2497
+ for (let i = 0; i < 5; i++) {
2498
+ try {
2499
+ const content = readFileSync(join4(dir, "package.json"), "utf-8");
2500
+ const pkg = JSON.parse(content);
2501
+ if (pkg.version) return pkg.version;
2502
+ } catch {
2503
+ }
2504
+ dir = dirname2(dir);
2505
+ }
2506
+ return "0.0.0";
2507
+ }
2508
+ var VERSION = loadVersion();
2509
+ function buildHealthResponse(ctx) {
2510
+ const serverStatuses = ctx.getServerStatuses();
2511
+ const dbHealthy = ctx.isDatabaseHealthy();
2512
+ const servers = {};
2513
+ for (const [name, status2] of serverStatuses) {
2514
+ servers[name] = status2;
2515
+ }
2516
+ const serverValues = [...serverStatuses.values()];
2517
+ const allConnected = serverValues.length > 0 && serverValues.every((s) => s === "connected");
2518
+ const noneConnected = serverValues.length === 0 || serverValues.every((s) => s !== "connected");
2519
+ let status;
2520
+ if (!dbHealthy || noneConnected) {
2521
+ status = "unhealthy";
2522
+ } else if (allConnected) {
2523
+ status = "healthy";
2524
+ } else {
2525
+ status = "degraded";
2526
+ }
2527
+ return {
2528
+ status,
2529
+ uptime_seconds: Math.floor((Date.now() - ctx.startTime) / 1e3),
2530
+ servers,
2531
+ bridges: ctx.getBridgeCount(),
2532
+ database: dbHealthy ? "ok" : "error",
2533
+ last_audit_write: ctx.getLastAuditWrite(),
2534
+ version: VERSION
2535
+ };
2536
+ }
2537
+
2538
+ // src/dashboard/server.ts
2539
+ init_errors();
2540
+ function createDashboardServer(options) {
2541
+ const { port, healthContext, logger: logger2 } = options;
2542
+ const authToken = options.authToken ?? randomBytes2(32).toString("hex");
2543
+ const authTokenBuffer = Buffer.from(authToken, "utf-8");
2544
+ const server = createServer2(async (req, res) => {
2545
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
2546
+ if (url.pathname === "/healthz" && req.method === "GET") {
2547
+ const health = buildHealthResponse(healthContext);
2548
+ const statusCode = health.status === "unhealthy" ? 503 : 200;
2549
+ res.writeHead(statusCode, { "Content-Type": "application/json" });
2550
+ res.end(JSON.stringify(health));
2551
+ return;
2552
+ }
2553
+ if (url.pathname === "/api/status" && req.method === "GET") {
2554
+ if (!verifyBearerToken(req.headers.authorization, authTokenBuffer)) {
2555
+ res.writeHead(401, { "Content-Type": "application/json" });
2556
+ res.end(JSON.stringify({ error: "Unauthorized" }));
2557
+ return;
2558
+ }
2559
+ const health = buildHealthResponse(healthContext);
2560
+ res.writeHead(200, { "Content-Type": "application/json" });
2561
+ res.end(JSON.stringify(health));
2562
+ return;
2563
+ }
2564
+ res.writeHead(404, { "Content-Type": "application/json" });
2565
+ res.end(JSON.stringify({ error: "Not found" }));
2566
+ });
2567
+ return {
2568
+ async listen() {
2569
+ await new Promise((resolve, reject) => {
2570
+ server.on("error", (err) => {
2571
+ reject(new DashboardError(`Dashboard server failed to start: ${err.message}`));
2572
+ });
2573
+ server.listen(port, "127.0.0.1", () => {
2574
+ logger2.info("Dashboard server started", { port });
2575
+ resolve();
2576
+ });
2577
+ });
2578
+ if (options.home) {
2579
+ const actualPort = String(server.address() && typeof server.address() === "object" ? server.address().port : port);
2580
+ const tokenPath = join5(options.home, "dashboard.token");
2581
+ const portPath = join5(options.home, "dashboard.port");
2582
+ await writeFile3(tokenPath, authToken, { mode: 384 });
2583
+ await writeFile3(portPath, actualPort, { mode: 384 });
2584
+ }
2585
+ },
2586
+ async close() {
2587
+ await new Promise((resolve, reject) => {
2588
+ server.close((err) => {
2589
+ if (err) {
2590
+ reject(new DashboardError(`Dashboard server close failed: ${err.message}`));
2591
+ } else {
2592
+ logger2.info("Dashboard server closed");
2593
+ resolve();
2594
+ }
2595
+ });
2596
+ });
2597
+ },
2598
+ getAuthToken() {
2599
+ return authToken;
2600
+ },
2601
+ getPort() {
2602
+ const addr = server.address();
2603
+ if (addr && typeof addr === "object") {
2604
+ return addr.port;
2605
+ }
2606
+ return port;
2607
+ }
2608
+ };
2609
+ }
2610
+ function verifyBearerToken(authHeader, expectedTokenBuffer) {
2611
+ if (!authHeader?.startsWith("Bearer ")) {
2612
+ return false;
2613
+ }
2614
+ const provided = Buffer.from(authHeader.slice(7), "utf-8");
2615
+ if (provided.length !== expectedTokenBuffer.length) {
2616
+ return false;
2617
+ }
2618
+ return timingSafeEqual3(provided, expectedTokenBuffer);
2619
+ }
2620
+
2621
+ // src/daemon/index.ts
2622
+ async function startDaemon(config, configPath) {
2623
+ const logger2 = createLogger({
2624
+ component: "daemon"
2625
+ });
2626
+ const home = config.daemon.home;
2627
+ const keyPath = join6(home, "daemon.key");
2628
+ const pidFile = join6(home, "daemon.pid");
2629
+ const dbPath = join6(home, "mcp-guard.db");
2630
+ await mkdir3(home, { recursive: true, mode: 448 });
2631
+ logger2.info("Home directory ready", { path: home });
2632
+ const daemonKey = await ensureDaemonKey(keyPath);
2633
+ logger2.info("Daemon key ready");
2634
+ await writeFile4(pidFile, String(process.pid), { mode: 384 });
2635
+ logger2.info("PID file written", { pid: process.pid, path: pidFile });
2636
+ const dbOptions = { path: dbPath };
2637
+ if (config.daemon.encryption.enabled) {
2638
+ dbOptions.encryptionKey = deriveDbEncryptionKey(daemonKey);
2639
+ logger2.info("Database encryption enabled");
2640
+ }
2641
+ const db = openDatabase(dbOptions);
2642
+ runMigrations(db);
2643
+ logger2.info("Database ready", { path: dbPath });
2644
+ const serverManager = createServerManager(config, logger2);
2645
+ await serverManager.startAll();
2646
+ const upstreamClients = /* @__PURE__ */ new Map();
2647
+ for (const name of Object.keys(config.servers)) {
2648
+ const client = serverManager.getClient(name);
2649
+ if (client) {
2650
+ upstreamClients.set(name, client);
2651
+ }
2652
+ }
2653
+ const proxyServer = createProxyServer(upstreamClients, logger2);
2654
+ const rateLimitStore = createRateLimitStore(db);
2655
+ const auditStore = createAuditStore(db);
2656
+ const auditTap = createAuditTap(auditStore, logger2, config);
2657
+ let currentConfig = config;
2658
+ function buildPipelines(cfg) {
2659
+ const registry = createPIIRegistry(cfg.pii);
2660
+ return {
2661
+ pipeline: createPipeline({
2662
+ interceptors: [
2663
+ createAuthInterceptor(cfg),
2664
+ createRateLimitInterceptor(rateLimitStore, cfg),
2665
+ createPermissionInterceptor(cfg),
2666
+ createSamplingGuardInterceptor(cfg),
2667
+ createPiiInterceptor(registry, cfg)
2668
+ ],
2669
+ timeout: cfg.interceptors.timeout * 1e3,
2670
+ logger: logger2
2671
+ }),
2672
+ responsePipeline: createPipeline({
2673
+ interceptors: [createPiiInterceptor(registry, cfg)],
2674
+ timeout: cfg.interceptors.timeout * 1e3,
2675
+ logger: logger2
2676
+ })
2677
+ };
2678
+ }
2679
+ let { pipeline: currentPipeline, responsePipeline: currentResponsePipeline } = buildPipelines(config);
2680
+ logger2.info("Interceptor pipeline ready", {
2681
+ interceptors: ["auth", "rate-limit", "permissions", "sampling-guard", "pii-detect"],
2682
+ timeout: config.interceptors.timeout
2683
+ });
2684
+ const socketServer = createSocketServer({
2685
+ socketPath: config.daemon.socket_path,
2686
+ daemonKey,
2687
+ logger: logger2,
2688
+ onConnection: (conn) => {
2689
+ conn.onMessage(async (msg) => {
2690
+ if (msg.type === "mcp") {
2691
+ const identity = resolveIdentity(conn.uid, conn.pid, currentConfig);
2692
+ const method = msg.data.method ?? "unknown";
2693
+ const startTime = Date.now();
2694
+ try {
2695
+ const ctx = {
2696
+ message: { method, params: msg.data.params },
2697
+ server: msg.server,
2698
+ identity,
2699
+ direction: "request",
2700
+ metadata: { bridgeId: conn.id, timestamp: Date.now() }
2701
+ };
2702
+ const pipelineResult = await currentPipeline.execute(ctx);
2703
+ const latencyMs = Date.now() - startTime;
2704
+ const effectiveIdentity = pipelineResult.resolvedIdentity ?? identity;
2705
+ auditTap.record({
2706
+ bridgeId: conn.id,
2707
+ server: msg.server,
2708
+ method,
2709
+ direction: "request",
2710
+ identity: effectiveIdentity,
2711
+ toolOrResource: extractToolOrResource(msg.data),
2712
+ pipelineResult,
2713
+ latencyMs
2714
+ });
2715
+ if (!pipelineResult.allowed) {
2716
+ const blockReason = pipelineResult.decisions.find(
2717
+ (d) => d.decision.action === "BLOCK"
2718
+ );
2719
+ conn.send({
2720
+ type: "mcp",
2721
+ data: {
2722
+ jsonrpc: "2.0",
2723
+ id: msg.data.id,
2724
+ error: {
2725
+ code: -32600,
2726
+ message: blockReason?.decision.action === "BLOCK" ? blockReason.decision.reason : "Blocked by security policy"
2727
+ }
2728
+ }
2729
+ });
2730
+ return;
2731
+ }
2732
+ const upstreamMsg = pipelineResult.finalParams ? { ...msg.data, params: pipelineResult.finalParams } : msg.data;
2733
+ const response = await proxyServer.handleMessage(upstreamMsg, msg.server);
2734
+ const responseContent = response.result ?? response.error;
2735
+ if (responseContent) {
2736
+ const responseCtx = {
2737
+ message: { method, params: responseContent },
2738
+ server: msg.server,
2739
+ identity: effectiveIdentity,
2740
+ direction: "response",
2741
+ metadata: { bridgeId: conn.id, timestamp: Date.now() }
2742
+ };
2743
+ const responseResult = await currentResponsePipeline.execute(responseCtx);
2744
+ auditTap.record({
2745
+ bridgeId: conn.id,
2746
+ server: msg.server,
2747
+ method,
2748
+ direction: "response",
2749
+ identity: effectiveIdentity,
2750
+ toolOrResource: extractToolOrResource(msg.data),
2751
+ pipelineResult: responseResult,
2752
+ latencyMs: Date.now() - startTime
2753
+ });
2754
+ if (!responseResult.allowed) {
2755
+ conn.send({
2756
+ type: "mcp",
2757
+ data: {
2758
+ jsonrpc: "2.0",
2759
+ id: msg.data.id,
2760
+ error: { code: -32600, message: "Response blocked by PII policy" }
2761
+ }
2762
+ });
2763
+ return;
2764
+ }
2765
+ if (responseResult.finalParams) {
2766
+ if (response.result) {
2767
+ response.result = responseResult.finalParams;
2768
+ } else if (response.error) {
2769
+ const redacted = responseResult.finalParams;
2770
+ if (typeof redacted["message"] === "string") {
2771
+ response.error.message = redacted["message"];
2772
+ }
2773
+ if ("data" in redacted) {
2774
+ response.error.data = redacted["data"];
2775
+ }
2776
+ }
2777
+ }
2778
+ }
2779
+ if (msg.data.method === "initialize" && response.result) {
2780
+ const result = response.result;
2781
+ const serverConfig = currentConfig.servers[msg.server];
2782
+ if (result.capabilities && serverConfig) {
2783
+ result.capabilities = filterCapabilities(result.capabilities, serverConfig);
2784
+ }
2785
+ }
2786
+ if (msg.data.method === "tools/list" && response.result) {
2787
+ const result = response.result;
2788
+ const serverConfig = currentConfig.servers[msg.server];
2789
+ if (result.tools && serverConfig) {
2790
+ result.tools = filterToolsList(result.tools, serverConfig, effectiveIdentity, currentConfig);
2791
+ }
2792
+ }
2793
+ if (msg.data.method === "resources/list" && response.result) {
2794
+ const result = response.result;
2795
+ const serverConfig = currentConfig.servers[msg.server];
2796
+ if (result.resources && serverConfig) {
2797
+ result.resources = filterResourcesList(result.resources, serverConfig, effectiveIdentity, currentConfig);
2798
+ }
2799
+ }
2800
+ conn.send({ type: "mcp", data: response });
2801
+ } catch (err) {
2802
+ const latencyMs = Date.now() - startTime;
2803
+ logger2.error("Message handler failed", { error: String(err), method });
2804
+ auditTap.record({
2805
+ bridgeId: conn.id,
2806
+ server: msg.server,
2807
+ method,
2808
+ direction: "request",
2809
+ identity,
2810
+ toolOrResource: extractToolOrResource(msg.data),
2811
+ pipelineResult: {
2812
+ allowed: false,
2813
+ decisions: [{
2814
+ interceptor: "internal",
2815
+ decision: { action: "BLOCK", reason: `Internal error: ${String(err)}` },
2816
+ durationMs: latencyMs
2817
+ }]
2818
+ },
2819
+ latencyMs
2820
+ });
2821
+ conn.send({
2822
+ type: "mcp",
2823
+ data: {
2824
+ jsonrpc: "2.0",
2825
+ id: msg.data.id,
2826
+ error: { code: -32603, message: "Internal error" }
2827
+ }
2828
+ });
2829
+ }
2830
+ }
2831
+ });
2832
+ }
2833
+ });
2834
+ await socketServer.listen();
2835
+ const dashboardServer = createDashboardServer({
2836
+ port: config.daemon.dashboard_port,
2837
+ healthContext: {
2838
+ startTime: Date.now(),
2839
+ getServerStatuses: () => serverManager.getStatus(),
2840
+ getBridgeCount: () => socketServer.getConnections().length,
2841
+ isDatabaseHealthy: () => {
2842
+ try {
2843
+ db.pragma("integrity_check");
2844
+ return true;
2845
+ } catch {
2846
+ return false;
2847
+ }
2848
+ },
2849
+ getLastAuditWrite: () => auditTap.getLastWriteTime()
2850
+ },
2851
+ logger: logger2,
2852
+ home
2853
+ });
2854
+ await dashboardServer.listen();
2855
+ let configWatcher;
2856
+ if (configPath) {
2857
+ configWatcher = createConfigWatcher(
2858
+ configPath,
2859
+ (newConfig, oldConfig) => {
2860
+ for (const name of Object.keys(newConfig.servers)) {
2861
+ const oldServer = oldConfig.servers[name];
2862
+ const newServer = newConfig.servers[name];
2863
+ if (!oldServer) {
2864
+ logger2.warn("New server added in config \u2014 requires daemon restart to take effect", { server: name });
2865
+ continue;
2866
+ }
2867
+ if (oldServer.command !== newServer.command || oldServer.url !== newServer.url || oldServer.transport !== newServer.transport || JSON.stringify(oldServer.args) !== JSON.stringify(newServer.args) || JSON.stringify(oldServer.env) !== JSON.stringify(newServer.env)) {
2868
+ logger2.warn("Server definition changed \u2014 requires daemon restart to take effect", { server: name });
2869
+ }
2870
+ }
2871
+ for (const name of Object.keys(oldConfig.servers)) {
2872
+ if (!newConfig.servers[name]) {
2873
+ logger2.warn("Server removed in config \u2014 requires daemon restart to take effect", { server: name });
2874
+ }
2875
+ }
2876
+ logger2.info("Config reloaded, rebuilding pipeline");
2877
+ const rebuilt = buildPipelines(newConfig);
2878
+ currentPipeline = rebuilt.pipeline;
2879
+ currentResponsePipeline = rebuilt.responsePipeline;
2880
+ currentConfig = newConfig;
2881
+ },
2882
+ logger2,
2883
+ config
2884
+ );
2885
+ logger2.info("Config watcher started", { path: configPath });
2886
+ }
2887
+ const shutdownHandle = registerShutdownHandlers({
2888
+ socketServer,
2889
+ serverManager,
2890
+ db,
2891
+ pidFile,
2892
+ timeout: config.daemon.shutdown_timeout * 1e3,
2893
+ logger: logger2,
2894
+ onBeforeShutdown: async () => {
2895
+ configWatcher?.stop();
2896
+ await dashboardServer.close();
2897
+ }
2898
+ });
2899
+ logger2.info("Daemon started", {
2900
+ socket: config.daemon.socket_path,
2901
+ servers: Object.keys(config.servers),
2902
+ pid: process.pid,
2903
+ dashboard: config.daemon.dashboard_port
2904
+ });
2905
+ return {
2906
+ shutdown() {
2907
+ return shutdownHandle.shutdown();
2908
+ },
2909
+ getDashboardPort() {
2910
+ return dashboardServer.getPort();
2911
+ },
2912
+ getDashboardToken() {
2913
+ return dashboardServer.getAuthToken();
2914
+ }
2915
+ };
2916
+ }
2917
+ function extractToolOrResource(data) {
2918
+ if (data.method === "tools/call") {
2919
+ return data.params?.["name"];
2920
+ }
2921
+ if (data.method === "resources/read") {
2922
+ return data.params?.["uri"];
2923
+ }
2924
+ return void 0;
2925
+ }
2926
+
2927
+ // src/bridge/index.ts
2928
+ import { connect as connect2 } from "net";
2929
+
2930
+ // src/bridge/auth.ts
2931
+ init_errors();
2932
+ init_constants();
2933
+ function writeFramed2(socket, data) {
2934
+ const json = JSON.stringify(data);
2935
+ const payload = Buffer.from(json, "utf-8");
2936
+ const header = Buffer.alloc(4);
2937
+ header.writeUInt32BE(payload.length, 0);
2938
+ socket.write(Buffer.concat([header, payload]));
2939
+ }
2940
+ function readFramed(socket, timeout) {
2941
+ return new Promise((resolve, reject) => {
2942
+ let buffer = Buffer.alloc(0);
2943
+ const timer = setTimeout(() => {
2944
+ cleanup();
2945
+ reject(new AuthError("Auth response timeout"));
2946
+ }, timeout);
2947
+ function onData(chunk) {
2948
+ buffer = Buffer.concat([buffer, chunk]);
2949
+ if (buffer.length >= 4) {
2950
+ const length = buffer.readUInt32BE(0);
2951
+ if (buffer.length >= 4 + length) {
2952
+ cleanup();
2953
+ const json = buffer.subarray(4, 4 + length).toString("utf-8");
2954
+ try {
2955
+ resolve(JSON.parse(json));
2956
+ } catch {
2957
+ reject(new AuthError("Invalid auth response"));
2958
+ }
2959
+ }
2960
+ }
2961
+ }
2962
+ function onError(err) {
2963
+ cleanup();
2964
+ reject(new AuthError(`Socket error during auth: ${err.message}`));
2965
+ }
2966
+ function cleanup() {
2967
+ clearTimeout(timer);
2968
+ socket.removeListener("data", onData);
2969
+ socket.removeListener("error", onError);
2970
+ }
2971
+ socket.on("data", onData);
2972
+ socket.on("error", onError);
2973
+ });
2974
+ }
2975
+ async function authenticateToDaemon(socket, keyPath) {
2976
+ const key = await readDaemonKey(keyPath);
2977
+ writeFramed2(socket, { type: "auth", key: key.toString("hex") });
2978
+ const response = await readFramed(socket, AUTH_TIMEOUT);
2979
+ if (response.type !== "auth_ok") {
2980
+ const reason = response.reason ?? "Unknown auth failure";
2981
+ throw new AuthError(`Daemon auth failed: ${reason}`);
2982
+ }
2983
+ }
2984
+
2985
+ // src/daemon/auto-start.ts
2986
+ init_constants();
2987
+ init_errors();
2988
+ import { connect } from "net";
2989
+ import { fork } from "child_process";
2990
+ import { fileURLToPath as fileURLToPath2 } from "url";
2991
+ import { dirname as dirname3, join as join7 } from "path";
2992
+ async function isDaemonRunning(socketPath) {
2993
+ const path = socketPath ?? DEFAULT_SOCKET_PATH;
2994
+ return new Promise((resolve) => {
2995
+ const socket = connect(path);
2996
+ const timeout = setTimeout(() => {
2997
+ socket.destroy();
2998
+ resolve(false);
2999
+ }, 1e3);
3000
+ socket.on("connect", () => {
3001
+ clearTimeout(timeout);
3002
+ socket.destroy();
3003
+ resolve(true);
3004
+ });
3005
+ socket.on("error", () => {
3006
+ clearTimeout(timeout);
3007
+ resolve(false);
3008
+ });
3009
+ });
3010
+ }
3011
+ async function autoStartDaemon(configPath, socketPath) {
3012
+ const thisFile = fileURLToPath2(import.meta.url);
3013
+ const cliPath = join7(dirname3(thisFile), "..", "cli.js");
3014
+ const args = ["start", "--daemon"];
3015
+ if (configPath) {
3016
+ args.push("--config", configPath);
3017
+ }
3018
+ const child = fork(cliPath, args, {
3019
+ detached: true,
3020
+ stdio: "ignore"
3021
+ });
3022
+ child.unref();
3023
+ const maxWait = 3e3;
3024
+ const interval = 100;
3025
+ let waited = 0;
3026
+ while (waited < maxWait) {
3027
+ await new Promise((r) => setTimeout(r, interval));
3028
+ waited += interval;
3029
+ if (await isDaemonRunning(socketPath)) {
3030
+ return;
3031
+ }
3032
+ }
3033
+ throw new BridgeError("Daemon failed to start within timeout");
3034
+ }
3035
+
3036
+ // src/bridge/index.ts
3037
+ init_constants();
3038
+ init_errors();
3039
+
3040
+ // src/identity/token-store.ts
3041
+ init_constants();
3042
+ init_errors();
3043
+ import { readFile as readFile4, writeFile as writeFile5, unlink as unlink2, readdir, mkdir as mkdir4, chmod as chmod2 } from "fs/promises";
3044
+ import { join as join8 } from "path";
3045
+ function createTokenStore(home) {
3046
+ const tokenDir = join8(home, OAUTH_TOKEN_DIR);
3047
+ async function ensureDir() {
3048
+ await mkdir4(tokenDir, { recursive: true, mode: 448 });
3049
+ }
3050
+ function tokenPath(name) {
3051
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
3052
+ return join8(tokenDir, `${safeName}.json`);
3053
+ }
3054
+ return {
3055
+ async save(name, token) {
3056
+ await ensureDir();
3057
+ const path = tokenPath(name);
3058
+ await writeFile5(path, JSON.stringify(token), { mode: OAUTH_TOKEN_FILE_MODE });
3059
+ await chmod2(path, OAUTH_TOKEN_FILE_MODE);
3060
+ },
3061
+ async load(name) {
3062
+ const path = tokenPath(name);
3063
+ try {
3064
+ const data = await readFile4(path, "utf-8");
3065
+ return JSON.parse(data);
3066
+ } catch (err) {
3067
+ if (err.code === "ENOENT") {
3068
+ return null;
3069
+ }
3070
+ throw new OAuthError(`Failed to read token: ${err instanceof Error ? err.message : String(err)}`);
3071
+ }
3072
+ },
3073
+ async remove(name) {
3074
+ const path = tokenPath(name);
3075
+ try {
3076
+ await unlink2(path);
3077
+ } catch (err) {
3078
+ if (err.code === "ENOENT") {
3079
+ return;
3080
+ }
3081
+ throw new OAuthError(`Failed to remove token: ${err instanceof Error ? err.message : String(err)}`);
3082
+ }
3083
+ },
3084
+ async list() {
3085
+ try {
3086
+ const files = await readdir(tokenDir);
3087
+ return files.filter((f) => f.endsWith(".json")).map((f) => f.replace(/\.json$/, ""));
3088
+ } catch (err) {
3089
+ if (err.code === "ENOENT") {
3090
+ return [];
3091
+ }
3092
+ throw new OAuthError(`Failed to list tokens: ${err instanceof Error ? err.message : String(err)}`);
3093
+ }
3094
+ }
3095
+ };
3096
+ }
3097
+
3098
+ // src/bridge/index.ts
3099
+ async function startBridge(serverName, configPath, options) {
3100
+ const socketPath = options?.socketPath ?? DEFAULT_SOCKET_PATH;
3101
+ const keyPath = options?.keyPath ?? DEFAULT_DAEMON_KEY_PATH;
3102
+ const home = options?.home ?? DEFAULT_HOME;
3103
+ let bearerToken;
3104
+ try {
3105
+ const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_loader(), loader_exports));
3106
+ const bridgeConfig = await loadConfig2(configPath);
3107
+ if (bridgeConfig.auth.mode === "oauth") {
3108
+ const tokenStore = createTokenStore(home);
3109
+ const stored = await tokenStore.load(serverName) ?? await tokenStore.load("default");
3110
+ if (stored) {
3111
+ const now = Math.floor(Date.now() / 1e3);
3112
+ if (stored.expires_at > now) {
3113
+ bearerToken = stored.access_token;
3114
+ } else {
3115
+ process.stderr.write("OAuth token expired \u2014 run `mcp-guard auth login` to refresh\n");
3116
+ }
3117
+ }
3118
+ }
3119
+ } catch {
3120
+ }
3121
+ if (!await isDaemonRunning(socketPath)) {
3122
+ await autoStartDaemon(configPath, socketPath);
3123
+ }
3124
+ const socket = connect2(socketPath);
3125
+ await new Promise((resolve, reject) => {
3126
+ socket.on("connect", resolve);
3127
+ socket.on("error", (err) => reject(new BridgeError(`Cannot connect to daemon: ${err.message}`)));
3128
+ });
3129
+ await authenticateToDaemon(socket, keyPath);
3130
+ let stdinBuffer = Buffer.alloc(0);
3131
+ process.stdin.on("data", (chunk) => {
3132
+ stdinBuffer = Buffer.concat([stdinBuffer, chunk]);
3133
+ while (true) {
3134
+ const newline = stdinBuffer.indexOf(10);
3135
+ if (newline === -1) break;
3136
+ const line = stdinBuffer.subarray(0, newline).toString("utf-8").trim();
3137
+ stdinBuffer = stdinBuffer.subarray(newline + 1);
3138
+ if (line.length === 0) continue;
3139
+ try {
3140
+ const data = JSON.parse(line);
3141
+ if (bearerToken && data.params) {
3142
+ data.params = { ...data.params, _bearer_token: bearerToken };
3143
+ } else if (bearerToken && !data.params) {
3144
+ data.params = { _bearer_token: bearerToken };
3145
+ }
3146
+ const msg = JSON.stringify({ type: "mcp", server: serverName, data });
3147
+ const payload = Buffer.from(msg, "utf-8");
3148
+ const header = Buffer.alloc(4);
3149
+ header.writeUInt32BE(payload.length, 0);
3150
+ socket.write(Buffer.concat([header, payload]));
3151
+ } catch {
3152
+ }
3153
+ }
3154
+ });
3155
+ let socketBuffer = Buffer.alloc(0);
3156
+ socket.on("data", (chunk) => {
3157
+ socketBuffer = Buffer.concat([socketBuffer, chunk]);
3158
+ while (socketBuffer.length >= 4) {
3159
+ const length = socketBuffer.readUInt32BE(0);
3160
+ if (length > MAX_MESSAGE_SIZE) {
3161
+ socketBuffer = Buffer.alloc(0);
3162
+ return;
3163
+ }
3164
+ if (socketBuffer.length < 4 + length) break;
3165
+ const json = socketBuffer.subarray(4, 4 + length).toString("utf-8");
3166
+ socketBuffer = socketBuffer.subarray(4 + length);
3167
+ try {
3168
+ const msg = JSON.parse(json);
3169
+ if (msg.type === "mcp" && msg.data) {
3170
+ process.stdout.write(JSON.stringify(msg.data) + "\n");
3171
+ } else if (msg.type === "shutdown") {
3172
+ process.exit(1);
3173
+ } else if (msg.type === "error") {
3174
+ process.stderr.write(`Daemon error: ${JSON.stringify(msg)}
3175
+ `);
3176
+ }
3177
+ } catch {
3178
+ }
3179
+ }
3180
+ });
3181
+ process.stdin.on("end", () => {
3182
+ socket.destroy();
3183
+ process.exit(0);
3184
+ });
3185
+ socket.on("close", () => {
3186
+ process.exit(1);
3187
+ });
3188
+ socket.on("error", (err) => {
3189
+ process.stderr.write(`Bridge socket error: ${err.message}
3190
+ `);
3191
+ process.exit(1);
3192
+ });
3193
+ }
3194
+
3195
+ // src/audit/query.ts
3196
+ function queryAuditLogs(db, filters) {
3197
+ const store = createAuditStore(db);
3198
+ return store.query(filters);
3199
+ }
3200
+ function formatAuditRow(row) {
3201
+ const status = row.allowed ? "\x1B[32mALLOW\x1B[0m" : "\x1B[31mBLOCK\x1B[0m";
3202
+ const blockInfo = row.blocked_by ? ` [${row.blocked_by}: ${row.block_reason}]` : "";
3203
+ const tool = row.tool_or_resource ? ` \u2192 ${row.tool_or_resource}` : "";
3204
+ const latency = row.latency_ms !== null ? ` (${row.latency_ms.toFixed(1)}ms)` : "";
3205
+ return `${row.timestamp} ${status} ${row.identity_username}@${row.server} ${row.method}${tool}${blockInfo}${latency}`;
3206
+ }
3207
+
3208
+ // src/identity/oauth-flow.ts
3209
+ init_constants();
3210
+ init_errors();
3211
+ import * as oauth from "oauth4webapi";
3212
+ import { createServer as createServer3 } from "http";
3213
+ async function executeOAuthFlow(options) {
3214
+ const callbackPort = options.callbackPort ?? OAUTH_CALLBACK_PORT;
3215
+ const redirectUri = `http://127.0.0.1:${callbackPort}/callback`;
3216
+ const issuerUrl = new URL(options.issuer);
3217
+ const discoveryResponse = await oauth.discoveryRequest(issuerUrl);
3218
+ const authServer = await oauth.processDiscoveryResponse(issuerUrl, discoveryResponse);
3219
+ if (!authServer.authorization_endpoint) {
3220
+ throw new OAuthError("OAuth provider missing authorization_endpoint");
3221
+ }
3222
+ if (!authServer.token_endpoint) {
3223
+ throw new OAuthError("OAuth provider missing token_endpoint");
3224
+ }
3225
+ const codeVerifier = oauth.generateRandomCodeVerifier();
3226
+ const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
3227
+ const authUrl = new URL(authServer.authorization_endpoint);
3228
+ authUrl.searchParams.set("client_id", options.clientId);
3229
+ authUrl.searchParams.set("redirect_uri", redirectUri);
3230
+ authUrl.searchParams.set("response_type", "code");
3231
+ authUrl.searchParams.set("scope", options.scopes.join(" "));
3232
+ authUrl.searchParams.set("code_challenge", codeChallenge);
3233
+ authUrl.searchParams.set("code_challenge_method", "S256");
3234
+ const state = oauth.generateRandomState();
3235
+ authUrl.searchParams.set("state", state);
3236
+ const callbackParams = await waitForCallback(callbackPort, authUrl.toString());
3237
+ const client = { client_id: options.clientId };
3238
+ const validatedParams = oauth.validateAuthResponse(authServer, client, callbackParams, state);
3239
+ const clientAuth = options.clientSecret ? oauth.ClientSecretPost(options.clientSecret) : oauth.None();
3240
+ const tokenResponse = await oauth.authorizationCodeGrantRequest(
3241
+ authServer,
3242
+ client,
3243
+ clientAuth,
3244
+ validatedParams,
3245
+ redirectUri,
3246
+ codeVerifier
3247
+ );
3248
+ const result = await oauth.processAuthorizationCodeResponse(authServer, client, tokenResponse);
3249
+ const expiresIn = result.expires_in ?? 3600;
3250
+ const expiresAt = Math.floor(Date.now() / 1e3) + expiresIn;
3251
+ return {
3252
+ access_token: result.access_token,
3253
+ refresh_token: result.refresh_token,
3254
+ id_token: result.id_token,
3255
+ expires_at: expiresAt,
3256
+ scope: result.scope ?? options.scopes.join(" ")
3257
+ };
3258
+ }
3259
+ async function waitForCallback(port, authUrl) {
3260
+ return new Promise((resolve, reject) => {
3261
+ const server = createServer3((req, res) => {
3262
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
3263
+ if (url.pathname !== "/callback") {
3264
+ res.writeHead(404);
3265
+ res.end("Not found");
3266
+ return;
3267
+ }
3268
+ const error = url.searchParams.get("error");
3269
+ if (error) {
3270
+ const desc = url.searchParams.get("error_description") ?? error;
3271
+ res.writeHead(200, { "Content-Type": "text/html" });
3272
+ res.end("<html><body><h1>Authentication Failed</h1><p>You can close this window.</p></body></html>");
3273
+ clearTimeout(timeout);
3274
+ server.close();
3275
+ reject(new OAuthError(`OAuth authorization failed: ${desc}`));
3276
+ return;
3277
+ }
3278
+ res.writeHead(200, { "Content-Type": "text/html" });
3279
+ res.end("<html><body><h1>Authentication Successful</h1><p>You can close this window.</p></body></html>");
3280
+ clearTimeout(timeout);
3281
+ server.close();
3282
+ resolve(url.searchParams);
3283
+ });
3284
+ server.listen(port, "127.0.0.1", () => {
3285
+ openBrowser(authUrl).catch(() => {
3286
+ console.log(`
3287
+ Open this URL in your browser to authenticate:
3288
+
3289
+ ${authUrl}
3290
+ `);
3291
+ });
3292
+ });
3293
+ const timeout = setTimeout(() => {
3294
+ server.close();
3295
+ reject(new OAuthError("OAuth flow timed out \u2014 no authorization callback received"));
3296
+ }, OAUTH_CALLBACK_TIMEOUT);
3297
+ });
3298
+ }
3299
+ async function openBrowser(url) {
3300
+ const { execFile } = await import("child_process");
3301
+ const { promisify } = await import("util");
3302
+ const execFileAsync = promisify(execFile);
3303
+ const platform2 = process.platform;
3304
+ if (platform2 === "darwin") {
3305
+ await execFileAsync("open", [url]);
3306
+ } else if (platform2 === "linux") {
3307
+ await execFileAsync("xdg-open", [url]);
3308
+ } else if (platform2 === "win32") {
3309
+ await execFileAsync("cmd", ["/c", "start", "", url]);
3310
+ } else {
3311
+ throw new Error(`Unsupported platform: ${platform2}`);
3312
+ }
3313
+ }
3314
+
3315
+ // src/cli/init.ts
3316
+ init_schema();
3317
+ import { readFile as readFile5, writeFile as writeFile6, access } from "fs/promises";
3318
+ import { join as join9 } from "path";
3319
+ import { homedir as homedir2, platform } from "os";
3320
+ import yaml2 from "js-yaml";
3321
+ function getClientConfigs() {
3322
+ const home = homedir2();
3323
+ const os = platform();
3324
+ const configs = [];
3325
+ if (os === "darwin") {
3326
+ configs.push({
3327
+ name: "claude-desktop",
3328
+ paths: [join9(home, "Library/Application Support/Claude/claude_desktop_config.json")],
3329
+ serverKey: "mcpServers"
3330
+ });
3331
+ } else if (os === "win32") {
3332
+ const appData = process.env.APPDATA ?? join9(home, "AppData/Roaming");
3333
+ configs.push({
3334
+ name: "claude-desktop",
3335
+ paths: [join9(appData, "Claude/claude_desktop_config.json")],
3336
+ serverKey: "mcpServers"
3337
+ });
3338
+ }
3339
+ configs.push({
3340
+ name: "claude-code",
3341
+ paths: [
3342
+ join9(home, ".claude.json"),
3343
+ join9(home, ".config/claude/settings.json")
3344
+ ],
3345
+ serverKey: "mcpServers"
3346
+ });
3347
+ configs.push({
3348
+ name: "cursor",
3349
+ paths: [
3350
+ join9(home, ".cursor/mcp.json"),
3351
+ join9(home, ".config/cursor/mcp.json")
3352
+ ],
3353
+ serverKey: "mcpServers"
3354
+ });
3355
+ configs.push({
3356
+ name: "vscode",
3357
+ paths: [
3358
+ join9(home, ".vscode/mcp.json")
3359
+ ],
3360
+ serverKey: "servers"
3361
+ });
3362
+ configs.push({
3363
+ name: "windsurf",
3364
+ paths: [
3365
+ join9(home, ".codeium/windsurf/mcp_config.json")
3366
+ ],
3367
+ serverKey: "mcpServers"
3368
+ });
3369
+ return configs;
3370
+ }
3371
+ async function fileExists(path) {
3372
+ try {
3373
+ await access(path);
3374
+ return true;
3375
+ } catch {
3376
+ return false;
3377
+ }
3378
+ }
3379
+ function extractServers(data, serverKey, source) {
3380
+ const serversObj = data[serverKey];
3381
+ if (!serversObj || typeof serversObj !== "object") return [];
3382
+ const servers = [];
3383
+ for (const [name, config] of Object.entries(serversObj)) {
3384
+ if (!config || typeof config !== "object") continue;
3385
+ const command = typeof config.command === "string" ? config.command : void 0;
3386
+ if (!command) continue;
3387
+ const args = Array.isArray(config.args) ? config.args.filter((a) => typeof a === "string") : [];
3388
+ const env = {};
3389
+ if (config.env && typeof config.env === "object") {
3390
+ for (const [k, v] of Object.entries(config.env)) {
3391
+ if (typeof v === "string") {
3392
+ env[k] = `\${${k}}`;
3393
+ }
3394
+ }
3395
+ }
3396
+ servers.push({ name, command, args, env, source });
3397
+ }
3398
+ return servers;
3399
+ }
3400
+ function deduplicateServers(servers) {
3401
+ const seen = /* @__PURE__ */ new Map();
3402
+ for (const server of servers) {
3403
+ const envKey = Object.keys(server.env).sort().join(",");
3404
+ const key = `${server.command}:${server.args.join(",")}:${envKey}`;
3405
+ if (!seen.has(key)) {
3406
+ seen.set(key, server);
3407
+ }
3408
+ }
3409
+ return [...seen.values()];
3410
+ }
3411
+ function generateConfig(servers) {
3412
+ const serversObj = {};
3413
+ for (const server of servers) {
3414
+ const entry = {
3415
+ transport: "stdio",
3416
+ command: server.command,
3417
+ args: server.args
3418
+ };
3419
+ if (Object.keys(server.env).length > 0) {
3420
+ entry.env = server.env;
3421
+ }
3422
+ serversObj[server.name] = entry;
3423
+ }
3424
+ const config = {
3425
+ servers: serversObj,
3426
+ daemon: {
3427
+ log_level: "info"
3428
+ }
3429
+ };
3430
+ return yaml2.dump(config, { lineWidth: 120, quotingType: '"', forceQuotes: false });
3431
+ }
3432
+ function generateInstructions(servers) {
3433
+ const lines = [
3434
+ "",
3435
+ "To use MCP-Guard, update your MCP client config to route servers through the proxy:",
3436
+ ""
3437
+ ];
3438
+ const serverNames = [...new Set(servers.map((s) => s.name))];
3439
+ for (const name of serverNames) {
3440
+ lines.push(` "${name}": {`);
3441
+ lines.push(` "command": "mcp-guard",`);
3442
+ lines.push(` "args": ["connect", "--server", "${name}"]`);
3443
+ lines.push(` }`);
3444
+ lines.push("");
3445
+ }
3446
+ lines.push("The daemon auto-starts on first connection.");
3447
+ return lines.join("\n");
3448
+ }
3449
+ async function runInit(opts) {
3450
+ const clientConfigs = getClientConfigs();
3451
+ const toScan = opts.client ? clientConfigs.filter((c) => c.name === opts.client) : clientConfigs;
3452
+ if (opts.client && toScan.length === 0) {
3453
+ const validClients = clientConfigs.map((c) => c.name).join(", ");
3454
+ console.error(`Unknown client: ${opts.client}. Valid clients: ${validClients}`);
3455
+ process.exit(1);
3456
+ }
3457
+ const allServers = [];
3458
+ for (const client of toScan) {
3459
+ for (const configPath of client.paths) {
3460
+ if (!await fileExists(configPath)) continue;
3461
+ try {
3462
+ const content = await readFile5(configPath, "utf-8");
3463
+ const data = JSON.parse(content);
3464
+ const servers = extractServers(data, client.serverKey, client.name);
3465
+ if (servers.length > 0) {
3466
+ console.log(`Found ${servers.length} server(s) in ${client.name} (${configPath})`);
3467
+ allServers.push(...servers);
3468
+ }
3469
+ } catch (err) {
3470
+ console.warn(`Skipping ${configPath}: ${err instanceof SyntaxError ? "invalid JSON" : err}`);
3471
+ }
3472
+ }
3473
+ }
3474
+ if (allServers.length === 0) {
3475
+ console.log("No MCP server configurations found.");
3476
+ console.log("");
3477
+ console.log("Looked in:");
3478
+ for (const client of toScan) {
3479
+ for (const p of client.paths) {
3480
+ console.log(` ${client.name}: ${p}`);
3481
+ }
3482
+ }
3483
+ console.log("");
3484
+ console.log("You can create a config manually \u2014 see mcp-guard.example.yaml");
3485
+ return;
3486
+ }
3487
+ const deduplicated = deduplicateServers(allServers);
3488
+ const yamlContent = generateConfig(deduplicated);
3489
+ const parsed = yaml2.load(yamlContent);
3490
+ const validation = configSchema.safeParse(parsed);
3491
+ if (!validation.success) {
3492
+ console.error("Generated config failed validation (this is a bug):");
3493
+ for (const issue of validation.error.issues) {
3494
+ console.error(` ${issue.path.join(".")}: ${issue.message}`);
3495
+ }
3496
+ process.exit(1);
3497
+ }
3498
+ if (opts.dryRun) {
3499
+ console.log("---");
3500
+ process.stdout.write(yamlContent);
3501
+ } else {
3502
+ await writeFile6(opts.output, yamlContent, "utf-8");
3503
+ console.log(`Config written to ${opts.output}`);
3504
+ }
3505
+ console.log(generateInstructions(deduplicated));
3506
+ }
3507
+ function registerInitCommand(program2) {
3508
+ program2.command("init").description("Generate mcp-guard.yaml from existing MCP client configs").option("-o, --output <path>", "Output path", "mcp-guard.yaml").option("--dry-run", "Print config to stdout instead of writing").option("--client <name>", "Only scan a specific client (claude-desktop, claude-code, cursor, vscode, windsurf)").action(async (opts) => {
3509
+ try {
3510
+ await runInit(opts);
3511
+ } catch (err) {
3512
+ console.error(`Failed to initialize: ${err}`);
3513
+ process.exit(1);
3514
+ }
3515
+ });
3516
+ }
3517
+
3518
+ // src/cli.ts
3519
+ var program = new Command().name("mcp-guard").description("Security proxy for MCP servers").version("0.1.0");
3520
+ program.command("start").description("Start the MCP-Guard daemon").option("-c, --config <path>", "Path to config file").option("-d, --daemon", "Run in background (detached)").action(async (opts) => {
3521
+ if (opts.daemon) {
3522
+ const { fork: fork2 } = await import("child_process");
3523
+ const child = fork2(process.argv[1], ["start", "--config", opts.config ?? "mcp-guard.yaml"], {
3524
+ detached: true,
3525
+ stdio: "ignore"
3526
+ });
3527
+ child.unref();
3528
+ console.log(`Daemon started (PID: ${child.pid})`);
3529
+ process.exit(0);
3530
+ }
3531
+ try {
3532
+ const config = await loadConfig(opts.config);
3533
+ await startDaemon(config, opts.config);
3534
+ } catch (err) {
3535
+ console.error(`Failed to start daemon: ${err}`);
3536
+ process.exit(1);
3537
+ }
3538
+ });
3539
+ program.command("stop").description("Stop the running daemon").option("-c, --config <path>", "Path to config file").action(async (opts) => {
3540
+ try {
3541
+ const config = await loadConfig(opts.config);
3542
+ const pidFile = join10(config.daemon.home, "daemon.pid");
3543
+ const pidStr = await readFile6(pidFile, "utf-8");
3544
+ const pid = parseInt(pidStr.trim(), 10);
3545
+ process.kill(pid, "SIGTERM");
3546
+ console.log(`Sent SIGTERM to daemon (PID: ${pid})`);
3547
+ const maxWait = 1e4;
3548
+ const interval = 200;
3549
+ let waited = 0;
3550
+ while (waited < maxWait) {
3551
+ await new Promise((r) => setTimeout(r, interval));
3552
+ waited += interval;
3553
+ try {
3554
+ process.kill(pid, 0);
3555
+ } catch {
3556
+ console.log("Daemon stopped");
3557
+ return;
3558
+ }
3559
+ }
3560
+ console.log("Daemon did not stop in time \u2014 sending SIGKILL");
3561
+ process.kill(pid, "SIGKILL");
3562
+ try {
3563
+ await unlink3(pidFile);
3564
+ } catch {
3565
+ }
3566
+ } catch (err) {
3567
+ const code = err.code;
3568
+ if (code === "ENOENT") {
3569
+ console.log("Daemon is not running (no PID file)");
3570
+ } else if (code === "ESRCH") {
3571
+ console.log("Daemon process not found \u2014 cleaning up stale PID file");
3572
+ } else {
3573
+ console.error(`Failed to stop daemon: ${err}`);
3574
+ process.exit(1);
3575
+ }
3576
+ }
3577
+ });
3578
+ program.command("connect").description("Start a bridge to proxy an MCP server through the daemon").requiredOption("-s, --server <name>", "Server name from config").option("-c, --config <path>", "Path to config file").action(async (opts) => {
3579
+ try {
3580
+ const config = await loadConfig(opts.config);
3581
+ await startBridge(opts.server, opts.config, {
3582
+ socketPath: config.daemon.socket_path,
3583
+ keyPath: join10(config.daemon.home, "daemon.key"),
3584
+ home: config.daemon.home
3585
+ });
3586
+ } catch (err) {
3587
+ console.error(`Bridge failed: ${err}`);
3588
+ process.exit(1);
3589
+ }
3590
+ });
3591
+ program.command("status").description("Show daemon status").option("-c, --config <path>", "Path to config file").action(async (opts) => {
3592
+ const config = await loadConfig(opts.config);
3593
+ const running = await isDaemonRunning(config.daemon.socket_path);
3594
+ if (running) {
3595
+ try {
3596
+ const pidFile = join10(config.daemon.home, "daemon.pid");
3597
+ const pidStr = await readFile6(pidFile, "utf-8");
3598
+ console.log(`Daemon is running (PID: ${pidStr.trim()})`);
3599
+ } catch {
3600
+ console.log("Daemon is running");
3601
+ }
3602
+ } else {
3603
+ console.log("Daemon is not running");
3604
+ }
3605
+ });
3606
+ program.command("health").description("Check daemon health (exit 0 if healthy, 1 if not)").option("-c, --config <path>", "Path to config file").action(async (opts) => {
3607
+ const config = await loadConfig(opts.config);
3608
+ try {
3609
+ const portPath = join10(config.daemon.home, "dashboard.port");
3610
+ let dashboardPort = config.daemon.dashboard_port;
3611
+ try {
3612
+ const portStr = await readFile6(portPath, "utf-8");
3613
+ dashboardPort = parseInt(portStr.trim(), 10);
3614
+ } catch {
3615
+ }
3616
+ const res = await fetch(`http://127.0.0.1:${dashboardPort}/healthz`);
3617
+ const health = await res.json();
3618
+ console.log(JSON.stringify(health, null, 2));
3619
+ process.exit(health.status === "healthy" ? 0 : 1);
3620
+ } catch {
3621
+ const running = await isDaemonRunning(config.daemon.socket_path);
3622
+ if (running) {
3623
+ console.log("Daemon is running (health endpoint unavailable)");
3624
+ process.exit(0);
3625
+ }
3626
+ console.log("Daemon is not running");
3627
+ process.exit(1);
3628
+ }
3629
+ });
3630
+ program.command("dashboard-token").description("Display the dashboard auth token").option("-c, --config <path>", "Path to config file").action(async (opts) => {
3631
+ const config = await loadConfig(opts.config);
3632
+ const tokenPath = join10(config.daemon.home, "dashboard.token");
3633
+ try {
3634
+ const token = await readFile6(tokenPath, "utf-8");
3635
+ console.log(token.trim());
3636
+ } catch {
3637
+ console.log("No dashboard token found \u2014 start the daemon first");
3638
+ process.exit(1);
3639
+ }
3640
+ });
3641
+ program.command("validate").description("Validate config file").option("-c, --config <path>", "Path to config file").action(async (opts) => {
3642
+ try {
3643
+ await loadConfig(opts.config);
3644
+ console.log("Config is valid");
3645
+ } catch (err) {
3646
+ console.error(`Config validation failed: ${err}`);
3647
+ process.exit(1);
3648
+ }
3649
+ });
3650
+ program.command("logs").description("Query audit logs").option("-c, --config <path>", "Path to config file").option("-s, --server <name>", "Filter by server name").option("--last <duration>", "Time range (e.g., 1h, 24h, 7d)").option("-u, --user <name>", "Filter by username").option("-m, --method <method>", "Filter by MCP method").option("-t, --type <type>", "Filter by type (allow/block)").option("-l, --limit <count>", "Maximum results", "100").action(
3651
+ async (opts) => {
3652
+ let db;
3653
+ try {
3654
+ const config = await loadConfig(opts.config);
3655
+ const dbPath = join10(config.daemon.home, "mcp-guard.db");
3656
+ const dbOptions = { path: dbPath };
3657
+ if (config.daemon.encryption.enabled) {
3658
+ const keyPath = join10(config.daemon.home, "daemon.key");
3659
+ try {
3660
+ const daemonKey = await readDaemonKey(keyPath);
3661
+ dbOptions.encryptionKey = deriveDbEncryptionKey(daemonKey);
3662
+ } catch {
3663
+ console.error("Cannot read daemon key for encrypted database \u2014 is the daemon running?");
3664
+ process.exit(1);
3665
+ }
3666
+ }
3667
+ db = openDatabase(dbOptions);
3668
+ const parsedLimit = parseInt(opts.limit, 10);
3669
+ const limit = Number.isNaN(parsedLimit) || parsedLimit < 1 ? 100 : Math.min(parsedLimit, 1e4);
3670
+ const rows = queryAuditLogs(db, {
3671
+ server: opts.server,
3672
+ last: opts.last,
3673
+ user: opts.user,
3674
+ method: opts.method,
3675
+ type: opts.type,
3676
+ limit
3677
+ });
3678
+ if (rows.length === 0) {
3679
+ console.log("No audit log entries found");
3680
+ } else {
3681
+ for (const row of rows) {
3682
+ console.log(formatAuditRow(row));
3683
+ }
3684
+ console.log(`
3685
+ ${rows.length} entries`);
3686
+ }
3687
+ } catch (err) {
3688
+ const code = err.code;
3689
+ if (code === "SQLITE_CANTOPEN" || code === "ENOENT") {
3690
+ console.log("No audit database found \u2014 is the daemon running?");
3691
+ } else {
3692
+ console.error(`Failed to query logs: ${err}`);
3693
+ process.exit(1);
3694
+ }
3695
+ } finally {
3696
+ db?.close();
3697
+ }
3698
+ }
3699
+ );
3700
+ var authCmd = program.command("auth").description("Manage OAuth authentication");
3701
+ authCmd.command("login").description("Authenticate with the configured OAuth provider").option("-c, --config <path>", "Path to config file").action(async (opts) => {
3702
+ try {
3703
+ const config = await loadConfig(opts.config);
3704
+ if (config.auth.mode !== "oauth" || !config.auth.oauth) {
3705
+ console.error('OAuth is not configured (auth.mode must be "oauth")');
3706
+ process.exit(1);
3707
+ }
3708
+ const result = await executeOAuthFlow({
3709
+ issuer: config.auth.oauth.issuer,
3710
+ clientId: config.auth.oauth.client_id,
3711
+ clientSecret: config.auth.oauth.client_secret,
3712
+ scopes: config.auth.oauth.scopes
3713
+ });
3714
+ const store = createTokenStore(config.daemon.home);
3715
+ await store.save("default", {
3716
+ access_token: result.access_token,
3717
+ refresh_token: result.refresh_token,
3718
+ id_token: result.id_token,
3719
+ expires_at: result.expires_at,
3720
+ scope: result.scope
3721
+ });
3722
+ console.log("Authentication successful \u2014 token stored");
3723
+ } catch (err) {
3724
+ console.error(`Authentication failed: ${err}`);
3725
+ process.exit(1);
3726
+ }
3727
+ });
3728
+ authCmd.command("status").description("Show current OAuth token status").option("-c, --config <path>", "Path to config file").action(async (opts) => {
3729
+ try {
3730
+ const config = await loadConfig(opts.config);
3731
+ const store = createTokenStore(config.daemon.home);
3732
+ const token = await store.load("default");
3733
+ if (!token) {
3734
+ console.log("Not authenticated \u2014 run `mcp-guard auth login`");
3735
+ return;
3736
+ }
3737
+ const now = Math.floor(Date.now() / 1e3);
3738
+ const expired = token.expires_at < now;
3739
+ const remaining = token.expires_at - now;
3740
+ console.log(`Status: ${expired ? "EXPIRED" : "Valid"}`);
3741
+ if (!expired) {
3742
+ console.log(`Expires in: ${Math.floor(remaining / 60)} minutes`);
3743
+ }
3744
+ console.log(`Scope: ${token.scope}`);
3745
+ try {
3746
+ const parts = token.access_token.split(".");
3747
+ if (parts.length === 3) {
3748
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
3749
+ console.log(`Subject: ${payload.sub ?? "unknown"}`);
3750
+ }
3751
+ } catch {
3752
+ }
3753
+ } catch (err) {
3754
+ console.error(`Failed to check status: ${err}`);
3755
+ process.exit(1);
3756
+ }
3757
+ });
3758
+ authCmd.command("logout").description("Remove stored OAuth tokens").option("-c, --config <path>", "Path to config file").action(async (opts) => {
3759
+ try {
3760
+ const config = await loadConfig(opts.config);
3761
+ const store = createTokenStore(config.daemon.home);
3762
+ await store.remove("default");
3763
+ console.log("Logged out \u2014 tokens removed");
3764
+ } catch (err) {
3765
+ console.error(`Failed to logout: ${err}`);
3766
+ process.exit(1);
3767
+ }
3768
+ });
3769
+ registerInitCommand(program);
3770
+ program.parse();
3771
+ //# sourceMappingURL=cli.js.map