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 +85 -7
- package/dist/index.js +371 -20
- package/package.json +3 -2
- package/rules/default.yml +14 -0
- package/rules/strict.yml +53 -0
package/README.md
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
+
<!-- mcp-name: io.github.behrensd/mcpwall -->
|
|
1
2
|
# mcpwall
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
[](https://www.npmjs.com/package/mcpwall)
|
|
5
|
+
[](https://github.com/behrensd/mcp-firewall/actions/workflows/ci.yml)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](./LICENSE)
|
|
4
8
|
|
|
5
|
-
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
902
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|