Skip to content

Repository files navigation

zish

A fast, familiar shell written in Zig — built to be handed to an agent.

release AUR Nix license

Install

curl -fsSL https://raw.githubusercontent.com/rotkonetworks/zish/main/install.sh | sh

Detects your platform, uses your package manager if zish is packaged for it, otherwise installs a release binary — and refuses to install one whose checksum it can't verify.

Prefer not to pipe curl into sh? (you're right)

Piping a script from the network into a shell runs code you never saw. It's the convenient option, not the safe one, and this project spends a lot of effort on not executing things you didn't ask for — so here's the honest version:

curl -fsSLO https://raw.githubusercontent.com/rotkonetworks/zish/main/install.sh
less install.sh          # ~170 lines, readable in a minute
sh install.sh

Or skip the script entirely:

paru -S zish                                       # Arch (AUR)
nix profile install github:rotkonetworks/zish      # Nix / NixOS
zig build --release=safe                           # from source

Your muscle memory and your POSIX/bash scripts keep working. It's a single binary with no interpreter startup, so it starts and runs quicker — roughly 1.2–1.8x faster than bash:

benchmark vs bash
command substitution 1.8x ± 0.3
conditionals 1.5x ± 0.3
case 1.4x ± 0.3
arithmetic 1.4x ± 0.3
nested loops 1.4x ± 0.3
for + function call 1.4x ± 0.3
variables 1.4x ± 0.3
functions 1.4x ± 0.3
pipelines 1.2x ± 0.1

Measured on the --release=safe binary, which is what ships. Unchecked (--release=fast) is roughly 1.3–2.0x instead — the difference buys bounds, overflow and alignment checks, which is a trade worth making in a shell an agent drives.

Reproduce with ./bench.sh (hyperfine; all shells run --norc/--no-rcs from /bin/sh). The error bars are wide relative to the gaps, and numbers move 5–10% between runs on the same machine, so treat these as "consistently faster, not dramatically faster" rather than precise figures. Pipelines are the weakest case, because the cost there is fork/exec and the kernel, not the shell.

bench.sh validates every result against bash before timing, so a wrong-but-fast answer fails instead of scoring well. That check is what caught a real arithmetic bug in 0.16.0, which is the main reason it exists.

Linux only.

Try it

zish                 # interactive
zish -c 'echo hi'    # one-shot
zish --version       # prints the build mode too: zish 0.16.1 (ReleaseSafe)
man zish             # full documentation

To make it yours:

cp example.zishrc ~/.zishrc

What works

Everything you'd expect from a POSIX shell: pipes, redirects, &&/||, $(cmd), $((math)), ${VAR:-default}, [[ ]], functions, job control, globbing, heredocs.

Interactively you also get hybrid vim/emacs editing (vim text objects with emacs keys still bound), syntax highlighting, a git-aware prompt, tab completion that reads --help and man pages, and persistent history.

Vim mode is always on — press Esc. man zish has the full keymap.

Compatibility

zish targets the shell people actually type, not all of zsh. Concretely, from a differential run against real zsh and bash:

Works${#v}, ${v/a/b}, ${v//a/b}, ${v#pat}/${v%pat}, ${v:-default}, [[ $v = pre* ]], [[ $v = *sub* ]], arrays with a+=(x) and ${#a[@]}, (( )) with unprefixed variables, [[ -o opt ]], {1..3}, functions, local, $@/$#/shift/return, job control, globbing, heredocs, here-strings, process substitution.

Not implementedtypeset, and therefore associative arrays; zsh parameter-expansion flags ${(k)}, ${(v)}, ${(P)}, ${(kv)}; zsh string indexing ${v[2]} and ${v[2,4]}; ${a[(Ie)val]}; glob qualifiers like *(N); zle widgets and compsys.

One difference to know about: zish arrays are 0-based, like bash. zsh's are 1-based.

a=(x y z)
${a[0]}   # zish/bash: x     zsh: (empty)
${a[1]}   # zish/bash: y     zsh: x

${#a[@]} agrees everywhere, so this is silent — a zsh script that indexes arrays will compute the wrong values without an error. If you are porting from zsh, that is the first thing to check.

Feats

Feats are small standalone binaries that answer one question each, so you don't reach for Python to do arithmetic or count something.

Install them once, then just use them like any other command:

make feats          # builds and stages into ~/.zish/feats/standard
$ calc '2^0.5'                 # bash can't: $(( )) is integer-only
1.4142135623730951
$ calc 3/2
1.5
$ echo 1+1 | calc
2
$ cnt file.txt                 # a single number
128
$ frq access.log               # field frequency table
$ pk -t 5 build.log            # last 5 lines
$ snf src/                     # size, lines, ext, magic per file
$ jls events.jsonl             # select/tally JSONL fields
$ ls *.log | para grep ERROR {}   # run N at a time, grouped output

They resolve as ordinary commands, but only as a fallback — a feat can never shadow a real binary, so installing one can't change what an existing script means. feat list shows what you have, feat help <name> explains one, and feat run <name> is the explicit form if you want it.

A feat is just a binary zish execs: no plugin ABI, no dynamic loading, no in-process hooks. See docs/feat-spec.md for the contract.

Dashboard (zash)

The team and agent feats run agent-orgs (a Captain decomposing work across parallel workers) and write a live JSONL trace per run. zash is a small SolidJS + Bun dashboard that folds that trace in real time — Captain, parallel workers, consults, critic, synthesis — lets you talk to the Captain in-chat, and shows which model actually ran each step. It lives in its own repo.

Driving zish from a program

A shell an agent drives has two jobs a shell you drive doesn't: report what happened in a form a program can read, and be containable when the agent is wrong.

Structured output on fd 3

Open file descriptor 3 and zish writes one JSON record per command, so a harness never has to parse ANSI escapes or prompt redraws to find out what happened:

$ zish -c 'make test' 3>trace.jsonl
$ cat trace.jsonl
{"ts":1786246738163,"cmd":"make test","cwd":"/src","exit":0,"ms":842,"sandbox":"none"}

It's off unless fd 3 is open — no flag, no config. stdout stays exactly as the command left it, and internals (rc sourcing, command substitution) are not recorded, only what you actually submitted. Use ZISH_TRACE_FD for a different descriptor.

The channel is meant to be trusted, so it is built not to be forgeable: the descriptor is moved out of reach of the commands zish runs (they can't write their own records), string fields are escaped so a crafted command or directory name can't inject a second record, and each line carries the sandbox profile in force so a harness can confirm the containment it asked for was applied.

Restricting what a session may touch

zish --profile readonly -c 'make test'    # may read; writes are denied
zish --profile workdir  -c 'make build'   # may write under $PWD, read elsewhere
zish --profile none                       # the default

--allow-write adds writable roots, :-separated like PATH — which is what makes it possible to wrap an agent, since the agent needs its own state directory:

zish --profile workdir --allow-write "$HOME/.claude:/tmp" -c 'claude'

The shell is not enforcing this. zish makes one syscall at startup (Landlock, the kernel's unprivileged sandbox — no root, no container, no LD_PRELOAD) and then gets out of the way. After that the kernel refuses the write. There is no parser to trick, no quoting to get right, no allowlist to slip past.

Which is why it holds for programs that never involve a shell at all:

$ zish --profile readonly -c 'bash -c "echo x > /tmp/A"'
/usr/bin/bash: line 1: /tmp/A: Permission denied      # bash's own redirect

$ zish --profile readonly -c 'python3 -c "open(\"/tmp/B\",\"w\")"'
PermissionError: [Errno 13] Permission denied: '/tmp/B'

The restriction lives in the process's credentials: fork copies it, exec preserves it, and nothing can clear it. A child may add another Landlock ruleset, but rulesets only intersect — it is a one-way ratchet.

$ zish --profile readonly -c 'grep NoNewPrivs /proc/self/status'
NoNewPrivs:  1                              # 0 outside the sandbox

tried to drop no_new_privs: -1              # the kernel refuses

no_new_privs also makes the kernel ignore setuid bits and file capabilities on exec, so escaping by running something setuid does not work either.

It is session-scoped rather than per-command, deliberately: because the kernel enforces it against the whole tree, it also bounds zish itself. Zig's safety checks (on in the shipped build) turn the bugs they cover into a clean abort, but no in-process check covers everything — zish should not be the only thing between an agent and your filesystem.

It fails closed: an unknown profile, or a kernel without Landlock, exits non-zero rather than quietly running unrestricted. Write-only device sinks (/dev/null, /dev/tty, ...) stay writable under every profile, because cat x >/dev/null is a write and a sandbox that breaks it is just a broken shell.

Every restrictive profile also installs a seccomp syscall filter — the "pledge" half to Landlock's "unveil". It rides along with no flag of its own and denies ptrace and process_vm_readv/writev (attaching to or reading another process's memory) and kexec — syscalls a shell's children have no legitimate need for. Denied calls return EPERM, so a program that tries one fails gracefully rather than being killed. This is deliberately a small list; the syscalls with real legitimate uses (socket, unshare, mount, memfd) are left for named profiles rather than defaulted on.

What it does not stop

Worth keeping straight, because the above sounds stronger than it is:

  • Reads are unrestricted. Every profile can read the whole filesystem — SSH keys, .env files, tokens. This bounds damage, not disclosure.
  • Network is unrestricted. Combined with the above: a sandboxed process can read a secret and POST it somewhere. Landlock can restrict TCP connect/bind; zish does not use that yet.
  • Process creation and signals are unrestricted.
  • Anything writable is code you will run later. This is the one that gets people. A granted root usually contains .git/hooks, a Makefile, package.json scripts, .envrc — all of which execute, unsandboxed, the next time you run git or make. Nothing has to break Landlock for that to happen.

A blast radius, not a jail — which is why it belongs underneath an agent's own permission prompts rather than replacing them.

You also do not have to change the agent to get it. Claude Code drives bash with no option to swap it, and Python's shell=True is hardcoded to /bin/sh, but neither matters when the restriction is inherited by every descendant. Recipes are in docs/agents.md.

Ghost text

As you type, zish suggests the rest of the command from your history and from completion candidates — shown ahead of the cursor in a dimmer colour, so a suggestion never reads as something you typed. ctrl+o toggles it, alt+e accepts one character, Right/End accept the whole thing.

There is no model involved. zish used to ship a GGUF inference engine for this; it was removed in favour of history matching, which is where the useful suggestions came from anyway.

Tests

./tests/regress.sh     # end-to-end, incl. differential tests against bash
python3 tests/pty_test.py  # interactive: line editor, job control, signals
zig build test         # unit tests + randomized sweeps
zig build fuzz         # fuzz targets on parser/lexer/arithmetic/glob

tests/regress.sh is the one to run before sending a patch. Every case in it is a bug that was once real, so a red case means a regression.

See docs/security.md for the threat model and what's already been found and fixed.

Contributing

Patches welcome. Please make sure ./tests/regress.sh and zig build test are green, and add a case for whatever you fixed.

License

See LICENSE.

About

opinionated shell in zig

Topics

Resources

Security policy

Stars

8 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages