Don't give your AI agent SSH
The obvious way to let an agent help with infrastructure is to give it a shell. Here is what that costs, and what to build instead.
AI coding agents are genuinely useful during an incident. They read logs faster than you do, they hold more context than you do at 3am, and they are not tired.
So the natural next step is to let one look at real infrastructure. And the
natural implementation — hand it an SSH key and an execute(command) tool — is a
mistake worth talking about explicitly, because it is so easy and it fails so
quietly.
What execute(command) actually costs
No audit trail. Shell history is not an audit log. It does not record who asked, why, what the result was, or how long it took. After an incident you cannot reconstruct what the agent did.
No input validation. Every argument is string-interpolated into a shell. The usual injection concerns apply, except the untrusted input is now model output shaped by whatever was in the context window — including the contents of the logs it just read.
Unstructured output. The agent parses free text. Most agent misbehaviour we have seen came not from choosing a wrong action but from misreading the output of a correct one — a truncated table, an unexpected locale, a warning line that shifted a column.
Unbounded blast radius. The key reaches everything the key reaches. There is no distinction between "look at disk usage" and "restart the database".
Credential exposure. Everything in the session is in the model's context, including anything printed by a command that was more verbose than expected.
The framing that fixes it
The agent should reason about infrastructure. Exactly one component should touch it.
Agent
Decides what to look at. Holds no credentials.
MCP server
Typed parameters, inventory-scoped targets, read-only by default, one log line per call.
Adapters
Where credentials live. Never cross into the protocol surface.
Infrastructure
Reached only through an operation someone chose to expose.
That component exposes high-level operations — disk_usage(), docker_ps(),
docker_logs(), grafana_alerts() — instead of arbitrary command execution.
The Model Context Protocol is a reasonable way to expose them, because it is open
and client-agnostic: the same server works with Claude Code, Cursor, Codex and
anything else that speaks the spec.
The properties that matter:
- Inventory-driven targeting. The agent names a host or a tag. It cannot address anything that is not in inventory. There is no hostname it can invent.
- Typed, validated parameters. No shell interpolation anywhere in the path.
- Structured JSON responses. The agent reasons over data, not prose.
- Read-only by default. Mutating operations require an explicit
confirm: true. This costs one token and removes an entire class of incident. - Credentials isolated in the adapter layer. They never cross into the protocol surface, so they are never in the model's context.
- A structured log per call: tool, user, target, duration, result.
What it looks like in practice
An agent investigating a slow server:
cpu_usage(server: "archive") → 92% used, status: "warning"
docker_ps(server: "archive") → prometheus: "Restarting (1) 4s ago"
docker_logs(server: "archive", container: "prometheus") → "panic: too many open files"
grafana_alerts(instance: "main") → firing: HighDiskUsage on archive:/
Four calls to a verifiable root cause. Every one logged, validated and scoped — and reviewable by a human afterwards, which is the part that makes it safe to leave running.
The hard part is granularity
This is the real design tension, and there is no formula for it.
Too coarse, and the agent cannot investigate anything the tool author did not anticipate; it hits the edge of the toolset and gives up or hallucinates. Too fine, and you have rebuilt a shell with extra ceremony and none of the safety.
The heuristic that worked for us: expose the operations an engineer would actually perform during an incident. Check CPU. List containers. Read logs. Check alerts. That keeps the surface both genuinely useful and genuinely bounded.
Say what you do not have
One last thing, easy to skip: be explicit in the tool descriptions about what is implemented versus planned. An agent that believes a tool exists behaves worse than one that knows it does not — it will construct a plan around a capability it cannot use and then improvise when the call fails.
Safety here is a design property, not a policy one. Writing "be careful" in a system prompt is not a control. Read-only defaults, inventory-scoped targeting and validated parameters are.