Per-project shell sandboxes for testing scripts against specific shells and option profiles without polluting your real shell setup.
- Treat shells like runtimes: declare the shell version and profile a project expects, and run commands inside that sandbox.
- Keep experiments contained: environments live under
./.shellenv/<name>andSHELLENV_HOME(default~/.shellenv), avoiding edits to your login shell. - Make cross-shell QA easy: pin and swap
bash/zshruntimes and POSIX-style option profiles to catch portability issues early.fishsessions are supported for activation and profiles (--shell-type fish,.fishprofile variants), but shellenv does not install fish itself.
Scope: shellenv is a user-space sandbox — it pins
PATHand the shell runtime, and redirectsHOME/TMPDIR/XDG_*writes to a per-env sandbox — for not polluting your own shell and home while you test scripts. It is not a security sandbox for untrusted code: network, processes, and the wider filesystem stay shared unless you run withexec --container <image>, which adds real namespace isolation. Seedocs/ARCHITECTURE.mdfor exactly what is and isn't isolated.
This project is provided as-is with no warranties; use at your own risk. See LICENSE for details.
Download the tarball for your platform from the releases page, verify it against SHA256SUMS, extract it, and put the extracted directory on your PATH:
tar -xzf shellenv-<version>-linux-amd64.tar.gz
export PATH="$PWD/shellenv-<version>-linux-amd64:$PATH"
shellenv --versionKeep the binary next to its bundled profiles/ directory — built-in profiles resolve relative to the executable. If you symlink the bare binary elsewhere instead, also set SHELLENV_PROFILES to the extracted profiles/ directory or profile resolution silently falls back to ./profiles only.
Prerequisites: Go 1.22+ and make on your PATH (plus bats only if you run the integration tests).
make build # produces ./dist/shellenvThe examples below call the binary as shellenv; from a fresh checkout use ./dist/shellenv (or put dist/ on your PATH).
- Global home (
SHELLENV_HOME, default~/.shellenv): created byshellenv initwithinstalls/,cache/, andtmp/. Point it at a throwaway directory while experimenting to keep your real home pristine. - Project envs (
./.shellenv/<env>/): created byshellenv create. Each holdsmetadata.json(declared shell + profile), abin/directory for project-local tools, and a sandboxhome/directory used byexecandactivate --isolate-home(below). - Profiles: option presets sourced into the shell — built-ins
strict(set -euo pipefail),posix(set -o posix), andinteractive. Resolved viaSHELLENV_PROFILES→./profiles/<name>.sh→ next to the binary. Fish sessions source a<name>.fishvariant instead (fish can't source POSIX scripts, and has noset -eequivalent — the built-in fish variants only carry comments/exports).
make build
./dist/shellenv init # set up the global home
# Inside a project directory:
./dist/shellenv create --shell bash@5.2 --profile strict
./dist/shellenv install bash@5.2 # optional: build the pinned bash from source (minutes, once)
./dist/shellenv exec -- printenv SHELLENV_ENV_NAME # one-off, isolated -> default
eval "$(./dist/shellenv activate)" # or activate your shell sessionIf you skip the install step, commands still run against your system shell — you'll just see a one-line warning on stderr that the declared version isn't installed (see Pinning).
Both put the env's bin/ first on PATH. They differ in how far the isolation goes:
eval "$(shellenv activate)" |
shellenv exec -- <cmd> |
shellenv exec --container <image> -- <cmd> |
|
|---|---|---|---|
Prepends env bin/ to PATH |
yes | yes | yes (inside container) |
Sets SHELLENV_* variables |
yes | yes | yes (inside container) |
Sandboxes HOME / TMPDIR |
opt-in (--isolate-home) |
yes (./.shellenv/<env>/home/) |
yes (mounted on container) |
| Network / Process isolation | no | no | yes (container namespaces) |
| System filesystem isolation | no | no | yes (container namespaces) |
| Changes current shell session | yes (until you reset it) | no (subprocess only) | no (container execution) |
| Propagates command exit code | n/a | yes | yes |
- Use
activatefor an interactive session where you want the env's tools and profile in your prompt. - Use
exec(Host Mode) for one-off or scripted runs you want local-user contained — including writes to$HOMEand temp files. Add--ephemeralfor a throwaway sandbox home deleted after the run. - Use
exec --container <image>(Container Mode) for system-mutating scripts (like installation scripts) that need full filesystem, process, and network namespace isolation.
For all, the env is chosen as: the name you pass → ./.shellenv/current (set by shellenv use) → default.
Fish users: the activation syntax differs — pipe instead of eval: shellenv activate --shell-type fish | source. Fish sessions source the profile's .fish variant (see Concepts).
By default an activated session keeps your real ~ (only exec sandboxes writes). If you want the interactive session sandboxed too, opt in:
eval "$(shellenv activate --isolate-home)" # HOME/TMPDIR/XDG_* now point at ./.shellenv/<env>/home/
...
eval "$(shellenv deactivate)" # restores PATH, prompt, HOME/TMPDIR/XDG_*, unsets SHELLENV_*deactivate works after any activation (with or without --isolate-home) and is a silent no-op when nothing is active. Restoration is snapshot-based: activation saves the original values once, so even after activating twice you return to the pre-first-activation state; PATH edits you made during the session are not preserved.
Why opt-in: redirecting HOME mid-session affects everything in that shell — prompt frameworks re-reading ~/.config, ssh/git suddenly not finding ~/.ssh/~/.gitconfig, agents keyed on your real home. Use it for focused testing sessions, not your daily driver shell. Also note shell options set by a sourced profile (set -e, set -o posix) can't be restored by deactivate — only environment and prompt are.
This runs a script through exec and shows that its $HOME writes land in the sandbox (your real home is untouched) and that its exit code is propagated.
# Keep the global home off your real ~ while experimenting.
export SHELLENV_HOME="$(mktemp -d)" # or: mkdir /tmp/se-home && export SHELLENV_HOME=/tmp/se-home
# From inside your project directory:
shellenv create --shell bash@5.2 --profile strict # makes ./.shellenv/default
# (exec below will warn that bash@5.2 isn't installed and fall back to the
# system shell — harmless here; run `shellenv install bash@5.2` to pin it.)
# A script that writes to $HOME and fails:
cat > probe.sh <<'EOF'
#!/bin/sh
: > "$HOME/wrote-here" # creates a file in $HOME
echo "HOME is $HOME"
exit 5
EOF
chmod +x probe.sh
shellenv exec -- ./probe.sh
echo "shellenv exit: $?" # -> 5 (the child's real status)
ls .shellenv/default/home/wrote-here # the write landed in the sandbox
test -e "$HOME/wrote-here" && echo "leaked!" || echo "real HOME untouched"exec resolves commands against the env's bin/ first, so dropping a tool in ./.shellenv/default/bin/ lets it shadow the system copy for the duration of the run.
By default the sandbox home (./.shellenv/<env>/home/) persists between runs, which is handy for inspecting what a script wrote. Add --ephemeral to use a fresh throwaway home instead, deleted as soon as the command exits (whether it succeeds or fails):
shellenv exec --ephemeral -- ./probe.sh # $HOME/TMPDIR/XDG writes vanish after the runIt composes with --container too — the throwaway home lives under the env dir, so it stays inside the container's workspace mount.
--profile sources the env's declared profile in the declared shell before running your command, so the profile's exported settings and shell options are in effect:
# strict mode (set -e) aborts a failing direct command:
shellenv exec --profile -- false && echo reached || echo "aborted ($?)"
# -> aborted (1)Caveat: the profile's shell options (set -e, set -o pipefail, …) apply to commands the profiled shell runs directly. A command that re-invokes an interpreter — a script with its own shebang, or bash -c '…' — starts a fresh shell and inherits only the profile's exported environment, not its options. (Well-written scripts set their own set -euo pipefail.) Profiles resolve from SHELLENV_PROFILES, then ./profiles/<name>.sh, then beside the binary — so running ./dist/shellenv finds the built-ins shipped in this repo; an installed binary needs ./profiles or SHELLENV_PROFILES.
create --shell bash@5.2 records the shell your project expects, and activate/exec now act on it: if $SHELLENV_HOME/installs/bash/5.2/bin exists (created by shellenv install bash@5.2), it is added to PATH right after the env's bin/, so the pinned interpreter wins over the system one while project-local tools still win over both.
If the declared version is not installed, the command still runs against the system shell but prints a warning to stderr. Pass --strict-shell (on activate or exec) to fail instead:
shellenv exec --strict-shell -- ./run-tests.sh
# error: declared shell "bash@5.2" is not installed (run 'shellenv install bash@5.2', or omit --strict-shell)shellenv install builds the runtime from the official source tarball: it downloads into $SHELLENV_HOME/cache/, verifies the SHA-256 for pinned versions (bash@5.2, zsh@5.9 — other versions install with an unverified-download warning; pass --require-checksum to fail instead of warning, before anything is downloaded), and runs configure/make/make install (a few minutes, once per version; build output lands in a build.log if anything fails). This needs cc/gcc, make, and tar — run shellenv doctor to check. Supported today: bash and zsh.
Caveat: resolution is host-only — exec --container skips it (pick an image that provides the shell) and rejects --strict-shell.
shellenv exec exits with the command's exact status (e.g. exit 5 above), so it composes cleanly with set -e and CI pipelines. Runtime failures print a single clean line rather than a usage dump:
shellenv exec --profile -- ./run-tests.sh || exit $? # fail the build on a non-zero test runshellenv init: create the global home.shellenv create [--name default] --shell <shell>@<ver> [--profile strict|posix|interactive]: scaffold a project env.shellenv use <env>/shellenv list [--all]/shellenv destroy <env>: set the current env, list envs (--allalso shows envs registered from other directories asNAME SHELL ROOT), or remove one.shellenv activate [<env>] [--shell-type bash|zsh|fish] [--strict-shell] [--isolate-home]: print an activation snippet toeval;--isolate-homealso redirectsHOME/TMPDIR/XDG_*to the env sandbox.shellenv deactivate [--shell-type bash|zsh|fish]: print a snippet restoring the session (PATH, prompt, isolated vars,SHELLENV_*).shellenv exec [<env>] [--profile] [--strict-shell] [--ephemeral] [--container <image>] -- <cmd> [args]: run a command in the env without activating your shell (sandboxesHOME/TMPDIR/XDG_*, propagates the exit code).--ephemeralswaps the persistent sandbox home for a throwaway one deleted after the run. If--containeris provided, executes inside the specified Docker or Podman image with mounted workspace.shellenv which <binary>: resolve a tool, preferring the active env'sbin/.shellenv install <shell>@<ver> [--require-checksum]/shellenv uninstall …/shellenv versions: build a runtime from the official source tarball (bash and zsh; needscc/make/tar;--require-checksumrefuses versions without a pinned SHA-256), remove one, or list what's installed.uninstallwarns about envs that still declare the removed version — in the current directory and, via the advisory registry ($SHELLENV_HOME/registry.json, maintained bycreate/destroy), in other registered projects.shellenv doctor: quick health check of the global home and the build toolchaininstallneeds.
SHELLENV_HOME: global state root (default~/.shellenv).SHELLENV_PROFILES: directory checked first when resolving a profile.SHELLENV_ACTIVE: set to1inside an activated session or anexecchild.SHELLENV_ENV_NAME: the active env's name (handy for prompts and debugging).
- Contributor workflow and standards:
CONTRIBUTING.md. - Architecture, isolation model, and flows:
docs/ARCHITECTURE.md. - Design decisions, rationale, and roadmap:
docs/DESIGN.md. - Task notes and change log:
docs/Task.md.
- Unit tests:
make test. Lint and vulnerability scan:make lint/make vulncheck(need a recent Go toolchain; versions pinned in the Makefile). - Integration tests (require
bats):SHELLENV_HOME=$(mktemp -d) bats -r test/integration. The real-source-build test is skipped unlessSHELLENV_TEST_REAL_INSTALL=1is set (network + several minutes). The container test skips when no docker/podman is found — setSHELLENV_TEST_REQUIRE_CONTAINER=1(CI does, on Linux) to make that a failure instead. - If your environment restricts the default Go cache, use a repo-local one:
GOCACHE=$PWD/.cache/go-build go test ./.... - Keep experiments isolated by pointing
SHELLENV_HOMEat a temp directory when hacking on the tool.