The sandboxing model

A Jaiph workflow runs scripts, calls agents, and reads and writes files on whatever machine you run jaiph run on. That access is the point of a workflow, and it is also the risk. A careless or untrusted script can read your files, send secrets off the machine, and run any program it wants unless something limits it.

Jaiph limits this risk at two layers, and each layer does a different job.

This page explains the model behind sandboxing. It covers what each layer protects, what each layer does not protect, and why the design makes the trade-offs it does. The steps for turning Docker on or off, the full list of configuration keys, and the error codes live on their own how-to and reference pages, so this page stays with the concepts.

For the runtime implementation, see the Docker runtime helper in Architecture.

Two layers, two jobs

Rules and Docker isolation do different work, so it helps to keep them separate.

Layer When it fires What it constrains What it does not constrain
Rules Compile time The set of step types allowed inside a rule body (no inline shell, no prompt, no const … = prompt, no send, no run async) Anything a script does at runtime (rules can still call scripts via run)
Docker jaiph run launch time Filesystem reach, process isolation, capabilities, and env-var exposure for every step in the workflow Outbound network (on by default), agent credentials (forwarded by design), hooks (run on the host)

Rules are about structure. By the time the compiler finishes, a rule cannot contain a step type that changes state in a surprising way. There is no operating system sandbox around a rule body. When a rule calls a script, that script runs as a normal managed subprocess with the same access the workflow has. Treat rules as checks that do not change state, and do the state changes in workflows.

Docker is about limiting damage. It cannot stop a script from misbehaving, but it can keep the misbehavior inside a throwaway container.

The two sandbox modes

When Docker is on, the CLI picks one of two sandbox modes at launch. The mode controls how the workspace appears inside the container. The environment allowlist, the mount allowlist, and the container settings are the same in both modes. Every mode runs with --cap-drop ALL and zero cap-adds, --security-opt no-new-privileges, no --device, no AppArmor exception, and on Linux --user host_uid:host_gid.

Snapshot mode gives you one clear property. The host workspace is unchanged after a Docker run. Inplace mode gives up that property in exchange for a faster edit loop, so on jaiph run the CLI asks for confirmation before launch, because the run can change your files. jaiph mcp uses the same default of an isolated workspace. Set JAIPH_INPLACE=1 to bind the live workspace read-write for MCP tool calls, and see the MCP server’s safety posture.

What the snapshot contains

The snapshot is defined by git, not a raw copy of the directory. For a git workspace, the snapshot contains exactly these two things:

Nothing else is copied. Gitignored files never enter the sandbox. A .env, a credentials.json, an .npmrc with a token, a built dist/, and a node_modules/ tree are all absent from /jaiph/workspace. They are not empty, they are absent. This matches how the environment allowlist handles secrets. The approved way to get a secret into a run is to inject it into trusted steps on purpose, with --env or trusted_envs, and never because it happened to sit in a gitignored file. Leaving out ignored files is also the main reason cloning is fast, because ignored build directories are usually most of a workspace’s files and they are never even scanned.

The content is the same no matter which copy method runs (APFS clonefile, block-level reflink, or a plain data copy) and no matter the platform. What the agent sees comes from git’s answer, not from the host filesystem.

Details and edges:

The result is that the sandbox is a clean checkout plus untracked files. Because node_modules/ and other gitignored build output are absent, a workflow that builds or tests must install its dependencies inside the container, with npm install, pip install, cargo fetch, or the like, the same way a fresh CI checkout would. There is no configuration option to add ignored paths back. If a workflow needs a dependency tree, it installs the tree as one of its steps.

Confirmation prompts and access scope

Both of these opt-outs can change your files or your machine, so jaiph run shows an interactive Continue? [y/N] prompt that defaults to no. Each prompt states, in plain language, what the run can reach, because that is what decides how much damage it can do.

Mode Sandbox Filesystem reach Network / env
--inplace Docker on (container boundary, dropped caps, env allowlist) This workspace directory only, bind-mounted :rw at /jaiph/workspace. Scripts and agents cannot read or write host paths outside it Outbound network on by default (set JAIPH_DOCKER_NETWORK=none to disable). Only allowlisted env vars cross unless you use --env
--unsafe Docker off. The workflow runs as the host jaiph process Your entire host filesystem (and host $HOME, SSH agent, Keychain, and so on), with no mount restriction Full host environment visible to scripts and agent backends

An unsafe or host-only run has no Docker timeout, because Docker is off, so by default the only automatic stop is a manual Ctrl-C. Set JAIPH_RUN_TIMEOUT (seconds) to give the host run a parent-enforced wall-clock cap that stops it without a manual signal. See Configuration — Overall run timeout and step cap.

In every mode, run artifacts go to a separate read-write mount at /jaiph/run, which is outside the workspace sandbox, so the artifact tree under .jaiph/runs/ stays on the host no matter what happened inside the container.

Interrupting a Docker run

Pressing Ctrl+C, or sending SIGTERM to the host jaiph process, stops the whole run, including the container. Stopping the container matters because a docker run --rm container can outlive its host docker client. On some setups, such as Docker Desktop, killing the client leaves the container running, so simply killing the CLI would leave an orphaned container. That orphaned container would keep running workflow and agent work against the sandbox with no CLI attached.

Jaiph closes that gap. Every sandboxed container is launched with a fixed name. On interrupt the host CLI removes the container by name before it deletes the host-side sandbox clone. It runs docker kill to stop the container, then docker rm -f to drop the record of the --rm container. The two steps are split on purpose, because a single docker rm -f on a still-running container can block on Docker Desktop lock contention while the host docker run client is shutting down. The behavior you can rely on is this:

The same teardown runs when a run hits its Docker timeout (E_TIMEOUT), and when a jaiph mcp server cancels a single call (see how to cancel an in-flight MCP call). The runtime wiring lives in the Docker runtime helper in Architecture.

What Docker protects against

The Docker sandbox is built to limit the damage from untrusted or semi-trusted workflow scripts. It protects the following things.

What Docker does not protect against

The following list covers what Docker does not defend, on purpose.

Jaiph lists these limits because a sandbox that claims too much is worse than one that is honest about what it does. Jaiph treats the Docker boundary as a way to limit the damage a workflow script can do, not as a vault for credentials or a network firewall.

Prompt captures in shell steps

A workflow can receive free-form text from an agent through a prompt step and then use that value in later steps. The value is controlled by the agent or user by design, so a shell step has to treat it as data and never as a command to run.

Workflow shell steps, which are free-form lines in a workflow body, run through sh -c after Jaiph substitutes ${varName} references. Before the runtime substitutes a value into a shell step, it shell-quotes the value, so a value like `id` or ; rm -rf . reaches the shell as literal data and is never read as commands. The quoting covers every value a shell step can interpolate, including workflow parameters, const values, prompt and other captures, for loop iterators, channel payloads, and inline ${run …} / ${ensure …} capture results. A caller who reaches a shell step through jaiph mcp or jaiph serve, where request arguments bind to workflow parameters, cannot inject a command this way.

The compiler adds a second layer for prompt captures. It emits a W_PROMPT_IN_SHELL diagnostic when a prompt capture is interpolated into a shell step:

workflow default() {
  const msg = prompt "Enter a label:"
  git commit -m "${msg}"   # W_PROMPT_IN_SHELL: msg is agent-controlled
}

The diagnostic fails the build. jaiph compile exits non-zero and jaiph run refuses to start, through the same recoverable-error channel every other E_ or W_ diagnostic uses, because Jaiph has no separate non-fatal warning level today. It steers you toward the argv path below, which keeps an agent-controlled value out of the shell command string in the first place.

The safe pattern is to pass prompt captures as named arguments to a script step. Scripts receive arguments through $1 $2 … as argv, not as shell-expanded strings, so there is no substitution step between the capture value and the script’s argument.

In your script body (commit_with_label), use positional parameters:

# commit_with_label — receives label as $1
git commit -m "$1"

In the workflow, call it with the prompt capture as a bare argument:

workflow default() {
  const msg = prompt "Enter a label:"
  run commit_with_label(msg)   # no W_PROMPT_IN_SHELL: argv path is safe
}

The compiler does not warn on run script(promptCapture), which is the recommended form.

When the diagnostic fires and when it does not:

Pattern Diagnostic
echo "${capture}" in a workflow body (shell step) W_PROMPT_IN_SHELL
run myscript(capture) none (argv is safe)
log "${capture}" / logerr "${capture}" none (log output is not a shell sh -c execution)
Non-prompt variable interpolated in a shell step none

To resolve the diagnostic, remove the prompt capture from the shell line. There is no inline suppress comment and no non-fatal-warning mode. The intended fix is the argv path above. Move the shell line into a named or inline script that receives the value as $1, which is both the safe form and the form the compiler accepts. Rewriting the substitution with your own shell quoting inside the same shell step does not clear the diagnostic, because the check flags the data flow of a prompt capture reaching a shell step, not the specific escaping.

Under --unsafe or --inplace, the host filesystem is fully exposed, so any command a shell step runs takes effect directly on the host. Runtime shell-quoting keeps an interpolated value from injecting extra commands, whatever its source, and the compile-time diagnostic steers prompt captures onto the argv path. The argv path is still the form to prefer, because passing a value as $1 hands the script the exact bytes with no quoting applied. Shell-quoting a value that contains shell metacharacters changes how it prints. For example, a value of $(id) interpolated into echo "${name}" prints as the literal $\(id\), because the runtime escaped it. A script that reads the value as $1 receives $(id) unchanged.

Why opt-out, not opt-in

Docker is on by default. It stays on unless the host sets JAIPH_UNSAFE=true, or sets JAIPH_DOCKER_ENABLED to any value other than exactly true. The default is deliberate. Workflows run agent and script code that is often pulled from a repository, edited by a model, or contributed by someone else. Making the safer setting the easy path means a careless workflow is contained by default, and it escapes the container only when a person types out the override.

A second choice is also deliberate. Turning the sandbox on or off lives entirely in environment variables, not in the in-file config. Module-level runtime.docker_* keys can set the image, the network, and the timeout, but nothing in a .jh file can turn Docker off, and runtime.docker_enabled is rejected at parse time. This keeps the host in charge of whether the sandbox runs, so a workflow file from a less-trusted source cannot ship an off-switch with it.

The escape hatch is JAIPH_UNSAFE=true or jaiph run --unsafe. It exists because some environments genuinely cannot run Docker, such as a sandboxed CI without nested virtualization, or a developer working on the runtime itself. Taking the hatch should be clear and easy, so it is a single host-side switch rather than an in-file config setting. On jaiph run it is also gated behind the unsafe confirmation prompt, or an explicit --yes or JAIPH_INPLACE_YES for non-interactive use, so turning off the sandbox is a deliberate act, not a silent default.

Windows runs host-only

On Windows (win32) the Docker sandbox is out of scope. The sandbox modes rely on POSIX socket paths and on workspace handling specific to Linux and macOS, so Jaiph does not try them on Windows. jaiph run on Windows uses host-only mode automatically, which is the same as an explicit JAIPH_UNSAFE=true, and it prints a one-line notice that the run is host-only. The CLI never probes for docker and never fails just because no Docker daemon is present, and JAIPH_DOCKER_ENABLED=true cannot force the sandbox back on. Windows workflows therefore run with no operating system sandbox, so keep the what Docker does not protect against list in mind, or run under WSL, where the Linux path and the full sandbox apply.

Why jaiph test does not use Docker

The test runner runs in-process on the host, on purpose. Tests are a fast development loop, they usually mock prompts and replace outside calls, and the cost of spawning Docker would slow that loop down. Tests already get isolation from the things they care about, such as prompts and network, through the runtime’s mock support. The Docker boundary is for jaiph run, where the workflow runs real scripts against real resources.

How sandboxing fits the rest of Jaiph

The Docker sandbox does not change what a workflow means. The runtime inside the container is the same NodeWorkflowRuntime AST interpreter that runs locally. The container runs jaiph run --raw, which spawns the internal __workflow-runner child the same way host --raw execution does (see the Docker runtime helper in Architecture). It uses the same __JAIPH_EVENT__ stream on stderr and writes the same run_summary.jsonl under .jaiph/runs/. The only differences are where the processes run and what host resources they can reach.

That sameness is the point of the design. A workflow is the same workflow whether it runs sandboxed or not. The sandbox is a deployment choice, not a programming model.

Runtime image toolchain

The default sandbox image is ghcr.io/jaiphlang/jaiph-runtime, built from runtime/Dockerfile. It ships a curated set of engineering tools so script steps and agent backends can run common build, test, and lint commands without installing them each time. It is not a full clone of a GitHub Actions VM. It ships one stable version per language, no browser or Android SDK matrix, and no nested Docker daemon. The published image is currently about 3.2 GB on disk (linux/amd64). The first docker pull downloads that once, and after that the layers are cached locally.

Jaiph and agent backends

Backend Mechanism In image?
jaiph Workflow runner inside the container yes
claude (@anthropic-ai/claude-code) Anthropic CLI subprocess yes (global npm install)
cursor-agent Cursor CLI subprocess yes (user install under /home/jaiph)
codex OpenAI Chat Completions HTTP API, built into jaiph, no separate CLI yes, uses bundled node and jaiph, and needs OPENAI_API_KEY on the host (forwarded when the entry file selects codex)

Set the backend with agent.backend = "cursor" | "claude" | "codex". For credential rules, see Authenticate agent backends.

Version control and shell

Tool Role
git, git-lfs Clone, commit, LFS assets
bash, curl, wget, openssh-client Shell automation and downloads
jq, yq, ripgrep JSON/YAML/text search
rsync, zip, unzip, xz-utils File sync and archives
file, sqlite3 File typing and local DB inspection
shellcheck Bash script linting
dnsutils, netcat-openbsd, iproute2 Network diagnostics

JavaScript / TypeScript

Tool Role
node, npm, corepack Node runtime and package management
pnpm, yarn Alternate JS package managers
bun Bun-first JS/TS repos

Python

Tool Role
python3, pip, python-is-python3 Python runtime
uv Fast env/deps (modern alternative to raw pip)
pipx Isolated Python CLI tools

Go, Java, Rust

Tool Role
go Go toolchain (single stable release)
java, javac, JAVA_HOME OpenJDK 21 LTS
mvn, gradle JVM build systems
rustc, cargo Rust stable minimal profile

Build, codegen, and task runners

Tool Role
make, g++, pkg-config, libssl-dev Native C/C++ builds and cgo
cmake Cross-language native builds
protoc (protobuf-compiler) Protobuf / gRPC codegen
just, task Modern task runners

Platform and cloud CLIs

Tool Role
gh GitHub PR/CI/releases API
kubectl Kubernetes cluster operations
aws AWS CLI v2

The workspace snapshot is taken on the host, with no support needed inside the image, so the image ships no packages specific to the sandbox.

You can use a custom image through JAIPH_DOCKER_IMAGE. The image is host-controlled: a file-declared runtime.docker_image is rejected (E_DOCKER_IMAGE_HOST_ONLY) when Docker is the active sandbox, so a repo- or model-supplied entry file cannot point the sandbox at an arbitrary image — only the operator selects it via JAIPH_DOCKER_IMAGE. The selected image must already contain jaiph, or the run fails with E_DOCKER_NO_JAIPH. Jaiph runs that presence check under the same sandbox hardening as a real run, with every capability dropped, no new privileges, a non-root user, and no network, and it uses a non-login shell. Because the shell is non-login, the check never sources profile scripts baked into the image (/etc/profile and /etc/profile.d/*), so a custom image cannot run startup or profile code at a higher privilege than the run itself just to answer the check. Project-specific extras, such as several language versions, database servers, or cloud CLIs beyond the defaults, belong in a workspace override image, not in the published default.