DevOps MCP is a personal tool for one engineer in a trusted self-hosted environment. It is not a public-facing service and not a shared one: there is no user model, and every decision below is measured against that. The 12.10.2026 audit (CHANGELOG 0.6.0) is written up with this model in mind.
What confirmed=true actually is. The flag is set by the model itself, in the same
call it is meant to authorize. Nothing stops a model from setting it, and in five months
of real use it was set on 47% of ssh_exec calls. So the allowlist is a guard against an
accidental destructive command, not a security boundary. The boundaries that do hold are
the permission prompt in the MCP client and the fact that the endpoint listens on
localhost only. Tool annotations (readOnlyHint, destructiveHint) exist so the client
can enforce that prompt by protocol instead of by convention.
What it protects against:
- SSH read-only by default — only safe, read-only commands are allowed without
confirmed=true(uptime, df, cat, grep, journalctl, systemctl status, docker ps, etc.) - Flags are parsed, not searched — for commands whose flags decide between a read and a write (
sed,curl,git,ip,journalctl,dpkg,find,date,sort,ssand others) the words are parsed per command incommand_args.py: glued short options (-ni), GNU abbreviations (--rot), tabs, sed scripts withe/w,git -c,ls-remote --upload-packandext::all count. Anything the parser does not understand asks for confirmation.wgetalways asks: it writes a file by default.commandpasses only ascommand -v. The words are judged before bash expands them, so$VAR,${X:--i}and{a,b}in the arguments of these commands ask ($HOME,$USER,$PWDexcepted); curl long options come from an allowlist - Hard denials —
findwithout-maxdepth,pkill -f/pgrep -fwithout a[b]racketpattern, andrm -ron/or a top-level directory are refused even withconfirmed=true; each entry comes from a real incident (see CHANGELOG 0.5.3). The check looks for the word anywhere outside quotes, sees through wrappers (sudo,nice -n,timeout,xargs,/usr/bin/find),--longabbreviations, backslashes, split quotes and quoted payloads given tosh -c,eval,su -c,ssh. It is not a shell parser: a variable that expands to the word or base64 piped into a shell is left to the allowlist andconfirmed=true. - SSH command injection — blocks
$(...)and backtick substitution; output redirection always blocked - Bounded output —
ssh_execreads at most 1 MiB of output; past that the channel is closed and the reply says it was cut. A singlecatof a big log used to be read whole and the 256M container was OOM-killed - DNS rebinding —
/mcpand SSE accept only requests addressed to127.0.0.1orlocalhost(any port) and no foreignOrigin(MCP SDKTransportSecuritySettings;MCP_ALLOWED_HOSTSoverrides). A web page that points its own domain at 127.0.0.1 can no longer reach the tunnel from a browser. Cross-site requests without rebinding were already refused (Content-Typeand preflight) - Database queries — without
confirmed=truea query runs in a read-only transaction, so the server itself refusesWITH ... DELETEorEXPLAIN ANALYZE DELETEthat look like reads;pg_terminate_backend,pg_cancel_backend,pg_reload_conf,pg_rotate_logfile,pg_promoteneed confirmation; a confirmed MySQL write is committed. The checks also run on the raw query, so a comment marker inside a string hides neither a second statement (aiomysql always allows several) norINTO OUTFILE; any;before the end is refused - Path traversal in log access —
log_tailresolves symlinks and checks against an allowlist - Unauthorized Docker container control —
stopandrestartrequireconfirmed=true; the protected list is checked against the name Docker resolved, so/devops-mcpor an ID prefix cannot stop the server itself - Privilege escalation — non-root container (mcpuser), all Linux capabilities dropped,
no-new-privileges, source mounted read-only - Remote file access —
file_getandfile_putrefuseshadow,gshadow,sudoers,authorized_keysand private keys outright, also when the path is a symlink to one; both work on the resolved path, so writing to a symlinkedsites-enabledconfig updates the file it points to and keeps the link;file_putneedsconfirmed=true, keeps mode and owner, and writes through a temporary file in the same directory - Backups outside service directories — the previous content goes to
/var/backups/oleg/<full path>.bak_<time>(directories 0700;~/backups/without rights there). A copy next to the file was loaded by nginx as a second config fromsites-enabled/*. No file or directory created on a server names the tool - Verified writes —
file_put's optionalverify_cmdrestores the previous content when the check fails. Because that command runs automatically as part of a write, it is limited to config tests (nginx -tand the like) with no operators, redirects or substitutions, and the arguments are checked per tool: nopostconf -e,php-fpmwithout-t,json.toolwith an output file,yq -i,wp --exec/--require,nginx -g; general execution stays inssh_exec, where it appears as its own tool call the user can see and refuse. For the servers the arguments are an allowlist:nginx -t/-T/-q,apachectl configtest,sshd -t [-f],php -l FILE,php-fpm -t [-y],haproxy -c -f - Audit trail — every tool call logged to
/audit/audit.jsonlwith an outcome ofok,refusedorerror; write failures emit a warning instead of being silently swallowed. File bodies and other long values are stored as<N chars, sha256=...>, so the log proves what was written without becoming a copy of it - Pooled connections are per credential — a kept-open SSH connection is keyed by host, port, username and which key or password opened it, so one host's session is never handed to a different credential; passwords are stored as a hash, sessions close after five minutes idle and on shutdown, and
ssh_sessionscloses them on demand - Host keys are checked by default — once
known_hostsis populated, a host that is not in it is refused rather than warned about; an empty file falls back to warn mode so a fresh install is usable - Jump hosts carry bytes, not credentials — with
jump_hostthe target's authentication happens end to end inside the tunnel, so its password never appears in the process list of the intermediate server, which is what runningsshpassas a command there would do - SSH password auth disabled — password authentication off by default (ALLOW_SSH_PASSWORD=false)
What it does NOT protect against, on purpose:
- A compromised AI client with access to the MCP endpoint
- Insider threats with physical access to the server
- Anyone who can already run code as the owner on the VPS or on the engineer's machine. There is no HTTP token: such a process can read the SSH keys directly, so a token would add nothing for one user. Add one before the endpoint serves more than one person or leaves 127.0.0.1
- Docker socket —
/var/run/docker.sockis root on the host, and:roon a socket does not limit the API. Whoever takes over the MCP process also holds the keys to every client server, which is worse, so a socket proxy was considered and left out for a one-user deployment - Reading secrets —
ssh_exec,file_get(outside the deny-list) anddb_querycan read.envfiles, configs and password hashes; reading them is part of the work. Commands that pass passwords on the command line (mysql -p...) are visible in the process list of the target and are logged verbatim in the audit log, which only root, the owner and the MCP can read - Commands keep running after a timeout — the reply says so; without a pty there is no reliable way to stop the remote process
Not handled yet:
- Sending data out through DNS tools —
dig -f FILEandnslookupreading stdin turn each line of a file into a query to a server of the caller's choice, without confirmation. curl's ways to send a local file ask - State prepared beforehand — a file named
-iin the working directory turnssed p *into an in-place edit, a repository's own.git/configcan name programs thatgit statusruns, and a config under test can load code (lua-loadin haproxy). Each needs a write first, and writes ask - Recursive walks from
/—du,grep -rand the like pass the validator and keep running on the server after a timeout, which on a large disk is real load. Blocking them outright would cut a useful tool, so the fix is still being worked out: a disk overview of the remote host first (usage and inodes per filesystem), then walks only into the directories it points to. Until then the operator picks the narrowest command for the server at hand
Intended deployment: localhost-only (127.0.0.1:8765), accessed via SSH tunnel from a trusted machine.
If you find a security issue, please do not open a public GitHub issue.
Email: oleg.v.usoltsev@gmail.com
Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
You can expect a response within 72 hours.