Unix shell and filesystem worker on the iii bus. Every agent that needs to touch the OS (run a build, read a file, list a directory, call a CLI) goes through shell::* and shell::fs::*, so allowlists, timeouts, output caps, and a host-root jail live in one place. Both surfaces accept an optional target field that forwards the call into a live iii-sandbox microVM, so the same allowlist policy gates host and sandbox execution.
Host-targeted shell::exec is not an isolation boundary. The denylist is a regex tripwire on argv.join(" "). A caller running an allowlisted interpreter (sh, node, python3) can construct any forbidden token at runtime and bypass it. For untrusted input, pass target: { kind: "sandbox", sandbox_id } so the call forwards into a microVM. Prefer shell::fs::ls, shell::fs::stat, and shell::fs::grep over exec-ing the same tools; the fs backends stay in-process, respect the jail, and return structured results.
iii worker add shelliii worker add fetches the binary, writes a config block into the engine's config.yaml, and the engine starts the worker on the next iii worker start.
For sandbox-targeted execution and shell::fs::* forwarding, install iii-sandbox; iii worker add shell does not currently pull it in. For surfacing shell::* to LLM agents, pair with skills:
iii worker add iii-sandbox
iii worker add skillsuse iii_sdk::{register_worker, InitOptions, TriggerRequest};
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let worker = register_worker("ws://localhost:49134", InitOptions::default());
let result = worker
.trigger(TriggerRequest {
function_id: "shell::exec".into(),
payload: json!({
"command": "echo",
"args": ["hello"],
}),
action: None,
timeout_ms: Some(5_000),
})
.await?;
println!("{result:#?}");
Ok(())
}import { registerWorker } from 'iii-sdk'
const worker = registerWorker('ws://localhost:49134')
const result = await worker.trigger({
function_id: 'shell::exec',
payload: { command: 'echo', args: ['hello'] },
})
console.log(result)from iii import register_worker
worker = register_worker("ws://localhost:49134")
result = worker.trigger({
"function_id": "shell::exec",
"payload": {"command": "echo", "args": ["hello"]},
})
print(result)The example calls shell::exec on the host. The same payload retargets at a microVM with target: { "kind": "sandbox", "sandbox_id": "<uuid>" }. Other entry points: shell::exec_bg, shell::status, shell::kill, shell::list, plus the shell::fs::* family (ls, stat, read, write, grep, sed, mkdir, rm, chmod, mv).
max_timeout_ms: 30000
default_timeout_ms: 10000
max_output_bytes: 1048576
working_dir: null
inherit_env: false
allowed_env:
- PATH
- HOME
- LANG
- LC_ALL
- TERM
# Default allowlist is intentionally read-only. Tools that can shell out
# (git hooks, curl -o/file://, find -exec, awk system(), sed e/-i, cargo
# build.rs, node -e, python3 -c, npm run, env <cmd>) are left out on
# purpose — add them per deployment after you've decided on the threat
# model. This worker is NOT a sandbox. Use `printenv` for read-only env
# inspection; `env` is excluded because `env <cmd>` execs arbitrary
# programs while passing argv[0]=="env" through the allowlist gate.
allowlist:
- ls
- cat
- pwd
- echo
- grep
- wc
- head
- tail
- sort
- uniq
- cut
- date
- whoami
- hostname
- which
- jq
- uname
- df
- du
- ps
- printenv
- basename
- dirname
# Denylist patterns are advisory, not a security boundary. They run as
# regex against `argv.join(" ")`, so a caller invoking an allowlisted
# shell or interpreter (sh, node, python, etc.) can bypass any pattern
# by constructing the forbidden token at runtime — variables, eval,
# IFS tricks, base64, etc. Treat these as a tripwire for honest
# mistakes; the actual security boundary is the sandbox backend.
denylist_patterns:
- "rm\\s+-rf\\s+/"
- ":\\(\\)\\s*\\{\\s*:\\|"
- "mkfs"
- "dd\\s+if="
- "shutdown"
- "reboot"
- "/etc/passwd"
- "/etc/shadow"
# Sub-execution / write escapes for commonly-added tools
- "\\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"
max_concurrent_jobs: 16
job_retention_secs: 3600
fs:
# SET host_root to a directory you intend to expose to shell::fs::*.
# When unset, the worker refuses to start unless allow_unjailed is true
# (because the alternative is "the entire filesystem is reachable
# behind only the advisory denylist", which is rarely intended).
#
# Default is /tmp: exists on every Unix host, is writable, and contains
# only ephemeral data. Operators should point this at the workspace
# they actually intend the shell worker to manage.
host_root: /tmp
allow_unjailed: false
max_read_bytes: 16777216
max_write_bytes: 16777216
denylist_paths:
- /etc/passwd
- /etc/shadow
# When enabled is true, callers can target a live sandbox via the
# top-level `target` field on shell::exec, shell::exec_bg, and every
# shell::fs::* request. When false, every sandbox-targeted call
# returns S210 ("sandbox target disabled in config") regardless of
# whether iii-sandbox itself is running.
sandbox:
enabled: true- Changing a path's permissions
- Running a one-shot command in the foreground
- Spawning a long-running command as a background job
- Searching a directory tree with regex
- Terminating a running background job
- Surveying current background jobs
- Listing a directory inside the jail
- Creating a directory inside the jail
- Renaming or moving a path inside the jail
- Streaming a file's bytes through a channel
- Removing a path inside the jail
- Find-and-replace across files
- Reading a single path's metadata
- Polling a background job to completion
- Streaming bytes into a file