mcpwall 0.1.1 → 0.2.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/README.md CHANGED
@@ -1,8 +1,20 @@
1
+ <!-- mcp-name: io.github.behrensd/mcpwall -->
1
2
  # mcpwall
2
3
 
3
- Deterministic security proxy for [MCP](https://modelcontextprotocol.io) tool calls. Sits between your AI coding tool (Claude Code, Cursor, Windsurf) and MCP servers, intercepting every JSON-RPC message and enforcing YAML-defined policies — no LLM, no cloud, pure rule-based.
4
+ [![npm version](https://img.shields.io/npm/v/mcpwall)](https://www.npmjs.com/package/mcpwall)
5
+ [![CI](https://github.com/behrensd/mcp-firewall/actions/workflows/ci.yml/badge.svg)](https://github.com/behrensd/mcp-firewall/actions/workflows/ci.yml)
6
+ [![Node.js](https://img.shields.io/node/v/mcpwall)](https://nodejs.org)
7
+ [![License: FSL-1.1-ALv2](https://img.shields.io/badge/license-FSL--1.1--ALv2-blue)](./LICENSE)
4
8
 
5
- Think **iptables**, but for MCP tool calls.
9
+ <a href="https://glama.ai/mcp/servers/@behrensd/mcpwall"><img width="380" height="200" src="https://glama.ai/mcp/servers/@behrensd/mcpwall/badge" alt="mcpwall MCP server" /></a>
10
+
11
+ **iptables for MCP.** Blocks dangerous tool calls, scans for secret leakage, logs everything. No AI, no cloud, pure rules.
12
+
13
+ Sits between your AI coding tool (Claude Code, Cursor, Windsurf) and MCP servers, intercepting every JSON-RPC message and enforcing YAML-defined policies.
14
+
15
+ <p align="center">
16
+ <img src="demo/demo.gif" alt="mcpwall demo — blocking SSH key theft, pipe-to-shell, and secret leakage" width="700">
17
+ </p>
6
18
 
7
19
  ## Why
8
20
 
@@ -13,7 +25,8 @@ mcpwall adds one. It's a transparent stdio proxy that:
13
25
  - **Blocks sensitive file access** — `.ssh/`, `.env`, credentials, browser data
14
26
  - **Blocks dangerous commands** — `rm -rf`, pipe-to-shell, reverse shells
15
27
  - **Scans for secret leakage** — API keys, tokens, private keys (regex + entropy)
16
- - **Logs everything** — JSON Lines audit trail of every tool call
28
+ - **Scans server responses** — redacts leaked secrets, blocks prompt injection patterns, flags suspicious content
29
+ - **Logs everything** — JSON Lines audit trail of every tool call and response
17
30
  - **Uses zero AI** — deterministic rules, no LLM decisions, no cloud calls
18
31
 
19
32
  ## Install
@@ -66,7 +79,7 @@ That's it. mcpwall now sits in front of all your Docker MCP servers, logging eve
66
79
  npx mcpwall init
67
80
  ```
68
81
 
69
- This finds your existing MCP servers in `~/.claude.json` or `.mcp.json` and wraps them.
82
+ This finds your existing MCP servers in Claude Code, Cursor, Windsurf, and VS Code configs and wraps them.
70
83
 
71
84
  ### Option 3: Manual wrapping (any MCP server)
72
85
 
@@ -112,14 +125,27 @@ npx mcpwall wrap filesystem
112
125
  │ Claude Code │ ──────────▶ │ mcpwall │ ──────────▶ │ Real MCP │
113
126
  │ (MCP Host) │ ◀────────── │ (proxy) │ ◀────────── │ Server │
114
127
  └──────────────┘ └──────────────┘ └──────────────┘
128
+ ▲ Inbound rules │
129
+ │ (block dangerous requests) │
130
+ │ │
131
+ └── Outbound rules ◀───────────┘
132
+ (redact secrets, block injection)
115
133
  ```
116
134
 
117
- 1. Intercepts every JSON-RPC message on stdin/stdout
135
+ **Inbound** (requests):
136
+ 1. Intercepts every JSON-RPC request on stdin
118
137
  2. Parses `tools/call` requests — extracts tool name and arguments
119
138
  3. Walks rules top-to-bottom, first match wins
120
139
  4. **Allow**: forward to real server
121
140
  5. **Deny**: return JSON-RPC error to host, log, do not forward
122
- 6. Responses from server are forwarded back transparently
141
+
142
+ **Outbound** (responses):
143
+ 1. Parses every response from the server before forwarding
144
+ 2. Evaluates against `outbound_rules` (same first-match-wins semantics)
145
+ 3. **Allow**: forward unchanged
146
+ 4. **Deny**: replace response with blocked message
147
+ 5. **Redact**: surgically replace secrets with `[REDACTED BY MCPWALL]`, forward modified response
148
+ 6. **Log only**: forward unchanged, log the match
123
149
 
124
150
  ## Configuration
125
151
 
@@ -208,6 +234,55 @@ secrets:
208
234
 
209
235
  The special key `_any_value` applies the matcher to ALL argument values.
210
236
 
237
+ ### Outbound rules (response inspection)
238
+
239
+ Outbound rules scan server responses before they reach your AI client. Add them to the same config file:
240
+
241
+ ```yaml
242
+ outbound_rules:
243
+ # Redact secrets leaked in responses
244
+ - name: redact-secrets-in-responses
245
+ match:
246
+ secrets: true
247
+ action: redact
248
+ message: "Secret detected in server response"
249
+
250
+ # Block prompt injection patterns
251
+ - name: block-prompt-injection
252
+ match:
253
+ response_contains:
254
+ - "ignore previous instructions"
255
+ - "provide contents of ~/.ssh"
256
+ action: deny
257
+ message: "Prompt injection detected"
258
+
259
+ # Flag suspiciously large responses
260
+ - name: flag-large-responses
261
+ match:
262
+ response_size_exceeds: 102400
263
+ action: log_only
264
+ ```
265
+
266
+ #### Outbound matchers
267
+
268
+ | Matcher | Description |
269
+ |---------|-------------|
270
+ | `tool` | Glob pattern on the tool that produced the response (requires request-response correlation) |
271
+ | `server` | Glob pattern on the server name |
272
+ | `secrets` | When `true`, scans response for secret patterns (uses same `secrets.patterns` config) |
273
+ | `response_contains` | Case-insensitive substring match against response text |
274
+ | `response_contains_regex` | Regex match against response text |
275
+ | `response_size_exceeds` | Byte size threshold for the serialized response |
276
+
277
+ #### Outbound actions
278
+
279
+ | Action | Behavior |
280
+ |--------|----------|
281
+ | `allow` | Forward response unchanged |
282
+ | `deny` | Replace response with `[BLOCKED BY MCPWALL]` message |
283
+ | `redact` | Surgically replace matched secrets with `[REDACTED BY MCPWALL]`, forward modified response |
284
+ | `log_only` | Forward unchanged, log the match |
285
+
211
286
  ### Built-in rule packs
212
287
 
213
288
  - `rules/default.yml` — sensible defaults (blocks SSH, .env, credentials, dangerous commands, secrets)
@@ -237,7 +312,7 @@ All tool calls are logged by default — both allowed and denied. Logs are writt
237
312
 
238
313
  ```json
239
314
  {"ts":"2026-02-16T14:30:00Z","method":"tools/call","tool":"read_file","action":"allow","rule":null}
240
- {"ts":"2026-02-16T14:30:05Z","method":"tools/call","tool":"read_file","action":"deny","rule":"block-ssh-keys","message":"Blocked: access to SSH keys"}
315
+ {"ts":"2026-02-16T14:30:05Z","method":"tools/call","tool":"read_file","args":"[REDACTED]","action":"deny","rule":"block-ssh-keys","message":"Blocked: access to SSH keys"}
241
316
  ```
242
317
 
243
318
  Denied entries have args redacted to prevent secrets from leaking into logs.
@@ -246,8 +321,11 @@ mcpwall also prints color-coded output to stderr so you can see decisions in rea
246
321
 
247
322
  ## Security Design
248
323
 
324
+ - **Bidirectional scanning**: Both inbound requests and outbound responses are evaluated against rules
249
325
  - **Fail closed on invalid config**: Bad regex in a rule crashes at startup, never silently passes traffic
326
+ - **Fail open on outbound errors**: If response parsing fails, the raw response is forwarded (never blocks legitimate traffic)
250
327
  - **Args redacted on deny**: Blocked tool call arguments are never written to logs
328
+ - **Surgical redaction**: Secrets in responses are replaced in-place, preserving the JSON-RPC response structure
251
329
  - **Path traversal defense**: `not_under` matcher uses `path.resolve()` to prevent `../` bypass
252
330
  - **Pre-compiled regexes**: All patterns compiled once at startup for consistent performance
253
331
  - **No network**: Zero cloud calls, zero telemetry, runs entirely local
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);
@@ -878,7 +1213,12 @@ async function runInit() {
878
1213
  try {
879
1214
  const configPaths = [
880
1215
  { path: join3(homedir3(), ".claude.json"), name: "Claude Code global config" },
881
- { path: join3(process.cwd(), ".mcp.json"), name: "Claude Code project config" }
1216
+ { path: join3(process.cwd(), ".mcp.json"), name: "Claude Code project config" },
1217
+ { path: join3(homedir3(), ".cursor", "mcp.json"), name: "Cursor global config" },
1218
+ { path: join3(process.cwd(), ".cursor", "mcp.json"), name: "Cursor project config" },
1219
+ { path: join3(homedir3(), ".config", "windsurf", "mcp.json"), name: "Windsurf config" },
1220
+ { path: join3(homedir3(), ".vscode", "mcp.json"), name: "VS Code global config" },
1221
+ { path: join3(process.cwd(), ".vscode", "mcp.json"), name: "VS Code project config" }
882
1222
  ];
883
1223
  const foundConfigs = [];
884
1224
  for (const { path: path2, name } of configPaths) {
@@ -898,8 +1238,11 @@ async function runInit() {
898
1238
  if (foundConfigs.length === 0) {
899
1239
  process.stderr.write("No MCP server configurations found.\n");
900
1240
  process.stderr.write("Looked for:\n");
901
- process.stderr.write(" - ~/.claude.json\n");
902
- process.stderr.write(" - ./.mcp.json\n\n");
1241
+ for (const { path: path2, name } of configPaths) {
1242
+ process.stderr.write(` - ${path2} (${name})
1243
+ `);
1244
+ }
1245
+ process.stderr.write("\n");
903
1246
  process.stderr.write("You can manually configure mcpwall by wrapping your MCP server commands:\n");
904
1247
  process.stderr.write(" Original: npx -y @some/server\n");
905
1248
  process.stderr.write(" Wrapped: npx -y mcpwall -- npx -y @some/server\n\n");
@@ -988,7 +1331,12 @@ import { homedir as homedir4 } from "os";
988
1331
  import { join as join4 } from "path";
989
1332
  var CONFIG_PATHS = [
990
1333
  () => join4(homedir4(), ".claude.json"),
991
- () => join4(process.cwd(), ".mcp.json")
1334
+ () => join4(process.cwd(), ".mcp.json"),
1335
+ () => join4(homedir4(), ".cursor", "mcp.json"),
1336
+ () => join4(process.cwd(), ".cursor", "mcp.json"),
1337
+ () => join4(homedir4(), ".config", "windsurf", "mcp.json"),
1338
+ () => join4(homedir4(), ".vscode", "mcp.json"),
1339
+ () => join4(process.cwd(), ".vscode", "mcp.json")
992
1340
  ];
993
1341
  async function runWrap(serverName) {
994
1342
  for (const getPath of CONFIG_PATHS) {
@@ -1058,6 +1406,7 @@ if (dashDashIndex !== -1) {
1058
1406
  config.settings.log_level = options.logLevel;
1059
1407
  }
1060
1408
  const policyEngine = new PolicyEngine(config);
1409
+ const outboundPolicyEngine = config.outbound_rules?.length ? new OutboundPolicyEngine(config) : void 0;
1061
1410
  const logger = new Logger({
1062
1411
  logDir: config.settings.log_dir,
1063
1412
  logLevel: config.settings.log_level
@@ -1067,7 +1416,9 @@ if (dashDashIndex !== -1) {
1067
1416
  args,
1068
1417
  policyEngine,
1069
1418
  logger,
1070
- logArgs: config.settings.log_args
1419
+ logArgs: config.settings.log_args,
1420
+ outboundPolicyEngine,
1421
+ logRedacted: config.settings.log_redacted
1071
1422
  });
1072
1423
  } catch (err) {
1073
1424
  const message = err instanceof Error ? err.message : String(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpwall",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Deterministic security proxy for MCP tool calls — iptables for MCP. Blocks dangerous tool calls, scans for secret leakage, logs everything. No AI, no cloud, pure rules.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,8 @@
34
34
  "type": "git",
35
35
  "url": "git+https://github.com/behrensd/mcp-firewall.git"
36
36
  },
37
- "homepage": "https://github.com/behrensd/mcp-firewall",
37
+ "mcpName": "io.github.behrensd/mcpwall",
38
+ "homepage": "https://mcpwall.dev",
38
39
  "bugs": {
39
40
  "url": "https://github.com/behrensd/mcp-firewall/issues"
40
41
  },
package/rules/default.yml CHANGED
@@ -89,6 +89,20 @@ rules:
89
89
  action: deny
90
90
  message: "Blocked: detected secret/API key in tool arguments"
91
91
 
92
+ # === OUTBOUND RULES (Response Inspection) ===
93
+ outbound_rules:
94
+ - name: redact-secrets-in-responses
95
+ match:
96
+ secrets: true
97
+ action: redact
98
+ message: "Secret detected in server response and redacted"
99
+
100
+ - name: flag-large-responses
101
+ match:
102
+ response_size_exceeds: 102400
103
+ action: log_only
104
+ message: "Response exceeds 100KB"
105
+
92
106
  secrets:
93
107
  patterns:
94
108
  - name: aws-access-key
package/rules/strict.yml CHANGED
@@ -196,6 +196,59 @@ rules:
196
196
 
197
197
  # Default: deny everything else (set in settings above)
198
198
 
199
+ # === OUTBOUND RULES (Response Inspection) ===
200
+ outbound_rules:
201
+ - name: redact-secrets-in-responses
202
+ match:
203
+ secrets: true
204
+ action: redact
205
+ message: "Secret detected in server response and redacted"
206
+
207
+ - name: block-prompt-injection-patterns
208
+ match:
209
+ response_contains:
210
+ - "ignore previous instructions"
211
+ - "ignore all previous instructions"
212
+ - "disregard previous instructions"
213
+ - "disregard your instructions"
214
+ - "forget your instructions"
215
+ - "override your instructions"
216
+ - "new instructions:"
217
+ - "system prompt:"
218
+ - "you are now"
219
+ - "act as if"
220
+ - "pretend you are"
221
+ - "provide contents of ~/.ssh"
222
+ - "provide contents of /etc/passwd"
223
+ - "read the file ~/.ssh"
224
+ - "output your system prompt"
225
+ - "reveal your instructions"
226
+ action: deny
227
+ message: "Prompt injection pattern detected in server response"
228
+
229
+ - name: flag-shell-patterns-in-responses
230
+ match:
231
+ response_contains_regex:
232
+ - "rm\\s+-rf\\s+/"
233
+ - "curl.*\\|.*bash"
234
+ - "wget.*\\|.*sh"
235
+ - "nc\\s+-[le].*\\d+"
236
+ action: log_only
237
+ message: "Shell command pattern detected in server response"
238
+
239
+ - name: flag-zero-width-chars
240
+ match:
241
+ response_contains_regex:
242
+ - "[\\u200B\\u200C\\u200D\\u2060\\uFEFF]"
243
+ action: log_only
244
+ message: "Zero-width Unicode characters detected in response (possible ATPA attack)"
245
+
246
+ - name: flag-large-responses
247
+ match:
248
+ response_size_exceeds: 51200
249
+ action: log_only
250
+ message: "Response exceeds 50KB"
251
+
199
252
  secrets:
200
253
  patterns:
201
254
  - name: aws-access-key