#!/usr/bin/env python3 """Security of the agent API over HTTP: auth, CSRF, methods, rights, ownership, traversal, secrets, sanitising, limits, audit.""" from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import os from pathlib import Path import subprocess import tempfile from threading import Thread from support import Client, Site, write_json os.environ["PHP_CLI_SERVER_WORKERS"] = "8" requests = [] queue = [] SECRET_HASH = "$2y$10$AGENTSECRETHASHVALUEfortestsonlyDoNotLeak0123456789abcdefgh" GOAL_MARKER = "SECRET-GOAL-TEXT-4711" class Provider(BaseHTTPRequestHandler): def log_message(self, *_): pass def do_POST(self): body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) requests.append(body) answer = queue.pop(0) if queue else {"choices": [{"message": {"role": "assistant", "content": "unscripted"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}} data = json.dumps(answer).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def call(name, arguments, call_id): return {"id": call_id, "type": "function", "function": {"name": name, "arguments": json.dumps(arguments)}} def turn(*calls): return {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": list(calls)}, "finish_reason": "tool_calls"}], "usage": {"prompt_tokens": 120, "completion_tokens": 30}} def say(text): return {"choices": [{"message": {"role": "assistant", "content": text}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 120, "completion_tokens": 30}} def tree(path): return sorted(str(p.relative_to(path)) for p in path.rglob("*") if p.is_file() and "ai-agent" not in p.parts and "ai-audit" not in p.parts and "ai-usage" not in p.name and "rate" not in p.parts) with tempfile.TemporaryDirectory(prefix="raiju-agent-security-") as folder: site = Site(folder) root = site.root editor = next(u for u in site.users if u["role"] == "editor") site.users.append({**editor, "id": "u_editor2", "username": "editor2", "email": "editor2@example.invalid"}) write_json(root / "content/users.json", {"users": site.users}) write_json(root / "content/settings.json", {"modules": {"ai": True}, "privacy": {"analyticsEnabled": False}}) write_json(root / "content/pages/en_secret.json", {"page": "en_secret", "lang": "en", "title": "Board minutes", "visibility": {"status": "private", "passwordHash": SECRET_HASH}, "sections": [{"id": "s", "type": "text", "title": "Minutes", "content": "
Confidential
"}]}) server = ThreadingHTTPServer(("127.0.0.1", 0), Provider) Thread(target=server.serve_forever, daemon=True).start() site.start() defaults = json.loads(subprocess.run(["php", "-d", "display_errors=0", "-r", "require 'includes/ai/ai-helper.php'; echo json_encode(raijuAiDefaults());"], cwd=root, text=True, capture_output=True, check=True).stdout) def configure(agent=None, modules=True): settings = json.loads(json.dumps(defaults)) settings.update(enabled=True, provider="openai-compatible", allowLocalProvider=True, chatModel="gpt-4.1-mini", baseUrl=f"http://127.0.0.1:{server.server_port}", apiKey="test-key") settings["features"]["backendAssistant"] = True settings["providerCredentials"]["openai-compatible"]["baseUrl"] = settings["baseUrl"] settings["limits"].update(dailyRequests=1000, dailyTextRequests=1000, monthlyBudgetCents=100000, maxInputTokens=60000) agent = agent or {} settings["agent"] = {"enabled": True, **agent, "limits": {"runsPerHour": 100, "stepsPerMinute": 600, **agent.get("limits", {})}} write_json(root / "content/ai-settings.json", settings) write_json(root / "content/settings.json", {"modules": {"ai": modules}, "privacy": {"analyticsEnabled": False}}) configure() visitor = Client(site) owner = Client(site).login("editor") other = Client(site).login("editor2") admin = Client(site).login("admin") READS = ["ai-agent-status", "ai-agent-changeset", "ai-agent-media"] WRITES = ["ai-agent-start", "ai-agent-step", "ai-agent-reply", "ai-agent-cancel", "ai-agent-revalidate", "ai-agent-apply", "ai-agent-discard", "ai-agent-undo", "ai-agent-publish", "ai-agent-upload"] def post(client, action, **fields): return client.api(action, **fields) def get(client, action, **query): qs = "&".join(f"{k}={v}" for k, v in query.items()) status, headers, body = client.request(f"/admin/api.php?action={action}&{qs}") try: return status, json.loads(body) except ValueError: return status, {"raw": body[:100]} def run_to_end(client, run_id, max_steps=40): for _ in range(max_steps): status, payload = post(client, "ai-agent-step", runId=run_id) assert status == 200 and payload["success"], payload if payload["data"]["status"] not in ("running", "busy"): return payload["data"] raise AssertionError("run did not finish") def package(client, goal="Add a news post"): queue[:] = [turn(call("stage_create_news", {"lang": "en", "title": "Security news", "date": "2026-10-10", "contentHtml": "Body
"}, "n1")), turn(call("validate_changeset", {}, "v1")), turn(call("present_changeset", {"summary": "Done"}, "p1"))] status, payload = post(client, "ai-agent-start", goal=goal) assert payload["success"], payload state = run_to_end(client, payload["data"]["runId"]) assert state["status"] == "awaiting_review", state return state["runId"], state["changesetId"] try: # ---- Not signed in ----------------------------------------------------------------------------------------------------------------- for action in WRITES: status, payload = post(visitor, action) assert status in (401, 403) and payload["success"] is False, (action, status, payload) for action in READS: status, payload = get(visitor, action, runId="run_00000000", id="cs_0000000000000000", ref="m_00000000") assert status in (401, 403) and payload.get("success") is not True, (action, status, payload) status, _, body = visitor.request("/services/test/") assert status == 200 and b"RJ_AI_AGENT" not in body and b"ai-agent.js" not in body, "guests get no agent" # ---- CSRF and methods ------------------------------------------------------------------------------------------------------------------- for action in WRITES: status, _, body = owner.request("/admin/api.php", {"action": action}) assert status == 403 and json.loads(body)["success"] is False, (action, "missing CSRF token", status) status, _, body = owner.request("/admin/api.php", {"action": action, "csrf_token": "forged"}) assert status == 403, (action, "forged CSRF token", status) status, _, body = owner.request(f"/admin/api.php?action={action}") assert status == 405, (action, "GET on a write action", status) status, _, _ = owner.request("/admin/api.php?action=ai-agent-start", method="PUT") assert status == 405, "other methods" # ---- Switched off ---------------------------------------------------------------------------------------------------------------------------- configure(agent={"enabled": False}) status, payload = post(owner, "ai-agent-start", goal="Hello") assert payload["success"] is False and "switched off" in payload["message"], payload status, _, body = owner.request("/services/test/") assert b"RJ_AI_AGENT" not in body and b"ai-agent.js" not in body, "no agent on the page when it is switched off" configure(modules=False) status, payload = post(owner, "ai-agent-start", goal="Hello") assert payload["success"] is False and "disabled" in payload["message"], payload configure() status, _, body = owner.request("/services/test/") text = body.decode() assert "window.RJ_AI_AGENT" in text and "ai-agent.js" in text, "editors get the agent" assert '"publish":false' in text.replace(" ", "") and '"uploadImages":true' in text.replace(" ", ""), "the page tells the editor's rights" assert '"publish":true' in admin.request("/services/test/")[2].decode().replace(" ", ""), "admins may publish" # ---- Ownership: nobody else sees or touches a run, a package or its images -------------------------------------------------- run_id, cs_id = package(owner, GOAL_MARKER) review = get(owner, "ai-agent-changeset", id=cs_id)[1]["data"] for action, fields in [("ai-agent-step", {"runId": run_id}), ("ai-agent-reply", {"runId": run_id, "message": "hi"}), ("ai-agent-cancel", {"runId": run_id}), ("ai-agent-revalidate", {"id": cs_id}), ("ai-agent-apply", {"id": cs_id, "previewHash": review["previewHash"], "confirmed": "1"}), ("ai-agent-discard", {"id": cs_id}), ("ai-agent-undo", {"id": cs_id, "token": cs_id + ".x"}), ("ai-agent-publish", {"id": cs_id, "item": json.dumps({"kind": "news", "target": "news:2026-10-10-security-news"})})]: status, payload = post(other, action, **fields) assert payload["success"] is False and status != 200 or payload["success"] is False, (action, status, payload) assert (payload.get("data") or {}).get("status", "missing") in ("missing", None), ("another user learns nothing about the package", action, payload) assert get(other, "ai-agent-status", runId=run_id)[0] == 404 and get(other, "ai-agent-changeset", id=cs_id)[0] == 404, "no peeking" assert get(other, "ai-agent-media", id=cs_id, ref="m_00000000")[0] == 404 assert not list((root / "content/news").glob("*security*")), "nothing was applied by someone else" assert get(admin, "ai-agent-changeset", id=cs_id)[0] == 200, "admins may look" # Invalid ids never reach the file system for bad in ["../x", "run_../../etc", "..%2f..%2fadmin", "run_" + "a" * 200, ""]: assert get(owner, "ai-agent-status", runId=bad)[1].get("success") is not True, bad for bad in ["../content/settings", "cs_../../x", "cs_" + "f" * 200]: assert get(owner, "ai-agent-changeset", id=bad)[1].get("success") is not True, bad # ---- Publishing needs the right --------------------------------------------------------------------------------------------------------- status, payload = post(owner, "ai-agent-apply", id=cs_id, previewHash=review["previewHash"], confirmed="1") assert status == 200 and payload["success"], payload item = payload["data"]["items"][0] status, payload = post(owner, "ai-agent-publish", id=cs_id, item=json.dumps(item)) assert status == 403 and payload["success"] is False, (status, payload) news_file = next((root / "content/news").glob("*security*.json")) assert json.loads(news_file.read_text())["hidden"] is True, "still hidden" forged = dict(item, target="news:2026-10-10-other-post") assert post(admin, "ai-agent-publish", id=cs_id, item=json.dumps(forged))[1]["success"] is False, "only items of the package can be published" assert post(admin, "ai-agent-publish", id=cs_id, item=json.dumps(item))[1]["success"] is True and json.loads(news_file.read_text()).get("hidden") is not True, "admins may publish" # ---- What the agent may touch --------------------------------------------------------------------------------------------------------------- before = tree(root) attempts = [ ("read_content", {"target": "page:../../admin/config"}), ("read_content", {"target": "news:../a"}), ("read_content", {"target": "event:..%2f..%2fx"}), ("get_page_capabilities", {"target": "page:../../etc/passwd"}), ("read_content", {"target": "page:en_secret"}), ("read_content", {"target": "page:en_services__test", "path": "visibility.passwordHash"}), ("stage_set_field", {"target": "page:en_services__test", "path": "visibility.passwordHash", "value": "x"}), ("stage_set_field", {"target": "page:en_services__test", "path": "__proto__.polluted", "value": "x"}), ("stage_set_field", {"target": "page:en_services__test", "path": "sections.0.__proto__", "value": "x"}), ("stage_set_field", {"target": "page:en_services__test", "path": "constructor.prototype.x", "value": "x"}), ("stage_set_field", {"target": "page:en_secret", "path": "sections.0.content", "value": "x
"}), ("stage_create_page", {"lang": "en", "slug": "../evil", "title": "Evil", "sections": [{"type": "text", "fields": {"content": "x
"}}]}), ("stage_create_page", {"lang": "en", "slug": "a/../../evil", "title": "Evil", "sections": [{"type": "text", "fields": {"content": "x
"}}]}), ("stage_create_news", {"lang": "en", "title": "Evil", "slug": "../x", "date": "2026-10-10", "contentHtml": "x
"}), ("stage_create_news", {"lang": "en", "title": "Evil", "date": "2026-10-10", "contentHtml": "x
", "imagePath": "/content/ai-settings.json"}), ("stage_create_news", {"lang": "en", "title": "Evil", "date": "2026-10-10", "contentHtml": "x
", "imagePath": "/assets/images/../../admin/config.php"}), ("stage_set_field", {"target": "page:en_services__test", "path": "sections.0.content", "value": "See this
"}), ("stage_set_field", {"target": "page:en_services__test", "path": "sections.0.content", "value": "Hi