mcpwall 0.1.2 → 0.3.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/index.js CHANGED
@@ -59,15 +59,35 @@ var ruleSchema = z.object({
59
59
  action: z.enum(["allow", "deny", "ask"]),
60
60
  message: z.string().optional()
61
61
  });
62
+ var outboundMatchSchema = z.object({
63
+ tool: z.string().optional(),
64
+ server: z.string().optional(),
65
+ secrets: z.boolean().optional(),
66
+ response_contains: z.array(z.string()).optional(),
67
+ response_contains_regex: z.array(validRegex).optional(),
68
+ response_size_exceeds: z.number().positive().optional()
69
+ }).refine(
70
+ (match) => Object.values(match).some((v) => v !== void 0),
71
+ { message: "Outbound rule must have at least one match field" }
72
+ );
73
+ var outboundRuleSchema = z.object({
74
+ name: z.string(),
75
+ match: outboundMatchSchema,
76
+ action: z.enum(["allow", "deny", "redact", "log_only"]),
77
+ message: z.string().optional()
78
+ });
62
79
  var configSchema = z.object({
63
80
  version: z.number(),
64
81
  settings: z.object({
65
82
  log_dir: z.string(),
66
83
  log_level: z.enum(["debug", "info", "warn", "error"]),
67
84
  default_action: z.enum(["allow", "deny", "ask"]),
68
- log_args: z.enum(["full", "none"]).optional()
85
+ log_args: z.enum(["full", "none"]).optional(),
86
+ outbound_default_action: z.enum(["allow", "deny", "redact", "log_only"]).optional(),
87
+ log_redacted: z.enum(["none", "hash", "full"]).optional()
69
88
  }),
70
89
  rules: z.array(ruleSchema),
90
+ outbound_rules: z.array(outboundRuleSchema).optional(),
71
91
  secrets: z.object({
72
92
  patterns: z.array(secretPatternSchema)
73
93
  }).optional()
@@ -146,6 +166,20 @@ var DEFAULT_RULES = [
146
166
  message: "Blocked: detected secret in arguments"
147
167
  }
148
168
  ];
169
+ var DEFAULT_OUTBOUND_RULES = [
170
+ {
171
+ name: "redact-secrets-in-responses",
172
+ match: { secrets: true },
173
+ action: "redact",
174
+ message: "Secret detected in server response and redacted"
175
+ },
176
+ {
177
+ name: "flag-large-responses",
178
+ match: { response_size_exceeds: 102400 },
179
+ action: "log_only",
180
+ message: "Response exceeds 100KB"
181
+ }
182
+ ];
149
183
  var DEFAULT_CONFIG = {
150
184
  version: 1,
151
185
  settings: {
@@ -154,6 +188,7 @@ var DEFAULT_CONFIG = {
154
188
  default_action: "allow"
155
189
  },
156
190
  rules: DEFAULT_RULES,
191
+ outbound_rules: DEFAULT_OUTBOUND_RULES,
157
192
  secrets: {
158
193
  patterns: DEFAULT_SECRET_PATTERNS
159
194
  }
@@ -210,6 +245,10 @@ function mergeConfigs(global, project) {
210
245
  },
211
246
  // Project rules first (higher priority), then global rules
212
247
  rules: [...project.rules, ...global.rules],
248
+ outbound_rules: [
249
+ ...project.outbound_rules || [],
250
+ ...global.outbound_rules || []
251
+ ].length > 0 ? [...project.outbound_rules || [], ...global.outbound_rules || []] : void 0,
213
252
  secrets: {
214
253
  patterns: [
215
254
  ...project.secrets?.patterns || [],
@@ -310,6 +349,52 @@ function deepScanObject(obj, patterns) {
310
349
  }
311
350
  return null;
312
351
  }
352
+ function redactSecrets(obj, patterns, marker = "[REDACTED BY MCPWALL]") {
353
+ const matchCounts = /* @__PURE__ */ new Map();
354
+ function redactString(str) {
355
+ let result = str;
356
+ for (const pattern of patterns) {
357
+ pattern.regex.lastIndex = 0;
358
+ let match;
359
+ const globalRegex = new RegExp(pattern.regex.source, "g");
360
+ while ((match = globalRegex.exec(result)) !== null) {
361
+ if (pattern.entropy_threshold !== void 0) {
362
+ const entropy = shannonEntropy(match[0]);
363
+ if (entropy < pattern.entropy_threshold) {
364
+ continue;
365
+ }
366
+ }
367
+ matchCounts.set(pattern.name, (matchCounts.get(pattern.name) || 0) + 1);
368
+ result = result.slice(0, match.index) + marker + result.slice(match.index + match[0].length);
369
+ globalRegex.lastIndex = match.index + marker.length;
370
+ }
371
+ }
372
+ return result;
373
+ }
374
+ function walk(node) {
375
+ if (typeof node === "string") {
376
+ return redactString(node);
377
+ }
378
+ if (Array.isArray(node)) {
379
+ return node.map(walk);
380
+ }
381
+ if (node && typeof node === "object") {
382
+ const result = {};
383
+ for (const [key, value] of Object.entries(node)) {
384
+ result[key] = walk(value);
385
+ }
386
+ return result;
387
+ }
388
+ return node;
389
+ }
390
+ const redacted = walk(obj);
391
+ const matches = Array.from(matchCounts.entries()).map(([pattern, count]) => ({ pattern, count }));
392
+ return {
393
+ redacted,
394
+ matches,
395
+ wasRedacted: matches.length > 0
396
+ };
397
+ }
313
398
  function shannonEntropy(str) {
314
399
  if (str.length === 0) {
315
400
  return 0;
@@ -497,6 +582,128 @@ var PolicyEngine = class {
497
582
  }
498
583
  };
499
584
 
585
+ // src/engine/outbound-policy.ts
586
+ import { minimatch as minimatch2 } from "minimatch";
587
+ var OutboundPolicyEngine = class {
588
+ rules;
589
+ defaultAction;
590
+ compiledSecrets;
591
+ compiledRegexes = /* @__PURE__ */ new Map();
592
+ constructor(config) {
593
+ this.rules = config.outbound_rules || [];
594
+ this.defaultAction = config.settings.outbound_default_action || "allow";
595
+ this.compiledSecrets = compileSecretPatterns(config.secrets?.patterns || []);
596
+ for (const rule of this.rules) {
597
+ if (rule.match.response_contains_regex) {
598
+ const compiled = rule.match.response_contains_regex.map((pattern) => ({
599
+ source: pattern,
600
+ regex: new RegExp(pattern, "i")
601
+ }));
602
+ this.compiledRegexes.set(rule.name, compiled);
603
+ }
604
+ }
605
+ }
606
+ /**
607
+ * Evaluate a response message against outbound rules.
608
+ * Returns the decision (action + matched rule).
609
+ */
610
+ evaluate(msg, toolName, serverName) {
611
+ for (const rule of this.rules) {
612
+ if (this.matchesRule(msg, rule, toolName, serverName)) {
613
+ return {
614
+ action: rule.action,
615
+ rule: rule.name,
616
+ message: rule.message
617
+ };
618
+ }
619
+ }
620
+ return {
621
+ action: this.defaultAction,
622
+ rule: null
623
+ };
624
+ }
625
+ /**
626
+ * Redact secrets from a response message.
627
+ * Returns the redacted message and match details.
628
+ */
629
+ redactResponse(msg) {
630
+ const redactionResult = redactSecrets(msg.result, this.compiledSecrets);
631
+ const redactedMsg = {
632
+ ...msg,
633
+ result: redactionResult.redacted
634
+ };
635
+ return { message: redactedMsg, result: redactionResult };
636
+ }
637
+ matchesRule(msg, rule, toolName, serverName) {
638
+ const match = rule.match;
639
+ if (match.tool) {
640
+ if (!toolName || !minimatch2(toolName, match.tool, { dot: true })) {
641
+ return false;
642
+ }
643
+ }
644
+ if (match.server) {
645
+ if (!serverName || !minimatch2(serverName, match.server, { dot: true })) {
646
+ return false;
647
+ }
648
+ }
649
+ if (match.secrets) {
650
+ const text = this.extractResponseText(msg);
651
+ if (!text) return false;
652
+ const hasSecrets = redactSecrets(msg.result, this.compiledSecrets).wasRedacted;
653
+ if (!hasSecrets) return false;
654
+ }
655
+ if (match.response_contains) {
656
+ const text = this.extractResponseText(msg);
657
+ if (!text) return false;
658
+ const lower = text.toLowerCase();
659
+ const found = match.response_contains.some((phrase) => lower.includes(phrase.toLowerCase()));
660
+ if (!found) return false;
661
+ }
662
+ if (match.response_contains_regex) {
663
+ const text = this.extractResponseText(msg);
664
+ if (!text) return false;
665
+ const compiled = this.compiledRegexes.get(rule.name) || [];
666
+ const found = compiled.some((c) => {
667
+ c.regex.lastIndex = 0;
668
+ return c.regex.test(text);
669
+ });
670
+ if (!found) return false;
671
+ }
672
+ if (match.response_size_exceeds !== void 0) {
673
+ const serialized = JSON.stringify(msg.result ?? msg.error ?? "");
674
+ const byteSize = Buffer.byteLength(serialized, "utf-8");
675
+ if (byteSize <= match.response_size_exceeds) return false;
676
+ }
677
+ return true;
678
+ }
679
+ /**
680
+ * Extract text content from an MCP response message.
681
+ * Handles MCP standard content array format: { content: [{ type: "text", text: "..." }] }
682
+ * Falls back to JSON.stringify for non-standard formats.
683
+ */
684
+ extractResponseText(msg) {
685
+ if (msg.result === void 0 && msg.error === void 0) {
686
+ return null;
687
+ }
688
+ if (msg.error) {
689
+ return msg.error.message || JSON.stringify(msg.error);
690
+ }
691
+ const result = msg.result;
692
+ if (result && Array.isArray(result.content)) {
693
+ const texts = [];
694
+ for (const block of result.content) {
695
+ if (block && typeof block === "object" && typeof block.text === "string") {
696
+ texts.push(block.text);
697
+ }
698
+ }
699
+ if (texts.length > 0) {
700
+ return texts.join("\n");
701
+ }
702
+ }
703
+ return JSON.stringify(result);
704
+ }
705
+ };
706
+
500
707
  // src/logger.ts
501
708
  import * as fs from "fs";
502
709
  import * as path from "path";
@@ -560,11 +767,12 @@ var Logger = class {
560
767
  writeToStderr(entry) {
561
768
  const timestamp = new Date(entry.ts).toISOString().substring(11, 19);
562
769
  const action = this.formatAction(entry.action);
770
+ const direction = entry.direction === "outbound" ? "outbound " : "";
563
771
  const method = entry.method || "unknown";
564
772
  const tool = entry.tool ? ` ${entry.tool}` : "";
565
773
  const rule = entry.rule ? ` [${entry.rule}]` : "";
566
774
  const message = entry.message ? ` - ${entry.message}` : "";
567
- const logLine = `[${timestamp}] ${action} ${method}${tool}${rule}${message}
775
+ const logLine = `[${timestamp}] ${action} ${direction}${method}${tool}${rule}${message}
568
776
  `;
569
777
  process.stderr.write(logLine);
570
778
  }
@@ -575,9 +783,11 @@ var Logger = class {
575
783
  getLogLevel(action) {
576
784
  switch (action) {
577
785
  case "deny":
786
+ case "redact":
578
787
  return "warn";
579
788
  case "ask":
580
789
  case "allow":
790
+ case "log_only":
581
791
  return "info";
582
792
  default:
583
793
  return "info";
@@ -594,6 +804,12 @@ var Logger = class {
594
804
  case "ask":
595
805
  return "\x1B[33mASK\x1B[0m";
596
806
  // yellow
807
+ case "redact":
808
+ return "\x1B[36mREDACT\x1B[0m";
809
+ // cyan
810
+ case "log_only":
811
+ return "\x1B[34mLOG\x1B[0m";
812
+ // blue
597
813
  default:
598
814
  return action.toUpperCase();
599
815
  }
@@ -668,7 +884,33 @@ function createLineBuffer(onLine) {
668
884
 
669
885
  // src/proxy.ts
670
886
  function createProxy(options) {
671
- const { command, args, policyEngine, logger, logArgs = "none" } = options;
887
+ const { command, args, policyEngine, logger, logArgs = "none", outboundPolicyEngine, logRedacted = "none", serverName } = options;
888
+ const pendingRequests = /* @__PURE__ */ new Map();
889
+ const REQUEST_TTL_MS = 6e4;
890
+ function trackRequest(msg) {
891
+ if (msg.id !== void 0 && msg.id !== null && msg.method === "tools/call") {
892
+ const params = msg.params;
893
+ pendingRequests.set(msg.id, {
894
+ tool: params?.name,
895
+ method: msg.method,
896
+ ts: Date.now()
897
+ });
898
+ }
899
+ }
900
+ function resolveRequest(id) {
901
+ if (id === void 0 || id === null) return void 0;
902
+ const ctx = pendingRequests.get(id);
903
+ if (ctx) {
904
+ pendingRequests.delete(id);
905
+ }
906
+ const now = Date.now();
907
+ for (const [key, val] of pendingRequests) {
908
+ if (now - val.ts > REQUEST_TTL_MS) {
909
+ pendingRequests.delete(key);
910
+ }
911
+ }
912
+ return ctx;
913
+ }
672
914
  const child = spawn(command, args, {
673
915
  stdio: ["pipe", "pipe", "inherit"]
674
916
  });
@@ -737,6 +979,7 @@ function createProxy(options) {
737
979
  return;
738
980
  }
739
981
  evaluateMessage(msg, decision);
982
+ trackRequest(msg);
740
983
  if (child.stdin && !child.stdin.destroyed) {
741
984
  child.stdin.write(line + "\n");
742
985
  }
@@ -754,6 +997,7 @@ function createProxy(options) {
754
997
  }
755
998
  } else {
756
999
  evaluateMessage(msg, decision);
1000
+ trackRequest(msg);
757
1001
  forwarded.push(msg);
758
1002
  }
759
1003
  }
@@ -789,22 +1033,113 @@ function createProxy(options) {
789
1033
  child.stdin.end();
790
1034
  }
791
1035
  });
1036
+ function evaluateOutbound(msg) {
1037
+ if (!outboundPolicyEngine) {
1038
+ process.stdout.write(JSON.stringify(msg) + "\n");
1039
+ if (msg.result !== void 0 || msg.error !== void 0) {
1040
+ logger.log({
1041
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1042
+ method: "response",
1043
+ tool: void 0,
1044
+ action: "allow",
1045
+ rule: null,
1046
+ direction: "outbound",
1047
+ message: msg.error ? `Error: ${msg.error.message}` : void 0
1048
+ });
1049
+ }
1050
+ return;
1051
+ }
1052
+ const ctx = resolveRequest(msg.id);
1053
+ const toolName = ctx?.tool;
1054
+ if (msg.result === void 0 && msg.error === void 0) {
1055
+ process.stdout.write(JSON.stringify(msg) + "\n");
1056
+ return;
1057
+ }
1058
+ const decision = outboundPolicyEngine.evaluate(msg, toolName, serverName);
1059
+ switch (decision.action) {
1060
+ case "allow": {
1061
+ process.stdout.write(JSON.stringify(msg) + "\n");
1062
+ logger.log({
1063
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1064
+ method: "response",
1065
+ tool: toolName,
1066
+ action: "allow",
1067
+ rule: decision.rule,
1068
+ direction: "outbound",
1069
+ message: msg.error ? `Error: ${msg.error.message}` : void 0
1070
+ });
1071
+ break;
1072
+ }
1073
+ case "deny": {
1074
+ const blocked = {
1075
+ jsonrpc: "2.0",
1076
+ id: msg.id,
1077
+ result: {
1078
+ content: [{
1079
+ type: "text",
1080
+ text: `[BLOCKED BY MCPWALL] ${decision.message || "Response blocked by outbound policy"}`
1081
+ }]
1082
+ }
1083
+ };
1084
+ process.stdout.write(JSON.stringify(blocked) + "\n");
1085
+ logger.log({
1086
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1087
+ method: "response",
1088
+ tool: toolName,
1089
+ action: "deny",
1090
+ rule: decision.rule,
1091
+ direction: "outbound",
1092
+ message: decision.message
1093
+ });
1094
+ break;
1095
+ }
1096
+ case "redact": {
1097
+ const { message: redactedMsg, result: redactionResult } = outboundPolicyEngine.redactResponse(msg);
1098
+ process.stdout.write(JSON.stringify(redactedMsg) + "\n");
1099
+ const patternNames = redactionResult.matches.map((m) => m.pattern);
1100
+ logger.log({
1101
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1102
+ method: "response",
1103
+ tool: toolName,
1104
+ action: "redact",
1105
+ rule: decision.rule,
1106
+ direction: "outbound",
1107
+ message: decision.message,
1108
+ redacted_patterns: patternNames.length > 0 ? patternNames : void 0
1109
+ });
1110
+ break;
1111
+ }
1112
+ case "log_only": {
1113
+ process.stdout.write(JSON.stringify(msg) + "\n");
1114
+ logger.log({
1115
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1116
+ method: "response",
1117
+ tool: toolName,
1118
+ action: "log_only",
1119
+ rule: decision.rule,
1120
+ direction: "outbound",
1121
+ message: decision.message
1122
+ });
1123
+ break;
1124
+ }
1125
+ }
1126
+ }
792
1127
  const outboundBuffer = createLineBuffer((line) => {
793
1128
  try {
794
- process.stdout.write(line + "\n");
795
1129
  const result = parseJsonRpcLineEx(line);
796
- if (result?.type === "single") {
797
- const msg = result.message;
798
- if (msg.result !== void 0 || msg.error !== void 0) {
799
- logger.log({
800
- ts: (/* @__PURE__ */ new Date()).toISOString(),
801
- method: "response",
802
- tool: void 0,
803
- action: "allow",
804
- rule: null,
805
- message: msg.error ? `Error: ${msg.error.message}` : void 0
806
- });
1130
+ if (!result) {
1131
+ process.stdout.write(line + "\n");
1132
+ return;
1133
+ }
1134
+ if (result.type === "single") {
1135
+ evaluateOutbound(result.message);
1136
+ return;
1137
+ }
1138
+ if (result.type === "batch") {
1139
+ for (const msg of result.messages) {
1140
+ evaluateOutbound(msg);
807
1141
  }
1142
+ return;
808
1143
  }
809
1144
  } catch (err) {
810
1145
  const message = err instanceof Error ? err.message : String(err);
@@ -863,13 +1198,80 @@ function createProxy(options) {
863
1198
  }
864
1199
 
865
1200
  // src/cli/init.ts
866
- import { readFile as readFile2, writeFile, mkdir } from "fs/promises";
1201
+ import { readFile as readFile2, writeFile, mkdir, readdir } from "fs/promises";
867
1202
  import { existsSync as existsSync2 } from "fs";
868
1203
  import { homedir as homedir3 } from "os";
869
- import { join as join3 } from "path";
1204
+ import { join as join3, resolve as resolve2, dirname as dirname2 } from "path";
1205
+ import { fileURLToPath as fileURLToPath2 } from "url";
870
1206
  import { createInterface } from "readline/promises";
871
1207
  import { stringify as yamlStringify } from "yaml";
872
- async function runInit() {
1208
+ function getProfilesDir() {
1209
+ const thisFile = fileURLToPath2(import.meta.url);
1210
+ const packageRoot = dirname2(dirname2(thisFile));
1211
+ return join3(packageRoot, "rules", "profiles");
1212
+ }
1213
+ async function listAvailableProfiles() {
1214
+ const profilesDir = getProfilesDir();
1215
+ try {
1216
+ const entries = await readdir(profilesDir);
1217
+ return entries.filter((f) => f.endsWith(".yaml")).map((f) => f.replace(/\.yaml$/, ""));
1218
+ } catch {
1219
+ return [];
1220
+ }
1221
+ }
1222
+ async function loadProfile(profile) {
1223
+ if (!/^[a-z0-9-]+$/.test(profile)) {
1224
+ const available = await listAvailableProfiles();
1225
+ process.stderr.write(`[mcpwall] Invalid profile name "${profile}". Names must be lowercase letters, numbers, and hyphens only.
1226
+ `);
1227
+ if (available.length > 0) {
1228
+ process.stderr.write(` Available profiles: ${available.join(", ")}
1229
+ `);
1230
+ }
1231
+ process.exit(1);
1232
+ }
1233
+ const profilesDir = getProfilesDir();
1234
+ const profilePath = join3(profilesDir, `${profile}.yaml`);
1235
+ const resolvedProfiles = resolve2(profilesDir);
1236
+ const resolvedProfile = resolve2(profilePath);
1237
+ if (!resolvedProfile.startsWith(resolvedProfiles + "/") && resolvedProfile !== resolvedProfiles) {
1238
+ process.stderr.write(`[mcpwall] Invalid profile name "${profile}".
1239
+ `);
1240
+ process.exit(1);
1241
+ }
1242
+ try {
1243
+ return await readFile2(profilePath, "utf-8");
1244
+ } catch (err) {
1245
+ if (err.code === "ENOENT") {
1246
+ const available = await listAvailableProfiles();
1247
+ const profileList = available.length > 0 ? available.join(", ") : "(none found)";
1248
+ process.stderr.write(`[mcpwall] Unknown profile "${profile}". Available profiles: ${profileList}
1249
+ `);
1250
+ process.exit(1);
1251
+ }
1252
+ throw err;
1253
+ }
1254
+ }
1255
+ async function runInit(profile) {
1256
+ if (profile) {
1257
+ if (!/^[a-z0-9-]+$/.test(profile)) {
1258
+ const available2 = await listAvailableProfiles();
1259
+ process.stderr.write(`[mcpwall] Invalid profile name "${profile}". Names must be lowercase letters, numbers, and hyphens only.
1260
+ `);
1261
+ if (available2.length > 0) {
1262
+ process.stderr.write(` Available profiles: ${available2.join(", ")}
1263
+ `);
1264
+ }
1265
+ process.exit(1);
1266
+ }
1267
+ const available = await listAvailableProfiles();
1268
+ if (!available.includes(profile)) {
1269
+ const profileList = available.length > 0 ? available.join(", ") : "(none found)";
1270
+ process.stderr.write(`[mcpwall] Unknown profile "${profile}". Available profiles: ${profileList}
1271
+ `);
1272
+ process.exit(1);
1273
+ }
1274
+ }
873
1275
  process.stderr.write("\n\u{1F512} mcpwall setup wizard\n\n");
874
1276
  const rl = createInterface({
875
1277
  input: process.stdin,
@@ -878,7 +1280,12 @@ async function runInit() {
878
1280
  try {
879
1281
  const configPaths = [
880
1282
  { path: join3(homedir3(), ".claude.json"), name: "Claude Code global config" },
881
- { path: join3(process.cwd(), ".mcp.json"), name: "Claude Code project config" }
1283
+ { path: join3(process.cwd(), ".mcp.json"), name: "Claude Code project config" },
1284
+ { path: join3(homedir3(), ".cursor", "mcp.json"), name: "Cursor global config" },
1285
+ { path: join3(process.cwd(), ".cursor", "mcp.json"), name: "Cursor project config" },
1286
+ { path: join3(homedir3(), ".config", "windsurf", "mcp.json"), name: "Windsurf config" },
1287
+ { path: join3(homedir3(), ".vscode", "mcp.json"), name: "VS Code global config" },
1288
+ { path: join3(process.cwd(), ".vscode", "mcp.json"), name: "VS Code project config" }
882
1289
  ];
883
1290
  const foundConfigs = [];
884
1291
  for (const { path: path2, name } of configPaths) {
@@ -898,8 +1305,11 @@ async function runInit() {
898
1305
  if (foundConfigs.length === 0) {
899
1306
  process.stderr.write("No MCP server configurations found.\n");
900
1307
  process.stderr.write("Looked for:\n");
901
- process.stderr.write(" - ~/.claude.json\n");
902
- process.stderr.write(" - ./.mcp.json\n\n");
1308
+ for (const { path: path2, name } of configPaths) {
1309
+ process.stderr.write(` - ${path2} (${name})
1310
+ `);
1311
+ }
1312
+ process.stderr.write("\n");
903
1313
  process.stderr.write("You can manually configure mcpwall by wrapping your MCP server commands:\n");
904
1314
  process.stderr.write(" Original: npx -y @some/server\n");
905
1315
  process.stderr.write(" Wrapped: npx -y mcpwall -- npx -y @some/server\n\n");
@@ -955,11 +1365,39 @@ async function runInit() {
955
1365
  }
956
1366
  const firewallConfigDir = join3(homedir3(), ".mcpwall");
957
1367
  const firewallConfigPath = join3(firewallConfigDir, "config.yml");
958
- if (!existsSync2(firewallConfigPath)) {
959
- process.stderr.write("\nCreating default firewall configuration...\n");
960
- if (!existsSync2(firewallConfigDir)) {
961
- await mkdir(firewallConfigDir, { recursive: true });
1368
+ if (!existsSync2(firewallConfigDir)) {
1369
+ await mkdir(firewallConfigDir, { recursive: true });
1370
+ }
1371
+ if (profile) {
1372
+ const profileContent = await loadProfile(profile);
1373
+ if (existsSync2(firewallConfigPath)) {
1374
+ const overwrite = await rl.question(
1375
+ `
1376
+ Config already exists: ${firewallConfigPath}
1377
+ Overwrite with "${profile}" profile? (y/n): `
1378
+ );
1379
+ if (overwrite.trim().toLowerCase() !== "y") {
1380
+ process.stderr.write(" Keeping existing config.\n");
1381
+ } else {
1382
+ await writeFile(firewallConfigPath, profileContent, "utf-8");
1383
+ process.stderr.write(`
1384
+ \u2713 Applied "${profile}" profile to ${firewallConfigPath}
1385
+ `);
1386
+ process.stderr.write(` Edit ${firewallConfigPath} to customize.
1387
+ `);
1388
+ }
1389
+ } else {
1390
+ process.stderr.write(`
1391
+ Creating firewall configuration from "${profile}" profile...
1392
+ `);
1393
+ await writeFile(firewallConfigPath, profileContent, "utf-8");
1394
+ process.stderr.write(` \u2713 Created ${firewallConfigPath} using "${profile}" profile
1395
+ `);
1396
+ process.stderr.write(` Edit ${firewallConfigPath} to customize.
1397
+ `);
962
1398
  }
1399
+ } else if (!existsSync2(firewallConfigPath)) {
1400
+ process.stderr.write("\nCreating default firewall configuration...\n");
963
1401
  const yamlConfig = yamlStringify(DEFAULT_CONFIG);
964
1402
  await writeFile(firewallConfigPath, yamlConfig, "utf-8");
965
1403
  process.stderr.write(` \u2713 Created ${firewallConfigPath}
@@ -988,7 +1426,12 @@ import { homedir as homedir4 } from "os";
988
1426
  import { join as join4 } from "path";
989
1427
  var CONFIG_PATHS = [
990
1428
  () => join4(homedir4(), ".claude.json"),
991
- () => join4(process.cwd(), ".mcp.json")
1429
+ () => join4(process.cwd(), ".mcp.json"),
1430
+ () => join4(homedir4(), ".cursor", "mcp.json"),
1431
+ () => join4(process.cwd(), ".cursor", "mcp.json"),
1432
+ () => join4(homedir4(), ".config", "windsurf", "mcp.json"),
1433
+ () => join4(homedir4(), ".vscode", "mcp.json"),
1434
+ () => join4(process.cwd(), ".vscode", "mcp.json")
992
1435
  ];
993
1436
  async function runWrap(serverName) {
994
1437
  for (const getPath of CONFIG_PATHS) {
@@ -1036,6 +1479,174 @@ async function runWrap(serverName) {
1036
1479
  process.exit(1);
1037
1480
  }
1038
1481
 
1482
+ // src/cli/check.ts
1483
+ var MAX_INPUT_BYTES = 10 * 1024 * 1024;
1484
+ function sanitizeForDisplay(value) {
1485
+ const raw = typeof value === "string" ? value : JSON.stringify(value);
1486
+ return raw.replace(/\x1b\[[0-9;]*[mGKHF]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
1487
+ }
1488
+ async function readStdin() {
1489
+ return new Promise((resolve3, reject) => {
1490
+ let data = "";
1491
+ process.stdin.setEncoding("utf-8");
1492
+ process.stdin.on("data", (chunk) => {
1493
+ data += chunk;
1494
+ });
1495
+ process.stdin.on("end", () => resolve3(data));
1496
+ process.stdin.on("error", reject);
1497
+ });
1498
+ }
1499
+ function printInboundDecision(decision, msg) {
1500
+ const method = sanitizeForDisplay(msg.method ?? "(unknown)");
1501
+ const params = msg.params;
1502
+ const toolName = params?.name ? sanitizeForDisplay(params.name) : "";
1503
+ const firstArg = params?.arguments ? sanitizeForDisplay(Object.values(params.arguments)[0] ?? "") : "";
1504
+ const contextStr = [method, toolName, firstArg].filter(Boolean).join(" ");
1505
+ if (decision.action === "allow") {
1506
+ process.stdout.write(`\u2713 ALLOW ${contextStr}
1507
+ `);
1508
+ if (decision.rule) {
1509
+ process.stdout.write(` Rule: ${sanitizeForDisplay(decision.rule)}
1510
+ `);
1511
+ } else {
1512
+ process.stdout.write(` No rule matched \u2014 default action: allow
1513
+ `);
1514
+ }
1515
+ } else if (decision.action === "deny") {
1516
+ process.stdout.write(`\u2717 DENY ${contextStr}
1517
+ `);
1518
+ if (decision.rule) {
1519
+ process.stdout.write(` Rule: ${sanitizeForDisplay(decision.rule)}
1520
+ `);
1521
+ }
1522
+ if (decision.message) {
1523
+ process.stdout.write(` ${sanitizeForDisplay(decision.message)}
1524
+ `);
1525
+ }
1526
+ } else {
1527
+ process.stdout.write(`? ASK ${contextStr}
1528
+ `);
1529
+ if (decision.rule) {
1530
+ process.stdout.write(` Rule: ${sanitizeForDisplay(decision.rule)}
1531
+ `);
1532
+ }
1533
+ }
1534
+ }
1535
+ function printOutboundDecision(decision, _msg) {
1536
+ if (decision.action === "allow") {
1537
+ process.stdout.write(`\u2713 ALLOW (response)
1538
+ `);
1539
+ if (decision.rule) {
1540
+ process.stdout.write(` Rule: ${sanitizeForDisplay(decision.rule)}
1541
+ `);
1542
+ } else {
1543
+ process.stdout.write(` No rule matched
1544
+ `);
1545
+ }
1546
+ } else if (decision.action === "deny") {
1547
+ process.stdout.write(`\u2717 DENY (response)
1548
+ `);
1549
+ if (decision.rule) {
1550
+ process.stdout.write(` Rule: ${sanitizeForDisplay(decision.rule)}
1551
+ `);
1552
+ }
1553
+ if (decision.message) {
1554
+ process.stdout.write(` ${sanitizeForDisplay(decision.message)}
1555
+ `);
1556
+ }
1557
+ } else if (decision.action === "redact") {
1558
+ process.stdout.write(`~ REDACT (response)
1559
+ `);
1560
+ if (decision.rule) {
1561
+ process.stdout.write(` Rule: ${sanitizeForDisplay(decision.rule)}
1562
+ `);
1563
+ }
1564
+ if (decision.message) {
1565
+ process.stdout.write(` ${sanitizeForDisplay(decision.message)}
1566
+ `);
1567
+ }
1568
+ } else if (decision.action === "log_only") {
1569
+ process.stdout.write(`! LOG (response)
1570
+ `);
1571
+ if (decision.rule) {
1572
+ process.stdout.write(` Rule: ${sanitizeForDisplay(decision.rule)}
1573
+ `);
1574
+ }
1575
+ if (decision.message) {
1576
+ process.stdout.write(` ${sanitizeForDisplay(decision.message)}
1577
+ `);
1578
+ }
1579
+ }
1580
+ }
1581
+ async function runCheck(inputStr, configPath) {
1582
+ if (!inputStr && process.stdin.isTTY) {
1583
+ process.stderr.write(`Usage: mcpwall check --input '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"/tmp/test.txt"}}}'
1584
+ `);
1585
+ process.stderr.write(` echo '{"jsonrpc":"2.0",...}' | mcpwall check
1586
+ `);
1587
+ process.exit(1);
1588
+ }
1589
+ const raw = inputStr ?? await readStdin();
1590
+ if (Buffer.byteLength(raw, "utf-8") > MAX_INPUT_BYTES) {
1591
+ process.stderr.write("[mcpwall] Error: input exceeds 10MB limit\n");
1592
+ process.exit(2);
1593
+ }
1594
+ const trimmed = raw.trim();
1595
+ if (!trimmed) {
1596
+ process.stderr.write("[mcpwall] Error: empty input\n");
1597
+ process.exit(2);
1598
+ }
1599
+ const parsed = parseJsonRpcLineEx(trimmed);
1600
+ if (!parsed) {
1601
+ process.stderr.write('[mcpwall] Error: invalid JSON-RPC message (must have jsonrpc: "2.0")\n');
1602
+ process.exit(2);
1603
+ }
1604
+ let config;
1605
+ try {
1606
+ config = await loadConfig(configPath);
1607
+ } catch (err) {
1608
+ const message = err instanceof Error ? err.message : String(err);
1609
+ process.stderr.write(`[mcpwall] Error loading config: ${message}
1610
+ `);
1611
+ process.exit(2);
1612
+ }
1613
+ const inboundEngine = new PolicyEngine(config);
1614
+ const outboundEngine = config.outbound_rules?.length ? new OutboundPolicyEngine(config) : void 0;
1615
+ const messages = parsed.type === "batch" ? parsed.messages : [parsed.message];
1616
+ let anyDenied = false;
1617
+ for (const msg of messages) {
1618
+ if (msg.method) {
1619
+ const decision = inboundEngine.evaluate(msg);
1620
+ printInboundDecision(decision, msg);
1621
+ if (decision.action === "deny") {
1622
+ anyDenied = true;
1623
+ }
1624
+ } else if (msg.result !== void 0 || msg.error !== void 0) {
1625
+ if (outboundEngine) {
1626
+ const decision = outboundEngine.evaluate(msg);
1627
+ printOutboundDecision(decision, msg);
1628
+ if (decision.action === "deny" || decision.action === "redact") {
1629
+ anyDenied = true;
1630
+ }
1631
+ } else {
1632
+ process.stdout.write(`\u2713 ALLOW (response)
1633
+ `);
1634
+ process.stdout.write(` No outbound rules configured
1635
+ `);
1636
+ }
1637
+ } else {
1638
+ const decision = inboundEngine.evaluate(msg);
1639
+ printInboundDecision(decision, msg);
1640
+ if (decision.action === "deny") {
1641
+ anyDenied = true;
1642
+ }
1643
+ }
1644
+ }
1645
+ if (anyDenied) {
1646
+ process.exit(1);
1647
+ }
1648
+ }
1649
+
1039
1650
  // src/index.ts
1040
1651
  var require2 = createRequire(import.meta.url);
1041
1652
  var { version } = require2("../package.json");
@@ -1058,6 +1669,7 @@ if (dashDashIndex !== -1) {
1058
1669
  config.settings.log_level = options.logLevel;
1059
1670
  }
1060
1671
  const policyEngine = new PolicyEngine(config);
1672
+ const outboundPolicyEngine = config.outbound_rules?.length ? new OutboundPolicyEngine(config) : void 0;
1061
1673
  const logger = new Logger({
1062
1674
  logDir: config.settings.log_dir,
1063
1675
  logLevel: config.settings.log_level
@@ -1067,7 +1679,9 @@ if (dashDashIndex !== -1) {
1067
1679
  args,
1068
1680
  policyEngine,
1069
1681
  logger,
1070
- logArgs: config.settings.log_args
1682
+ logArgs: config.settings.log_args,
1683
+ outboundPolicyEngine,
1684
+ logRedacted: config.settings.log_redacted
1071
1685
  });
1072
1686
  } catch (err) {
1073
1687
  const message = err instanceof Error ? err.message : String(err);
@@ -1078,9 +1692,20 @@ if (dashDashIndex !== -1) {
1078
1692
  })();
1079
1693
  } else {
1080
1694
  program.name("mcpwall").description("Deterministic security proxy for MCP tool calls").version(version);
1081
- program.command("init").description("Interactive setup wizard to wrap existing MCP servers").action(async () => {
1695
+ program.command("init").description("Interactive setup wizard to wrap existing MCP servers").option("--profile <name>", "use a named security profile (local-dev, company-laptop, strict)").action(async (options) => {
1696
+ try {
1697
+ await runInit(options.profile);
1698
+ } catch (err) {
1699
+ const message = err instanceof Error ? err.message : String(err);
1700
+ process.stderr.write(`[mcpwall] Error: ${message}
1701
+ `);
1702
+ process.exit(1);
1703
+ }
1704
+ });
1705
+ program.command("check").description("dry-run: test a JSON-RPC message against your rules without running the proxy").option("--input <json>", "JSON-RPC message as a string (reads from stdin if not provided)").action(async (options) => {
1706
+ const globalOptions = program.opts();
1082
1707
  try {
1083
- await runInit();
1708
+ await runCheck(options.input, globalOptions.config);
1084
1709
  } catch (err) {
1085
1710
  const message = err instanceof Error ? err.message : String(err);
1086
1711
  process.stderr.write(`[mcpwall] Error: ${message}