Permissions and Risks
A grant in mcp-sudo.yaml gives an AI agent root on your host - an agent that follows instructions found in the files, web pages and logs it reads. Several grants that look narrow are, in practice, full root: write access to /etc, services/manage, unrestricted sysctl writes. Grant the least a task needs, limit it with paths:, network: and sysctl:, and assume that anything an agent may do, someone who controls its input may make it do.
In stdio mode (mcpd stdio) none of this applies: there are no grants, and nothing runs as root.
This page is about the defaults - what a user can do with no grant, and what each kind of grant adds - and about the grants that are riskier than they look. The syntax of every option is on Sudo Privileges.
Who a call runs as
Every tool call runs in a separate worker process, as the OS account with the same name as the MCP user (alice in users.yaml → the Linux account alice):
- Without
privileged: true- as that account, with its groups. The kernel decides what it may read, write or signal, exactly as for a shell of that account.mcp-sudo.yamlis not consulted, except for thenetwork:limits below. - With
privileged: true- as root, but only for a tool granted to that user inmcp-sudo.yaml, and only within the grant'spaths:/sysctl:limits. Anything else is refused before a worker starts.
So the OS account is the first permission, and it matters as much as the grants:
- An MCP user must not be a root account - it would be root on every call, without any grant. A
users.yamllisting a user whose OS account has uid 0 is a config error: mcpd doesn't start with it,daemon/reload-configandlinuxctl edit mcpd config usersreject it,linuxctl create mcpd userwon't create one, and a worker refuses to run as one. - Groups count. An account in
docker,lxdordiskis root in all but name (it can start a privileged container, or read raw disks);admandsystemd-journalread every log. Create a dedicated account for each agent, in no such group:useradd --system --no-create-home agent-web.
The defaults
| What the user has | What the agent can do |
|---|---|
A token, no entry in mcp-sudo.yaml | Call every tool and resource as its own OS account: read what that account can read, change what it owns. No root. daemon/reload-config, every docker/* tool and the devices:///kernel:// resources are refused. network/curl and network/ping reach any address - see below. |
allowed: true on a tool without a path (services/manage, processes/delete, kernel/system-control, logs/*, ...) | That tool as root, with any arguments: any service, any process, any kernel parameter. |
allowed: true on a path tool (files/*, disks/usage, disks/free) | Nothing, and the config is rejected: a path tool must list its paths:. paths: ["/"] for the whole filesystem - written out, so whoever reads the config sees it. |
allowed: true + paths: [/var/log] | That tool as root inside /var/log only (/var/log/../../etc is judged as /etc and refused). Symlinks are not followed there, so one planted in /var/log can't lead out of it. |
network: on network/curl/network/ping | Limits every call of the tool - privileged or not - to the allowed destinations. Without it: any destination. |
sysctl: write_keys: on kernel/system-control | Root writes only to the listed keys. Without it: any key. Reads never need root. |
resources: | Reading those resources as root: "*" for fixed ones (devices://usb), a path prefix for file:// (/var/log), "" for all of a template (file:// → every file). |
Examples
A user with only a token:
# mcp-sudo.yaml: no entry for "reader"
| Call | Result |
|---|---|
files/read /etc/os-release | ✅ world-readable |
files/read /etc/shadow | ❌ permission denied - the kernel refuses the reader account |
files/read /etc/shadow, privileged: true | ❌ user reader is not authorized to run files/read on path /etc/shadow as root |
processes/delete another user's process, privileged: true | ❌ Permission denied. Hint: You are not authorized to use 'privileged: true' for this tool in mcp-sudo.yaml |
services/manage restart nginx | ❌ refused by systemd: a non-root account may not manage system units |
network/curl http://169.254.169.254/ | ✅ allowed - no network: block |
A log reader:
users:
logs:
privileged:
tools:
files/read:
allowed: true
paths: [/var/log]
files/list:
allowed: true
paths: [/var/log]
logs/journal-control:
allowed: true
| Call | Result |
|---|---|
files/read /var/log/auth.log, privileged: true | ✅ |
files/read /var/log/../../etc/shadow, privileged: true | ❌ user logs is not authorized to run files/read on path /var/log/../../etc/shadow as root |
files/read /var/log/link-to-shadow (a symlink), privileged: true | ❌ refusing to follow a symbolic link |
files/update /var/log/x, privileged: true | ❌ not granted |
Grants that amount to full root
Each of these lets an agent turn its grant into unrestricted root - by writing a file root executes, by starting a program as root, or by reading a secret. Treat them as "root on this host", and grant them only where that is acceptable.
| Grant | Why it is full root |
|---|---|
files/create / files/update with paths covering /etc, /root, /usr, /var/spool/cron, /etc/systemd, or / | Write /etc/sudoers.d/x, a cron job, /root/.ssh/authorized_keys, a systemd unit, a binary on PATH. |
files/create / files/update on mcpd's own config directory (/etc/mcpd/configs) | Rewrite mcp-sudo.yaml or users.yaml and grant itself everything at the next reload or restart. There is deliberately no MCP tool that edits them - don't hand one over through files/*. |
files/chmod / files/chown on / or system directories | Make a copy of a shell setuid root, or /etc/shadow world-readable. |
files/read on /, or file:// with "" | /etc/shadow, SSH host keys, mcpd's TLS private key (/etc/mcpd/configs/tls/mcpd.key), application secrets, other users' files. |
services/manage | Start, stop, restart, enable any unit - stop ssh or mcpd itself; together with any write to /etc/systemd, run any program as root. |
kernel/system-control without sysctl.write_keys | kernel.core_pattern and kernel.modprobe name programs the kernel runs as root; other keys can cut the host off the network. |
processes/delete | Signal any process: sshd, mcpd, PID 1, a database mid-write. |
docker/exec | A command as root inside the target container. Anything the container can reach, the agent can reach - and a container started --privileged, or with the host's filesystem or Docker socket mounted, is the host. Always give this one a containers: list. |
docker/manage without containers: | Stop, kill or remove any container on the host - including mcpd's own, and a database mid-write. containers: ["*"] means the same thing, written out. |
Any docker/* grant | Talking to the Engine API socket is the docker group, which is root - which is why every docker/* tool is root-or-nothing and invisible in tools/list without the grant. The containers: globs, matched against name and resolved ID, are the whole boundary. |
MCP user whose OS account is in docker/lxd/disk | Root through the group, even with no grant at all (see above). |
Also dangerous without any grant
network/curl,network/pingconnect from the server: tolocalhostservices, the private network, the cloud metadata endpoint (169.254.169.254, which can hand out cloud credentials). A prompt-injected agent can be told to fetch an internal URL and pass the answer on. Give every user that doesn't need internal access anetwork:block withdeny_private: true.- Reading logs and process lists shows command lines, environment-derived arguments and log lines - sometimes with passwords or tokens in them.
- The token is the user. Whoever has it acts as that user. Keep TLS on (the default), give each agent its own user and token, and rotate a token (
linuxctl update mcpd user NAME) whenever it may have leaked.
Crontabs
A crontab is a list of commands the system runs as a user, on a schedule. Reading one is a read; writing one is scheduling code. How it works: your own crontab is free (like any other unprivileged call - the worker runs as your account, and crontab acts on it); another user's crontab needs privileged: true (meaning "act on another account", not root: the worker runs as that account) and a rule naming it, view or edit, in the cron/manage grant (rules); the privileged listing shows only the crontabs of accounts you have view on. Whichever way you use it, know these risks:
| Risk | What can happen | What mcpd does, and what is left to you |
|---|---|---|
| Persistence | A job written by an agent outlives its session, its token and the token's rotation. Revoking an MCP user does not remove a job it already wrote. | Every write is an audit-log line (caller, target, size, line count, content hash) and every crontab can be read back, so a job can be found and removed. Own-crontab edits are open by design; to forbid them for an account, list it in /etc/cron.deny (mcpd has no switch for it). |
| Code as another user, or as root | edit on a user's crontab is arbitrary commands as that user; on root's, as root. | mcpd does not inspect the text it writes - the content is stored exactly as given, so a job can run any command, including one that downloads and runs something (`curl … |
| Argument injection | A user name such as -l becoming a crontab option. | The name is validated by the daemon (allowed characters, must start with a letter or digit, must be an existing account). There is no crontab -u <name> at all: the worker is started as that account and runs a fixed crontab -l or crontab -; the content goes through stdin, never a shell. |
Bypassing cron.deny | root can write the crontab of a user the administrator denied. | Not possible through mcpd: the worker runs as the target account, so crontab applies /etc/cron.allow and /etc/cron.deny to it as usual. |
| Secrets in the text | Crontabs and the journal's CMD lines hold whatever was typed into a command, including passwords. | Reading needs view. Audit logs record size and hash, never the content. Keep secrets out of crontabs regardless. |
| Reading the spool as root | The listing of users with a crontab reads a root-owned directory. | File names are treated as untrusted: checked against the account database, no symlinks followed, anything that is not a regular file skipped. The list shows only users the rules allow view for. |
| Oversized or abusive content | A huge file, or a job every minute. | A size cap on content; cron's own limits apply. |
| Mail as an exfiltration path | MAILTO= sends job output to any address when a mail agent is installed. | Part of what edit means; not filtered. |
| Concurrent edits | A whole-file write replaces someone else's later change. | Optional if_match (the hash you read) refuses a write if the crontab changed. |
| Cron not running | A write succeeds while the cron service is stopped; nothing runs. | Not detected by the tool; check the service with linuxctl describe system cron. |
Before granting edit on a crontab, treat it as you would a shell: give it only to accounts and users you would give a shell to, and read the audit log.
Recipes
Read-only diagnostics - load, memory, disks, processes, services, logs readable by the account: no entry at all, plus a network limit.
users:
diag:
privileged:
tools:
network/curl:
network: { deny_private: true }
network/ping:
network: { deny_private: true }
Log reader - as above, plus root reads under /var/log and the journal (the log reader example).
Web server operator - its config and site files, reloading nginx; nothing else as root.
users:
web:
privileged:
tools:
files/read:
allowed: true
paths: [/etc/nginx, /var/www, /var/log/nginx]
files/update:
allowed: true
paths: [/etc/nginx/sites-available, /var/www]
files/list:
allowed: true
paths: [/etc/nginx, /var/www, /var/log/nginx]
services/manage:
allowed: true # any unit - see the table above; prefer this only on a host that is nginx and nothing else
network/curl:
network:
allow: [127.0.0.1] # its own sites via localhost
deny_private: true
services/manage has no per-unit limit yet, so on a shared host this recipe is still "root" through it - leave it out there, and restart nginx yourself.
Before granting - a checklist
- Does the task need root at all? Most diagnostics don't: try the call without
privileged: truefirst. - Is the MCP user a dedicated OS account, not root and not in
docker,lxd,disk,adm? - Is every path tool limited to the directories the task needs - and none of them
/etc,/root,/usr,/etc/systemd,/etc/mcpdfor writes? - Is the grant on the "full root" list above? Then decide as if giving a root shell.
- Does the user have
network:limits onnetwork/curl/network/ping? - Does
kernel/system-controlhavewrite_keys, or noallowedat all? - After the change, read the reload's summary (
linuxctl edit mcpd config sudoprints it; the daemon logs it as anaudit=trueline) and check it says what you meant.