muschel — German for shell (the bivalve). A Clojure(Script) library that parses bash/POSIX-shell source into a structured AST, checks it against a permit (allow/deny) policy, and executes it through a pluggable host — JVM, babashka, Node.js, or in the browser against a virtual file system and a virtual tool registry.
Built for LLM agent shells: agents emit bash naturally, and muschel lets that flow without giving away the keys to the kingdom.
Most LLM tool integrations either:
- Trust the agent's bash output and pipe it to
sh -c— easy, dangerous. - Whitelist commands by argv prefix — bypassable through env vars
(
PYTHONWARNINGS,PAGER), command substitution$(...), etc. - Replace shell entirely with curated structured tools — safe, but strips the agent of the bash idioms it's been trained on.
muschel takes a different shape: parse the real shell, walk the AST, gate every effect through a permit, and execute through a host you control — including a virtual host that runs in the browser with no spawn capability at all.
The security story — host layering, the FS protocol, permits, budgets,
tracing — lives in doc/sandbox.md.
src/muschel/
lex.cljc hand-written tokenizer (quotes, $, ((, [[, heredocs)
errors.cljc position-bearing errors
ast.cljc AST node ctors, predicates, walk/zip
parse.cljc recursive-descent parser over tokens
env.cljc immutable shell-environment value
expand.cljc POSIX expansion (brace → tilde → param → cmd-subst → arith → split → glob → quote removal)
arith.cljc $(( … )) evaluator
permit.cljc parse-time + runtime-hook allow/deny against rules
budget.cljc cooperative interrupt-fn (step, deadline, combine)
trace.cljc bounded ring-buffer + streaming hooks (tools, fs, denials)
runtime.cljc late-bound parse/run/new-env registry — breaks exec↔posix cycle for `sh -c`
exec.cljc executor over the AST (builtins, control flow, pipes, redirects, bg jobs)
session.cljc forkable session protocol (AtomSession + SpindelSession)
fs.cljc containment-aware FS protocol (resolve, read, list, stat, open-sink, mkdir, …)
fs/virtual.cljc in-memory VFS — no real-disk access possible
fs/disk.clj real-disk DiskFS pinned to a root (JVM only)
fs/traced.cljc wraps any FS, records every protocol op for introspection
host.cljc Host protocol — abstracts buffers / pipes / spawn / fs
host/builtin.cljc BuiltinHost — routes commands through a builtin registry first, fallback second
host/jvm.clj JVM fallback — `babashka.process` + `java.io`
host/node.cljs Node fallback — `child_process` + node fs
host/browser.cljs Browser fallback — virtual buffers, virtual tool registry, no spawn
builtins/posix.cljc ~50 POSIX builtins (cat, ls, grep, find, sed, awk dispatch, cp, mv, tee, sh -c, …)
builtins/awk.cljc real awk subset (lexer + parser + interp)
builtins/awk_compat.cljc cross-platform shims (regex, StringBuilder, format, codepoints)
js_api.cljs ^:export bindings for the npm bundle
playground.cljs browser playground entry point
cli.clj JVM CLI entry — bash-shaped invocation (-c, script.sh, -s, -n, -x, -o, --) + sandbox flags
muschel runs in four runtimes against a single .cljc source. The
bb test task and script/test-all exercise the full test suite on
JVM, ClojureScript-on-Node, and babashka in one go.
| Runtime | Status | How |
|---|---|---|
| JVM (Clojure) | ✓ | host/jvm — babashka.process, java.io; full DiskFS |
| Babashka (sci) | ✓ | Same .cljc; :bb reader-conds skip DiskFS (JVM-only nio); VFS works |
| Node.js (cljs) | ✓ | host/node — child_process.spawnSync |
| Browser (cljs) | ✓ | host/browser — virtual fs + virtual tool registry, no spawn |
| npm / TypeScript | ✓ | dist/muschel.js (+ dist/muschel.d.ts) |
Current counts: JVM 447/1068, CLJS 417/1016, babashka 415/1008 — all green, 0 failures.
The contained sandbox is builtin-host over a fallback (jvm-host),
backed by either virtual-fs (in-memory) or disk-fs (real disk pinned
to a root). The agent only ever sees what the FS lets it resolve, and
only ever runs commands from the builtin set.
(require '[muschel.core :as m])
(let [host (m/builtin-host
{:fs (m/virtual-fs {"/work/a.txt" "alpha\nbeta\n"} {:cwd "/work"})
:fallback-host (m/jvm-host)})]
(m/run-and-capture (m/new-env) "grep beta a.txt" {:host host}))
;; → {:stdout "beta\n" :stderr "" :exit 0 :env …}With a permit (parse-time gate + runtime hook):
(let [host (m/builtin-host {:fs (m/virtual-fs {} {:cwd "/"})
:fallback-host (m/jvm-host)})]
(m/run-and-capture (m/new-env) "rm -rf /tmp/x"
{:host host
:permit {:rulesets [m/default-rules]
:prompter m/deny-all-prompter}}))
;; → :exit 126, :stderr explains the denialdisk-fs pins the FS to a real directory with symlink-aware
containment, using the wrapper layout: --root WRAPPER is a
wrapper directory holding one or more mounts. By default the
agent gets two: the project workspace at /home/agent and a
writable /tmp. Both are real subdirectories of the wrapper
(<WRAPPER>/home/agent/ and <WRAPPER>/tmp/ respectively) — both
auto-created on construction.
;; /tmp/proj is a fresh empty wrapper; /tmp/proj/home/agent/ and /tmp/proj/tmp/
;; are auto-created. Put your project files in /tmp/proj/home/agent/.
(let [host (m/builtin-host
{:fs (m/disk-fs "/tmp/proj")
:fallback-host (m/jvm-host)
:fallback-allowlist #{"git"}})]
(m/run-and-capture (m/new-env) "pwd" {:host host}))
;; → :stdout "/home/agent\n"Both layers (builtin FS protocol AND --os-sandbox bwrap externals)
see the same /tmp files — /tmp is no longer the bwrap-ephemeral
tmpfs but a real bind that persists across spawns within the
wrapper:
(m/run-and-capture (m/new-env)
"echo log > /tmp/run.log; cat /tmp/run.log"
{:host host})
;; → :stdout "log\n" (file lands at /tmp/proj/tmp/run.log on disk)The wrapper layout keeps system mounts (/usr, /etc, … exposed by
the OS sandbox) out of the agent's project view — git status,
find ., etc. rooted at /home/agent see only project files. The
ancestor view also lets cd /; ls / work as expected (returns
[home tmp] from builtins; bwrap-spawned externals see the full
FHS at /).
Customise with the :mounts option
([[sandbox-path wrapper-subdir] …]), or pass :mount-at "/" for
the legacy flat layout where <WRAPPER> itself is the workspace.
muschel.cli is a JVM-side command-line entry that mirrors bash's
invocation surface and adds the sandbox flags on top. From a checkout:
clojure -M:cli # interactive shell
clojure -M:cli -c 'echo hi | grep h' # one-shot
clojure -M:cli script.sh foo bar # run a script ($1=foo $2=bar)
clojure -M:cli -n script.sh # validate syntax only
clojure -M:cli -o errexit -c 'false; echo no' # -o sets shell options
# Sandboxed against a wrapper directory (./sbx).
# The agent's workspace lives at ./sbx/home/agent/ on disk (auto-created)
# and surfaces as /home/agent/ inside the sandbox; pwd reports /home/agent.
clojure -M:cli --sandbox --root ./sbx -c 'pwd && ls'
# Sandboxed against an in-memory VFS (optionally seeded from edn):
clojure -M:cli --sandbox --virtual ./fixtures/seed.edn -c 'cat /work/a.txt'
# Let some real-world tools through to the fallback host.
# NOTE: allowlisting an interpreter (python, node, ruby, …) gives that
# language full FS / network / exec authority — the permit layer can't
# parse Python or JS. Pair with an OS sandbox if that's a concern.
# See doc/sandbox.md "Threat model".
clojure -M:cli --sandbox --root . --allow git,clojure -c 'git status'
# Custom permit overlay on top of the default ruleset:
clojure -M:cli --sandbox --root . --permit ./tight.edn -c 'curl example.com'
# OS-level sandbox (Linux + bubblewrap): externals run in their own
# namespaces (no network by default), with cgroup limits via systemd-run.
# /tmp is the same real-disk dir for builtins AND externals — writes
# persist across spawns.
clojure -M:cli --sandbox --root ./sbx --allow git --os-sandbox bwrap \
--net off --mem-max 2G --cpu-quota 200% -c 'git status'
# Analysis subcommands:
clojure -M:cli translate -f script.sh # bash → Clojure form
clojure -M:cli check -f script.sh --permit ./tight.edn # permit dry-run
clojure -M:cli parse 'echo hi' # pretty-print ASTBash positional semantics are honoured: anything after -c CMD, after
a script-file, or after -- becomes $0/$1/... and is NOT
re-interpreted as a flag. So muschel script.sh -x runs the script
with $1="-x" — same as bash, NOT with xtrace on.
--sandbox requires exactly one of --root DIR (DiskFS pinned to
DIR) or --virtual [FILE] (empty VFS, or one seeded from an edn map
{"/path" "content", ...}). Without --sandbox the host is JvmHost
— full disk + permissions, no permit gate — same shape as bb sh.
clojure -M:cli --help has the full flag table.
npm install muschelconst m = require('muschel');
// Browser-style virtual FS, but from Node. Nothing touches real disk.
const host = m.browserHost({
files: { '/work/a.txt': 'alpha\nbeta\n' },
});
const r = m.run("cd /work && grep beta a.txt | tr a-z A-Z", { host });
console.log(r.stdout); // "BETA\n"
// Stateful session: cd / var assignment persists across calls.
const sess = m.atomSession();
m.run("cd /work; export FOO=bar", { host, session: sess });
const r2 = m.run("echo $FOO from $(pwd)", { host, session: sess });
console.log(r2.stdout); // "bar from /work\n"m.nodeHost() is real-disk on Node and is unsandboxed; wrap it in
m.builtin-host (cross-platform — see doc/sandbox.md)
or just use m.browserHost for the in-memory path.
muschel runs in babashka without
modification: every layer is .cljc, sci-safe, and uses
babashka.process for spawn. :bb reader-conditionals skip the
fs/disk.clj namespace (bb ships no java.nio.file.PosixFileAttributeView);
everything else — including nested sh -c, the FS-protected VFS, the
permit gate, tracing, budgets — runs the same as on JVM.
From a checkout (the bundled bb.edn provides paths + tasks):
bb exec "echo hi | tr a-z A-Z" # → HI
bb sh # interactive shell prompt
bb test # cross-platform test suiteFrom your own project, add muschel to your bb.edn:
{:deps {org.replikativ/muschel {:mvn/version "0.1.x"}}}(require '[muschel.core :as m])
(let [host (m/builtin-host {:fs (m/virtual-fs {"/work/data.csv"
"name,age\nalice,30\nbob,25\n"}
{:cwd "/work"})
:fallback-host (m/jvm-host)})]
(m/run-and-capture (m/new-env)
"awk -F , '{print $1}' data.csv"
{:host host}))import * as m from 'muschel';
const host = m.browserHost({
files: { '/README.md': '# hi\n', '/etc/issue': 'demo\n' },
tools: { // optional: virtual external commands
...m.stockTools(), // wc, grep, head (cat is a stub)
git: (argv) => ({ stdout: `[simulated git ${argv.join(' ')}]\n`, exit: 0 }),
},
});
m.run("cat /README.md | wc -l", { host });
// → { stdout: " 1 …", stderr: "", exit: 0 }
// Nested sh -c also works — the inner shell goes through the SAME host,
// so the sandbox holds across recursion.
m.run('sh -c "echo hello && cat /etc/issue"', { host });The playground is one static HTML file in docs/playground/;
the bundle ships in the npm package and is loaded from
unpkg at runtime, so main stays JS-free. Run
locally with npm run watch:playground and open
http://localhost:8888/index.html.
Three required layers + one optional, composed:
fs/FSprotocol — every read/write/list/stat goes through a single containment-aware handle (VirtualFS,DiskFS, or your own). A path that resolves outside any configured mount returns nil; no operation reaches real disk unless you explicitly hand the host adisk-fs. Every value crossing the protocol is sandbox-shaped (/home/agent,/tmp,/); real-disk paths are an implementation detail private to each impl.BuiltinHost— wraps a fallback host (jvm-host/node-host/browser-host) and overrides-spawnso commands are dispatched to a builtin registry first. Anything not in the registry has to be in:fallback-allowlistor it gets refused with exit 126. The agent sees the builtins, never rawbash.permit/check— runs twice. Once at parse time over the whole AST (including recursing into shell-interpreter heredoc bodies —bash <<EOF rm -rf /tmp EOFis still gated), once at everyrun-externalsite so dynamic dispatch via$cmdand lazy command substitution$(…)are also caught.SandboxedHost(optional, JVM-only, Linux) — decorates the BuiltinHost's fallback so allowlisted system tools (git,npm,python, …) run inside bubblewrap:--bind <wrapper>/{home/agent,tmp,…} → /home/agent,/tmp,…(the FS mount table is reused verbatim),--unshare-net/--unshare-pid/ FHS ro-binds at/usr,/etc. Optional cgroup caps viasystemd-run --user --scope(MemoryMax,CPUQuota,TasksMax). The agent's view and bwrap's view of every shared path (/home/agent,/tmp) line up; writes from either side are visible to the other.
On top of that, resource budgets (budget/step-interrupt,
budget/deadline-interrupt, budget/combine) interrupt runaway loops
cooperatively, and tracing (:trace opt) captures every tool call,
FS op, and permit denial in a bounded ring buffer with optional
streaming hooks.
Full walkthrough including a worked LLM-agent integration is in
doc/sandbox.md.
The permit layer runs twice:
- Parse time — walk the AST, match each
callagainst the active rulesets, classify asallow/deny/prompt. - Runtime hook — at every
run-externalinvocation, re-check the resolved command name + argv (catches dynamic dispatch via$cmdand lazy command substitution).
Default rules ship in muschel.permit/default-rules — POSIX-ish
allowlist of read-only file tools plus a denylist of mutating /
network commands. Rules are data (:tool / :pattern / :action),
not regex pairs, and the pattern uses one of several matcher kinds:
{:rulesets
[[;; Order-insensitive flag set — fires whether the agent types
;; `rm -r /tmp/x`, `rm /tmp/x -r`, `rm -rf /tmp`, or
;; `rm --recursive /tmp`. POSIX short clusters auto-split.
{:tool :bash
:pattern {:kind :argv-flags
:head ["rm"]
:any-of #{"-r" "-R" "--recursive"}}
:action :deny
:reason "recursive delete"}
;; Exact prefix: allow `git status [args…]`
{:tool :bash
:pattern {:kind :argv-vec :vec ["git" "status"]}
:action :allow}
;; Everything else for git: ask via the prompter
{:tool :bash
:pattern {:kind :cmd-name :name "git"}
:action :ask}]]
:prompter (fn [{:keys [argv]}]
;; return {:result :allow-once | :allow-always
;; | :deny-once | :deny-always}
{:result :deny-once})}Available matcher kinds: :cmd-name, :argv-vec (ordered prefix),
:argv-shape (exact length, supports :* / :** wildcards),
:argv-flags (order-insensitive flag-set match), :argv-glob
(joined-argv bash glob), and :ast-pred (predicate over the call
node) for anything the data forms can't express.
For multi-turn shells (agents, REPLs, playgrounds), use a session so
cd, export, set -o, and background jobs survive across calls:
(def sess (m/atom-session (m/new-env)))
(m/run-and-capture (m/new-env) "cd /tmp; X=42" {:host host :session sess})
(m/run-and-capture (m/new-env) "echo $X from $(pwd)" {:host host :session sess})
;; → "42 from /tmp"For forkable / persistent sessions backed by
spindel, require
muschel.session.spindel. (JVM only — its deps don't load in bb.)
muschel.emit/translate walks a parsed AST and returns a Clojure form
(fn [env opts] …) that runs the program — with bash control flow
expressed as native Clojure control flow (if, loop, reduce,
as->) and leaf work delegated to small muschel.exec /
muschel.env helpers. Useful for inspection, AOT, and embedding.
For a fully-inlineable program:
$ bb translate "X=42; for i in a b c; do echo \$i=\$X; done"(fn [env opts]
(let [opts (merge opts (muschel.exec/expand-opts opts))]
(as-> env env
(muschel.env/set-var env "X" "42")
(reduce (fn [env x]
(let [env (muschel.env/set-var env "i" (str x))]
(muschel.exec/run-argv env
["echo" (str (muschel.env/get-var env "i")
"="
(muschel.env/get-var env "X"))] opts)))
env ["a" "b" "c"]))))What inlines: if / for / while / until / && / || / ;,
literal calls, variable set+ref, and double-quoted strings of those.
What defers: pipes, redirects, command substitution, arithmetic
expansion, test brackets, case, subshells, function defs, background
jobs. Constructs the emitter doesn't yet handle throw
:muschel.emit/unsupported.
;; deps.edn
org.replikativ/muschel {:git/url "https://github.com/replikativ/muschel"
:git/sha "<sha>"}# npm
npm install muschelCross-runtime testing — the script that gates a release:
./script/test-all # JVM + CLJS-on-Node + babashka, fail fastOr one at a time:
clojure -M:test # JVM (Kaocha)
npx shadow-cljs compile ci && node out/ci-tests.js # CLJS (Node)
bb test # babashkaOther tasks:
clojure -M:format # cljfmt check (CI gate)
clojure -M:ffix # cljfmt fix in place
clojure -M:lint # clj-kondo
clojure -M:outdated # check for newer dep versions
npm run build:npm # rebuild dist/muschel.js
npm run build:playground # rebuild dist/playground/playground.js
npm run watch:playground # dev server at http://localhost:8888/index.htmlRelease tasks (run from clojure -T:build):
clojure -T:build clean
clojure -T:build jar # build target/*.jar
clojure -T:build install # install to local Maven repo
clojure -T:build deploy # push to Clojars (CLOJARS_USERNAME/PASSWORD)
clojure -T:build release # attach jar to GitHub release (GITHUB_TOKEN)
clojure -T:build npm-publish # bump version, build, publish to npmCI runs on CircleCI via the replikativ/clj-tools
orb — setup → build → format → unittest + cljstest → deploy + release
(on main). See .circleci/config.yml.
The JS ecosystem has parsers and executors, but the gap muschel fills is parse + permit + pluggable host in one library, with the same semantics across Node / browser / JVM / babashka.
| Tool | Parses bash | Executes | Permit gate | Browser-safe | TS types |
|---|---|---|---|---|---|
| bash-parser | ✓ POSIX+ | — | — | ✓ | — |
| shell-quote | ◯ split only | — | — | ✓ | ✓ |
| shelljs | — | ✓ as JS | — | — | ✓ |
| zx | — | ✓ via sh | — | — | ✓ |
| bash.wasm, busybox | ✓ real bash | ✓ | — | ✓ (~2-5MB) | — |
| muschel | ✓ POSIX+bash | ✓ | ✓ allow/deny | ✓ (~264KB) | ✓ |
- bash-parser gives you an AST; you write the executor + safety layer yourself.
- shelljs / zx start past the parser — they assume your code produces the argv, so LLM-emitted strings still need parsing first.
- bash.wasm / busybox-wasm run an actual shell binary in WebAssembly. Real semantics, big payload, no permit hook, no JS API for the host's tools.
- muschel is for when an LLM (or human) emits bash and you want to inspect what it'll do, gate it, then run it through a host you control — same code path in Node, the browser, the JVM, and bb.
doc/sandbox.md— sandbox model, FS protocol, permits, budgets, tracing, agent-integration walkthrough.doc/builtins.md— POSIX builtin reference.doc/awk.md— built-in awk subset, known gaps.doc/grammar-study.md— notes on the grammar we implement and the bash extensions we deliberately skip.doc/llm-corpus.md— what LLMs actually emit when asked for shell, and what muschel's permit shape pins down.
- mvdan/sh — Go bash parser/formatter;
we port a slice of its test corpus (see
LICENSE-mvdan-sh.txtfor attribution) - babashka — runtime philosophy
- Claude Code permissions — the prompt/allow/deny UX pattern
- superficie — npm + browser REPL packaging shape
Copyright © 2026 Christian Weilbach. Apache License 2.0.
Includes test-corpus port from
mvdan/sh under BSD-3-Clause — see
LICENSE-mvdan-sh.txt.