The path gate
Every path an agent sends is judged before a capability sees it, and this page is the whole of that judgement: what a root is, what a symbolic link or a hard link can and cannot do, how git is held to it, and what is withheld because it is rta's own. MCP and the safety gate is the short version, with the flag you will actually type. Read this one when you need to know exactly where the line falls.
The gate governs path arguments only. A capability that opens a fixed file of its own — net hosts list and /etc/hosts — is unaffected, because that path is never an argument for anyone to send. A path a capability derives from an argument is held to the gate as though it had been sent: git finds the repository a directory belongs to by walking up from it, and follows a .git file, a commondir and objects/info/alternates to the directories they name, and a repository any of those place outside the roots is refused rather than read. A linked worktree is opened only under a root that also holds its main checkout.
Each file git diff would show goes to the gate too, so rta's own data and configuration inside a versioned home directory are named in the diff as refused, never shown — and so is a file of them the working tree reaches by another name, a hard link or one moved onto a tracked file's name, which the diff opens and finds is rta's by the file itself. That holds for a bare repository as well, the usual shape of a dotfiles repository: its files are placed where git would check them out — the directory core.worktree names, or else the one holding the repository, as ~/.cfg used with --work-tree=$HOME is laid out — and withheld there when they are rta's own state, as is the file git blame is asked about. A checkout whose core.worktree names another directory, where git checks its files out, has them withheld there too.
The directory git hooks lists goes to the gate as well, since core.hooksPath — the repository's or the operator's own — can name one anywhere. So can an include: git reads the file an [include] or a matching [includeIf] names as though it were written in its place, and one of sections and keys reads as config, a MySQL client's ~/.my.cnf and its password among them. An include of a file outside the roots made in a file of config a caller can write — .git/config, config.worktree, and every other file of config inside the roots, whoever includes it, the operator's own among them: ~/.gitconfig itself under a root drawn around the home directory, or a ~/work/.gitconfig that ~/.gitconfig includes, served with a root of ~/work — is not followed at all: the file is never opened, so every answer is the one an include of a missing file gets, and nothing of the file, not even whether it is there, whether it parses as config or how large it is, shows in one. Since git does follow it, git config and git hooks name the include as one outside the roots, not followed.
The operator's own config outside the roots, and a file it includes from outside them, is read as git reads it; one whose name passes through the roots on its way, as it is spelled or where one of the operator's own links leads, and leaves them again, where a link a caller made could lead anywhere, is not read, and is named the same way. So is a core.excludesFile a file the caller can write names outside the roots: not applied, and named.
A hasconfig:remote.*.url condition written in a file a caller can write is a glob a caller could try against URL after URL, so it is matched against the remotes the repository's own config sets inside the roots alone: one that matches the operator's remote, or the environment's, is answered as one that matches nothing. One the operator's config outside the roots writes is matched against every URL, those a caller is not shown with their credentials masked. An include of a file the gate refuses for any other reason, rta's own state or configuration among them, refuses the call, and the file is not read.
None of these is opened by its name again once the gate has judged it: over MCP git reads the repository's directories, its working tree, the hooks directory, an included file and the excludes file from the root each lies under, through a handle held open on the directory, so a directory or a file swapped for a link out of the roots between the judgement and the read is not read through, and a link out of the roots is not looked through to say whether its far end exists. At a terminal each is read by name, as git reads it.
A path argument naming a file to read — a certificate, a file to hash, a hosts file or a resolv.conf, a lockfile or an SBOM to audit — names a regular file. A named pipe, a device or a directory in its place is refused before anything opens it, and a file larger than its format ever is — past 16 MiB for a certificate file, 32 MiB for a hosts file, 1 MiB for a resolv.conf, 64 MiB for a lockfile or an SBOM — is refused by name rather than read whole.
A repository's own files, which git reads whole, are held the same way whether or not anybody named them: a config or a .gitmodules past 4 MiB, an index past 128 MiB, a packed-refs past 64 MiB, or a HEAD or ref past 1 MiB is refused as git.repository.toolarge before anything reads it. Its ignore files are held in all rather than one by one: a status reads at most 1 MiB of them, each .gitignore, the repository's info/exclude and the file core.excludesFile names, and applies at most 10000 patterns from them, and a file past that is not applied, as git applies no pattern file past 100 MB. So is a .gitignore that is a symbolic link, which git does not follow either. What such a file ignores is listed by git.status, with a warning naming it, and git.diff names an untracked file it may have ignored rather than showing it. Each pattern is matched as git's own matcher matches it, a [!...] class negated and a directory it excludes keeping everything under it excluded, so an untracked file git ignores is not listed or shown. A core.excludesFile the repository's own config names is put to the gate as a path the caller sent, since its lines would be read as patterns; one the operator's own config names, or git's default ~/.config/git/ignore, is read wherever it is. And the working tree itself, which git.status, git.diff without a commit and git.overview read, is read for at most two seconds a call: past them the call is refused as git.status.timeout, naming what it had read, rather than answered with the part of the tree it had got to, which would read as a cleaner tree than the one there.
The CLI still reads /dev/stdin as its guide shows, because a pipe given at a terminal has a writer the person started. A file nobody named is a file on every surface, the terminal included: a lockfile audit deps finds in the directory it was given, or the project .mcp.json audit clients reads from the working directory, is whatever the repository put there, and a pipe in its place is named as a file that could not be read.
A path is judged with its symbolic links resolved, one hop at a time as the kernel follows them, and a link that cannot be followed to its end — its target missing, or behind a directory the server may not search — is judged by where it points: a link out of the roots is refused in the same words whether or not anything is there, so a refusal never says whether a file outside exists. A loop inside the roots is refused as unresolvable, and one outside them as outside, as a missing name there is. The capability is still told when the name it was given was a link, since for some that is the answer — net resolver list on a resolv.conf linked into /run says who owns it, over MCP as at a terminal. What the link holds is told as written only when it names places under the roots; a link whose next step is a name outside them, even one that leads back in, is told as leading outside the roots, without the name. A link a capability comes across rather than is given is told by the same rule: fs tree lists each link in the directory with what it holds, and over MCP one naming a place outside the roots reads a path outside this server's roots, and git diff diffs a link in the working tree by its text only where the text names a place under them, and names any other as changed without showing it, while at a terminal every link shows its target. Nor is such a link followed: git hooks judges a hook that is a link by what it leads to, as git does, and over MCP one leading outside the roots is listed as active without being looked through, since git may run what it leads to.
What the gate then guarantees about the file read depends on who opens it, and how. fs, cert, the net capabilities that read a hosts file or a resolv.conf, and audit deps and audit why never open a path they were given by its name: it is judged again and opened from the root it lies under, one directory at a time from the root's own descriptor, following nothing that was not there when it was judged and opening nothing that is not what was looked at a moment before. Each directory on the way, the root included, is opened to be gone down from, so a path under one the server may search but not list is refused as unreadable. So a caller who can write inside a root and swaps the file, or a directory above it, for a link out between the gate and the open is refused, never handed what the link leads to, in the same words whatever the link points at.
A directory fs tree, fs usage or audit deps walks is gone down the same way, a link the walk comes across is never followed by fs and followed by audit deps only where the gate would let a caller name it, and rta's own state and configuration is left out where a walk reaches it as well as refused where a path names it: an operator serving their home directory sees the data directory listed as withheld, not what is in it, and a configuration file under a root is listed as withheld, not sized. rta's own state and configuration is known by its files as well as by their names: a hard link inside a root to one of them, or one of them moved onto a name the gate has already judged, is another name for the same file, and what is opened, or reached by a walk, is compared by the file's identity with everything the data and configuration directories hold, and refused or withheld as the name itself is. The clones of the plugin indexes are the exception, refused by their names alone: an index attached from a checkout on this machine is a git clone of it, which git makes by hard-linking the checkout's objects, and compared by identity the checkout itself was refused as rta's own. Each call reads what those directories hold once, the first time it opens something, and the server reads it once more when it starts, for a file moved out of them before a call looks; the data or the configuration directory itself, moved under a root by another name, is refused whole, and git lists nothing of it. The refusal says the path is another name for rta's own state or configuration, which is how an operator finds a hard link, or a file moved, that they did not mean as rta's.
Two things are beyond what a root can see. A hard link is the file itself rather than a pointer to one, so a hard link made inside a root to any other file outside it, on the same filesystem, is read as the file inside that it is. And a mount inside a root is inside it: fs tree and fs usage do not cross one, but a file under it can be named. git opens what it reads over MCP the same way, from the root each part of a repository lies under, and holds each file it opens to rta's own state by its identity too.
A plugin opens its own path inputs, in its own process, where the root the host holds is not. For a Path input the gate judges the path exactly as it judges a built-in's, refuses it on the same terms, and hands the plugin the path it judged with its links resolved — and that is the whole of what it guarantees: the path as it was when judged. The plugin opens it by name, so a caller who can write inside a root can still swap the file, or a directory above it, for a link between the gate and the plugin's open, and the plugin follows it; a path the plugin works out from its inputs is not judged at all. What bounds a plugin past that point is its sandbox — on macOS, sandbox-exec denies it rta's own state and the credential directories, as rta doctor lists; on Linux, nothing rta applies. Serve an agent that can reach a plugin with a Path input from a root nothing else writes in, or count the plugin's sandbox as the bound.
Related
- MCP and the safety gate — what an agent can reach before you decide anything