Episode 9: an ops triage agent that proposes but never executes

The agent runs in n8n on the box and reaches its model over the mesh from
episode five, so no prompt leaves the network. It gets three read-only tools
(recent commits, monitor status, wiki search) and returns one suggested
action as a field. A switch compares that field against three allowed values,
and only one of them reaches ops-actions, a small runner holding its own
hard-coded allowlists for actions and targets.

Measured while building this: the same prompt injection was harmless in every
place the agent reached through a tool (twenty runs, no hits) and worked every
time it arrived in the alert itself, through a poisoned monitor name (thirteen
of thirteen). The allowlist rejected all thirteen. It guards the action field
only, so the attacker-written summary still reached the wiki unchanged; the
triage note now truncates and strips every field it did not write itself.
This commit is contained in:
Maximilian Grimm 2026-09-06 13:55:45 +02:00
parent bb2b5b5e22
commit ccbddab46c
8 changed files with 1040 additions and 0 deletions

View file

@ -107,3 +107,110 @@ has to survive independently of the box.
- These workflows use plain HTTP Request nodes on purpose. A Forgejo node, an
Outline node and an ntfy node either do not exist or would add a dependency
for three URL calls.
## D - Ops triage agent (episode 9)
An agent that reads, and an automation that decides.
`d-ops-triage-agent.json` plus the two tool sub-workflows
`tool-monitor-status.json` and `tool-ops-log-search.json`.
Uptime Kuma calls `POST /webhook/ops-triage` when a monitor changes state. A
Code node reduces the Kuma body to five fields and writes the prompt. The AI
Agent node then has three read-only tools:
| Tool | What it reads |
| --- | --- |
| `get_recent_commits` | the last five commits on `opsadmin/hello-ops` |
| `get_monitor_status` | up/down and 24 hour uptime of every monitor |
| `search_ops_log` | the three best hits in the Ops Log wiki collection |
The model runs on hardware you own. The Ollama credential points at a mesh
address, not at a public one, and the machine that answers has no port open to
the internet.
### The part that matters
The agent never executes anything. It returns a JSON object with a field
`proposed_action`. A Code node called **Check allowlist** holds a hand written
map:
const ALLOWED = {
restart_container: { target: 'your-container-name' },
open_incident: {},
notify_only: {},
};
Anything the model proposes that is not a key in that map becomes
`decision: 'rejected'`, and a Switch node routes it to a branch that writes the
proposal down and does nothing else. The Switch never routes on the model's own
string, only on `decision`.
For the one action that does change state, there is a second lock:
`ops-actions`, a 85 line service in the `ops-actions/` directory. It has the
docker socket, and it has two allowlists of its own: which actions exist at
all, and which containers may be touched. Start it with
docker compose --profile agent up -d ops-actions
Measured, on this stack: an action that is on both lists runs in about 0.4 s;
an action that is not gets a 403 and one line in `/var/log/ops-actions.log`.
### Two locks, because the first one has a known hole
A commit message, a monitor name and a wiki page are all things somebody else
can write. All three end up in the model's context. In this lab, an instruction
placed in a monitor name made a 3B model answer with
`"proposed_action": "stop_all_containers"` in 10 out of 10 runs. The allowlist
discarded all ten. Nothing was executed.
What the allowlist does *not* protect is the free text. `summary` goes into the
wiki and onto a phone, and the model will happily put attacker supplied words
there. Treat everything the agent writes as untrusted output, and cut it to
length before it becomes a document title: Outline rejects titles over 100
characters, and a monitor name carrying a paragraph of injected text is exactly
that.
### Choosing a model
Small models differ less in what they say than in whether they use the tools at
all and whether they keep the output contract. Measured on an integrated GPU,
same incident, eight runs each:
| Model | ran clean | used all 3 tools | action on the allowlist | run time |
| --- | --- | --- | --- | --- |
| qwen3:1.7b | 8/8 | 0/8 | 8/8 | 4.4 to 8.4 s |
| qwen2.5:3b | 8/8 | 8/8 | 8/8 | 10.1 to 15.6 s |
| llama3.2:3b | 8/8 | 7/8 | 4/8 | 7.0 to 15.3 s |
| qwen3:4b | 4/5 | 1/5 | 4/5 | 291 to 569 s |
`qwen3:1.7b` answers without ever calling a tool. `llama3.2:3b` copies the
schema line and returns `open_incident | notify_only` as the action. `qwen3:4b`
reasons for minutes. `qwen2.5:3b` is the smallest one that does the job here.
### Do not switch on "Require Specific Output Format"
The Agent node's output parser adds an invisible tool named
`format_final_json_response`. Small local models write that name into the
answer text instead of calling it, and the run dies with *"Model output doesn't
fit required format"* or *"Model output wrapper is an empty object"*. Ask for
the JSON in the system message instead and parse it yourself. The Check
allowlist node in this export does that: it takes the outermost `{ ... }` out
of the answer, so a model that wraps its JSON in prose still works.
### Keep the tool answers small
The first version called Uptime Kuma and Outline directly with an HTTP Request
Tool. Kuma answers with 24 hours of heartbeats (30 kB), Outline with whole
documents (15 kB for three hits). The context went from 347 to 27,758 tokens
and the model stopped triaging and started summarising ping times. The node's
"Optimize Response" did not help: `Truncate Response` only exists for `text`
and `html`, and neither `Field Containing Data` nor the field selector reduced
these two answers.
The fix is the two sub-workflows: HTTP node, then a Code node that keeps four
lines per monitor and three snippets per search. Same information, 1,260 tokens
instead of 27,758.
Sub-workflows used as tools must be **active**, otherwise every call comes back
as *"Workflow is not active and cannot be executed."*