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.

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

Defensive security research. This post analyses publicly disclosed findings so the controls that stop them can be tested. It describes mechanisms, not procedures, and links the primary sources for anyone verifying the work.

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 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 show rules like this working today:

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 : 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 , 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-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.

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

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.

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 . 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.

Frequently asked questions

Can agentgateway's mcpAuthorization policy block a tool call based on a file path argument?

No. mcpAuthorization evaluates CEL rules against mcp.tool.name, mcp.tool.target, and JWT claims. mcp.tool.arguments is only populated after the call completes, for access logs, so a rule can't reference the path argument before the read happens. Blocking by path needs an external policy server through ExtMCP's CheckRequest hook, which runs before the call reaches the upstream server.

Why can a filesystem MCP server read .env or .ssh files if it's scoped to one directory?

Root scoping restricts which directory an agent can reach, not which file inside it. .env, .ssh, and .git normally live inside that same scoped directory, so a server correctly honoring its root still opens them when a tool call asks for that exact path. Scoping and path policy are two different controls.

What is ExtMCP in agentgateway?

A gRPC policy interface defined in ext_mcp.proto. An external server implements CheckRequest and CheckResponse, and agentgateway calls it before a tools/call request reaches the upstream MCP server and after the response comes back, so the policy server can reject, mutate, or redact either side.

What happens if the ExtMCP policy server goes down?

With failureMode: failClosed, agentgateway denies every guarded call while the policy server is unreachable, including reads that would otherwise be allowed, because the gateway has no way to know the policy server would have said yes. The alternative, failOpen, lets calls through unchecked instead: no outage risk, but the .env and .ssh reads this policy exists to stop go through unchecked the moment the policy server has a bad day. For credential-bearing paths, failing closed is the only choice that doesn't quietly become the baseline again under load.