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

A session runs one line at a time, the way one bash process does. Two execute() calls on the same session share its env, cwd and $?, so the second waits for the first to finish rather than interleaving with it; a for f loop never reads a value another loop on that session just set. A call with cwd / env overrides still runs on a clone, so nothing it does leaks back, but it takes its turn on the session like any other line. For independent streams of commands that should run at the same time, give each its own session.

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. The program runs on each line of a .jsonl file unchanged, as jq runs it: jq -c '.[]' prints each record’s values, not each record, and -s is the way to read the lines as one array (jq -s 'map(.id)'). Every operand joins one stream that one parser reads, as in jq, so a value may run on from one file into the next: files holding 1 and 2 read as 12, and under -R a file with no final newline joins its last line to the next file’s first. 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.
Numbers and keys print the way jq prints them. libjq is handed each document as the text it was read from, so jq’s own parser reads it, and each output comes back as jq’s own dump of it. A number keeps its literal, and an object keeps its key order, on both hosts. A number the program computes takes jq’s spelling.

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). jq’s option loop reads each operand the moment it reaches it, and mirage reads the line the same way: the last of --args and --jsonargs typed before an operand decides how it is read, and an operand typed before either one is still an input file.
jq reads a word as options only when a letter or a second dash follows its dash, and so does mirage. Any other dash word (-1, -.5, - x, a lone -) is an operand, so a program, an input file or a positional value may start with a dash, while -nan is still -n -a -n.

Errors

An error that no try catches ends that document’s run, the way jq’s own main loop handles it: what the run printed before the error stays printed, the report goes to stderr, and the next document runs.
The report names the input as the command line spelled it (<stdin> for a pipe) and the lines jq’s reader had read by then. It reads a line at a time, and a long line in 4,091-byte pieces. Under -n, a program that reads no input reports <unknown>, and a message that is not a string is dumped after (not a string). The exit status is the last document’s: 5 when its run failed, otherwise 0, or 1 and 4 under -e as above. halt ends the whole command with status 0. halt_error writes its input to stderr (a string as it is, anything else as JSON on a line of its own) and exits with its code, 5 by default. A program that does not compile is refused with status 3 before any input is opened. Both hosts evaluate with libjq 1.8.2, so the messages are jq 1.8.2’s own. Input that is not JSON ends the stream the way jq’s main loop ends it: every document before the bad one runs, and then jq: parse error: <message> goes to stderr with status 5.
mirage reads its input with a port of jq 1.8.2’s own parser, so it takes what jq takes (nan, Infinity, a leading +, .5) and refuses where jq refuses, with jq’s message, line and column. The line and column count bytes, across every operand. Under -s nothing prints, --stream hands over the events before the error, and --seq reports jq: ignoring parse error: <message> and reads on. input and inputs raise the parse error as a runtime error, which try can catch, and the next run starts past it, with or without --seq. A --slurpfile holding bad JSON is refused with jq: Bad JSON in --slurpfile <name> <file>: <message> and status 2, and --argjson and --jsonargs accept what jq’s parser accepts. A --jsonargs operand is parsed where it was typed, so jq -n . --jsonargs '{' --indent 9 reports the bad JSON, not the bad width. Options are read the way jq’s option loop reads them: one word at a time, stopping at the first one it cannot take, in jq’s words and with status 2. A long option is a whole word, with no abbreviation and no =value, so --nul and --indent=3 are refused as jq: Unknown option --nul and jq: Unknown option --indent=3. An option the line ends too early for says what it takes (jq: --arg takes two parameters (e.g. --arg varname value)), and each refusal ends with jq’s two-line Use jq --help hint. Whatever comes first on the line is what answers: a bad --jsonargs value typed before an unknown option is the one reported, -h/--help and -V/--version answer where they are typed, and the -f file is read after every option, so jq -n -f missing.jq --bogus reports --bogus. jq opens a flag’s file by name: /dev/stdin reads stdin, and - is a file named -.
A file that cannot be opened is reported when the reader reaches it, and the reader goes on to the next one. jq’s main loop checks for such a failure before each document it reads, so the document the reader went on to still runs, nothing after it does, and the status is 2 whatever the runs answered.
A directory opens and then fails at its read, so jq reports it as jq: error: Is a directory, without its name. -n opens nothing its program does not read. A --rawfile or --slurpfile that cannot be read is refused with jq: Bad JSON in --rawfile <name> <file>: Could not open <file>: <reason> (It's a directory for a directory) and status 2. A few limits worth knowing. input and inputs read the documents still unread: input takes the next one (and fails with break when none is left, as jq does), inputs yields every one after it. mirage cannot see how many a run took, so it assumes the idioms: a program that calls inputs drains the stream in one run ([., inputs], reduce inputs as $x, input as $header | inputs), and one that calls only input takes one document per run ([., input] pairs them up). A partial drain (first(inputs)), a second input in one run, or an input in a branch not taken leaves jq a different remainder than mirage. Error positions follow the same model, so a run that takes some other count than the idioms above reports a different position than jq, and a try input that catches a parse error cannot read on past it. A halt ends the command wherever it is called, a try of the program’s own included. Its message and code are read by running the program again with halt and halt_error redefined, which reads the clock again and which the program’s own error handling can defeat: a halt whose message or choice rests on now reports what the rerun read, a halt inside a try inside a collector ([try halt_error(2) catch .]) reports status 5 with no message, and a halt that a try ... catch empty swallows can report the message and code of a later halt instead. Under --stream, input nested more than 10,000 levels deep is refused with Exceeds depth limit for parsing, the limit jq’s ordinary parser has. jq’s streaming parser has no limit, but every event carries a copy of its path, so input nested n deep costs time in n squared. Not implemented, and reported as an unknown option (jq: Unknown option -C) 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 (which hands a parse error over as an array value instead of reporting it), and --build-configuration. -V prints mirage’s version line, as --version does. 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. Under -o or parentheses an action ends its -a chain and the whole expression holds one action, so find . -path ./skip -prune -o -type f -print and -name a -print -o -name b -print run as GNU runs them, while -print on one arm and -print0 or a different -exec on the other, an action that is not last in its chain, or an action under ! is refused. -exec ... \; is true only when its command exits 0, which is learned after the walk, so under -o it may stand only where nothing follows it (-type d -exec false \; -o -prune is refused), while -exec ... {} + is true regardless, as in GNU, and sits anywhere an action may. -prune skips a directory’s contents, a mount nested under that directory included (and a file it reaches skips nothing); it does nothing under -depth, and GNU’s refusal of -prune with -delete is kept unless -depth is spelled out. -path matches the row as it prints, under the operand as typed. 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, while a -prune on the other arm still fires exactly where GNU’s does. A time test keeps its place before or after -prune: find d -newermt X -prune skips the contents of the directories newer than X alone, and -prune -newermt X skips every directory’s contents and prints the newer ones, as GNU orders them. A repeated -print prints each row once per occurrence, as in GNU. grep, zgrep, sed and expr follow the locale the command’s environment names, as GNU does: the first of LC_ALL, LC_CTYPE and LANG that is set and not empty. In the C locale, the default, they match, count and index bytes. When that variable names a UTF-8 codeset (C.UTF-8, en_US.utf8), they work on whole characters: . and a bracket expression match one character and never a byte that is not part of one, expr length and substr count characters, and grep leaves out a line or match holding such a byte and reports binary file matches, unless -a is given. Every UTF-8 name counts as installed, where glibc falls back to the C locale for a missing one. Character classes, \w, word boundaries, -i and sed’s \U and \L keep their ASCII meaning under a UTF-8 locale, and diagnostics keep the C locale’s quoting. awk and tr work on bytes under any locale, as mawk and GNU tr do.

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). A read end keeps its offset and a dup shares it, as bash’s do: after exec 1<file, read a <&1; read b <&1 reads two lines and exec 0<&1 reads on from there, while a new open starts at the file’s beginning. Mirage keeps no offset on a write-open descriptor, so every write through 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) ${PIPESTATUS[@]} (the per-segment statuses of the last pipeline) and ${FUNCNAME[@]} (the running functions, innermost first, a sourced file as source; empty outside every function, and an assignment to it is ignored).
  • Control flow: if/elif/else/fi, for, while, until, case, select, function name() {}, break, continue, return.
  • Grouping: subshells (cmd), compound { cmd; }, negation ! cmd.
  • Standard input: the commands of a group, loop, list, function, subshell, eval, source or nested shell read one standard input in turn, as bash’s share one descriptor: read takes its line (or its -n/-N/-d amount) and the next command reads on from there, so printf 'a\nb\n' | { read x; cat; } prints b. exec < file rebinds that input for every statement after it, inside the group or subshell that runs it too (printf 'z\n' | { exec < f; read a; } reads f), and the next line reads on from where the last one stopped; opening the file again starts at its beginning. A command substitution and a background job read no standard input, where bash’s read the shell’s.
  • 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, every GNU xargs option (batching by words, lines (-L/-l) or bytes (-s, 128 KiB by default, with GNU’s -x and too-long errors), -I/-i runs once per input line with the line in place of the string, input quotes and backslashes processed as GNU does, -E/-e end-of-file words, -0/-d with GNU’s escapes, -a FILE, -t traces, --show-limits and -s bounded by the environment’s size against a 2 MiB ARG_MAX, -P N runs up to N commands at once, each in its own copy of the session, with output kept in input order and --process-slot-var numbering them; there is no terminal, so -p and -o fail as GNU does without one; long options may be abbreviated as GNU’s getopt_long allows; GNU exit codes: 123 when an invocation fails, 124 after one exits 255, 127 for a command nothing provides (xargs: cd: No such file or directory); a function runs too), every GNU timeout option (timeout DURATION takes a C float with an s/m/h/d suffix; the command reads timeout’s stdin; at the deadline -s’s signal, TERM by default, does what its default action does to a process: most end the command (exit 124, or 128+N with -p), CONT/CHLD/URG/WINCH/0 let it finish, a stop halts it, and -k’s KILL ends what is left with 137; -f and -v as in GNU; usage errors exit 125 with GNU’s Try line). 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: every program a session can run (a mount command, an installed CLI, a builtin a real system also ships as a file, such as echo or xargs) has an executable file under the read-only /usr/bin, and $PATH is /usr/bin. which name prints that file (/usr/bin/ls) and reports a miss (cd, a function, an unknown word) through exit 1 alone; command -v prints the file, or the bare name for a builtin or function; type name says ls is /usr/bin/ls or cd is a shell builtin (type -t prints one of alias, keyword, function, builtin, file; type -a lists every layer holding the name; type -p/-P print the file). Reading a file (cat /usr/bin/ls) shows a short script whose comment says what runs the command (ls is built into mirage. Help: ls --help, or the runtime a captured program runs on), so running /usr/bin/ls by path works too; a write into /usr/bin is refused as read-only. man name renders a page: a command’s spec, or an installed CLI’s own --help tree (man linear issue create).
  • Program runs: env, xargs and timeout run their command the way find -exec does, as the program a real system would exec, so a builtin that doubles as a program answers as the program. printf there is coreutils’: -v is a format string, -- ends its options, and a format that takes no argument warns printf: warning: ignoring excess arguments, starting with 'a' (the builtin drops them silently, as bash’s does).
  • Globs: *, ?, [...] classes and [!...] negation (Python fnmatch semantics in both implementations), resolved by the shell or pushed down to the VFS.
  • 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).
  • timeout without processes: a command is a task, not a process, so a signal is modeled by its default action rather than delivered; a command cannot trap one. timeout’s own process group is modeled too: without -f, KILL, 32 and 33 kill timeout itself (exit 128+N even with -p), STOP stops it for good and CHLD leaves it waiting until -k’s KILL (or forever), as GNU does. What a nested sh -c printed before the deadline is lost, because a nested line hands back its output only when it finishes.
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. exec adds one thing: running code in a language runtime. The interpreter commands (python3, python, js, node) hand their program to a runtime that runs it outside the shell. A script file named as their operand (python3 /data/job.py) must sit on an exec mount; inline code (-c, -e, stdin) and -m need only an exec mount somewhere in the workspace, and the modules a program imports are not checked by path. Without one they exit 126 with not in EXEC mode. Shell scripts need no exec: bash script.sh, source script.sh, and ./script.sh when it has no shebang or one naming sh or bash, run in the shell itself, which checks each of their commands like a typed line, so a script can do nothing its lines could not. A ./script whose shebang names another interpreter is handed to that command, so one starting #!/usr/bin/env python3 needs exec just as python3 does. To keep a path’s scripts from running at all, deny the path in the session’s profile: that refuses every command on it, cat script.sh | bash included. 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.vfs.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.

Timing pipelines

time pipeline measures the entire pipeline, including output consumption, and writes the report to stderr. It preserves the pipeline’s exit status and session changes. time -p uses the POSIX real, user, and sys layout; otherwise TIMEFORMAT controls the report (%R, %U, %S, %P, precision, and the l minute format). An empty TIMEFORMAT suppresses it. Elapsed time uses a monotonic clock. User and system CPU times are zero in both hosts: browser runtimes have no per-job CPU counters, and process-wide counters would include unrelated sessions.

HTTP command compatibility

curl -4 / --ipv4 and -6 / --ipv6 are accepted as compatibility flags; the host HTTP transport chooses the address family. -w / --write-out renders http_code, response_code, url_effective, num_redirects, size_download, content_type, method, exitcode, and time_total after the transfer, including failed transfers. Templates accept @file, @-, escaped newlines, %%, and %{stdout}, %{stderr}, and %{onerror}. Unsupported variables produce a diagnostic. Output selected by -o contains only the response body. wget -T / --timeout sets the request timeout in seconds; zero disables it. Timeouts return network-error status 4. The shared HTTP transport uses one request deadline, rather than GNU Wget’s separate DNS, connect, and read timers.