Skip to main content
Mirage Bash is how agents act on the workspace. execute() parses a bash-style command, looks up the target session, resolves mounts, runs the executor, applies I/O side effects, and records history through the Observer.

Per-call overrides: cwd, env

Providing cwd or env runs the command in an ephemeral session clone, like a bash subshell (cd /data && cmd). Mutations like cd or export inside the call do NOT persist back to the workspace’s session. To change persistent state, run the command without these options.
This makes per-call overrides safe under concurrent calls on the same session. Two parallel execute() calls with different cwd see their own cwd without cross-contamination, even on the same session.

Subshells (...)

Wrapping commands in ( ... ) runs them in an isolated copy of the session: cd, export, and other mutations inside the parens do not leak back. It is the same isolation as the cwd / env overrides above, and the CLI’s stand-in for them (there are no --cwd / --env flags).
The isolation holds under concurrency, and subshells are still covered by the per-session mount allowlist and the cancellation boundaries below.

Mid-flight cancellation: cancel / signal

Both bindings support cooperative cancellation. TypeScript observes the signal at recursion boundaries (LIST, PIPELINE, FOR/WHILE/UNTIL iterations, COMMAND, subshells, command substitution), inside sleep, in the readers, and at the op door. Python runs the whole line as one task, which the event cancels at whatever await it is in, and joins before raising. On cancel, the call raises an abort error. A handler you write has to cooperate: let CancelledError propagate, and observe the signal in any loop of your own.

Three Scopes for State

JSON with jq

jq reads a stream of JSON values and runs the program once per value, matching the real jq binary. The filename is irrelevant: a .json file holding several concatenated or pretty-printed values is a stream just like a .jsonl file, and so is multi-document input arriving on stdin.
Because evaluation is per document, commands that emit newline-delimited JSON (such as paginated gws list calls) pipe straight into jq with no reshaping. Output arity follows the program, not the input. A jq program emits a stream of values and each one prints on its own line, so .a[], .a, .b and range(3) all print several lines, while a program that collects into an array ([.a[] | .t]) emits one value and prints one line.

Flags

Build JSON with a binding rather than by hand: the value arrives as a value, so quotes and newlines in it need no escaping.
$ARGS is always defined, carrying named (the --arg family) and positional (--args / --jsonargs). Three limits worth knowing. inputs is bound to whatever is still unread, so a program that drains it ([., inputs], reduce inputs as $x) runs once and sees everything, but the stateful single input and a partial drain (first(inputs)) are not modeled. --stream reads whole documents and expands them, which matches jq except that jq’s incremental parser splits the closing event of an input with no trailing newline into its own -s group. And --seq reads and writes the separator, but drops text before the first one silently where jq names it on stderr. Not implemented, and reported as an unknown option rather than quietly ignored: -C (colorized output, which an agent would only have to strip again), -L (no module system, so include has nothing to search), --stream-errors (it reports truncated-parse errors, which whole-value reads never produce), and --build-configuration. find -exec runs each invocation in an isolated child shell, and it looks the head word up the way findutils’ execvp does: only an installed program runs there (echo, printf, true, a registered CLI), so a shell function or a shell-only builtin such as cd or export is refused with find: 'name': No such file or directory, and a function or alias that shadows a program name is bypassed, the program running as it would under command, and a builtin that doubles as a program answers as the program (printf -v is a format string there, as under coreutils printf). Changes to variables, working directory, arguments, and shell options stay in the child. -ls renders the stat find already holds, as GNU’s does: a start point, or a row a -size, -mtime, -newer or -empty test statted, still lists after -delete, while a row only -name or -type selected is reported gone. Predicates must precede actions: find d -name '*.txt' -exec echo {} \; works, while a test following -exec, -print, -delete, or another action is refused because backend filtering cannot preserve that evaluation order. All actions (-exec, -print, -print0, -printf, -ls, and -delete) refuse placement under -o, negation, or parentheses. Tests may be grouped before a trailing action. Action operands retain whole filenames, including embedded newlines, across mount boundaries. -printf must be the only action; combining it with other actions or repeating it is refused. -delete runs where it is written, so a later -exec sees the row gone, and it turns on -depth, which lists a directory after its contents. -newer FILE reads a symlink reference itself under the default -P and its target under -H or -L, as GNU find does. -newermt accepts GNU date expressions such as yesterday, 24 hours ago, and @1700000000, with unzoned dates interpreted as UTC. Invalid calendar dates are refused. -newer and -newermt must be in a top-level AND chain; placing them under OR, negation, or parentheses is refused. Repeated time tests in that chain intersect, so -newer old -newer new keeps only what is newer than both; -mtime tests under -o widen to the union of their windows, which can match more than GNU. A repeated -print prints each row once per occurrence, as in GNU.

Supported bash syntax

Mirage Bash is a tree-sitter-bash parser plus a custom executor. It implements the constructs LLMs reach for most often. What is not supported returns a clear, parseable error so an agent can self-correct on its next turn.

Supported

  • Operators: pipes |, |&; lists &&, ||, ;; background &.
  • Redirects: >, >>, >|, <, 2>, 2>&1, >&2, &>, &>>, >&- (a closed stdout fails the write, as GNU echo reports), heredoc <<, herestring <<<. The shell models descriptors 0, 1 and 2 only: a redirect naming any other (3>f, >&3, exec 3>&-) is refused with 3: Bad file descriptor and exit 1, the line continuing as it would after any redirect error. Duplications involving fd 0 follow the same left-to-right rules. Writing to a read-only descriptor fails with write error: Bad file descriptor, and reading a closed or write-only one fails with Bad file descriptor once the command reads (cat 0<&1 exits 1; true 0<&1 is untouched). A standard descriptor may be opened in the other direction, as bash allows: exec 0>file makes fd 0 the file’s write end (a read is refused, and >&0 writes there), and exec 1<file makes fd 1 its read end (exec 0<&1 and <&1 read the file, a write to it fails). Mirage keeps no offset on a descriptor, so every read through a read-open stream starts at the file’s beginning and every write through a write-open one appends.
  • Substitutions: command substitution `cmd` and $(cmd); arithmetic $((expr)) (an invalid expression such as $((1/0)) aborts the line with exit 1 and bash: 1/0: division by 0, as a non-interactive bash does); parameter expansion ${VAR}, ${VAR:-default}, ${VAR%suffix}, etc.; input-direction process substitution <(cmd).
  • Shell variables: $?, $#, $@, $*, $0..$9, $$, $!, $RANDOM (bash 5.2’s generator, so RANDOM=42 draws bash’s own sequence; bare RANDOM in arithmetic draws lazily too; assigning seeds it immediately; invalid arithmetic seeds emit a diagnostic without changing the sequence or failing the assignment; unset RANDOM strips it, as in bash) and ${PIPESTATUS[@]} (the per-segment statuses of the last pipeline).
  • Control flow: if/elif/else/fi, for, while, until, case, select, function name() {}, break, continue, return.
  • Grouping: subshells (cmd), compound { cmd; }, negation ! cmd.
  • Builtins: cd, pwd, echo, printf, printenv, read, source, ., eval, export, unset, local, declare (including -A), let, set, shopt, alias, umask, mapfile, shift, exec (redirect-only form), disown, trap (no-op), test, [, [[, true, false, sleep, xargs, timeout, bash, sh, python, python3, man, command, type, which.
  • Builtin options (GNU semantics): echo -n/-e/-E (leading-word option rule: echo hi -n prints hi -n), read -r, xargs -n/-0/-d/-r/-- (batching, GNU exit codes: 123 when an invocation fails, 126/127 stop the run), timeout DURATION with s/m/h/d suffixes (kills at the deadline with exit 124, usage errors exit 125). shift and return report bash’s numeric argument required errors, and shift past $# is bash’s silent exit 1. test/[/[[ support the file-pair operators -nt, -ot and -ef (-ef means the same resolved path: a mount has no device or inode).
  • Name lookup: type name reports what a name resolves to (type -t prints one of keyword, function, cli, builtin; type -a lists every layer holding the name), which name prints the name of anything runnable (there is no PATH, so there is no path to print) and reports a miss through exit 1 alone, and man name renders a page: a command’s spec, or an installed CLI’s own --help tree (man linear issue create).
  • Globs: *, ?, [...] classes and [!...] negation (Python fnmatch semantics in both implementations), resolved by the shell or pushed down to the resource.
  • Comments: #.

Unsupported (returns clear error)

  • Job control: bg. (fg, jobs, wait, kill, disown, ps work; use the --background flag and mirage job CLI for long-running work.)
  • Shell internals: exec CMD (process replacement; the redirect-only exec > file works), complete, compgen, ulimit, file descriptors above 2.
  • Output process substitution: >(cmd) (the <(cmd) direction works).
  • Builtin options with no process backing: xargs -I/-P (exit 1) and timeout -s/-k/--preserve-status (exit 125) return an unsupported option error: commands run as coroutines inside the workspace, so there is no process to signal or parallelize.
Each returns exit_code 2 with stderr mirage: unsupported builtin: <name> or mirage: unsupported: process substitution >(...), except the builtin options above, which use the listed GNU-shaped exit codes.

Syntax errors

Commands the parser cannot make sense of return exit_code 2 with stderr mirage: syntax error near '<token>'. Earlier versions silently ran whatever fragment did parse; that no longer happens.

What --background is and isn’t

The daemon’s --background flag detaches a job and returns a job id. It is not the same as the bash & operator, which the shell does support inline (sleep 30 &). Use & for in-shell job parallelism, --background (or mirage job) for long-lived work that should outlive the request.

Per-session mount modes

A session can be created with its own per-mount modes, like a container that mounts the same volume ro while another mounts it rw. Each named prefix carries a mode on the read < write < exec ladder, written as the words read/write/exec or the cumulative filesystem aliases r/rw/rwx (exec implies write implies read, so bit-style forms like a bare w are rejected). A command exceeding the session’s mode fails exactly like it would on a read-only mount. Naming a mount narrows it; it is not an allowlist. A mount a session does not name keeps its own configured mode, and a session’s mode can only narrow, never widen: the effective permission is the weaker of the mount’s own mode and the session’s, so rw on a READ mount is still read-only. Keeping a session away from a mount entirely is a hide, written in the role the session runs under. A hidden path answers No such file or directory rather than a permission error, so a refusal never names something the agent was not meant to know is there. Creating under a hidden path is the one case that answers out loud (Permission denied), because silently succeeding would leave a file the session cannot see. This is a soft boundary, enforced inside the daemon process, not an OS or process-level isolation. Use it to shrink the blast radius of prompt-injection in multi-agent workspaces: a Slack-only agent cannot pivot to read /linear, /github, or any other mount its role hides. A FUSE mountpoint is served under the default unrestricted view unless it was added for a session (add_fuse_mount(prefix, session_id=...) in Python, addFuseMount(prefix, mountpoint, sessionId) in TypeScript), in which case every op through it runs under that session’s grants. Both halves fire for every code path that reaches a mount: shell commands (cat, ls, …), redirects (>, <), cross-mount cp/mv, wget -O, curl -o, command substitution $(...), subshells (...), pipes, &&/|| chains, background jobs, and the programmatic ws.fs.read/write/... API. The history view (/.bash_history, which the history builtin and the GNU histfile render from) and the implicit scratch root (/, where stateless text-processing commands like wc resolve when given no path) stay reachable unless a role hides them.
The modes are a property of the session, so they cover every command issued under that session_id, including subshells, pipelines, and recursive bash -c '...'. They do not change the mount’s own MountMode: a write to a session-writable mount is still rejected if the mount itself is READ. The two checks compose.

Agent Pattern

Agent harnesses commonly fan out tool calls in parallel, each with its own cwd/env/cancel. The clone semantics make this race-free without per-call boilerplate. From the CLI, a subshell per call gives the same isolation.