Goal
Add a popen()-style entry point on Sandbox that returns a live process handle exposing streaming stdin / stdout / stderr, so callers can drive bidirectional stdio protocols (MCP, LSP, REPLs, JSON-RPC over stdio) without losing the kernel-enforced jail.
Motivation
Today's Sandbox API is capture-mode only:
Sandbox.run(cmd) blocks and returns a Result with buffered stdout / stderr.
Sandbox.spawn(cmd) + wait() exposes a live pid but no stream access; you still only get final buffered output.
That works for batch tools. It does not work for any subprocess whose protocol is request / response streamed over stdio while the process is alive. Concrete cases this blocks today:
- Running an untrusted MCP server inside a jail and connecting an MCP client to its stdio.
- Driving an LSP server in a jail.
- Hosting a REPL or interactive process whose output must be read as it arrives.
- Anything JSON-RPC over stdio.
The available workarounds are unattractive: either skip the Python API and shell out to sandlock run so a higher-level SDK can manage stdio, or build private stdio plumbing on top of the FFI. Neither composes well; both push complexity onto every caller.
Current state
Sandbox.run / spawn / wait (Python): capture-mode, see python/src/sandlock/sandbox.py:463, 744, 757.
sandlock_handle_* (FFI): no pipe-fd accessors; sandlock_handle_wait* always buffers stdout / stderr into the returned result.
crates/sandlock-core/src/sandbox.rs: launch path always wires stdio for capture.
The Python Sandbox object also conflates two roles: it is a policy dataclass and a single-process controller (handle is stashed in _handle, so one Sandbox instance can only run one process at a time). That wart blocks straightforward "launch many processes from one policy" patterns.
Proposed API
Python
class Sandbox:
async def popen(
self,
cmd: Sequence[str],
*,
stdin: bool = True,
stdout: bool = True,
stderr: bool = True,
) -> "Process": ...
class Process:
pid: int
stdin: anyio.abc.ByteSendStream | None
stdout: anyio.abc.ByteReceiveStream | None
stderr: anyio.abc.ByteReceiveStream | None
async def wait(self) -> int: ... # exit code only
def terminate(self) -> None: ... # SIGTERM
def kill(self) -> None: ... # SIGKILL
async def __aenter__(self) -> "Process": ...
async def __aexit__(self, *exc) -> None: ...
Existing run, spawn, wait, cmd, fork are unchanged.
Rust core
pub enum StdioMode { Inherit, Piped, Null }
pub struct StdioConfig {
pub stdin: StdioMode,
pub stdout: StdioMode,
pub stderr: StdioMode,
}
impl Sandbox {
pub fn popen(&self, cmd: &[&str], io: StdioConfig)
-> Result<Child, SandlockError>;
}
pub struct Child { /* ... */ }
impl Child {
pub fn pid(&self) -> i32;
pub fn stdin(&mut self) -> Option<&mut ChildStdin>;
pub fn stdout(&mut self) -> Option<&mut ChildStdout>;
pub fn stderr(&mut self) -> Option<&mut ChildStderr>;
pub fn take_stdin(&mut self) -> Option<ChildStdin>;
pub fn take_stdout(&mut self) -> Option<ChildStdout>;
pub fn take_stderr(&mut self) -> Option<ChildStderr>;
pub fn wait(self) -> Result<ExitStatus, SandlockError>;
pub fn terminate(&self) -> Result<(), SandlockError>;
pub fn kill(&self) -> Result<(), SandlockError>;
}
Naming follows std::process::{Command, Child}. Existing Sandbox methods unchanged.
FFI
// Stdio modes
const int SANDLOCK_STDIO_INHERIT = 0;
const int SANDLOCK_STDIO_PIPED = 1;
const int SANDLOCK_STDIO_NULL = 2;
// Spawn with caller-managed stdio. *_out_fd receives the pipe fd when the
// matching mode is PIPED; otherwise -1. Caller owns and must close the fds.
sandlock_handle_t *sandlock_handle_popen(
SandboxBuilder *builder,
const char *const *argv, size_t argc,
int stdin_mode, int *stdin_out_fd,
int stdout_mode, int *stdout_out_fd,
int stderr_mode, int *stderr_out_fd
);
// Wait without buffering stdout / stderr (caller drained the pipes).
sandlock_result_t *sandlock_handle_wait_exit(
sandlock_handle_t *h, uint64_t timeout_ms);
// Signal the running process.
int sandlock_handle_terminate(sandlock_handle_t *h);
int sandlock_handle_kill(sandlock_handle_t *h);
Existing handle entry points (sandlock_handle_create*, sandlock_start, sandlock_handle_wait*, sandlock_handle_pid, sandlock_handle_free, port mappings, checkpoint) are unchanged.
Design rationale
Why a separate Process / Child type rather than putting streams on Sandbox:
Sandbox is a policy; a running process is an instance of that policy. Today's API stashes process state on the policy dataclass (_handle), which silently caps it at one process per Sandbox. Splitting unblocks "launch many processes from one policy."
- Streams only exist while a process is running. Putting them on
Sandbox would either return None for non-running instances or invent process state on the policy. Both are worse than a dedicated type.
- The split is what
std::process, tokio::process, and anyio.open_process all converged on. subprocess.Popen (which conflates) is the cautionary tale.
popen() is async because streaming stdio is inherently async-shaped. Sync callers keep run() / spawn() / wait().
Example
async with await sandbox.popen(["server-bin", "--config", "x"]) as proc:
await proc.stdin.send(b'{"jsonrpc":"2.0","method":"...","id":1}\n')
chunk = await proc.stdout.receive(4096)
On context-manager exit: signal if still alive, wait, close pipes.
Implementation notes
- Rust core: extend the launch path with a stdio-mode variant that creates pipes (or inherits /
/dev/null) per stream and skips the existing capture reader threads. Reuse the seccomp / Landlock setup.
- FFI: thread the three pipe fds out through the handle constructor.
sandlock_handle_wait_exit is wait minus the buffered stdout / stderr fields.
- Python: wrap the raw fds via anyio into
ByteReceiveStream / ByteSendStream. Context-manager lifecycle: kill on unclean exit, close fds, reap zombies.
- anyio is already a transitive dependency via the
mcp extra. A sync-stream variant can be added later; do not dual-pave at first.
Open questions
popen vs open_process for the Python method name. popen reads as the right mental model; the Process return type disambiguates from subprocess.Popen. Open to open_process for anyio symmetry.
- Should
Process.wait() return an exit code, an ExitStatus-like struct, or the existing Result? The existing Result includes buffered stdout / stderr which are meaningless in pipe mode. Lean: a slim type or bare exit code.
- Does the legacy
Sandbox.spawn + wait keep its current in-place shape, or migrate to also return Process and drop the stashed _handle? Pre-1.0 hard break is allowed; defer to a follow-up.
- Behaviour if a
Process is dropped without wait(): kill-and-reap on drop, or leak-and-warn. std::process::Child leaks; tokio::process::Child can kill if configured. Lean: kill on drop (safer default in a sandbox context).
Acceptance
Sandbox(net_allow=[]).popen(["cat"]) round-trips a byte payload through proc.stdin / proc.stdout while the jail is enforced (verifiable by attempting a denied syscall inside the child).
- Two processes can run concurrently from one
Sandbox instance via popen.
- A subprocess killed via
proc.kill() returns a non-zero exit promptly; pipes close cleanly.
- Dropping a
Process without calling wait() does not leak a child (verified by pgrep).
- The
mcp Python SDK can drive a sandboxed MCP server end-to-end using Process.stdin / Process.stdout as ClientSession streams.
- Rust integration test:
Sandbox::popen + Child round-trip under tokio.
Goal
Add a
popen()-style entry point onSandboxthat returns a live process handle exposing streaming stdin / stdout / stderr, so callers can drive bidirectional stdio protocols (MCP, LSP, REPLs, JSON-RPC over stdio) without losing the kernel-enforced jail.Motivation
Today's
SandboxAPI is capture-mode only:Sandbox.run(cmd)blocks and returns aResultwith bufferedstdout/stderr.Sandbox.spawn(cmd) + wait()exposes a live pid but no stream access; you still only get final buffered output.That works for batch tools. It does not work for any subprocess whose protocol is request / response streamed over stdio while the process is alive. Concrete cases this blocks today:
The available workarounds are unattractive: either skip the Python API and shell out to
sandlock runso a higher-level SDK can manage stdio, or build private stdio plumbing on top of the FFI. Neither composes well; both push complexity onto every caller.Current state
Sandbox.run/spawn/wait(Python): capture-mode, seepython/src/sandlock/sandbox.py:463, 744, 757.sandlock_handle_*(FFI): no pipe-fd accessors;sandlock_handle_wait*always buffers stdout / stderr into the returned result.crates/sandlock-core/src/sandbox.rs: launch path always wires stdio for capture.The Python
Sandboxobject also conflates two roles: it is a policy dataclass and a single-process controller (handle is stashed in_handle, so oneSandboxinstance can only run one process at a time). That wart blocks straightforward "launch many processes from one policy" patterns.Proposed API
Python
Existing
run,spawn,wait,cmd,forkare unchanged.Rust core
Naming follows
std::process::{Command, Child}. ExistingSandboxmethods unchanged.FFI
Existing handle entry points (
sandlock_handle_create*,sandlock_start,sandlock_handle_wait*,sandlock_handle_pid,sandlock_handle_free, port mappings, checkpoint) are unchanged.Design rationale
Why a separate
Process/Childtype rather than putting streams onSandbox:Sandboxis a policy; a running process is an instance of that policy. Today's API stashes process state on the policy dataclass (_handle), which silently caps it at one process perSandbox. Splitting unblocks "launch many processes from one policy."Sandboxwould either returnNonefor non-running instances or invent process state on the policy. Both are worse than a dedicated type.std::process,tokio::process, andanyio.open_processall converged on.subprocess.Popen(which conflates) is the cautionary tale.popen()is async because streaming stdio is inherently async-shaped. Sync callers keeprun()/spawn()/wait().Example
On context-manager exit: signal if still alive, wait, close pipes.
Implementation notes
/dev/null) per stream and skips the existing capture reader threads. Reuse the seccomp / Landlock setup.sandlock_handle_wait_exitiswaitminus the buffered stdout / stderr fields.ByteReceiveStream/ByteSendStream. Context-manager lifecycle: kill on unclean exit, close fds, reap zombies.mcpextra. A sync-stream variant can be added later; do not dual-pave at first.Open questions
popenvsopen_processfor the Python method name.popenreads as the right mental model; theProcessreturn type disambiguates fromsubprocess.Popen. Open toopen_processfor anyio symmetry.Process.wait()return an exit code, anExitStatus-like struct, or the existingResult? The existingResultincludes bufferedstdout/stderrwhich are meaningless in pipe mode. Lean: a slim type or bare exit code.Sandbox.spawn+waitkeep its current in-place shape, or migrate to also returnProcessand drop the stashed_handle? Pre-1.0 hard break is allowed; defer to a follow-up.Processis dropped withoutwait(): kill-and-reap on drop, or leak-and-warn.std::process::Childleaks;tokio::process::Childcan kill if configured. Lean: kill on drop (safer default in a sandbox context).Acceptance
Sandbox(net_allow=[]).popen(["cat"])round-trips a byte payload throughproc.stdin/proc.stdoutwhile the jail is enforced (verifiable by attempting a denied syscall inside the child).Sandboxinstance viapopen.proc.kill()returns a non-zero exit promptly; pipes close cleanly.Processwithout callingwait()does not leak a child (verified bypgrep).mcpPython SDK can drive a sandboxed MCP server end-to-end usingProcess.stdin/Process.stdoutasClientSessionstreams.Sandbox::popen+Childround-trip undertokio.