AI Agents·8 min read·

Agent Guardrails: Allowlists, Dry-Run, Approval

Ship ops-agent guardrails that actually hold: tool allowlists, default dry-run, and a human approval file before any write hits a Linux VPS.

NB

Netbay Developer Relations

Netbay Engineering

On this page

Prompts are not guardrails. A system message that says "never restart production" will be ignored the first time the model is sure it found the fix. Guardrails are code paths the model cannot talk its way around: the tool is absent, the dry-run flag is forced, or a human-signed approval file is missing. If a write can happen because the model asked nicely, you do not have a guardrail. You have a suggestion.

This post is the minimum set that belongs on a Linux VPS running an ops agent: allowlists, dry-run as the default execution mode, and an approval artifact that is not a chat message.

Allowlists at Three Layers

Allow the tool name. Allow the arguments. Allow the Unix user. Missing any one of those is how a tight schema still wrecks a box.

The process should not even import write tools unless the binary is the apply worker. The diagnose worker's DISPATCH dict contains readers only. That is stronger than a prompt.

Argument allowlists are enums and name-to-resource maps. Unix allowlists are the service user, systemd NoNewPrivileges, a dedicated sudoers line if you must talk to systemctl, and a working directory that is not /.

python
import os
import pwd

ALLOWED_TOOLS_WRITE = {"restart_unit", "reload_unit"}
ALLOWED_UNITS = {"nginx.service", "app.service"}
AGENT_USER = "opsagent"

def assert_runtime():
    name = pwd.getpwuid(os.getuid()).pw_name
    if name != AGENT_USER:
        raise SystemExit("refusing to run as " + name)
    if os.geteuid() == 0:
        raise SystemExit("refusing to run as root")

def guard_write(tool, args, dry_run, approval):
    if tool not in ALLOWED_TOOLS_WRITE:
        return "error: tool not in write allowlist"
    unit = args.get("unit")
    if unit not in ALLOWED_UNITS:
        return "error: unit not in allowlist"
    if dry_run:
        return "dry-run: would call %s %s" % (tool, unit)
    if not approval_ok(approval, tool, args):
        return "error: write blocked, approval missing"
    return None

Call guard_write before the subprocess. Returning an error string keeps the loop alive and gives the model something to tell the user. Raising SystemExit is for process-level mistakes like running as root, which should never be a tool observation.

Dry-Run Must Be Real

A dry-run that prints "I would restart nginx" without showing the exact argv is theatre. Construct the command list first, log it, and skip the exec. The log line is the contract.

python
import hashlib
import json
import time

APPROVAL_DIR = "/var/lib/ops-agent/approvals"

def command_for(tool, args):
    if tool == "restart_unit":
        return ["systemctl", "restart", args["unit"]]
    if tool == "reload_unit":
        return ["systemctl", "reload", args["unit"]]
    raise ValueError("no command mapping")

def fingerprint(tool, args):
    blob = json.dumps({"tool": tool, "args": args}, sort_keys=True).encode()
    return hashlib.sha256(blob).hexdigest()[:16]

def approval_ok(approval, tool, args):
    if not approval:
        return False
    path = os.path.join(APPROVAL_DIR, approval + ".json")
    try:
        rec = json.loads(open(path, encoding="utf-8").read())
    except OSError:
        return False
    if rec.get("fp") != fingerprint(tool, args):
        return False
    if rec.get("exp", 0) < time.time():
        return False
    return rec.get("actor") and rec.get("ok") is True

The approval file names a fingerprint of the exact tool and args, an expiry, and a human actor. A model cannot mint this file if the directory is writable only by an admin group. Chat-based "yes, go ahead" is not an actor. An SSH session that writes the JSON is.

Expire approvals in minutes, not days. An approval to restart nginx at 14:00 should not be valid at 02:00 after the unit was already recovered.

Human Approval as a Separate Channel

Do not take approval from the same chat that proposed the plan. That is the model approving itself through a confused user. Send the plan to email, a ticket, or a small localhost UI bound to SSH. The apply worker only reads files in APPROVAL_DIR.

For a one-person shop, a command is enough: ops-approve <run-id> after reading the dry-run log. Record the actor as your Unix user. That beats a Slack bot that reacts with an emoji the model can hallucinate in a screenshot.

Keep the agent off the public internet. If you need a webhook, put it behind SSH or an allowlisted IP and rely on the L3/L4 DDoS filtering already in front of the VPS. Lucknow DC01 does not change the rule: writes are local, approvals are human, keys stay in EnvironmentFile.

Sudoers is a fourth allowlist people skip. If opsagent must talk to systemctl, the line names the binary and the verbs, not ALL. A wildcard unit is how an enum bug becomes a fleet restart. Prefer a tiny helper binary with a hardcoded unit list over a general sudo. Combine that with NoNewPrivileges and ProtectSystem=strict so even a missed allowlist cannot write /etc.

Dry-run logs belong on High-Speed SSD next to traces, mode 640, group adm. Humans read them. The model does not. If the apply worker cannot find the matching fingerprint, it returns an error observation and stops. It does not ask the chat for a second opinion. Intel Xeon Platinum will run this check in microseconds; the expensive part is waiting for a human, which is the correct expensive part.

Write path with three gates Allowlist tool + unit + user Dry-run log argv, skip exec Approval file fp + actor + expiry Exec systemctl What is not a guardrail System prompts, chat replies, emoji reactions, model-written yes Approval lives in a directory the agent user cannot write Diagnose worker does not import write tools at all

Takeaway

Allowlists, a real dry-run of argv, and a fingerprint-matched approval file are the guardrails. Prompts are comments. Put the apply worker on a Netbay Ubuntu VPS in Lucknow and keep approval off the public internet — 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