mcpwall 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/LICENSE +105 -0
- package/README.md +230 -0
- package/dist/index.js +1106 -0
- package/package.json +57 -0
- package/rules/default.yml +114 -0
- package/rules/strict.yml +226 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { program } from "commander";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
|
|
7
|
+
// src/config/loader.ts
|
|
8
|
+
import { readFile } from "fs/promises";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { join, resolve, dirname } from "path";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import { parse as parseYaml } from "yaml";
|
|
13
|
+
|
|
14
|
+
// src/config/schema.ts
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
function hasReDoSRisk(pattern) {
|
|
17
|
+
const nestedQuantifier = /\([^)]*[+*][^)]*\)[+*{]/;
|
|
18
|
+
if (nestedQuantifier.test(pattern)) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
const quantifiedAlternation = /\([^)]*\|[^)]*\)[+*]{1,2}/;
|
|
22
|
+
if (quantifiedAlternation.test(pattern)) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
var validRegex = z.string().refine(
|
|
28
|
+
(val) => {
|
|
29
|
+
try {
|
|
30
|
+
new RegExp(val);
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
(val) => ({ message: `Invalid regex: "${val}"` })
|
|
37
|
+
).refine(
|
|
38
|
+
(val) => !hasReDoSRisk(val),
|
|
39
|
+
(val) => ({ message: `Potentially unsafe regex (ReDoS risk): "${val}" \u2014 avoid nested quantifiers like (a+)+` })
|
|
40
|
+
);
|
|
41
|
+
var secretPatternSchema = z.object({
|
|
42
|
+
name: z.string(),
|
|
43
|
+
regex: validRegex,
|
|
44
|
+
entropy_threshold: z.number().optional()
|
|
45
|
+
});
|
|
46
|
+
var argumentMatcherSchema = z.object({
|
|
47
|
+
pattern: z.string().optional(),
|
|
48
|
+
regex: validRegex.optional(),
|
|
49
|
+
not_under: z.string().optional(),
|
|
50
|
+
secrets: z.boolean().optional()
|
|
51
|
+
});
|
|
52
|
+
var ruleSchema = z.object({
|
|
53
|
+
name: z.string(),
|
|
54
|
+
match: z.object({
|
|
55
|
+
method: z.string().optional(),
|
|
56
|
+
tool: z.string().optional(),
|
|
57
|
+
arguments: z.record(z.string(), argumentMatcherSchema).optional()
|
|
58
|
+
}),
|
|
59
|
+
action: z.enum(["allow", "deny", "ask"]),
|
|
60
|
+
message: z.string().optional()
|
|
61
|
+
});
|
|
62
|
+
var configSchema = z.object({
|
|
63
|
+
version: z.number(),
|
|
64
|
+
settings: z.object({
|
|
65
|
+
log_dir: z.string(),
|
|
66
|
+
log_level: z.enum(["debug", "info", "warn", "error"]),
|
|
67
|
+
default_action: z.enum(["allow", "deny", "ask"]),
|
|
68
|
+
log_args: z.enum(["full", "none"]).optional()
|
|
69
|
+
}),
|
|
70
|
+
rules: z.array(ruleSchema),
|
|
71
|
+
secrets: z.object({
|
|
72
|
+
patterns: z.array(secretPatternSchema)
|
|
73
|
+
}).optional()
|
|
74
|
+
});
|
|
75
|
+
function parseConfig(raw) {
|
|
76
|
+
return configSchema.parse(raw);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/config/defaults.ts
|
|
80
|
+
var DEFAULT_SECRET_PATTERNS = [
|
|
81
|
+
{
|
|
82
|
+
name: "aws-access-key",
|
|
83
|
+
regex: "AKIA[0-9A-Z]{16}"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "aws-secret-key",
|
|
87
|
+
regex: "[A-Za-z0-9/+=]{40}",
|
|
88
|
+
entropy_threshold: 4.5
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "github-token",
|
|
92
|
+
regex: "(gh[ps]_[A-Za-z0-9_]{36,}|github_pat_[A-Za-z0-9_]{22,})"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "openai-key",
|
|
96
|
+
regex: "sk-[A-Za-z0-9]{20,}"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "anthropic-key",
|
|
100
|
+
regex: "sk-ant-[A-Za-z0-9-]{20,}"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "stripe-key",
|
|
104
|
+
regex: "(sk|pk|rk)_(test|live)_[A-Za-z0-9]{24,}"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "private-key-header",
|
|
108
|
+
regex: "-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "jwt-token",
|
|
112
|
+
regex: "eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "slack-token",
|
|
116
|
+
regex: "xox[bpoas]-[A-Za-z0-9-]+"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "database-url",
|
|
120
|
+
regex: "(postgres|mysql|mongodb|redis)://[^\\s]+"
|
|
121
|
+
}
|
|
122
|
+
];
|
|
123
|
+
var DEFAULT_RULES = [
|
|
124
|
+
{
|
|
125
|
+
name: "block-ssh-keys",
|
|
126
|
+
match: { method: "tools/call", tool: "*", arguments: { _any_value: { regex: "(\\.ssh/|id_rsa|id_ed25519)" } } },
|
|
127
|
+
action: "deny",
|
|
128
|
+
message: "Blocked: access to SSH keys"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "block-env-files",
|
|
132
|
+
match: { method: "tools/call", tool: "*", arguments: { _any_value: { regex: "(\\.env(\\.local|\\.prod|\\.dev)?$|\\.env/)" } } },
|
|
133
|
+
action: "deny",
|
|
134
|
+
message: "Blocked: access to .env files"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: "block-destructive-commands",
|
|
138
|
+
match: { method: "tools/call", tool: "*", arguments: { _any_value: { regex: "(rm\\s+-rf|rm\\s+-r\\s+/|mkfs\\.|dd\\s+if=)" } } },
|
|
139
|
+
action: "deny",
|
|
140
|
+
message: "Blocked: dangerous command"
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "block-secret-leakage",
|
|
144
|
+
match: { method: "tools/call", tool: "*", arguments: { _any_value: { secrets: true } } },
|
|
145
|
+
action: "deny",
|
|
146
|
+
message: "Blocked: detected secret in arguments"
|
|
147
|
+
}
|
|
148
|
+
];
|
|
149
|
+
var DEFAULT_CONFIG = {
|
|
150
|
+
version: 1,
|
|
151
|
+
settings: {
|
|
152
|
+
log_dir: "~/.mcpwall/logs",
|
|
153
|
+
log_level: "info",
|
|
154
|
+
default_action: "allow"
|
|
155
|
+
},
|
|
156
|
+
rules: DEFAULT_RULES,
|
|
157
|
+
secrets: {
|
|
158
|
+
patterns: DEFAULT_SECRET_PATTERNS
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// src/config/loader.ts
|
|
163
|
+
function resolveConfigPaths() {
|
|
164
|
+
const home = homedir();
|
|
165
|
+
const cwd = process.cwd();
|
|
166
|
+
return {
|
|
167
|
+
global: join(home, ".mcpwall", "config.yml"),
|
|
168
|
+
project: join(cwd, ".mcpwall.yml")
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
async function loadConfigFile(path2) {
|
|
172
|
+
try {
|
|
173
|
+
const contents = await readFile(path2, "utf-8");
|
|
174
|
+
const raw = parseYaml(contents);
|
|
175
|
+
const config = parseConfig(raw);
|
|
176
|
+
return config;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
179
|
+
if (error.code === "ENOENT") {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`Failed to load config from ${path2}: ${error.message}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function substituteVariables(value) {
|
|
186
|
+
return value.replace(/\$\{HOME\}/g, homedir()).replace(/\$\{PROJECT_DIR\}/g, process.cwd()).replace(/^~\//, join(homedir(), "/"));
|
|
187
|
+
}
|
|
188
|
+
function substituteInObject(obj) {
|
|
189
|
+
if (typeof obj === "string") {
|
|
190
|
+
return substituteVariables(obj);
|
|
191
|
+
}
|
|
192
|
+
if (Array.isArray(obj)) {
|
|
193
|
+
return obj.map(substituteInObject);
|
|
194
|
+
}
|
|
195
|
+
if (obj && typeof obj === "object") {
|
|
196
|
+
const result = {};
|
|
197
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
198
|
+
result[key] = substituteInObject(value);
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
return obj;
|
|
203
|
+
}
|
|
204
|
+
function mergeConfigs(global, project) {
|
|
205
|
+
return {
|
|
206
|
+
version: project.version,
|
|
207
|
+
settings: {
|
|
208
|
+
...global.settings,
|
|
209
|
+
...project.settings
|
|
210
|
+
},
|
|
211
|
+
// Project rules first (higher priority), then global rules
|
|
212
|
+
rules: [...project.rules, ...global.rules],
|
|
213
|
+
secrets: {
|
|
214
|
+
patterns: [
|
|
215
|
+
...project.secrets?.patterns || [],
|
|
216
|
+
...global.secrets?.patterns || []
|
|
217
|
+
]
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
async function loadBuiltinDefaultRules() {
|
|
222
|
+
try {
|
|
223
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
224
|
+
const packageRoot = dirname(dirname(thisFile));
|
|
225
|
+
const defaultRulesPath = join(packageRoot, "rules", "default.yml");
|
|
226
|
+
const config = await loadConfigFile(defaultRulesPath);
|
|
227
|
+
if (config) {
|
|
228
|
+
return config;
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
}
|
|
232
|
+
return DEFAULT_CONFIG;
|
|
233
|
+
}
|
|
234
|
+
async function loadConfig(configPath) {
|
|
235
|
+
if (configPath) {
|
|
236
|
+
const resolved = resolve(configPath);
|
|
237
|
+
const config2 = await loadConfigFile(resolved);
|
|
238
|
+
if (!config2) {
|
|
239
|
+
throw new Error(`Config file not found: ${resolved}`);
|
|
240
|
+
}
|
|
241
|
+
const substituted2 = substituteInObject(config2);
|
|
242
|
+
return substituted2;
|
|
243
|
+
}
|
|
244
|
+
const paths = resolveConfigPaths();
|
|
245
|
+
const [globalConfig, projectConfig] = await Promise.all([
|
|
246
|
+
loadConfigFile(paths.global),
|
|
247
|
+
loadConfigFile(paths.project)
|
|
248
|
+
]);
|
|
249
|
+
let config;
|
|
250
|
+
if (projectConfig && globalConfig) {
|
|
251
|
+
config = mergeConfigs(globalConfig, projectConfig);
|
|
252
|
+
} else if (projectConfig) {
|
|
253
|
+
config = projectConfig;
|
|
254
|
+
} else if (globalConfig) {
|
|
255
|
+
config = globalConfig;
|
|
256
|
+
} else {
|
|
257
|
+
config = await loadBuiltinDefaultRules();
|
|
258
|
+
}
|
|
259
|
+
const substituted = substituteInObject(config);
|
|
260
|
+
return substituted;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/engine/policy.ts
|
|
264
|
+
import { minimatch } from "minimatch";
|
|
265
|
+
|
|
266
|
+
// src/engine/secrets.ts
|
|
267
|
+
function compileSecretPatterns(patterns) {
|
|
268
|
+
return patterns.map((p) => ({
|
|
269
|
+
name: p.name,
|
|
270
|
+
regex: new RegExp(p.regex),
|
|
271
|
+
entropy_threshold: p.entropy_threshold
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
function scanForSecrets(value, patterns) {
|
|
275
|
+
for (const pattern of patterns) {
|
|
276
|
+
pattern.regex.lastIndex = 0;
|
|
277
|
+
const match = pattern.regex.exec(value);
|
|
278
|
+
if (match) {
|
|
279
|
+
if (pattern.entropy_threshold !== void 0) {
|
|
280
|
+
const matchedString = match[0];
|
|
281
|
+
const entropy = shannonEntropy(matchedString);
|
|
282
|
+
if (entropy < pattern.entropy_threshold) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return pattern.name;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
function deepScanObject(obj, patterns) {
|
|
292
|
+
if (typeof obj === "string") {
|
|
293
|
+
return scanForSecrets(obj, patterns);
|
|
294
|
+
}
|
|
295
|
+
if (Array.isArray(obj)) {
|
|
296
|
+
for (const item of obj) {
|
|
297
|
+
const found = deepScanObject(item, patterns);
|
|
298
|
+
if (found) {
|
|
299
|
+
return found;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (obj && typeof obj === "object") {
|
|
304
|
+
for (const value of Object.values(obj)) {
|
|
305
|
+
const found = deepScanObject(value, patterns);
|
|
306
|
+
if (found) {
|
|
307
|
+
return found;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
function shannonEntropy(str) {
|
|
314
|
+
if (str.length === 0) {
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
const freq = {};
|
|
318
|
+
for (const char of str) {
|
|
319
|
+
freq[char] = (freq[char] || 0) + 1;
|
|
320
|
+
}
|
|
321
|
+
const len = str.length;
|
|
322
|
+
let entropy = 0;
|
|
323
|
+
for (const count of Object.values(freq)) {
|
|
324
|
+
const p = count / len;
|
|
325
|
+
entropy -= p * Math.log2(p);
|
|
326
|
+
}
|
|
327
|
+
return entropy;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/engine/policy.ts
|
|
331
|
+
import { homedir as homedir2, platform } from "os";
|
|
332
|
+
import { resolve as resolvePath } from "path";
|
|
333
|
+
import { realpathSync } from "fs";
|
|
334
|
+
var CASE_INSENSITIVE_FS = platform() === "darwin" || platform() === "win32";
|
|
335
|
+
var PolicyEngine = class {
|
|
336
|
+
config;
|
|
337
|
+
compiledSecrets;
|
|
338
|
+
/** Pre-compiled regexes keyed by "ruleIndex:argKey" */
|
|
339
|
+
compiledMatchers = /* @__PURE__ */ new Map();
|
|
340
|
+
constructor(config) {
|
|
341
|
+
this.config = config;
|
|
342
|
+
const askRules = config.rules.filter((r) => r.action === "ask");
|
|
343
|
+
if (askRules.length > 0) {
|
|
344
|
+
process.stderr.write(`[mcpwall] Warning: ${askRules.length} rule(s) use action "ask" which is not yet interactive \u2014 these will ALLOW traffic (logged). Rules: ${askRules.map((r) => r.name).join(", ")}
|
|
345
|
+
`);
|
|
346
|
+
}
|
|
347
|
+
this.compiledSecrets = compileSecretPatterns(config.secrets?.patterns || []);
|
|
348
|
+
for (let i = 0; i < config.rules.length; i++) {
|
|
349
|
+
const rule = config.rules[i];
|
|
350
|
+
if (rule.match.arguments) {
|
|
351
|
+
for (const [key, matcher] of Object.entries(rule.match.arguments)) {
|
|
352
|
+
if (matcher.regex) {
|
|
353
|
+
this.compiledMatchers.set(`${i}:${key}`, {
|
|
354
|
+
regex: new RegExp(matcher.regex)
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Evaluate a JSON-RPC message against all rules
|
|
363
|
+
* Returns the action to take (allow/deny/ask) and the matched rule
|
|
364
|
+
*/
|
|
365
|
+
evaluate(msg) {
|
|
366
|
+
if (!msg.method) {
|
|
367
|
+
return { action: "allow", rule: null };
|
|
368
|
+
}
|
|
369
|
+
for (let i = 0; i < this.config.rules.length; i++) {
|
|
370
|
+
const rule = this.config.rules[i];
|
|
371
|
+
if (this.matchesRule(msg, rule, i)) {
|
|
372
|
+
return {
|
|
373
|
+
action: rule.action,
|
|
374
|
+
rule: rule.name,
|
|
375
|
+
message: rule.message
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
action: this.config.settings.default_action,
|
|
381
|
+
rule: null
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
matchesRule(msg, rule, ruleIndex) {
|
|
385
|
+
if (rule.match.method && rule.match.method !== msg.method) {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
if (!rule.match.method && (rule.match.tool || rule.match.arguments)) {
|
|
389
|
+
if (msg.method !== "tools/call") {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (msg.method === "tools/call") {
|
|
394
|
+
const params = msg.params;
|
|
395
|
+
if (!params || typeof params !== "object") {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
if (rule.match.tool) {
|
|
399
|
+
const toolName = params.name;
|
|
400
|
+
if (!toolName || typeof toolName !== "string") {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
if (!minimatch(toolName, rule.match.tool, { dot: true })) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (rule.match.arguments) {
|
|
408
|
+
const args = params.arguments;
|
|
409
|
+
if (!args || typeof args !== "object") {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
for (const [key, matcher] of Object.entries(rule.match.arguments)) {
|
|
413
|
+
const compiled = this.compiledMatchers.get(`${ruleIndex}:${key}`);
|
|
414
|
+
if (key === "_any_value") {
|
|
415
|
+
if (!this.matchesAnyValue(args, matcher, compiled)) {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
const value = args[key];
|
|
420
|
+
if (!this.matchesArgumentValue(value, matcher, compiled)) {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
matchesAnyValue(args, matcher, compiled) {
|
|
430
|
+
if (matcher.secrets) {
|
|
431
|
+
return deepScanObject(args, this.compiledSecrets) !== null;
|
|
432
|
+
}
|
|
433
|
+
return this.deepMatchAny(args, matcher, compiled);
|
|
434
|
+
}
|
|
435
|
+
deepMatchAny(obj, matcher, compiled) {
|
|
436
|
+
if (obj === null || obj === void 0) return false;
|
|
437
|
+
if (typeof obj === "string" || typeof obj === "number" || typeof obj === "boolean") {
|
|
438
|
+
return this.matchesArgumentValue(obj, matcher, compiled);
|
|
439
|
+
}
|
|
440
|
+
if (Array.isArray(obj)) {
|
|
441
|
+
for (const item of obj) {
|
|
442
|
+
if (this.deepMatchAny(item, matcher, compiled)) return true;
|
|
443
|
+
}
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
if (typeof obj === "object") {
|
|
447
|
+
for (const value of Object.values(obj)) {
|
|
448
|
+
if (this.deepMatchAny(value, matcher, compiled)) return true;
|
|
449
|
+
}
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
matchesArgumentValue(value, matcher, compiled) {
|
|
455
|
+
const strValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
456
|
+
if (matcher.pattern) {
|
|
457
|
+
if (minimatch(strValue, matcher.pattern)) {
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (matcher.regex && compiled?.regex) {
|
|
462
|
+
compiled.regex.lastIndex = 0;
|
|
463
|
+
if (compiled.regex.test(strValue)) {
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (matcher.not_under) {
|
|
468
|
+
const allowedPath = this.expandPath(matcher.not_under);
|
|
469
|
+
const normalizedAllowed = this.normalizePath(allowedPath);
|
|
470
|
+
const normalizedValue = this.normalizePath(strValue);
|
|
471
|
+
if (!normalizedValue.startsWith(normalizedAllowed)) {
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (matcher.secrets) {
|
|
476
|
+
return deepScanObject(value, this.compiledSecrets) !== null;
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
expandPath(path2) {
|
|
481
|
+
return path2.replace(/\$\{HOME\}/g, homedir2()).replace(/\$\{PROJECT_DIR\}/g, process.cwd()).replace(/^~\//, homedir2() + "/");
|
|
482
|
+
}
|
|
483
|
+
normalizePath(p) {
|
|
484
|
+
let normalized = p.replace(/^["']|["']$/g, "");
|
|
485
|
+
normalized = resolvePath(normalized);
|
|
486
|
+
try {
|
|
487
|
+
normalized = realpathSync(normalized);
|
|
488
|
+
} catch {
|
|
489
|
+
}
|
|
490
|
+
if (CASE_INSENSITIVE_FS) {
|
|
491
|
+
normalized = normalized.toLowerCase();
|
|
492
|
+
}
|
|
493
|
+
if (!normalized.endsWith("/")) {
|
|
494
|
+
normalized += "/";
|
|
495
|
+
}
|
|
496
|
+
return normalized;
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// src/logger.ts
|
|
501
|
+
import * as fs from "fs";
|
|
502
|
+
import * as path from "path";
|
|
503
|
+
var LOG_LEVELS = {
|
|
504
|
+
debug: 0,
|
|
505
|
+
info: 1,
|
|
506
|
+
warn: 2,
|
|
507
|
+
error: 3
|
|
508
|
+
};
|
|
509
|
+
var Logger = class {
|
|
510
|
+
logDir;
|
|
511
|
+
logLevel;
|
|
512
|
+
currentLogFile = null;
|
|
513
|
+
writeStream = null;
|
|
514
|
+
constructor(options) {
|
|
515
|
+
this.logDir = this.expandPath(options.logDir);
|
|
516
|
+
this.logLevel = LOG_LEVELS[options.logLevel] || LOG_LEVELS.info;
|
|
517
|
+
if (!fs.existsSync(this.logDir)) {
|
|
518
|
+
fs.mkdirSync(this.logDir, { recursive: true });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
log(entry) {
|
|
522
|
+
const level = this.getLogLevel(entry.action);
|
|
523
|
+
if (LOG_LEVELS[level] < this.logLevel) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const fullEntry = {
|
|
527
|
+
...entry,
|
|
528
|
+
ts: entry.ts || (/* @__PURE__ */ new Date()).toISOString()
|
|
529
|
+
};
|
|
530
|
+
this.writeToFile(fullEntry);
|
|
531
|
+
this.writeToStderr(fullEntry);
|
|
532
|
+
}
|
|
533
|
+
close() {
|
|
534
|
+
if (this.writeStream) {
|
|
535
|
+
this.writeStream.end();
|
|
536
|
+
this.writeStream = null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
writeToFile(entry) {
|
|
540
|
+
const logFile = this.getLogFilePath();
|
|
541
|
+
if (logFile !== this.currentLogFile) {
|
|
542
|
+
if (this.writeStream) {
|
|
543
|
+
this.writeStream.end();
|
|
544
|
+
}
|
|
545
|
+
this.currentLogFile = logFile;
|
|
546
|
+
this.writeStream = fs.createWriteStream(logFile, { flags: "a" });
|
|
547
|
+
this.writeStream.on("error", (err) => {
|
|
548
|
+
process.stderr.write(`[mcpwall] Log write error: ${err.message}
|
|
549
|
+
`);
|
|
550
|
+
this.writeStream = null;
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
const line = JSON.stringify(entry) + "\n";
|
|
554
|
+
if (this.writeStream) {
|
|
555
|
+
this.writeStream.write(line);
|
|
556
|
+
} else {
|
|
557
|
+
fs.appendFileSync(logFile, line);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
writeToStderr(entry) {
|
|
561
|
+
const timestamp = new Date(entry.ts).toISOString().substring(11, 19);
|
|
562
|
+
const action = this.formatAction(entry.action);
|
|
563
|
+
const method = entry.method || "unknown";
|
|
564
|
+
const tool = entry.tool ? ` ${entry.tool}` : "";
|
|
565
|
+
const rule = entry.rule ? ` [${entry.rule}]` : "";
|
|
566
|
+
const message = entry.message ? ` - ${entry.message}` : "";
|
|
567
|
+
const logLine = `[${timestamp}] ${action} ${method}${tool}${rule}${message}
|
|
568
|
+
`;
|
|
569
|
+
process.stderr.write(logLine);
|
|
570
|
+
}
|
|
571
|
+
getLogFilePath() {
|
|
572
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
573
|
+
return path.join(this.logDir, `${date}.jsonl`);
|
|
574
|
+
}
|
|
575
|
+
getLogLevel(action) {
|
|
576
|
+
switch (action) {
|
|
577
|
+
case "deny":
|
|
578
|
+
return "warn";
|
|
579
|
+
case "ask":
|
|
580
|
+
return "info";
|
|
581
|
+
case "allow":
|
|
582
|
+
return "debug";
|
|
583
|
+
default:
|
|
584
|
+
return "info";
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
formatAction(action) {
|
|
588
|
+
switch (action) {
|
|
589
|
+
case "allow":
|
|
590
|
+
return "\x1B[32mALLOW\x1B[0m";
|
|
591
|
+
// green
|
|
592
|
+
case "deny":
|
|
593
|
+
return "\x1B[31mDENY\x1B[0m";
|
|
594
|
+
// red
|
|
595
|
+
case "ask":
|
|
596
|
+
return "\x1B[33mASK\x1B[0m";
|
|
597
|
+
// yellow
|
|
598
|
+
default:
|
|
599
|
+
return action.toUpperCase();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
expandPath(p) {
|
|
603
|
+
if (p.startsWith("~/")) {
|
|
604
|
+
return path.join(process.env.HOME || "", p.slice(2));
|
|
605
|
+
}
|
|
606
|
+
return p;
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
// src/proxy.ts
|
|
611
|
+
import { spawn } from "child_process";
|
|
612
|
+
|
|
613
|
+
// src/parser.ts
|
|
614
|
+
function parseJsonRpcLineEx(line) {
|
|
615
|
+
const trimmed = line.trim();
|
|
616
|
+
if (!trimmed) {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
const parsed = JSON.parse(trimmed);
|
|
621
|
+
if (Array.isArray(parsed)) {
|
|
622
|
+
const messages = [];
|
|
623
|
+
for (const item of parsed) {
|
|
624
|
+
if (item && typeof item === "object" && item.jsonrpc === "2.0") {
|
|
625
|
+
messages.push(item);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (messages.length > 0) {
|
|
629
|
+
return { type: "batch", messages };
|
|
630
|
+
}
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
if (!parsed || typeof parsed !== "object" || parsed.jsonrpc !== "2.0") {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
return { type: "single", message: parsed };
|
|
637
|
+
} catch {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
var MAX_LINE_LENGTH = 10 * 1024 * 1024;
|
|
642
|
+
function createLineBuffer(onLine) {
|
|
643
|
+
let buffer = "";
|
|
644
|
+
return {
|
|
645
|
+
push(chunk) {
|
|
646
|
+
buffer += chunk;
|
|
647
|
+
if (buffer.length > MAX_LINE_LENGTH && !buffer.includes("\n")) {
|
|
648
|
+
process.stderr.write(`[mcpwall] Warning: discarding oversized message (${buffer.length} bytes)
|
|
649
|
+
`);
|
|
650
|
+
buffer = "";
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const lines = buffer.split("\n");
|
|
654
|
+
buffer = lines.pop() || "";
|
|
655
|
+
for (const line of lines) {
|
|
656
|
+
if (line) {
|
|
657
|
+
onLine(line);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
flush() {
|
|
662
|
+
if (buffer.trim()) {
|
|
663
|
+
onLine(buffer);
|
|
664
|
+
buffer = "";
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/proxy.ts
|
|
671
|
+
function createProxy(options) {
|
|
672
|
+
const { command, args, policyEngine, logger, logArgs = "none" } = options;
|
|
673
|
+
const child = spawn(command, args, {
|
|
674
|
+
stdio: ["pipe", "pipe", "inherit"]
|
|
675
|
+
});
|
|
676
|
+
let isShuttingDown = false;
|
|
677
|
+
child.on("error", (err) => {
|
|
678
|
+
process.stderr.write(`[mcpwall] Error spawning ${command}: ${err.message}
|
|
679
|
+
`);
|
|
680
|
+
process.exit(1);
|
|
681
|
+
});
|
|
682
|
+
function evaluateMessage(msg, decision) {
|
|
683
|
+
let toolName;
|
|
684
|
+
if (msg.method === "tools/call" && msg.params && typeof msg.params === "object") {
|
|
685
|
+
toolName = msg.params.name;
|
|
686
|
+
}
|
|
687
|
+
if (decision.action === "deny") {
|
|
688
|
+
logger.log({
|
|
689
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
690
|
+
method: msg.method,
|
|
691
|
+
tool: toolName,
|
|
692
|
+
args: "[REDACTED]",
|
|
693
|
+
action: "deny",
|
|
694
|
+
rule: decision.rule,
|
|
695
|
+
message: decision.message
|
|
696
|
+
});
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
const loggedArgs = logArgs === "full" && msg.method === "tools/call" ? msg.params?.arguments : void 0;
|
|
700
|
+
logger.log({
|
|
701
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
702
|
+
method: msg.method,
|
|
703
|
+
tool: toolName,
|
|
704
|
+
args: loggedArgs,
|
|
705
|
+
action: decision.action,
|
|
706
|
+
rule: decision.rule,
|
|
707
|
+
message: decision.message
|
|
708
|
+
});
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
function buildDenyError(msg, decision) {
|
|
712
|
+
return {
|
|
713
|
+
jsonrpc: "2.0",
|
|
714
|
+
id: msg.id,
|
|
715
|
+
error: {
|
|
716
|
+
code: -32600,
|
|
717
|
+
message: `[mcpwall] ${decision.message || "Blocked by policy"}`
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
const inboundBuffer = createLineBuffer((line) => {
|
|
722
|
+
try {
|
|
723
|
+
const result = parseJsonRpcLineEx(line);
|
|
724
|
+
if (!result) {
|
|
725
|
+
if (child.stdin && !child.stdin.destroyed) {
|
|
726
|
+
child.stdin.write(line + "\n");
|
|
727
|
+
}
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (result.type === "single") {
|
|
731
|
+
const msg = result.message;
|
|
732
|
+
const decision = policyEngine.evaluate(msg);
|
|
733
|
+
if (decision.action === "deny") {
|
|
734
|
+
if (msg.id !== void 0 && msg.id !== null) {
|
|
735
|
+
process.stdout.write(JSON.stringify(buildDenyError(msg, decision)) + "\n");
|
|
736
|
+
}
|
|
737
|
+
evaluateMessage(msg, decision);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
evaluateMessage(msg, decision);
|
|
741
|
+
if (child.stdin && !child.stdin.destroyed) {
|
|
742
|
+
child.stdin.write(line + "\n");
|
|
743
|
+
}
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (result.type === "batch") {
|
|
747
|
+
const forwarded = [];
|
|
748
|
+
const errors = [];
|
|
749
|
+
for (const msg of result.messages) {
|
|
750
|
+
const decision = policyEngine.evaluate(msg);
|
|
751
|
+
if (decision.action === "deny") {
|
|
752
|
+
evaluateMessage(msg, decision);
|
|
753
|
+
if (msg.id !== void 0 && msg.id !== null) {
|
|
754
|
+
errors.push(buildDenyError(msg, decision));
|
|
755
|
+
}
|
|
756
|
+
} else {
|
|
757
|
+
evaluateMessage(msg, decision);
|
|
758
|
+
forwarded.push(msg);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (errors.length === 1) {
|
|
762
|
+
process.stdout.write(JSON.stringify(errors[0]) + "\n");
|
|
763
|
+
} else if (errors.length > 1) {
|
|
764
|
+
process.stdout.write(JSON.stringify(errors) + "\n");
|
|
765
|
+
}
|
|
766
|
+
if (forwarded.length > 0 && child.stdin && !child.stdin.destroyed) {
|
|
767
|
+
if (forwarded.length === 1) {
|
|
768
|
+
child.stdin.write(JSON.stringify(forwarded[0]) + "\n");
|
|
769
|
+
} else {
|
|
770
|
+
child.stdin.write(JSON.stringify(forwarded) + "\n");
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
} catch (err) {
|
|
776
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
777
|
+
process.stderr.write(`[mcpwall] Error processing inbound message: ${message}
|
|
778
|
+
`);
|
|
779
|
+
if (child.stdin && !child.stdin.destroyed) {
|
|
780
|
+
child.stdin.write(line + "\n");
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
process.stdin.on("data", (chunk) => {
|
|
785
|
+
inboundBuffer.push(chunk.toString());
|
|
786
|
+
});
|
|
787
|
+
process.stdin.on("end", () => {
|
|
788
|
+
inboundBuffer.flush();
|
|
789
|
+
if (child.stdin && !child.stdin.destroyed) {
|
|
790
|
+
child.stdin.end();
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
const outboundBuffer = createLineBuffer((line) => {
|
|
794
|
+
try {
|
|
795
|
+
process.stdout.write(line + "\n");
|
|
796
|
+
const result = parseJsonRpcLineEx(line);
|
|
797
|
+
if (result?.type === "single") {
|
|
798
|
+
const msg = result.message;
|
|
799
|
+
if (msg.result !== void 0 || msg.error !== void 0) {
|
|
800
|
+
logger.log({
|
|
801
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
802
|
+
method: "response",
|
|
803
|
+
tool: void 0,
|
|
804
|
+
action: "allow",
|
|
805
|
+
rule: null,
|
|
806
|
+
message: msg.error ? `Error: ${msg.error.message}` : void 0
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
} catch (err) {
|
|
811
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
812
|
+
process.stderr.write(`[mcpwall] Error processing outbound message: ${message}
|
|
813
|
+
`);
|
|
814
|
+
try {
|
|
815
|
+
process.stdout.write(line + "\n");
|
|
816
|
+
} catch {
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
if (child.stdout) {
|
|
821
|
+
child.stdout.on("data", (chunk) => {
|
|
822
|
+
outboundBuffer.push(chunk.toString());
|
|
823
|
+
});
|
|
824
|
+
child.stdout.on("end", () => {
|
|
825
|
+
outboundBuffer.flush();
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
child.on("exit", (code, signal) => {
|
|
829
|
+
if (!isShuttingDown) {
|
|
830
|
+
isShuttingDown = true;
|
|
831
|
+
logger.close();
|
|
832
|
+
if (signal) {
|
|
833
|
+
process.stderr.write(`[mcpwall] Child process killed by signal ${signal}
|
|
834
|
+
`);
|
|
835
|
+
process.exit(1);
|
|
836
|
+
} else {
|
|
837
|
+
process.exit(code ?? 0);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
function handleSignal(sig) {
|
|
842
|
+
if (!isShuttingDown && child && !child.killed) {
|
|
843
|
+
isShuttingDown = true;
|
|
844
|
+
child.kill(sig);
|
|
845
|
+
setTimeout(() => {
|
|
846
|
+
if (!child.killed) {
|
|
847
|
+
child.kill("SIGKILL");
|
|
848
|
+
}
|
|
849
|
+
}, 5e3).unref();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
process.on("SIGINT", () => handleSignal("SIGINT"));
|
|
853
|
+
process.on("SIGTERM", () => handleSignal("SIGTERM"));
|
|
854
|
+
process.on("SIGHUP", () => handleSignal("SIGHUP"));
|
|
855
|
+
process.on("exit", () => {
|
|
856
|
+
if (!isShuttingDown) {
|
|
857
|
+
logger.close();
|
|
858
|
+
if (child && !child.killed) {
|
|
859
|
+
child.kill();
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
return child;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/cli/init.ts
|
|
867
|
+
import { readFile as readFile2, writeFile, mkdir } from "fs/promises";
|
|
868
|
+
import { existsSync as existsSync2 } from "fs";
|
|
869
|
+
import { homedir as homedir3 } from "os";
|
|
870
|
+
import { join as join3 } from "path";
|
|
871
|
+
import { createInterface } from "readline/promises";
|
|
872
|
+
import { stringify as yamlStringify } from "yaml";
|
|
873
|
+
async function runInit() {
|
|
874
|
+
process.stderr.write("\n\u{1F512} mcpwall setup wizard\n\n");
|
|
875
|
+
const rl = createInterface({
|
|
876
|
+
input: process.stdin,
|
|
877
|
+
output: process.stderr
|
|
878
|
+
});
|
|
879
|
+
try {
|
|
880
|
+
const configPaths = [
|
|
881
|
+
{ path: join3(homedir3(), ".claude.json"), name: "Claude Code global config" },
|
|
882
|
+
{ path: join3(process.cwd(), ".mcp.json"), name: "Claude Code project config" }
|
|
883
|
+
];
|
|
884
|
+
const foundConfigs = [];
|
|
885
|
+
for (const { path: path2, name } of configPaths) {
|
|
886
|
+
if (existsSync2(path2)) {
|
|
887
|
+
try {
|
|
888
|
+
const contents = await readFile2(path2, "utf-8");
|
|
889
|
+
const config = JSON.parse(contents);
|
|
890
|
+
if (config.mcpServers && Object.keys(config.mcpServers).length > 0) {
|
|
891
|
+
foundConfigs.push({ path: path2, name, config });
|
|
892
|
+
}
|
|
893
|
+
} catch (error) {
|
|
894
|
+
process.stderr.write(`[mcpwall] Warning: Could not parse ${path2}
|
|
895
|
+
`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (foundConfigs.length === 0) {
|
|
900
|
+
process.stderr.write("No MCP server configurations found.\n");
|
|
901
|
+
process.stderr.write("Looked for:\n");
|
|
902
|
+
process.stderr.write(" - ~/.claude.json\n");
|
|
903
|
+
process.stderr.write(" - ./.mcp.json\n\n");
|
|
904
|
+
process.stderr.write("You can manually configure mcpwall by wrapping your MCP server commands:\n");
|
|
905
|
+
process.stderr.write(" Original: npx -y @some/server\n");
|
|
906
|
+
process.stderr.write(" Wrapped: npx -y mcpwall -- npx -y @some/server\n\n");
|
|
907
|
+
rl.close();
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
process.stderr.write("Found MCP servers:\n\n");
|
|
911
|
+
const allServers = [];
|
|
912
|
+
for (const { path: path2, name, config } of foundConfigs) {
|
|
913
|
+
process.stderr.write(`In ${name} (${path2}):
|
|
914
|
+
`);
|
|
915
|
+
for (const [serverName, serverConfig] of Object.entries(config.mcpServers)) {
|
|
916
|
+
allServers.push({ configPath: path2, serverName, config: serverConfig });
|
|
917
|
+
const commandStr = `${serverConfig.command} ${serverConfig.args.join(" ")}`;
|
|
918
|
+
process.stderr.write(` [${allServers.length}] ${serverName}: ${commandStr}
|
|
919
|
+
`);
|
|
920
|
+
}
|
|
921
|
+
process.stderr.write("\n");
|
|
922
|
+
}
|
|
923
|
+
const answer = await rl.question(
|
|
924
|
+
'Enter server numbers to wrap (comma-separated, or "all" for all): '
|
|
925
|
+
);
|
|
926
|
+
let selectedIndices;
|
|
927
|
+
if (answer.trim().toLowerCase() === "all") {
|
|
928
|
+
selectedIndices = allServers.map((_, i) => i);
|
|
929
|
+
} else {
|
|
930
|
+
selectedIndices = answer.split(",").map((s) => parseInt(s.trim()) - 1).filter((i) => i >= 0 && i < allServers.length);
|
|
931
|
+
}
|
|
932
|
+
if (selectedIndices.length === 0) {
|
|
933
|
+
process.stderr.write("No servers selected. Exiting.\n");
|
|
934
|
+
rl.close();
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
process.stderr.write("\nWrapping servers with mcpwall...\n\n");
|
|
938
|
+
for (const index of selectedIndices) {
|
|
939
|
+
const { configPath, serverName, config } = allServers[index];
|
|
940
|
+
if (config.command === "npx" && config.args.includes("mcpwall")) {
|
|
941
|
+
process.stderr.write(` \u2713 ${serverName} is already wrapped
|
|
942
|
+
`);
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
const wrappedConfig = {
|
|
946
|
+
command: "npx",
|
|
947
|
+
args: ["-y", "mcpwall", "--", config.command, ...config.args],
|
|
948
|
+
env: config.env
|
|
949
|
+
};
|
|
950
|
+
const configContents = await readFile2(configPath, "utf-8");
|
|
951
|
+
const fullConfig = JSON.parse(configContents);
|
|
952
|
+
fullConfig.mcpServers[serverName] = wrappedConfig;
|
|
953
|
+
await writeFile(configPath, JSON.stringify(fullConfig, null, 2), "utf-8");
|
|
954
|
+
process.stderr.write(` \u2713 Wrapped ${serverName}
|
|
955
|
+
`);
|
|
956
|
+
}
|
|
957
|
+
const firewallConfigDir = join3(homedir3(), ".mcpwall");
|
|
958
|
+
const firewallConfigPath = join3(firewallConfigDir, "config.yml");
|
|
959
|
+
if (!existsSync2(firewallConfigPath)) {
|
|
960
|
+
process.stderr.write("\nCreating default firewall configuration...\n");
|
|
961
|
+
if (!existsSync2(firewallConfigDir)) {
|
|
962
|
+
await mkdir(firewallConfigDir, { recursive: true });
|
|
963
|
+
}
|
|
964
|
+
const yamlConfig = yamlStringify(DEFAULT_CONFIG);
|
|
965
|
+
await writeFile(firewallConfigPath, yamlConfig, "utf-8");
|
|
966
|
+
process.stderr.write(` \u2713 Created ${firewallConfigPath}
|
|
967
|
+
`);
|
|
968
|
+
} else {
|
|
969
|
+
process.stderr.write(`
|
|
970
|
+
\u2713 Config already exists: ${firewallConfigPath}
|
|
971
|
+
`);
|
|
972
|
+
}
|
|
973
|
+
process.stderr.write("\n\u2705 Setup complete!\n\n");
|
|
974
|
+
process.stderr.write("Your MCP servers are now protected by mcpwall.\n");
|
|
975
|
+
process.stderr.write(`View logs in: ${join3(homedir3(), ".mcpwall/logs")}
|
|
976
|
+
`);
|
|
977
|
+
process.stderr.write(`Edit rules in: ${firewallConfigPath}
|
|
978
|
+
|
|
979
|
+
`);
|
|
980
|
+
} finally {
|
|
981
|
+
rl.close();
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// src/cli/wrap.ts
|
|
986
|
+
import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
987
|
+
import { existsSync as existsSync3 } from "fs";
|
|
988
|
+
import { homedir as homedir4 } from "os";
|
|
989
|
+
import { join as join4 } from "path";
|
|
990
|
+
var CONFIG_PATHS = [
|
|
991
|
+
() => join4(homedir4(), ".claude.json"),
|
|
992
|
+
() => join4(process.cwd(), ".mcp.json")
|
|
993
|
+
];
|
|
994
|
+
async function runWrap(serverName) {
|
|
995
|
+
for (const getPath of CONFIG_PATHS) {
|
|
996
|
+
const configPath = getPath();
|
|
997
|
+
if (!existsSync3(configPath)) continue;
|
|
998
|
+
let raw;
|
|
999
|
+
let config;
|
|
1000
|
+
try {
|
|
1001
|
+
raw = await readFile3(configPath, "utf-8");
|
|
1002
|
+
config = JSON.parse(raw);
|
|
1003
|
+
} catch {
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
if (!config.mcpServers?.[serverName]) continue;
|
|
1007
|
+
const server = config.mcpServers[serverName];
|
|
1008
|
+
if (server.command === "npx" && server.args.includes("mcpwall")) {
|
|
1009
|
+
process.stderr.write(`[mcpwall] ${serverName} is already wrapped in ${configPath}
|
|
1010
|
+
`);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
config.mcpServers[serverName] = {
|
|
1014
|
+
command: "npx",
|
|
1015
|
+
args: ["-y", "mcpwall", "--", server.command, ...server.args],
|
|
1016
|
+
env: server.env
|
|
1017
|
+
};
|
|
1018
|
+
await writeFile2(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
1019
|
+
process.stderr.write(`[mcpwall] Wrapped ${serverName} in ${configPath}
|
|
1020
|
+
`);
|
|
1021
|
+
process.stderr.write(` ${server.command} ${server.args.join(" ")}
|
|
1022
|
+
`);
|
|
1023
|
+
process.stderr.write(` -> npx -y mcpwall -- ${server.command} ${server.args.join(" ")}
|
|
1024
|
+
`);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
process.stderr.write(`[mcpwall] Server "${serverName}" not found in any config file.
|
|
1028
|
+
`);
|
|
1029
|
+
process.stderr.write(`Searched:
|
|
1030
|
+
`);
|
|
1031
|
+
for (const getPath of CONFIG_PATHS) {
|
|
1032
|
+
const p = getPath();
|
|
1033
|
+
const exists = existsSync3(p) ? "" : " (not found)";
|
|
1034
|
+
process.stderr.write(` - ${p}${exists}
|
|
1035
|
+
`);
|
|
1036
|
+
}
|
|
1037
|
+
process.exit(1);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/index.ts
|
|
1041
|
+
var require2 = createRequire(import.meta.url);
|
|
1042
|
+
var { version } = require2("../package.json");
|
|
1043
|
+
var dashDashIndex = process.argv.indexOf("--");
|
|
1044
|
+
if (dashDashIndex !== -1) {
|
|
1045
|
+
const optionsArgs = process.argv.slice(0, dashDashIndex);
|
|
1046
|
+
const commandParts = process.argv.slice(dashDashIndex + 1);
|
|
1047
|
+
program.name("mcpwall").description("Deterministic security proxy for MCP tool calls").version(version).option("-c, --config <path>", "Path to config file").option("--log-level <level>", "Log level", "info").parse(optionsArgs);
|
|
1048
|
+
const options = program.opts();
|
|
1049
|
+
(async () => {
|
|
1050
|
+
try {
|
|
1051
|
+
if (commandParts.length === 0) {
|
|
1052
|
+
process.stderr.write("[mcpwall] Error: No command provided after --\n");
|
|
1053
|
+
process.stderr.write("Usage: mcpwall [options] -- <command> [args...]\n");
|
|
1054
|
+
process.exit(1);
|
|
1055
|
+
}
|
|
1056
|
+
const [command, ...args] = commandParts;
|
|
1057
|
+
const config = await loadConfig(options.config);
|
|
1058
|
+
if (options.logLevel) {
|
|
1059
|
+
config.settings.log_level = options.logLevel;
|
|
1060
|
+
}
|
|
1061
|
+
const policyEngine = new PolicyEngine(config);
|
|
1062
|
+
const logger = new Logger({
|
|
1063
|
+
logDir: config.settings.log_dir,
|
|
1064
|
+
logLevel: config.settings.log_level
|
|
1065
|
+
});
|
|
1066
|
+
createProxy({
|
|
1067
|
+
command,
|
|
1068
|
+
args,
|
|
1069
|
+
policyEngine,
|
|
1070
|
+
logger,
|
|
1071
|
+
logArgs: config.settings.log_args
|
|
1072
|
+
});
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1075
|
+
process.stderr.write(`[mcpwall] Error: ${message}
|
|
1076
|
+
`);
|
|
1077
|
+
process.exit(1);
|
|
1078
|
+
}
|
|
1079
|
+
})();
|
|
1080
|
+
} else {
|
|
1081
|
+
program.name("mcpwall").description("Deterministic security proxy for MCP tool calls").version(version);
|
|
1082
|
+
program.command("init").description("Interactive setup wizard to wrap existing MCP servers").action(async () => {
|
|
1083
|
+
try {
|
|
1084
|
+
await runInit();
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1087
|
+
process.stderr.write(`[mcpwall] Error: ${message}
|
|
1088
|
+
`);
|
|
1089
|
+
process.exit(1);
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
program.command("wrap <server-name>").description("Wrap a specific MCP server with mcpwall").action(async (serverName) => {
|
|
1093
|
+
try {
|
|
1094
|
+
await runWrap(serverName);
|
|
1095
|
+
} catch (err) {
|
|
1096
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1097
|
+
process.stderr.write(`[mcpwall] Error: ${message}
|
|
1098
|
+
`);
|
|
1099
|
+
process.exit(1);
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
program.option("-c, --config <path>", "Path to config file").option("--log-level <level>", "Log level", "info").argument("[command...]", "MCP server command to proxy (use -- before command)").action(() => {
|
|
1103
|
+
program.help();
|
|
1104
|
+
});
|
|
1105
|
+
program.parse();
|
|
1106
|
+
}
|