Live session editor · hardened

🖥️ celest-chat

What it is

celest-chat is a browser-based observatory for a live AI coding session, with an editor deck bolted on. The left half streams the session as it happens — the conversation, the tool calls, what the agent is touching right now. The right half is a real multi-file editor: browse the filesystem, open several files as tabs, edit, save. When the agent writes to a file you have open, the diff is projected straight into your pane, live.

It is deliberately small. No framework on either side. The backend is Python's standard library and nothing else; the frontend is plain JavaScript in ordinary <script> tags. There is no websocket layer, no build step, no bundler, no node_modules. It is one process you can read end to end in an afternoon.

A tool that edits real files as root is not a toy. It is an attack surface with a nice UI.

The part worth telling: we attacked our own tool

The service runs as root, and it has to — its whole job is tailing session transcripts that are owned by root and readable by nobody else. That is a legitimate reason, and it is also exactly the kind of legitimate reason that quietly justifies a dangerous shape.

So the tool got audited the way an attacker would audit it, not the way an author would review it. The question was never "does saving a file work?" — it was "what is the worst thing a stranger can make this process do?"

1
Found it
The save route — a POST that writes a file to disk — had no authentication of any kind. Not a token, not a session, not an origin check. A root-privileged arbitrary-file overwrite, sitting behind a plain HTTP endpoint.
2
Understood why "localhost only" wasn't a defence
The service binds to loopback, which feels safe and isn't. Any web page open in the same browser can issue a cross-origin POST to 127.0.0.1 — a classic drive-by CSRF. The browser is inside the trust boundary; the network binding never was the control.
3
Proved it instead of arguing it
A theoretical vulnerability gets debated; a demonstrated one gets fixed. The attack was carried out for real against a throwaway file, from an off-origin page, and it succeeded — the server cheerfully returned success. No estimate, no severity guess. A working exploit.
4
Fixed it in one place, not five
The obvious patch is to guard the write route. The better patch is to notice that five routes mutate state — write, message-relay, two keystroke-injection routes — and that a per-route check is a checklist you will eventually forget to tick. One gate went in above the dispatch, so a route added tomorrow is covered by default.
5
Re-attacked
The same exploit was run again against the patched server and refused. A fix you have not tried to break is a hypothesis, not a fix.
Before
0 / 5
state-changing routes protected — including a root-privileged file write.
After
5 / 5
covered by a single check above the router. No per-route bypass to maintain.

The fix itself

The whole control is six lines. That is the point — the engineering was in choosing where it goes and what it defaults to, not in writing it.

def _csrf_ok(self): origin = self.headers.get("Origin") if not origin: return True # no Origin: not a browser — CLI tools stay working host = urlsplit(origin).hostname return host in ("127.0.0.1", "localhost", "::1")

The interesting decision is the absent-header case. Browsers always attach an Origin to a cross-origin POST, so a missing one means the caller isn't a browser — a script, a health check, a shell one-liner. Rejecting those would have broken every legitimate non-browser caller in exchange for blocking an attack that cannot originate there. Security that breaks the tool gets switched off within a week, and a control that is switched off protects nothing.

Defence in depth around it

Resolve, then check Every path is passed through realpath before the allowed-roots comparison — so .. traversal and symlinks pointing outside are both refused by the same line, rather than by two separate rules that can drift apart.
Overwrite, never create The write route refuses any path that isn't already a file. An attacker cannot plant a new .service unit, cron entry, or shell profile — only overwrite something that already exists inside the sandbox.
Atomic writes Write to a temporary file, then rename over the target. A crash mid-save cannot leave a half-written file where a whole one used to be.
Narrow the keyboard The keystroke-injection route accepts digits and escape — nothing else. It exists to answer a yes/no prompt, so that is the entire alphabet it is given.
Read cap File reads are capped and binary content is rejected, so the reader cannot be turned into a memory-exhaustion lever.

The traversal defences were verified the same way as the CSRF fix — by trying them. Requests for /etc/passwd, a .. climb toward /etc/shadow, and a symlink pointing out of the sandbox were all fired at the running server and all refused.

Radical simplicity, on purpose

The second thing worth saying about this tool is how little is in it. A live-updating multi-file editor with a streaming activity view is the sort of thing that usually arrives as a React app, a websocket server, a bundler config and four hundred transitive dependencies.

Backend Python standard library only — the built-in threading HTTP server, subclassed. No Flask, no FastAPI, no ASGI. Zero third-party backend packages.
Frontend Vanilla JavaScript in ordinary script tags. No framework, no build step, no bundler. Edit a file, reload the tab.
Transport HTTP long-polling, not websockets — the session stream on a sub-second poll, each open editor pane checking its own file for on-disk changes. Boring, debuggable with curl, and impossible to get into a half-open state you can't see.
Persistence None. No database, no ORM, no migrations. The filesystem is the state, and the session transcript is the log.

Long-polling instead of websockets is the trade this project is happy to defend. A websocket buys latency the interface cannot perceive, and costs a reconnect state machine, a heartbeat, and a class of "connected but dead" bugs that are miserable to reproduce. A poll that fails just fails — visibly, on the next tick.

Python 3 stdlib http.server vanilla JS HTTP long-polling systemd no framework no database loopback-bound

Scope & roadmap

It is an editor, not an IDE, and the difference is deliberate. The text surface is a plain textarea with a line-number gutter — no syntax highlighting, no language server, no integrated terminal. A full editor component was evaluated and dropped: shipping it would have meant adopting a bundler, which would have cost the one property the project is built around.

On the roadmap, honestly ranked: syntax highlighting without a build step, new-file creation (currently refused by design, since overwrite-only is a security property worth keeping until it's replaced deliberately), and find-and-replace. The tool is single-operator and loopback-bound by design — multi-user access would need real authentication, not an origin check, and that is a different product.

Built in a fortnight of evenings. The security work took one of them — and is the part worth reading.