Computerwelt: how we built a secure sandbox for agentic workflows

Rafael

Computerwelt, part 2 of 4. Part 1 was the port of bashkit and monty to C#. This post is the library: what it guarantees and how a host extends it. Part 3 is Sudo, and part 4 is building your own.

Two lines get you a shell:

var bash = Bash.CreateBuilder().Build();

var result = await bash.ExecAsync("echo hello | tr a-z A-Z");
Console.WriteLine(result.Stdout);   // HELLO

ExecAsync returns a value. There is no global stdout, no console, and nothing written anywhere: ExecResult carries stdout, stderr, the exit code, the control flow that ended the script and whether output was truncated. Two Bash instances share nothing mutable, so a server can hold one per tenant and one per conversation without thinking about it.

What is behind /

Nothing, until you put something there.

$ ls -la /
drwxr-xr-x   1 user     user         4096 Jan 01 00:00 .
drwxr-xr-x   1 user     user         4096 Jan 01 00:00 ..
drwxrwxrwx   1 user     user         4096 Sep 18 10:31 tmp

$ cat /etc/passwd
cat: /etc/passwd: No such file or directory

The default backend is an in-memory tree with a byte and file-count quota. Every read and write in the interpreter and in all ~126 commands goes through one interface:

public interface IFileSystem
{
    ValueTask<byte[]> ReadFileAsync(VPath path, CancellationToken cancellationToken = default);
    ValueTask WriteFileAsync(VPath path, ReadOnlyMemory<byte> content, CancellationToken cancellationToken = default);
    ValueTask<IReadOnlyList<DirectoryEntry>> ReadDirectoryAsync(VPath path, CancellationToken cancellationToken = default);
    ValueTask<FileMetadata> StatAsync(VPath path, CancellationToken cancellationToken = default);
    // … rename, copy, symlink, chmod, mtime, usage, limits
}

That interface is the integration point that matters, and it is a small one on purpose. A host implements it and the whole vocabulary (ls, find, grep -r, sed -i, jq, python's open()) works over whatever the implementation answers with. In Curiosity Workspace the implementation is backed by a graph rather than by a disk, and no command knows that. The rule inside the library is absolute: nothing outside a backend may call System.IO.File or System.IO.Directory.

VPath is why a path argument can be handled safely at all. It is a POSIX-only readonly struct, normalised on construction, and it is deliberately not System.IO.Path: on Windows Path.Combine("/etc", "C:\\secrets") returns C:\secrets, which would be a sandbox escape that exists on one operating system and not the others.

The two halves share it

Computerwelt, the package that joins the two libraries, adds python as a shell command whose os, os.path and open are backed by the shell's own IFileSystem. So this is one sandbox, not two:

$ mkdir -p /data
$ printf 'alpha,3\nbeta,11\ngamma,7\n' > /data/rows.csv
$ python - <<'PY'
rows = [l.split(',') for l in open('/data/rows.csv').read().splitlines()]
top  = max(rows, key=lambda r: int(r[1]))
open('/data/top.txt', 'w').write(top[0] + '\n')
PY
$ cat /data/top.txt
beta

No CPython, no PATH lookup, no fork. python is an interpreter compiled into the same assembly, and the file it wrote is a file the shell around it can cat, grep and pipe.

This is what makes a sandbox usable by a model rather than merely safe. An agent that knows bash and Python does not have to be taught an API; it reaches for grep and gets grep, reaches for collections.Counter and gets it. The importable module list is small and closed (json, re, os, pathlib, posixpath, glob, fnmatch, math, datetime, collections, itertools, dataclasses, typing, io, sys, gc, asyncio, unicodedata), and a module that is not on it cannot be imported at all. There is no search path and no fallback.

What "sandboxed" means, precisely

What a session can reach: the host hands in a filesystem, limits and commands before the session is built, and a process, the host disk, the network and another session have no route in at all.

Five properties, and each of them is a thing the code cannot do rather than a thing it is asked not to.

No process spawning. Every command is a managed implementation. There is no PATH, no fork, no exec, and Process.Start does not appear in the library. A builtin that shells out would be a bug, not a shortcut.

No ambient filesystem. Above.

No ambient network. There is no HTTP client in the library. curl, wget and friends are not implemented, so they are not commands that refuse; they are names that do not exist:

$ type curl
bash: type: curl: not found
$ curl https://example.com
bash: curl: command not found
$ echo $?
127

Deterministic limits, charged during evaluation. Command count, loop iterations, total loop iterations across nested loops, function depth, nesting depth, output bytes, input bytes, parser fuel, glob matches, brace expansions, and two wall clocks, one for the whole run and one for parsing alone.

var strict = Bash.CreateBuilder().WithLimits(ExecutionLimits.Strict).Build();

await strict.ExecAsync("for i in $(seq 1 100000); do :; done; echo reached");
// exit 1
// bash: resource limit exceeded: max_loop_iterations (limit: 1000)

Three profiles ship (Strict, Default, Permissive), and every cap is a record with init setters, so a host takes one and adjusts:

.WithLimits(ExecutionLimits.Default with
{
    Timeout        = TimeSpan.FromSeconds(120),
    MaxCommands    = 100_000,
    MaxOutputBytes = 4_000_000,
})

Two things about limits are worth more than the list. The first is that MaxTotalLoopIterations exists at all: without it, n nested loops each capped at 10,000 permit 10,000ⁿ iterations, and the per-loop cap is decoration. The second is that a limit raises rather than quietly stopping. A traversal that hit a depth cap and returned what it had would report a subset of a tree as though it were the whole tree, and a model reading that output has no way to tell.

That principle shows up twice in the same feature. Depth is bounded where paths are created and again where they are walked:

What it is What happens at it
FsLimits.MaxDepth (64) the cap on a path the filesystem will create mkdir refuses — the tree cannot get deeper
PythonOptions.MaxDirectoryDepth (64) how deep a traversal descends raises OSError, which the program can catch
os.walk(..., max_depth=N) the caller's own bound ends the walk cleanly — this is asking for less, not hitting a limit

The first is the real containment: nothing can walk depth that cannot exist. The second only matters for a host-supplied IFileSystem over storage this sandbox did not build, where a tree can be arbitrarily deep or, through a link to its own ancestor, bottomless.

Multi-tenant isolation. Two sessions share no mutable state. This is also why the extension points below have the shapes they have.

Four ways in, and none of them is a way out

A host adds vocabulary. It never adds authority. Everything registered runs under the same limits, against the same virtual filesystem, with no route to the host that the sandbox did not already have.

var bash = Bash.CreateBuilder()

    // A related set of commands, granted as one unit.
    .WithExtension(new TicketsExtension())

    // One command, straight from a delegate.
    .WithBuiltin("queue-size",
        context => ExecResult.Ok($"{context.State.Get("QUEUE_SIZE") ?? "0"}\n"),
        llmHint: "queue-size: how many tickets are waiting.")

    // A name space too large to enumerate, answered one name at a time, consulted last.
    .WithCommandResolver(new RunbookResolver(catalogue))

    // Withheld: the session has no such command, and no script can discover one.
    .WithoutBuiltins("tar", "curl", "wget")

    // Python libraries, importable from the `python` command.
    .WithPython(new PythonOptions
    {
        Libraries = [PythonLibraries.Tickets(), PythonLibraries.Formatting()],
        HostFunctions = PythonLibraries.HostFunctions(),
    })

    .Build();

Each has a rule attached to it.

A builtin is shared by every execution of every session it is registered with, so it must be stateless and thread-safe. Everything one invocation can see arrives in its BuiltinContext.

A resolver is consulted last (after shell functions, after registered commands, after the search for a script in the filesystem), so it can extend the vocabulary but never shadow it. The price is that its names are not enumerable, and it pays that price honestly: they do not appear in type, in command -v, or in Bash.BuiltinNames, because the host cannot list them either.

A withheld name is absent, not refusing. WithoutBuiltin is applied after every registration, so it loses to nothing, and the result is exit 127 and command not found, the same answer as a name that was never implemented. A script cannot discover the shape of what it was denied.

A library is built per run. PythonRunner.Modules hands one object to every run, which is right for a constant table and wrong for anything a program can mutate: that would be one tenant's state becoming another's. Libraries builds a fresh module per run, which is why a library written in Python, module-level state and all, goes through that route.

Writing a command in C# does not weaken it

This is the part that surprises people. Host code is not beside the sandbox; it is handed the sandbox's own environment and gets nothing else.

// A command, in C#.
.WithBuiltin("upper", async (context, token) =>
{
    var text = await context.ReadTextAsync(context.Arguments[0], token);
    await context.WriteTextAsync(context.Arguments[1], text.ToUpperInvariant(), token);
    return ExecResult.Success;
})

// A function Python can call, in C#.
.WithPython(new PythonOptions
{
    HostFunctions = new()
    {
        ["disk_usage"] = (context, args) => new PyInt(Total(context.RequireFileSystem(), args)),
    },
})

BuiltinContext on the shell side and PythonHostContext on the Python side carry the same things (the virtual filesystem, the working directory, the environment the script exported, the run's limits and its clock) and nothing else. There is no host disk, no process and no network behind either of them.

They read that environment live, not from a snapshot, so a cd earlier in the script is where host code finds itself:

$ mkdir -p /srv && cd /srv
$ python -c "print(whereami())"
desk:/srv
$ cd / && python -c "print(whereami(), disk_usage('/var'), 'bytes in /var')"
desk:/ 86 bytes in /var

And because the sandbox has no ambient anything, host code that needs storage where there is none says so in the program's own language: RequireFileSystem() raises a Python OSError the script can catch, rather than letting a host exception escape into it. That distinction is load-bearing: a ShellException reaching a sandboxed program is the sandbox leaking, not an error the program can handle.

There is deliberately no way to compile C# from inside a script. Host code is registered by the host, in the host's own assembly, before the session is built. That is what keeps the reachable surface a list somebody wrote.

What that looks like end to end

samples/Computerwelt.Sample.Extensibility is a support desk in about 450 lines: a ticket command in C# storing to /var/tickets.tsv, a tickets Python library reading the same file, a formatting library written in Python, two C# functions in the program's globals, a resolver for an open-ended runbook catalogue, and three commands taken away. Running it prints:

== the shell ==
#1 [closed] Coffee machine is down
#2 [open] VPN drops on Wi-Fi
#3 [open] Badge reader is slow

== python, over the same filesystem ==
ID  STATUS  TITLE
--  ------  ----------------------
1   closed  Coffee machine is down
2   open    VPN drops on Wi-Fi
3   open    Badge reader is slow

== C# functions, over the live environment ==
desk:/srv
desk:/ 86 bytes in /var

== and one that was withheld ==
exit status 127
bash: tar: command not found

== 128 commands, of which the host added ==
python, queue-size, ticket, ticket-export

That last line is the exercise. The vocabulary a session has is a list somebody wrote, it can be printed, and tar is not on it.

The one exception, and why it is one

Computerwelt.Playwright is an optional package that makes Playwright's Python sync_api importable inside the sandbox, over Microsoft's .NET driver. A browser is a process, and the first rule of this repository is that the sandbox spawns none.

The rule is not weakened but moved. The host starts the browser, in host code, before any script is parsed; inside the sandbox p.chromium.launch() hands back a view of what is already running, and a launch option a script passes (headless, args, executable_path, proxy) is refused by name rather than ignored. Ignoring it would leave a script believing it had asked for something.

Everything else survives by construction:

  • AllowedHosts is empty by default and enforced as a RouteAsync("**/*") filter on every context, not only on the goto a script typed, because a page redirects, loads images, embeds iframes and calls fetch on its own behalf. goto is checked too, purely so the error names the URL the script wrote. file: is not an allowed scheme with or without a wildcard.
  • Every path in the API is a virtual path. A screenshot's bytes come back to this process and go out through the run's IFileSystem; an upload is sent as a payload the sandbox read. record_video_dir, record_har_path and downloads_path are refused rather than redirected, because there is nowhere honest to put them.
  • A context per script is the isolation unit (Playwright isolates cookies, storage and cache per context), so one browser with a context per run is both the cheap arrangement and the correct one.
  • The error translation is total. The first version matched PlaywrightException and let everything else through; the browser tests found the hole immediately, because the .NET driver raises the framework's own System.TimeoutException for a wait that ran out. A host exception was escaping into a script that could neither catch it nor print it.

Then /report/home.png is a file in the virtual filesystem, which the shell around the script can ls and cat. Nothing reached the host's disk.


That is the library. Part 3 is what we built on it: Sudo, the assistant in Curiosity Workspace, whose entire tool surface is one shell over a filesystem that is the workspace's own configuration.

Read next

Articles on context graphs, enterprise search and industrial AI

Connected knowledge for AI systems