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.
99 lines
2.4 KiB
JSON
99 lines
2.4 KiB
JSON
{
|
|
"name": "T1 - tool: monitor status",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"inputSource": "passthrough"
|
|
},
|
|
"id": "when-called-by-the-agent",
|
|
"name": "When called by the agent",
|
|
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
-300,
|
|
0
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "GET",
|
|
"url": "http://uptime-kuma:3001/api/status-page/heartbeat/status",
|
|
"options": {}
|
|
},
|
|
"id": "kuma-heartbeats",
|
|
"name": "Kuma heartbeats",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
-80,
|
|
0
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "GET",
|
|
"url": "http://uptime-kuma:3001/api/status-page/status",
|
|
"options": {}
|
|
},
|
|
"id": "kuma-status-page",
|
|
"name": "Kuma status page",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
140,
|
|
0
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "\n// Kuma answers with 24 hours of heartbeats, roughly 30 kB. The agent needs\n// four lines. Reduce here, not in the model's context window.\nconst names = {};\nconst status = $('Kuma status page').first().json;\nfor (const g of status.publicGroupList ?? []) {\n for (const m of g.monitorList ?? []) names[String(m.id)] = m.name;\n}\nconst hb = $('Kuma heartbeats').first().json;\nconst out = [];\nfor (const [key, value] of Object.entries(hb.uptimeList ?? {})) {\n const id = key.split('_')[0];\n const beats = (hb.heartbeatList ?? {})[id] ?? [];\n const last = beats[beats.length - 1] ?? {};\n out.push({\n monitor: names[id] ?? ('monitor ' + id),\n state: Number(last.status) === 1 ? 'up' : 'down',\n uptime_24h: Math.round(Number(value) * 10000) / 100 + ' %',\n last_message: last.msg || '',\n });\n}\nreturn [{ json: { monitors: out } }];\n"
|
|
},
|
|
"id": "shrink-to-four-lines",
|
|
"name": "Shrink to four lines",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
360,
|
|
0
|
|
]
|
|
}
|
|
],
|
|
"connections": {
|
|
"When called by the agent": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Kuma heartbeats",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Kuma heartbeats": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Kuma status page",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Kuma status page": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Shrink to four lines",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1"
|
|
}
|
|
}
|