Building an agent sandbox
The pattern Computerwelt exists for: give a language model a shell, mount the data it may read as a filesystem, and keep whatever it writes reviewable rather than applied.
Why a shell rather than a tool per operation
A model that is handed a catalogue of tools needs a new tool every time the product grows a surface,
and the catalogue is what the model has to learn before it can do anything. A filesystem needs no
new tool: a new kind of thing is a new folder, and ls, cat and grep already work on it.
The practical consequence is that most of what the model does costs you nothing to support. Finding
a file, reading it, changing a line and checking what changed are all the shell's job. What you have
to write are the operations a text editor and grep genuinely cannot do.
The shape
var bash = Bash.CreateBuilder()
// 1. What the model may see.
.WithFileSystem(new ProjectionFileSystem(tenant))
// 2. Where it starts, and who it thinks it is.
.WithWorkingDirectory("/work")
.WithUsername("agent")
.WithHostname("sandbox")
// 3. What it may spend.
.WithLimits(ExecutionLimits.Default with
{
Timeout = TimeSpan.FromSeconds(120),
MaxCommands = 100_000,
MaxOutputBytes = 4_000_000,
})
// 4. What only you can do for it.
.WithBuiltins(MyCommands.Create(session))
// 5. What it must not have, even if the library grows it later.
.WithoutBuiltins("curl", "wget", "nc", "ssh", "scp", "ping", "dig", "rsync", "git")
// 6. Python, over the same filesystem.
.WithPython(new PythonOptions())
.Build();
Then one tool on the model's side, taking one string, returning what it printed:
var result = await bash.ExecAsync(script, cancellationToken);
return new
{
stdout = result.Stdout.ToString(),
stderr = result.Stderr.ToString(),
exitCode = result.ExitCode,
truncated = result.StdoutTruncated || result.StderrTruncated,
};
Six things worth getting right
1. Withhold names you never want, before you need to
WithoutBuiltins makes a name absent rather than refusing. Listing the networking commands costs
nothing today, when the library ships none of them, and means that the day it grows one your session
does not silently acquire it.
2. Mount, do not copy
A filesystem whose contents are generated on read cannot go stale. Regenerating /proc-style state
per read means the same cat run twice reports two different moments, which is what a model
watching a long-running job needs.
Serve a document as the text a reader can use, not as its bytes. A PDF's bytes are no use to a shell; its extracted text is the thing a question about it is actually about.
3. Make writes provisional
Give the model a writable overlay over the real data rather than the real data. Then a diff of the overlay against the base is a complete, reviewable statement of what it wants to change, and applying it is a separate step a person authorises.
That is the whole design of
Sudo in Curiosity Workspace: the model
edits its own copy, commit stages a diff and stops, and the administrator's approval is what
applies anything.
4. Validate in the sandbox, before the review
If the files mean something (they compile, they parse, they have a schema), give the model a command that checks them, and make the hand-over refuse while that command fails. A reviewer should be reading a change that is known to be well-formed, not finding out that it is not.
5. One session per conversation
Keep the session keyed to the conversation, so a model that half-finished something yesterday finds the file where it left it, in the directory it left it in. Persist only what the session changed; re-read everything else live, so a change someone else made meanwhile is the one the model sees.
Sweep idle sessions on access rather than from a timer, and never evict one while a command is running.
6. Give it a help
A command that prints the layout, the extra commands, and which of them this session may use is worth more than any amount of prompt text, because it is read at the moment it is needed and it cannot drift out of date with the code that answers it.
Errors are text, not exceptions
A non-zero exit, a Python traceback and a compile diagnostic are all things a model can read and act on. Hand them over as they are. Reserve real exceptions for conditions the model cannot do anything about, and translate anything crossing the boundary into the sandboxed program's own terms.
A worked example
Sudo, the admin assistant in Curiosity Workspace, is this pattern at full size: eight mounts, a writable overlay over the live configuration, a dozen extra commands, per-conversation sessions, a build step that must pass, and a human approval that is the only path to a write.
Read next
- Extending the shell — the commands in step 4.
- Limits — the budget in step 3.
- Sudo — the worked example.