Our machines, through pull requests
We let an AI agent look after the machines of a home network with limen. It never gets a shell, and it never gets to decide what it may do: when it needs a new script or a new permission, it asks for it in a pull request, and a person merges it or not.
What the agent can do
limen is an MCP server, the way agents such as Claude Code call tools, that reaches each machine over
SSH. On each machine, a limen.toml says which files the agent can read and which packs of
scripts it can run, and every script becomes a tool with the arguments its header declares. Nothing
else: no shell, no command line, and limen checks every argument before the script runs.
So the question that matters is who writes the packs and the limen.toml. In our case, the
agent does, and a person approves.
Everything in a repository
Each machine's configuration and packs live in a private Git repository:
infra/ packs/systemd/ packs/docker/ shared packs nodes/nas/limen.toml what the agent may read and run nodes/nas/sync the main branch, then apply nodes/nas/apply a helper, run by sync
Only the first clone is made by hand, when a machine is set up, with a token that can only read, kept under
/etc/limen/, a folder limen never lets the agent read. Its limen.toml is a link
into that clone.
The agent proposes, a person merges
When the agent needs something it can't do yet, it can't change the machine to get it. It changes the repository instead, and opens a pull request. Say it wants to read nginx's logs and reload it after a change:
# nodes/nas/limen.toml [files] - allow = ["/opt/state/nodes/nas/**"] + allow = ["/opt/state/nodes/nas/**", "/var/log/nginx/*.log"] # nodes/nas/reload_nginx, new + #!/bin/sh + #: description = "Checks nginx's configuration and reloads it" + set -eu + nginx -t + exec nginx -s reload
A person reviews it on GitHub like any other change: the script that will run as root, the arguments
limen will check, the paths the agent wants to read. Once it is merged, we ask the agent to sync, and
nobody opens a shell: the agent calls sync, one of the machine's own scripts, which brings the
clone to main and applies it. From then on reload_nginx is a tool the agent can
call.
# nodes/nas/sync, what the agent runs on the machine (abridged)
#: description = "Brings the machine to the repository's main branch and applies it"
cd /opt/state
git fetch --depth 1 origin main
git reset --hard origin/main
exec ./nodes/"$LIMEN_NODE"/apply
Why it holds
-
The agent can't give itself a permission. It can't push to
main, and the token on the machine only reads: every script and every path it can use went through a pull request a person merged. - Each change is reviewed once, where it can be read. A pull request shows the whole script and what it changes, and it stays there: months later, the repository's history says when the agent got each permission, and its pull request who approved it.
- The limits are on the machine. limen enforces them on each machine, not in the agent or the server it talks to: a confused agent, or one led by something it read in a log, still finds only the scripts on offer.
What we don't do
limen can also ask a person before each run of a script that changes things. We don't use it: the review happens once, on the pull request, and the scripts on offer are the ones we are fine with the agent running whenever it decides to, such as restarting a container, clearing old images or syncing. Anything we'd want to confirm every time would go behind that question instead.
The setup, step by step, is in limen's docs/scripts.md.