Computerwelt: porting a sandboxed shell and Python interpreter to C#
Rafael
Computerwelt, part 1 of 4. This post is the port itself. Part 2 is the library and its extension points, part 3 is how Sudo — the assistant in Curiosity Workspace — is built on it, and part 4 is how a workspace builds its own.
The admin assistant in Curiosity Workspace needs to run code that a language model wrote,
against the workspace's own configuration, inside a request. Not "call a function we
defined", but run a script: grep -rl "Triage" /workspace, then sed a file, then a
short Python program to check the result.
There are three usual answers to that and we did not like any of them.
A container per turn is the honest one, and it costs a container per turn. The configuration the script reads lives in a graph in the server's own process, so a container means serialising it out, running, and reading a diff back, plus an image to keep current, a scheduler, and a second thing to make multi-tenant. For a shell command that takes 30 ms of actual work, the overhead is the product.
CPython with the dangerous builtins removed is the one that looks cheap and isn't.
__subclasses__, __globals__, gc.get_referrers, and a decade of published escapes say
that an interpreter which can reach the host and is asked not to is a different thing
from one that cannot.
No sandbox at all (a fixed catalogue of tools, each doing one safe thing) is where we started, and part 3 is the story of that catalogue reaching seventy-four entries and still not answering the question somebody actually asked.
The fourth answer is an interpreter with no route to the host in the first place. Two Rust projects already were one:
| Upstream | What it is |
|---|---|
| bashkit | an in-process bash interpreter with a virtual filesystem, no PATH and no exec |
| monty (Pydantic) | a minimal Python interpreter — parser, bytecode compiler, VM — for running LLM-written code |
They already fit together upstream: bashkit exposes monty as its python builtin. Both are
MIT licensed. Neither is written in C#, and our server is.
Computerwelt is the port of both, as one .NET 10 library.
Why a port and not a binding
bashkit ships a C API (bashkit-capi), so P/Invoke was on the table, and we did not take it
for three reasons.
The first rule of the sandbox is that it spawns no process. Shelling out to a Rust binary is spawning a process. That moves the security boundary from "inside the library" to "between two processes", which is a fine boundary. It is just a completely different design, with its own supervisor, its own lifetime, and its own way to leak one tenant's filesystem into another.
Every interesting integration point crosses the boundary per call. The filesystem a
script reads is not a directory; it is generated from a graph on demand: a mount that
renders one file per node type, another that answers one directory at a time as the shell
walks into it (part 4 is all of these). Host
commands are C# closures over the request's own scope. Over FFI, ls /data becomes a
callback storm across a marshalling layer, with the cancellation token and the async graph
db read on the wrong side of it.
One artifact. dotnet build, one NuGet package, no native asset per RID, no second
toolchain in CI.
We reached the same conclusion about a different Rust library for overlapping but not identical reasons. There it was the deployment matrix and an FFI boundary in a hot data path. Here it is the security model.
So: a ground-up port, with the Rust kept in the repository as .reference/, read-only, as
the specification. When behaviour is ambiguous, the Rust is the answer.
What the type system actually bought
"Type safety" is easy to claim and hard to point at, so here is the pointing. Four of these changed real behaviour.
Paths. This is the one that matters most, and it is not subtle. The host's own path API is the wrong instrument for a virtual filesystem:
// On Windows, in .NET:
System.IO.Path.Combine("/etc", "C:\\secrets") // → "C:\secrets"
A second argument the host reads as absolute wins outright, and which strings count as
absolute changes with the operating system. So virtual paths here are not strings and never
reach System.IO.Path. They are VPath, a readonly struct with POSIX semantics
implemented directly: normalised on construction, .. resolved lexically, and identical on
Linux, macOS and Windows. Nothing in the interpreter or in a builtin can call the host's
path logic by accident, because the type in hand does not have it.
The containment falls out of the same type. Every path resolves inside the virtual root,
and .. above that root clamps back to it:
$ ls /home/../..
data
tmp
$ cat ../../../../etc/hostname
cat: /etc/hostname: No such file or directory
Both of those resolve inside the virtual root rather than on the machine: the listing is the
sandbox's own /, and /etc/hostname is missing because the sandbox has no /etc. That is
the whole of /, and there is no host disk under it.
Bytes, not strings. Shell data is bytes; StreamData holds them and decodes to UTF-8
only at the edges, lossily and deliberately. We learned why the hard way later: an early
file object held its content as a decoded string, so open(path, 'rb') on a screenshot
round-tripped every invalid byte through U+FFFD. The file read back was not the file
written. Both conformance corpora missed it because both read and write text.
Result<T, E> split in two. Rust's one return type became two things in C#, because
they are two things: an ExecResult value carries an ordinary non-zero exit (grep found
nothing), and a ShellException is thrown for a fatal error. Exit codes leak into scripts
and had to stay exact (127 not found, 126 not executable, 2 usage), so they are constants,
not literals scattered through 24 builtin files.
enum with payloads became abstract record plus sealed cases, matched with switch
patterns. The AST is the obvious beneficiary; the compiler now tells you about the arm you
forgot when you add a node.
And the boring one that pays every day: #nullable enable with warnings as errors, across
the whole solution. Rust's Option<T> maps onto it exactly, and the port had a mechanical
rule (if the Rust has an Option, the C# has a ?), which means the places where upstream
checks are places the compiler makes you check too.
| Rust | C# |
|---|---|
async fn + tokio |
ValueTask<T> / Task<T>, CancellationToken threaded explicitly |
Arc<dyn Trait> |
an interface, injected by constructor; instances must be thread-safe |
enum with payloads |
abstract record + sealed record cases |
Result<T, E> |
ShellException for fatal, ExecResult for a non-zero exit |
Option<T> |
nullable reference types, warnings as errors |
&[u8] / Vec<u8> |
ReadOnlySpan<byte> / byte[], wrapped by StreamData |
PathBuf |
VPath, a POSIX-only readonly struct |
clap derive |
a hand-written POSIX/GNU option cursor, no dependency |
#[cfg(feature = "x")] |
an optional assembly, or opt-in at registration — not #if |
The corpora are the acceptance criteria
Neither upstream had to be trusted, because both ship the thing that makes a port checkable: a golden corpus in a language-agnostic format.
bashkit's is 2,521 runnable shell cases:
### test_name
# optional description
<script lines>
### expect
<expected stdout>
### end
Monty's 568 are simpler still. Each fixture is ordinary Python whose body is assert
statements, so a case passes when the file runs to completion without raising:
# === Simple interpolation ===
x = 'world'
assert f'hello {x}' == 'hello world'
Both suites run under a ratchet. tests/spec/baseline.json records how many cases each
file currently passes, and the run fails if any file drops below its line. Raise the
baseline when you make things pass; never lower it to make a build green. That rule is what
let the port land in slices ("add grep, sed and 30 more builtins; conformance 48.6 % to
62.6 %" is a commit) without a later slice quietly undoing an earlier one.
Where they stand now:
| conformance | ||
|---|---|---|
| shell | 2,521 / 2,521 | 27 cases skipped by upstream directive |
| python | 557 / 558 | the one asserts id([]) == id([]), which holds on a recycled-slot heap and not on the host runtime |
| joined | 210 / 210 | 153 agent-operation tests, plus upstream's 57 python command cases |
| extensions | 12 / 12 | behaviour this port adds beyond monty |
| browser | 40 / 40 | the optional Playwright package |
The suite the corpora could not be
Two conformance corpora prove each builtin is individually right. They say much less about the handful of shapes a caller actually types, and that is where the port's real defects were.
So there is a third suite, Computerwelt.AgentTests, written by replaying a real working
session against the repository and turning each operation into a test: read a file, search
a tree, patch a source file with a heredoc Python program, check the result, keep going.
153 cases, taken from sessions rather than invented.
It found five defects in code both corpora already covered:
| Found | Was | Now |
|---|---|---|
A generator with a second yield |
ArgumentOutOfRangeException out of the host — yield left no value where the compiler's Pop expected one |
The yield expression evaluates to None, as in CPython |
sys.argv |
The constant ['<script>'], so no script could read its own options |
The invocation as written: -c, -, or the script path, then the arguments |
sys.exit(n) |
Status 1 and a traceback, so python check.py \|\| handle never fired |
Status n, no traceback |
python -c "2 + 3" |
Printed nothing | Echoes the value, as upstream's corpus pins — and only for -c |
python in a pipeline |
input and sys.stdin did not exist |
Both read the shell's standard input |
Every one of those is a seam. sys.exit is the Python half and the shell half disagreeing
about what an exit status is; sys.stdin is the pipeline and the interpreter disagreeing
about who owns the input stream. A feature-by-feature corpus cannot see a seam, because
there is no feature there.
The rule for adding to that suite is the same one that makes it worth having: an operation goes in because someone performed it, not because it would round out a matrix.
Where the port stands
108 commits between 19 and 31 August 2026, 58 of them on the first day, which is what it looks like when the specification is sitting in the repository and the acceptance criteria are a file. About 40,000 lines of C# for the shell and 29,000 for Python, against the 166,000 and 79,000 lines of Rust in the two crates they follow (Rust keeps its unit tests in the same file, which is most of the difference).
What is deliberately still open is worth saying out loud, because a sandbox whose limits are undocumented is not a sandbox:
- The filesystem ships one backend, an in-memory tree with byte and file-count quotas. The
overlay, mountable, read-only and jailed-real backends upstream has are not ported. A host
that wants something else implements
IFileSystem, which is what Curiosity Workspace does. - Snapshot, static script analysis and the network allowlist are not ported. There is no network at all here instead, which is the stricter answer and the one we need; a host that wants HTTP has nothing to turn on.
- Job control,
gzip/zip,base64,curl/wgetand a virtualgitare not implemented. For the shells we build, most of those are things we would withhold anyway.
The ledger is todo.md,
and it is current rather than aspirational, including the parts that say "recorded, not
fixed".
Part 2 is the library itself: what the sandbox guarantees, how the two halves share one filesystem, and the four points where a host adds vocabulary to it without adding authority.
Read next
Articles on context graphs, enterprise search and industrial AI