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 cat and grep, 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.

© 2026 Curiosity. All rights reserved.