dsh) is a plugin-based agent harness on Cordis where the filesystem and the shell are swappable capability seams. @struktoai/mirage-dsh provides both seams over one Mirage workspace: ctx.fs for dsh’s read/write/edit tools and ctx.shell for its bash tool. Anything Mirage mounts (S3, Gmail, Slack, Notion, Postgres) becomes the world those tools operate in.
Install
Compose
Three Cordis plugins.MirageService owns the workspace; the two providers inject it. Mix any resources, such as Slack and Redis beside a scratch RAM mount:
READ < WRITE < EXEC, so mount with MountMode.WRITE where dsh’s write tools should work and MountMode.EXEC where scripts may run. dsh’s bash tool now spans every source in one line (resolve fills a request into a spec with the defaults and run executes it, the same two calls dsh’s own tools make):
MirageService also accepts mounts instead of a live workspace; it then constructs the workspace itself and closes it when the plugin unloads.
Each command is a clean slate: cd, export, and function definitions inside one bash call do not survive into the next, which is the one-shot contract of dsh’s bash tool. For a persistent shell instead, bind the executor to a named workspace session:
DSH_* variables with every call, and in Mirage a per-call directory or environment forks a subshell. Taken at face value, a bound session would therefore have nothing left to persist, so both are checked first. The workdir dsh computed on its own machine is not a directory in this world, so it is ignored: the executor’s own workdir when unbound, the session’s cwd when bound, and ctx.fs resolves relative paths against the same base. The DSH_* variables are set in the session once instead of riding along per call. A workdir that does exist here, or any other per-call variable, still forks a subshell, which is what “only this command” should mean.
Run Python with monty
Theruntimes entry above sets monty, a sandboxed Python interpreter with no host access, up to capture python and python3; the catch-all vfs runtime serving the shell commands is always present and needs no entry. A captured invocation (a script file, inline -c code, or code piped on stdin) runs inside the workspace, never on the machine. A script can live on any mount: upload example.py to a Slack channel and run it straight off the mount, with the shell’s redirection filing its stdout back into Redis:
examples/typescript/dsh/dsh.ts is the runnable version of this whole page, with the script in example.py.
Install into a dsh profile
The package is also a dsh bundle: its manifest carries a patch layer that swaps dsh’s filesystem and bash providers for the mirage ones and disables the host-subprocess surfaces the workspace does not contain (PowerShell, the ripgrep search tool). Stock dsh, including its web app, then runs on mounted data:/tmp. Mount real resources by overriding the mirage row in the profile’s own cordis.patch.yml, in declarative form (the resource registry name, a mode, and the resource’s config; !!js expressions resolve at mount time):
MirageService’s mounts.
Both seams tell dsh they are a workspace-write sandbox, which is what lets dsh’s permission presets compose over them. The claim holds only while every runtime stays inside the VFS (each runtime’s reach is vfs). Add a host-reaching one, such as local Python, and both seams drop the claim, since a script could then act outside the mounts: dsh is better told there is no sandbox than a false one.
The claim is enforced per call, not merely advertised. dsh resolves a policy for every command and every file edit, and Mirage applies it. Under read-only the shell runs in a twin of its own session with every grant narrowed to read, so a session already confined to some mounts stays confined (the twin narrows, it never widens), and ctx.fs refuses a write or an edit with FS_SANDBOX_DENIED, the same error dsh’s own sandboxed backend raises:
DSH_PERMISSION_MODE=read-only confines a Mirage world, and the result reports the mode the command actually ran under. The /dev null sink stays writable, as that mode requires. The policy’s workspaceRoot is a directory on the host and is deliberately never consulted: here the mounts and their modes are the boundary.
Permissions: allow, ask, deny
A sandbox mode says where writes may land. It says nothing about which commands a session may run at all, and that is what a Mirage permission document is for. A role lists the tools it admits, the lines it refuses, and the lines a human has to sign off on:MirageService’s profiles and profile. Each role is validated as it loads, so a misspelled field is a startup error rather than a surprise on the first command. A word no allow entry covers is not a command here at all — the agent reads bash’s own command not found, so an unlisted tool never leaks that it exists.
profiles configures a workspace MirageService builds, so it pairs with mounts and is refused beside a live workspace — an adopted workspace’s roles are the embedder’s own, and so is how its asks get answered. Passing both would mean quietly taking over a seam that may already be filled. To adopt a workspace and route its asks through dsh, build the handler yourself and hand it to the workspace:
ctx.approval at ask time, not at construction, so an approval plugin composed later is still found.
A deny refuses the line at 126 in bash’s voice, and the reason rides on the result as its refusal record, which is also what dsh reads to tell a policy refusal from a command that failed on its own. A command entry is a token-prefix pattern, so rm would cover every rm line while rm -rf covers only the recursive ones:
ctx.approval — the same prompt dsh already shows for its escalations, so nothing new appears in the UI. The bridge maps dsh’s answer onto Mirage’s ledger:
The prompt is the rule’s reason and the line it is about, quoted word by word as a shell would read it back: the human is authorizing this run, so a name carrying a space or a newline cannot pass itself off as two operands or as two lines. Ordinary names stay bare.
Only
allowed-once is a yes, and it grants exactly the line that asked; a single nod is never widened into a standing grant. The three refusals match what dsh does with the same vocabulary for its own escalations, where a rejection, a dismissal and an unanswerable ask all fail closed. A refused retry of the same line is answered from the record rather than prompting again, so a human who said no is not asked twice about it.
A question never outlives the run that raised it. The run’s abort signal goes to the channel with the request, so a prompt can take itself down, and the ledger stops waiting on the same signal — a line whose approval is still unanswered when its timeout expires (or when the caller kills it) comes back as the kill it was given, not as a refusal:
ask, and silently rewriting that as deny would change what the operator wrote. The question is real and answerable, through ctx.mirage.decisions:
ONCE passes the retry of that one line; SESSION passes every line the same rule covers for the rest of the session. Nothing reaches further — an answer is never inherited by another session, and never re-opens a deny rule, which is consulted first.
A refused line is reported as refused, whatever refused it: result.sandbox.denied is set for a policy deny and for an unanswered ask, not only for the read-only narrowing, and under workspace-write as readily as under read-only. A mode describes what the mounts allow and says nothing about whether a rule forbids the line. It holds for a line that redirects its diagnostics too (rm secrets 2>&1 leaves stderr empty and the refusal on stdout), where the exit code has to agree before words on stdout are read as a ruling, so a command printing the phrase as data is not mistaken for one.
One gap is worth naming: a rule scoped to a path refuses one operand in GNU’s own voice (cat: /data/notes/a.txt: no notes, exit 1), which carries no fixed marker, so that shape is not flagged — Mirage has no out-of-band denial channel on ExecuteResult, and inventing a marker would report ordinary command failures as policy refusals.
Through ctx.fs, a rule refusal arrives as FS_SANDBOX_DENIED rather than FS_PERMISSION_DENIED, which is what makes dsh’s tool layer attach its escalation hint. A plain mount-mode refusal keeps FS_PERMISSION_DENIED: a mode is the shape of this world, while a rule is a confinement the call may be entitled to escalate past.
Asking what a line would do
explain runs a line through the same gate the dispatcher uses, without running it:
Custom backends
A mount is not limited to the builtin resources (ram, s3, slack, redis, …). Register your own resource factory host-side and its name becomes usable in a mounts block exactly like a builtin:
mounts block only names a resource, it does not construct one. In a dsh bundle that means a small plugin the profile loads alongside @struktoai/mirage-dsh (or a plain import in code, before MirageService starts), not the declarative patch. A builtin name cannot be shadowed, so a custom backend needs its own name. Nothing else changes: the shell, the commands, and the sandbox claim treat a custom mount like any other.
Background commands
A backgrounded command streams its output through a workspace console rather than arriving whole: each statement of a compound line lands as it finishes, and stdout and stderr keep their own channels. The unread backlog is bounded to the stdout budget, so a reader that never drains cannot grow it without limit; output past the budget is flagged lossy, with the freshest kept. SetspillDir on MirageShellExecutor to a workspace path (for example /tmp on a ram mount) and an overrunning command’s full stdout and stderr are written there, so the agent can read the complete output back through the same VFS; unset, nothing spills.
A concrete scenario: serving agents from a TypeScript server (an Express endpoint, say), where every request is assigned its own context and workspace, created on entry and gone with the response. What one 2 vCPU / 8 GB box holds then depends on the harness:
Concurrency is estimated as about 6 GB of free memory divided by a session’s measured resident footprint (a dsh session with a live Python engine is about 27 MB, idle workspaces 240 KB, a Codex session about 200 MB, a Claude Code CLI session about 430 MB); startup is the measured time to bring one session up, process boot for the CLIs and workspace construction for dsh. The dsh numbers already include isolation, since monty is the sandbox and each engine runs on its own crash-isolated worker process; the CLI sessions run on the host, so isolating them means provisioning a container or remote sandbox per session, measured at about 150 ms even for a warm local container and more for a cold or remote one.