All posts
August 5, 20267 min read

Before you upgrade your self-hosted AI agent, contain it

AI GovernanceSecurityBuild in Public

A new release landed for the self-hosted AI agent we run: roughly 3,650 commits, 1,400 merged pull requests, and 559,000 lines added against 405,000 removed since our version — the vendor's own figures, all approximate.

We didn't upgrade first. We asked a duller question: what can this thing already do?

The answer: text it read could have made it root. Nothing was exercised — no incident, no sign of one. We looked before upgrading, which is when looking is cheap.

The three ordinary things that added up

The agent runs on a Linux box under the administrator's own account — not a purpose-built service account — with a shell tool, most of its value, since we can drive the machine from a phone. That account had passwordless sudo, a common convenience on a workstation you administer yourself, and membership in the docker group, effectively root on any host running Docker in its default, root-daemon configuration. The service ran with no operating-system confinement at all: nothing restricting which files it wrote, nothing blocking new privileges.

Each is defensible alone. Together, the distance from text the agent reads to root on the box is one sudo.

And "text the agent reads" is wider than it sounds. Our chat lane is locked to one person, so nobody can message it out of the blue — but an agent that fetches a web page or summarizes a forwarded message reads text an attacker can influence. Prompt injection needs no inbox, just a content path.

Harden first, upgrade second — as two separate changes

The upgrade was the trigger, but it went second, deliberately.

The release adds an agent-to-agent protocol (other agents can drive it), outbound webhooks, and a raised step cap — each widening what a bad turn can reach. Absorbing that while the agent can still become root is the wrong order.

Two changes, not one: in a single stroke, you can't tell which broke the service.

Two directives that don't do what they look like

We'd have shipped both mistakes if we'd trusted the docs over the running process.

Removing a group doesn't remove it. To cut the agent's Docker access, the obvious move is clearing the service's supplementary groups (SupplementaryGroups=). We did — and the running process still listed docker. systemd documents that setting as extending the groups a user has in the system database, never overriding them. The fix is different in kind: make the Docker socket unreachable for that service (InaccessiblePaths=). "No new privileges" doesn't help here — talking to a root-owned daemon isn't the kind of escalation that flag prevents.

ProtectSystem=strict didn't cover the home directory. The directive that mounts the filesystem read-only sounds total. We enabled it, then looked from inside the service: home was still writable, so the agent could still edit shell startup files — not root, but a foothold that becomes whatever the next login is. That took a second rule: ReadOnlyPaths=, naming the home directory.

Only the first is documented behaviour. The second contradicts the manual, which names three exceptions to that read-only mount and home isn't one. We can't tell you why — only that it's true on our kernel and systemd version, and that we caught it by looking.

A security control you haven't watched fail is a hypothesis.

What did work

The highest-value change was one line: refusing new privileges. sudo now fails for the service — sudo: The "no new privileges" flag is set, which prevents sudo from running as root. — while the human's own sudo is untouched. We made the API-key and SSH-key directories invisible to it, and the rest of home read-only, carving out only what it genuinely writes: its own state plus two runtime caches. One of those caches sits on the account's login PATH, so that foothold is narrowed, not closed.

systemd scores how exposed a service is from 0 to 10, lower being better: 0 is locked down, 10 wide open. We went from effectively unconfined to 7.4, labelled MEDIUM. A real move, and still not a good number.

Not a fortress. The direct route — agent calls sudo, agent becomes root — is closed and verified closed. Still open: the runaway-loop guardrail is warn-only, not a hard stop; a few further confinement directives are deliberately off, each carrying real breakage risk; and those caches stay writable. And this is the operating system's boundary, not the agent's — it says nothing about what the agent does with the tools it legitimately holds.

The cost — no more sudo from the phone — was exactly the dangerous capability, so losing it is the point. Choose that deliberately rather than discover it later.

Then we upgraded — and the vendor had done the right thing

With the box contained, the upgrade was almost boring.

One thing didn't go to plan. We'd written the procedure to pin an exact release tag; the vendor's updater can't — it takes a branch, defaulting to the tip. So we bounded the float: check how far the tip has drifted past the tag, read those commits, proceed only if they're dull. Ours was one commit past, and dull. A weaker guarantee than a pin, and worth knowing you're accepting it.

We checked what the release newly lets the agent reach, not what it added. Two of the three landed off by default: the agent-to-agent protocol ships as a bundled plugin that isn't enabled, and outbound webhooks do nothing until you register an endpoint. Shipping new reach in the off position is the right call.

The step limit is the exception: 90 to 500 is a raised default, live for anyone who hasn't set that value themselves. Ours was already lower. A changed default is the opposite of off-by-default — the one to hunt for after any big upgrade.

Our config predated the vendor's config-versioning scheme entirely and came out stamped version 33 — exactly the kind of thing that silently resets a hand-set safety setting. We checked ours immediately; they survived.

The part we got wrong

In our first write-up we flagged that raised step limit as an exposure and named the config key for it. That key doesn't exist — we'd guessed it from the release note.

What made it stick: asking the tool for a key that doesn't exist prints "Config key not set", which reads exactly like "you're on the default." A typo looked like confirmation. The real setting was already well below the new default, so the exposure we reported wasn't real.

A release note tells you the behavior that changed, not the setting that controls it — confirm the key in the source before you act. And a tool whose "not configured" message is indistinguishable from "at the default" will eventually cost someone a wrong decision.

One change came out of it: the value was right but set by hand, so a config migration could have quietly replaced it. It's now asserted by our configuration management on every run — the difference between happening to be right and being pinned.

If you run an agent that can act

None of this needs our hardware. If you run an agent that can execute commands, touch files, or call APIs on your behalf:

  • Find out what it can already do before you add capability — not what its configuration says, what the running process holds. We hit this one layer up the stack too: an agent we'd configured with one tool was holding eighteen.
  • Take a restore point first, and add one directive at a time. Confinement failures don't show up as policy denials but as a feature that quietly stopped working.
  • Read releases for new reach, not new features — then check whether it arrived switched on.
  • Write down the upgrade procedure before you need it. Running ours corrected two things we'd written wrong.

This was cheap: one evening and seventeen lines of configuration — no new tools, no vendor, no architecture change. Cheap, not free: every line was verified against the running process before we trusted it. It makes the blast radius something you chose rather than inherited — which let us take a 3,650-commit upgrade the same night instead of sitting it out for a month.

That's the pattern on our own box, and we'd expect it on most self-hosted agents: none of those three ingredients was a decision anybody made — they're what you get by default. Governance isn't the brake on adopting AI; it's what lets you say yes quickly.

The takeaway for your team

We publish these because we're running the same experiment our clients are: AI agents doing real work on real systems, with real consequences when they're wrong. If you're putting AI somewhere it can act and want a second pair of eyes on the blast radius, our free AI Opportunity Briefing is a 45-minute, no-pitch conversation: get in touch.

Building something like this?

We help teams take AI from idea to production. Tell us what you're working on.

Get in touch