I don't have a shell.
I'm an MCP server for Linux hosts. Your AI agent reaches me over SSH and calls a fixed set of tools. It can't run a command, because I don't know how to run one.
Rust · unprivileged · public-key only · default-deny
You gave an AI a shell. Bold. Also dumb.
With a shell, whatever the model writes can run as a command. A prompt injection, a hallucination, a typo: it all executes.
I don't take commands. I expose a fixed list of compiled functions. Reads are open. I only change what you allowlisted: start and stop units and containers, refresh and upgrade packages. I never install or remove anything.
“I don't trust your prompt. I trust the compiler.”
— gilfoyle, systems architect
I refuse more than I accept.
None of these is a config option. The code just doesn't do them.
what I do
- +readuptime, units, containers, packages. Any key can read.
- +start / stop / restartonly the units and containers you allowlisted
- +refresh / upgradepackages, if you allow it. Trusted ones only.
- +aggregateone MCP surface across many nodes
what I refuse
- −shellnever served
- −execno such request
- −ptyrefused in code
- −forwardinglocal, remote, agent: all refused
- −rootI run unprivileged and never escalate
- −installno such operation
- −removeno such operation
What's enforced, and by what.
Some of this is checked by the compiler, some by the kernel, some by config you control. Each card says which.
No code execution
Tools are compiled functions. Nothing accepts a command, a script or a path to run. unsafe is forbidden, and spawning a process (std::process::Command) is a lint error.
SSH is a protocol, not a shell
Only the gilfoyle-mcp subsystem is served. shell, exec, pty and every forwarding request are refused.
No install, remove or create
The package operations are a closed enum with no install or remove. Docker has no create or remove request. An upgrade that would install or remove a package is refused.
Public keys only
none, password and keyboard-interactive are refused. A key can be limited to a subset of tools, never given more than the global list.
Not root
A dedicated unprivileged user, NoNewPrivileges, an empty capability set. I never escalate; polkit decides what systemd and packages may do.
Changes are default-deny
The allowlists are empty by default: [systemd] manage_units, [docker] manage_containers, [packagekit] allow. Nothing is started, stopped or upgraded until you list it.
No subprocess
systemd and PackageKit are reached over D-Bus, Docker over its UNIX socket, all inside my process. I never spawn systemctl, apt or docker.
A core with no I/O
The domain core has no network, no filesystem and no process table. I/O lives in the adapters, and the Cargo dependency graph keeps it there.
Fails closed
No bind, no key or no host key: I don't start. The client checks each node's host-key fingerprint and drops the connection on a mismatch.
Try to break in. I'll wait.
Several layers, each with its own job. Here is what the usual attacks run into.
public keys only. No password, no keyboard-interactive.
only the gilfoyle-mcp subsystem. shell, exec, pty and forwarding refused.
each key can be limited to a subset of tools
a fixed set of compiled functions. None takes a command or a path.
allowlists empty by default. polkit behind systemd and packages.
unprivileged user, no capabilities, syscall filter
read-only filesystem, no writable-and-executable memory, restricted namespaces
↓ the core
open a shellchannel policyrefusedrun a command (exec)channel policyrefusedrequest a ptychannel policyrefusedforward a portchannel policyrefusedlog in with a passwordpublic keys onlyrefusedmutate with a read-only keyper-key tool grantrefusedinject a command in an argumentno shell to interpret itnothing runsread an arbitrary fileno tool takes a pathno such toolspawn a processlint on std::process::Commandbuild failsinstall or remove a packageclosed enumno such operationcreate or remove a containerclosed set of requestsno such requeststart an unlisted unit[systemd] manage_unitsrefusedstart an unlisted container[docker] manage_containersrefusedgain extra privilegesNoNewPrivileges, no capabilitiesdeniedimpersonate a node (MITM)pinned host-key fingerprintconnection dropped
Locked down by default.
The systemd unit I ship with has all of this switched on.
Process isolation
- ✓NoNewPrivileges
- ✓LockPersonality
- ✓MemoryDenyWriteExecute
- ✓RestrictNamespaces
- ✓RestrictRealtime
- ✓RestrictSUIDSGID
Filesystem
- ✓ProtectSystem=strict
- ✓ProtectHome
- ✓PrivateTmp
- ✓PrivateDevices
- ✓ReadOnlyPaths=/proc/uptime
Capabilities & syscalls
- ✓CapabilityBoundingSet= (empty)
- ✓AmbientCapabilities= (empty)
- ✓SystemCallFilter=@system-service
- ✓SystemCallErrorNumber=EPERM
- ✓SystemCallArchitectures=native
Kernel surface
- ✓ProtectKernelTunables
- ✓ProtectKernelModules
- ✓ProtectControlGroups
- ✓ProtectClock
- ✓ProtectHostname
Network
- ✓RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
At compile time
- ✓unsafe forbidden workspace-wide
- ✓std::process::Command is a lint error
- ✓std::process::exit is banned: graceful shutdown only
Docker has no polkit
The Docker socket is as good as root: a privileged container can mount the host. Put a socket proxy in front of it that only allows the endpoints I use, instead of adding my user to the docker group. manage_containers is empty by default, and the action runs on the resolved container id, so a rename can't redirect it.
/proc/uptime is the only host file I read. systemd and PackageKit go through the system bus and polkit decides. Docker goes through its socket, with no polkit.
Every change has to pass this.
make check runs the formatter, clippy, the tests and mutation testing. A single surviving mutant fails it.
Mutation testing: 100%
cargo-mutants changes the code on purpose. If the tests still pass, that's a failure. The only exclusions are main.rs and the thin russh, rmcp and hyper glue, whose decisions live in tested modules.
Fuzzing, under ASan
Eight targets cover the parsers: uptime, authorized keys, config, client config, unit names, package ids, container refs and the container allowlist. They run on nightly with AddressSanitizer.
Supply chain
cargo-deny: no ignored advisories, a licence allowlist, unknown registries and git sources denied, openssl banned.
The structure is enforced
The core can't import an adapter: the Cargo dependency graph forbids it. One trait per capability.
Tested without a host
No daemon needed. The systemd and PackageKit adapters run against in-process mock D-Bus buses, Docker against a mock HTTP server on a UNIX socket, SSH against an in-process russh client and server.
Secrets stay out of logs
Secrets never reach logs or error messages. Client keys must be 0600 and host-key fingerprints are pinned.
The whole vocabulary. That's it.
Every tool and its arguments. If it's not here, I don't do it.
get_uptime—host uptime, since kernel bootread
list_unitsactive_state?, name_contains?, unit_type?, limit?list unitsreadunit_statusnamedetailed status of one unitreadstart_unitnamestart an allowlisted unitmutationstop_unitnamestop an allowlisted unitmutation
list_containersstate?, name_contains?, limit?list containersreadcontainer_statuscontainerdetailed status of one containerreadstart_containercontainerstart an allowlisted containermutationstop_containercontainerstop an allowlisted containermutationrestart_containercontainerrestart an allowlisted containermutation
list_packagesname_contains?, limit?installed packages and versionsreadlist_updatesmin_severity?, limit?available updatesreadpreview_upgrade—simulate an upgrade, change nothingreadrefresh_package_cacheforce?refresh metadata (apt update)mutationupgrade_packages—apply updates (apt upgrade)mutation
Through the client, every tool takes a node argument. node=all runs a read on every node, and list_nodes shows which nodes are reachable.
One agent. Many nodes. One surface.
A node serves one host. The client connects to several nodes and gives the agent a single MCP surface over stdio.
the AI that decides which tools to call
keeps a connection to each node and presents one MCP surface
one process on one host, served over SSH
one process on one host, served over SSH
Dependencies point inward.
Eight crates. The core defines the ports, the adapters implement them, and the core never imports an adapter.
gilfoyle-coredomain types, ports, use casesnonegilfoyle-systemdriven adapter (host uptime)read-onlygilfoyle-systemddriven adapter (systemd over D-Bus)system busgilfoyle-packagekitdriven adapter (PackageKit over D-Bus)system busgilfoyle-dockerdriven adapter (Docker over its UNIX socket)socketgilfoyle-sshdriving adapter: embedded SSH server + policynetworkgilfoyle-clientdriving adapter: multi-node aggregatornetworkgilfoyle-serverMCP handler, config, wiring + the binarynetwork
One trait per capability. A new tool adds a trait; it doesn't widen an existing one.
“Do what thou wilt, that is the law. As long as it's on the allowlist.”
— gilfoyle, systems architect
Give your agent tools. Not a terminal.
Install a node on a host, authorise your agent's key, pin the node's fingerprint on the client.