Add agent-fullstack-app example: API + frontend + seeded DB in one sandbox - #12
Add agent-fullstack-app example: API + frontend + seeded DB in one sandbox#12opencolin wants to merge 1 commit into
Conversation
…ndbox A full-stack app (stdlib Python http.server + sqlite3) that boots in a single microVM: seed the DB, start the server, drive GET/POST/PATCH endpoints, and expose the frontend on a public preview URL. Each step is mapped in the README to its Tenki MCP tool (create_sandbox / write_file / exec / expose_port) so an agent can run the same loop — the 'Claude steers a backend and manipulates a frontend' use case. verify.mjs asserts seed -> health -> POST -> PATCH -> count -> frontend 200 and terminates the sandbox; live-verified 3x. run.mjs leaves the sandbox up for exploration with idle/lifetime billing guards. Also documents the multi-sandbox pattern (verified: one sandbox reaches another via its exposed URL). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review complete. 🟠 1 high · 🟡 2 medium 💬 Inline comments (3)
This PR introduces The server renders task rows via
Reviewed commit: cdbcde3 |
There was a problem hiding this comment.
Adds an agent-fullstack-app example (FastAPI-style Python server, JS runner, verifier, and docs) that spins up a public sandbox VM exposing a mutable task API.
Key findings
- 🟠 Stored XSS via unescaped task title/status in
innerHTML— server.py:33 - 🟡 Write handlers crash on malformed or non-dict JSON instead of 400 — server.py:64
- 🟡 Public preview port exposes unauthenticated write API — run.mjs:58
| document.getElementById('list').innerHTML = tasks.map(t => | ||
| `<li><span class="s ${t.status}">${t.status}</span><span>${t.title}</span></li>`).join(''); |
There was a problem hiding this comment.
🟠 security · high
Stored XSS via unescaped task title/status in innerHTML
The GET / handler builds each task row with a template literal and assigns it to element.innerHTML (server.py:33-34), interpolating the untrusted title and status values with no HTML-escaping. do_POST accepts arbitrary title/status from the request body with no validation and persists them, and the app is exposed to the public internet via allowInbound: true plus exposePort(PORT) (run.mjs), so any visitor to the preview URL executes attacker-supplied markup in their browser. A remote attacker can POST a payload such as <img src=x onerror=...> and drive-by any user who opens the public URL.
📋 Prompt for AI Agents
In examples/agent-fullstack-app/app/server.py at lines 33-34, replace the innerHTML template-literal rendering in the GET / script with safe DOM construction: create each <li> via document.createElement, set the title text node with textContent, and map status through a whitelist (only todo/doing/done) to a CSS class (defaulting to todo) before assigning className. This prevents stored XSS because attacker-controlled title/status values are never parsed as HTML.
| return json.loads(self.rfile.read(n) or b"{}") | ||
|
|
||
| def do_GET(self): | ||
| if self.path == "/": | ||
| return self._send(200, INDEX.encode(), "text/html; charset=utf-8") | ||
| if self.path == "/api/tasks": | ||
| return self._send(200, q("SELECT id, title, status FROM tasks ORDER BY id")) | ||
| if self.path == "/api/health": | ||
| return self._send(200, {"ok": True, "tasks": q("SELECT COUNT(*) c FROM tasks")[0]["c"]}) | ||
| return self._send(404, {"error": "not found"}) | ||
|
|
||
| def do_POST(self): | ||
| if self.path != "/api/tasks": | ||
| return self._send(404, {"error": "not found"}) | ||
| b = self._body() | ||
| if not b.get("title"): | ||
| return self._send(400, {"error": "title required"}) | ||
| new_id = q("INSERT INTO tasks (title, status) VALUES (?, ?)", | ||
| (b["title"], b.get("status", "todo")), commit=True) | ||
| return self._send(201, {"id": new_id, "title": b["title"], "status": b.get("status", "todo")}) | ||
|
|
||
| def do_PATCH(self): | ||
| if not self.path.startswith("/api/tasks/"): | ||
| return self._send(404, {"error": "not found"}) | ||
| task_id = self.path.rsplit("/", 1)[-1] | ||
| status = self._body().get("status") |
There was a problem hiding this comment.
🟡 bug · medium
Write handlers crash on malformed or non-dict JSON instead of 400
_body() (server.py:64) calls json.loads(...) with no try/except, and do_POST (server.py:78-79) and do_PATCH (server.py:89) dereference the result with .get(...). Malformed JSON raises json.JSONDecodeError and valid-but-non-object bodies (array, string, number, or null) raise AttributeError on .get; neither is caught, so the handler throws and the connection is torn down with no HTTP response instead of returning 400. Because the API is publicly reachable, any remote client can send a malformed body and get a failed/empty request for these write endpoints.
📋 Prompt for AI Agents
In examples/agent-fullstack-app/app/server.py, harden _body() (line 64) and its callers: wrap json.loads in try/except json.JSONDecodeError and return None on parse failure; in do_POST (line 78-79) and do_PATCH (line 89), guard with if not isinstance(b, dict): return self._send(400, {"error": "invalid body"}) before calling .get(...). This ensures malformed or non-object JSON bodies get a clean 400 response instead of crashing the request handler.
| const { previewUrl } = await sandbox.exposePort(PORT); | ||
| console.log(`\n-- the UI is live --\n ${previewUrl}`); | ||
| const res = await fetch(previewUrl); | ||
| console.log(` GET ${res.status} · ${(await res.text()).includes("<title>") ? "frontend served" : "unexpected body"}`); |
There was a problem hiding this comment.
🟡 security · medium
Public preview port exposes unauthenticated write API
The sandbox is created with allowInbound: true and the server binds 0.0.0.0 (server.py:102), then sandbox.exposePort(PORT) publishes the entire port to the public internet (run.mjs:58). This exposes not only the intended read-only frontend but also POST /api/tasks and PATCH /api/tasks/<id> (server.py:75-94), which mutate the SQLite data with no authentication, authorization, or rate limiting. Because run.mjs leaves the sandbox running up to 2 hours and prints the preview URL, anyone who obtains that URL can create or modify demo data and consume the public sandbox's resources.
📋 Prompt for AI Agents
In examples/agent-fullstack-app, gate the state-mutating endpoints: add a bearer-token check to do_POST (server.py:75) and do_PATCH (server.py:85) that rejects requests without a token from the sandbox environment before writing to the DB, OR restructure the demo so exposePort in run.mjs:58 (and verify.mjs:67) targets only a read-only static frontend while the write API is exercised solely via in-sandbox exec. This prevents the public preview URL from exposing unauthenticated writes.
Adds a full-stack example for the "an agent drives a real app" use case — a customer asked whether Claude could hit backend endpoints and manipulate a frontend, with data behind it.
What it does: boots one sandbox, seeds a SQLite DB, starts a stdlib-Python API + frontend, drives
GET/POST/PATCH /api/tasks, and exposes the UI on a public preview URL.Why it's useful: the README maps every step to its Tenki MCP tool (
tenki_create_sandbox/tenki_write_file/tenki_exec/tenki_expose_port), so an agent can run this exact loop with no bespoke backend.Verification:
verify.mjsasserts seed → health → POST → PATCH → count → frontend 200, then terminates the sandbox. Live-verified 3x against api.tenki.cloud (green each time, no leaked sandboxes). Stdlib-only app (no pip install), so it boots clean.Conventions: follows the current repo style —
TENKI_WORKSPACE_IDonly (noproject_id), README row under Sandbox API basics.run.mjsleaves the sandbox up for exploration but sets idle/lifetime billing guards.Also documents the multi-sandbox pattern in the README (verified separately: one sandbox can reach another via its exposed URL — a public-gateway hop, not a private mesh).