shell
v0.3.6Unix 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:
- ls
- cat
- pwd
- echo
- grep
- wc
- head
- tail
- sort
- uniq
- cut
- date
- whoami
- hostname
- which
- jq
- uname
- df
- du
- ps
- printenv
- basename
- dirname
default_timeout_ms: 10000
denylist_patterns:
- rm\s+-rf\s+/
- :\(\)\s*\{\s*:\|
- mkfs
- dd\s+if=
- shutdown
- reboot
- /etc/passwd
- /etc/shadow
- \bfind\b[^|;&]*-exec(dir)?\b
- \bawk\b[^|;&]*system\s*\(
- \bsed\b[^|;&]*(-i\b|\be\b)
- \bcurl\b[^|;&]*(file://|-o\s|--output-dir\b|-F\s+@)
- \bgit\b[^|;&]*(--upload-pack|--receive-pack|core\.pager|core\.hooksPath|GIT_SSH_COMMAND)
- \b(node|python3?)\b[^|;&]*\s-(e|c)\b
- \bnpm\b[^|;&]*\brun\b
fs:
allow_unjailed: false
denylist_paths:
- /etc/passwd
- /etc/shadow
host_root: /tmp
max_read_bytes: 16777216
max_write_bytes: 16777216
inherit_env: false
job_retention_secs: 3600
max_concurrent_jobs: 16
max_output_bytes: 1048576
max_timeout_ms: 30000
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-directoryConfigure
Settings load from a YAML file passed with --config (default ./config.yaml). 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.
max_timeout_ms: 30000 # hard cap; per-call timeout_ms is clamped to this
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: false # when false, only allowed_env keys are forwarded
allowed_env: [PATH, HOME, LANG, LC_ALL, TERM]
# exec gate. argv[0] is matched by basename or exact path; an empty
# allowlist means open. denylist_patterns are advisory regex over
# argv.join(" "), a tripwire for honest mistakes only.
allowlist: [ls, cat, pwd, echo, grep, wc, head, tail, sort, uniq, cut, date]
denylist_patterns:
- "rm\\s+-rf\\s+/"
- "mkfs"
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]
sandbox:
enabled: true # false -> every target: sandbox call returns S210Host 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.
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, 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. |
shell::exec_bg |
Spawn an allowlisted command as a background job; returns { job_id, argv } immediately. Host-targeted jobs ignore timeout_ms (end via shell::kill or natural exit); sandbox jobs honor it. |
shell::status |
Fetch one job's full record: state, exit code, and captured stdout/stderr. not_found means the id never existed or aged out past job_retention_secs. |
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::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. |
shell::fs::rm |
Remove a file or directory, optionally recursive. |
shell::fs::chmod |
Change a path's mode, and optionally its uid/gid. |
shell::fs::mv |
Rename or move one path within the jail. |
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 |
Stream bytes into a file through an SDK channel; writes via a temp file and renames atomically. No inline content field. |
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.
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, or sandbox.enabled: false on a sandbox-targeted call. |
S211 |
Path not found. |
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 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). |
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.