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 half of the port feature by feature and are ratcheted; the defects lived in the seam where the two halves meet, which only a suite of whole operations could see.

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/wget and a virtual git are 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

Connected knowledge for AI systems