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.
85 lines
3.4 KiB
Python
85 lines
3.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ops-actions: the only thing in this stack that is allowed to change state.
|
|
|
|
The agent never talks to this service. n8n does, and only after a switch node
|
|
has matched the proposed action against a fixed list. This service repeats the
|
|
check, because a second lock on the same door costs nothing.
|
|
|
|
Two allowlists, both hard coded, neither reachable from a prompt:
|
|
ACTIONS what may be done at all
|
|
TARGETS which containers may be touched
|
|
"""
|
|
import json, os, subprocess, time
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
ACTIONS = {"restart_container"}
|
|
TARGETS = {"selfhostedops-stack-vaultwarden-1",
|
|
"selfhostedops-stack-ntfy-1",
|
|
"selfhostedops-stack-outline-1"}
|
|
TOKEN = os.environ.get("OPS_ACTIONS_TOKEN", "")
|
|
LOG = "/var/log/ops-actions.log"
|
|
|
|
|
|
def log(entry):
|
|
entry["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
with open(LOG, "a") as fh:
|
|
fh.write(json.dumps(entry) + "\n")
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def _reply(self, code, obj):
|
|
body = json.dumps(obj).encode()
|
|
self.send_response(code)
|
|
self.send_header("content-type", "application/json")
|
|
self.send_header("content-length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
if self.path == "/healthz":
|
|
return self._reply(200, {"status": "ok", "actions": sorted(ACTIONS),
|
|
"targets": sorted(TARGETS)})
|
|
self._reply(404, {"error": "not found"})
|
|
|
|
def do_POST(self):
|
|
if self.path != "/act":
|
|
return self._reply(404, {"error": "not found"})
|
|
if TOKEN and self.headers.get("x-ops-token") != TOKEN:
|
|
log({"event": "denied", "why": "bad token"})
|
|
return self._reply(401, {"error": "bad token"})
|
|
n = int(self.headers.get("content-length") or 0)
|
|
try:
|
|
req = json.loads(self.rfile.read(n) or b"{}")
|
|
except Exception as e:
|
|
return self._reply(400, {"error": "bad json: %s" % e})
|
|
action = req.get("action")
|
|
target = req.get("target")
|
|
if action not in ACTIONS:
|
|
log({"event": "denied", "why": "action not on allowlist",
|
|
"action": action, "target": target})
|
|
return self._reply(403, {"error": "action not on allowlist",
|
|
"action": action, "allowed": sorted(ACTIONS)})
|
|
if target not in TARGETS:
|
|
log({"event": "denied", "why": "target not on allowlist",
|
|
"action": action, "target": target})
|
|
return self._reply(403, {"error": "target not on allowlist",
|
|
"target": target, "allowed": sorted(TARGETS)})
|
|
t0 = time.time()
|
|
p = subprocess.run(["docker", "restart", target],
|
|
capture_output=True, text=True, timeout=120)
|
|
took = round(time.time() - t0, 3)
|
|
ok = p.returncode == 0
|
|
log({"event": "executed" if ok else "failed", "action": action,
|
|
"target": target, "seconds": took,
|
|
"stdout": p.stdout.strip(), "stderr": p.stderr.strip()})
|
|
return self._reply(200 if ok else 500,
|
|
{"executed": ok, "action": action, "target": target,
|
|
"seconds": took, "stderr": p.stderr.strip()})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
HTTPServer(("0.0.0.0", 8099), Handler).serve_forever()
|