shell
v0.6.1Unix shell + filesystem worker — exec with denylist/timeout/output caps and background jobs; fs::ls|stat|mkdir|rm|chmod|mv|grep|sed|read|write with host jail, denylist, size caps, and sandbox-target forwarding
- macOS: arm64 · x64
- Linux: arm64 · armv7 · x64
exact versions are immutable; binary and bundle artifacts are digest-pinned.
configuration
- allowed_env:
- PATH
- HOME
- LANG
- LC_ALL
- TERM
allowlist:
code:
default_exclude_globs:
- **/.git/**
- **/node_modules/**
- **/target/**
- **/dist/**
- **/.venv/**
- **/__pycache__/**
non_accessible_globs:
- **/.env
- **/.env.*
- **/*.pem
- **/*.key
- **/secrets/**
default_timeout_ms: 10000
denylist_patterns:
- rm\s+-rf\s+/
- :\(\)\s*\{\s*:\|
- mkfs
- dd\s+if=
- shutdown
- reboot
- /etc/shadow
fs:
allow_unjailed: false
denylist_paths:
- /etc/passwd
- /etc/shadow
host_root: /tmp
max_read_bytes: 16777216
max_write_bytes: 16777216
inherit_env: true
job_retention_secs: 3600
max_bg_timeout_ms: 0
max_concurrent_jobs: 16
max_output_bytes: 1048576
max_timeout_ms: 120000
sandbox:
enabled: true
working_dir: nullreadme
open as markdownshell
Run allowlisted Unix commands, background jobs, and structured filesystem operations from the iii engine, on the host or forwarded into a sandbox microVM.
Install
iii worker add shellSandbox-targeted execution and shell::fs::* forwarding need the iii-sandbox worker; iii worker add shell does not pull it in. To surface shell::* to LLM agents, pair with iii-directory:
iii worker add iii-sandbox
iii worker add iii-directorySkills
Install the shell agent skill for Claude Code, Cursor, and 30+ other agents:
npx skills add iii-hq/workers --skill shellBrowse or install every worker skill at once:
npx skills add iii-hq/workers --list
npx skills add iii-hq/workers --allConfigure
Settings are managed through the central configuration worker. On boot, the shell worker registers its schema (id shell) and fetches the live value over RPC — that live value is the authoritative config, not a local file. The optional --config flag (default ./config.yaml) provides the initial_value sent on first registration only; once registered, subsequent boots pull the stored value from the configuration worker. When the config changes, the worker hot-reloads the security policy and fs backend automatically (see Hot-reload).
The worker refuses to start unless fs.host_root is set, or fs.allow_unjailed: true is explicitly opted in, because an unset root exposes the whole host filesystem behind only the advisory denylist.
By default, mkdir/chmod/write reject modes carrying setuid/setgid/sticky bits (the top octal digit, e.g. 4755) with S210, since they are a privilege-escalation primitive when the worker runs as root inside the jail. Set fs.allow_special_bits: true only if your workload genuinely needs them.
max_timeout_ms: 120000 # foreground exec hard cap; per-call timeout_ms is clamped to this
max_bg_timeout_ms: 0 # host bg job hard cap in ms; 0 = unbounded (foreground uses max_timeout_ms)
default_timeout_ms: 10000 # applied when the caller omits timeout_ms
max_output_bytes: 1048576 # 1 MiB; stdout/stderr past this set *_truncated
inherit_env: true # forward the worker's env to children; per-call dangerous keys still blocked
allowed_env: [PATH, HOME, LANG, LC_ALL, TERM] # gates per-call `env` (dangerous keys never settable)
# exec gate. argv[0] is matched by basename or exact path; an empty
# allowlist means OPEN — the shipped default, so any command runs.
# denylist_patterns are advisory regex over argv.join(" "), a tripwire for
# catastrophic mistakes only, NOT a security boundary.
allowlist: []
denylist_patterns:
- "rm\\s+-rf\\s+/"
- "mkfs"
- "dd\\s+if="
max_concurrent_jobs: 16 # exec_bg past this is rejected
job_retention_secs: 3600 # finished jobs pruned after this
fs:
host_root: /tmp # jail root for shell::fs::*; required (see above)
allow_unjailed: false # opt-in to running with host_root unset
max_read_bytes: 16777216 # 0 = unlimited
max_write_bytes: 16777216 # 0 = unlimited
denylist_paths: [/etc/passwd, /etc/shadow]
allow_special_bits: false # permit setuid/setgid/sticky bits in mode (default false)
sandbox:
enabled: true # false -> every target: sandbox call returns S210Zero-config default
With no --config file and no value stored in the configuration worker, the worker seeds a built-in default on first registration — so it boots with nothing configured (database-style). That built-in default is the shipped config.yaml: jailed to /tmp, env forwarded, open exec with a catastrophic-only denylist (kept in sync by a unit test). If the stored value is later nulled, the worker does not silently fall back to this seed: boot fails closed and a hot-reload keeps the last-good config. A config that is present but leaves fs.host_root unset (without fs.allow_unjailed: true) also fails closed.
Host shell::exec is not a security boundary: any allowlisted interpreter (sh, node, python3) can construct a denylisted token at runtime and bypass the regex. Run untrusted input with target: { kind: "sandbox", sandbox_id }, which forwards through the iii-sandbox microVM. The allowlist and denylist still apply on top of either backend.
Per-call cwd, env, and stdin (host target)
shell::exec and shell::exec_bg each accept optional fields so an agent can scope a single command to a directory, set specific env values, and feed it standard input without wrapping everything in sh -lc (which would defeat the argv allowlist):
cwd(string): the working directory for this one call. It is confined to the fs jail exactly likeshell::fs::*paths — jail-relative whenfs.host_rootis set (else absolute), canonicalized, and required to resolve insidehost_rootand missdenylist_paths. Acwdthat escapes the jail returnsS215; one that doesn't exist or isn't a directory returnsS211/S210. Omit it to use the configuredworking_dir(unchanged default).env(object of string→string): per-call environment values. A key may be set only if the operator already listed it inallowed_env, and never for an exec-hijacking key —PATH,IFS,HOME, everyLD_*/DYLD_*variant, and other loader/lookup-path and interpreter startup-file keys (GCONV_PATH,BASH_ENV,ENV,PYTHONSTARTUP,PERL5OPT,RUBYOPT,NODE_OPTIONS, …) are on a hardcoded denylist that wins overallowed_env. Note thatHOMEships in the defaultallowed_envfor the worker's own forwarded env but is not settable per-call. Supplying a key that is not inallowed_env, or any dangerous key, rejects the whole call withS210(the offending key is named and the permitted keys are listed); the env is never silently dropped. A permitted per-call value overrides the value that would otherwise be forwarded for that key. So an agent can doNODE_ENV=testonly if the operator putNODE_ENVinallowed_env, and can never injectPATH,HOME, orLD_PRELOAD.stdin(string): written to the program's standard input, which is then closed (EOF). Use it to feedtee,patch,cat, or any stdin filter instead of a shell heredoc. Omit it and stdin is/dev/null.
All three fields are host-only. The sandbox::exec protocol does not forward cwd/env/stdin, so a sandbox-targeted call that supplies any of them is rejected with S210 rather than silently ignoring it. Omit them and behaviour is identical to prior versions.
Quick start
import { registerWorker } from 'iii-sdk'
const iii = registerWorker(process.env.III_URL ?? 'ws://127.0.0.1:49134')
const result = await iii.trigger({
function_id: 'shell::exec',
payload: { command: 'echo', args: ['hello'] },
})
console.log(result)The example runs on the host. The same payload retargets at a microVM with target: { kind: 'sandbox', sandbox_id: '. The other entry points are shell::exec_bg, shell::status, shell::kill, shell::list, shell::config-status, plus the shell::fs::* family (ls, stat, read, write, grep, sed, mkdir, rm, chmod, mv).
Functions
| Function | Purpose |
|---|---|
shell::exec |
Run an allowlisted command in the foreground; returns stdout, stderr, exit code, and timing. Blocks until exit or timeout. Accepts optional host-only cwd (jail-confined), env (gated by allowed_env + a dangerous-key denylist), and stdin (string piped to the program's stdin, then EOF) — see Per-call cwd, env, and stdin. |
shell::exec_bg |
Spawn an allowlisted command as a background job; returns { job_id, argv } immediately. Host-targeted jobs run until they exit or shell::kill terminates them — unbounded by default, and 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 cwd/env/stdin as shell::exec. |
shell::status |
Fetch one job's full record: state, exit code, and captured stdout/stderr. A missing id — one that never existed or aged out past job_retention_secs — returns an S211 ("no such job") error. |
shell::list |
Enumerate current jobs as lightweight summaries; argv, stdout, and stderr are redacted. |
shell::kill |
Terminate a running background job by job_id. Sandbox jobs cannot be hard-killed: the record flips to killed but the in-VM process runs until its timeout_ms (or sandbox::stop). |
shell::config-status |
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, path, already_existed }. |
shell::fs::rm |
Remove a file or directory, optionally recursive. Returns { removed, path, was_present }. |
shell::fs::chmod |
Change a path's mode, and optionally its uid/gid. Returns { entries_changed, path, recursive }. (Breaking: field renamed from updated to entries_changed.) |
shell::fs::mv |
Rename or move one path within the jail. Returns { moved, src, dst, overwrote }. |
shell::fs::grep |
Recursive regex search across a tree; returns structured matches. Keys are singular include_glob/exclude_glob; the case flag is ignore_case. |
shell::fs::sed |
Regex find-and-replace across one file or many. |
shell::fs::write |
Write a file. Simplest form passes inline string content (host target only): { path, content: "file text" }, with mode (octal, default "0644") and parents: true to create parents. A ContentRef object in content instead streams large/staged payloads through an SDK channel (temp file + atomic rename) and is required for sandbox targets — an inline string on a sandbox target returns S210. Batch form: pass files: [{ path, content, mode?, parents? }, ...] (host, inline per file) to write several files in one call; the response then carries per-file files: [{ path, bytes_written }]. A single-file write leaves files empty and returns { bytes_written, path }. Supplying both single path/content and files returns S210. |
shell::fs::read |
Stream a file's bytes out through an SDK channel. For an inline read on the web surface, use the harness::fs::read_inline wrapper instead. |
Every shell::fs::* call accepts the same optional target as exec, so host and sandbox share one wire shape.
Code surface (coder::*)
The shell worker also serves the coder::* code-file functions (the former
standalone coder worker, folded in). They are agent-ergonomic, structured
file operations over the same jail as shell::fs::* (fs.host_roots):
| Function | Purpose |
|---|---|
coder::info |
Discover the jail: roots, caps, response budgets, exclude/non-accessible globs. Call first. |
coder::read-file |
Windowed reads (line_from/line_to), stat probe, byte-budgeted full reads, and multi-file batch reads. |
coder::search |
Literal/regex content + path search with context lines, bounded by match/byte budgets. |
coder::list-folder |
Paginated single-folder listing. |
coder::tree |
Recursive depth- and per-folder-bounded directory snapshot. |
coder::create-file / coder::update-file / coder::delete-file / coder::move |
Batched create, line/regex edits, delete, and atomic rename/move. |
Roots come from fs.host_roots; protection globs come from
code.non_accessible_globs — the same list shell::fs::* enforces (declared
once, see Configure). coder::* returns C2xx error codes (its
own taxonomy), distinct from shell::*'s S2xx. No separate install: iii worker add shell brings the whole surface.
Hot-reload
When the configuration worker pushes an updated config, the shell worker swaps in the new security policy and fs backend atomically. A few things to know:
- Each call executes against one consistent runtime snapshot; there is no mid-call config change.
- Already-running background jobs are not retroactively re-checked when the policy tightens — they continue under the policy that was active when they were spawned.
- A reload that widens the jail (for example, clearing
host_root) succeeds but is logged as a privilege change. - If the incoming config is invalid or unsafe, the worker keeps the last-good runtime and logs an error. The rejection is also surfaced through
shell::config-status(arejectedoutcome with a non-zerorejected_reloadscount), so the divergence between the central store and the policy shell is actually enforcing is detectable instead of silent. Rejections are kept last-good and not retried (re-fetching returns the same bad value), so they will not retry-storm. - At boot the reconcile against the configuration worker is fail-closed: the worker refuses to start (and exposes no functions) if it cannot confirm the authoritative config, so it never serves a possibly stale security policy.
Errors
Returned error bodies carry a stable code field. Allowlist and denylist rejections come back as a plain message (command ', command matches denylist: ) rather than an S-code.
| Code | Meaning |
|---|---|
S200 |
In-VM execution failure on a sandbox target. |
S210 |
Invalid request: non-absolute path, empty command or pattern, bad octal mode, malformed payload, sandbox.enabled: false on a sandbox-targeted call, a cwd that is not a directory, an env key outside allowed_env or in the dangerous-key denylist, cwd/env/stdin supplied on a sandbox target (host-only), an inline string content on a sandbox-targeted shell::fs::write, or both single path/content and files on shell::fs::write. |
S211 |
Path not found (including a cwd that does not exist). |
S212 |
Wrong file type for the operation (for example, a file where a directory was expected). |
S213 |
Path already exists. |
S214 |
Directory not empty (non-recursive rm). |
S215 |
Path (or a per-call cwd) escapes host_root, hits fs.denylist_paths, or permission denied. |
S216 |
Generic shell-internal failure: host spawn error, channel error, or a bad engine response. |
S217 |
Invalid regex passed to grep/sed. |
S218 |
fs.max_read_bytes / fs.max_write_bytes cap exceeded. |
S300 |
Sandbox VM boot failed (needs a virtualization host: Apple Silicon or /dev/kvm). |
Sandbox-forwarded fs::*/exec errors can also surface engine codes verbatim instead of collapsing to S216: S001–S004 (sandbox lifecycle), S100–S102 (image/VM/resource), S300, and S400. Branch on the specific code where relevant; only an unrecognized engine code falls back to S216.
Upgrading to 0.4.0
0.4.0 is a breaking release. Migrating from 0.3.x:
shell::fs::chmodresponse field renamedupdated→entries_changed; readentries_changed. Inbound legacy{ updated }from an older engine still deserializes (serde alias), but new consumers should readentries_changed.mkdir/rm/mvgained structured fields (path/already_existed,path/was_present,src/dst/overwrote) alongside the original boolean. Additive: existing readers ofcreated/removed/movedkeep working.- Configuration moved to the central
configurationworker (database-style) with hot-reload.--configis now a first-boot seed only; the live value from the configuration worker wins once an entry exists.--manifestwas removed (the interface is collected live). - New read-only
shell::config-statusreports the last hot-reload outcome. Operator/automation only; it is denied to agents. - Error envelope: the S-code is now the top-level wire
code. Every S-coded failure returns{ "code": "S211", "message": "no such job: ..." }with the S-code as the top-levelcode. Previously the code was buried — handlers returned the{code,message}JSON stringified intomessageunder the genericinvocation_failedcode. Consumers that branched oncode == "invocation_failed"or parsed the embedded JSON out of the message must update to readerror.codedirectly. - Host background jobs are unbounded by default. A host-targeted
shell::exec_bgjob runs until it exits orshell::killterminates it — long installs, builds, dev-servers, and tails are not killed. To force-kill a runaway host bg job, set a positivemax_bg_timeout_ms(new operator config field, default0= unbounded; separate frommax_timeout_ms, which bounds foregroundshell::exec); a job that exceeds it is killed and its status becomeskilled. Sandbox jobs still honortimeout_ms. - Per-call
envdenylist broadened. The always-rejected key set now includesHOMEand loader/lookup-path keys (LD_*,DYLD_*,GCONV_PATH, …) and interpreter/shell startup-file keys (BASH_ENV,ENV,PYTHONSTARTUP,PERL5OPT,RUBYOPT,NODE_OPTIONS). These are rejected even if listed inallowed_env.LD_*/DYLD_*are matched by family prefix, so a loader variable not in the explicit list is still rejected. Note thatHOMEis in the defaultallowed_envfor the worker's own forwarded env but is not settable per-call. - Sandbox-forwarded fs/exec errors can now surface engine S-codes that 0.3.7 collapsed to
S216— e.g.S001–S004(lifecycle),S100–S102(image/VM),S300, andS400. Branch on the specific code where relevant.
Troubleshooting
fs.host_root is unset ... refusing to start unjailed: setfs.host_rootto a directory, or setfs.allow_unjailed: true.command ': the basename of' not in allowlist argv[0]is not in a non-emptyallowlist. Add it, or empty the list to allow anything.S215 path escapes host_rooton a path inside the jail: a symlink in the path resolves outside the jail. Resolve it yourself, or move the target insidehost_root.S300on a sandbox target: the host cannot boot microVMs. Sandbox execution requires Apple Silicon or/dev/kvm.- Worker never connects: the engine is not running or not bound on the configured
--url. Start the engine first; the default WebSocket port is 49134.
For the threat model, streaming wire shapes, and contributor build steps, see ARCHITECTURE.md.
License
Apache 2.0 — see LICENSE.