Skip to content

lash-box

lash-box runs a command inside the restrictions a security profile describes — filesystem scope, network policy, resource limits, environment rules — and then gets out of the way.

It works from any shell. No daemon, no ~/.lash, no lash process:

Terminal window
lash-box --profile ci -- npm i

You do not have to adopt lash to use it. If you want a fenced-in npm i in a bash script or a CI job, that is the whole setup.

lash-box applies the profile to itself and then execs your command. It never forks. The process id stays the one your shell spawned, so job control, signals, exit status and pipes all behave exactly as if lash-box were not there:

Terminal window
lash-box --profile ci -- sleep 30 &
kill %1 # works: %1 is the sleep, not a wrapper

A -- anywhere means the rest is the command to run. Without one, the first word is a subcommand:

Terminal window
lash-box profile list # prints the profile table
lash-box --profile dev -- profile list # runs the `profile` binary, sandboxed
OptionMeaning
--profile NAMEProfile to apply. Defaults to $LASH_PROFILE, then your configured default, then the persisted one, then default.
--force-env KEY=VALUEForce an environment variable. Repeatable.
--allow-rw PATHAdd a read-write path. Repeatable.
--tun-fd NUse an inherited TUN device for network interception.
--explainPrint the resolved container before running.
--warn-onlyRun even when the kernel cannot enforce what the profile asks for.
-h, --helpShow usage.
--versionShow the version.

--explain prints the container before the command runs, which is the fastest way to answer “why did my build fail in here”:

Terminal window
$ lash-box --explain --profile ci -- true
profile: ci
read-only: /usr, /lib, /etc
read-write: /tmp
allow hosts: registry.npmjs.org, github.com
allow ports: 443
network: blocked except the rules above
strip env: AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, LD_PRELOAD
max cpu: 60s

If a profile asks for something the running kernel cannot provide — Landlock on an old kernel, a memory cap on macOS — lash-box refuses rather than running your command unconfined:

$ lash-box --profile ci -- npm i
lash-box: profile requires enforcement this system cannot provide:
filesystem sandboxing: Landlock unavailable (kernel too old or not enabled)
Refusing to run unconfined. Re-run with --warn-only to override.

This is deliberately stricter than the interactive shell, which warns once and carries on. At a prompt someone is watching and can decide; in a CI job nobody is, and silently dropping the sandbox is the wrong default.

Pass --warn-only when you want it to proceed anyway.

CodeMeaning
2The command line was malformed.
126The profile or the container could not be established.
127The command was not found.
anything elseYour command’s own exit status.

126 and 127 are kept distinct on purpose: a sandbox that failed to build should never look like a typo in a command name.

An unknown profile name is a 126, never a silent fall back to running unconfined:

$ lash-box --profile prod-ci -- ./deploy.sh
lash-box: unknown profile 'prod-ci'. Run 'lash-box profile list' to see what is available.

The built-in profiles (default, developer, script, ci, restricted) resolve even with no ~/.lash directory, so a container image that has never run lash can still do:

Terminal window
lash-box --profile ci -- make

Profiles you write in ~/.lash/profiles/ take precedence over the built-ins of the same name.

A profile with allow_hosts needs a proxy running for the whole session to filter by hostname. That proxy is lash-box too:

Terminal window
lash-box proxy --profile ci

It prints the port it bound on stdout, then streams audit records for every allowed and denied connection. It exits when its stdin closes, so the daemon that started it cannot leave one behind.

You rarely run this yourself — the daemon starts one per session — but it is the same binary and the same profile.

lash-box takes the profile name from its command line and runs as you. Anyone who can run it can run lash-box --profile default -- anything. The restrictions are ones it applies to itself before exec — nothing here is setuid.

It is a tool for containing what you chose to run, not for containing someone else.