Capability
Audit log & takeover
A durable record of every exec/file operation against a sandbox session, a live-watch feed of that same record, and a human-takeover shell that bypasses the agent entirely. These three ship at different stages of completeness today — this page says exactly which parts exist and which don't, rather than describing the finished design as if it were all live.
What's actually implemented today
ExecLogEntry rows are written for every exec, file_create, view, str_replace, ls, glob, and grep call that goes through the hosted control-plane — readable via both SDKs and the dashboard (next section). Each row captures: session_id, account_id, source ("agent" or "human_takeover"), operation, a detail object (the command string or path, operation-specific, size-capped the same way request payloads already are), exit_code, output_truncated (first slice of stdout/stderr where an operation produces any), started_at, and duration_ms. A failed operation (one that already 502s) never produces a row — only completed operations are logged.
For the self-hosted, direct-embed path (no control-plane in front), the equivalent hook is AuditSink.record_exec(...) — a new method on the same AuditSink protocol the file-write audit hooks already use, defaulting to a no-op like every other method on that protocol so self-hosters opt in the same way they already do for file writes:
from boxkite.audit import AuditSink
class MyAuditSink:
async def record_exec(
self, *, organization_id, work_item_id, session_id,
agent_name, command, exit_code, duration_ms,
) -> None:
await my_db.insert_exec_record(
organization_id=organization_id,
session_id=session_id,
command=command,
exit_code=exit_code,
duration_ms=duration_ms,
)
# every other AuditSink method can stay unimplemented -- the base
# protocol no-ops anything you don't override
create_sandbox_tools(..., audit_sink=MyAuditSink())record_exec is called from bash_tool after every command — it never blocks or fails command execution if the sink itself raises (mirrored via the same safe_call wrapper every other AuditSink call uses).
Reading the log, and live watch
Both SDKs ship client methods for this — get_log/watch in Python, getLog/watchin JavaScript — calling the control-plane's GET .../log (paginated) and GET .../watch (Server-Sent Events) routes:
page = sb.get_log(limit=50, offset=0)
for entry in sb.watch(): # blocks, streams as new rows are written
print(entry["source"], entry["operation"], entry.get("detail"))Watch is a feed of completed operations, not a live terminal
watch() pushes one event per completedexec/file operation — it is not a live stream of a command's stdout mid-run. For that, see "Human takeover" below, a materially different and separately-gated feature. GET .../watch is implemented as a polling Server-Sent-Events stream (control-plane checks for new rows roughly every 500ms) rather than a push-based subscription — simple and sufficient for this use case, not a promise of sub-second latency.Human takeover
Takeover is a materially different feature from the log above: a human gets an actual interactive shell inside the sandbox container — not a view of agent-issued commands, a real PTY. The full path exists end to end: the sidecar's WS /pty endpoint allocates a PTY via the same namespace-entry mechanism exec_in_sandboxalready uses and relays it bidirectionally; the control-plane's WS /v1/sandboxes/{id}/takeoverroute authenticates the caller, checks their API key's role permits takeover, and proxies through to it; and the dashboard has a takeover terminal (a minimal keystroke-in/output-out view, not a full terminal emulator like xterm.js). Auth and the role check both happen before accepting the WebSocket upgrade, not after, so an unauthenticated or unauthorized caller never gets so much as a briefly-live shell.
Every API key has a role — "admin" (the default, every capability including takeover) or "member" (everything except initiating a takeover session). Mint a "member"-role key via POST /v1/api-keys for any teammate who should be excluded from this capability — see docs/API.md's API-keys section.
Both SDKs now ship a takeovermethod too — a raw duplex byte stream, no message envelope, matching this route's wire contract exactly. The Python SDK's takeover(session_id) returns a websockets connection (sync or async) authenticated with a normal Authorization header; the JS SDK's takeover(sessionId) first mints a short-lived, single-use token via POST .../takeover-token (a normal, header-authenticated call) and then resolves a Promise<WebSocket> once that token is redeemed as ?token=on the WS URL — the long-lived API key itself never reaches the WebSocket URL, for the same browser-API reason the dashboard's terminal takes the same approach (see below). You can still speak the WebSocket protocol directly, or use the dashboard, if you'd rather not pull in either SDK.
Two things worth knowing before you rely on this: first, the nsenter-based PTY allocation has been exercised against a local shell in tests, not against a real Kubernetes pod — the one-shot equivalent (exec_in_sandbox) has run against real pods for a long time, but a long-lived interactive PTY inheriting a real pty slave fd across the nsenter/unshareprocess chain is new territory, untested end-to-end. Second, the single-use guard on takeover tokens is tracked in one control-plane process's memory (the same documented limitation the rate limiter carries) — a multi-replica deployment doesn't share that state across replicas yet, so single-use only holds within whichever replica handled both the mint and the redemption; the token's short TTL (30s by default) bounds that exposure either way.
Takeover is RBAC-gated and fully logged
"admin"-role API key — a "member"-role key is rejected before the WebSocket upgrade is ever accepted (control-plane close code 4403). This is a restriction within an account, on top of the same account-ownership check every other sandbox route already applies — it does not, and cannot, cross an account boundary. Independent of that check, every keystroke and command issued during a takeover session is still written to the same exec_log_entries audit trail as agent-issued commands, tagged source: "human_takeover" — so what happened during any takeover session is always reconstructable afterward, regardless of who was authorized to start it. See SECURITY.md's "Human takeover" section for the full model.