boxkite
← All posts
Observability

Shipping human takeover, and the RBAC gap we shipped it with

We shipped three things that belong together: an audit log of everything a sandbox did, a live feed of what it's doing right now, and a way for a human to reach in and take over a session directly. The first two are unambiguously good. The third came with a tradeoff we made deliberately and are disclosing here rather than letting anyone find out the hard way.

One audit table, several writers and readers

The audit log is a new exec_log_entries table and ExecLogEntry model in the control-plane, written through one shared _log_exec_entry helper wired into all seven existing sandbox routes. On top of that: a paginated GET .../log route and a GET .../watch Server-Sent Events route for a live feed.

Every operation writes to one table; two routes read it back

Agent exec / file-opexec, file_create, view, str_replace, ls, glob, grep — via _log_exec_entry
Human takeoverkeystrokes + commands, tagged source: "human_takeover"
exec_log_entriesExecLogEntry — control-plane audit table
GET .../logpaginated history
GET .../watchSSE live feed
Self-hosted, direct-embed usage gets the same record through a new AuditSink.record_exec hook wired through bash_tool. GET .../log and GET .../watch are hosted-mode only — local docker-compose mode has no audit store of its own.

Both routes have matching get_log/watch methods on the Python and JS SDKs, and boxkite log/boxkite watch CLI commands.

The takeover connection path

Takeover is the interesting one: a sidecar WS /ptyendpoint allocates a real interactive PTY into the sandbox's namespace, a control-plane WS /v1/sandboxes/{id}/takeover route proxies it, and our own dashboard now has a takeover terminal on top of that.

Human ⇄ control-plane proxy ⇄ sidecar PTY

Humandashboard terminal or SDK takeover()
WS .../takeovercontrol-plane proxy · auth checked before upgrade
WS /ptysidecar · real PTY into sandbox namespace
Both the sidecar and control-plane ends validate auth before ever accepting the WebSocket upgrade, so an unauthenticated caller never gets a live shell. Both SDKs also expose a takeover(session_id) method, so this is reachable from code, not only the dashboard.

The tradeoff we shipped it with

That's a scoping decision, not a bug we missed — it's written down in SECURITY.md's "Known follow-ups" as a disclosed, accepted risk, and fine-grained RBAC on takeover is the tracked follow-up whenever there's time for it.

The mitigation we shipped in the same change as the feature itself: every operation during a takeover session — including every keystroke or command a human issues — is written to the same exec_log_entries audit table as everything else, tagged source: "human_takeover". So even without RBAC, what happened during any takeover session is always reconstructable afterward.

We'd rather ship this with the gap written down than wait for full RBAC before anyone gets an audit log or a live feed at all — but we wanted the tradeoff stated as plainly here as it is in SECURITY.md, not buried in a changelog line.