# shell

[← All adapters](/docs/reference/adapters)

```ts
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.

```ts
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:

```ts
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()`:

```ts
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](/docs/reference/linting) 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.

| Tier | What it is | Availability |
| --- | --- | --- |
| `unshare` | Linux kernel namespaces. The default. | Linux |
| `docker` | A throwaway container per command on a Docker Engine daemon. The only tier that contains the filesystem. | Linux and macOS, wherever a daemon answers (`DOCKER_HOST` or the default socket). Refused on Windows, whose mount paths the tier does not parse |
| `none` | A plain subprocess. No isolation promised, none provided. | Everywhere |

```ts
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`](/docs/reference/errors):

```ts
// 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 `/proc` remounted 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: true` if 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 `unshare` deliberately 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: true` runs 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**: `HOME` is a private tmpfs inside the container, mode `0700` and 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.

```ts
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](#options) 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`](/docs/reference/errors) 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`](/docs/reference/errors) 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.

| Variable | Value |
| --- | --- |
| `PATH` | `/usr/local/bin:/usr/bin:/bin` (system locations only) |
| `HOME` | a private directory created for this process, mode `0700`, holding none of your dotfiles; on `docker`, a private tmpfs inside the container with the same properties |
| `LANG` | `C.UTF-8` |
| `TZ` | `UTC` |

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:

```ts
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](#what-docker-does-not-guarantee).

## Options

| Option | Default | Tiers | Purpose |
| --- | --- | --- | --- |
| `isolation` | `unshare` | all | Which tier to run under. Static: it is the containment the route promises, not a value data may choose |
| `network` | `false` | all | Allow network egress |
| `mapRootUser` | `false` | all | Root inside the user namespace or the container, instead of yourself |
| `cwd` | the host process's, or the image's | all | Working directory, static or resolved from the exchange |
| `env` | `{}` | all | Environment variables by value, static or resolved from the exchange. A grant surface: never derive a variable **name** from data, only a value; a name outside `[A-Za-z_][A-Za-z0-9_]*` is refused with `RC5003`. On `docker` the values are visible in `docker inspect` |
| `passEnv` | `[]` | all | Parent variables to forward by name |
| `stdin` | closed | all | Bytes written to standard input before the command reads, then closed, static or resolved from the exchange. The place for a token that must appear nowhere else: on stdin it reaches the command and nothing besides it, where in `env` it is in `docker inspect` and in `args` it is in the process list |
| `timeout` | none | all | Duration before the command and its children are killed, static or resolved from the exchange, so two exchanges through one step can carry different deadlines |
| `image` | none, required on `docker` | `docker` | The image the command runs in. Required with no default: selecting the tier without one is a configuration error naming the option. Passed to the daemon as one field, never interpolated, so a value from data cannot carry flags; never pulled. A host tier refuses it with [`OS1004`](/docs/reference/errors) |
| `mounts` | `[]` | `docker` | Host paths exposed inside the container, `{ host, container, readonly? }` with both paths absolute and in normal form: no `.` or `..` segments, no repeated or trailing separators (`RC5003` otherwise), so a path built from data cannot climb out of the directory the route named. The route decides what is exposed; nothing outside the list is visible to the command. A host tier refuses it with `OS1004` |
| `name` | `rc-<routeId>-<exchangeId>` | `docker` | Container name, so an operator can find a run with `docker ps` and a later re-attach can name it. A host tier refuses it with `OS1004` |
| `failOnNonZero` | `true` | all | Throw [`OS1002`](/docs/reference/errors) when the command exits non-zero |
| `maxOutputBytes` | 8 MiB | all | Cap on captured output, per stream |

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

```ts
{ 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`](/docs/reference/errors) 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:

```ts
.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

```ts
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.
