Extending the shell
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.
Everything a session can reach is decided when it is built and cannot be widened afterwards.
var bash = Bash.CreateBuilder()
// A related set of commands, granted as one unit.
.WithExtension(new TicketsExtension())
// One command, straight from a delegate.
.WithBuiltin("greet", context => ExecResult.Ok($"Hello, {context.Arguments[0]}!\n"))
// A name space too large to enumerate, answered one name at a time and consulted last.
.WithCommandResolver(new RunbookResolver(catalogue))
// Withheld: the session has no such command, and no script can discover one.
.WithoutBuiltins("tar", "curl")
.Build();
The four points
| Point | Shape | For |
|---|---|---|
WithBuiltin |
IBuiltin, or a delegate |
one command; replaces a default of the same name |
WithExtension |
IShellExtension |
a domain vocabulary, granted or withheld as a unit |
WithCommandResolver |
ICommandResolver |
an open-ended name space, resolved on demand |
WithoutBuiltin / WithoutBuiltins |
a name | taking a command away |
A resolver is consulted last, after shell functions, registered commands and the search for a script, so it can extend the vocabulary but never shadow it, and its names are not enumerable.
WithoutBuiltin is applied after every registration, so a withheld name loses to nothing. The
name is absent rather than refusing: type, command -v and Bash.BuiltinNames do not report
it. That distinction matters when the point is that a capability does not exist rather than that it
is currently denied.
Writing a command in C#
Host code is handed the sandbox's environment rather than reaching for one. A command gets a
BuiltinContext carrying the virtual filesystem, the working directory as it stands right now,
the environment the script exported, and the run's limits and clock. There is no host disk, no
process and no network behind it.
.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;
})
echo hi > /a.txt && upper /a.txt /b.txt
Because the context reads the environment rather than a snapshot of it, a cd earlier in the script
is where host code finds itself too.
A builtin is shared, so it must be stateless
One IBuiltin instance serves every execution of every session it was registered with. Hold no
per-invocation state on it and make it thread-safe; everything one invocation can see arrives in
its BuiltinContext.
Returning results
ExecResult is a value the command produces and returns. Builtins never write to a global stdout,
which is what lets one be called inside a pipeline, a subshell or a command substitution without
knowing about any of them.
return ExecResult.Ok(StreamData.FromText(report)); // success with output
return ExecResult.FromExitCode(1); // ordinary failure
return ExecResult.Usage("upper", "usage: upper SRC DST"); // exit 2
Ordinary failure is an exit code, not an exception. Reserve exceptions for genuinely fatal conditions.
Telling a model what a command does
WithBuiltin takes two optional strings beside the implementation:
.WithBuiltin(
name: "tickets",
run: RunTickets,
llmHint: "tickets list|show <id>: reads the ticket queue. Read-only.",
help: "tickets - the ticket queue\n\n tickets list\n tickets show <id>\n")
llmHint is the one-line capability summary a host puts in front of a model, so a registered
command is discoverable without the model guessing at it; help is what --help prints for a
person. Both are optional, and a command with no hint simply does not appear in that summary.
Bytes, not strings
StreamData holds bytes and UTF-8 decoding happens only at the edges, so do not round-trip binary
content through string. A command that reads a file and writes it back should move bytes.
Deciding what to register
The library will not stop you registering a command that opens a socket or reads the host disk. It simply never does that on your behalf, so what a session can reach is a list somebody wrote.
Two questions worth asking about anything you register:
- Does it need authority the sandbox does not have? If yes, that authority is now the script's. Make it narrow: a command that fetches one known URL is a different proposition from one that fetches any URL.
- Can it be expressed as data instead? Putting the answer in the virtual filesystem, where the
script reads it with
catandgrep, needs no new command at all, and is usually the better design.
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.
Read next
- The sandbox model — where the boundary sits.
- Python libraries — the same idea on the Python side.
- Building an agent sandbox — the whole thing put together.