A shell parser and interpreter in Go.
Status: early — these are 0.x tags and they mean it. The core parser
and interpreter are in place, and all five dialect binaries grade against a
panel of the real shells they model. Measured on macOS against the golden
record make oracle writes, 2026-10-04, 4488 cases each:
| our binary | graded against | exact | behavioral |
|---|---|---|---|
bash |
bash 5.3.20 | 99.8% | 99.8% |
zsh |
zsh 5.9.2 | 99.8% | 99.8% |
dash |
dash | 99.9% | 99.96% |
ksh |
ksh93 AJM 93u+ | 99.1% | 99.5% |
ash |
BusyBox 1.37 ash | 99.5% | 99.6% |
ash is graded inside the pinned BusyBox container, with ours
cross-compiled and run beside it, so both sides share one platform (#5709).
Every case still short of exact is listed per dialect in
internal/oracle/testdata/conformance/, and CI's conformance ratchet fails a
change that adds to a list or leaves a fixed case on one. Each listed case has
a written reason in docs/spec/by-design.md — the reference shell crashes or
hangs, the panel's own builds disagree, a C-library quirk rather than the
shell, or a deliberate choice — and a test fails a listed case without one, so
the rest of the distance is decided, not unfinished.
Exact is byte-identical stdout, stderr and exit status. Behavioral lets a diagnostic be worded differently so long as the status and the standard output agree; a case that prints something else on standard output is a behavioral difference however it exits. Until 2026-10-03 the column counted the status alone and read 99–100% (#5708).
Two bash builds are in play and they do not always agree. This table is
the laptop's bash 5.3.20; the bash suite in CI is graded against
debian:sid-slim's 5.3.15, the same release and a different patch level.
Where the two disagree, the common denominator of the panel decides and the
difference is written down — ~ after a HOME assignment is the worked
example, in docs/spec/grammar/expansion.md. Behavior lands spec-first, per CLEANROOM.md.
The core language is the common denominator of real shells — not
strict POSIX, and not bash. Dialects (bash, zsh, ksh, dash, ash,
plus core and posix) are presets over a semantics vector, not
layers, translations or forks.
That distinction is not stylistic. Measuring twenty-eight behavioral axes
across dash, bash, ksh93 and zsh produces eight different groupings of
those shells — dash sides with zsh on echo, bash sides with zsh on
shift, ksh93 sides with zsh on pipelines. No "sh → bash → zsh" ladder
explains the data. Dialect is a point in a multi-dimensional space, so
the model is a vector of independent switches. See
docs/spec/semantics.md.
Execution is gateable and observable from the inside. Sandbox
policy, agent permission prompts, AI context and audit trails are
interpreter-internal seams rather than wrappers, because eval and
source re-parse inside the interpreter and a wrapper never reaches
them. See docs/design.md.
This is an independent implementation. It is not a fork or a port of any
existing project, and no file here derives from one. Behavior is learned
from POSIX, from vendor manuals, and from running real shell binaries as
oracles; it is written down in docs/spec/; code is written from the
spec. CLEANROOM.md is the binding rule set.
From the tap:
brew install blairham/tap/sh
Or from source:
make install # /usr/local/libexec/sh — needs sudo to write
make install PREFIX="$HOME/.local" # no sudo
Six shells: sh, and the dialect binaries bash, zsh, ksh, dash
and ash. They land in libexec and not in a bin directory, because
they are named after the shells they model — a directory ahead of /bin
on PATH would answer for every program on the machine that resolves a
shell by name. make install refuses a SHELLDIR that is on PATH
unless it is told to go ahead, and the formula keeps them out of
Homebrew's bin for the same reason.
So you run one by its full path, and docs/install.md has the whole of
it — including what /etc/shells and chsh need to make one of them a
login shell, and how to try one first as a terminal profile's command,
which is a checkbox to revert rather than a rescue.
The formatter shfmt is the exception. The tap does put it on your
PATH, because that rule is about names a machine resolves implicitly —
a shebang, a system(), a chsh — and nothing resolves a formatter that
way; an editor or a pre-commit hook calls it by name or not at all.
Homebrew's own shfmt (mvdan.cc/sh) wants that name too, so the formula
declares the conflict and you pick one. make install does not install
shfmt at all yet.
A session reads everything a real shell of the same name reads, in the
same order: ~/.bashrc, ~/.zshrc, the profile files, and the machine's
own file in front of each. docs/install.md has the measured grid.
The tags are 0.x because this list is real, not because the list is
short.
- Diagnostic wording is a long tail, not the headline gap. This list
used to open by saying wording was most of the distance above, and two
measurements disagree. One is the
kshre-count beside the table. The other is a bounded sample, first run 2026-09-16: twenty error situations a person actually hits — a missing command,cdonto a file,set -uon an unset name, a redirect that cannot open, division by zero, an unknown option to six different builtins, running a directory,exit foo— each run once through the real shell and through ours under a matchingargv[0], compared on stderr and exit status together. Re-measured 2026-09-27: byte-identical 20/20 on every one of the five, 100 of 100,ashincluded — its reference is a container, and the 16/20 it read on 2026-09-16 is gone along with thekshandzshmisses of that day. Twenty common situations is a sample and not a census, and the corpus behind the percentages above is two hundred times larger and much stranger. The probe isinternal/cmd/diagsample, a few seconds a column, so this bullet can be re-checked rather than inherited — which is how it came to be wrong: its only previous instrument was an hour-long conformance run, so nobody ever ran it to find out. - The sandbox contains the shell, not the process tree. A policy
decides what the shell opens, runs and signals; a command it was
allowed to start makes its own accesses and nothing here sees them.
docs/design/sandboxing.mdhas the scope limit in full. - POSIX only. No Windows target — every route into this program is a
process group, a controlling terminal, a signal or a
syscall.Exec, and a binary that cannot start is worse than no binary.
The core is a library, and it does not know which shells exist: syntax
and interp define the questions, and each shell answers them in its own
package under dialect/.
sem, dial, diag := bash.Semantics(), bash.Dialect(), bash.Diagnostics()
r := &interp.Runner{Semantics: &sem, Dialect: &dial, Diagnostics: &diag}
bash.Apply(r) // what this shell adds or removes
r.Register("cd", cd) // only what shell cannot express
prelude, _ := syntax.Parse(`basename() { printf '%s\n' "${1##*/}"; }`, dial)
r.Run(ctx, prelude) // everything elseAdding a shell adds a directory. Nothing under syntax/ or interp/
names one.
Functions shadow builtins and external commands alike, so most of a dialect needs no Go at all.
CLEANROOM.md the rules that keep this independent
docs/install.md installing, and running it as a login shell
docs/design.md architecture
docs/lessons-carried-forward.md
starting constraints, learned the hard way
docs/spec/ behavioral specs — the wall
oracle.md how behavior is learned from real binaries
shell-matrix.md measured: which constructs exist where
semantics.md measured: where shells conflict
core.md the core language boundary
style.md measured: how source is laid back out
grammar/ per-construct specs
cmd/shfmt the formatter — every dialect the parser reads
Apache-2.0. See LICENSE and NOTICE. Report vulnerabilities privately —
see SECURITY.md. Participation is under the CODE_OF_CONDUCT.md.
Contributions require a signed CLA — see CONTRIBUTING.md and CLA.md.
The CLA exists so the project retains the option to offer different
terms later; you keep your copyright.