An agent asks its file tool to read allowed/docs/note.txt. The adapter checks that the path starts inside allowed, approves the request, and opens it. The bytes come from outside/note.txt because docs is a symbolic link.
No parent traversal appeared in the request. The agent did not need to say ../. The adapter approved a name without controlling what the operating system would open.
I would reject a file-tool security review that ends at an allowed-directory string check. The boundary has to survive filesystem resolution and the interval between checking and opening. That applies to an application-owned MCP server or any other file adapter. This article demonstrates a deliberately vulnerable local tool, not a reported vulnerability in Claude Code or a specific MCP implementation.
Reproduce the escape without reading real secrets
The downloadable Python lab creates a private temporary fixture beneath a scratch directory you supply. Its outside file contains only OUTSIDE fixture only. It makes no network requests and runs no model.
Use Python 3.9 or later on a POSIX platform with os.open(dir_fd=...), O_NOFOLLOW, O_DIRECTORY and O_NONBLOCK. I ran this fixture with Python 3.9.6 on macOS. The Linux-specific deployment options discussed later are documentation-backed recommendations, not results from this run. Windows needs a different implementation and tests.
Download and inspect the script, then run these commands in a disposable project directory:
mkdir -p scratch
python3 agent-file-symlink.py --scratch ./scratch
python3 agent-file-symlink.py --scratch ./scratch --test
python3 -O agent-file-symlink.py --scratch ./scratch --test
The demonstration prints:
lexical check: OUTSIDE fixture only
resolve then swap: OUTSIDE fixture only
bounded reader: symlink rejected
The fixture removes its own temporary files when it finishes. It leaves the scratch directory itself in place. Eleven distinct unit tests passed in ordinary and optimised Python, including a test that deliberately shows the bounded reader accepting a hard link. That last result marks a remaining boundary, not a security success.
Why the allowed path passes
The vulnerable function uses Path.is_relative_to:
def lexical_read(root, name):
candidate = root / name
if not candidate.is_relative_to(root):
raise ValueError('outside root')
return candidate.read_bytes()
Python documents is_relative_to as a string-based operation. It does not access the filesystem or treat .. specially. That makes it useful for manipulating paths, but insufficient for deciding which file an untrusted request may read.
In the fixture, allowed/docs/note.txt is lexically beneath allowed. When read_bytes() opens the path, the operating system follows the docs link to the sibling directory. A string prefix check would have the same symlink problem, plus its own problems with similarly named sibling directories.
Calling resolve(strict=True) before comparing paths catches this already-present link. The lab tests that improvement. It also tests where that improvement stops.
Resolving once leaves a check/use gap
Consider the following sequence:
- Resolve
allowed/note.txtwhile it is an ordinary file. - Confirm that the resolved path is beneath the allowed root.
- Replace that directory entry with a link to
outside/note.txt. - Open the previously checked pathname.
The resolved value is still a pathname. Opening it performs another lookup and follows the replacement link.
The lab inserts the replacement through an after_check callback. This creates a deterministic time-of-check/time-of-use interleaving without racing threads or hoping to win a timing contest. It proves that the sequence admits the wrong read. It does not measure how often a real attacker would win the race.
This failure requires somebody to be able to change the relevant filesystem entries between the operations. A genuinely immutable input tree changes that threat model. A shared writable checkout, an extraction directory or a workspace another process can modify does not give you that assurance merely because the current agent was told to read only.
The Linux open manual explains the useful contrast: a returned file descriptor refers to an open file description, and that reference is unaffected when the pathname is subsequently removed or changed to refer to another file. The fixture verifies the corresponding behaviour on the test host: after opening the approved file, replacing its name does not redirect a read through the existing descriptor.
That does not freeze the contents of the opened file. Another writer can still modify that same file object. If an approval concerns exact bytes, use an immutable input snapshot or an independently enforced content-binding design.
Make the file tool smaller before making it cleverer
A document-inspection tool often needs less freedom than a shell. One option is a broker that accepts a document ID and maps it to a single file in a private staging directory. It need not accept arbitrary nested paths.
The lab implements the smaller building block below. The trusted caller supplies an already-open directory descriptor. The request supplies one filename. The function rejects path syntax, refuses a final symlink, checks the opened object’s type and reads at most one byte beyond its size limit.
def bounded_read(root_fd, name):
if not isinstance(name, str) or name in ('', '.', '..'):
raise ValueError('one filename required')
if '/' in name or '\\' in name or '\x00' in name:
raise ValueError('path syntax is forbidden')
flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
fd = os.open(name, flags, dir_fd=root_fd)
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
raise ValueError('regular file required')
with os.fdopen(fd, 'rb', closefd=False) as stream:
data = stream.read(LIMIT + 1)
if len(data) > LIMIT:
raise ValueError('file exceeds read limit')
return data
finally:
os.close(fd)
The full download imports os and stat, defines LIMIT = 4096, establishes the fixture directory and runs the tests. The excerpt is the same reader used by those tests.
Python’s os.open interface supports opening a relative path against a directory descriptor on supported platforms. The important restriction here is that the relative path contains exactly one component. O_NOFOLLOW applies to the final component; it does not generally refuse symlinks in every parent component. Allowing docs/note.txt back into this function would invalidate its simple argument.
O_NONBLOCK lets the fixture open its named pipe without waiting for a writer, then reject it through fstat. This is not a general promise that arbitrary device opens are harmless. The staging directory must prevent untrusted creation of device nodes and other unsupported objects. A production broker also needs resource and concurrency limits beyond this small per-read byte bound.
What the tests establish, and what they leave open
| Case | Observed result | Meaning |
|---|---|---|
| Lexical check, linked parent directory | Reads outside fixture | A lexical subtree check is insufficient |
| Resolve an existing outside link | Rejects request | Resolution catches this static escape |
| Resolve, replace entry, then open | Reads outside fixture | Checking and using the name are separate |
| Flat regular filename | Reads approved fixture | The narrow happy path works |
| Flat filename replaced by symlink | Rejects request | The tested open refuses the final link |
| Open descriptor, then replace name | Reads original fixture | Later name replacement does not redirect that descriptor |
| Hard link created inside root | Reads outside fixture bytes | Symlink refusal does not establish file provenance |
Additional tests reject path syntax, directories, the fixture’s named pipe and a file larger than the read limit. These are local component tests. They do not certify a deployed agent sandbox.
The hard-link test deserves attention before anyone copies the reader into a service. A hard link is another directory entry for the same file, not a symbolic link to traverse. O_NOFOLLOW does not reject it. The fixture deliberately creates one to show that limitation. The broker must control how files enter staging, isolate its operating-system authority and avoid treating arbitrary same-filesystem links as trusted imports. Rejecting multi-link files may be an additional policy, but it is not a replacement for those controls.
Likewise, the root descriptor must come from trusted setup. If an attacker can substitute the directory before the broker opens it, the broker may anchor itself to the wrong place. This example creates and opens its own private fixture. It does not implement production root discovery, per-user authorisation or document-ID mapping.
If nested paths are required, use a platform boundary
On Linux, openat2 provides resolution controls intended for untrusted paths. RESOLVE_BENEATH restricts resolution beneath the supplied directory descriptor. RESOLVE_NO_SYMLINKS rejects symlink resolution throughout the path and implies the magic-link restriction. RESOLVE_NO_XDEV additionally prevents crossing mount points, including bind mounts, when that matches the service’s policy.
Choose these controls deliberately. Some repositories legitimately use symlinks, and some input trees intentionally cross mounts. Refusing them may be the correct tool contract, but it is a compatibility decision you should test rather than hide. Require the selected kernel and binding support at startup. Falling back silently to resolve() followed by open() restores the gap the policy was meant to close.
The Python fixture does not call openat2, and the flags above do not solve hard-link provenance or concurrent content mutation. For an agent that can run arbitrary shell commands, a Python wrapper alone is especially inadequate: the process can bypass it. Enforce filesystem visibility and credentials at the process or sandbox boundary as well. Yesterday’s headless CI article covers the broader execution workflow; a worktree still needs an explicit filesystem isolation policy.
For the wider design of tool authority and operational controls, see my Enterprise AI Agent Security book page. The Packt Kindle edition on Amazon UK is the primary purchase option. This lab is a separate teaching example, not a claim that this exact implementation appears in the book.
Before widening your file tool, add the linked-parent and name-replacement cases to its own tests. Record whether the guarantee covers path resolution, file provenance or immutable content, and leave the unimplemented guarantees explicit.