A path-traversal guard for MCP file tools that actually survives symlinks A developer released a path-traversal guard for MCP file tools that withstands symlink-based escapes, addressing a critical security flaw in servers exposing read_file, write_file, or list_dir tools. The guard uses canonical path resolution and strict containment checks, refusing to clamp or repair malicious inputs. The developer also provides regression tests and a free security scanner for MCP servers. If your MCP server exposes a read file / write file / list dir tool, it is one clever prompt away from serving /etc/passwd to whoever controls the model's input. The naive fixes — prefix checks, os.path.normpath , stripping .. — all fail against symlinks and absolute paths. Here is a guard that holds, plus the regression test that keeps it holding. BROKEN 1: prefix check on the raw string if not user path.startswith BASE : "/base/../etc/passwd".startswith "/base" is True reject BROKEN 2: normpath, then join open os.path.join BASE, os.path.normpath user path normpath doesn't resolve symlinks normpath is pure string math. A symlink inside BASE that points to / turns a "safe" relative path into a full-filesystem read. Absolute paths /etc/passwd sail straight through a join in many languages. php from pathlib import Path def resolve within base: str, user path: str - Path | None: base p = Path base .resolve strict=True canonical, symlinks followed target = base p / user path .resolve strict=False containment check that works for base itself and everything under it if target == base p or base p in target.parents: return target return None REFUSE — never clamp/repair Two rules that matter more than the code: user path.replace "..","" is where the regression re-opens six months later. Return None and error out. .resolve on the target, not just the base. .. A guard without a regression test rots. Fire the actual attacker payloads at it: python import pytest from mymcp.paths import resolve within BASE = "/srv/sandbox" @pytest.mark.parametrize "evil", "../../../../etc/passwd", "/etc/passwd", "..%2f..%2fetc%2fpasswd", if you url-decode before calling, test the decoded form too "sub/../../etc/passwd", "./././../etc/shadow", def test traversal refused evil : assert resolve within BASE, evil is None def test symlink escape refused tmp path : base = tmp path / "sandbox"; base.mkdir base / "link" .symlink to "/etc" symlink out of the sandbox assert resolve within str base , "link/passwd" is None def test legit path allowed tmp path : base = tmp path / "sandbox"; base.mkdir base / "notes.txt" .write text "ok" assert resolve within str base , "notes.txt" is not None If test symlink escape refused passes, you have beaten the class of bug that string-based guards miss. The regression that bites is a second file tool added later that opens paths directly and forgets to route through resolve within . Grep every release: grep -rnE "open\ |Path\ |send file|shutil\. copy|move " src/ | grep -v resolve within Every hit is a call site to audit. This is one guard of six I keep in a hardening kit for MCP servers — path containment, argv-only subprocess, safe deserialization, an SSRF resolver for fetch url tools, input bounds, and a pre-deploy grep+payload checklist with tests. If you want the whole set as copy-paste code, it's the MCP Server Security Hardening Kit $19 . The guard and tests above are yours free — ship them today. Free tool: paste your MCP server's tool code into the MCP Server Security Scanner https://smeltworks.com/mcp-security-scanner/ and get instant findings across all six vuln classes — path traversal, command injection, unsafe deserialization, SSRF, hardcoded secrets and input bounds. 100% client-side, your code never leaves the browser.