A Server-Admin Agent That Runs Shell Safely
Build a server-admin agent that never gets a raw shell: allowlisted binaries, a cwd jail, timeouts, and no pipes, on a Linux VPS in Lucknow.
Netbay Engineering
Netbay Engineering
On this page
The tempting tool is run_shell. It makes demos look smart and production look like an incident. A server-admin agent can still run commands. It must not run a shell. argv is a list you constructed. stdin is closed. cwd is a jail. PATH is a directory you own. If the model wants systemctl restart nginx.service, that is a named tool, not /bin/sh -c with a string.
This post is a concrete layout for a Linux VPS: a dedicated user, a tiny executor, and a handful of admin tools that cover 90 percent of "is the box sick" work without exposing bash.
Never Give the Model a Shell String
A string that reaches sh -c is a programming language. Models are good at programming languages. They will add && curl, they will add ; reboot, they will add pipes. subprocess.run with a list and shell=False is the floor. Anything else is a product bug.
Map each tool to a constant binary and a validated argument list. Do not concatenate. Do not format a command line.
import os
import subprocess
BIN = {
"systemctl": "/usr/bin/systemctl",
"journalctl": "/usr/bin/journalctl",
"curl": "/usr/bin/curl",
}
CWD = "/var/lib/ops-agent/jail"
ALLOWED_UNITS = {"nginx.service", "caddy.service", "app.service"}
ALLOWED_URLS = {"http://127.0.0.1:8080/health", "http://127.0.0.1:8080/ready"}
def run_argv(argv, timeout=5):
env = {"PATH": "/usr/bin:/bin", "LANG": "C"}
proc = subprocess.run(
argv,
cwd=CWD,
env=env,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=timeout,
shell=False,
check=False,
)
out = (proc.stdout or "") + "\n" + (proc.stderr or "")
return "exit=%s\n%s" % (proc.returncode, out[:4000])
def tool_unit_show(args):
unit = args["unit"]
if unit not in ALLOWED_UNITS:
return "error: unit not allowlisted"
return run_argv([BIN["systemctl"], "show", unit, "--property=ActiveState,SubState,MainPID"])
def tool_journal_tail(args):
unit = args["unit"]
if unit not in ALLOWED_UNITS:
return "error: unit not allowlisted"
lines = int(args.get("lines") or 50)
if lines < 10 or lines > 100:
return "error: lines out of range"
return run_argv([BIN["journalctl"], "-u", unit, "-n", str(lines), "--no-pager"])
def tool_health(args):
url = ALLOWED_URLS.get(args["name"])
if not url:
return "error: url name unknown"
return run_argv([BIN["curl"], "-fsS", "--max-time", "3", url])stdin is DEVNULL so a tool cannot hang waiting for input. env is a stub so the model cannot inject LD_PRELOAD through your process environment. cwd is a directory with nothing important in it, in case a binary decides to write.
Jail the Process, Not Just the Arguments
The executor should run as opsagent, not root. If systemctl needs privileges, grant a single sudoers line for /usr/bin/systemctl show|restart|reload plus the unit names, with no wildcards. No /bin/bash. No visudo access. No NOPASSWD: ALL.
systemd-run or a dedicated systemd service with ProtectSystem=strict, ProtectHome=true, NoNewPrivileges=true, and ReadWritePaths limited to the log and jail directories will do more than a prompt. PrivateTmp=true. RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 if you must curl localhost.
Do not mount Docker.socket into this user. Do not give it ssh to other hosts. A server-admin agent for one VPS should administer one VPS.
Pin the helper binaries by path. PATH=/usr/bin:/bin is a start; a dict of absolute paths is better. If someone puts a fake systemctl earlier on PATH, shell=False with an absolute path still wins. Keep that dict in git next to the schemas.
[Service]
User=opsagent
Group=opsagent
WorkingDirectory=/var/lib/ops-agent/jail
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/ops-agent
TimeoutStartSec=30
EnvironmentFile=/etc/ops-agent.env
ExecStart=/usr/bin/python3 /opt/ops-agent/admin_loop.pyThat unit is the jail. The Python is the argv builder. Together they replace a root shell with a process you can reason about.
Writes Still Need Dry-Run and Approval
show and journalctl and curl GET can run without a human. restart and reload cannot. Reuse the fingerprint approval file from the guardrails post. The executor should construct argv, log it, and return dry-run text unless approval_ok is true.
Timeouts matter more here than in JSON tools. journalctl on a noisy unit can stall. curl without --max-time can stall. subprocess timeout=5 plus a SIGKILL from systemd TimeoutStopSec is belt and braces.
Log every argv. If you cannot grep the last fifty commands from JSONL, you do not have an admin agent. You have an unattended intern.
Hardware-wise this is a small Ubuntu box: Intel Xeon Platinum, High-Speed SSD for the JSONL, L3/L4 DDoS filtering on the public interface so a webhook trigger is not the incident. The model stays on an external API. Lucknow DC01 is the only place this process should run unless you like two sources of truth. If you need a second box, copy the allowlists, not the root shell.
Takeaway
A server-admin agent runs argv you built, as a jailed user, with timeouts and an allowlist. It never sees a shell string. Build that executor on a Netbay Ubuntu VPS in Lucknow at netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance