MCP_NODE_POLICY_FILE configures per-token policies that constrain both tool
access and call arguments (argv[0], cwd, timeout ceiling, and filesystem
read/write prefixes). When unset, the node uses a single shared token with
unrestricted tool access.
Each policy rule is evaluated on the node before execution, pinning the authorized directory and executable descriptors so post-check symlink swaps are rejected.
# /etc/mcp-node/policy.json (0600)
{
"version": 1,
"tokens": [
{
"file": "/etc/mcp-node/tokens/build",
"tools": ["sys_info", "exec", "read_file", "list_dir"],
"exec_argv0": ["/usr/bin/git", "/usr/bin/make", "tar"],
"exec_cwd": ["/srv/build"],
"max_timeout_s": 120,
"read_paths": ["/srv/build", "/var/log/build"],
"write_paths": [],
"write_max_mode": "0644",
"read_deny": ["/srv/build/keys"]
},
{
"file": "/etc/mcp-node/tokens/admin",
"allow_all": true
}
]
}
MCP_NODE_POLICY_FILE=/etc/mcp-node/policy.json mcp-node
Each entry's file is a 0600 file holding the raw token (same format as the
classic token file). The token values never enter the policy file, the
audit log, or the config fingerprint; the node keeps only
sha256(value) per entry and compares it timing-safe.
The classic MCP_NODE_TOKEN_FILE must name a token that one of the entries
also names (matched by value). If it does not, startup fails: opting into
policies must not leave an ungated backdoor token by accident. Node, hub and
stdio modes refuse a policy file outright because node and stdio modes have no client token,
and on a hub the relay route (/n/<name>/mcp) forwards raw bodies to nodes
where no policy exists, so a hub-side policy would be a false sense of
security. Run the policy on each node's own listen listener instead.
MCP_NODE_INSECURE=1 combined with a policy file is also a startup error.
| Rule | Applies to | Meaning |
|---|---|---|
tools |
all 14 tools | Exact names, or "*". Default-deny. |
exec_argv0 |
exec, exec_start, exec_shell | Allowed argv[0]. Bare names match bare requests exactly; slash entries must be absolute and match slash requests (both sides canonicalised). No cross-boundary matching: allowing python3 does not authorise /usr/bin/python3 and vice versa. |
exec_cwd |
exec-family cwd |
Absolute prefixes. Absent → the caller may not set cwd at all; inheriting the daemon cwd stays allowed. |
max_timeout_s |
exec-family, exec_wait, copy_path | A timeout above the cap is refused, not clamped. Values with the wrong JSON type (e.g. "timeout": "10" or 123.5) are refused too; the gate validates the exact grammar the tool accepts, so nothing can slip past on a type technicality. exec/exec_start/exec_shell default to 120s and exec_wait to 30s when timeout is absent or null: a cap below the default refuses the call outright. |
read_paths |
read_file, list_dir, copy_path (source) | Absolute prefixes. Required when either tool is callable. |
write_paths |
write_file, copy_path (destination) | Absolute prefixes. Empty array is legal and denies every path. copy_path requires a nonempty write_paths at load time. |
write_max_mode |
write_file, copy_path | Octal string cap (e.g. "0644"). A requested mode above the cap is refused, preventing a token from creating setuid/sticky files. Required when write_file is callable. The cap applies to the effective mode: an omitted mode means the tool default 0644, and a mode with the wrong JSON type is refused (the tool accepts integral floats like 420.0; the gate converts them the same way, so they are checked, not skipped). For copy_path the cap also gates directory publishes: a recursive copy is refused with CopyModeUnsupported unless the cap includes 0700. |
read_deny / write_deny / cwd_deny |
corresponding paths | Subtractive prefixes evaluated after the allowlist: allow+deny → deny. |
Parsing is strict: unknown keys, bad versions, relative or ~ prefixes,
cross-key contradictions (exec listed with empty exec_argv0, write_file
without write_max_mode, exec_shell with no shell reachable) all fail
startup with the offending key named in the error.
exec_shell runs a script under an interpreter. A script cannot be
argument-gated in any meaningful sense because the script text is the program.
The gate evaluates the shell the tool will actually spawn (explicit shell
argument or the platform default) against exec_argv0, and the strict
parser refuses an exec_shell policy that lists no shell at all, because
every call would be denied anyway. Allowing any shell is equivalent to
allowing arbitrary code as far as the policy is concerned. If you do not
mean to allow that, omit exec_shell from tools; that is the supported
way to disable it.
Paths are canonicalised before matching, in the order the kernel resolves
them: symlink resolution first (realpath on the raw path), lexical ..
collapse second, then only for a not-yet-existing tail, realpath of the
deepest existing raw ancestor with the tail re-attached before collapsing.
~ expansion and the anchor depend on which side of the policy/tool
contract the path lives on: read_file/write_file/list_dir expand a
leading ~/~/ to the home directory (the tools do the same before
touching the filesystem, so gate and tool see one path), while exec
argv[0]/cwd are passed verbatim to the kernel, where a leading ~
is a literal relative component anchored to the effective child cwd, and
the gate evaluates exactly that literal path. Relative exec cwd values
anchor to the daemon working directory first (the child chdirs from
there). //
collapses. Prefix matches are segment-aligned: /srv/build matches
/srv/build/x, never /srv/buildkeys.
Scope and boundaries:
- TOCTOU (closed for the file tools): the gate's decision and the open
used to resolve the path twice (string check, then a raw-path open), and
a local racing process swapping a component for a symlink in that window
could redirect the open.
read_file/write_file/list_dirnow run the secure-open pipeline: the gate emits the matched allow prefix and the canonical path as a pin, the tool re-opens the prefix and the target relative to a pinned directory descriptor (Linuxopenat2withRESOLVE_BENEATH|RESOLVE_NO_MAGICLINKS; Darwin a component walk withO_NOFOLLOW+F_GETPATH; Windows handle-relative opens +GetFinalPathNameByHandleW), and the opened descriptor's kernel path is verified against the pin before any byte moves. A symlink inside the allow tree is still followed when the gate canonicalised it inside the tree; the kernel confinement refuses an escape, the post-check refuses a swap planted after the gate, and a swapped allow-prefix ancestor refuses withPrefixSwapped.allow_alltokens skip the pin by design (admin escape hatch). The exec tools passargv[0]/cwdverbatim to the gate; the spawn itself is pinned (Linux/Darwin: the child fchdir's into the pinned cwd descriptor and execveat's the pinned executable fd; Windows: the authorized exe and cwd are held as share-mode handles that block rename/delete across the spawn, and the child is created suspended and its image verified against the pin before it runs; a mismatch is terminated withExecPinMismatchbefore a single instruction executes). Windowswrite_filewithmkdirswalks one verified component at a time, so a reparse point planted on an intermediate component cannot leave directories outside the allow tree behind. - Windows pinned-exec residuals: an in-place content overwrite of the
authorized executable (write access to the file itself) is outside the
rename/replay threat model on every platform. A bare
argv[0]under a policy resolves by CreateProcess's own.exe-append rule only: a name that only PATHEXT-aliases (.bat/.cmd, or a.comsibling of a missing.exe) refuses instead of running, and.bat/.cmdtargets refuse at the image check (std wraps them incmd.exe, whose image is not the pinned object). - Hardlinks defeat path rules: realpath does not see hardlinks, so a file hardlinked into an allowed tree from outside it reads as allowed. No tool creates hardlinks, but a co-tenant with shell access can.
- Bare argv[0] trusts the daemon's PATH: an allowlisted bare name is resolved by the daemon's PATH at exec time; a writable directory early in PATH redefines it. Prefer absolute entries where it matters.
- Case-insensitive filesystems (macOS, Windows) are matched byte-exactly. Write prefixes in the canonical case the host reports; a mismatch denies.
- A path under an allow prefix but not yet existing passes on the strength of its real (existing) ancestor, which supports creating new files in an allowed tree.
- Session ids are enumerable:
exec_pollon another token's session returnsSessionNotOwnedversusUnknownSessionfor a missing one, which distinguishes the two states. With a policy active the ids a token can act on are only its own, so the oracle reveals existence, not control.
exec_start sessions record the sha256 of the starting token. While a
policy is active, exec_poll, exec_write, exec_kill, exec_close,
exec_wait refuse sessions started by a different token, and exec_list
shows only your own. This closes the obvious bypass: a token denied exec
guessing another token's session id and driving it via exec_write.
A denied call returns the normal tool-error envelope with isError: true:
{"ok": false, "error": "PolicyDenied", "message": "timeout exceeds this token's cap", "reason": "timeout_exceeds_cap", "field": "timeout"}The denial happens before dispatch: a denied exec never spawns, a denied
write never opens the file. Every denial is recorded in the audit log (when
enabled) as a policy.deny event with the tool, the same args
digest/summary as tool.call, the reason, and a 16-hex fingerprint of the
token (the raw token value is never logged). The start record's config fingerprint
includes the policy file path and a sha256 of its bytes, so policy edits
show up in the next start record.
- An allowed interpreter re-opens everything.
exec_argv0: ["python3"]means arbitrary Python;bashmeans arbitrary shell. The gate constrains the capability reach of a token, not what the allowed binaries can do when combined creatively. Treat every exec allowance as code execution with the node user's ambient rights. - argv[1..] is not filtered. Allowing
gitallows every git subcommand. - The policy constrains the token holder, not the OS user. A local compromise of the node's user is unaffected by this feature.
- Hub relay is out of scope, by refusal. A hub refuses to start with
MCP_NODE_POLICY_FILEset: the/n/<name>/mcproute forwards raw bodies to nodes that have no policy, so gating only the hub's own client listener would be a false sense of security. Configure the policy on each node's listen listener; hub-side client tokens keep their v0.3.1 meaning. Node↔hub links are trusted and unaffected either way. allow_all: trueentries provide an explicit administrative token alongside scoped tokens.