shell
The shell worker is the single door every agent uses to touch the OS: run a
build, call a CLI, read a file, list a directory. Routing it all through
shell::* and shell::fs::* keeps a denylist, timeouts, output
caps, and an optional host-root jail (unjailed by default — see Boundaries)
in one enforceable place. Both surfaces take an
optional target field that forwards the call into a live iii-sandbox
microVM, so the same denylist gates host and sandbox execution alike.
Host-targeted shell::exec is not an isolation boundary. The denylist is a
regex tripwire on argv.join(" "), and any interpreter (sh,
node, python3) can construct any forbidden token at runtime to bypass it.
Run untrusted input with target: { kind: "sandbox", sandbox_id }. Prefer the
shell::fs::* backends over exec-ing ls/stat/grep/rg: they stay
in-process, honor fs.host_roots when it's configured, and return
structured results.
Sandbox forwarding (and shell::fs::* into a VM) requires the iii-sandbox
worker; iii trigger compose::add worker=shell does not pull it in. To surface shell::* to LLM
agents, pair with the skills worker.
When to Use
- Run a one-shot command and block for its full output:
git status, wc,
head, a quick compile probe (shell::exec).
- Kick off long work (build, watcher, wide grep) without blocking the turn,
then poll for completion (
shell::exec_bg + shell::status).
- Survey or terminate in-flight background jobs (
shell::list, shell::kill).
- List, stat, or read files with structured output instead of shelling out to
ls/stat/cat (shell::fs::ls, shell::fs::stat, shell::fs::read).
- Search or rewrite across a tree without spawning
rg/sed
(shell::fs::grep, shell::fs::sed).
- Create, move, remove, or re-permission paths (jailed only if
fs.host_roots is set)
(shell::fs::mkdir, shell::fs::mv, shell::fs::rm, shell::fs::chmod).
- Persist a generated artefact, or bootstrap files into a sandbox, by streaming
bytes to a path (
shell::fs::write with a target).
Boundaries
- Host
shell::exec is not a security sandbox: the denylist is bypassable by
any interpreter. Run untrusted commands with target: sandbox
(needs iii-sandbox).
shell::fs::* honors fs.host_roots as a jail WHEN it's set — empty (the
shipped default) means unjailed, confined only by fs.denylist_paths — and
always refuses denylisted paths regardless; paths must be absolute (unless
jailed, where a relative path resolves against the primary root) and
symlinks are never followed.
- Sandbox-backed background jobs cannot be hard-killed:
shell::kill flips the
record but the in-VM process runs until its timeout_ms (or sandbox::stop).
- Not for inlining file bytes into an LLM tool result:
shell::fs::read/
write move bytes over channels; use the harness worker's
harness::fs::read_inline wrapper for inline reads on the web surface.
- No batch or glob form for single-path ops (
mv, rm, stat, …); loop in the
caller.
- Not a package manager, editor, or migration tool; for SQL use the
database
worker.
Functions
shell::exec: run a command in the foreground and return its
stdout, stderr, exit code, and timing; blocks until exit or timeout. Sandbox
execution is fully valid (target: { kind: "sandbox", sandbox_id }); only the
host-only override fields — stdin (string piped to the program's stdin, then
EOF), plus cwd/env — are rejected with S210 when supplied on a sandbox
target, because the sandbox exec protocol does not forward them.
shell::exec_bg: spawn a command as a background job and return
a job_id immediately. Host-targeted jobs run until they exit or shell::kill
terminates them — unbounded by default, capped only when the operator sets a
positive max_bg_timeout_ms (default 0 = unbounded), after which a runaway
job is killed and its status becomes killed. Sandbox jobs honor timeout_ms.
Same optional host-only stdin as shell::exec.
shell::status: fetch one job's full record: state, exit code, and captured
stdout/stderr. A missing id (never existed or aged out) returns an S211
("no such job") error.
shell::list: enumerate current jobs as lightweight summaries (no argv,
stdout, or stderr).
shell::kill: terminate a running background job by job_id.
shell::config-status (operator/automation only — not agent-callable):
report the last hot-reload outcome — last_outcome (applied/rejected),
last_error, and rejected_reloads (count since boot). A rejected outcome or
non-zero count means a stored config was refused and shell is enforcing an
older policy than the central store. Takes no arguments.
shell::fs::ls: list a directory's entries with structured metadata.
shell::fs::stat: read one path's metadata (size, mode, symlink flag).
shell::fs::mkdir: create a directory, optionally with missing parents. Returns { created: bool, path: string, already_existed: bool }.
shell::fs::rm: remove a file or directory, optionally recursive. Returns { removed: bool, path: string, was_present: bool }.
shell::fs::chmod: change a path's mode, and optionally its uid/gid. Returns { entries_changed: u64, path: string, recursive: bool }. Note: the field was renamed from updated to entries_changed — callers relying on updated must migrate.
shell::fs::mv: rename or move one path within the jail. Returns { moved: bool, src: string, dst: string, overwrote: bool }.
shell::fs::grep: recursive regex search across a tree, returning structured
matches.
shell::fs::sed: regex find-and-replace across one file or many.
shell::fs::write: write a file. Simplest form is inline string content
(host target only): { path, content: "file text" }, with mode (octal,
default "0644") and parents: true. A ContentRef object in content
instead streams large/staged payloads via a channel (temp file + atomic
rename) and is required for sandbox targets. Batch form: pass
files: [{ path, content, mode?, parents? }, ...] to write several files in
one host call; the response then carries per-file files: [{ path, bytes_written }] (a single-file write leaves files empty).
shell::fs::read: stream a file's bytes out through a channel.
Every shell::fs::* call accepts the same optional target as exec, so host
and sandbox share one wire shape; reads and writes move bytes over SDK channels
rather than inlining them.
Live change feed (shell::changed)
A custom trigger type backed by a system-level directory watch. Bind it
with config: { path: "/some/dir" } to stream every change under that
directory — whoever made it: coder::* calls, shell::exec side
effects, or an editor outside the engine. Payload:
{ path, kind, root, dir } with kind ∈ created | modified | deleted,
path relative to root, dir true for directories (skip those when
opening files). Events coalesce per path in a short window
(create + write = created; deletion supersedes), .git internals are
filtered, and fan-out is fire-and-forget. Ignored paths (git-ignored, or
data/, config/, .iii/, node_modules/ outside a repository) are not
delivered unless the binding sets include_ignored: true — a wake on a
project root would otherwise fire on the engine's own data writes. Read
content on demand via coder::read-file — the event deliberately carries
none.
Code files (coder::*)
The shell worker also serves the coder::* code-file surface (formerly a
standalone worker) over the same jail (fs.host_roots): coder::info
(discover roots/caps first), coder::read-file (windowed + batch reads),
coder::search (add respect_gitignore: true to skip ignored files,
fuzzy_paths: true to rank path matches like an editor's quick open),
coder::list-folder, coder::tree, and the batched
coder::create-file / coder::update-file / coder::delete-file /
coder::move. Prefer these structured ops over editing files through
shell::exec. They return C2xx error codes (distinct from shell::*'s
S2xx); protected paths are the shared code.non_accessible_globs.
1---2name: shell3description: Run Unix commands and structured filesystem ops from the iii engine: exec, background jobs, and a structured fs (ls/stat/mkdir/rm/chmod/mv/grep/sed/ read/write, jailed only if fs.host_roots is configured), all forwardable into a sandbox microVM.4---56# shell78The shell worker is the single door every agent uses to touch the OS: run a9build, call a CLI, read a file, list a directory. Routing it all through10`shell::*` and `shell::fs::*` keeps a denylist, timeouts, output11caps, and an optional host-root jail (unjailed by default — see Boundaries)12in one enforceable place. Both surfaces take an13optional `target` field that forwards the call into a live `iii-sandbox`14microVM, so the same denylist gates host and sandbox execution alike.1516Host-targeted `shell::exec` is not an isolation boundary. The denylist is a17regex tripwire on `argv.join(" ")`, and any interpreter (`sh`,18`node`, `python3`) can construct any forbidden token at runtime to bypass it.19Run untrusted input with `target: { kind: "sandbox", sandbox_id }`. Prefer the20`shell::fs::*` backends over `exec`-ing `ls`/`stat`/`grep`/`rg`: they stay21in-process, honor `fs.host_roots` when it's configured, and return22structured results.2324Sandbox forwarding (and `shell::fs::*` into a VM) requires the `iii-sandbox`25worker; `iii trigger compose::add worker=shell` does not pull it in. To surface `shell::*` to LLM26agents, pair with the `skills` worker.2728## When to Use2930- Run a one-shot command and block for its full output: `git status`, `wc`,31 `head`, a quick compile probe (`shell::exec`).32- Kick off long work (build, watcher, wide grep) without blocking the turn,33 then poll for completion (`shell::exec_bg` + `shell::status`).34- Survey or terminate in-flight background jobs (`shell::list`, `shell::kill`).35- List, stat, or read files with structured output instead of shelling out to36 `ls`/`stat`/`cat` (`shell::fs::ls`, `shell::fs::stat`, `shell::fs::read`).37- Search or rewrite across a tree without spawning `rg`/`sed`38 (`shell::fs::grep`, `shell::fs::sed`).39- Create, move, remove, or re-permission paths (jailed only if40 `fs.host_roots` is set)41 (`shell::fs::mkdir`, `shell::fs::mv`, `shell::fs::rm`, `shell::fs::chmod`).42- Persist a generated artefact, or bootstrap files into a sandbox, by streaming43 bytes to a path (`shell::fs::write` with a `target`).4445## Boundaries4647- Host `shell::exec` is not a security sandbox: the denylist is bypassable by48 any interpreter. Run untrusted commands with `target: sandbox`49 (needs `iii-sandbox`).50- `shell::fs::*` honors `fs.host_roots` as a jail WHEN it's set — empty (the51 shipped default) means unjailed, confined only by `fs.denylist_paths` — and52 always refuses denylisted paths regardless; paths must be absolute (unless53 jailed, where a relative path resolves against the primary root) and54 symlinks are never followed.55- Sandbox-backed background jobs cannot be hard-killed: `shell::kill` flips the56 record but the in-VM process runs until its `timeout_ms` (or `sandbox::stop`).57- Not for inlining file bytes into an LLM tool result: `shell::fs::read`/58 `write` move bytes over channels; use the `harness` worker's59 `harness::fs::read_inline` wrapper for inline reads on the web surface.60- No batch or glob form for single-path ops (`mv`, `rm`, `stat`, …); loop in the61 caller.62- Not a package manager, editor, or migration tool; for SQL use the `database`63 worker.6465## Functions6667- `shell::exec`: run a command in the foreground and return its68 stdout, stderr, exit code, and timing; blocks until exit or timeout. Sandbox69 execution is fully valid (`target: { kind: "sandbox", sandbox_id }`); only the70 host-only override fields — `stdin` (string piped to the program's stdin, then71 EOF), plus `cwd`/`env` — are rejected with `S210` when supplied on a sandbox72 target, because the sandbox exec protocol does not forward them.73- `shell::exec_bg`: spawn a command as a background job and return74 a `job_id` immediately. Host-targeted jobs run until they exit or `shell::kill`75 terminates them — unbounded by default, capped only when the operator sets a76 positive `max_bg_timeout_ms` (default `0` = unbounded), after which a runaway77 job is killed and its status becomes `killed`. Sandbox jobs honor `timeout_ms`.78 Same optional host-only `stdin` as `shell::exec`.79- `shell::status`: fetch one job's full record: state, exit code, and captured80 stdout/stderr. A missing id (never existed or aged out) returns an `S211`81 ("no such job") error.82- `shell::list`: enumerate current jobs as lightweight summaries (no argv,83 stdout, or stderr).84- `shell::kill`: terminate a running background job by `job_id`.85- `shell::config-status` *(operator/automation only — not agent-callable)*:86 report the last hot-reload outcome — `last_outcome` (`applied`/`rejected`),87 `last_error`, and `rejected_reloads` (count since boot). A rejected outcome or88 non-zero count means a stored config was refused and shell is enforcing an89 older policy than the central store. Takes no arguments.90- `shell::fs::ls`: list a directory's entries with structured metadata.91- `shell::fs::stat`: read one path's metadata (size, mode, symlink flag).92- `shell::fs::mkdir`: create a directory, optionally with missing parents. Returns `{ created: bool, path: string, already_existed: bool }`.93- `shell::fs::rm`: remove a file or directory, optionally recursive. Returns `{ removed: bool, path: string, was_present: bool }`.94- `shell::fs::chmod`: change a path's mode, and optionally its uid/gid. Returns `{ entries_changed: u64, path: string, recursive: bool }`. **Note**: the field was renamed from `updated` to `entries_changed` — callers relying on `updated` must migrate.95- `shell::fs::mv`: rename or move one path within the jail. Returns `{ moved: bool, src: string, dst: string, overwrote: bool }`.96- `shell::fs::grep`: recursive regex search across a tree, returning structured97 matches.98- `shell::fs::sed`: regex find-and-replace across one file or many.99- `shell::fs::write`: write a file. Simplest form is inline string `content`100 (host target only): `{ path, content: "file text" }`, with `mode` (octal,101 default `"0644"`) and `parents: true`. A `ContentRef` object in `content`102 instead streams large/staged payloads via a channel (temp file + atomic103 rename) and is **required** for sandbox targets. Batch form: pass104 `files: [{ path, content, mode?, parents? }, ...]` to write several files in105 one host call; the response then carries per-file `files: [{ path,106 bytes_written }]` (a single-file write leaves `files` empty).107- `shell::fs::read`: stream a file's bytes out through a channel.108109Every `shell::fs::*` call accepts the same optional `target` as `exec`, so host110and sandbox share one wire shape; reads and writes move bytes over SDK channels111rather than inlining them.112113## Live change feed (`shell::changed`)114115A custom trigger type backed by a system-level directory watch. Bind it116with `config: { path: "/some/dir" }` to stream every change under that117directory — whoever made it: `coder::*` calls, `shell::exec` side118effects, or an editor outside the engine. Payload:119`{ path, kind, root, dir }` with `kind` ∈ `created | modified | deleted`,120`path` relative to `root`, `dir` true for directories (skip those when121opening files). Events coalesce per path in a short window122(create + write = `created`; deletion supersedes), `.git` internals are123filtered, and fan-out is fire-and-forget. Ignored paths (git-ignored, or124`data/`, `config/`, `.iii/`, `node_modules/` outside a repository) are not125delivered unless the binding sets `include_ignored: true` — a wake on a126project root would otherwise fire on the engine's own data writes. Read127content on demand via `coder::read-file` — the event deliberately carries128none.129130## Code files (`coder::*`)131132The shell worker also serves the `coder::*` code-file surface (formerly a133standalone worker) over the **same jail** (`fs.host_roots`): `coder::info`134(discover roots/caps first), `coder::read-file` (windowed + batch reads),135`coder::search` (add `respect_gitignore: true` to skip ignored files,136`fuzzy_paths: true` to rank path matches like an editor's quick open),137`coder::list-folder`, `coder::tree`, and the batched138`coder::create-file` / `coder::update-file` / `coder::delete-file` /139`coder::move`. Prefer these structured ops over editing files through140`shell::exec`. They return `C2xx` error codes (distinct from `shell::*`'s141`S2xx`); protected paths are the shared `code.non_accessible_globs`.