shell
import { shell, untrusted } from '@routecraft/os'
import { z } from 'zod'
Run a command and produce its output, isolated by default. The adapter is an enricher: use it with .enrich() to merge the result, .to() to replace the body with it, or .tap() to discard it. Output is always captured, because a command's output is usually why you ran it. Requires execa and shescape as peer dependencies, and dockerode for the docker tier.
Requires @routecraft/os and its optional peers execa and shescape, plus dockerode for the docker tier: bun add @routecraft/os execa shescape. A missing peer raises RC5017 with an install hint.
import { shell, untrusted } from '@routecraft/os'
import { z } from 'zod'
craft()
.id('clone-repo')
.input({ body: z.object({ url: z.string() }) })
.from(direct())
.enrich(shell('git', (ex) => ['clone', untrusted(ex.body.url), '/work'], {
network: true,
timeout: 60_000,
}))
.to(log())
It never invokes a shell
Despite the name, shell() does not run a shell. It spawns the named program directly with an argument vector, so no bash, zsh or sh -c ever interprets a command line. An argument can therefore never become a command, however hostile its content: ; rm -rf / arrives at the program as that literal text.
This is the security boundary, and it is stronger than escaping. If you want shell interpretation, ask for it visibly:
shell('bash', ['-c', script])
Now a shell is in the loop and script is yours to trust.
Mark what came from outside
Direct spawning stops an argument becoming a command. It does not stop an argument posing as an option to the program you invoked. A url of --upload-pack=evil handed to git clone is still just an argument, and git honours it.
Wrap values that came from outside your code in untrusted():
shell('git', (ex) => ['clone', untrusted(ex.body.url), '/work'])
Marked values get flag-injection protection; every argument gets control-character hygiene either way. Protection is per value rather than blanket because it works by refusing leading dashes, and applying that to a whole argument list would destroy your own flags: --oneline would become oneline.
The require-untrusted-shell-args lint rule catches an exchange-derived value you forgot to mark.
Isolation tiers
Each tier is named for the mechanism that provides it, so its name is its promise.
shell('ls', ['-la'], { isolation: 'none', network: true })
A tier refuses what it cannot satisfy
network defaults to denied, and the none tier contains nothing, so it cannot deny anything. Rather than ignore the option and hand back full egress under a default that says otherwise, the call is refused with OS1004:
// Refused: the tier cannot deny egress, and the call left egress denied.
shell('curl', ['https://example.com'], { isolation: 'none' })
// Accepted: egress is granted out loud, so nothing is assumed.
shell('curl', ['https://example.com'], { isolation: 'none', network: true })
Running uncontained costs two visible words rather than one silent default. The same holds for mapRootUser on a tier with no user namespace. This matters more than its size suggests: denied egress is the strongest guarantee this adapter makes, because the tier deliberately does not contain filesystem reads, so no-network is what stands between a command reading a credential and sending it somewhere.
What unshare guarantees
- Processes: a PID namespace with
/procremounted inside it. Host processes are invisible to the command and cannot be signalled by it. - Network: a network namespace with no interfaces unless the call sets
network: true, so egress is denied by default. - Identity: a user namespace. The command never holds your host privileges. Pass
mapRootUser: trueif the work genuinely needs root inside. - Mounts: a mount namespace, so anything the command mounts is contained.
- IPC: an IPC namespace. Host SysV shared memory, message queues and semaphores are invisible.
- Hostname: a UTS namespace. The command can set a hostname of its own without touching yours.
What unshare does not guarantee
The command can still read every file you can read, including ~/.ssh, .env files, and the rest of your home directory.
This is stated plainly because the word "isolation" invites the opposite assumption. A mount namespace isolates mount propagation, not filesystem visibility: / inside the namespace is still the host's /. Read containment needs a pivot_root into a prepared root filesystem, which unshare alone cannot sequence.
If a command must not read your secrets, either run it on a host whose files it may all read, or wait for a tier that contains them.
Note how this interacts with network: true. Granting egress to a command that can read your files is what turns readable-but-local into exfiltratable. That combination deserves a deliberate decision, particularly for agent workloads.
What docker guarantees
- Filesystem: the command sees the image's filesystem and the mounts the call declared, and nothing else from the host. This is the containment
unsharedeliberately does not provide, and the reason an agent's build runs here. - Network: none unless the call sets
network: true, so egress is denied by default. - Identity: the caller's own uid and gid inside the container by default;
mapRootUser: trueruns as root inside. - Processes: the container's own PID namespace.
- The command: the program named at the call site is what runs. The image's own entrypoint is replaced rather than composed with, so an image whose entrypoint is a shell wrapper does not put a shell in front of the argument vector.
- Home:
HOMEis a private tmpfs inside the container, mode0700and owned by the container user, holding nothing of the host's; the baseline's promise on the host tiers, kept where the host's private directory cannot be seen. - Lifetime: one container per command, removed when it exits (
--rm). No pool and no reuse; a warm pool is a later decision with a benchmark behind it.
craft()
.id('sandbox-run')
.description('Run a command in a container mounted on the session workspace')
.input({ body: RunInput }) // { session: string; image: string; cmd: string; timeout?: Duration }
.from(direct())
.to(shell<RunInput>('sh', (ex) => ['-lc', untrusted(ex.body.cmd)], {
isolation: 'docker',
image: (ex) => ex.body.image,
timeout: (ex) => ex.body.timeout ?? '10m',
mounts: (ex) => [{ host: `/work/${ex.body.session}`, container: '/workspace' }],
cwd: '/workspace',
}))
This example deliberately executes data as shell code: sh -lc interprets cmd, and untrusted() marks the argument as data for the lint rule without quoting or neutralising it, so the guardrail is the container and nothing else. On a host tier the same line is command injection and must not be written; there the program is named at the call site and data travels through args as the git clone example above does. isolation stays static too; it sits in the same precedence chain as every tier.
What docker does not guarantee
A container shares the host kernel; it is not a virtual machine. The image is chosen by the call and pulled by nobody: an image that is not present fails the call with OS1002 naming it and the docker pull, so what runs inside is only as trusted as the image an author, or an agent, named. There is no image allowlist here; which images may run is the route's policy, and a route that takes the image from data has made that policy the data's. env values are visible in docker inspect; a value that must not be goes through stdin. The image's own ENV is present beside the granted baseline: the daemon merges the two, and the grant wins only on the names it sets, so an image that bakes in a credential hands it to every command. A mount whose host path does not exist is created by the daemon as a root-owned directory rather than refused. mapRootUser: true is the host's real uid 0 on a daemon without user-namespace remapping, not the unprivileged root of the unshare tier: with a writable mount it writes root-owned files on the host. A restart of the Routecraft process while a container runs fails that exchange; the container itself is left to --rm, and re-attaching to it afterwards is what name exists for and is not built yet.
Under Bun the tier feeds stdin through its own connection upgrade on the daemon's socket rather than through the client library's, because the library's upgrade never completes there. That upgrade reaches a unix socket or a TCP daemon, plain or TLS with the certificate settings the library resolved from DOCKER_CERT_PATH; an ssh:// DOCKER_HOST, which the library tunnels through an agent of its own, runs everything except stdin, and a call that sets it there is refused with OS1002 naming the connection. The TLS path is implemented from those settings and not exercised by the smoke suite, which runs against the local socket.
A tier that cannot be established fails
shell() never falls back to a weaker tier. If the requested isolation cannot be established, the call fails with OS1001 naming the cause and the ways out: grant the privilege, start the daemon, or write isolation: "none" deliberately. The realistic cases are unshare inside a container whose seccomp profile blocks namespace creation, a distro restricting unprivileged user namespaces, and docker on a host where no daemon answers.
The environment is granted, not inherited
A command gets a documented baseline of PATH, HOME, LANG and TZ, and nothing else. The values are fixed, not inherited, which is the point of the grant: an inherited HOME would point at your real home, so every command would find ~/.aws/credentials and ~/.ssh/config without anything granting them, and an inherited PATH would let a single writable entry on it choose which program runs. HOME is a directory this process creates and owns rather than the shared temp directory, because a world-writable home is worse than an inherited one: it lets any local account plant a .gitconfig or .npmrc that every command would read.
Everything further is declared at the call site, and passEnv is also how a command that genuinely needs your own HOME or PATH asks for it:
shell('deploy', ['--now'], {
env: { REGION: 'eu-west-1' }, // explicit values
passEnv: ['DEPLOY_TOKEN'], // forwarded from the parent by name
})
No credential-bearing variable reaches a command that did not ask for it. A name in passEnv that is unset in the parent is simply absent rather than an error. On the docker tier the image's own ENV sits beside this grant, which is the one place the grant is not the whole environment; see what docker does not guarantee.
Options
Per-call options beat the ROUTECRAFT_SHELL_ISOLATION environment override, which beats shellPlugin() defaults. The environment sits above plugin config so an operator can harden a loosely configured deployment, and below the call site so an explicit demand is never quietly changed. A container option on a host tier is refused rather than dropped, because a dropped image is the worst kind of silence: the author named a filesystem the command was to be confined to, and the command ran on the host's.
The result
{ stdout: string, stderr: string, exitCode: number, signal?: string, truncated: boolean }
ShellResult is the same on every tier. A timeout stops the command (or the container) and reports OS1003 the same way everywhere. A non-zero exit throws by default. For commands whose exit code is data rather than failure, read it off the result instead:
.enrich(shell('grep', ['-q', 'TODO', 'src'], { failOnNonZero: false }))
Output over maxOutputBytes keeps the head and the tail with a marker between them and sets truncated. On a long build log the tail is usually the part that explains the failure, so neither end is thrown away.
Context defaults
import { shellPlugin } from '@routecraft/os'
plugins: [shellPlugin({ timeout: 30_000 })]
shellPlugin() carries no network or mapRootUser default. Both widen what a command may do, and a context-level default that widens is the kind of grant nobody reads: whether a given command may reach the network is a property of that command, so it is stated where the command is written.