MCP Server Path-Level Guardrails: Blocking .env File Reads

Root scoping can't stop .env reads inside a sandbox — intercept at ExtMCP's request and response hooks.
A filesystem MCP server's root scoping only prevents the agent from leaving the sandbox; it doesn't constrain which file gets opened inside, and .env, .ssh/id_rsa, and .git/config sit in the same directory. The author tried using agentgateway's mcpAuthorization policy for path-level blocking, but discovered mcp.tool.arguments isn't populated at request time, so rules returned identical results regardless of path. The real control point is ExtMCP: a gRPC service whose CheckRequest inspects path arguments before tools/call reaches upstream, and whose CheckResponse scans and redacts secret-shaped content after the response returns. Together they form two layers of defense — prevent upfront, catch afterward. failClosed brings a real availability cost: when the policy server is unreachable, even harmless file reads get denied.
A filesystem MCP server scoped to a single directory will still read .env if the agent explicitly asks for it. Root scoping only stops the agent from leaving the sandbox — it places no constraint on which specific file gets opened inside the sandbox. And .env, .ssh/id_rsa, and .git/config are exactly the files sitting in the same directory the agent is supposed to work in.
A recent post on r/mcp described this failure mode: Claude nearly read a production .env through a filesystem MCP, and the poster ended up writing their own guard in Rust because they couldn't find an off-the-shelf solution upstream. The author built the filesystem-mcp-env-guard project to find the right control point — and the first approach they tried simply didn't work.

Why the mcpAuthorization policy can't stop paths
agentgateway ships with a built-in MCP authorization policy called mcpAuthorization, which uses CEL rules to evaluate mcp.tool.name, mcp.tool.target, and JWT claims such as jwt.sub. The rule shown in the docs looks like this:
mcpAuthorization:
rules:
- allow: |
mcp.tool.name == "echo" ||
mcp.tool.name == "get-sum"
This is a tool-name allowlist, and it genuinely works — the author tested multiple variants on a local agentgateway, and all of them behaved as expected. The problem is that what the author needed was a rule that could reference the path argument in a read_text_file call, so that any path containing .env would be denied.
But that field doesn't exist. mcp.tool.arguments appears in agentgateway's CEL variable table, but it's marked as post-request: it's only populated for the access log after the call has already executed. And mcpAuthorization rules are evaluated before the call, at which point arguments aren't populated yet.
Rather than concluding from the docs alone, the author wrote rules and ran them against a real filesystem MCP server and the agentgateway v1.5.0 binary, and observed that every read_text_file call evaluated identically regardless of the path. Tool-name rules work; path rules have no data to read.
CEL (Common Expression Language) is a non-Turing-complete expression language open-sourced by Google, designed for fast, predictable policy evaluation in security-sensitive contexts. It's commonly used by Envoy, Kubernetes, and similar projects for access control. In agentgateway's mcpAuthorization, each allow or deny rule is a CEL expression, and the evaluation environment exposes several variables (such as mcp.tool.name, mcp.tool.target, jwt.sub).
The key limitation of CEL is that it can only read data that actually exists at the moment of evaluation. agentgateway explicitly marks mcp.tool.arguments as post-request, meaning this variable is only populated after tools/call completes and the access-log record is constructed. Authorization rules are evaluated before the call happens. So at the request stage, any rule referencing mcp.tool.arguments gets a null value — no matter what the path string says, the result is the same. This isn't a problem with how the CEL expression is written; the data simply isn't there at that stage. Understanding this is what explains why a tool-name allowlist works while a path allowlist doesn't.
The control point that can actually see the path: ExtMCP
Both argument inspection and response modification go through ExtMCP: an external gRPC service that agentgateway calls before the tools/call request reaches the upstream server, and again after the response comes back. The interface is defined in ext_mcp.proto and is quite small: two RPCs — CheckRequest and CheckResponse — each returning either "allow", "return a modified version", or "deny with an error".
It's wired up with an mcpGuardrails policy block:
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, and CheckResponse after it responds. failureMode: failClosed looks like just a config switch, but it matters far more than it appears.
gRPC (gRPC Remote Procedure Call) is a high-performance RPC framework built on HTTP/2. It uses Protocol Buffers to define service interfaces, supports bidirectional streaming and multiplexing, and enforces strongly typed method contracts. ExtMCP exposes two RPCs to agentgateway through a gRPC service: CheckRequest and CheckResponse.
gRPC's strongly typed contract is critical here: ext_mcp.proto precisely defines the field types of request and response messages, so interactions between the gateway and the external policy server can't suffer from "wrong field name but silently returning null" problems. At the same time, gRPC connections are explicit and probeable, which is exactly why the failClosed behavior described later can be observed — when the policy server process is killed, the gateway side receives an explicit Connection refused, not a vague timeout or a silent allow. Protocol Buffers' compact encoding also keeps the two extra round-trips per tools/call at a very low cost.
How the policy server works
The policy server is about 150 lines of Python. CheckRequest parses the JSON-RPC params, extracts name and arguments, and for path-bearing tools (read_text_file, read_file, read_media_file, get_file_info, and read_multiple_files, whose parameter is a paths array rather than a single path), compares each 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
On a match, CheckRequest returns an AuthorizationError, and the filesystem server never opens the file at all:
$ 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 with the same call through the same gateway with no guard policy:
$ 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\
API_KEY=sk-demo-FAKE1234567890abcdef\
"}]}
Same server, same sandbox, same path. The only difference is whether the policy server is in the chain.
JSON-RPC is the message format underlying MCP (Model Context Protocol): each tool call is a JSON object containing method (e.g., tools/call), params (where name is the tool name and arguments is the parameter dictionary), and id. The policy server can precisely intercept paths because it parses this params.arguments structure rather than guessing from a blob of text.
Note that read_text_file and read_multiple_files have different parameter shapes: the former takes a single string path, while the latter takes a string array paths. This is why _check_arguments first checks isinstance(value, list) and then iterates. If only the single-value case were handled, array-shaped calls would become a hole bypassing the denylist. This approach of "mapping tool name to its path field name" (the PATH_TOOLS table in the code) is essentially a small tool-schema table, and it must be updated as the upstream filesystem server adds new path-bearing tools — otherwise new tools default to being unconstrained by path checks.
The fallback: a safe filename doesn't mean safe content
A denylist on path arguments catches the obvious cases, but not files with innocuous names whose contents are credentials — for example, an app.log where a debug line has an API key pasted into it. This is where CheckResponse comes in.
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\
2026-10-15 connecting with API_KEY=[REDACTED] to upstream\
2026-10-15 password=[REDACTED] for service account\
"}]}
app.log never appeared on the denylist, and it doesn't need to. The response hook doesn't care which path the content came from — only what the content looks like. Two distinct control points, two distinct responsibilities: the request hook stops the obvious read before it happens, and the response hook catches what the former misses.
DLP (Data Loss Prevention) usually refers to a class of mechanisms that systematically detect and block sensitive data from leaving, encompassing content fingerprinting, regex rules, machine-learning classification, policy orchestration, and more. The CheckResponse in this article is just a minimal content scanner: a fixed set of secret-shape regexes (API key, password=, Postgres connection string, PEM private-key block, AWS access key ID), replaced with [REDACTED] on match.
It differs from real DLP in two essential ways. First, the pattern set is fixed and aimed at the demo credential shapes; new secret formats (such as newer tokens from certain cloud vendors) won't be automatically covered. Second, it only redacts rather than blocks — the matching response is still returned to the agent, just with the sensitive substring replaced. So it's appropriate as a response-side fallback, not as the sole control point. Combined with the request-side path denylist, it forms a two-layer defense of "prevent the obvious upfront + catch the misses afterward," which is exactly what the article means by saying the two control points each do their job.
The real cost of failClosed
failureMode: failClosed means that when the policy server is unreachable, agentgateway denies the guarded call rather than allowing it. For credential-path control, this is the correct default — but it's also a real availability dependency, not just a config option.
The author killed the policy server mid-demo and re-ran a read of a perfectly harmless notes.txt:
$ 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 containing nothing sensitive was denied, because the gateway couldn't know that the policy server would have said "yes". This is the honest cost of fail-closed: the availability of the filesystem MCP server is now bounded by the availability of the policy server. failOpen removes this dependency but reintroduces the original problem the moment the policy server has an issue. For credential-bearing paths, fail-closed is the only answer that won't quietly fall back to the baseline under load.
How to run it and its limitations
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 starts the unguarded baseline and demonstrates the .env leak, tears it down and starts the guard server and guard gateway, then runs five calls (a safe read, a .env denial, an .ssh/id_rsa denial, a read_multiple_files call containing one denied path, and the response-redaction fallback), then kills the policy server to show fail-closed behavior. All seven checks pass.
To be clear: the denylist is substring matching on path strings, not a path-policy language. Symlinks planted before the guard was deployed, or cleverly named directories, aren't something nine string checks can catch. The response-redaction patterns are also a fixed set targeted at this demo's credential shapes, not a general-purpose DLP engine. Both are the right shape of control, honestly scoped to: a first line of defense against the obvious, sitting at the one place in the request path that can see arguments before the read happens.
Related articles

Random Pruning of the DPO Computation Graph: 1.4x Training Speedup and Lower Peak Memory
DPO training too slow and memory-hungry? This paper proposes a random pruning framework for the DPO computation graph, sampling independently across position, sequence, and branch dimensions; branch pruning achieves a 1.426x step-time speedup and cuts training time by about 30%, with in-depth attribution of memory and time gains.

Why Does JEPA Underperform MLM in Longitudinal EHR Representation Learning?
New study compares JEPA and MLM for longitudinal EHR representation learning on MIMIC-IV: three JEPA variants train stably without collapse but underperform MLM; target size, not heterogeneity, drives prediction error.

Transfer Learning + Visible Light Communication: A New Approach to Energy-Efficient Indoor Positioning
A study proposes a transfer learning-based visible light communication indoor positioning method, achieving 47% accuracy improvement on the BOSCH factory dataset, 67.5% model efficiency under high noise, and introducing the ME composite metric for energy-efficient positioning in energy-constrained industrial IoT scenarios.