# Stopping a Filesystem MCP Server From Reading .env Files

> agentgateway's mcpAuthorization can't see a tool call's path argument. Here's the ExtMCP policy that blocks a filesystem MCP server's .env read instead.

- Canonical URL: https://webofmike.com/filesystem-mcp-env-guard/
- Author: Mike Moore (https://webofmike.com/about/)
- Published: 2026-10-16
- Last modified: 2026-10-16
- Tags: MCP, Security, AI Gateways, AI Agents
- Cite as: Mike Moore, "Stopping a Filesystem MCP Server From Reading .env Files", Web of Mike (webofmike.com), 2026-10-16. https://webofmike.com/filesystem-mcp-env-guard/


*Disclosure: I work at Solo.io, which created agentgateway and sells Solo Enterprise for agentgateway. The demos I cite are public repositories.*

A filesystem MCP server scoped to one directory will still read `.env` out of that directory if an agent asks for it by path. Root scoping stops an agent leaving the sandbox. It says nothing about which file inside the sandbox gets opened, and `.env`, `.ssh/id_rsa`, and `.git/config` all live inside the same directory the agent is supposed to be working in. A r/mcp post from 2026-10-05, "Claude almost ingested my production `.env` via filesystem MCP," is exactly this failure mode, and the author ended up writing their own Rust guardrail rather than finding one upstream. I built [themsquared/filesystem-mcp-env-guard](https://github.com/themsquared/filesystem-mcp-env-guard) to find the right control point for this, and the first thing I tried turned out not to work at all.

## What I expected to write, and why it didn't work

agentgateway has a built-in MCP authorization policy: `mcpAuthorization`, CEL rules evaluated against `mcp.tool.name`, `mcp.tool.target`, and JWT claims like `jwt.sub` when authentication is configured. It's what you'd reach for first, and the [agentgateway docs](https://agentgateway.dev/docs/standalone/latest/configuration/security/mcp-authz/) show rules like this working today:

```yaml
mcpAuthorization:
  rules:
    - allow: |
        mcp.tool.name == "echo" ||
        mcp.tool.name == "get-sum"
```

That's a tool-name allowlist, and it's real: it runs, it works, I tested variants of it against a local agentgateway. The rule language is CEL, and CEL rules can reference whatever fields the runtime populates. The field I needed was the `path` argument of a `read_text_file` call, so a rule could say "deny when `path` contains `.env`."

That field isn't there. `mcp.tool.arguments` exists in agentgateway's CEL variable table, but it's marked post-request: populated for access logs, after the call has already run. `mcpAuthorization` rules evaluate before the call, when arguments aren't populated yet. You can gate `read_text_file` as a tool. You cannot gate it by which path it's being asked to read. I didn't want to assert that from documentation alone, so I wrote the rule, ran it against a real filesystem MCP server behind a real agentgateway v1.5.0 binary, and watched it evaluate every `read_text_file` call identically regardless of path. The tool-name rule works. The path rule has nothing to read.

## The control that does see the path

Argument inspection and response mutation both go through [ExtMCP](https://agentgateway.dev/docs/standalone/main/mcp/guardrails/about/): an external gRPC service that agentgateway calls before a `tools/call` request reaches the upstream server, and again after the response comes back. The interface is [`ext_mcp.proto`](https://github.com/agentgateway/agentgateway/blob/main/crates/protos/proto/ext_mcp.proto), and it's small: two RPCs, `CheckRequest` and `CheckResponse`, each returning either "pass it through," "here's a mutated version," or "reject this with an error."

Wiring it in is a `mcpGuardrails` policy block pointing at the policy server:

```yaml
# yaml-language-server: $schema=https://agentgateway.dev/schema/config
config:
  adminAddr: "off"
mcp:
  port: 13300
  policies:
    mcpGuardrails:
      processors:
      - kind: remote
        host: "localhost:9001"
        failureMode: failClosed
        methods:
          tools/call: full
  targets:
  - name: filesystem
    stdio:
      cmd: npx
      args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/sandbox"]
```

`methods: tools/call: full` means both hooks run for `tools/call`: `CheckRequest` before the filesystem server sees the call, `CheckResponse` after it answers. `failureMode: failClosed` matters more than it looks like it should, and I'll come back to it.

## What the policy server does

The server is about 150 lines of Python. `CheckRequest` parses the JSON-RPC `params`, pulls `name` and `arguments`, and for path-bearing tools (`read_text_file`, `read_file`, `read_media_file`, `get_file_info`, and `read_multiple_files`, whose argument is a `paths` array rather than a single `path`) checks every path string against a denylist: `.env`, `.ssh`, `.git/`, `credentials`, `id_rsa`, `id_ed25519`, `.aws/`, `.npmrc`, `.netrc`.

```python
def _check_arguments(tool_name: str, arguments: dict) -> str | None:
    fields = PATH_TOOLS.get(tool_name)
    if not fields:
        return None
    for field in fields:
        if field not in arguments:
            continue
        value = arguments[field]
        values = value if isinstance(value, list) else [value]
        for v in values:
            if isinstance(v, str) and (hit := _matches_denied_path(v)):
                return hit
    return None
```

A match returns an `AuthorizationError` from `CheckRequest`, and the filesystem server never opens the file:

```
$ npx @modelcontextprotocol/inspector --cli http://localhost:13300/mcp \
  --method tools/call --tool-name read_text_file --tool-arg path=/sandbox/.env
{"error":{"code":"error","message":"path argument matches denied pattern '.env'; filesystem-mcp-env-guard blocks reads of credential-bearing paths"}}
```

Compare that to the same call through an identical gateway with no guardrail policy at all:

```
$ npx @modelcontextprotocol/inspector --cli http://localhost:13301/mcp \
  --method tools/call --tool-name read_text_file --tool-arg path=/sandbox/.env
{"content":[{"type":"text","text":"DATABASE_URL=postgres://demo:demopass@localhost:5432/demodb\nAPI_KEY=sk-demo-FAKE1234567890abcdef\n"}]}
```

Same server, same sandbox, same path. The only difference is whether a policy server is in the loop.

## The backstop: a path can look safe and still carry a secret

A denylist on the path argument catches the obvious case; it doesn't catch a file with an innocuous name whose contents happen to be a credential, say an `app.log` that got an API key pasted into a debug line. That's what `CheckResponse` is for. It scans returned text content for secret-shaped substrings (API keys, `password=`, Postgres connection strings, PEM private key blocks, AWS access key IDs) and redacts matches before the response leaves the gateway:

```
$ npx @modelcontextprotocol/inspector --cli http://localhost:13300/mcp \
  --method tools/call --tool-name read_text_file --tool-arg path=/sandbox/app.log
{"content":[{"type":"text","text":"2026-10-15 boot ok\n2026-10-15 connecting with API_KEY=[REDACTED] to upstream\n2026-10-15 password=[REDACTED] for service account\n"}]}
```

`app.log` was never on the denylist. It didn't need to be; the response hook doesn't care which path produced the content, only what the content looks like. Two different control points, two different jobs: the request hook stops the obvious reads before they happen, the response hook catches what the obvious reads didn't.

## The tradeoff `failClosed` actually costs you

`failureMode: failClosed` means agentgateway denies a guarded call if the policy server is unreachable, rather than letting it through unchecked. That's the right default for a credential-path control, and it's also a real availability dependency, not just a config knob. I killed the policy server mid-demo and re-ran a read of the completely harmless `notes.txt` file:

```
$ npx @modelcontextprotocol/inspector --cli http://localhost:13300/mcp \
  --method tools/call --tool-name read_text_file --tool-arg path=/sandbox/notes.txt
{"error":{"code":"error","message":"mcpGuardrails checkRequest failed: upstream call failed: Connect: Connection refused (os error 61)"}}
```

A file with nothing sensitive in it, denied, because the gateway has no way to know the policy server would have said yes. That's the cost of fail-closed stated honestly: your filesystem MCP server's uptime is now bounded by your policy server's uptime. `failOpen` removes that dependency and reintroduces the original problem the moment the policy server has a bad day. For credential-bearing paths, failing closed is the only answer that doesn't quietly become the baseline again under load.

## Running it

```bash
git clone https://github.com/themsquared/filesystem-mcp-env-guard
cd filesystem-mcp-env-guard
AGENTGATEWAY_BIN=/path/to/agentgateway bash scripts/demo.sh
```

The script spins up the unguarded baseline, proves the `.env` leak, tears it down, brings up the guard server and the guarded gateway, runs five more calls (safe read, `.env` denied, `.ssh/id_rsa` denied, a `read_multiple_files` call with one denied path in the array, and the response-mask backstop), then kills the policy server and shows the fail-closed behavior. Seven checks, all passing, full JSON captured under `captures/`.

## What this doesn't claim

The denylist is substring matching on a path string, not a path-policy language. A symlink planted before the guard is in place, or a cleverly named directory, isn't something nine string checks catch, and I'm not claiming it is. The response-mask patterns are a fixed set for this demo's credential shapes, not a general DLP engine. Both are the right shape of control, scoped honestly: a first line of defense for the obvious case, sitting at the one point in the request path that can actually see the argument before the read happens.

## Related

I covered a related problem on the discovery surface, server-controlled MCP instructions landing in a model's trusted prompt, in [Keeping MCP Server Instructions Out of Your Agent's System Prompt](/mcp-discovery-prompt-injection/). Same lesson from a different angle: the control has to sit at the point in the request path that can actually see the untrusted input, not wherever is easiest to configure.

