Skip to content

RFC: Add streaming-stdio process API (popen / Process) #67

@congwang-mk

Description

@congwang-mk

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:

  1. 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."
  2. 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.
  3. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels
    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions