Skip to content

Latest commit

 

History

History

README.md

shell

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.

Install

iii worker add shell

iii 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 skills

Quickstart

use 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).

Configuration

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

Additional Resources