Design for giving Envoy (or another agent) safe filesystem/execution access, so it can actually deploy fixes rather than only reading/writing notes and email. Grew out of a 2026-07-27 conversation prompted by handing Envoy a live code fix (sheet_ui.py contrast bug) it had no way to actually perform, since Envoy's current action vocabulary (memory/envoy/orchestrator) is only write_notes/delete_notes/send_emails/move_emails — no shell, no file write, no git.
Motivating incident and the original mitigation proposal (protected-host lookup) are in ideas/host-safety-and-remote-execution — read that first. This note is the follow-on design for the execution sandbox itself.
Ephemeral compute, persistent decisions. The sandbox environment (uid, mounts, network namespace) can and should be disposable — destroyed and rebuilt per task with no state carried over. The result of a task (an actual code fix) must never be ephemeral — it has to be explicitly published to somewhere durable (git) before the sandbox is torn down. Conflating these two was an early mistake in this design discussion: "nothing survives to the next task" must describe the environment, never the output.
Chroot alone only confines the filesystem view — it isolates nothing else, and is not safe against a process running as root (root can escape via fd/fchdir tricks). Two non-negotiable rules for the starter process:
chroot()chdir("/")setuid()Reversion is handled via OverlayFS, not by chroot itself:
git addcommitDependency: this only works cleanly once gravlax is an actual git clone (already an open item in gdata-server/todo, "Source control for live gdata-server"). Building the sandbox before that fix just relocates the deploy-uncertainty problem rather than removing it — do the git-clone fix first.
The dedicated uid alone gives one useful property for free: a non-root process's kill(-1, SIGKILL) only reaches processes it has permission to signal — for a normal uid, that means processes owned by that same uid. So kill -9 -1 inside the sandbox can't touch the rest of the host, no namespace required, provided the uid really is exclusive to sandbox use.
For anything stronger (PID namespace, full process isolation), reach for an already-audited unprivileged tool — bubblewrap (bwrap), the sandboxing engine Flatpak uses — rather than hand-rolling unshare() calls from scratch. Open question, not yet decided: whether filesystem+uid containment is sufficient on its own, or whether tasks that need to actually run something (e.g. launching notes_browser.py to visually verify a UI fix) need PID-namespace isolation too. Verifying a fix and containing a fix are different concerns — flagged here, not resolved.
"No routing" isn't a property of any particular link technology — it's just the absence of a default route in the namespace and no IP forwarding on the host side. A veth pair configured with only its own two IPs, no default route added in the namespace, and no net.ipv4.ip_forward on the host end gives a genuine point-to-point link: the namespace can reach exactly one address (the host-side veth IP) and nothing else, because no routing table entry exists for anything further.
Considered and rejected for this role:
For anything beyond the single fixed local destination, bind a SOCKS5 proxy to the host-side veth IP as the only thing the namespace can reach. SOCKS5 (unlike SOCKS4) carries the destination hostname in its CONNECT request, so the proxy can enforce a domain-level allowlist without needing to inspect or terminate TLS — it just refuses CONNECTs for hostnames not on the list, before anything opens. This was chosen over the transparent-proxy approach above specifically because it's one enforcement point (the proxy's ACL) rather than two, and it covers arbitrary TCP, not just HTTP(S).
Open decision: whether the local notes-API call also goes through the SOCKS proxy (one allowlist, one place to maintain it — preferred) or gets a direct nftables exception (two paths, but skips SOCKS negotiation for the local case). Leaning toward routing everything through SOCKS for a single source of truth on what the sandbox can reach.
Implementation choice not yet made: Dante (sockd, fuller per-rule ACLs, more config surface) vs. microsocks or a small hand-rolled SOCKS5 server (smaller, fully readable end-to-end, coarser ACLs out of the box). Given the general house preference for owned/understood tooling (the notes server, the linter, and Envoy itself are all hand-rolled rather than adopted), microsocks or custom is the likely direction, but this is a judgement call to make later, not a decision made here.
The allowlist itself should live in the notes system (e.g. a table note such as sandbox/allowed-destinations) rather than a static config file, so changing what the sandbox can reach is an editable note — consistent with notes-as-source-of-truth elsewhere in this system — with a small script regenerating the proxy's actual ACL from it.
The starter process inside the chroot exposes a JSON-RPC-over-Unix-domain-socket control interface — reusing the exact convention already working for the notes browser's control socket (documented in MEMORY/claude) rather than inventing a new wire format. It can start programs, run commands, and report back; per John's design, Envoy should run subprocesses in the background rather than blocking on them.
An MCP connector accesses the same control socket, giving interactive sessions (Claude, etc.) the same capability Envoy has via email. Because both can reach the same socket, job IDs and either a queue or explicit locking are needed so two agents don't stomp the same upperdir concurrently — the same optimistic-concurrency shape as if_rev in the notes system, reapplied here.
Background subprocesses fit Envoy's existing async model directly: it already has continuation-email machinery for multi-iteration work (envoy/continuation-emails). "Dispatch to sandbox, get a job id back immediately, poll on a later iteration/continuation email" is the same pattern already used for long email tasks. This suggests new action types in Envoy's existing dispatch vocabulary (run_sandboxed, check_job) rather than new plumbing.
The starter process is the natural enforcement point for the protected-host/protected-path lookup proposed in ideas/host-safety-and-remote-execution: every command or path it's asked to act on should be checked against a protected-paths note via the notes HTTP API as a hard gate before exec, not routed through an MCP tool that could be bypassed.
An always-on or per-task dedicated EC2 instance was considered and set aside on cost/complexity grounds. An on-demand spot instance (awslaunch.py already supports --spot and is idempotent by Name tag) would have been cheap, but the chroot+overlay+netns design gives the same core properties (reversion by default, scoped network access, process containment) using only local kernel features already on gravlax — no host to provision, bill for, or tear down. A dedicated host remains an option later for tasks that need host-level isolation specifically (not just filesystem/process isolation), but isn't needed for the design as it stands.
Recommended build order, since several pieces depend on earlier ones: