Skip to content
early · systemd · docker · packages
gilfoyle — systems. networking. security.
gilfoyle@host:~$

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

nestor@web1ssh
$ssh gilfoyle@web1
·authenticated as "nestor". subsystem gilfoyle-mcp, no shell.
$ssh gilfoyle@web1 exec "rm -rf /"
✗refused. exec is not something I do.
$mcp › start_unit nginx.service
✓done. nginx.service is active.
$mcp › stop_unit sshd.service
✗refused. sshd.service is not in manage_units.
$
the hard no'sno shellno rootno execno createno installno remove
[01]why

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

[02]refusals

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
[03]guarantees

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.

01compiler

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.

02code

SSH is a protocol, not a shell

Only the gilfoyle-mcp subsystem is served. shell, exec, pty and every forwarding request are refused.

03code

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.

04code

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.

05systemd unit

Not root

A dedicated unprivileged user, NoNewPrivileges, an empty capability set. I never escalate; polkit decides what systemd and packages may do.

06config

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.

07lint

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.

08cargo

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.

09startup

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.

[04]defense in depth

Try to break in. I'll wait.

Several layers, each with its own job. Here is what the usual attacks run into.

01Network

public keys only. No password, no keyboard-interactive.

02Transport

only the gilfoyle-mcp subsystem. shell, exec, pty and forwarding refused.

03Identity

each key can be limited to a subset of tools

04Tools

a fixed set of compiled functions. None takes a command or a path.

05Authority

allowlists empty by default. polkit behind systemd and packages.

06Process

unprivileged user, no capabilities, syscall filter

07Kernel

read-only filesystem, no writable-and-executable memory, restricted namespaces

↓ the core

  • open a shellchannel policyrefused
  • run a command (exec)channel policyrefused
  • request a ptychannel policyrefused
  • forward a portchannel policyrefused
  • log in with a passwordpublic keys onlyrefused
  • mutate with a read-only keyper-key tool grantrefused
  • inject a command in an argumentno shell to interpret itnothing runs
  • read an arbitrary fileno tool takes a pathno such tool
  • spawn a processlint on std::process::Commandbuild fails
  • install or remove a packageclosed enumno such operation
  • create or remove a containerclosed set of requestsno such request
  • start an unlisted unit[systemd] manage_unitsrefused
  • start an unlisted container[docker] manage_containersrefused
  • gain extra privilegesNoNewPrivileges, no capabilitiesdenied
  • impersonate a node (MITM)pinned host-key fingerprintconnection dropped
refused at runtimecan't be written
[05]hardening

Locked down by default.

The systemd unit I ship with has all of this switched on.

/etc/systemd/system/gilfoyle.serviceas shipped
User=gilfoyle
Group=gilfoyle
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
CapabilityBoundingSet=∅
AmbientCapabilities=∅
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
ReadOnlyPaths=/proc/uptime

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.

[06]quality

Every change has to pass this.

make check runs the formatter, clippy, the tests and mutation testing. A single surviving mutant fails it.

make checkmutation score required
fmt --checkclippy -D warningstestscargo-mutants100%

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.

[07]the tools

The whole vocabulary. That's it.

Every tool and its arguments. If it's not here, I don't do it.

Host/proc/uptime
  • get_uptime—host uptime, since kernel bootread
systemdD-Bus, in-process
  • list_unitsactive_state?, name_contains?, unit_type?, limit?list unitsread
  • unit_statusnamedetailed status of one unitread
  • start_unitnamestart an allowlisted unitmutation
  • stop_unitnamestop an allowlisted unitmutation
ContainersDocker socket, in-process
  • list_containersstate?, name_contains?, limit?list containersread
  • container_statuscontainerdetailed status of one containerread
  • start_containercontainerstart an allowlisted containermutation
  • stop_containercontainerstop an allowlisted containermutation
  • restart_containercontainerrestart an allowlisted containermutation
PackagesPackageKit over D-Bus, in-process
  • list_packagesname_contains?, limit?installed packages and versionsread
  • list_updatesmin_severity?, limit?available updatesread
  • preview_upgrade—simulate an upgrade, change nothingread
  • refresh_package_cacheforce?refresh metadata (apt update)mutation
  • upgrade_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.

[08]topology

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.

agent

the AI that decides which tools to call

MCP over stdio
client

keeps a connection to each node and presents one MCP surface

SSH · gilfoyle-mcp subsystem
node· host A

one process on one host, served over SSH

node· host B

one process on one host, served over SSH

[09]architecture

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 casesnone
  • gilfoyle-systemdriven adapter (host uptime)read-only
  • gilfoyle-systemddriven adapter (systemd over D-Bus)system bus
  • gilfoyle-packagekitdriven adapter (PackageKit over D-Bus)system bus
  • gilfoyle-dockerdriven adapter (Docker over its UNIX socket)socket
  • gilfoyle-sshdriving adapter: embedded SSH server + policynetwork
  • gilfoyle-clientdriving adapter: multi-node aggregatornetwork
  • gilfoyle-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.