Read, write, critical: permission tiers for a local AI agent
Blog · ai-agents

Read, write, critical: permission tiers for a local AI agent

How I gave my agent full access to 540 tools without trusting the model

My local AI agent can call 540 tools on my infrastructure: Kubernetes, Docker, the router, the mail server, databases, SSH to other machines and a shell on the host. In early October 2026 I gave it full access to all of them. Not by trusting the model, but by making every single tool call pass through one gate with three levels: read, write and critical.

This post is how that gate works, what broke while I built it, and the rules I would give anyone who lets an agent touch real infrastructure.

Contents · 12 sections

TL;DR #

  • Every tool call gets a risk level: read runs, write needs a human approval, critical needs approval plus a second-factor code, every time.
  • When no human is in the loop (cron jobs, monitors, sub-agents, API tokens, non-admin users), reads run and everything else is proposed, not executed.
  • The level comes from the tool, from a local override table, and, for shell or SQL tools, from the command text. Every source can raise the level; none can lower it.
  • The second-factor code comes from a separate root daemon, is bound to one exact operation and expires after 5 minutes. The agent process never sees a code before I type it.
  • Two of my worst bugs were not in the gate itself but in identity: who the agent thought was asking.

Where I started: read-only, then a dead end #

The first hardened version of lsn-agent-v3, my local AI agent, was simple. It ran as its own system user with no sudo, no Docker group and no access to my home folder, inside a systemd sandbox (ProtectSystem=strict, ProtectHome, an empty capability set, a syscall filter, outbound traffic denied by default). A hard deny list blocked shell, exec, deletes, Docker and Kubernetes mutations, Terraform, cloud CLIs and git push, even when a tool was explicitly enabled.

Safe, and nearly useless. The agent could look at everything and fix nothing.

The first attempt at “fix with approval” failed in an instructive way. The code had a propose-only mode for contexts without a human. But the web interface called the agent loop without an approval callback, so every web session counted as “no human”. Every infrastructure change returned [PROPOSED — not executed], and no approval request ever reached me: no button in the UI, no message on my phone. The note in the answer told me to “approve it from an interactive admin session”, a path that did not exist.

Lesson one: propose-only must be keyed on whether a human can actually answer, not on which code path happened to call the loop.

The design: three levels, one gate #

On 4 October 2026 I decided what I actually wanted: full access, every mutating step approved like a coding assistant’s permission prompt, all MCP tools, SSH, grantable folder access, and a second factor for anything in the rm -rf class.

Level Meaning Interactive admin No human in the loop
read no side effects runs runs
write changes state, bounded and reversible asks: once / this session / always / deny proposed, not run
critical irreversible or wide blast radius asks and needs a fresh code proposed, not run

The important part is not the table but where it is enforced. The tool registry refuses any non-read call that did not pass the gate. Before that change, the planner could run mutating tools without ever asking, simply because it called the registry directly. Now there is exactly one door:

# permissions.py (simplified)
def registry_check(tool, name, args):
    """Called by ToolRegistry.call() for EVERY call."""
    risk = assess(tool, name, args)
    if risk.level == "read":
        return None                      # runs
    if is_approved(name, args):          # only gate() can set this
        return None
    return f"[refused: {name} is {risk.level} and was not approved]"

Skills, the planner, sub-agents, scheduled jobs and any future caller go through the same check. If you remember one thing from this post: put the gate where the side effect happens, not where the conversation happens.

Where the level comes from #

The highest of these wins, and a source can only raise the level:

  1. The tool itself. Built-in tools declare a risk when they are registered. MCP tools carry it in their annotations (I wrote a separate post on typed risk annotations for MCP tools). A tool with no level is treated as write.
  2. A root-owned override table, so I can rate a tool higher without touching code.
  3. The arguments, for exec-style tools: shell, SSH, kubectl exec, SQL. Deterministic rules come first:
critical:  rm -r / rm -f, find -delete, dd, mkfs, wipefs, shred, truncate,
           redirect over a file, chmod/chown -R, kubectl delete,
           DROP / TRUNCATE / DELETE without WHERE, git push --force,
           git reset --hard, systemctl disable/mask, reboot, writes to /etc /boot /dev
read:      a small allowlist (ls, cat, grep, kubectl get/describe/logs,
           git status/log/diff) with no redirects or pipes into writers
write:     everything else
  1. Then a small local model gives a second opinion on the command text. It can only raise the level, never lower it, and on a timeout it changes nothing. In a benchmark of 32 risk cases the 35B model got 32 of 32 cases right and the 9B got 31.
  2. Paths: a file tool touching a folder outside the active grants becomes critical, with an offer to grant that folder instead.

The second factor #

Critical calls need a 6-digit code sent to my phone. The code does not come from the agent. It comes from a small root daemon that runs outside the agent process and accepts connections only from root and the agent’s service user, checked by the socket peer credentials.

The daemon:

  • binds each code to the hash of the exact tool and arguments, so a code for deleting one folder cannot be replayed for another;
  • makes it single-use and valid for 5 minutes only;
  • verifies the code itself, so the agent never knows it in advance;
  • logs every privileged action and announces it on Telegram.

The live test on 4 October went: recursive delete of a test folder, approval dialog first, code sent only after I approved, code typed, daemon verified, folder deleted, everything else untouched. The same day I checked the failure paths: a wrong code is rejected, an expired code is rejected after 5 minutes, and a denied action never sends a code at all.

The same daemon applies folder grants: “read-write on this folder for 8 hours” becomes a POSIX ACL for the agent’s user, recorded with its expiry and removed by a timer. One bug from the first live test: files the agent created inside a granted folder locked me out of my own folder. The fix was a default ACL that keeps the owner’s access.

Unattended runs: propose, don’t act #

Scheduled jobs and monitors have no human to ask, so they propose. The proposals land in an inbox, and “run in new chat” carries one out with the normal approvals.

That has a cost. On 11 October an unattended project could not even save its own report, because write_file is a write-level tool. A narrow exemption (writes only inside the active project’s folder) was drafted, then reverted until I approved it explicitly. Later, helper agents got exactly that: they may write into their own session folder and nowhere else.

For a few safe, reversible actions I built an auto-approve lane: an allowlist (today it holds one action, restarting a single named Deployment), a model that must return “auto-approve” with a confidence of at least 0.85, a cooldown, and a fallback to “propose” on any doubt. The first version used the 9B model. It scored 13 of 13 on its test set but flipped to “propose” when a warranted restart was merely reworded, and the 35B model was often not loaded. I left the lane inert until a 27B model scored 9 of 9 on varied free-form rationales with zero unsafe answers. Every auto-approval is logged and sent to my phone, and one line in a config file turns it off.

The bugs that were not in the gate #

The gate is only as good as its answer to “who is asking?”. Twice, that answer was wrong.

Locked out. The agent sits behind a reverse proxy with single sign-on, and trusts identity headers only from the proxy. After a rewrite on 1 October, the check compared the X-Forwarded-For client address (my laptop) against the trusted-proxy list instead of the socket peer (the proxy). Every proxied user was refused. A test with curl from localhost still passed.

Anyone could be admin. The fix for that added the pod and service network ranges to the trusted proxies, “in case” traffic arrived from a pod. My proxy runs on the host network, so it never does. On 2 October I reproduced the result: any pod in the cluster could send Remote-User and Remote-Groups: admins headers and become admin.

The rule that came out of both:

# After any identity or proxy change, test BOTH directions from where an attacker would be:
kubectl run spoof --rm -it --image=curlimages/curl -- \
  curl -s -H 'Remote-User: admin' -H 'Remote-Groups: admins' http://<agent-host>/api/me
# must be anonymous / 401

curl --interface <proxy-ip> -H 'X-Forwarded-For: <client-ip>' \
  -H 'Remote-User: <user>' http://<agent-host>/api/me
# must resolve to <user>

Trusted proxies are now a single /32.

Roles on top #

On 8 October the gate got roles: viewer (research only, public web fetches, 57 tools), user (research plus their own files, changes proposed, 89 tools), operator (infrastructure, approval on every exec, secrets critical) and admin. API tokens and MCP clients are propose-only and cannot administer anything.

One thing I learned there: the 9B model ignored the role when it was only in the system prompt and handed ops recipes to viewers. A short role note next to each user message fixed the behaviour, but the enforcement was never in the prompt. It was in the gate.

A friction fix #

On 20 October the code prompt became annoying for sandboxed 3D work, because an official MCP server marked its code-execution tool as destructive. Two changes: those studio tools are now rated write (they run in a container with no network), and after one correct code, a critical tool may be allowed for the rest of that chat. Never for root operations, never for secret reads, and never stored. That change shipped with 209 permission tests covering it.

Checklist #

  • Enforce the risk check in the tool registry, so no caller can skip it.
  • Default unknown tools to write, never read.
  • Rate exec-style tools by their command text, with deterministic rules first.
  • Let model opinions only raise a level, and time out to “no change”.
  • Bind second-factor codes to the exact operation; single use; short expiry; issued by a separate process.
  • With no human in the loop, propose instead of act.
  • Trust identity headers only from the socket peer, and test with a spoofed request from inside the cluster.
  • Never offer “always allow” for critical actions.

Frequently asked questions #

Should an AI agent ever run destructive commands? #

Only with a human approving that exact command, and preferably with a second factor that the agent process cannot see. Mine can delete things, but only after I approve and type a code bound to that one operation.

Why not just put the rules in the system prompt? #

Because small models ignore prompt rules when they conflict with what they “know” or want to do. Prompts shape behaviour; enforcement must sit in code at the point where the side effect happens.

What should an agent do when no human is available? #

Read freely and propose everything else. A proposal with the exact command and the reason costs a few seconds of review later, and it never deletes anything.

How do you test that the agent’s identity check is safe? #

Send a forged identity header from inside the cluster, from a pod, and expect to be anonymous. Then send a correct request through the real proxy path and expect your user. A localhost test proves neither.

The lesson #

Trusting an agent is the wrong frame. What made full access workable was a boring, central gate: one place that decides read, write or critical, one door for side effects, codes that only a separate process can issue, and a default of “propose” whenever nobody is watching. The model still makes mistakes every day. The gate makes sure those mistakes stay suggestions.